LV015-帧指针偏移分析与运行时校验
按 LV005 实现完打印后,首测输出整体错位一格:PC 落在 FSMC 区、xPSR 的 T 位为 0,明显不是合法的异常帧。本文记录从「感觉错位」到「证据确凿」的四步交叉验证,以及最终不依赖具体板子行为的运行时校验修法。
一、 问题现象
打印功能就绪,烧录运行,崩溃如期发生,但栈帧部分明显不对劲:
R0 = 0x08004F27
R1 = 0x08004F28
R2 = 0x00000000
R3 = 0x00000000
R12 = 0x080008E5
LR = 0x08005C78 (EXC_RETURN) ← 标注还是错的
PC = 0x61000000 <-- faulting instruction ← FSMC 区地址, 不可能是代码
xPSR = 0xF6FCFB78 ← bit24 的 T 位为 0, 线程模式不可能三个值单独看都荒谬,合起来更像一个整体错位的快照:把窗口往里挪一格,一切就合理了。本文记录怎么从「感觉错位」走到「证据确凿」,再到「不依赖具体板子行为的修法」。
二、 分析方法:四步交叉验证
1. 第一步:用「必然存在的值」当探针
分析偏移类问题,不要从「哪个值看起来对」出发,要从测试现场已经确定的事实出发。本次崩溃是读 0x30000000 触发的,源码与反汇编都确认出错指令序列是:
MOV r0,#0x30000000
LDR r0,[r0,#0] ; ← 出错指令精确总线错误下,硬件压栈的 R0 就是出错瞬间的 R0——必然等于 0x30000000。
但打印出的 8 个字里根本没有 0x30000000(它只出现在 MMFAR/BFAR 里,那是状态寄存器,不是栈帧)。结论立刻明确:打印窗口漏掉了栈上 R0 那一格,窗口起点比真实帧基址高了至少一格。
2. 第二步:反汇编实际烧录的固件,而不是重新编译
要确认代码路径,必须反汇编板子上跑的那份固件。用 fromelf 直接分析 .axf:
fromelf -c atk-f103.axf > axf_disasm.txt三个关键确认:
- 向量表(
__Vectors段)第 3 项确实指向 C 版HardFault_Handler,没有指错到跳板; HardFault_Handler的指令是BL hardfault_trampoline+ 自循环,无 prologue 压栈;- 跳板的指令是
MRS r0, MSP/ADD r0, r0, #4/BL hardfault_dump,与源码一致。
同时验证出错指令地址:反汇编里 0x08005C78 处正是 LDR r0,[r0]——而它出现在打印窗口的第 6 个字(偏移 0x14),恰好是异常帧里 PC 槽的位置。窗口起点比帧基址高 4 字节,实锤。
【注意】
分析固件行为一定要用实际烧录的 axf/hex 对应的 axf,不要重新编译一份再看。编译器版本、优化选项、甚至源码后续改动都会让地址对不上。本次就是靠串口 banner 里的 __DATE__ __TIME__ 时间戳(build Oct 15 2026 09:56:13)与 axf 文件时间戳对齐,确认分析对象无误。这个 banner 时间戳习惯值得每个工程保留。
3. 第三步:三个独立槽位交叉验证
单凭 PC 一个特征还不够,把三个槽位一起验:
| 打印窗口位置 | 值 | 按「帧基址 = 窗口起点 - 4」解读 | 是否合理 |
|---|---|---|---|
第 5 个字(+0x10) | 0x080008E5 | 帧的 LR 槽 | 奇数 = 合法 Thumb 返回地址形态 ✓(-O3 下 LR 槽是最后一次 BL 的残留值,未必反映调用链,详见 LV020) |
第 6 个字(+0x14) | 0x08005C78 | 帧的 PC 槽 | 精确总线错误的出错指令地址 ✓ |
第 7 个字(+0x18) | 0x61000000 | 帧的 xPSR 槽 | bit24(T)= 1、IPSR = 0,合法线程模式 xPSR ✓ |
三个互不相关的槽位全部自洽,偏移量确定为 +4:传给打印函数的指针 = 真实帧基址 + 4。
4. 第四步:用对齐规律做旁证
重新烧录修正版后,打印里有了 MSP(entry) = 0x20005DA4。按 +4 偏移算,帧基址 = 0x20005DA0:
0x20005DA0以 0 结尾,恰好 8 字节对齐;0x20005DA4则是 4 mod 8。
这与「入口 MSP = 帧基址 + 4,帧基址保持 8 字节对齐」的规律完全吻合——栈帧落在 8 字节边界上是 AAPCS 的自然结果,旁证成立。
三、 结论与修法:不硬编码偏移
标准 ARMv7-M 手册模型里,异常入口的 MSP 就等于帧基址,这也是绝大多数 HardFault 示例(MRS r0, MSP 后直接用)的前提。但本板实测入口 MSP 比帧基址高 4 字节(STM32F103,Cortex-M3 r1p1,成因未深究,按实测行为处理)。
错位的责任不在跳板:MRS + ADD #4 本身没错,它只是把 HardFault_Handler 里 BL 压掉的 4 字节补了回来,传入的正是入口 MSP。错在打印一侧把入口 MSP 直接当帧基址用——在这块板子上,两者恰好差 4 字节,于是窗口整体高了一格。
1. 为什么不能把偏移改成 -4 了事
两个原因:
- 不可移植。换内核版本、换编译器、换优化等级,偏移可能变化——在标准行为的内核上硬编码 -4 反而会错;
- 不可证明。写死 -4 只是「这台机器此刻对」,下次换板子出错时,你无法从输出判断是偏移又变了还是别的问题。
2. 运行时校验定位
改为在入口 MSP 附近扫描候选偏移,用异常帧的三个独立特征挑出真正的帧基址:
// Core/Src/stm32f1xx_it.c(节选)
static uint32_t *hardfault_find_frame(uint32_t msp, int32_t *p_off)
{
static const int32_t cand[] = { -4, 0, 4, -8, 8 }; /* 常见偏移, -4 为本板实测值 */
uint32_t i;
for (i = 0u; i < (sizeof(cand) / sizeof(cand[0])); i++)
{
uint32_t *f = (uint32_t *)(uint32_t)((int32_t)msp + cand[i]);
if (((f[7] & 0x01000000u) != 0u) /* EPSR.T = 1 */
&& ((f[7] & 0x000001FFu) <= 0x0000004Bu) /* IPSR 为合法异常号(F103ZE 60 个 IRQ,最大异常号 0x4B) */
&& (f[6] >= 0x08000000u) && (f[6] < 0x08100000u) /* PC 在内部 Flash */
&& ((f[5] & 1u) != 0u)) /* LR 是返回地址 */
{
*p_off = cand[i];
return f;
}
}
*p_off = -4; /* 兜底: 本板实测偏移 */
return (uint32_t *)(uint32_t)((int32_t)msp - 4);
}【函数作用】
在入口 MSP 附近的候选偏移里,找出真正满足异常帧特征的帧基址
【参数含义】
msp:跳板传入的异常入口 MSPp_off:输出参数,回写命中的候选偏移量(随打印输出,如frame offset = -4)
【返回值】
有候选命中时返回定位到的帧基址指针;全部候选校验失败时返回按本板实测偏移 -4 计算的兜底指针
2.1 候选偏移是怎么来的
帧基址 = 传入值 + 某个固定偏移,而这个偏移由三件事叠加决定,每件事的取值都有明确来源:
(1)硬件把 SP 留在哪:ARMv7-M 规定压完 8 个字后 SP 指向 R0 槽(= 帧基址);若故障前 SP 是 4 mod 8 且 STKALIGN=1,硬件会在 R0 下方多垫一个字,SP = 帧基址 − 4;本板实测则是 SP = 帧基址 + 4(见本章开头)。相对帧基址分别是 0、−4、+4。
(2)C 包装在跳板 MRS 之前压了几个字:尾调用不压(0)、BL(−4)、PUSH {lr} + BL(−8)、PUSH {r4,lr} + BL(−12)等,由编译器代码生成决定,换编译器或优化等级可能变化。
(3)跳板补偿多少:本工程固定 ADD r0, r0, #4(假定包装用 BL 调用)。
把组合代入「传入值 = 硬件 SP + 包装压栈 + 跳板补偿」,就得到候选集合的来历(c 为「帧基址 − 传入值」,即 cand 数组里的值):
| 场景 | 硬件压栈后 SP | 包装压栈 | 传入值 | 命中候选 c |
|---|---|---|---|---|
| 教科书标准链 | 帧基址 | BL(−4) | 帧基址 | 0 |
| STKALIGN 垫字 | 帧基址 − 4 | BL(−4) | 帧基址 − 4 | +4 |
| 本板实测 | 帧基址 + 4 | BL(−4) | 帧基址 + 4 | −4 |
| 标准硬件 + 尾调用 | 帧基址 | B.W(0) | 帧基址 + 4 | −4 |
| 标准硬件 + 包装多压一个字 | 帧基址 | PUSH{lr}+BL(−8) | 帧基址 − 4 | +4 |
| 标准硬件 + 包装多压两个字 | 帧基址 | PUSH{r4,lr}+BL(−12) | 帧基址 − 8 | +8 |
| 本板 + 尾调用 | 帧基址 + 4 | B.W(0) | 帧基址 + 8 | −8 |
cand[] = { -4, 0, 4, -8, 8 } 正好覆盖这些组合:−4 排最前是本板实测优先;0 是标准链;+4 对应垫字或多压一字;±8 对应压栈更深的变体。相邻候选只差 4 字节,而 2.3 节将说明「错一格的窗口必然过不了校验」,所以不存在两个候选同时通过、先后顺序影响结果的问题。
2.2 四个校验条件与上下限是怎么来的
校验对象是异常帧的三个槽位:f[7](偏移 0x1C,xPSR)、f[6](0x18,PC)、f[5](0x14,LR),布局即 LV001 三.3.1 的栈帧表。四个条件各自钉死一条架构不变量:
(1)f[7] & 0x01000000——EPSR 的 T 位(bit24,掩码即 1<<24)。Cortex-M 只执行 Thumb 指令,真实压栈的 xPSR 该位恒为 1(为 0 本身就会触发 INVSTATE)。随机数据通过率 1/2。
(2)f[7] & 0x1FF 后 <= 0x4B——IPSR。IPSR 占 xPSR 低 9 位(掩码 0x1FF,ARMv7-M 的异常号字段就是 9 位宽,最多容纳 512 个异常号);取值 0 = 线程模式,1~15 = 系统异常,16 起 = 外部 IRQ。上限 0x4B 来自本芯片:F103ZE 共 60 个 IRQ,最大异常号 = 16 + 59 = 75 = 0x4B,换芯片要跟着 IRQ 总数改。随机 9 位值通过率约 76/512 ≈ 15%。
(3)0x08000000 <= f[6] < 0x08100000——PC 槽。本系列固件的代码只可能在内部 Flash:下限 0x08000000 是 STM32F1 的 Flash 基址;上限取 0x08100000(XL 密度 1MB 的顶),本板 F103ZE 实际只有 512KB(到 0x08080000),放宽一档只为兼容同系更大容量,对判定无影响。随机 32 位值落入这 1MB 窗口的概率是 1/4096,四条里排除力最强。
(4)f[5] & 1——LR 槽。Thumb 调用约定下返回地址 bit0 = 1(BX/BLX 靠它指示 Thumb 态),异常发生时程序里的 LR 若是返回地址必为奇数;嵌套场景压的可能是 EXC_RETURN(0xFFFFFFF1/F9/FD),同样为奇数。随机数据通过率 1/2。
2.3 排除力:错一格为什么过不了
三个特征各自的排除力:
| 特征 | 能排除的误判 |
|---|---|
| xPSR 的 T 位 = 1 且 IPSR ≤ 0x4B | 普通栈上数据(随机值几乎不可能同时满足) |
| PC 落在内部 Flash 范围 | 把 xPSR 当 PC、把 LR 当 PC 这类错一格 |
| LR 的 bit0 = 1 | 把 R12 / R1 当 LR 这类错两格 |
拿本板实测值把「错一格」具体走一遍更直观:
(1)窗口比真帧基址高 4 字节(f = 帧基址 + 4):f[6] 读到的是 xPSR 槽 0x61000000——它本来就是合法 xPSR,T 位、IPSR 检查形同虚设,但 0x61000000 落在 FSMC 区,被 PC 范围检查拦下。
(2)窗口低 4 字节(f = 帧基址 − 4):f[7] 读到的是 PC 槽 0x08002D7C,bit24 = 0,T 位检查直接拦下;窗口再往下挪,f[7] 依次读到 LR / PC 槽的 0x08xxxxxx,bit24 同样是 0。
(3)联合误判率:四个条件近似独立,一个随机 8 字窗口全部通过的概率 ≈ 1/2 × 15% × 1/4096 × 1/2 ≈ 9×10⁻⁶,五个候选全扫一遍也只有约 4.5×10⁻⁵——栈上残留数据实际不可能凑齐这张「指纹」,而真实帧必然通过。
2.4 兜底与已知边界
候选顺序把 -4 放在最前:本板实测行为优先命中,其他板子也会因为校验失败而落到正确候选。命中的偏移随打印输出(frame offset = -4),下次换环境一眼就能看出偏移是否变化。
三条已知边界:
(1)兜底与命中无法从输出区分:全部候选失败时按本板实测 −4 兜底返回,打印仍是 frame offset = -4,与「校验命中 −4」看起来一样。增强方向是兜底时多打一行警告。
(2)PC 不在 Flash 的崩溃会被误拒:若崩溃瞬间正在执行搬到 RAM 里运行的代码(典型如 Flash 擦写例程),PC 槽是 0x2000xxxx,条件(3)必失败,只能走兜底。此时兜底值 −4 在本板上大概率恰好正确,但 PC 字段要按 RAM 代码去解读。
(3)帧本身被写坏则全部失效:栈溢出覆盖了异常帧(MSTKERR 场景)时四个条件全灭,同样落兜底,读出的寄存器不可信——与 LV001 三.4.1.1 的结论同源。
3. 附带修正:LR 槽的标注
顺带修一个概念错误。异常入栈时,硬件压入的是出事前的 LR 值(即出错函数调用者的返回地址),而 EXC_RETURN 是被写进 LR 寄存器的,并不在栈帧里。所以打印标签不能写 LR (EXC_RETURN),正确标注是「return address of faulting context」。想知道 EXC_RETURN,只能在 handler 第一条指令(任何 BL 之前)读 LR 寄存器——这也是 RTOS 场景必须把汇编跳板做成向量表直接入口的原因。
四、 修复后的自洽输出
================ HardFault Dump ================
MSP(entry) = 0x20005DA4, frame offset = -4
R0 = 0x30000000
R1 = 0x08004FCB
R2 = 0x08004FCC
R3 = 0x00000000
R12 = 0x00000000
LR = 0x080008ED (return address of faulting context)
PC = 0x08005D1C <-- faulting instruction, locate it in disassembly
xPSR = 0x61000000验收标准只有一条:R0 与 BFAR / MMFAR 三处都指向 0x30000000。三个独立来源(寄存器快照、两个状态寄存器)互相印证,说明从故障触发、硬件压栈、跳板取址到帧定位的整条链路正确,此时 PC 才可以被拿去定位源码(见 LV020)。
五、 可复用的排查套路
这次问题里真正通用的不是「+4」这个数字,而是方法:
(1)找现场里必然成立的量(测试函数故意读的地址、必然置位的状态位),用它当探针验证快照。
(2)分析对象必须是实际运行的固件(反汇编 axf,用构建时间戳对齐),不能是重新编译的产物。
(3)用多个独立特征交叉验证同一结论,单一特征可能是巧合,三个同时自洽就不是。
(4)行为不确定时,运行时校验而非硬编码,并把校验结果打印出来,让差异可见。
本文档由 markdowncli 技能辅助生成