上一站:嵌入式转型的五个阶段(总览) 上一关:S2:下到 MCU 这一篇:只做 S3 —— 网关 + 3 个节点的小系统。 写给谁:S2 已经收口,能独立让 MCU 联网、能写出网关程序的人。 耗时:4–6 周,每天 1.5–2 小时。


先说结论:这一关考的不是「能通信」

能通信很简单。socket 发一串字节,对面收到,这叫能通信。

这一关真正考的是:断了之后怎么办。

你以为要做的实际要做的
让节点把数据发到网关让节点在网断了以后还能把数据补上去
让网关显示三个节点的数据让一个节点掉线时界面变灰,其他两个照常
网关重启后重新连上网关重启时节点完全无感知,自己就接回去了
数据格式定好断网期间的数据时间戳不能乱

这一关过不了,S4 做不出来。 因为长期运行的系统,99% 的问题都发生在「异常路径」上,而异常路径在第一版里通常一条都没写。


一、为什么中间要有一个 Broker

先说一个反直觉的决定:节点不直接连网关。

错误直觉:node → 网关
正确做法:node → Broker ← 网关

S3 系统拓扑

看起来是绕远了。但这一层中间人换来了三件事:

1. 节点不需要知道网关在哪

网关换了 IP、加了第二个网关、临时下线维护——节点完全不需要改代码,甚至不知道发生过。

2. 网关重启,节点毫无感知

如果是直连,网关一重启,所有节点的 TCP 连接全断,要一起重连——三个节点同时打过来。有了 Broker,节点的连接对象是 Broker,网关重启只影响网关自己。

3. 加法变成线性

加第四个节点,不需要通知任何人。它自己连上 Broker、发自己的主题,网关订阅通配符就自动收到了。

这就是「中间件」的普遍价值:不是让通信更快,是让两端解耦。 你在 Linux 那 28 天里已经见过一次——nginx 挡在浏览器和你的程序之间,同一个道理。


二、为什么是 MQTT,不是 HTTP

这个问题面试常问,但答案通常答偏。常见答案是「MQTT 报文小、省流量」——这只说对了一半,而且不是关键。

三种通信模式对比

真正关键的是这一句:

HTTP 只能「客户端问,服务器答」。MQTT 服务器可以主动「推」给客户端。

设备要做远程控制,服务器就必须能主动找设备。HTTP 做不到这件事——只能让设备不停地问「有我的命令吗」。这就是轮询。

三者的代价对比:

方式连接每日握手次数(3 节点 × 1Hz)能否服务端主动推
HTTP 轮询每次请求重建259,200❌
HTTP 长轮询挂住,超时重连取决于超时⚠️ 勉强(靠挂住)
MQTT一次连接长期保持3✅

「能推」这件事不是协议高明,是「连接一直在」。 连接在,服务器随时可以往里塞数据。

MQTT 的三个 QoS,用哪个

QoS语义代价什么时候用
0发出去就不管,可能丢最低高频传感器数据(丢一条无所谓)
1至少一次,可能重复中大多数场景(要自己做幂等)
2恰好一次最高(两轮握手)计费、开关机等不能重复的动作

新手最容易犯的错:以为 QoS 1 是「可靠送达」。它是「至少一次」——同一份数据可能到两次。 所以接收端必须能用时间戳或序号去重。不然你会看到曲线莫名其妙跳一下。


三、主题怎么设计:把「谁」和「什么」分开

主题结构

一条硬规矩:设备标识放主题里,不要放数据里。

❌ 坏设计:home/data          {"id":"node01","temp":25.3}
✅ 好设计:home/node01/sensor/temp    25.3

为什么:

  • 网关要「只订阅温度」→ 好设计一条通配符搞定,坏设计要收全量再过滤
  • 权限控制:好设计可以精确授权某个节点只能写自己的主题
  • 调试:mosquitto_sub -t 'home/#' 直接看出谁在发什么

推荐的完整结构:

用途主题方向
上报数据home/<id>/sensor/<metric>节点 → 网关
在线状态home/<id>/status节点 → Broker → 网关
下发命令home/<id>/cmd/<action>网关 → 节点
命令回执home/<id>/ack/<action>节点 → 网关

两个通配符(只能用于订阅):

  • + 匹配一层:home/+/sensor/temp → 命中 node01/02/03 的 temp
  • # 匹配剩余所有层:home/# → 全部

⚠️ 发布时不能用通配符。 只能订阅。这条限制是对的——否则一个节点能冒充所有节点发数据。


四、节点端:一个能跑几个月的 MQTT 客户端

这是本关最核心的一段代码。它必须同时处理四件事:上报、收命令、断线重连、断网缓存。

4.1 连接时就把「遗嘱」交代清楚

#include "mqtt_client.h"
#include "esp_log.h"
#include <string.h>

static const char *TAG = "node";

/* 节点自己的身份,烧录时改这三个宏 */
#define NODE_ID      "node01"
#define WIFI_SSID    "your-ssid"
#define WIFI_PASS    "your-pass"

static esp_mqtt_client_handle_t s_client = NULL;
static volatile bool s_online = false;

static void mqtt_event_handler(void *args, esp_event_base_t base,
                               int32_t event_id, void *event_data)
{
    esp_mqtt_event_handle_t ev = (esp_mqtt_event_handle_t)event_data;

    switch ((esp_mqtt_event_id_t)event_id) {
    case MQTT_EVENT_CONNECTED: {
        s_online = true;
        ESP_LOGI(TAG, "connected");

        /* 连上先报「我在线」 */
        esp_mqtt_client_publish(s_client,
            "home/" NODE_ID "/status", "online", 0, 1, /*retain=*/1);

        /* 订阅自己的命令主题 */
        esp_mqtt_client_subscribe(s_client, "home/" NODE_ID "/cmd/+", 1);

        /* 把断网期间攒的数据补发出去 */
        extern void cache_flush(void);
        cache_flush();
        break;
    }
    case MQTT_EVENT_DISCONNECTED:
        s_online = false;
        ESP_LOGW(TAG, "disconnected, will retry");
        break;

    case MQTT_EVENT_DATA:
        /* 主题可能很长,不能当 C 字符串直接用 —— 见 4.4 */
        handle_command(ev->topic, ev->topic_len, ev->data, ev->data_len);
        break;

    default: break;
    }
}

注意 retain=1 那个参数。 这叫「保留消息」:Broker 会把它存下来,任何新订阅者一上来就立刻收到最后一条。

用在 status 主题上是必须的——否则网关刚启动时,不知道哪些节点在线,要等下一次上报才知道。

4.2 遗嘱(LWT):让 Broker 帮你报丧

static void mqtt_start(void)
{
    esp_mqtt_client_config_t cfg = {
        .broker.address.uri = "mqtt://192.168.1.10:1883",
        .credentials.client_id = NODE_ID,

        /* keepalive:多久没心跳就判定掉线(秒) */
        .session.keepalive = 15,

        /* 遗嘱:异常断开时,Broker 代我发这条 */
        .session.last_will.topic  = "home/" NODE_ID "/status",
        .session.last_will.msg    = "offline",
        .session.last_will.msg_len = 7,
        .session.last_will.qos    = 1,
        .session.last_will.retain = 1,
    };

    s_client = esp_mqtt_client_init(&cfg);
    esp_mqtt_client_register_event(s_client, ESP_EVENT_ANY_ID, mqtt_event_handler, NULL);
    esp_mqtt_client_start(s_client);
}

遗嘱的关键细节(书上不会强调,但会踩):

  • Broker 只在「异常断开」时代发。如果节点主动调用 esp_mqtt_client_disconnect()(正常断开),遗嘱不会发出。
  • 这意味着:你正常关机时,界面永远显示「在线」。要么正常关机前主动发一条 offline,要么干脆别调用正常的 disconnect。

这一条我第一次做的时候就栽了:测试时用 Ctrl+C 停掉程序,界面一直绿灯。查了两小时才想明白——那不是 bug,是协议规定。

4.3 断网缓存:环形缓冲,满了丢最旧

断网期间数据不能全丢,但内存有限,也不能无限攒。

#define CACHE_MAX 60      /* 60 条 × 每 30 秒一条 = 30 分钟 */

typedef struct {
    uint32_t ts;          /* ⚠️ 必须是「采集时刻」,不是「发送时刻」 */
    float    temp;
    float    rh;
} sample_t;

static sample_t  s_cache[CACHE_MAX];
static int       s_head = 0;    /* 写指针 */
static int       s_tail = 0;    /* 读指针 */
static int       s_cnt  = 0;

/* 生产者:无论在线离线,采集后一律先入缓存 */
static void cache_push(const sample_t *s)
{
    s_cache[s_head] = *s;
    s_head = (s_head + 1) % CACHE_MAX;
    if (s_cnt < CACHE_MAX) {
        s_cnt++;
    } else {
        /* 满了:覆盖最旧,读指针跟进 —— 丢最旧的,保最新的 */
        s_tail = (s_tail + 1) % CACHE_MAX;
    }
}

/* 消费者:只在在线时调用 */
static bool cache_pop(sample_t *out)
{
    if (s_cnt == 0) return false;
    *out = s_cache[s_tail];
    s_tail = (s_tail + 1) % CACHE_MAX;
    s_cnt--;
    return true;
}

void cache_flush(void)
{
    sample_t s;
    int flushed = 0;
    while (cache_pop(&s)) {
        char payload[96];
        /* 关键:把原始时间戳一起带上 */
        snprintf(payload, sizeof(payload),
                 "{\"ts\":%lu,\"t\":%.1f,\"rh\":%.1f}", (unsigned long)s.ts, s.temp, s.rh);
        esp_mqtt_client_publish(s_client,
            "home/" NODE_ID "/sensor/bundle", payload, 0, 1, 0);
        flushed++;
        if (flushed >= 10) break;   /* 一次别补太多,给别的任务留机会 */
    }
    if (flushed) ESP_LOGI(TAG, "flushed %d cached samples", flushed);
}

这里有一个必须讲清的取舍:为什么「丢最旧」而不是「丢最新」。

因为传感器数据有两种语义:

  • 状态型(当前温度):新的才重要,旧的丢了无所谓 → 丢最旧
  • 事件型(窗户被打开了):每一条都不能丢 → 满了应该拒绝写入并告警,不能静默丢

你要先想清楚自己的数据是哪一种,再决定缓存策略。 写反了,等于把报警事件扔了。

4.4 一个真实的坑:主题不是 C 字符串

/* ❌ 错误:topic 不是以 \0 结尾的 */
if (strcmp(ev->topic, "home/node01/cmd/relay") == 0) { ... }

/* ✅ 正确:用 topic_len 限定 */
static bool topic_is(esp_mqtt_event_handle_t ev, const char *want)
{
    size_t n = strlen(want);
    return (ev->topic_len == (int)n) && (memcmp(ev->topic, want, n) == 0);
}

为什么错? MQTT 协议里主题长度是单独字段,esp_mqtt_event_t 给的是指针 + 长度,不保证后面有 \0。

strcmp 会一路读到别的地方去。症状极其诡异:有时候正常,有时候恰好匹配到相邻内存里的字符串。

这类 bug 的可怕之处:它 100% 正常的时候不代表对。你测十次都对,上线后偶尔错一次,查不出来。

4.5 断线重连:指数退避 + 随机抖动

断线重连全过程

ESP-IDF 的 MQTT 客户端自带重连,但默认策略是「固定间隔一直重试」。三个节点一起掉线时,会同时重连、同时失败、同时再重连——这叫惊群。

/* 自己控制重连节奏(关闭库自动重连) */
.cfg.network.disable_auto_reconnect = true,

static void reconnect_task(void *arg)
{
    int backoff_ms = 1000;
    const int cap_ms = 30000;

    for (;;) {
        if (!s_online) {
            esp_mqtt_client_reconnect(s_client);
            vTaskDelay(pdMS_TO_TICKS(backoff_ms));

            if (!s_online) {
                /* 指数退避 + ±20% 抖动(避免多节点同步重连) */
                int jitter = (backoff_ms / 5) * ((esp_random() % 3) - 1);
                backoff_ms = backoff_ms * 2 + jitter;
                if (backoff_ms > cap_ms) backoff_ms = cap_ms;
            } else {
                backoff_ms = 1000;   /* 连上了,重置 */
            }
        } else {
            vTaskDelay(pdMS_TO_TICKS(1000));
        }
    }
}

「随机抖动」不是可选项。 三个节点同时掉线、同时用 1s 重试,等于一直撞车。加 ±20% 抖动后,它们会自然错开。

这是分布式系统里最便宜的一个改进:一行随机数,解决一群节点同步打爆服务端的问题。


五、网关端:树莓派上的 C 程序

网关做四件事:订阅全部数据 → 存下来 → 提供给网页 → 下发命令。

用 mosquitto + paho-mqtt-c,全 C,符合 S1 的「C 重写」基调。

5.1 装环境

sudo apt install -y mosquitto mosquitto-clients libmosquitto-dev
sudo systemctl enable --now mosquitto

# 验证 Broker 起来了
mosquitto_sub -h localhost -t 'test' -v &
mosquitto_pub -h localhost -t 'test' -m 'hello'

先确认 Broker 通,再写代码。 顺序反了,出了问题是分不清「Broker 没起」还是「我代码错」。

5.2 订阅全部节点

#include <mosquitto.h>
#include <stdio.h>
#include <string.h>
#include <time.h>

#define BROKER_HOST "127.0.0.1"
#define BROKER_PORT 1883
#define GW_ID       "gw-collector"

/* 收到消息:把主题拆开,取出 <id> 和 <metric> */
static void on_message(struct mosquitto *m, void *obj,
                       const struct mosquitto_message *msg)
{
    if (!msg->payload || msg->payloadlen <= 0) return;
    if (!msg->topic) return;

    /* 主题形如 home/node01/sensor/temp
       用 sscanf 精确分段,别用 strtok(它会改原串) */
    char dev[32] = {0}, kind[16] = {0}, metric[32] = {0};
    if (sscanf(msg->topic, "home/%31[^/]/%15[^/]/%31[^/]", dev, kind, metric) != 3) {
        return;   /* 层级不对,不是我们的主题 */
    }

    time_t now = time(NULL);
    char *payload = strndup((const char *)msg->payload, msg->payloadlen);
    if (!payload) return;

    if (strcmp(kind, "sensor") == 0) {
        printf("[%ld] %s.%s = %s\n", (long)now, dev, metric, payload);
        /* 这里写数据库 / 内存表 / 环形缓冲,供网页读 */
    } else if (strcmp(kind, "status") == 0) {
        printf("[%ld] %s is %s\n", (long)now, dev, payload);
        /* 更新在线表:online / offline */
    }

    free(payload);
}

int main(void)
{
    mosquitto_lib_init();

    struct mosquitto *m = mosquitto_new(GW_ID, true, NULL);
    if (!m) { fprintf(stderr, "new failed\n"); return 1; }

    mosquitto_message_callback_set(m, on_message);

    /* keepalive 20s:比节点端 15s 大一点,避免网关先判死 */
    if (mosquitto_connect(m, BROKER_HOST, BROKER_PORT, 20) != MOSQ_ERR_SUCCESS) {
        fprintf(stderr, "connect failed\n");
        return 1;
    }

    /* 订阅全部节点数据 + 状态。注意最后一个参数是 QoS */
    mosquitto_subscribe(m, NULL, "home/+/sensor/#", 1);
    mosquitto_subscribe(m, NULL, "home/+/status",   1);

    /* loop_forever 内部处理重连,不用自己写 */
    mosquitto_loop_forever(m, -1, 1);

    mosquitto_destroy(m);
    mosquitto_lib_cleanup();
    return 0;
}

编译:

gcc -std=c11 -Wall -Wextra -Werror -O2 \
    -o gw-collector gw-collector.c -lmosquitto

注意 -Werror。 S1 定的编译基线就是这个。警告不是建议,是必须处理的东西——尤其 -Wall 抓的那些未初始化变量,在嵌入式里全是随机崩溃。

5.3 下发命令:从网页到继电器

/* 网页点按钮 → 网关发一条命令 */
int send_relay_cmd(const char *node_id, int on)
{
    char topic[96], payload[8];
    snprintf(topic, sizeof(topic), "home/%s/cmd/relay", node_id);
    snprintf(payload, sizeof(payload), "%d", on ? 1 : 0);

    /* QoS 1:命令不能丢。retain 0:命令是「事件」,不是「状态」 */
    int rc = mosquitto_publish(s_client, NULL, topic,
                               (int)strlen(payload), payload, 1, 0);
    return rc == MOSQ_ERR_SUCCESS ? 0 : -1;
}

两个参数的选择,值得单独说:

参数选什么为什么
QoS1命令丢了,用户点了没反应,体验是坏的
retain0命令是「现在做一件事」,不是「当前状态」

如果 retain 设成 1 会怎样? 新节点一上线,Broker 立刻把上一条命令推给它——比如三个月前那条「开灯」。这是真实会发生的事故。

判据:状态用 retain=1(当前值),动作用 retain=0(一次性事件)。 这条区分搞错,会做出「每次重启设备都自动执行上次命令」的诡异行为。

5.4 一个必须做的自检:确认订阅真的生效

# 网关跑起来后,另开一个终端,模拟一个节点发数据
mosquitto_pub -h localhost -t 'home/node99/sensor/temp' -m '25.3'

# 看网关输出里有没有 [时间] node99.temp = 25.3

没有? 按这个顺序查:

  1. 网关的订阅主题写错(home/+/sensor/# 少了 home/ 前缀)
  2. 网关根本没连上(看 mosquitto_connect 返回值)
  3. Broker 没起(systemctl status mosquitto)

六、时间同步:没有 RTC 的节点怎么知道现在几点

这一节是 S3 里最容易被跳过、但跳过后一定出问题的地方。

问题:ESP32 断电重来,时间是 1970 年。它发的数据都带 1970 的时间戳。网关收到后:

  • 图表要么全挤在 1970 年
  • 要么被当成「最新数据」覆盖掉真数据

三种解法,从简单到正规:

方案 A:节点不报时间,网关盖时间戳(最省事)

/* 节点只发值,不发时间 */
esp_mqtt_client_publish(s_client, "home/node01/sensor/temp", "25.3", 0, 1, 0);

/* 网关收到时用本地时间 */
time_t ts = time(NULL);

优点:节点完全不需要时钟。 缺点:断网补发的数据会全部盖上「现在」的时间——曲线出现假尖峰,历史被扭曲。

方案 B:节点启动时从网关取一次时间(推荐)

/* 节点订阅一个对时主题,网关定期广播 */
esp_mqtt_client_subscribe(s_client, "home/broadcast/time", 0);

/* 收到后设置系统时间 */
case MQTT_EVENT_DATA:
    if (topic_is(ev, "home/broadcast/time")) {
        time_t t = (time_t)strtoll(payload, NULL, 10);
        struct timeval tv = { .tv_sec = t };
        settimeofday(&tv, NULL);
        ESP_LOGI(TAG, "time synced: %ld", (long)t);
    }

网关每分钟广播一次:

char buf[24];
snprintf(buf, sizeof(buf), "%ld", (long)time(NULL));
mosquitto_publish(m, NULL, "home/broadcast/time", strlen(buf), buf, 0, 0);

优点:节点有了可信时间,断网补发的时间戳是准的。 缺点:节点开机后到收到对时之前,仍然没有时间——所以开机后先不采集,等对时成功再开始。

方案 C:NTP

最正规,但要节点有网络并且实现 SNTP 客户端。ESP-IDF 有现成的 esp_sntp。如果节点能上网,用这个最省心。

共同的坑:对时之前采集的数据怎么办? 答案是不要采集。开机 → 连网 → 对时 → 才开始采集。顺序反了,你会得到一批 1970 年的脏数据,而且没法事后修正。


七、数据格式:JSON 还是二进制

JSON二进制(protobuf / 自定义)
可读性✅ 直接看得懂❌ 要工具
体积❌ 约 2–4 倍✅ 小
解析开销❌ 要 parser(嵌入式上不小)✅ 直接按偏移取
调试✅ mosquitto_sub 直接看❌ 得写解码
兼容性✅ 加字段不影响老代码❌ 要严格对版本

本项目的选择:用 JSON,但只用一种极简形式。

{"ts":1730611200,"t":25.3,"rh":58.1}

为什么不用完整 JSON 库? 在 ESP32 上,cJSON 解析一个 30 字节的报文要几百微秒到几毫秒,还占堆。如果字段固定,用 snprintf 拼、用 sscanf 拆就够了。

/* 拼:定长格式,注意 %lu 和 %.1f */
snprintf(buf, sizeof(buf), "{\"ts\":%lu,\"t\":%.1f,\"rh\":%.1f}",
         (unsigned long)ts, temp, rh);

/* 拆:字段顺序固定,直接按格式读 */
unsigned long ts; float t, rh;
if (sscanf(payload, "{\"ts\":%lu,\"t\":%f,\"rh\":%f}", &ts, &t, &rh) == 3) { ... }

什么时候必须换真 JSON 库? 当字段会增删、或者要处理嵌套结构时。固定三五个字段的时候,手写更快更省。


八、排查清单(比正文更有用)

这一关的问题几乎全在「网络」和「时序」上,而这两样看不见摸不着。所以工具比原理重要。

症状最可能原因怎么确认
网关收不到任何数据Broker 没起systemctl status mosquitto
网关收不到订阅主题层级写错mosquitto_sub -t 'home/#' -v 手动订阅看有没有
只有部分节点能上主题里的 id 不一致逐条 mosquitto_sub -t 'home/+/status' -v
节点显示永远在线正常 disconnect 不触发遗嘱拔电源测(不是 Ctrl+C)
曲线出现假尖峰补发数据用了「现在」的时间检查 payload 里的 ts
网关重启后全线卡住keepalive 太长,或节点没重连缩短 keepalive,看重连日志
三个节点同时掉线后连不上没加随机抖动,撞车看重连时间是否完全同步
命令有时执行有时不执行QoS 0 丢了改 QoS 1
命令重复执行QoS 1 重复投递,没做幂等命令里带序号,收到重复就丢
重启设备自动开灯命令主题设了 retain=1改 retain=0
内存越跑越少strndup 后没 freevalgrind --leak-check=full
偶尔收到乱码主题当 C 字符串用 strcmp改用 memcmp + 长度

这张表的用法:出问题先查表,别改代码。瞎改代码是嵌入式里最浪费时间的做法——改完问题可能消失了,但你不知道是不是自己改对了。


九、6 周怎么排

周目标手上要出的东西
1装 Broker,命令行跑通 pub/sub两个终端互相收发消息
2节点联网 + 上报(不带断线处理)网页能看到一个节点的数据
3加 status + LWT + retain界面能区分在线/离线
4断线重连 + 指数退避 + 缓存补发拔网线 5 分钟,数据不丢
5下发命令 + 回执网页点按钮,继电器响
63 个节点 + 时间同步 + 全链路压测拔掉任一节点系统不崩

十、过门标准

这一关有三条硬指标,缺一条都不算过:

  • 拔掉任意一个节点,系统不崩,其余两个正常 (不是「看起来正常」,是「数据还在更新、命令还能下发」)
  • 网关重启,节点 30 秒内自动重连,且节点侧不需要任何操作
  • 断网 5 分钟,数据不丢:恢复后曲线补上那 5 分钟,且时间戳是原始时刻,不是恢复时刻

软指标(能答出来就行):

  • 为什么 IoT 不用 HTTP?—— 不是「报文小」,是「服务器要能主动推」
  • QoS 1 是「恰好一次」还是「至少一次」?—— 至少一次,要做幂等
  • 为什么命令主题不能设 retain=1?—— 设备重启会重放三个月前的命令
  • 为什么重连要加随机抖动?—— 否则多节点同时重连会互相撞车
  • 为什么节点启动后要先对时再采集?—— 否则会攒一批 1970 年的脏数据

十一、这一关结束时你应该能回答

  1. 为什么中间要有个 Broker?直连有什么问题?
  2. 遗嘱(LWT)什么时候会触发,什么时候不会?
  3. keepalive 设大设小,各有什么代价?
  4. 断网缓存为什么要区分「状态型」和「事件型」数据?
  5. 主题为什么不能当 C 字符串用?

答不出第 2 和第 5 题,说明还没真正跑起来过。 这两条都是踩过一次才记得住的。


下一站

S3 收口后,你手上已经是一个能用的分布式雏形:脑(网关)+ 手脚(节点)+ 中间人(Broker)。

S4 把它变成能装在墙上、跑三十天不用管的东西:交叉编译、看门狗、OTA 升级、断电恢复、日志。


参考

MQTT 协议细节(QoS 语义、遗嘱、keepalive、retain)依据 OASIS MQTT 3.1.1 / 5.0 规范;ESP-IDF 与 mosquitto 的 API 用法依据各自官方文档。代码、接线图、拓扑设计、排查清单、排期与过门标准均为原创整理,可直接用于教学。