4. GPIO驱动封装(下):输入读取封装、外部中断封装、中断回调机制、实战:按键消抖与LED控制

上一节我们把GPIO输出封装得差不多了。这一节,咱们来啃输入这块硬骨头。

输入读取看起来简单——不就是读个寄存器吗?但真正做产品时你会发现,输入信号的处理质量,直接决定了系统的稳定性。我见过太多因为按键抖动没处理好,导致设备误触发的案例。

4.1 输入读取封装:别只读一次

先看最基本的输入读取API。我的封装习惯是这样的:

typedef enum {
    GPIO_PIN_RESET = 0,
    GPIO_PIN_SET = 1
} GPIO_PinState;

GPIO_PinState GPIO_ReadPin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin);

这个函数直接调用HAL库的 HAL_GPIO_ReadPin。但注意,我从来不直接暴露HAL的返回值给应用层。为什么?

举个例子。你读一个按键,HAL返回的是 GPIO_PIN_RESET 或 GPIO_PIN_SET。但应用层想知道的是“按键按下”还是“按键松开”。这两个概念在硬件上可能是反的——低电平表示按下,高电平表示松开。如果应用层直接跟硬件电平耦合,换一块板子就得改应用代码。

所以我通常会加一层抽象:

typedef enum {
    KEY_STATE_RELEASED = 0,
    KEY_STATE_PRESSED = 1
} KeyState;

KeyState Key_GetState(KeyHandle_t* hkey);

在 Key_GetState 内部,根据硬件极性做一次映射。这样应用层永远只关心“按下/松开”,不关心电平高低。

我的习惯:输入读取API至少提供两种模式——即时读取和带滤波的读取。即时读取用于调试,带滤波的用于正式产品。

4.2 外部中断封装:把中断处理函数藏起来

外部中断的封装,核心就一句话:不要让应用层直接写中断服务函数。

我见过很多新手这么写:

void EXTI0_IRQHandler(void) {
    if(__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET) {
        // 按键处理逻辑直接写在这里
        HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
    }
}

这样写有什么问题?

  • 中断处理函数里塞了业务逻辑,耦合度高
  • 换个引脚,整个函数得重写
  • 多个中断源共用一个处理函数时,代码乱成一团

我的做法是:把中断处理函数做成一个调度器。

// 中断处理函数只做一件事:调用回调
void EXTI0_IRQHandler(void) {
    if(__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET) {
        GPIO_EXTI_Callback(GPIO_PIN_0);
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
    }
}

// 回调函数由用户注册
void GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    // 这里根据 GPIO_Pin 分发到不同的处理函数
    switch(GPIO_Pin) {
        case GPIO_PIN_0:
            Key_IRQHandler(&hkey1);
            break;
        case GPIO_PIN_1:
            Key_IRQHandler(&hkey2);
            break;
        default:
            break;
    }
}

这样,中断处理函数本身是固定的,业务逻辑通过回调函数注入。换引脚、换功能,只需要改回调注册表。

注意:中断回调函数里不要做耗时操作。我曾经在一个项目里,有人在回调里加了延时,结果系统中断响应延迟,导致通信丢包。回调里只做标记,具体处理放到主循环或任务里。

4.3 中断回调机制:让驱动主动通知应用

回调机制的本质是驱动层主动通知应用层。应用层不需要轮询,驱动检测到事件后直接调用注册的函数。

我的回调封装结构:

typedef void (*pKeyCallback)(void* arg);

typedef struct {
    GPIO_TypeDef* port;
    uint16_t pin;
    pKeyCallback press_callback;   // 按下回调
    pKeyCallback release_callback; // 松开回调
    void* callback_arg;            // 回调参数
} KeyHandle_t;

注册回调的API:

void Key_RegisterCallback(KeyHandle_t* hkey, 
                          pKeyCallback cb, 
                          void* arg,
                          uint8_t event_type); // 0:按下 1:松开

这样,应用层可以这样用:

void MyKeyPressHandler(void* arg) {
    // 按键按下,翻转LED
    LED_Toggle();
}

void main(void) {
    KeyHandle_t hkey1;
    Key_Init(&hkey1, GPIOA, GPIO_PIN_0, KEY_MODE_EXTI);
    Key_RegisterCallback(&hkey1, MyKeyPressHandler, NULL, KEY_EVENT_PRESS);
    
    while(1) {
        // 主循环不用管按键,中断会自动触发回调
    }
}

这种模式的好处是:应用代码干净、可读性强。驱动层负责底层细节,应用层只关心业务逻辑。

4.4 实战:按键消抖与LED控制

好了,理论说完了,咱们来做个实战。这个例子我做了不下十次,每次都能发现新问题。

4.4.1 硬件消抖 vs 软件消抖

方案 优点 缺点 适用场景
硬件消抖(RC滤波) 不占CPU,响应快 增加BOM成本,参数固定 对成本不敏感、要求高可靠性的产品
软件消抖(延时去抖) 零成本,灵活可调 阻塞CPU,不适合多按键 简单产品、按键数量少
软件消抖(状态机) 非阻塞,适合多按键 代码稍复杂 大多数嵌入式产品

我个人推荐状态机消抖。它不阻塞CPU,可以同时处理多个按键,而且消抖时间可调。

4.4.2 状态机消抖实现

按键状态机通常有3个状态:

  • IDLE:空闲状态,等待按键按下
  • DEBOUNCE:消抖状态,等待抖动结束
  • PRESSED:确认按下状态

代码实现:

typedef enum {
    KEY_STATE_IDLE,
    KEY_STATE_DEBOUNCE,
    KEY_STATE_PRESSED
} KeyFsmState;

typedef struct {
    KeyHandle_t base;
    KeyFsmState state;
    uint32_t debounce_tick;   // 消抖计时起点
    uint32_t debounce_time;   // 消抖时间(ms)
    uint8_t last_level;       // 上次读取的电平
} KeyFsm_t;

void KeyFSM_Process(KeyFsm_t* k) {
    uint8_t cur_level = GPIO_ReadPin(k->base.port, k->base.pin);
    
    switch(k->state) {
        case KEY_STATE_IDLE:
            if(cur_level != k->last_level) {
                k->state = KEY_STATE_DEBOUNCE;
                k->debounce_tick = GetSysTick();
            }
            break;
            
        case KEY_STATE_DEBOUNCE:
            if(GetSysTick() - k->debounce_tick >= k->debounce_time) {
                if(cur_level != k->last_level) {
                    // 抖动结束,确认电平变化
                    k->last_level = cur_level;
                    if(cur_level == KEY_PRESSED_LEVEL) {
                        k->state = KEY_STATE_PRESSED;
                        if(k->base.press_callback) {
                            k->base.press_callback(k->base.callback_arg);
                        }
                    } else {
                        k->state = KEY_STATE_IDLE;
                        if(k->base.release_callback) {
                            k->base.release_callback(k->base.callback_arg);
                        }
                    }
                } else {
                    // 抖动恢复,回到空闲
                    k->state = KEY_STATE_IDLE;
                }
            }
            break;
            
        case KEY_STATE_PRESSED:
            if(cur_level != KEY_PRESSED_LEVEL) {
                k->state = KEY_STATE_IDLE;
                if(k->base.release_callback) {
                    k->base.release_callback(k->base.callback_arg);
                }
            }
            break;
    }
}

这个状态机要在主循环里周期调用,比如每5ms调用一次。消抖时间我一般设20ms,这个值在大多数机械按键上都够用。

避坑指南:我曾经在一个项目里把消抖时间设成了50ms,结果用户反映按键响应太慢。后来改成20ms,问题解决。但如果你用的是那种老式机械按键,抖动时间可能长达30ms,这时候20ms就不够了。建议在产品开发阶段,用示波器抓一下按键波形,看看实际抖动时间。

4.4.3 LED控制封装

LED控制相对简单,但我也见过不少坑。我的封装:

typedef struct {
    GPIO_TypeDef* port;
    uint16_t pin;
    uint8_t active_level;  // 0:低电平点亮 1:高电平点亮
    uint8_t state;         // 当前状态
} LED_Handle_t;

void LED_Init(LED_Handle_t* hled, GPIO_TypeDef* port, uint16_t pin, uint8_t active_level);
void LED_On(LED_Handle_t* hled);
void LED_Off(LED_Handle_t* hled);
void LED_Toggle(LED_Handle_t* hled);

注意 active_level 这个参数。不同板子的LED驱动方式不同,有的高电平点亮,有的低电平点亮。把这个参数抽出来,换板子只需要改初始化参数,不用改业务代码。

4.4.4 完整示例:按键控制LED

// 定义按键和LED句柄
KeyFsm_t hkey;
LED_Handle_t hled;

// 按键按下回调
void OnKeyPressed(void* arg) {
    LED_Toggle(&hled);
}

void main(void) {
    // 初始化LED
    LED_Init(&hled, GPIOB, GPIO_PIN_0, LED_ACTIVE_HIGH);
    LED_Off(&hled);
    
    // 初始化按键
    Key_Init(&hkey.base, GPIOA, GPIO_PIN_0, KEY_MODE_EXTI);
    hkey.debounce_time = 20;  // 20ms消抖
    hkey.state = KEY_STATE_IDLE;
    hkey.last_level = GPIO_ReadPin(GPIOA, GPIO_PIN_0);
    hkey.base.press_callback = OnKeyPressed;
    
    while(1) {
        KeyFSM_Process(&hkey);  // 每5ms调用一次
        Delay(5);
    }
}

这个例子虽然简单,但包含了驱动封装的核心思想:分层、抽象、回调。按键驱动不关心LED怎么亮,LED驱动不关心按键怎么按,它们通过回调机制松耦合地协作。

扩展思考:如果你需要支持长按、双击、连发等功能,可以在状态机里增加状态和计时器。我做过一个项目,用这个框架实现了8个按键的独立检测,每个按键支持单击、双击、长按3种事件,代码量不到200行。
GPIO输入驱动封装知识结构 应用层(业务逻辑) 回调接口层(press_callback / release_callback) 驱动封装层 输入读取封装 外部中断封装 状态机消抖 HAL库底层(HAL_GPIO_ReadPin / EXTI) 硬件层(GPIO寄存器) 核心思想:分层抽象 + 回调机制,让应用层与硬件解耦

这张图把整个GPIO输入驱动的层次结构画清楚了。从下往上,每一层只依赖下一层,不跨层调用。这就是驱动封装的核心——让每一层各司其职。