前一部分我们写好了一个 Modbus RTU 通信库,但那个库是”没有脸”的——你只能在代码里写死参数去调用,看不到也摸不着。这一部分,我们给它配上一张”脸”:做一个图形界面软件,让你在窗口里点按钮、看数据,真正体验”上位机”是什么感觉。

这一部分用的是 WinForm(而不是 WPF)。WinForm 的特点是”拖控件 + 写代码”,不需要写 XAML,非常适合刚入门的你。我们一边拖控件设计界面,一边写事件代码,把这个测试软件一点点做出来。

  1. 先新建一个 WinForm 项目;
  2. 把上一部分的通信库加进来(直接复用,不用重写);
  3. 拖控件设计界面(4.1);
  4. 写”参数初始化 + 串口连接”代码(4.2);
  5. 逐个功能写测试代码(4.3 到 4.9);
  6. 用 Modbus Slave 模拟从站,跑起来看效果。
  • Visual Studio(推荐 2022);
  • 已经会一点第 3 部分的内容(知道怎么开串口、怎么调读写方法);
  • 最好装一个 Modbus Slave 软件,用来模拟从站,方便测试。

准备工作:新建 WinForm 项目

第一步:用 Visual Studio 新建项目

  1. 打开 Visual Studio,点击”创建新项目”;
  2. 搜索”窗体”,选择”Windows 窗体应用“(语言选 C#,框架选 .NET 8 或更高);
  3. 项目名称填 ModbusRTUTest,位置选你找得到的地方,点”创建”。

创建完成后,你会看到设计器里有一个空白窗体(默认叫 Form1),这就是我们软件的主窗口。

第二步:安装串口依赖包

和第 3 部分一样,需要装 System.IO.Ports:

  1. 右键项目名 ModbusRTUTest;
  2. 选择”管理 NuGet 程序包”;
  3. 浏览搜 System.IO.Ports,安装。

第三步:把通信库加进来

第 3 部分我们已经写好了三个库文件(SerialBase.cs、IModbusRW.cs、ModbusRTU.cs),现在直接复用,不用重写:

  1. 右键项目名 ModbusRTUTest;
  2. 选择”添加”,然后”现有项“;
  3. 找到第 3 部分项目文件夹里的这三个文件,选中加进来。

你的项目里就有了开串口、读线圈、读寄存器等全部能力。接下来我们只需要专心做界面,用这些能力就行。

第四步:认识 WinForm 的三个窗口

左边是”工具箱”,里面装满了各种控件(按钮、文本框、下拉框等),我们要拖的就是它们。

中间是”窗体设计器”,显示窗体的样子,拖进去的控件会实时显示。

右边是”属性”窗口,选中某个控件后,在这里改它的名字、显示文字、大小等。


4.1 Modbus RTU 软件 UI 界面设计

这一节要做什么

设计软件的界面。我们的界面分三块:顶部”串口连接”、中间”功能测试”(7 个标签页)、底部”日志”。

整体布局

区域 控件 作用
顶部 GroupBox(串口连接) 放串口号、波特率等参数,以及打开/关闭按钮
中间 TabControl(7 个标签页) 每个功能一个页,互不干扰
底部 GroupBox(日志) 显示操作记录

认识我们要用的几种控件

Label:标签,就是一行说明文字,比如”串口号”。

ComboBox:下拉框,点一下能选一个选项,比如选波特率。

TextBox:文本框,用来输入内容,比如输入从站地址、寄存器地址。

Button:按钮,点一下触发一个动作,比如”打开串口”、”读取”。

GroupBox:分组框,一个带标题的框,把一组相关控件框起来。

TabControl:标签页容器,顶部有一排标签,点不同标签切换不同页面。

手把手:拖控件、设属性

下面以”串口连接区”为例,演示完整的拖控件流程。其它区域的做法完全一样,照着类推即可。

第一步:拖一个 GroupBox。

在工具箱里找到 GroupBox(在”容器”分类下),按住鼠标左键把它拖到窗体上,松开。它的默认标题是 groupBox1,我们在属性窗口把它的 Text 改成”串口连接”。

第二步:拖 Label 和 ComboBox。

  • 拖一个 Label 到 GroupBox 里,属性 Text 改成”串口号”,Name 改成 lblPort;
  • 拖一个 ComboBox 到它旁边,Name 改成 cboPort。

第三步:照这个套路拖完其它参数。

依次拖出:波特率、校验位、数据位、停止位、从站地址,一共 5 组 Label 加输入控件。它们的 Name 建议按下面的清单来,这样后面写代码时一眼就知道是谁。

第四步:拖两个按钮。

拖两个 Button,Text 分别改成”打开串口”和”关闭串口”,Name 改成 btnOpen 和 btnClose。

  • Name:控件的”内部代号”,代码里用它来操作这个控件,比如 txtSlave.Text。这个必须自己起一个好记的名字。
  • Text:控件”显示出来的文字”,是给用户看的,比如”打开串口”。

新手最容易混淆这两个。记住:Name 是给代码用的,Text 是给人看的。

完整控件清单

下面是整个软件用到的所有控件,你可以照着这个清单一个个拖出来,并设置好 Name(Text 也照填)。这是本部分最重要的一张表,建议边拖边对照。

串口连接区(GroupBox:grpConnect)

控件类型 Name Text(默认显示) 用途
Label lblPort 串口号 说明文字
ComboBox cboPort 空 选串口号
Label lblBaud 波特率 说明文字
ComboBox cboBaud 空 选波特率
Label lblParity 校验位 说明文字
ComboBox cboParity 空 选校验位
Label lblDataBits 数据位 说明文字
ComboBox cboDataBits 空 选数据位
Label lblStopBits 停止位 说明文字
ComboBox cboStopBits 空 选停止位
Label lblSlave 从站地址 说明文字
TextBox txtSlave 1 填从站地址
Button btnOpen 打开串口 打开串口
Button btnClose 关闭串口 关闭串口

功能测试区(TabControl:tabMain,含 7 个 TabPage)

标签页 页内控件 Name Text 用途
读线圈 Label lblReadCoilAddr 起始地址 说明
读线圈 TextBox txtReadCoilAddr 0 起始地址
读线圈 Label lblReadCoilCount 数量 说明
读线圈 TextBox txtReadCoilCount 8 数量
读线圈 Button btnReadCoil 读取 执行读取
读线圈 TextBox txtReadCoilResult 空 显示结果(多行)
读寄存器 Label / TextBox / Button lblReadRegAddr / txtReadRegAddr / lblReadRegCount / txtReadRegCount / btnReadReg / txtReadRegResult 同读线圈 读寄存器
写单线圈 Label / TextBox lblWriteCoilAddr / txtWriteCoilAddr 线圈地址 地址
写单线圈 Label / ComboBox lblWriteCoilValue / cboWriteCoilValue 值 选开/关
写单线圈 Button btnWriteCoil 写入 执行写入
写多线圈 Label / TextBox lblWriteCoilsAddr / txtWriteCoilsAddr 起始地址 地址
写多线圈 Label / TextBox lblWriteCoilsValues / txtWriteCoilsValues 值(逗号分隔) 值
写多线圈 Button btnWriteCoils 写入 执行写入
写单寄存器 Label / TextBox / Button lblWriteRegAddr / txtWriteRegAddr / lblWriteRegValue / txtWriteRegValue / btnWriteReg 寄存器地址 / 值 / 写入 写单寄存器
写多寄存器 Label / TextBox / Button lblWriteRegsAddr / txtWriteRegsAddr / lblWriteRegsValues / txtWriteRegsValues / btnWriteRegs 起始地址 / 值(逗号分隔) / 写入 写多寄存器
写寄存器位 Label / TextBox lblWriteBitAddr / txtWriteBitAddr 寄存器地址 地址
写寄存器位 Label / TextBox lblWriteBitIndex / txtWriteBitIndex 位号(0-15) 第几位
写寄存器位 Label / ComboBox lblWriteBitValue / cboWriteBitValue 值 选开/关
写寄存器位 Button btnWriteBit 写入 执行写入

日志区(GroupBox:grpLog)

控件类型 Name Text 用途
TextBox txtLog 空 显示日志(多行、只读)

读线圈、读寄存器的结果框,以及日志框,都要设成”多行只读”:选中控件,在属性窗口里把 Multiline 设为 True、ReadOnly 设为 True、ScrollBars 设为 Vertical。这样它们只显示内容,不能被用户乱改,内容多了还能滚动。

TabControl 默认没有标签页,需要点它右上角的小三角,选”添加选项卡”来添加 TabPage,并把每个 TabPage 的 Text 改成”读线圈””读寄存器”等。标签页里的控件要拖到对应的页里,而不是拖到窗体空白处。

如果你觉得拖控件太繁琐,我也准备了一份”参考答案”:把整份界面代码(Form1.Designer.cs)放在了项目里,你直接看它就知道每个控件长什么样、摆在哪里。但强烈建议你先自己拖一遍,拖控件是 WinForm 的基本功。


界面:

image-20260829143903489


4.2 通信参数初始化及串口连接

界面搭好了,现在开始写代码。这一节做两件事:一是给界面上的下拉框填好选项和默认值(参数初始化),二是实现”打开/关闭串口”。

先引入库和通信对象

打开 Form1.cs,在文件顶部加一句 using ModbusRTULib;,然后在类里声明一个通信对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using System;
using System.IO.Ports;
using System.Windows.Forms;
using ModbusRTULib; // 引入我们的通信库

namespace ModbusRTUTest;

public partial class Form1 : Form
{
// 通信对象,后面所有操作都靠它
private ModbusRTU modbus = new ModbusRTU();

public Form1()
{
InitializeComponent();
InitControls(); // 初始化参数
}
}

private ModbusRTU modbus = new ModbusRTU(); 是造一个通信对象。以后所有读写,都是 modbus.某个方法(...) 这样调用。这个对象是整个软件的”发动机”。

写参数初始化方法 InitControls

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
private void InitControls()
{
// 波特率选项
cboBaud.Items.AddRange(new object[] { "9600", "19200", "38400", "57600", "115200" });
cboBaud.SelectedIndex = 0; // 默认选第一项 9600

// 校验位选项
cboParity.Items.AddRange(new object[] { "None", "Odd", "Even" });
cboParity.SelectedIndex = 0;

// 数据位选项
cboDataBits.Items.AddRange(new object[] { "8", "7" });
cboDataBits.SelectedIndex = 0;

// 停止位选项
cboStopBits.Items.AddRange(new object[] { "1", "2" });
cboStopBits.SelectedIndex = 0;

// 串口号:动态获取当前电脑有哪些串口
cboPort.Items.Clear();
string[] ports = SerialPort.GetPortNames();
if (ports.Length > 0)
{
cboPort.Items.AddRange(ports);
cboPort.SelectedIndex = 0;
}

// 写单线圈 / 写寄存器位的"值"下拉框
cboWriteCoilValue.Items.AddRange(new object[] { "开", "关" });
cboWriteCoilValue.SelectedIndex = 0;
cboWriteBitValue.Items.AddRange(new object[] { "开", "关" });
cboWriteBitValue.SelectedIndex = 0;
}

逐段讲解:

  • cboBaud.Items.AddRange(...):给下拉框”塞”进去几个选项。Items 是下拉框的选项集合,AddRange 是一次性加一串。
  • cboBaud.SelectedIndex = 0:默认选中第 0 项(第一项)。
  • SerialPort.GetPortNames():这是系统接口,能自动查出电脑上现在有哪些串口,比我们写死 COM1、COM2 更聪明。查出来填进 cboPort,用户直接选就行。

串口号不是固定的,插不同的 USB 转串口线,串口号会变。用 GetPortNames() 自动列出来,用户就不会选错。这是上位机软件的常见做法。

写”打开串口”和”关闭串口”

回到设计器,双击”打开串口”按钮,Visual Studio 会自动生成一个事件方法 btnOpen_Click。在里面写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private void btnOpen_Click(object sender, EventArgs e)
{
try
{
// 从下拉框读参数
string port = cboPort.Text;
int baud = Convert.ToInt32(cboBaud.Text);
Parity parity = (Parity)Enum.Parse(typeof(Parity), cboParity.Text);
int dataBits = Convert.ToInt32(cboDataBits.Text);
StopBits stopBits = (StopBits)Enum.Parse(typeof(StopBits), cboStopBits.Text);

// 调用库打开串口
modbus.Open(port, baud, parity, dataBits, stopBits);

Log("串口 " + port + " 打开成功");
}
catch (Exception ex)
{
MessageBox.Show("打开失败:" + ex.Message);
}
}

逐段讲解:

  • cboPort.Text:取下拉框当前选中的文字(就是串口号)。
  • Convert.ToInt32(cboBaud.Text):把”9600”这样的文字转成数字 9600。
  • (Parity)Enum.Parse(typeof(Parity), cboParity.Text):把”None”这样的文字转成枚举值 Parity.None。Enum.Parse 是”把文字转成枚举”,前面加 (Parity) 是类型转换。
  • modbus.Open(...):调用我们库里的开串口方法。
  • try catch:万一出错(比如串口被占用),不崩溃,而是弹窗提示。

“关闭串口”按钮同理,双击它写:

1
2
3
4
5
6
7
8
9
10
11
12
private void btnClose_Click(object sender, EventArgs e)
{
try
{
modbus.Close();
Log("串口已关闭");
}
catch (Exception ex)
{
MessageBox.Show("关闭失败:" + ex.Message);
}
}

写一个日志辅助方法

后面每个功能都要往日志框写一行字,所以先写一个 Log 方法复用:

1
2
3
4
private void Log(string msg)
{
txtLog.AppendText(msg + "\r\n");
}

AppendText 是”在文本框末尾追加文字”,\r\n 是换行。

写一个取从站地址的辅助方法

每个功能都要读”从站地址”输入框,并且要检查它合不合法(1 到 247)。写一个公共方法,避免每个按钮里重复写:

1
2
3
4
5
6
7
8
9
10
private bool GetSlaveAddr(out byte slaveAddr)
{
slaveAddr = 0;
if (!byte.TryParse(txtSlave.Text.Trim(), out slaveAddr) || slaveAddr < 1 || slaveAddr > 247)
{
MessageBox.Show("从站地址不对,请填 1 到 247 之间的数字");
return false;
}
return true;
}

逐段讲解:

  • byte.TryParse(文字, out slaveAddr):尝试把文字转成 byte,成功就 true,并把结果放进 slaveAddr;失败就 false。
  • out:表示”这个方法会把结果传出来”。这是 C# 里返回多个值的一种方式。
  • 后面判断范围 1 到 247,不合法就弹窗并返回 false。

Log 和 GetSlaveAddr 都是”会被反复用到的小工具”。把它们单独写成方法,后面 7 个功能测试里直接调用,代码就干净多了,不用复制粘贴几十遍。


4.3 读取输入输出线圈方法测试

这一节要做什么

在”读线圈”标签页实现:用户填好起始地址和数量,点”读取”,就把线圈状态读出来显示在结果框里。

完整代码

双击”读线圈”页里的”读取”按钮,写:

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
private void btnReadCoil_Click(object sender, EventArgs e)
{
try
{
// 1. 取从站地址,不合法就退出
if (!GetSlaveAddr(out byte slave)) return;

// 2. 取起始地址和数量
ushort addr = Convert.ToUInt16(txtReadCoilAddr.Text.Trim());
ushort count = Convert.ToUInt16(txtReadCoilCount.Text.Trim());

// 3. 调用库读取输出线圈
bool[] result = modbus.ReadOutputCoil(slave, addr, count);

// 4. 把结果拼成 0/1,每 8 个换一行,方便看
string text = "";
for (int i = 0; i < result.Length; i++)
{
text += result[i] ? "1" : "0";
if ((i + 1) % 8 == 0) text += "\r\n";
}
txtReadCoilResult.Text = text;

// 5. 记日志
Log("读线圈成功:从站 " + slave + ",地址 " + addr + ",数量 " + count);
}
catch (Exception ex)
{
MessageBox.Show("读取失败:" + ex.Message);
}
}

逐段讲解

  • Convert.ToUInt16(...):把文字转成 ushort 类型(16 位无符号整数)。
  • modbus.ReadOutputCoil(slave, addr, count):调用库方法,返回一个 bool 数组,每个元素是”开(true)或关(false)”。
  • 显示时把 true 拼成 “1”、false 拼成 “0”,每 8 个换一行,看起来清楚。(i + 1) % 8 == 0 表示”每满 8 个就换行”。

ReadOutputCoil 对应功能码 01(读输出线圈)。如果你想测输入线圈(功能码 02),把方法换成 modbus.ReadInputCoil(slave, addr, count) 就行,其它一模一样。我们界面上这个按钮默认测输出线圈,方便演示。


4.4 读取输入输出寄存器方法测试

这一节要做什么

在”读寄存器”页,读保持寄存器的值,显示在结果框。

完整代码

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
private void btnReadReg_Click(object sender, EventArgs e)
{
try
{
if (!GetSlaveAddr(out byte slave)) return;
ushort addr = Convert.ToUInt16(txtReadRegAddr.Text.Trim());
ushort count = Convert.ToUInt16(txtReadRegCount.Text.Trim());

// 调用库读取保持寄存器
ushort[] result = modbus.ReadOutputRegister(slave, addr, count);

// 逐行显示每个寄存器的值
string text = "";
for (int i = 0; i < result.Length; i++)
{
text += "寄存器[" + (addr + i) + "] = " + result[i] + "\r\n";
}
txtReadRegResult.Text = text;

Log("读寄存器成功:从站 " + slave + ",地址 " + addr + ",数量 " + count);
}
catch (Exception ex)
{
MessageBox.Show("读取失败:" + ex.Message);
}
}

逐段讲解

和读线圈几乎一样,区别在于:

  • 方法换成 ReadOutputRegister(对应功能码 03);
  • 返回的是 ushort[](一队数字);
  • 显示时把每个寄存器连地址一起打印,比如”寄存器[5] = 100”,更直观。

想测输入寄存器(功能码 04),换成 modbus.ReadInputRegister(slave, addr, count)。


4.5 写入单输出线圈方法测试

这一节要做什么

在”写单线圈”页,把某个线圈打开或关闭。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void btnWriteCoil_Click(object sender, EventArgs e)
{
try
{
if (!GetSlaveAddr(out byte slave)) return;
ushort addr = Convert.ToUInt16(txtWriteCoilAddr.Text.Trim());
bool value = cboWriteCoilValue.Text == "开"; // 下拉框选"开"就是 true

// 调用库写单个线圈
modbus.WriteSingleCoil(slave, addr, value);

Log("写单线圈成功:从站 " + slave + ",地址 " + addr + ",值 " + (value ? "开" : "关"));
}
catch (Exception ex)
{
MessageBox.Show("写入失败:" + ex.Message);
}
}

逐段讲解

  • cboWriteCoilValue.Text == "开":下拉框选的是”开”还是”关”,转成 bool(开是 true,关是 false)。
  • modbus.WriteSingleCoil(slave, addr, value):调用库方法写入。
  • 写操作没有返回值,所以成功后写一行日志即可。

4.6 写入多输出线圈方法测试

这一节要做什么

在”写多线圈”页,一次性写一串线圈。值用逗号分隔的 0/1 输入,比如”1,0,1,0,1,0,1,0”。

完整代码

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
private void btnWriteCoils_Click(object sender, EventArgs e)
{
try
{
if (!GetSlaveAddr(out byte slave)) return;
ushort addr = Convert.ToUInt16(txtWriteCoilsAddr.Text.Trim());

// 把 "1,0,1,0" 这样的字符串拆成 bool 数组
string[] parts = txtWriteCoilsValues.Text.Split(',');
bool[] values = new bool[parts.Length];
for (int i = 0; i < parts.Length; i++)
{
values[i] = parts[i].Trim() == "1";
}

// 调用库写多个线圈
modbus.WriteMultiCoils(slave, addr, values);

Log("写多线圈成功:从站 " + slave + ",地址 " + addr + ",数量 " + values.Length);
}
catch (Exception ex)
{
MessageBox.Show("写入失败:" + ex.Message);
}
}

逐段讲解

这段唯一的新东西是”把字符串拆成数组”:

  • txtWriteCoilsValues.Text.Split(','):按逗号把字符串切开,得到一串小字符串,比如”1,0,1”切成”1”、”0”、”1”。
  • 循环里 parts[i].Trim() == "1":去掉空格后判断是不是”1”,是就是 true,否则 false。

Split 是”拆分”的意思,是处理”逗号分隔”这类输入的常用方法。写多寄存器那里还会用到它。


4.7 写入单个寄存器方法测试

这一节要做什么

在”写单寄存器”页,把某个寄存器的值改成指定的数字。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void btnWriteReg_Click(object sender, EventArgs e)
{
try
{
if (!GetSlaveAddr(out byte slave)) return;
ushort addr = Convert.ToUInt16(txtWriteRegAddr.Text.Trim());
ushort value = Convert.ToUInt16(txtWriteRegValue.Text.Trim());

// 调用库写单个寄存器
modbus.WriteSingleRegister(slave, addr, value);

Log("写单寄存器成功:从站 " + slave + ",地址 " + addr + ",值 " + value);
}
catch (Exception ex)
{
MessageBox.Show("写入失败:" + ex.Message);
}
}

逐段讲解

和写单线圈几乎一样,区别在于:写入的值是一个数字(ushort),直接从文本框转过来即可,不需要”开/关”下拉框。


4.8 写入多个寄存器方法测试

这一节要做什么

在”写多寄存器”页,一次性写多个寄存器。值用逗号分隔的数字输入,比如”100,200,300”。

完整代码

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
private void btnWriteRegs_Click(object sender, EventArgs e)
{
try
{
if (!GetSlaveAddr(out byte slave)) return;
ushort addr = Convert.ToUInt16(txtWriteRegsAddr.Text.Trim());

// 把 "100,200,300" 这样的字符串拆成 ushort 数组
string[] parts = txtWriteRegsValues.Text.Split(',');
ushort[] values = new ushort[parts.Length];
for (int i = 0; i < parts.Length; i++)
{
values[i] = Convert.ToUInt16(parts[i].Trim());
}

// 调用库写多个寄存器
modbus.WriteMultiRegisters(slave, addr, values);

Log("写多寄存器成功:从站 " + slave + ",地址 " + addr + ",数量 " + values.Length);
}
catch (Exception ex)
{
MessageBox.Show("写入失败:" + ex.Message);
}
}

逐段讲解

和写多线圈结构一样,区别是:每个小字符串 parts[i] 用 Convert.ToUInt16 转成数字(而不是判断是否等于”1”)。


4.9 写入寄存器位地址方法测试

这一节要做什么

在”写寄存器位”页,只修改某个寄存器的某一位(0 到 15),其它位保持不变。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void btnWriteBit_Click(object sender, EventArgs e)
{
try
{
if (!GetSlaveAddr(out byte slave)) return;
ushort addr = Convert.ToUInt16(txtWriteBitAddr.Text.Trim());
int bitIndex = Convert.ToInt32(txtWriteBitIndex.Text.Trim());
bool value = cboWriteBitValue.Text == "开";

// 调用库写寄存器某一位(内部是"读-改-写")
modbus.WriteRegisterBit(slave, addr, bitIndex, value);

Log("写寄存器位成功:从站 " + slave + ",地址 " + addr + ",第 " + bitIndex + " 位 = " + (value ? "1" : "0"));
}
catch (Exception ex)
{
MessageBox.Show("写入失败:" + ex.Message);
}
}

逐段讲解

  • 输入有三样:寄存器地址、位号(0 到 15)、值(开/关)。
  • modbus.WriteRegisterBit(slave, addr, bitIndex, value):调用库方法。还记得第 3 部分吗?这个方法内部是”先读、再改位、再写回”,所以我们界面上只管传”改哪一位、改成什么”,剩下的它自己搞定。

到这里,读线圈、读寄存器、写单线圈、写多线圈、写单寄存器、写多寄存器、写寄存器位,7 个功能测试全部写完。整个测试软件就成型了。


运行测试:用 Modbus Slave 模拟从站

软件写好了,但”读”需要有设备回应才有数据。手头没有真实 PLC 也没关系,用 Modbus Slave 软件模拟一个从站即可。

测试步骤

  1. 打开 Modbus Slave 软件,建立一个从站(比如站号 1,功能码 03 读保持寄存器);
  2. 在 Modbus Slave 里设置几个寄存器,填上一些值(比如地址 0 填 100,地址 1 填 200);
  3. 如果用串口,先用 VSPD 虚拟一对串口(比如 COM3 和 COM4),Modbus Slave 连 COM4,我们的软件连 COM3;
  4. 回到我们自己的软件,选好串口和参数,点”打开串口”;
  5. 切到”读寄存器”页,从站地址填 1,起始地址 0,数量 2,点”读取”,结果框里应该出现 100 和 200;
  6. 再切到”写单寄存器”页,把地址 0 的值改成 300,点”写入”,然后回到 Modbus Slave 看,那个值已经变成 300 了。

当你在自己写的软件里点一下”读取”,结果框里蹦出真实的寄存器数值时,你就完整走通了”上位机软件”的全过程:界面、串口、协议、数据,全都打通了。


第四部分小结

这一部分我们把通信库真正用了起来,做出了一个有界面的 Modbus RTU 测试软件。

  1. 怎么新建 WinForm 项目,怎么拖控件、设属性;
  2. Name(给代码用)和 Text(给人看)的区别;
  3. 用 ComboBox 做参数选择,用 SerialPort.GetPortNames 动态获取串口;
  4. 用 try catch 处理异常,避免程序崩溃;
  5. 把界面输入(文字)转成程序需要的数据类型(数字、bool、数组);
  6. 把上一部分的库方法接上按钮事件,做出完整功能。

我能新建一个 WinForm 项目并装好 System.IO.Ports

我能把上一部分的 3 个库文件加进新项目

我能拖控件并设置 Name 和 Text

我理解 InitControls 是干嘛的

我能解释 try catch 的作用

我照着写完了 7 个功能的按钮事件

我能用 Modbus Slave 模拟从站并读到数据

这一部分我们做的是”测试软件”,界面还比较朴素,输入要靠手动填。下一部分(或后续阶段),我们会做一个更实用的”串口助手”或”数据看板”,加入定时自动读取、数据显示、甚至曲线图表,让上位机软件真正”用起来”。有任何一步卡住,把报错发给我,我陪你一起查。