一次“预取中止”引发的深度追查:如何在ARM7上揪出未映射内存访问

你有没有遇到过这种情况——系统上电后毫无征兆地卡死,连串口都来不及吐出一个字节?或者固件升级之后,板子再也启动不起来,JTAG也连不上?更离谱的是,某些功能偶尔崩溃,但复现无规律,像是被“诅咒”了一样。

如果你正在用ARM7系列处理器(比如LPC21xx、AT91SAM7这类经典芯片),那很可能,问题就藏在一个不起眼的异常里: 预取中止(Prefetch Abort)

这玩意儿听起来挺学术,但它其实是嵌入式开发中最值得掌握的“现场取证”技能之一。尤其是在没有调试器、没有操作系统、甚至连堆栈都没初始化的裸机环境下,它几乎是唯一能告诉你“刚才到底发生了什么”的线索。


预取中止不是Bug,是CPU在报警

我们先来打个比方:

想象你在图书馆找一本书,管理员告诉你:“这本书在3楼东区第5排。”你兴冲冲跑过去,结果发现那里根本没书架——空荡荡的一面墙。

这时候你会怎么做?当然是回头问管理员:“你说的位置不对啊!”

ARM7的预取中止机制,干的就是这个事。

当CPU准备执行下一条指令时,它会去内存地址“取指”。如果那个地址压根就不该存在(比如超出了Flash范围、RAM边界外、或者某个外设区域没使能),硬件就会说:“等等,这片区域我不能让你读!”然后拉高 nABORT 信号线。

CPU一检测到这个信号,立刻停下脚步,切换到 Abort模式 ,保存现场,并跳转到异常向量表中的 0x0000000C 位置——也就是预取中止的入口。

🧠 所以说,预取中止不是程序写错了才出现的问题;相反,它是系统 正常工作的表现 ——说明硬件保护机制起了作用,阻止了灾难性的非法操作继续蔓延。

可惜的是,很多开发者把异常处理函数留空,或者只放了个无限循环:

void PrefetchAbortHandler(void) {
    while(1);  // 啥也不做
}

这就等于警察赶到案发现场,看到满地血迹,却转身走了……下次还出事,照样抓不到元凶。


异常发生时,CPU到底记住了什么?

关键就在于几个寄存器的状态。别小看这几行数据,它们就是破案的关键证据链。

1. LR_abt :最后一个已知的“安全点”

当预取中止触发时,ARM7会自动把返回地址存入 R14_abt (即Abort模式下的LR)。这个值等于 发生异常时PC减去4

为什么是PC-4?

因为ARM采用三级流水线结构:

  • PC指向当前要取的指令
  • PC-4是指令译码阶段的地址
  • PC-8是正在执行的指令地址

所以当CPU试图从A地址取指失败时,实际“想执行”的那条指令是在 PC - 8 处,而 LR_abt = PC - 4 刚好指向下一条待执行的指令。

换句话说, LR_abt + 4 大致就是导致异常的那条“致命跳转”所在的地址。

举个例子:

LDR     PC, =BadAddr    ; 假设这条指令位于 0x0000_1238
NOP                     ; 位于 0x0000_123C

如果 BadAddr 0x0009_0000 (超出映射范围),那么:

  • CPU尝试从 0x0009_0000 取指 → 失败
  • 触发预取中止
  • LR_abt 被设置为 0x0000_123C (即PC-4)
  • 因此,异常地址 ≈ LR_abt + 4 = 0x0000_1240 ?不对!

等等,这里有个陷阱!

实际上,在执行 LDR PC,=BadAddr 这条指令时,PC已经指向 0x0000_123C (ARM流水线特性),所以真正出问题的指令地址是 LR_abt - 4 = 0x0000_1238

✅ 正确公式应为:

故障指令地址 ≈ LR_abt - 4

这个细节非常重要。如果你直接拿 LR_abt 去反汇编,可能会错判成下一条指令,从而误入歧途。


2. SPSR_abt :异常前的“精神状态”

SPSR_abt 保存的是进入异常之前的CPSR内容,包括:

  • 当前处理器模式(User? IRQ? SVC?)
  • 中断使能标志(I/F位)
  • 条件标志(N/Z/C/V)

这些信息可以帮助你判断:

  • 是在中断服务程序里跳飞的?
  • 还是在用户任务中调用了野函数指针?
  • 或者系统已经被严重破坏,模式都乱套了?

比如,如果你发现 SPSR_abt 显示当时处于Undefined模式,那就说明之前已经有另一个异常没处理好,现在属于“二次事故”。


3. FAR FSR :协处理器里的“黑匣子”

这两个寄存器位于CP15(协处理器15),通过MRC/MCR指令访问:

MRC p15, 0, R0, c6, c0, 0   ; 读 FAR —— 故障虚拟地址
MRC p15, 0, R1, c5, c0, 0   ; 读 FSR —— 故障状态码

但注意⚠️:根据ARMv4T架构规范, 预取中止并不会自动更新FAR !只有数据中止才会强制写入FAR。

这意味着,如果你在预取中止处理函数里读FAR,得到的可能是上次数据中止遗留下来的脏数据!

😱 听起来很危险对吧?

所以最佳实践是:在每次进入预取中止处理函数时,先主动清零FAR:

MOV R0, #0
MCR p15, 0, R0, c6, c0, 0   ; 清除 FAR

这样后续若发生数据中止,才能确保FAR记录的是最新错误地址。

至于FSR,它提供了一个编码值,告诉你具体是什么类型的访问失败。常见值有:

FSR值 二进制 含义
0x08 0b1000 指令预取中止(外部中止)
0x0C 0b1100 数据中止:段缺失
0x15 0b10101 权限不足(用户模式访问内核页)

虽然预取中止的FSR通常是 0x08 0x0E ,但它至少能帮你确认是不是真的“取指失败”,而不是别的异常伪装过来的。


实战案例:一次固件升级后的神秘死亡

让我讲个真实的故事。

某客户反馈:新版本固件烧录后,板子完全无法启动,电源灯亮,但没有任何串口输出,JTAG也无法连接。

他们第一反应是:“Flash驱动有问题?”、“Bootloader损坏?”、“芯片焊坏了?”

但我们知道,只要芯片还能供电,就有机会留下痕迹。

于是我们在旧版固件中埋入一个“自杀测试”:

void TestSuicide(void) {
    unsigned int *p = (unsigned int*)0x00090000;
    ((void(*)(void))p)();
}

模拟跳转到非法地址。运行后,果然触发预取中止。

此时我们通过UART输出关键寄存器:

[ABORT] LR_abt: 0x0000_123C
        CPSR: 0x6000_00D3
        SPSR: 0x2000_001F
        FAR : 0x0000_0000
        FSR : 0x08

计算得出:故障指令地址 = 0x0000_123C - 4 = 0x0000_1238

反查MAP文件:

.text.main_entry  0x00001238  ... main.c

定位到了主函数入口附近的一次跳转。再一看链接脚本:

ENTRY(Main_Entry)
SECTIONS {
    .text : {
        *(.vectors)
        *(.text)
    } > FLASH_ORIGIN
}

问题来了: FLASH_ORIGIN 定义为 0x00000000 ,最大容量512KB( 0x0007FFFF ),但新版固件体积已达600KB!

linker毫不客气地把部分代码塞进了 0x00080000 以上区域——而这正是外部存储控制器未映射的空间。

结论: 链接脚本未适配新固件大小,导致入口函数落在非法区域

修复方法很简单:调整片选配置,扩展EMC地址映射,或优化代码分区。

但这背后的意义更大:如果没有预取中止机制,这个问题可能需要一周时间排查;有了它,我们只用了两个小时。


如何构建一套可靠的异常诊断框架?

你不需要每次都手动分析寄存器。我们可以封装一个轻量级的“异常侦探包”。

第一步:重定向异常向量表

必须保证前8个word都是合法指令,否则连异常处理自己都会崩。

典型做法是在启动文件中定义:

.section .vectors, "ax"
.globl _vectors_start

_vectors_start:
    LDR PC, =Reset_Handler
    LDR PC, =Undefined_Handler
    LDR PC, =SWI_Handler
    LDR PC, =PrefetchAbort_Handler
    LDR PC, =DataAbort_Handler
    LDR PC, =NotUsed_Handler
    LDR PC, =IRQ_Handler
    LDR PC, =FIQ_Handler

所有Handler都要指向有效函数,哪怕只是打印日志。


第二步:编写“裸体”异常处理函数

使用 __attribute__((naked)) 防止编译器插入栈操作或其他潜在非法访问:

void __attribute__((naked)) PrefetchAbort_Handler(void)
{
    __asm volatile (
        "STMFD SP!, {R0-R3, R12, LR}       \n\t"   // 保存通用寄存器
        "MRC    p15, 0, R0, c6, c0, 0      \n\t"   // 读FAR
        "MRC    p15, 0, R1, c5, c0, 0      \n\t"   // 读FSR
        "STR    R0, [SP, #-4]!             \n\t"   // 临时压栈
        "STR    R1, [SP, #-4]!             \n\t"
        "MOV    R0, LR                     \n\t"   // 传参:LR_abt
        "MRS    R1, SPSR                   \n\t"   // 传参:SPSR
        "BL     LogPrefetchAbort           \n\t"   // C语言日志函数
        "B      Reset                      \n\t"   // 最终复位
    );
}

然后在C层解析并输出:

void LogPrefetchAbort(unsigned int lr_abt, unsigned int spsr)
{
    unsigned int far, fsr;
    __get_far_fsr(&far, &fsr);  // 内联汇编读取

    uart_printf("\r\n[PREFETCH ABORT]\r\n");
    uart_printf("Instr Addr: 0x%08X\r\n", lr_abt - 4);
    uart_printf("SPSR      : 0x%08X\r\n", spsr);
    uart_printf("FAR       : 0x%08X\r\n", far);
    uart_printf("FSR       : 0x%08X (%s)\r\n", 
                fsr, fsr == 0x08 ? "Ext PAbort" : "Unknown");

    DumpModeFromSPSR(spsr);  // 分析原运行模式
}

第三步:建立“异常指纹库”

长期收集不同场景下的异常日志,你会发现一些典型的模式:

现象 可能原因
LR_abt 总在中断向量附近 NVIC配置错误,IRQ跳转到NULL
SPSR 显示IRQ模式 在中断中修改了函数指针并立即调用
FAR=0 , FSR=0x08 典型预取中止,地址由LR推算
FAR≠0 , FSR=0x0C 数据访问段缺失,可能DMA越界

把这些做成checklist,下次遇到类似问题可以直接匹配。


为什么说这是每个嵌入式工程师的“必修课”?

因为在真实世界中,以下情况比比皆是:

  • 客户现场没法接JTAG
  • 量产设备不允许留调试接口
  • Bootloader阶段根本没有调试支持
  • 系统跑着跑着突然重启,日志全无

而预取中止异常就像一颗微型黑匣子,默默地记录着每一次“致命跳跃”的轨迹。

更重要的是,它的实现成本极低:

  • 不需要操作系统
  • 不依赖malloc或复杂库
  • 即使栈都没初始化,也可以用SP做最基础的日志输出(比如点亮LED闪烁编码)

只要你愿意花半小时把它接通,未来就能省下几十个小时的盲目排查。


一个容易被忽视的设计细节:FAR的一致性管理

再说一遍重点: 预取中止不会自动更新FAR

这意味着如果你不清它,下一次数据中止发生时,FAR里可能还是几个月前某个已解决bug的残留地址。

更可怕的是,有些RTOS或库函数会在内部触发数据访问异常,而你的异常处理函数如果不区分类型,就会误把FAR当作预取中止的来源。

解决方案有两个:

✅ 方法一:进入每类异常时主动清理FAR

// 在PrefetchAbort和DataAbort开头都加这一句
__asm__("MCR p15, 0, %0, c6, c0, 0" :: "r"(0));

确保每次都是“干净”的上下文。

✅ 方法二:只信任LR_abt用于预取中止定位

既然FAR不可靠,那就干脆不用。坚持用 LR_abt - 4 推算故障地址,配合MAP文件反查符号,准确率更高。

毕竟, 最可靠的证据永远来自CPU自身的行为逻辑 ,而不是某个可能被污染的寄存器。


如何避免误报?外部硬件也很关键

有时候你会发现:明明地址是合法的,却频繁触发预取中止。

这往往不是软件的问题,而是硬件时序没调好。

比如:

  • Flash访问周期太短,没等数据稳定就采样
  • 外部SRAM未启用页模式,突发访问超时
  • 地址线接触不良,偶发错位

这时你需要检查:

  • 存储控制器(EMC/BSC)的等待周期(WAIT cycles)
  • 片选信号的有效宽度
  • 总线仲裁是否冲突

有些芯片手册明确写着:“若总线无响应超过N个周期,则产生中止信号。”

所以, 预取中止不仅是内存映射问题,也可能是时序问题的间接反映

建议在系统初始化早期就完成所有存储外设的配置,最好在第一条C代码运行前搞定。

否则,编译器生成的 .rodata 变量一旦落在未配置区域,还没进main()就挂了,你还以为是启动文件写错了……


让异常成为系统的“免疫细胞”

最后我想说的是:不要害怕异常。

恰恰相反,你应该欢迎它们的到来。

每一次预取中止,都是系统在说:“嘿,我差点干了件蠢事,幸亏你设置了防护!”

与其想着怎么屏蔽异常,不如思考如何让它更有价值:

  • 把异常日志通过CRC校验保存到备份RAM
  • 上电自检时回放上次异常记录
  • 结合看门狗实现“异常→记录→复位→恢复”
  • 在安全关键系统中,将多次异常上报云端监控

甚至可以设计一种“灰度发布”机制:在测试版本中允许某些非关键异常继续运行,仅记录而不复位,用于收集边缘场景数据。

这才是现代嵌入式系统应有的健壮性思维。


写在最后:那些年我们跳过的坑

回想这些年踩过的坑:

  • 函数指针数组越界,跳到了 .bss 区执行,触发中止
  • 动态加载模块释放后未置空回调,下次定时器触发直接飞走
  • 编译器优化导致常量池跨区,链接脚本没预留足够空间
  • 中断向量表复制错误,HardFault Handler变成了NOP序列……

每一个,都可以通过预取中止的日志快速定位。

所以,请务必在你的下一个项目里:

✅ 开启预取中止异常处理
✅ 至少输出 LR_abt SPSR
✅ 用UART/LED留下一点痕迹
✅ 把这套机制变成模板固化下来

别等到系统上线后再后悔:“要是当初留个日志就好了……”

毕竟, 最好的调试工具,不是JTAG,而是你提前埋好的那一行异常处理代码

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐