无标题
Cortex-M 从上电到运行:启动、汇编与链接脚本入门
本文面向刚接触 Cortex-M 固件的开发者,介绍处理器从复位到运行 C 程序的大致过程、常见汇编和 GNU 链接脚本语法,以及内存段和启动代码之间的关系。示例以本仓库的 MCU1 工程为准;不同芯片、Bootloader 和工具链的具体细节可能不同。
相关文件:
app/Startup.S:复位处理函数和早期初始化。app/exceptions.S:异常/中断处理函数和向量表。app/mcu1.ld:MCU1 链接脚本和内存布局。build/config.mk:MCU1 映像加载地址和入口地址。app/main.c:应用 C 入口。
1. 先建立整体模型
可以把启动理解为“找到入口、准备运行环境、进入应用”:
上电或复位 |
Cortex-M 处理器本身不认识 main() 这个 C 语言惯例。处理器只按硬件规则读取地址和执行机器指令。启动代码的任务是先准备好 C 程序依赖的环境。
1.1 几个容易混淆的地址
- 映像加载地址:固件字节被放置到内存的地址。
- 向量表地址:处理器查找初始 MSP、Reset Handler 和中断入口的表格起始地址。
- Reset Handler 地址:向量表第 1 项指向的启动代码地址。
- ELF 入口符号:链接器记录在 ELF 元数据中的入口;它不一定等同于 Bootloader 使用的向量表地址。
这些地址在不同启动方案中可能相同,也可能不同。务必查看芯片启动手册和 Bootloader 的约定。
2. 处理器复位后如何找到代码
2.1 向量表
Cortex-M 向量表是内存中的一组 32 位表项,通常按以下顺序开始:
| 表项 | 内容 |
|---|---|
| 第 0 项 | 初始主栈指针 MSP |
| 第 1 项 | Reset Handler 地址 |
| 第 2 项 | NMI 处理函数地址 |
| 第 3 项 | HardFault 处理函数地址 |
| 后续项 | 其他系统异常和外部中断处理函数 |
复位时,处理器从当前向量表基地址读取前两项:
向量表[0] → MSP |
所以向量表必须放在 Boot ROM、Bootloader 或处理器当前配置所期待的位置,表中前两项也必须正确。
2.2 本项目的 MCU1 启动方式
本项目 MCU1 映像加载地址为 0x01040000,入口配置也为 0x01040000,见 build/config.mk。这里的 MCU1 ENTRY_ADDR 是向量表地址,不是 Reset Handler 函数地址:Bootloader 将该地址配置为 MCU1 的向量表位置,处理器再从向量表读出 MSP 和复位入口。
本项目的向量表位于 app/exceptions.S:
CORTEX_MX_VECTOR_TBL: |
CORTEX_MX_VECTOR_TBL 是表的起始符号。第一项由链接脚本符号 __main_stack__ 提供;第二项指向 BCM_RESET_HANDLER。
本项目还在启动代码中把向量表地址写入 VTOR。对于支持并使用 VTOR 的 Cortex-M,这可以显式指定后续异常和中断向量表位置。具体哪些阶段负责设置 VTOR,要以芯片和 Bootloader 约定为准。
3. Reset Handler 的主要工作
本项目的复位处理函数是 BCM_RESET_HANDLER。它大致执行以下步骤。
3.1 关闭 MPU
LDR r0, =CORTEX_MPU_CTRL |
将 0 写入 MPU 控制寄存器,暂时关闭 MPU。应用之后可以按自己的内存属性重新配置它。
3.2 设置 VTOR
LDR r0, =CORTEX_MX_VECTOR_TBL |
把向量表地址写入 SCB 的 VTOR 寄存器。处理器随后使用这张表查找异常和中断处理入口。
3.3 设置主栈 MSP
LDR r0, =__main_stack__ |
__main_stack__ 是链接脚本定义的地址符号。MSR 将该地址写入 MSP(Main Stack Pointer,主栈指针)。
栈通常从高地址向低地址增长。函数调用、局部变量和异常现场保存都需要有效栈,因此在进入 C 代码前必须先设置好栈。
3.4 初始化 .data
LDR r0, =__data_load__ /* 源地址:初始值映像的位置 */ |
.data 保存有初始值的全局变量和静态变量,例如:
int retry_count = 3; |
常见布局是初始值存放在 Flash(LMA),运行时变量放在 RAM(VMA),启动代码负责从 Flash 复制到 RAM。
本项目的 app/mcu1.ld 将 .data 收进 .text 映像,并将 __data_load__ 和 __data_start__ 放在同一个位置。因此在当前布局下,这个循环是对同一位置的自拷贝;这与常见“Flash 到 RAM”的复制情况不同。
3.5 清零 .bss
LDR r0, =__bss_start__ |
.bss 保存没有显式初始值的全局变量和静态变量,例如:
static int ready; |
C 语言要求此类变量在程序开始时为零。启动代码从 __bss_start__ 写到 __bss_end__,每次写入 4 字节的 0。起始地址和长度应满足循环所用的 4 字节对齐条件;本项目由链接脚本对边界进行对齐。
这段循环清零的范围只由 __bss_start__ 和 __bss_end__ 决定。启动汇编中虽然还声明了 __privileged_bss_start__ 和 __privileged_bss_end__,但当前复位处理函数没有使用它们。
3.6 配置故障检测
LDR r0, =CORTEX_CCR |
本项目用 CCR 的 DIV_0_TRP 位启用除零陷阱,并用 SHCSR 的相关位启用 UsageFault、BusFault 和 MemManage fault。
寄存器写入应以芯片和 ARM 架构文档为准。直接写一个常数可能影响同一寄存器的其他可写位;需要保留已有配置时,通常要按寄存器定义做读-改-写。
3.7 调用 C 入口
LDR r0, =BCM8915X_Main |
BLX 调用 BCM8915X_Main,并把返回地址保存到 lr。当前 C 入口进行应用初始化、设置任务并启动 FreeRTOS 调度器。若入口意外返回,启动代码进入无限循环,避免继续执行未知内容。
4. 汇编语法入门
汇编源文件中有两类内容:
- 处理器指令:生成机器码,由 CPU 执行,例如
MOVS、STR、BLX。 - 汇编器指示符:指导汇编器组织代码或生成数据,例如
.section、.global。它们一般不作为 CPU 指令执行。
此外,.S(大写 S)通常会先经过 C 预处理器,所以文件里的 #define 会在汇编器处理前展开。
4.1 目标架构声明
.syntax unified |
| 写法 | 含义 |
|---|---|
.syntax unified |
使用 ARM 统一汇编语法 |
.arch armv7-m |
指定 ARMv7-M 架构 |
.cpu cortex-m7 |
指定 Cortex-M7 目标 CPU |
.code 16 |
选择 Thumb 指令编码(16 位状态;Thumb-2 也包含 32 位编码) |
.text |
切换到代码段 |
Cortex-M 执行 Thumb 指令。.code 16 不是说所有指令恰好都是 16 位;Thumb-2 指令也可能占 32 位。
4.2 寄存器速查
| 寄存器 | 常见用途 |
|---|---|
r0–r3 |
参数、返回值和临时值 |
r4–r11 |
通常由被调用函数按调用约定保存 |
r12 |
临时寄存器 |
sp |
当前栈指针,指向 MSP 或 PSP |
lr |
链接寄存器,常保存函数返回地址 |
pc |
程序计数器 |
MSP |
主栈指针 |
PSP |
进程栈指针 |
常见 ARM 调用约定下,前几个参数通过 r0–r3 传递,整数或指针返回值通常放在 r0。调用者和被调用者对寄存器的保存责任由 ABI(应用二进制接口)约定。
4.3 立即数、符号地址和内存访问
MOVS r1, #0 |
#0 是立即数 0:把数值 0 放入寄存器。
LDR r0, =CORTEX_MPU_CTRL |
LDR Rd, =expression 是 GNU 汇编常见的伪指令,表示把常数或符号地址放进寄存器。汇编器会选择合适的指令或常量池实现它;它本身不表示“从这个地址读内存”。
LDR r0, [r1] |
方括号表示内存访问:从 r1 指向的地址读取一个字到 r0。
STR r0, [r1] |
将 r0 的值写到 r1 指向的地址。
对比:
LDR r0, =0xE000ED94 /* 将地址数值放入 r0 */ |
4.4 地址偏移和自动更新
本项目使用后索引形式:
LDR r3, [r0], #4 |
执行顺序是先用当前地址读写,然后将地址寄存器加 4。这适合逐字复制数据。
另一种常见形式:
LDR r3, [r0, #4]! |
! 表示写回:先用 r0 + 4 访问内存,再将新地址写回 r0。没有 ! 时,基址寄存器通常不变:
LDR r3, [r0, #4] |
4.5 算术、比较与条件分支
CMP r1, r2 |
CMP 做比较并更新条件标志,不会把差值写入普通寄存器。BCC 是“无符号比较小于时跳转”的条件分支。
常见条件分支:
| 指令 | 条件 |
|---|---|
BEQ label |
相等 |
BNE label |
不相等 |
BHI label |
无符号大于 |
BHS / BCS |
无符号大于或等于 |
BLO / BCC |
无符号小于 |
BLT label |
有符号小于 |
BGT label |
有符号大于 |
地址和指针一般按无符号值比较;不要把有符号与无符号条件混用。
4.6 跳转、调用和返回
| 指令 | 作用 |
|---|---|
B label |
无条件跳转,不保存返回地址 |
BL function |
调用函数,把返回地址保存在 lr |
BX rN |
跳到寄存器地址 |
BLX rN |
通过寄存器调用,保存返回地址;也可根据目标地址状态切换指令状态 |
BX lr |
常见函数返回方式 |
4.7 常用汇编器指示符
| 指示符 | 作用 |
|---|---|
.section name |
切换到指定输入段 |
.global symbol |
导出符号,使其他目标文件或链接阶段可见 |
.extern symbol |
声明符号由其他目标文件或链接阶段提供 |
.word value |
在当前位置生成 32 位数据 |
.byte value |
生成 8 位数据 |
.hword value |
生成 16 位数据 |
.set alias, target |
将符号设为另一个符号或表达式的别名 |
.type symbol, %function |
标记符号类型为函数 |
.size symbol, expression |
记录符号大小 |
.weak symbol |
声明弱符号,可由强定义覆盖 |
.balign n |
将当前位置按 n 字节对齐 |
.ltorg |
在当前位置输出待处理的 literal pool |
#define NAME value |
C 预处理宏,不是 CPU 指令 |
本项目示例:
.global BCM_RESET_HANDLER |
.global 导出入口;.set 创建兼容别名;.extern 声明外部 C 符号;.section 将后续内容放入指定输入段。
.word 经常用于向量表,因为表项是 32 位地址或值:
.word __main_stack__ |
4.8 宏和注释
GNU 汇编宏示例:
.macro LOAD_ZERO reg |
汇编器会在汇编阶段展开宏;它不是运行时函数调用。C 预处理器宏则使用 #define,常用于常量或文本替换。
注释可以用 /* ... */ 或 // ...。注释不会生成机器指令。
4.9 其他启动代码中可能遇到的指令
| 指令 | 一般用途 |
|---|---|
MRS r0, MSP |
从特殊寄存器 MSP 读取值 |
MSR MSP, r0 |
将值写入 MSP |
CPSID i |
屏蔽可配置中断 |
CPSIE i |
允许可配置中断 |
DSB |
等待此前显式内存访问完成 |
ISB |
刷新指令流水线,使后续指令按新状态执行 |
WFI |
等待中断 |
SVC #0 |
触发 Supervisor Call 异常 |
NOP |
空操作 |
是否需要这些指令取决于芯片、启动流程和 RTOS 移植层;不要在不了解寄存器和执行顺序时机械添加。
5. 链接脚本入门
编译器把 C 文件编译成目标文件,汇编器把汇编源文件变成目标文件,链接器再把这些目标文件和库组合起来。链接脚本(通常为 .ld)负责指定最终段的地址与布局。
5.1 MEMORY:声明内存区域
本项目 app/mcu1.ld 中的定义包括:
MEMORY |
ORIGIN:区域起始地址。LENGTH:区域长度。r:可读;w:可写;x:可执行。这些属性供链接器检查段的使用是否合理。K、M:表示 KiB、MiB 量级的大小。
例如 SRAM 从 0x01040000 开始,长度 192 KiB(0x30000 字节),结束地址是 0x01070000。通常按“起始地址包含、结束地址不包含”理解:
0x01040000 <= 地址 < 0x01070000 |
5.2 SECTIONS:收集输入段并安排输出段
编译器和汇编器把内容放在不同的输入段中:
| 输入段 | 常见内容 |
|---|---|
.text |
指令代码 |
.rodata |
字符串、只读常量 |
.data |
有初始值的全局变量和静态变量 |
.bss |
未显式初始化的全局变量和静态变量 |
| 自定义段 | 日志缓冲区、共享内存、特定用途数据等 |
链接脚本示例:
.text : |
.text(冒号左侧)是链接输出段名称。- 花括号内是要收集的输入段。
*(.text*)表示从所有输入文件收集名称匹配.text*的段;*匹配文件名或段名中的任意字符串。> SRAM表示将输出段放入SRAM区域。KEEP(...)防止链接器在垃圾回收未引用段时把重要内容删除。
向量表与 Reset Handler 可能没有普通 C 函数调用关系,但它们仍必须保留,因此常用 KEEP。
5.3 .:当前位置计数器
链接脚本里的 . 表示当前链接地址,不是 C 变量或 CPU 寄存器:
. = ALIGN(4); |
将当前位置向上对齐到 4 字节边界。
. = . + 0x100; |
从当前位置向前预留 0x100 字节。
5.4 链接符号:把地址交给启动代码
. = ALIGN(4); |
这里定义了两个地址符号:
__bss_start__:.bss的起始地址。__bss_end__:.bss的结束地址。
它们不是在 RAM 中创建的普通变量。汇编文件可以用 .extern __bss_start__ 声明它们,链接器最终把符号解析为地址。
启动代码和链接脚本必须使用一致的符号名和段范围,否则启动代码可能没有清零或复制正确的区域。
5.5 NOLOAD:只占运行内存,不带初始化载荷
.bss (NOLOAD) : |
NOLOAD 表示该段需要占用运行时地址空间,但不要求在固件载荷中保存每个字节的初始内容。.bss 通常采用这种方式,再由启动代码清零。
栈、堆、运行时缓冲区也经常不需要在映像里带一份初始内容。
5.6 VMA 与 LMA:运行地址和加载地址
- VMA(Virtual Memory Address):程序运行时访问该段的地址。
- LMA(Load Memory Address):该段初始内容在映像中加载或存放的地址。
常见的 Flash + RAM 布局:
.data 的初始值存于 Flash(LMA) |
GNU 链接脚本可以使用 AT > FLASH 等方式指定加载区域。使用这类布局时,要确认固件打包工具包含 .data 的初始值,并且启动代码从正确的 LMA 复制到正确的 VMA。
本项目的布局有所不同:app/mcu1.ld 将 .data 收进 .text 映像,且将加载和运行地址放在一起,因此当前 .data 复制循环为自拷贝。
5.7 常见链接脚本语法速查
| 语法 | 用途 |
|---|---|
ENTRY(symbol) |
设置 ELF 元数据中的入口符号 |
MEMORY { ... } |
声明可用内存区域 |
SECTIONS { ... } |
定义输出段及其内容 |
ORIGIN(region) |
取得区域起始地址 |
LENGTH(region) |
取得区域长度 |
ALIGN(n) |
按 n 字节对齐当前位置 |
KEEP(pattern) |
强制保留匹配的输入段 |
NOLOAD |
段占运行地址空间,但不要求初始化载荷 |
AT > region |
指定输出段的加载内存区域 |
PROVIDE(name = value) |
仅在该符号尚未定义时提供定义 |
ASSERT(condition, "message") |
链接时检查布局条件,不满足则报错 |
GROUP(lib1.a lib2.a) |
将库作为一组供链接器处理 |
本项目使用 ASSERT 检查映像起始地址、JTAG 日志地址和向量表地址,减少 Bootloader 配置与实际链接布局不一致的风险。
6. 内存布局与分配
6.1 常见区域及用途
| 区域 | 常见用途 | 特点 |
|---|---|---|
| Flash / ROM | 代码、只读常量、初始数据映像 | 非易失,断电后保留 |
| SRAM | 运行时变量、缓冲区;某些系统也在此执行代码 | 易失,可读写 |
| TCM | 对实时性敏感的代码、数据或栈 | 靠近内核,访问延迟较稳定 |
| 外部 DRAM | 大容量图像、网络或共享缓冲区 | 容量较大,初始化和访问条件更复杂 |
| MMIO 地址空间 | 外设控制寄存器 | 读写地址会控制硬件,不是普通 RAM |
并非每个芯片都具有上述全部区域。地址和可用容量应以芯片手册及平台内存图为准。
6.2 本项目的主要区域
按 app/mcu1.ld 的定义:
| 区域 | 地址范围 | 链接脚本用途 |
|---|---|---|
SRAM |
0x01040000 至 0x0106FFFF |
MCU1 映像和应用相关段,长度 192 KiB |
JTAG_LOG |
0x01070000 至 0x0107FFFF |
固定地址的 JTAG 日志缓冲区,长度 64 KiB |
DTCM_BSS |
0x20000000 起,长度 16 KiB |
DTCM 中的特定数据及主栈预留空间 |
FLASH |
0x08000000 起,长度 16 MiB |
链接脚本声明的区域;是否有段放入需看脚本分配 |
ARM_DUMMY |
0x10000000 起,长度 16 KiB |
.ARM.exidx、.ARM.extab 等段的占位区域 |
mcu1.ld 将 .text 放在 SRAM,并在其中依次收集向量表、复位代码、普通代码、只读常量和初始化数据。.bss 是运行时需要的未初始化数据,由启动代码清零。具体最终地址应以构建产生的 ELF、map 文件或 objdump 输出为准。
6.3 栈和堆
- 栈(stack):函数调用、局部变量、返回地址和异常现场等使用。
- 堆(heap):动态内存分配使用,例如
malloc,或 RTOS 的动态对象分配。 - 栈通常向低地址增长;堆常见为向高地址增长,但项目可采用不同布局和分配器。
- 栈、堆、全局变量和固定用途缓冲区都不能越界覆盖彼此或覆盖 Bootloader 保留区。
本项目的主栈大小由 main_stack_size = 0x400 指定,即 1 KiB,并由链接脚本在 DTCM 中预留。FreeRTOS 任务通常还有各自的任务栈;它们与复位启动时使用的主栈不是同一概念。
6.4 C 对象通常放在哪里
int initialized = 5; |
典型映射如下(具体由编译器、链接脚本和 ABI 决定):
| 对象 | 常见段或区域 | 典型启动处理 |
|---|---|---|
函数 task |
.text |
通常不复制 |
message |
.rodata |
初始内容随固件映像提供 |
initialized |
.data |
若 LMA 与 VMA 不同,复制到运行 RAM |
uninitialized |
.bss |
启动时清零 |
local |
栈 | 函数运行期间分配 |
C 语言没有规定这些对象必须落在哪个具体地址;最终布局由编译和链接配置决定。
7. 一个自己写启动代码的检查清单
如果启动代码不由芯片厂商库或 Bootloader 全部提供,至少要确认以下内容:
- Bootloader/硬件入口约定正确:加载地址和向量表地址配置准确。
- 向量表存在且布局正确:第一项是有效 MSP,第二项是有效 Reset Handler。
- 向量表满足对齐要求:按芯片/ARM 架构要求对齐。
- Reset Handler 存在:链接后确实保留,并且入口符号地址正确。
- 有有效栈:栈区有效、足够大、不与其他区重叠。
.data初始化方案一致:若 LMA 与 VMA 不同,映像里有初始值,启动代码执行复制。.bss清零范围正确:链接符号覆盖全部应清零的变量。- 特殊内存段有明确初始化规则:例如 DTCM BSS、共享 RAM、保留区等,不能假定普通
.bss循环会自动处理它们。 - C 运行库初始化完整:需要时调用构造函数数组、运行库初始化函数等。
- 异常和中断处理函数有效:至少提供可调试的默认处理函数。
- 系统时钟、FPU、MPU 等设置符合平台要求:这些步骤可能由 Bootloader 或启动库预先完成。
- 链接布局通过检查:检查 map 文件,验证段地址、段大小、对齐和内存边界。
不是所有项目都要把所有初始化写进 Reset Handler。例如 Bootloader 可能已经设置时钟,C 运行库可能负责初始化 .data/.bss,RTOS 移植层可能提供 SVC、PendSV 和 SysTick 处理程序。关键是明确每项工作由谁完成,避免重复初始化或遗漏。
8. 学习和排查时的实用方法
8.1 看链接 map 文件
map 文件可以回答:
- 向量表实际地址是多少?
- Reset Handler 实际地址是多少?
.text、.data、.bss分别占多少空间?__main_stack__和其他链接符号最终是什么值?- 哪些库或目标文件贡献了某个段?
8.2 检查 ELF 和反汇编
工具链通常提供:
arm-none-eabi-objdump -h firmware.elf |
objdump -h查看段名、大小和地址。objdump -d查看反汇编指令。nm -n按地址排序查看符号。
实际工具前缀可能不同,应使用工程配置的交叉编译工具链。
8.3 启动失败时优先检查
- Bootloader 写入的加载地址和向量表地址是否正确。
- 向量表前两项是否分别是有效栈顶和 Thumb 函数入口。
- Reset Handler 是否落在已加载且可执行的内存中。
- MSP 指向的 DTCM/SRAM 是否已可访问。
.data源、目标边界及映像内容是否一致。.bss清零是否越界、是否覆盖了映像或保留区。- 故障向量是否指向正确处理函数,VTOR 是否正确。
- 查看链接 map 文件,不能只根据源码推测最终地址。
9. 最后总结
- 向量表告诉 Cortex-M 初始栈在哪里、复位后从哪里开始执行。
- 启动汇编建立最初的栈、异常向量和 C 运行环境。
- 链接脚本决定代码、常量、数据、BSS、栈和固定缓冲区的地址。
- **
.data**保存有初始值的变量;若加载地址和运行地址不同,通常需要复制。 - **
.bss**保存未显式初始化的变量,通常需要在启动时清零。 - 链接符号把内存边界从链接脚本传给启动汇编。
- 汇编、链接脚本、Bootloader 和固件打包配置必须保持一致;最终布局应通过 map/ELF 工具验证。

