
1. ESP32 项目里为什么值得单独聊 MQTT 客户端做 ESP-IDF 开发的朋友应该都有体会一旦设备需要联网绝大多数项目最终都会落到 MQTT 这条路上。不管是智能家居面板、传感器数据采集还是工业网关、农业大棚监控MQTT 几乎成了嵌入式设备与服务器之间通信的默认选项。我自己接触 ESP-IDF 是从 ESP32 开始的早期也用 ESP8266 配合 AT 指令做过几个 demo后来转到 ESP-IDF 原生开发后发现最大的区别在于ESP-IDF 的 MQTT 客户端不再是调一个接口等结果的玩具而是一套完整的事件驱动机制。你以为写个esp_mqtt_client_publish就能发消息实际上你要先理解 client 的连接状态机、事件回调、QoS 语义甚至 session 恢复机制。这些理解不到位做出来的客户端一断网就死或者一重启就重复收数据。这篇学习笔记不是官方文档的翻译而是我从能跑通例程到真正敢在生产项目里用这个过程中整理出的关键认知和实操经验。适合刚入门 ESP-IDF 的开发者也适合那些已经跑通 demo 但想知道为什么我的客户端这么脆弱的朋友。另外多说一句本文使用的环境是Ubuntu 24.04 VSCode ESP-IDF 插件这是目前从零开始最省事的一套组合。后面的步骤和代码都是在这个环境下实测过的。2. 开发环境与工程骨架Ubuntu 24.04 下装哪个版本最稳2.1 版本选择和安装坑点关于Ubuntu 24.04 安装 ESP-IDF 的哪个版本我直接说结论截至本文写作时间release/v5.2 分支是最稳妥的选择。v5.3、v5.4 虽然新功能多但 24.04 的 CMake 和 Python 版本较新部分老组件编译会出现兼容性问题v5.1 及以下分支对较新的工具链支持又不太好尤其在新版 CMake 处理上容易报错。安装时推荐走 VSCode 的 ESP-IDF 插件方式因为插件会自动帮你管理工具链、Python 虚拟环境和 PATH。手动装的话经常会遇到idf.py: command not found或 Python 包冲突的问题调试环境的时间比写代码还多。一个非常容易踩的坑Ubuntu 24.04 默认的 Python 是 3.12如果你用系统自带的 Python 跑install.sh部分依赖包可能编译不过。插件方式会自动创建虚拟环境绕开这个问题。如果你坚持命令行安装建议先python3 -m venv创建独立环境再执行安装脚本。2.2 创建带 MQTT 组件的工程环境就绪后用 VSCode 的命令面板执行ESP-IDF: Create Project from Template选择hello_world模板即可。MQTT 客户端功能是通过组件Component的方式集成的你不需要单独去下载什么包ESP-IDF 自带的esp-mqtt组件就在框架内部直接引用头文件就能用。关键点在于idf.py menuconfig里的配置。需要确认以下两项Component config→ESP-MQTT Configurations→ 确认Enable MQTT已勾选Component config→LWIP→ 确认Enable IPV4和 TCP 相关支持正常默认就是开的如果你用到域名而不是 IP 直连还需要确认Component config→LWIP→Enable DNS是启用的默认开启但有些精简工程会把它关掉。工程骨架层面main目录下会有CMakeLists.txt记得在SRC_DIRS里包含你的源码目录并在REQUIRES中添加mqtt和esp_event。如果你创建的是模板工程默认的REQUIRES里没有mqtt编译时会出现esp_mqtt_client.h: No such file or directory错误这个非常典型。# main/CMakeLists.txt 关键部分 idf_component_register( SRCS main.c INCLUDE_DIRS . REQUIRES mqtt esp_event nvs_flash )3. 动手前必须搞清楚的 MQTT 协议要点QoS、遗嘱、Keep Alive很多做嵌入式开发的朋友第一次接触 MQTT 时下意识会拿 HTTP 的经验去套我连接服务器发一段数据服务器返回结果完事。但 MQTT 是完全不同的一套逻辑它是发布/订阅模型不是请求/响应模型。这也是热词里出现mqtt broker可以接收到发布的主题的内容吗不需要订阅这种疑问的来源——很多人没搞明白 broker 的角色。我建议在写代码之前先把这几个协议层面的概念吃透否则后面的回调逻辑会写得很混乱。3.1 Broker 只转发给订阅者不缓存消息给非订阅者Broker服务器的核心工作是接收发布者的消息然后只转发给当前订阅了对应主题的客户端。如果你只是 publish 了一条消息但没有客户端订阅这个主题这条消息就直接丢掉了。热词里不需要订阅这个疑问其实混淆了两个角色发布者不需要订阅就能发布但如果想收到消息必须订阅对应主题。这个特性直接影响你的客户端逻辑如果设备刚启动还没完成订阅服务器就推送了一条消息这条消息你是收不到的。要解决这个问题要么提前让设备连上服务器保持较长时间并等待下发要么通过遗嘱Will和保留消息Retained Message机制做补偿。关于保留消息后面我会在代码部分详细演示。3.2 QoS 0、1、2 的选择逻辑QoSQuality of Service决定了消息投递的可靠性。我平时在绝大多数 ESP32 项目里只用 QoS 0 和 QoS 1QoS 2 用得极少。QoS 0消息发出后不关心对方是否收到传输最快。适合周期性上传传感器数据丢一帧也无所谓。QoS 1保证对方至少收到一次可能重复。适合控制类指令比如开灯、关阀门。QoS 2保证恰好收到一次原理复杂协议开销大嵌入式场景很少用到。一个很容易出问题的细节QoS 1 的重复投递是协议层允许的。你的接收端逻辑必须做成幂等的也就是说同一条指令收到两次执行结果应该一致。我在一个智能浇灌项目里就吃过这个亏设备收到两次开启电磁阀的指令结果线圈烧了一次后续在代码里加了指令去重逻辑才解决。3.3 Keep Alive 和遗嘱决定断线后服务器怎么处理你MQTT 客户端连上服务器后必须在 Keep Alive 时间内发一次数据哪怕是 PINGREQ 心跳包否则服务器会认为客户端已经掉线然后做两件事把客户端的遗嘱消息LWTLast Will and Testament广播出去然后清理这个会话。遗嘱消息是客户端在连接服务器时主动声明的如果我异常掉线请帮我向某个主题发布一条默认消息。这在设备状态监控里非常实用设备正常关机时可以主动发offline异常断电时则由 broker 代发offline业务端据此判断设备状态。代码里配置遗嘱的 API 是esp_mqtt_client_config_t中的will字段后面会给示例。Keep Alive 的时长不宜设得太短否则网络稍微抖动就会触发服务器清理会话也不宜太长否则设备掉线后服务器要很久才能感知。我个人习惯在局域网环境设30 秒公网环境设60 秒。4. Broker 选型和本机搭建EMQX 单机版从拉到配通代码写之前你总得有一个能连的服务器。热词里频繁出现 EMQX、MQTT 服务器搭建、Docker说明这是大家绕不开的一步。我不建议拿公共测试服务器做开发因为消息可能会被别人看到也可能被莫名其妙的流量干扰。自己本机跑一个 broker 是最好的选择。4.1 为什么选 EMQX目前主流的 broker 有 EMQX、Mosquitto、VerneMQ、HiveMQ 等。对个人开发和中小型项目我推荐EMQX原因有三支持 Dashboard 可视化界面能看到在线客户端、订阅关系、消息速率调试效率极高同时支持 MQTT 3.1、3.1.1 和 5.0方便以后探索新特性配置简单默认配置就能跑通不必像 Mosquitto 那样去读一堆配置项如果你只是做嵌入式学习Mosquitto 也够用但它的鉴权、监控配置比较繁琐没有 Dashboard出了问题排查不如 EMQX 直观。4.2 Docker 方式 5 分钟跑起来在装有 Docker 的机器上用一行命令就能启动单机版 EMQXdocker run -d --name emqx \ -p 1883:1883 \ -p 18083:18083 \ emqx/emqx:5.8.0命令说明1883是 MQTT 协议默认的 TCP 端口设备和服务器通信走这个18083是 Dashboard 的 Web 端口浏览器打开http://localhost:18083就能看到管理界面默认用户名admin密码public首次登录会强制改密码如果你想用 WebSocket比如配合浏览器或小程序再加一个-p 8083:8083EMQX 默认开启 WebSocket 监听端口 8083跑起来后用浏览器打开 Dashboard能看到一个 Connections 页面之后 ESP32 连接上来时会实时显示连接数这对排查到底连没连上非常有帮助。4.3 创建专用用户名密码而不是用匿名EMQX 默认配置下客户端可以用任意用户名密码或者不带用户名密码连接这是出于开箱即用的考虑但不安全。开发阶段问题不大如果设备要长期运行建议创建专用账户。具体操作路径Dashboard → Access Control → Authentication → 点击 Create → 选择 Username/Password。创建好用户后在 ESP32 客户端配置里加上用户名密码即可。另外切记Dashboard 里的修改很多是热生效的但认证模块的创建后可能需要Enable一下否则客户端还是能匿名连接。这个细节我踩过一次原因是新创建的认证器默认没有启用默默延后了二十分钟上线时间。5. ESP-IDF MQTT 客户端核心代码实操从连接到收发消息环境有了服务器有了现在进入正题。ESP-IDF 的 MQTT 客户端使用起来分三步创建一个配置好的 client 句柄、注册事件回调、调用 start 让客户端跑起来。逻辑不算复杂但里面有不少需要理解的设计。5.1 初始化流程一个最简却完整的例子下面这段代码是精简后的可运行版本去掉了错误处理和业务逻辑只保留核心骨架#include stdio.h #include esp_wifi.h #include esp_event.h #include nvs_flash.h #include mqtt_client.h static const char *TAG mqtt_basic; static void mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) { esp_mqtt_event_handle_t event event_data; esp_mqtt_client_handle_t client event-client; switch ((esp_mqtt_event_id_t)event_id) { case MQTT_EVENT_CONNECTED: ESP_LOGI(TAG, Connected to broker); esp_mqtt_subscribe(client, device/status, 1); esp_mqtt_client_publish(client, device/online, true, 0, 1, 0); break; case MQTT_EVENT_DATA: ESP_LOGI(TAG, Topic: %.*s, Data: %.*s, event-topic_len, event-topic, event-data_len, event-data); break; case MQTT_EVENT_DISCONNECTED: ESP_LOGW(TAG, Disconnected from broker); break; default: break; } } void mqtt_app_start(void) { esp_mqtt_client_config_t mqtt_cfg { .broker.address.uri mqtt://192.168.1.100:1883, .credentials.username dev01, .credentials.authentication.password secret, .session.keepalive 30, .network.reconnect_timeout_ms 5000, }; esp_mqtt_client_handle_t client esp_mqtt_client_init(mqtt_cfg); esp_mqtt_client_register_event(client, ESP_EVENT_ANY_ID, mqtt_event_handler, NULL); esp_mqtt_client_start(client); }调用mqtt_app_start()前记得先完成 WiFi 连接和 NVS 初始化否则网络层不通客户端会一直在连接中状态打转。void app_main(void) { nvs_flash_init(); // 必须WiFi 驱动需要 NVS 保存配置 wifi_init_sta(); // 你自己实现的 WiFi 连接逻辑 mqtt_app_start(); // 启动 MQTT 客户端 }5.2 事件回调MQTT 客户端的心脏这个客户端是事件驱动的所有关键动作都发生在mqtt_event_handler里。有人会觉得这种方式麻烦我直接调用发布函数不就行了吗但事件机制恰恰是 ESP-IDF MQTT 组件最值得称道的设计。因为 MQTT 连接是长期维护的状态连接可能随时被服务器断开、网络抖动、重新握手。如果你用调用一次 publish 就不管了的思路消息发出去的那一刻可能连接刚好断开消息就丢了。事件回调把连接生命周期和消息收发都暴露给你你才能在MQTT_EVENT_CONNECTED后重新订阅主题、在MQTT_EVENT_DISCONNECTED后做重连计数、在MQTT_EVENT_DATA里响应业务消息。注意一个细节事件回调里执行的操作应该尽量轻量。不要在这里做长时间的日志打印、写 Flash、执行阻塞式的业务逻辑否则会阻塞 MQTT 组件的事件循环导致收发卡顿。如果业务逻辑复杂用xQueueSend把事件数据交给其他任务去处理。5.3 保留消息解决开机订阅前消息丢失的问题前面提到过设备没订阅时 broker 是不会把消息推给它的。如果你的业务要求设备一上线就要知道某个状态比如当前网关是否允许上传数据有两个办法发布者发布消息时设置retain标志为 1这样 broker 会为这个主题保存最后一条消息设备上线订阅时broker 会立即把保留消息推给新订阅者具体到发布函数esp_mqtt_client_publish(client, device/online, true, 0, 1, 1);最后一个参数retain设为 1。设备上线时不管有没有人在线broker 都会把最近一次device/online的值推过来。这是一个非常实用的机制我做设备状态面板时就靠这个避免面板启动晚了几分钟就看不到状态的尴尬。但注意不要滥用保留消息是针对主题的如果每条消息都设置 retainbroker 会为所有主题累积最后一条消息占用内存和存储而且客户端每次订阅都会收一批旧消息造成混淆。5.4 遗嘱消息配置示例配置遗嘱消息要在esp_mqtt_client_config_t里加一段esp_mqtt_client_config_t mqtt_cfg { .broker.address.uri mqtt://192.168.1.100:1883, .credentials.username dev01, .credentials.authentication.password secret, .session.keepalive 30, .session.last_will.topic device/status, .session.last_will.msg offline, .session.last_will.msg_len 7, .session.last_will.qos 1, .session.last_will.retain true, };配置后当设备正常阶段未发送 DISCONNECT 而连接意外断开时broker 会立即向device/status主题发布一条 offline 消息。正常关机时你自己 publish 一条 offline 再断开连接。这样业务端只要订阅device/status就能感知每台设备的心跳状态。一个容易忽略的参数msg_len必须和msg实际长度一致否则 broker 收到的是截断或多余字节的遗嘱消息。我最初写的时候以为msg_len可填 0 表示自动计算结果 broker 收到的是空白消息查了很久文档才意识到必须显式给长度。6. 真正跑通之后的坑连接、配置和回调里最容易翻车的地方这部分不是教你怎么连上而是记录我在实际项目中踩过、也在社区里反复看到别人踩的坑。有些问题不遇到可能很难察觉但一遇到就是数小时的排查。6.1 连接一直失败先分清是 WiFi 层还是 MQTT 层一个常见现象程序烧进去日志里没有任何 MQTT 事件看起来像死了一样。这个时候不要一开始就去查 MQTT 配置先用日志确认 WiFi 是否已经获得了 IPmonitor 输出里搜索 got ip 或者 sta ipESP-IDF 的日志默认是分级打印的如果看不到 WiFi 相关日志可能是等级设置成了 ERROR 或者你的 WiFi 初始化代码还没执行到。我一度以为 MQTT 有问题最后发现是 WiFi 事件处理里少注册了一个IP_EVENT_STA_GOT_IP的监听导致 WiFi 根本没连上。连接上了还是失败再看这几个方向broker 的 IP/域名对不对端口是不是 1883服务器防火墙有没有放行 1883 端口用户名密码是否正确Dashboard 的认证是否已经启用设备与服务器是否在同一网络是否有跨网段隔离其中跨网段隔离在学校和企业网络里最常见两个设备都在同一 WiFi 下但开启了 AP 隔离设备之间互相 ping 不通这种情况检查一下手机能不能 ping 通服务器 IP 就能判断。6.2 MQTT_EVENT_DATA 触发次数比你预期的多QoS 1 的重复投递是最典型的看起来正常但实际有坑的例子。我用模拟器连 EMQX发布一条指令给设备结果设备的事件回调进两次第一次以为是 bug后来查了协议才知道 QoS 1 在确认报文丢失时确实会重发。处理办法有两个思路消息体内携带自增序列号接收端记录最近处理过的序列号重复则丢弃业务操作设计成幂等重复执行无害我建议嵌入式设备尽量用第二种思路因为维护序列号状态本身也有开销。比如控制继电器时消息内容直接带上目标状态开或者关而不是切换这种相对操作那么重复收到两次开就无害了。6.3 重连时为什么订阅会丢MQTT 会话分两种Clean Session 0持久会话和Clean Session 1清理会话。ESP-IDF 里对应esp_mqtt_client_config_t中的session.disable_clean_session字段为true时使用持久会话。如果设备使用清理会话每次重连成功后broker 都会清空之前的订阅关系。你在MQTT_EVENT_CONNECTED里调用的esp_mqtt_subscribe是在每次连接成功后才生效的所以只要在事件回调里重新订阅就没问题。如果设备使用持久会话断线期间服务器收到的消息会缓存起来重连后按 QoS 等级补推。这个机制对低功耗设备特别友好设备休眠前断开连接服务器缓存消息设备唤醒重连后一次收齐。但使用这个功能要注意持久会话会占用服务器资源如果设备频繁上下线broker 的内存会被不断累积的 session 状态拖垮。EMQX 默认限制了客户端会话数量但这个限制通常不会让你直接感知到。在 ESP-IDF 里清理会话的配置是这样的esp_mqtt_client_config_t mqtt_cfg { .broker.address.uri mqtt://192.168.1.100:1883, .session.disable_clean_session true, // 持久会话 };6.4 内存不足老型号芯片做 MQTT 的隐形边界ESP32 经典款有 520KB SRAM跑 WiFi MQTT TLS 后剩余内存不多。当你同时订阅多个主题、buffer 开得很大时可能出现out of memory或者无限重启。MQTT 相关内存大头是esp_mqtt_client_config_t里的network.buffer_size默认是 1024 字节用于收发 MQTT 报文。如果报文比较大比如 OTA 固件分块发布的场景你需要调大这个值但它会直接占用堆内存。我的建议普通数据收发保持 1024 或稍微 2048 即可需要传输较大消息时优先考虑把数据拆包发布而不是一味调大 buffer实时查看内存用ESP_LOGI(TAG, free heap: %d, esp_get_free_heap_size());在事件回调里打一个观察波动7. 进阶用法多主题订阅、TLS 加密与低功耗场景适配如果基础收发已经玩明白了下面这几个方向可以直接升级你的项目健壮性。7.1 多主题订阅用数组批量配置需要订阅多个主题时最简单的方式是在MQTT_EVENT_CONNECTED后逐个调用esp_mqtt_subscribe。但如果订阅关系长期固定、且频繁重连你可以用一套订阅主题清单维护重连时统一提交static const char *topics[] { device/control, device/config/update, gateway/ota/cmd, }; static void subscribe_all(esp_mqtt_client_handle_t client) { for (int i 0; i sizeof(topics) / sizeof(topics[0]); i) { esp_mqtt_subscribe(client, topics[i], 1); } }注意esp_mqtt_subscribe是异步的broker 的 SUBACK 不会立刻返回不能假设调用后马上就能收到这个主题的消息。如果你在 publish 和 subscribe 之间间隔很短存在消息竞争的可能。实际项目中我会在连接成功后的回调里先订阅、再发布顺序上保证订阅先发出减少这个窗口。7.2 TLS 加密从mqtt://切换为mqtts://直接以明文方式传输 MQTT 消息等于把所有设备数据暴露在局域网里。如果设备需要上公网我强烈建议开启 TLS。ESP-IDF 中启用 TLS 非常简单只需要menuconfig里开启Component config→ESP-MQTT Configurations→Enable MQTT over TLS把uri从mqtt://192.168.1.100:1883改成mqtts://broker.example.com:8883在esp_mqtt_client_config_t中配置证书验证esp_mqtt_client_config_t mqtt_cfg { .broker.address.uri mqtts://broker.example.com:8883, .broker.verification.certificate server_cert_pem_start, // 服务器证书 .session.keepalive 60, };证书处理是 TLS 开发里最容易出问题的环节。开发阶段可以先关闭证书验证skip_cert_common_name_check和use_global_ca_store那些配置项可以组合但生产环境一定要把服务器证书加入设备端否则连接会失败或者被中间人截获。TLS 握手对内存和 CPU 的消耗都不小实测下来经典 ESP32 在启用 TLS 后空闲内存会下降 30-40KB握手瞬间 CPU 占用明显。如果项目对成本和功耗敏感判断清楚业务场景再决定是否启用。7.3 低功耗场景的保持连接策略如果你的设备是电池供电长期保持 MQTT 连接是奢侈的。WiFi 本身在 IoT 设备耗电中占比极高长连接意味着 WiFi 模块始终在工作状态。我的做法是按需连接模式设备平时处于 deep sleep不维持 MQTT 连接定期唤醒连接 WiFi快速建立 MQTT 连接发布数据然后主动断开需要接收下行指令时配合 broker 的持久会话把指令缓存到服务器唤醒后一次性收取这个模式下keepalive不用太大因为连接只维持几秒clean_session设为 0 让 broker 缓存离线消息连接成功后先订阅再发布最后主动断开并进入 deep sleep。实测一个 18650 电池供电的温湿度采集器采用这个策略每 10 分钟上报一次待机电流降到 uA 级续航可以达到数个月。代价是下行指令不能实时到达最坏情况要等设备下一次唤醒业务上要做好取舍。8. 最后一个实操建议把调试手段提前建好做 MQTT 开发最焦虑的时刻不是代码报错而是设备显示已连接但消息就是收不到。这种模糊问题靠肉眼排查太折磨人我的经验是提前把调试工具链建好EMQX Dashboard 的 Topics 页面可以直接发布消息到指定主题设备不需要额外写测试代码就能验证订阅是否生效用 MQTT Explorer 或者 MQTTX 这类桌面客户端观察 broker 里有没有消息进来ESP32 端在MQTT_EVENT_DATA回调里打印topic和data_len不要只打印一份可能被截断的 data 字符串否则消息里带不可见字符时你会被误导我最推荐先手工发布一条 QoS 1 且 retain 的测试消息然后用设备日志确认收到。如果这条链路通了再怀疑代码逻辑如果这条链路不通优先查 broker 配置和网络而不是抱着代码死磕。MQTT 客户端的坑本质上都来自长连接这个特性带来的状态管理和网络不确定性和 HTTP 那套发一次请求等一次响应的思维完全不同。想通这一点很多困扰自然就解开了。