Linux-信号2
上篇我们讲到了信号的产生了,那么我们现在继续来讲解信号的保存!
什么是信号发送?
即将这些信号发送出去。
那,
在内核中,它是怎么样发送信号?
其实它是经过一张位图表。
对于普通信号而言,对于信号而言,自己有还是没有,收到哪一个信号?
本质上是给进程的PCB发。
1.比特位的内容是0还是1,表面它是否收到?
2.比特位的位置(第几个),表示信号的编号
3.所谓的“发信号”,本质就是OS去修改task_struct的信号位图对应的比特位。
写信号,意味着OS是进程的管理者,只有它有资格才能修改task_struct内部的属性!
为什么要有信号保存?
我们之前也说到过,进程收到信号后,可能不会立即去处理这个信号。信号不好被立即处理,就要有一个时间窗口!
信号的相关概念:
实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态,称为信号未决(Pending)。进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。
虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
POSIX.1允许系统递送该信号一次或多次。
Linux是这样实现的:常规信号在递达之前产生多次只计一次,
而实时信号在递达之前产生多次可以依次放在一个队列里。
OS系统不相信任何人,所以需要有系统调用接口!
信号的系统调用:


sigset_t是什么?
每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态
塞信号集也叫做当前进程的信号屏蔽(阻塞的意思)字(Signal Mask),
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的
函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零, 表示该信号集不包含 任何有效信号。 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位, 表示 该信号集的有效信号包括系统支持的所有信号。 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化, 使信号集处于确定的状态。 初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号成功返回0,出错返回-1 sigismember 包含某种信号返回信号的编号,不包含返回0,出错返回-1
sigprocmask信号屏蔽字
如果oldset是非空指针,则读取进程的当前信号屏蔽字通过oldset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oldset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值
SIG_BLOCK set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask|=set SIG_UNBLOCK set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask&=(~set) SIG_SETMASK 设置当前信号屏蔽字为set所指向的值,相当于mask=set
我们对上面的调用接口进行实验一下:
#include <iostream>
#include<unistd.h>
#include<signal.h>void PrintSig(sigset_t*set)
{ for(int i=0;i<32;i++){if(sigismember(set,i)){putchar('1');}else{putchar('0');}}puts("");
}int main()
{//定义信号集,并完成初始化sigset_t s,y;sigemptyset(&s);//增加信号sigaddset(&s,2);//设置阻塞信号集,阻塞SIGINT信号sigprocmask(SIG_BLOCK,&s,NULL);// 获取未决信号集while(true){sigpending(&y);PrintSig(&y);sleep(1);}return 0;
}
信号的处理
信号是什么时候被处理的?
当我们的进程从内核态返回到用户态的时候,进行信号的检测和处理!
那么,
内核态:允许你访问操作系统的代码和数据
用户态:只能访问自己的代码和数据
所以需要调用系统调用!
操作系统是自动会做“身份”切换的,用户身份变成内核身份 / 内核身份变成用户身份
eg:int 80 就是从用户态陷入内核态
x86架构(32位)下Linux系统中,用户态进程主动陷入内核态的中断指令,
1. 触发条件:用户态进程执行 int 80 指令时,会触发软中断(中断号80)。
2. 身份切换:CPU从“用户态”切换到“内核态”(权限提升,能访问内核资源)。
3. 系统调用处理:内核通过寄存器(如 eax 存系统调用号、 ebx/ecx 存参数)识别请求,执行对应的内核函数,处理完后再返回用户态。
第三次谈地址空间

那么,了解完了信号的处理过程后,我们来回答一下下面的问题:
1. 为什么所有信号产生最终都要OS执行?
因为OS是进程的管理者:进程本身没有权限直接操作硬件、调度资源,而信号涉及进程的状态变更(比如终止、暂停)、资源分配等,这些操作必须由OS(内核)统一管控,保证系统的安全性和资源协调。
2. 信号的处理是立即处理的吗?
不是立即处理的,而是在**“合适的时候”**处理:
进程在用户态运行时,无法被信号打断;只有当进程从用户态切换到内核态(比如发生系统调用、中断),执行完内核态操作后,才会检查是否有未处理的信号,再进行处理。(看我上面的信号处理过程图)
3. 信号不立即处理时,是否需要记录?记录在哪里?
需要暂时记录。
最合适的记录位置是进程的PCB(进程控制块):PCB是OS管理进程的核心数据结构,其中会有一个“信号掩码/未决信号集”(block,pending)的字段,专门存储进程收到但尚未处理的信号。
4. 进程没收到信号时,如何知道对合法信号的处理方式?
进程会提前在PCB中注册“信号处理函数”:
每个信号对应一种处理方式(默认处理、忽略、自定义函数),进程启动时会初始化这些映射关系(比如继承父进程的处理方式,或通过 signal() / sigaction() 主动设置),所以即使没收到信号,也知道该如何处理合法信号。
5. 如何理解OS向进程发送信号?完整过程是怎样的?
OS向进程发信号,本质是OS修改进程PCB中的“未决信号集”,标记进程需要处理某个信号;完整过程如下:
1. 信号产生:比如用户按下Ctrl+C(产生SIGINT信号)、进程出错(产生SIGSEGV)等;
2. OS标记信号:OS找到目标进程的PCB,将对应信号加入“未决信号集”(标记为待处理);
3. 进程检查信号:进程从用户态切换到内核态后,执行完内核操作,会检查PCB中的未决信号集;
4. 进程处理信号:根据PCB中注册的处理方式(默认/忽略/自定义函数),执行对应的逻辑;
5. 清除信号标记:处理完成后,OS清除PCB中该信号的未决标记。
内核如何进行信号捕捉(完整流程)
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
sigaction的接口

第二个参数结构体内容

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo
是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传
出该信号原来的处理动作。act和oact指向sigaction结构体:将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动
作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回
值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信
号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来
的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果
在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需
要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,
#include <iostream>
#include <unistd.h>
#include <signal.h>void handler(int signum)
{std::cout<<"捕捉到:"<<signum<<"信号"<<std::endl;
}int main()
{struct sigaction sa;//绑定自定义处理函数sa.sa_handler=handler;//初始化sigemptyset(&sa.sa_mask);//无特殊标志,设为0sa.sa_flags=0;sigaction(2,&sa,nullptr);while(true){std::cout<<"I am running..."<<std::endl;sleep(1);}return 0;
}

可重入函数
什么是可重入函数?
如何一个函数,被重复进入的情况下,出错了,或者可能出错,这是不可重复函数。
否则,叫做可重入函数!

main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了
(黑色粗体那里本质上就是处理信号的过程!)
对于上面链表出现错乱的情况,这是属于不可重入函数。
反之,如果一个函数只访问自己的局部变量或参数,,称为可重入函数!
满足其中一个条件即是不可重入函数:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构
volatile关键词:
站在信号的角度看它存在的作用:
作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量
的任何操作,都必须在真实的内存中进行操作
在此之前,我们先了解一下:
在 GCC 编译中, -O2 是生产级优化选项,和默认的无优化( -O0 )相比,核心区别在于代码效率、编译行为和调试体验:
-O0与-O2的区别:
维度 -O0(无优化) -O2 (高度优化) 编译目标 保留代码结构,方便调试 提升运行速度、精简代码体积 代码执行效率 低(频繁内存读写、冗余指令多) 高(寄存器优化、指令合并、循环优化) 编译时间 快 慢(需执行近百种优化操作) 代码体积 大(保留冗余指令) 小(死代码消除、公共子表达式合并) 调试体验 好(代码与源码一一对应) 差(代码重排、内联后,调试信息不准确) - 寄存器优先:把频繁使用的变量(如循环计数器、局部变量)直接存到 CPU 寄存器,减少内存访问(比内存快 10~100 倍)。
- 消除冗余:去掉无用变量、重复计算(比如 a = b + c; d = b + c 会优化为 a = d = b + c )。
- 循环优化:展开小循环、把循环内的不变计算提到循环外(比如 for(i=0; i<100; i++) sum += 5 会优化为 sum += 500 )。
- 指令合并:用更高效的指令替代多步操作(比如把“内存读 + 加法 + 内存写”合并为一条寄存器加法指令)。
- 函数内联:把小型函数(比如信号处理函数 sigint_handler )直接嵌入调用处,减少函数调用的栈开销。
ps:对嵌入式设备来说, -O2 是性能、体积、稳定性的最优平衡点,绝大多数场景下启用 -O2 能让程序更高效、更适配嵌入式资源限制。
#include <iostream>
#include <unistd.h>
#include <signal.h>int flag=0;
void handler(int signum)
{std::cout<<"I am 2 signal"<<std::endl;flag=1;
}int main()
{signal(2,handler);while(!flag);std::cout<<"process quit normal"<<std::endl;return 0;
}
无优化的情况:
有优化的情况:
优化情况下,键入 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 ,但是 while 条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?很明显, while 循环检查的flag,并不是内存中最新的flag,这就存在了数据二异性的问题。 while 检测的flag其实已经因为优化,被放在了CPU寄存器当中。如何解决呢?
所以,我们需要加上volatile,去避免过度优化,保持内存的可见性!
SIGCHILD信号
这个信号是17

子进程退出的时候,并不是静悄悄的出去,会主动向父进程发送SIGCHLD(17)信号。
怎么证明?
子进程在进行等待的时候,我们可以采用基于信号的方式进行等待。
等待的好处:
1.获取子进程的退出状态,释放子进程的僵尸。
2.虽然不知道父子谁先运行,但是我们清楚,一定是父进程最后退出!
所以,还是要调用wait/waitpid这样的接口,父进程必须保证自己是一致在运行的!
因此,我们可以试着把子进程等待写入到信号捕捉函数中!
用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。
采用第一种方式,父进程阻塞了就不 能处理自己的工作了;
采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可
现在,我们用代码来实现证明一下:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/fcntl.h>
#include <wait.h>
void handler(int signum)
{printf("I am 17 signal\n");
}
int main()
{signal(17, handler);pid_t id = fork();if (id == 0){int cnt = 0;while (cnt < 5){printf("i am child\n");sleep(1);cnt++;}}else if (id > 0){int ret = 0;while (ret < 2){printf("i am father\n");ret++;sleep(1);}pid_t rid = waitpid(id, nullptr, 0);if (rid == id){printf("process quit\n");}}return 0;
}

由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可 用:
int main() {// 1. 配置sigaction,忽略SIGCHLD信号struct sigaction sa;sa.sa_handler = SIG_IGN; // 设置为忽略sigemptyset(&sa.sa_mask);sa.sa_flags = 0;if (sigaction(SIGCHLD, &sa, NULL) == -1) {perror("sigaction failed");exit(1);}// 2. fork子进程pid_t pid = fork();if (pid == -1) {perror("fork failed");exit(1);}if (pid == 0) {printf("子进程(PID: %d)运行并退出\n", getpid());exit(0);} else {// 父进程:休眠10秒,期间不调用wait()printf("父进程(PID: %d)休眠10秒,不处理子进程\n", getpid());sleep(10);// 3. 验证子进程是否为僵尸进程printf("父进程休眠结束,检查子进程状态:\n");// 尝试wait子进程(实际已被系统自动清理)pid_t wait_pid = waitpid(pid, NULL, WNOHANG);if (wait_pid == 0) {printf("子进程仍存在(可能是僵尸进程)\n");} else if (wait_pid == pid) {printf("子进程已被清理(无僵尸进程)\n");} else {printf("子进程不存在(已被系统自动回收)\n");}}return 0; }
好了,关于信号的知识点就分享到这里结束了,希望大家一起进步!
最后,到了本次鸡汤环节:
熬过低谷,繁花自现。













