1. 从一次现场翻车说起:为什么ModbusTCP在ESP32上需要分片缓存
去年帮一个做小型环境监测终端的朋友处理过一个很典型的问题。设备用的是ESP32-WROOM-32模组,跑的是ModbusTCP从站,上位机每隔500ms轮询一次,读20个保持寄存器。单机测试一切正常,但现场部署了8台设备之后,上位机开始频繁报超时,抓包一看,ESP32这边偶尔会返回一个长度不对的响应帧,或者干脆把两个请求的响应粘在一起发出去。
这个问题折腾了我差不多两天。最后定位到的根因是:ESP32的LwIP协议栈在TCP发送时,如果一次send()的数据量超过了当前可用的发送窗口,或者底层MSS分片处理不当,数据会被拆成多个TCP段发出。而ModbusTCP的MBAP头里有一个2字节的长度字段,上位机解析时如果先读到半个帧,就会认为帧头损坏,直接丢弃,然后重传。重传又叠加了新的请求,恶性循环。
解决思路其实不复杂:在应用层做一个分片缓存,把ModbusTCP的响应帧先完整地攒在内存里,确认长度正确之后再一次性推给TCP层。这个思路听起来简单,但实际落地时涉及缓冲区大小怎么定、超时怎么处理、多连接怎么隔离、内存碎片怎么避免等一系列细节。这篇文章就把我当时踩过的坑和最终跑稳的方案完整拆一遍,适合正在用ESP32做ModbusTCP从站或者网关的兄弟参考。
2. 整体设计思路:为什么不能直接send
2.1 ModbusTCP帧结构回顾与分片问题的根源
先把ModbusTCP的帧结构摆出来,不然后面说不清楚。一个标准的ModbusTCP ADU(Application Data Unit)由三部分组成:
| 字段 | 长度 | 说明 |
|---|---|---|
| 事务标识符 | 2字节 | 请求和响应配对用,从站原样返回 |
| 协议标识符 | 2字节 | Modbus固定为0x0000 |
| 长度字段 | 2字节 | 后续字节数(单元标识符+PDU) |
| 单元标识符 | 1字节 | 从站地址,TCP场景下常用于网关路由 |
| 功能码+数据 | N字节 | 实际的PDU |
关键就在那个长度字段。上位机解析时,先读6个字节的MBAP头,从中取出长度字段,然后再读对应长度的数据。如果ESP32这边因为TCP分片,先发出去3个字节,上位机读到的长度字段就是错的,整个帧就废了。
那为什么ESP32会分片?几个原因叠加:
- LwIP的发送缓冲区有限。ESP32默认的TCP发送缓冲区(
TCP_SND_BUF)在menuconfig里默认是5744字节左右,但实际可用量受TCP_SND_QUEUELEN和内存池限制。如果你一次要发2000字节的响应,而当前窗口只有1400字节,LwIP就会先发一部分。 - MSS协商结果。以太网MTU是1500,减去IP头20字节和TCP头20字节,MSS通常是1460。如果你的响应帧超过1460字节,必然分片。ModbusTCP单帧最大260字节左右(PDU最大253+MBAP 7),单帧本身不会超MSS,但多个响应连续发送时,如果中间没有正确分隔,TCP的Nagle算法可能把它们合并成一个大段发出。
- Nagle算法与延迟确认的交互。ESP32默认开启Nagle(
TCP_NODELAY未设置),小包会攒着一起发。如果上位机连续发两个请求,ESP32的两个响应可能被合并成一个TCP段,上位机按第一个帧的长度字段解析完,剩下的字节就被当成了下一个帧的一部分,直接错位。
所以分片缓存要解决的核心问题是:保证每一个ModbusTCP响应帧在应用层是完整的、边界清晰的,并且以可控的方式交给TCP层。
2.2 分片缓存的核心设计原则
我最终采用的方案遵循三条原则:
第一,按连接隔离缓冲区。每个TCP连接(每个客户端)维护自己独立的发送缓存,互不干扰。ESP32作为从站,可能同时被多个上位机连接,如果共用一个缓冲区,A客户端的响应可能被推到B客户端的socket上,这是灾难性的。
第二,先攒后发,长度校验通过才出队。响应帧在应用层组装完成后,先写入该连接的缓存区,然后检查缓存区里是否至少有一个完整的帧(根据MBAP长度字段判断)。只有完整帧才允许发送,不完整的继续等。
第三,设置合理的超时和上限。缓存不能无限增长,否则内存耗尽直接重启。每个连接的缓存设一个上限(我用的2048字节),超过就丢弃最旧的数据并记录错误。同时设一个组装超时(500ms),超时还没凑齐完整帧就清空,防止死等。
这个设计的好处是:应用层完全掌控了帧的边界,TCP层怎么分片、怎么合并都不影响业务逻辑。上位机收到的永远是边界清晰的完整帧。
2.3 方案选型:为什么不用LwIP的现成机制
有兄弟可能会问,LwIP不是有tcp_write()和tcp_output()吗,设置TCP_NODELAY不就行了?我试过,不够。
TCP_NODELAY只能禁用Nagle算法,保证每个send()调用立即发出,但它不解决发送窗口不足导致的分片。当你要发的数据超过当前可用窗口时,LwIP仍然会分片。而且TCP_NODELAY对每个小包都立即发送,在ModbusTCP这种请求-响应模式下反而增加了网络上的小包数量,效率更低。
另一个思路是用tcp_sndbuf()查询可用发送空间,不够就等。这个在ESP-IDF里可以用,但问题是它返回的是当前socket的可用空间,你没法精确控制LwIP内部怎么排队。而且等待过程中如果来了新请求,处理逻辑会变复杂。
所以最终选择在应用层做缓存,用最笨但最可控的方式:自己管内存,自己判边界,自己决定什么时候调send()。实测下来,这个方案在8台设备、每台每秒2次轮询的压力下,连续跑了72小时没有出现一次帧错误。
3. 核心细节解析:缓冲区结构与管理策略
3.1 缓冲区数据结构设计
每个连接对应一个缓存结构体,我用的是环形缓冲区(ring buffer)的变体,但做了简化,因为ModbusTCP的帧不会太大,不需要真正的环形回绕。结构定义大概是这样:
#define MODBUS_TCP_MAX_FRAME 260 #define MODBUS_TCP_BUF_SIZE 2048 #define MODBUS_TCP_TIMEOUT_MS 500 typedef struct { uint8_t buf[MODBUS_TCP_BUF_SIZE]; uint16_t head; // 写入位置 uint16_t tail; // 读取位置 uint32_t last_write_ms; // 最后一次写入时间,用于超时判断 int sock; // 对应的socket描述符 bool in_use; } modbus_tcp_tx_buf_t;这里有几个细节值得说:
缓冲区大小2048字节的由来。ModbusTCP单帧最大约260字节,2048字节可以容纳约7个完整帧。为什么是7个?因为上位机轮询通常是请求-响应模式,同一时刻在途的响应不会超过2-3个。2048给了足够的余量,同时不会占用太多内存。ESP32-WROOM-32有520KB SRAM,如果支持4个并发连接,4×2048=8KB,完全可以接受。
head和tail用uint16_t。2048字节的缓冲区,uint16_t足够表示索引,而且回绕计算简单。不需要用uint32_t,省点内存。
last_write_ms的作用。每次往缓冲区写数据时更新这个时间戳。在发送任务里检查:如果当前时间减去last_write_ms超过500ms,且缓冲区里还有不完整的数据,就清空缓冲区。这防止了因为某个帧永远凑不齐(比如上位机发了个畸形请求)导致缓冲区被占满。
in_use标志。连接建立时置位,断开时清零。发送任务只处理in_use为true的缓冲区。
3.2 帧完整性判断逻辑
判断缓冲区里是否有一个完整帧,核心是读MBAP头的长度字段。但这里有个坑:缓冲区里的数据可能从任意位置开始,head和tail的关系需要仔细处理。
我的做法是:先计算当前缓冲区里的有效数据长度len = (head - tail + BUF_SIZE) % BUF_SIZE。如果len < 6,肯定不完整,直接返回。如果len >= 6,从tail位置开始读6个字节的MBAP头,取出长度字段frame_len(第5、6字节,大端序)。然后判断len >= 6 + frame_len,如果满足,说明有一个完整帧。
这里要注意:长度字段的值是“单元标识符+PDU”的字节数,不包括MBAP头的前6个字节。所以完整帧的总长度是6 + frame_len。这个很容易搞错,我一开始就多算了6个字节,导致一直认为帧不完整。
还有一个边界情况:如果frame_len本身是个异常值(比如大于253+1=254),说明这个帧是畸形的,应该直接丢弃。我在代码里加了一个判断:如果frame_len > 254,就把tail往前移动6个字节(跳过这个畸形的MBAP头),然后继续检查。这样不会因为一个坏帧卡死整个缓冲区。
3.3 多连接隔离与内存分配策略
ESP32作为ModbusTCP从站,通常用listen()接受多个客户端连接。每个连接对应一个modbus_tcp_tx_buf_t。我用的策略是静态数组+动态绑定:
#define MAX_MODBUS_CONNECTIONS 4 static modbus_tcp_tx_buf_t tx_bufs[MAX_MODBUS_CONNECTIONS];启动时初始化这个数组,所有in_use置false。每当accept()返回一个新的socket,就遍历数组找一个in_use为false的槽位,绑定socket并置位。连接断开时,清空缓冲区并释放槽位。
为什么不用动态分配(malloc)?因为ESP32上频繁malloc/free容易产生内存碎片,尤其是在长时间运行的场景下。静态数组虽然限制了最大连接数,但内存布局是确定的,不会碎片化。4个并发连接对于大多数小型监测终端足够了,如果不够,把MAX_MODBUS_CONNECTIONS改大就行,代价是固定的内存占用。
注意:如果你的应用场景需要支持超过8个并发连接,建议改用内存池(memory pool)方案,预分配固定大小的块,用链表管理。但大多数ModbusTCP从站场景,4-8个连接是合理上限。
3.4 发送触发时机与任务调度
缓存的写入发生在Modbus请求处理完成之后。当从站解析完一个请求、生成响应帧后,不是直接send(),而是调用modbus_tcp_buf_write(sock, frame, len)把响应写入对应连接的缓冲区。
发送动作由一个独立的任务(task)驱动,我用的是FreeRTOS任务,优先级设得比Modbus解析任务低一级,周期10ms。任务里遍历所有in_use的缓冲区,对每个缓冲区:
- 检查超时,超时则清空。
- 循环判断是否有完整帧。
- 如果有完整帧,调用
send()发送,发送成功后tail前移。 - 如果
send()返回错误(比如EAGAIN),说明TCP发送缓冲区满了,本次不发送,等下一个周期再试。
这里的关键是发送和写入解耦。写入方只管往缓冲区塞数据,不关心TCP层能不能发。发送方只管从缓冲区取完整帧往TCP层推,不关心数据是怎么来的。这样即使TCP层暂时阻塞,也不会影响Modbus请求的处理速度。
实操心得:发送任务的周期不要设得太短,10ms是个比较合适的值。设成1ms会导致任务频繁切换,CPU占用率上升;设成100ms又会导致响应延迟增加。10ms在ESP32上实测CPU占用不到2%,响应延迟增加不超过10ms,完全可以接受。
4. 实操过程:从零搭建分片缓存模块
4.1 环境准备与基础工程搭建
我用的开发环境是ESP-IDF v5.1,芯片是ESP32-WROOM-32。如果你用的是Arduino框架,思路完全一样,只是API换成WiFiClient的write()和available(),但Arduino的WiFiClient内部有自己的缓冲,控制粒度不如直接用LwIP的socket。所以下面以ESP-IDF为例。
工程结构很简单,在main目录下建三个文件:
modbus_tcp_slave.c:ModbusTCP从站主逻辑,负责解析请求、生成响应。modbus_tx_cache.c:分片缓存模块,负责缓冲区的写入、完整性判断和发送。modbus_tx_cache.h:头文件,定义结构体和函数声明。
sdkconfig里需要确认几个配置:
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=5744 CONFIG_LWIP_TCP_WND_DEFAULT=5744 CONFIG_LWIP_MAX_SOCKETS=8 CONFIG_FREERTOS_HZ=1000TCP_SND_BUF_DEFAULT和TCP_WND_DEFAULT保持默认的5744就行,不需要改大。MAX_SOCKETS设成8,留出余量。FREERTOS_HZ设成1000,这样vTaskDelay()的精度是1ms,方便做10ms周期任务。
4.2 缓冲区初始化与连接绑定
初始化函数在系统启动时调用一次:
void modbus_tx_cache_init(void) { for (int i = 0; i < MAX_MODBUS_CONNECTIONS; i++) { memset(&tx_bufs[i], 0, sizeof(modbus_tcp_tx_buf_t)); tx_bufs[i].in_use = false; tx_bufs[i].sock = -1; } }连接绑定的逻辑放在accept()之后:
int modbus_tx_cache_bind(int sock) { for (int i = 0; i < MAX_MODBUS_CONNECTIONS; i++) { if (!tx_bufs[i].in_use) { tx_bufs[i].in_use = true; tx_bufs[i].sock = sock; tx_bufs[i].head = 0; tx_bufs[i].tail = 0; tx_bufs[i].last_write_ms = 0; return i; } } return -1; // 没有空闲槽位 }如果返回-1,说明连接数已满,应该直接close(sock)拒绝新连接。这里不要尝试动态扩容,静态数组的边界就是硬上限,超出就拒绝,逻辑简单可靠。
4.3 响应帧写入缓冲区的完整流程
写入函数是核心,每一步都要仔细:
int modbus_tx_cache_write(int sock, const uint8_t *frame, uint16_t len) { // 1. 找到对应的缓冲区 modbus_tcp_tx_buf_t *b = NULL; for (int i = 0; i < MAX_MODBUS_CONNECTIONS; i++) { if (tx_bufs[i].in_use && tx_bufs[i].sock == sock) { b = &tx_bufs[i]; break; } } if (!b) return -1; // 2. 检查剩余空间 uint16_t used = (b->head - b->tail + MODBUS_TCP_BUF_SIZE) % MODBUS_TCP_BUF_SIZE; uint16_t free_space = MODBUS_TCP_BUF_SIZE - 1 - used; if (len > free_space) { // 空间不足,丢弃最旧的数据 // 实际实现中这里应该记录错误日志 return -2; } // 3. 写入数据,处理回绕 uint16_t first_chunk = MODBUS_TCP_BUF_SIZE - b->head; if (len <= first_chunk) { memcpy(&b->buf[b->head], frame, len); b->head = (b->head + len) % MODBUS_TCP_BUF_SIZE; } else { memcpy(&b->buf[b->head], frame, first_chunk); memcpy(&b->buf[0], frame + first_chunk, len - first_chunk); b->head = len - first_chunk; } // 4. 更新时间戳 b->last_write_ms = xTaskGetTickCount() * portTICK_PERIOD_MS; return 0; }这里有几个容易出错的点:
回绕处理。当head接近缓冲区末尾时,写入的数据会分成两段:一段写到末尾,一段写到开头。上面的代码用first_chunk判断,逻辑是对的。但要注意head更新后的值:如果发生了回绕,head应该等于len - first_chunk,而不是(head + len) % BUF_SIZE。这两个在数学上等价,但前者更直观。
空间判断用BUF_SIZE - 1。环形缓冲区通常要留一个字节的空位来区分“空”和“满”。所以实际可用空间是BUF_SIZE - 1。如果你不打算区分空和满(比如用额外的计数器),可以用满BUF_SIZE,但那样逻辑会复杂一点。
写入失败的处理。如果空间不足,我选择直接返回错误,由调用方决定是丢弃还是重试。在实际的Modbus从站逻辑里,如果缓冲区满了,说明上位机轮询太快或者网络太慢,这时候应该丢弃最旧的未发送数据,保证最新的响应能进去。但丢弃逻辑要小心,不能把半个帧留在缓冲区里,否则会破坏帧边界。我的做法是:如果空间不足,直接把整个缓冲区清空(head=tail=0),然后重新写入当前帧。这样虽然丢了之前的响应,但保证了帧边界的完整性。
4.4 发送任务的实现与参数调优
发送任务是独立运行的FreeRTOS任务:
void modbus_tx_task(void *arg) { while (1) { uint32_t now = xTaskGetTickCount() * portTICK_PERIOD_MS; for (int i = 0; i < MAX_MODBUS_CONNECTIONS; i++) { modbus_tcp_tx_buf_t *b = &tx_bufs[i]; if (!b->in_use) continue; // 超时检查 uint16_t used = (b->head - b->tail + MODBUS_TCP_BUF_SIZE) % MODBUS_TCP_BUF_SIZE; if (used > 0 && (now - b->last_write_ms) > MODBUS_TCP_TIMEOUT_MS) { b->head = b->tail = 0; continue; } // 循环发送完整帧 while (1) { uint16_t len = (b->head - b->tail + MODBUS_TCP_BUF_SIZE) % MODBUS_TCP_BUF_SIZE; if (len < 6) break; // 读MBAP头,取长度字段 uint8_t mbap[6]; for (int j = 0; j < 6; j++) { mbap[j] = b->buf[(b->tail + j) % MODBUS_TCP_BUF_SIZE]; } uint16_t frame_len = (mbap[4] << 8) | mbap[5]; if (frame_len > 254) { // 畸形帧,跳过MBAP头 b->tail = (b->tail + 6) % MODBUS_TCP_BUF_SIZE; continue; } uint16_t total = 6 + frame_len; if (len < total) break; // 发送完整帧 uint8_t tmp[MODBUS_TCP_MAX_FRAME]; for (int j = 0; j < total; j++) { tmp[j] = b->buf[(b->tail + j) % MODBUS_TCP_BUF_SIZE]; } int sent = send(b->sock, tmp, total, 0); if (sent == total) { b->tail = (b->tail + total) % MODBUS_TCP_BUF_SIZE; } else if (sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { break; // TCP缓冲区满,下个周期再试 } else { // 发送错误,关闭连接 close(b->sock); b->in_use = false; break; } } } vTaskDelay(pdMS_TO_TICKS(10)); } }这段代码里有几个参数需要根据实际情况调:
任务周期10ms。前面说过,这个值在响应延迟和CPU占用之间取得了平衡。如果你对响应延迟特别敏感(比如要求<5ms),可以改成5ms,但CPU占用会翻倍。
超时500ms。这个值要大于上位机的最长轮询间隔。如果上位机每200ms轮询一次,500ms的超时是安全的。如果上位机轮询间隔是1秒,超时要设成1500ms以上。原则是:超时时间 > 最大轮询间隔 × 2。
发送缓冲区大小2048。前面算过,够用。如果你的响应帧特别大(比如读100个寄存器,响应帧约207字节),2048仍然够用。但如果你的应用场景是批量读大量寄存器,单帧接近260字节,且上位机轮询很快,可以考虑加大到4096。
实操心得:
send()返回的值可能小于请求发送的长度,这在TCP里是正常的(部分发送)。上面的代码里,如果sent != total且不是EAGAIN,我直接关闭连接。严格来说,部分发送时应该更新tail为sent,然后下个周期继续发剩余部分。但ModbusTCP帧很小(最大260字节),部分发送的概率极低,而且一旦发生,说明TCP层状态异常,关闭重连更干净。如果你追求极致的健壮性,可以改成处理部分发送。
4.5 与Modbus请求处理逻辑的对接
分片缓存模块本身不关心Modbus协议,它只负责缓冲和发送。Modbus请求处理逻辑在生成响应帧后,调用modbus_tx_cache_write()即可。对接点大概是这样:
void modbus_handle_request(int sock, uint8_t *req, uint16_t req_len) { uint8_t resp[MODBUS_TCP_MAX_FRAME]; uint16_t resp_len = 0; // ... 解析请求,生成响应到resp,设置resp_len ... // 不直接send,而是写入缓存 modbus_tx_cache_write(sock, resp, resp_len); }这样,请求处理逻辑和发送逻辑完全解耦。请求处理任务可以快速处理完请求就返回,不用等待TCP发送完成。发送任务在后台以10ms周期把完整帧推出去。实测下来,这种解耦让请求处理任务的响应时间从平均15ms降到了平均3ms,因为不再阻塞在send()上。
5. 常见问题与排查技巧实录
5.1 上位机收到粘包或半包
这是最常见的问题,表现是上位机解析时报“帧长度错误”或“事务标识符不匹配”。排查思路:
第一步,确认分片缓存是否生效。在send()调用处加日志,打印每次发送的字节数和前6个字节的内容。如果发现发送的字节数小于6,或者MBAP头的长度字段和实际发送字节数不匹配,说明缓存逻辑有问题。
第二步,检查TCP_NODELAY设置。虽然分片缓存解决了应用层的帧边界问题,但如果TCP_NODELAY没设置,Nagle算法仍然可能把两个小帧合并成一个TCP段。虽然上位机按长度字段解析不会错,但有些上位机实现比较粗糙,会按固定缓冲区读取,导致粘包。建议在accept()之后设置:
int flag = 1; setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));第三步,检查上位机的读取逻辑。如果ESP32这边确认发送的是完整帧,但上位机仍然报错,很可能是上位机的读取缓冲区太小,一次recv()没读完整帧。这种情况需要上位机配合修改,按MBAP长度字段循环读取。
5.2 缓冲区溢出导致响应丢失
表现是上位机偶尔收不到响应,超时重试。排查思路:
检查缓冲区大小是否足够。计算最坏情况:上位机轮询间隔T,ESP32处理一个请求的时间P,那么在途的响应数约为T/P。如果T=100ms,P=5ms,在途响应约20个,每个260字节,需要5200字节的缓冲区。2048字节就不够了。这时候要么加大缓冲区,要么降低轮询频率。
检查发送任务是否被阻塞。如果发送任务的优先级太低,或者被其他高优先级任务抢占,可能导致发送不及时,缓冲区积压。用uxTaskPriorityGet()确认发送任务的优先级,确保它比空闲任务高,但比Modbus解析任务低。
检查超时时间是否合理。如果超时时间设得太短,比如100ms,而上位机轮询间隔是200ms,那么每次响应还没发出去就被超时清空了。超时时间必须大于最大轮询间隔。
5.3 多连接场景下的响应错乱
表现是A客户端收到了B客户端的响应。这是最危险的问题,排查思路:
确认缓冲区绑定逻辑。每个socket必须绑定到独立的缓冲区槽位。在accept()之后立即调用modbus_tx_cache_bind(),在close()之前调用modbus_tx_cache_unbind()。如果绑定失败(返回-1),直接关闭新连接,不要让它进入请求处理流程。
确认发送时使用的是正确的socket。发送任务里,send()的第一个参数必须是b->sock,不能是全局的socket变量。我见过有人在发送任务里用了一个全局的client_sock,结果所有响应都发到了最后一个连接的客户端上。
确认连接断开时清理了缓冲区。如果连接断开了但缓冲区没有解绑,新的连接可能复用这个槽位,导致旧数据被发到新连接上。在unbind函数里,除了置in_use=false,还要清空head和tail。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 上位机报帧长度错误 | 发送了不完整帧 | 在send处打印字节数和MBAP头 | 检查完整性判断逻辑,确认frame_len计算正确 |
| 上位机报事务ID不匹配 | 粘包或响应错乱 | 抓包看TCP段边界 | 设置TCP_NODELAY,检查缓冲区绑定 |
| 偶尔收不到响应 | 缓冲区溢出 | 打印缓冲区使用率 | 加大缓冲区或降低轮询频率 |
| 响应延迟大 | 发送任务周期太长 | 测量从写入到发送的时间差 | 减小任务周期,或提高任务优先级 |
| 连接数满后新连接被拒 | 静态数组槽位不足 | 打印当前连接数 | 加大MAX_MODBUS_CONNECTIONS |
| 运行一段时间后重启 | 内存耗尽 | 打印剩余堆内存 | 检查是否有内存泄漏,确认缓冲区没有无限增长 |
避坑技巧:在开发阶段,建议在
modbus_tx_cache_write()和发送任务里加详细的日志,记录每次写入的长度、缓冲区使用率、每次发送的字节数和结果。这些日志在排查问题时非常有用。量产时可以关掉,或者只保留错误级别的日志。
5.5 性能实测数据与调优建议
我在8台设备、每台每秒2次轮询、每次读20个寄存器的压力下做了72小时连续测试,数据如下:
| 指标 | 数值 |
|---|---|
| 平均响应延迟 | 8ms |
| 最大响应延迟 | 23ms |
| 帧错误率 | 0 |
| CPU占用率 | 约12% |
| 内存占用(4连接) | 约10KB |
| 连续运行时间 | 72小时无重启 |
调优建议:
- 如果响应延迟要求<5ms,把发送任务周期改成5ms,CPU占用会升到约18%。
- 如果连接数超过4个,把
MAX_MODBUS_CONNECTIONS改成8,内存占用增加到约18KB。 - 如果响应帧普遍较大(>200字节),把缓冲区大小改成4096,内存占用翻倍,但能容纳更多在途帧。
这个方案的核心价值在于:用确定性的应用层缓冲,替代了不确定的TCP层分片行为。对于ModbusTCP这种对帧边界敏感的应用层协议,这是最稳妥的做法。ESP32的内存虽然有限,但通过静态分配和合理的上限控制,完全可以支撑一个小型ModbusTCP从站的稳定运行。