信号的完整传递路径:从产生到 handler 执行再到恢复
这是 signals-kernel(信号内核总纲)的拆分篇之二,专讲"一条信号从产生到 handler 执行再到恢复现场"的完整路径。配套拆分篇:分类 · 数据结构 · 性能。
信号不是"魔法般地"立刻跳到你的 handler 函数——内核需要经历六个阶段:① 产生 → send_signal 将信号置入未决集;② 选目标 → complete_signal 设 TIF_SIGPENDING 标志并唤醒目标线程;③ 内核退出 → exit_to_user_mode_loop 检查到标志 → do_signal;④ 伪造栈帧 → handle_signal → setup_rt_frame 在用户栈上构建 sigframe(保存完整 pt_regs + 返回地址指向 sigreturn);⑤ 跳转执行 → 修改 pt_regs 让 CPU "以为"即将返回的地址就是 handler;⑥ 恢复现场 → handler 返回时经由 VDSO sigreturn trampoline → sys_rt_sigreturn → 将保存的 pt_regs 写回 → iret/sysret 回到原始用户代码。
全景架构图:一次信号传递的所有角色与数据流

六个阶段映射到内核函数调用链:
| 阶段 | 内核函数 | 做的事情 |
|---|---|---|
| ① 产生 | sys_kill → kill_pid_info → group_send_sig_info → send_signal | 将信号置入目标进程/线程的 pending set |
| ② 选目标 | complete_signal → signal_wake_up | 遍历线程组选一个未屏蔽的线程,设 TIF_SIGPENDING,若睡眠则唤醒 |
| ③ 退出检查 | exit_to_user_mode_loop → arch_do_signal_or_restart → do_signal | 内核返回用户态前检查 thread flag,发现 TIF_SIGPENDING → 进入信号处理 |
| ④ 伪造栈帧 | handle_signal → setup_rt_frame | 在用户栈上构建 rt_sigframe,保存 pt_regs,设置返回地址为 VDSO sigreturn |
| ⑤ 跳转执行 | setup_rt_frame 修改 pt_regs → iret/sysret | CPU "误以为"返回目标是 handler 函数,实际跳转到 signal handler |
| ⑥ 恢复现场 | handler ret → __vdso_rt_sigreturn → sys_rt_sigreturn | 将保存的 pt_regs 写回,CPU 回到原始被中断的代码位置 |
一句话:信号的处理不发生在信号产生的时刻——它发生在目标线程下一次从内核态返回用户态的那一刻。这是理解信号"异步但非抢占"特性的关键。内核通过伪造用户栈帧 + 修改 pt_regs + sigreturn 恢复这套机制,让用户 handler 看起来像是被"插入"到正常执行流中。
阶段详解①:信号如何到达目标进程 —— send_signal → complete_signal
信号的三类来源最终都汇入同一个函数 send_signal():

关键细节——__send_signal() 做了什么:
- 权限检查:
si_fromuser为真时,检查发送方是否有权限给对方进程发信号(跨用户时,只有SIGKILL/SIGCONT等有限信号能通过)。 - 忽略信号:若目标对此信号处置为
SIG_IGN且不是SIGKILL/SIGSTOP——直接返回,不放入 pending。 - 置位 pending:
- 普通信号 (1~31):未决集是
sigset_t位掩码——sigaddset(&pending->signal, sig)置一位。同种信号来 N 次也只记一位(不排队)。 - 实时信号 (SIGRTMIN~SIGRTMAX):分配
struct sigqueue,链入 pending 链表。每个都排队,可附带sigqueue传来的 int/pointer。
- 普通信号 (1~31):未决集是
- 是否立即投递:若信号被目标线程的 blocked 屏蔽字挡住 → 留在 pending,不调
complete_signal。等目标sigprocmask解除屏蔽时再补投。若未屏蔽 → 调complete_signal。
complete_signal() 选目标线程的规则:
- 发给整个进程 (
group_send_sig_info):遍历线程组链表 → 找第一个满足!(signal 被 blocked) && (线程可被唤醒)的线程。如果所有线程都屏蔽了 → 留在 shared_pending,等解除屏蔽。遍历逻辑展开见signal-multithread。 - 发给特定线程 (
specific_send_sig_info):直接操作该线程的 private pending,略过选线程步骤。 - 同步异常 (SIGSEGV/SIGFPE 等):刚产生时就已绑定当前线程(
force_sig直接操作current),不存在"选"的问题。
唤醒机制:
signal_wake_up(t, ...)
→ 若 task 处于 TASK_INTERRUPTIBLE 睡眠
→ wake_up_state(t, TASK_INTERRUPTIBLE)
→ try_to_wake_up(t)
→ 把 t 移到运行队列
→ 如果 t 优先级高于 current → 设 NEED_RESCHED
→ 若 task 正在运行
→ 什么也不做——TIF_SIGPENDING 已在 complete_signal 阶段
设好,目标线程下一次退出内核时会自动检查到关键理解:
kill()返回成功 ≠ 信号已被目标处理。kill() 只是在 pending set 中置了一位——目标可能正在内核态执行,信号会等到它返回用户态时才被处理(见下节)。甚至目标阻塞了该信号,信号会一直卡在 pending 里。
阶段详解②③:信号的"检查窗口"——只有从内核返回用户态时才处理
信号传递最反直觉的一点:即使目标线程正在 CPU 上运行用户代码,信号也不会立刻打断它。信号处理只在以下窗口发生:

四个窗口的核心结论:
| 窗口 | 触发条件 | TIF_SIGPENDING 何时被检查 |
|---|---|---|
| 系统调用返回 | 目标线程主动调用任意 syscall (read, write, nanosleep, ...) | syscall 执行完毕后,exit_to_user_mode_loop |
| 中断返回 | 硬件中断/异常将线程"拉入"内核 | 中断/异常处理完成后 |
| 被唤醒 | 目标线程正以 TASK_INTERRUPTIBLE 睡眠,signal_wake_up 唤醒它 | 调度器将其重新投入 CPU 后,返回用户态前 |
| 重新调度 | 时间片用完后被重新调度 | 同"中断返回"路径 |
为什么不在运行用户代码时立刻打断?
信号不是硬件中断——不走 IDT、没有硬件级上下文保存。若做成抢占式(任何时刻都能打断用户代码),需要硬件在每条指令后检查信号位,x86 上不可行。内核只在"用户→内核→用户"这个必经瓶颈点检查 TIF_SIGPENDING,代价小、复用中断返回的检查逻辑。
一句话:说信号是"异步"的,不是说它能随时打断任何代码——而是说它的产生和它的处理不在同一个时刻。产生由发送方触发(可能在任何上下文),处理被推迟到目标线程退出内核态的那一刻。
阶段详解④⑤:内核如何"跳"到用户态的 handler —— handle_signal → setup_rt_frame
这是信号机制中最精妙的部分。内核并不直接"调用"用户态函数——它在用户栈上伪造一个栈帧,然后修改 CPU 寄存器让返回用户态时"恰好"跳转到 handler。
前提:当前的寄存器现场 (pt_regs)
当线程因系统调用/中断/异常进入内核时,硬件和软件在内核栈上保存了完整的寄存器快照 struct pt_regs(x86-64 上约 23 个寄存器:R15..R8, RDI, RSI, RBP, RBX, RDX, RCX, RAX, RSP, SS, RFLAGS, CS, RIP, ...)。内核要返回用户态时,会从这个 pt_regs 恢复寄存器,然后 iretq/sysret 跳转到 pt_regs->ip 指定的地址。思路很简单:改 pt_regs->ip,就能控制 CPU 返回用户态后去哪。
正常返回路径:
pt_regs->ip = syscall 下一条指令 (用户代码)
pt_regs->sp = 原始用户栈指针
→ iretq/sysret 回到原来的位置继续执行
信号投递路径:
pt_regs->ip = handler 函数地址 ← 改写!
pt_regs->sp = 新栈顶 (sigframe 之后) ← 改写!
→ iretq/sysret "跳到" handler 函数setup_rt_frame 在用户栈上的工作(逐步骤拆解):
用户栈布局变化:
BEFORE (信号到来前) AFTER setup_rt_frame
┌──────────────────┐ ← 高地址 ┌──────────────────┐ ← 高地址
│ 原始栈帧 │ │ 原始栈帧 │
│ (局部变量/参数) │ │ (局部变量/参数) │
├──────────────────┤ ├──────────────────┤
│ 原始 RSP ──────→ │ │ rt_sigframe │
│ (指向某栈帧) │ │ ┌──────────────┐ │
│ │ │ │ uc_mcontext │ │ ← 保存的 pt_regs
│ │ │ │ (完整寄存器) │ │ (RIP, RSP, RAX, ...)
│ │ │ ├──────────────┤ │
│ │ │ │ siginfo_t │ │ ← 信号信息
│ │ │ ├──────────────┤ │
│ │ │ │ pretcode ────→┼─┼→ VDSO __vdso_rt_sigreturn
│ │ │ └──────────────┘ │ (handler 返回地址!)
│ │ ← 新 RSP │ │
└──────────────────┘ ← 低地址 └────────────────────┘ ← 低地址
关键设计:
· uc_mcontext 中保存的 RIP = 信号到来时用户代码被中断的位置
· uc_mcontext 中保存的 RSP = 原始用户栈指针
· pretcode (栈顶位置) = VDSO __vdso_rt_sigreturn 的地址
→ handler 执行 ret 指令时, CPU 从栈上 pop 出这个地址并跳转
→ 等于自动进入了 sigreturn 系统调用!伪代码还原内核行为:
// kernel/signal.c
static void handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
// 1. 检查是否有 SA_SIGINFO 标志
// 2. 调用 setup_rt_frame 或 setup_frame
if (failed)
force_sigsegv(ksig->sig); // 栈不够 → SIGSEGV
// 3. 解除该信号的屏蔽(除非 SA_NODEFER)
signal_setup_done(failed, ksig, ...);
}
// arch/x86/kernel/signal.c (简化)
static int setup_rt_frame(struct ksignal *ksig, struct pt_regs *regs)
{
struct rt_sigframe __user *frame;
// ① 在用户栈上分配空间(RSP 向下生长)
frame = get_sigframe(ksig, regs, sizeof(*frame), ...);
// ② 将当前 pt_regs 写入 sigframe.uc.uc_mcontext
if (!__put_user(regs, &frame->uc.uc_mcontext))
return -EFAULT;
// ③ 复制 siginfo_t
if (copy_siginfo_to_user(&frame->info, &ksig->info))
return -EFAULT;
// ④ ★ 最关键:设置返回地址 = VDSO __vdso_rt_sigreturn
if (__put_user(vdso_rt_sigreturn_addr, &frame->pretcode))
return -EFAULT;
// ⑤ ★ 改写 pt_regs, 让 CPU "以为"返回目标就是 handler
regs->ip = (unsigned long)ksig->ka.sa.sa_handler; // handler 地址
regs->sp = (unsigned long)frame; // 新栈顶
regs->di = ksig->sig; // 第1参数: signum
regs->si = (unsigned long)&frame->info; // 第2参数: siginfo*
regs->dx = (unsigned long)&frame->uc; // 第3参数: ucontext*
regs->cs = __USER_CS; // 用户态代码段
regs->ss = __USER_DS; // 用户态数据段
return 0;
}问题 1:内核到底改不改用户栈? 改。
setup_rt_frame()通过put_user()把rt_sigframe(保存的寄存器 uc_mcontext、siginfo、FPU 状态、返回地址 pretcode)实实在在写到用户态栈上,再改写pt_regs->sp/ip。唯一例外是SA_ONSTACK+sigaltstack()——此时写入备用信号栈,本质仍是写用户态内存。 问题 2:handler 为什么不直接ret回原始代码,非要 sigreturn 再进一次内核? 因为 sigframe 只是"备份",handler 在 Ring 3 没有能力把它还原回硬件和内核状态:①iretq进 handler 时原 pt_regs 已加载进硬件寄存器并作废,handler 手中没有可返回的栈帧;② FPU/SIMD 与 cr4 相关的恢复只能在 Ring 0 做;③ 信号屏蔽字、TIF_SIGPENDING、altstack 状态都是内核task_struct数据。所以:内核写 sigframe 负责"备份",sigreturn 负责"还原"——这是用户态跨不过的能力边界。
阶段详解⑥:handler 返回如何恢复现场 —— rt_sigreturn
handler 执行完毕后执行 ret 指令。栈顶的返回地址是 VDSO 中 __vdso_rt_sigreturn 的地址——这不是普通函数,而是一个 trampoline(跳板),它立刻执行 sys_rt_sigreturn 系统调用:

sys_rt_sigreturn 的关键行为:
sys_rt_sigreturn()
→ 从当前 RSP (指向 sigframe) 读取:
1. uc.uc_mcontext → 写回 current->pt_regs ★ 恢复全部寄存器
2. uc.uc_sigmask → 恢复信号屏蔽字
3. 备用栈信息 → restore_altstack()
写回 pt_regs 后,内核的 iretq/sysret 就会回到:
· RIP = 信号到来时的用户代码地址 (被中断位置)
· RSP = 信号到来时的用户栈指针
· RAX, RBX, ... = 信号到来时的值 (handler 的副作用全部丢弃)为什么不是 handler 直接 longjmp 回去? handler 拿不到内核栈上的 pt_regs(Ring 0 数据,用户态不可见),恢复现场只能是内核行为;VDSO trampoline 把"恢复"包装成一个普通系统调用,是用户态可执行的唯一入口。
完整时序图:从 kill() 到 handler 返回的全路径

内核态 ↔ 用户态切换全景
一次完整的信号处理经历了 4 次特权级切换:
时间轴 →
用户态 ──┐ ┌──────────────┐ ┌──────────┐
│ │ handler 执行 │ │ 原始代码 │
│ │ │ │ 继续执行 │
内核态 ──┼─────────┼──────────────┼─────────┼──────────┼──
│ │ │ │ │
① 陷入 ② iretq/sysret ③ handler ret ④ iretq/sysret
(syscall 跳到 handler → VDSO 回到原始
或中断) → sys_rt 用户代码
sigreturn
切换 ① ② ③→④
说明
──┼─────────────────────────────────────────
① │ 用户→内核: syscall / 中断 / 异常陷入, 硬件+软件保存 pt_regs
② │ 内核→用户: setup_rt_frame 改 pt_regs, iretq/sysret 跳到 handler
③ │ 用户→内核: handler ret → VDSO trampoline → sys_rt_sigreturn
④ │ 内核→用户: restore_sigcontext 恢复 pt_regs, iretq/sysret 回到原始代码每一步的特权级和关键数据结构:
| 步骤 | 方向 | 触发 | 内核关键操作 | pt_regs 的变化 |
|---|---|---|---|---|
| ① 陷入 | 用户→内核 | syscall / 中断 | 硬件压 SS/RSP/RFLAGS/CS/RIP,软件压剩余寄存器 → 构建 pt_regs | 创建:包含当前全部寄存器 |
| ② 跳到 handler | 内核→用户 | iretq/sysret | setup_rt_frame 在用户栈建 sigframe,改写 regs->ip/sp/di/si/dx | 修改:IP→handler, SP→sigframe, RDI/RSI/RDX→参数 |
| ③ sigreturn | 用户→内核 | handler ret → VDSO → syscall | sys_rt_sigreturn → restore_sigcontext 读 sigframe 写回 pt_regs | 恢复:全部寄存器回到信号到来前的值 |
| ④ 回到原始代码 | 内核→用户 | iretq/sysret | 从恢复后的 pt_regs 恢复寄存器 | 执行:RIP 指向原始被中断处 |
特殊场景
系统调用被信号中断:重启 vs 不重启
若目标线程正在执行慢系统调用(如 read 从 socket 等待数据),信号可能在 TASK_INTERRUPTIBLE 休眠时到来:
signal_wake_up唤醒线程schedule()返回后,检查signal_pending(current)→ 返回-ERESTARTSYS- 内核返回用户态的
exit_to_user_mode_loop中检测到TIF_SIGPENDING→ 进入信号处理 - handler 执行完毕后,sigreturn 恢复了 pt_regs(包括原始 syscall 的返回地址 RAX)
- 关键分歧:
SA_RESTART的 handler:glibc 在sigreturn后检查 RAX ==-ERESTARTSYS→ 重新执行原系统调用(read/write/wait等适用)- 无
SA_RESTART:syscall 直接返回EINTR,用户代码需要手动处理中断
read(fd, buf, n) ← 阻塞中
→ nanosleep (TASK_INTERRUPTIBLE)
→ 信号到达 → 唤醒
→ 返回 -ERESTARTSYS
→ do_signal → handler → sigreturn
→ SA_RESTART?
YES: 重新执行 read(fd, buf, n) (内核自动重启)
NO: 返回 -EINTR 给用户代码嵌套信号
嵌套的基本概念
handler 正在用户态执行期间,另一个(或同一个)信号又来了。处理结果取决于两个因素:
| 因素 | 说明 |
|---|---|
| 新信号是否被屏蔽 | 由 task->blocked 决定——handler A 执行期间 blocked = 原始屏蔽字 ∪ sa_mask ∪ 信号A自身(如果没设 SA_NODEFER) |
| 新信号的信号号 | 跟当前 handler 的信号是否相同决定了默认行为 |
两种嵌套类型:
┌──────────────────────────────────────────────────────────────┐
│ 类型 1:不同种信号嵌套(默认允许) │
│ │
│ handler_SIGINT() 执行中 → SIGTERM 到达 │
│ → SIGTERM 不在 blocked 里 → 内核嵌套处理 SIGTERM │
│ → handler_SIGINT 被中断 → handler_SIGTERM 入栈执行 │
│ → handler_SIGTERM 返回 → handler_SIGINT 继续 │
│ │
│ 类型 2:同种信号嵌套(默认禁止,SA_NODEFER 开启) │
│ │
│ handler_SIGINT() 执行中 → 又一个 SIGINT 到达 │
│ → 默认:SIGINT 在 blocked 里 → 留在 pending,不嵌套 │
│ → SA_NODEFER:SIGINT 不在 blocked → 嵌套处理 → 递归! │
└──────────────────────────────────────────────────────────────┘内核如何实现自动屏蔽
这个自动屏蔽不是在发送信号时做的,而是在 handler 开始执行前的 handle_signal() 中:
// kernel/signal.c: handle_signal()
static void handle_signal(struct ksignal *ksig, struct pt_regs *regs)
{
sigset_t *oldset = sigmask_to_save();
int ret;
// ① 当前 blocked 保存到 sigframe 里(sigreturn 时恢复需要它)
// ② ★ 把 handler 的 sa_mask 和当前信号本身加入 blocked
// block_action() 等价于:
// sigorsets(&blocked, ¤t->blocked, &ka->sa.sa_mask);
// if (!(ka->sa.sa_flags & SA_NODEFER))
// sigaddset(&blocked, ksig->sig);
// sigprocmask(SIG_SETMASK, &blocked);
// ③ 设置 sigframe,伪造返回地址
ret = setup_rt_frame(ksig, oldset, regs);
// ④ SA_ONESHOT (SA_RESETHAND): handler 执行一次后自动重置为 SIG_DFL
if (ret == 0 && (ksig->ka->sa.sa_flags & SA_RESETHAND))
ksig->ka->sa.sa_handler = SIG_DFL;
// ⑤ signal_delivered: 从 pending 中清除该信号
signal_delivered(ksig->sig, &ksig->info, &ksig->ka, regs, 0);
}关键时序:自动屏蔽发生在
setup_rt_frame之前——也就是说,当iretq进入 handler 的那一刻,当前信号的屏蔽已经生效。handler 从头到尾都不会被同种信号打断(除非设了SA_NODEFER)。
嵌套的完整时序:Handler A 执行中被信号 B 打断
以 handler_SIGUSR1 执行时 SIGUSR2 到达为例(完整 12 步时序已从拆分前文档迁入):

用户栈的 LIFO 结构
每次嵌套都在用户栈上压入一个新的 sigframe,形成严格的 LIFO 链(uc_link 指针串起):
高地址(栈顶,往低增长 ↓)
┌─────────────────────┐
│ 原始代码栈帧 │ ← 信号到来前的 RSP
├─────────────────────┤
│ rt_sigframe_USR1 │ ← uc_link = NULL(第一层)
│ · uc_mcontext │ 保存:原始代码的寄存器
│ · uc_sigmask │ 保存:原始屏蔽字
├─────────────────────┤
│ rt_sigframe_USR2 │ ← uc_link = NULL(第二层)
│ · uc_mcontext │ 保存:handler_USR1 的寄存器(write 后下一条指令)
│ · uc_sigmask │ 保存:handler_USR1 期间的屏蔽字(SIGUSR1 blocked)
├─────────────────────┤ ← 当前 RSP(handler_USR2 执行时)
│ handler_USR2 栈帧 │
└─────────────────────┘
低地址sigreturn 的展开顺序(正好是压入的逆序):
handler_USR2 sigreturn → restore_sigcontext(sigframe_USR2) → 回 handler_USR1(write 后下一条)
handler_USR1 sigreturn → restore_sigcontext(sigframe_USR1) → 回原始代码 → blocked 恢复原始值核心理解:嵌套不是"B 插入到 A 中间"那么简单,而是完整"切换→执行→sigreturn 切回"循环;sigframe 在用户栈上的排列顺序保证了 LIFO 正确返回。
SA_NODEFER:打开同种信号递归
// 默认(无 SA_NODEFER):handler 执行期间同种信号被屏蔽
struct sigaction sa;
sa.sa_handler = handler_SIGINT;
sa.sa_flags = 0; // ← 默认:SIGINT 自动屏蔽
sigaction(SIGINT, &sa, NULL);
// handler_SIGINT() 执行时,另一个 SIGINT 留在 pending
// → handler 返回后,sigreturn 恢复 blocked,SIGINT 再次投递
// SA_NODEFER:允许同种信号递归
sa.sa_flags = SA_NODEFER; // ← SIGINT 不自动屏蔽
sigaction(SIGINT, &sa, NULL);
// handler_SIGINT() 执行时,另一个 SIGINT 可以嵌套
// → 如果 SIGINT 持续高速到达,用户栈无限增长 → 栈溢出SA_NODEFER 的危险性:每次嵌套至少消耗 ~2KB(一个 rt_sigframe 的大小,含 FPU 状态)。Linux 默认线程栈 8MB,理论上约 4000 次嵌套后栈溢出。实际应用中,几个快速到来的同种信号就可能导致溢出——因为 handler 本身的局部变量也在消耗栈。
最佳实践:除非 handler 极简单且可重入(如仅置
volatile sig_atomic_t标志),否则别用SA_NODEFER;快速响应同种信号改用signalfd+ epoll。
SA_NODEFER 与 SA_RESETHAND 的组合
// System V 经典行为(BSD/Linux 不推荐但兼容)
sa.sa_flags = SA_NODEFER | SA_RESETHAND;
// handler 执行 1 次、期间同种信号不屏蔽(SA_NODEFER),
// 但 handler 返回后自动重置为 SIG_DFL(SA_RESETHAND)
// → 第二次同种信号不会递归进 handler,而是走默认动作
// → 这是传统 System V 信号语义的残余嵌套与 SA_ONSTACK 的关系
如果 handler A 和 handler B 都指定了 SA_ONSTACK,嵌套帧会压进备用信号栈(sigaltstack),普通栈完全不受影响:
普通栈 备用信号栈 (sigaltstack)
──────── ─────────────────────────
┌──────────────┐ ┌──────────────────────┐
│ 原始帧 │ │ sigframe_USR1 │ ← handler A 入口
├──────────────┤ ├──────────────────────┤
│ (空,未使用) │ │ handler_USR1 栈帧 │
│ │ ├──────────────────────┤
│ │ │ sigframe_USR2 │ ← handler B 入口
│ │ ├──────────────────────┤
│ │ │ handler_USR2 栈帧 │
└──────────────┘ └──────────────────────┘内核判断逻辑(handle_signal()):regs->sp 不在备用栈范围内则切到备用栈,sigreturn 时恢复 sigframe 中的原始 RSP;若当前 RSP 已在备用栈内(handler 内被再次中断,on_sig_stack 判断),不切栈,直接在备用栈上继续压 sigframe,可容纳多层嵌套。
一句话总结:一条信号从产生到处理完,内核走的是"置入未决集 → 选目标线程 → 下次退出内核时检查 → 伪造用户栈帧跳进 handler → sigreturn 恢复现场"这条固定路径,处理时机的唯一窗口是目标线程从内核态返回用户态的那一刻。