个人主页HABuo个人专栏《C系列》《Linux系列》《数据结构》《C语言系列》《Python系列》《YOLO系列》⛰️如果再也不能见到你祝你早安午安晚安目录一、信号其他相关常见概念二、 进程如何保存信号三、信号的处理流程2.1 进程地址空间第二讲2.2 深度理解信号的处理四、信号集操作函数五、信号捕捉(sigaction)六、扩充知识6.1 可重入函数6.2 volatile关键字6.3 SIGCHLD信号七、总结前言上篇博客我们介绍了信号的预备知识以及信号产生这篇博客我们就接着信号的流程往下走:信号的保存、信号递达等相关知识的学习知识繁杂。请耐心学习本章重点信号递达、信号未决、信号阻塞和忽略的区别、信号如何通过pending、block、handler保存信号的进程地址空间的内核空间如何映射到内存的信号的处理流程以及其余扩充知识一、信号其他相关常见概念在讲解进程是如何保存信号之前,要先了解下面几个概念:信号递达:实际执行处理信号的动作叫做信号的递达信号的未决状态:在信号产生到信号递达之间的状态叫做信号的未决状态信号阻塞:一个信号被设置了阻塞之后,那么此进程就不会收到此信号信号忽略:一个信号的处理动作被设置为忽略,那么当这个信号来临后,进程不会对此信号做处理阻塞和忽略的区别:一个信号被阻塞后,它就不会递达,而忽略是指信号递达后,执行的动作是不做处理二、 进程如何保存信号在进程的PCB中,存在三张和信号相关的表更准确的讲,前两个结构是位图,最后一个是表(数组).block位图:这个位图代表在这个进程中有哪些信号是被阻塞了的位图的第一个元素为0,代表1号信号没有被阻塞,第n个位置为1,代表n号信号被阻塞了,也就是说0/1代表某个信号是否被阻塞,这个位图又被称为信号屏蔽字pending位图:这个位图是进程存储信号的结构,位图中的第n个位置为0/1代表是否收到了n号信号,如若收到,会在后续进行处理,这个位图又被称为信号集handler数组:首先,这个数组是一个函数指针数组,里面存储的是函数的地址,数组的n号元素存储的函数地址代表收到n号信号之后,要去处理信号时,需要调用的函数.在上图中,SIG_DFL宏代表这个函数就是此信号的默认处理函数,SIG_IGN宏代表收到这个信号后,直接忽略此信号,当然我们也可以自己写一个函数来充当信号的处理方法上篇博客中我们自定义的handle方法就是储存在这里对上图的解释每个信号都有两个标志位分别表示阻塞block和未决pending还有一个函数指针表示处理动作。信号产生时内核在进程控制块中设置该信号的未决标志直到信号递达才清除该标志。在上图的例子中SIGHUP信号末阻塞也未产生过当它递达时执行默认处理动作。SIGINT信号产生过但正在被阻塞所以暂时不能递达。虽然它的处理动作是忽略但在没有解除阻塞之前不能忽略这个信号因为进程仍有机会改变处理动作之后再解除阻塞。SIGQUIT信号未产生过一旦产生SIGQUIT信号将被阻塞它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次将如何处理POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的常规信号在递达之前产生多次只计一次而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。三、信号的处理流程首先我们先清楚以下知识--- 信号什么时候进行处理当我们的进程从内核态允许用户访问操作系统的代码和数据 返回用户态只能访问用户自己的代码和数据的时候进行信号的检测与处理 --- 那么什么时候进程从用户态进入内核态呢 当调用系统调用的时候操作系统是会进行“身份”切换的用户态变成内核态也就是说想执行操作系统相关代码和接口需要权限的升级即变成内核态你才能执行也就是说用户态比内核态操作权限要低一般在访问硬件资源或者系统调用的时候需要将进程从用户态变为内核态来回的切换会降低效率因此要尽可能降低使用系统调用的频率那么OS怎么给我们的进程进行用户态和内核态的标识呢在切换过程中CPU利用内部的通用和控制寄存器临时保存用户态的现场如PC、通用寄存器值并建立起内核态的新场如设置SP指向内核栈同时通过其它寄存器明确标识当前所处的特权级别。这个过程是操作系统实现保护和隔离的基础。2.1 进程地址空间第二讲我是一个进程怎么跑到OS中执行方法呢每一个进程都有自己的地址空间用户空间独占内核空间被映射到了每一个进程的3~4G经内核级页表跳转到物理内存进程要访问OS的接口其实只需要在自己的地址空间上进行跳转就可以了整体的流程进程要访问系统调用先将进程的运行级别由用户态更改为内核态然后在虚拟地址空间中跳转至3-4G的内核空间经内核级页表跳转至物理内存中的操作系统代码和数据执行完毕后返回到虚拟地址空间对应位置再继续跳转至系统调用位置继续往下执行2.2 深度理解信号的处理信号递达的整个流程当程序执行系统调用时会进入到内核态当执行完系统调用后,会回到用户态当然回之前会做下述处理在这期间会进行信号的检测和处理如若此时检测到有信号到来,那么会把代码直接跳转到信号处理的函数处如果是默认和忽略行为就直接进行执行执行完成后直接从内核态返回用户态回到对应的方法调用处继续执行如果是捕捉信号做自定义方法就需要将内核态转到用户态进行执行执行完成后经过再返回内核态经过特定的调用再将内核态返回用户态我们自己执行的代码处。具体如下图对信号进行自定义捕捉流程简化一下,就得到了一个无穷大的图因此整体的流程是重点当进程执行到系统调用或者进程切换时进程会先从用户态转到内核态然后去执行相应的操作执行完成后会去进行信号检测先去block中检测对应信号是否阻塞如果阻塞就不用进行下一步如果没有再去pending中检测是否有该信号如果没有则结束有进行下一步找到对应的函数指针数组表默认和忽略操作直接操作系统帮我们做了只有自定义需要进行另外的操作因为自定义代码是属于用户态因此进程必须转到用户态执行对应的方法因为操作系统不信任任何人虽然理论可以在内核态的情况下执行官用户态内容但是操作系统并不知道该方法的具体实现是什么因此进程必须要重新转到用户态去执行执行完成之后因为该方法并不知道上下文如何因此进程必须再次进入内核态通过相应的系统调用再次回退到用户态的上下文代码四、信号集操作函数第一个参数代表要设置几号信号第二个参数代表,信号到来需要调用哪个函数这在上篇博客中我们已经使用过读取或更改信号屏蔽字:实验代码#define MAX_SIGNUM 31 static vectorint sigarr { 2 }; static void show_pending(const sigset_t pending) { for (int signo MAX_SIGNUM; signo 1; signo--){ if (sigismember(pending, signo)) cout 1; else cout 0; } cout \n; } static void myhandler(int signo) { cout signo 号信号已经被递达!! endl; } int main() { for (const auto sig : sigarr) signal(sig, myhandler); // 1. 先尝试屏蔽指定的信号 sigset_t block, oblock, pending; // 1.1 初始化 sigemptyset(block); sigemptyset(oblock); sigemptyset(pending); // 1.2 添加要屏蔽的信号 for (const auto sig : sigarr) sigaddset(block, sig); // 1.3 开始屏蔽,设置进内核(进程) sigprocmask(SIG_SETMASK, block, oblock); // 2. 遍历打印pengding信号集 int cnt 10; while (true) { // 2.1 初始化 sigemptyset(pending); // 2.2 获取它 sigpending(pending); // 2.3 打印它 show_pending(pending); sleep(1); if (cnt-- 0) { sigprocmask(SIG_SETMASK, oblock, block); // 一旦对特定信号进行解除屏蔽一般OS要至少立马递达一个信号 cout 恢复对信号的屏蔽不屏蔽任何信号\n; } } }五、信号捕捉(sigaction)#include signal.h int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);先说结论--- 当我们进行正在递达某一个信号期间同类型信号无法被递达 --- 当当前信号正在被捕捉系统会自动将当前信号加入到进程的信号屏蔽字block --- 当信号完成捕提动作系统又会自动解除对该信号的屏蔽 --- 一般一个信号被解除屏蔽的时候会自动进行递达当前屏蔽信号 --- 如果该信号已经被pending的话没有就不做任何动作试验代码void Count(int cnt) { while(cnt) { printf(cnt: %2d\r, cnt); fflush(stdout); cnt--; sleep(1); } printf(\n); } void handler(int signo) { cout get a signo: signo 正在处理中... endl; Count(20); } int main() { struct sigaction act, oact; act.sa_handler handler; act.sa_flags 0; sigemptyset(act.sa_mask); sigaddset(act.sa_mask, 3); sigaction(SIGINT, act, oact); while(true) sleep(1); return 0; }现象当我们发送三个同类型的2号信号只递达了两次而且中间间隔了20秒在此期间我们发送3号信号无任何反应当2号信号递达之后才递达了3号信号解释2号信号递达两次原因第一个SIGINT到达进入处理函数handler。在此期间该信号会被自动加入进程的信号屏蔽字sa_flags 0未设置SA_NODEFER因此后续的SIGINT被阻塞。第二个SIGINT到达由于被阻塞变为pending状态此时只有一个pending记录。第三个SIGINT到达检查发现SIGINT已经pending于是该信号被直接丢弃。第一个handler执行 20 秒结束后信号屏蔽字恢复内核发现SIGINT仍处于pending状态于是递达该信号再次调用handler第二个 20 秒。3号信号最后递达原因假设当前进程的信号屏蔽字为old_mask最初可能是空集。当SIGINT信号 2递达时内核准备调用handler。内核会构造一个新的屏蔽字new_mask old_mask | {SIGINT} | sa_mask。{SIGINT}是当前正在处理的信号默认情况下也会被阻塞防止嵌套。sa_mask在这里是{SIGQUIT}信号 3。因此在handler执行期间信号 2 和信号 3 都被阻塞。handler返回后内核将进程的信号屏蔽字恢复为调用前的值即old_mask。此时信号 3 不再被阻塞因此之前pending的信号 3如果有就可以递达了。六、扩充知识6.1 可重入函数main函数调用insert函数向一个链表head中插入节点node1插入操作分为两步刚做完第一步的时候因为硬件中断使进程切换到内核再次回用户态之前检查到有信号待处理于是切换到sighandler函数sighandler也调用insert函数向同一个链表head中插入节点node2插入操作的两步都做完之后从 sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步 之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。导致了node2节点的内存泄露那我们就称insert函数为不可重入函数如果一个函数符合以下条件之一则是不可重入的:调用了malloc或free,因为malloc也是用全局链表来管理堆的。调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。6.2 volatile关键字volatile作用保持内存的可见性告知编译器被该关键字修饰的变量不允许被优化对该变量 的任何操作都必须在真实的内存中进行操作试验代码int quit 0; void handler(int signo) { printf(pid: %d, %d 号信号正在被捕捉!\n, getpid(), signo); printf(quit: %d, quit); quit 1; printf(- %d\n, quit); } int main() { signal(2, handler); while(!quit); printf(注意 我是正常退出的!\n); return 0; }现象当我们把Linux编译器的优化级别调高会出现不打印 “注意 我是正常退出的!”的情况而且进程不退出原因就是我们更改的数据在内存但是运算在cpu由于quit在while中进行判断操作系统认为你对它不进行更改因而就不再让quit 的值进行检测更新一直使用的是cpu中寄存器所储存的上下文信息即初始值因而导致quit始终为0解决办法volatile int quit 0;6.3 SIGCHLD信号进程等待部分子进程退出父进程等待那么父进程是如何得知子进程退出了呢事实上就是子进程退出后操作系统给父进程发送了SIGCHLD信号得知的因此进程等待便有了信号版本void handler(int signo) { //等待子进程 } int main() { signal(SIGCHLD, handler); printf(我是父进程, %d, ppid: %d\n, getpid(), getppid()); pid_t id fork(); if (id 0) { printf(我是子进程, %d, ppid: %d我要退出啦\n, getpid(), getppid()); exit(1); } while (1) sleep(1); return 0; }也可以这样做int main() { signal(SIGCHLD, SIG_IGN);// 显示的设置对SIGCHLD进行忽略 printf(我是父进程, %d, ppid: %d\n, getpid(), getppid()); pid_t id fork(); if (id 0) { printf(我是子进程, %d, ppid: %d我要退出啦\n, getpid(), getppid()); exit(1); } while (1) sleep(1); return 0; }由于UNIX的历史原因要想不产生僵尸进程还有另外一种办法父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN这样fork出来的子进程在终止时会自动清理掉不会产生僵尸进程也不会通知父进程。此方法对于Linux可用不保证在其它系统上能使用对于sigchld信号大家了解即可七、总结本篇博客我们了解了进程是如何保存信号的信号的处理流程是什么进程地址空间用户级页表是什么信号集操作函数信号捕捉可重入函数volatile关键字sigchld信号等。小结以下信号的其它常见概念信号递达、信号未决、信号忽略和信号阻塞信号保存pending、block、handler三张表的含义用户态和内核态通过CPU中相应的寄存器进行标识一般在访问硬件资源或者系统调用的时候需要将进程从用户态变为内核态。切换会影响效率进程地址空间第二讲每个进程通过虚拟地址空间中的内核空间经内核级页表映射到内存中独一份的操作系统代码!信号的处理流程当程序执行系统调用时会进入到内核态当执行完系统调用后,会回到用户态当然回之前会做下述处理在这期间会进行信号的检测和处理如若此时检测到有信号到来,那么会把代码直接跳转到信号处理的函数处如果是默认和忽略行为就直接进行执行执行完成后直接从内核态返回用户态回到对应的方法调用处继续执行如果是捕捉信号做自定义方法就需要将内核态转到用户态进行执行执行完成后经过再返回内核态经过特定的调用再将内核态返回用户态我们自己执行的代码处。信号操作函数signal、sigprocmask、sigemptyset、sigfillset、sigaddset、sigdelset、sigismember信号捕捉另一函数sigactionsigaction也是一个结构体了解其中的sa_handler、sa_mask最终还是推荐使用signal简单int sigaction(int signo, const struct sigaction *act, struct sigaction *oact)不可重入函数由于信号自定义捕捉或者其它方式导致执行流变换调用同一个函数导致内存泄漏或者其它问题我们称此类函数就是不可重入函数volatile关键字作用保持内存的可见性告知编译器被该关键字修饰的变量不允许被优化对该变量 的任何操作都必须在真实的内存中进行操作