做半导体设备上位机这几年,碰过最多的设备不是那种花里花哨的桌面软件,而是看起来平平无奇、实际上承担整条产线节拍的搬移类机台。这里说的搬移,不只是普通传送带上的物料流转,而是晶圆和石墨岛之间的精确取放、传输和回传。我在接手这类 C# + WPF 上位机项目前,也以为无非是“读传感器、发脉冲、显示状态”,真正做完一版能稳定跑产线的系统后,才发现坑远比想象中多。这篇文章就把我搭建这套半导体晶圆与石墨岛搬移上位机系统时的需求拆解、架构取舍、关键实现和实战踩坑完整记录下来,给正在做同类设备的软件工程师一点参考。
1. 先拆业务:晶圆/石墨岛搬移系统的真实工况与上位机职责
1.1 搬什么、从哪里到哪里:设备动作流程
晶圆在热处理、薄膜沉积这类工艺里,不是一片一片单独进出腔室的,通常会把一批晶圆放在石墨舟或者石墨载盘里,一起送入工艺炉管。有些设备的载具设计得像一个小岛,中间是石墨基座,四周有支撑柱,业内干脆叫它石墨岛。上位机要管的,就是在上下料台、缓存工位、工艺腔室、冷却工位之间,把晶圆和石墨岛按流程搬来搬去。
一套典型的搬移动作大概是这样的:人工或者上游设备把装有晶圆的石墨岛放到上料台,上位机确认物料到位后,通知机械手夹具夹取石墨岛,上升到安全高度,水平移动到工艺腔室门口,等待腔室门打开,再下降、放置、退回。工艺完成后,机械手再反向把石墨岛从腔室搬运到下料台。中间还可能经过冷却台、缓存台、翻转台,每个工位都有传感器、气缸、真空吸盘、升降机构要配合。
做这类系统的上位机,核心职责不是去控制每一个电机,而是把设备当成一个有“状态”的有机体来管理:知道物料在哪、要去哪、现在能不能动、动了之后有没有异常。动作之间的时序和互锁,比单纯画界面要重要得多。
1.2 上位机、PLC、运动控制卡三者如何分工
很多刚入行的人会问,为什么不用 PLC 一把梭?核心原因是节拍和点位精度。石墨岛的定位精度通常是毫米级甚至亚毫米级,体积又大,需要多个轴协同运动,用通用 PLC 写点位插补不是不行,但开发效率低,后期示教点位也麻烦。我这里采用的运动控制方案是“上位机 + 运动控制卡 + 远程 IO + 传感器”,PLC 只做一部分安全回路和气缸阀岛控制。
三者分工大概是这样的:
| 模块 | 负责内容 | 为什么这么分 |
|---|---|---|
| 上位机(C#) | 流程调度、状态管理、点位管理、报警处理、MES 交互、UI | 逻辑复杂、变更多,适合用高级语言快速迭代 |
| 运动控制卡 | 直线轴/旋转轴的插补、回零、限位、到位判断 | 实时性强,供应商 SDK 里都封装好了 |
| PLC / IO 模块 | 气缸、真空、门开关、光幕、急停等开关量 | 安全链路相对固定,交给可靠逻辑控制 |
这样分之后,上位机通过调用运动控制卡的 SDK 来发运动指令,通过 Modbus TCP 或 TCP Socket 去读写 PLC 的寄存器来获取气缸和传感器状态,再把这些信息汇总成一个统一的设备模型。
1.3 需求里的隐藏点:节拍、追溯、联锁
需求文档上写“实现晶圆与石墨岛搬移”这十几个字,拆开之后全是细节。第一个隐藏点是节拍。产线上整个工艺循环大概多长时间,机械手搬一次要多久,上下料台能不能提前准备好,这些直接决定流程能不能并行。第二个隐藏点是追溯。石墨岛用了多少次、是哪一批晶圆、在哪个腔室做过工艺,都要记录,否则出了问题查不到批次。第三个隐藏点是联锁。腔室门没开到位绝对不能伸进去,真空没建立绝对不能松开夹具,光幕被遮挡绝对不能让轴动,这些不只是写在代码 if 里,还要有独立的安全回路。
所以在动手写第一行代码之前,我习惯先画一张“工位-动作-条件”表格,把每个动作的前提条件、完成后的变化都列出来,后面写状态机的时候直接照着抄。
2. 架构与技术选型:为什么 C# + WPF 适合这类系统
2.1 上位机软件的分层:界面、业务、驱动三层的边界
项目一开始最容易犯的错误,是把所有代码堆在窗口的按钮点击事件里。比如点击“自动运行”按钮,里面直接写:打开控制卡、读传感器、发运动指令、更新 TextBox。看起来很快,但产线上一出问题,根本定位不到是界面问题还是运动逻辑问题。
我做这类系统时固定分成三层:驱动层、业务层、界面层。
驱动层只做一件事:封装硬件通信,向上层提供“MoveAbs”“ReadInput”“WriteOutput”这类方法,不包含任何流程判断。业务层负责状态机、动作序列、防碰撞逻辑、报警判定,它只面向驱动层的接口,不关心界面怎么显示。界面层只负责展示和接收用户操作,把业务层抛出来的状态和事件绑定到 WPF 控件上。
这样做最大的好处是可以单独测试业务层。我用一个模拟驱动类去替代真实控制卡,在不上机台的情况下,就能把搬移流程的时序逻辑跑一遍。等到了现场,只替换驱动层的实现,上面的流程代码一行不用改。
2.2 实时通信选型:控制卡 SDK、TCP/Modbus、OPC UA
半导体设备现场,通信协议远比想象中杂。运动控制卡一般自带 Windows 动态库,C# 里头用 P/Invoke 调用;PLC 和远程 IO 模块多数支持 Modbus TCP;有些新设备支持 EtherCAT,但上位机要拿到 EtherCAT 数据通常还是通过控制卡或者网关转成 TCP/Modbus;再往上,MES 系统的交互经常走 OPC UA 或者 HTTP REST。
我的选型原则是:能用标准协议尽量用标准协议,少碰厂商私有协议。比如远程 IO 模块,我优先选支持 Modbus TCP 的,这样用一个通用的 Modbus 客户端库就能搞定,不依赖厂商 SDK。如果某些特殊的真空计、质量流量计只有串口协议,就单独写一个驱动服务,统一暴露成接口。
下面是一段简化后的 Modbus TCP 读写封装思路:
public class ModbusTcpClient { private readonly IModbusClient _client; public async Task<bool> ReadCoilAsync(byte unitId, ushort address, CancellationToken ct) { // 根据所选库调用,例如 NModbus4 var result = await _client.ReadCoilsAsync(unitId, address, 1, ct); return result.First(); } public async Task WriteCoilAsync(byte unitId, ushort address, bool value, CancellationToken ct) { await _client.WriteSingleCoilAsync(unitId, address, value, ct); } }因为通信的异常是不可避免的,所以驱动层所有方法都要考虑超时和重试,而且要把异常往上抛,让业务层决定是暂停流程还是进入安全状态。
2.3 框架选择:Prism/MVVM 的实践尺度
WPF 本身没有强制 MVVM,但这类多界面、多状态、频繁更新的上位机,如果不用 MVVM,代码很快就变成一团乱麻。我用过 Prism 也用过 CommunityToolkit.Mvvm,实际项目里更推荐后者,轻量,没有太多魔法,容易控制。
不过我要强调一点:上位机不是所有地方都适合纯 MVVM。比如实时监控画面里,机械手的坐标位置每 20 毫秒更新一次,如果通过 ViewModel 绑定的方式刷新,会频繁触发属性通知,效率并不高。我的做法是:常规按钮、状态文字、报警列表用 MVVM;高频坐标和示教操作,允许在 View 的后台代码里直接调用业务层接口,再用手动方式写入绘图元素。这样既保住了界面逻辑的可维护性,又不会把性能浪费在绑定管道上。
3. 运动控制与流程调度的关键实现
3.1 点位管理:示教、映射、坐标系变换
石墨岛搬移系统最基础的数据,就是各个工位的取放料点位。实践中点位不能写死在代码里,因为机械安装有误差,传感器位置可能微调,产线工程师需要在不改软件的情况下重新示教。
我的做法是用点位表来管理。每个点位包含轴位置、移动到该点位的速度、加速度,以及一个文本描述。点位表存在一个 JSON 或者数据库中,界面上提供示教模式:操作员选定某轴,用点动按钮移动到合适位置,点“保存”就把当前坐标写回点位表。
更复杂的情况是,机械手夹具具有多个吸盘,石墨岛放在不同高度的工位上。这时候点位不只是 XYZ 坐标,还要加上夹具旋转角度、Z 轴下探距离。为了减少示教工作量,我会按“目标工位”而不是“目标坐标”来组织点位:每个工位有一组取料点和放料点,业务层根据工位号自动查表。
public class PointTableItem { public string Id { get; set; } public string Name { get; set; } public double X { get; set; } public double Y { get; set; } public double Z { get; set; } public double Rot { get; set; } public long Speed { get; set; } // 单位 mm/min public long Accel { get; set; } }坐标系变换也是这里容易忽略的点。机械手的基准点、夹具中心、石墨岛中心可能不重合,如果不做位置补偿,就会出现“看起来放到位了,实际上把石墨岛别住了”。我通常会预先在软件里定义工具坐标系和工件坐标系,示教时直接示教工件坐标,运行时通过矩阵变换换算成各轴的实际目标位置。这样产线只关心工装摆放,不关心机械结构细节。
3.2 轴指令封装与 PLC 交互代码示例
运动控制卡的 SDK 往往是 C 接口,要把它封装成 C# 服务。比如某款控制卡的移动指令是int move_abs(int axis, double pos, int speed, int accel),我封装一层:
public class MotionService { [DllImport("MotionLib.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int move_abs(int axis, double pos, int speed, int accel); [DllImport("MotionLib.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int get_position(int axis); public void MoveToPosition(string pointId, CancellationToken ct) { var p = _pointTable.GetPoint(pointId); CheckCanMove(); // 业务层联锁判断 foreach (var axis in p.Axes) { int ret = move_abs(axis.AxisId, axis.Position, axis.Speed, axis.Accel); if (ret != 0) throw new MotionException($"轴 {axis.AxisId} 启动失败: {ret}"); } // 等待所有轴到位 WaitAllInPosition(_pointTable.GetAxes(pointId), 5000, ct); } }这里的WaitAllInPosition不只是简单轮询位置,还要同时监测报警状态。如果某轴处于限位、跟丢或驱动器报警,必须立刻取消流程。
和 PLC 的交互,我通常把“条件是否满足”放到 PLC 里去判断,因为安全回路的响应时间要远快于软件轮询。比如“腔室门未关到位”,PLC 直接切断运动使能,上位机只要读达成状态即可。但有些气缸没有硬互锁,就需要上位机自己判断顺序,这类逻辑我是通过 Modbus 读写线圈实现的:
public async Task<bool> IsVacuumReadyAsync(CancellationToken ct) { return await _plc.ReadCoilAsync(unitId: 1, address: 0x100, ct); } public async Task EngageGripperAsync(CancellationToken ct) { await _plc.WriteCoilAsync(unitId: 1, address: 0x101, true, ct); // 等待到位反馈,而不是立即返回 for (int i = 0; i < 10; i++) { if (await _plc.ReadCoilAsync(unitId: 1, address: 0x102, ct)) return; await Task.Delay(200, ct); } throw new TimeoutException("夹具真空反馈超时"); }这段代码里有个容易被新手的忽略点:发出气缸动作指令后,不能马上开始下一步,必须等待反馈信号返回,而且要有超时机制。否则气缸还没到位,机械手就已经开始运动,轻则剐蹭,重则撞坏石墨岛。
3.3 多工位状态机与防碰撞调度
搬移系统往往不止一个机械手在干活。比如上料台侧有机器人 A,腔室侧有机器人 B,中间通过缓存台交接。还有的机台是一个龙门机械手负责多个腔室。这时候就需要用状态机把每个工位、每个机械手的状态管理起来。
我把每个物理实体建模为一个状态对象,状态可以从“空闲”“运行中”“等待条件”“报警”“急停”等状态之间迁移。搬移任务进入一个调度队列,由调度器统一分配。调度器要把“工位是否空闲”和“机械手是否可达”作为两个独立条件来检查。
比如一个典型的“从缓存台取石墨岛到工艺腔室”的任务,业务层处理逻辑大致是:
public async Task ExecuteTransferAsync(CancellationToken ct) { await _scheduler.WaitSlotAsync(CancellationToken.None); // 限制并发任务数 try { if (!await IsChamberDoorClosedAsync(ct)) throw new SafetyException("腔室门未关,禁止搬入"); await _motion.MoveToPoint("CachePickReady", ct); // 气缸到位、真空吸附 await EngageGripperAsync(ct); await _motion.MoveToPoint("CachePickAbove", ct); // 移动到腔室门口 await _motion.MoveToPoint("ChamberWait", ct); if (!await IsChamberDoorOpenAsync(ct)) throw new SafetyException("腔室门未打开"); await _motion.MoveToPoint("ChamberPlace", ct); await ReleaseGripperAsync(ct); await _motion.MoveToPoint("ChamberRetract", ct); } finally { _scheduler.Release(); } }这里的_scheduler我用SemaphoreSlim实现,保证同一时间进入危险区域的搬移任务只有配置好的数量。防碰撞不只是软件层面,还要在点位层做“占用区”判断:一个工位如果是“占用中”,其他任务就不能把它作为目标或途经点。
3.4 异步、超时与异常处理:让搬移动作“知道什么时候失败”
工业软件最怕的不是报错,而是“卡住不动却没有提示”。我刚开始写的时候,很多函数是同步阻塞的,一旦控制卡通信卡死,整个软件就假死,操作员不知道是设备坏了还是程序死了。
后来我把所有运动交互改成异步,并且给关键动作设置超时。比如“等待轴到位”这个操作,在标准情况下应该在 3 秒内完成,如果超过 5 秒还没到位,就取消当前任务并弹报警。使用CancellationTokenSource可以很方便地实现超时取消:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { await _motion.WaitAllInPosition(axisIds, 5000, cts.Token); } catch (OperationCanceledException) { await _motion.StopAllAxesAsync(); _alarmService.Raise("轴到位超时,已停止所有运动"); }异常处理也不能只弹一个 MessageBox,而是要把异常对象、当时的步骤、设备状态一起记录到日志里,方便后面查原因。
4. WPF 界面:让操作员一眼看清当前物料在哪台设备
4.1 上位机界面的设计原则:高频信息一目了然
很多设备软件界面花花绿绿,反而不好用。搬移设备操作员最关心的是三件事:当前是什么模式、物料在哪个工位、有没有报警。我把主界面设计成三栏结构:左侧是设备状态总览,中间是物料跟踪图,右侧是操作按钮和报警摘要。所有颜色遵循一个原则:正常是绿色或灰色,动作中是黄色,报警是红色,急停是红色闪烁。
背景色用深色或者浅色都可以,但要注意产线环境光线较强,界面对比度要高。字号也要偏大,因为操作员往往是隔着一米多看的,不能按办公室软件的默认 12 号字设计。这个细节经常被人忽略,但操作员用下来的反馈都很好。
4.2 物料位置实时跟踪:Canvas + Binding 实现车间布局图
要直观展示石墨岛和晶圆的位置,我用 WPF 的 Canvas 画了一个设备俯视图,按照真实比例放置上料台、缓存台、腔室、机械手活动范围。每个物理对象对应一个自定义控件,位置绑定到 ViewModel 里的坐标属性。
由于位置更新频率不高,我直接使用普通属性加INotifyPropertyChanged就够了。如果机械手移动轨迹要动态显示,就在后台代码里每隔几十毫秒读取一次实时坐标,然后更新 Canvas 元素的位置。这里不要把所有坐标变化都通过 MVVM 绑定,否则会产生大量垃圾对象。简单直接的后台写法是:
private void OnMotionPositionUpdated(double x, double y, double z) { // 更新机械手图标在 Canvas 上的位置 ManipulatorIcon.SetValue(Canvas.LeftProperty, x); ManipulatorIcon.SetValue(Canvas.TopProperty, y); }物料跟踪图还有一个作用:操作员不需要打开数据报表,只看图就能知道某个石墨岛在哪,是否需要人工干预。
4.3 报警与操作权限:安全互锁的人机交互
报警不能只是界面变红,还要把报警分类、解除方式和是否需要确认区分开。比如“真空压力不足”这种报警,需要操作员确认真空恢复正常后再手工清除;“光幕触发”报警则要求操作员离开光幕区域,并且重新按下复位按钮才能继续。我会把报警做成一个列表,每一行记录报警时间、报警代码、中文描述、当前状态,并提供“报警复位”按钮,只有满足条件时才允许点击。
权限管理也是必需品。操作员只能执行“启动自动流程”“暂停流程”;工程师可以进入示教模式、修改点位参数;管理员才能修改配方和系统配置。WPF 里我简单地做一个登录窗口,登录后把当前用户的角色放到全局 session 里,按钮和菜单绑定到权限规则。这个逻辑不难,难的是界面上每个可操作控件都要有权限校验,不能只隐藏不校验,因为隐藏的按钮也可以通过后台代码被调用。
4.4 UI 性能优化:大数据量刷新不卡顿的实践
WPF 上位机经常会遇到“日志列表刷新过快导致界面卡顿”“示教画面拖动卡顿”这类问题。我踩过一个大坑:把收到的每一条实时数据都直接放到 ObservableCollection,结果 UI 线程忙不过来,连急停操作都响应慢。后来我做了三层处理:
- 日志和传感器数据先进入内存队列,使用定时器每 100 毫秒批量刷新一次 UI。
- 列表控件使用虚拟化,
VirtualizingStackPanel.IsVirtualizing="True"。 - 高频属性通知只更新必要控件,不再触发整棵可视化树刷新。
实测下来,界面响应速度明显提升,CPU 占用也降了不少。
5. 数据追溯与 MES 对接
5.1 晶圆 ID 与石墨岛身份绑定
石墨岛在设备里会被反复使用,每批晶圆是哪几个石墨岛承载的、工艺参数是什么,都需要记录。上位机通过扫码枪或者 RFID 读头获取晶圆盒和石墨岛的身份信息,建立绑定关系。
我用的方案是:在上料台装一个扫码枪加 RFID 天线,物料到位后自动读取 ID。读取成功后,软件把“石墨岛 ID + 晶圆批次号 + 当前时间”写入当前批次记录。后续每一次搬移动作,都把动作类型、目标工位、时间戳、操作模式追加到这条记录里。这样出了品质问题,可以倒查每一个环节。
public class MoveRecord { public long Id { get; set; } public string CarrierId { get; set; } public string LotId { get; set; } public string ActionType { get; set; } // Load / Unload / Transfer public string FromStation { get; set; } public string ToStation { get; set; } public DateTime Timestamp { get; set; } }5.2 本地数据库与 MES 接口的设计
本地数据我先用 SQLite 记录所有搬移记录,因为部署简单、不需要额外安装数据库服务。MES 交互如果有实时性要求,就通过 HTTP REST 接口推送,没有实时要求就在本地记录后定时批量上报。
为了不让本地数据库成为瓶颈,我使用了异步写入队列。业务层只把记录放到内存队列,后台线程负责批量写入 SQLite,并处理写入失败后的重试。这样即使 MES 临时故障,也不会影响设备本体的自动化流程。
5.3 报表与历史数据导出
产线工程师经常要按批次查询搬移历史,或者按时间导出报警记录。我在 WPF 里做了一个报表页面,支持按时间范围、批次号、工位号筛选,结果可以导出为 CSV,方便在 Excel 里进一步分析。报表查询因为数据量不大,直接使用 SQL 查询内存表即可。这里要注意的一个细节是,SQLite 的时间字段最好存 UTC 而不是本地时间,否则跨班次查询会有 8 小时偏差。
6. 实战踩坑记录:从调试机到产线,哪些问题最消耗时间
6.1 与运动控制卡交互的 P/Invoke 坑
控制卡 SDK 是 C 接口,我在 C# 里 DllImport 调用时遇到过两个典型案例。一个是字符集问题:控制卡回传错误信息用的 char 数组,如果 C# 侧默认使用 Unicode 封送,读出来的字符串就会乱码。解决方法是在 DllImport 声明里指定CharSet = CharSet.Ansi。另一个是回调函数:控制卡在某个轴到位后会触发回调,这个回调线程不是 UI 线程,如果直接在回调里操作控件,就会抛异常。我的做法是回调里只做一件事——把事件放进ConcurrentQueue,由专门的事件分发线程负责处理。
6.2 线体联锁的时序冲突:传感器反馈与指令完成的竞态
有一次调试时,机械手在腔室里放完石墨岛后,立刻收到“真空松开”指令,但此时机械手其实还没有完全退出腔室,结果石墨岛被带偏了一点。调查发现,问题出在我的代码里:放料动作完成后,我直接执行了松开夹具的指令,没有等待机械手移动到安全高度。而在前一个工位,因为这个安全高度条件刚好满足,所以没暴露。
后来我在所有“松开夹具”之前都加了明确的位置校验:先让 Z 轴抬起到安全高度,再执行 ReleaseGripper。如果当前位置不在安全区,宁可报错停下,也不能继续走。这个经验后来也延续到其他动作上:上一个动作是下一个动作的前提,但前提不能只依赖“指令完成”,还要依赖“空间位置安全”。
6.3 软件升级与配置管理:不要让产线工程师拿着 U 盘拷贝配置
刚交付第一台设备时,我在现场改了两次点位,重新编译上位机,然后复制整个发布目录过去。结果第二次复制后,操作员发现之前示教的点位都没了,因为发布目录里的点位文件被我重置了。产线工程师只好想办法找回旧配置文件,折腾了很久。
后来我做了两件事:一是把配置文件和软件程序分离开,点位表、配方、日志都放在独立的配置文件夹,发布程序时不会覆盖;二是给点位表增加版本号,软件启动时如果发现配置版本与程序不一致,会弹出提示,让工程师决定加载哪一份。这样升级软件时,不会因为疏忽把产线参数冲掉。
6.4 急停恢复后的状态清理
急停不是简单按一下按钮就能继续生产的事。急停触发时,机械手可能正停在半路,气缸可能没有到位,真空可能已经泄掉。如果直接按复位就继续执行原流程,大概率会撞机或掉片。
我的方案是:急停解除后,上位机进入“恢复确认”状态,先手动把所有轴回到原点或者回到安全位置,再逐项检查气缸和真空状态,全部确认完成后,才允许进入自动模式。这个流程不能自动跳过,每一步都要有操作员确认。虽然看起来多花了一点时间,但相比一次撞机带来的停机损失,这点时间完全可以接受。
6.5 从“跑通”到“稳定”的最后一公里
代码能跑通,和能在产线 24 小时稳定运行,差距很大。稳定性靠的不是某个大招,而是很多小细节:通信异常有没有重试?超时时间是否合理?日志是否记录了关键现场?报警复位会不会留下隐患?我的习惯是每次现场问题处理后,都追加一条视频级复盘:问题现象、定位路径、根因、修复方案、同类问题如何避免。半年下来,这些复盘记录比任何文档都有价值。
最后分享一个小技巧:如果你也在做这类搬移上位机,一定要在开发环境里把“模拟模式”做完整。用模拟驱动替代真实硬件,把所有状态迁移和报警分支在电脑上先跑一遍,到了现场再逐步替换真实 IO 和运动控制。这样既能缩短现场调试时间,也能让产线工程师提前熟悉软件操作,真正把精力花在解决实际问题上。