系统调用,中断,缺页,断点,全是踩着异常处理这条路径进内核的.把这条主干吃透,后面讲系统调用,讲缺页,讲中断,都只是它身上的分支.
这篇回答几件事:
- 异常向量表长什么样,为什么 16 个槽
- 一场异常从硬件跳转到 C 函数,中间每步发生了什么
- pt_regs 里存了什么,内核怎么靠它找回现场
- ESR 怎么手动解码(分析 Oops 天天用)
- eret 返回用户态时做了什么
代码全在 arch/arm64/kernel/entry.S 和 entry-common.c 里,总共两千行不到,值得整个读一遍.
先把异常分个类.ARM64 按硬件分四类,向量表每类一列:
ARM64 异常按硬件分四类,向量表每类一列:
- 同步异常:由某条指令的执行直接引起,ELR 指向确定位置.缺页,SVC,未定义指令,断点全是它.原因和指令一一对应.
- IRQ:普通中断,异步,ELR 是"本要执行的下一条指令".taken 时 PSTATE 的 I 位自动置 1.
- FIQ:快速中断,历史上给 Secure World 留的,现在主线常拿它当 pseudo-NMI 用,entry-common.c 里走的是和 IRQ 平行的 handle_arch_fiq.
- SError:异步的外部错误,典型来源是总线上迟到的 ECC 报告.它和当前指令没有对应关系,可能错误发生了很久才上报,现场早对不上了,所以内核对它态度最悲观,走 NMI 上下文,处理不好直接 panic.
一个容易忽略的点:同步异常的 ELR 一定是"肇事指令",IRQ/FIQ/SError 的 ELR 是被打断的那条.缺页修好要重执行,中断处理完是继续原来的流,这个差别决定了 ELR 的语义.
分类说完,看向量表本身.entry.S:519:
先看表本身,entry.S:519:
.align 11
SYM_CODE_START(vectors)
kernel_ventry 1, t, 64, sync // Synchronous EL1t
kernel_ventry 1, t, 64, irq // IRQ EL1t
kernel_ventry 1, t, 64, fiq // FIQ EL1t
kernel_ventry 1, t, 64, error // Error EL1t
kernel_ventry 1, h, 64, sync // Synchronous EL1h
...
kernel_ventry 0, t, 64, sync // Synchronous 64-bit EL0
...
kernel_ventry 0, t, 32, sync // Synchronous 32-bit EL0
...
SYM_CODE_END(vectors)
16 个槽,4 类异常 × 4 种来路.来路由"异常时 EL + 用哪个 SP"决定:
| 槽位 | 含义 |
|---|---|
| EL1t | 来自 EL1 且用 SP_EL0 |
| EL1h | 来自 EL1 且用 SP_EL1 |
| EL0 64 | 来自 64 位用户态 |
| EL0 32 | 来自 AArch32 用户态 |
几个硬性约束.表必须 2KB 对齐(.align 11),每槽 128 字节(.align 7),这是架构的要求,硬件按 VBAR_EL1 + 0x80 × 槽号直接跳,不搜索.每槽只有 128 字节放代码,所以 kernel_ventry 宏末尾有个 .org 编译期检查,写爆了汇编直接报错,把运行时问题变成编译期问题,这招我喜欢.
真正的活不在槽里,槽里的代码跳去 el{0,1}{t,h}_{64,32}_{sync,irq,fiq,error} 这些标签,entry_handler 宏批量生成.
EL1t 那四个槽很特殊,内核不用它们.内核态永远 SPsel #1,只有用户态才用 SP_EL0,如果异常报告说"你来自 EL1 且用 SP_EL0",那必然是内核自己出 bug 了,所以这四个槽直接 panic(entry-common.c 里的 UNHANDLED 宏).哪天 dmesg 里看到 "64-bit el1t sync",基本等于栈或上下文切换被写坏了.
VBAR_EL1 的装载在 head.S 的 __primary_switched 里,一行 adr_l x8, vectors; msr vbar_el1, x8.每个 CPU 都要设一遍,这寄存器是 per-CPU 的.
开了 KPTI(CONFIG_UNMAP_KERNEL_AT_EL0)后,用户态跑起来时内核映射整个撤掉,VBAR 指向的是精简的 tramp_vectors,它每个槽只干一件事:把 TTBR1 切回内核页表,把 VBAR 换成真向量表,然后跳过去.返回用户态时 kernel_exit 也走 tramp_exit 把映射再撤掉.栈回溯里看到 tramp 字样要认识它.
跳转的目标定了,接下来异常发生时硬件都记了什么.三组寄存器:
异常发生时硬件把现场记在几组寄存器里:
- ESR_EL1,为什么来.高 6 位 [31:26] 是 EC(Exception Class),低 25 位 ISS,按 EC 有不同解释
- ELR_EL1,回哪儿去
- FAR_EL1,访存类异常的目标地址.不是所有异常都写它
- SPSR_EL1,被打断时的 PSTATE,包括来自哪个 EL,DAIF,NZCV
EC 完整定义在 arch/arm64/include/asm/esr.h,挑高频的记:
| EC | 含义 |
|---|---|
| 0x15 | SVC (AArch64) |
| 0x18 | MSR/MRS 被 trap |
| 0x20/0x21 | 取指 abort(低 EL / 当前 EL) |
| 0x24/0x25 | 数据 abort(低 EL / 当前 EL) |
| 0x2F | SError |
| 0x30/0x32/0x34 | 断点/单步/watchpoint |
| 0x3C | BRK 指令 |
同一个数据 abort,用户态踩的是 0x24,内核态踩的是 0x25.
C 层分发在 entry-common.c:455 的 el1h_64_sync_handler:
asmlinkage void noinstr el1h_64_sync_handler(struct pt_regs *regs)
{
unsigned long esr = read_sysreg(esr_el1);
switch (ESR_ELx_EC(esr)) {
case ESR_ELx_EC_DABT_CUR:
case ESR_ELx_EC_IABT_CUR:
el1_abort(regs, esr);
break;
case ESR_ELx_EC_PC_ALIGN:
el1_pc(regs, esr);
break;
...
default:
__panic_unhandled(regs, "64-bit el1h sync", esr);
}
}
EL0 版本是 el0t_64_sync_handler,结构一模一样,case 多几个(SVC,SVE/SME,BTI...).每个 case 的函数都是同一个模板:进用户态上下文 → local_daif_restore 开中断 → 干活 → 退出收尾.对仗是刻意的.
数据 abort 再往下走一层,arch/arm64/mm/fault.c 有张按 ISS 细分的 fault_info 表,do_mem_abort 用 esr_to_fault_info(esr) 查表调 fn.用户态的错最后变 SIGSEGV 发回进程,内核态的错走 die_kernel_fault 出 Oops.第7章讲缺页时顺着 do_page_fault 往下挖.
下面是入口路径里信息密度最高的部分,保存现场.kernel_entry 精简后的骨架(entry.S:197):
这是入口路径里信息密度最高的部分,精简后的骨架(entry.S:197):
.macro kernel_entry, el, regsize = 64
stp x0, x1, [sp, #16 * 0] // x0-x29 成对压栈
stp x2, x3, [sp, #16 * 1]
... // 一直压到 x28, x29
.if el == 0
clear_gp_regs // 存完后把 x0-x29 清零
mrs x21, sp_el0 // 用户态的栈指针
ldr_this_cpu tsk, __entry_task, x20
msr sp_el0, tsk // 借用 sp_el0 存 current
...
.else
add x21, sp, #PT_REGS_SIZE // EL1: 被打断时的内核栈指针
get_current_task tsk
.endif
mrs x22, elr_el1 // 返回地址
mrs x23, spsr_el1 // 被打断时的 PSTATE
stp lr, x21, [sp, #S_LR]
stp x22, x23, [sp, #S_PC]
...
.endm
逐条说门道.
压栈顺序就是 pt_regs 的布局.31 个通用寄存器加 sp,pc,pstate,再加几个内核私有字段,就是 arch/arm64/include/asm/ptrace.h 的 struct pt_regs.前半段 user_regs 是用户态可见的(信号,ptrace 只碰这段),后面 orig_x0,syscallno,pmr 这些是内核私有.汇编压栈的便宜和 C 结构体布局在这里对上,entry_handler 宏里一句 mov x0, sp 就把 pt_regs * 传给了 C 世界.
EL0 入口为什么 clear_gp_regs:x0-x29 已经存好了,清零是因为这些寄存器留着旧值会给推测执行侧信道留素材,顺带满足 syscall ABI 里"未定义寄存器调用后为 0"的假设.
sp_el0 的戏法.ARM64 的 current 实现就一行,arch/arm64/include/asm/current.h:
static __always_inline struct task_struct *get_current(void)
{
unsigned long sp_el0;
asm ("mrs %0, sp_el0" : "=r" (sp_el0));
return (struct task_struct *)sp_el0;
}
内核跑在 EL1 时 SP 用 SP_EL1,SP_EL0 闲着,拿它白嫖一个 per-CPU 且免原子操作的 current 指针.用户态时 SP_EL0 是真栈指针,所以进内核第一件事把用户 sp 存进 pt_regs,然后把 task 指针装进去,上下文切换(cpu_switch_to)里那句 msr sp_el0, x1 干的也是这个.一句话:SP_EL0 在用户态是栈,在内核态是 current,进出门换装.
寄存器约定.宏结尾注释写了,x20 = PMR,x21 = 被打断的 SP,x22 = PC,x23 = PSTATE.读这段汇编先记住这四个.
元数据帧.6.18 的 pt_regs 里新挂了个 frame_record_meta 字段,kernel_entry 会在栈上写一个 FRAME_META_TYPE_PT_REGS(EL0 来路是 FINAL)的标记帧,给栈回溯器用:回溯到异常边界时靠它识别"这里有一份可信的寄存器现场".以前回溯器靠启发式猜,现在异常入口自己声明.这两年主线一直在做可靠 unwind,Oops 栈回溯质量比老内核好一大截,根子在这类工作.
栈溢出检测.槽位代码 kernel_ventry 在压栈前后用了个不占额外寄存器的加减法技巧,把原始 sp 换到 x0 里检查第 THREAD_SHIFT 位,坏了就切 per-CPU 的 overflow_stack 跑 handle_bad_stack,报告 stack overflow 而不是在坏栈上继续跑然后迷之三连嵌套异常.VMAP_STACK 开了之后栈底还有 guard page,两道防线.
IRQ 栈.中断默认不在任务栈上跑,do_interrupt_handler 查 on_thread_stack(),是就 call_on_irq_stack 切到 per-CPU IRQ 栈再执行,深中断栈把任务栈的深度压力接走了.
看完进门,看出门.出去的路大致是进来的镜像(entry.S:335),几个点单独说.
出去的路大致是进来的镜像(entry.S:335),几个点单独说.
EL1 出口先 disable_daif,收尾阶段不能再被打断,恢复一半的寄存器状态被保存就乱了.ret_to_user 前还要重读 thread_info flags 处理单步,CONFIG_KSTACK_ERASE 打开时把这次 syscall 用过的栈擦掉(6.18 新增的加固选项).所以返回用户态从来不是一条直路,asm_exit_to_user_mode 里还有信号,need_resched 一整串收尾活.
KPTI 出口:恢复完寄存器不直接 eret,先把 VBAR 换回 trampoline,跳到 tramp_exit 撤掉内核映射,在那里 eret.
最后两条指令:
eret
sb
eret 用 ELR/SPSR 原子地恢复 PC 和 PSTATE 回原来的特权级.sb 是 Speculation Barrier,防投机执行越过异常边界,又是侧信道补丁的痕迹.
IRQ 这边还有个小分叉值得一提.el1_interrupt 里,如果被打断时中断本来就是关的(说明这段代码声称不能被打断),这个 IRQ 必须按 NMI 规矩来,走 irqentry_nmi_enter,不碰 RCU 不做抢占记账.这就是 pseudo-NMI 的落点:GICv3 优先级掩码把一部分中断伪装成不可屏蔽,FIQ 向量位正好接它们.细节第9章展开.
道理讲完,上板子亲手炸一次.
道理讲完,上板子.板子是 Firefly AIO-3399J,RK3399,2xA72+4xA53,Debian 13,自编的 6.18.56.写了个模块挂一个 /dev/oops0,ioctl 触发指定类型的错误访问:
static long oops_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
volatile int *bad = (volatile int *)(unsigned long)arg;
int v;
if (cmd == OOPS_TRIGGER_WRITE) {
*bad = 0x1234; // 内核态写错误地址
} else if (cmd == OOPS_TRIGGER_READ) {
v = *bad; // 内核态读错误地址
}
return 0;
}
先写 0x10,dmesg 抓到(截取):
[1676.428553] Unable to handle kernel NULL pointer dereference at virtual address 0000000000000010
[1676.433225] Internal error: Oops: 0000000096000044 [#4] SMP
[1676.437940] pstate: 60000005 (nZCv daif -PAN -UAO -TCO -DIT -SSBS BTYPE=--)
[1676.438552] pc : oops_ioctl+0x34/0x6c [oops]
...
[1676.443302] Call trace:
[1676.443520] oops_ioctl+0x34/0x6c [oops] (P)
[1676.443901] __arm64_sys_ioctl+0x414/0xb00
[1676.444298] invoke_syscall.constprop.0+0x4c/0xc0
[1676.444695] do_el0_svc+0x40/0xc0
[1676.444992] el0_svc+0x40/0x180
[1676.445286] el0t_64_sync_handler+0xa0/0xf0
[1676.445660] el0t_64_sync+0x198/0x19c
[1676.446003] Code: ... (b9000260)
三个看点:
- Call trace 从下往上就是这篇文章讲的整条链:el0t_64_sync → el0t_64_sync_handler → el0_svc → do_el0_svc → invoke_syscall → 驱动.ioctl 从用户态进来,SVC 异常,一路到这里炸掉.书上的调用链在实机上原样印出来.
- ESR 手动解码.写入错误的 ESR 是 0x96000044:EC = 0x96000044 >> 26 = 0x25,数据 abort,来自当前 EL(内核态).ISS 低 25 位 = 0x44 = 0b1000100,bit6 是 WnR(Write not Read),1 表示写;低 6 位 DFSC = 0x04,level 0 translation fault,页表第一级就找不到条目.再看读错误的对照组,ESR = 0x96000004,只差 bit6 那一位:同样的位置,读和写就差这一个 bit.以后看 Oops 的 ESR 行,先取高 6 位查 EC,再看 ISS bit6 分读写,两步定位一大半问题.
顺带纠个我自己的错:一开始我以为"0x94 开头是读,0x96 开头是写",拿到实机数据才发现不对,0x94 和 0x96 的 EC 都是 0x25,区别在 ISS 的 CM 位,和读写无关.读写看 WnR,0x96xxxx04(读)和 0x96xxxx44(写)这种对比才是对的.书本印象不如一次实测.
- pgd = 0000000000000000 这行(完整 Oops 里有),页表指针是空的.0x10 明明是合法用户地址范围,用户进程的 PGD 怎么会是 0?因为 A72/A53 是 ARMv8.0,没有 PAN 硬件特性,内核跑的是软件 TTBR0 PAN:每次从用户态进内核,把 TTBR0 换成一张保留的空页表,内核态任何对用户地址的直接访问都会在 walk 第一级就失败,这就是 entry.S 里 __swpan_entry_el0 那段代码干的事.所以内核直接解引用用户指针,连真正的用户页表都摸不到,错误现场里 PGD 为 0 正是软件 PAN 工作的指纹. Oops 里那个 -PAN 标记,对应的是 SPSR 里被软件置上的模拟 PAN 位.
最大的坑:ioctl 命令号 2 的灵异事件.
调试这个模块时踩了个特别有教育意义的坑,记下来.
一开始我图省事,触发命令直接用裸数字:ioctl(fd, 1, addr) 写错误,ioctl(fd, 2, addr) 读错误.结果命令 1 正常炸出 Oops,命令 2 却永远返回 -EFAULT(errno 14),不炸也不报,驱动里加的入口日志压根没打出来.换 glibc 封装,换裸 syscall(2, ...),现象一致.没有 seccomp,没有 LSM(内核配置 DAC only),也没 audit.也就是说有个什么东西在驱动之前就把 arg 当指针访问了一下:地址有效返回 0,无效返回 EFAULT,典型的 get_user/put_user 行为.
最后反汇编板子内核 vmlinux 里的 __arm64_sys_ioctl(do_vfs_ioctl 被内联进去了),看到一行 cmp w21, #0x2 紧挨着 cmp w21, #0x541b(FIONREAD),恍然大悟:cmd == 2 是 FIGETBSZ.用 _IO(0x00, 2) 宏算一下就是 (0x00 << 8) | 2 = 2,一个 VFS 层直接处理的古老 ioctl,它在 fs/ioctl.c 的 do_vfs_ioctl 里对 arg 做 put_user(块大小),成功返回 0,失败 -EFAULT,根本轮不到驱动.
教训两条:驱动的 ioctl 命令号永远用 _IO/_IOR/_IOW 带私有魔数编码,别用裸数字,裸数字可能撞上 VFS 层的通用命令;排查"驱动行为诡异"时,dmesg 没有你的日志而返回值又很"合理",就要怀疑调用根本没到你那儿.后来把命令改成 _IO('k', 1) 和 _IO('k', 2),读写两种错误都按预期炸出来了.
最后留几个容易忽略的细节:
- 向量表每槽 128 字节是硬上限,.org 编译期检查.往这地方加代码心里要有数.
- EL1t 四个槽是 panic,不是"留着没用".看到相关报错先怀疑栈被写穿.
- entry.S 的入口代码在 .entry.text 段,大多标 noinstr:进入路径上不能跑任何可插桩代码,插桩本身可能触发异常,直接套娃.
- EC 0x2D(GCS, Guarded Control Stack)的 case 在 6.18 的 handler 里已经有了.ARMv9 新特性总是最先在异常处理冒头,这张 switch 就是架构演化的实时快照.
- 板上想用 ftrace 的 function tracer 抓入口的话,先看 available_tracers,我这块板的配置里只有 nop(FUNCTION_TRACER 没编),只能老老实实用 tracepoint 和 GDB.别拿着网上的教程命令硬敲.
- FAR 不是所有异常都有效,只有访存类硬件会写它.SError 的 FAR 不可信,代码里 serror 路径根本不用它.