Linux内核do_signal深入解析:信号递送、sigreturn与寄存器魔法

发布时间:2026/10/11 14:43:17

Linux内核do_signal深入解析:信号递送、sigreturn与寄存器魔法 内核开发里有个很有意思的函数叫do_signal。很多人第一次听到它是在看系统调用返回路径的代码时被exit_to_user_mode_loop()里那个显眼的调用点吸引住然后就一头雾水为什么一个“发信号”的函数要在每次系统调用结束、准备回到用户态的时候出现而且它还不叫send_signal也不叫sys_kill偏偏叫do_signal这里面到底藏着什么门道。这篇文章就围绕do_signal这个函数来展开。我会从它在整个信号处理链路中的位置讲起拆解内核到底是怎么把一个信号“递送”给用户态进程的重点说清楚寄存器如何被改写、sigreturn为什么必不可少、以及那个著名的signal frame长什么样。无论你是正在给某公司做内核驱动开发还是纯粹对 Linux 内核机制感兴趣只要你想搞懂“信号从诞生到 handler 执行”的完整路径这篇内容都不会浪费你时间。我尽量用做过实际项目的人说话的方式来讲不会给你整一堆浮在表面的概念而是把每一步都落到位。1. 从内核态回到用户态的那一步do_signal 到底在哪里出现很多人直觉上以为“发信号”就是把信号塞进队列然后进程就会自动执行 handler。实际上没那么简单。信号的产生和信号的递送delivery是两码事。do_signal就是“递送”这一环节最核心的执行者而它执行的时机几乎永远是“内核准备回到用户态之前”。1.1 do_signal 的调用场景系统调用退出路径、异常/中断返回路径我先给出最常见的调用现场。以 x86-64 架构为例当你的程序执行read()这类系统调用时CPU 会通过syscall指令陷入内核。内核完成实际的工作后并不会马上sysret弹出用户上下文而是先走到exit_to_user_mode_loop()在arch/x86/entry/common.c里。这个循环里有一段逻辑就是检查当前进程的thread_info-flags中是否设置了_TIF_SIGPENDING如果设置了就调用do_signal()。同理当 CPU 响应一个外部中断或者用户程序触发缺页异常、非法指令异常而进入内核处理完毕最终返回用户态时也会经历同样的检查。也就是说do_signal的调用点不是某一处而是一个统一的“返程检查站”。内核把所有能回到用户态的路口都设置了关卡不管你是系统调用返回、中断返回、还是异常返回只要你敢往回走就必须先过信号这一关。为什么非要在“回到用户态之前”处理因为信号 handler 是用户态的代码内核没法在内核态直接执行它。要想执行用户态的函数就必须把 CPU 的控制权交还给用户态并且让它的执行流跳到 handler 的入口地址。这个“交还控制权”的动作本质上就是一次架构相关的上下文切换而do_signal干的就是在真正切换之前先把你想要的寄存器状态全部安排好。1.2 为什么你不能只喊一个“信号处理函数”内核-用户态切换的本质你可以把进程想象成一个正在舞台上表演的演员。内核是后台工作人员用户态是前台舞台。演员现在正演到一半比如执行read()系统调用相当于走到后台问工作人员要点道具信号就是后台有人递过来一张字条“观众席有情况请你处理一下。”但字条上的内容信号 handler是另一段表演得演员走到舞台的另一个位置去演。演员不能站在后台就把戏演了他必须先回到前台同时被指引到新位置。于是问题来了演员CPU回到前台时他默认会从原来中断的地方继续演也就是回到read()系统调用之后的下一行代码。但现在不行我们要让他先跳到 handler 的地址去执行。怎么办最直接的办法就是在回到前台之前偷偷把“演出剧本指针”也就是rip寄存器以及栈指针rsp、参数寄存器改成 handler 对应的数值。这就是do_signal的日常工作。等你 handler 演完还得想办法回到原来中断的地方继续演这就引出了sigreturn机制后面会细说。2. 信号递送的完整流水线从 signal_pending 到 do_signal 再到用户态 handler要真正理解do_signal你得把它放在整个信号生命周期里看。信号从产生到 handler 执行至少经历三个阶段产生generation、挂起pending、递送delivery。我们平时写的kill()只是完成了“产生”后续还有很长一段路要走。2.1 信号产生、挂起与递送的三步走当一个进程调用kill()或pthread_kill()内核会通过__send_signal()函数把信号放入目标进程的未决信号队列里。这个队列存储在task_struct中具体来说有两层task_struct-pending进程级未决信号集合使用struct sigpending描述。task_struct-signal-shared_pending线程组共享的未决信号集合同一进程的所有线程都能看到。凡是发给“进程”的信号通常挂在shared_pending发给特定线程的信号则挂在pending。信号进了队列只表示“有这个事要处理”但还没处理。这个状态称为“信号挂起”。接着内核会在合适的时机把_TIF_SIGPENDING标志设置到当前线程的thread_info标志位里。这个标志就是之前提到的大检查站的“开关”。递送阶段才是do_signal登场的时候。它会从挂起队列里摘取一个信号判断是否被阻塞、是否已经忽略然后根据信号类型决定是执行默认动作还是用户自定义 handler。如果是用户自定义 handler就启动那一套寄存器魔法。2.2 do_signal 核心流程分解取信号、清挂起、改寄存器、插入 handler 地址下面我用一种接近源码的视角把do_signal在主流程上做的事拆开。内核源码版本不同细节略有出入但核心逻辑非常稳定检查thread_info-flags里的_TIF_SIGPENDING若没有则直接返回0。调用get_signal()从当前线程的信号队列中取出一个待处理信号同时返回该信号的ksignal信息包括信号编号、是否需要用户态处理ka结构即该信号对应的 action。如果取到的信号是默认行为比如默认终止进程或暂停进程并且没有用户自定义 handler就直接执行默认动作可能根本不会回到用户态比如 SIGKILL 直接让进程结束。如果信号被用户自定义 handler 接管就需要进入setup_rt_frame()新的 rt 信号版本或setup_frame()旧版。这一步会在用户栈上构造一个信号帧signal frame保存当前寄存器状态、信号编号、信号掩码等关键信息。然后修改regs-ipx86-64 上对应 RIP为 handler 地址修改regs-spRSP为新的栈指针同时设置某些寄存器为信号参数。最后返回让 CPU 得以回到用户态。回到用户态后指令指针就是 handler 的入口于是 handler 开始执行。注意大多数时候do_signal()只处理一个信号即使有多余的未决信号内核也会在后续返回用户态时再次触发检查。所以从效果上看信号是一个接一个被递送的而不是一次全部塞给用户态。2.3 关键数据结构task_struct 里的 pending 与 sigpendingstruct sigpending在include/linux/signal_types.h中定义核心字段有两个struct sigpending { struct list_head list; // 信号队列链表头保存待处理信号的 sigqueue 节点 sigset_t signal; // 未决信号位图每一位代表一个信号编号是否有未决实例 };sigset_t实际上是一个足够长的位图Linux 用 64 位或更多来覆盖所有标准信号与实时信号。当一个信号进入队列时除了把节点挂到list上还要在signal位图中把对应的位置 1。do_signal在取信号时通过dequeue_signal()来把节点从队列摘除并把位图清零。这里有个容易被忽视的点如果同一个普通信号非实时信号已经被挂起多次在队列里它只有一个节点不会每个kill()都产生一个新节点。这是普通信号和实时信号1~64 的范围在 Linux 中用于实时信号编号从 32 起的重要区别。实时信号是排队信号能区分多次触发普通信号是合并的再多次触发也只算一次。这些数据结构的设计直接影响do_signal的行为它取信号时只要对应位图位是 1就能取出一个信号取完清除位图可能队列里还有别的信号但普通信号的位图位已经清零就不会再重复取同一个信号了。3. 内核如何“假装”调用了你的 handler寄存器魔法与 sigreturn 机制我们平时写signal(SIGINT, handler)之后根本没想过 handler 是怎么被“插入”到正常执行流里的。从用户态看好像内核直接帮你调用了 handler调完之后handler 返回程序还能继续从原来中断的地方往下走。这个“继续原来执行流”的能力全靠sigreturn系统调用和信号帧撑起来。3.1 修改 pt_regs 的 rip/cs 和 rsp让用户态跳到 handler在do_signal的路径里struct pt_regs保存着用户态被打断时的完整寄存器快照。这个快照在系统调用入口处由硬件和软件联合保存。x86-64 上pt_regs里有rip、cs、rflags、rsp、ss这些段寄存器以及通用寄存器rax、rbx、rcx、rdx、rsi、rdi等。当do_signal确认要递送一个用户 handler 时它会修改这个快照中的关键字段regs-rip (unsigned long) ka-sa_handler; regs-rsp (unsigned long) frame; // frame 是用户栈上新构造的信号帧首地址同时为了让 handler 看起来像被正常调用还需要按照 ABI 设置参数寄存器。x86-64 的函数调用约定里第一个参数放在rdi第二个参数放在rsi第三个在rdx。标准信号 handler 的原型是void handler(int sig, siginfo_t *info, void *ucontext);如果使用的是sigaction并开启了SA_SIGINFO内核会把信号编号放入rdi把siginfo_t指针放入rsi把ucontext_t指针放入rdx。这样当用户态返回到 handler 时handler 序言里从rdi取到的就是信号编号不需要额外特殊处理完全符合普通函数调用的规则。但注意这是有违“普通函数调用”的。普通函数调用时返回地址会被压栈调用者返回后继续执行。而这里 handler 是被“强行设置为进程的当前执行函数”没有调用者也没有返回地址。所以内核必须在栈上布置一个伪造的现场让 handler 执行完ret汇编指令时弹出的返回地址不是别的东西而是一个特殊的“魔法地址”——其实这还会走sys_rt_sigreturn路径下面细说。3.2 handler 返回后的必经之路sigreturn 系统调用在 x86-64 上当你用sigaction()给信号挂接 handler 时大多数 libc 实现如 glibc会在信号帧中设置一个所谓的restorer函数也就是rt_sigreturn系统调用的入口。当内核构造信号帧时会在栈上写入一个返回地址这个地址通常指向一个小的粘合代码trampoline。这个粘合代码的唯一任务就是调用rt_sigreturn系统调用。如果不依赖 libc由内核本身处理旧式信号里内核直接在信号帧中硬编码了__kernel_rt_sigreturn那流程也一样。handler 执行完毕后执行ret弹出栈顶的返回地址这个地址就是 restorer。restorer 触发rt_sigreturn系统调用进入内核的sys_rt_sigreturn。sys_rt_sigreturn的主要工作是什么它从用户栈上的信号帧里恢复所有寄存器值包括之前被打断时的rip、rsp、flags、以及所有通用寄存器。然后调用restore_sigcontext()把ucontext里面的uc_mcontext数据填回pt_regs再恢复信号掩码通过restore_altstack等逻辑。最后内核携带恢复后的pt_regs返回用户态。由于pt_regs已经被恢复成了“中断前的样子”所以用户态程序会继续执行被打断前的下一行代码就像什么都没发生过。这整个流程设计得非常巧妙它不需要内核主动去保存用户态执行现场而是让用户态在进入 handler 之前把现场压栈再由 sigreturn 系统调用从栈上弹回现场。内核只负责在do_signal阶段把栈上的信号帧布置好在sigreturn阶段把信息恢复好。3.3 rt_sigreturn 与 signal frame 的构造基于 x86-64 的实践我把信号帧的细节展开写一下。内核在setup_rt_frame()中会在用户栈上分配一块内存布局大致如下首先根据当前rsp向下偏移留出足够的空间确保 16 字节对齐满足 ABI。然后填入一个struct rt_sigframe它里面有struct ucontext包含uc_flags、uc_link、uc_stack、uc_mcontext、uc_sigmask。fpu状态用于保存浮点寄存器如果进程使用了 FPU。pretcode即返回到 restorer 的地址。各种对齐填充。特别提醒保存浮点寄存器是一件比较重量级的事情。如果每个信号递送都保存/恢复完整的 FPU 状态性能会非常差。所以内核在信号帧中保存浮点状态时通常依赖XSAVE指令族且只有在当前regs-flags或线程上下文里确实需要保存 FPU 时才会做。不过对于普通程序来说这一块属于透明的不需要你操心。uc_mcontext里面包含了gregs数组顺序按REG_R8、REG_R9...REG_RIP、REG_RSP等排列。sigreturn恢复时就是从这个数组里拷回pt_regs。同时uc_sigmask保存了本次信号递送前的信号掩码因为进入 handler 时内核往往会把被递送的信号加入阻塞集除非使用了SA_NODEFERhandler 返回后必须恢复原先的掩码。如果你自己在写底层库或者在做裁剪版内核构造/解析信号帧是最容易出错的地方。我以前在某实验室调试一个实时信号扩展方案时曾遇到过 handler 一进去就段错误的情况。后来定位到原因是信号帧没有做 16 字节对齐导致 handler 内部使用movaps指令时触发对齐检查异常。这不是理论问题是真实踩过的坑。原理就是用户态编译器默认假设栈在函数入口处时%rsp模 16 等于 8。如果你不按这个规矩布置信号帧直接从内核跳进 handler那么函数序言里的subq $N, %rsp之后的栈就可能不对齐一碰上 SSE 指令就挂。所以内核在为信号帧分配空间时必须把这一条单独计算清楚。4. 调试与排查 do_signal 相关问题的实战经验你真正在项目里碰到和do_signal相关的问题往往不是直接去看这个函数的代码而是遇到一些很诡异的行为handler 没有被调用、handler 里sigreturn失败、程序莫名其妙在信号帧附近崩溃、或者高并发下系统调用变慢。我把自己实际排查过的问题以及一些工具用法整理出来供你参考。4.1 单步跟踪 do_signal 的现场用 ftrace 或 kgdbdo_signal不是系统调用接口不能直接用strace跟踪。但你可以用内核的ftrace的 function trace 功能看到内核是否调用了它。例如在 tracefs 里设置echo function /sys/kernel/debug/tracing/current_tracer echo do_signal /sys/kernel/debug/tracing/set_ftrace_filter echo 1 /sys/kernel/debug/tracing/tracing_on然后在用户态执行一个会触发信号的程序比如#include signal.h #include unistd.h void handler(int sig) { write(1, got signal\n, 11); } int main() { signal(SIGUSR1, handler); raise(SIGUSR1); return 0; }raise()会调用tgkill系统调用内核在系统调用返回时就会进入do_signal。ftrace 输出中会看到do_signal被调用以及它内部的一些函数调用如setup_rt_frame。如果你需要更细粒度的内核调试可以用kgdb或者kgdboc在do_signal入口下断点然后单步观察寄存器如何被修改。当然跑kgdb的环境要串口比较麻烦但它是确认寄存器修改逻辑最直观的方式。一个更实用的小技巧是在do_signal的返回处打印regs-rip和regs-rsp的最终值。如果看到rip是 handler 地址而rsp指向了一个看起来像栈的地址说明信号帧已经布置成功。你可以再进用户态用调试器检查这个栈地址上的内容是否与struct rt_sigframe的布局匹配。4.2 常见 bug 案例信号丢失、嵌套信号覆盖、restorer 段错误我梳理几个在业务中反复出现的典型问题。第一个是“信号丢失”。你连续向进程发送多次SIGUSR1但 handler 只执行一次。这个不算 bug是普通信号的合并特性。如果你需要每次信号都得到处理必须使用实时信号比如SIGRTMIN1。这在嵌入式项目里特别容易踩因为很多人想当然地认为每发一次信号就会回调一次。第二个是“嵌套信号覆盖”。当 handler 正在执行时又来了同一个信号。默认情况下该信号会被阻塞所以它会被保持为未决状态等 handler 返回后再次递送。但如果你在 handler 里干的事情太长而且又对信号进行了sigprocmask解除阻塞那就有可能导致同一个信号产生嵌套调用引发栈溢出或不可重入问题。排查时你需要看一下sa_mask的设置或者SA_NODEFER的使用。在setup_rt_frame中内核都会对当前屏蔽字做修改这段逻辑经常是问题的核心。第三个是“restorer 段错误”。这通常发生在信号帧的返回地址被破坏或者程序使用了非标准方式替换了 restorer。比如有些二进制加壳工具或部分 JIT 运行时会自己篡改信号帧。当你发现段错误的地址进到了[vdso]附近或者一个奇怪的地址时就要怀疑是不是信号返回到 restorer 时出了岔子。你可以在sys_rt_sigreturn入口加断点看它读到的帧指针是否合法如果非法多半是栈被写坏了往下查内存踩踏。4.3 性能杂谈为什么高并发场景下信号会拖慢系统调用你可能会好奇为什么 Linux 上每次系统调用返回都要检查信号实际上这个检查非常廉价它只是测试thread_info-flags的一个位。如果没有未决信号测试失败整个do_signal流程直接跳过不会调用任何重量级代码。因此在高并发无信号场景下这个检查的性能开销可以忽略不计。但一旦信号开始大量产生情况就不同了。每次信号递送都要分配信号帧、复制siginfo、保存 FPU 状态、恢复 FPU 状态、执行两次用户态/内核态切换第一次进 handler第二次从sigreturn返回。这比一次普通系统调用的开销高一个数量级。如果你在性能关键的循环里用信号来作为 IPC 机制会发现整体吞吐下降得很厉害。此时最好考虑换用eventfd、pipe、共享内存等机制。反过来如果你确实需要用信号也要注意实时信号的排队会导致大量内存分配因为每个实时信号实例都会创建一个sigqueue节点反复发送高频实时信号会让内存压力上升、延迟抖动加剧。5. 一些容易被忽略的细节与个人体会最后再聊几个书本上不太会专门提、但实际项目里很影响结论的细节。第一do_signal和signal_pending()的关系。很多人以为do_signal会主动去“查看”是否有信号但实际上它只负责“处理”。是否进入处理流程靠的是之前提到的_TIF_SIGPENDING。这个标志可以被set_tsk_thread_flag()设置也可以被signal_wake_up()之类的函数设置。当你调用kill()向一个正在睡眠的进程发信号时唤醒逻辑会设置这个标志然后进程从睡眠返回时走到返回用户态的关卡此时exit_to_user_mode_loop()才会发现它并调用do_signal。第二do_signal里对SIGKILL和SIGSTOP这类不可捕获信号的处理很早。get_signal()内部会判断ka-sa_handler是否为SIG_DFL然后把默认处理动作交给sig_kernel_stop或sig_kernel_coredump之类的 helper。所以如果你在do_signal上看到进程没有回到用户态就直接没了别慌先检查是不是默认行为终结了进程。第三如果你在写内核模块并且想在进程被信号打断后恢复某个系统调用的状态一定要关注系统调用重启语义syscall restart。do_signal在递送信号前如果检测到当前系统调用返回的是-ERESTART_RESTARTBLOCK或-ERESTARTSYS会在信号帧中保存相应字段并在sigreturn后决定是重启系统调用还是返回错误给用户态。这个逻辑常常被忽视但它直接影响nanosleep、clock_nanosleep这类可中断调用的行为。比如你发送一个信号给正在nanosleep的进程默认行为可能是中断睡眠并返回剩余的睡眠时间也可能重启睡眠并继续睡。这套规则是由regs-ax中保存的返回值决定的。你在追踪这类问题时不要只在do_signal里打断点还要看get_signal()之前对regs-orig_ax的处理。我个人在整个摸索过程中最大的一个感受是真正理解do_signal不能只看这一个函数必须把它所在的“返回用户态公共路径”看作一个整体。系统调用、异常、中断这些不同的入口最终都会汇聚到同一条检查链路上。理解了这条链路你就会明白为什么信号被称为“异步”的它并不像中断一样严格地立即打断 CPU而是最多打断用户态执行流而且时机被安排在了下一次内核态返回用户态之前。这既是设计上的简化也是信号性能可预测的关键。希望这篇文章能帮你把这团线理顺下次再看到do_signal时你能一眼看穿它接下来要走的每一步。
延伸阅读

更多相关文章

2026/10/11 14:43:17

在Python中操作MongoDB的详细教程和案例分享

前言 先把最容易搞错的一点说清楚:MongoDB 不是关系型数据库。 它没有 SQL、没有表、没有「行」和「列」、没有 JOIN、没有固定表结构。它是文档型数据库:数据以 BSON 文档(一种类似 JSON 的二进制格式)存储,一层层嵌套…

2026/10/11 14:43:17

5分钟上手GithubLauncher:新手快速开始完整指南

【免费下载链接】GithubLauncher A Launcher that Downloads and Updates Applications from Github Releases 项目地址: https://gitcode.com/gh_mirrors/n6/GithubLauncher 点击查看 免费下载 GithubLauncher 是一款免费、开源的应用启动器,帮你从 Gi…

2026/10/11 14:38:17

软考软件设计师下午真题解析:数据流图、数据库与UML采分点技巧

简介:2025年下半年软件设计师下午真题及参考答案,面向软考中级软件设计师考生,适合备考冲刺与案例题专项训练。内容涵盖四道典型试题:订单处理系统的数据流图分析,包括顶层图与0层图实体、数据存储及缺失数据流补全&am…

2026/10/11 16:58:25

非LVM根分区爆满怎么办?四步救急与迁移实战指南

半夜两点被电话叫醒,登录服务器敲命令,结果连 history 都写不了,满屏 “No space left on device”,这种感觉干运维的都懂。而更尴尬的是,这台机器的根分区当初装系统时就是默认分区,不是 LVM,也…

2026/10/11 16:58:25

朱雀查AI率太高怎么办?盘点3款免费好用的朱雀降AI与AI降重工具

长假熬夜写完了一篇精心准备的深度运营复盘,满心欢喜地点击了发布,结果苦等半天阅读量卡在个位数。我不信邪地拿去朱雀查AI系统一测,满屏幕刺眼的红色让人血压飙升,AI率直接顶到了百分之百。 现在各大平台的风控机制越来越严格&a…

2026/10/11 16:53:25

SMU02C站点监控单元配置与避坑指南:从手册到实战

简介:SMU02C V500R001C50 站点监控单元用户手册面向销售工程师、技术支持工程师与维护工程师,用于掌握华为盒式及柜式电源系统的监控管理方法。手册围绕监控模块SMU02C展开,涵盖LCD与Web用户界面操作、用户接口模块UIM02C、网络IO模块NIM02D、…

2026/10/11 0:02:13

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/11 0:02:13

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/11 0:02:13

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/11 0:02:13

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/11 0:02:13

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/11 0:02:13

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑