4. 汇编级启动代码:编写 Reset_Handler,初始化堆栈和中断向量表

说实话,很多做嵌入式开发的朋友,写了几年 C 语言,却从来没真正看过芯片上电后第一行代码长什么样。我刚开始带项目时也这样,直到有一次板子死活跑不起来,用调试器一看,PC 指针卡在 0x00000000 不动——嗯,从那以后,我老老实实把启动代码从头到尾捋了一遍。

这一章,我们就来亲手写一个最核心的汇编启动文件。说白了,就是告诉芯片:上电后先别急着跑 main,把堆栈设好、中断表摆好、全局变量准备好,然后再跳过去。

4.1 芯片上电后到底在干什么?

ARM Cortex-M 系列内核(比如 STM32、GD32、NXP LPC 这些)上电后,硬件会自动做两件事:

  1. 从地址 0x00000000 读取 栈顶指针(MSP),加载到 SP 寄存器
  2. 从地址 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 整体启动流程

下面这张图把整个启动流程串起来了,你可以对照着看:

Cortex-M 启动流程 芯片上电 / 复位 硬件加载 MSP 和 PC 执行 Reset_Handler 设置栈指针 SP 拷贝 .data 段 清零 .bss 段 SystemInit(时钟配置) 跳转到 main() 注意 向量表前 8 字节由硬件固定 0x00: MSP / 0x04: Reset_Handler 不可随意修改顺序

4.7 常见问题与避坑

  • 栈溢出没察觉:我建议在链接脚本里给栈区域填充一个固定模式(比如 0xDEADBEEF),然后在 main 里定期检查栈顶附近的值有没有被覆盖。
  • 中断向量表对齐:Cortex-M 要求向量表按 256 字节对齐(有些芯片是 512 字节)。如果没对齐,SCB->VTOR 设置时会出错。
  • .data 段拷贝顺序:源地址 _sidata 在 Flash,目标地址 _sdata 在 RAM。我见过有人把这两个搞反,结果从 RAM 往 Flash 写,直接写崩。

一句话总结:Reset_Handler 是嵌入式系统的「第一块多米诺骨牌」。它倒下了,后面的 C 代码才能跑起来。写启动代码时,多看一眼链接脚本,多确认一次地址,能省下后面几天的调试时间。