在开始之前,先问一个几乎所有写过多进程程序的人都会遇到、却很少真正想清楚的问题:为什么一个进程能够"知道"并"响应"某些不请自来的事件? 比如你写了一个死在 while(1) 里的程序,按下 Ctrl+C,它立刻退出;你在终端里敲 kill -9 <pid>,一个怎么都杀不掉的进程却应声倒地。这背后的主角,就是信号(signal)。
信号是 Linux/UNIX 世界里最古老、最底层的进程间异步通知机制之一。它不像管道、消息队列、共享内存那样需要你先去"建立连接",它更像天空中的一道闪电——说来就来,你拦不住它什么时候打下来,但内核早已替你准备好了"打雷时该怎么办"的预案。这篇文章我就带你把它彻底拆开,从"信号是什么"讲到"信号怎么产生、怎么保存、怎么被捕捉",最后落到可重入函数、volatile 和 SIGCHLD 这几个与信号强绑定的话题上。
你应该有的知识准备
在往下读之前,你需要对下面几样东西有基本概念,它们大多来自 C 语言课和操作系统课,我在这里只做一句话回顾:
- 进程控制块(PCB):内核为每个进程维护的一块数据结构,记录了这个进程的一切状态,比如 PID、状态、打开的文件表、以及我们今天的主角——信号相关字段。你可以把它理解成"进程的档案本"。
- 用户态与内核态:CPU 分特权级别运行。普通程序以低权限的用户态运行,不能直接碰硬件;内核以高权限的内核态运行,掌管所有资源。进程从用户态切到内核态要靠"系统调用、异常、中断"三种途径。理解信号,本质就是理解"内核态如何把某个事件塞给一个用户态进程"。
- 进程可重入:这个概念后面会专门讲,你只要先记住"同一时刻一个函数被多条独立的控制流反复进入而不会出乱子"叫可重入即可。
fork与wait/waitpid:创建子进程、回收子进程的接口,最后一节讲SIGCHLD时要用。
如果你带着这些基础进来,往下读会非常顺;哪条不熟,回头翻对应的章节即可。
认识信号:从"快递"到"软中断"
生活角的信号:快递到了
要理解信号,没有比"等快递"更贴切的比喻了。假设你在网上买了三件东西,快递员会陆续送到:
- 即便快递还没到,你也知道自己该"怎么处理快递"——签收、拆包、看是不是你的。这种"处理预案"在你买东西时就已内置了,因为你是人,你的经验里早就认得"快递"是什么。换成进程也一样:进程天生认识信号、懂得如何处置信号,这种"识别"和"预案"是内核程序员写进内核的内置特性,不需要进程在信号来临前临时去学习。
- 快递员到楼下打电话通知你,但你正在打游戏,得 5 分钟之后才下去取。在这 5 分钟里,你没有去取快递,但你知道有一个快递已经来了——你"记住了有快递要去取"。这个从"得知快递"到"拿到快递"之间的时间窗,就是信号里的"未决"状态。
- 处理快递一般有三种方式:执行默认动作(拆开使用)、执行自定义动作(是零食,顺手送给朋友)、忽略(扔床头继续打游戏)。
- 整个过程对你是异步的——你无法预知快递员究竟几点打来电话。
这就是信号最核心的三段式:信号到来 → 信号保存 → 信号处理。对应到内核与进程身上,"快递员"就是操作系统,"你"就是进程,"快递"就是信号,"通知电话"就是内核把信号塞进进程的 PCB,"下去取快递"就是进程挑一个合适的时机去递达信号,"拆快递的三种方式"就是信号的三种处理动作:默认、忽略、自定义(也是一种"捕捉")。
技术角的信号:本质是一种"软中断"
生活比喻讲透了,我们落回严谨的技术定义。
信号(signal)是进程之间事件异步通知的一种方式,在实现上属于"软中断"。
这里有两个词需要掰开:
- 异步(Asynchronous):信号的到来和信号的处理,都不在进程的控制流程之内。你编译出来的指令流在用户态一条条执行,信号随时可能从任何位置插入进来,进程没法准确预知它何时发生。这就是"异步"的含义。
- 软中断(soft interrupt):中断是一种让 CPU 暂停当前指令、转去执行"中断处理程序"的机制,常见的键盘输入、时钟到点都是硬件中断——由设备触发,直接作用于 CPU。而"软中断"是纯软件模拟的中断行为:它同样打断进程的执行流,但它不是发给 CPU 的中断,而是发给进程的一个信号。一句话:硬件中断发给 CPU,信号(软中断)发给进程。两者行为相似,但所处层级不同。
第一次实战:用 signal 接住 Ctrl+C
纸上谈兵不如动手。我们先写一个除了"等待信号"之外什么都不干的程序,然后看看 Ctrl+C 到底把它怎么了。
// wait_signal.c
#include <stdio.h>
#include <unistd.h>
int main(void)
{
while (1)
{
// 每隔 1 秒打印一次,表示进程"活着"并且相当悠闲
printf("I am a process, I am waiting signal!\n");
sleep(1);
}
return 0;
}编译并前台运行它,随后按 Ctrl+C:
$ gcc -o wait_signal wait_signal.c
$ ./wait_signal
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C # 按下 Ctrl+C,进程立刻退出^C 打印出来之后进程就没了。这个简单的现象背后藏着整条链路:
- 你在 Shell 里启动了一个前台进程(这是默认的启动方式,Shell 会等它结束才能接收下一条命令;命令后加个
&可以放到后台,此时 Shell 不必等它,可以直接继续)。 - 你按下
Ctrl+C,键盘产生了中断,被操作系统捕获并且解释成一个信号——这个信号就是SIGINT,编号 2,全称 Interrupt from keyboard(来自键盘的中断)。 - 操作系统把
SIGINT发送给当前这个前台进程。 - 前台进程收到
SIGINT,触发了它的默认处理动作——终止进程,于是程序退出。
Ctrl+C 只能发给前台进程,这值得单独强调:Shell 可以同时运行一个前台进程和若干后台进程,但只有前台进程能收到 Ctrl+C 这类控制键产生的信号。这跟进程组、会话有关,网络部分会深入讲,现在先记住这个事实。
现在,我们不想让它默认退出,而是想在收到 Ctrl+C 时打印一段话再继续跑。这就用到了信号处理领域第一张门票——signal 函数:
// catch_sigint.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
// 这是信号处理函数的签名:参数是信号编号
// 当进程收到信号时,内核会"回调"这个函数
printf("我是进程 %d, 我获得了一个信号: %d\n", getpid(), signumber);
}
int main(void)
{
printf("我是进程: %d\n", getpid());
// 把 2 号信号 SIGINT 的处理动作改成自定义函数 handler
signal(SIGINT, handler);
while (1)
{
printf("I am a process, I am waiting signal!\n");
sleep(1);
}
return 0;
}编译运行,再按几次 Ctrl+C:
$ gcc -o catch_sigint catch_sigint.c
$ ./catch_sigint
我是进程: 212569
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C我是进程: 212569, 我获得了一个信号: 2
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C我是进程: 212569, 我获得了一个信号: 2现象里藏着两个极有价值的事实:
- 进程这次没退出。 因为
SIGINT的默认处理动作是"终止进程",而我们用signal(SIGINT, handler)把它的动作改成了"调用我们自己的函数"。收到信号后,内核转而执行我们的handler,打印完又回到主循环继续跑。也就是说,信号的处理到底由谁来决定,是进程自己说了算(除非遇到下面要讲的SIGKILL这类硬约束)。 - 信号处理函数是"回调"而不是"调用"。 注意,
handler并不是main调用的,而是当信号真正递达时,由操作系统在用户态去执行的。所以signal只是"设定了一个预案",它本身并不触发处理;如果这个信号后续从未产生,那么你注册的handler一辈子都不会被调用。
用生活比喻把这条链路串一遍:进程就是你,操作系统就是快递员,信号就是快递,发信号的过程就是快递员给你打电话。你设定 signal(SIGINT, handler),等于提前写好了一份"收到快递该怎么处理"的说明书;快递员打电话(内核发信号)之后,你会挑一个合适时机(内核决定何时递达),照着说明书去拆快递(执行 handler)。
思考题 1:为什么我在
signal(SIGINT, handler)之后,进程收到了信号却不立即在"收到的那一刻"调用handler,内核总要"挑个合适时机"?答案:因为进程在用户态执行的是一段普通指令流,内核并不可能在每一条指令执行到一半的时候"插播"你的 handler。信号的递达时机,被内核安排在"进程从内核态返回用户态、且判定有信号需要处理"的时候——也就是一次系统调用、异常、或中断结束、正准备恢复用户态上下文的那一刻。这样做有几个好处:一是保证了"在合适的时候而不是无论什么时候"去处理;二是处理的过程需要一个完整、可恢复的用户态上下文栈,只有在"从内核回到用户态"这个天然的边界上切换才是最安全、最可控的。想一想快递的比喻:你也不会在打游戏最关键的一帧抛下鼠标去取快递,而是在打到某个自然停顿点再去。
信号列表:编号、宏与默认动作
进到细节之前,先把"有哪些信号"这张地图铺开。每个信号都有两样身份:一个数字编号和一个宏定义名称。这些宏定义在 <signal.h> 里,例如:
#define SIGINT 2 // 中断信号,来自键盘 Ctrl+C在 Linux 上,编号 1 到 31 是普通信号(也有人叫传统信号/标准信号),编号 34 以上是实时信号(real-time signal,与前者的关键区别我们稍后提到)。本文只讨论 1 到 31 的普通信号。下面是 x86_64 Linux 上最常用的一批信号表,man 7 signal 里有完整说明。表里"默认动作"一列分四类,我们马上逐个解释。
| 编号 | 名称 | 产生条件 | 默认动作 |
|---|---|---|---|
| 1 | SIGHUP | 终端挂断 / 控制进程死亡 | Term(终止) |
| 2 | SIGINT | 键盘 Ctrl+C | Term(终止) |
| 3 | SIGQUIT | 键盘 Ctrl+\ | Core(终止并转储) |
| 4 | SIGILL | 非法指令 | Core |
| 5 | SIGTRAP | 断点/单步,调试用 | Core |
| 6 | SIGABRT | 调用 abort() 函数 | Core |
| 7 | SIGBUS | 总线错误(非对齐等) | Core |
| 8 | SIGFPE | 运算异常(除以 0 等) | Core |
| 9 | SIGKILL | 强制杀死进程 | Term(不可堵、不可捕) |
| 10 | SIGUSR1 | 用户自定义信号 1 | Term |
| 11 | SIGSEGV | 非法内存访问(段错误) | Core |
| 12 | SIGUSR2 | 用户自定义信号 2 | Term |
| 13 | SIGPIPE | 向无读者管道写数据 | Term |
| 14 | SIGALRM | 闹钟超时(alarm() 触发) | Term |
| 15 | SIGTERM | 请求正常终止(kill 默认发它) | Term |
| 17 | SIGCHLD | 子进程终止/停止 | Ign(忽略) |
| 18 | SIGCONT | 让停止的进程继续 | Cont(继续) |
| 19 | SIGSTOP | 停止进程 | Stop(不可堵、不可捕) |
| 20 | SIGTSTP | 键盘 Ctrl+Z 停止 | Stop |
| 23 | SIGURG | 紧急数据到达(socket) | Ign |
| 24 | SIGXCPU | CPU 时间超限 | Core |
| 28 | SIGWINCH | 终端窗口尺寸变化 | Ign |
四类"默认动作"分别指:Term(Terminate,终止进程)、Core(终止进程并产生 core dump 文件,用于事后调试)、Stop(停止进程,但进程还活着,只是挂起)、Ignore(忽略,什么都不做,进程继续)。注意:默认动作是"忽略"不等同于"阻塞",这层区别是全文最重要的辨析之一,后面专门讲。
有一个必须刻进脑子的硬规则:SIGKILL(9)和 SIGSTOP(19)既不能被阻塞、不能被忽略,也不能被捕捉。这两兄弟是操作系统保留的"终极手段":SIGKILL 用来把怎么都不肯退的合作进程一记打死,SIGSTOP 用来无论进程干什么都把它冻结。内核在实现层面直接无视对这两个信号的 signal/sigaction 设置与 sigprocmask 阻塞请求,就是为了保证"踢门"的权力永远握在内核手里。这也是为什么后面你会看到"有些进程连 Ctrl+C 都不怕(把 SIGINT 忽略了),却还是会被 kill -9 干掉"。
信号的产生:四大家族的袭击
信号不是凭空出现的,归纳起来有四大来源。我们逐个用代码验证。
通过终端按键产生
| 按键 | 产生的信号 | 默认动作 |
|---|---|---|
Ctrl+C | SIGINT(2) | 终止进程 |
Ctrl+\ | SIGQUIT(3) | 终止进程并 Core dump |
Ctrl+Z | SIGTSTP(20) | 停止进程(挂起到后台) |
Ctrl+C 我们已经验证过了,来看看 Ctrl+\。它是"终止 + 转储"的加强版——进程默认会被终止,同时(在允许产生 core 文件的前提下)留下一个 core dump 文件供事后调试:
// catch_sigquit.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
printf("我是进程 %d, 我获得了信号: %d\n", getpid(), signumber);
}
int main(void)
{
signal(SIGQUIT, handler); // 捕捉 3 号信号
while (1)
{
printf("I am a process, I am waiting signal!\n");
sleep(1);
}
return 0;
}前台运行后按 Ctrl+\(屏幕上通常显示为 ^\):
$ ./catch_sigquit
我是进程: 213056
I am a process, I am waiting signal!
^\我是进程: 213056, 我获得了信号: 3 # 被捕捉,于是继续运行如果你把 signal(SIGQUIT, handler) 那行注释掉再跑,Ctrl+\ 会让进程直接以 "Quit(core dumped)" 方式退出——这就是默认动作 Core 的体现,后面讲 core dump 时我们再深入。
再看 Ctrl+Z,它发送 SIGTSTP(20),默认把进程"停止"(不是终止):
$ ./catch_sigquit & # 放到后台运行更容易观察
$ jobs
[1]+ Stopped ./catch_sigquitSIGTSTP 与 SIGSTOP 的区别也值得一提:SIGTSTP 是"软性停止",可以被捕捉和忽略,它留给进程一个"临别前做点收拾"的机会;SIGSTOP(19)则是"硬性停止",不可堵不可捕,直接冻结。
通过命令产生:kill 命令与 kill 函数
kill 命令可以给任意指定进程发送任意信号。先制造一个永远不会自己退出的后台进程,然后从别处开一枪:
$ cat > forever.c <<'EOF'
#include <stdio.h>
#include <unistd.h>
int main(void)
{
while (1) sleep(1); // 死循环,等信号
return 0;
}
EOF
$ gcc -o forever forever.c
$ ./forever & # 放到后台
$ ps ajx | grep forever # 查出它的 PID,记下来
$ kill -SIGSEGV <该进程的PID> # 也可以写作 kill -11 <pid>
[1]+ Segmentation fault ./forever这段实验揭露几个点:
- 用
ps查出 PID 后,kill -SIGSEGV <pid>把 11 号信号SIGSEGV打给它。这个程序本身没写任何非法内存访问的代码,却照样以 Segmentation fault 结束——说明段错误本质上就是接收到SIGSEGV之后的默认表现,而段错误并不是"只能由自己写坏代码"才会发生。 - 你可能会诧异:为什么要在
kill之后"多按一次回车"才看到Segmentation fault?因为forever被放到了后台,它死后 Shell 已经回到提示符等着下一条命令。Shell 不希望"某个后台任务的死亡消息"和"用户的输入"交错在一起,于是把Segmentation fault这样的汇报信息积着,等你输入命令之后才连带显示。 kill命令本质上就是调用了kill这个系统调用封装函数。所以"命令"和"函数"其实是同一个东西的两张脸。
通过函数产生:kill、raise、abort
kill 函数能让任意进程收到任意信号。它的原型是:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
// 成功(至少发出了一个信号)返回 0,出错返回 -1 并设置 errno我们来手写一个迷你版 kill 命令:
// mykill.c
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
// 用法: ./mykill -signum pid
int main(int argc, char *argv[])
{
if (argc != 3)
{
// 参数不齐就报错给用户看
fprintf(stderr, "用法: %s -signum pid\n", argv[0]);
return 1;
}
// argv[1] 形如 "-9",去掉开头的 '-',剩下的数字就是信号编号
int signo = atoi(argv[1] + 1);
// argv[2] 是目标进程的 PID
pid_t pid = atoi(argv[2]);
// 调用真正的系统调用 kill 去发送信号
if (kill(pid, signo) == -1)
{
perror("kill"); // 发送失败(如目标进程不存在)时打印原因
return 1;
}
return 0;
}raise 更简单——它让当前进程自己给自己发信号,等于 kill(getpid(), sig) 的简写:
#include <stdio.h>
#include <stdlib.h>
int raise(int sig); // 给调用它的进程自己发一个 sig 信号// raise_demo.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
printf("获取了一个信号: %d\n", signumber);
}
int main(void)
{
signal(SIGINT, handler); // 先捕捉 2 号信号
while (1)
{
sleep(1);
raise(SIGINT); // 每隔 1 秒,自己给自己发一次 2 号信号
}
return 0;
}运行输出:
$ ./raise_demo
获取了一个信号: 2
获取了一个信号: 2
获取了一个信号: 2
...abort 则是"自杀式"的:它让当前进程接收一个信号而异常终止。它内部产生的是 SIGABRT(6 号):
#include <stdlib.h>
void abort(void); // 从不返回——它必定成功,所以没有返回值意义// abort_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int signumber)
{
printf("捕获到信号: %d\n", signumber);
}
int main(void)
{
signal(SIGABRT, handler); // 即使捕捉了 SIGABRT……
abort(); // ……进程最后还是会以 Aborted 退出
return 0;
}运行它:
$ ./abort_demo
捕获到信号: 6 # handler 确实被调用了
Aborted # 但进程最终还是异常终止了这里有个值得玩味的细节:我们明明把 SIGABRT 捕捉了,为什么进程还是死了? 因为 abort() 的语义就是"无论如何都让进程以异常方式终止"。它的实现策略大致是:先向自己发 SIGABRT;如果这个信号被捕捉且处理函数返回了,abort() 会解除对该信号的屏蔽、恢复默认动作,然后再次触发 SIGABRT——于是进程仍以默认动作(Core)死去。这就保证了:abort() 一旦调用,进程绝不会"若无其事地继续运行",这是标准对 abort() 的硬性保证。
思考题 2:
raise()和abort()都能"自己给自己制造信号",它们有什么区别?答案:
raise(sig)是"发送一个信号给自己",这个信号的处理遵循当前设置——可以被忽略、被默认处理或被捕捉,进程是否因此终止完全取决于这个信号的处置方式。而abort()是"无论如何异常终止当前进程":它必然产生SIGABRT,并且即便进程捕捉了这个信号,abort()也会在 handler 返回后想办法再次触发默认的终止动作。所以raise(2)(发 SIGINT)如果被你忽略,进程会活得好好的;而abort()没有这种"侥幸"。
由软件条件产生:alarm 与 SIGALRM
"软件条件"指的是由程序内部或内核软件操作触发的信号。比如向一个已经关闭读端的管道写数据会触发 SIGPIPE(这就是你在管道那章踩过的"Broken pipe")。本节的主角是 alarm 和 SIGALRM。
alarm 函数让内核在指定秒数后,给当前进程发一个 SIGALRM(14 号)信号:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
// 返回值:以前设定闹钟剩余未响的秒数;如果没有以前的闹钟,返回 0
// 传入 0 表示取消以前的闹钟SIGALRM 的默认动作是终止当前进程。它最经典的用法是"限时执行":程序只允许跑 1 秒,1 秒到了 alarm 帮你踢人。我们先用它来观察一件事——IO 效率问题:
// alarm_count.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
// volatile 防止编译器把 count++ 这个大热循环"优化掉",保证它是真实计数
volatile long count = 0;
void handler(int signo)
{
// 1 秒到了,打印统计结果并退出
printf("1 秒内 count = %ld\n", count);
exit(0);
}
int main(void)
{
signal(SIGALRM, handler);
alarm(1); // 1 秒后发 SIGALRM,本程序打算"数到被踢"为止
while (1)
{
count++; // 纯内存计数,没有任何 IO,飞快
}
return 0;
}运行它,count 会达到大约几亿:
$ gcc -O2 -o alarm_count alarm_count.c
$ ./alarm_count
1 秒内 count = 492333713反过来,如果循环里每次都做一次打印这种 IO,1 秒内能跑的次数会暴跌到十几万。结论:alarm 闹钟只响一次,默认起效(发一次 SIGALRM)就完成任务;而要在每次响铃后继续,就得在 handler 里用 alarm(1) 重新挂一个闹钟——这正是"重复闹钟"的标准做法,也是很多系统里用定时信号周期刷新的原型。
顺带说说 alarm 的返回值和"剩余时间":它返回的是上一次闹钟还有多少秒会响。打个比方,你设 30 分钟后响铃,20 分钟后被人吵醒想再睡 15 分钟,这时你重设 alarm(15*60) 拿到的是"上次闹钟剩余 10 分钟"这个旧值(不是 15)。若 seconds=0 则是取消旧闹钟,返回值仍是旧闹钟剩余的秒数。
如何理解"软件条件":在操作系统中,软件条件是指由软件内部状态或特定软件操作触发的信号产生机制——定时器超时(
alarm到点)、软件异常(向已关闭管道写入触发SIGPIPE)等都属于这一类。条件一旦满足,OS 就向相关进程"投递"对应信号。而"系统能按时响铃"这件事本身,依赖内核自带并管理的定时器:内核里的定时器被组织成timer_list这类结构,记录"过期时间expires"和"到点后的处理函数function",再被放入时间轮(时间轮可以简单理解为按过期先后排序的一堆桶,或近似理解成一个按到期时间组织的堆)里统一调度。所以"系统闹钟"= 内核具备定时能力 + 允许用户设置定时器。
由硬件异常产生:除零与野指针
进程在执行指令时,硬件(CPU、MMU)会"看见"一些不对劲的东西:除以 0、访问非法的内存地址……硬件把这些异常报告给内核,内核再把它们翻译成信号发给当前进程。这是"硬错误"走上"信号"这条路的标准流程。
- 除以 0 → CPU 运算单元产生异常 → 内核翻译成
SIGFPE(8 号,Floating-Point Exception,浮点运算异常,整数除零也走它)。 - 非法内存访问 → MMU(内存管理单元)产生异常 → 内核翻译成
SIGSEGV(11 号,Segmentation Violation,段错误)。
验证除零:
// fpe.c
#include <stdio.h>
#include <unistd.h>
int main(void)
{
sleep(1);
int a = 10;
a /= 0; // 除零 -> 触发 SIGFPE(8),默认动作是终止进程并可能 core dump
return 0;
}$ gcc -o fpe fpe.c
$ ./fpe
Floating point exception (core dumped)验证野指针,并且试着捕捉它:
// segv.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
printf("捕获到信号: %d\n", signo);
}
int main(void)
{
signal(SIGSEGV, handler); // 捕捉 11 号信号
int *p = NULL;
*p = 100; // 解引用空指针 -> SIGSEGV(11)
while (1);
return 0;
}$ gcc -o segv segv.c
$ ./segv
捕获到信号: 11
捕获到信号: 11
捕获到信号: 11
... # 无限刷屏这里出现了一个非常有意思、也非常重要的现象:为什么 SIGSEGV 的 handler 会无穷尽地被调用?
秘密在于"寄存器里的异常状态没有被清理"。除零、非法内存访问这类硬件异常发生后,CPU 里的一些控制和状态寄存器(可以理解成一张位图,用来标记"是否发生了溢出/除零等异常标志")会留下一笔"账"。而所谓"捕捉信号",只是让进程绕道去执行了 handler;handler 并没有去清除这些异常标志位、没有关闭进程打开的文件、没有做内存清理。于是 handler 返回后,进程回到用户态,那条仍然会再次触发异常的指令被重新执行,又产生一次异常,内核又发一次信号……于是死循环式地刷屏。
这段代码同时暴露了一个真实的工程陷阱:对 SIGSEGV、SIGFPE 这种"由错误指令触发"的信号,无脑捕捉往往毫无意义甚至有害——除非你的 handler 能"治愈"那条出错指令(例如修正寄存器状态、改变执行流程),否则捕捉它只会制造反复崩溃。这正是为什么绝大多数程序对段错误/浮点异常选择默认动作(终止 + core dump)而不是去"拦"。
思考题 3:既然
SIGSEGV和SIGFPE都是"硬件异常翻译而来",那它们产生的时机和Ctrl+C这类信号有什么本质不同?答案:
Ctrl+C(SIGINT)属于"外部事件性"信号,它何时到达完全取决于用户按键,进程正在执行的指令本身没有错;而SIGSEGV/SIGFPE是同步于错误指令本身的——讲到底,是你正在执行的某条指令自己"犯了错"才触发。所以前者的 handler 返回后,原指令可以继续正常跑;后者的 handler 返回后,出错指令还会原样再执行一遍,从而反复又错。这也叫"同步信号"与"异步信号"的区别:同步信号紧跟出错指令、和进程当前执行流绑在一起,异步信号则随时可能插进来。
Core Dump:异常终止后留下的"事故现场"
信号表里的"Core"默认动作会顺带做一件事——写 core dump。它是什么?
Core dump:当一个进程异常终止时,系统可以把该进程的用户空间内存数据全部保存到磁盘上,生成一个通常叫
core的文件,这个动作就叫 core dump。因为进程异常终止多半是因为有 bug(比如非法内存访问),事后可以用调试器(如 gdb)加载 core 文件,查出它崩溃时的调用栈、变量值,"按图索骥"地定位 bug。这种"事后去查崩溃现场"的调试方法叫 Post-mortem Debug(死后/事后调试)。
一个进程允许产生多大的 core 文件,由它 PCB 里保存的 Resource Limit(资源上限) 决定。出于安全考虑——core 文件里可能包含用户的密码等敏感内存数据——默认情况下系统不允许产生 core 文件。开发调试时可以临时放开:
$ ulimit -c 1024 # 允许 core 文件最大 1024K
$ ulimit -a # 可以看到 core file size 变成了 1024
core file size (blocks, -c) 1024
...注意 ulimit 修改的是 Shell 进程的资源上限,而你前台启动的测试进程的 PCB 是由 Shell 复制而来的,因而继承了同样的上限,于是它能产生 core 文件了。用一个程序把子进程搞崩,然后尝试"事后验尸":
// core_test.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
if (fork() == 0) // 子进程
{
sleep(1);
int a = 10;
a /= 0; // 除零,触发 SIGFPE
exit(0);
}
int status = 0;
waitpid(-1, &status, 0); // 父进程阻塞等待子进程结束
// status 的低 7 位是子进程被哪个信号终止(0 表示正常退出)
// 第 8 位(右移 7 位)表示是否产生了 core dump
printf("exit signal: %d, core dump: %d\n",
status & 0x7F, (status >> 7) & 1);
return 0;
}$ ulimit -c 1024
$ ./core_test
exit signal: 8, core dump: 1 # 8 号信号 SIGFPE,且产出了 core dumpwaitpid 拿到的 status 是高深的二进制编码,但核心就两件事:低 7 位告诉你是哪个信号终结了它,第 8 位告诉你有无 core。读懂这个编码,就能让父进程在芯片层面"看见"子进程是怎么死的。
思考题 4:既然 core 文件这么有用,为什么系统默认禁止产生它?
答案:core dump 会把进程的用户空间内存完整写到磁盘。这等于把你程序运行时的所有数据——可能包括邮箱密码、会话 token、私人文档内容、加密密钥等敏感信息——原封不动地落盘成一个数据文件。默认禁止是为了保护隐私与安全(防止非正常终止时敏感数据泄漏到磁盘上)。所以只有在你明确处于开发调试阶段、确认这台机器安全、且确实需要"事后验尸"时,才用
ulimit -c N放开这个限制。
信号的保存:block 位图与 pending 位图
现在我们知道信号如何产生、默认如何处理。但抓住这个疑点不放:信号产生之后,是不是立刻、无条件处理? 不是。前面快递比喻就解释了,进程可能正在忙优先级更高的事,所以信号常常要"等一等"。这一等,就牵扯出信号的世界里最核心的一对概念——未决(pending) 与 阻塞(block)。
三个关键术语:递达、未决、阻塞
先把三个术语钉死,它们首现,务必吃透:
- 递达(Delivery):信号从产生到真正执行处理动作的过程。当一个信号的处理动作真正被执行了,我们就说这个信号"被递达了"。注意,"递达"关注的是"处理有没有发生",而不是"信号有没有到达"。
- 未决(Pending):信号从产生到递达之间的这段状态叫未决。一个信号已经产生、但还没被处理,它就一直"悬而未决",这就是未决状态。
- 阻塞(Block):进程可以选择阻塞某个信号。一个被阻塞的信号产生后,它会一直保持未决状态,直到进程解除对该信号的阻塞,才轮到它递达并执行处理动作。
一句话串起三者:信号产生 → 若未阻塞,尽快递达处理;若被阻塞,则停留在未决状态,等解除阻塞后再递达。
这里必须澄清一个全篇最重要的辨析:阻塞 ≠ 忽略。
- 阻塞是"处理节奏"层面的动作,它说的是"这个信号我现在不让它递达"——它只是被摁住不放,一旦解除阻塞就会被递达。阻塞是个"开关",管的是"会不会以及何时进入处理流程"。
- 忽略是"处理动作"层面的选择,它说的是"这个信号递达之后怎么处理——当作没看见"。忽略发生在递达之后,是处理动作的一种。
做个生活类比:阻塞像"我要开会,任何电话都先不接"——电话(信号)一直有未接提示(未决),开完会(解除阻塞)我再挨个回过去(递达);忽略像"我手机开了勿扰,快递电话来了我根本不接听完不成事件",这是"发生了但我选择不当回事"。所以一个信号完全可以"被忽略这种处理动作"但"暂时被阻塞"。这也是为什么内核在"信号尚未解除阻塞、处理动作还没最终敲定"之前,不能提前把它忽略掉——因为进程仍有机会在解除阻塞前,把处理动作从"忽略"改成"自定义捕捉",改完再解除阻塞,那就该执行新的动作。
内核里究竟怎么表示:pending 位图与 block 位图
为了支持上面那套状态,内核在进程的 PCB(task_struct,我们以 2.6.18 内核为例)里为每个信号准备了三样东西:
struct task_struct {
...
struct sighand_struct *sighand; // 指向信号处理动作表的指针
sigset_t blocked; /* 阻塞集:一个 bit 管一个信号 */
struct sigpending pending; /* 未决集:一个 bit 管一个进程 */
...
};其中 sigset_t 就是我们反复提到的信号集,你可以把它理解成一个位图(bitmap)。每个信号在某个 sigset_t 里占一个 bit:blocked(阻塞集,也叫信号屏蔽字 Signal Mask)中"第 n 位为 1"表示第 n 号信号被阻塞;pending(未决集)中"第 n 位为 1"表示第 n 号信号当前处于未决状态(产生了、还没递达)。
于是一个进程的信号状态,就可以用"两个位图 + 一份处理动作表"干净地描述:
- blocked / 信号屏蔽字:哪个信号被阻塞。一个 bit 表示"有效(阻塞)/ 无效(不阻塞)"。
- pending / 未决信号集:哪个信号处于未决态。一个 bit 表示"有效(有未决信号)/ 无效(没有)"。
- 处理动作表(sighand):对这个信号执行默认、忽略、还是自定义函数,通常用函数指针记录。
关键点在于这些位图“每个 bit 只有 0/1 两种状态,并不记录这个信号到底产生了多少次”。也就是说,普通信号在未决期间如果又来了同一种信号,多次只会累计为一个 bit;信号正式"递达一次",然后在未决态再积累多次也不会被排队——这正是普通信号与实时信号的分水岭。实时信号(编号 34+)在未决期间允许"多次产生、依次排队",而普通信号不行。POSIX 标准允许系统在"一个信号被阻塞期间多次产生"时只递送一次或多次,Linux 的普通信号实现选择只计一次。
同时,sigset_t 这样的位图数据类型不能靠肉眼直接读,也不能通过 printf 打印来判断某个 bit(它的内部布局依赖系统实现)。它提供了专门的操作函数,见下节。
操作信号集的基础函数
操作信号集有五个基础函数,来自 <signal.h>:
#include <signal.h>
int sigemptyset(sigset_t *set); // 把 set 清空:所有信号对应 bit 置 0
int sigfillset(sigset_t *set); // 把 set 填满:所有信号对应 bit 置 1
int sigaddset(sigset_t *set, int signo); // 把 signo 这个 bit 置 1(加入集合)
int sigdelset(sigset_t *set, int signo); // 把 signo 这个 bit 置 0(移出集合)
int sigismember(const sigset_t *set, int signo);
// sigismember 是"判断"函数:含则返回 1,不含返回 0,出错返回 -1sigemptyset/sigfillset是初始化函数,必须二选一在刚声明sigset_t变量后先调用,让信号集处于确定状态。之后才能用sigaddset/sigdelset增删成员。- 前四个函数成功返回 0,出错返回 -1。
- 程序员不应该对
sigset_t的内部存储位做任何直接解释(比如拿它博主 printf),一切交给这些函数。
用 sigprocmask 改屏蔽字、用 sigpending 看未决集
有了信号集,真正"写回进程"要靠 sigprocmask:
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
// 成功返回 0,出错返回 -1参数规则:
- 若
oset非空:先把当前的信号屏蔽字备份到oset(方便后面恢复)。 - 若
set非空:根据how指示的方式,用set去修改当前屏蔽字。 how三个取值:
| how 值 | 含义 |
|---|---|
SIG_BLOCK | 把 set 里的信号加入当前屏蔽字(累计式地"多屏蔽几个") |
SIG_UNBLOCK | 把 set 里的信号从当前屏蔽字中删除(解除屏蔽) |
SIG_SETMASK | 整表替换:直接用 set 覆盖当前屏蔽字 |
读取未决集则用 sigpending:
#include <signal.h>
int sigpending(sigset_t *set); // 读取当前进程的未决信号集,存到 set,成功返回 0现在我们把这几件装备组装成一个"信号解剖实验",亲眼看着 SIGINT 被阻塞、变成未决、又被释放:
// block_demo.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
// 把当前进程的未决信号集打印成 31 位的 0/1 字符串
static void PrintPending(void)
{
sigset_t pset;
sigpending(&pset); // 取出当前未决信号集
printf("pending: ");
for (int signo = 1; signo <= 31; signo++)
{
// 逐位判断该信号是否处于未决状态
putchar(sigismember(&pset, signo) ? '1' : '0');
}
printf("\n");
}
void handler(int signo)
{
// 信号一旦真正递达,说明屏蔽已解除,这里再看一眼未决集
printf("%d 号信号被递达!!!\n", signo);
PrintPending();
}
int main(void)
{
signal(SIGINT, handler); // 捕捉 2 号信号,以便递达时不退出
sigset_t block;
sigemptyset(&block); // 先初始化:所有位清 0
sigaddset(&block, SIGINT); // 把 SIGINT(2) 这一位置 1
sigprocmask(SIG_BLOCK, &block, NULL); // 真正把 SIGINT 加进进程屏蔽字
int cnt = 5;
while (1)
{
PrintPending();
sleep(1);
if (--cnt == 0)
{
// 解除对 SIGINT 的屏蔽(用 SIG_UNBLOCK)
sigprocmask(SIG_UNBLOCK, &block, NULL);
printf("已解除对 2 号信号的屏蔽!\n");
}
}
return 0;
}运行,中途按 Ctrl+C,观察输出:
$ ./block_demo
pending: 0000000000000000000000000000000 # 一开始什么未决信号都没有
pending: 0000000000000000000000000000000
^C pending: 0000000000000000000000000000010 # 从右往左第 2 位(SIGINT)变成 1!
pending: 0000000000000000000000000000010 # 一直保持为未决:因为被屏蔽了
pending: 0000000000000000000000000000010
...
已解除对 2 号信号的屏蔽! # 屏蔽一解除,SIGINT 立刻递达
2 号信号被递达!!!多冷静地观察输出:按 Ctrl+C 之后,pending 位图的第 2 位变成了 1——SIGINT 产生了,但因为被 sigprocmask(SIG_BLOCK, ...) 屏蔽着,它既不递达也不消失,就这样"悬而未决"地挂在未决集里。等 cnt 归零、SIG_UNBLOCK 解除屏蔽,信号立刻递达,执行 handler,PrintPending 打印出第 2 位已经清零(递达后被清除)。
这个实验同时印证了前面的论断:阻塞期间多次 Ctrl+C,pending 里第 2 位依然只是一个 1——普通信号不计数、不排队。
思考题 5:既然 pending 位图只有 0/1,那一个普通信号在"被阻塞"期间来 10 次和来 1 次,解除阻塞后效果相同吗?
答案:相同。普通信号在未决期间"产生多次只计一次"——pending 里始终只有一个对应的 bit。解除阻塞后,信号只会被递达一次(最多一次)。如果你真的需要一个"来的每一下都不被吞掉、能依次排队处理"的机制,就得使用实时信号(编号 34+),实时信号在未决期间可以多个同时排队。这就是普通信号与实时信号最重要的行为差异,也是编写可靠信号程序时必须意识到的边界。
信号的捕捉:signal 与 sigaction
信号的第三种处理动作"自定义捕捉"我们已经用 signal 初步体验过了。这一节把它讲透,并引入更专业的 sigaction。
signal 的粗糙原理
看回 signal 的签名与它的本质:
#include <signal.h>
typedef void (*sighandler_t)(int); // 信号处理函数的类型
sighandler_t signal(int signum, sighandler_t handler);
// 返回旧的 handler(可用于恢复),出错返回 SIG_ERR而 <signal.h> 里其实这样定义那几个特殊宏:
#define SIG_DFL ((__sighandler_t)0) /* Default action. 默认动作 */
#define SIG_IGN ((__sighandler_t)1) /* Ignore signal. 忽略 */
typedef void (*__sighandler_t)(int); /* 信号处理函数类型 */原来 SIG_DFL 和 SIG_IGN 就是把整型常量 0、1 强转成函数指针类型而已。所以:
signal(SIGINT, SIG_DFL):处理动作设为"默认"(终止进程)。signal(SIGINT, SIG_IGN):处理动作设为"忽略"。signal(SIGINT, handler):处理动作设为"自定义函数 handler",也就是注册一个信号处理函数(signal handler,收到对应信号时由内核回调执行)。
验证忽略,写一个"怎么按 Ctrl+C 都没反应"的程序:
// ignore_demo.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
int main(void)
{
signal(SIGINT, SIG_IGN); // 把 SIGINT 的动作设为"忽略"
while (1)
{
printf("I am alive!\n");
sleep(1);
}
return 0;
}$ ./ignore_demo
I am alive!
I am alive!
^C^C^C^C^C^CI am alive! # 连续按 Ctrl+C,毫无反应
I am alive!但别忘了,这个程序仍可以用 kill -9(SIGKILL)强杀——SIGKILL 不可堵不可捕。
信号捕捉的完整流程:一次"入内核 → 回用户态"的惊险一跃
自定义捕捉看似简单,底层却是一场精妙的状态切换。假设用户给 SIGQUIT 注册了处理函数 sighandler,当 SIGQUIT 递达时,完整的流程是这样的:
- 用户程序正执行
main函数;这时发生中断或异常(也可能是一次系统调用),进程被切入内核态,去执行对应的内核处理例程。 - 内核处理完毕后,准备返回用户态的
main之前,检查到"有信号SIGQUIT需要递达"。 - 内核决定返回用户态后,不是恢复
main的上下文继续执行,而是先执行sighandler。注意,sighandler和main使用不同的堆栈空间,它俩之间不存在调用与被调用的关系——sighandler是从内核"凭空"切入的另一条独立控制流。这也是"信号处理函数是异步插入"的本质所在。 sighandler执行完毕后,会自动执行一个特殊的系统调用sigreturn(信号返回)再次进入内核态,去清理"为这次信号处理搭建的临时栈帧/恢复现场"。- 若此时没有新的信号待递达,这一次再返回用户态,才是真正恢复
main的上下文继续执行。
把这条链画成一张简图:
用户态 main() ——(中断/异常/系统调用)——> 内核态
| 处理完毕,检查到待递达信号
v
内核决定先执行 sighandler, 恢复 sighandler 的栈帧
|
用户态 main() <——(从内核再次返回)UIKit—— sigreturn 回内核清理
^
若还有新信号则继续处理
也正因为"跑 sighandler 要额外搭一套用户态栈、做一次完整的用户/内核切换",信号处理是有真实开销且处理期间主流程是"暂停"的——这再次解释了为什么信号不是"随到随处理",而是"在合适时机处理"。
sigaction:更可靠、更精细的捕捉
signal 历史上有"行为在不同 UNIX 上不统一"的坏名声(老 SysV 语义下,信号被递达后处理函数会被重置回 SIG_DFL,导致"信号只接得了一次")。吸收教训,POSIX 推荐用 sigaction:
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
// 成功返回 0,出错返回 -1signo:要设置的信号编号。act:非空时,用它描述的"新动作"去设置该信号。oact:非空时,把该信号原来的动作带回来(可当记忆用,下次恢复)。
它接受的结构体 struct sigaction:
struct sigaction {
void (*sa_handler)(int); // 处理函数(或 SIG_DFL/SIG_IGN)
sigset_t sa_mask; // 处理期间额外屏蔽的信号集
int sa_flags; // 选项位
void (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号等更丰富回调
// (具体字段顺序因平台而异;这里示意)
};要点拆解:
sa_handler赋SIG_DFL是执行默认动作,赋SIG_IGN是忽略,赋函数指针则是注册信号处理函数。该函数返回void、接受一个int信号编号,因而同一个函数可以处理多种信号(靠参数区分)。sa_mask:在信号处理函数被调用期间,sa_mask里列出的信号会被自动加入进程的屏蔽字,函数返回时自动恢复。它用来"在处理 A 信号期间,临时夹住你也不想被插进来的 B 信号"。- 对 Linux(glibc)上实现的
signal来说,它底层其实也是调用sigaction(现代 glibc 的signal带SA_RESTART标志),所以在现代 Linux 上signal也具备"事件驱动重启被中断的系统调用"等较温和的行为。但为了跨平台稳定,工程上更推崇sigaction。
写一个使用 sigaction 的例子,并且用 sa_mask 演示"信号处理期间的屏蔽":
// sigaction_demo.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
static void handler(int signo)
{
printf("进入 %d 号信号的处理函数\n", signo);
sleep(5); // 故意让 handler 待 5 秒,观察期间的屏蔽效果
printf("离开 %d 号信号的处理函数\n", signo);
}
int main(void)
{
struct sigaction act;
act.sa_handler = handler;
sigemptyset(&act.sa_mask); // 不额外屏蔽其它信号
sigaddset(&act.sa_mask, SIGQUIT); // 但要求处理 SIGINT 期间,顺带屏蔽 SIGQUIT
act.sa_flags = 0;
sigaction(SIGINT, &act, NULL); // 用 sigaction 注册 SIGINT 捕捉
while (1)
{
printf("主循环工作...\n");
sleep(1);
}
return 0;
}跑起来按一次 Ctrl+C,再在 handler 的 5 秒睡眠期内连按 Ctrl+\,你会看到 SIGQUIT 被 sa_mask 挡下——它不会被立刻递达,一直等 handler 返回、屏蔽解除后才递达。这就是"信号处理期间的屏蔽":在处理某个信号时,内核自动把正在处理的这个信号(以及 sa_mask 指定的额外信号)临时加入屏蔽字,期间这些信号再来会被搁置为未决,等 handler 返回、屏蔽字恢复原状后再递达——从而保证处理同一个信号时不会出现"自己打断自己"的递归混乱。
思考题 6:如果在
handler正在处理SIGINT的 5 秒内,又来了好几次Ctrl+C,最终会怎样?跟来了几次有关吗?答案:期间来的每次
Ctrl+C都会让SIGINT进入未决状态,但因为SIGINT本身在 handler 执行期间自动被屏蔽,它们不会立刻递达。同时,普通信号在未决期间"多次只计一次",pending 位图里就一个 bit。所以 handler 返回、解除屏蔽后,最多再递达一次SIGINT(handler 最多再被调用一次),与"期间按了多少次Ctrl+C"无关。这再次提醒:用普通信号来统计"事件发生次数"是不可靠的,它只适合"通知有事情发生"。
阻塞与忽略的再次辨析:为什么忽略是"递达之后的选择"
把我们前面埋的伏笔在这儿彻底收拢。内核在处理一个信号时,逻辑上分两阶段:
- 能否递达:先看这个信号是否在屏蔽字(blocked 位图)里。在 → 只能挂在 pending 位图里等着,根本不进入"处理动作"这个环节。
- 递达后怎么办:信号若未被阻塞,就让它递达,此刻才去看
sighand里的处理动作——是忽略、是默认、还是自定义 handler。
因此"忽略"绝不是"不处理"的代名词,它本身就是一种处理动作;而"阻塞"根本不谈处理动作,它连"要不要处理"都还没到。内核之所以要区分这两件事,是因为 "处理动作"在信号解除阻塞之前是可以改的:某信号此刻的处理动作可能被临时改成"忽略",但在它没有被递达之前,进程仍有权利把处理动作再改成别的再做定论。为了保证"动作还没最后定夺前不轻举妄动",内核天然要求"被阻塞的信号必须等解除阻塞、递达的那一刻再兑现它的处理动作"。
可重入函数:信号处理函数的天花板
信号处理函数最大的特点,就是它能插入到任意一条正在执行的指令之间。这就引出了一个危险的场景——重入(reentrancy)问题。
先看一个教科书级的翻车示例。假设 main 调用 insert 函数,向链表 head 插入节点 node1。插入分两步(比如先 head->next = node1,再改些别的)。刚做完第一步,硬件中断把进程切进内核,回到用户态之前发现有信号待处理,于是切到 sighandler;而 sighandler 也调用 insert 向同一个链表 head 插入节点 node2。等 handler 里的两步都做完、返回内核、再回到 main 时,main 的 insert 才接着往下做第二步。最终结果是:明明插入了两个节点 node1 和 node2,链表里却只有一个真正插进去了——因为 main 和 handler 两个控制流各自看到的是"没插完"的中间状态,互相覆盖。
这个现象揭示了核心概念:
像
insert这样,同一个函数被两条以上独立的控制流调用,有可能在第一次调用还没返回时就被再次进入,这种情况叫重入。如果这个函数访问了可能被多个控制流并乱修改的共享数据(如全局链表),重入就会造成错乱,这种函数叫不可重入(non-reentrant)函数;反之,如果一个函数只访问自己的局部变量或参数,那它天然安全,叫可重入(reentrant)函数。
为什么"只访问局部变量/参数"就可重入?因为每个控制流在被调时必须为局部变量/参数分配独立的栈帧——main 调 insert 有一套栈帧,sighandler 调 insert 又新起一套栈帧,两套互不干扰,各自拿到的都是自己那份局部数据。任何一份都不会被别人动到。而全局数据只有一份,谁改都是改同一块内存,两条控制流抢来抢去必然出乱子。
那么哪些函数在信号处理函数里用是危险的? 规则很具体:
- 调用了
malloc/free的函数:malloc内部用一个全局链表管理堆空闲块,信号打断期间再调malloc,两条控制流都可能改这个全局链表,堆管理信息会被打乱。 - 调用标准 IO 库函数的函数(
printf、fread、fwrite等):标准 IO 的很多实现以不可重入的方式使用全局数据结构(比如内部缓冲区),也会互相踩踏。 - 反之,只访问自身局部变量、局部数组、以及"专为信号处理准备的、每次都从头初始化的数据"的函数,才是可重入的。
所以写信号处理函数有一条铁纪律:handler 里除非绝对必要,不要调用 malloc、free、printf 这类不可重入函数。 这就是为什么很多严肃的工程里,信号处理函数只做一个动作——把一个 volatile 标志位置 1,真正的"重活"放回主流程去干。这也正好衔接下一节。
volatile:让共享标志位真正可见
既然信号处理函数最常干的是"改一个标志位给主流程看",那我们就聊聊标志位,以及一个初学信号几乎必踩的坑——编译器优化让主流程读不到 handler 改过的值。
看这个程序(有 bug 的前两版):
// flag_demo.c (编译时用 -O0 不优化,程序正常)
#include <stdio.h>
#include <signal.h>
int flag = 0; // 全局标志位,handler 会把它改成 1
void handler(int sig)
{
printf("change flag 0 to 1\n");
flag = 1; // 信号处理函数:把 flag 置 1
}
int main(void)
{
signal(SIGINT, handler); // 捕捉 Ctrl+C
while (!flag); // 主流程忙等:flag 变 1 就退出循环
printf("process quit normal\n");
return 0;
}第一版,用 gcc -O0(不优化)编译运行,行为符合直觉:
$ gcc -o flag_demo flag_demo.c -O0
$ ./flag_demo
^Cchange flag 0 to 1
process quit normal # flag 变 1,循环退出,正常结束但如果用 gcc -O2(开优化)编译,同样跑起来,按 Ctrl+C 却诡异了:
$ gcc -o flag_demo flag_demo.c -O2
$ ./flag_demo
^Cchange flag 0 to 1
^Cchange flag 0 to 1
^Cchange flag 0 to 1 # 无限刷屏:handler 分明改了 flag,循环却永远出不来!明明 handler 打印了"change flag 0 to 1"、确实把 flag 改成了 1,while (!flag) 却始终转不出来。这是为什么?
原因在于编译器优化。main 里的 while (!flag) 是一个"不修改 flag 的值、也不依赖任何外部副作用"的高频读循环。聪明的编译器(-O2)会发现:"这个循环里 flag 的值始终是恒定的嘛(从当前流程看,确实没有任何语句改它),那我直接把它当成常量、放到 CPU 寄存器里读,根本不用每次去内存取!"于是循环被优化成"读一次寄存器,永远用那个旧值"。而 handler 写的是内存里的 flag,两者"各读各的",主循环自然永远等不到新值。这就是信号处理场景下最典型的数据不一致/可见性问题:handler 在内存里改了值,主循环却在寄存器里读旧值。
解决办法就是给 flag 加上 volatile 关键字:
// flag_demo_volatile.c
#include <stdio.h>
#include <signal.h>
volatile int flag = 0; // volatile:告诉编译器这变量可能被信号/中断改动,
// 每次读它都必须真正去内存取,禁止优化进寄存器
void handler(int sig)
{
printf("change flag 0 to 1\n");
flag = 1;
}
int main(void)
{
signal(SIGINT, handler);
while (!flag);
printf("process quit normal\n");
return 0;
}这次即便 -O2 编译也乖乖正常退出(因为编译器不再敢把循环优化成"读寄存器里的旧值",每次 !flag 都会老老实实去内存里取)。
volatile的作用:保持"内存可见性",告诉编译器——被它修饰的变量,不允许按普通优化规则缓存进寄存器;对该变量的任何读取和写入,都必须真实发生在内存中。它专门用于"这个变量的值可能被当前指令流之外的东西修改"的场合,比如:信号处理函数修改标志位、多线程共享变量(配合其它同步手段)、访问内存映射的硬件寄存器。注意,volatile解决的是"读者能不能看到新值"的可见性/优化问题,它并不提供原子性——如果多个地方同时做"读-改-写",仍需要别的同步机制。
思考题 7:既然
-O0下不加volatile的程序也能正常跑,那我是不是可以不管volatile?答案:不行。
-O0只是碰巧(因为没优化、不做寄存器缓存)"看起来正常",它并没有任何语言层面的保证。一旦切到发布用的-O2/-O3或其它优化级别,同一个 bug 立刻现形(你会看到上面"无限刷屏")。所以对于"信号处理函数或其它异步上下文需要修改、主流程又需要读取"的标志位,必须在声明处稳定加上volatile——它不是可选的性能技巧,而是正确性问题。
SIGCHLD:让父进程自动回收子进程
讲完信号的核心机制,来看一个信号在实际工程里最有价值的应用——用 SIGCHLD 解决僵尸进程的回收问题。
先回顾背景:子进程终止后,如果父进程一直不 wait/waitpid,子进程会变成僵尸进程。老办法有两种,都很别扭:一是父进程阻塞在 wait() 上,那就啥也别干了,只等子进程;二是父进程忙着自己工作的同时,隔一会儿轮询一下 waitpid(-1, &st, WNOHANG),实现繁琐、又浪费。
信号的解法优雅得多:
子进程在终止时(或停止时)会给父进程发
SIGCHLD信号。SIGCHLD的默认动作是忽略。父进程可以自定义SIGCHLD的处理函数,在其中调用waitpid把子进程"回收"掉——这样父进程平时专心干自己的活,子进程一死,内核自动发SIGCHLD来通知,父进程顺手把它清理掉,两不耽误。
// sigchld_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
void handler(int sig)
{
pid_t id;
// 循环回收所有已终止的子进程,WNOHANG 表示"没有就立刻返回,不阻塞"
while ((id = waitpid(-1, NULL, WNOHANG)) > 0)
{
printf("回收子进程成功: %d\n", id);
}
}
int main(void)
{
signal(SIGCHLD, handler); // 父进程捕捉 SIGCHLD
pid_t cid = fork();
if (cid == 0)
{
// 子进程:干点活然后退出
printf("child: %d\n", getpid());
sleep(3);
exit(0);
}
// 父进程:专心忙自己的事,不必惦记子进程
while (1)
{
printf("father proc is doing something!\n");
sleep(1);
}
return 0;
}运行输出:
$ ./sigchld_demo
child: 44892
father proc is doing something!
father proc is doing something!
father proc is doing something!
回收子进程成功: 44892 # 子进程 3 秒后退出,SIGCHLD 通知父进程把它回收
father proc is doing something!
...注意 handler 里用的是 waitpid(-1, NULL, WNOHANG) 的循环,而不是一次 wait。为什么刻意用循环?因为多个子进程可能几乎同时死亡、而 SIGCHLD 这种普通信号又不排队、会"合并",一次递达未必对应一个子进程。用循环兜底,就能把此刻所有已终止的子进程一次清理干净,避免留下漏网的僵尸。
再讲一个历史遗留的"旁门",仅作了解:因为 UNIX 历史原因,如果用
sigaction把SIGCHLD的处理动作置为SIG_IGN,那么fork出来的子进程在终止时会由内核自动清理、不产生僵尸进程,也不会再通知父进程。这算"忽略"的一个特例——我个人再强调一遍,系统默认的忽略动作和用户用sigaction自定义的忽略"通常没有区别,但SIGCHLD是例外"。此招在 Linux 上可用,但不保证所有 UNIX 都相同。所以工程上建议优先用"捕捉SIGCHLD+waitpid(WNOHANG)循环"这条四平八稳的路线。
思考题 8:为什么
SIGCHLD信号处理函数要用waitpid(-1, NULL, WNOHANG)循环回收子进程,而不用带阻塞的wait(NULL)?答案:有两层原因。其一,
SIGCHLD是普通信号,不排队,多个子进程几乎同时退出时,信号可能被合并成一次递达,handler 只被调用一次,若只用wait一次只能回收一个子进程,其它僵尸会漏掉;用循环 +WNOHANG可以把当下所有已终止的子进程一起收走。其二,wait(NULL)会阻塞,而 handler 里绝不该出现"阻塞等待",否则万一没有子进程可回收,父进程的控制流就被卡死在信号处理函数里,且信号处理期间是不可打扰的,极易引发死锁性质的问题。WNOHANG(不等待)保证找不到子进程时立刻返回 0,handler 干净利落地退出。
写在最后
我们从"等快递"这个生活比喻出发,一路走到内核的 PCB 位图:到现在你应该能完整回答开篇那个问题了——进程凭什么能"知道并响应"异步事件?因为它天生认识信号(处理预案内置在 sighand 的动作表里),信号来了先落进 pending 位图(未决),能不能及时处理要看 block 位图(阻塞),真要动手则在从内核返回用户态的那个合适时机去递达,递达后按默认、忽略、还是自定义函数去兑现。
顺这条线,你还掌握了如何产生信号(Ctrl+C / Ctrl+\ / Ctrl+Z 的按键家族,kill 命令/函数、raise、abort 的函数家族,alarm 代表的软件条件,除零/野指针代表的硬件异常,以及 ulimit -c 与 core dump 的事后调试姿势),如何用 sigset_t + sigprocmask + sigpending 去正视 block/pending 两个位图,如何用 signal 与更规范的 sigaction 去捕捉,并透过 sa_mask 看清"信号处理期间的屏蔽"与"阻塞 ≠ 忽略"这两对极易混淆的概念,最后落到信号处理函数最严肃的三条纪律:可重入(handler 少碰 malloc/printf)、volatile(共享标志位必须真正内存可见)、SIGKILL/SIGSTOP 不可堵不可捕(终极手段永远在内核手里)。
信号是 Linux 系统编程的承重墙之一——它与文件、管道、进程控制深度交织,也几乎是理解更高级异步模型的必经起点。如果你能把这篇文章里的每个例子亲手编译运行、把每道思考题自己推演一遍、把每个"为什么"都自己讲给自己听,那么信号这门课,你就真的拿下了。下一站,我们不妨顺着"父子进程协同"继续往深处走,看看进程与进程之间还有哪些更精彩、更复杂的协作方式。
还没有评论 — 第一条由你来留。