Modbus 是 1979 年由 Modicon 提出并公开的工业通信协议,至今仍是工业现场的事实标准。它的核心特点决定了它为什么活得这么久:协议简单、完全开放、无版税、无授权门槛。Modbus TCP 是它跑在以太网上的版本——把 Modbus 的应用层报文直接装进 TCP,去掉了串口时代的 CRC 校验,靠 TCP 自己保证可靠性。
本文分两部分:前半部分把协议讲透(报文到底长什么样、地址怎么算、异常怎么读),后半部分用 C# 写一个真正能跑的客户端,按实际场景把三个端口、100 个寄存器的点表读出来。
一、先建立正确的心智模型
它是"请求-响应"式的,不是"推送"式的
Modbus 是典型的主从(客户端-服务器)模型:
客户端(主站)主动发请求,问"把 0 号开始的 100 个保持寄存器给我";
服务器(从站)只做两件事——响应,或者不响应。它永远不会主动发数据。
这一点必须先记住,因为它决定了你的程序形态:要拿到数据,只能靠定时轮询。 没有"数据变化就通知我"这种机制,所有"实时性"都靠你轮询得够快。
数据模型:四个区
Modbus 把数据分成四块,用不同的功能码访问:
实际项目里 90% 的工作都在 4x 保持寄存器上,本文也以它为主线。原因很简单:它是唯一既能读又能写的区,设备厂家天然喜欢把参数、测量值都放这里。
注意 3x 和 4x 的只读/读写区别:很多设备把实时测量值放在 3x(输入寄存器),你以为能写其实写不了,会收到异常码 02。
二、报文长什么样
这是全文最该看懂的一节。看懂报文,后面所有问题你都能自己定位。
结构:MBAP 头 + PDU
TCP 上的 Modbus 报文叫 ADU(应用数据单元),由两部分拼成:
┌──────────────────────────┬─────────────────────┐
│ MBAP 头(7 字节) │ PDU(N 字节) │
├────┬────┬────────┬───────┼──────┬──────────────┤
│ 事务│协议│ 长度 │ 单元 │功能码│ 数据 │
│ 2B │ 2B │ 2B │ 1B │ 1B │ N B │
└────┴────┴────────┴───────┴──────┴──────────────┘
MBAP 头逐字段说明
两个最容易搞错的地方:
Length 字段不是报文总长度,而是"从这个字段之后还有多少字节"。所以:
text Length = 完整报文总长度 - 6
减 6 是因为它前面已经有事务号(2) + 协议号(2) + 自己(2) = 6 字节。
Length 包含单元标识符。所以 PDU 的长度是
Length - 1。
一个真实的请求:读 3 号端口的前 100 个寄存器
假设具体场景是:
关键换算先行:点表上写的是 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"。
三、功能码与异常处理
常用功能码
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 就说明是异常,第二个字节就是异常码。
异常码对照表
排障时最有用的是 0x02 和 0x0B:前者几乎总是你地址算错了(多减了 1、或者用了 4xxxx 的编号而不是偏移量),后者说明网络层通了但串口那一端没响应。
四、把这套用到一个真实场景上
你的现场参数是这样:
先做几个判断
判断一:这是三条独立的链路,不是一台设备的三个区。
三个端口、从站号都是 1——如果是同一台设备的三个数据区,通常会用一个端口、靠不同单元号区分。三个独立端口更像是三台设备(或一台设备的三个独立服务/网关通道)。不论哪种,客户端侧的处理方式完全一样:一个端口一条连接。
判断二:100 个寄存器可以一次读完。
100 ≤ 125,不用拆包。一条 FC03 请求搞定,响应 209 字节。
判断三:网络是直连的。
192.168.1.147 和 192.168.1.200 在同一个 /24 网段,同一广播域,不需要配网关,直接可达。先 ping 一下确认,ping 不通就别往下写了。
建立点表
这是本文的实用核心。点表(Point Table)本质上就是"寄存器地址 → 工程含义"的映射表。
重要说明:每个地址具体代表什么物理量,只有你设备的通信手册才知道,任何人都替代不了。下面给的是结构示例——把名称、类型、系数、单位换成你的真实定义就能用。
几个要点:
地址区间要连续。
Float32和UInt32占 2 个寄存器,UInt64/Float64占 4 个,别在中间切断了。点表地址是 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 上有两个名字很像的包:
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);
字序:读出来是天文数字时先看这里
一个具体的对照。假设设备发来的两个寄存器是 0x41CC 和 0xCCCD:
所以:读到 25.6 是对的,读到 -107374000 这种就是字序选错了。 一秒钟就能判断。
还有两种更少见的字节序(BADC、DCBA)需要在字节层面再交换。厂家的手册上一般会直接写"字序: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-NetConnection 的 TcpTestSucceeded 为 True 只说明端口有人监听,不代表它认 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、异常码是多少。"客户端发了但服务器没回"和"服务器回了异常"是两类完全不同的问题,抓包是唯一能一次分清的办法。
常见现象对照
十一、常见坑速查
十二、上手路径
一句话总结: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)设置;每个地址的物理含义必须对照设备的通信手册,点表中的名称、类型、系数仅为结构示例。