Modbus 学习笔记(第三部分)C# 开发 Modbus RTU 通信库

恭喜你走到这一步。前面两部分我们都在”纸上谈兵”:先搞懂了协议的概念,再拆解了报文格式。从这一部分开始,我们真正动手写代码,把前面学到的”报文格式”用 C# 一行一行地实现出来。

这一部分的目标非常明确:用 C# 自己写一个 Modbus RTU 通信库。所谓”库”,就是把那些重复要用的功能打包在一起,以后写串口助手、写数据采集程序时,直接调用就行,不用每次重新写一遍。

这一篇我会手把手带着你从零开始:先新建项目,再装依赖,然后一个文件一个文件地把代码敲出来。全程每一步都有详细说明,你照着做,就能自己独立完成,不需要依赖任何现成文件。遇到不认识的 C# 语法,我会先用大白话解释。

  1. 电脑上装了 Visual Studio(2019 或更高版本,推荐 2022);
  2. 会一点点 C# 基础(变量、循环、判断就够了);
  3. 有一颗”照着敲一遍”的耐心。

第一步:从零新建一个项目

写代码之前,得先有一个”项目”来装代码。项目就像一本空白的练习本,我们往里面一页页写东西。

方式一:用 Visual Studio 图形界面(推荐)

  1. 打开 Visual Studio;
  2. 点击右侧的”创建新项目“;
  3. 在搜索框输入”控制台”,选择”控制台应用“(注意语言选 C#,框架选 .NET 8 或更高,.NET 6 也行);
  4. 项目名称填 ModbusRTULib,位置选一个你找得到的地方(比如桌面),然后点”创建“;
  5. 创建完成后,界面上会有一个 Program.cs 文件,这是入口文件,先不用管它。

为什么用”控制台应用”?因为这一部分我们只写库的逻辑,用控制台最简单、最容易运行看到结果。等第 4 部分做 WinForm 界面时,再把这个库搬过去。

第二步:安装串口依赖包

C# 里操作串口要用到一个叫 System.IO.Ports 的工具箱。在 .NET 5 及更高版本里,它需要手动安装(老版本 .NET Framework 自带,不用装)。

在 Visual Studio 里的操作:

  1. 在右侧”解决方案资源管理器”里,右键点击你的项目名 ModbusRTULib
  2. 选择”管理 NuGet 程序包“;
  3. 切到”浏览“标签,搜索框输入 System.IO.Ports
  4. 找到后点”安装“,等它装完。

方式二:用命令行(可选,我已经帮你验证过)

如果你更喜欢命令行,也可以在终端里执行这三条命令(我用它们验证过,能编译通过):

1
2
3
dotnet new console -n ModbusRTULib -o ModbusRTULib
cd ModbusRTULib
dotnet add package System.IO.Ports

现在你有了一个空项目,并且装好了串口依赖。接下来我们往里面加四个文件:SerialBase.csIModbusRW.csModbusRTU.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

  1. 右键项目名 ModbusRTULib
  2. 选择”添加“然后”“;
  3. 文件名填 SerialBase.cs,点确定;
  4. 把下面这段代码整个敲进去(注释不用敲也可以,但建议保留,方便以后回看)。
SerialBase.cs 完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
using System;
using System.IO.Ports; // 引入串口工具箱

namespace ModbusRTULib
{
/// <summary>
/// 串口基础类:负责串口的打开和关闭。
/// 以后所有 Modbus 收发都建立在它之上。
/// </summary>
public class SerialBase
{
// 串口对象,收发数据全靠它。
// 用 protected 修饰,表示"子类可以使用",方便 ModbusRTU 类直接访问。
protected SerialPort serialPort = new SerialPort();

/// <summary>
/// 打开串口。
/// </summary>
/// <param name="portName">串口号,比如 COM3</param>
/// <param name="baudRate">波特率,比如 9600</param>
/// <param name="parity">校验位,一般 None</param>
/// <param name="dataBits">数据位,一般 8</param>
/// <param name="stopBits">停止位,一般 1</param>
public void Open(string portName, int baudRate, Parity parity, int dataBits, StopBits stopBits)
{
// 如果已经打开了,先关掉,避免重复打开报错
if (serialPort.IsOpen)
{
serialPort.Close();
}

serialPort.PortName = portName; // 串口号
serialPort.BaudRate = baudRate; // 波特率
serialPort.Parity = parity; // 校验位
serialPort.DataBits = dataBits; // 数据位
serialPort.StopBits = stopBits; // 停止位

serialPort.Open(); // 真正打开串口
}

/// <summary>
/// 关闭串口。
/// </summary>
public void Close()
{
// 开着才关
if (serialPort.IsOpen)
{
serialPort.Close();
}
}

/// <summary>
/// 判断串口是否处于打开状态。
/// </summary>
public bool IsOpen
{
get { return serialPort.IsOpen; }
}
}
}

逐段讲解

第一段:引入工具箱。 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 个字节,用来防错。算法是几十年前定好的标准公式,你不用背、不用搞懂每一步为什么,照着写就行。只需记住三件事:

  1. 初始值固定是 0xFFFF
  2. 核心两句:最低位是 1 就”右移再异或 0xA001”,最低位是 0 就”直接右移”;
  3. 结果低字节在前、高字节在后
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static byte[] CRC16(byte[] data, int length)
{
ushort crc = 0xFFFF; // CRC 初始值固定是 FFFF
for (int i = 0; i < length; i++)
{
crc ^= data[i]; // 先和当前字节做"异或"
for (int j = 0; j < 8; j++) // 一个字节要处理 8 位
{
if ((crc & 0x0001) != 0) // 如果最低位是 1
crc = (ushort)((crc >> 1) ^ 0xA001); // 右移再异或
else // 如果最低位是 0
crc = (ushort)(crc >> 1); // 直接右移
}
}

byte[] result = new byte[2];
result[0] = (byte)(crc & 0xFF); // 低字节放前面
result[1] = (byte)(crc >> 8); // 高字节放后面
return result;
}

这里正好复习位运算:

  • crc ^= data[i]:异或,CRC 和当前字节做异或;
  • crc >> 1:右移 1 位;
  • crc & 0x0001:与运算,专门取”最低位是 0 还是 1”;
  • crc & 0xFF:取低 8 位(低字节);
  • crc >> 8:右移 8 位,得到高字节。

(ushort) 是”类型转换”。因为位运算会自动把结果变成更大的 int 类型,而我们要存进 ushort,所以要显式 (ushort) 转回来。写的时候照抄即可。

再写 SendAndReceive 通用方法

这个方法的作用:把一条完整报文发出去,接收从站回应。之所以叫”通用方法”,是因为后面所有读写方法都调用它来收发,不用每个方法都重写收发逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public byte[] SendAndReceive(byte[] sendData)
{
// 串口没打开就没法通信,直接报错提醒
if (!serialPort.IsOpen)
{
throw new Exception("串口没有打开,请先调用 Open 方法");
}

// 发送前清空接收缓冲区,避免读到上一次残留的数据
serialPort.DiscardInBuffer();

// 把报文发出去
serialPort.Write(sendData, 0, sendData.Length);

// 等一小段时间,给从站留出处理和回应的机会
Thread.Sleep(100);

// 看看缓冲区里收到了多少字节
int len = serialPort.BytesToRead;

// 新建数组,把收到的数据读进来
byte[] recvData = new byte[len];
serialPort.Read(recvData, 0, len);

return recvData;
}

逐段讲解:

  • !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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
namespace ModbusRTULib
{
/// <summary>
/// Modbus 读写接口。
/// 像一份"合同",规定通信类必须会哪些事。
/// 只写方法名字,不写具体怎么做,怎么做由 ModbusRTU 类实现。
/// </summary>
public interface IModbusRW
{
/// <summary>读取输出线圈(功能码 01)</summary>
bool[] ReadOutputCoil(byte slaveAddr, ushort startAddr, ushort count);

/// <summary>读取输入线圈(功能码 02)</summary>
bool[] ReadInputCoil(byte slaveAddr, ushort startAddr, ushort count);

/// <summary>读取保持寄存器(功能码 03)</summary>
ushort[] ReadOutputRegister(byte slaveAddr, ushort startAddr, ushort count);

/// <summary>读取输入寄存器(功能码 04)</summary>
ushort[] ReadInputRegister(byte slaveAddr, ushort startAddr, ushort count);

/// <summary>写入单个线圈(功能码 05)</summary>
void WriteSingleCoil(byte slaveAddr, ushort coilAddr, bool value);

/// <summary>写入单个寄存器(功能码 06)</summary>
void WriteSingleRegister(byte slaveAddr, ushort regAddr, ushort value);

/// <summary>写入多个线圈(功能码 0F)</summary>
void WriteMultiCoils(byte slaveAddr, ushort startAddr, bool[] values);

/// <summary>写入多个寄存器(功能码 10)</summary>
void WriteMultiRegisters(byte slaveAddr, ushort startAddr, ushort[] values);

/// <summary>写入寄存器某一位(先读、改位、再写回)</summary>
void WriteRegisterBit(byte slaveAddr, ushort regAddr, int bitIndex, bool value);
}
}

逐段讲解

注意接口里的方法只有名字和参数,没有方法体(没有大括号里的具体代码),方法后面直接是分号。这就是接口的特点:只规定”做什么”,不规定”怎么做”。

看几个方法的样子:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public bool[] ReadOutputCoil(byte slaveAddr, ushort startAddr, ushort count)
{
// 读输出线圈,功能码是 01
return ReadCoils(slaveAddr, startAddr, count, 0x01);
}

public bool[] ReadInputCoil(byte slaveAddr, ushort startAddr, ushort count)
{
// 读输入线圈,功能码是 02
return ReadCoils(slaveAddr, startAddr, count, 0x02);
}

/// <summary>读线圈的公共逻辑,功能码 01 和 02 共用</summary>
private bool[] ReadCoils(byte slaveAddr, ushort startAddr, ushort count, byte funcCode)
{
// 发送报文共 8 字节:地址 + 功能码 + 起始地址(2) + 数量(2) + CRC(2)
byte[] sendData = new byte[8];
sendData[0] = slaveAddr; // 从站地址
sendData[1] = funcCode; // 功能码
sendData[2] = (byte)(startAddr >> 8); // 起始地址高字节
sendData[3] = (byte)(startAddr & 0xFF); // 起始地址低字节
sendData[4] = (byte)(count >> 8); // 数量高字节
sendData[5] = (byte)(count & 0xFF); // 数量低字节

// 计算前 6 个字节的 CRC,拼到末尾
byte[] crc = CRC16(sendData, 6);
sendData[6] = crc[0];
sendData[7] = crc[1];

// 发送并接收
byte[] recvData = SendAndReceive(sendData);

// 解析数据:recvData[0] 地址,[1] 功能码,[2] 字节计数,[3..] 线圈状态
bool[] result = new bool[count];
for (int i = 0; i < count; i++)
{
int byteIndex = 3 + i / 8; // 第 i 个线圈在第几个字节
int bitIndex = i % 8; // 在这个字节的第几位
result[i] = ((recvData[byteIndex] >> bitIndex) & 0x01) == 1; // 取出这一位
}
return result;
}

逐段讲解

第一段:两个公开方法。 ReadOutputCoilReadInputCoil 自己不干活,而是转交给公共方法 ReadCoils,只是传的功能码不同(01 和 02)。这种写法避免把同样代码写两遍。

第二段:拼发送报文。new byte[8] 造 8 字节数组。关键是怎么把一个 ushort 拆成 2 个字节

  • startAddr >> 8 右移 8 位,得到高字节;
  • startAddr & 0xFF 与 0xFF,得到低字节。

举例:起始地址 0x000A(十进制 10),>> 80x00(高字节),& 0xFF0x0A(低字节)。数量 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public ushort[] ReadOutputRegister(byte slaveAddr, ushort startAddr, ushort count)
{
// 读保持寄存器,功能码 03
return ReadRegisters(slaveAddr, startAddr, count, 0x03);
}

public ushort[] ReadInputRegister(byte slaveAddr, ushort startAddr, ushort count)
{
// 读输入寄存器,功能码 04
return ReadRegisters(slaveAddr, startAddr, count, 0x04);
}

/// <summary>读寄存器的公共逻辑,功能码 03 和 04 共用</summary>
private ushort[] ReadRegisters(byte slaveAddr, ushort startAddr, ushort count, byte funcCode)
{
// 发送报文同样 8 字节,结构和读线圈完全一样
byte[] sendData = new byte[8];
sendData[0] = slaveAddr;
sendData[1] = funcCode;
sendData[2] = (byte)(startAddr >> 8);
sendData[3] = (byte)(startAddr & 0xFF);
sendData[4] = (byte)(count >> 8);
sendData[5] = (byte)(count & 0xFF);

byte[] crc = CRC16(sendData, 6);
sendData[6] = crc[0];
sendData[7] = crc[1];

byte[] recvData = SendAndReceive(sendData);

// 解析数据:一个寄存器占 2 字节,高字节在前
ushort[] result = new ushort[count];
for (int i = 0; i < count; i++)
{
result[i] = (ushort)((recvData[3 + i * 2] << 8) | recvData[4 + i * 2]);
}
return result;
}

逐段讲解

拼发送报文部分和读线圈一样。重点看解析数据那一行:

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、低字节 0x640x00 << 80x0000| 0x640x0064,即十进制 100。

寄存器是”高字节在前”(先收到的是高位),所以要左移高字节;线圈是”低位在前”,所以读线圈时从低位开始取。这两个顺序的区别,是第二部分反复强调的坑,写代码要对应清楚。


3.6 写入单输出线圈方法编写

这一节要做什么

实现”写单个线圈”,对应功能码 05。用于把某一个线圈打开或关闭。

  • 发送:从站地址 + 05 + 线圈地址(2) + 写入值(2) + CRC;
  • 写入值:FF 00 表示开,00 00 表示关;
  • 从站收到后”原报文返回”。
写单线圈完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void WriteSingleCoil(byte slaveAddr, ushort coilAddr, bool value)
{
byte[] sendData = new byte[8];
sendData[0] = slaveAddr;
sendData[1] = 0x05; // 功能码 05
sendData[2] = (byte)(coilAddr >> 8); // 线圈地址高字节
sendData[3] = (byte)(coilAddr & 0xFF); // 线圈地址低字节
sendData[4] = value ? (byte)0xFF : (byte)0x00; // 开 = FF 00,关 = 00 00
sendData[5] = 0x00;

byte[] crc = CRC16(sendData, 6);
sendData[6] = crc[0];
sendData[7] = crc[1];

SendAndReceive(sendData);
}

逐段讲解

唯一的新东西是这一行:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void WriteSingleRegister(byte slaveAddr, ushort regAddr, ushort value)
{
byte[] sendData = new byte[8];
sendData[0] = slaveAddr;
sendData[1] = 0x06; // 功能码 06
sendData[2] = (byte)(regAddr >> 8); // 寄存器地址高字节
sendData[3] = (byte)(regAddr & 0xFF); // 寄存器地址低字节
sendData[4] = (byte)(value >> 8); // 值的高字节
sendData[5] = (byte)(value & 0xFF); // 值的低字节

byte[] crc = CRC16(sendData, 6);
sendData[6] = crc[0];
sendData[7] = crc[1];

SendAndReceive(sendData);
}

逐段讲解

和写单线圈几乎一样,区别两处:

  1. 功能码是 0x06
  2. 写入值是一个 16 位 ushort,要拆成两个字节:value >> 8 是高字节,value & 0xFF 是低字节。

举例:要把值改成 200(十六进制 0x00C8),200 >> 80x00200 & 0xFF0xC8,拼出的报文就是 ... 00 C8 ...,和第二部分 2.7 的案例一模一样。

“写单个”的两个方法(05 和 06)套路完全一致,只是功能码和”写入值”拆法不同。Modbus 就是这么规整。


3.8 写入多输出线圈方法编写

这一节要做什么

实现”写多个线圈”,对应功能码 0F。用于一次性把一串线圈全部设置好。

  • 发送:从站地址 + 0F + 起始地址(2) + 数量(2) + 字节计数(1) + 数据 + CRC;
  • “数据”是把线圈状态按”低位在前”打包成的字节。
写多线圈完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public void WriteMultiCoils(byte slaveAddr, ushort startAddr, bool[] values)
{
int count = values.Length;
int byteCount = (count + 7) / 8; // 线圈打包需要几个字节,向上取整

// 报文长度 = 地址1 + 功能码1 + 起始地址2 + 数量2 + 字节计数1 + 数据 + CRC2
byte[] sendData = new byte[9 + byteCount];
sendData[0] = slaveAddr;
sendData[1] = 0x0F; // 功能码 0F
sendData[2] = (byte)(startAddr >> 8);
sendData[3] = (byte)(startAddr & 0xFF);
sendData[4] = (byte)(count >> 8);
sendData[5] = (byte)(count & 0xFF);
sendData[6] = (byte)byteCount; // 字节计数

// 把每个线圈状态按"低位在前"打包进字节
for (int i = 0; i < count; i++)
{
if (values[i])
sendData[7 + i / 8] |= (byte)(1 << (i % 8)); // 置 1
}

byte[] crc = CRC16(sendData, 7 + byteCount);
sendData[7 + byteCount] = crc[0];
sendData[8 + byteCount] = crc[1];

SendAndReceive(sendData);
}

逐段讲解

第一处新东西:算字节计数。

1
int byteCount = (count + 7) / 8;

8 个线圈打包 1 字节。线圈数不是 8 的整数倍时,要多一个字节。(count + 7) / 8 是”向上取整”的写法。你代两个数验证:8 个线圈 (8+7)/8 = 1;10 个线圈 (10+7)/8 = 2

第二处新东西:把线圈打包进字节。

1
2
3
4
5
for (int i = 0; i < count; i++)
{
if (values[i])
sendData[7 + i / 8] |= (byte)(1 << (i % 8));
}

这段和读线圈时的解析是”反过程”: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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public void WriteMultiRegisters(byte slaveAddr, ushort startAddr, ushort[] values)
{
int count = values.Length;
int byteCount = count * 2; // 每个寄存器 2 个字节

byte[] sendData = new byte[9 + byteCount];
sendData[0] = slaveAddr;
sendData[1] = 0x10; // 功能码 10
sendData[2] = (byte)(startAddr >> 8);
sendData[3] = (byte)(startAddr & 0xFF);
sendData[4] = (byte)(count >> 8);
sendData[5] = (byte)(count & 0xFF);
sendData[6] = (byte)byteCount; // 字节计数

// 每个寄存器高字节在前
for (int i = 0; i < count; i++)
{
sendData[7 + i * 2] = (byte)(values[i] >> 8); // 高字节
sendData[8 + i * 2] = (byte)(values[i] & 0xFF); // 低字节
}

byte[] crc = CRC16(sendData, 7 + byteCount);
sendData[7 + byteCount] = crc[0];
sendData[8 + byteCount] = crc[1];

SendAndReceive(sendData);
}

逐段讲解

和写多线圈结构几乎一样,区别在于:

  1. 功能码是 0x10
  2. 字节计数是 count * 2(每个寄存器 2 字节);
  3. 打包数据时,每个寄存器拆成高、低两个字节,按”高字节在前”填入。

到这里,8 个标准功能码的方法就全写完了。你会发现它们骨架高度一致:先拼字节数组,再算 CRC,最后 SendAndReceive 一发。 这就是 Modbus 简单规整的地方。


3.10 写入寄存器某个位方法编写

这一节要做什么

实现一个”进阶小技巧”:只修改某个寄存器的某一位,其它位保持不变。

为什么单独写这个方法

实际项目里,一个寄存器的 16 位,每一位可能代表不同开关(第 0 位是”启动”,第 1 位是”急停”)。想单独控制”启动”又不能搞乱其它位。而 Modbus 写寄存器最少也是写”整个寄存器”,所以要只改一位,得用三步套路:

  1. :先把寄存器当前值读出来;
  2. :在内存里只改我们关心的那一位;
  3. :把改好的值写回去。

这个套路叫”读、改、写”,是位操作的经典思路。

写寄存器某位完整代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void WriteRegisterBit(byte slaveAddr, ushort regAddr, int bitIndex, bool value)
{
// 第一步:先把寄存器当前的值读出来(读 1 个,取数组第 0 个元素)
ushort current = ReadOutputRegister(slaveAddr, regAddr, 1)[0];

// 第二步:只修改我们关心的那一位,其它位保持不变
if (value)
current |= (ushort)(1 << bitIndex); // 把第 bitIndex 位置成 1
else
current &= (ushort)~(1 << bitIndex); // 把第 bitIndex 位置成 0

// 第三步:把改好的值写回去
WriteSingleRegister(slaveAddr, regAddr, current);
}

逐段讲解

第一步:读。 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
using System;
using System.IO.Ports; // 串口相关
using ModbusRTULib; // 我们写的库

class Program
{
static void Main(string[] args)
{
// 创建一个 Modbus 通信对象
ModbusRTU modbus = new ModbusRTU();

// 打开串口。COM3 和参数要改成你自己的实际情况。
// 串口号、波特率、校验位、数据位、停止位,要和从站设备完全一致。
modbus.Open("COM3", 9600, Parity.None, 8, StopBits.One);

Console.WriteLine("串口已打开,开始读写演示...");

// 1. 读 1 号从站,从第 1 个保持寄存器开始,读 2 个
ushort[] regs = modbus.ReadOutputRegister(1, 0, 2);
Console.WriteLine("读到的寄存器值:" + regs[0] + ", " + regs[1]);

// 2. 读 1 号从站,从第 1 个输出线圈开始,读 8 个
bool[] coils = modbus.ReadOutputCoil(1, 0, 8);
Console.Write("读到的线圈状态:");
foreach (bool c in coils)
{
Console.Write(c ? "开 " : "关 ");
}
Console.WriteLine();

// 3. 写单个线圈:把 1 号从站第 2 个线圈打开
modbus.WriteSingleCoil(1, 1, true);

// 4. 写单个寄存器:把 1 号从站第 1 个寄存器改成 200
modbus.WriteSingleRegister(1, 0, 200);

// 5. 写多个寄存器:把 1 号从站从第 1 个寄存器开始的 3 个寄存器都改成 100
modbus.WriteMultiRegisters(1, 0, new ushort[] { 100, 100, 100 });

// 6. 写寄存器某一位:把第 1 个寄存器的第 0 位(最低位)置 1
modbus.WriteRegisterBit(1, 0, 0, true);

Console.WriteLine("演示结束,关闭串口。");

// 用完记得关闭串口
modbus.Close();
}
}

使用步骤,手把手带你走

  1. 新建对象:ModbusRTU modbus = new ModbusRTU();new 就是”造一个出来”。
  2. 打开串口:modbus.Open("COM3", 9600, Parity.None, 8, StopBits.One);。参数必须和从站设备一致。
  3. 调用读写方法:比如 ReadOutputRegister(1, 0, 2) 表示”读 1 号从站、从第 1 个寄存器开始、读 2 个”,返回一队数字。
  4. 用完关闭:modbus.Close();

点 Visual Studio 顶部的绿色”启动”按钮(或按 F5),程序会编译并运行,控制台窗口里会打印读到的数据。前提是你有真实从站,或用 Modbus Slave 软件模拟一个从站,否则收不到数据。

常见报错排查

多半是没装 System.IO.Ports 包,或者文件顶部没写 using System.IO.Ports;。检查这两点。

检查 COM 口号对不对(设备管理器里看),以及串口是不是被别的程序占用了。

从站地址、波特率、校验位等参数和从站不一致,或者根本没有从站回应。先用串口调试助手单独确认链路通不通。


第三部分小结

这一部分我们完成了一件大事:把 Modbus RTU 通信逻辑,从理论变成了能跑的 C# 代码。

  1. 通信库的整体结构:串口基础类(开串口)+ 读写接口(列清单)+ 通信主类(干实事);
  2. 必要的 C# 概念:byte、ushort、bool、数组、类和方法、接口、继承;
  3. 位运算的用法,以及如何”拆字节、合字节、置位、清零”;
  4. 8 个功能码的读写方法,骨架高度一致:拼字节、算 CRC、发送接收;
  5. 进阶技巧”读、改、写”,实现”只修改寄存器的某一位”。

我能自己新建一个控制台项目

我装了 System.IO.Ports 包

我能解释 byte、ushort、bool 和数组

我能解释”拆字节”用 >> 和 &,”合字节”用 << 和 |

我照着笔记敲出了 CRC16 方法

我照着笔记敲出了读线圈和读寄存器方法

我理解”写单”和”写多”报文的区别

我理解”读、改、写”三步修改某一位

到这里,你的 Modbus RTU 通信库已经成型了。下一部分,我们要给它配上一个图形界面:用 WinForm 做一个串口助手,让你在窗口里点按钮、看数据,真正把这个库用起来,做成一个能用的上位机小程序。

如果你在跑代码的过程中遇到任何报错,把报错信息发给我,我陪你一起查。