
很多学 Linux 的人会在“信号 handler 到底是谁调用的”这个问题上卡很久。我当年也卡过注册了一个 SIGSEGV 处理函数printf 能执行可断点一打发现返回地址不是内核栈上也不是普通函数调用的样子。后来把用户态与内核态的切换、异常中断返回路径、虚拟地址空间这几块拼在一起才算真的看明白。这篇是进程信号系列的第五篇专门讲“信号处理下”——也就是信号已经产生、已经挂到进程 pending 队列之后内核如何在某个异常、中断或系统调用返回路径上把它递交给用户态并且让 handler 看起来就像一次普通调用。这篇内容不挑特定发行版用的是主流 x86_64 Linux 内核视角代码路径名基于 5.x/6.x 主线。适合正在啃内核源码、准备面试、或者被崩溃栈和信号整到头秃的开发者。需要的前置知识不多知道信号有哪些、会写 sigaction 就够了。但看完之后你会对“用户态与内核态”这六个字有完全不同的体感。1. 信号 handler 不是被内核“调用”的它是被“放出去”执行的1.1 一个通俗模型内核搭台用户唱戏先纠正一个最常见的误解内核不会像调用 C 函数那样直接调用你注册的 handler。CPU 的特权级不允许内核随随便便跳进用户态代码内核态运行在 CPL0用户态运行在 CPL3两者之间隔着一整套页表权限和特权级检查。如果内核直接用 call 指令跳到用户地址先不说硬件是否允许光是栈切换、段寄存器、RFLAGS 这些状态就全乱了。真实情况更像“搭台唱戏”。内核负责在用户栈上搭好一个 signal frame也就是一个精心布置的数据结构里面保存了信号编号、siginfo、信号屏蔽字、通用寄存器、浮点寄存器等现场信息。然后把当前进程的寄存器上下文“偷改”把返回地址改成 handler 的入口地址把栈指针指向 signal frame 上方的位置。做完这些之后内核从异常或中断的返回路径上退回用户态CPU 的 iret 或 sysret 指令一执行用户态的下一条指令恰好就是 handler 的第一条指令。所以 handler 真正的“调用者”不是内核代码而是 CPU 的模式切换机制。内核只是通过修改 pt_regs 里的寄存器值给用户态安排了一个“舞台”。这个模型可以解释很多现象为什么 handler 里能访问局部变量为什么 handler 不能像普通函数那样直接返回为什么信号处理期间当前信号会被自动屏蔽。1.2 检查信号的那一瞬间返回用户态的必经之路信号不是内核随时想发就能发的。如果用户态进程正在执行一段普通指令没有任何异常、中断或系统调用发生CPU 一直待在用户态内核根本没有机会插手。所以信号的递送时机被设计在“进程从内核态返回用户态”的路径上。这条路径非常特殊。无论是系统调用返回、外部中断返回、异常返回还是进程被调度到之后第一次回到用户态内核手中都握着一份完整的寄存器快照也就是 pt_regs。这份快照保存在内核栈上记录了用户态当时的 RIP、RSP、CS、SS、通用寄存器等。内核如果想改变用户态执行流只需要修改这份快照里的几个值然后执行 iret 或 sysret用户态就会跑到新的位置去。这个检查点通常都会看进程的TIF_SIGPENDING标志。只要这个标志被置位退出到用户态的逻辑就会进入 do_signal 或等价函数开始走信号处理的下半场。系统调用返回是最常见的触发点因为程序只要一干正经事就会陷入内核定时器中断返回次之异常返回则是同步信号比如段错误的主要触发路径。2. 用户态与内核态同一份虚拟地址空间里的两个特权世界2.1 虚拟地址空间划界用户内存与内核内存怎么在同一张页表里共存要理解信号处理为什么非要这样折腾得先看清楚“用户态与内核态”在地址空间里是怎么划分的。每个进程都有一张页表但 Linux 并没有给内核单独建一张完全不相关的页表而是把用户空间和内核空间放在同一张页表里只是在页表项的权限位上做了区分。在 x86_64 的经典 4 级页表布局里用户地址空间是低地址的 128TB大约从0x0000000000000000到0x00007fffffffffff内核地址空间占据高地址的 128TB从0xffff800000000000往上。用户态进程访问高地址的内核映射区时因为页表项的 U/S 位没给用户权限会直接触发缺页异常最终表现为 SIGSEGV。程序员写空指针崩溃本质上是用户态访问了没有映射的低地址而不是访问了内核区。这块划分很多人知道但容易忽略一个关键点用户栈、堆、代码段、共享库全部落在用户空间范围内。signal frame 之所以能搭在用户栈上正是因为它要被执行在用户态的 handler 读取。内核栈上的数据用户态根本看不到所以内核不可能把信号现场塞进内核栈然后指望用户态去读。2.2 切换 CPU 视角特权级、栈、页表三件套从用户态进入内核态硬件会做三件套动作特权级从 CPL3 升到 CPL0栈指针切换到当前进程的内核栈然后跳转到 IDT 或 MSR 中预设的内核入口。用户态的寄存器现场会被压在内核栈上形成 pt_regs。从内核态返回用户态时再从 pt_regs 恢复寄存器特权级降回 CPL3栈指针切回用户栈。注意这里有一个细节同一进程的用户态和内核态切换通常不需要切换 CR3 页表基址因为页表里同时包含了用户映射和内核映射切换的只是特权级。但近年来因为 Meltdown 漏洞x86 内核普遍开启 PTIKernel Page Table Isolation。在 PTI 模式下用户态 CR3 指向的页表只包含用户空间映射内核映射被剥离用户态陷入内核时需要先切换 CR3 到完整的内核页表返回用户态时再切回来。这个变化对信号处理链路有实际影响每个系统调用、每次中断返回都可能多一次 CR3 切换。所以如果你在对比性能时发现某个大量系统调用的程序变慢不一定是业务代码的问题可能是用户态与内核态切换的代价变高了。信号处理因为频繁进出内核同样会分摊这部分开销。2.3 为什么信号处理非得先钻进内核再出来有人可能会问信号处理函数是用户态代码为什么不干脆由用户态自己调用非要绕一圈先进内核答案很简单用户态没有权限做完整的现场保存和上下文恢复。保存浮点寄存器、修改信号屏蔽字、把当前信号自动加入 blocked mask、处理同时到达的多个信号、给调试器留出 ptrace 截获点这些操作都需要内核特权。尤其是信号屏蔽字的修改涉及进程的blocked集合这个数据结构在内核里用户态碰不到。更关键的是被中断的系统调用需要处理-EINTR和SA_RESTART这个决策只能在内核态完成后再决定返回用户态时跳到哪里。所以信号处理的标准流程必然是先陷入内核在内核中完成决策和现场搭建再回到用户态执行 handler最后通过另外一个系统调用rt_sigreturn重新陷入内核恢复现场。用户态与内核态的两次切换构成了信号处理下半场的基本循环。3. 异常中断信号处理链路里的“发令枪”3.1 异常、中断、陷阱CPU 的三种“打断方式”标题里的“异常中断”四个字其实是把异常和中断放在一起说了。严格讲中断是外部设备或时钟异步触发的和当前执行的指令没有关系异常是 CPU 执行指令时同步产生的比如除零、缺页、非法指令。两者共同点是都会让 CPU 跳进内核的特定处理函数然后在返回用户态的路径上检查信号。异常内部又分 fault、trap、abort。缺页异常是 fault处理完成后 CPU 会重新执行那条触发异常的指令断点异常和系统调用相关的特殊指令属于 trap处理完后从下一条指令继续机器检查是 abort已经救不回来了只能终止。这个分类对信号处理很重要因为异常最终是否转成信号、转成哪个信号完全由内核异常处理程序决定。中断通常不直接映射成信号但它可以中断用户态进程让 CPU 陷入内核。例如定时器中断处理完后内核发现某个进程的定时器到期了就在该进程的 pending 位图里置一个 SIGALRM。这个信号不是硬件中断直接产生的而是软件在中断处理路径上“顺手”设置的。所以中断的作用更像“敲门砖”把 CPU 拉进内核给信号检查创造机会。3.2 常见异常到信号的映射表内核把大多数无法修复的异常直接转换成信号送给当前进程。我整理了一张常见映射表这个表在调试崩溃问题时会反复用到异常向量名称典型触发场景产生的信号0#DE 除法错整数除以零SIGFPE3#BP 断点int3 指令、调试断点SIGTRAP6#UD 非法操作码执行无效指令SIGILL13#GP 一般保护异常访问非规范地址、权限违规SIGSEGV14#PF 缺页异常空指针解引用、非法访问SIGSEGV 或 SIGBUS16#MF x87 浮点错误浮点运算异常SIGFPE17#AC 对齐检查非对齐访问SIGBUS缺页异常是最复杂的。页面不存在、权限不足、用户访问内核映射区都会触发 #PF。内核的 do_page_fault 会先判断这个缺页是不是合法的比如是否在用户地址范围、是否有对应的 VMA、是不是写保护等。如果是合法缺页就正常换入页面如果根本不该访问就调用 force_sig_fault 给进程发送 SIGSEGV并把异常地址填到 si_addr 里。这也是为什么我们在 handler 里拿到 si_addr可以直接定位到出错的字符串。3.3 系统调用返回是信号处理最常见的入口虽然任何返回用户态的路径都可能处理信号但实际工作中绝大多数信号是在系统调用返回时被处理的。原因很简单程序只要读文件、写日志、收发网络包、睡眠、创建线程都会陷入内核。syscall 入口保存现场内核完成工作后在 exit_to_user_mode 路径上发现TIF_SIGPENDING于是开始处理信号。异步信号如果在用户态普通指令执行期间到达CPU 不会立刻被打断必须等到下一次中断或系统调用才有机会处理。这就是信号递送存在延迟的根本原因。比如有一个死循环程序既不调用系统调用也不响应中断通过kill发送 SIGTERM 可能不会及时生效。碰上这种情况你可以在循环里加一个sched_yield()或者nanosleep()给内核一个介入的机会。4. 信号递送的完整舞台从 do_signal 到 signal frame4.1 ret_to_user 检查点do_signal 什么时候跑现代 x86 内核的统一退出路径是exit_to_user_mode_loop它会依次检查是否要重新调度、是否有未完成的信号、是否需要通知调试器。老内核里这个检查散落在ret_from_syscall和ret_from_intr路径上名字可能不同但逻辑一致发现_TIF_SIGPENDING后调用 do_signal。do_signal 是信号处理下半场的中枢。它先调用 get_signal 从 pending 队列里取一个待处理信号然后判断信号的处理方式。如果 handler 是 SIG_IGN 或 SIG_DFL就按默认动作处理比如终止进程、停止进程、继续进程。如果注册了用户 handler就进入 setup_rt_frame 流程准备搭建信号帧。这里有个容易忽略的点do_signal 不是“取一个信号处理完就结束”它是一个循环。内核会尽可能在这一次返回用户态之前把所有可以处理的信号都处理掉。但同一个信号如果在 handler 执行期间再次到来通常会被自动屏蔽所以标准信号不排队后续同类信号可能丢掉。实时信号有独立队列但同样受屏蔽影响。4.2 往用户栈上搭一个“信号帧”setup_rt_frame 做的最重要事情是在用户栈上构造一个rt_sigframe。这个结构里至少包含siginfo结构体就是 handler 第二个参数指向的东西、ucontext结构体第三个参数指向的东西、浮点寄存器状态、以及一个返回地址pretcode。分配位置很有讲究。内核会在当前用户栈指针上向下扩展同时按 16 字节对齐符合 x86_64 ABI 对栈对齐的要求。如果进程调用过 sigaltstack 配置了独立信号栈内核会改用备用栈。这么做的好处是即使主栈已经快要溢出handler 依然有地方搭 frame不至于处理 SIGSEGV 时再因为栈不足二次崩溃。浮点状态也需要保存。x86_64 上浮点寄存器通过 XSAVE 区域保存可能几百字节。如果在 handler 里打印浮点数或做计算这些状态必须完整保留否则 handler 返回后原程序的浮点环境就乱了。4.3 偷梁换柱的 pt_regs让 CPU 一回去就跳进 handler信号帧搭好之后内核开始修改当前进程在内核栈上的 pt_regs。这一步是“欺骗”硬件的关键把 pt_regs 里的 RIP 字段改成 handler 的函数地址把 RSP 字段改成 signal frame 的地址再按照 x86_64 调用约定填好参数寄存器。SA_SIGINFO 场景下handler 有三个参数int sig、siginfo_t *info、void *context分别对应 RDI、RSI、RDX。之后内核按正常路径执行 iret 或 sysret。CPU 恢复“修改后的现场”于是用户态的指令指针落在了 handler 入口用户态栈指针指向 signal frame 上方。对 handler 来说它根本不知道自己是“被伪造出来的函数调用”它只是看到一个正常的栈布局返回地址已经写在栈上参数已经就位它只管执行。调试器里经常看到的诡异现象——handler 的调用栈里没有真正的函数调用者只有一行信号帧地址——就是这个原因。它不是靠 call 指令调进来的而是靠 iret 跳进来的。所以在这种情况下使用 gdb 的 bt 只会看到 signal frame 相关的地址不会看到普通函数链。4.4 退场机制rt_sigreturn 系统调用如何还原一切handler 执行完最后一条 ret 指令会把栈上的返回地址弹给 CPU。这个返回地址是内核预先放好的__kernel_rt_sigreturn它在 vDSO 里是一小段汇编核心动作是发起rt_sigreturn系统调用。x86_64 上这个系统调用号是 15。内核收到 rt_sigreturn 后回到用户态栈上找到刚才那个 signal frame从里面恢复之前保存的寄存器、信号屏蔽字、浮点状态。然后再次返回用户态时CPU 的 RIP 就回到了进入信号处理之前的位置。对于被信号中断的代码来说它几乎感觉不到过程中发生了什么除了可能收到一个 EINTR或者因为 SA_RESTART 被重新执行。这就是为什么用 strace 跟踪程序时经常看到一对组合拳某个系统调用被打断进入 handler随后出现一行rt_sigreturn(...) ?紧接着原来的系统调用又重新发起或者返回 EINTR。rt_sigreturn 本质上不是普通意义上的系统调用它的作用是“把时间拨回信号处理之前”。5. 动手验证一个段错误程序背后到底发生了什么5.1 一个刻意“崩溃”的小程序理论说多了容易飘还是直接看现象。我写了一个故意解引用空指针的程序注册 SIGSEGV handler 并带上 SA_SIGINFO#define _GNU_SOURCE #include stdio.h #include signal.h #include ucontext.h #include stdlib.h static void handler(int sig, siginfo_t *si, void *ctx) { ucontext_t *uc (ucontext_t *)ctx; fprintf(stderr, caught signal %d, si_addr%p\n, sig, si-si_addr); fprintf(stderr, RIP0x%llx RSP0x%llx\n, (unsigned long long)uc-uc_mcontext.gregs[REG_RIP], (unsigned long long)uc-uc_mcontext.gregs[REG_RSP]); _exit(1); } int main(void) { struct sigaction sa {0}; sa.sa_sigaction handler; sa.sa_flags SA_SIGINFO; sigaction(SIGSEGV, sa, NULL); int *p NULL; *p 42; return 0; }编译运行gcc -g -o segv segv.c ./segv典型输出类似caught signal 11, si_addr(nil) RIP0x40118d RSP0x7ffc8b3a6d90注意si_addr是(nil)因为出错地址就是 0。RIP 指向的是*p 42那条赋值指令RSP 则是进入 handler 之前的用户栈指针。这两个值合起来基本就是一次段错误的全貌。5.2 在 gdb 里看 signal frame 的长相如果你想亲眼看到内核搭的舞台可以进 gdb。普通情况下 gdb 默认会拦截 SIGSEGV导致你的 handler 根本不执行所以先要把 SIGSEGV 设为交给程序处理(gdb) handle SIGSEGV nostop noprint pass然后在 handler 入口打断点运行(gdb) break handler (gdb) run断点命中后看一下现场(gdb) info registers rsp rdi rsi rdx (gdb) x/20gx $rsp你会看到栈顶附近保存着pretcode、ucontext、siginfo等数据。直接在 handler 断点上打印第三个参数也能看到完整上下文(gdb) p ((ucontext_t*)$rdx)-uc_mcontext.gregs[REG_RIP] $1 62977这个值就是原程序崩溃点的 RIP。即使 handler 已经在执行原现场也被完整保存在 signal frame 里等着 rt_sigreturn 恢复。5.3 strace 视角rt_sigreturn 和系统调用重启看完了 SIGSEGV再看一个更典型的异步信号场景。用 SIGALRM 打断阻塞的 read观察 rt_sigreturn 和系统调用重启#include signal.h #include unistd.h #include stdio.h static void handler(int sig) { write(1, got alarm\n, 10); } int main(void) { char buf[8]; struct sigaction sa {0}; sa.sa_handler handler; sa.sa_flags SA_RESTART; sigaction(SIGALRM, sa, NULL); alarm(1); read(0, buf, sizeof(buf)); return 0; }用 strace 跑不要加-e trace完整输出会显示alarm(1) 0 read(0, ... --- SIGALRM {si_signoSIGALRM, si_codeSI_KERNEL} --- write(1, got alarm\n, 10) 10 rt_sigreturn(0x7f...) 0 read(0, ...注意最后的 read因为设置了 SA_RESTARTrt_sigreturn 之后 read 被重新发起了而不是返回 EINTR。如果你去掉 SA_RESTARTstrace 里会显示read(...) ? ERESTARTNOHAND (To be restarted)最终用户态拿到的是-1 EINTR。这一来一回就是信号处理下半场最直观的外在表现。6. 这条链路上的经验与坑我实际调试中遇到的几件事6.1 信号帧也是内存栈不够用会引发二次崩溃很多人以为 handler 执行时会自动获得一个“安全”的栈其实并不会。signal frame 搭在用户栈上handler 的局部变量也消耗这同一个栈。如果进程栈已经非常接近上限内核在搭建 signal frame 时可能因为新页面无法分配而失败于是本来要处理 SIGSEGV 的 handler 反而触发新的 SIGSEGV。遇到这种情况先不要怀疑内核先看是不是没有配置 sigaltstack。处理崩溃类信号、或者 handler 里有大量分配时我建议进程启动后尽早调用 sigaltstack准备一块独立的备用信号栈。内核检测到备用栈存在时会把 signal frame 搭在备用栈上handler 用的也是备用栈主栈就算烂掉也影响不到信号处理过程。6.2 同类信号不会无限嵌套但不同信号会内核在递送某个信号时默认会把这个信号加进当前屏蔽字所以 handler 执行期间同类信号再次到来会被阻塞不会出现一个信号不停递归调用自己的情况。但如果你在 handler 里主动提权或者碰到不同信号嵌套就完全可能发生SIGUSR1 的 handler 执行到一半SIGUSR2 到来CPU 又进入内核搭建第二个 signal frame再执行 SIGUSR2 的 handler。多次嵌套意味着多次信号帧叠加在栈上。普通深度还好但如果信号风暴严重栈空间会很快被吃光。生产环境里我看到过因为周期性定时器信号和业务信号互相嵌套最终进程栈溢出崩溃的案例。从那以后我的 handler 里只做置标志、写一次日志、唤醒线程这些短平快操作绝不在 handler 里做大量本地变量初始化或者复杂计算。6.3 SA_RESTART 不是“自动重新调用”而是“假装没发生”不少人以为 SA_RESTART 是内核在系统调用被打断后自动把系统调用“再执行一遍”。字面上看差不多但内核实现得更取巧它在信号处理完成后把 pt_regs 里的 RIP 改回触发系统调用那条指令的地址然后重新返回用户态。用户态的 CPU 再次执行同一条 syscall 指令于是整个系统调用从零开始。这带来一个有意思的细节系统调用被打断时如果已经产生了一部分副作用比如 read 已经从设备读了一些数据那么重启后这些数据不会“回放”读取位置可能不对。所以 SA_RESTART 不是所有场景都安全。老手写代码时对可能部分执行的系统调用还是会手动处理 EINTR而不是完全依赖 SA_RESTART。man 7 signal 里列了哪些系统调用即使设置了 SA_RESTART 也不会重启比如某些 socket 操作这些差异在面试里也常考。6.4 调试器、ptrace 与信号的三角关系调试器本质上是一个 ptrace 跟踪者。信号在递送之前内核会给 tracer 一个截获机会tracer 可以选择“把信号吞掉”“把信号还给进程”“把信号替换成另一个”。这就是为什么 gdb 默认会拦 SIGSEGV导致进程里的 handler 没机会跑。实际调试时如果我发现 handler 里的代码始终不执行第一件事就是检查 gdb 的handle设置。如果想验证用户态 handler 的逻辑记得把对应信号设为nostop noprint pass如果想验证调试器的截获行为就保持默认。另一件常见的事是strace 也会影响信号递送因为 strace 本身就是一个 ptrace 跟踪者有些信号会被它截获并显示进程里 handler 的行为可能因此和裸跑时不一样。遇到“strace 下正常裸跑崩”的诡异情况先考虑这个因素。我这几年的习惯是碰到任何信号相关的奇怪问题先开 strace 看一遍系统调用和信号帧再在 gdb 里把 handler 断点打上最后带着si_addr和ucontext里的 RIP 回源码里找。这一套流程跑下来绝大多数问题都能定位到具体指令甚至能直接算出是栈问题还是指针问题。信号处理的下半场虽然绕但只要理解了“内核搭台、用户唱戏、sigreturn 收尾”这三点再回头看源码整个脉络就顺了。