1. 为什么这三个概念总让人犯迷糊刚接触CODESYS那会儿我对着新建POU的对话框愣了好几分钟——FUN、FB、PRG三个选项摆在那儿文档里各有一段定义翻来覆去读还是拿不准该点哪个。后来在项目里踩了几次坑才慢慢摸清这三个东西不是难度分级而是三种完全不同的代码组织契约选错了不会报错但会在后期维护、状态管理、扫描周期上给你埋雷。这篇内容面向的是已经能写梯形图或ST、但对POU类型选择还靠感觉的工程师也适合带新人时拿来当对照材料。核心就一件事把FUN函数、FB功能块、PRG程序三者的本质差异讲透然后落到真实工况里——什么时候必须用FB、什么时候用FUN反而更干净、PRG为什么不能随便当万能容器用。我会按设计思路—核心差异—实操落地—踩坑排查的顺序展开每个结论都尽量给出背后的逻辑而不是甩一句官方推荐。先说清楚一个前提CODESYS里这三者都叫POUProgram Organization Unit本质都是代码封装单元区别在于有没有内部状态、能不能多实例、调用方式如何。理解这一点后面所有选择都是它的推论。2. 三者本质差异的底层拆解2.1 从有没有记忆这个角度切入FUN最纯粹。它没有内部状态每次调用都是输入进去、结果出来同样的输入永远得到同样的输出。你可以把它想象成计算器按3加5永远得8计算器不记得你上次算过什么。CODESYS里标准的ADD、MUL、SEL、LIMIT都是FUN你自己写的摄氏度转华氏度取绝对值字符串拼接也应该是FUN。FB不一样它有记忆。FB内部可以声明变量并保持值每次调用时这些变量保留上一次的状态。典型例子是定时器TON、计数器CTU、PID控制器——它们必须记住已经计时多久计到几了上一次的积分项是多少。FB的调用需要先声明一个实例instance比如fbTimer1 : TON;然后fbTimer1(IN : xStart, PT : T#5S);。这个实例就是它的记忆载体。PRG则是程序组织单元里的执行入口。它没有实例化的概念在标准工程里PRG是被任务直接调用的内部变量是全局可见的在资源范围内每次扫描周期都会被调用一次。PRG更像是一个场景脚本或者装配车间负责把FUN和FB组织起来、按顺序执行。2.2 调用机制与内存模型的对照把三者的关键属性拉成一张表比读十页手册都直观维度FUN函数FB功能块PRG程序内部状态无有随实例保持有全局保持是否需要实例不需要必须声明实例不需要任务直接调用多实例支持天然支持无状态支持每个实例独立不支持多实例返回值有且仅有一个返回值无返回值通过输出引脚无返回值调用方式表达式内直接调用实例名(参数)任务配置调用典型代表ADD、SEL、自定义换算TON、CTU、PIDMain、各工位主程序递归调用允许需注意不允许不适用这张表里最容易被忽视的是多实例支持这一行。FUN因为无状态你可以在十个地方同时调用同一个FUN互不干扰FB如果只声明了一个实例却在两个地方调用那两个地方会共享同一份状态——这是新手最常犯的错误之一后面会专门讲。2.3 为什么CODESYS要设计这三种而不是一种有人会问既然FB这么强大有状态又能封装为什么不全部用FB这个问题问得好答案藏在确定性和可测试性里。FUN的无状态特性意味着它是纯函数——给定输入必然得到确定输出不依赖任何历史。这让它极易测试、极易复用、极易做单元验证。你写一个根据温度区间返回报警等级的FUN可以脱离整个工程单独验证它的逻辑输入-20度看返回什么、输入85度看返回什么一目了然。如果把它写成FB你就得先造实例、模拟扫描周期、观察内部变量测试成本高一个量级。反过来定时器这种必须记住时间的逻辑用FUN根本实现不了FUN没有地方存上次调用时间。所以三者不是替代关系而是按是否需要记忆和是否需要被任务调度两个维度切分出来的正交设计。选型的第一个问题永远是这段逻辑需要记住上一次的状态吗3. 选型决策什么场景该用哪个3.1 FUN的适用边界与典型场景FUN最适合输入决定输出、与历史无关的纯计算逻辑。我在项目里固定用FUN处理的有几类单位换算与标定模拟量原始值转工程值比如4-20mA对应的0-100范围映射这类逻辑每次都是纯计算用FUN最干净。数据合法性判断给定一个传感器读数返回它是否在合理区间、属于哪个报警等级。位操作与编码解码把一个WORD拆成若干BOOL、把几个状态位打包成一个字节。数学与字符串处理限幅、取整、格式化。写FUN有个硬性约束要记住FUN内部不能有需要跨周期保持的变量。你在FUN里声明VAR临时变量没问题但它的生命周期只在本次调用内下次调用就重置了。如果你在FUN里写了个边沿检测逻辑想记住上次的输入那是无效的——因为上次的值根本没存下来。这种需求必须换成FB。提示FUN的返回值类型在声明时就固定了一个FUN只能返回一个值。如果需要一次算出多个结果要么拆成多个FUN要么改用带多个输出引脚的FB。别为了省事在FUN里用VAR_IN_OUT硬塞多个输出那会破坏FUN的纯粹性也让调用方难以理解。3.2 FB的不可替代性与实例化要点FB的价值在于封装带状态的设备或过程。真实工况里几乎所有的设备对象都该用FB一台电机、一个阀门、一个气缸、一个PID回路、一个通信从站。因为它们都有状态——电机有当前是否运行故障锁存阀门有开到位/关到位动作超时PID有积分累积。用FB封装设备的好处是一个FB定义、N个实例复用。比如你写了一个电机控制FB里面封装了启停逻辑、故障锁存、运行超时判断那么现场20台电机就声明20个实例每个实例独立维护自己的状态代码只写一遍。这是FB相对于复制粘贴20段PRG代码的碾压性优势。实例化的关键点在于实例声明在哪里状态就存在哪里。实例可以声明在PRG里作为该程序的局部变量也可以声明在另一个FB里形成嵌套。声明位置决定了它的作用域和生命周期。我见过有人在FB内部又声明了一个TON实例这是完全合法的也是嵌套封装的常见手法——外层FB管业务逻辑内层TON管延时。3.3 PRG的定位调度者而非计算者PRG最容易被误用。很多人把PRG当成主函数把所有逻辑一股脑塞进去结果一个PRG几千行改一处牵动全身。PRG的正确定位是任务调度的装配层它负责按工艺顺序调用各个FB实例、调用FUN做计算、处理跨设备的协调逻辑但它自己不应该承载太多设备级的细节。在CODESYS的任务配置里PRG是被主任务周期性调用的。你可以建多个PRG分别挂到不同任务上比如一个快速任务跑10ms、一个慢速任务跑100ms实现不同扫描周期的分离。这是PRG相对于FB/FUN的独有能力——它可以被任务直接调度。FB和FUN本身不能被任务直接调用必须被某个PRG或FB调用才行。所以PRG的选型判断很简单这段逻辑是不是工程的主干流程、是不是需要被任务周期性驱动、是不是要协调多个设备是就用PRG。如果它只是某台设备的控制细节那应该下沉到FB里。3.4 一张决策流程图文字版我把选型逻辑压成几个连续判断实际用的时候按顺序问自己这段逻辑需要被任务直接周期调用吗需要 → PRG。不需要的话它需要记住上一次调用的状态吗需要 → FB。不需要记忆、纯输入决定输出 → FUN。如果既需要记忆又需要多实例 → FB声明多个实例。如果需要返回多个结果但无状态 → 拆多个FUN或用带输出引脚的FB。这个顺序不能颠倒。先问要不要被调度再问要不要记忆最后才落到FUN。很多人一上来就想我用FB吧反正强大结果把一个纯换算逻辑写成了FB白白增加了实例管理负担。4. 实操落地三类POU的完整写法对照4.1 用FUN写一个模拟量标定函数假设现场有个4-20mA的压力变送器量程0-1.6MPaPLC的AI模块把4-20mA转成0-27648的整数。我要写一个FUN把原始值转成工程值。声明部分FUNCTION FUN_ScalePressure : REAL VAR_INPUT iRaw : INT; // AI原始值 0-27648 rRawMin : REAL : 0.0; rRawMax : REAL : 27648.0; rEngMin : REAL : 0.0; rEngMax : REAL : 1.6; END_VAR VAR rRatio : REAL; END_VAR实现部分用线性映射rRatio : (iRaw - rRawMin) / (rRawMax - rRawMin); FUN_ScalePressure : rEngMin rRatio * (rEngMax - rEngMin);这里有个细节值得说rRatio声明在VAR区它是临时变量每次调用重新计算不跨周期保持——这正是FUN该有的样子。如果我把rRatio声明成VAR RETAIN想缓存它那就违背了FUN的语义而且多实例调用时会互相污染。参数默认值: 0.0这类让调用方可以只传iRaw其余用默认量程减少重复填写。调用时直接当表达式用rPressure : FUN_ScalePressure(iRaw : aiPressureRaw);。干净、可测、可复用。4.2 用FB封装一台电机含故障锁存与超时电机控制是FB的经典战场。需求是给启动命令后电机运行运行反馈5秒内没到就报启动超时故障故障需要复位信号才能清除运行中反馈丢失报运行中失联。声明FUNCTION_BLOCK FB_Motor VAR_INPUT xStart : BOOL; xStop : BOOL; xFbRun : BOOL; // 运行反馈 xReset : BOOL; // 故障复位 tStartTimeout : TIME : T#5S; END_VAR VAR_OUTPUT xRunCmd : BOOL; xFault : BOOL; iFaultCode : INT; END_VAR VAR fbStartTimer : TON; xFaultLatch : BOOL; END_VAR实现逻辑里fbStartTimer是内嵌的TON实例xFaultLatch是锁存位——这两个都是记忆所以必须是FB。核心片段IF xStop THEN xRunCmd : FALSE; ELSIF xStart AND NOT xFaultLatch THEN xRunCmd : TRUE; END_IF fbStartTimer(IN : xRunCmd AND NOT xFbRun, PT : tStartTimeout); IF fbStartTimer.Q THEN xFaultLatch : TRUE; iFaultCode : 1; // 启动超时 END_IF IF xRunCmd AND NOT xFbRun AND NOT fbStartTimer.ET tStartTimeout THEN ; // 运行中失联判断此处简化 END_IF IF xReset THEN xFaultLatch : FALSE; iFaultCode : 0; END_IF xRunCmd : xRunCmd AND NOT xFaultLatch; xFault : xFaultLatch;现场20台电机就在PRG里声明20个实例fbMotor1 : FB_Motor; fbMotor2 : FB_Motor; ...每台独立状态。这里的关键认知是FB定义只有一份但实例的状态是各自独立的。fbMotor1.fbStartTimer和fbMotor2.fbStartTimer是两个完全不同的定时器互不干扰。4.3 用PRG做工艺调度PRG负责把上面这些组织起来。假设一个简单的泵站两台泵一用一备根据液位启停故障时切换。PRG里声明实例和中间变量然后按扫描顺序写协调逻辑PROGRAM PRG_PumpStation VAR fbPumpA : FB_Motor; fbPumpB : FB_Motor; fbLevelTimer : TON; rLevel : REAL; xAutoMode : BOOL : TRUE; END_VAR主体逻辑先调FUN做标定再根据液位决定启停最后调用两个电机FBrLevel : FUN_ScalePressure(iRaw : aiLevelRaw, rEngMax : 100.0); IF xAutoMode THEN IF rLevel 30.0 THEN fbPumpA(xStart : TRUE, xStop : FALSE, xFbRun : diPumpARun, xReset : xResetBtn); ELSIF rLevel 80.0 THEN fbPumpA(xStart : FALSE, xStop : TRUE, xFbRun : diPumpARun, xReset : xResetBtn); END_IF // 备用泵切换逻辑略 END_IF注意PRG里没有实例化这个动作fbPumpA直接声明就能用因为PRG本身是被任务调用的它的变量天然就是全局保持的。这也是为什么PRG里的变量要谨慎命名——它们在整个资源范围内可见容易和别的PRG撞名。4.4 三者协作的完整数据流把上面三段串起来看数据流任务每10ms调用一次PRG_PumpStation→ PRG先调FUN_ScalePressure把原始值转成液位 → PRG根据液位调用fbPumpAFB实例→ FB内部调用自己的fbStartTimerTON实例做超时判断 → FB把xRunCmd输出回PRG → PRG把命令写到物理输出。这条链上FUN负责算、FB负责记、PRG负责串。任何一环换成别的类型都会别扭把标定写成FB多了一堆没必要的实例把电机写成FUN状态无处安放把调度写成FB任务没法直接调它。这个协作模型是我认为理解三者关系最有效的一张心智图。5. 踩坑实录与排查速查5.1 FB实例共享导致的状态串扰这是新手最高频的坑。有人在PRG里只声明了一个fbMotor : FB_Motor;然后在两个不同的工位逻辑里都调用fbMotor(...)以为调用两次就是两个电机。实际结果是两个工位共享同一个实例的状态——A工位启动后B工位看到的xRunCmd也是TRUE故障也互相传染。排查方法看FB实例的声明数量。每个需要独立状态的设备必须有自己的实例。如果两个工位确实要控制两台电机就声明fbMotorA和fbMotorB。如果逻辑上就是同一台设备被两处引用那共享实例反而是对的——但要清楚这是同一台设备不是两台。注意CODESYS编译时不会因为一个实例被多处调用而报错这个错误是静默的只在运行行为上暴露。所以代码评审时要专门检查FB实例的声明与调用是否一一对应。5.2 在FUN里做边沿检测的无效尝试有人想在FUN里实现按钮上升沿触发一次写了类似IF xBtn AND NOT xBtnLast THEN ...然后把xBtnLast声明在FUN的VAR区。结果发现每次调用xBtnLast都是FALSE边沿永远检测不到。原因很直接FUN的VAR区变量不跨调用保持每次进来都重新初始化。边沿检测的本质是比较本次和上次必须有地方存上次这就注定了它只能是FB的活。正确做法是写一个FB_RisingEdge内部用VAR存xLast或者直接用CODESYS内置的R_TRIG它本身就是FB。5.3 PRG变量全局可见引发的命名冲突PRG里声明的变量在整个资源内全局可见。两个PRG都声明了iCount编译可能通过但运行时互相覆盖排查起来极其痛苦。我的习惯是给PRG变量加前缀PRG_PumpStation里的变量统一用ps_开头PRG_Conveyor用cv_开头。这样即使全局可见也能一眼看出归属。另外PRG里不要声明太多临时计算变量能下沉到FUN的就下沉。PRG越薄全局命名空间越干净维护成本越低。5.4 常见问题速查表现象可能原因排查方向两台设备状态互相影响FB实例被共享检查实例声明数量边沿检测永远不触发逻辑写在FUN里改用FB或R_TRIG定时器计时不准/复位异常定时器实例被多处调用每个定时逻辑独立实例修改一处逻辑影响无关设备逻辑堆在PRG里下沉到FB封装编译通过但运行行为诡异PRG变量命名冲突加前缀、缩小作用域FUN调用结果依赖调用顺序FUN里藏了状态检查VAR区是否有跨周期依赖5.5 几条压箱底的经验第一条先问要不要记忆再问要不要被调度。这两个问题能覆盖90%的选型场景剩下的10%是既要记忆又要被任务直接调——这种情况用PRG里包一个FB实例来解决。第二条FB的粒度按设备切不按功能切。一个FB对应一台物理设备或一个完整控制回路而不是一个延时功能一个报警功能。粒度对了实例管理才清晰。第三条FUN尽量写成纯函数别碰全局变量。FUN里一旦读写全局变量它就失去了可测试性也失去了同样输入同样输出的保证。需要访问全局状态的老老实实用FB。第四条PRG只做装配不做计算。PRG里出现超过三行的数学运算就该考虑抽成FUN了。装配层越薄工艺调整时改动面越小。6. 从选型到重构一个真实工程的演进思路我拿一个脱敏的模拟项目说事某产线有8个气缸、4台电机、2路模拟量输入最初版本所有逻辑写在一个PRG里约1200行。问题很明显——加一个气缸要复制粘贴一大段、改一个超时参数要全局搜索、故障排查时根本分不清哪段逻辑对应哪个设备。重构分三步走。第一步把模拟量标定抽成FUN因为它是纯计算抽出来之后PRG里只剩一行调用。第二步把气缸和电机各自封装成FB每个设备一个实例PRG里变成声明实例按顺序调用。第三步PRG按工位拆成多个挂到不同任务上快逻辑10ms、慢逻辑100ms分离。重构后PRG从1200行降到约200行新增一个气缸只需声明一个实例加几行调用改动面从全局缩到局部。这个演进过程本身就是三者选型的最佳注脚FUN管算、FB管设备、PRG管流程各归其位工程才扛得住迭代。如果你现在手上有个什么都往PRG里塞的老工程不用一次性推倒重来。挑一个最独立的设备先封装成FB跑通一个实例验证行为一致后再推广到同类设备。这种增量重构比大改安全得多也更容易在产线不停机的情况下推进。最后分享一个我常用的自检习惯每次新建POU之前先在纸上写一句话描述它的职责如果这句话里出现记住保持上次这类词直接选FB如果出现根据X算出Y选FUN如果出现按顺序执行协调调度选PRG。这个土办法比翻手册快也比凭感觉靠谱。