你知道为什么当程序中出现除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 <signal.h>
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函数自定义的忽略通常是没有区别的
至此,本篇文章就结束了,信号这篇内容比较繁多,事实上,系统编程就不是省油的灯,其不仅要掌握编程知识,对计算机体系结构的要求也很高,想要熟练掌握实属不易。虽然知识比较繁杂,好在写一些底层的代码不断打消曾经学习编程时的疑惑,逐渐有种茅舍顿开感。希望各位能在学习的过程中找到属于自己的那份喜悦,不为前途,不为功名,为的是纯粹求知的满足