1. 前言
上一篇讲进程间通信时,通信双方都是约定好再交流:管道要先建立,共享内存要先挂接,数据的收发时机是可控的。但还有一种通知完全不受进程控制——无论进程此刻执行到哪一行代码,按下 Ctrl-C 它都会终止。这种异步通知机制就是信号(Signal),它是 Linux 中进程之间事件异步通知的一种方式。
本章主要目标:
- 理解信号的概念与生命周期,掌握信号递达时的三种处理方式。
- 理解信号在内核中的表示(三张表),掌握信号集操作与 sigprocmask 的使用。
- 掌握 sigaction 信号捕捉,并通过 volatile 与 SIGCHLD 两个典型应用理解信号的实际用途。
2. 信号的基本概念
2.1 信号是什么
一句话定义:信号是进程之间事件异步通知的一种方式,属于软中断。
两个关键词:
- 异步:信号什么时候到来,进程无法预测,它相对于进程自身的控制流是异步发生的。进程执行到任何一行代码时都可能收到信号。
- 软中断:信号是软件层面模拟中断机制的产物,区别于键盘、网卡等外设触发的硬件中断。比如按下 Ctrl-C 时,键盘输入先通过硬件中断到达操作系统,操作系统再把它解释成信号发给前台进程。
信号本身只携带一个编号,告诉进程"某件事发生了",不传输业务数据。 使用kill -l可以查看系统支持的全部信号:
kill -l # 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP # ...131 号是普通信号,3464 号是实时信号,本章重点讨论普通信号。查看信号详细说明可以执行man 7 signal。
2.2 信号的生命周期
一个信号从产生到处理完毕,经历三个阶段:
产生 → 未决(Pending) → 递达(Delivery)- 未决(Pending):信号已经产生,但还没有被处理,处于等待状态。这段时间里信号被记录在进程的 PCB 中。
- 递达(Delivery):实际执行信号的处理动作。
信号递达时有三种处理方式,三选一:
- 执行默认动作(SIG_DFL):每个信号出厂自带的处理行为,大多数信号的默认动作是终止进程。
- 忽略此信号(SIG_IGN):递达后什么都不做。
- 自定义捕捉:注册一个处理函数,内核在递达该信号时调用它。这是回调机制,handler函数不是main主动调用,而是操作系统触发调用。
2.3 常见信号汇总
| 编号 | 名称 | 触发场景 | 默认动作 |
|---|---|---|---|
| 2 | SIGINT | Ctrl-C | 终止进程 |
| 3 | SIGQUIT | Ctrl-\ | 终止 + Core Dump |
| 8 | SIGFPE | 除零等算术异常 | 终止 + Core Dump |
| 9 | SIGKILL | kill -9 | 终止进程 |
| 11 | SIGSEGV | 野指针等非法内存访问 | 终止 + Core Dump |
| 13 | SIGPIPE | 管道读端关闭后继续写 | 终止进程 |
| 14 | SIGALRM | alarm 设定的闹钟到点 | 终止进程 |
| 17 | SIGCHLD | 子进程终止时通知父进程 | 忽略 |
| 19 | SIGSTOP | kill -19 | 暂停进程 |
重点:SIGKILL(9) 和 SIGSTOP(19) 比较特殊:不能被捕捉、不能被阻塞、不能被忽略,后面会解释底层原因。
2.4 阻塞与忽略的区别
进程可以选择**阻塞(Block)**某个信号:被阻塞的信号产生后会一直停留在未决状态,直到解除阻塞才能递达。
一句话区分:阻塞是信号还没递达就被拦住(不许进门),忽略是信号已经递达后选择不处理(进门了装作没看见)。 阻塞决定信号"能不能"被处理;忽略只是递达之后的一种处理策略。
3. 信号的产生
信号共有四种产生方式:终端按键、系统调用、软件条件、硬件异常。
3.1 终端按键
终端按键产生的信号,只会发送给前台进程。命令末尾加
&可以把进程放到后台运行,后台进程无法接收键盘信号,只能使用kill命令发送信号。
| 按键 | 信号 | 默认动作 |
|---|---|---|
| Ctrl-C | SIGINT(2) | 终止 |
| Ctrl-\ | SIGQUIT(3) | 终止 + Core Dump |
| Ctrl-Z | SIGTSTP(20) | 暂停 |
3.2 系统调用产生信号
#include <signal.h> int kill(pid_t pid, int sig); // 向指定进程发信号,kill 命令的底层实现 int raise(int sig); // 向自己发信号,等价于 kill(getpid(), sig) #include <stdlib.h> void abort(void); // 向自己发 SIGABRT(6),异常终止进程3.3 软件条件产生信号
程序运行满足软件条件,内核自动发送信号,属于软件层面触发。 典型例子:
- SIGPIPE:管道读端关闭,写端继续调用write写数据,内核向写进程发送SIGPIPE(13),默认终止进程。
- SIGALRM闹钟信号:
#include <unistd.h> unsigned int alarm(unsigned int seconds); // 告诉内核 seconds 秒后向自己发 SIGALRM(14) // 返回值:0,或上一个闹钟的剩余秒数;alarm(0) 取消之前的闹钟3.4 硬件异常产生信号
CPU/MMU检测到硬件异常,内核捕获异常,翻译成信号发送给当前进程:
- 整数除以 0:CPU运算单元检测,产生 SIGFPE(8)
- 访问非法内存(解引用空指针、越界访问):MMU检测页表异常,产生 SIGSEGV(11)
核心结论:C/C++里面遇到的除零、内存越界崩溃,底层都是硬件异常被内核转为信号处理。程序崩溃链路:MMU发现非法访问 → 通知内核 → 内核向进程发送SIGSEGV → 默认动作终止进程。
4. 信号在内核中的表示
4.1 内核三张表
信号能否被处理、是否已经产生、如何处理,全部记录在进程PCB(task_struct)内部的三张表:
block 表(阻塞信号集) pending 表(未决信号集) handler 表(处理动作) 1 0 0 1 ... 0 1 0 0 ... SIG_DFL / SIG_IGN / 函数指针三条核心规则:
- 信号产生时,内核把 pending 表对应bit置1;信号递达处理完成后,该bit清零。发送信号的本质,仅仅是修改pending位图的bit位,没有任何数据拷贝。
- 如果信号被block阻塞,就算信号产生,pending置1,也停留在未决状态,不会递达。
- handler处理动作,在信号到来之前就提前登记,进程预先知道每个信号该如何处理。
三张表底层都是位图,数据类型为
sigset_t,不能直接用普通位运算操作,必须调用系统提供的信号集函数。
4.2 信号集操作函数
| 函数 | 功能 |
|---|---|
sigemptyset | 清空信号集,全部置0 |
sigfillset | 将信号集全部bit置1 |
sigaddset | 向集合中添加指定信号 |
sigdelset | 从集合中删除指定信号 |
sigismember | 判断指定信号是否在集合中,存在返回1,不存在返回0 |
4.3 sigprocmask 与 sigpending
#include <signal.h> int sigprocmask(int how, const sigset_t *set, sigset_t *oset); // 读写block阻塞信号集 // set不为空:根据how参数修改阻塞掩码;oset不为空:输出修改前旧的阻塞信号集 int sigpending(sigset_t *set); // 读取pending未决信号集,把当前未决信号放入sethow参数三种取值:
| how | 含义 |
|---|---|
| SIG_BLOCK | 将set里的信号追加到阻塞集(按位或,新增阻塞) |
| SIG_UNBLOCK | 从阻塞集中,解除set内信号的阻塞 |
| SIG_SETMASK | 使用set整体直接替换当前阻塞掩码 |
4.4 实验:阻塞 SIGINT 观察未决信号
// block_sig.c #include <stdio.h> #include <signal.h> #include <unistd.h> void printpending(sigset_t *set) { for (int i = 1; i <= 31; i++) { if (sigismember(set, i)) putchar('1'); else putchar('0'); } putchar('\n'); } int main() { sigset_t iset, oset, pset; sigemptyset(&iset); sigaddset(&iset, SIGINT); // 集合中加入 SIGINT sigprocmask(SIG_BLOCK, &iset, &oset); // 阻塞 SIGINT while (1) { sigpending(&pset); // 读取未决信号集并打印 printpending(&pset); sleep(1); } return 0; }编译运行:
gcc block_sig.c -o block_sig ./block_sig # 0000000000000000000000000000000 # 0000000000000000000000000000000 # 按下 Ctrl-C 后 # 0100000000000000000000000000000 # 0100000000000000000000000000000按下Ctrl-C,SIGINT对应的bit变为1并保持不变:信号已经产生,pending标记置1,但是被阻塞,无法递达。此时按下Ctrl-\发送SIGQUIT,该信号没有被阻塞,正常递达终止进程。
5. 信号的捕捉
5.1 signal 简易接口
#include <signal.h> void (*signal(int signum, void (*handler)(int)))(int); // 用法:signal(信号编号, 处理函数) // 处理函数原型必须是 void func(int)捕捉SIGINT示例代码:
// catch_sig.c #include <stdio.h> #include <signal.h> void handler(int sig) { printf("catch a sig : %d\n", sig); } int main() { signal(2, handler); while (1); return 0; }gcc catch_sig.c -o catch_sig ./catch_sig ^Ccatch a sig : 2 ^Ccatch a sig : 2捕捉后,Ctrl-C不再终止进程,每次按下触发handler打印。此时只能用Ctrl-\退出,因为SIGQUIT没有注册捕捉函数,执行默认动作终止。
5.2 硬件异常捕捉实验
// segv_sig.c #include <stdio.h> #include <signal.h> #include <unistd.h> void handler(int sig) { printf("catch a sig : %d\n", sig); } int main() { signal(SIGSEGV, handler); sleep(1); int *p = NULL; *p = 100; // 对空指针写入,触发 SIGSEGV while (1); return 0; }gcc segv_sig.c -o segv_sig ./segv_sig # catch a sig : 11 # catch a sig : 11 # ...无限刷屏现象分析:捕捉SIGSEGV之后进程不会退出,持续循环打印信号编号。 原因:handler执行结束后,CPU回到触发异常的那条非法指令*p=100重新执行。内存非法访问问题本身没有修复,指令再次执行,再次触发SIGSEGV,形成死循环。
重点:信号捕捉只是接住通知,不会自动修复异常。想要跳出,需要在handler内部使用非局部跳转(longjmp)跳出错误代码。
5.3 sigaction(推荐标准接口)
signal接口简单,但是存在跨平台行为差异,正式开发优先使用posix标准接口sigaction。
#include <signal.h> int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact); struct sigaction { void (*sa_handler)(int); // 处理动作:SIG_DFL / SIG_IGN / 自定义函数 sigset_t sa_mask; // handler执行期间,额外屏蔽的信号集 int sa_flags; // 选项,一般置0 };使用示例:
struct sigaction act; act.sa_handler = handler; sigemptyset(&act.sa_mask); // sa_mask使用前必须初始化清空 act.sa_flags = 0; sigaction(SIGINT, &act, NULL);两个核心细节:
- handler执行的时候,当前触发的信号会自动屏蔽,防止递归触发;sa_mask用来指定handler运行期间额外要屏蔽的其他信号。handler函数返回后,阻塞信号集自动恢复。
- 自定义handler运行在用户态,和main属于同一个进程,没有函数调用关系,是两条独立控制流,栈空间互相独立。
5.4 SIGKILL 和 SIGSTOP 不能被捕捉
SIGKILL(9) 和 SIGSTOP(19),无法捕捉、无法阻塞、无法忽略。 设计原因:操作系统必须保留对进程的最高管控权限。如果进程可以屏蔽所有信号,恶意程序就会拒绝被终止,操作系统失去控制权,系统卡死。kill -9永远可以杀掉正常存活的进程,就是这个原理。
6. 两个典型应用
6.1 volatile:保持内存可见性
// volatile_sig.c #include <stdio.h> #include <signal.h> int flag = 0; // 修改为 volatile int flag = 0; void handler(int sig) { flag = 1; } int main() { signal(2, handler); while (!flag); printf("process quit normal\n"); return 0; }编译运行,开启O2优化:
gcc -O2 volatile_sig.c -o volatile_sig ./volatile_sig # 按下Ctrl-C,handler执行,但是循环不会退出 # 修改为 volatile int flag = 0; 重新编译,按下Ctrl-C,程序正常退出原理:编译器优化时,发现main函数内部没有修改flag,把flag缓存到CPU寄存器。while循环每次读取寄存器副本,永远是0;但是handler修改的是内存中的flag,寄存器没有同步更新,循环无法退出。
volatile作用:保持内存可见性,告诉编译器,该变量读写必须访问内存,禁止缓存到寄存器。
注意:volatile只保证可见性,不保证原子性,i++这种复合操作依旧需要加锁保证线程安全。
6.2 SIGCHLD:异步回收子进程
回收子进程传统两种方案:阻塞wait(父进程卡住等待)、轮询非阻塞waitpid(不断循环查询,浪费CPU)。 借助SIGCHLD信号:子进程退出时,内核主动发送SIGCHLD通知父进程,父进程在信号handler里面完成回收,不阻塞业务逻辑。
// wait_sig.c #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <signal.h> #include <sys/wait.h> void handler(int sig) { pid_t id; // 必须while,不能if:普通信号不排队,多个子进程同时退出只会置一次pending位 // while循环一次性把所有已经退出的子进程全部回收干净 while ((id = waitpid(-1, NULL, WNOHANG)) > 0) { printf("wait child success: %d\n", id); } } int main() { signal(SIGCHLD, handler); pid_t cid = fork(); if (cid == 0) { // 子进程:3 秒后退出 sleep(3); exit(1); } while (1) { // 父进程继续执行自己的业务任务 printf("father working...\n"); sleep(1); } return 0; }gcc wait_sig.c -o wait_sig ./wait_sig # father working... # father working... # father working... # wait child success: 7340 # father working...关键点:
- 使用
waitpid(-1, NULL, WNOHANG),非阻塞回收,没有可回收子进程立刻返回0,避免handler卡住。 - 必须while循环:普通信号pending只是位图,多个子进程同时退出只会记录一次信号,if只能回收一个子进程,while保证全部僵尸子进程被回收。
7. 本章小结
- 信号是进程之间事件异步通知的一种方式,属于软中断;生命周期:产生 → 未决(Pending)→ 递达(Delivery)。
- 三种处理方式:SIG_DFL 默认动作、SIG_IGN 忽略、自定义函数捕捉;阻塞发生在递达之前拦截信号,忽略是递达之后选择不处理,二者阶段不同。
- 信号4种产生方式:终端按键、系统调用、软件条件、硬件异常;C/C++除零、内存越界底层都会转化为信号。
- 内核依靠PCB内block/pending/handler三张表管理信号;发送信号本质是修改pending位图对应bit,无数据拷贝。
- sigaction捕捉信号:handler运行在用户态,与main是两条独立控制流;handler执行期间当前信号自动屏蔽;SIGKILL(9)与SIGSTOP(19)不可捕捉、阻塞、忽略。
- volatile保证内存可见性,阻止编译器把变量缓存寄存器;SIGCHLD配合while+waitpid(WNOHANG)异步回收子进程,避免僵尸进程。