文章目录
-
- [一、按键硬件:为什么是"按下接地 + 上拉"](#一、按键硬件:为什么是"按下接地 + 上拉")
-
- [1.1 四个按键的接法](#1.1 四个按键的接法)
- [1.2 为什么不配成"双边沿触发"](#1.2 为什么不配成"双边沿触发")
- [1.3 EXTI 线的一个硬限制:同号线只能选一个](#1.3 EXTI 线的一个硬限制:同号线只能选一个)
- [1.4 EXTI15_10 是"合租"的中断](#1.4 EXTI15_10 是"合租"的中断)
- [1.5 优先级为什么全是 1](#1.5 优先级为什么全是 1)
- 二、中断链路:从引脚跳变到你的代码
-
- [2.1 完整的三跳](#2.1 完整的三跳)
- [2.2 中断里能做什么、不能做什么](#2.2 中断里能做什么、不能做什么)
- [2.3 为什么中断里 `HAL_Delay` 会卡死](#2.3 为什么中断里
HAL_Delay会卡死) - [2.4 那中断里的 `printf` 为什么能用](#2.4 那中断里的
printf为什么能用)
- [三、消抖:40ms 时间窗与每键独立时间戳](#三、消抖:40ms 时间窗与每键独立时间戳)
-
- [3.1 机械按键为什么会"一按多响"](#3.1 机械按键为什么会"一按多响")
- [3.2 最朴素的消抖:40ms 内重复触发直接丢](#3.2 最朴素的消抖:40ms 内重复触发直接丢)
- [3.3 为什么用减法而不是比较大小](#3.3 为什么用减法而不是比较大小)
- [3.4 本工程真实踩过的坑:共用一个时间戳](#3.4 本工程真实踩过的坑:共用一个时间戳)
- [3.5 为什么不用"延时 20ms 再读电平"的经典消抖](#3.5 为什么不用"延时 20ms 再读电平"的经典消抖)
- 四、三模式状态机:一个变量管住整机
-
- [4.1 状态变量与三个模式](#4.1 状态变量与三个模式)
- [4.2 按键语义总表(这一章的核心)](#4.2 按键语义总表(这一章的核心))
- [4.3 按键回调的完整分派逻辑](#4.3 按键回调的完整分派逻辑)
- [4.4 KEY4 的复杂逻辑:为什么只置标志不干活](#4.4 KEY4 的复杂逻辑:为什么只置标志不干活)
- [4.5 Switch_Mode:一切切换的入口](#4.5 Switch_Mode:一切切换的入口)
- [4.6 `g_net` 为什么也要在切模式时归零](#4.6
g_net为什么也要在切模式时归零)
- 五、中断与主循环的协作:标志位模式
-
- [5.1 本工程的"中断→主循环"通信清单](#5.1 本工程的"中断→主循环"通信清单)
- [5.2 为什么 `printf` 也要挪出中断](#5.2 为什么
printf也要挪出中断) - [5.3 主循环的纪律:任何分支都不能堵死](#5.3 主循环的纪律:任何分支都不能堵死)
- [5.4 如果当初不用状态机会怎样](#5.4 如果当初不用状态机会怎样)
- 六、调不通时按这个顺序查
-
- [6.1 按键完全没反应](#6.1 按键完全没反应)
- [6.2 按键行为诡异对照表](#6.2 按键行为诡异对照表)
- 七、本章易错点总结
- 八、小结与下一篇
- [附录 A:本章涉及的完整代码](#附录 A:本章涉及的完整代码)
-
- [A.1 引脚与模式宏(`main.c` 顶部)](#A.1 引脚与模式宏(
main.c顶部)) - [A.2 全局状态(中断与主循环共享,全部 volatile)](#A.2 全局状态(中断与主循环共享,全部 volatile))
- [A.3 按键中断回调(完整版)](#A.3 按键中断回调(完整版))
- [A.4 模式切换函数](#A.4 模式切换函数)
- [A.5 主循环里的协作段](#A.5 主循环里的协作段)
- [A.6 CubeMX 配置清单(对照检查用)](#A.6 CubeMX 配置清单(对照检查用))
- [A.7 最小验证步骤](#A.7 最小验证步骤)
- [A.1 引脚与模式宏(`main.c` 顶部)](#A.1 引脚与模式宏(
系列第九篇。前面几章解决了"感知"(DHT11、ENS160)和"动作"(PWM 三件套),这一章解决"人怎么插手":四个按键、三种模式,全靠这一层调度。
我在这一章踩过的最久的坑不是代码逻辑,而是一个"看着没问题"的消抖写法:四个按键共用一个时间戳。现象是"连按两个键,第二个键经常没反应",我一度以为是按键硬件接触不良,换了一板按键才定位到软件。
一、按键硬件:为什么是"按下接地 + 上拉"
1.1 四个按键的接法
本工程四个按键全部是一端接 GPIO、一端接 GND:
text
3.3V
│
[内部上拉电阻 ~40kΩ] ← CubeMX 里开 Pull-up,不用外接
│
GPIO(PC2/PC3/PA1/PB10)──┐
├── 松开:被上拉拉到 3.3V = 高电平
[按键] │
│ └── 按下:直接连到 GND = 低电平
GND
| 按键 | 引脚 | EXTI 线 | NVIC 优先级 |
|---|---|---|---|
| KEY1 | PC2 | EXTI2 | 1, 0 |
| KEY2 | PC3 | EXTI3 | 1, 0 |
| KEY3 | PA1 | EXTI1 | 1, 0 |
| KEY4 | PB10 | EXTI15_10 | 1, 0 |
要点:
- 按下 = 低电平,松开 = 高电平 。所以触发沿选下降沿 (
GPIO_MODE_IT_FALLING)------按下的一瞬间产生中断; - 上拉用 CubeMX 的内部上拉(Pull-up)就够了,不用外接电阻。内部上拉约 40kΩ,对按键这种慢速信号完全够;
- 如果你的板子是"按下接 3.3V"的设计,全部逻辑反过来:下降沿改上升沿、上拉改下拉。先看你板子的原理图再选,别抄。
1.2 为什么不配成"双边沿触发"
有人想"按下抬手都检测",选 GPIO_MODE_IT_RISING_FALLING。对教学项目来说这是自找麻烦:
- 按一次键会进两次中断(按下一次、抬手一次),消抖逻辑要处理两个方向;
- 机械弹跳在按下和松开时都存在,双倍的中断量让"按一次算几次"更难判断。
只检测下降沿,一次按下对应一次有效事件,简单可靠。需要"长按检测"再说,本工程用不上。
1.3 EXTI 线的一个硬限制:同号线只能选一个
EXTI 的线是按引脚编号分的,不是按端口分的:
text
EXTI1 线上可以挂:PA1 或 PB1 或 PC1 ...... 但同一时刻只能选一个
本工程 KEY3 用了 PA1,那就意味着 PB1、PC1 都不能再做外部中断输入(本工程 PB1 是 DHT11 数据脚、PC1 是蓝牙复位脚,恰好都只是普通 GPIO,不冲突)。
CubeMX 里给 PA1 配 EXTI 后,如果你再给 PB1 配 EXTI,它会把 PA1 的中断挤掉(SYSCFG 里同一根线只能映射一个端口)。现象是"PA1 按键突然不灵了",排查半天才发现是后配的那个引脚干的。
1.4 EXTI15_10 是"合租"的中断
EXTI0~4 各自有独立的中断入口(EXTI0_IRQHandler ... EXTI4_IRQHandler),但:
- EXTI5~9 共用一个入口
EXTI9_5_IRQHandler; - EXTI10~15 共用一个入口
EXTI15_10_IRQHandler。
KEY4 在 PB10,属于后者。这意味着:如果以后再给 PC13、PA15 之类的引脚配中断,它们和 KEY4 进同一个 IRQHandler ,回调时要用 GPIO_Pin 参数区分是谁触发的。
好消息是 HAL 的回调参数 GPIO_Pin 就是为这个设计的------不管哪个入口进来,最后都汇到同一个 HAL_GPIO_EXTI_Callback(GPIO_Pin),靠参数分辨按键。下一节细说。
1.5 优先级为什么全是 1
四个按键的 NVIC 配置都是 HAL_NVIC_SetPriority(EXTIx_IRQn, 1, 0)------抢占优先级 1、子优先级 0,完全相同。
效果是:
- 四个按键互相不能打断(同抢占优先级不嵌套);
- 谁先按谁先执行完,后到者排队(毫秒级,人无感);
- 按键中断可以被 优先级 0 的中断打断,也可以打断优先级 ≥2 的中断。
真正要盯的不是按键之间的优先级,而是按键和 SysTick 的相对关系 ------这决定了"中断里能不能用 HAL_Delay",2.3 节会专门讲。
二、中断链路:从引脚跳变到你的代码
2.1 完整的三跳
text
按下 KEY1(PC2 出现下降沿)
│
▼
EXTI2_IRQHandler() ← 在 stm32f4xx_it.c,CubeMX 生成
│
▼
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_2) ← HAL 库函数:清除挂起标志位
│
▼
HAL_GPIO_EXTI_Callback(GPIO_PIN_2) ← 你的业务代码写这里
三个细节:
① 标志位是 HAL 帮你清的
HAL_GPIO_EXTI_IRQHandler 内部会先检查"这条线的挂起标志置位没有",置位就清掉 再回调。如果你绕过 HAL 直接写 IRQHandler,忘了清标志,结果是进一次中断后无限重进------主循环完全卡死,现象和程序跑飞一模一样。
② 业务代码写在哪
写在 HAL_GPIO_EXTI_Callback() 里,而且放在 main.c 的 /* USER CODE BEGIN 0 */ 这类保护区。不要 去改 stm32f4xx_it.c 里的 EXTI2_IRQHandler------那里没有 USER CODE 保护区,CubeMX 重新生成代码时会被覆盖,你的逻辑无声无息就没了。
③ 回调是"弱定义",全工程只能有一个
HAL_GPIO_EXTI_Callback 在 HAL 库里是 __weak 函数,你自己定义一个就覆盖它。全工程只能定义一次 ------如果你在 key.c 里写了一个、又在 main.c 里写了一个,链接器不会报错,但只有一个生效,另一个永远不执行。排查"按键一半灵一半不灵"时记得搜一下这个函数名出现了几次。
2.2 中断里能做什么、不能做什么
这是本章最重要的一节,比消抖还重要。
| 操作 | 能不能做 | 原因 |
|---|---|---|
读写全局标志(g_mode、g_fan_on) |
✅ 可以 | 一条指令的事 |
__HAL_TIM_SET_COMPARE 改 CCR |
✅ 可以 | 只写一个寄存器 |
短时间 printf 打日志 |
⚠️ 可以但要克制 | 阻塞发送,字符串长了会拖住中断 |
HAL_Delay() |
❌ 禁止 | 见下面 2.3 |
HAL_UART_Receive 阻塞接收 |
❌ 禁止 | 等不到数据就永远卡住 |
Mqtt_Server_Init() 这种十几秒的操作 |
❌ 绝对禁止 | 整机卡死十几秒,按键全废 |
while(1) 等待 |
❌ 绝对禁止 | 等于死机 |
一句话总结:中断里只做"立刻能做完的事",耗时的活全部打包成"请求",扔给主循环去做。 本工程有两处现成的例子,第五章展开讲。
2.3 为什么中断里 HAL_Delay 会卡死
HAL_Delay() 的实现是:
c
uint32_t start = HAL_GetTick();
while((HAL_GetTick() - start) < delay) { } // 等 uwTick 涨够 delay 毫秒
而 uwTick 是靠 SysTick 中断每 1ms 加一次的。
CubeMX 默认给 SysTick 的优先级是最低(15) ,按键 EXTI 是 1。当按键中断正在执行时,优先级更低的 SysTick 中断进不来 ,uwTick 被冻结,HAL_GetTick() 永远返回同一个值------while 循环变成死循环。
现象:按一下按键,整机死机,串口最后一条日志停在中断里那句打印。这个 bug 的危害在于:调试时如果在中断里随手加一句 HAL_Delay(10) 想"消抖",程序立刻死给你看,而且死得毫无提示。
2.4 那中断里的 printf 为什么能用
printf 重定向到串口后,底层是 HAL_UART_Transmit() 的阻塞发送 :发一个字节等一个发送完成标志。它等的是硬件寄存器标志,不靠任何中断,所以在中断里也能跑完。
但代价是时间:115200 波特率下一个字节约 87µs,一句 30 字符的日志要堵在中断里约 2.6ms。本工程按键日志都很短,可以接受;但如果你在中断里打印一大段 JSON,后果是:
- 中断执行时间变长,其他中断被延迟;
- 自动模式的 2 秒节拍被拖歪;
- DHT11 的
__disable_irq()区间如果撞上长 printf,时序直接报废。
经验值:中断里的日志控制在 30 字符以内,且只用于调试关键事件。 正式版本里,中断只置标志,打印挪到主循环。
三、消抖:40ms 时间窗与每键独立时间戳
3.1 机械按键为什么会"一按多响"
理想中按一下按键,电平干净地跳一次。实际上机械触点接触的瞬间会弹跳几毫秒:
text
理想: ‾‾‾‾‾\____________________
一次下降沿
实际: ‾‾‾‾‾\/\/\/\/\____________
←~5~10ms→
弹跳期间产生 3~5 个下降沿
软件上看到的就是:按一次,进 3~5 次中断。如果不处理,按一下风扇会"开-关-开-关-开",最后停在哪一档全凭运气。
3.2 最朴素的消抖:40ms 内重复触发直接丢
c
if(new_tick - *p_tick < 40) return; // 距上次有效触发不足 40ms → 是弹跳,丢弃
*p_tick = new_tick;
原理:弹跳全部发生在按下后的头 10ms 内,任何两次触发间隔小于 40ms,后一次一定是弹跳。人手指连续按两下,间隔再快也在 100ms 以上,不会误伤。
为什么是 40ms 而不是 10ms:
- 10ms 离弹跳期太近,劣质按键弹跳能拖到 15ms,挡不住;
- 40ms 留足余量,又远小于人手最快连按间隔(约 100ms)。
这个值不用精确,20~50ms 都行,但别超过 100ms------否则快速双击会被吞。
3.3 为什么用减法而不是比较大小
注意写法是 new_tick - *p_tick < 40,不是 new_tick > *p_tick + 40。
区别在 HAL_GetTick() 的溢出:uwTick 是 uint32_t,约 49.7 天 后从 0xFFFFFFFF 回绕到 0。
- 减法写法:回绕瞬间
new_tick=5、*p_tick=0xFFFFFFFA,无符号减法得5 - 0xFFFFFFFA = 11,结果依然正确; - 加法写法:
*p_tick + 40溢出变成小数,new_tick > 小数永远成立,回绕后消抖失效。
49.7 天看似遥远,但这是一个"零成本就能写对"的习惯:所有 HAL_GetTick 的间隔判断都用减法,以后每个工程都这么写,永远不用想回绕的事。
3.4 本工程真实踩过的坑:共用一个时间戳
最原始的版本是这么写的:
c
static uint32_t last_tick = 0; // ❌ 四个键共用
if(HAL_GetTick() - last_tick < 40) return;
last_tick = HAL_GetTick();
逻辑看起来没问题,但实际使用中出现一个诡异现象:
按一下 KEY1 开风扇,紧接着按 KEY3 想动窗帘,KEY3 没反应。 过一会儿再按又好了。
原因:两个按键共用一个消抖时钟 。KEY1 刚把 last_tick 更新成"现在",40ms 内 KEY3 的中断进来,被判定为"重复触发"丢弃。人手"连按两个不同键"的间隔经常小于 40ms------尤其调试时一手按一个键,几乎是同时。
修法就是本工程现在的写法:每个按键一个独立时间戳:
c
static uint32_t tick_key1 = 0, tick_key2 = 0, tick_key3 = 0, tick_key4 = 0;
uint32_t *p_tick;
if (GPIO_Pin == KEY1_Pin) p_tick = &tick_key1;
else if(GPIO_Pin == KEY2_Pin) p_tick = &tick_key2;
else if(GPIO_Pin == KEY3_Pin) p_tick = &tick_key3;
else p_tick = &tick_key4; // KEY4
if(new_tick - *p_tick < 40) return;
*p_tick = new_tick;
消抖的目的是"挡住同一个键的弹跳",不是"限制人按多快"。不同按键之间没有消抖关系,时间戳必须分开。
排查口诀:"连按被吞查共用,一按多响查没抖。" 前者的根因是共享时间戳,后者的根因是根本没消抖或窗口太小。
3.5 为什么不用"延时 20ms 再读电平"的经典消抖
教科书里还有一种写法:
c
HAL_Delay(20); // ❌ 在中断里等 20ms
if(HAL_GPIO_ReadPin(KEY1_GPIO_Port, KEY1_Pin) == GPIO_PIN_RESET)
{
// 确认真的按下了
}
它的问题在 2.3 节已经讲过了:中断里不能 HAL_Delay,会冻结 SysTick 死循环。就算换个方式实现延时,在中断里多待 20ms 也意味着这 20ms 内其他按键、其他中断全部排队------完全没必要。时间戳法不占中断时间,是这个场景下的最优解。
四、三模式状态机:一个变量管住整机
4.1 状态变量与三个模式
c
#define MODE_MANUAL 0 // 手动模式:KEY1/3/4 直接开关设备
#define MODE_AUTO 1 // 自动模式:温度+空气质量自动控制
#define MODE_REMOTE 2 // 远程模式:蓝牙/MQTT 命令控制
volatile uint8_t g_mode = MODE_MANUAL; // 上电默认手动
两个设计点:
① 为什么上电默认是手动,不是自动?
安全。上电时硬件状态最不可控------传感器没预热、网络没连上,这时候如果直接进自动模式,可能根据"还没稳定的数据"误开加热片。手动模式所有设备默认全关,是最安全的初始状态,由人确认环境后再切自动。
② volatile 一个都不能少
g_mode 在中断里写、主循环里读 。没有 volatile,编译器可能把主循环里的 g_mode 缓存在寄存器里,中断改了内存主循环却永远读到旧值------现象是"按了 KEY2,串口都打印切模式了,主循环的行为却没变"。
判断标准很简单:一个变量只要跨"中断/主循环"或"主循环/回调"两边访问,就必须 volatile 。本工程的 g_fan_on、g_curtain_open、g_heat_on、g_net、g_net_req、g_net_busy、g_print_req 全都是。
4.2 按键语义总表(这一章的核心)
四个按键在三种模式下的含义完全不同:
| 当前模式 | KEY1 (PC2) | KEY2 (PC3) | KEY3 (PA1) | KEY4 (PB10) |
|---|---|---|---|---|
| 手动 MANUAL | 风扇 开/关 | 退出手动 → 回自动 | 窗帘 拉开/关闭 | 加热 开/关 |
| 自动 AUTO | 串口周期打印 开/关 | 进手动 | (已在自动,无变化) | 进远程(默认蓝牙) |
| 远程 REMOTE | 串口周期打印 开/关 | 进手动 | 回自动 | 蓝牙 → MQTT → 蓝牙 循环切换 |
记忆方法(三条就够):
- KEY2 永远管"手动":不在手动就进手动,在手动就退出;
- KEY3 永远管"自动":非手动模式下按它回自动;只有在手动模式里它才"让位"给窗帘;
- KEY4 永远管"远程/加热":非手动模式按它进远程、再按切协议;手动模式里让位给加热。
4.3 按键回调的完整分派逻辑
c
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
// ------ 消抖(第三章)------
static uint32_t tick_key1 = 0, tick_key2 = 0, tick_key3 = 0, tick_key4 = 0;
uint32_t *p_tick;
uint32_t new_tick = HAL_GetTick();
if (GPIO_Pin == KEY1_Pin) p_tick = &tick_key1;
else if(GPIO_Pin == KEY2_Pin) p_tick = &tick_key2;
else if(GPIO_Pin == KEY3_Pin) p_tick = &tick_key3;
else p_tick = &tick_key4;
if(new_tick - *p_tick < 40) return;
*p_tick = new_tick;
g_print_req = 1; // 任意键按下 → 主循环打印一次环境数据(第五章讲)
// ------ 按"当前模式 + 哪个键"分派 ------
if(GPIO_Pin == KEY1_Pin)
{
if(g_mode == MODE_MANUAL) { /* 翻转风扇 */ }
else { /* 翻转串口打印开关 */ }
}
else if(GPIO_Pin == KEY2_Pin)
{
Switch_Mode(g_mode == MODE_MANUAL ? MODE_AUTO : MODE_MANUAL);
}
else if(GPIO_Pin == KEY3_Pin)
{
if(g_mode == MODE_MANUAL) { /* 翻转窗帘 */ }
else { Switch_Mode(MODE_AUTO); }
}
else if(GPIO_Pin == KEY4_Pin)
{
if(g_mode == MODE_MANUAL) { /* 翻转加热 */ }
else { /* 进远程 / 请求切协议,只置标志 */ }
}
}
注意这个结构:第一层按"哪个键"分,第二层按"什么模式"分。不要反过来(先模式后按键),因为 KEY2/KEY3 这种"跨模式语义固定"的键会写得很别扭。
4.4 KEY4 的复杂逻辑:为什么只置标志不干活
KEY4 在非手动模式下干的事最重------切到 MQTT 要重新配串口波特率、连 WiFi、连 broker,整个流程 10~16 秒 。所以它在中断里只做一件事:置请求标志。
c
else // 非手动模式
{
if(g_mode != MODE_REMOTE)
{
Switch_Mode(MODE_REMOTE); // 第一次按:进远程,默认蓝牙
g_net_req = NET_REQ_BT; // 请求主循环:切 9600 + 挂蓝牙接收
}
else if(g_net_busy)
{
// MQTT 初始化进行中,这次按键直接丢弃,否则会插进 AT 指令流
printf("[KEY4] busy: MQTT init in progress, ignored\r\n");
}
else if(g_net == NET_BT)
{
g_net_req = NET_REQ_MQTT; // 第二次按:请求切 MQTT
printf("[KEY4] request MQTT\r\n");
}
else
{
g_net_req = NET_REQ_BT; // 第三次按:请求切回蓝牙
printf("[KEY4] request Bluetooth\r\n");
}
}
三个分支对应"按键次数",但真正的切换动作(Uart2_SetBaudrate、Mqtt_Server_Init)全在主循环里:
c
// 主循环 while(1) 中
if(g_net_req != NET_REQ_NONE)
{
uint8_t req = g_net_req;
g_net_req = NET_REQ_NONE; // 先清请求,避免重复执行
if(req == NET_REQ_MQTT) { /* 切 115200,首次连 WiFi+MQTT */ }
else { /* 切 9600,挂蓝牙接收 */ }
}
先清请求再干活 这个顺序也有讲究:如果先干活再清,干活过程中(十几秒)用户又按了 KEY4,新请求会覆盖 g_net_req,等活干完清标志时把新请求也清掉了------用户白按一次。先取值、先清零,干活得罪谁也不会丢请求。
4.5 Switch_Mode:一切切换的入口
c
static void Switch_Mode(uint8_t new_mode)
{
g_mode = new_mode;
g_net = NET_BT; // 协议状态归零:任何模式切换后都回到蓝牙态
g_net_req = NET_REQ_NONE;
// 安全:所有输出全关
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0); // 风扇
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 0); // 加热
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, 0); // 预留通道
HAL_GPIO_WritePin(DHT11_LED_GPIO_Port, DHT11_LED_Pin, GPIO_PIN_RESET);
// 一致性:手动模式的三个标志位同步清零
g_fan_on = 0; g_curtain_open = 0; g_heat_on = 0;
if(new_mode == MODE_MANUAL)
__HAL_TIM_SET_COMPARE(&htim5, TIM_CHANNEL_1, CURTAIN_CLOSE_PULSE);
switch(new_mode) // 日志
{
case MODE_MANUAL: printf("[MODE] Manual (KEY2) | Keys: 1=FAN 3=CURTAIN 4=HEATER\r\n"); break;
case MODE_AUTO: printf("[MODE] Auto | Temp Control ON\r\n"); break;
case MODE_REMOTE: printf("[MODE] Remote (KEY4) | Bluetooth ON\r\n"); break;
}
}
第 8 章讲过"全关"和"窗帘归位"的理由,这里补一个本章视角的设计点:
Switch_Mode 是切换模式的唯一入口。 全工程没有任何地方直接写 g_mode = MODE_AUTO,全部走这个函数。好处是"进一个模式要做的事"只有一份定义------如果你图省事在某处直接赋值,就跳过了全关逻辑,留下"自动模式继承手动模式风扇转速"这种隐蔽 bug。
4.6 g_net 为什么也要在切模式时归零
Switch_Mode 里有两行和按键无关:
c
g_net = NET_BT;
g_net_req = NET_REQ_NONE;
含义:USART2 的协议状态(蓝牙还是 MQTT)跟着模式一起复位。原因:
- 蓝牙/MQTT 只在远程模式有意义,离开远程模式后保留
NET_MQTT状态只会造成混乱; - 下次再进远程模式,从"蓝牙 9600"这个确定状态开始,而不是"上次走到哪算哪";
- 拨码开关也要切回蓝牙侧------硬件和软件必须同时回到同一侧,这是第 3 章就定下的规矩。
五、中断与主循环的协作:标志位模式
5.1 本工程的"中断→主循环"通信清单
| 标志 | 谁置位 | 谁清零并执行 | 干什么 |
|---|---|---|---|
g_print_req |
任意按键中断 | 主循环开头 | 打印一次温湿度+空气质量 |
g_net_req |
KEY4 中断 | 主循环协议处理段 | 切波特率/连 WiFi |
g_net_busy |
主循环(MQTT 初始化前) | 主循环(初始化完) | 告诉 KEY4 中断"别来添乱" |
g_mode |
中断(Switch_Mode) |
主循环 switch 读取 |
决定主循环走哪个分支 |
g_fan_on 等 |
中断 | 中断自己 + 自动/远程逻辑 | 记录设备逻辑状态 |
规律一目了然:中断只写字节大小的标志,主循环看到标志再干活。
5.2 为什么 printf 也要挪出中断
第 2.4 节说中断里短 printf "能用",但 g_print_req 这个设计更进一步:按键要求打印环境数据,这件事整体挪到主循环。
c
// 主循环 while(1) 开头
if(g_print_req)
{
g_print_req = 0;
Read_ENS160(); // 里面是一串 I2C 通信
Print_Env(); // 里面是一长串 printf
}
因为 Read_ENS160() 是 I2C 阻塞通信、Print_Env() 是一整行几十字符的日志------两个都是"慢活",放中断里会拖垮整个系统节拍。中断里只花一条指令置 g_print_req = 1,剩下的主循环顺手就做。
判断标准:一件事如果超过几十微秒,就不配待在中断里。
5.3 主循环的纪律:任何分支都不能堵死
状态机要转起来,前提是主循环的 while(1) 每一圈都足够快。本工程主循环的结构:
c
while (1)
{
if(g_print_req) { /* 打印一次 */ }
if(g_net_req != NET_REQ_NONE) { /* 协议切换 */ }
switch(g_mode)
{
case MODE_AUTO: /* 每 2s 采集+控制,其余时间直接过 */ break;
case MODE_REMOTE: /* 每 2s 上报,其余时间查命令 */ break;
case MODE_MANUAL: /* 每 2s 刷新缓存,其他啥也不干 */ break;
}
if(send_flag) { /* 每 1s 周期打印 */ }
}
关键点:所有"定时"都用 HAL_GetTick() - last_tick >= 间隔 的非阻塞写法 ,没有任何 HAL_Delay 大延时、没有任何 while(等标志) 死等。这样无论哪个模式,主循环一圈都是微秒级,按键中断一来立刻能被响应。
唯一的例外是 Mqtt_Server_Init() 那十几秒------它确实是阻塞的,但它是用户主动按 KEY4 触发的一次性动作 ,而且期间用 g_net_busy 把后续按键请求显式丢弃,行为是"可预期、可解释"的,日志也会打 [NET] MQTT init ~10s... 提示。这和"无意中写了个死循环"是两码事。
5.4 如果当初不用状态机会怎样
很多初学者(包括一开始的我)会想:不就是三个模式吗,写三个 while(1) 各管各的:
c
void Manual_Loop() { while(1) { /* 手动逻辑 */ } } // ❌
void Auto_Loop() { while(1) { /* 自动逻辑 */ } } // ❌
问题立刻出现:怎么从 Manual_Loop 跳到 Auto_Loop? 死循环出不来,只能用 goto 或长跳转,代码迅速变成意大利面。更别说每个循环里都要重复写"检查按键、检查串口"。
状态机的本质是:把"模式"从"程序流程"降级为"数据" 。主循环永远只有一条,模式只是一个变量,switch(g_mode) 决定这一圈干什么。切模式 = 改变量,没有任何跳转问题。
六、调不通时按这个顺序查
6.1 按键完全没反应
| 顺序 | 检查项 | 怎么查 |
|---|---|---|
| 1 | NVIC 中断使能勾了没 | CubeMX → NVIC 页,EXTI1/2/3/15_10 四项都要勾 |
| 2 | 触发沿 | 按下接地的按键必须是下降沿 |
| 3 | 上拉 | GPIO 必须是 Pull-up,否则松开时引脚悬空乱跳 |
| 4 | 回调函数名 | 必须是 HAL_GPIO_EXTI_Callback,拼错一个字就永远不进来 |
| 5 | 回调定义了几次 | 全工程搜索,只能有一处 |
| 6 | EXTI 线冲突 | 同号线(如 PA1/PB1)只能挂一个 |
| 7 | 硬件 | 万用表量引脚:松开 3.3V、按下 0V |
最快的一招:在回调第一行加一句超短打印 ,比如 printf("K\r\n");。按一下有没有输出,立刻区分"中断没进来"(查 1~6)和"进来了但逻辑没走到"(查模式变量)。
6.2 按键行为诡异对照表
| 现象 | 优先怀疑 |
|---|---|
| 按一下风扇开关好几次 | 没消抖 / 消抖窗口太小 |
| 连按两个键第二个没反应 | 消抖时间戳共用了(3.4 节) |
| 按一下,串口打印切模式了但设备没动 | 直接赋值 g_mode 绕过了 Switch_Mode |
| 按键后整机死机 | 中断里写了 HAL_Delay 或死循环 |
| 模式切了但设备还开着 | 同上,Switch_Mode 被绕过 |
| KEY4 按了 MQTT 没连 | 拨码开关没切到 WiFi 侧(硬件先行!) |
| 中断里日志打了,主循环没反应 | 标志位忘了 volatile |
七、本章易错点总结
| 现象 | 真正原因 | 处理 |
|---|---|---|
| 按键中断永远不触发 | NVIC 没勾 / 回调函数名拼错 / 回调重复定义 | 逐项核对,全工程只留一个 Callback |
| 进一次中断后程序卡死 | 自己写 IRQHandler 忘了清挂起标志 | 用 HAL 的 HAL_GPIO_EXTI_IRQHandler |
| 按一下触发好几次 | 机械弹跳没消抖 | 40ms 时间窗 |
| 连按两个键,后一个被吞 | 四键共用一个消抖时间戳 | 每键独立时间戳 |
| 消抖 49 天后失效 | 用 tick > last + 40 比较,回绕后恒真 |
改用减法 tick - last < 40 |
| 中断里加延时后死机 | SysTick 优先级低于 EXTI,HAL_Delay 等不到 tick |
中断里禁止 HAL_Delay |
| 中断里调 MQTT 初始化卡死十几秒 | 耗时操作放进了中断上下文 | 中断只置 g_net_req,主循环干活 |
| 按键切模式后风扇还转着 | 某处直接 g_mode = x 绕过了 Switch_Mode |
一切切换走 Switch_Mode |
| 日志说切了模式行为却没变 | g_mode 没加 volatile,主循环读到缓存值 |
跨中断/主循环的变量全部 volatile |
| 进手动后按 KEY3 舵机不动 | 窗帘本来就在"开",标志位却是 0 | 进手动时让舵机归位到关闭 |
| PA1 配了 EXTI 后 PB1 按键失灵 | 同一根 EXTI 线只能映射一个端口 | 同号线不要重复用 |
| MQTT 初始化期间按键"失灵" | g_net_busy 主动丢弃,这是设计 |
不是 bug,等初始化完再按 |
| KEY4 按了没切 MQTT | 拨码开关还在蓝牙侧 | 先拨码再按键 |
主循环里塞了 while(等串口) |
阻塞主循环,按键响应变迟钝 | 非阻塞轮询,超时用减法判断 |
| 双边沿触发逻辑混乱 | 按下松开都进中断,消抖难做 | 只用下降沿 |
八、小结与下一篇
这一章记住五件事:
- 按键 = 下降沿 + 上拉 ,业务代码只写在一个
HAL_GPIO_EXTI_Callback里; - 消抖用时间戳减法,每个键独立一个,别共用;
- 中断里只做立刻能做完的事,慢活全部变成"标志"扔给主循环;
- 模式是数据不是流程 ,一个
g_mode加一个switch,别写三个死循环; - 切模式走唯一入口
Switch_Mode,先全关、再清标志、后归位。
到这里,"人按按键"这条路通了。下一章进入"机器跟机器说话":USART1 调试串口与 USART2 蓝牙 ------printf 是怎么重定向到串口的、蓝牙 9600 波特率的坑、以及"收发都能通但就是没数据"为什么九成是接线问题。
如果这篇文章对你有帮助,欢迎点赞收藏。系列更新中,下一篇见。
附录 A:本章涉及的完整代码
A.1 引脚与模式宏(main.c 顶部)
c
#define MODE_MANUAL 0 // 手动模式(KEY2 进/出):设备由 KEY1/KEY3/KEY4 手动开关
#define MODE_AUTO 1 // 自动模式:按温湿度+空气质量自动控(按 KEY3 进,或退出手动时回到这里)
#define MODE_REMOTE 2 // 远程模式(KEY4):蓝牙/MQTT 命令控制
// KEY4 的网络切换请求:中断里只置请求,真正的切波特率/连WiFi放在主循环做
#define NET_REQ_NONE 0
#define NET_REQ_BT 1
#define NET_REQ_MQTT 2
A.2 全局状态(中断与主循环共享,全部 volatile)
c
volatile uint8_t send_flag = 0; // KEY1 控制的串口周期打印开关
volatile uint8_t g_print_req = 0; // KEY1~4 任意键按下置 1,主循环打印环境数据
volatile uint8_t g_mode = MODE_MANUAL; // 当前模式:0手动(默认)/1自动/2远程
volatile uint8_t g_fan_on = 0; // 风扇状态(KEY1 控制)
volatile uint8_t g_curtain_open = 0; // 窗帘状态(KEY3 控制):1=拉开 / 0=关闭
volatile uint8_t g_heat_on = 0; // 加热状态(KEY4 控制)
volatile uint8_t g_net = NET_BT; // USART2 当前协议:蓝牙/MQTT
volatile uint8_t g_net_req = NET_REQ_NONE; // 中断置的协议切换请求,主循环处理
volatile uint8_t g_net_busy = 0; // 1=正在连WiFi/broker,期间 KEY4 请求直接丢弃
A.3 按键中断回调(完整版)
c
// 按键中断回调(KEY1/2/3/4 全在这里处理)
// 按键语义按【当前模式】分派:
// 非手动模式:KEY1=串口发送开关 KEY2=进手动 KEY3=进自动 KEY4=进远程
// 手动模式 :KEY2=退出手动(回到自动) KEY1=风扇 KEY3=窗帘舵机 KEY4=加热(各自开/关翻转)
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
// 消抖:给每个键记各自的时间戳。四键共用一个会把"连按第二个键"误吞掉
static uint32_t tick_key1 = 0, tick_key2 = 0, tick_key3 = 0, tick_key4 = 0;
uint32_t *p_tick;
uint32_t new_tick = HAL_GetTick();
if (GPIO_Pin == KEY1_Pin) p_tick = &tick_key1;
else if(GPIO_Pin == KEY2_Pin) p_tick = &tick_key2;
else if(GPIO_Pin == KEY3_Pin) p_tick = &tick_key3;
else p_tick = &tick_key4; // KEY4
if(new_tick - *p_tick < 40) return; // 该键 40ms 内重复触发 → 忽略(弹跳)
*p_tick = new_tick;
g_print_req = 1; // 任意按键按下 → 主循环打印一次温湿度 + 空气质量
if(GPIO_Pin == KEY1_Pin)
{
if(g_mode == MODE_MANUAL)
{
// 手动模式:KEY1 = 风扇 开/关
g_fan_on = !g_fan_on;
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, g_fan_on ? FAN_PWM : 0);
printf("[MANUAL] FAN %s\r\n", g_fan_on ? "ON" : "OFF");
}
else
{
// 其它模式:KEY1 仍是串口发送开关
send_flag = !send_flag;
printf("[KEY1] send_flag=%d\r\n", send_flag);
}
}
else if(GPIO_Pin == KEY2_Pin)
{
// KEY2:手动模式开关。进手动 → 全关;再按一次 → 退出手动,回到自动模式
Switch_Mode(g_mode == MODE_MANUAL ? MODE_AUTO : MODE_MANUAL);
}
else if(GPIO_Pin == KEY3_Pin)
{
if(g_mode == MODE_MANUAL)
{
// 手动模式:KEY3 = 窗帘舵机 拉开/关闭
g_curtain_open = !g_curtain_open;
__HAL_TIM_SET_COMPARE(&htim5, TIM_CHANNEL_1,
g_curtain_open ? CURTAIN_OPEN_PULSE : CURTAIN_CLOSE_PULSE);
printf("[MANUAL] CURTAIN %s\r\n", g_curtain_open ? "OPEN" : "CLOSE");
}
else
{
Switch_Mode(MODE_AUTO);
}
}
else if(GPIO_Pin == KEY4_Pin)
{
if(g_mode == MODE_MANUAL)
{
// 手动模式:KEY4 = 加热 开/关
g_heat_on = !g_heat_on;
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, g_heat_on ? HER_PWM : 0);
printf("[MANUAL] HEATER %s\r\n", g_heat_on ? "ON" : "OFF");
}
else
{
// 非手动模式:KEY4 循环切换"蓝牙远程 → MQTT远程 → 蓝牙远程"
// 注意:中断里只置请求标志。切波特率、连WiFi都要十几秒,绝不能放在中断里做,
// 否则主循环和按键全部卡死。真正的动作由主循环的 g_net_req 处理段执行。
if(g_mode != MODE_REMOTE)
{
Switch_Mode(MODE_REMOTE); // 第一次按:进远程模式,默认走蓝牙
g_net_req = NET_REQ_BT; // 请求主循环:USART2 切 9600 + 挂蓝牙接收
}
else if(g_net_busy)
{
// 正在初始化 MQTT(要等3秒ESP启动 + AT指令往返,总共十几秒),
// 这期间再按 KEY4 会把"切回蓝牙"的请求插进 AT 指令流里,把初始化彻底搅乱,
// 所以直接丢掉这次按键。
printf("[KEY4] busy: MQTT init in progress, ignored\r\n");
}
else if(g_net == NET_BT)
{
g_net_req = NET_REQ_MQTT; // 第二次按:请求切到 WiFi/MQTT
printf("[KEY4] request MQTT\r\n");
}
else
{
g_net_req = NET_REQ_BT; // 第三次按:请求切回蓝牙
printf("[KEY4] request Bluetooth\r\n");
}
}
}
}
A.4 模式切换函数
c
// 切换模式的唯一入口:先全关保安全,再清标志保一致,最后按模式收尾
static void Switch_Mode(uint8_t new_mode)
{
g_mode = new_mode;
g_net = NET_BT; // USART2 协议状态回到蓝牙(离开远程模式后不留 MQTT 残留)
g_net_req = NET_REQ_NONE;
// 关所有输出,安全第一
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0); // FAN 关
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 0); // HER 关
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_3, 0); // CUR 关
HAL_GPIO_WritePin(DHT11_LED_GPIO_Port, DHT11_LED_Pin, GPIO_PIN_RESET);
// 手动模式的三个标志位同步清零:进出任何模式都从"全关"开始,不留残余状态。
// 再让窗帘舵机归位到"关闭",保证标志位(0=关)和实物状态一致,之后按 KEY3 一定能看到动作。
g_fan_on = 0; g_curtain_open = 0; g_heat_on = 0;
if(new_mode == MODE_MANUAL)
__HAL_TIM_SET_COMPARE(&htim5, TIM_CHANNEL_1, CURTAIN_CLOSE_PULSE);
// 打印模式切换日志
switch(new_mode)
{
case MODE_MANUAL: printf("[MODE] Manual (KEY2) | Keys: 1=FAN 3=CURTAIN 4=HEATER\r\n"); break;
case MODE_AUTO: printf("[MODE] Auto | Temp Control ON\r\n"); break;
case MODE_REMOTE: printf("[MODE] Remote (KEY4) | Bluetooth ON\r\n"); break;
default: printf("[MODE] Unknown=%d\r\n", new_mode); break;
}
}
A.5 主循环里的协作段
c
while (1)
{
// ====== KEY1~4 任意键按下 → 打印一次温湿度 + 空气质量 ======
if(g_print_req)
{
g_print_req = 0;
Read_ENS160(); // 空气数据实时读一次(温湿度用 2 秒内的缓存)
Print_Env();
}
// ====== KEY4 的协议切换请求:中断置标志,这里才真正干活 ======
if(g_net_req != NET_REQ_NONE)
{
uint8_t req = g_net_req;
g_net_req = NET_REQ_NONE; // 先清请求,避免干活期间被覆盖丢失
if(req == NET_REQ_MQTT)
{
// 切 115200 + 首次连 WiFi/MQTT(10~16s,期间 g_net_busy=1 丢弃按键)
}
else
{
// 切回 9600 + 重挂蓝牙接收
}
}
// ====== 根据模式执行不同任务(任何分支都不得阻塞)======
switch(g_mode)
{
case MODE_AUTO: /* 每 2s 采集 + 四分支控制 */ break;
case MODE_REMOTE: /* 每 2s 上报 + 查远程命令 */ break;
case MODE_MANUAL: /* 只刷新缓存,设备等按键 */ break;
}
}
A.6 CubeMX 配置清单(对照检查用)
| 项目 | 配置值 |
|---|---|
| KEY1~KEY4 GPIO 模式 | GPIO_MODE_IT_FALLING(下降沿触发) |
| KEY1~KEY4 上下拉 | GPIO_PULLUP(内部上拉) |
| NVIC 使能 | EXTI1、EXTI2、EXTI3、EXTI15_10 全部勾选 |
| NVIC 优先级 | 抢占 1,子优先级 0(四键一致) |
| SysTick 优先级 | 保持默认 15(低于按键 → 中断里禁用 HAL_Delay) |
A.7 最小验证步骤
- 烧录上电,串口打印
[MODE] Manual ...,三个设备全关; - 按 KEY1 →
[MANUAL] FAN ON,风扇转;再按 →FAN OFF,风扇停。连续快速按 10 次,每次都应有响应且状态正确翻转(验证每键独立消抖); - 一手按 KEY1、一手几乎同时按 KEY4:两个都应有响应(验证时间戳不共用);
- 按 KEY3 → 舵机动作;按 KEY4 → 加热开(可量 PA7 电压约 2.64V);
- 按 KEY2 →
[MODE] Auto,所有设备全关;自动模式下按 KEY3 → 无变化;按 KEY1 →send_flag翻转,串口开始/停止周期打印; - 自动模式下按 KEY4 →
[MODE] Remote;再按 →[KEY4] request MQTT,几秒后打印波特率切换日志;MQTT 初始化期间按 KEY4 →[KEY4] busy ... ignored; - 远程模式下按 KEY2 → 回手动、设备全关;按 KEY3 → 回自动。
七步全过,说明消抖、分派、状态机、标志协作全部就位,可以进入串口通信章节了。