用了好几年signal()的人,多半都会在某一次线上故障里被狠狠上一课:handler 里调了printf程序卡死、加了malloc直接段错误、被信号打断的read返回EINTR之后整个逻辑乱套。这些问题追到根上,全都指向同一个地方——信号捕获并不是“用户态代码里注册一个回调”这么简单,真正决定 handler 怎么被调起来、怎么跑完、怎么回去的,是内核态与用户态之间那几次隐蔽的进出场。这篇就沿着一条完整的时间线,把信号捕获这件事从寄存器级别拆开,讲讲内核到底在哪里、用什么手段把你的代码“塞”进执行流,又怎么在 handler 返回时把你“放回”原来的位置。适合已经会写基本信号处理程序、但想彻底搞懂底层机制的人。
先说一个很容易被忽略的事实:进程在用户态跑代码时,根本没有任何指令负责“检查有没有信号”。CPU 不会在执行mov或add的间隙去翻 pending 位图。信号的发现、递送、处理函数跳转,全部发生在线程因为某种原因陷入内核、再返回用户态的那一瞬间。这一来一回的缝隙,才是整个信号捕获机制真正的舞台。
1. 一次信号捕获的完整旅程:从进程“不知道”到进程“不得不执行”
1.1 信号产生的三种渠道与“挂起”状态
信号来源大体可以分成三类。第一类是硬件异常,比如访问了非法内存产生SIGSEGV、执行了非法指令产生SIGILL,这种信号不是某个线程主动“发”出来的,而是 CPU 在取指或访存时发现异常,强制陷入内核后由内核转写的。第二类是外部事件,终端组合键产生SIGINT,定时器到期产生SIGALRM,本质上是硬件中断触发了内核里的信号标记逻辑。第三类是主动调用,kill()、raise()、alarm()、abort()这些 API 最终也是通过系统调用进入内核,在目标进程的pending信号集合里把对应 bit 置位。
很多人会把“信号产生”和“信号处理”混为一谈,这是一个非常影响理解的概念坎。信号产生只是在内核里写了一个标记,进程此刻可能完全不知情。比如你kill(pid, SIGUSR1),内核只把SIGUSR1加进目标进程的信号 pending 集合,目标进程的指令流此刻该怎么跑还怎么跑,没有任何一条代码会“停下来看看”。信号真正开始生效,是在某个安全检查点被触发之后,这个检查点几乎只存在于“内核返回用户态”的路上。
那如果进程一直跑用户态纯计算循环,信号不就永远没机会被处理了吗?不会,因为时钟中断会周期性打断进程,每次中断都是一次陷入内核再返回用户态的完整过程,所以信号处理再慢也有个上限。这里还顺带解释了一个经验现象:为什么在高频计算循环里,信号处理函数的执行会有肉眼可见的延迟,不是你代码的问题,是信号本来就只能“搭车”执行。
1.2 为什么信号处理函数是“异步”的——正常执行流里没有检查点
这个“检查点只在内核返回路径上”的事实,直接决定了信号处理函数全部的异步特性。你无法预测SIGINT到达时,主流程正执行到哪条指令:可能在调用栈很深的地方,可能正准备修改一个链表,可能正持有某个自旋锁。handler 被“塞”进来执行的时机是完全随机的,这也正是 async-signal-safe 约束的根源——后面第 5 节会展开讲。
在内核源码里,这条返回路径一般对应着从系统调用返回、从中断/异常返回时的公共出口。在 x86-64 Linux 上,内核在准备回到用户态之前会调用类似do_signal()的逻辑:检查当前线程的 pending 信号集合,如果有未被屏蔽的信号,就进入信号递送流程。这一步对多线程程序还有一个容易忽略的分流:进程定向信号会被递给“某个不阻塞该信号的线程”,而线程定向信号只递给特定线程。也就是说,信号处理函数可能出现在任何一个线程的栈上,不是固定在主线程里。
所以整个信号捕获的流程可以概括成四步:信号在某处产生并进入 pending 集合;进程因为中断、异常或系统调用陷入内核;内核在返回路径上发现待递送信号;内核构造好现场、修改寄存器,把 CPU 执行流导向 handler。前两步很容易理解,真正让大多数人卡住的是第四步——“构造现场”到底构造了什么。
2. 递送现场:内核如何在用户栈上“伪造”一次函数调用
2.1 从 ret_to_user 到 setup_rt_frame
这里的关键问题很直白:handler 明明是运行在用户态的普通函数,它怎么从一个无人知晓的内核检查点“突然”出现在执行流里?答案很反直觉——内核是在用户栈上手工搭了一个“伪函数调用框架”,然后直接改写了被打断进程的寄存器。
具体来说,当内核在do_signal()里确认某个信号需要递送给用户态自定义 handler 时,会走setup_rt_frame这条路(现代内核默认都是rt系列,兼容旧式sigreturn的路径基本已经退出历史舞台)。它会做这几件事:先保存当前用户态现场,也就是把刚陷入内核时留在内核栈pt_regs里的那些寄存器值,完整复制到用户栈上新构造的一个结构体里。然后在这个结构体里填上siginfo_t,包括信号编号、发送者 PID、触发原因si_code等。再准备一个ucontext_t,里面是完整的寄存器快照和信号屏蔽字。最后在这个框架的某个位置放好“返回地址”,并把用户栈指针往下调整。
这块内存的布局大致长这样,写在用户栈的高地址方向往下延伸:
用户栈(高地址) ┌─────────────────────────────┐ │ 原调用栈里的数据 │ ├─────────────────────────────┤ │ siginfo_t │ <- 信号信息 │ ucontext_t │ <- 被打断时的完整寄存器现场 │ fpstate(浮点/AVX上下文,可选)│ │ restorer 入口地址 │ <- handler return 后跳这里 ├─────────────────────────────┤ │ 新 sp 指向的位置 └─────────────────────────────┘ 低地址这个框架不是给内核自己用的,是营造出“一个人刚调用了 handler 函数”的假象给用户态看。如果你是第一次接触这个机制,可以把setup_rt_frame理解成一个手术医生:它把患者(进程)本来要执行的指令地址藏进保险箱(ucontext),然后给患者换了个新地址,让他先跑完一段临时插进来的代码。
2.2 内核直接改写寄存器:handler 是被“安排”出来的跳转
搭好框架之后,内核做的事非常粗暴:直接改pt_regs里保存的用户态寄存器。它把指令指针ip改成 handler 函数的入口地址,把栈指针sp改成框架的指定位置,同时把必要的参数(比如sa_sigaction风格下需要传入的siginfo_t和ucontext_t指针)按调用约定摆好。然后内核返回用户态。CPU 按新ip取指令,进程“恢复执行”时第一个跑到的就是 handler。
这个过程最妙的一点是,对整个用户态进程来说,它根本意识不到自己刚被中断过。它只是觉得自己“执行了一个普通函数调用”:栈帧有了、返回地址有了、参数有了,一切看起来都很正常。你如果站在用户态代码的视角去读,完全找不到任何一条“跳进 handler”的指令,因为跳转是内核通过修改寄存器实现的,不是用户态代码主动调用的。
需要提醒的是,这里谈的“改写寄存器”不是说物理 CPU 寄存器被动了,而是指内核在保存于内核栈上的pt_regs结构里改了值。真正的物理寄存器要到返回用户态前的最后一步才被这批新值覆盖。理解了这一步,你就理解了为什么说“信号处理函数的执行与主流程共享同一个栈”:它没有自己的线程栈或内核栈,就是借用户栈搭了个临时棚子。
2.3 执行完怎么回去:restorer 与 rt_sigreturn 的桥
handler 执行完之后,怎么“回到”被中断的主流程?如果内核只是改了ip跳了进来,那 handler 执行完的ret指令会从栈上弹出哪个地址?答案就是这个框架里预留的 restorer 入口。
很多教学代码里 handler 一旦执行完,程序仿佛“自动”就回到了原来的逻辑,这其实是内核在背后铺好了路。x86-64 Linux 上,restorer 通常来自 VDSO,也就是内核映射到每个进程用户空间的一段轻量代码,入口处执行的无非是syscall指令。handler 正常执行到ret,返回地址弹出的是 restorer 的入口,于是 CPU 又执行到一个系统调用:rt_sigreturn。
这个rt_sigreturn是信号机制里最容易被忽略、却最关键的桥。它意味着 handler 结束并不是“回到主流程”,而是“再次陷入内核”,让内核来处理恢复工作。你在strace里经常能看到进程输出里出现rt_sigreturn,很多人不知道这一行背后的含义——它正是 handler 跑完、被内核接管的痕迹。这个系统调用执行完,CPU 再次回到用户态,但这次不再是执行 handler,而是恢复执行最初被打断的位置。这就是第 3 节要展开的内容。
3. 现场恢复:rt_sigreturn 如何把程序“原地复活”
3.1 第二次内核态-用户态切换:恢复现场再出发
rt_sigreturn这个系统调用的全部工作,就是从用户栈上那个rt_sigframe里把之前保存的ucontext_t读回来,用里面的寄存器值重新覆盖内核栈上的pt_regs,再把信号屏蔽字恢复成 handler 执行之前的状态。然后内核走常规返回路径回到用户态,用户态的指令流从被打断的那条指令继续执行,就像这段时间只是做了一场梦。
这里有一个容易混淆的地方:既然现场都恢复了,为什么进程不直接“原地继续”,还要绕这么大一圈?因为 CPU 在用户态无法直接访问内核保存的现场,也无法自己恢复某个“被中断了的栈帧”。这套机制的本质是:内核负责保存现场,用户态 handler 负责临时执行,rt_sigreturn负责把现场还原,整个恢复动作必须发生在内核态,因为你不能再信任用户态栈上的任何状态——handler 执行期间它可能已被改得面目全非。
这个过程也和“函数调用返回”做了一个完美的类比:普通函数返回靠ret从栈上取返回地址,信号处理函数返回靠rt_sigreturn从 sigframe 里取“全现场”,只不过这个现场不光包括返回地址,还包括所有通用寄存器、标志寄存器、栈指针、指令指针,有时还有浮点寄存器状态。恢复的粒度比普通函数调用返回要完整得多。
3.2 SA_RESTART 的幕后操作:悄悄回退程序计数器
如果你看过sigaction的sa_flags,一定见过SA_RESTART,文档上写着“重启被信号中断的系统调用”。听起来很普通,但它的底层实现非常精致。要理解它,先得知道为什么没有它时,read()会返回EINTR。
当一个慢速系统调用,比如阻塞在终端输入上的read(),被信号打断时,这套流程里会多出一个小分支:内核发现目标系统调用“还没完成”,它可以选择直接让这个系统调用返回失败属标记,也可以选择让它从头再来。默认行为是前者,于是用户态拿到的返回值对应的errno就是EINTR。这个语义本身是合理的,因为系统调用被中断时,谁也不知道它内部已经推进到什么状态了,直接粗暴重跑可能产生副作用,比如read可能已经读掉了一部分数据。
设置SA_RESTART之后,内核在递送信号时会把“重启”意愿记录下来,等rt_sigreturn恢复现场时,把指令指针回拨到系统调用指令的位置,而不是系统调用之后的下一条指令。于是用户态进程看起来就像这个系统调用从来没被中断过一样,重新发起了一遍。注意,这个“重新发起”是完整重跑,不是接着上次进度继续,数据已经读进缓冲区的那部分,由程序自行负责处理。这也是为什么很多重视精度的代码,即使设置了SA_RESTART,仍然会在循环里手动判断EINTR并重新计算偏移。
这里补一个实际经验:SA_RESTART不是万能的。终端驱动的某些状态变化、connect()等特定系统调用、以及被SA_RESETHAND之类标志影响的路径,都可能让“重启”语义不完整。所以别把SA_RESTART当成“加了就永远不出EINTR”的保险丝,它只是覆盖了绝大多数常规阻塞场景。
3.3 siginfo 与 ucontext:内核递给 handler 的“现场快照”
启用SA_SIGINFO后,handler 的签名变成void handler(int sig, siginfo_t *info, void *ucontext),这时你拿到的info和ucontext都不是随手填的,它们就是刚才那个rt_sigframe里的两块数据。siginfo_t里的si_code能精确告诉你是谁触发了信号:是内核发的SI_KERNEL、是kill()系统调用来的SI_USER,还是定时器内核模块产生的SI_TIMER,排查线上问题时非常有价值。ucontext_t里的uc_mcontext则保存了被打断瞬间每个寄存器的值,包括指令指针。
这个现场快照还有一个很实际的工程用途:崩溃信号处理。比如捕获SIGSEGV时,信息里会带上触发异常的地址,ucontext 里带上了异常瞬间的指令指针和栈指针,崩溃信息配合backtrace往往能快速定位到崩溃行。不过要记住循例:handler 本身就是个“环境极其恶劣”的地方,你想在里面安全地打印堆栈,只能依赖write、open、sigaction这一小撮异步信号安全函数,复杂的回溯逻辑建议在主流程或独立故障线程里做。
4. 用三个可复现实验把理论“钉死”
4.1 实验一:在 strace 里找到 rt_sigreturn
第一个实验最简单,却不无聊。写一个基本捕获程序,把SIGINT交给自定义 handler,然后循环输出,跑起来后从另一个终端向进程发SIGINT,同时用strace -f -o trace.log ./sig_demo记录系统调用。你会看到类似这样的序列:主循环的write()、nanosleep(),然后突然一个--- SIGINT ---,紧接着 handler 里的write(),最后是rt_sigreturn()。
这个序列就是整个机制的时间线压缩版。--- SIGINT ---是内核在返回路径上发现 pending 信号;handler 里的write()证明 CPU 确实跳到用户态 handler 执行;rt_sigreturn()证明 handler 执行完以后确实又回到了内核。跑完这一次,你对“信号处理函数和主流程共享用户栈、依赖于内核返回路径”的理解会从抽象变成直觉。
#include <csignal> #include <cstring> #include <unistd.h> extern "C" void on_sigint(int) { const char msg[] = "handler running\n"; write(STDOUT_FILENO, msg, sizeof(msg) - 1); } int main() { struct sigaction sa {}; memset(&sa, 0, sizeof(sa)); sa.sa_handler = on_sigint; sigemptyset(&sa.sa_mask); sigaction(SIGINT, &sa, nullptr); for (int i = 0; i < 20; ++i) { const char msg[] = "main loop\n"; write(STDOUT_FILENO, msg, sizeof(msg) - 1); sleep(1); } return 0; }编译时建议开启-Wall -O2,handler 里故意不用printf,而是用write,因为异步信号安全是底线,实验里就该养成好习惯。
4.2 实验二:EINTR 与 SA_RESTART 的对照
这个实验会直观展示SA_RESTART到底改变了什么。程序注册SIGALRM,两秒后闹钟触发,主流程阻塞在read(STDIN_FILENO, ...)上等待输入。第一版不设置SA_RESTART,跑起来后什么都不输入,两秒后你会在终端看到errno=4 Interrupted system call;第二版把sa_flags |= SA_RESTART加上,同样的操作,read()不会返回,而是继续等待输入直到你真正敲入回车。
#include <csignal> #include <cstring> #include <iostream> #include <cerrno> #include <unistd.h> extern "C" void on_sigalrm(int) { const char msg[] = "handler running\n"; write(STDOUT_FILENO, msg, sizeof(msg) - 1); } int main() { struct sigaction sa {}; memset(&sa, 0, sizeof(sa)); sa.sa_handler = on_sigalrm; sigemptyset(&sa.sa_mask); // 第二版实验时:sa.sa_flags |= SA_RESTART; sigaction(SIGALRM, &sa, nullptr); alarm(2); char buf[16]; ssize_t n = read(STDIN_FILENO, buf, sizeof(buf)); if (n < 0) { std::cout << "read failed, errno=" << errno << ", " << strerror(errno) << std::endl; } return 0; }对照结果很清楚:没有SA_RESTART,read()会带着EINTR失败返回;有了SA_RESTART,内核在恢复现场时把指令指针回拨到系统调用入口,整个read()被静默重启。这个改动让很多不熟悉底层的人误以为“信号处理函数执行完,系统调用就继续了”,其实它压根是重新发起的,对已经部分读取的数据要格外小心。
4.3 实验三:handler 里读取被打断现场的 RIP
最后一个实验验证一个看起来很玄的东西:handler 真的能拿到“被打断瞬间的完整寄存器”。启用SA_SIGINFO,handler 的第三参数指向ucontext_t*,里面uc_mcontext.gregs[REG_RIP]就是被打断时用户态的指令指针。你在 handler 里把它打出来,再和主程序某个已知函数的地址比对,会发现两者恰好落在同一片代码区域,证明内核保存的现场是真实有效的。
#define _GNU_SOURCE #include <ucontext.h> #include <csignal> #include <cstdio> #include <unistd.h> extern "C" void on_sig(int sig, siginfo_t* info, void* ucontext_v) { auto* uc = static_cast<ucontext_t*>(ucontext_v); auto rip = uc->uc_mcontext.gregs[REG_RIP]; dprintf(STDOUT_FILENO, "signal %d, interrupted rip=0x%lx\n", sig, rip); } void spin_loop() { volatile int x = 0; for (int i = 0; i < 1000000000; ++i) { x += i; } } int main() { struct sigaction sa {}; memset(&sa, 0, sizeof(sa)); sa.sa_sigaction = on_sig; sa.sa_flags = SA_SIGINFO; sigemptyset(&sa.sa_mask); sigaction(SIGUSR1, &sa, nullptr); printf("spin_loop address=%p\n", reinterpret_cast<void*>(&spin_loop)); spin_loop(); return 0; }实验里dprintf是演示用途,真实项目里要换成纯write方案。跑起来后向进程发SIGUSR1,看到的 rip 地址大概率就在spin_loop函数内,你可以用addr2line把它换算成源码行,那一刻你会非常直观地感受到:内核把现场交到你手里的方式,不是讲故事,是实打实地把所有寄存器原样保存了一份。
5. C++ 工程里的信号代码:绕开这些坑才算真正会用
5.1 handler 里为什么不能碰 printf、malloc 和锁
很多人第一次写信号处理函数,都是按普通函数的思路来:打日志用printf,分配内存用malloc,保护共享数据加锁。这些写法在“看起来正常”的小程序里可能侥幸通过,但都埋在雷区里。根因正是第 1 节那个核心结论:handler 是异步插入到主流程任意位置的。
主流程可能正好执行到printf内部,已经持有了标准输出流的内部锁,handler 里再调printf,同一线程自己尝试再拿同一把锁,面对非递归锁就是死锁。这还只是最浅的一层。更隐蔽的是malloc:堆分配器内部维护着链表、bin、空闲块元数据,主流程如果正好处于“已经改了一部分堆结构、还没更新完指针”的半更新状态,handler 里调用malloc就可能复用一个已损坏的元数据,表现为奇奇怪怪的段错误、栈损坏。锁也一样,主流程持有锁时被信号打断,handler 里尝试拿同一把锁,直接自己锁死自己。
所以 C 和 C++ 标准对信号处理函数的限制,不是“建议”,是未定义行为级别的红线。C++ 标准明确说:handler 里访问的普通对象如果不是volatile sig_atomic_t或 lock-freestd::atomic,行为未定义。理解到这里,你才会明白为什么那些工程经验听起来那么“保守”——不是大家不信任新特性,是这个异步插入的执行模型决定了它确实承受不了复杂性。
5.2 extern "C"、对象构造与异步信号安全函数清单
C++ 里注册 handler 时,处理函数必须用extern "C"声明,否则函数签名经过名称修饰后,sigaction拿到的地址类型会不匹配。这不算坑,只是绕不过去的语法事实。但更隐蔽的是:不要在 handler 里创建任何有非平凡构造或析构的对象。栈上的std::string需要构造函数分配内部缓冲区,std::vector同理,这些操作在异步插入的环境里都不是安全操作。标准库容器内部锁、内存管理、异常机制都是“重武器”,全都不适合在信号处理函数的微型环境里展开。
能用的范围其实很小。POSIX 定义了一组 async-signal-safe 函数,常见的有:write、read、open、close、sync、_exit、kill、sigaction、sigemptyset、sigaddset等系统调用层面的接口,以及部分访问errno的只读操作。printf、malloc、free、pthread_*、std::mutex相关的一切都不在里面。常规做法就是:handler 里只做两件事——把信号编号写进一个volatile sig_atomic_t标志位,或者用write直接落一条简短日志,其余所有复杂处理都放在主循环或专门线程里轮询标志位。
如果你需要siginfo_t里的详细信息,又不想在 handler 里做复杂操作,正确姿势是用sigaction的SA_SIGINFO把siginfo_t拷贝到预分配的volatile结构里(前提是这个结构体本身的写入是原子的、可重入的),或者用第 5.3 节说的signalfd方案,把信号处理彻底变成普通 I/O 逻辑。
5.3 事件驱动时代更常用的替代:signalfd + epoll 统一事件源
如果你的项目已经用epoll做事件循环,那信号捕获还有一个更现代的姿势:signalfd。它把信号变成了一个文件描述符,你在signalfd()里传入需要捕获的信号集合,之后read()这个 fd 会返回signalfd_siginfo结构。信号到达时,这个 fd 就变成可读事件,与网络事件、定时器事件一起交给epoll_wait()分发。
这种做法有个巨大的优势:handler 的 async-signal-safe 限制彻底消失了。因为信号不再打断任意指令流,而是作为普通 I/O 事件在事件循环里被处理,你可以放心地用std::string、std::vector、日志库、甚至业务逻辑直接处理信号。代价是 Linux 专有,可移植性不如sigaction,而且不能捕获SIGKILL和SIGSTOP。
| 方案 | 可移植性 | 信号信息 | 是否受 async-signal-safe 限制 | 适用场景 |
|---|---|---|---|---|
signal() | 不同系统语义有差异 | 只有信号编号 | 是 | 简单教学、快速原型 |
sigaction() | POSIX | 完整siginfo_t、ucontext_t | 是 | 通用生产环境单线程/小规模处理 |
signalfd + epoll | Linux 专有 | 相对完整的信号信息 | 否 | 事件驱动服务端、需要统一事件源 |
我个人的习惯是:能走事件循环的项目优先signalfd + epoll,信号处理甚至可以在单独工作线程里做;只有那些没法改造架构的老进程,或者确实需要在信号打断瞬间抓现场的场景,才回到sigaction方案。两种路线都值得掌握,因为它们背后的机制是相通的。
6. 从信号机制里悟到的一点系统观
回头再看整个信号捕获流程:产生、pending、陷入内核、返回路径检查、搭建 sigframe、改写寄存器、执行 handler、restorer 跳回内核、rt_sigreturn恢复现场、再次返回用户态继续执行。每个环节都不复杂,但串起来之后,你会发现“用户态代码”和“内核态代码”之间的边界原来是这么有弹性:内核可以把一个函数调用硬生生插进用户态的执行流,也能在事后把现场一滴不差地还回来。
这对我写 C++ 服务端代码影响很大。遇到奇怪问题时,我会先问自己一句:这里是不是信号在“看不见的地方”移动了寄存器?很多看起来像“偶发崩溃”“多线程竞争”的疑难杂症,最后追下来都是信号处理函数在错误的位置插进来,破坏了主流程的某种中间状态。
如果你正在啃这块硬骨头,最后分享一个小技巧:调试阶段,尽量把 handler 里的日志用write而不是printf打,前者更接近系统调用层,行为可预测;同时养成在strace里观察rt_sigreturn的习惯。这两件事做多了,你对内核态与用户态切换的体感,会比读十篇原理文章都好使。