简介这份STM32 MQTT通讯源程序面向嵌入式开发与物联网方向的工程师及学生以STM32F407为核心结合LWIP轻量级TCP/IP协议栈与DHT11温湿度传感器实现MQTT客户端与OneNet云平台的数据交互可用于远程监控与控制类项目开发。压缩包共1077个文件以334个h头文件、259个c源文件为主另含147个d依赖文件、147个o目标文件及145个crf编译记录并附带sct分散加载、uvprojx工程、map映射与hex固件等整体约73.1MB工程结构完整。目前已有1303人学习下载。源码覆盖MQTT发布订阅、LWIP网络连接建立、DHT11数据采集上传等关键环节读者可据此掌握嵌入式物联网通信的完整实现思路并直接参考移植到自有硬件平台。1. STM32 MQTT通讯源程序从裸机到网关的完整落地路径手里有一块 STM32想让它通过 MQTT 协议把传感器数据发到服务器或者接收远程指令控制继电器——这个需求在物联网网关、智能鱼缸、工业数据采集这些场景里反复出现。但真正动手时很多人卡在同一个地方MQTT 协议栈怎么移植用哪个库裸机跑还是上 FreeRTOS网络掉线了怎么恢复市面上的“STM32 MQTT通讯源程序”往往只给一个能编译的工程却不告诉你参数为什么这么设、换一个模组要改哪里、断线重连的逻辑藏在哪一层。这篇笔记就按一线调试的顺序把从选型、移植、联调到避坑的完整路径拆开目标只有一个你照着做能在自己的板子上跑通一条稳定的 MQTT 链路。适合已经会用 STM32 HAL 库、调过 UART 或以太网、但对 MQTT 还停留在“知道概念”阶段的嵌入式工程师。2. 先定方案STM32 跑 MQTT 的三种典型架构与选型依据2.1 裸机 串口 AT 模组最省事的起步方式常见做法是 STM32 通过 UART 接一个 Wi-Fi 或 4G 模组模组本身固件里已经集成了 MQTT AT 指令集。STM32 这边只需要按格式发 AT 命令解析返回的字符串。这种架构的好处是协议栈完全不用自己管模组厂商把 TCP、TLS、MQTT 都封装好了。代价是灵活性差模组换型号指令集就变而且多一层串口交互实时性受 AT 响应速度限制。我一般会在项目初期用这种方式快速验证业务逻辑比如先确认传感器数据能不能正确打包成 JSON、服务器能不能收到。代码层面主要就是串口收发加状态机// 发送 MQTT 连接命令的简化示例模组以常见 AT 指令风格为例 void MQTT_Connect(void) { // 设置 MQTT 客户端 ID、用户名、密码 HAL_UART_Transmit(huart2, (uint8_t*)ATMQTTCFG\clientID\,\stm32_01\,1\r\n, 38, 1000); HAL_Delay(500); // 连接指定服务器和端口 HAL_UART_Transmit(huart2, (uint8_t*)ATMQTTCONN0,\192.168.1.100\,1883,1\r\n, 40, 1000); HAL_Delay(1000); // 订阅主题 HAL_UART_Transmit(huart2, (uint8_t*)ATMQTTSUB0,\cmd/device01\,1\r\n, 33, 1000); }这段代码里clientID必须全局唯一否则服务器会踢掉前一个连接MQTTCONN的最后一个参数 1 表示启用 clean session调试阶段建议开启避免服务器保留旧会话状态导致订阅不生效。串口超时设 1000ms 是保守值实际要看模组响应速度太快会丢返回。2.2 裸机 以太网 轻量级 MQTT 库适合有线的工业场景如果板子上有 LAN8720 这类 PHY 芯片跑 LwIP 协议栈那就可以直接在 STM32 内部实现 MQTT。常用的是 Eclipse Paho 的嵌入式 C 客户端或者自己裁剪的 MQTT 报文打包函数。这种方案不依赖外部模组数据路径短延迟低适合工业现场固定布线。关键点在于 LwIP 的配置。lwipopts.h里MEM_SIZE至少给到 4096MEMP_NUM_TCP_PCB给 5 以上否则 MQTT 的 keepalive 心跳包和业务数据并发时会分配失败。MQTT 的 keepalive 一般设 60 秒服务器在 1.5 倍时间内没收到任何报文就判定掉线。所以即使没有数据要发也要在 60 秒内发一次 PINGREQ。// MQTT 心跳维护放在主循环或定时器里 void MQTT_KeepAlive(void) { static uint32_t last_send 0; if (HAL_GetTick() - last_send 45000) { // 45秒发一次留余量 uint8_t pingreq[2] {0xC0, 0x00}; // MQTT PINGREQ 固定报文 send_tcp_data(pingreq, 2); last_send HAL_GetTick(); } }这里 45 秒是我踩过坑之后定的值。曾经设 55 秒结果网络稍微抖动服务器在 60 秒时没收到直接断开。留 15 秒余量比较稳妥。2.3 FreeRTOS MQTT 任务复杂网关的必然选择当设备要同时处理传感器采集、本地控制、MQTT 收发、OTA 升级时裸机的前后台架构就撑不住了。典型做法是建三个任务传感器任务、MQTT 发送任务、MQTT 接收任务。任务间用消息队列传递数据MQTT 库的 socket 接收用阻塞模式单独占一个任务。FreeRTOS 下要注意 MQTT 库的可重入性。Paho 嵌入式客户端不是线程安全的所有对同一个MQTTClient结构的操作必须串行化。我一般用互斥量包住 publish 和 subscribe 调用接收回调里只做数据拷贝不执行耗时操作。// FreeRTOS 下用互斥量保护 MQTT 客户端 SemaphoreHandle_t mqtt_mutex; void MQTT_Publish_Safe(const char* topic, const char* payload) { if (xSemaphoreTake(mqtt_mutex, pdMS_TO_TICKS(1000)) pdTRUE) { MQTTClient_publishMessage(client, topic, msg, token); xSemaphoreGive(mqtt_mutex); } }选型建议很直接原型验证用 AT 模组有线固定设备用以太网加 Paho带屏带多外设的网关上 FreeRTOS。不要一上来就追求“全自研协议栈”时间花在业务上更值。3. 移植 MQTT 源程序从库文件到第一个 publish 报文3.1 获取与裁剪 MQTT 嵌入式客户端常见的开源实现是 Eclipse Paho Embedded C 和 MQTT-C。Paho 的MQTTClient-C子集比较适合 STM32核心文件就四个MQTTClient.c、MQTTClient.h、MQTTPacket.c、MQTTPacket.h。移植时不需要动协议解析部分只需要实现网络层接口。网络层要提供的函数就几个transport_sendPacketBuffer、transport_getdata、transport_open、transport_close。以 LwIP 为例transport_sendPacketBuffer里调tcp_write和tcp_outputtransport_getdata里调netconn_recv或 raw API 的tcp_recv回调。// LwIP raw API 下的发送实现 int transport_sendPacketBuffer(int sock, unsigned char* buf, int buflen) { struct tcp_pcb *pcb (struct tcp_pcb *)sock; err_t err tcp_write(pcb, buf, buflen, TCP_WRITE_FLAG_COPY); if (err ! ERR_OK) return -1; tcp_output(pcb); // 立即发送不等待 Nagle 合并 return buflen; }TCP_WRITE_FLAG_COPY让 LwIP 拷贝数据这样上层缓冲区可以立刻复用。如果内存紧张可以改成不拷贝但必须保证发送完成前缓冲区不被改写。tcp_output强制立即发送MQTT 报文通常不大不需要攒包。3.2 配置连接参数ClientID、KeepAlive、CleanSession 怎么定连接参数直接决定链路的稳定性和服务器侧的行为。ClientID 在同一个服务器上必须唯一建议用设备 MAC 地址或芯片 UID 生成不要用固定字符串否则批量部署时互相踢下线。KeepAlive 设 60 秒是通用值NB-IoT 等低功耗场景可以设 120 到 300 秒但服务器通常有上限超过 65535 秒会被拒绝。CleanSession 标志位要仔细选。设为 1 时每次连接都是全新会话服务器不保留订阅关系和未确认消息。设为 0 时服务器会保留会话设备断线重连后能收到断线期间的消息。但保留会话会占用服务器资源而且如果设备用随机 ClientID每次重连都创建新会话旧会话永远不释放。我一般调试阶段用 1量产根据业务定控制指令类用 0 保证不丢周期上报类用 1 省资源。// MQTT 连接参数配置 MQTTClient_connectOptions conn_opts MQTTClient_connectOptions_initializer; conn_opts.keepAliveInterval 60; conn_opts.cleansession 1; conn_opts.username device; conn_opts.password 123456; // ClientID 用芯片 UID 生成 char client_id[32]; sprintf(client_id, stm32_%08X%08X, HAL_GetUIDw0(), HAL_GetUIDw1()); MQTTClient_create(client, tcp://192.168.1.100:1883, client_id, MQTTCLIENT_PERSISTENCE_NONE, NULL);MQTTCLIENT_PERSISTENCE_NONE表示不持久化未确认消息掉电就丢。如果需要 QoS 1 或 2 的可靠传输要改成文件系统或 Flash 持久化但 STM32 上实现起来复杂多数场景用 QoS 0 加应用层重传就够了。3.3 第一个 publish 与 subscribe验证链路的最小闭环链路通不通发一条报文就知道。先连服务器再 publish 一条测试消息到某个主题然后在服务器侧用 mosquitto_sub 订阅同一个主题看能不能收到。这一步能排除掉 80% 的配置问题。// 发布一条 JSON 格式的传感器数据 MQTTClient_message pubmsg MQTTClient_message_initializer; char payload[128]; sprintf(payload, {\temp\:%.1f,\hum\:%.1f}, temperature, humidity); pubmsg.payload payload; pubmsg.payloadlen strlen(payload); pubmsg.qos 0; pubmsg.retained 0; MQTTClient_publishMessage(client, sensor/device01/data, pubmsg, token);retained设为 0 表示不保留消息服务器不会为后续订阅者保存这条数据。如果希望新订阅者立刻拿到最新状态可以设为 1但要注意 retained 消息会一直存在服务器上直到被同主题的新 retained 消息覆盖或清空。订阅侧的回调函数里不要做耗时操作把数据拷到队列里让业务任务去处理。回调是在 MQTT 接收线程里执行的阻塞太久会导致心跳超时。4. 联调与稳定性断线重连、QoS 选择、数据格式的实战处理4.1 断线重连的状态机设计网络抖动、服务器重启、路由器抽风都会导致 TCP 断开。MQTT 库的MQTTClient_isConnected返回 0 时必须走重连流程。重连不是简单再调一次 connect要按状态机来先关闭旧 socket再重新创建然后 connect成功后重新订阅所有主题。// 简化的重连状态机 typedef enum { MQTT_DISCONNECTED, MQTT_CONNECTING, MQTT_CONNECTED, MQTT_RESUBSCRIBING } mqtt_state_t; mqtt_state_t mqtt_state MQTT_DISCONNECTED; void MQTT_StateMachine(void) { switch (mqtt_state) { case MQTT_DISCONNECTED: if (network_is_up()) { MQTTClient_create(client, broker_addr, client_id, MQTTCLIENT_PERSISTENCE_NONE, NULL); mqtt_state MQTT_CONNECTING; } break; case MQTT_CONNECTING: if (MQTTClient_connect(client, conn_opts) MQTTCLIENT_SUCCESS) { mqtt_state MQTT_RESUBSCRIBING; } else { HAL_Delay(5000); // 5秒后重试 } break; case MQTT_RESUBSCRIBING: MQTTClient_subscribe(client, cmd/device01/#, 0); mqtt_state MQTT_CONNECTED; break; case MQTT_CONNECTED: if (!MQTTClient_isConnected(client)) { mqtt_state MQTT_DISCONNECTED; } break; } }重连间隔不要设太短5 秒起步连续失败可以退避到 30 秒。曾经设 1 秒重试结果服务器负载高的时候几百台设备同时重连直接把服务器打挂。退避策略是血泪经验。4.2 QoS 0/1/2 在 STM32 上的取舍QoS 等级决定消息可靠性也决定资源开销。QoS 0 最多发一次不确认丢了就丢了。QoS 1 至少发一次有 PUBACK 确认可能重复。QoS 2 恰好一次四次握手开销最大。STM32 上跑 QoS 2 需要缓存消息 ID 和状态内存占用明显增加。我一般这样选周期传感器数据用 QoS 0丢一两条无所谓控制指令用 QoS 1配合应用层去重固件升级包用 QoS 1 加分片校验不用 QoS 2。QoS 1 的重复问题在应用层用消息序号解决比协议层 QoS 2 省资源。4.3 数据格式JSON 打包与 GBK 转 UTF8 的坑STM32 上拼 JSON 字符串是最常见的做法但要注意浮点数格式化。sprintf的%f在 Keil 默认配置下可能不支持需要在工程选项里勾选 Use MicroLIB 或者开启浮点打印支持。更稳妥的做法是用整数放大十倍传输服务器侧再除。中文内容涉及 GBK 转 UTF8。如果传感器或用户输入带中文字符而 MQTT 服务器和前端按 UTF8 解析直接发 GBK 会乱码。STM32 上做编码转换需要引入转换表或轻量级库常见做法是在设备端统一用 UTF8 存储和发送输入环节就转换好。// 整数放大传输避免浮点打印问题 int temp_x10 (int)(temperature * 10); sprintf(payload, {\temp\:%d.%d}, temp_x10 / 10, temp_x10 % 10);这样不依赖浮点打印代码体积也小。服务器侧解析时按字符串处理再转数值。5. 避坑与排查STM32 MQTT 通讯源程序最常见的 5 个翻车点5.1 连接成功但订阅收不到消息现象MQTTClient_connect返回成功MQTTClient_subscribe也返回成功但服务器发到订阅主题的消息就是收不到。原因MQTT 接收依赖网络层的数据到达回调。如果用的是 LwIP raw APItcp_recv回调里没有把数据交给 MQTT 库处理或者接收线程被其他高优先级任务饿死报文就堆在缓冲区里。解决检查transport_getdata是否被周期性调用。裸机下要确保主循环里调用了MQTTClient_yield或手动触发接收处理。FreeRTOS 下给 MQTT 接收任务足够的优先级和栈空间栈至少 512 字。5.2 心跳超时导致频繁掉线现象设备每隔一两分钟就断线重连服务器日志显示 keepalive timeout。原因MQTTClient_yield调用间隔超过了 keepalive 时间或者系统 tick 被高优先级中断阻塞导致心跳包没按时发出。解决把心跳维护放在独立定时器中断里或者确保主循环周期小于 keepalive 的一半。检查是否有其他任务长时间关中断。用示波器抓 UART 或以太网发送引脚看心跳包实际发送间隔。5.3 ClientID 冲突导致互相踢下线现象两台设备同时上线其中一台反复掉线。原因ClientID 用了相同的固定字符串服务器检测到重复 ClientID 后踢掉旧连接。解决用芯片唯一 ID 或 MAC 地址生成 ClientID。STM32 的 UID 在HAL_GetUIDw0/1/2里拼成字符串即可。批量生产时确保每台设备 ID 不同。5.4 内存不足导致 connect 失败现象MQTTClient_connect返回MQTTCLIENT_FAILURE但网络是通的。原因LwIP 的MEM_SIZE或MEMP_NUM_TCP_SEG太小TCP 发送缓冲区分配失败。或者 MQTT 库的发送缓冲区在栈上定义栈溢出。解决增大lwipopts.h里的MEM_SIZE到 8192MEMP_NUM_TCP_SEG到 16。MQTT 的发送缓冲区改成静态全局变量不要放栈上。用xPortGetFreeHeapSize监控 FreeRTOS 堆剩余。5.5 服务器地址写错但 connect 不报错现象MQTTClient_connect返回成功但 publish 没反应。原因连接的是本地某个默认服务器或者 DNS 解析到了错误地址。有些 MQTT 库在 connect 时只做 TCP 连接不验证 MQTT 层响应。解决connect 后立刻 publish 一条 retained 消息到已知主题用服务器侧工具确认收到。检查MQTTClient_connect的返回值不要只看 TCP 连接状态。用 Wireshark 抓包看 MQTT CONNECT 报文发到了哪个 IP。6. 进阶技巧用 MQTT 主题设计撑起一个可扩展的 STM32 网关主题设计是 MQTT 落地里最容易被忽视、但后期扩展最依赖的一环。我见过太多项目一开始用data这种主题名设备一多就乱套。好的主题结构应该像文件路径一样有层次让订阅者能用通配符批量匹配。推荐的结构是{产品线}/{设备ID}/{方向}/{数据类型}。比如gateway/device01/cmd/relay表示发给 device01 的控制指令gateway/device01/status/temp表示温度上报。服务器侧订阅gateway//status/#就能拿到所有设备的状态数据订阅gateway/device01/cmd/#就能给指定设备发指令。// 主题拼接函数避免硬编码 void build_topic(char* buf, const char* device_id, const char* direction, const char* data_type) { sprintf(buf, gateway/%s/%s/%s, device_id, direction, data_type); } // 使用示例 char topic[64]; build_topic(topic, device01, status, temp); MQTTClient_publishMessage(client, topic, msg, token);通配符匹配单层#匹配多层。注意#只能放在主题末尾gateway/#合法gateway/#/temp不合法。服务器侧 ACL 权限也要按主题前缀配置否则设备能订阅到不该看的数据。另一个技巧是遗嘱消息。设备在 connect 时设置 will topic 和 will payload当设备异常断线时服务器会自动发布这条遗嘱消息。用这个机制做在线状态监测很省事will topic 设为gateway/device01/status/onlinepayload 设为{online:false}设备正常上线后主动发一条{online:true}的 retained 消息。这样服务器和前端随时能知道设备是否在线。// 设置遗嘱消息 MQTTClient_willOptions will_opts MQTTClient_willOptions_initializer; will_opts.topicName gateway/device01/status/online; will_opts.message {\online\:false}; will_opts.retained 1; will_opts.qos 1; conn_opts.will will_opts;遗嘱消息的 retained 设为 1这样新订阅者立刻能拿到设备最后的状态。QoS 用 1 保证遗嘱不丢。设备正常下线前应该主动发一条{online:false}并断开避免遗嘱和实际状态不一致。最后说一个验证方法用 mosquitto 自带的命令行工具做端到端测试。mosquitto_sub -t gateway//status/# -v订阅所有状态mosquitto_pub -t gateway/device01/cmd/relay -m {relay:1}发控制指令。设备端收到指令后操作继电器再把新状态发到 status 主题。整个闭环跑通说明主题设计和收发逻辑都没问题。我自己的习惯是每做一个新项目先把主题表列出来标清楚谁发布、谁订阅、QoS 等级、是否 retained然后再写代码。这个表后来成了团队交接的必备文档比代码注释管用。希望帮到你。本文还有配套的精品资源点击获取