上一回我们认识了进程信号的基本概念,进程信号的本质等讲解了,这一篇文章,将带领大家继续深入学习进程信号,保存进程信号,信号处理方等知识。
一.保存信号
当前阶段

1.1信号其他相关常⻅概念
- 实际执⾏信号的处理动作称为信号递达(Delivery)
- 信号从产⽣到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产⽣时将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,⽽忽略是在递达之后可选的⼀种处理动作。
1.2 在内核中的表⽰
信号在内核中的表示示意图
实际上在 Linux kernel 的 task_struct 中还包含了一些信号相关字段,如下面这个信号在内核中的示意图:这个图应该横着来看:
SIGHUP(1):没有收到 pending,也没有 block,默认处理是 SIG_DFL(默认动作)。
SIGINT(2):收到了 pending,但因为被 block 了,所以信号被阻塞,暂时不会递达给进程,也就不会执行 SIG_IGN 或其他处理动作。等 block 解除后,才会继续执行对应的处理动作。
SIGQUIT(3):没有收到 pending,但收到了 block。需要注意,block 位图是独立于 pending 存在的,即使没有收到信号,block 状态也可以提前设置好。所以阻塞更准确的理解是:它是一种状态,表示"我不希望收到这个信号",而不是"这个信号来了才阻塞它"。

信号的自定义捕捉方法是用户提供的,是在用户权限下对应的方法。下面学习信号的操作都是围绕着这三个表来展开。
- 每个信号都有两个标志位分别表⽰阻塞(block)和未决(pending) ,还有⼀个函数指针表⽰处理动作。信号产⽣时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例⼦中,SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作。
- **SIGINT信号产⽣过,但正在被阻塞,所以暂时不能递达。**虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产⽣过,⼀旦产⽣SIGQUIT信号将被阻塞,它的处理动作是⽤⼾⾃定义函数sighandler。
如果在进程解除对某信号的阻塞之前这种信号产⽣过多次,将如何处理?
POSIX.1允许系统递送该信号⼀次或多次**。Linux是这样实现的:常规信号在递达之前产⽣多次只计⼀次,⽽实时信号在递达之前产⽣多次可以依次放在⼀个队列⾥**。这篇不讨论实时信号。
cpp
// 内核结构 2.6.18
struct task_struct {
...
/* signal handlers */
struct sighand_struct *sighand;
sigset_t blocked
struct sigpending pending;
...
}
struct sighand_struct {
atomic_t
count;
struct k_sigaction action[_NSIG]; // #define _NSIG
64
spinlock_t
siglock;
};
struct __new_sigaction {
__sighandler_t sa_handler;
unsigned long
sa_flags;
void
(*sa_restorer)(void);
/* Not used by Linux/SPARC */
__new_sigset_t sa_mask;
};
struct k_sigaction {
struct __new_sigaction sa;
void
__user* ka_restorer;
};
/* Type of a signal handler. */
typedef void (*__sighandler_t)(int);
struct sigpending {
struct list_head list;
sigset_t signal;
};
1.3 sigset_t
为了方便操作 PCB 里的三张表,OS 提供了
sigset_t类型,本质上就是个位图。从上面的图可以看出,pending 和 block 都是位图,每个信号只占一个 bit,不记录信号来了多少次,只记录有没有来、有没有被阻塞。所以这两种状态可以用同一个数据类型
sigset_t来表示。在阻塞信号集里,bit 为 1 表示"这个信号被阻塞了";在未决信号集里,bit 为 1 表示"这个信号已经收到了,但还没处理"。
sigset_t定义在用户空间(栈上),最终要设置到内核的 PCB 里。从用户态到内核态,需要系统调用接口来传递,不能直接操作。另外,OS 不推荐用户手动做位运算,因为不同系统的位图实现可能不一样。OS 提供了一套标准的操作函数,比如
sigemptyset、sigfillset、sigaddset、sigdelset等。用户态用这些函数操作sigset_t,然后通过系统调用(如sigprocmask)把数据交给内核。
简单总结就是:sigset_t 是用户空间描述信号集的类型,配合 OS 提供的操作接口,可以影响内核 PCB 中的信号状态。
类似权限哪⾥的 umask指令可以做到
1.4 信号集操作函数
光有 sigset_t 类型还不够,它本质上就是个位图,但不能直接操作它。因为不同的系统、不同的架构下,sigset_t 的底层实现可能不一样。比如 32 位和 64 位系统,这个位图的大小和组织方式可能不同。所以 OS 提供了专门操作 sigset_t 的接口,用户态通过这些接口来设置信号集,再传给系统调用。sigset_t类型对于每种信号⽤⼀个bit表⽰"有效"或"⽆效"状态, ⾄于这个类型内部如何存储这些bit则依赖于系统实现, 从使⽤者的⻆度是不必关⼼的, 使⽤者只能调⽤以下函数来操作sigset_ t变量,⽽不应该对它的内部数据做任何解释, ⽐如⽤printf直接打印sigset_t变量是没有意义的。sigset_t称为信号集信号屏蔽字(Signal Mask)
cpp
#include <signal.h>
// 清空信号集(所有位设为 0)
int sigemptyset(sigset_t *set);
// 填满信号集(所有位设为 1)
int sigfillset(sigset_t *set);
// 向信号集中添加某个信号(对应位置 1)
int sigaddset(sigset_t *set, int signum);
// 从信号集中删除某个信号(对应位置 0)
int sigdelset(sigset_t *set, int signum);
// 检查某个信号是否在信号集中
int sigismember(const sigset_t *set, int signum);
这些函数先在用户层操作
sigset_t位图,然后通过sigprocmask、sigpending、sigaction等系统调用把处理好的信号集设置到内核的 PCB 中。
信号集操作函数说明
使用 sigset_t 类型的变量之前,必须先调用 sigemptyset 或 sigfillset 做初始化,让信号集处于一个确定的状态,否则里面的值是随机的,没法用。
cpp
#include <signal.h>
int sigemptyset(sigset_t *set);
sigemptyset 把 set 指向的信号集全部清零,表示这个信号集里不包含任何有效信号。
cpp
int sigfillset(sigset_t *set);
sigfillset 把 set 指向的信号集全部置 1,表示这个信号集包含系统支持的所有信号。信号集初始化好之后,就可以用 sigaddset 往里面添加信号,或者用 sigdelset 从里面删除信号。
cpp
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
sigismember 用来检查某个信号是否在信号集中,是个布尔函数。
cpp
int sigismember(const sigset_t *set, int signum);
上面的四个函数(
sigemptyset、sigfillset、sigaddset、sigdelset)成功返回 0,出错返回 -1。sigismember返回值稍微不同:包含返回 1,不包含返回 0,出错返回 -1。
(1)sigprocmask 函数
1. 接口说明
sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。说白了就是把用户空间定义的信号集设置到内核的 block 位图里。这个阻塞信号集也叫信号屏蔽字(Signal Mask),注意屏蔽的意思是阻塞,不是忽略。
cpp
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数:
set:输入型参数,用户定义好的信号集,要设置到内核
oldset:输出型参数,返回旧的信号屏蔽字,方便以后恢复,不想保存就传 NULL
how:指定怎么改,有三种方式
oldset 和 set 都是指针,可以是 NULL,也可以指向一个 sigset_t 变量。
如果
oldset非空,就把当前进程的信号屏蔽字(旧的 block 位图)拷贝到oldset里,供用户查看或后续恢复用。如果
set非空,说明要修改屏蔽字,具体怎么改由how决定。如果
set和oldset都非空,那就先把旧的备份到oset,再根据set和how去修改。
how 参数告诉内核怎么改,假设当前信号屏蔽字是 mask:
how 的三种方式:
| 方式 | 含义 |
|---|---|
| SIG_BLOCK | 把 set 中的信号加到当前屏蔽字里,相当于 mask = mask | set |
| SIG_UNBLOCK | 从屏蔽字中移除 set 里的信号,相当于 mask = mask & ~set |
| SIG_SETMASK | 直接用 set 替换当前屏蔽字,相当于 mask = set |

返回值: 成功返回 0,失败返回 -1。
注意:如果 sigprocmask 解除了对某些未决信号的阻塞,在函数返回前,至少会把其中一个信号递达给进程。
2. 查看手册
cpp
man 2 sigprocmask


返回值:若成功则为 0,若出错则为 -1。
3. 代码如下
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int sig)
{
cout << "捕获到信号: " << sig << endl;
}
int main()
{
signal(2, handler); // 注册 SIGINT 处理函数
sigset_t set, oldset;
sigemptyset(&set);
sigaddset(&set, SIGINT); // set 里只放 2 号信号
// 阻塞 SIGINT
sigprocmask(SIG_BLOCK, &set, &oldset);
cout << "2号信号已被阻塞,PID: " << getpid() << endl;
cout << "5秒内按 Ctrl+C 试试" << endl;
sleep(5);
// 解除阻塞
cout << "解除阻塞" << endl;
sigprocmask(SIG_SETMASK, &oldset, NULL);
sleep(1);
return 0;
}
4. 运行效果
按 Ctrl+C 时,程序不会立刻响应,等解除阻塞后才处理信号。

(2)sigpending 函数
读取进程当前的未决信号集(pending 位图)。
cpp
#include <signal.h>
int sigpending(sigset_t *set);
set 是输出型参数,内核把当前未决信号集拷贝给用户。
返回值: 成功返回 0,失败返回 -1。
cpp
man 2 sigpending


那问题来了,如果对所有的信号都进行自定义捕捉,那是不是就相当于写了一个不会被异常或者用户杀死的进程?
不是的,OS 的设计者也想到了这一点,所以,设定了 9 号信号属于管理员信号。
9号信号不可被捕获
假如把所有信号都自定义捕捉了,是不是就无敌了?进程不会被杀死,也不会被异常终止?理论上是的,但 OS 设计者早就防着这一手了,所以规定了 9号信号(SIGKILL) 和 19号信号(SIGSTOP) 不能被捕获、不能被阻塞、不能被忽略。
cpp
signal(9, handler); // 无效,注册不成功
signal(19, handler); // 无效,注册不成功
这两个信号是"管理员信号",专门留给系统或 root 用户用的。
kill -9 PID就是最后的杀招,不管进程在干什么,直接干掉,进程没有任何反抗的余地。


运行结果:

根据结果你可以看见最后输入kii -9 ID号进程直接显示被kiled了。
验证 pending 位图变化一
如果我们把 2 号信号 block 掉,然后不断打印当前进程的 pending 信号集,这时候发一个 2 号信号,就能看到 pending 的第 2 位从 0 变成 1。
pending 位图用户不能自己改,所有的信号发送(kill、raise、abort)都是由内核去改 pending 位图。但我们能用 sigpending 读取当前的 pending 信号集。
- 屏蔽(阻塞)2 号信号。
- 不断的获取 pending 信号集,并输出。
- 发送 2 号信号给进程。
代码如下:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cassert>
using namespace std;
// 打印 pending 信号集
static void showPending(sigset_t &pending)
{
for(int sig = 1; sig <= 31; sig++)
{
if(sigismember(&pending, sig))
cout << "1";
else
cout << "0";
}
cout << endl;
}
int main()
{
// 1. 定义信号集对象
sigset_t bset, obset;
sigset_t pending;
// 2. 初始化信号集
sigemptyset(&bset);
sigemptyset(&obset);
sigemptyset(&pending);
// 3. 添加要阻塞的信号(2号信号)
sigaddset(&bset, SIGINT);
// 4. 设置到内核,阻塞 2 号信号
int n = sigprocmask(SIG_BLOCK, &bset, &obset);
assert(n == 0);
cout << "block 2 号信号成功..." << endl;
// 5. 循环打印 pending 信号集
while(true)
{
sigpending(&pending);
showPending(pending);
sleep(1);
}
return 0;
}


验证 pending 位图变化二
- 屏蔽(阻塞)2 号信号。
- 不断的获取 pending 信号集,并输出。
- 发送 2 号信号给进程。
- 20 秒后取消屏蔽 2 号信号

打印这个指令后


程序运行时,每秒钟打印一次各信号的未决状态(pending 位图)。因为我们用
sigprocmask阻塞了 SIGINT(2号)信号,所以按下Ctrl+C后,SIGINT 信号不会被递达,而是处于未决状态,pending 位图的第 2 位会从 0 变成 1。但 SIGQUIT(3号)信号没有被阻塞,所以按下
Ctrl+\时,进程会直接终止,不会进入未决状态,也不会被捕获。
问题补充:为什么 Ctrl+C 杀不掉?
因为
Ctrl+C发送的是 2 号信号,被我们提前阻塞了,信号一直挂起(pending),进程不处理它,所以程序继续跑。
Ctrl+\发送的是 3 号信号,没被阻塞,所以进程收到后执行默认动作(终止 + core dump),程序就退出了。
把所有信号都阻塞,然后看看 pending 位图长什么样。
程序每秒钟打印一次 pending 位图,刚开始全是 0。另一个终端挨个发信号(1号、2号、3号...),每发一个,pending 位图里对应的 bit 就从 0 变成 1,并且一直保持为 1,因为信号被阻塞了,没人处理。


Pending 位图记录的是"已经收到、但还没被处理的信号",发一个信号,对应的 bit 就置 1,处理完才清 0。阻塞信号就是让信号一直 pending,不处理。
9 号信号不能被屏蔽或阻塞。

运行./signal
终端一:

终端二:

二.处理信号(捕捉信号)
当前阶段

1. signal 接口的本质
前面讲过的 signal(2, handler),本质上是把 2 号信号的处理动作从默认(SIG_DFL)改成用户自定义的 handler 函数。OS 在 PCB(task_struct)中维护了一个信号处理函数指针数组,以信号编号为索引,signal 调用就是把数组中对应位置的内容替换成用户传入的 handler 地址。
cpp
// 注册信号处理函数
signal(SIGINT, handler); // 把 2 号信号的处理函数改成 handler
2. 信号什么时候被处理?
信号不是收到就立即处理的,而是在合适的时候才处理。所谓合适的时候,就是进程从内核态返回用户态时 。
为什么是这个时间点?
因为进程运行过程中,大部分时间都在用户态执行代码。当发生系统调用、中断或异常时,进程会陷入内核态,由内核完成相应工作后再返回用户态继续执行。这个从内核态返回用户态的切换点,就是检查和处理信号的最佳时机:
此时 CPU 权限从内核态切换回用户态,是系统最后一次介入的机会
进程的状态信息完整,可以安全地调用用户空间的信号处理函数
不会打断进程正在执行的关键操作
1.用户态和内核态
什么是用户态和内核态?
进程跑的时候,如果执行的是用户空间的代码,比如你自己写的
main函数里的各种逻辑、运算、函数调用,这时候进程处于用户态。如果执行的是内核空间的代码,比如系统调用内部的逻辑,这时候进程处于内核态。我们平时写代码,大部分时间都在用户态。但很多操作我们自己干不了,比如读写文件、创建进程、收发网络数据,这些必须通过系统调用让内核帮忙做 。调用系统接口的时候,CPU 会自动完成身份切换:从用户态切换到内核态,让内核去干活。干完之后切回用户态,程序继续执行后面的代码。
OS 怎么知道当前是用户态还是内核态?
CPU 内部有一个状态寄存器,专门记录当前 CPU 工作在什么权限级别。用户态和内核态就是通过这个寄存器区分的。除了寄存器,还要看用的是哪套页表------每个进程有自己的用户级页表,OS 有一份内核级页表,所有进程共享。内核态时访问的是内核级页表,用户态时访问的是进程自己的页表。
什么情况会触发从用户态切换到内核态?
触发方式挺多的,大概分几类:
系统调用:这是最直接的。比如调用
read、write、open、fork,这些函数内部会触发软中断,让 CPU 陷入内核。硬件中断:敲键盘、移动鼠标、网卡收到数据包,这些硬件事件会触发中断,CPU 停下当前的工作去处理中断。比如你写的程序里用
cin等着用户输入,看起来程序卡住了,其实是键盘输入触发中断被 OS 先拿走放缓冲区,再通过系统调用把数据交给你的程序。早期硬件中断管理靠的是 8259 这类中断控制器芯片,现在的 APIC 系统也是类似原理。异常:比如除零、野指针访问,CPU 会触发异常,自动切换到内核态由内核处理。
还有一个经典例子,汇编里有个指令叫 int 0x80,这是 x86 下系统调用的老路子------通过软件中断陷入内核。虽然现在 64 位系统换成了 syscall 指令,但本质上都是让 CPU 主动陷入内核去干活。

从内核态返回用户态
内核干完活就返回用户态继续跑。调完系统调用返回,中断处理结束返回,异常处理完也返回。每次返回用户态之前,OS 都会检查有没有信号需要处理,这也解释了为什么信号是在内核态返回用户态的时候被递达的。
用户态和内核态权限级别不同,能访问的资源也不一样。内核态权限高,但不能直接执行用户空间代码。OS不信任任何用户代码,万一有恶意操作,以内核态跑就可能搞崩系统。所以原则就是:用户代码在用户态执行,内核代码在内核态执行,跨权限执行必须经过状态切换。信号捕捉的时机是进程从内核态返回用户态的时候。递达分三种**:忽略、默认、自定义**。忽略和默认好处理,内核自己就搞定了。麻烦的是自定义捕捉------handler 是用户写的,在用户空间,可当前进程在内核态,怎么执行用户代码?
信号处理完整流程
进程在用户态执行代码时,因为系统调用、中断或异常触发而陷入内核态。内核完成相应工作后准备返回用户态,此时就是信号检测的时机。内核检查 pending 位图,根据信号的处理方式执行不同动作。
第一步:陷入内核
用户态进程执行代码,调用系统接口(如 read、write)后触发软中断,CPU 自动完成权限切换,从用户态陷入内核态,开始执行内核空间中的系统调用代码。
第二步:内核返回前检测信号
系统调用执行完毕,准备返回用户态。在返回之前,内核检查当前进程的 pending 位图,看看有没有信号需要处理。
第三步:根据处理方式分支
没有信号待处理:直接返回用户态,继续执行被中断的代码
有信号待处理:查 handler 表,根据注册的处理方式分三种情况:
SIG_DFL(默认):内核直接处理,终止进程或暂停进程或生成 core 文件,进程不返回用户态
SIG_IGN(忽略):内核把 pending 位图中对应位清 0,然后返回用户态继续执行自定义 handler:这是最复杂的情况,需要执行用户空间的自定义函数
第四步:执行自定义 handler
内核发现信号的处理方式是用户自定义的 handler 函数(在用户空间),于是从内核态切回用户态,执行 handler 函数。
第五步:sigreturn 返回
handler 执行完毕后,不能直接返回被中断的代码位置,因为内核还需要清理现场、恢复上下文。所以 handler 必须调用 sigreturn() 系统调用,再次陷入内核,让内核完成收尾工作。
第六步:恢复用户态
内核清理完现场后,再次返回用户态,回到程序被信号中断的位置继续执行。
为什么自定义捕捉这么麻烦?
内核态不能直接执行用户代码,这是出于安全考虑。 所以流程就绕了:用户态调系统调用陷入内核,内核忙完准备返回时发现有自定义信号要处理,于是切回用户态执行 handler。handler 跑完不能直接回原代码,得通过 sigreturn 再进一次内核,让内核帮忙恢复现场,再返回用户态继续执行。
整个过程在用户态和内核态之间来回切换,核心原因就是OS 不信任用户代码,必须保证任何用户代码都在用户态执行。
三种处理方式对比
| 处理方式 | 执行者 | 处理过程 |
|---|---|---|
| 忽略 | 内核 | pending 位图清 0,返回用户态 |
| 默认 | 内核 | 终止进程 / 暂停进程 / 生成 core 文件 |
| 自定义 | 用户态 | 切回用户态执行 handler --> sigreturn 回内核 -->返回用户 |
2.内核如何实现信号的捕捉
示意图是如下:

如果信号的处理动作是⽤⼾⾃定义函数,在信号递达时就调⽤这个函数,这称为捕捉信号。
由于信号处理函数的代码是在⽤⼾空间的,处理过程⽐较复杂,举例如下:
- ⽤⼾程序注册了 SIGQUIT 信号的处理函数 sighandler 。
- 当前正在执⾏ main 函数,这时发⽣中断或异常切换到内核态。
- 在中断处理完毕后要返回⽤⼾态的 main 函数之前检查到有信号 SIGQUIT 递达。
- 内核决定返回⽤⼾态后不是恢复 main 函数的上下⽂继续执⾏,⽽是执⾏ sighandler 函数, sighandler 和 main 函数使⽤不同的堆栈空间,它们之间不存在调⽤和被调⽤的关系,是两个独⽴的控制流程。
- sighandler 函数返回后⾃动执⾏特殊的系统调⽤ sigreturn 再次进⼊内核态。
- 如果没有新的信号要递达,这次再返回⽤⼾态就是恢复 main 函数的上下⽂继续执⾏了

上面的文字有些复杂,我用这个简单的图带大家理解一下,信号捕捉:
宏观理解来看,说白了,信号捕捉就是用户态和内核态之间来回切换的过程。下面简化一下整个流程。
流程如下:
程序在用户态跑着,调用系统调用(比如 read),陷入内核态
内核把活干完,准备返回用户态
返回之前先检查一下:有没有信号要处理?
没有 --> 直接返回用户态,该干嘛干嘛
有 - ->处理方式
如果是用户自定义的 handler(在用户空间),就从内核态切回用户态去执行
handler 执行完,调用
sigreturn再次陷入内核内核清理现场,然后再次返回用户态,回到程序原来被打断的地方继续跑
举个具体例子,方便大家理解
假设程序注册了 3 号信号(SIGQUIT)的自定义处理函数
sighandler,正在执行 main 函数。这时候用户按了Ctrl+\,触发了信号。
流程是这样的:
main 函数正在跑,因为系统调用或者中断,程序陷入了内核态
内核处理完自己的事,准备返回用户态继续跑 main
返回之前检查发现有 3 号信号要处理,而且处理方式是自定义的
sighandler内核不返回 main 了,而是切回用户态去执行
sighandler
sighandler和 main 是两个独立的控制流,用不同的栈空间,不存在谁调用谁的关系
sighandler执行完,调用sigreturn再次陷入内核内核清理现场,发现没有其他信号要处理,就返回用户态继续跑 main
3.sigaction
前面聊 signal 的时候,我们知道了怎么修改信号处理表,但那套接口其实比较简陋。现实中更常用的是 sigaction,它功能更强,可配置的选项也更多。虽然它设计上还兼顾了实时信号,但我们平时用的时候,只要掌握基本用法就够用了。
(1) 接口原型
cpp
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数含义如下:
signum :要捕获或设置的信号编号,比如
SIGINT、SIGTERM等。act :传入参数,指向一个
sigaction结构体,里面定义了新的信号处理方式。oldact :传出参数,如果不为
NULL,内核会把旧的处理方式填进去,方便你之后恢复;不需要就传NULL。
结构体说明
cpp
struct sigaction {
void (*sa_handler)(int); // 旧式处理函数,类似 signal 的用法
void (*sa_sigaction)(int, siginfo_t *, void *); // 带额外信息的处理函数(实时信号相关)
sigset_t sa_mask; // 信号处理函数执行期间要屏蔽的信号集
int sa_flags; // 行为标志位,比如 SA_SIGINFO 等
void (*sa_restorer)(void); // 已废弃,不用管
};

其中:
sa_handler和sa_sigaction是互斥的,具体用哪个由sa_flags中的SA_SIGINFO标志决定。如果你不关心实时信号的附加信息,直接用sa_handler就行,写法跟signal差不多。
sa_mask可以用来指定在信号处理函数运行期间,额外屏蔽哪些信号,避免嵌套触发。
sa_flags控制一些行为细节,比如是否自动重启被中断的系统调用(SA_RESTART)、是否使用sa_sigaction等。
sa_restorer这个字段现在基本不用,了解即可,不用纠结。
实际开发中,我们大多数时候只关心 sa_handler、sa_mask 和 sa_flags 这三个成员,实时信号那套(sa_sigaction)只有在做高级信号处理时才会涉及。


sigaction的作用,说白了就是读取和修改指定信号对应的处理动作 。你可以把它当成signal的"增强版",只不过参数写得稍微啰嗦一点。调用成功时返回
0,出错返回-1,错误信息可以通过errno进一步查看。
填 sa_handler
sa_handler 是 sigaction 结构体里最常用的字段,它的取值有三种情况:
赋值为
SIG_IGN:告诉内核,这个信号来了我不理它,直接忽略。赋值为
SIG_DFL:恢复成系统默认行为,比如该终止的终止,该停的停。赋值为一个函数指针:表示你要用自定义函数去处理这个信号。
这个函数返回值必须是
void,可以带一个int参数------那个参数就是信号的编号。这样一来,你就可以写一个通用的处理函数,根据传入的编号区分是哪个信号触发了它,省得每个信号写一个单独的函数。
另外要提一嘴的是,这个函数本质上是一个回调函数------它不是被你写的 main 函数直接调用的,而是由内核在信号到达的时候主动去调用的。这一点跟 signal 是一样的,理解成"注册一个处理函数,等系统来调用"就对了。
(2)sa_mask & sa_flags
先聊一个场景:
假设进程正在执行 2 号信号(比如 SIGINT)的处理函数,这时候操作系统又收到一个 2 号信号发往该进程。那这个新信号能不能立马被处理?
答案是不行。
原因很简单:如果允许嵌套处理同一个信号,逻辑会乱掉,处理函数重入也容易出问题。所以系统的默认行为是------在处理某个信号的过程中,同类型的信号会被短暂阻塞(block),直到当前处理函数结束,再解除阻塞,让后来的信号有机会被递达。
假如,当 1 号信号被捕捉并进入处理函数时,内核会把该信号的 pending 标志清零(表示这个信号已经被受理了),同时把对应的 block 位置为 1。这样如果处理期间又来了一个同样的 1 号信号,因为它被阻塞了,所以暂时不会递达,只能停留在 pending 状态 。等到当前处理函数执行完毕,通过类似 sys_sigreturn 的机制返回用户态时,内核再把 block 位清 0,这时候如果还有挂起的信号,就可以继续递达处理了。
这种机制还有个额外的好处:防止短时间内大量相同信号不断触发处理函数,导致进程频繁上下文切换,也算是一种系统层面的"限流"保护。
sa_mask 和 sa_flags 分别负责什么?
自动屏蔽当前信号
这个是内核默认的行为,不需要你额外配置。也就是说,不管你用不用
sa_mask,当前正在处理的信号在处理期间都会被自动屏蔽。
sa_mask:额外要屏蔽的信号如果你觉得除了当前信号以外,在处理函数期间还想顺便屏蔽掉其他某些信号(比如处理
SIGINT的时候顺便把SIGQUIT也挡住),就可以在sa_mask里指定这些信号。处理函数返回后,这些信号会自动解除屏蔽,恢复原状。
sa_flags:控制一些可选行为自动屏蔽当前信号这个行为,并不是靠
sa_flags开启的,它本身就是默认行为。
sa_flags是用来控制其他扩展选项的,比如:
SA_RESTART:让被信号中断的系统调用自动重启;
SA_SIGINFO:表示使用sa_sigaction而不是sa_handler,可以获取更多信号来源信息,等等。
实际应用中分配
如果你只是简单地想注册一个信号处理函数,不想折腾太多花样,那通常情况下:
把
sa_mask设为空集(sigemptyset(&sa.sa_mask));
sa_flags直接设为0;只填
sa_handler和signum。
这样就已经够用了,剩下的交给内核默认行为去处理就行。


这段代码的核心目的是演示 sigaction 的注册过程,以及信号处理期间 pending 位图的变化。
解析如下:
1. 注册信号处理函数
cpp
struct sigaction act, oact;
act.sa_flags = 0;
sigemptyset(&act.sa_mask); // 不额外屏蔽任何信号
act.sa_handler = handler; // 自定义处理函数
sigaction(2, &act, &oact); // 给 SIGINT(2号信号)注册 handler
这里注册了 2 号信号(SIGINT,即 Ctrl+C)的处理函数 handler,并且:
sa_flags = 0:全部用默认行为
sa_mask为空:除了当前信号(2号)会被自动屏蔽外,不额外屏蔽其他信号
2. 打印旧的处理动作
cpp
cout << "default action: " << (int)(oact.sa_handler) << endl;
sigaction 的第三个参数 &oact 会把该信号原来的处理动作填进去。
打印出来你会看到类似 0 或 1 之类的数字:
0表示SIG_DFL(默认行为,即终止进程)
1表示SIG_IGN(忽略)其他数值表示之前注册过的函数地址
3. handler 里做了什么
cpp
void handler(int signum)
{
cout << "获取了一个信号:" << signum << endl; // 重复打印了好几次
sigset_t pending;
int c = 10;
while(true)
{
sigpending(&pending); // 获取当前进程的 pending 位图
showPending(&pending); // 把 1~31 号信号的 pending 状态打印出来
c--;
if(!c) break;
sleep(1);
}
}
当 2 号信号触发时,处理函数会:
打印几行提示(告诉你收到了哪个信号)
进入一个循环,连续 10 秒,每秒打印一次当前进程的 pending 位图
循环结束后退出
4. showPending 打印 pending 位图
cpp
for(int sig = 1; sig <= 31; sig++)
{
if(sigismember(pending, sig)) cout << "1";
else cout << "0";
}
这个函数把 1~31 号普通信号的 pending 状态依次打印出来,1 表示该信号正在被阻塞(等待处理),0 表示没有挂起。
5. main 里最后进入死循环
cpp
while(true) sleep(1);
主进程一直在休眠,等着你按 Ctrl+C 触发信号。

这里有个点需要注意------如果 2 号信号正在被处理(被阻塞),第二次按 Ctrl+C 时,pending 位图中 2 号信号对应的位置应该是
1,但我们在循环里看到的可能一直是全0。为什么? 因为
sigpending读到的 pending 位图,只记录当前被阻塞且未递达的信号。而 2 号信号在第一次进入 handler 时,pending已经被清零(表示已受理),然后block置 1 用于阻塞后续同类型信号。第二次按 Ctrl+C 时,新信号确实会被挂起(pending 置 1),但它在block为 1 的情况下不会递达,所以sigpending应该能读到这个1。如果你实际运行时发现没看到这个
1,原因很可能是:第二次按 Ctrl+C 时,第一次 handler 已经退出了,所以信号直接递达并再次进入 handler,而不是被阻塞。
补充:
cpp
cout << "default action: " << (int)(oact.sa_handler) << endl;
oact.sa_handler是函数指针类型(8 字节),你把它强转成int(4 字节)。在 64 位系统上:把 8 字节转成 4 字节,会丢失精度,编译器觉得这是个大问题,应该报错。
加上
-fpermissive后:编译器就懒得报错了,只给你一个警告,让你能编译通过。
-fpermissive是干嘛的简单说:让编译器对一些不合规范的代码"睁一只眼闭一只眼",把错误降级成警告。
这段代码展示了 sa_mask 的用法------在处理 2 号信号的同时,额外屏蔽 3~7 号信号。
代码如下:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void showPending(sigset_t *pending)
{
for(int sig = 1; sig <= 31; sig++)
{
if(sigismember(pending, sig)) cout << "1";
else cout << "0";
}
cout << endl;
}
void handler(int signum)
{
cout << "获取了一个信号:" << signum << endl;
cout << "获取了一个信号:" << signum << endl;
cout << "获取了一个信号:" << signum << endl;
cout << "获取了一个信号:" << signum << endl;
cout << "获取了一个信号:" << signum << endl;
cout << "获取了一个信号:" << signum << endl;
sigset_t pending;
int c = 20;
while(true)
{
sigpending(&pending);
showPending(&pending);
c--;
if(!c) break;
sleep(1);
}
}
int main()
{
cout << "getpid: " << getpid() << endl;
struct sigaction act, oact;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
act.sa_handler = handler;
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
sigaddset(&act.sa_mask, 6);
sigaddset(&act.sa_mask, 7);
sigaction(2, &act, &oact);
cout << "default action: " << (long)(oact.sa_handler) << endl;
while(true) sleep(1);
return 0;
}
解析如下:
1. main 函数开头
cpp
cout << "getpid: " << getpid() << endl;
打印当前进程的 PID,方便等会儿在另一个终端用 kill 命令给它发信号。
2. 配置 sigaction 结构体
cpp
struct sigaction act, oact;
act.sa_flags = 0; // 啥额外功能都不要,用默认的
sigemptyset(&act.sa_mask); // 先清空屏蔽集
act.sa_handler = handler; // 告诉内核:2号信号来了就调用 handler 函数
sa_mask 的意思是:在处理 2 号信号期间,额外把哪些信号也给挡住。
cpp
sigaddset(&act.sa_mask, 3); // 挡住 Ctrl+\ (SIGQUIT)
sigaddset(&act.sa_mask, 4); // 挡住 SIGILL(非法指令)
sigaddset(&act.sa_mask, 5); // 挡住 SIGTRAP(调试断点)
sigaddset(&act.sa_mask, 6); // 挡住 SIGABRT(abort 函数触发)
sigaddset(&act.sa_mask, 7); // 挡住 SIGBUS(总线错误)
问题来了,为啥要挡这些?
比如你在处理 Ctrl+C 的时候,不希望被 Ctrl+\ 打断,那就把 3 号也屏蔽掉。
3. 注册
cpp
sigaction(2, &act, &oact); // 给 2 号信号设置新的处理方式
oact 会把原来的处理方式存下来,然后打印出来看看:
cpp
cout << "default action: " << (long)(oact.sa_handler) << endl;
默认情况下,2号信号的处理方式是 SIG_DFL,也就是终止进程,所以这里通常会打印 0。
4. handler 函数
cpp
void handler(int signum)
{
cout << "获取了一个信号:" << signum << endl; // 打印6遍
// ...(重复打印)
sigset_t pending;
int c = 20;
while(true)
{
sigpending(&pending); // 问内核:现在有哪些信号被挡住了?
showPending(&pending); // 把结果打印出来
c--;
if(!c) break;
sleep(1); // 等1秒再继续
}
}
收到 2 号信号后:
先打印几行提示,然后进入一个 20 秒的循环,每秒打印一次当前被阻塞的信号,20 秒后退出
5. showPending Function
cpp
void showPending(sigset_t *pending)
{
for(int sig = 1; sig <= 31; sig++)
{
if(sigismember(pending, sig)) cout << "1";
else cout << "0";
}
cout << endl;
}
把 1~31 号信号的 pending 状态打印成一行 0 和 1:
第 1 位对应 1 号信号,第 2 位对应 2 号信号,以此类推
1 表示这个信号被阻塞了,正在排队等待;0 表示没有被阻塞。
运行结果如下:


补充:
信号捕捉不创建新进程或新线程。它只是在当前进程的执行路径上,由内核在合适的时机(从内核态返回用户态前)插入执行一段自定义函数,执行完再回到原来的代码继续跑。可以理解为"借道执行",不是并发,是串行插入。
4.可重⼊函数
main函数调⽤insert函数向⼀个链表head中插⼊节点node1,插⼊操作分为两步,刚做完第⼀步的时候,因为硬件中断使进程切换到内核,再次回⽤⼾态之前检查到有信号待处理,于是切换 到
sighandler函数,sighandler也调⽤insert函数向同⼀个链表head中插⼊节点node2,插⼊操作的
两步都做完之后从sighandler返回内核态,再次回到⽤⼾态就从main函数调⽤的insert函数中继续往下执⾏,先前做第⼀步之后被打断,现在继续做完第⼆步。结果是,main函数和sighandler先后 向链表中插⼊两个节点,⽽最后只有⼀个节点真正插⼊链表中了。

概念:
像上面这样,insert函数被不同的控制流程调⽤,有可能在第⼀次调⽤还没返回时就再次进⼊该函数,这称为重⼊,insert函数访问⼀个全局链表,有可能因为重⼊⽽造成错乱,像这样的函数称为 不可重⼊函数,反之,如果⼀个函数只访问⾃⼰的局部变量或参数,则称为可重⼊(Reentrant) 函数。
**先说什么叫重入。重入不是个功能,是个现象-----**一个函数还没跑完,又被叫进来跑了一次。比如你给2号信号绑了个handler,handler里调了show函数,main函数里也调了show函数。程序跑的时候,main正执行show呢,突然来了个信号,内核切到handler,handler里又调show。这时候show这个函数就同时有两份执行在跑,只不过一份被暂停了等着另一份先跑完。这就叫重入。在信号这里,main是一个执行流,信号捕捉是另一个执行流。两个执行流跑同一个函数,就叫重入。那有啥问题?用链表插入举个例子。
insert函数大概长这样:
cpp
void insert(Node* node)
{
node->next = head; // 第一步
head = node; // 第二步
}
head是全局变量,指向链表的头。正常跑没问题。但假设main正调用insert,刚跑完第一步node->next = head,还没来得及跑第二步head = node,这时候来了个信号。**内核切到信号处理函数,信号处理函数里也调了insert。这个insert从头到尾跑完了,把head改成了新节点。然后返回main,main接着跑刚才没跑完的第二步head = node。**结果呢就是head被覆盖了,原来链表里的节点丢了,找不回来了。内存泄漏。**所以结论就是:如果一个函数被多个执行流同时调用,访问了全局变量或者静态数据,那它就不安全,叫不可重入函数。反过来,如果它只用自己的局部变量,不碰共享数据,那就是安全的,叫可重入函数。**信号处理函数里尽量只调可重入的函数,像printf、malloc、cout这些都不要用。我测试代码里用cout只是为了看效果,实际写代码别这么干。
如果⼀个函数符合以下条件之⼀则是不可重⼊的:
- 调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的。
- 调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构
5.volatile
volatile 是 C 语言里的一个关键字,也叫"易变关键字"。说白了就是告诉编译器:这个变量随时可能会变,你别自作聪明给我优化掉。
这段代码演示了信号处理函数如何通过修改全局变量,影响主程序的控制流;顺便引出了 volatile 关键字在信号编程中的必要性。
编译器优化对信号处理的影响
看一下这段代码:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
int flag = 0;
void changeFlag(int signum)
{
(void)signum;
cout << "change flag: " << flag;
flag = 1;
cout << "->" << flag << endl;
}
int main()
{
signal(2, changeFlag);
cout << "进程启动,PID: " << getpid() << endl;
cout << "不加 volatile,开优化后可能退不出来" << endl;
while(!flag);
cout << "进程正常退出,flag = " << flag << endl;
return 0;
}
我们期望的行为是:程序启动后卡在死循环,按 Ctrl+C 触发信号处理函数,把 flag 从 0 改成 1,然后循环退出,程序结束。实际跑起来呢?看下面两组测试。
测试结果
不开优化:
cpp
g++ -o signal signal.cc -std=c++11
./signal
按 Ctrl+C 后正常退出。

开优化(-O):
cpp
g++ -O -o signal signal.cc -std=c++11
./signal

按 Ctrl+C 后程序卡住,退不出来。连续按多次 Ctrl+C,发现信号处理函数虽然执行了(有打印输出),但
flag始终是 1,循环就是不退出。第一次按 Ctrl+C 时,
flag从 0 变成 1。再按 Ctrl+C 时,内存里的flag已经是 1 了,所以打印的是1->1。但循环判断用的是寄存器,寄存器里还是 0,所以循环继续跑。这就解释为什么信号处理函数执行了,但循环退不出来。
为什么会这样?
问题出在编译器优化。
编译器为了提升程序性能,在开优化(如
-O、-O2)时会做各种优化。对于
while(!flag)这个循环,编译器一眼看上去:flag在循环体里从来没被改过啊。那每次循环都去内存读flag多慢,干脆把它放到 CPU 寄存器里,循环的时候直接从寄存器读就行了。这样程序跑得快。但这里有个坑:
flag确实没在循环体里被改,但它会被信号处理函数改啊!编译器不知道信号这回事,它只看到代码表面。于是结果就是:
信号处理函数改了内存里的
flag(内存里的值变成 1)但循环判断用的是寄存器里的值(还是 0)
解决办法:volatile 关键字
volatile是 C/C++ 里的关键字,意思是"易变的"。用它修饰变量,就是告诉编译器:这个变量随时可能被外部改变,你不要自作聪明去优化它,每次用的时候都老老实实从内存读。
cpp
volatile int flag = 0;
加上 volatile 后,不管开不开优化,循环每次判断都从内存读 flag,信号改了内存里的值,循环就能读到新值,正常退出。
测试结论
| 编译方式 | 不加 volatile | 加 volatile |
|---|---|---|
| 不开优化(默认) | 能退出 | 能退出 |
| 开优化(-O / -O2) | 退不出来 | 能退出 |

加 volatile 之前得先说清楚编译器优化干了啥。代码里 flag 定义时确实在内存中开了空间,但主执行流中的 while(!flag) 循环体里没有任何修改 flag 的操作,所以开了优化(比如 -O1 或 -O3)以后,编译器为了效率就把 flag 从内存搬到寄存器里了,之后循环判断只读寄存器不再读内存。可信号处理函数里 flag = 1 改的是内存里的那份,寄存器里的值还是 0,于是内存和寄存器数据就不一致了,表现出来的现象就是信号处理函数明明执行了,打印也打了,但主循环死活退不出来。加上 volatile 就是告诉编译器别干这事儿,每次判断 flag 都老老实实去内存读,这就保证了内存的可见性------一个执行流改了,另一个执行流马上能看到。
补充说明
volatile 只保证"每次都从内存读",不保证原子性,也不解决多线程安全问题。在信号处理的场景里,用它修饰全局标志位就够了。实际开发中,信号处理函数里尽量少做事,一般就是改个标志位,然后让主循环去处理具体逻辑。而那个标志位,记得加上 volatile。
信号处理函数里要改的全局变量,记得加 volatile,否则编译器优化可能让你的程序"假装没收到信号"。
三.SIGCHLD 信号(了解)
进程⼀章讲过⽤wait和waitpid函数清理僵⼫进程,⽗进程可以阻塞等待⼦进程结束,也可以⾮阻 塞地查询是否有⼦进程结束等待清理(也就是轮询的⽅式)。采⽤第⼀种⽅式,⽗进程阻塞了就不 能处理⾃⼰的⼯作了;采⽤第⼆种⽅式,⽗进程在处理⾃⼰的⼯作的同时还要记得时不时地轮询⼀ 下,程序实现复杂。其实,⼦进程在终⽌时会给⽗进程发SIGCHLD信号,该信号的默认处理动作是忽略,⽗进程可以⾃ 定义SIGCHLD信号的处理函数,这样⽗进程只需专⼼处理⾃⼰的⼯作,不必关⼼⼦进程了,⼦进程 终⽌时会通知⽗进程,⽗进程在信号处理函数中调⽤wait清理⼦进程即可。
SIGCHLD 是第 17 号信号,子进程退出时内核会自动给父进程发送这个信号,父进程可以自定义 SIGCHLD 的处理函数,收到信号后调用 wait/waitpid 回收子进程资源,这样就避免了父进程被阻塞等待或反复轮询子进程状态,只需要专心干自己的活就行。不过要注意,多个子进程同时退出时 SIGCHLD 信号可能会合并,所以信号处理函数里要用 waitpid 配合 WNOHANG 循环回收,确保所有僵尸子进程都被清理干净。

过程如下:
这段代码就是验证子进程退出时会不会给父进程发 SIGCHLD 信号。
在子进程退出后,向父进程发送了第 17 号信号:

.
SIGCHLD 信号处理 + waitpid 回收,子进程被正常清理,没有变僵尸。
父进程注册了 SIGCHLD 的处理函数
handler,然后创建子进程,自己在死循环里"忙自己的事"。子进程sleep(100)模拟干活,100 秒后退出,内核自动给父进程发 SIGCHLD 信号。父进程收到后,执行handler打印信息。



流程如下:
父进程注册了 SIGCHLD 信号处理函数;
子进程创建后 sleep(100);
100 秒后子进程退出,内核给父进程发 17 号信号;
父进程收到信号,执行 handler;
handler 里调用
waitpid(-1, NULL, WNOHANG)回收子进程;子进程彻底消失,不会变僵尸。
总结:
**子进程退出时内核会自动给父进程发 SIGCHLD,父进程收到后回收子进程,不用主动去等,也不用轮询。**这就是 SIGCHLD 信号存在的意义。
除了父进程自定义 SIGCHLD 处理函数来回收子进程之外,还有另一种方式:直接把 SIGCHLD 的信号处理动作设为 SIG_IGN 显式忽略,这样子进程终止时内核会自动清理资源,不会产生僵尸进程,父进程也不需要 wait。不过这种方式仅在 Linux 下有效(比如 CentOS 7.6),其他类 Unix 系统不一定支持,跨平台代码还是老老实实用 waitpid 回收。


父进程(PID: 4098568)在死循环里每隔 1 秒打印一行
子进程(PID: 4098569)打印 PID,等 5 秒,打印 "child exit" 后退出
子进程退出时,父进程没有收到信号通知(因为被
SIG_IGN忽略了)父进程继续打印,完全不关心子进程
子进程被 OS 自动回收,没有变僵尸

输出只有
grep命令本身,没有看到子进程 4098569,说明子进程已经被 OS 自动回收了,没有变成僵尸。


总结:
自定义 SIGCHLD handler 是兼顾"父进程干自己的事"和"回收子进程"的优雅方案;如果连回收都不想管,直接
signal(SIGCHLD, SIG_IGN)让 OS 自动清理。
由于 UNIX 历史遗留问题,想要避免僵尸进程其实还有一条路:父进程通过 sigaction 把 SIGCHLD 设成 SIG_IGN,这样子进程退出时系统会直接回收资源,不会变成僵尸,也不会给父进程发信号。一般来说,系统默认的忽略和用户手动设置的忽略没什么两样,但 SIGCHLD 是个例外。这个办法在 Linux 下好使,换个 UNIX 系统就不一定了。
僵尸进程与内存泄漏:等还是不等?
前面我们聊了僵尸进程的问题,你可能会有个疑问:一会说要 wait,一会又说可以不 wait,那到底该怎么决定?其实核心就一句话:看父进程关不关心子进程的退出状态。
如果父进程关心子进程
比如需要知道子进程是正常退出的还是被信号干掉的,或者需要拿到子进程的退出码,那就必须调用 wait/waitpid 来获取。这种情况 wait 不是为了"防僵尸",而是为了"拿结果"。
如果父进程不关心子进程
比如子进程就是个干活的,干完拉倒,父进程压根不想知道它怎么退的。那就可以用两种方式避免僵尸:
自定义 SIGCHLD handler,在信号处理函数里调用 waitpid 回收
或者直接把 SIGCHLD 设成 SIG_IGN(Linux 下可用),让操作系统自动清理
两种方式都能避免僵尸进程,区别在于前者父进程会被通知,后者连通知都省了。
所以结论是:站在防止内存泄漏的角度,等不等都行,都有办法解决僵尸问题。但 wait 还有一个更重要的目的------拿到子进程的退出状态。 如果需要这个信息,就必须等;如果不需要,就可以不等,用 SIG_IGN 或信号回收都行。