MCU 启动、内存布局、总线访问、volatile 与 Cache 详解
本文系统说明 MCU 上电启动、中断向量表、栈与堆、.data/.bss 初始化、变量存储位置、CPU 总线访问、volatile、Cache 与 DMA 一致性等基础问题。
一、MCU 上电启动流程
以常见的 ARM Cortex-M MCU 为例:
flowchart LR
A[电源稳定] --> B[复位状态]
B --> C[采样 BOOT 引脚或选项字节]
C --> D[确定启动存储器]
D --> E[读取向量表第 0 项]
E --> F[设置 MSP 主栈指针]
F --> G[读取向量表第 1 项]
G --> H[跳转 Reset_Handler]
H --> I[初始化时钟和底层硬件]
I --> J[复制 .data 到 RAM]
J --> K[清零 .bss]
K --> L[初始化 C/C++ 运行库]
L --> M[进入 main]
1. 上电与复位
电源刚建立时,电压和时钟可能还不稳定,因此 MCU 内部复位电路会让 CPU 保持复位状态。
当以下条件满足后,复位才会释放:
- 电源达到允许范围;
- 内部或外部复位信号解除;
- 时钟源满足启动要求;
- 看门狗或掉电复位状态被清除。
复位释放后,CPU 并不是直接寻找 main(),而是先读取中断向量表。
二、BOOT0、BOOT1 的作用
“Boot01”通常指 BOOT0/BOOT1 启动配置。
启动引脚或选项字节决定复位后哪块存储器被映射到启动地址。以部分经典 STM32 为例:
| BOOT0 | BOOT1 | 启动区域 |
|---|---|---|
| 0 | 任意 | 主 Flash,运行用户程序 |
| 1 | 0 | System Memory,运行芯片内部 Bootloader |
| 1 | 1 | SRAM,从 RAM 启动 |
不同 STM32 系列的规则并不完全相同。较新的型号可能使用:
BOOT0引脚;nBOOT0、nBOOT1选项字节;- System Memory Bootloader;
- TrustZone 或安全启动选项。
因此最终应查看具体 MCU 的参考手册,不能把上述表格套用到所有型号。
System Memory Bootloader 有什么用
它是芯片厂家预置在 ROM 或系统 Flash 中的程序,可能支持:
- UART 下载;
- USB DFU;
- CAN 下载;
- SPI、I²C 下载。
它不是用户编译进去的 Bootloader。
三、中断向量表
Cortex-M 启动地址处存放的是一张地址表。
典型向量表开头如下:
const uint32_t vector_table[] = {
(uint32_t)&_estack, // 第 0 项:初始 MSP
(uint32_t)Reset_Handler, // 第 1 项:复位入口
(uint32_t)NMI_Handler,
(uint32_t)HardFault_Handler,
// ...
};
1. 第 0 项:栈顶地址
CPU 读取向量表第一个 32 位数据,将它写入主栈指针 MSP:
MSP = *(uint32_t *)(启动地址 + 0)
这个地址一般由链接脚本中的 _estack、__StackTop 等符号确定,通常指向 SRAM 顶部。
2. 第 1 项:Reset_Handler
CPU 读取第二个 32 位数据,将其作为程序入口:
PC = *(uint32_t *)(启动地址 + 4)
这个地址指向 Reset_Handler。
对 Cortex-M 来说,函数地址最低位通常为 1,用来表示 Thumb 状态。最低位不是实际地址的一部分。
3. 其他向量
后面的表项分别对应:
- NMI;
- HardFault;
- SysTick;
- 外部中断;
- UART、SPI、定时器等外设中断。
在支持 VTOR 的处理器上,程序可以修改:
SCB->VTOR = 新向量表地址;
从而把向量表搬到另一个 Flash 分区或 SRAM。这在 Bootloader 跳转应用程序时非常常见。
四、Reset_Handler 做了什么
Reset_Handler 是启动代码的真正入口,通常由汇编和少量 C 代码组成。
典型流程是:
void Reset_Handler(void)
{
SystemInit();
copy_data_from_flash_to_ram();
clear_bss();
__libc_init_array(); // C++ 构造函数等
main();
while (1);
}
不同工具链的实际先后顺序可能稍有区别。
五、.data 和 .bss 的初始化与“重定位”
这里的重定位主要指:把具有初始值的可写变量从 Flash 初始镜像复制到 RAM 运行地址。
1. 为什么 .data 需要复制
例如:
int count = 10;
count 在运行过程中可能被修改,所以它必须位于 RAM。
但是刚上电时 RAM 中没有可靠内容,因此初始值 10 必须保存在 Flash 中。
于是该变量有两个地址概念:
- LMA,装载地址: 初始值在 Flash 中的位置;
- VMA,运行地址: 变量在 RAM 中的位置。
启动代码执行类似操作:
uint32_t *src = &_sidata; // Flash
uint32_t *dst = &_sdata; // RAM
while (dst < &_edata) {
*dst++ = *src++;
}
这就是常说的复制或重定位 .data。
2. 为什么 .bss 只需要清零
例如:
int error_count;
static int state;
int flag = 0;
具有静态存储期但未显式初始化的变量,按照 C 语言规则初始值为 0。
没有必要在 Flash 中存放大量连续的零,只需要记录 .bss 的起止地址,然后启动时清零:
uint32_t *dst = &_sbss;
while (dst < &_ebss) {
*dst++ = 0;
}
需要特别区分:
int global_value; // 全局变量:上电后为 0,通常在 .bss
void func(void)
{
int local_value; // 普通局部变量:未初始化,值不确定
}
六、栈和堆是怎样建立的
1. 栈
栈用于保存:
- 局部自动变量;
- 函数返回地址;
- 被保护的寄存器;
- 函数参数;
- 中断现场。
在大多数 Cortex-M 系统中,栈向低地址方向增长:
SRAM 高地址
┌──────────────┐
│ 初始栈顶 MSP │
├──────────────┤
│ 栈 ↓ │
│ │
│ 空闲区 │
│ │
│ 堆 ↑ │
├──────────────┤
│ .bss / .data │
└──────────────┘
SRAM 低地址
栈通常不是启动代码调用某个函数“创建”的。链接脚本预留一块区域,向量表第 0 项提供栈顶地址,CPU 在复位时直接写入 MSP。
Cortex-M 还有两个栈指针:
MSP:主栈指针,复位后默认使用,中断通常也使用它;PSP:进程栈指针,常用于 RTOS 任务。
2. 堆
堆用于:
malloc();
calloc();
realloc();
free();
new;
delete;
链接脚本会为堆预留地址范围,但只有调用动态分配函数时才真正使用。
裸机系统中,C 库可能通过 _sbrk() 扩展堆空间。很多嵌入式项目为了避免碎片和分配失败,完全不使用堆。
需要防止:
栈向下增长 + 堆向上增长 → 二者碰撞
栈溢出可能直接覆盖全局变量、堆或控制数据,是非常隐蔽的故障。
七、代码和变量分别存在哪里
常见程序段如下:
| 程序段 | 典型内容 | 运行位置 |
|---|---|---|
.text |
程序指令、部分常量池 | Flash |
.rodata |
字符串常量、只读常量 | Flash |
.data |
有非零初始值的全局、静态变量 | RAM,初值来自 Flash |
.bss |
未初始化或零初始化的全局、静态变量 | RAM,启动时清零 |
| Stack | 局部变量、返回地址、中断现场 | RAM |
| Heap | malloc/new 动态分配对象 |
RAM |
具体放置仍由编译器和链接脚本决定。
1. 未赋值的全局变量
int value;
具有静态存储期,程序启动后必须为 0,一般放在 .bss。
2. 有初始值的全局变量
int value = 100;
运行实体位于 RAM 的 .data,初始值镜像保存在 Flash。
某些编译器也可能把显式初始化为 0 的变量优化到 .bss。
3. const 修饰的全局变量
const int table[4] = {1, 2, 3, 4};
在常见 MCU 工具链中通常进入 .rodata,位于 Flash。
但需要注意:
C 语言的
const表示“不能通过该对象名修改”,并不从语言层面保证它一定存放在 Flash。
最终存储位置由链接脚本、地址属性和芯片架构决定。
下面这种变量通常是内存映射外设,必须使用 volatile:
volatile const uint32_t STATUS_REG;
这里的含义是:
- 软件不能通过该声明写入;
- 硬件可能随时改变其值;
- 编译器每次都要重新读取。
4. static 修饰的局部变量
void func(void)
{
static int a;
static int b = 10;
}
static 局部变量具有静态存储期,不在函数栈上反复创建:
a通常进入.bss;b通常进入.data;- 生命周期贯穿整个程序;
- 作用域仍然只在
func()内。
所以“static 局部变量放在哪里”不能统一回答为 .data,要看它是否具有非零初值。
5. 文件级 static
static int module_state;
这里的 static 主要表示内部链接属性,即变量只在当前源文件内可见。
存储段仍取决于初始化方式:
- 未初始化:通常在
.bss; - 非零初始化:通常在
.data; static const:通常在.rodata。
八、CPU 怎样访问内存和外设
CPU 并不会从根本上区分“这是 RAM”还是“这是串口寄存器”。CPU 主要看到的是地址。
Cortex-M 通常使用统一的内存映射:
CPU 执行 LDR/STR
│
▼
CPU 总线接口
│
▼
总线矩阵 / AHB
┌──┼────────┐
▼ ▼ ▼
Flash SRAM AHB/APB 桥
│
▼
GPIO / UART / SPI / TIM
以典型 Cortex-M 地址规划为例:
| 地址范围示例 | 用途 |
|---|---|
0x00000000 附近 |
代码、启动映射区 |
0x20000000 附近 |
SRAM |
0x40000000 附近 |
外设寄存器 |
0x60000000 附近 |
外部存储器或外部设备 |
具体地址必须查看芯片手册。
总线中的角色
- CPU 是总线主设备;
- DMA 也可以成为总线主设备;
- Flash、SRAM、外设是从设备;
- 总线矩阵负责仲裁;
- 地址译码器根据地址选择目标设备;
- AHB/APB 桥把高速总线访问转换为外设总线访问。
因此,“访问某个外设”本质上就是访问某个固定地址。
九、汇编层面怎样访问外设
假设某个 GPIO 输出寄存器地址为:
0x40020014
汇编代码可能类似:
LDR R0, =0x40020014 ; 把寄存器地址放入 R0
LDR R1, [R0] ; 读取寄存器
ORR R1, R1, #1 ; 修改某一位
STR R1, [R0] ; 写回寄存器
其中:
LDR R0, =地址是汇编器伪指令;- 汇编器可能把它转换成
MOVW/MOVT; - 也可能从文字常量池加载地址;
[R0]表示访问 R0 所指向的地址;- 总线硬件负责把该地址路由到对应 GPIO。
所以并不是“汇编解决了总线寻址”,而是分成三层:
- 编译器、汇编器和链接器计算符号地址;
- CPU 的
LDR/STR指令发出地址和读写请求; - 总线矩阵与地址译码器把请求发送给目标存储器或外设。
C 语言中:
#define GPIO_ODR (*(volatile uint32_t *)0x40020014)
最终也会生成类似的 LDR/STR 指令。
十、volatile 解决什么问题
volatile 是对编译器优化行为的约束。
假设有一个由中断修改的标志:
uint8_t finished = 0;
while (finished == 0) {
}
如果没有 volatile,编译器可能认为循环内没有代码修改 finished,于是只读取一次:
LDRB R0, [finished]
loop:
CMP R0, #0
BEQ loop
即使中断已经修改内存,寄存器 R0 中还是旧值。
改成:
volatile uint8_t finished = 0;
编译器必须在循环中重新读取:
loop:
LDRB R0, [finished]
CMP R0, #0
BEQ loop
这就是 volatile 的核心作用:
每一次源代码中的 volatile 访问,都必须形成实际的可观察访问,不能被编译器随意删除或长期保存在普通寄存器中。
常见使用场景
1. 内存映射外设寄存器
#define UART_SR (*(volatile uint32_t *)0x40011000)
while ((UART_SR & RXNE) == 0) {
}
状态寄存器可能被硬件改变,必须反复读取。
2. 中断与主程序共享的简单标志
volatile uint8_t rx_done;
3. DMA 或其他硬件修改的状态字段
变量可能不由当前 CPU 指令流修改,需要阻止编译器假设其值不变。
十一、volatile 不能解决什么
volatile 经常被误认为“线程安全”或“内存同步”,这是错误的。
它不能保证:
- 原子操作;
- 多核缓存一致性;
- CPU 硬件访存顺序;
- DMA 与 Cache 一致性;
- 不发生竞争条件;
- 操作不会被中断打断。
例如:
volatile uint32_t counter;
counter++;
它通常对应:
读取 counter
加 1
写回 counter
如果中断或另一个 CPU 在读写之间修改了 counter,更新仍可能丢失。
正确方案可能是:
- 临界区;
- 关闭中断;
- C11 原子变量;
- RTOS 同步原语;
- Cortex-M 的
LDREX/STREX; - 硬件原子置位/清零寄存器。
十二、为什么 volatile 解决不了 Cache 问题
这是一个非常关键的区别。
volatile 只保证编译器生成读写指令,但 CPU 执行这条指令时,访问可能命中 Cache。
例如 DMA 把新数据写入 SRAM:
DMA 写入 SRAM:新数据 = 100
CPU Cache 中:旧数据 = 20
CPU 执行:
volatile uint32_t value = dma_buffer[0];
虽然编译器确实生成了 LDR,但硬件可能直接从 Cache 返回旧值 20。
所以:
volatile 保证“CPU 重新执行读取指令”
不保证“读取一定绕过 Cache 到达 SRAM”
CPU 写数据给 DMA
如果 CPU 修改了缓冲区,但新数据仍停留在 D-Cache 中:
CPU Cache:新数据
SRAM:旧数据
DMA:读取 SRAM
DMA 会读到旧数据。
启动 DMA 前通常需要:
SCB_CleanDCache_by_Addr(buffer, length);
即把脏 Cache 行写回 SRAM。
DMA 写数据给 CPU
DMA 已写入 SRAM,但 CPU Cache 中还有旧副本:
DMA → SRAM:新数据
CPU Cache:旧数据
DMA 完成后通常需要:
SCB_InvalidateDCache_by_Addr(buffer, length);
使 CPU 下次从 SRAM 重新读取。
常见解决方法
- 把 DMA 缓冲区配置在不可缓存区域;
- 使用 MPU 将对应区域设置为 non-cacheable;
- DMA 启动前 Clean Cache;
- DMA 完成后 Invalidate Cache;
- 缓冲区按 Cache Line 对齐;
- 清理长度按 Cache Line 边界扩展;
- 配合内存屏障保证访问顺序;
- 避免同一 Cache Line 中混放 DMA 数据和普通变量。
错误地执行 Invalidate 可能丢弃同一 Cache Line 中尚未写回的脏数据,因此对齐和操作顺序非常重要。
十三、内存屏障与 Cache 操作不是一回事
Cortex-M 中常见三种屏障:
DMB
Data Memory Barrier,保证屏障前后的内存访问顺序。
__DMB();
DSB
Data Synchronization Barrier,等待前面的显式内存访问完成。
__DSB();
ISB
Instruction Synchronization Barrier,刷新取指流水线,使系统控制寄存器等修改影响后续指令。
__ISB();
需要注意:
DMB/DSB 主要解决顺序和完成性
Clean/Invalidate 解决 Cache 数据一致性
volatile 解决编译器优化
三者处于不同层次,不能互相替代。
十四、把这些概念串起来
一个外设轮询变量可能需要:
volatile
一个中断共享计数器可能需要:
volatile + 临界区或原子操作
一个 DMA 缓冲区可能需要:
Cache Clean/Invalidate + 屏障 + 对齐
一个多核共享变量可能需要:
原子操作 + Cache 一致性协议或共享内存机制
一个半桥、DMA、网卡等高性能外设场景,往往同时涉及编译器、CPU、Cache、总线和外设五个层次。
可以用下面这张表快速区分:
| 问题来源 | 典型表现 | 解决手段 |
|---|---|---|
| 编译器优化 | 循环不重新读取变量 | volatile |
| 读改写竞争 | 更新丢失 | 原子操作、临界区 |
| CPU 访存乱序 | 外设配置顺序错误 | DMB/DSB |
| 指令流水线 | 系统配置修改未立即生效 | ISB |
| D-Cache | CPU/DMA 看到不同数据 | Clean/Invalidate 或不可缓存区 |
| 总线竞争 | CPU、DMA 同时访问导致延迟 | 总线仲裁、带宽规划 |
| 外设异步更新 | 状态寄存器自行改变 | volatile 外设寄存器 |
| 感知不到启动映射 | 跳错向量表 | BOOT 配置、VTOR、链接地址 |
最核心的理解是:
链接脚本决定代码和数据放在哪里;启动代码建立 C 语言运行环境;CPU 通过地址和总线访问存储器及外设;
volatile只约束编译器;Cache、并发和硬件顺序必须使用各自对应的同步机制。