深入解析 Linux 信号机制(一):信号基本概念与处理方式
从生活快递到内核软中断,一文读懂信号的产生、传递与处理
目录
- [深入解析 Linux 信号机制(一):信号基本概念与处理方式](#深入解析 Linux 信号机制(一):信号基本概念与处理方式)
-
- 前言
- 一、从生活角度理解信号
- 二、技术角度:信号是什么?
-
- [2.1 信号的定义](#2.1 信号的定义)
- [2.2 经典案例:Ctrl-C 终止前台进程](#2.2 经典案例:Ctrl-C 终止前台进程)
- [2.3 前台进程 vs 后台进程](#2.3 前台进程 vs 后台进程)
- [三、Linux 信号列表](#三、Linux 信号列表)
- 四、信号的三种处理方式
-
- [4.1 忽略信号(Ignore)](#4.1 忽略信号(Ignore))
- [4.2 执行默认动作(Default)](#4.2 执行默认动作(Default))
- [4.3 捕捉信号(Catch)](#4.3 捕捉信号(Catch))
- 五、信号处理流程详解
-
- [5.1 信号的生命周期](#5.1 信号的生命周期)
- [5.2 信号处理的时机](#5.2 信号处理的时机)
- [5.3 信号阻塞(Blocking)](#5.3 信号阻塞(Blocking))
- [六、Core Dump:调试的利器](#六、Core Dump:调试的利器)
-
- [6.1 开启 Core Dump](#6.1 开启 Core Dump)
- [6.2 使用 GDB 调试 Core 文件](#6.2 使用 GDB 调试 Core 文件)
- [6.3 Core Dump 示例](#6.3 Core Dump 示例)
- 七、常用信号函数(基础版)
-
- [7.1 `signal()` ------ 简化版注册函数](#7.1
signal()—— 简化版注册函数) - [7.2 `kill()` ------ 向进程发送信号](#7.2
kill()—— 向进程发送信号) - [7.3 `raise()` ------ 向自身发送信号](#7.3
raise()—— 向自身发送信号) - [7.4 `pause()` ------ 等待信号](#7.4
pause()—— 等待信号)
- [7.1 `signal()` ------ 简化版注册函数](#7.1
- [八、完整示例:捕捉 SIGINT](#八、完整示例:捕捉 SIGINT)
- 九、总结
前言
在 Linux 系统编程中,信号(Signal) 是一种古老而重要的进程间通信方式。它用于通知进程某个事件已经发生,例如用户按下 Ctrl-C、除零错误、定时器超时等。信号是一种异步通知机制,进程无法预知信号何时到达,只能预先设置好处理方式,在信号到达时做出响应。
理解信号是掌握 Linux 系统编程的必备技能。本文将用生活化的比喻、具体的代码示例和系统调用接口,带你从零开始认识信号。
一、从生活角度理解信号
假设你在网上买了很多商品,等待不同快递的到来:
- 识别快递:你知道快递员打电话时应该怎么处理(签收、转交或拒收)。这就像进程知道每个信号的含义和默认行为。
- 异步到达:快递员什么时候打电话你无法预测。信号也是异步的,进程不知道何时会收到信号。
- 延迟处理 :快递到了,但你正在打游戏,可以 5 分钟后再去取。信号也不必立即处理,进程可以在合适的时机(从内核态返回用户态时)处理。
- 记忆通知 :快递员通知你后,你"记住了"有快递要取,即使还没拿到手。进程收到信号后,会在
pending位图中记录,待处理。 - 处理方式:拿到快递后,你可以(1)直接使用(默认动作);(2)转送他人(自定义处理);(3)扔一边不管(忽略)。对应信号的三种处理方式。
对应关系:进程就是你,操作系统是快递员,信号就是快递。
二、技术角度:信号是什么?
2.1 信号的定义
信号(Signal) 是 Linux 系统提供的一种软件中断 机制,用于进程间异步通知事件。它可以由内核(硬件异常、终端输入)、其他进程(kill 系统调用)或自身(raise)产生。
信号相对于进程执行流是异步的------进程不知道信号何时到达,它的到来会打断正常的执行序列(除非被阻塞)。
2.2 经典案例:Ctrl-C 终止前台进程
当你运行一个死循环程序时:
c
// sig.c
#include <stdio.h>
#include <unistd.h>
int main() {
while (1) {
printf("I am a process, I am waiting signal!\n");
sleep(1);
}
return 0;
}
编译运行后,按下 Ctrl-C,进程立即终止。这个过程发生了什么?
- 用户按下
Ctrl-C,键盘驱动产生一个硬件中断。 - 操作系统捕获中断,将其解释为 SIGINT 信号(编号 2)。
- 内核向当前前台进程发送 SIGINT。
- 进程收到信号,默认动作是终止进程,于是进程退出。
2.3 前台进程 vs 后台进程
- 前台进程 :占用终端,可以接收终端产生的信号(如
Ctrl-C、Ctrl-\)。 - 后台进程 :不占用终端,无法接收终端信号(除非用
fg调回前台)。
启动后台进程:./sig &,此时按下 Ctrl-C 不会影响它。
Shell 可以同时运行一个前台进程和多个后台进程,但只有前台进程能收到终端控制键产生的信号。
三、Linux 信号列表
使用 kill -l 命令查看系统支持的信号(不同架构可能略有差异):
bash
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN ... (实时信号)
编号 1~31 是不可靠信号 (非实时),不支持排队(多个相同信号可能丢失)。编号 34 以上是实时信号,支持排队,本文暂不讨论。
每个信号都有默认行为(man 7 signal 查看详细说明),常见的有:
| 信号 | 编号 | 默认动作 | 产生条件 |
|---|---|---|---|
| SIGINT | 2 | 终止 | 用户按 Ctrl-C |
| SIGQUIT | 3 | 终止+Core Dump | 用户按 Ctrl-\ |
| SIGKILL | 9 | 终止 | kill -9,不可捕获/忽略 |
| SIGSEGV | 11 | 终止+Core Dump | 无效内存访问(段错误) |
| SIGTERM | 15 | 终止 | kill 默认,可捕获 |
| SIGCHLD | 17 | 忽略 | 子进程状态变化,父进程可捕获 |
四、信号的三种处理方式
进程收到信号后,可以采取三种处理方式:
4.1 忽略信号(Ignore)
进程告诉内核:这个信号来了我不在乎,直接丢弃。但 SIGKILL 和 SIGSTOP 不能被忽略,这是为了确保系统管理员能终止失控进程。
c
signal(SIGINT, SIG_IGN); // 忽略 Ctrl-C
4.2 执行默认动作(Default)
每个信号都有系统预定义的默认动作,通常是终止、终止+Core Dump、停止或继续。
c
signal(SIGINT, SIG_DFL); // 恢复默认行为(终止)
4.3 捕捉信号(Catch)
进程注册一个自定义函数(信号处理函数),当信号到达时,内核会暂停进程的正常执行流,切换到用户态执行该函数,完成后返回之前被打断的位置继续执行。
c
void handler(int sig) {
printf("Received signal %d\n", sig);
}
signal(SIGINT, handler);
注意 :信号处理函数中应使用异步安全函数 (如
write,不要用printf,或者确保不产生重入问题)。
五、信号处理流程详解
5.1 信号的生命周期
- 产生(Generate):硬件中断、系统调用、软件条件(如除零)导致信号产生。
- 递送(Delivery):信号被实际发送给进程,进程开始处理。
- 未决(Pending) :信号已产生但尚未递送(被阻塞)。进程的
pending位图记录哪些信号在等待处理。
5.2 信号处理的时机
信号处理不是立即发生 的,而是在进程从内核态返回用户态时检查是否有未决信号,如果有则处理。这保证了内核态操作(如系统调用)的原子性。
例如,当进程执行系统调用(如 read)时,若收到信号,系统调用可能被中断并返回 -1,errno 设为 EINTR。
5.3 信号阻塞(Blocking)
进程可以阻塞某些信号,使其保持未决状态,直到解除阻塞后才递送。阻塞不同于忽略,阻塞是"暂时不处理",而忽略是"直接丢弃"。
使用 sigprocmask 可以设置或获取进程的信号掩码。
六、Core Dump:调试的利器
当某些信号(如 SIGSEGV、SIGQUIT)的默认动作是"终止并产生 Core Dump"时,内核会将进程的内存映像(包括堆栈、寄存器等)写入一个名为 core 的文件中,供调试器(如 gdb)事后分析。
6.1 开启 Core Dump
默认情况下,Core Dump 可能是禁用的(文件大小限制为 0)。使用 ulimit 命令开启:
bash
ulimit -c unlimited # 取消大小限制
然后运行一个会崩溃的程序,就会生成 core 文件。
6.2 使用 GDB 调试 Core 文件
bash
gdb ./program core
(gdb) bt # 查看调用栈
(gdb) info registers
6.3 Core Dump 示例
编写一个故意访问空指针的程序:
c
// crash.c
int main() {
int *p = NULL;
*p = 42; // 段错误,产生 SIGSEGV
return 0;
}
编译运行(开启 Core Dump):
bash
gcc crash.c -o crash
./crash
Segmentation fault (core dumped)
然后可以用 gdb 分析 core 文件定位错误。
七、常用信号函数(基础版)
7.1 signal() ------ 简化版注册函数
c
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
- 参数:信号编号,处理函数指针(或
SIG_IGN/SIG_DFL)。 - 返回值:之前的处理函数指针。
- 缺点:行为在不同 Unix 系统上有所差异,且不支持阻塞、传递额外信息等。
7.2 kill() ------ 向进程发送信号
c
#include <signal.h>
int kill(pid_t pid, int sig);
pid > 0:发送给指定进程。pid == 0:发送给同组所有进程。pid == -1:发送给所有有权限发送的进程(小心使用)。- 返回值:0 成功,-1 失败。
7.3 raise() ------ 向自身发送信号
c
int raise(int sig); // 等价于 kill(getpid(), sig)
7.4 pause() ------ 等待信号
c
int pause(void); // 挂起调用线程,直到收到信号(被信号处理函数中断后返回 -1)
八、完整示例:捕捉 SIGINT
c
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void sigint_handler(int sig) {
printf("Caught SIGINT (%d), but I won't exit!\n", sig);
// 注意:printf 在信号处理函数中并不异步安全,这里仅作演示
}
int main() {
// 注册 SIGINT 处理函数
if (signal(SIGINT, sigint_handler) == SIG_ERR) {
perror("signal");
exit(1);
}
printf("Running... Press Ctrl-C to trigger handler.\n");
while (1) {
printf("Sleeping...\n");
sleep(3);
}
return 0;
}
编译运行,按下 Ctrl-C,程序不会退出,而是打印一条消息后继续运行。按 Ctrl-\ 产生 SIGQUIT 可以终止(默认动作)。
九、总结
| 概念 | 说明 |
|---|---|
| 信号本质 | 软件中断,异步通知 |
| 产生方式 | 硬件中断、终端按键、系统调用、软件异常 |
| 生命周期 | 产生 → 未决(Pending)→ 递送(Delivery) |
| 三种处理 | 忽略、默认、捕捉(自定义) |
| 不可被忽略/捕获 | SIGKILL、SIGSTOP |
| 前台/后台 | Ctrl-C 只发送给前台进程 |
| Core Dump | 用于事后调试,需用 ulimit -c unlimited 开启 |
信号是 Linux 系统编程的基础,理解其机制不仅有助于编写健壮的程序,也为后续学习进程间通信、多线程同步等打下基础。