4. 汇编级启动代码:编写 Reset_Handler,初始化堆栈和中断向量表
说实话,很多做嵌入式开发的朋友,写了几年 C 语言,却从来没真正看过芯片上电后第一行代码长什么样。我刚开始带项目时也这样,直到有一次板子死活跑不起来,用调试器一看,PC 指针卡在 0x00000000 不动——嗯,从那以后,我老老实实把启动代码从头到尾捋了一遍。
这一章,我们就来亲手写一个最核心的汇编启动文件。说白了,就是告诉芯片:上电后先别急着跑 main,把堆栈设好、中断表摆好、全局变量准备好,然后再跳过去。
4.1 芯片上电后到底在干什么?
ARM Cortex-M 系列内核(比如 STM32、GD32、NXP LPC 这些)上电后,硬件会自动做两件事:
- 从地址 0x00000000 读取 栈顶指针(MSP),加载到 SP 寄存器
- 从地址 0x00000004 读取 复位向量,加载到 PC 寄存器
然后 CPU 就开始执行 PC 指向的那条指令。所以,中断向量表的前两个位置是硬性规定的,不能乱放。
核心要点:向量表的前 4 字节是栈顶地址,紧接着 4 字节是 Reset_Handler 入口地址。这是芯片厂商和 ARM 架构约定好的,谁都不能改。
4.2 中断向量表的结构
一个标准的 Cortex-M 中断向量表长这样:
| 偏移地址 | 异常/中断号 | 描述 |
|---|---|---|
| 0x00 | - | 栈顶指针(MSP 初始值) |
| 0x04 | Reset | 复位向量(Reset_Handler) |
| 0x08 | NMI | 不可屏蔽中断 |
| 0x0C | HardFault | 硬错误 |
| 0x10 | MemManage | 内存管理错误(可选) |
| 0x14 | BusFault | 总线错误(可选) |
| 0x18 | UsageFault | 用法错误(可选) |
| ... | ... | ... |
| 0x40+ | IRQ0~IRQn | 外设中断向量 |
我个人习惯把前 16 个系统异常都列出来,哪怕暂时用不到。为什么?因为一旦发生 HardFault,调试时能看到向量表里有没有对应的入口,省得抓瞎。
4.3 编写 Reset_Handler
Reset_Handler 是芯片复位后执行的第一个函数。它的职责很明确:
- 初始化堆栈指针(其实硬件已经做了,但有时需要重新设一下)
- 拷贝 .data 段(已初始化全局变量)从 Flash 到 RAM
- 清零 .bss 段(未初始化全局变量)
- 调用 SystemInit(时钟配置等)
- 跳转到 main
下面是一个典型的汇编启动代码片段(基于 ARM GCC 工具链):
.syntax unified
.cpu cortex-m4
.fpu softvfp
.thumb
.global _estack
.global _sidata
.global _sdata
.global _edata
.global _sbss
.global _ebss
.section .vectors, "a", %progbits
.type g_pfnVectors, %object
.size g_pfnVectors, .-g_pfnVectors
g_pfnVectors:
.word _estack
.word Reset_Handler
.word NMI_Handler
.word HardFault_Handler
.word MemManage_Handler
.word BusFault_Handler
.word UsageFault_Handler
.word 0
.word 0
.word 0
.word 0
.word SVC_Handler
.word DebugMon_Handler
.word 0
.word PendSV_Handler
.word SysTick_Handler
/* 外设中断从这里开始 */
.word WWDG_IRQHandler
.word PVD_IRQHandler
/* ... 省略后续中断 */
.section .text.Reset_Handler, "ax", %progbits
.type Reset_Handler, %function
.global Reset_Handler
Reset_Handler:
/* 设置栈指针(保险起见,再设一次) */
ldr sp, =_estack
/* 拷贝 .data 段 */
ldr r0, =_sdata
ldr r1, =_edata
ldr r2, =_sidata
movs r3, #0
b .L_copy_data_done
.L_copy_data_loop:
ldr r4, [r2, r3]
str r4, [r0, r3]
adds r3, r3, #4
.L_copy_data_done:
adds r4, r0, r3
cmp r4, r1
bcc .L_copy_data_loop
/* 清零 .bss 段 */
ldr r0, =_sbss
ldr r1, =_ebss
movs r2, #0
b .L_clear_bss_done
.L_clear_bss_loop:
str r2, [r0]
adds r0, r0, #4
.L_clear_bss_done:
cmp r0, r1
bcc .L_clear_bss_loop
/* 调用 SystemInit */
bl SystemInit
/* 跳转到 main */
bl main
/* 如果 main 返回,死循环 */
b .
小技巧:我在项目中遇到过链接脚本里 _sidata 和 _sdata 搞反的情况,结果 .data 段拷贝出来全是乱码。建议每次改链接脚本后,都先看 map 文件确认这三个符号的地址对不对。
4.4 堆栈初始化细节
堆栈初始化不只是设一个 SP 值那么简单。你想想看,如果栈顶指针指向的地址不在 RAM 范围内,或者 RAM 大小不够,一压栈就 HardFault。
我一般会在链接脚本里这样定义:
_estack = ORIGIN(RAM) + LENGTH(RAM);
这样栈顶就在 RAM 的最高地址,向下生长。注意,栈是满递减的,所以初始 SP 指向 RAM 末尾 + 1 的位置。
曾经踩过的坑:有一次我把 _estack 定义在了 RAM 起始地址,结果 SP 向上增长,压栈几次就把全局变量区覆盖了。程序跑起来各种诡异,查了两天才发现是栈方向搞反了。Cortex-M 默认是满递减栈,SP 初始值必须指向 RAM 最高地址 + 1。
4.5 弱符号与默认中断处理
你不可能在启动文件里把所有中断都实现一遍。常用的做法是:给每个中断向量一个默认的弱符号处理函数,比如:
.weak NMI_Handler
.type NMI_Handler, %function
NMI_Handler:
b .
.weak HardFault_Handler
.type HardFault_Handler, %function
HardFault_Handler:
b .
/* 其他中断同理 */
这样,如果你在 C 代码里定义了同名的函数,链接器会优先用你的版本;如果没有定义,就执行默认的无限循环。我在调试阶段经常故意不实现 HardFault_Handler,让它卡住,然后通过调试器看寄存器状态。
4.6 整体启动流程
下面这张图把整个启动流程串起来了,你可以对照着看:
4.7 常见问题与避坑
- 栈溢出没察觉:我建议在链接脚本里给栈区域填充一个固定模式(比如 0xDEADBEEF),然后在 main 里定期检查栈顶附近的值有没有被覆盖。
- 中断向量表对齐:Cortex-M 要求向量表按 256 字节对齐(有些芯片是 512 字节)。如果没对齐,SCB->VTOR 设置时会出错。
- .data 段拷贝顺序:源地址 _sidata 在 Flash,目标地址 _sdata 在 RAM。我见过有人把这两个搞反,结果从 RAM 往 Flash 写,直接写崩。
一句话总结:Reset_Handler 是嵌入式系统的「第一块多米诺骨牌」。它倒下了,后面的 C 代码才能跑起来。写启动代码时,多看一眼链接脚本,多确认一次地址,能省下后面几天的调试时间。