前阵子给某工厂做产线数据网关,遇到一个特别典型的需求:现场老设备只有Modbus RTU接口,需要统一转成Modbus TCP交给上位机,寄存器点位大概两万出头,还分布在好几个不同的功能区。ESP32作为网关核心,第一版方案我走的是最朴素的路线——按官方文档里的写法,直接声明一个大数组,把保持寄存器、输入寄存器全部映射进内存。代码确实简单,逻辑一眼就能看明白。结果跑到现场一跑就露馅:堆内存被砍掉一大半,Wi-Fi协议栈时不时丢包,TCP连接稍微多一点系统就反复重启。
后来我把这套“全量数组映射”推翻,换成ESP32 Modbus TCP分片缓存的做法——按块加载、按需缓存、脏块延迟写回。内存占用从几十KB压到几KB级,读写响应速度反而更稳定,断电重启也不再丢数据。这篇就把完整设计思路、数据结构和实际踩过的坑全部拆开讲,适合正在做Modbus网关、设备仿真或者协议转换的朋友参考。
1. 为什么需要“分片缓存”:传统方案的三个坑
1.1 全量数组映射的内存灾难
很多ESP32 Modbus从站教程里,做法基本是:用esp-modbus组件定义一个数组,比如uint16_t holding_regs[20000],然后把指针注册给从站。两万个保持寄存器占40KB,再加一万个输入寄存器又占20KB,总共60KB的RAM就交代出去了。ESP32看似有520KB SRAM,实际能自由分配的堆大概只有300多KB,Wi-Fi协议栈和lwIP要吃掉很大一块,应用代码、任务栈、TCP连接缓冲再一分,剩下能自由支配的空间本来就不多。60KB的大数组压上去,系统启动时不报错,等上位机开始频繁轮询、TCP连接多了再分配缓冲,内存就彻底顶不住了。
我当时遇到的典型症状是:刚上电一切正常,运行十几分钟后系统自动重启,看日志是malloc failed,再查就是堆碎片化严重。这种问题排查起来特别恶心,因为不是必现,还容易被误判成Wi-Fi不稳定或者电源问题。
1.2 直接读写NVS也撑不住
那有人会说,不加数组,直接把寄存器数据放NVS,每次读写都走存储?这个方案更不靠谱。NVS的写寿命按十万次级别算,如果上位机每隔200ms写一次保持寄存器,几天就把Flash寿命耗尽,而且NVS写入是毫秒级延迟,Modbus主站的超时时间经常按几十毫秒设置,响应必超时。
更要命的是,Modbus TCP的读操作远远比写操作频繁。如果每个读请求都要从NVS里把数据查出来,响应延迟会非常难看。工业现场的上位机大多是周期轮询,几万个点位轮一圈,每次都翻Flash,纯粹是给自己找麻烦。
1.3 分片缓存到底分了什么
分片缓存的核心思想其实特别朴素:Modbus寄存器地址空间本质上是一个大数组,但主站真正频繁访问的往往只是其中一小部分。以现场常见的工艺参数为例,上位机主要盯着温度、压力、流量这几个区段反复读,历史报表那些数据半天才碰一次。那就没必要把全部数据都堆在RAM里。
把寄存器地址空间按固定大小切成很多块,RAM里只保留一块固定大小的“缓存窗口”,放当前活跃的块。主站访问某个块时,先查RAM,不在就按块号从Flash加载;写操作先改RAM里的数据,标记为脏块,后台任务再延迟批量写回Flash。热数据一直在内存里,冷数据乖乖待在Flash里,两边兼顾。这也是“分片缓存”四个字的来源:分片是地址空间切片,缓存是RAM窗口。
2. 分片缓存设计:数据结构与读写流程
2.1 分片大小与缓存槽数量怎么定
分片大小是整个设计里最重要的参数。Modbus协议单次读保持寄存器最多125个,单次写最多123个,所以块大小至少要大于125,保证单次请求最多跨两个块。我选的块大小是256个寄存器,也就是512字节。
为什么不是128个寄存器或者1024个寄存器?块太小,比如128寄存器,那2万个点位要切成157块,块数量多,索引和淘汰管理开销变大,写回时Flash擦写也碎。块太大,比如1024寄存器,缓存窗口里能放的块数就少了,容易出现“主站访问块A,块B被挤出去,过一会儿又访问块B,又把A挤出去”的抖动。256是个比较舒服的平衡点,单个块512字节,和4KB的Flash扇区还构成整除关系,8个块正好占一个扇区,写回时对齐比较友好。
缓存槽数量我建议取8。8块就是4KB数据,加上块结构体头部的开销,总共不到5KB,对ESP32来说非常轻松。槽数太少容易抖动,槽数太多RAM压力变大,实际跑下来8个槽应对2万个点位绰绰有余。
2.2 缓存块结构与查找逻辑
上代码,这是整个分片缓存最核心的数据结构:
#define BLOCK_REGS 256 #define BLOCK_SIZE (BLOCK_REGS * 2) /* 512字节 */ #define N_CACHE_BLOCK 8 typedef enum { BLOCK_INVALID = 0, BLOCK_CLEAN, BLOCK_DIRTY } block_flag_t; typedef struct { uint16_t buf[BLOCK_REGS]; /* 实际寄存器数据 */ uint16_t block_id; /* 块号,从0开始 */ uint32_t last_hit_ms; /* 最近一次访问时间戳 */ block_flag_t flag; /* 块状态 */ } cache_block_t; static cache_block_t s_cache[N_CACHE_BLOCK];块号的计算很简单:block_id = modbus_addr / BLOCK_REGS。缓存的查找就是线性遍历8个槽,比哈希表还省事:
static cache_block_t *find_block(uint16_t block_id) { for (int i = 0; i < N_CACHE_BLOCK; i++) { if (s_cache[i].block_id == block_id && s_cache[i].flag != BLOCK_INVALID) { return &s_cache[i]; } } return NULL; }这里有个细节:block_id是uint16_t,最大能表示65535个块,每块256个寄存器,换算下来寄存器空间上限约1600万个,实际工程绝对够用。块状态区分CLEAN和DIRTY,CLEAN表示RAM数据和Flash数据一致,DIRTY表示RAM被改过、还没落盘。
2.3 读路径和写路径的完整流程
读路径是Modbus里最频繁的操作。假设主站来读0x03保持寄存器,起始地址1000,连续读100个:
esp_err_t cache_read_regs(uint16_t addr, uint16_t cnt, uint16_t *out) { while (cnt > 0) { uint16_t block_id = addr / BLOCK_REGS; uint16_t offset = addr % BLOCK_REGS; uint16_t len = BLOCK_REGS - offset; if (len > cnt) len = cnt; cache_block_t *blk = find_block(block_id); if (!blk) blk = load_block(block_id); memcpy(out, &blk->buf[offset], len * 2); blk->last_hit_ms = xTaskGetTickCount(); addr += len; cnt -= len; out += len; } return ESP_OK; }重点在于按块切分循环:起始地址可能落在块中间,长度还可能跨块,所以每次取BLOCK_REGS - offset和剩余长度cnt中较小的那个,一段一段地拷贝。如果不做这个边界处理,数据错位和越界访问只是迟早的事。
写路径稍微复杂一点,因为要处理脏标记:
esp_err_t cache_write_regs(uint16_t addr, uint16_t cnt, const uint16_t *data) { while (cnt > 0) { uint16_t block_id = addr / BLOCK_REGS; uint16_t offset = addr % BLOCK_REGS; uint16_t len = BLOCK_REGS - offset; if (len > cnt) len = cnt; cache_block_t *blk = find_block(block_id); if (!blk) blk = load_block(block_id); memcpy(&blk->buf[offset], data, len * 2); blk->flag = BLOCK_DIRTY; blk->last_hit_ms = xTaskGetTickCount(); addr += len; cnt -= len; data += len; } return ESP_OK; }注意,写操作只改RAM,不碰Flash。脏块交给后台任务慢慢写。这个设计直接避免了最烦人的Flash写寿命和响应超时问题。
2.4 淘汰策略与池化写回
缓存槽只有8个,总有不够用的时候。淘汰策略我选最经典的LRU(最近最少使用),找一块最久没被访问的块踢出去。但有个原则:优先淘汰CLEAN块,因为CLEAN块不用写回Flash,直接覆盖就行。如果8个槽全是DIRTY,那就挑last_hit_ms最久的那块,先写回Flash再覆盖。
static cache_block_t *find_victim(void) { cache_block_t *victim = NULL; uint32_t oldest = 0xFFFFFFFF; for (int i = 0; i < N_CACHE_BLOCK; i++) { if (s_cache[i].flag == BLOCK_INVALID) return &s_cache[i]; if (s_cache[i].flag == BLOCK_CLEAN && s_cache[i].last_hit_ms < oldest) { oldest = s_cache[i].last_hit_ms; victim = &s_cache[i]; } } if (victim) return victim; /* 全是脏块,选最旧的强制写回 */ oldest = 0xFFFFFFFF; for (int i = 0; i < N_CACHE_BLOCK; i++) { if (s_cache[i].last_hit_ms < oldest) { oldest = s_cache[i].last_hit_ms; victim = &s_cache[i]; } } flush_block(victim); return victim; }后台写回任务一般是500ms扫描一次,把脏块按时间顺序批量落盘。但如果系统突然空闲,比如上位机停止轮询了,下一次扫描到来前脏块一直留在RAM里,这时候可以考虑加一个“空闲立即写回”的判断:如果连续1秒没有新的Modbus请求,就把所有脏块全部落盘。这个优先级可以调低,空闲时写不会影响Modbus响应。
3. Modbus TCP接入与缓存联动
3.1 为什么选择自己解析Modbus TCP帧
可能有人会问:ESP-IDF自带esp-modbus组件,LibModbus也现成,为什么非要自己解析帧?这个问题我纠结过。esp-modbus组件确实方便,但它的数据访问接口默认还是面向“连续数组映射”设计的,比如往holding_regs指针指到的大数组里塞数据,分片缓存这种非连续的存储结构要跟它对接,得写一层很别扭的适配层,反而把框架绑死了。
LibModbus的从站逻辑也类似,底层已经有很成熟的TCP帧解析,但中间层同样假设寄存器存放在一个连续的内存映射里。自己解析其实没有想象中可怕,Modbus TCP帧格式非常固定,核心代码也就一百行左右,而且可以完全掌控异常处理和缓存联动逻辑,排查问题更快。
3.2 TCP粘包拆包与请求解析
Modbus TCP跑在TCP协议上,TCP是字节流,一个recv返回的数据可能包含半个请求,也可能一次收到好几个请求。初学阶段最容易踩的坑就是默认一次recv就是完整一帧。我这个项目里第一个版本就吃过这个亏,上位机一旦把几个请求连着发出来,解析就会乱掉,随之而来的是一堆异常的非法功能码响应。
正确做法是维护一个buffer,把收到的字节先攒起来,按帧头长度字段来判断什么时候攒够一帧:
typedef struct { uint8_t buf[512]; uint16_t len; } modbus_conn_t; static int read_modbus_frame(int fd, modbus_conn_t *conn, uint8_t *frame) { while (1) { if (conn->len >= 6) { uint16_t frame_len = (conn->buf[4] << 8) | conn->buf[5]; if (conn->len >= frame_len + 6) { memcpy(frame, conn->buf, frame_len + 6); /* 把剩余字节移到前面,留给下一帧 */ uint16_t remain = conn->len - (frame_len + 6); memmove(conn->buf, conn->buf + frame_len + 6, remain); conn->len = remain; return frame_len + 6; } } int n = recv(fd, conn->buf + conn->len, sizeof(conn->buf) - conn->len, 0); if (n <= 0) return -1; conn->len += n; } }MBAP头一共7个字节:事务ID(2字节)、协议ID(2字节,固定为0)、长度字段(2字节)、单元号(1字节)。长度字段的值是后续PDU的字节数加1,所以一帧总长度是frame_len + 6。先把头部6字节收够,再按长度字段把PDU收完整,这就是最可靠的拆帧方法。
3.3 功能码处理与缓存API对接
拿到完整帧之后,处理逻辑就清晰了。先是事务ID和协议ID直接原样拷贝到响应头,协议ID必须是0,Unit ID一般随主站配置,这里不改。PDU部分按功能码分发,最核心的是这三个:
- 0x03:读保持寄存器,请求里带起始地址和数量,走
cache_read_regs,响应填字节数和数据。 - 0x06:写单个保持寄存器,请求里直接带地址和值,走
cache_write_regs,响应原样回显请求。 - 0x10:写多个保持寄存器,请求里带起始地址、数量和寄存器数据,走
cache_write_regs,响应回显起始地址和数量。
协议细节里最值得注意的就是长度上限。0x03单次最大读125个寄存器,超出要回异常;0x10单次最大写123个寄存器。Modbus规范规定PDU里寄存器数据字段是字节计数,单字节最大255,减掉其他字段的占用,读上限就是125,写上限是123。别图省事不检查这个,有的上位机对超限请求会直接报通讯故障。
static void handle_pdu(int fd, modbus_conn_t *conn, uint8_t *frame, uint16_t len) { uint16_t tx_id = (frame[0] << 8) | frame[1]; uint8_t unit = frame[6]; uint8_t *pdu = frame + 7; uint8_t fc = pdu[0]; switch (fc) { case 0x03: { uint16_t addr = (pdu[1] << 8) | pdu[2]; uint16_t cnt = (pdu[3] << 8) | pdu[4]; if (cnt > 125) { send_exception(fd, tx_id, unit, fc, 0x03); break; } uint16_t tmp[256]; if (cache_read_regs(addr, cnt, tmp) != ESP_OK) { send_exception(fd, tx_id, unit, fc, 0x02); break; } uint8_t resp[7 + 1 + 1 + cnt * 2]; fill_header(resp, tx_id, unit, 1 + 1 + cnt * 2); resp[7] = 0x03; resp[8] = cnt * 2; memcpy(resp + 9, tmp, cnt * 2); send(fd, resp, sizeof(resp), 0); break; } /* 0x06和0x10类似,写走cache_write_regs */ } }这里有个细节:读取用的临时bufferuint16_t tmp[256],栈上直接开512字节。Modbus单次最多读125个寄存器,也就是250字节,256个寄存器的大小足够覆盖所有情况,还能顺手应对一些不规范主站发的126个请求。
3.4 多连接并发保护
工业现场经常有PLC和上位机同时连上来,两个客户端可能同时读写同一个寄存器,所以分片缓存必须加锁。不加锁的话,两个连接同时操作同一个缓存块,数据会被交叉写坏。
我用的是FreeRTOS mutex:
static SemaphoreHandle_t s_cache_lock; static void cache_lock(void) { xSemaphoreTake(s_cache_lock, portMAX_DELAY); } static void cache_unlock(void) { xSemaphoreGive(s_cache_lock); }然后在cache_read_regs和cache_write_regs的入口加锁、出口解锁。锁的粒度是整段读写操作,一次Modbus读最多125个寄存器,内存操作都是微秒级,不会造成明显的响应延迟。但有一点要特别注意:不要在持锁状态下做Flash写回,Flash操作是毫秒级,会让Modbus响应直接超时。写回操作拿锁后只交换指针或者标记状态,实际写Flash放在后台任务里锁外执行。
4. 持久化设计与掉电保护
4.1 存储介质选择:自定义分区加wear_levelling
脏块写回最终要落到ESP32的Flash上。直接裸调esp_partition接口当然可以,但每个块的地址要自己算擦除偏移,磨损均衡也得自己处理,工作量不小。
ESP-IDF自带的wear_levelling组件就是干这个的,它把底层Flash抽象成一个可寻址的文件系统区域,自动处理扇区擦除和磨损均衡。配合一个简单的文件,读写就跟普通文件一样。我在项目里专门划了一个256KB的data分区,格式化后用wear_levelling挂载,然后开一个固定大小的文件cache_bank.bin,长度就是总块数乘以512字节。
#define FLASH_BASE_ADDR 0 #define FLASH_FILE "/spiflash/cache_bank.bin" static esp_err_t flash_init(void) { esp_vfs_spiffs_conf_t conf = { .base_path = "/spiflash", .partition_label = "storage", .max_files = 4, .format_if_mount_failed = true, }; esp_err_t ret = esp_vfs_spiffs_register(&conf); if (ret == ESP_OK) { FILE *fp = fopen(FLASH_FILE, "r+"); if (!fp) { fp = fopen(FLASH_FILE, "w+"); /* 写入初始数据 */ } fclose(fp); } return ret; }注意,这里引入了一个重要的工程细节:文件里的数据长度必须和设备上正在用的块配置一致。如果以后改了BLOCK_REGS或者N_CACHE_BLOCK,Flash里老数据就作废了。我在文件开头存一个16字节的配置头,包含magic、块大小、块数量、版本号,打开文件时先校验,不一致就重新初始化。这个设计帮我避免了好几次“升级固件后寄存器数据全部乱掉”的惨剧。
4.2 写回策略:定时扫描加阈值强制
后台写回任务用FreeRTOS的vTaskDelay(pdMS_TO_TICKS(500))周期性扫描。每次扫描检查所有缓存块,凡是DIRTY的块,按脏时间排序后逐个写回。
static void flash_flush_task(void *arg) { while (1) { vTaskDelay(pdMS_TO_TICKS(500)); cache_lock(); for (int i = 0; i < N_CACHE_BLOCK; i++) { if (s_cache[i].flag == BLOCK_DIRTY) { write_block_to_flash(&s_cache[i]); s_cache[i].flag = BLOCK_CLEAN; } } cache_unlock(); } }但光有定时写回不够。如果上位机疯狂连续写,后台任务还没轮到,缓存里的脏块可能越积越多,直到8个槽全是脏数据。这时候再触发淘汰,被迫同步写Flash,Modbus响应就会卡顿。所以我在写路径里加了阈值判断:如果DIRTY块数量大于等于4,立刻唤醒一次后台写回,而不是干等500ms。
上面的flush任务里,write_block_to_flash直接放在锁里了,实际项目里千万别这么干。Flash写一个块大概需要几十毫秒,持锁几十毫秒意味着Modbus读请求全部排队,必然超时。正确做法是先把指向脏块的指针挪到一个待写队列里,释放锁,再在锁外执行写盘。
4.3 掉电检测与紧急落盘
延迟写回最大的隐患是掉电。如果两个写周期之间突然断电,最后一次写入的数据就丢了。工业现场电源波动是家常便饭,必须兜底。
我做的第一层兜底是缩短写回周期,从500ms改成200ms,脏数据在RAM里的生命周期控制在200ms以内,但这还不够保险。
第二层兜底是掉电检测电路。在电源输入端接一个电压比较器,检测到主电源掉到阈值以下时,拉一个GPIO中断给ESP32。此时利用电源板上电容储存的残留电量,把当前所有脏块紧急写入Flash。ESP32从检测到掉电到完全复位,一般有几十毫秒的窗口,足够把几个脏块写进去。
static void power_loss_isr(void *arg) { BaseType_t hi = pdFALSE; vTaskNotifyGiveFromISR(flush_trigger_task_handle, &hi); portYIELD_FROM_ISR(hi); }这个方案的难点在于硬件电容容量要够。我当时算过,紧急写4个块约2KB数据,加上文件系统开销,要保证几十毫秒的维持时间,电源输入端至少得放4700μF的电解电容。如果空间允许,用10000μF更稳妥。
如果业务上某些数据绝对不能丢,比如配方参数、校准值,那就不要用延迟写回,单独给这些区域走“写透”模式:每次Modbus写操作更新RAM的同时同步写Flash。代价是单次写入延迟高一点,但这类配置参数的写入频率通常很低,牺牲一点点速度换可靠性完全值得。我在项目里是把寄存器空间划分成多个区段,不同区段配置不同的同步策略,可变的工艺参数走write-back,固定的设备配置走write-through。这个灵活性正是自己做缓存管理的好处。
4.4 可选:RTC RAM保存脏块索引
ESP32有一块8KB的RTC RAM,掉电后只要给RTC供电,数据就不会丢失。我后来在项目里利用RTC RAM保存了一份脏块索引:每次标记DIRTY时,同时把块号写进RTC RAM,紧急落盘后清空索引。
这样一来,如果掉电检测没有触发,或者紧急落盘只写了一半,下次上电时可以通过RTC RAM里的索引判断上次断电前哪些块还没写成功,重新补写。这个功能属于锦上添花,我建议先把掉电检测和紧急落盘做好,再做RTC RAM这层加固。毕竟RTC RAM空间有限,8KB要省着用。
5. 实测效果与踩坑排查
5.1 性能测试:脚本化压测
调试阶段我用Python的pymodbus库做了压力测试。测试场景很简单:模拟上位机每隔100ms写一次数据,然后再每隔500ms读一次1280个寄存器(跨5个块),连续跑几个小时,统计响应时间和错误率。
from pymodbus.client import ModbusTcpClient import time client = ModbusTcpClient('192.168.1.10', port=502) client.connect() start = time.time() for i in range(5000): client.write_registers(100, [1, 2, 3, 4, 5], unit=1) rr = client.read_holding_registers(100, count=1280, unit=1) if rr.isError(): print("error at", i) break elapsed = time.time() - start print("avg per loop:", elapsed / 5000 * 1000, "ms")实测下来,热数据读取(缓存命中)单次响应在1ms以内,冷数据首次读取(要从Flash加载块)大概2~3ms,完全满足Modbus TCP主流主站设备50ms超时的要求。
同样的压力条件下,全量数组方案运行不到1小时就malloc failed,分片缓存方案连续跑了72小时,堆内存曲线一条直线,稳定在6KB左右。
5.2 踩坑实录
第一个坑是TCP粘包。项目第一次联调时,上位机那边一个事务没回完就发下一帧,我的代码还在等第二帧的数据,结果一帧数据混在一起,解析出来的功能码乱七八糟。后来加上了前面讲的那个read_modbus_frame缓冲拆帧逻辑才算稳。
第二个坑是跨块写覆盖。有个主站写保持寄存器时一次性写130个,我的块大小是256,但起始地址不是对齐的,比如从地址200开始写130个,实际跨越了块0和块1。第一版代码没按块循环切分,直接把130个寄存器从起始地址一路写下去,结果块1的偏移算错,把相邻寄存器覆盖了。排查了很久才发现是边界对齐问题。
第三个坑是Flash写回把Modbus请求堵死。刚开始的flush任务直接在锁里写盘,写一个块要几十毫秒,期间所有读请求全部阻塞,上位机那边频繁报超时。后来改成锁外写盘+脏块队列,这个问题才消失。
第四个坑是wear_levelling第一次格式化。新烧录的固件第一次挂载分区时,VFS初始化会慢一些,如果这个时候上位机正好发来Modbus请求,监听任务会被阻塞。我的解决办法是在app_main里先完成分区初始化和缓存预加载,再启动TCP server,保证系统ready之前对外不开放服务。
5.3 排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 系统运行一段时间后重启,日志有malloc失败 | 内存被大数组或任务栈吃光 | 检查堆剩余空间,减少TCP连接缓冲任务栈,确认缓存槽数量是否过大 |
| Modbus响应超时 | 写回Flash在锁内阻塞了读写路径 | 确认flush任务是否持锁写盘,改为锁外写盘 |
| 掉电后部分数据丢失 | 写回周期太长或者掉电检测未触发 | 缩短写回周期,增加电容,确认掉电中断GPIO配置是否正确 |
| 写入寄存器后读取正常,重启后数据不对 | 脏块没有正确标记或Flash偏移错误 | 确认block_id到Flash偏移的换算,在文件头增加配置校验 |
| 多个主站同时连接,偶发数据错乱 | 缓存并发访问没有加锁 | 检查所有读写路径是否都走了mutex,录像复现 |
| 跨块写时相邻寄存器被覆盖 | 起始地址未按块对齐切分 | 在写路径里按BLOCK_REGS - offset分片处理 |
我个人在实际项目中的体会是,分片缓存这个架构的价值不仅仅在于节省内存,更重要的是它逼着我把“数据在内存、数据在Flash、数据在协议层”这三层关注点彻底拆开,每一层都能独立调优。最开始花了一个多小时把所有流程画在纸上,反而节省了后面好几天的联调时间。如果后续点位规模继续扩大,比如要做到十万个寄存器,只需要调整BLOCK_REGS和Flash分区大小,代码结构不用动。那个把脏块索引存进RTC RAM的小技巧,建议在掉电要求严格的现场都试试。