3. 存储器映射与总线架构:Cortex-M的4GB地址空间
好,咱们今天来聊聊 Cortex-M 的存储器映射。说白了,就是芯片上电后,这 4GB 的地址空间到底是怎么分配的。我刚开始接触 ARM 时,看着那个地址表也是一头雾水。后来被一个 bug 折磨了两天,才发现是外设地址配错了。从那以后,我养成了一个习惯——先看存储器映射图,再写代码。
3.1 4GB 地址空间的宏观划分
Cortex-M 使用 32 位地址总线,理论上可以访问 4GB(2^32 字节)的地址空间。ARM 把这 4GB 空间划分成了几个大区。每个区都有固定的用途。你想想看,这就像一个大楼,每层干什么用,早就规划好了。
| 地址范围 | 区域名称 | 用途 |
|---|---|---|
| 0x0000 0000 - 0x1FFF FFFF | Code 区 | 程序代码、异常向量表、常量 |
| 0x2000 0000 - 0x3FFF FFFF | SRAM 区 | 堆、栈、全局变量、位带别名区 |
| 0x4000 0000 - 0x5FFF FFFF | Peripheral 区 | 片上外设寄存器、位带别名区 |
| 0x6000 0000 - 0x9FFF FFFF | External RAM 区 | 外部存储器(如 SDRAM) |
| 0xA000 0000 - 0xDFFF FFFF | External Device 区 | 外部设备寄存器 |
| 0xE000 0000 - 0xFFFF FFFF | System 区 | 系统控制、调试、内部外设 |
嗯,这里要注意:Code 区和 System 区是固定的,芯片厂商不能随便改。但 SRAM 和 Peripheral 的大小,不同芯片会有差异。比如 STM32F103 的 SRAM 只有 20KB,而 STM32H743 有 1MB。它们都从 0x2000 0000 开始,但结束地址不同。
3.2 四大核心区域详解
3.2.1 Code 区(0x0000 0000 - 0x1FFF FFFF)
这是程序运行的地方。Cortex-M 上电后,CPU 会从 0x0000 0000 读取栈顶指针,从 0x0000 0004 读取复位向量,然后跳转执行。所以,你的代码必须放在这个区域。
我个人习惯把 Code 区再细分成三块:
- 0x0000 0000 - 0x0000 03FF:异常向量表。这是硬规定,不能动。
- 0x0000 0400 - 0x0000 FFFF:启动代码和关键中断服务函数。
- 0x0001 0000 以上:应用程序主体。
3.2.2 SRAM 区(0x2000 0000 - 0x3FFF FFFF)
SRAM 是程序运行时的“工作台”。全局变量、堆、栈都在这里。Cortex-M 的 SRAM 区支持位带操作,这是个好东西,后面会细说。
我建议你画个 SRAM 分配图:
- 低地址放 .data 和 .bss 段(全局变量)。
- 中间放堆(Heap),用于动态内存分配。
- 高地址放栈(Stack),向下生长。
为什么要这样?因为栈向下生长,堆向上生长,它们共用一块区域,能最大化利用内存。我在一个项目中,就是因为没规划好,导致栈溢出覆盖了全局变量,查了三天才找到原因。
3.2.3 Peripheral 区(0x4000 0000 - 0x5FFF FFFF)
这是外设寄存器的家。GPIO、UART、SPI、定时器……所有外设的控制寄存器都在这里。每个外设占用 1KB 或 2KB 的空间,但实际用到的寄存器可能只有几十个字节。
举个例子,STM32F4 的 GPIOA 基址是 0x4002 0000。你要操作 GPIOA->ODR(输出数据寄存器),地址就是 0x4002 0000 + 0x14 = 0x4002 0014。
// 直接操作寄存器地址
#define GPIOA_ODR_ADDR (0x40020014UL)
*(volatile uint32_t *)GPIOA_ODR_ADDR |= (1 << 5); // PA5 输出高电平
嗯,这里要注意:Peripheral 区也支持位带操作。这意味着你可以直接操作单个比特,而不需要读-改-写。对于 GPIO 输出、中断标志位清除等操作,位带能省不少事。
3.2.4 System 区(0xE000 0000 - 0xFFFF FFFF)
System 区是 Cortex-M 内核的“控制室”。NVIC(嵌套向量中断控制器)、SysTick(系统滴答定时器)、MPU(内存保护单元)、FPB(闪存断点单元)……都在这里。
这个区域用户不能随便改。但有几个寄存器你必须知道:
- SCB->VTOR(0xE000 ED08):向量表偏移寄存器。如果你的程序在 RAM 中运行,需要把向量表重映射到 RAM 地址。
- SysTick->LOAD(0xE000 E014):系统滴答定时器的重装载值。用于产生周期性中断。
- NVIC->ISER[0](0xE000 E100):中断使能寄存器。使能某个外设的中断。
3.3 AHB/APB 总线矩阵
Cortex-M 内部不是一根总线通到底。它有一个总线矩阵,连接着 CPU、DMA、外设和存储器。这个矩阵的核心是 AHB(高级高性能总线)和 APB(高级外设总线)。
从上图可以看出:
- CPU 和 DMA 都是 AHB 主设备,它们可以同时访问不同的从设备。比如 CPU 读 Flash,DMA 写 SRAM,互不干扰。
- Flash、SRAM 和 AHB 外设是 AHB 从设备。它们速度高,适合批量数据传输。
- APB 桥把 AHB 总线转换成 APB 总线。APB 外设(GPIO、UART、SPI 等)挂在 APB 上。为什么?因为 APB 接口简单,功耗低,适合慢速外设。
3.4 位带操作:用内存地址操作单个比特
位带操作是 Cortex-M 的一个特色功能。说白了,就是把一个 32 位的字映射到一个比特上。你写这个字,就相当于写那个比特。
位带操作有两个区域:
- SRAM 位带区:0x2000 0000 - 0x200F FFFF(1MB),对应的位带别名区是 0x2200 0000 - 0x23FF FFFF(32MB)。
- Peripheral 位带区:0x4000 0000 - 0x400F FFFF(1MB),对应的位带别名区是 0x4200 0000 - 0x43FF FFFF(32MB)。
位带别名地址的计算公式:
别名地址 = 位带基址 + (字节偏移 * 32) + (比特编号 * 4)
举个例子,你要操作 GPIOA->ODR 的第 5 位。GPIOA->ODR 的地址是 0x4002 0014。它在 Peripheral 位带区,位带基址是 0x4000 0000,别名基址是 0x4200 0000。
// 计算位带别名地址
#define GPIOA_ODR_ADDR (0x40020014UL)
#define BIT_NUM (5UL)
// 字节偏移 = 目标地址 - 位带基址
// 字节偏移 = 0x40020014 - 0x40000000 = 0x20014
// 别名地址 = 0x42000000 + (0x20014 * 32) + (5 * 4)
// 别名地址 = 0x42000000 + 0x400280 + 0x14 = 0x42400294
#define GPIOA_ODR_BIT5_ADDR (0x42400294UL)
// 写 1 使 PA5 输出高电平
*(volatile uint32_t *)GPIOA_ODR_BIT5_ADDR = 1;
// 写 0 使 PA5 输出低电平
*(volatile uint32_t *)GPIOA_ODR_BIT5_ADDR = 0;
你想想看,如果没有位带操作,你要这样写:
// 读-改-写
uint32_t temp = GPIOA->ODR;
temp |= (1 << 5);
GPIOA->ODR = temp;
这个操作不是原子的。如果在读和写之间发生了中断,中断里也修改了 ODR,那就会出问题。位带操作是原子操作,不会被中断打断。
// 位带操作宏
#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))
// 使用示例
#define PA5 BITBAND_PERIPH(&(GPIOA->ODR), 5)
*PA5 = 1; // PA5 输出高
这样代码可读性高,而且效率也高。不过要注意:位带别名区也是内存空间,不要把它当成普通变量来用。我曾经犯过一个错——在位带别名地址上用了自增操作,结果行为完全不对。
3.5 本章小结
存储器映射是 Cortex-M 的“地图”。你写代码前,一定要先看这张地图。记住几个关键点:
- Code 区放程序,SRAM 区放数据,Peripheral 区放外设寄存器,System 区放内核控制。
- AHB 总线速度快,APB 总线速度慢。高速外设挂 AHB,低速外设挂 APB。
- 位带操作是原子操作,适合 GPIO 输出、中断标志位清除等场景。但别滥用,它占用了 32MB 的别名空间。
嗯,这一章的内容就到这里。记住:地址空间是死的,但你的代码是活的。理解了存储器映射,你就能更好地控制芯片的每一个比特。