审校基线:本页引用的内核符号、行号与源码链接以 Linux 7.3-rc4(torvalds/linux master,2026-09-26 实测)为基准;审校当日 master 的确切 commit 未记录,后续版本可能已有漂移。
用户态进内核只有三扇门:中断、异常、系统调用。SYSCALL 把进门压到几十条指令,IDT 把向量排成一张表,而信号是内核的“回马枪”——在返回用户态的最后一刻,把执行流劫持到 handler。
2026-09-26 · Interrupts, Syscalls and Signals: from IDT to sysret

第 8 关 · 中断、系统调用与信号:从 IDT 到 sysret

定位

这一关把“进出内核”当成一个整体:硬件怎么找到入口(IDT/向量/MSR)、软件怎么建现场(pt_regs)、怎么分发(do_syscall_64/异常 handler)、怎么出去(sysret/iret 的快慢两条路)。信号不是第四扇门——它是出口处的劫持:内核在返回用户态前检查 TIF_SIGPENDING,把用户栈改造成信号帧再放行。

本关的支点:IDT 的门描述(vector/IST/DPL/handler)→ syscall_init 写 MSR_STAR/LSTAR → entry_SYSCALL_64 建 pt_regs → do_syscall_64 分发与 SYSRET 判定 → arch_do_signal_or_restart 的信号帧。中断还有下半部:softirq/tasklet/workqueue 三层。M09「锁与并发」会讲中断上下文的锁规则,M13「网络协议栈」会从 NET_RX softirq 往下走。

核心机制深讲

8.1 IDT:中断描述符表

IDT 是 CPU 按向量号查表的“总入口表”。每个门描述 7 个字段:vector、IST、type、DPL、present、handler 地址、segment(arch/x86/kernel/idt.c 的 G 宏)。

INTG 是 DPL0 中断门(CPU 异常、外部中断),SYSG 是 DPL3 系统门——int3(#BP)、overflow、ia32 的 int 0x80 必须能从用户态触发,所以用 SYSG。DPL 是“谁有资格按这个门铃”的硬件检查。

三阶段安装:early_idts(早期陷阱)→ def_idts(trap_init,含 NMI/double fault/#DB/#MC 的 IST 门)→ apic_idts(SMP/APIC 向量:RESCHEDULE_VECTOR 调度 IPI、CALL_FUNCTION_VECTOR、LOCAL_TIMER_VECTOR、IRQ_WORK_VECTOR)。

IST 是给“栈可能已坏”的异常准备的独立栈:NMI、double fault、#DB、#MC 用 TSS 里的 IST 栈——double fault 的场景就是“处理异常时又异常”,用原栈等于自杀。安全细节:idt_map_in_cea 把 IDT 搬进 CPU entry area 并设只读——sidt 不再泄漏内核地址,也防任意内存写篡改 IDT。

8.2 系统调用的快速路:MSR 与 entry_SYSCALL_64

SYSCALL 不走 IDT——它是“我知道你要去哪”的特快专递,目标地址预存在 MSR 里,硬件只做最小交换。

syscall_init(arch/x86/kernel/cpu/common.c):wrmsrq(MSR_STAR, …) 装用户/内核段选择子;idt_syscall_init 里 wrmsrq(MSR_LSTAR, entry_SYSCALL_64) 装入口地址;wrmsrq(MSR_SYSCALL_MASK, …) 指定进内核要清的 rflags 位(IF/DF/TF 等,一个都不能少)。FRED 开启的 CPU 跳过 LSTAR——FRED 用统一入口,sysret 会 #UD。

entry_SYSCALL_64(arch/x86/entry/entry_64.S):硬件行为——rip→rcx、rflags→r11、从 MSR 加载新 cs/ss/rip;注释原话:SYSCALL does not save anything on the stack and does not change rsp。软件接着做:swapgs、rsp 暂存到 tss.sp2、SWITCH_TO_KERNEL_CR3、切到内核栈、在栈上建 pt_regs、call do_syscall_64。寄存器约定:rax=调用号,rdi/rsi/rdx/r10/r8/r9=6 个参数。

do_syscall_64(arch/x86/entry/syscall_64.c):nr < NR_syscalls → x64_sys_call 的 switch 分发到 __x64_##sym,非法号走 __x64_sys_ni_syscall(-ENOSYS)。函数返回 bool:true 用 SYSRET,false 用 IRET——返回路径的选择权在分发函数手里。

8.3 sysret 与 iret:快慢两条出路

返回用户态有快慢两条路,do_syscall_64 用一串前置检查决定走哪条——“现场足够干净”才配走 SYSRET。

强制 IRET 的条件:XENPV;rcx != rip 或 r11 != rflags(ptrace 改过);cs/ss 不是 __USER_CS/__USER_DS;rip >= TASK_SIZE_MAX(非规范地址);rflags 含 RF/TF。注释原话:用户能改 pt_regs 就强制 IRET,因为 SYSRET 处理非规范地址有 AMD/Intel 的 CPU bug。

SYSRET 路径(syscall_return_via_sysret):POP_REGS 恢复寄存器 → 换到 trampoline 栈 → SWITCH_TO_USER_CR3 → swapgs → sysretq。IRET 路径(swapgs_restore_regs_and_return_to_usermode)慢而通用。

设计含义:快路径的代价是前置检查——“能快”的前提是“先证明干净”。这是 8.2 节“特快专递”的另一半:进门快(硬件最小交换),出门也快(SYSRET),但两头都要软件自己把契约检查补上。

8.4 中断下半部:softirq、tasklet、workqueue

硬中断处理越快越好,“剩下的活”按“能不能睡眠”分三层下半部。这是中断延迟和吞吐的根本 trade-off。

softirq(kernel/softirq.c):softirq_vec 数组 + per-CPU pending 位图,10 种:HI、TIMER、NET_TX、NET_RX、BLOCK、IRQ_POLL、TASKLET、SCHED、HRTIMER、RCU。设计注释:无共享变量、per-CPU、需要串行化的自己加锁;一次跑不完就唤醒 ksoftirqd 内核线程接着跑。NET_RX 就是 M13 网络收包的下半部。

tasklet:仍占一个软中断槽位(TASKLET_SOFTIRQ),语义是“同一 tasklet 不并发、自己跟自己串行”——遗留接口,新代码别用。

workqueue(kernel/workqueue.c):work item 在进程上下文执行(kworker 内核线程),worker pool 共享、自动管理——唯一能睡眠的下半部。分层口诀:硬中断里只做“必须关中断做的事”,softirq 做“延迟但不能睡的”,workqueue 做“能睡的”。(M09「锁与并发」会讲为什么中断上下文不能拿 mutex——本节是那一问的事实基础。)

8.5 信号:返回用户态时的劫持

信号递送不创建新执行流——它只是篡改返回用户态时的寄存器现场。递送点永远在“内核→用户”的边界上(syscall 退出、中断返回)。

get_signal(kernel/signal.c,现返回 bool):先 task_sigpending 检查;同步信号(SIGSEGV/SIGBUS/SIGILL/SIGTRAP/SIGFPE/SIGSYS)优先出队——注释原话:让信号栈帧里的 ip 指向 faulting instruction;SIG_IGN 跳过;有 handler → ksig->ka 记下,SA_ONESHOT 则重置为 SIG_DFL;默认动作致命 → vfs_coredump + do_group_exit。

arch_do_signal_or_restart(arch/x86/kernel/signal.c):有信号 → handle_signal;无信号但系统调用返回 -ERESTART* → 重启逻辑。handle_signal 的 ERESTART 开关:-ERESTART_RESTARTBLOCK/-ERESTARTNOHAND → rax=-EINTR;-ERESTARTSYS → 没 SA_RESTART 则 -EINTR,否则与 -ERESTARTNOINTR 一样:rax=orig_ax、rip-=2(syscall 指令 2 字节,回退即重执行)。

信号帧:get_sigframe 在用户栈上布局——先扣 128 字节红区,SA_ONSTACK 则切到 altstack,16 字节对齐;setup_rt_frame 建 rt_sigframe(含 siginfo+ucontext+fpstate);失败 → signal_fault → force_sig(SIGSEGV)。signal_delivered 更新 blocked mask(sa_mask 恒阻塞,信号自身除非 SA_NODEFER)。闭环:pt_regs->ip 指向 handler → sysret/iret 回用户态先跑 handler → sigreturn 恢复现场(注释原话:saved sigmask 存在信号帧上,由 sigreturn 恢复)。

为什么这样设计

为什么 SYSCALL 不走 IDT/中断门,而要用专用 MSR?

中断门是通用入口:查 IDT、硬件压栈、DPL 检查,一套流程服务所有向量。SYSCALL 是高频路径(一次 read 就是一次往返),用 MSR 把目标地址写死,硬件只做最小交换(rcx/r11),压栈建帧全交给软件。代价是入口契约焊死在 MSR 里——换来的是几十条指令的进门开销。通用 vs 专用、慢 vs 快,选的是“为高频路径定制硬件捷径”。

为什么返回时“能 SYSRET 就不用 IRET”?

IRET 通用但慢(恢复全部段、可回任意特权级);SYSRET 快但挑食(要求现场干净:rcx==rip 等一串条件)。do_syscall_64 的检查清单就是“干净”的定义——ptrace 动过寄存器、地址非规范,一律回 IRET。快路径从不免费,代价是前置检查。

为什么信号在返回用户态时递送,而不是随时打断?

用户态执行流的全部现场就是 pt_regs,随时可重建、篡改也安全;内核态打断要面对可重入地狱(锁、中断嵌套、栈深度)。TIF_SIGPENDING + 出口检查把“异步信号”变成“同步检查点”:信号永远在内核→用户的边界上递送,handler 跑在用户态。这就是“边界递送”——也是本关标题“从 IDT 到 sysret”的落点:一切故事都结束于返回用户态的那一刻。

与已学模块的关联

自测

出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释“为什么这样设计而不是那样”。难度递进,最后两道需要想一会儿。

Q1. IDT 门描述的 7 个字段是什么?INTG 和 SYSG 的区别是什么?

7 个字段:vector、IST、type、DPL、present、handler 地址、segment(G 宏)。INTG 是 DPL0 中断门(CPU 异常、外部中断);SYSG 是 DPL3 系统门——int3(#BP)、overflow、ia32 的 int 0x80 必须能从用户态触发,所以用 SYSG。DPL 是“谁有资格按这个门铃”的硬件检查。

Q2. 哪几种异常必须用 IST 独立栈?为什么 double fault 不能用普通内核栈?

NMI、double fault、#DB、#MC(x86-64 用 ISTG 安装)。double fault 的场景就是“处理异常时又发生异常”——第一次异常可能就是因为内核栈坏了,再用原栈等于自杀。IST 让 CPU 在门描述里直接换到 TSS 预置的独立栈,不依赖当前栈的可用性。

Q3. syscall_init 写了哪三个 MSR?各自的作用是什么?

MSR_STAR:用户/内核段选择子(syscall 硬件加载新 cs/ss 的依据);MSR_LSTAR:64 位系统调用入口地址(entry_SYSCALL_64);MSR_SYSCALL_MASK:进内核要清的 rflags 位(IF/DF/TF 等)。三者分工:STAR 定“身份”,LSTAR 定“去哪”,MASK 定“进门先关什么”。

Q4. SYSCALL 硬件做了什么、没做什么?没做的部分谁来补?

做了:rip→rcx、rflags→r11(清 RF)、从 MSR 加载新 ss/cs/rip。没做:没压栈、没改 rsp(注释原话)。没做的部分由 entry_SYSCALL_64 软件补:swapgs、rsp 暂存 tss.sp2、切内核栈、在栈上手工建 pt_regs。硬件只做最小交换,剩下的都是软件的活——这就是“特快专递”的代价分摊。

Q5. do_syscall_64 返回 true/false 各意味着什么?列出至少 3 个强制走 IRET 的条件。

true → SYSRET(快路径),false → IRET(慢而通用)。强制 IRET 的条件:XENPV;rcx != rip 或 r11 != rflags(ptrace 改过寄存器);cs/ss 不是 __USER_CS/__USER_DS;rip >= TASK_SIZE_MAX;rflags 含 RF/TF。记住注释原话:用户能改 pt_regs 就强制 IRET。

Q6. 为什么“用户能改 pt_regs 就强制 IRET”?这和 CPU bug 有什么关系?

因为 SYSRET 对非规范地址的处理有 AMD 和 Intel 的 CPU bug——用 SYSRET 返回到坏地址可能在内核态 #GP,等于把内核控制权送给用户态。而 ptrace 可以任意改 pt_regs(比如把 rip 改成非规范地址),所以只要用户碰过现场,就回退到能正确处理非规范地址的 IRET。快路径的前提是“现场干净”,脏了就别快。

Q7. 描述硬中断 → softirq → workqueue 三层下半部的分工,并说明每一层“能做什么、不能做什么”。

硬中断:关中断上下文,越快越好,只能做“必须关中断做的事”(ack 中断、raise softirq),不能睡眠、不能碰可被调度的东西。softirq:per-CPU pending 位图,开中断后(或 ksoftirqd 线程)执行,10 种(HI/TIMER/NET_TX/NET_RX/…),依然不能睡眠。workqueue:kworker 进程上下文,唯一能睡眠的下半部。口诀:越往下层,延迟越高、约束越少。

Q8. handle_signal 里,系统调用返回 -ERESTARTSYS 时,有 SA_RESTART 和没有 SA_RESTART 分别发生什么?“rip -= 2”是什么意思?

没 SA_RESTART:rax = -EINTR,用户态看到系统调用被信号打断。有 SA_RESTART(或 -ERESTARTNOINTR):rax = orig_ax(恢复调用号)、rip -= 2——把指令指针回退 2 字节,正好回到 syscall 指令开头,信号处理完返回用户态时会重新执行这条系统调用,用户态无感知。

Q9. get_signal 里同步信号(SIGSEGV 等)为什么优先于普通信号出队?

注释原话:让信号栈帧里的 ip 指向 faulting instruction。同步信号由某条指令的执行直接产生(如访存越界),handler(或调试器)需要看到“哪条指令 fault 了”;如果先递送一个异步信号,pt_regs->ip 还是对的,但信号帧里记录的上下文顺序会乱——优先出队保证 fault 上下文的精确性。

Q10. 设计题:要在内核里实现“给指定进程注入一次任意代码执行”(类似信号,但没有信号语义),你会复用信号机制的哪几步、改掉哪几步?

复用:TIF_SIGPENDING 式的标记 + 返回用户态出口检查(边界递送)、get_sigframe 在用户栈布帧、篡改 pt_regs->ip/sp 让出门先跑注入代码——“劫持返回现场”是核心,不用重造。改掉:ksig/sigaction 查表(没有信号语义,不需要 disposition)、SA_RESTART/ERESTART 逻辑(没有“被打断的系统调用”)、sigreturn 恢复(注入代码跑完不需要回到“被打断处”,直接退出或跳走)。一句话:信号机制 = 边界递送 + 现场篡改 + 语义恢复;注入只需要前两项。

延伸阅读

SYSCALL 进出全景(x86-64)
==========================
用户态:  rax=调用号, rdi..r9=参数; syscall
           |
    硬件:  rcx=rip, r11=rflags, rip=MSR_LSTAR
           |  (不压栈、不改 rsp)
           v
内核:  swapgs → 暂存rsp → 切内核栈 → 建 pt_regs
       → do_syscall_64() → x64_sys_call 分发
           |
    返回:  现场干净? ——是——→ sysretq(快)
           |
           否(XENPV / ptrace改过 / 地址非法 / RF|TF)
           v
       swapgs_restore_regs_and_return_to_usermode(IRET,慢而通用)
           |
           +→ 出口检查 TIF_SIGPENDING → 信号劫持(见下图)

进门快(硬件最小交换)、出门也快(SYSRET),但两头的契约检查全由软件补——这是 8.2 + 8.3 的一句话总结。

信号递送:返回用户态时的“劫持”
================================
syscall 退出 / 中断返回
    → TIF_SIGPENDING?
        → get_signal():出队(同步信号优先)
            → handle_signal()
                → ERESTART 开关:-EINTR 还是 rip-=2 重执行
                → get_sigframe():用户栈上建 rt_sigframe
                → pt_regs->ip = handler(现场被篡改)
        → sysret/iret 回用户态 → 先跑 handler
            → handler 返回 → sigreturn
                → 恢复 sigcontext → 回到被打断处

信号没有创造新执行流——它只是把“回家”的地址改成了 handler,跑完再用 sigreturn 把家搬回来。

中断下半部三层:按“能不能睡眠”分
==================================
硬中断(关中断,越快越好)
    → raise_softirq(),只做必须关中断的事
        → softirq(开中断后 / ksoftirqd,per-CPU)
            HI TIMER NET_TX NET_RX BLOCK IRQ_POLL
            TASKLET SCHED HRTIMER RCU
            依然不能睡眠
        → tasklet(TASKLET 槽位,自己跟自己串行,遗留接口)
        → workqueue(kworker 进程上下文)
            唯一能睡眠的下半部

越往下层延迟越高、约束越少。M09「锁与并发」会解释为什么上两层拿 mutex 是死路一条。

在页面里每题写下你的答案(关键词即可),卡住的题点一下“卡住了”标记,
点“生成作答汇总”后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说“下一关”。

作答汇总

点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。