你知道为什么当程序中出现除0就会引发程序崩溃退出吗?你知道为何在Linux中输入kill -9 pid 就能杀死进程id为pid的进程吗?这篇文章将详细探讨解答这些问题,文章内容比较长,大家可以收藏慢慢看
什么是信号
在进程间通信这篇文章中,我们学习过信号量这个概念,这里跟大家说一下,信号量和信号完全是两个概念,两者之间没有什么关系。那信号是什么呢?生活中我们常见的信号有信号弹,有红绿灯,看到信号弹,我们就知道了接下来要怎么行动了,看到红绿灯,我们就知道接下来是该走还是该停了,包括各位同学女朋友的脸色,脸色一变就能明白接下来该是讲道理的时间了
总结一下信号,信号不仅仅是一种现象,还包括出现这种现象接下来该如何操作的方法,是对即将或者可能出现的某种现象的应对,这样说略显抽象,其实就是当操作系统给进程发送某种信号时,进程收到这种信号就要做出相应的处理,处理方法是程序员预先编写好的,当出现这种情况,直接调用就好了。就像等红绿灯一样,当大脑收到红灯这个信号,就知道该停下来,因为我们提前接受过红灯停绿灯行这种教育
怎么判断进程是否收到了操作系统发给它的信号,以及对这种信号做出相应的处理了呢?
我们给正在运行的一个进程发送信号,看看进程收到信号有什么变化就可以验证了?
接下来写一个测试demo,代码如下
#include<signal.h> #include<unistd.h> #include<cstdio> int main(){ while(true){ sleep(2); printf("pid: %d is waiting signal...\n", getpid()); } return 0; }
测试结果如下,test程序正在运行的时候,我们通过root用户给该进程发送9号命令,从结果上看该进程收到了信号,并且执行了进程结束的方法
这里给大家介绍一下,我们可以通过命令 kill - l 查看可以给进程发送哪些信号,总共有64个信号,前32个属于普通信号,是需要我们花时间了解的,34-64就是属于实时信号,不是我们目前学习的重点,因此本篇文章只涉及1-32个信号,这并不意味着我们要全部了解这32个信号,这样篇幅臃肿没有必要,对几个信号学习后,就具备查阅使用其他信号的能力
如何给进程发送信号
1.命令行与组合键形式
前面我们提到过,也是大家经常用到的一个方法就是通过shell命令行给进程发送信号,这就会用到 kill 命令,还有通过快捷组合键的形式,例如CTRL+C,CTRL+\,CTRL+D等等
通过kill命令给进程发送信号的格式为 kill -num pid
其中num表示信号的序号,可以通过kill -l查看,pid是指要被发信号的进程的id(未来参数中出现pid,笔者不再重复说明,默认指进程的id)
ctrl+c:热键 --- 本质是一个组合键 -> os -> os将ctrl+c解释成2号信号 2号信号就是终止程序
ctrl+\:热键 --- 本质是一个组合键 -> os -> os将ctrl+\解释成3号信号 3号信号也是终止程
2.程序内部的相关系统调用
首先就是比较重要的kill()函数,kill()可以给任意进程发送任意信号
这个函数就两个参数,一个是进程的id,另一个是要发送什么信号,这个可以用信号的序号,也可以用信号名来表示
接下来我们写2个简单的demo来演示kill函数的用法
第一个测试内容是让进程给自己发送发送一个9号命令
第二个测试内容是让进程A给进程B发送一个9号命令(因为进程A用kill给进程B发送信号,就要知道进程B的id,这里笔者偷个懒,不在A与B之间建立通信,而是让A与B为父子进程,目的是一样的,都能演示出kill函数的效果)
//demo 1 #include<signal.h> #include<unistd.h> #include<cstdio> int main(){ int count = 5; while(count--){ sleep(2); printf("pid: %d is waiting signal...\n", getpid()); if (count == 1) kill(getpid(), 9); } return 0; }
//demo 2 #include <signal.h> #include <unistd.h> #include <cstdio> int main() { pid_t id = fork(); if (id > 0) { // 休眠十秒后,父进程将给子进程发送9号信号 sleep(10); kill(id, 9); } if (id == 0) { while (true) { sleep(2); printf("childpid: %d is waiting signal...\n", getpid()); } } return 0; }
通过两个实例,相信大家可以很轻松的掌握kill函数的用法,接下来我们继续学习两个常用的的发送信号的系统调用
raise()给自己发送任意信号
abort()给自己发送指定信号 SIGABRT
这两个函数其实都可以用kill函数来实现,例如raise函数,就等于 kill(getpid(), signo)
abort函数,就等于 kill(getpid(), SIGABRT)
会了kill函数的用法,raise和abort的用法自然不在话下
如果你尝试过给进程发送不同的信号,会发现进程接收到信号大部分的处理动作都是终止进程,不同的信号代表着因不同的原因导致进程终止,一般来说,进程受到信号就意味着进程运行时出现了问题或者进程即将结束,再运行下去没有必要,因此进程收到信号的默认动作就是终止进程,接下来我们逐步了解什么情况下,进程会收到系统发送的信号
不过在此之前,咱们来看看进程到底是怎么收到信号的,OS又是如何把信号传递给进程的
信号位图在OS中的名称为pending位图,关于进程接收信号及处理过程笔者后续会详细介绍,这里简单理解即可
进程信号捕捉
前面说了那么多,到底是理论而已,我们要通过实践去检验理论,OS到底有没有给进程发送信号,我们站在进程的角度,到信号就能证实前面的理论
可以通过signal()来接收OS发来的信号
signum表示接收哪个信号, sighandler_t是一个函数指针类型,handler即是函数指针,这个函数表示收到signum信号后的处理方法,系统默认的处理方法是终止进程,在shell界面按下快捷键CTRL+c可以终止进程,即给进程发送SIGINT信号
接下来通过一个demo来演示该函数捕捉SIGINT信号,然后按我们的操作来执行
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdio> using namespace std; void handler(int signo) { printf("SIG: %d was captured by process: %d\n",signo, getpid()); sleep(3); return; } int main() { signal(SIGINT, handler); while(true){ sleep(1); printf("waiting signal...\n"); } return 0; }
由运行结果可得知,进程确实捕捉到了2号信号,并执行了我们的handler函数
除了signal(),还有sigaction() ,这个函数用法更复杂一点,但是可操作性更高
#include
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
这个函数的介绍笔者放到后面,里面一些参数目前没法使用
产生进程信号的情况
硬件原因
1.最典型的就是/0问题,看下面一段代码,当运行该demo时系统就会报错
#include<iostream> using namespace std; int main() { int test = 1, tmp = 0; //test /= 0; 这种写法无法通过编译 test /= tmp; printf("%d", test); return 0; }
运行后直接报错,程序终止,可以猜测出这是进程收到了系统发给它的某个信号,从而造成它停止运行,一起来分析这个过程,如下图
2.解引用空指针导致硬件工作异常从而产生信号
解引用野指针是编程学习过程中进程会遇到的问题,等到编译运行报错了,我们才反应过来,那个时候我们更改错误然后就不管了,今天我们从底层深刻来理解为什么解引用空指针程序就会终止报错
上图是我们的老朋友了,虚拟地址空间通过页表的映射转换成物理地址空间,不过这个转换的过程是由谁来完成的,之前并没有说,这个将虚拟地址空间转换成物理地址空间是由MMU负责的,不过MMU并不是和页表在一起,而是集成到了CPU中,当CPU读取到虚拟地址空间时,就会自动在内部通过MMU转换成了物理地址,如下图
程序因为访问的是一个空地址,那么负责给CPU解析页表映射地址的硬件MMU能检测到这个空地址错误并报给OS,OS便给进行这个解引用的程序发送错误信号SIGSEGV,从而导致进程报错退出,过程如下图
软件原因
1.还记得前面谈论过的进程间的通信吗?那个时候笔者提到过,管道通信时,读端如果关闭了,那么操作系统为了节省系统资源,会自动关闭写端,这个过程就是OS给写端发送SIGPIPE信号,让其停止写入
2. alarm形式的信号,alarm字面意思就是闹钟,人可以被闹钟叫醒,进程也有软性形式的闹钟,当闹钟响了,OS就会给指定闹钟的进程发送SIGALRM信号
unsigned int alarm(unsigned int seconds);
利用这个函数,我们写出检测出电脑一秒钟大概可以计算多少次的demo,代码如下
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdio> using namespace std; long long count = 0; void handler(int signo) { printf("计算次数为:%lld\n", count); exit(0); } int main() { alarm(1); //设定一秒后的闹钟 signal(SIGALRM, handler); //等待接收alarm信号 while(true){ count++; } //检测闹钟响前,count被运算的次数 return 0; }
根据运行结果可知,我的服务器一秒大概可以运行4亿多次,但这可不是CPU的运行速度,因为CPU会有时间片轮转,这个只能大概测出一个进程一秒内能被CPU执行多少次
由这个demo可见,alarm还是比较好用的,任意一个进程都可设置alarm,那么可想而知OS中会存在大量的进程设置了alarm,OS就要有对应的结构来管理好这些alarm,等到时间到了,再唤醒对应的进程,给其发送alarm信号,结构大致如下图
核心转储
不捕捉信号的时候,系统给进程发送信号会执行默认的行为,这个行为一般就是终止进程,也不全是直接终止,这个我们可以通过命令man 7 signal来查看
可以发现,标Term的信号默认行为都是终止进程,还有Core, stop(暂停进程)等等,标Core的信号表示支持核心转储,可以发现SIGSEGV信号默认处理是核心转储,数组越界写入就会产生SIGSEGV信号,我们写一个越界demo看看会发生什么
#include <iostream> #include <signal.h> #include <cstdio> using namespace std; int main() { int a[20] = {0}; a[10000] = 300; return 0; }
除了报一个段错误,好像并没有什么特别的现象,核心转储体现在哪呢?这是因为核心转储在云服务器上默认是关闭状态的,我们需要手动打开
使用命令ulimit -a 可以查看核心转储文件的大小,如图默认为0
使用命令ulimit -c 2048 将核心转储文件的大小设置为2048
修改完成后,再次运行前面越界的demo
神奇的现象出现了, 打开核心转储文件后,再次执行越界程序,除了报了段错误,还多个一个文件,这个文件就是核心转储文件,所以核心转储就是在进程出现异常的时候,进程在对应的时刻将进程的有效数据转储到磁盘中,形成的文件就是核心转储
通过gdb,可以使用核心转储文件,操作如下
通过核心转储文件,可以直接定位到错误地点,不用一步一步调试了,记得在编译代码文件时加上-g即支持调试
信号阻塞
pending和block位图
OS发来的信号,我进程就一定,必须,无条件的执行吗?就不能在接收信号后将其屏蔽,不执行其对应的方法吗?
当然可以,接下来就学习信号阻塞,看到这里不知道大家伙还记得前面提到过的pending位图吗?那时说的比较简单,就认为进程中的信号都保存在pending位图中,pending位图中的比特位被置为1,就表示收到该信号,然后就立即执行对应的处理方法。事实上,pending位图是一种未决状态,意思是pending位图某个信号被置为了1,表示进程确实收到了这个信号,但是并没有执行,而是等待OS的内核来执行,阻塞信号的原理就是在OS内核准备查看pending位图中哪些信号需要被执行时,在中间加了一道锁,即使进程收到了某个信号,其对应的pending位图也被置为了1,只要阻塞该信号,就相当于给该信号加了一道锁,OS内核一看有把锁就直接走了,然后该这个信号就一直处于未决状态,直到取消该对该信号的阻塞
阻塞信号同样是用位图来表示的,名称为block位图,下图把二者对应起来分析
sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志(block)也是这样表示的
因此,未决(pending)和阻塞标志(block)可以用相同的数据类型 sigset_t 来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态
阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的,sigset_t要配合下列函数调用来使用
信号位图处理函数
#include <signal.h> int sigemptyset(sigset_t *set); 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零, 表示该信号集不包含 任何有效信号 int sigfillset(sigset_t *set); 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位, 表示该信号集的有效信号包括系统支持的所有信号 int sigaddset (sigset_t *set, int signo); int sigdelset(sigset_t *set, int signo); 在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化, 使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用 sigaddset和sigdelset在该信号集中添加或删除某种有效信号 int sigismember(const sigset_t *set, int signo); sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含 某种 信号,若包含则返回1,不包含则返回0,出错返回-1 int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集),oset是一个输出型参数, 如果oset是非空指针,则读取进程的当前的信号屏蔽字(block位图)保存到oset中。 如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。 如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里, 然后根据set和how参数更改信号屏蔽字 int sigpending(sigset_t *set); 读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1 set为输出型参数 int sigaction(int signo, const struct sigaction *act, struct sigaction *oact); //这是前面提到过的信号捕捉的另一个函数
下图说明sigprocmask函数中how参数如何填写,假设当前的block位图为mask
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达
接下来详细介绍一下sigaction()的用法
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo是指定信号的编号,若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体
说白了,oact就是当前设置的一个备份,是一个输出型参数
可见想用好这个函数,还得知道struct sigaction里包含了什么字段
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用
这么多函数想一次消化掉可不容易,也不要想着去清晰的记住他们,如果它们的使用够高频你绝对忘不了,如果使用不够高频,用到时再查,但是要有印象,知道怎么用
接下来通过demo来体验这几个函数的用法
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdio> #include<assert.h> void handler(int signal){ printf("已经捕捉到2号信号了\n"); } int main() { //创建并初始化3个位图,block_backup用来备份block位图 sigset_t test_block, test_pending, block_backup; sigemptyset(&test_block); sigemptyset(&test_pending); sigemptyset(&block_backup); //在test_block位图中添加2号信号,并通过sigprocmask() //将test_block置为当前进程的block位图 sigaddset(&test_block, 2); sigprocmask(SIG_SETMASK, &test_block, &block_backup); signal(2,handler); while(true){ printf("进程:%d 正在等待2号信号,并执行handler函数\n", getpid()); sleep(2); } return 0; }
通过运行结果可知,2号信号还真被阻塞了,通过kill给进程发送多次2号信号,handler函数并没有被执行,为了证明2号信号真被收到了,但是被阻塞一直处于未决状态,咱们就要把pending位图给打印出来看看,为了方便,咱们就不用kill发送2号信号了,直接用CTRL+c
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdio> #include<assert.h> void handler(int signal){ printf("已经捕捉到2号信号了\n"); } void show_pending(sigset_t *pending){ for (int i = 1; i<= 32; i++){ if (sigismember(pending, i) == 1) printf("1"); else printf("0"); } printf("\n"); } int main() { //创建并初始化两个位图 sigset_t test_block, test_pending, block_backup; sigemptyset(&test_block); sigemptyset(&test_pending); //在test_block位图中添加2号信号,并通过sigprocmask() //将test_block置为当前进程的block位图 sigaddset(&test_block, 2); sigprocmask(SIG_SETMASK, &test_block, &block_backup); signal(2,handler); while(true){ //打印当前进程pending位图 sigpending(&test_pending); show_pending(&test_pending); printf("进程:%d 正在等待2号信号,并执行handler函数\n", getpid()); sleep(2); } return 0; }
可以发现,当我们发送2号信号时,pending位图的2号位置由0变为1,说明2号信号确实被阻塞一直处于未决状态,通过这么一个示例,就可以把这几个信号阻塞函数应用,希望大家可以自行编写该测试代码
接下来看看sigaction函数的用法
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdio> #include<assert.h> void handler(int signal){ printf("已经捕捉到2号信号了\n"); exit(0); } int main() { sigset_t test_pending; sigemptyset(&test_pending); //设置sigaction结构的相关参数 struct sigaction test_1, test_2; test_1.sa_handler = handler; test_1.sa_mask = test_pending; test_1.sa_flags = 0; //其余参数不需要操心 //捕捉2号信号 sigaction(2, &test_1, &test_2); while(true){ printf("正在等待信号传递\n"); sleep(1); } return 0; }
信号捕捉及处理的详细流程
尽管上文对信号的讲解足够大家日常使用了,但是大家心中可能仍然对信号捕捉处理过程中OS的具体做法心存疑惑,接下来我们就从头开始理解整个信号的捕捉和处理流程
我们前面对OS的理解都把它作为一个整体,现在要具体划分一下OS,OS其实分为内核态和用户态,什么是内核态?什么又是用户态?
用户态:像我们用户平时写的一些测试代码,一些算法代码等等,虽然被加载到内存运行了,但一直是运行在OS的用户态,在用户态下,用户程序只能访问受操作系统授权的资源和执行受限的操作,不能直接访问底层的硬件资源。用户态提供了一种安全的环境,使得用户程序无法对系统造成损害,同时也限制了用户程序的权限
内核态:是指操作系统内核运行的部分,内核拥有最高的权限,可以直接访问和操作系统底层的硬件资源。在内核态下,操作系统可以执行特权指令和访问敏感资源,可以对硬件和系统资源进行管理和控制
当用户程序需要访问需要特权的操作或资源时,例如进行系统调用、访问硬件设备或进行特定的内核操作时,会触发一个从用户态到内核态的切换。在内核态中执行完成所需的操作后,又会切换回用户态,将结果返回给用户程序
如何以全局的视角看待程序执行从用户态进入到内核态呢?前面我们一直说用户的代码保存在进程地址空间的代码区,而进入内核态则需要执行内核的代码,这个过程是如何跳转的
关于页表,我们并没有完全探明其结构,平时我们都是以一张页表来映射所有的虚拟地址,事实并非如此,页表也是分为用户级页表和内核级页表的
有了上述知识的铺垫,就能把整个信号捕捉的过程走一遍了
整个过程和下面这张流程图十分相像
volatile
volatile是C语言中的一个关键字,在平时的代码练习中,它的出场率并不高,很多同学包括笔者几乎忘记C语言还有这么个关键字。不过既然能作为关键字出现在一个语言中,可见其作用还是不凡的,只是目前我们接触不到其使用场景
在进程信号中,我们可以感受到volatile其中的一个应用场景
看下面一段代码
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdio> #include<assert.h> using namespace std; int flag = 0; void handler(int signal){ flag = 1; printf("已经捕捉到2号信号了\n"); } int main() { signal(2, handler); while(!flag); printf("程序开始退出\n"); return 0; }
结果符合预期,接下来提高编译器的优化级别,不动代码,修改makefile即可
神奇的事情发生了,提高了代码的优化级别,给进程发送2号信号,进程却不退出了 ,flag不应该被置为1了吗?那么!flag就是0啊,为什么循环不退出了呢?
SIGCHLD信号
在进程部分提到过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了。采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂
事实上子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可
这个方法对单个子进程比较好用,当遇到多个子进程时,如果多个子进程同时退出,那么就要循环阻塞wait,如果多个子进程部分退出,部分不退出则循环不阻塞wait
想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程,系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的
至此,本篇文章就结束了,信号这篇内容比较繁多,事实上,系统编程就不是省油的灯,其不仅要掌握编程知识,对计算机体系结构的要求也很高,想要熟练掌握实属不易。虽然知识比较繁杂,好在写一些底层的代码不断打消曾经学习编程时的疑惑,逐渐有种茅舍顿开感。希望各位能在学习的过程中找到属于自己的那份喜悦,不为前途,不为功名,为的是纯粹求知的满足