功能块(FB)写多了,绕不开一个问题:同样一段逻辑,我要复制给多少个对象用,每个对象的数据存在哪里。刚开始写程序那阵子,我的做法很粗暴——每调用一次 FB 就建一个独立的背景数据块,项目做完一看,数据块列表里编号排到好几百,找块比找代码还费劲。后来改用多重实例,DB 数量一下子降下来,又发现有些场景它不合适;再后来接触到参数实例,才明白这三种方式其实对应三种完全不同的设计意图:单个实例是“一个对象一份数据”,多重实例是“把子对象收进父对象的肚子里”,参数实例是“把对象当参数递进去”。这篇就把数据块里这三种实例的区别拆开讲透,从内存布局、声明方式、调用写法一直讲到选型决策和排查坑,手上有 S7-1200/1500 的项目就能直接对照着改。
1. 三种实例机制到底在说什么
1.1 从“复制粘贴到崩溃”的场景说起
先讲个特别常见的起点。一条装配线有八个结构完全一样的工位,每个工位都有气缸、有输送电机、有几个检测开关,动作时序一模一样。正常人的第一反应是写一个功能块FB_Station,里面把气缸伸缩、电机启停、故障复位全封装好,然后在主程序里调用八次。问题来了:FB 是带记忆的块,它里面的静态变量、定时器、计数器、沿检测位,每次调用都得有一份独立存储,否则八个工位的数据会互相覆盖。这份存储就是背景数据块,也叫实例数据块。
最直接的做法是每个调用点建一个独立的实例 DB,得到DB_Station_1到DB_Station_8。程序能跑,但代价很明显:块列表变长、交叉引用变乱、想改一个参数要挨个打开八个 DB、备份的时候文件体积也上去了。如果工位不是八个而是八十个传感器组,这个膨胀就完全失控了。
多重实例解决的正是这个膨胀问题——把子 FB 的实例数据塞进父 FB 的实例 DB 里,对外只暴露一个块。而参数实例解决的是另一个维度的问题:我要让一个通用的处理块去操作“任意一个”实例,而不是绑死在某个具体实例上。三者不是新旧替代关系,是三种不同的组织方式,搞混了就会写出结构别扭、后期难维护的程序。
1.2 三种实例的准确定义与边界
单个实例:每次调用 FB 时,为它单独分配一个实例数据块,这个 DB 与 FB 一一对应(一个 FB 可以有多个实例 DB,但一个实例 DB 只服务一次调用)。实例 DB 在块列表中独立可见,有自己的块号,可以被其他程序通过绝对地址或符号访问(优化访问下只能符号访问)。它的生命周期跟着 CPU 走,掉电保持区可以设置。
多重实例:在一个 FB 的静态变量区声明另一个 FB 类型的变量,被声明的这个内部 FB 的实例数据,就存放在外层 FB 的实例 DB 内部,不单独占用一个 DB 块号。外层 FB 每被调用一次、每建一个实例 DB,里面就自动带上这份子实例空间。子实例没有独立的块号,只能通过“外层实例名.静态变量名”这种路径访问。
参数实例:把 FB 的实例本身当作参数传递。做法是在 FB 的接口区声明一个数据类型为某个 FB(块类型)的形参,调用这个 FB 的时候,把某个具体的实例——可以是单个实例 DB,也可以是外层 FB 里的多重实例——作为实参填进去。块内部通过这个形参去读写目标实例的数据。
三者的关键区别在于实例的归属和地址解析时机。单个实例的地址在编译时就固定到某个 DB 块;多重实例的地址是相对于外层实例 DB 的偏移;参数实例在编译时只知道“有个块引用”,具体指向哪块存储在运行时的调用参数里决定。理解这一层,后面所有的行为差异都能推出来。
1.3 一张表先看清三者的归属关系
| 对比项 | 单个实例 | 多重实例 | 参数实例 |
|---|---|---|---|
| 是否占用独立 DB 块号 | 是 | 否,嵌在父实例 DB 内 | 不占用,它只是引用 |
| 声明位置 | 调用 FB 时选择“单个实例” | 父 FB 的 Static 区,类型填 FB | 接口区 InOut(或 Input),类型填 FB |
| 访问路径 | "DB_xxx".变量 | "DB_父".子实例名.变量 | 块内部#形参名.变量 |
| 能否跨 FB 复用 | 不能 | 不能,绑死父实例 | 能,同一次调用传不同实例 |
| 典型用途 | 顶层设备、单台机组 | 父子聚合,如工位含气缸 | 通用框架、库函数 |
| 调试可见性 | 直接打开 DB 看 | 展开父 DB 逐层看 | 打开实参指向的 DB 看 |
| 对块大小的影响 | 分散,单个块小 | 集中,父块变大 | 无影响 |
表格里“能否跨 FB 复用”这一行是分水岭。单个实例和多重实例都属于“编译期确定归属”,参数实例属于“运行期指定目标”。这不是性能差异,是设计模式的差异,选错了后面改起来很痛苦。
2. 内存布局:钱到底花在哪
2.1 实例数据块里到底存了哪些东西
很多人以为实例 DB 只存静态变量,其实不止。一个 FB 的实例数据通常包含这几类内容:静态变量(Static 区声明的所有变量)、多重实例(Static 区里声明为 FB 类型的变量,本身也是一块子结构)、需要保持的输出和输入映像(部分编译器会把 InOut 的实际参数地址、以及带保持属性的接口数据放进来)、以及块内部使用的临时状态,比如某些编译器生成的边沿存储位。
临时变量(Temp)不进实例 DB,它在 L 堆栈上,扫描周期结束就没了。这点要拎清楚:临时变量不占实例 DB,所以不用为了省 DB 空间去把静态变量改成临时变量,那样只会引入“这个值下个周期就丢了”的隐患。
按标准访问(非优化)方式建的实例 DB,打开后能看到每个变量明确的偏移量和绝对地址;按优化访问建的 DB,只显示符号名,不显示偏移,编译器可以自由排列、做对齐优化,还可能把没用到的变量裁掉。这带来两个直接影响:一是优化访问下没法用绝对地址访问多重实例的子成员,二是优化访问的 DB 不能用某些老式的间接寻址写法。项目里如果还要和老程序对接,建块时就要先想清楚访问方式。
2.2 多重实例的嵌套布局与偏移计算
多重实例在内存上是顺序嵌入的。假设外层 FB 的静态区是这样声明的:
// FB_Station 的 Static 区 fbCylinder_1 : FB_Cylinder; // 多重实例 fbCylinder_2 : FB_Cylinder; // 多重实例 iStep : Int; rCycleTime : Real;而FB_Cylinder的内部静态数据加起来是 24 字节(举个数,实际看声明),那么DB_Station里大致是这样排的:偏移 0 开始放fbCylinder_1的 24 字节,偏移 24 开始放fbCylinder_2的 24 字节,偏移 48 开始放iStep,偏移 50 开始放rCycleTime。标准访问下打开 DB 就能看到这个偏移顺序。这里有个细节值得注意:变量的声明顺序会影响偏移,如果中途在静态区插入一个新变量,后面所有变量的偏移都会平移,用绝对地址写的间接寻址就会错位。这也是我后来尽量改用符号访问的原因。
嵌套层数不是无限的。CPU 对调用深度有上限,多重实例一层套一层,每层都算调用深度。S7-1200 和 S7-1500 的具体上限不一样,一般十几层到二十几层,实际项目里套到三四层就该警觉了——不是技术不允许,是可读性崩了。一个DB_Line.fbStation[3].fbCylinder.fbValve这样的路径,维护的人得一层层扒。
还有个常被忽略的点:父实例 DB 一旦被初始化或整体复位,里面所有多重实例跟着一起复位,因为它们物理上就是同一块内存。这既是优点(复位干净彻底)也是坑(只想复位某个子模块,结果把兄弟模块的状态也清了)。
2.3 参数实例传的是引用而不是副本
这是最容易误解的地方。形参声明为某个 FB 类型时,传进去的不是数据拷贝,是块引用——可以理解成“告诉块内部:数据在那边,你自己去拿”。块内部通过形参名读写变量,实际操作的就是实参指向的那个实例 DB 的内存。
推论一:块内部对#形参名.某个静态变量的修改,在块退出后依然有效,因为它直接写进了目标实例 DB。这跟传一个 Struct 类型的 InOut 参数是一个道理。
推论二:不能在块内部改变形参的指向。形参的指向由调用者决定,块内部只能顺着用,不能中途换对象。
推论三:如果把同一个实例同时传给两个形参,两个形参就指向同一块内存,块内部对它们的写操作会互相踩。这种“别名”问题编译器一般不报错,跑起来数据莫名其妙,排查起来非常费劲。我吃过一次亏:一个通用处理块有两个 InOut 形参,本来是给两个不同对象用的,某次调用图省事两个都填了同一个实例,结果块内部的中间计算把源数据改了,现象是“输入莫名其妙自己变了”,查了两个晚上才定位到。
推论四:如果实参是多重实例,传进去的路径是“外层实例.子实例名”,块内部访问时编译器知道这是个块引用,会生成相应的间接访问代码。相对直接访问单个实例,会有极轻微的开销,但在普通逻辑控制里完全可以忽略,别为这个纠结。
2.4 实际内存和扫描周期的影响
说点量化的东西。假设一个FB_Valve有 12 字节静态数据、4 字节 InOut 接口数据,实例 DB 大约 20 字节上下。一条产线 60 个阀,用单个实例就是 60 个 DB、约 1.2KB 数据加 60 个块管理开销;用多重实例,60 份数据塞进 6 个工位 FB 的实例 DB 里,每个工位约 200 字节,总共还是 1.2KB 左右——数据量本身没变,省的是块数量和块管理开销。
块数量这事在小项目上感觉不明显,在大项目上影响很实在:块列表滚动半天、交叉引用结果里一堆同名 DB、在线监控时打开一堆窗口、程序备份和下载的元数据变多。我做过对比,同样逻辑改成多重实例后,块数量能砍掉三四成,尤其在大量重复设备的项目里效果明显。
扫描周期方面,参数实例因为多了一层引用解析,单次调用的指令数会略多一点点,但也就是几条指令的量级,跟一个普通的比较指令差不多。真正影响周期的是 FB 内部逻辑本身,不是实例的组织方式,别在这个方向上做优化。
3. 动手写三遍:单个、多重、参数
3.1 单个实例的建立与调用
最基础的方式,操作步骤很简单:
- 在项目树里新建一个功能块
FB_Cylinder,语言选 SCL 或 LAD 都行。 - 在接口区定义好 Input(如
iCmdExtend、iCmdRetract)、Output(如qExtended、qFault)、Static(如iStep、tonDelay这类需要保持的中间状态)。 - 在主程序 OB 里把它拖进程序段,弹出调用框时,“实例”那一栏选“单个实例”,系统提示输入 DB 名称,填
DB_Cylinder_1。 - 后面第二次调用,再建
DB_Cylinder_2,依此类推。
调用后代码差不多是这样:
"DB_Cylinder_1"(iCmdExtend := "I0.0", iCmdRetract := "I0.1", qExtended => "Q0.0", qFault => "Q0.1"); "DB_Cylinder_2"(iCmdExtend := "I0.2", iCmdRetract := "I0.3", qExtended => "Q0.2", qFault => "Q0.3");这种写法的好处是直白。打开DB_Cylinder_1就能看到这个气缸的全部状态,在线监控一目了然,改参数也方便。缺点就是前面说的块膨胀。我的经验是:顶层设备、单台机组、逻辑差异大的对象,用单个实例没问题,别为了“省块”硬套多重实例,那样反而不好维护。
3.2 多重实例的建立:静态区里声明子 FB
多重实例的建立稍绕一点,关键动作全在父 FB 的静态区里:
- 先准备好子功能块
FB_Valve,接口和静态变量定义完整。 - 打开父功能块
FB_Station,切到接口区的Static行。 - 新建一个变量,比如
fbValveIn,数据类型直接填FB_Valve(下拉列表里选“块”,再选具体 FB)。 - 系统会把这个变量标记为多重实例。按同样方式再建
fbValveOut。 - 在
FB_Station的代码里调用#fbValveIn(...),此时不需要指定实例 DB,编译器自动把它指向父实例内部的对应区域。 - 回到 OB 里调用
FB_Station,选“单个实例”,建DB_Station_1,多重实例就自动跟着进去了。
代码大致长这样:
// FB_Station 内部 #fbValveIn(iOpen := #iValveInCmd, qOpened => #qValveInOpened, qFault => #qValveInFault); #fbValveOut(iOpen := #iValveOutCmd, qOpened => #qValveOutOpened);几个实操要点:
- 子 FB 的静态变量不要在父 FB 里重复声明一遍。有人习惯在外层再建一个
iStep记录子模块状态,这就重复了,子实例里已经有了,通过#fbValveIn.iStep访问即可。 - 声明顺序会影响父实例 DB 的布局,中途插入变量会平移偏移。用符号访问就不用管,用绝对寻址就要小心。
- 如果子 FB 里还有多重实例(三层嵌套),注视路径会越来越长。我一般控制在两层,第三层改用参数实例或者拆开写,不然调试时逐层展开太累。
- 父 FB 的实例 DB 会明显变大,打开时如果按绝对地址显示,能看到一大片连续区域都属于子实例。别被吓到,这是正常布局。
3.3 参数实例的建立:把实例当参数递进去
参数实例的写法是三者里最“面向对象”的。假设我要写一个通用的数据记录块FB_DataLog,它能对任意一个设备实例做数据采集和统计,但事先不知道是哪个设备。做法:
- 在
FB_DataLog的接口区,InOut 里新建参数ioTarget,数据类型填某个 FB,比如FB_Cylinder。 - 块内部通过
#ioTarget.某个静态变量读写目标实例的数据。 - 调用时,实参位置填具体的实例路径:可以是单个实例 DB 名
"DB_Cylinder_1",也可以是父 FB 里的多重实例名#fbValveIn或"DB_Station_1".fbValveIn。
调用示意:
// 主程序里 #fbDataLog_A(ioTarget := "DB_Cylinder_1"); #fbDataLog_B(ioTarget := "DB_Station_1".fbValveIn);块内部代码:
// FB_DataLog 内部 IF #ioTarget.qFault THEN #iFaultCount := #iFaultCount + 1; END_IF; #rRunHours := #rRunHours + #rCycleTime;这里有个必须提醒的点:形参声明在 InOut 区,语义上表达“我要读写这个实例的内部数据”,可读性最好。用 Input 区声明块类型在某些版本里也能通过编译,但会让读代码的人以为只是“读一下”,实际写操作照样生效,容易误导。我统一用 InOut,团队里也按这个约定来。
另一个要点是版本和平台支持。把 FB 作为参数传递这个功能,在较新版本的编程软件里才比较完善,老版本或低端型号的 CPU 有可能不支持块类型形参。动手之前先确认自己的软件版本和 CPU 型号,别照着写一半发现声明不了。
还有个容易踩的坑:参数实例不能是临时变量,也不能是空。调用时必须给出一个真实存在的实例,否则编译报错。如果业务上确实存在“这次调用不需要目标”的情况,就要在块内部加个使能判断,或者改用可选参数(部分版本支持),别硬传一个假实例进去。
3.4 三种写法怎么验证对不对
写完不能靠“看起来没问题”,要有验证手段。我的习惯是三步走:
第一步,看块结构。单个实例:打开实例 DB,变量列表应该和 FB 静态区一一对应。多重实例:展开父实例 DB,能看到子实例作为一个节点,点进去是子 FB 的静态变量。参数实例:块本身没有独立的数据区,数据都在实参指向的 DB 里。
第二步,做交叉污染测试。给两个不同实例写不同的输入,观察输出是否互不影响。多重实例特别要做这个测试,因为一旦父实例的复位逻辑写错,兄弟模块会被连带清零。参数实例则要测“同一次调用传不同实例”的结果是否正确。
第三步,在线监控对比。把实参 DB 和块内部形参同时在线,改一个值看另一个是否跟着变。参数实例如果传的是引用,两边应该同步变化;如果发现不同步,说明哪里理解错了,或者传的不是实例而是别的东西。
4. 选型:什么场景用哪个
4.1 决策依据不是“省空间”,是“归属关系”
我见过不少讨论把重点放在“哪个省 DB”,其实这不是选型的第一考量。真正该问的是:这份数据归谁所有,谁对它负责复位,谁会来访问它。
如果一份数据属于某台独立设备,这台设备的启停、复位、故障处理都由它自己的逻辑管,那用单个实例最自然。数据边界清晰,出问题一眼定位。
如果一份数据在逻辑上是某个更大设备的组成部分,比如工位里的气缸、气缸里的电磁阀,父设备复位时子设备必须跟着复位,父子之间存在聚合关系,那就用多重实例。把子实例收进父实例,等于把“从属关系”写进了内存结构里,复位和初始化天然一致。
如果一份数据要被一段通用逻辑处理,而这段逻辑事先不知道处理谁——比如通用报警汇总、通用数据记录、通用配方下发——那就用参数实例。让调用者决定操作对象,块只管逻辑。
按这个标准过一遍,大部分选型其实没争议。真正纠结的往往是“这个设备将来会不会被别的地方复用”,这种时候我倾向于先用单个实例把功能做出来,等复用需求真的出现再抽参数实例,别一开始就过度设计。
4.2 选型决策表与反模式清单
| 场景特征 | 推荐方式 | 理由 |
|---|---|---|
| 单台独立设备,逻辑不重复 | 单个实例 | 边界清晰,调试直观 |
| 多台设备逻辑相同、数量固定 | 单个实例 + 循环调用 | 结构简单,别过早抽象 |
| 设备内部含多个相同子模块 | 多重实例 | 聚合关系明确,复位一致 |
| 子模块数量在编译期不确定 | 参数实例 + 实例数组 | 运行期灵活指定目标 |
| 通用库函数,要操作不同对象 | 参数实例 | 解耦逻辑与数据 |
| 需要在线单独监控某个子模块 | 单个实例或展开多重实例 | 参数实例监控要跳转,稍麻烦 |
| 数据需要掉电保持且要精确定位 | 单个实例 + 保持区设置 | 多重实例的保持设置粒度较粗 |
反模式清单,这几条我都见过或者踩过:
- 把本该用多重实例的子模块全建了独立 DB,结果一个工位有二十几个 DB,复位时要挨个写复位逻辑,漏一个就是隐患。
- 为了省块,把不相关的模块硬塞进同一个父 FB 当多重实例,逻辑上没有从属关系,纯属为了省块号,后期谁都不敢动,一改就全乱。
- 用参数实例去操作一个需要掉电保持的实例,但忘记检查保持属性。参数实例本身不改变目标实例的保持设置,保持属性还是由实参那个 DB 决定的,这点要提前确认。
- 多层嵌套套太深,为了“优雅”套四层,结果调试时路径长得看不全,还不如老老实实拆开。
5. 常见问题与排查实录
5.1 典型报错与现象速查
| 现象 | 可能原因 | 处理思路 |
|---|---|---|
| 调用 FB 时提示“实例数据块不存在” | 选了“单个实例”但没建 DB | 在调用框里新建,或改选多重实例 |
| 多重实例子模块数据不刷新 | 父 FB 没被调用,或调用在条件分支里没执行 | 检查父 FB 的执行路径 |
| 参数实例报“实参类型不匹配” | 传的是别的 FB 的实例,或传了 Struct | 确认实参是该 FB 的真实例 |
| 两个模块数据互相影响 | 参数实例传了同一个实例给两个形参 | 拆开,各传各的 |
| 父实例复位后子模块全丢状态 | 多重实例共享父 DB,复位范围过大 | 细化复位逻辑,只清必要变量 |
| 优化访问下绝对地址访问失败 | 优化 DB 无固定偏移 | 改符号访问,或建块时选标准访问 |
| 新增静态变量后老逻辑错乱 | 标准访问下偏移平移,绝对寻址失配 | 改符号访问,避免硬编码偏移 |
| 嵌套调用深度超限 | 多重实例层数过多 | 减少嵌套,或改用参数实例 |
5.2 多重实例最隐蔽的两个坑
坑一:条件调用下的状态残留。多重实例的数据存在父实例 DB 里,只要父实例存在,子实例的数据就在。如果父 FB 里的某个子 FB 调用被放在IF分支里,条件不满足时子 FB 不执行,但它上一周期的输出、定时器状态还留在实例 DB 里,不会自动清零。表现出来就是“设备停了但故障灯还亮着”“定时器卡在中途不动”。解决办法是在不执行的分支里显式复位关键输出,或者把调用改成无条件执行、内部用使能位控制逻辑。
坑二:父实例 DB 的“整体初始化”。有些项目习惯在启动时把整个实例 DB 写零来做初始化。对多重实例来说,这一写会把所有子模块的状态、包括那些设置了保持的变量一起清掉。如果业务上需要保留某些计数或累计值,这种粗暴初始化会直接丢数据。正确的做法是只复位流程相关的变量,保持类变量单独处理。
5.3 参数实例的时序与初始化问题
参数实例最典型的错误是在块内部对目标实例做初始化,结果每个周期都初始化一遍。因为块内部不知道调用频率,如果写了“进入块就清零目标实例”的逻辑,只要这个块被高频调用,目标实例的数据永远攒不起来。正确做法是用首次扫描位或者状态机控制,只在需要的时候初始化一次。
第二个问题是调用顺序。如果 A 块通过参数实例操作某个实例,B 块也通过参数实例操作同一个实例,两个块在同一周期里的执行顺序就会影响最终结果。这在逻辑上属于“共享数据竞争”,编译器不报错,现象是“有时候对有时候不对”。避免办法是明确数据所有权:同一个实例在同一周期里只让一个块写,其他块只读,或者把写操作集中到一个块里。
第三个问题是调试时的可观察性。参数实例在块内部只显示形参名,看不到实际数据,得跳转到实参指向的 DB 才能看。刚开始用会觉得别扭,习惯了就好。我的做法是在块内部把关键中间值也输出一份到 Output,在线时直接看块的调用点,比来回跳转快。
6. 一些实际操作中的体会
这三种方式我都用了几年,最后形成的手感是:结构上优先用多重实例表达从属关系,通用逻辑用参数实例解耦,剩下的顶层对象老老实实用单个实例。别追求“全部用多重实例”或者“全部参数化”,那种为了统一而统一的做法,维护成本比收益高得多。
有个细节值得单独说:多重实例和参数实例可以组合用。父 FB 里声明多重实例,同时把这个多重实例作为实参传给另一个通用块,这样既保持了数据归属清晰,又复用了通用逻辑。示意写法是#fbDataLog(ioTarget := #fbValveIn),父 FB 内部调用,实参就是自己静态区里的多重实例。这种组合在大项目里很实用,我现在的标准做法就是这么搭的。
另外提一句版本差异。同样一段多重实例的代码,在不同版本的编程软件里,实例 DB 的显示方式、能不能直接展开子节点、优化访问下能不能看到偏移,表现都不完全一样。跨版本打开别人做的项目时,先确认访问方式(优化还是标准),再动代码,否则可能改了一处牵连一片。我自己踩过一次:接手一个标准访问的老项目,把某个 FB 改成优化访问,结果所有依赖绝对偏移的间接寻址全废了,返工了半天。
最后说个验证习惯。不管用哪种实例方式,写完一小段就下载进去,在线看一眼实例 DB 的实际内容,确认数据落在预期位置、父子关系正确、复位范围符合设想。这一步花不了几分钟,但能挡住后面百分之八十的“莫名其妙”问题。PLC 程序的特点是错误会累积,一个实例归属搞错,可能在几十个周期之后才以某个奇怪的现象暴露出来,那时候再回头查,成本就高太多了。