03-C#开发Modbus RTU通信库
Modbus 学习笔记(第三部分)C# 开发 Modbus RTU 通信库
恭喜你走到这一步。前面两部分我们都在”纸上谈兵”:先搞懂了协议的概念,再拆解了报文格式。从这一部分开始,我们真正动手写代码,把前面学到的”报文格式”用 C# 一行一行地实现出来。
这一部分的目标非常明确:用 C# 自己写一个 Modbus RTU 通信库。所谓”库”,就是把那些重复要用的功能打包在一起,以后写串口助手、写数据采集程序时,直接调用就行,不用每次重新写一遍。
这一篇我会手把手带着你从零开始:先新建项目,再装依赖,然后一个文件一个文件地把代码敲出来。全程每一步都有详细说明,你照着做,就能自己独立完成,不需要依赖任何现成文件。遇到不认识的 C# 语法,我会先用大白话解释。
- 电脑上装了 Visual Studio(2019 或更高版本,推荐 2022);
- 会一点点 C# 基础(变量、循环、判断就够了);
- 有一颗”照着敲一遍”的耐心。
第一步:从零新建一个项目
写代码之前,得先有一个”项目”来装代码。项目就像一本空白的练习本,我们往里面一页页写东西。
方式一:用 Visual Studio 图形界面(推荐)
- 打开 Visual Studio;
- 点击右侧的”创建新项目“;
- 在搜索框输入”控制台”,选择”控制台应用“(注意语言选 C#,框架选 .NET 8 或更高,.NET 6 也行);
- 项目名称填
ModbusRTULib,位置选一个你找得到的地方(比如桌面),然后点”创建“; - 创建完成后,界面上会有一个
Program.cs文件,这是入口文件,先不用管它。
为什么用”控制台应用”?因为这一部分我们只写库的逻辑,用控制台最简单、最容易运行看到结果。等第 4 部分做 WinForm 界面时,再把这个库搬过去。
第二步:安装串口依赖包
C# 里操作串口要用到一个叫 System.IO.Ports 的工具箱。在 .NET 5 及更高版本里,它需要手动安装(老版本 .NET Framework 自带,不用装)。
在 Visual Studio 里的操作:
- 在右侧”解决方案资源管理器”里,右键点击你的项目名
ModbusRTULib; - 选择”管理 NuGet 程序包“;
- 切到”浏览“标签,搜索框输入
System.IO.Ports; - 找到后点”安装“,等它装完。
方式二:用命令行(可选,我已经帮你验证过)
如果你更喜欢命令行,也可以在终端里执行这三条命令(我用它们验证过,能编译通过):
1 | dotnet new console -n ModbusRTULib -o ModbusRTULib |
现在你有了一个空项目,并且装好了串口依赖。接下来我们往里面加四个文件:SerialBase.cs、IModbusRW.cs、ModbusRTU.cs,以及改造 Program.cs。
第三步:搞清楚这四个文件的分工
在动手写之前,先看清全局,免得写着写着迷路:
| 文件 | 负责的事 |
|---|---|
| SerialBase.cs | 打开串口、关闭串口(地基) |
| IModbusRW.cs | 列一张”方法清单”(合同) |
| ModbusRTU.cs | 收发报文、CRC 校验、实现所有读写方法(核心) |
| Program.cs | 演示怎么调用这个库 |
它们的关系一句话说清:ModbusRTU 类继承了 SerialBase(所以能开串口),同时实现了 IModbusRW 接口(所以会读写 Modbus 数据)。
第二步:补几个必要的 C# 基础知识
开始写代码前,有几个类型和概念你必须先认识。别怕,都很简单。
byte:字节,范围 0 到 255。报文里每一个十六进制数(01、03、FF)都是一个 byte,一条报文就是一队 byte。ushort:无符号 16 位整数,范围 0 到 65535。一个寄存器就是 16 位,所以寄存器的值(100、200)用 ushort 存。bool:布尔,只有 true 和 false。线圈的”开/关”正好对应 true/false。- 数组:一排同类型变量。
byte[]是一队字节(一条报文),bool[]是一队开关,ushort[]是一队数字。
- 类:像一张”图纸”,描述某样东西有哪些功能(比如”串口类”能打开、能关闭)。
- 方法:图纸上的一个具体动作(比如”打开串口”)。
- 接口:一份”合同”,只规定”必须有哪些方法、每个方法长什么样”,不写具体怎么做。谁实现这份接口,谁就必须把方法真正写出来。
- 继承:子类继承父类的本领。父类写好的方法,子类直接能用,不用重写。
写 Modbus 库,最核心的技巧就是把”数字”和”字节”来回转换,全靠下面这几个符号。用 8 位二进制举例:
>>右移:1010 0000右移 1 位变0101 0000(相当于除以 2);<<左移:0000 0001左移 1 位变0000 0010(相当于乘以 2);&与:两个位都是 1 才得 1,如1100 & 1010 = 1000;|或:有一个是 1 就得 1,如1100 | 1010 = 1110;^异或:两个位不一样是 1,如1100 ^ 1010 = 0110(CRC 用它);~取反:1 变 0,0 变 1。
现在先有个印象,后面用到时再结合代码解释。
3.1 SerialBase 串口打开关闭
这一节要做什么
写一个”串口基础类”,负责串口的打开和关闭。这是整个库的地基。
涉及的新东西
using System.IO.Ports;:把”串口工具箱”引进来。串口相关的类都在这里面。SerialPort:微软帮我们写好的串口类,打开、读、写这些底层操作它都封装好了。protected:访问修饰符,表示”子类也能用”。ModbusRTU 类要继承它、用它,所以用 protected。
手把手:新建 SerialBase.cs
- 右键项目名
ModbusRTULib; - 选择”添加“然后”类“;
- 文件名填
SerialBase.cs,点确定; - 把下面这段代码整个敲进去(注释不用敲也可以,但建议保留,方便以后回看)。
SerialBase.cs 完整代码
1 | using System; |
逐段讲解
第一段:引入工具箱。 using System; 和 using System.IO.Ports; 是”引入工具箱”,其中 System.IO.Ports 里装的就是串口相关类。
第二段:定义类和串口对象。 public class SerialBase 是定义一个类。类里第一句 protected SerialPort serialPort = new SerialPort();,意思是”造一个串口对象,取名叫 serialPort”。
第三段:Open 方法。 打开串口要告诉电脑五个信息:串口号(portName)、波特率(baudRate)、校验位(parity)、数据位(dataBits)、停止位(stopBits)。方法里先把这五个值赋给串口对象的属性,再调用 serialPort.Open() 真正打开。
第四段:Close 方法。 判断开着就关掉。用完串口一定要关,否则别的程序用不了这个串口。
serialPort.Open() 这一句,参数是”串口对象自己的属性”,前面那些赋值就是在设置这些属性。属性设对了,Open 才能成功;属性设错(比如 COM 口号填了个不存在的),Open 就会报异常。
到这里,地基打好了。后面我们只要继承它,就能直接用”开串口、关串口”的能力。
3.2 SendAndReceive 通用方法
这一节要做什么
写两个方法:一个是”CRC 校验”,一个是”发送并接收”。这俩是所有读写功能的地基。CRC 前面只是”知道有这回事”,现在要亲手用代码算出来。
先写 CRC16 校验
CRC 是报文末尾 2 个字节,用来防错。算法是几十年前定好的标准公式,你不用背、不用搞懂每一步为什么,照着写就行。只需记住三件事:
- 初始值固定是
0xFFFF; - 核心两句:最低位是 1 就”右移再异或 0xA001”,最低位是 0 就”直接右移”;
- 结果低字节在前、高字节在后。
1 | public static byte[] CRC16(byte[] data, int length) |
这里正好复习位运算:
crc ^= data[i]:异或,CRC 和当前字节做异或;crc >> 1:右移 1 位;crc & 0x0001:与运算,专门取”最低位是 0 还是 1”;crc & 0xFF:取低 8 位(低字节);crc >> 8:右移 8 位,得到高字节。
(ushort) 是”类型转换”。因为位运算会自动把结果变成更大的 int 类型,而我们要存进 ushort,所以要显式 (ushort) 转回来。写的时候照抄即可。
再写 SendAndReceive 通用方法
这个方法的作用:把一条完整报文发出去,接收从站回应。之所以叫”通用方法”,是因为后面所有读写方法都调用它来收发,不用每个方法都重写收发逻辑。
1 | public byte[] SendAndReceive(byte[] sendData) |
逐段讲解:
!serialPort.IsOpen:!是取反,意思是”没打开”。没打开就抛异常提醒。DiscardInBuffer():清空接收缓冲区,避免读到脏数据。Write(sendData, 0, sendData.Length):发报文。三个参数是”要发的数组、从第几个字节开始、发多少字节”。Thread.Sleep(100):程序”睡”100 毫秒,等从站回应。注意文件顶部要加using System.Threading;。BytesToRead:看缓冲区现在有多少字节,然后 Read 读出来返回。
这个 SendAndReceive 是”教学版”,简单好懂,但还不够健壮。真实项目还要加三样:超时判断(等不到就报错)、校验收到的 CRC、判断从站是否回了异常码。这些等你跑通基础版后,我们再一起升级。现在先用这个简单版,重点是搞懂流程。
Thread.Sleep 需要 using System.Threading;。如果你敲完发现 Thread 报错,多半是文件顶部没加这一句。
3.3 IModbusRW 读写接口实现
这一节要做什么
写一个”接口”,把我们要实现的 9 个读写方法名字先列出来,作为一份”合同”。
为什么用接口
两大好处:一是当清单用,一眼看清”通信类必须会哪些方法”,不会漏写;二是当规范用,将来换成 Modbus TCP 实现,只要新类也实现这份接口,调用方代码不用改。
对初学者来说,先把它理解成”一张方法清单”就行。
手把手:新建 IModbusRW.cs
和建 SerialBase.cs 一样,右键项目 → 添加 → 类 → 命名 IModbusRW.cs。
IModbusRW.cs 完整代码
1 | namespace ModbusRTULib |
逐段讲解
注意接口里的方法只有名字和参数,没有方法体(没有大括号里的具体代码),方法后面直接是分号。这就是接口的特点:只规定”做什么”,不规定”怎么做”。
看几个方法的样子:
bool[] ReadOutputCoil(...):返回一队布尔值(线圈的开关);ushort[] ReadOutputRegister(...):返回一队数字(寄存器的值);void WriteSingleCoil(...):void 表示”写操作不返回数据”。
每个方法都有参数:slaveAddr(从站地址)、startAddr(起始地址)、count(数量)或 value(要写的值)。
接口 = 一张列满”方法名字”的清单。接下来 3.4 到 3.10,就是照着这张清单,把每个方法的具体实现写出来。
3.4 读取输出输入线圈方法编写
这一节要做什么
实现”读线圈”,对应功能码 01(读输出线圈)和 02(读输入线圈)。两个功能码报文结构一样,只是功能码不同,所以我们写一个公共方法,两个公开方法都调用它。
回顾报文格式
- 发送:从站地址 + 功能码 + 起始地址(2字节) + 数量(2字节) + CRC(2字节),共 8 字节;
- 接收:从站地址 + 功能码 + 字节计数(1字节) + 数据 + CRC(2字节)。
手把手:新建 ModbusRTU.cs 并写读线圈
先建 ModbusRTU.cs(右键项目 → 添加 → 类)。这个文件会越来越大,后面所有方法都往里面加。
读线圈完整代码
1 | public bool[] ReadOutputCoil(byte slaveAddr, ushort startAddr, ushort count) |
逐段讲解
第一段:两个公开方法。 ReadOutputCoil 和 ReadInputCoil 自己不干活,而是转交给公共方法 ReadCoils,只是传的功能码不同(01 和 02)。这种写法避免把同样代码写两遍。
第二段:拼发送报文。 先 new byte[8] 造 8 字节数组。关键是怎么把一个 ushort 拆成 2 个字节:
startAddr >> 8右移 8 位,得到高字节;startAddr & 0xFF与 0xFF,得到低字节。
举例:起始地址 0x000A(十进制 10),>> 8 得 0x00(高字节),& 0xFF 得 0x0A(低字节)。数量 count 同理。
第三段:算 CRC 拼到末尾。 CRC16(sendData, 6) 只对前 6 字节算(不含 CRC 自己),结果放进末尾两个字节。
第四段:发送并接收。 调用 SendAndReceive 拿到回应 recvData。
第五段:解析数据(本节的难点)。 线圈数据是”8 个打包 1 字节、低位在前”。要还原成一个个开关:
byteIndex = 3 + i / 8:算出第 i 个线圈在第几个字节;bitIndex = i % 8:算出在这个字节的第几位(%是取余数);(recvData[byteIndex] >> bitIndex) & 0x01:右移到最低位,再用& 0x01取出这一位;== 1:把 0 或 1 转成 bool,存进结果数组。
建议你对照第二部分 2.2 里”拆 55 01”的例子,把这段循环再走一遍。那边是”手工拆字节”,这边是”代码拆字节”,是同一件事。
3.5 读取输出输入寄存器方法编写
这一节要做什么
实现”读寄存器”,对应功能码 03 和 04。结构和读线圈几乎一样,区别两点:功能码不同,以及”一个寄存器占 2 字节、高字节在前”。
读寄存器完整代码
1 | public ushort[] ReadOutputRegister(byte slaveAddr, ushort startAddr, ushort count) |
逐段讲解
拼发送报文部分和读线圈一样。重点看解析数据那一行:
1 | result[i] = (ushort)((recvData[3 + i * 2] << 8) | recvData[4 + i * 2]); |
这一行是”把 2 个字节合成 1 个寄存器值”,和读线圈的”拆字节”正好相反:
- 第 i 个寄存器的高字节在
recvData[3 + i*2],低字节在recvData[4 + i*2]; << 8把高字节左移 8 位,推到高 8 位;|把低字节接到低 8 位;(ushort)转成 16 位数字。
举例:高字节 0x00、低字节 0x64,0x00 << 8 得 0x0000,| 0x64 得 0x0064,即十进制 100。
寄存器是”高字节在前”(先收到的是高位),所以要左移高字节;线圈是”低位在前”,所以读线圈时从低位开始取。这两个顺序的区别,是第二部分反复强调的坑,写代码要对应清楚。
3.6 写入单输出线圈方法编写
这一节要做什么
实现”写单个线圈”,对应功能码 05。用于把某一个线圈打开或关闭。
- 发送:从站地址 + 05 + 线圈地址(2) + 写入值(2) + CRC;
- 写入值:
FF 00表示开,00 00表示关; - 从站收到后”原报文返回”。
写单线圈完整代码
1 | public void WriteSingleCoil(byte slaveAddr, ushort coilAddr, bool value) |
逐段讲解
唯一的新东西是这一行:
1 | sendData[4] = value ? (byte)0xFF : (byte)0x00; |
这叫”三目运算符”,意思是”如果 value 是 true 就填 FF,否则填 00”。翻译成大白话:要打开线圈(true),写入值是 FF 00;要关闭线圈(false),写入值是 00 00。正好对应第二部分讲的”开 = FF 00,关 = 00 00”。
返回类型是 void,因为它只是”写”,不返回数据。
3.7 写入单输出寄存器方法编写
这一节要做什么
实现”写单个寄存器”,对应功能码 06。用于把某一个寄存器的值改成我们想要的值。
写单寄存器完整代码
1 | public void WriteSingleRegister(byte slaveAddr, ushort regAddr, ushort value) |
逐段讲解
和写单线圈几乎一样,区别两处:
- 功能码是
0x06; - 写入值是一个 16 位 ushort,要拆成两个字节:
value >> 8是高字节,value & 0xFF是低字节。
举例:要把值改成 200(十六进制 0x00C8),200 >> 8 得 0x00,200 & 0xFF 得 0xC8,拼出的报文就是 ... 00 C8 ...,和第二部分 2.7 的案例一模一样。
“写单个”的两个方法(05 和 06)套路完全一致,只是功能码和”写入值”拆法不同。Modbus 就是这么规整。
3.8 写入多输出线圈方法编写
这一节要做什么
实现”写多个线圈”,对应功能码 0F。用于一次性把一串线圈全部设置好。
- 发送:从站地址 + 0F + 起始地址(2) + 数量(2) + 字节计数(1) + 数据 + CRC;
- “数据”是把线圈状态按”低位在前”打包成的字节。
写多线圈完整代码
1 | public void WriteMultiCoils(byte slaveAddr, ushort startAddr, bool[] values) |
逐段讲解
第一处新东西:算字节计数。
1 | int byteCount = (count + 7) / 8; |
8 个线圈打包 1 字节。线圈数不是 8 的整数倍时,要多一个字节。(count + 7) / 8 是”向上取整”的写法。你代两个数验证:8 个线圈 (8+7)/8 = 1;10 个线圈 (10+7)/8 = 2。
第二处新东西:把线圈打包进字节。
1 | for (int i = 0; i < count; i++) |
这段和读线圈时的解析是”反过程”:i / 8 决定放进第几个字节,i % 8 决定第几位,1 << (i % 8) 把 1 左移到对应位,|= 在原来的基础上把这一位置成 1(不影响其它位)。value 是 true 就置 1,false 就保持默认 0。
第三处:数组长度变了。 因为多了”字节计数”和”数据”,数组长度从固定 8 变成 9 + byteCount。CRC 要算的字节数也变成 7 + byteCount(去掉末尾 CRC 的 2 字节)。
3.9 写入多输出寄存器方法编写
这一节要做什么
实现”写多个寄存器”,对应功能码 10。用于一次性把一串寄存器的值都设置好。
写多寄存器完整代码
1 | public void WriteMultiRegisters(byte slaveAddr, ushort startAddr, ushort[] values) |
逐段讲解
和写多线圈结构几乎一样,区别在于:
- 功能码是
0x10; - 字节计数是
count * 2(每个寄存器 2 字节); - 打包数据时,每个寄存器拆成高、低两个字节,按”高字节在前”填入。
到这里,8 个标准功能码的方法就全写完了。你会发现它们骨架高度一致:先拼字节数组,再算 CRC,最后 SendAndReceive 一发。 这就是 Modbus 简单规整的地方。
3.10 写入寄存器某个位方法编写
这一节要做什么
实现一个”进阶小技巧”:只修改某个寄存器的某一位,其它位保持不变。
为什么单独写这个方法
实际项目里,一个寄存器的 16 位,每一位可能代表不同开关(第 0 位是”启动”,第 1 位是”急停”)。想单独控制”启动”又不能搞乱其它位。而 Modbus 写寄存器最少也是写”整个寄存器”,所以要只改一位,得用三步套路:
- 读:先把寄存器当前值读出来;
- 改:在内存里只改我们关心的那一位;
- 写:把改好的值写回去。
这个套路叫”读、改、写”,是位操作的经典思路。
写寄存器某位完整代码
1 | public void WriteRegisterBit(byte slaveAddr, ushort regAddr, int bitIndex, bool value) |
逐段讲解
第一步:读。 ReadOutputRegister(slaveAddr, regAddr, 1) 返回一个数组(读 1 个寄存器,长度就是 1),[0] 取第 1 个元素,即当前值,存进 current。
第二步:改。 两个位操作技巧:
- 置 1:
current |= (ushort)(1 << bitIndex)。1 << bitIndex把 1 移到第 bitIndex 位,再或运算,这一位变 1,其它位不变。 - 置 0:
current &= (ushort)~(1 << bitIndex)。~(1 << bitIndex)是”只有第 bitIndex 位是 0、其它位都是 1”的数,再与运算,这一位变 0,其它位不变。
第三步:写。 WriteSingleRegister 把改好的值写回去。
这个”读、改、写”技巧在工业项目里非常常用,建议好好体会。它体现了”只动一位、不动其它位”的思想。
把整个库跑起来
所有方法都写完了。最后一步:改造 Program.cs,演示怎么调用这个库。
打开项目里自带的 Program.cs,把内容全部删掉,替换成下面这段:
Program.cs 完整演示代码
1 | using System; |
使用步骤,手把手带你走
- 新建对象:
ModbusRTU modbus = new ModbusRTU();。new就是”造一个出来”。 - 打开串口:
modbus.Open("COM3", 9600, Parity.None, 8, StopBits.One);。参数必须和从站设备一致。 - 调用读写方法:比如
ReadOutputRegister(1, 0, 2)表示”读 1 号从站、从第 1 个寄存器开始、读 2 个”,返回一队数字。 - 用完关闭:
modbus.Close();。
点 Visual Studio 顶部的绿色”启动”按钮(或按 F5),程序会编译并运行,控制台窗口里会打印读到的数据。前提是你有真实从站,或用 Modbus Slave 软件模拟一个从站,否则收不到数据。
常见报错排查
多半是没装 System.IO.Ports 包,或者文件顶部没写 using System.IO.Ports;。检查这两点。
检查 COM 口号对不对(设备管理器里看),以及串口是不是被别的程序占用了。
从站地址、波特率、校验位等参数和从站不一致,或者根本没有从站回应。先用串口调试助手单独确认链路通不通。
第三部分小结
这一部分我们完成了一件大事:把 Modbus RTU 通信逻辑,从理论变成了能跑的 C# 代码。
- 通信库的整体结构:串口基础类(开串口)+ 读写接口(列清单)+ 通信主类(干实事);
- 必要的 C# 概念:byte、ushort、bool、数组、类和方法、接口、继承;
- 位运算的用法,以及如何”拆字节、合字节、置位、清零”;
- 8 个功能码的读写方法,骨架高度一致:拼字节、算 CRC、发送接收;
- 进阶技巧”读、改、写”,实现”只修改寄存器的某一位”。
我能自己新建一个控制台项目
我装了 System.IO.Ports 包
我能解释 byte、ushort、bool 和数组
我能解释”拆字节”用 >> 和 &,”合字节”用 << 和 |
我照着笔记敲出了 CRC16 方法
我照着笔记敲出了读线圈和读寄存器方法
我理解”写单”和”写多”报文的区别
我理解”读、改、写”三步修改某一位
到这里,你的 Modbus RTU 通信库已经成型了。下一部分,我们要给它配上一个图形界面:用 WinForm 做一个串口助手,让你在窗口里点按钮、看数据,真正把这个库用起来,做成一个能用的上位机小程序。
如果你在跑代码的过程中遇到任何报错,把报错信息发给我,我陪你一起查。


