简介:面向工业自动化通信学习与C#网络编程实践,这套基于TCP的Modbus源码示例适合需要理解PLC及自动化设备数据交换机制的初中级开发者,也可作为上位机通信模块与协议调试工具的参考实现。代码围绕TcpClient连接管理、请求与响应报文构造、NetworkStream数据收发、异常码检查及输入寄存器、线圈状态、离散输入等响应解析展开,并涉及async/await异步通信、超时处理和断线重试思路,读者可据此梳理请求响应流程、功能码使用和常见排错方法。压缩包共46个文件,约148KB,含C#源码、sln解决方案与csproj工程文件,另有dll、exe编译产物及xml、chm说明文档,可用开发工具直接打开对照调试,便于按模块阅读与验证。已有1076人学习下载,适合从示例工程入手理解TCP Modbus通信细节,并将连接、收发、解析与异常处理思路迁移到实际采集项目,提升协议实现与排错能力。
1. 上午正常下午掉线的温控表
上午十点前一切正常,过了中午,那台温控表在上位机里就变成一排NaN。现场拔插网线、重启软件、换交换机,全试过,时好时坏。后来抓包才看清:客户端每读一次就新建一条 TCP 连接,报文还经常只读到半截,粘包之后把下一条的 MBAP 头当成了寄存器数据。
这份 C# Modbus TCP 源码,就是照着这类现场问题整理出来的:纯 C# 实现的主站与从站,含 MBAP 报文封装、八种常用功能码分发、四张寄存器数据表、超时与重连,附带可直接运行的示例程序。它更像是一个能接进项目的骨架,而不是一份协议教科书。正在写 .NET 上位机、数据采集网关、设备模拟器的开发者,拿到手改两处配置就能跑。下面按结构、跑通、核心代码、踩坑、改造的顺序往下拆。
2. 分层拆解:MBAP 报文头、PDU 与四张数据表怎么对上号
Modbus TCP 看着简单,真正让人栽跟头的地方通常不在协议本身,而在分层没有分干净。源码包里的代码量不大,但层次切得很清楚:字节流层只管把 7 字节头和后续负载读满,协议层只管解析 PDU 和功能码,数据层只管四张表的读写,业务层拿到的已经是ushort[]或者bool[]。我在别的项目里见过把NetworkStream直接塞进业务代码的写法,第一次改需求就崩了,所以这个分层值得先讲透。
2.1 工程目录:五个核心文件撑起一个协议栈
源码包的目录不复杂,按职责大致是这么几块:
| 目录/文件 | 职责 | 改动频率 |
|---|---|---|
Protocol/MbapHeader.cs | 事务号、协议号、长度、单元号的封装与序列化 | 几乎不改 |
Protocol/ModbusPdu.cs | PDU 拼装与解析,功能码常量 | 加功能码时改 |
Protocol/BigEndian.cs | 大端读写、float/double 跨寄存器转换 | 遇到字序问题时改 |
Master/ModbusTcpClient.cs | 主站:连接、发送、收响应、超时重连 | 常改 |
Slave/ModbusTcpServer.cs | 从站:监听、每连接一个任务、请求分发 | 常改 |
Slave/SlaveDataStore.cs | 四张数据表与地址校验 | 常改 |
Demo/ | 控制台或 WinForm 示例,主站主站各一个 | 参考用 |
这种切法的好处是,从站和主站共用同一套MbapHeader、BigEndian和功能码常量。现场调试最怕的就是主站和从站各自实现一份字节序,两边都“自己觉得对”,结果一个按 ABCD 拼、一个按 CDAB 解,浮点数永远在跳。共用一套底层工具类,这种扯皮能省掉一大半。
目录里没有看到依赖注入容器、日志框架这类东西,Demo里是直接new出来的。我一般会在二次开发时自己套一层接口,把IModbusMaster抽出来,方便单测时打桩——毕竟让测试用例去连一台真实 PLC,CI 上跑一次要等半分钟。
2.2 一次请求的三种视角:字节流 / PDU / 业务值
理解这份源码最快的方式,是把同一次“读保持寄存器”的请求,用三种视角各看一遍。
第一种是字节流视角。主站发出去的完整帧(ADU)长这样,以读从站 1 的 40001 起 2 个寄存器为例:
00 01 00 00 00 06 01 03 00 00 00 02 └─┬─┘ └─┬─┘ └─┬─┘ │ │ └─┬─┘ └─┬─┘ 事务号 协议号 长度 单元号 功能码 起始地址 数量 (2B) (2B) (2B) (1B) (1B) (2B) (2B)前 7 个字节是 MBAP 头,后面的03 00 00 00 02是 PDU。注意长度字段写的是00 06,它统计的是“单元号 1 字节 + PDU 5 字节”,不含MBAP 头自身的前 6 字节。这一点是新手最容易写错的地方,长度少算 1 字节,对端就会一直等后续数据,表现出来就是“连上了但没响应”。
第二种是 PDU 视角。PDU 永远是“1 字节功能码 + N 字节数据”,请求和响应共用同一个功能码。响应里03后面第一个字节是字节数(04,表示 4 个字节的数据),再往后才是寄存器值。异常响应则是把功能码最高位置 1,也就是0x83,后面跟 1 字节异常码。
第三种是业务值视角。到了业务层,调用方看到的应该只有ushort[] { 235, 1180 },至于这 4 个字节怎么拼出来的、大端小端怎么转的,全部在底层消化掉。源码里ModbusTcpClient.ReadHoldingRegistersAsync返回的就是ushort[],这个边界划得对。
对应的 MBAP 头定义大概是这样:
public sealed class MbapHeader { public ushort TransactionId { get; set; } // 事务号,主站自增,从站必须原样回填 public ushort ProtocolId { get; set; } // Modbus 恒为 0,非 0 说明不是我们的报文 public ushort Length { get; set; } // 单元号(1) + PDU 长度,不含前 6 字节 public byte UnitId { get; set; } // 从站地址,TCP 下常用 1,网关场景才用得多 public const int Size = 7; }字段含义上面注释里写了,这里补两个实战注意点。TransactionId在 TCP 上没有协议层强制校验,但主站必须校验——如果一条连接上有并发请求,事务号对不上就意味着响应串了,宁可抛异常也不要硬塞给调用方。ProtocolId非 0 时应当直接断开连接而不是跳过,因为它说明对面根本不是 Modbus TCP 设备,继续读下去只会读到垃圾。UnitId在纯 TCP 直连场景下常被忽略,但走串口服务器或者 Modbus 网关时它是真实存在的,源码里保留了它,这点比较务实。
2.3 四张表的地址模型:为什么 40001 读出来是 HoldingRegisters[0]
Modbus 规定了四类数据,各自独立编址,互不干扰:
| 表 | 位/字 | 功能码 | 习惯地址段 | 读写 |
|---|---|---|---|---|
| 线圈 Coils | 位 | 0x01 / 0x05 / 0x0F | 0xxxx | 读写 |
| 离散输入 Discrete Inputs | 位 | 0x02 | 1xxxx | 只读 |
| 输入寄存器 Input Registers | 字 | 0x04 | 3xxxx | 只读 |
| 保持寄存器 Holding Registers | 字 | 0x03 / 0x06 / 0x10 | 4xxxx | 读写 |
数据层的实现极简,就是四个数组:
public sealed class SlaveDataStore { // 索引即协议地址偏移,0 对应 40001 / 30001 / 00001 / 10001 public ushort[] HoldingRegisters = new ushort[65536]; public ushort[] InputRegisters = new ushort[65536]; public bool[] Coils = new bool[65536]; public bool[] DiscreteInputs = new bool[65536]; }数组下标就是协议里的“起始地址”,所以 40001 对应HoldingRegisters[0],40002 对应[1]。这个 1-based 到 0-based 的换算必须在某一层统一做掉,否则每处调用都要减一,漏一次就是整段数据错位一格的经典事故。
65536 个元素的数组在 .NET 里,ushort[]占 128KB,bool[]占 64KB,四张表加起来不到 400KB,对现代机器完全无压力。但如果一个网关要模拟几百个从站,就该换成Dictionary<ushort, ushort>或者按段分配的稀疏结构,这一点在二次开发里会再说。
3. 跑通第一条链路:从 dotnet build 到读出 10 个保持寄存器
理论讲完,接下来落到能不能跑起来。这份源码用纯 C# 写成,不依赖任何第三方通信库,所以环境准备的成本很低。整个流程分四步:编译、起从站、起主站、用工具对拍。中间任何一步不通,都可以停在那里单独排查,不用一口气怀疑整条链路。
3.1 环境与工程骨架
需要 .NET SDK。源码的工程文件里目标框架写的是net6.0,机器上装了 .NET 8 也能直接编译,dotnet会向后兼容。如果手上的项目还卡在 .NET Framework 4.8,把LangVersion调到 7.3 以上、把Span<byte>换成byte[]也能跑,改动量不大,但会损失一点性能。
# 1. 看一眼 SDK 版本,低于 6.0 就先装 dotnet --list-sdks # 2. 在解决方案根目录还原并编译 dotnet restore dotnet build -c Debug # 3. 直接跑从站示例(Demo 里的服务端工程名按实际为准) dotnet run --project Demo/SlaveDemo编译报NETSDK1045基本就是 SDK 版本太低;报找不到System.Net.Sockets之类的,检查一下是不是误把工程当成 .NET Framework 类库建的,目标框架那行应该写成<TargetFramework>net6.0</TargetFramework>。
端口这块提前说清楚:Modbus TCP 的默认端口是 502,但 502 在 Linux 上属于特权端口(小于 1024),非 root 进程绑定会直接抛SocketException: Permission denied。源码的示例里监听端口做成了可配的,我一般本地调试先用 1502,部署到正式环境再改回 502 并给进程加上相应权限。Windows 上绑定 502 一般不需要管理员,但如果装过别的工控软件,很可能已经被占用了,netstat -ano | findstr :502先查一下省事。
3.2 起一个能应答的从站
从站的核心就是“监听 + 每连接一个任务 + 循环读请求”。这部分代码不长,但有几个细节决定了它稳不稳:
public async Task StartAsync(int port, CancellationToken ct) { var listener = new TcpListener(IPAddress.Any, port); listener.Start(); Console.WriteLine($"从站已监听 0.0.0.0:{port}"); while (!ct.IsCancellationRequested) { TcpClient client = await listener.AcceptTcpClientAsync(ct); client.NoDelay = true; // 关掉 Nagle,短报文场景延迟能降一个量级 _ = Task.Run(() => ServeAsync(client, ct), ct); // 一连接一任务,不阻塞 accept } } private async Task ServeAsync(TcpClient client, CancellationToken ct) { using (client) using (NetworkStream stream = client.GetStream()) { try { while (!ct.IsCancellationRequested) { ModbusRequest req = await ReadRequestAsync(stream, ct); byte[] pdu = _handler.Handle(req); // 业务分发返回 PDU byte[] adu = ModbusPdu.Wrap(req, pdu); // 回填事务号,重新算长度 await stream.WriteAsync(adu, ct); } } catch (IOException) { /* 对端关闭,正常退出 */ } catch (OperationCanceledException) { } } }NoDelay = true这行别省。Modbus 报文普遍在 20 字节以内,Nagle 算法会为了凑包把小的 write 攒一攒,表现出来就是“明明局域网,响应却要等 40 毫秒”。有同事排查过一整天的“通信慢”,最后就是这一行。
Task.Run里跑的是异步方法,外面套一层Task.Run其实不是必须的,ServeAsync本身在第一个await处就会让出线程。但现场代码我一般还是套上,避免有人在里面不小心写了同步阻塞调用把 accept 循环拖住。
ReadRequestAsync里必须做两件事:读满 7 字节头,再按头里的长度字段读满剩下的。这两步任何一步“读一次就当地读完”都是错的,原因在第 4 章会细说。
从站起来之后,先别急着连主站,找个 Modbus 调试工具连上试读一次。读 40001 起 10 个保持寄存器,如果返回 10 个 0,说明链路通了——SlaveDataStore初始化是全零,读出来零是对的。这一步能把“协议实现有 bug”和“后面业务代码有问题”彻底分开,省很多时间。
3.3 主站连上去,读第一段寄存器
主站侧暴露出来的接口应该让人用起来不像在拼接字节:
using var master = new ModbusTcpClient("192.168.10.50", 502, timeoutMs: 1000); await master.ConnectAsync(); // 读从站 1 的 40001 起 10 个保持寄存器,即 HoldingRegisters[0..9] ushort[] values = await master.ReadHoldingRegistersAsync(unitId: 1, start: 0, quantity: 10); for (int i = 0; i < values.Length; i++) Console.WriteLine($"4{i + 1:D4} = {values[i]}"); // 写单个寄存器:把 40003 改成 500(对应功能码 0x06) await master.WriteSingleRegisterAsync(unitId: 1, address: 2, value: 500); // 批量写:功能码 0x10,一次写 4 个 await master.WriteMultipleRegistersAsync(unitId: 1, start: 10, new ushort[] { 1, 2, 3, 4 });这里三个参数要说明白。unitId在 TCP 直连时传 1 就行,但走网关时它决定报文会被转发到哪台串口设备,填错的表现是“超时”而不是“异常响应”,因为网关根本不会应答。start是 0-based 地址,也就是 40001 传 0,别传 1,这是新手最高频的错。quantity是数量不是结束地址,读 40001 到 40010 要传 10。
timeoutMs给 1000 是工业现场的常见值。给太短,PLC 扫描周期抖动一下就超时;给太长,断线时要僵在那里好几秒才报错,画面看起来像卡死。我一般会在外面再包一层重试,连续 3 次超时才判定设备离线,而不是超时一次就报警——车间里一次瞬时丢包就弹窗报警,值班的人会被烦死。
3.4 用调试工具对拍,确认字节没有错位
主站能读出数不等于读对了。我见过数据地址整体偏移一个、结果整段工艺曲线看着“有点像但不对”的案例,这种最坑,因为数值看起来是合理的,没人会怀疑。
对拍的方法很直接:先在调试工具里手动把 40001 写成 1234,再用主站读,两边必须一致;然后写个边界值,比如 65535 和 0,确认没被截断;最后写浮点数占两个寄存器,确认字序。三步都过了,再谈业务。
4. 核心实现:功能码分发、大端拼装与粘包处理
跑通之后,该看真正撑起这套代码的几处实现了。这一章挑四个点:功能码怎么分发、字节序怎么统一、粘包半包怎么处理、异常响应怎么返回。这四块任何一块写歪,上层业务代码写得再漂亮也白搭。
4.1 功能码分发器:一个 switch 撑起八种请求
从站收到 PDU 之后,第一件事是看功能码,然后路由到对应的处理函数。源码里就是一个switch,没上什么设计模式:
public byte[] Handle(ModbusRequest req) { // PDU 至少 1 字节功能码,长度不够直接当非法报文丢弃 if (req.Pdu.Length < 1) return Array.Empty<byte>(); switch (req.FunctionCode) { case 0x01: return ReadCoils(req); // 读线圈 case 0x02: return ReadDiscreteInputs(req); // 读离散输入 case 0x03: return ReadHoldingRegisters(req); // 读保持寄存器 case 0x04: return ReadInputRegisters(req); // 读输入寄存器 case 0x05: return WriteSingleCoil(req); // 写单个线圈 case 0x06: return WriteSingleRegister(req); // 写单个寄存器 case 0x0F: return WriteMultipleCoils(req); // 写多个线圈 case 0x10: return WriteMultipleRegisters(req); // 写多个寄存器 default: return Exception(req, 0x01); // 非法功能码 } }用switch而不是策略字典,理由是功能码在协议里是封闭集合,就这八个常用,加一个0x17(读写复合)也不过是多一行case。上策略模式反而让调用链变长,调试时跳转不直观。
每个处理函数开头都要做参数校验,以读保持寄存器为例:
private byte[] ReadHoldingRegisters(ModbusRequest req) { ushort start = BigEndian.ReadUInt16(req.Pdu, 1); ushort qty = BigEndian.ReadUInt16(req.Pdu, 3); // 数量范围:0x03 功能码规定 1~125 if (qty < 1 || qty > 125) return Exception(req, 0x03); // 地址越界:用 int 运算,避免 ushort 相加溢出绕过判断 if (start + qty > _store.HoldingRegisters.Length) return Exception(req, 0x02); byte[] pdu = new byte[2 + qty * 2]; pdu[0] = 0x03; pdu[1] = (byte)(qty * 2); // 字节数 for (int i = 0; i < qty; i++) BigEndian.WriteUInt16(pdu, 2 + i * 2, _store.HoldingRegisters[start + i]); return pdu; }qty > 125这个上限是协议硬规定:PDU 最大 253 字节,减掉功能码、字节数、地址、数量,剩下的空间最多装 125 个寄存器。有些设备能读更多,那是厂商扩展,不要照着写进通用代码,否则接别的设备就崩。
start + qty必须用int算。ushort直接相加在 C# 里是合法的,但start = 65535, qty = 10会溢出回绕成 9,校验直接失效,然后for循环里就是数组越界异常。这类异常如果被外层catch (Exception)吞掉,表现出来就是“某些地址读不到,连接还在”,极难查。
4.2 字节序:Modbus 的大端和 .NET 的小端怎么和平共处
Modbus 规定所有 16 位数值按大端(Big-Endian)传输,高位字节在前。而 x86 机器是小端,所以每个字的两个字节都得翻一次。这份源码把所有翻转都收在一个静态类里:
public static class BigEndian { public static ushort ReadUInt16(byte[] buf, int offset) => (ushort)((buf[offset] << 8) | buf[offset + 1]); public static void WriteUInt16(byte[] buf, int offset, ushort value) { buf[offset] = (byte)(value >> 8); buf[offset + 1] = (byte)value; } // 32 位浮点占两个寄存器:wordSwap 解决 CDAB / BADC 这类字序差异 public static float ReadSingle(byte[] buf, int offset, bool wordSwap = false) { var tmp = new byte[4]; if (wordSwap) { tmp[0] = buf[offset + 2]; tmp[1] = buf[offset + 3]; // 低字在前 tmp[2] = buf[offset]; tmp[3] = buf[offset + 1]; } else { Array.Copy(buf, offset, tmp, 0, 4); } if (BitConverter.IsLittleEndian) Array.Reverse(tmp); // 再按本机端序翻 return BitConverter.ToSingle(tmp, 0); } }wordSwap这个开关是现场逼出来的。协议只规定“字内字节顺序”,没规定“两个寄存器谁在前”,于是四家大厂给出了四种排法:
| 排法 | 40001 | 40002 | 典型来源 |
|---|---|---|---|
| ABCD | 0x4142 | 0x4344 | 严格标准,高字在前 |
| CDAB | 0x4344 | 0x4142 | 高字在后,国内表计常见 |
| BADC | 0x4241 | 0x4443 | 字内字节互换 |
| DCBA | 0x4443 | 0x4241 | 字节和字序都反 |
看到浮点数读出1.6E-38或者NaN,八成就是字序没对上,先用wordSwap试一遍,通了再固化到点表配置里,不要每次现场手动改代码。
double占四个寄存器,规则更麻烦,很多设备干脆不传,用 int 按 0.1 倍率缩放代替,实际项目里我更推荐缩放整数,少一层字序雷区。
4.3 粘包与半包:为什么 Read 一次读不完整
NetworkStream.Read返回的是“当前可用的字节数”,没有任何边界保证。一条 12 字节的请求,可能一次读到 12 字节,也可能先读到 5 字节、下一次读到 7 字节。更要命的是多条请求连着发时,一次Read可能把两整条都读回来,这就是粘包。
解法只有一个:先读固定长度的头,从头里拿到长度,按长度读满。
private static async Task<byte[]> ReadExactAsync(NetworkStream s, int count, CancellationToken ct) { var buf = new byte[count]; int off = 0; while (off < count) { int n = await s.ReadAsync(buf.AsMemory(off, count - off), ct); if (n == 0) throw new IOException("对端已关闭连接"); // 关键:0 是关闭信号 off += n; } return buf; } public static async Task<ModbusRequest> ReadRequestAsync(NetworkStream s, CancellationToken ct) { byte[] header = await ReadExactAsync(s, MbapHeader.Size, ct); // 先读满 7 字节 ushort tid = (ushort)((header[0] << 8) | header[1]); ushort pid = (ushort)((header[2] << 8) | header[3]); ushort len = (ushort)((header[4] << 8) | header[5]); byte uid = header[6]; if (pid != 0) throw new IOException($"协议号非 0 ({pid}),不是 Modbus TCP 报文"); if (len < 2 || len > 260) throw new IOException($"长度字段非法: {len}"); byte[] pdu = await ReadExactAsync(s, len - 1, ct); // 再读满剩余部分 return new ModbusRequest { TransactionId = tid, UnitId = uid, Pdu = pdu }; }if (n == 0)这一行是黑匣子级别的。TCP 连接被对端优雅关闭时,Read不抛异常,只是返回 0,如果循环写成while (n > 0)之外的形式,很容易变成死循环空转,CPU 直接跑满,日志里什么都没有。我踩过这个坑,之后凡是手写Read循环,第一件事就是处理 0。
len的上限 260 也是防御性写法。协议规定 PDU 最大 253 字节,加 1 字节单元号是 254,给到 260 已经够宽松。遇到过对端发来len = 65500的情况,不校验就是等着分配 64KB 然后阻塞在那里。
4.4 异常响应:设备说不的时候,报文长什么样
设备拒绝请求时,不会不吭声,而是返回一个功能码最高位为 1 的 PDU(协议数据单元),后面跟 1 字节异常码:
| 异常码 | 含义 | 通常意味着 |
|---|---|---|
| 0x01 | 非法功能码 | 设备不支持这个功能,比如只读表收到 0x06 |
| 0x02 | 非法数据地址 | 地址越界,或这段地址没被定义 |
| 0x03 | 非法数据值 | 数量超限、字节数对不上 |
| 0x04 | 从站设备故障 | 设备内部执行出错 |
| 0x06 | 从站设备忙 | 让主站稍后重试,别当死机处理 |
从站侧生成:
private static byte[] Exception(ModbusRequest req, byte code) => new[] { (byte)(req.FunctionCode | 0x80), code };主站侧必须分开处理“超时”和“异常响应”。超时是链路问题,重连;异常响应是设备明确拒绝,重连没用,要改请求参数:
if ((pdu[0] & 0x80) != 0) { byte code = pdu[1]; throw code switch { 0x02 => new ModbusException($"地址越界: start={start}, qty={qty}"), 0x03 => new ModbusException($"数量非法: qty={qty}"), 0x06 => new ModbusBusyException("从站忙,稍后重试"), _ => new ModbusException($"设备返回异常码 0x{code:X2}") }; }把0x06单独抽成一个异常类型,是为了让上层的重试逻辑能区分对待。设备忙重试三次大概率会成功,地址越界重试一百次也没用,还会把日志刷爆。
5. 排查手册:连不上、读到 0、浮点数乱跳的五个现场
协议实现得再规范,现场该翻的车一样翻。下面这五条都是我在调试时真实遇到过的,按“现象 → 原因 → 解决”记,遇到了直接对号入座。
5.1 连接层:连不上,或者连上就断
现象一:ConnectAsync直接抛Connection refused,设备 ping 得通。
原因通常不是网络,而是端口或者监听地址。设备上的 Modbus 服务没起来、监听在 502 以外的端口、或者防火墙把 502 挡了,都会是同一个报错。ping通只能证明 IP 层可达,说明不了 TCP 端口开着。
解决:先用telnet 192.168.10.50 502或者Test-NetConnection -ComputerName 192.168.10.50 -Port 502确认端口通不通。通了再怀疑代码;不通就去查设备配置和防火墙。另外注意,很多 PLC 只允许有限的并发连接数(常见是 4 到 8 条),客户端每读一次新建一条连接、还不关,几分钟后就会全部拒绝,这就是第 1 章那个“上午正常下午掉线”的根因。改成一条长连接 + 断线重连,问题消失。
现象二:连上之后三五秒就断,日志里是对端重置。
原因是心跳缺失。有些设备或网关有连接空闲超时,一段时间没有报文就主动断开。解决是加一个低频保活,比如每 5 秒读一次某个固定寄存器,或者干脆把轮询周期设在设备超时时间的一半以内。别用 TCP 的KeepAlive指望它,默认两小时才探测一次,黄花菜都凉了。
5.2 报文层:超时、错位、异常码
现象三:报文发出去了,一直等到超时,抓包看设备根本没回。
按概率排,最可能的是长度字段算错了。Length只统计“单元号 + PDU”,如果按整个 ADU 的字节数填,从站会认为还有数据没到,一直等;如果少算了,从站会把下一条报文的前几个字节当成本条的尾巴吃掉,然后整条链路错位,后面全是超时。
解决:抓包对照。一条读 10 个寄存器的请求,ADU 应该是 12 字节,Length字段是 6。用调试工具发同样的请求抓一次,与自己的字节逐位对比,通常一眼就能看出来。
现象四:读回来的数整体偏移一格,或者读 10 个只回来 8 个。
地址换算错了。1-based 的“40001”被当成 0-based 传了 1,整段后移一位;或者quantity被理解成“结束地址”,传了 40010 而不是 10,数量校验直接触发异常码 0x03。
解决:在客户端里把start和quantity的命名写清楚,我习惯写成startOffset和count,让人不容易误会。同时在调试日志里打印实际发出的十六进制报文,跟点表对一遍。点表这东西,宁可多花十分钟核对,也别指望现场改。
5.3 数据层:值不对、浮点数乱跳、写入不生效
现象五:整型读出来正常,浮点数一会儿NaN一会儿1.6E-38。
字序没对上。前面那张 ABCD/CDAB 表就是干这个的。有个快速判断法:把两个寄存器当ushort打印出来,如果高位字的数值明显像是“另一半”,那就是字序反了;如果每个字内部的两个字节看着别扭(比如 1234 读成 0x3412),那是字节序反了。
解决:先按wordSwap试一次,通了再把该设备的字序写进点表配置,不要写死成全局开关——一个网关下挂三台不同厂的表,字序大概率不一样。
顺带说一个“写入不生效”的老坑:功能码 0x06 写单个寄存器,响应是把请求原样回显,很多人误以为“有响应就是写成功了”。有的设备会先回显再异步落盘,如果紧接着读一次,可能读到旧值。稳妥做法是写完延时 50 到 100 毫秒再读回校验,别拿回显当成功凭证。
6. 二次开发:把点表搬进配置,把轮询压进一条连接
源码能跑通之后,真正的活才开始:一个项目动辄几十上百个点位,硬编码的ReadHoldingRegistersAsync(1, 0, 10)写到第十个就没人维护得动了。我在原包基础上做的第一件事是把点表挪进配置,第二件事是把轮询调度收成一条连接上的串行队列。
6.1 点表驱动:让新增设备只改 JSON
思路很简单,把“设备地址 + 功能码 + 起始地址 + 数量 + 数据类型 + 字序 + 缩放系数”描述成数据,代码只负责按描述去读。配置长这样:
{ "devices": [ { "name": "1号温控表", "host": "192.168.10.50", "port": 502, "unitId": 1, "pollMs": 1000, "points": [ { "tag": "PV", "fc": 3, "start": 0, "count": 1, "type": "int16", "scale": 0.1 }, { "tag": "SV", "fc": 3, "start": 1, "count": 1, "type": "int16", "scale": 0.1 }, { "tag": "Flow", "fc": 3, "start": 10, "count": 2, "type": "float", "wordSwap": true }, { "tag": "Run", "fc": 1, "start": 0, "count": 1, "type": "bool" } ] } ] }对应的读取逻辑变成一次分组:把同一功能码、地址连续的多个点合并成一次请求。上面PV和SV地址相邻,一次fc=3, start=0, count=2就够,请求数从 2 降到 1。一个 40 点的设备,合并之后通常只剩七八条报文,轮询周期能压下来一半以上。
这里有两条硬约束得守住:合并后的count不能超过 125(fc=3/4)或 2000(fc=1/2 的位数量),超了就拆成多批;间隔很远的地址不要硬合并,中间那些没定义的空间有些设备会直接回异常码 0x02,整批都读不到,反而更慢。我的做法是地址间隔小于 8 个寄存器才合并,超过就单独成批。
6.2 轮询与优化:一条连接、一个队列、一套退避
并发这块必须克制。多数 PLC 的 TCP 连接数和并发处理能力都有限,多线程同时往一条连接上写,报文会交错、事务号会撞。最稳的结构是:每台设备一条长连接,连接上挂一个串行队列,所有请求排队执行。
// 每台设备一个信号量,保证同一条连接上的请求串行 private readonly SemaphoreSlim _gate = new(1, 1); public async Task<ushort[]> ReadAsync(PointDef p, CancellationToken ct) { await _gate.WaitAsync(ct); try { return await SendAndReceiveAsync(p, ct); } finally { _gate.Release(); } }再配一套指数退避的重连策略:断开后等 1 秒重连,失败等 2 秒,再失败 4 秒,封顶 30 秒。固定间隔重连在设备断电时会变成每秒一次的无效连接风暴,日志刷屏不说,有的设备还会因为连接数打满而彻底拒绝服务。退避之后,设备恢复供电基本在十几秒内自动接回来。
验证方法我一般做三层。第一层是单元测试,用内存流模拟收发,校验字节拼装和字序转换,这部分不依赖网络,CI 上跑得飞快。第二层是回环测试,同一台机器上起源码里的从站,主站连127.0.0.1跑一万次读写,看内存和句柄有没有泄漏——TcpClient忘记Dispose的话,句柄数会稳步上涨,跑一晚上就崩。第三层才是接真实设备,只验证物理层和对方设备的兼容性,前两层已经把逻辑错误挡掉了。
改别人的通信代码,我最怕的不是写错,是“改完看起来对了”。从那以后我每次动协议层的代码,都强制走一遍:先抓包比对字节,再跑回环压测,最后才上设备。少一步,后面多半要多花三倍时间在车间里找补。这份 C# Modbus TCP 源码把分层和字节处理这两块铺得比较正,照着它改点表和调度,比自己从零拼报文要省心得多,希望帮到你。
本文还有配套的精品资源,点击获取