在上一篇中,我们详细探讨了信号的准备知识和产生方式(如硬件异常、终端输入、kill命令、体系调用等)及其背后的操作体系行为。信号作为进程间异步通讯的核心机制,其生命周期远不止“产生”这一环节——信号的生存与处置惩罚才是实现可靠异步事件响应的关键。
一、信号其他相干常见概念
- 实际执行信号的处置惩罚动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择壅闭 (Block )某个信号。
- 被壅闭的信号产生时将保持在未决状态,直到进程解除对此信号的壅闭,才执行递达的动作.
- 注意,壅闭和忽略是不同的,只要信号被壅闭就不会递达,而忽略是在递达之后可选的一种处置惩罚动作。
二、信号生存
壅闭信号
在Linux信号机制中,壅闭(Block)与未决(Pending)是信号生命周期中两个关键状态,但其概念常被肴杂。通过一个生动的类比,我们可以清晰理解它们的本质区别与联系:
假设你是一王谢生,数学老师部署的作业类比为操作体系发送的信号:
- 信号产生:数学老师(进程)部署作业(发送信号),你将其记载在作业本上(未决状态)。
- 未决(Pending):作业已记载,但未开始处置惩罚(信号已接收但未被处置惩罚)。
- 壅闭(Block):若你因讨厌数学老师,决定暂时不处置惩罚他的作业(壅闭信号),但仍会记载全部部署的作业(未决状态),直到你改变态度(解除壅闭)。
- 信号抵达(Deliver):终极你选择完成作业(执行默认处置惩罚)、草草应付(忽略)或按本身的方法解题(自定义处置惩罚)。
壅闭 vs 忽略:本质区别
- 壅闭(Block)
- 耽误处置惩罚:信号被标记为未决,暂不处置惩罚,直到解除壅闭。
- 主动控制:即使信号未产生,也可预先设置壅闭(如“无论数学老师是否部署作业,我都拒绝处置惩罚”)。
- 忽略(Ignore)
- 处置惩罚方式之一:信号抵达时直接丢弃,属于信号处置惩罚的三种动作之一(默认、自定义、忽略)。
- 结果:忽略后信号不会被生存,也不会触发后续处置惩罚。
Linux信号处置惩罚的内核布局
在Linux内核中,信号的产生、生存与处置惩罚通过三张核心数据布局实现:未决信号集(Pending)、壅闭信号集(Block)和信号处置惩罚函数表(Handler)。这三张表共同管理信号的全生命周期,是理解信号异步处置惩罚机制的关键。
Linux通过两个位图管理信号状态:
- 未决信号集(Pending):记载已接收但未处置惩罚的信号。
- 操作体系向进程发信号,其实就是向目的进程的判定位图当中进行比特位设置。把比特位直接从0至1就代表的当前我们对应的进程收到了信号。
- 壅闭信号集(Block):定义哪些信号被暂时屏蔽(即使收到信号,也保持未决)。
- 若信号被壅闭,厥后续接收的实例会被丢弃(多次SIGINT仅保存一次)。
- SIGKILL(9号)和SIGSTOP(19号)不可被壅闭或忽略,确保管理员始终拥有进程控制权。
信号处置惩罚函数表(Handler)
- 本质:函数指针数组,每个元素对应一个信号的处置惩罚方式。
- 索引:数组下标为信号编号-1(如SIGINT对应下标1)。
- 处置惩罚方式:
- SIG_DFL:默认处置惩罚(如停止进程、忽略等)。
- SIG_IGN:忽略信号。
- 自定义函数:用户定义的信号处置惩罚函数。
- #include <iostream>
- #include <vector>
- #include <signal.h>
- #include <unistd.h>
- // #define BLOCK_SIGNAL 2
- #define MAX_SIGNUM 31
- using namespace std;
- // static vector<int> sigarr = {2,3};
- static vector<int> 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);
- // 3. 慢一点
- sleep(1);
- if(cnt-- == 0)
- {
- sigprocmask(SIG_SETMASK, &oblock, &block); // 一旦对特定信号进行解除屏蔽,一般OS要至少立马递达一个信号!
- cout << "恢复对信号的屏蔽,不屏蔽任何信号\n";
- }
- }
- }
复制代码 
进程为什么可以大概识别信号
由于程序员在设计信号这一套体系大概机制的时候,在内核当中已经为每个进程都设置好了。它对应的三种布局分别称为未决信号集(Pending)、壅闭信号集(Block)和信号处置惩罚函数表(Handler)。这三个布局组合起来就可以大概去完成识别信号的一个目的。
壅闭 ≠ 进程壅闭
- 信号壅闭:控制信号的接收与处置惩罚流程,属于信号管理机制。
- 进程壅闭:进程因等候资源(如I/O)进入就寝状态,属于调治范畴。二者毫无关联。
三、信号的捕捉
当一个进程正在运行时,他收到了一个信号。那么该信号在被收到之后,可能并不会被我们立即处置惩罚,而是在内核态返回用户态的时候才进行处置惩罚该信号。
用户态&&内核态
在谈这两个概念之前,我们先回首一下用户代码和内核代码。我们所写的代码,诸如数据布局的代码、算法的代码,还有本身所写的全部的代码,在编译运行之后,全部都是运行在用户态的。但是在用户态的时候,我们难免会访问两种资源。第一种叫做操作体系自身的资源,第二个叫做硬件资源。比如说我们现在有一个进程,他想调用getpid(),这个就叫做获取操作体系自身的资源,由于要访问内核数据布局,这些数据是由操作体系去维护的。我们也可能在未来访问我们的硬件资源,诸如调用printf,write,read的方法来读写磁盘,读写文件,大概读写显示器,这都叫做访问硬件资源。无论是操作体系自身的资源,照旧硬件资源,它都属于操作体系及其操作体系之下。我们用户在本身写的代码当中,为了访问这些资源,你必须直接或间接的去调用操作体系提供的接口。这批接口我们称之为体系调用。我们在调用体系调用的时候,其实除了你本身调体系调用这件事变。还有一个很紧张的事变就是普通用户无法以本身用户态的身份来执行体系调用,必须让本身的状态酿成内核态。以是体系调用一定会比普通的你本身写的函数调用本钱要高。以是频仍调用体系调用的程序,效率一般都不会特别高,以是我们一般只管避免叫做频仍调用体系调用。
一个进程,它在实际执行时,他一定要把本身的上下文信息投递到CPU当中。在CPU当中一定会存在着大量的寄存器,而寄存器我们一般可以将寄存器分别为两类。可见寄存器与不可见寄存器。无论是哪一种,其中这些寄存器当中有相称大的一部分都和进程强相干的。在进程当中是有特定用途的,比如会有一些寄存器直接就可以大概指向当前正在运行的进程的PCB,还有一些寄存器可以生存当前进程的用户级页表。CPU内部还集成了MMU地址转化单元,它根据页表起始地址在CPU内部就完成了虚拟到物理的转化。
虚拟地址到物理地址转化的时候是采用页表这种软件布局和MMU这种硬件布局,软硬件联合的方式。在寄存器中,硬件的速度远弘大于软件的速度,由于软件还要从内存和CPU来回迁移。但是硬件一旦电路分别好,它的可维护本钱比较高,维护性比较低。软件可以机动调整更改,软件来维护页表,硬件进行地址转化。进程等于内核数据布局+进程的代码和数据。数据在磁盘中,程序通过exec加载到内存中,操作体系会自动创建虚拟到物理之间的一个映射关系。可执行程序在编译的时候已经按照虚拟地址的方式在编译器内部给我们编译好了。加载好之后,操作体系会帮助我们创建对应的内核数据布局。
CR3寄存器里面有对应的比特位用来表征当前进程的状态,表征了当前进程的运行级别。对应的数字如果为零,表示内核态,如果为三,表示用户态。
凡是和当前进程强相干的,我们也就是说这个寄存器上直接生存或间接生存的是这个进程的相干数据。我们都称之为当前进程的上下文数据。当进程在切换的时候,它除了切换PCB地址空间页表这些东西,它其中还要帮我们去切换进程的是CPU内的匹配它对应的上下文。寄存器只有一套,但寄存器里面的值可以有多套。每个进程在切走的时候,可以把本身的上下文带走,当进程回来的时候再把本身的上下文拿回来。
- 用户态(毕业生身份)
- 权限限制:犹如小学毕业生只能访问教室等基础区域
- 操作限制:无法直接操作硬件或访问核心体系资源(类似无法进入校长办公室)
- 内核态(教师/校长身份)
- 特权模式:拥有体系完全控制权(可访问全部区域)
- 关键能力:直接操作硬件、管理内存、调治进程(类似审批教学操持)
- 典型场景:执行体系调用、处置惩罚停止、驱动硬件
再次理解地址空间
我们今天先容一下地址空间里面的内核空间。内核空间的映射的就是让当前进程去映射到操作体系的。每个进程都有本身的代码和数据,一定被放在物理内存的不同区域。为了进程保证独立性,每个进程都有本身独立的用户级页表。其实还在操作体系内部,它还维护了一张内核级页表。内核级页表是操作体系为了维护从虚拟到物理之间的操作体系级别的代码所构建的一张内核映射表。在开机的时候,是要把操作体系加载到内存的,由于操作体系只有一份,以是它的代码和数据在体系内就是唯一的,以是内核级页表只需要一份就足够了。以是每个进程都可以用内核级页表的方式去访问访问操作体系的代码和数据。访问操作体系的接口其实只需要在本身的地址空间上进行跳转即可。操作体系本身要做好一些权限管理,很多东西不答应你访问,最多只让你访问体系调用接口。
当我们的进程它在执行我们对应的想跳转到这个区域的时候,那么它必须得保证本身要更改一下我们当前进程所对应的运行级别,CPU内对应的就是CR3寄存器。实际上体系调用接口,它在最开始的时候,照旧在用户态执行的,然后在体系调用的开头通过Int 80陷入内核的方式使得用户态转为内核态,在体系调用末端再转换到用户态,会把你的用户权限从我们对应的叫做三号状态改成零号状态,然后才让你跳转到这个区域去访问操作体系的代码和数据。
无论是内核态照旧用户态,一定是当前这个进程正在运行。无非就是我当前的执行级别是用户态照旧内核态的。然后用的页表是用户级页表照旧内核级页表,访问的资源是操作体系的照旧我本身的,其中我们在状态变化的时候,意味着我们要做一大堆的资源切换。诸如我们要修改我们的状态寄存器,陷入内核进行函数跳转等等。这些都比较费时间, (我们好不容易切换成内核态了,为了提升效率,操作体系要多做一些事变)以是从内核态返回用户态时,他要做信号捕捉处置惩罚。那什么时候我会进入内核态呢?最典型的就是体系调用。第二个叫做进程切换。还有包括异常、缺陷、陷阱等等。一个进程在切换时,他没有被执行完,一定被放到运行队列大概等候队列。放过去的时候,他就一定要处于内核态,以操作体系的身份把我才能放过去。
能不能以内核态的身份执行用户态代码?
从技术上可以。但是从设计方面一定不可以,由于操作体系不信任任何人提供的接口。操作体系它无法直接去识别到你的逻辑毕竟是非法照旧合法。以是操作体系固然他以为他本身能,但是不敢。如果直接以内核态的身份去执行用户的代码很容易,这份代码如果被恶意分子使用,就可以以一个内核态的身份去进行一定水平的越权,进行一系列的非法动作。以是当我们在进行我们对应的捕捉,处置惩罚这个信号的时候,绝对不能以内核态的身份去执行,他要重新将身份更改为用户态。
信号捕捉全过程
大略图如下:由于横线和无穷大有四个交点,以是它一定在信号处置惩罚过程之中,如果是自定义动作,一定是需要四次状态切换。
相干体系调用函数
sigset_t
每个信号只有一个bit的未决标记,非0即1,不记载该信号产生了多少次,壅闭标记也是如许表示的。
因此,未决和壅闭标记可以用相同的数据范例sigset_t来存储,sigset_t称为信号集,这个范例可以表示每个信号的“有效”或“无效”状态,在壅闭信号会合“有效”和“无效”的含义是该信号是否被壅闭,而在未决信号会合“有效”和“无效”的含义是该信号是否处于未决状态。由于只有一个位图,以是只能改一次。你发很多次,他也是相称于只能捕捉一次
信号集操作函数
#include <signal.h>
int sigemptyset(sigset_t *set);// 初始化set所指向的信号集,使其中全部信号的对应bit清零,表示该信号集不包罗任何有效信号。
int sigfillset(sigset_t *set); //初始化set所指向的信号集,使其中全部信号的对应bit置位,表示 该信号集的有效信号包括体系支持的全部信号。
int sigaddset (sigset_t *set, int signo);//新增
int sigdelset(sigset_t *set, int signo);//清除
int sigismember(const sigset_t *set, int signo);//用于判断一个信号集的有效信号中是否包罗某种信号,若包罗则返回1,不包罗则返回0,堕落返回-1
- 注意,在使用sigset_ t范例的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号会合添加或删除某种有效信号。
- #include <stdio.h>
- #include <stdlib.h>
- #include <unistd.h>
- #include <signal.h>
- // volatile: 保持内存可见性!
- // volatile int quit = 0;
- void handler(int signo)
- {
- // 1. 我有非常多的子进程,在同一个时刻退出了
- // waitpid(-1) -> while(1)
- // 2. 我有非常多的子进程,在同一个时刻只有一部分退出了
- // while(1)
- // {
- // pid_t ret = waitpid(-1, NULL, WNOHANG);
- // if(ret == 0) break;
- // }
- // printf("pid: %d, %d 号信号,正在被捕捉!\n", getpid(), signo);
- // printf("quit: %d", quit);
- // quit = 1;
- // printf("-> %d\n", quit);
- }
- void Count(int cnt)
- {
- while (cnt)
- {
- printf("cnt: %2d\r", cnt);
- fflush(stdout);
- cnt--;
- sleep(1);
- }
- printf("\n");
- }
- int main()
- {
- // 显示的设置对SIGCHLD进行忽略
- signal(SIGCHLD, SIG_IGN);
- signal(SIGCHLD, SIG_DFL);
- printf("我是父进程, %d, ppid: %d\n", getpid(), getppid());
- pid_t id = fork();
- if (id == 0)
- {
- printf("我是子进程, %d, ppid: %d,我要退出啦\n", getpid(), getppid());
- Count(5);
- exit(1);
- }
- while (1)
- sleep(1);
- // signal(2, handler);
- // while(!quit);
- // printf("注意, 我是正常退出的!\n");
- return 0;
- }
- // #include <iostream>
- // #include <cstdio>
- // #include <signal.h>
- // #include <unistd.h>
- // using namespace std;
- // 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); // 当我们正在处理某一种信号的时候,我们也想顺便屏蔽其他信号,就可以添加到这个sa_mask中
- // sigaddset(&act.sa_mask, 3);
- // sigaction(SIGINT, &act, &oact);
- // while(true) sleep(1);
- // return 0;
- // }
复制代码 当某个信号的处置惩罚函数被调用时,操作体系会自动将当前信号加入到进程的信号屏蔽字(block)里面。然后当信号处置惩罚函数返回时,自动恢复为原来的信号屏蔽值。如许的话就保证当我们在一个时间段内大量的信号同样的信号产生时,我们并不会由于这个信号被重复高频次的调用而产生函数出现问题。比如说递归了大概是重复进入了如许的环境。那么同范例的信号捕捉方法,它一定是要串行执行的,这也减少了体系设计的一个难度大概本钱。
四、volatile关键字
我们站在信号的角度理解一下这个关键字
- #include <stdio.h>
- #include <signal.h>
- int flag = 0;
- void handler(int sig)
- {
- printf("chage flag 0 to 1\n");
- flag = 1;
- }
- int main()
- {
- signal(2, handler);
- while(!flag);
- printf("process quit normal\n");
- return 0;
- }
复制代码 标准环境下,键入 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 , while 条件不满足,退出循环,进程退出。优化环境下,键入 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 ,但是 while 条件仍旧满足,进程继承运行!但是很明显flag肯定已经被修改了,但是为何循环仍旧执行?很明显, while 循环查抄的flag,并不是内存中最新的flag,这就存在了数据二异性的问题。 while 检测的flag其实已经由于优化,被放在了CPU寄存器当中。如何办理呢?很明显需要 volatile。
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不答应被优化,对该变量的任何操作,都必须在真实的内存中进行操作
结语
以上就是我对 【Linux】进程信号(下) 的理解,以为这篇博客对你有帮助的,可以点赞收藏关注支持一波~ |