目录
一、关于信号
Linux中信号的概念:
本质是一种通知机制,用户或操作系统通过发送一定的信号,通知进程某些事件已经发生,可以在后续进行处理
结合前面所学知识,总结几个信号的结论:
a.进程要处理信号,必须具备信号“识别” 的能力,即能够看到信号和处理信号的动作
b.进程能够识别信号是由于程序员编写相关代码时所做的处理
c.信号产生是随机的,进程可能正在忙自己的事情,所以信号的后续处理,可能不是立即处理的
d.信号会临时的记录下对应的信号,方便后续进行处理
e.信号会在合适的时候进行处理
g. 一般而言,信号的产生相对于进程而言是异步的(不互相影响)
二、信号的产生
初步了解信号
第一种常见的信号是我们之前所学的热键:Ctrl + C
当出现死循环或其他问题时,如果我们想终止掉进程,Ctrl + C即可,Ctrl + C的本质就是通过键盘组合键向目标进程发送2号信号:即下图的2号信号SIGINT
例如有一个程序loop.cc死循环
编译并运行,会死循环打印hello,直到我们键盘Ctrl + C才会终止:
我们执行2号信号同样可以终止进程:
首先运行loop可执行程序,同时另一个窗口查看当前进程的pid,发现是30955
然后执行2号信号,发现死循环的进程退出了:
所以上面的例子Ctrl + C后,进程退出了,我们的进程处理了这个信号,进行的就是默认动作(见下面的信号处理方式)
信号常见的处理方式
1、默认(是进程自带的,程序员写好的逻辑)
2、忽略(也是处理的方式之一)
3、自定义动作(即捕捉信号)
常见的信号
可以通过kill -l查看:
可以发现31-34中间,没有32、33号信号
其中把1-31号这31个信号称为普通信号
34-64号的这31个信号称为实时信号
信号是如何被进程保存的
进程必须要有保存信号的相关数据结构(位图)
例如unsigned int,有32个比特位,刚刚上面学习了普通信号有31个,所以每一个比特位可以代表一个信号,即可以表示不同的信号
而比特位是0还是1则表示的是该信号是否产生
这里的位图信号字段是在PCB结构体内部保存的
信号的发送问题
通过理解上面所说的信号是用位图保存的,比特位0和1表示是否发送信号
而位图是保存在PCB结构体中的,并且PCB结构体又是内核数据结构,所以产生了一个结论,只有OS有权利发送信号
因此,信号发送的本质是:OS向目标进程写信号,OS直接修改PCB中指定的位图结构,完成发送信号的过程
理解组合键如何变成信号的问题
例如我们最开始说的键盘上输入Ctrl + C,可以终止进程,和执行2号信号有一样的结果
键盘的工作方式是通过:中断方式进行的
也能够识别组合键Ctrl + c
所以OS解释组合键,即查找进程列表,从而找到前台运行的程序,OS写入对应的信号到进程内部的位图结构中
发送信号的方式:
a、键盘发送信号
上面提到的信号处理方式有三种,分别是默认、忽略、自定义捕捉,其中第三种是自定义捕捉,如何自定义捕捉呢,如果不想发送2号信号后执行默认动作,而想自定义捕捉一下,该怎么操作呢?需要调用signal函数
下面了解signal函数:
包含头文件signal.h
函数参数:
第一个参数:signum代表想要对哪一个信号进行捕捉,既可以写名称,也可以写序号,例如如果想传入2号信号,既可以传2,也可以传SIGINT
第二个参数:handler,类型是sighandler_t
而sighandler_t是上面typedef出来的,即返回值为void,参数为int的一个函数指针
向一个函数传入函数指针,叫做回调函数,即通过回调的方式,修改对应的信号捕捉方法
如下所示:
这时我们signal函数中,第一个参数是SIGINT(2号信号),第二个参数是返回值为void,参数为int的函数指针catchsig
而catchsig函数中的参数signum即为SIGINT的信号编号2,可以让catchsig函数中使用这个信号
下面运行观察结果:
发现Ctrl + c无法终止这个进程,这是为什么呢?
其实原因就是以前2号信号的处理动作默认是终止这个进程,现在使用signal函数,把2号信号的处理动作改变了,所以执行Ctrl + C时进程就不退出了(这时执行ctrl + \终止)
并且可以观察到,原本是执行的main函数中while循环里的语句,Ctrl + c后变为了执行catchsig中的语句了,由此可以明白,在main函数中执行signal函数后,catchsig的方法暂时是没有被调用的,只有当我们进行Ctrl + c操作,也就是2号信号产生的时候,catchsig才会被调用
signal函数,仅仅是修改进程对特定信号的后续处理动作,不是直接调用对应的处理动作
如果后续没有任何SIGINT信号产生, catchsig是会永远也不会被调用的
核心转储功能
信号的处理动作:
使用man -7 signal查看:
可以观察到,信号1/2的动作(action)都是term,表示终止
信号3/8的动作(action)都是core,表示核心转储
一般来说云服务器的核心转储功能是被关闭的,通过ulimit -a可以查看
core file单位是blocks,当前是0,表示一般云服务器core file对应的核心转储的功能是被禁用掉的
如果想打开核心转储的功能,可以看到选项是-c,所以执行ulimit -c 10240,将core file大小改为10240,也就是我们打开了core file(仅仅是在当前会画中打开,当我们关闭当前终端时就会自动关闭了)
下面对比一下打开与不打开核心转储的区别:
如果不打开核心转储,我们执行kill -8 [pid],即使对应的动作是core,但由于没有打开核心转储,结果是:
此时查看当前目录下的文件,没有变化:
如下所示演示打开核心转储后的变化:
右图中,多了一个core dumped
此时再查看当前目录下的文件:
多了一个core的临时文件,后缀是刚刚运行进程的pid
所以有核心转储功能时,当进程出现某种异常时,OS会将内存中的相关核心数据转存到磁盘中,存储在一个生成的.core 的文件中,主要是为了调试
所以信号后面的动作term、core,都是终止进程,区别是要不要核心转储
那么什么叫主要是为了调试呢?如何调试呢?
关于调试
就以8号信号SIGFPE为例,8号信号也就是浮点数错误,所以我们将代码改为除0错误,观察核心转储功能的效果:
代码改为:
因为要用到调试,所以在Makefile中,加上-g选项,即以开发模式运行,能够调试
这时运行后会出现如下的提示,后面有个core demped即核心转储功能是开启的
再查看当前目录下的文件:
这时有了对应的core文件,我们是看不懂的,这时我们gdb调试:
本身gdb需要逐行定位调试,但是当前目录下已经有core demped文件了,已经核心转储了,所以就不需要逐行分析了,直接执行core-file core.14016:
就会直接定位到20行的tmp/=0的这个除0错误了,上面划线的句子也说到程序终止是因为8号信号,算术异常
所以根据这个core文件可以自动的将代码的错误的位置给我们定位出来,所以在写完代码后,可以先打开核心转储功能,再gdb运行一下,可以直接定位到错误的位置,比较方便
以上即为core的应用场景
进程等待中:core dump标记位
在进程等待那里,曾经说到了waitpid函数的第二个参数是status,status我们只学习其中的低16位,其中低16位中,次低的8位,表示的是进程的退出码,最低7位是终止信号,而倒数第8位是core dump标记位,表示是否发生核心转储,所以验证进程等待时的core dump标记位的操作是:(status >> 7) & 1即可,终止信号则是(status & 0x7F)判断出来的,具体在下面的代码中体现:
结果为:
因为代码中是除0错误,所以终止信号是8,且我们上面打开了核心转储功能,所以core dump标记位是1,也就是发生核心转储了,所以也就有了对应的core文件:
而如果子进程是以信号2的方式终止的,也就是以term的方式终止的,我们将上述代码子进程中sleep(1000),在sleep时执行kill -2 [pid],即可使用2号信号终止进程,下面观察这个core dump标记位的情况:
此时core dump标记位为0,就意味着该进程不发生核心转储
所以core dump标记位0/1就表示,子进程退出时是否是以core dump的方式终止的
b、系统调用接口发送信号
①kill
kill需要包含两个头文件,分别是sys/types.h和signal.h
kill的两个参数,就表示向指定的进程pid,发送指定的信号signumber
下面是使用kill函数发送信号的代码示例,使用了命令行参数argc、argv:
发送信号的示例:
我们先让进程sleep 10000,然后ps axj | head -1 && ps axj | grep 'sleep 10000'查看sleep 10000的进程pid,接着执行./mykill 9 21300,也就是执行9号信号,传入sleep 10000的pid:21300,这时观察右侧窗口,发现刚刚的sleep进程被killed了
这就是kill向指定的进程发送指定的信号
②raise
上面的kill是向指定的进程发送指定的信号,raise就是向自己发送指定的信号
raise直接传入指定的信号即可
上面代码表示直接向自己发送8号信号,结果为:
8号信号就是SIGFPE,所以就表示传入了8号信号
③abort
raise是向自己发送确定的信号
abort没有参数,直接向自己发送确定的信号
这里abort发送的确定信号其实就是6号信号:
代码:
运行为:
由于信号执行的动作是core,有核心转储功能,我们前面操作时也打开了核心转储,所以会自动生成一个core的文件:
上述三种系统调用接口发送信号的情况,也可以理解为以下步骤:
用户调用系统接口、用户调用后会执行OS对应的系统调用代码、OS提取对应的参数(例如调用的kill,就会提取pid和signumber),或是设定对应的数值、OS向目标进程写信号、修改对应进程的信号标记位、进程后续处理信号、执行对应的处理动作
所以本质依旧是由OS完成具体的信号操作
c、软件条件产生信号
在管道中,如果出现了父进程关闭了读端,而子进程的写端一直在写,此时的写是没有意义的
所以OS会自动终止对应的写端进程,通过发送信号(SIGPIPE)的方式 ,SIGPIPE是13号信号
上述情况就叫做软件条件产生信号
alarm
下面一个例子是闹钟alarm的:
alarm函数可以设定一个闹钟,可以在seconds秒后给当前进程发送SIGALRM信号
上述代码中,先执行了alarm函数,设定为1秒,1秒后会发送SIGALRM信号,所以就会调用catchSig函数,打印一秒中的count++的数量
而在catchSig函数中,又有一个alarm(1)的闹钟,即捕捉完信号后,又重设闹钟,所以又会执行上述操作,从而形成了每隔一秒自动循环打印的操作:
这就类似于定时器的功能,下面加以改进:
设定一个闹钟,执行1秒后发送SIGALRM信号,被捕捉到信号后进入catchSig函数,在catchSig函数中将call中push_back的func类型的函数全部执行一遍,运行结果如下:
以上即为闹钟问题,类似于定时器的功能
软件条件产生信号可以理解为:
OS先识别到某种软件条件触发或者不满足、OS构建信号,发送给指定的进程
d、硬件异常产生信号
除0错误
前面接触很多的除0错误就是硬件异常,关于除0错误的理解如下:
在计算机中,进行计算的是CPU这个硬件,CPU中有寄存器,其中有一个寄存器叫做状态寄存器,里面有对应的溢出标记位,OS会自动检测计算完成后的情况,如果溢出标记位是1,OS就能够识别出溢出问题,只需要找到当前是哪个进程在运行,提取出对应的pid,OS完成信号的发送,对应的进程会在适当的时候进行处理
所以除0错误是硬件问题
出现硬件异常时不一定会退出,一般默认是退出,但是如果我们捕捉异常,就不会退出,但是我们也做不了什么
除0错误代码演示:
结果为:
段错误
段错误有很多,像C/C++中的越界、野指针等问题,都被划分为段错误,即segmentation fault
段错误是11号信号:
而越界、野指针等问题之所以是硬件错误,原因是:
指针会通过地址找到对应的位置,而语言层次上的地址都是虚拟地址,所以实际中需要将虚拟地址转化为物理地址,而转化的方式就是通过页表 + MMU(内存管理单元)进行转化的,MMU是硬件
而如果出现越界、野指针等问题,说明此时的地址是非法地址,所以在MMU转化的时候一定会报错(而MMU是硬件,即所以MMU报错即为硬件错误),报错时只需查看页表用的是哪个进程的,即可以很容易找到出现硬件MMU报错的进程,接着OS就把MMU的报错转化为对应的信号,发送给对应的进程,最后进程退出
三、信号的保存
信号几个常见概念
实际执行信号的处理动作,称为信号递达
信号从产生到递达之间的状态,称为信号未决
进程可以阻塞/屏蔽某个信号,称为信号阻塞/屏蔽
上述的三个概念,在内核当中,进程PCB内部就对应了三张表:分别是block(阻塞)、pending(未决)、handler(递达)
pending表是位图结构,是无符号整数,用比特位的位置代表信号编号,用比特位的内容(0/1)代表是否收到信号
handler表可以理解为函数指针数组,每个函数是返回值为void,参数为int的函数。当收到一个信号时,OS修改pending表位图结构中对应的比特位,等到要处理这个信号时,拿着对应信号编号在handler数组中就能够直接找到该信号的处理方法,所以调用对应的函数指针所指向的方法,从而完成信号捕捉。
所以我们上面代码中的signal(signum,catchSig),含义就是根据signum这个信号编号,在handler这个数组中,找到对应的位置,最后将catchSig函数地址填入到这个handler数组中,就完成了信号的自定义捕捉
上面说到的是自定义捕捉,而信号常见的处理动作还有忽略和默认,在使用signal函数时,2号信号自定义捕捉是signal(2,catchSig),忽略动作是signal(2,SIG_IGN),默认动作是signal(2,SIG_DFL)
SIG_IGN和SIG_DFL这两个宏,SIG_IGN默认值是0,SIG_DFL默认值是1,所以信号编号如果是2的话,OS在拿到信号编号后,会先判断handler[2]是否等于0/1,若handler[2]的值是0/1则会执行忽略或默认的动作,若不是才会进行自定义捕捉,执行对应的函数
block表也是位图结构,它的结构和pending一样,比特位的位置也代表的是信号编号,它位图中的内容(0/1)代表的含义是对应的信号是否被阻塞
因此结合上面三张表的说明,一个信号被处理的动作应是如下方式:
操作系统先发送一个信号到pending表中,改变位图中对应位置的数值,进程处理信号时首先需要检测pending位图中是否有位置被改为了1,找到为1的比特位后,必须要先找到对应位置的block表中的数值,若block表中对应的位置为1,则说明该信号被堵塞了,所以不对该信号做任何处理;若block表对应位置为0,才会进入handler表中进行相关的处理动作
sigset_t
每个信号只有一个bit的阻塞与未决标志(0/1),所以阻塞与未决可以用相同的数据类型sigset_t来表示,sigset_t被称为信号集,这个类型可以表示每个信号的状态,未决信号集表示该信号是否处于未决状态;阻塞信号集表示该信号是否被阻塞,阻塞信号集也叫做信号屏蔽字(signal mask)
sigset_t是位图结构,但是不允许用户自己进行位操作,OS提供了对应的操作位图的方法
sigset_t用户可以直接使用该类型,和使用内置类型、自定义类型没有区别
sigset_t需要对应的系统接口完成对应的功能,系统接口需要的参数包含sigset_t定义的变量或对象
OS提供的操作位图的方法:
int sigemptyset(sigset_t *set);(比特位全部置0)
int sigfillset(sigset_t *set);(比特位全部置1)
int sigaddset (sigset_t *set, int signo);(将特定的信号添加到信号集中)
int sigdelset(sigset_t *set, int signo);(从信号集中删除特定的信号)
int sigismember(const sigset_t *set, int signo);(判定一个信号是否在该信号集中)
上述这5个接口都包含在头文件signal.h中
sigpending函数
sigpending函数可以获取当前调用进程的pending信号集
返回值:成功返回0,失败返回-1
sigprocmask函数
sigprocmask函数可以对当前调用进程的block信号集做修改
返回值:成功返回0,失败返回-1
函数参数:
第一个参数how:表示如何进行修改,有三个选项:SIG_BLOCK、SIG_UNBLOCK、SIG_SETMASK
SIG_BLOCK:表示希望添加到当前信号屏蔽字的信号,相当于mask = mask | set
SIG_UNBLOCK:表示希望从当前信号屏蔽字中解除阻塞的信号,相当于mask = mask & ~set
SIG_SETMASK:设置当前信号屏蔽字为set所指向的值,相当于mask = set
第二个参数set:表示信号集
第三个参数oldest:是输出型参数,返回老的信号屏蔽字(如果需要的话)
下面验证一下如果我们对所有的信号都进行了自定义捕捉,那是不是就出现了一个不会被异常或用户杀掉的进程呢?
答案当然是否动的,下面看代码验证,将1-31号信号都捕捉:
结果为:
观察结果发现不论执行哪个信号,都被自定义捕捉了,无法退出,可如果是9号信号呢,看下面结果:
所以可以明白,不论是否将全部信号都自定义捕捉,9号信号始终可以终止进程,并不会出现上面所猜测的将全部信号都捕捉了,进程就不会被杀掉的情况
同样的,如果我们对所有的信号都进行了阻塞,那是不是就出现了一个不会被异常或用户杀掉的进程呢?同样,存在信号9不会被阻塞,依旧可以终结进程
四、信号的处理
信号的捕捉过程
首先了解用户态和内核态:
用户态是一个受管控的状态
内核态是一个OS自己执行自己代码的一个状态,是具备非常高的优先级的
之所以要从用户态到内核态,是因为用户需要访问某些资源,而OS不允许访问,所以只能先变为内核态,然后再去访问
而从内核态又返回到用户态的原因是:当前用户的代码可能还没有执行完;当前用户层可能有一些进程还没有被调度
信号产生后可能无法立即被处理,而是会在从内核态返回用户态的时候被处理
信号捕捉流程只需要记住下面这张图即可:
红线上下分成了用户态和内核态,其中4个圈起来的交点就是4次状态变化,即由用户态变为内核态,或从内核态变为用户态
信号的操作
sigaction
man查看sigaction函数
包含在头文件signal.h中
函数参数:
第一个参数signum,是之前我们也使用过的信号编号,如2号信号就传入2
第二个参数act,是一个结构体,它是一个输入型参数,要将对信号处理 的回调函数传入
第三个参数oldact,是一个输出型参数,将老的处理方法返回
返回值:成功返回0,失败返回-1
代码如下:
运行结果为:
如果正在处理2号信号,再来一个2号信号是不会处理的,会在当前信号处理结束后才会处理
下面代码可以验证上述情况:
运行时刚开始会打印默认动作:0,当键盘执行Ctrl + C时相当于产生了2号信号,这时进入handler函数中,打印十次pending位图中各个比特位的情况,此时应都为0,因为初始化为0,且正在执行2号信号
但如果这个过程中,又执行Ctrl + C,相当于又产生了2号信号,此时就会屏蔽这个信号,在当前信号处理结束后才会处理这个2号信号,所以打印的pending位图的第二个位置就会为1,直到10秒结束,执行这个2号信号时,打印的pending位图才会恢复为全0
上图即为运行结果,红框中的即为键盘执行Ctrl + C,捕捉到了2号信号,即打印三行handler函数中的,"获取了一个信号:2"
接着执行showspending函数,打印10行spending位图的各个比特位的情况
在这个过程中,如果再执行Ctrl + C,就会屏蔽信号2,即在spending位图中,第二个比特位为1,直到10秒后执行完当前信号,再执行刚刚屏蔽的信号2
所以10秒后spending位图中的第二个比特位又变为了0,符合上述描述的情景
如果还想屏蔽其他信号,此时可以使用上面提到过的sigaddset函数,即:
其余部分都不用变,只需要将打印时间变长即可:
此时在执行2号信号过程中,如果又产生3、4、5、6号信号时,会屏蔽它们,即在打印出来的pending位图中比特位都为1
补充知识:volatile关键字
volatile关键字能够告诉编译器,不要优化volatile修饰的字段。所以能够保证在同样的代码中,不同的优化级别时能够输出同样的结果
那么不加volatile关键字,难道不同的优化级别运行的结果不同吗,下面看举例:
比如说上述代码,正常情况下,全局变量flag==0,由于!flag == 1,所以main函数中一直在死循环,直到出现2号信号后全局变量flag会变为1,从而回到main函数后!flag == 0,终止进程,即:
而当加上优化条件,即在Makefile中加上-O3优化后:
此时再运行程序:
会发现执行Ctrl + C产生2号信号后,程序并没有像我们所想的那样回到main函数后终止,而是持续死循环
原因就是:编译器在有了-O3优化条件后,发现flag是全局变量,而main函数中并没有改变flag变量的代码,而每次检测flag的值都要访问一次内存,再读入,这样效率太低了,所以编译器优化成了默认将flag=0存入CPU的寄存器,所以以后的每一次执行代码,编译器不会再从内存中检测flag的值,而是默认从寄存器中读入0这个值
所以当我们产生了2号信号,2号信号在change函数中改变的是内存中的flag的值,而编译器每次运行时却是从寄存器中读取的flag值,所以不论内存中的flag值有没有改变,寄存器中的flag值始终为0,所以当我们执行Ctrl + C产生2号信号时,程序不会退出,依旧为死循环
解决这种问题的方法就是,给全局变量flag前加上关键字volatile
此时在执行同样的代码,就不会出现之前的问题了:
SIGCHLD信号
前面说到可以使用wait或waitpid函数清理僵尸进程的问题,有两种方法:①父进程可以阻塞等待子进程结束;②父进程非阻塞即以轮询的方式查询是否有子进程需要清理
这两种方式都有缺陷:
第一种方式父进程阻塞等待子进程,就不能处理自己的工作
第二种方式父进程处理自己工作时,还必须得时常轮询子进程的状态,比较复杂
其实子进程终止时会给父进程发送SIGCHLD信号,该信号的默认动作是忽略,而父进程是可以自定义SIGCHLD信号的处理函数(捕捉信号),由此可以改进上面两种方式的缺陷,既不用阻塞等待,也不用父进程在执行工作时时常轮询子进程的状态,子进程在终止时会通知父进程,父进程在信号处理函数中调用wait/waitpid清理子进程即可
子进程退出发送的SIGCHLD信号是17号信号:
下面代码证明子进程退出会向父进程发送SIGCHLD信号(17号信号):
运行结果为:
所以按照之前的,fork()函数创建子进程,子进程在5秒后退出,父进程继续执行,就会出现僵尸进程:
结果为下图,5秒后子进程变为僵尸进程(状态为Z+),而父进程继续运行:
如果我们在代码中加入signal(SIGCHLD, SIG_IGN);即用户手动将SIGCHLD的动作改为忽略,即可解决上面的僵尸进程问题:
此时运行代码:
发现子进程运行5秒后,子进程退出,并没有出现刚刚代码中的僵尸进程,所以此方法解决了僵尸进程的问题
那么又出现了一个疑问:
通过下图可以看到SIGCHLD信号的默认动作就是IGN(忽略)的
那么既然OS默认SIGCHLD信号是忽略的,但是为什么还需要用户显示设置为忽略,并且如果不显示设置为忽略的话,结果截然不同呢?
那是因为操作系统级别的忽略就是默认的动作,该有僵尸进程就有僵尸进程,什么也不做
而用户自己手动设置的SIG_IGN,就是用户明确的告诉操作系统进行忽略动作,并且在忽略动作时要把已经退出的子进程自动释放掉
这两种忽略的程度不同