3.1 GPIO 寄存器详解与点亮流程 第 2 章把地址、落位、时钟三块地基打好了,本节正式兑现全书承诺:不碰任何库函数,只靠参考手册和几行寄存器代码,点亮板子上的 LED。这一节是全书的主调所在——把点亮一颗 LED 的每个动作都追到寄存器的某个位,之后所有章节都会不时回到这条基准线。 先认识 GPIO 端口的寄存器全家福 STM32F103 上每个 GPIO 端口(GPIOA 到 GPIOG)都是一组布局完全相同的寄存器块,只差基地址(GPIOA 在 0x4001 0800,依次排开)。
第 2 章把地址、落位、时钟三块地基打好了,本节正式兑现全书承诺:不碰任何库函数,只靠参考手册和几行寄存器代码,点亮板子上的 LED。这一节是全书的主调所在——把点亮一颗 LED 的每个动作都追到寄存器的某个位,之后所有章节都会不时回到这条基准线。
STM32F103 上每个 GPIO 端口(GPIOA 到 GPIOG)都是一组布局完全相同的寄存器块,只差基地址(GPIOA 在 0x4001 0800,依次排开)。常用成员有六个:
| 寄存器 | 偏移 | 属性 | 作用 |
|---|---|---|---|
| CRL / CRH | 0x00 / 0x04 | 读写 | 每引脚 4 位配置:模式 + 电气形态 |
| IDR | 0x08 | 只读 | 输入采样:16 个引脚的当前电平 |
| ODR | 0x0C | 读写 | 输出锁存:决定输出电平 |
| BSRR | 0x10 | 只写 | 置位/复位:原子改输出 |
| BRR | 0x14 | 只写 | 复位:原子输出低 |
| LCKR | 0x18 | 读写 | 引脚配置冻结(少用) |
关键是 CRL 与 CRH 的编码方式。32 位寄存器按每 4 位一组切成 8 组,CRL 管 0-7 号引脚、CRH 管 8-15 号引脚。每个引脚的 4 位里,低 2 位 MODE 选方向与速度(00 输入,01 输出 10 MHz,10 输出 2 MHz,11 输出 50 MHz),高 2 位 CNF 选电气形态(输入时:00 模拟、01 浮空、10 上拉/下拉、11 保留;输出时:00 通用推挽、01 开漏、10 复用推挽、11 复用开漏)。引脚号乘 4 就是它在 CRL/CRH 里的位偏移——PB5 在 CRL 的第 20 到 23 位。

万事俱备,把点亮流程写成代码。三步:开时钟、配模式、写电平,一步都不能省:
#include "stm32f10x.h" void led_pb5_init(void) { /* 第 1 步:开 GPIOB 时钟。GPIOB 挂在 APB2 总线上, 时钟使能位在 RCC->APB2ENR 的 bit3(IOPBEN)。 不开时钟,后面所有寄存器写入都无效——这是新手第一大坑。 */ RCC->APB2ENR |= RCC_APB2ENR_IOPBEN; /* 第 2 步:配置 PB5 为通用推挽输出,速度 2 MHz。 PB5 由 CRL 的位 20-23 控制:先清零再填 0b0010。 */ GPIOB->CRL &= ~(0xFUL << 20); /* 清空原配置 */ GPIOB->CRL |= (0x2UL << 20); /* CNF=00 推挽,MODE=10 输出 2 MHz */ /* 第 3 步:输出高电平点亮 LED(板载 LED 接法为低电平点亮时改用 BRR)。 用 BSRR 原子置位,不干扰其他引脚。 */ GPIOB->BSRR = (1UL << 5); } int main(void) { led_pb5_init(); while (1) { /* 主循环暂时无事可做;第 4 章定时器会接管这里的节奏 */ } }
对照参考手册核对每一行:RCC_APB2ENR_IOPBEN 是 CMSIS 头文件里定义好的位掩码(数值为 0x8),它只是 (1UL << 3) 的可读写法——不引入任何函数调用,代码仍然是最纯粹的寄存器操作。烧录后运行,接在 PB5 上的 LED 应当稳定亮起。
如果没亮,按这个顺序排查,命中率从高到低:时钟没开(往 RCC 寄存器读回 APB2ENR 看 bit3)、位段算错(把 CRL 读回来打印或看调试器)、接法搞反(板载 LED 常是低电平点亮,输出低才亮)、电阻太大或 LED 极性接反。调试器在此处价值巨大:直接在内存窗口看 0x40010C00 起的 GPIOB 寄存器块,配置写没写进去一目了然。
3.3 节会详细对比推挽与开漏,这里先在寄存器层认识开漏的写法,与推挽只差 CNF 两位:
GPIOB->CRL &= ~(0xFUL << 20); GPIOB->CRL |= (0x5UL << 20); /* 4 位段整体写 0b0101: MODE=01 输出 10 MHz,CNF=01 开漏输出 */
开漏模式下"输出高"实为"放手":引脚悬空,由外部上拉电阻拽到高电平;只有"输出低"是主动拉地。因此开漏驱动 LED 时接线要反过来——LED 正极接 VCC、限流电阻到引脚,"输出低才亮"。4 位段必须整体设计而不能只改一半,这是位段拼装最容易出错的地方:CNF 与 MODE 是一个整体的 4 位,拼错组合比选错引脚更常见。先混个脸熟,3.2 节的输入上拉与 3.3 节的 I2C 总线应用会把它讲透。
把 PB5 的经验固化成两条公式,正反都能用。正向(引脚 → 位段):偏移 = 引脚号 × 4;引脚号 0-7 查 CRL、8-15 查 CRH;掩码 = 0xF 左移偏移位。反向(调试图 → 引脚):拿到 CRL 的十六进制值,先除以 16 取整定位引脚号,再对 16 取余得到该引脚的 4 位配置值。例如 CRL 读回 0x44228844,倒数第 3 组(每 2 位十六进制对应 8 位,即 4 个引脚)……这个心算练习做上几次,第 7 组、第 5 组的含义就刻在脑子里了——比每次翻手册快得多,也避开了"照抄例程改错位"的高发错误。
顺手再给一个工程习惯:初始化函数里把"读回校验"写上。配置完 CRL 后立即读回,与期望值比对,不符就置错误标志——几行代码换来现场排障时的一目了然,这笔账怎么算都划算。
点亮只是起点,工程上更常用的是翻转。 naive 的写法是"置位-延时-复位-延时"循环,用 ODR 读改写或 BSRR 都行:
void busy_delay(volatile uint32_t n) /* volatile 防止空循环被优化掉 */ { while (n--) { } } int main(void) { led_pb5_init(); while (1) { GPIOB->BSRR = (1UL << 5); /* 高:亮 */ busy_delay(500000); GPIOB->BRR = (1UL << 5); /* 低:灭,BRR 是只写复位寄存器 */ busy_delay(500000); } }
这个版本能跑,但延时长短全靠试,而且 CPU 在空转。它存在的意义是对照:第 4 章我们用定时器中断替代空转延时,用 PWM 替代手工翻转——那时你会真正体会"把时间交给硬件"的含义。此处也请注意 busy_delay 参数上的 volatile:没有它,优化开启后整个循环会被当成无用代码删除,灯常亮不闪。
把 PB5 的经验抽成流程,换任何引脚都成立。以"把 PC13 配成上拉输入"为例(蓝药丸板载 LED 恰好挂在这里,输入场景同样适用):
void pc13_input_pullup(void) { RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; /* 开 GPIOC 时钟 */ GPIOC->CRH &= ~(0xFUL << 20); /* PC13 在 CRH 的位 20-23(13-8=5,再乘 4)*/ GPIOC->CRH |= (0x8UL << 20); /* MODE=00 输入,CNF=10 上拉/下拉 */ GPIOC->ODR |= (1UL << 13); /* 输入 + CNF=10 时,ODR 该位决定上拉还是下拉 */ }
注意最后一行的新知识:输入模式配了 CNF=10 之后,上拉还是下拉由 ODR 对应位决定——写 1 上拉、写 0 下拉。这解释了为什么 ODR 叫"输出数据寄存器"却能在输入模式里起作用。三节的 GPIO 知识点就这么多:CRL/CRH 定行为,IDR 读输入,ODR/BSRR/BRR 管输出。下一节我们把 IDR 用起来接按键,并让中断替掉轮询。