在嵌入式开发中,启动文件 (例如startup_stm32f407xx.s)是单片机系统运行的基础。它负责在单片机上电或复位后,完成最初的硬件和软件环境初始化 ,包括设置堆栈、建立中断向量表、跳转到主程序入口等关键步骤。
下面我将以startup_stm32f407xx.s为例子,逐段介绍该启动文件 ,最后再梳理一下单片机从上电到执行main()函数的整个过程。相信在看完本篇博客后,你对单片机的启动会有一个比较深刻的理解,同时当单片机调试的时候卡死在了汇编.s文件里面,你也能大致知道是什么问题了!
一、堆栈的配置
堆和栈都是用来管理程序运行时的内存空间。
栈 : 用于存放函数调用的局部变量、参数和返回地址 ,每次进入函数自动分配空间,退出自动释放,空间较小但访问速度快,适合临时数据和递归调用。在单个任务/线程的局部变量多、递归深度大时,栈需要设置得较大。
堆 :用于动态分配内存 (如malloc/new),适合存放生命周期较长或大小不确定的数据,空间较大但分配和释放速度较慢,需要手动管理。当需要频繁动态分配大量数据(如动态数组、链表、任务控制块等)时,堆需要设置得较大。
本节用到的汇编代码:
EQU
(equal):等于。
AREA
:是ARM汇编的伪指令,定义一个内存区域,不实际分配空间。
SPACE
:在上个AREA定义的区域内,分配指定大小的空间。
EXPORT
:导出为全局符号,让链接器和其他文件都能访问它们。
标签
:用来标记某个内存地址或代码位置的符号。标签本质上就是一个名字,代表当前位置的地址,方便后续引用或跳转。
1.1 栈区域定义

这段代码完成了栈的配置:
EQU
是"等于"的意思,用于定义常量。Stack_Size EQU 0x400
意思是 Stack_Size 这个符号的值等于 0x400(1024字节)AREA STACK, NOINIT, READWRITE, ALIGN=3
定义一个名为STACK
的内存区域,属性为无初始化(NOINIT)、可读写(READWRITE)、8字节对齐(ALIGN=3,即2³=8)Stack_Mem SPACE Stack_Size
在当前区域内分配指定大小的空间,Stack_Mem
是这块空间的起始地址标签__initial_sp
是一个标签,标记堆栈顶部地址,CPU复位时会用它初始化堆栈指针
1.2 堆区域定义

堆区域的配置与栈类似,但增加了起始和结束标签(__heap_base和__heap_limit),便于C库进行动态内存管理。
1.3 其他配置

这两个配置确保了代码在Cortex-M4处理器上的正确运行。
二、中断向量表的构建
中断向量表 ,是 Cortex-M4 启动时最重要的数据结构之一,位于程序存储器的起始地址 (通常是0x00000000)。每一项都是一个32位的地址,告诉CPU发生某种异常或中断时应该跳转到哪个处理函数。
单片机上电后,一般默认从中断向量表开始执行,并且把表内第一个值赋值给SP寄存器,第二个值赋值给PC寄存器。
本节用到的汇编代码:
DCD
(Define Constant Doubleword):定义32位常量。
EXPORT
:导出为全局符号,让链接器和其他文件都能访问它们。
2.1 向量表区域声明

AREA RESET, DATA, READONLY 定义了名字为RESET区域,是数据段,只读,之后分配的空间就属于这个区域。再次申明,AREA 不会实际分配地址,只是给某一片区域起一个名字。
然后用export导出。EXPORT
指令将这些符号导出为全局符号,让链接器和其他模块可以访问。
2.2 系统异常向量表(前16项)

DCD
(Define Constant Doubleword)定义32位常量,每一项都是一个中断处理函数的地址。前16项是ARM Cortex-M内核规定的系统异常,包括:
- 第0项(__initial_sp):初始堆栈指针,CPU复位时自动加载到SP寄存器,而__initial_sp正好是栈顶指针。
- 第1项(Reset_Handler):复位处理函数,CPU复位时自动加载到PC寄存器,因此接下来会跳转到Reset_Handler捏~
- 第2-15项:各种系统异常的处理函数
特别注意: 在32位单片机中,双字应该是64位,但这里DCD定义的双字却是32位常量,这是历史遗留问题,这边困惑了我好久来着,但是网上是这么说的。除此之外还有DCB(Define Constant Byte):定义8位常量。
DCW(Define Constant Word):定义16位常量。
2.3 外部中断向量表

外部中断向量表定义了STM32F407xx特有的82个外设中断,涵盖了定时器、通信接口、DMA、USB等所有外设的中断处理入口。
很多初学者会疑问:为什么向量表中只是登记了地址,CPU就能跳转执行,正常不应该是PC=PC+4吗?这是因为中断响应是CPU的硬件行为。当中断发生时,CPU会自动查找中断向量表,获取对应的处理函数地址,然后硬件自动将PC(程序计数器)设置为该地址,开始执行中断处理代码。这个过程不需要软件指令参与,完全由硬件完成。
三、复位处理函数:系统启动的核心
复位处理函数 是系统启动后执行的第一段用户代码,负责完成从硬件复位到跳转至C程序的整个过程。
本节用到的汇编代码:
PROC/ENDP
:定义一个汇编过程(函数),类似C语言的函数定义。
EXPORT [WEAK]
:将符号导出为弱符号,允许用户在其他文件里面重新定义。
IMPORT
:声明外部符号,告诉链接器这些函数在其他文件中定义。
LDR R0, =symbol
(Load Register):将symbol的地址加载到寄存器R0。
BLX
(Branch with Link and Exchange)和BX
(Branch and Exchange):BLX会保存返回地址到LR寄存器,用于函数调用;BX只是跳转,不保存返回地址。
3.1复位处理函数的完整实现:

执行流程如下:
- PROC/ENDP:定义一个汇编过程(函数),名字叫Reset_Handler,这是复位中断的入口函数
- EXPORT [WEAK]:把Reset_Handler导出为全局符号,链接器可以找到它。[WEAK]表示弱符号,用户可以在其他文件中重新定义它
- IMPORT:声明外部符号,告诉汇编器和链接器,这些符号在其他文件里定义
- LDR R0, =SystemInit:把SystemInit的地址加载到寄存器R0。= 表示取SystemInit的地址
- BLX R0:跳转并调用SystemInit,同时保存返回地址到LR寄存器,完成系统初始化
- BX R0:跳转到__main,不保存返回地址(因为不会返回),进入C库启动流程
注意:__main
不是用户的main函数,而是C库的启动入口,这个由编译器和标准库实现,用户一般是看不到代码的,也无需关心。它负责初始化C运行环境(如全局变量、BSS段清零等),然后自动调用用户的main()函数。双下划线是编译器约定,表示这是一个特殊的、内部使用的符号。
3.2.SystemInit()的实现

LDR R0, =SystemInit会自动调用system_stm32f4xx.c里面的SystemInit(),这个函数主要干了三件事:
1.FPU(浮点运算单元)设置
如果芯片带有 FPU 并且启用了 FPU(由 __FPU_PRESENT
和 __FPU_USED
两个宏控制),SystemInit
会通过设置 SCB->CPACR
寄存器,让协处理器 CP10 和 CP11(即 FPU)拥有完全访问权限。这样后续 C 代码里的浮点运算就能直接用硬件加速,提高运算效率。
#if (__FPU_PRESENT == 1) && (__FPU_USED == 1)
SCB->CPACR |= ((3UL << 10 * 2) | (3UL << 11 * 2)); /* set CP10 and CP11 Full Access */
#endif
2.外部存储器初始化(可选)
如果你定义了 DATA_IN_ExtSRAM
或 DATA_IN_ExtSDRAM
,说明你的板子上接了外部 SRAM 或 SDRAM 芯片。此时会调用 SystemInit_ExtMemCtl()
,初始化外部存储器控制器(FMC/FSMC),配置相关引脚和时序,让外部 RAM 可用。这样可以突破片内 RAM 的限制,把堆、栈、全局变量等分配到外部存储器,适合需要大量内存的应用场景。
#if defined (DATA_IN_ExtSRAM) || defined (DATA_IN_ExtSDRAM)
SystemInit_ExtMemCtl();
#endif
3.中断向量表重定位(可选)
默认情况下,中断向量表位于 Flash 的起始地址。如果你定义了 USER_VECT_TAB_ADDRESS
,则会通过设置 SCB->VTOR
寄存器,把中断向量表重定位到 SRAM 或其他指定位置。这在 Bootloader、IAP(在线升级)、多系统切换等场景下非常有用,可以灵活切换中断入口。
#if defined(USER_VECT_TAB_ADDRESS)
SCB->VTOR = VECT_TAB_BASE_ADDRESS | VECT_TAB_OFFSET;
#endif
四、默认中断处理函数的设计
启动文件为所有中断都提供了默认的处理机制,既保证了系统的稳定性,又为用户提供了灵活的定制空间。
本节用到的汇编代码:
B .
:跳转指令,.
代表当前指令的地址,因此就是个死循环。
\
:表示当前行与下一行是连续的,应该被当作一行来处理。
弱符号:允许用户在自己的代码中重新定义,编译器会优先使用用户定义的版本。
4.1 系统异常的独立处理

系统异常采用独立定义的方式,每个异常都有自己的处理函数。这是占位符式的异常处理函数,提供最基本的"陷入死循环"功能。B .
指令创建了一个无限循环:
B
是跳转指令.
代表当前指令的地址- 因此
B .
就是"跳转到自己",形成死循环
这种设计的目的是:
- 防止程序跑飞:异常发生时程序停在已知位置
- 便于调试:调试器可以准确定位异常类型
- 允许重定义:用户可以实现自己的异常处理逻辑
4.2 外设中断的统一处理机制

...省略类似代码

...省略类似代码

外设中断采用了巧妙的多对一设计,所有这些中断处理函数都指向同一个Default_Handler,并被导出为弱符号,所有未实现的中断都执行同一个死循环。
这种设计既节省了代码空间,又保持了完全的可定制性。前面在中断向量表里面已经定义了入口地址,在发生中断后,CPU自动通过中断向量表跳到对应的地址,如果重写了函数就开始执行,不然会进这里的死循环。
五、内存管理的兼容性设计
启动文件通过条件编译支持不同的C库,因为不同的库需要有不同的堆栈初始化过程。
本节用到的汇编代码:
IF :DEF:__MICROLIB / ELSE / ENDIF
:条件编译结构,用于适配不同的C库。
LDR R0, =address
:将地址加载到寄存器R0。
BX LR
:返回到调用者,相当于C语言中的return。
条件编译结构:

如果定义了 __MICROLIB(轻量级库),则直接导出 __initial_sp(栈顶)、__heap_base(堆起始)、__heap_limit(堆结束)这几个符号,供库使用。
如果使用标准库,则导入 __use_two_region_memory(告诉链接器采用两段式内存管理),并导出 __user_initial_stackheap。标准库会调用 __user_initial_stackheap,该函数通过寄存器返回堆和栈的起始与结束地址(R0~R3),用于C库初始化内存分区。
最后用 ALIGN 保证区域对齐,ENDIF 结束条件编译。
六、启动流程的完整梳理
综上所属,整个启动过程可分为如下几个步骤:
1.硬件复位:单片机上电或复位后,CPU自动从向量表的第0项开始执行,即标记为__Vectors的地址。

2.堆栈指针初始化:CPU读取向量表第0项(__initial_sp,即Stack_Mem + Stack_Size,也就是栈空间的最高地址(顶端)),并自动将其设置SP寄存器,这是硬件完成的

3.程序计数器设置:CPU读取向量表第1项(Reset_Handler),设置PC寄存器指向Reset_Handler,因此后面会直接运行Reset_Handler这个过程

4.系统初始化:由于CP跳转到了指向Reset_Handler的地址,接下来会运行Reset_Handler,在这里面执行SystemInit和__main,__main最后跳入用户定义的main()函数。

SystemInit位于system_stm32f4xx.c里面,主要是完成系统级硬件初始化,包括 FPU(浮点运算单元)权限设置、外部存储器(SRAM/SDRAM)初始化(可选)、中断向量表重定位(可选),为后续 C 语言环境和主程序运行做好准备。

__main函数是 C 库的入口,由编译器和标准库实现。__main 会负责初始化 C 运行环境(比如堆栈、全局变量、C库等)。
5.应用程序启动:__main最终会调用用户的main()函数,随后运行你的逻辑。
七、实际应用中的考虑因素
7.1 内存配置的优化
在实际项目中,需要根据应用需求调整内存配置:
; 根据应用需求调整堆栈大小
Stack_Size EQU 0x800
Heap_Size EQU 0x1000
内存配置建议:
- 简单应用:堆栈1KB,堆512字节即可
- 中等复杂度:堆栈2-4KB,堆1-2KB
- 复杂应用:根据实际需求动态调整,可能需要8KB以上
7.2 中断处理的实现
用户可以在任意地方重新定义任意中断处理函数,只要被编译器编译了就行。编译器会自动使用用户定义的版本:
// 在C文件中实现具体的中断处理,给出两个例子
void WWDG_IRQHandler(void) {
// 处理窗口看门狗中断
// 清除中断标志
// 执行相应的处理逻辑
}
void HardFault_Handler(void) {
// 保存错误信息
// 尝试恢复或安全关闭
// 重启系统
while(1); // 最后的安全措施
}