上一站:嵌入式转型的五个阶段(总览) 上一关:S2:下到 MCU 这一篇:只做 S3 —— 网关 + 3 个节点的小系统。 写给谁:S2 已经收口,能独立让 MCU 联网、能写出网关程序的人。 耗时:4–6 周,每天 1.5–2 小时。
先说结论:这一关考的不是「能通信」
能通信很简单。socket 发一串字节,对面收到,这叫能通信。
这一关真正考的是:断了之后怎么办。
| 你以为要做的 | 实际要做的 |
|---|---|
| 让节点把数据发到网关 | 让节点在网断了以后还能把数据补上去 |
| 让网关显示三个节点的数据 | 让一个节点掉线时界面变灰,其他两个照常 |
| 网关重启后重新连上 | 网关重启时节点完全无感知,自己就接回去了 |
| 数据格式定好 | 断网期间的数据时间戳不能乱 |
这一关过不了,S4 做不出来。 因为长期运行的系统,99% 的问题都发生在「异常路径」上,而异常路径在第一版里通常一条都没写。
一、为什么中间要有一个 Broker
先说一个反直觉的决定:节点不直接连网关。
错误直觉:node → 网关
正确做法:node → Broker ← 网关
看起来是绕远了。但这一层中间人换来了三件事:
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;
}
两个参数的选择,值得单独说:
| 参数 | 选什么 | 为什么 |
|---|---|---|
| QoS | 1 | 命令丢了,用户点了没反应,体验是坏的 |
| retain | 0 | 命令是「现在做一件事」,不是「当前状态」 |
如果 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
没有? 按这个顺序查:
- 网关的订阅主题写错(
home/+/sensor/#少了home/前缀) - 网关根本没连上(看
mosquitto_connect返回值) - 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 后没 free | valgrind --leak-check=full |
| 偶尔收到乱码 | 主题当 C 字符串用 strcmp | 改用 memcmp + 长度 |
这张表的用法:出问题先查表,别改代码。瞎改代码是嵌入式里最浪费时间的做法——改完问题可能消失了,但你不知道是不是自己改对了。
九、6 周怎么排
| 周 | 目标 | 手上要出的东西 |
|---|---|---|
| 1 | 装 Broker,命令行跑通 pub/sub | 两个终端互相收发消息 |
| 2 | 节点联网 + 上报(不带断线处理) | 网页能看到一个节点的数据 |
| 3 | 加 status + LWT + retain | 界面能区分在线/离线 |
| 4 | 断线重连 + 指数退避 + 缓存补发 | 拔网线 5 分钟,数据不丢 |
| 5 | 下发命令 + 回执 | 网页点按钮,继电器响 |
| 6 | 3 个节点 + 时间同步 + 全链路压测 | 拔掉任一节点系统不崩 |
十、过门标准
这一关有三条硬指标,缺一条都不算过:
- 拔掉任意一个节点,系统不崩,其余两个正常 (不是「看起来正常」,是「数据还在更新、命令还能下发」)
- 网关重启,节点 30 秒内自动重连,且节点侧不需要任何操作
- 断网 5 分钟,数据不丢:恢复后曲线补上那 5 分钟,且时间戳是原始时刻,不是恢复时刻
软指标(能答出来就行):
- 为什么 IoT 不用 HTTP?—— 不是「报文小」,是「服务器要能主动推」
- QoS 1 是「恰好一次」还是「至少一次」?—— 至少一次,要做幂等
- 为什么命令主题不能设 retain=1?—— 设备重启会重放三个月前的命令
- 为什么重连要加随机抖动?—— 否则多节点同时重连会互相撞车
- 为什么节点启动后要先对时再采集?—— 否则会攒一批 1970 年的脏数据
十一、这一关结束时你应该能回答
- 为什么中间要有个 Broker?直连有什么问题?
- 遗嘱(LWT)什么时候会触发,什么时候不会?
- keepalive 设大设小,各有什么代价?
- 断网缓存为什么要区分「状态型」和「事件型」数据?
- 主题为什么不能当 C 字符串用?
答不出第 2 和第 5 题,说明还没真正跑起来过。 这两条都是踩过一次才记得住的。
下一站
S3 收口后,你手上已经是一个能用的分布式雏形:脑(网关)+ 手脚(节点)+ 中间人(Broker)。
S4 把它变成能装在墙上、跑三十天不用管的东西:交叉编译、看门狗、OTA 升级、断电恢复、日志。
参考
MQTT 协议细节(QoS 语义、遗嘱、keepalive、retain)依据 OASIS MQTT 3.1.1 / 5.0 规范;ESP-IDF 与 mosquitto 的 API 用法依据各自官方文档。代码、接线图、拓扑设计、排查清单、排期与过门标准均为原创整理,可直接用于教学。