任何一个在工业现场调过 ModbusTCP 的人,对“分片”这两个字多少都有点复杂情绪。我在做一款数据采集网关的时候,遇到一个很具体的需求:一台 ESP32 设备,要同时充当多个 ModbusTCP 从机的数据采集器,还要做一层本地缓存,供上位机随时取数。设备侧加在一起上千个寄存器,协议单帧最多读 125 个,网络链路偶尔还断一下。这一串约束叠在一起,“ESP32 ModbusTCP 分片缓存”这个项目就自然诞生了。
乍看这个标题像个小工具,但它背后,其实是一个工业物联网关的核心数据通路。只要把下面几个问题想明白,整个项目就稳了一半:单帧读不完的数据怎么拆?拆完怎么保证结果一致?从 TCP 字节流里怎么切出完整的 Modbus 帧?缓存放 RAM 还是 Flash?掉电会不会丢?某一段从站超时,会不会拖垮整个缓存刷新链路?
这篇文章就是这套排雷过程的完整记录。适合手里有 ESP32 开发板、想做 ModbusTCP 采集或网关工程的开发者,也适合在工控现场被设备轮询和缓存问题折腾过的嵌入式工程师。
1. 先把问题拎清楚:这个项目到底在解决什么
1.1 场景模型:这台网关在工控现场长什么样
先把场景具体化。假设现场有 4 台 ModbusTCP 设备:空压机、温控器、流量计、电表。空压机有 80 个保持寄存器,包括运行状态、排气温度、排气压力、运行时长、故障码;温控器有 50 个寄存器;流量计有 40 个;电表有 120 个。正常情况下,系统需要每秒钟把所有设备的数据刷新一遍,缓存到 ESP32 本地,上位机再通过 HTTP 接口拉取。
为什么不让上位机直接去连这些设备?两个原因。第一,很多工控设备的 ModbusTCP 服务能力很弱,同时接入两个以上 TCP 客户端就开始丢包、超时,电表尤其明显。第二,上位机的采集程序希望逻辑简单,它只需要访问网关一个地址,不想去处理多台设备的协议差异。于是 ESP32 就成了“ModbusTCP 主站 + 数据缓存 + 对外服务”的三合一网关。
“分片缓存”里的“分片”,是主站和从机通信时的数据拆分;“缓存”则是三层存储联动。读操作和存操作是流水线的上游和下游,必须放在一起设计,否则后面会遇到一堆割裂的问题。
1.2 为什么非分片不可:Modbus 协议的单帧硬限制
这是全项目第一条必须遵守的铁律。标准 Modbus 功能码 0x03(读保持寄存器)和 0x04(读输入寄存器),PDU 中寄存器字节计数只有 1 字节,最多表示 255 字节。每个寄存器占 2 字节,理论上最多能读 127 个,但大量协议栈和从机实现会主动把上限压到 125,有些甚至只有 123。我统一策略是:单片不超过 120 个寄存器,留 5 个余量,避免从机实现差异导致边界报错。
假设一台设备有 300 个寄存器,从地址 0 开始:
| 分片序号 | 起始地址 | 寄存器数 | 地址范围 |
|---|---|---|---|
| 第 1 片 | 0x0000 | 120 | 0 — 119 |
| 第 2 片 | 0x0078 | 120 | 120 — 239 |
| 第 3 片 | 0x00F0 | 60 | 240 — 299 |
每一片都是一次完整的 ModbusTCP 请求。如果设备寄存器地址区间不连续,比如中间有一段未实现地址,还得拆得更细。我后来干脆做成按区间配置的方式,用一张区间表驱动分片,不在主循环里写死。
1.3 缓存分两层:快速访问走 RAM,掉电参数进 Flash
分片读回来的数据,第一落点一定是 ESP32 内部 SRAM,只有内存扛得住高频查询。比如上位机每 100ms 来一次 HTTP 轮询,每次都去真设备读寄存器,网络带宽、设备响应上限、连接稳定性,没有一样能撑住。RAM 缓存的目标,是“最短时间内给出最近一次快照”。
但 RAM 掉电就没了。关键参数,比如设备校准值、地址映射表、最近一次完整采集快照,掉电后需要恢复。这时候用 NVS 或者 LittleFS。实测下来,NVS 适合放几百字节的小键值,像分片配置表;完整快照几百字节到几 KB 之间,LittleFS 更合适。NVS 每次操作都要擦写扇区,频繁写会加速 Flash 磨损。
所以缓存架构定为三层:RAM 实时镜像、NVS 配置项、LittleFS 快照存档。上层查询先看 RAM 缓存新鲜度,不新鲜才触发从站分片读取。
2. ModbusTCP 的数据通路与分片重组的底层原理
缓存架构定了之后,就要动手啃 ModbusTCP 本身。网上很多示例代码是串口 ModbusRTU,走 TCP 的案例少,而且容易踩字节序、粘包这些坑。
2.1 MBAP 头:一帧在字节流里的“身份证”
ModbusTCP 帧格式,固定前 7 个字节是 MBAP 头,后面跟 1 字节功能码和若干数据。MBAP 头里最重要的两个字段是事务标识符和后续长度。事务标识符是请求-响应配对的纽带;后续长度告诉你这一帧还剩多少字节,是整个解帧逻辑的核心。
| 字段 | 长度 | 作用 |
|---|---|---|
| 事务标识符 | 2 字节 | 请求序号,响应必须原样返回 |
| 协议标识符 | 2 字节 | 固定 0x0000 |
| 后续长度 | 2 字节 | 从单元标识符开始到帧尾的字节数 |
| 单元标识符 | 1 字节 | 从站编号,类似 RTU 的站地址 |
| 功能码 | 1 字节 | 0x01/02/03/04/05/06... |
| 数据区 | N 字节 | 请求参数或响应数据 |
注意,多字节字段一律是大端序,高字节在前,低字节在后。ESP32 的 FreeRTOS 主机本身是小端序,直接强转指针读取会读反,必须手动移位拼接。这个点我一开始没注意,导致解出来的寄存器数量出现 65535 这种怪值,回头排查才发现是字节序弄拧了。
2.2 TCP 流分片重组:你以为收到的是帧,其实是一串字节
TCP 是字节流协议,它不保证一次 recv 拿到的就是一个完整 ModbusTCP 报文。上层发了一个 100 字节的 Modbus 帧,底层 TCP 栈可能拆成三个包,也可能把两个 Modbus 帧合并在一个包里。这就是“分片”这个题的核心考点之一。
处理思路不复杂,核心是用接收缓冲区攒数据,再按 MBAP 头里的长度字段切帧。流程是:
- 循环 recv,把新数据追加到接收缓冲区尾部。
- 判断缓冲区长度是否大于等于 7 字节,不够继续等。
- 读第 4、5 字节算出后续长度,加 6 字节头部,得到完整帧长。
- 缓冲区长度大于等于帧长,说明攒齐了一帧,取走处理。
- 剩余字节前移,回到第 2 步继续解,也许里面还有一帧。
这段逻辑写成函数大约几十行,但它是整个通信稳定性的地基。这里漏处理,Modbus 事务标识符会全部错位,寄存器数据牛头不对马嘴。
2.3 请求-响应配对:一个串行循环的事,别贪多线程
经常有人问:能不能多个 socket 并发轮询多个从站?理论可以,但没必要。ModbusTCP 和 ModbusRTU 一样是主从问答制,一次只允许一个未完成的请求事务。ESP32 虽然有多核,并发主站要自己管理每个事务标识符的匹配,逻辑复杂度会明显上升。我的方案是单任务串行轮询,用一个状态机遍历配置表里的每一台设备、每台设备的每一片,发请求、等响应、写缓存、再发下一片。实际轮询一圈的时间,在几十台设备规模内是毫秒级到几百毫秒级,完全够用。
3. 分片缓存核心设计:参数、结构与调度策略
这是整个项目最重的部分,也是标题里“分片缓存”的落点。前面的 ModbusTCP 通信只是通路,如何高效拆、存、取,才真正决定网关体验。
3.1 用分片配置表驱动:把“怎么读”变成可配置的数据结构
第一个经验:别把分片逻辑写死在主流程里。设备地址段有没有空洞、哪些寄存器是只写、哪些变化快、哪些一个月不变一次,这些差异最好用一张结构体表表达。我设计的配置项类似这样:
typedef struct { uint16_t start_addr; // 寄存器起始地址 uint16_t count; // 寄存器数量 uint16_t timeout_ms; // 本片超时 uint8_t priority; // 刷新优先级 uint32_t refresh_interval_ms; // 刷新周期 } slice_config_t;初始化阶段调用分片生成函数,得到运行时分片表。运行时主轮询循环只遍历这张表,逻辑变化只改配置,不碰主代码。
分片数量计算很简单:总数除以 120 向上取整。但有个坑:起始地址不一定为 0。比如从地址 100 开始读 200 个寄存器,最后一片是 100 + 120 + 120 = 340,计算地址时注意别让 uint16_t 溢出。
3.2 RAM 缓存结构:时间戳 + 有效位 + 环形索引
内存缓存表面是数组加时间戳,实际里面藏着“新鲜度”和“一致性”两个关键设计。我做成以寄存器地址为索引的缓存表:
#define CACHE_MAX_REG 4096 typedef struct { uint16_t value; // 寄存器值 uint32_t last_ms; // 最近一次更新时间 uint8_t valid; // 是否已初始化 uint8_t dirty; // 是否有写操作未同步 } cache_reg_t; static cache_reg_t s_cache[CACHE_MAX_REG];查询流程很简单:先判断该寄存器valid是否为 1,再看last_ms距今是否小于 TTL 阈值,最后才返回value。不新鲜就触发一次整片刷新。
TTL 设置不能太死。比如采集周期是 1 秒,TTL 也设 1 秒,就会让上位机每次查询都命中过期数据,甚至陷入“永远在等刷新”的循环。我建议 TTL 取刷新周期的 1.2 到 1.5 倍,给刷新过程留出时间余量。
缓存命中率值得量化。实测上位机高频查询时命中率能到 95% 左右,只有每个完整刷新周期前后会触发一次底层读取。这个数据说明缓存不是可有可无的优化,而是解放设备 CPU 和网络的关键。
3.3 分片刷新调度:优先级与时间窗
不同寄存器的重要程度不一样。比如空压机故障码、排气温度是报警类,希望刷新频率高;流量计累计量变化慢,刷新频率可以低。这时候分片调度就不能是简单均速轮询,要给每个分片配置刷新周期,再用“下次到期时间”驱动调度。
核心逻辑如下:
while (1) { now = get_tick_ms(); next_slice = NULL; for (each slice in slice_table) { if (now >= slice.next_due_ms) { if (next_slice == NULL || slice.next_due_ms < next_slice->next_due_ms) { next_slice = &slice; } } } if (next_slice) { read_and_cache(next_slice); next_slice->next_due_ms = now + next_slice->refresh_interval_ms; } else { vTaskDelay(1); } }报警类寄存器每 200ms 刷一次,累计量类每 5 秒刷一次,整体数据刷新密度和实时性都能满足,又不会把所有从站通信时间占满。
3.4 Flash 持久化与掉电恢复
RAM 缓存解决快速读取,但掉电即失。项目里有一个要求:网关重启后,上位机即使没有马上按新配置初始化,也要能从缓存读到“最近一次成功采集”的数据。因此每完成一整轮全部分片刷新后,我会把快照写入 LittleFS 的二进制文件。
这里最大的坑就是断电瞬间写文件会损坏。我做了两层防护:
- 双镜像文件:写 A 文件时,先完整写一个临时文件,再改文件名指针切到 A。重启读取时优先读当前指针指向的那份,校验文件头 CRC,损坏就切到另一份。
- 分批写:大快照不要一次写几十 KB,按分片序号分批追加,保证每一片数据完整且可独立校验。即使中途断电,最多丢最后一片,不会整库报废。
实测中 ESP32 的 Flash 擦写次数足够做日志型写入,但如果每秒钟写一次,一年就是 3000 多万次,会把 Flash 写穿。所以快照落地频率控制在“每轮完成一次”,对上位机来说延迟完全可接受。
4. 实操过程:从零到跑的完整实现
前面把设计讲清楚了,接下来聊落地。这一节偏实战,包括工程结构、核心代码片段、关键接线和实测数据。
4.1 开发环境与工程骨架
项目基于 ESP-IDF v5.1 稳定分支。为什么不用 Arduino?因为 ESP-IDF 在 lwIP 网络栈、FreeRTOS 任务调度、NVS、LittleFS 这些底层组件的接口更完整,尤其做多任务协同和掉电恢复时,原生环境更顺手。工程模块分四块:
modbus_tcp.c:MBAP 解帧、建连、收发slice_scheduler.c:分片表管理、轮询调度cache_store.c:RAM 缓存读写、命中判断flash_snapshot.c:LittleFS 快照写入与恢复
4.2 ModbusTCP 主站核心收发代码
TCP 连接部分使用 lwIP 的 socket 接口。核心解帧逻辑如下,重点看缓冲区切分:
#define RX_BUFFER_SIZE 4096 static uint8_t s_rx_buf[RX_BUFFER_SIZE]; static uint16_t s_rx_len = 0; void mb_rx_process(void) { while (s_rx_len >= 6) { uint16_t mbap_len = (s_rx_buf[4] << 8) | s_rx_buf[5]; uint16_t frame_len = mbap_len + 6; if (frame_len > RX_BUFFER_SIZE) { s_rx_len = 0; // 异常帧,清空缓存 break; } if (s_rx_len < frame_len) { break; // 数据未收齐,等待下一次 recv } handle_mbap_frame(s_rx_buf, frame_len); memmove(s_rx_buf, s_rx_buf + frame_len, s_rx_len - frame_len); s_rx_len -= frame_len; } }发送请求时手工拼 MBAP 头,事务标识符每次自增并存到待响应结构。响应回来时检查事务标识符是否匹配,不匹配直接丢弃继续等。这是防止乱序响应的兜底措施。
4.3 分片缓存联调:一个完整的采集周期
初始化阶段从 NVS 读分片配置,构建分片表。每台设备对应一个 TCP 连接,用非阻塞 connect + select 轮询。整个采集周期关键日志如下:
[tx] slot #0, txid=12, addr=0x0000, cnt=120 [rx] slot #0, txid=12, len=245, status=OK [cache] slot #0 updated, miss=0 hit=34 [tx] slot #1, txid=13, addr=0x0078, cnt=120 [rx] slot #1, txid=13, len=245, status=OK [cache] slot #1 updated, miss=1 ... [snapshot] all 4 devices, 1248 regs, write 3.2KB, 18ms上面的miss=0 hit=34意思是这一片覆盖的 120 个寄存器里,有 34 个在刷新前已被查询过,其余是首次填充。整个 4 台设备、1248 个寄存器的完整刷新,在 100M 局域网内实测约 120ms,包括 TCP 建连、请求-响应往返、缓存写入和快照存档。这个速度对绝大多数工控轮询需求绰绰有余。
4.4 上位机访问接口
缓存做出来要给上层用。我对外提供一组 HTTP REST 接口,最简单的一个是:
GET /api/reg/{start}/{count} response: {"code":0,"data":[...],"ts":1710000000}上位机一次能拿到任意连续地址段的寄存器快照,不用关心底层分片逻辑。同时提供POST /api/write_reg写寄存器,写成功后同步更新 RAM 缓存值并置 dirty 标志,下一轮刷新以写后值为准。这就是“读缓存、写直通”的一致性策略。
5. 高频问题与排查技巧实录
这节是一个多月调试日志里挑出来的典型问题,每个都曾经让人头疼过。
5.1 TCP 粘包导致事务标识符错乱
这个问题前面预告过,实际症状是:缓存值偶尔是上一次读到的旧值,不同地址区间的数据“串味”。排查方法:在解帧函数打日志,发现收到的帧头事务标识符不连续,甚至出现重复 txid。确认 TCP 粘包后,修复就是那段s_rx_buf切帧逻辑。修复后连续跑 48 小时,再没出现一次错帧。
5.2 单从站超时拖垮整条轮询链
某天流量计重启,TCP 连接断开,整个 ESP32 采集任务卡在 connect 函数里。原因很直接:单任务内同步调用 socket,一个阻塞等待超时,后续所有分片全部排队。解决办法:
- 所有 socket 操作改成非阻塞 + select 超时,超时时间按分片配置走;
- 连接不上的从站标记为 offline,直接从轮询名单摘除,后台心跳任务探测恢复后再插回去。
改完后单点故障隔离效果立竿见影,某个采集目标掉线,其他设备缓存刷新完全不受影响。
5.3 Flash 写入次数与磨损
最初用 NVS 存快照,调用nvs_set_blob存 2.5KB 数据,测试一段时间后日志出现 NVS 报错。后来改成 LittleFS 文件快照加双镜像,并把快照频率降到“每完成一轮刷新写入一次”,磨损风险基本可控。额外加一个保护:连续两次快照内容一致时跳过写入,能再省一点写次数。
判断技巧:Flash 写入速度越来越慢,或者读到数据偶发错误,优先怀疑是不是写太频繁。量产固件里提前算清楚写入概率,比事后怀疑硬件可靠得多。
5.4 寄存器地址漂移与边界错误
一次把某设备分片数从 3 片改成 4 片,最后一片写成count=120,但设备只剩 53 个寄存器,结果该片一直返回异常。之后养成习惯:分片生成函数里加边界校验,如果total_count % 120 == 0,不要补一片空数据,按实际剩余数量收尾。所有地址计算用统一基址,别一部分代码用绝对地址,另一部分用偏移,改配置时不别扭根本不可能。
5.5 多主站并发导致写冲突
项目后期有上位机想直接向设备写寄存器,结果和 ESP32 轮询任务冲突,寄存器值来回跳。排查后确认是多个 Modbus 主站同时操作同一台从机导致的经典问题。解决方案很直接:对外明确网关是唯一 Modbus 主站,写请求统一走网关 API,由网关串行化发送。对有连接权限的 MAC/IP 加白名单,这是这次事故留下的经验。
6. 个人体会与几个可扩展的方向
这个项目做完,我对“缓冲区、分片、缓存”这三件套的理解又深了一层。一开始觉得 ModbusTCP 是上世纪的老协议,没什么好折腾的,真把分片缓存做成网关后才意识到:越是工业现场的老协议,越考验系统设计的约束意识。协议的单帧限制、设备的响应上限、Flash 的磨损寿命,每一条都是“明规则之外的老经验”,写代码时不管它们,迭代时全都会变成坑。
复盘下来,最想留给大家的建议有三条:
- 分片表一定要可配置,不要写死在逻辑里。现场设备寄存器经常换,可配置才能救后期维护的命。
- 缓存永远要带时间戳,不要只存值。没有保鲜期的缓存,面对上位机查询时就是一颗定时炸弹。
- 优先隔离异常,所有网络超时、重试、断连都要能在日志里单独看出一台设备的动静,否则现场几十台设备一出问题,排查就是大海捞针。
如果后续扩展,我计划把这个缓存模型推广到混合协议接入场景,比如设备侧同时有 ModbusRTU、DL/T645 电表协议,统一归入同一套分片缓存框架;另外给快照文件加一层 AES 加密,工业数据出网关后安全等级会再上一个台阶。有同在做 ModbusTCP 采集网关的同学,欢迎交流各自的避坑经验。