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 引脚;
  • nBOOT0nBOOT1 选项字节;
  • 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。

所以并不是“汇编解决了总线寻址”,而是分成三层:

  1. 编译器、汇编器和链接器计算符号地址;
  2. CPU 的 LDR/STR 指令发出地址和读写请求;
  3. 总线矩阵与地址译码器把请求发送给目标存储器或外设。

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、并发和硬件顺序必须使用各自对应的同步机制。