恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C# WPF MVVM打造半导体设备上位机:PLC通信与状态机实战
首页
资讯中心
/
C# WPF MVVM打造半导体设备上位机:PLC通信与状态机实战
C# WPF MVVM打造半导体设备上位机:PLC通信与状态机实战
发布时间:2026/9/11 5:47:17
1. 半导体搬移场景的硬约束为什么最后选了 C# WPF MVVM这事得从我去年接的一个项目说起。客户是做半导体设备配套的要我在三个月内给一套晶圆与石墨岛搬移设备做上位机系统。设备本体已经由机械和电气工程师搭完了运动控制卡、西门子 S7-1200 PLC、气缸、真空吸附、扫码枪、一堆传感器全都有唯独缺一个能让操作员看得见、点得动、而且能留下完整追溯记录的大脑。产线上的实际情况比想象中复杂得多。晶圆在半导体过程里是核心载体石墨岛则是用来承托晶圆进出高温炉管的关键部件两者之间的搬移动作必须极其精准。晶圆薄而脆石墨岛笨重而怕磕碰机械手每动一次都牵涉到真空检测、位置互锁、动作时序。一旦搬移过程中出了偏差轻则碎片重则整批产品报废损失是六位数起步的。所以上位机系统的第一要求不是花哨而是稳定、透明、可追溯。当时技术选型有两个方向在纠结一个是传统的 WinForms开发快、资料多但界面做出来确实不够现代客户设备要出口对外观有要求另一个就是 C# WPF绑定机制、模板系统、动画能力都明显强于 WinForms而且 MVVM 模式下界面和逻辑分离得干净后续加功能不会牵一发动全身。我最后选了 WPF而且用了 Prism 这个 MVVM 框架原因后面会细说。这套系统最终要解决的核心问题其实可以拆成四块通信PLC 扫码枪 MES 的 Socket、数据采集与展示实时曲线、状态灯、工艺流程编排自动搬移、报警互锁、以及人机交互界面手动操作、配方管理、日志追溯。四块内容互相牵连这也是上位机项目里最容易翻车的地方。如果一开始架构没理顺后面每加一个功能都是在拆东墙补西墙。先说我最终定的技术栈C# 8.0 .NET Framework 4.7.2设备电脑是老工控机Windows 10 IoT 系统不想冒险上跨版本、WPF Prism 8、HslCommunication 做 PLC 通信、LiveCharts2 做实时曲线、SQLite 存日志、Newtonsoft.Json 做 Socket 协议序列化。这套组合在工业现场已经经受过很多项目验证属于比较稳妥的搭配。2. 通信层设计PLC、扫码枪、Socket 三路数据怎么理顺2.1 西门子 S7-1200 通信的实现细节与坑搬移设备的动作控制全靠 PLC上位机需要做的事情是读取 IO 状态、下发动作指令、读写配方参数。通信方式我用了 S7 协议直连不走 OPC 网关原因很简单减少一个中间环节就减少一个故障点。这里用到的库是 HslCommunication它封装的 SiemensS7Net 类用起来相当顺手。核心代码大概长这样SiemensS7Net plc new SiemensS7Net(SIEMENS_S7_1200, 192.168.0.10); plc.SetPersistConn(new TimeSpan(0, 0, 30)); // 30秒内复用连接 // 读取M区连续数据 OperateResultbool[] inputs plc.ReadBool(M10.0, 16); // 写Q区或DB块 plc.Write(DB1.DBD0, (float)temperatureSetPoint);有个特别容易踩的坑S7-1200 默认对 DB 块开启了优化的块访问这种块在外部读取时地址会变乱。必须让电气同事在博途里把需要上位机读写的 DB 块属性改成非优化访问取消优化的块访问勾选否则你读出来的数据就是乱的而且是那种时好时坏的乱排查起来特别浪费生命。还有个细节是通信要放在独立线程里轮询不能直接在 UI 事件里读写 PLC。我开了三个后台任务一个每 100ms 读一次状态字IO 变化要快一个每 500ms 读一次模拟量温度、真空度、压力一个处理写入指令队列。写入指令用 ConcurrentQueue 来排队避免多个线程同时写 PLC 造成数据竞争。2.2 扫码枪触发事件与晶圆 ID 追溯晶圆搬移必须做到逐片追溯。扫码枪我选的是串口模式的工业扫码枪触发方式有两种一是外部传感器触发扫码二是操作员按键触发。串口模式下扫码枪就是一台上位机的外设数据会通过串口事件送进来。串口接收的代码用 SerialPort.DataReceived 事件这和很多人想的不一样——这个事件是在线程池线程上触发的不是在 UI 线程。所以扫码结果要抛到 UI 线程才能更新界面否则就会出现数据到了界面没反应或者直接抛异常的情况。SerialPort scanner new SerialPort(COM3, 115200, Parity.None, 8, StopBits.One); scanner.DataReceived (s, e) { string data scanner.ReadExisting(); // 通过同步上下文切回UI线程 Application.Current.Dispatcher.Invoke(() { viewModel.HandleScanResult(data.Trim()); }); };这里有个设计上的心得扫码结果不要直接在页面里写死处理而是通过事件消息走 Prism 的 EventAggregator 发布。因为一个扫码枪可能在不同界面用到手动模式下扫完要绑定到当前工位自动模式下扫完要触发配方校验追溯模式下扫完要查询历史记录。用事件解耦后每个模块订阅自己关心的事件就行互不干扰。扫码枪还有个容易忽略的问题返回的数据通常带 STX/ETX 或者 CR/LF 结尾有些枪还支持前缀配置。我在初始化时就把扫码枪配置成仅输出码内容回车省去了解析的麻烦。这个一定要和供应商确认不同品牌默认设置差异很大。2.3 Socket 通信与 MES 对接的协议约定设备不是孤立的晶圆 ID 扫出来之后要上报给 MES制造执行系统同时要从 MES 拉取当前批次的产品配方。这里用了 TCP Socket自定义了一个非常简单的 JSON 报文协议。报文格式固定为4 字节长度头 UTF-8 JSON 正文。这是我见过工业现场最常见也最稳妥的做法比 SocketAsyncEventArgs 硬解析字节流好维护多了。class MesMessage { public string MsgId { get; set; } // 消息ID用于关联请求与响应 public string MsgType { get; set; } // ScanReport / RecipeQuery / Heartbeat public string Payload { get; set; } // 具体业务数据JSON }客户端主动连接 MES 服务器这比服务器端监听端口简单因为不用担心防火墙设置。连不上时要自动重连我做了指数退避策略第一次等 2 秒然后 4 秒、8 秒、16 秒最大 60 秒封顶避免断网时疯狂重连占用带宽。3. 数据采集与实时曲线不让 UI 卡成 PPT 的关键设计3.1 循环数据采集与 UI 刷新卡顿的根因这是整个项目里最容易被写崩的地方。很多人写实时曲线直接在 PLC 读取循环里更新图表控件结果跑几分钟 UI 就卡得不能动。根因很简单图表控件每一个点的刷新都触发一次完整的布局和渲染而 WPF 的 UI 线程只有一条数据更新太快就会把 UI 线程堵死。我的做法是把数据采集和 UI 刷新彻底分离采集线程每 100ms 把数据存入一个环形缓冲区我用的是数组加索引避免频繁分配内存UI 侧每 200ms 通过 DispatcherTimer 去取缓冲区里的最新数据一次性更新图表图表控件用的 LiveCharts2它有批量更新机制一次 Set 几百个点也不会太吃力。// 采集线程线程池 Task.Run(() { while (_isRunning) { var temp plc.ReadFloat(DB2.DBD4); // 炉温 var vacuum plc.ReadFloat(DB2.DBD8); // 真空度 _buffer.Add(new SampleData(DateTime.Now, temp, vacuum)); Thread.Sleep(100); } }); // UI线程 每200ms刷新 DispatcherTimer timer new DispatcherTimer() { Interval TimeSpan.FromMilliseconds(200) }; timer.Tick (s, e) { var latest _buffer.GetAll(); chart.Series[0].Values latest.Select(x x.Temp).ToArray(); chart.Series[1].Values latest.Select(x x.Vacuum).ToArray(); }; timer.Start();关于通道间数据不同步的问题也要注意。PLC 里温度、真空度不是同一个地址连续区域分两次读就会存在时间差。我的处理是给每组数据打时间戳绘制曲线时以时间戳对齐而不是以读取次数对齐。这样即使某次读取失败导致某一通道少一个点曲线也不会整体错位。3.2 LiveCharts2 在 WPF 里的实战配置LiveCharts2 是现在 WPF 里比较好用的图表库它的前身 LiveCharts 已经停更了所以新项目我直接用的 LiveChartsCore.SkiaSharpView.WPF 这个包。配置一个实时曲线图其实不难关键是要理解它的 ViewModel 绑定方式。我在 VM 里维护一个 ISeries 数组把它绑定到 CartesianChart 控件的 Series 属性public ISeries[] TemperatureSeries { get; set; } new ISeries[] { new LineSeriesdouble { Name 炉温, Values new double[600], // 预分配600个点 Stroke new SolidColorPaint(SKColors.OrangeRed) { StrokeThickness 2 }, GeometryFill null, // 不画点曲线更流畅 GeometryStroke null } };有一点必须说明如果不是做大数据量展示别把 Values 绑定成 ObservableCollection 然后一个点一个点 Add。LiveCharts2 虽然性能不错但每次集合变更通知都会触发重绘。我是直接替换整个数组并且预先分配好长度用滑窗的方式覆盖旧数据这样曲线绘制的开销最小。曲线刷新频率我最终定在 200ms。低于 100ms 根本看不出意义人眼对超过 10Hz 的变化不敏感反而徒增 UI 负担高于 500ms 操作员会觉得曲线是顿的在观察温度趋势时不直觉。实测下来200ms 既能满足操作员观察需求CPU 占用也一直稳定在 5% 以下。工控机配置不高这个指标很重要。遇到过一个小坑LiveCharts2 在首个点数据为 NaN 或者双精度无穷大时绘制会异常曲线直接消失。所以我们上山温度之前先灌 0等真值到了再覆盖要保证数组里永远没有 NaN。如果你不想引入 LiveCharts2OxyPlot 也可以做同样的事它更轻量、文档也全但交互能力略弱。图表库选型这种事情够用就好别为了炫技来回切换时间都浪费在熟悉 API 上了。4. 搬移工艺流程编排用状态机替代一坨 if else4.1 动作序列与互锁逻辑的设计思路晶圆从料盒搬到石墨岛动作序列是固定的机械手移动到取片位、Z 轴下降到晶圆上方、真空吸附、Z 轴抬升、旋转到目标位置、Z 轴下降、吹气释放、Z 轴抬升归位。整个过程如果在代码里用 if else 硬写动作一多起来就会变成一团乱麻而且你很难在中间插入暂停、急停、复位这些异常处理。我用的是一个很轻量的状态机方案不至于引入沉重的第三方工作流引擎。核心是三个类型状态枚举、状态处理器、状态转移表。public enum RobotStep { Idle, MoveToPickPosition, DescendToWafer, EnableVacuum, AscendWithWafer, RotateToTarget, DescendToGraphite, DisableVacuum, AscendToHome, Done }转移表是一个字典每一个状态下定义了成功后的下一步和失败后的转移策略。执行循环里拿到当前状态对应的处理委托调用它根据返回结果决定下一步。_stepTransitions new DictionaryRobotStep, StepTransition { [RobotStep.MoveToPickPosition] new StepTransition( execute: () _plc.WriteBool(Q0.0, true), // 触发气缸动作 check: () _plc.ReadBool(I0.1), // 到位传感器 success: RobotStep.DescendToWafer, timeout: TimeSpan.FromSeconds(10), failure: RobotStep.HandlePickPositionTimeout) };这样做的最大好处是每个动作的互锁条件都在check里写清楚超时时间和失败处理也都集中在一处。出了报警上位机界面能直接显示出哪一步没到位、到位信号持续了多久、下一步动作是什么现场电气工程师看了就能去查不用翻代码。4.2 异常处理与急停恢复宁可停不能乱动搬移设备里最危险的是带片乱动也就是真空没吸住晶圆但机械手照样跑了或者目标位置有人/有障碍物但设备照样降下去。这种情况我在状态机里加了重度防护真空吸附后必须等待 300ms然后读取真空压力值压力必须低于设定阈值才认为吸附成功每个动作的超时时间单独配置不在代码里写死。比如真空建立超时是 2 秒X 轴移动到位超时是 10 秒不同轴的机械特性不一样急停信号通常是硬接线到 PLC一旦触发上位机立刻停止下发任何新动作只显示当前状态和报警信息。复位时由操作员手动点击复位系统才会让状态机回到 Idle准备新一轮流程。这里我想多说一句很多新手容易忽略上位机无论怎么设计都只是辅助真正的安全保护一定要靠 PLC 硬逻辑和机械限位。上位机追求的是让动作过程透明、让异常快速定位而不是替代 PLC 去做安全联锁。所以和电气工程师对接口信号表时要把每个互锁信号的含义、高低电平有效、信号更新周期都确认清楚这个沟通成本省不得。4.3 配方管理与批次号绑定不同批次的晶圆厚度不同、石墨岛层数不同对应的机械手吸取高度和吹气压力也不同。所以我建了一张配方表存 SQLite内容包括配方号、晶圆厚度、吸取高度、吹气压力、真空阈值、每个步骤的超时时间等。自动流程开始时上位机先从 MES 拉取当前批次配方绑定扫码得到的晶圆 ID然后把配方参数下发到 PLC。操作员也可以在手动模式下预先加载配方库确认参数避免自动运行时才发现参数不对。SQLite 的使用很简单用 Microsoft.Data.Sqlite 这个库表结构和查询都很直观。数据库文件就放在工控机本地目录定时备份到 U 盘或网络共享。追溯数据量不大一年也就几十万条SQLite 完全扛得住没必要上 SQL Server。5. 界面工程化WPF 模板、转换器与 MVVM 框架实践5.1 用自定义模板重构设备状态视图设备状态页如果只用 TextBlock 和 Button 堆界面上几十个状态灯、几十个阀门开关会乱到没法看。我用 WPF 的 DataTemplate 把设备状态做成了卡片式视图每台设备是一张卡片卡片上有设备名、运行状态、当前动作、关键参数。卡片内部的核心是一个自定义的状态指示灯控件。它本质上是一个 Border Ellipse通过样式绑定状态值来切换颜色和闪烁效果Ellipse x:NameLight Width14 Height14 Ellipse.Style Style TargetTypeEllipse Style.Triggers DataTrigger Binding{Binding DeviceStatus} ValueRunning Setter PropertyFill Value#2ECC71/ /DataTrigger DataTrigger Binding{Binding DeviceStatus} ValueAlarm Setter PropertyFill Value#E74C3C/ DataTrigger.EnterActions BeginStoryboard Storyboard DoubleAnimation Storyboard.TargetPropertyOpacity From1 To0.2 AutoReverseTrue Duration0:0:0.4 RepeatBehaviorForever/ /Storyboard /BeginStoryboard /DataTrigger.EnterActions /DataTrigger /Style.Triggers /Style /Ellipse.Style /Ellipse这样一个控件配合 Converter就把布尔型的报警信号转换成了人眼容易感知的红色闪烁。现场操作员不用懂英文看到灯在闪就知道这台机出问题了这在复杂的搬移设备上特别重要。5.2 转换器把 PLC 的 int 翻译成人类语言PLC 里的数据基本都是数字设备状态寄存器是 int真空压力是 float报警代码也是 int。直接把这些数字怼到界面上操作员看着是一头雾水的。所以要写一堆 IValueConverter把数字翻译成状态文本和颜色。我最常用的几个转换器BoolToVisibilityConverter控制面板是否可见用于权限管理IntToDeviceStatusConverter把 PLC 返回的状态字转换为枚举再配合 DataTrigger 显示文字和颜色FloatToTemperatureConverter保留一位小数超过设定范围时返回红色 Brush。这个可以在 Converter 参数里传上限和下限。public class AlarmStateToBrushConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { if (value is AlarmState state) { return state switch { AlarmState.None Brushes.LightGreen, AlarmState.Warning Brushes.Orange, AlarmState.Critical Brushes.Red, _ Brushes.Gray }; } return Brushes.Gray; } }转换器写得好不好直接影响后续维护成本。建议把常用的转换器放到单独的项目里统一管理并且写好单元测试。特别是那种既有输入又有参数的转换器很容易在改界面时因为参数类型不匹配炸掉。5.3 Prism 在项目里的实际分工Prism 这个框架在 WPF 社区评价一直两极分化但在我这个项目里确实带来了实打实的好处。我用了它的三个核心能力模块化把设备控制、数据采集、历史查询、用户管理拆成独立模块每个模块有自己的 Region 和导航。这在一开始规模不大的时候看起来有点「杀鸡用牛刀」但项目后期加需求时优势很明显我只需要在新增模块里写自己的逻辑不影响已有页面DelegateCommandWPF 自带的 Command 用起来不够灵活Prism 的 DelegateCommand 支持泛型还能通过 RaiseCanExecuteChanged 动态控制按钮可用性。比如设备在自动运行中手动移动按钮全部置灰这个就是 CanExecute 的典型用法EventAggregator扫码枪事件、MES 连接状态变化、报警信息广播全都走事件总线。解耦效果很好我在 2.2 节里提到的扫码结果就是靠它分发的。当然Prism 的学习曲线确实存在如果项目很小、只有两个界面不上框架反而更快。我的判断标准是如果系统里明显存在多个模块之间需要通信、需要共享状态的情况就上 Prism如果只是单页面 Demo直接用原生绑定就好。6. 实测中的问题与排查链路这些坑我替你们踩过了6.1 现象程序跑一段时间 UI 明显卡顿曲线掉帧排查链路是这样的第一步打开任务管理器看 CPU 和内存。发现 CPU 占用并不高但内存持续上涨。于是判断不是计算密集型问题而是内存泄漏或集合无限增长。查代码发现实时曲线的历史数据 List 没有做上限控制一直 Add 下去GC 压力越来越大。第二步把历史数据改成环形缓冲限制最多保留 5 万条记录内存曲线立刻平坦了很多。第三步继续观察发现卡顿仍然存在但频率降低了。进一步定位发现是 UI 线程里有大量 String 拼接。因为每 100ms 要更新设备状态把十几个 PLC 点位都拼成一个字符串再绑定到 TextBlock这种高频拼接会引发严重的 GC 分配。第四步改用 StringBuilder 拼接、并且把高频更新的控件从绑定改为直接赋值卡顿问题才彻底解决。经验总结WPF 上位机里 UI 卡顿 90% 以上是数据量无限增长或者 UI 线程做重活导致的不是 WPF 本身性能差。定位时不要瞎猜用 WinDbg 或者 Visual Studio 的性能分析器抓一下托管堆看看什么类型的对象数量在暴涨基本一次就能找到病根。6.2 现象Socket 偶尔连不上 MES过一会儿又自己恢复了这个问题出现在长时间运行之后。排查第一步在服务端看连接状态发现大量 TIME_WAIT 状态的连接堆积。第二步检查上位机代码确认每次连接 MES 时是否正确地关闭了网络资源。结果发现在一次异常分支里 TcpClient.Close() 没被调用导致连接没有正常释放。修复方案很简单TcpClient client null; try { client new TcpClient(); await client.ConnectAsync(ip, port); // ... 通信逻辑 } finally { client?.Close(); // 确保释放 }这里还要解释一个TCP 连接数量的经典问题Windows 下 TIME_WAIT 状态的连接在 2~4 分钟内不会被系统回收如果端口耗尽就会出现连不上的假象。解决思路是让连接复用不要频繁地建立和关闭。我把 MES 通信改成了长连接 心跳机制每 15 秒发一个 Heartbeat 报文服务端 45 秒收不到心跳就判定离线。这样 TCP 连接数始终保持在个位数问题彻底消失。6.3 现象VS2022 里新建项目时找不到 WPF 应用模板这个问题很让人哭笑不得。换了一台新电脑装了 VS2022打开新建项目搜WPF只看到控制台和类库WPF 应用模板死活不出现。其实原因很简单安装 VS2022 时没勾选.NET 桌面开发工作负载WPF 模板不会默认安装。解决办法有两个在 Visual Studio Installer 里点击修改勾选.NET 桌面开发等待安装完成不用装 Visual Studio直接命令行dotnet new wpf -n MyWpfApp前提是安装了 .NET SDK。VS2022 用这个命令创建的 .NET 6/8 项目也能正常打开。这个问题其实提醒了一件事遇到模板不见了先别急着重装检查工作负载是最快路径。我还遇到过 WinForms 模板在但 WPF 模板不在的情况原理完全一样都是工作负载未勾选。6.4 关于 WPF PropertyGrid 的替代方案项目里有用到属性面板来展示配方参数的需求一开始想直接用 WinForms 里的 PropertyGrid 控件拖到 WPF 里虽然能通过 WindowsFormsHost 塞进去但样式和 WPF 格格不入编辑手感也奇怪。后来我用 WPF 原生的 ItemsControl DataTemplate 自己写了一个简单的配方参数表。把每个配方项定义成一个 RowViewModel包含参数名、参数值、单位、上下限用 DataGrid 绑定即可。这样既保留了 WPF 的风格一致性又不需要额外的第三方控件库。顺带说一句网上看 WPF PropertyGrid 相关的资料很多但真正靠谱的免费实现不多与其东拼西凑不如花半天自己写一个反而更可控。7. 项目落地后的思考与几个可以继续深挖的方向设备在现场已经跑了大半年最让我欣慰的不是功能多炫而是看得见。操作员能清楚看到机械手走到了哪一步、真空压力是多少、温度曲线正不正常维修人员能根据报警记录快速定位是哪个传感器没到位管理后台能查到每一片晶圆的完整搬移时间线。上位机系统做到这种透明程度才算真正帮到了客户。如果再给我一次机会重新做这个项目我会提前做两件事一是把日志系统设计得更完善一些从第一天就实现操作日志 报警日志 数据曲线三合一不然后补日志格式总是不如一开始设计得自然二是把 PLC 点位表做成代码生成器直接从 Excel 生成点位常量和读写封装能省掉大量手写重复代码的时间。这套系统后续还可以扩展的方向不少。比如接 OPC UA 让上层管理系统直接读取设备实时状态或者加一套简单的配方版本管理让工程师在远端就能预览并切换配方。等设备量多起来之后还可以考虑用 gRPC 做一个设备集中监控端把所有机台的报警和效率数据汇总到一块大屏上。最后分享一个经验上位机项目的成败三分之一在代码三分之一在沟通三分之一在测试。代码写得再漂亮如果一开始没有和电气工程师把 IO 表对明白后面全是返工流程测得不充分现场第一片晶圆就可能给你颜色看。所以拿到项目先别急着写界面花时间把信号表、动作时序、异常分支一条条理清楚这才是最省时间的路。