简介:一份基于 C# 的 ModBus TCP/RTU 通信协议源码实现,适合工业自动化开发人员、嵌入式工程师及需要在 .NET 项目中集成 ModBus 通信的读者。其中包含完整的类库与测试工程,封装了连接建立、数据编码解码、功能码调用、事务管理、错误处理及日志记录等关键流程,可直接参考或移植到实际设备交互场景中。压缩包共 329 个文件,核心为 66 个 C# 源码文件,同时包含可执行程序、动态库、配置与资源文件以及少量调试符号和 SVN 元数据,整体约 1.74MB,结构以 Visual Studio 解决方案形式组织,便于加载和二次开发。已有 4941 人学习下载,内容还包含测试程序示例,便于对照请求与响应报文进行调试。借助该源码可快速理解 ModBus 协议从请求构造到响应解析的完整链路,减少从零实现协议栈的调试成本,尤其适合作为学习样板或改造基础。
1. 别急着写驱动:这份老外 C# 版 modbus tcp/rtu 源码,能省掉大半联调时间
某设备联调现场,温度传感器读数每隔几分钟跳一次,排查到深夜才发现是驱动程里寄存器地址偏移了一位。类似问题我在用 modbus tcp/rtu 写 C# 采集程序时踩过不止一次。老外这份源码把主站、从站、TCP 与 RTU 两条链路都拆成独立模块,核心价值在于同一套 API 能切换传输方式,报文日志和 CRC 校验也提前写好了。对刚开始接触工业总线的新手,这是可直接模仿的范本;对熟手,至少能省掉大半接口封装和异常定位时间。下面按我从源码里拆出的主次顺序讲。
2. 源码结构与调用路径:主从分层到 TCP 客户端三步跑通
拿到源码别急着编译,先盘目录结构。国外作者通常把传输层、业务层和示例项目分开,这份源码保留了非常典型的组织方式。先花十分钟把文件分布看明白,后面用起来会顺手很多。
2.1 先看目录结构与主从分层
源码包里的文件大致可以按下面这张表归类,下载后对照找一遍,基本就知道每个文件是干什么的。
| 目录/分区 | 主要内容 | 使用场景 |
|---|---|---|
| 示例工程 | 完整可运行的 Demo,演示 TCP 和 RTU 的读写 | 先跑通这里,确认环境没问题 |
| 主站模块 | TCP 客户端、RTU 串口客户端、统一读写接口 | 项目里主要用这部分 |
| 从站模块 | 模拟从站,可设置寄存器初始值 | 联调前用软件模拟设备 |
| 协议工具 | 功能码封装、CRC16 计算、报文解析 | 排查数据异常时看这里 |
阅读顺序我建议是:先跑示例工程,再翻主站模块的读写接口,最后看协议工具。主从分层是理解这份源码的钥匙。Modbus 通信模型里主站主动发请求,从站被动应答,源码里这两个角色是独立的命名空间,类名上基本能看出用途。你把它当普通类库用就行,主站侧类名通常以 Master 或 Client 结尾,从站侧以 Slave 或 Server 结尾,别混了。
理解了分层,后面所有调用都围绕主站侧展开。
2.2 TCP 客户端三步:连接、读寄存器、断开
TCP 模式在源码里封装得最干净,使用起来就是最朴素的三个动作。常见写法如下:
using (var client = new ModbusTcpClient("192.168.1.10", 502)) { client.Connect(); // 站号 1,从保持寄存器地址 0 开始,连续读 10 个寄存器 short[] values = client.ReadHoldingRegisters(1, 0, 10); foreach (short value in values) { Console.WriteLine("寄存器值: " + value); } client.Disconnect(); }参数拆开说:IP 是设备地址,502 是 Modbus TCP 的标准端口,几乎所有的网关和控制器都默认监听这个端口。站号参数 1 对应设备里的从站地址,多设备挂同一网关时,靠这个站号区分设备。地址 0 是协议偏移地址,不是 PLC 手册里写的 40001,直接用会读错区。返回值是 short 数组,因为每个寄存器是 16 位,一个元素对应一个寄存器。
这套代码在源码示例里基本原样存在。实际使用中,ReadHoldingRegisters 这种语义化方法名是老外写这套代码时的习惯,不同版本命名可能略有出入,但意思一致。连接失败或响应超时,源码里会抛异常,异常信息里带站号和功能码,方便定位。
2.3 用从站模拟器验证读写链路
手头没有真实设备时,先把源码里的从站示例跑起来。这样做的价值在于:主站的协议逻辑能在纯软件环境下闭环验证,排除硬件因素干扰。模拟从站的典型用法如下:
// 模拟从站:监听 502 端口,站号 1,预设两个寄存器 int port = 502; var slave = new ModbusTcpSlave(port, 1); slave.SetHoldingRegister(0, 1234); slave.SetHoldingRegister(1, 5678); slave.Start();参数含义:端口和前面主站保持一致,站号也对应。SetHoldingRegister 两个参数分别是寄存器地址和初始值。把主站程序里的 IP 换成 127.0.0.1,如果读写逻辑正确,读出来就是 1234 和 5678。
这一步非常值得做,我在项目里凡是拿到新的采集程序,都会先和模拟从站跑一遍再上现场,能省掉大量现场排错时间。
3. TCP 与 RTU 选型参数:串口配置、超时和字节序一次算清楚
源码里 TCP 和 RTU 是两套独立的传输实现,但对外暴露的读写接口几乎一样。选哪种模式取决于设备硬件情况,参数配置才是真正容易出问题的地方。
3.1 TCP 与 RTU 怎么选:一张表说清边界
把两种模式的核心差异列出来,选型时一眼就能定。
| 对比维度 | TCP 模式 | RTU 模式 |
|---|---|---|
| 物理层 | 网线、交换机,布线简单 | RS485 双绞线,需串口或转换器 |
| 典型速率 | 百兆网络,毫秒级响应 | 9600 或 19200 波特率 |
| 最大站点数 | 理论无上限,通常受网关限制 | 一般 32 或 128 个节点 |
| 调试便利性 | PC 直接抓包,网络工具现成 | 需要接 USB 转 485,时序敏感 |
| 适用设备 | 带网口的 PLC、网关、新仪表 | 老式仪表、分布式 IO、存量设备 |
结论很直接:新项目能选 TCP 就选 TCP。RTU 的优势只在老旧设备接入和现场布线简单这两个场景,别因为看着省事就硬选 RTU。现场最怕的是设备明明支持 TCP,却为了省一个交换机走 RTU,最后时序问题调试到崩溃。
3.2 RTU 串口参数配置:一个 SerialPort 的完整范例
用 RTU 模式时,串口参数必须和设备手册严格一致。常见配置如下:
SerialPort serialPort = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One); serialPort.Open(); var client = new ModbusRtuClient(serialPort); short[] values = client.ReadHoldingRegisters(1, 0, 10);波特率、数据位、校验位、停止位这四个参数,任何一个不匹配都会出现响应超时或乱码。Modbus RTU 标准帧要求字符间保持 3.5 个字符时间的静默间隔,源码的 RTU 实现内部会处理帧间隙,但底层串口驱动不稳定时就会翻车。USB 转 485 的线材质量差,或者 COM 口号识别错乱,是最常见的两个坑,遇到乱码先查这两个地方。
RTU 模式下还有个容易被忽略的点:RS485 是半双工总线,同一时刻只能有一个设备发送数据。如果现场还有其他上位机软件同时轮询同一总线,会直接导致报文冲突,表现为偶发超时。这种现象看起来像玄学,其实是总线仲裁没做对。
3.3 字节序与地址换算:协议大端和 C# 小端的冲突
Modbus 协议规定寄存器内数据按大端存储,也就是高字节在前。而 C# 的 BitConverter 默认按小端解析,直接用会得到颠倒的结果。看这段代码就明白了:
ushort raw = 0x1234; byte high = (byte)(raw >> 8); // high = 0x12 byte low = (byte)(raw); // low = 0x34协议报文里发的是 high 然后 low,也就是 0x12 0x34。如果直接用 BitConverter.GetBytes(raw) 拿到的数组是 0x34 0x12,直接拼进报文就反了。这一点是新手最容易翻车的地方,我见过不少同事把 CRC 算对了,却栽在字节序上。
寄存器地址换算也值得注意。很多设备手册写的是 40001 这种 PLC 风格的地址,实际上 Modbus 报文里的偏移地址是 0。换算逻辑是这样的:
// 手册地址 40001 对应偏移 0 ushort modbusOffset = (ushort)(plcAddress - 40001);有些国产设备还采用 1 基址,偏移 0 对应手册地址 1,那就不需要减。拿到设备第一件事就是确认地址偏移规则,别想当然。
4. 功能码与报文细节:把读寄存器、写线圈和 CRC16 拆到字节级
源码已经把功能码封装成了方法,但理解里面的报文结构依然很重要。现场排查问题、和对端厂商沟通时,绝大部分沟通成本都花在功能码和字节序的确认上。
4.1 常见功能码对应的读写套路
Modbus 功能码很多,实际采集项目用到的就那几个。源码里暴露的方法和功能码的对应关系如下:
// 读线圈,功能码 0x01 bool[] coils = client.ReadCoils(1, 0, 8); // 写单个线圈,功能码 0x05 client.WriteSingleCoil(1, 0, true); // 写单个寄存器,功能码 0x06 client.WriteSingleRegister(1, 0, 100); // 写多个寄存器,功能码 0x10 ushort[] data = new ushort[] { 10, 20, 30 }; client.WriteMultipleRegisters(1, 0, data);读线圈返回布尔数组,因为线圈本质是位。写单个线圈时,true 对应报文里的 FF00,false 对应 0000,这是协议规定的固定写法,不能用 0001 表示 true。写多寄存器时,方法内部会自动计算报文长度字段,你只需要传寄存器数组。
需要注意,功能码 0x03 读保持寄存器和 0x04 读输入寄存器是两个不同的数据区域。保持寄存器一般可读可写,输入寄存器通常只读。很多设备把温度压力放在输入寄存器区,代码里却用 ReadHoldingRegisters 去读,结果全是 0 或异常值。遇到这种情况,换个读法往往立刻解决。
4.2 CRC16 校验:RTU 报文完整性的最后一道防线
RTU 报文在功能码和数据之后附加两字节 CRC 校验,算错直接收不到响应。源码里的 CRC16 计算是 Modbus 标准多项式 0xA001,初始值 0xFFFF,按位计算。核心算法如下:
public static ushort CalculateCrc16(byte[] buffer, int start, int length) { ushort crc = 0xFFFF; for (int i = start; i < start + length; i++) { crc ^= buffer[i]; for (int j = 0; j < 8; j++) { if ((crc & 0x0001) != 0) { crc = (ushort)((crc >> 1) ^ 0xA001); } else { crc >>= 1; } } } return crc; }这段代码是通用的 Modbus CRC16 实现,不依赖任何第三方库。逻辑说明:crc 初值固定为 0xFFFF,对每个字节先异或再逐位处理,多项式 0xA001 是 0x8005 的逆序。计算完成后,低字节先发送,高字节后发送,顺序反了也会导致校验失败。
实际使用中,把请求报文(站号、功能码、数据区)整体传入计算,得到结果后追加到报文尾部。如果设备始终不响应,先用调试工具看下发的报文,再自己和一次 CRC,比对是不是低位高位顺序搞反了。
4.3 浮点和长整型的寄存器拼接顺序
32 位浮点数在 Modbus 里占两个寄存器。不同设备厂商对寄存器顺序的定义不一样,最常见的是 AB CD 和 CD AB 两种。C# 里正确拼法如下:
ushort reg0 = 0x4049; // 高字节能产生 3.14 的寄存器组合 ushort reg1 = 0x0FDB; byte[] raw = new byte[4]; raw[0] = (byte)(reg0 >> 8); // A raw[1] = (byte)reg0; // B raw[2] = (byte)(reg1 >> 8); // C raw[3] = (byte)reg1; // D float value = BitConverter.ToSingle(raw, 0);AB CD 顺序对应的是大端传输,C# 里 BitConverter.ToSingle 默认为小端,所以这里手动按字节组装而不是直接把两个寄存器值拼接。CD AB 的写法就是把 raw[0] 和 raw[2] 对调。
设备手册没写明顺序时,两种方案都试一遍,哪个读出来是合理物理值就用哪个。这也是为什么我在写采集程序时,会把浮点转换函数单独拎出来做成公共方法,方便在两种顺序间切换。
5. 避坑排查:五个最常见的 Modbus 联调故障与修复
这部分内容全是我在实际项目里用这套源码踩过的记录。每条按现象、原因、解决的顺序写,对照排查能省不少时间。
5.1 连接层的三个坑:读全零、RTU 乱码、断连无感知
坑一:TCP 能连接,读保持寄存器返回全 0
现象:设备 IP 能 ping 通,连接也不报错,但 ReadHoldingRegisters 返回的数组元素全是 0。原因有两个,一是站号不对,设备默认站号是 1,代码里却传了 0;二是寄存器区域不对,设备把温度放在输入寄存器区,程序却用 0x03 功能码去读。解决:对照设备手册确认站号和寄存器区域,把 0x03 换成 0x04 再读一次,大多数情况立刻能定位。
坑二:RTU 模式偶发乱码,同一批数据时好时坏
现象:同样的代码和参数,读同一段寄存器,有时正常有时返回超时,对波特率调整特别敏感。原因:RTU 对字符间时间间隔要求严格,标准是 3.5 个字符时间。USB 转 485 的驱动时序不稳定时,帧间隙会被破坏。解决:保证串口独占,同一根总线上不要有多个上位机同时轮询;如果用的是 USB 转 485,换一根带屏蔽的线,或在驱动里关闭延时。这些调整做完,偶发超时基本消失。
坑三:设备断电后程序不报错,还在显示旧数据
现象:现场设备掉电重启,采集程序没有任何异常,界面上数据停留在一个旧值。原因:TCP 连接被系统静默断开,读写操作在底层套接字上不一定会立即报错。解决:给客户端设置 ReceiveTimeout,比如 500 毫秒;再写一个心跳读取,定时读一个固定寄存器,连续三次失败就重新建立连接。这套源码的 TCP 客户端支持重连,但需要业务层去触发。
5.2 数据层的两个坑:多线程串扰与浮点错乱
坑四:多线程同时读同一个客户端,数据偶发错位
现象:启了多个线程分别读不同寄存器区,部分线程读到另一个线程请求的数据。原因:Modbus 是请求应答模型,TCP 模式下没有事务标识去匹配请求和响应,并发发送会造成应答错位。解决:用信号量把读写操作串行化,一个请求完成后才能发下一个。示例代码如下:
SemaphoreSlim modbusLock = new SemaphoreSlim(1, 1); async Task<short[]> ReadWithLockAsync(byte unit, ushort start, ushort count) { await modbusLock.WaitAsync(); try { return client.ReadHoldingRegisters(unit, start, count); } finally { modbusLock.Release(); } }参数说明:信号量初始计数 1 表示同时只允许一个请求,WaitAsync 和 Release 保证读写是原子操作。这样做不影响整体吞吐,因为 Modbus 报文本身很快,加锁几乎没有性能损失。
坑五:浮点数读出来是巨大数值或 NaN
现象:寄存器值能读回来,但转成 float 后完全不像是物理量,有时是 1e20 这种荒唐数。原因:寄存器字节序与解析顺序不匹配,设备按 CD AB 存储,代码却按 AB CD 解析。解决:两种顺序各写一个转换方法,分别读取对比。现场实测时,有一次是寄存器地址没写错、CRC 也正确,最后就是这一步害的数据全是错的,换顺序后立刻正常。
6. 趁手技巧:先把断连、错站号、错字节序三种异常测一遍
源码拿到手后,不要急着接真实设备。先花半小时在模拟从站上把异常路径测完,这半小时能抵现场半天。
6.1 用模拟从站制造故障
把从站模拟器跑起来后,依次做三个测试:把主站站号改成从站不存在的值,观察异常信息是否包含明确的错误提示;把从站 Stop 掉,再发读写请求,观察客户端是否会抛超时异常;用两种字节序各读一遍浮点,确认模拟量数据能正确还原。这三件事做完,源码给你的那层协议保障就算验证过了。
6.2 重试封装与超时兜底
真实工业现场里,一次性的读写往往不够稳。建议在源码的客户端外面套一个重试方法,用的时候直接调用:
public static short[] ReadHoldingRegistersSafe( ModbusTcpClient client, byte unit, ushort start, ushort count, int retry = 3) { for (int i = 0; i < retry; i++) { try { return client.ReadHoldingRegisters(unit, start, count); } catch (Exception ex) when (i < retry - 1) { // 重试间隔随次数递增,避免连续撞上同一问题 Thread.Sleep(100 * (i + 1)); } } return null; // 连续失败交给业务层处理告警 }重试次数设 3 比较合理,间隔 100 毫秒起步。间隔太短会把失败时的超时时间叠加,太长又会影响采集周期。这里 catch 里没有直接吞异常,而是把最终失败结果返回 null,由上层决定是否报警。这样既保证了采集线程不因偶发网络波动而崩溃,又不会掩盖真正的硬件故障。
从站模拟器配合重试封装,是这套源码最可靠的使用方式。从那以后我每次拿到新的驱动源码,先花半小时把断连、错站号、错字节序三件事测一遍,确认异常路径的表现再上现场。希望帮到你。
本文还有配套的精品资源,点击获取