【Linux系统篇】:信号的生命周期---从触发到保存与捕捉的底层逻辑
✨感谢您阅读本篇文章,文章内容是个人学习笔记的整理,如果哪里有误的话还请您指正噢✨
✨ 个人主页:余辉zmh–CSDN博客
✨ 文章所属专栏:Linux篇–CSDN博客
文章目录
- 一.信号保存
- 1.什么是信号发送
- 2.信号其他相关常见概念
- 3.信号保存原理
- 4.信号集以及相关操作函数
- 5.`sigprocmask`函数
- 6.`sigpending`函数
- 二.信号捕捉
- 1.内核如何实现信号的捕捉
- 三.补充内容
- 1.可重入函数
- 2.volatile关键字
- 3.SIGCHLD信号
前言:关于信号的基础概念和信号产生相关的内容在上一篇文章中已经讲解,感兴趣的可以看上一篇文章。本篇文章重点讲解信号的保存与捕捉机制。
一.信号保存
1.什么是信号发送
信号是由操作系统发送给进程的,对于发送普通信号来说,实际上是发送给进程的PCB。
之前讲过信号编号1~31
对应的是普通信号,加上0号位置表示没有收到信号正好对应32个编号。
而一个整形有4字节,一字节是八比特位,所以正好有32个比特位。如果用一个整形位图来表示,1~31
个比特位正好对应31个普通信号。
比特位的位置(第几个),表示信号的编号;比特位的内容是0还是1,表明是否收到对应编号的信号。
所谓的“发信号”,本质上是系统去修改task_struct
的信号位图对应的比特位,实际上应该叫做“写信号”。
系统是进程的管理者,所以只有系统采用资格修改tast_struct
内部的属性!!!
2.信号其他相关常见概念
- 实际执行信号的处理动作叫做信号递达;
- 信号从产生到递达之间的状态的叫做信号未决(实际上就是信号保存阶段);
- 进程可以选择性的阻塞(屏蔽)某个信号,被阻塞的信号产生时将保持在未决状态,知道进程解除对该信号的阻塞,才能执行信号处理(递达);
- 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略则是在递达之后的一种处理动作。
3.信号保存原理
为什么要保存信号,因为信号产生后可能并不会立即处理,所以一定会存在一个时间窗口用来保存信号,此时的信号状态就是信号未决。
下图是信号在内核中的表示示意图:
pending
是一个位图结构,用来保存未决信号,信号产生到递达这一时间段的信号;下标对应信号编号,内容0表示信号没有产生,内容1表示信号产生,但还没有递达,处于未决状态。block
也是一个位图结构,用来保存被屏蔽的信号,下标对应信号编号,内容0表示该信号没有被屏蔽;内容1表示该信号被屏蔽(注意,屏蔽是一种状态,和有没有产生没有关系,比如在没有产生之间提前屏蔽掉,产生之后就会一直处于未决状态,不递达,直到解除屏蔽状态,比如图中的SIGOUIT
信号)。信号产生时默认为非屏蔽状态,进程可以选择该信号是否要屏蔽。handler
是一个函数指针数组,每一个信号都要有自己的一种处理方法;以信号编号为下标,相应位置存放的是该信号的处理方法(本质上是一个函数指针,用来调用对应的函数);如果是默认处理,存放的就是指向默认处理函数的指针。
每个信号都有两个标志位分别表示阻塞(block
)和未决(pending
),还有一个函数指针表示处理动作。信号产生时,系统在进程控制块中设置该信号的未决状态,直到信号递达才能清除该标志;如果被屏蔽,就会一直处于未决状态,直到解除屏蔽。
对于发送普通信号,因为只存在一个pengding
位图,所以系统多次发送某一个信号,最终只会被记录一次。
发送信号本质上是修改pengding
位图表,捕捉信号本质上是修改handle
表中的函数指针。
根据信号编号到handler
数组中找对应的处理方法这一过程和硬件中断非常相似,而信号其实就是模拟硬件中断来实现的。
4.信号集以及相关操作函数
1.什么是信号集:
信号集sigset_t
是系统提供的一种内核数据结构类型,方便用户对进程的pending
和block
位图表进行操作。这个类型可以表示每个信号的“有效(1)”和“无效(0)”状态,在阻塞信号集中有效和无效表示该信号是否被屏蔽(阻塞);而在未决信号集中有效和无效表示该信号是否处于未决状态。
//定义一个信号集
struct sigset_t set;
2.相关操作函数:
#include <signal.h>// 参数sigset_t *set表示目标信号集的地址 int signo 表示目标信号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 sigismembet(const sigset_t *set, int signo);
-
sigemptyset
函数用来初始化参数set指向的信号集,将其中所有信号的状态设置为无效,表示该信号集不包含任何有效的信号。 -
sigfillset
函数和上面的那个相反,将其中所有信号的状态设置为有效,表示该信号集不包含任何无效的信号。 -
sigaddset
函数将信号集中的指定信号修改成有效状态,表示添加。 -
sigdelset
函数将信号集中的指定信号修改成无效状态,表示删除。 -
sigismembet
函数用来判断信号集中的有效信号中是否包含目标信号,若包含则返回1,不包含则返回0,出错返回-1。
前四个函数的返回值都是成功返回0,失败返回-1。
注意:从定义一个信号集到通过相关函数进行操作,都只是在用户层面上对我们自己定义的这个信号集进行操作,并不是对进程PCB中的pengding
和block
位图进行操作。
5.sigprocmask
函数
调用该函数可以读取和更改进程的信号屏蔽字(阻塞信号集,就是进程的block
位图)。
#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oset);//返回值:成功返回0,失败返回-1
参数:
how
是标志位,不同的选项,函数实现功能不同:
how | set | oset |
---|---|---|
SIG_BLOCK | 向进程的信号屏蔽字(block 位图中)添加set信号集中的所有有效信号 | 获取进程更改前的信号屏蔽字(输出型参数) |
SIG_UNBLOCK | 向进程的信号屏蔽字中解除set信号集中的所有有效信号 | 获取进程修改前的信号屏蔽字 |
SIG_SETMASK | 将set信号集直接赋值给进程的信号屏蔽字 | 获取进程修改前的信号屏蔽字 |
6.sigpending
函数
#include <signal.h>int sigpending(sigset_t *set);//返回值:成功返回0,失败返回-1
用来获取当前进程的未决信号集(pending
位图),通过输出型参数set
传出。
测试:
通过一个程序用上面的几个函数做一个测试
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;void Print(const sigset_t &pending){for (int signo = 31; signo >= 1; signo--){cout << sigismember(&pending, signo);}cout << endl;
}void handler(int signo){cout << "catch a signo: " << signo << endl;
}int main(){// 0. 捕捉2号信号signal(2, handler);// 1. 先对2号信号进行屏蔽sigset_t bset, oset;sigemptyset(&bset);sigemptyset(&oset);sigaddset(&bset, 2); //此时只是在用户层面上将信号集中的2号信号屏蔽,并没有设置到进程的block位图中// 调用系统调用,将数据设置进内核中sigprocmask(SIG_SETMASK, &bset, &oset);// 2. 重复打印当前进程的未决信号集sigset_t pending;int cnt = 0;while (true){// 2.1 调用系统调用获取进程的pending位图int n = sigpending(&pending);if (n < 0){continue;}// 2.2 打印pending信号集Print(pending);sleep(1);cnt++;// 2.3 解除阻塞if(cnt==20){cout << "unblock 2 signo" << endl;sigprocmask(SIG_SETMASK, &oset, nullptr);}}// 3. 发送2号信号return 0;
}
二.信号捕捉
1.内核如何实现信号的捕捉
如果信号的处理动作是用户自定义的函数,在信号递达时就会调用这个函数,这个过程就是信号捕捉。
当进程从内核态返回到用户态的时候,会进行信号的检测和处理!
先来搞清楚什么是用户态,什么是内核态?
每个进程都有一个大小为4GB的地址空间,其中3GB为用户空间,通过用户级页表映射的是物理内存上用户的代码和数据;而剩余1GB则是内核空间,通过内核级页表影射的是物理内存上的操作系统的代码和数据。
因为进程具有独立性,有几个进程,系统中就有几份用户级页表;但是操作系统只有一个,所有进程都被同一个系统管理,所以内核级页表只有一个。
1.每一个进程看到的内核空间这1GB内容都是一样的,整个系统中,进程再怎么切换,这1GB的空间内容是不变的!!!
2.以进程的视角看待,调用系统中的方法(系统调用函数),就是在当前进程的地址空间中的内核空间进行执行的。
3.以系统的视角看待,任何一个时刻,都有进程在执行,想执行操作系统的代码(系统调用函数),就可以随时执行!
4.计算机硬件中有一个时钟芯片,每个很短的时间向系统发送时钟中断,系统收到中断,就会开始执行对应的方法,所以操作系统是由硬件的时钟中断来推动执行的,本质上就是一个基于时钟中断的死循环。
用户态:CPU执行进程时只能访问用户空间的内存区域,也就是只能用户的代码和数据;程序的大部分代码都在用户态执行。
内核态:CPU执行进程时,可以访问所有内存,包括用户空间和内核空间,也就是可以访问用户和系统的代码和数据;对于系统调用,中断处理,异常等只能在内核态执行。
状态切换:
- 用户态—>内核态:通过系统调用,中断,异常等触发
- 内核态—>用户态:系统调用完成或中断处理等结束时返回
状态标识:
CPU有一个ecs
寄存器,该寄存器中的其中两个比特位组合表示权限位:00
内核态;11
用户态。
由用户态切换到内核态(11--->00
),需要借助int 80
陷入内核。
信号的捕捉过程图:
由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序自定义了SIGQUIT
信号的处理函数sighandler
。 当前正在执行main
函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main
函数之前检查到有信号SIGQUIT
递达。 内核决定返回用户态后不是恢复main
函数的上下文继续执行,而是执行sighandler
函数,sighandler
函数和main
函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sighandler
函数返回后自动执行特殊的系统调用sigreturn
再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main
函数的上下文继续执行了。
三.补充内容
1.可重入函数
以链表的头插函数为例:
ListNode node1,node2,*head;void sighandler(int signo){...insert(&node2);...
}void insert(ListNode *p){p->next=head; //第一步head=p; //第二步
}int main(){...insert(&node1);...
}
假设当前mian函数正在执行头插函数,将node1
节点头插到链表中,刚做完第一步时,因为硬件中断进程切换到内核态,再次回用户态之前检查到有信号待处理,于是切换到用户态的sighandler
函数,而信号的自定义函数此时也需要调用头插函数,将node2
节点头插到链表中,插入完成后从用户态sighandler
函数返回到内核态,再次检查后没有其他信号待处理,所以重新返回到用户态的main
函数,先前完成第一步后被打断,所以现在继续执行第二步。
最后的结果就是main
函数和sighandler
函数先后向链表中插入两个节点,最后只有node1
一个节点真正插入到链表中,而node2
节点则会造成丢失,导致内存泄漏。
在上面的过程中,信号的sighandler
执行流和信号是否到来有关系,和main
函数没有关系。执行main
函数和信号处理是两套不同的逻辑。这两个过程是在一个进程的上下文里执行的,但是属于两种不同的执行流。
如果一个函数像上面的头插函数一样,被重复进入的情况下,导致出错或者可能出错,这个函数就是不可重入函数,否则就是可重入函数。
目前学过的大多数函数都是不可重入函数。
可重入和不可重入描述的是函数特点。
2.volatile关键字
站在信号的角度理解volatile
关键字。
测试代码:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;int flag = 0;void handler(int signo){flag = 1;cout << "catch a signo: " << signo << endl;
}int main(){signal(2, handler);while(!flag);cout << "process quit!" << endl;return 0;
}
正常编译之后,执行生成的可执行程序,结果如下:
收到2号信号后被捕捉,执行自定义动作,修改全局变量flag=1
,此时返回到main
函数后,while
条件不满足,退出循环,然后进程退出。
优化情况下,编译时加上-O3
选项,重新生成可执行程序并执行,结果如下:
收到2号信号被捕捉,执行自定义动作,修改全局变量flag=1
,此时返回到main
函数后,while
条件依旧满足,进程继续执行!
为什么会有这样的现象?
这是因为,在没有优化前,CPU寄存器中的flag
变量在每次执行条件判断时,会重新获取内存中的flag
变量的值,所以执行信号处理动作后,即使变量值修改,重新从内存中获取就会得到更改后的值,然后条件判断不满足,退出循环。
而优化后,CPU寄存器中的flag
变量只在第一次从内存中获取后,之后不再从内存中获取,后续执行条件判断时一直保持不变(一直为0不变),所以即使执行信号处理动作后,内存中的flag
变量修改,CPU寄存器中的flag
变量还是不变,所以就会继续循环执行。
如果在flag
变量前添加volatile
关键字:
volatile int flag=0;
上面的例子就是因为优化,导致内存不可见。而volatile
关键字的作用就是防止编译器过度优化,保持内存的可见性。
3.SIGCHLD信号
在之前学习进程等待的时候讲解过父进程可以通过wait
和waitpid
函数来回收子进程,父进程可以阻塞等待子进程结束,也可以非阻塞轮询的方式等待子进程结束;如果是第一种方式,父进程阻塞就不能继续处理自己的工作;而第二种方式父进程在处理自己工作的同时,还要不断轮询查看子进程是否结束。
但是父进程为什么会知道子进程什么时候退出的?
这是因为子进程并不是悄悄退出的,而是在退出的时候,主动向父进程发送一个SIGCHLD
信号(信号编号为17),该信号的默认处理动作是忽略,这就导致在表面上看起来父子进程什么都没做,父进程就知道子进程退出了。
父进程可以自定义处理函数,在自定义处理函数中调用wait
或waitpid
函数,这样父进程只需要专心处理自己的工作,不必关心子进程了,子进程终止退出时,会自动发送信号通知父进程,然后父进程捕捉信号,执行自定义处理动作,完成子进程回收。
这就是基于信号形式的等待。
通过一段代码进行测试:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;void handler(int signo){cout << "catch a signo: " << signo << endl;pid_t rid = waitpid(-1, nullptr, 0);if(rid>0){cout << "wait child success!" << endl;}
}int main(){signal(17, handler);pid_t id = fork();if(id==0){// childint cnt = 3;while(cnt>0){cout << "I am a child process, pid: " << getpid() << endl;sleep(1);cnt--;}cout << "child process quit!" << endl;exit(0);}// fatherint cnt = 5;while(cnt>0){cout << "I am a father process, pid: " << getpid() << endl;sleep(1);cnt--;}return 0;
}
以上就是关于信号第二部分:信号保存于信号捕捉的讲解,如果哪里有错的话,可以在评论区指正,也欢迎大家一起讨论学习,如果对你的学习有帮助的话,点点赞关注支持一下吧!!!