2.1 CPU 内核与存储映像 上一章我们比较了三种开发范式,并承诺要看穿库的封装。看穿的第一步是拿到芯片的"地址地图"——存储映像。这一节先看 Cortex-M3 内核怎么访问世界,再解释外设寄存器为什么也占用地址,最后给出用 C 指针操作外设的标准姿势。第 3 章点亮 LED 的代码,地基就打在这。 内核的三条总线:取指、常量、数据各走各路 Cortex-M3 内核引出三条主要总线:I-Code 总线专管从 Flash 取指令,D-Code 总线专管从 Flash 读常量数据,系统总线负责 SRAM 和全部外设。为什么分三条?并行。CPU 在执行指令的同时可以读常量表、访问 SRAM,互不堵车。
上一章我们比较了三种开发范式,并承诺要看穿库的封装。看穿的第一步是拿到芯片的"地址地图"——存储映像。这一节先看 Cortex-M3 内核怎么访问世界,再解释外设寄存器为什么也占用地址,最后给出用 C 指针操作外设的标准姿势。第 3 章点亮 LED 的代码,地基就打在这。
Cortex-M3 内核引出三条主要总线:I-Code 总线专管从 Flash 取指令,D-Code 总线专管从 Flash 读常量数据,系统总线负责 SRAM 和全部外设。为什么分三条?并行。CPU 在执行指令的同时可以读常量表、访问 SRAM,互不堵车。这也是为什么把中断向量表放在 Flash 起点(0x08000000 一带)能让内核复位后立刻取到第一条指令。
对 STM32F103 来说,片上总线结构再往外延伸出 AHB(高速)与 APB1/APB2(外设)两级桥。挂在 AHB 上的有 SRAM、RCC、DMA 和 GPIO 端口,APB2 上是 GPIO、USART1、ADC 这些需要较快时钟的外设,APB1 挂定时器、I2C、USART2/3 等低速外设。外设挂在哪条总线,决定了它时钟使能位在哪个 RCC 寄存器里——这是初学者最常见的第一个坑,第 3 章开 GPIO 时钟就是往 APB2ENR 里置位。
ARM 体系采用统一编址:内存、Flash、外设寄存器共享同一套 32 位地址空间。STM32F103 的地址地图大致如下(详见参考手册的 Memory Map 一章):
| 地址区间 | 归属 | 说明 |
|---|---|---|
| 0x0800 0000 起 | Flash 主存储区 | 代码、常量、向量表 |
| 0x2000 0000 起 | SRAM | 变量、栈、堆 |
| 0x4000 0000 起 | APB1 外设 | 定时器 2-7、I2C、USART2/3 等 |
| 0x4001 0000 起 | APB2 外设 | GPIOA-E、USART1、ADC1/2 |
| 0x4001 8000 起 | AHB 外设 | RCC、DMA |
| 0xE000 0000 起 | 内核外设 | NVIC、SysTick、调试单元 |
所谓 GPIOA 的 ODR 寄存器,物理上是 GPIOA 端口控制块里的一个 32 位触发器组,只是被映射到了地址 0x4001 080C。CPU 往这个地址写值,数据经总线落到那个触发器组上,引脚电平随之改变——写寄存器就是写一个特殊地址,仅此而已。这种设计的好处是 C 语言天然支持:会操作指针,就会操作外设。
标准写法是用一个指向无符号 32 位整数的指针常量把地址"钉"下来:
#include <stdint.h> /* 把外设寄存器地址定义成指针常量,直接解引用读写 */ #define REG32(addr) (*(volatile uint32_t *)(uintptr_t)(addr)) #define PERIPH_BASE 0x40000000UL #define GPIOA_BASE (PERIPH_BASE + 0x00010800UL) #define GPIOA_ODR REG32(GPIOA_BASE + 0x0CUL) /* 输出数据寄存器,偏移 0x0C */ int main(void) { GPIOA_ODR |= (1UL << 5); /* 读-改-写:把 bit5 置 1,PA5 输出高 */ GPIOA_ODR &= ~(1UL << 5); /* 把 bit5 清 0,PA5 输出低 */ while (1) { } }
其中 volatile 是嵌入式 C 的第一关键字。它告诉编译器:这个地址的值可能随时被硬件改变(或写入会引发硬件动作),每次都必须老老实实访问内存,不许缓存到寄存器、不许省略"看似多余"的读写。少了它,while ((REG32(0x40021000) & flag) == 0); 这种等待硬件置位的循环会被优化成死循环——编译器以为值不会变。凡是硬件会背着你改写的地址,一律加 volatile。
上面 GPIOA_ODR |= ... 是三步操作:读回整个寄存器、在 CPU 里改、写回整个寄存器。中断随时可能插进来,若中断里也改同一个寄存器,就可能互相覆盖——这是并发编程在寄存器层的雏形,第 6 章会用队列和临界区正规解决。
硬件设计者早预料到这个问题,GPIO 外设专门提供了 BSRR(置位/复位寄存器):低 16 位写 1 置位对应引脚,高 16 位写 1 复位对应引脚,且写 1 生效、写 0 无影响,一条写指令原子完成,不需要读回:
#define GPIOA_BSRR REG32(GPIOA_BASE + 0x10UL) GPIOA_BSRR = (1UL << 5); /* PA5 置高,原子操作 */ GPIOA_BSRR = (1UL << (5 + 16)); /* PA5 清低,同样原子 */
第 3 章点灯会统一用 BSRR 而不是 ODR,原因就在这:安全、原子、不牵连其他引脚。
Xtensa 架构的 ESP32 一样是存储映射 I/O,只是地图不同:外设基地址换成了 0x3FF 开头的段,引脚矩阵还允许任意外设信号路由到任意引脚。ESP-IDF 里你很少直接写地址,因为驱动已经包好了;但读源码会发现 REG_WRITE(GPIO_OUT_W1TS_REG, 1<<5) 这样的操作——W1TS 后缀就是"写 1 置位",与 STM32 的 BSRR 同一设计哲学。芯片厂商换来换去,思路就这么几条。
Cortex-M3 还藏着一个专为"改一个位"设计的机制:位带(bit-band)。SRAM 与 GPIO 的部分区域被映射出一片"别名区",别名区的一个 32 位字对应主区的一个位——往别名字写 1,硬件自动完成"读主字、改那一位、写回主字"的整套原子动作。比如 PB5 的输出位别名地址是 0x4222 8294(公式可查参考手册),一行 REG32(0x42228294) = 1; 就等价于前面的读改写三连。这个机制是硬件对 2.1 开头"读改写会被中断打断"问题的官方答案,代价是只覆盖部分地址区。理解它不求背公式,求的是知道"原子改位"是真实需求——需求大到硬件专门为它开了扇门。
统一编址带来一个必须知道的副作用:任何地址都能写,哪怕那里什么都没有。把外设地址敲错一位(比如把 0x4001 0800 写成 0x4002 0800),Cortex-M3 不会报错,数据被默默丢弃;若访问的是总线上"无人认领"的保留区,多数情况下会触发总线错误并升级成 HardFault。所以"写寄存器没反应"的第一反应应当是核对地址映射表,而不是怀疑芯片坏了——地址敲错不会炸,只会失效,这是嵌入式里最安静也最常见的 bug。
调试器在这种场合是决定性的:把怀疑的寄存器地址加进内存观察窗口,写入后立刻回读。读回的值与写入不符(例如恒为 0),通常是时钟没开或地址不对;读回乱跳,可能是没有 volatile 导致编译器缓存了旧值。用"写-读-对比"三步走,寄存器层的疑难杂症一大半当场现形。
不接任何外设也能验证本节内容。在调试器里(第 7 章会正式教)打开内存窗口,逐个地址看:0x08000000 处应当是栈顶初始值和一个跳转地址(那是向量表);0x20000000 处是你定义的全局变量;往 0x4001080C 写 0xFFFF,再读回来——如果读回 0,先想想为什么(GPIOA 时钟还没开,2.3 节揭晓)。这个"用地址穿行芯片"的游戏玩熟了,你就真正拥有了本章开头承诺的空间感。下一节我们接着看:编译出来的程序是怎么被铺进这些地址的。