嵌入式C实战第19篇从输出到输入 —— 为什么按钮比 LED 难仓库已经开源仍然在持续建设中喜欢的话点个⭐相关的链接如下https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP静态网页直接阅览https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/PS: 现在仓库大更新笔者更新了一下仓库的架构预计周末会出一个新仓库宣发嘿嘿还记得我最早的500粉公众号宣发的操作系统教程吗期待一下C写的操作系统和一个老仓库也就是这个系列的文章归属的仓库的更新宣发恭喜你走完了 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 就是 01 就是 1。按钮是输入设备信号来自物理世界而物理世界永远不完美。消抖debounce——在软件层面过滤掉这些机械抖动——是按钮教程绕不过去的必修课。挑战三时序管理LED 教程中我们大量使用HAL_Delay()来控制闪烁间隔。HAL_Delay(500)就是死等 500 毫秒CPU 什么都不做就是循环数 tick。在 LED 场景下这没问题——反正闪烁是唯一的任务等就等了。但按钮不行。按钮的消抖需要时间通常 20ms如果你在这段时间里用HAL_Delay()阻塞等待整个系统就停了。如果你的项目里不只有按钮还有 LED 要闪烁、有传感器要读取、有通信协议要处理那阻塞等待 20ms 就意味着其他任务全部暂停。这在实时系统中是不可接受的。解决方案是非阻塞消抖用HAL_GetTick()获取当前时间戳记住状态变化发生的时间下次循环时检查是否已经过了足够长的时间来确认状态。这种方式不阻塞 CPU主循环可以继续干其他事。但它引入了一个新的编程范式——状态机。你需要用状态变量来记录当前处于什么阶段、“下一个阶段是什么”而不是简单地延时等待。这三个挑战叠加在一起让按钮控制看起来比 LED 复杂了好几倍。但别担心——我们有 12 篇文章的时间一个一个把它们吃透。最终效果预览在正式开始之前我想先把我们要达到的最终效果亮出来让你知道终点长什么样。这是完成所有重构后main.cpp的完整代码#includedevice/button.hpp#includedevice/button_event.hpp#includedevice/led.hpp#includesystem/clock.hexternC{#includestm32f1xx_hal.h}intmain(){HAL_Init();clock::ClockConfig::instance().setup_system_clock();device::LEDdevice::gpio::GpioPort::C,GPIO_PIN_13led;device::Buttondevice::gpio::GpioPort::A,GPIO_PIN_0button;while(1){button.poll_events([](device::ButtonEvent event){std::visit([](autoe){usingTstd::decay_tdecltype(e);ifconstexpr(std::is_same_vT,device::Pressed){led.on();}else{led.off();}},event);},HAL_GetTick());}}如果你完成了 LED 教程前半部分应该很眼熟HAL_Init()、系统时钟配置、LEDGpioPort::C, GPIO_PIN_13模板实例化——这些和 LED 教程一模一样。新鲜的是后半部分。ButtonGpioPort::A, GPIO_PIN_0声明了一个按钮对象编译时就把端口 A、引脚 0、上拉模式、低电平有效这些配置全部锁进了类型系统。poll_events()是这个按钮对象的核心方法——它在内部维护一个 7 状态的状态机每次被调用时采样一次引脚电平根据当前状态和时间戳判断是否发生了有效的按下或释放事件。如果确认了状态变化poll_events()会通过回调函数通知你。回调参数ButtonEvent是一个std::variantPressed, Released——这是 C17 的类型安全联合体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 篇用 C20 Concepts 约束回调函数的类型确保传给poll_events()的回调签名正确。第 12 篇引入 EXTI 外部中断作为按钮检测的替代方案附带常见坑位汇总和练习题。硬件准备硬件方面你需要的还是 LED 教程那一套 Blue Pill ST-Link额外加一个按钮开关。具体来说STM32F103C8T6 Blue Pill 开发板— 和 LED 教程同一块板子ST-Link V2 调试器— 烧录和调试用和 LED 教程一样一个按钮开关— 最普通的轻触按键就行2 脚或 4 脚都可以淘宝几毛钱一个接线方案非常简单按钮一端 → PA0 排针孔 按钮另一端 → GND 排针孔就这两根线。不需要电阻——STM32 内部有上拉电阻我们在软件里启用它就行了。PC13 的板载 LED 还是和 LED 教程一样不需要额外接线。为什么选 PA0两个原因。第一PA0 在 Blue Pill 的排针上很好找接线方便。第二STM32F103 的 EXTI外部中断控制器中PA0 对应 EXTI0EXTI0 有自己独立的中断向量EXTI0_IRQn。这意味着我们在第 12 篇讲中断驱动按钮时不需要处理中断向量共享的问题。如果你选了 PA5那 EXTI5 和 EXTI9 之间就要共享一个中断向量配置起来多一步。先用最简单的 PA0把原理搞清楚再说。⚠️ 如果你手边没有按钮开关也可以直接用一根杜邦线模拟——一端插 PA0另一端碰一下 GND 再松开效果和按钮一样。只是没有弹簧回弹手感差一些但用来学习足够了。接下来去哪准备工作做完了挑战也列出来了最终效果也看了。从下一篇开始我们要一头扎进 GPIO 输入模式的内部电路里去。下一篇讲的是 GPIO 在输入模式下的信号路径引脚上的电压信号经过了哪些电路元件上拉电阻和下拉电阻在芯片内部是怎么连接的施密特触发器为什么是输入路径中不可缺少的一环以及IDR寄存器的每一个 bit 是怎么和物理引脚对应的。理解了这些你在配置 GPIO 输入模式时就不会是照着代码抄参数而是我知道这个参数在电路里做了什么。准备好了吗我们出发。相关阅读第15篇第三次重构 —— if constexpr让时钟使能在编译时自动选对 - 相似度 67%第17篇C23特性收尾 —— 属性、链接与零开销抽象的最终证明 - 相似度 67%