从事Linux服务端开发这些年,我见过太多同行在信号处理函数上栽跟头:有的进程随机无响应,有的日志写到一半卡死,有的明明只是处理一个SIGINT程序却整个栈被压穿。这些问题的根源,几乎都指向同一个点——很多人把信号处理函数当成一个"能短暂插一脚的普通函数",却没有真正理解它在内核视角下到底是什么。这篇博客我就从"中断流程"这四个字入手,把信号处理函数从触发、进入、执行到返回的全过程拆开揉碎讲清楚,也会把我实际排查过的三个典型故障完整复盘。如果你正在写守护进程、自研框架,或者只想弄明白"为什么处理函数里不能碰printf",这篇文章应该能帮你省下不少弯路。
1. 先搞清楚:信号处理函数到底是怎么"插队"进来的
1.1 信号从产生到被"看到":位图、挂起与屏蔽
我们经常说"进程收到一个信号",但这个说法很容易让人误以为信号一到,处理函数马上就跑。真实情况是:信号到达之后,最先生效的是进程内部的一组"未决信号标记"(pending signal bitmap),而不是处理函数本身。
内核为每个线程维护一个pending位图和一个阻塞位图(blocked mask)。当信号产生时,比如你执行kill(pid, SIGINT),内核做的事情仅仅是到目标线程的pending位图上把对应位点亮,然后看这个信号是否在阻塞集合里。如果被阻塞了,那就暂时搁置;如果没被阻塞,内核会设置一个TIF_SIGPENDING标志,提醒该线程"从内核返回用户态之前有个信号等着你处理"。
这里有个关键区别:普通信号(1~31号)在未决状态是不排队的,同一个信号连续来10次,位图上仍然只有一个位。而实时信号(32号以后)会走独立的信号队列,可以排队,也能携带额外数据(比如sigqueue传的 int 值)。我见过有人用 SIGUSR1 统计某类事件数量,结果漏了9次,这就是位图合并导致的。若真想计数,要么用实时信号,要么用共享内存自己计数。
1.2 真正的递送时机:为什么它总在"返回用户态"那一刻发生
了解了未决标记还不够,真正容易让人困惑的问题是:为什么信号处理函数执行的位置总是"突然"的?有时程序明明在read()里阻塞,信号一来 read 立刻返回EINTR;有时程序明明在死循环里疯狂计算,处理函数却像精确地插进了两条指令之间。
答案在于内核的递送时机:绝大多数信号不是在信号产生那一瞬间处理的,而是在目标线程从内核态切回用户态的前夕被统一检查的。落在 x86-64 上就是系统调用返回到用户空间、或调度器切回该线程的那段路径上,内核看到TIF_SIGPENDING后调用do_signal(),把当前进程的 trap frame(寄存器现场)保存到一个精心准备的栈帧里,然后修改指令指针(RIP)指向处理函数的入口。等 CPU 真正切回用户态,程序看起来就像被"打断"了一样,跳进了 handler。
这就解答了一个经典的疑问:为什么信号处理函数不会真正打断内核里的代码,比如 syscall 内部正在把数据从内核缓冲区复制到用户缓冲区时,你不用担心复制一半被信号拆散。它们要么在 syscall 返回后被打断,要么在用户态任意两条指令之间被插入。这种异步插入模型,和硬中断抢占执行流是完全不同的两种机制,也是后面所有坑的源头。
1.3 和硬中断的差别:它并没有独立的"中断栈"
不少刚接触嵌入式或驱动开发的同学会把信号处理函数和硬件中断处理程序类比,但两者差别很大。硬件中断发生后,CPU 会跳到中断门,内核为了让当前执行流继续恢复,会专门准备中断栈;而信号处理函数运行时,CPU 完全处于用户态,它使用的是当前被中断线程的用户栈。
换句话说,信号处理函数并没有自己专属的栈空间(除非你显式使用sigaltstack+SA_ONSTACK)。它就像一个不速之客,直接搬进了主人家的客厅,靠的是"主程序栈上肯定还有空余空间"这个假设。这个细节我在后面讲栈溢出的坑时会再展开,这里先记住一点:信号处理函数不是被调度器调度出来的线程,它是插进你主调用栈里的一段临时上下文。
2. x86-64 上,进入处理函数前内核悄悄做了多少事
2.1 sigframe:把现场整个搬到用户栈
我们常说信号处理函数是"异步打断"主流程,那么这个"打断"具体体现在哪里呢?从进程的用户态视角看,你只看到了一个跳转。但从内核的视角看,在跳转之前它就动手布置了一整个现场帧(sigframe)。
以 x86-64 为例,内核在准备递送一个带用户处理函数的信号时,会调用setup_rt_frame:它会把当时保存在 pt_regs 里的用户寄存器上下文,连同信号编号、siginfo、旧的信号屏蔽字,一起写入当前用户栈顶部。这个被写进去的区域在 glibc/内核代码里被称为rt_sigframe,其中包含ucontext_t等结构。随后,内核重新计算栈指针,把 RIP 修改为 handler 的地址,再往栈里压入一个特殊的"返回地址"——restorer。
这里有个非常容易忽略的重点:在进入处理函数的那一刻,你当前指令位置、栈指针、寄存器现场并不是鲁棒地保存在内核里的。它们已经离开了内核查不到的、只属于用户态的寄存器,被固化到了用户栈的sigframe里。内核只保留了一个极薄的层层状态,剩下的全靠这个栈帧来续命。
2.2 入口与 restorer:谁收拾返回的残局
上一节说的返回地址(restorer)很多教材没讲透,但它恰恰是理解"信号处理函数如何结束"的钥匙。普通函数调用返回靠的是ret指令从栈里弹出一个调用者的地址。而信号处理函数结束的路径其实并不是直接回到主程序的"断点"——因为内核在改写寄存器时,并没有像普通函数调用那样在栈里保存原调用者的返回地址。
正确的机制是:内核在布置 sigframe 时,会把 handler 的"返回地址"设置成一个特殊函数,这个函数通常叫__kernel_rt_sigreturn(在 x86-64 上由 vdso 或由 glibc 通过 SA_RESTORER 指定)。当 handler 执行到ret时,CPU 跳转到这个 restorer,restorer 再发起一个rt_sigreturn系统调用,把控制权交还给内核,由内核根据 sigframe 里的内容恢复整个现场,再切回原指令继续跑。
如果你绕过 glibc,用裸syscall注册 handler 而不提供 restorer,大概率的结局是 handler 返回后跳进一个未知地址直接崩溃。这是很多自实现 runtime 的人踩过的大坑:依赖 glibc 时sigaction封装会默默帮你搞定 restorer,一旦脱离 glibc,你就得自己准备一段汇编。
2.3 屏蔽字与 SA_NODEFER:防止"自己打断自己"
当你注册处理函数时,如果看过sigaction结构体,一定注意到了sa_mask字段。它的作用是:在进入处理函数之前,内核会把这些信号追加到当前线程的阻塞集合里,处理函数返回时再恢复。更微妙的是,当前正在被处理的信号默认也会被自动阻塞——这就是为什么你在 handler 里再次触发同一个信号时,通常不会立刻形成无限递归。
如果你明确不希望如此,可以设置SA_NODEFER标志,这时同一个信号可以在 handler 里再次打断 handler。我很少建议别人这么做,除非你对自己的处理函数有绝对的可重入性把控。大多数稳定架构的做法恰恰相反:在 handler 里只做极小的事,自动屏蔽反而是一道安全保险,让处理动作不会被打断重入。
顺带一提:整个信号屏蔽字的状态也是保存在 sigframe 里的,也就是说进入处理函数前你手工设置的sigprocmask结果,以及进入后临时追加的自动阻塞,都被固化在那个栈帧里,等rt_sigreturn时统一恢复。理解这一点,对调试"为什么处理函数退出后,程序信号屏蔽状态和我预期不一样"很有帮助。
3. 返回路径:rt_sigreturn 如何把现场还原回去
3.1 一个特殊的系统调用,而不是普通 return
前面提到了 restorer 会调用rt_sigreturn,那这个系统调用到底做了什么?如果说 setup_rt_frame 是"把现场写进用户栈",那么rt_sigreturn就是一个反向操作:内核从当前用户栈上找到那个 sigframe,把里面保存的寄存器上下文、信号屏蔽字、栈指针全部取出来,恢复到当前线程的 pt_regs 里,然后返回到用户态。
这里有一个很重要的技术事实:rt_sigreturn不仅恢复普通的通用寄存器,还会恢复比如eflags、fs/gs基址等状态。由于恢复后线程的栈指针也跟着变了,rt_sigreturn系统调用的返回地址并不可信,所以内核在处理完恢复后,直接走那条"恢复现场然后返回用户态"的路径,指令流就落在原断点处继续执行。从用户程序的角度看,read()可能刚刚返回一个-1/EINTR,或者某条普通指令刚算完一次加法,一切毫无察觉。
值得强调的是,正因为返回路径依赖于栈里的 sigframe,所以 handler 内部如果破坏性地操作栈、或者主动改变栈指针,会导致恢复时找不到有效的 sigframe,结果往往是不可预期的崩溃或诡异的行为。凡是搞过协程/用户态切换库的人应该立刻意识到:如果你想做用户态上下文切换,绝对不能在一个信号处理函数活动期间的旧栈帧上做手脚,否则整个rt_sigreturn通路会彻底错乱。
3.2 现场恢复的粒度:寄存器、eflags、屏蔽字
有同事问过我这样一句话:"信号处理函数执行完了,程序是从断点回来还是从下一条指令回来?"答案很干脆:严格回到被中断的指令那里。因为信号的递送时机是"返回用户态之前",而内核保存的 RIP/RSP 就是"即将被执行的那条用户态指令"的位置。被中断的指令其实并没有执行,恢复后它会重新从那一行开始,像是根本没被打断过。
当然,"根本没被打断"只是用户态的假象,几个副作用还是存在:
- 某些系统调用可能返回
EINTR(后面细说),这是最显眼的证据。 - 如果 handler 修改了 errno,而主程序又没能恢复,会看到一个"凭空出现"的错误码,导致后续调用判断错乱。
- 如果 handler 修改了全局变量、关闭了 fd、改变了信号屏蔽字,这些都是长期副作用,并不会因为返回而被自动撤销。
3.3 对比普通函数调用:中断式调用为什么留下这些后遗症
把信号处理函数和普通函数调用放在一起看,后遗症就显而易见了。普通函数调用时,调用者和被调者之间有明确的约定:栈帧按规范管理,参数是主动传进去的,调用前你清楚自己调了谁、什么时候结束、会修改哪些状态。而中断式调用是"半路强行插入"的:主程序正在干任何事,数据结构的修改可能处于中间状态,某些库的内部锁可能正被抓在自己手里。
所以,"处理函数里能调用哪些函数"的唯一标准不是它看着简单不安全,而是它是否具备异步信号安全(async-signal-safe)。标准里给出的安全函数白名单很窄,基本都是对 fd 的直接write、read、_exit、kill、sigaction这类不依赖进程级共享状态的函数。诸如printf、malloc、pthread_mutex_lock、gettimeofday(某些平台上)都在危险名单里。这个约束我在第四节用一个真实死锁案例说明,那是我印象最深的一次排查。
4. 三个最容易踩的坑,以及一次完整的死锁排查
4.1 errno 被"偷改":看起来没错的代码只会欺骗你
先看一个我帮人查过多次的诡异 bug。某程序在主流程里做文件写入:
while ((w = write(fd, buf, count)) < 0 && errno == EINTR) { // 重试 }逻辑看起来没问题,但它偶尔会返回错误:“write failed: Interrupted system call",而且是发生在根本没有外部信号的时候。后来查来查去,问题在一个 SIGALRM handler 上。handler 内部做了类似库调用,顺手把 errno 写成了EINTR。主程序的 write 调用本身成功了,可下次判断时读到的 errno 并不是本次 write 设置的,而是 handler 里残留的错误码。
这里要很清楚地解释 errno 的本质:errno 本质上是线程局部存储中的一个 int,它不是一个"错误返回值",而是一个"上次某个库函数设置的错误状态"。信号处理函数在执行时,复用的就是当前线程的 errno。所以,一个异步信号安全的 handler 的第一规范是:进门先保存 errno,出门前恢复 errno,除非 handler 确实要主动传递错误信息。下面的模板至少保证了大部分场景下不污染主流程:
static void safe_handler(int sig) { int saved_errno = errno; // 只做异步安全操作 errno = saved_errno; }4.2 可重入性灾难:某后台服务死锁的完整排查链路
下面这个案例来自我给一个朋友的中间件项目做的远程排查,过程相当典型。现象:服务每运行一段时间,某个 worker 进程就彻底无响应,CPU 也不高,网络连接也不收不发。刚开始以为是偶发死循环,但长时间运行后必然复现。
排查步骤大致如下:
- 用
gdb -p <pid>attach 上去,thread apply all bt看所有线程栈。结果发现主业务线程停在一次普通的日志打印调用里,函数栈底是__lll_lock_wait。 - 再看另一条看起来不太起眼的栈帧,居然是 SIGTERM 的信号处理函数,它也在
__lll_lock_wait里等同一把锁。 - 于是锁的归属立刻明确了:主业务线程调用日志库写日志,先拿到锁,写到一半时进程收到 SIGTERM,内核异步插入 handler,而 handler 内部也尝试调用同样的日志接口或某个被同一把锁保护的重入不安全的函数,于是它试图获取同一把锁——但锁的持有者正是它自己"打断"的那个线程,永远不可能释放,死锁就这么形成了。
这里的细节值得多强调一句:很多人觉得"死锁只发生在多线程之间",但实际上同一个线程也能被自己打断并死锁。信号处理函数不是另一个线程,它是在同一个线程的栈上下文里运行的。如果它去抢一把当前线程已经持有的非递归锁,就必然自锁到底。要避免这个问题,只有两条路:要么 handler 里绝不碰任何加锁操作;要么用sigwait/signalfd把信号转给专用线程处理,从机制上绕开"异步插入执行"。
4.3 栈深度失控:处理函数里的递归如何压穿线程栈
第三个坑来自一次栈溢出崩溃。项目里有人写了一个很"聪明"的 handler:收到 SIGURG 时在 handler 内部解析数据,并间接调用了某个可能再次触达信号的逻辑。由于同一信号默认自动阻塞,本不该递归打断,但他们用了SA_NODEFER来追求"实时响应",结果某次峰值流量下信号密集到达,handler 在 handler 里嵌套 handler,一层层在同一个用户栈上叠加。
最终进程直接段错误。查 log 里留下的栈回溯时,能看到一长串重复的 handler 帧,每一层都叠加了几百字节的栈空间,直到把靠近栈顶的另一个映射踩碎。更隐蔽的是,业务线程的栈如果本身已经很深(比如递归解析日志或者深层调用链),那么常规的 handler 即使不递归,也可能把栈压穿。内核写 sigframe 时并不检查剩余栈空间够不够,它只是在当前 RSP 上往下写,写爆了就直接触发缺页异常。
对这种场景,标准解法是sigaltstack为处理函数准备一块独立的备用栈,再配合SA_ONSTACK标志注册 handler。这样即使主调用栈已经很深,handler 也跑在预设的备用栈上,不至于炸穿。备用栈通常分配 64KB 或更大,并根据 handler 实际需要的栈深去调优。
| 坑 | 典型症状 | 根因 | 规避手段 |
|---|---|---|---|
| errno 被污染 | 明明成功却报错 | handler 内改变了线程局部 errno | 进入/退出时保存恢复 errno |
| 自锁死锁 | 进程卡死 | 打断主流程后又抢同一把锁 | handler 不加锁;信号转线程处理 |
| 栈溢出 | 段错误 | 递归 handler 或将 sigframe 叠进深栈 | SA_ONSTACK + sigaltstack |
5. 和"软中断"和平共处的工程手段
5.1 安全处理函数只做三件事:标记、唤醒、异步安全调用
理解了前面的机制,一个清晰的结论就浮出来了:绝大多数场景下,在 handler 里做复杂业务逻辑是错的。我给出的硬性建议是:信号处理函数里只做三件事——置位全局标志、唤醒被阻塞的主循环(比如往管道里写一个字节)、调用_exit处理致命错误。
如果你需要在收到信号后做清理、落盘、发心跳、打日志,那就把真正的逻辑全部留在主流程中处理。收到信号后,handler 只负责“通知”,主循环在一个可控的、非异步的位置去执行善后逻辑。这样做还有额外的好处:主循环可以按自己的节拍处理多个信号,避免信号风暴把进程拖垮。
网上有个经典的说服性比喻:信号处理函数就像有人在你的代码中间突然插播广告,你不能在广告里开始整理房间。广告时间只够你记下"该整理房间了"。
5.2 self-pipe 真实现:一字节把异步信号变成 IO 事件
self-pipe 是最古老也最稳的一招,很适合没有 epoll 基础的老系统。原理一句话:初始化时创建一对pipe,把写端 fd 保存到全局变量;信号处理函数里只用write(fds[1], &c, 1)写入一个字节;主程序则用poll/select监视读端 fd,一旦可读,就去读取并且知道"某个信号发生了"。
static int g_pipe_write_fd = -1; static void handler(int sig) { char c = (char)sig; /* 也可以直接写成 "x" */ int saved_errno = errno; write(g_pipe_write_fd, &c, 1); /* 异步信号安全 */ errno = saved_errno; } int main(void) { int fds[2]; if (pipe(fds) != 0) return 1; g_pipe_write_fd = fds[1]; struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = handler; sigaction(SIGINT, &sa, NULL); sigaction(SIGTERM, &sa, NULL); struct pollfd pfd = { fds[0], POLLIN, 0 }; while (1) { if (poll(&pfd, 1, -1) < 0) { if (errno == EINTR) continue; break; } char c; if (read(fds[0], &c, 1) > 0) { /* 主循环里做真正的处理 */ printf("got signal %d\n", (int)c); fflush(stdout); } } }注意这里有几个细节:handler 里只用write,不用printf;异常情况下即使写端缓冲区满了也不阻塞(可配合O_NONBLOCK);读端最好也置为非阻塞,防止poll返回后read时信号再次打断导致读不到数据。
如果你担心不同信号信息丢失,可以把信号编号当作那个字节内容写入,一个字节足够表示常见信号了。多个信号并发时,管道缓冲区能自然排队,这也是我更喜欢 self-pipe 而不是只置一个标志位的原因——仅置标志位的话,SIGINT 和 SIGTERM 一起到时,你可能根本不知道哪个信号来过。自 Linux 2.6.27 起,evensocketpair/eventfd也可以承担类似的角色,eventfd 还能直接计数,语义更简单。
5.3 signalfd 与 epoll:把信号推进事件循环
现代 Linux 程序我更推荐直接用signalfd。它的思路不是注册 handler,而是先把目标信号用sigprocmask阻塞掉,然后打开一个 fd,当有信号到达时,这个 fd 变得可读。你只需要把 fd 丢进epoll或poll的事件循环,信号就被彻底地"事件化"了。
sigset_t mask; sigemptyset(&mask); sigaddset(&mask, SIGINT); sigaddset(&mask, SIGTERM); sigprocmask(SIG_BLOCK, &mask, NULL); /* 很重要,先阻塞 */ int sfd = signalfd(-1, &mask, SFD_NONBLOCK); /* 把 sfd 加入 epoll,监听 EPOLLIN */顺带一提使用 signalfd 时的一个前提:因为信号已经被阻塞,所以不会走传统的 handler 递送路径,也就没有异步插入的问题。信号处理逻辑从"异步打断"变成了"事件循环中的一次普通读取",这几乎是根治前面所有坑的最优雅方式。唯一的注意点是:阻塞信号之后,如果程序调用了read()这样的慢系统调用,通常情况下不会因为信号而返回 EINTR 了,所以某些依赖 EINTR 触发重试的代码逻辑也需要重新审视。
5.4 SA_RESTART 到底可不可靠:慢系统调用的边界
既然提到了 EINTR,最后把SA_RESTART说一说。很多人在注册 handler 时习惯无脑加上SA_RESTART,以为这样所有系统调用都会被自动恢复,不用管 EINTR 了。
实际上,Linux 对SA_RESTART的支持有清晰的边界:对于像read、write这类可能被信号中断的"慢"系统调用,如果数据尚未到达/缓冲区未准备就绪,SA_RESTART 会让内核在信号处理完成后自动重启该系统调用;但如果已经传输了一部分数据,read/write仍然返回已传输的字节数,不会重启。更麻烦的是poll、epoll_wait、nanosleep等在 Linux 上并不是所有都受 SA_RESTART 影响,不同内核版本表现还有过变化。
与其猜测某个调用是否会被 SA_RESTART 覆盖,不如把手动处理 EINTR 当作释放信号后的标配。我的习惯是:该设的 SA_RESTART 仍设上,同时任何涉及慢系统调用的代码都写"被 EINTR 就重试"的循环。两条腿走路,才不会在某个冷门系统调用上被咬一口。
处理这个问题没有银弹,但把信号处理函数从"业务执行者"降级为"事件通知者"、把逻辑量降到最低,几乎能规避九成以上的麻烦。我在实际项目里的最后一道底线,永远是那条只包含write、_exit或标志位赋值的最小 handler。