4. GPIO 编程:从寄存器到灯亮
GPIO,说白了就是芯片的「手脚」。你要让芯片对外输出信号,或者读取外部电平,都得靠它。我刚开始学嵌入式那会儿,觉得 GPIO 不就是置 1 清 0 嘛,有啥好学的?后来踩了几个坑才明白——模式配不对,灯都不带亮的。
4.1 GPIO 模式配置:推挽、开漏、上拉、下拉
每个 GPIO 引脚,都可以配置成好几种模式。选错了,信号就出不来。咱们一个一个说。
| 模式 | 典型用途 | 内部结构 |
|---|---|---|
| 推挽输出 | 驱动 LED、普通数字信号输出 | PMOS + NMOS,交替导通 |
| 开漏输出 | I²C 总线、多设备共享线路 | 只有 NMOS,高阻需外接上拉 |
| 上拉输入 | 按键检测(默认高电平) | 内部弱上拉电阻 |
| 下拉输入 | 按键检测(默认低电平) | 内部弱下拉电阻 |
推挽输出
推挽输出,就是芯片内部有两个管子:一个推上去输出高电平,一个拉下来输出低电平。速度快,驱动能力强。我一般驱动 LED 或者给其他芯片发控制信号时,都用推挽。
开漏输出
开漏输出就只有一个下拉管。输出 0 的时候能拉低,输出 1 的时候……嗯,它不管了,引脚悬空。所以必须外部接一个上拉电阻到 VCC。为什么要有这种奇怪模式?你想想看,I²C 总线上挂了好几个设备,如果都用推挽,两个设备同时输出一个高一个低,那就短路了。开漏模式配合「线与」逻辑,谁拉低谁说了算。
上拉/下拉输入
按键检测最常用。按键没按下时,引脚电平不确定,必须靠内部上拉或下拉电阻给它一个确定的电平。我个人习惯:按键接 GND,引脚配内部上拉。这样按键按下就是低电平,代码里检测下降沿很方便。
4.2 点亮 LED 与按键输入
理论说完了,咱们直接上手。下面是一个典型的 LED + 按键例程。硬件连接:LED 接 PA0(推挽输出),按键接 PA1(上拉输入,按下为低)。
#include "stm32f10x.h"
void GPIO_Init(void)
{
RCC->APB2ENR |= (1 << 2); // 开启 GPIOA 时钟
// PA0: 推挽输出,50MHz
GPIOA->CRL &= ~(0xF << 0); // 先清空配置位
GPIOA->CRL |= (0x3 << 0); // 0x3 = 推挽输出 50MHz
// PA1: 上拉输入
GPIOA->CRL &= ~(0xF << 4); // 清空
GPIOA->CRL |= (0x8 << 4); // 0x8 = 上拉/下拉输入
GPIOA->ODR |= (1 << 1); // 使能内部上拉
}
int main(void)
{
GPIO_Init();
while(1)
{
if((GPIOA->IDR & (1 << 1)) == 0) // 按键按下
{
GPIOA->BSRR = (1 << 0); // PA0 输出高,LED 亮
}
else
{
GPIOA->BRR = (1 << 0); // PA0 输出低,LED 灭
}
}
}
4.3 位带操作:让 GPIO 像 51 一样爽
用寄存器操作 GPIO,代码写起来有点啰嗦。每次都要读-改-写。有没有更直接的办法?有,位带操作。
位带操作,就是把一个 32 位的字,映射到 1 个 bit 上。你往这个地址写 1,对应的 GPIO 引脚就输出高;写 0 就输出低。不需要读寄存器,直接写就行。
STM32 的位带区域在 0x20000000 ~ 0x200FFFFF(SRAM)和 0x40000000 ~ 0x400FFFFF(外设)。GPIO 的 ODR 寄存器就在外设位带区。
计算公式:
bit_word_addr = bit_band_base + (byte_offset * 32) + (bit_number * 4)
举个例子,PA0 的 ODR 位:
// GPIOA->ODR 地址: 0x4001080C
// PA0 对应 bit 0
// 位带别名地址 = 0x42000000 + (0x0001080C * 32) + (0 * 4)
// = 0x42000000 + 0x00210180
// = 0x42210180
#define PA0_OUT (*(volatile uint32_t *)0x42210180)
// 使用
PA0_OUT = 1; // PA0 输出高
PA0_OUT = 0; // PA0 输出低
是不是简洁多了?我个人习惯把常用的 GPIO 位带地址都定义成宏,代码可读性高很多。
- 原子操作:读-改-写不会被中断打断
- 代码简洁:一行搞定,不需要 &= 和 |=
- 速度快:直接写内存,比读-改-写少一条指令
4.4 寄存器映射:从物理地址到 C 语言结构体
为什么我们可以用 GPIOA->ODR = 0x01 这种写法?这背后就是寄存器映射。
芯片厂商在头文件里,把外设的基地址定义好,然后用结构体把寄存器排列起来。比如:
// GPIO 寄存器结构体
typedef struct
{
volatile uint32_t CRL; // 0x00
volatile uint32_t CRH; // 0x04
volatile uint32_t IDR; // 0x08
volatile uint32_t ODR; // 0x0C
volatile uint32_t BSRR; // 0x10
volatile uint32_t BRR; // 0x14
volatile uint32_t LCKR; // 0x18
} GPIO_TypeDef;
// GPIOA 基地址: 0x40010800
#define GPIOA ((GPIO_TypeDef *)0x40010800)
这样,GPIOA->ODR 就等价于 *(uint32_t *)(0x40010800 + 0x0C)。结构体成员的偏移量必须和硬件寄存器地址完全对齐,中间不能有空洞。所以 volatile 关键字不能少——告诉编译器,这个变量可能被硬件修改,别优化掉了。
我曾经遇到过一个问题:调试时发现 GPIO 输出不对,单步执行没问题,全速跑就不行。后来发现是编译器把 GPIOA->ODR = 0 优化掉了,因为后面没再读它。加上 volatile 就好了。
4.5 本章知识结构图
下面这张图,把 GPIO 编程的核心知识点串起来了。从模式配置到寄存器操作,再到位带优化,一条线走到底。
4.6 避坑指南
- 时钟没开: 操作 GPIO 之前,一定要先开启对应端口的时钟。我见过有人配了半天寄存器,结果 GPIO 纹丝不动——RCC 没使能。
- 模式配错: 推挽输出配成了开漏,LED 不亮;输入配成了输出,按键读不到。配置前先想清楚:这个引脚到底是输入还是输出?
- 位带地址算错: 位带别名地址的计算公式容易出错。我建议你算完后,用调试器读一下那个地址的值,确认和 ODR 寄存器一致。
- volatile 漏掉: 寄存器映射的结构体成员,必须加 volatile。否则编译器优化后,你可能读到的是缓存值,不是真实硬件状态。