☰
STM32上电启动流程深度拆解:从复位向量到main函数的每一步
2026/10/19 0:12:28 网站建设 项目流程

1. 上电那一瞬间,芯片内部到底在忙什么

很多人第一次接触STM32的时候,都会有一个疑问:我写的main()函数,到底是怎么被调用的?明明代码里没有一行去调用它,为什么程序一上电就能跑起来?这个问题看起来简单,但如果你真的能把从上电到main()之间的每一步都讲清楚,那说明你对ARM Cortex-M架构和STM32的启动机制已经有了相当扎实的理解。

我自己当年也是模模糊糊过来的。刚开始写STM32的时候,只知道"启动文件很重要,不要乱动",至于里面那些汇编代码到底在干什么,完全是一头雾水。后来因为一个项目里遇到了变量未初始化的问题,才逼着自己把启动流程从头到尾捋了一遍。捋完之后发现,很多之前觉得"玄学"的问题,其实都能在这个流程里找到答案。

这篇文章就是把我自己拆解STM32上电启动流程的过程完整记录下来。从复位向量怎么取、栈指针怎么设、SystemInit做了什么、__main和main有什么区别,一直到C运行时环境是怎么建立起来的,每一步我都会尽量讲清楚"为什么是这样"而不只是"是这样"。适合有一定STM32开发基础、想深入理解底层启动机制的朋友。如果你还在纠结"为什么我的全局变量初值不对""为什么中断还没配置就进来了"这类问题,那这篇内容应该能帮你把思路理顺。

整篇内容我会以STM32F1系列(Cortex-M3内核)为主要参考,但核心流程在F0、F4、H7等系列上是相通的,差异点我会单独指出来。

2. 从复位到第一条指令:硬件层面发生了什么

2.1 复位向量的取址过程

STM32上电或者按下复位键之后,CPU并不是从地址0x00000000开始执行代码的。Cortex-M内核的复位行为是:从地址0x00000000处取出一个字(4字节),把这个值加载到主栈指针MSP中;然后从地址0x00000004处再取出一个字,把这个值加载到程序计数器PC中,然后从PC指向的地址开始执行。

这两个地址里存的东西,就是所谓的中断向量表的前两项。第一项是栈顶地址,第二项是复位处理函数的入口地址。

这里有一个很多人容易搞混的点:STM32的Flash物理起始地址是0x08000000,但为什么复位后是从0x00000000取向量?这是因为STM32内部做了地址重映射。根据BOOT引脚的不同配置,0x00000000这个地址会被映射到不同的物理存储区域:

BOOT1BOOT0映射区域典型场景
x0主Flash (0x08000000)正常运行
01系统存储器出厂Bootloader
11内置SRAM调试/特殊用途

所以你在启动文件里看到的向量表,最终会被链接器放到Flash的最前面,而硬件通过重映射机制让CPU在0x00000000处就能读到它。

2.2 栈顶地址是怎么确定的

向量表的第一个字是栈顶地址。在STM32的启动文件(比如startup_stm32f103xb.s)里,通常能看到这样的定义:

__initial_sp EQU 0x20005000

这个值是怎么来的?它其实就是RAM的结束地址。以STM32F103C8T6为例,它有20KB的SRAM,起始地址0x20000000,结束地址就是0x20005000。栈是从高地址向低地址生长的,所以栈顶设在RAM的最末尾。

但实际项目中,这个值不是固定的。链接器会根据你使用的RAM大小、是否有其他区域占用等因素,通过链接脚本自动计算栈顶位置。在Keil中,这个值由分散加载文件(.sct)决定;在GCC中,由链接脚本(.ld)决定。

注意:如果你在项目中使用了外部SRAM或者对内存布局做了特殊安排,一定要确认栈顶地址是否合理。栈顶设错了,程序可能在第一次压栈的时候就跑飞。

2.3 复位处理函数的第一批操作

取完栈顶和复位向量之后,CPU就开始执行复位处理函数了。在启动文件里,这个函数通常叫Reset_Handler。它做的第一批事情非常关键,我逐行拆一下:

Reset_Handler: LDR R0, =SystemInit BLX R0 LDR R0, =__main BX R0

看起来只有四行,但信息量很大。第一行把SystemInit的地址加载到R0寄存器,第二行跳过去执行。SystemInit是CMSIS标准里定义的一个函数,主要做时钟系统的初始化。第三、四行则是跳转到__main,注意这里是__main而不是main,两者不是一回事,后面会详细讲。

有些启动文件在调用SystemInit之前还会做一些额外操作,比如使能FPU(对于Cortex-M4F/M7)、设置向量表偏移等。这些操作因芯片系列而异,但核心逻辑是一致的。

3. SystemInit里藏了哪些容易被忽略的细节

3.1 时钟配置的默认状态

SystemInit这个函数定义在system_stm32f1xx.c文件中(不同系列文件名不同)。它最主要的工作是配置系统时钟。但这里有一个很多人不知道的事实:在SystemInit执行之前,STM32跑的是内部HSI时钟,通常是8MHz(F1系列)或者16MHz(F4系列)。

也就是说,从复位到SystemInit执行完这段时间,CPU是以一个相对较低的频率在运行的。这就是为什么有些对时序敏感的外设操作,如果在SystemInit之前执行,可能会得到意想不到的结果。

SystemInit内部通常会做这几件事:

  1. 复位RCC寄存器到默认状态
  2. 根据宏定义选择时钟源(HSI/HSE)
  3. 配置PLL倍频参数
  4. 切换系统时钟到PLL输出
  5. 更新SystemCoreClock变量

以F1系列为例,如果你用的是8MHz外部晶振,SystemInit会把PLL配置成9倍频,最终系统时钟跑到72MHz。这个过程涉及对RCC_CFGR、RCC_CR等寄存器的精确操作,每一步都有时序要求。

3.2 向量表偏移寄存器的设置

在Cortex-M3/M4/M7中,有一个叫VTOR(Vector Table Offset Register)的寄存器。SystemInit里通常会根据VECT_TAB_OFFSET宏来设置这个寄存器。

为什么需要这个?因为在实际项目中,你的程序可能不是从Flash的0x08000000开始存放的。比如你做了一个Bootloader+App的架构,App的起始地址可能是0x08008000。这时候中断向量表也跟着偏移了,如果不设置VTOR,中断发生时CPU还是会去0x00000000处找向量表,结果就跑到Bootloader的中断处理函数里去了。

SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET;

这行代码看起来简单,但如果你在做OTA升级或者多固件切换,忘了设这个寄存器,就会出现"中断响应了但执行的是错误的函数"这种非常难排查的问题。

3.3 一个常见的误解

很多人以为SystemInit是必须的,其实不是。你完全可以在启动文件里不调用它,自己写一个时钟配置函数。但这样做的前提是,你清楚地知道自己在干什么,并且确保在main()之前系统时钟已经配置到你期望的状态。

我见过有项目为了"优化启动时间",把SystemInit里的时钟配置简化了,结果某些外设初始化时因为时钟频率不对,波特率算出来偏差很大,通信一直不稳定。所以我的建议是:除非你有明确的理由和充分的测试,否则不要轻易改动SystemInit的默认行为。

4. __main不是main:C运行时环境的建立过程

4.1 为什么需要__main

这是整个启动流程里最容易让人困惑的地方。启动文件的最后是跳转到__main,但你的C代码里写的是main。这两个到底什么关系?

__main是ARM编译器(armcc/armclang)提供的一个库函数,它不属于你的代码,而是编译工具链的一部分。它的核心职责是建立C运行时环境,然后才调用你写的main函数。

具体来说,__main会做这几件事:

  1. 代码和数据段的搬移:把Flash中的初始化数据复制到RAM中
  2. ZI段的清零:把未初始化的全局变量和静态变量所在区域清零
  3. 堆栈的初始化:设置堆的起始和结束地址
  4. 调用__rt_entry:最终进入C库的入口,再调用main

如果你用的是GCC工具链,对应的入口是_start,功能类似但实现方式不同。

4.2 数据段搬移的详细过程

这是启动流程里最核心的一步,也是很多"玄学问题"的根源。你的C代码里写了:

int global_var = 42; int global_var2;

global_var有初值,它会被放在.data段;global_var2没有初值,它会被放在.bss段(也叫ZI段)。

问题来了:.data段的初值存在哪里?答案是存在Flash里。程序运行时,.data段必须在RAM中,所以启动代码需要把Flash里的初值复制到RAM中对应的位置。而.bss段不需要复制,只需要全部清零。

这个过程在__main里自动完成,具体是通过链接器生成的分散加载信息来指导的。在Keil中,你可以通过查看.map文件来确认各个段的地址和大小。

段名存放位置是否占用Flash启动时操作
.textFlash是无需操作
.dataFlash+RAM是从Flash复制到RAM
.bssRAM否清零
StackRAM否设置栈顶
HeapRAM否设置堆边界

4.3 一个真实的踩坑案例

我之前遇到过一个项目,代码在Debug模式下运行正常,Release模式下就出问题。排查了很久才发现,是因为Release模式下编译器做了优化,把一些变量的初始化顺序调整了,而代码中有一个全局变量在另一个全局变量的初始化表达式中被引用,导致使用了未初始化的值。

这个问题的根源就在于对启动流程中数据段搬移的顺序理解不够。C标准并没有规定不同编译单元之间全局变量的初始化顺序,所以如果你的代码依赖于这种顺序,那就是在走钢丝。

解决办法很简单:不要写依赖于全局变量初始化顺序的代码。如果确实需要,用一个显式的初始化函数,在main开头调用。

5. 中断向量表与启动文件的配合关系

5.1 向量表的完整结构

中断向量表不只有复位向量,它包含了所有异常和中断的入口地址。Cortex-M3的向量表结构如下:

偏移地址内容说明
0x00栈顶地址初始化MSP
0x04Reset_Handler复位处理
0x08NMI_Handler不可屏蔽中断
0x0CHardFault_Handler硬件错误
.........
0x3CSysTick_Handler系统滴答定时器
0x40+外设中断IRQ0, IRQ1, ...

在启动文件里,这些处理函数通常被定义成"弱符号"(weak symbol)。这意味着如果你在C代码里定义了一个同名的函数,链接器会用你的版本覆盖启动文件里的默认版本。默认版本通常就是一个死循环:

Default_Handler: B .

这个设计非常巧妙:如果你没有实现某个中断处理函数,但该中断被触发了,程序会停在那里,方便你用调试器发现问题。而不是跳到一个随机地址跑飞。

5.2 启动文件中的栈和堆配置

启动文件里除了向量表和复位处理,还会定义栈和堆的大小:

Stack_Size EQU 0x00000400 Heap_Size EQU 0x00000200

Stack_Size默认是1KB,Heap_Size默认是512字节。这两个值在很多项目中都需要调整。

栈大小的估算是一个经验活。你需要考虑:最深的中断嵌套层数、每个中断处理函数里的局部变量大小、函数调用深度等因素。我一般会这样估算:

  • 基础函数调用:预留256字节
  • 每个中断嵌套层级:预留64-128字节
  • 如果有递归或大局部数组:额外增加

堆的大小则取决于你是否使用了malloc。如果你从来不用动态内存分配,可以把Heap_Size设为0,省出RAM给其他用途。但要注意,某些C库函数(如printf的某些实现)可能会隐式使用堆。

提示:在Keil中,如果栈溢出,不一定会立刻报错。它可能会覆盖相邻的变量区域,导致一些莫名其妙的问题。建议在调试阶段开启栈检查功能,或者手动在栈顶和栈底填充特征值,定期检查是否被改写。

5.3 中断优先级与启动的关系

有一个容易被忽略的点:在main()执行之前,中断优先级分组和各个中断的优先级都是默认值。Cortex-M3复位后,所有中断优先级都是0,优先级分组也是默认的。

这意味着如果你在SystemInit或者更早的阶段就使能了某个中断,它的优先级可能不是你期望的。所以标准的做法是:在main()里,先配置优先级分组,再配置各个中断的优先级,最后才使能中断。

我见过有项目在SystemInit里就打开了SysTick中断,结果因为优先级没配置好,导致其他中断响应异常。这种问题排查起来非常痛苦,因为现象可能是不确定的。

6. 不同工具链下的启动差异与移植要点

6.1 Keil MDK与GCC的启动文件对比

Keil MDK使用的是ARMCC/ARMCLANG编译器,启动文件是汇编格式(.s),入口是__main。GCC工具链使用的是GNU汇编格式,入口是_start,通常由crt0.o提供。

两者在功能上是对等的,但细节上有不少差异:

对比项Keil MDKGCC
启动文件格式ARM汇编GNU汇编
C库入口__main_start
数据段搬移由__main自动完成由启动代码或链接脚本处理
向量表定义汇编中直接定义通常在C文件中定义
弱符号语法__weak__attribute__((weak))

如果你需要把Keil工程移植到GCC(比如用STM32CubeIDE或者Makefile构建),启动文件是必须替换的。但更重要的是理解两者在C运行时初始化上的差异,否则可能会遇到变量未初始化、构造函数未调用等问题。

6.2 C++全局对象构造的问题

如果你的项目用到了C++,那启动流程还多了一步:全局对象的构造函数调用。在Keil中,__main会遍历.init_array段,依次调用其中保存的构造函数指针。在GCC中,类似的操作由__libc_init_array完成。

这里有一个坑:如果你自己写了启动代码,但忘了调用构造函数初始化,那所有全局C++对象的构造函数都不会执行。表现就是对象的状态不对,但编译和链接都不会报错。

// 在启动代码中需要确保调用了类似这样的函数 extern void __libc_init_array(void); __libc_init_array();

6.3 移植时的检查清单

当你需要把一个STM32工程从一个工具链迁移到另一个,或者从一个芯片系列迁移到另一个时,启动相关的检查清单如下:

  1. 启动文件是否与目标芯片匹配(中断向量数量和名称)
  2. 栈顶地址是否正确(RAM大小和起始地址)
  3. 堆大小是否满足需求
  4. 向量表偏移是否设置正确
  5. 系统时钟配置是否与硬件匹配(晶振频率)
  6. C++全局构造函数是否被正确调用
  7. 弱符号定义是否与编译器匹配

这份清单看起来简单,但每一条我都见过有人踩坑。特别是第5条,换了晶振但忘了改SystemInit里的宏定义,导致系统时钟跑到了一个完全不对的频率,所有基于时钟的外设全部异常。

7. 几个值得深挖的启动相关调试技巧

7.1 用调试器观察启动过程

在Keil或IAR中,你可以在启动文件的第一条指令处设置断点,然后单步执行,观察寄存器和内存的变化。具体操作是:在Reset_Handler的第一行设置断点,复位芯片,然后单步。

重点观察这几个时刻:

  • SystemInit执行前后,RCC相关寄存器的值
  • __main执行前后,.data段和.bss段的内容变化
  • 进入main时,栈指针的值

这种观察方式比看文档直观得多,而且能帮你建立起对启动流程的"肌肉记忆"。

7.2 通过map文件确认内存布局

.map文件是链接器生成的,里面详细记录了每个段、每个函数、每个变量的地址和大小。在启动流程调试中,我主要关注这几个信息:

  • Image$$RW_IRAM1$$Base和Image$$RW_IRAM1$$Limit:RAM中数据段的边界
  • Image$$ER_IROM1$$Base:Flash中代码段的起始地址
  • Load Region和Execution Region:加载域和执行域的对应关系

理解了这些符号的含义,你就能清楚地知道启动代码到底把哪些数据从哪里搬到了哪里。

7.3 启动时间的测量与优化

有些低功耗项目对启动时间有要求。测量启动时间的方法很简单:在main的第一行翻转一个GPIO,用示波器看从上电到GPIO翻转的时间。

如果启动时间过长,可以从这几个方面优化:

  • 减少SystemInit中的等待时间(比如HSI启动比HSE快)
  • 关闭不必要的初始化
  • 把一些初始化推迟到main之后按需执行
  • 检查是否有大的.data段需要搬移

但要注意,优化启动时间不能以牺牲稳定性为代价。我见过为了省几毫秒而跳过时钟稳定等待时间的做法,结果在低温环境下系统根本起不来。

7.4 一个关于启动文件的常见误区

最后说一个我经常看到的问题:有人觉得启动文件是"系统文件",不应该去动它。这个观念在大多数情况下是对的,但如果你不理解它,就永远无法真正掌握STM32的启动过程。

我的建议是:至少完整读一遍你所用芯片的启动文件,把每一行都搞懂。遇到不认识的汇编指令就查ARM的指令集手册。这个过程可能花你半天时间,但收获是巨大的。之后遇到任何启动相关的问题,你都能有一个清晰的排查思路,而不是靠猜。

启动流程是嵌入式系统的"第一性原理"之一。把它搞透了,很多上层的问题都会变得有迹可循。我在实际项目中越来越体会到,底层知识不是"用不到",而是"用到了但你不知道"。希望这篇拆解能帮你把这块知识补上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询