
做半导体设备上位机这些年我接触最多的活就是碳化硅外延炉这类设备的上下料系统。整套设备真正难啃的部分其实是“搬”字机械手要把装着晶圆的石墨岛从进料台取出来送进高温反应腔工艺结束后再原路接回整个流程没人敢靠近全靠上位机去协调传感器、气缸、伺服轴还要随时跟 PLC、MES 对话。我当时定的技术方案就是 C# WPF开发效率和界面表现都太适合设备上位机这个场景了。这套系统从选型到上线跑了快两年中间踩了不少坑今天把它完整拆开讲一遍希望给正在做同类项目或者准备转设备上位机的朋友一个参照。1. 先看这套系统到底解决什么问题1.1 晶圆与石墨岛在设备里的角色晶圆衬底要长碳化硅外延层通常不是裸片直接进炉而是先放到一个叫石墨岛行业内也叫石墨舟、石墨基座的载具上。这个岛一般做成圆形托盘带凹陷腔wafer 放在凹陷里通过升降机构从反应腔底部升到高温区生长。反应腔里温度可以到 1500℃ 以上硅烷、氢气这些工艺气体在石墨表面反应所以对载具的耐温和洁净度要求极高这东西本身就是贵重耗材。搬移系统的核心任务就是把石墨岛从这个工位搬到另一个工位。以我做的外延炉上料段为例工艺流程大致是人工或 AGV 把石墨岛放在进料台设备扫码确认 ID机械手抓取到预对准台进行角度校准然后等待炉膛温度和门锁信号满足条件再把岛送入反应腔下方通过升降轴升入腔体。工艺结束后机械手把岛取出放到冷却台冷却到指定温度后搬运到出料台流程结束。这个过程中最尴尬的是温度边界。出炉后的石墨岛表面还有几百度的余温冷却台附近也是高温区人不可能徒手操作。所以整个搬运链必须自动化而且要有极高的可靠性岛摔了是几十万的成本wafer 破了就整批报废任何一次失误都足够让产线停半天。1.2 上位机的职责边界很多刚入行的朋友容易把上位机想象成“画几个按钮控制设备”真做起来根本不是这样。在半导体设备里安全性要求极高的部分必须下沉到 PLC 和硬接线逻辑层上位机干的是三件事状态可视化、任务智能调度、数据记录与追溯。我习惯把这套系统的控制职责分成三层来理解。PLC 负责硬实时逻辑比如门锁互锁、急停回路、气缸运动保护运动控制卡负责伺服轴的位置闭环和插补上位机负责流程编排和业务决策。举个例子机械手是否允许入炉上位机要综合判断炉门开到位信号、炉内温度、MES 放行指令、RFID 校验结果把结论下发给 PLCPLC 再真正驱动动作。上位机不是控制链路的终点而是决策中心。职责边界清晰之后调试的时候就知道问题该找谁。我遇到过现场一出问题大家就盯着上位机代码看的情况最后查出来是气缸磁性开关松动这就白费了很多时间。所以架构里一定要明确上位机只做“判断和请求”不直接绕过 PLC 去驱动危险部件。1.3 为什么是 C# 和 WPF选型的时候我也纠结过包括 WinForms、.NET MAUI、甚至用纯 Python 搭界面都考虑过最后还是定了 C# WPF。说到底就四个字生态合适。C# 跟设备通信相关的库太成熟了Modbus 协议有现成库SQLite 有 Microsoft.Data.SqliteHTTP 调用 MES 接口用 HttpClient 几行搞定对开发效率的提升非常明显。WPF 能撑住设备界面的复杂度这个很关键。半导体设备操作界面信息密度极高实时状态、报警列表、温度曲线、配方参数、权限操作全部要放一个屏幕上WinForms 做这种界面后期维护非常痛苦。WPF 的数据绑定和 MVVM 模式可以让界面跟业务逻辑解耦界面怎么改都不用动控制代码。而且 WPF 做矢量图形和动画很方便画个设备俯视图、加个工位状态变化动画都是比较常规的操作。有人会提 .NET MAUI但就目前的工业落地情况看WPF 在 Windows 工控机上仍然是稳定性和生态最成熟的方案。设备上位机跑在 Windows IPC 工控机上没必要为了跨平台给自己找麻烦。2. 系统架构与模块划分2.1 分层架构各司其职我这套系统没有用特别复杂的框架就是经典的四层UI 层WPF MVVM所有界面元素绑定到 ViewModel 的属性业务层任务调度、状态机、配方逻辑、报警处理设备抽象层把 PLC、运动控制卡、读码器、温控器等设备封装成统一接口通信层负责 Modbus TCP、串口、TCP Socket、DLL 调用等具体协议分层最大的好处是可以单点调试。写得好的设备抽象层可以对上层隐藏“对面是谁”的细节。比如我封装了一个 ILifter 接口有 MoveTo(position)、GetStatus() 这些方法底层可以是 PLC 的 Modbus 寄存器驱动也可以是真接运动控制卡的 DLL甚至测试时可以用模拟器代替。业务层根本不知道这样你写流程逻辑的时候思路会非常干净不会被某个设备的字节序问题带偏。我在做这套系统时给通信层加了一个设备状态统一模型。每个设备都有 Offline、Running、Fault、Disconnected 四种状态UI 上所有设备卡片绑定的就是这套状态模型。这样设备切换和诊断非常直观哪个设备掉线一看就知道。2.2 状态机是搬移系统的灵魂搬移系统最怕的就是流程写死。如果直接用 if else 把动作串起来初期功能能跑但一旦中间某一步超时或者报警恢复逻辑就无从下手。我当时很早就定了要上状态机模型。核心是两个状态机一个是工位状态机一个是任务状态机。工位状态机描述每个工位当前处于什么情况比如进料台是 Empty、Waiting、Loaded、Busy任务状态机描述当前整机在干什么比如 Idle、Dispatching、MoveWafer、Heating、Unloading、FaultHolding。状态之间用事件触发转移事件就是传感器信号、PLC 通知、MES 指令、超时回调。C# 写状态机可以很简单不一定用第三方库。我常用这种方式public enum TaskState { Idle, WaitEnter, ReadingID, Handling, Aligning, WaitingFurnace, Loading, Processing, Unloading, Cooling, Outputting, Completed, Fault } public class TaskMachine { public TaskState Current { get; private set; } public bool CanTransition(TaskState next) { // 这里定义合法跳转表 return transitionTable.ContainsKey((Current, next)); } public bool Fire(TaskState next) { if (!CanTransition(next)) return false; Current next; StateChanged?.Invoke(Current); return true; } }关键点在于状态跳转表必须显式声明不允许任意跳转。比如 Fault 状态只能通过工程师复位回到 Idle不允许直接在代码里让它去 Handling。这样即便有异常系统也知道当前停在哪个环节恢复时也知道从哪一步重新走不会出现现场操作员一顿乱点把设备弄懵的情况。2.3 数据追溯与 MES 交互产线上一定会要求追溯。哪个石墨岛在哪一天、哪个配方、哪台设备、哪一批晶圆、当时有没有报警都要能查出来。这块我选择了本地 SQLite 存储加 MES 上报的方式。本地存储的好处是不依赖厂内网络设备断网也能先干活数据不丢。我用 Microsoft.Data.Sqlite 建了配方表、任务记录表、报警记录表、操作日志表。任务记录表里会存任务号、石墨岛 ID、wafer 批次、开始结束时间、关键步骤的时间戳、异常标志。跟 MES 的交互用最朴素的方式REST 接口JSON 报文HttpClient 异步上报。上报逻辑放在了业务层的一个独立服务里任务完成或者报警产生时调用上报接口。如果 MES 返回失败我把记录标记成 Pending后台定时重发直到成功。这套设计的经验是数据库结构一开始就要考虑查询方便因为后续涉及 MES、EAP 审计、统计 OEE很多数据需求是上线后才冒出来的。我把 Cassandra 那套油爆虾也考虑过但设备上位机真没必要SQLite 简单稳定单文件备份方便。3. 通信层的设计与实现3.1 PLC 通信Modbus TCP 实例设备通信是上位机最容易出 bug 的地方。我这套系统的 PLC 走 Modbus TCP上位机作为 Modbus MasterPLC 作为 Server。最常用的功能是读保持寄存器03和写单个/多个寄存器06/16。用 C# 的话推荐 NModbus4 或者 ModbusTCP 这类库但我会在库外面再包一层不直接暴露原始 client。封装后的接口大概这样public class PlcModbusClient : IDisposable { private TcpClient _tcpClient; private ModbusIpMaster _master; private readonly string _ip; private readonly int _port; public PlcModbusClient(string ip, int port 502) { _ip ip; _port port; } public void Connect() { _tcpClient new TcpClient(); _tcpClient.Connect(_ip, _port); _master new ModbusIpMaster(_tcpClient); } public ushort[] ReadHoldingRegisters(byte unit, ushort startAddress, ushort length) { return _master.ReadHoldingRegisters(unit, startAddress, length); } public void WriteSingleRegister(byte unit, ushort address, ushort value) { _master.WriteSingleRegister(unit, address, value); } }写的时候有几个很经典的坑。第一个是寄存器地址偏移有些 PLC 厂家手册的地址编号是从 1 开始而协议里是 0 开始差一个地址读上来的数据完全对不上。第二个是字节序两个字节能组成一个 16 位整数或一个 32 位浮点但有些 PLC 是高字在前有些是低字在前必须统一约定并在文档里写清楚。第三个是轮询周期别把 UI 线程拿去轮询用后台线程一般 200ms 到 500ms 一个周期就够了太快反而把 PLC 负载拉满。现场信号交互我一般定义成一张地址映射表每个信号有名字、寄存器地址、数据类型、读写属性、换算系数。上位机启动时把这表加载进来界面上的状态全部通过这张表解释。数据结构化之后新增一个信号不用改代码改配置文件就行。3.2 运动控制卡与 DLL 的对接机械手的伺服轴我直接走运动控制卡的接口调用。市面主流的运动控制卡基本都是提供 C/C 的 DLLC# 要 P/Invoke 调用。这部分的典型问题在热词里几乎都能对上尤其是 access violation c0000005。这通常是因为 C# 调用 C DLL 时结构体布局不一致或者缓冲区长度不够或者委托被 GC 回收了。我的经验是所有结构体显式标注 LayoutKind.Sequential字符串传递用 StringBuilder 或者 IntPtr回调函数必须用字段保存委托实例防止垃圾回收。示例大概是这样的[StructLayout(LayoutKind.Sequential)] public struct AxisCmd { public int axis; public double velocity; public double acceleration; } [DllImport(MotionLib.dll, CallingConvention CallingConvention.Cdecl)] private static extern int MC_MoveAbsolute(ref AxisCmd cmd); public void MoveAxis(int axis, double vel, double acc) { var cmd new AxisCmd { axis axis, velocity vel, acceleration acc }; int result MC_MoveAbsolute(ref cmd); if (result ! 0) { throw new DeviceException($运动控制卡调用失败错误码 {result}); } }开发阶段一定要做两步验证第一步用厂商自带的调试软件把运动控制卡调通第二步再用 C# 封装调用。如果原生软件都动不了先怀疑硬件接线或参数配置如果原生软件正常、C# 调用崩再查 P/Invoke 封装。这两步分开问题定位会快很多。现场遇到过维护人员上来就说 C# 调用 DLL 有问题结果最后发现是急停按钮挡住了控制卡使能信号冤枉了代码。3.3 异常处理、心跳与重连策略工业现场网络和硬件不可能永远稳定通信层必须有抗故障能力。我给每个设备通信加了三层保护心跳检测、自动重连、状态上报。心跳检测使用定时器每隔 500ms 执行一次轻量查询比如读 PLC 的一个状态字。连续三次查询没有响应就标记设备为 Disconnected 并触发断线报警。自动重连不要用死循环连续试图连接建议用指数退避比如第一次等 1 秒第二次等 2 秒最多等 30 秒避免设备在恢复过程中把上位机资源耗尽。关键指令必须做“发出-等待回执-超时重试-失败报警”的闭环。比如机械手开始抓取上位机发指令给 PLC 后不能不管结果要等待 PLC 回一个“已执行”信号。我在业务层写了一个统一的 CommandExecutor所有关键动作都走同一套执行链发送请求 - 记载日志 - 等待确认 → 超时进入异常路径。这个设计救了我很多次现场很多诡异的偶发问题都可以在日志里找到直接证据。4. WPF 界面层的要点4.1 MVVM 基础与线程模型WPF 如果不用 MVVM业务逻辑和界面混在一起后期改需求会非常痛苦。我选用的方案是 CommunityToolkit.Mvvm它没有引入很重的依赖注解和源码生成让代码量少很多。ViewModel 要从设备状态更新数据时必须考虑线程模型。通信层的后台线程收到 PLC 数据后不能直接改绑定属性需要切到 UI 线程。用 Dispatcher.InvokeAsync 可以但要注意锁死问题。最常见的死锁场景是UI 线程同步等待一个 Task而这个 Task 内部又在等待 Dispatcher.Invoke 执行两边互相等界面假死。这个坑我踩了不止一次建议所有耗时操作用 async/await并且始终使用异步的 Dispatcher.InvokeAsync少用同步 Invoke。数据更新的另一个选择是把设备状态封装成 ObservableObject 的 Model后台线程直接更新属性靠绑定底层自动派发。不过要小心高频刷新如果每秒刷新几十次界面重绘会卡。我通常把传感器类状态合并为一个批量属性包一次性赋值用一个 NotificationSupressor 屏蔽中间过程的 ChangeNotification。4.2 设备监控图形化与实时动画半导体设备界面上设备俯视图绝对是核心。用一个 Canvas 画出进料台、预对准台、反应腔、冷却台、机械手的相对位置再用矩形、椭圆、Path 表示设备对象。把这些对象的 Canvas.Left 和 Canvas.Top 绑定到 ViewModel 里的坐标属性就能实现机械手位置的实时移动。状态颜色一般用约定俗成的一套绿色表示正常、灰色表示空闲、黄色表示警告、红色表示故障。这些颜色我建议用资源字典统一管理别在 XAML 里写死。动画方面用 WPF 的 DoubleAnimation 让机械手图标平滑移动很出效果但要注意动画时长跟实际机械手运动时间匹配否则现场人员会看着图标乱飞失去信任。我在界面上还加了一个“回放模式”把记录的坐标点位序列重新放到 Canvas 上播放。调试或者远程支援时非常有价值。有一次售后同事在客户现场说机械手动作不规律我把日志里的坐标轨迹导出来回放发现是预对准台视觉数据的零点偏了 5 毫米跟运动控制没关系。可视化的价值就在于让人一眼看出问题。4.3 操作交互与权限控制半导体设备的操作界面不欢迎花哨但必须稳。按钮分为几类启动/停止类需要二次确认涉及危险动作如开炉门、机械手介入需要弹窗加密码配方修改必须进入工程师模式。权限我用最简单的角色模型Operator、Engineer、Admin。登录后界面动态切换可用按钮和 Tab 页面。因为 WPF 有数据绑定命令的 CanExecute 可以直接根据当前角色去判断这比到处散落 if 判断要整洁得多。操作日志里记录“谁在什么时间做了什么”找问题的时候直接按操作员账号查责任清楚。界面框架我选用了 HandyControl它提供了不少现成的控件样式特别是 Notification、Window、Card 等可以减少很多自己造 UI 的工作。工业软件的观感虽然不需要炫但整洁、条理、信息层级清晰的界面确实能减少操作员误操作。5. 搬移流程与安全联锁的落地5.1 搬移任务怎么拆一个完整的搬移任务我习惯拆成步骤表每个步骤要有明确的触发条件、执行指令、完成信号和超时时间。以“石墨岛从进料台入炉”为例进料台转载传感器由 Off 变 On触发任务申请RFID 读码器读取石墨岛 ID和 MES 下发的批次配方匹配机械手移动到进料台取片位检测真空吸盘负压确认岛已吸附机械手抬起移动到预对准台释放预对准台上接触确认传感器 On视觉模块拍照计算旋转偏差机械手抓取岛进行角度纠偏等待炉膛状态温度在工艺范围内、炉门开关状态正常、MES 放行机械手送岛到反应腔入口升降轴接住确认 INSIDE 传感器机械手退回安全位任务状态切换为 Processing每步骤的状态在 HMI 上都有文字提示以及时告诉操作员当前在做什么。这个拆步的过程可能没有什么高深算法但它是整个系统可靠性的基础。每一步必须有完成信号没有完成信号的任务不能往下走否则就会出悬空状态。5.2 防呆防错细节防呆设计是跟事故打交道的经验沉淀。我见过不少设备事故最后都可以追溯到某个信号没有做双校验。常用的防呆检查方式有这些检查项判定方法异常动作石墨岛 ID 与配方匹配RFID/条码和 MES 数据比对拒绝搬运报警提示机械手是否真的抓到岛真空负压传感器 到位传感器双确认未确认到位不上抬岛是否正确放置在工位两个对称位置传感器逻辑与倾斜即报警不动作炉门安全条件炉门关闭到位 温度上限 门锁状态任一不满足禁止入炉出炉温度是否可操作冷却台温度传感器低于阈值温度不达标禁止出料破片检测工位底部光电传感器扫描检测疑点即停机确认这里面每一条都在实际维护中真实的排障价值。比如双传感器校验如果只有一个传感器判断石墨岛放平机械手撤走后可能有一边翘起下一炉进去就是绝大的事故。我后来又加了重力传感器做第三个校验维度排查成本是几个传感器对比一颗破碎的cost非常值。5.3 安全互锁与异常恢复对半导体设备新手容易犯一个错误把安全逻辑全放在上位机。这是绝对不行的。安全回路必须在 PLC 和硬件层面实现上位机软件只能在逻辑层做辅助判断。说到互锁我用的是“软互锁 硬互锁”两层软互锁在上位机和 PLC 程序里硬互锁用继电器回路直接断开危险动作。异常恢复的场景也很重要。比如机械手在搬运途中突发报警系统应该立即停止运动不能直接复位而是记录 FaultHolding 状态。现场人员排查后需要工程师权限才能解除 Hold恢复到安全 Home 位。恢复路径必须从安全位开始重新走流程不能跳步。这个逻辑完全由状态机保证如果状态机跳转表控制得好就不会出现由于一个超时引起的“状态错乱追尾”连锁故障。6. 开发中的坑与排查心得6.1 高频踩坑清单开发这套系统的过程中我在项目文档里攒了不少“案底”挑几个最典型的列出来问题表现原因与规避Dispatcher 死锁界面卡死任务栏显示无响应UI 线程同步等待异步任务建议全链路 async/await避免 Task.Wait 和 Dispatcher.Invoke 阻塞混用绑定不刷新界面显示旧数据忘记实现 INotifyPropertyChanged或者属性名拼写不匹配用 nameof 代替字符串Modbus 数据错位数值对不上忽大忽小寄存器地址起始位不同导致偏移统一使用协议地址而非厂家编号C DLL AccessViolation进程直接崩溃事件查看器报 c0000005结构体布局不一致或委托被 GC 回收加 StructLayout持委托实例高频刷新卡顿拖动窗口或机械运动时界面掉帧传感器状态合并批量更新减少 Dispatcher 调用次数关闭不必要的动画串口数据乱码读码器读取 ID 含乱码检查波特率、停止位、校验位和帧超时时间尤其注意 ASCII 与二进制混合协议每个问题都是现场熬出来的教训。比如 Dispatcher 死锁我以前在“设备启动”按钮的点击事件里用 Task.Wait 等待一个通信任务结果界面直接卡死最后排查才发现是那个通信任务里又在等待 UI 线程释放资源死锁链闭环了。改成彻底异步后问题完全消失。6.2 调试与离线验证手段开发设备上位机最烦的事情就是设备不在身边。我的方案是给 PLC 侧写一个模拟服务用 C# 写一个简单的 Modbus TCP Server 模拟 PLC 的寄存器状态。模拟器可以手动设置传感器状态、模拟超时、模拟报警这就让 UI 和业务流程在没有真实 PLC 的情况下跑起来。模拟器代码我放在另一个项目工程里和主程序共用协议定义。这样主程序的通信层可以设置模式接真实设备或者接模拟器通过配置文件切换。开发初期 90% 的流程代码问题都是靠模拟器发现的真正上电调试时只需要调硬件参数整体上线时间缩短很多。日志系统也是调试利器。我用 Serilog 记两类日志流水日志和业务日志。流水日志记录所有通信收发的原始字节和寄存器快照业务日志记录关键动作、状态迁移、报警触发原因、用户操作。所有日志按天滚动保留至少 30 天。有一次客户反馈半夜设备报警“机械手取岛超时”第二天把日志导出来看发现预对准台下方的无尘风扇振动过大导致高精度位置传感器在搬运动作时被振动干扰出现了瞬时误触发。如果不是流水日志把毫秒级的时序都记下来了这种偶发问题基本没法定位。6.3 上线前最好检查这几项真机联调阶段我的习惯是拿着检查清单一项项过这条清单是吃过亏之后长出来的急停链路的动作是否完美落到硬件而不是只在上位机弹窗断电恢复测试设备运行到一半切断主电再上电上位机和 PLC 能否恢复到安全位报警逻辑的准确性设置一个假故障确认对应报警名称、状态颜色、操作提示是否和手册一致日志磁盘写满保护长时间运行日志文件是否会撑爆 C 盘是否需要自动清理传感器与环境引入的干扰振动、气流、电压波动测试不是所有情况都能避免至少要让系统在报警时给出可排查的线索长时间空跑稳定性让程序连续跑 24 小时密切关注内存占用、句柄泄漏、UI 帧率还有一条上位机安装包的部署脚本要提前准备好包括 .NET 运行时、控制卡驱动、数据库初始化、配置文件模板。现场部署环境跟开发环境总会有差异这种细节提前准备好可以给你留出很多处理意外问题的余地。我个人的体会是这套系统最值钱的不是界面或者某个算法而是状态机和日志体系的完备性。设备自动化越往后做越会发现“稳定运行”不是靠某个聪明代码段而是靠整个系统在异常时刻“自我解释”的能力。每次故障都能从日志和状态机里还原过程系统才真正算成熟。回头我也想把运动控制部分逐步往 EtherCAT 和更标准化的运动控制接口方向迁移让设备上层逻辑能更彻底地和底层硬件解耦。