说一个之前做项目遇到的怪事。某设备的数据采集底板用ESP32当主控,WiFi连局域网,上位机组态软件通过ModbusTCP来读寄存器。单读两三个寄存器时一切正常,但只要一次读的寄存器数量一多,比如一次读120个保持寄存器,上位机就时不时报“超时”或“无响应”。最初排查方向全放在无线链路上:改信道、调发射功率、查供电纹波,折腾了两三天没结果。后来用抓包工具看了半天TCP会话,才算找到真正的根子——ESP32这边的socket接收处理根本没有把TCP流里的ModbusTCP报文完整拼出来,响应丢在了应用层组帧这一步。
这篇就专门写这个坑:在ESP32上用ModbusTCP通信时,为什么必须在代码里做一层“分片缓存”,以及这套缓存机制应该怎么设计、怎么实现、怎么处理各种异常。先说清楚,这里的“分片缓存”不是IP层分片重组,而是应用层把TCP字节流重新拼装成完整ModbusTCP帧的缓冲区和配套逻辑。整篇基于ESP-IDF环境,用BSD socket API实现,Arduino环境下逻辑照样能搬,只是套接字写法略有差别。
1. 一次读多寄存器引发的数据错乱:分片缓存到底解决什么问题
1.1 现场现象:寄存器读得越多,上位机越容易“失联”
那个项目里,上位机是通过ModbusTCP读取ESP32上映射的设备数据。开始时测的都是简单寄存器,一次读几个,响应帧很小,比如读3个保持寄存器,整个响应也就17个字节左右,上位机每次都能正常解析。后来把设备参数表调大,改成一次读120个保持寄存器,响应帧一下子涨到两百多字节,问题就冒出来了。
有趣的是,这种失败不是每次都发生。跑十分钟可能一切正常,然后突然连续几次超时,上位机那边甚至会把连接断开,等几秒又能自动恢复。用串口日志看ESP32端,socket连接本身没有异常,TCP链路也没有断,就是业务层一直没有给上位机回包。
我一度怀疑WiFi信号抖动或者发送缓冲区不够,加了重传机制也没用。直到我在上位机和ESP32中间加了一道透明的TCP转发,把双方原始数据流同时打出来,才看清真相:ESP32其实把响应发出来了,但发送时没有一次性写完整个帧,而是分了几次写;上位机那边也并没有一次收齐,TCP把它拆成了好几段,最终解析端拿到的是一段不完整的数据,自然就废弃了。
1.2 问题的本质:TCP流没有应用层消息边界
很多人第一次写socket通信时,都会默认“send一次对应recv一次,recv返回的就是一整个消息”。这个假设在UDP里勉强说得通(UDP有消息边界),但在TCP里完全不成立。TCP是一个面向字节流的协议,它只保证字节顺序和可靠性,不保证应用层的消息边界。
也就是说,ESP32的socket调用一次recv(),返回的数据可能只是一个完整ModbusTCP帧的前半段;也可能一次返回好几个完整帧;还可能是半个帧加下一个帧的前几个字节。WiFi链路上尤其明显,802.11帧、lwIP的pbuf、TCP滑动窗口都会影响应用层拿到的数据分段情况。
给一个更直观的类比:TCP像一条传送带,ModbusTCP报文是放在传送带上的纸箱。传送带不保证一个箱子完整地停在你面前,可能半截箱子先到,也可能两个箱子叠在一起滑过来。应用层要做的,就是站在传送带出口,自己判断“这个箱子完整了没有”“这是不是一个新箱子的开头”,然后把它们正确地拼起来。
1.3 分片缓存的定位:协议栈与业务逻辑之间的“拼帧层”
所谓分片缓存,就是在socket之上、Modbus业务解析之前,加一层专门负责拼帧的逻辑。每次recv()到的裸字节先进入一个固定缓冲区,缓冲区里攒够一个完整ModbusTCP帧后,才交给协议解析函数;如果不够一帧,就把数据留在缓存区里等下一批;如果缓冲区里有多帧,就一帧一帧拆出来,剩下的继续留在缓冲区。
这套逻辑做好之后,业务层不管是解析请求还是解析响应,拿到的永远是一段完整的ModbusTCP帧,再也不用关心底层TCP把它切成了几块。整个分层非常清晰:socket负责传输字节流,分片缓存负责切帧,业务逻辑负责协议语义。
2. 先看懂MBAP头:长度字段是分片缓存的“指路标”
2.1 ModbusTCP帧的七个字节头部(准确说是前六个字节起关键作用)
要写组帧逻辑,第一步就是精确掌握ModbusTCP的报文结构。经典说法是MBAP头7字节,包括事务标识符2字节、协议标识符2字节、长度字段2字节、单元标识符1字节。但这里有个非常容易算错的地方:长度字段表示的是“从单元标识符开始一直到帧尾”的字节数,事务标识符和协议标识符不算在内。
所以程序里真正要用来判断帧长的,是前6个字节:事务ID(2字节)、协议ID(2字节)、长度(2字节)。拿到这6个字节后,从偏移4和偏移5读出长度值,就能提前知道“这个帧总共有多少字节”。
| 字段 | 长度 | 偏移 | 说明 |
|---|---|---|---|
| 事务标识符 Transaction ID | 2字节 | 0~1 | 请求/响应匹配用,从站响应时原样复制 |
| 协议标识符 Protocol ID | 2字节 | 2~3 | ModbusTCP固定为0x0000 |
| 长度 Length | 2字节 | 4~5 | 后续Unit ID + PDU的字节数 |
| 单元标识符 Unit ID | 1字节 | 6 | 网关多从站寻址用 |
| 功能码与数据 | 1+N字节 | 7起 | 具体由功能码决定 |
实现时要注意区分“MBAP头7字节”和“完整帧总长度 = 6字节固定头 + Length字段值”这两个概念。很多人把单元标识符既算进7字节头部,又算进Length,最后总长度多算了1个字节,解析永远错位。
2.2 最大帧长260字节的计算方法
Length字段在ModbusTCP里的合法范围是1~254,因为PDU最大253字节,单元标识符1字节,加起来254。这里说的PDU就是功能码加数据部分。
最大帧长的典型场景是读取125个保持寄存器:
- 响应数据部分:字节计数字段1字节 + 125个寄存器数据250字节 = 251字节
- PDU:功能码1字节 + 数据251字节 = 252字节
- Length:单元标识符1字节 + PDU 252字节 = 253字节
- 完整帧总长度:6字节固定头 + 253 = 259字节
所以把接收缓冲区开到260字节,基本能覆盖所有合法的ModbusTCP帧。有些资料把上限写成256字节,那是没算上功能码和单元标识符的位数,真遇到读大块寄存器时会溢出。
2.3 为什么长度字段能作为组帧依据
分片缓存的核心思路,就是“先读固定长度的头,从头部里读出长度字段,再按长度等剩下的数据”。这和串口ModbusRTU的做法完全不同,RTU没有自描述长度的字段,只能靠时间间隔判断帧边界;ModbusTCP因为有Length字段,等于每个帧自带“总长度说明书”,组帧逻辑就简单可靠得多。
每次recv()拿到新数据后,第一件事是检查缓冲区里是不是已经攒够6个字节。如果够,就解析出Length值,计算出期望总长度;然后继续收,直到缓冲区长度达到期望总长度,才把这一帧完整拿走。整个过程就是一个小小的状态机。
3. 接收状态机的实现:从socket原始流到完整报文
3.1 接收缓冲区与状态定义
先定义接收结构体,每个TCP连接实例都维护一份,字段要带状态标记、缓冲区、已收长度、期望长度和最近收包时间。
#define MODBUS_MBAP_LEN 6 #define MODBUS_TCP_MAX_LEN 260 #define MODBUS_TCP_BODY_MAX 254 typedef enum { MODBUS_RX_STATE_WAIT_MBAP = 0, // 等待前6字节头部 MODBUS_RX_STATE_WAIT_BODY // 等待Length所指示的剩余数据 } modbus_rx_state_t; typedef struct { int sock; // 对应的socket uint8_t buf[MODBUS_TCP_MAX_LEN]; // 分片缓存区 uint16_t buf_len; // 当前已缓存字节数 uint16_t expect_len; // 当前帧期望总长度 modbus_rx_state_t state; // 组装状态 uint32_t last_ts; // 最后一次收到数据的时间戳(ms) } modbus_tcp_rx_t;缓冲区上限就取260字节。有人问为什么不做动态分配,在这个场景下完全没必要:ModbusTCP帧长上限固定,静态数组就能保证结构体占用恒定,方便多连接各自维护,也不会产生碎片。
3.2 核心代码:modbus_tcp_feed 的完整逻辑
接收处理函数是整套机制的心脏。思路是:每次recv()到的字节先追加进缓冲区,然后进入一个while(1)循环,反复尝试组帧。循环不退出,是为了处理粘包时一次recv()拿到多帧的情况,一帧取走之后继续看剩下数据能否成帧。
int modbus_tcp_feed(modbus_tcp_rx_t *rx) { uint8_t tmp[256]; int n = recv(rx->sock, tmp, sizeof(tmp), 0); if (n == 0) { return -1; // 对端关闭 } if (n < 0) { int e = errno; if (e == EAGAIN || e == EWOULDBLOCK) { return 0; // 接收超时,没有数据 } return -1; // 其他socket错误 } // 防止缓冲区溢出;正常不会发生,一旦发生说明同步已丢失 if (rx->buf_len + n > sizeof(rx->buf)) { rx->buf_len = 0; rx->state = MODBUS_RX_STATE_WAIT_MBAP; } memcpy(rx->buf + rx->buf_len, tmp, n); rx->buf_len += n; rx->last_ts = xTaskGetTickCount() * portTICK_PERIOD_MS; while (1) { if (rx->state == MODBUS_RX_STATE_WAIT_MBAP) { if (rx->buf_len < MODBUS_MBAP_LEN) { return 0; // 头部还没凑齐,继续等 } // 协议标识符必须为0,否则视为非法数据 if (rx->buf[2] != 0 || rx->buf[3] != 0) { rx->buf_len = 0; rx->state = MODBUS_RX_STATE_WAIT_MBAP; return -2; // 错帧,已复位 } uint16_t body_len = ((uint16_t)rx->buf[4] << 8) | rx->buf[5]; if (body_len < 2 || body_len > MODBUS_TCP_BODY_MAX) { rx->buf_len = 0; rx->state = MODBUS_RX_STATE_WAIT_MBAP; return -2; // Length非法,已复位 } rx->expect_len = MODBUS_MBAP_LEN + body_len; rx->state = MODBUS_RX_STATE_WAIT_BODY; } if (rx->state == MODBUS_RX_STATE_WAIT_BODY) { if (rx->buf_len < rx->expect_len) { uint32_t now = xTaskGetTickCount() * portTICK_PERIOD_MS; if (now - rx->last_ts > 3000) { rx->buf_len = 0; rx->state = MODBUS_RX_STATE_WAIT_MBAP; return -3; // 半包超时,已复位 } return 0; // 整帧还没凑齐,继续等 } // 攒够一帧,交给业务解析 process_modbus_frame(rx->buf, rx->expect_len); // 剩余数据前移,作为下一帧的起点 uint16_t remain = rx->buf_len - rx->expect_len; if (remain > 0) { memmove(rx->buf, rx->buf + rx->expect_len, remain); } rx->buf_len = remain; rx->state = MODBUS_RX_STATE_WAIT_MBAP; } } return 0; }注意这里用的是memmove而不是memcpy,因为源地址和目标地址有重叠,memcpy在重叠场景下是未定义行为。最后一个return 0其实不会执行到,写在这里只是让编译器不再报警告。
3.3 状态机为什么按“头6字节→长度→剩余字节”来切
这个状态机的聪明之处在于:它不需要等到“缓冲区满了”才知道帧长度,而是先读头部里的Length字段,计算出精确的目标长度,然后有目标地等待。每一步都非常节省内存,缓冲区最大260字节就够了。
如果把“等完整帧”简单实现成“攒够一个固定大小再判断”,会有两个问题:一是不知道固定大小设多少,设太小装不下大帧,设太大又浪费内存;二是无法处理粘包,因为一帧取走后剩余数据不知道从哪开始。现在用状态机按Length字段切,每一帧从哪里开始、到哪里结束都是精确的。
4. 半包、粘包、坏帧:异常分支的完整处理策略
4.1 半包等待与半包超时
半包是分片缓存最常遇到的场景。一个260字节的响应帧,在WiFi环境下很可能分两三次到达,第一次recv()可能只有100字节,第二次120字节,第三次才到齐。状态机天然支持这种情况:第一次数据进入后,状态从WAIT_MBAP切到WAIT_BODY,buf_len小于expect_len,直接返回0,等下一轮recv()继续填充。
真正要防的是“半包永远等不齐”。如果对端发了一半突然断线,或者网络长时间不返回剩余字节,缓冲区会一直留着一坨残缺数据。所以我在代码里加了时间戳检查:如果持续3秒没有新数据,而当前帧还没凑齐,就把缓冲区清空、回到WAIT_MBAP状态,避免脏数据无限残留。
实测下来,3秒这个门限对ModbusTCP足够宽。正常轮询周期通常几百毫秒以内,一帧数据不存在拆成几秒到达的情况;真到了3秒还收不齐,基本可以认定连接已经出问题,优先保证复位重同步。
4.2 粘包时的循环拆帧与剩余数据保留
粘包和半包正好相反:一次recv()拿到了多个完整帧。最常见的原因是上位机连续下发了多个请求,而TCP把它们合并到了一个段里。如果程序默认“一个包就是一帧”,这会直接解析出错误长度。
modbus_tcp_feed里的while(1)处理的就是这个场景。比如缓冲区里有两个完整的读请求,每个12字节,第一轮循环进行到WAIT_BODY,发现buf_len已经达到expect_len,就取走第一帧,然后用memmove把第二帧剩余的12字节挪到缓冲区开头,状态重置回WAIT_MBAP;第二轮循环发现头部够了,Length也合法,继续取走第二帧。整个过程一轮函数调用就完成了。
如果第二帧只到了8个字节,那第一轮取走第一帧后,剩余8字节不够构成头部,循环在WAIT_MBAP阶段返回0,下次recv()到了新数据再继续组装。这个“取完一帧、剩下的留下”的思路,是粘包处理的核心。
4.3 长度字段异常的同步恢复
还有一种情况比较少见但必须处理:缓冲区里根本不是ModbusTCP数据,或者同步已经完全错位。典型特征是协议标识符不是0,或者Length字段的值不在2~254的合法范围。
碰到这种情况,我不做复杂的滑动搜索,直接丢弃全部缓冲数据,状态复位。原因很简单:ModbusTCP帧头没有固定起始标记,没有像ModbusRTU那样可用的前导静默期,想从错位字节流里“找下一个可能的头部”非常容易误判,成功率不高。反正ModbusTCP帧的发送频率不高,丢一次重新同步,对实际业务影响很小。
4.4 recv返回值与超时设置的取舍
写socket接收时,recv()的返回值处理是基本功,但很多人会忽略阻塞超时。不设超时的话,recv()会一直阻塞在连接上,对端不传数据任务就卡死。建议用SO_RCVTIMEO设置200~500毫秒的超时,这样既能确保及时苏醒处理其他连接状态,又不会因为系统调用太频繁而浪费CPU。
超时返回时,lwIP下recv()返回-1,errno为EAGAIN或EWOULDBLOCK。我把它当成正常分支处理:返回0表示“暂时无数据”,任务继续循环。这样一轮循环的节奏大约是200毫秒一次,任务优先级只要不低于5,吞吐和延迟都能接受。
5. 多客户端连接下的缓存规划与内存预算
5.1 一连接一缓存实例的服务器模型
ESP32做ModbusTCP从站时,通常要支持多个上位机同时连接。每个连接必须有一套独立的接收缓存结构,绝对不能共用,否则两个上位机的请求会交叉写入同一块缓冲区,互相串帧。
正确做法是:accept到一个新连接后,创建一个FreeRTOS任务,任务栈上放置一个modbus_tcp_rx_t实例,然后在这个任务里循环调用modbus_tcp_feed()。连接关闭后,任务自己退出并释放资源。
void modbus_conn_task(void *arg) { int sock = *(int *)arg; free(arg); modbus_tcp_rx_t rx; memset(&rx, 0, sizeof(rx)); rx.sock = sock; for (;;) { int ret = modbus_tcp_feed(&rx); if (ret == -1) break; // 连接关闭或错误 if (ret == -2 || ret == -3) continue; // 坏帧或半包超时,已复位 // ret == 0 表示继续等待 } close(sock); vTaskDelete(NULL); } void modbus_server_task(void *arg) { int lfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); int opt = 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(502); bind(lfd, (struct sockaddr *)&addr, sizeof(addr)); listen(lfd, 4); while (1) { int cfd = accept(lfd, NULL, NULL); if (cfd < 0) continue; int *psock = malloc(sizeof(int)); *psock = cfd; xTaskCreate(modbus_conn_task, "mb_conn", 4096, psock, 5, NULL); } }任务里用malloc(sizeof(int))传递socket描述符,子任务拿到后马上free,避免泄漏。任务栈给4096字节,因为modbus_tcp_feed()里有uint8_t tmp[256]局部数组,还有各种函数调用栈,1024字节确实不够,我试过调到1536还是会出栈溢出问题。
5.2 内存估算与任务栈大小
按4个上位机连接来估算:
- 任务栈:4096字节 × 4 = 16KB
- 接收缓存:约264字节 × 4 = 1KB
- lwIP每个TCP连接自身还有发送/接收队列,每个连接大约需要几KB内存
整体开销不算夸张,ESP32只要开了足够的堆空间,6个连接以内基本没问题。如果连接数上到十几个,就得考虑把任务栈压到2048字节,并且把tmp数组缩小到128字节。用uxTaskGetStackHighWaterMark()可以实际测量每个任务的栈余量,这是调栈大小最可靠的方法。
5.3 网关场景:双向各做一层分片缓存
如果ESP32不只是做从站,还要作为客户端向下轮询现场设备,那就等于要同时管理两条方向的ModbusTCP流。每一条流的每个连接,都得有一个分片缓存实例。
比如ESP32向上位机提供从站服务,同时去读另一台设备的寄存器。上位机发请求过来,需要用上行连接的分片缓存把请求拼好;ESP32发给下层设备的响应返回时,又需要用下行连接的分片缓存把响应拼好。这两个缓存互相独立,各有状态,谁也不能复用谁。我在一个网关项目里就吃过这个亏:想省内存让上下行共用一块缓存区,结果两个方向的帧互相覆盖,调试了整整一下午才定位到根因。
6. 实测复盘:ESP32上跑通分片缓存的隐藏细节
6.1 Nagle算法与小包合并发送
分片缓存解决了“收”的问题,但“发”的方向上也有一个容易被忽略的坑。ESP32作为从站响应上位机时,如果组帧逻辑不完善,多次调用send()分别发送帧头和帧体,Nagle算法会把这些小包合并,或者等收到确认后再发下一段,造成本来应该立刻返回的响应多出几十毫秒延迟。
我的建议是:无论如何,组好完整响应帧后一次性send()。这样既绕开了Nagle带来的小包延迟,也让对端接收时更可能一次拿到完整帧。实在需要多次发送的,可以在socket上显式设置TCP_NODELAY,但这不是首选方案,首选的还是“一次发送完整帧”。
6.2 任务优先级与WiFi协议栈的配合
连接接收任务不要盲目设成最高优先级。ESP32的WiFi和lwIP底层有自己的一套任务在跑,如果应用任务优先级比协议栈任务还高,又长时间占用CPU,底层任务得不到调度,WiFi吞吐会显著恶化,甚至连接断开。
实测里,把连接任务优先级设在5到10之间比较合适(configMAX_PRIORITIES通常25左右),一方面保证分片缓存能及时处理收到的数据,另一方面又不会饿死底层协议栈任务。整个系统里数据类型比较单一,不需要为了“快”去抢占最高优先级。
6.3 事务ID:缓存之后还要过的一道关
分片缓存把完整帧切出来之后,业务层还有一件重要的事要处理:事务ID匹配。如果ESP32是客户端,发出请求后收到响应,必须检查响应的事务ID和最近发出的请求是否一致,不一致的响应直接丢弃,否则前后请求的响应会串。
如果ESP32是从站,处理上位机请求时,则要把请求帧前两个字节(事务ID)原样复制到响应帧里。上位机就是靠这个ID匹配请求和响应的,复制错了它就会把响应当成乱序数据丢掉。
6.4 实测数据与调参建议
最后给一组我实测的大致数据,环境是某开发板通过家用路由器连普通工控机,上位机每200毫秒读取120个保持寄存器。未加分片缓存前,1000次轮询大约出现40次解析失败或超时;加上分片缓存后,连续跑数小时累计上万次轮询,没有出现一次因为组帧问题导致的失败。这个数据在不同WiFi环境里会有波动,但趋势是一致的:分片缓存对症下药,效果非常直接。
调参上总结几条供参考:
SO_RCVTIMEO设为200到500毫秒,平衡响应速度和系统调用开销- 半包超时3秒到5秒,太久没必要,太短会误杀慢速设备
- 接收缓冲区固定260字节,不要为了省内存改成小容量
- 分片缓存复位后,如果频繁出现
-2返回值,要先抓原始报文确认对端在发什么,而不是盲目加大代码里的容错
这套分片缓存逻辑,并不只适用于寄存器读取场景。凡是ESP32上跑ModbusTCP,不管是做从站、做客户端轮询,还是做双向网关,只要涉及TCP流切帧,都可以直接复用。最初那几天排查无线链路浪费了不少时间,回头想,其实组帧这个事情本来就该在socket接收的第一层就做掉,越早做,后面解析的烦心事越少。