2026/10/29 6:43:12

CODESYS中FUN、FB、PRG选型指南:从内存模型到工程实践

CODESYS中FUN、FB、PRG选型指南:从内存模型到工程实践 做PLC这行十来年带过不少新人也review过大量现场代码。有个现象特别普遍很多人写了几年CODESYSFUN、FB、PRG这三个东西还是凭感觉用问他为什么这里用FB那里用FUN答不上来只说“能跑就行”。结果就是程序越写越臃肿改一个地方崩三个地方在线调试的时候自己都理不清调用关系。这篇东西就是想把这三个POU类型掰开揉碎讲清楚从底层机制到选型逻辑再到我踩过的坑一次性说明白。不管你是刚接触CODESYS的新手还是已经能写项目但总觉得架构别扭的老手看完应该都能对“什么时候该用哪个”有一个不靠猜的判断标准。1. 先把三个概念的本质掰清楚1.1 FUN、FB、PRG在CODESYS里到底是什么关系CODESYS里的POUProgram Organization Unit程序组织单元是代码的基本容器FUN、FB、PRG是三种不同类型的POU。很多人把它们理解成“函数、功能块、程序”三个名字就完事了但真正影响选型的是它们背后的内存模型和调用语义。打个比方。FUN像一台自动售货机——你投币、选商品它出货交易结束机器不记得你来过。FB像一间带储物柜的办公室——你进去办公柜子里存着你的文件下次再来文件还在。PRG则像整个车间的总控台——它自己就是被循环扫描的主体不需要别人来“调用”它才能运转。从技术层面说三者的核心差异体现在这几个维度维度FUN函数FB功能块PRG程序实例化无需实例必须声明实例由任务直接调用状态保持无状态有状态实例内存有状态全局返回值有且仅有一个返回值通过输出引脚无返回值概念调用方式表达式内调用实例名括号调用任务配置中绑定内存分配调用栈临时分配实例声明时静态分配全局静态分配可否递归理论上可以不可以不可以典型用途数学计算、类型转换设备控制、状态机主循环、任务调度这张表建议存下来后面所有的选型判断都是从这里推导出来的。我见过太多人把FB当FUN用——明明不需要保持状态非要建个实例结果程序里一堆只调用一次的FB实例白白占内存还增加理解成本。反过来也见过把需要记忆状态的逻辑塞进FUN里靠全局变量硬撑最后调试的时候全局变量满天飞根本不知道谁改的。1.2 为什么CODESYS要设计这三种类型而不是一种这个问题值得想一层。很多现代语言里函数就是函数类就是类没有单独搞一个“程序”的概念。CODESYS这么设计根源在于PLC的循环扫描执行模型。PLC和普通PC程序最大的区别是PC程序跑完就结束PLC程序是无限循环的每个扫描周期把所有逻辑过一遍。这就需要一个“顶层入口”来组织每个周期的执行顺序——这就是PRG的定位。PRG不是被谁调用的它是被任务配置Task Configuration直接绑定的每个扫描周期由运行时自动执行。而工业控制里大量逻辑是“带记忆”的一个电机启动后要保持运行状态一个阀门打开后要记住当前位置一个报警触发后要等待确认。这些逻辑天然适合封装成有状态的FB。至于纯计算类的——单位换算、限幅、斜率计算、CRC校验——不需要记忆任何东西每次给输入就算输出这就是FUN的用武之地。三种类型对应三种不同的“生命周期”需求这是CODESYS设计哲学的底层逻辑。理解了这一点选型就不是背规则而是看你的逻辑需要活多久、记多少事。1.3 一个容易被忽略的点调用约定与内存分配再往深挖一层。FUN在调用时输入参数和局部变量分配在调用栈上调用结束栈帧回收所有临时数据消失。这意味着FUN的调用开销相对小但也意味着它绝对不能持有任何跨周期的状态。你在FUN里写VAR而不是VAR_INPUT/VAR_OUTPUT这些变量每次调用都会重新初始化——新手常在这里翻车以为FUN里的静态变量能记住上次的值实际上每次都是全新的。FB的实例是一块静态分配的内存区域在声明实例的时候就确定了。实例内部的所有变量——包括输入、输出、内部变量——都存在于这块内存里跨扫描周期保持。这也是为什么FB不能递归调用实例内存是固定的递归会导致同一块内存被重入改写结果不可预期。PRG的内存是全局的程序启动时分配整个运行期间存在。PRG里声明的变量相当于全局变量任何其他POU都可以通过命名空间访问虽然不推荐这么做。PRG的执行由任务绑定可以配置不同的优先级和周期。注意FB实例的内存是在编译期确定的如果你在一个FB里嵌套声明了大量子FB实例内存占用会层层叠加。嵌入式控制器内存紧张的时候这个要提前算账。2. 选型逻辑什么场景该用哪个2.1 FUN的适用边界无状态纯计算是唯一标准FUN的选型标准其实非常清晰输出完全由当前输入决定不依赖任何历史信息。只要你的逻辑满足这个条件就应该优先用FUN。典型的FUN场景包括工程量转换比如4-20mA原始值转实际物理量、限幅与死区处理、斜率与变化率计算、字符串处理、CRC或校验和计算、查表插值、简单的逻辑组合判断。这些逻辑的共同特征是“给同样的输入永远得到同样的输出”没有任何记忆需求。用FUN的好处很实在。第一不需要声明实例调用就是一行表达式代码干净。第二没有实例内存开销对于频繁调用的计算逻辑内存和性能都更友好。第三可测试性极强——你可以单独给一组输入验证输出不依赖任何外部状态。但FUN有个硬限制只有一个返回值。如果你需要返回多个结果要么把多个值打包成结构体返回要么改用FB通过多个输出引脚返回。我个人的经验是如果需要返回超过两个值就该考虑FB了硬用结构体返回会让调用处的代码变得很啰嗦。还有一个坑要提醒FUN里不要访问全局变量。一旦FUN依赖了全局变量它就不再是“纯函数”了输出不再只由输入决定测试和复用都会变得困难。我见过有人在FUN里直接读某个全局标志位做条件判断后来这个FUN被复用到另一个项目全局标志位不存在编译都过不了。FUN就应该自包含所有依赖都从输入引脚进来。2.2 FB的核心价值状态保持与复用封装FB是工业控制里用得最多的POU类型因为大部分设备控制逻辑都需要记忆状态。一个气缸的伸出缩回控制需要记住当前是在伸出位还是缩回位一个电机的启停控制需要记住运行状态和故障状态一个PID回路需要记住积分累积量和上次误差。这些统统是FB的菜。FB的核心价值有两层。第一层是状态保持——实例内存跨周期存在逻辑可以记住“上次发生了什么”。第二层是封装复用——你把一个设备的控制逻辑封装成一个FB声明不同的实例就能控制多台同类设备每个实例的状态互相独立。这两层价值叠加起来才是FB真正的威力。举个具体的例子。假设你要控制8台同样的变频电机每台都有启动、停止、故障复位、运行状态反馈、故障状态反馈。如果你不用FB就得为每台电机写一遍逻辑或者用数组加索引硬编代码又长又容易出错。用FB的话定义一个FB_Motor里面封装好所有逻辑然后声明fbMotor1到fbMotor8八个实例每个实例调用一次传各自的IO引脚。代码量骤减而且新增一台电机只需要多声明一个实例、多调一行维护成本极低。FB的选型判断可以归纳成几个问题这段逻辑需要记住上次的状态吗这段逻辑会被多处复用吗这段逻辑有多个输出结果吗这三个问题只要有一个答案是“是”就应该考虑FB。如果三个都是“否”那用FUN更合适。2.3 PRG的定位任务入口与顶层编排PRG最容易理解也最容易被滥用。它的本质是任务绑定的执行入口每个扫描周期由运行时自动调用。PRG里通常放的是顶层编排逻辑调用各个FB实例、组织执行顺序、处理跨设备的协调逻辑。PRG不该做的事不该在里面写大段的具体设备控制逻辑不该在里面堆砌大量计算代码不该把它当成“万能容器”什么都往里塞。我见过最夸张的一个项目整个程序只有一个PRG里面写了两千多行所有逻辑从头到尾平铺没有任何FB封装。这种代码能跑但没法维护——改一个设备的逻辑要在两千行里找找完了还不敢动怕影响别的部分。正确的PRG用法是“薄顶层”PRG里主要是实例调用和流程编排具体逻辑都在FB和FUN里。一个设计良好的项目PRG的代码量应该占总代码量的很小一部分大部分逻辑都在FB和FUN里封装好了。PRG读起来应该像一份目录——一眼能看出这个程序组织了哪些功能模块执行顺序是什么。PRG的另一个用途是放置全局变量和全局常量。虽然CODESYS有专门的GVL全局变量列表但很多人习惯把全局变量放在PRG的VAR_GLOBAL里。这两种做法都可以关键是团队内要统一别一半放GVL一半放PRG后来的人找变量找半天。2.4 一张决策流程图帮你30秒定类型把上面的逻辑浓缩成一个判断流程实际写代码的时候按这个顺序问自己这段逻辑需要跨扫描周期记住状态吗——不需要往下走需要跳到第3步。这段逻辑只有一个返回值且无副作用吗——是用FUN不是往下走。这段逻辑需要被多处复用、每个复用点状态独立吗——是用FB不是往下走。这段逻辑是顶层编排或任务入口吗——是用PRG不是回到第3步重新考虑。大部分情况下这个流程能帮你快速定位。少数边界情况——比如一个逻辑既需要状态又只在一个地方用——用FB也没错只是稍微重了一点但可维护性比硬塞进PRG好。3. 实战场景对比同一需求三种写法3.1 场景设定一个温度报警逻辑用一个具体需求来对比三种写法的差异。需求是这样的读取一个温度传感器的原始值转换成实际温度判断是否超限超限时触发报警并记录超限持续时间温度回落后自动复位报警。这个需求里其实包含了三种不同性质的逻辑温度转换是纯计算无状态报警判断和计时需要记忆状态整体组织需要一个入口。正好对应FUN、FB、PRG三种类型。3.2 用FUN实现温度转换温度转换部分写成FUN输入原始值输出实际温度FUNCTION FUN_RawToTemp : REAL VAR_INPUT rRawValue : REAL; // 原始AD值 0-27648 rRawMin : REAL : 0.0; rRawMax : REAL : 27648.0; rEngMin : REAL : -50.0; rEngMax : REAL : 150.0; END_VAR VAR rSpan : REAL; END_VAR rSpan : rRawMax - rRawMin; IF rSpan 0.0 THEN FUN_RawToTemp : rEngMin; RETURN; END_IF FUN_RawToTemp : rEngMin (rRawValue - rRawMin) * (rEngMax - rEngMin) / rSpan;这个FUN的好处是显而易见的不占实例内存调用就是一行rTemp : FUN_RawToTemp(rRaw);而且可以单独测试——给0输出-50给27648输出150给13824输出50验证起来非常直接。注意里面做了除零保护这是实际项目里必须加的量程配置错误导致除零会让整个扫描周期报错。3.3 用FB实现带状态的报警管理报警管理需要记住“当前是否已报警”“报警持续了多久”这是典型的状态逻辑用FBFUNCTION_BLOCK FB_TempAlarm VAR_INPUT rTemp : REAL; rThreshold : REAL : 80.0; rHysteresis: REAL : 2.0; // 回差 bReset : BOOL; END_VAR VAR_OUTPUT bAlarm : BOOL; tDuration : TIME; END_VAR VAR tonDuration : TON; bLatched : BOOL; END_VAR // 带回差的报警判断 IF rTemp rThreshold THEN bLatched : TRUE; ELSIF rTemp (rThreshold - rHysteresis) THEN bLatched : FALSE; END_IF IF bReset THEN bLatched : FALSE; END_IF bAlarm : bLatched; // 报警持续时间计时 tonDuration(IN : bAlarm, PT : T#24H); IF bAlarm THEN tDuration : tonDuration.ET; ELSE tDuration : T#0S; END_IF这个FB里用到了回差hysteresis这是实际项目里必须考虑的东西。如果只用单一阈值温度在阈值附近波动时报警会疯狂闪烁操作员会疯掉。回差的作用是超过80度报警降到78度以下才复位中间2度的死区防止抖动。这个细节在很多教科书例子里没有但现场调试的时候不加就是等着被投诉。FB内部还嵌套了一个TON定时器实例这是FB嵌套FB的典型用法。注意tonDuration是声明在FB的VAR区里的它作为FB实例内存的一部分存在跨周期保持计时状态。如果你把TON写在FUN里每次调用计时器都重新开始永远计不出结果——这是新手常犯的错误。3.4 用PRG做顶层编排PRG负责把上面两个POU串起来组织执行顺序PROGRAM PRG_Main VAR fbAlarm1 : FB_TempAlarm; fbAlarm2 : FB_TempAlarm; rRawTemp1 : REAL; rRawTemp2 : REAL; rTemp1 : REAL; rTemp2 : REAL; bResetBtn : BOOL; END_VAR // 第一步原始值转工程量FUN调用 rTemp1 : FUN_RawToTemp(rRawValue : rRawTemp1); rTemp2 : FUN_RawToTemp(rRawValue : rRawTemp2); // 第二步报警判断FB实例调用 fbAlarm1(rTemp : rTemp1, rThreshold : 80.0, bReset : bResetBtn); fbAlarm2(rTemp : rTemp2, rThreshold : 65.0, bReset : bResetBtn); // 第三步后续协调逻辑 // ...PRG里读起来就是一条清晰的流水线先转换、再判断、再协调。两个报警实例用同一个FB类型但不同阈值状态完全独立——fbAlarm1报警不影响fbAlarm2。这就是FB复用的价值。如果以后要加第三个温度点只需要多声明一个实例、多调一行改动量极小。对比一下如果全塞进PRG会怎样温度转换的公式要写两遍或者写成内联表达式报警判断的回差逻辑要写两遍计时器要声明两个TON实例代码量翻倍不说改一处逻辑要同步改两处漏一处就是bug。这就是为什么“薄PRG厚FB/FUN”是更健康的架构。3.5 三种写法的代码量与可维护性对比把上面的对比量化一下对比项全PRG写法FUNFBPRG写法代码总行数约120行约90行含FB/FUN定义新增一个温度点改动量复制约40行声明1个实例1行调用修改报警逻辑影响范围需同步改多处只改FB定义一处单独测试难度无法单独测试FUN/FB可独立验证状态隔离靠变量命名区分易错实例天然隔离在线调试可观测性全局变量混杂实例展开清晰这个对比不是说要追求代码行数少而是说改动成本和出错概率。全PRG写法在需求不变的时候没问题一旦需求变化——而工业项目需求变化是常态——维护成本会指数上升。FUNFBPRG的写法前期多花一点时间做封装后期每次改动都省事。4. 进阶话题那些文档里不会写的细节4.1 FB的实例化时机与内存陷阱FB实例在声明的时候就已经分配了内存不是在第一次调用的时候。这意味着如果你声明了100个FB实例但只用了10个剩下90个的内存照样占着。在资源紧张的控制器上比如一些紧凑型PLC这个要提前算账。更隐蔽的坑是FB嵌套的递归内存放大。假设FB_A内部声明了3个FB_B实例FB_B内部又声明了2个FB_C实例。那么每声明一个FB_A实例实际分配的内存是FB_A自身 3×FB_B 6×FB_C的总和。如果FB_C里还有嵌套继续放大。我见过一个项目一个顶层FB嵌套了四层单个实例内存占了十几KB声明了二十个实例直接把控制器内存吃满了编译能过但运行时各种异常。后来把嵌套拆平、把不需要状态的逻辑改成FUN内存降到了原来的三分之一。提示CODESYS编译后可以在“消息”窗口或者生成的内存映射文件里查看各POU的内存占用。项目中期就该检查一次别等到下载的时候才发现内存不够。4.2 FUN里不能用定时器和边沿检测的深层原因新手最常问的问题之一“为什么我在FUN里写TON不工作”答案在于TON这类功能块依赖实例内存来保存计时状态而FUN没有实例内存每次调用都是全新的栈帧计时器永远从零开始。边沿检测R_TRIG/F_TRIG同理。边沿检测需要记住“上次的信号状态”这是状态必须存在实例里。在FUN里做边沿检测每次调用都认为“上次是FALSE”结果就是只要输入是TRUE就每次都触发根本不是边沿。这个限制反过来也是选型信号如果你的逻辑里需要定时器、计数器、边沿检测、任何形式的“记住上次”那它就不该是FUN。这几个东西是FB的专属工具。我一般跟新人说看到TON、CTU、R_TRIG这几个词直接往FB方向想不用犹豫。4.3 PRG之间的调用关系与执行顺序陷阱多个PRG之间可以互相调用但这里有个执行顺序的坑。CODESYS的任务配置里每个任务绑定一个PRG作为入口任务按配置的优先级和周期执行。如果你在PRG_Task1里调用了PRG_Sub那PRG_Sub就在PRG_Task1的执行流里被调用执行时机取决于调用位置。问题出在跨任务的数据交互上。假设PRG_Fast绑在1ms任务里PRG_Slow绑在10ms任务里两者通过全局变量交换数据。如果PRG_Slow写、PRG_Fast读由于任务优先级不同读到的数据可能不是最新的甚至可能读到写了一半的数据如果写操作不是原子的。这种问题极难排查因为现象是偶发的。我的做法是跨任务数据交互一律走明确的同步机制或者把数据交互收敛到同一个任务里处理。如果确实需要跨任务至少保证每个共享变量只在一个任务里写、在其他任务里只读避免读写竞争。这个原则写进团队规范里比事后debug省事得多。4.4 方法Method和属性Property什么时候该上CODESYS的FB支持方法Method和属性Property这是面向对象的能力。什么时候该用我的判断标准是当FB内部有一组操作只针对该FB的实例数据、且需要被外部按需调用时用方法比暴露一堆输入引脚更清晰。举个例子。一个FB_Recipe管理配方数据配方有加载、保存、校验三个操作。如果不用方法你得在FB上开三个触发输入引脚bLoad、bSave、bValidate然后在FB内部用边沿检测判断哪个被触发。用方法的话外部直接fbRecipe.Load()、fbRecipe.Save()、fbRecipe.Validate()语义清晰得多也不用在FB内部做触发判断。但方法不是越多越好。方法适合“动作型”操作不适合“查询型”的简单数据访问——查询用属性更合适。而且方法内部不要做耗时操作方法调用是同步的耗时操作会阻塞整个扫描周期。我一般把方法限制在“轻量动作”范围内重活还是走状态机在FB的循环体里分周期完成。5. 常见问题与排查实录5.1 选型相关的典型翻车案例案例一FUN里藏全局变量导致复用失败。某项目里一个计算功率因数的FUN内部直接读了全局的电压互感器变比。后来这个FUN被复制到另一个项目全局变量名不一样编译报错。更麻烦的是第一个项目里有人改了全局变比功率因数计算结果跟着变但没人意识到是这个FUN在偷偷依赖全局。修复方式是把变比改成输入引脚FUN变成纯函数。案例二FB实例忘记调用导致状态不更新。声明了FB实例但忘了在PRG里调用实例的输出引脚永远是初始值。这个错误编译不报错运行时也不报错就是逻辑不对。排查的时候先检查所有声明的FB实例是否都有对应的调用语句。我现在的习惯是声明实例后立刻写调用不留“回头再调”的尾巴。案例三PRG里做边沿检测但变量是临时的。在PRG里用R_TRIG做按钮边沿检测但R_TRIG的实例声明在了某个条件分支内部CODESYS不允许在分支内声明但有人用临时变量模拟导致边沿检测时灵时不灵。正确做法是把R_TRIG实例声明在PRG的VAR区顶部确保它跨周期存在。5.2 在线调试时如何快速定位POU类型问题在线调试的时候如果发现某个逻辑行为异常按这个顺序排查现象可能原因排查动作状态不保持每次都是初始值逻辑写在了FUN里检查该POU类型状态逻辑应移到FB定时器不计时或每次重新计TON实例在FUN里或临时变量里把TON实例移到FB的VAR区边沿检测每次都触发R_TRIG实例未跨周期保持检查实例声明位置多个设备状态互相串扰共用了同一个FB实例检查是否每个设备独立实例修改一处逻辑影响多处逻辑重复写在多个PRG里抽取成FB统一维护内存占用异常高FB嵌套过深或实例过多查看内存映射拆平嵌套这张表是我这些年debug攒下来的大部分POU相关的怪现象都能对上号。在线调试的时候CODESYS的实例展开功能很好用——在监视窗口里展开FB实例能看到内部所有变量的实时值比在PRG里盯全局变量清晰得多。养成展开实例看内部状态的习惯很多问题一眼就能看出来。5.3 团队协作中的命名与组织规范一个人写代码怎么都行团队协作就必须有规范。我们团队现在的约定是FUN统一以FUN_开头FB统一以FB_开头PRG统一以PRG_开头。一眼能看出类型review的时候不用点进去看。每个FB的实例变量以fb开头比如fbMotor1、fbAlarmTemp。这样在PRG里扫一眼就知道哪些是实例调用。FUN的命名用动词开头描述行为比如FUN_CalcSlope、FUN_RawToEng。FB的命名用名词描述对象比如FB_Motor、FB_PID。全局变量统一放GVL不在PRG里散落声明。GVL按功能分文件比如GVL_IO、GVL_Config、GVL_Status。这些规范看起来是小事但团队大了之后没有规范的项目接手成本极高。我接手过一个没有命名规范的项目FUN和FB混着命名找个电机控制逻辑翻了半天最后发现藏在一个叫Calc2的FB里。规范的价值在长期维护中才体现出来。5.4 性能敏感场景下的选型权衡大部分项目里FUN和FB的性能差异可以忽略但在高频任务比如1ms周期里选型会影响扫描时间。FUN的调用开销比FB小因为不需要访问实例内存。如果一个计算逻辑在1ms任务里被调用几十次用FUN比用FB更合适。但要注意FUN的“轻量”是有前提的——FUN内部不能有复杂操作。如果FUN里做了大量字符串处理或者循环那开销照样大。性能优化的第一原则永远是“先测量再优化”CODESYS有扫描时间统计功能先看实际数据再决定要不要为了性能牺牲可读性。我见过有人为了“性能”把本该用FB的逻辑硬写成FUN加全局变量结果可维护性崩了性能也没提升多少——因为瓶颈根本不在那里。真正影响性能的往往是架构问题而不是POU类型选择。比如在1ms任务里做了本该在10ms任务里做的逻辑或者FB嵌套过深导致每次调用要穿透多层。这些问题的优化收益远大于纠结FUN还是FB。6. 我个人的几条实操心得写到这里分享几条不在任何文档里、纯粹是现场攒下来的经验。第一条新项目先画POU结构图再写代码。别上来就开PRG写逻辑先在纸上或者白板上把有哪些设备、每个设备对应哪个FB、哪些计算抽成FUN、PRG怎么组织画一遍。这个习惯让我少走了很多弯路——结构想清楚了代码是水到渠成的事结构没想清楚写到一半发现要重构成本翻倍。第二条FB的输入输出引脚宁多勿少但内部变量要克制。引脚多说明接口清晰外部调用的时候一目了然但内部VAR区不要堆太多临时变量能复用的复用能局部化的局部化。我见过一个FB内部声明了四十多个VAR变量其中一半只用了一次纯属历史遗留。定期清理FB内部变量保持整洁。第三条FUN的测试用例跟着FUN一起写。CODESYS支持在线给FUN喂输入看输出我习惯在FUN写完的第一时间就用几组边界值验证一遍——最小值、最大值、中间值、非法值比如除零、超量程。这个习惯帮我拦下过好几次量程配置错误的问题比等到现场调试才发现强得多。第四条PRG的代码超过200行就该考虑拆了。200行不是硬指标是个信号——说明PRG里可能混入了本该封装的具体逻辑。拆的时候先找重复模式重复出现的逻辑抽成FB再找纯计算抽成FUN剩下的编排逻辑留在PRG里。拆完之后PRG应该读起来像目录而不是像小说。第五条别为了“面向对象”而过度设计。CODESYS支持方法、属性、接口、继承但工业控制的大部分场景用不上这么重的设计。一个简单的电机控制一个FB加几个输入输出引脚就够了不需要搞继承体系。过度设计的代码比没有设计的代码更难维护因为后来的人要花时间理解你的“架构”而实际需求根本没那么复杂。我的原则是能用简单FB解决的不引入方法能用FUN解决的不建FB实例。简单直接是工业软件的美德。最后说一个我最近才想明白的点。FUN、FB、PRG的选型表面上是技术选择本质上是对“这段逻辑的生命周期”的判断。FUN的生命周期是一次调用FB的生命周期是一个实例从声明到销毁PRG的生命周期是整个程序运行期。想清楚你的逻辑需要活多久、需要记住什么选型自然就对了。技术细节可以查文档但这个判断力只能靠项目喂出来。多写、多拆、多重构慢慢就有感觉了。