3. STM32启动流程分析:从复位向量到main函数的完整启动链路

各位同学,今天我们来聊聊STM32上电后到底干了些什么。很多人写了好几年嵌入式程序,一上来就直接找main函数,却不知道在main之前,芯片已经默默执行了一大堆初始化工作。我个人觉得,搞Bootloader和IAP的人,如果不把启动流程吃透,后面遇到问题基本就是两眼一抹黑。

说白了,从复位到main,芯片走了一条固定的路。这条路就是启动文件(startup.s)铺好的。咱们今天就把这条路从头到尾走一遍。

3.1 复位后的第一件事:取向量

STM32上电复位后,CPU会干两件事:

  1. 从0x00000000地址取出栈顶指针(MSP),加载到SP寄存器
  2. 从0x00000004地址取出复位向量,加载到PC寄存器

嗯,这里要注意。0x00000000和0x00000004这两个地址,在STM32内部其实被映射到了Flash的起始地址(0x08000000)。所以你在启动文件里看到的栈顶和复位向量,实际上就是放在Flash最前面的8个字节。

关键点:复位后CPU自动完成向量加载,不需要任何软件干预。这是硬件行为。

3.2 启动文件(startup.s)到底干了什么

启动文件是汇编写的,不同型号的STM32略有差异,但核心逻辑一模一样。我以STM32F103为例,带大家过一遍。

3.2.1 中断向量表

文件开头就是中断向量表。它长这样:

__Vectors       DCD     __initial_sp          ; 栈顶指针
                DCD     Reset_Handler         ; 复位向量
                DCD     NMI_Handler           ; NMI
                DCD     HardFault_Handler     ; 硬错误
                DCD     MemManage_Handler     ; 内存管理错误
                DCD     BusFault_Handler      ; 总线错误
                DCD     UsageFault_Handler    ; 用法错误
                ; ... 后面还有几十个中断向量

每个DCD占4个字节。第一个是栈顶,第二个是复位入口。后面的都是各种异常和中断的处理函数地址。我在项目中遇到过有人把向量表写错位置,结果一进中断就跑飞,查了两天才发现是链接脚本里向量表偏移设错了。

避坑指南:做IAP时,APP的向量表必须偏移。偏移量必须是0x200的整数倍,否则中断响应会出问题。我曾经因为这个原因,在客户现场调试到凌晨三点。

3.2.2 Reset_Handler:真正的起点

复位后CPU跳到这里。它做三件事:

  1. 初始化系统时钟(调用SystemInit)
  2. 初始化全局变量(搬运.data段,清零.bss段)
  3. 调用main函数

看一段典型的Reset_Handler代码:

Reset_Handler   PROC
                EXPORT  Reset_Handler
                IMPORT  __main
                IMPORT  SystemInit

                LDR     R0, =SystemInit
                BLX     R0          ; 调用SystemInit配置时钟

                LDR     R0, =__main
                BX      R0          ; 跳转到C库的__main
                ENDP

这里有个细节:它跳转的是__main,不是直接跳main。__main是C运行时库的入口,它负责完成变量初始化后,再调用你的main。你想想看,如果直接跳main,全局变量都没初始化,程序肯定跑不起来。

3.3 变量初始化的幕后工作

这部分是启动文件里最容易被忽略的。我画个图帮大家理解:

STM32启动流程:从复位到main 系统复位 取栈顶指针 + 复位向量 Reset_Handler SystemInit(时钟配置) __main(C运行时初始化) main函数 注意: __main内部完成.data搬运 和.bss清零后才调用main

从图上可以清楚看到,__main干了这些事:

  • 搬运.data段:把Flash里存储的已初始化全局变量,复制到RAM中
  • 清零.bss段:把未初始化或初始化为0的全局变量所在区域,全部填0
  • 初始化堆栈:设置堆和栈的边界
  • 调用main:最后才跳转到你的main函数

个人经验:调试Bootloader时,我习惯在Reset_Handler里加个GPIO翻转。这样一上电就能用示波器看到芯片是否真的跑到了启动代码。如果没看到波形,说明芯片根本没复位成功,或者硬件有问题。

3.4 启动文件中的段定义

启动文件里还定义了各种段(Section)。这些段告诉链接器,代码和数据应该放在哪里。常见的有:

段名 存储位置 运行位置 内容
.text Flash Flash 代码、常量、向量表
.data Flash RAM 已初始化的全局变量
.bss 无(初始值0) RAM 未初始化的全局变量
.stack 无 RAM末尾 栈空间
.heap 无 RAM中 堆空间(malloc用)

这里有个坑:.data段在Flash里存了一份初始值,启动时搬到RAM。如果你做IAP,APP的.data段起始地址和大小必须算对,否则搬数据时会把Bootloader的区域给覆盖掉。我曾经见过有人把APP的.data段地址设错了,一启动就把Bootloader的代码给冲了,板子直接变砖。

3.5 堆栈初始化细节

启动文件里还会定义堆栈大小。以STM32F103为例:

Stack_Size      EQU     0x00000400    ; 1KB栈
                AREA    STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem       SPACE   Stack_Size
__initial_sp

Heap_Size       EQU     0x00000200    ; 512B堆
                AREA    HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem        SPACE   Heap_Size
__heap_limit

栈顶__initial_sp就是复位后加载到SP的值。它指向栈空间的末尾地址。为什么是末尾?因为栈是向下生长的,从高地址往低地址压栈。

避坑指南:做Bootloader时,栈大小要根据实际情况调整。如果你的Bootloader要处理复杂的串口协议,或者要校验大文件,1KB栈可能不够。我曾经在某个项目中,Bootloader的栈设小了,结果在解析YModem协议时栈溢出,程序莫名其妙跑飞。后来把栈改成2KB才稳定。

3.6 启动流程中的看门狗问题

这个点跟咱们课程主题直接相关。复位后,独立看门狗(IWDG)默认是关闭的,但窗口看门狗(WWDG)在某些系列里复位后可能处于开启状态。如果你在Bootloader里使能了看门狗,那么在SystemInit和__main执行期间,看门狗也在计数。

我建议的做法是:

  • 在Reset_Handler最开头就喂一次狗
  • 在调用SystemInit之前,如果不需要看门狗,先把它关掉
  • 如果需要看门狗,确保初始化时间不会超过看门狗超时时间

嗯,这里要特别提醒一下。有些库函数在SystemInit里会花不少时间,比如配置PLL锁相环。如果看门狗超时设得太短,芯片会在时钟还没配好时就复位了,形成死循环。我遇到过这种问题,板子一直闪,就是起不来。

3.7 小结

从复位到main,STM32走了一条清晰的路:硬件取向量 → Reset_Handler → SystemInit配置时钟 → __main初始化变量 → 最终进入main。这条路是固定的,但每个环节都有坑。做Bootloader和IAP的人,必须把这条路刻在脑子里。

启动文件虽然看着是汇编,但说白了就是一张初始化清单。你理解了这张清单,后面做中断向量表重映射、做跳转、做看门狗处理,才能游刃有余。