目录
上一篇文章我们学习了信号产生的五种方式,本片文章我们继续学习进程的保存。
一、信号保存
在此之前,我们先介绍几个相关的概念,搞清楚这几个概念,对我们学习下面的内容很有帮助。
概念介绍
- 信号递送(Delivery)
就是真正开始去执行信号处理动作的过程。当信号没有被阻塞,且准备好了要处理它时,系统开始执行对应的处理逻辑,这个动作叫递送。
- 信号未决(Pending)
就是信号产生了,但还没来得及处理的那一瞬间状态。它就是一个中间态。信号已经通知到了系统,但系统还没开始执行它的处理动作,这个信号就像是被暂时 "存" 在那儿了,没处理之前的这个状态,就叫未决。
- 信号阻塞/信号屏蔽(Block)
这是进程的一种主动选择。进程说:"这个信号来了,我先不处理,把它给我挡回去。"一旦阻塞,信号就进不来,也就不会进入处理流程。阻塞是发生在信号递送之前的拦截。
- 阻塞与未决的关系
阻塞:是拦住信号,不让它进来,连处理的机会都不给。
忽略:是信号已经递送到了进程面前,但进程选择直接当没看见,不执行任何处理。
信号产生后,正常流程是:产生 → 未决 → 递送。
- 信号的处理动作
信号的处理动作有:1. 默认动作,2. 忽略,3. 用户自定义动作。
- 阻塞 vs 忽略(核心区别)
阻塞(Block):是递送前的拦截------ 信号被内核拦住,不递送给进程,保持未决状态;
忽略(Ignore):是递送后的处理------ 信号已经递送给进程,进程选择直接丢弃它。
两者的本质区别:阻塞发生在 "递送前",忽略发生在 "递送后"。
认识三张表
弄清楚上面的概念之后,我们下面来学习三张表。

pending表(信号未决)
pending 表就是内核用来记录进程当前是否处于 "未决" 状态的信号集合的位图结构。它的每一个比特位对应一个信号编号(比如第 2 位对应 2 号信号 SIGINT)。比特位为 1 表示该信号已经产生,但尚未被进程处理,正处于 "保存待处理" 的未决状态。比特位为 0 则表示该信号没有未决实例。
此时2号信号处于信号未决状态
看一下内核源代码:



从内核源码可以看到 :进程描述符 task_struct 中包含 struct sigpending pending; 成员,用于管理该进程的未决信号。struct sigpending 结构体里的 sigset_t signal; 就是实际的位图容器,底层是 unsigned long 类型(64 位系统下为 64 比特),用来标记哪些信号处于未决状态。紧接着下面我们就要学习这个 sigset_t 类型,当信号产生且被阻塞时,内核会将对应比特位置为 1;当信号被递送处理后,对应比特位会被清 0。
block表(信号阻塞)
block 表(信号屏蔽字)是内核记录进程哪些信号是否处于阻塞状态 的位图。**每一位对应一个信号编号,位值为 1 表示该信号被阻塞,位值为 0 表示该信号未被阻塞。**它是进程的一种主动拦截机制,用来决定信号是否能进入后续流程。
2 3信号处于信号阻塞状态


内核源码中,task_struct 结构体里有 sigset_t blocked 成员,这就是 Block 表的实际存储sigset_t 底层是 unsigned long 整数,通过位运算来设置和检测每一位的阻塞状态。
block 表和 pending 表有没有关系?
它们是拦截前和拦截后的因果关系:**信号产生时内核先检查 block 表。若被阻塞(Bit=1),信号不会递送给进程,也不会进入处理流程,而是被暂存到 Pending 表中,保持未决状态。**若未被阻塞(Bit=0),信号会直接被递送给进程处理。
handler表(信号递达)


handler 表是一个函数指针数组,类型为 sighandler_t handler[NSIG],NSIG 是系统支持的信号总数。数组下标 = 信号编号 - 1,这和 Linux 内核中信号的存储方式完全一致,比如 2 号信号(SIGINT) 就对应 handler[1]。每个元素存储的是该信号被捕捉时要执行的处理函数指针,精准概括了它的作用 ------ 接管信号的默认处理逻辑。**handler 表和信号递达直接对应,**它描述的就是信号递达之后的处理过程。
问题一 : signal (2 , myhandler) 底层会做什么? 和这个handler表有什么关系 ?
根据传入的信号编号 2 (SIGINT),计算数组下标 2 - 1 = 1。在 handler 表中找到下标为 1 的位置。
将该位置的函数指针替换为 myhandler,这就是信号捕捉。当 SIGINT 信号递达时,不再执行默认 的进程终止动作,而是跳转到 handler 函数执行 handler 函数的逻辑。
问题二 : 那在此基础上 ,进程忽略怎么理解?

SIG_IGN 和 SIG_DFL 是两个特殊的宏定义,上篇文章我们也提到过,内核把 0 和 1 这两个特殊地址,强转为 sighandler_t(函数指针类型)。在 handler 这个函数指针数组,数组下标的前两位的默认值分别是:1 号信号 → IGN(忽略),2 号信号 → DFL(默认处理)这里默认处理的意思就是让内核按这个信号本来的规矩办事,有的终止进程,有的忽略,有的暂停,具体行为由信号编号决定。
- signal(2, SIG_IGN) 时底层会先找到 handler 表中对应 2 号信号(SIGINT)的位置。把该位置的函数指针替换为 SIG_IGN (也就是 (sighandler_t)1)。当 2 号信号递达时,内核检查 handler 表发现值是 SIG_IGN,然后就直接默认忽略该信号,不执行任何处理。
- signal(2, SIG_DFL)也是同理,同样找到 handler 表中 2 号信号的位置,把函数指针替换为 SIG_DFL (也就是 (sighandler_t)0)。当 2 号信号递达时,内核发现值是 SIG_DFL,就会执行该信号的默认动作。
- 并且 SIG_DFL 和 SIG_IGN 表示 0 和 1 已经被内置在这个 handler 表中。虽然被内置了,但是我们依然能覆盖数组中的这两个位置。我们可以看一下下面的问题三 :
问题三 : 那如果 signal(2,handler),这个handler是我自定义的函数,那此时和 headler 表有关系吗?
其实 signal(2, handler) 的本质是修改内核为进程维护的 handler 表(函数指针数组):根据信号编号 2,在 handler 表(函数指针数组)中找到下标为 1 的位置(因为下标是等于编号-1 )。把我们自定义的 handler 函数的内存地址,覆盖写入这个位置原来的地址。原来的地址可能是 SIG_DFL (0)或者 SIG_IGN(1),现在被换成了我们自定义的 handler 函数地址。后续当 2 号信号递达时,内核会读取 handler 表,直接跳转到数组 1 号下标处执行 handler 函数的自定义处理逻辑,从而实现信号捕捉。
问题四 : 如何理解在信号还没有产生的时候 进程就已经能够识别和处理信号了?
答案就是这三张表,进程在创建时,内核就为它初始化了三张核心表:
- block 表:记录哪些信号会被阻塞(不允许递送)
- pending 表:记录哪些信号已经产生但还未处理
- handler 表:记录每个信号递达时要执行的处理动作
这三张表是进程内置的信号管理"模板",就像提前写好的"操作手册"。哪怕信号还没出现,进程已经知道:哪些信号要拦下来(block 表),拦下来的信号要存在哪里(pending 表),信号最终要怎么处理(handler 表)。




从源码可以看到 struct sighand_struct 里的 struct k_sigaction action[NSIG] 就是handler 表的真身,它是一个数组,下标对应信号编号。struct k_sigaction 封装了 struct sigaction,里面的 sa_handler 是一个函数指针(类型为 __sighandler_t ),用来存储:
- SIG_DFL :默认处理动作
- SIG_IGN :忽略信号
- 用户自定义函数地址:自定义处理逻辑
二、引入系统调用
OS需要让用户控制信号,本质就是访问和操作上面的三张表,又因为这三张表是在内核数据结构task_struct中的,所以访问这三张肯定需要系统调用,其中访问 handler 表我们已经有了系统调用signal,那访问剩下的 block 表和 pending 表同样也有对应的系统调用。

下面我们就来学习这两个系统调用,但是在此之前,我们要先学习一种内核级数据类型,因为这个数据类型和这两个系统调用有关。
sigset_t信号集
sigset_t 信号集是OS提供的数据类型。
sigset_t 在 pending 表中 : sigset_t 在 block 表中:


我们可以从三个层面来看这个类型 :
1. 从语法层面上来看 ,它就是一个整数类型 unsigned long,只是给 unsigned long(无符号长整型,64 位)起了一个别名 sigset_t。所以在编译器眼里,sigset_t 和 unsigned long 是完全一样的东西,占 8 字节内存,本质就是一个 64 位的整数。
2. 从语义层面上来看,我们就把它当作 "位图" 来用,虽然它是整数,但我们不把它当作普通数字用,而是把它的 64 个 bit 位,每一位对应一个信号,因为信号不光有普通信号还有实时信号。
**3. 从内核层面上来看,**内核里的 pending 表和 block 表,本质就是用 sigset_t 位图标记的 :
- block 表:就是进程的信号屏蔽字(Signal Mask),用 sigset_t 位图表示,每一位标记 "这个信号是否被阻塞"。
- pending 表:就是进程的未决信号集,用 sigset_t 位图表示,每一位标记 "这个信号是否已经产生但还没被处理"。
它们的底层存储,都是 sigset_t 类型的变量------也就是我们看到的 unsigned long,逻辑上就是位图。
sigset_t 是类型,是 "位图" 的抽象外壳。
每个 sigset_t 变量,就是一个具体的位图实例,用来存储一组信号的状态。
这里补充一个和 block 表有关的概念,block 表在 sigset_t 是位图结构,每一位对应一个信号,这种 "用某一位的 0/1 来控制是否允许某个事件发生" 的结构,在计算机里就叫掩码(Mask)。因为它专门用来控制信号是否被递送,因此我们把 block 表也叫信号屏蔽字。
所以我们可以把block表叫做信号屏蔽字 或者block信号集,pending表叫pending信号集 ,因为它们都可以用相同的数据类型 sigset_t 来存储,但是handler不行,它就只叫handler表 。
信号集操作函数
信号集操作函数是什么?
信号集操作函数就是专门用来给 sigset_t 这个位图 "增删改查" 的工具函数。因为 sigset_t 是位图,我们不能直接用 =、+、- 去操作它,必须用专门的位运算函数来修改里面的 0/1。还要注意的是信号集操作函数不是系统调用,它们是 C 库封装的普通用户态函数,纯在用户空间做位运算,不陷内核、不触发中断。

| 函数 | 功能 |
|---|---|
sigemptyset(sigset_t *set) |
清空信号集(所有位设为 0) |
sigfillset(sigset_t *set) |
填满信号集(所有位设为 1,包含所有信号) |
sigaddset(sigset_t *set, int signum) |
向集合中添加一个信号 |
sigdelset(sigset_t *set, int signum) |
从集合中删除一个信号 |
sigismember(const sigset_t *set, int signum) |
判断某个信号是否在集合中 |
下面我们就来学习这两个访问 block 表和 pending 表对应的系统调用。
signprocmask
signprocmask 是 Linux 中**用于检查和修改进程信号屏蔽字(阻塞信号集)**的核心系统调用,也就是专门用来操作信号阻塞的系统调用,我们上面说过,信号阻塞的本质就是进程的信号屏蔽字(block mask) ------ 这是一个集合,里面记录了哪些信号当前被挡住、不会被处理,和block表有关。
而且在信号场景下,阻塞和屏蔽基本是同一个意思,只是表述角度不同,本质都是让信号暂时不被递送给进程处理。

第一个参数 how 表示操作模式,决定如何修改信号屏蔽字(block表),它有三个可选值 :

- SIG_BLOCK:把 set 里的信号,加到当前 block 表(新掩码 = 当前掩码 | set)
- SIG_UNBLOCK:把 set 里的信号,从当前 block 表删掉(新掩码 = 当前掩码 & ~set)
- SIG_SETMASK:直接用 set 替换当前整个 block 表
第二个参数 set 就是我们准备好的、要用来参与修改的信号屏蔽字(block表),若为 NULL 则不修改当前屏蔽字。
第三个参数 oldset 则是操作系统将PCB中还未进行修改的 block 信号集,在进行修改之前拷贝到oldset 中带出来给我们,即这个oldset是一个输出型参数,我们将PCB中原有的未进行修改的 block 信号集进行了保存工作,所有的保存都是为了恢复,即我们后续可能使用到原来未进行修改的信号集。
这个系统调用的流程就是内核里本来就有一个 block 表(当前正在用的信号屏蔽字),我们现在想改它,就要用 sigprocmask,第二个参数 set 是我们准备好的、要用来参与修改的信号集,第一个参数 how 告诉内核用哪种方式改原来的 block 表,第三个参数 oldset 是把修改之前、原来的那个 block 表存一份出来,方便以后恢复,所以后面两个参数都是同一个类型sigset_t * 的。本质都是存信号集合(block 表结构)的,只是用途不一样。

返回值成功返回 0,失败返回 -1,并设置 errno(常见错误:EINVAL 表示 how 参数无效)。
sigpending
signpending 是 Linux 中用于检查待处理信号(信号未决)的系统调用,和 pending 表有关,信号阻塞是原因,信号未决是结果:被阻塞的信号无法立即处理,就会进入未决状态;sigpending 就是用来查询当前哪些信号因阻塞而处于未决状态的系统调用,本质上是在看 "有哪些信号因为被阻塞,还没来得及处理"。

参数 set 是个输出型参数,是我们自己定义的一个变量,函数执行成功后,sigpending 会把内核里的 pending 表复制一份到 set 里给我们看。
sigpending 与 sigprocmask 在功能上存在本质区别:sigprocmask 是可读写的系统调用 ,核心作用是修改进程的信号阻塞掩码(Block 表),通过设置哪些信号需要被阻塞,来控制信号是否能被立即递送给进程处理;而 sigpending 是纯只读的查询接口 ,它仅会将内核中当前进程因阻塞而处于未决状态(Pending)的信号集合复制到用户传入的 sigset_t 类型参数 set 中,不会对内核里的阻塞表或未决表做任何修改。真正会改变未决表状态(pending表)的是 kill、raise 这类信号发送接口 ------ 当它们向进程发送信号后,若该信号正被 sigprocmask 设置为阻塞状态,就会进入未决表等待处理,直到阻塞解除后被交付处理,而 sigpending 自始至终都只是被动查询这些待处理信号的存在,无法参与任何修改操作。
代码demo:
场景一:
那么我们现在已经了解了sigset_t,sigprocmask,sigpending,signal以及信号集函数的使用,下面我们来设计一个场景来将这些接口全部实际使用一下,学以致用。
这个场景就是,我们通过 sigset_t 定义信号集,用 sigprocmask 将 2 号信号(SIGINT 即Ctrl+C)进行阻塞屏蔽(信号场景下,阻塞和屏蔽是一个意思 ),使其无法被立即递达 ;随后在循环中,每隔 1 秒调用 sigpending 获取进程的未决信号集(pending表),并遍历打印pending表中 1~31 号信号的未决状态,初始状态下所有信号位均为 0。**当向进程发送 2 号信号时,由于该信号被阻塞,无法递送给进程处理,因此进入未决状态;**再次打印时,2 号信号对应的比特位会从 0 变为 1(具体位置取决于打印顺序:若从 1 到 31 打印则是第 2 位,若从 31 到 1 打印则是倒数第 2 位),直观验证了信号阻塞与未决的关系。

因为上述代码中既有信号集函数,也有系统调用,信号集函数只是专门用来操作 sigset_t 类型的函数集合,它们不与内核进行交互,但是系统调用是直接和内核交互、并控制信号,所以我们在代码图片中用红色字体表示信号集函数,用黄色字体表示系统调用,并且对各个函数和系统调用进行了详细的注释。

运行结果在初始状态下还没发送 2 号信号,所有信号都未处于未决状态(即输出全0),当我们按下 Ctrl+C(发送 2 号信号)后,2 号信号被就阻塞即无法处理。从而进入未决状态,因为打印是从 31 到 1,所以 2 号信号对应输出字符串的倒数第 2 位,这一位从 0 变成 1,图中红框里的 ...0010 就是证明:最后一位是 1 号信号(0),倒数第二位是 2 号信号(1)。
结论 :
- sigprocmask 可以阻塞信号,让信号暂时无法被处理
- 被阻塞的信号会进入未决状态,保存在内核的 pending 集合中
- sigpending 可以只读查询 pending 集合,sigismember 可以检查具体信号是否处于未决状态
- 发送 2 号信号后,只有对应位会从 0 变为 1,其他信号不受影响
场景二:
下面我们对代码再改进一下,之前只屏蔽 2 号信号,现在我们屏蔽 1~31 号所有信号,**任何发送给进程的信号都会被阻塞,进入未决状态。**所以后续循环中,sigpending 会持续从pengding表中获取并打印所有未决信号的集合。


结论 :
- 信号阻塞的范围:可以通过 sigaddset 循环,将多个信号(甚至全部 1~31 号信号)加入阻塞集,实现批量阻塞。
- 特殊信号的特殊性:SIGKILL(9 号信号)和 SIGSTOP(19 号信号)无法被阻塞、捕捉或忽略,内核会直接执行默认行为。
场景三:
下面我们继续修改代码,因为上面都是对信号进行屏蔽,即屏蔽了2号信号,那现在我们要解除对2号信号的阻塞屏蔽,然后继续观察会有什么现象发生 :

这和场景二最大的区别是之前是永久阻塞,现在是先阻塞一段时间,再自动解除。我们也能推测在发送 2 号信号后,它先处于未决状态(输出里第 2 位为 1)。大约 20 秒后解除阻塞。

观察运行结果,过了若干秒之后,确实解除了对 2 号信号的屏蔽,但是进程为什么直接退出了?
答案是 2 号信号 SIGINT 的默认处理动作是终止进程。当你用 kill -2 发送信号时,它被阻塞,只能待在 pending 队列里。20 秒后解除阻塞,内核立刻把这个信号递送给进程,进程执行默认的终止逻辑,所以直接退出了。
所以我们又能得出结论:
结论1 : 一旦解除对某个信号的阻塞,该信号就会立即被递送。
结论2 : 之前的场景只验证了信号从 阻塞到未决 的过程,而这个场景补上了 信号解除阻塞到信号递送再到执行默认行为 的后半段过程,形成了完整的信号生命周期:发送信号 → 被阻塞 → 进入未决 → 解除阻塞 → 立即递送 → 执行默认处理
场景四:
可是我们不想让2号信号解除屏蔽后直接终止,那么此时我们可以用 signal 函数对2 号信号进行信号自定义捕捉,从而让2号信号去执行其他任务不让它直接终止。


发送 kill -2 后,输出中 2 号位变为 1,信号处于未决状态。20 秒后解除阻塞,打印 "解除对 2 号信号屏蔽",紧接着打印 "处理完成: 2",这是 handler 被执行的证明。后续输出程序继续打印全 0 的 pending 表(因为 2 号信号已经被处理,不再处于未决状态),进程持续运行,没有退出。
结论:
未决信号在解除阻塞后会立即递送:即使信号被阻塞了很久,一旦解除阻塞,内核会立刻把它交给处理函数。
场景五:
在场景四中,我们成功解除了对信号的阻塞并完成了信号捕捉,由此产生一个疑问:2 号信号从未决状态(pending=1)变为非未决状态(pending=0)的时机,是在调用 handler 函数之前,还是在调用 handler 函数之后?接下来我们用反证法验证这个问题:我们的验证方法是在 handler 函数内部继续调用 sigpending 获取 pending 信号集,并打印该集合。如果打印结果中 2 号位为 1,说明信号仍处于未决状态,从未决变为非未决的时机是在 handler 执行之后。如果打印结果中 2 号位为 0,说明信号已经从 pending 集合中移除,从未决变为非未决的时机是在 handler 调用之前。
在信号处理函数内部,主动查询当前进程的 pending 信号集,观察信号递送时的状态变化。

从运行结果中我们能看出在 handler 内部打印时 2 号位为 0,这直接证明了信号从未决变为非未决的时机,是在调用 handler 函数之前------ 内核在递送信号前,会先将其从 pending 集合中移除,再执行 handler。这也就推翻了 "在 handler 之后才解除" 的假设,完美验证了我们的猜想。
总结:
我们通过上面五个场景的代码 :
- 场景 1:阻塞 2 号信号,循环打印未决状态,验证信号被阻塞后会进入未决。
- 场景 2:阻塞全部 1~31 号信号,逐个发送信号,验证未决信号可累积,且 9 号信号无法被阻塞。
- 场景 3:定时自动解除对 2 号信号的阻塞,验证解除阻塞后未决信号会立即递送并执行默认终止行为。
- 场景 4:用signal注册自定义处理函数,验证信号可被捕捉并自定义处理逻辑,避免进程终止。
- 场景 5:在处理函数内部查询未决信号集,验证信号从未决变为非未决的时机在调用处理函数之前。
所以综上我们从最简单的阻塞查看未决,一步步进化到批量阻塞、自动解除阻塞、信号捕捉、handler 内部验证未决时机,完整验证了 Linux 信号的阻塞、未决、递送、捕捉整套机制。
三、重新理解这三张表和信号的流程
当一个信号被发送到进程时,它会依次经过阻塞屏蔽、未决等待、递达处理三个阶段,而 block表、pending表、handler表这三张表正是内核用来管理这个流程的核心数据结构(位图)。
block 表(阻塞掩码)是进程的"过滤规则",由 sigprocmask 读写,它标记哪些信号暂时被禁止递达(即被阻塞) ------只要某个信号在 block 表中被置为 1,即使已经产生,也无法直接交付给进程,只能进入 pending 表中进行未决等待。 pending 表是进程的"待办队列",由 sigpending 只读查询,它记录所有已经产生但因阻塞而暂时无法处理的信号,每一位对应一个信号,1 表示该信号处于未决状态、正在等待递达。 当进程解除对某个信号的阻塞(修改 block 表为 0),或者内核主动检查待办时,pending 表中对应的信号会被取出,进入递达阶段 :递达时内核会先将该信号从 pending 表中清除(位从 1 变为 0),再根据 handler 表决定如何处理------ handler 表记录了每个信号的处理方式 ,要么执行内核预设的默认行为(如终止进程),要么执行用户通过 signal 注册的自定义处理函数,要么直接忽略该信号。
简单来说, block 表决定"能不能来", pending 表记录"来了但没处理", handler 表规定"来了之后怎么办";信号从发送到处理的完整链路是:发送信号 → 检查 block 表 → 若被阻塞则进入 pending 表等待 → 阻塞解除后从 pending 表清除 → 递达并依据 handler 表执行处理,这三张表共同协作,完成了 Linux 信号从产生到处理的全生命周期管理。后面我们还会继续深入了解这三张表的用法。
四、总结
本文深入讲解了Linux进程信号管理的核心机制,重点分析了信号处理的三个关键数据结构:block表(阻塞信号集)、pending表(未决信号集)和handler表(信号处理函数表)。文章通过五个实验场景,详细演示了信号从产生、阻塞、未决到递送处理的完整生命周期。主要内容包括:1. 信号处理的基本概念:递送、未决、阻塞的区别 2. 三张核心表的作用与关系:block表控制信号递送,pending表记录待处理信号,handler表决定处理方式 3. 相关系统调用:sigprocmask修改阻塞集,sigpending查询未决信号 4. 五个实验场景验证信号处理流程,包括阻塞、解除阻塞、自定义处理等关键环节 5. 信号处理时机的验证:信号从未决到递送的转换发生在调用处理函数之前
本文通过代码实验和理论分析,全面阐述了Linux信号管理的底层机制和工作原理。
谢谢大家的观看!
