Linux进程信号的处理
@[toc]
零.前言
信号的发送与进程间通信是不同的,信号只能够由操作系统来进行发送,而进程的作用是请求操作系统来发送信号。我们使用control+C可以终止一个进程的本质上其实就是向该进程发送一个2号信号。
一、信号的引入
1.在生活中有很多信号的场景的存在,比如红绿灯,闹钟,老师的脸色等,当我们获得了这些信号之后,我们立刻就知道下一步要去做什么了。注意,信号与进程之间通信的信号量之间是没有任何关系的。
2.同时,只有当绿灯量起来的时候我们才知道应该在绿灯的时候行走吗?显然不是的,进程也是这样,不管是否接收到了信号,进程都知道如果收到这些信号应该做什么。进程收到信号之后应该做什么是由操作系统工程师已经处理好的。
3.当我们收到某一种信号时,不一定立刻去处理该信号,因为可能有重要的事情需要去做。此时就需要将信号存储。存储的位置就是进程的PCB中,信号的本身也是数据,因此在向进程中传递信号的本质就是向PCB中写入数据。
4.信号的发出者只有操作系统,无论我们如何发送信号都是请求操作系统来进行发送的。
通过以上分析,我们可以将信号的发送分为三大部分:分别是信号产生,信号保存和信号处理。在Linux系统中,我们可以使用
kill -l
来查看所有信号:
注意观察,是没有32和33号信号的,我们只研究前31个信号。
二、信号的产生
1.通过键盘产生
当一个进程是一个死循环的进程时,我们可以使用键盘进程control+C来终止掉进程。control+C的本质就是向该进程传递2号信号从而使该进程终止。注意,键盘只能向前端进程传递信号。
下面来验证一下以上内容:
(1)发送2号信号
要验证这一问题,我们需要认识一个函数:
在signal这一函数中,它的第一个参数代表的是信号编号。它的第二个参数是一个函数指针,它指向的是一个返回值为void,参数为int的函数,该函数的参数就是signum,该参数会被signal函数自动传入到该函数中。
注意该函数执行的前提是收到了signum号信号,否则不会执行该函数。
当向该进程发送signum信号,则执行handler所指向的函数。我们可以根据该函数这一功能来间接判断发送的是哪一个信号。
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
void handler(int signo)
{
printf("get a signo:%d\n",signo);
}
int main()
{
signal(2,handler); //当收到2号信号时执行handler函数
while(1)
{
sleep(1);
printf("assistant is stupid!\n");
}
}
此时在进程运行的过程中,我们使用control+C就不会使进程退出了,而是直接执行handler函数:
我们可以使用control+\,即三号信号来终止进程。
(2)只能向前端进程传递信号
如果我们让进程在后台中运行,那么键盘将无法向进程中输入信号:
./mytest &
此时我们发现,当在键盘输入control+C的时候,并没有向进程传递信号,此时进程只能通过系统向进程传递9号信号来关闭。
2.程序异常收到信号
(1)程序异常发送信号的现象
当我们对一个空指针进行解引用的时候,程序会发生崩溃退出的,而程序退出的本质就是收到了某种信号,导致了程序的退出,我们可以通过以下代码来找到令空指针解引用程序退出的信号。
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<stdlib.h>
void handler(int signo)
{
printf("get the signal:%d\n",signo);
exit(1);
}
int main()
{
int sig=1;
for(;sig<=31;sig++)
{
signal(sig,handler);//接收所有信号
}
int* p=NULL;
*p=100;//Segmentation fault段错误
}
此时我们可以发现,传入的是第11号信号。
我们还可以测试一下,如果进行除0操作而造成崩溃使程序退出的信号:
我们发现,传入的是8号信号。
因此我们可以得出结论:在win或者Linux系统下,进程崩溃的本质是进程收到了与崩溃对应的信号,然后进程执行信号的默认动作,即杀死进程。
(2)程序异常发送信号的原因
那么为什么会收到信号呢?
首先我们需要明确,计算机的一切计算操作都是在CPU中完成的,当CPU进行除0操作的时候会出现异常,而操作系统是硬件的管理者,当操作系统得知CPU运算出现异常的时候就会向产生异常进程发信号,使其终止。
因此我们可以得到这样一个结论,程序的异常最终其实都会体现在其他的软件或者硬件上。
(3)如何定位程序异常
当程序异常崩溃时,我们最想知道的就是程序崩溃的原因,在哪里崩溃的。
在Linux系统中,当一个进程退出时,它的退出码和退出信号都会被设置(正常情况),当一个进程异常的时候,进程的终止信号会被设置,表明进程退出的原因。如果必要,OS还会设置退出信息中的标志位core dump(它在status的第8位),并将进程在内存中的数据转移到磁盘中,方便后期调试。
在默认情况下,这种基于core dump的调试方式是被关掉的。当需要进行coredump调试,coredump位被设为1
我们可以通过ulimit指令来进行查看:
ulimit -a //查看系统资源
ullimit -c 10240 //允许进行coredump,设置大小为10240
此时当我们再运行问题代码时,会带一个core dump的提示,并且在当前目录下,还会找到生成的一个文件,这个就是我们的调试文件:
此时我们使用gdb来进行调试(注意如果要使用gdb的话需要在生成可执行文件的时候使用-g选项):
此时当我们使用r选项令代码运行起来时,gdb就会自动查看core.2206这个文件,从而给出问题出现的位置:
这里表示异常发生在代码的28行。
注意,并不是所有的异常(信号)可以进行上述coredump调试。
3.系统调用产生的信号
说人话就是用代码输入信号。
我们不仅可以在终端使用kill -x输入信号,系统也提供了一个名为kill的函数可以用于输入信号:
它的第一个参数为进程的pid,它的第二个参数为信号序号。通过这个函数我们可以向pid这个进程发送sig号信号。
我们可以使用它以及命令行传参,来模拟实现kill进程:
我们令argv第二个参数为信号,第三个参数为进程的pid,与kill进行对应。
static void Usage(const char* proc)//说明函数
{
printf("Usage:\n\t %s signo who\n",proc);
}
int main(int argc,char* argv[])
{
//./mytest signo who
if(argc!=3)
{
Usage(argv[0]);
return 1;
}
int signo=atoi(argv[1]);
int who=atoi(argv[2]);
printf("signo:%d,who:%d\n",signo,who);
kill(who,signo);
}
此时就成功地模拟实现了kill进程。
kill函数可以给任何pid的进程输入信号,同时还有一个专门给自己输入信号的函数:raise。
raise(8)代表给自己输入8号信号
还可以使用abort()来给自己的进程输入abort信号(6号信号)。
4.软件条件产生信号
通过某种软件触发信号的发送,系统层面设置定时器,或者某种操作而导致条件不就绪的场景下,触发的信号发送。
比如在进程通信的管道通信中,当读端关闭,写端仍然在继续写,操作系统就会给写端发送sigpipe信号。就是一种典型的软件条件触发的信号发送。
我们再来介绍一个软件条件产生的信号,信号发出的软件时OS。
它表示的是一个闹钟,意思是seconds秒之后发送14号信号。它的返回值是上一个闹钟剩余的时间。当之前已经设定闹钟且该闹钟没有结束时,再次调用alarm只会取消闹钟,而不是设定新的闹钟。
int ret=alarm(30);
printf("assistant is stupid!\n");
while(1)
{
sleep(1);
int res=alarm(0);
printf("ret:%d,res:%d\n",ret,res);
}
此时我们就可以捕捉到alarm的返回值,即上一个alarm剩余的时钟时间。
我们还可以利用这个alarm函数来记录一下5s内程序运行的速度:
int count=0;
alarm(5);
printf("hello world!\n");
while(1)
{
count++;
printf("%d\n",count);
}
可以看到5s内count被加到了446706。
5.如何理解OS向进程发送信号
OS发送的信号时直接发送给进程的PCB的。在进程的PCB中存在一个32位整数,我们将该整数看成一个位图结构,它的每一位都代表一个信号,当值为1时,表示收到了该信号,当值为0时,表示没有收到该信号。
信号的传递本质是信号的写入,即OS向进程的PCB中的位图中写入比特位,就完成了信号的发送。
三、信号产生中
1.基本概念
1.信号递达:实际执行的信号处理动作叫做信号递达。递达其实就是信号的执行过程。
2.信号未决:信号从产生到递达之间的状态,称为信号未决。
3.信号阻塞:进程可以选择阻塞某个信号。
当进程阻塞某信号时,该信号产生时将处于未决状态,直到进程取消阻塞,才执行递达的动作。只要被阻塞就不会被递达。这其实就是信号保存的过程。
阻塞与忽略的区别在于,忽略已经开始进行处理信号了,只不过处理信号的方式是不处理。
2.三张表
操作系统根据三张表来确定是否处理信号,以及如何处理信号:
这三张表分别是:block,pending,handler
其中block也是一个32位整数,我们也将其看成一个位图,比特位的位置代表信号的编号,比特位的内容代表是否被阻塞。block也称为屏蔽字。
pending表示的是是否收到该信号。即32位位图。
handler的内容是函数指针,它是一个函数指针数组,如果信号成功递达,则执行该函数指针所指向的函数。其中SIG_DEF表示的是默认,SIG_ICM表示的是忽略。如果我们使用signal改变信号的执行内容,则该函数的地址会被传递给对应信号的handler中。
第一行表示的是,没有收到信号1,信号1没有被阻塞,如果信号1被递达执行默认操作。
第二行表示的是,收到了信号2,信号2被阻塞了,如果信号2递达执行忽略策略。
第三行表示的是,没有收到信号3,信号3被阻塞了,如果收到信号3,执行默认的操作。
等等。。。
注意,如果某个信号被屏蔽了,操作系统就不会关心该信号是否被接收。
3.sigset_t
(1)系统调用类型
系统调用除了可以体现在函数上之外,还体现在操作系统提供的数据类型上。这些数据类型的最终目的也是配合系统调用函数来使用的。
比如在以上的三张表中,block表和pending表的本质其实就是一个32位整数,这个32位整数的类型就是sigset_t,由于它们并不是在用户层出现而是在系层出现的。因此需要操作系统的接口来实现对表的更改。
因此要修改sigset_t类型的数据,不能让用户直接进行修改,而是需要使用系统调用接口来进行修改。
sigset_t s;//定义一个sigset_t的变量
s=10;//错误,用户不能直接修改该变量的值
(2)信号集处理函数
修改sigset_t类型的变量,操作系统提供了如下函数:
int sigemptyset(sigset_t set);//初始化set所指向的信号集,将其中所有信号对应的bit清零。
int sigfillset(sigset_t set);//初始化set所指向的信号集,将所有信号对应的bit置为1。
int sigaddset(sigset_t set,int signo);//在信号集中添加signo号信号。
int sigdelset(sigset_t set,int signo);//在信号集中删除signo号信号。
int sigismember(const sigset_t* set,int signo);//判断是否含有signo号信号。
4.sigprocmask与sigpending
(1)sigprocmask
使用man手册查询结果如下:
该函数是与屏蔽字有关的函数(block):
第二个参数表示传入一个新的sigset_t类型的变量set,是一个输入型参数。
第三个参数表示返回之前的屏蔽字,是一个输出型参数。
第一个参数表示的是对屏蔽字进行的操作,可以传入如下几个变量:
SIG_BLOCK:包含了我们希望添加到屏蔽字的信号,相当于mask=mask|set。
SIG_UNBLOCK:set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set。
SIG_SETMASK:设置当前信号屏蔽字为set所指向的值,相当于maks=set。
(2)sigpending
它的用法很简单:
读取当前进程的pending表,通过set传出。
5.举例
下面用一个例子来具体地使用这些函数:
(1)屏蔽2号信号
#include<stdio.h>
#include<signal.h>
#include<stdlib.h>
#include<unistd.h>
int main()
{
sigset_t iset,oset;
sigemptyset(&iset);
sigemptyset(&oset);
sigaddset(&iset,2);
sigprocmask(SIG_SETMASK,&iset,&oset);
while(1)
{
sleep(1);
printf("hello world!\n");
}
}
(2)打印信号位图
如果进程首先屏蔽掉2号信号,此时传入2号信号,并不断获取pending位图,并打印显示。由于2号信号不会被递达,因此它一直在pending位图中,我们就可以进行观察了。
void showpending(sigset_t* set)
{
int i=1;
for(i=1;i<=31;i++)
{
if(sigismember(set,i))
{
printf("1");
}
else{
printf("0");
}
}
printf("\n");
}
int main()
{
sigset_t iset,oset;
sigemptyset(&iset);
sigemptyset(&oset);
sigaddset(&iset,2);
sigprocmask(SIG_SETMASK,&iset,&oset);
sigset_t pending;
while(1)
{
sigemptyset(&pending);
sigpending(&pending);
showpending(&pending);
sleep(1);
}
}
此时当我们发送2号信号时,就可以看到pending表的变化:
(3)将2号信号递达
当将信号屏蔽之后,再解除屏蔽我们就可以看到pending表中2号信号由1变成0的过程。
但是由于2号信号递达之后进程会立刻退出,因此我们需要修改2号信号的执行方式来方便进行观察。
我们令20s之后2号信号递达:
#include<stdio.h>
#include<signal.h>
#include<stdlib.h>
#include<unistd.h>
void showpending(sigset_t* set)
{
int i=1;
for(i=1;i<=31;i++)
{
if(sigismember(set,i))
{
printf("1");
}
else{
printf("0");
}
}
printf("\n");
}
void handler(int signo)
{
printf("2号信号被递达了!\n");
}
int main()
{
signal(2,handler);
sigset_t iset,oset;
sigemptyset(&iset);
sigemptyset(&oset);
sigaddset(&iset,2);
sigprocmask(SIG_SETMASK,&iset,&oset);
sigset_t pending;
int count=0;
while(1)
{
count++;
sleep(1);
if(count==20)
{
sigprocmask(SIG_SETMASK,&oset,NULL);
printf("恢复2号信号\n");
}
sigemptyset(&pending);
sigpending(&pending);
showpending(&pending);
}
}
四、信号的处理
1.信号的处理方式
一般而言,信号的处理有三种情况:
1.默认动作:即系统默认处理信号的方式,通常是暂停或者终止。
2.忽略动作:是一种信号处理方式,只不过处理方式是什么也不干。
3.自定义动作:使用signal函数就是在修改信号的处理方式,也称为信号的捕捉。注意,9号信号不能被自定义处理。
2.信号检测和处理
在信号产生中的模块中,我们了解到OS会对进程中信号的三张表进行检测,从而判断是否要执行某一个信号。这里提到了两个关键词,分别是检测和执行,那么这两个动作发生在哪里呢?
要了解这一部分,我们需要了解进程处理过程中的两个状态:用户态和内核态
(1)用户态和内核态
用户态:用户代码和数据被访问或者执行得我时候所处的状态,我们自己的代码都是在用户态被执行的。
内核态:执行OS代码和数据的时候,计算机所处的状态,叫做内核态。OS代码全部在内核态执行。
两者的区别在于权限,它们之间的切换表现为系统调用。
当用户调用系统调用接口时,除了进入函数,身份也会发生变化,会由用户的身份变成内核的身份。
我们知道当执行某个进程的时候,用户的代码和数据会被加载到内存中,同理,内核的代码和数据也一定要加载到内存中。当我们开机的时候就是将内存的代码和数据加载到内存中的过程。
除了用户区的页表之外,还有内核区的页表,对于任意一个进程来说,它的内核区的内容都是一样的。因此内核区的页表只需要存在一份即可,被多个进程所使用。由于内核页表的存在,我们能够保证所有的进程都能找到同一个操作系统。所谓系统调用,其实就是将进程的身份转换为内核,然后根据内核页表找到系统函数并执行。
同时在CPU内部,有一个CR3的寄存器,它是用来判断当前进程执行的是用户态还是内核态,如果是内核态,它的值就会被赋值为0,如果是用户态它的值就会被赋值为3。
(2)信号检测与处理流程
当我们执行用户代码的时候,执行到一个系统调用,此时进入内核态,执行完系统调用的函数的代码后需要返回用户态。继续执行用户的下一条代码。在返回之前的需要进行信号的检测与处理操作。当没有信号,或者信号被阻塞,或者信号的处理方式不是自定义的,此时直接进入用户态执行用户的下一条代码。
但如果信号被递达了,且是自定义处理的。此时就需要进入用户态执行该自定义函数,然后再返回内核态的sigret,然后再返回用户态执行下一条代码:
整个处理流程类似数学中的无穷大:
在处理信号的过程中:
1.当handler为默认状态的时候,直接释放资源,进程结束。(因为在内核态)
2.当handler为忽略状态的时候,直接将pending的1置为0。
3.当handler为自定义的时候,进程由用户态->内核态->用户态->内核态->用户态,一共经历了四次转变。
但是为什么要切换到用户态去执行handler的代码呢?OS显然也有权限去执行,但是它不相信任何人,用户只能使用OS的接口去让OS执行一系列的操作。
结论:当内核态即将切换为用户态的时候,进行信号的检测和处理。
3.修改信号执行的两个函数
其中一个函数就是signal函数,这个在前面已经介绍了,这里不多赘述。
另一个函数shisigaction。我们可以通过man手册进行查询:
它的第二个参数是一个输入型参数,表示的是一个结构体(这个结构体和函数是同名的),这个结构体中的第一个参数是一个函数指针,指向自定义的信号处理方法:
它的第三个参数是一个和第二个参数相同类型的结构体,它的第一个元素指向未修改之前的信号处理方法。
下面使用这个函数来自定义一个信号处理的方法:
#include<stdio.h>
#include<signal.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
void handler(int signo)
{
printf("get a signo:%d\n",signo);
}
int main()
{
struct sigaction act;
memset(&act,0,sizeof(act));
act.sa_handler=handler;
sigaction(2,&act,NULL);
while(1)
{
printf("hello world!\n");
sleep(1);
}
}
此时就对2号信号完成了修改:
下面来介绍一下该结构体中的另一个变量:sg_mask:
当某个信号的处理函数在被调用的时候,内核自动将当前信号加入进程的屏蔽字。当函数结束后,屏蔽字恢复。这样保证了当处理一个信号时,如果该信号再次出现,则不会被处理。直到前一个信号已经被处理结束。
当处理一个信号时,除了该信号,我们还想屏蔽另一些信号,此时就需要sg_mask发挥作用了。
比如进程正在处理2号信号,此时再传入2号信号,信号不会被处理。我们希望不仅仅传入2号信号不会被处理,传入3号信号也不会被处理,就可以将sg_mask的3号信号置为1(sg_mask也是sigset_t类型)。
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask,3);
在代码中加入这两段代码,即将sa_mask的信号3添加。此时当处理信号2的时候,信号3也就被阻塞了。
五、补充概念
1.可重入函数
有这样一种场景,当我们在进行链表的插入时,本质上分为两步:
p->next=head;
head=p;
我们假设这段代码在一个名为insert的函数中执行。
如果执行完第一步之后,接收到了信号,信号的处理方式还是这个insert函数,又向其中插入一个新的节点,这种情况我们称之为函数的重入,它可能带来不好的后果:
此时就会造成无法找到node2这个节点的后果。
注意,并不是只有遇到系统调用接口的时候进程才会进入到内核态,当CPU调度不同的进程,将其从运行队列前端移到后端的时候,是操作系统进行操作的,因此此时也会进入内核态从而完成对信号的处理。因此信号的处理是可能在任意的时间的。
此时insert在用户层调用一次,在信号捕捉时又调用了一次,我们称之为函数重入,但是由于函数重入造成了我们不想看到的结果,因此我们称insert函数为不可重入函数。与之对应的还有可重入函数。
大部分函数都是不可重入的。
2.volatile关键字
该关键字是C语言的比较冷门的关键字,我们站在信号的角度来认识一下它:
int flag=0;
void handler(int signo)
{
flag=1;
printf("change flag 0 to 1\n");
}
int main()
{
signal(2,handler);
while(!flag);
printf("进程正常退出\n");
return 0;
}
这段代码表达的意思是,这是一个死循环的程序,当收到2号信号的时候,flag由0置为1,结束死循环,程序退出。执行结果是这样的:
如果我们编译过程中采用优化编译的方式:
gcc test.c -o mytest -O3
此时运行的结果,我们发现死循环不会被终止。
下面来分析一下原因:
在main执行流的过程中,gcc发现main函数中没有人对flag进行修改,就会进行优化:即将flag永久地存放在寄存器中,下一次CPU调度该进程的时候就不需要进行寻址访问了,而是直接使用寄存器中的值。
当CPU读取完flag之后,就不会再对其进行读取了。而信号到来,内存中的flag发生了变化在CPU中的flag是不知道的。而判断操作都是在CPU中进行的,因此会一直死循环。
为了避免这一情况,只需要将flag定义为:
volatile int flag=0
volatile的作用就是告诉编译器不要对这个变量做任何优化,要读取必须贯穿式地读取内存,不要读取缓冲区中的数据,保存内存的可见性。
3.SIGCHLD信号
该进程是在子进程退出后对父进程发出的信号。该信号的默认处理动作是忽略。
void Getchild(int signo)
{
printf("get a signo:%d\n",signo);
}
int main()
{
signal(SIGCHLD,Getchild);
pid_t id=fork();
if(id==0)
{
int count=5;
while(count)
{
printf("I am a child:%d\n",getpid());
sleep(1);
count--;
}
exit(0);
}
while(1);
return 0;
}
当子进程退出时,就会捕捉到17号信号:
而SIGCHLD更重要的作用在于,当如果我们显示设置忽略17号信号的话,子进程退出后会直接被父进程回收,而不用等待父进程结束而变成僵尸进程:
signal(SIGCHLD,SIG_IGN)
这种做法目前只在Linux系统下有效。
六、总结
信号的内容有很多,总结起来可以分为三个大方向:
信号产生前,信号产生中,信号产生后。
信号的产生共有四种方式,分别是由键盘产生,进程崩溃产生,系统调用产生,和软件条件产生。
信号产生中要记住三张表以及它们代表的含义。以及处理三张表的各种接口。
信号产生后需要了解用户态和内核态,以及进程对信号的检测和处理的"合适时间"所指。以及两个可以修改信号的函数。
最后补充了三个概念,分别是可重入函数,volatile关键字以及SIGCHLD信号。
- 点赞
- 收藏
- 关注作者
评论(0)