2. GPIO 输入输出基础:从寄存器到 HAL 库
GPIO,说白了就是单片机跟外界打交道的最基本通道。我刚开始学 STM32 的时候,觉得不就是个引脚嘛,能有多复杂?后来做项目才发现,光是一个推挽和开漏的选择,就能让板子工作不正常。这一章,咱们就把 GPIO 的底裤扒干净。
2.1 GPIO 模式详解
STM32 的每个 GPIO 引脚都可以配置成多种模式。你想想看,一个引脚既要能输出高电平点亮 LED,又要能检测按键按下,还要能模拟通信协议,它内部得有多复杂?
2.1.1 推挽输出 vs 开漏输出
这是最常用的两种输出模式,也是面试最爱问的。
| 特性 | 推挽输出 (Push-Pull) | 开漏输出 (Open-Drain) |
|---|---|---|
| 输出高电平 | 由内部 PMOS 直接驱动到 VDD | 必须外接上拉电阻 |
| 输出低电平 | 由内部 NMOS 驱动到 GND | 由内部 NMOS 驱动到 GND |
| 驱动能力 | 强,可同时提供和吸收电流 | 只能吸收电流(灌电流) |
| 典型应用 | 驱动 LED、普通数字信号 | I2C 总线、电平转换、多路复用 |
推挽输出:内部有两个管子(一个推,一个挽)。输出 1 时,上管导通,直接怼到 VDD;输出 0 时,下管导通,拉到 GND。速度快,驱动强。我习惯用它来驱动 LED,简单粗暴。
开漏输出:只有下管,上管被砍掉了。输出 0 时,下管导通拉低;输出 1 时,下管断开,引脚处于高阻态。所以必须外接一个上拉电阻到 VDD,才能输出高电平。为什么要这么麻烦?因为多个开漏输出的引脚可以“线与”在一起,任何一个拉低,总线就是低电平。I2C 协议就是靠这个工作的。
2.1.2 上拉与下拉
输入模式下,引脚不能悬空。悬空意味着电平不确定,读到的值可能是 0 也可能是 1,完全看心情。STM32 内部集成了上拉和下拉电阻,省掉了外部电阻。
- 上拉输入:内部接一个约 40kΩ 的电阻到 VDD。默认读到的就是高电平。按键按下,引脚接地,读到低电平。
- 下拉输入:内部接一个约 40kΩ 的电阻到 GND。默认读到的就是低电平。按键按下,引脚接 VDD,读到高电平。
- 浮空输入:内部不上拉也不下拉。引脚电平完全由外部决定。适合外部已经有驱动电路的场景。
2.2 GPIO 寄存器详解
用 HAL 库之前,我建议你先看看寄存器。不是为了炫技,而是为了理解底层到底发生了什么。STM32 的 GPIO 寄存器不多,但每个都很关键。
2.2.1 端口配置寄存器 (GPIOx_CRL / CRH)
这两个寄存器决定了每个引脚的模式和速度。CRL 控制低 8 位(Pin 0-7),CRH 控制高 8 位(Pin 8-15)。每个引脚占用 4 个 bit。
| CNF[1:0] | MODE[1:0] | 配置模式 |
|---|---|---|
| 00 | 00 | 模拟输入 |
| 01 | 00 | 浮空输入 |
| 10 | 00 | 上拉/下拉输入 |
| 00 | 01/10/11 | 推挽输出 (10MHz/2MHz/50MHz) |
| 10 | 01/10/11 | 复用功能推挽输出 |
| 11 | 01/10/11 | 复用功能开漏输出 |
MODE 位除了配置模式,还配置了输出速度。速度越高,功耗越大,EMI 也越强。我一般 LED 用 2MHz,SPI 用 50MHz,够用就行,别盲目开最高速。
2.2.2 端口输出数据寄存器 (GPIOx_ODR)
写这个寄存器,就能控制引脚输出高电平还是低电平。比如 ODR 的第 0 位写 1,PA0 就输出高电平;写 0,就输出低电平。
2.2.3 端口输入数据寄存器 (GPIOx_IDR)
读这个寄存器,就能知道引脚当前的电平状态。IDR 是只读的,你只能读,不能写。
2.3 实战:使用寄存器点亮 LED
咱们来点个灯。假设 LED 接在 PA0 上,高电平点亮。用寄存器操作,代码非常直接。
// 开启 GPIOA 时钟
RCC->APB2ENR |= (1 << 2); // RCC_APB2ENR 的第 2 位是 IOPAEN
// 配置 PA0 为推挽输出,50MHz
GPIOA->CRL &= ~(0xF << 0); // 先清零 CNF0 和 MODE0
GPIOA->CRL |= (0x3 << 0); // MODE0 = 11 (50MHz), CNF0 = 00 (推挽)
// 点亮 LED
GPIOA->ODR |= (1 << 0); // PA0 输出高电平
// 熄灭 LED
GPIOA->ODR &= ~(1 << 0); // PA0 输出低电平
你看,就这么几行。但要注意,操作 CRL 时一定要先清零再赋值,否则会影响到其他引脚。我见过有人直接赋值,结果把其他引脚的模式改乱了,查了半天。
2.4 实战:使用 HAL 库点亮 LED
HAL 库封装了上面的寄存器操作,用起来更安全,也更可读。但代价是代码体积变大,执行速度变慢。对于 LED 这种低速操作,完全没问题。
// 定义 GPIO 初始化结构体
GPIO_InitTypeDef GPIO_InitStruct = {0};
// 开启 GPIOA 时钟
__HAL_RCC_GPIOA_CLK_ENABLE();
// 配置 PA0
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出
GPIO_InitStruct.Pull = GPIO_NOPULL; // 不上拉也不下拉
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 低速
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 点亮 LED
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);
// 熄灭 LED
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET);
// 翻转 LED
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_0);
2.5 实战:按键输入检测
按键检测比 LED 麻烦一点,因为存在机械抖动。按键按下和释放的瞬间,电平会跳变多次,如果不做处理,程序会误判为多次按下。
2.5.1 硬件去抖 vs 软件去抖
硬件去抖:在按键两端并联一个 0.1μF 的电容。简单有效,但增加 BOM 成本。
软件去抖:检测到电平变化后,延时 10-20ms 再读一次。如果电平一致,就确认是有效动作。不花钱,但占用 CPU 时间。
我一般用软件去抖,因为省成本。但要注意,延时不能用 HAL_Delay() 这种阻塞延时,否则会影响系统实时性。可以用定时器或者状态机来实现非阻塞去抖。
2.5.2 寄存器方式实现按键检测
假设按键接在 PA1,按下为低电平(外部上拉)。
// 配置 PA1 为上拉输入
GPIOA->CRL &= ~(0xF << 4); // 清零 CNF1 和 MODE1
GPIOA->CRL |= (0x8 << 4); // CNF1 = 10 (上拉/下拉), MODE1 = 00 (输入)
// 读取按键状态
if ((GPIOA->IDR & (1 << 1)) == 0) {
// 按键按下
// 延时去抖
for (volatile uint32_t i = 0; i < 100000; i++);
if ((GPIOA->IDR & (1 << 1)) == 0) {
// 确认按键按下
}
}
2.5.3 HAL 库方式实现按键检测
// 配置 PA1 为上拉输入
GPIO_InitStruct.Pin = GPIO_PIN_1;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 读取按键状态
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET) {
// 按键按下
HAL_Delay(20); // 延时去抖
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET) {
// 确认按键按下
}
}
2.6 本章知识结构图
下面这张图把 GPIO 的核心知识点串起来了,你可以对照着回顾一下。
嗯,这一章的内容就到这儿。GPIO 看似简单,但它是所有外设的基础。推挽和开漏的区别、上拉和下拉的应用、寄存器和 HAL 库的对比,这些搞清楚了,后面的章节你会轻松很多。