Reset_Handler源码分析,为什么我刚开始调试APSR就已经有值了?
[W04/16] 从 Reset_Handler 开始——读懂芯片上电到 main 的 42 行汇编
《Cortex-M33 内核与 RTOS 源码学习》系列第 4 篇
硬件:NUCLEO-L552ZE-Q · 配套书:Yiu Ch6(异常与中断)+ ARMv8-M Architecture Reference Manual B3.3
目标:逐行读完 Reset_Handler + 字节级还原 xPSR 初值的来源
结果产物:W2 四颗种子全部破案,启动路径从硬件引脚到main()彻底打通
开篇:W2 留下的最后一颗种子
W2 我们用调试器看了 10 个寄存器,发现了 4 个意外:
- MSP 不在
0x2004_0000,而是0x2002_FFF8 - LR 是
0xFFFF_FFFF(奇数) - xPSR bit24(T)= 1
- xPSR 进入 main 前 bit30 (Z) = 1、bit29 © = 1
W3 用 .ld 破了前两颗(_estack = 0x2003_0000 的来源 + W3 末尾给出了预告)。
还有第 4 颗没答案——为什么 Z 和 C 标志恰好都是 1?
这周把这颗种子的答案挖出来。答案藏在 startup_stm32l552xx.s 的 FillZerobss 循环的最后一次 cmp 指令里——一个能用 Flash 里 6 个字节(4b 08 42 9a d3 f9)解释的现象。
本周收获一句话:
Reset_Handler 的 42 行汇编 = 硬件引脚到
main()的全部剧本。
读懂这 42 行,你就知道芯片上电后"前 1 毫秒"到底发生了什么。
零、从 C 源码到芯片上跑:编译链接 5 步全景
在扣 Reset_Handler 汇编之前,先看一眼全景。从 main.c 到芯片上跑起来的代码,中间经历 5 步,缺一步都不行。
0.1 五步流水帐
第一步 第二步 第三步 第四步 第五步
~~~~~~ ~~~~~~ ~~~~~~ ~~~~~~ ~~~~~~
编译器 链接器 链接脚本 启动代码 运行时
C / asm → .o → .elf → .bin/.hex → 烧录到 Flash → CPU 取指
▲ ▲ ▲ ▲
挖坑 填坑 搬家 main()
0.2 每步具体干什么
第一步:编译器生成 .o 文件
main.c ──编译器──→ main.o
编译器做的事:
- 把 C 代码翻译成汇编指令
- 遇到塞不进指令的大常数(如 32 位地址
0x40021000),在函数末尾预留字面量池槽位 - 遇到外部函数调用(如
HAL_GPIO_WritePin),不知道最终地址,生成占位符 - 把这些"待定的坑"记录在 重定位表(Relocation Table) 里
查看重定位表:
arm-none-eabi-objdump -r main.c.obj
输出示例:
RELOCATION RECORDS FOR [.text.main]:
OFFSET TYPE VALUE
00000050 R_ARM_THM_CALL HAL_GPIO_WritePin ← BL 指令的跳转偏移待填
00000078 R_ARM_ABS32 g_noinit_magic ← 字面量池的绝对地址待填
一句话:编译器挖坑,用重定位表标注"这些位置译时不知道填什么"。
第二步:链接器生成 .elf
main.o + gpio.o + ... + libc.a ──链接器──→ firmware.elf
链接器做的事:
- 把所有
.o文件和库拼到一起,确定每个符号的最终地址 - 遍历重定位表,把每个"坑"填上:
R_ARM_THM_CALL→ 计算BL指令到目标函数的偏移,填进指令R_ARM_ABS32→ 把全局变量的最终地址填进字面量池槽位
- 按链接脚本
.ld把各段放到正确的内存区域
查看最终反汇编:
arm-none-eabi-objdump -d firmware.elf > dump.txt
一句话:链接器填坑,把编译时留白的地址全部钉死。
第三步:链接脚本控制内存布局
第二步里说的"把各段放到正确的内存区域",规则来自 .ld:
.text : { ... } >ROM /* 代码:VMA = LMA = Flash */
.rodata : { ... } >ROM /* 只读数据:同上 */
.data : { ... } >RAM AT> ROM /* 已初始化变量:VMA=RAM, LMA=Flash */
.bss : { ... } >RAM /* 零初始化变量:VMA = LMA = RAM */
.RamFunc : { ... } >RAM AT> ROM /* RAM 函数:VMA=RAM, LMA=Flash */
关键概念(W3 已详解):
- VMA(Virtual Memory Address)= 运行时地址(CPU 看到的地址)
- LMA(Load Memory Address)= 存储地址(镜像在 Flash 里的地址)
>RAM AT> ROM= “运行时在 RAM,初始值固化在 Flash”
第四步:启动代码搬运 ⭐
上电后 Reset_Handler 做的 7 件事(本文章五详解):
1. 设置栈指针 ldr sp, =_estack
2. 初始化时钟 bl SystemInit
3. 拷贝 .data Flash → RAM(全局变量初始值)
4. 拷贝 .RamFunc Flash → RAM(关键函数代码,本工程未启用)
5. 清零 .bss RAM 填 0(未初始化全局变量)
6. 调用构造函数 bl __libc_init_array
7. 进入 main bl main
第五步:运行时内存全景图
Flash (0x08000000) RAM (0x20000000)
┌───────────────────┐ ┌───────────────────┐
│ .isr_vector │ │ .data (全局变量运行体) │
│ .text (代码) │ │ .bss (零初始化) │
│ .rodata (只读数据) │ │ .RamFunc (RAM 函数) │
│ .data 初始值 (LMA) │── 启动拷贝──→│ Heap │
│ .RamFunc 初值 (LMA) │── 启动拷贝──→│ Stack ← _estack │
│ 字面量池(在 .text)│ └───────────────────┘
└───────────────────┘
0.3 核心思想一句话
编译器挖坑(重定位表),链接器填坑(最终地址),启动代码搬家(Flash→RAM),main 开跑。
0.4 常用命令速查
| 目的 | 命令 |
|---|---|
| 看重定位表(编译器挖的坑) | arm-none-eabi-objdump -r xxx.o |
| 看最终反汇编(链接器填的坑) | arm-none-eabi-objdump -d xxx.elf > dump.txt |
| 看符号地址映射 | 查看 .map 文件 |
| 看段的大小 | arm-none-eabi-objdump -h xxx.elf |
| 看字面量池指向 | arm-none-eabi-objdump -d xxx.elf | grep '.word' |
有了这份全景图,下面再扣启动文件就不会迷路。
一、启动文件全景:607 行只有 256 行有价值
ST 官方 startup_stm32l552xx.s 有 607 行,但结构性价值分布极不均衡:
| 行号 | 内容 | 学习价值 |
|---|---|---|
| 28-34 | .syntax / .cpu / .fpu / .thumb 汇编器指令 |
⭐ |
| 36-48 | .word _sidata/... 5 个符号声明(死代码) |
⭐ |
| 58-103 | Reset_Handler(42 行,本周核心) | ⭐⭐⭐⭐⭐ |
| 113-117 | Default_Handler(1 行死循环) | ⭐ |
| 125-256 | .isr_vector 中断向量表(107 项) |
⭐⭐⭐ |
| 266-607 | .weak + .thumb_set 弱别名 —— 可跳过 |
— |
后 350 行全是弱别名清单,格式都一样:
.weak WWDG_IRQHandler
.thumb_set WWDG_IRQHandler, Default_Handler
含义:如果用户在自己代码里没定义 WWDG_IRQHandler,就让它指向 Default_Handler(死循环)。这是 C 语言世界里"未实现的接口降级到占位符"的汇编表达。
二、文件头的 4 句汇编器指令
.syntax unified
.cpu cortex-m33
.fpu softvfp
.thumb
这 4 句不是 CPU 指令,是对汇编器的配置:
| 指令 | 含义 | 为什么这么写 |
|---|---|---|
.syntax unified |
用统一的 ARM/Thumb 语法 | Cortex-M 只能用 Thumb-2,unified 让 movs r0, #1 这种写法通用 |
.cpu cortex-m33 |
目标 CPU = M33 | 决定允许的指令集子集(M33 支持 DSP / TrustZone 扩展) |
.fpu softvfp |
浮点用软件模拟 | ⚠ 有坑:L552 其实有硬件 FPU,这里写 softvfp 是因为启动代码不用浮点;真正的 FPU 激活在 SystemInit 里置位 CPACR |
.thumb |
默认按 Thumb-2 编码 | Cortex-M 只有 Thumb 状态,不存在 ARM 状态 |
与 W2 的伏笔呼应 ⭐:W2 测到 xPSR = 0x6100_0000,其中 bit 24 = 1 就是 “T”(Thumb)位——正是 .thumb 这条指令的运行时投影:
.thumb 指令
↓
编译器生成 Thumb 编码
↓
函数地址 LSB 被标记为 1(奇数地址)
↓
向量表里存奇数地址
↓
硬件复位时 PC[0] → xPSR.T
↓
xPSR bit24 = 1
一行汇编器伪指令,跨越编译期 + 链接期 + 硬件复位期,最终体现在运行时的一个 bit 上——这是"静态文本"到"动态状态"的经典链路。
三、硬件复位的纳秒级时序
不是任何一行代码在运行,全部是硬件固化行为:
┌── t = 0 ns ──────────────────────────────────────────┐
│ NRST 引脚拉高,CPU 脱离复位 │
├── t = 几个时钟周期(~60ns @ 8MHz HSI)──────────────┤
│ 硬件做 4 件事,没有任何人写过代码: │
│ ① 从地址 0x0000_0000 读 4 字节 → 装入 MSP │
│ ② 从地址 0x0000_0004 读 4 字节 → 装入 PC │
│ ③ xPSR.T = PC[0] │
│ ④ LR = 0xFFFF_FFFF │
├── t = CPU 第一次取指 ───────────────────────────────┤
│ PC 指向 Reset_Handler,开始执行 Thumb 指令 │
└──────────────────────────────────────────────────────┘
3 个必须搞清楚的细节
1) 为什么读 0x0000_0000,不是 0x0800_0000?
STM32L552 复位时 BOOT 选择位决定 0x0000_0000 被 alias 到哪:
- BOOT0 = 0 → Flash
0x0800_0000被映射到0x0000_0000 - BOOT0 = 1 → 系统 Bootloader 被映射到
0x0000_0000
所以硬件永远读 0x0000_0000,但背后指向哪看 BOOT 引脚。你用 ST-LINK 烧写 Flash 属于默认 BOOT0=0 场景。
2) MSP 先于 PC 装载,意义重大
Reset_Handler 第一条指令 bl SystemInit 需要压栈保存 LR——如果栈没准备好,这一步就炸了。所以硬件把"装 MSP"做在"跳 PC"之前。你跳进函数就能直接 call,不用自己先搭栈。
3) LR = 0xFFFF_FFFF 是故意的
这个魔数不是随便选的——它的 bit[0]=1 是合法的 Thumb 返回地址,但指向的位置是非法的(0xFFFFFFFE 是在 ARMv8-M 的 System Address 区域)。
作用 ⭐:如果你在 Reset_Handler 里一不小心 bx lr 了,硬件会触发 HardFault。这是 ARM 给"复位后还没调用任何函数"这个状态植入的保险丝。
四、中断向量表逐字节解剖
4.1 源码长什么样
.section .isr_vector,"a",%progbits
.type g_pfnVectors, %object
g_pfnVectors:
.word _estack ; [0] 初始 MSP
.word Reset_Handler ; [1] 复位向量
.word NMI_Handler ; [2]
.word HardFault_Handler ; [3]
.word MemManage_Handler ; [4]
.word BusFault_Handler ; [5]
.word UsageFault_Handler ; [6]
.word SecureFault_Handler; [7] ← M33 新增(TrustZone)
.word 0 ; [8-10] ARM 保留
.word 0
.word 0
.word SVC_Handler ; [11]
.word DebugMon_Handler ; [12]
.word 0 ; [13] 保留
.word PendSV_Handler ; [14] ← RTOS 调度命根
.word SysTick_Handler ; [15] ← RTOS 心跳
.word WWDG_IRQHandler ; [16] ← 从这里开始是 STM32 厂商外设
...
4.2 3 个新概念
| 语法 | 含义 |
|---|---|
.section .isr_vector,"a",%progbits |
开 ELF 段;"a" = allocatable(占内存),%progbits = 有实际内容 |
.word XXX |
往当前位置写 32 位地址(= XXX 符号的地址) |
.size g_pfnVectors, .-g_pfnVectors |
告诉 ELF 调试信息:符号大小 = 当前位置 - 起点 |
.word 的本质:不是指令,是编译期数据——相当于 C 的 const uint32_t g_pfnVectors[] = { _estack, Reset_Handler, ... };。
4.3 字节级验证(实测输出)
arm-none-eabi-objdump -s -j .isr_vector L552_Template_Base.elf | Select-Object -First 15
输出:
8000000 00000320 051d0008 5f1c0008 671c0008 ... ...._...g...
8000010 6f1c0008 771c0008 7f1c0008 551d0008 o...w.......U...
8000020 00000000 00000000 00000000 871c0008 ................
8000030 951c0008 00000000 a31c0008 b11c0008 ................
小端序解码表:
| 表项 | 原始字节 | 小端翻转 | 含义 |
|---|---|---|---|
| [0] MSP | 00 00 03 20 |
0x2003_0000 |
= _estack ✓,W2 MSP 之谜的最底层来源 |
| [1] Reset | 05 1d 00 08 |
0x0800_1d05 |
nm 说 Reset=0x0800_1d04,差 1 ⭐ |
| [2] NMI | 5f 1c 00 08 |
0x0800_1c5f |
nm 证实用户重写了 NMI |
| [3-6] fault | 各不相同,步长 8 | 0x1c67/6f/77/7f | 用户在 stm32l5xx_it.c 里重写了这 5 个 fault |
| [7] SecureFault | 55 1d 00 08 |
0x0800_1d55 |
= Default_Handler 地址,没重写 |
| [8-10] 保留 | 00 00 00 00 × 3 |
0 | ARM 保留 ✓ |
| [11] SVC | 87 1c 00 08 |
0x0800_1c87 |
4.4 Thumb 位的字节级证据 ⭐
nm 告诉你: Reset_Handler = 0x0800_1d04 ← 真实函数地址(偶数)
↓ 编译器自动 +1
向量表字节: 05 1d 00 08 → 0x0800_1d05 ← 存进向量表的值(奇数)
↓ 硬件复位
PC ← 0x0800_1d04 (bit0 清零再跳)
xPSR.T ← 1 (bit0 抠出来塞进去)
所有 Cortex-M 函数地址在内存里都是奇数——这就是"Cortex-M 没有 ARM 状态,只有 Thumb"这句话的二进制表现。
五、Reset_Handler 逐行拆(核心)
5.1 源码 + 反汇编并列表
┌─ 地址 ──┬─ 字节 ──────┬─ 反汇编 ────────────────┬─ 源码 ───────────────────┐
│ 8001d04 │ f8df d034 │ ldr.w sp, [pc, #52] │ ldr sp, =_estack │
│ 8001d08 │ f7fe fa96 │ bl 8000238 │ bl SystemInit │
│ 8001d0c │ 2100 │ movs r1, #0 │ movs r1, #0 │
│ 8001d0e │ e003 │ b.n 8001d18 │ b LoopCopyDataInit │
├─────────┼──────────────┼─────────────────────────┼──────────────────────────┤
│ 8001d10 │ 4b0b │ ldr r3, [pc, #44] │ ldr r3, =_sidata │
│ 8001d12 │ 585b │ ldr r3, [r3, r1] │ ldr r3, [r3, r1] │
│ 8001d14 │ 5043 │ str r3, [r0, r1] │ str r3, [r0, r1] │
│ 8001d16 │ 3104 │ adds r1, #4 │ adds r1, r1, #4 │
├─────────┼──────────────┼─────────────────────────┼──────────────────────────┤
│ 8001d18 │ 480a │ ldr r0, [pc, #40] │ ldr r0, =_sdata │
│ 8001d1a │ 4b0b │ ldr r3, [pc, #44] │ ldr r3, =_edata │
│ 8001d1c │ 1842 │ adds r2, r0, r1 │ adds r2, r0, r1 │
│ 8001d1e │ 429a │ cmp r2, r3 │ cmp r2, r3 │
│ 8001d20 │ d3f6 │ bcc.n 8001d10 │ bcc CopyDataInit │
│ 8001d22 │ 4a0a │ ldr r2, [pc, #40] │ ldr r2, =_sbss │
│ 8001d24 │ e002 │ b.n 8001d2c │ b LoopFillZerobss │
├─────────┼──────────────┼─────────────────────────┼──────────────────────────┤
│ 8001d26 │ 2300 │ movs r3, #0 │ movs r3, #0 │
│ 8001d28 │ f842 3b04 │ str.w r3, [r2], #4 │ str r3, [r2], #4 │
├─────────┼──────────────┼─────────────────────────┼──────────────────────────┤
│ 8001d2c │ 4b08 │ ldr r3, [pc, #32] │ ldr r3, =_ebss │
│ 8001d2e │ 429a │ cmp r2, r3 │ cmp r2, r3 │
│ 8001d30 │ d3f9 │ bcc.n 8001d26 │ bcc FillZerobss │
│ 8001d32 │ f000 f819 │ bl 8001d68 │ bl __libc_init_array │
│ 8001d36 │ f7ff feb9 │ bl 8001aac │ bl main │
│ 8001d3a │ e7fe │ b.n 8001d3a │ LoopForever: b . │
└─────────┴──────────────┴─────────────────────────┴──────────────────────────┘
── 字面量池(literal pool,紧跟 LoopForever)──
8001d3c: 20030000 _estack
8001d40: 08001e28 _sidata ← Flash 里 .data 的初值镜像起点
8001d44: 20000000 _sdata ← RAM 里 .data 的起点
8001d48: 20000010 _edata ← RAM 里 .data 的终点
8001d4c: 20000010 _sbss ← ⚠ 和 _edata 同址!
8001d50: 20000038 _ebss ← RAM 里 .bss 的终点
5.2 3 个底层机制
机制 ①:字面量池(literal pool)
Thumb-2 的 LDR 指令只能编码 12 位立即数偏移,装不下 32 位地址。GNU 汇编器做了魔法:
源码: ldr sp, =_estack ← 伪指令
↓
汇编器: ① 把 0x20030000 存到函数末尾的字面量池
② 把这行翻译成 ldr sp, [pc, #偏移]
↓
反汇编: ldr.w sp, [pc, #52]
字面量池在 0x8001d3c: 20030000
PC 的值:Thumb 模式下 PC = 当前指令地址 + 4(流水线)。
- 当前指令在 0x8001d04,
PC = 0x8001d08 PC + 52 = 0x8001d3c✓ 正好命中字面量池
这就是为什么所有 ldr rX, =符号 都反汇编成 ldr rX, [pc, #...]。
机制 ②:Thumb-2 的 2 字节 vs 4 字节指令
| 字节 | 位宽 | 例子 |
|---|---|---|
2100 |
2 字节 | movs r1, #0(短指令 T1 编码) |
f8df d034 |
4 字节 | ldr.w sp, ...(长指令 T3,.w = wide) |
同一个操作可能有短/长两种编码——汇编器看能不能塞得下操作数。sp 是高寄存器,短指令装不下,必须走 ldr.w。
机制 ③:str r3, [r2], #4 后变址寻址
f842 3b04 str.w r3, [r2], #4
动作分解:
*r2 = r3(写内存)- 然后
r2 += 4(写完再加)
这个"写完自动前进 4 字节"特性让 FillZerobss 循环只需 2 条指令就搞定。
5.3 实战:亲手校验字面量池
上面讲了字面量池的原理,这里用 4 个小实验把它从"概念"扣到"字节可视"。
新建 Core/Src/lit_pool_exp.c(注意 CMake 路径必须用正斜杠/不能用反斜杠\):
#include <stdint.h>
/* 实验 A:默认池位置 */
__attribute__((noinline))
uint32_t lit_exp_a(void) {
volatile uint32_t *rcc = (uint32_t *)0x40021000;
return *rcc + 0xDEADBEEF;
}
/* 实验 B:相同常数多次引用,看是否合并 */
__attribute__((noinline))
uint32_t lit_exp_b(uint32_t x) {
x ^= 0x12345678;
x ^= 0x12345678;
x ^= 0x12345678;
return x + 0xABCDEF01;
}
/* 实验 C:不同大小立即数的编码策略 */
__attribute__((noinline))
uint32_t lit_exp_c(uint32_t x) {
x += 0x7F; /* 小:直接 imm8 */
x += 0x1234; /* 中:movw */
x += 0x12345678; /* 大:movw+movt 或字面量池 */
return x;
}
/* 实验 D:超长函数迫近≤±4KB 范围,测字面量池 PC-relative 极限 */
__attribute__((noinline, naked))
void lit_exp_d(void) {
__asm volatile (
"ldr r0, =0xCAFEBABE \n"
"b 1f \n"
".ltorg \n" /* 手动放池,绕过 naked 无自动池的问题 */
"1: \n"
".rept 2200 \n"
" nop \n"
".endr \n"
"ldr r1, =0xFACEFEED \n"
"bx lr \n"
);
}
在 CMakeLists.txt 里注册:
target_sources(${CMAKE_PROJECT_NAME} PRIVATE
Core/Src/lit_pool_exp.c
)
编译后看反汇编:
arm-none-eabi-objdump -d build/*.elf > dump.txt
实验 A:默认池位置
在 dump.txt 里搜 <lit_exp_a>,会看到类似:
080016b4 <lit_exp_a>:
80016b4: 4b02 ldr r3, [pc, #8] ; (80016c0) ← 读 0x40021000
80016b6: 4a03 ldr r2, [pc, #12] ; (80016c4) ← 读 0xDEADBEEF
80016b8: 681b ldr r3, [r3]
80016ba: 4413 add r3, r2
80016bc: 4618 mov r0, r3
80016be: 4770 bx lr
80016c0: 40021000 ← 字面量池槽位 1
80016c4: deadbeef ← 字面量池槽位 2
结论:字面量池默认紧跟在函数末尾(bx lr 之后)。这样 PC-relative 计算偏移最小,也不会被指令流执行到。
实验 B:相同常数只存一份
lit_exp_b 里 0x12345678 用了 3 次,但反汇编中:
<lit_exp_b>:
...
ldr r3, [pc, #N] ← 全部指向同一个 pc+N
eors r0, r3
ldr r3, [pc, #N] ← 同一个
eors r0, r3
ldr r3, [pc, #N] ← 同一个
eors r0, r3
...
结论:相同常数在同一函数内只存 1 份,节省 Flash。但跨函数不合并,因为超出 ±4KB 就用不着。
实验 C:考编译器编码策略
| 常数 | 范围 | 预期指令 |
|---|---|---|
0x7F |
imm8 | adds r0, #0x7F,不用池 |
0x1234 |
imm16 | movw r3, #0x1234,不用池 |
0x12345678 |
imm32 | movw+movt 或 ldr =… (这时池才出现) |
结论:字面量池是 最后手段,编译器优先用立即数编码节省 Flash。在 -Os 下 movw+movt 和字面量池的选择值得仔细对比。
实验 D:验证 ±4KB 极限 ⭐
直接编译 2200 个 nop 的版本,汇编器报错:
ccUQCDix.s:202: Error: offset out of range
这是实验 D 的最好结果 —— 它证明了 PC-relative ldr rX, [pc, #imm12<<2] 的 ±4KB 范围不是理论,是硬邦邦的墙。
为什么 naked 函数会穿墙:编译器在普通函数里会自动选合适位置插入字面量池(在 .pool 标记或无条件跳转后);但 __attribute__((naked)) + 内联汇编时,编译器不插,所以字面量池被挤到函数结尾(bx lr 后),距离开头的 ldr r0, =0xCAFEBABE 超 4KB,汇编器无法编码。
两种修复:
- 手动插池(推荐):用
b 1f跳过池,.ltorg在此处放池,1:后面继续代码。上面样例代码已经如此修复。 - 缩短函数:把 2200 个 nop 改成 ≤ 1000,距离 < 4KB 自然没错。
修复后反汇编:
<lit_exp_d>:
ldr r0, [pc, #4] ; 读前面 8 字节的池
b.n 4f
.word 0xCAFEBABE ← 提前放的池
4:
nop
nop
... (2200 个)
ldr r1, [pc, #4]
bx lr
.word 0xFACEFEED ← 函数末尾默认池
结论:字面量池需要程序员配合编译器 —— 普通函数靠编译器自动,naked / 超长汇编块靠手动 .ltorg。
4 个实验收尾:实证结论
| 问题 | 答案 |
|---|---|
| 池放哪里? | 默认紧跟在函数末尾(bx lr 后),不会被执行到 |
| 相同常数合并吗? | 函数内合并,跨函数不合 |
| 小常数进池吗? | 不,优先 imm8 / movw / movw+movt,池是最后手段 |
| 池有多远? | 限 ±4KB(imm12<<2 = 4095*4 = ±16380 字节),硬指标 |
| naked 为何容易爆? | 编译器不插自动池,一定要手写 .ltorg |
记住这个 4KB:超出范围编译器会直接罢工,这是 Thumb-2 指令编码位宽硬定的极限。
5.4 4 个阶段逐一破译
阶段 1:栈指针兜底
ldr sp, =_estack ; sp ← 0x20030000
冷知识:这句其实是冗余的——硬件在复位时已经装过 MSP 了。但 ST 写出来兜底:
- 如果向量表被改过偏移(
SCB->VTOR重定位,比如 bootloader 场景) - 如果 debugger 直接从 Reset_Handler 启动跳过了硬件复位流程
防御性编程典范——多写一条指令 Flash 成本忽略不计,换来 2 种异常场景的鲁棒性。
阶段 2:时钟系统初始化
bl SystemInit ; 跳到 CMSIS 标准函数
只一句——但是里面会做:
- 置位 CPACR 激活 FPU(这才是
.fpu softvfp不冲突的原因) - 设置
SCB->VTOR指向向量表(多段系统中用) - TrustZone 模式下初始化 SAU
详细内容留给需要时再挖——今天只要知道 SystemInit 是一个"CMSIS 标准约定"函数。
阶段 3:CopyDataInit —— Flash→RAM 搬运
汇编等价 C 代码:
r1 = 0; // 偏移量
while (true) {
r0 = _sdata; // 0x20000000
r3 = _edata; // 0x20000010
r2 = r0 + r1; // 当前 RAM 目标地址
if (r2 >= r3) break; // 全拷完了?退出
r3 = _sidata; // 0x08001e28(Flash 里的初值镜像)
r3 = *(r3 + r1); // 读一个 32 位字
*(r0 + r1) = r3; // 写到 RAM
r1 += 4;
}
数据量实测:
_edata - _sdata = 0x20000010 - 0x20000000 = 16 字节- 只有 4 个 32 位 word 要搬——几乎所有全局变量要么是 const(
.rodata)要么初值为 0(.bss),真正的.data非常小
一个小低效点 ⚠:每次循环都重新 ldr r3, =_sidata(其实应该在循环外装一次)。这是"清晰优先于性能"——Flash→RAM 只跑一次,浪费几个周期无所谓,代码可读性爆棚。
阶段 4:FillZerobss —— RAM 清零 ⭐
r2 = _sbss; // 0x20000010(承接自 _edata!)
while (true) {
r3 = _ebss; // 0x20000038
if (r2 >= r3) break; // 退出条件
*r2 = 0; // 写 0
r2 += 4; // 后变址自动前进
}
数据量实测:
_ebss - _sbss = 0x20000038 - 0x20000010 = 40 字节= 10 次循环
最重要的观察 ⭐⭐:_sbss (0x20000010) == _edata (0x20000010)——.data 和 .bss 在 RAM 里零距离衔接!
这是 W3 链接脚本里你自己写过的规则:
.data : { ... } >RAM AT> ROM ← 结束后定位计数器 = 0x20000010
.bss : { ... } >RAM ← 从上次位置继续,起点 = 0x20000010
三周全部落地:W3 你写 .ld → W4 看到字节级证据——链接脚本 → 字面量池 → 汇编 → RAM 布局 → 芯片行为全程打通。
六、W2 四颗种子全部破案 🎉
6.1 xPSR Z=1 C=1 字节级指纹 ⭐⭐⭐
FillZerobss 循环退出时到底发生了什么:
LoopFillZerobss:
8001d2c: ldr r3, =_ebss ; r3 = 0x20000038
8001d2e: cmp r2, r3 ; 做减法 r2 - r3,只更新 CPSR 不存结果
8001d30: bcc.n FillZerobss ; 如果 C=0(r2 < r3)就跳回去;否则往下走
最后一次循环完毕时:
- r2 刚刚被
str.w r3, [r2], #4自增过,变成了 0x20000038(= _ebss) cmp r2, r3等价于r2 - r3 = 0x20000038 - 0x20000038 = 0- 标志位变化:
- Z = 1(结果为 0)
- C = 1(无符号减法无借位 → r2 ≥ r3)
- N = 0(结果非负)
- V = 0(无溢出)
bcc检查C == 0→ 不满足,不跳,直接 fall-through 到bl __libc_init_array
进入 main 时 xPSR 高 8 位:
bit 31 (N) = 0
bit 30 (Z) = 1 ← FillZerobss 最后一次 cmp
bit 29 (C) = 1 ← FillZerobss 最后一次 cmp
bit 28 (V) = 0
bit 24 (T) = 1 ← Thumb 状态(向量表奇数地址抠出来)
= 0b0110_0001_xxxx_...xxxx = 0x61xx_xxxx
= W2 观察到的 xPSR = 0x6100_0000 ✓✓✓
bl __libc_init_array内部的最后一次 cmp 通常也是"相等退出",所以不会破坏这对指纹。这是裸机工程里初值为 0 的.init_array的典型情况。
6.2 四颗种子总清单
| W2 观察 | 真相源头 | 字节级证据 |
|---|---|---|
| MSP = 0x2002_FFF8 | 向量表 [0] = 0x20030000,栈已用 8 字节 | 8000000: 00 00 03 20 |
| LR = 0xFFFF_FFFF | 硬件复位值(异常返回魔数) | 非字节,硬件固定值 |
| xPSR bit24 (T) = 1 | 向量表 [1] 地址末位 = 1 | 8000004: 05 1d 00 08 |
| xPSR bit30/29 (Z/C) = 1/1 | FillZerobss 最后一次 cmp r2, r3 |
8001d2c-8001d30 三条指令 |
W2 时你挠头的 4 个现象——现在全部在 Flash 的 0x1f8 字节向量表 + .text 的几条汇编里找到了。
七、反汇编暴露的 3 个彩蛋
彩蛋 1:ADC1_2_IRQHandler 蹭 Default_Handler 地址
08001d54 <ADC1_2_IRQHandler>:
8001d54: e7fe b.n 8001d54 <ADC1_2_IRQHandler>
这个 b.n 自己 就是 Default_Handler: b .——同一条指令被命名了两次。向量表里凡是 weak alias 到 Default_Handler 的中断,都指向 0x08001d54。所以向量表尾部大量的 55 1d 00 08 就是这些未重写的 IRQ。
为什么 nm 里看到 ADC1_2_IRQHandler 而不是 Default_Handler? 因为 .thumb_set 让 ADC1_2_IRQHandler 成为 strong 符号,优先级高于 weak 的 Default_Handler 标签——所以链接器选了它作为这个地址的"首选名字"。
彩蛋 2:newlib-nano 的 memset 只用 8 字节
08001d56 <memset>:
8001d56: 4402 add r2, r0
8001d58: 4603 mov r3, r0
8001d5a: 4293 cmp r3, r2
8001d5c: d100 bne.n 8001d60
8001d5e: 4770 bx lr
8001d60: f803 1b01 strb.w r1, [r3], #1
8001d64: e7f9 b.n 8001d5a
字节版 memset(不是字版 + 尾部字节),非常省 Flash。整个函数 14 字节,比你手写的还短。
彩蛋 3:字面量池总在函数末尾
注意 LoopForever 紧跟在 bl main 后面,然后字面量池紧跟 LoopForever:
8001d36: bl main
8001d3a: b.n LoopForever ← 函数逻辑结束
8001d3c: .word _estack ← 字面量池从这开始
8001d40: .word _sidata
...
编译器刻意的安排——让前面的 ldr rX, [pc, #xx] 通过 PC-relative 就能够到。每个使用了 ldr rX, =符号 的函数末尾都会有这么一块字面量池,是 ARM 汇编的通用模式。
八、4 阶段启动全流程图
复位引脚 NRST
↓
┌────────────────────────────────────┐
│ 硬件固化行为(不是代码) │
│ MSP ← [0x00000000] = 0x20030000 │
│ PC ← [0x00000004] = 0x08001d04 │
│ xPSR.T ← 0x08001d05 的 bit0 = 1 │
│ LR ← 0xFFFFFFFF │
└────────────────────────────────────┘
↓ 跳到 Reset_Handler
┌────────────────────────────────────┐
│ 阶段 1:ldr sp, =_estack │
│ (兜底,硬件已经做过了) │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ 阶段 2:bl SystemInit │
│ → 激活 FPU、设 VTOR │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ 阶段 3:CopyDataInit 循环 │
│ Flash 0x08001e28 ─┐ │
│ │ 16 字节 │
│ RAM 0x20000000 ←─┘ │
│ (.data 段初值拷贝) │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ 阶段 4:FillZerobss 循环 │
│ RAM 0x20000010 ~ 0x20000038 清零 │
│ (.bss 段 40 字节) │
│ ⭐ 循环退出 → xPSR.Z=1 C=1 │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ bl __libc_init_array │
│ (遍历 .init_array,裸机通常为空) │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ bl main ← 你的代码从这里开始 │
│ xPSR = 0x61000000(W2 观察值) │
└────────────────────────────────────┘
九、本周收获 + W5 预告
本周收获
- ✅ Cortex-M 硬件复位的 4 步固化行为
- ✅ 中断向量表的 ELF 编码(
.word+ 奇数地址) - ✅ 字面量池机制(Thumb-2 装载 32 位常量的唯一方式)
- ✅ Reset_Handler 4 个阶段全流程(栈 → 时钟 →
.data拷 →.bss清) - ✅
ldr [rX], #4后变址寻址 - ✅ W2 xPSR 指纹字节级破案——Z=1 C=1 来自 FillZerobss 最后一次
cmp
W2 ↔ W3 ↔ W4 三周完整闭环
W2: 看现象 → "MSP 是 0x2002FFF8?为啥?"
↓
W3: 读布局 → "因为 _estack = ORIGIN(RAM) + LENGTH(RAM)"
↓
W4: 拆汇编 → "向量表 [0] 就是 _estack 的 4 个字节"
↓
三周打通:C 代码 → 链接布局 → 汇编指令 → 物理字节
W5 预告:PendSV 机制与上下文切换
从下周开始真正进入 RTOS 源码:
- CMSIS-RTX 5 的
rtx_kernel.c / rtx_thread.c - PendSV 中断如何被 OS 借用做"任务切换"
R0-R3、R12、LR、PC、xPSR的自动入栈 vsR4-R11的手动入栈——每个 word 在栈里的位置都是 ARM 钦定的- TCB(Thread Control Block)长什么样
这里的每一个话题都会用到 W2~W4 的基础——你读完就会发现,RTOS 不是魔法,是硬件中断机制 + 一个极小的调度器。
十、命令速查附录
向量表相关
# 1. 向量表符号信息
arm-none-eabi-readelf -s L552_Template_Base.elf | Select-String "g_pfnVectors"
# 2. 向量表所在段
arm-none-eabi-readelf -S L552_Template_Base.elf | Select-String -Pattern "isr_vector" -Context 1,0
# 3. 向量表字节 dump
arm-none-eabi-objdump -s -j .isr_vector L552_Template_Base.elf | Select-Object -First 15
# 4. Reset_Handler 和 _estack 的实际地址
arm-none-eabi-nm L552_Template_Base.elf | Select-String -Pattern "_estack|Reset_Handler"
Reset_Handler 反汇编
# 只看 Reset_Handler 一块
arm-none-eabi-objdump -d L552_Template_Base.elf `
| Select-String -Pattern "<Reset_Handler>:" -Context 0,60 `
| Select-Object -First 1
# 看某个符号在哪
arm-none-eabi-nm L552_Template_Base.elf | Select-String -Pattern "HardFault|Default_Handler"
链接符号校验
# 5 个关键符号一次看
arm-none-eabi-nm L552_Template_Base.elf `
| Select-String -Pattern "_sidata|_sdata|_edata|_sbss|_ebss"
标志位手算公式
CMP Rn, Rm 本质 = Rn - Rm(不存结果,只更新标志)
Z = (Rn - Rm == 0)
C = (Rn >= Rm) ← 无符号借位反转
N = (Rn - Rm).bit31
V = 符号溢出
BCC label ← 跳转条件:C == 0
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)