2. GPIO 基础:从寄存器到点亮第一盏灯
GPIO,全称 General-Purpose Input/Output,通用输入输出口。说白了,就是芯片上那些可以编程控制的引脚。你可以让它输出高电平(3.3V)或低电平(0V),也可以读取外部信号是高还是低。
我刚开始学 STM32 的时候,觉得 GPIO 不就是个开关嘛,有啥好学的?后来做项目才发现,GPIO 的工作模式选不对,轻则功能异常,重则烧芯片。嗯,这一节我们就把 GPIO 彻底讲透。
2.1 GPIO 的四种工作模式
STM32 的 GPIO 引脚,可以配置成四种基本模式。我习惯把它们分成两组:输出模式和输入模式。
| 模式 | 方向 | 典型应用 |
|---|---|---|
| 推挽输出 | 输出 | 驱动 LED、普通数字信号输出 |
| 开漏输出 | 输出 | I2C 总线、电平转换、多路信号线与 |
| 浮空输入 | 输入 | 读取外部数字信号(按键、传感器) |
| 上拉/下拉输入 | 输入 | 防止引脚悬空、按键检测(默认电平) |
推挽输出(Push-Pull)
推挽输出,内部有两个 MOS 管:一个负责「推」(输出高电平),一个负责「挽」(输出低电平)。你写 1,引脚就是 3.3V;你写 0,引脚就是 0V。速度快,驱动能力强。
我在项目中用推挽输出驱动 LED 和继电器,从来没出过问题。但要注意,推挽输出不能把两个引脚直接连在一起,否则一个输出高、一个输出低,那就是短路。
开漏输出(Open-Drain)
开漏输出,内部只有下拉的 MOS 管。输出 0 时,引脚拉低到 GND;输出 1 时,引脚处于高阻态(相当于断开)。所以,开漏输出必须外接上拉电阻到 VCC,才能输出高电平。
为什么要用这么麻烦的模式?因为开漏输出可以实现「线与」功能——多个开漏输出可以共用一个上拉电阻,任何一个输出 0,总线就被拉低。I2C 总线就是靠这个原理工作的。
浮空输入与上拉/下拉输入
浮空输入,引脚内部没有任何上拉或下拉电阻。引脚电平完全由外部电路决定。如果外部什么都没接,引脚电平就是不确定的,会乱跳。
上拉输入,内部接了一个几十千欧的电阻到 VCC。引脚默认是高电平,外部拉低才能读到 0。下拉输入则相反,默认是低电平。
我曾经在一个按键检测项目里,用了浮空输入,结果按键没按下时,引脚电平随机跳变,导致系统误触发。后来改成上拉输入,问题立刻解决。
2.2 寄存器映射与位操作
STM32 的 GPIO 控制,本质就是读写寄存器。每个 GPIO 端口(如 GPIOA、GPIOB)都有一组寄存器,映射到固定的内存地址。
举个例子,GPIOA 的基地址是 0x40020000。它的控制寄存器都在这个基地址上偏移。常用的几个寄存器:
| 寄存器 | 偏移 | 功能 |
|---|---|---|
| GPIOx_MODER | 0x00 | 模式选择(输入、输出、复用、模拟) |
| GPIOx_OTYPER | 0x04 | 输出类型(推挽、开漏) |
| GPIOx_PUPDR | 0x0C | 上拉/下拉配置 |
| GPIOx_ODR | 0x14 | 输出数据寄存器 |
| GPIOx_IDR | 0x10 | 输入数据寄存器 |
| GPIOx_BSRR | 0x18 | 位设置/复位寄存器 |
操作寄存器,最常用的就是位操作。C 语言里,我们用 &(与)、|(或)、~(取反)、<<(左移)来操作特定位。
比如,要把 GPIOA 的第 5 脚设为输出模式:
// MODER 每两位控制一个引脚:00=输入,01=输出,10=复用,11=模拟
// 先清除第 5 脚对应的两位(位 10 和位 11)
GPIOA->MODER &= ~(0x3 << (5 * 2));
// 再设置第 5 脚为输出模式(01)
GPIOA->MODER |= (0x1 << (5 * 2));
我个人习惯用 BSRR 寄存器来设置或复位输出。BSRR 的低 16 位写 1 对应置位,高 16 位写 1 对应复位。这样一条指令就能完成,而且不会影响其他引脚。
// 置位 PA5(输出高电平)
GPIOA->BSRR = (1 << 5);
// 复位 PA5(输出低电平)
GPIOA->BSRR = (1 << (5 + 16));
2.3 点亮 LED 灯实战
理论说完了,我们来点一盏灯。这是嵌入式开发的「Hello World」。我当年第一次点亮 LED 的时候,那种成就感,到现在还记得。
假设 LED 接在 PA5 引脚,正极通过电阻连到 PA5,负极接地。那么 PA5 输出高电平时,LED 亮;输出低电平时,LED 灭。
完整代码(基于寄存器操作,不使用 HAL 库):
#include "stm32f4xx.h"
void delay(volatile uint32_t count) {
while (count--) {
__NOP(); // 空操作,消耗一个指令周期
}
}
int main(void) {
// 1. 使能 GPIOA 时钟
// RCC_AHB1ENR 的位 0 控制 GPIOA 时钟
RCC->AHB1ENR |= (1 << 0);
// 2. 配置 PA5 为输出模式
GPIOA->MODER &= ~(0x3 << (5 * 2)); // 先清除
GPIOA->MODER |= (0x1 << (5 * 2)); // 设为输出
// 3. 配置 PA5 为推挽输出(默认就是推挽,这一步可省略)
GPIOA->OTYPER &= ~(1 << 5);
// 4. 配置 PA5 无上拉/下拉(输出模式不需要)
GPIOA->PUPDR &= ~(0x3 << (5 * 2));
while (1) {
// 点亮 LED
GPIOA->BSRR = (1 << 5);
delay(500000);
// 熄灭 LED
GPIOA->BSRR = (1 << (5 + 16));
delay(500000);
}
}
代码逻辑很清晰:
- 使能时钟:STM32 的外设默认时钟是关闭的,必须先打开 GPIOA 的时钟。
- 配置模式:MODER 寄存器设 PA5 为输出。
- 配置类型:OTYPER 设推挽(默认值,可省略)。
- 配置上下拉:输出模式不需要上下拉。
- 循环闪烁:用 BSRR 置位和复位,中间加延时。
2.4 GPIO 知识体系总览
下面这张图,把 GPIO 的核心知识点串起来了。你可以把它当作一个快速参考。
GPIO 看似简单,但它是所有外设控制的基础。推挽输出驱动能力强,开漏输出适合总线通信,上拉输入防悬空。寄存器操作虽然比 HAL 库繁琐,但能让你真正理解芯片的工作原理。
我建议你亲手敲一遍上面的代码,用示波器或者逻辑分析仪看看波形。当你看到 LED 按照你的代码闪烁时,那种感觉,嗯,就是嵌入式开发的开始。