做过HMI画面的人,多半被弹窗折磨过。设备报警要点出来、手动操作要确认、参数修改要二次核对,这些场景在工控现场天天发生。早期我在一个设备项目里用HMI脚本写弹窗,逻辑一多就乱套:弹窗A还没关,弹窗B又要弹,操作员对着满屏窗口发懵,最后只能轮流值班守着。后来我把弹窗的控制逻辑从HMI挪到PLC里,用SCL写了一个自定义功能块作为弹窗管理器,主画面的显示、切换、关闭完全由PLC侧状态决定。这套做法在博图V17里跑得很稳,维护成本也低,这篇就把实现思路、源码细节和调试经验完整拆开聊聊。
1. 弹窗为什么要放在PLC里做:三种实现方案怎么选
1.1 纯HMI脚本方案的短板在哪里
不少项目用WinCC的脚本写弹窗,按钮按下触发一个函数,把某个画面窗口的Visible属性置TRUE。单看一个弹窗很简洁,但项目一复杂就露馅了。脚本散落在各个画面的按钮事件里,新同事接手根本不知道哪里有弹窗逻辑;想加一个“最高优先级报警弹窗优先显示”的规则,脚本里要做全局变量记录当前弹窗状态,写着写着就成了意大利面条。
最头疼的是脚本运行在HMI侧,PLC完全不知道当前有没有弹窗、弹的是哪个。设备在自动运行中需要弹窗确认时,如果操作员没点,设备下一步动作还在继续,这就很危险。弹窗这类交互本质上是“设备在等人”,这种语义放在PLC里才安全。
1.2 PLC侧SCL方案的优势和适用边界
改成SCL自定义功能块之后,弹窗逻辑变成了一段在PLC里跑的程序。任何需要弹窗的功能块只要往公共请求区里写一个请求,管理器自动决定当前该显示哪一个,HMI侧的按钮再把结果写回PLC,形成一条完整的数据链。
这个方案有几个直接的好处:互斥和优先级可以在PLC里做到非常严谨,用的是循环扫描和数值比较,而不是脚本里容易被绕晕的全局变量;弹窗状态可被PLC其他逻辑读取,比如“有弹窗等待操作员确认时禁止设备启动”;所有逻辑在博图里统一调试,PLCSIM能把各种工况模拟一遍,不用反复在真机上跑。
当然也有边界。弹窗里要放复杂表格、图表、曲线这类HMI控件时,控制框架在PLC,内容页仍然放HMI去做,两者不冲突。我的建议是:判断型、确认型、提示型的弹窗交给PLC管理器;数据展示型、报表型的弹窗保留HMI能力,但开关仍然由PLC控制。
1.3 整体架构一句话说清楚
整个方案可以分成三块:一块公共数据区,哪个功能块要弹窗就往里写请求;一个管理器功能块,负责扫描请求、仲裁优先级、输出窗口开关和画面索引;一层HMI壳,也就是主画面上的画面窗口对象,只负责显示和按钮回执。逻辑脑在PLC,展示壳在HMI,这是我认为最适合博图V17项目的解耦方式。
2. 功能块接口设计:请求区和管理器怎么拆
2.1 公共数据块的结构怎么定
弹窗请求的载体我用了一个全局DB,里面是一个结构体数组。每个结构体代表一路弹窗请求,包括这个请求是否有效、优先级是多少、要弹的是哪个画面、操作员确认和取消的标志位。结构体定义如下。
TYPE "typ_PopupRequest" STRUCT active : Bool; // 请求激活位,TRUE表示有弹窗需求 priority : Int; // 优先级,数值大优先,范围建议0到10 popupType : Int; // 画面索引号,对应HMI弹窗子画面的编号 confirmed : Bool; // 操作员按了“确认”按钮 canceled : Bool; // 操作员按了“取消”按钮 END_STRUCT END_TYPE全局DB里实例化这个结构体数组,数量根据项目实际弹窗种类来定,一般6到10路足够了。
"DB_PopupRequest" request : ARRAY[1..8] OF "typ_PopupRequest"为什么不给每个弹窗单独建一个DB?统一成数组之后,管理器可以用一条FOR循环扫描所有请求,仲裁逻辑只写一次,以后增加弹窗种类只要把数组元素用起来,不用改管理器。这比散落一堆独立变量清爽很多,也方便在HMI调试画面里把整个数组显示出来看。
2.2 管理器功能块的输入输出怎么规划
管理器功能块我用的是FB而不是FC,原因在后面讲超时定时器的时候会提到,FB能自带定时器实例和静态变量,FC做不到这一点。
FUNCTION_BLOCK "FB_PopupManager" { S7_Optimized_Access := 'TRUE' } VERSION : 0.1 VAR_INPUT i_Timeout : Time := T#10S; // 操作员超时未响应自动关闭时间 END_VAR VAR_OUTPUT q_ConfirmTask : Bool; // 有请求被确认了,输出一个脉冲给请求方 q_CancelTask : Bool; // 有请求被取消了,输出脉冲 q_TimeoutTask : Bool; // 有请求超时被自动关闭,输出脉冲 END_VAR VAR_IN_OUT io_WindowOpen : Bool; // 与HMI画面窗口的Visible属性关联 io_ContentIndex : Int; // 与HMI画面窗口的过程值(画面索引)关联 END_VAR2.3 为什么用InOut而不是纯Output
这是一个容易被忽视的细节。io_WindowOpen和io_ContentIndex用InOut而不是Output,是因为这两个变量要直接接到HMI画面窗口的属性上。博图的HMI画面窗口动画属性在关联PLC变量时,要求变量能双向访问,读当前值用于显示判断,写入时用于初始化。InOut参数相当于把外部变量引用进来操作,底层地址直接连到HMI变量,不会被功能块内部复制一份,这样HMI侧显示的值和管理器操作的地址是同一个,避免数据不同步的诡异问题。
实际项目中这两个InOut变量我一般点名到一个专用显示DB里,比如“DB_HmiDisplay”。这样HMI组态时直接从那个DB选变量,画面窗口属性配置非常顺手,也方便在博图的监视表里一眼看到当前窗口状态。
3. SCL核心逻辑:管理器又是怎么一步步跑起来的
3.1 请求扫描与最高优先级仲裁的写法
管理器每隔一个扫描周期被OB1调用一次。每次调用先做一轮数组扫描,找出当前处于active状态且优先级最高的请求。这个优先级仲裁建议用简单的“大于”比较,相同优先级时让数组下标小的先命中,这等于实现了先来先服务的效果。
#scanFound := FALSE; #maxPriority := -1; #curIndex := 0; FOR #i := 1 TO 8 BY 1 DO IF "DB_PopupRequest".request[#i].active THEN // 用“>”而不是“>=”,保证相同优先级下先触发的请求优先 IF "DB_PopupRequest".request[#i].priority > #maxPriority THEN #maxPriority := "DB_PopupRequest".request[#i].priority; #curIndex := #i; #scanFound := TRUE; END_IF; END_IF; END_FOR;FOR循环结尾这行必须写固定的数字8,不能写数组长度变量,博图V17的SCL编译器要求循环边界在编译期可确定。如果以后数组扩容,这里要同步改,我一般习惯在功能块头部用注释标明数组长度,提醒自己别漏。
3.2 窗口输出和内容索引怎么处理
扫描结束之后的逻辑分两种情况。如果找到了有效请求,就把画面窗口打开,并把当前请求的popupType赋值给io_ContentIndex。HMI侧的画面窗口会根据这个数值自动切换对应的弹窗子画面。
IF #scanFound THEN #io_WindowOpen := TRUE; #io_ContentIndex := "DB_PopupRequest".request[#curIndex].popupType; ELSE // 没有请求时确保窗口关闭,内容索引归零 #io_WindowOpen := FALSE; #io_ContentIndex := 0; END_IF;特别提醒io_WindowOpen这个变量在无请求时一定要强制赋FALSE,不能只在上电初始化的地方清一次。因为在循环扫描周期里变量可能被置TRUE,如果本周期没有请求发现,不把它拉下来,弹窗就会一直挂在屏幕上。这也是很多初学者遇到的“弹窗关不掉”问题的根源,定期刷新比一次性复位可靠得多。
3.3 操作员回执处理:确认、取消、超时
弹窗显示后,操作员会按HMI上的按钮,按钮把DB里的confirmed或canceled位置TRUE。管理器在下一周期扫描时就能看到这个变化,做对应的业务处理。
IF #curIndex > 0 THEN // 确认按钮被按下 IF "DB_PopupRequest".request[#curIndex].confirmed THEN #q_ConfirmTask := TRUE; // 先输出确认脉冲 // 再清掉当前请求 "DB_PopupRequest".request[#curIndex].active := FALSE; "DB_PopupRequest".request[#curIndex].confirmed := FALSE; "DB_PopupRequest".request[#curIndex].canceled := FALSE; END_IF; // 取消按钮被按下 IF "DB_PopupRequest".request[#curIndex].canceled THEN #q_CancelTask := TRUE; "DB_PopupRequest".request[#curIndex].active := FALSE; "DB_PopupRequest".request[#curIndex].confirmed := FALSE; "DB_PopupRequest".request[#curIndex].canceled := FALSE; END_IF; END_IF;这里有个顺序问题:q_ConfirmTask必须先置TRUE,再去清请求标志。如果反过来,先清了active,同一周期里再判断发现当前请求已经不存在了,输出的脉冲就会丢。很多功能块用着用着出现“偶尔确认没反应”就是这种顺序写反造成的。
3.4 超时自动DISS的设计:这也是用FB的原因
弹窗弹出来之后,操作员可能走开了或者玩手机没看到。我加了一个超时时间,默认10秒,超过时间没操作就自动关闭,并把超时标志输出给请求方。
#tonTimeout(IN := #io_WindowOpen, PT := #i_Timeout, Q => #timeoutFlag); IF #timeoutFlag AND #curIndex > 0 THEN #q_TimeoutTask := TRUE; "DB_PopupRequest".request[#curIndex].active := FALSE; "DB_PopupRequest".request[#curIndex].confirmed := FALSE; "DB_PopupRequest".request[#curIndex].canceled := FALSE; // 定时器输出保持为TRUE,这里要把IN拉低才能继续响应下一次弹窗 #tonTimeout(IN := FALSE, PT := #i_Timeout); END_IF;注意代码里TR被同一个周期内调用了两次,第一次用#io_WindowOpen作为IN,第二次在超时处理时人为把IN拉低。这样TON内部的定时时间才能复位,否则超时标志会一直保持TRUE,后面所有弹窗都会立刻超时。第一次写这个功能块的时候我忽略了第二次调用,现场表现为弹窗刚弹出就自动关闭,查了半天才定位到定时器没有复位。这个坑值得记下来。
3.5 完整功能块参考源码
把上面的片段整合到一起,一个完整可用的管理器功能块就成型了。完整代码我贴出来,项目里实际用的时候只需要把数组尺寸和超时时间按需调整。
FUNCTION_BLOCK "FB_PopupManager" { S7_Optimized_Access := 'TRUE' } VERSION : 0.1 VAR_INPUT i_Timeout : Time := T#10S; END_VAR VAR_OUTPUT q_ConfirmTask : Bool; q_CancelTask : Bool; q_TimeoutTask : Bool; END_VAR VAR_IN_OUT io_WindowOpen : Bool; io_ContentIndex : Int; END_VAR VAR tonTimeout : TON; END_VAR VAR_TEMP scanFound : Bool; maxPriority : Int; curIndex : Int; timeoutFlag : Bool; i : Int; END_VAR BEGIN #scanFound := FALSE; #maxPriority := -1; #curIndex := 0; #timeoutFlag := FALSE; #q_ConfirmTask := FALSE; #q_CancelTask := FALSE; #q_TimeoutTask := FALSE; // 1. 扫描请求数组,选出最高优先级 FOR #i := 1 TO 8 BY 1 DO IF "DB_PopupRequest".request[#i].active THEN IF "DB_PopupRequest".request[#i].priority > #maxPriority THEN #maxPriority := "DB_PopupRequest".request[#i].priority; #curIndex := #i; #scanFound := TRUE; END_IF; END_IF; END_FOR; // 2. 输出窗口状态和画面索引 IF #scanFound THEN #io_WindowOpen := TRUE; #io_ContentIndex := "DB_PopupRequest".request[#curIndex].popupType; ELSE #io_WindowOpen := FALSE; #io_ContentIndex := 0; END_IF; // 3. 回执处理 IF #curIndex > 0 THEN IF "DB_PopupRequest".request[#curIndex].confirmed THEN #q_ConfirmTask := TRUE; "DB_PopupRequest".request[#curIndex].active := FALSE; "DB_PopupRequest".request[#curIndex].confirmed := FALSE; "DB_PopupRequest".request[#curIndex].canceled := FALSE; END_IF; IF "DB_PopupRequest".request[#curIndex].canceled THEN #q_CancelTask := TRUE; "DB_PopupRequest".request[#curIndex].active := FALSE; "DB_PopupRequest".request[#curIndex].confirmed := FALSE; "DB_PopupRequest".request[#curIndex].canceled := FALSE; END_IF; END_IF; // 4. 超时自动关闭 #tonTimeout(IN := #io_WindowOpen, PT := #i_Timeout, Q => #timeoutFlag); IF #timeoutFlag AND #curIndex > 0 THEN #q_TimeoutTask := TRUE; "DB_PopupRequest".request[#curIndex].active := FALSE; "DB_PopupRequest".request[#curIndex].confirmed := FALSE; "DB_PopupRequest".request[#curIndex].canceled := FALSE; #tonTimeout(IN := FALSE, PT := #i_Timeout); END_IF; END_FUNCTION_BLOCK4. HMI画面组态:画面窗口怎么跟PLC变量对接
4.1 画面窗口的属性配置是核心
在博图V17的WinCC画面编辑器里,主画面添加一个“画面窗口”(Screen Window)对象,这个对象就是弹窗的显示区域。属性面板里要配置两处最关键的关联。
第一处是可见性动画。选中画面窗口,在属性“动画->可见性”中添加新动画,关联PLC变量io_WindowOpen。如果变量为TRUE就显示,FALSE就隐藏。这里有个容易踩的坑:博图的可见性动画默认“可见性”属性在变量为TRUE时显示,为FALSE时隐藏,这正好符合我们的需求,不需要反向,别手滑改成“反转”。
第二处是过程值属性。画面窗口的“属性->常规->过程值”里配置PLC变量io_ContentIndex,这个变量用来切换画面窗口内部显示哪个子画面。博图会根据这个整型值自动加载对应编号的画面,0一般是空白页或者索引起始页。
4.2 弹窗子画面的设计思路
弹窗子画面在WinCC里作为独立画面创建,不要放在主画面里做成成组对象。每个弹窗内容页就是一个独立画面,比如报警确认页、电机操作确认页、配方参数确认页。创建画面的顺序会决定索引对应的内容,第一个创建的画面索引是0,第二个是1,以此类推。所以索引对照表最好写进项目文档里,不然隔一个月自己也会忘。
弹窗子画面内部放置一个全尺寸的半透明矩形作为遮罩,透明度我习惯放到80%左右,既能压住背景,又能让操作员看到主画面还在运行。确认和取消按钮放在遮罩层之上,按钮的“操作模式”要选“释放时置位”,不要用“按下时置位”。现场操作员经常是点一下觉得没反应又点好几下,按压时长和释放时序差异会导致“按下时置位”的变量被多次触发,而“释放时置位”配合PLC侧边沿处理能过滤掉大部分重复点击。
4.3 画面索引切换的底层逻辑
io_ContentIndex切换画面的过程在WinCC里是立即生效的。假设当前显示的是索引1的报警确认页,管理器在某个周期把值改成2,HMI会立刻切到索引2对应的参数确认页,中间没有任何过渡动画,也不需要额外的脚本。这个过程听起来简单,但有个变量类型的问题需要重视。
HMI画面窗口的过程值属性期望的通常是Word或UInt类型,而SCL里我用的是Int。如果直接把Int变量关联过去,编译可能不报错,但运行时会有类型转换的隐患,偶尔会出现索引值不刷新。我在项目里习惯的做法是在管理器输出的位置加一个CONV指令显式转换,或者干脆把io_ContentIndex改成UInt。建议代码里保持一致,减少不必要的隐式转换。
4.4 上电初始化别忽略
WinCC运行画面启动时,画面窗口对象会先以上次保存的组态状态显示。如果PLC还没跑起来,io_WindowOpen还没来得及被赋值,弹窗区域有可能先显示一瞬间。解决办法是在PLC的启动组织块OB100里给io_WindowOpen做一次强制复位,同时在WinCC画面窗口属性的“初始可见性”设为隐藏,双保险。
我在调试阶段遇到过一个问题:重下PLC程序后弹窗区域一直显示黑块,怎么点都关不掉。后来发现是HMI运行系统启动时PLC还没运行,画面窗口加载了默认画面,但关联变量没有被刷新。加了OB100初始化之后这种问题再没出现过。
5. 实战中踩过的坑:问题排查与技巧
5.1 画面窗口不显示或者闪烁的排查顺序
弹窗不显示是最常见的故障,我的排查顺序永远是先看PLC侧,再看HMI侧。先在博图的监视表里看io_WindowOpen的实时值,如果一直是FALSE,说明管理器认为当前没有请求,往回查是哪个请求位没有置TRUE,还是数组扫描逻辑没跑。如果PLC侧值是TRUE但画面没弹出来,问题大概率在HMI侧:画面窗口的可见性动画关联有没有勾选生效、关联的变量路径有没有拼错、画面窗口尺寸是否被设成了零。
闪烁问题往往是可见性变量被多处写入。比如OB1里调用了管理器,某个FC里又顺手把io_WindowOpen赋了一次FALSE,两个周期交替写入,画面就在显示和隐藏之间抖动。排查方法是用博图的“交叉引用”功能搜io_WindowOpen,看所有写入位置,该删的写入点就删掉。
5.2 按钮按下没反应和重复触发怎么处理
确认按钮按下去完全没有反应,先查按钮事件里关联的变量名是confirmed还是其他位,再看PLC监视表里这个位有没有被置TRUE。如果置了TRUE但管理器没有响应,检查DB的访问模式。博图V17里优化访问的DB,Boole变量会被压缩存储,某些HMI版本直接读写会有偏移问题。稳妥的做法是把公共弹窗DB改为标准访问模式,或者在DB属性里取消“优化的块访问”,这样HMI变量表里能看到完整的位变量,按钮置位才可靠。
重复触发的问题我在现场遇过好几次,操作员连点三下确认,设备动作被重复触发了三次。解决方法是管理器对confirmed位做边沿检测而不是电平判断,也就是只在该位从FALSE变TRUE的上升沿处理一次。SCL里可以这样处理。
// 用上一次的保持变量做比较,只处理上升沿 IF "DB_PopupRequest".request[#curIndex].confirmed AND NOT #lastConfirmedState THEN #q_ConfirmTask := TRUE; END_IF; #lastConfirmedState := "DB_PopupRequest".request[#curIndex].confirmed;5.3 优先级仲裁的边界情况要想清楚
数组扫描用的是“>”比较,默认先触发者优先。但有些场景希望后触发的更高优先级请求能抢占当前弹窗,比如设备急停信号来了,必须立刻覆盖当前正在显示的参数设置页。这种情况可以把比较条件改成“>=”,这样后触发的同优先级请求会顶替先前的,再配合额外的抢占逻辑。两种模式在代码里差一个字符,语义却完全不同,建议在功能块里加一个输入参数i_PreemptEnable,用这个开关切换比较方式,不要直接改代码。
还要注意请求方自己把优先级设置正确。某设备厂商给的参数页弹窗优先级设成了5,报警确认也是5,结果报警来了参数页还在前面,报警弹窗排不上号。项目规范里最好把优先级常量固定下来,比如报警类10到20,确认类5到9,信息提示类1到4,谁都不许乱写。
5.4 类型转换和数组越界是编译器的眼泪
SCL里数组越界不会像高级语言那样报运行时异常,而是直接访问到相邻内存,可能把一个错误值传给HMI画面索引,导致弹窗内容张冠李戴。所以请求方在业务逻辑里设置popupType时,一定要限制范围。我会在管理器头部加一个UMIN和UMAX的过滤,把非法值夹到安全范围内,防止HMI过程值越界后加载不存在的画面导致运行系统报错。
写法也很简单。
#io_ContentIndex := MAX(0, MIN(8, #io_ContentIndex));画面窗口的可显示画面数量也要和这个上限保持一致,在画面窗口属性里填8,表示0到7共8种画面,超出部分不要分配。
5.5 调试阶段的实用技巧
调试这种弹窗管理器,用PLCSIM比用真实PLC效率高得多。博图V17的PLCSIM配合WinCC运行系统仿真,可以直接在仿真器里强制DB数组元素的active位和priority值,模拟10种请求组合,几分钟就能把仲裁逻辑验证完。真实设备上反复触发弹窗太伤硬件了,这种交互逻辑应该在仿真阶段全部验完。
另外我会在HMI主画面的角落里做一个调试小面板,把这个弹窗请求数组的关键字段用表格显示出来,哪个请求是激活的、优先级多少、内容索引多少,一眼就能看到。现场调试带这个面板,处理问题比翻PLC变量快太多。
还有一点心得:把请求数组的请求方做一个约定,谁设置active为TRUE,谁就必须负责在设备停机时清掉这个标志。我把这个规则写进了项目交接文档,后面维护的人改起来也心里有数。
最后分享一个我实际用得很顺手的扩展想法。这个管理器目前只管一层弹窗,如果你想做“二级弹窗”,也就是主弹窗里再弹一个确认页,可以把数组分成两个组别,管理器扫描的时候只扫当前组别,每组独立维护io_WindowOpen和io_ContentIndex,HMI画面窗口也放两个层。逻辑框架不需要大改,就是多实例化一次功能块的事。这算是这个方案比较耐用的地方,改配置不改核心,一套框架能撑起很多场景。