Modbus TCP 做一主多从,设备少的时候随便写都能跑,设备一多就全变慢——杭州这个注塑车间 12 台设备(温控模块、电表、PLC、机械手臂控柜)挂在同一个交换机上,初期按"每个点一个请求"写,轮询一圈 6 秒多,温度超限了画面上半天才变红。问题根子不在网速,在报文数量和调度方式。我们抓包重新排了轮询表,一圈压到 400 毫秒以内。这篇按报文结构、轮询调度、故障隔离三层讲。
先把报文看明白:Modbus TCP 的 MBAP 头到底有什么用?
串口 Modbus 靠从站地址 + 时序(一问一答)保证不出错,TCP 改成以太网后,多笔请求可以同时在一条连接上飞,响应还可能乱序回来。于是 TCP 版在 PDU 前面加了 7 字节 MBAP 头:
| 事务标识符 | 协议标识符 | 长度 | 单元标识符 | 功能码 | 数据 |
|---|---|---|---|---|---|
| 2 字节 | 2 字节 | 2 字节 | 1 字节 | 1 字节 | N 字节 |
| 事务ID由主站自增,从站原样回填——这是你判断"这个回包对应哪个请求"的唯一凭据;协议ID固定 0000(Modbus);长度指后面字节数;单元ID在纯TCP设备上一般填 01 或 FF,网关后挂串口从站时填实际从站地址。 | |||||
抓包看一笔功能码 03(读保持寄存器)的真实报文,读 1 号从站 40001 起的 2 个寄存器:
→ 请求 00 01 00 00 00 06 01 03 00 00 00 02 │事务ID=1│协议=0│长度=6│单元=1│03读保持│起始地址0│数量2│ ← 响应 00 01 00 00 00 07 01 03 04 00 C8 01 2C │事务ID=1(原样回填)│长度=7│单元=1│03│字节数=4│ 00C8=200 │ 012C=300 │
▲ 返回值 200、300 就是两个寄存器的原始值,工程值换算(乘以系数、加偏移)在主站侧做。
第一层优化:能合并的寄存器,绝不分两次读
初期的写法是每个变量一笔请求:温度一笔、设定温度一笔、运行状态一笔……单台设备 30 个变量就是 30 笔报文,12 台设备 360 笔。其实 Modbus 读寄存器是按地址连续区间读的——40001 到 40010 只要地址连续,一笔功能码 03 就能读回 10 个寄存器,报文数直接砍到十分之一。我们和电气工程师约定,PLC 侧把同一类数据按地址连续排布,中间的空隙也一起读回来丢掉:
| 数据块 | 寄存器区间 | 优化前 | 优化后 |
|---|---|---|---|
| 温度/压力/流量 8 个模拟量 | 40001~40008 连续 | 8 笔 03 | 1 笔 03(读 8 个) |
| 设备状态字 + 报警字 | 40020~40023 连续 | 4 笔 03 | 1 笔 03 |
| 电表电能/功率/功率因数 | 分散在 40101、40113、40125 | 3 笔 03 | 1 笔 03(连读 25 个,丢弃空隙) |
连读也有上限:Modbus TCP 单笔最多读 125 个保持寄存器。我们的经验是一笔控制在 60 个寄存器以内,再大拆成两笔——个别廉价网关处理长报文会超时,短报文反而稳。
第二层优化:轮询周期分级,别让慢变量占着通道
不是所有点都要 100 毫秒刷一次。温度 1 秒变不了 0.1℃,电能累计值 5 秒读一次都够用,急的是状态字和报警。把采集点按变化快慢分三档,轮询器按节奏派发,报文量立刻再降一大截:
| 档位 | 周期 | 点位内容 | 调度方式 |
|---|---|---|---|
| 快档 | 200 ms | 运行/故障状态字、报警字、急停反馈 | 每轮必发,优先级最高 |
| 中档 | 1000 ms | 温度、压力、流量、主轴转速等模拟量 | 每 5 轮发一次 |
| 慢档 | 5000 ms | 电表电能累计、配方号、产量计数、固件版本 | 每 25 轮发一次 |
实际派单顺序:每 200ms 一个轮询节拍,每个节拍发"所有设备的快档 + 轮上的部分中档",慢档打散到不同节拍里,避免某一个节拍报文扎堆。12 台设备每节拍约 15~20 笔报文,局域网内一笔请求+应答 3~8 毫秒,一个节拍 100 毫秒内全部收完,通道还留一半余量。
第三层优化:写入用功能码 16 一笔成型,别逐点写
下发设定值(温度设定、配方参数)时,新手爱用功能码 06(写单个寄存器)一个点一个点写——10 个参数 10 笔报文,写到一半断了,PLC 里就是新旧参数混着的半套配方。改用功能码 16(0x10,写多个寄存器):整段参数一笔报文写进去,PLC 侧要么全收要么不收,天然原子性。注意 16 功能码报文里要带"后续字节数":
// 功能码16:一笔写 3 个保持寄存器(设定温度上下限+保温时间) byte[] req = { 0x00,0x02, // 事务ID = 2 0x00,0x00, // 协议ID = 0(Modbus) 0x00,0x0D, // 长度 = 后续13字节 0x01, // 单元ID 0x10, // 功能码 = 16 写多个 0x00,0x20, // 起始地址 40033 0x00,0x03, // 寄存器数量 = 3 0x06, // 后续数据字节数 = 3寄存器 × 2 0x00,0xB4, // 180(下限温度 ×1) 0x00,0xFA, // 250(上限温度) 0x0B,0xB8 // 3000(保温时间,秒) }; // 应答只有 12 字节:回显事务ID、单元ID、功能码、起始地址、数量 // 收到应答后再回读一遍该区间比对,确认 PLC 真正写入(见第三篇回执机制)
▲ 配方下发的"回读比对 + 握手字"完整机制,我们在配方下发专题里展开。
故障隔离:一台设备掉线,不能拖死整条轮询链
一主多从最容易翻车的地方:某台设备掉电后,如果请求同步等待应答,超时设 3 秒,这台设备每个节拍卡 3 秒,其他 11 台全跟着慢。我们做了三件事:
- 超时设短不设长:局域网内正常应答 10 毫秒内,超时设 500~800 毫秒足够,连续 3 次超时判该站离线,不是等到 3 秒;
- 离线站降频:判离线后该站轮询降到 10 秒一次探活,恢复后自动回到正常周期——掉线设备不再占用每一节拍;
- 事务表 + 异步收发:发出去的请求登记"事务ID→站点→时间戳",收包按事务ID销账;轮询线程不等单包,到点统一扫描超时请求并重试。整个采集无阻塞。
// 收发分离:发送不等待,收包按事务ID销账 readonly ConcurrentDictionary<ushort, PendingReq> _pending = new(); void OnDataReceived(byte[] frame) { ushort txId = (ushort)((frame[0] << 8) | frame[1]); // 取MBAP事务ID if (!_pending.TryRemove(txId, out var req)) return; // 事务ID对不上(迟到/乱码/串包),直接丢弃 if (frame[7] & 0x80 != 0) // 功能码最高位置1 = 异常响应 req.SetError($"从站异常码: 0x{frame[8]:X2}"); else req.SetResult(ParseRegisters(frame)); } void SweepTimeout() // 定时器每100ms扫一次 { foreach (var kv in _pending) if (DateTime.Now - kv.Value.SendTime > TimeSpan.FromMilliseconds(800)) kv.Value.FailAndMarkOffline(); // 超时计数++,3次置离线并降频 }
动手前先抓 5 分钟包
如果你手头的 Modbus TCP 网络也慢,别急着改代码——先挂 Wireshark 抓 5 分钟,过滤器填 tcp.port == 502,数两件事:一秒钟多少笔报文、有没有大量重传/重复事务ID。报文数多,就是没做寄存器合并;重传多,查物理层和交换机;单站超时拖慢全队,就查同步等待。Modbus TCP 这个协议简单到没有秘密,慢,一定是报文排得不对。
