💖作者:小树苗渴望变成参天大树🎈
🎉作者宣言:认真写好每一篇博客💤
🎊作者gitee:gitee✨
💞作者专栏:C语言,数据结构初阶,Linux,C++ 动态规划算法🎄
如 果 你 喜 欢 作 者 的 文 章 ,就 给 作 者 点 点 关 注 吧!
文章目录
- 前言
- 一、信号的预备工作
- 1.1生活->计算机
- 1.2ctrl+c终止进程
- 1.3通过硬件来谈谈ctrl+c
- 二、信号的产生
- 2.1os自己给进程发送信号
- 2.2硬件异常和软件条件
- 2.2.1硬件异常
- 2.2.2软件条件
- 2.3 core dump
- 三、信号的保存
- 3.1信号的其他相关概念
- 3.2在内核中表示
- 3.3sigset_t
- 3.4信号集操作函数
- 3.5验证3.1节的概念
- 四、信号的处理
- 4.1信号处理的流程
- 4.2 信号捕捉的另一个函数(sigaction)
- 五、总结
前言
今天博主将给大家讲解的信号这个知识,信号和信号量是两个哪有任何关系的概念,所以大家不要以为信号量没有学,就不敢来学信号,信号是我们日常生活中必备的,有了信号才会让我们社会变得稳定,计算机里面的信号也是起了这样的作用,没有信号,那么程序就会乱,所以接下来博主就通过生活信号的例子来映射到计算机里面的信号,在带大家认识计算机信号是怎么样,会有一条讲解逻辑,大家跟着博主的思路走下去吧。
讲解逻辑
- 预备工作(讲解信号的认识)
- 信号的产生
- 信号的保存
- 信号的处理
一、信号的预备工作
1.1生活->计算机
先给大家讲解一个小故事,跟着博主总能时不时听到一个小故事
有一天小明在家非常的饿,但是他又不想出门,此时他就点了一份外卖,但是他不能干等着,觉得太无聊了,就开了一把王者,当他准备推别人高低了,外卖到了打电话来说,你外卖到了来去一下,但是你此时有更重要的事情要做,你就说,等会去拿,但你打完这把,此时你大概率会取外卖,而不是再开一把,上面的小故事,就包含了讲解逻辑的四点,我们知道打电话来了就是信号的认识,真的打电话来了,就是信号的产生,等一会再拿,就需要把信号保存起来,打完了去拿就是信号的处理。 显然大家对上面会产生疑惑,但是又觉得这是必然的,谁不知道打电话来了就是外卖到了呢,好比红灯停绿灯行的道理呢,但是博主还是需要一点点的给大家灌输信号的概念,越是简单的东西,就要细细的讲。
听完上面的故事,接下来通过上面的故事引发了下面这三点结论:
在生活中,我们常见的信号有信号弹,上下课铃声,红绿灯,闹钟以及外卖的电话等等
- 你是怎么认识这些信号??
a.肯定是有人教,我自己记住了这些常见的信号
b.教会了识别信号,还要教处理信号的方法
- 即使是我们现在没有信号的产生,我也知道信号产生之后,我该干什么
大家现在在家里坐着,也知道红灯绿灯这个信号如果发生我们该干什么
- 信号产生了,我们有可能不会立即处理这个信号,在合适的时候(后面会讲)。
因为我们此时在做更重要的事情,好比在推高地,所以在信号产生后->时间窗口->信号处理,在这个时间窗口内,你必须记住信号已经到来了。
上面用加粗标记的你在计算机里面指的是进程,也对应三点结论:
- 进程必须要有识别信号和信号产生后的处理能力,信号没有产生,也要具备处理信号的你能力,所以这些信号在一开始就内置在进程的描述对象里面,创建时候自动初始化
- 进程即便是没有收到信号,也能知道哪些信号该怎么处理,所以在进程中可以在任意位置使用kill -l查看有哪些信号
- 当一个进程针对收到一个具体的信号的时候,进程可能并不会立即处理这个信号,会在合适的时候,所以一个进程当信号产生到信号开始被处理,就一定会有一个时间窗口,就要保证在时间窗口内进程要具有临时保存这些信号的能力(这也叫信号相对于进程控制来说好似异步的。)
我们还没有开始讲解进程的信号,通过生活的例子,我们就可以得出进程应该也符合生活中的结论
1.2ctrl+c终止进程
我们常用操作哪些是具备了发送信号的功能:
我们来写一个简单的程序:
#include<iostream>
#include <unistd.h>
using namespace std;
int main()
{
while(1)
{
cout<<"i am aprocess"<<endl;
sleep(1);
}
return 0;
}
大家应该知道CTRL+C会终止进程,但是为什么可以终止进程呢,这个肯定和信号有关,但是先放放,先带大家认识一下前台进程和后台进程
1)前台进程 ./test
2)后台进程 ./test &
前台进程和后台进程的区别是,我们在前台进程运行test.cc,其余的指令就跑不了(
问题1
),而且命令行也显示不出来,但是CTRL+c(问题2
)可以终止进程,而后台进程,我们发现指令程序还可以继续跑(问题1
),而且分开输入指令依旧可以跑(问题3
),最重要的是CTRL+C终止不了进程了(问题2
),但是还是打印在显示器上(问题4)
问题2: 我们的每一次登录,就是一个终端窗口,只允许一个进程是前台进程,bash就是一开始的前台进程,和运行多个后台进程,当有其他前台进程,bsah就自动变回后台进程,当没有前台进程,bash就变成前台进程,前台进程能够收到键盘输入,所以当我们的test变成后台进程,这个进程就收不到键盘输入的CTRL+c,就退出不了,但我们的bash是前台进程的时候,我们使用CTRL+c,bash怎么不退出,原因是bash进程受保护了,收到ctrl+c不做处理
问题1: 我们的CTRL+c只能终止前台进程,后台进程只能通过kill -9 +pid杀死,原因是只有前台进程才可以收到键盘输入,test是前台的时候可以被ctrl+c终止,是后台的时候就不行
问题3: 答案看1.3章节的第四点
问题4: 后台进程为什么还打印在显示器上,不能以为打印在屏幕上就是前台进程,这是一个显示器文件,和进程是没有关系的,该往显示器打就往显示器上打印
讲解完毕前后台,我们提出疑惑:为什么我们的ctrl+c能够终止前台进程
原因是收到了2号信号,是终止程序,我们来看看进程内置了多少个信号:kill -l
我们一共有62个信号,没有32 33号信号,这些信号其实都宏定义,左右两边都是一个意思,0-31是我们这篇要讨论的信号,34-64是实时信号,收到信号立马要处理,我们不讨论。为什么没有32 33号信号,这就与历史有关,大家下来可以去搜搜
信号的处理方式
- 大家默认的处理动作
- 无视这个信号,不做处理
- 自己设计一个处理动作(自定义捕捉)后面会经常用这个验证结论
上面的三点在我们日常生活中其实这样,比如红绿灯,默认是红灯等绿灯行,不管红绿灯直接无视,红绿灯亮起你有自己的一套动作,就这三种
突然说起这个是为了验证我们程序在输入CTRL+C确实是收到2号信号:
我们来介绍一个函数:
这个函数的第一个参数是传入要修改信号处理方式的信号,第二个参数对应自定义动作,是一个函数指针,这个函数有一个参数,是我们第一个参数,让我们一起来验证一下吧:
#include<iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
void myhandle(int signal)//这个函数后面会经常用到
{
cout<<"i get a signal:"<<signal<<endl;
}
int main()
{
while(1)
{
//signal(SIGINT,myhandle);
signal(2,myhandle);//修改2号信号的默认处理方式
cout<<"i am aprocess:"<<getpid()<<endl;
sleep(1);
}
return 0;
}
确实我们验证我们的进程是收到了2号信号,因为修改了处理方式,所以就没有终止进程,因为处理方式只能选择一种
解决疑惑:
我们的signal函数为什么第一个参数已经传信号了,为什么myhandle还要传参数??
- 系统接口都是c语言里面写的,函数直接本质是独立的,不是c++的类,声明一个成员变量,内部的函数都可以使用这个成员变量,signal内部会调用myhandle这个函数,因为在一个进程里面可能不止一个信号要修改处理方式,都是调用myhandle这个处理方式,为了标识是哪个信号调用了这个处理方式的函数,所以需要传参。就好比下面:
signal(2,myhandle); signal(3,myhandle);
这样就可以知道哪个信号使用这个处理方式我们的signal为什么放在第一行,不放在其他地方,我们的目的是让这个进程以后收到这个2号信号换成自定义处理方式,不需要放在循环里面,放在最后也不行,放在最前面就恁恶搞保证这个歌程序在任意时刻收到这个信号都可以执行我的自定义方式
看看哪些信号可以被修改处理方式
既然可以修改信号的处理方式,我们如果把9号也修改,进程在死循环,那么这个进程是不是就无敌了??我们来看验证结果:
通过结果我们发现除了9号和19号不能被修改其余都可以被修改,这样说明系统不会让一个无敌的进程存在,因为根据常理,这两个信号允许被修改,可能会造成一系列严重的后果,大家下来自己去思考一下,也很容易理解。
1.3通过硬件来谈谈ctrl+c
以键盘代替硬件
这一幅图,让大家了解硬件的数据是怎么被os读到内存里面的。博主讲解这个一个是为了给大家涨知识,另一个是让大家再次认识信号,我们的硬件中断其实就是一个信号,但是和我们今天讲的信号没有关系,但是我们今天讲的信号就是用软件的方式对进程模拟硬件中断
- 如果是那种abcd的输入按键,os就直接识别到这样的数据,但是收到像ctrl+c这样的组合键的时候会做判断,将ctrl+c转换成2号信号给进程
- 我们的键盘有数据,os就会将键盘的数据读取到内存,那么我们的os怎么知道数据读取结束呢?os不必知道,键盘数据读完,也会给cpu发送硬件中断,还是一样的套路,其他硬件也是这样的操作
- 为什么我们cin,scanf在输入的时候会进行阻塞等待键盘输入呢?原因是我们os是进程的管理者,你键盘不给我数据,我就让这个进程等待,你啥时候给我,我就让进程继续运行下去,是os控制着一切的。
- cpu的引脚连接多个硬件,但不是每个硬件都对应一个寄存器,这样就会导致多对一的关系,万一多个硬件发生中断怎么办,CPU先处理那个硬件中断呢??所以我们设计者为了避免这个问题,在体格时刻值允许一个硬件中断发过来,即使你同时使用鼠标和键盘,在屏幕上看着是一起的,但是对于CPU他是不同时刻获取中断的,我们感知太慢了,给我们他们是同时发送中断的错觉。
- 来回答1.2中问题3的问题:
相信大家的问题应该得到了解答,我们有的时候要回车才可以,有的时候按下来就有,通过上面的图,大家应该就可以明白,设置了缓冲区A的缓冲策略,什么场景用什么刷新策略。
二、信号的产生
上面是我们讲解信号的预备工作,接下来就开始讲解信号的产生。
通过1.3节我们通过ctrl+c这样的组合键给进程发送信号,也就是信号产生的过程,也知道进程是怎么收到的组合键的,通过os. 所以接下来就来看看我们键盘上面还有哪些组合键可以给进程发送信号,博主带大家来测试一下:
2.1os自己给进程发送信号
这个信号的产生的不一定是进程出现了问题,而是os自己去发送的。有三种方式:
第一种方式:
ctrl+c:2
ctrl+\:3
ctrl+z:19
大家看到结果了吧,还有其他的大家可以自己去摸索。
第二种方式:
kill -signal pid
第三种方式:
这个是通过系统调用接口来产生信号,一会介绍完系统调用接口,博主带大家模拟实现一个符合第二种方式的mykill,也会比较有意思:
- kill
这是一个可以在命令行输入也可以当成函数,知识名字一样,一个是指令,一个是函数,不要以为是一样的,这个函数的主要功能就是给任意进程发送信号,好比父子进程
第一个参数:进程的pid,第二个参数就是信号
来看案例:
#include<iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
void myhandle(int signal)
{
cout<<"i get a signal:"<<signal<<endl;
}
int main()
{
cout<<getpid()<<endl;
signal(7,myhandle);
int cnt=5;
while(1)
{
cout<<"i am a proc"<<endl;
if(cnt==0)
{
kill(getpid(),7);//我这里就一个进程,所以进程pid就是自己
cout<<"我收到7号信号了,退出循环"<<endl;
break;
}
cnt--;
sleep(1);
}
return 0;
}
我们使用kill函数实现了给进程发送信号,大家下来自己测试一下给不同进程发信号,可以使用管道让我们进程间进行通信,把你要发信号的进程号传过去。接下来模拟的mykill中也有体现
- raise
这是一个kill子集的函数,他的作用是给调用他的进程发送信号,所以少了一个参数。
raise(7);//把刚才kill的位置换成这个就可以了
这个函数还是比较简单的,
- abort
这个函数是给调用他的进程发送指定信号,不需要传参。也就是六号信号,我们将它修改默认方法看看结果:
abort();
signal(6,myhandle);
abort();
大家发现我们修改了六号的默认处理方式之后,doily abort之后,确实是使用了我们自定义的处理方式,但是最后还出现一个abort函数单独调用的结果也显示出来了,这是因为abort函数做了特殊处理。
结论:
上面三个函数,每一种发送信号的方式都展示出来了,给任意进程发任意信号,给唯一进程发送任意信号,给唯一进程发送唯一信号。后面两个都本质都是调用了第一个函数去实现的。博主也通过第一个函数来给大家写一个我们自己在命令行上的mykill程序。
mykill.cc
#include<iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include<string>
#include<stdlib.h>
using namespace std;
void UseKillPage(string name)//使用文档
{
cout<<"kill page:"<<endl;
cout<<"\t -signal pid"<<name<<endl;
}
int main(int argc,char*argv[])
{
if(argc!=3)
{
UseKillPage(argv[0]);
return 0;
}
//获取命令行参数
string argv1=argv[1];
argv1.erase(0,1);//去掉-这个字符
int signal=stoi(argv1.c_str());
pid_t pid=stoi(argv[2]);
int n=kill(pid,signal);
if(n==-1)
{
perror("kill");
return 0;
}
return 0;
}
test.cc
#include<iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include<stdlib.h>
using namespace std;
void myhandle(int signal)
{
cout<<"i get a signal:"<<signal<<endl;
}
int main()
{
signal(2,myhandle);
while(1)
{
cout << "I am a process, pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
我们使用自己写的mykill,也完成了对其他进程进行发送信号的功能,大家下来可以将自己的mykill放到PATH下,程序名改成kill,那么就和系统的kill使用一样了,大家下来去试试。
2.2硬件异常和软件条件
这两个产生信号非常的相似,但是又有点不一样,但是共同点都是代码出现了问题,是os收到软硬件的问题,给进程发信号,不是os主动的行为,其次我们的硬件异常产生问题是博主重点讲解的,让大家可以更好的认识信号
2.2.1硬件异常
第四种方式:
在C++或者java等一些语言都说过异常,但是今天的硬件异常和之前的没有关系,硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程,大家应该明白原来是这样发生硬件异常的,但是计算机具体是怎么完成这一系列的操作的,这个博主后看画图给大家进行讲解。
先来看看硬件异常的现象:
(1)除0异常
#include<iostream>
#include<unistd.h>
using namespace std;
int main()
{
cout<<"div before"<<endl;
sleep(2);
int a = 10;
a/=0;
cout<<a<<endl;
cout<<"div after"<<endl;
return 0;
}
我们看到遇到除0之后,我们看不到div after这个代码了,而且我们看到的是浮点数错误,看名字对应的应该是8号信号,他的默认处理动作是退出程序,但是你怎么保证就是8号信号??我们换成默认动作看看,还是之前的自定义函数myhandle
我们看到我们的除0确实是收到了8号信号,并且收到异常我们的程序还没有退出,原因是我们修改了默认处理动作,被自定义捕捉了。我们通过man 7 signal
也看出来他的默认动作是退出程序。
问题:我们的程序没有写循环,我程序出现异常了,你os发一次信号不就行了,为什么一直发信号??一会回答-
(2)野指针异常
按照上面的方式,来验证一下野指针异常
int main()
{
//signal(8,myhandle);
cout<<"ptr before"<<endl;
sleep(2);
int *p=NULL;
*p=100;
cout<<"ptr after"<<endl;
return 0;
}
这是段错误,收到的是11号信号,来验证确实收到了11号信号:
我们发现和除0错误的效果是一样的,程序确实收到11号信号,而且修改了自定义捕捉进程就不退出了,我们的11号信号默认的处理动作也是退出程序
问题:我们的程序和上面一样怎么也是循环收到信号,一会解决。
总结:通过上面的两个案例说明,我们的硬件异常会收到信号,也就是硬件异常让信号产生了,同样硬件异常不一定会使进程退出,因为会捕捉,但是硬件的异常你没有办法去修改,就算不退出也没有意义,因为程序已经出错了,在跑下去的结果也不敢保证是正确的,所以自定义动作的时候,处理完自己的后序工作也让进程退出吧,那为什么设计者可以让这个两个信号的默认动作可以被修改呢,像9号或者19号哪些不被修改不就行了,原因来看下一章节
通过硬件的来分析上面出现的问题的原因,来看图解:
通过上面的图,大家应该知道我们的除0和野指针为什么叫硬件异常,因为这个两个操作都会有对应的硬件,而这些硬件在出产的时候就设定好了,不按照他的开发手册就会出现异常,不止这个两个硬件会发生异常,还是好多硬件都会,比如磁盘,网卡等
解决问题:
为什么我们之前上面的两个例子在修改默认处理动作的时候,会死循环的发送信号?原因是我们的pc指针指向除0代码的时候,会发送一次信号,此时下面的代码就没有执行的必要,所以pc指针会一直指向这段除0的代码,你的进程一直没有退出,所以就会被cpu不断的拿上拿下,所以状态寄存器里面一直会是溢出的,os说,我们发送一次信号说出现异常了,你还不退出,我又修改不了这个异常,你每次都会造成硬件出现问题,所以我只能一直给你发送信号,野指针循环打印也是这样,cpu通过虚拟地址访问,其实也就是pc指针指向了野指针那一块代码,后面的分析和前面的一样。
硬件异常的出现实际不是让你解决这个异常的,你也没有办法解决,他只是想让你处理一下后序工作,比如打日志,数据保存,大部分进程出现硬件异常都是直接退出,如果os让所有进程遇到硬件异常直接退出,那么我们的后序工作都做不了了1,所以已经出异常,但是我们的异常代码还是可以执行的,比如上面的例子,我们遇到除0异常,还是可以执行默认或者自定义的处理动作,这不就是信号的产生我们没有立即处理吗??因为要给用户最大的宽容度,万一进程出现异常,直接给进程干掉,又重要数据的话,os就要担责,所以出异常了就给进程发送一个信号,你自己自定义处理动作,但是我也给你一个默认动作,,这也是硬件异常信号的处理动作可以被修改的原因。看到这里,大家在回头来看我们上面说的例子为什么博主要这样的设计案例给大家展示,这样可以将我们的知识展开讲,由不懂到懂的过程,在回头理解,这也是我们学习底层原理必须要做的,所以一般上面看现象,看完现象看本质。
我猜大家应该还有疑惑,这里博主就给大家一起解决了,我们的os是怎么知道寄存器里面由数据,大家不要忘记了os也是硬件的管理者,由对应的描述硬件的结构体,我们有对应读取硬件的方法,一旦数据有变化或者不对时,os里面就可以知道,就发送对应的信号给进程,那么进程是怎么收到信号,准确来说是进程的task_struct怎么收到信号的,大家还记得一开始博主就说过信号是内置在进程里面的,os直接修改进程结构体里面关于信号的属性不就可以了,大家还记得博主一开始说过的我们这篇就讲解31个信号吗,1-31,我们的tast_struct里面肯定有一个属性是int signal类似的,这31个信号数字很特殊,刚还就对应我们整型的每一个比特位,就是位图的结构,0号位置不同,用来做标记位,其余的刚好31位,对应每一个信号,但是这是针对普通信号
通过上面位图得出三大结论
- 比特位的内容是0还是1,表明是否收到信号
- 比特位的位置,表示信号的编号
- 所谓的“发信号”,本质就是os去修改task_struct的信号位图对应的比特位,写信好比较准确。
这个结论会在信号的保存这一章节去通过内核带大家演示的,这个目前先知道他是通过位图的方式去保存的,具体在内核是什么样方式去保存的一会再说。
2.2.2软件条件
第五种方式:
这个也是程序出现的问题,大家还记得博主在讲解匿名管道的时候,第四种情况,写端打开,读端关闭,此时是写端的进程就会被杀掉,因为写已经没有意义了,此时就会收到我们对应的13号信号,这个博主就不给大家演示了,看这篇博客.
今天重点给大家讲解的是alarm函数 和SIGALRM信号
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数
这个函数就是一个类似于闹钟的装置,参数是多长时间会出发这个闹钟,一般闹钟设置一次就会响一次,这个函数设置一次也就触发一次,来看验证:
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
int main()
{
int n=alarm(5);
while(1)
{
cout<<"i am a proc,pid"<<getpid()<<endl;
sleep(1);
}
return 0;
}
确实闹钟响了而且默认动作是退出进程,他收到的是14号信号,让我们来证明一下吧,使用myhandle函数:
确实是收到14号信号,而且闹钟只会响一次,那么多设置几个怎么办?
大家看到结果了吧
讲解这个一是来让大家看到这个信号的产生完全使由软件条件去产生的,而是博主接下来讲解一下类似于定时器的功能,在我们大厂,一般需要对服务器做定期检查,有时候看看程序有没有出现问题,接下来就简单的实现一下:
#include <iostream>
#include <string>
#include <vector>
#include <functional>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <stdlib.h>
using namespace std;
typedef function<void ()> func;
vector<func> callbacks;
uint64_t count = 0;
void showCount()
{
// cout << "进程捕捉到了一个信号,正在处理中: " << signum << " Pid: " << getpid() << endl;
cout << "final count : " << count << endl;
}
void showLog()
{
cout << "这个是日志功能" << endl;
}
void logUser()
{
if(fork() == 0)
{
execl("/usr/bin/who", "who", nullptr);
exit(1);
}
wait(nullptr);
}
// 定时器功能
// sig:
void catchSig(int signum)
{
for(auto &f : callbacks)
{
f();
}
alarm(1);
}
int main(int argc, char *argv[])
{
signal(SIGALRM, catchSig);
alarm(1);
callbacks.push_back(showCount);
callbacks.push_back(showLog);
callbacks.push_back(logUser);
while(true) count++;
return 0;
}
2.3 core dump
大家还记得我们在进程等待的时候,有一幅图吗??
当时在讲解这个图的时候,有一个比特位叫core dump没有讲解,今天我们就可以把他拿出来讲了
在2.2.1章节我们使用man 7 signal手的去查看信号的默认动作:
圈中都是信号默认处理动作都是退出进程,那么为什么有trem和core,这两个有什么区别呢,我们以2号和8号为例给大家演示效果,肯定是coredump有关系,我们使用进程等待,就core dump也打印出来看看:
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
#include<stdio.h>
#include<string.h>
using namespace std;
int main()
{
pid_t id=fork();
if(id==0)
{
int cnt=50;
while(cnt)
{
cout<<"child proc pid:"<<getpid()<<endl;
cnt--;
sleep(1);
if(cnt==50)
{
break;
}
}
exit(0);
}
int status;
pid_t ret=waitpid(id,&status,0);
if(ret==id)
{
cout<<"child wait suceeful:"<<"codeexit:"<<((status>>8)&0xff)<<" signal:"<<(status&0x7f)<<" coredump:"<<((status>>7)&1)<<endl;
}
return 0;
}
我们发现上面两个没有啥区别啊,coredump都是0,这是为什么??原因是 我们的云服务默认的core dump是关闭的,虚拟机默认应该是打开的 ,我们使用
ulimit -a
来查看:
我们使用下面的命令给他打开ulimit -c 10240
,这个大小随便给:
我们再来测试一下上面的代码
2号:
8号:
此时我们就看出来两者的区别了,有core功能的退出,不止是单纯的退出,而且 会把错误信息用文件的形式从内存中带到当前进程的目录下,这也叫核心转储,我们没有办法直接打开查看,要配合gdb,使用core-file
去加载刚才的文件:
我们将刚才的代码写的简单一点
int main()
{
int a=10;
a/=0;
return 0;
}
他可以帮助我们定位到错误,程序越大,这个好处越明显,coredump的具体含义就是我们的程序在退出收到默认是core的处理动作是是否生成core文件。
为什么云服务默认是关闭的,虚拟机默认是打开的,原因是云服务器是生产工具,他可能有一个小bug在有一天发生了,一直开机重启,那么就导致我们的一直生成core文件,这样问题最后就不是这个给小bug引起的,而是由于磁盘满了引起了,通过上面的图我们也可以发现这个文件内容不多但是大小挺大的,所以一般生产工具就默认关闭的,而虚拟机是个人工具,没有关系。
结论:通过上面的所有方式我们实现了信号的产生,可以说有信号在让我们的进程看到一些场景就可以自己去处理,不需要用户去搞1,就好比我们已经长大了,知道红绿灯了,不像小时候还需要家里人带,而且我们也提到发信号是怎么做到的,通过os去修改进程里面的位图属性的比特位就可以了,接下来我们将要讲解的时候信号的保存,来看下节
三、信号的保存
通过上卖弄的学习,相信大家对信号已经不在那么陌生了,我们通过修改位图属性来给进程写信号,那么既然有东西属性让os去写,这也侧面说明信号要被保存下来的,就好比,你把明天决定不吃的饭菜,放在冰箱里面保存有啥意义,信号也是,你放进去肯定是要保存的,所以博主就要通过内核带大家来看看我们的信号是如何被保存起来的,以及怎么执行处理方式的,并且通过一系列的函数来带大家看到位图属性里面的内容,再次来验证9,19号信号必须要执行默认方法,跟着博主的思路走下去吧。
3.1信号的其他相关概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。就是我们之前提到的时间窗口
- 进程可以选择阻塞 (Block )某个信号。就是屏蔽一个信号,就算收到信号也不执行处理动作
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
相信学过前面的知识,这上面五点大家应该清楚,大家可以按照生活中的一个例子,你讨厌一个老师,不想做他作业,就可以阻塞他布置的作业即使他没有布置,我也选择阻塞,反正他布置了我不写,有一天你发现这个老师还不错,你解除对他布置作业的阻塞,去完成作业,下次布置作业,你在记下来去写,这个过程叫做保存,按照这个例子,对比上面的五点去理解,会简单很多。
3.2在内核中表示
我们的进程内部其实有三张表,第一张表就是表示信号有没有被阻塞,第二个表就是表示收到那个信号,第三个就是要执行的方法,前两个表都是位图,只是用数组的形式表示出来了,第三张表就是对应执行的默认方法,这三张表,我们要横向看:
- 第一行:我们的1号信号没有被阻塞,也没有收到信号,没有修改,所以使用默认对应方法
- 第二行:我们的2号信号被阻塞,也收到2号信号了,但是被阻塞,默认方法就改成了忽略
- 第三行:我们的三号信号被忽略了,也没有受到信号,但是修改了处理方法,当收到信号也不会执行,知道解除阻塞才会执行
-
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子
中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。 -
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
-
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号
3.3sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略
3.4信号集操作函数
这些操作函数都是对我们刚才提到的两个表做操作的,忽略我们用户可以一开始就设定一下,但是pending表只能由os发信号去改变,不能我们一开始就设定,但是我们可以一直查看pending表里面的数据,接下来就是通过改变阻塞表的数据,来验证收到信号会不会因为阻塞被忽略,当阻塞已解除我们才会执行递达操作,将上面的五点进行验证。
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
- 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
- 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。
注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
我们的sigset-t其实也是一个类型,他定义变量也是在栈上开辟的,上面的五个函数的操作其实就是对这些比特位就操作,属于用户层的想要给系统的表中写入,就必须调用下面的两个系统函数
(1)sigprocmask:对block表做修改
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
第一个参数:
他是起功能的作用,第一个是向已经有的block表中添加新的set当中有的信号的屏蔽,重复就不会有变化
第二个是取消set当中有的信号的阻塞,第三个就是把block表中都设置和色图比特位一样,不管之前的block表中是怎样的。
第二个参数:就是传刚才操作定义的信号集变量的地址
第三个参数:不管上面的那种功能都会改变旧的block表,所以这是一个输出型参数,就旧表的内容带出来,目的是为了恢复。
(2)sigpending:对pending表进行读取数据
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
3.5验证3.1节的概念
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
void pritfpending(sigset_t *set)
{
for(int i=31;i>=1;i--)
{
if(sigismember(set,i))
{
cout<<"1";
}
else
{
cout<<"0";
}
}
cout<<endl;
}
void myhandle(int singno)
{
cout<<"catch signal:"<<singno<<"pid:"<<getpid()<<endl;
}
int main()
{
signal(2,myhandle);
sigset_t set;//定义一个
sigemptyset(&set);
//将二号信号进行屏蔽,并且写到block表中
sigaddset(&set, SIGINT);
sigprocmask(SIG_SETMASK, &set, NULL);
int cnt=0;
while(1)
{
int n=sigpending(&set);//读取pending表
if(n<0)continue;
pritfpending(&set);//打印pending表
sleep(1);
if(cnt==5)
{
kill(getpid(),2);
cout<<"我收到了二号信号,但是被阻塞不执行自定义动作,并且pending表的倒数第二位变成1"<<endl;
}
cnt++;
if(cnt==10)
{
cout << "unblock 2 signo,我将递达处理二号信号,处理完我的pending表的倒数第二位变成0" << endl;
sigprocmask(SIG_UNBLOCK, &set, NULL);//10秒后解除阻塞,再次执行递达动作,因为默认是退出看不到结果,所以进行捕捉一下
cout<<"五秒后我将再回收到一个2号信号"<<endl;
}
if(cnt==15)
{
cout<<"我收到了二号信号,但是没有被阻塞执行自定义动作"<<endl;
kill(getpid(),2);
}
}
return 0;
}
接下里带大家来看看在自定义哪里,我们的9号和19号不能被捕捉,那么在阻塞这里我们看看能不能被阻塞,我们将set的所有位置都设置为1,那么就以为这所有的信号都被屏蔽了,在给程序发送信号。来看案例:每次运行改变cnt的初始值
int main()
{
signal(2,myhandle);
sigset_t set;//定义一个
//sigemptyset(&set);
sigfillset(&set);
//将二号信号进行屏蔽,并且写到block表中
//sigaddset(&set, SIGINT);
sigprocmask(SIG_SETMASK, &set, NULL);
int cnt=1;
while(1)
{
kill(getpid(),cnt);
cout<<"pid:"<<getpid()<<"第"<<cnt<<"号信号可以被阻塞"<<endl;
int n=sigpending(&set);//读取pending表
if(n<0)continue;
pritfpending(&set);//打印pending表
sleep(1);
cnt++;
}
return 0;
}
os在自定义捕捉哪里都开始防着你了,在这不也防着你。
总结:相信讲到这里大家对于信号的保存应该理解了,还希望大家下来可以自己去测试一样。这个函数没有共享内存哪个函数复杂,这个函数理解了博主话的两份图就理解,不要把两个结构当成一个就行了,接下来博主将带大家认识信号的处理
四、信号的处理
4.1信号处理的流程
讲解上面的知识之后,我猜想同学对于信号的处理可能想不出来什么问题,就是执行我们的处理动作就行了啊,但是博主想讲的不只于此,我们之前说过信号的处理不一定立即处理,要在合适的时候,那么这个合适的时候到底是什么时候,当我们的进程从内核态返回到用户态的时候,会进行信号的检测和处理,系统调用是用户的代码去调用的,需要os自动的会做身份切换,从用户态切换到内核态,或者反着来,int80 这个汇编指令。接下来博主给大家来解释上面说的,我们要重谈进程地址空间,来看图解:
我们通过上面的图大约知道用户态和内核态的作用了,那让我们一起看看信号的处理是什么样的:
![在这里插入图片描
我们的信号收到默认和忽略的处理动作的时候,到第三步就处理完毕然后退回内核态返回给用户态,但是遇到自定义函数,就麻烦了,我们到第三步的时候,发现是自定义函数,要通过用户态来调用,那么为什么os自己不调用,按道理是可以的,但是os怕用户在这个函数里面做手脚,获取os的信息就不好的,所以不敢使用os的权限,所以就需要从内核态返回用户态去执行,处理完还要再一次回到内核态到第五步(因为我们的函数执行完需要把修改一些关于信号的一些其他属性,收尾工作 这时候就需要使用内核态去操作),然后最后再回到用户态,记忆图:
上面讲述完信号的处理流程,大家要注意的是,我们的用户态和内核态的关系,它两者是在进程运行期间需要频繁切换的,用户态和内核态和执行各的代码,虽然内核有权限执行用户的代码,但是它不愿意这样做,这个大家要明白。而且pending位图的1是什么时候变成0的,答案是信号处理之前,这个等会大家验证一下。我们先来可能看下一个知识点。
4.2 信号捕捉的另一个函数(sigaction)
这也是一个改变信号处理动作的函数,功能比signal更强大,让我们一起通过官方文档来看看这个函数是怎么去使用的吧
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体:将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
第一个参数:传入的是我们的信号
第二个参数:是一个结构体,里面有一些属性,我们只看圈住的,没圈住的是实时信号应该关注的。这个结构体里面存放的是关系我们信号的处理方法等
第三个参数:是一个输出型参数,和sigprocmask类似,这是返回一个修改之前的结构。
(1)有两个属性我们要研究,我们先看第一个属性,第二个一会在说:
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
void my_handler(int signo)
{
cout<<"catch signal "<<signo<<endl;
}
int main()
{
struct sigaction act,oact;
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));//初始化
act.sa_handler = my_handler;//将结构体里面的方法属性给修改成自定义函数
sigaction(2,&act,&oact);
while(true)
{
cout<<"i am a proc:"<<getpid()<<endl;
sleep(1);
}
return 0;
}
我们看到这个函数的效果和signal是没有什么区别。来看第二个属性
(2)第二个属性
大家有没有想过当我们的进程收到一个信号来了,被自定义捕捉了,那么此时他在执行自定义函数的时候,又来了一个同样的信号过来会怎么样,在4.1节我们说过,内核为什么不想帮助用户u1执行代码还要费尽心思返回用户态去执行,原因是怕用户在自定义函数有访问内核的操作,不安全,所以在自定义函数里面,我们的信号正在执行,又来了一个同样的信号,此时自定义函数里面万一有调用系统函数的接口,此时就要进入内核态,那么就又会对来了同样的信号进行检测和处理,而之前的进程还没有执行完毕呢,这样就套娃了,os为了防止这种事情的发生,当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果
在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字
- 我们先来验证是否执行一个信号时,又来了一个同样的信号,被屏蔽了:
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<string.h>
using namespace std;
void printpending()
{
sigset_t set;
sigpending(&set);
for(int i=31;i>=1;i--)
{
if(sigismember(&set,i))
{
cout<<"1";
}
else
{
cout<<"0";
}
}
cout<<endl;
}
void my_handler(int signo)
{
cout<<"catch signal "<<signo<<endl;
while(1)
{
printpending();
sleep(1);
}
}
int main()
{
struct sigaction act,oact;
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));
act.sa_handler = my_handler;//将结构体里面的方法属性给修改成自定义函数
sigaction(2,&act,&oact);
while(true)
{
cout<<"i am a proc:"<<getpid()<<endl;
sleep(1);
}
return 0;
}
我们通过打印pending表,发现来了同样的信号,没有被处理,因为pending位图会在信号递达的时候置成0,所以通过pending位图来间接反映block位图被屏蔽。
- 想要额外在屏蔽其他信号的来临怎么办,使用sa_mask,它是一个sigset_t类型的,所以要使用我们信号集操作函数去改变它的比特位,我们来看案例:
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<string.h>
using namespace std;
void printpending()
{
sigset_t set;
sigpending(&set);
for(int i=31;i>=1;i--)
{
if(sigismember(&set,i))
{
cout<<"1";
}
else
{
cout<<"0";
}
}
cout<<endl;
}
void my_handler(int signo)
{
cout<<"catch signal "<<signo<<endl;
while(1)
{
printpending();
sleep(1);
}
}
int main()
{
struct sigaction act,oact;
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask,3);//屏蔽其他信号
sigaddset(&act.sa_mask,4);
sigaddset(&act.sa_mask,5);
act.sa_handler = my_handler;//将结构体里面的方法属性给修改成自定义函数
sigaction(2,&act,&oact);
while(true)
{
cout<<"i am a proc:"<<getpid()<<endl;
sleep(1);
}
return 0;
}
我们想要达到的效果是在执行二号信号的时候,把我们3,4,5号信号也给屏蔽,我将对比来验证:
通过结果我们看到使用sa_mask可以在我们处理一个信号的时候,也屏蔽其他信号的,让每一次只执行一个信号的处理动作
总结:
上面就是我们说的信号处理的所有知识点,希望大家下去好好理解一下,对于信号的处理博主就讲解到这里,博主将会在下一篇博客中给大家在画一个详细的图来好好理解一下信号的处理,还有几个小知识点,都放在下一篇博客,希望大家过来支持
五、总结
博主画了小两周的时间写了快三万字,讲解了整个信号的过程,里面有的只是是关于硬件的补充,所有对大家来说有点陌生,但是硬件不会我们学习软件要完全掌握的,大家了解就好了,在想研究的深点,可能就要读者自己下来去查找资料,信号这一节也我们更充分的认识了进程他是怎么做到对信号的操作,解决了我们之前的一些疑问,如果读者还有什么问题在评论区告诉我你的问题。我会给大家解答的,话不多说了我们下篇进行信号的小知识点补充以及线程的前期补充知识点讲解。
⏰