做工业数据采集的时候,只要把ESP32当ModbusTCP主站去对接PLC、电表、传感器网关,十有八九会撞见一个鬼现象:设备明明在线,TCP连接也正常,但读回来的数据时不时错位一两个字节,或者整帧消失,多读几次才恢复正常。抓包一看,问题根本不在设备,而是TCP把Modbus报文切成了好几段到达,接收端又没做重组;反过来,一个TCP包也可能塞了两三个完整的Modbus帧,应用层来不及处理就丢掉了尾巴。这段时间我把一套ESP32网关设备的通信代码从裸socket直解析改成带环形缓冲的分片缓存方案,顺手把整套实现和排查过程整理成这篇实操记录。里面包含可直接抄走的环形缓冲区实现、ModbusTCP组帧/拆帧状态机、半包粘包处理技巧,以及我在真实设备上踩过的几个坑。
1. 为什么ModbusTCP必须做分片缓存
1.1 TCP的“水管”特性和Modbus的“包裹”特性
TCP是个流式协议,数据像水管里的水一样连续向前推,它自己不认帧边界。应用层调用send发送100字节,TCP栈可能按路径MTU切成多个segment,对端recv的时候可能只拿到40字节;反过来,对端程序短时间内连续写了好几个响应,底层又会把它们合并成一个数据块一次交给应用层。ModbusTCP的帧结构有明确边界,天然是一个一个的“包裹”,但当包裹被丢进TCP这条水管之后,接收端拿到的就是混合后的连续水流,必须按照包裹上的长度字段重新把边界找出来。
在ESP32上用lwIP协议栈时这个问题尤其明显。Wi-Fi环境本身有丢包和重传,加上TCP的延迟确认和Nagle算法,数据到达节奏完全没法预测。如果代码里直接依赖“一次recv就能收到完整一帧”这种假设,测试时可能一切正常,一到施工现场就随机出问题。
这里要纠正一个常见误解:ModbusTCP没有RTU里那种CRC/LRC校验,也不用自己在应用层计算。TCP协议栈通过校验和、序号重传已经保证了应用层拿到的字节是正确且有序的,重复计算校验没有意义,反而可能把帧结构搞乱。ETCP提供的可靠性和流式传输,正好决定了上层必须自己负责“组帧”。
1.2 不带缓存直接解析会踩的三个坑
很多初版代码长这样:
int n = recv(fd, buf, sizeof(buf), 0); if (n > 0) { parse_modbus_frame(buf, n); }这个写法有三个典型问题:
第一个坑:n只是半帧。比如读保持寄存器响应总共106字节,TCP分片后第一次recv只返回了53字节,parse_modbus_frame解析到一半发现长度不够,如果代码不做处理,轻则丢弃这一半,重则把后面接续到达的数据当成新帧的头部,导致整个数据流错位。
第二个坑:n大于一帧。一次recv可能返回了180字节,里面包含一个完整的106字节帧加另一个帧的前半段。解析器处理完第一帧后,如果没把剩余74字节保存下来,后半段就丢了。很多设备“数据偶尔跳变”就是这么来的,看起来像是寄存器值不对,实际是帧索引错位。
第三个坑:缓冲区大小不好定。定小了收不下完整帧,定大了每次recv都带着巨大栈开销,效率低。而且这些坑不是稳定复现的,往往跟Wi-Fi信号强度、路由器转发瓶颈、对端设备处理速度都有关系,排查起来相当折腾。
1.3 为什么“延时等一会”不是好方案
我早期处理半包时用过一招:recv之后如果判断长度不够,就sleep(10ms)再读一次。这个办法在局域网、单一设备、测试环境里勉强能用,但一到真实项目就露馅。
原因不复杂:TCP和以太网的延迟是动态的。设备如果经过光猫、交换机、无线中继,往返延迟可能从几毫秒抖动到几十毫秒。固定等待10ms解决不了延迟超过10ms的半包,把延时加到50ms倒是能覆盖大多数情况,但采集频率被拖到20Hz以下,轮询几十个寄存器点位的周期被无限拉长,实时性彻底废掉。更关键的是,延时等待完全解决不了粘包问题,一个TCP包里有两帧时,延时再久还是两帧挤在一起。
正确的思路是:接收裸字节后先全部放进一个缓存区,由解析器按照ModbusTCP帧里的长度字段判断“完整的帧凑齐没有”,凑齐了就整帧取出来给业务层处理,没凑齐就留在缓存里继续等下一个recv。这就是分片缓存的核心逻辑,也是本文后面所有代码的基础。
2. 核心细节解析与实操要点
2.1 帧结构:长度字段是组帧的命根子
ModbusTCP帧由两部分组成:MBAP头(7字节)加上PDU(功能码加数据)。MBAP头的结构如下:
| 字段 | 长度(字节) | 含义 |
|---|---|---|
| 事务处理标识 | 2 | 请求与响应的配对编号 |
| 协议标识 | 2 | 固定为0x0000,用于区分其他协议 |
| 长度 | 2 | 后续所有字节数,包含单元标识和PDU |
| 单元标识 | 1 | 从站地址,相当于串口RTU里的从机地址 |
大端字节序(网络字节序)。
这个长度字段是重组帧的命根子。一个完整帧的总长度等于7加上长度字段的值。比如主站读48个保持寄存器,正常响应PDU里是:功能码1字节、字节计数字段1字节、数据部分96字节,总共98字节,再算上单元标识1字节,长度字段的值就是99,完整帧长度7+99=106字节。
需要注意,标准ModbusTCP限定了PDU最大253字节,但有些厂商对长度字段的使用并不规范,甚至见过在长度字段里直接放本地字节序的情况。解析器不要死板限制“必须等于多少”,而是做一致性校验:协议标识必须是0,长度字段必须在合理范围内,超了就触发防御逻辑。
有个常被忽略的细节:ModbusTCP响应帧里不会携带起始地址和请求时的寄存器数量。读保持寄存器功能码0x03的响应帧里只有字节计数和数据,主站必须根据自己发出的请求来推断当前响应对应的是哪一段地址范围。这个特性在后续做寄存器分片拼接时特别关键。
2.2 环形缓冲区的设计与内存考量
分片缓存的载体,我推荐环形缓冲区。用memcpy直接读UDP那种定长帧简单,但Modbus帧是变长的,环形缓冲区天然支持“写入任意长度、按帧长度逐步消费”的模型。
设计要点:
第一,容量得够大。标准ModbusTCP单帧理论上不超过261字节,但如果做寄存器大块读取或某些扩展实现,帧长可能到500字节以上。考虑到防御和余量,我通常直接定义2048字节的缓冲区。ESP32内部SRAM有几百KB,一个TCP连接占2KB完全可接受;如果有4个从站连接,每个独立分配自己的缓冲区,也不过8KB。
第二,容量取2的幂。这样在计算剩余空间和已用空间时可以直接用& (size - 1)代替% size,省掉除法运算。在解析循环里这个操作会执行很多次,省一点是一点。
第三,静态数组而不是动态malloc。ESP32长期运行后,频繁的内存分配释放会产生碎片,跑几天后malloc失败导致系统异常的情况不少见。环形缓冲区的内存直接在modbus_session结构体里用固定数组声明就行。
第四,单生产者单消费者模型下不需要锁。最简单且不容易错的做法是:同一个任务里先recv写入缓存,然后立刻调用解析器处理,处理完再回到select等待。这样缓冲区永远只有一个主人,不存在并发竞争。
2.3 解析器状态机:从字节流到整帧
有了环形缓冲区之后,需要一个解析器从字节流里找出完整的ModbusTCP帧。整体思路是三步:
第一步,判断缓冲区内已用字节数是否达到7字节。不足则返回,等待更多数据。达到则用peek方式读取MBAP头,注意是“读取但不消费”,因为还没确定这是不是合法帧头。
第二步,校验MBAP头。协议标识必须是0,长度字段不能超过预设的最大帧长。如果非法,说明当前数据流里的帧边界不对,可能是错位或者被污染了,这时候只丢弃1个字节,然后重新走一遍,而不是把整个缓冲区清空。逐字节丢弃虽然看着笨,但在错位场景下是最可靠的恢复策略。
第三步,根据长度字段算出完整帧长,再次检查缓冲区已用字节数是否足够。如果不够,就是半包,保持数据不动,返回继续等待。如果够,把整个帧从缓冲区里读取出来,交给上层的帧处理函数,然后循环继续解析下一个可能的帧。
这个“peek-校验-再读”的流程值得反复推敲,因为所有的边界条件都被覆盖了:半包会卡在第二步反复等待,粘包会在一次循环里处理掉所有完整帧,错位包会通过逐字节丢弃自动找回边界。
3. 实操过程与完整代码实现
3.1 工程骨架与socket初始化
我这里用的是ESP-IDF环境,Wi-Fi连上之后,用BSD socket API建立ModbusTCP连接。工程结构上,把环形缓冲区和帧解析器分成两个模块:一个管缓存,一个管协议,方便以后复用到其他项目。
socket初始化的核心代码:
int fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (fd < 0) { ESP_LOGE(TAG, "socket create failed"); return -1; } struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_port = htons(502); inet_pton(AF_INET, "192.168.1.100", &addr.sin_addr); // 连接超时设置成5秒,避免对端不可达时长时间卡死 struct timeval conn_timeout = {5, 0}; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &conn_timeout, sizeof(conn_timeout)); if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { ESP_LOGE(TAG, "connect failed"); close(fd); return -1; }连接成功之后,主循环用select监听可读事件,而不是靠sleep轮询。select是事件驱动,没有数据时任务阻塞但不占CPU,有数据时立刻唤醒。recv需要配合MSG_DONTWAIT或非阻塞模式,避免在意外边界上长时间阻塞。注意区分两种“读不到数据”:返回0表示对端主动关闭了连接;返回负数且errno为EAGAIN表示当前没有数据可读,这是正常的。
3.2 环形缓冲区源码
头文件定义:
typedef struct { uint8_t *buf; uint32_t size; // 必须是2的幂 uint32_t head; // 写指针 uint32_t tail; // 读指针 } ring_cache_t; void ring_init(ring_cache_t *r, uint8_t *buf, uint32_t size); uint32_t ring_used(ring_cache_t *r); uint32_t ring_free(ring_cache_t *r); int ring_write(ring_cache_t *r, const uint8_t *data, uint32_t len); uint32_t ring_read(ring_cache_t *r, uint8_t *out, uint32_t len); void ring_peek(ring_cache_t *r, uint8_t *out, uint32_t len); void ring_discard(ring_cache_t *r, uint32_t len);实现:
void ring_init(ring_cache_t *r, uint8_t *buf, uint32_t size) { r->buf = buf; r->size = size; r->head = 0; r->tail = 0; } uint32_t ring_used(ring_cache_t *r) { return (r->head - r->tail) & (r->size - 1); } uint32_t ring_free(ring_cache_t *r) { return r->size - 1 - ring_used(r); } int ring_write(ring_cache_t *r, const uint8_t *data, uint32_t len) { if (len > ring_free(r)) { return -1; // 空间不足 } uint32_t pos = r->head & (r->size - 1); uint32_t first = r->size - pos; if (first > len) { first = len; } memcpy(&r->buf[pos], data, first); memcpy(&r->buf[0], &data[first], len - first); r->head += len; return len; } uint32_t ring_read(ring_cache_t *r, uint8_t *out, uint32_t len) { uint32_t used = ring_used(r); if (len > used) { len = used; } uint32_t pos = r->tail & (r->size - 1); uint32_t first = r->size - pos; if (first > len) { first = len; } memcpy(out, &r->buf[pos], first); memcpy(&out[first], &r->buf[0], len - first); r->tail += len; return len; } void ring_peek(ring_cache_t *r, uint8_t *out, uint32_t len) { uint32_t pos = r->tail & (r->size - 1); uint32_t first = r->size - pos; if (first > len) { first = len; } memcpy(out, &r->buf[pos], first); memcpy(&out[first], &r->buf[0], len - first); } void ring_discard(ring_cache_t *r, uint32_t len) { r->tail += len; }实现说明:head和tail不是每次做减法再取模,而是直接累加直到自然回绕,只在计算实际位置时才& (size - 1),这样避免溢出问题,也快。ring_free返回size - 1 - used,留一个字节的空隙来区分“全空”和“全满”,这是环形缓冲区里经典的做法。
3.3 帧解析与重组源码
帧解析器放在modbus_session结构体里:
#define MBAP_LEN 7 #define MODBUS_TCP_MAX_LEN 1024 typedef struct { ring_cache_t ring; uint8_t ring_buf[MODBUS_TCP_MAX_LEN]; } modbus_session_t; // 返回1表示成功取出一整帧,0表示缓存不足或等待中 int modbus_session_poll(modbus_session_t *s) { if (ring_used(&s->ring) < MBAP_LEN) { return 0; } uint8_t mbap[MBAP_LEN]; ring_peek(&s->ring, mbap, MBAP_LEN); uint16_t protocol_id = (uint16_t)((mbap[2] << 8) | mbap[3]); uint16_t data_len = (uint16_t)((mbap[4] << 8) | mbap[5]); if (protocol_id != 0 || data_len > MODBUS_TCP_MAX_LEN - MBAP_LEN) { // 非法帧头,丢弃一个字节重新找边界 ring_discard(&s->ring, 1); return 0; } uint32_t frame_len = MBAP_LEN + data_len; if (ring_used(&s->ring) < frame_len) { return 0; // 半包,继续等 } uint8_t *frame = malloc(frame_len); if (frame == NULL) { return 0; } ring_read(&s->ring, frame, frame_len); modbus_on_frame(frame, frame_len); free(frame); return 1; }主循环里处理数据的部分:
while (1) { fd_set rd; struct timeval tv = {0, 10000}; FD_ZERO(&rd); FD_SET(fd, &rd); int ret = select(fd + 1, &rd, NULL, NULL, &tv); if (ret > 0 && FD_ISSET(fd, &rd)) { uint8_t tmp[256]; ssize_t n = recv(fd, tmp, sizeof(tmp), 0); if (n > 0) { ring_write(&g_session.ring, tmp, n); } else if (n == 0) { break; // 对端关闭 } else if (errno != EAGAIN && errno != EWOULDBLOCK) { break; // 真正的错误 } } // 不管本次有无收到新数据,都把缓存里能解析的帧解析一遍 while (modbus_session_poll(&g_session) > 0) { } }代码里反复调用modbus_session_poll直到返回0,是为了处理粘包:一次recv可能带进来多帧,每处理完一帧,环形缓冲区的已用字节数会减少,循环可以继续把下一帧也取出来。处理到缓冲区里的数据不够组成一帧时,modbus_session_poll返回0,循环退出,等待下一次select唤醒。
select的10毫秒超时其实可有可无,主要用来让主循环在无数据时还能定期做点别的事,比如发送心跳、检查连接状态。如果整个任务只服务一个连接,把超时设为NULL也行。
3.4 业务回调与事务ID匹配
modbus_on_frame是帧解析完成后的回调。一个完整帧里最先要取出的就是事务处理标识(TID)和单元标识(Unit ID),然后按功能码分发:
void modbus_on_frame(uint8_t *frame, uint32_t len) { if (len < MBAP_LEN + 1) { return; } uint16_t tid = (uint16_t)((frame[0] << 8) | frame[1]); uint8_t unit = frame[6]; uint8_t func = frame[7]; ESP_LOGI(TAG, "recv tid=0x%04x unit=0x%02x func=0x%02x len=%d", tid, unit, func, len); if (func & 0x80) { uint8_t err_code = frame[8]; ESP_LOGW(TAG, "modbus exception func=0x%02x code=%d", func & 0x7F, err_code); return; } if (func == 0x03 || func == 0x04) { // 读保持寄存器/输入寄存器响应 // frame[8]是字节计数,frame[9]开始是数据 uint8_t byte_count = frame[8]; uint16_t *data = (uint16_t *)&frame[9]; int16_t values[125]; for (int i = 0; i < byte_count / 2; i++) { values[i] = (data[i] >> 8) | (data[i] << 8); } // 交给业务层处理 handle_register_values(unit, values, byte_count / 2); } }事务ID匹配的逻辑:如果同一时间只发一个请求,等响应的时候校验TID是否和自己发出去的一致即可。但工程上更要紧的是“软校验”思路。遇到过好几次这样的情况:某些设备的TID回显是固定值,或者会话里的TID始终是1,根本不递增。如果代码硬性要求“必须严格等于”,大批量读取时前面几帧没问题,后面的响应全被过滤掉,数据莫名其妙少一块。所以TID要做的是:先看长度和功能码,再把TID当作辅助参考,不要当成绝对标准。
3.5 多从站并发处理的结构
现场通常不止一个设备。多从站场景下,每个连接对应一个独立的modbus_session,拥有自己的缓冲区。主循环改成遍历所有fd:
typedef struct { int fd; modbus_session_t session; uint8_t pending; } modbus_conn_t; modbus_conn_t conns[8]; int conn_cnt = 0;每次select全部连接,有可读的就往对应会话的环形缓冲区写,然后挨个调用modbus_session_poll处理。这种结构天然隔离了故障:一个从站设备异常导致长时间不返回数据,只影响它自己的缓冲区,不会阻塞其他从站的轮询。
连接管理还要考虑设备重启的情况。ModbusTCP没有主动通知机制,设备重启后旧TCP连接会超时断开,主站要靠应用层超时检测,比如连续请求超时N次后主动关闭fd并重新connect。重连时务必清空对应的modbus_session,具体原因在排查章节里细说。
4. 常见问题与排查技巧实录
4.1 用抓包工具确认半包和粘包
调试ModbusTCP这类问题,抓包是第一手段,不要靠猜。在目标设备同一网段抓包,过滤器直接写tcp.port == 502。
抓包怎么判断半包和粘包:看TCP报文里的Length列,再对比Modbus帧头里的长度字段。如果一个TCP segment的长度小于ModbusMBAP中的长度字段,基本可以断定应用层的recv大概率会拿到半包;如果一个TCP segment的长度明显大于Modbus单帧长度,那多半就是粘包。实际上,局域网设备大量数据交互时,TCP的Nagle算法和延迟确认机制会自动合并数据,粘包才是常态,反而单帧包是少数。
有些人会发现目标设备响应的是连续两三个Modbus帧,TCP段把它们合并成一个300多字节的数据块到达。如果上一版代码不做循环解析,只处理第一帧,后面两帧就白白丢了。抓包看到这种现象,基本可以断定问题出在自己的重组逻辑上。
4.2 错位帧的恢复:丢一字节还是清缓存
在开发调试早期,我犯过一个典型错误:解析失败时直接ring_init清空整个缓冲区。这个操作表面上看很干净,实际上会制造更隐蔽的问题。
举一个具体场景:一帧106字节的数据,到达顺序是53字节、53字节。第一段数据没问题,但解析到了第二段时因为某种原因触发了校验失败,比如对端中间插入了一个非Modbus心跳包。此时缓冲区里有53字节的有效帧尾。如果直接清空缓冲区,这53字节就丢了;对端接下来的新数据会接在后面,但因为前半帧已经丢失,新数据可能被误判为一个新帧的头部,后续的解析又错位。
正确做法是上面代码里的“逐字节少许丢弃”:ring_discard(&s->ring, 1)。每丢一个字节重新尝试解析,最多丢一个最大帧长就能找回正确边界。这个策略在网络数据流中存在少量污染字节时非常有效,代价是极端情况下会多跑几次循环,对ESP32来说完全可以忽略。
4.3 缓存溢出与无响应保护
环形缓存满了一般有两种原因:一个是从站响应异常,反复发数据但主站一直不取;另一个是请求发出后从站长时间不响应,数据没来,但主站还在不断发新请求,导致响应逐渐堆积。
如果从站根本不回复,缓冲区永远不会满,因为根本没有数据写进来。真正危险的是从站一直回复但都是无效帧,或者响应频率远高于主站处理速度。这时候ring_write返回-1,意味着多余的数据只能丢弃。
工程上的保护手段很简单:在解析器里限制单帧最大长度,超过就直接视为非法;同时在业务层给每个请求挂一个超时时钟,比如3秒内没等到对应响应就认定超时,累计超时次数达到阈值就执行重连。重连前把会话里的缓存和待响应状态全部复位,防止旧数据污染新连接。
4.4 事务ID不一致的问题怎么处理
前面提过“软校验”。严格的事务ID校验听起来严谨,但实际工程里并不好用。
有一次接某国产电表,所有响应的事务ID都一样,固定回显0x0001。我最初按照“请求TID必须等于响应TID”的规则过滤,结果前几个寄存器组正常,后面全被当成不匹配帧丢掉。后来抓包对比发现,TID只是在响应里被原样回显或者固定为1,和ModbusTCP标准里的“客户端生成唯一事务ID”语义完全不同。最后把事务ID匹配从“硬过滤”改成“软匹配”:只要当前连接正常、功能码合理、长度相符,就接受这个响应。TID仅用于日志里的请求对应关系,不再作为响应是否合法的唯一判定条件。
4.5 性能优化与内存碎片经验
ESP32双核跑起来CPU余量很大,一般ModbusTCP采集任务只占一个核的很小一部分。但是有几点要注意:
第一,recv每次缓冲区不要太大,256字节足够。TCP内核里没收完的数据会留在内核缓冲,下次继续读,不需要在应用层一次性把大量数据搬出来。走更大的tmp只会白白增加内存拷贝。
第二,大块响应帧的分配尽量用栈内存或者静态内存。malloc和free频率过高会导致ESP32堆碎片化,长时间运行后malloc(2048)可能因为找不出连续内存而失败。环形缓冲区本身是静态数组,解析时临时帧可以考虑在会话结构体里放一个固定大小的临时帧缓冲区,避免反复动态分配。
第三,lwIP默认的TCP窗口和收发缓冲可以调大一些,CONFIG_LWIP_TCP_WND_DEFAULT和CONFIG_LWIP_TCP_RECVMBOX_SIZE适当往上加。如果从站设备吞吐量大,默认配置可能在协议栈层面就造成拥塞,应用层的缓存策略再优化也没用。
4.6 寄存器分片读取与结果拼接
“分片缓存”还有另一层含义,是把大数据块拆成多个Modbus请求来读,再把结果拼接回完整缓存。
Modbus协议里单个读保持寄存器请求最多读125个寄存器,对应的数据载荷是250字节,正好压在ModbusPDU的253字节限制内。如果业务需要读1000个连续保持寄存器,就必须分8次请求,每次读125个。
这里有一个新手容易忽略的坑:响应帧里没有起始地址。0x03功能码的响应只包含功能码、字节计数和数据,主站必须根据自己发出的请求去推断当前响应是哪个区间的。所以分片请求要设计成队列,每个分片记录起始地址和长度:
typedef struct { uint16_t start; uint16_t count; uint16_t tid; } modbus_read_req_t;流程是:先构造分片请求队列,按顺序发送,每个请求用独立的TID;收到响应后根据TID找到对应的分片信息,把数据memcpy到结果缓存里的对应偏移位置;等所有分片都完成后,再统一上报。缓存结果的数组大小就是总寄存器数乘2字节,事先算好,直接静态分配。
如果响应乱序到达,拼接时不要依赖接收顺序,而是依赖分片记录的起始地址。这也是TID软匹配思想的应用场景之一。我见过有人直接把第8段响应当成第1段去解析,结果数据全错,排查了半天才发现是没按TID定向分片。
5. 进阶:把分片缓存做成通用组件
5.1 断线重连时的缓存清理
TCP断线重连是个高频问题。设备断电、网线拔插、路由器重启、对端程序重启,都会造成连接断开。重连时要做的三件事:关闭旧fd、清空modbus_session里的环形缓冲区、复位所有请求状态。
清空缓存这一步很容易被漏掉。如果主站在断线前已经收到半帧数据,残留在环形缓冲区里,重新连接后设备可能重新发送新响应,程序可能把旧半帧和新响应拼在一起,解析出一个畸形帧,然后触发逐字节丢弃逻辑。问题表现是重连成功后数据错乱,但实际上根源在旧的残留数据。所以在connect成功后第一件事就是ring_init,把缓冲区重置归零。
重连的退避策略也简单实用:连续失败时指数退避,第一次延迟1秒,第二次2秒,最多30秒,避免对不可达设备疯狂重连占用CPU。
5.2 网关场景下的模块复用
如果把分片缓存这层做好,它完全可以变成一个通用组件,用在更多协议转换场景里。
我做过一个比较典型的网关:底层通过串口连接一批ModbusRTU从站,上层通过ModbusTCP对外提供数据服务。这个时候ESP32既是TCP服务端又是TCP客户端,接入层全部复用同一套环形缓冲和帧解析器。RTU侧和TCP侧的差异只在“是否有CRC”上:RTU需要额外校验CRC,TCP不用。把modbus_session_poll稍微抽象一下,增加一个“是否需要校验CRC”的开关,两侧就都能用了。
实现上的建议是:把环形缓冲区、帧解析器、连接管理分成三个独立模块,用结构体传参而不是全局变量。这样项目多了以后,随便复制模块文件过去就能用,不用从头改一遍。
5.3 单元测试与回归验证
通信模块最怕改出回归问题。手动现网测试又不稳定,推荐写一个简单的模拟测试脚本,在PC上跑。模拟方式是:把环形缓冲区和解析器代码原封不动地编到PC测试工程里,然后用脚本产生半包、粘包、错位、长度异常四个场景的输入数据,递归调用解析器后断言输出的帧数、帧内容和事务ID是否正确。
半包测试场景:一帧106字节,先喂53字节,断言返回0;再喂53字节,断言返回1且帧内容完整。粘包测试场景:一次喂进180字节,断言循环返回处理后得到两帧。错位测试场景:在数据流前插几个随机字节再喂进去,断言解析器能自动找回边界且最终输出正确帧。这些测试跑一遍不到一毫秒,但能保证我每次改完代码都有底。我甚至会把这类测试写成一个独立的test_main.c,用make test一条命令跑完。
这个模块后来成了我做所有网络协议栈的标准起点。不光是Modbus,凡是TCP承载的带长度的二进制协议,都可以复用这套“环形缓冲 + 长度字段状态机”的组合。
踩过几次坑之后,我的体会是:这类通信问题,写代码和找根因的时间比例大概三七开,找到根因后修复往往只需要几十行。想给ESP32加ModbusTCP通信的话,核心建议是三条:一、先把环形缓冲写好并充分测试,这是所有重组逻辑的地基;二、解析状态机每一步只做一件事,不要着急返回数据给业务层;三、所有超时、断线、重连都必须配合缓存清理,别让脏数据进入新连接。把这三点做到位,ModbusTCP采集就基本稳了。