
ModbusTCP 跑在 ESP32 上踩的第一个坑往往不是协议本身而是 TCP 的数据到了你手里已经变成了一堆没有边界的字节流。你以为调一次recv就能拿到一帧完整的 Modbus 请求结果打印出来发现要么只有半个报文要么一次塞进来好几个报文整个程序瞬间就懵了。所以我做 ESP32 网关时第一步永远是写一个靠谱的分片缓存组件把 TCP 流式数据重新组装成一帧帧完整的 ModbusTCP 报文再交给业务逻辑去解析。这篇文章就把我实际写过的方案完整拆开讲清楚。从为什么需要分片缓存、帧结构怎么判断边界到缓存容量怎么定、状态机怎么写、半包粘包怎么测、多连接怎么管全部基于 Arduino 与 PlatformIO 环境给出可直接抄的代码和踩坑清单。适合正在用 ESP32 做工业数据采集、ModbusTCP 从站/主站、或者 Modbus 转 MQTT 这类边缘网关项目的开发者。1. 为什么 ModbusTCP 跑在 ESP32 上必须做分片缓存1.1 TCP 是字节流不是报文流很多从串口 Modbus 转过来的朋友会天然带入一个习惯以为send()一次、对端recv()就一定能完整收到一次。这个直觉在 TCP 下面完全不成立。TCP 把发送端的所有数据看成一段连续的字节流接收端每次recv能拿到多少字节取决于内核缓冲区、网络拥塞、对端发送时序和设备发送时调用了几次write()完全没有关系。举个例子工控电脑上的组态软件向 ESP32 发送一个 12 字节的 ModbusTCP 读请求它在应用层可能只调用了一次send()。但 ESP32 这边用client.read()去读时第一次可能只读到 4 个字节剩下的 8 个字节要等下一次事件才到达。反过来如果组态软件连续发送两帧请求中间只隔了几毫秒ESP32 一次read()很有可能把 24 个字节全部读回来也就是两个帧粘在了一起。我把这种现象跟做网页后端的朋友解释说这就是经典的粘包和半包问题。ESP32 做 ModbusTCP 网关的时候你不可能控制对端 PLC 或组态软件的发送节奏所以唯一的办法就是自己在应用层维护一个组装器把收到的字节逐个或按批喂进去根据 ModbusTCP 自身的帧结构推断出帧的结束位置凑够一帧再推给上层解析。1.2 ModbusTCP 帧结构决定了解析方式要做分片缓存必须先清楚一个关键点ModbusTCP 报文不像 Modbus RTU 那样有帧尾特征RTU 靠 3.5 字符静默时间区分。ModbusTCP 是一个自描述长度的报文格式帧边界完全由 MBAP 头部中的长度字段决定。ModbusTCP 帧结构如下字段字节数说明事务标识符2请求与响应配对的编号协议标识符2Modbus 协议固定为 0x0000长度字段2表示单元标识符 PDU的总字节数大端序单元标识符1相当于串口从站地址PDU0~253功能码 数据关键就在这个长度字段上。MBAP 头部一共 6 个字节事务标识符 2 协议标识符 2 长度字段 2长度字段的值是单元标识符后面还跟着多少字节也就是1 个单元标识符 PDU 长度。所以一个完整的 ModbusTCP 帧总长度 6 长度字段值。举个例子功能码 0x03 读 4 个保持寄存器PDU 是功能码 1 字节 起始地址 2 字节 寄存器数量 2 字节一共 5 字节。长度字段的值就是 1 5 6。整帧长度 6 6 12 字节。如果你的程序只做了 12 字节定长读取那遇到功能码不同、读写长度不同的大报文立刻就会出错。这也是为什么必须动态解析长度字段而不是按固定长度处理。1.3 分片缓存的本质是两个状态的事分片缓存这个说法听起来有点玄但处理过程其实非常朴素收到一个字节就往缓冲区里放同时检查缓冲区头部能不能读出长度字段一旦知道了整帧长度就继续收直到缓冲区里的字节数等于这个长度此时一帧完整了取走处理如果缓冲区里还有剩余数据说明存在粘包继续按同样的规则解析下一帧。所以从状态机的角度整个过程只有两个状态等待头部前 6 字节没齐、等待完整帧长度字段已知但 PDU 没齐。任何一次recv返回任意数量的字节都能套进这个流程里无论是半包、粘包还是恰好一整帧都不会错。我最早用这个方案时犯过一个蠢错误试图在recv循环里判断数据长度刚好等于 12再用一个全局计数去猜下一帧是不是马上来。这样遇到多连接或者报文变长直接就全乱了。记住协议本身给了你长度字段你要信任它不要自己猜边界。2. 核心设计三个必须提前想清楚的决策2.1 缓存容量与合法性校验既然要根据长度字段计算整帧长度那缓冲区开多大就是一个绕不开的问题。ModbusTCP 的 PDU 最大长度是 253 字节这是 Modbus TCP 协议规范的限制加上长度字段本身包含 1 字节单元标识符所以长度字段的最大合法值是 254。整帧最大值 6 254 260 字节。所以我把内部缓冲区直接开到 260 字节不多不少。有些资料建议开 512 字节当然也可以用但在 MCU 上能省就省260 字节刚好吃满合法帧的上限。这里需要补一个关键细节长度字段是 2 字节无符号整数最大本身可以是 65535。但如果真的读到大于 254 的值要么是数据本身不合法要么是缓冲区里的字节根本就不是 ModbusTCP 帧可能是乱入的字符串或错误端口的数据。所以第二步一定要做合法性校验如果lenField 0 || lenField 254直接判定这一帧非法清空缓冲区重新同步。从实战角度看我还习惯在读前 6 个字节时顺带检查一下协议标识符是否为 0x0000。虽然 Modbus TCP 规范规定协议标识符固定为 0但很多设备厂商的私有实现会把高位字节塞别的值所以要不要强制检查取决于你的对接对象。如果是自家设备之间通信可以加如果是兼容各种第三方组态软件我只检查长度字段不硬卡协议标识符避免因为厂商不规范导致误杀。2.2 逐字节组装还是批量扫描把收到的数据喂给组装器有两种常见实现路径。第一种是逐字节喂入。每次client.read()拿到一批字节用一个 for 循环把每个字节依次传给组装器的appendByte()方法。这个方法内部维护一个位置指针每次存一个字节到第 6 个字节时解析长度字段然后再继续存到目标长度。优点是实现简单、逻辑单一不需要考虑缓冲区里刚好有两帧怎么办这种问题因为appendByte()天然携带帧完整了的边界信号。第二种是批量扫描。把read()拿到的数据直接追加到一个大的字节缓存里然后写一个解析函数从缓存头部尝试读取长度字段如果缓存长度已经达到6 lenField就切片取走一帧再把剩余数据前移。这种做法的好处是read()到函数调用的开销更小适合高速连续处理大量报文。我实际用的是逐字节方案原因很简单ESP32 的 TCP 报文量级在 Modbus 场景下根本不需要追求极限吞吐率一个一秒钟轮询一次的从站设备每秒才几十个字节逐字节处理完全够用。而且逐字节方案配合帧完成回调写起来特别清晰几乎不可能出现批量扫描那种数组越界前移的隐藏 bug。下面是两种方案的适用场景对比方案代码复杂度适合场景常见风险逐字节喂入低报文量小的从站、网关无但注意循环调用开销批量扫描中高频主站、大批量连续读写剩余数据搬移时越界、边界判断遗漏2.3 单路与多路连接的内存规划ESP32 做 ModbusTCP 服务端时经常是组态软件或云平台同时开好几条连接。每条连接都必须有独立的分片缓存否则 A 连接收到一半的数据被 B 连接的数据混进来立刻就会解析错乱。我建议的做法是为每个连接静态分配一个组装器对象。比如支持 4 路同时连接就一次性声明一个长度为 4 的组装器数组每个连接分配一个索引。这种做法的好处是不涉及动态内存分配不会出现malloc导致的内存碎片也不存在用完了delete的问题。如果你同时需要做 ModbusTCP 主站主动去轮询 PLC 和电表那主站侧同样需要维护一个按事务标识符匹配的响应缓存。一个完整的工程里应该有主站组装器和从站组装器两套实例彼此独立。多连接场景下我见过不少人直接用全局变量数组然后用客户端连接的remoteIP去索引。这里有个坑ESP32 的 WiFi 客户端对象在连接断开后如果被重新赋值给新连接底层 socket 的地址可能会变。更可靠的是用连接描述符或者 socket fd 作为索引在 accept 时固定绑定。3. 实操实现手写一个可复用的 ModbusTCP 帧组装器3.1 环境准备与工程结构我用的是 PlatformIO Arduino 框架工程依赖只需要原生 WiFi 库不需要额外引入任何第三方 Modbus 库因为分片缓存这种基础层功能自己写更可控。直接在 PlatformIO 工程里新建src/modbus_tcp_assembler.h和src/modbus_tcp_assembler.cpp两个文件。工程结构大概是这样的modbus_tcp_gateway/ ├── include/ ├── lib/ │ └── ModbusAssembler/ │ ├── ModbusAssembler.h │ └── ModbusAssembler.cpp ├── src/ │ └── main.cpp └── platformio.ini如果你还没装 PlatformIO直接在 VS Code 里装扩展即可。ESP32 的开发板包如果在线下载慢可以去下离线包手动安装具体的离线包路径就不细说了搜ESP32 离线包能找到很多现成资源。编译和烧录都走 PlatformIO 的标准流程选中开发板型号后pio run -t upload就行。3.2 帧组装器完整代码我直接贴出我用在好几个项目里的核心类这个类只负责一件事把字节流变成完整帧。// ModbusAssembler.h #pragma once #include stdint.h #include stddef.h #define MBAP_HEADER_LEN 6 #define MODBUS_MAX_FRAME_LEN 260 // 6 254 class ModbusAssembler { public: ModbusAssembler() { reset(); } // 核心方法喂入一个字节返回 true 表示凑出了一整帧 bool appendByte(uint8_t b); // 重启解析状态清空内部缓冲 void reset(); // 当 appendByte 返回 true 时调用下面两个方法取帧 const uint8_t* frame() const { return m_buf; } uint16_t frameLen() const { return m_frameLen; } bool isComplete() const { return m_complete; } bool isError() const { return m_error; } private: uint8_t m_buf[MODBUS_MAX_FRAME_LEN]; uint16_t m_pos; uint16_t m_frameLen; bool m_complete; bool m_error; };// ModbusAssembler.cpp #include ModbusAssembler.h bool ModbusAssembler::appendByte(uint8_t b) { // 上一帧已经完成喂入的新字节属于下一帧自动复位 if (m_complete || m_error) { reset(); } if (m_pos MODBUS_MAX_FRAME_LEN) { m_error true; return false; } m_buf[m_pos] b; // 收集满 6 字节 MBAP 头后解析长度字段 if (m_pos MBAP_HEADER_LEN) { uint16_t lengthField ((uint16_t)m_buf[4] 8) | m_buf[5]; if (lengthField 0 || lengthField 254) { m_error true; return false; } m_frameLen MBAP_HEADER_LEN lengthField; if (m_frameLen MODBUS_MAX_FRAME_LEN) { m_error true; return false; } } // 判定帧完整 if (m_pos MBAP_HEADER_LEN m_pos m_frameLen) { m_complete true; return true; } return false; } void ModbusAssembler::reset() { m_pos 0; m_frameLen 0; m_complete false; m_error false; }这段代码初看很简单但有几个细节值得说明。第一个是m_complete || m_error时自动复位的逻辑。如果上一帧已经完整但还没来得及取走又来了新字节说明调用方在read()循环里一下子拿到了多个帧的数据这时候不能丢弃新数据应该立刻进入下一帧的组装。所以复位判断必须放在m_pos自增之前。第二个是长度字段解析的位置。长度字段在第 5、6 两个字节也就是m_buf[4]和m_buf[5]因为事务标识符占了 0~1协议标识符占了 2~3。我之前见过网上有代码把m_buf[5]当作低字节、m_buf[6]当作长度高字节写过结果整个帧偏移了一字节调试了整整半天。记住 ModbusTCP 头只有 6 字节数据是从索引 6 开始的。第三个是m_frameLen的语义。它存的是长度字段计算出来的完整帧总长度不是还差多少字节。判断完整度的表达式是m_pos m_frameLen不要写成m_pos - 6 m_frameLen那样会把 PDU 多算 6 个字节等不齐。3.3 客户端读写循环与超时清理有了组装器之后服务端的read()循环就非常清爽了。每次读到数据逐个字节喂入组装器凑齐一帧就交给上层processFrame()处理。#include WiFi.h #include ModbusAssembler.h #define MAX_CLIENTS 4 ModbusAssembler assemblers[MAX_CLIENTS]; void handleClient(WiFiClient client, int index) { uint8_t temp[128]; while (client.connected()) { int n client.read(temp, sizeof(temp)); if (n 0) { delay(1); continue; } if (n 0) { // 检查超时如果组装器里还有半截帧且超过 2 秒没新数据 if (millis() - lastDataTime[index] 2000 !assemblers[index].isComplete()) { assemblers[index].reset(); } delay(1); continue; } lastDataTime[index] millis(); for (int i 0; i n; i) { if (assemblers[index].appendByte(temp[i])) { processFrame(assemblers[index].frame(), assemblers[index].frameLen()); assemblers[index].reset(); // 处理完立刻复位 } } } }这里有个非常重要的点processFrame()处理完一帧后必须立刻调用reset()。因为下一帧的数据可能已经在同一批read()的剩余字节里了如果不在appendByte()完成时复位下一帧就会被错误地叠加上去。超时清理是我后来加上去的。ModbusTCP 服务端经常遇到一种情况客户端发了一个半截帧就断电了组装器里残留十几个字节永远等不齐。如果不清除这台客户端的后续请求全部会被误判为错误帧。所以我在没有新数据的循环分支里检查一下超过 2 秒就复位。这个超时值参考了 Modbus 协议常见的 1~2 秒超时你按实际项目调节。那为什么不直接用client.setTimeout()因为client.read()在阻塞模式下会一直等待那会拖住整个任务。工业设备上传感器可能几秒才上报一次你必须用非阻塞方式把超时控制权握在自己手里。4. 测试与调试如何证明你的分片缓存可靠4.1 用 Python 模拟半包和粘包很多开阔发环境里没有真实的 PLC 或组态软件测试就靠一个 Python 脚本自己当 TCP 客户端。脚本的核心价值不是发数据而是能精确控制分几次发、每批发多少字节这样才能构造出你想要的各种边界情况。下面是我常用的测试脚本骨架import socket import time def build_frame(tid, unit_id, pdu): length len(pdu) 1 mbap tid.to_bytes(2, big) bytes([0, 0]) length.to_bytes(2, big) bytes([unit_id]) return mbap pdu # 1. 半包测试把 12 字节的一帧拆成 5 7 两次发送 frame build_frame(0x0001, 0x01, bytes([0x03, 0x00, 0x00, 0x00, 0x03])) sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((192.168.1.100, 502)) sock.send(frame[:5]) time.sleep(0.3) # 中间故意停顿 sock.send(frame[5:]) # 2. 粘包测试两帧一次性全部发出 frame1 build_frame(0x0002, 0x01, bytes([0x03, 0x00, 0x00, 0x00, 0x04])) frame2 build_frame(0x0003, 0x01, bytes([0x03, 0x00, 0x05, 0x00, 0x02])) sock.send(frame1 frame2) # 3. 大帧测试读 120 个寄存器构造 7 字节头 245 字节 PDU pdu bytes([0x03, 0x00, 0x00]) (120).to_bytes(2, big) frame3 build_frame(0x0004, 0x01, pdu) sock.send(frame3[:100]) time.sleep(0.2) sock.send(frame3[100:]) sock.close()测试时在 ESP32 的processFrame()里打印事务标识符和功能码。半包测试通过的标准是ESP32 在第二次send()之后才打印出完整帧且只打印一次粘包测试的标准是一次send()后打印出两条独立帧事务标识符分别对应 0x0002 和 0x0003。4.2 日志与断言调试分片缓存最忌讳的就是凭感觉。我建议在组装器里临时加两个日志变量当前缓冲区的m_pos和已经解析出的m_frameLen。每次appendByte()之后打印一行几行日志就能看出帧是在哪个位置停住、长度字段是不是解析对了。我最终在正式固件里保留了一个精简版断言函数在processFrame()里做三项检查帧总长度是否等于6 lengthField事务标识符是否与请求端保持奇数递增规律Modbus 主站常用奇偶识别请求/响应功能码高字节是否为 0读异常响应时功能码最高位为 1但要结合具体场景判断如果断言失败直接复位组装器并打一条 WARN 日志。这样做的好处是现场如果出了解析错乱至少能根据日志知道是哪一格数据出了问题而不是整个通信链路一摊泥。4.3 边界压力测试逻辑上跑通之后离可靠还差一步压力测试。我在实际项目中跑过的几个典型场景供你参考第一是 260 字节大帧。ModbusTCP 最大合法帧是 260 字节直接让脚本把一整帧拆成最小 1 字节的包发过来每个包间隔 1 毫秒验证组装器不会因为频繁复位或指针越界崩掉。第二是连续 1000 帧粘包。脚本一次性把 1000 条读请求全部拼接发送然后立刻统计 ESP32 侧processFrame()的实际调用次数。如果组装器逻辑正确数量应该精确等于 1000一帧不多一帧不少。第三是瞬时多连接。用脚本同时开 4 条 TCP 连接每个连接以不同节奏发送数据观察服务端是否有连接相互污染的情况。这个场景尤其能检验多路组装器的内存隔离有没有做好。这三项测试我是一层一层跑的每一步通过后才进入下一项。如果全都能稳定跑过 24 小时这个分片缓存基本就可以放心交到现场了。5. 常见问题排查与实战经验5.1 常见问题速查表我在多个项目里踩过、帮别人排查过的问题整理成一张速查表按现象排查会快很多。现象可能原因排查与解决方法一帧数据总差 1~2 字节等不齐长度字段解析偏移MBAP 头索引算错打印前 6 字节原始值确认第 5、6 字节才是长度报文解析出来功能码乱掉缓冲区粘包残留未复位下一帧数据叠加上去确认processFrame()后立即reset()偶尔整帧被丢弃长度字段为 0 或小于 6非法帧触发复位在appendByte()中打印出错时的m_pos和原始字节检查数据源两个客户端数据互相串每连接独立组装器没做全局变量共用了改为按连接索引分配独立ModbusAssembler实例设备重启后首帧无法解析客户端在建立连接后立即发包但服务端没有完整读取缓冲区在 accept 后先做一次非阻塞读取再进入主循环处理较慢时丢帧processFrame()里做阻塞延时如写 MQTT导致read()不及时用 FreeRTOS 队列把完整帧交给独立任务处理WiFi 重连后收不到数据TCP 连接因为链路中断被静默重置服务端没有清理旧连接在任务循环里检查client.connected()为 false 时主动断开并释放索引5.2 实测中踩过的坑第一个坑是关于client.read()返回 0 的。一开始我以为连接断开时connected()会立刻变成 false实际不是。WiFi 断连后 TCP 连接可能还处于半开状态read()返回 0 但不报错。如果不加超时逻辑组装器会一直停在等待数据的状态新连接根本分配不到索引。解决方式就是我上面写的把lastDataTime和millis()超时检查用起来超过阈值就强制关闭。第二个坑是 Buffer 到底该开多大。有个项目里我为了省 RAM 开了 128 字节结果有个 PLC 厂家发了一条读 200 个寄存器的请求PDU 到了 240 多字节直接把我缓存撑爆那一台设备从此所有请求都解析失败。后面我把所有设备都按 260 字节统一处理再没出过这个问题。第三个坑是processFrame()里面做了别的操作导致丢帧。我最初在处理函数里同步调用 MQTT publish结果 WiFi 链路忙的时候publish会阻塞几十毫秒而这期间 TCP 缓冲区里的新数据又到了等回到read()循环时内核可能已经丢包。后来我把 MQTT publish 放到了另一个任务通过 FreeRTOS 队列传递帧指针主循环只管最快速度地把数据从 socket 搬进组装器这个问题就消失了。5.3 从能用到好用的优化经验分片缓存本身逻辑很简单但放进完整的实时系统里会牵扯到中断、DMA、任务优先级这些更底层的设计。我试着总结几条实际经验。ESP32 的 WiFi 协议栈运行在 lwIP 之上数据到达后是协议栈在后台维护不需要你开外部中断去接收 TCP 数据。外部中断更适合用到 GPIO 状态检测这种场景比如门槛开关、脉冲计数和 TCP 接收路径分开设计别混在一起。如果你在 ESP-IDF 上做可以考虑不使用阻塞式recv而是用事件组或队列通知来唤醒处理任务。Arduino 框架下不提供直接的事件钩子所以简单场景用非阻塞read() 任务循环就够了。我的经验是任务优先级不要给太高和 lwIP 的默认优先级错开避免某些版本的协议栈因为 TCP 处理任务被饿死而出现收包延迟。另外在数据量比较大的场景下可以考虑把多个字节一次性拷贝进组装器缓存而不需要每个字节都做一次类方法调用。比如在appendByte()之上再封装一个appendBuffer(buf, len)内部循环调用即可。这样既保留了逐字节解析的清晰逻辑在循环层面也不会产生大量函数栈切换开销。实测在 ESP32 主频 240MHz 下这种量级的调用开销可以忽略不计。最后说一下 DMA。SPI 和串口场景下 DMA 接收非常常见但 ModbusTCP 的数据走的是 WiFi 协议栈DMA 由无线网络控制器内部处理用户层不需要也不应该自己去做 DMA 接收。如果你看到网上有人把外部设备的数据用 DMA 搬进内存再组装 Modbus 帧那多半是 SPI 外挂芯片或串口转 WiFi 模块的场景和原生 ESP32 WiFi 数传路径不是一回事。这个分片缓存组件我前后用了三四个项目从裸 TCP 起步到后来加上 MQTT 转发、RS485 透传、多客户端管理最核心的组装器逻辑一直是这一百多行代码。一个容易被人忽略的细节是这个组装器不应该只被看成收报文的工具它其实定义了整个网关的数据入口节奏。只要帧边界切得干净后续的协议解析、事务转发、边缘计算都能站在一个稳定的起点上。写完后跑一遍上面的压测脚本让它在角落安静转上一天你会发现 ModbusTCP 通信这件事比想象中简单得多。