2. 内存映射基础:ARM Cortex-M的存储器映射、Flash与RAM的地址空间划分、外设寄存器的内存映射

做Bootloader,第一件事就是搞清楚芯片的地址空间。说白了,你得知道你的代码放在哪,数据放在哪,外设寄存器又在哪里。我见过不少新手,上来就写链接脚本,结果芯片跑飞了都不知道为什么——其实就是内存映射搞错了。

ARM Cortex-M系列,从M0到M7,内存映射的骨架是统一的。嗯,这里要注意,我说的“统一”是指系统级地址分配的大框架,具体到每个芯片厂商,细节会有差异。但只要你掌握了这个框架,换芯片也就是查个手册的事。

2.1 Cortex-M的系统级存储器映射

Cortex-M使用4GB的线性地址空间(0x00000000 ~ 0xFFFFFFFF)。这4GB被划分成几个固定的区域。为什么这么分?因为ARM在设计时就定好了——哪个区域放代码,哪个区域放外设,哪个区域留给系统内部用。

我个人习惯把这张表贴在工位上,写代码时随时瞄一眼:

地址范围 区域名称 用途 典型属性
0x00000000 - 0x1FFFFFFF Code区 程序代码、异常向量表 可执行、可读
0x20000000 - 0x3FFFFFFF SRAM区 运行时数据、堆栈 可读写
0x40000000 - 0x5FFFFFFF 外设区 片上外设寄存器 可读写(需对齐)
0x60000000 - 0x9FFFFFFF 外部RAM区 扩展存储器(SDRAM等) 可读写
0xA0000000 - 0xDFFFFFFF 外部设备区 外部外设映射 可读写
0xE0000000 - 0xE00FFFFF 系统区(私有外设) NVIC、SysTick、MPU等 系统控制
0xE0100000 - 0xFFFFFFFF 保留/厂商自定义 芯片厂商扩展 视芯片而定

你想想看,这个分区设计其实很聪明。Code区从0地址开始,保证了上电后CPU能直接取到复位向量。SRAM区紧挨着Code区,方便链接脚本做紧凑布局。外设区单独划出一大块,避免了和外扩存储器地址冲突。

核心要点:Bootloader的链接脚本,本质上就是在Code区和SRAM区里“切蛋糕”。你要决定:向量表放哪?主程序放哪?数据段放哪?堆栈放哪?

2.2 Flash与RAM的地址空间划分

实际芯片中,Flash和RAM的物理地址是挂载在上述系统映射中的。以我常用的STM32F4系列为例:

  • Flash:起始地址0x08000000,大小从256KB到2MB不等。映射在Code区。
  • SRAM:起始地址0x20000000,大小从64KB到256KB不等。映射在SRAM区。

这里有个坑——0x00000000地址。Cortex-M上电后,CPU会从0x00000000读取栈指针(MSP),从0x00000004读取复位向量。但大多数芯片的Flash并不在0地址!那怎么办?

芯片厂商用了别名映射(Alias)机制。上电时,Flash的内容被自动映射到0地址区域。也就是说,你写代码时用的是0x08000000,但CPU取向量时实际访问的是0x00000000,两者指向同一块物理存储。

我曾经踩过的坑:在做Bootloader跳转时,我直接写了跳转到0x08010000(APP起始地址)。结果芯片死活不跑。后来发现,跳转前必须把0x00000000的别名映射关掉,或者重新设置向量表偏移寄存器(VTOR)。否则CPU以为向量表还在0地址,而那里还是Bootloader的向量表。

Flash和RAM的典型划分,我用一张图来展示:

Flash与RAM地址空间划分(以STM32F4为例) Flash (0x08000000) Bootloader (16KB ~ 64KB) APP 主程序 APP 升级区 (OTA) 参数/配置存储区 Flash大小:256KB ~ 2MB SRAM (0x20000000) .data 段 (全局变量) .bss 段 (未初始化变量) 堆 (Heap) 栈 (Stack) SRAM大小:64KB ~ 256KB 注意:Flash和SRAM的物理地址由芯片厂商决定,但都落在Cortex-M系统映射的对应区域内 Bootloader和APP共用同一片SRAM,需要约定好堆栈使用范围

从这张图你能看到,Flash里放了Bootloader、APP主程序、升级区和参数区。SRAM里放了.data、.bss、堆和栈。Bootloader和APP虽然代码段在Flash里是分开的,但运行时都共用同一片SRAM。

我的建议:在链接脚本里,给Bootloader和APP分别定义SRAM的使用范围。比如Bootloader只用0x20000000 ~ 0x20003FFF(16KB),APP用0x20004000 ~ 0x2001FFFF。这样即使Bootloader跑飞了,也不会踩到APP的数据。

2.3 外设寄存器的内存映射

外设寄存器,说白了就是一堆特殊的内存地址。你往这些地址写数据,就能控制GPIO、UART、定时器等硬件。Cortex-M把外设寄存器统一放在0x40000000 ~ 0x5FFFFFFF这个区域。

每个芯片厂商会在这个区域内继续细分。以STM32F4为例:

  • GPIOA:基地址 0x40020000
  • USART1:基地址 0x40011000
  • SPI1:基地址 0x40013000
  • TIM2:基地址 0x40000000

每个外设内部,寄存器按偏移量排列。比如GPIOA的MODER寄存器在偏移0x00,ODR在偏移0x14。你写代码时,通常会用结构体指针来访问:

// 定义GPIO寄存器结构体
typedef struct {
    volatile uint32_t MODER;   // 0x00
    volatile uint32_t OTYPER;  // 0x04
    volatile uint32_t OSPEEDR; // 0x08
    volatile uint32_t PUPDR;   // 0x0C
    volatile uint32_t IDR;     // 0x10
    volatile uint32_t ODR;     // 0x14
    // ... 省略其他寄存器
} GPIO_TypeDef;

// 将GPIOA基地址强制转换为结构体指针
#define GPIOA ((GPIO_TypeDef *)0x40020000)

// 使用:设置PA5为输出模式
GPIOA->MODER |= (1 << 10);  // MODER[10:9] = 01

这里有个关键点——volatile关键字。我刚开始做嵌入式时,就因为忘了加volatile,编译器优化掉了寄存器读写,导致外设死活不工作。加了volatile,就是告诉编译器:这个地址的值随时可能被硬件改变,你别给我优化掉。

我曾经犯过的错:在做Bootloader的串口驱动时,我直接用指针访问USART寄存器,但没加volatile。结果编译器把状态寄存器的读取优化成了常量,导致轮询永远超时。查了两天才发现是这个问题。从那以后,我所有外设寄存器定义都强制加volatile。

外设寄存器的访问还有对齐要求。Cortex-M要求32位寄存器必须4字节对齐访问,16位寄存器2字节对齐。如果你用未对齐的指针去访问,轻则触发异常,重则读回错误数据。

嗯,这里再补充一点。外设寄存器的内存映射是固定的,不像Flash和RAM那样可以重映射。所以你在写Bootloader时,可以直接用芯片手册里的基地址,不用担心地址会变。

2.4 本章小结

内存映射是Bootloader的基石。你搞清楚了Flash放哪、RAM放哪、外设寄存器放哪,写链接脚本和启动代码时心里就有底了。我个人的经验是:每换一款芯片,第一件事就是打开它的内存映射图,把Flash和RAM的起止地址记下来,然后才开始写代码。