本文以ARM Cortex-M系列单片机为例,系统性地阐述其从复位到执行main函数的完整启动过程,涵盖了零地址映射、栈与程序计数器初始化、C语言环境建立以及分散加载机制等核心概念。

一、启动方式选择
单片机上电时,通常允许通过控制一个或多个BOOT引脚来选择程序的启动源。对于ARM芯片而言,启动方式决定了CPU从哪个存储区域获取第一条指令,常见的选项包括内置的自举程序区(系统存储区)、用户Flash区或RAM区。
例如,下图展示了一种典型的启动配置方式:

图中的“启动程序存储器”即指芯片出厂预置的自举程序区。该程序通常由芯片厂商编写并固化,提供了通过串口等简易接口烧录用户程序的功能。若没有此自举程序,对空白芯片进行编程则往往需要依赖ST-LINK之类的专用调试器。
二、零地址映射机制
ARM处理器内核设计为总是从地址0x00000000开始取指执行。为了兼容通过BOOT引脚选择不同启动源的需求,芯片厂商会在硬件层面将不同物理存储器(如主Flash、系统存储器或RAM)的起始地址映射到零地址。这种机制确保了无论选择哪种启动方式,CPU都能从地址0处获取到正确的初始指令。
以下是一个示例芯片的Flash内存映射图:

从图中可以看出:
- 用户Flash区:地址范围为
0x0800_0000 ~ 0x0807_FFFF,用于存储用户编写的应用程序代码。
- 系统存储器:地址范围为
0x1FFF_F000 ~ 0x1FFF_7FFF,即存放厂商自举程序的2KB区域。
- 选项字节:地址范围为
0x1FFF_F800 ~ 0x1FFF_F80F,用于配置芯片的特定功能。
当BOOT引脚配置为从主Flash启动(通常为默认方式)时,地址0和0x08000000均指向同一物理存储单元,即用户程序区的起始位置。
三、栈指针与程序计数器的初始化
上电复位后,ARM Cortex-M内核需要立即确定栈空间和第一条指令的地址,这对应于初始化栈指针寄存器(SP, R13)和程序计数器寄存器(PC, R15)。内核硬件设计为自动从零地址映射区域的前8个字节读取这两个初始值:
- 从地址
0处读取的32位数据作为SP的初始值(栈顶地址)。
- 从地址
4处读取的32位数据作为PC的初始值(复位处理函数的入口地址)。
这就要求,编译生成的可执行文件(如.bin或.hex文件)的最开头必须依次存放着正确的栈顶指针值和复位向量。芯片厂商提供的启动文件(Startup File)已经为我们安排好了这一切。启动文件中的关键片段如下:
AREA RESET, DATA, READONLY
EXPORT __Vectors
EXPORT __Vectors_End
EXPORT __Vectors_Size
__Vectors DCD __initial_sp ; 栈顶指针
DCD Reset_Handler ; 复位处理函数
这段代码定义了一个名为RESET的只读数据段,其开头直接放置了__initial_sp(栈顶符号)和Reset_Handler(复位函数)的地址。其中__initial_sp在栈段末尾定义:
Stack_Size EQU 0x00000400
AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp
通过这种方式,芯片上电瞬间就能自动获得运行所需的栈空间和第一个执行函数地址,从而顺利启动。
四、进入main函数
由上节可知,系统执行的第一条C语言级指令位于Reset_Handler函数中。该函数通常在启动文件中以下述汇编形式实现:
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT __main
IMPORT SystemInit
LDR R0, =SystemInit
BLX R0
LDR R0, =__main
BX R0
ENDP
-
函数调用:
SystemInit:一个用于初始化系统基础环境(尤其是时钟系统)的函数,用户可以根据需要修改其实现。
__main:由编译器提供的库函数,负责初始化C语言运行环境(如搬运已初始化数据到RAM、清零ZI段等),并最终调用用户的main()函数。
-
关键指令解析:
LDR R0, =SystemInit:这是一条伪指令,汇编器会将其转换为合适的指令,以将SystemInit函数的地址加载到寄存器R0中。
BLX R0:带链接和状态切换的跳转指令。用于调用函数,跳转前会将返回地址保存到LR寄存器,并根据目标地址的最低比特位切换Thumb/ARM状态。
BX R0:带状态切换的跳转指令,此处用于跳转到__main库函数执行,不再返回。
完成这些步骤后,程序的控制权便交给了C语言环境,并最终进入用户编写的main()函数。
五、分散加载机制详解
__main函数所完成的C环境初始化工作中,核心部分之一是“分散加载”。该机制负责将存储在Flash中的已初始化全局变量数据(.data段)拷贝到RAM中的指定位置,并将未初始化全局变量所在区域(.bss段)清零。这一切行为都由链接脚本(Linker Script)控制。
1. 分散加载文件分析
链接脚本在不同开发工具中后缀名不同(MDK: .sct, IAR: .icf, GCC: .ld),但功能相似。以下是一个MDK工程中典型的分散加载文件(.sct)示例:
LR_IROM1 0x08000000 0x00100000 { ; 加载域:起始于Flash的0x08000000,大小1MB
ER_IROM1 0x08000000 0x00100000 { ; 执行域1:RO段(代码、常量)在此执行
*.o (RESET, +First) ; 将所有目标文件的RESET段放在最前面
*(InRoot$$Sections) ; 编译器用于分散加载的特殊段
.ANY (+RO) ; 收集所有只读(RO)属性段
.ANY (+XO) ; 收集所有只执行(XO)属性段
}
RW_IRAM1 0x20000000 0x00030000 { ; 执行域2:RW段(全局变量)在RAM中执行
.ANY (+RW +ZI) ; 收集所有读写(RW)和零初始化(ZI)属性段
}
}
脚本解析:
- 加载域:定义了程序映像在Flash中的存储布局。
- 执行域:定义了程序运行时各段在内存中的地址。对于RW/ZI段,其“加载地址”(在Flash中)和“执行地址”(在RAM中)是不同的,这正是分散加载代码需要搬运的数据。
- 通配符:
.ANY用于收集未被前面更具体规则匹配到的段,优先级低于*。
开发者可以通过__attribute__((section("段名")))将函数或变量分配到自定义段,并在链接脚本中精确控制其位置。例如,将特定目标文件的代码链接到备用Flash区域:
ER_IROM1 0x08000000 0x00100000 {
*.o (RESET, +First)
*(InRoot$$Sections)
.ANY (+RO)
}
ER_IROM2 0x08100000 0x00020000 { ; 新增一个执行域
file1.o (+RO) ; file1.o的所有RO段放在这里
file2.o (my_section) ; file2.o中名为my_section的段放在这里
}
编译后生成的MAP文件可以清晰地展示各段的加载地址(Load Addr)和执行地址(Exec Addr),验证链接脚本的效果。理解内存映射和链接过程,是进行底层系统优化和调试复杂问题的基础。
2. 程序的可重定位性
默认情况下,编译器生成的代码中的绝对地址是固定的。例如,一个被链接到0x0800A000运行的函数,如果将其二进制代码直接拷贝到0x0800C000的位置,它将无法正确执行,因为内部的地址引用并未随之改变。
为了增强灵活性,一些编译器(如ARM Compiler)提供了让代码段(RO)和数据段(RW)位置无关的选项:
- Make RO Sections Position Independent (ROPI):使代码段使用PC相对寻址,从而可以在Flash的任何位置运行。
- Make RW Sections Position Independent (RWPI):使数据段通过一个基址寄存器(如R9)进行访问,从而可以动态加载到RAM的任何区域。
启用这些功能通常会牺牲少量性能,并可能需要程序在运行时动态设置中断向量表的位置。对于大多数只需要在固定Flash地址运行的应用,通常无需开启这些选项。理解程序的可重定位性原理,对于学习更复杂的嵌入式系统启动引导程序(如Bootloader)至关重要。
总结
ARM Cortex-M单片机的完整启动流程可以归纳为以下几个关键步骤:
- 硬件映射:根据BOOT引脚状态,由芯片硬件将对应的物理存储器(用户Flash、系统存储器或RAM)映射到地址
0。
- 内核初始化:Cortex-M内核从地址
0和4分别自动加载初始SP和PC值。
- 执行复位处理程序:跳转到
Reset_Handler函数,依次调用SystemInit进行系统初始化(如时钟配置)。
- C环境建立:调用编译器提供的
__main函数,执行分散加载(搬运.data段,清零.bss段),初始化C/C++运行时库。
- 用户程序入口:最终跳转至用户的
main()函数,启动过程结束,应用程序开始运行。
透彻理解这一启动链条,有助于开发者在进行嵌入式开发时,更好地处理内存布局、优化启动速度,并诊断各种与启动相关的疑难问题。