news 2026/11/4 19:02:31

WPF上位机与S7-1200 PLC通讯实战:晶圆搬移系统开发避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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绑定一个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地址定义好,做成配置文件,而不是硬编码在代码里。另外心跳检测和断线重连应该在第一天就加上,不要等到现场出了问题才补。这些都是实打实的经验,希望能帮到正在做类似项目的朋友。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 19:02:19

ADS122U04IPWR+STM32F756ZG工业温度测量系统设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/4 19:00:51

STM32+FreeRTOS实战:多传感器房间监测节点RoomSense Monitor

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/4 19:00:50

特斯拉AP3.0域控制器深度拆解:FSD硬件设计原理与车规级工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/4 19:00:34

残障人士专属安全设备:从交互设计到跌倒检测的完整实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/4 18:59:51

ADS122U04+PIC18F66K40:24位Δ-Σ ADC打造高精度工业温度测量

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/11/4 18:58:46

harness-sdk 架构设计与工程实践:从契约层到运行时层的集成抽象

1. 从零认识 harness-sdk&#xff1a;它到底解决什么问题 第一次看到 harness-sdk 这个名字&#xff0c;很多人会愣一下——harness 是“马具、线束”的意思&#xff0c;放在软件工程语境里&#xff0c;它其实指的是“把一堆零散能力套上统一框架、让它们能被牵引和调度”的那…

作者头像 李华