摘要:本文完整记录了基于 Hi3861(HarmonyOS LiteOS-M)的智能家居项目开发与调试全过程。设备端通过 WiFi 接入 MQTT Broker,上位机使用 MQTTX 发送 JSON 指令控制 LED、继电器和电机。文章围绕四个典型踩坑案例展开:Topic 前缀不一致导致消息无法送达、缺少心跳被 Broker 静默踢下线、重连时旧 TCP 连接未释放导致 AT+CIPSTART 超时、去掉 OLED 后 I2C 等待 ACK 卡死。每个问题都配有真实串口日志、根因分析和解决方案,并总结了串口打印定位法、常见问题速查表及稳定性建议,适合正在做 WiFi-IoT 项目的开发者参考。

最近在做一个基于 Hi3861(HarmonyOS LiteOS-M)的智能家居项目,设备端通过 WiFi 接入 MQTT Broker,上位机用 MQTTX 发送 JSON 指令来控制 LED、继电器和电机。

听起来很简单的一个流程,实际开发中却踩了一大堆坑:连不上 Broker、Topic 对不上、设备收到一两条消息就掉线、重连失败、去掉 OLED 后 I2C 卡死……

这篇文章把整个调试过程完整记录下来,每一个坑都有真实的串口日志和解决方案,希望能帮到同样在做 WiFi-IoT 项目的同学。

一、项目架构

1.1 硬件清单

硬件

说明

Hi3861 开发板

主控,运行 HarmonyOS LiteOS-M

三色 LED

红、绿、蓝三路 GPIO 控制

继电器模块

GPIO 控制开关

电机模块

PWM 调速,正反转控制

OLED 显示屏

I2C 接口(后期去掉)

1.2 软件架构

 

┌─────────────┐ MQTT JSON ┌──────────────┐ MQTT JSON ┌─────────────┐

│ MQTTX │ ───────────────> │ MQTT Broker │ <───────────── │ Hi3861 │

│ (上位机) │ <─────────────── │ broker.emqx │ ──────────────> │ (设备端) │

└─────────────┘ wj/device/to/app └──────────────┘ wj/app/to/device └─────────────┘

Topic 设计

  • wj/app/to/device:上位机 → 设备,下发控制指令
  • wj/device/to/app:设备 → 上位机,上报传感器数据

注意:公共 Broker 是全世界共用的,Topic 一定要加自己的前缀(这里用了 wj/),否则会收到陌生人的消息。


二、MQTTX 客户端配置

2.1 选择 Broker

刚开始学习推荐用公共 Broker,不用自己搭服务器:

Broker 地址

TCP 端口

TLS 端口

说明

broker.emqx.io

1883

8883

EMQX 官方,最常用

broker.hivemq.com

1883

8883

HiveMQ 公共服务

test.mosquitto.org

1883

8883

Eclipse Mosquitto

如果项目要长期稳定运行,建议自己在云服务器上搭 EMQX,后面会说为什么。

2.2 MQTTX 连接参数

新建连接,只需要关注 General 栏:

字段

填写内容

说明

Name

随便起,如 WJ

仅本地显示用

Host

broker.emqx.io

直接填域名,MQTTX 自动解析

Port

1883

明文 TCP,不用开 SSL/TLS

Client ID

留空自动生成

设备和 MQTTX 的 Client ID 不能相同,否则互相踢下线

Username / Password

留空

公共 Broker 不需要认证

SSL/TLS

关闭

1883 端口是明文

Advanced 栏全部默认即可(MQTT 3.1.1、Keep Alive 60s、Auto Reconnect 开启)。

2.3 订阅和发布

连接成功后:

  1. + New Subscription,订阅 wj/device/to/app(接收设备上报)
  1. 底部发布区 Topic 填 wj/app/to/device(给设备发指令)
  1. 格式选 JSON,QoS 选 0

小知识:如果你同时订阅了自己发布的 Topic,会收到自己发出去的消息(白色气泡),这是正常的 "回声",不是设备回复的。绿色气泡才是你自己发出去的。


三、设备端基础代码

3.1 WiFi + MQTT 初始化

 
#include "led.h"
#include "los_task.h"
#include "los_compiler.h"
#include "relay.h"
#include "stdint.h"
#include "data.h"
void os_entry(){
static int length;
static char *p;
ui_init(); // OLED 初始化(后期去掉 OLED 后注释掉)
key_entry(); // 按键初始化
// WiFi 模块初始化(通过 UART 发 AT 指令)
WifiUartInit();
EnableWifi();
// 连接路由器
HOME_DATA da = {0};
ConnectToAP("你的WiFi名", "你的WiFi密码");
// 连接 MQTT Broker
mqtt_connect("broker.emqx.io", 1883);
// 设备上线,发布一条消息
mqtt_publish("wj/device/to/app", da);
// 订阅控制指令 Topic
mqtt_subscribe("wj/app/to/device");
// 主循环:接收消息
while(1){
p = mqtt_getdata(&length);
if(p != NULL){
mqtt_parsedata(p);
delay_ms(1 * 1000);
}
}
}

开机串口输出:

 
entering kernel init...
---------------liteos_m start------------
init i2c ok
Enable Wi-Fi ... Success ...
Connect to hosspot ... Success ...
Connect to Server broker.emqx.io OK
Sending to hostname broker.emqx.io port 1883
mqtt connect ok
mqtt pushlish ok
sub topic wj/app/to/device ok

3.2 JSON 指令解析

上位机发送 JSON 格式的控制指令,设备端用 cJSON 解析:

void mqtt_parsedata(char *msg)
{
cJSON *root = cJSON_Parse(msg);
if(root == NULL){
printf("json parse fail: %s\n", msg);
return;
}
cJSON *item;
/* ---------- 红灯 ---------- */
item = cJSON_GetObjectItem(root, "led_r");
if(cJSON_IsString(item)){
if(!strcmp(item->valuestring, "on")) red_entry();
else if(!strcmp(item->valuestring, "off")) led_off(LED_RED);
}
/* ---------- 绿灯 ---------- */
item = cJSON_GetObjectItem(root, "led_g");
if(cJSON_IsString(item)){
if(!strcmp(item->valuestring, "on")) green_entry();
else if(!strcmp(item->valuestring, "off")) led_off(LED_GREEN);
}
/* ---------- 蓝灯 ---------- */
item = cJSON_GetObjectItem(root, "led_b");
if(cJSON_IsString(item)){
if(!strcmp(item->valuestring, "on")) blue_entry();
else if(!strcmp(item->valuestring, "off")) led_off(LED_BLUE);
}
/* ---------- 继电器 ---------- */
item = cJSON_GetObjectItem(root, "relay");
if(cJSON_IsString(item)){
if(!strcmp(item->valuestring, "on")) relay_on(RELAY);
else if(!strcmp(item->valuestring, "off")) relay_off(RELAY);
}
/* ---------- 电机 ---------- */
item = cJSON_GetObjectItem(root, "motor_state");
if(cJSON_IsString(item)){
cJSON *jspd = cJSON_GetObjectItem(root, "motor_speed");
int speed = cJSON_IsNumber(jspd) ? jspd->valueint : 0;
if(speed < 0) speed = 0;
if(speed > 100) speed = 100;
if(!strcmp(item->valuestring, "forward")){
motor_ctl(MOTOR_FORWARD, speed);
}else if(!strcmp(item->valuestring, "backward")){
motor_ctl(MOTOR_BACKWARD, speed);
}else{
motor_ctl(MOTOR_STOP, 0); // stop / standby 都停转
}
}
cJSON_Delete(root);
free(msg); // 对应 mqtt_getdata 里的 malloc,防止内存泄漏
}

3.3 上位机指令速查表

在 MQTTX 里发送以下 JSON,Topic 统一填 wj/app/to/device:

功能

JSON 报文

红灯开

{"led_r":"on"}

红灯关

{"led_r":"off"}

绿灯开

{"led_g":"on"}

蓝灯开

{"led_b":"on"}

继电器开

{"relay":"on"}

继电器关

{"relay":"off"}

电机正转(80% 速度)

{"motor_state":"forward","motor_speed":80}

电机反转(50% 速度)

{"motor_state":"backward","motor_speed":50}

电机停止

{"motor_state":"stop"}

多条合并

{"led_r":"on","led_g":"off","relay":"on"}

JSON 格式注意事项

  • 键名和字符串值必须用英文双引号 ",不能用中文引号
  • 数字(如 motor_speed)不加引号
  • MQTTX 左下角选 JSON 格式,编辑框里没有红色波浪线才是合法 JSON

四、踩坑一:Topic 对不上,消息发了但设备收不到

4.1 现象

MQTTX 里消息显示发送成功,但设备完全没反应。

4.2 排查过程

一开始设备代码里写的是:

mqtt_publish("device/to/app", da);

mqtt_subscribe("app/to/device");

后来在 MQTTX 里为了防止和别人串消息,加了 wj/ 前缀:

  • 订阅:wj/device/to/app
  • 发布:wj/app/to/device

结果设备代码没同步改,两边 Topic 差了一个前缀,Broker 当成完全不同的主题,消息根本不会转发

4.3 解决

设备代码和 MQTTX 里的 Topic 必须一字不差

mqtt_publish("wj/device/to/app", da);

mqtt_subscribe("wj/app/to/device");

4.4 调试技巧:订阅通配符 #

如果不确定设备到底发到哪个 Topic 了,可以在 MQTTX 里订阅 #(井号),它会接收 Broker 上所有 Topic 的消息,设备一发就能看到实际的 Topic 名字。


五、踩坑二:设备收到一两条消息后就 "死" 了(重点)

这是整个项目中耗时最长的一个坑。

5.1 现象

设备刚连上时能正常收到一两条指令,LED 也能亮,但过一会儿再发消息就完全没反应了。串口一直在刷 66666(主循环打印),看起来程序还在跑,但就是收不到消息。

5.2 第一步:加打印定位

在主循环关键位置加 printf:

while(1){
printf("loop start\n");
p = mqtt_getdata(&length);
printf("loop end, p=%p\n", p);
if(p != NULL){
printf("[1] before parse\n");
mqtt_parsedata(p);
printf("[2] after parse\n");
delay_ms(1 * 1000);
}
}

串口输出:

message arrived {"led_b":"on"}
loop end, p=0x2000ca64
[1] before parse
[2] after parse
loop start
loop end, p=(nil)
loop start
loop end, p=(nil)
loop start
loop end, p=(nil)
...(一直返回空)

说明:

  • [1] 和 [2] 都打印了 → JSON 解析没问题,不是卡死
  • 之后 p=(nil) 一直返回空 → MQTT 连接已经断了,但程序不知道

5.3 根本原因:没有心跳,被 Broker 踢下线

MQTT 协议规定:客户端在连接时会协商一个 Keep Alive 时间(默认 60 秒),如果在 1.5 倍 Keep Alive 时间内 Broker 没收到客户端的任何数据包(包括 PINGREQ 心跳),就会主动断开连接。

设备端的 MQTT 库不会自动发心跳,需要我们手动调用。原来的代码里完全没有心跳逻辑,所以连上几十秒后就被 Broker 静默踢掉了。

5.4 心跳函数

MQTT 心跳就是客户端发 PINGREQ,Broker 回 PINGRESP:

/**
@brief 发送 MQTT 心跳包
@return 0=成功, -1=失败(连接已断)
*/
int mqtt_jump(void)
{
int rc = 0;
unsigned char buf[200];
int buflen = sizeof(buf);
// 序列化 PINGREQ 报文并发送
int len = MQTTSerialize_pingreq(buf, buflen);
rc = transport_sendPacketBuffer(mysock, buf, len);
// 等待 PINGRESP 响应
if(MQTTPacket_read(buf, buflen, transport_getdata) == PINGRESP){
printf("mqtt ping packet ok\n");
rc = 0;
}else{
printf("mqtt ping packet failed\n");
rc = -1;
}
return rc;
}

5.5 最终的主循环:心跳 + 自动重连

void os_entry(){
static int length;
static char *p;
int heartbeat_cnt = 0;
ui_init();
key_entry();
WifiUartInit();
EnableWifi();
HOME_DATA da = {0};
ConnectToAP("你的WiFi名", "你的WiFi密码");
mqtt_connect("broker.emqx.io", 1883);
mqtt_publish("wj/device/to/app", da);
mqtt_subscribe("wj/app/to/device");
while(1){
p = mqtt_getdata(&length);
if(p != NULL){
/* ---------- 收到消息 ---------- */
printf("message arrived %s\n", p);
mqtt_parsedata(p);
delay_ms(1 * 1000);
heartbeat_cnt = 0; // 收到数据说明连接正常,重置计数
}else{
/* ---------- 没有消息 ---------- */
delay_ms(100);
heartbeat_cnt++;
// 连续 N 次没收到数据,认为连接断了,重连
if(heartbeat_cnt >= 10){
heartbeat_cnt = 0;
printf("reconnect...\n");
mqtt_connect("broker.emqx.io", 1883);
mqtt_subscribe("wj/app/to/device");
}
}
}
}

5.6 踩坑:计数阈值不能想当然

一开始我以为每次循环约 100ms,设了 50 次(5 秒)触发重连。结果等了 20 秒都没触发。

后来打印计数器才发现:mqtt_getdata() 内部的 MQTTPacket_read 是阻塞函数,每次调用本身就要等 1~2 秒才返回 NULL。所以实际每次循环约 2 秒,阈值要根据实际循环周期调整。

经验:阈值定多少,先用 printf 打印计数器实际观察,不要拍脑袋。


六、踩坑三:重连失败,AT+CIPSTART 超时

6.1 现象

自动重连触发了,但重连失败,串口报:

reconnect...
Sending1 to hostname
rsp AT+CIPSTART= timeout: 0
rsp AT+CIPMODE= timeout: 0

6.2 原因

WiFi 模块通过 AT 指令建立 TCP 连接:

  • AT+CIPSTART:建立 TCP 连接
  • AT+CIPCLOSE:关闭 TCP 连接

MQTT 掉线后,旧的 TCP 连接在 WiFi 模块里还占着没有释放,直接发 AT+CIPSTART 开新连接就会超时失败。

6.3 尝试过的错误方案

尝试 1:重新初始化 UART + WiFi

WifiUartInit(); // 报错:LL_USART_Init failed,UART 已经初始化过了
EnableWifi();
ConnectToAP(...);
UART 重复初始化直接失败。

尝试 2:只重连 WiFi

EnableWifi();
ConnectToAP(...);

报错:

rsp AT+CWMODE= timeout: 0
Failed, reason is : ERROR_WIFI_UNKNOWN
rsp AT+CWJAP= timeout: 0

因为 WiFi 本来就没断(断的是 TCP/MQTT),重复连 WiFi 也会失败。

6.4 正确思路

  • WiFi 没断 → 不要动 WiFi
  • 旧 TCP 没关 → 重连前先发 AT+CIPCLOSE 关掉旧连接

需要在 mqtt_connect 之前加一个关闭旧 TCP 的操作(具体函数名根据你的封装来定):

if(heartbeat_cnt >= 10){
heartbeat_cnt = 0;
printf("reconnect...\n");
// 先关闭旧 TCP 连接(AT+CIPCLOSE)
mqtt_tcp_close();
delay_ms(500);
// 再建立新连接
mqtt_connect("broker.emqx.io", 1883);
mqtt_subscribe("wj/app/to/device");
}

建议:如果条件允许,直接换成自己云服务器上搭的 Broker(如 EMQX),公共 Broker 对频繁重连有连接数限制,自建的更稳定可控。


七、踩坑四:去掉 OLED 后,程序卡死在 I2C

7.1 现象

项目后期不需要 OLED 了,把 OLED 相关代码删了一部分,结果开机后卡住:

entering kernel init...
---------------liteos_m start------------
init i2c ok

打印完 init i2c ok 就不动了。

7.2 排查过程

一开始以为是 I2C 初始化卡死,后来发现 init i2c ok 都打印出来了,说明 I2C 总线初始化本身没问题。

真正卡死的位置是在后面调用的 ui_init()(OLED 初始化)里 —— 它要往 OLED 的 I2C 地址发命令并等待 ACK 应答

7.3 I2C 卡死原理

I2C 通信流程:

主设备(Hi3861) 从设备(OLED)
| |
|------ START + 设备地址 --------------->|
| |
|<-------- 等待 ACK 应答 ----------------|
| |
| OLED 没接 / 没初始化,无人应答 |
| |
v 驱动里没有超时判断,死循环等待 v
【卡死】

I2C 驱动的底层逻辑大概是:

while(等待ACK){
if(SDA线被从设备拉低){
break; // 收到 ACK,继续
}
// 没有超时退出机制 → 没人应答就永远等
}

7.4 解决

不接 OLED,就把调用 OLED 初始化的那行注释掉:

void os_entry(){
// ui_init(); // 不接 OLED 就注释掉,否则 I2C 等 ACK 卡死
key_entry();
...
}

注意:init i2c ok 是系统底层自动初始化 I2C 总线时打印的,这个不用管,它不会卡死。卡死的是应用层主动去访问 OLED 设备的代码。


八、完整调试方法论

经过这一天的折腾,总结出嵌入式 MQTT 调试的标准流程:

8.1 串口打印定位法

不要猜,用 printf 让程序自己告诉你卡在哪。

在每个关键步骤前后加打印:
printf("step 1: before connect\n");
mqtt_connect(...);
printf("step 2: after connect\n");
while(1){
printf("loop\n");
p = mqtt_getdata(&length);
printf("got data: %p\n", p);
if(p != NULL){
printf("before parse\n");
mqtt_parsedata(p);
printf("after parse\n");
}
}

看最后停在哪条打印,问题就出在它和下一条之间。

8.2 常见问题速查表

现象

原因

解决

MQTTX 连不上

Host/Port 填错、SSL 开关不对

用 Test-NetConnection IP 端口 测网络

消息发了设备收不到

Topic 字符串不一致

两边逐字核对,或订阅 # 通配符

设备频繁被踢下线

Client ID 和别的客户端重复

每个客户端用唯一 Client ID

收一两条就没反应了

没心跳,被 Broker 踢

加 PINGREQ 心跳 + 断线重连

重连 AT+CIPSTART 超时

旧 TCP 连接没关

先 AT+CIPCLOSE 再重连

重连 WiFi 报 UNKNOWN

WiFi 本来就没断

只重连 TCP/MQTT,别动 WiFi

开机卡死

I2C 等不到从设备 ACK

注释掉没接设备的初始化代码

JSON 发送报 SyntaxError

键名 / 字符串没加英文双引号

严格按 JSON 语法写

8.3 稳定性建议

  1. 心跳必须有:Keep Alive 建议设 30~60 秒,心跳间隔设为 Keep Alive 的一半
  1. 自动重连必须有:检测到断线后自动关闭旧连接、重新 connect、重新 subscribe
  1. 重连后要重新订阅:TCP 重连后之前的订阅关系就没了,必须重新 mqtt_subscribe
  1. 生产环境用自建 Broker:公共 Broker 有连接数和频率限制,只适合学习测试
  1. WiFi 信号要稳定:信号弱是掉线的重要原因,天线和路由器位置注意一下

九、最终稳定版代码结构

os_entry()
├── 硬件初始化(LED、按键等,不接的设备不要初始化)
├── WiFi 初始化(UART → EnableWifi → ConnectToAP)
├── MQTT 连接(mqtt_connect → mqtt_subscribe)
└── while(1) 主循环
├── mqtt_getdata() 轮询消息
├── 有消息 → mqtt_parsedata() 解析执行 → 重置计数
└── 无消息 → 计数累加
└── 超过阈值 → 关旧 TCP → 重连 → 重新订阅

十、写在最后

回头看,这些坑其实都不难,但在刚接触嵌入式 MQTT 的时候,每一个都能卡半天。最大的体会是:

  1. 嵌入式开发,串口日志是第一生产力。不要盯着代码猜,加几行 printf,问题在哪一层立刻就清楚了。
  1. 网络通信是 "会断的"。不要假设连接一直正常,心跳和重连是必备代码,不是可选功能。
  1. 协议规范要读。MQTT 的 Keep Alive 机制、I2C 的 ACK 机制,文档里都写得很清楚,踩坑后再回头看文档印象特别深。

希望这篇文章对你有帮助,欢迎在评论区交流讨论。


相关阅读

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐