news 2026/11/6 2:08:52

C# Modbus TCP源码:MBAP、功能码与粘包处理实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# Modbus TCP源码:MBAP、功能码与粘包处理实战

简介:面向工业自动化通信学习与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.csPDU 拼装与解析,功能码常量加功能码时改
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 / 0x0F0xxxx读写
离散输入 Discrete Inputs位0x021xxxx只读
输入寄存器 Input Registers字0x043xxxx只读
保持寄存器 Holding Registers字0x03 / 0x06 / 0x104xxxx读写

数据层的实现极简,就是四个数组:

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这个开关是现场逼出来的。协议只规定“字内字节顺序”,没规定“两个寄存器谁在前”,于是四家大厂给出了四种排法:

排法4000140002典型来源
ABCD0x41420x4344严格标准,高字在前
CDAB0x43440x4142高字在后,国内表计常见
BADC0x42410x4443字内字节互换
DCBA0x44430x4241字节和字序都反

看到浮点数读出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 源码把分层和字节处理这两块铺得比较正,照着它改点表和调度,比自己从零拼报文要省心得多,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/6 2:08:27

Hurys毫米波雷达调试软件深度指南:CAN驱动安装与点云解析实战

简介&#xff1a;本资源是面向汽车电子工程师与ADAS系统开发者的毫米波雷达调试工具集&#xff0c;聚焦Hurys品牌雷达&#xff08;如ARS_408&#xff09;的参数配置、实时数据解析与硬件协同调试需求。压缩包共19个文件&#xff0c;含3个可执行程序&#xff08;雷达主调试软件及…

作者头像 李华
网站建设 2026/11/6 2:08:27

基于PLC的花式喷水池控制:定时器与计数器实现顺序控制

简介&#xff1a;这份资源是面向自动化、电气控制相关专业学生及PLC初学者的课程设计参考资料&#xff0c;围绕基于PLC的花式喷水池控制系统展开&#xff0c;帮助读者完成从硬件配置到程序仿真的完整设计流程。压缩包内共1个doc文档&#xff0c;约956KB&#xff0c;以Word文档形…

作者头像 李华
网站建设 2026/11/6 2:08:17

JSP企业电子投票系统:Java Web毕设全流程实现指南

简介&#xff1a;这是一套基于JSP技术实现的企业级电子投票系统完整源码项目&#xff0c;面向Java Web初学者与课程设计实践者&#xff0c;解决小型组织内部投票管理的典型业务需求。资源包含64个文件&#xff0c;以37个JSP页面为核心实现前后端交互逻辑&#xff0c;辅以8张功能…

作者头像 李华
网站建设 2026/11/6 2:07:46

Android Studio简易计算器实战:Java双栈求值与状态保存

简介&#xff1a;面向K12阶段学习者与Android入门者的期末大作业实践包&#xff0c;主题为Android Studio简易计算器&#xff0c;涵盖布局设计、按钮事件监听、计算逻辑封装、运算符优先级处理、结果显示与APK打包发布等基础环节&#xff0c;可用于课程设计、课堂演示或个人练手…

作者头像 李华
网站建设 2026/11/6 2:03:36

乡村金融只拼贷款,是个伪命题

《乡村金融只拼贷款&#xff0c;是个伪命题》 ——网点铺到哪里是上半场的成绩&#xff0c;风险分到哪里才是下半场的分数先出一道题&#xff1a;一个村子&#xff0c;银行网点有了&#xff0c;手机银行有了&#xff0c;连村口小卖部都能扫码了。请问&#xff0c;这个村子的金融…

作者头像 李华