在开始之前,先问一个几乎所有写过多进程程序的人都会遇到、却很少真正想清楚的问题:为什么一个进程能够"知道"并"响应"某些不请自来的事件? 比如你写了一个死在 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 打印出来之后进程就没了。这个简单的现象背后藏着整条链路:

  1. 你在 Shell 里启动了一个前台进程(这是默认的启动方式,Shell 会等它结束才能接收下一条命令;命令后加个 & 可以放到后台,此时 Shell 不必等它,可以直接继续)。
  2. 你按下 Ctrl+C,键盘产生了中断,被操作系统捕获并且解释成一个信号——这个信号就是 SIGINT,编号 2,全称 Interrupt from keyboard(来自键盘的中断)。
  3. 操作系统把 SIGINT 发送给当前这个前台进程。
  4. 前台进程收到 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

现象里藏着两个极有价值的事实:

  1. 进程这次没退出。 因为 SIGINT 的默认处理动作是"终止进程",而我们用 signal(SIGINT, handler) 把它的动作改成了"调用我们自己的函数"。收到信号后,内核转而执行我们的 handler,打印完又回到主循环继续跑。也就是说,信号的处理到底由谁来决定,是进程自己说了算(除非遇到下面要讲的 SIGKILL 这类硬约束)。
  2. 信号处理函数是"回调"而不是"调用"。 注意,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 里有完整说明。表里"默认动作"一列分四类,我们马上逐个解释。

编号名称产生条件默认动作
1SIGHUP终端挂断 / 控制进程死亡Term(终止)
2SIGINT键盘 Ctrl+CTerm(终止)
3SIGQUIT键盘 Ctrl+\Core(终止并转储)
4SIGILL非法指令Core
5SIGTRAP断点/单步,调试用Core
6SIGABRT调用 abort() 函数Core
7SIGBUS总线错误(非对齐等)Core
8SIGFPE运算异常(除以 0 等)Core
9SIGKILL强制杀死进程Term(不可堵、不可捕)
10SIGUSR1用户自定义信号 1Term
11SIGSEGV非法内存访问(段错误)Core
12SIGUSR2用户自定义信号 2Term
13SIGPIPE向无读者管道写数据Term
14SIGALRM闹钟超时(alarm() 触发)Term
15SIGTERM请求正常终止(kill 默认发它)Term
17SIGCHLD子进程终止/停止Ign(忽略)
18SIGCONT让停止的进程继续Cont(继续)
19SIGSTOP停止进程Stop(不可堵、不可捕)
20SIGTSTP键盘 Ctrl+Z 停止Stop
23SIGURG紧急数据到达(socket)Ign
24SIGXCPUCPU 时间超限Core
28SIGWINCH终端窗口尺寸变化Ign

四类"默认动作"分别指:Term(Terminate,终止进程)、Core(终止进程并产生 core dump 文件,用于事后调试)、Stop(停止进程,但进程还活着,只是挂起)、Ignore(忽略,什么都不做,进程继续)。注意:默认动作是"忽略"不等同于"阻塞",这层区别是全文最重要的辨析之一,后面专门讲。

有一个必须刻进脑子的硬规则:SIGKILL(9)和 SIGSTOP(19)既不能被阻塞、不能被忽略,也不能被捕捉。这两兄弟是操作系统保留的"终极手段":SIGKILL 用来把怎么都不肯退的合作进程一记打死,SIGSTOP 用来无论进程干什么都把它冻结。内核在实现层面直接无视对这两个信号的 signal/sigaction 设置与 sigprocmask 阻塞请求,就是为了保证"踢门"的权力永远握在内核手里。这也是为什么后面你会看到"有些进程连 Ctrl+C 都不怕(把 SIGINT 忽略了),却还是会被 kill -9 干掉"。

信号的产生:四大家族的袭击

信号不是凭空出现的,归纳起来有四大来源。我们逐个用代码验证。

通过终端按键产生

按键产生的信号默认动作
Ctrl+CSIGINT(2)终止进程
Ctrl+\SIGQUIT(3)终止进程并 Core dump
Ctrl+ZSIGTSTP(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_sigquit

SIGTSTP 与 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 dump

waitpid 拿到的 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,出错返回 -1
  • sigemptyset / 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 递达时,完整的流程是这样的:

  1. 用户程序正执行 main 函数;这时发生中断或异常(也可能是一次系统调用),进程被切入内核态,去执行对应的内核处理例程。
  2. 内核处理完毕后,准备返回用户态的 main 之前,检查到"有信号 SIGQUIT 需要递达"。
  3. 内核决定返回用户态后,不是恢复 main 的上下文继续执行,而是先执行 sighandler。注意,sighandler 和 main 使用不同的堆栈空间,它俩之间不存在调用与被调用的关系——sighandler 是从内核"凭空"切入的另一条独立控制流。这也是"信号处理函数是异步插入"的本质所在。
  4. sighandler 执行完毕后,会自动执行一个特殊的系统调用 sigreturn(信号返回)再次进入内核态,去清理"为这次信号处理搭建的临时栈帧/恢复现场"。
  5. 若此时没有新的信号待递达,这一次再返回用户态,才是真正恢复 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,出错返回 -1
  • signo:要设置的信号编号。
  • 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"无关。这再次提醒:用普通信号来统计"事件发生次数"是不可靠的,它只适合"通知有事情发生"。

阻塞与忽略的再次辨析:为什么忽略是"递达之后的选择"

把我们前面埋的伏笔在这儿彻底收拢。内核在处理一个信号时,逻辑上分两阶段:

  1. 能否递达:先看这个信号是否在屏蔽字(blocked 位图)里。在 → 只能挂在 pending 位图里等着,根本不进入"处理动作"这个环节。
  2. 递达后怎么办:信号若未被阻塞,就让它递达,此刻才去看 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 系统编程的承重墙之一——它与文件、管道、进程控制深度交织,也几乎是理解更高级异步模型的必经起点。如果你能把这篇文章里的每个例子亲手编译运行、把每道思考题自己推演一遍、把每个"为什么"都自己讲给自己听,那么信号这门课,你就真的拿下了。下一站,我们不妨顺着"父子进程协同"继续往深处走,看看进程与进程之间还有哪些更精彩、更复杂的协作方式。