简介:面向C#开发者的Modbus TCP通信解决方案,包含可直接调用的动态库与完整源代码,支持浮点数与双整形数据的读写。这套资料特别适合需要与可编程逻辑控制器、传感器等Modbus TCP设备交换数据的上位机工程师,也可作为高校工业通信课程的参考项目。压缩包约959KB,便于下载与部署;当前未提供文件类型明细,解压后即可获取动态库、源码工程及使用说明。已有二十八人学习下载,虽然数量不多,但内容聚焦实用。通过阅读源代码,可以深入理解Modbus TCP报文的构造、功能码调用、寄存器地址映射以及浮点字节序转换的实现逻辑;直接使用动态库则能快速集成到现有项目中,缩短通信模块开发周期。无论是课程设计、毕业设计还是实际项目,都能从这套资源中获得“拿来可用、拆开可学”的参考价值。
1. 说一个很多人踩过的坑:用现成 Modbus 库读 PLC 里的浮点数,读回来全是乱码
做 C# 上位机对接 PLC 的工程师,多半经历过这一幕:ReadHoldingRegisters返回的 ushort 数组明明和组态软件里看到的值对得上,可一旦按BitConverter.ToSingle拼成 float,数值就变得完全不可信。原因不复杂——Modbus-TCP 协议本身只定义寄存器的地址和值,不关心这两个相邻寄存器到底是一个 float,还是两个独立的 ushort。至于字节顺序,协议文档里更是只字未提。于是各家 PLC(西门子、三菱、施耐德、台达、汇川)在实现浮点映射时各玩各的,ABCD、CDAB、BADC、DCBA 四种排列都真实存在于现场设备中。这个项目标题的价值就在于此:与其每次换一款 PLC 就重写一遍通讯层,不如把「读寄存器」和「浮点/双整形解析」这两件事彻底拆开,做成一款自带源代码、可修改的 DLL,一次沉淀,到处复用。本文就按这个思路,把 Modbus-TCP 通讯 DLL 从接口设计到浮点/双整形编码实现的完整路径捋一遍。
2. Modbus-TCP 的协议边界与 DLL 的接口设计:先立好规矩再写代码
2.1 MBAP 头和功能码表:一条完整请求报文里到底装了什么
Modbus-TCP 与串口 Modbus 最大的差异在于它不需要 CRC 校验,而是靠 TCP 的可靠传输机制保证数据完整性。在 C# 里用Socket甚至TcpClient都能轻松建立连接,但这只是通讯的第一步。真正需要严谨对待的是应用层封装的格式。
一条标准请求的报文结构由 MBAP 头(7 字节)加 PDU(协议数据单元)组成。MBAP 头四个字段的含义如下表:
| 字段 | 字节数 | 说明 |
|---|---|---|
| 事务处理标识符 | 2 | 请求与响应对应的编号,通常每次请求自增 |
| 协议标识符 | 2 | 固定为 0x0000,表示 Modbus 协议 |
| 长度 | 2 | 从单元标识符开始到报文末尾的字节数 |
| 单元标识符 | 1 | 对应串口 Modbus 中的从站地址,一般填 0xFF 或实际站号 |
PDU 部分则由功能码加数据构成。一个标准的 DLL 必须覆盖的常用功能码至少包括以下表中这六项,这也是「全功能」的最低门槛:
| 功能码 | 名称 | 操作对象 | 常用场景 |
|---|---|---|---|
| 0x01 | 读线圈 | 位 | 读取开关量输出 |
| 0x02 | 读离散输入 | 位 | 读取开关量输入 |
| 0x03 | 读保持寄存器 | 字 | 读数值参数、浮点、定时器 |
| 0x04 | 读输入寄存器 | 字 | 读模拟量输入 |
| 0x05 | 写单个线圈 | 位 | 控制单个 DO 点 |
| 0x10 | 写多个寄存器 | 字 | 下发浮点、双整形、批量参数 |
明确了协议边界后,DLL 的职责划分就很清晰了:它需要做的不是把「读线圈」和「读寄存器」各做一套独立逻辑,而是统一封装「构建请求 → 发送 → 接收响应 → 按功能码分发解析」这条流水线,对外暴露语义化的读写接口。至于浮点、双整形这种跨多寄存器的数据类型,则放在更上层的转换层处理。
2.2 对外暴露的接口设计:面向场景而不是面向功能码
在动手写代码之前,先把 DLL 的公共 API 定下来。设计原则只有一条:让调用方在业务代码里不需要关心任何 Modbus 协议细节,也不需要手工拼 buffer。我经手的上位机项目里,一套经过验证的接口应当同时覆盖连接管理、位读写、字读写和类型化读写四个维度。
public enum EndianFormat { DCBA = 0, // 低位在前,高位在后,西门子默认风格 ABCD = 1, // 高位在前,低位在后,Modbus 工具常用 CDAB = 2, // 高字在前,低字在后(寄存器交换) BADC = 3 // 低字交换内的字节序 } public interface IModbusTcpClient : IDisposable { bool Connect(string ip, int port, int timeoutMs); void Disconnect(); bool IsConnected { get; } ushort[] ReadHoldingRegisters(byte unitId, ushort startAddr, ushort count); bool[] ReadCoils(byte unitId, ushort startAddr, ushort count); void WriteSingleCoil(byte unitId, ushort addr, bool value); void WriteMultipleRegisters(byte unitId, ushort startAddr, ushort[] values); float ReadFloat(byte unitId, ushort startAddr, EndianFormat format); double ReadDouble(byte unitId, ushort startAddr, EndianFormat format); long ReadInt64(byte unitId, ushort startAddr, EndianFormat format); void WriteFloat(byte unitId, ushort startAddr, float value, EndianFormat format); void WriteDouble(byte unitId, ushort startAddr, double value, EndianFormat format); }注意到接口里的unitId不是可选项,这是第一个需要解释的细节。在三菱 FX5U 这类支持多站(多从站)组网的 PLC 上,同一个 IP 地址下可能挂载多个 Modbus 从站模块,单元标识符就是用于区别这些逻辑设备的。如果直接写死 0xFF 或 0x01,往往会出现「PC 端抓包正常但 PLC 无响应」的怪问题。把unitId提升为参数,至少不会在适配不同 PLC 时被堵死在连接层。
超时参数timeoutMs的默认值在多数库中设为 1000 毫秒,但在实际工业现场,PLC 的扫描周期较长加通信链路繁忙时,这个值偏激进,一般建议放到 2000 到 3000 毫秒。后续章节会详细说明超时与重试逻辑的实现,这里只需要记住:接口设计的弹性和严谨性,决定了这个 DLL 能否在多个项目中长期复用而不被推翻重写。
3. 核心实现:请求构建、响应解析与事务一致性
3.1 一个稳定的 TCP 长连接是怎么维护的
Modbus-TCP 的常规用法是长连接:上位机与 PLC 建立 TCP 连接后持续复用,而不是每次读写都重新握手,因为 TCP 的建立与断开(尤其是有 TIME_WAIT 状态时)在高频轮询场景下会造成明显的延迟和端口资源浪费。但长连接也带来了一个问题:一次请求发出后,在超时时间内没有收到响应,TCP 连接可能已经处于半开状态(设备断电、网线松动、PLC 程序跑飞)。这时如果继续往这条连接里写数据,写操作本身往往不会报错,但响应永远不会回来。
处理这个问题的常见做法是引入响应超时与自动重连机制。我一般会在 DLL 里同时设置Socket.ReceiveTimeout和发送侧的互斥锁。互斥锁保证在同一连接上,任意时刻只有一个未完成的请求——否则多线程并发写请求会导致响应报文错配,A 线程的请求响应对不上 B 线程的序号。
private readonly object _syncLock = new object(); private TcpClient _client; private NetworkStream _stream; private ushort _transactionId = 0; private byte[] ExecuteTransaction(byte unitId, byte functionCode, byte[] data, int timeoutMs) { lock (_syncLock) { if (_client == null || !_client.Connected) throw new IOException("连接已断开,请先调用 Connect"); _transactionId++; byte[] request = BuildRequest(_transactionId, unitId, functionCode, data); _client.Client.SendTimeout = timeoutMs; _client.Client.ReceiveTimeout = timeoutMs; _stream.Write(request, 0, request.Length); _stream.Flush(); return ReadResponse(timeoutMs); } }代码中的lock是关键,它把整个交互过程(写请求 + 读响应)变成原子操作。很多自研通讯库在并发量稍高时就出现「请求与响应错位」的诡异现象,根本原因就是没有做这个互斥。SendTimeout和ReceiveTimeout同时配置是为了防止在写入阶段就卡死——如果 TCP 缓冲区已满且对端不接收,写操作也可能无限阻塞。
3.2 响应读取与异常码处理:不只是读一次就完事
读响应的逻辑相对简单,MBAP 头 7 字节固定,前面 4 字节(事务 ID 与协议 ID)与请求一致,紧接着的长度字段告诉我们后面还有多少字节需要读取。但这里有一个很容易被轻视的细节:TCP 是流式协议,Read方法一次返回的数据量并不保证等于你请求的数据量。即使你只请求 12 个字节,底层可能只收到了 5 个字节就返回了。
private byte[] ReadResponse(int timeoutMs) { byte[] header = new byte[7]; int offset = 0; while (offset < 7) { int n = _stream.Read(header, offset, 7 - offset); if (n <= 0) throw new IOException("连接被对端关闭"); offset += n; } int length = (header[4] << 8) | header[5]; byte[] body = new byte[length - 1]; // 去掉已读的单元标识符 offset = 0; while (offset < body.Length) { int n = _stream.Read(body, offset, body.Length - offset); if (n <= 0) throw new IOException("连接被对端关闭"); offset += n; } byte funcCode = body[0]; if ((funcCode & 0x80) != 0) { int errCode = body[1]; throw new ModbusException($"功能码 0x{funcCode & 0x7F:X2} 返回异常: {GetErrorMessage(errCode)}"); } return body; }这段代码里的两个循环分别用于完整读取 MBAP 头和 PDU 部分,循环的目的就是为了对抗 TCP 粘包与半包。异常码的判断在函数码的高位:如果响应中的功能码最高位为 1,表示设备返回异常,异常码一律位于 PDU 的第二个字节。表格列出最常见的异常码:
| 异常码 | 含义 | 排查方向 |
|---|---|---|
| 0x01 | 非法功能码 | 设备不支持该功能码,对照设备手册确认 |
| 0x02 | 非法数据地址 | 起始地址 + 寄存器数量越界,检查地址范围 |
| 0x03 | 非法数据值 | 请求中的数据长度或值不合法 |
| 0x04 | 从站设备故障 | 设备内部错误,检查 PLC 程序运行状态 |
| 0x06 | 从站忙 | 重试片刻即可,不要立即加重负载 |
异常响应时把_transactionId自增的逻辑保持不变,因为它本来就是一对一的计数,不需要在异常时回退。但要注意:异常响应的事务 ID 必须与请求一致,否则应该丢弃该报文继续等待,这在实现时需要额外判断一次。
4. 浮点和双整形数据的编码/解码:详解四种字节序与寄存器组合
4.1 单精度浮点:两个寄存器如何拼出一个小数
Modbus 协议中最常用的浮点数据是 IEEE 754 单精度格式(也就是 C# 的float),占 4 字节,恰好等于两个保持寄存器。上位机拿到 ushort 数组后,常用的解析方式是BitConverter.ToSingle(ushortBytes, 0),但这里存在两个变数:第一个是 4 字节内的高低字节排列(Byte Order),第二个是两个寄存器之间的先后顺序(Word Order)。它们组合起来就是前文表格中定义的四种EndianFormat。
每种模式的完整对应关系如下:
| 格式枚举 | 寄存器顺序 | 字节排列 | 实际效果 | 常见设备 |
|---|---|---|---|---|
| ABCD | 低地址寄存器 → 高地址寄存器 | 高字节在前 | 4 字节按原始顺序直接拼接 | PowerFlex 变频器、多数国产仪表 |
| DCBA | 低地址寄存器 → 高地址寄存器 | 低字节在前 | 每个寄存器内字节翻转 | 西门子 S7-1200/1500、三菱 FX5U |
| CDAB | 高地址寄存器 → 低地址寄存器 | 高字节在前 | 寄存器顺序颠倒 | 施耐德部分 PLC |
| BADC | 高地址寄存器 → 低地址寄存器 | 低字节在前 | 字节序和字序都颠倒 | A-B 罗克韦尔 MicroLogix |
在原生的 Modbus 协议规范中并没有定义浮点数据的排布——这是行业实践层面的约定,因此 DLL 若要做成全功能的,这四个模式必须是运行时可配置的,而不是编译期宏开关。下面是支持四种模式的ReadFloat实现:
public float ReadFloat(byte unitId, ushort startAddr, EndianFormat format) { ushort[] regs = ReadHoldingRegisters(unitId, startAddr, 2); byte[] bytes = new byte[4]; switch (format) { case EndianFormat.ABCD: // 16 32 48 64 case EndianFormat.DCBA: // 64 48 32 16 bytes = BitConverter.GetBytes(regs[0]); bytes = bytes.Concat(BitConverter.GetBytes(regs[1])).ToArray(); if (format == EndianFormat.DCBA) Array.Reverse(bytes); break; case EndianFormat.CDAB: // 32 16 64 48 case EndianFormat.BADC: // 48 64 16 32 bytes = BitConverter.GetBytes(regs[1]); bytes = bytes.Concat(BitConverter.GetBytes(regs[0])).ToArray(); if (format == EndianFormat.BADC) Array.Reverse(bytes); break; } return BitConverter.ToSingle(bytes, 0); }注意 C# 在当前主流平台上(x86/x64/ARM64)的BitConverter都是小端模式,GetBytes(ushort)产生的结果是低字节在前、高字节在后,因此 DCBA 模式需要通过Array.Reverse统一处理。写入方向则是上面的完全可逆过程:先BitConverter.GetBytes(float)转为 4 字节,再按格式拆分进两个寄存器后调用WriteMultipleRegisters。建议在 DLL 内部内置一个自检方法,把已知浮点数按四种格式写出再读回,以验证设备实际行为。
4.2 双精度浮点和 64 位整形:把 8 字节拆进四个寄存器
标题中特别提到「双整形」这个词,在工控语境里通常指 64 位有符号整数(long)或双精度浮点(double)。无论哪种,它们在 Modbus-TCP 中的映射方式一致:占用四个连续寄存器,共 8 字节。拆分方法与单精度浮点同理,区别在于需要先确定四个寄存器的排列方向,再处理每个寄存器内的字节顺序。
我在这类 DLL 中给出的通用方案是:定义一个 8 字节数组byteArr,按字节序模式将其填入寄存器数组,再用BitConverter.ToDouble或BitConverter.ToInt64生成最终值:
public double ReadDouble(byte unitId, ushort startAddr, EndianFormat format) { ushort[] regs = ReadHoldingRegisters(unitId, startAddr, 4); byte[] bytes = new byte[8]; for (int i = 0; i < 4; i++) { int target = (format == EndianFormat.ABCD || format == EndianFormat.DCBA) ? i : 3 - i; byte[] temp = BitConverter.GetBytes(regs[target]); if (format == EndianFormat.DCBA || format == EndianFormat.BADC) Array.Reverse(temp); Buffer.BlockCopy(temp, 0, bytes, i * 2, 2); } return BitConverter.ToDouble(bytes, 0); }这段逻辑的关键在target索引的选取:ABCD与DCBA模式中,寄存器 0 对应 8 字节数组的低两个字节;CDAB与BADC模式中,寄存器 0 对应高两个字节。BlockCopy直接按字节搬家,避免额外的手工位移运算。处理long类型时,把这四行的BitConverter.ToDouble换成ToInt64就行,其余逻辑完全一致。
在实际应用中,有必要在 DLL 中同时提供ReadInt64和ReadDouble两个独立接口,因为它们在 PLC 侧的地址可能是重叠的——同一个起始地址,既可以解释为双精度小数,也可以解释为 64 位整数计数器的值。数据解释权交给业务层而不是通讯层,这是保持 DLL 通用性的底线。
5. 用模拟器与抓包工具验证 DLL 的字节序处理
5.1 基于 Modbus Slave 模拟器的验证流程
如果你手头暂时没有 PLC,最常见的验证路线是使用 Modbus 模拟器软件(如 Modbus Slave、ModRSsim2、Doliwang 等)在电脑上虚拟出一台从站设备。这类软件通常允许手动指定从站地址、寄存器值,以及浮点显示格式,是验证 DLL 字节序解析逻辑的理想工具。
验证步骤如下:在模拟器中设置保持寄存器地址 0 和 1,手动填入 0x40490FDB 和 0x00000000 两个 32 位值。前者换算为两个 16 位寄存器各是多少,可以直接用 Windows 自带的计算器在程序员模式下换算,也可以直接在 DLL 里写一段自测代码,调用WriteFloat和ReadFloat回环验证。
我通常会在单元测试里做一次四种格式的全覆盖循环:写入一个已知浮点数(如 3.14159265),分别按 ABCD、DCBA、CDAB、BADC 读回,其中只有与模拟器配置一致的模式才返回值一致。经过这一步,基本可以确认 DLL 内部字节序处理没有方向性错误。
如果模拟器不显示浮点而只显示十六进制原始值,另一个可用手段是抓包。在 DLL 发出请求到设备返回响应的过程中,用 Wireshark 过滤器tcp.port == 502抓取整个交互报文,响应数据区中的字节顺序就是设备实际返回的原始字节。此时与 DLL 解析结果对比,能迅速定位是通讯层出问题还是字节序模式选错。
5.2 现场 PLC 接入的常见坑:从站号、地址偏移与连接复用
模拟器验证通过后,真正接入 PLC 才是 DLL 编写与选型中最容易出现歧义的一环。上面提到的unitId就是最容易踩的坑:很多 PLC 虽然在参数中设为 1,但 Modbus-TCP 服务器层实际接受 0xFF 作为广播目标。如果发现请求始终无响应,试着用模拟器软件扫描这个 IP 的所有站号,通常能扫到设备实际监听的 unitId。
另一个频繁出现的坑是地址偏移。大部分 PLC 的地址映射遵循「地址 = 协议地址 + 1」的规则:三菱 FX5U 在编程软件里看到的保持寄存器地址是 D100,而在 Modbus 报文里的起始地址可能是 99(即 400100 线中地址为 100,对应协议地址 99)。许多初版 DLL 没有做这个偏移换算,导致从 PLC 读回的数值与组态软件看到的数据对不上。我的一般做法是在 DLL 中提供一个AddressOffset属性,调用方根据自己的 PLC 说明书随手配置即可,不用修改任何业务代码。
连接复用方面,长连接的探活策略建议通过定时读写一个已知地址来实现,而不是用 TCP 层的 keepalive。因为工业以太网交换机在链路空闲时可能直接老化掉半开连接,TCP keepalive 的默认间隔又太长(Linux 默认 7200 秒),不足以覆盖现场频繁上下电的场景。在 DLL 的IsConnected属性中,应该维护「上次通信时间戳」而不是仅依赖TcpClient.Connected的判断,后者在连接断开但未触发任何异常时仍然返回 true,是典型的假连接状态。
本文还有配套的精品资源,点击获取