1. 从"1个FB vs 8种机型"说起:这个标题到底在讲什么
第一次看到"1个FB vs 8种机型,ST复用这样写"这个标题,很多做PLC和工控的朋友应该会心一笑。FB就是Function Block,功能块;ST就是Structured Text,结构化文本,IEC 61131-3标准里那套长得像Pascal的编程语言。标题的意思很直白:用一个功能块去覆盖八种不同机型的控制逻辑,而且是用ST语言写的复用方案。
这事在工控圈里其实是个老生常谈但又总有人踩坑的话题。你去看任何一个非标自动化项目,只要机型超过三种,代码里必然出现大量"复制粘贴改一改"的痕迹。A机型的轴控逻辑抄到B机型,改两个参数;B机型再抄到C机型,再改两个参数。抄到第八个机型的时候,原始代码长什么样已经没人记得了,改一个bug要改八遍,漏一遍就等着现场调试的时候炸。
所以这个标题背后真正要解决的问题是:如何用ST语言设计一个高内聚、低耦合的功能块,让它通过参数化配置适配多种机型,而不是为每个机型写一份独立的逻辑。关键词里的"配置表"三个字是核心线索——这意味着方案不是靠IF-ELSE堆出来的,而是靠数据驱动。
这篇文章适合谁看?如果你正在做多机型兼容的PLC项目,或者你手里已经有一堆"复制粘贴"出来的功能块想重构,再或者你刚学ST语言想知道工程上到底怎么组织代码,那接下来的内容应该对你有用。我会从设计思路、ST语言特性、配置表结构、实操步骤、常见坑几个维度把这个方案拆开讲,尽量做到你看完能直接套到自己项目里。
2. 为什么是"1个FB"而不是"8个FB":复用方案的设计逻辑
2.1 多机型项目的典型困境
先说说大多数人的做法。假设你有八种机型,每种机型的差异集中在几个地方:轴的数量不同(有的3轴有的5轴)、气缸数量不同、安全门配置不同、HMI交互流程略有差异、报警阈值不一样。很多工程师的做法是建八个FB,FB_MachineA、FB_MachineB……每个里面把逻辑写一遍。
这种做法在项目初期看起来很快,因为你可以直接复制上一个机型的代码,改改就行。但问题会在三个时间点集中爆发:
第一个时间点是客户改需求。比如所有机型都要增加一个"急停后自动复位"的功能。你得打开八个FB,每个里面加同样的逻辑。加完之后测试,发现第七个FB里漏了一个变量没改,现场跑起来复位到一半卡住了。
第二个时间点是新人接手。新人打开项目,看到八个几乎一样的FB,完全不知道它们之间的差异在哪里,改任何一个都怕影响其他机型。
第三个时间点是版本管理。八个FB意味着八份代码要维护,Git diff看起来一片红,code review根本没法做。
2.2 单FB方案的核心思想:变化点分离
单FB方案的核心思想其实就一句话:把变化的部分抽出来变成数据,把不变的部分留下来变成逻辑。
八种机型之间,什么是变的?轴数、气缸数、阈值、使能条件、时序参数。什么是不变的?状态机的流转逻辑、报警的处理流程、轴的使能-回零-定位-停止这套动作序列。不变的部分写进FB,变化的部分通过配置表传入。
这就像做菜。八个机型是八道菜,但炒菜这个动作是一样的——热锅、下油、下料、翻炒、调味、出锅。你不需要为每道菜写一个"炒菜功能块",你只需要一个炒菜功能块,把"什么料、什么火候、炒多久"作为参数传进去。
2.3 为什么选ST而不是梯形图
这里要专门说一下语言选型。梯形图(LD)做逻辑互锁很直观,但做数据驱动和复杂数据结构很吃力。你要在梯形图里搞一个结构体数组的遍历,画出来的图能占满三屏,维护起来是灾难。
ST语言的优势在于它支持结构体(STRUCT)、数组(ARRAY)、循环(FOR/WHILE)、条件分支(CASE),这些恰好是数据驱动方案需要的基础设施。你可以定义一个配置结构体数组,用FOR循环遍历,每个元素对应一个轴或一个气缸。这种写法在ST里可能就二十行,在梯形图里可能要画两百个网络。
当然ST也不是万能的。涉及安全逻辑(比如安全门、急停链)的部分,很多公司规范还是要求用梯形图或者安全功能块来做,因为可读性和审查便利性更好。我的做法是:安全相关的用LD或认证安全块,业务逻辑和数据处理用ST,两者在同一个程序里混用,各取所长。
2.4 方案的整体架构
整个方案的架构分三层:
- 配置层:一个全局的配置结构体数组,每个元素描述一个机型或一个子模块的参数。这部分数据可以来自HMI、来自文件、来自上位机下发,也可以直接常量写死。
- 功能层:一个通用的FB,接收配置数据作为输入,内部用ST实现状态机、轴控、气缸控、报警处理等通用逻辑。
- 适配层:机型选择逻辑,根据当前机型编号从配置表里取出对应的配置,喂给功能层。
这个架构的好处是,新增一个机型只需要在配置表里加一行数据,功能层的代码一行都不用动。改一个通用逻辑,八个机型同时生效,不存在漏改的问题。
3. ST语言实现复用的关键技术点
3.1 结构体设计:配置表的骨架
配置表的核心是结构体。以轴控为例,一个轴需要哪些参数?轴号、使能条件、回零方式、软限位正负、定位速度、加速度、到位判定窗口、超时时间。把这些打包成一个结构体:
TYPE ST_AxisConfig : STRUCT nAxisNo : INT; // 轴号 bEnable : BOOL; // 是否启用该轴 nHomingMode : INT; // 回零方式 0-直接 1-找原点 2-找Z相 fSoftLimitPos : REAL; // 正软限位 fSoftLimitNeg : REAL; // 负软限位 fSpeedFast : REAL; // 快速速度 fSpeedSlow : REAL; // 慢速速度 fAccel : REAL; // 加速度 fPosWindow : REAL; // 到位窗口 tTimeout : TIME; // 超时时间 END_STRUCT END_TYPE然后定义一个数组,长度就是最大轴数:
VAR_GLOBAL aAxisCfg : ARRAY[1..8] OF ST_AxisConfig; END_VAR八种机型的差异就体现在这个数组的填充内容上。机型A可能只用3个轴,那aAxisCfg[4..8].bEnable就都是FALSE;机型B用5个轴,前5个bEnable为TRUE。功能块里遍历这个数组,遇到bEnable为FALSE的就跳过,逻辑完全不用改。
注意:结构体里的成员命名建议加前缀(n表示INT,f表示REAL,b表示BOOL,t表示TIME),这样在ST代码里一眼就能看出变量类型,减少类型不匹配的编译错误。这个习惯在大型项目里能省很多时间。
3.2 功能块接口设计:输入输出怎么定
FB的接口设计直接决定了复用性好不好。一个常见的错误是把太多东西做成输入输出变量,导致调用的时候要连几十根线。正确的做法是:配置数据通过结构体数组传入,状态数据通过结构体数组传出,只有少量控制信号用单独的引脚。
FUNCTION_BLOCK FB_MachineCtrl VAR_INPUT nMachineType : INT; // 机型编号 1-8 bStart : BOOL; // 启动 bStop : BOOL; // 停止 bReset : BOOL; // 复位 aAxisCfgIn : ARRAY[1..8] OF ST_AxisConfig; // 轴配置 aCylCfgIn : ARRAY[1..16] OF ST_CylConfig; // 气缸配置 END_VAR VAR_OUTPUT nState : INT; // 当前状态 bReady : BOOL; // 就绪 bError : BOOL; // 故障 aAxisStatus : ARRAY[1..8] OF ST_AxisStatus; // 轴状态 END_VAR VAR // 内部变量 i : INT; nActiveAxis : INT; fbAxis : ARRAY[1..8] OF FB_AxisCtrl; // 轴控FB实例 END_VAR这里有个细节:轴控FB实例用了数组。这意味着每个轴都有一个独立的FB实例,它们之间互不干扰。在ST里,FB数组是允许的,调用的时候用fbAxis[i](...)这种形式。这个特性是做复用方案的关键,没有它就得为每个轴声明一个单独的实例变量,八个轴就是八行声明,虽然也能用但不够优雅。
3.3 状态机设计:用CASE而不是IF堆叠
状态机是机型控制的核心。八种机型的状态流转逻辑大同小异,差异只在某些状态的进入条件和超时时间上。用CASE语句写状态机,结构清晰,扩展方便:
CASE nState OF 0: // 空闲 IF bStart AND bReady THEN nState := 10; END_IF 10: // 回零 FOR i := 1 TO 8 DO IF aAxisCfgIn[i].bEnable THEN fbAxis[i](bEnable := TRUE, nMode := 1); IF NOT fbAxis[i].bDone THEN RETURN; END_IF END_IF END_FOR nState := 20; 20: // 定位 // ... 30: // 完成 // ... END_CASE这种写法的好处是,新增一个状态只需要加一个CASE分支,不会影响其他状态。而且每个状态内部的逻辑是独立的,调试的时候可以单独测试某个状态。
3.4 配置表的加载方式
配置表的数据从哪来?三种常见方式:
第一种是常量写死。在全局变量初始化的时候直接赋值,适合机型固定、不常改的场景。优点是简单可靠,缺点是改配置要重新下载程序。
第二种是HMI输入。操作员在触摸屏上选择机型,PLC根据机型编号从预设的配置表里取数据。这种方式适合机型切换频繁的场景,但配置表本身还是写在程序里。
第三种是文件或数据库加载。配置存在SD卡、配方文件或上位机数据库里,PLC启动时读取。这种方式最灵活,但实现复杂度也最高,需要考虑文件格式、读取失败的处理、版本兼容等问题。
我的建议是:大多数项目用第二种就够了。在PLC里维护一个二维数组,第一维是机型编号,第二维是参数索引,HMI只负责传机型编号。这样既灵活又可靠,不需要处理文件读写的各种异常。
4. 实操过程:从零搭建一个可复用的机型控制FB
4.1 第一步:梳理机型差异矩阵
动手写代码之前,先拿一张纸(或者Excel)把所有机型的差异列出来。这一步不能省,省了后面一定返工。矩阵的行是机型,列是差异点:
| 差异项 | 机型A | 机型B | 机型C | 机型D | 机型E | 机型F | 机型G | 机型H |
|---|---|---|---|---|---|---|---|---|
| 轴数量 | 3 | 3 | 5 | 5 | 4 | 4 | 6 | 6 |
| 气缸数量 | 4 | 6 | 6 | 8 | 4 | 6 | 8 | 10 |
| 安全门 | 无 | 有 | 有 | 有 | 无 | 有 | 有 | 有 |
| 回零方式 | Z相 | Z相 | 原点 | 原点 | Z相 | Z相 | 原点 | 原点 |
| 节拍要求 | 快 | 快 | 中 | 中 | 快 | 中 | 慢 | 慢 |
这张表出来之后,哪些参数需要进配置结构体就一目了然了。轴数量、气缸数量、安全门有无、回零方式、节拍参数,全部变成配置项。
4.2 第二步:定义配置结构体和全局配置表
根据差异矩阵定义结构体。这里要注意一个原则:结构体成员只放"因机型而异"的参数,不放"所有机型都一样"的常量。比如"轴使能后的等待时间"如果八个机型都是200ms,那就不要放进结构体,直接写在FB里作为常量。放进去反而增加了配置的复杂度。
TYPE ST_MachineConfig : STRUCT nAxisCount : INT; // 轴数量 nCylCount : INT; // 气缸数量 bHasSafetyDoor : BOOL; // 是否有安全门 nHomingMode : INT; // 回零方式 tCycleTarget : TIME; // 目标节拍 fSpeedScale : REAL; // 速度缩放系数 END_STRUCT END_TYPE VAR_GLOBAL CONSTANT cMachineCfg : ARRAY[1..8] OF ST_MachineConfig := [ (nAxisCount:=3, nCylCount:=4, bHasSafetyDoor:=FALSE, nHomingMode:=2, tCycleTarget:=T#8S, fSpeedScale:=1.0), (nAxisCount:=3, nCylCount:=6, bHasSafetyDoor:=TRUE, nHomingMode:=2, tCycleTarget:=T#8S, fSpeedScale:=1.0), (nAxisCount:=5, nCylCount:=6, bHasSafetyDoor:=TRUE, nHomingMode:=1, tCycleTarget:=T#12S, fSpeedScale:=0.8), (nAxisCount:=5, nCylCount:=8, bHasSafetyDoor:=TRUE, nHomingMode:=1, tCycleTarget:=T#12S, fSpeedScale:=0.8), (nAxisCount:=4, nCylCount:=4, bHasSafetyDoor:=FALSE, nHomingMode:=2, tCycleTarget:=T#9S, fSpeedScale:=1.0), (nAxisCount:=4, nCylCount:=6, bHasSafetyDoor:=TRUE, nHomingMode:=2, tCycleTarget:=T#10S, fSpeedScale:=0.9), (nAxisCount:=6, nCylCount:=8, bHasSafetyDoor:=TRUE, nHomingMode:=1, tCycleTarget:=T#15S, fSpeedScale:=0.7), (nAxisCount:=6, nCylCount:=10, bHasSafetyDoor:=TRUE, nHomingMode:=1, tCycleTarget:=T#15S, fSpeedScale:=0.7) ]; END_VAR这段代码是配置表的核心。八个机型,每个一行,所有差异参数都在里面。新增机型只需要加一行,改参数只需要改对应的数值。
提示:
VAR_GLOBAL CONSTANT表示全局常量,编译后存在ROM里,掉电不丢失。如果你的配置需要在线修改,就不能用CONSTANT,要用普通的VAR_GLOBAL,并且考虑掉电保持的问题。
4.3 第三步:编写通用FB的主体逻辑
FB的主体逻辑分几个模块:初始化、状态机、轴控、气缸控、报警处理。每个模块都用ST写,通过配置参数控制行为。
初始化模块负责根据配置设置内部变量:
// 初始化 IF bInit THEN nActiveAxis := cMachineCfg[nMachineType].nAxisCount; nActiveCyl := cMachineCfg[nMachineType].nCylCount; bSafetyDoorEn := cMachineCfg[nMachineType].bHasSafetyDoor; fSpeedFactor := cMachineCfg[nMachineType].fSpeedScale; bInit := FALSE; END_IF轴控模块遍历轴数组,只处理使能的轴:
FOR i := 1 TO nActiveAxis DO fbAxis[i]( bEnable := bAxisEnable[i], nHomingMode:= cMachineCfg[nMachineType].nHomingMode, fSpeed := fBaseSpeed * fSpeedFactor, fAccel := fBaseAccel * fSpeedFactor ); aAxisStatus[i] := fbAxis[i].stStatus; END_FOR注意这里fSpeedFactor的作用。机型G和H的节拍要求慢,速度缩放系数0.7,意味着所有轴的速度都降到70%。这个逻辑只写了一遍,八个机型通用。
气缸控模块类似,但气缸的逻辑通常比轴简单,主要是伸出、缩回、到位检测、超时报警:
FOR i := 1 TO nActiveCyl DO fbCyl[i]( bExtend := bCylCmd[i], tTimeout := T#3S, bSensorExt := bCylSensorExt[i], bSensorRet := bCylSensorRet[i] ); END_FOR4.4 第四步:机型切换的处理
机型切换有两种场景:一种是断电切换,设备关机,操作员换机型,重新上电;另一种是在线切换,设备运行中操作员在HMI上改机型。
断电切换简单,上电初始化的时候读一次机型编号就行。在线切换复杂,必须确保设备处于安全状态才能切换,否则轴还在动的时候改了轴数量,程序直接乱套。
我的做法是:在线切换必须满足三个条件——设备在空闲状态、所有轴已停止、所有气缸已复位。满足条件后,先执行一个"卸载"流程(把当前机型的资源释放掉),再执行"加载"流程(按新机型初始化)。这个逻辑用一个独立的状态机来管,不要混在主状态机里。
CASE nSwitchState OF 0: // 空闲,等待切换命令 IF bSwitchCmd AND (nState = 0) AND bAllAxisStopped AND bAllCylHome THEN nSwitchState := 10; END_IF 10: // 卸载当前机型 bInit := TRUE; // 触发重新初始化 nSwitchState := 20; 20: // 加载新机型 nMachineType := nNewMachineType; nSwitchState := 0; END_CASE4.5 第五步:测试与验证
单FB方案最大的风险是"改一处影响八处"。所以测试必须覆盖所有机型。我的测试流程是:
先做单元测试,每个机型单独跑一遍完整流程,确认基本功能正常。然后做回归测试,改一个通用逻辑,八个机型全部重跑。最后做边界测试,比如机型A只有3个轴,但配置表里第4个轴的bEnable如果被误设为TRUE会怎样?程序应该能容错,而不是直接崩掉。
测试的时候建议做一个机型模拟器,在HMI上做一个页面,可以手动设置机型编号、手动触发各个状态、手动模拟传感器信号。这样不用实际接设备就能测大部分逻辑。
5. 常见问题与排查技巧实录
5.1 配置表数据错位
这是最常见的问题。配置表是一个二维数组,如果某一行的参数顺序写错了,或者少写了一个参数,编译可能不报错,但运行起来行为完全不对。
排查方法:写一个配置校验程序,在初始化的时候检查配置的合理性。比如轴数量不能超过数组上限、速度系数必须在0.1到2.0之间、回零方式只能是0/1/2。校验不通过就报警,不要让它带着错误配置跑。
// 配置校验 IF (cMachineCfg[nMachineType].nAxisCount < 1) OR (cMachineCfg[nMachineType].nAxisCount > 8) THEN bCfgError := TRUE; sCfgErrMsg := '轴数量配置错误'; END_IF5.2 FB实例数组的调用陷阱
ST里FB数组的调用有个坑:不能跳过某个实例不调用。如果你声明了fbAxis : ARRAY[1..8] OF FB_AxisCtrl,那么每个扫描周期八个实例都会被调用,即使某个轴的bEnable是FALSE。这本身不是问题,但如果你在FB内部写了"bEnable为FALSE时复位所有输出"的逻辑,那没问题;如果你没写,FB可能会保持上一次的状态,导致轴使能信号残留。
解决方法:在FB内部第一行就处理bEnable:
IF NOT bEnable THEN // 复位所有输出 bDone := FALSE; bBusy := FALSE; bError := FALSE; RETURN; END_IF5.3 机型切换时的状态残留
在线切换机型的时候,如果旧机型的状态机还停在某个中间状态,新机型加载后可能会从这个中间状态继续跑,导致逻辑混乱。
解决方法:切换时强制把状态机复位到空闲状态,所有内部变量清零。这个复位逻辑要写在加载流程里,不能依赖状态机自己走回空闲。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 某机型轴不动 | 配置表中该轴bEnable为FALSE | 在线监控配置数组 | 修改配置表对应项 |
| 切换机型后报警 | 旧状态未复位 | 查看nState值 | 切换时强制复位状态机 |
| 速度不对 | 速度系数配置错误 | 检查fSpeedScale | 修正配置表 |
| 编译报错类型不匹配 | 结构体成员类型用错 | 查看编译错误行 | 检查变量前缀和类型 |
| 运行中突然停止 | 配置校验未通过 | 查看bCfgError | 修正配置数据 |
| 气缸动作顺序错 | 气缸配置顺序与实物不符 | 对照IO表 | 调整配置表顺序 |
5.5 几个实操心得
第一个心得:配置表的注释要写清楚。每个参数的含义、取值范围、单位,全部写在注释里。我见过太多项目,配置表里一个nParam1、nParam2,过了半年连原作者都不知道是什么意思。
第二个心得:保留一份配置表的Excel版本。PLC里的配置表是给程序用的,Excel是给人看的。每次改配置,先改Excel,再同步到PLC代码,这样不容易出错。
第三个心得:机型编号从1开始,不要从0开始。虽然ST数组默认从0开始,但机型编号从1开始更符合人的直觉,而且0可以保留作为"未选择机型"的默认值。
第四个心得:新机型上线前,先用模拟器跑一遍完整流程。不要直接上实机,实机调试的成本太高,一个逻辑错误可能导致撞机。
6. 这个方案的扩展方向
单FB方案跑通之后,还有几个可以继续优化的方向。
第一个方向是配置表的外部化。把配置从PLC代码里挪到配方文件或数据库中,通过HMI或上位机下发。这样新增机型不需要改PLC程序,只需要更新配置文件。适合机型多、变化快的场景。
第二个方向是自动机型识别。通过读取设备上的硬件配置(比如拨码开关、RFID、或者IO模块的型号),自动判断当前是什么机型,不需要操作员手动选择。这样能避免选错机型导致的问题。
第三个方向是参数自整定。某些参数(比如速度、加速度)可以根据实际运行数据自动优化,不需要人工调。这个方向实现难度较高,但对节拍要求严格的产线很有价值。
第四个方向是与MES系统对接。机型信息、生产参数、运行状态全部上传到MES,实现远程监控和数据分析。这个方向已经超出了PLC编程的范畴,涉及到上位机开发和网络通信,但和单FB方案是天然兼容的——因为配置数据已经结构化了,上传和下发都很方便。
我在实际项目里用这套方案做过一个八机型兼容的装配线,从第一版跑通到最终交付,功能层的代码只改了三次,每次都是增加新功能而不是修bug。配置表从最初的八行扩展到后来的十二行(客户中途加了四个机型),功能层一行没动。这种"改数据不改代码"的体验,是复制粘贴方案永远给不了的。