ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

ARM寄存器组织与异常处理:从崩溃日志到Linux内核

2026/9/29 8:39:12 拓冰建站 浏览量
ARM寄存器组织与异常处理:从崩溃日志到Linux内核 前几天帮一个朋友看他那块 i.MX6 板子的崩溃日志串口只吐出来很短一段一屏r0到r9的寄存器值一句Code: e5900004 ...然后 PC 和 LR 落在同一个内核函数的两个相邻位置。他问我怎么从这一屏数字里看出问题来。说实话这类现场能不能读出来取决于你对 ARM 寄存器组织和异常处理这一套机制的熟悉程度——ARM 的寄存器不是R0 到 R15 一共 16 个这么简单异常进出的那几条规则也不是背下来就行哪一条记错了读出来的结论就完全是反的。这篇东西我准备把 ARMAArch32也就是大家平时说的 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、R14IRQ、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 是 0b10000FIQ 是 0b10001IRQ 是 0b10010SupervisorSVC是 0b10011Abort 是 0b10111Undefined 是 0b11011System 是 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如果是 FIQF 位也被置 1。注意这里说的是进入异常时而不是进中断时。复位之后I 和 F 都是 1也就是默认是关中断的你得显式开。这是刻意设计的安全措施——上电后栈指针还没初始化这时候要是来一个中断必死。2.3 SPSRCPSR 的那张存档照片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上电或复位引脚有效0x00SVC无意义不返回Undefined Instruction遇到无法译码的指令0x04Undefined该指令地址 4LR - 4Supervisor CallSVC/SWI执行 SVC 指令0x08SVC下一条指令地址LR - 4Prefetch Abort取指失败如权限不足0x0CAbort该指令地址 4LR - 4Data Abort访存失败0x10Abort该指令地址 8LR - 8保留历史遗留ARMv7 中保留0x14———IRQ普通外部中断0x18IRQ下一条指令地址 4LR - 4FIQ快速中断0x1CFIQ下一条指令地址 4LR - 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。如果你的异常处理程序离向量表超过 32MBB就跳不过去了。这时候的标准做法是换成LDR PC, [PC, #offset]从一个附近的字面量池literal pool里把绝对地址加载进来。但LDR的 PC 相对偏移只有 12 位也就是正负 4KB所以字面量池必须放在离向量表 4KB 以内。这就带来一个很别扭的约束向量表本身只有 8 个字字面量池不能塞在里面塞进去就破坏了对齐只能放到向量表后面。而且LDR读 PC 的时候PC 的值是当前指令地址 8ARM 状态下偏移量要按这个基准算。我第一次手写向量表的时候就在这儿栽过字面量池的位置算错 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] 目标地址写在向量表后面 0x188 处 ... .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-4Data 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 到 R6SVC #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-address0xc0008000 --stop-address0xc0009000 | 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 consolettyAMA0 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 能打断 IRQI 位不是全局开关这是最常被误解的一条。很多人觉得CPSR 里 I 位置 1 就是关中断了天下太平其实不是——I 位只管 IRQF 位才管 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 转到 AArch64ARMv8-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 表是必须背的。我在两个架构之间来回切换的时候总结出一个方法把异常进入时硬件做了什么返回地址存在哪里故障原因存在哪里这三个问题列成表每个架构各填一遍对照着看。填完一遍两套机制的区别就很清楚了比零散地记指令管用。