Modbus 是 1979 年由 Modicon 提出并公开的工业通信协议,至今仍是工业现场的事实标准。它的核心特点决定了它为什么活得这么久:协议简单、完全开放、无版税、无授权门槛。Modbus TCP 是它跑在以太网上的版本——把 Modbus 的应用层报文直接装进 TCP,去掉了串口时代的 CRC 校验,靠 TCP 自己保证可靠性。

本文分两部分:前半部分把协议讲透(报文到底长什么样、地址怎么算、异常怎么读),后半部分用 C# 写一个真正能跑的客户端,按实际场景把三个端口、100 个寄存器的点表读出来。


一、先建立正确的心智模型

它是"请求-响应"式的,不是"推送"式的

Modbus 是典型的主从(客户端-服务器)模型:

  • 客户端(主站)主动发请求,问"把 0 号开始的 100 个保持寄存器给我";

  • 服务器(从站)只做两件事——响应,或者不响应。它永远不会主动发数据。

这一点必须先记住,因为它决定了你的程序形态:要拿到数据,只能靠定时轮询。 没有"数据变化就通知我"这种机制,所有"实时性"都靠你轮询得够快。

数据模型:四个区

Modbus 把数据分成四块,用不同的功能码访问:

名称

位/字

可读写

常见地址写法

读取功能码

0x

离散输出(线圈 Coil)

读写

00001-09999

FC01

1x

离散输入(Discrete Input)

只读

10001-19999

FC02

3x

输入寄存器(Input Register)

字(16 位)

只读

30001-39999

FC04

4x

保持寄存器(Holding Register)

字(16 位)

读写

40001-49999

FC03

实际项目里 90% 的工作都在 4x 保持寄存器上,本文也以它为主线。原因很简单:它是唯一既能读又能写的区,设备厂家天然喜欢把参数、测量值都放这里。

注意 3x 和 4x 的只读/读写区别:很多设备把实时测量值放在 3x(输入寄存器),你以为能写其实写不了,会收到异常码 02。


二、报文长什么样

这是全文最该看懂的一节。看懂报文,后面所有问题你都能自己定位。

结构:MBAP 头 + PDU

TCP 上的 Modbus 报文叫 ADU(应用数据单元),由两部分拼成:

┌──────────────────────────┬─────────────────────┐
│      MBAP 头(7 字节)   │      PDU(N 字节)  │
├────┬────┬────────┬───────┼──────┬──────────────┤
│ 事务│协议│  长度  │ 单元  │功能码│    数据      │
│ 2B │ 2B │   2B   │  1B   │  1B  │     N B      │
└────┴────┴────────┴───────┴──────┴──────────────┘

MBAP 头逐字段说明

字段

长度

含义

关键点

事务标识符 Transaction ID

2 字节

请求/响应的配对编号

由客户端生成,服务器原样抄回来

协议标识符 Protocol ID

2 字节

恒为 0

非 0 说明对面不是标准 Modbus

长度 Length

2 字节

后续字节数(含单元标识符)

不是整个报文长度

单元标识符 Unit ID

1 字节

从站地址

经过网关路由到串口从站时用

两个最容易搞错的地方:

  1. Length 字段不是报文总长度,而是"从这个字段之后还有多少字节"。所以:

text Length = 完整报文总长度 - 6

减 6 是因为它前面已经有事务号(2) + 协议号(2) + 自己(2) = 6 字节。

  1. Length 包含单元标识符。所以 PDU 的长度是 Length - 1

一个真实的请求:读 3 号端口的前 100 个寄存器

假设具体场景是:

项目

服务器 IP

192.168.1.200

客户端 IP

192.168.1.147

端口

10001

从站地址 Unit ID

1

点表地址

1100

读取功能码

FC03(保持寄存器)

关键换算先行:点表上写的是 1 基地址 1-100,但报文里用的是 0 基协议地址。所以:

点表地址 1   →  协议地址 0x0000
点表地址 100 →  协议地址 0x0063
起始地址 = 0x0000,数量 = 100 = 0x0064

发出的 12 字节:

00 01 | 00 00 | 00 06 | 01 | 03 | 00 00 | 00 64
─────  ─────  ─────  ──   ──   ─────  ─────
  │      │      │    │    │     │      └─ 数量 = 100
  │      │      │    │    │     └─ 起始地址 = 0(即点表的 1)
  │      │      │    │    └─ 功能码 03:读保持寄存器
  │      │      │    └─ 单元标识符 = 1
  │      │      └─ 长度 = 6(单元号1 + 功能码1 + 地址2 + 数量2)
  │      └─ 协议标识符 = 0
  └─ 事务标识符 = 1

长度那个 6 是这样来的:单元标识符(1) + 功能码(1) + 起始地址(2) + 数量(2) = 6。这个数字必须对,服务器会校验。

对应的响应

00 01 | 00 00 | 00 CB | 01 | 03 | C8 | <200 字节数据>
─────  ─────  ─────  ──   ──   ──
  │      │      │    │    │    └─ 字节数 = 0xC8 = 200(100 个寄存器 × 2)
  │      │      │    │    └─ 功能码 03
  │      │      │    └─ 单元标识符(原样返回)
  │      │      └─ 长度 = 0xCB = 203
  │      └─ 协议标识符
  └─ 事务标识符(与请求一致,用来配对)

响应长度 203 的算法:单元标识符(1) + 功能码(1) + 字节数(1) + 数据(200) = 203 = 0xCB

整个响应是 209 字节(6 字节 MBAP 前缀 + 203)。你的接收缓冲区如果开得太小,会读不完整——这是初学手写客户端时很常见的失败原因。

数据部分怎么读

后面 200 字节是 100 个寄存器,每个寄存器 2 字节,且是高位在前(大端)

数据首 4 字节: 0A 8B  02 1F
               ─────  ─────
              寄存器[0] 寄存器[1]
              = 0x0A8B  = 0x021F
              = 2699    = 543

对应到点表就是"点表地址 1 = 2699"、"点表地址 2 = 543"。


三、功能码与异常处理

常用功能码

功能码

名称

作用

单次上限

0x01

Read Coils

读线圈

2000 个

0x02

Read Discrete Inputs

读离散输入

2000 个

0x03

Read Holding Registers

读保持寄存器

125 个

0x04

Read Input Registers

读输入寄存器

125 个

0x05

Write Single Coil

写单个线圈

1 个

0x06

Write Single Register

写单个寄存器

1 个

0x0F

Write Multiple Coils

写多个线圈

1968 个

0x10

Write Multiple Registers

写多个寄存器

123 个

0x16

Mask Write Register

掩码写寄存器

1 个

0x17

Read/Write Multiple

一次读写组合

125 这个上限必须记住:想读 100 个寄存器,一次请求就够;想读 200 个,必须拆成两次。超了会被回异常码 03。

异常响应长什么样

服务器处理不了请求时,会返回一个"变异"的响应:

  • 功能码加上 0x80(最高位置 1);

  • 数据部分只有一个字节:异常码。

00 01 | 00 00 | 00 03 | 01 | 83 | 02
                             ──   ──
                              │    └─ 异常码 02:非法数据地址
                              └─ 0x83 = 0x03 + 0x80,表示 FC03 出异常

TCP 上的异常响应总共只有 9 字节(7 字节头 + 2 字节 PDU)。

判断逻辑很简单:拿到响应后先看功能码,(功能码 & 0x80) != 0 就说明是异常,第二个字节就是异常码。

异常码对照表

名称

什么意思

你该查什么

0x01

非法功能码

设备不支持这个功能码

换功能码,或换寄存器区

0x02

非法数据地址

地址超出设备范围

地址算错了,或者点表不对

0x03

非法数据值

数量/数值超限

单次读超了 125,或写入值超范围

0x04

从站设备故障

设备执行时内部错误

查设备自身状态

0x05

确认

已接受,正在处理

稍后重试

0x06

从站设备忙

正忙

降低轮询频率,稍后重试

0x08

存储奇偶校验错误

内存校验失败

设备硬件问题

0x0A

网关路径不可用

网关找不到目标

查网关配置

0x0B

网关目标设备无响应

从站没响应网关

查从站是否在线、地址是否对

排障时最有用的是 0x02 和 0x0B:前者几乎总是你地址算错了(多减了 1、或者用了 4xxxx 的编号而不是偏移量),后者说明网络层通了但串口那一端没响应。


四、把这套用到一个真实场景上

你的现场参数是这样:

参数

服务器 IP

192.168.1.200

本机 IP

192.168.1.147

端口

100011000210003

从站地址(Unit ID)

三个端口都是 1

点表地址

每个端口 1100

寄存器数量

每个端口 100 个

先做几个判断

判断一:这是三条独立的链路,不是一台设备的三个区。

三个端口、从站号都是 1——如果是同一台设备的三个数据区,通常会用一个端口、靠不同单元号区分。三个独立端口更像是三台设备(或一台设备的三个独立服务/网关通道)。不论哪种,客户端侧的处理方式完全一样:一个端口一条连接。

判断二:100 个寄存器可以一次读完。

100 ≤ 125,不用拆包。一条 FC03 请求搞定,响应 209 字节。

判断三:网络是直连的。

192.168.1.147192.168.1.200 在同一个 /24 网段,同一广播域,不需要配网关,直接可达。先 ping 一下确认,ping 不通就别往下写了。

建立点表

这是本文的实用核心。点表(Point Table)本质上就是"寄存器地址 → 工程含义"的映射表。

重要说明:每个地址具体代表什么物理量,只有你设备的通信手册才知道,任何人都替代不了。下面给的是结构示例——把名称、类型、系数、单位换成你的真实定义就能用。

端口

点表地址

协议地址

变量名

类型

系数

单位

10001

1-2

0-1

环境温度

Float32

1

10001

3-4

2-3

环境湿度

Float32

1

%RH

10001

5

4

供电电压

UInt16

0.1

V

10001

6-100

5-99

待定义

UInt16

1

10002

1-2

0-1

压力

Float32

0.01

MPa

10002

3

2

运行状态字

UInt16

1

10002

4-100

3-99

待定义

UInt16

1

10003

1-4

0-3

累计流量

UInt64

1

10003

5-100

4-99

待定义

UInt16

1

几个要点:

  • 地址区间要连续Float32UInt322 个寄存器UInt64/Float644 个,别在中间切断了。

  • 点表地址是 1 基,协议地址是 0 基,差 1。这个换算只在"组装报文"和"切分数据"的时候做一次,中间层统一用 0 基,不容易乱。

  • 系数是用来把原始整数变成工程值的。UInt16 读出来 4850,系数 0.1,实际就是 485.0 V。


五、方案 A:不依赖任何库,原生 Socket 实现

先说清楚为什么要手写一遍:这一百来行代码把 MBAP 的每个字段都摸了一遍,以后用任何库、看任何抓包都能对上号。而且现场有些机器装不了 NuGet 包,手写版本零依赖。

完整代码

using System;
using System.IO;
using System.Net.Sockets;

namespace ModbusTcpDemo;

/// <summary>Modbus 异常响应:功能码最高位为 1 时,数据字段是异常码。</summary>
public sealed class ModbusProtocolException : Exception
{
    public byte FunctionCode { get; }
    public byte ExceptionCode { get; }

    public ModbusProtocolException(byte functionCode, byte exceptionCode)
        : base($"Modbus 异常:功能码 0x{functionCode:X2},异常码 0x{exceptionCode:X2}({Describe(exceptionCode)})")
    {
        FunctionCode = functionCode;
        ExceptionCode = exceptionCode;
    }

    private static string Describe(byte code) => code switch
    {
        0x01 => "非法功能码",
        0x02 => "非法数据地址",
        0x03 => "非法数据值(常见于读取数量超过 125)",
        0x04 => "从站设备故障",
        0x05 => "确认,正在处理",
        0x06 => "从站设备忙",
        0x08 => "存储奇偶校验错误",
        0x0A => "网关路径不可用",
        0x0B => "网关目标设备无响应",
        _    => "未知异常码",
    };
}

/// <summary>
/// 不依赖第三方库的 Modbus TCP 客户端,实现 FC03(读保持寄存器)。
/// </summary>
public sealed class ModbusTcpRawClient : IDisposable
{
    private const byte FcReadHoldingRegisters = 0x03;

    private readonly TcpClient _tcp;
    private readonly NetworkStream _stream;
    private ushort _transactionId;

    public ModbusTcpRawClient(string host, int port, int timeoutMs = 3000)
    {
        _tcp = new TcpClient
        {
            NoDelay = true,             // 禁用 Nagle 算法,小报文立即发出
            ReceiveTimeout = timeoutMs, // 防止设备不响应时死等
            SendTimeout = timeoutMs,
        };
        _tcp.Connect(host, port);
        _stream = _tcp.GetStream();
    }

    /// <summary>
    /// 读保持寄存器(FC03)。
    /// startAddress 是 0 基协议地址:点表地址 1 对应这里传 0。
    /// </summary>
    public ushort[] ReadHoldingRegisters(byte unitId, ushort startAddress, ushort quantity)
    {
        if (quantity is < 1 or > 125)
            throw new ArgumentOutOfRangeException(nameof(quantity), "单次读取数量必须在 1-125 之间");

        ushort transactionId = unchecked(++_transactionId);

        // MBAP 头 7 字节 + PDU 5 字节 = 12 字节
        byte[] request = new byte[12];
        WriteUInt16BigEndian(request, 0, transactionId);  // 事务标识符
        WriteUInt16BigEndian(request, 2, 0);              // 协议标识符,恒为 0
        WriteUInt16BigEndian(request, 4, 6);              // 长度 = 单元号1 + 功能码1 + 地址2 + 数量2
        request[6] = unitId;                              // 单元标识符
        request[7] = FcReadHoldingRegisters;              // 功能码 0x03
        WriteUInt16BigEndian(request, 8, startAddress);   // 起始地址
        WriteUInt16BigEndian(request, 10, quantity);      // 寄存器数量

        _stream.Write(request, 0, request.Length);

        return ParseReadHoldingRegistersResponse(transactionId, unitId, quantity);
    }

    private ushort[] ParseReadHoldingRegistersResponse(ushort expectedTransactionId, byte expectedUnitId, ushort quantity)
    {
        byte[] header = ReadExactly(7);
        ushort transactionId = ReadUInt16BigEndian(header, 0);
        ushort protocolId    = ReadUInt16BigEndian(header, 2);
        ushort length        = ReadUInt16BigEndian(header, 4);
        byte   unitId        = header[6];

        // 事务标识符必须与请求一致,否则说明响应串包了(同一连接上并发发请求时会发生)
        if (transactionId != expectedTransactionId)
            throw new IOException($"事务标识符不匹配:期望 {expectedTransactionId},收到 {transactionId}");

        if (protocolId != 0)
            throw new IOException($"协议标识符应为 0,实际收到 {protocolId},对端可能不是标准 Modbus TCP");

        if (unitId != expectedUnitId)
            throw new IOException($"单元标识符不匹配:期望 {expectedUnitId},收到 {unitId}");

        // length 包含单元标识符,所以 PDU 长度 = length - 1
        if (length < 2)
            throw new IOException($"长度字段非法:{length}");

        byte[] pdu = ReadExactly(length - 1);
        byte functionCode = pdu[0];

        // 功能码最高位为 1 → 这是异常响应
        if ((functionCode & 0x80) != 0)
            throw new ModbusProtocolException((byte)(functionCode & 0x7F), pdu[1]);

        if (functionCode != FcReadHoldingRegisters)
            throw new IOException($"功能码不匹配:期望 0x03,收到 0x{functionCode:X2}");

        int byteCount = pdu[1];
        if (byteCount != quantity * 2)
            throw new IOException($"数据字节数不匹配:期望 {quantity * 2},收到 {byteCount}");

        ushort[] registers = new ushort[quantity];
        for (int i = 0; i < quantity; i++)
            registers[i] = ReadUInt16BigEndian(pdu, 2 + i * 2);

        return registers;
    }

    /// <summary>
    /// TCP 是字节流,一次 Read 不保证拿到完整报文,必须循环读满。
    /// 这是手写 Modbus 客户端最常见的 bug 来源。
    /// </summary>
    private byte[] ReadExactly(int count)
    {
        byte[] buffer = new byte[count];
        int offset = 0;

        while (offset < count)
        {
            int read = _stream.Read(buffer, offset, count - offset);
            if (read <= 0)
                throw new IOException("连接被对端关闭");
            offset += read;
        }

        return buffer;
    }

    private static void WriteUInt16BigEndian(byte[] buffer, int offset, ushort value)
    {
        buffer[offset]     = (byte)(value >> 8);   // 高字节在前
        buffer[offset + 1] = (byte)value;
    }

    private static ushort ReadUInt16BigEndian(byte[] buffer, int offset)
        => (ushort)((buffer[offset] << 8) | buffer[offset + 1]);

    public void Dispose()
    {
        _stream?.Dispose();
        _tcp?.Dispose();
    }
}

调用

using var client = new ModbusTcpRawClient("192.168.1.200", 10001);

// 点表地址 1-100 → 协议地址 0-99,数量 100
ushort[] registers = client.ReadHoldingRegisters(unitId: 1, startAddress: 0, quantity: 100);

for (int i = 0; i < registers.Length; i++)
    Console.WriteLine($"点表地址 {i + 1,3}: {registers[i],6}  (0x{registers[i]:X4})");

这段代码里最该注意的三点

1. ReadExactly 的循环不能省。

TCP 没有"消息边界"的概念。服务器一次 write 了 209 字节,你的 Read 完全可能只拿到前 40 字节。直接 _stream.Read(buf, 0, 209) 然后解析,会随机失败——而且在同一台机器上测试往往不出现,换到现场就出问题,是最难查的一类 bug:单次 Read 在数据已经到齐时通常返回全部内容。

2. 事务标识符要校验。

它是用来配对请求和响应的。单连接串行发请求时不校验也"能跑",但一旦你改成并发(或者对端有重发),就会出现"读到的是上一个请求的响应"这种诡异现象。校验一下,出错立刻暴露。

3. 长度字段要验,不要盲信。

先按 7 字节读出头部,再根据 Length 字段决定读多少——这正是 MBAP 设计的用意。如果反过来"我先读 209 字节",就写死了数量,换个寄存器数就不通用了。


六、方案 B:用 NModbus(生产环境推荐)

手写版用来理解协议,真正上项目建议用成熟库。C# 生态里最稳的是 NModbus。

关于包的选择,有个坑

NuGet 上有两个名字很像的包:

包名

命名空间

状态

NModbus4

Modbus.Device

老项目,已多年无人维护

NModbus

NModbus

目前应该用的,最新稳定版 3.0.83

NModbus 是 NModbus4 的 fork(官方 README 明确写了"The NModbus4 project appears to have gone quiet. This is a fork of that project."),采用 MIT 协议,支持 .NET 6 / .NET Standard 2.0 / .NET Framework 4.6。

你在网上搜到的示例代码,绝大多数是 NModbus4 的写法using Modbus.Device; + ModbusIpMaster.CreateIp())。照着抄到新包里会编译不过。新版的入口是 ModbusFactory.CreateMaster(TcpClient)

dotnet add package NModbus

封装一个客户端

using System;
using System.Net.Sockets;
using System.Threading.Tasks;
using NModbus;

namespace ModbusTcpDemo;

/// <summary>基于 NModbus 3.x 的 Modbus TCP 客户端封装。</summary>
public sealed class ModbusTcpClient : IDisposable
{
    private readonly TcpClient _tcp;
    private readonly IModbusMaster _master;

    public ModbusTcpClient(string host, int port, int timeoutMs = 3000)
    {
        _tcp = new TcpClient
        {
            NoDelay = true,               // 禁用 Nagle,降低小报文时延
            ReceiveBufferSize = 32768,    // 单次读 100 个寄存器返回 209 字节,缓冲给足
            SendBufferSize = 32768,
        };
        _tcp.Connect(host, port);

        // NModbus 3.x 的入口:工厂 → CreateMaster(TcpClient)
        var factory = new ModbusFactory();
        _master = factory.CreateMaster(_tcp);

        _master.Transport.ReadTimeout = timeoutMs;
        _master.Transport.WriteTimeout = timeoutMs;
        _master.Transport.Retries = 0;    // 重试自己控制,避免掩盖真实故障
    }

    /// <summary>读保持寄存器(FC03)。startAddress 为 0 基协议地址。</summary>
    public Task<ushort[]> ReadHoldingRegistersAsync(byte unitId, ushort startAddress, ushort quantity)
        => _master.ReadHoldingRegistersAsync(unitId, startAddress, quantity);

    /// <summary>读输入寄存器(FC04)。</summary>
    public Task<ushort[]> ReadInputRegistersAsync(byte unitId, ushort startAddress, ushort quantity)
        => _master.ReadInputRegistersAsync(unitId, startAddress, quantity);

    /// <summary>写单个寄存器(FC06)。</summary>
    public Task WriteSingleRegisterAsync(byte unitId, ushort address, ushort value)
        => _master.WriteSingleRegisterAsync(unitId, address, value);

    /// <summary>写多个寄存器(FC16)。</summary>
    public Task WriteMultipleRegistersAsync(byte unitId, ushort startAddress, ushort[] values)
        => _master.WriteMultipleRegistersAsync(unitId, startAddress, values);

    public void Dispose()
    {
        _master?.Dispose();
        _tcp?.Dispose();
    }
}

调用方式:

using var client = new ModbusTcpClient("192.168.1.200", 10001);
ushort[] registers = await client.ReadHoldingRegistersAsync(unitId: 1, startAddress: 0, quantity: 100);

一个必须知道的限制:库不是线程安全的

同一个 IModbusMaster 实例不能并发调用。 因为它内部只维护一个请求队列和一个响应读取流程,两个线程同时发请求,就会发生"你拿到我的响应"的串包问题。

所以并发的正确做法是:

做法

是否可行

一个连接,多线程同时调用

❌ 会串包

一个连接,加锁串行调用

✅ 可行,但吞吐受限

每个端口一条独立连接,各跑一个轮询任务

推荐

你的场景正好是三个端口,天然适合"一个端口一条连接"。


七、三端口并发轮询

三个端口互不干扰,用三个独立的连接并行跑。整体结构:

              ┌──────────────────────────────┐
              │  采集调度器(每秒一次)        │
              └──────────────┬───────────────┘
                             │
        ┌────────────────────┼────────────────────┐
        ▼                    ▼                    ▼
  ┌───────────┐        ┌───────────┐        ┌───────────┐
  │ :10001    │        │ :10002    │        │ :10003    │
  │ 独立连接  │        │ 独立连接  │        │ 独立连接  │
  │ Unit ID 1 │        │ Unit ID 1 │        │ Unit ID 1 │
  │ 读 100 个 │        │ 读 100 个 │        │ 读 100 个 │
  └───────────┘        └───────────┘        └───────────┘
        │                    │                    │
        └────────────────────┼────────────────────┘
                             ▼
                   点表解析 → 输出 / 入库
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

const string Host = "192.168.1.200";
const byte UnitId = 1;
const ushort RegisterCount = 100;      // 点表地址 1-100
const ushort StartAddress = 0;         // 1 基点表地址 1 → 0 基协议地址 0

int[] ports = { 10001, 10002, 10003 };

using var cts = new CancellationTokenSource();

var tasks = new List<Task>();
foreach (int port in ports)
{
    int capturedPort = port;   // 闭包捕获,别直接用 foreach 变量
    tasks.Add(Task.Run(() => PollChannelAsync(capturedPort, cts.Token)));
}

// Ctrl+C 时优雅退出
Console.CancelKeyPress += (_, e) =>
{
    e.Cancel = true;
    cts.Cancel();
};

await Task.WhenAll(tasks);

async Task PollChannelAsync(int port, CancellationToken token)
{
    while (!token.IsCancellationRequested)
    {
        try
        {
            using var client = new ModbusTcpClient(Host, port);
            ushort[] registers = await client.ReadHoldingRegistersAsync(UnitId, StartAddress, RegisterCount);

            // 取前两个寄存器按 32 位浮点解析,作为示例
            float temperature = RegisterDecoder.ToFloat32(registers, 0);

            Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] :{port}  共 {registers.Length} 个寄存器," +
                              $"首值 {registers[0]},浮点 {temperature:F2}");
        }
        catch (OperationCanceledException)
        {
            break;
        }
        catch (Exception ex)
        {
            // 单个端口失败不能拖垮其它端口:记录、等一会、重连
            Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] :{port}  失败:{ex.Message}");
        }

        try
        {
            await Task.Delay(1000, token);
        }
        catch (OperationCanceledException)
        {
            break;
        }
    }
}

注意这里的重连策略:每次循环都 using 新建一个连接。这看起来"浪费",但对现场设备特别实用——很多 Modbus 网关在异常后会进入半死状态,只有断掉重连才能恢复。如果追求长连接效率,可以改成"连接失败才重建",但一定要有"连续 N 次失败就断开重连"的兜底。


八、点表解析:把 16 位整数变成工程值

寄存器读回来是一串 ushort,点表的意义就是把它翻译成有物理含义的值。这一节是最容易出错的地方。

点表定义

namespace ModbusTcpDemo;

public enum ModbusPointType
{
    UInt16, Int16,
    UInt32, Int32, Float32,
    UInt64, Int64, Float64,
}

/// <summary>点表中的一个测点。</summary>
public sealed record ModbusPoint(
    int    Port,            // 端口,如 10001
    string Name,            // 变量名
    ushort ProtocolAddress, // 0 基协议地址
    ModbusPointType Type,
    double Scale = 1.0,     // 工程值 = 原始值 × Scale
    string Unit  = "")
{
    /// <summary>该类型占用几个 16 位寄存器。</summary>
    public int RegisterCount => Type switch
    {
        ModbusPointType.UInt32 or ModbusPointType.Int32 or ModbusPointType.Float32 => 2,
        ModbusPointType.UInt64 or ModbusPointType.Int64 or ModbusPointType.Float64 => 4,
        _ => 1,
    };
}

解码器

using System;
using System.Buffers.Binary;

namespace ModbusTcpDemo;

/// <summary>32/64 位数据的字序。设备手册上常写成 ABCD / CDAB / BADC / DCBA。</summary>
public enum WordOrder
{
    /// <summary>高字在前(MSRF,即 ABCD),多数设备的默认值。</summary>
    HighWordFirst,
    /// <summary>低字在前(LSRF,即 CDAB)。</summary>
    LowWordFirst,
}

public static class RegisterDecoder
{
    public static double Decode(
        ushort[] registers,
        int offset,
        ModbusPointType type,
        WordOrder wordOrder = WordOrder.HighWordFirst) => type switch
    {
        ModbusPointType.UInt16  => registers[offset],
        ModbusPointType.Int16   => (short)registers[offset],
        ModbusPointType.UInt32  => ToUInt32(registers, offset, wordOrder),
        ModbusPointType.Int32   => (int)ToUInt32(registers, offset, wordOrder),
        ModbusPointType.Float32 => ToFloat32(registers, offset, wordOrder),
        ModbusPointType.UInt64  => ToUInt64(registers, offset, wordOrder),
        ModbusPointType.Int64   => (long)ToUInt64(registers, offset, wordOrder),
        ModbusPointType.Float64 => ToFloat64(registers, offset, wordOrder),
        _ => throw new NotSupportedException($"不支持的类型 {type}"),
    };

    /// <summary>把两个寄存器拼成 32 位浮点(IEEE 754 单精度)。</summary>
    public static float ToFloat32(ushort[] registers, int offset, WordOrder wordOrder = WordOrder.HighWordFirst)
    {
        ushort high = wordOrder == WordOrder.HighWordFirst ? registers[offset]     : registers[offset + 1];
        ushort low  = wordOrder == WordOrder.HighWordFirst ? registers[offset + 1] : registers[offset];

        // 按 IEEE 754 的大端顺序排出 4 个字节:A B C D
        Span<byte> bytes = stackalloc byte[4]
        {
            (byte)(high >> 8), (byte)high,
            (byte)(low  >> 8), (byte)low,
        };

        return BinaryPrimitives.ReadSingleBigEndian(bytes);
    }

    public static double ToFloat64(ushort[] registers, int offset, WordOrder wordOrder = WordOrder.HighWordFirst)
    {
        Span<byte> bytes = stackalloc byte[8];
        for (int i = 0; i < 4; i++)
        {
            int index = wordOrder == WordOrder.HighWordFirst ? i : 3 - i;
            ushort word = registers[offset + index];
            bytes[i * 2]     = (byte)(word >> 8);
            bytes[i * 2 + 1] = (byte)word;
        }
        return BinaryPrimitives.ReadDoubleBigEndian(bytes);
    }

    public static uint ToUInt32(ushort[] registers, int offset, WordOrder wordOrder = WordOrder.HighWordFirst)
    {
        ushort high = wordOrder == WordOrder.HighWordFirst ? registers[offset]     : registers[offset + 1];
        ushort low  = wordOrder == WordOrder.HighWordFirst ? registers[offset + 1] : registers[offset];
        return ((uint)high << 16) | low;
    }

    public static ulong ToUInt64(ushort[] registers, int offset, WordOrder wordOrder = WordOrder.HighWordFirst)
    {
        ulong value = 0;
        for (int i = 0; i < 4; i++)
        {
            int index = wordOrder == WordOrder.HighWordFirst ? i : 3 - i;
            value = (value << 16) | registers[offset + index];
        }
        return value;
    }
}

关于 BinaryPrimitives.ReadSingleBigEndian:这是 .NET 5 引入的,语义比 BitConverter + Array.Reverse 清楚得多——明确按大端读,不用关心运行机器的字节序,也不用分配临时数组。如果你的项目还在 .NET Framework 上,就退回 BitConverter 写法:

byte[] bytes = { (byte)(high >> 8), (byte)high, (byte)(low >> 8), (byte)low };
if (BitConverter.IsLittleEndian)
    Array.Reverse(bytes);       // 让小端机器也能正确解释大端数据
return BitConverter.ToSingle(bytes, 0);

字序:读出来是天文数字时先看这里

一个具体的对照。假设设备发来的两个寄存器是 0x41CC0xCCCD

字序

解释方式

结果

HighWordFirst(ABCD / MSRF)

0x41CC0000 + 0xCCCD = 0x41CCCCCD

25.6

LowWordFirst(CDAB / LSRF)

0xCCCD0000 + 0x41CC = 0xCCCD41CC

-1.07e8(一个巨大的负数)

所以:读到 25.6 是对的,读到 -107374000 这种就是字序选错了。 一秒钟就能判断。

还有两种更少见的字节序(BADCDCBA)需要在字节层面再交换。厂家的手册上一般会直接写"字序:CDAB"或者"高低字交换",按手册设置即可。如果手册没写,就用原始十六进制模式(-t 4:hex 或直接打印寄存器值)读一次,手工比对确定。


九、一个完整的可运行程序

把前面几块拼起来:三个端口、100 个寄存器、按点表解析、定时轮询、失败重连。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace ModbusTcpDemo;

public static class Program
{
    private const string Host = "192.168.1.200";
    private const byte   UnitId = 1;
    private const ushort PointTableBase = 0;    // 点表 1-100 → 协议地址 0-99
    private const ushort RegisterCount  = 100;

    /// <summary>
    /// 点表定义。示例填法——每个地址的真实含义请对照设备的通信手册替换。
    /// 协议地址 = 点表地址 - 1。
    /// </summary>
    private static readonly ModbusPoint[] Points =
    {
        new(10001, "环境温度",   0, ModbusPointType.Float32, 1,    "℃"),
        new(10001, "环境湿度",   2, ModbusPointType.Float32, 1,    "%RH"),
        new(10001, "供电电压",   4, ModbusPointType.UInt16,  0.1,  "V"),
        new(10002, "压力",       0, ModbusPointType.Float32, 0.01, "MPa"),
        new(10002, "运行状态字", 2, ModbusPointType.UInt16,  1,    ""),
        new(10003, "累计流量",   0, ModbusPointType.UInt64,  1,    "m³"),
    };

    public static async Task Main()
    {
        using var cts = new CancellationTokenSource();
        Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };

        int[] ports = { 10001, 10002, 10003 };

        var tasks = ports
            .Select(port => Task.Run(() => PollAsync(port, cts.Token)))
            .ToArray();

        await Task.WhenAll(tasks);
        Console.WriteLine("已停止。");
    }

    private static async Task PollAsync(int port, CancellationToken token)
    {
        var channelPoints = Points.Where(p => p.Port == port).ToArray();

        while (!token.IsCancellationRequested)
        {
            try
            {
                using var client = new ModbusTcpClient(Host, port);

                // 一次读满 100 个寄存器(100 ≤ 125,无需拆包)
                ushort[] registers = await client.ReadHoldingRegistersAsync(UnitId, PointTableBase, RegisterCount);

                Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] :{port}  读取 {registers.Length} 个寄存器成功");

                foreach (var point in channelPoints)
                {
                    int offset = point.ProtocolAddress - PointTableBase;

                    if (offset + point.RegisterCount > registers.Length)
                    {
                        Console.WriteLine($"    {point.Name,-12} 越界,跳过");
                        continue;
                    }

                    double raw = RegisterDecoder.Decode(registers, offset, point.Type);
                    double value = raw * point.Scale;

                    Console.WriteLine($"    {point.Name,-12} = {value,10:F3} {point.Unit}");
                }
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] :{port}  失败:{ex.Message}");
            }

            try { await Task.Delay(1000, token); }
            catch (OperationCanceledException) { break; }
        }
    }
}

跑起来大概是这样:

[22:15:03] :10001  读取 100 个寄存器成功
    环境温度     =     25.600 ℃
    环境湿度     =     61.200 %RH
    供电电压     =    220.400 V
[22:15:03] :10002  读取 100 个寄存器成功
    压力         =      0.850 MPa
    运行状态字   =     3.000
[22:15:03] :10003  读取 100 个寄存器成功
    累计流量     = 123456.000 m³

十、先验收连通性,再写代码

代码写完跑不通时,不要先怀疑代码。按这个顺序排除:

第 1 步:物理与网络

# 本机 192.168.1.147 → 服务 192.168.1.200
ping 192.168.1.200

不通就先查网线、网段、防火墙。这一步不过,后面都白搭。

第 2 步:端口是否开着

# Linux / macOS
nc -zv 192.168.1.200 10001

# Windows PowerShell
Test-NetConnection 192.168.1.200 -Port 10001

注意Test-NetConnectionTcpTestSucceededTrue 只说明端口有人监听,不代表它认 Modbus。三个端口要分别测。

第 3 步:用现成工具发一次请求

在写代码之前,先用命令行工具确认设备真的会响应:

modpoll -m tcp -p 10001 -a 1 -r 1 -c 100 -1 192.168.1.200

如果这个也读不出来,说明问题在设备侧或参数上(地址、单元号、功能码),跟你的 C# 代码无关。这一步能把"协议问题"和"代码问题"彻底分开,是最省时间的一步。

第 4 步:抓包看真实字节

# Linux 上抓包
sudo tcpdump -i eth0 host 192.168.1.200 and port 10001 -w modbus.pcap

modbus.pcap 用 Wireshark 打开(它会自动识别 Modbus TCP),能直接看到请求发出去了没有、响应回来了没有、功能码是不是 03、异常码是多少。"客户端发了但服务器没回"和"服务器回了异常"是两类完全不同的问题,抓包是唯一能一次分清的办法。

常见现象对照

现象

大概率原因

连接直接被拒绝(Connection refused)

端口不对,或服务没启动

连上了,请求发出去没响应,最终超时

Unit ID 不对,或设备只接受特定功能码

收到异常码 0x02

地址算错了(多减 1 / 用了 4xxxx 编号)

收到异常码 0x03

单次读超了 125 个

收到异常码 0x0B

网关通了但后端串口设备没响应

数值巨大或极小

字序选错

偶尔读到上一次的数据

同一连接上并发发请求,串包了


十一、常见坑速查

现象

解法

地址多减了 1

异常码 02,或数值整体偏移一个寄存器

1 基点表地址 − 1 = 0 基协议地址,只减一次

以为 40001 是地址

异常码 02

40001 是编号,协议地址是 0

单次读超过 125 个

异常码 03

拆成多段,每段 ≤ 125

手写客户端一次 Read 就解析

偶发失败,换机器就崩

循环读满(ReadExactly

同一 IModbusMaster 多线程调用

拿到别人的响应

每连接加锁,或每端口独立连接

读 32 位浮点只传 1 个寄存器

值完全不对

32 位占 2 个寄存器,64 位占 4 个

字序不匹配

天文数字

切换 HighWordFirst / LowWordFirst

3x 区试图写入

异常码 02

输入寄存器只读,要写改用 4x 区

端口写 502

连接超时

你的设备用 10001/10002/10003,显式指定

装了 NModbus4 抄新写法

编译不过

NModbus 包 + ModbusFactory.CreateMaster

没用 NoDelay

小报文延迟偏高

TcpClient.NoDelay = true

超时不设

设备掉线时程序卡死

ReceiveTimeout / ReadTimeout


十二、上手路径

阶段

做什么

达成什么

第 1 天

ping 通、三个端口逐个测通

确认网络和端口

第 2 天

用命令行工具读出 100 个寄存器

确认单元号、功能码、地址都正确

第 3 天

抓一次包,对着本文第二节核对字节

报文格式彻底看懂

第 4 天

跑通手写的 Socket 版本

理解 MBAP 和 PDU

第 5 天

换成 NModbus,三个端口并发

生产可用的采集骨架

第 6 天

填真实点表,核对每个测点的值

数据可信

之后

加落库、报警、断线重连、日志轮转

变成真正的采集系统

一句话总结:Modbus TCP 本身简单到可以背下来——7 字节头 + 功能码 + 数据;真正花时间的永远是地址换算、字序和现场连通性这三件事。


十三、参考

  • Modbus 应用协议规范(Modbus Application Protocol Specification V1.1b3):https://modbus.org/specs.php

  • Modbus Messaging on TCP/IP 实现指南:https://modbus.org/specs.php

  • NModbus(MIT,本文使用的 C# 库):https://github.com/NModbus/NModbus

  • NModbus API 文档:https://nmodbus.github.io/api/NModbus.html

  • NModbus NuGet 包:https://www.nuget.org/packages/NModbus

  • Wireshark 官方下载(自带 Modbus TCP 解析器):https://www.wireshark.org/download.html

本文基于 Modbus 应用协议规范 V1.1b3 与 NModbus 3.0.83 编写。示例中的 IP、端口、寄存器数量按实际现场参数(服务 192.168.1.200、客户端 192.168.1.147、端口 10001/10002/10003、单元号 1、地址 1-100)设置;每个地址的物理含义必须对照设备的通信手册,点表中的名称、类型、系数仅为结构示例。