在做 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 连接,并且不会通知设备。

注意两个关键点:

  1. 任何 MQTT 报文都算“活着”的证据——PUBLISH、SUBSCRIBE、PINGREQ 都可以,不只是心跳;
  1. 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();
    
    }
    
    }
    
    }
    
    }
    
    }
    
    }

    三个设计要点:

    1. 心跳间隔取 Keep Alive 的一半(60s → 30s);
    1. 连续失败 2 次再重连,偶发的一次丢包不触发重连,避免抖动;
    1. 收到任何报文都把计数清零,正常通信本身就完成了保活,不必额外发心跳。

    补充:如果设备本身就要周期性上报传感器数据(比如每 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

    分三步验证:

    1. 保活验证:连续观察 3~5 分钟,心跳按时打印,然后随时在 MQTTX 下发一条指令,设备能立刻响应,说明不会再被 Broker 踢掉;
    1. 重连验证:拔掉路由器(或断开外网)十几秒再插回,串口应依次出现 mqtt ping packet failed → heartbeat fail → reconnect... → 重新订阅成功,之后又能收到指令;
    1. 长时间稳定性:连续运行并间歇性下发几十条指令,设备不卡死、不掉线、无内存报错。

    八、常见问题速查

    现象

    根因

    解决

    收一两条消息后就没反应

    无心跳,被 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);


    九、小结

    1. MQTT 是跑在 TCP 之上的协议,TCP 会断、Broker 会踢空闲连接,心跳是必备代码,不是可选功能;
    1. 心跳 = 定时发 PINGREQ、确认 PINGRESP,判断连接死活靠应答,不靠 "有没有消息";
    1. 心跳间隔取 Keep Alive 的一半,连续失败再重连,避免误判;
    1. 重连只动 TCP/MQTT:关旧连接 → 重连 → 重新订阅,不要重新初始化 WiFi;
    1. 嵌入式网络开发,串口日志是第一生产力,把心跳成功 / 失败、重连过程都打印出来,问题一目了然。

    希望这篇文章能帮你少走半天弯路,欢迎在评论区交流讨论。


    相关阅读:

    更多推荐