目录
第一部分:为什么每个嵌入式工程师都必须懂寄存器调试?
第二部分:Cortex-M寄存器基础——从R0到R15
第三部分:Default Handler / HardFault是怎么触发的?
第四部分:实战——复现HardFault并通过寄存器定位(核心章节)
第一部分:为什么每个嵌入式工程师都必须懂寄存器调试?
大部分小白只会用printf调试,但HardFault发生时printf本身可能已经不可用了,或者是实际工程上串口或者其他形式的通信协议已经无法进行自身的再扩展,这时在debug模式下利用寄存器去解决一些实际问题则很重要了。
第二部分:Cortex-M寄存器基础——从R0到R15
我们先捋一捋,R0到R15的实际作用是什么。
| 寄存器 | 解释 |
| R0~R3 | 通常是函数内cpu用来计算的 |
| R4~R11 | 存局部变量,被调用者保存 |
| R12 (IP) | 临时打杂,编译器常用 |
| R13 (SP) | 指向当前栈顶,PUSH/POP、局部变量、中断现场都靠它 |
| R14 (LR) | 保存函数返回地址;异常时保存 EXC_RETURN |
| R15 (PC) | 指向下一条要执行的指令,写 PC 就是跳转 |
关于sp可以看这一篇文章ARM——栈 - dongry - 博客园,最后理清楚这一张图就可以了:
有了这个前置知识后我们先看一个简单的代码:
volatile int counter = 0; void increment(void) { int a = 0; a++; counter = a; } int main(void) { increment(); }这段代码只做了一件事:将局部变量 a 从 0 变成 1,然后赋值给全局变量counter,最后再在main中调用此函数
但实际CPU的运行并没有那么简单:
找到 a 在哪里;
把 a 的值读到寄存器;
寄存器里加 1;
把结果写回 a;
再把 a 写到 counter;
最后返回。
实际反汇编长这样:
increment: push {r7, lr} ; 保存 r7 和返回地址 LR sub sp, sp, #8 ; 给局部变量 a 腾出 8 字节栈空间 add r7, sp, #0 ; r7 作为帧指针,方便找局部变量 movs r3, #0 ; r3 = 0 str r3, [r7, #4] ; a = 0,把 0 写到栈上 ldr r3, [r7, #4] ; r3 = a,把 a 读到 r3 adds r3, r3, #1 ; r3 = r3 + 1,相当于 a + 1 str r3, [r7, #4] ; a = r3,把结果写回 a ldr r3, [r7, #4] ; 再读 a 到 r3 ldr r2, .L3 ; r2 = &counter,取 counter 地址 str r3, [r2] ; counter = r3,把 a 的值写到 counter add sp, sp, #8 ; 释放局部变量空间 pop {r7, pc} ; 恢复 r7,把 LR 弹回 PC,函数返回 .L3: .word counter在此之前我们先解释一下压栈是什么东西:
栈,本质上就是 RAM 里划出来的一块内存区域。那为什么要进行压栈呢?因为寄存器数量有限,而函数调用、中断、异常会不断发生。如果不保存,原来的值就会被覆盖,这个时候如果不进行压栈,首先我是根本回不到我原本的函数,因为我没有保存返回地址,第二假设我们可以回调原来的函数,在调用完新的函数以后,回到原来的函数就会因为没有压栈导致原来的数值丢失,从而无法正常运行。总共的压栈步骤大概分为这个几步:
1. 保存返回地址 2. 保存局部变量 3. 保存被调用者保存寄存器
结合一下我这段代码解释:因为 increment 是被 main 调用了。CPU 执行 increment 时,会自动把“返回地址”写进 LR然后。函数结束时要靠 LR 回到 main。
我们来逐行来解释每一个寄存器都在干什么?
1.push {r7, lr}
push是压栈。
此时:
SP 会减小,指向新的栈顶;
R7 被保存到栈里;
LR 也被保存到栈里。
为什么保存的是R7?因为 R7 属于 R4~R11,是“被调用者保存”的寄存器。increment 想拿 R7 当帧指针用,就必须先保存原来的值,返回前再恢复。
2.sub sp, sp, #8
局部变量 a 不是凭空住在 CPU 里的,它通常住在栈上。此段就可以理解为往下挪 8 字节,给a腾地方。
此时:
SP 指向新的栈顶;
PC 继续指向下一条指令。
3.add r7, sp, #0
把 SP 的值加上 0,结果放到 R7。加 0 等于没加,所以它真正等价于:mov r7, sp(把当前栈指针 SP 的值复制到 R7)
为什么要抄给R7? 先理解SP是那只一直移动的手,它永远指着最上面那张便签;每张便签上写一个数据;你只能从最上面放新便签;也只能从最上面拿走便签;你不能从中间抽一张,也不能从底下塞一张。如以下两种情况
这就是压栈:
┌──────────┐
│ 便签 3 │ ← SP 指着这里(最上面)
├──────────┤
│ 便签 2 │
├──────────┤
│ 便签 1 │
└──────────┘
┌──────────┐
│ 便签 4 │ ← SP 现在指着这里(新最上面)
├──────────┤
│ 便签 3 │
├──────────┤
│ 便签 2 │
├──────────┤
│ 便签 1 │
└──────────┘
这就是出栈:
┌──────────┐
│ 便签 4 │ ← SP 现在指着这里(最上面)
├──────────┤
│ 便签 3 │
├──────────┤
│ 便签 2 │
├──────────┤
│ 便签 1 │
└──────────┘
┌──────────┐
│ 便签 3 │ ← SP 指着这里(新最上面)
├──────────┤
│ 便签 2 │
├──────────┤
│ 便签 1 │
└──────────┘
函数里一旦继续 PUSH、POP,或者再sub sp,SP 就会动来动去;如果每次找局部变量都要用“当前 SP + 偏移”,那 SP 一变,偏移就全乱了。所以编译器找了一个“不动”的基准:在函数刚进来、局部变量空间刚分配好的时候,把当时的 SP 记到 R7 里。以后就都用 R7 来定位局部变量。后面不管 SP 怎么动,R7 都不动,找局部变量就方便了。
4.movs r3, #0 str r3, [r7, #4]
R3 是通用寄存器,充当临时存储位;a 在栈上,地址由 R7/SP 定位;str 是 store,把寄存器里的值写到内存。
此时:
先把 0 放进 R3,再把R3写到 a 所在的内存。
5.ldr r3, [r7, #4]
ldr 是 load,把内存里的值读到寄存器。R7由上两条的反汇编后,R7(SP)+4 的地址存储的就是 a 的值,此时是把这个地址的值赋值给 R3。
此时:
R3 里现在是 a 的值,也就是 0;
PC 指向下一条 adds。
6.adds r3, r3, #1
这就是 a++ 的核心:不是内存自己会加 1,而是先把值读到 R3,再让 R3 加 1。
此时:
R3 从 0 变成 1;
PC(指向下一条要执行的指令) 指向下一条 str 指令。
7.str r3, [r7, #4] ldr r3, [r7, #4]
str 是 store,把寄存器里的值写到内存。将 R3 寄存器里面的值存储到 R7(SP)+4 ,现在 a 才真正从 0 变成 1。
ldr 是 load,把内存里的值读到寄存器。再读 a 的值到 r3寄存器。
8.ldr r2, .L3 str r3, [r2]
r2 = &counter,取 counter 地址。
此时:
R3 里是 a 的值,也就是 1;
R2 里是全局变量 counter 的地址;
str r3, [r2] 把R3的值写到 r2 指向的地址即是 counter的地址。
9.add sp, sp, #8 pop {r7, pc}
add sp, sp, #8 = ADD 目标寄存器, 源1, 源2。
pop 是出栈,意思是“从当前 SP 指向的栈顶,把数据弹回寄存器”。
此时:
SP = SP + 8 。现在函数要结束了,局部变量 a 不再需要,所以SP 加回去,栈顶就上移了,a 的空间就被“释放”了。
恢复旧的 R7,把之前 push {r7, lr} 保存的返回地址(LR)弹进 PC;
第三部分:Default Handler / HardFault是怎么触发的?
通过上面的例子现在对15个寄存器有了初步的认识,我们再来补充最后一个前置知识。
什么是Default Handler?HardFault 是 Cortex-M 处理器中最“兜底”的异常,简单理解就是当程序执行到一些无法处理的错误的时候,就会跳转到HardFault函数(是一个while(1)死循环),此时 cpu 就卡在那个函数,无法跳出。
那么通常又会因什么触发呢?
| 序列号 | 原因 | 典型现象 |
| 1 | 空指针 / 野指针解引用 | PC 为 0,或 BFAR 有非法地址 |
| 2 | 数组越界 / 内存踩踏 | 返回时 PC 跳到奇怪地址 |
| 3 | 堆栈溢出 | SP 接近栈底,PC 在0x2000xxxx |
| 4 | 函数指针 / 回调被破坏 | 调用后 PC 到 0 或 RAM 区 |
| 5 | 中断向量表未重定位 | 主循环正常,一开中断就 HardFault |
这里就拿序列1去进行解释,有兴趣的铁可以自行学习一下。
1.空指针 / 野指针解引用
空指针:
typedef void (*fn_t)(void); fn_t f = NULL; f(); // 跳到地址 0 去取指令野指针:
uint32_t *p = (uint32_t *)0x12345678; // 一个随便写的地址 *p = 0xABCD; // 这个地址很可能没有对应内存访问 NULL 或者野指针,本质上是 CPU 去读/写一个没有对应物理内存的地址,这个时候就会触发Default Handler,跳转到 HardFault。
第四部分:实战——复现HardFault并通过寄存器定位(核心章节)
现在我先复现一个典型的HardFault:
我们将断点打到trigger_hardfault();
切换debug模式:
点击全速运行来到trigger_hardfault();如果正常运行的话则会跳到while(1)前。
可以发现已经跳到了hardfault里面
此时我们就需要看向右边的寄存器一栏:
可以发现此时的LR寄存器已经不是0x0800xxxx是一个特殊值,这是当当发生中断或异常时,CPU 硬件会自动做两件事,把出错现场的寄存器压入当前堆栈(MSP 或 PSP),把LR改写成一个特殊值(EXC_RETURN),这个值的高 28 位全是 1,所以我们只需要看最后4位的:
| 位 | 名称 | 当前值0xFFFFFFF9 | 含义 |
| bit[3] | Return Mode | 1 | 1 = 返回 Thread 模式 0 = 返回 Handler 模式 |
| bit[2] | Return Stack | 0 | 0 = 返回时使用 MSP 1 = 返回时使用 PSP |
| bit[1] | 保留 | 0 | 保留位,通常为 0 |
| bit[0] | Return State | 1 | 1 = 返回 Thumb 状态(Cortex-M 只支持 Thumb,所以恒为 1) |
所以我们此刻返回的是 msp 下的thread模式,可看到msp为 0x20000488。得到这个地址以后我们打开 memory windows去查看对应地址下存放的内容,可以知道到 hardfault 前的栈顶内容是什么,从而知道对应的 LR(此时指向的是出错函数的返回地址)。
右键更改显示形式回更舒服:
由这张图可知内容:
| 地址 | 内存内容 | 对应寄存器 | 实际值 |
| 0x20000488 | BEEF DEAD | R0 | 0xDEADBEEF |
| 0x2000048C | 5678 1234 | R1 | 0x12345678 |
| 0x20000490 | 000D 0000 | R2 | 0x0000000D |
| 0x20000494 | 3800 4001 | R3 | 0x40013800 |
| 0x20000498 | 0000 0000 | R12 | 0x00000000 |
| 0x2000049C | 1FD1 0800 | LR | 0x08001FD1 |
| 0x200004A0 | 1FD0 0800 | PC | 0x08001FD0 |
| 0x200004A4 | 0000 4100 | xPSR | 0x41000000 |
这里再次解释一下整个触发流程:
步骤1执行时:p 被赋值为 0xDEADBEEF,这个值通常被放在寄存器 R0 或栈上。
步骤2执行时:CPU 准备执行 STR R1, [R0](把 0x12345678 写入 0xDEADBEEF)。
硬件检测到错误:总线发现0xDEADBEEF没有对应的物理内存,立刻向CPU报告总线错误。
自动压栈:CPU 收到错误信号,决定进入 HardFault。此时,CPU 硬件自动把当前 8 个核心寄存器的值压入当前使用的堆栈(此时是 MSP)。
跳转:硬件把 HardFault 处理函数的地址加载到 PC,程序跳进 HardFault_Handler。
也就是说:
LR = 0x08001FD1,这是之前调用它的地方(函数执行完后应该回到哪里)。
所以我们只需要根据这个地址跳转到目标函数位置就可以知道是哪个函数出现了问题:
先点出disassembly window:
后在disassembly window的区域右键:
点击 show disassembly at address 再输入目标地址最后点击 go to:
可以发现蓝色指针指向的是:
因为LR = 0x08001FD1,这是之前调用它的地方(函数执行完后应该回到哪里)。
所以while(1)前的这个trigger_hardfault()就是导致系统错误的地方。如果不敢保证的话其实是可以根据 R0-R3 的值确认是哪个函数使用了这些参数(这样就更加确认了是trigger_hardfault())。
确认了是哪个函数导致了hardfault这就好处理了,接下来就根据实际情况进行bug的修改就好了。
非常你能看到这里!希望我的这篇文章能帮到你!如果文章有什么错误或需要讨论的都可以在评论区进行讨论或者直接私信我就好了。感谢相遇。