3、GPIO基础与按键输入:GPIO模式详解、按键消抖原理、轮询方式读取按键、控制LED亮灭
好,咱们今天来聊聊GPIO。这玩意儿可以说是嵌入式开发里最基础、也最常用的外设了。说白了,它就是芯片和外界打交道的一根根“手脚”。你让它输出高电平,它就输出3.3V;你让它读取电平,它就知道外面是0V还是3.3V。
我在项目里见过不少新手,一上来就对着寄存器猛写,结果LED死活不亮,按键怎么按都没反应。其实啊,很多时候不是代码写错了,而是GPIO的模式没配对。咱们今天就把这事儿彻底讲明白。
3.1 GPIO模式详解
GPIO的模式,比你想象的要复杂一些。不是简单的“输入”和“输出”两个选项。我习惯把它分成两大类:输出模式和输入模式。每种模式下还有细分。
3.1.1 输出模式
输出模式,就是让芯片去控制外部设备。比如点亮一个LED,驱动一个蜂鸣器。这里有两个关键参数:推挽输出和开漏输出。
- 推挽输出(Push-Pull):这是最常用的模式。芯片内部有两个管子,一个负责“推”(输出高电平),一个负责“拉”(输出低电平)。输出高电平时,电流从芯片流出;输出低电平时,电流流入芯片。驱动能力强,适合直接驱动LED。
- 开漏输出(Open-Drain):这个模式只负责“拉”(输出低电平),不负责“推”。要输出高电平,必须外部接一个上拉电阻到VCC。我一般在I2C总线或者需要电平转换的场合用这个模式。你想想看,如果多个设备共用一根线,开漏输出可以避免短路。
重要提醒: 输出模式还有一个“速度”参数,比如2MHz、10MHz、50MHz。这个速度不是输出频率,而是输出电平跳变时的斜率。对于普通LED,2MHz就够了。如果配得太高,反而会引起信号过冲和EMI问题。我曾经在一个项目中,把GPIO速度配到了50MHz去驱动一个继电器,结果继电器没坏,倒是把旁边的传感器给干扰了。
3.1.2 输入模式
输入模式,就是让芯片去读取外部信号。比如检测按键是否按下。这里也有几个选项:
- 浮空输入(Floating):引脚既不接上拉也不接下拉。外部信号是什么电平,就读到什么电平。但引脚悬空时,电平不确定,容易受干扰。我基本不用这个模式。
- 上拉输入(Pull-Up):内部接了一个几十千欧的电阻到VCC。默认读到的就是高电平。按键按下时,引脚接地,读到低电平。这个模式最常用。
- 下拉输入(Pull-Down):内部接了一个电阻到GND。默认读到的就是低电平。按键按下时,引脚接VCC,读到高电平。用得少一些。
我的习惯: 按键输入,我一般用上拉输入。这样按键没按下时,电平是稳定的高电平。按下后,电平被拉低。逻辑上比较直观。而且内部上拉电阻已经够用,不需要额外加外部电阻。
3.2 按键消抖原理
按键消抖,是每个嵌入式工程师都会遇到的“坑”。为什么需要消抖?
机械按键在按下和释放的瞬间,触点会弹跳。这个弹跳时间大概有5-20毫秒。在这段时间里,电平会反复跳变。如果你直接去读,可能会读到多次按下。我刚开始做项目时,就因为这个原因,一个按键按一次,LED闪了三次。嗯,当时我还以为是芯片坏了。
消抖的方法有两种:硬件消抖和软件消抖。
- 硬件消抖:在按键两端并联一个电容(通常0.1uF),利用电容的充放电来平滑电平跳变。简单粗暴,但会增加BOM成本。
- 软件消抖:检测到电平变化后,延时一段时间(比如20ms),再读取一次。如果电平稳定,就认为是有效按下。这是最常用的方法,不增加硬件成本。
注意: 软件消抖的延时时间不是固定的。我建议根据实际按键的弹跳特性来调整。有些劣质按键,弹跳时间可能长达50ms。我曾经在一个工业项目中,用了30ms的消抖时间,结果按键偶尔还是误触发。后来换成了50ms,问题就解决了。
3.3 轮询方式读取按键
轮询,就是CPU不停地去检查按键的状态。虽然效率不高,但对于简单的按键控制来说,完全够用。
下面是一个典型的轮询读取按键的代码示例。我习惯用状态机的方式来处理,这样逻辑更清晰。
// 伪代码示例:轮询读取按键并控制LED
#define KEY_PIN GPIO_PIN_0 // 按键接在GPIOA的Pin0
#define LED_PIN GPIO_PIN_1 // LED接在GPIOA的Pin1
uint8_t key_state = 0; // 0: 未按下, 1: 按下
void GPIO_Init(void) {
// 配置按键引脚为上拉输入
GPIOA->MODER &= ~(3 << (0 * 2)); // 输入模式
GPIOA->PUPDR |= (1 << (0 * 2)); // 上拉
// 配置LED引脚为推挽输出
GPIOA->MODER |= (1 << (1 * 2)); // 输出模式
GPIOA->OTYPER &= ~(1 << 1); // 推挽
GPIOA->OSPEEDR |= (1 << (1 * 2)); // 低速
}
void Delay_ms(uint32_t ms) {
// 简单的延时函数,实际项目中建议用定时器
for (uint32_t i = 0; i < ms * 1000; i++) {
__NOP();
}
}
uint8_t Read_Key(void) {
// 读取按键状态
if ((GPIOA->IDR & (1 << 0)) == 0) { // 按键按下(低电平)
Delay_ms(20); // 消抖延时
if ((GPIOA->IDR & (1 << 0)) == 0) { // 再次确认
return 1; // 有效按下
}
}
return 0; // 未按下
}
int main(void) {
GPIO_Init();
while (1) {
if (Read_Key()) {
// 按键按下,翻转LED
GPIOA->ODR ^= (1 << 1);
// 等待按键释放,防止一次按下多次触发
while ((GPIOA->IDR & (1 << 0)) == 0);
}
}
}
小技巧: 上面的代码中,我加了一个“等待按键释放”的循环。这样每次按下只触发一次动作。如果不加这个循环,你按一次按键,LED可能会连续翻转多次。因为CPU跑得很快,在消抖延时之后,按键可能还没释放,Read_Key()会一直返回1。
3.4 控制LED亮灭
控制LED亮灭,其实就是控制GPIO输出高电平或低电平。对于大多数单片机,直接操作ODR寄存器(输出数据寄存器)就可以了。
但这里有一个细节:不要用读-改-写的方式操作ODR。比如:
// 不推荐的做法
GPIOA->ODR |= (1 << 1); // 置位
GPIOA->ODR &= ~(1 << 1); // 清零
为什么?因为ODR寄存器是可读可写的。如果你在读和写之间,发生了中断,中断里也修改了ODR,那么你的修改可能会被覆盖。这就是所谓的“读-改-写”问题。
我建议使用BSRR寄存器(位设置/复位寄存器)。这个寄存器是只写的,写1到低16位,对应的引脚输出高电平;写1到高16位,对应的引脚输出低电平。不会出现读-改-写的问题。
// 推荐的做法
GPIOA->BSRR = (1 << 1); // 输出高电平,点亮LED
GPIOA->BSRR = (1 << (1 + 16)); // 输出低电平,熄灭LED
总结一下: 控制LED亮灭,看似简单,但背后涉及GPIO模式配置、消抖处理、寄存器操作等多个知识点。我在项目中见过太多因为GPIO模式配错、消抖没做好而导致的问题。希望你能把这些基础打牢,后面学中断、定时器、PWM的时候,才会更顺畅。
好了,这一章的内容就到这里。下一章咱们会讲中断,那才是真正的高效处理方式。轮询嘛,说白了就是“傻等”,中断才是“主动通知”。到时候你就知道,为什么按键用中断更合适了。