[内核学习]ARM64异常处理

/ 0评 / 0赞

系统调用,中断,缺页,断点,全是踩着异常处理这条路径进内核的.把这条主干吃透,后面讲系统调用,讲缺页,讲中断,都只是它身上的分支.

这篇回答几件事:

代码全在 arch/arm64/kernel/entry.S 和 entry-common.c 里,总共两千行不到,值得整个读一遍.

先把异常分个类.ARM64 按硬件分四类,向量表每类一列:

ARM64 异常按硬件分四类,向量表每类一列:

一个容易忽略的点:同步异常的 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 字样要认识它.

跳转的目标定了,接下来异常发生时硬件都记了什么.三组寄存器:

异常发生时硬件把现场记在几组寄存器里:

EC 完整定义在 arch/arm64/include/asm/esr.h,挑高频的记:

EC含义
0x15SVC (AArch64)
0x18MSR/MRS 被 trap
0x20/0x21取指 abort(低 EL / 当前 EL)
0x24/0x25数据 abort(低 EL / 当前 EL)
0x2FSError
0x30/0x32/0x34断点/单步/watchpoint
0x3CBRK 指令

同一个数据 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)

三个看点:

  1. Call trace 从下往上就是这篇文章讲的整条链:el0t_64_sync → el0t_64_sync_handler → el0_svc → do_el0_svc → invoke_syscall → 驱动.ioctl 从用户态进来,SVC 异常,一路到这里炸掉.书上的调用链在实机上原样印出来.
  1. 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(写)这种对比才是对的.书本印象不如一次实测.

  1. 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),读写两种错误都按预期炸出来了.

最后留几个容易忽略的细节:

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注