Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3.3 中断、异常与系统调用

本章回答

网卡收到一个包,CPU 怎么知道?为什么中断处理函数里不能 `sleep`,而「下半部」就可以?syscall 指令执行的那一瞬间,CPU 和内核各做了什么?为什么自己加一个系统调用要改三处文件、还要重新编译内核?strace 又是怎么看到别人在调什么?

读完你会

画出从硬件中断到驱动回调、再到软中断与 workqueue 的完整路径;说清中断上下文与进程上下文的边界;用 /proc/interrupts、/proc/softirqs 定位中断风暴;读懂 entry_SYSCALL_64 到 sys_call_table 的跳转;用 ptrace 写一个 40 行的迷你 strace。

3.2 结束时,系统已经有了 PID 1,调度器也开始工作。但还有一条路没讲:CPU 怎么从用户态回到内核态。启动是一次性的,这条路是每时每刻都在走的——一次 read、一次缺页、一次网卡收包,都要经过它。

进入内核只有三个门:中断(外部设备异步通知)、异常(当前指令同步出错或陷入)、系统调用(程序主动请求)。三者在硬件层面的动作相似:保存现场、跳到内核预设的入口、按向量号查表。差别在于「谁触发」「什么时候返回」「能不能睡眠」。

这一章的顺序是:先看门本身(IDT 与门描述符),再看硬件中断的两段式处理(上半部/下半部),然后走一遍系统调用的完整路径,最后用 ptrace 把这条路变成可观测的。李治军第 2 章的 0.11 实现(int 0x80 + system_call.s)会作为对照出现——它比 6.8 少了一百个细节,但主干一模一样。

3.3.1 三个门:中断、异常、系统调用

定义 3.3.1 · 中断、异常与陷阱
  • 中断(interrupt):来自 CPU 外部(APIC、设备)的异步信号。当前指令流被打断,处理完回到下一条指令。
  • 异常(exception):由当前指令同步引发。分三类:故障(fault) 处理后重试当前指令(如缺页);陷阱(trap) 处理后返回下一条指令(如 int3、系统调用);中止(abort) 无法恢复(如 #MC 机器检查)。
  • 系统调用(system call):一种特殊的陷阱,用 syscall 指令主动进入内核,按系统调用号分派。

CSAPP 第 8 章把异常分成四类(interrupt/fault/trap/abort),与上表一致,只是把「系统调用」归入 trap。李治军第 2 章讲的是同一件事的 0.11 版本:用户程序执行 int 0x80,CPU 查 IDT 第 0x80 项,跳到 system_call,在那里把 eax 当作系统调用号查 sys_call_table。6.8 的路径长了十倍,但这个骨架没变。

维度硬件中断异常系统调用
触发者设备/APIC/定时器指令执行结果程序主动
同步性异步同步同步
返回位置下一条指令当前指令(fault)或下一条(trap)下一条指令
上下文中断上下文可能是任一上下文进程上下文
能否睡眠不能(上半部)缺页处理可以可以
典型入口common_interruptasm_exc_*entry_SYSCALL_64

「能不能睡眠」是这三个门最重要的实践差别。中断上下文没有「当前进程」可供调度(current 只是被中断的那个任务,不能代表它继续执行),所以任何可能阻塞的操作——kmalloc(GFP_KERNEL)、mutex_lock()、copy_from_user() 触发缺页——都是禁止的。内核用 in_interrupt()、in_atomic()、might_sleep() 在调试配置下抓这类错误。

为什么中断不能睡眠

睡眠的本质是「把自己挂到等待队列,然后调用 schedule() 换别人跑」。调度器切走时要保存当前上下文;对中断上下文来说,这个上下文是「借来的」——它属于被中断的进程,而不是中断处理本身。如果在这里切走,栈上还压着中断帧,谁也不知道该在什么时候、以什么身份切回来。所以内核干脆规定:中断上下文不许睡眠。

3.3.2 IDT:256 个门,装的是入口地址

x86-64 用 IDT(Interrupt Descriptor Table) 描述「向量号 → 入口地址」。256 个表项,每项 16 字节,含段选择子、偏移、类型(中断门/陷阱门)、DPL 与 IST 索引。

向量范围用途门类型与 DPL
0x00–0x1fCPU 异常(#DE、#PF、#GP、#DF…)中断门,DPL 0
0x03int3 断点陷阱门,DPL 3(用户态可用)
0x20–0x2f外部 IRQ(经 IOAPIC/APIC 重映射)中断门,DPL 0
0x8032 位 int 0x80 系统调用陷阱门,DPL 3
0xec–0xff本地 APIC 与系统向量(定时器、IPI、spurious)中断门,DPL 0

三处代码把这些门装起来:

  • arch/x86/kernel/idt.c: idt_setup_traps() 装异常与 int3;
  • idt_setup_apic_and_irq_gates() 装外部中断与 APIC 向量;
  • 早期 idt_setup_early_traps() 只装 #PF/#DB 等少数几个,供 setup_arch() 期间使用。

每个门的入口是一个汇编桩,最终落到 C 函数。6.8 用宏统一生成桩与原型:

/* arch/x86/kernel/traps.c —— 一个典型的异常处理函数 */
DEFINE_IDTENTRY(exc_divide_error)
{
	do_error_trap(regs, 0, "divide error", X86_TRAP_DE, SIGFPE, 0, 0);
}

/* arch/x86/kernel/apic/apic.c —— 一个典型的系统向量处理函数 */
DEFINE_IDTENTRY_SYSVEC(sysvec_apic_timer_interrupt)
{
	...
}

DEFINE_IDTENTRY 展开后包含 DEFINE_IDTENTRY_RAW 与 __visible 的 asm_exc_divide_error 桩:桩把寄存器压成 struct pt_regs,调 C 函数,返回时走 iretq。DEFINE_IDTENTRY_SYSVEC 多一层 irqentry_enter/exit,因为系统向量要参与中断统计与 irqtime 记账。

IST(Interrupt Stack Table) 是 64 位模式的新东西:#DF(双重故障)、NMI、#MC(机器检查)、#DB 可以指定切换到独立的栈。原因是这些异常可能发生在栈已经损坏的时候,继续用坏栈只会三重故障(CPU 直接复位)。CONFIG_X86_64 下 IST_INDEX_DF/NMI/MCE/DB 一共四个索引。

关于 IDT 的四个误解
  1. 「int 0x80 在 64 位程序里还能用」:能用,但走的是 32 位 ABI:系统调用号是 32 位的,指针被截断到 32 位,参数用 ebx/ecx/edx/esi/edi/ebp 传递。地址超过 4 GiB 的参数会 -EFAULT。新代码一律用 syscall。
  2. 「IDT 在用户态可改」:lidt 是特权指令。用户态只能通过 int3/syscall 触发已登记的门。
  3. 「门描述符里的 DPL 只影响权限检查」:它还决定 int n 能否从用户态发起。DPL 0 的门被用户态 int 命中会变成 #GP。
  4. 「异常号可以随便改」:0–31 由 CPU 架构固定,改不了;能自定义的只有 0x20 以上的可编程部分。

3.3.3 一次硬件中断的完整路径

以网卡收包为例,从设备拉高 IRQ 线到驱动拿到 sk_buff,中间经过这些站:

  网卡 DMA 写完描述符
        │  拉高 INTx / 发 MSI-X 消息
        ▼
  IOAPIC / PCI-MSI 控制器 ──► Local APIC ──► CPU 核
        │                                        │
        │  按 vector 查 IDT                       │  硬件自动:压 SS/RSP/RFLAGS/CS/RIP
        ▼                                        ▼
  asm_sysvec_apic_timer_interrupt ...      common_interrupt(汇编桩)
        │                                        │  压其余寄存器 → pt_regs
        ▼                                        ▼
  irqentry_enter(regs) ──► handle_irq(desc) ──► generic_handle_irq_desc()
                                                  │
                                                  ▼
                                        desc->handle_irq(handle_edge_irq 等)
                                                  │  遍历 irqaction 链表(可共享)
                                                  ▼
                                        action->handler()  ← 驱动的上半部
                                                  │  返回 IRQ_HANDLED / IRQ_WAKE_THREAD
                                                  ▼
                                        irqentry_exit() ──► irq_exit()
                                                  │  有 pending softirq?
                                                  ▼
                                    invoke_softirq() ──► __do_softirq()
                                                  │  或唤醒 ksoftirqd/N 收尾
                                                  ▼
                                        NET_RX_SOFTIRQ → net_rx_action() → NAPI poll

读图:上半部只做「必须立刻做」的事——读状态寄存器、清中断、把数据挂到队列、触发下半部。真正耗时的协议栈处理在软中断里做。这条分界线不是风格问题,是正确性问题:上半部运行时该 CPU 的中断是关着的(或至少同优先级被屏蔽),做得越久,丢中断的概率越高。

内核里的关键数据结构:

/* include/linux/irqdesc.h —— 一个中断线的描述符(节选) */
struct irq_desc {
	struct irq_common_data	irq_common_data;
	struct irq_data		irq_data;
	unsigned int __percpu	*kstat_irqs;
	irq_flow_handler_t	handle_irq;   /* 流控:边沿/电平/MSI */
	struct irqaction	*action;      /* 处理函数链表,可共享 */
	unsigned int		status_use_accessors;
	...
};

irq_desc 是「一条中断线」的全局对象,irqaction 是挂在它上面的处理函数(request_irq() 一次加一个)。handle_irq 指向流控函数:电平触发用 handle_level_irq(),边沿触发用 handle_edge_irq(),MSI 常用 handle_edge_irq() 或 handle_fasteoi_irq()。流控函数负责「屏蔽—处理—解除屏蔽」的顺序,写驱动的人通常不需要碰它。

中断统计在 /proc/interrupts 里,每个 CPU 一列:

           CPU0       CPU1
  1:          0          0   IO-APIC   1-edge      i8042
  8:          0          0   IO-APIC   8-edge      rtc0
 24:       1234          0   PCI-MSI 327680-edge      virtio0-input.0
 25:          0       5678   PCI-MSI 327681-edge      virtio1-input.0
LOC:      98765      87654   Local timer interrupts
RES:       1234       2345   Rescheduling interrupts

读数三条规则:① 某一列远高于其他列,说明中断都打在一个 CPU 上,看 /proc/irq/N/smp_affinity;② LOC 增长过快说明定时器频率高或空转;③ RES 是 IPI 重调度,多线程 CPU 密集程序会让它飙升,不一定是问题。

3.3.4 下半部:softirq、tasklet 与 workqueue

下半部有三套机制,选型取决于「能不能睡眠」和「并发模型」:

机制运行上下文能睡眠并发性典型用途
softirq软中断上下文(in_softirq)否同一 vector 可在多 CPU 并行网络收发、块层、定时器
tasklet软中断上下文否同一 tasklet 串行老驱动、USB 收尾
workqueue进程上下文(kworker)是同一 work 默认不并发大部分「稍后做」的工作
threaded IRQ进程上下文(专用线程)是每个 irq 一个线程需要睡眠的中断处理

softirq 是编译期固定的 10 个向量(include/linux/interrupt.h):

enum {
	HI_SOFTIRQ = 0,      /* tasklet 高优先级 */
	TIMER_SOFTIRQ,       /* 定时器 */
	NET_TX_SOFTIRQ,      /* 网络发送 */
	NET_RX_SOFTIRQ,      /* 网络接收 */
	BLOCK_SOFTIRQ,       /* 块设备完成 */
	IRQ_POLL_SOFTIRQ,    /* io_uring 轮询 */
	TASKLET_SOFTIRQ,     /* 普通 tasklet */
	SCHED_SOFTIRQ,       /* 调度负载均衡 */
	HRTIMER_SOFTIRQ,     /* 高精度定时器 */
	RCU_SOFTIRQ,         /* RCU 回调 */
	NR_SOFTIRQS
};

__do_softirq() 在中断返回时被调用,最多处理 MAX_SOFTIRQ_TIME(2 ms)或 MAX_SOFTIRQ_RESTART(10 轮);超时就把剩余工作交给 ksoftirqd/N 内核线程,避免用户态进程被无限拖延。local_bh_disable() 只关下半部,不关硬中断——它保护的是「softirq 不会在本 CPU 上抢占我」,不是「设备不会打断我」。

tasklet 建在 softirq 之上:tasklet_schedule() 把 tasklet_struct 挂到 per-CPU 链表,TASKLET_SOFTIRQ 的处理函数逐个执行。它的保证是「同一个 tasklet 不会同时在两个 CPU 上跑」,但不同 tasklet 之间可以并行。6.x 的驱动已经很少新建 tasklet,新代码优先用 workqueue 或 threaded IRQ。

workqueue 把工作交给内核线程:schedule_work(&w) 等价于 queue_work(system_wq, &w),由 kworker/*(per-CPU)或 kworker/u*(unbound)执行。它运行在进程上下文,可以睡眠、可以拿 mutex、可以 copy_from_user()。

选下半部机制的判断顺序
  1. 处理需要睡眠(I/O、mutex、大块分配)?→ workqueue 或 threaded IRQ。
  2. 是网络/块层这类高频、极短的收尾?→ softirq(通常已由子系统提供,不自己加)。
  3. 需要「同一对象串行、不同对象并行」且不睡眠?→ tasklet(存量代码)。
  4. 中断本身很短、后续工作与设备强相关?→ threaded IRQ(request_threaded_irq),上半部只做确认,下半部在线程里做。

下面的模块演示 tasklet 与 workqueue 的差别。加载后它会打印两种上下文的 in_interrupt() 与「能否睡眠」的判断:

// bh_demo.c — 对比 tasklet 与 workqueue 的运行上下文(Linux 6.8)
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/interrupt.h>
#include <linux/workqueue.h>
#include <linux/delay.h>
#include <linux/jiffies.h>

static struct tasklet_struct demo_tasklet;
static struct work_struct demo_work;
static struct workqueue_struct *demo_wq;

static void demo_tasklet_fn(unsigned long data)
{
	/* 软中断上下文:这里 msleep() 会触发 scheduling while atomic */
	pr_info("tasklet: in_interrupt=%d in_softirq=%d jiffies=%lu\n",
		in_interrupt(), in_softirq(), jiffies);
}

static void demo_work_fn(struct work_struct *work)
{
	/* 进程上下文:可以睡眠 */
	pr_info("work: in_interrupt=%d in_softirq=%d pid=%d\n",
		in_interrupt(), in_softirq(), current->pid);
	msleep(20);
	pr_info("work: slept 20ms without complaint\n");
}

static int __init bh_demo_init(void)
{
	demo_wq = alloc_workqueue("bh_demo", WQ_UNBOUND, 0);
	if (!demo_wq)
		return -ENOMEM;

	tasklet_setup(&demo_tasklet, demo_tasklet_fn);
	INIT_WORK(&demo_work, demo_work_fn);

	tasklet_schedule(&demo_tasklet);
	queue_work(demo_wq, &demo_work);
	pr_info("bh_demo: scheduled both, current pid=%d\n", current->pid);
	return 0;
}

static void __exit bh_demo_exit(void)
{
	tasklet_kill(&demo_tasklet);
	cancel_work_sync(&demo_work);
	destroy_workqueue(demo_wq);
	pr_info("bh_demo: unloaded\n");
}

module_init(bh_demo_init);
module_exit(bh_demo_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("tasklet vs workqueue context demo");

用 3.1 的 Makefile 模板编译(obj-m += bh_demo.o),在虚机里 insmod bh_demo.ko。预期 dmesg 里 tasklet 那行 in_interrupt=1 in_softirq=1,work 那行 in_interrupt=0 in_softirq=0 且 pid 是某个 kworker 的 tid。把 msleep(20) 挪进 tasklet,会得到 BUG: scheduling while atomic——这是最直观的边界演示。

cmwq:workqueue 的并发管理

现代 workqueue 不是「一个队列一个线程」,而是 concurrency-managed workqueue(cmwq):

类型每 CPU并发适用
per-CPU(默认)是每 CPU 一个 worker pool短、无阻塞的工作
unbound否跨 CPU 的 pool,可配 max_active可能阻塞、需要并行
ordered是/否同一队列串行执行有顺序依赖的初始化/清理

alloc_workqueue("name", WQ_UNBOUND | WQ_MEM_RECLAIM, max_active) 创建队列;queue_work() 入队,flush_workqueue() 等所有已入队工作完成,cancel_work_sync() 取消并等待正在跑的实例。WQ_MEM_RECLAIM 保证内存回收路径也能推进(内核在内存紧张时仍需要 workqueue 做回写)。

worker pool 由 worker_pool/pool_workqueue 管理:每个 pool 有 worker 链表、nr_running、max_active;调度器按 CPU 亲和与 NUMA 选 pool。workqueue 的 flags 决定「能不能睡眠、是否保序、是否跨 CPU」,不是「线程池大小」这么简单。

3.3.5 系统调用:从 syscall 指令到 C 函数

用户态执行 syscall 时,CPU 做四件事(由 MSR_LSTAR、MSR_STAR、MSR_SFMASK 三个寄存器配置):

  1. 把 RIP 存进 RCX,把 RFLAGS 存进 R11;
  2. 按 MSR_SFMASK 清标志位(清 IF,关中断);
  3. 从 MSR_LSTAR 取入口地址,跳到 entry_SYSCALL_64;
  4. 切到内核栈(TSS.RSP0 或 per-CPU 的 cpu_current_top_of_stack)。

arch/x86/entry/entry_64.S: entry_SYSCALL_64 是一段手写汇编:

/* arch/x86/entry/entry_64.S —— 节选,省略了 PTI 与 FRED 分支 */
SYM_CODE_START(entry_SYSCALL_64)
	swapgs                          /* 用户 GS → 内核 GS,拿到 per-CPU 基址 */
	movq	%rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
	SWITCH_TO_KERNEL_CR3
	movq	PER_CPU_VAR(cpu_current_top_of_stack), %rsp
	pushq	$__USER_DS
	pushq	PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
	pushq	%r11                    /* RFLAGS */
	pushq	$__USER_CS
	pushq	%rcx                    /* RIP */
	pushq	%rax                    /* orig_ax = 系统调用号 */
	...
	call	do_syscall_64
	...
	swapgs
	sysretq
SYM_CODE_END(entry_SYSCALL_64)

压栈的顺序决定了 struct pt_regs 的布局:r15 r14 r13 r12 rbp rbx r11 r10 r9 r8 rax rcx rdx rsi rdi orig_ax rip cs eflags rsp ss。调试器里读 regs->di、regs->si、regs->dx 就是系统调用的前三个参数。

C 侧的入口是 arch/x86/entry/common.c: do_syscall_64():

/* arch/x86/entry/common.c —— 6.8 节选,已简化 */
__visible noinstr void do_syscall_64(struct pt_regs *regs, int nr)
{
	struct thread_info *ti = current_thread_info();
	unsigned long nr;
	...
	nr = syscall_enter_from_user_mode(regs, nr);   /* seccomp / ptrace / 审计 */
	if (likely(nr < NR_syscalls)) {
		nr = array_index_nospec(nr, NR_syscalls);
		regs->ax = sys_call_table[nr](regs);
	}
	syscall_exit_to_user_mode(regs);
}

sys_call_table 不是手写的,而是由 arch/x86/entry/syscalls/syscall_64.tbl 经 scripts/syscalltbl.sh 生成:

# arch/x86/entry/syscalls/syscall_64.tbl —— 节选
0	common	read			sys_read
1	common	write			sys_write
2	common	open			sys_open
...
257	common	openat			sys_openat
...

表里第三列是名字,第四列是 C 符号。SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count) 展开出 __x64_sys_write(const struct pt_regs *regs),从 regs 里取参数再调用 ksys_write()。这层包装是为了统一 ABI:无论 32 位兼容、x32 还是 64 位,C 实现只写一份。

定义 3.3.2 · 系统调用号与返回值编码

系统调用号在 rax 里传入(进入内核后保存在 orig_ax),参数依次放 rdi、rsi、rdx、r10、r8、r9——注意第四个是 r10 而不是 rcx,因为 syscall 指令用 rcx 存返回地址。

返回值放 rax。内核约定:返回值落在 [-4095, -1] 区间表示错误,其绝对值是 errno。glibc 的包装函数检查这个区间,转成 -1 并设置 errno。所以内核里 return -ENOMEM; 是正确的,return -1; 是 bug。

3.3.6 自己加一个系统调用:三处改动

实验价值最高的一节。目标:加一个 demo_add(int a, int b),返回两数之和。总共改三处、重新编译、在虚机里用 syscall() 调它。

第一处,实现函数。放在 kernel/sys.c 末尾(任何 built-in 文件都行):

/* kernel/sys.c 末尾追加 —— 6.8 */
SYSCALL_DEFINE2(demo_add, int, a, int, b)
{
	if (a > 1000000 || b > 1000000)
		return -EINVAL;
	pr_info("demo_add: %d + %d\n", a, b);
	return a + b;
}

第二处,登记号。在 arch/x86/entry/syscalls/syscall_64.tbl 里挑一个 6.8 未使用的号(例如 462),加一行:

462	common	demo_add		sys_demo_add

第三处,声明原型(可选但推荐)。include/linux/syscalls.h 末尾加:

asmlinkage long sys_demo_add(int a, int b);

然后 make -j"$(nproc)"。注意两个细节:

  • 表的最后一行之后,__NR_syscalls 会自动变成「最大号 + 1」,中间的洞由 sys_ni_syscall 填。所以你不需要连续编号。
  • syscall_64.tbl 里 common 表示 64 位与 x32 共用;64 只给 64 位;x32 只给 x32。写错列会导致 32 位兼容层拿到错误的号。

用户态程序不需要 glibc 的包装(它不认识 462),直接用 syscall(2):

/* call_demo_add.c — 在虚机内编译:cc -O2 -Wall -o call_demo_add call_demo_add.c */
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>

#define __NR_demo_add 462   /* 与 syscall_64.tbl 中一致 */

int main(void)
{
	long r = syscall(__NR_demo_add, 3, 4);
	if (r == -1) {
		fprintf(stderr, "demo_add failed: %s\n", strerror(errno));
		return 1;
	}
	printf("demo_add(3, 4) = %ld\n", r);

	r = syscall(__NR_demo_add, 2000000, 1);   /* 触发 -EINVAL */
	printf("demo_add(2000000, 1) = %ld, errno = %s\n", r, strerror(errno));
	return 0;
}

预期输出 demo_add(3, 4) = 7 与 -1, errno = Invalid argument,dmesg 里有一行 demo_add: 3 + 4。

新增系统调用的五个坑
  1. 「号随便挑」:挑到已用的号会静默覆盖别人的实现,后果不可预测。查 unistd_64.h 或 syscall_64.tbl 的最大号再加一。
  2. 「忘了 make syscall_64.tbl 之外的生成物」:改了 tbl 必须全量 make,arch/x86/include/generated/asm/syscalls_64.h 才会重生成。增量编译有时不触发,make clean 最稳。
  3. 「参数直接当指针用」:用户传来的指针必须 copy_from_user(),不能解引用。忘了就是提权漏洞。
  4. 「返回 long 就够」:32 位兼容程序走 compat_sys_* 路径,返回值会被截断。要兼容就同时提供 COMPAT_SYSCALL_DEFINE。
  5. 「加系统调用是常规做法」:上游极不鼓励。能用 ioctl、sysfs、netlink、eBPF 解决的,不要加号——号是全局稀缺资源,一旦分配就不能回收。

3.3.7 ptrace:把系统调用变成可观测的

strace 不神秘:它用 ptrace(PTRACE_SYSCALL) 让子进程在每次系统调用进入和退出时停下来,父进程读寄存器、打印、再放行。

ptrace 的核心请求只有几个:

请求作用
PTRACE_TRACEME子进程声明「我愿意被父进程跟踪」
PTRACE_SYSCALL放行到下一个系统调用边界(进入或退出)
PTRACE_GETREGS读 user_regs_struct,含 orig_rax(调用号)与 rax(返回值)
PTRACE_PEEKDATA / PTRACE_POKEDATA读写被跟踪进程的内存(按字)
PTRACE_SETOPTIONS打开 PTRACE_O_TRACESYSGOOD 等行为开关
PTRACE_CONT / PTRACE_DETACH继续执行 / 解除跟踪

PTRACE_O_TRACESYSGOOD 很实用:打开后,系统调用停点的信号值变成 SIGTRAP|0x80,可以和真正的 SIGTRAP(断点)区分开。

下面这个 60 行的迷你 strace 只打印调用号与返回值,但已经具备完整骨架:

/* ministrace.c — 用法: ./ministrace /bin/echo hello
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -O2 -o ministrace ministrace.c
 */
#define _GNU_SOURCE
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/user.h>
#include <sys/wait.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
	if (argc < 2) {
		fprintf(stderr, "usage: %s PROG [ARGS...]\n", argv[0]);
		return 1;
	}

	pid_t pid = fork();
	if (pid == -1) { perror("fork"); return 1; }

	if (pid == 0) {
		/* 子进程:声明被跟踪,停住等父进程准备好 */
		if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) == -1) {
			perror("PTRACE_TRACEME");
			_exit(1);
		}
		raise(SIGSTOP);
		execvp(argv[1], &argv[1]);
		perror("execvp");
		_exit(127);
	}

	int status;
	if (waitpid(pid, &status, 0) == -1) { perror("waitpid"); return 1; }
	if (!WIFSTOPPED(status)) { fprintf(stderr, "child not stopped\n"); return 1; }

	/* 让系统调用停点带上 0x80 标记,便于与 SIGTRAP 区分 */
	ptrace(PTRACE_SETOPTIONS, pid, 0, (void *)PTRACE_O_TRACESYSGOOD);

	int entering = 1;
	for (;;) {
		if (ptrace(PTRACE_SYSCALL, pid, 0, 0) == -1) {
			if (errno == ESRCH) break;
			perror("PTRACE_SYSCALL");
			break;
		}
		if (waitpid(pid, &status, 0) == -1) break;
		if (WIFEXITED(status) || WIFSIGNALED(status)) break;
		if (!WIFSTOPPED(status)) continue;

		int sig = WSTOPSIG(status);
		if (sig != (SIGTRAP | 0x80)) {         /* 不是系统调用停点,原样放行 */
			ptrace(PTRACE_SYSCALL, pid, 0, (void *)(long)sig);
			continue;
		}

		struct user_regs_struct regs;
		if (ptrace(PTRACE_GETREGS, pid, 0, &regs) == -1) break;
		if (entering)
			printf("enter syscall %-3lld\n", (long long)regs.orig_rax);
		else
			printf("exit  syscall %-3lld -> %lld\n",
			       (long long)regs.orig_rax, (long long)regs.rax);
		entering = !entering;
	}

	printf("child done, status=0x%x\n", status);
	return 0;
}

运行 ./ministrace /bin/echo hi,会先看到一长串 execve、brk、mmap、openat 的进出对,最后是 write 与 exit_group。把 regs.orig_rax 映射成名字,再解出参数字符串,就是 strace 的全部。

ptrace 的代价与替代

被 ptrace 的进程每次系统调用要停两次,性能下降一个数量级;PTRACE_SYSCALL 还会让内核走慢路径。生产环境的系统调用观测更适合用 perf trace、bpftrace 的 tracepoint:syscalls:sys_enter_*,或 seccomp 的审计日志。ptrace 的主场是调试器与沙箱(容器运行时用它注入 seccomp)。

3.3.8 中断亲和性与观测

多核机器上,中断默认由 IOAPIC 按轮询或固定优先级分发,可能全打到 CPU 0。/proc/interrupts 的列不均就是信号。调整手段:

# 1. 看某个中断当前允许哪些 CPU 处理(掩码,十六进制)
cat /proc/irq/24/smp_affinity        # 例如 00000003 表示 CPU0/CPU1

# 2. 把 24 号中断固定到 CPU2(bit2 = 0x4)
echo 4 | sudo tee /proc/irq/24/smp_affinity

# 3. 看软中断在各 CPU 的分布(NET_RX 是否压在一个核上)
grep -E 'NET_RX|NET_TX|BLOCK|TASKLET' /proc/softirqs

# 4. 看每个 CPU 的中断/软中断时间占比
mpstat -I SUM -P ALL 1 3

驱动侧也可以在 request_irq() 后用 irq_set_affinity_hint() 给一个建议掩码,但最终仍受 irqs_disabled 与 CONFIG_GENERIC_IRQ_MIGRATION 约束。不要把中断赶到正在跑实时任务的核上:isolcpus= 隔离出来的 CPU 默认不接收普通中断,这既是实时性的前提,也是「为什么我的网卡只用了一个核」的常见原因。

例 3.3.1 · 一次中断风暴的排查

现象:一台机器 %si(软中断)常年 40%,ssh 延迟抖动,/proc/interrupts 里某个 PCI-MSI 行的 CPU0 列每秒增长几十万。

三步定位:① watch -n1 'grep virtio /proc/interrupts' 确认是哪个设备;② cat /proc/irq/N/smp_affinity 发现掩码是 1(只在 CPU0);③ ethtool -S eth0 | grep -i drop 看到 rx_dropped 增长。根因是单队列网卡 + 中断全在 CPU0,收包软中断也全在 CPU0。

处理:ethtool -L eth0 combined 4 开多队列(若驱动支持),或 echo f > /proc/irq/N/smp_affinity 把中断分散;再看 RPS(/sys/class/net/eth0/queues/rx-0/rps_cpus)是否该打开。这类问题的本质不是「中断太多」,而是「中断与软中断都挤在一个核上」。

3.3.10 门描述符的位域:IDT 表项到底怎么编码

这一节回答:一个 IDT 表项的 16 个字节分别是什么、type_attr 的每一位什么意思、IST 索引怎么用、内核用什么函数装门。

3.3.2 给了 IDT 的向量分配。真正调试 #GP/#DF 或写一个自定义门时,需要看懂表项的位域。

16 字节的布局

x86-64 的 IDT 门描述符(gate_desc)是两个 64 位字:

位字段含义
0–15offset_low处理函数地址的低 16 位
16–31segment代码段选择子(内核通常是 __KERNEL_CS,即 0x10)
32–34istIST 栈索引(0 表示不用 IST)
35–39保留必须为 0
40–43type门类型:0xE 中断门、0xF 陷阱门
44s必须为 0(系统段)
45–46dpl描述符特权级(0 内核,3 用户可 int)
47p存在位
48–63offset_mid地址的 16–31 位
64–95offset_high地址的 32–63 位
96–127保留0

内核里的对应结构:

/* arch/x86/include/asm/desc_defs.h —— 6.8 节选 */
struct idt_bits {
	u16	ist	: 3,
		zero	: 5,
		type	: 5,
		dpl	: 2,
		p	: 1;
} __attribute__((packed));

struct gate_struct {
	u16		offset_low;
	u16		segment;
	struct idt_bits	bits;
	u16		offset_middle;
	u32		offset_high;
	u32		reserved;
} __attribute__((packed));

type 的 5 位里,低 4 位是门类型,最高位是 S(0 表示系统描述符)。0xE 展开成 0b01110:类型 1110、S=0;加上 DPL 与 P 后,内核中断门的 type_attr 通常是 0x8E(P=1、DPL=0、type=0xE),用户态可用的 int3 是 0xEE(DPL=3)。

装门的三个函数

/* arch/x86/include/asm/desc.h —— 6.8 节选 */
static inline void set_intr_gate(unsigned int n, const void *addr)
{
	BUG_ON((unsigned)n > 255);
	_set_gate(n, GATE_INTERRUPT, addr, 0, 0, __KERNEL_CS);
}

static inline void set_system_intr_gate(unsigned int n, const void *addr)
{
	BUG_ON((unsigned)n > 255);
	_set_gate(n, GATE_INTERRUPT, addr, 0x3, 0, __KERNEL_CS);
}

static inline void set_intr_gate_ist(unsigned int n, const void *addr,
				     unsigned int ist_index)
{
	BUG_ON((unsigned)n > 255);
	_set_gate(n, GATE_INTERRUPT, addr, 0, ist_index, __KERNEL_CS);
}
函数DPLIST用途
set_intr_gate00普通异常/中断
set_system_intr_gate30int3、int 0x80
set_intr_gate_ist0指定#DF、NMI、#MC、#DB
set_system_trap_gate30用户态 int 陷阱门

idt_setup_from_table() 批量安装:

/* arch/x86/kernel/idt.c —— 6.8 节选 */
static const __initconst struct idt_data def_idts[] = {
	INTG(X86_TRAP_DE,		asm_exc_divide_error),
	INTG(X86_TRAP_DB,		asm_exc_debug),
	INTG(X86_TRAP_NMI,		asm_exc_nmi),
	INTG(X86_TRAP_BR,		asm_exc_bounds),
	INTG(X86_TRAP_UD,		asm_exc_invalid_op),
	INTG(X86_TRAP_DF,		asm_exc_double_fault),
	...
};

INTG 是「中断门、DPL 0、IST 0」的简写;ISTG 带 IST 索引;SYSG 是 DPL 3。asm_exc_* 是汇编桩,最终 call exc_*(C 函数)。

IST:为什么异常需要独立栈

#DF(双重故障)、NMI、#MC、#DB 可能在当前栈已经不可用时发生:

  • #DF 是「处理一个异常时又发生异常」,此时原栈可能已经溢出或损坏;
  • NMI 可以在任何时刻到来,包括正在切换栈的瞬间;
  • #MC 是硬件报告不可恢复错误,栈内容可能已不可信;
  • #DB 需要保存调试状态,不能被用户栈污染。

IST 的做法是在 TSS 里登记几张独立栈,门描述符的 ist 字段选一张。CPU 进入该门时自动切到 IST 栈,不依赖当前 RSP。6.8 的 IST 索引:

索引名称用于
0IST_INDEX_DF#DF
1IST_INDEX_NMINMI
2IST_INDEX_DB#DB
3IST_INDEX_MCE#MC

这些栈在 cpu_entry_area(CEA)里,每个 CPU 一份,地址固定且映射在用户页表之外(防止 Meltdown 类攻击读到)。cea_exception_stacks 的大小由 EXCEPTION_STKSZ 决定,通常是 8 KiB 或 16 KiB。

IDT 与门的五个坑
  1. 「DPL=3 的门用户态就能随便调」:还要看 IA32_LSTAR/syscall 的 MSR 配置和 seccomp;int3 虽然 DPL=3,但 #BP 的处理会检查 ptrace 与信号。
  2. 「IST 栈越大越好」:IST 栈在 CEA 里是稀缺资源,每 CPU 四张;调大意味着挤占其他映射,且栈溢出检测更难。
  3. 「type=0xF 与 0xE 只差中断是否关闭」:陷阱门不关 IF,中断门关;系统调用用陷阱门(或 syscall 指令),异常大多用中断门。
  4. 「IDT 可以动态扩容」:lidt 的基址与限长在启动时定;Linux 固定 256 项,不扩容。
  5. 「#GP 一定是权限问题」:段选择子越界、非规范地址、写只读段、wrmsr 非法值都会 #GP;看 error_code 与 RIP 才能定位。

3.3.11 中断子系统的对象:irq_desc、irq_data、irq_chip、irq_domain

这一节回答:一个硬件中断号怎么变成 Linux 的 virq、irq_desc 里为什么有两层 irq_data、MSI 与 GPIO 控制器怎么把自己的中断接到 APIC。

3.3.3 展示了从 IDT 到 action->handler() 的路径。中间那层「中断控制器抽象」是驱动开发者最常打交道的部分。

四个对象

设备/控制器(GPIO、PCI-MSI、IOAPIC)
   │  hwirq(控制器内的中断号)
   ▼
irq_domain(映射域)──► virq(Linux 全局中断号,即 /proc/interrupts 的编号)
   │
   ▼
irq_desc[virq](一条中断线的全局描述)
   ├── irq_data(本层)
   │     ├── chip ──► irq_chip(mask/unmask/ack/eoi/set_type)
   │     ├── domain ──► irq_domain
   │     └── parent_data ──► 上一级 irq_data(级联)
   └── action ──► irqaction 链表(request_irq 注册的处理函数)
对象关键字段谁维护
irq_deschandle_irq、action、status_use_accessors、kstat_irqs中断核心
irq_datairq、hwirq、chip、domain、chip_data、parent_data控制器驱动
irq_chipirq_mask、irq_unmask、irq_ack、irq_eoi、irq_set_type控制器驱动
irq_domainops、host_data、hwirq_max、revmap控制器驱动
irqactionhandler、thread_fn、flags、name、dev_idrequest_irq 调用者

irq_desc 在 6.8 用 struct irq_desc 的「大数组 + 分配」混合:前 NR_IRQS_LEGACY(16)个静态,其余动态分配。irq_to_desc(virq) 查表;desc->kstat_irqs 是 per-CPU 计数,/proc/interrupts 读的就是它。

irq_domain:hwirq → virq 的映射

现代 SoC 有几十个中断控制器,每个控制器有自己的编号空间。irq_domain 负责把「控制器内的号」翻译成「Linux 全局号」:

映射方式数据结构适用
线性(linear)数组 revmap[hwirq]hwirq 连续、范围不大(GPIO 常用)
基数树(radix)radix treehwirq 稀疏、范围大(MSI 常用)
无映射(nomap)不建表固定 1:1 映射

关键 API:

/* 控制器驱动侧 */
domain = irq_domain_add_linear(np, hwirq_max, &demo_domain_ops, priv);
virq = irq_create_mapping(domain, hwirq);
irq_set_chip_and_handler(virq, &demo_chip, handle_level_irq);

/* 中断到来时 */
virq = irq_find_mapping(domain, hwirq);
generic_handle_domain_irq(domain, hwirq);   /* 内部 find + generic_handle_irq */

irq_domain_ops 里最重要的是 xlate(解析 DT/ACPI 的中断描述)与 map(建立映射)。设备树里的 interrupts = <0 42 4> 经 irq_of_parse_and_map() 最终落到 xlate + map。

级联中断:parent_data

GPIO 控制器通常不是一个独立的中断控制器,而是「挂在父控制器的一条线上」:

GPIO 控制器(32 个引脚)──► 父中断控制器(GIC/IOAPIC)的第 42 号

irq_data.parent_data 指向父层的 irq_data。当 GPIO 中断到来时,父控制器先收到,处理函数再调 generic_handle_domain_irq(gpio_domain, pin) 分发到具体引脚。irq_chip 的 irq_mask/unmask 要负责「屏蔽引脚」还是「屏蔽父线」,取决于硬件设计;irq_set_type 则配置边沿/电平。

流控函数

irq_desc.handle_irq 指向流控函数,它决定「什么时候 mask、什么时候 ack、什么时候 eoi」:

流控函数触发方式行为
handle_edge_irq边沿ack 后处理;处理期间新边沿置 IRQ_PENDING,处理完再跑一次
handle_level_irq电平先 mask 再处理,处理完 unmask;防止同一电平重复触发
handle_fasteoi_irqMSI/电平处理完只发 EOI,不 mask(依赖控制器)
handle_percpu_irqper-CPU不拿 desc->lock,直接调 handler
handle_bad_irq未配置打印 nobody cared

nobody cared 是常见故障:中断来了但 action 链表为空,或所有 handler 都返回 IRQ_NONE。/proc/interrupts 里该行的计数会涨,dmesg 会有 irq 42: nobody cared (try booting with the "irqpoll" option)。

线程化中断

request_threaded_irq(irq, handler, thread_fn, flags, name, dev) 把处理拆成两段:handler 在硬中断上下文跑(必须快速返回 IRQ_WAKE_THREAD),thread_fn 在专用内核线程 irq/<irq>-<name> 里跑,可以睡眠。6.8 的 irq_thread 负责调度;IRQ_WAKE_THREAD 是两段之间的握手。GPIO 按键、触摸屏常用这种模式。

3.3.12 系统调用入口的寄存器级细节:MSR、swapgs 与返回路径

这一节回答:syscall 指令怎么找到入口、swapgs 为什么不能省、syscall_enter_from_user_mode() 在 C 入口前做了什么、返回用户态时内核要检查哪些「待办」。

3.3.5 走了一遍 entry_SYSCALL_64 → do_syscall_64 → sys_call_table。这里补三块:MSR 配置、GS 切换、退出路径。

三个 MSR

syscall/sysret 的行为由一组 MSR 配置,内核在 syscall_init()(arch/x86/kernel/cpu/common.c)里写:

MSR地址内容
MSR_STAR0xC0000081内核/用户代码段选择子(sysret 用)
MSR_LSTAR0xC000008264 位系统调用入口地址(entry_SYSCALL_64)
MSR_SFMASK0xC0000084进入内核时要清掉的 RFLAGS 位(IF、DF、TF 等)
MSR_EFER0xC0000080bit0 SCE 打开 syscall 指令

MSR_SFMASK 通常设为 X86_EFLAGS_IF | X86_EFLAGS_DF | X86_EFLAGS_TF | X86_EFLAGS_AC:进入内核自动关中断、清方向标志、关单步、关对齐检查。这意味着 syscall 入口的第一条指令运行时,中断已经是关的,不需要再 cli。

swapgs:用户 GS 与内核 GS

x86-64 的 GS 基址在用户态通常指向 TLS,在内核态指向 per-CPU 数据。swapgs 一条指令交换两者:

swapgs          /* 用户 GS ↔ 内核 GS;现在 GS 基址是 per-CPU */
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)   /* 保存用户栈 */
SWITCH_TO_KERNEL_CR3                            /* PTI:换内核页表 */
movq PER_CPU_VAR(pcpu_hot + X86_top_of_stack), %rsp

swapgs 不能省:内核要用 %gs:current_task 找 current、用 per-CPU 变量找栈。它也不能在不可信路径上执行两次,否则 GS 基址会错乱——2019 年的 swapgs 漏洞(CVE-2019-1125)就是利用「异常入口没正确 swapgs」泄露内核内存。

SWITCH_TO_KERNEL_CR3 是 KPTI(页表隔离)的开关:用户态页表里不映射内核大部分地址,进入内核要换 CR3。CONFIG_PAGE_TABLE_ISOLATION=y 时每次系统调用多两次 CR3 写,这是 Meltdown 缓解的性能代价。

进入 C 之前:syscall_enter_from_user_mode

汇编桩压好 pt_regs 后调 do_syscall_64(),后者第一件事是:

/* arch/x86/entry/common.c —— 6.8 节选,已简化 */
__visible noinstr void do_syscall_64(struct pt_regs *regs, int nr)
{
	struct thread_info *ti = current_thread_info();

	nr = syscall_enter_from_user_mode(regs, nr);
	...
}

syscall_enter_from_user_mode() 依次做:

步骤作用失败/拒绝时
context_tracking_enter()标记 CPU 进入内核(RCU 静止态)—
instrumentation_begin()允许插桩—
syscall_trace_enter()ptrace 停、seccomp 过滤、审计返回 -1 跳过系统调用
syscall_enter_from_user_mode_work()处理 TIF_* 待办—

seccomp 就在 syscall_trace_enter() 里过滤:seccomp_run_filters() 按 BPF 程序决定 SECCOMP_RET_ALLOW/KILL/ERRNO/TRACE/USER_NOTIF。如果返回 KILL,进程在系统调用真正执行前就被杀死;如果返回 ERRNO,do_syscall_64 直接把 -errno 写进 regs->ax,不调 sys_call_table。

退出:syscall_exit_to_user_mode

系统调用返回前,内核要检查「有没有事没做」:

/* arch/x86/entry/common.c —— 6.8 节选,已简化 */
static __always_inline void syscall_exit_to_user_mode(struct pt_regs *regs)
{
	...
	exit_to_user_mode_prepare(regs);
	...
}

exit_to_user_mode_prepare() 检查 _TIF_* 标志:

标志含义处理
_TIF_NEED_RESCHED该调度了schedule()
_TIF_SIGPENDING有信号待投递do_signal()
_TIF_NOTIFY_RESUME有延后工作(io_uring、rseq)resume_user_mode_work()
_TIF_NOTIFY_SIGNALtask_work 触发的信号同上
_TIF_SYSCALL_AUDIT审计退出audit_syscall_exit()
_TIF_PATCH_PENDINGlivepatch 待切换klp_update_patch_state()
_TIF_UPROBEuprobe 命中uprobe_notify_resume()

这张表解释了三个常见现象:

  • 系统调用「返回慢」:可能是 _TIF_NEED_RESCHED 触发了调度,或信号投递走了 do_signal();
  • io_uring 的完成事件在 syscall 返回时才处理:_TIF_NOTIFY_RESUME 里跑 task_work;
  • livepatch 的切换点:_TIF_PATCH_PENDING 让任务在返回用户态时换函数。

sysret 之前还要 swapgs 换回用户 GS、SWITCH_TO_USER_CR3 换回用户页表、检查 RCX/R11 是否合法(防止 sysret 漏洞)。6.8 的 entry_SYSCALL_64 末尾用 IBRS_EXIT/UNTRAIN_RET 等宏处理 Spectre 缓解,这些分支让汇编比教科书长得多。

系统调用入口的五个坑
  1. 「syscall 会自动保存所有寄存器」:只保存 RIP→RCX、RFLAGS→R11;其余寄存器由汇编桩压栈。C 函数看到的 pt_regs 是桩构造的。
  2. 「rax 返回值可以直接当 int」:内核返回值是 long,错误区间 [-4095,-1];用户态必须按 long 判断,截断成 int 可能把 -ENOMEM 当成合法值。
  3. 「int 0x80 与 syscall 等价」:int 0x80 走 32 位表、指针截断、性能差一个数量级;64 位程序不要用。
  4. 「seccomp 只过滤参数」:它过滤系统调用号与参数,返回 SECCOMP_RET_KILL 时进程直接死,连 errno 都没有。
  5. 「swapgs 只是优化」:它是 per-CPU 寻址的基础;入口/出口不配对会立刻 #GP 或读到错误 CPU 的数据。

3.3.13 缺页异常:error code 的每一位与信号映射

这一节回答:#PF 的 error code 每一位什么意思、内核怎么判断「这是用户态的正常缺页」还是「内核 bug」、为什么同一个空指针有时是 SIGSEGV 有时是 SIGBUS。

error code 的七位

#PF 压栈的 error code 不是「错误号」,而是七个独立的标志位:

位名称为 1 表示内核常量
0P页存在,是保护违规;为 0 表示页不存在X86_PF_PROT
1W写访问触发X86_PF_WRITE
2U用户态访问触发X86_PF_USER
3RSVD页表项保留位被置 1X86_PF_RSVD
4I/D取指触发(NX 违规)X86_PF_INSTR
5PK保护键(PKU)违规X86_PF_PK
6SS影子栈(CET)违规X86_PF_SHSTK
7SGXSGX 相关X86_PF_SGX

组合起来能读出很多信息:

  • P=0, U=1:用户态访问未映射地址,正常缺页或野指针;
  • P=1, W=1, U=1:写一个只读页(COW 或 mprotect),可能正常也可能 SIGSEGV;
  • P=1, W=1, U=0:内核写只读页,通常是内核 bug;
  • P=0, U=0:内核访问未映射地址,Oops;
  • RSVD=1:页表项损坏或硬件不支持,几乎总是 bug。

用户态缺页的路径

exc_page_fault(regs, error_code, address)
  → handle_page_fault()
      → 地址在内核空间?→ do_kern_addr_fault()(vmalloc 缺页 / ioremap / Oops)
      → 地址在用户空间?→ do_user_addr_fault()
          → faulthandler_disabled()? → 返回(atomic 上下文不能缺页)
          → mmap_read_lock(mm)
          → find_vma(mm, address)
              → 没有 VMA:bad_area → SIGSEGV / SEGV_MAPERR
          → 权限检查:access_error(error_code, vma)
              → 不匹配:bad_area_access_error → SIGSEGV / SEGV_ACCERR
          → handle_mm_fault(vma, address, flags, regs)
              → 正常:建立 PTE,返回 VM_FAULT_*
              → OOM:VM_FAULT_OOM → 杀死进程或返回 ENOMEM
              → 总线错误:VM_FAULT_SIGBUS → SIGBUS
          → mmap_read_unlock(mm)

do_user_addr_fault() 里有一个容易忽略的设计:它先 find_vma(),再检查 vma->vm_start <= address。如果地址在 VMA 之前(vma->vm_start > address),说明是「洞」,直接 bad_area;如果地址在 VMA 内但权限不对,走 bad_area_access_error。两者都发 SIGSEGV,但 si_code 不同:前者 SEGV_MAPERR,后者 SEGV_ACCERR。

信号与 si_code

异常默认信号常见 si_code典型原因
#PF 用户态SIGSEGVSEGV_MAPERR(1)空指针、越界、未映射
#PF 权限SIGSEGVSEGV_ACCERR(2)写只读页、执行 NX 页
#PF 保护键SIGSEGVSEGV_PKUERR(4)PKU 限制
#PF 总线错误SIGBUSBUS_ADRERR(2)mmap 文件被截断、设备移除
#DE 除零SIGFPEFPE_INTDIV(1)整数除零
#OF 溢出SIGSEGVSEGV_ACCERR?into 指令(32 位)
#UD 非法指令SIGILLILL_ILLOPC(1)执行数据、CPU 不支持
#BP 断点SIGTRAPTRAP_BRKPT(1)int3
#AC 对齐检查SIGBUSBUS_ADRALN(1)未对齐访问(开了 AC)
#MC 机器检查SIGBUSBUS_MCEERR_AR(4)硬件内存错误

si_code > 0 是硬件/内核填的,si_code <= 0 是 kill()/raise() 发的。调试时先看 si_code:SEGV_MAPERR 说明地址根本没映射,SEGV_ACCERR 说明映射了但权限不对,两者的修法完全不同。

为什么 mmap 读越界是 SIGBUS 而不是 SIGSEGV

mmap 一个文件后,如果文件被 truncate() 变小,进程再读超出新文件大小的页,页缓存里没有数据、文件系统也不允许读——内核返回 VM_FAULT_SIGBUS,进程收到 SIGBUS。这不是「地址没映射」(VMA 还在),而是「映射背后的存储不在了」,所以用 BUS_ADRERR 而不是 SEGV_MAPERR。

同样地,访问 mmap 的设备内存时设备被热拔,也会得到 SIGBUS。判断方法:

# 看信号与 si_code
strace -f -e trace=none ./program 2>&1 | tail
# 或在程序里装 SIGSEGV/SIGBUS handler,打印 siginfo_t
异常与信号的五个坑
  1. 「空指针解引用一定 SIGSEGV」:如果地址落在 mmap_min_addr 之上的某个映射里,可能是合法访问;内核 6.8 默认 mmap_min_addr=65536,NULL 页不会被映射,所以空指针仍会 SIGSEGV。
  2. 「SIGSEGV 就是段错误」:它也可能是栈溢出、保护键、执行 NX 页;看 si_code 与 si_addr。
  3. 「内核缺页可以随便睡眠」:在 atomic 上下文或持有 spinlock 时缺页,faulthandler_disabled() 返回真,内核直接 Oops。
  4. 「mprotect(PROT_NONE) 后访问是 SIGSEGV」:是,但 si_code 是 SEGV_ACCERR;如果页根本不在 VMA 里才是 SEGV_MAPERR。
  5. 「SIGBUS 只来自硬件」:文件截断、mmap 超出 EOF、设备移除都会产生 SIGBUS,与硬件无关。

3.3.14 上下文的判定:preempt_count 的四个字段

这一节回答:in_interrupt() 到底检查什么、local_bh_disable() 为什么加 2 而不是 1、might_sleep() 怎么知道「现在不能睡」。

3.3.1 说「中断上下文不能睡眠」。内核不是靠约定,而是靠 preempt_count 这个 per-task 整数来判定。

位域

6.8 的 preempt_count 是 32 位,分成四段:

位字段宽度含义
0–7PREEMPT8抢占禁用计数(preempt_disable())
8–15SOFTIRQ8软中断禁用计数(local_bh_disable())
16–19HARDIRQ4硬中断嵌套计数(irq_enter())
20–23NMI4NMI 嵌套计数
31PREEMPT_NEED_RESCHED1有调度请求但被抢占禁用挡住

对应常量:

/* include/linux/preempt.h —— 6.8 节选 */
#define PREEMPT_BITS	8
#define SOFTIRQ_BITS	8
#define HARDIRQ_BITS	4
#define NMI_BITS	4

#define PREEMPT_SHIFT	0
#define SOFTIRQ_SHIFT	(PREEMPT_SHIFT + PREEMPT_BITS)
#define HARDIRQ_SHIFT	(SOFTIRQ_SHIFT + SOFTIRQ_BITS)
#define NMI_SHIFT	(HARDIRQ_SHIFT + HARDIRQ_BITS)

#define SOFTIRQ_OFFSET	(1 << SOFTIRQ_SHIFT)
#define HARDIRQ_OFFSET	(1 << HARDIRQ_SHIFT)
#define NMI_OFFSET	(1 << NMI_SHIFT)
#define SOFTIRQ_DISABLE_OFFSET	(2 * SOFTIRQ_OFFSET)

local_bh_disable() 加的是 2 * SOFTIRQ_OFFSET,不是 1 倍。原因是 SOFTIRQ_OFFSET 的最低位被 local_bh_enable() 用来标记「有 pending softirq 需要处理」:

  • preempt_count & SOFTIRQ_MASK == SOFTIRQ_OFFSET:禁用中,无 pending;
  • == 2 * SOFTIRQ_OFFSET:禁用中,有 pending;
  • local_bh_enable() 先减 SOFTIRQ_DISABLE_OFFSET,再看最低位决定是否 do_softirq()。

判定宏

宏判定为真意味着
in_irq()hardirq_count()正在硬中断处理(含嵌套)
in_softirq()softirq_count()软中断上下文或 local_bh_disable() 区间
in_interrupt()irq_count()以上任一(含 NMI)
in_nmi()nmi_count()NMI 上下文
in_task()!in_interrupt()进程上下文
in_atomic()preempt_count() != 0 || irqs_disabled()不能睡眠
in_atomic_preempt_off()抢占被关调度器内部用

in_interrupt() 不检查 PREEMPT 字段:preempt_disable() 只是禁止抢占,仍在进程上下文,可以睡眠(虽然不推荐长时间睡眠)。in_atomic() 才把「抢占禁用」与「中断禁用」都算进去。

路径:irq_enter() 与 irq_exit()

/* kernel/softirq.c —— 6.8 节选,已简化 */
void irq_enter(void)
{
	rcu_irq_enter();
	__irq_enter_raw();
	...
}

static __always_inline void __irq_enter_raw(void)
{
	preempt_count_add(HARDIRQ_OFFSET);
	...
}

void irq_exit(void)
{
	...
	__irq_exit_rcu();
	...
}

static inline void __irq_exit_rcu(void)
{
	preempt_count_sub(HARDIRQ_OFFSET);
	if (!in_interrupt() && local_softirq_pending())
		invoke_softirq();
	...
}

顺序很关键:preempt_count_sub(HARDIRQ_OFFSET) 之后才检查 local_softirq_pending()。如果先检查,in_interrupt() 仍为真,软中断永远不会在中断返回时执行。

invoke_softirq() 有两条路:

条件行为
!force_irqthreads()直接 __do_softirq(),在中断返回路径上跑
force_irqthreads() 或超时wakeup_softirqd(),交给 ksoftirqd/N

__do_softirq() 最多跑 MAX_SOFTIRQ_TIME(2 ms)或 10 轮,超过就唤醒 ksoftirqd,把控制权还给用户态。这就是「软中断不会无限拖住进程」的保证。

might_sleep() 的实现

might_sleep() 在 CONFIG_DEBUG_ATOMIC_SLEEP 下展开为 __might_sleep(file, line):

/* kernel/sched/core.c —— 6.8 节选,已简化 */
void __might_sleep(const char *file, int line)
{
	unsigned int state = get_current_state();
	...
	if (state && (state & TASK_NORMAL) && !in_atomic())
		return;
	...
	printk("BUG: sleeping function called from invalid context at %s:%d\n", file, line);
	printk("in_atomic(): %d, irqs_disabled(): %d, non_block: %d, pid: %d, name: %s\n",
	       in_atomic(), irqs_disabled(), current->non_block_count,
	       current->pid, current->comm);
	dump_stack();
}

它检查三件事:当前是否处于可睡眠状态、in_atomic() 是否为真、irqs_disabled() 是否为真。三者任一不满足就打印 BUG: sleeping function called from invalid context,并 dump_stack()。这是排查「在 spinlock 里 kmalloc(GFP_KERNEL)」的最快手段。

想做的事允许的上下文
kmalloc(GFP_KERNEL)进程上下文
kmalloc(GFP_ATOMIC)任意(不能睡眠)
mutex_lock()进程上下文
spin_lock()任意(临界区不能睡眠)
copy_to_user()进程上下文(可能缺页)
msleep()进程上下文
udelay()任意(忙等,短)
request_irq()进程上下文
上下文的五个坑
  1. 「in_interrupt() 为假就能睡眠」:还要看 irqs_disabled() 与是否持有 spinlock;in_atomic() 更严格。
  2. 「local_bh_disable() 会关硬中断」:不会,只关本 CPU 的软中断;硬中断仍可打断,所以共享数据仍需 spin_lock_irqsave()。
  3. 「preempt_disable() 可以嵌套任意层」:8 位计数,溢出会 wrap;虽然实际很难到 255,但泄漏会让 preempt_count 永久非 0,调度器报 scheduling while atomic。
  4. 「软中断会在每个 CPU 上并行执行同一个 vector」:会;NET_RX_SOFTIRQ 在多核上并行,所以网络代码要用 per-CPU 或锁保护。
  5. 「ksoftirqd 是普通内核线程,可以随便阻塞」:它负责收尾,阻塞会导致软中断积压、网络丢包。

3.3.15 观测:中断、软中断与系统调用的三条证据链

这一节回答:怎么确认中断真的落在某个 CPU、软中断有没有积压、系统调用慢在入口还是退出。

/proc/interrupts 与 /proc/softirqs 的读法

# 每秒看一次增量(watch 会清屏,脚本更适合算差值)
awk '/virtio0-input/ {print}' /proc/interrupts
cat /proc/softirqs

/proc/interrupts 的列是 CPU,行是 virq;/proc/softirqs 的行是 softirq vector(HI、TIMER、NET_TX、NET_RX、BLOCK、TASKLET、SCHED、HRTIMER、RCU)。判断规则:

  • 某一 CPU 的某行增长远快于其他 CPU:中断亲和性集中,调 /proc/irq/N/smp_affinity;
  • NET_RX 在所有 CPU 上均匀增长:RPS/RSS 生效;
  • SCHED 增长快:IPI 重调度多,常见于线程数远超核数的场景;
  • RCU 增长快:RCU 回调多,结合 rcu_preempt 线程看。

更细的每中断信息在 debugfs:

ls /sys/kernel/debug/irq/irqs/ | head
cat /sys/kernel/debug/irq/irqs/24
# handler:  handle_edge_irq
# device:   0000:00:04.0
# status:   0x00000000
# ist:      0
# affinity: 0-1
# effect:   {edge}
# domain:   PCI-MSI

handler 是流控函数,affinity 是允许的 CPU,domain 是映射域。中断不触发时,先确认 status 是否 IRQ_DISABLED,再看设备是否真的在发中断(lspci -vv 的 MSI: Enable+)。

tracepoint:irq 与 softirq

# 每个中断的触发次数(按名字)
sudo bpftrace -e '
tracepoint:irq:irq_handler_entry { @[args->name] = count(); }
interval:s:5 { print(@); clear(@); }'
# 中断处理耗时直方图(微秒),按 virq 分组
sudo bpftrace -e '
tracepoint:irq:irq_handler_entry { @s[args->irq] = nsecs; }
tracepoint:irq:irq_handler_exit /@s[args->irq]/ {
	@us[args->irq] = hist((nsecs - @s[args->irq]) / 1000);
	delete(@s[args->irq]);
}'

注意 irq_handler_entry/exit 包住的是 action->handler(),不含流控函数与软中断;要看总时间,用 irq:softirq_entry/exit 或 irq_handler_entry 到 irq_handler_exit 之外的 kprobe。

# 软中断各 vector 的次数
sudo bpftrace -e '
tracepoint:irq:softirq_entry { @[args->vec] = count(); }
interval:s:5 { print(@); clear(@); }'

args->vec 是数字,0=HI、1=TIMER、2=NET_TX、3=NET_RX、4=BLOCK、5=IRQ_POLL、6=TASKLET、7=SCHED、8=HRTIMER、9=RCU。

系统调用:入口、退出与阻塞点

# 按进程统计系统调用次数
sudo bpftrace -e '
tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }
interval:s:5 { print(@); clear(@); }'
# 单次系统调用的总耗时(从进入到退出),只报超过 1 ms 的
sudo bpftrace -e '
tracepoint:raw_syscalls:sys_enter { @s[tid] = nsecs; }
tracepoint:raw_syscalls:sys_exit /@s[tid]/ {
	$us = (nsecs - @s[tid]) / 1000;
	if ($us > 1000)
		printf("pid=%d comm=%s nr=%d %d us\n", pid, comm, args->id, $us);
	delete(@s[tid]);
}'

raw_syscalls:sys_exit 的 args->id 是系统调用号,args->ret 是返回值。要还原名字,用 bpftrace -l 'tracepoint:syscalls:sys_enter_*' 找具体调用,或查 syscall_64.tbl。

perf 侧:

# 系统调用耗时排行(需要 debugfs 与符号)
sudo perf trace -s --duration 5
# 中断/软中断事件计数
sudo perf stat -e 'irq:irq_handler_entry,irq:softirq_entry' -a sleep 5
中断问题的排查顺序
  1. /proc/interrupts 看该行是否增长——不增长说明设备没发中断,查设备/驱动。
  2. 增长但业务没进展——看 dmesg 的 nobody cared,检查 request_irq 的 flags 与共享。
  3. 增长且集中在一个 CPU——看 smp_affinity 与 irqbalance。
  4. 处理时间长——用 irq_handler 直方图找具体 virq,再看驱动 handler 是否做了重活。
  5. 软中断积压——看 ksoftirqd 的 CPU 占用与 /proc/softirqs 的 NET_RX。

3.3.16 实验:量系统调用的代价,看硬中断的上下文

这一节给两个可运行实验:一个用户态程序比较 syscall、int 0x80、vDSO 的耗时;一个内核模块用 hrtimer 展示硬中断上下文。均在 QEMU(Linux 6.8,x86-64)验证流程,未在真机实测。

实验一:syscall 到底有多贵

/* syscall_cost.c — 比较 syscall / int 0x80 / vDSO
 * 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -o syscall_cost syscall_cost.c
 */
#define _GNU_SOURCE
#include <stdint.h>
#include <stdio.h>
#include <sys/syscall.h>
#include <time.h>
#include <unistd.h>

#define N 1000000

static inline uint64_t now_ns(void)
{
	struct timespec ts;

	clock_gettime(CLOCK_MONOTONIC, &ts);
	return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec;
}

/* 32 位 ABI:eax=20 是 i386 的 getpid */
static long int80_getpid(void)
{
	long ret;

	asm volatile("int $0x80" : "=a"(ret) : "a"(20) : "memory");
	return ret;
}

int main(void)
{
	volatile long sink = 0;
	uint64_t t0, t1;

	t0 = now_ns();
	for (int i = 0; i < N; i++)
		sink += syscall(SYS_getpid);
	t1 = now_ns();
	printf("syscall getpid : %6.1f ns/call\n", (double)(t1 - t0) / N);

	t0 = now_ns();
	for (int i = 0; i < N; i++)
		sink += int80_getpid();
	t1 = now_ns();
	printf("int 0x80 getpid: %6.1f ns/call\n", (double)(t1 - t0) / N);

	t0 = now_ns();
	for (int i = 0; i < N; i++) {
		struct timespec ts;

		clock_gettime(CLOCK_MONOTONIC, &ts);
		sink += ts.tv_nsec;
	}
	t1 = now_ns();
	printf("vDSO clock_gettime: %6.1f ns/call\n", (double)(t1 - t0) / N);

	printf("sink=%ld\n", sink);   /* 防止循环被优化掉 */
	return 0;
}

运行与预期(QEMU + 2 vCPU,数字随宿主变化):

syscall getpid :  420.3 ns/call
int 0x80 getpid: 1180.7 ns/call
vDSO clock_gettime:   18.4 ns/call

三个数量级说明三件事:

  • syscall 一次约 300–600 ns,主要是 swapgs、CR3 切换(KPTI)、入口/出口的寄存器保存;
  • int 0x80 慢 2–3 倍:走 32 位兼容表、额外的段检查、且不能享受 sysret 的快路径;
  • vDSO 完全不进内核:clock_gettime 读共享内存页,约 20 ns。

这也解释了为什么高频计时用 clock_gettime(CLOCK_MONOTONIC) 而不是 gettimeofday 系统调用,以及为什么 getpid() 在 glibc 里被缓存(POSIX 允许)——它曾经是每次都要进内核的。

实验二:hrtimer 里看硬中断上下文

// irq_ctx.c — 硬中断与 workqueue 的上下文对比(Linux 6.8)
#include <linux/hrtimer.h>
#include <linux/init.h>
#include <linux/interrupt.h>
#include <linux/ktime.h>
#include <linux/module.h>
#include <linux/workqueue.h>

static struct hrtimer demo_timer;
static struct work_struct demo_work;
static struct workqueue_struct *demo_wq;

static enum hrtimer_restart demo_tick(struct hrtimer *t)
{
	pr_info("hrtimer: in_irq=%d in_softirq=%d in_interrupt=%d pc=%#x\n",
		in_irq(), in_softirq(), in_interrupt(), preempt_count());
	queue_work(demo_wq, &demo_work);
	hrtimer_forward_now(t, ms_to_ktime(1000));
	return HRTIMER_RESTART;
}

static void demo_work_fn(struct work_struct *w)
{
	pr_info("work: in_interrupt=%d in_atomic=%d pid=%d\n",
		in_interrupt(), in_atomic(), current->pid);
}

static int __init irq_ctx_init(void)
{
	demo_wq = alloc_workqueue("irq_ctx", WQ_UNBOUND, 0);
	if (!demo_wq)
		return -ENOMEM;
	INIT_WORK(&demo_work, demo_work_fn);
	hrtimer_init(&demo_timer, CLOCK_MONOTONIC, HRTIMER_MODE_REL);
	demo_timer.function = demo_tick;
	hrtimer_start(&demo_timer, ms_to_ktime(1000), HRTIMER_MODE_REL);
	pr_info("irq_ctx: timer started\n");
	return 0;
}

static void __exit irq_ctx_exit(void)
{
	hrtimer_cancel(&demo_timer);
	cancel_work_sync(&demo_work);
	destroy_workqueue(demo_wq);
	pr_info("irq_ctx: stopped\n");
}

module_init(irq_ctx_init);
module_exit(irq_ctx_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("hrtimer hardirq context demo");

加载后每秒一行:

hrtimer: in_irq=1 in_softirq=0 in_interrupt=1 pc=0x10000
work: in_interrupt=0 in_atomic=0 pid=87

pc=0x10000 就是 HARDIRQ_OFFSET(bit 16)。在 demo_tick() 里加 msleep(10),会得到 BUG: scheduling while atomic: .../0x00010000——这正是 3.3.14 那张表的实证。work 那行 in_atomic=0 说明它在进程上下文,可以睡眠、可以拿 mutex。

实验的四个坑
  1. 「int 0x80 在 64 位程序里一定失败」:getpid 这类无指针参数的系统调用能成功;带指针的会因截断而 -EFAULT。
  2. 「计时循环不会被优化」:必须把结果累加到 volatile 变量,否则 -O2 会把整个循环删掉。
  3. 「hrtimer 回调一定在硬中断」:默认是,但 HRTIMER_MODE_SOFT 会推到软中断;hrtimer_start 的 mode 决定上下文。
  4. 「in_atomic() 在 workqueue 里一定为 0」:如果 work 里拿了 spinlock,preempt_disable() 会让它变 1;判断「能否睡眠」要看具体持有什么锁。

3.3.17 陷阱清单:中断、异常与系统调用篇

#现象原因修复
1BUG: scheduling while atomic硬中断/软中断/持锁区间睡眠移到 workqueue/threaded IRQ
2nobody caredrequest_irq 的 handler 返回 IRQ_NONE,或 flags 与硬件不符检查共享/触发类型;读设备状态寄存器
3中断只打在一个 CPUsmp_affinity 或 irqbalance 配置写 /proc/irq/N/smp_affinity
4软中断积压,ksoftirqd 满收包速率超过处理能力调大 ring、RPS、多队列
5int 0x80 参数 -EFAULT32 位 ABI 截断指针64 位程序改用 syscall
6新系统调用号冲突挑了已用的号查 syscall_64.tbl 最大号
7seccomp 杀死进程无 errnoSECCOMP_RET_KILL改 RET_ERRNO 或放宽过滤器
8缺页在 atomic 上下文 Oops持 spinlock/中断里访问用户页先 copy_from_user 到内核缓冲
9SIGBUS 而不是 SIGSEGVmmap 文件被截断/设备移除检查文件大小与设备状态
10preempt_count 永久非 0preempt_disable/local_bh_disable 泄漏配对检查;看 BUG: ... preemption imbalance
11共享中断处理函数不触发IRQF_SHARED 未设置,或 dev_id 不唯一加 IRQF_SHARED,dev_id 用设备指针
12线程化中断线程不跑上半部没返回 IRQ_WAKE_THREAD检查返回值;确认 thread_fn 非空

3.3.18 本章出口检查

检查项达标线没达标去哪
区分中断/异常/系统调用的返回位置说清 fault 重试、trap 跳过3.3.1
说出 IDT 三项关键字段段选择子、偏移、DPL/IST3.3.2
画出中断到驱动的路径至少含 APIC、IDT、irq_desc、irqaction3.3.3
选对下半部机制按「能否睡眠」给出四种选择3.3.4
说清 syscall 的寄存器约定号在 rax,参数 rdi/rsi/rdx/r10/r8/r93.3.5
独立加一个系统调用三处改动 + 用户态 syscall() 调通3.3.6
用 ptrace 观察系统调用能打印 enter/exit 对3.3.7
读出 IDT 门描述符的位域type/DPL/IST3.3.10
画出 hwirq→virq 的映射domain 与 irq_data3.3.11
说出 #PF error code 的七位P/W/U/RSVD/ID/PK/SS3.3.13
解释 preempt_count 四段0x10000 是什么3.3.14
即时自测 · 3.3

Q1 缺页异常处理后,CPU 返回到哪条指令?int3 处理后返回到哪条?为什么不同?

Q2 为什么 syscall 的第四个参数用 r10 而不是 rcx?r11 又为什么不能用?

Q3 一个驱动在硬中断处理函数里调用 kmalloc(GFP_KERNEL, ...),可能发生什么?正确的写法是什么?

Q4 tasklet_schedule() 与 queue_work() 之后,代码分别跑在哪个上下文?各举一个只能用后者的场景。

Q5 内核返回 -ENOMEM 时,用户态 syscall() 的返回值是什么?errno 是什么?如果内核错误地返回 -1 呢?

答案

A1 缺页是 fault,#PF 处理程序修好页表后 iretq 回到触发缺页的那条指令重新执行;int3 是 trap,返回地址是下一条指令。这是 CPU 在压栈时就决定的:fault 压的是当前 RIP,trap 压的是 RIP+指令长度。

A2 syscall 指令把返回地址存进 rcx、把 RFLAGS 存进 r11,这两个寄存器在进入内核的瞬间就被覆盖了,不能再用来传参。x86-64 的系统调用 ABI 因此把第 4 个参数放在 r10。

A3 GFP_KERNEL 允许睡眠等待内存,而硬中断上下文不允许调度,内核会打印 BUG: sleeping function called from invalid context(开了 CONFIG_DEBUG_ATOMIC_SLEEP 时),未开时可能死锁或损坏调度器状态。正确写法:上半部只 kmalloc(GFP_ATOMIC) 做最小分配,或把工作推到 workqueue/threaded IRQ 里用 GFP_KERNEL。

A4 tasklet 跑在软中断上下文(in_softirq()==1,不能睡眠);queue_work() 的工作由 kworker 在进程上下文执行(可以睡眠)。只能用后者的场景:需要读文件/发阻塞 I/O、需要拿 mutex、需要 copy_from_user()。

A5 用户态 syscall() 返回 -1,errno = ENOMEM(glibc 包装把 -ENOMEM 转成 -1 并设 errno)。如果内核返回 -1,glibc 会认为这是合法返回值 -1,不设置 errno,调用者无法区分成功与失败——这正是内核必须用 -Exxx 而不是 -1 的原因。

母本对应

母本章节本书落点程度
奔跑吧 Linux 内核第 5 章 5.1 Linux 中断管理机制3.3.2–3.3.3完整(IDT、irq_desc、流控)
奔跑吧 Linux 内核第 5 章 5.2 软中断和 tasklet3.3.4完整
奔跑吧 Linux 内核第 5 章 5.3 workqueue 工作队列3.3.4、3.3.4.1完整(cmwq/worker_pool/WQ flags)
李治军第 2 章 系统接口(实践项目 2 添加系统调用)3.3.5–3.3.6对照(0.11 的 int 0x80 与 6.8 的 syscall)
CSAPP第 8 章 8.1 异常3.3.1完整(四类异常的理论框架)
Kerrisk第 3 章 系统调用3.3.5、3.3.7精选(用户态视角的 ABI 与 syscall())

下一站:3.4 进程描述与创建 进入调度器的输入侧:task_struct 里到底存了什么、fork() 为什么能瞬间完成、kernel_clone() 的参数怎么变成一个新进程。