[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 个意外:

  1. MSP 不在 0x2004_0000,而是 0x2002_FFF8
  2. LR 是 0xFFFF_FFFF(奇数)
  3. xPSR bit24(T)= 1
  4. xPSR 进入 main 前 bit30 (Z) = 1、bit29 © = 1

W3 用 .ld 破了前两颗(_estack = 0x2003_0000 的来源 + W3 末尾给出了预告)。
还有第 4 颗没答案——为什么 Z 和 C 标志恰好都是 1?

这周把这颗种子的答案挖出来。答案藏在 startup_stm32l552xx.sFillZerobss 循环的最后一次 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.s607 行,但结构性价值分布极不均衡:

行号 内容 学习价值
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,unifiedmovs 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

动作分解

  1. *r2 = r3(写内存)
  2. 然后 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_b0x12345678 用了 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+movtldr =… (这时池才出现)

结论:字面量池是 最后手段,编译器优先用立即数编码节省 Flash。在 -Osmovw+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,汇编器无法编码。

两种修复

  1. 手动插池(推荐):用 b 1f 跳过池,.ltorg 在此处放池,1: 后面继续代码。上面样例代码已经如此修复。
  2. 缩短函数:把 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_IRQHandlerDefault_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 预告

本周收获

  1. ✅ Cortex-M 硬件复位的 4 步固化行为
  2. ✅ 中断向量表的 ELF 编码(.word + 奇数地址)
  3. ✅ 字面量池机制(Thumb-2 装载 32 位常量的唯一方式)
  4. ✅ Reset_Handler 4 个阶段全流程(栈 → 时钟 → .data 拷 → .bss 清)
  5. ldr [rX], #4 后变址寻址
  6. 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 5rtx_kernel.c / rtx_thread.c
  • PendSV 中断如何被 OS 借用做"任务切换"
  • R0-R3R12LRPCxPSR自动入栈 vs R4-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
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐