3、ARM Cortex-M启动流程:向量表、复位向量、堆栈初始化、跳转到main函数

好,咱们今天聊点硬核的。ARM Cortex-M 的启动流程,说白了就是芯片上电后,从「啥也没有」到「跑起 main 函数」的这段路。很多初学者觉得这步很简单,不就是跳个转吗?其实不然。我在项目中遇到过好几次,因为启动代码没写好,板子死活跑不起来,最后发现是向量表放错了位置。

嗯,咱们一步步拆开看。

3.1 向量表:芯片的「通讯录」

芯片一上电,CPU 做的第一件事是什么?不是执行你的代码,而是去查一张表——向量表。

你可以把向量表想象成一本通讯录。里面记录了各种异常和中断发生时,CPU 该找谁去处理。比如复位、硬 fault、SVC 等等。每个条目占 4 字节,存的就是对应处理函数的地址。

Cortex-M 强制要求向量表必须从地址 0x00000000 开始(当然,有些芯片可以通过 SCB->VTOR 重映射,但默认就是 0 地址)。

向量表的前两个条目特别重要:

  • 第 0 项(地址 0x00000000):存放主堆栈指针(MSP)的初始值。
  • 第 1 项(地址 0x00000004):存放复位向量,也就是复位后第一条指令的地址。

为什么这么设计?我个人的理解是:芯片需要先知道「栈在哪」,才能调用函数。所以先把栈指针准备好,再跳转到复位处理函数。

关键点:向量表第 0 项不是函数地址,是栈顶地址。很多新手在这里栽跟头,把函数地址填进去了,结果一上电就跑飞。

3.2 复位向量:一切从这里开始

复位向量,就是芯片复位后,CPU 要执行的第一段代码。它通常指向一个叫 Reset_Handler 的函数。

这个函数做什么?说白了就三件事:

  1. 初始化全局变量(把 .data 段从 Flash 拷贝到 RAM,把 .bss 段清零)。
  2. 初始化系统时钟、外设等硬件。
  3. 调用 main() 函数。

你想想看,如果没有这一步,你的全局变量初始值都是乱的,程序根本没法跑。

我曾经遇到过一个坑:某款芯片的 RAM 初始化需要等待几个时钟周期,但启动代码里没加延时,结果 .data 段拷贝了一半,RAM 还没准备好,数据全写歪了。从那以后,我每次写启动代码都会在 RAM 初始化后加个 __DSB() 指令。

个人习惯:我一般会在 Reset_Handler 开头放一个 __disable_irq(),防止中断在初始化过程中捣乱。等所有初始化完成后再开中断。

3.3 堆栈初始化:给 C 语言搭好舞台

C 语言能跑起来,靠的是栈。局部变量、函数调用、参数传递,全得用栈。所以堆栈初始化是启动流程里最基础的一步。

Cortex-M 的栈分为两种:

  • 主堆栈(MSP):默认使用的栈,处理异常和中断时也用这个。
  • 进程堆栈(PSP):可选,用于任务切换,RTOS 里常用。

启动时,CPU 会自动从向量表第 0 项加载 MSP 的值。但你要确保这个值是正确的——它必须是 RAM 的末尾地址,而且要 8 字节对齐。

为什么是 RAM 末尾?因为栈是向下生长的。你把栈顶设在 RAM 的最高地址,栈往低地址长,就不会跟全局变量区冲突。

代码里通常这么写:

__attribute__((section(".isr_vector")))
void (* const g_pfnVectors[])(void) = {
    (void *)&_estack,          // 第0项:栈顶地址
    Reset_Handler,             // 第1项:复位向量
    NMI_Handler,
    HardFault_Handler,
    // ... 其他异常向量
};

这里的 _estack 是链接脚本里定义的 RAM 末尾地址。我建议你每次改链接脚本后,都检查一下这个值对不对。我有一次改了 RAM 大小,忘了更新链接脚本,结果栈顶跑到 ROM 里去了,程序跑起来就死。

注意:栈空间别设太小。我见过有人只给栈留了 256 字节,结果函数嵌套深一点就栈溢出,查 bug 查了三天。一般建议至少 1KB,RTOS 环境建议 4KB 以上。

3.4 跳转到 main 函数:最后的临门一脚

所有初始化都完成后,最后一步就是跳转到 main()。这一步看起来简单,其实也有讲究。

标准的做法是:

void Reset_Handler(void) {
    // 1. 拷贝 .data 段
    // 2. 清零 .bss 段
    // 3. 初始化系统时钟
    // 4. 调用 main
    main();
}

但这里有个细节:main() 函数不应该返回。如果它返回了,程序该去哪?

我个人的做法是:

void Reset_Handler(void) {
    // ... 初始化 ...
    main();
    // 如果 main 返回,进入死循环
    while(1);
}

或者更严谨一点,调用 exit() 或者触发一个硬 fault,让调试器知道程序结束了。

还有一个容易被忽略的点:main() 函数的参数。标准 C 的 main 可以带 argcargv,但在嵌入式环境里,这两个参数通常没用。我建议直接用 int main(void),省得编译器生成多余的参数传递代码。

避坑指南:我曾经在一个项目里,启动代码直接调用了 main(),但链接脚本里把 main 放在了某个特殊段里,结果跳转地址算错了,程序直接跑飞。后来我改成用函数指针调用,才定位到问题。所以,启动代码里的函数调用,尽量用绝对地址或者直接函数名,别搞花活

3.5 完整启动流程总结

咱们把整个流程串起来,看看芯片上电后到底干了啥:

步骤 动作 说明
1 从 0x00000000 加载 MSP CPU 自动读取向量表第 0 项,设置主堆栈指针
2 从 0x00000004 加载 PC 读取复位向量地址,跳转到 Reset_Handler
3 执行 Reset_Handler 初始化全局变量、系统时钟、外设等
4 调用 main() 进入用户程序主循环

你看,整个过程其实就四步。但每一步都有坑,每一步都值得你仔细推敲。

我记得刚入行时,带我的老工程师说:「启动代码是嵌入式的第一道门槛,跨过去,后面就顺了。」现在想想,确实如此。你把这部分吃透了,后面学 RTOS、学驱动,都会轻松很多。

好,这一章就到这。下一章咱们聊聊链接脚本,看看怎么把代码和数据安排得明明白白。