ESP32 ModbusTCP 分片缓存

说明:本文重点讲解标志位窗口方案实现 ModbusTCP 分片缓存,不涉及 xTaskGetTickCount 计时漂移修复,聚焦业务逻辑设计、结构体定义、代码、测试方法。

一、需求背景

硬件:ESP32,作为 ModbusTCP 服务端,接收 TCP 客户端(ModbusPoll)下发标准 ModbusTCP 报文(主要 03 读保持寄存器指令)。 业务链路:TCP 收到报文 → 解析 ModbusTCP 请求 → 匹配缓存。

  • 如果缓存命中:直接返回缓存数据,不经过 RF 模块抄表,提升响应速度;
  • 如果缓存未命中:下发 RF 抄表,收到从站返回数据后存入缓存;
  • 缓存规则:每一条独立的 Modbus 请求单独管理生命周期。

一条请求唯一标识:从站地址 + 功能码 + 寄存器起始地址 + 寄存器长度

  • 超时规则:某条缓存,连续 5 分钟没有被客户端访问,则缓存失效(EN=0);
  • 只要 5 分钟窗口内被访问过,缓存继续保留,重新进入下一轮 5 分钟检测窗口。

选型:标志位窗口方案 ,不使用每个缓存条目单独保存时间戳。 原理:用一个全局的 5 分钟检测窗口,窗口内用access_flag标记该缓存条目是否被访问;窗口到期统一遍历判断所有缓存。

二、核心原理(大白话)

想象有一个 5 分钟的检查窗口:

  1. 窗口开启瞬间,所有有效缓存的access_flag全部清零;
  2. 在这 5 分钟内,只要客户端访问这条缓存,就把这条缓存的access_flag = 1;
  3. 5 分钟窗口时间到,遍历全部缓存槽位:
    • 缓存使能EN=1,并且access_flag ==1:窗口内有访问,缓存保留,access_flag清零,等待下一轮窗口;
    • 缓存使能EN=1,并且access_flag ==0:整个 5 分钟没人访问,缓存失效,EN=0,清空缓存数据;
  4. 窗口重置,开启新一轮 5 分钟计时。

优点:

  1. 不需要每个缓存项存独立时间戳,内存开销小;
  2. 逻辑简单,调试直观,日志打印EN、access_flag就能直接看状态;
  3. 每条缓存独立判断,互不干扰。 局限:超时是窗口粒度,最大误差等于窗口周期(5 分钟),业务允许这种误差时非常适合。

三、结构体定义(分片缓存)

tcp_section[]就是分片缓存数组,SECTION_NUM代表最大缓存条目数量,最多同时缓存 N 条不同 Modbus 请求。

复制代码
// 缓存条目结构体
typedef struct
{
    uint8_t EN;                  // 缓存总使能标志 1:有效 0:失效
    uint8_t addr;                // Modbus从站地址
    uint8_t funcode;             // 功能码,03/04
    uint16_t reg_addr;           // 寄存器起始地址
    uint16_t reg_len;            // 读取寄存器长度
    uint8_t buf[BUF_SIZE];       // 缓存的Modbus应答报文数据
    uint16_t buflen;             // 缓存数据有效长度
    uint8_t access_flag;         // 【核心标志位】本窗口是否被访问 1=访问过 0=未访问
}format_typedef;

// 全局缓存数组
format_typedef tcp_section[SECTION_NUM] = {0};

字段说明:

  • EN:缓存是否可用。EN=0 时,这条缓存槽位是空的,可以存入新请求;EN=1 代表缓存有效,可以应答客户端。
  • access_flag:窗口访问标记标志位,只在 5 分钟窗口内生效,不是计时器。
  • 四元组(addr,funcode,reg_addr,reg_len)作为匹配 key,用来判断新来 TCP 报文是否命中已有缓存。

四、全局变量与宏定义

复制代码
#define TAG "TcpTrans"
#define SECTION_WINDOW_TICK 300000   //5分钟窗口计数阈值
uint32_t section_window_tick = 0;   //全局窗口计时器

五、TCP 接收回调:收到 ModbusTCP 报文,匹配缓存

TcpTrans_Rec_Callback是 TCP 接收回调,收到客户端 ModbusTCP 报文后执行:

  1. 解析 ModbusTCP,提取从站地址、功能码、起始寄存器、寄存器长度;

  2. 遍历tcp_section数组,逐个对比四元组;

  3. 如果匹配成功(命中缓存):将access_flag =1,直接读取缓存 buf 回复客户端;

  4. 如果遍历完没有匹配缓存:寻找 EN=0 的空槽位,新建缓存,EN=1、access_flag=1,把请求送入 RF 任务去抄表。

    void TcpTrans_Rec_Callback(uint8_t *buf,uint16_t len,uint8_t sock)
    {
    uint8_t i,j;
    UTIL_BUFFRCV_W(TAG,buf,len);

    复制代码
     // 省略ModbusTCP报文解析,得到addr,funcode,reg_addr,reg_len
     
     //第一步:遍历缓存,查找是否存在匹配的条目
     for(i = 0; i < SECTION_NUM; i++)
     {
         if(tcp_section[i].EN == 1)
         {
             if( tcp_section[i].addr == addr &&
                 tcp_section[i].funcode == funcode &&
                 tcp_section[i].reg_addr == reg_addr &&
                 tcp_section[i].reg_len == reg_len )
             {
                 //命中缓存!置访问标志位
                 tcp_section[i].access_flag = 1;
                 UTIL_LOG_E(TAG,"section[%d]命中缓存,access_flag置1",i);
                 //读取缓存数据,组装TCP应答报文,发送给客户端
                 //......发送代码省略
                 return;
             }
         }
     }
    
     //走到这里:没有命中缓存,寻找空闲槽位新建缓存
     for(j = 0; j < SECTION_NUM; j++)
     {
         if(tcp_section[j].EN == 0)
         {
             UTIL_LOG_E(TAG,"新建缓存 section[%d]",j);
             tcp_section[j].EN = 1;
             tcp_section[j].addr = addr;
             tcp_section[j].funcode = funcode;
             tcp_section[j].reg_addr = reg_addr;
             tcp_section[j].reg_len = reg_len;
             tcp_section[j].access_flag = 1; //新建缓存,本窗口标记为已访问
             //把请求压入消息队列,交给RF任务去抄表
             osMessageQueuePut(tcp_trans.TxQueue,&Tbuf,0,0);
             break;
         }
     }

    }

关键点:只要访问缓存,access_flag 必须置 1,告诉超时检测逻辑:这个窗口内这条指令有人访问,不能过期。

六、5 分钟窗口轮询检测逻辑(标志位核心代码)

放在网口 / 状态机轮询任务里面,循环执行,section_window_tick持续累加。 当计数达到阈值,触发一次全缓存遍历,执行标志位判断。

复制代码
// 放在while(1)轮询任务中
section_window_tick++;
if(section_window_tick >= SECTION_WINDOW_TICK)
{
    section_window_tick = 0; //重置窗口,开启下一个5分钟
    UTIL_LOG_E(TAG,"5min窗口到达,遍历检查section缓存");
    for(uint8_t i = 0; i < SECTION_NUM; i++)
    {
        if(tcp_section[i].EN == 1)
        {
            if(tcp_section[i].access_flag == 0)
            {
                //整个5分钟窗口没有收到这条指令,缓存失效
                UTIL_LOG_E(TAG,"section[%d] 5min无访问,缓存失效",i);
                tcp_section[i].EN = 0;
                tcp_section[i].buflen = 0;
                memset(tcp_section[i].buf,0,sizeof(tcp_section[i].buf));
            }
            else
            {
                //窗口内有访问,缓存保留,清空access_flag,等待下一轮窗口
                UTIL_LOG_E(TAG,"section[%d]窗口内有访问,保留缓存,清空access_flag",i);
                tcp_section[i].access_flag = 0;
            }
        }
        else
        {
            //EN=0,空槽位,跳过
            UTIL_LOG_E(TAG,"section[%d]缓存槽位空闲",i);
        }
    }
}

逻辑拆解

  1. 窗口到期,进入 for 循环遍历全部分片缓存;
  2. 有效缓存EN=1:
    • access_flag == 0:5 分钟窗口全程没有访问 → 关闭 EN,清空缓存数据;
    • access_flag == 1:窗口内有访问,保留缓存,清零 access_flag,准备下一轮计时;
  3. EN=0:空闲槽位,跳过。

注意:access_flag只在窗口到期时统一清零,收到报文只做置 1,不做清零! 这是标志位窗口方案最核心的要点,不要写反。

七、断网 / 重连场景:缓存整体清空

TCP 断开、WiFi 重连时,所有缓存全部失效,防止旧脏缓存残留:

复制代码
for(uint8_t i = 0 ; i<SECTION_NUM; i++)
{
    tcp_section[i].EN = 0;
    tcp_section[i].access_flag = 0;
    tcp_section[i].buflen = 0;
    memset(tcp_section[i].buf,0,sizeof(tcp_section[i].buf));
}

八、ModbusPoll 测试方案(验证分片缓存 + 标志位超时)

测试环境

电脑 ModbusPoll 作为 TCP 客户端,连接 ESP32 设备。 最少设备:1 台 ESP32 + ModbusPoll 软件,不需要额外硬件。

测试用例

用例 1:新建缓存,命中缓存
  1. ModbusPoll 下发 03 读寄存器指令 A(从站 1,起始 0,长度 10);
  2. ESP32 无缓存,触发 RF 抄表,收到数据存入 section 缓存,EN=1,access_flag=1;
  3. 再次下发指令 A,代码命中缓存,access_flag 被置 1,直接返回缓存数据,不再触发 RF 抄表。
用例 2:多条分片缓存独立管理
  1. 下发指令 A,存入 section 0;
  2. 下发指令 B(从站 2,起始 10,长度 5),存入 section 1;
  • A、B 两条缓存互相独立;持续轮询 A,B 不轮询;5 分钟窗口到期后,B 缓存失效,A 保持有效。
用例 3:5 分钟超时失效
  1. 下发指令 A,建立缓存;
  2. 停止发送指令 A,等待 5 分钟窗口到达;
  3. 窗口触发遍历,检测 access_flag=0 → EN 置 0,缓存清空;
  4. 再次下发 A,会重新新建缓存,重新走 RF 抄表流程。
用例 4:持续轮询,缓存永久保活
  1. 循环下发指令 A,每几十秒轮询一次;
  2. 每个窗口内 access_flag 都会被置 1;
  3. 每次窗口到期,access_flag 清零,缓存 EN 保持 1,缓存永久有效。
相关推荐
GG.WinGel9 分钟前
质数数目(蓝桥杯练习题)
java·数据结构·算法·青少年编程·蓝桥杯
泡泡糖的中文规格书14 分钟前
HDLbits 2.2 学习笔记 · 多路复用器
java·开发语言
John_Hui17 分钟前
第 3 篇:配置 JDK 17 环境
java·开发语言·ubuntu
秋天的怀念_David22 分钟前
算法一千零一夜 · 第十七夜 长街点灯
java·程序人生·算法
不吃鱼的羊38 分钟前
如何应对恐惧
java
不吃鱼的羊40 分钟前
如何建立高自尊
java·服务器·前端
Magicdata大数据41 分钟前
AI辅助教学:调用大模型从教材中生成习题,方便学员练习
java·spring boot·大模型·知识图谱·neo4j·ai辅助教学
香蕉大香蕉(06敬畏市场)1 小时前
高频八股 · Java 并发编程(10):异步、ThreadLocal 与虚拟线程(收官)
java·开发语言·网络·jvm·python·ai·实习
想做小南娘,发现自己是女生喵1 小时前
LAN Device Monitor 第二周学习文档
java·服务器·数据库
念何架构之路1 小时前
Go reflect包值操作 Value 的读写、可设置性与 interface 布局
开发语言·后端·golang