1. 从一个尴尬的现场说起:为什么需要这个中转站
去年帮一个做环境监测的朋友处理过一套数据采集系统,现场情况很典型:三块STM32F103的板子分别挂在三个温室大棚里,负责采集温湿度、光照和土壤墒情,数据要汇总到管理房的服务器上做曲线展示和告警。最初的方案是每块STM32配一个4G模块直接往服务器推数据,结果跑了不到两个月就出了一堆问题——流量费超支、4G信号在棚区边缘时断时续、每块板子都要单独维护SIM卡和心跳逻辑,最要命的是服务器那边要同时维护三套连接状态,哪块板子掉线了排查起来特别费劲。
后来把架构改了一下:每个大棚里放一块ESP8266,STM32通过串口把数据交给它,ESP8266统一走WiFi连到管理房的MQTT Broker,服务器只订阅Broker上的主题就行。改完之后,STM32端的代码量砍掉了将近一半,4G模块全部撤掉,维护成本直线下降。这个方案的核心思路就是标题里说的——把ESP8266当作MQTT和STM32之间的中转站。
说白了,STM32擅长的是实时控制、外设驱动、ADC采样这些事,它的强项在"端";而ESP8266自带WiFi协议栈和TCP/IP协议栈,跑MQTT客户端是它的舒适区。让两者各干各擅长的事,中间用串口做一条清晰的数据通道,整个系统的耦合度就降下来了。这套架构特别适合以下几类人:手上有STM32开发板但不想碰网络协议栈的嵌入式初学者、做物联网小项目需要快速联网的开发者、以及像我朋友这样需要把多个分散节点统一接入MQTT的现场应用。
这篇文章我会把整套方案的来龙去脉讲透——从硬件怎么连、串口协议怎么定、ESP8266端固件怎么写、STM32端怎么发数据,到实际调试中那些文档里不会写的坑,全部摊开来说。你不需要有MQTT基础,但最好对STM32的串口收发和ESP8266的AT指令有个基本概念,这样读起来会更顺。
2. 为什么不让STM32直接跑MQTT:架构选型的底层逻辑
2.1 两种方案的资源账本对比
很多人第一反应是"STM32也能跑MQTT啊,为什么要多此一举加个ESP8266"。这话没错,STM32F4系列跑个轻量级MQTT客户端库确实没问题,但账要这么算:
| 对比维度 | STM32直接跑MQTT | STM32+ESP8266中转 |
|---|---|---|
| 网络硬件 | 需外接以太网PHY或4G模块 | ESP8266自带WiFi |
| 协议栈内存占用 | TCP/IP+MQTT约30-50KB RAM | STM32端仅需串口缓冲 |
| Flash占用 | 协议栈+证书约100KB+ | 串口解析约5-10KB |
| 开发难度 | 需移植LwIP或类似协议栈 | AT指令或透传,门槛低 |
| 断线重连 | 需自己实现完整状态机 | ESP8266固件内置处理 |
| 多节点部署 | 每节点都要网络硬件 | WiFi覆盖范围内即可 |
对于F103这类RAM只有20KB的芯片,直接跑MQTT基本是捉襟见肘的。而ESP8266模组现在便宜到几块钱一个,自带完整的WiFi和TCP/IP能力,把它当成一个"串口转MQTT的透明管道"来用,STM32端只需要关心"我要发什么数据"和"我收到了什么数据",网络层的所有破事都交给ESP8266。
2.2 串口作为分界线的合理性
串口是嵌入式领域最古老也最可靠的通信方式之一,用它做STM32和ESP8266的分界线有几个好处。第一是电气简单,两根线TX/RX交叉接上就行,不需要额外的时钟同步。第二是调试直观,拿个USB转串口工具并上去就能看到两边在聊什么,出问题的时候一眼就能定位是STM32没发还是ESP8266没回。第三是解耦彻底,STM32的固件升级和ESP8266的固件升级互不影响,换一个WiFi模组也不用动STM32的代码。
当然串口也有它的局限——速率和距离。但在温室大棚、智能家居、小型工控这类场景里,STM32和ESP8266通常就在同一块板子上或者相距几十厘米,115200bps的波特率传传感器数据绰绰有余。真要传大文件或者视频流,那这套架构就不合适了,得换方案。
2.3 MQTT在这个架构里扮演什么角色
MQTT是一个基于发布/订阅模式的轻量级消息协议,特别适合物联网场景。它的核心概念就三个:Broker(消息中转服务器)、Topic(消息主题,类似邮件的收件地址)、Client(发布者或订阅者)。
在这个架构里,ESP8266作为MQTT Client,把STM32给它的数据发布到某个Topic上,比如sensor/greenhouse1/temp;服务器端订阅这个Topic就能收到数据。反过来,服务器要下发指令,就发布到sensor/greenhouse1/cmd,ESP8266订阅了这个Topic,收到消息后通过串口转给STM32。
这种模式的好处是发布者和订阅者互不知道对方的存在,只认Topic。以后要加一个新的数据消费方(比如手机App、大屏展示),只要订阅同一个Topic就行,STM32和ESP8266这边完全不用改。这就是为什么MQTT在物联网领域这么流行——它把"谁发数据"和"谁收数据"彻底解耦了。
3. 硬件连线与串口协议设计:把规矩定在前面
3.1 硬件连接的实际接法
STM32和ESP8266之间的硬件连接,核心就是串口交叉接线加电源处理。以STM32F103和ESP-01S为例:
- STM32的USART2_TX(PA2)接ESP8266的RX
- STM32的USART2_RX(PA3)接ESP8266的TX
- STM32的3.3V接ESP8266的VCC和CH_PD(使能脚)
- 共地
这里有几个实际接线时容易翻车的点。第一,ESP8266的供电必须够。ESP-01S在发射WiFi信号瞬间电流能冲到300mA以上,如果直接从STM32的3.3V稳压器取电,很可能导致STM32自己复位。稳妥的做法是给ESP8266单独一路LDO,或者至少并一个100uF以上的电解电容在VCC和GND之间做储能。第二,CH_PD必须拉高,很多人忘了接这个脚,结果ESP8266死活不启动,查半天以为是串口问题。第三,如果用的是ESP-01S这种8脚模组,GPIO0在上电时必须为高电平,否则会进入下载模式而不是运行模式。
3.2 串口通信协议怎么定
STM32和ESP8266之间跑什么格式的数据,这个必须提前定好,不然后面两边各写各的,联调的时候就是灾难。我推荐两种方案,根据你的需求选:
方案一:AT指令直通模式。STM32直接发AT指令给ESP8266,ESP8266执行后返回结果。这种方案的好处是ESP8266端不需要写任何固件,刷个AT固件就行。坏处是STM32端要处理AT指令的解析和超时重试,代码量不小,而且AT指令的响应格式有时候不太规范,解析起来容易出bug。
方案二:自定义帧协议。STM32和ESP8266约定一个简单的帧格式,比如[帧头][长度][命令][数据][校验][帧尾]。ESP8266端跑自己写的固件,收到帧后解析并执行对应操作。这种方案STM32端代码简单,但需要你会写ESP8266的固件(用Arduino框架或者SDK都行)。
我个人更推荐方案二,因为一旦协议定好,两边的代码都很干净。下面是我常用的帧格式:
帧头: 0xAA 0x55 (2字节) 长度: 数据段长度 (1字节) 命令: 0x01=发布MQTT消息, 0x02=订阅主题, 0x03=心跳 (1字节) 数据: 变长 校验: 从长度到数据的异或和 (1字节) 帧尾: 0x0D 0x0A (2字节)这个格式简单到用肉眼都能解析,调试的时候拿串口助手一看就明白。校验用异或虽然不如CRC可靠,但对于短帧和良好电气环境下的通信足够了,而且计算量小,STM32和ESP8266都不费劲。
3.3 数据流向的完整链路
把整个数据链路串起来看是这样的:
- STM32采集到温度25.6度
- STM32按帧格式打包:
AA 55 0A 01 73 65 6E 73 6F 72 2F 67 31 2F 74 65 6D 70 32 35 2E 36 XX 0D 0A(这里数据段是Topic+payload) - 通过串口发给ESP8266
- ESP8266解析帧,提取出Topic和payload
- ESP8266通过MQTT协议把payload发布到对应Topic
- Broker把消息推送给所有订阅了该Topic的客户端
- 服务器收到数据,存入数据库并更新曲线
反向的指令下发链路是一样的,只是方向反过来。理解了这条链路,后面写代码的时候心里就有谱了。
4. ESP8266端的固件实现:让模组自己管好网络
4.1 固件框架的选择
ESP8266写固件有几种选择:官方SDK、Arduino框架、MicroPython。对于这个中转站场景,我强烈推荐Arduino框架,原因很简单——PubSubClient这个MQTT库太好用了,几行代码就能连上Broker收发消息,而且社区资料多,遇到问题一搜就有答案。官方SDK虽然更底层更灵活,但开发效率低不少,除非你有特殊需求,否则没必要折腾。
用Arduino框架开发ESP8266,环境搭建很简单:装个Arduino IDE,在首选项里加上ESP8266的板管理URL,然后在板管理器里搜"esp8266"装上就行。选板子的时候选"Generic ESP8266 Module",Flash Size根据你的模组选,ESP-01S通常是1MB。
4.2 串口收帧的解析逻辑
ESP8266端的主循环要做两件事:一是从串口收STM32发来的帧并解析,二是维护MQTT连接和收发消息。串口收帧我习惯用一个状态机来写,比死等帧头可靠得多:
// 串口收帧状态机 enum RxState { WAIT_HEAD1, WAIT_HEAD2, WAIT_LEN, WAIT_CMD, WAIT_DATA, WAIT_CHECK, WAIT_TAIL1, WAIT_TAIL2 }; RxState rxState = WAIT_HEAD1; uint8_t rxBuf[256]; uint8_t rxLen = 0, rxIdx = 0, rxCmd = 0, rxCheck = 0; void loop() { while (Serial.available()) { uint8_t b = Serial.read(); switch (rxState) { case WAIT_HEAD1: if (b == 0xAA) rxState = WAIT_HEAD2; break; case WAIT_HEAD2: rxState = (b == 0x55) ? WAIT_LEN : WAIT_HEAD1; break; case WAIT_LEN: rxLen = b; rxIdx = 0; rxCheck = b; rxState = WAIT_CMD; break; case WAIT_CMD: rxCmd = b; rxCheck ^= b; rxState = WAIT_DATA; break; case WAIT_DATA: rxBuf[rxIdx++] = b; rxCheck ^= b; if (rxIdx >= rxLen) rxState = WAIT_CHECK; break; case WAIT_CHECK: if (b == rxCheck) rxState = WAIT_TAIL1; else rxState = WAIT_HEAD1; break; case WAIT_TAIL1: rxState = (b == 0x0D) ? WAIT_TAIL2 : WAIT_HEAD1; break; case WAIT_TAIL2: if (b == 0x0A) handleFrame(rxCmd, rxBuf, rxLen); rxState = WAIT_HEAD1; break; } } mqttLoop(); }这个状态机的好处是不会因为丢了一个字节就卡死,任何一步校验失败都会回到WAIT_HEAD1重新找帧头。实际跑起来很稳,我用了大半年没出现过解析错乱的情况。
4.3 MQTT连接的建立与保活
MQTT连接这块,PubSubClient库已经封装得很好了,但有几个参数必须根据实际情况调:
WiFiClient espClient; PubSubClient mqttClient(espClient); void mqttConnect() { mqttClient.setServer("192.168.1.100", 1883); mqttClient.setKeepAlive(30); // 心跳间隔30秒 mqttClient.setBufferSize(512); // 缓冲区大小 mqttClient.setCallback(mqttCallback); while (!mqttClient.connected()) { String clientId = "esp8266-" + String(ESP.getChipId(), HEX); if (mqttClient.connect(clientId.c_str())) { mqttClient.subscribe("sensor/g1/cmd"); } else { delay(3000); } } }setKeepAlive设成30秒是个经验值。设太短(比如10秒)会导致心跳包太频繁,增加网络负担;设太长(比如120秒)则Broker要等很久才能发现客户端掉线。30秒在大多数场景下是个平衡点。setBufferSize默认是256字节,如果你要发的payload比较大(比如JSON格式的传感器数据),一定要调大,否则消息会被截断,而且截断的时候不报错,排查起来很坑。
4.4 断线重连与异常处理
ESP8266在实际部署中会遇到各种网络异常:路由器重启、信号衰减、Broker宕机。固件必须能自己从这些异常中恢复,不能指望人工去重启。我的做法是在主循环里检查两个状态:
void mqttLoop() { if (WiFi.status() != WL_CONNECTED) { WiFi.reconnect(); delay(5000); return; } if (!mqttClient.connected()) { mqttConnect(); } mqttClient.loop(); }这段代码看起来简单,但覆盖了绝大多数异常场景。WiFi断了就重连WiFi,MQTT断了就重连MQTT。唯一要注意的是重连的时候要加延时,不能死循环里疯狂重试,否则会把ESP8266的看门狗喂爆导致重启。我一般用3到5秒的延时,既不会响应太慢,也不会把CPU占满。
5. STM32端的实现:把数据打包发出去就完事
5.1 串口初始化和发送函数
STM32端的事情比ESP8266端简单得多,核心就是初始化串口、打包数据、发出去。以HAL库为例:
UART_HandleTypeDef huart2; void MX_USART2_UART_Init(void) { huart2.Instance = USART2; huart2.Init.BaudRate = 115200; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = UART_PARITY_NONE; huart2.Init.Mode = UART_MODE_TX_RX; huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE; HAL_UART_Init(&huart2); }波特率选115200是个通用选择,ESP8266的AT固件默认也是这个速率。如果你用的是自己写的ESP8266固件,两边约定好就行,9600到921600都可以,但建议不要低于9600,否则发一帧数据要等太久。
5.2 数据帧的打包与发送
打包函数就是把前面定的帧格式用代码实现出来:
void sendMqttPublish(uint8_t *topic, uint8_t topicLen, uint8_t *payload, uint8_t payloadLen) { uint8_t frame[300]; uint8_t idx = 0; uint8_t dataLen = topicLen + payloadLen + 1; // +1是分隔符 frame[idx++] = 0xAA; frame[idx++] = 0x55; frame[idx++] = dataLen; frame[idx++] = 0x01; // 命令:发布 uint8_t check = dataLen ^ 0x01; for (uint8_t i = 0; i < topicLen; i++) { frame[idx++] = topic[i]; check ^= topic[i]; } frame[idx++] = '|'; // Topic和payload的分隔符 check ^= '|'; for (uint8_t i = 0; i < payloadLen; i++) { frame[idx++] = payload[i]; check ^= payload[i]; } frame[idx++] = check; frame[idx++] = 0x0D; frame[idx++] = 0x0A; HAL_UART_Transmit(&huart2, frame, idx, 1000); }调用的时候就很清爽了:
char topic[] = "sensor/g1/temp"; char payload[] = "25.6"; sendMqttPublish((uint8_t*)topic, strlen(topic), (uint8_t*)payload, strlen(payload));5.3 接收ESP8266回传数据的处理
STM32端接收ESP8266的数据,主要是处理两种情况:一是ESP8266返回的发送确认,二是服务器下发的指令。接收可以用中断方式,也可以用DMA,我一般用空闲中断+DMA的方式,效率最高:
uint8_t rxBuffer[256]; uint16_t rxLen = 0; void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART2) { rxLen = Size; parseEspFrame(rxBuffer, rxLen); HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rxBuffer, sizeof(rxBuffer)); } }空闲中断的好处是一帧数据收完就触发一次中断,不用在中断里一个字节一个字节地判断。配合DMA,CPU几乎不参与接收过程,主循环该干嘛干嘛。
5.4 心跳与超时机制
STM32和ESP8266之间也需要一个心跳机制,用来判断对方是不是还活着。我的做法是STM32每隔10秒发一个心跳帧(命令0x03,数据段为空),ESP8266收到后回一个同样的心跳帧。如果STM32连续3次没收到ESP8266的心跳回复,就认为ESP8266挂了,可以触发一个告警或者尝试复位ESP8266(通过控制ESP8266的复位脚)。
这个机制在实际部署中救过我好几次。有一次现场反馈数据不更新了,远程一看是ESP8266死机了(后来查出来是电源纹波太大导致的),但STM32还在正常采集。因为有心跳检测,STM32检测到ESP8266失联后自动拉低了它的复位脚,ESP8266重启后恢复了正常,整个过程不到5秒,用户几乎无感知。
6. 联调阶段踩过的坑:文档里不会写的那些事
6.1 串口乱码的三种可能原因
联调第一步就是看串口能不能通,但这一步经常出问题。串口乱码我遇到过三种原因:
第一种是波特率不匹配。STM32设的115200,ESP8266固件里设的9600,收到的就是一堆乱码。这个最容易排查,两边对一下就行。
第二种是时钟配置错误。STM32的串口波特率是从系统时钟分频出来的,如果外部晶振频率和代码里配的不一致(比如板子上是8MHz晶振但代码里按12MHz算),实际波特率就会偏,偏得不多的时候表现为偶尔出错,偏得多就是全乱码。这个用示波器量一下TX脚的波形,算一下位宽就能确认。
第三种是电平不匹配。有些ESP8266模组是5V容忍的,有些不是。如果STM32是3.3V电平而ESP8266模组要求5V,或者反过来,都可能出问题。不过现在大多数模组都是3.3V的,这个问题相对少见。
6.2 MQTT连接成功但收不到消息
这个问题我卡了整整一个下午。现象是ESP8266的串口打印显示MQTT连接成功了,但服务器那边就是收不到数据。排查过程是这样的:
先确认ESP8266确实发布了消息——在mqttClient.publish()后面加了一句串口打印,确认代码执行到了。然后确认Topic对不对——用MQTT调试工具直接连上Broker,手动发布同样的Topic,服务器能收到,说明Topic没问题。最后发现问题出在payload的格式上:我发的是纯文本"25.6",但服务器端的解析代码期望的是JSON格式{"value":25.6}。因为MQTT本身不关心payload格式,Broker只管转发,所以消息确实发出去了,只是服务器解析失败给丢弃了。
这个坑的教训是:联调的时候一定要用MQTT调试工具同时看发布端和订阅端,确认消息到底有没有到Broker。如果到了Broker但订阅端没收到,那就是Topic匹配或者QoS的问题;如果连Broker都没到,那就是ESP8266端的问题。
6.3 ESP8266频繁掉线的电源问题
前面提过ESP8266的供电问题,这里展开说一下。我最初用AMS1117-3.3给STM32和ESP8266一起供电,结果ESP8266每隔几分钟就掉线一次。用示波器看3.3V轨,发现ESP8266发射瞬间电压会跌到2.8V左右,虽然还在工作范围内,但已经接近临界值了。
解决办法是在ESP8266的VCC和GND之间并一个220uF的电解电容加一个0.1uF的陶瓷电容。电解电容负责储能,在发射瞬间提供电流;陶瓷电容负责滤高频噪声。改完之后电压波动降到了100mV以内,再也没出现过无故掉线。
提示:如果你用的是ESP-01S这种小模组,尤其要注意电源问题。模组越小,板载电容越少,对外部电源的要求就越高。
6.4 数据丢包与缓冲区溢出
跑了一段时间后发现偶尔会丢几条数据。排查下来是STM32发送太快,ESP8266的串口缓冲区溢出了。STM32的主循环里如果连续调用sendMqttPublish,两帧之间的间隔可能只有几毫秒,而ESP8266端解析完一帧、发布MQTT消息、再准备接收下一帧,这个过程需要几十毫秒。
解决办法有两个:一是在STM32端加流控,每发一帧等ESP8266的确认再发下一帧;二是加大ESP8266的串口接收缓冲区。我选了方案一,因为确认机制还能顺便检测丢帧。具体做法是STM32发完一帧后等一个确认帧(命令0x04),超时200ms没收到就重发,重发3次还失败就丢弃并记录错误。
7. 性能调优与稳定性加固的几条经验
7.1 串口波特率能跑多高
115200bps是保守选择,实际测试下来,STM32F103和ESP8266之间跑460800bps也是稳的,921600bps在短线(10cm以内)且电源干净的情况下也能跑。提高波特率的好处是降低单帧传输时间,对于高频采集场景(比如每秒采10次)很有意义。
但波特率不是越高越好。高波特率对信号质量要求更高,线长了、有干扰了就容易出错。我的建议是:如果115200够用就别往上调,稳定比快重要。真要高频传输,不如把数据在STM32端做一下聚合,攒够一批再发,减少通信次数。
7.2 MQTT的QoS等级怎么选
MQTT有三个QoS等级:0(最多一次)、1(至少一次)、2(恰好一次)。QoS越高越可靠,但开销也越大。
对于传感器数据这种场景,我一般用QoS 0。因为传感器数据是周期性的,丢一两条无所谓,下一周期就补上了。用QoS 1或2的话,每条消息都要等确认,吞吐量会明显下降,而且ESP8266的内存也吃紧。
但如果是控制指令(比如开关灯、设置阈值),那就必须用QoS 1,确保指令一定送达。QoS 2虽然最可靠,但握手过程太繁琐,在资源受限的设备上不推荐。
7.3 看门狗与异常恢复
ESP8266的固件里一定要开看门狗。Arduino框架默认是开的,但如果你在某个地方写了死循环或者长时间delay,看门狗就会触发重启。这其实是好事——宁可重启也不要卡死。我还会在loop()的开头喂一次狗,确保主循环在正常跑。
STM32端也建议开独立看门狗(IWDG),万一程序跑飞了能自动复位。但要注意,如果STM32复位了,ESP8266并不知道,所以复位后STM32要重新初始化串口并和ESP8266重新握手。
7.4 固件升级的考虑
ESP8266支持OTA升级,这在部署后非常有用。你可以在固件里加一个OTA的逻辑,当收到特定MQTT指令时进入OTA模式,从指定URL下载新固件。这样现场设备不用拆下来就能升级。
不过OTA也有风险——升级过程中断电会导致设备变砖。稳妥的做法是保留一个出厂固件分区,OTA失败时能回滚。Arduino框架对ESP8266的OTA支持还算完善,但配置起来有点繁琐,建议在项目初期就把OTA功能规划进去,不要等部署了几十台设备才想起来。
8. 这套架构还能怎么扩展
8.1 多STM32挂一个ESP8266
一个ESP8266的串口只能接一个STM32,但如果你用软串口或者多路UART,理论上可以挂多个。不过更优雅的做法是用RS485总线,多个STM32通过RS485连到ESP8266,ESP8266轮询各个节点。这样一套WiFi模组就能带起一个小型传感器网络。
8.2 加入本地缓存应对断网
网络不可能永远在线,断网期间的数据不能丢。可以在ESP8266端加一个SPIFFS文件系统,断网时把数据先写到Flash里,网络恢复后再补发。ESP8266的Flash通常有1MB以上,存几万条传感器数据没问题。
8.3 从MQTT扩展到其他协议
如果服务器端用的是HTTP接口而不是MQTT,ESP8266端改一下就行——把PubSubClient换成HTTPClient,串口协议不用动。这就是分层架构的好处,换一层不影响其他层。
我在实际项目里还试过让ESP8266同时跑MQTT和HTTP两个通道,MQTT用于实时数据,HTTP用于批量历史数据上传,效果也不错。ESP8266虽然资源有限,但只做协议转换的话,余量还是够的。
这套STM32+ESP8266+MQTT的中转架构,我从最初搭起来到现在,已经在好几个项目里复用过。每次只需要改改Topic名字和payload格式,核心的串口协议和固件框架基本不用动。对于中小规模的物联网数据采集场景,它的性价比和可维护性都是很能打的。如果你正在为STM32怎么联网发愁,不妨试试这个思路。