ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ESP32 ModbusTCP分片缓存实战:从半包粘包到状态机完整拆解

ESP32 ModbusTCP分片缓存实战:从半包粘包到状态机完整拆解

做过串口Modbus的老手,第一次把程序搬到ESP32上用ModbusTCP通信时,很容易踩一个很隐蔽的坑:上位机发来的请求或设备返回的响应,明明在抓包软件里看是完整一帧,可代码里一处理就只剩半截。而且问题不是每次都能复现,经常是数据一长就冒出来,短报文却一直稳定。排查到最后,根子往往就落在“分片”两个字上——TCP是流式传输,一个应用层的ModbusTCP帧在网络上可能被切成好几个片段,接收端必须自己负责缓存和拼帧。这篇文章就围绕ESP32下的ModbusTCP分片缓存,把问题从原理到实现完整拆开,适合正在做ESP32工业采集网关、ModbusTCP从站或主站的朋友参考。

1. 现场丢帧现象的排查起点:ESP32 的 ModbusTCP 为什么非得分片缓存不可

1.1 现象记录:短帧正常,长帧偶发错乱

之前在一个产线数据采集项目里,用ESP32做ModbusTCP从站,上位机大约100ms轮询一次,读取设备状态、温度、压力等几十个寄存器。最初程序沿用串口Modbus的思路:收到一帧,处理一帧,处理完立即回复。读取16个寄存器、响应帧不到40字节,跑了几个小时都很稳。

后来上位机那边要一次性读125个寄存器,响应帧长度从几十字节涨到200字节以上,问题立刻出现:上位机会偶发超时,甚至读到一部分是上一帧的数据,一部分是当前帧的数据。一开始怀疑是电磁干扰、布线问题,后来在电脑上用抓包软件监控,发现ESP32发出的响应在TCP层被拆成了两个segment,对端接收时,第一次recv只拿到了前半段,第二次recv才拿到后半段。而程序里恰恰是把这个recv的返回值当成完整一帧去解析的,前半段不足一帧长度,解析逻辑自然就错了。

1.2 三个层面的根本原因

第一个层面,TCP协议本身没有“消息边界”。应用层调用一次send发出去的一个完整ModbusTCP帧,在传输层可能被拆成多个TCP段(segment),也可能和别的帧合并到一起。MTU、MSS、对端Nagle算法、中间设备缓冲策略都会影响拆分方式。也就是说,不能指望“每次recv都刚好返回一个完整应用帧”。

第二个层面,ESP32使用的LWIP协议栈和socket接收语义也放大了这个问题。read(sock, buf, sizeof(buf))返回的是当前socket接收缓冲区里可读的字节数,这个值可能比你要的短,也可能比你要的长。如果直接把返回值当成一个Modbus帧处理,半包会被误判为完整包,粘包时又会丢帧。

第三个层面和MCU的硬件资源有关。ESP32的可用RAM有限,每个TCP连接动不动开8KB、16KB接收缓冲区是不现实的。上位机可以豪横地给每个socket分配大缓冲,嵌入式设备必须在内存占用和协议解析之间取一个平衡。所以“分片缓存”不是一个可选项,在ESP32的ModbusTCP实现里,它是必须做的事情。

2. 动手前必须吃透的两样东西:MBAP 帧结构 与 TCP 流边界

2.1 MBAP头里的长度字段是分片缓存的“路标”

ModbusTCP的帧结构分为MBAP头(Modbus Application Protocol header)和PDU(Protocol Data Unit)两部分。前6字节的MBAP头是整个分片缓存方案的基石,字段定义如下:

字段字节数说明
事务标识符 Transaction Identifier2用于匹配请求和响应,由发起方生成
协议标识符 Protocol Identifier2ModbusTCP固定为0x0000
长度 Length2后续字节数,从单元标识符开始算起
单元标识符 Unit Identifier1相当于串口从站地址

关键在这个Length字段。它的含义是“本帧中除MBAP头之外还有多少字节”,也就是单元标识符、功能码、数据三部分的总长度。那么一个完整ModbusTCP帧的总字节数就是:

total_frame_len = 6 + Length

比如上位机发来一个读保持寄存器的请求:事务ID(2) + 协议ID(2) + 长度字段(2) + 单元ID(1) + 功能码03(1) + 起始地址(2) + 寄存器数量(2),此时Length = 1 + 1 + 2 + 2 = 6,帧总长 = 6 + 6 = 12字节。而响应帧的数据区多了一个“字节计数”字段,Length会更大。

所以在分片缓存中,我们的目标非常明确:先拼命凑齐那6字节MBAP头,解析出Length,就能算出整帧应该有多长,然后按这个目标值继续凑数据。这个过程和拼图一样,MBAP头就是拼图包装盒上的完成图。

2.2 为什么不能假设一次recv能读完整帧

这里还要深入理解LWIP和socket层的行为。ESP32的以太网或WiFi数据最终都要经过LWIP协议栈,TCP协议栈会把数据按接收窗口、MSS等条件分段,而LWIP的socket接收缓冲足够大时,多个TCP段也可能被合并成一次recv返回。

也就是说,实际面对的数据流长这样:

[ 帧1前4字节 | 帧1后8字节 | 帧2前若干字节 | ... ]

一次recv可能只覆盖其中一部分,也可能跨多个帧。所以接收侧处理的基本单元应该是“字节流”,我们要做的事是:从字节流中识别出完整的ModbusTCP帧边界,并把所有不完整的数据缓存下来。这和水管送水、瓶子装水是一个道理:TCP数据流是水管里的连续水流,ModbusTCP帧才是瓶子,水里没有瓶子的标记,必须自己在进水口根据长度字段来切瓶子。

2.3 分片缓存的核心任务就三个词

把上面的分析收敛一下,分片缓存要解决的无非三件事:缓存不完整的帧、识别出帧的完整长度、拼好一帧后交给业务层解析。缓存不完整帧时要注意,帧可能被任意切分,所以缓存必须能接受“任意长度的追加数据”;识别完整长度依赖MBAP头里的Length字段;拼好帧后还要处理“一次recv带出多余数据”的残留问题。后面的代码就是围绕这三个任务展开的。

3. 三类实现方案对比:一次性大缓冲、环形队列、链式分片缓存

3.1 方案A:每个连接分配最大帧长的一次性缓冲

最直觉的做法是给每个TCP连接分配一块固定大小的缓冲区,大小设为所有可能出现的Modbus帧里的最大值。收到数据就往里填,填满一个帧就处理,然后清空复用。

这个方案的优点只有一条:结构简单,适合帧长固定或者很小的场景。但它的缺点在ESP32上很致命。第一,ModbusTCP一帧最大理论长度并不小,一些扩展功能码、批量读写场景下帧长可能超过1KB,如果开了10个连接,光缓冲就要占10KB以上,ESP32的总SRAM本来就紧张。第二,它并没有真正解决问题,因为半包仍然是半包,程序还是要判断“填了多少字节、还差多少字节”,本质上还是要在代码里做一次状态管理。第三,如果出现一帧比预设最大值还长的数据,缓冲区直接溢出,这是最容易引入内存破坏的地方。

所以方案A只适合那种“帧长上限明确且很小”的私有协议,拿来对付通用ModbusTCP不够稳妥。

3.2 方案B:环形缓冲区

环形缓冲区在串口和网络协议栈里非常常见,长度固定,读写指针循环移动,内存零碎片,性能好。每个连接维护一个环形队列,数据进来先入环,再由解析逻辑从环里一点点取。

环形缓冲区的缺点在于ModbusTCP应用场景的复杂性。ModbusTCP帧长度不固定,响应和请求交替出现,同一个TCP连接上可能前一帧还没处理完,下一帧数据又进来了。环上会同时存在“上一帧残留”“当前帧半截”“下一帧开头”三种数据,解析器需要记录帧起点、长度、已解析字节数,逻辑链比较绕。另一个麻烦是:如果环的长度小于一帧的最大长度,这一帧就会写不进去,必须扩容或放弃,比链式方案多了限制条件。

如果只做“单帧交互、一次收一个请求回一个响应”的简单场景,环形缓冲区是够用的;但设计一个健壮的通用ModbusTCP网关,我个人更推荐第三种方案。

3.3 方案C:固定块池 + 链式分片缓存(最终选择)

这个方案的思路是:准备一批固定大小的内存块,比如每块128字节,通过链表串起来。接收数据时,把数据按块复制到链表尾部;帧组装时,沿着链表扫描拼接成完整帧。如图:

block0(128B) -> block1(128B) -> block2(64B有效)

每帧占多少块可以动态扩展,用完归还到池里,天然避免了碎片。选择它有三个原因:

  • 内存利用率高。按实际帧长取块,帧短就少占内存,帧长可动态增长,不会出现整块大缓冲被小帧浪费的问题。
  • 固定块池在MCU上容易管理。ESP32的堆经过频繁malloc/free后会产生碎片,而固定大小块池的分配回收非常规整,不会碎。
  • 状态机实现更直观。头6字节单独缓存,Length解析出来后,按Length决定再取多少块,逻辑和帧结构一一对应,排查问题容易。

代价只是指针操作多一点,在工业轮询这种百毫秒级交互频率下完全不是瓶颈。

4. 分片缓存的关键实现:从接收到拼帧的状态机

4.1 数据结构:帧上下文与固定块池

设计上分为两层:一是全局固定块池,二是每个连接的帧上下文。帧上下文负责记录当前正在拼装的帧处于哪个阶段,以及已经缓存了多少字节。

#define BLOCK_SIZE 128 #define BLOCK_NUM 12 #define MAX_FRAME_BODY (BLOCK_NUM * BLOCK_SIZE) // 上限 1536 字节,够用 typedef struct seg_block { struct seg_block *next; uint16_t len; // 本块有效数据长度 uint8_t data[BLOCK_SIZE]; } seg_block_t; // 全局块池(静态数组,不用动态malloc) static seg_block_t block_pool[BLOCK_NUM]; static seg_block_t *free_list; // 每个TCP连接的帧上下文 typedef struct { uint8_t state; // 0=等待头部, 1=等待数据体 uint8_t head_buf[6]; uint8_t head_len; // 已收到的头字节数 uint16_t length_field; // MBAP中的Length字段 uint16_t body_received; // 数据体已收到字节数 uint16_t body_target; // 数据体目标字节数,即 length_field seg_block_t *list_head; seg_block_t *list_tail; } mb_frame_t;

块池初始化很简单,把静态数组里的块全部串到空闲链表上,取块和还块都只操作链表头,O(1)完成。注意取块和还块必须保证互斥,我用的是一个简单的临界区保护,多任务环境下不会同时操作free_list。

4.2 核心处理函数:rx_feed

这个函数接收任意长度的一段数据(无论是一次recv的返回值,还是残缺的片段),内部驱动状态机前进。整体流程分为三段:先凑头,再解析Length,最后凑数据体。

// 从空闲池取一块 static seg_block_t *pool_alloc(void) { seg_block_t *blk = free_list; if (blk) { free_list = blk->next; blk->next = NULL; blk->len = 0; } return blk; } // 归还一块到空闲池 static void pool_free(seg_block_t *blk) { blk->next = free_list; free_list = blk; } // 清空帧上下文,释放所有缓存块 static void mb_frame_reset(mb_frame_t *f) { while (f->list_head) { seg_block_t *tmp = f->list_head; f->list_head = f->list_head->next; pool_free(tmp); } f->list_head = f->list_tail = NULL; f->state = 0; f->head_len = 0; f->body_received = 0; f->body_target = 0; } // 向帧上下文追加一段数据,返回当前已缓存的数据体总字节数 static uint16_t mb_frame_append(mb_frame_t *f, const uint8_t *data, uint16_t len) { while (len) { if (f->list_tail == NULL) { f->list_tail = pool_alloc(); if (!f->list_tail) break; // 池耗尽 f->list_tail->next = NULL; f->list_head = f->list_tail; } seg_block_t *tail = f->list_tail; uint16_t space = BLOCK_SIZE - tail->len; uint16_t copy = len < space ? len : space; memcpy(tail->data + tail->len, data, copy); tail->len += copy; data += copy; len -= copy; f->body_received += copy; if (tail->len == BLOCK_SIZE) { seg_block_t *nb = pool_alloc(); if (!nb) break; // 池耗尽 nb->next = NULL; nb->len = 0; f->list_tail->next = nb; f->list_tail = nb; } } return f->body_received; }

然后是状态机的驱动入口,这是整个分片缓存的核心逻辑:

// 把socket读到的chunk喂给分片缓存 // 如果拼出一帧完整响应,返回帧长度;否则返回0 uint16_t rx_feed(mb_frame_t *f, const uint8_t *chunk, uint16_t len) { while (len) { if (f->state == 0) { // 阶段1:凑6字节MBAP头 uint16_t need = 6 - f->head_len; uint16_t copy = len < need ? len : need; memcpy(f->head_buf + f->head_len, chunk, copy); f->head_len += copy; chunk += copy; len -= copy; if (f->head_len < 6) { return 0; // 头还没齐 } // 解析长度字段(大端序) f->length_field = (f->head_buf[4] << 8) | f->head_buf[5]; if (f->length_field == 0 || f->length_field > MAX_FRAME_BODY) { // 长度异常,丢弃重来 mb_frame_reset(f); return 0; } f->body_target = f->length_field; f->body_received = 0; f->state = 1; } else { // 阶段2:凑数据体,目标是 length_field 字节 uint16_t need = f->body_target - f->body_received; uint16_t copy = len < need ? len : need; mb_frame_append(f, chunk, copy); chunk += copy; len -= copy; if (f->body_received >= f->body_target) { // 这一帧完整了,取出交给业务层 uint16_t frame_len = f->length_field + 6; // ... 业务层解析 f->head_buf[0..5] 和链表里的数据体 ... // 解析完成后: mb_frame_reset(f); // 注意:如果 len 还有剩余,说明是粘包里的下一帧,继续循环 if (len) { continue; } return frame_len; } return 0; } } return 0; }

这个函数最值得注意的一点,是while (len)循环和continue的配合。如果一次recv带回的数据超过一帧,比如同时包含完整的帧A和帧B的前半段,那么在帧A拼好后立刻清空上下文继续处理剩余数据,帧B的半截不会丢,而是自然进入下一轮头缓存。这就是粘包问题的处理基础。

4.3 数据体链表上的落盘与业务解析

数据体存放在链表中,业务解析时没必要再拷贝成连续缓冲,可以直接沿着链表读。比如要解析读保持寄存器的响应,前3字节是单元ID、功能码、字节计数,后面每个寄存器占2字节:

uint16_t mb_frame_read_u16_at(mb_frame_t *f, uint16_t offset) { seg_block_t *blk = f->list_head; uint16_t pos = 0; while (blk) { if (pos + 1 <= offset && blk->len > offset - pos) { uint8_t hi = blk->data[offset - pos]; uint8_t lo = blk->data[offset - pos + 1]; return (hi << 8) | lo; } pos += blk->len; blk = blk->next; } return 0; }

在寄存器数量不太多、解析逻辑不复杂时,直接访问链表完全够用。如果有人希望业务层拿到连续的uint8_t *,可以在拼帧完成后动态分配一块连续buffer再memcpy,但ESP32上我建议能省一次拷贝就省一次,毕竟每次拷贝都是对CPU周期和内存的双重消耗。

4.4 发送侧同样存在“分片”问题

很多人只关心接收侧分片,忽略了发送侧。ESP32的socket send并不保证一次发送完整帧。它返回的是实际写入TCP发送缓冲的字节数,在发送缓冲紧张或数据较长时,这个返回值会小于len。如果程序只调用一次send就对结果不管了,上位机会收到截断的响应。

发送侧的正确打开方式是循环发送:

static int send_all(int sock, const uint8_t *buf, uint16_t len, uint32_t timeout_ms) { uint16_t sent = 0; while (sent < len) { int ret = send(sock, buf + sent, len - sent, 0); if (ret > 0) { sent += ret; } else if (ret == 0 || (ret < 0 && errno == EAGAIN)) { vTaskDelay(pdMS_TO_TICKS(10)); // 发送缓冲暂时满了,等一下 if (--timeout_ms == 0) return -1; } else { return -1; } } return sent; }

这一点从根上讲,其实是同一个问题的镜像:TCP把一个大PDU拆成多段发送,接收端需要分片缓存来重组;发送端也需要循环发送来对抗TCP发送缓冲的容量限制。

5. 反复踩过的坑:粘包、半包、超时、内存碎片

5.1 粘包:一次recv带回了1.5个帧

我最初版本的rx_feed在处理完一帧后直接break返回,结果就是每两帧中必丢一帧。这点在做协议栈时特别容易忽略,因为从串口调试的习惯来看,“一次中断里收到完整一帧”是共识,但TCP没有这个保证。一次recv带回255字节,里面可能是帧A完整(120字节)+帧B前3字节。

修复后的思路在第4.2节已经体现:处理完当前帧后不跳出,继续用剩余数据跑状态机。你会发现这个处理方式让粘包问题自然消失,不需要额外判断。

5.2 半包:头部不足6字节时的处理

帧被切得最碎的情况是一次recv只有2字节,甚至只有1字节。这种情况下head_len小于6,状态机停在等待头阶段,返回0,主循环继续调recv。这里有个必须注意的点:不要把head_len清零,必须累加。有同事就是在这写了个head_len=0的重置逻辑,导致TCP重传的字节永远凑不满头。

5.3 超时与半帧残留

如果对端发了一半就不发了,帧上下文会一直占着缓存块。必须给接收加超时保护。ESP-IDF下可以在socket上配置SO_RCVTIMEO,也可以自己在任务里维护“上次收到数据的时间戳”,超过某个阈值就调用mb_frame_reset清掉残留。

超时值我实测用400ms比较合适。上位机100ms轮询,TCP把帧切到最碎,两次片段到达间隔通常不超过几十毫秒;如果400ms还没凑齐,几乎可以断定链路异常或对端协议栈出问题了。注意:这个超时和Modbus应用层的响应超时(通常1s)是两个东西,别混用一个定时器。

// 在recv之前或之后检查时间戳 if ((now_tick - last_rx_tick) > pdMS_TO_TICKS(400)) { mb_frame_reset(&frame_ctx); }

5.4 块池耗尽时不能死等

固定块池总数是有限的,如果遇到超大帧或者多个连接同时使用,可能出现pool_alloc返回NULL的情况。这时候我的策略是:宁可丢弃当前帧,也不能让内存池耗尽导致整个任务崩溃。丢弃后帧上下文重置,TCP协议栈会重传数据,应用层最多损失一次轮询周期。等下一轮数据到达时,块池大概率已经释放出足够的块。

这个地方有一个经验:块池的总容量最好按“最大预期帧长+并发连接数+2块余量”计算,而不是按平均帧长算,否则传输大帧时必现丢帧,而且很难定位。

6. 缓冲参数怎么定、任务架构怎么配:最终权衡

6.1 块大小与总数的计算

块大小和总数需要根据实际业务帧长来定。我常用的一套参数如下:

参数取值说明
BLOCK_SIZE128字节多数Modbus帧在300字节以内,4块足矣
BLOCK_NUM16支持2~3个并发连接同时缓存大帧
MAX_FRAME_BODY2048字节超过此长度直接丢弃,保护内存

假设上位机每一个轮询周期读125个寄存器,响应帧约256字节,需要2个块存数据体;如果同时有3个连接各缓存一个帧,就是6块。加上粘包残留、半帧等待等场景,16块留有充足余量。如果你只做单连接、小寄存器量,12块足够;如果会批量读写很多寄存器,可以加大到20块。

块大小也值得斟酌。128字节在大多数场景下是好选择,因为大多数ModbusTCP帧在200~400字节之间,块太大浪费,块太小链表过长、指针操作增多。可以根据实际抓包得到的帧长分布,选一个能让“平均帧长 ÷ 块大小”接近整数的值,这样内存利用率最高。

6.2 单任务还是多任务:互斥与临界区的取舍

在ESP32上,我建议每个TCP连接的recv和rx_feed放在同一个任务里处理,不要拆成“接收任务+解析任务”。原因很简单:ModbusTCP的请求响应轮询模式天然是串行的,拆成两个任务只会引入锁和队列,反而增加逻辑复杂度和上下文切换开销。单任务循环内,rx_feed处理的是本连接的私有帧上下文,不需要加锁。

但块池是全局共享的,pool_alloc和pool_free必须保护。最简单的方式是用临界区(portENTER_CRITICAL)包住链表操作,或者用mutex。如果是多任务多连接,每个连接的任务都可能取块,临界区不能省。不过临界区要短,链表头切换就几行代码,完全合适。

6.3 再往深处走:多连接共享池和异常码处理的扩展

分片缓存本身不区分帧内容,所以它天然支持多连接。给每个连接分配一个独立的mb_frame_t,所有连接共享同一个块池,就能同时处理上位机、触摸屏、数据采集卡等多路ModbusTCP连接。块池共享时注意每个连接的最大帧长不要超过池总量的比例分配,否则一个连接的大帧可能把池吃光,饿死其他连接。

ModbusTCP没有链路层的CRC校验,因为下面的TCP已经做了可靠传输和校验,但这不代表应用层没有异常码。功能码返回0x80时带上异常码,比如0x02表示非法数据地址,解析层需要识别并向上位机反馈,这个逻辑属于PDU解析层,和分片缓存解耦,但设计时最好预留回调接口。

另外,如果你使用的是现成的Modbus协议栈(比如某个第三方库),它们的串口版本和TCP版本内部实现差异很大。串口版一般按字节中断接收,天然是“一字节一字节地喂”;TCP版则必须面对大块字节流。用第三方库时,最好先看一眼它的数据入口是“单包Poll”还是“流式Feed”,很多移植问题的根源就在这。

最后分享一下实际调试中的体会

这个分片缓存模块写完之后,我最推荐做的测试不是直接接真实设备,而是写一个Python脚本,把完整的ModbusTCP报文按随机长度切碎,再用TCPsocket分批发给ESP32,比如把255字节的帧随机切成[4,1,200,50]四段,乱序接收,看程序能否完整重建所有帧。我用这个办法在一次测试里发现了三处半包处理逻辑的边界问题,比接真实设备慢慢复现效率高得多。做串口转TCP或者任何基于TCP的私有协议时,这个方法都值得试一次,因为TCP面向字节流的特性决定了,任何“假设一次接收刚好一帧”的代码,在真实网络里都迟早会翻车。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进