☰
ModbusTCP采集优化:ESP32分块缓存机制解决寄存器轮询性能瓶颈
2026/10/12 1:02:44 网站建设 项目流程

前阵子接手了一个老产线改造,核心是把一台旧控制器的运行数据采集进新平台。控制器没有更高级的接口,只有一路以太网口,支持 ModbusTCP;要读的寄存器四百多个,分布在电流、电压、温度、累计量和报警字等多个区域。起初我觉得这事不难——ESP32 做客户端,循环发 ModbusTCP 请求就完事了。可真到了现场联调才发现,问题恰好出在"循环发请求"这四个字上:不是 ESP32 跑不动,而是 Modbus 协议本身不允许一次把 400 个寄存器全拿回来,必须在请求层面做分片;与此同时,多段重复读取又把本就一般的工业网络拖得更慢。最后我在 ESP32 上实现了一层"分片缓存",按固定大小的寄存器块做缓存和合并读取,算是把这个问题彻底解决了。这篇把这个小项目的设计过程、核心代码和踩坑记录整理出来,给做 ModbusTCP 采集网关的朋友一个可直接参考的落地方案。

1. 现场问题复盘:不是设备慢,而是请求方式有问题

1.1 最初版本的"暴力轮询"

第一版代码写得很直白:根据设备文档把寄存器表分成十几个区间,每个区间对应一组启动参数、运行参数或报警字,然后在 while 循环里挨个发起 ModbusTCP 读请求。大部分区间只有 2~10 个寄存器,但因为设备文档没有给连续的寄存器映射,所以每一段都是独立请求。数了一下,完整采集一轮要发 86 个请求。

单独看每个请求都不慢,局域网内 RTT 也就几毫秒,设备处理请求也就十几毫秒。但串起来就出问题了:86 个请求按照 10~30ms 一个算,一轮轮询就要 1 秒到 1.5 秒。这还不算 Wi-Fi 在真实环境里的重传、TCP 超时等待,遇到网络抖动时一轮下来两三秒都正常。于是采集周期只能放到 3 秒以上,丢数据的报警还时不时冒出来。

1.2 ModbusTCP 报文上限是绕不开的硬约束

排查性能问题之前,先得弄清楚一个底层事实:Modbus 应用层报文(PDU)最大只有 253 字节。这个限制是从 RS485 时代继承下来的,帧太长在串口链路上容易出错,所以协议设计者把长度卡得很死。到了 ModbusTCP,虽然以太网帧本身能装 1500 字节,协议也允许更大的 TCP 段,但 Modbus 报文内部依然沿用 253 字节的约束。

以最常用的读保持寄存器功能码 03 为例,请求部分只带"起始地址 + 寄存器数量",响应部分要带"字节数 + 寄存器数据"。一个寄存器占 2 字节,于是单次响应最多能装的寄存器数量是:

(253 - 1 功能码 - 1 字节计数字节) / 2 = 125 个

写多个寄存器(功能码 16)更紧,因为请求本身就要带上要写的数据,算下来单次最多 123 个寄存器。

项目数值
Modbus PDU 最大长度253 字节
读保持寄存器(FC03)单次最大数量125 个寄存器
写多个寄存器(FC16)单次最大数量123 个寄存器
ModbusTCP MBAP 头长度7 字节
TCP 承载的最大 ADU 长度260 字节

这就意味着,四百多个寄存器的地图,协议层已经替我们分好了片:至少 4 次 FC03 请求才能读完。任何号称"一次拉取全部寄存器"的实现,要么是违反了协议,要么是偷偷在底层做了合并。

1.3 为什么"多读几次"治标不治本

有人会说:那就把每个区间多读几次、提高频率不就完了?实测下来完全不是这么回事。

第一,每一次 Modbus 事务都有固定的通信开销。TCP 连接虽然保持不复用,但每个请求仍然要走一次完整的"发送-设备处理-响应-确认"循环。请求数量翻倍,等待时间基本就翻倍。

第二,数据被大量重复读取。日志模块读电流区间,告警模块读报警字区间,组态模块读参数区间,大家都从设备端拿原始数据。同一份数据在同一秒内被网络传了好几遍,纯属浪费。

第三,请求越频繁,对脆弱设备的压力越大。现场控制器本身的 CPU 并不强,每个请求都要做一次协议解析和数据查表。密集的小请求会让设备端的任务调度变得不稳定,响应时间反而抖动。

所以问题的本质很清楚:我们需要的不只是"改大请求块"或者"缩短超时",而是在 ESP32 和 ModbusTCP 设备之间加一层能复用数据的缓存,并且把分散的小读操作合并成协议允许的整块大读。

2. 分片缓存的设计思路:从"按需读"到"按块缓存"

2.1 寄存器空间怎么分块?块大小到底选多少

先定下第一个原则:缓存不能以单个寄存器为单元。四百多个寄存器每个都记录状态和过期时间,管理复杂、命中率低、内存碎片化严重。正确做法是像内存管理里的"页"一样,把整个寄存器空间切成固定大小的块,缓存的最小单位就是整块。

地址和块的换算关系很简单:

块号 = 地址 / 块大小 块内偏移 = 地址 % 块大小

比如块大小定为 100,地址 235 就会被映射到块 2、偏移 35。读取请求落到块 2 上时,第一次会发出一个覆盖地址 200~299 的 FC03 请求,之后凡是落在该块内的读请求都可以直接从缓存返回。

块大小的选择直接决定系统的表现。我画了一张参考表:

块大小优点缺点适用场景
32内存占用小,单次响应快块数量多,补读次数多寄存器总量小、请求频率低的场景
64均衡仍会有较多网络请求通用测试环境
100兼容面广,单响应约 200 字节对超大寄存器地图块数偏多大多数现场设备
125协议理论最大,网络请求最少部分设备不支持最大长度实验室或标准 Modbus 测试工具

我最终默认用的是 100,而不是协议上限 125。原因很实际:现场有好几款控制器,其中一款的手册里明确写着"单次读取寄存器数量不得超过 100"。虽然理论值 125 没问题,但遇到这种不做标准实现的老设备,你一次发 125 个寄存器过去,它要么返回异常码,要么干脆不回。与其在协议边缘试探,不如直接在缓存层把块大小收敛到设备的安全范围内。

最后一个块要特殊处理。如果寄存器总量不是块大小的整数倍,最后一块的读取数量要按剩余量截断,否则会读到超出设备寄存器表的地图外地址,触发 Illegal Data Address 异常。

2.2 缓存块的有效性与 TTL

每个缓存块需要记住几样东西:寄存器数据本身、数据加载的时间戳、是否有效,以及当前连接代次。有效性不能只用一个布尔量表示,因为"有效"和"新鲜"是两回事。

数据刚从设备读回来时,既有效又新鲜。随着时间推移,缓存块的新鲜度会下降,但数据本身并没有错——设备端的寄存器值可能没变,也可能变了,我们不知道。这时候有两种策略:

  • 严格模式:TTL 一到就判定缓存过期,下一次读请求必须重新走网络。
  • 宽松模式:TTL 到了仍然先返回旧值,同时在后台异步刷新,避免读请求阻塞等待。

工业数据采集我倾向于严格模式,因为控制逻辑和告警判断对数据时效性敏感。但 TTL 的值不能一刀切。运行参数区可以设 500ms~1s,配置参数区可以放宽到 10s 以上,累计量这类变化慢的数据甚至能到 30s。有效性和 TTL 分开管理之后,调参只需要改配置表,不用动主逻辑。

2.3 读写时缓存如何联动

读路径逻辑很直接:先查块,命中且未过期就返回;未命中或已过期就补读整个块,再返回数据。这里有一个关键点——补读永远读的是整块,而不是只读请求缺失的那一小段。

写路径复杂一些。缓存系统里最安全的数据一致性策略是"写直通 + 写后失效":先把数据通过 FC16 写到设备,然后立刻把涉及的缓存块全部标记为无效。为什么失效而不直接更新本地缓存?因为设备端很可能对写入值做了钳位、换算或联动修改,比如你把温度写到上限值,设备内部会把关联的风机转速一起调整。本地缓存去"猜"设备的最终状态,十有八九会猜错。直接失效,让下一次读请求从设备拉取权威值,是最省心也最稳妥的做法。

2.4 多个小读请求的合并效应

分块缓存还有一个容易被低估的好处:它天然把小读请求合并成了大读请求。

举个实际例子:A 任务每 100ms 读 10 个电流寄存器,B 任务每 500ms 读 20 个电压寄存器,C 任务偶尔读 5 个设备状态字。这三个区间如果落在同一个缓存块里,第一个请求触发一次整块网络读取,后面所有请求都命中缓存。原本平均每秒几十个网络事务,直接降成每个 TTL 周期几个事务,设备端只是"咦,怎么这么清闲"。

3. ESP32 上的工程实现:直接可用的代码骨架

3.1 数据结构与常量定义

我用的是 ESP-IDF 环境,FreeRTOS + lwIP 的 BSD socket。首先定义块大小和缓存结构:

#include <stdio.h> #include <string.h> #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "esp_log.h" #include "esp_timer.h" #include "lwip/sockets.h" #define MODBUS_TCP_PORT 502 #define TOTAL_REGS 400 // 设备寄存器地图总长度 #define CACHE_BLOCK_SIZE 100 // 块大小,可按设备实际限制调整 #define CACHE_BLOCK_COUNT ((TOTAL_REGS + CACHE_BLOCK_SIZE - 1) / CACHE_BLOCK_SIZE) #define CACHE_TTL_MS 1000 // 默认过期时间 1 秒 #define MODBUS_TIMEOUT_MS 500 // 单次事务超时 typedef struct { uint16_t data[CACHE_BLOCK_SIZE]; // 寄存器数据 uint32_t loaded_ms; // 加载时间戳 uint16_t valid; // 是否有效 uint16_t gen; // 连接代次 } cache_block_t; static cache_block_t s_cache[CACHE_BLOCK_COUNT]; static SemaphoreHandle_t s_cache_mutex; // 保护缓存数组 static SemaphoreHandle_t s_mb_mutex; // 串行化 Modbus 网络事务 static int s_sock = -1; static uint16_t s_tid = 0; static uint16_t s_gen = 1; static inline uint32_t now_ms(void) { return (uint32_t)(esp_timer_get_time() / 1000); } static inline uint16_t mb_min_u16(uint16_t a, uint16_t b) { return a < b ? a : b; }

CACHE_BLOCK_COUNT 用向上取整的方式计算,天然处理了最后一块不完整的问题。s_mb_mutex 和 s_cache_mutex 是两个独立的信号量,分别保护"网络 socket 访问"和"缓存数组访问",后面会解释为什么必须分开。

3.2 网络事务层:带超时和拼帧的收发

ModbusTCP 是跑在 TCP 上的,TCP 是字节流,不是消息边界。一次 recv 可能只收到半帧,也可能一次收到两帧。网上很多示例代码假设"发一次请求就能 recv 到完整响应",这在局域网里大多是侥幸,压力一大就会出诡异问题。

所以我单独写了一个帧接收函数,先收 MBAP 头,从 Length 字段算出整个帧长度,再循环收满:

static esp_err_t mb_recv_frame(uint8_t *buf, uint16_t buf_len, uint16_t *got, uint16_t exp_tid) { uint16_t idx = 0; uint16_t frame_len = 0; int64_t deadline = esp_timer_get_time() + MODBUS_TIMEOUT_MS * 1000; while (esp_timer_get_time() < deadline) { int want = 6; if (idx >= 6) { want = ((buf[4] << 8) | buf[5]) + 6; // Length 字段 + MBAP 头 } if (want > buf_len) { return ESP_ERR_INVALID_SIZE; } int r = recv(s_sock, buf + idx, want - idx, 0); if (r > 0) { idx += r; if (idx >= 6) { frame_len = ((buf[4] << 8) | buf[5]) + 6; } if (frame_len && idx >= frame_len) { break; } } else if (r == 0) { return ESP_ERR_INVALID_STATE; // 对端关闭 } else { if (errno != EAGAIN && errno != EWOULDBLOCK) { return ESP_ERR_INVALID_STATE; } } } if (idx < 6) { return ESP_ERR_TIMEOUT; } uint16_t tid = ((uint16_t)buf[0] << 8) | buf[1]; if (tid != exp_tid) { return ESP_ERR_INVALID_RESPONSE; // 事务 ID 不匹配 } *got = idx; return ESP_OK; }

MCU 上的 socket 需要提前设置好接收超时(SO_RCVTIMEO),比如 50ms。这样 recv 会周期性返回 EAGAIN,外层循环靠时钟判断整体是否超时,不影响其他任务调度。

事务函数把请求发给设备并等待响应。所有网络访问必须拿 s_mb_mutex,保证同一时刻只有一个事务在 socket 上跑:

static esp_err_t mb_transaction(uint8_t fc, const uint8_t *pdu, uint16_t pdu_len, uint8_t *resp, uint16_t *resp_len) { uint8_t frame[7 + 1 + 253]; uint16_t tid = ++s_tid; frame[0] = tid >> 8; frame[1] = tid & 0xFF; // Transaction ID frame[2] = 0; frame[3] = 0; // Protocol ID,Modbus 固定为 0 frame[4] = (pdu_len + 2) >> 8; // Length:Unit ID + FC + PDU frame[5] = (pdu_len + 2) & 0xFF; frame[6] = 0x01; // Unit ID,按设备配置 frame[7] = fc; memcpy(frame + 8, pdu, pdu_len); xSemaphoreTake(s_mb_mutex, portMAX_DELAY); int err = send(s_sock, frame, 8 + pdu_len, 0); if (err <= 0) { xSemaphoreGive(s_mb_mutex); return ESP_ERR_INVALID_STATE; } esp_err_t ret = mb_recv_frame(resp, 256, resp_len, tid); xSemaphoreGive(s_mb_mutex); return ret; }

有了这个事务层,读写寄存器就只剩组包和解包了。读保持寄存器:

static esp_err_t mb_read_holding_regs(uint16_t addr, uint16_t qty, uint16_t *dst) { uint8_t pdu[4]; pdu[0] = addr >> 8; pdu[1] = addr & 0xFF; pdu[2] = qty >> 8; pdu[3] = qty & 0xFF; uint8_t resp[256]; uint16_t resp_len = 0; esp_err_t err = mb_transaction(0x03, pdu, 4, resp, &resp_len); if (err != ESP_OK) { return err; } if (resp_len < 3 + 2 * qty) { return ESP_ERR_INVALID_RESPONSE; } if (resp[0] != qty * 2) { // 字节计数字段 return ESP_ERR_INVALID_RESPONSE; } for (uint16_t i = 0; i < qty; i++) { dst[i] = (resp[1 + i * 2] << 8) | resp[2 + i * 2]; } return ESP_OK; }

3.3 缓存读路径:命中和未命中

核心读取函数按"循环处理每个涉及的块"来写。fast path 只拿缓存锁,慢速路径先释放缓存锁、再去读网络、最后回填缓存。这避免了长时间持锁导致其他任务卡死:

esp_err_t modbus_cache_read(uint16_t addr, uint16_t *out, uint16_t n) { uint16_t done = 0; while (done < n) { uint16_t cur = addr + done; uint16_t blk = cur / CACHE_BLOCK_SIZE; uint16_t off = cur % CACHE_BLOCK_SIZE; uint16_t chunk = mb_min_u16(n - done, CACHE_BLOCK_SIZE - off); xSemaphoreTake(s_cache_mutex, portMAX_DELAY); cache_block_t *b = &s_cache[blk]; uint32_t age = now_ms() - b->loaded_ms; bool fresh = b->valid && b->gen == s_gen && age < CACHE_TTL_MS; if (fresh) { memcpy(out + done, &b->data[off], chunk * sizeof(uint16_t)); xSemaphoreGive(s_cache_mutex); done += chunk; continue; } xSemaphoreGive(s_cache_mutex); uint16_t start = blk * CACHE_BLOCK_SIZE; uint16_t qty = mb_min_u16(CACHE_BLOCK_SIZE, TOTAL_REGS - start); uint16_t tmp[CACHE_BLOCK_SIZE]; if (mb_read_holding_regs(start, qty, tmp) != ESP_OK) { return ESP_FAIL; } xSemaphoreTake(s_cache_mutex, portMAX_DELAY); memcpy(b->data, tmp, qty * sizeof(uint16_t)); b->loaded_ms = now_ms(); b->valid = 1; b->gen = s_gen; memcpy(out + done, &b->data[off], chunk * sizeof(uint16_t)); xSemaphoreGive(s_cache_mutex); done += chunk; } return ESP_OK; }

这里有两个容易忽略的细节。

细节一:chunk 的计算保证了跨块读取不会被截断。比如地址 95 读 10 个寄存器,第一次处理块 0 的 5 个(offset 95,长度 5),done 变成 5,然后处理块 1 的 5 个。后续拿到的是拼接好的连续数据。

细节二:tmp 临时数组直接放在栈上。CACHE_BLOCK_SIZE 是 100,tmp 占 200 字节,对这个量级的 MCU 没问题。如果块大小调到 125 并且系统里任务栈紧张,可以把 tmp 放到静态区或从堆分配。

3.4 缓存写路径与失效

写路径比读路径短得多:先写设备,再失效涉及的缓存块。这样最安全,也最不容易引入一致性 bug。

esp_err_t modbus_cache_write(uint16_t addr, const uint16_t *data, uint16_t n) { uint8_t pdu[5 + 2 * 123]; pdu[0] = addr >> 8; pdu[1] = addr & 0xFF; pdu[2] = n >> 8; pdu[3] = n & 0xFF; pdu[4] = n * 2; // 字节计数 for (uint16_t i = 0; i < n; i++) { pdu[5 + i * 2] = data[i] >> 8; pdu[6 + i * 2] = data[i] & 0xFF; } uint8_t resp[256]; uint16_t resp_len = 0; if (mb_transaction(0x10, pdu, 5 + n * 2, resp, &resp_len) != ESP_OK) { return ESP_FAIL; } xSemaphoreTake(s_cache_mutex, portMAX_DELAY); for (uint16_t i = 0; i < n; i++) { uint16_t b = (addr + i) / CACHE_BLOCK_SIZE; s_cache[b].valid = 0; } xSemaphoreGive(s_cache_mutex); return ESP_OK; }

写入数量 n 被严格限制在 FC16 允许的 123 个寄存器以内。如果某个业务逻辑要一次写超过这个量,调用方得在上层拆分,这个缓存层不替调用方做跨功能码的重组。

3.5 并发保护与断线重生

嵌入式系统里常见误区是"反正只有一个任务在采集,不用加锁"。但实际上报警处理、日志上传、远程调试这些任务都可能调读接口,所以在缓存层必须做好互斥。两个信号量的分工是:s_mb_mutex 保证 socket 同一时刻只有一个事务;s_cache_mutex 保证缓存数组不会在回填过程中被别的任务读到半截。

需要特别注意的是,绝不能持有 s_cache_mutex 去做网络收发。网络一发一收至少几十毫秒,长时间持锁会让其他读请求全部阻塞。我上面的代码就是先释放缓存锁再读网络,回填时再拿锁,这个顺序看起来简单,却是整个并发设计的关键。

断线重连后,设备可能重启过,寄存器值可能已经全部复位。如果缓存还停留在旧数据上,上层拿到的是过期值,会造成逻辑误判。解决方法是给缓存加一个"连接代次"字段 s_gen。每次 TCP 连接断开重连成功,s_gen 就自增,所有缓存块的 gen 和当前代次不一致,统一判失效:

static void invalidate_cache_on_reconnect(void) { s_gen++; xSemaphoreTake(s_cache_mutex, portMAX_DELAY); for (int i = 0; i < CACHE_BLOCK_COUNT; i++) { s_cache[i].valid = 0; s_cache[i].gen = 0; } xSemaphoreGive(s_cache_mutex); }

这样重连后第一个读请求会强制走网络,把设备当前的真实状态拉回来,绝不会出现"看起来连着但数据还是半天前的"这种隐蔽问题。

4. 实测对比:轮询周期从秒级到毫秒级是怎么发生的

4.1 三种方案在同一台设备上的实测

搭了一套可复现的测试环境:一块 ESP32 开发板通过 Wi-Fi 连接同网段的 PC,PC 上跑一个 ModbusTCP 从站模拟器,寄存器地图 400 个,中间的数据每隔几十毫秒变化一次,模拟真实控制器的运行状态。

第一版"暴力轮询"的实测结果是:完整采集一轮 86 个小请求,平均耗时约 1.1 秒,网络稍微拥挤一点就直接到 1.8 秒。用示波器抓应用层数据可以清楚看到一串密集的短请求,像有的人写日志一样零碎。

第二版我不加缓存,单纯把读取改成整块轮询:400 个寄存器分 4 块,每块 100,完整采集一轮只需要 4 个请求,单轮耗时降到约 120ms。这已经能明显改善采集周期,但问题依然存在:日志任务、告警任务、组态读取任务还是各自发起请求,同一份数据反复读,而且每轮都重新拉全量,累计量这类不常变化的寄存器也被高频刷新。

第三版就是前面写的分块缓存方案,TTL 设 1 秒。测试结果如下:

方案首轮采集耗时稳态单轮耗时每轮网络请求数
逐段直读(不缓存)约 1.1s约 1.1s86 个
整块轮询(不缓存)约 120ms约 120ms4 个
分块缓存(TTL=1s)约 120ms0~120ms0~4 个

稳态下如果所有需要的块在 TTL 内仍然有效,整个采集轮询只消耗 ESP32 本地 CPU,网络请求数为 0。TTL 到期后的那一轮会触发 4 个块级刷新,耗时又回到 120ms,但随后又进入"几乎零成本"的缓存命中阶段。整体算下来,平均每轮的网络事务数量从 86 降到了不到 1。

4.2 命中率与 TTL 的关系

缓存命中率很大程度取决于上层业务怎么读数据。实测中我们的日志和告警逻辑读取规律性很强,基本集中在十几个固定区间,所以缓存预热完成后命中率长期接近 100%。

如果业务是随机地址读取,比如配置管理工具偶尔从分布式的偏移地址读单寄存器,命中率会下降。但即使如此,分块缓存依然能保证同一块的第二次读取不打网络。这一点在 Wi-Fi 不稳定的环境里价值很大:把多个随机小读合并成整块大读,相当于降低了对链路质量的敏感度。

TTL 的调整也直接影响命中率。1 秒的 TTL 适合实时性要求高的运行参数区;测试后我把配置参数区的 TTL 单独调到了 10 秒,那里一年都改不了几次,没必要跟着运行数据显示频繁刷新。

4.3 对端设备类型的兼容性说明

测试了两种常见的 ModbusTCP 从站实现:一种是标准模拟器,几乎支持 125 单次上限;另一种是现场真实控制器,对超长请求比较敏感。分块缓存把块大小定为 100 之后,两类设备都没有出现异常码。

有些老设备还有另一个脾气:请求发得太密会直接吞帧。取消缓存后并发多任务可能瞬间打出几十个小请求,设备忙不过来;有了缓存与 TTL 的削峰,设备端几乎也处于一种"被动降频"的状态。对老旧设备来说,这是保护作用大于减速作用。

5. 实际调试中踩过的坑

5.1 地址偏移与分块错位的经典 bug

第一版缓存代码跑起来后,某些块老是频繁补读,命中率就是上不去。打印日志发现,应用层传入的地址和协议层实际发出的地址差了 100 多。原因是在设备侧的组态工具里,寄存器地址是按"PLC 扩展地址"显示的,比如 400101,但 ModbusTCP 报文里要填的地址是 0 基址的协议地址,100 对应的是扩展地址 400101 减去 400001 的差。两层地址一混淆,缓存索引算出来的块和实际设备数据的位置对不上,自然每次都 miss。

解决方法是驱动层统一做地址归一化:所有外部接口只认协议 0 基址地址,扩展地址转换在上层组态解析时完成,缓存层永远不碰"400xxx"这类表示法。调试时打日志也只打印 0 基址地址,省得自己看混。

5.2 32 位数据的字序问题

Modbus 寄存器本身是 16 位,且报文内字节是大端序,这一点标准文档写得很清楚。但 32 位数据跨两个寄存器时,哪个寄存器是高字、哪个是低字,协议里并没有规定,完全看设备厂商心情。

我遇到的那台控制器,累计量是"低字在前",另一台测试设备则是"高字在前"。同一个上位程序,同一套缓存代码,两次数据组合出来的 32 位整型方向截然不同。最后我在缓存层的上层加了一个简单的字序配置项,读取出来组合时按其配置决定是否交换高低寄存器:

static uint32_t build_u32(uint16_t lo, uint16_t hi, bool swap) { if (swap) { return ((uint32_t)lo << 16) | hi; } return ((uint32_t)hi << 16) | lo; }

这个问题和分片缓存无关,但调试时会严重影响你对缓存正确性的判断。如果组合出来的数值乱跳,先别怀疑缓存,先检查字序。

5.3 recv 一次收不完整帧的拼帧处理

一开始我偷懒,用一次 recv 期望收到完整响应,结果在现场环境出现了零星的数据错误。原因就是前面说的 TCP 字节流特性:设备响应可能被 TCP 分段,也可能和下一个请求的数据一起到达 socket 缓冲区。后来老老实实上了按帧长度循环接收的代码,问题彻底消失。

这里还有一个连带坑:如果来不及处理 socket 缓冲区里的旧数据,下一次请求发出的响应可能会和旧响应混在一起。因为事务 ID 每次递增,旧响应的事务 ID 和当前请求不匹配,会被直接丢弃。我在 mb_recv_frame 里校验了事务 ID,实际上就是给字节流加了一个排序和去重机制。

5.4 掉线重连后缓存要不要作废

最开始没做连接代次,重连后缓存依然有效,结果出现过一次很吓人的现象:设备已经重启了,寄存器全部清零,但 ESP32 还在上报几分钟前的旧数据。后来加了代次机制,每次重连就让整个缓存整体失效,宁可多花 120ms 重建缓存,也不能让上层拿到不可信的数据。

同理,如果从站设备在业务上被复位(比如远程 watchdog 重启),我们也应该手动触发一次 invalidate_cache_on_reconnect。把这个函数封装成对外接口后,所有业务都能在关键时刻强制刷新缓存。

6. 个人体会:这套结构还能往哪些方向延伸

项目验收之后,我把这个分块缓存的思路也带到了其他采集任务里。比如 ModbusRTU 串口链路,块大小得缩到和从站允许的最大长度一致,通常是 125 或更小,同时要处理串口收发切换的延时;但只要保住"整块缓存、按需补读"这个核心思路,效果同样明显。

如果时间再充裕一点,我还会加两个改进。一是根据顺序读的特征做预取:当上层 HMI 翻页时,经常是读取第 N 块紧接着读第 N+1 块,可以在某块首次 miss 时把相邻块一起拉回来,虽然多了些网络流量,但页面切换会非常顺滑。二是把缓存块的统计信息(命中次数、补读次数、平均耗时代次)暴露出来,通过 MQTT 上报到监控平台,这样后续排查现场网络问题就多了一个视角。

这套"分片缓存"不依赖任何特殊的协议扩展,完全是标准 ModbusTCP 请求的重组与复用。如果你的网关项目也出现"寄存器读不完、轮询周期上不去、重复请求打爆设备"的问题,不妨先别急着换更贵的硬件或调 TCP 参数——先想想,数据真的每次都需要从设备实拉吗?加上一层按块管理的缓存,很可能就是性价比最高的解法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询