做过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的报警确认页管理器在某个周期把值改成2HMI会立刻切到索引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里优化访问的DBBoole变量会被压缩存储某些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_ContentIndexHMI画面窗口也放两个层。逻辑框架不需要大改就是多实例化一次功能块的事。这算是这个方案比较耐用的地方改配置不改核心一套框架能撑起很多场景。
阅读完成 · 觉得有帮助?