目录
写在前面的话
什么是信号
生活中的信号
Linux下的信号
Linux常见信号
Core核心转储
信号如何产生
键盘组合键
1.如何理解信号被进程保存
2.如何理解信号发送的本质
通过系统调用向进程发送信号
kill()
手动实现kill指令
raise()
abort()[非系统调用]
如何理解通过系统调用发送信号
软件条件产生信号
alarm()
如何理解软件条件产生信号
硬件异常产生信号
总结
信号捕捉初识
阻塞信号
信号一些相关的概念
信号在内核中的表示(信号如何保存的)
sigset_t信号集
信号集操作函数
sigpending
sigprocmask
函数的使用
信号捕捉
用户态和内核态的理解
信号捕捉流程
sigaction()
可重入函数
volatile
本文将详细的带读者了解Linux进程间通信方式——信号,整体分为三个部分来讲:
- 什么是信号
- 为什么要有信号
- 信号如何使用★
前两个部分主要处理信号产生前的相关问题:例如信号的概念以及如何产生等
最后一个部分是处理信号产生后的相关问题:例如信号的发送过程以及处理过程等,这一部分是所占比重较大的,所以会重点讲解。
本文章几乎涵盖了信号的所有内容,累计字数1.5w+,仔细读完一定会有所收获的!
让我们开始愉快的探索信号之旅吧
信号在我们生活中无处不在,比如红绿灯,闹钟,汽车转向灯等等,就以红绿灯为例,我们为什么知道绿灯的时候就可以走了呢,这是因为我们记住了对应场景下的信号 + 后续的动作,因为我们从小也说红灯停,绿灯行,所以你识别到了绿灯信号并执行通过的动作。
假设此时是红灯,我们依然知道是绿灯的时候才能通行,即如果特定的信号没有产生,但是我们依然知道该如何处理这个信号。
假设此时绿灯了,但恰巧此时有一个同学在你后面和你打招呼,于是你没有通行,而是和这位同学也打了打招呼,并没有立即通行,这就是在收到这个信号后,可能不会立即处理这个信号。
你们打完招呼后,你还记得此时是绿灯,所以你赶紧就过去了。当然看一眼红绿灯没关系哈哈,可能例子有点牵强。这就是说信号本身,在我们无法立即处理的时候,也要被暂时保存。
Linux下信号本质是一种 |通知| 机制,用户或者 操作系统通过发送指定的信号,告诉进程,某些时间已经发生,需要后续进行相应的处理。
结合上面生活中的例子,我们对信号作出以下结论:
a. 进程要处理信号,必须具备“识别”信号的能力(看到+处理动作)
b. 为什么进程可以“识别”信号?程序员在进程内部已经内置了处理信号的动作相关的代码。
c. 信号的产生时间是随机的,产生时进程可能在执行优先级更高的事情,所以对信号的处理,可能不是立即的!
d. 进程会临时记下对应的信号,方便后续的处理
e. 在什么时候处理呢?这个只能暂时回答是合适的时候。
g. 一般而言,信号的产生相对于进程是异步的,这个后面也会详细解释。
异步的概念现在可以先暂时了解一下,还是比较容易理解的,和同步对比着说:
同步(Synchronous)是指在执行一个操作时,必须等待该操作完成后才能继续执行下一个操作。换句话说,同步操作是按照顺序依次执行的,每个操作的完成都依赖于前一个操作的结果。
异步(Asynchronous)是指执行一个操作时,可以继续执行其他操作,而不需要等待当前操作的完成。异步操作通常会立即返回,随后在操作完成后通过通知、回调函数或轮询等方式来处理结果。
举个简单的例子来说明两者的区别,假设有一个下载文件的操作:
Linux下可以使用kill -l来查看所有的信号。
实时信号我们平常使用的非常少,所以不做过多的解释,一般只会在一些特殊的行业使用,比如一些智能型汽车会采用操作系统,比如踩刹车,这个动作需要立即执行或尽快处理,不能延迟,这个时候可采用实时信号。
所以我们只需要了解几个常用的普通的信号即可.
我们可以使用man 7 signal 来查看前31个信号的默认动作及原因。
其中默认动作:
1.Term(Terminate终止):进程收到信号后将立即终止
2.Ign(Ignore忽略):进程收到信号后将忽略它,不做任何操作。
3.Core:进程收到信号后将终止,并生成一个包含进程当前状态的核心转储文件。
只知道这三个就可以.
下面是一些常见的信号,默认动作及描述:
1.SIGHUP(Hangup):默认动作是终止进程。通常由终端挂断或网络连接断开时发送给控制进程。
2.SIGINT(Interrupt):默认动作是终止进程。通常由用户在终端上按下 Ctrl+C 发送给前台进程组。
9.SIGKILL(Kill):默认动作是立即终止进程。无法被捕获、忽略或阻塞,是"强制杀死"进程的信号。
10.SIGUSR1(User-defined signal 1):默认动作可以是终止进程或用户定义的操作。用户自定义信号1。
12.SIGUSR2(User-defined signal 2):默认动作可以是终止进程或用户定义的操作。用户自定义信号2
14.SIGALRM(Alarm clock):默认动作可以是终止进程或用户定义的操作。用于定时器事件
上面信号的默认动作有一个Core,这个有必要和大家说一下。
在Linux中,核心转储(core dump)是指将进程在崩溃或异常终止时的内存中相关核心数据映像转存到文件中中,以便进行后续的调试和分析
大家是否还记得我们在进程等待讲解waitpid的时候,有下面这张图:
这个code dump标志,就是用来标记该程序有没有发生核心转储。
例如,我们随便运行一个可执行程序,然后使用3号信号杀掉它,3号信号默认动作就是Core.
当我们向进程发送3号信号时,进程被终止了,于此同时还会多出来一个文件:
后缀正是进程的pid.
使用核心转储的前提是得打开它:
可以使用ulimit -c unlimited指令打开核心转储,然后再使用核心转储功能.
验证进程等待中的core dump标志位
看上面这张图,我们知道如果父进程等待的子进程是被信号所杀的,那么后7位是杀掉这个进程的信号编号,第8位是core dump,1/0代表有无发生核心转储
我们首先可以利用fork()创建一个子进程,然后子进程执行除0错误,父进程waitpid等待子进程,然后输出结果的后7位(&0x7F),和第8位((>>7)&1).
代码如下:
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程进行除0
int a = 100;
a /= 0;
exit(0);
}
int status = 0;
waitpid(id,&status,0);
cout << "父进程: " << getpid() << " 子进程: " << id \
<< "exit sig: "<< (status&0x7F) << " is core " << ((status >> 7) &1) <
然后我们运行一下这段程序:
可以发现信号是8号信号(SIGFPE浮点数错误),然后发生了核心转储.看上面的图,也看到8号信号默认动作时Core,发生核心转储的.
在我们使用linux时,通常想结束一个进程,都会使用ctrl +c快捷键来终止一个进程,它的本质就是通过键盘组合键向目标进程发送2号信号。
那进程是如何处理这个信号的呢?常见的有以下三种方式:
a.默认(进程自带的,即执行程序员已经写好的逻辑)
b.忽略(不对信号做任何处理,也是处理信号的一种方式)
c.自定义动作(捕捉信号,后面会说).
所以这个进程执行了默认动作2号信号,而2号信号是使进程退出的一种信号,所以会结束。
那么如何理解组合键变成信号呢?
在说这个问题之前,需要先说明下面两个问题:
被进程保存的信号,需要知道这两条信息:
a.是什么信号
b.是否产生
信号种类有很多,我们该如何把它管理起来呢?既然信号只有两种状态,那么我们就可以使用位图来将它们保存,可以创建一个unsigned int类型,每个比特位代表对应的信号,1代表该信号产生,0代表该信号没有被产生。
在进程PCB结构体内部就保存了这个信号位图字段。
刚才说了,信号是保存在位图中的,所以想发送一个信号,只需要将信号位图中的对应比特位修改为1即可.
信号位图在PCB(task_strcut)中,而只有OS能访问PCB中的数据结构,所以实则是OS在修改对应的信号位图.完成信号的“发送”过程.
知道了这两个问题的答案,我们再来回到刚才的问题:
如何理解组合键变成信号呢?
键盘的工作方式 是通过中断方式进行的,当然也能够识别组合键ctrl + c
具体流程是:OS解释组合键 --> 查找进程列表 -->找到前台运行的进程 -->OS写入对应的信号。
我们可以通过kill()函数向指定进程发送特定信号,该函数原型如下:
int kill(pid_t pid, int sig);
其中,第一个参数为收到信号的进程
第二个参数为发送的信号
返回值:
如果成功的话,会返回0,否则返回-1.
我们平常使用的kill指令,它的内部其实就是调用了这个kill()系统调用,因此我们也可以手动实现一个kill.
首先我们可以利用命令行参数获取用户的输入,我们想要的是 命令+选项+进程id,所以如果命令行参数的数量不等于3,那么就输出使用手册。然后再分别提前选项和进程id,传入kill()函数中,这样就完成了kill的一个实现。
代码如下:
static void Usage(string proc)
{
cout << "Usage:\r\n\t" << proc << " Signumber pid" << endl;
}
//./mykill 2 pid
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
int signumber = atoi(argv[1]);
int processid = atoi(argv[2]);
kill(processid,signumber);
}
ps:如果对命令行参数不明白的,可以参考我的这篇文章:传送门 最后一部分。
然后我们编译好后,就可以正常使用我们编写的kill了,我的文件名是mykill,所以
kill()是给指定的进程发送信号,而raise是让操作系统给自己发送一个信号。
函数原型:
int raise(int sig);
其中第一个参数为 向自己的进程发送的信号编号。
返回值:
可以看到发送成功返回0,失败返回非0.
这是一个由C语言库提供的函数 ,该函数的作用是终止当前进程,并使该进程生成一个核心转储文件(前提是核心转储被打开)。
调用abort()时,会自动向当前进程发送SIGABRT
信号,所以说不需要传入任何参数。
void abort(void);
也可以当做exit(),效果是一样的。只不过abort()可以生成一个核心转储文件,
通过上面说的,我们可以按如下流程 进行理解:
用户调用系统接口 ——> 执行OS对应的系统调用代码 ——> OS提取参数,或者设置特定的数值 ——> OS向目标进程写信号 ——> 修改对应信号的标记位 ——> 进程后续处理信号 ——>执行相对应的操作.
我们之前学过管道,知道父子进程通过管道一个进程读,另一个进程写。
如果此时关闭读端,然而写端一直在写,此时写入便没有了任何意义! 因此操作系统会自动终止对应的写端进程,通过发送13号信号 SIGPIPE.
alarm()
函数是一个用于设置定时器的函数,它会在指定的时间间隔后发送 SIGALRM
信号给调用进程。该函数可以用于实现一些定时的操作或执行超时处理。
该函数原型如下:
#include
unsigned int alarm(unsigned int seconds);
参数seconds为定时器的时间间隔,单位为秒。
函数返回值是上一个定时器的剩余时间,如果之前没有设置定时器,则返回0.
也就是说假设我先设了一个10秒的定时器,此时由于之前没有设置过定时器,所以返回0,然后过了5秒,又设置了一个定时器,此时这个定时器返回值为5.因为上一个定时器还剩余5秒。
例如我们可以计算一下,一秒钟内一个while循环可以执行几次:
int main(int argc, char* argv[])
{
alarm(1);
int count = 0;
while(true)
{
cout << "count: " << count++ << endl;
}
}
我们运行这段程序,可以发现一秒钟内被执行了3w+次,当然由于网络和设备原因,次数肯定会有所不同.
实际上CPU执行的速度很快,每秒可以执行上亿次操作,之所以最后就执行了3w多次,是因为大量的IO,消耗了很多时间导致的.
a.OS先识别到某种软件条件触发或者不满足。b.OS 构建信号,然后发送给指定的进程.
这种信号并不是由硬件或操作系统事件触发,而是由程序自身根据逻辑判断产生的。
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。
为什么除0错误是硬件异常呢?
1.首先我们要知道,进行计算的是CPU,它是个硬件。
2.CPU内部是有寄存器的,状态寄存器(位图),有对应的状态位标记,比如溢出或者其它错误,OS会自动进行计算完毕后的检测,如果溢出标记位是1,则OS识别出里面有溢出问题,立即只要找到当前谁在运行,提取进程的pid,然后OS向该进程发送信号,进程会在合适的时候进行处理。
3.一旦出现硬件异常,进程不一定会退出,而我们一般默认动作是退出。但是即便不退出,我们也做不了什么
知道了这些,我们看下面这个现象:
void catchSig(int signum)
{
sleep(1);
cout << "进程捕捉到了一个信号,正在处理中:" << signum << " Pid: "<< getpid() << endl;
}
int main(int argc, char* argv[])
{
signal(SIGFPE,catchSig);
int a = 100;
a /= 0;
while(true)
{
sleep(1);
}
}
这里用到了一个信号捕捉signal,这个马上就会讲解,现在知道它的意思是当收到对应信号时,不执行信号的默认动作,而是执行我们指定的函数。
我们看到有一个a/=0,它会触发 SIGFPE信号,然后执行我们对应的函数。下面进程就一直死循环运行。然后我们运行看一下结果:
我们发现它一直也在执行这个,但理论上说不应该发送一次信号,执行一次吗?
这是由于CPU寄存器中的异常一直没有被解决,正常来说,OS看到异常。会发送一个信号来终止进程,但这个信号被我们捕捉了,不再退出进程了,里面的异常就一直在寄存器中。
当进程调度的时候,这个寄存器里的异常也会被作为上下文带走,下次这个进程再被调度的时候,异常依然存在,然后OS继续发送信号,又被我们捕捉但不退出,这就是这种现象产生的原因。
同样地,那我么如何理解野指针或者越界问题呢?
首先我们都要通过地址找到目标位置,而语言上的地址都是虚拟地址,需要将虚拟地址转化为物理地址,转化需要通过页表+MMU转化,这样非法地址在进行MMU转化的时候一定会报错!
所有的信号,都有它的来源,但最终都是被OS识别,解释,并发送的!
信号捕捉(Signal handling)是指在程序中对接收到的信号做出相应的处理操作。当进程接收到一个信号时,可以通过信号捕捉机制来指定一个特定的处理行为,例如执行一个处理函数或采取特定的操作。
总结来说,就是不让特定的信号执行默认的动作,而是指定我们所指定的操作。
这里就需要用到signal()函数,函数原型如下:
#include
typedef void (*sighandler_t)(int);//函数指针
sighandler_t signal(int signum, sighandler_t handler);
该函数接受两个参数:signum和handler。
参数signum指定要捕捉的信号的编号。
handler是一个函数指针,指向处理该信号的自定义函数。
例如我们想修改一下2号信号的默认行为:
#include
#include #include #include using namespace std; // void catchSig(int signum) { cout << "进程捕捉到了一个信号,正在处理中: " << signum << "Pid: "<< getpid() << endl; } int main() { // signal(2,fun); 使用信号对应的数字也可以,但一般还是写名字比较好 signal(SIGINT,catchSig);//特定信号的处理动作,一般只有一个 //signal函数,仅仅是修改进程对特定信号的后续动作,不是直接调用对应的处理动作 //如果后续没有任何的SIGINT信号产生,catchSig就永远不会被调用 //... while(true) { cout << "I am a process, I am running, pid: " << getpid() << endl; sleep(1); } return 0; }
这里我们将2号信号的动作改为了catchSig函数所执行的动作,这样我们再收到2号信号时,便不会执行原来的终止操作,而是catchSig.
我们来编译运行一下:
我们发现每次我们按ctrl+c时,程序不会再终止,而是执行了我们指定的函数操作。
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
上面提到了那些概念,操作系统为了实现它,在内核中建了3张表,如下:
其中第一张表block代表该信号是否被阻塞。
第二张表代表的是否收到对应的信号。
第三张表代表该信号对应的处理动作。
所以可以按下面的理解解读上面的图:
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,
SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
block和pending一样,都是位图,block位图中的内容,代表的含义是对应的信号是否被阻塞。
handler是一个函数指针数组,每个元素指向对应的函数,即信号的处理动作。
那么一个信号被处理,是怎么样的一个过程呢?
首先OS发送信号,即向pending中对应位置将0改为1,然后此时不是直接去执行对应的handler方法,而是先检测对应的block是否为1,如果不为1,再执行对应的方法,否则被阻塞。
所以流程就是:
有了以上的认识,我们具体是怎么使用这些pending和block的呢,这位图具体是如何使用和实现的呢?
sigset_t
是 C 语言中的一个数据类型,用于表示一个信号集(signal set)。信号集是一组信号的集合,用于管理和处理信号。
sigset_t
可以看作是一个位向量,其中每个位代表一个信号。通过设置、清除和查询位的状态,可以控制信号集中信号的状态。这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集(block)中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集(pending)中“有效”和“无效”的含义是该信号是否处于未决状态.
每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的.
一共有以下几个函数:
#include
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);//signo为要在set中添加的信号
int sigdelset(sigset_t *set, int signo);//signo为要在set中删除的信号
int sigismember(const sigset_t *set, int signo);//判断signo有没有在set中,如果有返回1,否则返回0
- 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
- 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置为1,表示 该信号集的有效信号包括系统支持的所有信号。
- 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。
- 初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号.
- sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含
某种 信号,若包含则返回1,不包含则返回0,出错返回-1
这些函数的使用和位图类似,具体是如何使用的。后面我们会编码实现并使用这些函数。
sigpending
函数用于获取当前进程未决(pending)的信号集。
该函数的原型如下:
#include
int sigpending(sigset_t *set);
这个参数为输出型参数,用来接收当前pending信号集
返回值
可以看到当获取成功时会返回0,负责返回-1,并且设置错误码。
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
该函数原型如下:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
第一个参数为how,第二个参数为set,它们搭配着使用,有以下三个选项:
这里再简单解释一下:
SIG_BLOCK:表示将set中新的信号加入到当前信号屏蔽字中(block那张表)
SIG_UNBLOCK:表示将set中的信号从当前信号屏蔽字中解除
SIG_SETMASK:直接将当前信号屏蔽字 设置为 set,相当于完全替换了
最后一个参数为输出型参数oldset,获取修改前的信号屏蔽字的值(block表)。
返回值
同样地,如成功返回0,否则返回-1.
刚才上面说了那么多函数,现在我们要来使用一下这些函数。
比如我们先将2号信号block,然后发送一个2号信号,此时2号信号由于被block,然后就会一直在pending位图(对应的比特位为1)中
同时,while循环,10秒后我们解除对2号信号的block,此时2号信号应该会被执行,然后进程被终止。我们为了现象明显一些,将2号信号捕捉,进行一次输出,然后继续输出pending,里面2号信号对应的比特位为0。
代码如下:
static void handler(int signum)
{
cout << "捕捉到信号: " << signum << endl;
}
//打印pending位图中的信息
static void showPending(sigset_t& pending)
{
for(int sig = 1; sig <= 31; sig++)
{
//如果pending中对应的位为1,则输出1,否则输出0
if(sigismember(&pending,sig)) cout << "1 ";
else cout << "0 ";
}
cout << endl;
}
int main()
{
//0.方便测试,捕捉2号信号,使其不要退出
signal(2,handler);
//1.定义信号集
sigset_t bset, obset;
sigset_t pending;
//2.初始化
sigemptyset(&bset);
sigemptyset(&obset);
sigemptyset(&pending);
//3.添加要进行屏蔽的信号
sigaddset(&bset,2);
//4.设置set到内核中(默认情况进程不会任何信号blcok)
int n = sigprocmask(SIG_BLOCK,&bset,&obset);
assert(n == 0);
cout << "block 2 号成功...,pid: " << getpid() << endl;
int count = 0;
//5.重复打印当前进程的pending信号集
while(true)
{
//5.1获取当前pending信号集
sigpending(&pending);
//5.2显示pending信号集中没有被递达的信号
showPending(pending);
count++;
sleep(1);
if(count == 10)
{
sigprocmask(SIG_SETMASK,&obset,nullptr);
cout << "解除对于2号信号的block" << endl;
}
}
return 0;
}
大家可以仔细看一看这段代码,我对每一部分做了注释,然后注意函数的用法.
我们把效果图来看一下:
这里还有两个问题:
1.如果我们对所有信号都进行自定义捕捉 --- 那我们是不是就写了一个不会被异常或者用户杀掉的进程?
答案是不会的,9号信号不会被自定义捕捉,它是一个管理员信号。19号信号也是如此.
2.同样地,.如果我们对所有信号都进行block(阻塞) --- 那我们是不是就写了一个不会被异常或者用户杀掉的进程?
答案也是不会的,9号信号依然不会被阻塞,还有19号,但是其它的信号会被阻塞,无法正常执行。
可以用下面的代码验证:
//阻塞特定的信号
void blockSig(int sig)
{
sigset_t bset;
sigemptyset(&bset);
sigaddset(&bset,sig);
int n = sigprocmask(SIG_BLOCK,&bset,nullptr);
}
int main()
{
//循环遍历,分别将1-31号信号阻塞
for(int sig = 1; sig <= 31; sig++)
{
blockSig(sig);
}
sigset_t pending;
//不断获取pending位图,并且打印
while(true)
{
sigpending(&pending);
showPending(pending);
sleep(1);
}
return 0;
}
然后效果图:
当我们发送9号信号时,进程便被杀死了,没有被block阻塞住.
我们之前还提到过一个结论:信号产生之后,信号可能无法立即处理,会在合适的时候处理,那么这个合适的时候是什么时候呢?
我们知道,信号相关的数据字段都是在进程PCB内部,它属于内核范畴,那就必须由OS即内核状态的时候执行,而我们大部分执行代码的时候都是在用户态,所以想处理信号,必须先进入内核态,才能处理。
在内核态中,从内核态返回用户态的时候,会进行信号的检测和处理!
但是怎么会进入内核态呢?
通常在系统调用的,缺陷陷阱异常等都会进入内核态.
那这用户态和内核态到底是什么呢?
用户态:执行用户自己的代码,系统所处的状态叫做用户态。用户态是一种受监管的状态。
内核态:我们写的代码中,调用了系统接口,实际上就是调用了内核级的代码,这时候就需要内核级权限。内核态通常用来执行os代码,是一种权限非常高的状态。
当我们在执行我们自己的代码时,我们会执行用户空间中的代码和数据,当我们调用系统接口,进入内核态时,就会需要执行内核级别的代码,那么是如何找到内核级所对应的代码呢?
操作系统是也是一种软件,在开机时,会把内核各种代码和数据加载到物理内存,然后它是每个进程的地址空间都可以看到和共享的,内核空间保存了内核的虚拟地址,可以通过内核页表映射找到内存中操作系统内核的代码和数据。
当然既然它是操作系统的代码,我们用户也不能访问,只有在内核态时才能访问,但OS怎么知道我们是处于用户态还是内核态呢?
代码是加载到CPU中运行的,而CPU中有一个cr3寄存器,用来标识当前CPU的执行状态,比如1是内核态,3是用户态等,通过这种方式,我们便可以切换用户与内核态,并执行相应级别的代码了。
我们一般通过系统调用进入内核态,然后内核处理完准备返回用户态的时候,会先处理当前进程的信号,如果此时信号动作是自定义的,即捕捉到的信号,此时我们需要从内核态转化到用户态执行完自定义的handler方法,然后再返回到内核态,然后结束内核态再返回到用户态执行之前的代码。
如果信号动作是默认和忽略,就可以直接继续执行内核的代码,然后返回到用户态继续执行后面的代码。
上面说的,可能只凭文字比较抽象,下面用一张图来说明整个流程。
然后可以简化一下,成为下面这样:
每个篮圈代表状态的切换,箭头代表流程的方向。只要记住这样图,信号捕捉的流程就搞定了。
这个函数的作用也是信号捕捉,但是signal的作用更加强大一些。
我们先来看这个函数的原型:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
signum
参数指定了要捕捉的信号。例如SIGINT等,即
Ctrl+C)。
act
参数是一个指向struct sigaction
结构的指针,用于设置信号的处理方式。可以通过配置该结构的成员来定义信号处理程序和处理选项。常用的结构成员包括:
sa_handler
:指定信号处理程序的函数指针,用于自定义对信号的处理。可以将处理程序设置为一个函数或者使用SIG_IGN
表示忽略该信号,或者使用SIG_DFL
表示采用系统默认的处理方式。
sa_mask
:数据类型为sigset_t,表示是否启用信号屏蔽。
sa_flags
:这个成员用于设置信号处理的选项,例如是否启用信号重新启动(SA_RESTART
)
oldact
参数是一个指向struct sigaction
结构的指针,用于获取之前的信号处理程序和处理选项。相当于是一个输出型参数。
返回值
成功返回0,失败返回-1并且错误码被设置。
我们来使用一下它
void handler(int signum)
{
cout << "获取了一个信号: " << signum << endl;
}
int main()
{
// 内核数据结构,用户栈定义的
struct sigaction act, oact;
//初始化
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
act.sa_handler = handler;
//设置进调用进程的PCB中
sigaction(2,&act,&oact);
while (true)
{
sleep(1);
}
return 0;
}
这样我们在执行的时候,ctrl + c发送的2号信号便被捕捉到了,并执行了自定义动作。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。然后再执行被阻塞的那个信号。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字并执行刚才被阻塞的信号。
比如我们想在2号信号执行自定义处理动作的时候,也不要其它3,4,5,6号信号执行,即把它们也屏蔽,就设置sa_mask即可,如下:
首先大家看这么一种场景:
在main函数中,我们执行了insert函数,但是当insert函数执行了一半还没有执行完,就被进程调度了,然后此时恰好有一个信号,而这个信号又恰好执行的是自定义捕捉动作,也是执行insert函数,这样insert函数被信号的自定义动作执行了一次,然后返回到main主进程,继续执行insert,又执行了一次insert函数,这样由于时序问题导致了链表的插入错误。因为一个函数同时被进入了两次。
以上是大概理解, 下面是对上面那张图的详细说明:
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中.
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之
可重入函数(reentrant function)是一个在多个执行实例中能够正确工作的函数。它只访问自己的局部变量或参数,可以被同时调用多次而不会出现不正确的结果或产生竞态条件。
这个关键字其实之前在C语言中也有所了解,就是阻止编译器对一些变量进行优化,现在我们在信号的角度重新理解一下这个关键字。
首先大家先看这么一段代码:
int flag = 0;
void handler(int signum)
{
cout << "pid :" << getpid () << " get a signal" << signum << endl;
flag = 1;
}
int main()
{
signal(2,handler);
while(!flag);
cout << "Quit" << endl;
return 0;
}
这段代码时对2号信号进行捕捉,然后修改全局变量flag 的值为1,然后继续执行main后面的代码,此时由于flag是1,!flag = 0,所以此时并不会进入死循环,然后输出“Quit”,
确实如我们所想的,但是如果我们把编译器优化级别改为O3(默认是O1或O2).
g++ -O3 mysignal.cc -o mysignal
此时我们发现陷入了死循环,这是为什么呢,flag不已经是1了吗?
这是因为编译器执行main函数时,发现没有修改flag变量的语句,而且flag是全局变量,就直接把flag=0填入到寄存器中了,下次取的时候直接从寄存器中取,速度快.
所以此时我们修改flag=1只是修改 内存中的值,而编译器每次只从寄存器中读取了,这就造成了错误。
为了避免这种情况,我们需要在这个全局变量前面加上volatile关键字,告诉编译器不要优化变了量,每次只从内存中取,而不要放到寄存器。
int volatile flag = 0;
此时我们再运行,便正常结束了
总之,volatile
是一个用于标识变量可能发生意外变化的关键字,用于告诉编译器不要对该变量进行优化,从而保证在特定情况下对变量的读取和写入的可见性和正确性。
至此,信号的全部内容就此结束了,非常感谢你的阅读,本章深入讲解了信号的大部分相关知识, 相信看完了之后,你对信号有一个全新的了解。