在这里插入图片描述

在这里插入图片描述

🎬 云泽Q个人主页

🔥 专栏传送入口: 《C语言》《数据结构》《C++》《Linux》《蓝桥杯系列》《笔试算法》《AI赋能》《STM32

⛺️遇见安然遇见你,不负代码不负卿~

前言

大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~

一、GPIO 输入实战案例设计:从基础检测到工程化封装

在嵌入式开发中,GPIO 输入功能最经典的应用场景莫过于按键检测。为了让大家透彻掌握这一环节,我们设计了三个层层递进的实战案例,分别对应基础接口使用、信号消抖处理以及工程化代码封装。

1.1 案例一:基础电平检测与 LED 联动

这是我们的入门案例,核心目标是熟悉 GPIO 输入相关的基础 API 接口调用。

在这个场景中,我们设定了明确的硬件映射关系:开发板上预留了 4 个 LED 灯(编号 1-4)和 4 个独立按键(编号 1-4)。虽然板子上有具体的丝印命名,但在逻辑上我们将其一一对应。

功能逻辑:

  • 按下即亮:当检测到某个按键被按下时,立即点亮对应的 LED 灯。
  • 松开即灭:一旦按键松开,LED 灯随即熄灭。

这个案例不涉及复杂的状态保持,完全遵循“输入决定输出”的实时逻辑。通过这个过程就可以直观地看到 GPIO 输入寄存器数值的变化如何直接驱动外设,建立起对 HAL_GPIO_ReadPin 等基础函数的感性认识。

1.2 案例二:状态翻转与按键消抖引入

第二个案例在第一个的基础上增加了逻辑复杂度,旨在引出嵌入式开发中一个极其重要且无法回避的问题——按键抖动

功能逻辑:

  • 按一下亮:第一次按下并松开按键后,LED 灯保持点亮状态。
  • 再一下灭:再次按下并松开同一按键,LED 灯熄灭。
  • 循环切换:如此往复,实现类似“自锁开关”的效果。

在这个案例中如果仅仅依靠简单的电平判断,往往会出现“按一次变多次”或者“反应不灵敏”的现象。这就是因为机械按键在闭合和断开的瞬间,触点会发生物理弹跳,产生一系列高频的毛刺信号。为了解决这个问题,我们必须在软件层面引入“消抖”算法(通常是延时检测或状态机)

1.3 案例三:多路联动的软件工程化封装

前两个案例主要关注功能实现,而第三个案例则侧重于软件工程角度的思考。

当我们需要同时处理 4 个按键和 4 个 LED 灯的联动时,如果直接在代码中硬编码引脚名称(如 PG6PF10 等),不仅代码可读性极差,而且一旦硬件改版,修改代码将是一场灾难。

优化策略:

  • 宏定义重命名:我们将底层的物理引脚名称抽象为具有业务含义的逻辑名称。例如,将连接按键的 PG6PG7PG8PG10 分别重命名为 KEY1_PINKEY2_PIN 等;将 LED 引脚重命名为 LED1_PIN 等。
  • 模块化处理:通过这种封装,我们在主逻辑中只关心“按键 1”和“灯 1”,而不需要关心它们具体连在哪个端口上。这不仅规避了难记的引脚名称,也方便后续扩展更多按键或复用代码。

二、深入剖析:按键硬件电路与信号完整性

理解了软件要做什么,我们必须回过头来看硬件是怎么设计的。只有读懂了原理图,写出的代码才是有根基的。

2.1 按键的物理特性与型号

在我们的开发板上,按键属于典型的板级外设。这些贴片轻触开关,型号通常为 TS-1187A-B-A-B。这种开关内部有一个金属弹片,当按下时,会听到清脆的“滴答”声,这就是金属触点闭合的声音。虽然手感不错,但这种机械结构天生就带有不稳定性,为后续的抖动问题埋下了伏笔。

2.2 原理图解析:上拉电阻与接地

打开原理图,我们可以清晰地看到按键与 MCU 的连接方式。以 SYS_SW1 为例,它的一端连接到 MCU 的 PG10 引脚,另一端直接连接到 GND(地)。
在这里插入图片描述
关键点在于: 在按键和电源 VDD_3.3V 之间,串联了一个 10kΩ 的电阻(如 R43、R44 等)。

  • 默认状态(高电平):当按键未按下(断开)时,由于没有电流流向地,PG10 引脚通过这个 10kΩ 电阻被拉高到 3.3V。此时,MCU 读取到的就是高电平(逻辑 1)。
  • 按下状态(低电平):当按键按下(闭合)时,PG10 引脚直接与地短接。电流从 3.3V 经过电阻流入地,引脚处的电压被瞬间拉低至 0V。此时,MCU 读取到的就是低电平(逻辑 0)。

这种电路结构被称为外部上拉电路。这里的 10kΩ 电阻起到了限流和提供默认高电平的作用,是电路稳定工作的关键。如果没有这个电阻,按下按键时电源就会直接对地短路,造成严重后果。

2.3 硬件滤波:电容的作用

仔细观察原理图,你会发现每个按键旁边都并联了一个 100nF(即 0.1μF)的电容(如 C51、C53 等)。

这个电容的作用是硬件消抖。利用电容两端电压不能突变的特性,当按键产生高频抖动脉冲时,电容会通过充放电来平滑这些电压波动。这就好比给信号加了一个低通滤波器,滤除掉那些尖锐的毛刺,让进入 MCU 的信号相对平缓一些。但请注意,硬件电容只能缓解抖动,不能完全消除,因此软件消抖依然是必须的。
在这里插入图片描述

三、MCU GPIO 输入模式选择原则

很多初学者在配置 STM32 的 GPIO 输入模式时非常纠结:到底是选浮空输入、上拉输入还是下拉输入?其实,答案完全取决于你的外部硬件电路

3.1 三种输入模式的本质区别

我们可以通过 MCU 内部的 GPIO 结构图来理解这一点。在输入模式下,MCU 内部有两个关键的开关,分别连接 VDD(上拉)和 VSS(下拉)。

3.1.1 上拉输入模式

  • 内部状态:内部连接 VDD 的开关闭合,连接 VSS 的开关断开。
  • 适用场景:当外部电路是一个单纯的开关接地(如我们的按键电路),且外部没有接上拉电阻时。此时 MCU 内部提供高电平默认值。
  • 注意:如果外部已经有了强上拉电阻,再开启内部上拉虽然通常不会出错(相当于两个电阻并联),但属于资源冗余。
    在这里插入图片描述

3.1.2 下拉输入模式

  • 内部状态:内部连接 VSS 的开关闭合,连接 VDD 的开关断开。
  • 适用场景:当外部电路是开关接电源 VCC,且外部没有接下拉电阻时。此时 MCU 内部提供低电平默认值。
    在这里插入图片描述

3.1.3 浮空输入模式

  • 内部状态:内部的上拉和下拉开关全部断开
  • 适用场景:当外部电路已经提供了确定的上下拉能力时。此时 MCU 引脚呈现高阻态,完全听从外部电路的指挥。
    在这里插入图片描述
    在这里插入图片描述

3.2 为什么我们的按键电路选“浮空输入”?

回到我们的按键电路(图1右侧):

  1. 外部已经有了一个 10kΩ 的上拉电阻连接到 3.3V。
  2. 按键闭合时,外部直接连接到 GND。

这意味着,无论按键是开是合,外部电路都已经给出了确定的电平(3.3V 或 0V)。此时,如果我们再开启 MCU 内部的上拉电阻,不仅多余,还可能因为并联电阻改变分压特性(虽然影响不大,但不规范)。

更重要的是,外部提供的上拉能力是“硬”连接,比内部微弱的上拉更稳定。因此,针对这种外部自带上下拉能力的电路,MCU 端最标准、最合理的配置就是浮空输入

总结原则:

  • 外部没给电平参考 -> 用内部上拉/下拉。
  • 外部给了电平参考 -> 用浮空输入。
  • 记住一句话:MCU 的输入模式配置,是由外部硬件电路决定的,而不是由你的心情决定的。

四、按键抖动现象深度解析

在编写“按一下亮、按一下灭”的代码前,我们必须彻底搞懂什么是抖动。

4.1 理想 vs 实际波形

如果我们用示波器去观察一个机械按键的电压变化,会发现它与理论上的方波截然不同。

  • 理想波形:按下瞬间,电压垂直下降从 3.3V 变到 0V;松开瞬间,电压垂直上升从 0V 变到 3.3V。干净利落,没有任何拖泥带水。
  • 实际波形
    • 按下毛刺:在按键刚刚接触的瞬间,金属弹片会发生剧烈的弹性碰撞。电压会在 3.3V 和 0V 之间快速跳变多次,持续时间通常在 5ms 到 20ms 之间。这就是一串密集的脉冲。
    • 稳定期:抖动结束后,电压才稳定在 0V。
    • 释放毛刺:同理,在松开按键的瞬间,触点分离也会产生类似的反弹抖动,电压再次出现震荡,然后才稳定回到 3.3V。

4.2 施密特触发器能消抖吗?

很多兄弟会问:“MCU 的 GPIO 输入端不是有施密特触发器吗?它不是能整形波形吗?”

答案是:不能依赖它来消抖。

施密特触发器的作用是设定两个阈值(高阈值和低阈值),具有一定的迟滞特性,可以滤除幅度很小的噪声。但是,按键抖动产生的毛刺幅度非常大,往往是满幅度的(直接从 3.3V 跳到 0V 再跳回来)。这种大幅度的跳变完全穿透了施密特触发器的保护区间,会被识别为有效的逻辑电平变化。

如果不做软件处理,MCU 会在短短几毫秒内读到几十次“按下”和“释放”信号。对于运行速度极快的 CPU 来说,你的一次按压就是一次疯狂的连击。这就是为什么我们必须要在软件中加入延时判断或状态机逻辑,人为地“忽略”掉这段抖动时间,只取稳定后的电平值作为有效输入。

五、案例一二实践

5.1 硬件电路与 GPIO 模式选择依据

在编写代码之前,我们必须先看懂原理图
在这里插入图片描述

1. LED 驱动电路分析
观察开发板原理图中的 LED 部分(LED4-LED8),以我们要控制的 LED4(红色)为例:

  • 连接方式:LED4 的阳极通过一个 680Ω 的限流电阻(R47)连接到 3.3V 电源(VDD_3.3V),阴极连接到 MCU 的 PF8 引脚。
  • 点亮逻辑:这是一种典型的“低电平点亮”电路。当 PF8 输出低电平(0V)时,电流从 3.3V 流经电阻、LED 到 PF8,形成回路,LED 点亮;当 PF8 输出高电平(3.3V)时,两端无压差,LED 熄灭。
  • GPIO 配置:因此,在 CubeMX 中配置 PF8 时,我们需要将其设置为 GPIO_Output,输出类型选择 Open Drain(开漏)
    • 为什么要用开漏? 虽然推挽输出也能用,但开漏模式配合外部上拉(或内部上拉)在电平转换和“线与”逻辑中更灵活。在这里,开漏输出“1”时引脚呈高阻态,依靠外部电路或内部上拉维持高电平(灯灭);输出“0”时内部 NMOS 导通接地(灯亮)。

2. 按键输入电路分析
再看按键部分(SW6-SW9),以控制 LED4 的 SW6 为例:

  • 连接方式:SW6 一端连接到 MCU 的 PG10 引脚,另一端接地(GND)。同时,PG10 线上并联了一个 10kΩ 的上拉电阻(R43)连接到 3.3V,以及一个 100nF 的电容(C51)对地滤波。
  • 电平逻辑
    • 松开时:由于上拉电阻 R43 的存在,PG10 被拉高到 3.3V,读取到的电平为 1 (High)
    • 按下时:开关闭合,PG10 直接接地,读取到的电平为 0 (Low)
  • GPIO 模式选择:这里有一个关键知识点——为什么选“浮空输入”而不是“上拉输入”?
    • 虽然 MCU 内部有上拉电阻,但我们的硬件电路上已经设计了外部上拉电阻 R43 和滤波电容 C51。
    • 外部元件的参数(10kΩ, 100nF)是经过计算的,能提供比内部弱上拉更稳定的抗干扰能力。
    • 因此,在 CubeMX 中配置 PG10 时,Mode 选 Input mode,Pull-up/Pull-down 选 No pull-up and no pull-down(即浮空),完全依赖外部电路决定电平状态。
      在这里插入图片描述

5.2 底层原理:GPIO 输入数据寄存器 (IDR)

在代码中,我们使用 HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) 来读取按键状态。这个函数看似简单,但其底层直接操作了 STM32 的 IDR(Input Data Register,端口输入数据寄存器)。理解这一点对于排查问题至关重要。

GPIOx_IDR 寄存器解析
在这里插入图片描述
根据 STM32F103 的参考手册,GPIOx_IDR 寄存器的复位值是 0x0000 XXXX。这里的 XXXX 并不是一个固定的数值,它代表的是复位时刻引脚上的实际物理电平。

  • 高 16 位(Bit 31:16):保留位,始终读为 0。
  • 低 16 位(Bit 15:0):对应端口 0-15 号引脚的输入状态。
    这意味着,IDR 寄存器没有软件意义上的“默认初始值”,它的值完全由硬件电路决定。

MCU 读取引脚电平,本质上是在读 端口输入数据寄存器 (GPIOx_IDR)

  • 地址偏移:0x08。
  • 只读属性:IDR 是只读寄存器。当你执行读操作时,硬件会自动采样当前引脚的物理电平并映射到对应的位上。例如,如果 PG10 按下接地,那么 GPIOG->IDR 的第 10 位就会变成 0。

结合我们电路的实际状态
让我们回到刚才分析的电路:

  • PG10(按键):外部有 10kΩ 上拉电阻。系统刚上电复位时,即使还没运行代码,物理电路上 PG10 已经被拉高到 3.3V。因此,复位后读取 GPIOG->IDR,第 10 位必然是 1
    • 计算: 2 10 = 1024 = 0 x 400 2^{10} = 1024 = 0x400 210=1024=0x400。所以 GPIOG->IDR 的默认值约为 0x0000 0400
  • PF8(LED):外部有上拉且配置为开漏输出 High(高阻态)。同理,物理电平也是高电平。
    • 计算: 2 8 = 256 = 0 x 100 2^8 = 256 = 0x100 28=256=0x100。所以 GPIOF->IDR 的默认值约为 0x0000 0100

HAL 库函数的封装逻辑
我们在代码中调用的 HAL_GPIO_ReadPin 函数,就是对 IDR 寄存器的封装。让我们拆解一下它的源码实现:
在这里插入图片描述

GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin)
{
  GPIO_PinState bitstatus;

  /* 检查参数合法性 */
  assert_param(IS_GPIO_PIN(GPIO_Pin));

  /* 核心逻辑:位运算读取 IDR */
  // GPIOx->IDR 获取整个端口的输入状态
  // & GPIO_Pin 进行按位与操作,屏蔽掉其他无关引脚
  // != (uint32_t)GPIO_PIN_RESET 判断结果是否非零
  if ((GPIOx->IDR & GPIO_Pin) != (uint32_t)GPIO_PIN_RESET)
  {
    bitstatus = GPIO_PIN_SET;   // 结果为1,说明引脚是高电平
  }
  else
  {
    bitstatus = GPIO_PIN_RESET; // 结果为0,说明引脚是低电平
  }
  return bitstatus;
}
  • 枚举类型:返回值 GPIO_PinState 是一个枚举,GPIO_PIN_RESET 对应数值 0,GPIO_PIN_SET 对应数值 1(非零)。
  • 实际意义:当我们调用 HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) 时,如果返回 GPIO_PIN_RESET,就代表 PG10 被按下了(低电平)。

5.3 软件逻辑演进:从“跟随”到“翻转”

有了读写基础,我们来看看业务逻辑的实现。

Version 1:基础电平跟随(回顾)
最直观的写法是直接映射电平:

while (1)
{
    // 开关已经被按下 (读到低电平)
    if(HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET)
    {
        // 开漏模式,写0,点亮LED
        HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_RESET); 
    }
    else
    {
        // 开漏模式,写1,熄灭LED
        HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_SET);   
    }
}

这种逻辑下,灯的状态完全取决于手有没有按着。手一松,灯就灭。这不符合我们日常使用台灯开关的习惯。

Version 2:状态翻转(Toggle)
为了实现“按一下亮,再按一下灭”,我们需要引入一个变量来记录灯的当前状态。

  1. 定义状态变量:在 main.c 的用户代码区定义一个布尔变量 bool led_state = false;(false 代表灭,true 代表亮)。记得包含头文件 #include <stdbool.h>
  2. 翻转逻辑:检测到按键按下时,不再直接写死电平,而是先翻转状态变量,再根据变量写引脚。
// version2 逻辑片段
if(HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET)
{
    led_state = !led_state; // 开关状态取反
    
    // 三元运算符:如果 led_state 为 true,则写 RESET(0) 点亮;否则写 SET(1) 熄灭
    // 注意:因为是低电平点亮,所以 true 对应 RESET
    HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, led_state ? GPIO_PIN_RESET : GPIO_PIN_SET); 
}

在这里插入图片描述

遇到的问题:如果你直接烧录这段代码,会发现现象非常诡异——有时候按一下没反应,有时候按一下灯闪了好几次,或者状态随机变化。这就是著名的按键抖动

5.4 核心痛点:按键抖动与消抖策略

为什么会有抖动?
机械按键内部是金属弹片。在按下和松开的瞬间,金属片不会瞬间完美接触或分离,而是会发生快速的物理弹跳。

  • 理想波形:高 -> 瞬间变低 -> 稳定低。
  • 实际波形:高 -> 高低快速交替震荡(持续约 5ms-20ms) -> 稳定低 -> 松开时再次震荡 -> 稳定高。
    在这里插入图片描述

对于运行速度高达 72MHz 的 STM32 来说,这几十毫秒的震荡期内,CPU 可以执行几百万次循环。如果不处理,CPU 会误以为你在一秒钟内按了几十次开关,导致 led_state 被疯狂翻转,最终状态不可控。

消抖方案:软件延时法
虽然硬件上有电容 C51 做滤波,但为了保险起见,软件消抖依然是标配。最简单有效的方法是延时重测

Version 2-2:带消抖的完整逻辑
完善的代码如下:
在这里插入图片描述

/* Private define */
const uint32_t KEY_DEBOUNCE_MS = 20; // 按键消抖时间,单位毫秒

/* USER CODE END WHILE */
while (1)
{
    // version2-2: 第一次检测
    if(HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET)
    {
        // 1. 发现低电平,先别急,可能是抖动
        HAL_Delay(KEY_DEBOUNCE_MS); // 延时 20ms,等待物理震荡结束
        
        // 2. 第二次检测:如果还是低电平,说明是真的按下了
        if(HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET)
        {
            led_state = !led_state;
            HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, led_state ? GPIO_PIN_RESET : GPIO_PIN_SET);
            
            // 3. 【关键点】等待松手
            // 只要按键还按着(一直是低电平),就在这里死循环等待
            // 防止按住不放时,循环回来再次触发翻转
            while(HAL_GPIO_ReadPin(GPIOG, GPIO_PIN_10) == GPIO_PIN_RESET);
        }
    }
}

代码细节深度解析:

  1. 双重确认:第一次读到 0 后,延时 20ms 再读一次。如果此时还是 0,才确认为有效按下。这过滤掉了大部分抖动毛刺。
  2. 等待松手机制:注意最后那行 while(... == RESET);
    • 如果没有这行代码,当你手速较慢,按住按键超过 20ms 时,主循环会再次跑回来,再次进入 if,导致一次按压触发多次翻转。
    • 加上这行代码后,程序会“卡”在这里,直到你松开手(电平变回 1),循环条件不满足,程序才会继续往下走,回到 while(1) 的开头等待下一次按压。
    • 这就实现了完美的“单次触发”逻辑。

通过这一套组合拳(硬件浮空输入 + 软件延时消抖 + 松手检测),我们就完成了一个工程级的按键控制 LED 翻转功能。接下来的文章要讲‘四键四灯’的多路联动了~


结语

在这里插入图片描述

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐