WPF上位机与S7-1200 PLC通讯实战:晶圆搬移系统开发避坑指南 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绑定一个ObservableCollectionSlotViewModel每个槽位用一个Border加TextBlock显示。颜色通过DataTrigger根据状态切换。ItemsControl ItemsSource{Binding Slots} ItemsControl.ItemsPanel ItemsPanelTemplate UniformGrid Columns5/ /ItemsPanelTemplate /ItemsControl.ItemsPanel ItemsControl.ItemTemplate DataTemplate Border Width60 Height60 Margin4 CornerRadius4 Border.Style Style TargetTypeBorder Setter PropertyBackground ValueGray/ Style.Triggers DataTrigger Binding{Binding Status} ValueHasWafer Setter PropertyBackground ValueLimeGreen/ /DataTrigger DataTrigger Binding{Binding Status} ValueEmpty Setter PropertyBackground ValueLightGray/ /DataTrigger DataTrigger Binding{Binding Status} ValueError Setter PropertyBackground ValueRed/ /DataTrigger /Style.Triggers /Style /Border.Style TextBlock Text{Binding SlotNumber} HorizontalAlignmentCenter VerticalAlignmentCenter FontSize16 FontWeightBold/ /Border /DataTemplate /ItemsControl.ItemTemplate /ItemsControl这套模板跑下来25个槽位的状态刷新在工控机上完全无压力。关键是ObservableCollection只在增删项的时候触发UI更新修改项内部的属性走的是项自己的INotifyPropertyChanged不会重建整个列表。3.3 报警信息滚动显示报警列表我用的是ListBox绑定一个ObservableCollectionAlarmItem最新的报警插到最前面。为了防止列表无限增长我限制最多保留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 xmlnsurn:schemas-microsoft-com:asm.v3 windowsSettings dpiAware xmlnshttp://schemas.microsoft.com/SMI/2005/WindowsSettingstrue/pm/dpiAware dpiAwareness xmlnshttp://schemas.microsoft.com/SMI/2016/WindowsSettingsPerMonitorV2/dpiAwareness /windowsSettings /application5.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 NameWaferPosX AddressDB100.DBD0 TypeReal/ Item NameWaferPosY AddressDB100.DBD4 TypeReal/ Item NameArmStatus AddressDB100.DBW8 TypeInt/ Item NameAlarmCode AddressDB100.DBW10 TypeInt/ /PlcAddress程序启动的时候读这个XML把地址加载到内存里。这样客户改地址不用找我自己改配置文件就行。这套系统从立项到交付用了大概两个月其中通讯层和状态机花的时间最多。WPF界面反而比较快因为MVVM的模式很成熟拖控件、绑数据、调样式一周就能出原型。真正费时间的是现场调试PLC的时序、机械臂的响应速度、安全联锁的逻辑这些都要在实际设备上反复试才能调稳。如果让我重新做一遍我会在项目初期就把PLC地址定义好做成配置文件而不是硬编码在代码里。另外心跳检测和断线重连应该在第一天就加上不要等到现场出了问题才补。这些都是实打实的经验希望能帮到正在做类似项目的朋友。