4. ARM Cortex-M启动详解:中断向量表、堆栈初始化、C运行时环境、跳转到main函数

芯片上电后,第一件事是什么?不是你的应用程序,而是一段叫做「启动代码」的东西。说白了,Bootloader 也好,用户程序也好,都得靠它把硬件拉到能跑 C 语言的状态。

我个人习惯把 Cortex-M 的启动过程拆成四个步骤:中断向量表定位 → 堆栈指针初始化 → C 运行时环境构建 → 跳转 main。每一步都踩过坑,今天我把这些经验掰开揉碎了讲给你。

核心认知: Cortex-M 复位后,硬件自动从 0x00000000 读取栈顶地址,从 0x00000004 读取复位向量。这是硬连线行为,你改不了。

4.1 中断向量表——系统的「第一张多米诺骨牌」

中断向量表本质上是一张函数指针数组。Cortex-M 强制要求:向量表的起始地址必须 256 字节对齐(即低 8 位为 0)。

为什么?因为硬件内部用地址的低 8 位做异常编号索引。你想想看,如果地址没对齐,硬件取到的就是错误的值。

我在项目中遇到过一件事:某款国产 MCU 的 Bootloader 放在 0x08010000,向量表也设在那里,但忘了改 SCB->VTOR 寄存器。结果一进中断就飞了。查了两天才发现——硬件复位后默认从 0x08000000 取向量表,你得主动告诉它「嘿,表挪了」。

/* 标准向量表示例(汇编或C) */
__attribute__((section(".isr_vector")))
void (* const g_pfnVectors[])(void) = {
    (void (*)(void))(&_estack),         /* 0x0000: 栈顶地址 */
    Reset_Handler,                      /* 0x0004: 复位向量 */
    NMI_Handler,                        /* 0x0008: NMI */
    HardFault_Handler,                  /* 0x000C: 硬错误 */
    MemManage_Handler,                  /* 0x0010: 内存管理 */
    BusFault_Handler,                   /* 0x0014: 总线错误 */
    UsageFault_Handler,                 /* 0x0018: 用法错误 */
    /* ... 后续异常和中断向量 ... */
};
我的习惯: 在链接脚本里用 KEEP 保留向量表段,防止链接器优化掉未使用的向量。血的教训——少一个 HardFault 向量,调试时连错误原因都看不到。

4.2 堆栈初始化——别让 SP 指到「三不管」地带

复位后,硬件自动把向量表第一个字(栈顶地址)加载到 SP 寄存器。但这里有个陷阱:栈顶地址必须指向 RAM 区域的末尾 + 1。

举个例子,如果你的芯片 RAM 从 0x20000000 开始,大小 64KB(0x10000),那么栈顶地址应该是 0x20010000。为什么是末尾 +1?因为 Cortex-M 的栈是满递减的——压栈前 SP 先减 4,再存数据。如果栈顶设在 0x2000FFFF,第一次压栈 SP 变成 0x2000FFFB,没问题;但如果设在 0x20010000,第一次压栈 SP 变成 0x2000FFFC,刚好在 RAM 范围内。

我曾经见过一个新手把栈顶设成 0x2000FFFC,结果程序一跑就莫名其妙 HardFault。查了半天,原来是压栈时 SP 减到了 0x2000FFF8,踩到了未初始化区域。

/* 链接脚本中定义栈顶 */
_estack = ORIGIN(RAM) + LENGTH(RAM);   /* 0x20000000 + 0x10000 = 0x20010000 */

/* 启动汇编中显式设置SP(部分编译器自动完成) */
ldr   r0, =_estack
msr   msp, r0
注意: 如果你的 Bootloader 要跳转到 APP,跳转前必须把 MSP 重新设为 APP 的栈顶。否则 APP 的栈会污染 Bootloader 的栈空间,跑几个函数就崩。

4.3 C 运行时环境——BSS 清零与数据段拷贝

C 语言假设全局变量在 main 之前已经初始化好了。这个「之前」就是启动代码干的活。具体两件事:

  • BSS 段清零: 把未初始化的全局变量(默认值为 0)所在区域全部写 0。
  • 数据段拷贝: 把已初始化的全局变量从 Flash 拷贝到 RAM。

你想想看,如果跳过这一步,你的全局变量 int flag = 1; 在 main 里读出来可能是随机值。我调试过一个产品,偶尔开机死机,最后发现是 BSS 段没清零,某个标志位初始值是 0xFF,导致状态机直接进入非法分支。

/* 典型的 C 运行时初始化(简化版) */
extern uint32_t _sidata;   /* Flash 中数据段起始 */
extern uint32_t _sdata;    /* RAM 中数据段起始 */
extern uint32_t _edata;    /* RAM 中数据段结束 */
extern uint32_t _sbss;     /* BSS 段起始 */
extern uint32_t _ebss;     /* BSS 段结束 */

void __attribute__((naked)) Reset_Handler(void)
{
    /* 1. 拷贝数据段 */
    uint32_t *src = &_sidata;
    uint32_t *dst = &_sdata;
    while (dst < &_edata) {
        *dst++ = *src++;
    }

    /* 2. 清零 BSS 段 */
    for (dst = &_sbss; dst < &_ebss; dst++) {
        *dst = 0;
    }

    /* 3. 跳转到 main */
    main();

    /* 4. main 不应返回,但万一返回了... */
    while (1);
}
性能小技巧: 如果 RAM 较大,可以用 4 字节或 8 字节对齐的拷贝方式。我在 STM32F4 上试过,用 LDM/STM 指令一次拷贝 8 个字,速度能快 3 倍。不过要注意源地址和目的地址的对齐。

4.4 跳转到 main——最后一步,也是最容易翻车的一步

C 运行时环境准备好后,启动代码直接调用 main()。但这里有几个细节:

  • main 的返回值: 嵌入式系统的 main 通常定义为 int main(void),但实际不应该返回。如果返回了,启动代码应该进入死循环或调用错误处理。
  • 全局构造: 如果你用 C++,在 main 之前还要调用 __libc_init_array 执行全局对象的构造函数。C 语言一般不需要。
  • 系统初始化: 有些启动代码会在 main 之前调用 SystemInit() 配置时钟。我个人建议把时钟配置放在 main 的第一行,而不是启动代码里——这样 Bootloader 和 APP 可以各自管理时钟,互不干扰。

我曾经在跳转 main 之前忘了关全局中断,结果 main 还没跑,一个外设中断就进来了,而此时中断向量表还没完全准备好……嗯,HardFault 伺候。所以我的习惯是:在跳转 main 之前,关掉所有可屏蔽中断,让 main 自己决定何时开中断。

/* 安全的跳转方式 */
Reset_Handler:
    /* ... 数据段拷贝、BSS清零 ... */
    cpsid   i               /* 关全局中断 */
    bl      main            /* 跳转到 main */
    cpsie   i               /* 如果 main 返回,再开中断(实际不会执行到) */
loop:
    b       loop            /* 死循环 */

4.5 整体启动流程——一张图看懂

下面这张图总结了从复位到 main 的完整路径。我建议你把它打印出来贴在工位上。

Cortex-M 启动流程全景图 1. 芯片复位 2. 硬件取栈顶 + 复位向量 3. 设置 MSP 4. C 运行时初始化 BSS清零 | 数据段拷贝 | 全局构造 5. 跳转到 main() 6. 用户应用程序 关键说明 • 步骤1-2由硬件自动完成 • 步骤3-5由启动代码完成 • 步骤6才是你的业务逻辑 常见踩坑点 • 向量表未256字节对齐 • 栈顶地址超出RAM范围 • 跳转APP前未重设MSP

4.6 避坑清单——我替你踩过的雷

问题 现象 根因 解决方案
向量表未对齐 复位后直接 HardFault 硬件取向量时地址错误 检查链接脚本,确保 .isr_vector 段 256 字节对齐
栈顶地址错误 压栈时 SP 溢出到未定义区域 栈顶未设为 RAM 末尾 +1 用 ORIGIN(RAM) + LENGTH(RAM) 计算
BSS 未清零 全局变量初始值随机 启动代码中遗漏 BSS 循环 确认链接脚本导出了 _sbss / _ebss 符号
数据段未拷贝 全局变量值全为 0 Flash 到 RAM 的拷贝被跳过 检查 _sidata / _sdata / _edata 符号定义
跳转 APP 前未关中断 APP 启动时意外触发中断 中断向量表切换期间产生中断 跳转前 cpsid i,APP main 中再开中断
终极提醒: 调试启动代码时,别用仿真器的「运行到 main」功能。老老实实从复位开始单步,观察 SP、PC 和向量表地址。我见过太多人跳过启动代码直接调 main,结果出了问题根本不知道是启动阶段埋的雷。