3. GPIO实战:点亮LED灯、按键输入检测、GPIO位带操作
好,咱们直接进入正题。GPIO 是嵌入式开发里最基础、也最常用的外设。说白了,它就是芯片和外界打交道的一扇门——你可以让这扇门输出高电平、低电平,也可以读取外面是 0 还是 1。
这一章,我会带你从零开始,用 STM32 LL 库把 GPIO 玩明白。咱们不做花哨的演示,就聚焦三个最典型的场景:点亮 LED、读取按键、位带操作。这三个东西搞懂了,GPIO 这块你就拿下了七八成。
本章核心目标
- 掌握 LL 库 GPIO 初始化流程
- 理解推挽输出与上拉输入的区别
- 学会用位带操作实现原子级读写
- 能独立完成 LED 闪烁 + 按键控制逻辑
3.1 点亮LED:从寄存器到LL库
点亮 LED 是嵌入式界的 "Hello World"。我记得刚入行那会儿,第一次让板子上的小灯亮起来,那种成就感至今难忘。但点亮一个灯背后,其实藏着 GPIO 最核心的配置逻辑。
先看硬件连接。大多数开发板把 LED 接在 GPIO 引脚上,另一端通过限流电阻连到 VCC 或 GND。以正点原子探索者为例,板载 LED 接在 PF9 和 PF10,低电平点亮。
用 LL 库点亮 LED,分三步走:
- 使能 GPIO 时钟 —— 不打开时钟,外设就是死的
- 配置 GPIO 模式 —— 推挽输出、速度、上下拉
- 写电平 —— 置高或置低
来看代码:
// 1. 使能 GPIOF 时钟
LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_GPIOF);
// 2. 配置 PF9 为推挽输出,速度 50MHz,无上下拉
LL_GPIO_SetPinMode(GPIOF, LL_GPIO_PIN_9, LL_GPIO_MODE_OUTPUT);
LL_GPIO_SetPinSpeed(GPIOF, LL_GPIO_PIN_9, LL_GPIO_SPEED_FREQ_HIGH);
LL_GPIO_SetPinPull(GPIOF, LL_GPIO_PIN_9, LL_GPIO_PULL_NO);
// 3. 输出低电平,点亮 LED
LL_GPIO_ResetOutputPin(GPIOF, LL_GPIO_PIN_9);
这里有个细节我想强调一下:LL_GPIO_SetPinMode 这个函数,它其实是在操作 GPIO 的 MODER 寄存器。LL 库的好处就是,函数名直接告诉你它在干什么,不像 HAL 库那样层层封装。
我的习惯:在初始化 GPIO 时,我会把速度统一设成 HIGH。虽然低速模式更省电、EMI 更小,但开发阶段用高速模式可以避免一些时序问题。等产品定型了再根据实际需求优化。
如果你想让 LED 闪烁,加个延时循环就行:
while (1) {
LL_GPIO_TogglePin(GPIOF, LL_GPIO_PIN_9); // 翻转电平
for (volatile uint32_t i = 0; i < 500000; i++); // 简单延时
}
嗯,这里要注意:LL_GPIO_TogglePin 是个好函数,它用异或操作实现电平翻转,比先读再写要快得多。我在做 LED 呼吸灯效果时经常用它。
3.2 按键输入检测:消抖与边沿
按键输入比输出稍微复杂一点。为什么?因为机械按键有抖动。你按下去那一下,电平会在高低之间跳好几次,如果不处理,程序会误判成多次按下。
先看硬件。按键通常一端接 GPIO,另一端接 GND。内部上拉电阻使引脚默认高电平,按下时拉低。所以检测逻辑是:读到低电平 = 按键按下。
配置代码:
// 使能 GPIOA 时钟
LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_GPIOA);
// PA0 配置为输入,上拉
LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_0, LL_GPIO_MODE_INPUT);
LL_GPIO_SetPinPull(GPIOA, LL_GPIO_PIN_0, LL_GPIO_PULL_UP);
读取按键状态用 LL_GPIO_IsInputPinSet:
if (LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0) == 0) {
// 按键按下
}
但直接这么用会出问题。我在一个项目中就吃过这个亏——按键按下一次,LED 状态跳了好几次。原因就是没做消抖。
消抖有两种常见方式:
| 方式 | 原理 | 适用场景 |
|---|---|---|
| 硬件消抖 | RC 滤波电路,电容充放电 | 对成本不敏感、要求高可靠 |
| 软件消抖 | 延时 10~20ms 后再次检测 | 大多数嵌入式项目 |
我个人更推荐软件消抖,灵活且不增加 BOM 成本。一个典型的消抖流程:
if (LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0) == 0) {
// 延时 20ms
for (volatile uint32_t i = 0; i < 20000; i++);
// 再次检测
if (LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0) == 0) {
// 确认按键按下
while (LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0) == 0); // 等待释放
// 执行按键动作
}
}
我曾经踩过的坑:在 while 等待按键释放时,如果按键卡住或者用户一直按着,程序会卡死在这里。后来我加了一个超时退出机制,比如等待超过 1 秒就强制退出循环。这个在工业产品里特别重要。
3.3 GPIO位带操作:原子级的读写
位带操作是 Cortex-M3/M4 内核的一个特色功能。你想想看,普通的 GPIO 读写,你得先读整个寄存器的值,然后修改某一位,再写回去。这个过程不是原子的,如果在多中断环境下,可能会出问题。
位带操作就解决了这个问题。它把每个 bit 映射到一个 32 位的地址空间。你直接往这个地址写 0 或 1,就能单独控制那个引脚,不影响其他位。
STM32 的位带区域有两个:
- SRAM 位带区:0x2000 0000 ~ 0x200F FFFF
- 外设位带区:0x4000 0000 ~ 0x400F FFFF
GPIO 的寄存器就在外设位带区。映射公式如下:
位带别名地址 = 位带基址 + (字节偏移 × 32) + (位号 × 4)
听起来有点抽象?我直接给你一个宏定义,拿来就能用:
// GPIOF ODR 寄存器的位带别名地址
#define BITBAND_GPIOF_ODR(bit) (0x42000000 + ((uint32_t)&GPIOF->ODR - 0x40000000) * 32 + (bit) * 4)
// 使用示例:单独设置 PF9
#define LED_PF9 BITBAND_GPIOF_ODR(9)
// 点亮 LED
*(volatile uint32_t *)LED_PF9 = 0;
// 熄灭 LED
*(volatile uint32_t *)LED_PF9 = 1;
这段代码里,0x42000000 是外部位带别名区的起始地址。0x40000000 是外设位带区的起始地址。两者相减得到偏移量,再乘以 32 加上位号乘以 4,就得到了该位的别名地址。
位带操作的优势
- 原子操作:读-改-写 一步完成,不会被中断打断
- 速度快:直接内存访问,比调用函数快
- 代码简洁:一行代码控制一个引脚
我在做 LED 点阵屏驱动时,就用位带操作来刷新每一行。因为刷新频率高,用普通方式会有闪烁,位带操作就稳得很。
不过也要注意,位带操作只适用于 可位寻址的寄存器,比如 ODR、IDR、BSRR。像 MODER、PUPDR 这些配置寄存器,位带操作不适用,得老老实实用 LL 库函数。
3.4 本章知识结构
下面这张图,把 GPIO 实战的三个核心知识点串起来了。你可以把它当作一个快速索引:
3.5 综合实战:按键控制LED
把上面三个知识点串起来,做一个完整的例子:按一下按键,LED 状态翻转。
// 初始化
void GPIO_Init(void) {
// 使能时钟
LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_GPIOF);
LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_GPIOA);
// LED PF9 推挽输出
LL_GPIO_SetPinMode(GPIOF, LL_GPIO_PIN_9, LL_GPIO_MODE_OUTPUT);
LL_GPIO_SetPinSpeed(GPIOF, LL_GPIO_PIN_9, LL_GPIO_SPEED_FREQ_HIGH);
LL_GPIO_SetPinPull(GPIOF, LL_GPIO_PIN_9, LL_GPIO_PULL_NO);
// 按键 PA0 上拉输入
LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_0, LL_GPIO_MODE_INPUT);
LL_GPIO_SetPinPull(GPIOA, LL_GPIO_PIN_0, LL_GPIO_PULL_UP);
}
// 主循环
int main(void) {
GPIO_Init();
while (1) {
if (LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0) == 0) {
// 消抖延时
for (volatile uint32_t i = 0; i < 20000; i++);
if (LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0) == 0) {
// 翻转 LED
LL_GPIO_TogglePin(GPIOF, LL_GPIO_PIN_9);
// 等待释放,带超时
uint32_t timeout = 100000;
while (LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0) == 0) {
if (--timeout == 0) break;
}
}
}
}
}
这个例子虽然简单,但包含了 GPIO 开发的核心套路:初始化 → 轮询 → 消抖 → 动作。你把这个框架吃透了,后面做更复杂的驱动,比如矩阵键盘、段式 LCD,思路都是一样的。
一点补充:实际产品中,按键检测一般不会用这种阻塞式延时。更好的做法是用定时器做 10ms 周期扫描,或者用外部中断 + 定时器消抖。这些内容我们会在后面的章节展开。