
🔥个人主页:爱和冰阔乐 📚专栏传送门:《数据结构与算法》 、C++ 🐶学习方向:C++方向学习爱好者 ⭐人生格言:得知坦然 ,失之淡然

🏠博主简介
文章目录
- 前言
- 一、三个概念:递达、未决、阻塞
- 二、信号在内核中的表示:三张表
-
- 2.1 拿着三张表过一遍图
- 2.2 一个信号产生很多次怎么办?
- 三、sigset_t:信号集操作函数
- 四、sigprocmask:读取或更改信号屏蔽字
- 五、sigpending:获取未决信号集
- 六、动手实验:把三张表玩一遍
-
- 6.1 把所有信号都屏蔽,进程就无敌了?
- 6.2 解除屏蔽后发生了什么
- 6.3 pending 是先清 0,还是先执行 handler?
- 七、Term 和 Core:两种"死法"
-
- 7.1 为什么从没见过 core 文件?
- 7.2 查看、打开、使用
- 7.3 waitpid 状态字里的 core dump 位
- 总结
前言
上一篇讲了信号怎么产生。产生之后呢?信号不是立即处理的——进程要先把信号记下来,在合适的时候处理。那记在哪、怎么记、凭什么有的信号来了却"不处理"?
这一篇把信号的保存讲透:先厘清递达、未决、阻塞三个概念,再进内核看三张表,然后用 sigset_t、sigprocmask、sigpending 亲手改一遍这些表,最后说说 Term 和 Core 的区别——为什么云服务器上你从没见过 core 文件。
一、三个概念:递达、未决、阻塞
先补齐术语,后面全靠它们:
- 递达(Delivery):实际执行信号的处理动作。递达方式三种:自定义、默认、忽略;
- 未决(Pending):信号从产生到递达之间的状态。信号在位图里,就代表还没处理;
- 阻塞(Block):进程可以选择阻塞某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除阻塞,才执行递达动作。
用小明写作业理解:数学老师布置作业(发信号),小明正在上课不能去图书馆(不能立即处理),先记下作业内容(保存信号)——作业一直是未决的。下课去写(递达)。要是小明一直忘了写,作业虽然被记下来了但一直没写,作业被阻塞了。直到舍友提醒"明天要交",小明才想起来去写——解除阻塞。
注意:阻塞和忽略是两个概念,别混。 阻塞是"收得到、存得下,但就是不递达";忽略是递达之后的一种处理动作。两者是前后关系:只有解除阻塞才能递达,忽略是递达的其中一种动作。 阻塞信号也叫屏蔽信号。
block 表还可以理解成进程的一张黑名单:凡是里面标记为 1 的信号,来了先挂起,暂时不处理,等解除阻塞再说。
二、信号在内核中的表示:三张表

进程与信号相关的数据不止一张位图,是三张表:
1. pending 表(未决表)——保存收到的信号的位图。比特位的位置是第几个信号,内容是是否收到。
2. block 表——本质也是位图。位置是第几个信号,内容是是否阻塞。
3. handler 表——函数指针数组。数组下标是信号编号,数组内容是该信号的处理方法(默认动作/忽略/自定义函数地址)。
拿 2 号信号画个示意(横着看):
信号编号: 1 2 3 4 …
pending: [ 0 | 1 | 0 | 0 | …] ← 2号信号已产生,未决中
block: [ 0 | 1 | 0 | 0 | …] ← 2号信号被阻塞
handler: [ 默认 | 自定义 | 默认 | 忽略 | …] ← 每个信号的处理方法
三张表怎么联动?判断一个信号能不能被递达:
pending 表 & (~block 表) → 哪些信号可以被递达
现在回头看 signal 函数就彻底通了:传入信号编号和方法,signal 拿编号做 handler 数组的下标,把函数地址填到对应位置——signal 的本质就是修改 handler 表。
这也解释了前言里那条结论:进程在信号还没产生时就知道怎么处理——方法早就躺在 handler 表里了。
那 handler 表里的 SIG_DFL、SIG_IGN 是啥?
#define SIG_DFL ((__sighandler_t) 0) // 默认
#define SIG_IGN ((__sighandler_t) 1) // 忽略

就是把 0 和 1 强转成函数指针类型,用特殊的指针值表达"默认"和"忽略",不是真的函数地址。
2.1 拿着三张表过一遍图
上图的例子逐个分析:
- SIGHUP:未阻塞也未产生过,递达时执行默认处理动作;
- SIGINT:产生过(pending 为 1),但正在被阻塞,暂时不能递达。虽然它的处理动作是忽略,但在解除阻塞之前连忽略都轮不到——进程仍有机会先改处理动作再解除阻塞;
- SIGQUIT:未产生过,一旦产生将被阻塞,处理动作是自定义函数 sighandler。
每个信号都有两个标志位(阻塞 block、未决 pending)+ 一个函数指针(处理动作)。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除。
2.2 一个信号产生很多次怎么办?
如果在解除阻塞前,某信号产生了很多次?POSIX.1 允许系统递送一次或多次。Linux 的实现:常规信号在递达之前产生多次只计一次;实时信号可以依次排队。
普通信号好理解:父母喊你吃饭,喊一次和喊一百次,你记住的都是"要吃饭"这一件事,也只吃一次。
三、sigset_t:信号集操作函数
sigset_t 是 OS 提供的位图类型,能表示每个信号的"有效/无效"状态:在阻塞信号集里,有效 = 该信号被阻塞;在未决信号集里,有效 = 该信号处于未决状态。阻塞信号集也叫当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"要理解为阻塞而不是忽略。
用法上有一条铁律:sigset_t 内部的 bit 怎么存是系统实现的事,使用者不许直接解释它的内部数据——比如拿 printf 直接打印 sigset_t 变量没有任何意义。只能用下面这组函数操作:
int sigemptyset(sigset_t *set); // 信号集清空,全部置 0
int sigfillset(sigset_t *set); // 全部置 1
int sigaddset(sigset_t *set, int signo); // 第 signo 个比特位置 1
int sigdelset(sigset_t *set, int signo); // 第 signo 个比特位置 0
int sigismember(const sigset_t *set, int signo); // 判断 signo 是否在集合里
逐个说清楚:
- sigemptyset:初始化 set 指向的信号集,所有信号的对应 bit 清零——该集合不包含任何有效信号;
- sigfillset:初始化 set 指向的信号集,所有 bit 置位——有效信号包括系统支持的所有信号;
- sigaddset:把某个信号加入集合,信号编号是几,第几个比特位就置 1;
- sigdelset:从集合中删除某个信号,对应比特位置 0;
- sigismember:布尔函数,判断集合的有效信号中是否包含某信号。
使用 sigset_t 变量之前,必须先调用 sigemptyset 或 sigfillset 初始化,让信号集处于确定的状态,之后再调 sigaddset 和 sigdelset 增删。前四个函数成功返回 0、出错返回 -1;sigismember 包含返回 1,不包含返回 0,出错返回 -1。
为什么必须初始化? sigset_t 只是栈上的一段空间,不初始化就是随机值——你根本不知道哪些信号"被包含"了。
四、sigprocmask:读取或更改信号屏蔽字
谁调用就是设置、获取、更新自己的 block 表:
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
// 成功返回 0,出错返回 -1
假设当前信号屏蔽字为 mask,how 三个选项:
- SIG_SETMASK:用 set 整个替换 block 表(set = block);
- SIG_BLOCK:把 set 里为 1 的信号新增进 block,相当于 mask |= set;
- SIG_UNBLOCK:把 set 里这些信号从 block 里移除,对应比特位置 0,不再阻塞。
set 是用户传进来的信号集;oset 是输出型参数,把修改前的 block 表带出去——防止你改完后悔想恢复,留个备份。

参数组合的三种情况:
- oset 非空、set 为 NULL:只读取当前的信号屏蔽字,通过 oset 传出;
- set 非空、oset 为 NULL:按 how 的方式直接修改信号屏蔽字;
- 两个都非空:先备份原来的屏蔽字到 oset,再按 set 和 how 修改——所以想"改完还能恢复",就两个都传,恢复时拿 oact 再 set 回去。
到这里可以下一个判断了:Linux 提供的信号操作,一定是围绕三张表展开的——signal 改 handler 表(上一篇已验证),sigprocmask 改 block 表,sigpending 读 pending 表。接口就这三个方向,没有别的。
五、sigpending:获取未决信号集
#include <signal.h>
int sigpending(sigset_t *set);
// 读取当前进程的未决信号集,通过 set 传出。成功返回 0,出错返回 -1
set 也是输出型参数。注意:系统只提供了获取 pending 的接口,没有提供直接修改 pending 的接口——想改 pending?用发信号那一套(kill、键盘、raise),让 OS 去改。
Linux 提供的这些信号操作,全是围绕三张表展开的:signal 改 handler 表(已验证),sigprocmask 改 block 表,sigpending 读 pending 表。
六、动手实验:把三张表玩一遍
场景:默认情况下进程不屏蔽任何信号(block 全 0)。我们只屏蔽 2 号信号,然后不停获取并打印 pending 表。
void PrintPending(sigset_t &pending)
{
printf("我是一个进程(%d),pending:", getpid());
for (int signo = 31; signo >= 1; signo—)
{
if (sigismember(&pending, signo))
std::cout << 1;
else
std::cout << 0;
}
std::cout << "\\n";
}
int main()
{
// 1. 屏蔽 2 号信号
sigset_t block, oblock;
sigemptyset(&block);
sigemptyset(&oblock);
sigaddset(&block, SIGINT);
int n = sigprocmask(SIG_SETMASK, &block, &oblock);
(void)n;
// 2. 重复获取并打印 pending
while (true)
{
sigset_t pending;
int m = sigpending(&pending);
(void)m;
PrintPending(pending);
sleep(1);
}
}
跑起来后按 Ctrl+C:2 号信号进不了递达(被 block),但它确实产生了,于是 pending 表上 2 号位变成 1:

这就是"未决"的具象化:信号收到了、记下了,但没处理。
6.1 把所有信号都屏蔽,进程就无敌了?
把屏蔽 2 号改成屏蔽全部:
for (int i = 1; i < 32; i++)
sigaddset(&block, i);

kill -9 一发就死。9 号信号不能被捕捉,也不能被阻塞——所以它又叫管理员信号,是系统留给管理员的最后手段。
6.2 解除屏蔽后发生了什么
10 秒后解除对 2 号信号的屏蔽:
int cnt = 0;
while (true)
{
sigset_t pending;
sigpending(&pending);
PrintPending(pending);
if (cnt == 10)
{
// 恢复最初的 block(oblock 里存的是全 0)
sigprocmask(SIG_SETMASK, &oblock, nullptr);
std::cout << "解除对2号的屏蔽" << std::endl;
}
sleep(1);
cnt++;
}

现象:进程没打印"解除屏蔽",直接退出了。 因为解除屏蔽后 2 号信号立刻递达,默认动作是终止进程,连打印都来不及:

想看到完整流程,给 2 号注册自定义方法:
void handler(int sig)
{
std::cout << "递达" << sig << std::endl;
}
// main 里:signal(SIGINT, handler);

6.3 pending 是先清 0,还是先执行 handler?
一个很好的问题:pending 位从 1 变 0,是先递达再清 0,还是先清 0 再递达?
办法:在 handler 里再获取一次 pending。如果打印 0000 0010,说明处理完才清 0;如果打印 0000 0000,说明执行 handler 前就清了。
void handler(int sig)
{
std::cout << "递达" << sig << "信号!" << std::endl;
sigset_t pending;
sigpending(&pending);
PrintPending(pending);
std::cout << "####################" << std::endl;
}

结论:当我们准备递达的时候,先清空 pending 对应的比特位(1→0),再执行 handler。
七、Term 和 Core:两种"死法"
进程收到信号的默认行为大多是终止,但终止分两种:Term 和 Core。

Core(核心转储):进程以 Core 模式退出时,会在当前路径下生成一个文件——进程异常退出时,把进程在内存中的核心数据拷贝到磁盘,这个机制叫核心转储(Core Dump),主要为了支持 debug。做完转储,进程退出。
Term:直接退出,不做任何转储。
7.1 为什么从没见过 core 文件?
浮点错误 SIGFPE 的默认行为就是 Core,可云服务器上跑除 0 从没见过文件:
int main()
{
printf("hello world\\n");
printf("hello world\\n");
printf("hello world\\n");
int a = 10;
a /= 0;
printf("hello world\\n");
}

因为云服务器的 core dump 功能默认被关掉了。为什么?实际公司项目很大,项目挂掉会一直重启,重启就转储,转储文件会一直写——磁盘直接被写满。生产环境要禁止一切可能,要 debug 去测试/开发环境。
7.2 查看、打开、使用
ulimit -a # 查看用户层设置

core file size 是 0,确实被禁了。临时打开:
ulimit -c 10240 # 给 core 文件大小设个上限
再跑除 0,当前路径下出现 core 文件:

为什么要核心转储?debug。以前查进程在哪崩的要一行行翻代码,现在 gdb 里 core-file core 直接定位到出错行:

core 文件里存的是进程崩溃那一刻的现场快照:进程的核心数据、调用栈、寄存器上下文——相当于事故现场的照片。gdb 拿着照片还原现场,直接指给你看崩在哪一行。
以后找不到 bug,可以试着开启 core dump,让程序跑崩,gdb 加载 core 文件定位出错行——事后调试。
顺便把 Ctrl+\\ 的账算了:它发 3 号 SIGQUIT,默认动作就是 Core。所以开完 core dump 功能后按 Ctrl+\\ 杀进程,当前目录下就会多一个 core 文件——这也是为什么它经常和 Core Dump 一起出现。
7.3 waitpid 状态字里的 core dump 位
进程控制那篇说过 status 是位图,其实它不止退出码和退出信号,还有一个 core dump 标志位:

int main()
{
pid_t id = fork();
if (id == 0)
{
sleep(2);
int a = 10;
a /= 0;
}
int status = 0;
waitpid(id, &status, 0);
printf("signal:%d, exit code:%d, core dump:%d\\n",
(status & 0x7f), (status >> 8) & 0xFF, (status >> 7) & 0x1);
}

- 低 7 位:退出信号;
- 次低 8 位:退出码;
- 第 7 位:是否发生核心转储。
总结
一张图收尾:
信号产生
↓
pending 表置 1(未决)
↓
block 表对应位是 1?──是──→ 挡住,等解除阻塞
↓ 否
递达:先清 pending,再查 handler 表
↓
handler 表 → 默认 / 忽略 / 自定义函数
↓
默认动作里:Term 直接走,Core 先转储再走
- 递达/未决/阻塞:未决是"记下了没处理",阻塞是"存得下但不递达",忽略是递达的一种动作;
- 三张表:pending 记收到、block 记屏蔽、handler 存方法,pending & ~block 决定谁能递达;
- signal 改 handler,sigprocmask 改 block,sigpending 读 pending;
- 递达前先清 pending 再执行 handler;
- 9 号信号不可捕捉不可阻塞,管理员的底线;
- Term 与 Core 的区别是留不留"遗照",core 文件 + gdb = 事后调试。
信号什么时候被处理?处理自定义函数时进程在用户态还是内核态?sigaction、中断、用户态内核态切换,下一篇收尾。
三篇连着看效果最好,评论区聊聊你在生产环境见过 core 文件写满磁盘的事故吗?
资源分享: 【Linux】Ctrl+C 到底做了什么?从键盘、kill 到 alarm,一次讲清信号的产生 【Linux】共享内存为什么快?System V 的 key、shmget、shmat 与进程通信实战 【Linux】两个毫无关系的进程怎么通信?命名管道 FIFO 从原理到 Server/Client 实战



