3. GPIO 输入检测:按键控制 LED 实战
按键输入,是嵌入式开发里最基础、也最容易出坑的环节。你想想看,一个按键按下去,CPU 怎么知道它被按了?按下去之后抖不抖?用轮询还是中断?这些问题搞不清楚,后面做复杂项目就会反复踩坑。这一章,我就带你把这些事彻底理清楚。
3.1 按键输入原理
按键的本质,就是一个机械开关。按下时导通,松开时断开。我们把它接到 CH32V203 的 GPIO 引脚上,再配合上拉或下拉电阻,就能读到高低电平的变化。
常见的接法有两种:
- 上拉输入:按键一端接 GND,另一端接 GPIO。平时引脚被内部上拉电阻拉到高电平,按下后变成低电平。
- 下拉输入:按键一端接 VCC,另一端接 GPIO。平时引脚被内部下拉电阻拉到低电平,按下后变成高电平。
我个人习惯用上拉输入,因为 CH32V203 的 GPIO 内部自带可配置的上拉电阻,省掉外部元件,板子更干净。
3.2 消抖处理机制
按键是机械器件,按下和松开的瞬间,触点会弹跳。你想想看,一个金属片弹来弹去,电平信号就会在高低之间来回跳几次,持续几毫秒到十几毫秒。如果不处理,CPU 可能把一次按键误判成多次。
我在项目中遇到过最夸张的一次,按键按下一次,串口打印了 7 次 "KEY_PRESSED"。嗯,从那以后我再也不敢忽略消抖了。
消抖有两种主流方式:
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 硬件消抖 | RC 低通滤波 + 施密特触发器 | 不占 CPU,响应快 | 增加 BOM 成本 |
| 软件消抖 | 延时采样 / 状态机去抖 | 零成本,灵活 | 占用 CPU 时间 |
大多数项目用软件消抖就够了。我常用的方法是:检测到电平变化后,延时 10~20ms 再读一次,如果电平一致,就确认按键状态有效。
delay_ms(20),结果系统响应变得很迟钝。后来改用定时器轮询 + 状态机,既消了抖又不阻塞主循环。
3.3 中断与轮询模式对比
检测按键输入,有两种主流模式:轮询和中断。很多初学者觉得中断高大上,轮询很 low,其实不是这么回事。
| 对比项 | 轮询模式 | 中断模式 |
|---|---|---|
| CPU 占用 | 持续占用,不断查询 | 事件触发,空闲时休眠 |
| 实时性 | 取决于轮询周期 | 立即响应 |
| 代码复杂度 | 简单直观 | 需要配置 NVIC、中断服务函数 |
| 适用场景 | 按键少、主循环快 | 按键多、低功耗、高实时 |
说白了,如果你的主循环跑得很快(比如 1ms 以内),轮询完全够用。但如果你要做低功耗产品,或者按键需要唤醒系统,那就必须用中断。
我个人习惯:原型阶段用轮询,快速验证功能;产品阶段用中断,配合休眠省电。
3.4 按键控制 LED 实战
好了,理论讲完,咱们直接上手。这一节的目标是:按下按键,LED 翻转状态。
硬件连接:
- PA0 → 按键(上拉输入,按下为低)
- PC1 → LED(推挽输出,高电平点亮)
3.4.1 轮询模式实现
// 按键初始化
void KEY_Init(void) {
GPIO_InitTypeDef GPIO_InitStructure = {0};
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入
GPIO_Init(GPIOA, &GPIO_InitStructure);
}
// LED 初始化
void LED_Init(void) {
GPIO_InitTypeDef GPIO_InitStructure = {0};
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOC, &GPIO_InitStructure);
GPIO_ResetBits(GPIOC, GPIO_Pin_1); // 初始熄灭
}
// 带消抖的按键扫描
uint8_t KEY_Scan(void) {
static uint8_t key_last = 1; // 上次状态
uint8_t key_cur = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0);
if (key_last != key_cur) {
Delay_Ms(10); // 消抖延时
key_cur = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0);
if (key_last != key_cur) {
key_last = key_cur;
if (key_cur == 0) {
return 1; // 按下
}
}
}
return 0; // 未按下
}
// 主循环
int main(void) {
KEY_Init();
LED_Init();
while(1) {
if (KEY_Scan()) {
GPIO_ToggleBits(GPIOC, GPIO_Pin_1); // 翻转 LED
}
}
}
3.4.2 中断模式实现
中断模式需要配置外部中断线。CH32V203 的 PA0 对应 EXTI0 中断线。
// 中断方式按键初始化
void KEY_EXTI_Init(void) {
GPIO_InitTypeDef GPIO_InitStructure = {0};
EXTI_InitTypeDef EXTI_InitStructure = {0};
NVIC_InitTypeDef NVIC_InitStructure = {0};
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 配置 PA0 为 EXTI 源
GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0);
EXTI_InitStructure.EXTI_Line = EXTI_Line0;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling; // 下降沿触发
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);
NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
// 中断服务函数
void EXTI0_IRQHandler(void) {
if (EXTI_GetITStatus(EXTI_Line0) != RESET) {
Delay_Ms(10); // 消抖
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0) {
GPIO_ToggleBits(GPIOC, GPIO_Pin_1);
}
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
3.5 知识结构总览
下面这张图,把本章的核心知识点串起来了。你可以把它当作一个快速索引。
3.6 写在最后
按键输入看起来简单,但消抖和模式选择这两个点,决定了你产品的稳定性和功耗。我个人建议:
- 原型阶段用轮询 + 延时消抖,快速出活
- 产品阶段用中断 + 定时器状态机,兼顾响应和功耗
- 永远不要在中断里做耗时操作
这一章的内容就到这里。代码可以直接拿到 CH32V203 开发板上跑,有问题欢迎交流。