Cortex-M 从上电到运行:启动、汇编与链接脚本入门

本文面向刚接触 Cortex-M 固件的开发者,介绍处理器从复位到运行 C 程序的大致过程、常见汇编和 GNU 链接脚本语法,以及内存段和启动代码之间的关系。示例以本仓库的 MCU1 工程为准;不同芯片、Bootloader 和工具链的具体细节可能不同。

相关文件:


1. 先建立整体模型

可以把启动理解为“找到入口、准备运行环境、进入应用”:

上电或复位
↓
Boot ROM / Bootloader 准备并加载固件映像
↓
处理器从向量表取得初始栈指针和复位入口
↓
执行 Reset Handler(启动汇编)
├─ 设置异常向量表
├─ 设置栈
├─ 初始化全局变量所在内存
├─ 配置必要的系统寄存器
└─ 调用 C 应用入口
↓
初始化外设、创建任务
↓
主循环或 RTOS 调度器运行

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
向量表[1] → PC(复位处理入口)

所以向量表必须放在 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:
.word __main_stack__
VECTOR_FN(BCM_RESET_HANDLER)
VECTOR_FN(BCM_NMI_ISR_LOOP)
VECTOR_FN(BCM_HARD_FAULT_ISR)

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
MOVS r1, #0
STR r1, [r0]

将 0 写入 MPU 控制寄存器,暂时关闭 MPU。应用之后可以按自己的内存属性重新配置它。

3.2 设置 VTOR

LDR r0, =CORTEX_MX_VECTOR_TBL
LDR r1, =CORTEX_SCB_VTOR
STR r0, [r1]

把向量表地址写入 SCB 的 VTOR 寄存器。处理器随后使用这张表查找异常和中断处理入口。

3.3 设置主栈 MSP

LDR r0, =__main_stack__
MSR MSP, r0

__main_stack__ 是链接脚本定义的地址符号。MSR 将该地址写入 MSP(Main Stack Pointer,主栈指针)。

栈通常从高地址向低地址增长。函数调用、局部变量和异常现场保存都需要有效栈,因此在进入 C 代码前必须先设置好栈。

3.4 初始化 .data

LDR r0, =__data_load__    /* 源地址:初始值映像的位置 */
LDR r1, =__data_start__ /* 目标地址:程序运行时变量的位置 */
LDR r2, =__data_end__ /* 目标结束地址 */
B DATA_COPY_CHECK

DATA_COPY_LOOP:
LDR r3, [r0], #4
STR r3, [r1], #4

DATA_COPY_CHECK:
CMP r1, r2
BCC DATA_COPY_LOOP

.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__
LDR r1, =__bss_end__
MOVS r2, #0
B BSS_CPY_CHECK

BSS_CPY_LOOP:
STR r2, [r0], #4

BSS_CPY_CHECK:
CMP r0, r1
BCC BSS_CPY_LOOP

.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
LDR r1, =0x10
STR r1, [r0]

LDR r0, =CORTEX_SHCSR
LDR r1, =0x70000
STR r1, [r0]

本项目用 CCR 的 DIV_0_TRP 位启用除零陷阱,并用 SHCSR 的相关位启用 UsageFault、BusFault 和 MemManage fault。

寄存器写入应以芯片和 ARM 架构文档为准。直接写一个常数可能影响同一寄存器的其他可写位;需要保留已有配置时,通常要按寄存器定义做读-改-写。

3.7 调用 C 入口

LDR r0, =BCM8915X_Main
BLX r0

LOOP:
b LOOP

BLX 调用 BCM8915X_Main,并把返回地址保存到 lr。当前 C 入口进行应用初始化、设置任务并启动 FreeRTOS 调度器。若入口意外返回,启动代码进入无限循环,避免继续执行未知内容。


4. 汇编语法入门

汇编源文件中有两类内容:

  1. 处理器指令:生成机器码,由 CPU 执行,例如 MOVS、STR、BLX。
  2. 汇编器指示符:指导汇编器组织代码或生成数据,例如 .section、.global。它们一般不作为 CPU 指令执行。

此外,.S(大写 S)通常会先经过 C 预处理器,所以文件里的 #define 会在汇编器处理前展开。

4.1 目标架构声明

.syntax unified
.arch armv7-m
.cpu cortex-m7
.code 16
.text
写法 含义
.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 */
LDR r0, [r1] /* 从 r1 指向的地址读数值到 r0 */

4.4 地址偏移和自动更新

本项目使用后索引形式:

LDR r3, [r0], #4
STR r3, [r1], #4

执行顺序是先用当前地址读写,然后将地址寄存器加 4。这适合逐字复制数据。

另一种常见形式:

LDR r3, [r0, #4]!

! 表示写回:先用 r0 + 4 访问内存,再将新地址写回 r0。没有 ! 时,基址寄存器通常不变:

LDR r3, [r0, #4]

4.5 算术、比较与条件分支

CMP r1, r2
BCC copy_loop

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
.set BCM_OS_RESET_HANDLER, BCM_RESET_HANDLER
.extern BCM8915X_Main
.section .reset_func

.global 导出入口;.set 创建兼容别名;.extern 声明外部 C 符号;.section 将后续内容放入指定输入段。

.word 经常用于向量表,因为表项是 32 位地址或值:

.word __main_stack__

4.8 宏和注释

GNU 汇编宏示例:

.macro LOAD_ZERO reg
MOVS \reg, #0
.endm

LOAD_ZERO r2

汇编器会在汇编阶段展开宏;它不是运行时函数调用。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
{
DTCM_BSS (w) : ORIGIN = 0x20000000, LENGTH = 16K
SRAM (xw) : ORIGIN = 0x01040000, LENGTH = 192K
JTAG_LOG (w) : ORIGIN = 0x01070000, LENGTH = 64K
FLASH (r) : ORIGIN = 0x08000000, LENGTH = 16M
}
  • 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 :
{
*(.vector_tbl*)
KEEP(*(.reset_func*))
*(.text*)
*(.rodata*)
} > SRAM
  • .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_start__:.bss 的起始地址。
  • __bss_end__:.bss 的结束地址。

它们不是在 RAM 中创建的普通变量。汇编文件可以用 .extern __bss_start__ 声明它们,链接器最终把符号解析为地址。

启动代码和链接脚本必须使用一致的符号名和段范围,否则启动代码可能没有清零或复制正确的区域。

5.5 NOLOAD:只占运行内存,不带初始化载荷

.bss (NOLOAD) :
{
*(.bss*)
} > SRAM

NOLOAD 表示该段需要占用运行时地址空间,但不要求在固件载荷中保存每个字节的初始内容。.bss 通常采用这种方式,再由启动代码清零。

栈、堆、运行时缓冲区也经常不需要在映像里带一份初始内容。

5.6 VMA 与 LMA:运行地址和加载地址

  • VMA(Virtual Memory Address):程序运行时访问该段的地址。
  • LMA(Load Memory Address):该段初始内容在映像中加载或存放的地址。

常见的 Flash + RAM 布局:

.data 的初始值存于 Flash(LMA)
.data 运行时变量位于 RAM(VMA)
Reset Handler 将初始值从 Flash 复制到 RAM

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;
int uninitialized;
const char message[] = "hello";

void task(void)
{
int local = 1;
}

典型映射如下(具体由编译器、链接脚本和 ABI 决定):

对象 常见段或区域 典型启动处理
函数 task .text 通常不复制
message .rodata 初始内容随固件映像提供
initialized .data 若 LMA 与 VMA 不同,复制到运行 RAM
uninitialized .bss 启动时清零
local 栈 函数运行期间分配

C 语言没有规定这些对象必须落在哪个具体地址;最终布局由编译和链接配置决定。


7. 一个自己写启动代码的检查清单

如果启动代码不由芯片厂商库或 Bootloader 全部提供,至少要确认以下内容:

  1. Bootloader/硬件入口约定正确:加载地址和向量表地址配置准确。
  2. 向量表存在且布局正确:第一项是有效 MSP,第二项是有效 Reset Handler。
  3. 向量表满足对齐要求:按芯片/ARM 架构要求对齐。
  4. Reset Handler 存在:链接后确实保留,并且入口符号地址正确。
  5. 有有效栈:栈区有效、足够大、不与其他区重叠。
  6. .data 初始化方案一致:若 LMA 与 VMA 不同,映像里有初始值,启动代码执行复制。
  7. .bss 清零范围正确:链接符号覆盖全部应清零的变量。
  8. 特殊内存段有明确初始化规则:例如 DTCM BSS、共享 RAM、保留区等,不能假定普通 .bss 循环会自动处理它们。
  9. C 运行库初始化完整:需要时调用构造函数数组、运行库初始化函数等。
  10. 异常和中断处理函数有效:至少提供可调试的默认处理函数。
  11. 系统时钟、FPU、MPU 等设置符合平台要求:这些步骤可能由 Bootloader 或启动库预先完成。
  12. 链接布局通过检查:检查 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
arm-none-eabi-objdump -d firmware.elf
arm-none-eabi-nm -n firmware.elf
  • objdump -h 查看段名、大小和地址。
  • objdump -d 查看反汇编指令。
  • nm -n 按地址排序查看符号。

实际工具前缀可能不同,应使用工程配置的交叉编译工具链。

8.3 启动失败时优先检查

  1. Bootloader 写入的加载地址和向量表地址是否正确。
  2. 向量表前两项是否分别是有效栈顶和 Thumb 函数入口。
  3. Reset Handler 是否落在已加载且可执行的内存中。
  4. MSP 指向的 DTCM/SRAM 是否已可访问。
  5. .data 源、目标边界及映像内容是否一致。
  6. .bss 清零是否越界、是否覆盖了映像或保留区。
  7. 故障向量是否指向正确处理函数,VTOR 是否正确。
  8. 查看链接 map 文件,不能只根据源码推测最终地址。

9. 最后总结

  • 向量表告诉 Cortex-M 初始栈在哪里、复位后从哪里开始执行。
  • 启动汇编建立最初的栈、异常向量和 C 运行环境。
  • 链接脚本决定代码、常量、数据、BSS、栈和固定缓冲区的地址。
  • **.data**保存有初始值的变量;若加载地址和运行地址不同,通常需要复制。
  • **.bss**保存未显式初始化的变量,通常需要在启动时清零。
  • 链接符号把内存边界从链接脚本传给启动汇编。
  • 汇编、链接脚本、Bootloader 和固件打包配置必须保持一致;最终布局应通过 map/ELF 工具验证。