3. LL库基础架构
按键,是嵌入式开发里最基础、也最容易出问题的外设。你想想看,一个机械按键按下去,信号在几毫秒内会弹跳几十次。如果不处理,一次按下可能被识别成十次。这一章,我们就来彻底搞定它。
我会从 EXTI 外部中断的配置讲起,然后带你看看 LL 库的中断回调机制,最后给出软件和硬件两种消抖方案。这些都是我实际项目中反复用过的套路,希望能帮你少走弯路。
3.1 EXTI 外部中断:从轮询到事件驱动
轮询方式虽然简单,但 CPU 得一直盯着引脚看,浪费资源。外部中断(EXTI)则不同——引脚电平变化时,硬件自动触发中断,CPU 只需在中断服务函数里处理即可。
配置 EXTI 其实就三步:
- 使能 GPIO 时钟和 SYSCFG 时钟(EXTI 需要 SYSCFG 做引脚映射)
- 配置 GPIO 为输入模式,并选择中断触发边沿
- 配置 NVIC 中断优先级,使能中断通道
下面是一个 LL 库的配置示例,我习惯把按键初始化封装成一个函数:
void Button_Init(void)
{
// 1. 使能时钟
LL_APB2_GRP1_EnableClock(LL_APB2_GRP1_PERIPH_GPIOA);
LL_APB2_GRP1_EnableClock(LL_APB2_GRP1_PERIPH_SYSCFG);
// 2. 配置 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);
// 3. 将 PA0 连接到 EXTI 线 0
LL_SYSCFG_SetEXTISource(LL_SYSCFG_EXTI_PORTA, LL_SYSCFG_EXTI_LINE0);
// 4. 配置 EXTI:下降沿触发(按键按下时引脚从高变低)
LL_EXTI_EnableIT_0_31(LL_EXTI_LINE_0);
LL_EXTI_EnableFallingTrig_0_31(LL_EXTI_LINE_0);
LL_EXTI_EnableRisingTrig_0_31(LL_EXTI_LINE_0); // 如果需要双边沿触发
// 5. 配置 NVIC
NVIC_SetPriority(EXTI0_IRQn, 1);
NVIC_EnableIRQ(EXTI0_IRQn);
}
3.2 LL 库中断回调:别在中断里做复杂事
配置好 EXTI 后,中断来了会进哪个函数?在 LL 库里,中断服务函数需要我们自己写,但 LL 库提供了一个弱定义的回调函数 LL_EXTI_Line0_Callback,我们只需重写它。
中断服务函数的写法:
void EXTI0_IRQHandler(void)
{
// 检查中断标志位
if (LL_EXTI_IsActiveFlag_0_31(LL_EXTI_LINE_0))
{
// 清除中断标志位
LL_EXTI_ClearFlag_0_31(LL_EXTI_LINE_0);
// 调用 LL 库回调
LL_EXTI_Line0_Callback();
}
}
// 弱定义回调,用户重写
void LL_EXTI_Line0_Callback(void)
{
// 这里只做标记,不要做耗时操作
// 比如:设置一个全局标志位,主循环去处理
g_button_pressed = 1;
}
3.3 软件消抖:简单可靠的延时方案
机械按键按下和释放时,触点会弹跳 5~20ms。软件消抖的核心思路就是:检测到电平变化后,等一段时间再确认电平状态。
我常用的软件消抖有两种:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 延时消抖 | 检测到边沿后,延时 10~20ms,再次读取引脚电平 | 实现简单,代码量少 | 阻塞 CPU,不适合多任务 |
| 状态机消抖 | 定时采样引脚电平,连续 N 次相同才确认状态变化 | 非阻塞,适合 RTOS 或主循环 | 实现稍复杂,需要定时器配合 |
延时消抖的代码很直观:
void LL_EXTI_Line0_Callback(void)
{
// 延时 20ms 避开弹跳期
delay_ms(20);
// 再次读取引脚电平,确认是否真的按下
if (LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0) == 0)
{
g_button_pressed = 1;
}
}
但说实话,这种阻塞延时在复杂系统里不太友好。我更推荐状态机方式,配合一个 1ms 的定时器中断:
// 按键状态机
#define DEBOUNCE_TIME 5 // 连续采样 5 次(每次 1ms)
uint8_t Button_Scan(void)
{
static uint8_t count = 0;
static uint8_t last_state = 1; // 默认高电平(未按下)
uint8_t current_state = LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0);
if (current_state == last_state)
{
count++;
if (count >= DEBOUNCE_TIME)
{
count = DEBOUNCE_TIME;
// 如果连续 5ms 都是低电平,确认按下
if (current_state == 0)
return 1;
}
}
else
{
count = 0;
last_state = current_state;
}
return 0;
}
这个函数放在 1ms 定时器中断里调用,或者主循环里轮询。它不会阻塞,而且抗干扰能力强。
3.4 硬件消抖:RC 滤波与施密特触发器
如果你不想在软件上花太多心思,硬件消抖是更彻底的方案。原理很简单:用 RC 低通滤波器把弹跳的高频分量滤掉,再用施密特触发器整形。
典型电路:
VCC
|
R (10kΩ)
+-------> 到 MCU 引脚
|
按键 ----> +
|
C (0.1μF)
|
GND
RC 时间常数 τ = R × C。一般取 τ = 1~10ms。比如 10kΩ 和 0.1μF,τ = 1ms,能有效滤除 5~20ms 的弹跳。
| 元件 | 推荐值 | 说明 |
|---|---|---|
| R(上拉电阻) | 4.7kΩ ~ 10kΩ | 阻值太小功耗大,太大抗干扰差 |
| C(滤波电容) | 0.1μF ~ 1μF | 电容越大,滤波效果越好,但响应变慢 |
3.5 实战:一个可靠的按键驱动
最后,我把前面讲的内容整合成一个完整的按键驱动。它支持短按和长按检测,使用状态机消抖,非阻塞运行。
// key_driver.h
typedef enum {
KEY_IDLE,
KEY_PRESSED,
KEY_LONG_PRESS
} KeyState_t;
KeyState_t Key_GetState(void);
void Key_Task(void); // 1ms 调用一次
// key_driver.c
static uint8_t debounce_cnt = 0;
static uint8_t press_cnt = 0;
static uint8_t last_pin = 1;
KeyState_t Key_GetState(void)
{
static KeyState_t state = KEY_IDLE;
uint8_t pin = LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0);
// 状态机消抖
if (pin == last_pin) {
debounce_cnt++;
if (debounce_cnt >= 5) {
debounce_cnt = 5;
if (pin == 0) {
press_cnt++;
if (press_cnt == 1) {
state = KEY_PRESSED; // 短按触发
} else if (press_cnt >= 100) { // 100ms 长按
state = KEY_LONG_PRESS;
}
} else {
press_cnt = 0;
state = KEY_IDLE;
}
}
} else {
debounce_cnt = 0;
last_pin = pin;
}
return state;
}
void Key_Task(void)
{
KeyState_t s = Key_GetState();
switch (s) {
case KEY_PRESSED:
// 处理短按
break;
case KEY_LONG_PRESS:
// 处理长按
break;
default:
break;
}
}
这个驱动我在三个项目里复用过了,稳定可靠。你只需要在 1ms 定时器中断里调用 Key_Task(),主循环里查询状态即可。