目录
信号入门
生活角度的信号
信号产生的各种方式
信号处理常见方式概览
产生信号
一、信号产生前
core dump
系统调用产生信号
软件条件产生信号
【alarm】
扩展
信号入门
生活角度的信号
生活中有很多信号的场景,比如红绿灯,闹钟,信号枪,鸡叫声等等...,这些信号都是给人看的,如果这些信号脱离了人类,红绿灯给牛看,信号枪给鸟打,鸡给鸡叫,这些都是没有意义的。当这些场景触发的时候,我们人类立马就知道要做什么。那么是不是这些场景真正放在我们面前,我们才知道做什么呢?其实和场景触发没有直接关联。对于信号的处理动作,我们早就知道了,甚至远远早于信号产生。那么我们是怎么做到没有信号就知道该怎么做呢?我们对特定事件的反应,是被教育的结果,本质是我们记住了。
结论1:所以信号的产生是给进程看的,进程要在合适的时候,执行对应的动作,而进程在没有收到信号的时候,进程是知道对应进程应该怎么处理!那么进程是如何做到处理对应的信号呢?其实就是曾经编写操作系统的工程师在写进程代码的时候,就已经设置好了。
在生活中,我们受到某种”信号“的时候,并不一定立即处理,下课铃响了我们就能下课吗,可能老师没有讲完这个知识点;鸡叫了我们就一定要起床吗,我们仍旧想睡觉等等这种例子。所以信号随时都可能产生,但是进程当前可能做更重要的事情,这就造成了异步。
结论2:所以进程收到信号的时候,并不是立即处理的,而是在合适的时候去处理它。信号来的时候,进程说等一等,我先把手头的进程处理完,再去处理信号。
结论3:信号既然不能被处理,那么已经到来的信号,就被保存下来了。举个例子,当外卖员给你打电话,这时候是外卖到了的信号,但是可能因为我们正在写一个代码,刚有思路不能被打断,所以我们让信号等待,不去处理它,但是我们已经将信号保存下拉,记在心里了。那么进程的信号被保存放在哪里呢?信号放在了struct tash_struct进程控制块PCB中。PCB中包含了进程的所有属性信息,包括该进程是否收到信号以及到来的信号。信号的本质也是数据,信号发送给进程,就是往task_struct中写入数据。
结论4:task_struct是一个内核数据结构,用来定义进程对象的数据结构,但是内核不相信任何人,只相信子集,所以用户是不可能修改内核的task_struct的。所以向task_struct中写入信号的是OS操作系统!所以信号的任何发送,本质都是再底层通过OS发送的。
以下是信号要了解的三个步骤,我们逐步讲解
信号产生的各种方式
我们用kill -l查看系统支持的信号列表,发现它有64个,但是其中并没有31,32这两个信号,所以一共有62个信号。前31个信号是普通信号,后31个是实时信号。
当我们写一个死循环并执行的时候,我们要停止他可以用ctrl+c。当我们向进程写入ctrl+c的时候,实际上就是向目标进程发送2号信号。
在1-31信号中,除了有大写的名字之外,前面还带了个编号。在系统当中,信号编号就是整数,每一个信号都有它的宏值,可以理解成所有的信号就是被#define定义出来的,在使用信号的时候,既可以使用信号名,又可以使用数字。
那么怎么证明ctrl+c发送的是2号信号呢?
我们可以用一个函数signal证明,signal是一个函数,它的作用,可以修改指定的一个信号,它的类型是一个函数指针。
第一个参数是一个整数,就是用来传递信号的,第二个参数是用来修改信号的默认处理动作。我们可以将系统默认的信号操作,改成我们自定义对信号的操作。所以第二个参数传的是一个函数指针,只要传入一个函数的地址,那么我们就可以得到这个函数对信号的修改操作。我们知道如果只传函数名,不加后面的参数括号,那么就是传递函数的地址。
函数指针的返回值是void,参数是int。在下面的handler函数中,它是一个信号修改函数,因为函数指针的返回值是void,参数是int,所以handler的返回值是void,参数是一个int,用来接收signal传递的信号整数。
【测试】:
[wjy@VM-24-9-centos 7_signal]$ cat test.c #include#include #include void handler(int signo) { printf("get a signal:signal no:%d,pid:%dn",signo,getpid()); //打印信号树,并打印信号是给哪个进程发的 exit(1); } int main() { //通过signal注册对2号信号的处理动作,改成我们自定义的动作 //当我们收到2号信号时,2这个数字就会传递给handler函数。 signal(2,handler);//第二个参数是一个函数指针 while(1) { printf("hello world,pid:%dn",getpid());//打印出进程号 sleep(1); } return 0; } //运行结果 [wjy@VM-24-9-centos 7_signal]$ ./mytest hello world,pid:25208 hello world,pid:25208 hello world,pid:25208 ^Cget a signal:signal no:2,pid:25208
通过运行结果可以发现,当执行死循环的时候,进程pid是21608。当我们发出ctrl+c发出2号信号,进程开始执行signal函数,调用handler函数,传入2号信号,并进行相应的修改,当每次ctrl+c时都会向signal传入2号信号,每一次ctrl+c都会调用handler函数。此时发现,ctrl+c确实是2号信号,而且对应的进程也是21608。这就证明了上面的问题,ctrl+c所对应的信号是2号信号。(ps:要退出这个进程ctrl+)
那么signal本身是一个注册函数,当注册函数的时候,handler方法还没有被调用,只有当信号到来的时候,handler函数才会被调用。
信号处理常见方式概览
我们现在将上面代码改一下,当输入某个信号,就打印出信号对应的数字(在普通信号范围内)。
#include#include #include #include void handler(int signo) { printf("get a signal:%dn",signo); exit(1); } int main() { int sig=1; for(;sig<=31;sig++) { signal(sig,handler); } while(1) { printf("hello world,pid:%dn",getpid());//打印出进程号 sleep(1); } return 0; }
运行结果:
[wjy@VM-24-9-centos 7_signal]$ ./mytest hello world,pid:31108 hello world,pid:31108 hello world,pid:31108 hello world,pid:31108 chello world,pid:31108 hello world,pid:31108 ^Cget a signal:2 [wjy@VM-24-9-centos 7_signal]$ ./mytest hello world,pid:31126 hello world,pid:31126 ^Zget a signal:20 [wjy@VM-24-9-centos 7_signal]$ ./mytest hello world,pid:31139 hello world,pid:31139 ^get a signal:3
当输入ctrl+c,它的信号是2;当输入ctrl+z,它的信号是20,当输入ctrl+,它的信号是3。例举了这么多的信号,我们kill-l依次找一下信号对应的宏信息。20是一个暂停信号。
当我们在运行进程的后面加一个&,这个是将进程放在后台运行,此时ctrl+c是没有效果的。因为键盘产生的信号只能用来终止前台进程。
[wjy@VM-24-9-centos 7_signal]$ ./mytest &
那么怎么终止进程呢?在上图我们看到9号信息是杀掉进程信息。我们只需要输入kill -9 进程号,技能杀掉这个后台进程。如果不知道进程号,那么使用命令ps axj | grep mytest(mytest是我们编译时给程序另外起的名字),这样就能看到对应的进程的进程号,然后用kill -9 进程号命令来杀掉这个进程。
所以前台进程用ctrl+c,后台进程用kill -9命令就可以杀掉进程。
总结:一般而言,进程收到信号的处理方案:
1.默认动作(上层发来的信号默认动作):信号的产生方式,其中一种就是通过键盘产生的。键盘产生的信号只能用来终止前台进程。那么一部分默认动作就是终止自己或暂停等。
2.忽略动作:第二种收到信号的处理方式是忽略,忽略和默认的差别:默认是当信号来临,处理方式必须要做一个的,但是忽略它对信号的处理方式是,信号来临的时候,可以对信号进行处理,也可以对信号不进行处理。忽略也是一种信号的处理方式,只不过它的动作就是什么也不干。
3.自定义动作(信号的捕捉):我们杠杆用signal方法,就是在修改信号的处理动作,它将默认信号变成自定义动作。就像上面收到信号后,自定义输出内容,并退出,就把信号默认动作变成自定义动作。我们一般把自定义动作叫做信号的捕捉。
当我们用switch case语句将每个命令都重写一下,写入自定义动作(这里我们就写三个,举三个例子)。
void handler(int signo)
{
switch(signo)
{
case 2:
printf("hello 2:%dn",signo);
break;
case 3:
printf("hello 3:%dn",signo);
break;
case 9:
printf("hello 9:%dn",signo);
break;
default:
break;
}
//exit(1);这里就不退出了,让进程一直执行hello world语句
}
int main()
{
int sig=1;
for(;sig<=31;sig++)
{
signal(sig,handler);
}
while(1)
{
printf("hello world,pid:%dn",getpid());//打印出进程号
sleep(1);
}
return 0;
}
我们发现,当输出信号2或3的时候,进程不会停下来,而当输入信号9,,进程没有打印相对应我们自定义的hello 9,并且进程被杀死了。这是因为:9号信号不可被自定义捕获!
产生信号
一、信号产生前
信号产生的方式都有哪些呢?包括键盘
当我们写这样一个代码:下面这段代码是一个错误代码,我们定义一个指针p,给p变量赋100,p=(int*)100;这句代码是没错的,因为p是变量,保存的是地址,地址值是0,此时p做左值,100可以给p赋值,因为p有空间。但是下一句,将p解引用赋值,*p=100;这一句代码是不对的,因为p指向NULL,没有指向一块空间,不能赋值。
int main()
{
while(1)
{
int* p=NULL;
p=(int*)100;
*p=100;
printf("hello worldn");
sleep(1);
}
return 0;
}
当执行这一段代码,会出现Segmentation fault,这是段错误的报错,也就是进程的崩溃。
当我们将这段程序的信号打印出来,错误程序先给signal这个错误信号,然后signal将错误信号传给handler,因为不是2信号,所以执行default语句,将错误信号对应的信号码打印出来。最后它打印的是11信号。
void handler(int signo)
{
switch(signo)
{
case 2:
printf("hello 2:%dn",signo);
break;
default:
printf("hello signo:%dn",signo);
break;
}
exit(0);
}
int main()
{
int sig=1;
for(;sig<=31;sig++)
{
signal(sig,handler);
}
while(1)
{
int* p=NULL;
p=(int*)100;
*p=100;
printf("hello worldn");
sleep(1);
}
}
我们再来证明一下Segmentation fault就是11信号:将错误代码和注册信号代码屏蔽,运行进程,用kill -11命令停止进程,发现最后进程停止的提示语句就是Segmentation fault。
当我们写了int a=10;a/=0;这种代码,因为0不能做除数,很明显这个代码是错误的,当我们执行程序的时候,会显示8号信号,用kill -l命令查看一下8号信号是什么,发现8) SIGFPE,代表浮点数错误。用无错误代码用kill -8+进程号,将进程停止,发现8号信号打印出来的提示语句是Floating point exception,也就是浮点数指针异常。
所以在win/linux下,进程崩溃的本质是,进程收到了对应的信号,然后进程执行信号的默认处理动作(默认处理动作:杀死进程)。
【那么为什么我们会收到信号呢?】
当程序出错了就会收到信号,为什么程序会出错呢?是因为收到了错误信号。这样来看,我们就陷入了逻辑的死循环,出不来了。此时我们的理解不只限于软件理解,计算机中包括CPU和内存,我们刚刚的错误程序中,野指针和变量a是存在内存中,而a/0是CPU计算的。
不管是什么硬件,只要是硬件就要受CPU相关的管理工作,而我们自己的代码a/=0,是让CPU去计算除0的,CPU内部有一些状态寄存器,状态寄存器记录了计算结果,如果出问题,此时要把问题记录下来。只要计算,那么结果就要在硬件上有所表现。
当进行野指针访问,p指针解引用后,里面保存的是地址,这个地址是虚拟地址,最终要把虚拟地址转化为物理地址,转化的过程用页表+MMU的方式,对地址进行转化。但是万一野指针越界了呢,万一虚拟地址转物理地址的时候,页表没有对应的映射关系,那么此时OS底层的一些帮助我们转化的硬件:MMU、内存管理单元这样的硬件也会报错。所以软件上面的错误通常会体现在硬件或其它软件上。所以要进行野指针访问,会在内存以及MMU的虚拟地址物理映射上产生对应的硬件错误。
而OS操作系统是硬件的管理者,既然是硬件的管理者,那么OS需要知道硬件的状态,以便管理,所以OS就要对硬件的健康进行负责。我们进程中的代码运算时,当运算在硬件层面上有问题,硬件出问题,OS会立马识别到,OS发现这个硬件怎么坏了(坏了不止是硬件的算坏,还可能时代码运算错误出现异常等),OS直接去找对应的错误代码进程,并向该进程发送信号,最后执行信号默认处理动作:终止进程。
【信号产生的方式】:程序中存在异常问题,导致我们受到信号退出。
问题1:当进程崩溃的时候,我们最想知道崩溃的原因。在进程等待的时候,父进程waitpid会收到一个statue子进程信号。而status的低七位就是进程退出的信号,知道对出信号了,推出原因我们也就知道了,status的次低八位,也就是9-16位就是它的退出码信息,退出码信息就是进程退出的原因。所以崩溃的原因可以通过waitpid的status参数得到,在信号的这里就变成了,崩溃时收到了某一个信号。
core dump
问题2:那么除了知道崩溃原因,我们还想知道,程序代码在哪一行崩溃了。
在Linux中,正常情况下,当一个进程退出的时候,它的退出码和退出信号都会被设置;当一个进程异常的时候,进程的退出信号会被设置,但是不会设置退出码,这个退出信号表明当前进程退出的原因。如果有必要,OS会设置退出信息中的core dump标志位,并将进程在内存中的数据转储到磁盘当中,方便我们后期调试。
问题2:那么除了知道崩溃原因,我们还想知道,程序代码在哪一行崩溃了。
在Linux中,正常情况下,当一个进程退出的时候,它的退出码和退出信号都会被设置;当一个进程异常的时候,进程的退出信号会被设置,但是不会设置退出码,这个退出信号表明当前进程退出的原因。如果有必要,OS会设置退出信息中的core dump标志位,并将进程在内存中的数据转储到磁盘当中,方便我们后期调试。
但是在我们运行错误代码时候,没有看到core dump这个东西呀。这是因为在云服务器上,默认情况下core dump这个东西是被关掉的。我们通过ulimit -a选项查看,core file size是0,它的大小是被关掉的。(如果在虚拟机上,core file size默认是存在的,不是0)
那么我们想要看到core dump的file大小不是0,设置core dump,那么使用ulimit -c 大小,来设置core file的大小,我们设置file大小是10240,再使用ulimit -a 查看:
file的大小由0变成10240,就代表允许对错误程序进行core dump。
当我们写了一个错误程序,int a=10;a/=0;这个例子。在错误程序core dump前将它编译它只显示Floating point exception,但是在设置core dump后,编译之后显示Floating point exception (core dumped),说明设置core dump确实成功了。并且ll查看文件,发现目录中多了一个core.5092的文件,5092是刚刚崩溃进程的pid。并且这个文件打开后全是乱码,因为它是OS把内存数据dump到磁盘上,我们不用关心。而这个core文件是让我们将来进行调试的。
所以总的来说,当进程异常了,会收到信号,信号的编号就表明了错误的原因。但是知道原因后,后面想方便我们去调试,此时就可以打开云服务器的core dump选项,让程序直接崩溃,崩溃之后就会在程序的错误信息后面加上一个core dumped提示,标识我们是崩溃的,由核心转储。第二,它会在当前文件下形成一个core文件。
在设置core dump后,该怎么办呢?首先在makefile文件中,将形成可执行程序的语句中,加上一个-g选项,这个选项就代表程序可以被gdb调试。在有了core.5092这个文件和-g选项后,就可以开始gdb调试。
gdb mytest进入可执行程序调试,输入命令core-file core.xxx(刚才生成的core文件),就可以看到异常程序的错误地方,这里它告诉我们错误在第52行,我们打开程序查看,确实在52行。按q退出
这里告诉我们Program terminated with signal 8, Arithmetic exception.它是我们一个计算的异常,根本原因是你收到了8号信号。
所以我们就可以用core dump,先让程序直接出异常,出异常之后再用gdb调试,core-file命令直接得到了,错误原因和错误代码的行数,这种方案我们称之为事后调试。
所以waitpid中子进程的状态码中有一个core dump标志,是为了进程如果异常的情况,进程被core dump后,该位置就会被设置为1。
但是不一定所有的崩溃都会形成core dump文件,不是所有的信号都会收到core dump信息。就比如说对进程发送3号信号,就有core dump信息,而2号和9号就没有。
【证明core dump标志位是被设置的】:因为父进程会收到子进程的退出信号,所以我们将错误信息写在子进程,只有在错误的情况下,status的core dump位才会被设置,才能更好的验证我们的问题。那么在父进程中,用waitpid获取子进程的退出信号,最后被写进status中。
ps:-1是任何子进程,代表waitpid获取任意子进程.在子进程的退出信息中,有16位,从左往右依次是进程退出码(8位),core dump(1位),进程退出的错误信号(7位)。所以要获取进程退出码,先让status右移8位,然后与上8个1(16进制是0xFF),就得到退出码。其它类似,core dump左移7位,然后与1;退出信号直接与7个1(16进制0x7F)。
int main()
{
if(fork()==0)
{
while(1)
{
printf("I am child...n");
int a=10;
a/=0;
}
}
int status=0;//子进程状态
waitpid(-1,&status,0);
printf("wait code:%d,core dump:%d,wait sig:%dn",(status)>>8&0xFF,(status>>7)&1,status&0x7F);//子进程退出码,core dump,退出信号
}
最后发现core dump位的信号位是1,也就证明了core dump的形成
所以信号的产生方式:第一是键盘,第二是进程异常,也能产生信号。
系统调用产生信号
【kill:系统调用】
第一个参数pid,要给哪个进程发信号,第二个参数:要给该进程发几号信号。
我们来实现一个程序,来自己实现一个kill命令,实现的方式是通过./mytest编译,向某个进程发送signo信号,这里就需要用到命令行参数,
在命令行参数中,argv[0]就是可执行程序的名字,argc是命令行参数的个数,包括argv[0],可执行程序是./mytest 要发signo信号,向谁who发,(./mytest signo who)一共三个命令行参数。所以当输入的命令参数个数不是3个的时候,就给出Usage手册,查看使用方法,并直接返回。如果是三个,那么signo是第二个参数,who是第三个参数,因为下标是从0开始,所以signo=argv[1],who=argv[2]。因为给出的信号输入的是字符串,信号都是整数,我们要将字符串转整数:atoi。那么怎么知道进程得到了信息呢?我们用一句话打印一下。
当我们只输入一个./mytest,不是三个参数,运行会给我们显示一个手册,告诉我们应该怎么用。当我们给1234号进程发9号信号,此时显示发送成功。那么怎么实现kill命令呢?通过man 2 kill查看手册:我们知道了kill接口的使用方法。(给谁发who,发信号signo)
static void Usage(const char* proc)
{
printf("Usage:nt %s signo whon",proc);//porc是可变的,signo who是固定的
}
//./mytest signo who
int main(int argc,char* argv[])
{
if(argc !=3)
{
Usage(argv[0]);
return 1;
}
int signo=atoi(argv[1]);
int who=atoi(argn[2]);
kill(who,signo);
printf("signo %d,who:%dn",signo,who);
}
我们来测试一下:
所以产生信号的第三种方案,通过系统调用,产生信号。
【raise:自己给自己发】
打印一条语句,三秒之后,raise系统调用,进程自己给自己发8号信号。
int main(int argc,char* argv[])
{
while(1)
{
printf("I am a processn");
sleep(3);
raise(8);//自己给自己发8号信号
}
}
【abort】
:向自己发送一个确定的信号,不用传任何参数。我们可以用上面for循环signal函数来打印看一下,abort接收的固定信号是几号。
abort发送的固定信号是6号,用kill-l查看6号信号。
void handler(int signo)
{
switch(signo)
{
case 2:
printf("hello ...2:%dn",signo);
break;
case 3:
printf("hello world3:%dn",signo);
break;
case 9:
printf("hello 9:%dn",signo);
break;
default:
printf("hello signo:%dn",signo);
break;
}
exit(1);
}
void main()
{
int sig=1;
for(;sig<=31;sig++)
{
signal(sig,handler);
}
while(1)
{
printf("I am a process!n");
sleep(3);
abort();
}
}
软件条件产生信号
通过某种软件(OS),来触发信号的发送。系统层面设置定时器,或者某种操作而导致条件不就绪等这样的场景下,触发的信号发送。
进程间通信,当读端不仅不读,而且还关闭了读的fd文件描述符,写端一直在写,最终写进程会受到signpipe(13)信号,就是一种典型的软件条件触发的信号发送。
【alarm】
alarm是一个闹钟,设置的参数是秒数,也就是在second秒之后会发送一个alarm信号,这个信号用kill -l查看是14号SIGALRM。
我们让循环不断执行,每一秒执行一次,当三秒之后开始运行alarm,alarm(3)是在3秒之后发送信号(小于等于三秒是不发送信号的)。当alarm发送信号,signal接口接收信号,并传给handler函数,来重写发送信号的方法,之后退出程序exit(1)。
这是一种延迟性的发送信号,alarm有参数,也有返回值,如果我们设置5秒,但是在3秒就被唤醒了,那么剩余的秒数就是返回值。我们可以来验证一下。
先设置一个30秒的闹钟,然后一直循环打印语句,打印闹钟的返回值,这个时候闹钟还没有被中断,所以它的返回值就是0。在sleep(5)睡眠5秒之后,设置alarm(0)取消闹钟,那么在已经走了5秒的情况下,闹钟还剩25秒,所以我们打印来看一下,闹钟取消后的返回值,就是25。
void main()
{
int ret=alarm(30);
while(1)
{
printf("I am a process:%dnn",ret);
sleep(5);
int res=alarm(0);//取消闹钟
printf("res:%dn",res);
}
}
扩展
统计一下,一秒钟我们的计算机server能够对int递增到多少。
alarm(1),没有设置alarm的信号捕捉动作,也就是设置自定义动作,所以在alarm发送14号闹钟信号后,信号的默认动作是终止进程,在1秒之后,进程就会终止。所以在1秒内,我们不断自增count,看看1秒内能打印多少数字。
我们可以看到1秒之内打印了56k次左右。
int count=0;
void main()
{
alarm(1);
while(1)
{
printf("hello :%dn",count++);
}
}
当我们把代码改一下:
让count计数在signal自定义信号发送方式里面,计算1秒之内能跑多少次。我们可以看到每次跑差不多是2亿多次(每个电脑,云服务器都会造成差异)。
void HandlerAlarm(int signo)
{
printf("hello:%dn",count);
exit(1);
}
void main()
{
signal(SIGALRM,HandlerAlarm);
alarm(1);
while(1)
{
count++;
}
}
由5万次提高到2亿次,这里的效率提高了好几倍,为什么在循环里打印太慢了呢?根据体系结构,如果在循环里打印,它不会进行外设访问,它是纯CPU并和内存交互一点点,当加了printf这个IO函数,并且这个是在云服务器上测试的,还要经过网络传输,变得更加慢。所以为什么在循环里用printf语句计算慢?因为有IO。
总结:信号产生的方式有四种:
- 键盘产生
- 进程异常
- 通过系统调用--kill,raise,abort
- 软件条件--sigpipe,sigalarm.
信号产生的方式种类虽然非常多,但是无论产生信号的方式千差万别,但是最终一定都是通过操作系统OS向目标进程发送的信号。所以产生信号的方式,本质都是OS发送的。



