WPF 学习第三天 --- Modbus RTU 串口通信
目录
- [Modbus 协议基础](#Modbus 协议基础)
- 大端序与小端序(核心难点)
- 位运算与移位详解
- 项目文件结构
- [App.config 配置文件详解](#App.config 配置文件详解)
- [ModbusSerivice.cs 完整代码与注释](#ModbusSerivice.cs 完整代码与注释)
- [MainWindow.xaml.cs 完整代码与注释](#MainWindow.xaml.cs 完整代码与注释)
- [Monitor_Plc.csproj 项目文件](#Monitor_Plc.csproj 项目文件)
- [NModbus4 库的引用](#NModbus4 库的引用)
- 虚拟串口与从站模拟器配置
- 常见问题排查
1. Modbus 协议基础
1.1 什么是 Modbus
Modbus 是一种工业通信协议,广泛应用于 PLC、传感器、驱动器等工业设备之间的通信。
1.2 主站(Master)与从站(Slave)
┌────────────────┐ 串口/网络 ┌────────────────┐
│ 主站 (Master) │ ──────────────────────► │ 从站 (Slave) │
│ ╔════════════╗ │ │ ╔════════════╗ │
│ ║ C# 程序 ║ │ │ ║ PLC / 模拟器║ │
│ ╚════════════╝ │ ◄────────────────────── │ ╚════════════╝ │
└────────────────┘ └────────────────┘
主动发起读写请求 被动响应请求
- 主站(Master):我们的 C# 程序,主动向从站发送读写命令
- 从站(Slave):PLC 或 Modbus 从站模拟器,收到命令后回复数据
1.3 Modbus RTU 与 Modbus TCP
| 特性 | Modbus RTU | Modbus TCP |
|---|---|---|
| 物理层 | 串口 (RS-232/RS-485) | 以太网 (TCP/IP) |
| 传输单位 | COM 口 + 波特率等参数 | IP 地址 + 端口号 |
| 地址范围 | 1~247 (Slave ID) | 1~247 (Unit ID) |
我们的项目使用的是 Modbus RTU over 串口。
1.4 Holding Register(保持寄存器)
Modbus 有多种数据类型,我们使用的是 Holding Register(保持寄存器):
- 每个寄存器 = 16 位(2 字节)
- 可读可写(
ReadHoldingRegisters/WriteMultipleRegisters) - 地址范围:0 ~ 65535
- 一个 32 位 float 需要 2 个连续的寄存器 来存储
1.5 我们的寄存器地址规划
4 个轴,每个轴 4 个参数(位置、速度、加速度、减速度),每个参数占 2 个寄存器:
Axis1: 地址 0 ~ 7 Axis2: 地址 8 ~ 15
Axis3: 地址 16 ~ 23 Axis4: 地址 24 ~ 31
每个轴内部:
地址 +0 ~ +1: 位置 (Pos)
地址 +2 ~ +3: 速度 (Vel)
地址 +4 ~ +5: 加速度 (Accel)
地址 +6 ~ +7: 减速度 (Decel)
2. 大端序与小端序(核心难点)
2.1 什么是字节序
字节序(Endianness)是指多字节数据在内存中的存放顺序。
假设我们有一个 16 位整数 0x1234(十六进制):
| 字节序 | 低位地址 | 高位地址 | 说明 |
|---|---|---|---|
| 大端(Big-Endian, AB CD) | 0x12 |
0x34 |
高位字节存低地址 |
| 小端(Little-Endian, CD AB) | 0x34 |
0x12 |
低位字节存低地址 |
记忆口诀:
- 大端 :像写数字一样,先写高位(
0x12 0x34) - 小端 :反过来,先写低位(
0x34 0x12)
2.2 什么是高字节、低字节
拿 0x1234 举例:
- 高字节(High Byte) :
0x12(左边的、权重大的) - 低字节(Low Byte) :
0x34(右边的、权重小的)
2.3 float(单精度浮点数)的 4 个字节
float 在内存中占 4 个字节(32 位)。
以 8.37 为例,它在 IEEE 754 标准下的二进制表示为 0x410F5C29:
字节索引: [3] [2] [1] [0]
数值: 0x41 0x0F 0x5C 0x29
2.4 Windows 是小端序,Modbus 是大端序
-
Windows 系统(Intel CPU):小端序(Little-Endian)
BitConverter.GetBytes(8.37)返回:[0x29, 0x5C, 0x0F, 0x41]- 低地址 → 低字节:
bs[0]=29, bs[1]=5C, bs[2]=0F, bs[3]=41
-
Modbus 协议:大端序(Big-Endian)
- 寄存器 0 存放高 16 位:
0x410F - 寄存器 1 存放低 16 位:
0x5C29
- 寄存器 0 存放高 16 位:
2.5 为什么需要转换
由于系统(小端)和协议(大端)字节序不一致,我们必须手动转换:
float 8.37 = 0x410F5C29
BitConverter.GetBytes(8.37) 在小端系统上得到:
bs[0] = 0x29 (最低位)
bs[1] = 0x5C
bs[2] = 0x0F
bs[3] = 0x41 (最高位)
我们要发送大端序给 Modbus 设备:
寄存器 0 = 0x410F = (bs[3] << 8) | bs[2]
寄存器 1 = 0x5C29 = (bs[1] << 8) | bs[0]
2.6 Modbus Slave 中的格式设置
在 Modbus Slave 模拟器中,AB CD 就是大端(Big-Endian),CD AB 就是小端(Little-Endian)。
我们的代码使用大端读写,所以模拟器必须设置为 AB CD(大端)。
Modbus Slave Format 设置:
✅ AB CD = 大端序 (Big-Endian) → 匹配我们的代码
❌ CD AB = 小端序 (Little-Endian) → 读出来是错误值
3. 位运算与移位详解
3.1 移位运算符 << 和 >>
| 运算符 | 名称 | 含义 | 示例 |
|---|---|---|---|
a << n |
左移 | 二进制位向左移动 n 位(相当于乘以 2ⁿ) | 0x12 << 8 = 0x1200 |
a >> n |
右移 | 二进制位向右移动 n 位(相当于除以 2ⁿ) | 0x1234 >> 8 = 0x12 |
3.2 按位或运算符 |
将两个数的每一位进行 OR 运算,只要有一位为 1 则结果为 1:
0x1200
| 0x0034
--------
0x1234
3.3 合并两个字节为一个 ushort(写操作)
csharp
byte highByte = 0x41;
byte lowByte = 0x0F;
// 第一步: highByte << 8
// 0x41 → 左移 8 位 → 0x4100
// 二进制: 00000000 01000001 → 01000001 00000000
// 第二步: (highByte << 8) | lowByte
// 0x4100
// 0x000F → 按位或
// --------
// 0x410F
ushort result = (ushort)((0x41 << 8) | 0x0F); // = 0x410F
3.4 组合两个 ushort 为一个 32 位值(读操作)
csharp
ushort high = 0x410F; // 从寄存器[0]读到的高16位
ushort low = 0x5C29; // 从寄存器[1]读到的低16位
// 第一步: (uint)high << 16
// 0x410F → 左移 16 位 → 0x410F0000
// 第二步: (0x410F0000) | 0x00005C29
// 0x410F0000
// 0x00005C29 → 按位或
// -----------
// 0x410F5C29
uint raw = (uint)(high << 16) | low; // = 0x410F5C29
3.5 写入 float 的完整流程图解
csharp
float value = 8.37f;
// 步骤 1: BitConverter 将 float 转为 4 字节(小端序)
byte[] bs = BitConverter.GetBytes(value);
// bs[0] = 0x29 (最低位字节)
// bs[1] = 0x5C
// bs[2] = 0x0F
// bs[3] = 0x41 (最高位字节)
// 步骤 2: 组合成两个大端序的 ushort
ushort high = (ushort)((bs[3] << 8) | bs[2]);
// = (ushort)((0x41 << 8) | 0x0F)
// = (ushort)(0x4100 | 0x0F)
// = 0x410F
ushort low = (ushort)((bs[1] << 8) | bs[0]);
// = (ushort)((0x5C << 8) | 0x29)
// = (ushort)(0x5C00 | 0x29)
// = 0x5C29
// 步骤 3: 写入 Modbus 从站
_master.WriteMultipleRegisters(slaveId, startAddress, new[] { high, low });
// 地址 N: 0x410F
// 地址 N+1: 0x5C29
// 从站看到: 0x410F 5C29 → 解析为 float = 8.37 ✅
3.6 读取 float 的完整流程图解
csharp
// 步骤 1: 从从站读取 2 个寄存器
ushort[] register = _master.ReadHoldingRegisters(slaveId, address, 2);
// register[0] = 0x410F (高位寄存器)
// register[1] = 0x5C29 (低位寄存器)
// 步骤 2: 组合成 32 位无符号整数
uint raw = (uint)(register[0] << 16 | register[1]);
// = (0x410F << 16) | 0x5C29
// = 0x410F0000 | 0x00005C29
// = 0x410F5C29
// 步骤 3: 转为字节数组(小端序系统)
byte[] b = BitConverter.GetBytes(raw);
// b[0] = 0x29
// b[1] = 0x5C
// b[2] = 0x0F
// b[3] = 0x41
// 步骤 4: 转换为 float
float result = BitConverter.ToSingle(b, 0);
// = 8.37 ✅
4. 项目文件结构
Monitor_PLCDATA/
└── Monitor_Plc/
└── Monitor_Plc/ ← 解决方案根目录
├── App.config ← 配置文件(串口参数)
├── App.xaml / App.xaml.cs ← 应用程序入口
├── MainWindow.xaml ← 主窗口 UI
├── MainWindow.xaml.cs ← 主窗口逻辑代码
├── Monitor_Plc.csproj ← 项目文件(引用管理)
├── packages.config ← NuGet 包管理
├── NModbus/
│ └── ModbusSerivice.cs ← Modbus 通信服务类 ★核心
├── Properties/ ← 项目属性
├── bin/ ← 编译输出
└── obj/ ← 编译中间文件
5. App.config 配置文件详解
配置文件存放在项目根目录 App.config,编译后会自动复制为 输出目录\程序名.exe.config。
xml
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<!-- 自定义应用配置项 -->
<appSettings>
<!-- 串口号:PLC 连接的 COM 口 -->
<add key="ComPort" value="COM1"/>
<!-- 波特率:串口通信速率,常见 9600 / 19200 / 115200 -->
<add key="BaudRate" value="9600"/>
<!-- 数据位:一般固定为 8 -->
<add key="DataBits" value="8"/>
<!-- 校验位:None/Odd/Even/Mark/Space -->
<add key="Parity" value="None"/>
<!-- 停止位:One/Two/OnePointFive -->
<add key="StopBits" value="One"/>
<!-- 从站 ID(Slave ID):匹配 PLC 中设置的地址 -->
<add key="SlaveId" value="1"/>
</appSettings>
<!-- .NET 框架版本 -->
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>
代码中如何读取配置
csharp
var appSettings = ConfigurationManager.AppSettings;
// 读取字符串配置,如果缺失则用 ?? 后面的默认值
Comport = appSettings["ComPort"] ?? "COM1";
// ?? 是 null 合并运算符:左边是 null 就用右边
// 读取数值配置,用 int.Parse 转成整数
BaudeRate = int.Parse(appSettings["BaudeRate"] ?? "9600");
DataBits = int.Parse(appSettings["DataBits"] ?? "8");
// 读取枚举配置,用 Enum.TryParse 安全转换(忽略大小写)
Parity = Enum.TryParse(appSettings["Parity"] ?? "None",
ignoreCase: true, out Parity parity) ? parity : Parity.None;
// 读取 byte 类型
_slaveId = byte.Parse(appSettings["SlaveId"] ?? "1");
ConfigurationManager 的配置注意点
.NET Framework 4.7.2 项目使用 ConfigurationManager 需要:
- 在
.csproj中添加System.Configuration引用(第 8 节详述) - 代码中添加
using System.Configuration;
6. ModbusSerivice.cs 完整代码与注释
文件位置:NModbus/ModbusSerivice.cs
csharp
// ============================================================
// Modbus RTU 通信服务类
// 负责:串口连接/断开、float 数据的读写、多轴数据的批量读写
// ============================================================
using Modbus.Device; // NModbus4 库:ModbusSerialMaster 等
using System; // 基础类型、BitConverter、Enum 等
using System.Collections.Generic; // List<T>
using System.Configuration; // ConfigurationManager.AppSettings
using System.IO.Ports; // SerialPort、Parity、StopBits
namespace Monitor_Plc.NModbus
{
/// <summary>
/// Modbus 通信服务(主站)
/// 实现 IDisposable 接口,确保串口资源在使用完后被释放
/// </summary>
internal class ModbusSerivice : IDisposable
{
// ---- 私有字段 ----
private SerialPort _serialPort; // 串口对象
private ModbusSerialMaster _master; // Modbus 主站对象
private readonly byte _slaveId; // 从站 ID(从配置文件读取)
// ---- 只读属性(从配置文件读取,初始化后不可改) ----
public string Comport { get; } // 串口号
public int BaudeRate { get; } // 波特率
public int DataBits { get; } // 数据位
public Parity Parity { get; } // 校验位
public StopBits StopBits { get; } // 停止位
/// <summary>
/// 是否已连接:主站对象不为 null 且 串口已打开
/// </summary>
public bool IsConnected => _master != null && _serialPort.IsOpen;
// ---- 寄存器地址规划 ----
// 4 个轴,每个轴占 8 个寄存器(4 个参数 × 2 个寄存器/参数)
private static readonly ushort[] AxisBaseAddr = { 0, 8, 16, 24 };
// 每个轴内部 4 个参数的偏移量(单位:寄存器)
// 每个参数占 2 个寄存器(因为 float 是 32 位 = 2 × 16 位寄存器)
private static readonly ushort[] ParamOffset = { 0, 2, 4, 6 };
// ==========================================================
// 构造函数:从配置文件读取串口参数
// ==========================================================
public ModbusSerivice()
{
// ConfigurationManager.AppSettings 读取 App.config 中的 <appSettings>
var appSettings = ConfigurationManager.AppSettings;
// 读取字符串配置,?? 是 null 合并运算符
Comport = appSettings["ComPort"] ?? "COM1";
// 读取数值配置,int.Parse 将字符串转整数
BaudeRate = int.Parse(appSettings["BaudeRate"] ?? "9600");
DataBits = int.Parse(appSettings["DataBits"] ?? "8");
// ---- Enum.TryParse 详解 ----
// 作用:安全地将字符串转换为枚举,不会抛异常
// 参数1:要转换的字符串(???????? 左边是默认值)
// 参数2:ignoreCase = true 忽略大小写("none" / "None" 都行)
// 参数3:out Parity parity 转换成功后的结果
// 返回:bool,true 表示转换成功,false 表示失败
// 三元表达式:成功 → 使用转换后的值,失败 → 使用默认值 None
Parity = Enum.TryParse(appSettings["Parity"] ?? "None",
ignoreCase: true, out Parity parity)
? parity
: System.IO.Ports.Parity.None;
StopBits = Enum.TryParse(appSettings["StopBits"] ?? "One",
ignoreCase: true, out StopBits stopBits)
? stopBits
: System.IO.Ports.StopBits.One;
// 从站 ID(byte 类型,0~255)
_slaveId = byte.Parse(appSettings["SlaveId"] ?? "1");
}
// ==========================================================
// 连接 Modbus 从站
// ==========================================================
public void Connect()
{
// 如果已经连接则跳过
if (IsConnected) return;
// SerialPort 构造函数参数顺序:
// (端口号, 波特率, 校验位, 数据位, 停止位)
_serialPort = new SerialPort(Comport, BaudeRate, Parity, DataBits, StopBits);
_serialPort.Open(); // 打开串口
// 创建 RTU 模式的主站对象(绑定到串口)
_master = ModbusSerialMaster.CreateRtu(_serialPort);
}
// ==========================================================
// 断开连接
// ==========================================================
public void DisConnect()
{
// 释放主站对象
if (_master != null)
{
_master.Dispose();
_master = null;
}
// 释放串口对象
if (_serialPort != null)
{
if (_serialPort.IsOpen) // 串口已打开才需要关闭
_serialPort.Close(); // 关闭串口
_serialPort.Dispose(); // 释放资源
_serialPort = null;
}
}
// ==========================================================
// 写入一个 float 值到指定地址
// 核心:小端系统 → 大端 Modbus 的字节序转换
//
// 参数 startAddress: 起始寄存器地址(如 0, 2, 4...)
// 参数 value: 要写入的 float 值
// ==========================================================
public void WriteFloat(ushort startAddress, float value)
{
ThrowIfNotConnected();
// Step 1: float → 4 字节(小端序)
// 假设 value = 8.37,IEEE 754 = 0x410F5C29
// BitConverter 在小端系统上返回:
// bs[0] = 0x29 (最低位字节,存在低地址)
// bs[1] = 0x5C
// bs[2] = 0x0F
// bs[3] = 0x41 (最高位字节,存在高地址)
byte[] bs = BitConverter.GetBytes(value);
// Step 2: 字节重组 → 大端序的 2 个 ushort
// Modbus 协议要求大端序:高位字节在前,低位字节在后
//
// ushort 是 16 位无符号整数(C# 的 ushort = unsigned short)
//
// high = (bs[3] << 8) | bs[2]
// = (0x41 << 8) | 0x0F
// = 0x4100 | 0x0F
// = 0x410F
ushort high = (ushort)((bs[3] << 8) | bs[2]);
// low = (bs[1] << 8) | bs[0]
// = (0x5C << 8) | 0x29
// = 0x5C00 | 0x29
// = 0x5C29
ushort low = (ushort)((bs[1] << 8) | bs[0]);
// Step 3: 写入 Modbus 从站
// 地址 startAddress: 0x410F (高位寄存器)
// 地址 startAddress+1: 0x5C29 (低位寄存器)
// 从站以大端序解析这两个寄存器 → 0x410F5C29 → float 8.37
_master.WriteMultipleRegisters(_slaveId, startAddress, new[] { high, low });
}
// ==========================================================
// 从指定地址读取一个 float 值
// 逆过程:大端 Modbus → 小端系统
//
// 参数 startAddress: 起始寄存器地址
// 返回:读取到的 float 值
// ==========================================================
public float ReadRegister(ushort startAddress)
{
ThrowIfNotConnected();
// Step 1: 从从站读取 2 个连续的寄存器
// register[0] = 0x410F (高位寄存器)
// register[1] = 0x5C29 (低位寄存器)
ushort[] register = _master.ReadHoldingRegisters(_slaveId, startAddress, 2);
// Step 2: 组合成 32 位无符号整数
// register[0] << 16 : 0x410F → 0x410F0000
// | register[1] : 0x410F0000 | 0x00005C29 = 0x410F5C29
uint raw = (uint)(register[0] << 16 | register[1]);
// Step 3: 将 uint 转回字节数组(隐式回到小端序)
// BitConverter.GetBytes(0x410F5C29) = [0x29, 0x5C, 0x0F, 0x41]
byte[] b = BitConverter.GetBytes(raw);
// Step 4: 将 4 个字节还原为 float
// BitConverter.ToSingle([0x29, 0x5C, 0x0F, 0x41], 0) = 8.37
return BitConverter.ToSingle(b, 0);
}
// ==========================================================
// 批量写入所有轴的数据(从 UI 读取值 → 写入 PLC)
// ==========================================================
public void writeAllAxis(List<AxisItem> axes)
{
ThrowIfNotConnected();
// 遍历每个轴(最多 4 个轴)
for (int i = 0; i < axes.Count && i < AxisBaseAddr.Length; i++)
{
var axis = axes[i];
ushort baseAddr = AxisBaseAddr[i];
// float.TryParse 安全地将字符串转为 float
// 如果解析成功,调用 WriteFloat 写入 PLC
// 每个参数占 2 个寄存器,用 ParamOffset 计算偏移
if (float.TryParse(axis.PosValue, out float pos))
WriteFloat((ushort)(baseAddr + ParamOffset[0]), pos);
if (float.TryParse(axis.VelValue, out float vel))
WriteFloat((ushort)(baseAddr + ParamOffset[1]), vel);
if (float.TryParse(axis.AccelValue, out float accel))
WriteFloat((ushort)(baseAddr + ParamOffset[2]), accel);
if (float.TryParse(axis.DecelValue, out float decel))
WriteFloat((ushort)(baseAddr + ParamOffset[3]), decel);
}
}
// ==========================================================
// 批量读取所有轴的数据(从 PLC 读取 → 显示到 UI)
// ==========================================================
public void ReadAllAxes(List<AxisItem> axes)
{
ThrowIfNotConnected();
for (int i = 0; i < axes.Count && i < AxisBaseAddr.Length; i++)
{
ushort baseAddr = AxisBaseAddr[i];
// 读取每个参数(每个参数占 2 个寄存器)
float pos = ReadRegister((ushort)(baseAddr + ParamOffset[0]));
float vel = ReadRegister((ushort)(baseAddr + ParamOffset[1]));
float accel = ReadRegister((ushort)(baseAddr + ParamOffset[2]));
float decel = ReadRegister((ushort)(baseAddr + ParamOffset[3]));
// 将 float 转回字符串显示,"0.##" 表示最多保留 2 位小数
axes[i].PosValue = pos.ToString("0.##");
axes[i].VelValue = vel.ToString("0.##");
axes[i].AccelValue = accel.ToString("0.##");
axes[i].DecelValue = decel.ToString("0.##");
}
}
// ==========================================================
// 内部辅助方法:未连接时抛异常
// ==========================================================
private void ThrowIfNotConnected()
{
if (!IsConnected)
throw new InvalidOperationException("Modbus 未连接,请先调用 Connect()");
}
// ==========================================================
// 实现 IDisposable:释放资源
// 使用 using 语法时会自动调用此方法
// ==========================================================
public void Dispose()
{
DisConnect(); // 断开连接,释放串口资源
}
}
}
7. MainWindow.xaml.cs 完整代码与注释
文件位置:MainWindow.xaml.cs
csharp
using Monitor_Plc.NModbus; // 引入 Modbus 服务类
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace Monitor_Plc
{
/// <summary>
/// 数据模型类:表示一个轴的数据项
/// 绑定到 ListBox/ListView 的 ItemTemplate
/// </summary>
public class AxisItem
{
// Label 是显示文本(固定),Value 是用户输入/读取的数值
public string PosLabel { get; set; } // 位置标签
public string PosValue { get; set; } // 位置数值
public string VelLabel { get; set; } // 速度标签
public string VelValue { get; set; } // 速度数值
public string AccelLabel { get; set; } // 加速度标签
public string AccelValue { get; set; } // 加速度数值
public string DecelLabel { get; set; } // 减速度标签
public string DecelValue { get; set; } // 减速度数值
}
/// <summary>
/// 主窗口逻辑
/// </summary>
public partial class MainWindow : Window
{
// Modbus 服务实例(整个窗口生命周期内只有一个)
private ModbusSerivice _modbus;
public MainWindow()
{
InitializeComponent();
// 初始化 4 个轴的默认数据
// AxisItems 是 XAML 中定义的 ListBox/ListView 的 x:Name
AxisItems.ItemsSource = new List<AxisItem>()
{
new AxisItem{ PosLabel="Axis1Pos:", PosValue="", VelLabel="AxisVel:", VelValue="",
AccelLabel="Axis1Accel:", AccelValue="", DecelLabel="Axis1Decel:", DecelValue=""},
new AxisItem{ PosLabel="Axis2Pos:", PosValue="", VelLabel="AxisVel:", VelValue="",
AccelLabel="Axis2Accel:", AccelValue="", DecelLabel="Axis2Decel:", DecelValue=""},
new AxisItem{ PosLabel="Axis3Pos:", PosValue="", VelLabel="AxisVel:", VelValue="",
AccelLabel="Axis3Accel:", AccelValue="", DecelLabel="Axis3Decel:", DecelValue=""},
new AxisItem{ PosLabel="Axis4Pos:", PosValue="", VelLabel="AxisVel:", VelValue="",
AccelLabel="Axis4Accel:", AccelValue="", DecelLabel="Axis4Decel:", DecelValue=""},
};
// ---- 初始化 Modbus 并尝试连接 ----
try
{
_modbus = new ModbusSerivice(); // 读取配置文件
_modbus.Connect(); // 连接串口
MessageBox.Show($"Modbus RTU 连接成功: {_modbus.Comport}");
}
catch (Exception ex)
{
MessageBox.Show($"Modbus 连接失败: {ex.Message}");
}
}
// ==========================================================
// 按钮事件:显示选中行的数据(测试用)
// ==========================================================
private void Button_Click(object sender, RoutedEventArgs e)
{
var btn = sender as Button;
var item = btn.DataContext as AxisItem;
// 将非空的参数收集到字典中
var dict = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(item.PosValue)) dict[item.PosLabel] = item.PosValue;
if (!string.IsNullOrEmpty(item.VelValue)) dict[item.VelLabel] = item.VelValue;
if (!string.IsNullOrEmpty(item.AccelValue)) dict[item.AccelLabel] = item.AccelValue;
if (!string.IsNullOrEmpty(item.DecelValue)) dict[item.DecelLabel] = item.DecelValue;
if (dict.Count > 0)
MessageBox.Show(string.Join("\n", dict.Select(kvp => $"{kvp.Key} = {kvp.Value}")));
else
MessageBox.Show("请输入内容,请勿提交空数据");
}
// ---- 文本框输入验证(只允许数字和小数点) ----
private void TextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
TextBox tx = sender as TextBox;
string currentTx = tx.Text;
int selectionStart = tx.SelectionStart;
int selectionLength = tx.SelectionLength;
if (!verifyText(currentTx, e.Text, selectionStart, selectionLength))
e.Handled = true; // 阻止输入
}
/// <summary>
/// 验证文本输入规则:
/// 规则1:只允许数字和 .
/// 规则2:不能以 . 开头
/// 规则3:只能有一个 .
/// </summary>
private bool verifyText(string currentTx, string inputText, int selectionStart, int selectionLength)
{
// 模拟输入后的完整文本
string resultTx = currentTx.Substring(0, selectionStart)
+ inputText
+ currentTx.Substring(selectionStart + selectionLength);
// 规则1:检查每个输入的字符
foreach (char ch in inputText)
{
if (!char.IsDigit(ch) && ch != '.')
return false;
}
// 规则2:不能以 . 开头
if (resultTx.StartsWith("."))
return false;
// 规则3:只能有一个 .
int dotCount = 0;
foreach (char ch in resultTx)
{
if (ch == '.') dotCount++;
}
if (dotCount > 1)
return false;
return true;
}
// ---- 粘贴验证 ----
private void TextBox_Pasting(Object sender, DataObjectPastingEventArgs e)
{
string pasteText = e.DataObject.GetData(typeof(string)) as string;
if (string.IsNullOrEmpty(pasteText))
{
e.CancelCommand();
return;
}
TextBox tx = sender as TextBox;
if (!verifyText(tx.Text, pasteText, tx.SelectionStart, tx.SelectionLength))
e.CancelCommand();
}
// ---- 预留的按钮事件(可删除) ----
private void Button_Click_1(object sender, RoutedEventArgs e) { }
private void Button_Click_2(object sender, RoutedEventArgs e) { }
private void Button_Click_3(object sender, RoutedEventArgs e) { }
private void Button_Click_4(object sender, RoutedEventArgs e) { }
// ==========================================================
// 读取按钮:从 PLC 读取所有轴的数据 → 显示到 UI
// ==========================================================
private void Button_Click_5(object sender, RoutedEventArgs e)
{
// 安全检测:_modbus 未初始化则提示
if (_modbus == null) { MessageBox.Show("Modbus 未初始化"); return; }
try
{
var axes = AxisItems.ItemsSource as List<AxisItem>;
if (axes == null) return;
_modbus.ReadAllAxes(axes); // 读取数据并回写到 axes 对象
// 刷新 UI 绑定(让 ListBox/ListView 重新显示)
AxisItems.ItemsSource = null;
AxisItems.ItemsSource = axes;
MessageBox.Show("读取成功");
}
catch (Exception ex)
{
MessageBox.Show($"读取失败: {ex.Message}");
}
}
// ==========================================================
// 写入按钮:将 UI 上输入的数据写入 PLC
// ==========================================================
private void Button_Click_6(object sender, RoutedEventArgs e)
{
if (_modbus == null) { MessageBox.Show("Modbus 未初始化"); return; }
try
{
var axes = AxisItems.ItemsSource as List<AxisItem>;
if (axes == null) return;
_modbus.writeAllAxis(axes); // 将 UI 数据写入 PLC
MessageBox.Show("写入成功");
}
catch (Exception ex)
{
MessageBox.Show($"写入失败: {ex.Message}");
}
}
// ==========================================================
// 窗口关闭时释放 Modbus 资源
// OnClosed 是 Window 类的虚方法,重写它来执行清理逻辑
// ==========================================================
protected override void OnClosed(EventArgs e)
{
_modbus?.Dispose(); // ?. 表示如果 _modbus 不为 null 则调用 Dispose
base.OnClosed(e); // 调用基类的 OnClosed
}
}
}
8. Monitor_Plc.csproj 项目文件
xml
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<!-- 导入 MSBuild 通用属性 -->
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" ... />
<!-- 项目基本属性 -->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<RootNamespace>Monitor_Plc</RootNamespace>
<AssemblyName>Monitor_Plc</AssemblyName>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion> <!-- .NET 版本 -->
</PropertyGroup>
<!-- 引用(DLL 依赖) -->
<ItemGroup>
<!-- NModbus4 库:提供 Modbus 通信功能 -->
<Reference Include="NModbus4, Version=2.1.0.0, ...">
<HintPath>..\packages\NModbus4.2.1.0\lib\net40\NModbus4.dll</HintPath>
</Reference>
<!-- .NET 框架自带引用 -->
<Reference Include="System" />
<Reference Include="System.Data" />
<Reference Include="System.IO, ..." /> <!-- 文件 IO 支持 -->
<Reference Include="System.Configuration" /> <!-- ConfigurationManager 需要这个! -->
<Reference Include="System.Xml" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Core" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xaml" />
<Reference Include="WindowsBase" />
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" /><!-- WPF 核心引用 -->
</ItemGroup>
<!-- 源代码文件 -->
<ItemGroup>
<ApplicationDefinition Include="App.xaml" />
<Page Include="MainWindow.xaml" />
<Compile Include="App.xaml.cs" />
<Compile Include="MainWindow.xaml.cs" />
<Compile Include="NModbus\ModbusSerivice.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Properties\Resources.Designer.cs" />
<Compile Include="Properties\Settings.Designer.cs" />
</ItemGroup>
<!-- 配置文件 -->
<ItemGroup>
<None Include="App.config" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
关键点:为什么要加 System.Configuration 引用
代码中出现:using System.Configuration;
调用: ConfigurationManager.AppSettings
但是 .NET Framework 4.7.2 默认不包含 System.Configuration.dll 的引用,
需要手动在 .csproj 中添加:
<Reference Include="System.Configuration" />
如果不加,报错:
❌ 当前上下文中不存在名称 "ConfigurationManager"
9. NModbus4 库的引用
项目使用 NModbus4 库(版本 2.1.0)实现 Modbus RTU 通信。
在 packages.config 中的记录
xml
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="NModbus4" version="2.1.0" targetFramework="net472" />
</packages>
核心类使用
| 类名 | 作用 | 我们的用法 |
|---|---|---|
ModbusSerialMaster |
Modbus 串口主站 | ModbusSerialMaster.CreateRtu(serialPort) |
WriteMultipleRegisters |
写多个寄存器 | 写入 float 拆成的 2 个寄存器 |
ReadHoldingRegisters |
读保持寄存器 | 读取 2 个寄存器后再合成 float |
10. 虚拟串口与从站模拟器配置
10.1 测试环境架构
┌─────────────────────────────────────────────────────────┐
│ 你的电脑 │
│ │
│ C# 程序 (主站) VSPE (虚拟串口) Modbus Slave │
│ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ COM1 │◄────────►│ 连接器 │◄──►│ COM2 │ │
│ │ (Master) │ │ (虚拟连线) │ │ (Slave) │ │
│ └──────────┘ └──────────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
10.2 VSPE 设置步骤
- 打开 VSPE(Virtual Serial Ports Emulator)
- 点击 Create 按钮
- 选择 Connector(连接器)模式
- 设置:
- Port A :
COM1 - Port B :
COM2
- Port A :
- 点击确定,VSPE 自动创建一对互联的虚拟串口
- 你可以在列表中看到 COM1 和 COM2 的状态为 Opened
10.3 Modbus Slave 从站模拟器设置
-
打开 Modbus Slave 软件
-
菜单 Connection → Connect(或点击连接图标)
-
连接参数设置:
- Serial Port :选择
COM2(和 COM1 配对的另一个口) - Baud Rate :
9600 - Data Bits :
8 - Parity :
None - Stop Bits :
1
- Serial Port :选择
-
点击 OK 连接
-
设置寄存器:
- 双击左侧的 Slave ID 灰色区域 ,或菜单 Setup → Slave Definition
- Slave ID :
1(匹配 App.config 中的 SlaveId) - Address(起始地址) :
0 - Quantity(寄存器数量) :
32(至少覆盖 0~31 地址) - Format 列选择:
float AB CD(大端序!)
-
点击 OK
-
注意:Slave 的寄存器数量 Quantity 至少要设 32,否则地址越界会报错:
异常代码: 2 --- 非法的数据地址
11. 常见问题排查
Q1: ConfigurationManager.AppSettings 识别不到
原因 :项目缺少 System.Configuration.dll 引用
解决 :在 .csproj 中添加 <Reference Include="System.Configuration" />
Q2: 项目加载失败 / VS 卸载项目
原因 :.csproj XML 格式错误(如引用标签嵌套放错位置)
解决 :检查 .csproj,确保每个 <Reference> 标签独立,不要嵌套在其他标签内部
Q3: Modbus 连接失败
| 可能原因 | 检查方法 |
|---|---|
| 串口号不对 | 查看设备管理器,确认实际 COM 口 |
| 串口被占用 | 关闭其他占用串口的程序 |
| VSPE 未创建 | 打开 VSPE 确认虚拟串口已启动 |
| 波特率不匹配 | 确认 App.config 和 Modbus Slave 设置一致 |
Q4: 读取失败:异常代码 2 - 非法数据地址
原因:Modbus Slave 中的寄存器数量(Quantity)设置得太少,代码要读的地址超过了从站的范围
解决 :Modbus Slave → Setup → Slave Definition → 把 Quantity 改为 32 或更大
Q5: 只有第一个轴写入成功,其他轴数据错误
原因 :同 Q4,Quantity 太小了,只覆盖了少量地址
解决 :Quantity 改为 32 以上
Q6: 写入的值和读到的值不一致
可能原因 1:Modbus Slave 的 Format 设置错了
- 代码使用大端序(AB CD),模拟器必须设为
float AB CD - 如果设成了
CD AB(小端),读出来就是乱值
可能原因 2:字节序转换代码写错了
- 回顾第 3 节的移位 / 位运算流程
Q7: IDisposable 有什么用
没有 IDisposable:
用户忘记调用 DisConnect() → 串口一直打开
→ 下次无法连接同一 COM 口
→ 程序退出后串口资源泄漏
有 IDisposable:
using (var modbus = new ModbusSerivice()) {
modbus.Connect();
// ... 使用 ...
} // ← 离开 using 块自动调用 Dispose(),保证释放
Q8: Enum.TryParse 和 Enum.Parse 的区别
| 方法 | 失败时行为 | 安全性 |
|---|---|---|
Enum.Parse() |
抛异常 ArgumentException |
❌ 不安全 |
Enum.TryParse() |
返回 false,不抛异常 |
✅ 安全,适合处理用户输入 |
Q9: ?? 是什么运算符
?? 是 null 合并运算符(null-coalescing operator):
csharp
string value = appSettings["Key"] ?? "Default";
// 相当于:
// string value = appSettings["Key"];
// if (value == null) value = "Default";
Q10: ?. 是什么运算符
?. 是 null 条件运算符(null-conditional operator):
csharp
_modbus?.Dispose();
// 相当于:
// if (_modbus != null) _modbus.Dispose();
附录:常用类型对照表
| C# 类型 | 大小 | 范围 | Modbus 对应 |
|---|---|---|---|
byte |
8 位 | 0~255 | Slave ID |
ushort |
16 位 | 0~65535 | 1 个寄存器 |
uint |
32 位 | 0~4294967295 | 2 个寄存器 |
float |
32 位 | ±1.5×10⁻⁴⁵~±3.4×10³⁸ | 2 个寄存器 |
Parity |
枚举 | None/Odd/Even/Mark/Space | 串口校验 |
StopBits |
枚举 | One/Two/OnePointFive | 串口停止位 |