1. MCU内存模型:Flash、SRAM、寄存器映射与地址空间布局

做嵌入式开发这么多年,我见过太多新手一上来就埋头写代码,结果程序跑飞了、内存溢出了,半天找不到原因。说白了,你不了解MCU的内存布局,就像开车不看仪表盘——迟早要出事。

今天咱们就聊聊MCU的内存模型。这东西是RTOS任务调度的地基,地基不稳,上面盖什么楼都得塌。

1.1 三大存储区域:Flash、SRAM、寄存器

一个典型的MCU芯片内部,主要有三块存储区域。我习惯把它们比作「办公室的三个房间」:

  • Flash(程序存储器)——相当于文件柜。存放你的代码、常量、初始化数据。掉电不丢失,但写入慢、擦写次数有限。
  • SRAM(数据存储器)——相当于办公桌。存放变量、堆栈、运行时数据。掉电就丢,但读写快、寿命长。
  • 寄存器(外设控制)——相当于电话和开关。控制GPIO、定时器、UART等外设。每个寄存器都有固定地址。

你想想看,这三者的物理特性完全不同。Flash是NOR Flash,按扇区擦除;SRAM是静态随机存取;寄存器本质上是触发器阵列。它们的地址空间被统一映射到CPU的寻址总线上,CPU通过地址总线访问它们。

核心要点:CPU不关心你访问的是Flash还是SRAM,它只认地址。地址映射是硬件设计好的,我们开发者要做的就是搞清楚每个地址段对应什么。

1.2 地址空间布局:以STM32F103为例

拿最常见的STM32F103来说,它的地址空间是4GB(32位地址总线)。实际用到的只有一小部分。我给大家画个简化的布局:

起始地址 结束地址 区域 大小 说明
0x0000 0000 0x1FFF FFFF Code区(Flash/别名) 512MB 程序执行、中断向量表
0x2000 0000 0x3FFF FFFF SRAM区 512MB 变量、堆栈、堆
0x4000 0000 0x5FFF FFFF 外设寄存器区 512MB GPIO、TIM、USART等
0xE000 0000 0xE00F FFFF 系统级控制(SCB等) 1MB NVIC、SysTick等

嗯,这里要注意:实际芯片的Flash和SRAM大小远小于512MB。比如STM32F103C8T6只有64KB Flash和20KB SRAM。那为什么地址空间要留这么大?这是为了统一寻址,不同型号的MCU可以放在同一地址段的不同位置。

我的经验:拿到一个新板子,第一件事就是看芯片手册的「Memory Map」章节。把Flash起始地址、SRAM起始地址、关键外设基地址记下来。我曾经有个同事,把变量定义到了Flash地址段,结果程序死活写不进去——他以为那是SRAM。

1.3 寄存器映射:C语言如何操作硬件

寄存器映射,说白了就是给硬件寄存器起个C语言名字。比如GPIOA的ODR寄存器地址是0x4001 080C,我们不可能每次都写死地址。标准做法是用结构体指针:

// 定义GPIO寄存器结构体
typedef struct {
    volatile uint32_t CRL;    // 0x00 配置低寄存器
    volatile uint32_t CRH;    // 0x04 配置高寄存器
    volatile uint32_t IDR;    // 0x08 输入数据寄存器
    volatile uint32_t ODR;    // 0x0C 输出数据寄存器
    volatile uint32_t BSRR;   // 0x10 位设置/清除寄存器
    volatile uint32_t BRR;    // 0x14 位清除寄存器
    volatile uint32_t LCKR;   // 0x18 锁定寄存器
} GPIO_TypeDef;

// 将GPIOA基地址映射为结构体指针
#define GPIOA ((GPIO_TypeDef *) 0x40010800)

// 使用:点亮PA0
GPIOA->BSRR = 0x00000001;  // 设置PA0为高电平

这里有个关键点:volatile关键字绝对不能少。为什么?因为编译器会优化掉看似「无用」的读写。比如你连续读两次IDR寄存器,编译器可能只读一次,把结果缓存起来。但硬件寄存器的值随时可能变化,这种优化会出大问题。

避坑指南:我曾经在调试一个I2C驱动时,死活读不到从设备的ACK。查了两天,最后发现是忘了加volatile。编译器把读寄存器操作优化掉了,CPU根本没去访问硬件。从那以后,我写寄存器映射时第一件事就是检查volatile。

1.4 链接脚本:告诉编译器内存怎么用

链接脚本(Linker Script)是连接C代码和硬件内存的桥梁。它告诉链接器:代码放哪、变量放哪、堆栈放哪。以GCC的.ld文件为例:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 64K
    SRAM  (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}

SECTIONS
{
    .text : {
        *(.isr_vector)    // 中断向量表
        *(.text)          // 代码
        *(.rodata)        // 只读数据
        _etext = .;       // 代码结束地址
    } > FLASH

    .data : {
        _sdata = .;       // 数据段起始
        *(.data)          // 初始化数据
        _edata = .;       // 数据段结束
    } > SRAM AT > FLASH   // 数据在Flash中存储,运行时复制到SRAM

    .bss : {
        _sbss = .;        // BSS段起始
        *(.bss)           // 未初始化数据
        _ebss = .;        // BSS段结束
    } > SRAM

    .stack : {
        _stack_top = .;   // 栈顶
    } > SRAM
}

你想想看,这里有个很巧妙的设计:.data段在Flash里存储初始值,但运行时必须放到SRAM里。所以启动代码要做的第一件事,就是把.data从Flash拷贝到SRAM,然后把.bss清零。这个工作通常由汇编写的启动文件完成。

我个人习惯在链接脚本里明确指定堆和栈的大小。很多RTOS的栈空间分配,就是从这里开始的。比如FreeRTOS的堆栈,本质上就是在SRAM里划出一块区域。

1.5 内存对齐与位带操作

说到内存,有两个细节容易被忽略:对齐和位带。

内存对齐:ARM Cortex-M3要求32位数据必须4字节对齐访问,否则触发HardFault。我见过一个案例:有人把uint32_t变量定义在奇数地址的结构体里,结果程序跑着跑着就死机了。解决办法是加__attribute__((aligned(4)))

位带操作:Cortex-M3/M4有个很酷的特性——位带区。它把SRAM和外设寄存器区的每个bit,映射到别名区的一个32位地址。这样你就可以用C语言的位操作来原子地修改单个bit,而不需要读-改-写三步。

// 位带别名地址计算公式
// alias_addr = bit_band_base + (byte_offset * 32) + (bit_number * 4)

// 操作SRAM位带区
#define BITBAND_SRAM(addr, bit) ((volatile uint32_t *)(0x22000000 + ((uint32_t)(addr) - 0x20000000) * 32 + (bit) * 4))

// 操作外设位带区
#define BITBAND_PERIPH(addr, bit) ((volatile uint32_t *)(0x42000000 + ((uint32_t)(addr) - 0x40000000) * 32 + (bit) * 4))

// 使用:原子设置GPIOA->ODR的第0位
*BITBAND_PERIPH(&GPIOA->ODR, 0) = 1;

我的建议:位带操作在RTOS中特别有用。比如任务间通信的flag,用位带操作可以避免关中断。不过要注意,位带别名区也占用地址空间,别把变量定义到那里去了。

1.6 总结:内存模型对RTOS的意义

好了,咱们把MCU内存模型捋了一遍。这些东西和RTOS任务调度有什么关系?关系大了:

  • 任务栈分配——每个任务需要独立的栈空间,这些栈从SRAM里划分。栈大小、位置、对齐方式,都取决于你对内存布局的理解。
  • 任务控制块(TCB)——TCB通常放在SRAM的全局区或堆区。它的地址必须固定,因为调度器要频繁访问。
  • 中断向量表——默认在Flash起始地址。但有些RTOS支持动态重定位,把向量表搬到SRAM,实现运行时修改中断处理函数。
  • 内存保护(MPU)——如果MCU有MPU,你需要为每个任务设置不同的内存访问权限。这要求你精确知道每个内存区域的起始地址和大小。

说白了,不懂内存模型,你写的RTOS代码就像在雷区里跳舞。下一章咱们就聊聊任务栈到底怎么分配、怎么检测栈溢出。嗯,那才是真正考验功底的地方。