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 三个门:中断、异常、系统调用
- 中断(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_interrupt | asm_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–0x1f | CPU 异常(#DE、#PF、#GP、#DF…) | 中断门,DPL 0 |
0x03 | int3 断点 | 陷阱门,DPL 3(用户态可用) |
0x20–0x2f | 外部 IRQ(经 IOAPIC/APIC 重映射) | 中断门,DPL 0 |
0x80 | 32 位 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 一共四个索引。
- 「
int 0x80在 64 位程序里还能用」:能用,但走的是 32 位 ABI:系统调用号是 32 位的,指针被截断到 32 位,参数用ebx/ecx/edx/esi/edi/ebp传递。地址超过 4 GiB 的参数会-EFAULT。新代码一律用syscall。 - 「IDT 在用户态可改」:
lidt是特权指令。用户态只能通过int3/syscall触发已登记的门。 - 「门描述符里的 DPL 只影响权限检查」:它还决定
int n能否从用户态发起。DPL 0 的门被用户态int命中会变成#GP。 - 「异常号可以随便改」: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()。
- 处理需要睡眠(I/O、mutex、大块分配)?→ workqueue 或 threaded IRQ。
- 是网络/块层这类高频、极短的收尾?→ softirq(通常已由子系统提供,不自己加)。
- 需要「同一对象串行、不同对象并行」且不睡眠?→ tasklet(存量代码)。
- 中断本身很短、后续工作与设备强相关?→ 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 三个寄存器配置):
- 把
RIP存进RCX,把RFLAGS存进R11; - 按
MSR_SFMASK清标志位(清IF,关中断); - 从
MSR_LSTAR取入口地址,跳到entry_SYSCALL_64; - 切到内核栈(
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 实现只写一份。
系统调用号在 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。
- 「号随便挑」:挑到已用的号会静默覆盖别人的实现,后果不可预测。查
unistd_64.h或syscall_64.tbl的最大号再加一。 - 「忘了
make syscall_64.tbl之外的生成物」:改了 tbl 必须全量make,arch/x86/include/generated/asm/syscalls_64.h才会重生成。增量编译有时不触发,make clean最稳。 - 「参数直接当指针用」:用户传来的指针必须
copy_from_user(),不能解引用。忘了就是提权漏洞。 - 「返回
long就够」:32 位兼容程序走compat_sys_*路径,返回值会被截断。要兼容就同时提供COMPAT_SYSCALL_DEFINE。 - 「加系统调用是常规做法」:上游极不鼓励。能用
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, ®s) == -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_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 默认不接收普通中断,这既是实时性的前提,也是「为什么我的网卡只用了一个核」的常见原因。
现象:一台机器 %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–15 | offset_low | 处理函数地址的低 16 位 |
| 16–31 | segment | 代码段选择子(内核通常是 __KERNEL_CS,即 0x10) |
| 32–34 | ist | IST 栈索引(0 表示不用 IST) |
| 35–39 | 保留 | 必须为 0 |
| 40–43 | type | 门类型:0xE 中断门、0xF 陷阱门 |
| 44 | s | 必须为 0(系统段) |
| 45–46 | dpl | 描述符特权级(0 内核,3 用户可 int) |
| 47 | p | 存在位 |
| 48–63 | offset_mid | 地址的 16–31 位 |
| 64–95 | offset_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);
}
| 函数 | DPL | IST | 用途 |
|---|---|---|---|
set_intr_gate | 0 | 0 | 普通异常/中断 |
set_system_intr_gate | 3 | 0 | int3、int 0x80 |
set_intr_gate_ist | 0 | 指定 | #DF、NMI、#MC、#DB |
set_system_trap_gate | 3 | 0 | 用户态 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 索引:
| 索引 | 名称 | 用于 |
|---|---|---|
| 0 | IST_INDEX_DF | #DF |
| 1 | IST_INDEX_NMI | NMI |
| 2 | IST_INDEX_DB | #DB |
| 3 | IST_INDEX_MCE | #MC |
这些栈在 cpu_entry_area(CEA)里,每个 CPU 一份,地址固定且映射在用户页表之外(防止 Meltdown 类攻击读到)。cea_exception_stacks 的大小由 EXCEPTION_STKSZ 决定,通常是 8 KiB 或 16 KiB。
- 「DPL=3 的门用户态就能随便调」:还要看
IA32_LSTAR/syscall的 MSR 配置和 seccomp;int3虽然 DPL=3,但#BP的处理会检查ptrace与信号。 - 「IST 栈越大越好」:IST 栈在 CEA 里是稀缺资源,每 CPU 四张;调大意味着挤占其他映射,且栈溢出检测更难。
- 「
type=0xF与0xE只差中断是否关闭」:陷阱门不关IF,中断门关;系统调用用陷阱门(或syscall指令),异常大多用中断门。 - 「IDT 可以动态扩容」:
lidt的基址与限长在启动时定;Linux 固定 256 项,不扩容。 - 「
#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_desc | handle_irq、action、status_use_accessors、kstat_irqs | 中断核心 |
irq_data | irq、hwirq、chip、domain、chip_data、parent_data | 控制器驱动 |
irq_chip | irq_mask、irq_unmask、irq_ack、irq_eoi、irq_set_type | 控制器驱动 |
irq_domain | ops、host_data、hwirq_max、revmap | 控制器驱动 |
irqaction | handler、thread_fn、flags、name、dev_id | request_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 tree | hwirq 稀疏、范围大(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_irq | MSI/电平 | 处理完只发 EOI,不 mask(依赖控制器) |
handle_percpu_irq | per-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_STAR | 0xC0000081 | 内核/用户代码段选择子(sysret 用) |
MSR_LSTAR | 0xC0000082 | 64 位系统调用入口地址(entry_SYSCALL_64) |
MSR_SFMASK | 0xC0000084 | 进入内核时要清掉的 RFLAGS 位(IF、DF、TF 等) |
MSR_EFER | 0xC0000080 | bit0 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_SIGNAL | task_work 触发的信号 | 同上 |
_TIF_SYSCALL_AUDIT | 审计退出 | audit_syscall_exit() |
_TIF_PATCH_PENDING | livepatch 待切换 | klp_update_patch_state() |
_TIF_UPROBE | uprobe 命中 | 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 缓解,这些分支让汇编比教科书长得多。
- 「
syscall会自动保存所有寄存器」:只保存RIP→RCX、RFLAGS→R11;其余寄存器由汇编桩压栈。C 函数看到的pt_regs是桩构造的。 - 「
rax返回值可以直接当int」:内核返回值是long,错误区间[-4095,-1];用户态必须按long判断,截断成int可能把-ENOMEM当成合法值。 - 「
int 0x80与syscall等价」:int 0x80走 32 位表、指针截断、性能差一个数量级;64 位程序不要用。 - 「seccomp 只过滤参数」:它过滤系统调用号与参数,返回
SECCOMP_RET_KILL时进程直接死,连errno都没有。 - 「
swapgs只是优化」:它是 per-CPU 寻址的基础;入口/出口不配对会立刻#GP或读到错误 CPU 的数据。
3.3.13 缺页异常:error code 的每一位与信号映射
这一节回答:#PF 的 error code 每一位什么意思、内核怎么判断「这是用户态的正常缺页」还是「内核 bug」、为什么同一个空指针有时是 SIGSEGV 有时是 SIGBUS。
error code 的七位
#PF 压栈的 error code 不是「错误号」,而是七个独立的标志位:
| 位 | 名称 | 为 1 表示 | 内核常量 |
|---|---|---|---|
| 0 | P | 页存在,是保护违规;为 0 表示页不存在 | X86_PF_PROT |
| 1 | W | 写访问触发 | X86_PF_WRITE |
| 2 | U | 用户态访问触发 | X86_PF_USER |
| 3 | RSVD | 页表项保留位被置 1 | X86_PF_RSVD |
| 4 | I/D | 取指触发(NX 违规) | X86_PF_INSTR |
| 5 | PK | 保护键(PKU)违规 | X86_PF_PK |
| 6 | SS | 影子栈(CET)违规 | X86_PF_SHSTK |
| 7 | SGX | SGX 相关 | 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 用户态 | SIGSEGV | SEGV_MAPERR(1) | 空指针、越界、未映射 |
#PF 权限 | SIGSEGV | SEGV_ACCERR(2) | 写只读页、执行 NX 页 |
#PF 保护键 | SIGSEGV | SEGV_PKUERR(4) | PKU 限制 |
#PF 总线错误 | SIGBUS | BUS_ADRERR(2) | mmap 文件被截断、设备移除 |
#DE 除零 | SIGFPE | FPE_INTDIV(1) | 整数除零 |
#OF 溢出 | SIGSEGV | SEGV_ACCERR? | into 指令(32 位) |
#UD 非法指令 | SIGILL | ILL_ILLOPC(1) | 执行数据、CPU 不支持 |
#BP 断点 | SIGTRAP | TRAP_BRKPT(1) | int3 |
#AC 对齐检查 | SIGBUS | BUS_ADRALN(1) | 未对齐访问(开了 AC) |
#MC 机器检查 | SIGBUS | BUS_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
- 「空指针解引用一定 SIGSEGV」:如果地址落在
mmap_min_addr之上的某个映射里,可能是合法访问;内核 6.8 默认mmap_min_addr=65536,NULL 页不会被映射,所以空指针仍会SIGSEGV。 - 「
SIGSEGV就是段错误」:它也可能是栈溢出、保护键、执行 NX 页;看si_code与si_addr。 - 「内核缺页可以随便睡眠」:在
atomic上下文或持有 spinlock 时缺页,faulthandler_disabled()返回真,内核直接 Oops。 - 「
mprotect(PROT_NONE)后访问是 SIGSEGV」:是,但si_code是SEGV_ACCERR;如果页根本不在 VMA 里才是SEGV_MAPERR。 - 「
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–7 | PREEMPT | 8 | 抢占禁用计数(preempt_disable()) |
| 8–15 | SOFTIRQ | 8 | 软中断禁用计数(local_bh_disable()) |
| 16–19 | HARDIRQ | 4 | 硬中断嵌套计数(irq_enter()) |
| 20–23 | NMI | 4 | NMI 嵌套计数 |
| 31 | PREEMPT_NEED_RESCHED | 1 | 有调度请求但被抢占禁用挡住 |
对应常量:
/* 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() | 进程上下文 |
- 「
in_interrupt()为假就能睡眠」:还要看irqs_disabled()与是否持有 spinlock;in_atomic()更严格。 - 「
local_bh_disable()会关硬中断」:不会,只关本 CPU 的软中断;硬中断仍可打断,所以共享数据仍需spin_lock_irqsave()。 - 「
preempt_disable()可以嵌套任意层」:8 位计数,溢出会 wrap;虽然实际很难到 255,但泄漏会让preempt_count永久非 0,调度器报scheduling while atomic。 - 「软中断会在每个 CPU 上并行执行同一个 vector」:会;
NET_RX_SOFTIRQ在多核上并行,所以网络代码要用 per-CPU 或锁保护。 - 「
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
/proc/interrupts看该行是否增长——不增长说明设备没发中断,查设备/驱动。- 增长但业务没进展——看
dmesg的nobody cared,检查request_irq的 flags 与共享。 - 增长且集中在一个 CPU——看
smp_affinity与irqbalance。 - 处理时间长——用 irq_handler 直方图找具体 virq,再看驱动 handler 是否做了重活。
- 软中断积压——看
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。
- 「
int 0x80在 64 位程序里一定失败」:getpid这类无指针参数的系统调用能成功;带指针的会因截断而-EFAULT。 - 「计时循环不会被优化」:必须把结果累加到
volatile变量,否则-O2会把整个循环删掉。 - 「hrtimer 回调一定在硬中断」:默认是,但
HRTIMER_MODE_SOFT会推到软中断;hrtimer_start的 mode 决定上下文。 - 「
in_atomic()在 workqueue 里一定为 0」:如果 work 里拿了 spinlock,preempt_disable()会让它变 1;判断「能否睡眠」要看具体持有什么锁。
3.3.17 陷阱清单:中断、异常与系统调用篇
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | BUG: scheduling while atomic | 硬中断/软中断/持锁区间睡眠 | 移到 workqueue/threaded IRQ |
| 2 | nobody cared | request_irq 的 handler 返回 IRQ_NONE,或 flags 与硬件不符 | 检查共享/触发类型;读设备状态寄存器 |
| 3 | 中断只打在一个 CPU | smp_affinity 或 irqbalance 配置 | 写 /proc/irq/N/smp_affinity |
| 4 | 软中断积压,ksoftirqd 满 | 收包速率超过处理能力 | 调大 ring、RPS、多队列 |
| 5 | int 0x80 参数 -EFAULT | 32 位 ABI 截断指针 | 64 位程序改用 syscall |
| 6 | 新系统调用号冲突 | 挑了已用的号 | 查 syscall_64.tbl 最大号 |
| 7 | seccomp 杀死进程无 errno | SECCOMP_RET_KILL | 改 RET_ERRNO 或放宽过滤器 |
| 8 | 缺页在 atomic 上下文 Oops | 持 spinlock/中断里访问用户页 | 先 copy_from_user 到内核缓冲 |
| 9 | SIGBUS 而不是 SIGSEGV | mmap 文件被截断/设备移除 | 检查文件大小与设备状态 |
| 10 | preempt_count 永久非 0 | preempt_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/IST | 3.3.2 |
| 画出中断到驱动的路径 | 至少含 APIC、IDT、irq_desc、irqaction | 3.3.3 |
| 选对下半部机制 | 按「能否睡眠」给出四种选择 | 3.3.4 |
说清 syscall 的寄存器约定 | 号在 rax,参数 rdi/rsi/rdx/r10/r8/r9 | 3.3.5 |
| 独立加一个系统调用 | 三处改动 + 用户态 syscall() 调通 | 3.3.6 |
| 用 ptrace 观察系统调用 | 能打印 enter/exit 对 | 3.3.7 |
| 读出 IDT 门描述符的位域 | type/DPL/IST | 3.3.10 |
| 画出 hwirq→virq 的映射 | domain 与 irq_data | 3.3.11 |
说出 #PF error code 的七位 | P/W/U/RSVD/ID/PK/SS | 3.3.13 |
解释 preempt_count 四段 | 0x10000 是什么 | 3.3.14 |
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 软中断和 tasklet | 3.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()的参数怎么变成一个新进程。