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,分三步走:

  1. 使能 GPIO 时钟 —— 不打开时钟,外设就是死的
  2. 配置 GPIO 模式 —— 推挽输出、速度、上下拉
  3. 写电平 —— 置高或置低

来看代码:

// 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 实战的三个核心知识点串起来了。你可以把它当作一个快速索引:

GPIO实战知识结构 GPIO 外设驱动 点亮 LED 按键输入检测 位带操作 时钟使能 推挽输出 电平翻转 上拉输入 软件消抖 边沿检测 别名地址映射 原子操作 高速刷新 核心原则 先时钟 → 再模式 → 后读写;位带操作只用于数据寄存器,不用于配置寄存器

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 周期扫描,或者用外部中断 + 定时器消抖。这些内容我们会在后面的章节展开。