
做上位机这行快十年了经手了不少项目去年年底落地的石墨岛搬移上位机算是比较典型的一个。项目名称里的石墨岛不是真的岛而是炭素车间里一组组石墨化炉位——石墨电极在这些炉位之间装炉、出炉、转运每个炉位就是一个岛。这套系统的核心就一句话一台工控机上跑的C# WPF程序通过Modbus TCP和车间里的PLC对话再由状态机逻辑把控整个搬移流程的流转把原来靠老师傅盯屏、纸质交接的作业方式变成自动调度、自动记录、异常可追溯的数字化流程。如果你也正在用C#和WPF做类似的上位机项目或者在纠结搬移、流转类控制逻辑到底该怎么设计这篇博文值得你花几分钟看看。里面涉及MVVM在实际项目里的落地方式、Modbus TCP通讯层的稳定性设计、状态机如何处理真实世界里的意外情况还有几条从现场带回来的教训。1. 石墨岛搬移的业务场景与系统边界1.1 上位机到底在管什么先说清楚业务否则后面所有技术讨论都悬空。石墨化炉区通常并排布置几十个炉位每个炉位里码放着待石墨化处理的电极制品。搬移设备——一般是一台横跨炉区的龙门架机械手或者轨道式移载车——负责把电极从一个加工工序的炉位搬到下一个工序的炉位。这套搬移动作听起来简单但实际约束非常多设备什么时候能动作、往哪座岛搬、搬完之后如何确认到位、中途遇到故障怎么办。上位机在这个系统里的角色是大脑和记录仪的结合。它不直接控制伺服电机那是PLC的活而是做三件事向PLC下发明确定量的搬移指令比如目标岛号3任务编号1024开始执行周期性采集PLC反馈的设备状态、当前位置、报警代码把搬移流程中的每一次状态变化、报警记录、操作日志存下来方便事后追溯理解这个边界是项目能否顺利交付的关键。很多刚入手的人会不自觉地往上位机里塞太多东西比如想去改PLC里的电机参数、想在上位机里做PID调节——这些事不是不能做但会让整个系统的安全和可靠模型变得模糊。我的建议是上位机只做流程控制、状态监视和任务调度设备级的运动控制一律交给PLC。边界划清楚了后期的调试和故障排查会省掉大量时间。1.2 为什么不用触摸屏直连有人可能会问搬移动作这么固定用PLC自带的人机界面HMI不就行了大多数石黑车间的原有方案确实是这样。但这类项目一旦涉及以下需求触摸屏方案就会变得非常吃力需要记录每天的搬移历史、报警统计、设备利用率报表需要和生产管理系统的订单数据、工艺参数联动需要支持多台搬移设备同时调度操作界面要实时呈现所有设备的运行状态需要在上位机运行软件逻辑层面的安全互锁而不是单纯依赖PLC梯形图触摸屏的脚本能力和数据存储能力都有限。而且对于操作员来说一个大屏幕上的多窗口状态总览远比一块十寸屏上翻页更直观。这就是为什么这类现场最终都会走到上位机 PLC的分工模式。1.3 WPF在这个项目里的不可替代性技术选型时我也考虑过WinForm和.NET MAUI。WinForm做界面确实快但监控类界面的复杂程度一上来WinForm的布局和样式管理就会变得很难受。MAUI定位是跨平台但在工业现场的工控机上Windows系统仍是绝对主流MAUI在Windows上的成熟度不如WPF。WPF的XAML界面描述能力、数据绑定机制、样式模板体系是它多年在工业上位机领域依然是主选的原因。尤其对于这种状态总览 任务下发 报警列表的界面MVVM模式可以让界面和业务逻辑彻底解耦——做完数据绑定之后界面改版不碰逻辑逻辑调整不动界面。这个优势在项目后期加需求时特别明显。2. MVVM架构落地从项目骨架到实时刷新2.1 项目目录结构与职责划分MVVM不是WPF的附属品而是一套工程组织方法论。很多项目之所以MVVM写着写着变成了MVVM in name only根本原因是最初的目录和职责就没划清楚。下面是我在这个项目里用的目录结构你可以直接当模板抄GraphiteIsland/ ├── Models/ # 设备模型、任务模型、状态枚举 │ ├── MoverDevice.cs │ ├── MoveTask.cs │ └── DeviceStatus.cs ├── ViewModels/ # 各界面对应的ViewModel │ ├── MainViewModel.cs │ ├── MonitorViewModel.cs │ └── DiagnosticViewModel.cs ├── Views/ # XAML页面 │ ├── MainWindow.xaml │ ├── MonitorView.xaml │ └── DiagnosticView.xaml ├── Services/ # 与外部系统交互的服务类 │ ├── ModbusTcpClient.cs # Modbus TCP通讯服务 │ ├── StateMachineService.cs # 状态机引擎与状态定义 │ ├── TaskDispatcher.cs # 任务调度与指令下发 │ └── LogService.cs # 日志记录服务 ├── Commands/ │ └── RelayCommand.cs └── App.xaml.cs这里有一条重要的经验View的职责是唯一的——把ViewModel暴露出来的属性呈现给用户。它不应该响应某个按钮点击之后该干什么这种业务问题只应该抛出命令事件。Models只承载数据和状态不能持有通讯服务实例。Services是唯一的动作执行者。一旦View里出现了点击之后直接new一个服务并调用方法的写法MVVM就已经在退化了。2.2 ViewModel基类与消息通知机制ViewModel的核心是INotifyPropertyChanged。这个接口的含义是当属性值变化时通知WPF的绑定引擎刷新界面。理论很简单但代码写起来很容易重复所以我会先写一个基类public abstract class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; protected void OnPropertyChanged([CallerMemberName] string? propertyName null) PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); protected bool SetPropertyT(ref T storage, T value, [CallerMemberName] string? propertyName null) { if (EqualityComparerT.Default.Equals(storage, value)) return false; storage value; OnPropertyChanged(propertyName); return true; } }命令方面WPF自带的ICommand接口需要一个实现类最常用的就是RelayCommand。它本质上是把按钮点击和执行哪个方法之间的绑定抽象出来public sealed class RelayCommand : ICommand { private readonly Action _execute; private readonly Funcbool? _canExecute; public RelayCommand(Action execute, Funcbool? canExecute null) { _execute execute ?? throw new ArgumentNullException(nameof(execute)); _canExecute canExecute; } public event EventHandler? CanExecuteChanged { add CommandManager.RequerySuggested value; remove CommandManager.RequerySuggested - value; } public bool CanExecute(object? parameter) _canExecute?.Invoke() ?? true; public void Execute(object? parameter) _execute(); }这类代码网上到处都是但有两个细节我实测中经常踩。第一CanExecuteChanged用CommandManager.RequerySuggested是WPF最省事的做法它会在界面交互发生任何变化时自动重新查询命令是否可用第二如果你需要带参数的命令比如传递目标岛号要另写一个泛型版本的RelayCommand 不要在一个类里硬塞object参数然后到处强转。2.3 大屏实时数据的刷新策略上位机界面里通常有一块状态总览区域实时显示每台设备的当前位置、状态、报警信息。新手最容易犯的错误是用一个DispatcherTimer每200毫秒刷新一次界面上的所有文本。这会导致界面在数据量大时明显卡顿操作员看着也别扭。我的做法是把数据刷新任务放在独立的Task循环里而不是DispatcherTimer。通讯层拿到最新数据后更新一个共享的Model对象然后通过Dispatcher向UI线程发送属性更新通知。只有实际变化的属性才触发绑定刷新而不是整个界面无脑重绘。还有一点大量状态值比如设备状态、报警代码用枚举类型来定义比用字符串强得多。枚举天然有限、可穷举状态机的判断、日志的记录、界面的显示都能共用一套定义。字符串容易在各处拼写出错而且无法在编译期发现问题。3. Modbus TCP通讯层寄存器规划与稳定性设计3.1 报文结构、功能码与寄存器规划Modbus TCP的通讯本质是请求-响应模式上位机发一个请求帧PLC回一个响应帧。报文格式比很多人想象中简单。MBAP头7字节 PDU其中MBAP头包括事务处理标识2字节用于匹配请求和响应每次请求自增协议标识2字节Modbus协议固定为0长度2字节后续字节数单元标识1字节相当于从站地址通常和PLC的模块ID对应PDU里最关键的是功能码。这个项目里用到三个功能码功能码含义用途0x03读保持寄存器读取PLC里的状态字、任务号、岛号0x06写单个寄存器下发展复位指令0x10写多个寄存器一次性写入目标岛号任务号指令寄存器规划是整个通讯层设计的地基。我的习惯是先把全系统寄存器表列出来再动手写代码。合理的规划方式是指令区的寄存器全部是上位机写状态区的寄存器全部是PLC写两者绝对不交叉。如果发现某个寄存器一会儿上级写一会儿下级写这个系统的通讯会变得极其混乱。下面是我们这个项目的核心寄存器表简化版寄存器地址方向含义数据类型0x0000上位机写搬移指令1开始2停止3复位Word0x0001上位机写目标岛号Word0x0002上位机写任务编号Word0x1000PLC上报当前岛号Word0x1001PLC上报设备状态对应枚举值Word0x1002PLC上报报警代码Word寄存器地址的偏移以0为基准实际代码里不需要偏移加减但文档里必须明确约定。3.2 轮询与主动上报本项目怎么选Modbus TCP本身是主从协议PLC不会主动向上位机发数据。所以上位机只能主动去读。项目里我在PLC里维护一份状态快照——把当前岛号、设备状态、报警代码全部映射到一组连续的保持寄存器上PLC梯形图在每一个扫描周期里不停刷新这些寄存器。上位机只需要周期性地去读这一块连续区域就能拿到全量状态。这个方案相比上位机分别去读各个离散寄存器有几个好处通讯帧数少网络开销小读写都在一个稳定的地址区域调试时打开Modbus调试工具一眼就能看到所有数据逻辑清晰PLC和上位机的责任边界明确轮询周期我用的是1秒。对于搬移设备这种动作周期在几十秒以上的设备1秒足够实时。如果你要监控高速运动设备那就得把周期压缩到100毫秒但对应的网络负载和PLC处理压力都会上升。建议根据实际设备特性来确定不要一味贪快。3.3 超时、重试与断线重连的实战配置通讯层最大的坑不在正常流程而在网络抖动和设备重启。我把这套机制称为三层防御第一层单次请求超时。使用TCP连接时底层Socket的Connect和Read都可能长期阻塞。一定要给所有读写操作加超时时间我用的是500毫秒。超时后不等了立刻进入重试逻辑。第二层重试与错误分类。连续重试3次仍然失败判定为一次通讯故障。关键点在于是重新发送请求还是重建TCP连接。如果是收到的响应不完整或者校验不对重发即可。如果底层连接已经断开就得重新走TCP连接流程。第三层断线重连。重连不能做成死循环猛捶那样PLC的以太网模块和交换机会被无效连接请求拖垮。我用的是指数退避策略首次重连等待1秒失败后2秒、4秒、8秒最大间隔30秒。一旦重连成功立刻做一次全量状态读取把丢失期间的状态变化补回来。下面是伪代码级别的重连逻辑private async Task EnsureConnectedAsync(CancellationToken ct) { if (_tcpClient?.Connected true) return; var delay TimeSpan.FromSeconds(1); while (!ct.IsCancellationRequested) { try { await _tcpClient.ConnectAsync(_ip, _port, ct); _logger.Info(Modbus TCP连接成功); return; } catch { _logger.Warn($连接失败{delay.TotalSeconds}秒后重试); await Task.Delay(delay, ct); delay TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 30)); } } }有人会觉得这个机制过度设计但现场的真实情况是车间里电磁干扰剧烈变频器一启动以太网偶尔就会丢几个包操作员随手把网线碰松了交换机重启要半分钟PLC在软件升级时也会主动断网几十秒。没有重连机制操作员只能一遍遍重启上位机那这个系统就永远谈不上可用。4. 状态机设计把搬移流程的不确定性压到最低4.1 为什么搬移逻辑必须用状态机搬移流程如果用if-else写初期会觉得很爽——条件分支少逻辑看起来直白。但一旦加入任务下发后设备没有动作怎么办设备走到一半报警了怎么办操作员中途点了急停怎么办这些真实事件if-else的嵌套就会迅速膨胀最后变成一团无法维护的乱麻。状态机的本质是把系统可能处于哪些状态和在什么条件下从一个状态转移到另一个状态显式定义出来。用状态机写的逻辑每一个合法路径都是事先规划好的不在规划内的状态转移根本不会发生。这对工业控制来说意味着安全和可靠。一个很直白的例子在普通if-else代码里你完全可能在搬移中状态下再次下发一条开始搬移指令逻辑上是非法操作但代码可能因为某个逻辑漏洞真的执行了。状态机则从架构上杜绝了这种情况——搬移中状态根本不存在处理开始搬移事件的分支。4.2 状态定义与转移条件表这个项目的搬移流程相对标准我定义了5个核心状态状态含义界面显示Idle待机设备空闲灰色Ready已收到任务等待设备就绪黄色Moving搬移执行中绿色闪烁Arrived已到达目标岛位绿色常亮Fault故障/异常需人工干预红色对应的触发事件定义事件含义TaskAssigned操作员下达搬移任务DeviceReadyPLC反馈设备已就绪MoveStarted设备开始动作MoveArrived设备到达目标岛位FaultOccurred收到报警ResetRequested操作员确认复位状态转移表用简洁的方式表达Idle TaskAssigned → ReadyReady DeviceReady → MovingMoving MoveArrived → ArrivedArrived TaskAssigned → Ready说明可以连续执行下一个任务任意状态 FaultOccurred → FaultFault ResetRequested → Idle前提是报警代码已清除这段逻辑落到代码上我是用一个状态机引擎来管理的public enum IslandMoverState { Idle, Ready, Moving, Arrived, Fault } public enum IslandMoverEvent { TaskAssigned, DeviceReady, MoveStarted, MoveArrived, FaultOccurred, ResetRequested } public sealed class IslandMoverStateMachine { private readonly Dictionary(IslandMoverState, IslandMoverEvent), IslandMoverState _transitions new(); public IslandMoverState CurrentState { get; private set; } IslandMoverState.Idle; public event ActionIslandMoverState, IslandMoverEvent, IslandMoverState? OnTransition; public void Configure(IslandMoverState from, IslandMoverEvent evt, IslandMoverState to) _transitions[(from, evt)] to; public bool Fire(IslandMoverEvent evt) { if (_transitions.TryGetValue((CurrentState, evt), out var next)) { var prev CurrentState; CurrentState next; OnTransition?.Invoke(prev, evt, next); return true; } return false; } }这段代码的核心价值在Fire方法里转移未定义时返回false调用方就能明确知道这条路径未被允许进而记录日志或者提示操作员。比起在业务代码里到处嵌套if判断这个模式清晰太多。4.3 异常处理与安全互锁状态机设计里最重要的一环是异常状态的处理。Fault状态必须是一个黑洞——一旦进入任何其他事件都不能把它移出只有操作员明确确认复位后并且PLC侧的报警代码已经归零才能回到Idle。这听起来像给自己找麻烦但真实工业现场就是这样的设备报警意味着极可能有机械卡阻、电机过载、安全门被打开。这种情况下如果允许软件自动复位然后继续运行一旦机械隐患没有排除后果可能就是设备损坏甚至人身伤害。另外我还要强调状态机是上位机层面的流程管理不等于物理互锁可以省略。PLC梯形图里的硬互锁比如行程开关到位了才能启动电机是最后一道防线上位机状态机只是流程层面防止人为误操作。两者是互补关系不能互相替代。我在项目文档里明确写了上位机状态判断失误最多是多报一个操作提示PLC硬互锁缺失才是真正的安全隐患。5. 车间现场联调三条从现场带回来的教训5.1 电磁干扰下的Modbus TCP稳定性实验室里怎么测都好的程序一到车间就出问题这是上位机开发的成人礼。我们这个项目联调时的第一个问题附近有几台大功率变频器它们启动瞬间的电磁干扰导致Modbus TCP请求偶发超时。现象表现为界面上的设备状态偶尔会跳成通讯异常或者数据刷新卡住几秒。排查过程本身就值回票价。我先用Wireshark在工控机上抓包确认Modbus请求和响应在以太网层实打实地到达了。再在PLC侧通过诊断软件看通讯计数发现确实有请求到了但PLC没来得及响应——说明不是网络丢包是PLC在干扰下处理能力被占用了。最终定位到主要原因是变频器电缆和通讯线在同一个线槽里距离太近。解决措施分两层物理层把通讯线从变频器电缆线槽中挪出来使用带屏蔽层的工业以太网线。软件层把超时重试机制调到更保守的参数并且把通讯故障和设备故障在界面上分开呈现。这里有个经验现场布线不是软件工程师能完全控制的但软件必须有能力在物理层不完美的情况下优雅降级而不是直接罢工。5.2 操作员不按套路出牌与安全确认机制上位机做出来给人用就必然面对人。联调阶段我在现场观察操作员的使用方式发现一个普遍现象只要任务执行时间超过30秒操作员就容易失去耐心开始连续点击界面按钮反复下发指令。状态机在这种情况下起到了作用指令下发后界面立即在逻辑层面忽略了重复点击。但光有忽略不够还得给操作员明确反馈。我在界面上下发按钮上增加了状态锁定任务执行中按钮置灰并且显示任务执行中的提示文字。这看起来是小事但实际效果显著。还有一个需要重点处理的场景是中途干预。设备正在搬移时货架上的操作员喊了一声停一下。正确的做法是什么不是让上位机直接停而是提供一个停止请求指令下发后在界面上弹出确认框提示设备正在运动中停止后需重新校正位置确认停止吗——这一步确认非常必要因为误触停止在整个搬移流程中造成的时间损失比误触开始大得多。5.3 日志记录与远程排查上位机上线之后真正的考验来自远程排障。车间现场告诉我半夜设备报警值班人员可能只有初中文化水平他能做的事只有电话求助。所以日志系统必须做到全、细、可读。我用的方案是Serilog配置成同时写文件和滚动切割。每次状态转移、每次通讯超时、每次指令下发都必须有日志。日志格式按照时间, 组件, 级别, 事件, 附加信息的模式确保在排查问题时能通过时间轴上对齐还原整条事件链。还有一个细节不要在日志里只写状态变更要把变化的来源写清楚。比如状态从Moving转为Arrived触发事件MoveArrivedPLC报文{CurrentPosition:5}。这样排障时能直接看到PLC侧的真实数据而不是抽象的枚举名。我在现场排查时最常用的一条命令是打开日志文件后按级别过滤把ERROR和WARN记录全部拉出来看时间分布。如果某个时段密集出现WARN基本就是现场设备在那个时段有动作。这也是我认为上位机开发中最容易被忽略、但实际价值最高的一个环节。项目交付至今已经稳定跑了几个月中间有过几次小需求变更比如操作员要求增加一个连续搬移多岛的模式——因为状态机的状态表是显式配置的新增一个队列搬移的状态分支只是添加几行转移关系的事界面和逻辑几乎没动。这种好处在项目刚开始设计时体会不深到后期维护阶段才会真正感觉到当初把架构做扎实的价值。如果你也在做类似的上位机项目我的建议是先花时间把业务边界和状态转移表画清楚再动手写界面。通讯层宁可多写50行重连代码也不要等项目上线后深更半夜去车间修。至于WPF和MVVM别迷信框架真正让架构好用的是人的思考习惯。