跳到主要内容深入理解 Linux 信号机制:从 task_struct 到信号递达 | 极客日志C++算法
深入理解 Linux 信号机制:从 task_struct 到信号递达
Linux 信号机制基于内核 task_struct 结构,包含 Pending、Block 与 Handler 三个核心概念。信号发送通过修改 PCB 位图完成,Pending 表记录未决信号,Block 表控制阻塞状态。用户态通过 sigset_t 类型及系统调用接口访问内核数据。信号递达发生在进程从内核态返回用户态前,此时检查状态并执行处理动作。SIGCHLD 信号常用于处理子进程回收,防止僵尸进程。信号处理需注意可重入性,部分信号如 SIGKILL 不可屏蔽。
并发大师8 浏览 深入理解 Linux 信号机制
在学习 Linux 进程间通信时,信号往往是最早接触、却又最容易被'用而不懂'的一种机制。很多时候,我们能够熟练使用 kill、signal、sigaction,却并不清楚 信号在内核中究竟经历了哪些阶段。
本文将结合 Linux 内核中的 task_struct 结构,围绕信号的 Pending、Block 与 Handler 三个核心概念,对信号的发送、保存以及递达过程进行系统梳理,帮助读者从内核层面真正理解 Linux 信号机制。
什么是信号的发送

一个进程在同一时刻可以接收并保存多个信号,但同一种普通信号最多只会被记录一次。操作系统并未使用数组来保存信号,因为那样会占用过多空间。相反,操作系统采用了位图的方式,用一个整数即可表示所有的信号状态。

通过这种方式,接收到哪个信号就将对应位置的比特位置为 1,未收到则置为 0。由于没有 0 号信号,0 号位置通常置为 0。往具体点讲,操作系统是给进程的 PCB(即 task_struct)发送信号,真实的动作是修改 task_struct 的信号位图中对应的比特位。
信号的保存
信号的处理方式有三种:默认处理、忽略和自定义处理。无论哪种方式,实际执行信号时的处理动作称为信号递达。
将信号保存到进程的 PCB 中,用位图表示信号从产生到递达之间的状态,称为信号未决 (Pending)。
信号在内核中的表示示意图:

普通信号的范围是 1-31,每种信号都有处理方法。在进程的 PCB 中,操作系统维护一个 handler 表,这是一个函数指针数组。每个元素都是函数指针,默认指向操作系统设定的方法,用户可通过 signal() 接口设置新的函数地址。


signal 函数的第一个参数是 int 类型,作为数组下标定位 handler 表;第二个参数是函数指针。这样便实现了信号的自定义行为。
在进程 PCB 中存在三张关键表:
- Pending 表:保存哪些信号已产生但未递达。
Handler 表:记录信号递达时使用的处理方法。Block 表:用于阻塞特定信号。进程可以选择阻塞某个信号。一旦信号被屏蔽,即使处于未决状态也不会被处理,直到解除阻塞。阻塞和忽略不同:阻塞是未读,忽略是已读不回。
阻塞与信号是否产生无关,即使信号未产生也可以预先屏蔽。Block 表和 Pending 表一样,都是位图表。当 Block 表中对应信号位为 0 时不阻塞,为 1 时阻塞。
- 每个信号有两个标志位分别表示阻塞 (block) 和未决 (pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除。
- SIGINT 信号产生过,但正在被阻塞,所以暂时不能递达。
- SIGQUIT 信号未产生过,一旦产生将被阻塞,处理动作为用户自定义函数。
sigset_t
未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,称为信号集。它可以表示每个信号的'有效'或'无效'状态。在阻塞信号集中,'有效'指被阻塞;在未决信号集中,'有效'指处于未决状态。
这三张表属于内核数据结构,不允许用户直接修改。操作系统提供了函数调用接口供用户使用。用户层需要定义 sigset_t 类型的变量,通过系统调用接口获取内核数据,涉及用户空间与内核空间的数据拷贝。
信号集操作函数
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
sigemptyset:初始化信号集,所有信号 bit 清零。
sigfillset:初始化信号集,所有信号 bit 置为 1。
sigaddset:添加某种有效信号。
sigdelset:删除某种有效信号。
sigismember:判断信号集中是否包含某种信号。
使用前必须调用 sigemptyset 或 sigfillset 初始化。这四个函数成功返回 0,出错返回 -1。sigismember 若包含返回 1,不包含返回 0,出错返回 -1。
sigprocmask
调用 sigprocmask 可以读取或更改进程的信号屏蔽字 (Block 信号集)。
如果 oldset 非空,读取当前信号屏蔽字;如果 set 非空,更改信号屏蔽字。how 参数指示如何更改。
sigpending
读取当前进程在内核中的未决信号集,通过 set 参数传出。成功返回 0,失败返回 -1。
以下代码演示了屏蔽 2 号信号后,通过 Ctrl+C 输入信号,观察 pending 信号集的变化。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void printPending(sigset_t &pending) {
for (int i = 31; i >= 1; i--) {
if (sigismember(&pending, i) == 1) {
std::cout << "1";
} else {
std::cout << "0";
}
}
std::cout << std::endl;
}
int main() {
sigset_t set, oldset;
sigemptyset(&set);
sigemptyset(&oldset);
sigaddset(&set, 2);
sigprocmask(SIG_SETMASK, &set, &oldset);
sigset_t pending;
while (1) {
sigpending(&pending);
printPending(pending);
sleep(1);
}
return 0;
}
可以看到 pending 信号集确实保存了 2 号信号。为了验证解除屏蔽后信号递达,我们需要自定义信号处理方式,否则进程会结束。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void printPending(sigset_t &pending) {
for (int i = 31; i >= 1; i--) {
if (sigismember(&pending, i) == 1) {
std::cout << "1";
} else {
std::cout << "0";
}
}
std::cout << std::endl;
}
void myhandler(int signum) {
std::cout << "get a signal : " << signum << std::endl;
}
int main() {
signal(2, myhandler);
sigset_t set, oldset;
sigemptyset(&set);
sigemptyset(&oldset);
sigaddset(&set, 2);
sigprocmask(SIG_SETMASK, &set, &oldset);
int count = 1;
sigset_t pending;
while (1) {
sigpending(&pending);
printPending(pending);
count++;
if (count == 20) {
sigprocmask(SIG_SETMASK, &oldset, nullptr);
}
sleep(1);
}
return 0;
}
流程符合预期:屏蔽期间信号保持未决,解除屏蔽后递达并执行 handler。值得注意的是,9 号和 19 号信号是无法被屏蔽的。
除了 9 号和 19 号信号,其余普通信号均可被屏蔽。
信号的捕捉处理
信号产生后可能不会立即处理,而是暂存于 Pending 表。合适的时候根据 Handler 表执行动作。这个'合适的时候'是进程从内核态返回用户态时。
进程只有从用户态切换为内核态时才有权访问内核数据结构。进程进入内核态有三种情况:异常、中断、系统调用。例如 C 标准库中的 scanf 和 printf 底层调用系统接口 read 和 write。
Linux 系统中,每个进程拥有独立的虚拟地址空间,由 task_struct 和 mm_struct 管理。32 位 Linux 下,虚拟地址空间通常划分为 4GB:低 3GB 为用户空间,高 1GB 为内核空间。用户空间内存分布包括代码段、堆、栈等。内核空间在所有进程中共享。
虚拟地址通过页表映射到物理内存。每个进程拥有自己的用户级页表,所有进程共享内核级页表。
CPU 中有一个 CS 寄存器 (代码段寄存器),其低 2 位表示权限级别。00 表示内核态,11 表示用户态。信号处理需要在内核态检查三张表,并在返回用户态前处理。
- 进程因中断、异常或系统调用进入内核。
- 准备返回用户态前,检查 Pending 表、Block 表和 Handler 表,处理可递达的信号。
- 若为自定义行为,进程从内核态转变为用户态执行处理函数。
- 执行完自定义函数后再次进入内核。
- 最后返回用户态继续执行主程序。
关于信号处理函数为何需要返回用户态再执行:虽然内核态权限更高,但为了防止用户代码滥用权限,操作系统要求回到用户态执行。信号处理函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态,若无新信号则恢复主函数上下文。
Pending 表中的比特位在何时由 1 变为 0?是在信号处理之前还是之后?
sigaction
| 参数 | 含义 |
|---|
signum | 信号编号 |
act | 新的信号处理方式 |
oldact | 保存旧的处理方式 |
sigaction 函数可以读取和修改与指定信号相关联的处理动作。成功返回 0,失败返回 -1。
实验验证:在信号处理函数中打印 Pending 表。如果此时对应信号位已变为 0,说明递达前已清除。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void printPending() {
sigset_t pending;
sigemptyset(&pending);
sigpending(&pending);
for (int i = 31; i >= 1; i--) {
if (sigismember(&pending, i) == 1) {
std::cout << "1";
} else {
std::cout << "0";
}
}
std::cout << std::endl;
}
void myhandler(int signum) {
printPending();
std::cout << "get a signal : " << signum << std::endl;
}
int main() {
struct sigaction act;
act.sa_handler = myhandler;
sigaction(2, &act, nullptr);
while (1) {
std::cout << "this is a process : " << getpid() << std::endl;
sleep(1);
}
return 0;
}
结果确认:Pending 由 1 变为 0 是在信号处理之前完成的。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,处理函数返回时自动恢复原来的信号屏蔽字。这防止了信号处理过程中的嵌套调用(套娃)。如果希望额外屏蔽其他信号,可使用 sa_mask 字段。
int main() {
struct sigaction act;
act.sa_handler = myhandler;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 1);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaction(2, &act, nullptr);
while (1) {
std::cout << "this is a process : " << getpid() << std::endl;
sleep(1);
}
return 0;
}
可重入函数
- 如果函数被不同的控制流程调用,且第一次调用未返回时就再次进入该函数,称为重入。访问全局变量的函数可能因重入造成错乱,称为不可重入函数。反之,只访问局部变量或参数的函数称为可重入 (Reentrant) 函数。
SIGCHLD 信号
子进程退出时会向父进程发送 SIGCHLD (17 号) 信号。父进程可以通过自定义该信号的处理方式来回收子进程,避免僵尸进程。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void myhandler(int signum) {
std::cout << "get a signal : " << signum << std::endl;
}
int main() {
signal(SIGCHLD, myhandler);
pid_t id = fork();
if (id == 0) {
int cnt = 5;
while (cnt--) {
std::cout << "this is child process , pid : " << getpid() << std::endl;
sleep(1);
}
std::cout << "child process quit!" << std::endl;
exit(1);
}
while (1) {
std::cout << "this is father process, pid : " << getpid() << std::endl;
sleep(1);
}
return 0;
}
在信号处理函数中增加进程等待 (waitpid),父进程无需一直等待子进程,达到双赢。
void myhandler(int signum) {
sleep(5);
int rid = waitpid(-1, nullptr, 0);
std::cout << "get a signal : " << signum << " , rid : " << rid << std::endl;
}
如果同时创建许多子进程,信号处理中应循环回收所有子进程,防止内存泄漏。
void myhandler(int signum) {
sleep(5);
int rid = waitpid(-1, nullptr, 0);
while (rid > 0) {
rid = waitpid(-1, nullptr, 0);
std::cout << "get a signal : " << signum << " , rid : " << rid << std::endl;
}
}
也可以使用非阻塞方式等待,当 waitpid 返回 0 时表示没有子进程退出,处理结束。
void myhandler(int signum) {
sleep(5);
int rid = waitpid(-1, nullptr, WNOHANG);
while (rid > 0) {
rid = waitpid(-1, nullptr, 0);
std::cout << "get a signal : " << signum << " , rid : " << rid << std::endl;
}
}
另一种避免僵尸进程的方法是:将 SIGCHLD 的处理动作设置为 SIG_IGN,子进程终止时会自动清理。
本文介绍了 Linux 信号机制的核心原理及实践应用。
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online