简介:一套基于C#开发的全功能Modbus-TCP通讯组件及源代码,面向工业自动化上位机、设备数据采集与远程监控场景,适合需要快速集成Modbus TCP通讯功能的C#工程师、自动化项目实施人员以及相关专业学生参考学习。资源包以zip压缩格式提供,整体约959KB,包含可直接引用的DLL动态库和配套C#源码工程,便于按需调用或深入学习内部实现。目前已有28人学习下载。借助这套资源,读者既能直接获得支持浮点与双整形数据读写的Modbus TCP通讯模块,快速集成到WinForm、WPF或后台服务中;也能从源码层面理解TCP连接管理、Modbus报文组包与解析、功能码处理、字节序转换、超时重试等核心环节,为自定义扩展或通讯故障排查提供实践参考,整体具备较强的工程落地与学习复用价值。
1. 一个用 C# 打磨过的 Modbus-TCP 通讯包,值得拆开看的细节
做上位机和 PLC 通讯的人,几乎都绕不开 Modbus-TCP。这个协议本身不复杂,但真正写到能稳定跑产线,坑都在数据表达上:寄存器只有 16 位,而现场要传的浮点数、双精度整数怎么塞进去,不同厂家的字节序还不一样。这套基于 C# 开发的 Modbus-TCP 通讯 DLL 和完整源代码,解决的就是从报文组帧、连接管理到浮点和双整形数据转换的全套问题,适合正在做上位机集成、设备数据采集或者准备把通讯层封装成公共组件的工程师。我拆完这份源码后,觉得它比网上大多数只给两个函数的 Demo 有价值的地方在于:它把浮点的 IEEE 754 表示、双整形的 32 位拆装、以及异常帧的处理都做了完整落地,拿来即用,也能当学习模板改造成自己的通讯框架。下面按协议、架构、数据转换、实战验证的顺序把关键点过一遍。
2. Modbus-TCP 协议核心与 C# 实现选型
2.1 协议帧结构与报文构造原理
Modbus-TCP 在 TCP/IP 之上承载 Modbus 应用协议,端口固定为 502。报文格式始终是:事务处理标识符(2 字节)、协议标识符(2 字节)、长度字段(2 字节)、单元标识符(1 字节)、功能码(1 字节)、数据区。很多初学者会忽略一个关键点:长度字段指的是从单元标识符开始到报文结尾的字节数,而不是整个 TCP 报文长度。这份 DLL 源码里对报文长度的计算是在写完所有数据之后回填的,这个顺序很重要,先算长度再组帧会让你在增加数据区时反复修改长度值,容易出错。
// C# 构造一个读保持寄存器的请求帧 public byte[] BuildReadRequest(byte unitId, ushort startAddress, ushort quantity) { byte[] frame = new byte[12]; // 事务标识符,用于匹配响应,建议每次自增 frame[0] = (byte)(transactionId >> 8); frame[1] = (byte)transactionId; // 协议标识符,Modbus 固定为 0 frame[2] = 0x00; frame[3] = 0x00; // 长度:单元标识符1 + 功能码1 + 起始地址2 + 寄存器数量2 = 6 frame[4] = 0x00; frame[5] = 0x06; frame[6] = unitId; frame[7] = 0x03; // 功能码 3:读保持寄存器 frame[8] = (byte)(startAddress >> 8); frame[9] = (byte)startAddress; frame[10] = (byte)(quantity >> 8); frame[11] = (byte)quantity; return frame; }这段代码里frame[5]的常数 6 直接写在字节数组上,看起来不直观,但在源码里它对应的是「单元标识符 + 功能码 + 数据区」的固定边界。startAddress和quantity都是 16 位无符号整数,高位在前,这是 Modbus 的默认大端序。如果你接的是西门子 S7-1200 这类设备,它的以太网模块可能要求字节序不同,源码里保留了修改入口,建议在实际对接时先用调试工具抓包确认设备端的字节序约定。
2.2 为什么用异步 Socket 而不是串口轮询
这套源码的网络层选用了Socket加异步回调模式,而不是简单的TcpClient同步读写。原因在于 Modbus-TCP 的请求响应机制:一个事务对应一个事务标识符,若用同步阻塞方式,某个从站响应超时会卡住整个采集线程。异步方式下,每个请求发出后由回调处理超时和重试,主线程可以继续调度其他事务。源码中维护了一个Dictionary<ushort, PendingRequest>,以事务标识符为键存储挂起请求的回调、超时时间和重试次数。
// 异步发送并等待响应的核心逻辑 private async Task<byte[]> SendAsync(byte[] request, int timeoutMs) { ushort id = (ushort)(request[0] << 8 | request[1]); var tcs = new TaskCompletionSource<byte[]>(); pendingRequests[id] = new PendingRequest { Tcs = tcs, ExpireTime = Environment.TickCount64 + timeoutMs }; await socket.SendAsync(new ArraySegment<byte>(request), SocketFlags.None); using (var cts = new CancellationTokenSource(timeoutMs)) { var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(timeoutMs, cts.Token)); if (completedTask != tcs.Task) { pendingRequests.Remove(id); throw new TimeoutException($"事务 {id} 响应超时"); } return await tcs.Task; } }这里用TaskCompletionSource把异步 Socket 的接收回调转换为可等待的任务,是 C# 5.0 之后处理此类问题很地道的写法。Task.WhenAny和Task.Delay组合实现超时控制,注意超时后要从pendingRequests移除该事务,否则残留条目会越积越多。实际使用中我一般还会在接收回调里加入校验:响应的事务标识符必须与请求一致,并且功能码的最高位为 1 时表示异常响应,需要单独解析异常码而不是当作正常数据处理。
2.3 与 NModbus4 等现成库的对比取舍
网上有 NModbus4、EasyModbus 等开源库,为什么这份源码仍有参考价值?关键在可裁剪性和透明性。现成库为了兼容串口和 TCP 做了大量抽象,封装层级深,一旦现场设备返回非标准错误或者需要自定义功能码(比如三菱 FX5U 的专用报文),你得顺着继承链往下改,成本很高。这份 DLL 直接暴露了ModbusTcpClient类,核心方法只有 Connect、ReadHoldingRegisters、WriteSingleRegister 等几个,代码量小,逻辑集中,适合作为二次开发的基底。我通常的做法是:先用这套源码跑通业务逻辑,遇到特殊需求再在源码上新增方法,而不是去覆盖 NuGet 包里的虚方法。
3. 源码整体架构与各模块职责拆分
3.1 项目结构一览与关键类关系
解压源码后,解决方案里主要包含几个核心文件:ModbusTcpClient.cs(客户端操作入口)、ModbusMessage.cs(请求响应报文解析)、DataConverter.cs(字节与数值转换)、ModbusException.cs(异常定义)。下面是精简后的类关系表:
| 类名 | 职责 | 关键方法 |
|---|---|---|
ModbusTcpClient | 连接管理、读写接口、事务调度 | ConnectAsync,ReadFloatAsync,WriteDouble |
ModbusMessage | 报文构建与响应校验 | ParseResponse,BuildWriteRequest |
DataConverter | 字节序转换、浮点/双整形解析 | GetFloatFromRegisters,GetUInt32FromRegisters |
ModbusException | 通讯异常、异常码映射 | FromExceptionCode |
DataConverter是个静态类,我特别看了一下它的转换方法,没有用BitConverter而是手工移位拼装,原因是BitConverter受系统小端序影响,直接用于 Modbus 的大端字节流时会产生多余的Reverse调用,而且语义不清晰。手工移位则完全显式控制字节序,代码可读性和可移植性都好很多。如果后续要移植到 .NET 6 甚至 MAUI 上,这套转换逻辑可以原封不动搬走。
3.2 请求-响应的事务管理与超时重试机制
这里有一个容易被误解的地方:Modbus-TCP 可以一条 TCP 连接上并发多个请求吗?协议层面允许,因为事务标识符可以区分不同请求。但实际现场设备(尤其是老款 PLC)往往按顺序处理请求,并发会引发响应错乱。源码里的权宜之计是内部用SemaphoreSlim控制并发为 1,也就是同一连接同一时刻只允许一个在途请求。这个设计对 PLC 场景是稳妥的,代价是吞吐量受限,如果对接的是高性能 IO 模块,可以在ModbusTcpClient里暴露一个MaxConcurrentRequests属性,把信号量计数调大,但前提是设备支持并发事务。
// 信号量控制并发,保证同一连接的请求串行 private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); public async Task<byte[]> ExecuteAsync(byte[] request, int timeoutMs) { await _semaphore.WaitAsync(); try { return await SendAsync(request, timeoutMs); } finally { _semaphore.Release(); } }这个finally里的Release必须放在异步等待之后,如果漏写,一次异常就会让信号量永久归零,后续所有请求都会卡在WaitAsync。调试时遇到「请求发不出去但连接正常」的现象,第一反应就是检查信号量计数。源码里还把重试次数写死成了 3 次,实际项目中建议通过构造函数传入,因为不同设备的通讯稳定性差异很大:我做过的某个项目里,某国产温控器偶发丢帧,重试 5 次才算稳定,而西门子 S7 系列基本一次成功。
3.3 功能码覆盖范围与扩展点
源码完整支持 01、02、03、04、05、06、15、16 共 8 个常用功能码,覆盖了线圈、离散输入、保持寄存器、输入寄存器以及单/多写操作。每个功能码都封装成了独立方法,返回类型统一为Task<T>,其中读寄存器返回ushort[],写操作返回布尔结果。在做扩展时,你只需要在ModbusMessage.BuildXxxRequest增加一个构造方法,然后在ModbusTcpClient暴露对应接口即可。要注意功能码和报文数据区的对应关系:写多个寄存器时,数据区要依次拼接起始地址、寄存器数量、字节计数和各寄存器值,其中字节计数是数量乘以 2。下面是一个写多个寄存器的报文拼装示例。
public static byte[] BuildWriteMultipleRequest(byte unitId, ushort startAddress, ushort[] values) { int byteCount = values.Length * 2; byte[] frame = new byte[13 + byteCount]; // 12字节MBAP+单元+功能码+起始地址+数量+字节计数+数据 int offset = 0; // 此处省略 MBAP 头和功能码填充,参考读请求 frame[6] = unitId; frame[7] = 0x10; // 功能码 16 frame[8] = (byte)(startAddress >> 8); frame[9] = (byte)startAddress; frame[10] = (byte)(values.Length >> 8); frame[11] = (byte)values.Length; frame[12] = (byte)byteCount; offset = 13; foreach (ushort val in values) { frame[offset++] = (byte)(val >> 8); frame[offset++] = (byte)val; } // 回填长度字段 frame[5] = (byte)(9 + byteCount); return frame; }注意frame[5]回填的数值:单元标识符 1 字节 + 功能码 1 字节 + 起始地址 2 字节 + 数量 2 字节 + 字节计数 1 字节 + 数据区字节数。这里的 9 就是除数据区外的固定部分长度。如果你漏算了字节计数或者长度字段,设备端会直接返回异常码 03(非法数据值)。通讯排查时抓包看长度字段是第一步。
4. 浮点与双整形数据的寄存器拆装实战
4.1 IEEE 754 单精度浮点如何塞进两个寄存器
Modbus 保持寄存器是 16 位无符号整数,一个浮点占 4 字节,正好两个寄存器。源码里DataConverter.GetFloatFromRegisters的转换逻辑是:取两个连续寄存器,按照寄存器顺序组成 32 位大端序整数,再按 IEEE 754 单精度解释。很多初学者倒在「哪两个寄存器在前」的问题上:PLC 侧常见的有两种排列,一种是低地址存高 16 位,另一种反过来。源码默认采用低地址存高 16 位的 AB 模式,这也是大多数支持浮点的 Modbus 设备采用的模式,但如果你接的是某些国产仪表,可能需要改成 BA 模式。请看你设备的寄存器映射表再决定是否调用GetFloatFromRegistersBA。
public static float GetFloatFromRegisters(ushort[] registers, int startIndex) { uint raw = (uint)(registers[startIndex] << 16 | registers[startIndex + 1]); return BitConverter.ToSingle(BitConverter.GetBytes(raw), 0); }这段代码先拼成uint,再用BitConverter.GetBytes转为 4 字节后解释为浮点。注意这里用了BitConverter,它的字节序在小端机器上产出的是小端字节,但raw本身已经是按大端拼好的整数,BitConverter只是用来做位模式映射,不受字节序影响。如果要彻底避开BitConverter,也可以用Unsafe.As<uint, float>或者指针操作,但可读性会下降,性能提升并不显著。生产代码中我倾向于保留BitConverter,因为它的意图最直白。
4.2 双整形(32 位整数)的无符号与有符号处理
双整形指的就是 32 位整数,在 Modbus 里同样由两个寄存器承载。源码提供了GetInt32FromRegisters和GetUInt32FromRegisters两个方法,区别在于用int还是uint解释聚合后的 32 位值。这里有一个隐蔽的坑:C# 里ushort到int的隐式转换是无符号扩展,但如果你先强转为short再左移,高位符号位会导致错误。源码直接使用uint拼接然后转int,是安全的做法。
public static int GetInt32FromRegisters(ushort[] registers, int startIndex) { uint raw = (uint)(registers[startIndex] << 16 | registers[startIndex + 1]); return unchecked((int)raw); } public static void SetUInt32ToRegisters(ushort[] registers, int startIndex, uint value) { registers[startIndex] = (ushort)(value >> 16); registers[startIndex + 1] = (ushort)(value & 0xFFFF); }unchecked关键字在这里不是多余的。默认 C# 项目如果开启了CheckForOverflowUnderflow,uint转int的高位为 1 时会抛出异常,加上unchecked表示显式允许截断。写入时用掩码0xFFFF截取低 16 位,同时保证寄存器数组类型为ushort,不会出现负数意外赋值。写双整形到两个寄存器的顺序与读是对称的,这一点在实现 PLC 侧的联动时很重要,比如用三菱 FX5U 时,D0-D1 存一个双整形,低地址是高位字节,如果不一致会导致数值完全错乱。
4.3 字节序变体与设备兼容性处理
不同设备对浮点的字节序有四种可能:ABCD(大端)、DCBA(小端)、BADC(字交换)、CDAB(字节交换)。源码中主要通过GetFloatFromRegisters之后的Reverse组合来实现兼容。我的经验是不要试图猜测字节序,直接用工具读出已知浮点值(例如 1.0,其 IEEE 754 为0x3F800000),然后对比寄存器原始值,从四种变体中找到匹配的那一个。下面给出一个快速判断字节序的对照表:
| 寄存器原始值 | 对应字节序 | 解析结果 |
|---|---|---|
| 3F80 0000 | ABCD | 1.0 |
| 0000 3F80 | BADC | 1.0 |
| 803F 0000 | CDAB | 1.0 |
| 0000 803F | DCBA | 1.0 |
你只需要写一个 20 行的小工具,读两个寄存器后按四种方式用BitConverter.ToSingle转换,看看哪个结果等于预期值即可。源码的DataConverter里预置了GetFloatFromRegistersABCD、GetFloatFromRegistersDCBA等方法,但实际项目里我一般只保留一种,避免团队使用时选错。
5. 异常处理、性能调优与验证技巧
5.1 异常码到中文提示的映射表
Modbus 异常响应的功能码最高位置 1,数据区返回一个异常码。源码里的ModbusException.FromExceptionCode做了一一映射,我整理了处理时最常遇到的几个:
| 异常码 | 含义 | 常见原因 |
|---|---|---|
| 01 | 非法功能码 | 设备不支持该功能码 |
| 02 | 非法数据地址 | 寄存器地址越界 |
| 03 | 非法数据值 | 数量或字节计数不符合要求 |
| 04 | 从站设备故障 | 设备内部异常或硬件错误 |
| 06 | 从站设备繁忙 | 上一事务未完成,需重试 |
接到异常码后不要直接抛异常,而应该附加操作上下文(功能码、起始地址、寄存器数量)一起抛出,否则排查现场问题时要对着报文抓耳挠腮。源码里在ParseResponse中检测到功能码高位为 1 时,直接构造ModbusException,这个设计比返回bool更能暴露问题。
5.2 连接池与 Socket 超时参数的工程化调整
生产环境里最容易被问到的参数是两个:连接超时和读写超时。源码默认连接超时 5 秒,读写超时 2 秒,这在局域网场景偏保守。如果你的设备响应本身就有 500ms 延迟,2 秒可能误判超时。调参依据是设备的响应规格:普通 PLC 的响应延迟在 10-100ms,而某些 IO 模块或仪表可能到 500ms。将超时设置到 3-5 秒比较稳妥。
// 设置 Socket 读写超时 socket.ReceiveTimeout = 3000; socket.SendTimeout = 3000;上述设置的注意点:Socket.ReceiveTimeout是同步模式下Receive调用的超时,对于异步SendAsync/ReceiveAsync不生效,所以源码里用Task.Delay做超时控制。如果你把它和异步方法混用,会误以为超时设置无效。另外,断线重连时建议销毁原 Socket 重新创建,而不是复用同一个 Socket 实例,因为 TCP 状态机在异常断开后可能残留半打开状态,直接重连容易失败。
5.3 用模拟从站验证 DLL 功能是否正确的具体方法
没有真实 PLC 时,可以自己写一个 C# 控制台程序模拟 Modbus-TCP 从站,监听 502 端口。这样做的好处是能构造异常帧、延迟响应、错误字节序等测试用例。我一般会在模拟从站里维护一个ushort[]数组作为寄存器区,收到请求后按功能码处理并回包。下面给出验证浮点写入的测试核心逻辑:
// 模拟从站:接收写多个寄存器请求并回显 if (functionCode == 0x10) { ushort startAddr = (ushort)(frame[8] << 8 | frame[9]); ushort quantity = (ushort)(frame[10] << 8 | frame[11]); int byteCount = frame[12]; for (int i = 0; i < quantity; i++) { registers[startAddr + i] = (ushort)(frame[13 + i * 2] << 8 | frame[14 + i * 2]); } // 回写相同事务 ID 和功能码 }测试时,主站调用WriteFloat写入 3.14,从站打印寄存器值应为0x4048 0xF5C3。再用ReadFloat读回来校验是否等于 3.14。这套方法在调试字节序问题时尤其高效,你可以在模拟从站里故意调换高低字节顺序,看主站是否能识别。另一个验证点是事务 ID 的匹配:模拟从站返回错误的 ID,主站应该检测到并丢弃,直到超时重试。
5.4 压测与稳定性收尾
最后留一个实战中容易忽略的技巧:长时间运行后,pendingRequests字典里可能残留一些没有移除的事务条目,导致内存缓慢增长。建议在接收解析时先尝试移除条目,若Remove返回 false 说明该响应已经超时被清理,直接丢弃即可。另外,把ModbusTcpClient的Dispose方法实现完整,释放 Socket 和信号量,在使用using块时不会有隐患。如果你要把这套 DLL 引入 .NET 6 以上的环境,记得把BinaryFormatter相关的代码清理掉,那东西在新版本中默认不可用。
真正把这份源码吃透的标志,不是能调用ReadFloatAsync读出一个数,而是能自己从头写一遍报文组帧、解析和转换逻辑。对照源码实现过一遍以后,上位机跟任何设备的 Modbus-TCP 通讯基本都能驾驭。
本文还有配套的精品资源,点击获取