Linux 引入信号机制,核心原因是:在操作系统层面提供一种"异步事件通知"手段,让进程能在正常执行流程之外,被内核、其他进程或硬件事件打断,并做出响应。
可以从几个角度理解它为什么必要:
1. 处理异步事件,而不需要进程一直轮询
很多事件的发生时间和进程当前在做什么无关,比如:
- 用户按了
Ctrl+C - 子进程结束
- 定时器到期
- 硬件异常(如除零、非法内存访问)
- 终端窗口大小改变
如果没有信号,进程要么得不断轮询"有没有事情发生",浪费 CPU;要么根本无法及时知道这些事件。信号提供了一种事件驱动的通知方式:事件发生时,内核直接"通知"进程。
2. 提供进程间通信和控制的轻量手段
信号可以用于:
- 父进程通知子进程退出(如
SIGTERM) - 一个进程通知另一个进程重新加载配置(如
SIGHUP) - 进程间简单同步或唤醒
- shell 作业控制(如
SIGSTOP、SIGCONT暂停/继续进程)
它比管道、消息队列、共享内存等 IPC 机制更轻量,适合传递"发生了某件事"这种简单信息,而不是大量数据。
3. 统一处理硬件和软件异常
当进程执行了非法操作,比如:
- 访问无效内存 →
SIGSEGV - 除零 →
SIGFPE - 执行非法指令 →
SIGILL
内核需要一种方式把异常"报告"给进程。信号就是这种机制:内核向进程发送相应信号,进程可以选择终止、忽略或捕获后自行处理(例如打印堆栈、生成 core dump)。
4. 支持进程生命周期管理
操作系统和 shell 需要能控制进程:
- 强制终止:
SIGKILL - 请求优雅退出:
SIGTERM - 中断前台任务:
SIGINT - 暂停/恢复:
SIGSTOP/SIGCONT
没有信号,这些控制要么难以实现,要么需要更复杂的专门系统调用。
5. 与 Unix 设计哲学一致
Unix/Linux 倾向于提供小而通用的机制。信号不是为某个特定场景设计的,而是一个通用抽象:
- 内核可以发
- 进程可以发
- 硬件异常可以触发
- 用户可以通过终端发
同一个机制覆盖了通知、控制、异常、IPC 等多种需求,简洁且复用性强。
6. 历史与兼容性原因
信号机制从早期 Unix 就存在,后来被 POSIX 标准化。Linux 作为 Unix-like 系统继承并扩展了它(如实时信号 SIGRTMIN~SIGRTMAX)。大量现有软件、shell、库都依赖信号语义,因此它成为 Linux 进程模型中不可替代的一部分。
一句话总结
Linux 引入信号机制,是为了让进程能够异步地被通知和处理各种事件------包括用户输入、进程间控制、硬件异常和生命周期管理------而不必轮询,也不需要为每种事件设计一套单独的复杂接口。
一、信号和中断是不同的机制吗?
是的,它们本质上是不同层次的机制,但又有联系。
| 维度 | 中断(Interrupt) | 信号(Signal) |
|---|---|---|
| 层次 | 硬件/内核层 | 进程/用户层 |
| 触发者 | 硬件设备、CPU 异常、软件指令(int/syscall) |
内核、其他进程、终端、硬件异常经内核转换 |
| 接收者 | CPU / 内核 | 进程 |
| 处理者 | 内核中断处理程序(ISR) | 进程的信号处理函数,或默认动作 |
| 上下文 | 中断上下文,不是普通进程上下文 | 进程上下文(在返回用户态前处理) |
| 目的 | 让 CPU 暂停当前执行,处理紧急硬件/内核事件 | 通知进程发生了某个异步事件 |
| 是否可屏蔽 | 有可屏蔽/不可屏蔽之分 | 有可阻塞/不可阻塞之分(SIGKILL/SIGSTOP 不可阻塞) |
联系在于:
- 很多信号最初就是由中断或异常"转化"来的。比如:
- 用户按
Ctrl+C→ 键盘中断 → 内核终端驱动 → 向前台进程组发送SIGINT - 除零 → CPU 异常(一种中断) → 内核向进程发送
SIGFPE - 非法内存访问 → 缺页/保护异常 → 内核发送
SIGSEGV
- 用户按
- 中断是"硬件/内核级"的异步通知,信号是"进程级"的异步通知。可以理解为:中断是内核处理的事件,信号是内核通知进程的事件。
所以:不同机制,但信号常常由中断或异常触发。
二、如何编写信号处理代码
下面以 C 语言在 Linux 下为例。
1. 基本步骤
- 写一个信号处理函数(handler)
- 用
signal()或更推荐的sigaction()注册 - 处理信号
- 可选:恢复默认行为、阻塞信号等
2. 最简单例子:捕获 SIGINT
c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig) {
// 注意:信号处理函数里只能调用 async-signal-safe 的函数
write(1, "caught SIGINT\n", 14);
}
int main(void) {
// 注册处理函数
if (signal(SIGINT, handler) == SIG_ERR) {
perror("signal");
return 1;
}
while (1) {
pause(); // 等待信号
}
return 0;
}
按 Ctrl+C 时不再终止进程,而是打印信息。
三、应用程序不注册信号处理函数时,ctl+c功能也是正常的,到底谁在处理?
答案是:内核。
每个信号在内核里都有一个预设的"默认动作"(default action),常见的有:
| 默认动作 | 含义 | 例子 |
|---|---|---|
| Term | 终止进程 | SIGTERM、SIGINT、SIGKILL |
| Core | 终止并生成 core dump | SIGSEGV、SIGABRT、SIGFPE |
| Ign | 忽略 | SIGCHLD、SIGURG、SIGWINCH |
| Stop | 暂停进程 | SIGSTOP、SIGTSTP |
| Cont | 继续进程 | SIGCONT |
所以当你没有注册 handler 时,进程收到 SIGINT,内核就直接按默认动作 Term 把它终止掉。这不是某个用户态函数干的,是内核在递送信号时自己执行的。
四、内核怎么知道"没有处理函数"?
关键在于:每个进程在内核里都有一张信号处理表。
内核用 task_struct(进程描述符)里的 sighand_struct 来记录每个信号的处理方式。可以简化理解为一张表:
信号编号 → 处理方式
SIGINT → SIG_DFL(默认)
SIGUSR1 → 用户函数地址 0x4005a0
SIGCHLD → SIG_IGN(忽略)
...
对每个信号,处理方式只有三种可能:
SIG_DFL:默认动作,由内核执行SIG_IGN:忽略- 一个用户态函数地址:内核安排进程去执行它
初始化时
进程创建时(fork),内核把新进程的信号处理表继承自父进程 ;如果是初始进程,则所有信号都是 SIG_DFL。
注册时
当你调用:
c
signal(SIGINT, handler);
// 或
sigaction(SIGINT, &sa, NULL);
内核做的事就是:把当前进程信号表里 SIGINT 对应的项,从 SIG_DFL 改成你给的 handler 地址。
递送信号时
当信号产生,内核在递送前会查这张表:
- 如果是
SIG_DFL→ 直接执行默认动作(终止/忽略/暂停......) - 如果是
SIG_IGN→ 丢弃,什么都不做 - 如果是用户函数地址 → 内核不会直接调用它,而是修改进程的用户态栈和寄存器 ,让进程从内核返回用户态时,先去执行那个 handler,执行完再通过
sigreturn回到原来的执行点
所以"怎么知道没有处理函数"这件事,本质是:内核查表发现该项是 SIG_DFL,就知道没有用户处理函数,于是自己执行默认动作。
五、几个容易混淆的点
1. SIG_DFL 和 SIG_IGN 是特殊常量
它们不是函数地址,而是两个特殊值(通常是 0 和 1),内核靠它们区分"默认"和"忽略"。
c
#define SIG_DFL ((void (*)(int))0)
#define SIG_IGN ((void (*)(int))1)
2. 默认动作也分"终止"和"忽略"
比如 SIGCHLD 默认就是忽略,所以子进程退出时父进程不处理也不会出事,只是可能产生僵尸进程(因为忽略 ≠ 自动回收)。
3. 有些信号默认动作无法改变
SIGKILL 和 SIGSTOP 永远是默认动作,内核不允许你改它们的处理方式。
4. 默认动作里"Core"和"Term"的区别
Term:只是终止进程Core:终止并写 core dump 文件,方便调试
六、一句话总结
没有注册处理函数时,内核 根据进程信号表里该信号对应的 SIG_DFL 执行默认动作。内核之所以知道"没有处理函数",是因为它维护着每个进程的信号处理表,注册 handler 本质就是把表项从 SIG_DFL 改成用户函数地址;递送信号时查表,是 SIG_DFL 就自己执行默认动作,是用户函数地址就安排进程回用户态执行它。