2、启动模式解析:ARM Cortex-M的启动流程、启动文件(startup.s)的作用、中断向量表

大家好,我是你们的Bootloader讲师。今天咱们聊聊ARM Cortex-M的启动流程。说实话,我见过不少工程师,写了好几年嵌入式代码,却对芯片上电后那几百微妙里发生了什么一无所知。嗯,这其实挺危险的——你连系统怎么“活过来”的都不清楚,怎么敢说自己懂底层?

这一节,我会把Cortex-M的启动流程掰开揉碎了讲。包括启动文件(startup.s)到底在干什么,中断向量表为什么是“命根子”。

2.1 上电瞬间:CPU的第一口“奶”

ARM Cortex-M处理器复位后,硬件会自动做两件事:

  1. 从0x00000000读取栈顶指针(SP),初始化主堆栈指针MSP。
  2. 从0x00000004读取复位向量(Reset_Handler),跳转到那里执行第一条指令。

就这么简单?对,就这么简单。但这里有个坑——向量表必须放在起始地址。我曾经在一个项目里,因为链接脚本写错了,把向量表偏移到了0x2000,结果芯片死活跑不起来。查了两天,最后发现是向量表位置不对。

核心要点:Cortex-M复位后,硬件自动从0x00000000和0x00000004取数。这是硬编码行为,不可更改(除非你使能了VTOR重定位)。

2.2 启动文件(startup.s)到底在干什么?

很多初学者觉得startup.s是“天书”,一堆汇编看不懂。其实它就是个“保姆”,负责在main()之前把环境搭好。我习惯把它的工作分成四步:

  • 定义中断向量表——告诉CPU每个异常/中断的入口地址。
  • 初始化栈指针——设置好C语言运行需要的栈空间。
  • 搬运数据段——把RW段从Flash搬到RAM,把ZI段清零。
  • 调用SystemInit()和main()——初始化时钟、外设,然后进入主循环。

你想想看,如果没有startup.s,你的C代码连个全局变量都访问不了——因为RAM还没初始化呢。

我的习惯:在startup.s里,我会把未使用的中断向量都指向一个死循环函数(Default_Handler)。这样万一触发了未使能的中断,系统不会跑飞,而是停在原地等你调试。这个习惯救过我至少三次。

2.3 中断向量表:系统的“电话簿”

中断向量表,说白了就是一张地址映射表。每个中断源对应一个表项,表项里存的是中断服务函数(ISR)的入口地址。

Cortex-M的向量表前16个是系统异常,后面是外设中断。以STM32F103为例:

偏移地址 异常/中断 优先级 说明
0x00 栈顶指针(SP) - 初始MSP值
0x04 Reset_Handler -3(最高) 复位后第一条指令
0x08 NMI_Handler -2 不可屏蔽中断
0x0C HardFault_Handler -1 硬错误,最常见
0x10~0x3C 其他系统异常 可配置 MemManage、BusFault等
0x40+ 外设中断 可配置 TIM、UART、DMA等

这里有个关键点:向量表必须4字节对齐,因为每个表项是一个32位地址。我曾经见过有人把向量表定义成char数组,结果地址错位,系统一进中断就跑飞。

避坑指南:如果你用VTOR重定位了向量表,记得新地址必须按128字节对齐(Cortex-M3/M4要求)。我踩过这个坑——把向量表放在0x20000100,结果中断响应全乱套了。

2.4 启动流程完整图解

下面这张图,是我自己总结的Cortex-M启动流程。每次带新人,我都会先让他们看这张图:

ARM Cortex-M 启动流程 ARM Cortex-M 启动流程 ① 上电复位 ② 硬件读取向量表(0x00,0x04) ③ 跳转 Reset_Handler ④ startup.s 初始化 搬运RW段 / 清零ZI段 / 初始化堆栈 ⑤ SystemInit() 时钟配置 ⑥ 进入 main() 注意 向量表必须放在起始地址 或通过VTOR重定位 且128字节对齐 startup.s 核心工作 1. 定义中断向量表 2. 初始化栈指针 3. 搬运数据段 4. 调用SystemInit+main

2.5 一个典型的startup.s片段

光说不练假把式。我贴一段我常用的startup.s核心代码,注释都写好了:

; 中断向量表定义
__Vectors       DCD     __initial_sp          ; 栈顶指针
                DCD     Reset_Handler         ; 复位向量
                DCD     NMI_Handler           ; NMI
                DCD     HardFault_Handler     ; 硬错误
                DCD     MemManage_Handler     ; 内存管理错误
                DCD     BusFault_Handler      ; 总线错误
                DCD     UsageFault_Handler    ; 用法错误
                ; ... 其他异常向量 ...
                DCD     TIM2_IRQHandler       ; TIM2中断
                DCD     USART1_IRQHandler     ; USART1中断
                ; ... 更多外设中断 ...

; 复位处理函数
Reset_Handler   PROC
                EXPORT  Reset_Handler
                ; 1. 搬运RW段从Flash到RAM
                LDR     r0, =_sidata
                LDR     r1, =_sdata
                LDR     r2, =_edata
                BL      copy_data
                
                ; 2. 清零ZI段
                LDR     r1, =_sbss
                LDR     r2, =_ebss
                BL      zero_bss
                
                ; 3. 调用SystemInit
                BL      SystemInit
                
                ; 4. 进入main
                BL      main
                
                ; 5. 如果main返回,死循环
                B       .
                ENDP

这段代码里,_sidata、_sdata这些符号是链接脚本定义的。它们告诉启动文件:RW段在Flash里的起始地址、在RAM里的目标地址、以及长度。ZI段同理。

个人经验:我习惯在startup.s里加一个WDT_Feed调用——如果系统有看门狗,在初始化过程中定期喂狗。否则,如果SystemInit()里配置PLL耗时较长,看门狗可能先复位了。这个细节,我见过至少三个项目因此翻车。

2.6 中断响应:硬件自动压栈

Cortex-M的中断响应和传统ARM7/9不一样。它硬件自动压栈——进入中断时,CPU自动把R0-R3、R12、LR、PC、xPSR这8个寄存器压到当前栈上。退出中断时,硬件自动出栈恢复。

这意味着什么?意味着你的ISR里不需要手动保存/恢复寄存器了。但要注意:如果ISR里调用了其他函数,编译器仍然会为那些函数生成压栈代码。别搞混了。

我曾经在一个项目里,因为ISR里调用了printf(),导致栈溢出。为什么?因为printf()本身用了大量栈空间,加上硬件自动压栈的8个寄存器,栈直接爆了。从那以后,我定了个规矩:ISR里不调用复杂函数,只设标志位,主循环里处理。

2.7 总结一下

这一节的内容,说白了就三件事:

  • 启动流程:复位→取向量→跳Reset_Handler→初始化→进main。
  • startup.s:负责搬运数据、清零BSS、调用初始化函数。
  • 中断向量表:一张地址表,告诉CPU中断来了该去哪。

嗯,这些是Cortex-M启动的基石。你把这些搞透了,后面讲Bootloader的跳转、中断重定向,就会轻松很多。记住:底层没有魔法,只有你没读懂的文档。