2. GPIO 基础与寄存器映射:从原理到点亮第一颗 LED
好,咱们正式开始动手了。这一章是嵌入式开发的“Hello World”——点亮 LED。但我不打算让你直接抄代码跑一下了事。咱们得把 GPIO 到底是什么、怎么控制它、寄存器地址怎么来的,这些底层的逻辑理清楚。
你想想看,一个 LED 亮起来,背后是 CPU 通过总线往某个物理地址写了几个 bit。就这么简单,也这么核心。搞懂了这一章,后面所有外设驱动你都能举一反三。
2.1 GPIO 模式详解:不只是输入输出
很多初学者以为 GPIO 就是“输入”和“输出”两种模式。其实 STM32 的 GPIO 远不止这么简单。我个人习惯把 GPIO 模式分成四大类,每一类都有它的应用场景。
| 模式类别 | 具体模式 | 典型用途 |
|---|---|---|
| 输入模式 | 浮空输入、上拉输入、下拉输入、模拟输入 | 读取按键、检测电平、ADC 采样 |
| 输出模式 | 推挽输出、开漏输出 | 驱动 LED、控制继电器、I2C 总线 |
| 复用功能模式 | 复用推挽输出、复用开漏输出 | USART、SPI、I2C 等外设引脚 |
| 模拟模式 | 模拟输入/输出 | ADC 输入、DAC 输出、比较器输入 |
这里我重点说一下输出模式里的推挽和开漏。推挽输出,说白了就是引脚能主动拉高也能主动拉低,驱动能力强。开漏输出呢,只能拉低,拉高要靠外部上拉电阻。我在项目中遇到过用开漏输出做 I2C 总线的情况——多个设备共用一根线,谁拉低谁说话,不会打架。
2.2 寄存器地址映射:从物理地址到 C 语言指针
GPIO 怎么配置?本质就是往特定的寄存器地址写值。STM32 的 GPIO 外设挂载在 AHB 总线 上,每个 GPIO 端口(如 GPIOA、GPIOB)占用 1024 字节的地址空间。
以 STM32F103 为例,GPIOA 的基地址是 0x40010800。这个地址怎么来的?
- 外设基地址:
0x40000000(APB1 外设) - AHB 外设偏移:
0x00020000 - GPIOA 在 AHB 上的偏移:
0x00000800 - 所以 GPIOA 基地址 =
0x40000000 + 0x00020000 + 0x00000800 = 0x40010800
每个 GPIO 端口有 7 个 32 位寄存器,偏移地址如下:
| 寄存器 | 偏移地址 | 功能描述 |
|---|---|---|
| GPIOx_CRL | 0x00 | 端口配置低寄存器(Pin 0~7) |
| GPIOx_CRH | 0x04 | 端口配置高寄存器(Pin 8~15) |
| GPIOx_IDR | 0x08 | 端口输入数据寄存器 |
| GPIOx_ODR | 0x0C | 端口输出数据寄存器 |
| GPIOx_BSRR | 0x10 | 端口位设置/清除寄存器 |
| GPIOx_BRR | 0x14 | 端口位清除寄存器 |
| GPIOx_LCKR | 0x18 | 端口配置锁定寄存器 |
2.3 使用指针操作寄存器:点亮 LED 的完整流程
好了,理论讲完了,咱们直接上代码。点亮一个 LED 需要三步:
- 开启 GPIO 时钟 —— 不开启时钟,寄存器写不进去。
- 配置 GPIO 模式 —— 设置推挽输出,速度 50MHz。
- 设置输出电平 —— 写 ODR 或 BSRR 寄存器。
下面是一个完整的示例,用指针操作寄存器点亮 PA0 上的 LED:
// 定义寄存器地址
#define PERIPH_BASE ((uint32_t)0x40000000)
#define APB2PERIPH_BASE (PERIPH_BASE + 0x10000)
#define GPIOA_BASE (APB2PERIPH_BASE + 0x0800)
#define RCC_BASE (APB2PERIPH_BASE + 0x1000)
// 寄存器偏移
#define RCC_APB2ENR (*(volatile uint32_t *)(RCC_BASE + 0x18))
#define GPIOA_CRL (*(volatile uint32_t *)(GPIOA_BASE + 0x00))
#define GPIOA_ODR (*(volatile uint32_t *)(GPIOA_BASE + 0x0C))
// 位定义
#define RCC_APB2ENR_IOPAEN (1 << 2) // GPIOA 时钟使能位
#define GPIO_Pin0 (1 << 0)
int main(void)
{
// 1. 开启 GPIOA 时钟
RCC_APB2ENR |= RCC_APB2ENR_IOPAEN;
// 2. 配置 PA0 为推挽输出,50MHz
// CRL 每 4 位控制一个引脚:MODE[1:0] + CNF[1:0]
// 推挽输出 50MHz = MODE=11, CNF=00
GPIOA_CRL &= ~(0x0F << 0); // 先清除低 4 位
GPIOA_CRL |= (0x03 << 0); // 设置 MODE=11, CNF=00
// 3. 设置 PA0 输出高电平
GPIOA_ODR |= GPIO_Pin0;
while(1)
{
// 循环等待
}
}
这段代码里,我用了 volatile 关键字。为什么?因为寄存器值可能被硬件修改,编译器不能优化掉对这些地址的访问。嗯,这里要注意,不加 volatile,有时候优化后的代码会跳过你的写操作,LED 死活不亮。
2.4 知识体系总览
下面这张图把本章的核心知识点串起来了。你可以把它当作一个思维导图,每次写 GPIO 驱动时对照着看。
2.5 避坑指南与个人经验
最后,分享几个我踩过的坑,希望能帮你少走弯路。
- 时钟没开就操作寄存器: 我曾经在移植代码时忘了开 GPIO 时钟,结果读 IDR 永远返回 0,查了半天才发现是时钟没使能。记住,STM32 的外设默认时钟都是关闭的。
- CRL 和 CRH 搞混: 前面说过了,Pin 0~7 用 CRL,Pin 8~15 用 CRH。别问我怎么知道的——我 debug 了一下午。
- ODR 和 BSRR 的选择: 如果你要同时设置多个引脚的电平,用 BSRR 更高效,因为它可以一次完成置位和复位,而且不需要读-改-写操作。
- volatile 不能省: 没有 volatile,编译器可能把寄存器访问优化掉,尤其是你在循环里反复读同一个寄存器时。