4. ARM Cortex-M启动详解:中断向量表、堆栈初始化、C运行时环境、跳转到main函数
芯片上电后,第一件事是什么?不是你的应用程序,而是一段叫做「启动代码」的东西。说白了,Bootloader 也好,用户程序也好,都得靠它把硬件拉到能跑 C 语言的状态。
我个人习惯把 Cortex-M 的启动过程拆成四个步骤:中断向量表定位 → 堆栈指针初始化 → C 运行时环境构建 → 跳转 main。每一步都踩过坑,今天我把这些经验掰开揉碎了讲给你。
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
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);
}
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 的完整路径。我建议你把它打印出来贴在工位上。
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 中再开中断 |