文章目录
系列第十一篇。到这一章,第一次可以"离开电脑"控制设备:手机点一下,风扇转。
但它也是我踩坑最多的一章——不是代码难,而是每一层都可能坏:手机没配对、波特率不是 9600、拨码开关还在 WiFi 侧、忘了先退出手动模式、命令拼写多了两个字母……最离谱的一次:APP 明明显示"已连接",串口日志却连续打印
[CMD] unknown: F、unknown: A、unknown: N……后来才发现是命令缓冲区每收到一个字节就被清空了一次,完整命令永远拼不起来。这一篇会把这条链路从手机一路拆到定时器,并且把每个"看起来通、实际不通"的现象,对应到具体原因。
一、HC-06 是什么
1.1 三句话说完
| 特性 | 说明 |
|---|---|
| 角色 | 从机(Slave),只能被手机/电脑连,不能主动连别人 |
| 工作方式 | 透传:手机发什么,它就从 TXD 原样吐什么;STM32 往 RXD 写什么,手机就收到什么 |
| 协议 | 经典蓝牙 SPP,不是 BLE(低功耗蓝牙) |
由此推出三条必须记住的结论:
- 它不懂任何协议。它不会帮你解析
FAN_ON,也不认识 JSON。谁是命令、谁是数据,全靠 STM32 自己判断。 - 默认波特率 9600。出厂就是 9600 8N1,不是 115200。这是本章第一大坑。
- iPhone 连不上。iOS 不开放 SPP。安卓要用支持 SPP 的串口助手(如
Serial Bluetooth Terminal),或者微信里那些"蓝牙串口"小程序。
1.2 模块上的灯:最快的诊断工具
| LED 状态 | 含义 |
|---|---|
| 快闪(约每秒 2 次) | 未连接,正在等人配对 |
| 常亮 | 已有一台设备连上 |
| 上电瞬间闪一下再快闪 | 正常启动 |
排错时先看灯,再看代码。灯还在快闪,就说明手机根本没连上,这时候去改代码全是白费。
1.3 先裸测,别急着接单片机
这是我在第 2 章就强调过的习惯:通信模块一定要先单独验证。
- USB 转 TTL 接 HC-06:TTL 的 RX → 模块 TXD,TTL 的 TX → 模块 RXD,VCC、GND 接好;
- 串口助手选 9600 8N1,打开串口;
- 发
AT(不带换行),应回OK; - 回乱码就换波特率试 38400、115200——说明模块被人改过。
裸测通过,才说明"模块本身是好的、波特率是 9600",后面的问题就只剩 STM32 这一侧了。这一步能省掉一半的排查时间。
1.4 HC-06 的常用 AT 指令
| 指令 | 作用 | 典型回显 | 备注 |
|---|---|---|---|
AT | 测试通信 | OK | 只能在未连接状态发,连着手机时发无效 |
AT+VERSION | 查固件版本 | OKlinvorV1.8 | 不同批次字串不同,正常 |
AT+NAMEabc | 改蓝牙名 | OKsetname | 名字 ≤20 字符 |
AT+BAUD8 | 改波特率 | OKsetbaud | 改完必须断电重上才生效 |
AT+PSWD"1234" | 改配对码 | OKsetPIN | 有些版本是 AT+PIN1234,没有引号 |
AT+BAUD 后面那个数字是编码,不是波特率本身:
| 数字 | 波特率 | 数字 | 波特率 |
|---|---|---|---|
| 1 | 1200 | 5 | 19200 |
| 2 | 2400 | 6 | 38400 |
| 3 | 4800 | 7 | 57600 |
| 4 | 9600(默认) | 8 | 115200 |
给你的建议:不要改。 本工程默认按 9600 配 USART2,你改成 115200 之后,手机端、单片机端、串口助手端三处都得跟着改,忘一处就是乱码。真要改,记住用 AT+BAUD4 改回来。
顺便区分一下 HC-05:它是主从一体,进 AT 模式要把 EN/KEY 脚拉高,指令末尾还要带
\r\n,和 HC-06 完全不同。网上教程混着写,别照抄错版本。
二、接线:拨码开关是前提
2.1 四根线
| HC-06 | 接到 | 说明 |
|---|---|---|
| VCC | 5V 或 3.3V | 看模块丝印,带底板的多为 3.6~6V(板上有 LDO) |
| GND | GND | 必须和 STM32 共地 |
| TXD | PA3(USART2_RX) | 交叉:模块发 → 单片机收 |
| RXD | PA2(USART2_TX) | 交叉:单片机发 → 模块收 |
电平不用担心:STM32 输出 3.3V,HC-06 的 RXD 兼容;模块 TXD 也是 3.3V,STM32 能直接识别。不需要电平转换。
2.2 拨码开关必须在蓝牙侧
USART2 只有一组引脚(PA2/PA3),蓝牙和 ESP-12F 共用,靠双刀双掷拨码开关二选一(第 3 章 5.2 节)。
PA2 ── 公共端 1 ──┬── A 侧:HC-06 RXD
└── B 侧:ESP RXD
PA3 ── 公共端 2 ──┬── A 侧:HC-06 TXD
└── B 侧:ESP TXD
| 你要用 | 拨码开关 | 软件里的协议 | 波特率 |
|---|---|---|---|
| 蓝牙 | 蓝牙侧(A) | NET_BT | 9600 |
| WiFi/MQTT | WiFi 侧(B) | NET_MQTT | 115200 |
软件读不到开关状态。所以"命令发出去没反应"时,先低头看一眼开关——这是排查顺序里的第一步,不是最后一步。
2.3 调试串口要单独留着
手机上发命令的同时,你还需要在电脑上看日志。这两件事用的是不同的串口:
- 电脑:USART1(PA9/PA10)+ USB 转 TTL,115200;
- 手机命令:USART2(PA2/PA3)+ HC-06,9600。
如果你为了"省一个 USB 转 TTL"把日志也接到 USART2 上,日志会和蓝牙回显、命令搅在一起,最后一句话都读不懂(第 10 章开篇就是这么翻车的)。
三、进入远程模式的正确路径
3.1 上电路径:默认是手动模式
这是第二大坑:上电默认是手动模式,此时 KEY4 是"加热",不是"进远程"。
上电
│
├─ 默认:MODE_MANUAL(手动)
│ KEY4 = 加热开关 ← 按了只会加热,不会进远程
│
├─ 按 KEY2 ──→ MODE_AUTO(自动)
│
└─ 再按 KEY4 ──→ MODE_REMOTE(远程,默认蓝牙 9600)
│
└─ 再按 KEY4 ──→ 切 MQTT(115200)
│
└─ 再按 KEY4 ──→ 切回蓝牙(9600)
所以正确路径是:KEY2 → KEY4。
| 操作 | 从 | 到 | 日志 |
|---|---|---|---|
| 按 KEY2 | 手动 | 自动 | [MODE] Auto | Temp Control ON |
| 按 KEY4 | 自动 | 远程/蓝牙 | [MODE] Remote (KEY4) | Bluetooth ON → [NET] Bluetooth @9600 |
| 再按 KEY4 | 远程/蓝牙 | 远程/MQTT | [KEY4] request MQTT → [NET] WiFi/MQTT @115200 |
看到 [NET] Bluetooth @9600,才说明 USART2 已经切好波特率、挂好接收了。
3.2 为什么 KEY4 在中断里只"置请求"
第 9 章讲过中断禁区,这里再具体一点。KEY4 的处理在 HAL_GPIO_EXTI_Callback() 里:
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
{
if(g_mode != MODE_REMOTE)
{
Switch_Mode(MODE_REMOTE); // 只改状态、关输出
g_net_req = NET_REQ_BT; // ★ 只置请求,不进真正的切换
}
else if(g_net_busy) // MQTT 正在初始化 → 直接丢弃
printf("[KEY4] busy: MQTT init in progress, ignored\r\n");
else if(g_net == NET_BT) g_net_req = NET_REQ_MQTT;
else g_net_req = NET_REQ_BT;
}
}
真正的动作在主循环:
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 */ }
else
{
g_net = NET_BT; // 先置协议标志,再挂接收
Uart2_SetBaudrate(9600); // ★ 这里才真正切波特率
HAL_UART_Receive_IT(&huart2, bt_buf, 1);
printf("[NET] Bluetooth @9600\r\n");
}
}
原因很简单:Uart2_SetBaudrate() 里要"等 TC 发完、DeInit、Init、清标志",全程带等待(最坏几十毫秒)。放在中断里做,SysTick 优先级最低(15),HAL_GetTick() 会停住,所有超时判断全乱,严重时直接卡死。
顺序也很讲究:先把 g_net 置成目标协议,再挂接收。反过来的话,切换过程中到达的第一个字节会在回调里走错分支(进 ESP 缓冲而不是蓝牙缓冲)。
3.3 切换波特率到底做了什么
Uart2_SetBaudrate() 五步,每一步都不能省:
void Uart2_SetBaudrate(uint32_t baud)
{
uint32_t t0;
/* 1) 先关中断:DeInit/Init 过程中有字节进来会把 HAL 状态搞乱 */
HAL_NVIC_DisableIRQ(USART2_IRQn);
/* 2) 等最后一个字节真正发完(TC=1),50ms 超时防死等 */
t0 = HAL_GetTick();
while(__HAL_UART_GET_FLAG(&huart2, UART_FLAG_TC) == RESET)
{
if(HAL_GetTick() - t0 > 50) break;
}
/* 3) 若有挂起的接收请求,先中止:否则 RxState 停在 BUSY,
后面的阻塞式 HAL_UART_Receive 会立刻返回 HAL_BUSY */
HAL_UART_AbortReceive_IT(&huart2);
/* 4) DeInit 关时钟/复位引脚 → 改波特率 → Init 重新配时钟/GPIO/NVIC */
HAL_UART_DeInit(&huart2);
huart2.Init.BaudRate = baud;
HAL_UART_Init(&huart2);
/* 5) 清残留标志,避免把旧数据当成新模块的第一个字节 */
__HAL_UART_CLEAR_PEFLAG(&huart2); // 清 ORE/NE/FE/PE
__HAL_UART_CLEAR_FLAG(&huart2, UART_FLAG_RXNE); // 丢掉残留数据
HAL_NVIC_EnableIRQ(USART2_IRQn);
}
两个细节值得单独说:
- 第 2 步等的是 TC(发送完成),不是 TXE。 TXE 只代表"数据寄存器空了",最后一个字节可能还在移位寄存器里往外发;这时候 DeInit 会把它掐断,手机收到半截。
- 第 3 步的
AbortReceive_IT是保命的一行。 少了它,RxState可能一直停在HAL_UART_STATE_BUSY_RX,接下来所有HAL_UART_Receive()都返回HAL_BUSY——现象就是"AT 指令发给 ESP 了,但 recv 永远是空的",能查一整天。
四、命令协议:为什么用整串字符串
4.1 七条命令
| 命令 | 动作 | 落到硬件 |
|---|---|---|
FAN_ON | 风扇开 | TIM3_CH1(PA6) CCR=800 → 80% |
FAN_OFF | 风扇关 | CCR=0 |
HER_ON | 加热开 | TIM3_CH2(PA7) CCR=800 |
HER_OFF | 加热关 | CCR=0 |
CURTAIN_ON | 窗帘拉开 | TIM5_CH1(PA0) CCR=500μs |
CURTAIN_OFF | 窗帘关闭 | CCR=2000μs |
ALL_OFF | 一键全关 | 风扇+加热 CCR=0,窗帘关 |
三个"必须照抄"的点:
- 是
CURTAIN_ON/CURTAIN_OFF,不是CURTAIN_OPEN/CURTAIN_CLOSE。工程早期用过 OPEN/CLOSE,后来统一成了 ON/OFF,代码里只认后者。发错就打印[CMD] unknown。 - 全大写 + 下划线,
strstr()区分大小写,fan_on永远匹配不上。 - 不要带
\r\n。手机 APP 里如果有"发送时追加换行"的开关,务必关掉(原因 5.7 节细说)。
4.2 为什么不用单字符(1/2/3)
- 单字符太容易误触,APP 上滑一下就发出去一条;
- 无法扩展:
1到底是"风扇开"还是"模式1"?三个月后你自己都忘了; - 最关键的:能和 MQTT 复用。 第 12 章的 H5 页面下发的是同一套字符串,
Remote_Cmd_Exec()一行都不用改,蓝牙和 WiFi 两条通道行为完全一致。
五、逐字节拼命令(本章最坑的一节)
5.1 为什么只能一个字节一个字节收
蓝牙中断每次只搬 1 个字节:
HAL_UART_Receive_IT(&huart2, bt_buf, 1); // 第二个参数=本次收 1 个
收满 1 个字节,HAL 就关掉接收中断、调一次回调,然后停了。所以:
- 回调里只做一件事:标记"有数据"(
uartlog = 2); - 拼接放到主循环。中断里做
strlen、strstr、printf都属于"太长",会把下一个字节挤丢。
5.2 设计意图:攒够一整条再匹配
手机发 F A N _ O N
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
bt_cmd_buf: F → FA → FAN → FAN_ → FAN_O → FAN_ON
│
strstr 命中 → 执行
理想状态下,每次收到字节就往缓冲区追加,然后拿整个缓冲区去 strstr。前缀阶段匹配不上,攒齐了才命中——这是本设计最省事的地方。
5.3 实际代码里藏着一个顺序问题
先看工程里的这段(main.c 远程模式分支):
if(uartlog == 2)
{
uartlog = 0;
uint8_t clen = strlen((char*)bt_cmd_buf); // 当前已经攒了多少字符
if(clen < sizeof(bt_cmd_buf) - 1)
{
bt_cmd_buf[clen] = bt_buf[0]; // 追加新字节
bt_cmd_buf[clen + 1] = '\0'; // 补字符串结束符
}
else bt_cmd_buf[0] = '\0'; // 攒满还没命中 → 清空
Remote_Cmd_Exec((char*)bt_cmd_buf, 1); // 拿整串去匹配
bt_cmd_buf[0] = '\0'; // ★ 问题在这一行
HAL_UART_Receive_IT(&huart2, bt_buf, 1);
}
每一字节执行完,缓冲区立刻被清空。 于是下一字节到来时 strlen() 永远返回 0,缓冲区里永远只有一个字符。
而 strstr(cmd, "FAN_ON") 要求"包含完整的 6 个字符"——1 个字符的字符串永远不可能包含 6 个字符。
结果就是:发一条 FAN_ON,日志是这样的:
| 收到 | bt_cmd_buf 内容 | 匹配结果 | 串口日志 |
|---|---|---|---|
F | "F" | 无 | [CMD] unknown: F |
A | "A" | 无 | [CMD] unknown: A |
N | "N" | 无 | [CMD] unknown: N |
_ | "_" | 无 | [CMD] unknown: _ |
O | "O" | 无 | [CMD] unknown: O |
N | "N" | 无 | [CMD] unknown: N |
设备一点反应都没有,APP 也收不到任何回显。 如果你在自己的板子上看到"连续 unknown、每个只有 1 个字符",别怀疑蓝牙、别怀疑波特率——就是这个清空顺序的问题。
顺带说一句:MQTT 通道不受影响。第 12 章的 MQTT 是按行解析的,
esp_sub_data里拿到的是整条字符串,直接调Remote_Cmd_Exec(),压根不走bt_cmd_buf。所以会出现"WiFi 能控、蓝牙不能控"的诡异现象。
5.4 修法:只有命中才清空
最小改动是让执行函数返回是否命中,调用方据此决定清不清:
// 返回 1 = 匹配到并执行了;0 = 还没攒成完整命令
static uint8_t Remote_Cmd_Exec(const char *cmd, uint8_t echo_back)
{
if(strstr(cmd, "FAN_ON")) { /* ... */ }
else if(strstr(cmd, "FAN_OFF")) { /* ... */ }
/* ... 中间省略 ... */
else if(strstr(cmd, "ALL_OFF")) { /* ... */ }
else
{
return 0; // ★ 没命中:静默返回,让调用方继续攒
}
if(echo_back)
HAL_UART_Transmit(&huart2, (uint8_t*)cmd, strlen(cmd), HAL_MAX_DELAY);
return 1; // ★ 命中并执行完
}
调用方:
uint8_t clen = strlen((char*)bt_cmd_buf);
if(clen >= sizeof(bt_cmd_buf) - 1) // 攒满 15 字符还没命中 → 判定乱码
{
printf("[CMD] unknown: %s\r\n", bt_cmd_buf);
bt_cmd_buf[0] = '\0';
}
else
{
bt_cmd_buf[clen] = bt_buf[0];
bt_cmd_buf[clen + 1] = '\0';
if(Remote_Cmd_Exec((char*)bt_cmd_buf, 1))
bt_cmd_buf[0] = '\0'; // ★ 只有命中才清空
}
这样 FAN_ON 的累积过程是:F → FA → FAN → FAN_ → FAN_O → 命中 FAN_ON → 执行 + 回显 + 清空。中间五步都返回 0,日志干净,不会刷五行 unknown。
5.5 为什么 16 字节够用
| 命令 | 长度 |
|---|---|
ALL_OFF | 7 |
CURTAIN_ON | 10 |
CURTAIN_OFF | 11 |
最长 11 个字符,bt_cmd_buf[16] 留了 5 字节余量。
工程注释里写的是"最长 CURTAIN_CLOSE=13 字符"——那是早期命名版本留下的,现在命令已经改成 CURTAIN_OFF。注释和代码会一起变旧,看代码为准。
5.6 strstr 的包含匹配:是优点也是陷阱
优点:脏字节不影响执行。比如 APP 自动补了 \r\n,缓冲区里会出现 "\r\nFAN_ON",strstr 依然能命中并执行。
陷阱有两类:
- 误命中:如果你在调试时手发
XXFAN_ONYY,它照样会开风扇。strstr是"包含",不是"等于"。 - 前缀混淆:
FAN_ON和FAN_OFF没有前缀关系,安全;但设计命令时千万不要让一条命令成为另一条的前缀(比如FAN_ON和FAN_ON_TIMER),否则先命中的永远是短的那条。
5.7 命令后面多出来的 \r\n 会去哪
假设 APP 自动追加了 \r\n:
- 攒到
FAN_ON→ 命中 → 执行 → 回显 → 清空; - 接着收到
\r→ 缓冲区变成"\r"→ 不命中 → 留在缓冲区; - 收到
\n→ 变成"\r\n"→ 不命中 → 继续留在缓冲区; - 下一条命令进来 → 缓冲区变成
"\r\nFAN_OFF"→strstr命中FAN_OFF→ 正常执行。
也就是说:功能上不会坏,但缓冲区会一直带着两个脏字节。真正的坏处是日志——如果按 5.3 的原始代码,每来一个 \r、\n 都会打印一条 [CMD] unknown:。
最干净的做法还是 APP 里关掉"自动换行"。
六、回显:让 APP 知道命令真的执行了
if(echo_back)
HAL_UART_Transmit(&huart2, (uint8_t*)cmd, strlen(cmd), HAL_MAX_DELAY);
蓝牙必须回显(1),MQTT 必须不回显(0)。
| 通道 | echo_back | 原因 |
|---|---|---|
| 蓝牙 | 1 | 手机 APP 靠"收到同名回显"确认命中;同时把命令原样显示在接收框 |
| MQTT | 0 | 往 USART2 发任何东西都会插进 ESP 的 AT 指令流,把 AT 应答搅乱 |
三个容易忽略的细节:
- 未知命令不回显。
else分支是return 0;(原版是return;),在回显之前就返回了。所以 APP 端"没收到回显"="命令没被执行",这个判据是可靠的。 - 回显是阻塞的。
HAL_UART_Transmit(..., HAL_MAX_DELAY)会等每个字节进移位寄存器:11 个字符 @9600 ≈ 11.5ms。虽然不长,但在这 11ms 里主循环是停的。所以别在回显里塞长文本。 - 回显内容就是收到的整串。 如果缓冲区里带着残留的
\r\n,回显也会带——APP 端最好trim()一下再判断。
七、统一执行函数:蓝牙和 MQTT 共用一套
7.1 完整代码
// cmd :收到的命令字符串,如 FAN_ON / CURTAIN_OFF / ALL_OFF
// echo_back:1=回显给手机(蓝牙);0=不回显(MQTT)
static uint8_t Remote_Cmd_Exec(const char *cmd, uint8_t echo_back)
{
if(strstr(cmd, "FAN_ON")) // 风扇开
{
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, FAN_PWM);
g_fan_on = 1;
printf("[CMD] FAN ON\r\n");
}
else if(strstr(cmd, "FAN_OFF"))
{
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0);
g_fan_on = 0;
printf("[CMD] FAN OFF\r\n");
}
else if(strstr(cmd, "HER_ON")) // 加热开
{
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, HER_PWM);
g_heat_on = 1;
printf("[CMD] HER ON\r\n");
}
else if(strstr(cmd, "HER_OFF"))
{
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 0);
g_heat_on = 0;
printf("[CMD] HER OFF\r\n");
}
else if(strstr(cmd, "CURTAIN_ON")) // 窗帘拉开
{
__HAL_TIM_SET_COMPARE(&htim5, TIM_CHANNEL_1, CURTAIN_OPEN_PULSE);
g_curtain_open = 1;
printf("[CMD] CURTAIN OPEN\r\n");
}
else if(strstr(cmd, "CURTAIN_OFF")) // 窗帘关闭
{
__HAL_TIM_SET_COMPARE(&htim5, TIM_CHANNEL_1, CURTAIN_CLOSE_PULSE);
g_curtain_open = 0;
printf("[CMD] CURTAIN CLOSE\r\n");
}
else if(strstr(cmd, "ALL_OFF")) // 一键全关
{
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 0);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 0);
__HAL_TIM_SET_COMPARE(&htim5, TIM_CHANNEL_1, CURTAIN_CLOSE_PULSE);
g_fan_on = 0; g_heat_on = 0; g_curtain_open = 0;
printf("[CMD] ALL OFF\r\n");
}
else
{
return 0; // 未命中:交给调用方继续攒
}
if(echo_back)
HAL_UART_Transmit(&huart2, (uint8_t*)cmd, strlen(cmd), HAL_MAX_DELAY);
return 1;
}
7.2 三个设计要点
① 只改 CCR,不碰定时器配置。
FAN_PWM=800 对应 CCR/(ARR+1)=800/1000=80%;窗帘的两个常量直接就是高电平微秒数(500μs / 2000μs,50Hz 的 20ms 周期里)。这些在第 8 章推导过,这里只负责"写数"。
② 标志位必须同步。
g_fan_on / g_heat_on / g_curtain_open 是手动模式的"账本"。远程改硬件时不更新它们,等下切回手动模式,KEY1 第一次按下去会先"关"(因为标志还是 0,翻转成 1 才开),用户会觉得"按一下没反应、按两下才对"。
③ ALL_OFF 的顺序无所谓,但必须三个都写。
只关风扇不关窗帘,是最常见的"漏一条"。
7.3 和模式状态机的关系
Remote_Cmd_Exec() 只在远程模式跑:
switch(g_mode)
{
case MODE_REMOTE:
...
if(uartlog == 2) { /* 拼命令 + Remote_Cmd_Exec */ }
break;
case MODE_AUTO: /* 每 2s 读传感器 + 四分支控设备 */ break;
case MODE_MANUAL: /* 外设由按键控制,主循环只刷缓存 */ break;
}
所以:
- 在自动模式下发命令 → 没人接,
uartlog停在 2(这个隐患见 10.2 节); - 在手动模式下发命令 → 同上;
- 这也是"命令发了没反应"的第二大原因:模式不对。
八、远程模式还兼职"上报环境数据"
远程模式下,主循环还有一段 2 秒定时任务:
case MODE_REMOTE:
if(HAL_GetTick() - last_ctrl_tick >= 2000)
{
last_ctrl_tick = HAL_GetTick();
Sensors_Update(); // 刷新 DHT11 + ENS160 缓存
if(g_dht11_ok && g_net == NET_BT) // ★ 只有蓝牙协议才直接往 USART2 发
{
char json_buf[48];
int len = snprintf(json_buf, sizeof(json_buf),
"{\"t\":%d,\"h\":%d}\r\n", g_TempH, g_HumiH);
HAL_UART_Transmit(&huart2, (uint8_t*)json_buf, len, 100);
}
}
几点必须知道:
- 蓝牙通道只报温湿度(
t、h),没有 AQI/eCO2/TVOC。完整的五项数据只走 MQTT(第 12 章)。所以接蓝牙时,APP 上空气质量那一块永远是空的——这是设计如此,不是 bug。 g_net == NET_BT这个条件是保命条件。 MQTT 模式下如果也往 USART2 发这串 JSON,ESP 会把它当成 AT 指令,回你ERROR,甚至打乱正在进行的 MQTT 会话。snprintf的 48 字节绰绰有余。{"t":-10,"h":100}\r\n也才 20 字节。- 数据是"主动推"的,不需要 APP 去问。APP 只要按行读、
JSON.parse就能更新界面。注意结尾有\r\n,所以 APP 侧必须按行切分,不能每收到几个字节就 parse 一次。
九、手把手联调:七步走完
步骤 0:硬件
- 拨码开关 → 蓝牙侧;
- HC-06:TXD→PA3、RXD→PA2、VCC、GND;
- USB 转 TTL 接 USART1(PA9/PA10),串口助手 115200。
步骤 1:裸测模块
不接 STM32,USB 转 TTL 直接接 HC-06,9600 发 AT → 应回 OK。
步骤 2:上电看日志
预期能看到初始化打印(模式是手动):
[MODE] Manual (KEY2) | Keys: 1=FAN 3=CURTAIN 4=HEATER
步骤 3:进远程
按 KEY2(进自动)→ 按 KEY4(进远程):
[MODE] Auto | Temp Control ON
[MODE] Remote (KEY4) | Bluetooth ON
[NET] Bluetooth @9600
如果按 KEY4 只打印
[MANUAL] HEATER ON,说明你还留在手动模式——回步骤 3 开头按 KEY2。
步骤 4:手机连接
安卓装 Serial Bluetooth Terminal → 扫描 → 选 HC-06(名字可能是 HC-06 或改过的名)→ 配对码 1234 → 连接。
模块 LED 由快闪变常亮 = 连上了。
步骤 5:发命令
在 APP 的发送框输入 FAN_ON(关掉自动换行),发送。预期:
[CMD] FAN ON
同时 APP 接收区收到回显 FAN_ON,风扇开始转(80% 占空比)。
步骤 6:验证其余命令
| 发送 | 日志 | 现象 |
|---|---|---|
FAN_OFF | [CMD] FAN OFF | 风扇停 |
HER_ON | [CMD] HER ON | 加热片发热(别用手直接摸) |
CURTAIN_ON | [CMD] CURTAIN OPEN | 舵机转到一端 |
CURTAIN_OFF | [CMD] CURTAIN CLOSE | 舵机转到另一端 |
ALL_OFF | [CMD] ALL OFF | 全部停、窗帘落下 |
步骤 7:验证"只在远程生效"
按 KEY2 退出手动/回自动,再发一次 FAN_ON:
- 日志没有
[CMD] FAN ON; - 设备无反应。
这就证明了命令解析确实只在 MODE_REMOTE 分支里跑。
十、调不通:按这个顺序查
10.1 手机根本连不上
| 现象 | 原因 | 处理 |
|---|---|---|
| 搜不到设备 | 模块没供电 / 灯不闪 | 量 VCC、看灯 |
| 搜得到、配对失败 | 配对码不对 | 试 1234、0000 |
| 配对成功又断开 | 已被别的手机占着 | HC-06 只能连一台,先断开旧设备 |
| iPhone 搜不到可用服务 | iOS 不支持 SPP | 换安卓 |
| APP 显示连上、发数据无反应 | APP 没选"文本/ASCII"、误开 HEX | 切到文本模式 |
10.2 连上了,但设备不动
按这个顺序,一条一条排除(顺序就是命中率):
- 拨码开关在蓝牙侧吗?(最常见)
- 现在真的是远程模式吗? 日志里要有
[MODE] Remote和[NET] Bluetooth @9600。 - 命令拼写对吗?
CURTAIN_ON不是CURTAIN_OPEN。 - 日志里是不是连续一串单字符
unknown? 是 → 5.3 节的清空顺序问题。 - 日志里连
unknown都没有? 说明主循环压根没进拼接分支,uartlog可能被"上一次"残留的值占住了(见下面的说明)。 - 波特率对吗? 手机端不需要设波特率(蓝牙链路是透明的),但模块自身的串口波特率必须是 9600。如果模块被人改成 115200,你会看到"能收但全是乱码"。
关于
uartlog残留:在非远程模式时收到过字节 → 回调把uartlog置 2,但那个模式没人消费它,接收也不再重挂。等进入远程模式,主循环第一次处理的其实是"旧字节",还会顺手把接收重挂上。现象:刚进远程就冒一条[CMD] unknown: X(X 是你在别的模式下收到的字节),随后才恢复正常。修法很简单,切协议时顺手清一下:
g_net = NET_BT; // 先定协议
uartlog = 0; // ★ 清掉别处残留的"有数据"标记
Uart2_SetBaudrate(9600); // 再切波特率
HAL_UART_Receive_IT(&huart2, bt_buf, 1);
10.3 有反应,但日志是 unknown 或乱码
| 日志/现象 | 原因 | 处理 |
|---|---|---|
[CMD] unknown: F 连续单字符 | 缓冲区每字节被清空 | 5.4 节的修法 |
[CMD] unknown: FAN_ON 完整但报未知 | 命令名不匹配(大小写/下划线/OPEN vs ON) | 对照 4.1 表格 |
[CMD] unknown: \r / \n 零星出现 | APP 自动追加换行 | 关掉自动换行 |
| 收到的字符时对时错 | 波特率不匹配 / 没共地 | 量 GND、确认 9600 |
| 偶发丢命令 | DHT11 关中断约 4ms,9600 下丢 3~4 字节 | 命令重发;或改环形缓冲(第 10 章 6.4、第 12 章) |
最后一条要解释一下:远程模式每 2 秒调一次 Sensors_Update(),里面 __disable_irq() 保护 40 位时序约 4ms。9600 波特率下一个字节 1.04ms,4ms 里正好能进来 3~4 个字节——如果你恰好在那一瞬间发命令,就会残缺,日志表现为 [CMD] unknown: FAN_O。
这不是玄学,是可复现的。 连续发同一条命令 20 次,偶尔失败一两次,基本就是它。
10.4 回显收不到
- 未知命令不回显(7.1 节代码里
return 0在回显之前),所以先看日志有没有[CMD] xxx; - APP 端如果开了"自动换行/时间戳",回显会混在别的字符里,看起来像没收到;
- 手机端收到的是原始字节流,包括 2 秒一条的
{"t":..,"h":..},APP 要能区分"回显行"和"数据行"。
十一、本章易错点总结
| 现象 | 真正原因 | 处理 |
|---|---|---|
| 手机连着,设备不动 | 还在手动模式,KEY4 只是加热 | 先按 KEY2 进自动,再按 KEY4 |
| 命令发了完全没反应 | 拨码开关还在 WiFi 侧 | 拨到蓝牙侧,断电重上 |
日志连续 unknown: F/A/N | 命令缓冲每字节被清空,永远拼不齐 | 只有命中才清空(5.4 节) |
[CMD] unknown: FAN_ON | 命令字面量不对(CURTAIN_OPEN vs CURTAIN_ON) | 对照命令表 |
| 蓝牙收发全乱码 | 模块波特率不是 9600 | 裸测确认,AT+BAUD4 改回 |
| 修改波特率后模块失联 | AT+BAUD 后没断电重上 | 断电再上电 |
| 裸测发 AT 无回显 | 手机还连着模块(AT 只在未连接时有效) | 断开手机再试 |
| 手机搜不到 HC-06 | iPhone 不支持 SPP | 换安卓 |
| 第二台手机连不上 | HC-06 只支持一台主机 | 先断开第一台 |
| 蓝牙 OK、WiFi 不行(或反之) | 拨码没跟着切 | 硬件和软件协议必须一致 |
| 刚进远程就冒一条 unknown | 非远程模式残留的 uartlog=2 和旧字节 | 切协议时 uartlog=0 |
| 命令偶尔丢一两个字符 | DHT11 关中断 4ms 撞上 9600 收字节 | 重发;或改环形缓冲 |
| 切协议后第一字节丢失 | g_net 和挂接收的顺序反了 | 先置协议标志,再 Receive_IT |
| 切波特率后接收一直 HAL_BUSY | 少了 HAL_UART_AbortReceive_IT | 见 3.3 第 3 步 |
| 切波特率后最后一个字节残缺 | 只等 TXE 没等 TC | 等 UART_FLAG_TC |
| 中断里切波特率导致卡死 | 带等待的操作放进了中断 | 中断只置 g_net_req,主循环执行 |
| 远程控制后切回手动,第一次按键"没反应" | 标志位没同步 | Remote_Cmd_Exec 里同步 g_* |
| APP 上空气质量一直空白 | 蓝牙通道只报 t/h | 正常,五项数据走 MQTT |
| APP 收到一长串粘在一起的 JSON | 没按行切分 | 按 \n 断行再 JSON.parse |
日志里没有 [NET] Bluetooth @9600 | g_net_req 没被执行 | 检查主循环 g_net_req 处理段是否被 return/死循环挡住 |
十二、小结与下一篇
这一章记住六件事:
- HC-06 是从机、透传、默认 9600、只支持一台、iPhone 连不上;
- 上电默认手动模式,进远程的正确路径是
KEY2 → KEY4; - 拨码开关软件读不到,硬件和协议必须人工保持一致;
- 命令是整串字符串,
CURTAIN_ON不是CURTAIN_OPEN,APP 别自动加换行; - 中断只收字节,拼接和解析在主循环;缓冲区要"命中才清空";
- 蓝牙只报温湿度,MQTT 才报五项,两条通道共用同一个
Remote_Cmd_Exec()。
下一篇是全系列最难的一章:USART2 分时复用 —— ESP-12F + MQTT。同一组引脚、两种波特率、十几秒的阻塞初始化、AT 指令的增量匹配、AT+MQTTPUBRAW 的 > 握手……第 10 章和第 11 章铺的所有地基,都是为了它。
如果这篇文章对你有帮助,欢迎点赞收藏。系列更新中,下一篇见。
附录 A:本章涉及的完整代码
A.1 初始化:接收缓冲与首次挂载
/* main.c,MX_USART2_UART_Init() 之后 */
static uint8_t bt_buf[32] = {0}; // 中断每次只搬 1 个字节,实际只用到 bt_buf[0]
static uint8_t bt_cmd_buf[16] = {0}; // 命令拼接缓冲(最长 CURTAIN_OFF = 11 字符)
HAL_UART_Receive_IT(&huart2, bt_buf, 1); // 第二个参数是"本次收几个",不是缓冲容量
bt_buf[32]里的 32 是历史遗留,Receive_IT(..., 1)只写bt_buf[0]。别以为一次能收 32 个。
A.2 接收中断回调:按协议分派
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if(huart->Instance == USART1)
{
uartlog = 1; // 调试口收到的数据(本工程只标记,不消费)
}
else if(huart->Instance == USART2)
{
if(g_mode == MODE_REMOTE && g_net == NET_MQTT)
{
// MQTT:这 1 字节属于 ESP 的 AT 应答/订阅消息 → 进环形缓冲
Esp_Uart_RxByte(esp_rx_byte);
HAL_UART_Receive_IT(&huart2, &esp_rx_byte, 1); // 立刻重挂
}
else
{
// 蓝牙:只标记,主循环拼命令后再重挂(这样主循环能稳定读 bt_buf[0])
uartlog = 2;
}
}
}
两种重挂位置是有意不同的:
| 通道 | 重挂位置 | 原因 |
|---|---|---|
| MQTT | 中断里立刻重挂 | 连续流(AT 应答、订阅消息),晚一点就丢字节 |
| 蓝牙 | 主循环处理后重挂 | 主循环要先读 bt_buf[0],中断里重挂会把它覆盖 |
A.3 远程模式主循环:2 秒上报 + 命令拼接(修复版)
case MODE_REMOTE:
{
// ---- 每 2 秒:读传感器 + 蓝牙通道上报温湿度 ----
if(HAL_GetTick() - last_ctrl_tick >= 2000)
{
last_ctrl_tick = HAL_GetTick();
Sensors_Update();
if(g_dht11_ok && g_net == NET_BT)
{
char json_buf[48];
int len = snprintf(json_buf, sizeof(json_buf),
"{\"t\":%d,\"h\":%d}\r\n", g_TempH, g_HumiH);
HAL_UART_Transmit(&huart2, (uint8_t*)json_buf, len, 100);
}
}
// ---- 蓝牙命令:逐字节攒,凑齐一通就执行 ----
if(uartlog == 2)
{
uartlog = 0;
uint8_t clen = strlen((char*)bt_cmd_buf);
if(clen >= sizeof(bt_cmd_buf) - 1) // 攒满还没命中 → 乱码,丢弃重来
{
printf("[CMD] unknown: %s\r\n", bt_cmd_buf);
bt_cmd_buf[0] = '\0';
}
else
{
bt_cmd_buf[clen] = bt_buf[0];
bt_cmd_buf[clen + 1] = '\0';
if(Remote_Cmd_Exec((char*)bt_cmd_buf, 1)) // 命中才清空
bt_cmd_buf[0] = '\0';
}
HAL_UART_Receive_IT(&huart2, bt_buf, 1); // 重挂接收
}
break;
}
A.4 统一命令执行函数
见 7.1 节完整代码。注意:
- 返回类型从
void改为uint8_t(1=命中,0=未命中); - 未命中时不打印、不回显;
- 命中后同步
g_fan_on / g_heat_on / g_curtain_open。
MQTT 侧调用不受影响(忽略返回值即可):
Remote_Cmd_Exec(esp_sub_data, 0); // echo_back=0:不回显
A.5 运行时切波特率
见 3.3 节完整代码。调用约束:
| 约束 | 原因 |
|---|---|
| 只能在主循环调用 | 内部有等待,放中断会卡死 |
| 调用前拨码开关要到位 | 软件读不到开关 |
| 调用后必须自己重挂接收 | 函数内部已 AbortReceive_IT |
调用前先定好 g_net | 避免切换瞬间的字节走错分支 |
A.6 KEY4 的分派
else if(GPIO_Pin == KEY4_Pin)
{
if(g_mode == MODE_MANUAL)
{
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
{
if(g_mode != MODE_REMOTE)
{
Switch_Mode(MODE_REMOTE); // 进远程(默认蓝牙)
g_net_req = NET_REQ_BT;
}
else if(g_net_busy) // MQTT 初始化期间:丢弃
printf("[KEY4] busy: MQTT init in progress, ignored\r\n");
else if(g_net == NET_BT) g_net_req = NET_REQ_MQTT;
else g_net_req = NET_REQ_BT;
}
}
A.7 最小验证:三步确认蓝牙通
第一步:只验证"收到了字节"
在 uartlog = 2; 后面加一句临时打印:
printf("[BT] byte=%02X (%c)\r\n", bt_buf[0], bt_buf[0]);
手机发 F,串口助手应打印 [BT] byte=46 (F)。
- 没有 → 拨码开关、接线、模式、波特率(回到 10.2 节);
- 有 → 链路已通,问题一定在拼接/匹配。
第二步:验证拼接
把 5.4 节的修复版贴进去,手机发 FAN_ON,预期日志只有一行:
[CMD] FAN ON
并且 APP 收到回显 FAN_ON。
第三步:验证状态同步
发 FAN_ON → 按 KEY2 退到手模式 → 按 KEY1:
- 风扇应该立刻停(因为
g_fan_on已经是 1,翻转成 0); - 如果按第一下没反应、第二下才停,说明
Remote_Cmd_Exec()里没同步g_fan_on。
三步全过,蓝牙通道就算调通了,可以进入第 12 章的 ESP-12F。
本文详解STM32通过HC-06模块实现手机蓝牙远程控制智能家居设备的完整方案,涵盖HC-06透传特性、9600波特率默认配置、拨码开关硬件切换、远程模式状态机设计、整串命令协议(ON/OFF等7条)、逐字节接收与缓冲区命中清空机制、回显机制及与MQTT共用执行函数等关键技术点,并提供裸测验证、联调七步法和典型故障排查路径。
1万+

被折叠的 条评论
为什么被折叠?



