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 就是两个寄存器的原始值,工程值换算(乘以系数、加偏移)在主站侧做。

串包事故是这么来的:初期我们图省事,一个 TcpClient 连接上多线程同时发请求,响应回来按"到达顺序"解析——结果 3 号站的温度值被填进了 7 号站的变量,画面上数据乱跳但没有任何报警。根治办法只有一个:按事务ID匹配请求和响应,匹配不上、或事务ID对不上的回包直接丢弃。Modbus TCP 不是串口,绝不能"发完就等下一个包"。

第一层优化:能合并的寄存器,绝不分两次读

初期的写法是每个变量一笔请求:温度一笔、设定温度一笔、运行状态一笔……单台设备 30 个变量就是 30 笔报文,12 台设备 360 笔。其实 Modbus 读寄存器是按地址连续区间读的——40001 到 40010 只要地址连续,一笔功能码 03 就能读回 10 个寄存器,报文数直接砍到十分之一。我们和电气工程师约定,PLC 侧把同一类数据按地址连续排布,中间的空隙也一起读回来丢掉:

数据块寄存器区间优化前优化后
温度/压力/流量 8 个模拟量40001~40008 连续8 笔 031 笔 03(读 8 个)
设备状态字 + 报警字40020~40023 连续4 笔 031 笔 03
电表电能/功率/功率因数分散在 40101、40113、401253 笔 031 笔 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 台全跟着慢。我们做了三件事:

  1. 超时设短不设长:局域网内正常应答 10 毫秒内,超时设 500~800 毫秒足够,连续 3 次超时判该站离线,不是等到 3 秒;
  2. 离线站降频:判离线后该站轮询降到 10 秒一次探活,恢复后自动回到正常周期——掉线设备不再占用每一节拍;
  3. 事务表 + 异步收发:发出去的请求登记"事务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次置离线并降频
}
改造后的实测数字:报文数从 360 笔/圈降到 41 笔/圈(合并 + 周期分级),全站点位刷新周期从 6.2 秒压到 0.4 秒,掉线设备对其他站的影响从"全站卡顿"变成"画面置灰、其余正常"。交换机流量常年不到 1%。

动手前先抓 5 分钟包

如果你手头的 Modbus TCP 网络也慢,别急着改代码——先挂 Wireshark 抓 5 分钟,过滤器填 tcp.port == 502,数两件事:一秒钟多少笔报文、有没有大量重传/重复事务ID。报文数多,就是没做寄存器合并;重传多,查物理层和交换机;单站超时拖慢全队,就查同步等待。Modbus TCP 这个协议简单到没有秘密,慢,一定是报文排得不对。