2. MCU启动原理:ARM Cortex-M复位序列、向量表与堆栈指针、启动文件(startup.s)解析

做嵌入式开发这么多年,我见过不少同事上来就写应用代码,对MCU怎么“活过来”的完全不清楚。结果一跑就死,查半天发现是堆栈没配好,或者中断向量表地址错了。说白了,不理解启动原理,你连芯片怎么开始工作的都不知道,出了问题只能抓瞎。

这一章,我们就来彻底搞懂Cortex-M内核的启动过程。你想想看,芯片上电后,第一条指令从哪里取?堆栈指针怎么初始化?中断来了又怎么找到对应的处理函数?这些都在启动文件和向量表里定义好了。

2.1 ARM Cortex-M复位序列:芯片上电后发生了什么?

ARM Cortex-M内核的复位序列是硬件固化的,不需要你写任何代码。上电后,内核会自动做两件事:

  1. 从地址0x00000000读取初始堆栈指针(MSP),存入主堆栈指针寄存器。
  2. 从地址0x00000004读取复位向量(Reset_Handler的地址),然后跳转到该地址执行。

嗯,这里要注意:Cortex-M不是从0x00000000开始执行代码的,而是先读数据,再跳转。这和ARM7、ARM9那些老架构完全不同。我刚开始从ARM9转到Cortex-M时,就因为这个差异折腾了半天。

核心要点: Cortex-M复位后,PC(程序计数器)指向的是Reset_Handler的入口地址,而不是0x00000000。向量表的前两个word决定了系统的生死。

为什么会这样设计?因为Cortex-M内核支持多种异常和中断,需要一套统一的向量表来管理。复位也是一种异常(Reset Exception),优先级最高。硬件自动完成向量表的加载,然后才执行你的初始化代码。

2.2 向量表:中断与异常的“电话本”

向量表说白了就是一个地址表,每个表项对应一个异常或中断的处理函数入口。Cortex-M3/M4的向量表从地址0x00000000开始,默认大小为256个word(每个word 4字节)。

表的结构是这样的:

偏移地址 异常/中断号 描述
0x00 - 初始主堆栈指针(MSP)
0x04 1 复位(Reset_Handler)
0x08 2 不可屏蔽中断(NMI_Handler)
0x0C 3 硬错误(HardFault_Handler)
0x10 4 内存管理错误(MemManage_Handler)
0x14 5 总线错误(BusFault_Handler)
0x18 6 用法错误(UsageFault_Handler)
... ... ...
0x40 16 外部中断0(IRQ0)
0x44 17 外部中断1(IRQ1)
... ... ...

避坑指南: 我曾经在一个项目里,因为向量表地址没有4字节对齐,导致芯片上电后直接跑飞。Cortex-M要求向量表必须按256字节对齐(低8位为0),否则硬件会报错。检查你的链接脚本,确保 __Vectors 段对齐到了0x100边界。

向量表可以重定位。通过设置VTOR(向量表偏移寄存器),你可以把向量表搬到SRAM或Flash的其他位置。这在Bootloader升级时特别有用——你可以把新固件的向量表放在另一个地址,然后切换VTOR指向它。

2.3 堆栈指针:MSP与PSP

Cortex-M内核有两个堆栈指针:

  • MSP(主堆栈指针):复位后默认使用,用于异常/中断处理以及内核代码。
  • PSP(进程堆栈指针):用于任务级代码,通常在RTOS中使用。

复位时,硬件自动从向量表第一个word加载MSP的初始值。这个值必须是一个有效的RAM地址,并且要预留足够的堆栈空间。我见过有人把MSP初始值设成了Flash地址,结果一压栈就写Flash,直接HardFault。

警告: 堆栈向下生长。MSP初始值应该指向堆栈区域的最高地址。比如你的RAM从0x20000000到0x20010000,MSP初始值可以设为0x20010000。但别忘了留出堆栈空间,否则函数调用一深就溢出了。

我个人习惯在启动文件中把堆栈大小设为至少1KB,调试阶段设到4KB。等代码稳定了再根据实际使用量调整。你可以通过map文件查看堆栈使用峰值。

2.4 启动文件(startup.s)解析

启动文件是芯片上电后执行的第一段代码,通常用汇编编写。它负责三件事:

  1. 定义向量表。
  2. 初始化全局变量(BSS清零、DATA段拷贝)。
  3. 调用C库初始化函数,最后跳转到main()。

下面是一个典型的startup.s片段(以STM32F4为例):

; 向量表定义
__Vectors       DCD     __initial_sp          ; 栈顶地址
                DCD     Reset_Handler         ; 复位向量
                DCD     NMI_Handler           ; NMI
                DCD     HardFault_Handler     ; 硬错误
                DCD     MemManage_Handler     ; 内存管理错误
                DCD     BusFault_Handler      ; 总线错误
                DCD     UsageFault_Handler    ; 用法错误
                ; ... 其他异常向量 ...
                DCD     WWDG_IRQHandler       ; 窗口看门狗
                DCD     PVD_IRQHandler        ; 电压检测
                ; ... 外部中断向量 ...

; 复位处理函数
Reset_Handler   PROC
                EXPORT  Reset_Handler         [WEAK]
                IMPORT  __main
                IMPORT  SystemInit

                LDR     R0, =SystemInit
                BLX     R0
                LDR     R0, =__main
                BX      R0
                ENDP

; 默认中断处理函数(弱定义)
NMI_Handler     PROC
                EXPORT  NMI_Handler           [WEAK]
                B       .
                ENDP

HardFault_Handler PROC
                EXPORT  HardFault_Handler     [WEAK]
                B       .
                ENDP

注意看几个关键点:

  • __initial_sp 在链接脚本中定义,指向堆栈顶部。
  • Reset_Handler 先调用 SystemInit(配置时钟、FPU等),再跳转到 __main(C库初始化入口)。
  • 其他中断处理函数用 [WEAK] 标记,如果你在C代码里重新定义了同名函数,链接器会优先用你的版本。

个人经验: 我习惯在启动文件里给每个中断都写一个默认的弱定义处理函数,里面放一个死循环(B .)。这样如果某个中断意外触发了,芯片会停在那个循环里,方便调试器定位。否则没定义的中断会直接跑飞到未知地址。

2.5 启动流程全景图

为了让你更直观地理解整个启动过程,我画了一张流程图:

Cortex-M 启动流程全景图 上电复位 从0x00000000读MSP初始值 从0x00000004读Reset_Handler地址 跳转到 Reset_Handler SystemInit() 配置时钟/FPU __main() C库初始化 main() 用户代码 说明: __main() 内部完成BSS段清零、DATA段从Flash拷贝到RAM、全局对象构造等操作。 之后才真正进入你的 main() 函数。所以不要在 main() 之前依赖未初始化的全局变量。

2.6 启动文件中的段初始化细节

启动文件里最容易被忽略的就是BSS和DATA段的处理。BSS段存放未初始化的全局变量,必须清零。DATA段存放有初始值的全局变量,需要从Flash拷贝到RAM。

这些操作在 __main 函数里完成,但链接脚本必须提供正确的段地址信息:

; 链接脚本中定义的符号(由链接器生成)
__bss_start__   = .;        ; BSS段起始地址
__bss_end__     = .;        ; BSS段结束地址
__data_start__  = .;        ; DATA段在RAM中的起始地址
__data_end__    = .;        ; DATA段在RAM中的结束地址
__data_load__   = LOADADDR(.data);  ; DATA段在Flash中的存储地址

警告: 如果你的启动文件没有正确实现BSS清零,全局变量初始值会是随机的。我曾经在一个产品中遇到过,因为链接脚本里BSS段地址算错了,导致部分变量没清零,设备偶尔启动后行为异常。排查了整整两天才发现是这个问题。

2.7 常见启动问题与调试技巧

启动阶段出问题,芯片可能连main()都进不去。这里分享几个我常用的调试方法:

  • 检查PC值:复位后立即暂停,看PC是否指向Reset_Handler。如果不是,检查向量表地址和内容。
  • 检查SP值:看MSP是否指向RAM区域。如果SP是0或Flash地址,堆栈肯定有问题。
  • 单步执行启动代码:从Reset_Handler开始单步,看能不能顺利走到main()。卡在哪一步就查哪一步。
  • 使用HardFault调试器:如果进了HardFault,查看错误状态寄存器(CFSR、HFSR),能定位到具体原因。

避坑指南: 我曾经在一个Bootloader项目中,因为向量表重定位后忘记更新VTOR寄存器,导致中断全部跑飞。记住:如果你把向量表搬到了其他地址,必须在进入main()之前设置SCB->VTOR = 新地址。否则中断来了还是去查0x00000000处的旧表。

启动原理搞清楚了,后面写Bootloader时你就能理解为什么需要处理向量表重定位、为什么堆栈要预留足够空间、为什么中断处理函数要提前定义好。这些都是Bootloader稳定运行的基础。