第2章 启动流程分析:ARM Cortex-M内核的启动流程、复位向量表、堆栈指针初始化、跳转到main函数

做嵌入式开发,尤其是移植Bootloader,第一关就是搞懂芯片上电后到底干了什么。很多人拿着开发板直接点灯,觉得启动流程无所谓——嗯,我以前也这么想,直到有一次调试一个诡异的死机问题,折腾了两天,最后发现是向量表偏移配错了。

从那以后,我养成了一个习惯:移植任何新平台,先把启动流程手动画一遍。今天我们就来拆解Cortex-M内核的启动过程,看看从复位到main函数之间,到底发生了什么。

2.1 复位后的第一件事:读取向量表

Cortex-M内核和传统的ARM7/ARM9不一样。老架构上电后从0x00000000开始执行第一条指令,但Cortex-M更聪明——它直接从向量表取数据。

芯片复位后,硬件自动做两件事:

  1. 从地址 0x00000000 读取 栈顶指针(MSP),写入主堆栈指针寄存器
  2. 从地址 0x00000004 读取 复位向量(Reset_Handler),写入PC寄存器

说白了,硬件帮你把栈和入口地址都准备好了。你想想看,这比老架构省了多少事?

关键点:向量表的前两个字必须是MSP和Reset_Handler。如果这里放错了,芯片根本跑不起来。我在项目中遇到过有人把中断函数地址写到了0x00000000,结果上电直接HardFault。

2.2 向量表的结构

向量表本质上是一个地址数组,每个元素对应一个异常或中断的入口地址。Cortex-M把前16个向量定义为系统异常,从第16个开始是外部中断。

下面这张图展示了标准的向量表布局:

Cortex-M 向量表结构 偏移地址 向量名称 说明 0x00000000 MSP(栈顶指针) 硬件自动加载到SP 0x00000004 Reset_Handler 复位后第一条指令入口 0x00000008 NMI_Handler 不可屏蔽中断 0x0000000C HardFault_Handler 硬件错误处理 0x00000010 MemManage_Handler 内存管理异常 ...... 0x00000040+ IRQ 0 ~ IRQ N 外部中断向量(芯片厂商定义) 地址递增,每个向量占4字节 💡 注意: 向量表可以重定位到其他地址(通过VTOR寄存器),Bootloader常用这个特性跳转到APP。

每个向量占4个字节,所以第n个向量的地址就是 0x00000000 + n * 4。比如第15个向量(SysTick)的地址是 0x0000003C。

我的习惯:写启动文件时,我会在向量表末尾留一个哨兵值(比如0xDEADBEEF),用来检测向量表是否被意外覆盖。调试时特别有用。

2.3 堆栈指针初始化

Cortex-M有两个堆栈指针:MSP(主堆栈指针)和PSP(进程堆栈指针)。复位后默认使用MSP。

硬件从0x00000000读取的值直接写入MSP。这个值必须是合法的RAM地址,通常是SRAM的末尾地址。为什么是末尾?因为栈是向下生长的,从高地址往低地址压栈。

举个例子,如果你的芯片有64KB SRAM,起始地址是0x20000000,那么栈顶指针应该设为 0x20000000 + 0x10000 = 0x20010000。

; 典型的启动文件片段(汇编)
__Vectors       DCD     __initial_sp        ; 栈顶指针
                DCD     Reset_Handler       ; 复位向量
                DCD     NMI_Handler         ; NMI
                DCD     HardFault_Handler   ; HardFault
                ; ... 其他向量 ...

__initial_sp    EQU     0x20010000          ; 假设64KB SRAM

我曾经踩过的坑:有一次我把栈顶指针设在了SRAM的中间位置,结果程序跑起来后栈和堆打架,数据被互相覆盖,查了整整一天。记住:栈顶指针一定要指向SRAM的最高地址,除非你有特殊的内存分区需求。

2.4 Reset_Handler 的执行流程

硬件跳转到Reset_Handler后,软件正式接管。这个函数通常由启动文件(startup_xxx.s)提供,主要做三件事:

步骤 操作 说明
1 初始化 .bss 段 将全局/静态未初始化变量清零
2 初始化 .data 段 将已初始化全局变量从Flash复制到RAM
3 调用 SystemInit() 配置系统时钟、PLL、Flash等待周期等
4 跳转到 main() 进入C语言世界

你想想看,如果没有这些初始化,你在main里定义的全局变量全是随机值,那程序根本没法跑。

下面这张流程图展示了完整的启动链路:

Cortex-M 启动流程 芯片复位 硬件:从0x00000000加载MSP 硬件:从0x00000004加载PC 跳转到 Reset_Handler 软件初始化 清零.bss | 复制.data | SystemInit() 跳转到 main() 硬件自动完成 无需软件干预 约2个时钟周期 软件接管 启动文件负责 可自定义初始化顺序

2.5 跳转到main函数

初始化完成后,启动文件最后会调用 main()。这里有个细节:Cortex-M的 BL 指令会修改LR寄存器,所以main函数返回后还能回到启动文件。但实际嵌入式应用中,main函数通常是个死循环,不会返回。

看一个典型的启动文件结尾:

; 跳转到main
    LDR     R0, =main
    BLX     R0
; 如果main返回,进入死循环
    B       .

注意那个 B .,意思是跳转到当前地址,也就是死循环。这是为了防止main意外返回后程序跑飞。

我建议:在移植Bootloader时,可以在跳转到main之前加一段自检代码,比如检查CRC、验证向量表完整性。这样即使main出了问题,也能在启动阶段就发现。

2.6 常见问题与避坑

  • 栈顶指针不对:如果MSP指向了无效地址,上电直接HardFault。用调试器看SP寄存器的值,确认是否在RAM范围内。
  • 向量表偏移:Bootloader跳转到APP前,必须通过VTOR寄存器设置新的向量表地址。我见过有人忘了这步,APP里一发生中断就跑回Bootloader的向量表,死得莫名其妙。
  • .bss段未清零:全局变量默认是0,如果启动文件没清零,这些变量就是随机值。调试时打印出来看看,能省很多时间。
  • SystemInit缺失:有些芯片的SystemInit里配置了Flash等待周期,如果没调用,Flash读取可能出错,程序跑着跑着就挂了。

我曾经遇到一个案例:某款国产MCU,启动文件里忘了调用SystemInit,结果PLL没配置,芯片跑在内部低速振荡器上。串口打印全是乱码,我还以为是波特率配错了,折腾了两小时才发现是时钟问题。

2.7 小结

启动流程说白了就是:硬件帮你搭好栈和入口,软件负责把C语言环境准备好,最后跳转到main。每一步都很关键,任何一个环节出错,程序都跑不起来。

移植Bootloader时,我建议你先把启动文件从头到尾读一遍,确认向量表、栈指针、段初始化都没问题。这一步做好了,后面的移植工作会顺利很多。