ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C# WPF半导体晶圆与石墨岛搬移上位机系统实现

C# WPF半导体晶圆与石墨岛搬移上位机系统实现 扩散炉旁边的机械手咔哒一声到位机械臂托着一片石墨岛石墨岛上载着十二片晶圆稳稳送进炉口。我站在机台前盯着屏幕上旋转变绿的流程指示条心里那块石头终于落了地。这套系统从接手到稳定运行前后折腾了将近三个月代码写了无数版现场改了三轮最后总结下来真正核心的就一句话用 C# WPF 做半导体晶圆与石墨岛搬移上位机系统难点不在界面有多炫而在流程控制够不够稳、数据追溯够不够全、异常处理够不够快。这篇内容就围绕这套系统的完整实现来写。从需求分析、架构选型、核心代码实现到现场调试踩过的坑全部分享出来。适合正在做或准备做半导体设备上位机的工程师参考尤其是涉及晶圆搬移、载具搬运这类对顺序控制和安全互锁要求比较高的工位里面提到的思路和代码骨架基本可以直接抄作业。1. 项目背景与整体设计思路1.1 晶圆和石墨岛搬移搬的到底是什么先说清楚现场工艺。半导体前端工序里晶圆要经过氧化、扩散、退火等高温工艺这些工艺通常都在立式或卧式扩散炉里完成。晶圆不能直接放在炉管里必须由石墨材质做成的载具托着这个载具就是标题里说的“石墨岛”。一个石墨岛上可以放多片晶圆机械手把装载好的石墨岛送入炉膛工艺结束后再取出送到下一个工位。这个“搬移”动作看起来简单实际控制起来并不轻松。机械手要完成取料、升降、进炉、出炉、放料等多个步骤每一步都有气缸、电机、真空传感器、位置传感器参与任何一步没到位轻则卡料报警重则撞碎晶圆。一片晶圆的价值不用多说碎一片就是几万块起所以这套上位机系统的第一要务不是功能多而是流程严谨、动作可控、异常可查。原有设备用的是老式PLC加触摸屏工序改了要改梯形图产品换型要翻配方历史数据只能靠纸质记录溯源根本无从谈起。厂里要上数字化改造核心需求就是用上位机软件接管流程调度、配方管理、状态监控和数据追溯操作员在工控机上就能完成所有操作和排故。这才有了这个项目。1.2 技术选型为什么是 C# WPF而不是别的上位机技术方案其实有不少C# WinForms、C# WPF、QT C、LabVIEW 都能做我最终选 C# WPF有这几层考虑生态和开发效率。半导体设备上位机大量涉及与PLC、运动控制卡、传感器的通信这些硬件厂商提供的SDKC# 接口往往是最全的。我手头这台设备的PLC支持 Modbus TCPC# 里有现成的 NModbus4 库串口和网口通信都非常顺手。相比 C/QTC# 在写业务逻辑、数据处理、界面绑定时效率明显更高尤其是一个人撑一个项目开发节奏很重要。WPF 的界面表现力。半导体工位界面要同时呈现设备状态、流程动画、实时曲线、报警列表WPF 的 MVVM 模式和数据绑定让界面和业务逻辑分离得很彻底。我做过的 WinForms 项目逻辑和控件魔改交织在一起后期维护是真痛苦。WPF 换皮肤、改布局基本不用动后台代码。团队技术栈匹配。团队其他工程师都是 .NET 背景后续交接维护成本低不用专门养一个 C 客户端开发。这个因素在现实中往往比技术先进性更重要。2. 系统架构与核心模块设计2.1 分层架构先给系统划好边界做上位机最忌讳的就是一上来就画界面、写按钮事件把通信代码全塞进按钮点击事件里。这系统涉及设备调度、通信、流程控制、UI展示、数据存储多个方面不把边界划清楚后面加需求、查Bug都会非常痛苦。我最终采用的是经典四层架构界面层WPF MVVM只负责数据展示和用户操作指令下发不直接接触硬件。业务逻辑层流程状态机、配方解析、步骤执行引擎、报警处理这是整个系统的核心。设备抽象层把PLC、运动控制卡、传感器、气缸等硬件设备封装成统一接口上层不关心具体通信细节。通信层Modbus TCP、串口、TCP Socket、控制卡SDK调用处理数据帧收发和协议解析。分层带来的直接好处是我后来把设备从旧PLC换成了新PLC只改通信层的驱动实现界面和流程代码一行没动。这个收益在项目后期非常明显。2.2 设备通信层的接口设计设备抽象层是这套系统的地基。我的做法是先把现场所有被控对象“抽象成接口”再各自写具体实现。核心接口大概有这几个public interface IDevice : IDisposable { string DeviceName { get; } bool IsConnected { get; } Taskbool ConnectAsync(); Task DisconnectAsync(); } public interface IPlcController : IDevice { Taskbool ReadCoilAsync(string address); Taskbool[] ReadCoilsAsync(string address, ushort count); Task WriteCoilAsync(string address, bool value); Taskushort ReadRegisterAsync(string address); Taskushort[] ReadRegistersAsync(string address, ushort count); Task WriteRegisterAsync(string address, ushort value); } public interface IMotionController : IDevice { Taskbool MoveToPositionAsync(int axis, double position, double speed); Taskbool HomeAsync(int axis); Taskint GetAxisStatusAsync(int axis); } public interface ISensorHub : IDevice { Taskbool GetSensorStateAsync(string sensorId); TaskIReadOnlyDictionarystring, bool GetAllSensorStatesAsync(); }当时写这套接口前我花了一整天把现场所有I/O点、寄存器地址、传感器编号整理成一张点位表。这一步很关键接口设计可以后续再调但点位表一旦错了写出来的逻辑全白搭。点位表大致长这样点位名称类型地址方向说明机械手X轴正限位离散输入0x1001PLC→上位机到位信号机械手夹爪气缸离散输出0x2001上位机→PLC夹紧/松开石墨岛到位传感器离散输入0x1005PLC→上位机载具在位检测炉门开到位离散输入0x1008PLC→上位机允许进炉信号当前工步号保持寄存器0x3001PLC→上位机0-99工艺温度保持寄存器0x3002PLC→上位机实时温度值2.3 工艺流程状态机与配方管理晶圆搬移最忌讳的就是“想当然顺序执行”。我第一版就吃过亏流程代码写成一串await方法平铺直叙结果现场传感器一个信号抖动流程直接卡死也不知道卡在哪一步。后来痛定思痛改用显式状态机管理流程。整个搬移过程拆成几个明确的步骤取料检测、夹爪闭合、Z轴上升、X轴进给、Y轴定位、炉门检测、石墨岛放入、夹爪释放、Z轴下降、退回原点。每个步骤都是一个状态状态之间通过条件转移。用状态机的核心优势是任何时刻系统都知道自己“处于哪个状态”报警时能精准定位到具体步骤还可以实现“手动干预后从某一步继续执行”这在现场调试时太重要了。配方的设计也顺着状态机来做。一个工艺配方就是一串有序步骤的参数集合比如某个产品要搬移多快、在哪个位置停留多久、是否需要等待某个传感器信号。配方用JSON文件存改配方不用重新编译程序。3. 核心功能实现与关键代码3.1 项目骨架搭建与MVVM落地方案框架选择上我用了 Prism因为项目模块划分比较清楚Prism 的 Module、Region、DelegateCommand 这些机制正好能派上用场。如果项目规模没那么大用 CommunityToolkit.Mvvm 也完全够核心就是MVVM 三层分离 ViewModel 之间解耦通信。项目结构大概是这样的Solution ├── DeviceDrivers // 设备驱动层 │ ├── PlcModbusTcp.cs │ ├── MotionController.cs │ └── SensorHub.cs ├── EquipmentFramework // 设备抽象与业务逻辑 │ ├── Devices/ │ ├── Flow/ │ ├── Recipe/ │ └── Alarm/ ├── HmiApp // WPF界面层 │ ├── Views/ │ ├── ViewModels/ │ └── Converters/ └── DataService // 数据存储与日志Prism 里我把设备服务通过依赖注入注册成单例protected override void RegisterTypes(IContainerRegistry containerRegistry) { containerRegistry.RegisterSingletonIPlcController, PlcModbusTcp(); containerRegistry.RegisterSingletonIMotionController, MotionController(); containerRegistry.RegisterSingletonISensorHub, SensorHub(); containerRegistry.RegisterSingletonIFlowEngine, FlowEngine(); containerRegistry.RegisterSingletonRecipeService, RecipeService(); containerRegistry.RegisterSingletonAlarmService, AlarmService(); containerRegistry.RegisterSingletonLogService, LogService(); }ViewModel 里不直接 new 设备对象全部通过构造函数注入。这样写的好处是换驱动、写单元测试、做模拟调试都非常方便。3.2 基于 Modbus TCP 的 PLC 通信封装现场PLC支持 Modbus TCP我用 NModbus4 库封装了一套通信服务。先看最基础的读写public class PlcModbusTcp : IPlcController { private TcpClient _tcpClient; private IModbusMaster _modbusMaster; private readonly object _lock new object(); public string DeviceName MainPLC; public bool IsConnected _tcpClient?.Connected ?? false; public async Taskbool ConnectAsync() { try { _tcpClient new TcpClient(); await _tcpClient.ConnectAsync(192.168.1.10, 502); _modbusMaster ModbusIpMaster.CreateIp(_tcpClient); return true; } catch (Exception ex) { Logger.Error($PLC连接失败: {ex.Message}); return false; } } public async Taskbool ReadCoilAsync(string address) { var addr ParseAddress(address); lock (_lock) { bool[] result _modbusMaster.ReadCoils(addr.Item1, addr.Item2, 1); return result[0]; } } public async Taskbool WriteCoilAsync(string address, bool value) { var addr ParseAddress(address); lock (_lock) { _modbusMaster.WriteSingleCoil(addr.Item1, addr.Item2, value); return true; } } }注意我所有 Modbus 读写都加了lock因为多个任务可能同时访问PLC不加锁会出现“一个请求还没返回另一个请求又发出去”的错帧问题NModbus4 对并发支持其实并不好。这个经验是血泪换来的第一版没有锁生产时偶尔出现“写线圈成功但实际没执行”查了很久才发现是并发访问导致报文交叉。还有一个坑是Modbus TCP 长时间空闲会被设备断开。必须做心跳检测我写了一个后台任务每5秒轮询一次PLC的固定寄存器既保活又能顺便采集设备状态。3.3 搬移流程的步骤化执行引擎状态机的代码实现我抽象成了一个步骤执行引擎每个工艺步骤是一个IProcessSteppublic interface IProcessStep { string StepName { get; } TaskStepResult ExecuteAsync(StepContext context); } public class StepContext { public IPlcController Plc { get; } public IMotionController Motion { get; } public ISensorHub Sensors { get; } public CancellationToken CancellationToken { get; set; } public Dictionarystring, object DataBag { get; } new(); public StepContext(IPlcController plc, IMotionController motion, ISensorHub sensors) { Plc plc; Motion motion; Sensors sensors; } } public enum StepResult { Success, Fail, Retry, Abort }以“机械手取石墨岛”这个步骤为例核心实现是这样的public class PickGraphiteBoatStep : IProcessStep { private readonly int _timeoutMs; public PickGraphiteBoatStep(int timeoutMs 15000) { _timeoutMs timeoutMs; } public string StepName 取石墨岛; public async TaskStepResult ExecuteAsync(StepContext context) { // 1. 检查真空/机械夹爪是否已到位 bool boatInPlace await context.Sensors.GetSensorStateAsync(BOAT_DETECT); if (!boatInPlace) { Logger.Warn(石墨岛未检测到位中止取料); return StepResult.Fail; } // 2. 夹爪闭合 await context.Plc.WriteCoilAsync(GRIPPER_CLOSE, true); // 3. 等待夹爪到位信号 bool gripOk await WaitForSignalAsync( () context.Sensors.GetSensorStateAsync(GRIPPER_CLOSED), _timeoutMs ); if (!gripOk) { Logger.Error(夹爪闭合超时可能存在机械卡滞); await context.Plc.WriteCoilAsync(GRIPPER_CLOSE, false); return StepResult.Fail; } // 4. Z轴上升脱离承载台 bool liftOk await context.Motion.MoveToPositionAsync(0, 120.0, 50.0); if (!liftOk) { Logger.Error(Z轴上升失败); return StepResult.Fail; } return StepResult.Success; } private async Taskbool WaitForSignalAsync(FuncTaskbool checkFunc, int timeoutMs) { using var cts new CancellationTokenSource(timeoutMs); while (!cts.IsCancellationRequested) { if (await checkFunc()) { return true; } await Task.Delay(100, cts.Token); } return false; } }整个流程引擎就是一个顺序执行器按配方加载步骤列表逐步执行public class FlowEngine { private readonly ListIProcessStep _steps; private int _currentStepIndex; public async TaskRunResult RunAsync(StepContext context) { for (_currentStepIndex 0; _currentStepIndex _steps.Count; _currentStepIndex) { var step _steps[_currentStepIndex]; Logger.Info($执行步骤[{_currentStepIndex 1}/{_steps.Count}]: {step.StepName}); StepResult result await step.ExecuteAsync(context); if (result ! StepResult.Success) { return new RunResult(false, _currentStepIndex, step.StepName, result); } } return new RunResult(true, _currentStepIndex, 完成, StepResult.Success); } }这套“步骤类 顺序执行器”的设计我用了很多年最大的好处是新增一个动作只需要新写一个步骤类然后往配方里加一行不需要改动流程引擎。现场加了一个“出炉后吹扫等待”的步骤我新写了一个PurgeWaitStep一分钟搞定不用重新走总流程的代码。3.4 实时界面刷新与数据可视化WPF界面上要实时显示设备状态、流程进度、温度曲线和报警信息。刚开始容易犯的错误是直接从后台线程更新UI控件WPF会直接抛异常。正确做法是借助Dispatcher或数据绑定。我的做法是后台线程只更新ViewModel属性UI通过绑定自动刷新。ViewModel继承BindableBasepublic class MainViewModel : BindableBase { private string _currentStepName; public string CurrentStepName { get _currentStepName; set SetProperty(ref _currentStepName, value); } private bool _isRunning; public bool IsRunning { get _isRunning; set SetProperty(ref _isRunning, value); } private ObservableCollectionAlarmItem _alarms new(); public ObservableCollectionAlarmItem Alarms _alarms; }后台设备状态轮询任务里直接改属性就行private async Task PollDeviceStatusAsync(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { try { var sensors await _sensorHub.GetAllSensorStatesAsync(); DeviceStatusText FormatSensorText(sensors); CurrentTemperature await _plc.ReadRegisterAsync(PROCESS_TEMP); } catch (Exception ex) { Logger.Error($设备状态采集异常: {ex.Message}); } await Task.Delay(500, cancellationToken); } }需要注意的是属性变化通知太频繁会拖慢UI我做了个折中纯数值变化温度、位置每500ms刷新一次状态量变化气缸到位、传感器信号立即刷新趋势曲线每2秒采一个点。曲线展示用了 OxyPlot体积小操作灵活在WPF里绑定也方便。报警列表用的ObservableCollectionAlarmItem但实际使用时要小心如果后台线程直接 Add 到 ObservableCollectionWPF 会抛跨线程异常。我在界面上把报警数据的操作集中在 ViewModel 层用一个简单的队列缓冲再通过 Dispatcher 批量更新。4. 现场常见问题与排查技巧4.1 通信不稳定、偶发超时这是现场让我最头疼的问题。系统跑起来以后每隔一两个小时就出现一次 PLC 通信超时报完警过几秒自己又恢复了。排查过程非常折腾最后锁定两个原因第一是Modbus TCP 的并发访问。前面提到的lock就是在这时候加的。加了锁之后同一条 TCP 连接上就不会再有并发报文交叉的问题。第二是PLC 内部扫描周期和上位机请求频率的匹配。当上位机请求太密的时候PLC 来不及响应会丢掉部分请求。我的解决办法是给通信层加上重试和退避机制public async TaskT WithRetryAsyncT(FuncTaskT action, int maxRetryCount 3) { int retryCount 0; while (retryCount maxRetryCount) { try { return await action(); } catch (Exception ex) { retryCount; Logger.Warn($通信异常第{retryCount}次重试: {ex.Message}); if (retryCount maxRetryCount) { throw; } await Task.Delay(200 * retryCount); } } throw new InvalidOperationException(Unreachable); }现场实测下来加了这个重试机制以后偶发超时造成的中断基本消失了。但注意重试次数不能太多否则会放大一次故障的影响时间我设置了最多重试3次每次间隔递增。4.2 UI卡顿与设备轮询冲突项目初期经常出现“界面点了没反应”的情况尤其是一台机跑一天以后界面越来越卡。分析之后发现两个问题一是有后台任务直接在 UI 线程里做了耗时的 Modbus 读写阻塞了界面消息循环二是设备状态轮询任务和界面渲染太频繁挤占了 CPU。解决办法是所有设备通信操作全部交给后台任务UI 线程只做数据展示设备状态轮询用Task.RunCancellationToken不与界面线程混跑轮询间隔从 200ms 放宽到 500ms界面控件做了虚拟化避免一次性渲染过多数据行。对了还有一个容易被忽略的地方——日志文件不控制大小。跑一天下来日志文件几个GB磁盘IO把系统拖垮了。后来接了 NLog 的按天滚动和文件大小限制界面流畅度立刻好了一个档次。4.3 流程卡死怎么定位是哪一步状态机方案上线后流程卡死的问题还是会出现但排查快多了。我在流程引擎里加了详细的步骤日志和现场快照public class StepSnapshot { public int StepIndex { get; set; } public string StepName { get; set; } public DateTime Timestamp { get; set; } public Dictionarystring, bool SensorStates { get; set; } public Dictionarystring, object MotionPositions { get; set; } }每次步骤开始时把当前所有I/O状态和运动轴位置记录到快照里执行超时后自动把最近的快照写到独立日志文件同时界面弹窗告知“卡在取石墨岛—等待夹爪到位信号”并显示实时传感器状态。操作员看一眼就知道是传感器没触发还是气缸没动作不用再翻代码。这个设计后来也帮了大忙。现场有一次机械手卡在半空报警提示“Z轴上升超时”快照显示 Z轴实际位置在 3.2mm 到 3.5mm 之间反复变动一看就是编码器信号干扰。要是没有快照我只能去机台边上盯着监控和数据看半天。4.4 开发环境与部署的那些坑开发环境上我遇到的一个很实际的问题是用 VS2022 新建项目时找不到 WPF 项目模板。这通常是因为安装 VS2022 时没有勾选“.NET 桌面开发”工作负载。打开 Visual Studio Installer勾上这个工作负载就能看到 WPF 应用模板了。另外就是目标框架的选择。C# 项目如果只是为了兼容老设备建议能上 .NET 6/8 就上语法糖和性能都好很多但如果工控机是 Win7 且没有条件升级系统那就只能用 .NET Framework 4.6.2 或 4.7.2并且要确保部署机安装对应的运行时。WPF 项目打包可以用 Inno Setup 做安装包部署时注意把依赖的驱动、运行库一起打包省得现场一台一台手动装。5. 后续扩展方向与个人经验5.1 还能往哪些方向扩展这套系统跑稳之后扩展空间其实很大。半导体工厂对设备联网和数据追溯要求越来越高后续可以考虑对接 MES / EAP 系统。半导体行业标准是 SECS/GEMC# 里也有相应的库可以用实现设备与工厂管理系统之间的信息交互。这个做通了配方的下发、产品的跟踪、批次数据的上传都能自动化真正实现无纸化生产。引入视觉定位。晶圆的取放如果靠机械定位精度始终有限。加上工业相机做视觉对准可以让机械手每次取放的位置误差控制在更低范围。WPF 里集成相机取流和视觉结果显示也非常方便甚至可以做一个实时画面叠加工艺信息的界面。数据上云与远程监控。现场数据通过 MQTT 或 OPC UA 传到服务器的时序数据库做产线级的数据分析和工艺追溯。操作员在办公室就能看到每台设备的实时状态和历史曲线。5.2 我做完这套系统后的几点真实体会项目收尾后我复盘了整个开发过程有几条经验想单独拎出来说说。第一一定要先做模拟器再做真机调试。我开发过程中用了一个 PLC 模拟器把所有通信接口和流程逻辑都在模拟器上验证过了到现场真机调试时大量低级错误已经被提前过滤掉了。没有模拟器现场一边改代码一边试机效率会低很多而且一旦真机出事故代价谁都扛不住。第二最小闭环先跑通再扩展功能。我当时第一版只做了“手动控制 单步动作”验证通信链路和设备抽象层没问题之后才开始写自动流程和配方管理。很多人喜欢一上来就铺大摊子结果到最后一步跑不通回退的成本非常高。第三日志和告警信息一定要带上下文。不要只写“通信失败”要写“PLC通信失败寄存器地址0x3001重试第2次”这样现场排障时不需要再翻代码去猜是哪条指令出了问题。我在开发后期把所有日志都补上了上下文信息效果立竿见影。最后说个细节。界面上的“开始”“暂停”“复位”这三个按钮我在设计时就约定了一个原则只能由操作员在确认安全的情况下主动点击任何情况下程序都不允许自动进入“运行”状态。这个原则看起来简单但能避免掉绝大多数由于上位机自动逻辑误触发导致的安全事故。做设备控制软件安全永远是底线。这套系统上线到现在运行了大半年流程稳定性、数据完整性和操作效率都达到了预期。过程中踩过的坑、走过的弯路都写在这篇内容里了。如果你也正在做类似的上位机项目希望这些经验和代码骨架能帮你少走几步弯路。
返回列表