吃工控这碗饭的,谁还没被几个“灵异现象”折腾过?今天要聊的这个案例,我把它归档为“ST定时器FB踩坑实录”:程序里明明只调用了一次FB,设备却实实在在执行了两次动作。如果你也玩过ST语言,写过定时器逻辑,大概率能在这篇里找到自己当年的影子。
我调这套程序的时候,现场设备是一台烘干线,动作逻辑不复杂,用结构化文本(ST)写了几个功能块(FB),其中一个核心FB负责物料到位后的延时启动,内部用了一个TON定时器。写完下载,监控一切正常,结果设备一跑起来就原形毕露:明明给了一次启动信号,执行机构却“抽风”一样动了两次。
这个问题的诡异之处在于,从逻辑图上看,调用关系明确、变量绑定正确,怎么算都只有一次调用。但PLC扫描周期可不管你“看起来”怎样,实际执行了几次,结果不会说谎。这篇就把整个排查过程、根因分析和最终解决方案完整拆开,给同样在工控现场跟FB和定时器缠斗的朋友一个参考。
1. 故障现象复盘:一次启动信号,两次执行动作
1.1 设备动作流程还原
先交代一下现场环境。客户这套设备是流水线中的一个工位,做物料的烘干前处理。控制核心是一台中型PLC,程序主体用ST语言编写,我封装了一个功能块FB_DryStart,内部核心逻辑如下:
- 输入:启动按钮信号(bStart)、就绪信号(bReady)
- 输出:烘干启动指令(bDryRun)、状态字(wStatus)
- 内部逻辑:当bStart上升沿到来且bReady为TRUE时,TON定时器开始计时,定时5秒后输出bDryRun为TRUE,直到bStart复位
FB内部用了一个TON定时器,我就是在这里埋的雷。
设备联动方面,上位的HMI画面给一个“自动启动”按钮,按下后PLC收到信号,执行到FB_DryStart,定时5秒,随后变频器启动,风机电机开始运转。调试阶段用强制表测试,功能完全正常。但整线联动的时候,操作工反馈:按一次启动,风机会启动、停止、再启动一次,就像有人连续按了两次按钮一样。
1.2 第一反应:先排除外部因素
我的第一反应是查外围。工控现场出现“动作两次”,最先怀疑的永远是硬件和干扰:
- 按钮是否接触不良、抖动导致的重复触发?
- 变频器是否收到了重复的启动指令?
- 中间继电器是否回弹,导致二次吸合?
- 通讯线、IO线是否存在感应电干扰?
这几个方向花了我半天时间。用万用表量按钮信号,波形干净;监控变频器端子,启动指令确实是给了两次,而且两次间隔大约5秒——正好是定时器的设定时间。这说明外部触发没有问题,问题出在PLC内部的逻辑执行。
1.3 初步判断:这不是硬件问题,是程序层面的重复执行
确认信号来自PLC输出之后,我开始往程序深处挖。变频器的启动指令由FB_DryStart输出的bDryRun控制,bDryRun产生了两次上升沿,说明FB内部的逻辑在一个周期内被重复执行了,或者定时器本身被重复触发了。
这里的“重复”有两种可能:
- 整个FB被调用了两次(背景数据块被两个地方写)
- FB只调用一次,但定时器的执行条件被异常改写,导致定时器输出翻转了两次
这两条路,必须一条一条排除。当时我就意识到,问题可能出在FB的使用方式上——这是ST语言编程里面非常经典的一个坑。
2. 排查过程:从“万金油”梯形图思维切换到ST严谨思维
2.1 用交叉引用锁定FB的调用位置
在TIA Portal里(顺带说一句,信捷的Autoshop、台达的ISPSoft也有类似功能),第一步就是查看交叉引用。我在程序编辑器里选中FB_DryStart这个功能块,打开交叉引用表,逐条检查它被哪些位置调用了。
这一查,问题就浮出水面了:FB_DryStart确实只写了一个调用点,在OB1里,程序段Network 10,看起来没问题。但我仔细一看调用方式——我用了同一个背景数据块实例(Instance)在两个地方调用?不,当时没有,只有一个调用点。
但问题恰恰出在更隐蔽的地方:在OB1的Network 10里,FB_DryStart的调用并没有问题;问题在于,我在OB1里还调用了另一个FB——FB_RecipeManager,这个FB内部又调用了FB_DryStart的同一个实例。
这就是ST和LAD思维差异最大的地方。梯形图里,同一个FB实例放在两个网络里,你一眼就能看到;但ST语言里,调用嵌套非常常见,FB里套FB,实例的“可见性”变差。你在主程序里只看到一次“显式调用”,实际上还存在一次“隐式调用”——藏在另一个FB的内部。
我的问题就是:FB_RecipeManager内部逻辑中,为了做配方切换后的延时复位,在ST代码里又调用了一次dryStartInst(...),复用了同一个实例名。
2.2 监控定时器FB的背景数据块
光看调用位置还不够,还要用数据说话。我把FB_DryStart的背景数据块(比如DB20)里面的关键变量全部加到监控表:
DB20.bStart:启动信号DB20.bRun:定时器运行标志DB20.etElapsed:定时器累计时间DB20.bDryRun:输出
然后单步跑程序,观察这些变量在触发后的变化。结果非常直观:从第一次启动信号到bDryRun第一次输出为TRUE,ET累计到5秒;紧接着的几个扫描周期内,ET突然清零,又重新开始计时,5秒后又输出了一次bDryRun的上升沿。这证明定时器被“重置”了一次,重新跑完了一个完整的定时周期。能重置TON定时器的,要么是IN输入变FALSE,要么是执行条件被断开过。而我明明没有给复位信号,唯一的解释就是FB被第二次调用时,实例的IN输入状态和第一次调用时不一致,把定时器打断了。
2.3 中断OB与主循环OB的调用冲突排查
还有一种常见情况要提醒大家:某些PLC程序里,同一个FB实例既在OB1中调用,又在定时中断OB(比如OB35)或循环中断OB里调用。主循环执行到一半,定时中断插进来,也执行了一遍同一个FB。两处代码操作同一个背景数据块,就会出现“一次扫描周期内执行两次”的效果。
检查方法也很简单:打开程序块的调用结构,把所有OB组织块展开,看FB_DryStart有没有除了OB1之外的其他调用路径。如果项目中创建了OB35、OB100、OB82这些中断/启动组织块,优先查这些块里有没有调用同一个实例。
我这次的问题虽然没出在中断OB上,但这个排查动作一定要做,因为很多“间歇性抽风”的故障,源头都是不同优先级的OB在抢同一个FB实例。
3. 根因定位:同实例多处调用,等于一个周期内执行两次
3.1 ST语言中FB的本质:结构体加方法
要彻底理解这个坑,得先搞明白FB在ST语言里到底是什么。
FB(Function Block,功能块)和FC(Function,函数)最大的区别在于:FB有背景数据块(Instance DB),它保存着“记忆”,比如定时器的当前值、中间变量、输出状态;FC没有自己的数据存储区,用完就清空。
从编程语言的视角看,一个FB实例就等于“一个结构体变量 + 一组操作这个结构体的方法”。你在ST里写:
dryStartInst(bStart := bHmiStart, bReady := bSysReady);这行代码执行时,PLC会打开背景数据块,读取当前的结构体状态,执行内部逻辑,再写回背景数据块。如果这个逻辑被两个地方调用,每一处都会完整执行一遍,背景数据块会被反复读、反复写。
第一次调用:读取数据块,执行逻辑,bStart为TRUE,定时器计时到5秒,输出置TRUE,写回数据块。 第二次调用(另一处):再次读取数据块,此时可能因为输入条件不同,把内部状态又刷新了一遍,甚至把已经置位的输出给复位了,或者把正在计时的定时器给打断重置了。
这种双写操作,轻则逻辑异常,重则输出抖动。最可怕的是,当两处调用的输入参数完全一致时,你可能根本发现不了——直到某天两处输入不一致,故障突然爆发。
3.2 定时器为什么会让问题“二次放大”
定时器FB(TON)是这个问题里最敏感的环节。因为TON有一个特性:它的IN输入为TRUE时开始计时,为FALSE时立即复位清零。
假设第一次调用时,IN为TRUE,定时器正常计时;第二次调用时,因为调用者传入的bStart参数是FALSE(比如配方切换逻辑里读的是另一个变量),定时器就会被强制复位。下一次扫描,第一次调用又把IN置TRUE,定时器再次从头计时。
于是你看到的现象就是:定时器“永远差5秒”,每隔5秒输出一次脉冲。操作工看到的,就是“按一次,动作两次”——严格来说是动作了N次,只是有些人只注意到了前两次。
还有一个更隐蔽的情况:如果两次调用传入的IN都是TRUE,定时器不会复位,但输出Q端可能被两处不同的赋值逻辑写来写去,最终导致输出状态不一致,出现“输出抖动”。
3.3 不是“调用两次”的另一种伪装:边沿被丢弃
还有一种情况经常被误判为“调用两次”,就是FB的输入信号带了边沿检测,但边沿中间变量被覆盖了。
ST语言里,上升沿检测常用R_TRIG功能块或自己写边沿逻辑:
bRising := bStart AND NOT bStartOld; bStartOld := bStart;如果这段代码放在FB内部,而这个FB又被调用两次,问题就来了:第一次调用时,bStart是TRUE,bStartOld是FALSE,边沿成立;但FB内部执行完后,把bStartOld刷新为TRUE。第二次调用时,bStart还是TRUE,但bStartOld已经是TRUE了,边沿不成立。看起来逻辑没问题。
但如果两次调用的输入不一致,比如第二次调用读的是另一个变量,值为FALSE,那么bStartOld可能被刷新成FALSE,下一次扫描边沿检测又会捕获到一次上升沿,导致整个逻辑重新触发一遍。
这就是为什么很多老工程师坚持:带边沿检测的FB,绝不允许被两个地方调用同一个实例。
4. 解决方案与正确写法:如何确保“调用1次只执行1次”
4.1 方案一:一个实例只对应一处调用(最根本的解法)
规则很简单:一个FB实例,全项目只能有一个调用点。如果需要在两个地方使用同一个FB的逻辑,那就创建两个实例,各自拥有独立的背景数据块。
以我的案例为例,把FB_RecipeManager内部的二次调用改成独立实例:
// FB_RecipeManager内部 dryStartInstForRecipe(bStart := bRecipeStart, bReady := bRecipeReady);这样,主程序里的dryStartInst和配方管理里的dryStartInstForRecipe就是两个完全独立的实例,各自维护各自的定时器状态,互不干扰。
用面向对象的话说,这叫“一个对象一个实例”,不要试图复用同一个对象干两件事。每次新建实例的时候,脑子里要过一遍:这个背景数据块被几个地方引用?如果答案超过1,立刻拆。
4.2 方案二:对使能端做上升沿检测
定时器FB的IN输入,最好像触发按钮一样对待,给它做一下边沿检测,避免长期置TRUE导致的重复触发。
比如你要实现“按钮按下后启动5秒”,不要这样写:
TON_1(IN := bStart, PT := t#5s, Q => bOut, ET => tElapsed);因为bStart如果持续为TRUE,定时器输出Q会一直为TRUE,拖动设备动作持续执行,这不是我们想要的“按一次动一次”。
应该把“按下”这个动作提炼出来:
// 上升沿检测 bRiseStart := bStart AND NOT bOldStart; bOldStart := bStart; TON_1(IN := bRiseStart, PT := t#5s, Q => bOut, ET => tElapsed);这样,就算bStart维持TRUE状态,定时器也只会在边沿触发的那一个扫描周期开始计时,5秒后输出,再之后保持到定时器复位。如果需要在输出完成后自动停止,还需要结合定时器自身逻辑复位或使用脉冲定时器(TP)。
4.3 方案三:定时器复位逻辑单独处理
有时候,业务需求就是允许重复触发,但每次触发前要先复位定时器。这种情况下,不能依赖TON内部IN信号的FALSE复位,而是要显式复位。
我自己常用的写法是:
// 复位条件:模式切换或手动复位 IF bResetTimer THEN TON_1(IN := FALSE); bResetTimer := FALSE; ELSE TON_1(IN := bTrigAndRun, PT := t#5s, Q => bOut, ET => tElapsed); END_IF;先在复位分支调用一次IN := FALSE,把定时器清零,然后再走正常逻辑。这里有个细节:复位分支和正常分支的调用必须使用同一个实例,且在每个扫描周期内二选一执行,不能两个都执行。如果你在两个分支里各调用一次,本质上又犯了“多处调用”的毛病。
4.4 方案四:检查OB组织块优先级,避免中断插队
对于因为OB优先级导致的双重执行,解决办法是确保同一个FB实例只在一个OB中被调用。如果业务上需要在中断OB和主循环OB里都用同一个逻辑,那就复制两个实例,各自绑定独立的背景DB。
另外,定时中断OB(OB35)的执行周期要合理设置,默认100ms的循环周期里,如果调用了重量级FB,会影响主程序扫描。我在很多项目里习惯把OB35这类周期中断里的程序做到最简,只做“采集+快逻辑”,重逻辑尽可能扔到OB1里跑。
还要注意一点:如果两个OB的优先级不同,且一个OB正在执行时被另一个OB打断,被打断的FB实例可能在中断返回后出现状态错乱。尤其是在FB内部有自保持逻辑或者沿检测变量时,这种错乱会被放大成“动作两次”。所以我的原则是:FB实例跟着调用者走,一个实例归属一个调用者,绝不被跨OB共享。
5. 常见问题速查表与避坑清单
5.1 定时器FB三大坑
| 坑 | 表现 | 原因 | 对策 |
|---|---|---|---|
| 同实例多调用 | 定时器反复清零,输出反复触发 | 背景数据块被多处读写 | 一个实例只归一个调用者 |
| IN信号无沿处理 | 定时器输出持续时间过长或重复触发 | IN为TRUE期间定时器持续计时 | 对IN做上升沿检测 |
| 复位与计时分支混用 | 定时器状态异常,输出不受控 | 复位调用和计时调用互相覆盖 | 用IF-ELSE二分,二选一执行 |
| 中断OB插队 | 偶发重复执行 | 同一实例在主循环和中断OB中都被调用 | 跨OB调用用独立实例 |
5.2 排查流程速查表
我自己整理了一套排查“疑似多次执行”类故障的流程,按顺序来,能省大量时间:
- 打开交叉引用,查目标FB被几个位置调用。只要超过1个,基本就是元凶。
- 监控背景数据块的输入变量,确认两处调用时传入的输入是否一致。重点看IN等使能信号。
- 弹出在线监控表,观察定时器的ET值变化曲线。如果ET出现“清零-重计”的过程,说明有复位发生。
- 查OB组织块结构和优先级,确认是否存在中断OB调用同一实例。
- 用强制表或单步模式,把执行频率和扫描周期关联起来,判断故障是周期性的还是随机的。
5.3 我后来养成的几个习惯
这次踩坑之后,我给自己定了几个规矩,每条都是用代价换来的:
第一,新建FB实例时,在实例名后面加注释说明“此实例被谁调用”,防止后来者(包括三个月后的自己)误复用。
第二,带定时器或者带沿检测的FB,强制要求全项目唯一调用点。如果需要复用,禁止复制粘贴同一个实例名,必须新建实例。
第三,程序下载前,用交叉引用对所有FB实例做一次“调用次数体检”,重点检查有没有实例被调用超过一次。
第四,ST语言写复杂逻辑时,养成“自底向上”的调用习惯:底层FB只做单一功能,上层OB只负责调度,不要把多个业务逻辑耦合在同一个实例里。
这些习惯不一定能让你的程序没有Bug,但绝对能让你在定位Bug时少掉一半头发。
6. 这次踩坑,我最想说的几句话
回到这个案例本身,最核心的问题只有一个:没有遵循“一实例一调用”的原则。我用了同一个背景数据块在OB1和嵌套FB里各调用了一次,而这两处的输入条件恰恰不总是同步的,结果就是定时器被反复复位,动作被重复执行。
这类问题在纯梯形图逻辑里其实不难发现,但在ST语言里,调用关系变成了文本代码,背景数据块被隐藏在实例名背后,反而容易让人忽视。很多做PLC编程的朋友觉得ST语言更接近高级语言,写起来效率高,但别忘了,FB实例本质上是一个“全局可访问的数据结构”,你对它的每一次调用,都是在修改一个全局变量的状态。全局变量被多处修改意味着什么,C语言的教科书早就告诉我们了——那就是Bug的温床。
后来我在做程序标准化的时候,定了一条不成文的规矩:凡是FB内部使用定时器、计数器或者边沿检测的,全项目只允许有一个调用点;如果在多个工艺段要用同一个逻辑,就复制整个FB,创建多个实例,宁占内存,不乱共用。
定时器FB这个坑,我估计以后还会有人踩。如果你也在现场排查类似问题,记住一句话:程序里“看起来只调用一次”不等于“实际只执行一次”,用交叉引用和数据块监控说话,别靠肉眼数代码段。