阻塞信号
信号其他相关常见概念
实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号是可以被屏蔽或阻塞的,进程可以阻塞或屏蔽信号
阻塞和忽略的区别:忽略属于已经递达了,已经对信号做了忽略处理。阻塞根本不会对信号进行递达,让信号一直在排队。
在内核中示意图
pending就是位图,代表的含义是对应的信号是否被接收,若接收到了为1,否则为0,handler是函数指针数组,数组的下标就是信号的编号,方框里填的是函数地址
注意这俩个宏用0和1,0是默认,1是忽略
当获取到信号之后,handler并不是直接调用对应的函数,而是先强制类型转换判断是否为0或1,如果是0或1就执行0或1的动作,如果强转后不是0或1,当执行handle表时,若信号被忽略,pending位图会由1清0才会调用对应的函数
block表和阻塞有关
block也是位图,该位图结构和pending一模一样,位图中的内容代表的含义是对应的信号是否被阻塞,0代表未被阻塞,1代表被阻塞。
一个信号被处理,操作系统是怎样的处理过程呢?
操作系统向目标进程发送信号,其实就是在修改pending位图,接下来检测pengding位图,看哪些比特位是1,若有一个比特位为1,并不是直接调用该信号的处理方法,而是去block表看看该信号是否阻塞,如果被阻塞了,则对该信号不做任何处理,如果未被阻塞,我们才调用handler,执行handler的处理方法。
pending->block->handler
sigset_t
这是一个位图结构,是操作系统提供的数据类型,这个位图不允许用户自己进行操作,OS给我们提供了对应操作位图的方法。用户是可以直接使用该类型的,和用内置类型还有自定义类型没任何的差别。而且这个类型需要对应的系统接口,来完成对应的功能,其中系统接口需要的参数,可能就包含了sigset_t定义的变量或对象。
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_t来表示,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
block信号集:信号屏蔽字
信号集操作函数
#include <signal.h>
int sigemptyset(sigset_t *set);//清空位图
int sigfillset(sigset_t *set);//位图全为1
int sigaddset (sigset_t *set, int signo);//添加特定信号到信号集
int sigdelset(sigset_t *set, int signo);//删除某信号
int sigismember(const sigset_t *set, int signo);//判定一个信号是否在该位图中
sigpending
参数是一个信号集,通过该函数可获取当前调用进程的pending信号集,返回值成功就是0,失败错误码被设置
sigprocmask
检查并且更改block信号集,返回值成功0,失败设置错误码
第一个参数代表用什么方式调用,第三个参数时输出型参数,返回老的信号屏蔽字。
小测试
如果我们对所有的信号都进行了自定义捕捉--我们是不是就写了一个不会被异常或用户杀掉的进程?不是
如果我们对所有的信号都进行block--我们是不是就写了一个不会被异常或用户杀掉的进程?不是
如果我们将2号信号block,并且不断的获取当前进程的pengding信号集,如果我们突然发送一个2号信号,我们就该肉眼看到pengding信号集中,有一个比特位由0变到1
我们发现9号信号不可被捕捉。a验证完毕
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cassert>
static void showPending(sigset_t& pending)
{
for (int sig = 1; sig <= 31; sig++)
{
if (sigismember(&pending, sig))//sig信号是否在pending这个集合当中
std::cout << "1";
else
std::cout << "0";
}
std::cout << std::endl;
}
int main()
{
// 1. 定义信号集对象
sigset_t bset, obset;
sigset_t pending;
// 2. 初始化
sigemptyset(&bset);
sigemptyset(&obset);
sigemptyset(&pending);
// 3. 添加要进行屏蔽的信号
sigaddset(&bset, 2 /*SIGINT*/);
// 4. 设置set到内核中对应的进程内部[默认情况进程不会对任何信号进行block]
int n = sigprocmask(SIG_BLOCK, &bset, &obset);
assert(n == 0);
(void)n;
std::cout << "block 2 号信号成功...., pid: " << getpid() << std::endl;
// 5. 重复打印当前进程的pending信号集
int count = 0;
while (true)
{
// 5.1 获取当前进程的pending信号集
sigpending(&pending);
// 5.2 显示pending信号集中的没有被递达的信号
showPending(pending);
sleep(1);
}
return 0;
}
此时我们可以看到2号信号已经被屏蔽
屏蔽2号信号,仅仅是修改了当前的pengding位图,将对应2号信号的比特位由0变为1,并不代表当前收到了2号信号,我们当前没2号信号,pengding位图中2号信号对应的比特位依旧是0,我们现在可以确定的是如果发送了2号信号,2号信号一定不会被递达,这个2号信号就永远保存在pending表当中
我们发送二号信号后,该二号信号一直在pengding表中,我们此时正在打印的正是penging表,此时频幕上会出现1
c验证完毕
在c的基础上,我们对2号信号进行恢复,再观察现象
我们看到pengding中2号信号对应的比特位没有从1变成0,而是直接终止了,这是因为默认情况下恢复对于2号信号block的时候确实会进行递达,但是2号信号的默认处理动作是终止进程,因此我们需要对2号信号进行捕捉。
此时我们可以观察到pending位图由0变成了1
我们可以看出先打印捕捉,然后打印解除,我们的预期是先打印解除,然后再打印捕捉。这是因为我们在写代码的时候把捕捉放到了前面,若想先解除再捕捉,我们把解除这句话放在捕捉前面即可。
我们发现貌似没有一个接口可以用来设置pending位图,但是我们是可以获取的sigpending,因为所有的信号发送方式,都是修改pending位图的过程,所以我们不需要特定的接口来修改pending位图。
当捕捉方法执行完后,pending位图由1置0,便于下一次捕捉。
i=1; id=$(pidof mysignal); while [ $i -le 31 ]; do echo "i: $i, id: $id"; let i++;sleep 1; done
i=1; id=$(pidof mysignal); while [ $i -le 31 ]; do kill -$i $id; let i++;sleep 1; done
当我们发到9号信号的时候,进程被终止,说明9号信号不能被进行屏蔽或阻塞,9号信号不可被捕捉和屏蔽b验证完毕
当发到19号信号时,进程也会被终止,我们可以看到20号信号也没被block,但我们的进程并未终止,20号信号可能被忽略了。
信号处理
信号相关的数据字段都是在进程PCB内部,PCB内部属于内核范畴,要检测信号或检测当前信号是否被屏蔽一定在内核状态,当执行代码的状态叫用户态。
处理信号在内核态中,从内核态返回用户态的时候进行信号的检测和处理。
用户态是一个受管控的状态,如受访问权限的约束。
内核态是一个操作系统执行自己代码的一个状态,具备非常高的优先级,基本不受任何资源的约束和权限的管控。
为什么会进入内核态?
一般进行系统调用会进入内核态,当有缺陷陷阱异常等也会进入内核态。汇编语句中有int 80这条语句内置在了系统调用函数中,当执行这条语句后,我们便会进入内核态
用户级页表每个进程都有,而且不一样,因为进程具有独立性,每个进程都有3-4G的地址空间给内核用。
内核级页表可以被所有进程看到。内核级页表可以把内核地址空间中的数据映射到物理内存
软件层面:如我们要调用OPEN接口,OPEN相关的代码在内核地址空间当中,调用OPEN的时候跳转到内核地址空间,然后经过页表查找到对应的物理内存。
当执行进程切换的代码时:当进程时间片到了,操作系统底层硬件发送时钟中断,由于当前进程还在CPU上,操作系统在CPU中找到当前正在执行的进程,然后通过该进程的地址空间找到对应的切换进程的函数,然后在进程的上下文中进行切换,因此能直接访问该进程在CPU中的临时数据,然后所有的临时数据被压倒PCB当中,进而把进程放下去,然后选择一个进程再上来,操作系统继续使用下一个进程3-4G的地址空间和内核级页表,然后恢复上下文代码和数据,然后把上一个进程恢复上来。
内核是在所有进程的地址空间上下文数据跑的。
我们执行OS的代码依靠的是处于内核态还是用户态。
硬件层面:CPU寄存器有俩套,其中一套可见,另一套不可见(CPU自用),其中CR3寄存器中用比特位表示当前CPU的执行权限,如1表示内核态,3表示用户态。当执行int 80后,CPU内寄存器状态由内核态改为用户态
信号捕捉
当正在执行用户代码时,可能会遇到一些情况,而导致陷入内核,列入:进程的系统调用或进程时间片到了,此时会发生进程调度,会在当前进程的上下文执行调度函数。在执行调度函数之前会陷入内核。即时间片到了->陷入内核->执行调度函数,当陷入内核之后OS去查找原因,找为什么会陷入内核,当OS调度进程的时候,在调度期间进程可能会收到信号,即被调度。
当从用户态进入内核态,我们一定是在完成某种行为如打开文件或读写网络等等,当我们把所对应的工作做完了,OS就准备给我们从内核态返回到用户态,返回到曾经被中断的地方继续向后执行。
当返回的时候,OS会顺手处理信号,先检测当前进程所对应的pending位图,如果全为0,直接返回,若有1,再检测block位图,之后再根据block确定是否执行handle。当执行handle表时,若信号被忽略,pending位图会由1清0,然后直接返回继续执行剩下的代码。若是默认,默认大多数是终止,即不调度进程,把进程的PCB页表全部释放掉。我们之前的_exit就是进程终止,但这种终止方式不会返回用户态。系统中有一些系统调用会在进程终止之前,帮我们返回用户态,之后执行特定的方法。在进程终止的时候,帮我们执行设定好的用户返回调用。
有一些信号不会退出,如暂停,当某个进程收到暂停信号后,OS会把该进程放到等待队列里,然后重新选择进程去调度,所以该进程进入了暂停状态。
当我们检测到信号要被捕捉,要执行对应的信号捕捉方法时,我们身份是内核即操作系统,而且这个状态能执行用户设置好的handler方法。
OS能做到帮用户执行对应的handler方法,但是它不愿意,也不想。若以操作系统内核态的身份去执行handler,handler中若有非法操作,则会引起大麻烦。OS不相信任何人,因此不能用内核态执行用户的代码。因此执行handler方法时,需要从内核态变成用户态。
当我们把信号处理完毕,此时需将pending中的比特位由1置0,此时需要进入内核态修改,而且同时要返回用户陷入内核的地方,将曾经的代码继续向后执行(这里需要在内核态中将函数的栈帧结构等恢复,然后跳回用户态继续向后执行后续代码),这里从用户态到内核态的系统接口是sys_sigreturn。
横线上面是用户态,下面是内核态,蓝色圆圈代表状态的切换,箭头代表从某个状态跳转到另一个状态。