搞过PLC或者说用过CoDeSys的人,对函数(FUN)一定不陌生。在CoDeSys里写逻辑,函数是最高频的一类POU(程序组织单元),加减乘除、类型转换、比较选择,底层都是一堆函数在跑。但很多朋友学到这里容易迷糊:函数跟功能块到底什么区别?为什么我在函数里写了一个“自保持”逻辑,线上跑起来根本不按我想的来?函数能不能像子程序一样保存中间值?这篇文章从实战角度把CoDeSys里的函数(FUN)彻底捋一遍,包含自定义函数的完整步骤、函数属性边界、多个可以直接抄的实操案例,以及我踩过的坑。适合刚学完基本指令、开始尝试封装自己逻辑的初学者,也适合想回头把函数和功能块边界理清楚的工程朋友。
1. 函数到底是什么:先把FUN和FB的边界搞清楚
1.1 函数(FUN)在CoDeSys里的定位
在IEC 61131-3标准里,程序组织单元一共三类:函数(FUN)、功能块(FB)、程序(PRG)。函数是最简单的一类,它强调“无状态”:你给我输入,我给你算出一个输出,每次调用都是从零开始执行一遍,执行完结果返回,不保留任何内部记忆。
用生活化一点的话说,函数就像一台计算器。你按几个数字和运算符,它给你一个结果;下一个人再来按,它不会记得上一个人按了什么。而功能块则像一台带记忆的机床,它内部有气缸、有计时器、有锁定标志,前一次操作的状态会影响后一次的输出。
CoDeSys里的函数天然适合做这些事:
- 工程量换算:4到20mA原始值转温度、压力工程值
- 数值判定:超限判断、报警分级、模式选择
- 数据转换:字节拆分、双字拼接、位提取
- 校验计算:BCC校验、CRC校验、累加和
- 纯数学计算:滤波、限幅、比例运算
如果一个逻辑不需要历史数据,不依赖上一次的调用状态,输入和输出是确定的映射关系,那它就适合封装成函数。反之,如果需要保存累积值、记录历史样本、做沿触发,就应该做功能块,而不是函数。
1.2 函数与功能块(FB)的关键区别
很多新手第一次写功能块不觉得难,等到自定义函数时反而栽跟头,原因就是没把这几个区别刻在脑子里。
先说最核心的一条:函数不能有“记忆”。CoDeSys里新建一个函数时,你会发现自己根本无法声明VAR区——只能声明输入变量VAR_INPUT、输出变量VAR_OUTPUT、以及临时变量VAR_TEMP。VAR_TEMP是每次调用时在栈上临时分配的变量,调用结束就释放,不会保存上次的值。这跟FB里的VAR完全不同,FB里的VAR是在实例化时分配的静态存储区,掉电保持、调用之间都还在。
再一个区别是调用方式。FB先实例化再调用,通常在程序里声明一个FB实例,然后多次调用这个实例,实例内部状态会持续累积。函数则不需要实例化,直接F_XXX(...)调用,调用完就结束,没有“同一个实例”的概念。
还有一个容易被忽略的点:函数的返回值。在ST语言里,函数的返回值直接赋给函数名本身,比如:
F_SCALE := (rawValue - 0) * (100 - 0) / (27648 - 0);这个F_SCALE就是函数名,也是返回值。而FB是没有返回值的,你再怎么折腾,FB的输出还是要通过VAR_OUTPUT显式传递出来。
这个区别会直接决定你的程序架构选择。我在实际项目里的判断标准很简单:如果这个逻辑给我同样的输入永远得到同样的输出,而且不需要记住任何中间状态,那我就用FUN;只要它需要“记住”点东西,哪怕只记住上一次的一个数,我就改用FB。记住这句话,能替你省掉后面大量的排错时间。
1.3 标准库里的现成函数,别重复造轮子
CoDeSys的标准库本身提供了大量已经写好的函数,很多朋友不知道,导致自己重新实现了一遍,既费时间又容易出错。
最常用的有这几类:
- 数学函数:ADD、SUB、MUL、DIV、MOD、ABS、SQRT、LN、EXP、SIN、COS、TAN
- 比较与选择:GT、GE、LT、LE、EQ、NE、SEL、MUX、LIMIT、MAX、MIN
- 位运算与移位:AND、OR、XOR、ROL、ROR、SHL、SHR
- 类型转换:BOOL_TO_、BYTE_TO_、WORD_TO_、DINT_TO_、REAL_TO_* 等
- 字符串与BCD处理:很多标准库函数在编辑器里可以直接通过指令助手查到
举个例子,限幅这种逻辑大多数人都会自己写IF判断:
IF x > 100.0 THEN y := 100.0; ELSIF x < 0.0 THEN y := 0.0; ELSE y := x; END_IF;但标准库里有现成的LIMIT函数,一个调用就搞定:
y := LIMIT(Min := 0.0, In := x, Max := 100.0);类似的还有SEL双路选择器、MUX多路复用器,用起来非常快。我建议你先按F2打开指令助手,把标准库里的函数大致浏览一遍,知道有哪些东西可用。再动手写自定义函数之前,先问自己一句:标准库里是不是已经有了?免得别人看到你的代码里手写了一个SEL或者手写了一个绝对值,还得忍住不笑。
2. 自定义函数:从零开始创建你自己的FUN
2.1 新建函数与接口定义,第一步决定成败
在CoDeSys里新建函数的方法是:在项目树里选中应用(Application),右键“添加对象”——“POU”,然后在“类型”里选“函数(Function)”,给它起个名字。命名我有两个习惯:一是函数名前面统一加F_前缀,跟功能块FB_、程序PRG_区分开,二是名字要让别人看得懂行为,比如F_ScaleAI、F_AlarmLevel、F_CheckBCC。
新建完函数后,首先看到的是接口编辑器,在这里定义输入输出。接口定义里最关键的是“返回类型(Return Type)”,在“函数”选项卡里设置,比如 REAL、INT、WORD、BOOL,如果不需要数值返回,也可以设成VOID。返回类型很重要,因为你后面写的所有赋值给函数名的语句,都必须匹配这个类型。
接下来是输入变量。函数常用的输入区是VAR_INPUT,也就是调用时需要传进来的参数。比如我做一个温度报警分级函数,需要一个温度输入:
FUNCTION F_Alarm_Level : INT VAR_INPUT temperature : REAL; END_VAR这里temperature是形参,调用时你把实际温度值传给它。函数还可以有VAR_OUTPUT和VAR_IN_OUT,我在后面章节会详细讲,但建议新手一开始先只用VAR_INPUT,返回用函数名,模型最简单、最不容易出错。
接口定义这步为什么关键?因为后续所有实现逻辑都基于这个接口。输入变量多了,调用时要传的参数就多;少了,函数不好扩展。我一般的原则是少于5个输入参数最好,超过这个数,要么是接口设计有问题,要么该考虑拆分成多个函数了。
2.2 在函数体里写实现逻辑,注意返回值的坑
接口定义好之后,切换到“实现(Implementation)”标签页,开始写逻辑。实现语言可以是ST、FBD、LD等,我个人主力用ST,因为做复杂计算时ST的表达效率最高。
函数体的写法跟普通程序差别不大,唯一的特殊之处是:你没有办法直接声明一个“也叫函数名”的变量,但你可以给函数名赋值,这个值就是返回值。比如最简单的加法函数:
FUNCTION F_AddReal : REAL VAR_INPUT a : REAL; b : REAL; END_VAR F_AddReal := a + b;注意,函数体里每次调用都必须给函数名赋值,否则返回值是未定义的。这一点我在工程上吃过亏,写了一个函数,某个分支忘了赋值,程序运行到那个分支时返回了一个莫名其妙的数,排查了很久才意识到是返回值没覆盖所有路径。现在的做法是:在函数体开头先给返回值赋一个默认值,然后再走逻辑分支。比如:
FUNCTION F_Alarm_Level : INT VAR_INPUT temperature : REAL; END_VAR F_Alarm_Level := 0; // 先给默认值 IF temperature >= 80.0 THEN F_Alarm_Level := 3; ELSIF temperature >= 65.0 THEN F_Alarm_Level := 2; ELSIF temperature >= 55.0 THEN F_Alarm_Level := 1; END_IF这样无论走哪个分支,返回值都有确定内容,不会出现“未初始化”的野值。
还有一个容易踩的坑:函数体里的局部变量只能用VAR_TEMP声明,而且每次调用时不会自动初始化。如果你在VAR_TEMP里声明了一个变量,忘记赋值就直接使用,第一次运行可能恰好是0,程序看着好像正常;但下一次调用、换一个扫描周期,这个变量可能残留上次调用的值(栈上的旧数据),导致结果偶发不对。所以我的习惯是:VAR_TEMP里的变量,用之前必须先赋值,宁可在声明后立刻初始化,也不要贪图省那几行。
2.3 调用函数:ST、FBD、LD里都能用
自定义函数在ST里调用最直接:
alarmLevel := F_Alarm_Level(0) + F_Alarm_Level(temperature := motorTemp);这里有两种传参方式:位置传参和命名传参。第一个调用F_Alarm_Level(0)是按位置传参,方便但可读性差;第二个调用F_Alarm_Level(temperature := motorTemp)是命名传参,我推荐多用命名传参,代码一眼能看出这个参数是温度,不用对着接口定义去数第一个参数是谁。
FBD里的调用方式是把函数拖进画布,左侧引脚填写输入参数,右侧引脚是返回输出。函数在FBD里看起来很像一个功能块,但记住它不是FB,每一次扫描执行一遍,不保存状态。
LD里也可以用函数,通常放在线圈或功能框位置,适合做逻辑运算和比较判断。
还有一点,函数可以调用其他函数,包括标准库函数和用户自定义函数。比如在F_ScaleAI里内部调用LIMIT或MAX、MIN来限幅,这很常见。但我不建议无节制地嵌套,嵌套超过三四层,在线监视时就很痛苦,一旦数据不对,你得一层一层点进去看,调试效率直线下降。
3. 函数的属性与使用边界:哪些能碰,哪些不能碰
3.1 无记忆性与可重入特性,既是优点也是约束
函数最大的优点就是“无记忆”,而这也正是它的约束所在。因为你写的函数不保存状态,所以它天然具备可重入性——多个调用点可以同时使用同一个函数,互不干扰。这在多轴控制、多通道模拟量处理时特别有用,比如8个温度通道,我可以在一个FOR循环里调用同一个报警判断函数8次,每次传入不同通道的实时温度,函数内部自己算自己的,没有交叉污染。
但反过来,如果你试图在函数内部实现“保持上一次的数值”“累加”“掉电记忆”这类逻辑,基本就是跟自己过不去。有些朋友刚开始用函数,直接在函数体里写:
// 下面的写法是错误的示范 IF input > 0 THEN count := count + 1; // count无法在FUN里声明为静态变量 END_IF;CoDeSys会直接报错,因为整个函数里能声明的只有VAR_INPUT、VAR_OUTPUT、VAR_IN_OUT、VAR_TEMP,没有一个能在两次调用之间保存值。就算你用一些外部全局变量强行实现,也没法保证和你预期的行为一致,而且调试时特别容易发懵。
所以属性边界第一条:函数的输入输出关系必须是“纯函数”式的,同样的输入必然得到同样的输出。如果做不到这一点,请改成功能块。
3.2 返回值、输出参数与临时变量,讲究一个“明白”
前面提到函数通过函数名返回值。但是注意到一个问题没有:函数名只有一个,返回类型只能是一种。那如果我想返回两个结果怎么办?比如既要返回校验值,又想返回错误标志。
这时候可以用VAR_OUTPUT。CoDeSys里函数是可以声明VAR_OUTPUT的,它跟函数名的返回值不冲突,函数名仍然是主返回值,VAR_OUTPUT提供额外的输出。但要注意,VAR_OUTPUT在调用方式上有别于函数名,尤其在ST里直接调用时并不像FB那样方便地接引脚。我在工程上更倾向于把VAR_OUTPUT用在少数场景,比如FBD图形化编程里用起来比较顺手,ST里还是尽量保持“一个函数一个返回值”的简单模型。如果你在ST里又需要两个结果,比较实用的做法是拆成两个函数,或者其中一个结果放到全局变量里带出来,再或者直接用功能块。这不是什么高深理论,纯粹是实践经验:越简单的函数,越不容易在调用时写错。
VAR_IN_OUT也是函数支持的一种接口类型。它跟VAR_INPUT的区别在于,VAR_IN_OUT是引用传递,函数内部可以修改外部传入的变量本体。但我个人的建议是:函数尽量别用VAR_IN_OUT,因为这会破坏“无副作用”的纯函数特性。你在函数里改了外部变量,调用的人不一定意识到,排查问题时要多绕一大圈。真要改外部变量,直接做成FB用VAR_IN_OUT,语义更清晰。
VAR_TEMP前面说过,是函数内部唯一的“临时草稿纸”,每次调用现取现还,用之前先初始化。它的好处是:函数内部不会因为上次调用残留的数据影响本次结果,完全无记忆。它的坏处是:你没办法用它来缓存任何中间值,所有中间计算必须从头再来一遍。对于工业控制器的算力来说,这通常不是问题——一次扫描里多次调用同一个函数,计算量远远小于通信和IO的耗时。
3.3 函数里能碰哪些变量,不能碰哪些
函数体里可以使用:
- VAR_INPUT里的输入参数
- VAR_OUTPUT(如果声明了),可以给它赋值
- VAR_TEMP临时变量
- 项目全局变量列表里的变量,注意只是“读取”尽量别有写入动作
- 调用本函数时,传递给它的其他变量
函数体里最好避免做的事:
- 直接读写全局变量作为函数的内部逻辑,会让函数变成“有状态”
- 声明VAR区(根本做不到,编辑器不允许)
- 在函数里调用自身,也就是递归调用。虽然某些版本支持,但栈空间是有限的,递归次数稍微多一点就可能导致堆栈溢出,控制器直接停机或进入STOP状态。我在一个试验性项目里试过用递归做阶乘,调了几百次就出问题,从那以后我再也没在PLC上用过递归
很多人会忽略一个点:函数可以访问全局变量。这意味着什么?意味着你写了一个“看起来无状态”的函数,代码里读了一个全局变量参与计算,结果就会受全局变量变化的影响。如果调用前有人改了那个全局变量,函数输出就跟着变。这种隐式依赖调试起来非常隐蔽,我遇到过现场设备出现间歇性误动作,最后查到是函数内部引用了一个状态字,程序另一个地方恰好改了那个状态字的某一位。所以我的铁律是:函数体内部不要直接读写全局变量,需要什么数据,通过参数传进来。这叫“显式依赖”,别人看你的函数接口就知道它的输出由哪些输入决定。
4. 实操案例拆解:几个可以直接抄进项目的函数
4.1 模拟量工程量换算函数,现场用得最频繁
做模拟量采集的人都知道,PLC里读到的一般是原始数值。比如某模拟量模块0到10V对应0到27648,你要把它换算成0到100摄氏度的温度值,这就是一个纯线性的工程量转换。写成函数很合适:
FUNCTION F_Scale_AI : REAL VAR_INPUT rawValue : REAL; // 原始值 rawMin : REAL; // 原始量程下限 rawMax : REAL; // 原始量程上限 engMin : REAL; // 工程量下限 engMax : REAL; // 工程量上限 END_VAR F_Scale_AI := 0.0; // 默认返回 IF (rawMax - rawMin) <> 0.0 THEN F_Scale_AI := (rawValue - rawMin) * (engMax - engMin) / (rawMax - rawMin) + engMin; ELSE F_Scale_AI := engMin; END_IF调用示例:
tempValue := F_Scale_AI(rawValue := AI_Ch0, rawMin := 0, rawMax := 27648, engMin := -20.0, engMax := 80.0);这个函数我用了很多年,几乎每个涉及模拟量采集的工程都会复制一份。要注意的是量程下限和上限相等的情况,比如传感器断线或者模块配置错误,分母会变成0,所以我特意在函数里加了IF判断,保证不会出现除零。就算你不在乎这条IF,也建议保留,因为你不知道用户会在现场把量程配成什么鬼样子。
4.2 温度报警分级函数,把IF判断封装成统一规则
不同设备、不同测点的报警阈值往往不一样,如果把报警判断逻辑散落在程序各个地方,后期改阈值时要到处翻程序。我习惯把“按温度返回报警等级”的逻辑做成一个函数,阈值作为参数传进去:
FUNCTION F_Alarm_ByTemp : INT VAR_INPUT temperature : REAL; // 当前温度 warnTemp : REAL; // 预警阈值 alarmTemp : REAL; // 报警阈值 faultTemp : REAL; // 严重报警阈值 END_VAR F_Alarm_ByTemp := 0; IF temperature >= faultTemp THEN F_Alarm_ByTemp := 3; ELSIF temperature >= alarmTemp THEN F_Alarm_ByTemp := 2; ELSIF temperature >= warnTemp THEN F_Alarm_ByTemp := 1; ELSE F_Alarm_ByTemp := 0; END_IF调用时:
motorAlarm := F_Alarm_ByTemp(temperature := motorTemp, warnTemp := 55.0, alarmTemp := 65.0, faultTemp := 80.0);这种写法的好处是:同一套报警规则可以被电机、轴承、柜温多个测点复用,每个测点传入自己的阈值,互不干扰。哪天甲方说“电机温度报警阈值改成70度”,你只需要改电机那一个调用点的参数,不用动函数内部逻辑。
需要注意,如果是同一个测点、同一个阈值还要求报警保持(一旦报警过就保持住),那这个函数就不够用了,因为函数无记忆。保持逻辑应该放到外部或者改用FB。从实战来看,报警“分级”本身适合函数,报警“保持、消音、确认”这些有状态的操作,都应该往FB里放。
4.3 双字拆字/拼字函数,通信协议解析必备
做Modbus通信、串口协议解析时,经常要把两个16位寄存器拼成一个32位实数,或者把一个32位双字拆成两个16位字去发送。这时候手写移位和转换也能做,但每次都写一遍容易出错,封装成函数一劳永逸。
先看拼字函数。把两个WORD合成一个DINT:
FUNCTION F_WordsToDINT : DINT VAR_INPUT loWord : WORD; // 低16位 hiWord : WORD; // 高16位 END_VAR F_WordsToDINT := SHL(WORD_TO_DINT(hiWord), 16) OR WORD_TO_DINT(loWord);拆分函数我用VAR_OUTPUT返回高字和低字:
FUNCTION F_DINTToWords : DINT VAR_INPUT value : DINT; // 待拆分的双字 END_VAR VAR_OUTPUT loWord : WORD; // 低16位 hiWord : WORD; // 高16位 END_VAR F_DINTToWords := value; // 主返回值原样返回,便于调试 loWord := DINT_TO_WORD(value); hiWord := DINT_TO_WORD(SHR(value, 16));拆字设计时让函数名的主返回值等于输入值,主要是我调试时习惯在调用处直接看返回是不是等于输入,能快速确认函数有没有被调用。两个输出通过VAR_OUTPUT引出,在FBD里调用时,右侧能看到F_DINTToWords返回引脚、loWord引脚、hiWord引脚三个输出,使用时按需接线。
4.4 BCC异或校验函数,串口通信帧的保护伞
串口通信里最常用的校验方式是BCC异或校验,发送方把一帧数据的所有字节异或得到一个字节,放到帧尾;接收方同样把所有字节异或,如果等于帧尾字节就认为这一帧有效。这个逻辑只依赖输入的字节数组,没有任何状态,非常适合函数。
由于CoDeSys里数组作为函数参数需要明确类型,我通常在类型声明里定义一个数组别名:
TYPE ByteArray : ARRAY[0..255] OF BYTE; END_TYPE然后写校验函数:
FUNCTION F_CheckBCC : BYTE VAR_INPUT frameData : ByteArray; // 整帧数据 dataLen : INT; // 参与校验的字节数 END_VAR VAR_TEMP i : INT; END_VAR F_CheckBCC := 0; FOR i := 0 TO dataLen - 1 DO F_CheckBCC := F_CheckBCC XOR frameData[i]; END_FOR通信程序里接收一帧后直接调用:
rxBcc := F_CheckBCC(frameData := recvBuffer, dataLen := 8);这个函数写起来简单,但有两个实用注意点想提醒你。第一,dataLen如果大于数组定义的上限,FOR循环越界读会导致不定的结果甚至总线错误,所以调用前你必须保证dataLen <= 256,最好在函数内部再加一个IF限制,超出就返回0。第二,数组参数在CoDeSys函数里传的是实际数据还是引用,不同版本行为有差异,但绝大多数情况下,你只读不写数组内容,就不用担心性能问题。如果你要在函数里修改传入的数组内容,那就要小心了,强烈建议改成FB而不是函数,免得踩到传递方式的坑。
4.5 带死区的模拟量预处理函数,把传感器漂移挡在逻辑外面
很多温度、压力传感器在零点附近会有小幅漂移,比如温度显示0.4摄氏度、压力显示0.05千帕,虽然误差不大,但如果后面接着的是一个报警判定或积分累积,这些小漂移会被反复放大,最终导致误报警或累积误差。我习惯在采集之后先过一个死区函数,把阈值以内的波动直接归零:
FUNCTION F_DeadBand : REAL VAR_INPUT inputValue : REAL; // 原始测量值 deadZone : REAL; // 死区阈值,正数 END_VAR F_DeadBand := inputValue; // 默认输出原值 IF (inputValue <= deadZone) AND (inputValue >= (0.0 - deadZone)) THEN F_DeadBand := 0.0; END_IF使用:
filteredTemp := F_DeadBand(inputValue := tempRaw, deadZone := 0.5);这个函数最简单,但也最容易被人瞧不上,觉得不就是个IF吗。但实际设备在长期运行中,往往就是这些不起眼的小函数在帮系统挡噪音。需要提醒的是,死区函数只适合“零点附近小波动滤除”,它不能替代真正的低通滤波或均值滤波。如果你需要的是平滑曲线,那得用带历史样本的滤波算法,那又是另一类问题了,需要FB。
5. 常见问题与排查技巧:函数用得多了,你也会遇到这些
5.1 函数执行结果不对,先查这两个地方
第一个要查的就是返回值是否在所有分支都被赋值。我写过一千多行程序后回头看,发现这类低级错误依然存在。特别是逻辑复杂后,可能出现“所有条件都不满足,但没人给函数名赋过值”的情况。避免办法前面说过,函数体开头先给返回值赋默认值。
第二个要查的是临时变量有没有未初始化就使用。VAR_TEMP变量不会自动初始化这一条,是很多“间歇性错误”的源头。你单步调试第一次可能碰到0,没发现异常,但换成循环调用、再经过系统任务调度,栈上的旧数据传到函数里,结果就乱了。我的排错顺序是:先看临时变量,再看分支赋值,然后才去看输入参数有没有传对。
5.2 函数里用了全局变量,排查时绕了一大圈
这问题我在前面强调过了,这里再展开说一下排查思路。现象是:某台设备偶尔动作异常,看函数输入参数都是对的,返回值却不对。单步跟进去看,函数体里逻辑一点问题没有。最后才发现,函数里读了一个全局状态字,而这个状态字被另外一个异步任务或者中断任务更新了。
这类问题在CoDeSys项目里尤其隐蔽,因为不同任务(Task)之间如果没有做好互斥,全局变量可能在函数执行期间被其他任务改写。即使没有多任务问题,全局变量也可能在程序扫描的不同阶段被不同逻辑更新,函数的执行时机不同,读到的值就不同。
所以我在排查函数类问题时,一旦发现函数输入参数没问题但结果不对,第一反应就是全局搜索函数体内部引用了哪些全局变量。如果确实引用了,先临时改成参数传进来,再跑一下看是否恢复。这也算是我踩过坑后总结出来的排查口诀:函数出问题,先看返回值,再看全局。
5.3 给函数调试留一条后路
函数在CoDeSys里是可以打断点和单步运行的,这个大多数人都知道。但真实项目里,程序要跑在PLC上时,你很难一直保持在线单步。我常用的方法是给函数留一个调试位:利用函数名返回一个便于观察的主返回值,然后额外用VAR_OUTPUT引出一个诊断值,比如状态码、错误标志。
诊断值也要设默认值。比如F_DINTToWords这个函数,我把主返回值设为输入值本身,这样我在线监视调用处,就能同时看到输入有没有进来、输出两个字对不对。如果你的函数有多个可能出错的地方,可以在每个关键分支里给诊断输出赋不同的值,比如0表示正常、1表示量程无效、2表示输入超限。这样即使不看逻辑内部,光是看函数返回的诊断值就能定位问题。
另外,在线调试时还可以在调用处添加监视表达式(Watch),直接输入函数名+输入参数来调用函数,可以快速验证参数组合下的行为。比如在监视窗口输入F_Scale_AI(0, 0, 27648, -20.0, 80.0),看返回结果是不是-20。这个技巧在离线验证算法时很方便,不需要PLC真的接上传感器。
5.4 函数调函数,嵌套深了怎么办
我前面说了不推荐无限嵌套,但实际工程中函数调函数很常见。比如F_Scale_AI内部调用LIMIT和MAX,F_CheckBCC里面只做循环异或,本身不调别的函数。如果出现嵌套好几层,我建议你注意两点:
一是不要在多层函数调用之间传递过大数组,因为每传一层都可能带来额外的数据复制开销,影响任务周期。二是当嵌套层数超过三层时,想想是不是该用FB了。功能块可以把中间状态集中管理,在线监视比一长串函数嵌套要直观得多。
5.5 函数命名与代码规范,小事但影响很大
最后聊一个看似不重要的细节:函数命名。
我在项目里统一用F_前缀,后面跟动宾结构的简短英文,比如F_ReadTemperature、F_CalcSpeed。千万别用拼音缩写,也别用无意义的F_1、F_Test。CoDeSys的函数名在项目里是全局可见的,重名会直接编译报错,所以命名越有区分度越好。
参数命名也一样,尽量用完整单词,比如rawValue、engMax。不要只用a、b、c。函数是你的逻辑资产,隔了半年再看,好的命名能让你一眼想起这个函数是干什么的,坏的命名只能让你骂自己当初为什么不多写两个字母。
我在实际项目中还有个习惯:函数内部不做IO读写,不做通信收发,不做延时等待。IO读写和通信属于硬件相关操作,通常放到上层程序或功能块里,函数只做纯计算。这样函数的可测试性最好——我可以在监视窗口里直接喂数据看返回,不依赖现场环境。
如果你在项目里也养成了“能传参就不读全局,能无状态就不建FB,能纯计算就不碰IO”的习惯,你会发现在CoDeSys里写自定义函数是一件特别顺手的事。未必每个函数都很复杂,但胜在稳定、透明、好调试。