本文详细解析 STM32F10x 工程的启动文件 startup_stm32f10x_hd.s 中的汇编代码,无论你使用哪个工程,都能找到它的身影。理解启动文件,是深入掌握 STM32 运行机制的必经之路。
启动文件使用的ARM汇编指令汇总
先来看一张常用汇编指令表,方便查阅:

Stack——栈
栈的初始化代码如下:
Stack_Size EQU 0x00000400
AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp
这段代码开辟了大小为 0x00000400(1KB)的栈空间,段名为 STACK。属性 NOINIT 表示不进行初始化,READWRITE 说明可读可写,ALIGN=3 则是按 8(即 2^3)字节对齐。
栈主要用于局部变量、函数调用时的参数传递等开销。要注意栈的大小不能超过内部 SRAM 的总容量。
如果你的程序规模很大,定义了非常多的局部变量,就得适当调大 Stack_Size。你是否遇到过这样的情况:程序跑着跑着莫名奇妙地进入了硬错误(HardFault)?那时候,就该怀疑是不是栈空间不足,溢出了。
下面对涉及的指令做个简要说明:
EQU:宏定义伪指令,功能类似于 C 语言中的 #define。
AREA:告诉汇编器开始一个新的代码段或数据段。后面的 STACK 是段名,可自定义;NOINIT、READWRITE 是属性;ALIGN=3 表示 8 字节对齐。
SPACE:分配指定字节大小的内存空间,这里分配的大小等于 Stack_Size。
__initial_sp 标号紧挨着 SPACE 语句,它代表栈的结束地址,也就是栈顶。栈的生长方向是由高地址向低地址。
Heap——堆

堆的初始化与栈类似,它开辟了 0x00000200(512 字节)的空间,段名为 HEAP,同样不初始化、可读可写且按 8 字节对齐。其中 __heap_base 指向堆的起始地址,__heap_limit 则表示堆的结束地址。
堆的用处是动态内存分配,像 C 语言中 malloc() 申请的内存就是从堆上来的。不过,在 STM32 这种资源有限的单片机开发中,动态分配用得相对较少。注意堆是由低地址向高地址生长的,这一点和栈正好相反。
接着看两条指令:
PRESERVE8
THUMB
PRESERVE8:指定当前文件中的堆栈需按 8 字节对齐。
THUMB:声明随后的指令兼容 THUMB 指令集。早期的 THUMB 是 16 位指令集,如今 Cortex-M 系列核心都采用 32 位的 THUMB-2 指令集,它兼容 16 位和 32 位指令,可以看作是 THUMB 的超集。(关于堆栈的深入讲解,可参考此文:关于C语言堆栈的经典讲解)
向量表
接下来定义向量表所用的数据段:
AREA RESET, DATA, READONLY
EXPORT __Vectors
EXPORT __Vectors_End
EXPORT __Vectors_Size
这里定义了一个名为 RESET 的只读数据段,并通过 EXPORT 声明了 __Vectors、__Vectors_End、__Vectors_Size 三个全局标号,便于外部文件引用。
EXPORT:可将一个标号声明为全局属性,供其他文件调用。如果换作 IAR 编译器,对应的指令则是 GLOBAL。
当内核响应某个异常时,会执行对应的异常服务例程(ESR)。为了找到 ESR 的入口地址,内核采用“向量表查表机制”。
向量表本质上是一个 32 位整型(WORD)数组,每个下标对应一种异常,该下标位置存放的值就是对应 ESR 的入口地址。向量表在地址空间中的位置可通过 NVIC 内的重定位寄存器来设定,复位后,该寄存器值为 0。所以,在地址 0(即 Flash 起始处)必须放置一张向量表,用于初始的异常分配。
这里有一点很特别:向量表的 0 号位置存放的不是入口地址,而是复位后主堆栈指针(MSP)的初始值。下面是 F103 系列中断向量表的截图:

向量表的具体定义代码如下:
__Vectors DCD __initial_sp ; 栈顶地址
DCD Reset_Handler ; 复位程序地址
DCD NMI_Handler
DCD HardFault_Handler
DCD MemManage_Handler
DCD BusFault_Handler
DCD UsageFault_Handler
DCD 0 ; 0 表示保留
DCD 0
DCD 0
DCD 0
DCD SVC_Handler
DCD DebugMon_Handler
DCD 0
DCD PendSV_Handler
DCD SysTick_Handler
; 外部中断开始
DCD WWDG_IRQHandler
DCD PVD_IRQHandler
DCD TAMPER_IRQHandler
; 限于篇幅,中间代码省略
DCD DMA2_Channel2_IRQHandler
DCD DMA2_Channel3_IRQHandler
DCD DMA2_Channel4_5_IRQHandler
__Vectors_End
__Vectors_Size EQU __Vectors_End - __Vectors
__Vectors 是向量表起始地址,__Vectors_End 是结束地址,两者相减即可算出整个向量表的大小。
这张表从 Flash 的 0 地址开始,每个向量占 4 个字节。地址 0 处放栈顶地址,0x04 处放复位程序地址,以此类推。从代码中可以看到,向量表里存储的都是中断服务函数的名字,而 C 语言的函数名本质上就是一个地址。
DCD:分配一个或多个以字为单位的内存(4 字节对齐),并用指定的值进行初始化。在这里,DCD 就是分配内存并用各 ESR 的入口地址来初始化。
复位程序
复位程序的代码段定义如下:
AREA |.text|, CODE, READONLY
这行代码定义了一个名为 .text 的只读代码段。其主体部分如下图所示:

复位子程序是系统上电后最先执行的代码,它先调用 SystemInit 来初始化系统时钟,再调用 C 库函数 __main,最终引导程序进入 C 语言世界里的 main 函数。
对其中用到的指令和标号:
WEAK:表示弱定义。如果外部文件已定义同名标号,则优先使用外部的;如果外部没定义,也不报错。这表明复位子程序完全可以由用户在其他文件中重新实现,这里提供的只是一个默认版本。
IMPORT:声明标号来自外部文件,作用类似于 C 的 extern。这里的 SystemInit 和 __main 都来自外部。
SystemInit() 是一个标准库函数,定义在 system_stm32f10x.c 文件中,主要负责配置系统时钟。调用它之后,STM32 的主频一般就被配成了 72MHz。
__main 是标准的 C 库函数,主要任务就是初始化用户堆栈,并在最后拉起 main 函数。这也就解释了为什么我们写的程序都必须有一个 main 函数。
LDR、BLX、BX 都是 CM4 内核的指令,你可以在《CM3 权威指南 CnR2》的指令集章节查到。下图汇总了它们的作用:

中断服务程序
启动文件已经替我们写好了所有中断对应的服务函数。不同之处在于,这些函数体都是空的,真正的服务逻辑需要我们自己在外部 C 文件里重新实现,这里的空函数仅仅是为了占个位置。
如果在使用某个外设时使能了中断,却忘了编写配套的中断服务函数,或者把函数名写错了,一旦中断到来,程序就会跳进启动文件里预先定义好的空函数,并陷入死循环——说白了,程序就卡死在这里了。
NMI_Handler PROC
EXPORT NMI_Handler [WEAK]
B .
ENDP
; 限于篇幅,中间代码省略
SysTick_Handler PROC
EXPORT SysTick_Handler [WEAK]
B .
ENDP
Default_Handler PROC
EXPORT WWDG_IRQHandler [WEAK]
EXPORT PVD_IRQHandler [WEAK]
EXPORT TAMP_STAMP_IRQHandler [WEAK]
; 限于篇幅,中间代码省略
LTDC_IRQHandler
LTDC_ER_IRQHandler
DMA2D_IRQHandler
B .
ENDP
B:跳转到指定标号。此处的 B . 就是跳转到一个点号,也就是当前地址,从而形成一个死循环。
用户堆栈初始化
先看一行对齐指令:
ALIGN
ALIGN:让后续指令或数据的存放地址对齐。它后面通常会跟一个立即数,若省略则默认按 4 字节对齐。需要注意,这是一个编译器指令,并非 ARM 的标准汇编指令。在计算机基础的底层概念中,结构对齐和内存对齐是保障硬件高效访问的关键机制。
初始化用户堆栈的完整逻辑:
; 用户栈和堆初始化, 由 C 库函数 __main 来完成
IF :DEF:__MICROLIB
EXPORT __initial_sp
EXPORT __heap_base
EXPORT __heap_limit
ELSE
IMPORT __use_two_region_memory
EXPORT __user_initial_stackheap
__user_initial_stackheap
LDR R0, = Heap_Mem
LDR R1, =(Stack_Mem + Stack_Size)
LDR R2, = (Heap_Mem + Heap_Size)
LDR R3, = Stack_Mem
BX LR
ALIGN
ENDIF
END
这段代码首先会检查是否定义了 __MICROLIB 这个宏。
- 如果定义了,则把
__initial_sp(栈顶地址)、__heap_base(堆起始地址)、__heap_limit(堆结束地址)声明为全局,让 C 库函数 __main 来完成堆栈初始化。
- 如果没有定义,就采用双段存储器模式,并声明
__user_initial_stackheap 为全局属性,让用户自行完成堆栈的初始化。
__MICROLIB 是 Keil MDK-ARM 中的一个专用简版 C 库,开启位置如下图所示:

有关此段汇编代码,还需留意:
IF, ELSE, ENDIF:汇编中的条件分支语句,逻辑类似于 C 语言的 if...else。
END:文件结束标记。
至此,STM32 启动文件的骨架已梳理完毕。当你下次遇到上电不跑、中断卡死这些奇怪症状时,不妨回过头来检查一下这些最底层的初始化代码,也许问题的答案就藏在某一个不起眼的标号或内存分配里。