1. 项目缘起与整体架构设计
半导体制造车间里的晶圆搬移和石墨岛转运,听起来像是两个完全不同的工序,但在实际产线上它们经常出现在同一个工作站里。晶圆需要从卡匣中一片片取出、经过检测工位、再放回指定位置;石墨岛作为承载晶圆的托盘,又需要在不同工位之间流转。这套上位机系统要做的就是把这些动作串起来,让操作员在一个界面上完成所有控制。
我接这个项目的时候,客户给的需求很直接:一套运行在工控机上的WPF程序,通过以太网跟PLC通讯,控制机械臂完成晶圆和石墨岛的抓取、搬移、放置,同时要实时显示设备状态、报警信息、生产计数。听起来不复杂,但真正动手之后才发现坑一个接一个。
先说整体架构。这套系统我采用的是经典的三层结构:通讯层负责跟PLC打交道,业务逻辑层处理搬移流程和状态机,UI层用WPF做界面展示和操作交互。三层之间通过事件和委托解耦,避免UI线程被通讯阻塞。
为什么选WPF而不是WinForm?这个项目里界面元素比较多,晶圆卡匣的实时状态、机械臂位置、报警列表、生产统计,这些都需要频繁刷新。WPF的数据绑定机制让我不用手动去更新每个控件的值,只要ViewModel里的属性变了,界面自动跟着变。另外WPF的矢量渲染在工控机上缩放界面时不会糊,这对不同分辨率的触摸屏很友好。
通讯协议方面,客户现场用的是西门子S7-1200系列PLC,所以走的是S7协议。C#这边我用的是S7.Net这个开源库,轻量、稳定、API直观。为什么不走OPC UA?客户现有的PLC没有配置OPC UA服务,加这个要额外授权费用,而且S7.Net直接读写DB块的方式对产线维护人员来说更透明,他们用博图就能对照着看。
这里有个选型经验:如果PLC是西门子的,优先考虑S7.Net或者Sharp7;如果是三菱、欧姆龙,用HslCommunication会更省事。不要一上来就上OPC UA,除非客户明确要求或者多品牌设备混用。
机械臂这边是通过PLC间接控制的,上位机不直接发指令给机械臂控制器,而是把动作指令写到PLC的DB块里,PLC再去调度机械臂。这样做的好处是安全逻辑全部在PLC侧兜底,上位机就算死机了,PLC也能保证机械臂不会撞机。
2. 核心通讯层实现与避坑要点
2.1 S7协议连接与数据读写
S7.Net的用法很直接,但有几个参数必须配对,否则连不上。以S7-1200为例,连接需要指定CPU类型、IP地址、机架号和槽号。S7-1200的机架号是0,槽号是1,这个跟S7-300/400不一样,后者通常槽号是2。
private Plc _plc; public bool Connect(string ip) { _plc = new Plc(CpuType.S71200, ip, 0, 1); _plc.Open(); return _plc.IsConnected; }读写DB块的时候,地址格式是"DB块号.起始字节.起始位.数据类型"。比如要读DB100里从第0个字节开始的32位浮点数,写法是_plc.Read("DB100.DBD0")。这里有个坑:S7.Net的地址字符串里,DBD表示双字(32位),DBW表示字(16位),DBB表示字节(8位),DBX表示位。写错了不会报错,但读出来的数据是错的。
我习惯把所有的地址定义成常量,集中放在一个静态类里,这样后期PLC地址变了只需要改一个地方:
public static class PlcAddress { public const string WaferPosX = "DB100.DBD0"; public const string WaferPosY = "DB100.DBD4"; public const string ArmStatus = "DB100.DBW8"; public const string AlarmCode = "DB100.DBW10"; public const string Heartbeat = "DB100.DBX12.0"; }2.2 心跳检测与断线重连
工控现场的网络环境不比办公室,网线被叉车压断、交换机过热重启都是常有的事。上位机必须能检测到连接断开并自动重连,否则操作员看到界面还在但实际已经失控,那就危险了。
我的做法是在后台线程里每500毫秒读写一次心跳位。PLC侧写一个定时器,每200毫秒翻转一次心跳位;上位机读这个位,如果连续3次读到的值没变化,就判定连接异常,触发重连逻辑。
private async Task HeartbeatLoop(CancellationToken token) { int sameCount = 0; bool lastValue = false; while (!token.IsCancellationRequested) { try { var current = (bool)_plc.Read(PlcAddress.Heartbeat); if (current == lastValue) { sameCount++; if (sameCount >= 3) { OnConnectionLost(); await ReconnectAsync(); sameCount = 0; } } else { sameCount = 0; lastValue = current; } } catch { OnConnectionLost(); await ReconnectAsync(); } await Task.Delay(500, token); } }重连的时候要注意:先Close再Open,不要直接Open。S7.Net在连接已断开的情况下直接Open有时会抛异常。另外重连成功后要把所有缓存的状态清零,重新从PLC读一遍,避免界面上显示的是断线前的旧数据。
实操心得:心跳位不要用PLC的时钟存储器位(比如M0.0那种),因为那个位的翻转周期是固定的,上位机如果刚好在它翻转的瞬间读取,可能连续读到相同的值。用PLC程序里自己写的定时器翻转一个DB位更可靠。
2.3 批量读写与性能优化
晶圆搬移过程中需要监控的变量很多:机械臂的X/Y/Z坐标、真空吸盘的真空度、卡匣的槽位状态、光栅尺的读数等等。如果每个变量单独发一次S7请求,一轮下来要几百毫秒,界面刷新会明显卡顿。
S7.Net支持批量读取,把连续的地址合并成一个请求。比如DB100里从DBD0到DBD40是连续的40个字节,可以一次性读回来,然后在内存里解析。
var bytes = _plc.ReadBytes(DataType.DataBlock, 100, 0, 40); var posX = S7.Net.Types.Double.FromByteArray(bytes.Skip(0).Take(4).ToArray()); var posY = S7.Net.Types.Double.FromByteArray(bytes.Skip(4).Take(4).ToArray());但要注意,不是所有变量都适合批量读。报警代码、急停状态这类安全相关的变量,我建议单独读,而且读取频率要高一些。因为批量读一旦某个地址出错,整个请求都会失败,安全变量不能冒这个险。
3. WPF界面设计与数据绑定实战
3.1 MVVM模式在工控上位机中的落地
工控上位机跟普通的业务系统不一样,它对实时性要求高,但界面逻辑相对固定。我用MVVM主要是为了解耦,让通讯层的代码不污染UI代码。ViewModel里暴露属性,XAML里绑定这些属性,通讯层更新属性值的时候通过Dispatcher.Invoke切回UI线程。
private double _armPosX; public double ArmPosX { get => _armPosX; set { _armPosX = value; OnPropertyChanged(); } } // 通讯层更新 Application.Current.Dispatcher.Invoke(() => { viewModel.ArmPosX = newValue; });这里有个性能问题:如果每50毫秒更新一次坐标值,每次都要走一遍属性变更通知,界面上的绑定元素多了之后会卡。我的优化方案是对于高频更新的数值,用DispatcherTimer在UI线程里定时从共享内存读,而不是每次数据变化都触发通知。
3.2 晶圆卡匣状态的可视化
晶圆卡匣有25个槽位,每个槽位有"有片/无片/异常"三种状态。我用一个ItemsControl绑定一个ObservableCollection<SlotViewModel>,每个槽位用一个Border加TextBlock显示。颜色通过DataTrigger根据状态切换。
<ItemsControl ItemsSource="{Binding Slots}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <UniformGrid Columns="5"/> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemTemplate> <DataTemplate> <Border Width="60" Height="60" Margin="4" CornerRadius="4"> <Border.Style> <Style TargetType="Border"> <Setter Property="Background" Value="Gray"/> <Style.Triggers> <DataTrigger Binding="{Binding Status}" Value="HasWafer"> <Setter Property="Background" Value="LimeGreen"/> </DataTrigger> <DataTrigger Binding="{Binding Status}" Value="Empty"> <Setter Property="Background" Value="LightGray"/> </DataTrigger> <DataTrigger Binding="{Binding Status}" Value="Error"> <Setter Property="Background" Value="Red"/> </DataTrigger> </Style.Triggers> </Style> </Border.Style> <TextBlock Text="{Binding SlotNumber}" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="16" FontWeight="Bold"/> </Border> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl>这套模板跑下来,25个槽位的状态刷新在工控机上完全无压力。关键是ObservableCollection只在增删项的时候触发UI更新,修改项内部的属性走的是项自己的INotifyPropertyChanged,不会重建整个列表。
3.3 报警信息滚动显示
报警列表我用的是ListBox,绑定一个ObservableCollection<AlarmItem>,最新的报警插到最前面。为了防止列表无限增长,我限制最多保留200条,超出的从尾部移除。
private void AddAlarm(string message, AlarmLevel level) { Application.Current.Dispatcher.Invoke(() => { Alarms.Insert(0, new AlarmItem { Time = DateTime.Now, Message = message, Level = level }); while (Alarms.Count > 200) Alarms.RemoveAt(Alarms.Count - 1); }); }报警的级别用不同的背景色区分:红色是急停和故障,黄色是警告,蓝色是提示。操作员扫一眼就能判断严重程度。
注意:报警列表的滚动条要设置成自动滚动到顶部,否则新报警来了操作员看不到。可以在Insert之后调用
listBox.ScrollIntoView(listBox.Items[0])。
4. 搬移流程状态机与异常处理
4.1 状态机设计
晶圆搬移的流程可以拆成几个状态:空闲、取片、检测、放片、回原点、报警。每个状态之间有明确的转换条件,我用一个枚举加switch-case来实现。
public enum MoveState { Idle, PickingWafer, Inspecting, PlacingWafer, Returning, Alarm } private MoveState _currentState = MoveState.Idle; private async Task RunStateMachine() { while (!_cts.IsCancellationRequested) { switch (_currentState) { case MoveState.Idle: if (_startCommand) _currentState = MoveState.PickingWafer; break; case MoveState.PickingWafer: await PickWaferAsync(); _currentState = MoveState.Inspecting; break; case MoveState.Inspecting: await InspectAsync(); _currentState = MoveState.PlacingWafer; break; case MoveState.PlacingWafer: await PlaceWaferAsync(); _currentState = MoveState.Returning; break; case MoveState.Returning: await ReturnHomeAsync(); _currentState = MoveState.Idle; break; case MoveState.Alarm: await Task.Delay(1000); break; } await Task.Delay(50); } }每个动作方法里都是写PLC指令、等PLC反馈、检查结果。等待反馈的时候用轮询,每50毫秒读一次状态位,超时时间设成5秒。超时了就进Alarm状态,同时弹窗提示操作员。
4.2 异常处理与安全联锁
搬移过程中最怕的是撞机。我的做法是在每个动作执行前检查安全条件:真空度是否达标、机械臂是否在安全位置、急停是否被按下。任何一个条件不满足,立即停止并报警。
private async Task PickWaferAsync() { if (!await CheckSafetyAsync()) { _currentState = MoveState.Alarm; return; } _plc.Write(PlcAddress.Command, (short)Command.Pick); var timeout = DateTime.Now.AddSeconds(5); while (DateTime.Now < timeout) { var status = (short)_plc.Read(PlcAddress.ArmStatus); if (status == (short)ArmStatus.PickDone) return; if (status == (short)ArmStatus.Error) { AddAlarm("取片失败,机械臂报错", AlarmLevel.Error); _currentState = MoveState.Alarm; return; } await Task.Delay(50); } AddAlarm("取片超时", AlarmLevel.Error); _currentState = MoveState.Alarm; }安全联锁这块,急停信号我单独用一个高优先级线程去读,读取周期是20毫秒。一旦检测到急停按下,立即把所有输出置零,然后弹窗提示。这个线程的优先级要设成ThreadPriority.Highest,确保不会被其他任务阻塞。
4.3 生产计数与数据记录
每完成一次搬移,生产计数加一,同时把这次搬移的详细信息写到CSV文件里。CSV的写入我用的是StreamWriter加锁的方式,避免多线程同时写导致文件损坏。
private static readonly object _csvLock = new object(); private void LogMoveRecord(MoveRecord record) { lock (_csvLock) { var line = $"{record.Time:yyyy-MM-dd HH:mm:ss}," + $"{record.WaferId}," + $"{record.FromSlot}," + $"{record.ToSlot}," + $"{record.Result}"; File.AppendAllText(_logPath, line + Environment.NewLine); } }CSV文件按天分文件,文件名格式是MoveLog_20250101.csv。每天零点自动切换到新文件。这样方便追溯,也不会让单个文件太大。
踩过的坑:
File.AppendAllText在文件被Excel打开的时候会抛异常。解决办法是用FileStream以FileShare.ReadWrite的方式打开,或者干脆在写入前检查文件是否被占用,被占用就换个文件名。
5. 常见问题排查与实操技巧
5.1 通讯相关故障
问题一:S7.Net连接报"Connection refused"
这个一般是IP地址不对或者端口被防火墙拦了。S7协议默认走102端口,先在工控机上用telnet PLC_IP 102测一下通不通。如果telnet不通,检查PLC的IP设置和工控机的网卡配置是不是在同一个网段。
问题二:读DB块报"Address out of range"
这个错误说明地址超出了DB块的实际长度。比如DB100只定义了20个字节,你去读DB100.DBD24,就会报这个错。解决办法是在博图里打开DB块,确认"优化的块访问"没有勾选,然后看实际的最大偏移量。
问题三:读上来的浮点数是乱码
西门子的浮点数是大端格式,S7.Net的Double.FromByteArray已经处理了字节序问题。如果你自己用BitConverter.ToSingle去转,需要先Array.Reverse。我建议直接用S7.Net提供的方法,省得踩坑。
5.2 WPF界面相关故障
问题一:界面卡死
最常见的原因是通讯操作在UI线程里执行了。S7.Net的读写是同步阻塞的,放在UI线程里必然卡。解决办法是把所有通讯操作放到Task.Run里,或者用异步版本的API。
问题二:数据绑定不更新
检查ViewModel有没有实现INotifyPropertyChanged,属性的setter里有没有调OnPropertyChanged。另外如果是集合,要用ObservableCollection而不是List。
问题三:界面在高DPI屏幕上模糊
在项目的app.manifest里加上DPI感知的设置:
<application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application>5.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 连接PLC失败 | IP错误/防火墙/端口不通 | telnet测试102端口 | 检查IP配置,关闭防火墙 |
| 读DB块报错 | 地址越界/DB块未定义 | 博图里查看DB块长度 | 修正地址或扩展DB块 |
| 界面卡顿 | 通讯在UI线程执行 | 查看调用栈 | 移到Task.Run里执行 |
| 数据不刷新 | 未实现INotifyPropertyChanged | 检查ViewModel | 实现接口并触发通知 |
| 浮点数乱码 | 字节序问题 | 对比PLC里的值 | 用S7.Net的转换方法 |
| CSV写入失败 | 文件被占用 | 检查文件是否被打开 | 用FileShare.ReadWrite |
| 急停响应慢 | 轮询周期太长 | 查看急停线程周期 | 缩短到20ms并提高优先级 |
5.4 实操心得
这套系统在现场跑了半年多,我总结了几条经验。第一,通讯层一定要做断线重连,而且重连成功后要重新同步所有状态,不能依赖缓存。第二,安全相关的变量单独读,不要跟普通变量混在一个批量请求里。第三,界面刷新频率不要太高,坐标值50毫秒更新一次足够了,报警和状态位可以快一些。第四,日志一定要写,出了问题时日志是唯一的线索。第五,CSV文件按天分,方便追溯也方便清理。
还有一个细节:工控机的电源管理一定要关掉休眠和睡眠,否则半夜自动休眠了,第二天操作员来发现界面还在但PLC已经断连了。这个坑我踩过一次,后来在部署文档里专门加了一条"关闭电源管理"的检查项。
6. 部署与现场调试要点
6.1 工控机环境准备
工控机到手之后,先装系统。我一般用Windows 10 LTSC版本,这个版本没有应用商店和Cortana那些乱七八糟的东西,稳定。装完之后关掉自动更新,工控机最怕半夜自动重启。
.NET运行时方面,如果目标机器没有装.NET Framework 4.8,需要提前装好。我一般把安装包跟程序一起打包,用Inno Setup做个安装程序,双击就能装。
屏幕分辨率建议设成1920x1080,缩放100%。如果客户用的是触摸屏,把按钮做大一点,最小尺寸不要小于60x60像素,否则手指点不准。
6.2 现场调试流程
到了现场之后,先别急着连PLC。第一步是确认网络通不通,用ping命令测一下PLC的IP。第二步是用博图连一下PLC,确认PLC程序已经下载并且运行正常。第三步才是打开上位机,连PLC,看数据能不能读上来。
调试搬移流程的时候,先用空卡匣跑一遍,确认机械臂的动作顺序是对的。然后再放晶圆进去,跑完整流程。每跑一步都盯着看,有问题立即按急停。
现场调试切记:第一次跑自动流程的时候,机械臂的速度要调到最低,等流程跑通了再逐步提速。我见过有人一上来就全速跑,结果机械臂撞到卡匣上,修了好几天。
6.3 交付文档清单
交付的时候除了程序本身,还要给客户这几样东西:操作手册(带截图)、PLC地址对照表、常见故障排查指南、安装包和授权文件。操作手册要写得让不懂技术的人也能看懂,每一步都配截图,标注清楚点哪里。
PLC地址对照表尤其重要,后期如果PLC程序改了地址,客户自己能对照着改上位机的配置文件。我一般把地址做成XML配置文件,客户用记事本就能改,不用重新编译程序。
<PlcAddress> <Item Name="WaferPosX" Address="DB100.DBD0" Type="Real"/> <Item Name="WaferPosY" Address="DB100.DBD4" Type="Real"/> <Item Name="ArmStatus" Address="DB100.DBW8" Type="Int"/> <Item Name="AlarmCode" Address="DB100.DBW10" Type="Int"/> </PlcAddress>程序启动的时候读这个XML,把地址加载到内存里。这样客户改地址不用找我,自己改配置文件就行。
这套系统从立项到交付用了大概两个月,其中通讯层和状态机花的时间最多。WPF界面反而比较快,因为MVVM的模式很成熟,拖控件、绑数据、调样式,一周就能出原型。真正费时间的是现场调试,PLC的时序、机械臂的响应速度、安全联锁的逻辑,这些都要在实际设备上反复试才能调稳。
如果让我重新做一遍,我会在项目初期就把PLC地址定义好,做成配置文件,而不是硬编码在代码里。另外心跳检测和断线重连应该在第一天就加上,不要等到现场出了问题才补。这些都是实打实的经验,希望能帮到正在做类似项目的朋友。