第三章 GPIO与按键输入:裸机点亮第一个LED,读取按键状态,实现防抖逻辑
好,咱们开始进入真正动手的环节了。
前面两章我们搭好了开发环境,也理解了芯片是怎么启动的。但说真的,光看理论不过瘾。这一章,我们来点实在的——让板子上的LED亮起来,再让按键控制它。
你可能会想:「不就是点个灯嘛,有什么难的?」嗯,我当年也是这么想的。直到我在一个工业项目里,因为按键抖动没处理好,导致设备误触发,差点把产线搞停……从那以后,我对GPIO和防抖这件事,再也不敢马虎。
3.1 GPIO是什么?说白了就是芯片的「手脚」
GPIO,全称General Purpose Input Output,通用输入输出口。
你可以把它想象成芯片伸出来的一个个引脚。每个引脚,要么往外送信号(输出),要么从外面读信号(输入)。
输出模式:比如让引脚输出高电平(3.3V)或低电平(0V),就能点亮LED。
输入模式:比如读取引脚上的电平,就能知道按键是按下了还是松开了。
核心概念:GPIO的三种基本模式
- 推挽输出:能主动输出高或低电平,驱动能力强。点亮LED就用这个。
- 浮空输入:引脚电平由外部决定,内部不拉高也不拉低。容易受干扰。
- 上拉/下拉输入:内部有个电阻,把引脚默认拉到高电平(上拉)或低电平(下拉)。按键输入常用上拉。
我个人习惯,按键输入一律用内部上拉。省一个外部电阻,板子也干净。
3.2 裸机点亮第一个LED
好,我们先从最简单的开始。假设你的板子上,LED接在GPIOB的第0号引脚上,低电平点亮。
为什么是低电平点亮?很多开发板为了省事,LED正极接VCC,负极接GPIO。GPIO输出低,电流流过,灯亮。
点亮LED需要几步?其实就三步:
- 开启GPIOB的时钟(别忘!很多新手栽在这)
- 配置PB0为推挽输出模式
- 往PB0的输出数据寄存器写0(低电平)
来看代码:
// 点亮LED的裸机代码(STM32F103为例)
// 寄存器地址定义
#define RCC_APB2ENR (*(volatile unsigned int *)0x40021018)
#define GPIOB_CRL (*(volatile unsigned int *)0x40010C00)
#define GPIOB_ODR (*(volatile unsigned int *)0x40010C0C)
// 1. 开启GPIOB时钟(APB2总线上的外设)
RCC_APB2ENR |= (1 << 3); // 第3位置1,使能GPIOB时钟
// 2. 配置PB0为推挽输出,最大速度10MHz
// CRL寄存器控制低8位引脚,每4位控制一个引脚
GPIOB_CRL &= ~(0xF << 0); // 先清零PB0的配置位
GPIOB_CRL |= (0x1 << 0); // 设置为推挽输出模式
// 3. 输出低电平,点亮LED
GPIOB_ODR &= ~(1 << 0); // PB0输出0
小提示:volatile关键字不能省。编译器可能会优化掉你的寄存器操作,加上volatile就是告诉编译器:「别动我的寄存器,每次都要老老实实去读/写内存」。
我第一次写裸机代码时,忘了开时钟,灯死活不亮。查了半天手册才发现RCC_APB2ENR没配。嗯,这个坑我替你们踩过了。
3.3 读取按键状态
LED亮了,接下来让按键来控制它。
假设按键接在GPIOA的第0号引脚上,按下时引脚接地(低电平),松开时内部上拉(高电平)。
读取按键的步骤:
- 开启GPIOA的时钟
- 配置PA0为上拉输入模式
- 读取输入数据寄存器(IDR)
// 读取按键状态
#define RCC_APB2ENR (*(volatile unsigned int *)0x40021018)
#define GPIOA_CRL (*(volatile unsigned int *)0x40010800)
#define GPIOA_IDR (*(volatile unsigned int *)0x40010808)
// 1. 开启GPIOA时钟
RCC_APB2ENR |= (1 << 2); // 第2位置1
// 2. 配置PA0为上拉输入
GPIOA_CRL &= ~(0xF << 0); // 清零
GPIOA_CRL |= (0x8 << 0); // 设置为上拉/下拉输入模式
// 3. 读取按键状态
uint8_t key_state = (GPIOA_IDR >> 0) & 0x01;
// key_state == 0 表示按下,key_state == 1 表示松开
这里有个细节:上拉输入模式下,内部上拉电阻默认是开启的。但有些芯片需要额外配置上拉寄存器。我建议你养成习惯,每次配完输入模式后,再检查一下上拉寄存器是否使能。
3.4 按键防抖——这个坑我必须讲
好,现在你写了个循环:检测到按键按下就翻转LED。结果发现,按一次,LED闪了好几下。
为什么会这样?
机械按键按下和松开的瞬间,触点会弹跳。这个弹跳过程会产生多个高低电平脉冲,持续5~20ms。你的程序读到的不是一次按下,而是几十次。
警告:千万不要在产品里用裸读按键的方式!我见过一个同事,因为没做防抖,按键一次触发导致电机反转,设备直接撞坏了。这不是开玩笑的。
防抖的两种主流方法:
| 方法 | 原理 | 适用场景 | 缺点 |
|---|---|---|---|
| 硬件防抖 | RC低通滤波 + 施密特触发器 | 对响应速度要求高的场景 | 增加BOM成本 |
| 软件防抖 | 延时采样或定时器轮询 | 大多数嵌入式应用 | 占用CPU时间 |
我个人更推荐软件防抖,灵活且零成本。下面给出两种实现:
3.4.1 简单延时防抖(不推荐用于生产)
// 简单延时防抖
if ((GPIOA_IDR & (1 << 0)) == 0) { // 检测到按下
delay_ms(20); // 延时20ms,跳过抖动期
if ((GPIOA_IDR & (1 << 0)) == 0) { // 再次确认
// 按键确实按下了
LED_toggle();
while ((GPIOA_IDR & (1 << 0)) == 0); // 等待松开
}
}
这个方法简单,但delay_ms会阻塞CPU。如果系统里还有其他任务要处理,就别用这个。
3.4.2 状态机防抖(推荐)
这才是工程级的做法。用状态机来管理按键状态,不阻塞,不丢数据。
// 按键状态机防抖
typedef enum {
KEY_STATE_IDLE, // 空闲
KEY_STATE_DEBOUNCE, // 防抖中
KEY_STATE_PRESSED, // 已按下
KEY_STATE_RELEASE // 等待松开
} KeyState;
KeyState key_state = KEY_STATE_IDLE;
uint8_t key_pin = 0;
uint16_t debounce_timer = 0;
void Key_Scan(void) {
key_pin = (GPIOA_IDR >> 0) & 0x01;
switch (key_state) {
case KEY_STATE_IDLE:
if (key_pin == 0) { // 检测到低电平
key_state = KEY_STATE_DEBOUNCE;
debounce_timer = 20; // 启动20ms定时
}
break;
case KEY_STATE_DEBOUNCE:
if (debounce_timer > 0) {
debounce_timer--;
} else {
if (key_pin == 0) { // 20ms后还是低电平
key_state = KEY_STATE_PRESSED;
// 在这里触发按键事件
LED_toggle();
} else {
key_state = KEY_STATE_IDLE; // 抖动,回到空闲
}
}
break;
case KEY_STATE_PRESSED:
if (key_pin == 1) { // 检测到松开
key_state = KEY_STATE_RELEASE;
debounce_timer = 20;
}
break;
case KEY_STATE_RELEASE:
if (debounce_timer > 0) {
debounce_timer--;
} else {
key_state = KEY_STATE_IDLE; // 回到空闲,准备下一次
}
break;
}
}
关键点:这个状态机要在定时器中断或主循环里每隔1ms调用一次。debounce_timer的单位是毫秒,20ms是经验值。如果你的按键质量差,可以调到30ms甚至50ms。
我曾经在一个户外设备上,因为按键用了劣质货,抖动长达40ms。当时我调了三天才找到原因。所以,选按键的时候别太省,省下的几毛钱,可能换来几天的调试时间。
3.5 综合实验:按键控制LED
把上面的代码串起来,实现一个完整的功能:按一下按键,LED状态翻转。
// main.c - 按键控制LED完整示例
#include "stm32f10x.h"
void GPIO_Init(void) {
// 开启GPIOA和GPIOB时钟
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPBEN;
// PA0: 上拉输入
GPIOA->CRL &= ~(0xF << 0);
GPIOA->CRL |= (0x8 << 0);
GPIOA->ODR |= (1 << 0); // 使能内部上拉
// PB0: 推挽输出
GPIOB->CRL &= ~(0xF << 0);
GPIOB->CRL |= (0x1 << 0);
GPIOB->ODR |= (1 << 0); // 初始高电平,LED灭
}
int main(void) {
GPIO_Init();
while (1) {
Key_Scan(); // 状态机防抖,每秒调用1000次
// 这里可以干别的事,不阻塞
}
}
注意,Key_Scan()需要在定时器中断里每1ms调用一次,或者用SysTick做时基。如果你还没学到定时器,可以先在主循环里用delay_ms(1)凑合,但记住——这只是暂时的。
3.6 本章小结
这一章我们做了三件事:
- 点亮了第一个LED,理解了GPIO输出模式
- 读取了按键状态,理解了GPIO输入模式
- 实现了状态机防抖,这是工程级的做法
你可能会觉得,点个灯读个键,至于写这么多代码吗?
至于。因为从这一章开始,你不再是「照着教程敲代码」,而是在理解硬件和软件之间的交互。GPIO是所有外设的基础,防抖是所有输入处理的必修课。
下一章,我们会进入定时器的世界。到时候你会发现,有了定时器,很多事情都变得优雅了。
课后练习:
- 把LED改成高电平点亮,修改代码并验证
- 增加一个长按功能:按下超过1秒,LED快速闪烁
- 用示波器观察按键按下时的波形,看看抖动长什么样
嗯,动手试试吧。有问题随时翻翻芯片手册,那才是最终答案。