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里放了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的起止地址记下来,然后才开始写代码。