一、信号的产生
1.硬件触发
1.1硬件异常
当程序发生段错误、除0错误、浮点异常等CPU会触发中断,同时内核向进程发送相应信号。常见的如SIGSEGV:段错误信号、SIGFPE:浮点异常信号、SIGILL:非法指令信号、SIGBUS:总线错误信号

1.2硬件操作
当用户主动进行硬件操作Ctrl+C(SIGINT)、Ctrl+Z(SIGTSTP)、Ctrl+\(SIGQUIT)等指令时,内核会向进程发送相应信号。
2.软件触发
软件触发是开发中主动操控、实现进程间通知与事件响应的核心信号生成方式,区别于硬件异常、硬件操作的被动触发,其触发源均来自软件层面,涵盖命令行手动操作 、代码中系统调用 / 库函数调用 、内核检测到特定软件运行条件三类场景,也是信号机制在进程通信、定时任务、异常检测等业务场景中的主要应用形式。
软件触发的信号支持灵活的主动发送与定制化处理(除 SIGKILL、SIGSTOP 外):比如在命令行执行kill 12345会向 PID 为 12345 的进程发送默认的 SIGTERM(15 号)终止信号,执行kill -USR1 12345则可主动发送 SIGUSR1(10 号)实现自定义进程通知;代码中可通过alarm(3)设置 3 秒后内核向当前进程发送 SIGALRM(14 号)闹钟信号,实现简单的定时逻辑,也可调用kill(pid, SIGCHLD)主动模拟子进程状态变化信号;此外,内核还会在检测到特定软件条件时自动发送信号,例如向无读端的管道写数据会触发 SIGPIPE(13 号)管道破裂信号,子进程退出时内核会向父进程发送 SIGCHLD(17 号)信号,让进程能及时响应各类软件层面的事件与异常。
小结
二者核心差异体现在触发根源、可控性与触发性质上:硬件触发的信号源于 CPU、终端等硬件层的动作或异常,分为硬件异常(如非法内存访问触发 SIGSEGV、除 0 触发 SIGFPE)和硬件操作(如 Ctrl+C 触发 SIGINT、Ctrl+Z 触发 SIGTSTP)两类,需经硬件中断告知内核后转化为信号,多为进程无法预判的被动触发,可控性低;而软件触发的信号全程无硬件参与,触发源均为软件层面,涵盖命令行手动操作(如 kill 命令发 SIGTERM)、代码系统调用 / 库函数调用(如 alarm () 触发 SIGALRM、kill () 发 SIGUSR1)、内核检测软件运行条件(如管道破裂触发 SIGPIPE、子进程退出触发 SIGCHLD)三种场景,以开发者可精准控制的主动触发为主,仅少数为内核自动检测的被动触发,可控性更高。
二、信号的保存
由于并不是所有信号产生后都能立马被处理,因而就产生了对信号的保存问题,那么信号是如何被记录的呢?又是如何被保存的呢?
在 Linux 内核中,信号的核心存储载体基于sigset_t类型实现,进程的未决信号、阻塞信号分别对应未决信号集 和阻塞信号集 两大结构。对于 1-31 号普通信号,这两个信号集均采用位图结构标记状态:阻塞信号集中,某一位为 1 表示对应信号被进程主动屏蔽(阻塞状态),为 0 则表示未阻塞;未决信号集中,位值 1 标识该信号已产生但尚未递达处理,0 则表示无该信号未决。
受位图 "仅标记有无、无顺序记录" 的局限性影响,内核处理多个未决普通信号时,会遵循「信号编号从小到大」的优先级规则(SIGKILL(9 号)和 SIGSTOP(19 号)例外,优先级最高,会被内核优先处理);而同一普通信号即便被多次发送,未决信号集也仅保留 1 次标记,内核递达时仅处理 1 次,剩余次数直接丢失,这也是普通信号易出现 "丢失" 的核心原因。内核通常会在进程从内核态返回用户态前,检查并处理未被阻塞的普通信号,完成后清除未决信号集中对应位的标记。
三、信号的递达动作(补充细节)
1.三种处理方式
当信号未阻塞且位于未决集中的时候,内核会在合适时机对进程进行信号处理,当开始调用信号执行函数的时候,信号称为递达状态,对于信号的处理方式分为三种大情况:执行默认处理、忽略信号、自定义执行。其中默认处理是内核提前设计的处理方式,比如SIGALRM 的默认处理动作是终止进程(Term)、SIGSTOP的默认处理动作是程序暂停。忽略信号就是进程对信号不作为,忽略处理。自定义执行是用户通过signal函数对指定信号进行指定处理函数,进程收到指定信号后会按照用户设计进行函数调用。
2.两种终止的区别 Core vs Term

关于信号引起的程序终止,其实分为两种情况,一种终止方式为Core,另一种为Term,那么两种终止有什么区别呢?
Term:仅终止进程
处理终止类信号以Term执行方式执行默认处理时,进程会被内核直接终止,无任何额外资源操作。
Core:终止进程 + 生成 core dump 文件
处理终止类信号以Core执行方式执行默认处理时,进程会被内核终止,且会先生成core dump核心转储文件 (一种二进制文件),该文件会完整保存进程终止瞬间的内存数据、寄存器状态、函数调用栈、程序运行上下文等关键信息,再完成进程终止操作;核心转储文件是程序崩溃后的重要调试依据,开发者可通过gdb等调试工具加载该文件,还原进程崩溃时的运行状态,快速定位段错误、浮点异常等程序崩溃原因(需提前通过`ulimit -c unlimited`开启系统的核心转储功能,否则不会生成该文件)。通过转储文件进行调试的方法叫做事后调试(post-mortem debugging),也叫离线调试,和程序运行时的实时调试形成对应。
开启 core dump:ulimit -c unlimited 配置
通常云服务器不会默认开启core dump开关,导致进程在core终止后并没有所谓的转储文件,那么如果需要生成转储文件如何打开开关呢?首先我们可以在bash输入指令ulimit -a查看进程资源限制(含 core 文件大小)

其中core file size默认是0,也就是默认不会生成core文件,此时我们只需要用ulimit指令进行修改,可以看到core文件对应选项为-c,并且需要注意不同文件给的基础单位不同,core对应基础单位为block(ulimit中block与文件系统对应block不同,ulimit中1block=1KB)
bash
ulimit -c 【size】(自定义填入大小)
假如设置core文件最大为40MB,输入指令ulimit -c 40960即可

我们修改完后进行一个小测试:写一段简单的除0错误代码(注意:想要生成更方便调试的core文件的话需要携带-g选项)

会发现此时显示core dumped,表示转储文件已经生成,这个文件会在默认路径下生成,不同系统默认路径可能不同,以我的机器为例:/var/lib/apport/coredump/就是默认路径
bash
/var/lib/apport/coredump/core._home_lsir_learing_signal_a_out.1003.e12b6222-fc02-419c-8c77-13b8756c2c6a.471512.1039447805
如果想要依靠这个文件进行调试的话,需要使用gdb+可执行文件路径+core文件路径进行打开,由于core文件是二进制文件,直接打开会出现乱码:

可以看到,文件直接保存了程序异常的行数以及异常原因。
四、信号状态与保存的核心操作接口
1.sigprocmask函数

如果oldset是⾮空指针,则读取进程的当前信号屏蔽字通过oldset参数传出。如果set是⾮空指针,则 更改进程的信号屏蔽字,参数how指⽰如何更改。如果oldset和set都是⾮空指针,则先将原来的信号 屏蔽字备份到oldset⾥,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

简单来说使用SIG_BLOCK时传入的set只需要标记需要增加的阻塞信号位,SIG_UNBLOCK使用时只需要在传入的set中标记需要取消的阻塞信号,SIG_SETMASK在使用时会将传入的set覆盖式写入整张block表,其中set是我们用于对block表修改的标记表,而oldset会记录旧的block表以免丢失数据。
2.sigpending函数

这个函数相对简单,就是将进程的pending表写入到set中并进行返回,失败则返回-1,成功返回0.。
代码实战
了解了上面两个函数,其实我们就可以设计一段代码来观察pending和block之间的关系与机制
思路:自定义捕获信号2(SIGINT),也就是Ctrl+C,随后利用sigprocmask函数对block表进行修改,屏蔽信号2,此时若进程收到信号2会发生阻塞无法递达,我们利用sigpending进行pending表打印观察信号2位置置1,一段时间后解开屏蔽,就会发现此时pending表置0且触发自定义函数调用。
所用额外函数
sigemptyset():作用是将 sigset_t 类型的信号集变量初始化为空集(清空所有信号的标记位),确保信号集中没有任何信号被置位,避免变量中残留的随机内存值导致信号操作(如阻塞、查询)出现不可预期的结果。
sigaddset():作用是对sigset_t类型变量指定位置进行置1标记。
实战代码:
cpp
#include <signal.h>
#include <iostream>
#include <unistd.h>
void Printpending(sigset_t &pending)
{
std::cout << "current process pid[" << getpid() << "] pending : " << std::endl;
for (int i = 31; i >= 1; i--)
{
if (sigismember(&pending, i))
{
std::cout << '1';
}
else
{
std::cout << '0';
}
}
std::cout << std::endl;
}
void handler(int sig)
{
std::cout << sig << "号信号已被递达!!!" << std::endl;
sigset_t pending;
sigpending(&pending);
Printpending(pending);
}
int main()
{
signal(2, handler);
sigset_t set, oldset;
sigemptyset(&set);
sigemptyset(&oldset);
sigaddset(&set, 2);
sigprocmask(SIG_BLOCK, &set, &oldset);
int cnt = 10;
while (true)
{
sigset_t set_main;
sigemptyset(&set_main);
sigpending(&set_main);
Printpending(set_main);
cnt--;
if (cnt == 0)
{
std::cout << "已解除信号屏蔽!!!" << std::endl;
sigprocmask(SIG_SETMASK, &oldset, &set);
}
sleep(1);
}
}
执行结果:

实验结果可以看到在解除屏蔽后,立马执行了自定义函数并且pending中2信号的位置由1->0(其实真实顺序是pending表先恢复为0,随后执行自定义函数,输出顺序可能会导致误解)
3.SIGCHLD 特殊处理:默认忽略 vs 显式忽略(僵尸进程回收差异)
`SIGCHLD`是Linux系统中专门用于进程间状态通知的核心信号,由内核主动向父进程发送,其触发条件明确且唯一------当父进程创建的子进程发生状态变化时,无论子进程是正常终止、异常终止,还是被暂停运行、恢复运行,内核都会立即向对应的父进程发送`SIGCHLD`信号,其核心作用就是向父进程传递"子进程状态已改变"的通知,提醒父进程及时对子进程的相关资源进行处理,避免系统资源浪费。而在实际开发中,`SIGCHLD`信号最容易被混淆的点,就是其"默认忽略"与"显式忽略"两种处理方式,二者看似都是对信号的忽略,却在子进程资源回收(尤其是僵尸进程的产生与否)上存在本质区别,且直接影响进程管理的合理性与系统资源的稳定性。
其中,`SIGCHLD`的默认忽略,指的是父进程未对该信号做任何主动的配置和处理,此时内核会采用`SIG_DFL`(默认处理动作)对`SIGCHLD`信号进行处理,而该默认动作的核心就是单纯丢弃信号,不做任何额外的资源回收操作。这种情况下,子进程退出后,其进程控制块(PCB)所占用的系统资源不会被内核自动释放,子进程会以僵尸进程的形式驻留在内核中,直到父进程主动调用`wait()`或`waitpid()`系统调用,获取子进程的退出状态(如退出码、终止原因等)后,才能彻底释放其占用的PCB资源和PID资源;若父进程长期运行且未执行回收操作,大量僵尸进程会不断累积,最终可能导致系统PID资源耗尽,影响其他进程的正常创建。
与之相对的显式忽略,则是父进程主动通过编程手段,将`SIGCHLD`信号的处理动作设置为`SIG_IGN`(忽略信号),常用的实现方式包括调用`signal(SIGCHLD, SIG_IGN)`函数,或通过`sigaction()`函数配置信号处理结构体,将`sa_handler`字段设为`SIG_IGN`。这种主动声明的忽略方式,会触发Linux内核的特殊处理逻辑------内核会感知到父进程无需关心子进程的状态变化,因此在子进程退出后,会直接自动回收其子进程的所有PCB资源,包括PID、退出状态等相关信息,无需父进程再手动调用`wait()`或`waitpid()`执行回收操作,从根源上避免了僵尸进程的产生。但需要注意的是,这种显式忽略的方式存在一定代价:由于内核已自动回收子进程的所有资源,父进程后续再调用`wait()`或`waitpid()`函数时,会返回-1,并将`errno`设为`ECHILD`(表示无子进程可回收),无法再获取该子进程的任何退出相关信息,因此仅适用于父进程无需关注子进程退出状态的场景。