简介:NModbus4 C# 源码是一份面向.NET开发者的工业自动化通信库源码,适合需要快速集成Modbus协议的中高级C#程序员。它解决了在ASP.NET、WinForms、WPF、Windows服务及控制台程序中实现Modbus主站与从站通信的问题,支持Modbus TCP、ASCII、RTU等多种模式,并提供读写线圈、离散输入、保持寄存器、输入寄存器等丰富API,同时内置异常处理与异步通信机制,便于构建响应快、性能高的工业控制系统。资源包共223个文件,以104个cs源码文件、103个html文档、3个csproj工程文件及config、sln、chm帮助文档等为主,压缩包约719KB,目录结构清晰,便于按模块查阅与二次开发。目前已有402人学习下载,读者可从中获取完整的协议实现逻辑、主从站调用示例与排错思路,适合工厂自动化、能源管理、远程监控等场景下的开发参考。
1. NModbus4 C# 源码:从零手搓工业通信栈的起点
车间里一台老 PLC 还在跑,上位机却要换成 .NET 8 的新界面,这时候你大概率会搜到 NModbus4 C# 源码。它解决的不是“怎么调一个库”这么浅的问题,而是让你在 C# 里彻底掌控 Modbus RTU/TCP 的收发时序、异常码和超时重试。适合两类人:一是被第三方通信组件坑过、想自己改底层帧结构的工控上位机开发者;二是需要把 Modbus 嵌进自有协议栈、做网关或采集服务的 .NET 工程师。源码在手,你才能改串口读写超时、改从站地址映射、改批量寄存器的合并策略,而不是对着黑匣子干瞪眼。这一篇就按“能编译、能连、能排错”的路径,把 NModbus4 的骨架拆开讲透。
2. 把 NModbus4 源码拉进本地工程:编译、引用与最小连通
2.1 源码结构里真正要盯住的四个目录
拿到一份 NModbus4 C# 源码,先别急着 F5。常见做法是看根目录下的Modbus、Modbus.Device、Modbus.Serial、Modbus.Tcp这几个文件夹(不同分支命名略有差异,但职责基本一致)。Modbus放的是协议数据单元,比如ModbusMessage、ModbusFunctionCode、RegisterAddress;Modbus.Device是主站/从站的门面类,像ModbusSerialMaster、ModbusTcpMaster;Modbus.Serial封装串口流,Modbus.Tcp封装 TCP 流。你要改超时、改重试、改帧间隔,九成落在Modbus.Serial和Modbus.Device里。
我一般会先确认目标框架。老版本源码可能锁在 .NET Framework 4.0,直接扔进 .NET 8 会报SerialPort跨平台缺失。稳妥做法是新建一个netstandard2.0类库,把协议层和流层代码文件链接进去,串口相关代码用条件编译隔离。这样 Windows 上位机能跑,Linux 网关也能跑。
<!-- 新建 NModbus4.Core.csproj,只收协议与设备层 --> <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks> <LangVersion>latest</LangVersion> </PropertyGroup> <ItemGroup Condition="'$(TargetFramework)'=='net8.0'"> <PackageReference Include="System.IO.Ports" Version="8.0.0" /> </ItemGroup> </Project>这段工程文件的关键在TargetFrameworks和条件引用。netstandard2.0保证老项目能引用,net8.0让你用上新串口 API。System.IO.Ports只在 net8.0 下引入,避免 netstandard 目标被多余依赖污染。如果你只做 TCP 采集,串口包完全可以不引,编译体积和启动速度都会好一截。
2.2 用 ModbusTcpMaster 连一个模拟从站的最小命令
源码编译通过后,别直接上真设备。先跑一个本地模拟从站,把读写链路走通。下面这段代码创建 TCP 主站,读保持寄存器 0 开始的 10 个值。
using System; using System.Net.Sockets; using NModbus4.Device; class Program { static void Main() { // 模拟从站通常监听 502,本地测试可换 1502 避免权限问题 using var client = new TcpClient("127.0.0.1", 1502); var master = ModbusTcpMaster.CreateIp(client); // 读保持寄存器:从站地址 1,起始 0,数量 10 ushort[] regs = master.ReadHoldingRegisters(1, 0, 10); for (int i = 0; i < regs.Length; i++) Console.WriteLine($"HR[{i}] = {regs[i]}"); // 写单个寄存器:地址 5 写 1234 master.WriteSingleRegister(1, 5, 1234); Console.WriteLine("write ok"); } }逻辑上,CreateIp内部会把TcpClient的流包装成ModbusTransport,再交给主站对象。ReadHoldingRegisters的第一个参数是从站地址,第二个是起始地址,第三个是数量。这里有个容易翻车的点:数量上限。功能码 03 一次最多读 125 个寄存器,超了会抛异常或返回非法数据。写单个寄存器用功能码 06,写多个用 16,别混。
参数方面,TcpClient的连接超时默认跟系统走,建议显式设client.ReceiveTimeout和SendTimeout,否则网络抖动时主站会卡死。我一般设 1000ms 到 3000ms,视现场交换机质量定。
2.3 串口主站的初始化参数与帧间隔
RTU 比 TCP 多一层串口配置。下面是最小可用配置。
using System.IO.Ports; using NModbus4.Device; var port = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One); port.ReadTimeout = 1000; port.WriteTimeout = 1000; port.Open(); var master = ModbusSerialMaster.CreateRtu(port); ushort[] data = master.ReadInputRegisters(1, 0, 4);9600、None、8、One是最常见的 RTU 组合,但现场经常遇到Even校验或Two停止位。改之前先确认从站手册,改完必须重启串口。ReadTimeout设太小,长帧会截断;设太大,从站掉线时主站会僵住。RTU 还有个帧间隔问题,3.5 个字符时间。NModbus4 源码里通常用SerialPort的读写阻塞来近似,但在高波特率下可能不够准。如果你发现偶发 CRC 错,可以在发送前手动Thread.Sleep一个字符时间,或者换用带精确延时的串口封装。
3. 读透 NModbus4 的请求响应模型:功能码、地址与字节序
3.1 功能码映射表与常用读写方法对照
NModbus4 把 Modbus 功能码封装成了主站方法,但底层帧结构没变。下面这张表是我排错时贴在显示器边上的。
| 功能码 | 含义 | NModbus4 方法 | 数据区单位 |
|---|---|---|---|
| 01 | 读线圈 | ReadCoils | 位 |
| 02 | 读离散输入 | ReadInputs | 位 |
| 03 | 读保持寄存器 | ReadHoldingRegisters | 16 位字 |
| 04 | 读输入寄存器 | ReadInputRegisters | 16 位字 |
| 05 | 写单个线圈 | WriteSingleCoil | 位 |
| 06 | 写单个寄存器 | WriteSingleRegister | 16 位字 |
| 15 | 写多个线圈 | WriteMultipleCoils | 位 |
| 16 | 写多个寄存器 | WriteMultipleRegisters | 16 位字 |
这张表的价值在于:当你抓包看到0x03却调了ReadInputRegisters,立刻就知道方法用错了。线圈和寄存器在源码里走的是不同解析分支,混用不会报编译错,但运行时会返回异常码 02 或直接超时。
3.2 寄存器地址的两种编号体系与偏移换算
Modbus 地址有“协议地址”和“文档地址”两套。协议地址从 0 开始,文档地址常写成 40001、30001。NModbus4 的方法参数用的是协议地址,也就是从 0 开始。如果你拿到的点表写“40001”,传参时要减 1 变成 0;写“40010”就传 9。这个减一操作是血泪经验,我见过不止一个项目因为没减一,读回来的全是隔壁寄存器的值。
// 点表写 40001,实际传 0 ushort first = master.ReadHoldingRegisters(1, 0, 1)[0]; // 点表写 40010,实际传 9 ushort tenth = master.ReadHoldingRegisters(1, 9, 1)[0];另外,批量读的时候起始地址加数量不能跨区。比如从 39999 开始读 10 个,会越过 40001 边界,从站可能返回非法地址。稳妥做法是按区段拆分请求。
3.3 字节序与 32 位浮点的拼接坑
Modbus 寄存器是 16 位大端,但很多设备把 32 位浮点拆成两个寄存器时,字序和字节序都可能反。NModbus4 只负责把两个ushort给你,怎么拼是你的事。常见四种组合:ABCD、CDAB、BADC、DCBA。下面是一个可复用的拼接方法。
// 把两个寄存器按 CDAB 拼成 float(常见于部分变频器) static float ToFloatCdab(ushort high, ushort low) { byte[] bytes = new byte[4]; // low 在前,high 在后,再各自字节反转 bytes[0] = (byte)(low & 0xFF); bytes[1] = (byte)(low >> 8); bytes[2] = (byte)(high & 0xFF); bytes[3] = (byte)(high >> 8); return BitConverter.ToSingle(bytes, 0); }参数说明:high是地址较小的寄存器,low是地址较大的寄存器。如果你的设备是 ABCD,就把high放前两个字节、low放后两个字节。判断方法很简单:读一个已知值,比如 25.0,看哪种拼法出来是 25.0 就用哪种。别猜,猜就是玄学。
4. 避坑与排查:NModbus4 现场翻车的五条记录
4.1 现象:调用 ReadHoldingRegisters 一直超时,抓包却看不到请求
原因通常是串口没打开,或者SerialPort被别的进程占用。NModbus4 在流层抛异常前可能先阻塞在写操作上。解决:在创建主站前检查port.IsOpen,并用try/catch包住Open(),把UnauthorizedAccessException单独打日志。TCP 场景下则检查防火墙和从站端口是否监听。
4.2 现象:读回来的寄存器值整体偏移一位
点表地址没减一。文档写 40001,你传了 1,实际读的是 40002。解决:统一在配置层做减一,别在业务代码里到处减。我一般写一个ToProtocolAddress(int docAddress)方法,所有点表解析都走它。
4.3 现象:偶发 CRC 校验失败,重试一次又好了
RTU 帧间隔不够,或者串口线太长、屏蔽没做好。解决:降低波特率到 9600 试,换双绞屏蔽线,在发送前后加 1 到 2ms 延时。如果还不行,检查从站是否支持更高波特率,有时候是主站发太快,从站来不及响应。
4.4 现象:写多个寄存器时从站返回异常码 03
异常码 03 是非法数据值。常见原因是写入数量超过从站允许的单次上限,或者写入的值超出寄存器范围。解决:把批量写拆成每包不超过 100 个寄存器,并检查值是否在 0 到 65535 之间。有符号数要自己转成补码。
4.5 现象:TCP 连接正常,但读几包后卡死
TcpClient的NoDelay没开,Nagle 算法把小包合并了,Modbus 对时序敏感。解决:创建TcpClient后设client.NoDelay = true,并给ReceiveTimeout设一个合理值。另外,NModbus4 的 TCP 主站不是线程安全的,多线程读写要加锁,否则响应会串包。
5. 进阶:用 NModbus4 源码做自定义超时与批量采集调度
5.1 改源码里的超时逻辑,而不是在外面包一层
很多人遇到超时就写Task.Delay重试,结果越包越乱。正确做法是进ModbusTransport或SerialPortTransport里改ReadTimeout和WriteTimeout的赋值点。比如把固定 1000ms 改成从配置读,并区分“连接超时”和“响应超时”。下面是一个改法示意。
// 在 SerialPortTransport 构造函数里 public SerialPortTransport(SerialPort port, int responseTimeoutMs = 1000) { _port = port; _port.ReadTimeout = responseTimeoutMs; _port.WriteTimeout = responseTimeoutMs; // 帧间隔:3.5 字符时间,按波特率算 _interFrameDelayMs = (int)Math.Ceiling(3.5 * 11 * 1000.0 / port.BaudRate); }responseTimeoutMs建议按从站响应速度设,老设备给 2000ms,新设备 500ms 足够。_interFrameDelayMs在 9600 波特率下约 4ms,在 115200 下约 0.33ms,取整后至少 1ms。这个延时加在每次发送前,能显著降低 CRC 错。
5.2 批量采集的调度策略:分组、并发与失败隔离
一个网关要采 200 个点,不能一个点一个请求。按从站地址和功能码分组,每组一次读最多 125 个寄存器。下面是一个分组调度的骨架。
// 按 (slaveId, functionCode, start, count) 分组后顺序执行 foreach (var group in groups) { try { var values = master.ReadHoldingRegisters( group.SlaveId, group.Start, group.Count); // 写入缓存或数据库 } catch (Exception ex) { // 单组失败不影响其他组,记录后继续 _logger.LogError(ex, "group {Start} failed", group.Start); } }关键在try/catch放在循环内,而不是整个循环外。一个从站掉线不能拖垮全部采集。如果从站支持多连接,可以按从站拆成多个线程,但同一个master实例不要跨线程用。我一般每个从站一个主站实例,各自独立超时。
5.3 验证方法:用回环测试和已知值校验
改完源码别直接上产线。先做回环:把主站的发送接到自己的接收,或者用两个串口互连,跑读写一致性测试。再准备一组已知值,比如在模拟从站里把寄存器 0 到 9 设成 100 到 109,读回来逐个比对。TCP 场景可以用本地模拟从站,把功能码 03 的响应固定成已知字节数组,验证解析逻辑。最后跑 24 小时稳定性,看内存和句柄有没有涨。我自己的习惯是每次改完通信层,先跑 10000 次读写循环,通过再提交。希望帮到你。
本文还有配套的精品资源,点击获取