上周刚从石墨岛搬移上位机的现场验收回来,这个项目断断续续做了差不多四个月,现在总算能坐下来把整套技术方案完整复盘一遍。这条上位机用 C# 配合 WPF 开发,跑在工控机的 Windows 环境下,和下方的 PLC 通过 Modbus TCP 通信,整体架构按 MVVM 模式搭建,搬移流程用状态机管控。石墨岛是光伏产线里承载硅片的石墨工装,搬移机构要在装卸料台、工艺炉管、缓存区之间自动转运它。动作链长、节拍要求高、安全联锁又多,这套软件本质上要回答三个问题:设备现在在哪一步、下一步该干什么、什么情况下绝对不能动。这些问题听起来不难,真正落地的时候,通信稳定性、状态跳变、UI 卡顿、信号抖动,每一个都能让你折腾到半夜。
这篇文章就围绕这个项目展开,把我从选型到交付踩过的坑、最后沉淀下来的方案一次讲清楚。内容主要覆盖四个方面:为什么锁定了 C# + WPF + MVVM 这套组合、Modbus TCP 通信模块怎么设计才稳、状态机如何把整条搬移流程管得明明白白,以及现场调试时遇到的高频问题和排查思路。如果你正在做上位机开发,或者准备用 WPF 做设备控制软件,这些内容应该对你有参考价值。
1. 技术路线评估:为什么 C# + WPF 这套组合在设备控制场景里这么能打
1.1 石墨岛搬移上位机的职责与难点
先把这个项目的背景说清楚。石墨岛本身是光伏电池片产线上的重要治具,它承载硅片进入 PECVD、扩散等工艺设备,所以搬移机构必须准确、平稳地在各个工位之间传递它。搬移机构下方是伺服轴和夹爪模组,电气控制由 PLC 负责,但宏观上的“什么时候该夹取、什么时候该平移、什么时候该放料”这类流程编排,由上位机来决策。
这个上位机不直接驱动电机,而是通过 Modbus TCP 给 PLC 下发命令字,同时持续读取 PLC 上报的状态字、到位信号、报警信号。也就是说,上位机扮演的是“流程大脑”的角色,真正的安全执行留在 PLC 层。项目难点主要在这几个方面:搬移动作链很长,夹取、提升、平移、下降、释放、回位,每一步都要严格判据;产线节拍很快,上位机响应慢了就会拖低整线产能;手动调试模式和自动运行模式要无缝切换,急停状态下任何自动动作都必须立即冻结;还要有清晰的报警记录,方便现场人员排障。这些需求叠加在一起,对软件架构的约束其实很强。
如果用 WinForms 那种“控件拖拽 + 事件里写逻辑”的老思路来做,前期确实快,但做到中后期,状态刷新、界面联动、逻辑复用都会变成一个一个的补丁叠加,越补越乱。我当时和团队开会讨论技术选型时,几乎没有纠结就定了 C# 加 WPF,现在做完回头看,这个选择是整条产线稳定交付最关键的一步。
1.2 和 WinForms、MAUI 对比,WPF 赢在哪里
很多人会问,都是 C#,为什么不用 WinForms?WinForms 开发速度确实不慢,简单界面拖一拖就出来了,但它最大的问题是数据绑定能力弱。设备软件里有大量的实时状态要显示:当前流程处于哪个阶段、各轴位置是多少、报警有哪些。WinForms 的做法通常是 TextBox.Text = xxx,或者定时器循环里手动赋值,界面元素和业务逻辑很容易搅在一起。一旦界面需要大改,逻辑代码跟着动,风险极大。
WPF 的核心优势在于数据绑定和 MVVM 架构原生契合。界面只是 ViewModel 的“投影”,PLC 数据更新到 ViewModel,界面自动跟着变,不需要手工操作控件。再加上 WPF 的样式模板机制,做大屏监控界面,深色背景、状态灯、趋势曲线这些东西,做出来的视觉效果和自由度都远不是 WinForms 能比的。布局上我们用 Grid、StackPanel 配合自定义样式,把设备实时状态做成一块一块的状态卡片,看起来清晰直观。
那为什么不选 .NET MAUI 或者跨平台方案?工控机环境几乎全是 Windows,很多硬件驱动、OPC 客户端、第三方通信库都是 Windows 原生版本,MAUI 的跨平台优势在这里基本用不上。而且 .NET MAUI 相对年轻,工控场景要的是绝对稳定,我不太敢拿产线节拍去赌新框架的成熟度。至于 Qt 或者 Electron,要么开发语言不统一,要么内存占用和进程模型在工控机上显得笨重,都不是最优解。总结下来,WPF 在当前工业上位机场景就是最务实的取舍。
1.3 MVVM 不是“为了用而用”,而是设备软件的必选项
说实话,我第一次接触 MVVM 的时候也觉得这东西有点绕,ViewModel 多了一层,写起来似乎更费劲。但做了几个设备软件之后,我现在的观点很明确:设备控制软件天生适合 MVVM,因为它本质上是“实时数据流驱动的界面”。
设备状态是一堆持续变化的数据:PLC 寄存器里的状态字、伺服位置、报警字。界面是这些数据的可视化。MVVM 的好处在于 ViewModel 把数据和命令暴露给界面,界面不关心数据从哪来;通信层和状态机不关心界面长什么样。这样职责边界清清楚楚,我可以先写通信和状态机逻辑,界面设计完全不阻塞。而且命令机制 ICommand 对应到按钮的行为,CanExecute 直接控制按钮能否点击,这跟设备软件里的安全互锁观念天然吻合——比如只有在自动模式且设备处于待机状态时,“启动流程”按钮才可点,其它时候灰掉,这比在按钮事件里判断状态再 return 要优雅得多。
真实的收益是后期维护。现场调参数、加报警、改流程,如果逻辑全写在窗口事件里,改一个地方容易炸另一个地方;而 MVVM 下改 ViewModel 和状态机,界面层基本不会受影响。这个项目做到第二个月加了好几处功能,界面逻辑几乎没有怎么动,这在传统 WinForms 项目里是不可想象的。
2. Modbus TCP 通信模块:从寄存器规划到稳定连接
2.1 为什么总线方案选了 Modbus TCP
设备软件离不开通信,通信方案的选择直接决定后续开发的复杂度。这个项目里 PLC 支持以太网口,所以我第一轮就把串口 Modbus RTU 排除了——RTU 在距离长、节点多的时候不稳定,而且波特率和从站地址配置容易出错,调试效率低。最后定的是 Modbus TCP,理由很实在:它直接跑在 TCP/IP 协议栈上,PLC、上位机、触摸屏天然互通,不用额外网关,协议本身又简单开放,抓包就能看懂报文,出了问题排查路径短。
有人会提 OPC UA,功能确实强大,安全性、信息模型都比 Modbus TCP 高一个档次。但对于单台上位机连三四台 PLC、传输量不大、场景固定的项目,铺一套 OPC UA 有点杀鸡用牛刀,开发成本也高不少。Modbus TCP 的性价比在中小型设备控制项目里是最高的。如果你将来做大型工厂级数据平台,再考虑 OPC UA 也不迟,那种场景它更合适。
2.2 寄存器规划:把地址表当成 API 来设计
这块是我在这次项目里最想强调的经验:写代码之前,先和电气工程师把寄存器地址表定下来,并且当成一份正式的接口文档来维护。Modbus TCP 本身没有“变量名”的概念,它只有寄存器地址和数据。如果地址表规划得乱,通信代码写得再漂亮也白搭。
我们实际用的地址规划大致是这样:
- 0x0000 到 0x000F:设备状态字区,每一位代表一个布尔状态,比如急停、自动模式、原点、夹爪夹紧到位、提升到位、平移到位
- 0x0010 到 0x001F:上位机命令字区,每一位对应一个命令,比如启动流程、暂停、复位报警
- 0x0020 到 0x003F:各轴实时位置数据,每个轴占两个字,方便上位机做位置显示和趋势记录
- 0x0100 到 0x01FF:报警字区,每位对应一类报警,比如伺服报警、气压不足、超时报警
- 0x0200 到 0x020F:参数配置区,存放行程上限、速度设定、超时阈值等
读操作用功能码 03 读保持寄存器,写单条命令用 06 写单个寄存器,批量修改参数用 16(0x10)写多个寄存器。这里有个细节:状态和命令都建议放保持寄存器,不要贪图方便用线圈。因为线圈在某些 PLC 里会映射到物理继电器输出,语义容易被电气工程师误解,两边对接时容易搞混。保持寄存器相对独立,读写行为也更好控制。
地址表定好之后,建一个 RegisterMap 类,把这些地址全部命名成可读的常量或者枚举。代码里不许出现裸的数字地址,全部通过映射类访问。这样后期点位变更时只改映射类,不用满项目找魔法数字。
2.3 底层封装与断线重连
Modbus TCP 的通信库,我选了 HslCommunication 作为底层驱动,稳定性和功能都很成熟,节省了不少造轮子的时间。核心用法很简单:
var client = new ModbusTcpClient("192.168.1.10", 502); client.Connect(); // 读保持寄存器,起始地址 0,读取 10 个寄存器 var readResult = client.ReadInt16("0", 10); if (readResult.IsSuccess) { var stateWord = readResult.Content[0]; } // 写单个寄存器,地址 0x0010,值 1 client.Write("16", 1);用库是一方面,更关键的是连接管理策略。设备现场的网络环境不像办公室那么干净,电磁干扰、交换机老化、PLC 端口资源耗尽,都可能导致连接断开。我做的第一版是一次断线就报错然后人工重连,现场工人肯定不干。第二版加了自动重连,但是重连太频繁,又导致 PLC 端 TCP 资源爆掉。最终的重连策略是“指数退避”:第一次断开等 2 秒重连,第二次 4 秒,第三次 8 秒,最大间隔不超过 30 秒,连接成功后重置退避计数。
private async Task ReconnectLoop(CancellationToken token) { var delay = TimeSpan.FromSeconds(2); while (!token.IsCancellationRequested) { try { if (!_client.Connected) { await _client.ConnectAsync(); _logger.Info("Modbus TCP 重连成功"); delay = TimeSpan.FromSeconds(2); } await Task.Delay(TimeSpan.FromMilliseconds(500), token); } catch (Exception ex) { _logger.Error($"连接失败,{delay.TotalSeconds} 秒后重试", ex); await Task.Delay(delay, token); delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 30)); } } }另外一个容易忽略的点是上位机和 PLC 的心跳机制。Modbus TCP 是请求响应模式,服务器不会主动推送数据,所以必须由上位机定期去读。我们把状态字的地址区设计成固定周期轮询,轮询周期定在 100 到 150 毫秒。太快了容易给 PLC 造成不必要的负担,太慢了现场操作就会感觉卡顿。如果上位机连续两三秒读不到任何数据,就判定通信中断,这时界面上弹出红色断线标识,并自动冻结所有操作按钮。这里要提醒一下:轮询线程和 UI 线程必须彻底解耦,后文会详细展开。
2.4 状态缓存:别在 UI 线程里直接读 PLC
我见过不少同行写上位机,直接在 UI 线程里发 Modbus 请求,窗口一拖动或者频繁点击按钮,请求全部排队,一个超时就能把整个界面卡死好几秒。这种架构在简单演示时看不出来,到了真实产线,操作员一直点按钮,问题马上就暴露。
正确的做法是建立一层“数据镜像”。后台通信线程定时把 PLC 的所有关键寄存器读取回来,放入一个内存状态对象里。UI 通过绑定访问这层内存对象,而不是直接访问 PLC。通信断掉也没关系,界面至少还保留最后一份有效数据,同时显示断线提示。UI 刷新频率高也只影响界面线程,不会阻塞通信轮询,两者完全隔离。
我在这个项目里给每个从站 PLC 都封装了一个 DeviceStatus 对象,包含状态字解析出来的各个布尔属性,以及位置、报警等。通信线程每轮读取完成后更新这些属性,并且统一在 UI 线程触发属性变更通知。这样界面上的状态灯就像是一块实时刷新的仪表盘,通信模块自身的稳定性反而比界面表现更重要,因为它的后台运行不会受用户操作影响。
3. 状态机管理:让设备动作链“听话”
3.1 石墨岛搬移的流程拆解
石墨岛搬移的动作流程,从机台视角看大概是这样的:
待机 → 收到搬移请求 → 检查夹取允许条件 → 执行夹取 → 夹取到位 → 提升 → 提升到位 → 平移 → 平移到位 → 下降 → 下降到位 → 释放 → 释放完成 → 回位 → 回到待机
这还没算异常分支,比如请求超时、部件未到位、伺服报警、气压不足、安全门打开、急停拍下。任何一个分支处理不好,结果就是碎片或者设备撞机。纯把流程写成 if else 嵌套,第一版看着好像没问题,加两三个异常分支之后,代码就成了一团乱麻。用状态机是这类长流程控制问题的标准解法。
3.2 状态机实现:枚举 + 转移表 + 超时控制
状态机的实现我没有用第三方框架,自己写了一个轻量版本,效果已经很好。核心是三个枚举:状态、事件、转移表。
public enum MachineState { Idle, WaitingRequest, Grabbing, Lifting, Transferring, Lowering, Releasing, Fault } public enum MachineEvent { StartRequest, GrabDone, LiftDone, MoveDone, LowerDone, ReleaseDone, FaultDetected, Timeout }状态转移的核心是定义一个转移表:Dictionary<(MachineState, MachineEvent), Transition>,Transition 里存着动作委托和下一状态。
public class Transition { public Action? Action { get; set; } public MachineState NextState { get; set; } } private readonly Dictionary<(MachineState, MachineEvent), Transition> _transitions = new() { [(MachineState.Idle, MachineEvent.StartRequest)] = new Transition { Action = () => SendCommand(CommandCode.GrabRequest), NextState = MachineState.Grabbing }, [(MachineState.Grabbing, MachineEvent.GrabDone)] = new Transition { Action = () => SendCommand(CommandCode.LiftRequest), NextState = MachineState.Lifting }, // 其它状态转移按实际接线补充 };转移表的优势是“看得见”:整张表就是一个流程地图,逻辑关系一目了然,而不是散布在各种 if 和 switch 里。出问题的时候,只要把当前状态和触发事件打出来,就知道执行到了哪一步、发生跳变的原因是什么。
超时控制是状态机里绝对不能省的一环。每个状态进入时记录时间戳,后台监控线程定时检查当前状态是否超时。比如夹取动作超过 10 秒还没收到夹紧到位信号,就触发 Timeout 事件,转移到 Fault 状态,并弹出报警提示。不同状态的超时阈值不一样:夹取 10 秒、提升 15 秒、平移 30 秒,这些参数都放到配置区,现场可以调整,不用重新编译。
状态机还必须要处理“急停”。PLC 侧急停信号一旦有效,上位机状态机应当立即中止所有自动流程,回到一个安全状态。这里的处理逻辑是:状态机不直接物理急停,那是 PLC 干的事,但上位机会冻结后续所有命令下发,并且把界面切到“急停故障”提示状态,等待人工复位。急停优先级的实现,可以把急停信号当作最高优先级的独立输入,不参与转移表的常规流转,单独一个判断分支直接跳转。
3.3 上位机状态机与 PLC 状态机如何分工
这里要讲一个很容易犯迷糊的点:PLC 里其实也有一套状态逻辑,上位机再搞一套状态机,会不会冲突?实际上两套各自职责不同,配合起来反而最安全。
PLC 状态机负责底层执行和硬保护。它直接控制伺服驱动器、气缸电磁阀、夹爪电机,读取物理限位、光幕、急停回路,一旦检测到危险立即本机停机,不依赖上位机。上位机状态机负责流程编排和宏观决策。它判断“要不要进行下一步”,然后下发命令给 PLC;PLC 执行完一个动作后反馈到位信号,上位机收到信号再决定后续。
上位机关机或者通信断了,PLC 也能独立保证设备安全。这就是设备软件设计的兜底原则:所有直接危及人身和设备安全的逻辑,必须放在最底层,而不是放在依赖网络通信的上位机里。上位机状态机出问题时,停止发送命令即可,设备最多停住,不会乱动。
4. MVVM 落地过程中的模式与坑
4.1 项目结构:三个项目明确分工
MVVM 架构如果只在 WPF 项目内部“假分层”,代码最后还是乱。我把解决方案拆成三个独立项目,让依赖关系变成单向的:
- GrapheneIsland.Host:WPF 应用,存放 Views 和 ViewModels
- GrapheneIsland.Communication:Modbus TCP 通信、寄存器映射、设备管理
- GrapheneIsland.Core:状态机、流程调度、日志
依赖方向是 Host 依赖 Communication 和 Core,Communication 和 Core 完全不依赖 UI。好处很直接:通信模块和状态机逻辑可以写单元测试,在脱离界面的情况下验证正确性。现场出问题的时候,我可以在日志里看到状态机的每一步变化,而不是只能对着界面猜。
ViewModels 的代码组织上,我只用了一个基础 ViewModelBase 和自定义 RelayCommand。这个项目不算特别大,没有引入 Prism 或 CommunityToolkit.Mvvm 这种完整框架,但如果你想做更大的项目,社区常用框架是有价值的,可以少写很多模板代码。我自己的实现其实也很简单:
public class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; protected void OnPropertyChanged([CallerMemberName] string propName = "") => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName)); } public class RelayCommand : ICommand { private readonly Action _execute; private readonly Func<bool>? _canExecute; public RelayCommand(Action execute, Func<bool>? canExecute = null) { _execute = execute; _canExecute = canExecute; } public bool CanExecute(object? parameter) => _canExecute == null || _canExecute(); public void Execute(object? parameter) => _execute(); public event EventHandler? CanExecuteChanged { add => CommandManager.RequerySuggested += value; remove => CommandManager.RequerySuggested -= value; } }RelayCommand 的 CanExecute 与设备状态挂钩后,按钮可用性就由界面自动推导。状态机处于待机且自动模式时,CanExecute 返回 true,启动按钮可点;设备正在搬移流程中,按钮自动置灰。不再需要手工去给按钮设置 Enabled,省掉一堆状态同步代码。
4.2 属性变更通知:绑定不刷新多半是这里出了问题
MVVM 新手最常见的问题就是界面绑定好了,运行起来却不更新。大多数原因就是属性没有触发 PropertyChanged。我见过有人图省事,在 Model 层和 ViewModel 层混用 INotifyPropertyChanged,结果一个 ViewModel 对应多个 Model 属性,更新时只通知了一个,界面其它关联区域跟着错误。
我的做法是:只要是从 PLC 数据映射上来的状态字段,全部集中在 ViewModel 里更新,并且每次更新完统一刷新关联属性。比如“设备总体状态”是一个综合属性,它依赖夹爪状态、轴位置、报警状态好几个数据源,那就在这几个数据源 OnPropertyChanged 之后,再额外触发一次 OverallStatus 的通知,保证界面联动刷新。
另外还要仔细检查绑定模式。默认的 BindingMode 在某些属性上是 OneWay,如果你要实现双向修改,必须显式设置 TwoWay。设备参数的配置界面里,我所有输入框都用了 TwoWay 绑定,并且加 ValidatesOnExceptions 和 UpdateSourceTrigger=PropertyChanged,这样用户输入非法字符时能立刻看到红框,而不是等到提交才报错。
4.3 跨线程更新:后台轮询如何安全驱动 UI
通信层和状态机都在后台线程运行,它们产生的数据变化需要反映到界面上。WPF 里有个铁律:只有 UI 线程可以访问 UI 元素。跨线程更新如果直接操作控件对象,会抛“调用线程无法访问此对象”的异常。
MVVM 模式天然规避了这个问题,因为它不直接操作控件,而是更新 ViewModel 属性。但 INotifyPropertyChanged 事件如果是在后台线程触发的,绑定的属性值更新最终还是会走到 UI 线程的控件上,这依然有跨线程问题。最简单的解决方案是在 ViewModel 的属性更新入口统一用 Dispatcher 切到 UI 线程再触发事件。
private void SafeUpdate(Action updateAction) { if (Application.Current.Dispatcher.CheckAccess()) { updateAction(); } else { Application.Current.Dispatcher.Invoke(updateAction); } }注意不要里三层外三层到处塞 Dispatcher.Invoke,那会导致后台线程被 UI 线程阻塞,反过来拖慢通信轮询。正确的方式是:后台线程维护一个轻量状态对象,以事件或队列通知方式告诉 UI 线程“有数据要更新了”,UI 线程统一处理。我实际上用的是 Channel 或者简单的并发队列,后台线程把数据包 Post 进去,UI 线程定时批量取出来更新 ViewModel。轮询和数据刷新频率分离之后,CPU 占用明显降低,界面拖动时也不会给通信线程添堵。
4.4 数据刷新的性能优化:从全量刷新到定向刷新
刚开始做实时监控界面时,我用了很粗暴的方式:每次通信线程读完所有寄存器,就把所有属性全部触发更新,界面把所有状态控件全部重绘一遍。这种写法在调试时没问题,到了产线,刷新频率一旦提高,CPU 占用立刻上去了。
后来优化成“脏标记 + 定向通知”。后台线程把一个数据包解析完后和上一份数据比较,只有变化的字段才标记为 dirty,通知 UI 线程只更新对应属性。搬移机构的轴位置有一个专门的坐标显示区,但是位置数值每次变化都刷新 TextBlock 也会有开销,实际我们做了一层“节流”:位置数据每 500 毫秒固定刷新一次,而状态字、报警字这类关键数据保持 100 毫秒的高频刷新。这样既保证了安全信号实时可见,又不会拖垮界面性能。
如果你做的界面是大量点位列表或者图表,还可以考虑 WPF 的 UI 虚拟化和异步渲染。石墨岛搬移上位机的点位不算海量,用不到虚拟化那么重,但 Setter 的样式尽量放在 Style 里而不是直接在 XAML 元素上重复定义,这个原则任何时候都适用。
5. 现场调试实录:那些把我折磨到半夜的问题
5.1 一个“神秘”的网络闪断:PLC 端口的连接数量限制
这个坑是现场调试第二天出现的。设备运行十来分钟后,上位机开始出现间歇性 Modbus TCP 超时,然后自动重连,重连成功后跑几分钟又超时,反反复复。查电脑和 PLC 之间的网线、交换机、IP 配置都没问题,后来用抓包工具一抓才发现端倪:重连太频繁时,旧 TCP 连接没有完全关闭,PLC 端的连接资源被耗尽,新的连接握手迟迟完成不了导致的临时拒绝。
排查思路很简单:上位机断线重连后,旧 Socket 如果还处于 TIME_WAIT 状态,端口资源就被占用;反复多次后 PLC 侧 socket 耗尽。解决方法有两条:一是重连的 Socket 设置 LingerOption,关闭时立即释放;而是强制把重连退避最小间隔拉长到 2 秒以上。同时我还把工控机的 Windows 防火墙入站规则对 PLC 网段放行了 Modbus TCP 端口 502,避免防火墙规则干扰长连接。改完这三处,网络闪断彻底消失。
现场运维时如果遇到类似的“掉线又自动恢复”问题,先别急着怀疑硬件,抓包工具看一分钟流量,大概率能找到答案。
5.2 状态机“跳步”问题:布尔信号的抖动与滤波
状态机做完之后,试运行阶段出现过一个吓人的问题:启动流程后,夹取还没到位,状态机却已经收到了“夹取完成”的信号,跳去执行提升动作。当时差点真的撞了设备,还好下方机械限位挡住了。查 PLC 程序没发现逻辑错误,最后定位到是信号抖动——夹爪气缸将到未到位的瞬间,磁性开关或继电器触点抖动,信号在几个毫秒里反复翻转。上位机轮询恰好读到一次高电平,就当成一次有效事件了。
解决方法是给所有到位类信号加“消抖滤波”:不是读一次为 true 就算,而是连续 N 个轮询周期都读到 true,才认为信号稳定有效。轮询周期 100 毫秒,N 取 4,也就是说一个到位信号必须稳定维持 400 毫秒左右,上位机才认。代价是系统响应会延后不到半秒,对于搬移流程来说完全可以接受。这个教训我记了很久,设备信号处理如果不做消抖,再高级的状态机也白搭。
5.3 界面卡顿和“假死”:别在 UI 线程里等待后台任务
项目联调期间,操作员反馈界面有时候会突然卡好几秒,点按钮没反应。排查完发现是界面线程里有一段代码用了 Task.Run(...).Wait() 等待通信结果。这个写法看似用了多线程,实际上 UI 线程被阻塞在 Wait() 上,界面当然假死。而且通信一旦超时,UI 线程要等完整个超时周期才能恢复,表现就是卡三四秒。
这类问题用三个方案彻底解决:所有 Modbus 通信调用全部改成真正的异步/后台轮询模式,不在 UI 线程发起阻塞式请求;日志写入也挪到后台队列,避免 UI 线程写文件慢;界面按钮通过命令的 CanExecute 禁止重复点击,防止操作员在认为“没反应”时连续狂点按钮。一个操作频繁的设备上位机,必须从设计上就避免 UI 线程做任何可能阻塞的操作。
5.4 多路 UVC 摄像头:区分设备不是靠索引
石墨岛搬移机构上我加了两个 USB 摄像头,一个看工装定位,一个看夹爪状态。用 C# 的 DirectShow 相关技术做视频采集时,踩过一个典型的坑:插线顺序一变,摄像头的索引就换了,程序里固定用 deviceIndex 打开摄像头,结果视频画面串了。后来查资料才明白,USB 摄像头枚举时设备索引是动态分配的,不可靠。
可靠的区分方式是设备路径或者实例 ID。Windows 下可以通过设备枚举拿到摄像头的 DevicePath,那串唯一标识符基本不变,用它来建立“物理摄像头 → 逻辑用途”的映射。回调里区分多路摄像机画面时,也以这个标识作为 key 来分发图像帧,而不能靠顺序。具体到这个项目,我还在配置界面加了一个摄像头映射工具,把采集到的画面和实际位置对应检查,避免混淆。
5.5 日志与报警:设备软件除了功能,更要能“证明自己”
设备出故障时,操作员一句“它刚才没反应”就够研发查一天的。所以这个项目从第一天开始就强调日志。除了常规的信息日志,状态机每一步转移都会记录“当前状态、触发事件、目标状态、动作、时间戳”;Modbus 读写关键命令也会记录消抖前后的信号值。这些信息在问题排查时非常关键——直接能看到设备真实经历了什么,而不是靠人去回忆。
报警界面我用了两级设计:顶部是一排红色和黄色的状态灯,一眼看出当前有没有报警;下方报警列表按时间倒序显示,每条报警包含时间、级别、内容、复位按钮。所有报警记录还会同步写数据库,方便产线追批次。实际上产线上很多设备问题,追根到底都是信号层面的时序问题,没有日志找线索就是大海捞针。
6. 回头再看这套方案:经验沉淀与可扩展方向
验收完成后我再回头看这个项目,最大的体会是:设备上位机开发,真正难的不是某个单一技术,而是把所有技术拼成一个可靠整体的能力。C# 和 WPF 提供了趁手的工具,MVVM 让代码结构保持清爽,Modbus TCP 通信模块解决了现场数据交换,状态机让复杂的动作流程变得可预测、可验证。但每一部分都不是孤立存在的,通信模块要喂数据给 ViewModel,ViewModel 要把状态变化转成状态机事件,状态机再把决策结果发到命令寄存器,整个环环相扣。
如果后续还要扩展,我觉得有两个方向值得做:一是把状态转移表和流程参数外置到配置文件,现场调整流程顺序或者新增工位时不用改代码重新编译;二是如果设备数量多起来,多个上位机连同一个调度系统,那状态机的角色就要变成“执行器”,上面再加一层调度状态机,那时候可能需要引入更完整的队列管理和任务分配机制。这个项目目前的规模还用不上,但提前意识到这个演进方向,架构上不要把自己的路堵死,总归是好事。
最后分享一个纯个人心得:做这类软件,一定要多花时间在现场待着。很多问题在办公室写代码时根本想象不到,但只要你蹲在设备旁边看两次完整的搬移动作,听一听夹爪气缸的声音,看一看状态灯的颜色变化,立刻就能建立起对软件质量的直觉。这套石墨岛搬移上位机,就是靠着一遍遍在现场观察、调整、打磨,才从“能跑”变成“能交付”。