第19篇:从输出到输入 —— 为什么按钮比 LED 难
恭喜你走完了 LED 教程的 13 篇。现在我们有了 GPIO 输出的基础,有了模板和
enum class的经验,是时候面对一个新的挑战了:让芯片听懂人类的操作。
从"说话"到"听话"
LED 教程教会了我们一件事:怎么让芯片"说话"。我们用 GPIO 输出驱动 PC13 引脚,控制 LED 的亮和灭。整个过程中,主动权完全在芯片手里——代码决定什么时候拉高、什么时候拉低,引脚忠实地执行命令,LED 就乖乖地亮或灭。这是一条单向的街道:CPU → GPIO → 物理世界。
按钮做的事情恰好反过来。按钮是物理世界对芯片"说话"——用户按下按钮,引脚上的电压发生变化,CPU 需要去"听"这个变化,然后做出响应。听起来只是把输出换成输入,但一旦你真的动手去做,就会发现事情远没有那么简单。
为什么?因为在 LED 教程里,我们控制的是一个理想的数字世界。HAL_GPIO_WritePin() 写一个高电平,引脚就是高电平。一就是一,零就是零,干净利落。但按钮面对的是物理世界的真实信号,而物理世界从来不像数字世界那么"干净"。
按钮的三个新挑战
挑战一:读取而非写入
LED 教程里,我们的 GPIO 工作在输出模式。输出模式的核心操作是"写"——往 ODR(输出数据寄存器)写一个值,引脚电平就跟着变。芯片是信号的主人。
按钮要求 GPIO 工作在输入模式。输入模式的核心操作是"读"——从 IDR(输入数据寄存器)读一个值,这个值反映了引脚上当前的实际电压。芯片是信号的观察者。
这个角色转换听起来微不足道,但它意味着你需要理解一整套新的东西:输入模式下的 GPIO 内部电路长什么样?上拉电阻和下拉电阻有什么区别?浮空输入为什么不可靠?施密特触发器在输入路径中起什么作用?这些在 LED 教程中我们一笔带过的内容,现在必须掰开揉碎了讲清楚,因为输入配置做错了,你连按钮的状态都读不对。
挑战二:物理世界的噪声
这是按钮教程中最出乎意料、也最容易让人掉坑里的部分。
你可能以为按钮就是一个理想的开关——按下就是低电平,松开就是高电平,干净利落的 0 和 1 之间的切换。但现实是残酷的:机械开关在触点闭合和断开的瞬间,由于金属的弹性,会产生 5 到 20 毫秒的电平震荡。在示波器上看,就是你以为应该是一个干净的下降沿,结果是一连串快速的高高低低跳变。
如果你的代码不做任何处理,直接在主循环里读引脚状态,那一次正常的按钮按下可能会被 CPU 误读为三四次甚至七八次"按下-释放"循环。LED 不亮或者 LED 疯狂闪烁——不是硬件坏了,是你的代码被物理世界的噪声欺骗了。
LED 教程从来没遇到过这个问题。因为 LED 是输出设备,信号由芯片产生,0 就是 0,1 就是 1。按钮是输入设备,信号来自物理世界,而物理世界永远不完美。消抖(debounce)——在软件层面过滤掉这些机械抖动——是按钮教程绕不过去的必修课。
挑战三:时序管理
LED 教程中我们大量使用 HAL_Delay() 来控制闪烁间隔。HAL_Delay(500) 就是死等 500 毫秒,CPU 什么都不做,就是循环数 tick。在 LED 场景下这没问题——反正闪烁是唯一的任务,等就等了。
但按钮不行。按钮的消抖需要时间(通常 20ms),如果你在这段时间里用 HAL_Delay() 阻塞等待,整个系统就停了。如果你的项目里不只有按钮,还有 LED 要闪烁、有传感器要读取、有通信协议要处理,那阻塞等待 20ms 就意味着其他任务全部暂停。这在实时系统中是不可接受的。
解决方案是非阻塞消抖:用 HAL_GetTick() 获取当前时间戳,记住状态变化发生的时间,下次循环时检查"是否已经过了足够长的时间"来确认状态。这种方式不阻塞 CPU,主循环可以继续干其他事。但它引入了一个新的编程范式——状态机。你需要用状态变量来记录"当前处于什么阶段"、"下一个阶段是什么",而不是简单地延时等待。
这三个挑战叠加在一起,让按钮控制看起来比 LED 复杂了好几倍。但别担心——我们有 12 篇文章的时间,一个一个把它们吃透。
最终效果预览
在正式开始之前,我想先把我们要达到的最终效果亮出来,让你知道终点长什么样。这是完成所有重构后,main.cpp 的完整代码:
#include "device/button.hpp"
#include "device/button_event.hpp"
#include "device/led.hpp"
#include "system/clock.h"
extern "C" {
#include "stm32f1xx_hal.h"
}
int main() {
HAL_Init();
clock::ClockConfig::instance().setup_system_clock();
device::LED<device::gpio::GpioPort::C, GPIO_PIN_13> led;
device::Button<device::gpio::GpioPort::A, GPIO_PIN_0> button;
while (1) {
button.poll_events(
[&](device::ButtonEvent event) {
std::visit(
[&](auto&& e) {
using T = std::decay_t<decltype(e)>;
if constexpr (std::is_same_v<T, device::Pressed>) {
led.on();
} else {
led.off();
}
},
event);
},
HAL_GetTick());
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
如果你完成了 LED 教程,前半部分应该很眼熟:HAL_Init()、系统时钟配置、LED<GpioPort::C, GPIO_PIN_13> 模板实例化——这些和 LED 教程一模一样。
新鲜的是后半部分。Button<GpioPort::A, GPIO_PIN_0> 声明了一个按钮对象,编译时就把端口 A、引脚 0、上拉模式、低电平有效这些配置全部锁进了类型系统。poll_events() 是这个按钮对象的核心方法——它在内部维护一个 7 状态的状态机,每次被调用时采样一次引脚电平,根据当前状态和时间戳判断是否发生了有效的按下或释放事件。
如果确认了状态变化,poll_events() 会通过回调函数通知你。回调参数 ButtonEvent 是一个 std::variant<Pressed, Released>——这是 C++17 的类型安全联合体,Pressed 表示"按钮被按下",Released 表示"按钮被释放"。我们用 std::visit 加一个泛型 lambda 来处理这两种事件:按下就让 LED 亮,否则就灭。
别被这些新名词吓到——std::variant、std::visit、泛型 lambda、if constexpr——它们每一个都会在后面的文章中被拆解到不能再细。现在你只需要知道:这段代码完成了按钮消抖、状态机管理、事件分发三件事,而且全部是编译时零开销的。编译出来的机器码和你手写 C 直接读引脚、手动消抖的版本没有任何区别。
我们要走的路
按钮教程共 12 篇,分四个阶段。每个阶段解决一个问题,逐步从裸硬件演进到现代 C++ 抽象。
阶段一:硬件基础(第 02-03 篇)
先搞清楚硬件。第 02 篇讲 GPIO 输入模式的内部电路——上拉、下拉、浮空三种输入模式有什么区别,施密特触发器为什么存在,IDR 寄存器怎么工作。这些内容在 LED 教程里我们基本跳过了,因为输出模式不需要深入理解输入路径。但现在不一样了,输入路径就是我们的主战场。
第 03 篇把 GPIO 输入的知识用到按钮电路上。我们会画按钮的接线图,计算上拉电阻的电流,最重要的是——详细解释机械抖动的物理原理和示波器波形。理解了抖动是怎么回事,你才能真正理解后面所有消抖算法的设计动机。
阶段二:HAL + C 实战(第 04-06 篇)
硬件搞清楚了,接下来是 HAL API 和 C 语言实现。第 04 篇拆解 HAL_GPIO_ReadPin() 的工作原理和输入模式的初始化流程。第 05 篇用纯 C 写一个最简单的按钮轮询程序——能跑,但会因为抖动而多次触发。第 06 篇引入非阻塞消抖算法,用 HAL_GetTick() 做时间管理,消除抖动问题。
这三篇的价值在于让你"脏一次手"——先用最直接的方式解决问题,亲身体验 C 语言写法的局限性和消抖算法的演进过程。有了这些实际经验,后面 C++ 重构时你就会觉得"确实应该这样重构",而不是"为什么要搞这么复杂"。
阶段三:状态机消抖(第 07 篇)
第 07 篇是本系列的核心篇。我们用一个 7 状态的状态机来重新实现消抖逻辑。这个状态机不是什么过度设计——7 个状态中每一个都有明确的存在理由,包括一个特别的"启动锁"机制来处理"按钮在系统上电时已经被按住"这种边界情况。这一篇会逐行解读 button.hpp 中 poll_events() 方法的实现。
阶段四:C++ 重构(第 08-12 篇)
最后 5 篇是 C++ 重构的重头戏。第 08 篇用 enum class 重新定义按钮相关的枚举类型。第 09 篇引入 std::variant 和 std::visit 构建类型安全的事件系统。第 10 篇设计 Button 模板类,把端口、引脚、上下拉、电平有效极性全部编码进编译时类型。第 11 篇用 C++20 Concepts 约束回调函数的类型,确保传给 poll_events() 的回调签名正确。第 12 篇引入 EXTI 外部中断作为按钮检测的替代方案,附带常见坑位汇总和练习题。
硬件准备
硬件方面,你需要的还是 LED 教程那一套 Blue Pill + ST-Link,额外加一个按钮开关。具体来说:
- STM32F103C8T6 Blue Pill 开发板 — 和 LED 教程同一块板子
- ST-Link V2 调试器 — 烧录和调试用,和 LED 教程一样
- 一个按钮开关 — 最普通的轻触按键就行,2 脚或 4 脚都可以,淘宝几毛钱一个
接线方案非常简单:
按钮一端 → PA0 排针孔
按钮另一端 → GND 排针孔2
就这两根线。不需要电阻——STM32 内部有上拉电阻,我们在软件里启用它就行了。PC13 的板载 LED 还是和 LED 教程一样,不需要额外接线。
为什么选 PA0?两个原因。第一,PA0 在 Blue Pill 的排针上很好找,接线方便。第二,STM32F103 的 EXTI(外部中断控制器)中,PA0 对应 EXTI0,EXTI0 有自己独立的中断向量 EXTI0_IRQn。这意味着我们在第 12 篇讲中断驱动按钮时,不需要处理中断向量共享的问题。如果你选了 PA5,那 EXTI5 和 EXTI9 之间就要共享一个中断向量,配置起来多一步。先用最简单的 PA0,把原理搞清楚再说。
⚠️ 如果你手边没有按钮开关,也可以直接用一根杜邦线模拟——一端插 PA0,另一端碰一下 GND 再松开,效果和按钮一样。只是没有弹簧回弹,手感差一些,但用来学习足够了。
接下来去哪
准备工作做完了,挑战也列出来了,最终效果也看了。从下一篇开始,我们要一头扎进 GPIO 输入模式的内部电路里去。
下一篇讲的是 GPIO 在输入模式下的信号路径:引脚上的电压信号经过了哪些电路元件,上拉电阻和下拉电阻在芯片内部是怎么连接的,施密特触发器为什么是输入路径中不可缺少的一环,以及 IDR 寄存器的每一个 bit 是怎么和物理引脚对应的。理解了这些,你在配置 GPIO 输入模式时就不会是"照着代码抄参数",而是"我知道这个参数在电路里做了什么"。
准备好了吗?我们出发。