顺手捡起这个话题:STL里FC看着就像一段普通程序,往里塞逻辑就行了,实际上它比FB“自由”得多,也正因为这种自由,坑特别多。前面几篇写了STL指令和逻辑结构的常见错误,这次把FC单拎出来讲清楚——它没有背景数据块、临时量生命周期特殊、还能直接操作状态字和整个数据块寄存器,任何一个环节没考虑周全,现场就是一台间歇性抽风的设备。
一次“看不见的内存残留”引发的排查
先讲个真事。有一台包装线设备,S7-300做主站,程序里有个FC负责根据光电信号计算包装膜的长度补偿值。设备调试时一切正常,交付三个月后客户突然报故障:补偿值偶尔会跳到异常大,导致切膜位置偏了十几毫米。一天出现一两次,完全没有规律。
到现场把程序翻了个底朝天,梯形图看不出问题,后来把FC用STL打开一行行盯,终于发现了嫌疑:FC内部用了一个TEMP变量做中间值,但这个变量在块开头没有赋初值,只有某一条分支路径上才会被写入。当另一条路径执行时,它读到的就是L堆栈里上次残留的随机数据。那“随机数据”可能是任何东西,取决于上一次哪个块占用了这块局部数据区。
这就是FC在STL编程里最典型的坑,也是我这篇要讲的第一个问题:TEMP变量的脏数据。后文会逐个拆FC相关的高频错误,包括临时量生命周期、ENO/BR位检查、参数类型、DB寄存器和地址寄存器的“副作用”、定时器编号冲突,最后给一份能直接拿去用的自查清单。
1. 为什么FC是STL项目里的“重灾区”
1.1 FC与FB的本质区别:没有“自己的内存”
很多从梯形图转STL的人,容易把FC当成“一个筐”,什么逻辑都往里装。但FC和FB的底层模型完全不同:FB有背景数据块(背景DB),块里的静态变量会永久保存在背景DB中,每次调用之间数据不丢失,相当于FB自带一个小仓库;FC没有背景DB,也没有静态变量,它的所有局部变量都放在局域数据堆栈(L堆栈)里,调用结束即释放。
这个差异直接决定了FC的TEMP变量行为。L堆栈是全局共享的一块内存区域,OB1、FC1、FC2、FB10等所有块在运行时都用同一块区域,只是各自划分窗口。一个FC退出后,它占用的L堆栈区域不会被清零,下次无论哪一个块用到这块区域,读到的都可能还是上一次的数据。
打个比方,这就像多人合租公寓,每个租客退房后管家不做保洁,下一个租客进门发现前一任留下的垃圾还在。你运气好,垃圾是没用的废纸;运气不好,垃圾里有一张写着“错误补偿值”的便签,程序就照着执行了。
1.2 一段“看着正常”的STL代码是如何踩雷的
看一个简化后的例子。下面这个FC用于模式切换,客户希望它根据输入信号决定输出值,同时内部用一个TEMP计数器做简单统计:
FUNCTION FC100 : VOID VAR_INPUT Mode : INT; END_VAR VAR_OUTPUT OutVal : INT; END_VAR VAR_TEMP TempCount : INT; TempMode : INT; END_VAR BEGIN L #Mode T #TempMode L #TempCount L 1 +I T #TempCount L #TempMode T #OutVal END_FUNCTION表面上看逻辑完整:把输入模式传给输出,TEMP计数器自增一次。但这里有两个致命问题:第一,TempCount未经初始化直接自增,第一次调用时它可能是0,也可能是65535甚至任意值;第二,TempCount根本没有存储介质,下一次调用时它不会延续上次的值,这个“计数器”是虚假的。
如果在程序里把这个FC用在一个循环逻辑中,你会看到计数器忽大忽小,完全没有规律。这就是典型的把FC当FB用——真正需要跨调用保持的数据,应该用外部DB地址通过IN_OUT接口传入,或者干脆改成FB。
1.3 给TEMP变量“上户口”:初始化的正确姿势
解决脏数据问题只有一个可靠办法:FC入口处对所有会用到的TEMP变量统一赋初值。注意,是“所有”,不是“可能用到”,因为你无法保证哪条分支会执行。养成固定习惯,在FC声明区下面直接写初始化段:
L 0 T #TempCount T #TempMode L 0.000000e+000 T #TempFloatValue有的人觉得这样啰嗦,程序跑起来也没问题,就省略了。但在现场,省略一行初始化的代价,可能是三天的排查时间。我的习惯是:FC模板里预置初始化段,所有人都按这个模板写,新人的程序评审首先查这一条。顺便说一句,TEMP数组也一样,如果FC内用TEMP做缓冲区,初始化时可以用循环或者直接MOVE一整个数据块区域,比单个初始化可靠得多。
需要跨调用保持的数据,不要侥幸塞进TEMP。FC没有静态区,要么用IN_OUT接入外部DB,要么换成FB。这是个设计决策,不是语法问题,但后面所有“间歇性故障”几乎都从这里来。
2. 调用FC后不检查ENO和BR,相当于闭着眼开车
2.1 ENO在STL里是怎么工作的
LAD和FBD里,功能块都有ENO引脚,它告诉你这个块有没有正常执行完。STL里没有图形连线,但CALL指令执行后,状态字的BR位(二进制结果位)会携带类似的信息:FC内部无异常时,BR=1;内部出现某些错误(比如算术溢出、访问错误)时,BR=0。
问题在于,STL里CALL完之后你看到的就是一条孤立指令,后面可能马上接着其他逻辑,很多人根本不看一眼BR。这在FC内部只是简单赋值逻辑时问题不大,但只要FC内部有数学运算、数据转换、间接寻址,就一定要处理状态字。
2.2 除法溢出:一个典型的BR/OV连环坑
继续开头的案例。FC300把速度换算成变频器频率,内部逻辑简化如下:
L #SpeedValue L #Denominator /R T #FreqOutput正常情况下Denominator是一个不为零的系数。但现场上位机偶尔会下发一个特殊状态字,经过中间变量转换后,Denominator变成了0。STL的浮点除法遇到除数为0时,会置位状态字的OV位和OS位,结果变为无穷大或无效值,然后程序并不会停止,而是继续往下执行,把无效结果写到FreqOutput,变频器收到一个乱值。
排查链路是这样的:
- 在线监控FC300的输入输出,输入正常,故障时刻输出突然变成0或极大值;
- 用STL单步执行,在/R指令后观察状态字的OV和OS位,发现置位;
- 检查Denominator的来源,追到上位机状态字转换逻辑,确认极端工况下会变成0;
- 在FC内部除法后面加上溢出判断,或者在调用FC300后检查BR位,发现异常时置故障标志并保持上一次有效输出。
这个案例的关键不是除法本身,而是溢出后FC还会继续往下走。STL的数学指令不像高级语言会抛异常,它只修改状态字,后续一切照常。所以FC内部如果有除法、类型转换、间接寻址这类危险操作,应当显式检查状态字,至少让异常能被上层感知,而不是让错误结果在程序里“带病运行”。
顺带说清一个容易混淆的点:在STEP 7中,FC调用的BR位并不总是自动反映FC内部是否有错。如果FC内部没有显式处理状态字,BR的值取决于最后一条指令的状态,不一定可靠。因此最稳妥的做法是,在FC内部末尾用SET或者根据错误标志显式设置RLO,再通过不影响BR的指令流转出去;在调用侧,则习惯性地在CALL之后跟一条:
CALL FC300 A BR = #CallOK JCN ERR这样即使FC内部出了问题,调用侧也能拿到一个明确的“成功/失败”信号,而不是看着输出结果发愣。
2.3 ENO=0不代表程序停下来了,更不代表输出安全
有现场经验的工程师都有一个共识:程序里最危险的故障不是CPU停机,而是CPU继续跑但算错了。FC调用后不检查BR,恰恰就是给这种“算错还继续跑”开了绿灯。我的做法是对于关键功能FC,在调用后不但检查BR,还要对输出参数做合理性判断,比如范围检查、变化率限制。有些算法错误BR位并不会变0,但输出一看就不合理,这种二次检查往往能在故障还没造成损失之前就把问题暴露出来。
3. 参数传递里的类型陷阱:REAL、INT、常量的“暗战”
3.1 最容易翻车的三种传参写法
STL里给FC传参,表面看很简单,实际上类型问题相当隐蔽。以下几种是我在评审程序时反复见到的。
第一种,整型常量传给REAL形参。例如接口里定义了一个REAL输入,调用时直接:
CALL FC500 Input := 1010在S7中是INT型字面量,直接传给REAL形参并不安全。正确写法是写成浮点字面量:
Input := 10.0别小看这个小数点,项目现场因为这种低级类型错误导致计算结果偏差的情况并不少。
第二种,同一个地址既做输入又做输出。比如接口有Input和Output两个参数,调用时如果把两个都填成MD10,FC内部先读MD10做运算再写回MD10,一旦运算逻辑里输入和输出还参与了别的计算,结果就会以你意想不到的方式互相覆盖。
第三种,隐式的REAL和INT混算。STL中不会自动把INT转成REAL再计算,L 5 L 3 /R看起来没问题,但如果5是INT、3是REAL,指令本身就会编译报错。真正的麻烦来自那些通过M区传递数据的FC:一个FC把值以REAL格式写到MD100,另一个FC以INT格式从MW100读取。由于REAL占32位、INT只读16位,读出来的一定不是原来的数。这种错误交叉引用查不出来,因为两个FC各自都“合法”,纯粹是数据契约没对齐。
3.2 为什么STL传参比SCL更容易出事
SCL有相对严格的编译期类型检查,写错了IDE直接红字提示。STL的“自由度”恰恰是双刃剑——它允许你用绝对地址、允许你直接操作状态字、允许你用各种指针转换,编译器的很多错误提示在STL里会变成运行期的不可预期行为。特别是从STEP 7老项目迁移到TIA Portal时,移植过程中的数据类型自动调整可能会掩盖一部分问题,导致现场出现时好时坏的怪异现象。
遇到这类问题,不要急着改FC内部逻辑,先把接口定义打出来,核对每个参数的名称、类型、注释,再看每个调用点的实参。很多“FC计算结果不对”的故障,根源根本不在FC内部,而在某个调用点把参数传歪了。
3.3 接口变更后的未同步:隐藏的版本冲突
修改FC的接口(比如增加一个输入参数、调整参数顺序)之后,如果项目里这个FC被多个块调用,必须把所有调用点同步更新。TIA Portal有自动更新块调用的功能,但如果你是从STEP 7迁移过来的老项目、或者项目里存在大量间接寻址,调用点可能不会被全部识别。
现象很典型:PLC运行一段时间后CPU进入STOP,诊断缓冲区显示“块调用参数错误”之类的信息;或者某个调用点得到的输出一直是默认值。排查办法很简单:右键FC选择“交叉引用”,看所有调用处,逐个核对参数列表。我曾经在一个老项目里发现一个FC被OB1和OB10两个组织块调用,只更新了OB1里的调用,OB10里的还是旧接口,导致每天凌晨定时执行OB10时数据全是乱的。
4. 全局资源带病使用:OPN DB、定时器编号和M区“打架”
4.1 FC里打开DB却不还原,调用者跟着遭殃
STL的OPN指令用来打开数据块寄存器,打开之后,后续所有不带DB号的DBW、DBD访问都针对这个被打开的DB。这个机制用来写短小快速的代码非常方便:
OPN DB100 L DBW0 T #Value问题出现在FC边界。如果FC内部打开DB100后没有在退出前恢复原来的DB,那么调用这个FC的上一级程序,在FC返回后继续使用DBW0访问数据块时,访问的就不再是它自己以为的DB,而是DB100。
举个例子,OB1里有一段逻辑在访问DB1,中间调用FC200,FC200内部OPN了DB100,退出时没还原。OB1在FC200后面的指令L DBW0时,实际读到的是DB100.DBW0。程序不报错,因为语法完全合法,但数据就串了。
排查这种问题,在线监控时注意观察调用前后DB寄存器的值。修复方式有两种:一种是在FC入口把当前打开的DB号保存到TEMP,退出前再OPN回去;另一种是彻底放弃依赖OPN的寻址方式,全项目使用符号寻址,类似"DB100".DBW0。第二种更治本,因为符号寻址不依赖“当前打开的DB”这个全局状态,FC之间不会因为这个互相影响。
4.2 在FC里直接使用T1、C1:调用两次就打架
功能块FC没有背景数据块,因此如果FC内部直接使用定时器T1、计数器C1,这个FC就只能在整个项目里调用一次。一旦你在两个不同的地方调用同一个FC(这在FC的使用场景里很正常),同一时刻两个调用点共享同一个T1,定时逻辑必然互相干扰。
正确做法是把定时器编号作为参数传入:
FUNCTION FC400 : VOID VAR_INPUT TimerNo : TIMER; END_VAR调用时传入不同的实际定时器:
CALL FC400 TimerNo := T1如果使用IEC定时器(TONR、TP等),它们需要DB实例,同理把实例DB作为IN_OUT参数传入。这样FC就变成可重入的,也就是可以在项目中被多次调用而互不影响。判断自己的FC是否可重入,有一个简单标准:把这个FC复制成FC_A和FC_B,分别在不同地方调用,如果两边行为互不干扰,它就是可重入的。
4.3 M区标志位冲突:一个M10.0搅乱三个FC
M区是全局共享存储区,老项目里经常用M10.0做“启动标志”、用MB11做“状态字节”。如果多个FC各自维护一套M区逻辑,又没有规范的分配记录,时间一长就会互相覆盖。
更隐蔽的变种是用MOVE指令把一组M区当数据缓冲区。比如FC_A把一组数据MOVE到MB20开始的区域,FC_B也把自己的数据MOVE到MB20,两个FC的调用时间稍有重叠,缓冲区就被踩踏。
这类问题最难受的地方在于它不具备一致性:不是每次运行都出错,而是取决于FC_A和FC_B谁先执行、谁后执行。排查时用PLC变量表把所有M区的使用列出来,交叉引用看哪些地址被多个块访问,重点盯那些既是写又是读的地址。长期看,新项目应该尽量用DB块替代M区,把数据归属明确到块;存量项目至少要建立一张M区地址分配表,谁用了哪个地址、干什么用,记录清楚。
5. AR1/AR2和间接寻址的暗雷
5.1 地址寄存器不是局部变量,系统不会替你保存
AR1和AR2是CPU内部的地址寄存器,用于STL的间接寻址。很多STL程序员喜欢在FC里用AR1做数组遍历、指针偏移操作。但AR1/AR2是全局资源,不是某个FC的私有变量,系统在块调用边界不会自动保存和恢复它们。
考虑一个场景:OB1在调用某个FC之前,已经用AR2指向了一个正在处理的结构体;调用FC后,如果FC内部修改了AR2,返回后OB1再用AR2寻址,得到的就是错误位置。这几乎不会报错,只会产生错误数据。
正确的做法是FC内部若要用AR1/AR2,进入块后立刻把原值保存到TEMP变量,退出前恢复:
LAR1 P##TempSave // 先把原AR1存入TEMP ... LAR1 P##TempSave LAR1 // 退出前恢复原AR1更稳妥的做法是项目统一约定:凡是用了AR1/AR2的FC,必须在块内部自己保存、自己恢复,调用者也不要做任何假设。
5.2 偏移量计算:字节、字、双字的错位
间接寻址中有一个高频错误:偏移量单位搞错。AR1里的地址是字节地址,访问REAL数组第N个元素时,偏移量应该是N4字节;访问INT数组是N2字节;访问BYTE数组才是N*1字节。很多人写的时候直接用索引加上偏移,忘了乘以元素所占字节数。
正确的STL片段:
LAR1 P##ArrayStart L #Index SLD 2 // 乘以4,因为每个元素是REAL,占4字节 +D LAR1 L D [AR1, P#0.0] T #Result这里的SLD 2(左移两位)就是乘以4。如果Index是INT类型,还要先转成双整数(如ITD),因为地址运算要求32位。忽略了这一步,程序实际访问的地址完全错位,读出来的数据自然不对。
5.3 递归与嵌套深度:FC迟早被“堆”爆发
FC理论上可以自己调用自己,但实际工程中递归几乎总是坏事。调用链每深一层,就会消耗一块L堆栈区域,而CPU的L堆栈大小有限。多个FC嵌套调用、每个FC的TEMP数组又比较大时,L堆栈可能溢出,CPU直接进入STOP。S7-300系列对局部数据要求尤其敏感,OB1的本地数据大小设置过小,也会加剧这个问题。
遇到CPU偶发STOP、诊断缓冲区提示“本地数据溢出”时,优先检查调用链深度和FC的TEMP变量规模。一个不太合理但常见的现象是:有人把一个大数组直接放在FC的TEMP区,又在多个地方嵌套调用这个FC,单个L堆栈窗口就捉襟见肘了。改善方向有两个:一是把大数组挪到DB里,通过IN_OUT接口传递;二是压缩TEMP变量、减少嵌套层数。这不是改某一个指令的问题,而是整个块设计需要重新考虑。
6. 把FC写成“规范件”:一份能救命的自查清单
6.1 高频错误速查表
| 检查项 | 要求 | 典型错误 |
|---|---|---|
| TEMP初始化 | FC入口处全部赋初值 | 分支路径未覆盖,读取L堆栈残留 |
| 状态字BR/OV | 关键运算后检查状态位 | 除零、溢出后继续执行 |
| 参数类型 | 实参与形参严格一致 | 常量传REAL、REAL/INT混用 |
| DB寄存器 | 使用OPN后退出前还原 | DBW访问串到其他数据块 |
| 定时器/计数器 | 编号通过参数传入 | FC内部写死T1/C1 |
| AR1/AR2 | 内部保存并恢复 | 调用后地址寄存器被改 |
| M区使用 | 建立分配表避免冲突 | 多个FC复用同一M区 |
| 嵌套深度 | 控制L堆栈占用 | 大TEMP数组嵌套调用溢出 |
| 接口变更 | 所有调用点同步更新 | 旧接口调用点残留 |
6.2 用可重入性检验FC设计的质量
一个FC真正合格的标准,是它能被安全地多次复用。这意味着它不依赖未初始化的TEMP、不写死全局资源、不破坏调用者的全局寄存器状态。每次评审新人的程序,我不看逻辑对不对,先看这几条底线。TEMP有初始化、BR有处理、DB操作有还原、AR寄存器有保存恢复、定时器编号靠参数传入,满足这五条,FC的“坏脾气”基本就治好了。
6.3 我排查FC问题的标准顺序
遇到FC相关的疑难故障,我习惯按固定顺序来,比乱翻程序高效得多:
- 先查交叉引用,确认这个FC在项目里被谁调用过、调用点的参数和接口是否完全一致;
- 在线监控块调用前后,对比L堆栈和DB寄存器的变化,锁定异常;
- 逐行单步执行FC内部的数学运算,观察状态字的OV/OS、BR位变化;
- 检查FC内部是否有OPN DB、定时器编号、M区写入这类全局操作;
- 如果还查不出来,把FC的TEMP变量全部赋初值后再投入运行,看故障是否消失,以此反推是否脏数据问题。
这个方法在多次现场调试中都奏效,尤其是第5步,虽然看起来粗暴,但十次里有八次能快速定位TEMP相关问题。
最后再分享一条个人经验。以前我带项目时,团队写STL的FC,风格五花八门,有人不初始化TEMP,有人把定时器直接写在FC里,有人OPN完DB不还原。后来我把所有FC都改成统一模板,开头是初始化段,中间是业务逻辑,末尾是状态字处理段,评审只看这几处。坚持了两三个项目之后,FC相关的疑难故障明显少了一大半。编程语言的自由度是一把双刃剑,STL给了你无限操作空间,也给了你足够的犯错机会。把容易出问题的环节用规范管住,剩下的才真正是你的业务逻辑。