上周在一条灌装线上追一个“幽灵停机”:设备正常运行中偶尔自己停,复位之后又能安稳跑几个小时,监控表里翻遍了也没看到任何报警被置位。最后顺着交叉引用一层层剥下去,问题出在一个操作工认为“只是个临时量”的 Temp 变量上——他写的自锁逻辑被放在了一个 FC 里,而 FC 根本没有 Static 区,编译通过、下载成功、跑起来也像那么回事,直到某几个扫描周期条件凑巧对上,锁存就丢了。
这类问题在 TIA 博图(TIA Portal)的现场太常见了,根子往往就一句话:FB 和 FC 接口区里那七种变量——Input、Output、Inout、Static、Temp、Constant、Return——各自存在哪儿、活多久、谁能写谁只能读,很多人是靠“编译器没报错”来判断的。这篇东西我按自己做 S7-1200/1500 项目的习惯,把这七种变量从“声明怎么写”到“底层存哪儿”再到“什么时候会坑你”完整捋一遍,中间会带上一个能直接抄的电机控制 FB 实例,也会把我踩过的几个坑摊开讲。不管你是刚接触博图、连 FC 和 FB 都还分不太清的新手,还是写过几年程序但一直靠直觉选变量类型的老手,看完应该都能对得上号。
1. FB 和 FC 到底差在哪儿,为什么要先搞清楚这个
选变量类型这件事,绕不开一个前置问题:你写的这个块,是 FC 还是 FB。很多人觉得这只是“块类型”的选择,跟变量有什么关系?关系大了。因为接口区能出现哪几种变量,本身就是由块类型决定的。FC 里压根就没有 Static 这一栏,你找破头也找不到;FB 里则天然多出一整套状态存储能力。所以先把这两者的底层差异说清楚,后面七种变量才有落脚点。
1.1 FC:没有记忆的纯执行单元
FC 是 Function 的缩写,中文一般叫“函数”。它最大的特点是没有自己的存储区,也没有背景数据块。你把一个 FC 拖到程序里调用,它就像一个纯数学公式:给它输入,它给你输出,算完就完事,下一次调用又是干干净净的一次。上一次执行过程中产生的任何中间结果,都不会被带到下一次。
那 FC 里的数据存在哪?它靠的是调用时在L 堆栈(本地数据堆栈)上临时分配的一小片空间,Input、Output、Inout、Temp、Return 这些接口变量都在这里“临时落脚”。块执行结束、返回上一层,这片空间立刻回收,里头的值就不再有任何意义了。正因如此,FC 里做不了自锁、做不了状态机、做不了跨周期计时,这不是博图不支持,而是 FC 这个结构本身就没有地方放“上次的状态”。
FC 最适合放什么?单位换算、量程缩放、数学运算、字符串处理、纯逻辑判断、参数合法性校验这类给定输入就能算出输出、且不依赖任何历史信息的算法。这类逻辑写成 FC,干净、轻量、可复用,还没有背景数据块的开销。我一般会把公式类的运算统统放进 FC,比如把 0~27648 的原始值换算成工程量、把秒数格式化成时分秒字符串、把一串故障字按优先级挑出最高级的那一个。这些活儿用 FC 做,一个块能服务整个项目。
1.2 FB:自带仓库的有状态对象
FB 是 Function Block,中文叫“功能块”。它和 FC 最本质的区别是:每次调用都需要配一个背景数据块(Instance DB)。这个背景 DB 就是 FB 的私人仓库,Static 变量、Input 参数的快照、内部调用的其它 FB 实例,全都存在这里面。换句话说,FB 是有记忆的,只要背景 DB 还在,它上次算到哪儿、锁存了什么、计时器走到几点,下次调用时原封不动地还给你。
这里有个特别值得记住的比喻:FB 的代码只有一份,数据有 N 份。你写一个“电机控制”的 FB,程序代码在 CPU 里只存了一遍;但你用它控制 20 台电机,就会有 20 个背景 DB,每个 DB 里各有一套独立的 Static 变量和参数快照,互不干扰。这也是为什么 FB 的存储开销比 FC 大——每个实例都要占一块 DB 空间,电机多了、实例多了,背景 DB 的总体积是实打实要算进 CPU 装载内存和工作内存的。
正因为有这层“代码共享、数据独立”的机制,FB 才适合承载带状态、需要复用、成批出现的设备逻辑:电机、阀门、PID 回路、报警处理、通信握手、配方管理。同一个 FB 拖 20 次,改一处逻辑 20 个实例全部生效,这是 FC 给不了的效率。
1.3 接口区差异速览表
第一次打开块接口的时候,最直观的困惑就是:为什么我新建的 FC 里没有 Static?为什么 FB 多了一栏?先把这张表记在脑子里,后面拆变量的时候就不会迷路。
| 变量类型 | FC 中是否存在 | FB 中是否存在 | 主要存储位置 |
|---|---|---|---|
| Input | 是 | 是 | FC:L 堆栈;FB:背景 DB(基本类型) |
| Output | 是 | 是 | FC:L 堆栈;FB:背景 DB(基本类型) |
| Inout | 是 | 是 | 以引用/指针方式指向实参 |
| Static | 否 | 是 | 背景 DB |
| Temp | 是 | 是 | L 堆栈 |
| Constant | 是 | 是 | 编译期确定,不占运行时存储 |
| Return | 是(必须指定类型) | 是(默认 Void,可改) | 随调用临时传递 |
这张表里,Static 一栏那个“否”字,是新手最容易卡住的地方。如果你想在 FC 里保存一个跨周期的状态,唯一的路子就是把这个状态通过 Inout 或 Output 递回给调用方,让调用方(一般是某个 FB 的 Static,或者某个全局 DB)替你保管。理解这一点,后面选型就不容易走偏了。
2. 七种接口变量逐个拆解:含义、存储位置和生命周期
知道了 FC 和 FB 的差别,接下来把这七种变量一种一种掰开。我拆的时候不会只讲“它是什么”,重点会放在“它存在哪、活多久、能不能写、什么时候会出问题”这四件事上,因为现场百分之九十的怪现象,都能从这四件事里找到答案。
2.1 Input 和 Output:方向清楚,传递方式却藏了坑
Input 是外部喂进来的数据,块内部只能读。这一点在基本数据类型上非常严格:你在 FB 里给一个 Bool 类型的 Input 参数赋值,博图的编译器会直接给你标红报错,因为它被设计成只读。Output 反过来,是块算完往外递的结果,块内部可读可写,块执行结束时会把最终值传给调用方的实参。
对基本数据类型(Bool、Int、Real、Time 等),FB 的参数传递是“值传递 + 快照”的机制:调用 FB 的那一刻,实参的值被复制一份存进背景 DB 对应的位置,块内部看到的就是这份快照。这一点在多任务或者中断场景里很有意义——如果 OB1 正在调用一个 FB,中途来了个循环中断改了实参,FB 内部看到的仍然是调用开始时的那个值,不会算到一半发现输入变了。Output 同理,块内部怎么改都不会立刻影响实参,只有等块执行结束才统一写回去。
但是复杂数据类型(ARRAY、STRUCT、STRING 等)就完全是另一回事了。这类参数在博图里是以引用方式传递的,也就是块内部拿到的其实是一个指向实参的指针,而不是一份拷贝。这就带来了一个非常隐蔽的现象:即使你把它声明成 Input,在块内部修改它,改动照样会落到调用方的实参上。这一点官方的块接口文档里其实有提醒,但埋在细节里,很多人是踩了坑才回头去看的。我在做数组排序、字符串拼接这类功能时,如果希望原数组不被改动,一定会在块内部先复制到 Temp 或 Static 再处理,绝不当场改。
提示:判断一个参数到底是不是引用传递,最省事的办法是在块内部试着改一下,然后在线看调用方的实参有没有跟着变。实测一次比读十页手册快。
2.2 Inout:读写同体,大数据传参的主力
Inout 是“进出合一”的参数,同一个引脚既把值传进去,又把改完的值传出来。它的传递方式永远是引用,不管数据类型是基本类型还是复杂类型,块内部操作的就是实参本身那块内存,没有“拷贝进去、算完拷贝回来”这两步。这一点决定了 Inout 的两个典型用途。
第一个用途是需要读-改-写的累加型数据,比如运行小时累积、产量计数、滤波器的历史值、滑动平均的缓冲区。这类数据本质上需要一个外部容器来承载,谁提供容器、谁就拥有这份数据的所有权。放在 Inout 里,块只负责“加”,不负责“存”,数据的生命周期由调用方决定。这个设计有个很实在的好处:多个不同的 FB 实例可以共享同一个累积量,只要把同一个实参传进去就行;反过来,如果累积量放在 Static 里,那每个实例各算各的,想汇总还得在外面再累一次。
第二个用途是大数据量的传参,典型的就是数组。举个真实场景:我做过一个 32 通道的温度采集处理 FB,如果把 32 个 Real 的数组声明成 Input,虽然实测在优化访问下也是引用传递、不会真拷贝,但语义上会让人误会“这是只读的”;声明成 Inout 就很清楚——这个数组是会被处理的。同时,Inout 不占背景 DB 空间,因为它在背景 DB 里只存一个指针信息,不存数据本身。对于本身就有几千字节的数组来说,这个差别会影响背景 DB 的整体体积。
用 Inout 有几个必须注意的点。一是它指向的内存必须由调用方先初始化好,如果调用方传进去的是一个还没赋过值的 Temp,那 Inout 指向的就是一堆垃圾数据,块内部怎么算都是错的。二是不要在块内部把 Inout 当成临时变量随便清零,因为它直接改的是实参,清零会影响到调用方的原始数据,很多时候这不是你想要的。三是Inout 不能声明成 Constant 类型,常量是编译期固定值,没法既读又写。
2.3 Static:FB 的私有内存,跨周期保持全靠它
Static 只存在于 FB 中,它存放在背景数据块里,生命周期和背景 DB 一样长——只要 DB 没被删除、没被重新初始化、CPU 没做存储复位,它就一直保持着上一次的值。这是 FB 能“有状态”的根本原因。
现场凡是“需要记住上一周期发生了什么”的东西,统统要放 Static。我列几个使用频率最高的:自锁标志位、故障锁存位、边沿检测的 R_TRIG/F_TRIG 实例、定时器和计数器的 IEC 实例、设备运行累计时间、上一次的有效值(用于变化率判断)、状态机的当前步号、多重背景的实例变量。这些东西一旦放错到 Temp 里,程序可能“大部分时候是对的”,然后在某个特定时序下突然错一次,排查起来极其痛苦。
Static 有两个细节值得单独说。第一是初始值。Static 变量在声明时可以给一个起始值,这个值只在你第一次下载程序、或者执行“将快照恢复为起始值”这类操作时才会生效,运行过程中它不会自动回到初值。所以如果你想在每次设备启动时把某些 Static 清零,必须在程序里显式地写清零逻辑,别指望它会自己复位。
第二是保持性(Retain)设置。在优化访问的块里,Static 变量可以单独勾选“保持性”,勾了之后这些变量会进入 CPU 的保持性存储区,断电再上电值还在。这个功能非常实用,比如设备的累计运行小时数、上次停机的原因代码、机械原点位置,都适合设为保持。但要注意保持性存储区的容量是有限的,在 CPU 属性里能看到并设置大小,塞太多数据进去会报警。我的习惯是只把真正需要断电记忆的量设为保持,其它的保持默认不勾,省得存储区不够用。
2.4 Temp:最危险也最容易被误用的临时变量
Temp 是临时变量,存放在 L 堆栈上,块被调用时分配,块返回时释放。它最要命的特点就是:值不保证,用完必须自己负责。在博图里,块开始时 Temp 变量到底是不是被清零,跟 CPU 型号、固件版本、块是否优化访问都有关系,行为并不统一。所以官方一直推荐的做法只有一条:Temp 变量必须先赋值再使用,不要依赖它的初始状态。
这条规则听起来简单,但现场违反它的人太多了。我见过最典型的三种错法。第一种是把自锁逻辑放在 FC 里,用 Temp 存锁存位,结果某些扫描周期锁存自己就掉了;第二种是把边沿检测的 R_TRIG 实例声明成 Temp,导致边沿检测时灵时不灵,有时候一个信号能触发好几次;第三种是把 TON 定时器实例放 Temp 里,定时器永远走不完,或者刚走完又从头开始。这三种情况的共同点都是被存储的东西需要跨周期保持,却放在了不保持的地方。
那 Temp 到底该放什么?我的判断标准很简单:只在一个扫描周期内活着、用完就丢的中间结果。比如一次计算里的中间变量、判断条件的临时组合、循环里的下标、字符串处理时的临时缓冲、几行逻辑里的辅助标志。这些数据出了这个块就没用了,放 Temp 最省资源,也不会污染背景 DB。
还有一个容易忽略的点:Temp 数组如果不清零就直接参与累加,结果一定是错的。因为上一次调用留下的垃圾值还躺在 L 堆栈里,你的累加会从一个莫名其妙的数开始。我处理 Temp 数组的习惯是,进块第一件事先循环清零,或者干脆不用 Temp 数组改用 Static 数组。
2.5 Constant:编译期就定死下来的值
Constant 是常量,在接口区的 Constants 表里声明。它的特点是:编译期就被替换成具体的值,不占用任何运行时存储空间,快得没有开销,而且块内部绝对不能修改。它的作用更像是一个有名字的字面量,本质上是给代码里的“魔法数字”起了个能看懂的名字。
常量分两类。一类是用户常量,你自己在接口区定义的,比如把数组的长度定义成c_MaxAxisCount := 8,后面所有循环的上界都写这个常量。这样做的好处是,将来通道数从 8 改到 16,只要改一处常量声明,所有循环自动跟着变,不用去代码里一个一个数字地找。另一类是系统常量,由 CPU 自动产生,比如硬件组件的标识符、中断事件编号、报警类别编号。在使用一些需要传入硬件标识符的指令(比如读取模块的诊断信息)时,可以直接从系统常量里选,比手输数字靠谱得多,也不会因为硬件地址调整而失效。
用常量有两个经验。一是常量的作用域只在本块内,A 块里定义的常量,B 块是看不到的,如果整个项目都要用同一个常量,得在每个块里各定义一份,或者放到一个专门的“常量块”里靠编译指令共享——实际项目里我更倾向于局部定义,隔离性好。二是常量的数据类型要写清楚,尤其是 Time 类型的常量,要写成T#2s这种形式,别直接写个2,否则参与时间运算时类型对不上,编译器会给一堆警告。
2.6 Return:一次性交出去的返回值
Return 是块的返回值。FC 必须指定返回类型,默认是 Void,可以改成 Bool、Int、Real 等具体类型;FB 的 Return 默认也是 Void,同样可以改。在 LAD/FBD 里,当 FC 的返回类型是 Bool 的时候,这个返回值就映射成了块右上角的 ENO 管脚,后面可以串下一个块的 EN,做连锁用,这也是 FC 在梯形图里最常见的用法之一。
Return 最关键的性质是:它是一次性的,不跨周期保持。块执行到返回那一刻,把结果交给调用方,这个值就算完成使命了;下一次调用时它会重新被计算和赋值,上一次的值不会留在任何地方。这一点和 Output 有点像,但 Output 至少会在背景 DB 里占一个位置(FB 中),Return 则更轻量,连位置都不专门保留。
所以在 FB 里,如果你希望某个结果下次调用还能用上,不能只靠 Return,必须显式地写进 Static。我见过有人写了个返回 Bool 的 FB,指望通过 Return 做故障锁存,结果故障一闪就没了,原因就在这。正确做法是 Return 只负责把“本次判断的结果”递出去,锁存逻辑交给 Static 变量来做,两者分工明确。
3. 再往深一层:变量到底存在哪儿,什么时候会被清掉
前面把七种变量讲了一遍,但真正让人纠结的往往是“为什么这里用 Static 就行、用 Temp 就出事”。要回答这个问题,得往存储结构里再钻一层。这一段会稍微硬核一点,但理解了之后,选型基本不会再犹豫。
3.1 背景 DB 里的数据布局
打开一个 FB 生成的背景数据块,你会看到它里面排着好几类内容。最上面是 Input 参数的快照区,每次调用时被写入;接着是 Output 区、Inout 区的指针信息;再往下是 Static 区,这部分是 FB 真正的“私有财产”;最下面是这个 FB 内部调用别的 FB 所产生的多重背景实例。它们按声明顺序依次排布。
在标准访问模式下,每一个变量都能看到具体的数据类型和字节偏移量,是一块老老实实的线性地址空间。在优化访问模式下,地址被隐藏了,博图的编译器会自动调整变量的排布顺序,把相同类型的变量凑在一起,对齐到最优的边界上,这样能提高访问效率,也会顺带把一些零碎空间填满。所以同一个 FB,优化访问和标准访问下的背景 DB 体积是不一样的,优化访问通常会小一些,访问速度也更快。
这个细节的实际意义在于:当你做程序版本升级、需要把背景 DB 的数据迁移到新版本时,标准访问的绝对地址是稳定的,优化访问则要靠符号名来匹配。如果项目里有大量背景 DB 需要做“快照/恢复”操作,用优化访问会更省心,因为它是按符号名匹配的,不会因为加了一个变量就整体错位。
3.2 L 堆栈与临时变量的生命周期
L 堆栈是所有块共用的本地数据区。每次调用一个块,CPU 就从堆栈上切一块下来给它用,里面装 Temp 变量、基本类型的 Input/Output 快照(FC 的情况);块返回时这块空间被释放,指针回到上一层。这里有个很关键的性质:释放不等于清零。空间被还回去了,但里面的旧数据还留在那儿,下一个块切成同一块空间时,看到的就是上一个块留下的内容。
这就是为什么 Temp 变量“看起来有时候是对的、有时候是乱的”。如果你的调用层级和顺序恰好稳定,同一个 Temp 位置可能每次都是同一个块在用,旧值凑巧就是你想要的;一旦程序结构调整、调用顺序变化、或者插入了新块,这个“凑巧”就没了,程序行为突然就变了。这类问题通常表现为“改了一处不相干的地方,另一个地方出问题了”,最考验排查耐心。
L 堆栈还有容量限制。S7-1200 和 S7-1500 给每个优先级都分了一块本地数据区,嵌套层数越深、每个块的 Temp 用得越多,消耗就越大。如果嵌套太深或者 Temp 声明得太多,编译或运行时就会报本地数据相关的错误。我做项目时有个习惯:单个 FB 的 Temp 数量控制在十来个以内,嵌套层级不超过四层,遇到复杂的算法宁可拆成几个小 FC 分开写,也不在一个块里堆 Temp。
3.3 优化访问与标准访问带来的差异
优化访问和标准访问的差别,很多人只知道“优化访问性能好”,其实它对接口变量的行为也有影响。在优化访问下,块的参数按符号传递,编译器有更多优化空间,块之间的数据访问往往更紧凑;在标准访问下,参数按绝对地址传递,你得自己去关注数据块的偏移量,一旦顺序错了就会读到错误的数据。
对我们选变量类型影响最大的一点是:优化访问的块支持保持性设置,标准访问的块不支持。也就是说,如果你想让某个 Static 变量断电后还保留值,这个块必须是优化访问的,而且在变量属性里勾上保持性。这一点在做数据记录、累计量的时候必须提前规划,等程序写完再改访问模式,往往要连带调整一大堆东西。
我的常规做法是:新项目默认全部用优化访问,只有在需要跟老的第三方设备、或者需要按固定地址读写某些数据区的时候,才局部切到标准访问,并且会把原因写在块注释里,免得接手的人一脸茫然。
3.4 保持性设置与断电恢复
保持性只对 Static 变量有效,而且只在优化访问的块里能设。设置方法是在接口区选中变量,在属性里勾“保持性”。勾了之后,这个变量的值会被存到 CPU 的保持性存储区,断电、重启之后恢复。要注意的是,保持性存储区是 CPU 的一块专用区域,容量有限,在 CPU 的属性里可以看到当前用了多少、还剩多少,也可以在下载时选择“全部覆盖”还是“保留”。
这里有个实操上很容易踩的坑:下载程序时,如果选择了“全部覆盖”,保持性存储区会被清空,所有保持值归零。在一些已经投产的现场,工程师为了图快总是选“全部覆盖”,结果把运行小时数、原点位置这些关键数据清掉了,只能重新标定。所以做调试的时候,下载前一定要看清楚下载对话框里的选项,涉及到数据记忆的现场,宁可多花两分钟确认。
4. 实操:用全套变量写一个电机控制 FB
前面都是原理,这一段上干货。我拿一个现场最常见的电机控制 FB 做例子,把七种变量全都用上一遍,你可以照着这个结构套自己的设备。
4.1 需求梳理与接口规划
需求很典型:控制一台三相电机,带启动延时、停止按钮、外部故障输入、复位按钮,需要输出运行状态、就绪状态、报警状态和电流平均值,另外要累计这台电机的运行时间,运行时间要能在多台电机之间汇总。
按这个需求往下推:延时启动需要定时器,定时器实例必须跨周期保持,所以放 Static;按钮的边沿检测需要 R_TRIG,实例同样要放 Static;运行锁存和故障锁存必须跨周期保持,放 Static;电流平均值的中间计算只在一次扫描里用,放 Temp;累计运行时间要能被调用方汇总,所以放 Inout,由调用方提供存储容器;延时的时长、电流的上限比例这类不常改的参数,放 Constant。
4.2 引脚声明清单与选型理由
把上面想清楚,接口表就能定下来了。
| 引脚名 | 类型 | 数据类型 | 选它的理由 |
|---|---|---|---|
| i_xStart | Input | Bool | 启动按钮,外部给,块内只读 |
| i_xStop | Input | Bool | 停止按钮,同上是只读信号 |
| i_xFaultFB | Input | Bool | 外部故障反馈,只读 |
| i_xReset | Input | Bool | 复位按钮,只读 |
| i_tCycleTime | Input | Time | 本周期耗时,由调用方传入 |
| q_xRun | Output | Bool | 输出给外部继电器 |
| q_xReady | Output | Bool | 就绪状态输出 |
| q_xAlarm | Output | Bool | 报警输出 |
| io_tRunTotal | Inout | Time | 运行时间累积,容器由调用方持有 |
| stat_xRunLatch | Static | Bool | 运行自锁,必须跨周期 |
| stat_xFaultLatch | Static | Bool | 故障锁存,必须跨周期 |
| stat_trigStart | Static | R_TRIG | 边沿检测实例,必须跨周期 |
| stat_trigStop | Static | R_TRIG | 边沿检测实例,必须跨周期 |
| stat_tonDelay | Static | TON_TIME | 启动延时定时器实例 |
| temp_xFaultNow | Temp | Bool | 本次扫描的故障判断中间量 |
| temp_rRatio | Temp | Real | 电流比例中间值 |
| c_MaxRatio | Constant | Real | 电流上限比例,1.05 |
| c_MinDelay | Constant | Time | 最短延时时长,T#500ms |
4.3 SCL 实现与关键行解读
核心逻辑用 SCL 写出来大概是这样,我边贴边解释关键行。
// 1. 按钮边沿检测,实例必须来自 Static #stat_trigStart(CLK := #i_xStart); #stat_trigStop(CLK := #i_xStop); // 2. 启动延时:按住启动且没有停止时才计时 #stat_tonDelay(IN := #stat_trigStart.Q AND NOT #i_xStop, PT := #i_tCycleTime + #c_MinDelay); // 3. 运行自锁:延时到、无故障、无停止,才置位 IF #stat_tonDelay.Q AND NOT #i_xFaultFB THEN #stat_xRunLatch := TRUE; END_IF; // 4. 停止或故障,立即复位自锁 IF #stat_trigStop.Q OR #i_xFaultFB THEN #stat_xRunLatch := FALSE; END_IF; // 5. 故障锁存与复位 IF #i_xFaultFB THEN #stat_xFaultLatch := TRUE; END_IF; IF #i_xReset THEN #stat_xFaultLatch := FALSE; END_IF; // 6. 用 Temp 做一次性的中间判断 #temp_xFaultNow := #stat_xFaultLatch OR #i_xFaultFB; #temp_rRatio := 0.0; // 7. 运行时间内累积,容器来自 Inout IF #stat_xRunLatch THEN #io_tRunTotal := #io_tRunTotal + #i_tCycleTime; END_IF; // 8. 输出赋值 #q_xRun := #stat_xRunLatch; #q_xAlarm := #temp_xFaultNow; #q_xReady := NOT #temp_xFaultNow AND NOT #i_xFaultFB;第 1 行的两个 R_TRIG 实例来自 Static,这是整个程序能正常工作的前提。如果把它们挪到 Temp 区,边沿检测会变得不可靠,按钮按一次可能触发几次,也可能一次都不触发。第 7 行是 Inout 的典型用法:FB 只管加,容器io_tRunTotal由调用方在全局 DB 里提供,这样多台电机的运行时间可以方便地汇总到一个总的统计变量里,也可以各存各的。第 6 行的temp_rRatio只在本周期用一次,放 Temp 完全合适,不占背景 DB 一个字节。
4.4 调用、背景 DB 与多重背景
在 OB1 里调用这个 FB,博图会提示你生成一个单独的背景 DB,比如DB_Motor1。控制第二台电机就再生成DB_Motor2,两个 DB 里的 Static 和参数完全独立,互不影响,这就是最基础的单重背景用法。
如果项目里有一台设备包含五台电机,更好的做法是再写一个“设备级”的 FB,在这个 FB 里把电机 FB 声明成Static 变量,数据类型直接选电机 FB 的名字。这种就叫多重背景:电机 FB 的实例数据被嵌在设备 FB 的背景 DB 里,一个设备只对应一个背景 DB,里面装着它所有子 FB 的状态。
多重背景最大的好处是复制方便。设备 FB 写完,在 OB 里生成一个背景 DB 就是一套设备;要做第二套,复制粘贴背景 DB,把里面的参数一改就行,不用挨个去对应子 FB 的背景 DB。缺点也明显:多重背景的实例不能跨 FB 共用,它只属于宿主 FB,别的块想访问得一层层往下引。所以我的经验是,设备内部的子功能用多重背景,跨设备共用的功能(比如报警汇总、通信)用单重背景或者全局 DB。
4.5 在线联调与验证方法
下载之后,验证顺序建议这么走。先打开背景 DB,在线看 Static 区,确认下载后的初值符合预期;然后在监视表里手动改i_xStart为 TRUE,观察stat_trigStart.Q有没有瞬间跳一下,这是验证边沿检测实例是否放在了 Static 的直接方法;接着把启动按钮按住,看延时定时器是不是在走,延时到之后stat_xRunLatch有没有置位;最后拉高i_xFaultFB,确认stat_xFaultLatch锁存、q_xAlarm输出,再用i_xReset复位。
这套流程走一遍,Static、Temp、Input、Output、Inout、Constant、Return 里除了 Return 之外全都验证到了。Return 的验证单独用一个小的 FC 做,比如写一个把 Real 转成字符串的 FC,返回类型设为 String,调用时看返回值对不对就行。
5. 踩坑实录与常见问题速查
前面都是“应该怎么做”,这一段讲“出问题的时候怎么找”。我把自己和同事踩过的坑整理成了一张对照表,再加几个典型案例和排查手法。
5.1 十个高频问题对照表
| 现象 | 最可能的原因 | 处理办法 |
|---|---|---|
| 自锁标志偶尔自己复位 | 锁存位放 Temp 或 FC 里 | 改用 FB 的 Static 变量 |
| 按钮按一次触发多次 | 边沿检测实例放 Temp | 实例移到 Static |
| 定时器走不完或反复重来 | 定时器实例放 Temp | 实例移到 Static |
| 每次上电累计值归零 | Static 没勾保持性 | 勾选保持性后再下载 |
| 下载后所有记忆数据丢失 | 下载时选了全部覆盖 | 改用保留数据的下载方式 |
| 改了一个数组,另一个也跟着变 | 复杂类型参数引用传递 | 块内先复制再用 |
| Temp 数组累加结果乱七八糟 | Temp 没清零就用 | 进块先清零或改用 Static |
| 嵌套深的块报本地数据错误 | L 堆栈不够或嵌套过深 | 拆块、减少 Temp 数量 |
| 常量改了但程序没变化 | 常量属于块内作用域 | 检查改的是不是这个块的常量 |
| FB 的返回值下次读不到了 | Return 不跨周期保持 | 需要保持就写进 Static |
5.2 边沿检测和定时器放错位置
这两个是我见过次数最多的问题,单独拎出来说。边沿检测的本质是“比较这一周期和上一周期的信号”,所以它至少需要两个存储位置:当前值可以放 Temp,但上一次的值必须存放在能跨周期保留的地方。R_TRIG 这个指令实例内部就包含了“上一次的值”,所以实例本身必须放 Static。把它放 Temp,等于每次调用都重新创建一个空的实例,边沿检测自然就失效了,表现为信号偶尔能检测到、偶尔不能,或者一个脉冲被识别成好几次。
定时器的问题类似。TON 内部要保存已经计时的时间、定时器状态,这些都要跨周期保留。放在 Temp 里,每次调用都从头开始计时,结果就是定时器永远走不到设定时间,或者刚一启动就立刻输出。判断方法很简单:在线的背景 DB 里能看到stat_tonDelay的 ET 和 Q 在持续变化,就说明实例位置是对的;如果 ET 永远是零、Q 总是 FALSE,基本就是实例放错了位置。
5.3 复杂数据类型传参引发的“数据串味”
这个问题比前两个隐蔽得多。前面说过,ARRAY、STRUCT、STRING 这类复杂数据类型在块之间是引用传递的。我遇到过一次很典型的:一个 FC 负责把一个结构化数组里的数据做归一化,参数声明成了 Input,本意是“只读”。结果这个 FC 内部在做归一化时,顺手把中间结果写回了数组元素,调用方的原始数据被改掉了,导致后面另一个计算用了被改过的数据,结果全错。排查了半天才定位到这个“只读”参数其实可写。
避免这类问题的方法是:只要参数是 ARRAY、STRUCT、STRING,就在块内第一时间复制到 Temp 或 Static 副本上再处理,绝不当场改。多复制一次会多一点开销,但换来的是可预测的行为,这笔买卖很划算。如果确实需要修改原数组并返回,那就大大方方声明成 Inout,语义清楚,谁看代码都明白。
5.4 我常用的几种排查手段
碰到变量相关的怪问题,我一般按这个顺序动手。第一步,打开出问题的块的背景 DB 在线监控,重点看 Static 区里那些“本该记住”的值有没有被意外清零或者被改写,这一眼往往就能定位。第二步,用交叉引用功能查这个变量到底在哪些地方被写过,很多“自己变了”的变量其实是别的块偷偷写的。第三步,把怀疑的变量从 Temp 临时改成 Static 试跑,如果问题消失,就基本能确认是存储位置的问题——这招虽然糙,但在现场非常管用。
还有一个小技巧:在关键位置用RUNTIME指令测量块的实际执行时间,如果某个块耗时异常,往往是里面 Temp 用量过大、或者循环次数过多导致的。对于嵌套比较深的项目,也可以通过减少 Temp 变量、拆分成小 FC 来降低 L 堆栈压力,这在 S7-1200 这种本地数据相对紧张的 CPU 上尤其要注意。
我个人在实际项目里养成的习惯是,接口表的每一行都写清楚选型理由,就写在块的注释里或者维护文档里。当时觉得多此一举,等到半年后别人接手、或者自己回头改的时候,看到“这里用 Inout 是为了跨实例共享累计值”这一行字,能省下大半天的猜测时间。变量的选择从来不是随便挑一个能编译通过的就行,它背后是数据的所有权、生命周期和访问方式,这三件事想明白了,程序自然就稳。