【Hi3861 物联网实战】MQTT 心跳保活详解:设备为什么连上几十秒就 “假死“,PINGREQ/PINGRESP、自动重连一次讲清
在做 Hi3861(HarmonyOS LiteOS-M)智能家居项目时,遇到一个非常诡异的问题:
- 设备开机后能正常连上 MQTT Broker;
- 刚连上时能收到一两条 JSON 控制指令,LED 也能正常点亮;
- 但过一会儿再发指令,设备就彻底没反应了,主循环的 printf 还在刷,说明程序没死机。
折腾了半天才发现:MQTT 连接早就被 Broker 静默踢掉了,而设备根本不知道。根因是设备端没有发送 MQTT 心跳(Keep Alive)。
这篇文章专门把“心跳保活”这一块讲透:协议原理、心跳函数、怎么集成进主循环、心跳和“定时重连”的区别、断线后重连的正确顺序,以及对应的 AT 指令报错。
环境说明:Hi3861 通过 UART 驱动 WiFi 模组(AT 指令),MQTT 协议栈使用 Paho 嵌入式库 MQTTPacket,Broker 为公共服务器 broker.emqx.io:1883。
一、为什么必须有心跳
MQTT 客户端在建立连接(CONNECT 报文)时,会携带一个 Keep Alive(心跳间隔)字段,本项目设为 60 秒:
data.keepAliveInterval = 60;
MQTT 协议规定:
如果 Broker 在 1.5 倍 Keep Alive 时间(约 90 秒)内没有收到客户端的任何报文,就认为客户端已经掉线,会主动关闭这条 TCP 连接,并且不会通知设备。
注意两个关键点:
- 任何 MQTT 报文都算“活着”的证据——PUBLISH、SUBSCRIBE、PINGREQ 都可以,不只是心跳;
- Paho 嵌入式 MQTTPacket 库不会自动发心跳,需要应用层自己定时发送。
设备刚连上时一来一回有报文,Broker 认为它在线;之后如果一直没有指令下发、设备也不上报,连接空闲超过时限,Broker 就把它踢掉了。此时设备主循环还在调用接收函数,只是永远收不到东西 —— 这就是 "假死"。
二、心跳的本质:PINGREQ / PINGRESP
MQTT 的心跳由两个极简的报文组成:
设备端 Broker
│ │
│ ──────── PINGREQ(我还在线)────────────> │
│ │
│ <──────── PINGRESP(收到,你还在)──────── │
- 设备发出 PINGREQ,报文里没有任何负载;
- Broker 必须回一个 PINGRESP;
- 能收到 PINGRESP → 连接健康;读超时收不到 → 连接已死,需要重连。
心跳间隔一般取 Keep Alive 的一半:Keep Alive = 60s,就每 30s 发一次心跳,给网络抖动和重传留出余量。
三、心跳函数实现
MQTTPacket 库已经提供了 PINGREQ 的序列化函数 MQTTSerialize_pingreq,配合发送函数和报文读取即可:
/**
* @brief 发送 MQTT 心跳包
* @return 0 = 收到 PINGRESP,连接正常;-1 = 心跳失败,连接已断
*/
int mqtt_jump(void)
{
int rc = 0;
unsigned char buf[200];
int buflen = sizeof(buf);
/* 1. 序列化 PINGREQ 报文 */
int len = MQTTSerialize_pingreq(buf, buflen);
if(len <= 0) return -1;
/* 2. 发送 */
rc = transport_sendPacketBuffer(mysock, buf, len);
if(rc != len) return -1;
/* 3. 阻塞读取,判断 Broker 回的是不是 PINGRESP */
if(MQTTPacket_read(buf, buflen, transport_getdata) == PINGRESP){
printf("mqtt ping packet ok\n");
return 0;
}else{
printf("mqtt ping packet failed\n");
return -1;
}
}
头文件声明要和实现保持一致。工程模板里曾把声明写成 void mqtt_jump();,但函数体明明返回 int,会导致调用处无法判断成败,务必改成:
int mqtt_jump(void);
四、心跳 ≠ 定时重连(最容易混淆的概念)
很多同学(包括我自己一开始)会写成这样:主循环里计数,没收到消息 N 次就重新 mqtt_connect。这不是心跳,而是 "定时盲重连",两者有本质区别:
| 对比项 | 真心跳 mqtt_jump () | 定时盲重连 |
| 做了什么 | 发 PINGREQ,等待 PINGRESP | 什么都不发,直接重新 connect |
| 如何判断连接死活 | 有应答才算活,没应答才是死 | 完全不判断,"没消息" 就当死了 |
| 连接正常但暂时没消息 | 保持连接,什么都不做 | 会把一条好好的连接白白拆掉重建 |
| 对 Broker 的影响 | 友好,符合协议 | 频繁建立 / 断开连接,可能被限流 |
一定要建立这个观念:没收到消息 ≠ 连接断了。设备在线但没有人操作时,本来就没有任何消息,这是正常状态,不需要任何处理。
五、把心跳集成进主循环
5.1 一个关键事实:接收函数会阻塞 15 秒
不能想当然地用 delay_ms(100) 估算循环周期。看底层 transport 代码:
int transport_getdata(unsigned char *buf, int count)
{
int rc = ESP8266_Recv(buf, count, 1000 * 15); // 超时 15 秒
return rc;
}
mqtt_getdata() 在没有消息时,最长会阻塞 15 秒才返回 NULL。因此:
空转 1 次 ≈ 15 秒;
空转 2 次 ≈ 30 秒,正好是一个心跳周期。
5.2 最终主循环
void os_entry()
{
static int length;
char *p;
int idle_cnt = 0; // 无消息计数(空转 1 次约 15 秒)
int ping_fail = 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){
/* ---------- 收到消息:解析执行 ---------- */
mqtt_parsedata(p);
idle_cnt = 0; // 有报文往来,说明连接正常
ping_fail = 0;
delay_ms(1000); // 注意不要写成 11000,长延时会饿死心跳
}else{
/* ---------- 没有消息:空转一次约 15 秒 ---------- */
idle_cnt++;
if(idle_cnt >= 2){ // 约 30 秒发一次心跳
idle_cnt = 0;
if(mqtt_jump() == 0){
ping_fail = 0;
}else{
ping_fail++;
printf("heartbeat fail %d\n", ping_fail);
if(ping_fail >= 2){ // 连续 2 次失败才重连
ping_fail = 0;
reconnect_mqtt();
}
}
}
}
}
}
三个设计要点:
- 心跳间隔取 Keep Alive 的一半(60s → 30s);
- 连续失败 2 次再重连,偶发的一次丢包不触发重连,避免抖动;
- 收到任何报文都把计数清零,正常通信本身就完成了保活,不必额外发心跳。
补充:如果设备本身就要周期性上报传感器数据(比如每 30 秒 publish 一次电压、温湿度),PUBLISH 报文同样会刷新 Broker 的 Keep Alive 计时,心跳和周期上报放在同一个节拍即可。
六、断线重连的正确顺序
心跳确认连接已断后,重连只动 TCP/MQTT 层,不碰 WiFi 层:
void reconnect_mqtt(void)
{
printf("reconnect...\n");
transport_close(0); // 1. 先关旧 TCP(AT+CIPCLOSE)
delay_ms(500);
mqtt_connect("broker.emqx.io", 1883); // 2. 重新建立 TCP + 发送 CONNECT
mqtt_subscribe("wj/app/to/device"); // 3. 重新订阅(订阅关系不保留)
}
其中 transport_close() 内部调用的就是 WiFi 模组的 ESP8266_DisConnect(),对应 AT 指令 AT+CIPCLOSE。
重连时踩过的三个坑
| 错误做法 | 串口报错 | 原因 |
| 不关旧连接,直接 mqtt_connect | rsp AT+CIPSTART= timeout: 0 | 旧 TCP 还占着,模组拒绝新建连接 |
| 重新调用 WifiUartInit () | LL_USART_Init failed | UART 已经初始化过,不能重复初始化 |
| 重新调用 EnableWifi ()/ConnectToAP () | ERROR_WIFI_UNKNOWN、AT+CWJAP timeout | WiFi 一直连着,断的只是 TCP,重复连 WiFi 必然失败 |
一句话总结:掉线时 WiFi 没断,断的只是 TCP/MQTT,所以只关 TCP、重连 MQTT、重新订阅,绝不要重新初始化 WiFi。
另外,TCP 重连后之前的订阅关系会全部丢失,第 3 步的 mqtt_subscribe 不能省。
七、验证方法
烧录后保持设备空闲(不要下发任何指令),打开串口观察:
mqtt ping packet ok ← 每约 30 秒出现一次
mqtt ping packet ok
mqtt ping packet ok
分三步验证:
- 保活验证:连续观察 3~5 分钟,心跳按时打印,然后随时在 MQTTX 下发一条指令,设备能立刻响应,说明不会再被 Broker 踢掉;
- 重连验证:拔掉路由器(或断开外网)十几秒再插回,串口应依次出现 mqtt ping packet failed → heartbeat fail → reconnect... → 重新订阅成功,之后又能收到指令;
- 长时间稳定性:连续运行并间歇性下发几十条指令,设备不卡死、不掉线、无内存报错。
八、常见问题速查
| 现象 | 根因 | 解决 |
| 收一两条消息后就没反应 | 无心跳,被 Broker 按 Keep Alive 踢掉 | 每约 30 秒调用 mqtt_jump () |
| 心跳计数阈值不准 | mqtt_getdata 内部阻塞 15 秒,不是 100ms | 按 "空转一次≈15 秒" 计数,或用系统 Tick 计时 |
| 收到消息后长时间不发心跳 | 解析后写了 delay_ms (11000) 之类的长延时 | 改回 1000ms,别阻塞主循环 |
| 重连报 AT+CIPSTART timeout | 旧 TCP 没关 | 重连前先 transport_close () |
| 重连报 LL_USART_Init failed | 重复初始化串口 | 不要重调 WifiUartInit () |
| 重连报 ERROR_WIFI_UNKNOWN | WiFi 没断却去重连 WiFi | 只重连 MQTT,不动 WiFi |
| 重连后收不到指令 | 漏了重新订阅 | connect 之后必须重新 subscribe |
| mqtt_jump 没法判断成败 | 头文件声明成了 void | 改成 int mqtt_jump(void); |
九、小结
- MQTT 是跑在 TCP 之上的协议,TCP 会断、Broker 会踢空闲连接,心跳是必备代码,不是可选功能;
- 心跳 = 定时发 PINGREQ、确认 PINGRESP,判断连接死活靠应答,不靠 "有没有消息";
- 心跳间隔取 Keep Alive 的一半,连续失败再重连,避免误判;
- 重连只动 TCP/MQTT:关旧连接 → 重连 → 重新订阅,不要重新初始化 WiFi;
- 嵌入式网络开发,串口日志是第一生产力,把心跳成功 / 失败、重连过程都打印出来,问题一目了然。
希望这篇文章能帮你少走半天弯路,欢迎在评论区交流讨论。
相关阅读:
- MQTT 协议中文版:https://mcxiaoke.gitbook
更多推荐

所有评论(0)