1个FB适配8种机型:ST语言复用写法与工程实践
1. 从1个FB vs 8种机型说起这个标题到底在讲什么第一次看到1个FB vs 8种机型ST复用这样写这个标题很多人会愣一下——FB是什么ST又是什么8种机型指的是什么场景如果你做过三菱PLC的GX Works3项目尤其是接触过结构化文本ST编程大概率会心一笑这说的就是用一个功能块Function Block简称FB去适配8种不同机型的控制逻辑通过ST语言的复用写法把原本需要写8遍的代码压缩成一套可配置的模板。这个场景在工控行业里非常典型。一条产线上可能同时跑着8种不同规格的设备它们的控制流程大同小异——都是上料、定位、加工、下料、复位这几个阶段但每种机型的行程参数、气缸数量、传感器布局、节拍要求都不一样。如果每种机型单独写一套程序那就是8份几乎重复的代码维护起来简直是噩梦改一个逻辑要改8个地方漏改一个就是现场事故。而如果用FB封装加ST复用只需要维护一个功能块通过参数配置就能适配所有机型。这篇文章适合谁看如果你正在用GX Works3做多机型兼容项目或者你写的ST代码已经开始出现大量复制粘贴的痕迹又或者你对FB的复用机制一直似懂非懂那这篇内容就是为你准备的。我会从FB的设计思路讲起拆解ST复用的核心写法补充实际调试中踩过的坑最后给出一个可以直接参考的代码框架。全程不堆砌理论只讲能落地的做法。提示本文涉及的编程环境为GX Works3编程语言为STStructured Text核心概念是FBFunction Block的复用。如果你用的是其他品牌的PLC思路是相通的但具体语法需要对照对应手册调整。2. 为什么是1个FB而不是8个程序复用背后的工程账2.1 8份独立程序的隐性成本先算一笔账。假设每种机型的控制逻辑有500行ST代码8种机型就是4000行。这4000行里真正差异化的部分可能只有不到20%——也就是参数不同、个别工位的有无不同。剩下80%的逻辑是完全一致的启动条件判断、气缸动作时序、安全互锁、报警处理、复位流程。独立写8份程序的成本体现在哪里第一是初次开发时间8份代码就算复制粘贴也要逐个调整参数和工位逻辑至少多花3到5天。第二是调试成本每种机型都要单独验证测试用例要写8套。第三也是最要命的是后期维护成本。产线运行半年后客户提出要改一个报警逻辑你需要打开8个程序文件逐个修改逐个下载验证。漏掉一个那台设备就可能在不该报警的时候报警或者该报警的时候不报。2.2 FB封装带来的结构性收益用一个FB来封装公共逻辑差异部分通过输入输出参数暴露出来收益是立竿见影的。代码总量从4000行降到可能800行左右其中FB内部逻辑600行8种机型的配置数据200行。修改报警逻辑时只改FB内部一处所有机型同时生效。更重要的是FB的接口定义本身就是一份文档。你看到这个FB有哪些输入参数、哪些输出参数就大致知道它能控制什么、需要什么条件。这比翻8份程序去对比差异要高效得多。2.3 什么情况下不适合强行复用也不是所有场景都适合硬塞进一个FB。如果8种机型的控制流程差异超过50%比如有的是旋转加工、有的是直线冲压动作逻辑完全不同那强行复用会导致FB内部充满if-else分支可读性反而下降。这种情况下更合理的做法是提取更底层的公共FB比如气缸控制FB、轴运动FB在上层用不同的程序调用这些基础FB。判断标准很简单如果两种机型的差异可以用参数描述行程、速度、工位数量那就适合复用如果差异需要用不同的逻辑结构来描述那就不适合。3. ST语言写FB的语法要点与常见误区3.1 FB的声明与实例化在GX Works3中FB的声明和调用有固定的语法结构。先看一个最基本的FB声明FUNCTION_BLOCK FB_MachineControl VAR_INPUT bStart : BOOL; // 启动信号 bStop : BOOL; // 停止信号 iMachineType : INT; // 机型编号 1-8 rStrokePos : REAL; // 行程位置 rSpeed : REAL; // 运行速度 END_VAR VAR_OUTPUT bRunning : BOOL; // 运行中 bComplete : BOOL; // 完成 iErrorCode : INT; // 错误码 END_VAR VAR iStep : INT; // 内部步序 tonDelay : TON; // 延时定时器 bInitDone : BOOL; // 初始化完成标志 END_VAR这里有几个关键点。VAR_INPUT是输入参数每次调用时传入VAR_OUTPUT是输出参数供外部读取VAR是内部变量不对外暴露。注意TON定时器作为内部变量声明时每个FB实例都会有自己的定时器不会互相干扰——这是FB相对于普通函数的核心优势。实例化的写法VAR fbMachine1 : FB_MachineControl; fbMachine2 : FB_MachineControl; END_VAR fbMachine1(bStart : bStart1, bStop : bStop1, iMachineType : 1, rStrokePos : 100.0, rSpeed : 50.0); fbMachine2(bStart : bStart2, bStop : bStop2, iMachineType : 2, rStrokePos : 150.0, rSpeed : 60.0);每个实例独立维护自己的内部状态互不干扰。3.2 常见误区把FB当函数用很多人从C语言或Python转过来习惯性地认为函数调用完就结束了。但FB不是这样。FB的实例在PLC的一个扫描周期到下一个扫描周期之间会保持状态。这意味着你不能在FB内部用局部变量来临时存储跨周期的数据除非你明确知道这个变量会保持。另一个常见错误是在FB内部直接访问全局变量。虽然语法上允许但这会破坏FB的封装性。一旦FB依赖了某个全局变量复用时就多了一个隐性耦合点。正确的做法是把所有需要的外部数据都通过输入参数传进来。3.3 ST的运算符与结构化写法ST语言的运算符和主流编程语言接近但有几个细节需要注意。赋值用:而不是比较用而不是。逻辑运算用AND、OR、NOT而不是、||、!。这些细节在编译报错时经常让人抓狂。结构化写法方面CASE语句是处理多机型分支的利器CASE iStep OF 0: // 等待启动 IF bStart AND NOT bStop THEN iStep : 10; END_IF; 10: // 初始化 IF bInitDone THEN iStep : 20; END_IF; 20: // 运行 IF bComplete THEN iStep : 30; END_IF; 30: // 完成复位 iStep : 0; END_CASE;这种步序写法比一堆if-else嵌套清晰得多也更容易调试——出问题时直接看iStep的值就知道卡在哪一步。4. 8种机型的差异如何抽象成参数4.1 差异分类参数差异 vs 逻辑差异把8种机型的差异摊开来看大致可以分成两类。第一类是参数差异行程位置、运行速度、延时时间、气缸数量。这类差异直接用REAL、INT、BOOL数组等类型的输入参数就能覆盖。第二类是逻辑差异某种机型多一个工位、某种机型少一个检测步骤。这类差异需要用条件分支来处理但分支的粒度要控制好。我的经验是尽量把逻辑差异也转化成参数差异。比如某种机型多一个工位不要写成IF iMachineType 3 THEN 多做一个动作而是定义一个iStationCount参数用循环来处理。这样FB内部逻辑是统一的只是循环次数不同。4.2 参数表的设计参数表的设计直接决定了复用的优雅程度。我通常会把参数分成三组运动参数、时序参数、功能开关。参数组参数名类型说明运动参数rStrokePosREAL行程位置单位mm运动参数rSpeedREAL运行速度单位mm/s运动参数rAccelREAL加速度单位mm/s²时序参数tClampDelayTIME夹紧延时时序参数tReleaseDelayTIME松开延时时序参数tTimeoutTIME超时报警阈值功能开关bHasStation3BOOL是否有第三工位功能开关bHasVacuumBOOL是否有真空检测功能开关bHasCylinder4BOOL是否有第四气缸这张表就是8种机型的配置清单。每种机型对应一组参数值存储在一个数据结构数组里运行时根据机型编号索引取出。4.3 用结构体组织参数GX Works3支持结构体STRUCT类型可以把上面这些参数打包成一个结构体TYPE T_MachineParam : STRUCT rStrokePos : REAL; rSpeed : REAL; rAccel : REAL; tClampDelay : TIME; tReleaseDelay : TIME; tTimeout : TIME; bHasStation3 : BOOL; bHasVacuum : BOOL; bHasCylinder4 : BOOL; END_STRUCT END_TYPE然后定义一个8元素的数组VAR_GLOBAL aMachineParams : ARRAY[1..8] OF T_MachineParam; END_VAR初始化时把8种机型的参数填进去。FB的输入参数只需要一个iMachineType内部通过aMachineParams[iMachineType]来取参数。这样FB的接口非常干净增加第9种机型时只需要扩展数组不需要改FB接口。5. 复用写法实战从框架到可运行代码5.1 FB内部的分层结构一个可复用的机型控制FB内部我通常分成四层参数解析层、步序控制层、动作执行层、报警处理层。参数解析层负责根据机型编号取出参数并做一些合法性检查。步序控制层是核心用CASE语句管理整个流程的状态机。动作执行层把步序翻译成具体的输出信号——气缸动作、轴运动、真空开关等。报警处理层监控超时、传感器异常等情况设置错误码。这种分层的好处是调试时可以快速定位问题在哪一层。如果步序卡住了看步序控制层如果动作没执行看动作执行层如果误报警看报警处理层。5.2 步序控制的ST实现步序控制的核心是一个CASE状态机。下面是一个简化但可运行的框架CASE iStep OF 0: // 空闲状态 bRunning : FALSE; bComplete : FALSE; iErrorCode : 0; IF bStart AND NOT bStop THEN iStep : 5; END_IF; 5: // 参数加载与检查 stParam : aMachineParams[iMachineType]; IF stParam.rStrokePos 0.0 OR stParam.rSpeed 0.0 THEN iErrorCode : 100; // 参数非法 iStep : 900; ELSE iStep : 10; END_IF; 10: // 初始化动作 bRunning : TRUE; // 复位所有输出 bCylinder1 : FALSE; bCylinder2 : FALSE; bVacuum : FALSE; tonDelay(IN : TRUE, PT : T#500MS); IF tonDelay.Q THEN tonDelay(IN : FALSE); iStep : 20; END_IF; 20: // 夹紧 bCylinder1 : TRUE; tonDelay(IN : TRUE, PT : stParam.tClampDelay); IF tonDelay.Q THEN tonDelay(IN : FALSE); iStep : 30; END_IF; 30: // 运行到目标位置 // 这里调用轴运动控制实际项目中替换为具体运动指令 IF bAxisInPosition THEN iStep : 40; END_IF; // 超时监控 tonTimeout(IN : TRUE, PT : stParam.tTimeout); IF tonTimeout.Q THEN iErrorCode : 200; // 运动超时 iStep : 900; END_IF; 40: // 加工动作 IF stParam.bHasVacuum THEN bVacuum : TRUE; END_IF; tonDelay(IN : TRUE, PT : T#1S); IF tonDelay.Q THEN tonDelay(IN : FALSE); iStep : 50; END_IF; 50: // 松开与复位 bCylinder1 : FALSE; bVacuum : FALSE; tonDelay(IN : TRUE, PT : stParam.tReleaseDelay); IF tonDelay.Q THEN tonDelay(IN : FALSE); bComplete : TRUE; iStep : 60; END_IF; 60: // 等待完成信号被确认 IF NOT bStart THEN bComplete : FALSE; iStep : 0; END_IF; 900: // 错误处理 bRunning : FALSE; // 所有输出复位 bCylinder1 : FALSE; bCylinder2 : FALSE; bVacuum : FALSE; IF bStop THEN iErrorCode : 0; iStep : 0; END_IF; END_CASE;这段代码的关键在于所有机型差异都通过stParam来体现步序逻辑本身是统一的。bHasVacuum为FALSE的机型第40步就跳过真空动作但步序编号不变流程结构一致。5.3 多机型调用的组织方式8种机型可能对应8台设备也可能是一台设备切换8种配方。如果是8台设备就实例化8个FB每个传入不同的机型编号。如果是一台设备切换配方就实例化1个FB机型编号来自HMI选择。// 方案一8台设备 fbMachine1(iMachineType : 1, ...); fbMachine2(iMachineType : 2, ...); // ... 以此类推 // 方案二单台设备切换配方 fbMachine1(iMachineType : iSelectedRecipe, ...);方案二更简洁但要注意切换配方时FB必须处于空闲状态否则步序会乱。我的做法是在HMI上做互锁运行中不允许切换配方。6. 调试阶段踩过的坑与排查链路6.1 定时器不复位导致的步序卡死这是我踩过的第一个坑。FB内部用了TON定时器步序跳转后忘记复位定时器的IN信号导致下一个步序用到同一个定时器时Q输出一直是TRUE步序瞬间跳过。排查过程是这样的现场发现设备运行到某一步后直接跳到完成中间的动作全部没执行。打开GX Works3的监视模式看iStep的值发现它从20直接跳到了50中间的30和40一闪而过。再看tonDelay.Q发现它一直是TRUE。原因就是第20步结束后没有把tonDelay的IN置FALSE第30步一进来tonDelay.Q还是TRUE立刻又跳转。修复方法很简单每个步序结束时把定时器IN置FALSE。但更好的做法是给每个需要延时的步序分配独立的定时器实例避免互相干扰。FB内部声明多个TON变量tonDelay1、tonDelay2、tonDelay3各管各的。6.2 参数数组越界机型编号来自HMI如果HMI传了一个0或者9aMachineParams[iMachineType]就会越界。GX Works3对数组越界的处理是——不报错但读到的是垃圾数据。这比报错更危险因为程序会带着错误参数继续运行。我的做法是在FB内部加一道检查IF iMachineType 1 OR iMachineType 8 THEN iErrorCode : 101; // 机型编号非法 iStep : 900; END_IF;同时在HMI侧做输入限制双保险。6.3 多实例共享全局变量的陷阱前面提到过FB内部不要直接访问全局变量。我踩过的坑是两个FB实例都读取了同一个全局速度变量结果一台设备调速度另一台也跟着变。排查时一度以为是FB内部状态串了后来才发现是全局变量惹的祸。修复方法就是把速度参数也纳入机型参数结构体每个实例从自己的参数结构体里取彻底切断全局依赖。6.4 步序编号冲突当FB内部步序比较多时步序编号容易冲突。比如两个不同的分支都用了步序30但含义不同。调试时看iStep30根本分不清是哪个分支。我的经验是给步序编号分段0-99是正常流程100-199是手动模式200-299是回零流程900以上是错误处理。这样一看编号就知道大概在哪个阶段。7. 从能跑到好用几个提升复用质量的细节7.1 给FB加一个版本号FB一旦被多个项目引用修改时就要考虑兼容性。我习惯在FB内部加一个版本号常量VAR CONSTANT FB_VERSION : INT : 102; // 1.02版本 END_VAR这样在HMI上可以显示当前运行的FB版本现场排查时能快速确认设备上跑的是哪个版本的程序。7.2 错误码要成体系错误码不要随便编。我通常按模块分段100-199是参数错误200-299是运动错误300-399是传感器错误400-499是通讯错误。每个错误码对应一个明确的含义并且在HMI上显示对应的处理建议。错误码含义处理建议100参数非法检查机型参数配置101机型编号越界检查HMI机型选择200运动超时检查机械是否卡阻201轴未使能检查伺服使能信号300传感器异常检查传感器接线与供电400通讯超时检查通讯线缆与站号7.3 预留扩展接口8种机型不是终点客户随时可能加第9种。FB设计时要预留扩展空间。参数数组定义为1..16实际只用1..8后面8个空着。功能开关多留几个BOOL暂时不用但先声明着。这样加新机型时大概率不需要改FB接口只需要填参数。7.4 注释要写为什么而不是是什么ST代码的注释很多人写成// 步序10这种没什么信息量。我建议注释写为什么// 步序10初始化复位所有输出防止上电残留动作。这样过半年回来看能快速回忆起当时的意图。8. 写在最后复用不是目的可维护才是做了这么多年项目我越来越觉得代码复用的价值不在于少写几行而在于改一处就能全局生效。1个FB适配8种机型省下的不只是初次开发的时间更是后期无数次修改时的安心感。你不需要记住8份程序分别改了什么只需要维护一个FB和一张参数表。当然复用也有代价。FB的抽象层次越高调试时就越需要理解FB内部的逻辑。如果FB写得过于复杂新人接手时反而更难上手。所以我的原则是复用要适度FB内部逻辑要清晰参数表要一目了然。宁可多写几个基础FB组合使用也不要把一个FB写成万能黑盒。最后分享一个实用习惯每次改完FB我会用一台虚拟机型比如机型0跑一遍全流程仿真确认所有分支都能走通。这个习惯帮我拦住了好几次改A机型影响了B机型的隐性bug。GX Works3的仿真功能很好用不用连实际PLC就能验证逻辑建议你也把这个步骤纳入日常开发流程。