很多做工业自动化的工程师,一提到SCADA脑子里第一反应基本都是WinCC。前几年我也是这样,直到去年做一个产线集成项目,甲方明确说不想再为WinCC的授权和后期维护买单,让我另想办法。当时摆在我面前的有三个选择:用开源SCADA、继续和西门子生态拉扯、或者直接用WPF从零写一套。我最后选了第三条路——拒绝WinCC,基于WPF做一套自己的SCADA。这篇文章不是让你无脑抛弃商用组态软件,而是把我整个选型思路、架构设计、关键功能实现和踩过的坑完整复盘一遍,给同样考虑自研的工程师一个参考。
1. 先说说我为什么非换掉WinCC不可
WinCC在西门子生态里绝对算成熟产品,我前几年做项目也用它,做单机设备监控或者小型产线,WinCC确实能快速出活。但人一旦碰到需要长期维护、频繁改造、不断扩展的项目,WinCC那套东西就会逐渐显露出让你难受的地方。下面几条是我在实际项目里真实遭遇过的,不是道听途说。
1.1 授权体系与版本碎片化让人心力交瘁
WinCC的授权体系非常庞大,基本版、专业版、运行版、组态版,还要按外部变量点数、归档变量点数分别算授权。单台操作站的授权费用已经是实打实的成本,再算上后续增加点位、增加历史归档容量,又是一轮加钱。做项目的人都知道,这类授权成本通常都要计入报价,可甲方在项目启动时根本想不到后期要加多少点,等运行到一半说要扩展,预算流程又要重新走一圈。
更麻烦的是授权跟硬件绑定,换工控机就得重新迁移授权。WinCC项目文件在不同大版本之间切换时,经常出现打不开工程、打开工程无显示这类问题。你在网上搜“wincc v8.1安装教程”能看到大量提问,本质上不是安装包难找,而是版本兼容性问题太多。好多工程师在项目现场被WinCC的版本问题搞到心态炸裂,我身边就有同事因为升级WinCC把整个画面工程搞到连画面都显示不出来的情况,最后只能从备份恢复。这种依赖关系放在一个要持续演进五年的项目里,维护成本根本不受控。
1.2 组态脚本的历史包袱:VBS和C脚本
WinCC的组态逻辑里大量使用VBS和C脚本。写的时候感觉挺自由,一旦项目复杂起来,画面对象的动作脚本、全局脚本、内部函数散落在各个角落,想查一段逻辑改个参数,你得先翻半天这个脚本到底是挂在按钮上、还是挂在画面加载里、还是在全局脚本里。
而且组态脚本没有现代IDE那套编译期检查、调试器、单元测试工具。我记得很清楚,有一次同事改按钮动作,少写了一个判断语句的结尾,导致整个画面动作全部失效,排查了很久才发现是脚本解释器编译时才暴露错误。这种“写代码像在踩地雷”的体验,对一个需要多人协作、持续交付的项目来说,是致命的。WinCC Flexible打开工程无显示这类问题,不少也是因为脚本或工程文件版本错位导致,这种平台自身的脆弱性让人很难信任它作为长期方案。
1.3 版本管理这件事,WinCC给了我最直接的放弃理由
我所在团队交付项目,代码全部走Git,需求变更、功能迭代都有痕迹。但WinCC的画面工程文件是二进制或者数据库存储的,想对比两个版本画面差异,几乎做不到。工程大了,谁改过什么、什么时候改的、为什么改,全部无从追溯。
反过来想,如果画面是文本格式,进Git做版本管理,每次改动都能diff,出了问题随时回滚,这个优势对长期维护项目来说太重要了。代码和配置都能版本化,为什么组态画面不能版本化?WinCC给不了这个能力,那我自己用WPF写,XAML就是文本文件,组态描述也做成XML或者JSON,天然可版本化。这个理由直接让我下定了自研的决心。
2. WPF凭什么能扛起SCADA这面大旗
很多人觉得WPF只是个做桌面软件的界面框架,但深入用下来你会发现,WPF几个核心特性几乎是为SCADA这类“数据驱动图形”的应用量身定做的。下面拆开讲。
2.1 数据绑定天然契合“点位-画面”模型
SCADA页面的本质,说到底就是“点位数据”驱动“图形显示”。设备温度变了,界面上的数字要变;电机状态变了,画面上电机的颜色要变;报警发生了,报警列表要插一行。这种模型用传统WinForm写,你只能挨个控件手动赋值:txtTemp.Text = tag.Value.ToString(),然后还要再处理颜色、可见性、ToolTip,点位一多、刷新一快,代码直接爆炸。
WPF的数据绑定是另一种思路:界面上的每个元素直接声明“我要绑定哪个Tag的哪个属性”,之后后台只管更新数据,界面变化由Binding机制自动推送到UI线程。举个例子,温度显示文本框只需要写一行XAML:
<TextBlock Text="{Binding Line01.Temp.Value, StringFormat=F1}" />电机状态变色只需要一个DataTrigger:
<Rectangle Fill="Gray"> <Rectangle.Style> <Style TargetType="Rectangle"> <Style.Triggers> <DataTrigger Binding="{Binding Line01.Motor.Status}" Value="1"> <Setter Property="Fill" Value="Green" /> </DataTrigger> <DataTrigger Binding="{Binding Line01.Motor.Status}" Value="0"> <Setter Property="Fill" Value="Red" /> </DataTrigger> </Style.Triggers> </Style> </Rectangle.Style> </Rectangle>这种“声明式界面”的书写方式,让界面代码和业务逻辑彻底解耦,后台更新点位数据,界面自动跟着变,不用再写一堆牵线的赋值代码。说白了,WPF把SCADA的“数据驱动图形”这个核心需求,做成了框架的标配能力。
2.2 矢量渲染与样式模板让画面质量直接提升
传统组态软件里的图形控件多数是预置好的,风格固定、像素化严重。WPF里所有图形都可以用Path、Geometry来绘制,矢量渲染,缩放不失真;高分辨率显示器上线条照样锐利,文字不虚。我们要画一条管道,不再找组态库里的管道控件,直接用Path画一条带箭头的线;要画水泵,用椭圆加旋转动画就能做出现场设备的感觉。
更重要的是Style和Template体系。一套画面里的所有电机、阀门、管道,都共用同一个样式模板。状态变了换颜色,统一在一个模板里加DataTrigger就行。现场要求“低压电机运行绿色、停止红色”,你只需要改全局样式,所有画面同步生效。这套机制比传统组态软件里逐个对象设置颜色不知道高效了多少倍。
如果想把界面做得更现代一点,社区里还有HandyControl这类开源UI库,按钮、卡片、抽屉、通知栏都很完善。不过要注意,HandyControl风格偏现代互联网产品,工业现场使用需要调整一下配色和字号,否则反而会显得花哨。我自己是拿它做了个基础框架,然后定制了一套符合工业审美的深色主题。
2.3 MVVM模式让HMI逻辑变成了软件工程
SCADA里面有很多非画面逻辑:报警判断、联锁、权限管理、数据计算。在WinCC里,这些逻辑散落在各种脚本里,没有工程化目录,没法做单元测试。WPF生态里的MVVM模式把逻辑收拢到ViewModel中,ViewModel不引用任何UI控件,所以可以脱离界面独立测试。
我用的CommunityToolkit.Mvvm,用SourceGenerator自动生成属性通知代码,减少大量样板代码。比如定义一个报警服务:
public partial class AlarmViewModel : ObservableObject { [ObservableProperty] private ObservableCollection<AlarmRecord> activeAlarms = new(); public void OnTagAlarmRaised(TagItem tag) { ActiveAlarms.Insert(0, new AlarmRecord(tag.Name, tag.Value, DateTime.Now)); } }这段代码不依赖任何窗口、控件、Dispatcher,放进单元测试随便跑。这在做产线关键设备联锁的时候特别有价值——逻辑正确性可以直接验证,而不是靠现场点按钮去试。这一条,是传统组态软件给不了的工程纪律。
3. 整体架构与数据流:从PLC到界面的那条“管线”
自研SCADA最大的风险不是画不出好看的画面,而是从底层通信到上层显示的整条链路没有理清楚。我把整个系统拆成四层:设备层、驱动通信层、实时数据中枢、界面层。每一层只做一件事,层与层之间通过接口通信。
3.1 一条清晰的数据通道
数据流向很简单:PLC或仪表产生数据 → 驱动通信层定时读取 → 写入实时数据中枢 → 界面层的Binding自动拉取并显示。反向操作流程也一样:界面点击启动按钮 → ViewModel调用命令 → 命令通过驱动通信层写值到PLC。
这条管道有一个关键约束:数据流是单向的。界面显示绝对不能直接去读设备数据,所有数据都从实时数据中枢获取。这样做的好处是,我可以不打开界面,用命令行工具直接往数据中枢里塞模拟数据,界面马上就能看到效果,设备联调阶段极大提速。通信层也能脱离界面独立调试,接真机前先用模拟器跑通逻辑。
3.2 通信层选型:OPC UA、Modbus TCP与串口并存
工业现场不会只有一个品牌的设备,通信协议五花八门。我在这个项目里主要面对的是西门子PLC、第三方的仪器仪表、老式串口设备,三类通信方式都要支持。选型如下:
| 通信方式 | 适用设备 | 技术实现 | 注意事项 |
|---|---|---|---|
| OPC UA | 西门子S7-1500/1200、罗克韦尔等支持UA的PLC | OPCFoundation UA-.NETStandard官方库 | 证书信任、安全策略配置,容易握手失败 |
| Modbus TCP | 仪表、能源采集器、第三方PLC | 自写或HslCommunication库 | 报文超时时间要按设备实际响应调 |
| 串口 | 老款仪表、变频器 | System.IO.Ports | 多设备要轮询调度,波特率、校验位必须一致 |
所有驱动实现同一个接口IDeviceDriver,约等于:
public interface IDeviceDriver { Task<bool> ConnectAsync(CancellationToken ct); Task DisconnectAsync(); Task<Dictionary<string, TagValue>> ReadAsync(CancellationToken ct); Task<bool> WriteAsync(string tagId, object value, CancellationToken ct); DeviceQuality Quality { get; } event EventHandler<DeviceStatus> StatusChanged; }上层只跟这个接口打交道,换设备就是换一个驱动实现,界面层完全不受影响。这个抽象是自研SCADA里最值得花时间做好的部分。
3.3 实时数据中枢:内存中怎么存“几千个点位”
实时数据中枢是整条链路的“数据中心”,我用ConcurrentDictionary<string, TagValue>存储所有点位的最新快照。Key是全局唯一的点位名,例如Line01.Pump.P101.Status;Value包含数值、质量戳、时间戳。
历史存储我用SQLite,而不是MySQL这类重量级数据库。原因很实际:工控机通常跑在现场,现场没有DBA,还得考虑离线运行。SQLite部署零配置,历史库就是一个文件,备份直接复制文件,非常符合工业现场的运维习惯。采样策略我做了两种:等间隔采样(例如每10秒存一条)用于趋势曲线;变化率触发采样(变化量超过死区才记录)用于长期趋势和审计。报警和事件单独建表,记录触发时间、确认时间、恢复时间。
4. 核心功能实现:从点位到画面,几个绕不开的细节
架构搭起来之后,真正让系统跑起来的核心功能有几个:点位模型怎么让数据通知界面、组态画面怎么动态加载、报警状态机怎么做完整闭环、趋势曲线怎么画才不卡顿。逐一讲。
4.1 点位模型:先把“数据能通知界面”打通
前面提到WPF靠数据绑定自动刷界面,前提是点位Model必须实现INotifyPropertyChanged。我写了一个TagItem类,核心逻辑如下:
public partial class TagItem : ObservableObject { public string Name { get; init; } [ObservableProperty] private double _value; [ObservableProperty] private TagQuality _quality; public DateTime Timestamp { get; set; } public string DisplayValue => Quality == TagQuality.Good ? Value.ToString("F1") : "--"; }这里有一个极其重要的细节:浮点数的Value属性不能每次都触发通知。现场采回来的数据是有噪声的,可能每次都在小范围波动,如果波动小于0.001就去刷新界面,几千个点位会导致UI线程被打爆。所以我在采集侧做了死区判断,变化量超过阈值才更新Value属性。
通信线程采集回来的数据也不要一个一个更新到UI线程,用一个批量更新的机制:先把一批点位写进ConcurrentDictionary,然后统一触发界面定时器的批量刷新。这样UI线程从“每秒被调用几千次”降到“每500毫秒调用一次”,性能问题直接解决。
4.2 组态画面:XML描述 + 动态加载
自研SCADA必须解决“画面从哪来”的问题。我采用“动态组态为主、静态模板为辅”的方案:每个画面不是硬编码写在XAML里的,而是由一份XML描述文件定义,运行时动态加载。
例如“泵组画面”的XML描述大致长这样:
<TagPoints> <Machine Name="泵P101" TagId="Line01.Pump.P101.Status" X="120" Y="80" /> <Machine Name="泵P102" TagId="Line01.Pump.P102.Status" X="220" Y="80" /> <Valve Name="阀V201" TagId="Line01.Valve.V201.Position" X="320" Y="80" /> </TagPoints>运行时读取XML后,用反射创建对应的图形控件,把TagId绑定到控件的依赖属性上。这套方案的好处非常直接:画面描述变成纯文本,可以用Git做版本管理,出问题随时diff对比,连记事本都能改布局。相比WinCC的组态库,这种方案轻量、透明,任何人都能介入修改画面配置。
当然,如果要给甲方提供“拖拖拽拽画画面”的能力,需要再做一个设计器。我的做法是提供一个简单的组态编辑器:左边是图元列表,中间是画面画布,右边是属性面板,保存时把画面序列化成XML。这一部分工作量和WinCC的组态工具没法比,但胜在完全受控、完全透明。
4.3 报警状态机:不只是一个布尔量变色
报警是SCADA的核心能力,但很多轻量级实现只做了一个“数值超限就高亮变红”,这是不够的。一个可靠的报警必须表达四种状态:正常、触发未确认、触发已确认、恢复。
我实现的报警状态机是这样的:
- 正常状态下,点位超限,进入“触发未确认”状态,报警列表插入一条记录,背景高亮红闪。
- 操作人员在报警界面点击“确认”,状态变为“触发已确认”,红色闪烁变成常亮黄色。
- 点位恢复后,状态变为“恢复”,报警表记录恢复时间,显示变为绿色。
报警数据单独写入SQLite的报警事件表,每条报警都有触发时间、确认时间、恢复时间,形成完整审计链路。界面报警查询需要按时间段过滤,这里就有一个WPF的实际坑:自带的DatePicker控件不支持时分秒选择,做项目时我在社区里搜到“wpf 日期选择器控件带时分秒”的问题,发现很多同行都在找这个,后来自己扩了一个支持时分秒的选择控件才解决。
4.4 实时趋势:画曲线不难,难在高频不卡
趋势曲线分两种:实时曲线和历史曲线。历史曲线简单,从SQLite里查出来用ScottPlot直接画。实时曲线就有讲究了:现场100ms一个数据,一分钟600个点,一小时36000个点,如果全部往上堆,WPF的UI线和内存都遭不住。
我的做法是滑动窗口+降采样:界面上只保留最近5000个按时间排序的数据点,超出窗口的自动丢弃;显示时如果点密度超过像素宽度,做等分抽稀,保证渲染点数不超过2000。用ScottPlot画这类曲线非常稳,它的底层渲染性能处理得比我手写的Polyline好得多。如果不想引入第三方库,直接用WPF的Polyline也能做,但到几千个点之后就会出现肉眼可见的掉帧,我建议还是直接上成熟的绘图库。
5. 实测阶段的高频坑:UI卡顿、线程模型、OPC UA握手失败
任何系统上线前都要经过真机实测。这一阶段踩的坑最有价值,因为它们的成因往往不是“代码写出bug”,而是对运行环境和线程模型理解不够。列几个典型的。
5.1 界面卡成PPT:高频刷新的真相
第一次接上真机,设置100ms采集周期,2000多个点位在线刷新,界面直接卡成PPT。排查了半天,问题有两个根源。第一,每个点位Value变化都触发PropertyChanged,UI线程被高频调度淹没。第二,文本控件频繁更新内容导致布局系统反复重新计算。
解决方案前面讲过:把界面刷新周期从100ms拉长到500ms,采集到数据先写入内存快照,UI定时器一次拉一批点位批量刷新;数字显示控件固定宽度、禁止自动换行,减少布局重排。改完之后同样的点位量,画面流畅稳定,CPU占用反而降下来了。这个例子充分说明:HMI界面其实不需要太高刷新率,人眼对毫秒级数值跳变根本无感,过度刷新只会白白吃掉性能。
5.2 渲染优化:Freeze与Canvas虚拟化
点位多、图形复杂时,WPF的渲染也会成为瓶颈。我做了几项优化:
- 所有静态图形(管道、背景、静态文字)在初始化后调用
Geometry.Freeze(),把绘图对象冻结为不可变对象,WPF内部可以直接跨线程缓存,渲染效率翻倍。 - 动态图形尽量少用
DropShadowEffect、BlurEffect这类高级效果,它们会让每次状态变化触发GPU重绘,动画多的时候掉帧明显。 - 大画面当图从“全部实例化控件”改成“只实例化可视区域控件”,没进入视口的图形不创建真正的UIElement,用Visual层绘制或者用ItemsControl的虚拟化面板。我做的产线总览画面有几十台设备的图元,虚拟化之后画面切换速度提升到无延迟。
5.3 OPC UA连接握手失败:一半是证书配置问题
项目里接西门子S7-1500的OPC UA Server时,遇到了最常见的“握手失败”问题。OPC UA连接不只是IP通不通的问题,UA TCP握手阶段就要校验很多东西:
- 客户端证书没有加入服务器的受信任列表,服务端直接断开连接。
- 安全策略不匹配(None、Basic256Sha256、Aes128Sha256等),UA握手阶段就会失败。
- 工控机时间与服务器时间不同步,会影响证书有效期校验,导致明明配置正确也握手失败。
我踩了两次时间不同步的坑之后,直接在驱动启动流程里加了与服务器时间同步的逻辑。排查这个问题的标准路线是:服务器日志查看拒绝原因 → 检查客户端证书是否在受信任目录 → 统一安全策略 → 用UA Expert工具单独连接服务器验证服务端本身是否正常。这套排查思路对WinCC配OPC UA时同样适用,很多“WinCC握手错误”实际上就是证书信任链没打通。
5.4 设备掉线了,通信层得学会“不死机”
SCADA通信层最容易出的问题是设备断网以后,驱动线程一直阻塞在读取超时上,导致整个系统像死机一样。我给所有驱动加了统一的容错策略:
- 读超时:Modbus TCP一般设置500~1000ms,超时就放弃本轮读取。
- 重试退避:连续失败3次后,进入重连程序,重连间隔从1秒、2秒、5秒逐级递增,避免设备恢复时多个驱动同时猛冲。
- 质量戳代替错误数值:设备通信失败时,点位Quality置为Bad,界面显示“通信中断”或者“--”,绝不让错误数据写进历史库。
这条策略非常重要,现场设备难免有波动,通信层的鲁棒性直接决定了整套SCADA系统在上线后给人的第一印象——是“稳定可靠”还是“天天出故障”。
6. 跑了一年之后:这套WPF SCADA到底优在哪、坑在哪
系统上线跑了一年多,经历过产线改造、设备新增、点位扩展,整体表现符合预期。这节把我实际运行的数据和一些边界思考分享出来。
6.1 实际运行数据与稳定性
我这套系统目前接入6台PLC,总计约2200个点位,100ms采集周期、500ms界面刷新周期。工控机配置是i5-7500T、8GB内存,运行内存稳定在450MB左右,CPU占用日常在10%上下,画面切换无延迟。历史库用SQLite保存了一年多的数据,接近1GB大小,查询历史趋势基本在几百毫秒内返回。
对比我以前用WinCC跑类似规模项目的印象,感觉内存占用还更小一些。WinCC运行版的常驻进程多,启动后整体占用经常以GB计算。这里不是想论证WPF比WinCC实时性更强,而是说明一个事实:HMI显示层面根本不需要那么夸张的资源开销,现代桌面技术栈完全可以胜任。
6.2 自研WPF SCADA的适用边界
自研不是万能药,得有清晰的适用边界。我根据经验画了条线:
| 场景 | 自研WPF SCADA | WinCC或开源SCADA |
|---|---|---|
| 中小型产线、非标设备集成 | 适合,定制灵活 | 也能做,但成本高 |
| 大型流程工业主控(石化、电力) | 不适合,功能安全认证做不起 | 必须用专业成熟方案 |
| 界面需要频繁定制、沿企标改造 | 优势明显,样式模板统一改 | 受限严重 |
| 团队有.NET工程师 | 适合,能长期维护 | 不需要,但受制于厂商 |
| 甲方指定必须交付特定组态工程文件 | 不合适,别硬扛 | 必须按要求来 |
另外插一句开源SCADA的对比:像Rapid SCADA这类开源方案确实成熟,但二次开发接口的学习成本同样不低。遇到非标UI需求,一样要去改源码;加上它内部技术栈和团队掌握的技术不一定匹配,我评估之后还是选择从零做WPF版本。不是开源方案不行,而是“自己能完全掌控”这个优势,对长期项目实在太重要了。
6.3 后续演进:界面与通信分离以后,路会越走越宽
目前我准备做的下一步演进,是把通信层、历史库、报警服务抽成独立的Windows服务,WPF只做前端展示。这样以后要出移动端或者平板端,可以用MAUI重做一个前端界面,后端通信和数据服务直接复用,“一鱼多吃”。
视频接入也在考虑范围。现场要集成海康等视频监控的话,用厂商SDK配合WPF的控件支撑基本能直接实现,在监控画面内部嵌视频浮层,这种能力传统组态软件往往要买额外防爆插件才行。数据上云用MQTT把点位快照上报到企业物联网平台,也是顺手的事。
做完这一整套,我个人最大体会是:SCADA的界面从来不是瓶颈,通信可靠性和数据模型才是命根子。WPF只是把界面层交还给了现代软件工程方法论,让我能用一套自己滚瓜烂熟的技能栈,把工业监控系统做成一个“真正能迭代的软件”,而不是“永远不敢碰的组态工程”。