四轴运动控制系统 — 博客四 Modbus TCP/RTU 通信、大端字节序、数据轮询与异常处理

四轴运动控制系统 --- 博客四 Modbus TCP/RTU 通信、大端字节序、数据轮询与异常处理

项目名称: Four-Axis Motion Control System (WPF)

开发环境: .NET 10.0, WPF, NModbus4, System.IO.Ports

Git 仓库: Upper_Machine_Personal/UpperMachine


目录

  1. [今日 Q&A 整理](#今日 Q&A 整理)
  2. 今日开发内容总结
  3. 项目核心概念
  4. 项目文件结构
  5. 完整代码(含逐行注释)
  6. 总结与下一步

1. 今日 Q&A 整理

Q1: ABCD 在 Modbus 中是大端还是小端?

结论:ABCD = 大端模式。

复制代码
float 10.0 = 0x41100000 (32位 IEEE 754)
分成 4 个字节: A=0x41, B=0x10, C=0x00, D=0x00

Modbus 把 32 位 float 拆成两个 16 位寄存器:
  寄存器地址0(低地址) = AB = 0x4110 ← 高 16 位 (大端: 低地址存高位)
  寄存器地址1(高地址) = CD = 0x0000 ← 低 16 位

所以 ConvertPlcToFloat(regs[0], regs[1]) 正确
等价于: return (uint)regs[0] << 16 | regs[1]

Q2: "大端对应低地址存放高字节,值 1 存放为 0000 0001,数组 0 是低地址,所以把 0001 放进去了?"

答:混淆了两个概念------"16 位寄存器内部字节序"和"两个寄存器之间的顺序"。

  • 16 位寄存器内部字节序 (Modbus 库自动处理,开发者从不需要关心):
    • 大端时:低地址字节 = 高字节(0x00),高地址字节 = 低字节(0x01)
  • 两个寄存器的顺序 (开发者需要处理):
    • 大端时:数组0 = 低地址 = 高 16 位 → 左移 16 位
    • 数组1 = 高地址 = 低 16 位 → 低位直接 OR

Q3: 低地址存放高位就是大端模式,高地址存放高位就是小端模式?

答:对,这是寄存器级别的理解。

复制代码
大端: 数组[0]<<16 | 数组[1]  ← 当前代码正确
小端: 数组[1]<<16 | 数组[0]  ← 需要互换

Q4: Bit 层面最左边永远是高位,地址层面最左边就是低位?

答:对,两个不同的维度。

复制代码
Bit 层面:  位置 0(最左) = MSB = 最高位(值最大)
地址层面:  位置 0(最左) = 最低地址 = 存的可能是高位(大端)或低位(小端)

Q5: 断开连接后 MessageBox 一直弹窗怎么修复?

答:轮询异常的 catch 块中缺少"只弹一次"的标志位控制。

原因:DispatcherTimer 每隔 200ms 触发一次 PollAllAxes,每次异常都弹 MessageBox,导致弹窗关不完。

修复方案:

  1. 新增 _hasShownPollingError 轮询错误标志位
  2. 第一次轮询异常时:弹窗 + 停定时器 + 清理连接 + 标志位置 true
  3. 后续异常:标志位 true → 直接跳过,不再弹窗
  4. 连接成功时:标志重置为 false
  5. 手动断开时:标志重置为 false

2. 今日开发内容总结

已完成

  • 项目框架搭建(WPF, net10.0-windows, NModbus4, System.IO.Ports)
  • Models 层:CommConfigPlcAddressMapAxisDataAxisParam
  • Services 层:ModbusServiceBase(抽象基类)、ModbusTcpServiceModbusRtuService
  • Helpers 层:ModbusHelper(float ↔ ushort 转换)
  • ViewModels 层:MainViewModel(连接管理 + DispatcherTimer 轮询)
  • View 层:HomePage.xaml(4 轴数据卡片)、MainWindow.xaml(顶部连接面板)
  • Modbus 大端字节序理解与验证(ABCD 格式)
  • 轮询异常只提示一次的修复(_hasShownPollingError 标志位)
  • TCP 连接测试通过(Modbus Slave 配合验证)
  • RTU 连接测试通过

关键架构决策

  • 使用 DispatcherTimer 在 UI 线程进行轮询(简化多线程问题)
  • async void Connect 方法配合 Task.Run 避免 UI 阻塞
  • _service?.Disconnect() 等 null 条件调用确保安全
  • 轮询错误时自动停止定时器并清理连接资源

3. 项目核心概念

3.1 Modbus 大端字节序

复制代码
32-bit float 在 Modbus 中的存储:
  地址 n (低地址) = 高 16 位 (bits 31~16)
  地址 n+1 (高地址) = 低 16 位 (bits 15~0)

C# 代码转换:
  float → PLC:  ConvertFloatToPlc(float) → (low, high) 元组
  PLC → float:  ConvertPlcToFloat(high, low) → float

3.2 轴地址映射

复制代码
每轴占用 22 个寄存器 (11 个 float 参数 × 2 寄存器/float)

Axis 1: 寄存器  0 ~ 21
Axis 2: 寄存器 22 ~ 43
Axis 3: 寄存器 44 ~ 65
Axis 4: 寄存器 66 ~ 87

3.3 数据绑定链

复制代码
PLC 寄存器 → ModbusService.ReadHoldingRegisters → ushort[]
  → ModbusHelper.ConvertPlcToFloat → AxisParam 属性 (INPC)
  → AxisData (组合 Status + Data)
  → MainViewModel (暴露 Axis1Data ~ Axis4Data)
  → XAML {Binding Axis1Data.Data.CurrentPos}

3.4 连接与轮询生命周期

复制代码
Connect() 调用
  ├─ 已连接 → 断开分支: 停定时器 → 清理 service → IsConnected=false
  └─ 未连接 → 连接分支:
       ├─ 创建 ModbusTcpService / ModbusRtuService
       ├─ Task.Run → _service.Connect() (后台线程)
       ├─ 成功 → 创建 DispatcherTimer → 启动轮询
       └─ 失败 → catch → 打印日志 → IsConnected=false

PollAllAxes (定时器触发)
  ├─ _service==null || !IsConnected → return (安全守卫)
  ├─ 正常 → 读 4 轴寄存器 → 解析 float → 赋值 AxisData
  └─ 异常 → 首次 → 弹窗 + 停定时器 + 清理
            后续 → _hasShownPollingError=true → 跳过

4. 项目文件结构

复制代码
UpperMachine/
├── App.xaml                  # 应用入口 + 全局颜色资源
├── App.xaml.cs               # App 代码后置
├── MainWindow.xaml           # 主窗口 Shell(顶栏连接面板 + Frame + 底栏)
├── MainWindow.xaml.cs        # 主窗口代码后置(初始化 ViewModel + 导航)
│
├── Models/
│   ├── CommConfig.cs          # 通信配置(TCP 参数 / RTU 参数 / 轮询间隔)
│   ├── PlcAddressMap.cs       # PLC 寄存器地址映射(每轴 22 寄存器)
│   ├── AxisData.cs            # 轴数据容器(Status 状态 + Data 参数)
│   └── AxisParam.cs           # 单轴 12 个运动参数(带 INPC 通知)
│
├── ViewModels/
│   ├── MainViewModel.cs       # 主视图模型(连接/轮询/数据管理)
│   └── RelayCommand.cs        # ICommand 实现(按钮绑定桥接)
│
├── Services/
│   ├── ModbusServiceBase.cs   # Modbus 抽象基类(5 个标准读写方法)
│   ├── ModbusTcpService.cs    # TCP 协议实现(TcpClient + ModbusIpMaster)
│   └── ModbusRtuService.cs    # RTU 协议实现(SerialPort + ModbusSerialMaster)
│
├── Helpers/
│   └── ModbusHelper.cs        # 字节序转换工具(float ↔ ushort[])
│
└── View/
    ├── HomePage.xaml           # 主页:2×2 轴数据卡片
    └── HomePage.xaml.cs        # 主页代码后置

5. 完整代码(含逐行注释)

5.1 App.xaml ------ 全局资源与主题颜色

xml 复制代码
<!--===============================================================
  App.xaml ------ 应用入口
  定义 14 种全局颜色画刷,所有页面通过 {StaticResource ...} 引用
  深色主题(深蓝/暗黑风格),统一项目视觉风格
===============================================================-->
<Application x:Class="UpperMachine.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:UpperMachine"
             StartupUri="MainWindow.xaml">

    <Application.Resources>
        <!--
          颜色画刷定义(Key=资源键名, Color=十六进制颜色值)
          所有页面的背景/前景/边框都使用这些统一画刷
        -->
        <!-- 背景色:深蓝黑,用于窗口和页面的主背景 -->
        <SolidColorBrush x:Key="BackgroundBrush" Color="#1B1B2F"/>
        <!-- 面板色:深海军蓝,用于次级面板/模块的背景 -->
        <SolidColorBrush x:Key="PanelBrush" Color="#162447"/>
        <!-- 卡片色:中蓝,用于数据展示卡片的背景 -->
        <SolidColorBrush x:Key="CardBrush" Color="#1F4068"/>
        <!-- 顶栏色:更深的黑色,用于顶部导航栏和底部状态栏 -->
        <SolidColorBrush x:Key="HeaderBrush" Color="#0F0F23"/>
        <!-- 边框色:浅灰蓝,用于分隔线和边框描边 -->
        <SolidColorBrush x:Key="BorderBrush" Color="#2D2D44"/>
        <!-- 强调色:青色,用于标题圆点和高亮元素 -->
        <SolidColorBrush x:Key="AccentBrush" Color="#00D2FF"/>
        <!-- 文字色:浅灰白,用于主要文字内容 -->
        <SolidColorBrush x:Key="TextBrush" Color="#E0E0E0"/>
        <!-- 标签色:灰蓝色,用于辅助文字/单位/单位标签 -->
        <SolidColorBrush x:Key="LabelBrush" Color="#90A4AE"/>
        <!-- 绿色:已连接/正常运行指示 -->
        <SolidColorBrush x:Key="GreenBrush" Color="#00E676"/>
        <!-- 红色:断开/报警/严重错误指示 -->
        <SolidColorBrush x:Key="RedBrush" Color="#FF1744"/>
        <!-- 橙色:警告/负限位指示 -->
        <SolidColorBrush x:Key="OrangeBrush" Color="#FF9100"/>
        <!-- 黄色:正限位指示 -->
        <SolidColorBrush x:Key="YellowBrush" Color="#FFD740"/>
        <!-- 蓝色:回零完成/信息指示 -->
        <SolidColorBrush x:Key="BlueBrush" Color="#448AFF"/>
        <!-- 白色:输入框文字色 -->
        <SolidColorBrush x:Key="WhiteBrush" Color="#FFFFFF"/>
    </Application.Resources>
</Application>

5.2 App.xaml.cs ------ 应用代码后置(默认模板)

csharp 复制代码
// App.xaml.cs ------ 应用入口代码后置
// 无需自定义逻辑,使用默认 StartupUri 启动 MainWindow
using System.Configuration;
using System.Data;
using System.Windows;

namespace UpperMachine
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// WPF 自动将 App.xaml 的 StartupUri="MainWindow.xaml"
    /// 和本类的 public partial class App 连接
    /// 启动时自动加载 MainWindow 作为主窗口
    /// </summary>
    public partial class App : Application
    {
        // 无额外代码,所有逻辑在 MainWindow 和 ViewModel 中
    }
}

5.3 Models/CommConfig.cs ------ 通信参数配置

csharp 复制代码
//===============================================================
// CommConfig.cs ------ 通信参数配置数据模型
//
// 功能:存储 TCP 和 RTU 两种连接方式的所有配置参数
// 实现 INotifyPropertyChanged,使 XAML 双向绑定生效
// 不包含通信逻辑,仅作为纯数据对象(DTO)
//
// 使用场景:
//   MainWindow.xaml 的 TextBox 绑定 Config.IpAddress
//   → 用户输入 IP → setter 触发 OnPropertyChanged()
//   → WPF 引擎读取新值 → 界面实时更新
//===============================================================

// 引入 INotifyPropertyChanged 接口
using System.ComponentModel;
// 引入 Parity(校验位)和 StopBits(停止位)枚举类型
using System.IO.Ports;
// 引入 CallerMemberName 特性
using System.Runtime.CompilerServices;

namespace UpperMachine.Models
{
    /// <summary>
    /// 通信配置类 ------ 存储 TCP 和 RTU 两种连接方式的所有参数
    /// 不包含通信逻辑,仅作为数据传输对象(DTO),与服务层解耦
    /// </summary>
    public class CommConfig:INotifyPropertyChanged
    {
        // ========== 私有字段(实现 INPC 需要字段 + 属性模式) ==========

        // TCP 参数
        private string _ipAddress = "192.168.1.100";    // 目标 PLC 的 IP 地址,默认 192.168.1.100
        private int _tcpPort = 502;                      // TCP 端口号,Modbus 标准端口 502

        // RTU(串口)参数
        private string _portName = "COM1";               // 串口号,默认 COM1
        private int _baudRate = 9600;                    // 波特率,默认 9600
        private int _dataBits = 8;                       // 数据位,默认 8
        private Parity _parity = Parity.None;            // 校验位,默认 None(无校验)
        private StopBits _stopBits = StopBits.One;       // 停止位,默认 One(1 位停止位)

        // ComboBox 下拉选项数组
        private string[] _connectionTypes =  ["TCP", "RTU"];

        // =============== 轮询机制 ===============
        // 轮询间隔时间(毫秒)
        // 默认 200ms,即每 200ms 读取一次所有轴的数据
        public int IntervalMs { get; set; } = 200;

        // ========== TCP 参数 ==========

        /// <summary>目标 PLC 的 IP 地址,默认值 192.168.1.100</summary>
        public string IpAddress
        {
            get => _ipAddress;                    // 返回当前 IP 地址
            set { _ipAddress = value; OnPropertyChanged(); }  // 设置并通知 UI 刷新
        }

        /// <summary>TCP 端口号,Modbus TCP 标准端口为 502</summary>
        public int TcpPort
        {
            get => _tcpPort;                      // 返回当前端口号
            set { _tcpPort = value; OnPropertyChanged(); }    // 设置并通知 UI
        }

        // ========== RTU(串口)参数 ==========

        /// <summary>串口号,如 "COM1"、"COM3"</summary>
        public string PortName
        {
            get => _portName;                     // 返回串口号
            set { _portName = value; OnPropertyChanged(); }   // 设置并通知 UI
        }

        /// <summary>波特率,常用值 9600 / 19200 / 38400 / 115200</summary>
        public int BaudRate
        {
            get => _baudRate;                     // 返回波特率
            set { _baudRate = value; OnPropertyChanged(); }   // 设置并通知 UI
        }

        /// <summary>数据位,通常为 8</summary>
        public int DataBits
        {
            get => _dataBits;                     // 返回数据位
            set { _dataBits = value; OnPropertyChanged(); }   // 设置并通知 UI
        }

        /// <summary>校验位枚举(None=无校验, Odd=奇校验, Even=偶校验)</summary>
        public Parity Parity
        {
            get => _parity;                       // 返回校验位枚举值
            set { _parity = value; OnPropertyChanged(); }     // 设置并通知 UI
        }

        /// <summary>停止位枚举(One=1位, Two=2位)</summary>
        public StopBits StopBits
        {
            get => _stopBits;                     // 返回停止位枚举值
            set { _stopBits = value; OnPropertyChanged(); }   // 设置并通知 UI
        }

        // ========== 通用参数(TCP 和 RTU 共用) ==========

        /// <summary>Modbus 从站 ID(Slave ID),范围 1~247</summary>
        public byte SlaveId { get; set; } = 1;    // 默认 Slave ID = 1

        /// <summary>
        /// 通信超时时间(毫秒)
        /// TCP:连接超时 + 读写超时
        /// RTU:串口读写超时
        /// </summary>
        public int Timeout { get; set; } = 1000;  // 默认 1000ms(1 秒)

        /// <summary>
        /// ComboBox 下拉选项数组
        /// XAML 绑定: ItemsSource="{Binding Config.ConnectionTypes}"
        /// 显示两个选项:"TCP" 和 "RTU"
        /// </summary>
        public string[] ConnectionTypes { get => _connectionTypes; }

        // ========== INotifyPropertyChanged 实现 ==========

        /// <summary>
        /// 属性变更事件 ------ WPF 绑定引擎监听此事件
        /// 当任意属性 setter 调用 OnPropertyChanged() 时,
        /// WPF 自动重新读取绑定值,刷新 UI 显示
        /// </summary>
        public event PropertyChangedEventHandler? PropertyChanged;

        /// <summary>
        /// 触发属性变更通知(线程安全)
        /// [CallerMemberName] 编译器自动填入调用方属性名
        /// 例:IpAddress setter 中写 OnPropertyChanged(),
        ///     编译器自动展开为 OnPropertyChanged("IpAddress")
        /// </summary>
        protected void OnPropertyChanged([CallerMemberName] string? name = null)
            => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}

5.4 Models/PlcAddressMap.cs ------ PLC 寄存器地址映射

csharp 复制代码
//===============================================================
// PlcAddressMap.cs ------ PLC 寄存器地址映射表
//
// 功能:定义 4 轴在 Modbus 保持寄存器中的地址分配方案
//       每个轴占用 22 个寄存器(11 个 float 参数 × 2 寄存器/float)
//
// 地址分配:
//   Axis 1: 0  ~ 21   (每轴起始地址 0)
//   Axis 2: 22 ~ 43   (每轴起始地址 22)
//   Axis 3: 44 ~ 65   (每轴起始地址 44)
//   Axis 4: 66 ~ 87   (每轴起始地址 66)
//
// 使用场景:
//   PollAllAxes() 中通过 PerAxisStartAddress[i] 获取第 i 轴的起始地址
//   然后调用 ReadHoldingRegisters(startAddr, 22) 读取该轴的全部参数
//===============================================================

using System;
using System.Collections.Generic;
using System.Text;

namespace UpperMachine.Models
{
    /// <summary>
    /// PLC 寄存器地址映射表(静态常量 + 静态只读数组)
    /// 所有成员 static,无需实例化,直接 PlcAddressMap.ParamPerAxis 访问
    /// </summary>
    public class PlcAddressMap
    {
        // 每个轴的参数数量:11 个 float
        // CurrentPos, CurrentVel, CurrentTorque,
        // AbsoPos, AbsoVel, AbsoAccel, AbsoDecel,
        // RelPos, RelVel, RelAccel, RelDecel
        public const int ParamPerAxis = 11;

        // 每个轴占用寄存器数量 = 参数数量 × 2(每个 float 占 2 个寄存器)
        // Modbus 寄存器为 16 位,float 为 32 位,需要连续 2 个寄存器存储
        public const int RegisterPerAxisCount = 22;

        // 4 个轴的起始寄存器地址数组
        // PerAxisStartAddress[0] = 0   → Axis 1
        // PerAxisStartAddress[1] = 22  → Axis 2
        // PerAxisStartAddress[2] = 44  → Axis 3
        // PerAxisStartAddress[3] = 66  → Axis 4
        public static readonly int[] PerAxisStartAddress = new int[] { 0, 22, 44, 66 };
    }
}

5.5 Models/AxisData.cs ------ 轴数据容器

csharp 复制代码
//===============================================================
// AxisData.cs ------ 轴数据容器与状态枚举
//
// 功能:
//   1. AxisStatus 枚举:定义轴的 4 种运行状态
//   2. AxisData 类:组合状态(Status)和数据(Data)
//
// 使用场景:
//   MainViewModel 暴露 4 个 AxisData 属性:
//     Axis1Data, Axis2Data, Axis3Data, Axis4Data
//   XAML 绑定:{Binding Axis1Data.Status} 和 {Binding Axis1Data.Data.CurrentPos}
//===============================================================

namespace UpperMachine.Models
{
    /// <summary>
    /// 轴运行状态枚举
    /// 每个轴当前处于哪种工作阶段
    /// XAML 绑定 TextBlock 时会自动调用 .ToString() 显示枚举名
    /// </summary>
    public enum AxisStatus
    {
        StandBy,  // 待机状态(默认值),轴已就绪但未执行运动指令
        Running,  // 运行中,轴正在执行运动(位置/速度模式)
        Stopped,  // 已停止,轴完成运动后停止
        Error     // 错误/报警状态,轴出现故障需要处理
    }

    /// <summary>
    /// 单轴的数据容器 ------ 组合了状态(Status)和参数(Data)
    /// MainViewModel 暴露 4 个 AxisData 属性供 HomePage 绑定
    /// 
    /// 注意:本类不实现 INotifyPropertyChanged
    ///       AxisParam(Data 属性)内部实现了 INPC,参数变更会自动通知 UI
    ///       如果以后需要 Status 变更时 UI 自动刷新,需加 INPC 实现
    /// </summary>
    internal class AxisData
    {
        /// <summary>
        /// 轴运行状态
        /// 可选值:StandBy / Running / Stopped / Error
        /// XAML 绑定路径:{Binding Axis1Data.Status}
        /// </summary>
        public AxisStatus Status { get; set; }

        /// <summary>
        /// 轴实时参数(位置、速度、力矩等 12 个 float 值)
        /// 内嵌 AxisParam 对象,支持嵌套属性变更通知
        /// XAML 绑定路径:{Binding Axis1Data.Data.CurrentPos}
        /// </summary>
        public AxisParam Data { get; set; } = new AxisParam();
    }
}

5.6 Models/AxisParam.cs ------ 单轴 12 个运动参数

csharp 复制代码
//===============================================================
// AxisParam.cs ------ 单轴运动参数模型
//
// 功能:存储一个轴的全部 12 个 float 参数
//       实现 INotifyPropertyChanged 使 PLC 数据更新时 UI 自动刷新
//
// 参数分类:
//   实时数据(3个)  → 当前位置/速度/力矩(PLC 实时反馈)
//   绝对运动(4个)  → 目标位置/速度/加速度/减速度(从当前位置到指定值)
//   相对运动(4个)  → 距离/速度/加速度/减速度(从当前位置移动指定量)
//
// 每个参数占 2 个 Modbus 寄存器(32-bit float)
// PollAllAxes() 中每两个寄存器解析成一个 float,赋值到对应属性
//===============================================================

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace UpperMachine.Models
{
    /// <summary>
    /// 单轴 12 个运动参数
    /// 实现 INotifyPropertyChanged 以便 PLC 数据更新时 UI 自动刷新
    /// 每 set 一个属性都会触发通知,WPF 绑定引擎收到通知后重新读取 UI
    /// </summary>
    internal class AxisParam : INotifyPropertyChanged
    {
        // ========== 实时数据(PLC 当前位置/速度/力矩) ==========

        // 私有字段:当前位置(单位:mm 或度,取决于机械结构)
        private float _currentPos;
        // 私有字段:当前速度(单位:mm/s 或 deg/s)
        private float _currentVel;
        // 私有字段:当前力矩/扭矩(单位:Nm 或 %)
        private float _currentTorque;

        // ========== 绝对运动参数(从当前位置运动到指定位置) ==========

        // 绝对定位目标位置(要移动到的终点坐标)
        private float _absoPos;
        // 绝对运动过程中的速度
        private float _absoVel;
        // 绝对运动过程中的加速度
        private float _absoAccel;
        // 绝对运动过程中的减速度
        private float _absoDecel;

        // ========== 相对运动参数(从当前位置运动指定距离) ==========

        // 相对定位距离(要移动的长度,不是目标坐标)
        private float _relPos;
        // 相对运动过程中的速度
        private float _relVel;
        // 相对运动过程中的加速度
        private float _relAccel;
        // 相对运动过程中的减速度
        private float _relDecel;

        // ========== 公开属性(XAML 绑定目标) ==========

        // 每个属性都是 字段+getter+setter 模式
        // setter 中先更新字段值,再调用 OnPropertyChanged() 通知 UI

        /// <summary>当前位置(PLC 实时反馈)</summary>
        public float CurrentPos { get => _currentPos; set { _currentPos = value; OnPropertyChanged(); } }

        /// <summary>当前速度(PLC 实时反馈)</summary>
        public float CurrentVel { get => _currentVel; set { _currentVel = value; OnPropertyChanged(); } }

        /// <summary>当前力矩(PLC 实时反馈)</summary>
        public float CurrentTorque { get => _currentTorque; set { _currentTorque = value; OnPropertyChanged(); } }

        /// <summary>绝对定位目标位置</summary>
        public float AbsoPos { get => _absoPos; set { _absoPos = value; OnPropertyChanged(); } }

        /// <summary>绝对运动速度</summary>
        public float AbsoVel { get => _absoVel; set { _absoVel = value; OnPropertyChanged(); } }

        /// <summary>绝对运动加速度</summary>
        public float AbsoAccel { get => _absoAccel; set { _absoAccel = value; OnPropertyChanged(); } }

        /// <summary>绝对运动减速度</summary>
        public float AbsoDecel { get => _absoDecel; set { _absoDecel = value; OnPropertyChanged(); } }

        /// <summary>相对定位距离</summary>
        public float RelPos { get => _relPos; set { _relPos = value; OnPropertyChanged(); } }

        /// <summary>相对运动速度</summary>
        public float RelVel { get => _relVel; set { _relVel = value; OnPropertyChanged(); } }

        /// <summary>相对运动加速度</summary>
        public float RelAccel { get => _relAccel; set { _relAccel = value; OnPropertyChanged(); } }

        /// <summary>相对运动减速度</summary>
        public float RelDecel { get => _relDecel; set { _relDecel = value; OnPropertyChanged(); } }

        // ========== INotifyPropertyChanged 实现 ==========

        /// <summary>属性变更事件 ------ WPF 绑定引擎监听此事件</summary>
        public event PropertyChangedEventHandler? PropertyChanged;

        /// <summary>
        /// 触发属性变更通知
        /// [CallerMemberName] 编译器自动填入调用方属性名
        /// 例如 CurrentPos setter 中调用 OnPropertyChanged()
        /// 编译器自动展开为 OnPropertyChanged("CurrentPos")
        /// 无需手动写字符串,减少拼写错误
        /// </summary>
        protected void OnPropertyChanged([CallerMemberName] string? name = null)
            => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}

5.7 Helpers/ModbusHelper.cs ------ 字节序转换工具

csharp 复制代码
//===============================================================
// ModbusHelper.cs ------ Modbus 字节序转换工具
//
// 功能:
//   1. ConvertPlcToFloat(ushort high, ushort low)
//      → 将 PLC 读取的两个 16 位寄存器合并为 32 位 float
//
//   2. ConvertFloatToPlc(float v)
//      → 将 C# float 拆分为两个 16 位值,用于写入 PLC
//
// 字节序说明(大端模式):
//
//   Modbus 协议使用大端字节序(Big-Endian):
//   低地址寄存器存放 32 位数据的高 16 位
//   高地址寄存器存放 32 位数据的低 16 位
//
//   PLC 显示 ABCD 格式时(如 Modbus Slave 工具):
//     A=0x41, B=0x10, C=0x00, D=0x00  (float 10.0 = 0x41100000)
//     地址 n (低地址) = AB = 0x4110  ← 高 16 位
//     地址 n+1 (高地址) = CD = 0x0000  ← 低 16 位
//
// 验证:
//   ConvertPlcToFloat(0x4110, 0x0000) = 10.0 ✅
//
// 小端对照:
//   如果 PLC 使用小端(CDAB 格式):
//     地址 n (低地址) = CD = 低位
//     地址 n+1 (高地址) = AB = 高位
//     此时需要 ConvertPlcToFloat(low, high) 调换参数顺序
//===============================================================

using System;

namespace UpperMachine.Helpers
{
    /// <summary>
    /// Modbus 工具类 ------ 提供 PLC 与上位机之间的数据类型转换
    /// 所有方法静态,无需实例化,直接 ModbusHelper.ConvertPlcToFloat() 调用
    /// </summary>
    public class ModbusHelper
    {
        //===============================================================
        // 读取 PLC 转换为上位机数据
        //
        // 大端对应:低地址存放高字节
        //   例如 16 位整数值 1 在大端中的表示为:0000 0001
        //   低地址字节 = 0x00(高位),高地址字节 = 0x01(低位)
        //
        // 小端对应:高地址存放高字节
        //   例如 16 位整数值 1 在小端中的表示为:0001 0000
        //   低地址字节 = 0x01(低位),高地址字节 = 0x00(高位)
        //===============================================================
        /// <summary>
        /// PLC 寄存器 → C# float(大端模式)
        /// </summary>
        /// <param name="high">低地址寄存器值 = 32 位数据的高 16 位</param>
        /// <param name="low">高地址寄存器值 = 32 位数据的低 16 位</param>
        /// <returns>转换后的 float 值</returns>
        public static float ConvertPlcToFloat(ushort high, ushort low)
        {
            // 将两个 16 位值合并为 32 位无符号整数
            // high 左移 16 位 → 占据 bits 31~16
            // low 直接 OR → 占据 bits 15~0
            uint raw = (uint)(high << 16 | low);

            // 将 32 位整数按 IEEE 754 标准重新解释为 float
            // BitConverter.Int32BitsToSingle 直接内存 reinterpret,
            // 不涉及字节序,只在 32 位整数与 float 之间做位级映射
            return BitConverter.Int32BitsToSingle((int)raw);
        }

        //===============================================================
        // 上位机数据转换 PLC 的字节序实现
        //
        // low 为低地址,存放高字节的高 16 位(大端模式)
        // 返回值元组:(low, high)
        //   low  ← 要写入低地址寄存器的值(高 16 位)
        //   high ← 要写入高地址寄存器的值(低 16 位)
        //
        // 写入示例:
        //   var (regLow, regHigh) = ConvertFloatToPlc(10.0f);
        //   WriteSingleRegister(address, regLow);     // 写低地址
        //   WriteSingleRegister(address + 1, regHigh); // 写高地址
        //===============================================================
        /// <summary>
        /// C# float → PLC 寄存器值(大端模式)
        /// </summary>
        /// <param name="v">要写入 PLC 的 float 值</param>
        /// <returns>元组 (low, high):low=低地址寄存器值, high=高地址寄存器值</returns>
        public static (ushort low, ushort high) ConvertFloatToPlc(float v)
        {
            // 将 float 按 IEEE 754 重新解释为 32 位有符号整数
            int bits = BitConverter.SingleToInt32Bits(v);

            // 高 16 位 → 低地址寄存器(大端:低地址存高位)
            // 低 16 位 → 高地址寄存器
            return (
                (ushort)(bits >> 16),       // 右移 16 位,取高 16 位
                (ushort)(bits & 0xFFFF)      // 取低 16 位
            );
        }
    }
}

5.8 Services/ModbusServiceBase.cs ------ Modbus 抽象基类

csharp 复制代码
//===============================================================
// ModbusServiceBase.cs ------ Modbus 通信服务抽象基类
//
// 设计模式:模板方法模式
//   - Connect() / Disconnect() 为抽象方法,子类实现具体协议
//   - ReadHoldingRegisters / WriteSingleRegister 等为公共方法
//     子类通过继承获得一致的读写接口
//
// 功能:
//   1. 持有 IModbusMaster 接口(TCP 和 RTU 共用)
//   2. 提供 5 个标准 Modbus 功能码:
//      - 0x01 读取线圈    ReadCoils
//      - 0x03 读保持寄存器  ReadHoldingRegisters  ← 最常用
//      - 0x04 读输入寄存器  ReadInputRegisters
//      - 0x05 写单个线圈    WriteSingleCoil
//      - 0x06 写单个寄存器  WriteSingleRegister
//   3. 实现 IDisposable 接口,支持 using 和手动释放
//===============================================================

// IModbusMaster 是 NModbus4 的核心接口
// TCP 的 ModbusIpMaster 和 RTU 的 ModbusSerialMaster 都实现此接口
using Modbus.Device;

// 引用 Models 命名空间,使用 CommConfig 配置类
using UpperMachine.Models;

namespace UpperMachine.Services
{
    /// <summary>
    /// Modbus 通信服务的抽象基类
    /// 所有子类(TCP/RTU)共用的连接状态 + 5 个标准 Modbus 读写方法
    /// 实现 IDisposable 以便 using 块或 ViewModel 释放资源
    /// </summary>
    public abstract class ModbusServiceBase : IDisposable
    {
        // ============= 受保护字段 =============

        /// <summary>
        /// NModbus4 的通信主站接口
        /// TCP 子类赋值为 ModbusIpMaster,RTU 子类赋值为 ModbusSerialMaster
        /// 两者都实现了 IModbusMaster,基类用这个类型统一操作
        /// 问号 ? 表示可能为 null(未连接时)
        /// 
        /// protected 修饰:子类可以读写,外部只能通过基类的公共方法间接调用
        /// </summary>
        protected IModbusMaster? Master;

        // ============= 公开属性 =============

        /// <summary>当前是否已成功连接(子类可以用 protected set 修改)</summary>
        public bool IsConnected { get; protected set; }

        /// <summary>
        /// 通信配置 ------ TCP/RTU 的所有连接参数
        /// 公开字段,子类可以直接读写其中的 IpAddress / PortName / Timeout 等
        /// </summary>
        public CommConfig Config = new CommConfig();

        // ============= 抽象方法(子类必须实现) =============

        /// <summary>子类实现各自的连接逻辑,返回 true=连接成功 false=失败</summary>
        public abstract bool Connect();

        /// <summary>子类实现各自的断开逻辑</summary>
        public abstract void Disconnect();

        // ============= 私有辅助方法 =============

        /// <summary>
        /// 检查连接是否有效
        /// Master != null 确保对象存在
        /// IsConnected 确保连接状态为真
        /// 两者缺一不可(断开后 Master 被置为 null)
        /// </summary>
        private bool _connected() => Master != null && IsConnected;

        // ============= 公共读/写方法(Modbus 标准功能码) =============

        /// <summary>
        /// 读取保持寄存器(功能码 0x03)
        /// 保持寄存器可读可写,断电不丢失,是最常用的寄存器类型
        /// 我们的轴参数全部存储在保持寄存器中
        /// </summary>
        /// <param name="startAddress">起始地址,如 0</param>
        /// <param name="count">读取数量</param>
        /// <returns>ushort 数组,每个元素 16 位</returns>
        public ushort[] ReadHoldingRegisters(ushort startAddress, ushort count)
        {
            // 未连接时直接抛异常,由调用方(PollAllAxes)的 catch 处理
            if (!_connected()) throw new InvalidOperationException("未连接");
            // 调用 NModbus4 的标准读取方法
            // Config.SlaveId 从 CommConfig 读取,默认 1
            return Master!.ReadHoldingRegisters(Config.SlaveId, startAddress, count);
        }

        /// <summary>
        /// 读取输入寄存器(功能码 0x04)
        /// 输入寄存器只读,通常用于传感器等输入数据
        /// </summary>
        public ushort[] ReadInputRegisters(ushort startAddress, ushort count)
        {
            if (!_connected()) throw new InvalidOperationException("未连接");
            return Master!.ReadInputRegisters(Config.SlaveId, startAddress, count);
        }

        /// <summary>
        /// 读取线圈状态(功能码 0x01)
        /// 读取 DO/开关量输出,true=ON, false=OFF
        /// </summary>
        public bool[] ReadCoils(ushort startAddress, ushort count)
        {
            if (!_connected()) throw new InvalidOperationException("未连接");
            return Master!.ReadCoils(Config.SlaveId, startAddress, count);
        }

        /// <summary>
        /// 写入单个寄存器(功能码 0x06)
        /// 用于向 PLC 写入 16 位整数值
        /// 写入 float 时需要 ConvertFloatToPlc 先拆分再两次写入
        /// </summary>
        /// <param name="address">寄存器地址</param>
        /// <param name="value">要写入的 16 位值</param>
        public void WriteSingleRegister(ushort address, ushort value)
        {
            if (!_connected()) throw new InvalidOperationException("未连接");
            try
            {
                Master!.WriteSingleRegister(Config.SlaveId, address, value);
            }
            catch (Exception e)
            {
                // 包装异常信息,方便调用方定位问题
                throw new Exception($"写入失败: {e.Message}");
            }
        }

        /// <summary>
        /// 写入单个线圈(功能码 0x05)
        /// 用于控制 PLC 的开关量输出
        /// </summary>
        public void WriteSingleCoil(ushort address, bool value)
        {
            if (!_connected()) throw new InvalidOperationException("未连接");
            try
            {
                Master!.WriteSingleCoil(Config.SlaveId, address, value);
            }
            catch (Exception e)
            {
                throw new Exception($"写入失败: {e.Message}");
            }
        }

        // ============= 资源释放 =============

        /// <summary>
        /// 实现 IDisposable.Dispose()
        /// 供 using 块或 ViewModel 手动调用释放资源
        /// 内部调用子类的 Disconnect() 关闭连接 + 释放 Master
        /// GC.SuppressFinalize 告诉 GC 不需要再调用析构函数,提高性能
        /// </summary>
        public void Dispose()
        {
            Disconnect();              // 调用子类实现的断开逻辑
            GC.SuppressFinalize(this); // 告诉 GC 不需要再调用析构函数
        }
    }
}

5.9 Services/ModbusTcpService.cs ------ TCP 协议实现

csharp 复制代码
//===============================================================
// ModbusTcpService.cs ------ Modbus TCP 通信实现
//
// 通信流程:
//   1. new TcpClient() --- 创建 TCP 客户端对象
//   2. ConnectAsync() --- 异步连接 PLC(带可配置超时)
//   3. ModbusIpMaster.CreateIp(_tcpClient) --- 创建 Modbus 主站
//   4. 通过基类 ReadHoldingRegisters 等读取/写入数据
//
// 超时控制:
//   使用 CancellationTokenSource.CancelAfter(Config.Timeout)
//   实现可配置超时,避免 TcpClient 默认 20~30 秒不可控超时
//
// 断开流程:
//   Close() → Dispose() → null → 释放 Master
//===============================================================

// ModbusIpMaster 是 NModbus4 提供的 TCP 主站创建器
using Modbus.Device;

// TcpClient 提供 TCP 网络连接
using System.Net.Sockets;

// Task 用于异步连接 + 超时控制
using System.Threading.Tasks;

namespace UpperMachine.Services
{
    /// <summary>
    /// Modbus TCP 通信实现
    /// 通过 TcpClient 连接 PLC,使用 ModbusIpMaster 进行 Modbus 通信
    /// 核心改进:使用 ConnectAsync + GetAwaiter().GetResult() 实现可配置超时
    /// </summary>
    internal class ModbusTcpService : ModbusServiceBase
    {
        /// <summary>底层 TCP 客户端,生命周期由 Connect / Disconnect 管理</summary>
        private TcpClient? _tcpClient;

        /// <summary>
        /// 取消令牌源:用于实现连接超时控制
        /// CancelAfter(ms) 在指定毫秒后自动触发取消
        /// </summary>
        private CancellationTokenSource? _cts;

        /// <summary>
        /// 建立 TCP 连接
        /// </summary>
        public override bool Connect()
        {
            try
            {
                // ---- 前置清理:取消上一次的令牌(如果有) ----
                _cts?.Cancel();
                _cts?.Dispose();

                // ---- 第 1 步:创建超时取消令牌 ----
                _cts = new CancellationTokenSource();
                _cts.CancelAfter(Config.Timeout);  // 超时时间从 Config.Timeout 读取

                // ---- 第 2 步:创建 TcpClient(先不连接) ----
                _tcpClient = new TcpClient();

                // ---- 第 3 步:异步连接 + 超时控制 ----
                // ConnectAsync 不会阻塞当前线程,返回 Task
                // GetAwaiter().GetResult() = 同步等待异步结果
                // 配合 CancellationToken,超时后抛出 OperationCanceledException
                _tcpClient.ConnectAsync(Config.IpAddress, Config.TcpPort, _cts.Token)
                          .GetAwaiter().GetResult();

                // ---- 第 4 步:设置读写超时 ----
                // 读取/写入 PLC 数据的超时时间(毫秒)
                _tcpClient.ReceiveTimeout = Config.Timeout;
                _tcpClient.SendTimeout = Config.Timeout;

                // ---- 第 5 步:创建 Modbus 主站 ----
                // CreateIp 接收已连接的 TcpClient
                // 返回 ModbusIpMaster(实现了 IModbusMaster 接口)
                Master = ModbusIpMaster.CreateIp(_tcpClient);

                // 更新连接状态
                IsConnected = true;
                return true;
            }
            catch (Exception ex)
            {
                // 连接失败:打印日志、清理资源、返回 false
                Console.WriteLine($"TCP连接失败: {ex.Message}");

                IsConnected = false;

                // 清理 TCP 资源
                _tcpClient?.Close();
                _tcpClient = null;

                // 清理令牌资源
                _cts?.Cancel();
                _cts?.Dispose();
                _cts = null;

                return false;
            }
        }

        /// <summary>
        /// 断开 TCP 连接
        /// 关闭 TcpClient → 释放资源 → 置为 null
        /// </summary>
        public override void Disconnect()
        {
            IsConnected = false;        // 先改状态,防止断开过程中被误用

            _tcpClient?.Close();        // 关闭 TCP 连接(发送 FIN 包)
            _tcpClient?.Dispose();      // 释放托管资源
            _tcpClient = null;          // 解除引用,方便 GC 回收

            // 取消令牌
            _cts?.Cancel();
            _cts?.Dispose();
            _cts = null;

            // 释放 Modbus 主站
            Master?.Dispose();
            Master = null;
        }
    }
}

5.10 Services/ModbusRtuService.cs ------ RTU 协议实现

csharp 复制代码
//===============================================================
// ModbusRtuService.cs ------ Modbus RTU(串口)通信实现
//
// 通信流程:
//   1. new SerialPort() --- 创建串口对象,传入 COM 口/波特率/校验/数据位/停止位
//   2. Open() --- 打开串口(占用硬件资源)
//   3. ModbusSerialMaster.CreateRtu(_serialPort) --- 创建 Modbus 主站
//   4. 通过基类 ReadHoldingRegisters 等读取/写入数据
//
// 串口参数详解:
//   PortName: "COM1" ~ "COM256"(取决于系统可用串口)
//   BaudRate: 9600 / 19200 / 38400 / 115200(需与 PLC 一致)
//   Parity: None/Odd/Even(无校验/奇校验/偶校验,需与 PLC 一致)
//   DataBits: 通常为 8
//   StopBits: One/Two(1 位或 2 位停止位,需与 PLC 一致)
//
// 断开流程:
//   Close() → Dispose() → null → 释放 Master
//===============================================================

// ModbusSerialMaster 是 NModbus4 提供的 RTU 主站工厂类
using Modbus.Device;

// SerialPort / Parity / StopBits 等串口相关类型
using System.IO.Ports;

namespace UpperMachine.Services
{
    /// <summary>
    /// Modbus RTU(串口)通信实现
    /// 通过 SerialPort 连接 PLC,使用 ModbusSerialMaster 进行 Modbus 通信
    /// 继承自 ModbusServiceBase,共享基类的通用读写方法
    /// </summary>
    internal class ModbusRtuService : ModbusServiceBase
    {
        /// <summary>底层的串口对象,生命周期由 Connect / Disconnect 管理</summary>
        private SerialPort? _serialPort;

        /// <summary>
        /// 建立 RTU 连接
        /// 步骤:创建 SerialPort → 打开串口 → 创建 ModbusSerialMaster → 更新状态
        /// </summary>
        public override bool Connect()
        {
            try
            {
                // ---- 第 1 步:创建串口对象 ----
                // SerialPort 构造函数参数顺序:
                //   (端口名, 波特率, 校验位, 数据位, 停止位)
                _serialPort = new SerialPort(
                    Config.PortName,   // 如 "COM1"
                    Config.BaudRate,   // 如 9600
                    Config.Parity,     // 枚举,如 Parity.None
                    Config.DataBits,   // 通常为 8
                    Config.StopBits    // 枚举,如 StopBits.One
                )
                {
                    // 读写超时(毫秒),从 Config.Timeout 读取
                    ReadTimeout = Config.Timeout,
                    WriteTimeout = Config.Timeout
                };

                // ---- 第 2 步:打开串口 ----
                // 如果端口不存在或已被占用,Open() 会抛出异常
                // 常见的异常:UnauthorizedAccessException(端口被占用)
                //           IOException(端口不存在)
                _serialPort.Open();

                // ---- 第 3 步:创建 Modbus 主站 ----
                // CreateRtu 接收一个已打开的 SerialPort 对象
                // 返回 IModbusSerialMaster(继承 IModbusMaster)
                Master = ModbusSerialMaster.CreateRtu(_serialPort);

                IsConnected = true;
                return true;
            }
            catch (Exception ex)
            {
                // 连接失败:打印日志、更新状态、返回 false
                Console.WriteLine($"RTU连接失败: {ex.Message}");
                IsConnected = false;
                return false;
            }
        }

        /// <summary>
        /// 断开 RTU 连接
        /// 关闭串口 → 释放资源 → 置为 null
        /// 调用顺序:先关串口,再释放 Master
        /// </summary>
        public override void Disconnect()
        {
            IsConnected = false;         // 先改状态,防止断开过程中被误用

            _serialPort?.Close();        // 关闭串口(释放硬件资源)
            _serialPort?.Dispose();      // 释放托管资源
            _serialPort = null;          // 解除引用,方便 GC 回收

            Master?.Dispose();           // 释放 Modbus 主站
            Master = null;
        }
    }
}

5.11 ViewModels/RelayCommand.cs ------ ICommand 实现

csharp 复制代码
//===============================================================
// RelayCommand.cs ------ ICommand 桥接实现
//
// 为什么需要这个类?
//   WPF 按钮的 Command 属性需要 ICommand 对象,不能直接传方法
//   RelayCommand 包装了 ViewModel 中的方法,使按钮可以调用它
//
// 使用方式:
//   在 MainViewModel 中:
//     ConnectionCommand = new RelayCommand(Connect);
//   在 XAML 中:
//     <Button Command="{Binding ConnectionCommand}" />
//   点击按钮 → RelayCommand.Execute → MainViewModel.Connect()
//===============================================================

using System.Windows.Input;

namespace UpperMachine.ViewModels
{
    /// <summary>
    /// 简易 ICommand 实现 ------ 将按钮点击事件桥接到 ViewModel 的方法
    /// 
    /// WPF 按钮的 Command 属性需要 ICommand 对象,不能直接传方法
    /// 这个包装类让 ViewModel 可以:new RelayCommand(Connect)
    /// 告诉按钮:"点击我时执行 Connect() 方法"
    /// </summary>
    internal class RelayCommand : ICommand
    {
        /// <summary>存储要执行的方法(如 MainViewModel 的 Connect())</summary>
        private readonly Action _execute;

        /// <summary>
        /// 事件:当 CanExecute 的返回值变化时触发
        /// WPF 用它来刷新按钮的可用状态
        /// 我们所有按钮始终可用,所以从不触发此事件
        /// 但接口要求必须实现,所以用 #pragma 禁用警告
        /// </summary>
#pragma warning disable CS0067  // 禁用"事件从未使用"的编译器警告
        public event EventHandler? CanExecuteChanged;
#pragma warning restore CS0067

        /// <summary>
        /// 构造函数
        /// </summary>
        /// <param name="execute">要执行的无参方法,如 MainViewModel.Connect</param>
        public RelayCommand(Action execute) => _execute = execute;

        /// <summary>按钮是否可用,返回 true 表示始终可点击</summary>
        public bool CanExecute(object? parameter) => true;

        /// <summary>按钮被点击时执行此方法</summary>
        public void Execute(object? parameter) => _execute();
    }
}

5.12 ViewModels/MainViewModel.cs ------ 主视图模型(核心)

csharp 复制代码
//===============================================================
// MainViewModel.cs ------ 主视图模型
//
// 这是整个应用的核心,负责:
//   1. 管理 Modbus 连接/断开(Connect 方法)
//   2. 定时轮询 PLC 数据(DispatcherTimer + PollAllAxes)
//   3. 暴露 4 轴数据给 XAML 绑定(Axis1Data ~ Axis4Data)
//   4. 处理轮询异常(一次弹窗,防止 MessageBox 轰炸)
//
// 设计模式:
//   - INotifyPropertyChanged:属性变更时通知 WPF 引擎刷新 UI
//   - RelayCommand:将按钮 Command 绑定到 Connect 方法
//   - DispatcherTimer:UI 线程定时器,避免跨线程更新 UI
//
// 关键数据结构:
//   CommConfig Config  --- 通信参数(IP/端口/波特率/轮询间隔等)
//   AxisData Axis1~4Data --- 4 轴的数据和状态
//
// 轮询流程:
//   Connect 成功 → 创建 DispatcherTimer → 每 200ms 触发 PollAllAxes
//   PollAllAxes → 读取 4 轴寄存器 → 解析 float → 更新 AxisData
//   异常 → 首次弹窗 + 停定时器 + 清理 → 后续静静等待重连
//===============================================================

// INotifyPropertyChanged 接口 ------ 属性变化时通知 WPF 引擎刷新 UI
using System.ComponentModel;

// Parity / StopBits 枚举 ------ 用于 RTU 串口参数
using System.IO.Ports;

// CallerMemberName ------ 自动填入调用属性名,省去手写字符串
using System.Runtime.CompilerServices;

// Task.Run ------ 后台线程连接,不阻塞 UI 线程
using System.Threading.Tasks;

// ICommand ------ 按钮绑定到方法所需的接口
using System.Windows.Input;
using System.Windows.Threading;

using UpperMachine.Helpers;

// 引用数据模型 CommConfig / AxisData
using UpperMachine.Models;

// 引用服务层 ModbusServiceBase / ModbusTcpService / ModbusRtuService
using UpperMachine.Services;

namespace UpperMachine.ViewModels
{
    /// <summary>
    /// 主 ViewModel ------ 管理连接 + 4 轴数据
    /// 实现 INotifyPropertyChanged 让 UI 自动响应属性变化
    /// 这是前后端交互的桥梁:XAML 绑定属性 → 属性通知 → UI 刷新
    /// </summary>
    class MainViewModel : INotifyPropertyChanged
    {
        // ========== 私有字段 ==========

        /// <summary>当前选中的连接类型:"TCP" 或 "RTU"</summary>
        private string _connectionType = "TCP";

        /// <summary>
        /// 当前活动的 Modbus 服务实例
        /// Connect() 时创建,Disconnect() 时释放
        /// ? 表示可能为 null(未连接时)
        /// </summary>
        private ModbusServiceBase? _service;

        /// <summary>通信参数配置对象(IP/端口/波特率/轮询间隔等)</summary>
        public CommConfig Config { get; } = new CommConfig();

        /// <summary>连接状态(默认未连接)</summary>
        private bool _isConnected = false;

        // ===================== 轮询机制 ======================

        /// <summary>
        /// UI 线程定时器:每隔 IntervalMs 毫秒触发一次 PollAllAxes
        /// 使用 DispatcherTimer 而非 System.Timers.Timer 的原因:
        ///   - DispatcherTimer 在 UI 线程触发 Tick 事件
        ///   - 可以直接更新 UI 绑定的属性,无需 Invoke/Dispatcher.BeginInvoke
        ///   - System.Timers.Timer 在工作线程触发,更新 UI 需要额外处理
        /// </summary>
        private DispatcherTimer? _pollingTimer;

        /// <summary>
        /// 轮询异常标志位
        /// true  = 已经弹过轮询异常提示,不要再弹
        /// false = 还未弹过,下次异常时弹窗
        /// 
        /// 重置时机:
        ///   - 用户手动断开连接 → false(Connect 断开分支)
        ///   - 连接成功 → false(Connect 连接成功分支)
        /// </summary>
        private bool _hasShownPollingError;

        // ========== 公开属性 ==========

        /// <summary>
        /// 连接类型
        /// 设置时同时通知三个属性:自身 + IsTcpMode + IsRtuMode
        /// 让 XAML 中的 DataTrigger 根据值切换 TCP/RTU 面板显隐
        /// </summary>
        public string ConnectionType
        {
            get => _connectionType;
            set
            {
                _connectionType = value;
                OnPropertyChanged();
                OnPropertyChanged(nameof(IsTcpMode));  // 通知 TCP 面板显示/隐藏
                OnPropertyChanged(nameof(IsRtuMode));  // 通知 RTU 面板显示/隐藏
            }
        }

        /// <summary>
        /// 连接状态 ------ UI 灯和文字直接绑定此值
        /// true  = 已连接(绿灯 + "Connected")
        /// false = 未连接(红灯 + "DisConnect")
        /// </summary>
        public bool IsConnected
        {
            get => _isConnected;
            set { _isConnected = value; OnPropertyChanged(); }
        }

        // ========== 计算属性 ==========

        /// <summary>当前是 TCP 模式吗?XAML DataTrigger 绑定此值</summary>
        public bool IsTcpMode => ConnectionType == "TCP";

        /// <summary>当前是 RTU 模式吗?</summary>
        public bool IsRtuMode => ConnectionType == "RTU";

        // ========== ICommand ==========

        /// <summary>Connect/DisConnect 按钮绑定的命令</summary>
        public ICommand ConnectionCommand { get; }

        // ========== 4 轴数据 ==========

        /// <summary>轴 1 的状态 + 参数(绑定路径: Axis1Data.Status, Axis1Data.Data.CurrentPos)</summary>
        public AxisData Axis1Data { get; set; } = new AxisData();

        /// <summary>轴 2</summary>
        public AxisData Axis2Data { get; set; } = new AxisData();

        /// <summary>轴 3</summary>
        public AxisData Axis3Data { get; set; } = new AxisData();

        /// <summary>轴 4</summary>
        public AxisData Axis4Data { get; set; } = new AxisData();

        // ========== INotifyPropertyChanged ==========

        /// <summary>属性变更事件 ------ WPF 绑定引擎监听此事件</summary>
        public event PropertyChangedEventHandler? PropertyChanged;

        /// <summary>
        /// 触发属性变更通知
        /// [CallerMemberName] 编译器自动填入调用属性名
        /// 例:IpAddress setter 中写 OnPropertyChanged(),
        ///   编译器自动展开为 OnPropertyChanged("IpAddress")
        /// </summary>
        protected void OnPropertyChanged([CallerMemberName] string? name = null)
            => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));

        // ========== 核心方法:连接/断开 ==========

        /// <summary>
        /// 连接/断开切换方法
        /// async void 模式:方法签名 void 匹配 Action 委托(RelayCommand 要求)
        /// 内部可用 await 异步执行,不阻塞 UI 线程
        /// 
        /// 注意:async void 的异常会直接崩溃,所以必须 try-catch
        /// 
        /// 功能分支:
        ///   1. 已连接 → 断开:停定时器 → 清理 service → IsConnected=false
        ///   2. 未连接 → 连接:创建 service → 异步连接 → 成功则启动轮询
        /// </summary>
        public async void Connect()
        {
            // ======== 情况 1:当前已连接 → 断开 ========
            if (IsConnected)
            {
                // 重置轮询错误标志位,为下次连接做准备
                _hasShownPollingError = false;

                // 停止并清理轮询定时器
                _pollingTimer?.Stop();     // 停止定时器触发
                _pollingTimer = null;       // 释放引用,方便 GC 回收

                // 关闭 Modbus 连接
                _service?.Disconnect();    // 调用子类的断开逻辑(TCP Close / RTU Close)
                _service?.Dispose();       // 释放资源
                _service = null;           // 解除引用,方便 GC

                // 更新 UI 状态
                IsConnected = false;
                return;
            }

            // ======== 情况 2:当前未连接 → 建立连接 ========

            // 根据 ConnectionType 创建不同协议的服务实例
            if (ConnectionType == "TCP")
            {
                // ---- TCP 分支 ----
                var tcp = new ModbusTcpService();

                // 从 "192.168.0.1:502" 格式中分离 IP 和端口号
                // Split(':') → ["192.168.0.1", "502"]
                tcp.Config.IpAddress = Config.IpAddress.Split(':')[0];

                // 取端口号,如果没有则默认 502
                int.TryParse(Config.IpAddress.Split(':').ElementAtOrDefault(1) ?? "502", out var port);
                tcp.Config.TcpPort = port;

                _service = tcp;
            }
            else
            {
                // ---- RTU 分支 ----
                var rtu = new ModbusRtuService();

                // 从前端输入框读取参数,逐项赋值给 Config
                rtu.Config.PortName = Config.PortName;     // 串口号,如 "COM1"
                rtu.Config.BaudRate = Config.BaudRate;     // 波特率,如 9600
                rtu.Config.DataBits = Config.DataBits;     // 数据位,如 8
                rtu.Config.Parity = Config.Parity;         // 校验位枚举
                rtu.Config.StopBits = Config.StopBits;     // 停止位枚举

                _service = rtu;
            }

            // ======== 执行连接(异步!不阻塞 UI) ========
            try
            {
                // Task.Run(() => _service.Connect())
                //   → 将连接操作扔到线程池执行,UI 线程不阻塞
                //
                // await
                //   → 等后台线程完成后,自动回到 UI 线程继续执行
                //   → 后续代码 IsConnected = ... 在 UI 线程运行
                IsConnected = await Task.Run(() => _service.Connect());

                // 连接成功,启动轮询定时器
                if (IsConnected)
                {
                    // 创建 DispatcherTimer,设置轮询间隔
                    _pollingTimer = new DispatcherTimer();
                    // IntervalMs 从 Config 读取,默认 200ms
                    _pollingTimer.Interval = TimeSpan.FromMilliseconds(Config.IntervalMs);
                    // 绑定 Tick 事件处理函数
                    _pollingTimer.Tick += PollAllAxes;
                    // 启动定时器,开始定时触发 PollAllAxes
                    _pollingTimer.Start();

                    // 重置轮询错误标志位,清除之前可能残留的"已弹窗"状态
                    _hasShownPollingError = false;
                }
                // 连接失败 → IsConnected = false → UI 显示红灯 + "DisConnect"
            }
            catch (Exception ex)
            {
                // 连接过程异常(如 IP 格式错误、网络不通、串口被占用等)
                // 打印到控制台(调试时可见)
                Console.WriteLine($"连接异常: {ex.Message}");

                // 清理失败的 service
                _service = null;
                IsConnected = false;
            }
        }

        /// <summary>
        /// 轮询所有轴数据
        /// 由 DispatcherTimer 定时触发(默认每 200ms 一次)
        /// 
        /// 执行流程:
        ///   1. 检查 _service 和 IsConnected,任一无效应立即返回
        ///   2. 对 4 个轴依次:
        ///      a. 根据 PlcAddressMap 获取起始地址
        ///      b. 读取 22 个寄存器(保存当前轴的全部参数)
        ///      c. 每 2 个寄存器解析为 1 个 float
        ///      d. 赋值到对应轴的 AxisParam 属性
        ///   3. 异常时:首次弹窗 + 停定时器 + 清理连接
        /// </summary>
        private void PollAllAxes(object? sender, EventArgs e)
        {
            // 安全守卫:如果 service 为 null 或已断开,直接返回
            // 防止在 Disconnect 之后定时器又触发导致异常
            if (_service == null || !IsConnected) return;

            try
            {
                // 将 4 个轴数据对象放入数组,方便循环处理
                var axes = new[] { Axis1Data, Axis2Data, Axis3Data, Axis4Data };

                // 依次处理 4 个轴
                for (int i = 0; i < 4; i++)
                {
                    // 根据 PlcAddressMap 获取当前轴的起始寄存器地址
                    // Axis 1: 0, Axis 2: 22, Axis 3: 44, Axis 4: 66
                    int baseAddress = PlcAddressMap.PerAxisStartAddress[i];

                    // 调用 Modbus 读取方法
                    // 参数1: 起始地址(如 0)
                    // 参数2: 读取长度(22 个寄存器)
                    var regs = _service.ReadHoldingRegisters(
                        (ushort)baseAddress,
                        (ushort)PlcAddressMap.RegisterPerAxisCount
                    );

                    // 获取当前轴的 AxisParam 对象
                    var data = axes[i].Data;

                    // ---- 解析 22 个寄存器为 11 个 float ----
                    // 每 2 个寄存器 → 1 个 float(大端模式)
                    // ConvertPlcToFloat(high, low)
                    //   high = 低地址寄存器的值(32 位的高 16 位)
                    //   low  = 高地址寄存器的值(32 位的低 16 位)
                    data.CurrentPos    = ModbusHelper.ConvertPlcToFloat(regs[0],  regs[1]);   // 当前位置
                    data.CurrentVel    = ModbusHelper.ConvertPlcToFloat(regs[2],  regs[3]);   // 当前速度
                    data.CurrentTorque = ModbusHelper.ConvertPlcToFloat(regs[4],  regs[5]);   // 当前力矩
                    data.AbsoPos       = ModbusHelper.ConvertPlcToFloat(regs[6],  regs[7]);   // 绝对目标位置
                    data.AbsoVel       = ModbusHelper.ConvertPlcToFloat(regs[8],  regs[9]);   // 绝对速度
                    data.AbsoAccel     = ModbusHelper.ConvertPlcToFloat(regs[10], regs[11]);  // 绝对加速度
                    data.AbsoDecel     = ModbusHelper.ConvertPlcToFloat(regs[12], regs[13]);  // 绝对减速度
                    data.RelPos        = ModbusHelper.ConvertPlcToFloat(regs[14], regs[15]);  // 相对距离
                    data.RelVel        = ModbusHelper.ConvertPlcToFloat(regs[16], regs[17]);  // 相对速度
                    data.RelAccel      = ModbusHelper.ConvertPlcToFloat(regs[18], regs[19]);  // 相对加速度
                    data.RelDecel      = ModbusHelper.ConvertPlcToFloat(regs[20], regs[21]);  // 相对减速度
                }
            }
            catch (Exception ex)
            {
                // ---- 轮询异常处理(核心:只弹一次 MessageBox) ----
                // 使用 _hasShownPollingError 标志位控制弹窗次数
                // 防止 PLC 断开后每 200ms 弹一次 MessageBox 的 Bug
                if (!_hasShownPollingError)
                {
                    // 标记已弹窗,后续异常不再弹
                    _hasShownPollingError = true;

                    // 弹窗提示用户
                    System.Windows.MessageBox.Show($"轮询异常: {ex.Message}");

                    // 停止轮询定时器(防止继续触发)
                    _pollingTimer?.Stop();
                    _pollingTimer = null;

                    // 清理 Modbus 连接资源
                    _service?.Disconnect();
                    _service = null;

                    // 更新 UI 连接状态
                    IsConnected = false;
                }
                // 如果 _hasShownPollingError 已经为 true,
                // 则什么也不做------不弹窗、不操作,静静等待用户重新连接
            }
        }

        // ========== 构造函数 ==========

        /// <summary>
        /// 构造函数:在 ViewModel 创建时执行
        /// 将 ConnectionCommand 与 Connect() 方法绑定
        /// 
        /// XAML 中 <Button Command="{Binding ConnectionCommand}" />
        ///   点击按钮 → RelayCommand.Execute → MainViewModel.Connect()
        /// </summary>
        public MainViewModel()
        {
            // 创建 RelayCommand,传入 Connect 方法引用
            // 注意:这里不能加 (),Connect 不是调用而是引用
            ConnectionCommand = new RelayCommand(Connect);
        }
    }
}

5.13 MainWindow.xaml ------ 主窗口 Shell

xml 复制代码
<!--===============================================================
  MainWindow.xaml ------ 主窗口 Shell
  三行布局:
    行 0 (高 60) :顶部栏 ------ 标题 + 连接配置面板
    行 1 (高 *)  :内容区 ------ Frame 页面容器
    行 2 (高 50) :底部栏 ------ 4 轴状态一览

  连接面板(行 0 右侧 StackPanel)包含:
    ComboBox    → 选择 TCP / RTU
    TextBox     → TCP: IP 地址 / RTU: COM + 波特率
    Button      → Connect / DisConnect(文字随状态切换)
    Ellipse     → 状态灯(红/绿)
    TextBlock   → "Connected" / "DisConnect"
===============================================================-->
<Window x:Class="UpperMachine.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:UpperMachine"
        mc:Ignorable="d"
        Title="四轴运动控制系统" Height="850" Width="1280"
        MinHeight="700" MinWidth="1024"
        WindowStartupLocation="CenterScreen"
        Background="{StaticResource BackgroundBrush}"
        FontFamily="Microsoft Himalaya"
        >
        
    <Grid>
        <!-- 定义三行布局 -->
        <Grid.RowDefinitions>
            <RowDefinition Height="60"/>    <!-- 行 0:顶部栏(固定高度) -->
            <RowDefinition Height="*"/>     <!-- 行 1:内容区(占满剩余空间) -->
            <RowDefinition Height="50"/>    <!-- 行 2:底部栏(固定高度) -->
        </Grid.RowDefinitions>
        
        <!--===============================================================
          顶部栏(Grid.Row="0"):
          深色背景 + 底部边框,包含标题和右侧连接面板
        ===============================================================-->
        <Border Grid.Row="0"
                Background="{StaticResource HeaderBrush}"
                BorderBrush="{StaticResource BorderBrush}"
                BorderThickness="0,0,0,1">
            <Grid Margin="20,0">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto"/>    <!-- 左:标题 -->
                    <ColumnDefinition Width="*"/>       <!-- 中:留白 -->
                    <ColumnDefinition Width="Auto"/>    <!-- 右:连接面板 -->
                </Grid.ColumnDefinitions>
                
                <!-- === 左侧区域:青色圆点 + 系统标题 === -->
                <StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
                    <!-- AccentBrush 青色圆点 -->
                    <Ellipse Width="14" Height="14"
                             Fill="{StaticResource AccentBrush}"
                             Margin="0,-5,12,0"/>
                    <!-- 系统名称 -->
                    <TextBlock Text="4-Axis Motion System" FontSize="22" FontWeight="Bold"
                             Foreground="{StaticResource TextBrush}"/>
                </StackPanel>
                
                <!-- === 右侧区域:连接配置面板(水平排列所有控件) === -->
                <StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">

                    <!--
                      连接类型选择 ComboBox
                      ItemsSource:下拉数据源 → Config.ConnectionTypes (["TCP", "RTU"])
                      SelectedItem:选中项(双向绑定)→ ConnectionType
                    -->
                    <ComboBox ItemsSource="{Binding Config.ConnectionTypes}"
                              SelectedItem="{Binding ConnectionType}"
                              Width="90" Margin="0,-5,8,0"
                              VerticalAlignment="Center"
                              FontSize="22"
                              Background="Transparent"
                              Foreground="{StaticResource BorderBrush}"
                              BorderBrush="{StaticResource BorderBrush}">

                        <!-- 设置下拉列表选项的文字颜色和背景 -->
                        <ComboBox.ItemContainerStyle>
                            <Style TargetType="ComboBoxItem">
                                <Setter Property="Foreground" Value="{StaticResource BorderBrush}"/>
                                <Setter Property="Background" Value="Transparent"/>
                            </Style>
                        </ComboBox.ItemContainerStyle>
                    </ComboBox>

                    <!--
                      TCP/IP 地址输入框
                      双向绑定到 Config.IpAddress
                      DataTrigger:IsTcpMode=true 时显示,否则隐藏
                    -->
                    <TextBox Text="{Binding Config.IpAddress, UpdateSourceTrigger=PropertyChanged}"
                             Width="200" Margin="0,-4,8,0" Height="30"
                             VerticalContentAlignment="Bottom"
                             VerticalAlignment="Center"
                             FontSize="25"
                             Background="{StaticResource WhiteBrush}"
                             Foreground="{StaticResource BorderBrush}"
                             BorderThickness="0,0,0,1"
                             BorderBrush="{StaticResource BorderBrush}">
                        <TextBox.Style>
                            <Style TargetType="TextBox">
                                <Setter Property="Visibility" Value="Collapsed"/>
                                <Style.Triggers>
                                    <DataTrigger Binding="{Binding IsTcpMode}" Value="true">
                                        <Setter Property="Visibility" Value="Visible"/>
                                    </DataTrigger>
                                </Style.Triggers>
                            </Style>
                        </TextBox.Style>
                    </TextBox>

                    <!--
                      RTU 参数面板
                      包含:COM 口 / 波特率 / 8N1 标识
                      DataTrigger:IsRtuMode=true 时显示,否则隐藏
                    -->
                    <StackPanel Orientation="Horizontal" Margin="0,0,8,0">
                        <StackPanel.Style>
                            <Style TargetType="StackPanel">
                                <Setter Property="Visibility" Value="Collapsed"/>
                                <Style.Triggers>
                                    <DataTrigger Binding="{Binding IsRtuMode}" Value="true">
                                        <Setter Property="Visibility" Value="Visible"/>
                                    </DataTrigger>
                                </Style.Triggers>
                            </Style>
                        </StackPanel.Style>

                        <!-- COM 口名称输入框 -->
                        <TextBox Text="{Binding Config.PortName, UpdateSourceTrigger=PropertyChanged}"
                                 Width="80" Margin="0,-4,4,0"
                                 VerticalAlignment="Center"
                                 VerticalContentAlignment="Bottom"
                                 FontSize="24" Height="30"
                                 Background="{StaticResource WhiteBrush}"
                                 Foreground="{StaticResource BorderBrush}"
                                 BorderThickness="0,0,0,1"
                                 BorderBrush="{StaticResource BorderBrush}"/>

                        <!-- 分隔符 -->
                        <TextBlock Text=" / " FontSize="24"
                                   Foreground="{StaticResource WhiteBrush}"
                                   VerticalAlignment="Center" Margin="0,0,4,0"/>

                        <!-- 波特率输入框 -->
                        <TextBox Text="{Binding Config.BaudRate, UpdateSourceTrigger=PropertyChanged}"
                                 Width="100" Margin="0,-4,4,0"
                                 VerticalAlignment="Center"
                                 FontSize="24" Height="30"
                                 VerticalContentAlignment="Bottom"
                                 Background="{StaticResource WhiteBrush}"
                                 Foreground="{StaticResource BorderBrush}"
                                 BorderThickness="0,0,0,1"
                                 BorderBrush="{StaticResource BorderBrush}"/>

                        <!-- 8N1 参数标识(8数据位/无校验/1停止位) -->
                        <TextBlock Text=" 8N1" FontSize="20"
                                   Foreground="{StaticResource WhiteBrush}"
                                   VerticalAlignment="Center"/>
                    </StackPanel>

                    <!--
                      Connect / DisConnect 按钮
                      DataTrigger 切换按钮文字:
                        IsConnected=false → "Connect"
                        IsConnected=true  → "DisConnect"
                    -->
                    <Button Command="{Binding ConnectionCommand}"
                            FontSize="20" Margin="0,-5,8,0"
                            VerticalAlignment="Center"
                            Foreground="{StaticResource TextBrush}"
                            Background="Transparent"
                            BorderBrush="{StaticResource BorderBrush}"
                            BorderThickness="1"
                            Padding="12,4">
                        <Button.Style>
                            <Style TargetType="Button">
                                <Setter Property="Content" Value="Connect"/>
                                <Style.Triggers>
                                    <DataTrigger Binding="{Binding IsConnected}" Value="True">
                                        <Setter Property="Content" Value="DisConnect"/>
                                    </DataTrigger>
                                </Style.Triggers>
                            </Style>
                        </Button.Style>
                    </Button>

                    <!-- 连接状态灯(圆点) -->
                    <Ellipse Width="12" Height="12" Margin="0,-10,6,0" VerticalAlignment="Center">
                        <Ellipse.Style>
                            <Style TargetType="Ellipse">
                                <!-- 默认红色(断开) -->
                                <Setter Property="Fill" Value="Red"/>
                                <Style.Triggers>
                                    <!-- 已连接 → 绿色 -->
                                    <DataTrigger Binding="{Binding IsConnected}" Value="True">
                                        <Setter Property="Fill" Value="{StaticResource GreenBrush}"/>
                                    </DataTrigger>
                                </Style.Triggers>
                            </Style>
                        </Ellipse.Style>
                    </Ellipse>

                    <!-- 连接状态文字 -->
                    <TextBlock VerticalAlignment="Center" FontSize="24" Margin="0,-3,0,0"
                               Foreground="{StaticResource TextBrush}">
                        <TextBlock.Style>
                            <Style TargetType="TextBlock">
                                <Setter Property="Text" Value="DisConnect"/>
                                <Style.Triggers>
                                    <DataTrigger Binding="{Binding IsConnected}" Value="True">
                                        <Setter Property="Text" Value="Connected"/>
                                    </DataTrigger>
                                </Style.Triggers>
                            </Style>
                        </TextBlock.Style>
                    </TextBlock>
                </StackPanel>
            </Grid>
        </Border>

        <!--=== 行 1:内容区(Frame 页面容器) ===-->
        <Frame x:Name="MainFrame" Grid.Row="1"
               NavigationUIVisibility="Hidden"  <!-- 隐藏导航 UI -->
               Focusable="False"/>               <!-- 不允许获取焦点 -->

        <!--=== 行 2:底部状态栏(4 轴状态一览) ===-->
        <Border Grid.Row="2" Background="{StaticResource HeaderBrush}"
                BorderBrush="{StaticResource BorderBrush}"
                BorderThickness="0,1,0,0"
                Padding="15,0">
            <!-- 水平居中显示 4 轴状态 -->
            <Grid HorizontalAlignment="Center">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="Auto"/>
                    <ColumnDefinition Width="Auto"/>
                </Grid.ColumnDefinitions>

                <!-- Axis 1 -->
                <TextBlock Grid.Column="0" Foreground="{StaticResource LabelBrush}"
                           FontSize="28" VerticalAlignment="Center" Text="Axis1:"/>
                <TextBlock Grid.Column="1" Foreground="{StaticResource LabelBrush}"
                           FontSize="28" VerticalAlignment="Center"
                           Text="{Binding Axis1Data.Status}"/>
                <Ellipse Grid.Column="2" Width="8" Height="8"
                         Fill="{StaticResource GreenBrush}"
                         Margin="10,-5,80,0" VerticalAlignment="Center"/>

                <!-- Axis 2 -->
                <TextBlock Grid.Column="3" Foreground="{StaticResource LabelBrush}"
                           FontSize="28" VerticalAlignment="Center" Text="Axis2:"/>
                <TextBlock Grid.Column="4" Foreground="{StaticResource LabelBrush}"
                           FontSize="28" VerticalAlignment="Center"
                           Text="{Binding Axis2Data.Status}"/>
                <Ellipse Grid.Column="5" Width="8" Height="8"
                         Fill="{StaticResource GreenBrush}"
                         Margin="10,-5,80,0" VerticalAlignment="Center"/>

                <!-- Axis 3 -->
                <TextBlock Grid.Column="6" Foreground="{StaticResource LabelBrush}"
                           FontSize="28" VerticalAlignment="Center" Text="Axis3:"/>
                <TextBlock Grid.Column="7" Foreground="{StaticResource LabelBrush}"
                           FontSize="28" VerticalAlignment="Center"
                           Text="{Binding Axis3Data.Status}"/>
                <Ellipse Grid.Column="8" Width="8" Height="8"
                         Fill="{StaticResource GreenBrush}"
                         Margin="10,-5,80,0" VerticalAlignment="Center"/>

                <!-- Axis 4 -->
                <TextBlock Grid.Column="9" Foreground="{StaticResource LabelBrush}"
                           FontSize="28" VerticalAlignment="Center" Text="Axis4:"/>
                <TextBlock Grid.Column="10" Foreground="{StaticResource LabelBrush}"
                           FontSize="28" VerticalAlignment="Center"
                           Text="{Binding Axis4Data.Status}"/>
                <Ellipse Grid.Column="11" Width="8" Height="8"
                         Fill="{StaticResource GreenBrush}"
                         Margin="10,-5,8,0" VerticalAlignment="Center"/>
            </Grid>
        </Border>        
    </Grid>
</Window>

5.14 MainWindow.xaml.cs ------ 主窗口代码后置

csharp 复制代码
//===============================================================
// MainWindow.xaml.cs ------ 主窗口代码后置
//
// 初始化流程:
//   1. InitializeComponent() --- WPF 解析 XAML,构建控件树
//   2. new MainViewModel() --- 创建视图模型(连接管理 + 数据)
//   3. DataContext = vm --- 设置数据上下文(XAML 绑定从此处寻找数据源)
//   4. new HomePage() --- 创建主页
//   5. homePage.DataContext = vm --- 主页共享同一个 ViewModel
//   6. MainFrame.Navigate(homePage) --- 导航到主页
//
// 注意:ViewModel 和 MainWindow 持有同一个实例
// 所有页面通过 MainWindow.DataContext 共享数据
//===============================================================

using System.Text;
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;
using UpperMachine.View;

namespace UpperMachine
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// WPF 自动将 XAML 和本类通过 partial class 连接
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            // 解析 XAML,构建所有控件(必须第一步调用)
            InitializeComponent();

            // 创建共享视图模型实例(全局唯一)
            var vm = new ViewModels.MainViewModel();

            // 将 MainWindow 的 DataContext 设为 ViewModel
            // 从此 XAML 中的 {Binding ...} 都从 vm 查找属性
            DataContext = vm;

            // 创建主页页面
            var homePage = new HomePage();

            // 主页也持有同一个 ViewModel 实例
            homePage.DataContext = vm;

            // 导航到主页(显示在 Grid.Row="1" 的 Frame 中)
            MainFrame.Navigate(homePage);
        }
    }
}

5.15 View/HomePage.xaml ------ 主页数据卡片

xml 复制代码
<!--===============================================================
  HomePage.xaml ------ 主页(4 轴数据仪表盘)
  2×2 网格布局,每个格子是一张轴数据卡片
  每张卡片显示:标题 + 状态 + 当前位置 + 当前速度

  绑定路径:
    Axis1Data.Status        → 轴 1 状态
    Axis1Data.Data.CurrentPos → 轴 1 当前位置(带两位小数格式)
    Axis1Data.Data.CurrentVel → 轴 1 当前速度
    
  颜色资源:
    CardBrush   → 卡片背景色(深蓝中调)
    BorderBrush → 卡片边框色(浅灰蓝)
    AccentBrush → 标题圆点色(青色)
    LabelBrush  → 标签文字色(灰蓝)
    TextBrush   → 数值文字色(浅灰白)
===============================================================-->
<Page x:Class="UpperMachine.View.HomePage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
      xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
      mc:Ignorable="d"
      d:DesignHeight="600" d:DesignWidth="800"
      Title="HomePage"
      Background="{StaticResource BackgroundBrush}">

    <!-- 2×2 网格 + 右侧 200px 留白 -->
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>    <!-- 第一行:Axis 1 + Axis 2 -->
            <RowDefinition Height="*"/>    <!-- 第二行:Axis 3 + Axis 4 -->
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>   <!-- 左列:Axis 1 + Axis 3 -->
            <ColumnDefinition Width="*"/>   <!-- 右列:Axis 2 + Axis 4 -->
            <ColumnDefinition Width="200"/> <!-- 右侧留白(预留操作面板) -->
        </Grid.ColumnDefinitions>

        <!--===============================================================
          卡片模板:所有 4 张卡片结构完全相同
          区别只在于 Grid.Row/Column 和绑定路径中的轴编号
        ===============================================================-->

        <!-- === 卡片:Axis 1 === -->
        <Border Grid.Row="0" Grid.Column="0"
                Background="{StaticResource CardBrush}"
                BorderBrush="{StaticResource BorderBrush}"
                BorderThickness="1"
                CornerRadius="10" Margin="15">
            <StackPanel VerticalAlignment="Top" Margin="30">
                <!-- 标题行:青色圆点 + Axis 1 -->
                <StackPanel Orientation="Horizontal" Margin="0,0,0,15">
                    <Ellipse Width="12" Height="12"
                             Fill="{StaticResource AccentBrush}"
                             Margin="0,0,10,0" VerticalAlignment="Center"/>
                    <TextBlock Text="Axis 1" FontSize="30" FontWeight="Bold"
                              Foreground="{StaticResource TextBrush}"/>
                </StackPanel>

                <!-- 状态:Status: Running / StandBy / ... -->
                <TextBlock FontSize="30" Margin="0,0,0,8">
                    <Run Text="Status:" Foreground="{StaticResource LabelBrush}"/>
                    <Run Text="{Binding Axis1Data.Status}" Foreground="{StaticResource TextBrush}"/>
                </TextBlock>

                <!-- 当前位置:CurrentPos: 123.45 -->
                <TextBlock FontSize="30" Margin="0,0,0,8">
                    <Run Text="CurrentPos:" Foreground="{StaticResource LabelBrush}"/>
                    <Run Text="{Binding Axis1Data.Data.CurrentPos, StringFormat=N2}"
                         Foreground="{StaticResource TextBrush}"/>
                </TextBlock>

                <!-- 当前速度:CurrentVel: 67.89 -->
                <TextBlock FontSize="30">
                    <Run Text="CurrentVel:" Foreground="{StaticResource LabelBrush}"/>
                    <Run Text="{Binding Axis1Data.Data.CurrentVel, StringFormat=N2}"
                         Foreground="{StaticResource TextBrush}"/>
                </TextBlock>
            </StackPanel>
        </Border>

        <!-- === 卡片:Axis 2 === -->
        <Border Grid.Row="0" Grid.Column="1"
                Background="{StaticResource CardBrush}"
                BorderBrush="{StaticResource BorderBrush}"
                BorderThickness="1"
                CornerRadius="10" Margin="15">
            <StackPanel VerticalAlignment="Top" Margin="30">
                <StackPanel Orientation="Horizontal" Margin="0,0,0,15">
                    <Ellipse Width="12" Height="12"
                             Fill="{StaticResource AccentBrush}"
                             Margin="0,0,10,0" VerticalAlignment="Center"/>
                    <TextBlock Text="Axis 2" FontSize="30" FontWeight="Bold"
                              Foreground="{StaticResource TextBrush}"/>
                </StackPanel>
                <TextBlock FontSize="30" Margin="0,0,0,8">
                    <Run Text="Status:" Foreground="{StaticResource LabelBrush}"/>
                    <Run Text="{Binding Axis2Data.Status}" Foreground="{StaticResource TextBrush}"/>
                </TextBlock>
                <TextBlock FontSize="30" Margin="0,0,0,8">
                    <Run Text="CurrentPos:" Foreground="{StaticResource LabelBrush}"/>
                    <Run Text="{Binding Axis2Data.Data.CurrentPos, StringFormat=N2}"
                         Foreground="{StaticResource TextBrush}"/>
                </TextBlock>
                <TextBlock FontSize="30">
                    <Run Text="CurrentVel:" Foreground="{StaticResource LabelBrush}"/>
                    <Run Text="{Binding Axis2Data.Data.CurrentVel, StringFormat=N2}"
                         Foreground="{StaticResource TextBrush}"/>
                </TextBlock>
            </StackPanel>
        </Border>

        <!-- === 卡片:Axis 3 === -->
        <Border Grid.Row="1" Grid.Column="0"
                Background="{StaticResource CardBrush}"
                BorderBrush="{StaticResource BorderBrush}"
                BorderThickness="1"
                CornerRadius="10" Margin="15">
            <StackPanel VerticalAlignment="Top" Margin="30">
                <StackPanel Orientation="Horizontal" Margin="0,0,0,15">
                    <Ellipse Width="12" Height="12"
                             Fill="{StaticResource AccentBrush}"
                             Margin="0,0,10,0" VerticalAlignment="Center"/>
                    <TextBlock Text="Axis 3" FontSize="30" FontWeight="Bold"
                              Foreground="{StaticResource TextBrush}"/>
                </StackPanel>
                <TextBlock FontSize="30" Margin="0,0,0,8">
                    <Run Text="Status:" Foreground="{StaticResource LabelBrush}"/>
                    <Run Text="{Binding Axis3Data.Status}" Foreground="{StaticResource TextBrush}"/>
                </TextBlock>
                <TextBlock FontSize="30" Margin="0,0,0,8">
                    <Run Text="CurrentPos:" Foreground="{StaticResource LabelBrush}"/>
                    <Run Text="{Binding Axis3Data.Data.CurrentPos, StringFormat=N2}"
                         Foreground="{StaticResource TextBrush}"/>
                </TextBlock>
                <TextBlock FontSize="30">
                    <Run Text="CurrentVel:" Foreground="{StaticResource LabelBrush}"/>
                    <Run Text="{Binding Axis3Data.Data.CurrentVel, StringFormat=N2}"
                         Foreground="{StaticResource TextBrush}"/>
                </TextBlock>
            </StackPanel>
        </Border>

        <!-- === 卡片:Axis 4 === -->
        <Border Grid.Row="1" Grid.Column="1"
                Background="{StaticResource CardBrush}"
                BorderBrush="{StaticResource BorderBrush}"
                BorderThickness="1"
                CornerRadius="10" Margin="15">
            <StackPanel VerticalAlignment="Top" Margin="30">
                <StackPanel Orientation="Horizontal" Margin="0,0,0,15">
                    <Ellipse Width="12" Height="12"
                             Fill="{StaticResource AccentBrush}"
                             Margin="0,0,10,0" VerticalAlignment="Center"/>
                    <TextBlock Text="Axis 4" FontSize="30" FontWeight="Bold"
                              Foreground="{StaticResource TextBrush}"/>
                </StackPanel>
                <TextBlock FontSize="30" Margin="0,0,0,8">
                    <Run Text="Status:" Foreground="{StaticResource LabelBrush}"/>
                    <Run Text="{Binding Axis4Data.Status}" Foreground="{StaticResource TextBrush}"/>
                </TextBlock>
                <TextBlock FontSize="30" Margin="0,0,0,8">
                    <Run Text="CurrentPos:" Foreground="{StaticResource LabelBrush}"/>
                    <Run Text="{Binding Axis4Data.Data.CurrentPos, StringFormat=N2}"
                         Foreground="{StaticResource TextBrush}"/>
                </TextBlock>
                <TextBlock FontSize="30">
                    <Run Text="CurrentVel:" Foreground="{StaticResource LabelBrush}"/>
                    <Run Text="{Binding Axis4Data.Data.CurrentVel, StringFormat=N2}"
                         Foreground="{StaticResource TextBrush}"/>
                </TextBlock>
            </StackPanel>
        </Border>
    </Grid>
</Page>

5.16 View/HomePage.xaml.cs ------ 主页代码后置

csharp 复制代码
//===============================================================
// HomePage.xaml.cs ------ 主页代码后置
//
// 当前仅执行 XAML 初始化
// DataContext 在 MainWindow.xaml.cs 中通过 Navigate 前设置
// 主页本身没有额外的代码逻辑
//===============================================================

using System;
using System.Collections.Generic;
using System.Text;
using System.Windows.Controls;

namespace UpperMachine.View
{
    /// <summary>
    /// HomePage 代码后置
    /// 逻辑全部在 XAML 绑定和 ViewModel 中完成
    /// 此处仅调用 InitializeComponent() 解析 XAML
    /// </summary>
    public partial class HomePage : Page
    {
        public HomePage()
        {
            // 解析 XAML,构建页面控件树
            InitializeComponent();
        }
    }
}

6. 总结与下一步

今日核心收获

知识点 掌握程度 说明
Modbus 大端字节序 ✅ 理解 ABCD = 大端,低地址存高位
float ↔ 寄存器转换 ✅ 掌握 ConvertPlcToFloat(high, low)
DispatcherTimer 轮询 ✅ 掌握 UI 线程定时器,直接更新绑定
异常只弹一次模式 ✅ 掌握 _hasShownPollingError 标志位
TCP/RTU 连接管理 ✅ 掌握 抽象基类 + 多态实现
WPF 数据绑定 ✅ 掌握 INPC + XAML Binding + DataTrigger

下一步计划

  • 添加 Running 状态判断逻辑(位置/速度 ≠ 0 → Running)
  • 实现写入功能(ConvertFloatToPlc + WriteSingleRegister
  • AxisData 添加 INotifyPropertyChanged(Status 变更自动刷新)
  • 添加更多页面(手动调整 / 参数设置 / 实时监控 / 报警面板)
  • 配置持久化(JSON 序列化保存/加载)
  • 操作日志持久化(SQLite 或文件存储)