在 TIA 博图里点开任意一个 FB 或 FC,接口区最上面那一排 Input、Output、Inout、Static、Temp、Constant、Return,几乎是每个做西门子 PLC 的人每天都要摸的东西。但说实话,能把这几类参数全都用对、用顺的人真不多。我这些年给别人收拾过的项目里,功能块跑起来没报错、但后台一团糟的情况太常见了:背景数据块被 Input 和 Output 撑得比程序还大,Temp 变量被当成断电保持位用,FC 的输出参数在调用处悬空不接,在线一监控全是随机数,还有人把 Static 当成"断电保持"的代名词,结果设备一断电参数全丢。这篇文章就把这七类接口参数从头到尾捋一遍——它们各自存在哪儿、什么时候会被复制、什么时候是传引用、编译期发生了什么、哪些坑是新手几乎一定会踩的。不管你是刚学博图、还在照着视频抄程序的入门选手,还是写了几年 FB、接口设计一直凭感觉的中级工程师,看完至少能明确一件事:接口区里每一个格子,都不是随便填的。
1. FB 与 FC 到底差在哪:不讲清这个,参数永远用不明白
1.1 FB 有"记忆",FC 是"一次性"的
讨论参数之前,得先把 FB 和 FC 的底层存储模型说透,因为七种接口参数的行为差异,一大半都来源于这里。FB(Function Block,功能块)在调用时会自动生成或者绑定一个背景数据块,业内也叫实例 DB、Instance DB。这个 DB 就是 FB 的"记忆体",块里所有非临时性质的东西——Input、Output、Inout、Static——最终都落在这一块 DB 存储区里。也就是说,你第一次调用这个 FB 实例时给它赋的输入值,只要下一次调用时在调用处不重新给,它会一直保留着上次写进去的内容。
FC(Function,函数)则完全相反。FC 没有背景数据块,调用 FC 的时候,系统从 CPU 的本地数据区(也就是常说的 L 堆栈)上临时划一块空间来放它的接口参数和 Temp 变量,块执行完,这块空间立刻被回收,下一次调用可能被别的块占用了。这意味着 FC 是彻底的无状态块:它进来的时候什么样,取决于调用者给了什么;它出去的时候留不下任何痕迹。同一段逻辑,你用 FC 写十遍调用十次,每次都是"从零开始"。
这个差异带来的最直接的后果是:FB 的背景 DB 是可以被外部读写的。你在 HMI 上想直接改某个设备的启动延时、想在监控表里看某个状态机的当前步序,只要把这个 FB 实例的实例 DB 拖到 HMI 变量或者监控表里,就能直接访问里面的 Input 和 Static 变量。而 FC 完全没有这个能力,它的内部数据在块返回的瞬间就消失了,你想看也看不到。
1.2 选 FB 还是 FC,判断标准其实只有一条
网上关于"什么时候用 FB、什么时候用 FC"的说法五花八门,有的按功能分、有的按调用次数分,其实真正的判断标准只有一条:这段逻辑需不需要跨扫描周期记住东西。
需要记住的典型场景包括:边沿检测(必须记住上一次的输入状态)、状态机(必须记住当前步序)、累计计数(必须记住累加到哪了)、延时和超时判断(必须记住定时器实例)、报警确认(必须记住哪条报警已经确认过)。这些一律用 FB,把需要记住的量放进 Static 区。
不需要记住的典型场景:单位换算、模拟量标定、字符串拼接、CRC 校验、位组合逻辑、报警字按位打包。这些一律用 FC,输入进来、算完、结果返回,干净利落,不留任何存储。
我见过最常见的错误用法是"所有块都建成 FB"。新人觉得 FB 高级、能存东西,于是连一个"把摄氏度转华氏度"的小函数都非要建成 FB。一个中等规模的项目这么干下来,背景 DB 数量能翻两三倍,程序编译慢、下载慢、在线监控卡,而且每个 DB 都占着存储空间,纯属浪费。反过来也有另一个极端:把所有带状态的逻辑硬塞进 FC,然后在外面的 DB 里手动传状态变量。这么做能跑,但每次调用 FC 都要手动挂一堆状态引脚,代码可读性极差,想换个地方复用都难。
提示:新建块的时候,TIA 默认是 FB。养成一个习惯:先想清楚"这个东西需不需要记住上一次的状态",再决定点 FB 还是 FC。建成之后发现选错,改块类型能改,但接口区的内容要重新搬,挺麻烦的。
2. 七种接口参数逐个拆解
2.1 Input 与 Output:一对最容易被搞混的老搭档
Input 是输入形参,调用方传值进来,块内部读。Output 是输出形参,块内部写值,调用方读走。听起来很简单,但有两个细节几乎所有人都会踩。
第一个细节是 Input 在 SCL 里可以被写。你在 SCL 代码里对 IN 参数赋值,编译器通常不会直接拦你,最多给个警告,但运行时的行为会让很多人困惑:值确实改了,可是当块返回、你在调用方再去看那个实参的时候,它一点变化都没有。因为这个赋值只作用在块内部的那份副本上。对 FB 来说,这份副本存在背景 DB 里,改了之后下次调用还能看到这个改变值——但它跟调用方传进来的那个变量已经没有任何关系了。我见过有人想用"在 FB 内部改输入参数"的方式实现"反馈置位调用方的位",调试半天看不到效果。想往调用方写数据,只有一条路:用 Output 或者 Inout。
第二个细节是 Output 在 FC 和 FB 里的行为差异。FB 的 Output 存在背景 DB 中,如果某次调用因为逻辑分支没走到、没有给这个 Output 赋值,那么它保持的是上一次调用写进去的值,也就是"记忆型输出"。这有时是好事,有时是灾难。而 FC 的 Output 没有存储位置,如果块内部某条路径没有赋值,接收方拿到的就是它原本的值——如果接收方是个没初始化的临时变量或者 M 点,那就是一个随机数。所以写 FC 的时候,养成一个习惯:在块的开头先把所有 Output 按最坏情况赋一遍初值,把每条分支都覆盖到。
在 LAD/FBD 里调用这两种块,还有一个实用差别。FB 的输入引脚可以不连接,不连接时会使用背景 DB 中当前保留的值,这个特性特别适合做 HMI 可调的参数——工程师在 HMI 上把这个实例 DB 里的参数改了,程序不用动,下次调用直接生效。而 FC 的输入引脚在调用时一般不允许留空,编译器会提示参数未连接。同理,FB 的输出引脚不连接也不会报错,值留在 DB 里;FC 的输出引脚不接则会直接编译报错。
2.2 Inout:能读能写,但不是万金油
Inout(IN_OUT)是既能读又能写的参数,行为上像是把 Input 和 Output 合体了。但它的价值远不止省一个引脚,真正的核心在于传递方式。
在 FC 中,IN_OUT 参数是通过引用传递的,用行话说就是把实参的地址交进来了,块内部对它的读写,直接作用在调用方那块原始存储区上。这一个特性决定了 IN_OUT 最重要的使用场景:传递大块数据。比如你写一个 FC 用来处理一个有 100 个元素的 REAL 数组,如果用 Input 参数接这个数组,那么每次调用系统都要把这 100 个 REAL 复制一遍到 L 堆栈上,800 字节的复制开销;而用 IN_OUT 参数接,传的只是一个地址,块内部直接访问原始数组,零复制。数组越大、调用越频繁,这个差异越明显。
也正是因为 IN_OUT 处理的是引用而不是副本,它带来了一堆需要警惕的问题。首先,传给 IN_OUT 的实参必须是一个"可寻址的存储位置"——DB 里的变量、M 区的变量、FB 实例里的 Static 或 Inout,都行;但你不能传一个常量或者一个字面量进去,编译器会直接拒绝。其次,实参不能是那种"块执行完就失效"的东西。最典型的坑是:在 FC1 里调用 FC2,把一个 Temp 数组传给 FC2 的 IN_OUT 参数。FC2 执行的时候这个数组还在,没问题;但只要 FC2 内部把这个指针存下来或者传递出去,出了 FC1 之后就指向了一片无效区域,后面再访问就是标准的内存乱读甚至停机。
还有一个隐蔽的问题是两个 IN_OUT 参数指向了同一块内存。比如你写一个 FC,接口是"IN_OUT arr1、IN_OUT arr2",本意是做一个数组对数组的搬运,结果调用的时候不小心把同一个数组传了两遍。如果块内部是"先读 arr1、再写 arr2"的顺序,看着没事;但如果逻辑是逐元素边读边写,就会出现一半数据被覆盖的情况,而且这种 bug 极难定位,因为在线监控的时候每一帧看着都像是正常的。
在 FB 中,IN_OUT 还有一个不太为人知的特性:它也存在背景 DB 里。所以对一个 FB 来说,把一个数组声明成 IN_OUT 而不是 Input,并不省背景 DB 的空间,省的是调用时的数据复制开销。这一点跟 FC 里的情况不一样,要分清楚。
注意:IN_OUT 不是什么场景都该用。如果只是想传一个数值进来读一下、算个结果返回,用 Input + Output 语义更清晰,也更容易被别人看懂。IN_OUT 留给那些"进来之后要被改写、或者数据量大到不能复制"的场景。
2.3 Static:FB 专属的持久记忆体
Static 只存在于 FB 里,这是 FB 和 FC 在接口区最直观的差异。凡是需要跨扫描周期保留的东西——边沿检测的上一拍状态、状态机的当前步、TON 定时器实例、计数器实例、多重实例调用的子 FB——都放在这一区。
很多人对 Static 有一个根深蒂固的误解:以为放进 Static 就是断电保持。这是错的。Static 变量的"持久"只是相对于扫描周期而言:它存在背景 DB 里,CPU 运行期间一直有效,程序跑到哪一轮它都在。但掉电之后能不能回来,取决于 CPU 的保持性设置。在 S7-1200/1500 上,默认情况下 DB 里的变量是不保持的,掉电再来就是初始值。想要真正掉电保持,需要在 CPU 属性里配置保持性存储区,然后在 DB 的"保持性"列上逐个变量勾选设置。默认不保持这个设定,救过很多人,也坑过很多人。
Static 区另一个容易被低估的用途是放"多重实例"。西门子的定时器、计数器,本质上是 FB 或者系统功能块,它们的实例也必须占存储。你可以为每个定时器单独建一个背景 DB,文件树里就会多出一堆 DB;更好的做法是在当前 FB 的 Static 区声明一个 TON_TIME 类型的 Static 变量,然后直接调用它。这样一个设备的十几个定时器全都塞在它自己的背景 DB 里,文件树干净,DB 数量也少。这就是多重实例的写法,工程实践中非常推荐。
Static 里还可以放数组和结构体。比如做一台设备的运行统计,用一个长度为 30 的 INT 数组记录最近 30 天的产量,这个数组声明在 Static 区,每次调用 FB 往里写就行。放到 Temp 里就完全不行,因为 Temp 出了块就没了。
2.4 Temp:生命周期只有一个扫描周期的临时工
Temp 变量分配在块的本地数据区,随块调用而生,随块返回而灭。它最大的特点也是最大的坑:系统不会自动给它初始化。
这一点必须反复强调。Temp 变量的初值是"上一次占用这块内存的那个块留下来的垃圾数据",可能是某个 REAL 的小数部分,可能是某个状态机的步序,也可能是看起来很像那么回事的一个整数。你在 FC 里声明一个 Temp 的 INT,写完逻辑忘了初始化,直接拿来做判断,程序十有八九会在某个特定的时刻莫名其妙地跳闸一次,然后你再复现又复现不出来。所以规则只有一条:Temp 变量必须先赋值、后使用,没有例外。
Temp 的第二个特点是不适合装大东西。本地数据区的容量有限,可以在 CPU 属性里看到上限值,超了就会报"本地数据栈溢出"之类的错误。有些人不注意,在一个 FC 里声明一个长度 1000 的 REAL 数组做临时缓冲,一下就是 4000 字节的本地数据,稍微复杂一点的调用链就爆了。这种大数据缓冲,要么用 IN_OUT 参数把外部存储传进来,要么干脆上 DB。
Temp 也有它的正确用法。最典型的两个场景:一是纯粹的过程计算中间量,进来就用、算完就扔,不跨语句保留;二是配合循环使用的循环变量(FOR 里的 i),这类变量的生命周期本来就在块内。还有一类是"临时拷贝",比如你先从一个 Static 结构体里把某个字段复制到 Temp 做运算,避免频繁访问背景 DB,这也算合理优化,但前提是复制完立刻就用。
最后提醒一句关于边沿检测的经典错误:有人在 FC 里用 Temp 变量实现上升沿检测,思路是"把上一拍的输入状态存起来,跟这一拍比较"。用 Temp 存这个"上一拍状态"是完全无效的,因为上一拍的状态早就随着上次调用消失了,你比较的永远是随机值和当前值。边沿检测的上一拍状态必须存在 FB 的 Static 里、或者存在 M 点、或者用系统提供的 R_TRIG/F_TRIG 功能块实例。
2.5 Constant:编译期就被替换掉的"只读替身"
Constant 是在块的接口区定义的常量。定义的时候要写名称、数据类型和值,比如名叫MAX_START_COUNT、类型 INT、值 100000。
Constant 和普通变量的本质区别在于:它不占存储。编译的时候,代码里所有用到这个常量的地方,会被直接替换成那个字面值,运行时不存在"去某个地址读常量"这个动作。所以在 FB 里大量使用 Constant,背景 DB 不会因为多了几个常量就变大,运行速度也没有任何损失。
Constant 的使用场景大多是那些"写死了逻辑里到处要用的数字或字符串"。比如一个状态机的状态码,与其在代码里到处写 10、20、30,不如定义成STATE_IDLE := 10、STATE_RUNNING := 20,代码一眼就能读懂。又比如一个字符串提示信息,定义成 Constant 之后,在代码里引用名字,以后想改文案只需要改接口区那一个地方。
Constant 有一个限制必须知道:它是只读的,不能出现在赋值号左边。你写MAX_START_COUNT := 200;编译器会直接报错。想改它的值,得回到接口区改,然后重新编译下载。
同一个项目里其实有两套常量体系,容易混淆。一套是刚才说的"块接口区的 Constant",只在当前这个块内部有效,出了这个块,别的块看不到它,也不能引用。另一套是"PLC 变量表里的常量",它是全局的,任何块都能直接引用,适合做那种全项目统一的参数,比如设备的额定电压、通信超时时间。两套体系配合着用,块内专用的放接口区,跨块共享的放变量表。
2.6 Return:FC 的返回值,别和 RETURN 指令搞混
Return 这个参数只在 FC 里存在,FB 是没有的。它的作用很直接:给 FC 定义一个返回值,调用方可以直接把 FC 当成一个表达式来用。
这个设计的价值在于写复合算式的时候特别舒服。比如模拟量标定这个典型场景,标定公式是工程值 = (原始值 - 原始下限) × (工程上限 - 工程下限) / (原始上限 - 原始下限) + 工程下限。如果做成一个带 Output 参数的 FC,每次调用都要先定义一个接收变量,再写调用语句,两行;而做成有 Return 值的 FC,直接一行写进表达式就完事了,甚至可以把好几个 FC 串联起来做流水线式的计算。
在 SCL 里给返回值赋值的写法是给块名本身赋值。假设你要写一个 FC 叫"Scale_Analog",代码里最后一句就是"Scale_Analog" := 计算结果;。第一次写会觉得有点别扭,习惯就好了。如果你在 SCL 编辑器里拿不准块名的正确写法,可以把光标放到语句行,输入块名几个字母之后按 Tab,编辑器会自动补全。
这里必须把两个容易混的东西分清楚。接口区里的 Return 是一个"参数",它定义了这个 FC 的返回类型和返回值。而 SCL 代码里的RETURN;是一条"语句",作用是提前结束当前块的执行,跟返回值完全没有关系。我见过有人在 FC 里写完RETURN;以为把结果返回了,结果调用方拿到的一直是 0。这两件事在中文里都叫"返回",但在 TIA 里是两码事。
Return 的数据类型建议用基本数据类型,REAL、INT、BOOL、DINT 这些都没问题。理论上也可以选 STRUCT 或者 UDT,但我个人不建议——返回一个复杂结构会带来额外的数据复制,而且调用方拿到之后还要拆包,可读性反而变差。需要返回多个值的时候,老老实实用 Output 参数更清楚。
还有一个小细节值得一提:在 LAD/FBD 里调用带返回值的 FC,返回值会显示为块框上方的一个引脚。这个引脚不连接是允许的,返回值直接被丢弃,编译不会报错。这跟 Output 参数在 LAD 里必须连接的要求不一样,很多人在两者之间切换的时候会犯迷糊。
3. 参数到底怎么传:值传递、引用传递与内存去向
3.1 三种传递方式的对照
理解了每种参数的定位之后,还得知道它们底层到底是怎么"搬数据"的,这直接影响程序的性能和稳定性。下面这张表是我自己的经验总结,把七种参数的关键属性拉通对比了一遍。
| 参数类型 | 所在块 | 存储位置 | 传递方式 | 跨周期保持 | 是否占背景DB |
|---|---|---|---|---|---|
| Input | FB / FC | FB 在实例 DB,FC 在 L 堆栈 | 基本类型值传递,复杂类型可能复制 | FB 保持,FC 不保持 | FB 占,FC 不占 |
| Output | FB / FC | FB 在实例 DB,FC 在 L 堆栈 | 值传递 | FB 保持,FC 不保持 | FB 占,FC 不占 |
| Inout | FB / FC | FB 在实例 DB,FC 为引用 | 引用传递为主 | FB 保持,FC 不保持 | FB 占,FC 不占 |
| Static | 仅 FB | 实例 DB | 内部直接访问 | 保持(掉电保持需单独配置) | 占 |
| Temp | FB / FC | L 堆栈 | 不适用 | 不保持 | 不占 |
| Constant | FB / FC | 编译期替换,无存储 | 不适用 | 不适用 | 不占 |
| Return | 仅 FC | L 堆栈 | 值传递 | 不保持 | 不占 |
从表里能看出几个关键结论。第一,FB 的背景 DB 之所以容易变大,是因为 Input、Output、Inout、Static 四类全都在里面,只有 Temp 和 Constant 不占。第二,FC 里唯一能省下本地数据区开销的手段就是用 IN_OUT 传大数组,因为它是引用。第三,掉电保持这件事跟参数类型没有直接关系,跟 CPU 的保持性配置有关系。
3.2 优化访问和标准访问,带来的差异比想象的大
TIA 里新建 DB 和 FB 的时候,属性里有一个"优化的块访问"选项,默认是勾上的。这个选项对参数的实际行为有影响,值得单独说一下。
在启用优化的块访问的情况下,FB 实例 DB 里的变量不再按固定的字节偏移排列,而是由编译器自己决定怎么放,可能为了对齐插入空隙,也可能把多个小变量紧凑地排在一起。这么做的好处是访问速度快、存储利用率高,而且在后面给变量加新字段的时候,不会打乱已有变量的地址,修改接口不需要重新下载所有相关的块。缺点是你没法再对这个 DB 做绝对地址寻址,比如DB1.DBD0这种写法就不行了,必须用符号名。对绝大多数应用来说这个"缺点"根本不构成问题,因为我们本来就应该用符号名编程。
如果把优化访问取消,DB 就变成了传统 S7-300/400 那种标准布局,每个变量都有固定的绝对地址,同时复杂类型的 IN_OUT 参数会以经典指针的形式传递。这种模式主要在两种场合需要:一是和老程序或者第三方设备做地址级对接,二是某些库需要绝对寻址。新项目我建议始终保持优化访问勾选状态,除非有明确的理由。
还有一个和优化访问有关的细节:在优化的 FB 里,IN_OUT 参数即使对基本数据类型,编译器内部也是按引用的思路处理的,所以你在块内对它赋值,调用方立刻就能看到变化,不存在"值只改在本地"的问题。这一点和 Input 的赋值行为形成鲜明对比,也是很多人混淆的地方。
3.3 各参数在内存里的实际归宿
把视角拉高一点看,一个 FB 实例在内存里大致长这样:背景 DB 的最前面放着一组系统信息(块头,包含块号、时间戳一类的元数据),往后依次是 Input、Output、Inout、Static 这几块区域。每一部分的大小取决于你在接口区声明了多少东西、分别是什么数据类型。
一个 BOOL 在优化的块访问下可能只占一个位,在标准访问下对齐后会占一个字节甚至更多。一个 REAL 固定占 4 字节。一个 STRING 占的字节数跟最大长度有关,比如 STRING[20] 实际占 24 字节(2 字节头部加 22 字节内容空间)。一个 TON_TIME 定时器实例大概占 16 字节左右。这些数字在做背景 DB 容量估算的时候很有用。
举个实际例子:一个控制电机的 FB,接口区里放了 12 个 BOOL 输入、6 个 BOOL 输出、4 个 REAL 参数、2 个 IN_OUT 的 REAL、3 个 TON_TIMER 实例、1 个整数状态机、1 个累计运行时间 REAL。粗算一下,BOOL 加起来 18 个位、大约 3 字节,REAL 部分 4 加 2 加 1 共 7 个、28 字节,定时器 3 个 48 字节,加上状态机 2 字节,加上块头 40 字节左右,一个实例大概 120 到 150 字节。一个项目里有 200 台电机,就是 30KB 左右的背景 DB,完全可以接受。但如果把每个 BOOL 都声明成 INT,或者给每台设备都塞一个长度 100 的数组,那数字就会变得很难看。
提示:想知道某个 FB 实例到底占了多少存储,不用自己算。在项目树里选中这个 FB 右键"分配资源"或者看编译后的交叉引用,也可以把实例 DB 打开,在"信息"里能看到字节数。做设备量大的项目之前,先估算一遍,避免下载的时候才发现存储不够。
4. 实操:从零搭一个电机控制 FB 加一个标定 FC
光说理论没意思,下面用一个完整的小例子把七种参数都用一遍,你可以直接照着敲。
4.1 需求拆解与接口设计
目标:做一个电机控制 FB,实现启动、停止、故障处理、状态反馈、运行时间累计;再做一个模拟量标定 FC,把 0 到 27648 的原始值换算成 0 到 100 的工程值。
先设计 FB 的接口。我把每一类参数都刻意用上,方便你对照。
Input 区:i_Start(BOOL,启动)、i_Stop(BOOL,停止)、i_Fault(BOOL,故障信号)、i_RunFeedback(BOOL,运行反馈)、i_RatedCurrent(REAL,额定电流)。
Output 区:o_Run(BOOL,运行输出)、o_FaultLatch(BOOL,故障锁存)、o_StatusWord(WORD,状态字)。
Inout 区:io_RuntimeAcc(REAL,累计运行小时数)。这个参数之所以用 IN_OUT 而不是 Static,是因为我想让它的值存在 HMI 的 DB 里,方便画面直接显示和历史归档,FB 只负责改它。
Static 区:stat_RTrigStart(R_TRIG,启动沿)、stat_RTrigStop(R_TRIG,停止沿)、stat_TON_Feedback(TON_TIME,反馈超时)、stat_Step(INT,状态机步序)、stat_StartCount(DINT,启动次数)。
Temp 区:tmp_RuntimeDelta(REAL,本次扫描的运行时间增量)。
Constant 区:C_FEEDBACK_TIMEOUT(TIME,反馈超时时间,值 T#3S)、C_MAX_START_COUNT(DINT,最大启动次数,值 100000)。
你会看到这里有一个刻意的设计:状态量和边沿检测实例放 Static,跨设备共享的累计值走 IN_OUT,一次性的计算量放 Temp,固定参数放 Constant。这个划分方式我在很多项目里用下来比较顺手,你也可以按这个思路套自己的设备。
4.2 SCL 代码实现
下面是我在 TIA 里用 SCL 写的 FB 主体,注释我写得比较细,方便你逐行对照。
// FB: Motor_Ctrl —— 电机控制功能块 // 边沿检测:启动沿与停止沿必须用 Static 里的实例 #stat_RTrigStart(CLK := #i_Start); #stat_RTrigStop(CLK := #i_Stop); // 启动次数上限保护 IF #stat_StartCount >= #C_MAX_START_COUNT THEN #o_FaultLatch := TRUE; END_IF; // 状态机:0=停止, 10=启动中, 20=运行, 30=故障 CASE #stat_Step OF 0: #o_Run := FALSE; IF #stat_RTrigStart.Q AND NOT #i_Fault THEN #stat_StartCount := #stat_StartCount + 1; #stat_TON_Feedback(IN := TRUE, PT := #C_FEEDBACK_TIMEOUT); #stat_Step := 10; END_IF; 10: // 等待运行反馈,超时报故障 IF #i_RunFeedback THEN #stat_TON_Feedback(IN := FALSE); #o_Run := TRUE; #stat_Step := 20; ELSIF #stat_TON_Feedback.Q THEN #o_Run := FALSE; #o_FaultLatch := TRUE; #stat_Step := 30; END_IF; 20: #o_Run := TRUE; IF #stat_RTrigStop.Q OR #i_Fault THEN #o_Run := FALSE; #stat_Step := 0; END_IF; 30: // 故障状态,等外部复位 #o_Run := FALSE; IF NOT #i_Fault THEN #o_FaultLatch := FALSE; #stat_Step := 0; END_IF; END_CASE; // 运行时间累计:假设本 FB 在 100ms 的循环 OB 中调用 IF #o_Run THEN #tmp_RuntimeDelta := 0.1 / 3600.0; #io_RuntimeAcc := #io_RuntimeAcc + #tmp_RuntimeDelta; END_IF; // 状态字按位打包,方便 HMI 一次读取 #o_StatusWord := 0; IF #o_Run THEN #o_StatusWord := #o_StatusWord OR 16#0001; END_IF; IF #o_FaultLatch THEN #o_StatusWord := #o_StatusWord OR 16#0002; END_IF; #o_StatusWord := #o_StatusWord OR SHL(INT_TO_WORD(#stat_Step), 8);这段代码里有几个地方值得单独说。stat_Step用 0、10、20、30 这种间隔编号,是为了以后想在中间插状态的时候不用重排。tmp_RuntimeDelta只在IF #o_Run成立的那条分支里被赋值和使用,一出块就作废,这是 Temp 的典型用法。io_RuntimeAcc每扫描周期加一点点,累加结果直接写在调用方传进来的存储位置上,这就是 IN_OUT 引用的效果。
再看标定 FC。这个 FC 用上了 Input、Temp、Return、Constant 四类参数。
// FC: Scale_Analog —— 模拟量标定,返回工程量 // 输入:原始值、原始上下限、工程上下限 // 返回:工程量 REAL // 用 Temp 做中间计算,避免整数运算精度丢失 #tmpRawReal := INT_TO_REAL(#i_Raw); // 防止除零:上限等于下限时直接返回工程下限 IF #i_RawMax = #i_RawMin THEN "Scale_Analog" := #i_EngMin; RETURN; // 注意:这是提前结束块的语句 END_IF; // 标定公式,结果直接赋给块名(返回值) "Scale_Analog" := (#tmpRawReal - #i_RawMin) * (#i_EngMax - #i_EngMin) / (#i_RawMax - #i_RawMin) + #i_EngMin;注意这里的RETURN;是提前结束块的语句,而"Scale_Analog" :=才是给返回值赋值,两个东西不要搞混。这是我在前面强调过的那一点,写代码的时候看清楚。
4.3 调用、监控与验证
先看 FB 在 SCL 中的调用。注意输入用:=,输出用=>,IN_OUT 用:=(因为它既要进去也要回来)。
// 在 OB1 或某个 FC 中调用电机 FB "Motor_Ctrl_DB_01"(i_Start := "I_Start", i_Stop := "I_Stop", i_Fault := "I_Fault", i_RunFeedback := "I_RunFb", i_RatedCurrent := 5.6, o_Run => "Q_Run", o_FaultLatch => "Q_FaultLatch", o_StatusWord => "MW100", io_RuntimeAcc := "DB_HMI".Motor01_Runtime);这里io_RuntimeAcc接的是 HMI 数据块里的一个 REAL,HMI 画面可以直接显示它,也可以在归档趋势里记录,FB 每次调用都会往里累加,两边看到的是同一块内存。
再看 FC 的调用,在 SCL 里可以直接写进表达式,用起来很舒服:
// 把 IW64 的原始值标定成 0 到 100 的百分比 "Valve_OpenPct" := "Scale_Analog"(i_Raw := "IW64", i_RawMin := 0, i_RawMax := 27648, i_EngMin := 0.0, i_EngMax := 100.0);上线验证的时候,我一般按这个顺序走一遍。第一步,把 FB 的实例 DB 打开,在线监控stat_Step,手动在监控表里给i_Start置 1,看步序是不是从 0 跳到 10。第二步,不给定反馈信号,等 3 秒,看是不是跳到了故障状态、o_FaultLatch是不是置位,这一条验证的是 Static 里的定时器实例和 Constant 里的超时时间有没有生效。第三步,把反馈信号置位,看运行时间是不是在往上走,这一条验证的是 IN_OUT 有没有真的写回 HMI 的 DB。第四步,把 FC 的实参分别改成边界值,比如原始值等于下限、上限等于下限,看返回结果是不是符合预期,这一条验证的是 Temp 初始化和除零保护。
5. 常见问题与排查技巧实录
5.1 常见问题速查表
下面这张表是我这些年被问得最多的一些现象,以及对应的排查方向。遇到问题先照着现象查,比盲目翻程序快得多。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| FC 输出时有时无,特定情况下是莫名其妙的数 | FC 的 Output 在某条分支没赋值 | 打开 FC 逐条分支看是否有路径跳过了赋值 |
| FB 输出保持上次的值,逻辑上应该清零 | FB 的 Output 存在实例 DB 里,未赋值即保留 | 在不需要保持的输出上,开头先赋默认值 |
| 程序运行一段时间后行为异常,重启又正常 | Temp 变量未初始化,读到了垃圾数据 | 搜 Temp 区所有变量,确认使用前都赋过值 |
| 边沿检测偶尔漏掉一次触发 | 用了 Temp 或 FC 存上一拍状态 | 改用 FB 的 Static 或 R_TRIG/F_TRIG 实例 |
| 背景 DB 特别大,下载慢 | Input、Output、Inout、Static 全在 DB 里 | 合并冗余参数,考虑用 UDT 和数组 |
| 掉电后参数全丢 | Static 不等于掉电保持 | 在 CPU 和 DB 层面配置保持性 |
| 编译报"参数未赋值" | LAD 中 FC 的输出引脚未连接 | 连接实参,或改用 FB |
| FB 内部改了 Input,调用方看不到 | Input 是值传递 | 改用 Output 或 Inout |
| 大数组传递后程序变卡 | 复杂类型 Input 触发数据复制 | 改用 Inout 传引用 |
| 多个 FB 实例互相干扰 | 共用了同一个背景 DB 或全局变量 | 检查实例 DB 是否各自独立 |
| 常量改了但程序行为没变 | Constant 在编译期替换,改完必须重新编译下载 | 修改后重新编译整个项目 |
| 递归调用时参数错乱 | FB 的接口参数存在共享的实例 DB 中 | 尽量避免递归,或改用 FC 加参数传递 |
5.2 几个只有踩过才知道的细节
第一个细节跟在线监控有关。Temp 变量是可以监控的,但你在监控表里看到的永远是"这一瞬间"的值,因为下个扫描周期它就被别的块覆盖了。有人在监控表里盯着一个 Temp 看,发现它一直在乱跳,以为程序出问题了,其实是正常的。想稳定观察一个中间量,把它挪到 Static 或者临时挂到 M 区上。
第二个细节跟"未连接的输入引脚"有关。在 LAD 里调用 FB,如果某个输入引脚你留空不接,它用的就是实例 DB 里的当前值。这个特性适合做在线可调参数,但也埋了一个隐患:有人本来想接一个信号,画图的时候漏了,编译不报错,运行起来用的是上次调试时手改的值,结果现场表现跟程序对不上。所以组态完之后,我习惯把 FB 调用框挨个看一遍,确认所有该接的引脚都接了。
第三个细节是关于 IN_OUT 参数的实参类型。在 FC 里把一个 INT 数组传给 IN_OUT 参数,实参的类型必须跟形参完全一致,包括数组长度和元素类型。数组长度差一个元素,编译直接报错,不会给你兼容处理。这一点比某些高级语言要严格得多,好处是不会出现"传进去 10 个元素、取出来 20 个"这种错位问题。
第四个细节是 FC 里 Temp 变量的实际分配时机。有人以为只有调用时才分配 L 堆栈,其实在块调用链上,每一层嵌套的块都会占用自己的一份本地数据,嵌套越深,L 堆栈占用越多。在一个 FC 里再调用五层 FC,每层都用 Temp 存了个大数组,即使每个都不大,加起来也可能超限。做复杂算法的朋友尤其要注意控制嵌套层数。
第五个细节跟 Constant 有关。常量不能用于数组下标作为变量那种场景,因为它不是一个存储位置。但常量可以用于数组声明时指定长度,比如ARRAY[0..C_MAX_DEVICE] OF INT,这个用法在做设备数量固定的项目时很好用,改一个常量就能改整个数组的规模。
注意:上面这些坑里面,Temp 未初始化是最致命、最难查的。我的习惯是每次写完一个块,专门花两分钟把 Temp 区从头到尾看一遍,逐个确认"这个变量在哪一行被赋值、赋值之前有没有被读过"。这两分钟能省下的现场调试时间,可能是两天。
6. 大型项目里的接口约定
6.1 命名和分区规范,比写代码本身更重要
单机项目里,接口参数怎么命名都无所谓,反正只有你自己看。但几十上百个 FB 的项目里,命名规范能直接决定后期维护的痛苦程度。我目前比较习惯的一套前缀规则是这样的:输入用i_,输出用o_,输入输出用io_,静态用stat_,临时用tmp_,常量用C_。这样一眼看过去就知道一个变量属于哪一类,在 SCL 里写代码的时候也不容易搞混。
除了前缀,还有一个更重要的约定是"接口区不要过于臃肿"。我见过一个控制阀门的 FB,输入区里塞了 40 多个 BOOL,全是各种使能、屏蔽、模式选择。这种块调用的时候,每次都要接一大堆引脚,容易漏、容易接错。更好的做法是把相关的参数打包成结构体。比如把"超时时间、最大开度、最小开度、动作次数上限"这几个参数打包成一个 UDT 叫Type_ValveParam,接口区声明一个这个类型的 Input 就够了。调用方传一个结构体变量进来,参数改起来也集中。
同样地,输出区如果状态位很多,可以打包成一个 WORD 或者一个 UDT 状态结构体。前面例子里的o_StatusWord就是这个思路:底层 FB 把 16 个状态位按位塞进一个字,上位 HMI 只需要读一个字,然后用位选择功能显示不同指示灯,通信负担小,程序也清爽。
6.2 什么时候该升级成 UDT、Array 和 Variant
随着项目规模变大,会遇到一些单靠基础数据类型解决不了的需求,这时候要考虑升级接口的设计手法。
第一种情况是同类设备成批出现。一台设备一个 FB 实例是可以的,但 50 台一样的设备,如果每台都用独立的一组输入输出变量,交叉引用会乱成一团。更好的做法是用数组加循环调用:定义一个ARRAY[1..50] OF Type_MotorParam的参数结构,然后用 FOR 循环遍历调用同一个 FB。这样一是代码量少,二是增删设备只需要改数组范围。多实例调用的时候,每个设备对应一个实例,实例也可以放在一个数组里。
第二种情况是块需要处理"不确定类型的数据"。比如写一个通用的通信打包 FC,收到的数据可能是 INT、REAL、STRING 中的任意一种,长度也不一样。这时候就可以用 Variant 类型的参数,它能接收任意类型的实参,块内部用TypeOf、TypeOfElements这类系统函数判断类型再分支处理。Variant 很灵活,但也很难调试,因为它把类型检查从编译期挪到了运行期,用错了要到运行时才报错。我的建议是能用具体类型解决的就别上 Variant,只有在写通用库的时候才考虑。
第三种情况是需要在多个 FB 之间共享一个复杂状态。比如一个工艺段的状态要同时被顺控 FB、报警 FB、HMI 通信 FB 访问,这时候不要每个 FB 都通过接口传一份,而是定义一个全局 DB,把状态结构体放在里面,每个 FB 直接访问这个 DB。这么做打破了"块之间通过接口通信"的纯粹性,但在实际项目里非常常见,也比较高效。关键是要给这个全局 DB 定好访问规则:谁写、谁读,写的地方要尽量少,免得出现多个块同时改一个状态的问题。
最后回到开头那个话题。七种接口参数看着多,其实核心逻辑就三条:需要跨周期记住的东西放 Static;一次性的计算放 Temp;需要影响调用方的量走 Output 或 Inout。把这三条记牢,剩下的都是细节。我在带新人的时候,一般会让他们先把一个电机控制 FB 反复写三遍——第一遍随便写,第二遍按接口规范重写,第三遍改成用 UDT 和数组管理多台设备。三遍写完,接口区那一排格子基本上就刻在脑子里了,后面再看到别人写的块,一眼就能看出哪里的参数用错了类型。