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 运行时必须能被改成 101102,所以它不能只待在 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 内部的小存储单元,速度很快,例如 r0r1r2r3splrpc
  • 内存:Flash、RAM 等真实地址空间,例如 0x080000000x20000000

常见寄存器含义:

名称 含义
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。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐