你一定在 Linux 下用过 Ctrl+C 终止程序、用 kill 命令杀进程,也见过程序崩溃报 Segmentation fault ,这些日常操作背后,都是进程信号在起作用。
信号是 Linux 最基础、最核心的进程间异步通知机制,堪称进程的 "软中断"。本文用生活类比 + 代码实战 + 内核原理,带你从 0 到 1 彻底搞懂 Linux 信号。
一、信号是什么?先从生活看懂本质
先抛开复杂术语,用收快递完美类比信号机制:
- 你 = 进程
- 快递员 = 操作系统(OS)
- 快递 = 信号
- 取件通知 = 信号产生
- 暂时没空取、先记着 = 信号未决(Pending)
- 有空了再去取 = 信号递达(Delivery)
- 拆快递用 / 送人 / 扔一边 = 三种处理方式
由此得出信号4 大核心特性:
- 识别是内置的:进程天生 "认识" 信号,是内核写死的能力
- 处理方式提前定:信号没来,就已经知道该怎么处理
- 不是立即处理:进程可能在忙更高优先级的事,要等 "合适时机"
- 异步通知:进程不知道信号啥时候来,来了就响应
一句话总结:信号 = 内核发给进程的异步事件通知,是进程间最轻量的 "事件提醒"。
二、信号从哪来?5 大产生方式全覆盖
所有信号最终都由操作系统发送,来源分 5 类:
1. 终端按键产生(最常用)
Ctrl+C→ 发送 SIGINT(2):终止前台进程Ctrl+\→ 发送 SIGQUIT(3):终止并生成 core dumpCtrl+Z→ 发送 SIGTSTP(20):挂起前台进程
注意:
Ctrl+C只作用于前台进程 ,后台进程(加&运行)收不到。
2. 系统调用 / 命令产生
kill -信号 进程PID:手动发信号(如kill -9 PID强杀)kill():代码中给指定进程发信号raise():自己给自己发信号abort():给自己发 SIGABRT(6),强制异常退出
3. 软件条件触发
alarm(秒)→ 时间到发 SIGALRM(14)- 管道读端关闭,写端继续写 → SIGPIPE(13)
- 定时器超时、资源超限等
4. 硬件异常转化
硬件报错 → 内核解释成信号发给进程:
- 除 0 运算 → SIGFPE(8)
- 野指针 / 非法访问 → SIGSEGV(11)
- MMU 异常、指令非法等
5. 子进程退出通知
子进程退出 / 停止 → 父进程收到 SIGCHLD(17),默认忽略
三、信号来了怎么处理?3 种处理方式
进程对任何信号,只有3 种合法处理动作:
1. 默认动作(SIG_DFL)
- 多数信号:终止进程
- 部分信号:终止 + core dump(方便事后调试)
SIGCHLD:默认忽略
2. 忽略信号(SIG_IGN)
- 收到信号直接丢掉,不做任何处理
- 例外 :
SIGKILL(9)、SIGSTOP(19)不能忽略、不能捕获、不能阻塞,是系统 "终极权限" 信号
3. 自定义捕捉(信号捕获)
用 signal()/sigaction() 注册回调函数,信号来了执行你写的逻辑。
极简示例(捕获 Ctrl+C):
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
void handler(int sig) {
std::cout << "捕获到信号:" << sig << ",我不退出!\n";
}
int main() {
signal(SIGINT, handler); // 注册 2 号信号处理函数
while (1) {
std::cout << "运行中...\n";
sleep(1);
}
}
运行后按 Ctrl+C,进程不会退出 ,只会打印提示 ------ 这就是自定义捕捉的威力。
四、信号的一生:产生 → 保存 → 递达
信号不是 "来了就立刻处理",完整生命周期分 3 步:
1. 信号产生
OS 检测到事件,给目标进程发信号。
2. 信号保存(内核层核心)
进程用 3 个结构 管理信号,都在 PCB(task_struct)里:
- Pending 未决信号集:已产生、但还没处理的信号(用位图记录)
- Block 阻塞信号集(信号屏蔽字):被 "屏蔽" 的信号,产生了也暂时不递达
- Handler 处理函数指针:每个信号对应处理方式(默认 / 忽略 / 自定义)
关键结论:
- 阻塞 ≠ 忽略:阻塞只是 "暂缓处理",解除阻塞后照样递达
- 常规信号(1--31)多次产生只记录 1 次,不排队
- 实时信号(34+)支持排队,本章不讨论
3. 信号递达
从内核态 切回用户态的 "合适时机",处理未阻塞的未决信号:
- 系统调用返回
- 中断 / 异常处理完
- 时钟中断返回前
五、核心 API:信号集操作实战
想手动控制阻塞 / 未决,用这 4 个信号集函数 + 2 个系统调用:
1. 信号集操作函数
cpp
#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);
2. 读取 / 修改阻塞信号集
cpp
// 操作 Block 表
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
SIG_BLOCK:屏蔽 set 中的信号SIG_UNBLOCK:解除屏蔽SIG_SETMASK:直接设置为 set
3. 读取未决信号集
cpp
// 获取当前 Pending 信号集
int sigpending(sigset_t *set);
六、信号捕捉完整流程:用户态 ↔ 内核态切换
自定义信号处理,是面试最高频考点,完整流程如下:
- 进程在用户态运行 main 函数
- 发生中断 / 系统调用 → 切内核态
- 内核处理完,返回用户态前:检查未决信号
- 发现信号待处理,且是自定义捕捉
- 切回用户态,执行你的 handler 函数
- handler 执行完,自动切回内核态
- 无新信号 → 恢复 main 上下文,继续运行
关键点:
- 信号处理函数在用户态执行,保证内核安全
- 处理信号时,内核会自动阻塞当前信号,防止重入混乱
七、进阶关键:volatile 与可重入函数
1. volatile:解决编译器优化导致的 "数据看不见"
cpp
// 不加 volatile,O2 优化后 flag 会被放进寄存器,信号修改后主流程看不见
volatile int flag = 0;
void handler(int sig) {
flag = 1;
}
- 作用:保持内存可见性,禁止编译器优化,每次都从内存读最新值
- 场景 :信号处理函数与主流程共享的变量,必须加
volatile
2. 可重入函数
- 可重入:函数被中断后重入,执行结果不乱(只访问局部变量 / 参数)
- 不可重入 :调用
malloc/free、标准 I/O、全局变量等,信号重入会崩溃 - 信号安全 :handler 里只调用异步信号安全函数
八、SIGCHLD:优雅回收子进程,告别僵尸进程
父进程不用死循环 waitpid 轮询,靠信号自动回收:
cpp
void handler(int sig) {
// 非阻塞循环回收所有退出子进程
while (waitpid(-1, NULL, WNOHANG) > 0);
}
int main() {
signal(SIGCHLD, handler); // 注册子进程退出信号
if (fork() == 0) {
sleep(2);
exit(0);
}
while (1) pause(); // 父进程安心做自己的事
}
- 子进程退出 → 发
SIGCHLD→ 父进程回调回收 - 无僵尸进程,无轮询消耗
九、高频面试题(一文吃透)
- **信号是同步还是异步?**异步。进程不知道信号何时到来,随机触发处理。
- **
SIGKILL为什么不能捕获 / 忽略?**内核保留的终极权限,防止进程 "卡死杀不死"。 - **阻塞和忽略的区别?**阻塞:暂不处理,保留未决状态;忽略:直接丢弃。
- **信号什么时候被处理?**从内核态切回用户态的 "合适时机"。
- **信号处理函数为什么要加 volatile?**防止编译器优化,保证主流程能看到信号修改的值。
十、总结
Linux 信号本质就是:内核给进程发的异步软中断,用 "产生→保存→递达" 完成事件通知,支持默认 / 忽略 / 自定义 3 种处理。
从 Ctrl+C 到进程异常、子进程回收、定时器,信号无处不在。理解它,你就真正看懂了 Linux 进程的 "事件驱动模型",写出更稳定、更优雅的系统程序。