
先说说我被8种机型折腾的那段日子。项目是一个包装设备家族同一条产线上要放8款机器控制逻辑搭眼一看差不多但细抠下来各有各的脾气传感器类型不一样执行机构不一样通信协议不一样连启动时序都拧着来。按老思路写程序每个机型复制一份代码、维护一套变量表结果就是每次改工艺参数要重复8遍漏改一个就是半夜被电话叫醒的命。后来我用结构化文本ST写了一个设备模板FB把8种机型的控制逻辑收敛进同一个功能块所有差异全部外置成配置数据用8个FB实例在线上并行跑才把这一摊事彻底理顺。这篇就把这套ST代码复用架构完整拆开讲。核心就一句话把大逻辑写成固定代码把小差异全部变成数据。文章会覆盖差异分析怎么做、FB接口与数据结构怎么设计、配置表怎么落地、实例化之后资源占用和调试实测什么样以及我迭代过程中踩过的坑。做非标自动化、多机型设备程序架构的朋友或者正在被一机一程序维护量折磨的人这篇应该能帮到你。1. 8种机型把我逼到重构复制粘贴的代码库终于失控了1.1 一机一程序的原始状态有多痛先说清楚这里的两个缩写免得被搜索词带偏ST是IEC 61131-3标准里的结构化文本Structured TextFB是功能块Function Block。在PLC编程里ST语言适合写算法和数据处理FB则是代码复用的基本单位。我说的设备模板FB就是拿一个功能块当设备控制模板实例化8次分别服务8种机型。最初的程序结构是怎么烂掉的很简单一开始项目只有A型和B型两款机器我复制了一份程序把I/O映射改掉把PID参数换掉完事。后来加C型、D型……一直加到8款每一款都是从前一版里CtrlC、CtrlV出来的。代码仓库里飘着8份几乎一样的工程区别全藏在几百个变量的命名后缀里。真正失控的点有三个。第一修bug的成本是8倍。同一个执行机构抖动问题A型里改了B型漏了C型改的方式还不一样最后只能挨个打开比对。第二变量表完全不对齐。A型的nChannelCount是INT型到D型变成了WORD通讯地址映射也各写各的维护工程师到现场看程序先得翻手机备忘录确认这是哪一款的变量命名规则。第三也是最要命的谁都不敢加第9种机型了。因为根本说不清复制一份程序之后要改哪些地方改错一个地址整个工艺段都给你罢工。那段时间我被折腾得没脾气才开始认真想一个问题这8款机器的控制逻辑本质上到底差在哪1.2 为什么我选择FB模板而不是写8个FC函数有人可能会说你写8个FCFunction函数调用的时候传不同参数不也能复用吗确实能但FC有个硬伤它没有自己的记忆区。做过顺序控制的人都懂设备控制里最核心的不是那一两条数学公式而是状态。电机当前在不在运行、计时器走没走完、工艺步进到第几步、上一次的故障码是什么这些状态必须存在某个地方。FC每次被调用外部必须把所有状态变量准备好传进去、再传出来等于把记忆体散落在各个程序段里。8个机型要维护8套状态变量和复制整份程序的差别只是少复制了几个计算函数。FB就不一样。FB自带背景数据块或者说实例数据区每次实例化都有自己独立的一块内存。这意味着什么代码只有一份数据有8份。A型运行到工艺第5步B型运行到第3步C型在报故障各自的数据互不干扰。这正是多机型场景最需要的特性。我当时还有一个判断标准这8款机器的差异究竟是参数差异还是流程差异。如果是流程本质不同比如一个是连续运转、另一个是离散批次那硬做一个模板FB会把自己写死。但把8款机器的工艺捋了一遍之后我发现它们的骨架高度一致——都是进料、定位、工艺处理、出料、报警差的只是每一步的执行方式、数值、接口和次序。这就是典型的共性大于差异非常适合模板化。后面我会专门讲差异分析的具体方法那是整个架构的起点。2. 先拆差异再写代码8种机型的变与不变2.1 用一张表把8种机型的差异摊开动手写FB代码之前我花了大概一周时间把8种机型的所有控制需求列成了差异矩阵。这一步特别重要它决定了你后面是把差异参数化还是被差异牵着走。下面是我当时梳理的简化版机型代号做了脱敏处理机型代号控制通道数反馈传感器执行机构通信方式控制模式A 直线注液机4PT100热电阻伺服电机Modbus RTU恒温PIDB 转盘灌装机64-20mA变送器变频器Profibus DP恒压PIDC 双工位贴标机80-10V传感器比例阀EtherCAT随动跟踪D 旋盖机2增量编码器步进电机Modbus TCP位置闭环E 封箱机4光电开关气缸本地I/O顺序控制F 称重剔除机8称重变送器伺服气缸Profinet张力控制G 打包机24-20mA变送器变频器Modbus RTU恒速控制H 检测分拣机6视觉I/O信号伺服电机EtherCAT同步跟随这张表摊开之后结论非常清晰8款机器的控制骨架完全一致都存在传感器采集→设定比较→控制器计算→执行机构驱动→状态反馈这条链路顺序控制也都有就绪、启动、运行、停止、报警这五个大阶段。真正的差异集中在四个维度上。模拟量标度PT100、4-20mA、0-10V、称重变送器电气信号不同工程量换算公式也不同。执行机构驱动方式伺服要使能和脉冲/总线位置给定变频器要启停和频率给定比例阀要模拟量开度给定气缸是开关量逻辑。控制算法多数机型是PID但D型是位置闭环E型是纯开关逻辑H型是同步跟随。时序细节同样是启动A型要先加热再进料B型要先把转盘定位到位才允许灌装C型则要求输送带和贴标头同步后才开始贴。2.2 差异归类哪些能参数化哪些必须走状态机对着差异矩阵做归类我当时的判断准则是凡是能用一个数值或者一个枚举值描述的差异全部参数化凡是需要用先做A再做B再判断C才能描述的差异交给状态机加配置步骤表处理。第一类纯参数差异。量程上限、量程下限、PID的Kp/Ti/Td、目标设定值、额定速度、加减速时间、通信站号。这些直接做成FB的配置结构体成员一个实数或一个整数就搞定。第二类接口差异。传感器类型、执行机构类型、控制通道数量这属于枚举型配置在FB内部用CASE语句根据枚举值选择标度转换公式和驱动分支。第三类时序差异。这个最难参数化但也不是不能做把每款机型的工艺步骤拆成步骤表每一步记录步骤类型等待、移动、加热、灌装、检测等、目标值和持续时间FB内部用一个状态机解释执行这张表。这样设计的关键收益是新增机型时不需要碰FB本体代码只需要新增一张配置表。如果差异里混进了无法用数据表达的流程那这个架构就会开始变形我把这种情况放到后面适用边界一节单独讲。总之先区分变与不变是这套架构的根。根扎稳了后面写代码才不慌。3. 模板FB的接口与内部结构让差异全部从引脚流进来3.1 接口设计思路IN_OUT配置结构体比几十个引脚更可控把差异全部参数化之后第一个设计问题来了参数往哪放不少人的第一反应是全部做成VAR_INPUT引脚比如rScaleLow、rScaleHigh、eSensorType、eActuatorType、加一个stPID……8款机型要用的参数加起来有几十个。真这么做调用FB的时候光实参就要排好几行可读性灾难。我当时的选择是所有机型差异打包成一个配置结构体通过VAR_IN_OUT传入FB。结构体在工业PLC世界里很常见可以把散乱的变量组织成一块数据比如ST_PackCfg就是一个机型的全部配置。这里给一个接近实际工程的简化定义TYPE ST_ChannelMap : STRUCT eSensorType : INT; (* 0PT100, 14-20mA, 20-10V, 3称重复位 *) eActuatorType : INT; (* 0伺服, 1变频, 2比例阀, 3气缸 *) rScaleLow : REAL; (* 工程量下限 *) rScaleHigh : REAL; (* 工程量上限 *) rSetpoint : REAL; (* 该通道目标值 *) nIoRef : INT; (* 通道对应的全局IO地址索引 *) END_STRUCT END_TYPE TYPE ST_PidParam : STRUCT rKp : REAL; rTi : REAL; rTd : REAL; rOutLimitHigh : REAL; rOutLimitLow : REAL; END_STRUCT END_TYPE TYPE ST_PackCfg : STRUCT iVariantID : INT; (* 机型ID必须与外层输入一致 *) iChannelCnt : INT; (* 活动通道数 *) eComProtocol : INT; (* 0ModbusRTU, 1Profibus, 2EtherCAT *) tBaseCycle : TIME; (* 基准节拍 *) bUseTracking : BOOL; (* 是否启用随动跟踪 *) pidMain : ST_PidParam; (* 主回路PID *) aChannels : ARRAY[1..8] OF ST_ChannelMap; aSteps : ARRAY[1..32] OF ST_ProcessStep; (* 工艺步骤表 *) END_STRUCT END_TYPE为什么用IN_OUT而不是IN原因有两个。第一IN_OUT在多数PLC运行时系统里是按引用传递大结构体不会在调用时整个复制一遍性能开销小。第二交互意图更明确——配置结构体本质上是调用方维护、FB读取执行的共享数据IN_OUT的保护级别比VAR_INPUT稍低但更灵活适合配方切换。ST语言里结构体仅作输入的时候用IN也完全可以但一旦涉及配方切换和在线修改IN_OUT是更顺手的选择。3.2 FB的接口代码长什么样FB的接口区我设计得比较简单核心原则是运行参数走引脚静态配置走结构体。运行参数是每一周期的实时值比如启动命令、手动模式、反馈过程值静态配置是几乎不变的数据比如机型通道数、量程、PID参数。这样调用者一眼就能看出哪些是实时变化的哪些是固化配置。FUNCTION_BLOCK FB_PackDevice VAR_INPUT bEnable : BOOL; (* 硬件使能 *) bAutoStart : BOOL; (* 自动启动 *) bManualCmd : BOOL; (* 手动干预命令 *) eMachineVariant : INT; (* 机型编号 1-8 *) rProcessValue : REAL; (* 过程反馈值 *) END_VAR VAR_OUTPUT bRunState : BOOL; (* 运行状态 *) bAlarm : BOOL; (* 故障汇总 *) dwDiagCode : DWORD; (* 诊断码 *) rControlOut : REAL; (* 控制输出 *) END_VAR VAR_IN_OUT stCfg : ST_PackCfg; (* 机型配置结构体 *) END_VAR VAR wState : INT; (* 内部状态机状态 *) byStepIndex : BYTE; (* 工艺步骤表指针 *) fbPidMain : FB_Pid; (* 内部PID功能块实例 *) tonStep : TON; (* 工艺等待计时器 *) bCfgMatch : BOOL; (* 配置匹配标志 *) END_VAR注意我在接口里放了一个eMachineVariant输入和一个stCfg.iVariantID。这两个值必须一致FB每次扫描都会校验不匹配直接报错。这个设计帮我挡掉了后面好几次配置错绑的事故细节放到第6章讲。3.3 内部状态机一种代码、多套流程路径FB内部的灵魂是一个状态机。它把前面拆出来的时序差异用统一的执行框架消化掉。工艺流程被拆成一张步骤表每一步都描述做什么、持续多久、哪个条件算通过。FB只做一件事按照步骤表的索引一步一步往下推进。CASE wState OF 0: (* 待机状态 *) IF bEnable AND bAutoStart AND NOT bAlarm THEN byStepIndex : 0; wState : 10; END_IF; 10: (* 执行工艺步骤 *) // 当前步骤类型由配置表决定 IF stCfg.aSteps[byStepIndex].eType STEP_WAIT_TIME THEN tonStep(IN : TRUE, PT : stCfg.aSteps[byStepIndex].tTime); IF tonStep.Q THEN tonStep(IN : FALSE); byStepIndex : byStepIndex 1; IF byStepIndex stCfg.iChannelCnt THEN wState : 20; END_IF; END_IF; ELSIF stCfg.aSteps[byStepIndex].eType STEP_SET_TARGET THEN rControlOut : stCfg.aSteps[byStepIndex].rValue; byStepIndex : byStepIndex 1; END_IF; 20: (* 工艺完成 *) bRunState : TRUE; 99: (* 故障处理 *) IF bAlarm THEN rControlOut : 0.0; ELSIF bAutoStart THEN wState : 0; END_IF; END_CASE;这个状态机的关键点在于迁移规则是代码写死的但具体走哪条步骤序列、每一步干什么完全由配置表决定。A型机器的启停时序做成aSteps配置B型机器的时序做成另一份配置FB本身一个字都不用改。这比在FB里写IF iVariantID 1 THEN ... CASE iVariantID OF 2 ...要干净得多。我见过有人把8种机型的时序写成8个CASE分支那种模板其实只是把复制粘贴从工程级转移到了函数级本质上还是魔鬼分支维护起来照样痛。4. 配置表即代码把机型差异写成数据4.1 数据结构定义与标度转换的实际写法第3章的结构体定义里有一个ST_ProcessStep这里展开一下工艺步骤表的数据结构。本质上它是把工艺时序翻译成数据描述TYPE ST_ProcessStep : STRUCT eType : INT; (* 0等待时间, 1设定目标值, 2等待条件满足, 3调用子流程 *) tTime : TIME; (* 等待型步骤持续时间 *) rValue : REAL; (* 设定目标值或判定阈值 *) nIoIdx : INT; (* 条件判定用的IO索引 *) END_STRUCT END_TYPE有了这个结构A型机型的启动后先加热10秒就能写成一条步骤eType : 0, tTime : T#10SB型机型的转盘定位到1号工位写成eType : 3, nIoIdx : 31, rValue : 1.0。FB本体只负责解释执行不关心具体是哪个机型。传感器标度转换是另一个典型的配置驱动逻辑。不同传感器的电气信号和工程量之间不是线性都能直接用一个公式算我把它做成了按枚举分支的工具函数// 根据配置中的传感器类型把原始电气信号换算成工程量 CASE stCfg.aChannels[nIdx].eSensorType OF 0: (* PT100按电阻-温度线性表简化处理 *) rPV : (rRaw / 100.0) * (rHi - rLo) rLo; 1: (* 4-20mA减零点再按16mA量程展开 *) rPV : ((rRaw - 4.0) / 16.0) * (rHi - rLo) rLo; 2: (* 0-10V直接按10V量程展开 *) rPV : (rRaw / 10.0) * (rHi - rLo) rLo; 3: (* 称重变送器按变送器满量程标定 *) rPV : rRaw / 100.0 * (rHi - rLo) rLo; END_CASE这段代码看起来普通但它背后有一个设计决策我从来没有在FB本体里写如果机型是A就做PT100标度这类硬编码。机型信息只是配置表里的一个ID代码分支只认传感器类型的枚举值不认机型。这样当新增的I型机器用了一种全新的传感器我只需要在配置枚举里加一个新类型其他地方全部不动。这就是把差异当数据写和把差异当代码写的本质区别。4.2 配置初始化与新增机型的标准操作配置数据放在哪里也需要想清楚。我当时是在全局常量区定义每款机型的配置实例编译后固化进PLC。这样做的好处是配置不会被现场误改缺点是灵活性差一些。如果你希望支持现场配方切换可以把配置放到带掉电保持的数据块或配方存储区FB的调用方式不变只是配置来源不同。下面的代码展示了两款机型的配置初始化方式VAR_GLOBAL CONSTANT cfgTypeA : ST_PackCfg : ( iVariantID : 1, iChannelCnt : 4, eComProtocol : 0, pidMain : (rKp : 2.5, rTi : 120.0, rTd : 0.0, rOutLimitHigh : 100.0, rOutLimitLow : 0.0), aChannels : [ (eSensorType : 0, eActuatorType : 0, rScaleLow : 0.0, rScaleHigh : 150.0, rSetpoint : 80.0, nIoRef : 1), (eSensorType : 0, eActuatorType : 0, rScaleLow : 0.0, rScaleHigh : 150.0, rSetpoint : 80.0, nIoRef : 2), // ... ], aSteps : [ (eType : 0, tTime : T#10S), // 启动后预热 (eType : 3, nIoIdx : 10), // 等待进料到位 (eType : 1, rValue : 80.0) // 设定目标值进入PID调节 // ... ] ); cfgTypeB : ST_PackCfg : ( iVariantID : 2, iChannelCnt : 6, // B机型的不同之处其余成员结构相同 ); END_VAR我不建议把8份配置都贴在主程序里那样主程序会很难看。更合理的做法是建一个独立的机型配置库POU/库文件里面只放配置定义和对应的初始化函数。新增机型的时候我的标准流程是六步从最接近的现有配置复制一份数据结构在配置库里填写新机型的机型ID、通道数、量程、PID参数、步骤表检查枚举值定义如有新传感器或执行机构类型先在FB的CASE分支里补上对应处理在程序组织单元里声明一个新的FB实例名如fbPack_TypeI调用FB并绑定新配置编译、仿真、用全部现有机型做回归验证。这套流程走熟之后一个新机型从需求梳理到程序写完基本就是半天到一天的事而不是过去的一周起步。5. 8个实例同时在线的编译与实测资源占用和运行验证5.1 实例化与调用方式一份代码、八份内存FB的实例化在ST语言里就是声明变量。你可以同时声明8个FB实例每一个都拥有独立的背景数据区。这是整个架构能一个模板打8种机型的底气代码空间只占一份数据空间占八份。VAR fbPack_A : FB_PackDevice; (* 直线注液机 *) fbPack_B : FB_PackDevice; (* 转盘灌装机 *) fbPack_C : FB_PackDevice; (* 双工位贴标机 *) // 其余机型同理 END_VAR周期性调用时每一个实例绑定自己的配置结构和I/O映射。注意stCfg是IN_OUT所以实参必须是一个变量不能是一个常量表达式。我在代码里把配置变量名与FB实例名一一对应光看名字就能知道哪台机器在跑哪份配置fbPack_A( bEnable : bEnable_A, bAutoStart : bStart_A, eMachineVariant : 1, rProcessValue : rPV_A, stCfg : cfgTypeA, bRunState bRun_A, bAlarm bAlarm_A, dwDiagCode dwDiag_A, rControlOut rOut_A ); fbPack_B( bEnable : bEnable_B, bAutoStart : bStart_B, eMachineVariant : 2, rProcessValue : rPV_B, stCfg : cfgTypeB, // ... );有一些PLC平台支持把FB实例放在数组里比如fbPack : ARRAY[1..8] OF FB_PackDevice这样可以用FOR循环统一调用。这是个诱人的写法但实际工程里我一般不推荐直接用数组实例原因有两个一是不同机型的硬件I/O映射差异大循环体内很难统一处理外部IO绑定二是数组实例在在线调试时变量名可读性差fbPack[3].wState看着就不如fbPack_C.wState直观。如果真的要循环我会选择在FB内部完成通道级循环实例之间的调用仍然逐个写清楚。效率和可维护性之间我永远选后者。5.2 扫描周期、内存占用与调试实测数据一个FB跑8个实例PLC扛得住吗这是很多人一听模板化就会问的。从代码量上看FB的代码段只有一份无论实例化多少次指令代码在内存里只存一遍。增加的是背景数据块一个实例的配置结构体加内部变量以ST_PackCfg为例大约占200到500字节8个实例加起来不超过4KB对现在的PLC来说基本可以忽略。扫描周期的影响主要看FB内部的运算量。以我实测的倍福CX5140为例程序整体扫描周期1ms8个实例全部投入运行之后FB相关代码段带来的额外扫描时间不到0.3ms不影响工艺节拍。但在老款PLC上结构体复制和CASE分支判定的消耗会明显一些所以我的建议是配置结构体里的数组不要贪大比如通道数组够8个通道就行别拍脑袋写32个工艺步骤表如果有些机型只需要8步也不需要统一扩到64步。宁可多定义几个不同尺寸的结构体变体也别让所有机型为最大配置买单。调试阶段我习惯用PLC的监控表同时展开8个实例的关键变量比如wState、byStepIndex、bAlarm、dwDiagCode。模板FB写得好调试基本是看状态机走到哪一步的问题因为所有机型共用一套状态定义。如果哪个实例异常直接对比正常实例的wState和byStepIndex差异一眼就能看出来不用再翻8份程序找差异。对于PID响应这类动态量我会用Scope曲线同时录制A型和B型的过程值和控制输出确认量程与PID参数是否正确。这一步在投产前做一次能省掉现场一半的调机时间。6. 后续迭代的坑与风险控制模板不是写完就一劳永逸6.1 改模板等于改全场版本管理必须跟上模板FB带来的一个副作用是它把维护成本从8份代码压缩成了1份代码8份配置但代价是改FB本体的时候所有机型全部跟着变。这既是优势也是风险。我曾经在优化执行机构抖动时顺手改了FB内部一个时序分支本意是解决A型的问题结果B型、C型、F型的启动时序全都受了影响。那次之后我给自己定了几条铁律FB本体代码一旦发布稳定就进入变更评审状态任何修改都要记录变更原因、影响机型清单和回归测试结论。配置表单独维护版本号。我在ST_PackCfg里放了一个wCfgVersion字段FB内部校验配置版本与程序要求的基准版本一致不一致直接报警提示。升级前在离线仿真环境里把8款机型的配置全部跑一遍哪怕只改了一个机型参数也要做全量回归。因为配置结构体是共享的字段位置一旦变更所有配置初始化代码都要同步。用版本管理工具管理PLC工程导出文件每次迭代留好对比基线别靠现场还有个旧版备份这种直觉。这个风险控制思路不是PLC独有任何做软件复用的人都明白公共代码改动影响面大的道理但在PLC项目的现场氛围里最容易被人忽略因为大家习惯改完赶紧下载看看。6.2 配置表错绑与在线修改两个必须堵住的事故口子模板化之后最典型的事故不是程序写错而是配置表绑错实例。现场工程师在调试D型机器时如果打开了A型机器的工程把参数写进A型配置然后D型怎么调都不对排查起来非常绕。我在FB接口里加eMachineVariant输入和stCfg.iVariantID校验就是为了堵这个口子。FB每个扫描周期都会比对这两个值不一致就把dwDiagCode置成一个固定诊断码同时置位bAlarm。这个简单的校验让我后来少接了无数个现场电话。另一个事故口子是在线修改配置。PLC的在线下载有个特点当你修改了全局配置数据块并在线下载正在运行的实例会在下载瞬间读到不完整或临时的数据。如果FB内部正在执行CASE wState OF的状态机配置表一变步骤索引可能越界。解决办法有两个一是把配置修改都规划到停机窗口再做二是如果必须在线改至少要在FB内部做越界保护和配置更新完成标志位判定确保步骤指针在配置切换前被强制拉回待机状态。我曾经因为图省事在线改过一次PID参数结果步骤表指针被改成了一个很大的数设备当场停在半道从那以后再也不敢小看在线下载的时机问题。6.3 架构适用边界什么时候别硬套模板写到最后我想把话说得平衡一点。这套设备模板FB覆盖8种机型的架构确实好用但并不是所有项目都该这么上。从我自己的实践看它成立有三个前提共性大于差异、差异能用数据描述、机型数量不少于3个且大概率还会增加。如果只有两款机器凑合复制也还好模板化的设计成本反而更高如果两款机器一个做连续热成型、一个是间歇式批次罐装流程骨架压根不在一起硬塞进一个FB会导致内部状态机全是分支代码可读性和维护性都会显著恶化。边界判断其实有一个朴素的办法你写FB内部代码的时候如果发现需要根据机型ID频繁改写执行逻辑而不是根据配置枚举值做分支那就是架构已经越界的信号。合理状态应该是——FB本体完全不认识机型它只认配置表里的枚举和数值。对我个人来说这套架构最大价值不是省了多少行代码而是它逼着我先把8款机器的差异彻底想清楚了。想清楚之后写程序反而变成一件很无聊的事填配置、验证配置、跑回归。我现在的固定流程是接到新机型需求先做差异矩阵再做配置表设计代码改动通常只集中在枚举分支和极个别新算法上。这种按数据应变、不按机型硬编码的思路已经成了我做多机型设备的默认习惯了。