☰
Linux内核do_signal深入解析:信号递送、sigreturn与寄存器魔法
2026/10/11 14:43:02 网站建设 项目流程

内核开发里有个很有意思的函数,叫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在主流程上做的事拆开。内核源码版本不同,细节略有出入,但核心逻辑非常稳定:

  1. 检查thread_info->flags里的_TIF_SIGPENDING,若没有则直接返回0。
  2. 调用get_signal()从当前线程的信号队列中取出一个待处理信号,同时返回该信号的ksignal信息,包括信号编号、是否需要用户态处理(ka结构,即该信号对应的 action)。
  3. 如果取到的信号是默认行为(比如默认终止进程或暂停进程),并且没有用户自定义 handler,就直接执行默认动作,可能根本不会回到用户态(比如 SIGKILL 直接让进程结束)。
  4. 如果信号被用户自定义 handler 接管,就需要进入setup_rt_frame()(新的 rt 信号版本)或setup_frame()(旧版)。这一步会在用户栈上构造一个信号帧(signal frame),保存当前寄存器状态、信号编号、信号掩码等关键信息。
  5. 然后修改regs->ip(x86-64 上对应 RIP)为 handler 地址,修改regs->sp(RSP)为新的栈指针,同时设置某些寄存器为信号参数。
  6. 最后返回,让 CPU 得以回到用户态。回到用户态后,指令指针就是 handler 的入口,于是 handler 开始执行。

注意,大多数时候do_signal()只处理一个信号即使有多余的未决信号,内核也会在后续返回用户态时再次触发检查。所以从效果上看,信号是一个接一个被递送的,而不是一次全部塞给用户态。

2.3 关键数据结构:task_struct 里的 pending 与 sigpending

struct 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_NODEFER),handler 返回后必须恢复原先的掩码。

如果你自己在写底层库,或者在做裁剪版内核,构造/解析信号帧是最容易出错的地方。我以前在某实验室调试一个实时信号扩展方案时,曾遇到过 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 或 kgdb)

do_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,是普通信号的合并特性。如果你需要每次信号都得到处理,必须使用实时信号(比如SIGRTMIN+1)。这在嵌入式项目里特别容易踩,因为很多人想当然地认为每发一次信号就会回调一次。

第二个是“嵌套信号覆盖”。当 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时,你能一眼看穿它接下来要走的每一步。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询