简介:这份资源是一套基于 TCP 的 Modbus 通信协议 C# 实现源码,面向已具备一定 C# 基础、希望快速掌握工业以太网通信的开发者与自动化方向学生。示例围绕 Modbus TCP 的类封装展开,涵盖连接管理、请求报文构造、NetworkStream 数据收发、异常码检查、响应报文解析,以及 async/await 异步通信与异常重试思路,可直接对照理解 PLC、寄存器读写等典型工业场景的落地方式。压缩包共 46 个文件、约 148KB,以 cs 源码为主体,辅以 csproj、sln 工程配置,dll、pdb 依赖与调试文件,以及 xml 文档注释、chm 帮助和 exe 示例程序,构成可直接打开调试的完整解决方案。目前已有 1076 人学习下载,读者可通过阅读与断点调试源码,理清 Modbus TCP 报文结构与通信流程,并将其中的类模块迁移到自建上位机或设备联调项目中,减少从协议文档到可用代码之间的摸索成本。
1. 从一次车间联调说起:调试工具能读,自己的程序读不到
前阵子帮人看一个产线采集的小上位机,现场情况特别典型:拿现成的 Modbus 调试工具连 PLC,功能码 03 一按就出数;换成自己用 C# 写的程序,TCP 连接明明建立了,读回来要么是空数组,要么直接超时。折腾半天,最后发现是 MBAP 头里的长度字段算错了一个字节。这就是一份「基于 TCP 的 Modbus + C# 源码」最真实的价值——它不是教你 Modbus 是什么,而是把组包、拆包、地址换算、异常码这些容易在车间里翻车的地方,用能编译运行的代码摆在你面前。适合做上位机、设备网关、数据采集的开发,也适合刚接手一个老 PLC 项目、需要自己写通信层的人。下面按协议、结构、跑通、排查、改造五步拆。
2. 协议底盘拆解:MBAP 头与 PDU 的逐字节组包
Modbus TCP 相比 RTU 版本,最大的变化是把串口那套 CRC 校验换成了一个 7 字节的 MBAP 头,帧边界靠长度字段而不是靠 3.5 个字符时间的静默来判断。这个设计对写代码的人是好事——不用再算 CRC,也不用做定时器分隔,只要老老实实按长度字段读完就行。但坏处是长度字段写错一个数字,对面就会一直等,表现出来就是"连接正常、没有返回",非常难查。
2.1 一条读保持寄存器请求长什么样
先看一条完整的请求字节流,这是主站发给从站、要求从 0 号地址开始读 10 个保持寄存器的报文:
00 01 00 00 00 06 01 03 00 00 00 0A拆开看就是两段:前 7 个字节是 MBAP 头,后面是 PDU。
| 字段 | 字节位置 | 示例值 | 说明 |
|---|---|---|---|
| 事务标识符 | 0-1 | 0x0001 | 主站自增,响应必须原样带回 |
| 协议标识符 | 2-3 | 0x0000 | Modbus 固定 0,非 0 可以直接丢弃 |
| 长度 | 4-5 | 0x0006 | 后续字节数,含单元标识符 |
| 单元标识符 | 6 | 0x01 | 站号,TCP 下常用于区分网关后面的串口设备 |
| 功能码 | 7 | 0x03 | 读保持寄存器 |
| 起始地址 | 8-9 | 0x0000 | 0 基地址 |
| 寄存器数量 | 10-11 | 0x000A | 一次最多 125 个 |
对应的组包代码:
using System.Buffers.Binary; public static class ModbusCodec { // 组一条"读保持寄存器(0x03)"请求,返回 12 字节完整帧 public static byte[] BuildReadHoldingRegisters(ushort transactionId, byte unitId, ushort startAddress, ushort quantity) { if (quantity is < 1 or > 125) throw new ArgumentOutOfRangeException(nameof(quantity), "0x03 单次最多读 125 个寄存器"); var frame = new byte[12]; // MBAP 头:事务号 + 协议号 + 长度 + 单元标识 BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(0), transactionId); BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(2), 0x0000); // 长度 = 单元标识(1) + PDU(功能码1 + 起始地址2 + 数量2) = 6 BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(4), 6); frame[6] = unitId; // PDU frame[7] = 0x03; BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(8), startAddress); BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(10), quantity); return frame; } }这里有几个参数必须说清楚。transactionId由主站维护,每发一帧自增一次,它的作用是让响应能对上请求,在并发场景下尤其重要;unitId在纯 TCP 直连设备时通常填 1 或者设备手册指定的站号,很多网关设备会用它来路由到后面的串口从站,填错了就会出现"网关在线但不转发"的现象;startAddress是 0 基地址,和 PLC 手册上常见的 40001 这种 1 基编号差 1,这是新手最常栽的地方,后面第 5 章会专门说。
再看响应帧:
00 01 00 00 00 05 01 03 02 00 0A长度字段变成了 5,因为 PDU 从 5 字节缩成了 3 字节加上数据 2 字节:单元标识 1 + 功能码 1 + 字节数 1 + 数据 2 = 5。字节数0x02表示后面跟着 2 个字节的数据,读 10 个寄存器时这个字段就是0x14(20 字节)。解析时先读 7 字节头拿到长度,再按长度减 1(减掉单元标识)读 PDU,这个顺序不能反。
2.2 功能码与四个数据区的对应关系
Modbus 把数据分成四个区,两个可读写的、两个只读的,功能码和区是一一对应的。写代码的时候如果把这层映射搞混,就会出现"我明明写进去了、读出来还是旧值"——因为你写的是线圈,读的是保持寄存器。
| 功能码 | 名称 | 数据区 | 单位 | 单次上限 |
|---|---|---|---|---|
| 0x01 | 读线圈 | Coils | bool | 2000 位 |
| 0x02 | 读离散输入 | Discrete Inputs | bool | 2000 位 |
| 0x03 | 读保持寄存器 | Holding Registers | ushort | 125 个 |
| 0x04 | 读输入寄存器 | Input Registers | ushort | 125 个 |
| 0x05 | 写单个线圈 | Coils | bool | 1 个 |
| 0x06 | 写单个寄存器 | Holding Registers | ushort | 1 个 |
| 0x0F | 写多个线圈 | Coils | bool | 1968 位 |
| 0x10 | 写多个寄存器 | Holding Registers | ushort | 123 个 |
从站侧的数据区我一般用一个类装起来,四个数组都是 65536 长度,索引就是协议地址,省掉各种偏移换算:
public sealed class DataStore { // 索引即 Modbus 协议地址,从 0 开始 public bool[] Coils { get; } = new bool[65536]; public bool[] DiscreteInputs { get; } = new bool[65536]; public ushort[] HoldingRegisters { get; } = new ushort[65536]; public ushort[] InputRegisters { get; } = new ushort[65536]; }用满 65536 个槽位,ushort[]是 128KB,四个数组加起来 320KB 左右,对现代机器完全不是负担,换来的是地址直接当下标用,不用写任何偏移逻辑。如果你要接的真实设备只映射几百个点,可以把长度收小,但收小之后一定要在读写前做边界检查,否则越界异常会在协议层变成一个莫名其妙的连接断开。
2.3 大端序和位打包:C# 里最容易翻车的地方
Modbus 是网络字节序,也就是大端;x86 和 ARM 上的 .NET 跑的是小端。一个ushort值0x1234,直接BitConverter.GetBytes出来是34 12,写到线上就错了,对面读到的会是0x3412。这个错误在数值较小的时候不容易发现,因为0x0001反过来是0x0100,一读就露馅;但如果刚好是个对称的值,你可能要查一下午。
正确做法是用BinaryPrimitives里的 BigEndian 系列方法,或者把BitConverter的结果反转一次:
// 推荐:语义明确,不用自己管字节顺序 BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(offset), value); ushort v = BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(offset)); // 老项目里常见的写法,也能跑,但多一次分配 byte[] raw = BitConverter.GetBytes(value); if (BitConverter.IsLittleEndian) Array.Reverse(raw);线圈方向的坑更隐蔽。读线圈返回的数据是位打包的,1 个字节装 8 个线圈,规则是"第一个字节的最低位对应第一个线圈":
// 把读到的字节按位展开成 bool,bit0 对应起始地址 public static bool[] UnpackBits(ReadOnlySpan<byte> data, int count) { var result = new bool[count]; for (int i = 0; i < count; i++) { // i / 8 决定用哪个字节,i % 8 决定取哪一位 result[i] = (data[i / 8] & (1 << (i % 8))) != 0; } return result; }还有一个寄存器拼 32 位整数的顺序问题。Modbus 本身只定义到 16 位寄存器,32 位浮点或整数要占两个寄存器,先高后低还是先低后高,各家设备厂商做法不一样,有的叫 word swap,有的叫 byte swap。这个不写死在协议层,最好在业务层配置一个枚举,我一般叫它WordOrder,读出来先按寄存器顺序拿到两个ushort,再按配置拼装,改设备的时候只改配置不改代码。
3. 源码结构:主站与从站两条线怎么分
一份能用的 Modbus TCP C# 源码,不管规模大小,本质上都跑不出两条线:一条是主动发请求的主站(Master,也叫 Client),一条是应答请求的从站(Slave,也叫 Server)。把这两条线和协议编解码分开,是判断这份代码能不能拿去二次开发的第一标准。见过太多项目把 Socket 读写、报文拼装、业务逻辑全塞在一个 Form 的按钮事件里,改一个功能码要动三百行。
3.1 目录骨架与工程拆分
合理的拆法是四个工程,用.sln串起来:
ModbusTcp.sln ├── Modbus.Protocol/ # 纯计算,无 IO:编解码、功能码、异常码 │ ├── ModbusCodec.cs # 组包/拆包 │ ├── FunctionCode.cs # 功能码常量 │ └── ModbusException.cs # 异常码封装 ├── Modbus.Transport/ # Socket 层:连接、收满一帧、重连 │ ├── IModbusTransport.cs │ └── TcpModbusTransport.cs ├── Modbus.Master/ # 主站:请求/响应匹配、超时重试 │ └── ModbusMaster.cs ├── Modbus.Slave/ # 从站:监听、请求分发、数据区 │ ├── ModbusSlave.cs │ └── DataStore.cs └── Modbus.Demo/ # 控制台,用来联调验证 └── Program.csModbus.Protocol之所以要独立,是因为它可以完全脱离网络做单元测试——给一段字节,断言解析出来的功能码和地址对不对。这份代码里最值钱的部分就在这,只要转发层不出大问题,协议层测通了,剩下的就是连不连得上的问题。
环境上,.NET 6及以上都行,用dotnet new console起项目,System.Buffers.Binary和System.Net.Sockets都是框架自带,不需要任何第三方依赖。如果你的上位机还停在.NET Framework 4.7.2,BinaryPrimitives需要引一个System.Memory包,Span<T>也能用,代码基本不用改。业界也有一些成熟的开源 Modbus 库可以直接NuGet引用,如果只是临时采个数,用它更快;但要在别人的框架上做定制、或者要嵌到一个已有的通信中间件里,自己掌握这份源码会舒服很多。
3.2 传输层:TcpClient 长连接、分包与重连
Modbus TCP 用 502 端口,长连接。这里最容易做错的是"以为Read一次就能拿到一帧"——TCP 是字节流,一帧 12 字节可能分两次到,两帧也可能粘在一起到。所以传输层必须提供一个"读满 N 字节"的方法:
public sealed class TcpModbusTransport : IModbusTransport, IDisposable { private readonly TcpClient _client; private readonly NetworkStream _stream; public TcpModbusTransport(string host, int port, int timeoutMs = 2000) { _client = new TcpClient(); _client.NoDelay = true; // 关掉 Nagle,小包立刻发 _client.Connect(host, port); _client.ReceiveTimeout = timeoutMs; _client.SendTimeout = timeoutMs; _stream = _client.GetStream(); } // 读满 buffer.Length 个字节才返回,处理半包 public async Task<bool> ReadExactAsync(Memory<byte> buffer, CancellationToken ct) { int read = 0; while (read < buffer.Length) { int n = await _stream.ReadAsync(buffer[read..], ct); if (n == 0) return false; // 对端关闭连接 read += n; } return true; } public ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken ct) => _stream.WriteAsync(frame, ct); }NoDelay = true这一行值得单独说:Modbus 的请求帧通常只有十几个字节,如果开着 Nagle 算法,系统会攒一攒再发,表现到应用层就是"偶尔多出几十毫秒延迟",在高频轮询场景下很扎眼。ReceiveTimeout设成 2 秒是个经验值,产线设备响应一般在几十毫秒内,超过 2 秒基本可以判定为设备掉线或者报文错了。
重连不要写在传输层里面。传输层只管"给我一个能用的连接",重连由主站层用一个带退避的循环来做:连不上就等 1 秒、2 秒、4 秒,最多退到 30 秒,并且在重连成功后把transactionId重置。把重连塞进传输层,会导致上层无法区分"是首次连接失败"还是"用着用着断了",日志会很难看。
3.3 从站侧:一个字节流如何走到寄存器数组
从站的循环是"接一个连接起一个任务",每个连接内部循环"读 7 字节头 → 按长度读剩余 → 分发 → 回响应":
private async Task HandleClientAsync(TcpClient client, CancellationToken ct) { using var stream = client.GetStream(); var header = new byte[7]; while (!ct.IsCancellationRequested) { // 第 1 步:读满 7 字节 MBAP 头 if (!await ReadExactAsync(stream, header, ct)) return; ushort protocolId = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(2)); ushort length = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4)); // 协议标识非 0 说明不是 Modbus,直接断 if (protocolId != 0 || length is < 2 or > 260) return; // 第 2 步:长度字段含单元标识,PDU 长度 = length - 1 var pdu = new byte[length - 1]; if (!await ReadExactAsync(stream, pdu, ct)) return; // 第 3 步:分发并组响应 byte[] responsePdu = Dispatch(pdu, _store); var response = BuildMbap(header, (ushort)(responsePdu.Length + 1), responsePdu); await stream.WriteAsync(response, ct); } }length is < 2 or > 260这个边界检查不是摆设。真实网络里偶尔会收到扫描包或者上一条连接残留的脏数据,长度字段可能是 0 也可能是几万,如果不拦住,new byte[length - 1]要么抛异常,要么直接吃掉几百 KB 内存。上限 260 对应 Modbus 规范的最大 PDU 长度 253 加单元标识,超过就不是合法帧。
Dispatch是个switch,把功能码映射到具体处理函数,每个处理函数负责三件事:参数合法性检查、数据区读写、组装响应 PDU。读请求的响应 PDU 是"功能码 + 字节数 + 数据",写请求的响应 PDU 基本是原样回显前 5 个字节(功能码 + 地址 + 数量)。异常响应的规则是功能码最高位置 1(比如 0x03 变成 0x83),后面跟一个异常码字节。
4. 跑通全流程:起一个从站,再用主站读写
理论讲完,落地要能一次跑通。这一节按"先起从站,再用主站打"的顺序来,因为从站是自己写的,可控;主站打自己的从站,报文对不对一眼就能看出来。全部代码在控制台里跑,不依赖任何界面。
4.1 从站最小实现与启动参数
从站的入口就是一个TcpListener加一个接受循环:
public sealed class ModbusSlave { private readonly TcpListener _listener; private readonly DataStore _store = new(); public ModbusSlave(int port = 502) { // 502 是标准端口,非管理员身份在 Windows 上可能绑不上,调试期建议换 1502 _listener = new TcpListener(IPAddress.Any, port); } public async Task StartAsync(CancellationToken ct) { _listener.Start(); Console.WriteLine($"从站已监听 {_listener.LocalEndpoint}"); while (!ct.IsCancellationRequested) { var client = await _listener.AcceptTcpClientAsync(ct); _ = Task.Run(() => HandleClientAsync(client, ct), ct); } } }启动前先在数据区里塞几个值,方便验证:
var slave = new ModbusSlave(1502); // 预置:保持寄存器 0~9 放 100,101,...;线圈 0~7 前四个为 true for (int i = 0; i < 10; i++) slave.Store.HoldingRegisters[i] = (ushort)(100 + i); for (int i = 0; i < 4; i++) slave.Store.Coils[i] = true; _ = slave.StartAsync(cts);端口这里有个现实问题:502 是 Modbus TCP 的约定端口,但在 Windows 上绑定 1024 以下的端口需要管理员权限,直接跑会抛SocketException: Access is denied。调试阶段用 1502 或者 5020 完全可以,主站连的时候把端口一起改掉就行,协议本身跟端口无关。真到部署的时候再用管理员权限或者配一个端口转发。
AcceptTcpClientAsync后面跟_ = Task.Run(...)是故意不 await 的,每个连接跑自己的任务。这里要注意异常处理,HandleClientAsync内部必须把IOException和SocketException兜住并打日志,否则一个客户端异常断开就能让整个进程崩掉——这种问题在调试期很少遇到,一上现场就集中爆发。
4.2 主站读写线圈和保持寄存器
主站的核心是"发一帧、等一帧、校验、返回",用SemaphoreSlim保证同一时刻只有一个请求在飞,因为 Modbus TCP 虽然支持多事务并发,但绝大多数设备内部是串行处理的,并发发过去反而会乱序:
public async Task<ushort[]> ReadHoldingRegistersAsync(byte unitId, ushort start, ushort count, CancellationToken ct = default) { await _lock.WaitAsync(ct); try { ushort tid = _transactionId++; byte[] request = ModbusCodec.BuildReadHoldingRegisters(tid, unitId, start, count); await _transport.SendAsync(request, ct); // 读 7 字节 MBAP 头 var header = new byte[7]; if (!await _transport.ReadExactAsync(header, ct)) throw new IOException("从站关闭了连接"); ushort respTid = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(0)); if (respTid != tid) throw new ModbusException($"事务标识不匹配:期望 {tid},实际 {respTid}"); ushort length = BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4)); var pdu = new byte[length - 1]; if (!await _transport.ReadExactAsync(pdu, ct)) throw new IOException("PDU 读取不完整"); // 功能码最高位为 1 表示异常响应 if ((pdu[0] & 0x80) != 0) throw new ModbusException($"从站返回异常码 0x{pdu[1]:X2}"); int byteCount = pdu[1]; var result = new ushort[byteCount / 2]; for (int i = 0; i < result.Length; i++) result[i] = BinaryPrimitives.ReadUInt16BigEndian(pdu.AsSpan(2 + i * 2)); return result; } finally { _lock.Release(); } }调用起来就是一行:
var master = new ModbusMaster("127.0.0.1", 1502); ushort[] regs = await master.ReadHoldingRegistersAsync(unitId: 1, start: 0, count: 10); Console.WriteLine(string.Join(",", regs)); // 期望输出 100,101,...,109 await master.WriteSingleRegisterAsync(1, 0, 999); ushort[] again = await master.ReadHoldingRegistersAsync(1, 0, 1); // 期望 999unitId: 1是站号,跟从站里frame[6]读出来的值对应;start: 0是 0 基地址,对应从站HoldingRegisters[0];count: 10会被校验在 1~125 之间。三个参数任意一个不对,返回的就不是你期望的数组,而且往往是"不报错但数据错位",比抛异常更难查。
4.3 联调验证:报文抓包与返回码对齐
跑通之后建议立刻做两件事。第一件是把主站和从站放在同一台机器上跑,127.0.0.1:1502,排除网络因素;第二件是打开一个报文抓包工具或者干脆在SendAsync前后打十六进制日志,对着前面的报文结构表逐字节核对。
日志里最该看的是三个数:事务标识符是否一一对应、长度字段是否等于实际后续字节数、功能码是否被原样返回。这三个对上了,剩下的基本就是数据区的地址问题。如果从站返回的是0x83(0x03 最高位置 1),那就说明请求到达了从站但被拒绝了,后面的异常码字节会告诉你是地址越界(0x02)还是数量超限(0x03)。
写多个寄存器(0x10)的联调容易被忽略。它的请求 PDU 比读请求多一个字节数字段,长度 = 单元标识 1 + 功能码 1 + 起始地址 2 + 数量 2 + 字节数 1 + 数据 2n,很多人第一次写会把长度算成7 + 2n之外的数,结果从站一直等数据。稳妥的做法是让从站和主站共用同一套ModbusCodec里的常量,长度只在一个地方算。
5. 排查看这里:连接正常但读不到数的四类坑
这几种情况在车间里出现的频率远高于"协议不懂"。它们的共同特征是:连接是通的、程序不崩、但数据就是不对,靠猜是猜不出来的,得知道往哪儿看。
5.1 事务标识和单元标识错位
现象:主站收到响应,但解析出来的数据整体偏移,或者直接在"事务标识不匹配"上抛异常。
原因有两个方向。一是主站里_transactionId用了byte而不是ushort,超过 255 之后回绕,和老响应撞号;二是从站组响应时直接复用了请求的前 6 个字节,但忘了长度字段要按响应的实际长度重算,结果长度字段还停留在请求的值。
解决:事务标识用ushort并且只增不减,回绕就回绕,只要不在同一时刻重号就没问题;从站组 MBAP 头时,长度字段必须重新计算成responsePdu.Length + 1,不要复用请求头。单元标识则要在主站侧做校验,响应里的单元标识和请求不一致时直接丢弃这一帧,否则网关环境下会把别的从站的数据当成自己的。
5.2 半包、粘包与超时设置
现象:跑了几个小时没问题,忽然某一次读超时,重连之后又好了;或者调试工具里数据刷得飞快,自己的程序每隔几十次就错一次。
原因:TCP 是流式协议,一次ReadAsync不保证读到你想要的字节数。如果代码里写的是int n = await stream.ReadAsync(buffer);然后就直接解析buffer,那么当一帧被拆成两段到达时,第二段会被当成下一帧的开头,整个字节流从此错位。粘包则是相反的:一次读回来两帧,你只解析了第一帧,第二帧的字节被丢掉。
解决:所有读取都必须走"读满 N 字节"的循环,就像ReadExactAsync那样。还要注意ReadAsync返回 0 表示对端正常关闭,不是"暂时没数据",必须当成连接断开处理。超时值不要设得太短,2 秒是个稳妥的起点;PLC 在扫描周期的某些时刻会短暂不响应,设成 200 毫秒会误判。
5.3 地址基数与寄存器偏移
现象:想读 40001,结果读到的是 40002 的值;或者读 10 个寄存器,第一个是想要的,后面全部错位。
原因:PLC 手册上的地址是 1 基的,而且带区号前缀——40001 表示"保持寄存器区的第 1 个",协议里对应的地址是 0。30101 表示输入寄存器区第 101 个,协议地址是 100。有些设备手册干脆写"寄存器地址 40001~40100",很多人直接把这个数当协议地址填进去,整体偏移了 1 甚至 40000。
解决:写死一条规则——进协议层之前,所有地址统一换算成 0 基。在业务配置表里存原始地址和区类型,读的时候做一次映射:保持寄存器减 40001,输入寄存器减 30001,线圈减 1。这份映射单独写一个函数并加注释,不要散落在各个调用点。
5.4 异常码 0x02 / 0x03 的定位方法
现象:从站返回0x83 0x02,主站抛"地址越界",但代码里的地址看着没问题。
原因:0x02 是非法数据地址,0x03 是非法数据值。最常见的是数量超限——0x03 一次最多 125 个,写了 200 就会返回 0x03;其次是起始地址加数量超过了从站实际映射的区间。这两个错误由从站判定,主站侧只能根据异常码反推。
解决:在主站的ModbusException里把异常码翻译成人话,日志里直接打"非法数据地址,检查起始地址是否超出从站映射范围"和"非法数据值,检查数量是否超过 125"。同时把请求原文和响应原文一起打到日志里,十六进制,方便对照。有这个日志,现场排查时间能从半天压到十分钟。
6. 二次开发:把协议层换成可替换的接口
这份源码真正要用在自己的项目里,第一步不是加功能,而是把传输层抽成接口。原因是现场环境五花八门,今天是 TCP 直连,明天可能是串口转 TCP 的网关,后天又要换成 RTU 走 485。如果ModbusMaster里直接new TcpClient(),每换一种就得改一遍业务代码。
6.1 抽 IModbusTransport 接口
接口只需要两个方法:SendAsync和ReadExactAsync,加一个Dispose。
public interface IModbusTransport : IDisposable { ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken ct); Task<bool> ReadExactAsync(Memory<byte> buffer, CancellationToken ct); }上层ModbusMaster只依赖这个接口,构造函数注入。RTU 版本实现里SendAsync加 CRC、ReadExactAsync按帧长读,MBAP 头换成从站地址加 CRC;TCP 版本保持原样。业务层一行都不用改,只换一个工厂方法。这个改造我做过好几次,每次都是半小时的事,但省下的是后面几个月的返工。
6.2 批量读与缓存
轮询几十个点位的时候,别一个点发一帧。把地址连续的寄存器合并成一次读请求,是收益最大的优化——读 50 个寄存器,合并成 1 次 0x03 请求,比发 50 次快一个数量级。合并的时候注意两点:一次不超过 125 个,地址不连续的不能硬合。合并不了的点再单独发。
再往上一层加个缓存,把上次读到的值和时间戳存起来,业务层按需取,通信层用固定周期刷新。这样即使某一帧超时,业务层拿到的也是上一次的有效值,不至于整个界面全变红。
6.3 回环测试与验证
改完协议层,验证方法很土但有效:起一个从站,起一个主站,让主站对所有功能码各跑一遍写入再读回,断言值一致。线圈写true读回true,寄存器写0x1234读回0x1234,多寄存器写 100 个值读回 100 个值。这个回环测试跑通,说明组包、拆包、字节序、位打包四件事全对。
我从那次长度字段算错之后落下一个习惯:每次改协议层,先跑回环,再连真实设备,绝不跳步。真实设备的不确定性太多,用它来验证自己的代码,等于同时排查两个变量,血泪经验。这套代码你拿去之后,建议也先把回环测试建起来,后面加多少功能码都不慌。希望帮到你。
本文还有配套的精品资源,点击获取