从 STM32 启动文件开始,真正看懂 ARM 汇编、Flash、RAM 和内存段
text指令简单理解把栈顶地址放入sp调用,并用lr保存返回地址无条件跳到从地址r3 + r1读取 4 字节到r3把r3写入地址r0 + r1cmp r2, r3比较r2和r3,只更新标志位如果无符号比较结果是“小于”,跳到这段汇编不是在做复杂算法,而是在为 C 程序运行做准备:设栈、初始化内存、最后跳进 main。
STM32 启动汇编学习笔记
本文以本工程的 startup_stm32u575xx.s 和 STM32U575xx_FLASH.ld 为例,解释 Cortex-M 启动汇编里最常见的几条指令。
0. 先补几个最重要的背景概念
如果直接看:
ldr sp, =_estack
bl SystemInit
ldr r3, [r3, r1]
str r3, [r0, r1]
会很容易觉得乱。因为这些指令背后其实牵涉到几个基础概念:
- ARM 是什么
- STM32 是什么
- CPU、寄存器、内存是什么关系
- Flash 和 RAM 分别保存什么
.text、.data、.bss这些段是什么- 为什么 C 程序运行前还要先执行一段汇编
下面先把这些概念讲清楚。
0.1 ARM 是什么
ARM 可以从两个角度理解。
第一,ARM 是一种 CPU 架构。
所谓 CPU 架构,就是 CPU 能理解什么指令、有哪些寄存器、函数怎么调用、中断怎么进入、栈怎么使用等一整套规则。
比如这几条就是 ARM Cortex-M 能理解的指令:
ldr
str
movs
adds
cmp
b
bl
第二,ARM 不是具体某一颗芯片。
ARM 公司设计 CPU 架构和 CPU 内核,芯片厂商把 ARM 内核集成进自己的芯片里。
例如:
ARM 公司设计 Cortex-M33 内核
ST 公司把 Cortex-M33 放进 STM32U575 芯片
你现在写的程序运行在 STM32U575 里的 Cortex-M33 CPU 上
所以本工程里的关系是:
ARM 架构
↓
Cortex-M33 内核
↓
STM32U575 微控制器
↓
你的 C 程序和启动汇编
0.2 STM32 是什么
STM32 是 ST 公司生产的一系列微控制器,也就是 MCU。
MCU 可以理解成“一颗小型电脑”,但它不是普通电脑那种 CPU + 内存条 + 硬盘分开的结构,而是把很多东西集成在一颗芯片里。
一颗 STM32 里面通常有:
CPU 内核
Flash
RAM
GPIO
定时器 TIM
串口 USART
I2C
SPI
ADC
中断控制器 NVIC
时钟控制 RCC
本工程使用的是 STM32U575VGTx,它的核心信息来自 hafhald.ioc 和链接脚本:
芯片系列:STM32U5
CPU 内核:Cortex-M33
Flash:2048K
RAM:768K + SRAM4 16K
0.3 CPU、寄存器、内存的关系
CPU 真正执行指令时,不能直接对所有数据做复杂操作。它通常先把数据从内存取到寄存器,算完以后再写回内存。
可以粗略理解成:
内存:仓库,容量大,但访问相对慢
寄存器:CPU 手边的小格子,容量很小,但访问最快
CPU:真正执行指令的地方
例如:
ldr r3, [r0]
adds r3, r3, #1
str r3, [r0]
这三行大概是在做:
*r0 = *r0 + 1;
但是 CPU 实际步骤更细:
1. 从 r0 指向的内存地址读取数据,放到 r3
2. r3 = r3 + 1
3. 把 r3 写回 r0 指向的内存地址
汇编就是把这些非常细的步骤直接写出来。
0.4 Flash 是保存什么的
Flash 是非易失性存储器。
非易失性的意思是:
断电以后内容不会丢失
你把程序烧录进 STM32,本质上就是把机器码、常量、全局变量初始值等写入 Flash。
在本工程链接脚本中:
ROM (rx) : ORIGIN = 0x08000000, LENGTH = 2048K
这里的 ROM 实际上指的就是片上 Flash 区域。
它从地址 0x08000000 开始,大小是 2048K。
Flash 里通常保存:
1. 中断向量表
2. 程序机器码,也就是 .text 段
3. 只读常量,也就是 .rodata 段
4. 已初始化全局变量的初始值,也就是 .data 的加载镜像
比如你写:
const int version = 3;
int counter = 100;
大致可以理解为:
version 是只读常量,可以一直放在 Flash
counter 运行时要能修改,所以运行时必须在 RAM
counter 的初始值 100 会先保存在 Flash,启动时再复制到 RAM
0.5 RAM 是保存什么的
RAM 是易失性存储器。
易失性的意思是:
断电以后内容会丢失
但是 RAM 可以快速读写,所以程序运行时会把需要修改的数据放在 RAM。
在本工程链接脚本中:
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 768K
SRAM4 (xrw) : ORIGIN = 0x28000000, LENGTH = 16K
主要 RAM 从 0x20000000 开始,大小是 768K。
RAM 里通常保存:
1. .data 段:已初始化的全局变量、静态变量
2. .bss 段:未初始化或初始化为 0 的全局变量、静态变量
3. heap:堆,malloc/new 使用
4. stack:栈,函数调用、局部变量、中断现场保存使用
例如:
int a = 123; // .data,运行时在 RAM,初始值来自 Flash
int b; // .bss,运行时在 RAM,启动时清零
static int c = 5; // .data,运行时在 RAM,初始值来自 Flash
static int d; // .bss,运行时在 RAM,启动时清零
局部变量通常在栈里:
void test(void)
{
int x = 10; // 通常在 stack 上
}
如果用了动态内存:
void *p = malloc(100);
这块内存通常来自 heap。
0.6 什么是段:.text、.rodata、.data、.bss
C 程序编译以后,不是随便塞进 Flash/RAM 的,而是会分成不同的 section,中文常叫“段”。
常见段如下:
| 段名 | 主要保存什么 | 最终运行位置 | 是否占用 Flash |
|---|---|---|---|
.isr_vector |
中断向量表 | Flash 起始位置 | 是 |
.text |
程序机器码 | Flash | 是 |
.rodata |
只读常量、字符串常量 | Flash | 是 |
.data |
已初始化的全局变量/静态变量 | RAM | 是,Flash 中保存初始值 |
.bss |
未初始化或初始化为 0 的全局变量/静态变量 | RAM | 否,只记录大小 |
| heap | 动态分配内存 | RAM | 否 |
| stack | 函数调用栈 | RAM | 否 |
为什么 .data 比较特殊?
因为它有两个位置:
加载位置:Flash,保存初始值
运行位置:RAM,程序真正读写变量的位置
例如:
int counter = 100;
counter 运行时必须能被改成 101、102,所以它不能只待在 Flash,因为 Flash 不适合像 RAM 一样频繁改写。
于是系统这样处理:
烧录时:
把 counter 的初始值 100 放进 Flash
复位启动时:
启动汇编把 100 从 Flash 复制到 RAM
程序运行时:
C 代码读写 RAM 里的 counter
这就是为什么启动汇编里有 .data 复制循环。
0.7 为什么 .bss 要清零
比如你写:
int value;
static int flag;
int count = 0;
按照 C 语言规则,全局变量和静态变量如果没有显式初始化,默认值就是 0。
也就是说上面几个变量启动后都应该是:
value = 0
flag = 0
count = 0
但是 RAM 断电后内容是不确定的,刚上电时 RAM 里可能是随机值。
所以启动代码必须主动把 .bss 对应的 RAM 区域全部写成 0。
这就是启动文件里的这段:
FillZerobss:
movs r3, #0
str r3, [r2], #4
LoopFillZerobss:
ldr r3, = _ebss
cmp r2, r3
bcc FillZerobss
近似 C 代码:
while (addr < _ebss) {
*(uint32_t *)addr = 0;
addr += 4;
}
0.8 为什么需要链接脚本
链接脚本告诉链接器:
哪些代码/数据放到 Flash
哪些数据放到 RAM
Flash 从哪里开始
RAM 从哪里开始
栈顶在哪里
.data 的起点和终点在哪里
.bss 的起点和终点在哪里
程序入口是谁
本工程链接脚本里最关键的几行:
ENTRY(Reset_Handler)
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 768K
ROM (rx) : ORIGIN = 0x08000000, LENGTH = 2048K
_estack = ORIGIN(RAM) + LENGTH(RAM);
_sidata = LOADADDR(.data);
_sdata = .;
_edata = .;
_sbss = .;
_ebss = .;
这些符号会被启动汇编使用:
_estack:栈顶地址
_sidata:.data 初始值在 Flash 中的位置
_sdata:.data 在 RAM 中的起始位置
_edata:.data 在 RAM 中的结束位置
_sbss:.bss 在 RAM 中的起始位置
_ebss:.bss 在 RAM 中的结束位置
所以链接脚本和启动汇编是一对配合关系:
链接脚本负责告诉地址
启动汇编负责按这些地址初始化内存
0.9 从上电到 main() 的完整直觉
把所有概念串起来,可以这样理解:
1. 你写 C 代码
2. 编译器把 C 代码变成 ARM 指令
3. 链接器根据 STM32U575xx_FLASH.ld 安排 Flash/RAM 地址
4. 程序被烧录到 Flash
5. 芯片复位后从向量表找到 Reset_Handler
6. Reset_Handler 设置 sp
7. Reset_Handler 调用 SystemInit
8. Reset_Handler 把 .data 初始值从 Flash 复制到 RAM
9. Reset_Handler 把 .bss 对应 RAM 清零
10. Reset_Handler 调用 main
如果没有启动汇编,C 程序会遇到这些问题:
sp 没设置:函数调用和局部变量无法安全使用
.data 没复制:int a = 123; 运行时可能不是 123
.bss 没清零:int b; 启动后可能是随机值
main 没被调用:你的应用代码根本不会开始
1. 先理解 CPU 在操作什么
汇编指令操作的对象主要有两类:
- 寄存器:CPU 内部的小存储单元,速度很快,例如
r0、r1、r2、r3、sp、lr、pc。 - 内存:Flash、RAM 等真实地址空间,例如
0x08000000、0x20000000。
常见寄存器含义:
| 名称 | 含义 |
|---|---|
r0 - r12 |
通用寄存器,临时存放数据、地址、函数参数等 |
sp |
Stack Pointer,栈指针,指向当前栈位置 |
lr |
Link Register,函数返回地址 |
pc |
Program Counter,当前正在执行的指令地址 |
可以把 CPU 执行过程想成:
取一条指令 -> 执行这条指令 -> 修改寄存器/内存 -> pc 指向下一条指令
2. 本工程启动流程概览
芯片复位后,并不是直接执行 main()。
真正的顺序大致是:
复位
↓
读取中断向量表
↓
设置初始 sp
↓
进入 Reset_Handler
↓
调用 SystemInit()
↓
复制 .data 段到 RAM
↓
清零 .bss 段
↓
调用 __libc_init_array()
↓
调用 main()
本工程中,入口在 startup_stm32u575xx.s:
Reset_Handler:
ldr sp, =_estack
bl SystemInit
movs r1, #0
b LoopCopyDataInit
_estack 来自 STM32U575xx_FLASH.ld:
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 768K
_estack = ORIGIN(RAM) + LENGTH(RAM);
也就是说,RAM 起始地址是 0x20000000,大小是 768K,所以栈顶地址是:
0x20000000 + 768K = 0x200C0000
3. ldr sp, =_estack
原指令:
ldr sp, =_estack
作用:
把 _estack 代表的地址值放进 sp
执行后:
sp = 0x200C0000
这里的 ldr 是 load register,意思是“加载到寄存器”。
注意这里有一个特殊写法:
ldr 寄存器, =符号
它不是从内存读取符号里的内容,而是把“符号代表的地址/常量”放入寄存器。
所以:
ldr sp, =_estack
近似等价于:
sp = &_estack;
但更准确地说,_estack 是链接脚本定义的地址值,不是 C 变量。
为什么要先设置 sp?
因为 C 函数调用、局部变量、异常中断现场保存都需要栈。如果栈指针没有设置好,后面调用 SystemInit() 或 main() 都可能直接崩溃。
4. bl SystemInit
原指令:
bl SystemInit
bl 是 branch with link,意思是“跳转并保存返回地址”。
执行时 CPU 会做两件事:
lr = 当前指令的下一条指令地址
pc = SystemInit 的地址
其中:
pc决定 CPU 接下来执行哪里。lr保存函数执行完以后应该回到哪里。
所以:
bl SystemInit
近似等价于 C 语言:
SystemInit();
区别是,汇编里你能看到函数调用背后的细节:
1. 保存返回地址到 lr
2. 跳到 SystemInit
3. SystemInit 执行结束后,通过 lr 回来
在本工程里,SystemInit() 位于 Core/Src/system_stm32u5xx.c,主要做一些复位后的系统基础配置,例如 FPU、RCC、向量表位置等。
5. b LoopCopyDataInit
原指令:
b LoopCopyDataInit
b 是 branch,无条件跳转。
执行效果:
pc = LoopCopyDataInit 的地址
也就是 CPU 下一步直接去执行 LoopCopyDataInit: 标签处的代码。
近似等价于:
goto LoopCopyDataInit;
为什么这里要先跳过去?
因为这段代码写成了一个循环结构:
movs r1, #0
b LoopCopyDataInit
CopyDataInit:
...
adds r1, r1, #4
LoopCopyDataInit:
...
cmp r2, r3
bcc CopyDataInit
它先跳到循环判断位置,判断是否需要复制。如果需要,再跳回 CopyDataInit 执行复制。
近似 C 代码:
r1 = 0;
goto LoopCopyDataInit;
CopyDataInit:
copy_one_word();
r1 += 4;
LoopCopyDataInit:
if (not_finished) {
goto CopyDataInit;
}
6. ldr r3, [r3, r1]
原代码片段:
CopyDataInit:
ldr r3, =_sidata
ldr r3, [r3, r1]
str r3, [r0, r1]
adds r1, r1, #4
先看这一条:
ldr r3, [r3, r1]
这里的 [] 表示“把括号里的结果当成内存地址”。
格式:
ldr 目标寄存器, [基地址寄存器, 偏移寄存器]
含义:
目标寄存器 = 内存[基地址寄存器 + 偏移寄存器]
所以:
ldr r3, [r3, r1]
等价于:
r3 = *(uint32_t *)(r3 + r1);
它会从地址 r3 + r1 读取 4 字节数据,然后放入 r3。
为什么是 4 字节?
因为普通 ldr 在 ARM Thumb 汇编里通常读取一个 word,也就是 32 bit,等于 4 字节。
举个例子:
r3 = 0x08003000
r1 = 0x00000008
那么:
ldr r3, [r3, r1]
会读取:
内存地址 0x08003008 处的 4 字节数据
然后:
r3 = 读出来的数据
在启动代码中,这条指令的作用是从 Flash 读取 .data 段的初始化数据。
7. str r3, [r0, r1]
原指令:
str r3, [r0, r1]
str 是 store register,意思是“把寄存器里的值存入内存”。
格式:
str 源寄存器, [基地址寄存器, 偏移寄存器]
含义:
内存[基地址寄存器 + 偏移寄存器] = 源寄存器
所以:
str r3, [r0, r1]
等价于:
*(uint32_t *)(r0 + r1) = r3;
举个例子:
r0 = 0x20000000
r1 = 0x00000008
r3 = 0x12345678
执行:
str r3, [r0, r1]
结果是:
把 0x12345678 写入内存地址 0x20000008
在启动代码中,r0 是 _sdata,表示 RAM 中 .data 段的起始地址。
所以这条指令的作用是:
把刚刚从 Flash 读出的初始化值写入 RAM
8. cmp r2, r3
原指令:
cmp r2, r3
cmp 是 compare,比较。
它内部会做:
r2 - r3
但是结果不会保存到 r2,也不会保存到 r3。
它只会更新 CPU 的状态标志位。
常见状态标志:
| 标志 | 含义 |
|---|---|
Z |
zero,结果是否为 0 |
N |
negative,结果是否为负 |
C |
carry,进位/借位相关 |
V |
overflow,有符号溢出 |
后面的条件跳转指令会根据这些标志决定是否跳转。
例如:
cmp r2, r3
bcc CopyDataInit
可以先理解成:
if (r2 < r3) {
goto CopyDataInit;
}
9. bcc CopyDataInit
原指令:
bcc CopyDataInit
bcc 是 branch if carry clear。
在无符号数比较场景里,通常可以理解成:
如果前面的 cmp 判断出 r2 < r3,就跳转
所以:
cmp r2, r3
bcc CopyDataInit
近似等价于:
if (r2 < r3) {
goto CopyDataInit;
}
在本工程启动代码中:
LoopCopyDataInit:
ldr r0, =_sdata
ldr r3, =_edata
adds r2, r0, r1
cmp r2, r3
bcc CopyDataInit
含义是:
r0 = .data 在 RAM 中的起始地址
r3 = .data 在 RAM 中的结束地址
r2 = 当前要写入的 RAM 地址
如果 当前地址 < 结束地址:
继续复制
否则:
复制完成
近似 C 代码:
while ((_sdata + offset) < _edata) {
*(uint32_t *)(_sdata + offset) = *(uint32_t *)(_sidata + offset);
offset += 4;
}
10. 把 .data 复制循环完整翻译成 C
汇编原逻辑:
movs r1, #0
b LoopCopyDataInit
CopyDataInit:
ldr r3, =_sidata
ldr r3, [r3, r1]
str r3, [r0, r1]
adds r1, r1, #4
LoopCopyDataInit:
ldr r0, =_sdata
ldr r3, =_edata
adds r2, r0, r1
cmp r2, r3
bcc CopyDataInit
近似 C 代码:
uint32_t offset = 0;
while ((_sdata + offset) < _edata) {
uint32_t value = *(uint32_t *)(_sidata + offset);
*(uint32_t *)(_sdata + offset) = value;
offset += 4;
}
它的目的:
把 Flash 中保存的全局变量初始值,复制到 RAM 中真正运行的位置。
例如你写了:
int count = 100;
烧录时,100 存在 Flash。
运行时,变量 count 要在 RAM 中可读可写。
所以启动代码必须先做:
Flash 里的 100 -> 复制到 RAM 里的 count
这就是 .data 初始化。
11. 总结记忆
| 指令 | 简单理解 |
|---|---|
ldr sp, =_estack |
把栈顶地址放入 sp |
bl SystemInit |
调用 SystemInit(),并用 lr 保存返回地址 |
b LoopCopyDataInit |
无条件跳到 LoopCopyDataInit |
ldr r3, [r3, r1] |
从地址 r3 + r1 读取 4 字节到 r3 |
str r3, [r0, r1] |
把 r3 写入地址 r0 + r1 |
cmp r2, r3 |
比较 r2 和 r3,只更新标志位 |
bcc CopyDataInit |
如果无符号比较结果是“小于”,跳到 CopyDataInit |
最关键的一句话:
这段汇编不是在做复杂算法,而是在为 C 程序运行做准备:设栈、初始化内存、最后跳进 main。更多推荐


所有评论(0)