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 内部,根据硬件极性做一次映射。这样应用层永远只关心“按下/松开”,不关心电平高低。
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,这个值在大多数机械按键上都够用。
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驱动不关心按键怎么按,它们通过回调机制松耦合地协作。
这张图把整个GPIO输入驱动的层次结构画清楚了。从下往上,每一层只依赖下一层,不跨层调用。这就是驱动封装的核心——让每一层各司其职。