Linux之信号的产生,保存,捕捉
Linux之信号的产生,保存,捕捉处理
- 一.信号的概念
- 1.1概念
- 1.2分类
- 二.信号的产生
- 2.1通过键盘产生的信号
- 2.2系统调用接口产生的信号
- 2.3硬件异常产生的信号
- 2.4软件条件产生的信号
- 三.信号的保存
- 四.信号的捕捉
- 五.信号的其他杂碎知识
- 5.1可重入函数
- 5.2volatile关键字
- 5.3SIGCHLD信号
一.信号的概念
1.1概念
在我们日常生活中也有着信号的存在,简单一点的就像红绿灯,狼烟,下课铃声,喇叭等等都算是信号,那么在我们发现这些信号产生的时候我们通常会做出一些对应的行为比如红灯停,绿灯行,下课铃声一响就去厕所还有发现狼烟就说明有外敌入侵。这说明我们是可以知道这些信号产生的时候我们要做是什么的那么为什么可以识别出来是因为我们从小会被教育说红灯停绿灯行,在历史书上学到狼烟的作用。并且我们有感官来识别出来这些不同信号的产生,所以面对信号从我们日常生活的经验来说可以总结出两步:先识别出是什么信号再根据自己的记忆或本能来做出对应的事情。
同时我们也能从生活中的信号挖掘出来几点现象:
- 信号没有产生的时候我们都已经知道如何处理对应的信号了
- 我们不知道信号什么时候产生,并且信号的产生相对于我们现在做的工作是异步产生的。可能我们正在骑车,突然后方就有人按喇叭提醒我们。喇叭声音的出现相对于我们骑车的动作就是异步产生的。
- 对应产生的信号我们不一定要立刻去执行它,我们可以先记住这个信号然后等到合适的时机再去执行。
而将生活中的现象代入到我们Linux中同样可以兼容,所以在Linux中我们面对信号要有预备处理和保存的能力,而Linux中的我们指的就是进程。
那么在Linux中信号的概念就是信号是进程之间事件异步通知的一种方式。也属于进程间通信的一种,但是与之前的管道,共享内存消息队列的作用不同。
1.2分类
在Linux中我们可以使用kill -l命令来查看所有的信号
而在Linux中一共存在31+31=62个信号,这62个信号分为两类:普通信号和实时信号(今天对于信号的研究只针对普通信号)。其中1到31是普通信号,34到62是实时信号。
二.信号的产生
对于信号的产生大致可以分为四种方式:通过键盘产生的信号,系统调用接口产生的信号,硬件异常产生的信号以及软件条件产生的信号,我们来一个一个的介绍。
在介绍产生信号之前我们要先知道,当一个进程接收到一个信号之后它是如何判断它接收到的是哪个信号单论普通信号就已经有31个了。如何才能最简便的识别是哪个信号呢?
利用位图,我们只要创建一个32位的位图(多带一个0号,但是0号信号不存在),比特位的位置来判断信号的编号,比特位的内容来判断是否接收到信号。
但是在接收到了信号之后还不够,记得我们说的我们在接收到信号之前就已经知道如何处理信号了,那么对进程来说也要提前知道如何处理信号。所以每个进程的内部都会有一张函数指针数组,下标代表了信号的编号而内容就是对应信号的处理方法。所以对于进程来说有关信号的数据结构已经有了两个:信号位图和函数指针数组。
在了解了这两个知识后我们就可以更好理解以前说的发信号是什么意思,准确来说应该是写信号。操作系统不是向进程发送信号而是通过更改进程的信号位图来写入信号,无论我们使用什么方法来产生信号底层都是让操作系统通过更改进程的信号位图来写入信号。而这个现象的原因则是因为操作系统是进程的管理者!
2.1通过键盘产生的信号
在我们之前想要关闭一个正在执行的进程时我们可以通过同时按下ctrl+c的方式终止进程,那么在今天我就可以告诉大家这就是通过产生信号的方式来终止进程。但是准确的来说是ctrl+c是终止前台进程,那么什么是前台进程,ctrl+c又是产生了哪个信号来终止前台进程呢?
在Linux中我们想要将一个可执行文件变为进程的方式就是通过./可执行文件的方式,并且我们会发现通过这种方式生成的进程在运行时我们是无法再进行指令操作的。
这是因为我们通过./可执行文件产生的进程是前台进程,而如何判断是不是前台进程的方式就是能不能接收用户的输入,这个进程在运行的时候是接收了我们的输入但是它不像shell有相应的处理方法所以表现出来就是无视我们的指令。在提到了shell的时候我们会想到shell不也是个进程吗?再联想刚刚说的如何判断是不是前台进程的方法我们就可以知道我们平时在没有主动让某个可执行文件成为前台进程的时候shell一直就是我们的前台进程。那么在主动生成了新的前台进程后shell哪去了?
这就要提到我们相对于前台进程的后台进程了,想要产生后台进程我们可以通过./可执行文件 &的指令。同时我们可以使用jobs指令来查看后台进程。
我们发现在将刚刚的可执行文件运行为后台进程后我们还可以输入指令来操作,这就说明此时的前台进程还是shell。并且我们使用jobs来产生的后台进程列表中还为其标注了编号,那我们也可以从可以编号中得知后台进程是可以具有多个的但是前台进程只能有一个,所以是否可以将后台进程提到前台,将前台进程移到后台呢?
所以我们来介绍两个命令fg -number可以让后台进程提到前台,而用ctrl+z就可以让前台进程暂停,但是前台进程是无法暂停的,所以这个前台进程会被移到后台中,再使用bg+number就可以让暂停的进程在后台中继续运行。
在触发这些现象的时候我们发现只要一运行前台进程,shell就会自动变为后台进程而只要暂停了前台进程shell就又会自动变为前台进程。所以操作系统对shell的设定就是会根据情况来自动调整它为前台进程还是后台进程。
我们又学习到了ctrl+z可以暂停进程,这同样也是通过信号来完成的。但是我们有没有考虑过一个问题:为什么操作系统会知道键盘中有数据输入了呢?我们只是按下了ctrl+c或者ctrl+z,操作系统就自动识别其中的内容,这个知识牵涉到了计算机组成原理的知识,我来和大家大概的说一下。
那么在知道了中断号后我们就要使用程序来读取对应硬件输入的数据,所以在操作系统内还存在着一张中断向量表,它是一个函数指针数组其下标就是代表了各中断号而指针则是指向着不同硬件的读取方式。
所以操作系统是如何知道键盘输入数据了呢?就是根据中断来的。
2.2系统调用接口产生的信号
- kill接口
在我们之前想要终止一个进程时不仅可以使用ctrl+c还可以利用kill命令来杀掉一个进程,而kill命令的底层就是一个系统调用接口kill函数
在知道了kill命令底层的逻辑后我们是否可以自己自定义一个kill命令呢?
//process.cc
#include <iostream>
#include <stdio.h>
#include <sys/types.h>
#include <signal.h>
int main(int argc, char* argv[])
{
if(argc != 3)
{
printf("\nUsge: kill -signumber pid");
printf("\n");
return 1;
}
int signumber = std::stoi((argv[1]+1));
int pid = std::stoi((argv[2]));
//printf("signumber:%d pid:%d\n",signumber,pid);
kill(pid,signumber);
return 0;
}
//test.cc
#include <iostream>
#include <unistd.h>
#include <stdio.h>
using namespace std;
int main()
{
while(1)
{
printf("i am a process pid:%d\n",getpid());
sleep(1);
}
return 0;
}
- raise接口
raise函数可以让进程向自己发送信号
同时为了更加清晰的看见进程接收到了信号我们介绍一个函数signal(),这个函数的作用就是修改对应信号的处理方法。
注意:为了避免进程无法被任何信号暂停杀死终止,所以9号信号是无法通过signal修改处理方法的。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
using namespace std;
void handle(int signo)
{
cout << "i accept a sig:" << signo << endl;
exit(0);
}
int main()
{
signal(2,handle);
int cnt = 0;
while(1)
{
cout << "i am a process pid:" << getpid() << endl;
sleep(1);
if(++cnt == 5)
{
raise(2);
}
}
return 0;
}
- abort接口
abort函数的作用就是像自己传递信号SIGABRT也就是6号信号,如果我们想要查看每个信号的作用是什么我们可以通过man 7 signal的指令。
我们发现SIGABRT的作用是Core也就是终止进程,所以我们使用了abort函数后进程就会被终止但是我们也可以使用signal来改变处理方法。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
using namespace std;
void handle(int signo)
{
cout << "i accept a sig:" << signo << endl;
exit(0);
}
int main()
{
signal(6,handle);
int cnt = 0;
while(1)
{
cout << "i am a process pid:" << getpid() << endl;
sleep(1);
if(++cnt == 5)
{
abort();
}
}
return 0;
}
2.3硬件异常产生的信号
在C语言中有一个从现实生活中带来的异常叫做除零异常,在当时我们一直觉得这是语言层面的错误,是C语言让我们无法除零。而在今天我要告诉大家这不是语言层面的错误这是由硬件异常然后产生信号直接将进程终止了所以我们无法除零。其中的过程我用图来为大家解释。
而我们要知道操作系统是软硬件资源的管理者,所以操作系统必然会知道cpu发生了溢出异常所以操作系统就会处理这个异常而处理异常的方法就是利用kill命令来杀掉导致异常发生的进程。
Floating point exception是浮点数错误,这也是由信号产生的我们可以通过信号列表来找到其对应的信号编号,随后我们可以利用handle来修改处理方法从而来验证我们的说法。
那么如果我们修改处理方法不让使得发出信号但是不让这个进程退出呢?会发生什么?
我们会发现进程一直在接收信号,这是为什么呢?
我们要清楚发生除零异常的根本是什么,是因为我们进程中编写的代码所以如果我们不让这个进程退出,那么就会形成一个循环:在进行运行到了除零操作后cpu检测出了溢出所以让status中的溢出标志位置1随后被操作系统发现并将其解释为终止进程,但是我们修改了处理方法使得进程没有被终止仍在继续运行所以溢出标志位仍然为1然后再被操作系统发现,以此为循环。
那么可能会有人问:为什么操作系统不在kill操作后将溢出标志位置0呢?
首先我们要知道寄存器不等于寄存器的内容,寄存器是硬件而寄存器的内容是属于对应的进程的所以严格来说他是属于进程的上下文。那么在发生除零错误之后只要操作系统把进程终止了那么也就不想要管这个溢出标志位了因为当cpu在调度其他的进程时就可以直接让此进程所携带的上下文也包括寄存器内容将其覆盖了。所以操作系统不需要修改寄存器内容只需要让后面的进程的寄存器内容覆盖之前的内容即可这样溢出标记位自然就会置为0了。对于操作系统来说,杀掉进程是默认的一种处理问题的方法。
除了除零异常我们还可以举例比如以前学习指针时碰到的野指针异常,这个在当时我们知道是产生了硬件的异常但是不知道其具体的内容所以今天我们就结合之前的知识来深入理解一下。
我们首先要介绍一个在cpu中的小硬件mmu,它的功能是将虚拟地址转化为物理地址。在我们学习了虚拟地址空间后我们知道我们定义的变量的地址都是虚拟地址,而当我们想要调用这个变量的时候是cpu通过页表和mmu来在寄存器中进行虚拟物理地址的转化的,而野指针异常的产生就是cpu无法在页表中找到对应的物理地址从而产生错误。所以这个异常的产生也是在cpu内的那么在这就和上面的除零异常相同,操作系统会发现并利用信号将对应的进程杀死。
2.4软件条件产生的信号
因为操作系统是软硬件资源的管理者所以在了解了因为硬件异常产生的信号后我们现在来了解因为软件条件从而产生的信号。
因为软件条件产生的信号其实我们之前在学习管道的时候已经见过了,就是当读端关闭后操作系统会将写端杀死。而杀死的方式就是通过信号,我们在信号列表中也能发现。
为什么这个管道的例子算是软件条件产生的信号呢?大家听管道的名字可能潜意识里觉得它是有关硬件的但是在我们学习了管道后我们知道管道其实就是个文件所以它是属于软件资源的。当我们将读端关闭后操作系统发现你的写端还是往管道文件里写入数据它就会觉得你读端都关闭了都没人读取了你还往里面写入数据干啥。所以操作系统就会利用信号将写端也关闭了,但是严格来说关闭读端算是因为软件异常而产生的信号。
那么在今天我们来使用另外一个例子:闹钟。
闹钟就是一种软件条件它是操作系统利用时间戳来产生的一种倒计时。并且由于我们可以设置很多的闹钟所以操作系统同时也需要管理闹钟,但是由于闹钟是具有顺序的即按照触发时间的顺序来排列闹钟所以闹钟的管理通常是使用我们之前在C++中学习到的一个数据结构:大小堆。
在Linux中存在一个闹钟函数alarm
那么我们来利用闹钟函数和signal函数来完成定时触发某些行为的操作。
三.信号的保存
在了解了信号的产生后我们现在要来学习信号是如何进行保存的,在这之前我们需要了解三个新名词:信号递达,信号未决,信号阻塞。
信号递达(Delivery):实际执行信号的处理动作。
信号未决(Penting):信号从产生到递达之间的状态。
信号阻塞 (Block):进程可以选择阻塞某个信号。
在了解了概念后我们来用可以理解的话来分别阐述一下这三个名称:
信号递达就是指对信号的处理包括信号的忽略,信号的默认操作以及我们通过signal函数产生的对信号的自定义操作。
注意:信号的忽略也是对于信号的一种处理方式,处理方式就是忽略,就好像你在路上你发现了你不喜欢的人然后你就心里说你要装看不见他也就是忽略他。忽略的前提是你已经发现他了然后对于发现他之后的处理方式是忽略他。
信号未决就是当操作系统正打算在进程的信号位图中修改位图的时候。
信号阻塞就是让信号一直处于在未决之后,递达之前的状态直到取消对信号的阻塞。
那么进程是如何保存信号的呢?
在学习了信号的递达,未决和阻塞后我们知道信号的保存分为三步,首先是判断信号是否未决然后是阻塞最后才是递达。所以根据先描述再组织的原理我们要使用数据结构来分别保存这三种状态那么使用什么数据结构呢?
还是位图和函数指针数组,我们只需要创建两个位图用代表未决的信号以及阻塞了的信号然后对于处理方法我们也只要使用一个函数指针数组来存储各个信号的处理方法即可。
我们不能光知道是如何保存信号的我们还要学习是如何对block表和pending表进行操作即信号集操作函数。
在了解信号集函数之前我们先介绍一个变量sigset_t,这个变量就是信号集也就是一个位图其可以代表信号的未决状况以及阻塞状况。
- sigemptyset
- sigfillset
- sigaddset/sigdelset
- sigigmember
注意:
1.在使用信号集变量前必须使用sigemptyset或sigfillset函数来进行初始化从而让信号集变量处于一种稳定的状态。之后才能使用sigaddset和sigdelset来增加或删除信号。
2.这五个函数的返回值都是成功为0,失败为-1。
- sigprocmask
- sigpending
我们在了解了信号集操作函数后我们可以做一些测试代码来使用一下这些函数。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
using namespace std;
void PrintfPending(sigset_t* set)
{
int cnt = 31;
for(cnt;cnt > 0;cnt--)
{
if(sigismember(set,cnt))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
void handle(int signo)
{
cout << "i accept a sig:" << signo << endl;
//打印pending表
sigset_t pending;
sigemptyset(&pending);
while(1)
{
sigpending(&pending);
PrintfPending(&pending);
sleep(1);
}
exit(0);
}
//信号集
int main()
{
signal(2,handle);
//创建信号集变量
sigset_t mask,omask;
//初始化信号集
sigemptyset(&mask);
sigemptyset(&omask);
//增添信号屏蔽
sigaddset(&mask,19);
//向block表中增加信号屏蔽
sigprocmask(SIG_BLOCK,&mask,&omask);
cout << "i am a process pid:" << getpid() << endl;
while(1)
{
sleep(1);
cout << "running..." << endl;
}
return 0;
}
注意:
在检测到某个信号要被递达时,会在执行处理方法前就将信号的未决标志位修改了。
操作系统不允许一个信号在短时间内被重复获取,所以会在接收到某个信号后在一定时间内屏蔽掉这个信号直到允许再次获取这个信号。
四.信号的捕捉
我们从生活中提取的信号的几个问题如今只剩下了在保存了信号之后要在合适的时间处理它,那么什么才算是合适的时间呢?
这个合适的时间就是当进程从内核态返回到用户态的时候再进行信号的检测与处理。
那么内核态和用户态分别又是什么意思呢?我先把概念告诉大家之后我会用图来详细描述捕捉的过程其中也会包含对内核态和用户态的讲解。
内核态:是一种操作系统的工作状态,可以访问大多数的系统资源。
用户态:是一种受控的工作状态,只能访问一部分的系统资源。
在了解了概念后简单来说内核态就是处于一种更高的权限可以访问更多的资源,而用户态是受控的只能访问一些让你访问的资源。而这个访问资源的差异我们可以在虚拟地址空间中也能看出来。
在我们学习虚拟地址空间的时候我们给整个虚拟地址空间分为两大块,一块是0到3GB的用户空间,一块是3到4GB的内核空间,我们对于虚拟地址空间的学习一直都停留在用户空间上没有涉及内核空间这是因为内核空间只有在内核态的时候才能访问而我们用户平时是接触不到内核态的。那么这1GB的内核空间里面存放了什么呢?存放的是操作系统的代码,数据和数据结构,那么这也就可以解释了为什么很多的函数是系统调用接口的封装但是我们仍然可以使用。
但是我们要知道想要访问内核空间必须是内核态才行那么进程想要访问系统调用接口也就必须转为内核态这又是如何做到的呢?
那么在了解了我们内核空间以及进程是如何变换状态的之后我们就来理解信号的捕捉的全过程吧,一样上图。
我们要在这个过程中注意几个问题:
- 为什么访问自定义方法的时候需要转换到用户态
这是因为内核态可以访问的资源更多如果有用户在自定义方法中编写一些只有内核态才能修改的数据并且修改后还是对操作系统不利的,那么操作系统就会变得不稳定。这也验证了我们之前说操作系统不相信任何用户就像银行那样的说法。 - 在整个信号的捕捉的过程中最多一共会经历四次转换状态:调用系统接口,执行自定义方法,通过sigreturn,输出返回值。所以整个过程我们可以进行简化。
五.信号的其他杂碎知识
在了解了信号的概念,信号的产生,保存和捕捉后我们基本就了解了信号的前世今生,现在我们来了解一些和信号有关的一些其他的知识。
5.1可重入函数
大家可能没有听过可重入函数的概念,我们先用一个例子来为大家讲解一下可重入函数的意思
只要我们复盘这个情况我们就会发现一个问题,main函数和递达时都需要头插一个结点而且main函数的头插还没做完时就插入了信号递达的头插所以最后只会有一个结点头插成功另外一个结点则头插失败。
像上面这种一个函数被不同的执行流重复调用,可能在一个函数还没返回的时候就被再次进入再次运行的情况就被叫做这个函数被重入了即重复进入。如果像上面的头插导致一次调用的函数调用成功另外一次调用的函数失败那么这个函数就被叫做不可重入函数,如果两个或多次调用互相不影响都成功了则将这个函数叫做可重入函数。
要注意可重入函数和不同重入函数是没有好坏之分的不能说可重入就是好的不可重入就是坏的,能否重入只是一个函数的性质而已。那么想要判断一个函数是否是可重入函数的方法就是观察这个函数是否使用了全局变量,一般使用了全局变量的函数都是不可重入函数。
5.2volatile关键字
volatile关键字可能大家在学习C语言的时候碰到过当时只告诉我们这个关键字的作用是当要求使用 volatile声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。
现在我们从信号的角度上去重新理解一下volatile。
但是这个是处于Debug模式下运行,我们在学习C语言的时候提到过当我们在使用Release模式下运行的时候编译器会对我们的代码进行优化来实现节省时间或空间的作用,但是当时我们就说到了这个优化有利有弊如果我们的代码很完美那么优化只会让其锦上添花如果我们的代码写的比较粗糙那么优化可能就会让其火上浇油甚至导致一些错误,而这次的代码就是这种效果。
如果我们在linux中使用Release模式优化代码只需要在使用编译器后加上-O2即可。
而这是如何造成的呢?
在我们没有使用优化时当我们运行到while循环的时候cpu的寄存器会一直读取判断内存中flag的值所以当我们在发出信号改变了flag的值的时候寄存器就会第一时间发现并且通过判断终止掉循环从而导致代码可以继续往下走。
但是当我们使用Release模式对代码进行优化了之后,cpu会直接将flag的值存在寄存器中所以当进行循环判断的时候cpu一直读取的都是寄存器中的值,所以当我们改变内存中flag的值后循环并没有停止因为那时候的cpu读取的根本不是内存中flag的值。
而我们volatile关键字的作用就是让cpu每次读取变量的时候都是从内存中读取即使是优化模式。简单来说volatile的作用就是保存内存的可见性。
5.3SIGCHLD信号
在我们学习fork的时候我们创建了子进程后我们只知道子进程在退出的时候需要父进程来回收但是其实子进程在退出不是什么都没做,反而它在退出的时候会向父进程发送一个信号SIGCHLD即17号信号
我们可以用代码来进行证明