
如果你的 ESP32 项目要跟 ModbusTCP 打交道迟早会撞上“分片缓存”这四个字所描述的场景你从 TCP 连接里 read 到的数据长度跟 Modbus 报文长度对不上。明明发过来一条完整请求你 read 一次只拿到 7 个字节有时候你以为处理完一条结果缓冲区里还躺着三四条报文没解析。这种半包、粘包轮番出现的情况就是这个项目标题里“分片缓存”要解决的核心问题。我是在一个真实项目里被这个问题教育过的。ESP32 作为 ModbusTCP server 接一台 PLCPLC 每 100ms 周期读一批保持寄存器。刚开始一切正常跑了大概 20 分钟日志里跳出一条异常recv len7而一条完整 ModbusTCP 报文最少也要 12 字节。我第一反应是 Wi-Fi 丢包第二反应是 read 函数写错了折腾半天才发现不是网络丢数据而是 PLC 发过来的报文在 TCP 协议栈里被切成了两段ESP32 只 read 到了第一段。后面这篇内容我会把这件事完整拆开TCP 为什么会产生半包粘包、ModbusTCP 报文结构里哪个字段才是拆帧的唯一抓手、以及一段能直接抄进工程的分片缓存解析代码。无论你用 Arduino 框架、ESP-IDF 还是 PlatformIO只要底层是 TCP socket这套思路都适用。1. 为什么 ModbusTCP 需要“分片缓存”这种机制1.1 你在什么场景下会撞到半包和粘包先定义一下现象。半包就是一条完整的 Modbus 报文没到齐。比如请求是 12 个字节你 read 一次只拿到 7 个剩下 5 个还在网络栈里没送到。粘包反过来你按一条报文去解析结果缓冲区里一次来了三条报文摞在一起从哪儿切分都判断不好。这两个情况在 ModbusTCP 项目里出现的概率比新手想象的高很多尤其是下面这些典型场景ESP32 通过 Wi-Fi 连 PLCPLC 长时间周期性读写寄存器ESP32 做 ModbusTCP server上位机或另一台 PLC 以较高频率轮询或者现场网络有交换机、路由器缓冲报文在中间层被合并或拆分。我见过不少人踩坑前都有一个共同特征用电脑上的模拟主站测试时一切正常一接到真实 PLC 就开始乱。原因很简单模拟工具发报文时是一次 write 直接发一整条本地回环测试几乎不会拆包。真实 PLC 和真实网络里的 TCP 协议栈根据 MSS 最大报文段大小、Nagle 算法、发送缓冲区水位来决定把多少字节放进一个 TCP 段它根本不关心你的 Modbus 报文从哪开始、在哪结束。还有一个容易忽视的点ESP32 的 Wi-Fi 环境本身就涉及帧聚合、802.11 分片重传和路由器 QoS 缓冲链路层的时序被打散之后接收端 READ 到的字节长度就更难捉摸了。你唯一能确定的是字节之间的先后顺序不会乱一次能 read 到多少字节完全没有保证。1.2 根本原因TCP 是字节流不是报文流想懂分片缓存第一步就是接受一个事实TCP 眼里只有连续字节没有“消息”“帧”“命令”这些概念。TCP 协议栈拿到应用层写进来的数据只把它当成一个连续字节序列然后按自己的策略切成若干段交给 IP 层发送。这个行为很像往一个大管道里倒水。你倒一杯水管道另一端未必刚好出来一杯。可能你连倒三杯它才一次性吐出来也可能一杯水被管道分两次吐完。ModbusTCP 报文写在 TCP 字节流里就是那一杯水。你说的“一条报文”TCP 根本不知道它存在。再叠加 Nagle 算法的效果。发送端如果积压了少量字节TCP 会故意等一下把多个小包合并成一个大段再发这是典型的粘包源头。接收端如果缓冲区一次收下几十个字节应用层 read 拿到的可能就是“半条尾部 一条完整 另一条头部”的混合体。最需要理解的一点TCP 不能保证报文边界这是它设计上的固有行为不是 bug更不是玄学。所以在 ModbusTCP 通信层你必须自己实现“字节流到报文流”的组装这就是标题里“分片缓存”存在的根本原因。1.3 不是只有客户端才需要处理很多入门教程默认你是客户端也就是 ESP32 主动连 PLC 发请求、等响应好像只要能“收完整帧”就行。但实际工程里 ESP32 常常是被动端——它做 ModbusTCP server 挂在 PLC 下面。AAPLPLC 做主站主动连上来它发出的读请求可能是 12 字节写请求可能是 20 字节请求长度本身就不固定而且 PLC 的 TCP 栈同样会把请求拆开。作为 server你如果不做分片缓存收到的就是零零碎碎的字节必须先把完整请求拼出来才能处理功能码、寄存器地址、数量这些业务字段。再细一点说同一个 TCP 连接上可能先后到来多条请求如果代码写成“read 到多少字节就立刻当一帧处理”很快就会出现事务 ID 错位、功能码被拆开、寄存器地址被切到两帧里的情况。所以客户端、服务器端都必须做分片缓存。还有连接生命周期的问题。PLC 可能长时间保持连接也可能每隔几分钟重连一次。重连之后如果上一次连接缓冲区里还有没消费完的残留字节会被当成新请求处理后果比丢一帧还尴尬可能直接触发异常响应。这一点我后面在避坑环节会再强调。提示处理 ModbusTCP 的正确姿势不是去猜“TCP 会怎么切包”而是每次 read 到的字节都放进一个组装器由组装器根据 Modbus 报文头部的长度字段判断“够不够一帧”。这就是分片缓存机制的核心。2. 动手前必知的报文结构MBAP 报头和 PDU2.1 MBAP 报头里哪几个字段最关键ModbusTCP 的报文结构叫 ADU由两部分组成最前面的 7 字节是 MBAP 报头Modbus Application Protocol header后面跟着 PDU协议数据单元。MBAP 四个字段我列了张表你在代码里要跟它打交道建议把这张表抄在工位旁边偏移字节数字段名说明02事务标识符 Transaction ID请求和响应必须一致用于匹配22协议标识符 Protocol IDModbusTCP 必须为 042长度 Length表示 Unit ID 和 PDU 的字节数61单元标识符 Unit ID从站地址一般填 1~247 或 0xFF这四个字段全是网络字节序也就是大端序列高位在前、低位在后。ESP32 的 CPU 是小端序读出来之后如果不做字节交换解析出来的长度、事务 ID 全会错。你以为读到的 Length 是 10实际可能是 2560程序就会永远在半包状态里等待。后面我会专门讲这个坑。PDU 则由功能码 1 字节和数据区组成。比如“读保持寄存器”功能码是 0x03后面紧跟起始地址 2 字节、寄存器数量 2 字节所以一个典型的读请求 PDU 长度是 1 4 5 字节。2.2 长度字段是你拆帧的唯一抓手MBAP 报头里的 Length 字段最要命也最有用。它表示的既不是整条报文的总长度也不是 PDU 单独的长度而是Unit ID PDU 一共多少字节。也就是说MBAP 的第 7 个字节 Unit ID 被算进 Length 了但它前面的事务 ID、协议 ID、长度字段本身这 6 个字节不在里面。所以完整 ADU 的计算公式是完整 ADU 长度 6 Length很多人一开始会算成 7 Length把 Unit ID 也排除了结果每帧切出来的边界都偏一个字节。这个公式必须记牢它是分片缓存判断“是否凑够一帧”的根基。举个例子。读保持寄存器请求事务 ID 2 字节、协议 ID 2 字节、长度字段 2 字节、Unit ID 1 字节再加上功能码 1 字节、起始地址 2 字节、寄存器数量 2 字节整条请求是 12 字节。其中 Length Unit ID 1 PDU 5 6完整帧 6 Length 12。正确。再比如一条响应里带了 10 个寄存器的数据PDU 是功能码 1 字节数 1 数据 20共 22 字节Length Unit ID 1 PDU 22 23完整帧 6 23 29 字节。你只需要在收够前 6 个字节后解析出 Length23就知道这条响应共 29 字节接下来再等 23 个字节即可。抗饿Length 的合法范围是 2 到 253或按业务调整如果解析出来是 0xFFFF 这种极值直接丢弃当前缓冲区并重置组装器否则程序会被“饿死”在永远等不到完整帧的状态里。2.3 半包、粘包的判定逻辑其实是三段式有了长度字段判断逻辑就可以统一成三段式状态机第一阶段缓冲区字节数不足 6此时无法读取 Length 字段只能继续收。 第二阶段缓冲区字节数已经大于或等于 6能读出 Length但 buffer 内总字节数小于 6 Length说明一条报文还没完全到达处于半包状态。 第三阶段缓冲区字节数已经大于或等于 6 Length说明至少一条完整 ADU 已经齐了。缓冲区里可能还有多余的字节属于下一条报文的开头这就是粘包。这里最关键的设计思想是你不需要猜测 TCP 怎么切只需要维护一个预期长度和一个缓冲区每次压入一个字节或者一段字节然后重新计算状态。状态到了第三阶段就取出完整帧处理处理完把剩余字节移到缓冲区头部继续下一轮。注意同一条 TCP 连接上同时只应组装一个 ADU。等这条处理完缓冲区里剩余的字节自动进入下一条 ADU 的组装。不要把多条报文的字节混在一起逐帧解析否则事务 ID 必然错乱。3. 分片缓存的具体设计把收包变成组装3.1 为什么我推荐逐字节处理而不是按“recv 多少处理多少”有人会问read 回来明明是一段连续数据为什么不直接缓存整段再切答案是可以但很容易写歪。read 的返回值是不定的。你今天测试时每次 read 都拿 12 字节不代表现场永远如此。如果代码按“read 返回一段就直接当一帧”来写一旦半包出现data[0] 是报文中间的部分data[5] 是报文尾巴加下一条报文的脑袋整个解析逻辑就没有可靠边界了。反过来每一次 push 只处理一个字节不会有任何歧义。每一字节都按照顺序进入同一个组装器组装器自己判断边界代码结构统一调试时还能方便地打印每一字节的十六进制值。对 ModbusTCP 这种工业协议来说它的请求频率通常只有几百毫秒一次逐字节处理完全扛得住完全不必为了性能提前优化成复杂的 DMA 缓冲或者链式缓存。3.2 核心状态缓冲区、已收长度、预期总长我在工程里用的组装器核心就是下面几个变量buffer[512]存放 ADU 字节。ModbusTCP 单帧一般不超过 260 字节512 足够覆盖大点数读线圈的响应。recvLen缓冲区里已经收到的字节数。expectLen当前这一帧计算出来的预期总长也就是 6 Length。一个状态标志或直接用 expectLen 是否为 -1 来判断是否已经完成首段解析。组装逻辑的伪代码是这样的push 一个字节 b 把 b 放入 buffer[recvLen]recvLen 加 1 如果 recvLen 6 状态为“头部没齐”直接返回继续收 否则 读取 buffer[4] 和 buffer[5]计算 Length 如果 Length 非法2 或 253 清空缓冲区返回错误 设置 expectLen 6 Length 如果 recvLen expectLen 半包状态继续收 如果 recvLen expectLen 从 buffer 中取出一整帧交给业务处理 把剩余字节往前挪recvLen 扣掉刚处理的一帧长度 清空 expectLen进入下一轮组装这套逻辑的特点是无论你一次 push 一个字节还是 push 一整个 read 缓冲区它都能按“帧”为单位一个接一个地把 Modbus ADU 吐出来而且不会跨帧错位。3.3 Arduino 框架里的极简可运行实现为了方便你直接抄我写了一个最简的 C 类只依赖标准库和 Arduino 内建类型class ModbusTcpAssembler { public: static const int MAX_FRAME 512; uint8_t buffer[MAX_FRAME]; int recvLen 0; int expectLen -1; // 返回 0继续收, 1完整帧已处理, 负数异常 int push(uint8_t b) { if (recvLen MAX_FRAME) { reset(); return -1; } buffer[recvLen] b; if (recvLen 6) { return 0; } // 头部已经至少6字节可以解析长度字段 if (expectLen 0) { int lengthField (buffer[4] 8) | buffer[5]; if (lengthField 2 || lengthField 253) { reset(); return -2; } expectLen 6 lengthField; } if (recvLen expectLen) { return 0; // 半包继续等 } // 至少一条完整帧在缓冲区 int frameLen expectLen; handleFullFrame(buffer, frameLen); int remain recvLen - frameLen; if (remain 0) { memmove(buffer, buffer frameLen, remain); } recvLen remain; expectLen -1; return 1; } void reset() { recvLen 0; expectLen -1; } private: void handleFullFrame(uint8_t* buf, int len) { // 在这里解析 TransId、功能码和数据 // 举例 // uint16_t transId (buf[0] 8) | buf[1]; // uint8_t func buf[7]; // 然后把 buf8 到 buflen-1 作为数据区处理 } };代码里最要注意的两处第一expectLen 是用 6 Length不是 7 Length第二处理完整帧之后要 memmove 剩余字节到缓冲区头部这样下一轮组装才能正确衔接。提示handleFullFrame 里的业务逻辑我留空了。建议你先把 transId 和 len 打印出来跑通半包粘包测试后再加寄存器读写业务避免定位问题时分不清是分片组装的问题还是业务解析的问题。3.4 把组装器接进 TCP Client 的读取循环有了这个类主循环非常简单。假设你用的是 Arduino 框架的 WiFiClientWiFiClient client; ModbusTcpAssembler assembler; void loop() { while (client.available() 0) { uint8_t b client.read(); int res assembler.push(b); if (res 0) { assembler.reset(); break; } // res 1 说明已处理完一条完整ADU继续循环因为可能存在粘包 } }这个 while 循环会一直读到当前没有可读字节为止。期间每 push 一个字节组装器都会重新判断状态。完整帧一出现就立刻处理粘包时多余字节会自动进入下一条组装不需要额外细分逻辑。实际工程里我还会加一个超时保护如果 recvLen 已经大于 0但距离上一次收到字节超过了 500ms就调用 assembler.reset() 清掉半包状态。这个机制防止对端异常断开后缓冲区永远卡在半包状态后续报文再也无法正常组装。超时时间的设定不能太短因为工业 Wi-Fi 环境里偶尔有 100~300ms 的延迟波动如果你设太短正常慢速响应急容易被误杀。3.5 ESP32 做 server 时多连接怎么管理如果 ESP32 是 ModbusTCP server一个 server 同时监听多个客户端那组装器实例就不能只有一个。每条连接的组装器要独立保存最方便的是用一个数组加 sockfd 索引或者键值表管理客户端。连接建立时初始化组装器连接断开时销毁或重置。我见过一个典型的翻车案例有人图省事用了全局组装器两台 PLC 同时连上来后A PLC 的字节混进 B PLC 的缓冲区解析出的帧边界全错。所以在写 server 端代码前先把“每个 Socket 独立组装器”的架构定下来后面再大量轮询登记表也不会乱。4. 实测踩坑现场最容易翻车的 5 个怪问题4.1 字节序不对读出来的数据像被倒着念ModbusTCP 所有多字节字段都是大端序事务 ID 高字节在前长度字段的高字节也在前。ESP32 是小端 CPU有些人习惯直接从 buffer[4] 当长度低位读解析出来的 Length 就不是 0x000A而是 0x0A00变成 2560。于是完整帧长度被算错组装器永远等不齐一帧程序卡死在半包状态里。我的习惯是封装一个统一的读函数uint16_t readU16(uint8_t* buf, int off) { return (buf[off] 8) | buf[off 1]; }所有事务 ID、长度字段、协议 ID、寄存器地址和数量全部走这个函数读。从源头杜绝手写字节序错误。PDU 里的数据区如果是寄存器值也要按大端规则解析。4.2 事务 ID 对不上响应和请求错位客户端发出请求后响应报文里的事务 ID 必须和请求一致。问题在于如果你用同一个 TCP 连接连续发多条请求响应回来的顺序未必和请求顺序完全相同尤其是某一请求超时后你又重发旧响应和新响应可能同时到达不检查事务 ID 就会张冠李戴。解决方案有两种第一种最简单同一时间只允许一条在途请求发出后必须等到完整响应再发下一条否则阻塞第二种是在业务层维护一个事务 ID 队列响应回来后先匹配 ID 再处理。对 ESP32 做工业数据采集来说第一种方案稳定且省事我更推荐。4.3 长度字段异常缓冲区被“饿死”或“撑爆”如果对端实现不规范或者链路里混入了错误字节Length 字段可能读出 0xFFFF 这种极值。你缓冲区只有 512 字节expectLen 算出 665535那永远等不到完整帧整个接收链路就停摆了。所以组装器里 Length 的合法性检查必须做。建议根据业务场景收紧范围读保持寄存器响应一般不会超过 1 1 250 个数据字节所以 Length 上限 253应答线圈也有类似的点数上限。只要 Length 超过预期范围立刻 reset 并丢弃当前缓冲并在日志里打印错误帧的十六进制内容方便定位对端异常。4.4 Wi-Fi 重连后残留脏数据拼出来的帧全是乱的ESP32 用 Wi-Fi 做 ModbusTCP 时连接断开重连极其常见。我在一个项目里遇到过PLC 和 ESP32 的连接一段时间没有数据路由器把连接断了PLC 自动重连重连后 ESP32 收到了上一次连接遗留的半截数据组装的帧完全错乱响应的事务 ID 也对不上。解决办法只有一条每次建立新连接、接受新连接或者调用 connect() 成功后必须主动调用 assembler.reset()把 recvLen、expectLen 全部清零。别小看这一行它能消灭一大批只在重连几次后才出现的偶发问题。4.5 多客户端并发组装器状态被互相污染上面已经说过了全局组装器不能用于多客户端 server。每条连接必须一份独立状态。在代码结构上我建议把组装器作为一个成员变量放进每个连接的对象里。连接清理时顺便把组状态重置避免内存碎片。到这里我给你整理一个高频问题的速查表现场调试时可以拿来对照现象可能原因排查方向收包永远在半包状态Length 解析字节序反了打印 buffer[4]、buffer[5] 原始十六进制能拼出帧但响应总错事务 ID 没匹配检查请求和响应事务 ID 是否一致重连后乱帧残留缓冲区没清连接建立后调用 assembler.reset()并发连接后互相污染组装器实例共用每条连接独立组装器Length 异常巨大对端协议实现问题或误入错误字节做 Length 合法范围检查并重置5. 分片缓存之后还想再省点事怎么做5.1 把业务解析和组装器分开很多初学者把 Modbus 业务解析直接写在 handleFullFrame 里框架一跑起来缓冲区逻辑和寄存器读写逻辑缠在一起很难维护。我建议组装器只负责“切分字节流吐出一整帧”所有 Modbus 业务、事务 ID 匹配、超时重传全放到上层。这样一来组装器可以用单元测试充分验证把半包、粘包、错序字节组合成测试用例确认每一条 ADU 边界都正确。业务层则可以独立测试读写逻辑。两层各干各的可维护性会好很多。5.2 如果项目需要请求队列和并发控制如果你要在一条 TCP 连接上同时跑多个读请求、写请求甚至周期性地轮询不同寄存器区光有分片缓存还不够。你还需要一个请求队列。队列里记录每条请求的事务 ID、超时时间、重试次数响应回来之后按事务 ID 匹配匹配不到就继续等待。这一步做到位整个通信层才算真正工程化而不是“能跑通就行”。我在实际跑项目的体会是ModbusTCP 协议本身不复杂难就难在 TCP 这个字节流和多变的 Wi-Fi 环境。把“分片缓存”想明白相当于在通信层最脏的地方立起了一道栅栏。后面叠加连接管理、请求队列、超时控制都会顺畅很多。最后再分享一条小技巧如果你在 Arduino 框架下开发记得把 WiFiClient 的超时时间设置一下否则 read() 在阻塞模式下可能卡住整个任务调度。别问我怎么会知道问就是我曾经盯着一行卡死的 client.read() 看了半小时。