翻开 arch/arm64 的代码,满屏都是 EL1, x29, SPSR, DAIF 这种词.它们不是内核发明的,是 ARMv8 执行模型的地基.地基不打牢,后面看 entry.S,看异常处理,看上下文切换,全是云里雾里.这篇把地基一次说清,顺手在板子上全部验证一遍.
这篇回答几件事:
- EL0~EL3 四层特权级是什么,Linux 把内核放在哪
- 31 个通用寄存器怎么分工,x29/x30 为什么地位特殊
- PSTATE 是什么,Oops 里那串 pstate: 60000005 怎么逐位读
- 系统寄存器怎么编码,为什么用户态 mrs 一下就 SIGILL
- 函数调用约定和栈帧长什么样,不靠任何库手写一个回溯
先说 EL.Exception Level 是 ARMv8 的特权级分层,EL0 最低,EL3 最高.硬件只认层,不认角色,角色是软件赋的:
| EL | 硬件角色 | Linux 里的用法 |
|---|---|---|
| EL0 | 无特权执行 | 用户态 |
| EL1 | 操作系统层 | 内核本体 |
| EL2 | 虚拟化层 | KVM,或者内核直接用(VHE) |
| EL3 | 安全世界切换 | TF-A(TrustZone 固件),内核不碰 |
两条铁律.一,异常只能把 EL 抬高或持平,永远不会因为异常降级,想下去只有 eret.二,每层有自己的 SP(SP_EL0/1/2/3)和一套 ELR/SPSR,SPSel 决定当前用哪个 SP.这两条后面看异常处理会有体会,进门硬件记账,出门硬件销账,软件不操心.
内核启动早期就要确认自己被 bootloader 放在了哪层,head.S:135:
mrs x19, CurrentEL
cmp x19, #CurrentEL_EL2
b.eq 1f // booted in EL2
CurrentEL_EL2 = 2 << 2,定义在 asm/ptrace.h.查出来是 EL2 就先降到 EL1 再干活(开 VHE 的场景留在 EL2 跑,那是虚拟化章节的事).结果记进 __boot_cpu_mode,后面的代码靠它知道自己是从 EL1 还是 EL2 起的家,比如 KVM 初始化时要判断有没有 EL2 可用.
那我们平时到底跑在哪个 EL?写个模块在每个 CPU 上读一遍 CurrentEL:
asm volatile("mrs %0, CurrentEL" : "=r" (el));
asm volatile("mrs %0, SPSel" : "=r" (spsel));
asm volatile("mrs %0, daif" : "=r" (daif));
on_each_cpu 跑一圈,RK3399 六个核全是:
cpu0: CurrentEL=0x4 -> EL1, SPSel=1, DAIF=0xc0 (D0 A0 I1 F1)
cpu1: CurrentEL=0x4 -> EL1, SPSel=1, DAIF=0xc0 (D0 A0 I1 F1)
...(六个核一样)
CurrentEL 读出来 0x4,EL 号在 bit[3:2],0x4 >> 2 = 1,EL1.SPSel=1 表示内核用 SP_EL1 当栈.SP_EL0 在内核里另有用处,这个留到说完寄存器讲.
寄存器谱系.AArch64 有 31 个 64 位通用寄存器 X0~X30,Xn 的低 32 位叫 Wn,写 Wn 硬件自动把高 32 位清零,注意是零扩展不是符号扩展,这个细节记一下,读反汇编省得懵.x31 在大多数指令里是零寄存器 zr,在 ADD/LDR 这种"结果写回基址寄存器"的场景当 SP,全看指令怎么用.
x30 是 LR,bl 指令自动把返回地址写进去.x19~x28 是 callee-saved,函数想用必须先存.x29 是 AAPCS64 规定的帧指针,配合 SP 构成栈帧,这篇文章最后实测它.
SP 之外,每个 EL 一套"异常账本":ELR_ELx 记异常返回地址,SPSR_ELx 记被打断时的处理器状态.异常进门硬件自动写这两个,出门 eret 自动读回.所以内核从来不用手工保存"用户态下次从哪继续",硬件记着.
然后是 PSTATE.它不是一个寄存器,是处理器当前状态位的集合:条件标志 NZCV,中断屏蔽 DAIF,当前 EL,SPSel,还有 PAN/UAO/TCO/SSBS 这些后来陆陆续续加的安全和侧信道位.没有一条指令能整体读写 PSTATE,只能按字段访问;SPSR 的作用就是把某个时刻的 PSTATE 打个快照,异常返回时恢复.
Oops 里那行 pstate 就是一份 SPSR 快照,拿之前实机的输出逐位拆:
pstate: 60000005 (nZCv daif -PAN -UAO -TCO -DIT -SSBS BTYPE=--)
0x60000005,低 4 位 0101 是异常模式码,5 就是 EL0t(用户态,用 SP_EL0),说明这个 Oops 的现场来自用户态触发的一条路径.bit[31:28] = 0110,即 N=0 Z=1 C=1 V=0,所以打印 nZCv,这是出错前留下的条件标志.小写 daif 表示四个屏蔽位全 0(没屏蔽),如果哪位是大写,比如 dAIf,就是 D/A/I 被屏蔽了.前面带负号的 PAN,UAO,TCO,DIT,SSBS 全是 0.PAN 管的是"内核能不能直接访问用户地址",板上这对 A72/A53 是 ARMv8.0,硬件没这特性,内核用软件办法模拟了一层,异常处理那篇细说.
想读"当前"的 DAIF,内核里 read_sysreg(daif) 就行.不过我在模块里读的时候踩了个认知坑:on_each_cpu 的回调里读,六个核读出来全是 0xc0,I 位 F 位都屏蔽着.这不是被打断任务的现场,是我自己这个中断现场的 — CPU 接异常时硬件先把 DAIF 置起来,IPI 回调天然跑在屏蔽态里.想知道被打断者的 DAIF,得去 kernel_entry 存进 pt_regs 的 SPSR_EL1 里翻.读寄存器一行代码,想清楚"读到的是谁的现场"才是本事.
PSTATE 说完,到系统寄存器.ARMv8 的系统寄存器一大堆,TCR,TTBR,SCTLR,VBAR,ESR 全是,每个用五元组编码:op0/op1/CRn/CRm/op2.汇编里写名字,机器码里编码,CurrentEL 的编码就是 S3_0_C4_C2_2.访问就两条指令,MRS 读,MSR 写.
关键在访问权限,按 EL 分.低 EL 去读高 EL 的寄存器,行为是 UNDEFINED,直接触发同步异常.上板实测,两个寄存器对照:
asm volatile("mrs %0, cntvct_el0" : "=r" (v)); // 虚拟计数器,EL0 可读
asm volatile("mrs %0, CurrentEL" : "=r" (v)); // EL1 的寄存器
运行结果:
cntvct_el0 = 0x147372852 (虚拟计数器, EL0 可直接读)
下面读 CurrentEL:
SIGILL! code=4 addr=0xaaaad2d20900
pc=0xaaaad2d20900, insn=0xd5384241 (mrs x1, CurrentEL)
第一条正常,cntvct_el0 是少数 EL0 有权限的系统寄存器,还有 cntpct_el0 那一小撮,全是计时器,给用户态做高精度时间戳用的,比反复 syscall 便宜.第二条炸 SIGILL,si_code=4 是 ILL_ILLOPC.把 pc 处的指令抠出来,0xd5384241,反编码是 mrs x1, CurrentEL,编译器把输出寄存器分给了 x1.
内核那边发生的事:这条指令在 EL0 触发同步异常,ESR 的 EC = 0x18(MSR/MRS 访问系统寄存器),entry-common.c:788 的 el0t_64_sync_handler 接住,转到 el0_sys,再到 traps.c 的 do_el0_sys.do_el0_sys 先过一遍 sys64_hooks,看是不是要仿真的特殊访问,都不中就落到 do_el0_undef,给进程发 SIGILL.源码注释写得挺直白:新的 SYS 指令在老核的 EL0 上以前就是 undefined,统一走 undefined 处理.一条 mrs 的完整旅程,把异常处理那篇的主干先预演了一遍.
最后一块,调用约定和栈帧.AAPCS64 规定:x0~x7 传参,x0/x1 放返回值,x19~x28 加 x29/SP 是被调方必须保存的,x30 是 LR,栈必须 16 字节对齐(SCTLR 的 SA 位还能让硬件替你查对齐错误).
标准函数序幕在板上 objdump 一抓就有:
00000000000008cc <b>:
8cc: a9bf7bfd stp x29, x30, [sp, #-16]!
8d0: 910003fd mov x29, sp
8d4: 97fffff7 bl 8b0 <c>
8d8: a8c17bfd ldp x29, x30, [sp], #16
stp x29, x30, [sp, #-16]! 一条指令干三件事:栈伸 16 字节,旧 x29 和返回地址一起入栈.mov x29, sp 之后,x29 指向的那 16 字节就是 {上一帧的 x29, 返回地址},这个布局叫 frame record.尾声 ldp 弹回来走人.
frame record 串起来就是帧链:每个 x29 都指向上一帧的 frame record,顺着走就能回溯.原理简单到不像话,干脆不靠任何库,手撸一个:
static void walk(void)
{
unsigned long fp;
int depth = 0;
asm volatile("mov %0, x29" : "=r" (fp));
while (fp && depth < 16) {
unsigned long *frame = (unsigned long *)fp;
printf("#%d fp=%#lx lr=%#016lxn", depth, fp, frame[1]);
fp = frame[0];
depth++;
}
}
嵌套四层函数再调 walk,编译时留帧指针,完整走出 7 帧,从 walk 一路走到 libc 的启动函数.这就是 perf,gdb,内核 unwind 的基本原理,没有魔法,就是一条链表.
对照组同样重要,强制 -fomit-frame-pointer 之后,同一个函数的序幕变成:
8c0: f81f0ffe str x30, [sp, #-16]!
8c4: 97fffff8 bl 8a4 <c>
8c8: f84107fe ldr x30, [sp], #16
只存 x30,x29 不建帧了.帧链走两步就断头,fp 指到栈上无关内容,回溯到此为止.有个反直觉的实测发现:板上这版 Debian gcc(14.2)的 -O2 默认居然保留帧指针,大概是为 profiling 生态改的默认值,要显式 -fomit-frame-pointer 才关.所以别背"O2 一定没帧指针"的老黄历,拿 objdump 看一眼比什么都靠谱.内核这边板上配置是 CONFIG_FRAME_POINTER=y,arm64 的栈回溯质量就指着这条 x29 链,Oops 的 Call trace 能不能走对,一半功劳在帧指针.
最后留几个容易忽略的细节:
- 写 Wn 会把 Xn 高 32 位清零,零扩展.从 32 位遗留代码迁移时这里最容易埋雷,尤其负数.
- 内核跑 EL1 时 SP_EL0 被征用存 current,arch/arm64/include/asm/current.h 里一行 mrs sp_el0 拿到 task 指针,免原子免查表.代价是"EL1 + SP_EL0"成了非法状态,异常向量表里那四个槽干脆直接 panic.
- DAIF 四个位是 D(Debug),A(SError),I(IRQ),F(FIQ),别只记后两个.硬件断点,单步,watchpoint 报上来走的是 D 位那条线.
- CurrentEL 只有 bit[3:2] 有效,0x4 是 EL1,0x8 是 EL2.它只读,EL 想变只能靠异常进出.
- x18 在 ABI 里是平台保留寄存器,CONFIG_SHADOW_CALL_STACK 开着时内核拿它当影子调用栈指针(asm/scs.h 里 scs_sp .req x18),板上没开,但写内联汇编别去碰它.
- 用户态能直接 mrs 的系统寄存器就是 cntvct_el0/cntpct_el0 那几个计时器,做用户态高精度计时记住它们.
- 异常从来不降 EL,eret 是唯一的下降通道.所以内核里看到"从 EL2 降到 EL1"的代码,一定是在启动早期走的 eret,不是异常.