上篇文章:Linux信号机制:从键盘到内核、进阶实战硬核剖析
导语
在上一篇文章中,我们探讨了"信号是如何产生的",并且扒开了硬件异常(除零、野指针)触发操作系统发送信号强制终结进程的底层逻辑。
但这仅仅是信号机制的冰山一角。很多开发者对信号的理解只停留在 signal(SIGINT, handler) 这句简单的 API 上。但这会引来无数灵魂拷问:
-
一个信号产生后,如果被进程阻塞(Block)了,它会消失还是被保存在哪里?
-
操作系统底层究竟用什么样的数据结构在管理这些信号?
-
当信号处于未决(Pending)状态时,它到底是如何翻转和递达的?
本文将带你直接掀开 Linux 内核的源码底裤!我们不仅会深入剖析 task_struct 中的底层信号位图机制,更会手写硬核代码可视化呈现 pending 位图的转变过程,并亲自验证 9 号与 19 号信号的绝对特权。
目录
[1.1内核探秘:信号在 PCB 中的真实模样](#1.1内核探秘:信号在 PCB 中的真实模样)
[底层源码验证:内核结构(Linux 2.6.18)](#底层源码验证:内核结构(Linux 2.6.18))
[4.1int sigemptyset(sigset_t *set);](#4.1int sigemptyset(sigset_t *set);)
[4.2int sigfillset(sigset_t *set);](#4.2int sigfillset(sigset_t *set);)
[4.3int sigaddset(sigset_t *set, int signo);](#4.3int sigaddset(sigset_t *set, int signo);)
[4.4int sigdelset(sigset_t *set, int signo);](#4.4int sigdelset(sigset_t *set, int signo);)
[4.5int sigismember(const sigset_t *set, int signo);](#4.5int sigismember(const sigset_t *set, int signo);)
[6.硬核实战:验证 Pending 位的转变时机](#6.硬核实战:验证 Pending 位的转变时机)
[6.2Pending 标志位是在递达前清 0,还是递达后清 0?](#6.2Pending 标志位是在递达前清 0,还是递达后清 0?)
1.信号的生命周期与三大核心概念
在信号的处理流程中,有几个至关重要的状态和概念。理解了它们,你就懂了信号在底层的流转逻辑。
-
信号递达 (Delivery):实际执行信号的处理动作。
-
信号未决 (Pending):信号从产生到递达之间的状态。
-
信号阻塞 (Block):进程可以选择阻塞某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意阻塞与忽略的区别 :阻塞(Block)是拦截 ,只要信号被阻塞就不会递达,信号还在未决队列里攒着;忽略(Ignore)是信号已经递达并处理了,只是处理动作是可选的一种:"什么都不干"。在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
1.1内核探秘:信号在 PCB 中的真实模样
信号在内核中的表示示意图:

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
在上图例子中, SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
1.2深入内核:四大底层术语硬核释义
-
pending(未决状态 / 未决信号集)-
硬核释义 :信号已经产生 ,但还没有被处理的这种"中间状态"。
-
底层机制 :在内核中,它对应着一个位图(
pending表)。当操作系统给进程发信号时,本质上就是把这个位图里对应的比特位置为 1。只要这个位是 1,就说明有一个信号正停留在进程的 PCB 里,等着被处理。 -
生活隐喻:这就像是你微信里收到了消息,但你还没点开看。这个红色的未读角标,就是 pending。
-
-
block(阻塞状态 / 信号屏蔽字)-
硬核释义:允许进程拦截某些信号。
-
底层机制 :它在内核中也是一个位图(
block表,也叫信号屏蔽字 Signal Mask)。如果某个信号在block位图中对应的位被置为 1,那么该信号就被阻塞了,操作系统绝对不会把它递达(处理)给进程。 -
生活隐喻:这相当于你把某个群设为了"消息免打扰"。群里依然可以发消息(依然可以 pending),但系统不会弹窗震动提醒你。直到你取消免打扰(解除 block),你才会去集中处理这些消息。
-
-
handler(处理动作 / 处理方法表)-
硬核释义 :当信号终于突破重重阻碍,成功递达给进程时,进程具体要怎么做。
-
底层机制 :在内核中,它是一个函数指针数组 (
handler表)。数组的下标是信号编号,数组里存的是处理方法的地址。通常有三种:SIG_DFL(系统默认)、SIG_IGN(忽略)、或者指向用户自定义函数的指针。
-
-
sighandler/sighandler_t(自定义处理函数指针类型)-
硬核释义 :它是 glibc 库中
signal函数里用到的一个类型重命名,代表用户自定义的信号处理函数。 -
底层机制 :在 C 语言中,定义为
typedef void (*sighandler_t)(int);。当你调用signal(2, my_func)时,你就是把my_func这个类型的指针,写到了内核handler表的第 2 号位置上。
-
底层源码验证:内核结构(Linux 2.6.18)
cpp
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;
};
3.信号集sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集 ,这个类型可以表示每个信号的"有效"或"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(SignalMask),这里的"屏蔽"应该理解为阻塞不是忽略。
4.信号集操作函数
sigset_t类型对于每种信号用一个bit表示"有效"或"无效"状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
4.1int sigemptyset(sigset_t *set);
功能 :清空信号集 ,将集合中所有信号的位都置为0。
- 参数 :
set- 指向要初始化的信号集的指针 - 返回值 :成功返回
0,失败返回-1并设置errno
使用场景 :信号集在使用前必须初始化,这是最常用的初始化方式。
sigset_t block;
sigemptyset(&block); // 现在block集合中不包含任何信号
4.2int sigfillset(sigset_t *set);
功能 :填满信号集 ,将集合中所有信号的位都置为1。
- 参数:同上
- 返回值:同上
使用场景:需要阻塞所有信号时使用。
sigset_t all_signals;
sigfillset(&all_signals); // 现在all_signals包含了系统支持的所有信号
4.3int sigaddset(sigset_t *set, int signo);
功能 :向信号集中添加一个信号 ,将对应信号的位置为1。
- 参数 :
set- 目标信号集指针signo- 要添加的信号编号(如SIGINT即 2 号信号)
- 返回值:同上
使用场景:精确控制要阻塞或处理的信号。
sigaddset(&block, SIGINT); // 向block集合中添加2号信号
sigaddset(&block, SIGQUIT); // 再添加3号信号
4.4int sigdelset(sigset_t *set, int signo);
功能 :从信号集中删除一个信号 ,将对应信号的位置为0。
- 参数:同上
- 返回值:同上
使用场景:从一个填满的信号集中排除特定信号。
sigfillset(&block); // 先填满所有信号
sigdelset(&block, SIGKILL); // 删除9号信号(SIGKILL无法被阻塞)
sigdelset(&block, SIGSTOP); // 删除19号信号(SIGSTOP无法被阻塞)
4.5int sigismember(const sigset_t *set, int signo);
功能 :检查一个信号是否在信号集中。
- 参数:同上
- 返回值 :
1:信号在集合中0:信号不在集合中-1:调用失败(如signo无效)
使用场景:检测 pending 信号集或阻塞信号集的状态。
if(sigismember(&pending, SIGINT)) {
printf("2号信号处于pending状态\n");
}
5.核心系统调用:更改/读取信号集
5.1sigprocmask
调用此函数可以读取或更改进程的信号屏蔽字(阻塞信号集)
cpp
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
5.2sigpending
cpp
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。
调⽤成功则返回0,出错则返回-1
6.硬核实战:验证 Pending 位的转变时机
6.1pending过程可视化
我们先屏蔽 2 号信号(SIGINT),然后不断获取 pending 信号集并打印出来。此时如果我们按下 Ctrl+C,由于信号被阻塞,它会一直停留在 pending 状态!
代码:
cpp
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <signal.h>
void PrintPending(sigset_t &pending)
{
for(int signum = 31; signum >= 1; signum--)
{
if(sigismember(&pending, signum))
{
printf("1");
}
else
{
printf("0");
}
}
printf("\n");
}
int main()
{
std::cout << "pid: " << getpid() << std::endl;
// 定义&&初始化信号集
sigset_t block, old_block;
sigemptyset(&block);
sigemptyset(&old_block);
// 向block信号集添加信号
sigaddset(&block, SIGINT);
// 屏蔽2号信号
int n = sigprocmask(SIG_SETMASK, &block, &old_block);
(void)n;
// 获得pending信号集并打印
sigset_t pending;
while(true)
{
sigemptyset(&pending);
int m = sigpending(&pending);
(void)m;
PrintPending(pending);
sleep(1);
}
return 0;
}
结果:

6.2Pending 标志位是在递达前清 0,还是递达后清 0?
新增捕捉2号信号以及探讨pending信号位的转变是在递达之前1->0还是之后0->1,我们在代码中获取并打印pending信号集。
cpp
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <signal.h>
void PrintPending(sigset_t &pending)
{
for(int signum = 31; signum >= 1; signum--)
{
if(sigismember(&pending, signum))
{
printf("1");
}
else
{
printf("0");
}
}
printf("\n");
}
void handler(int signo)
{
std::cout << "处理了:" << signo << "号信号" << std::endl;
std::cout << "#########################" << std::endl;
sigset_t pending;
sigemptyset(&pending);
int m = sigpending(&pending);
(void)m;
// 打印
PrintPending(pending);
std::cout << "#########################" << std::endl;
}
int main()
{
// 捕捉2号信号
signal(2, handler);
std::cout << "pid: " << getpid() << std::endl;
// 定义&&初始化信号集
sigset_t block, old_block;
sigemptyset(&block);
sigemptyset(&old_block);
// 向block信号集添加信号
sigaddset(&block, SIGINT);
// 屏蔽2号信号
int n = sigprocmask(SIG_SETMASK, &block, &old_block);
(void)n;
// 获得pending信号集并打印
int cnt = 0;
sigset_t pending;
while(true)
{
sigemptyset(&pending);
int m = sigpending(&pending);
(void)m;
// 打印
PrintPending(pending);
sleep(1);
cnt++;
// 恢复2号信号
if(cnt == 20)
{
std::cout << "2号信号恢复" << std::endl;
sigprocmask(SIG_SETMASK, &old_block, nullptr);
}
}
return 0;
}
结果:

结论: pending 信号位是在信号递达(调用处理函数)之前由内核从 1 置为 0
6.3验证9号和19号信号不能被屏蔽
如果我们把 1~31 号信号全部添加进 block 集合进行屏蔽,进程是不是就无敌了?
代码:
cpp
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <signal.h>
void PrintPending(sigset_t &pending)
{
for(int signum = 31; signum >= 1; signum--)
{
if(sigismember(&pending, signum))
{
printf("1");
}
else
{
printf("0");
}
}
printf("\n");
}
void handler(int signo)
{
std::cout << "处理了:" << signo << "号信号" << std::endl;
std::cout << "#########################" << std::endl;
sigset_t pending;
sigemptyset(&pending);
int m = sigpending(&pending);
(void)m;
// 打印
PrintPending(pending);
std::cout << "#########################" << std::endl;
}
int main()
{
// 捕捉2号信号
signal(2, handler);
std::cout << "pid: " << getpid() << std::endl;
// 定义&&初始化信号集
sigset_t block, old_block;
sigemptyset(&block);
sigemptyset(&old_block);
for(int signum = 1; signum <= 31; signum++)
{
sigaddset(&block, signum);
}
// // 向block信号集添加信号
// sigaddset(&block, SIGINT);
// 屏蔽2号信号
int n = sigprocmask(SIG_SETMASK, &block, &old_block);
(void)n;
// 获得pending信号集并打印
int cnt = 0;
sigset_t pending;
while(true)
{
sigemptyset(&pending);
int m = sigpending(&pending);
(void)m;
// 打印
PrintPending(pending);
sleep(1);
// cnt++;
// // 恢复2号信号
// if(cnt == 20)
// {
// std::cout << "2号信号恢复" << std::endl;
// sigprocmask(SIG_SETMASK, &old_block, nullptr);
// }
}
return 0;
}
结果:
输入命令
cpp
cnt=1; while [ $cnt -le 31 ]; do kill -${cnt} 1133010; let cnt++; sleep 1; done

结论 :大部分信号都能看到位图被置为 1 且不被处理。但当发送 9 号(SIGKILL)时,进程立刻被强杀;发送 19 号(SIGSTOP)时,进程立刻被挂起。9号和19号信号不仅无法被捕捉,也无法被阻塞屏蔽!
结语
从基础的三大核心状态 pending、block、handler,到终端杀手信号的差异解析;从扒开 task_struct 源码直面底层的 sigset_t 位图机制,再到通过硬核代码亲手将位图可视化,见证信号状态在递达前翻转的瞬间。
至此,关于 Linux 进程信号的基础机制与内核保存方式,我们已经彻底理清了!
信号机制是操作系统与用户进程沟通的脉络,它看似轻巧,底层却涌动着精妙的数据结构设计。掌握了这篇硬核内容,你就不再只是一个只会调 signal API 的"调包侠",而是真正具备底层排错能力的系统开发者。
在下一篇文章中,我们还将继续深挖信号在"内核态"与"用户态"之间究竟是如何流转与处理的,敬请期待!