前几天帮一个朋友看他那块 i.MX6 板子的崩溃日志,串口只吐出来很短一段:一屏r0到r9的寄存器值,一句Code: e5900004 ...,然后 PC 和 LR 落在同一个内核函数的两个相邻位置。他问我怎么从这一屏数字里看出问题来。说实话,这类现场能不能读出来,取决于你对 ARM 寄存器组织和异常处理这一套机制的熟悉程度——ARM 的寄存器不是"R0 到 R15 一共 16 个"这么简单,异常进出的那几条规则也不是背下来就行,哪一条记错了,读出来的结论就完全是反的。
这篇东西我准备把 ARM(AArch32,也就是大家平时说的 ARMv7-A 这一代)的寄存器组织和异常处理从头捋一遍,重点不是罗列手册上的位定义,而是把"为什么这么设计""代码跑起来时实际发生了什么""哪里最容易记错"这三件事讲清楚。适合正在做裸机开发、写中断处理、移植 bootloader、或者啃 Linux 内核arch/arm/kernel/entry-armv.S的人。看之前最好有点基本的 ARM 汇编概念,比如知道stmia、ldmia大概在干什么;完全不熟也没关系,我会在需要的地方补一句。Linux、ARM、寄存器组织、异常处理这几个词看着分散,实际上它们是一条链上的四个环,缺一环后面就看不懂。
1. 一屏寄存器 dump 能推出什么:ARM 的寄存器文件不是 16 个
1.1 崩溃日志里最该看的是 CPSR 和模式
大多数人拿到 register dump,第一反应是看 PC 落在哪个函数,然后去翻源代码。这个顺序其实不太好。我一般先看 CPSR,尤其低五位。因为这五位直接告诉你"出事那一刻 CPU 处在哪个模式"——是用户态触发的系统调用路径,还是内核态自己踩了非法地址,这两种情况排查方向完全不同。如果是 User 模式,说明是应用程序传了坏参数进来,重点查系统调用的入参校验;如果是 SVC 模式、ABT 模式或者 IRQ 模式,那就是内核自己的代码在干活时出的问题,得往驱动或者内存管理那边找。
看完模式再看 LR。LR 在异常场景下往往比 PC 更有信息量,因为 ARM 的异常返回地址有几个固定的偏移,LR 减掉这个偏移才是真正出问题的那条指令。这个偏移量是硬件定的,不是软件随便写的,后面第 3 节会专门列表说清楚。很多人第一次读 oops 日志时,看到打印出来的 PC 和 LR 只差 4 或者 8,以为是打印错位了,其实那就是异常机制的正常表现。
再往后才是 PC 对应的函数名,以及Code:后面那串十六进制——那是出错指令附近的机器码,反汇编一下就能看到具体是哪条访存指令、用的哪个基址寄存器、偏移是多少。这一整套顺序走下来,基本能在不看源码的情况下先把问题范围框到几个函数里,效率比直接翻源码高得多。
1.2 寄存器银行的真实含义
ARM 的寄存器文件里,编号 R0 到 R15 看起来是一组,实际上"名字相同不代表是同一个物理器件"。这就是所谓的寄存器银行(banked registers)。同一个编号 R13,在 User 模式下和 IRQ 模式下指向的是两个完全不同的物理寄存器,你切模式的那一刻,读到的 R13 就换成另一个了,里面装的值跟刚才那个毫无关系。
这件事之所以重要,是因为它直接决定了异常处理代码能不能写对。异常发生的一瞬间,CPU 切到了目标模式,而目标模式用的 R13 是它自己的那一份——如果你从来没有给这个模式赋过栈指针,那它里面装的就是复位后的随机值或者零。这时候你哪怕只是执行一条压栈指令(比如想保存一下现场),SP 就会指向一个莫须有的地址,然后一步踩进内存黑洞。这是裸机中断代码最常见的翻车方式,没有之一。
按 ARMv7-A 的定义,被银行化的寄存器大致是这样分配的:FIQ 模式额外独占了 R8 到 R12 这五个寄存器,加上它自己的 R13、R14;IRQ、SVC、Abort、Undefined、Monitor 这几种模式各自独占 R13 和 R14。也就是说,只有 R13、R14 是"每个特权模式一份",R8 到 R12 只有 FIQ 有额外的副本,R0 到 R7 在所有模式下始终是同一份、共享的。
1.3 为什么只银行化这么几个寄存器
这里有个很值得琢磨的设计取舍:为什么不干脆把 R0 到 R15 全部银行化,每个模式发一套?那样中断一来,上下文切换就完全不用压栈了,多省事。
原因是芯片面积和上下文切换成本的权衡。寄存器堆是 CPU 里最贵也最占面积的部件之一,如果每个模式都发一整套 16 个寄存器,芯片成本上去了,而且真正需要"秒级切换、不保存现场"的场景其实只有 FIQ 一个。FIQ 的设计目标就是"极快响应",所以它拿到了 R8 到 R12 这五个额外的寄存器——FIQ 处理程序可以直接用这几个寄存器做中间计算,完全不用压栈,这就省掉了内存访问的时间。这也是为什么 FIQ 被安排在向量表的最后一格(偏移 0x1C),因为它是最后一格,后面不需要放跳转指令绕过去,处理程序可以直接紧贴着排在向量表后面,省一条跳转,又快了那么一点。
IRQ 就没这个待遇了。IRQ 处理程序原则上必须先把用到的寄存器压栈保存,因为用的是共享的 R0 到 R12。所以"IRQ 慢、FIQ 快"这个说法,根源不在中断控制器,而在寄存器银行的设计上。理解了这一点,再去看为什么工业控制里对抖动敏感的信号喜欢挂在 FIQ 上,就顺理成章了。
我实测下来有一个经验:如果只是做个普通的外部中断响应,完全没必要为了那点速度去折腾 FIQ——主线内核里对 FIQ 的支持一直是边缘功能,驱动模型也不太友好,收益远小于折腾成本。真正值得花力气的地方是把 IRQ 处理程序的路径做短,别在里面做 printk 或者等锁。
2. 把 CPSR 的每一位摊开看:那些你早晚会踩的位
2.1 模式位 M[4:0] 与七种处理器模式
CPSR 的低五位是模式位,直接决定当前在哪个模式。常用取值:User 是 0b10000,FIQ 是 0b10001,IRQ 是 0b10010,Supervisor(SVC)是 0b10011,Abort 是 0b10111,Undefined 是 0b11011,System 是 0b11111。另外还有 Monitor 和 Hyp 两个模式,是带安全扩展和虚拟化扩展以后才有的,平时做应用开发基本碰不上。
这里有个特别容易被忽略的点:User 和 System 模式用的是同一套寄存器(R0 到 R15 和 CPSR),没有 SPSR,两者的唯一差别是 System 模式属于特权模式,可以自由切到别的模式、可以访问一些 User 模式下被禁止的资源,但它不通过异常进入。内核里要读用户态寄存器、或者要临时在一套"像用户态"的寄存器上下文里干活时,常常切到 System 模式来做,而不是切到 User,因为 User 模式下没法用 MRS/MSR 去写 CPSR 切回来。
指令层面,切模式靠的是CPS指令,比如cps #0x12切到 IRQ 模式,cpsid i关中断,cpsie i开中断。老代码里常见的是MSR CPSR_c, #0xD2这种写法,效果一样但可读性差,现在更推荐用CPS,因为它不会误伤条件标志位。
2.2 条件标志与中断屏蔽位
CPSR 的高四位是 N、Z、C、V 四个条件标志,跟运算结果绑定:N 表示结果为负,Z 表示结果为零,C 是进位或借位,V 是有符号溢出。这四个位的作用在于条件执行——ARM 指令几乎都能带条件后缀,ADDEQ、SUBNE这种,编译器生成的代码里到处都是,尤其是在做 64 位加减或者边界判断的时候。
再往下几个位是控制位,按位序是:J 位(Jazelle 状态)、IT 位(Thumb-2 的 IT 块状态,占多位)、E 位(大小端)、A 位(异步中止屏蔽)、I 位(IRQ 屏蔽)、F 位(FIQ 屏蔽)、T 位(Thumb 状态)。日常调试最关心的是 I、F、T 这三位。T 位决定当前是在 ARM 状态还是 Thumb 状态执行,反汇编的时候如果选错了状态,出来的指令全部是垃圾——这是我调试时最常犯的低级错误之一,明明代码是对的,反汇编看起来乱七八糟,最后发现是忘了加-M force-thumb或者目标函数本来就是 ARM/Thumb 混合的。
I 位和 F 位在异常进入时会被硬件自动置上,具体规则是:任何异常进入时,I 位被置 1;如果是 FIQ,F 位也被置 1。注意这里说的是"进入异常时",而不是"进中断时"。复位之后,I 和 F 都是 1,也就是默认是关中断的,你得显式开。这是刻意设计的安全措施——上电后栈指针还没初始化,这时候要是来一个中断,必死。
2.3 SPSR:CPSR 的那张存档照片
SPSR 是"Saved Program Status Register",只有异常模式才有。异常进入的那一瞬间,硬件会自动把当前的 CPSR 完整拷贝到目标模式的 SPSR 里,一个字都不少。等异常处理完了,再把它拷回去,CPSR 就恢复成出事之前的样子了——包括当时的中断屏蔽状态、条件标志、Thumb/ARM 状态。
这一点非常关键。因为如果你在异常处理里随手开了中断,处理完返回时忘了恢复,那用户态就会莫名其妙地带着"中断开着"的状态继续跑,或者反过来,本来开着的被关掉了,程序就此失去响应。硬件自动保存/恢复 SPSR 这个机制,就是替你处理了这部分。
从软件角度看,读写 SPSR 用MRS和MSR,比如MRS r0, SPSR。但注意,只有在异常模式下才能读 SPSR;在 User 或者 System 模式下访问 SPSR 是未定义指令。我在内核模块里想读 SPSR 时,得先确认当前模式,或者干脆通过pt_regs里存下来的 CPSR 值来看,后者更安全。
3. 七类异常与向量表:硬件做了三件事,剩下的全归你
3.1 异常类型、优先级与向量偏移
AArch32 定义了七类处理器异常,每类对应向量表里的一个固定偏移:
| 异常类型 | 触发场景 | 向量偏移 | 进入后的模式 | 返回地址(LR 内容) | 软件取返回地址的方式 |
|---|---|---|---|---|---|
| Reset | 上电或复位引脚有效 | 0x00 | SVC | 无意义 | 不返回 |
| Undefined Instruction | 遇到无法译码的指令 | 0x04 | Undefined | 该指令地址 + 4 | LR - 4 |
| Supervisor Call(SVC/SWI) | 执行 SVC 指令 | 0x08 | SVC | 下一条指令地址 | LR - 4 |
| Prefetch Abort | 取指失败(如权限不足) | 0x0C | Abort | 该指令地址 + 4 | LR - 4 |
| Data Abort | 访存失败 | 0x10 | Abort | 该指令地址 + 8 | LR - 8 |
| 保留 | 历史遗留,ARMv7 中保留 | 0x14 | — | — | — |
| IRQ | 普通外部中断 | 0x18 | IRQ | 下一条指令地址 + 4 | LR - 4 |
| FIQ | 快速中断 | 0x1C | FIQ | 下一条指令地址 + 4 | LR - 4 |
这张表我建议直接背下来,尤其是最后一列。读 oops 日志、写中断返回、做断点调试,全都用得上。注意 0x14 那一格是保留的,在更老的架构(ARMv4 之前)它是"地址异常",现在纯粹是留着不用,向量表还是必须占满 8 个字,不能把后面的往前挪。
还有一个容易搞混的地方:向量表可以放在 0x00000000(低向量)或者 0xFFFF0000(高向量),由 CP15 里的一个控制位(V 位)决定。Linux 在 ARM 上默认用高向量,也就是 0xFFFF0000,配合 MMU 把它映射成内核页表里的一个只读页。裸机程序如果不设这一位,默认走低向量。
3.2 向量表为什么是 4 字节一格
向量表里每一格只有 4 个字节,也就是恰好一条 32 位指令的位置,八格一共 32 字节。为什么这么抠?因为异常响应速度是第一优先级,向量表必须尽量小、尽量密集,让 CPU 用最快的路径跳进去。
4 个字节只够放一条指令,所以每格通常就是一条跳转。但这里有个陷阱:ARM 的B指令是相对跳转,编码里的立即数只有 24 位,左移两位之后,跳转范围大约是正负 32MB。如果你的异常处理程序离向量表超过 32MB,B就跳不过去了。这时候的标准做法是换成LDR PC, [PC, #offset],从一个附近的字面量池(literal pool)里把绝对地址加载进来。
但LDR的 PC 相对偏移只有 12 位,也就是正负 4KB,所以字面量池必须放在离向量表 4KB 以内。这就带来一个很别扭的约束:向量表本身只有 8 个字,字面量池不能塞在里面(塞进去就破坏了对齐),只能放到向量表后面。而且LDR读 PC 的时候,PC 的值是"当前指令地址 + 8"(ARM 状态下),偏移量要按这个基准算。我第一次手写向量表的时候就在这儿栽过:字面量池的位置算错 4 个字节,程序一上电就飞,用 JTAG 单步才发现 PC 跳到了一个完全不相关的地址。
典型的写法长这样:
.section .vectors, "ax" .globl vectors vectors: b reset_handler @ 0x00 b undef_handler @ 0x04 b svc_handler @ 0x08 b pabt_handler @ 0x0c b dabt_handler @ 0x10 nop @ 0x14 保留位,占位用 b irq_handler @ 0x18 b fiq_handler @ 0x1c如果处理程序太远,就把某一条换成:
ldr pc, [pc, #0x18] @ 目标地址写在向量表后面 0x18+8 处 ... .word far_away_handler3.3 硬件自动完成的三件事
异常进入时,硬件做的事情只有三件,别的都不管:
- 把当前 CPSR 的值拷贝到目标模式的 SPSR。
- 把返回地址写进目标模式的 LR。
- 切换模式位到目标模式、置 I 位(FIQ 再置 F 位)、把 PC 设成对应的向量地址。
请注意这里面没有的几件事。它没有保存 R0 到 R12——你要用就得自己压栈。它没有帮你切换栈指针——它只是"切到了目标模式的 SP",那里面装的是什么它不管。它没有清除条件标志位,也没有清 I 位之外的其他控制位。它没有判断这次异常是不是嵌套进来的。
所以一个完整的中断处理程序,开头那几行必须自己做这些事:先确保栈是有效的,再把要用的寄存器存起来,然后判断是不是嵌套中断,最后才是业务逻辑。裸机代码里最常见的结构是:
irq_handler: sub lr, lr, #4 @ 修正返回地址,指向被打断的那条指令 srsdb sp!, #0x13 @ 把 lr_irq 和 spsr_irq 存到栈上,同时切到 SVC 模式 cpsid if @ 在 SVC 模式下关中断,准备用内核栈 push {r0-r3, r12, lr} @ 保存业务代码会用到的寄存器 bl do_irq @ 进 C 语言处理函数 pop {r0-r3, r12, lr} rfeia sp! @ 从栈上恢复 CPSR 和 PC,一次性返回SRSDB和RFEA这一对指令是 ARMv6 之后才有的,专门用来配合异常返回。老代码里对应的是手动STMDB加MSR加MOVS PC, LR的组合,写起来啰嗦还容易错,能上新指令就别用老的。
3.4 LR 上的偏移:那几个最容易被记错的数字
返回地址的偏移是这套机制里最反直觉的部分。为什么 Undefined 是 LR-4,Data Abort 却是 LR-8?
先说 Undefined 和 SVC。这两类异常发生的时候,出错的那条指令已经进入流水线并且被判定为"需要走异常",但处理器需要保存一个"下次从哪儿继续"的地址。对于 SVC,语义上 SVC 指令本身是"完成"了的,返回时应该执行下一条,所以 LR 就是下一条指令的地址,减 4 就得到了 SVC 本身的地址(如果你需要读 SVC 的立即数,就得这么算)。Undefined 类似,LR 也是出错指令地址 + 4,减 4 得到那条无法译码的指令,方便你把它打印出来或者跳过它。
Data Abort 的 +8 就有意思了。原因是 ARM 允许一条访存指令带基址寄存器回写,比如LDMIA r0!, {r1-r4}。如果这条指令在访问到第二或者第三个字的时候失败了,架构要求软件能够"撤销"这次访问已经产生的副作用,包括基址寄存器的更新。为了留出这个空间,硬件把 LR 设成了出错指令地址 + 8,处理程序用 LR-8 就能定位到那条指令,反汇编它、分析它的基址回写规则,然后把基址寄存器恢复成原值。这也是为什么数据中止处理程序写起来比预想的复杂——不是简单跳过去就完事,还得半路"撤回"。
Prefetch Abort 是 LR-4。IRQ 和 FIQ 都是 LR-4,因为中断是"插入"进来的,被打断的那条指令应该完整地重新执行一遍。
有一个我踩过的坑值得说一下:Abort 模式下 R13 和 R14 只有一份,也就是说 Prefetch Abort 和 Data Abort 共享同一个 LR 和 SP。如果你在处理 Prefetch Abort 的过程中又发生了一次 Data Abort(比如处理程序自己想读一段坏内存),第二次异常会把 LR_abt 覆盖掉,第一次的返回地址就丢了。这就是为什么内核里的 abort 处理程序开头往往极其小心,尽量不做可能再次触发异常的访存操作。
4. 从裸机到内核:Linux 是怎么把向量表接管过去的
4.1 从 head.S 到 0xFFFF0000
内核启动的早期阶段,MMU 还没开,运行在物理地址上。arch/arm/kernel/head.S里做的事情里,有一件就是准备好页表,然后开 MMU,把向量表所在的页映射到高地址 0xFFFF0000。同时,内核会把向量表以及紧跟在它后面的那一段桩代码(stub)一起拷贝到那个页里。
这里有个设计细节挺巧:向量表里 SWI 那一格不是B,而是一条LDR PC, [...],目标地址是向量表起始地址加上 0x1000——正好落在向量页后面紧邻的那一页的起始处,而系统调用处理的入口桩就放在那儿。为什么要这么绕?因为向量表那 8 个字必须严格连续、不能被任何数据打断,字面量池根本没法放进来。内核干脆把目标地址固化成一个固定偏移,让它落在另一页上,这样既解决了字面量池的位置问题,又不用在向量表里塞多余的东西。
向量页(0xFFFF0000 那一页,4KB)的布局大致是:起始处是 8 个字的向量表,接着是各个异常的分发桩,页的尾部放的是信号返回跳板(用户态收到信号时,内核让 PC 跳到这来执行sigreturn系统调用)和 kuser 辅助函数(0xFFFF0F60 附近的__kuser_cmpxchg64、__kuser_memory_barrier、0xFFFF0FE0 附近的__kuser_get_tls这些)。这些内容是内核在启动时一次性写进去的只读代码,用户态程序可以直接调用,不需要 trap 进内核——这是 ARM Linux 上一个非常实用的性能优化。
如果你去看arch/arm/kernel/entry-armv.S,开头就是.L__vectors_start那个标签,下面八行对应八个向量,每行都用W()宏包起来,后面统一加上stubs_offset来修正链接地址和运行时地址的差值。这个stubs_offset的概念值得留意:内核镜像链接的时候,向量表和桩代码的相对位置是固定的,但拷贝到 0xFFFF0000 之后,绝对地址变了,相对偏移没变,所以用偏移量修正跳转目标是最省事的做法。
4.2 vector_swi:最热的那条路径
系统调用是所有异常里流量最大的一条。用户态的read、write、open最终都会变成一条SVC指令。ARM EABI 的约定是:系统调用号放在 R7,参数放在 R0 到 R6,SVC #0触发。返回之后,返回值在 R0,出错的话 R0 里是一个负的 errno。
从向量表跳进vector_swi之后,内核要干的事情顺序大致是:从 CPSR 里取出上下文、确认是 EABI 还是老 OABI 调用方式、按 R7 去查sys_call_table、做参数检查、调用实际的系统调用实现、把返回值写回pt_regs的 R0 位置、然后走返回路径。
返回路径有快慢两条:ret_fast_syscall和ret_slow_syscall。快速路径适用于不需要重新调度、没有信号待处理的情况,它直接用RFEIA把 CPU 状态一次性恢复回用户态;慢速路径则要先检查_TIF_WORK_MASK里有没有待处理的工作(比如信号、抢占),有的话就先在内核里处理完再返回。这个快慢分支是 Linux 在 ARM 上性能优化的一个缩影——把最常见的情况做成最短路径。
有个细节新手容易困惑:为什么有时候明明只是调用了一个简单的系统调用,strace里显示的耗时却忽高忽低?因为快慢路径的选择取决于当前进程有没有 pending 的信号、有没有被标记需要重新调度。系统在负载高的时候,慢路径命中率会上升,耗时就上去了。这不是 bug,是设计。
4.3 从 Data Abort 到缺页处理:一条完整的链路
Data Abort 是从向量表到页错误处理的起点,这条链路值得完整走一遍,因为它把异常机制和内存管理串起来了。
用户态程序访问一个已经分配但还没真正映射物理页的地址时,MMU 走页表发现对应的页表项是空的,于是触发 Data Abort,进入 Abort 模式,PC 跳到向量表偏移 0x10 的位置。内核的分发桩(vector_dabt)会把现场保存成pt_regs,然后调用do_DataAbort。这个函数从 CP15 里读出两个关键的寄存器:DFSR(数据故障状态寄存器,MRC p15, 0, r0, c5, c0, 0)和 DFAR(数据故障地址寄存器,MRC p15, 0, r0, c6, c0, 0)。
DFSR 的低四位是故障状态码,告诉你这次故障是什么性质:0b00101 是段描述符的转换故障,0b00111 是页描述符的转换故障,0b01101 或 0b01111 是权限故障,0b00001 是对齐故障。DFAR 则给出出错的那个虚拟地址。内核根据状态码判断是"页不存在"还是"权限不够":前者走缺页处理,分配物理页、填页表、刷新 TLB,然后返回继续执行那条指令;后者走 SIGSEGV,给进程发信号。
最近在内核模块里两次读到的故障状态码含义要小心区分:
| 状态码低四位 | 含义 | 内核通常的处理 |
|---|---|---|
| 0b00001 | 对齐故障 | 视配置决定是修复还是报错 |
| 0b00011 / 0b00110 | 访问标志故障 | 置访问位,返回继续 |
| 0b00101 / 0b00111 | 转换故障(段/页) | 走缺页处理,分配物理页 |
| 0b01001 / 0b01011 | 域故障 | 通常是配置错误 |
| 0b01101 / 0b01111 | 权限故障 | 越权访问,发信号或报错 |
do_DataAbort之后会分发到do_translation_fault或者do_section_fault,前者再进do_page_fault,最终走到handle_mm_fault。这一整条链路,起点就是硬件的异常机制,终点是内存管理子系统。理解了从向量表到pt_regs的那一段,后面的部分就都是纯粹的软件逻辑了。
4.4 pt_regs:内核眼里的寄存器快照
内核在异常入口处会把寄存器状态保存成一个struct pt_regs,在 ARM 上它就是一个 18 个 long 的数组:
struct pt_regs { long uregs[18]; }; #define ARM_r0 uregs[0] #define ARM_r1 uregs[1] /* ... r2 到 r14 依次对应 ... */ #define ARM_pc uregs[15] #define ARM_cpsr uregs[16] #define ARM_ORIG_r0 uregs[17]第 17 项ARM_ORIG_r0是系统调用专用:因为 R0 既是入参又是返回值,内核在调用前把原始 R0 存一份到这里,这样ptrace或者seccomp需要看原始入参时还能拿到。这个字段只有内核开发者才会关心,但你在写 seccomp 过滤器或者调试 ptrace 相关问题时一定会撞上它。
pt_regs一旦建好,后面的所有处理都是"在 C 语言里操作一个结构体",汇编的复杂度被完全隔离在入口那一小段。信号处理、ptrace单步跟踪、核心转储、/proc/<pid>/syscall读取系统调用参数,用的都是这个结构体。你在 gdb 里p $r0看不到内核态寄存器时,往往是因为没找到当前任务的pt_regs指针——从栈上回溯或者从thread_info里找,路径不一样,但本质都是同一块内存。
5. 动手把寄存器状态真正读出来
5.1 用 objdump 确认向量表和桩代码的位置
光看代码不如实际看一眼编译出来的东西。如果你手上有一个 ARM 的 vmlinux,可以这样找向量表:
# 找到向量表符号的地址 arm-linux-gnueabihf-nm vmlinux | grep -i vectors # 反汇编向量表所在的那一段 arm-linux-gnueabihf-objdump -d vmlinux \ --start-address=0xc0008000 --stop-address=0xc0009000 | head -60反汇编出来的内容里,你会看到连续的八条指令,每条对应一个向量偏移。仔细对照偏移量:如果第三条不是b而是ldr pc,那就说明你看到的确实是内核的向量表,因为 SWI 那一格就是这么特意处理的。这一步能帮你确认符号地址和实际指令的对应关系,比自己脑补偏移可靠得多。
对于裸机程序,用readelf -S看一下.vectors段是不是被放在了链接脚本指定的地址上。有些工具链默认不会把.vectors段放在最前面,你得在链接脚本里显式写. = 0x00000000; .vectors : { *(.vectors) }才行。我见过好几次"上电不跳转"的案子,最后都是链接脚本里段顺序写错了。
5.2 在 QEMU 里人为制造一次 Data Abort
想观察 Data Abort 的完整过程,用 QEMU 跑一个 ARM 虚拟机最省事,不用担心把板子跑挂:
# 起一个 vexpress-a9 的虚拟机,内核直接挂上去 qemu-system-arm -M vexpress-a9 -m 512M -nographic \ -kernel ./arch/arm/boot/zImage \ -append "console=ttyAMA0 root=/dev/ram rdinit=/bin/sh" \ -initrd ./rootfs.cpio.gz -s -S-s -S会让 QEMU 在第 0 条指令处停住并监听 1234 端口等 gdb 接入。接上去之后:
arm-linux-gnueabihf-gdb ./vmlinux (gdb) target remote :1234 (gdb) break do_DataAbort (gdb) continue然后在虚拟机里加载一个故意踩空指针的模块,gdb 就会停在do_DataAbort。这时候依次执行info registers r0 r1 r2 pc看通用寄存器,p/x $cpsr看模式位,再用x/8i $pc-16看几条指令的上下文。用 gdb 的好处是可以直接读 CP15 的协处理器寄存器,确认 DFAR 里到底是不是你踩的那个地址——这一步能排除掉"故障地址是上一次遗留值"这种误判。
5.3 在内核模块里读 FSR 和 FAR
有时候没法单步调试,只能靠日志。这时候可以写个很短的模块,把故障状态寄存器读出来:
static inline unsigned long read_dfsr(void) { unsigned long v; asm volatile("mrc p15, 0, %0, c5, c0, 0" : "=r"(v)); return v; } static inline unsigned long read_dfar(void) { unsigned long v; asm volatile("mrc p15, 0, %0, c6, c0, 0" : "=r"(v)); return v; }不过这里有个我踩过两次的坑:DFSR 和 DFAR 是"最近一次数据故障"的记录,它不是每读一次就清空。如果你的模块在读取之前,系统里别的线程刚好触发过一次页错误,那你读到的可能是别人的故障信息。稳妥的做法是在自己的错误处理路径里立刻读,或者在读之前先制造一次已知的无效访问来"刷新"这两个寄存器。这个坑在真实产品里会表现为"日志里的故障地址看起来完全不相关",很容易把人带到错误的方向上去。
读出来的值怎么解读,就用第 4.3 节那张状态码表去对。另外注意 DFAR 在 ARMv7 上可能只有 32 位,如果用了 LPAE 扩展,故障地址可能超过 32 位,要看具体实现是否支持扩展格式的 FSR。
6. 几个一直被人记错的结论
6.1 FIQ 能打断 IRQ,I 位不是全局开关
这是最常被误解的一条。很多人觉得"CPSR 里 I 位置 1 就是关中断了,天下太平",其实不是——I 位只管 IRQ,F 位才管 FIQ,而且这两个是独立控制的。当 IRQ 异常发生时,硬件只会置 I 位,F 位保持原样;也就是说,一个正在执行的 IRQ 处理程序,完全可以被 FIQ 打断。如果你的系统里真有 FIQ 在跑,那么在 IRQ 处理程序里访问共享数据就必须考虑 FIQ 抢占的情况,光用local_irq_save是防不住的,得用local_fiq_disable。这在实际项目里翻车过一次:一个共享的计数器在 IRQ 里读改写,本来以为关了中断就安全了,结果一段时间后数据开始对不上,最后发现是 FIQ 在中间插了一刀。
6.2 中断入口的栈切换必须显式做
前面说过,异常进 IRQ 模式时用的是 IRQ 模式自己的 R13。裸机程序如果不在初始化阶段给每个模式都赋好栈,中断一来就崩。标准做法是在启动代码里逐个模式切过去设栈,一般用CPS切模式再给 SP 赋值,一路切完再切回 SVC 或 System 模式继续跑main。写的时候注意顺序:设完一个模式的栈要马上切到下一个模式,别在中途开了中断,否则如果你刚好切到一个栈还没设好的模式,中断进来直接飞。
另外一个相关经验:中断入口在压栈之前先SUB LR, LR, #4修正返回地址,这一步不能忘。忘了的后果是中断返回时从被打断指令的下一条继续执行——单次看起来没事,但如果被打断的是一条带条件执行的指令,或者在紧密循环里,行为就会错得莫名其妙,而且很难复现。
6.3 AArch64 是另一套东西,别把概念搬过来
如果你从 AArch32 转到 AArch64(ARMv8-A 的 64 位执行状态),上面这些东西基本都要换一套心智模型。AArch64 有 31 个通用寄存器 X0 到 X30,加一个 SP,没有"寄存器银行"这个概念了,异常级别(EL0 到 EL3)取代了处理器模式。返回地址不再靠 LR 上的偏移去猜,而是有专门的 ELR_ELx 寄存器存返回地址,异常原因有 ESR_ELx 描述,故障地址有 FAR_ELx,向量表基址有 VBAR_ELx。
向量表的结构也完全不同:16 个表项,每项 128 字节(0x80),一共 2KB。分成四组,分别对应"当前 EL 用 SP0""当前 EL 用 SPx""更低 EL 用 AArch64""更低 EL 用 AArch32",每组里的四项依次是同步异常、IRQ、FIQ、SError。也就是说,同步异常的入口和中断的入口不再只是偏移差几个字节,而是差了 0x80,而且每类异常都有自己的 128 字节空间,可以直接写一小段代码,不需要非得是跳转。这个设计比 AArch32 舒服得多,但也意味着你不能再拿"LR 减 4"那套去套。
同步异常里的 EC 字段(ESR_ELx 的高 8 位)是重点,比如 0x15 表示 SVC 调用、0x20 或 0x21 表示指令中止、0x24 或 0x25 表示数据中止、0x3C 表示 BRK 断点。做 AArch64 的内核调试或者 hypervisor 开发,这张 EC 表是必须背的。
我在两个架构之间来回切换的时候总结出一个方法:把"异常进入时硬件做了什么""返回地址存在哪里""故障原因存在哪里"这三个问题列成表,每个架构各填一遍,对照着看。填完一遍,两套机制的区别就很清楚了,比零散地记指令管用。