文章目录
上一章主要讲述了信号的产生:【linux】进程信号——信号的产生
这篇文章主要讲后面两个过程。
一、阻塞信号
1.1 信号的相关概念
- 实际执行信号的处理动作称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
因为信号不是被立即处理的,所以在信号产生之后,递达之前的这个时间窗口称作信号未决,也就是把信号暂时保存起来。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
而且没有信号产生我们也可以选择阻塞某个信号。
1.2 在内核中的构成
我们知道发送信号的本质:修改PCB中的信号位图。 而阻塞和未决也是通过位图的方式来保存信号。它们的位图也存在于进程的PCB内。
位图的第几个比特位代表第几个信号。
对于block,比特位的内容代表是否阻塞信号。
对于pending,比特位的内容代表是否收到信号。
对于handler,他是一个函数指针数组,代表处理动作。
信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
而只要阻塞位图对应比特位为1,那么信号永远不能递达。
如果一个信号想要递达,那么pending位图对应的比特位为1,block位图对应的比特位为0。
总结:
1️⃣ 因为pending和block是两个位图,所以不会互相影响,一个信号没产生并不影响先被阻塞。
2️⃣ 进程能够识别信号是因为,内核当中有这三种结构,它们组合起来就能够识别信号。
3️⃣ 当有多个信号同时来的时候,因为位图只有一个比特位,所以只会处理一次,其他的信号都会被丢失。
二、捕捉信号概念
2.1 内核态和用户态
信号产生后不会立即进行处理,而是在合适的时候进行处理。那么什么时候是合适的时候呢?
从内核态返回用户态的时候进行处理。
如果用户态想要获得操作系统自身资源(getpid……)或者硬件资源(write……)的时候必须通过系统调用接口完成访问。
而我们无法以用户态的身份调用系统调用,必须让自己的状态变成内核态。
所以往往系统调用比较花费时间,我们应该避免频繁调用系统接口。
既然有内核态和用户态,那么我们怎么辨别我们当前是哪个身份呢?
CPU内存有寄存器,而寄存器又分为可见寄存器(EXP)和不可见寄存器(状态寄存器),而所有保存在寄存器跟当前进程强相关的数据叫做上下文数据。
CPU里面有一个叫做CR3的寄存器,它表征的就是当前进程的运行级别:
0表示内核态
3表示用户态
那么一个进程是如何进入操作系统中执行方法呢?
因为操作系统会加载到内存且只有一份,所以内核级页表也只需要一份。在CPU里有一块寄存器指向这个内核级页表,进程切换时这个寄存器不变。
所以进程可以在特定的区域内以内核级页表的方式访问操作系统的代码和数据。当进程想要访问OS的接口,直接在自己的进程地址空间跳转即可。
而我们知道操作系统有自己的保护机制,用户凭什么能执行访问操作系统数据的接口呢?
当想要跳转到内核区会进行权限认证,如果CR寄存器显示的是内核态就可以访问,反之阻止访问。但是我们怎么把用户态切换成内核态呢?当我们调用系统接口的时候,起始的位置会帮忙改变,先把CR3中的用户态改成内核态,然后再跳转到内核区。
2.2 信号捕捉流程图
当执行代码要调用系统调用接口时,本来应该调用完成后返回用户态继续执行代码。但是我们知道用户态和内核态的转换消耗时间很大,所以这里不会直接返回。
它会去找task_struct中的三张表先遍历block,当发现为1就跳过,如果不是1就看pending表如果为1就进入handler完成动作。而默认动作直接杀死进程,忽略动作直接把pending位图中的1置为0。
但是如果时自定义动作,我们自己写的handler方法在用户态,因为内核态不能直接访问用户态(从技术上可以,但是不能,为了安全),所以又要把自己的身份变成用户态再进入用户态执行handler方法。
当我们执行完handler后能不能直接返回代码区继续执行呢?
答案是不能,因为上下文信息都还在操作系统里。所以要先回到内核,经过特殊的系统调用回到代码区继续执行代码。
我们可以把线路简化一下方便观察:
分析:
这里的绿色部位交点代表身份的切换,而箭头的指向:
向下表示从用户态切换到内核态
向上表示从内核态切换到用户态
三、信号操作
经过上面的的学习我们知道了内核中有block和pending位图,为了方便我们操作,操作系统定义了一个类型sigset_t。
#include <signal.h> int sigemptyset(sigset_t *set);// 清0 int sigfillset(sigset_t *set); int sigaddset (sigset_t *set, int signo);// 比特位由0变为1 int sigdelset(sigset_t *set, int signo);// 比特位由1变为0 int sigismember(const sigset_t *set, int signo);
- 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
- 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。
- 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
- sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
3.1 sigset_t信号集
我们能看到阻塞和未决都是用一个比特位进行标记(非0即1),所以在用户层采用相同的类型sigset_t进行描述。这个类型表示每个信号有效和无效的状态:在阻塞信号集就表示是否处于阻塞;在未决信号集就表示是否处于未决。
阻塞信号集有一个专业的名词叫做信号屏蔽字。
3.2 信号集操作函数
sigset_t对每个信号用一个比特位表示有效或者无效的状态。它的底层操作对于我们用户层来说不必要知道,我们只能调用下面的接口函数来操作sigset_ t变量。
3.2.1 更改block表sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h> int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); RETURN VALUE sigprocmask() returns 0 on success and -1 on error. In the event of an error, errno is set to indicate the cause.
参数介绍:
how
:怎么修改。set
:主要是用来跟how一起使用,用来重置信号。oldset
:输出型参数,把老的信号屏蔽字保存,方便恢复
3.2.2 获取pending信号集sigpending
#include <signal.h> int sigpending(sigset_t *set); RETURN VALUE sigpending() returns 0 on success and -1 on error. In the event of an error, errno is set to indicate the cause.
读取当前进程的未决信号集,通过set参数传出。 set是输出型参数。
3.3 验证
首先要知道默认情况所有信号都不会被阻塞。获取pending表对应的比特位变成1。
而如果被阻塞了,信号永远不会被递达,获取pending表对应的比特位永远为1。
static void show_pending(const sigset_t &Pending) { // 信号只有1 ~ 31 for(int signo = 31; signo >= 1; signo--) { if(sigismember(&Pending, signo)) { std::cout << "1"; } else std::cout << "0"; } std::cout << std::endl; } int main() { sigset_t Block, oBlock, Pending; // 初始化全0 sigemptyset(&Block); sigemptyset(&oBlock); sigemptyset(&Pending); // 在Block集添加阻塞信号 sigaddset(&Block, 2); // 修改block表 sigprocmask(SIG_SETMASK, &Block, &oBlock); // 打印 while(true) { // 获取pending sigpending(&Pending); show_pending(Pending); sleep(1); } return 0; }
前面我们使用signal函数捕捉信号不能自定义捕捉9号信号,这里也是一样不能屏蔽9号信号。
当然我们也可以解除阻塞,让信号递达,信号一旦递达,pending就会先由1置0,然后就会处理信号,进程退出。
static void show_pending(const sigset_t &Pending) { // 信号只有1 ~ 31 for(int signo = 31; signo >= 1; signo--) { if(sigismember(&Pending, signo)) { std::cout << "1"; } else std::cout << "0"; } std::cout << std::endl; } int main() { sigset_t Block, oBlock, Pending; // 初始化全0 sigemptyset(&Block); sigemptyset(&oBlock); sigemptyset(&Pending); // 在Block集添加阻塞信号 sigaddset(&Block, 2); // 修改block表 sigprocmask(SIG_SETMASK, &Block, &oBlock); // 打印 int cnt = 8; while(true) { // 获取pending sigpending(&Pending); show_pending(Pending); sleep(1); if(--cnt == 0) { // 恢复 sigprocmask(SIG_SETMASK, &oBlock, &Block); std::cout << "恢复对信号的屏蔽" << std::endl; } } return 0; }
而为什么没有打印后面那句话呢?
因为进程在内核态直接退出来,就不会返回到用户态执行代码。
四、捕捉信号操作
4.1 内核捕捉信号sigaction
在上一章【linux】进程信号——信号的产生中我们学习了捕捉信号自定义函数signal
。
sighandler_t signal(int signum, sighandler_t handler);
而sigaction
使用起来要比signal
使用起来复杂。
#include <signal.h> int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数说明:
signum
代表指定的信号。act
是一个跟函数名同名的结构体,输入型参数。
struct sigaction { void (*sa_handler)(int); //自己写的方法 void (*sa_sigaction)(int, siginfo_t *, void *);// null sigset_t sa_mask;// 信号集 int sa_flags;// 设置0 void (*sa_restorer)(void);// null };
oldact
输出型参数,保存过去的数据,方便恢复。
话不多说,直接上代码:
#include <iostream> #include <signal.h> #include <unistd.h> void handler(int signo) { std::cout << "catch signo: " << signo << std::endl; } int main() { struct sigaction act, oact; // 初始化 act.sa_handler = handler; act.sa_flags = 0; sigemptyset(&act.sa_mask); sigaction(SIGINT, &act, &oact); while(1) sleep(1); return 0; }
我们可以看到它可以实现跟signal
函数一样的功能。
那么它跟signal
有什么区别呢?
我们想象这样一个场景:
假设我们在handler设置等待15秒的倒计时函数,先发送一个SIGINT信号,在自定义处理等待15s的期间再次发送一个SIGINT信号,那么会不会递归似的调用handler呢?
运行中:
结束:
现象:
我们发了许多的二号信号,但是只处理了两个。
当我们处理第一个信号的时候,后边的信号不会再次被提交,当处理完后,后续信号就会递达,但是一共就两个信号递达了,后续信号全部丢失了。
结论:
当我们正在处理一个递达的信号时,同类信号无法被递达,因为当前信号正在被捕捉时,系统会自动把该信号设置进信号屏蔽字中(block)
当信号完成捕捉动作,系统又会自动解除对该信号的屏蔽。
所以为什么我们发送了一堆的二号信号,处理完第一次后会处理第二次?
当一个信号被递达时,pending位图的位置就由1置为0,后边再次发送多个,又由0置为1(只有一个比特位所以只收到一个),当一个信号被解除屏蔽的时候,OS会去检查pending位图,如果被置1,就再次递达。
4.1.1 act.sa_mask参数
上面我们是捕获了2号信号,如果我们想在处理某种信号的时候顺便屏蔽其他信号,就可以添加进sa_mask信号集中。
可以看到处理二号信号的时候3号信号被屏蔽了,那么为什么最后3号信号会起作用呢?
sa_mask :在执行捕捉函数时,设置阻塞其它信号,sa mask进程阻塞信号集,退出捕捉函数后,还原回原有的阻塞信号集。
五、可重入函数
假设一种场景:一个信号的处理方法是给一个链表进行头插,现在我们在main函数调用头插,而在头插的过程触发了信号的捕捉动作,又要进行头插,这样就会导致失去了头节点的位置。
因为两个执行流重复进入insert函数导致出现错误,我们把insert函数叫做不可重入函数。
没出问题就叫做可重入函数。
可不可重入是个特性(中义词),我们用的大部分接口都是不可重入的。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
六、volatile关键字
#include <signal.h> #include <unistd.h> #include <iostream> #include <cstdio> int quit = 0; void handler(int signo) { printf(" %d signo is being caught\n",signo); printf("quit:%d\n",quit); quit = 1; printf("->%d\n",quit); } int main() { signal(2,handler); while(!quit); printf("i am quit\n"); return 0; }
这样退出是正常情况。但是如果我们让编译器进行优化:
可以看到quit确实被改为1了,但是却没有终止循环。
这里是因为编译器把quit数据优化到了寄存器中。
如果不优化,每次判断quit都需要从物理内存获取quit的内容:
而如果要优化,编译器看到main中while(!quit)
并没有被修改,所以直接把quit的值放进寄存器中,不用再从物理内存中获取。
而我们后边修改quit改的是内存中的quit,并不会印象到寄存器,所以不会退出循环。这是因为寄存器的存在遮盖了物理内存的quit的值。
而加上volatile关键字就可以避免这种情况。
volatile的作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作 。