第2章 GPIO实战:从流水灯到位带操作

GPIO 是嵌入式开发最基础、也最绕不开的外设。说白了,它就是芯片的「手脚」——用来输出高低电平,或者读取外部信号。这一章我们不讲虚的,直接上手做四个实战:LED流水灯、按键输入、外部中断、位带操作。每个都是我项目中反复用到的套路。

本章核心知识点:
  • GPIO 输出模式与驱动能力
  • 按键消抖的工程处理
  • 外部中断的配置与优先级
  • 位带操作的原理与加速技巧
GPIO 实战知识体系 GPIO 实战 LED 流水灯 按键输入 外部中断 位带操作 推挽输出 上拉/下拉 NVIC 配置 别名区映射 延时循环 软件消抖 中断服务函数 位带原子操作

2.1 LED流水灯——GPIO输出的基本功

流水灯是嵌入式界的「Hello World」。但你别小看它,这里藏着GPIO输出模式的选型门道。

STM32的GPIO输出模式有两种:推挽输出和开漏输出。驱动LED,我习惯用推挽输出。为什么?因为推挽输出能同时提供高电平和低电平的驱动能力,LED正极接VCC、负极接GPIO,低电平点亮,简单直接。

我的习惯:LED串联一个330Ω~1kΩ的限流电阻。别省这个电阻,我曾经见过有人直接怼,结果GPIO口烧了——STM32的IO最大灌电流也就25mA,超过就冒烟。

来看代码。初始化部分,我习惯用HAL库的HAL_GPIO_Init(),但底层寄存器怎么配的,心里要有数。

// 流水灯核心代码
#define LED1_PIN GPIO_PIN_0
#define LED2_PIN GPIO_PIN_1
#define LED3_PIN GPIO_PIN_2
#define LED_PORT GPIOB

void LED_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    __HAL_RCC_GPIOB_CLK_ENABLE();
    GPIO_InitStruct.Pin = LED1_PIN | LED2_PIN | LED3_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;   // 推挽输出
    GPIO_InitStruct.Pull = GPIO_NOPULL;            // 不拉
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;   // 低速足够
    HAL_GPIO_Init(LED_PORT, &GPIO_InitStruct);
    
    // 初始全灭(低电平点亮)
    HAL_GPIO_WritePin(LED_PORT, LED1_PIN | LED2_PIN | LED3_PIN, GPIO_PIN_SET);
}

void LED_Flow(void)
{
    uint16_t pins[] = {LED1_PIN, LED2_PIN, LED3_PIN};
    for(int i = 0; i < 3; i++)
    {
        HAL_GPIO_WritePin(LED_PORT, pins[i], GPIO_PIN_RESET); // 点亮
        HAL_Delay(200);
        HAL_GPIO_WritePin(LED_PORT, pins[i], GPIO_PIN_SET);   // 熄灭
    }
}

嗯,这里要注意:HAL_Delay()依赖SysTick中断。如果你后面跑FreeRTOS,这个延时函数会出问题——因为FreeRTOS接管了SysTick。到时候我们会换成vTaskDelay()。

2.2 按键输入——别被抖动坑了

按键输入看起来简单:读GPIO电平就行。但实际项目中,按键抖动是最大的坑。

机械按键按下和释放时,触点会弹跳10~20ms。如果不做消抖,一次按下可能被识别成多次。我在一个工业控制面板项目里就吃过这个亏——操作员按一次「启动」,机器抖了三次,差点出事故。

避坑指南:硬件上可以加RC滤波(100nF电容+10kΩ电阻),但软件消抖更可靠。我建议两者都用,双保险。

软件消抖的标准做法:检测到电平变化后,延时20ms再读一次。如果电平一致,才算有效。

// 按键消抖示例
#define KEY_PIN GPIO_PIN_13
#define KEY_PORT GPIOC

uint8_t Key_Scan(void)
{
    static uint8_t last_state = 1;  // 默认高电平(上拉)
    uint8_t current_state = HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN);
    
    if(current_state != last_state)
    {
        HAL_Delay(20);  // 等待抖动过去
        current_state = HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN);
        if(current_state != last_state)
        {
            last_state = current_state;
            if(current_state == 0)  // 按下(低电平有效)
                return 1;
        }
    }
    return 0;
}

这里我用的是状态机思想——记录上一次状态,只有状态稳定变化才响应。比单纯延时更可靠。

2.3 外部中断——别让CPU空转

轮询按键太浪费CPU了。你想想看,主循环每秒要读几万次按键状态,大部分时间都在做无用功。这时候就该外部中断出场了。

STM32的EXTI(外部中断)可以配置为上升沿、下降沿或双边沿触发。我习惯用下降沿触发检测按键按下,配合内部上拉电阻,省掉外部上拉。

配置项 推荐值 说明
触发边沿 下降沿 按键按下时电平从高变低
GPIO上拉 内部上拉 省外部电阻,默认高电平
中断优先级 2(0-15可配) 数值越小优先级越高
抢占优先级 1 FreeRTOS下建议不低于1
// 外部中断配置
void EXTI_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    __HAL_RCC_GPIOC_CLK_ENABLE();
    __HAL_RCC_SYSCFG_CLK_ENABLE();
    
    GPIO_InitStruct.Pin = GPIO_PIN_13;
    GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;  // 下降沿中断
    GPIO_InitStruct.Pull = GPIO_PULLUP;            // 内部上拉
    HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
    
    HAL_NVIC_SetPriority(EXTI15_10_IRQn, 1, 0);   // 抢占1,子0
    HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);
}

// 中断服务函数
void EXTI15_10_IRQHandler(void)
{
    if(__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_13) != RESET)
    {
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_13);
        // 这里放你的按键处理逻辑
        // 注意:中断里不要做耗时操作!
    }
}
关键提醒:中断服务函数里千万别用HAL_Delay()!它依赖SysTick中断,而中断嵌套会导致死锁。我在调试一个电机控制程序时就被这个坑过——中断里加了个延时,系统直接卡死。

2.4 位带操作——让GPIO飞起来

位带操作是Cortex-M3内核的一个「隐藏技能」。说白了,它把一个位映射到一个字地址上。你操作这个地址,就等于操作那个位,而且是一条指令完成,原子操作。

STM32的GPIO输出寄存器(ODR)和输入寄存器(IDR)都在位带区。映射公式很简单:

// 位带别名地址计算公式
#define BITBAND(addr, bit) ((addr & 0xF0000000) + 0x02000000 + \
                           ((addr & 0x00FFFFFF) << 5) + (bit << 2))

// 以GPIOB的ODR寄存器为例
#define GPIOB_ODR_ADDR    (GPIOB_BASE + 0x14)
#define PB0_BITBAND       BITBAND(GPIOB_ODR_ADDR, 0)

// 使用位带操作点亮LED
*(volatile uint32_t *)PB0_BITBAND = 0;  // PB0输出低电平,LED亮
*(volatile uint32_t *)PB0_BITBAND = 1;  // PB0输出高电平,LED灭

为什么位带操作快?因为普通读写GPIO需要「读-改-写」三步,而位带操作直接写别名地址,一条STR指令搞定。我在做LED点阵屏驱动时,用位带操作把刷新率从30fps提到了60fps。

性能对比:
  • HAL_GPIO_WritePin:约12个CPU周期
  • 直接寄存器操作:约6个CPU周期
  • 位带操作:约3个CPU周期
在FreeRTOS下,位带操作还能避免任务切换时的GPIO状态不一致问题。

2.5 实战中的避坑总结

这一章内容不少,我最后列几个实战中容易踩的坑,你写代码时多留个心眼:

  1. GPIO速度别乱设——LED和按键用LOW速度就够了,设成HIGH反而会增加EMI干扰。我有个产品就因为GPIO速度设太高,导致辐射超标,改版才解决。
  2. 中断里别做printf——串口打印是阻塞的,中断里用printf会拖死系统。真要调试,用DMA+环形缓冲区。
  3. 位带操作只适用于SRAM和外设——Flash区没有位带功能,别搞错了。
  4. 按键中断要配合消抖——硬件消抖(RC滤波)+软件消抖(延时判断),两个都用才保险。

好了,GPIO这部分就到这里。代码你拿去直接编译就能跑,但建议你动手改改——比如把流水灯改成呼吸灯效果,或者用位带操作实现一个精确的PWM模拟。只有自己踩过坑,才算真正学会。