最近在做一台基于ESP32的工业数据采集网关,ModbusTCP从站是老需求了,但真把几百个寄存器开放给上位机轮询时,发现一个看似不起眼却非常致命的点:分片缓存。如果没有它,请求稍微密集一点,要么响应超时,要么数据发生跳变。这篇文章就把我在ESP32上做ModbusTCP分片缓存的完整思路、代码框架和实测数据整理出来,适合用ESP-IDF或者Arduino做工业通信的开发者参考,特别是你的项目里存在大寄存器表、多客户端并发、周期轮询这些场景时,这篇内容可以直接帮你少踩两个月的坑。
1. 先搞清楚题目:ModbusTCP里到底哪里需要“分片缓存”
1.1 ModbusTCP报文帧格式回顾
ModbusTCP的帧结构很简单,一个完整的请求/响应帧分为两部分:
- MBAP头,固定7字节:事务ID(2字节)、协议ID(2字节)、长度字段(2字节)、单元ID(1字节)
- PDU:功能码(1字节)+ 数据域
也就是说,读保持寄存器这类请求,一个报文长度约12字节;而读多个寄存器的响应帧,最长可以到7 + 1 + 1 + 250 = 259字节。这个长度其实不大,一个TCP segment就能装下,那为什么还要“分片”?
关键在TCP的流式传输特性上。TCP不关心你应用层怎么划分报文,它只保证字节流有序到达。于是上位机如果在一个TCP包里连续塞了两个Modbus请求,ESP32这边read()一次可能把两帧数据一起读出来,这叫粘包;反过来,一个完整的259字节响应,在某种链路条件下可能被拆成两次到达,这叫拆包。粘包要拆,拆包要等,这是分片处理的第一个含义。
另一个含义在大寄存器表场景里。比如你开放了1000个保持寄存器给上位机,客户端发一个读请求,读125个寄存器(这是Modbus协议单次读的最大数量),响应250字节数据。如果上位机每秒轮询几十次,每次都从传感器底层现取数据现拼报文,那性能和一致性都会出问题。正确的做法是把共享数据做成快照缓存,再从缓存里分段生成响应。
所以“分片缓存”在我这个项目里的定义,是两层能力:第一,把TCP字节流按Modbus应用帧边界切成一个个完整分片,对不完整的帧做暂存和重组;第二,对大寄存器表做分段快照缓存,请求来了直接从缓存的预生成响应里分段取出,避免现场采集和组帧的开销。
1.2 为什么在ESP32上这个问题特别明显
ESP32是一款性能不错的MCU,双核240MHz,但它的可用内存撑死也就是320KB左右的SRAM(还要扣掉WiFi协议栈的消耗)。如果上位机以100ms周期轮询一台设备,设备上又有四个ModbusTCP客户端同时连进来,每个连接都做缓冲区分配,内存会迅速吃紧。缓冲区不够大,自然装不下完整的259字节大帧,于是拆包就会被粗暴地丢弃——这是很多ESP32 Modbus从站“间歇性掉线”的真正原因。
而且ESP32的BLE、WiFi、OTA这些功能也在跟Modbus抢CPU和内存。我自己做过对比:同样的代码和寄存器规模,在PC上跑Freemodbus毫无压力;放到ESP32上,如果不对响应生成和内存缓冲做“分片缓存”式的优化,Modbus响应延迟会从稳定的3ms波动到50ms以上,上位机就开始报超时。
2. 方案选型与整体设计思路
2.1 用官方esp-modbus还是自己写协议栈
ESP32上做ModbusTCP从站,最简单的路子是直接用乐鑫官方维护的esp-modbus组件,基于FreeModbus的TCP实现在ESP32上跑得很稳,支持多客户端连接,内部已经做了MBAP解析、事务ID绑定、请求分发,底层依靠LWIP处理TCP粘包拆包。很多人卡在“分片缓存”这个需求上,是因为不知道协议的哪一层该做什么。
我的建议是:协议栈用esp-modbus,分片缓存自己做。原因很实际——ModbusTCP的协议解析、帧边界识别这种底层的活,官方库已经做过多年验证,你自己重写一套纯属给自己找事。但官方库不会帮你做“大表快照缓存”和“响应预生成”,这些属于业务层的优化,必须结合具体项目来做。
在Arduino环境下,如果不想引入esp-modbus,也可以直接用WiFiServer建一个TCP服务,自己解析MBAP,代码量不大,但要把粘包拆包处理写好。我后面会给出一个Arduino版本的核心简化实现,思路跟esp-modbus一致。
2.2 核心数据结构:响应快照表 + 分片发送队列
我最终在项目里用的方案是“双缓冲快照 + 分段预生成”。展开来说:
第一层是数据快照表。用一个长度为N的uint16_t数组保存所有保持寄存器的当前值。后台采集任务按固定周期(比如50ms)更新这个数组,同时用互斥锁保护。Modbus读请求进来时,不直接触碰硬件,而是从快照表取数据。
第二层是响应预生成缓存。如果寄存器表很大,比如512个寄存器,上位机每轮询一次,你需要把请求地址对应的那段寄存器值拼成一个响应帧。与其每次现拼,不如在快照更新的同时,把整个寄存器表按“每125个寄存器一段”划分成若干段,每段预生成一个带完整MBAP头(事务ID留空)的响应缓冲。请求进来后,把预生成缓冲拷贝出来,再填入当前的事务ID,马上就能发出去。
第三层是分片发送队列。ESP32的TCP发送缓冲有限,如果一个响应帧超过链路能一次性容纳的大小,或者上位机处理能力跟不上,发送端会阻塞。所以发送不能直接丢给LWIP就走,而是要把已经填充好的响应分片放进一个环形队列,由发送任务逐个发出。这样即使某次TCP窗口极小,也不会阻塞Modbus协议栈的处理。
用同一大小的缓冲区池管理这些分片。我定义的一个分片单元如下:
typedef struct { uint8_t data[300]; // 覆盖最长ModbusTCP帧 + 余量 uint16_t len; // 实际帧长度 uint16_t txid; // 关联的事务ID,用于匹配请求 uint8_t state; // 0 空闲, 1 已填充待发送, 2 发送中 } modbus_fragment_t;这里用300字节而不是259字节,是因为网络层和调试时经常会在帧前后附加一些字段,留一点余量可以避免边界问题。整个缓冲区池开8个分片,占用约2.4KB内存,在ESP32上完全可以接受。
2.3 ESP32资源分配:任务划分和内存规划
ESP32是双核处理器,合理利用两个核能显著提升Modbus的稳定性。我实际跑出来的任务结构是:
| 任务 | 运行核 | 优先级 | 栈大小 | 职责 |
|---|---|---|---|---|
| ModbusTCP服务任务 | 核1 | 5 | 4096 | 监听连接、接收请求、解析、调用处理函数 |
| 数据采集任务 | 核0 | 3 | 2048 | 周期读取传感器/IO,更新快照表 |
| 分片发送任务 | 核1 | 4 | 2048 | 从发送队列取分片,写入socket |
| 管理任务 | 核0 | 1 | 2048 | 看门狗喂狗、状态上报、日志输出 |
这个分配的出发点很简单:Modbus的接收和处理是硬实时需求,必须独占最高优先级;数据采集只负责更新快照,慢了最多导致缓存数据旧一点,不会丢帧;发送任务因为可能阻塞在TCP写操作上,所以单独拧出来,不让它拖累接收任务。
内存规划上,除了缓冲区池,还要注意每个TCP连接都会占用LWIP的socket缓冲区。默认情况下每个socket的收/发缓冲各占几十KB,如果同时开4个连接,光socket缓冲就接近200KB,这在ESP32上不可接受。所以我用了自己的分片发送队列,发送缓冲设置的比较小,靠发送任务把大帧拆成小分片慢慢喂给socket,实测内存占用能控制在90KB以内。
3. 核心代码实现与实操细节
3.1 基于esp-modbus的TCP从站初始化
如果你的工程用ESP-IDF,直接从component registry拉取esp-modbus,标准的从站初始化其实不长。下面是我在项目里实际使用的初始化代码:
#include "mbcontroller.h" #include "mb_tcp.h" #define MB_TCP_PORT 502 #define MB_REG_HOLDING_NUMS 512 #define MB_TASK_STACK_SIZE 4096 #define MB_TASK_PRIORITY 5 static uint16_t holding_regs[MB_REG_HOLDING_NUMS]; void modbus_tcp_init(void) { mb_communication_info_t comm_info = {0}; comm_info.mb_type = MB_MB_TCP; comm_info.ip_port = MB_TCP_PORT; comm_info.ip_addr = NULL; // 监听所有网卡地址 comm_info.ip_netif_ptr = NULL; // 默认netif mb_register_info_t reg_info = {0}; reg_info.mb_type = MB_MB_TCP; reg_info.holding_reg_start_addr = 0; reg_info.holding_reg_size = MB_REG_HOLDING_NUMS; reg_info.holding_reg_ptr = holding_regs; // 初始化从站参数和寄存器表 ESP_ERROR_CHECK(mbc_tcp_init(&comm_info)); ESP_ERROR_CHECK(mb_register_info_init(®_info)); // 关键:设置请求处理超时,防止异常帧卡死任务 ESP_ERROR_CHECK(mb_control_timeout_set(50)); // 启动协议栈 ESP_ERROR_CHECK(mbcontroller_start()); }有几个点需要单独说明。第一,holding_regs这个数组是协议栈直接使用的寄存器缓冲区,我的快照表更新不能直接把传感器数据塞进去,否则可能出现一个读请求读到半新半旧的数据。第二,超时时间我设为50ms,这个值比请求间隔小一个数量级,一旦协议栈内部出错卡住,能自己恢复。第三,mbc_tcp_init内部的默认行为是每个客户端连接独立处理,这就是为什么并发多客户端时底层不慌,真正要慌的是你应用层的数据一致性。
3.2 请求分片处理:粘包拆包的正确玩法
表现层来看,分片处理说的是:收到一个TCP segment,里面可能只有一个完整请求,也可能有半个、一个半、两个。正确做法不是每收到16字节就去解析一帧,而是要维护一个“接收字节流缓冲”。每次read()之后,把数据追加到缓冲,然后用Modbus帧长度字段去判断当前缓冲里有没有完整的帧,有就取走处理,没有就继续等下一段。这跟处理串口不定长帧完全是同一个套路。
esp-modbus内部已经做了这件事,不需要你碰。但如果你在Arduino下自己用WiFiServer实现,这是必须亲手写的。下面是一段简化但能直接跑的Arduino版分片接收逻辑:
#define RX_BUF_SIZE 512 uint8_t rx_buf[RX_BUF_SIZE]; uint16_t rx_len = 0; void handleTcpData(WiFiClient& client) { while (client.available()) { // 1. 先读进来,不管有没有完整帧 int n = client.read(rx_buf + rx_len, RX_BUF_SIZE - rx_len); if (n > 0) { rx_len += n; } // 2. 循环拆帧,因为一个segment里可能有多个请求 while (rx_len >= 6) { // MBAP长度字段是第5、6字节,表示从字节5开始到帧尾的长度 uint16_t mb_len = (rx_buf[4] << 8) | rx_buf[5]; uint16_t total_len = mb_len + 6; // 总帧长 = 长度字段值 + MBAP头中前面的6字节 if (rx_len < total_len) { break; // 半包,继续等 } // 这里就拿到了一个完整Modbus请求帧 rx_buf[0..total_len-1] processModbusFrame(rx_buf, total_len, client); // 把已处理的帧移出缓冲 memmove(rx_buf, rx_buf + total_len, rx_len - total_len); rx_len -= total_len; } } }注意一个常见坑:MBAP头的长度字段数值是从“单元ID”那一个字节开始算的,也就是它表示的长度等于“单元ID + PDU”,所以总帧长应该是长度字段数值加上前面6个字节(事务ID2字节、协议ID2字节、长度字段本身2字节)。这个换算错一位,整个缓冲区就会错位,后面的帧全部解析失败。我最初调试时就是被这个细节坑了整整半天。
3.3 响应预生成缓存:把大表切成段,按需取用
现在重点讲分片缓存的业务侧实现。寄存器表512个保持寄存器,Modbus协议一次最多读125个。如果按125个一组划分,512个寄存器需要拆成5段(125+125+125+125+12)。每个读请求落在哪一段,由起始地址和数量决定。
我的做法是:在后台采集任务更新快照之后,立即遍历这5段,对每一段预生成一个“半成品响应帧”——MBAP头先填好协议ID、长度、单元ID,事务ID留空,数据部分从快照拷贝;然后挂到分片缓存表里。等真正收到请求,只需要把请求里的事务ID填入缓存分片头部,然后把整段发送出去。
typedef struct { uint16_t start_addr; uint16_t reg_nums; uint8_t frame[300]; uint16_t frame_len; uint32_t update_tick_ms; uint8_t valid; } segment_cache_t; #define SEGMENT_CNT 5 static segment_cache_t seg_cache[SEGMENT_CNT]; void rebuild_all_segments(void) { // 更新快照后调用,重建所有段的响应帧 for (int i = 0; i < SEGMENT_CNT; i++) { uint16_t addr = i * 125; uint16_t cnt = (addr + 125 > 512) ? (512 - addr) : 125; uint8_t *p = seg_cache[i].frame; *p++ = 0x00; // 事务ID高字节,留待请求时填充 *p++ = 0x00; // 事务ID低字节 *p++ = 0x00; // 协议ID高字节 *p++ = 0x00; // 协议ID低字节 *p++ = (cnt * 2 + 2) >> 8; // 长度 = 单元ID1 + 功能码1 + 数据cnt*2 *p++ = (cnt * 2 + 2) & 0xFF; *p++ = 0x01; // 单元ID *p++ = 0x03; // 功能码:读保持寄存器 for (int j = 0; j < cnt; j++) { uint16_t val = holding_regs[addr + j]; *p++ = (uint8_t)(val >> 8); *p++ = (uint8_t)(val & 0xFF); } seg_cache[i].start_addr = addr; seg_cache[i].reg_nums = cnt; seg_cache[i].frame_len = p - seg_cache[i].frame; seg_cache[i].update_tick_ms = esp_log_timestamp(); seg_cache[i].valid = 1; } }预生成缓存带来两个直接收益。第一,请求处理函数不再需要从holding_regs数组里逐字拷贝数据再拼响应,一次memcpy就行,处理耗时显著下降。第二,一个读请求如果跨越两个段(比如起始地址100,读50个寄存器,涉及段1尾部和段2头部),也不需要两次采集数据,直接从两段缓存里各抄一段即可。
这里有个细节要提醒:如果上位机发的是随机地址区间,不是对齐到125的段边界,那么“按段缓存”的策略会稍微浪费一点内存,但处理逻辑非常简单。权衡下来,我认为125寄存器一段是最合适的粒度,因为这是协议允许的最大读数量,段数少,浪费也小。
3.4 写请求的缓存一致性处理
分片缓存不只是读快照,写请求也有一套讲究。上位机写单个或多个保持寄存器时,协议栈会直接修改holding_regs数组。这个修改必须同步到后台硬件。比如控制一个PWM输出,你不能等50ms的采集周期再处理,那样执行滞后太明显。
我做了两件事:第一,在注册表里划出几个“即时写区域”,写请求到达时立即执行硬件动作(比如GPIO写、PWM配置),并且同步更新快照;第二,写请求完成后,立即触发一次局部快照更新,保证同一个缓存段里其他寄存器还是新值,避免读出“旧蒙太奇”数据。
另外,写请求本身也可能跨多个寄存器,如果恰好横跨两个缓存段,需要两段同时更新。我这里的做法是,无论写多少个寄存器,在响应用写成功之前,统一重写所有缓存段。因为写请求频率通常远低于读请求,全量重建5段缓存的开销可忽略。
3.5 发送分片队列的实现要点
响应预生成做好之后,发送环节要把帧交给LWIP。这里我用一个发送队列,长度为8,入队函数在队列满的时候直接丢弃最老的分片,以保证新鲜度。有人会问,为什么不阻塞发送?因为ModbusTCP从站的响应要快,如果因为网络拥堵让一个慢客户端拖住协议栈,其他客户端也会受影响。丢弃最老分片虽然不优雅,但对SCADA系统来说,丢一个历史周期数据远比延迟超时好。
#define TX_QUEUE_SIZE 8 static modbus_fragment_t tx_queue[TX_QUEUE_SIZE]; static int tx_head = 0, tx_tail = 0; int tx_queue_push(modbus_fragment_t *frag) { int next = (tx_head + 1) % TX_QUEUE_SIZE; if (next == tx_tail) { // 队列满,丢弃最老的一帧 tx_tail = (tx_tail + 1) % TX_QUEUE_SIZE; } memcpy(&tx_queue[tx_head], frag, sizeof(modbus_fragment_t)); tx_head = next; return 0; }发送任务从队列取帧,填上事务ID后send()。这里的发送分片不单指大帧,还包括“把数据帧拆成小块,逐段写入socket”的情况。因为LWIP在发送缓冲区不足时,一个write()调用会阻塞很久,所以我每次最多写64字节,循环写完一整帧。实测下来,即使TCP窗口只有几百字节,发送任务也能保持流畅。
3.6 Arduino环境下的轻量自研方案
如果你还在用Arduino IDE而且不想换到ESP-IDF,自研一个轻量级的ModbusTCP从站其实也够用,核心是把上面3.2的接收解析和3.3的快照缓存组合起来。整体结构是:
- 用
WiFiServer监听502端口 - 每客户端分配一个
rx_buf(我实测每个连接给128字节接收缓冲也够,靠分片处理撑住数据量) - 收到完整帧后用
processModbusFrame分发到读/写处理 - 读请求处理直接走预生成缓存,不现场读寄存器数组
用Arduino做嵌入式的缺点是想精细控制线程栈大小比较难,因为Arduino core的WiFiServer是运行在loop()里配合yield机制。实测下来同时两个客户端轮询是稳的,四个客户端就开始掉包。如果你真要多客户端,还是建议上ESP-IDF。不过作为原型验证和教学演示,Arduino版本完全够用,代码跑通后迁移到esp-modbus也不难。
4. 调试与性能优化实录
4.1 用mbpoll和Wireshark验证分片缓存是否生效
分片缓存到底有没有生效,不能靠感觉,抓包是硬道理。我电脑上装了一个mbpoll工具,它本身就是ModbusTCP主站客户端。用法很简单:
mbpoll -m tcp -p 502 192.168.1.100 -t 3 -r 1 -c 125这条命令的意思是:以TCP方式连接192.168.1.100的502端口,功能码03(读保持寄存器),从地址1开始读125个寄存器。命令返回后,同时用Wireshark抓包,过滤规则用tcp.port == 502。
正常时Wireshark列表里每个请求都对应一个响应,响应长度应该是7 + 1 + 1 + 250 = 259字节,一个TCP segment搞定。如果抓到的是TCP拆包,比如一个响应分成两个segment,那说明你可能把send()的一次性大帧直接交给了阻塞写,LWIP在窗口不足时自己拆了包。这本身不致命,但会带来微小的时序抖动;如果加上分片发送队列,你会看到响应帧整体性更好。
验证分片缓存是否命中,我用了一个土办法:给rebuild_all_segments函数加一个计数器,再在读请求处理函数里加另一个计数器。当缓存命中时,读请求处理函数不回源读取寄存器,只做memcpy,所以两个计数器的差值就是真正省掉的现场采集次数。我实测读频率100ms周期时,缓存命中率接近100%,只有崩溃重启后第一次请求才会触发一次全量重建。
4.2 实测数据:响应延迟和内存占用
固定条件:ESP32-S3,WiFi AP模式,一台PC用mbpoll以100ms周期轮询,寄存器表512个保持寄存器,每次读125个。分别测三组配置:
| 配置 | 平均响应延迟 | 最大响应延迟 | 丢帧率 | 可用内存 |
|---|---|---|---|---|
| 不缓存,现场采集+现拼响应 | 8.2ms | 47ms | 0.4% | 48KB |
| 快照缓存,请求时拼响应 | 4.1ms | 15ms | 0.1% | 56KB |
| 快照缓存 + 分段预生成 + 分片发送队列 | 2.3ms | 9ms | 0% | 82KB |
第三组的优势不仅在延迟。不缓存时,读请求过来要先查硬件,这个过程可能被采集任务占用互斥锁,于是请求等锁,延迟就上去了。预生成方案则把“准备数据”这个动作彻底移出了请求路径,请求路径只剩memcpy和send,延迟自然降下来。可用内存从48KB提升到82KB,主要是因为第三组不再需要为每个请求临时分配一个300字节的响应缓冲,全部复用内存池。
当然,延迟这东西有个边际效应。如果上位机只是偶尔读一次,8ms和2ms你根本感知不到区别。但SCADA系统都是几百毫秒周期连续轮询,这时候延迟抖动会直接影响PLC的扫描周期,能稳定在个位数毫秒才是工业现场要的效果。
4.3 WiFi网络层需要配合的优化
分片缓存解决的是应用层和业务层问题,但ESP32的WiFi和LWIP设置也得同步调。我踩过的坑是默认LWIP的发送缓冲太小,导致TCP吞吐上不去。在sdkconfig里要改两个关键项:
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=8192 CONFIG_LWIP_TCP_WND_DEFAULT=8192 CONFIG_LWIP_TCP_RECVMBOX_SIZE=128发送缓冲和接收窗口都改成8KB,这是ESP32网络比较稳的甜点区。再改大,内存吃不消;再改小,拆包就会变多。另外,WiFi省电模式的CONFIG_PM_ENABLE我建议直接关掉,开着省电模式时TCP延迟动不动就跳20ms,完全不适合工业轮询。这两个改动对分片缓存的落地效果影响不小,没有足够的socket缓冲,你就算应用层做了分片队列,底层窗口还是卡你。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 现象 | 根因 | 解法 |
|---|---|---|
| 第一个请求正常,后面全部超时 | 事务ID没正确回填,上位机不认 | 检查预生成缓存填事务ID的时机,必须在发送前填 |
| 读125个寄存器偶尔返回数据错位 | 粘包拆包后没把缓冲剩余字节搬回开头 | memmove时长度参数写错,用剩余长度 = rx_len - total_len |
| 多个客户端连接后内存爆掉 | LWIP默认socket缓冲过大 | 调小socket缓冲,配合自定义发送队列 |
| 响应延迟周期性突然增大 | WiFi省电模式唤醒造成 | 关闭CONFIG_PM_ENABLE |
| 写寄存器后读取的是老数据 | 写操作只改了协议栈寄存器数组,没更新缓存段 | 写响应前重新rebuild_all_segments() |
| 抓包看到TCP层大量重传 | 发送缓冲配置太小,窗口不断收缩 | 按上文配置调大CONFIG_LWIP_TCP_SND_BUF_DEFAULT |
| 偶尔收到功能码为0的响应 | 内存越界导致帧数据被踩 | 检查分片池长度是否对齐到4字节,以及越界写防护 |
5.2 一个容易被忽略的时钟同步问题
如果上位机轮询周期和你后台采集周期正好成整数倍关系,会出现“拍频干扰”。比如采集周期50ms,轮询周期100ms,因为缓存更新和请求处理的时序存在相位漂移,有时会连续两次读到同一份旧数据,有时两次读到新数据,表现在上位机上就是数据一会儿跳变一会儿不动。
解决办法是给每个缓存段加一个update_tick_ms时间戳,在读请求处理时判断:如果该段缓存还没过期(比如距离上次重建不到一个采集周期),就直接返回;如果过期了,先重建再返回。这样能消除拍频干扰,也让上位机看到的数据变化频率与实际采集频率一致。这也是我在前文segment_cache_t里留下update_tick_ms字段的原因。
5.3 实战心得:这套方案还能扩展到哪里
分片缓存这个思路不局限于ModbusTCP。做MQTT网关时,如果上报的JSON负载很大,把payload按主题分段缓存,能有效降低序列化开销;做HTTP服务时,把大页面静态化分段缓存,也是同样的套路。核心思想都是:高频读取的内容避免路径上重复计算,低频变化的数据用快照提供一致性。
我个人对ESP32上做工业协议最深的体会是,不要指望把PC服务器的方案原样搬过来。PC上你有无限内存和CPU,现拼响应毫无压力;ESP32上必须把“数据准备”和“数据发送”解耦,用缓存换时间。这次做的分片缓存方案,在一台装着ESP32-S3的网关里连续跑了三个多月没重启,四台组态软件以200ms周期轮询,ModbusTCP响应延迟稳定在2~4ms,也算是对这套设计最好的验证了。