04-Modbus RTU通信测试软件开发
前一部分我们写好了一个 Modbus RTU 通信库,但那个库是”没有脸”的——你只能在代码里写死参数去调用,看不到也摸不着。这一部分,我们给它配上一张”脸”:做一个图形界面软件,让你在窗口里点按钮、看数据,真正体验”上位机”是什么感觉。
这一部分用的是 WinForm(而不是 WPF)。WinForm 的特点是”拖控件 + 写代码”,不需要写 XAML,非常适合刚入门的你。我们一边拖控件设计界面,一边写事件代码,把这个测试软件一点点做出来。
- 先新建一个 WinForm 项目;
- 把上一部分的通信库加进来(直接复用,不用重写);
- 拖控件设计界面(4.1);
- 写”参数初始化 + 串口连接”代码(4.2);
- 逐个功能写测试代码(4.3 到 4.9);
- 用 Modbus Slave 模拟从站,跑起来看效果。
- Visual Studio(推荐 2022);
- 已经会一点第 3 部分的内容(知道怎么开串口、怎么调读写方法);
- 最好装一个 Modbus Slave 软件,用来模拟从站,方便测试。
准备工作:新建 WinForm 项目
第一步:用 Visual Studio 新建项目
- 打开 Visual Studio,点击”创建新项目”;
- 搜索”窗体”,选择”Windows 窗体应用“(语言选 C#,框架选 .NET 8 或更高);
- 项目名称填
ModbusRTUTest,位置选你找得到的地方,点”创建”。
创建完成后,你会看到设计器里有一个空白窗体(默认叫 Form1),这就是我们软件的主窗口。
第二步:安装串口依赖包
和第 3 部分一样,需要装 System.IO.Ports:
- 右键项目名
ModbusRTUTest; - 选择”管理 NuGet 程序包”;
- 浏览搜
System.IO.Ports,安装。
第三步:把通信库加进来
第 3 部分我们已经写好了三个库文件(SerialBase.cs、IModbusRW.cs、ModbusRTU.cs),现在直接复用,不用重写:
- 右键项目名
ModbusRTUTest; - 选择”添加”,然后”现有项“;
- 找到第 3 部分项目文件夹里的这三个文件,选中加进来。
你的项目里就有了开串口、读线圈、读寄存器等全部能力。接下来我们只需要专心做界面,用这些能力就行。
第四步:认识 WinForm 的三个窗口
4.1 Modbus RTU 软件 UI 界面设计
这一节要做什么
设计软件的界面。我们的界面分三块:顶部”串口连接”、中间”功能测试”(7 个标签页)、底部”日志”。
整体布局
| 区域 | 控件 | 作用 |
|---|---|---|
| 顶部 | GroupBox(串口连接) | 放串口号、波特率等参数,以及打开/关闭按钮 |
| 中间 | TabControl(7 个标签页) | 每个功能一个页,互不干扰 |
| 底部 | GroupBox(日志) | 显示操作记录 |
认识我们要用的几种控件
手把手:拖控件、设属性
下面以”串口连接区”为例,演示完整的拖控件流程。其它区域的做法完全一样,照着类推即可。
第一步:拖一个 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 的基本功。
界面:

4.2 通信参数初始化及串口连接
界面搭好了,现在开始写代码。这一节做两件事:一是给界面上的下拉框填好选项和默认值(参数初始化),二是实现”打开/关闭串口”。
先引入库和通信对象
打开 Form1.cs,在文件顶部加一句 using ModbusRTULib;,然后在类里声明一个通信对象:
1 | using System; |
private ModbusRTU modbus = new ModbusRTU(); 是造一个通信对象。以后所有读写,都是 modbus.某个方法(...) 这样调用。这个对象是整个软件的”发动机”。
写参数初始化方法 InitControls
1 | private void InitControls() |
逐段讲解:
cboBaud.Items.AddRange(...):给下拉框”塞”进去几个选项。Items是下拉框的选项集合,AddRange是一次性加一串。cboBaud.SelectedIndex = 0:默认选中第 0 项(第一项)。SerialPort.GetPortNames():这是系统接口,能自动查出电脑上现在有哪些串口,比我们写死 COM1、COM2 更聪明。查出来填进cboPort,用户直接选就行。
串口号不是固定的,插不同的 USB 转串口线,串口号会变。用 GetPortNames() 自动列出来,用户就不会选错。这是上位机软件的常见做法。
写”打开串口”和”关闭串口”
回到设计器,双击”打开串口”按钮,Visual Studio 会自动生成一个事件方法 btnOpen_Click。在里面写:
1 | private void btnOpen_Click(object sender, EventArgs e) |
逐段讲解:
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 | private void btnClose_Click(object sender, EventArgs e) |
写一个日志辅助方法
后面每个功能都要往日志框写一行字,所以先写一个 Log 方法复用:
1 | private void Log(string msg) |
AppendText 是”在文本框末尾追加文字”,\r\n 是换行。
写一个取从站地址的辅助方法
每个功能都要读”从站地址”输入框,并且要检查它合不合法(1 到 247)。写一个公共方法,避免每个按钮里重复写:
1 | private bool GetSlaveAddr(out byte slaveAddr) |
逐段讲解:
byte.TryParse(文字, out slaveAddr):尝试把文字转成 byte,成功就 true,并把结果放进 slaveAddr;失败就 false。out:表示”这个方法会把结果传出来”。这是 C# 里返回多个值的一种方式。- 后面判断范围 1 到 247,不合法就弹窗并返回 false。
Log 和 GetSlaveAddr 都是”会被反复用到的小工具”。把它们单独写成方法,后面 7 个功能测试里直接调用,代码就干净多了,不用复制粘贴几十遍。
4.3 读取输入输出线圈方法测试
这一节要做什么
在”读线圈”标签页实现:用户填好起始地址和数量,点”读取”,就把线圈状态读出来显示在结果框里。
完整代码
双击”读线圈”页里的”读取”按钮,写:
1 | private void btnReadCoil_Click(object sender, EventArgs e) |
逐段讲解
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 | private void btnReadReg_Click(object sender, EventArgs e) |
逐段讲解
和读线圈几乎一样,区别在于:
- 方法换成
ReadOutputRegister(对应功能码 03); - 返回的是
ushort[](一队数字); - 显示时把每个寄存器连地址一起打印,比如”寄存器[5] = 100”,更直观。
想测输入寄存器(功能码 04),换成 modbus.ReadInputRegister(slave, addr, count)。
4.5 写入单输出线圈方法测试
这一节要做什么
在”写单线圈”页,把某个线圈打开或关闭。
完整代码
1 | private void btnWriteCoil_Click(object sender, EventArgs e) |
逐段讲解
cboWriteCoilValue.Text == "开":下拉框选的是”开”还是”关”,转成 bool(开是 true,关是 false)。modbus.WriteSingleCoil(slave, addr, value):调用库方法写入。- 写操作没有返回值,所以成功后写一行日志即可。
4.6 写入多输出线圈方法测试
这一节要做什么
在”写多线圈”页,一次性写一串线圈。值用逗号分隔的 0/1 输入,比如”1,0,1,0,1,0,1,0”。
完整代码
1 | private void btnWriteCoils_Click(object sender, EventArgs e) |
逐段讲解
这段唯一的新东西是”把字符串拆成数组”:
txtWriteCoilsValues.Text.Split(','):按逗号把字符串切开,得到一串小字符串,比如”1,0,1”切成”1”、”0”、”1”。- 循环里
parts[i].Trim() == "1":去掉空格后判断是不是”1”,是就是 true,否则 false。
Split 是”拆分”的意思,是处理”逗号分隔”这类输入的常用方法。写多寄存器那里还会用到它。
4.7 写入单个寄存器方法测试
这一节要做什么
在”写单寄存器”页,把某个寄存器的值改成指定的数字。
完整代码
1 | private void btnWriteReg_Click(object sender, EventArgs e) |
逐段讲解
和写单线圈几乎一样,区别在于:写入的值是一个数字(ushort),直接从文本框转过来即可,不需要”开/关”下拉框。
4.8 写入多个寄存器方法测试
这一节要做什么
在”写多寄存器”页,一次性写多个寄存器。值用逗号分隔的数字输入,比如”100,200,300”。
完整代码
1 | private void btnWriteRegs_Click(object sender, EventArgs e) |
逐段讲解
和写多线圈结构一样,区别是:每个小字符串 parts[i] 用 Convert.ToUInt16 转成数字(而不是判断是否等于”1”)。
4.9 写入寄存器位地址方法测试
这一节要做什么
在”写寄存器位”页,只修改某个寄存器的某一位(0 到 15),其它位保持不变。
完整代码
1 | private void btnWriteBit_Click(object sender, EventArgs e) |
逐段讲解
- 输入有三样:寄存器地址、位号(0 到 15)、值(开/关)。
modbus.WriteRegisterBit(slave, addr, bitIndex, value):调用库方法。还记得第 3 部分吗?这个方法内部是”先读、再改位、再写回”,所以我们界面上只管传”改哪一位、改成什么”,剩下的它自己搞定。
到这里,读线圈、读寄存器、写单线圈、写多线圈、写单寄存器、写多寄存器、写寄存器位,7 个功能测试全部写完。整个测试软件就成型了。
运行测试:用 Modbus Slave 模拟从站
软件写好了,但”读”需要有设备回应才有数据。手头没有真实 PLC 也没关系,用 Modbus Slave 软件模拟一个从站即可。
测试步骤
- 打开 Modbus Slave 软件,建立一个从站(比如站号 1,功能码 03 读保持寄存器);
- 在 Modbus Slave 里设置几个寄存器,填上一些值(比如地址 0 填 100,地址 1 填 200);
- 如果用串口,先用 VSPD 虚拟一对串口(比如 COM3 和 COM4),Modbus Slave 连 COM4,我们的软件连 COM3;
- 回到我们自己的软件,选好串口和参数,点”打开串口”;
- 切到”读寄存器”页,从站地址填 1,起始地址 0,数量 2,点”读取”,结果框里应该出现 100 和 200;
- 再切到”写单寄存器”页,把地址 0 的值改成 300,点”写入”,然后回到 Modbus Slave 看,那个值已经变成 300 了。
当你在自己写的软件里点一下”读取”,结果框里蹦出真实的寄存器数值时,你就完整走通了”上位机软件”的全过程:界面、串口、协议、数据,全都打通了。
第四部分小结
这一部分我们把通信库真正用了起来,做出了一个有界面的 Modbus RTU 测试软件。
- 怎么新建 WinForm 项目,怎么拖控件、设属性;
- Name(给代码用)和 Text(给人看)的区别;
- 用 ComboBox 做参数选择,用 SerialPort.GetPortNames 动态获取串口;
- 用 try catch 处理异常,避免程序崩溃;
- 把界面输入(文字)转成程序需要的数据类型(数字、bool、数组);
- 把上一部分的库方法接上按钮事件,做出完整功能。
我能新建一个 WinForm 项目并装好 System.IO.Ports
我能把上一部分的 3 个库文件加进新项目
我能拖控件并设置 Name 和 Text
我理解 InitControls 是干嘛的
我能解释 try catch 的作用
我照着写完了 7 个功能的按钮事件
我能用 Modbus Slave 模拟从站并读到数据
这一部分我们做的是”测试软件”,界面还比较朴素,输入要靠手动填。下一部分(或后续阶段),我们会做一个更实用的”串口助手”或”数据看板”,加入定时自动读取、数据显示、甚至曲线图表,让上位机软件真正”用起来”。有任何一步卡住,把报错发给我,我陪你一起查。




