WPF 学习第三天 — Modbus RTU 串口通信

WPF 学习第三天 --- Modbus RTU 串口通信

目录

  1. [Modbus 协议基础](#Modbus 协议基础)
  2. 大端序与小端序(核心难点)
  3. 位运算与移位详解
  4. 项目文件结构
  5. [App.config 配置文件详解](#App.config 配置文件详解)
  6. [ModbusSerivice.cs 完整代码与注释](#ModbusSerivice.cs 完整代码与注释)
  7. [MainWindow.xaml.cs 完整代码与注释](#MainWindow.xaml.cs 完整代码与注释)
  8. [Monitor_Plc.csproj 项目文件](#Monitor_Plc.csproj 项目文件)
  9. [NModbus4 库的引用](#NModbus4 库的引用)
  10. 虚拟串口与从站模拟器配置
  11. 常见问题排查

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

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 需要:

  1. .csproj 中添加 System.Configuration 引用(第 8 节详述)
  2. 代码中添加 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 设置步骤

  1. 打开 VSPE(Virtual Serial Ports Emulator)
  2. 点击 Create 按钮
  3. 选择 Connector(连接器)模式
  4. 设置:
    • Port ACOM1
    • Port BCOM2
  5. 点击确定,VSPE 自动创建一对互联的虚拟串口
  6. 你可以在列表中看到 COM1 和 COM2 的状态为 Opened

10.3 Modbus Slave 从站模拟器设置

  1. 打开 Modbus Slave 软件

  2. 菜单 Connection → Connect(或点击连接图标)

  3. 连接参数设置:

    • Serial Port :选择 COM2(和 COM1 配对的另一个口)
    • Baud Rate9600
    • Data Bits8
    • ParityNone
    • Stop Bits1
  4. 点击 OK 连接

  5. 设置寄存器:

    • 双击左侧的 Slave ID 灰色区域 ,或菜单 Setup → Slave Definition
    • Slave ID1(匹配 App.config 中的 SlaveId)
    • Address(起始地址)0
    • Quantity(寄存器数量)32(至少覆盖 0~31 地址)
    • Format 列选择:float AB CD(大端序!)
  6. 点击 OK

  7. 注意: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.TryParseEnum.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 串口停止位
相关推荐
guslegend1 小时前
理论学习:什么是 Coding Agent?
学习
自传.1 小时前
尚硅谷 Vibe Coding|第三章(1) Claude Code深度使用与进阶技巧 学习笔记
笔记·学习·尚硅谷·vibecoding
踏着七彩祥云的小丑1 小时前
Go学习第9天:并发编程 + 文件操作 + 正则表达式
学习·golang·正则表达式·go
有Li2 小时前
PTCMIL:基于提示 token 聚类的全切片图像多实例学习分析文献速递/多模态医学影像最新进展
论文阅读·学习·数据挖掘·聚类·文献·医学生
憧憬成为web高手2 小时前
l33t-hoster
学习·web安全·网络安全
Dick5072 小时前
ROS2 常用命令表
人工智能·学习·算法·机器人
qeen872 小时前
【Linux】Linux简单介绍与基本指令(上)
linux·运维·服务器·学习
.千余2 小时前
【C++】模板进阶全解:非类型参数|全特化|偏特化|分离编译完全指南
开发语言·c++·笔记·学习·其他
自传.2 小时前
尚硅谷 Vibe Coding|第二章 AI编程工具生态 学习笔记
笔记·学习·ai编程·尚硅谷·vibe coding
库奇噜啦呼3 小时前
【iOS】RunLoop学习
学习·ios