☰
ESP32 ModbusTCP分片缓存:彻底解决半包粘包问题
2026/10/25 1:07:28 网站建设 项目流程

如果你的 ESP32 项目要跟 ModbusTCP 打交道,迟早会撞上“分片缓存”这四个字所描述的场景:你从 TCP 连接里 read 到的数据长度,跟 Modbus 报文长度对不上。明明发过来一条完整请求,你 read 一次只拿到 7 个字节;有时候你以为处理完一条,结果缓冲区里还躺着三四条报文没解析。这种半包、粘包轮番出现的情况,就是这个项目标题里“分片缓存”要解决的核心问题。

我是在一个真实项目里被这个问题教育过的。ESP32 作为 ModbusTCP server 接一台 PLC,PLC 每 100ms 周期读一批保持寄存器。刚开始一切正常,跑了大概 20 分钟,日志里跳出一条异常:recv len=7,而一条完整 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 连 PLC,PLC 长时间周期性读写寄存器;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 下面。AAPL,PLC 做主站主动连上来,它发出的读请求可能是 12 字节,写请求可能是 20 字节,请求长度本身就不固定,而且 PLC 的 TCP 栈同样会把请求拆开。作为 server,你如果不做分片缓存,收到的就是零零碎碎的字节,必须先把完整请求拼出来,才能处理功能码、寄存器地址、数量这些业务字段。

再细一点说,同一个 TCP 连接上可能先后到来多条请求,如果代码写成“read 到多少字节就立刻当一帧处理”,很快就会出现事务 ID 错位、功能码被拆开、寄存器地址被切到两帧里的情况。所以客户端、服务器端都必须做分片缓存。

还有连接生命周期的问题。PLC 可能长时间保持连接,也可能每隔几分钟重连一次。重连之后,如果上一次连接缓冲区里还有没消费完的残留字节,会被当成新请求处理,后果比丢一帧还尴尬,可能直接触发异常响应。这一点我后面在避坑环节会再强调。

提示:处理 ModbusTCP 的正确姿势,不是去猜“TCP 会怎么切包”,而是每次 read 到的字节都放进一个组装器,由组装器根据 Modbus 报文头部的长度字段判断“够不够一帧”。这就是分片缓存机制的核心。

2. 动手前必知的报文结构:MBAP 报头和 PDU

2.1 MBAP 报头里,哪几个字段最关键

ModbusTCP 的报文结构叫 ADU,由两部分组成:最前面的 7 字节是 MBAP 报头(Modbus Application Protocol header),后面跟着 PDU(协议数据单元)。MBAP 四个字段我列了张表,你在代码里要跟它打交道,建议把这张表抄在工位旁边:

偏移字节数字段名说明
02事务标识符 Transaction ID请求和响应必须一致,用于匹配
22协议标识符 Protocol IDModbusTCP 必须为 0
42长度 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 个字节后解析出 Length=23,就知道这条响应共 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]; // 然后把 buf+8 到 buf+len-1 作为数据区处理 } };

代码里最要注意的两处:第一,expectLen 是用 6 + Length,不是 7 + Length;第二,处理完整帧之后要 memmove 剩余字节到缓冲区头部,这样下一轮组装才能正确衔接。

提示:handleFullFrame 里的业务逻辑我留空了。建议你先把 transId 和 len 打印出来,跑通半包粘包测试后再加寄存器读写业务,避免定位问题时分不清是分片组装的问题还是业务解析的问题。

3.4 把组装器接进 TCP Client 的读取循环

有了这个类,主循环非常简单。假设你用的是 Arduino 框架的 WiFiClient:

WiFiClient 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 算出 6+65535,那永远等不到完整帧,整个接收链路就停摆了。

所以组装器里 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() 看了半小时。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询