ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Tricon功能块图形化编程:类型前缀与扫描周期决定逻辑

Tricon功能块图形化编程:类型前缀与扫描周期决定逻辑

简介:面向工业自动化与PLC控制初学者的《tricon功能块》学习文档,聚焦Tricon系统功能块的引脚定义、数据类型与标准库调用方法,帮助刚接触该平台的人员理清输入输出连接关系与常用指令含义。文档围绕BOOL、DATE、DINT、REAL、TIME、TOD等数据类型展开,系统梳理类型转换、数学、算术、布尔、位串、选择、比较及字符串等标准编程函数,并收录双稳态、边缘检测、计数器、定时器等标准功能块。文中还说明I/O符号与内部信号符号,如w、a、v、y、d、c及r、i、e、f、g、k、t、m等标识的含义,并对STDLIB、TCXLIB中绝对值、三角函数、类型转换、计数器、触发器、延时与脉冲等功能的用法逐条解释。资源包为1个doc文档,约237KB,篇幅紧凑便于查阅;已有431人学习,适合作为Tricon平台入门与日常编程备查的速查资料。

1. Tricon 功能块:为什么拖拽连线的图形化逻辑,真正决定行为的是引脚上的类型前缀

图形化编程最容易被低估的一点是:看起来只是把方块拖到画布上拉根线,实际上每一个引脚旁边那个英文前缀,才是决定这段逻辑能不能跑对的东西。Tricon 功能块的资料里,左侧是输入、右侧是输出,引脚旁标着 BOOL、DINT、REAL、TIME 这类类型名,很多人第一次翻过去就直接跳过了,觉得那只是标注。可恰恰是这些标注,决定了模拟量到底走没走标定、计数器会不会翻转成负数、边沿脉冲会不会被下游吞掉。这份说明把标准编程函数、标准功能块和内部信号符号放在一起讲,本质上是在描述一套契约:谁能连谁、谁在什么时刻生效、谁只有一个扫描周期的寿命。契约没读明白,图形化逻辑写出来就是玄学,跑通了也不知道为什么通,跑挂了更不知道为什么挂。

2. 数据类型:为什么 BOOL 与 DINT 之间的那根线不能随便连

2.1 类型前缀不是注释,是编译期的一道墙

BOOL 是布尔型,一位,对应现场的开关量、限位、接触器辅助触点;INT 是 16 位有符号整型,范围从 -32768 到 32767;DINT 是 32 位有符号整型,模拟量的原始码通常走这一路;REAL 和 LREAL 分别是单精度和双精度浮点。这些定义本身没什么稀奇,稀奇的是它们在图形化环境里的表现——类型不匹配时,有的平台直接拒绝连线,有的平台默默插一个隐式转换节点,而你从画面上根本看不出那个节点存在。

这就是第一个反直觉的地方:同一张逻辑图,看起来一模一样,底层可能一个是"精确搬运",另一个是"截断后再搬运"。比如把一个 DINT 值接到 INT 引脚上,编译器不会拦你,但超出 16 位范围的高位会被直接丢掉,一个 70000 的模拟量码值接到 INT 上会变成 4464,数值难看还带着符号位的歧义。所以凡是跨类型连线的地方,我都会显式放一个***_TO_***转换块,让转换这件事在画面上留下痕迹——将来别人接手或者自己半年后回看,能一眼看出这里发生过类型收窄。

(* 显式转换:把"可能丢精度"这件事写在图上 *) VAR nRaw : DINT := 70000; (* 现场模拟量原始码值 *) nSmall : INT := 0; xEng : REAL := 0.0; xWide : LREAL := 0.0; END_VAR nSmall := DINT_TO_INT(nRaw); (* 70000 超出 16 位范围,高位被丢弃 *) xEng := DINT_TO_REAL(nRaw); (* 转浮点,数值量级保持 *) xWide := DINT_TO_LREAL(nRaw); (* 需要高精度累加时走 LREAL *)

这段代码里三个转换块的价值不在"能转",而在"声明了意图"。DINT_TO_INT明确告诉读图的人:这里接受精度损失,因为我只关心低 16 位。参数上要注意的是,转换函数的命名规则是"源类型_TO_目标类型",方向写反的块在库里根本不存在,所以连线时如果找不到某个转换块,先回头检查是不是把源和目标搞反了。

REAL 与 LREAL 的差别更值得单独说一句。REAL 的有效尾数是 24 位,能精确表示的连续整数上限是 2 的 24 次方,也就是 16777216。超过这个数值之后,加一并不会改变结果,因为最低位已经落到了尾数之外。做过累计流量或者长时间积分的人应该对这个现象不陌生:累加值到某个量级之后就不动了,看起来像"计数器卡死",实际上是浮点精度耗尽。这类场景要么改用 LREAL,要么把累加拆成"整数部分 + 小数部分"两段存。

2.2 WORD 与 INT:同一块内存的两种读法

WORD 是 16 位字型,DWORD 是 32 位双字型,它们和 INT、DINT 占据同样宽度的存储,但语义完全不同:前者是位串,后者是数值。位串不参与算术,它存在的意义是"打包"——把一组 BOOL 塞进一个变量里,方便通信传输、报警汇总、状态字读写。

资料里提到的BOOL_TO_DWORD就是干这个的:把布尔量转成双字型。单看这个名字容易误解成"一个 BOOL 变成一个有值的 DWORD",实际使用时通常是配合移位和按位或,把 32 个独立的布尔状态拼成一个双字。

(* 把 32 个布尔报警位打包成一个 DWORD,用于通信或状态字 *) VAR aAlarm : ARRAY[0..31] OF BOOL; dwPack : DWORD := 16#0000_0000; i : INT; END_VAR dwPack := 16#0000_0000; (* 每周期重新组装,避免残留旧位 *) FOR i := 0 TO 31 DO IF aAlarm[i] THEN dwPack := dwPack OR SHL(16#0000_0001, i); (* 第 i 位置 1 *) END_IF; END_FOR;

逻辑上有两个关键点。第一,SHL是左移,SHL(1, i)生成一个只有第 i 位为 1 的掩码,再和累加值做按位或,就完成了置位。第二,循环开始前必须把dwPack清零,否则上一周期留下的位会一直粘着,表现出来的现象是"报警消失了但状态字里还在",排查时极其费劲。

参数层面要留意位序约定。这里假定 bit0 是最低位,如果对端设备按相反的顺序解析,读出来的报警会整体错位甚至镜像翻转。跨设备通信时把这张打包表打印出来逐位核对,比事后在现场猜要省事得多。

2.3 DATE、TOD、DT、TIME:四种时间语义,不能互借

这套类型里最容易混的是四个时间相关的类型。DATE 是日期型,只表示哪一天;TOD 是时间点型,表示当天的哪个时刻,比如 10:03:04.000;DT 是日期时间连接型,把前两者拼在一起;TIME 是时间段,表示一段时长,比如 10s。前三个指向"什么时候",最后一个表示"多久",语义上是正交的。

资料里给了一个拼接的例子:输入分别是DATE#1970-01-02和TOD#00:00:00.000,输出是DT#1970-01-02-00:00:00.000。这个例子顺带暴露了一个重要信息——DATE 的纪元是 1970 年 1 月 1 日,也就是说 DATE 底层存的是"从纪元起过了多少天",TOD 存的是"当天过了多少毫秒"。知道这一点,很多看似诡异的边界行为就解释得通了:DATE 做加法加的是天,TOD 跨零点会回绕而不是进位。

(* 两个时刻求时长:先各自转成统一的时间基准再相减 *) VAR dtStart : DT := DT#1970-01-02-00:00:00.000; dtEnd : DT := DT#1970-01-03-06:00:00.000; tSpan : TIME; tRest : TIME; END_VAR tSpan := DT_TO_TIME(dtEnd) - DT_TO_TIME(dtStart); (* 30 小时 *) tRest := tSpan - T#24h; (* 拆出整天后的余量 = 6h *)

这段代码的机制说明:DT 之间不能直接相减,必须先落到同一个时间基准上。DT_TO_TIME把日期时间转成以时长表示的偏移量,相减得到的时间差才有意义。参数上,T#24h是时长字面量,小时单位用 h,毫秒用 ms,写错单位在编译期就会报错,这一点比字符串拼接安全得多。

真正容易翻车的是拿 TOD 当 TIME 用。比如想算"从 8:00 到 10:30 过了多久",如果两个值都存成 TOD,跨零点时 23:50 到 00:10 直接相减会得到一个巨大的负数。稳妥的做法是把 TOD 统一升格成 DT 再算差值,或者明确规定这个计时区间不会跨天,并在注释里写死这个前提。

3. 标准编程函数:CEIL、LIMIT、SEL 这些"一眼就会"的块,边界在哪

3.1 取整的两个方向:CEIL 与 FLOOR 差的不只是方向

CEIL 是向上取整,输出大于等于输入的最小整数,再转成实型:输入 1.1 输出 2.0,输入 1.0 输出 1.0。FLOOR 是向下取整,输出小于等于输入的最大整数:输入 2.8 输出 2.0。两个函数在输入恰好是整数时都不改变数值,这个"不动点"性质是判断实现是否正确的最快方式。

资料里 FLOOR 的示例写成"输入为 2.0,输出为 1.0",按取整的数学定义这里应该是 2.0。这类示例笔误在照抄实现时杀伤力不小——如果真按示例去写测试用例,反而会把正确的实现判成错误,或者把错误的实现当成标准。看到示例和定义打架,以定义为准,然后用边界值自己验一遍。

CEIL 和 FLOOR 在业务上的分工很明确:算"需要多少个容器"用 CEIL,算"能装满多少个容器"用 FLOOR。两者的差值就是有没有余量。

(* 分箱计算:总量 25.4,每箱 5.0 *) VAR xTotal : REAL := 25.4; xPerBox : REAL := 5.0; nFullBox : INT; (* 装满的整箱数 *) nBoxNeed : INT; (* 需要的总箱数 *) xRest : REAL; (* 最后一箱的余量 *) xRatio : REAL; xEps : REAL := 1.0E-6; (* 浮点比较容差 *) END_VAR xRatio := xTotal / xPerBox; nFullBox := REAL_TO_INT(FLOOR(xRatio + xEps)); (* 5.08 -> 5 *) nBoxNeed := REAL_TO_INT(CEIL(xRatio - xEps)); (* 5.08 -> 6 *) xRest := xTotal - INT_TO_REAL(nFullBox) * xPerBox; (* 0.4 *)

逻辑说明:FLOOR和CEIL返回的都是实型,要落到整型变量上还得再过一次REAL_TO_INT,这一步是显式的,不能省。参数说明:加xEps和减xEps是为了吃掉浮点表示误差——25.0 除以 5.0 在单精度下可能算出 5.0000001,直接 CEIL 会变成 6 箱,明明整除却多报一箱。这类问题在现场表现为"偶尔多算一个",复现概率低,靠猜是猜不出来的,只能靠容差兜住。

3.2 限幅与中值:LIMIT 与 MID 的参数顺序陷阱

LIMIT 的语义很直白:输入值在最小值 MIN 和最大值 MX 之间时原样输出,小于 MIN 输出 MIN,大于 MX 输出 MX。它是一行代码挡掉一类事故的典型代表——传感器断线时原始码值可能冲到量程之外,下游的 PID 或者比例换算拿到这个野值会直接把输出打满。

但 LIMIT 的参数顺序在不同平台上并不统一。有的库是LIMIT(IN, MIN, MX),有的是LIMIT(MN, IN, MX)。顺序搞反的后果很隐蔽:最小值位置传了输入值,输入值位置传了最小值,逻辑照样能跑,输出也落在合理范围内,只有在超限时才会表现出"限幅方向反了"。我的习惯是每次换平台先做一个三点测试——低于下限、区间中点、高于上限各喂一次,确认输出序列符合预期再接现场。

MAX 和 MIN 是多输入取极值,语义没有歧义。MID 稍微特殊一点:输入引脚为奇数个时输出中间那个数,为偶数个时输出两个中间数的平均值。资料给的例子很说明问题:输入 1、3、5 输出 3;输入 1、3 输出 2;输入 1、3、5、7 输出 4。

(* 三点中值滤波:抑制模拟量的单点野值 *) VAR rS1, rS2, rS3 : REAL; rFiltered : REAL; END_VAR rFiltered := MID(rS1, rS2, rS3);

机制说明:三个值里取中间那个,等价于"去掉一个最大值、去掉一个最小值、留中间"。单个采样点跳变时,它要么是最大值要么是最小值,都会被过滤掉,所以对单点尖峰免疫。参数上要注意的是代价——中值滤波本质上是排序,输出滞后于输入,三点中值最多滞后两拍。对于响应要求高的联锁回路,这个滞后必须算进响应时间预算里,不能当成无损滤波。

3.3 选择器:K 索引从 0 开始,别和 IEC 的 SEL 混为一谈

资料里的选择器是这样描述的:由 K 值决定输出等于哪一个输入值,K=0 则输出从 K 往下数第 1 个输入值,K=3 则输出从 K 往下数第 4 个输入值。翻译成程序员语言就是:这是个 0 起始索引的多路选择器,K 就是下标。

这里有一个移植时的高频误区。IEC 61131-3 标准里的 SEL 是二选一:SEL(G, IN0, IN1),G 为 FALSE 输出 IN0,为 TRUE 输出 IN1,选择信号是布尔量。而这套库里的选择器用整数 K 做多路索引,语义更接近 MUX。名字一样,行为完全不同,从别的平台搬逻辑过来时,只要有一处按名字对上了、按语义没对上,逻辑就会静默走错分支——不报错,不越界,只是选错了输入。

(* 多路选择 + 越界保护 *) VAR arInput : ARRAY[0..7] OF REAL; nK : INT; rDefault: REAL := 0.0; (* 越界时的安全回落值 *) rOut : REAL; END_VAR IF (nK < 0) OR (nK > 7) THEN rOut := rDefault; (* 越界不保持上次值,直接回落到安全值 *) ELSE rOut := arInput[nK]; END_IF;

逻辑说明:图形化的选择器块不会替你做下标检查,K 由上游计算得来时,越界是迟早的事。这里选择"回落到默认值"而不是"保持上一次输出",理由是保持会让故障隐形——画面上的数值看着正常,实际上早就不是当前选择的通道了。参数说明:数组上下界0..7要和选择器块实际支持的输入个数对齐,块上拉了几个输入引脚,上界就是几减一。

3.4 类型转换与数学函数:定义域比返回值更值得关心

***_TO_***这一族是这批函数里数量最多、也最容易被当成"无脑胶水"的。实际上有两个细节必须记住。第一,实数转整数时是截断还是四舍五入,不同平台可能不同,REAL_TO_INT(2.9)到底是 2 还是 3,直接决定计数结果差一。要四舍五入就显式调ROUND,不要指望转换函数替你决定。第二,转换失败时的行为——字符串转数值遇到非法字符,有的平台返回 0,有的置错误标志,有的保持原值。返回 0 是最危险的一种,因为 0 本身是合法值,故障完全隐形。

数学函数的坑集中在定义域。LN 要求输入大于 0,输入 0 得到负无穷,输入负数得到 NaN;LOG 是以 10 为底,同样受定义域约束;ACOS 和 ASIN 的定义域是 -1 到 1,超出范围返回 NaN;ATAN 没有定义域限制。EXP 和 EXPT 里,EXPT(x1, x2)是 x1 的 x2 次方,0 的 0 次方在数学上未定义,负底数配非整数指数会落到复数域,浮点实现里通常返回 NaN。

NaN 的可怕之处在于它会传播且比较永远为假。一个 NaN 流进比较块,NaN > 100是假,NaN <= 100也是假,互锁条件会静默失效——看起来逻辑都在,实际上所有判断都放行了。

(* 除法前的分母保护:用阈值代替与 0 比较 *) VAR rNum, rDenom, rQuot : REAL; xDivErr : BOOL; rEps : REAL := 1.0E-6; END_VAR xDivErr := FALSE; IF ABS(rDenom) < rEps THEN rQuot := 0.0; (* 分母近零,输出安全值 *) xDivErr := TRUE; (* 同时置诊断位,供上位机读取 *) ELSE rQuot := rNum / rDenom; END_IF;

机制说明:浮点数和 0 做相等比较是不可靠的,极端小的分母除出来的结果会直接冲到天文数字,把后续限幅、PID、输出全部打爆。用绝对值小于阈值来判断"近零",是工程上的通行做法。参数说明:rEps取 1.0E-6 是个经验值,具体要看物理量的量纲——如果分母本身就在 1E-3 量级,这个阈值得往下调两个数量级,否则正常运算会被误判成除零。

4. 标准功能块:RS 与 SR 差一个字,边沿脉冲只活一个扫描周期

4.1 双稳态:复位优先还是置位优先,写在块名里

RS 和 SR 都是双稳态触发器,区别只有一处:同时为 1 时谁说了算。RS 是复位优先,R 为 1 输出 0,R 为 0 输出 1,R 和 S 同时为 1 时输出 0。SR 是置位优先,S 为 1 输出 1,S 为 0 输出 0,同时为 1 时输出 1。

资料里对 RS 的描述带了一句很实用的话:"有复位按钮时用,S 端接复位按钮"。这句话透露出选型逻辑:启停回路里停机必须优先于启动,所以用 RS,让复位/停止信号在任何情况下都能压住启动信号。反过来,报警锁存场景希望"一旦触发就咬住",即使触发条件瞬断也要保持,这时候用 SR 更合适。

块类型置位端复位端同时为 1 时典型场景
RSSETRESET1输出 0(复位优先)启停回路、安全联锁
SRSET1RESET输出 1(置位优先)报警锁存、首出记忆

引脚名本身就带着信息:带数字 1 的那一端是"第二顺位"的。RS 的复位端叫 RESET1,说明复位是后加的那个、优先级更高的;SR 的置位端叫 SET1,同理。这个命名规律记住了,选块的时候不容易反。

(* RS:复位优先的启停回路 *) VAR fbRs : RS; xStart: BOOL; (* 启动按钮 *) xStop : BOOL; (* 停止按钮,常闭接法在外部取反 *) xRun : BOOL; END_VAR fbRs( SET := xStart, RESET1 := xStop ); xRun := fbRs.Q1;

机制说明:块实例每周期调用一次,内部状态跨周期保持,所以不需要额外的自保持逻辑。参数说明:RESET1是复位引脚,Q1是输出引脚,引脚名里的数字是这套库的固定命名,接线时按名字对,不要按位置对——不同版本的块引脚顺序可能调整过。

这里有个血泪经验:不要在同一张图上混用 RS 和 SR 做同一套启停逻辑的不同分支。看起来都是触发器,实际优先级相反,调试时会出现"按了停止还在跑"或者"报警消不掉"的现象,而且因为两个块图形几乎一样,肉眼排查极难发现。

4.2 边沿检测:脉冲的寿命只有一个扫描周期

上升沿检测块在 CLK 从 0 变成 1 时,输出一个扫描周期的脉冲;下降沿检测块在 CLK 从 1 变成 0 时输出一个扫描周期的脉冲。注意"一个扫描周期"这个限定,它不是一段时间,而是一次扫描——下一个周期就自动归零了。

实现原理其实只有两行:记住上一周期的输入值,本周期和它比较。

(* 上升沿检测的本质:本周期为 1 且上周期为 0 *) FUNCTION_BLOCK FB_RTrig VAR_INPUT CLK : BOOL; END_VAR VAR_OUTPUT Q : BOOL; END_VAR VAR M : BOOL; (* 保持型中间变量,跨扫描周期 *) END_VAR Q := CLK AND NOT M; (* 必须先算 Q *) M := CLK; (* 再更新历史值 *)

这两行的顺序是全部关键。如果先执行M := CLK再算Q,那么CLK AND NOT M里 M 已经等于 CLK,结果恒为假,脉冲永远出不来。图形化环境里块内部封装了这个顺序,所以拖块不会错;但一旦有人用基本逻辑门自己搭边沿检测,这个顺序错误出现的概率非常高,而且现象是"完全没反应",不是"偶尔出错",反而好查。

脉冲只有一个周期宽,意味着它的消费者必须排在这一个周期之内。如果脉冲产生在一个 POU 里,而消费它的块在同一扫描周期更早的位置已经被调用过了,这个脉冲就被彻底吞掉,没有任何补救机会。现场表现是"偶尔丢一次触发",复现全靠运气。稳妥的写法是把脉冲先锁进一个 RS 块,让"发生过"这件事变成状态而不是瞬时量,下游什么时候读都能读到。

还有一个更隐蔽的问题:同一个边沿检测块实例在同一个扫描周期里被调用两次。第一次调用已经把历史值 M 更新成本周期的 CLK,第二次调用时CLK AND NOT M恒为假,第二次拿到的永远是 0。所以功能块实例和调用点必须一一对应,不能图省事复用。

4.3 计数器:钳位、上升沿与 Q 的判定条件

资料描述了三种计数功能块。加计数器在 CU 为 1 时,CV 从 PV 值递增,最大值 32767;减计数器在 LD 和 CD 为 1 时,CV 从 PV 值递减,最小值 -32768;加减计数器把两者合并,CU 递增、CD 递减。

三个细节值得展开。第一是钳位。到 32767 就停住,不会回绕成 -32768,这是刻意设计——回绕会让计数器在满量程后突然变成一个巨大的负数,下游按数值做判断的逻辑全部错乱;钳位则让"卡在极值"这个故障可见,操作员一眼能看出计数不动了。第二是上升沿计数。如果按电平计数,一个持续为 1 的信号会在每个扫描周期都加一,几秒钟就冲到上限。第三是 LD 装载的优先级,装载信号有效时应该无条件覆盖当前值,其他计数动作让路。

(* CTUD 的核心逻辑:装载优先、上升沿计数、极值钳位 *) VAR nCV, nPV : INT; xCU, xCD : BOOL; xLD : BOOL; xPrevCU : BOOL; xPrevCD : BOOL; xQU : BOOL; END_VAR IF xLD THEN nCV := nPV; (* 装载优先级最高 *) ELSIF xCU AND NOT xPrevCU THEN (* 上升沿才计数 *) IF nCV < 32767 THEN nCV := nCV + 1; END_IF; (* 上限钳位,不回绕 *) ELSIF xCD AND NOT xPrevCD THEN IF nCV > -32768 THEN nCV := nCV - 1; END_IF; (* 下限钳位 *) END_IF; xPrevCU := xCU; xPrevCD := xCD; xQU := (nCV >= nPV); (* Q 的判定条件 *)

机制说明:xPrevCU和xPrevCD是保持型变量,用来记住上一周期的电平,实现上升沿检测。参数说明:nPV是预设值,既做计数的起点,也做 Q 输出的比较阈值。资料里把加计数器的 Q 条件写成"CV 大于等于 0 时输出 Q 变为 1",按计数器的通用语义这里应当是"CV 大于等于 PV"——如果真按文档字面实现,复位后 CV 为 0,Q 立刻就是 1,计数器还没开始计就已经报满了。这个矛盾在处理文档时值得专门验一次,后文排查章节会展开。

4.4 定时器:TON、TOF、TP 是三种不同的时间观

资料给了三种定时器。TON 是 0 变 1 延时输出:IN 从 0 变成 1 之后经过 PT,Q 从 0 变成 1。TOF 是 1 变 0 延时输出:IN 从 1 变成 0 之后经过 PT,Q 从 1 变成 0,ET 是 PT 变化的时间指示。TP 是脉冲输出:IN 从 0 变 1 时 Q 从 0 变 1,并保持 PT 时间。

三者的差别不在"延多长时间",而在"什么时候开始计时、什么时候结束"。TON 计的是"接通之后过了多久",TOF 计的是"断开之后过了多久",TP 计的是"脉冲还差多久结束"。选错块的现象往往是"逻辑看起来对,时序差半拍",用示波器抓一下 Q 的翻转时刻就能确认。

(* TON 的扫描周期实现:ET 递增,到点置位 *) FUNCTION_BLOCK FB_TON VAR_INPUT IN : BOOL; PT : TIME; END_VAR VAR_OUTPUT Q : BOOL; ET : TIME; END_VAR VAR rtStart : TIME; xRunning : BOOL; END_VAR IF IN THEN IF NOT xRunning THEN rtStart := TIME(); (* 记录上升沿发生的时刻 *) xRunning := TRUE; END_IF; ET := TIME() - rtStart; (* 已流逝时间 *) IF ET >= PT THEN Q := TRUE; (* 到达预设时间后置位 *) END_IF; ELSE Q := FALSE; ET := T#0s; xRunning := FALSE; END_IF;

机制说明:定时器不是硬件计时器,它靠每个扫描周期读一次当前时间做差。所以 ET 的更新粒度就是扫描周期,PT 设得比一个扫描周期还小的时候,实际延时会在 0 到一个扫描周期之间抖动。参数说明:PT是 TIME 类型,写T#5s、T#500ms都可以,单位写错编译期就会报错,这点比裸数字安全。

TOF 的行为要单独强调一下,因为它和直觉相反:IN 变 0 之后 Q 并不立刻落,而是继续维持 PT 时长才落。资料里"ET 为 PT 变化的时间指示"这句话说的就是 ET 在延时阶段会持续变化,可以用来在上位机上画倒计时进度条。

TP 则和 IN 的宽度解耦:IN 只要有一个上升沿,Q 就保证输出满 PT 宽度,哪怕 IN 只有一个扫描周期宽。这个特性适合做"按一下按钮,输出固定时长的动作",比如点动润滑、单次吹扫。

三者有一个共同的坑:定时器块必须每个周期都被调用。如果把它放在IF 条件 THEN里面,条件不满足时块体不执行,内部计时状态冻结,等条件重新满足时从冻结值继续往上数。表现出来的现象是"定时器偶尔慢半拍",而且慢的量取决于条件断开的时长,完全没有规律。所以定时器、计数器、边沿检测这三类带内部状态的块,一律无条件调用,把使能条件接到 IN 引脚上。

5. 常见问题排查:文档示例、扫描顺序与量纲这三类坑

5.1 示例与定义打架时,信谁

现象:加计数器复位之后,输出 Q 立刻变成 1,还没开始计数就已经报"满"。原因:资料里对加计数器的描述是"CV 大于等于 0 时输出 Q 变为 1",而计数器的通用语义是"CV 大于等于 PV 时置位"。这两句话在 PV 不为 0 时行为完全不同——按前者,CV 从 0 开始就已经满足条件。解决:不要把文档示例当规格书用,Q 的判定条件按CV >= PV实现,然后在联调时用三个点验证:CV 远小于 PV 时 Q 为 0、CV 等于 PV 时 Q 翻 1、CV 超过 PV 时 Q 保持 1。文档里这类描述性语句是给初学者建立印象的,真正约束行为的是块的引脚语义和现场设备表现。

现象:向下取整函数对整数输入返回了小一号的结果。原因:资料里 FLOOR 的示例写成"输入 2.0 输出 1.0",按定义应当是 2.0。照着示例写测试用例,会把正确实现判成错误。解决:以数学定义为准,把边界值(整数输入、刚好半整数、负数)单独列一组用例跑一遍,负数尤其要测——不同的取整实现对负数的处理差别很大。

5.2 扫描顺序与调用位置:偶发丢脉冲的根源

现象:手动触发一次,下游设备偶尔动作、偶尔不动,没有任何错误码。原因:边沿检测产生的脉冲宽度只有一个扫描周期,如果消费者在同一个扫描周期里排在脉冲产生者之前被调用,读到的是脉冲已经清零后的值。这类问题在逻辑图上看不出任何异常,因为连线是通的、块也是对的。解决:把脉冲信号先写进一个 RS 触发器锁存,下游读锁存器的状态,读完再复位;或者干脆把脉冲产生逻辑挪到调用链的最前端。我一般倾向于锁存方案,它把"瞬时事件"变成了"持久状态",扫描顺序就不再是变量。

现象:某个定时器偶尔延时不准确,延时长短不固定。原因:定时器块被放在条件分支里调用,条件不满足时块体不执行,内部计时状态冻结。解决:定时器无条件调用,把原来的分支条件接到 IN 引脚,让块自己决定是否计时。这条规则对计数器、边沿检测同样适用——凡是有内部状态的块,都不能有条件调用。

现象:同一个功能块实例在两个地方被调用,其中一个地方输出恒为 0。原因:实例的内部状态(历史值)被前一次调用更新了,第二次调用看到的历史值已经等于当前值。解决:一个实例对应一个调用点,需要多处使用就多声明几个实例。这点在图形化环境里尤其容易犯,因为复制粘贴一个块到别处,默认共享同一个实例名。

5.3 类型与量纲:从 DINT 到 REAL 的那一步没走

现象:画面上的模拟量数值差了好几个数量级,或者小数点位置完全不对。原因:把未标定的模拟量原始码直接当工程量用了。资料里的符号体系对此有明确区分:w 是未被标定的模拟量输入(DINT),a 是定标模拟输入(REAL),v 是未被标定的模拟量输出(DINT),y 是定标模拟输出(REAL)。原始码和工程量之间隔着一层标定换算,跳过去直接用,量纲必然错。解决:所有模拟量入口统一先标定再参与运算,标定这一步做成独立的功能块,禁止在业务逻辑里散落换算公式。资料里 TCXLIB 的"模拟量的标定"就是干这件事的,把它当成唯一的转换关卡,后面所有逻辑只认 REAL 工程量。

现象:一个负数经位串类型中转后变成了一个巨大的正数。原因:WORD 和 DWORD 是无符号位串,INT 和 DINT 是有符号数值,同一块内存按两种方式解读,符号位会被当成数据位。解决:位串类型只用于打包和拆包,任何需要参与算术的值都必须走数值类型。转换时明确方向——数值转位串是为了传输,位串转数值之前先确认目标类型能不能装下这个范围。

6. 设计取舍:为什么这套库宁可把引脚翻倍,也要把标定摆在明面上

看完这一整套符号和函数,会发现一个很有意思的选择:模拟量明明可以用一个 REAL 引脚搞定,这套库偏偏要分成 w 和 a 两套、v 和 y 两套,未标定和已标定各占一个前缀。代价很直接——接线数量翻倍,中间多出一层标定块,图面更密,初学者第一眼更容易绕晕。

但换来的是量纲错误无处藏身。如果只有一个 REAL 引脚,标定这件事就变成了"某处应该做但没人知道做没做"的隐式约定;分成两套之后,"这里拿的是原始码还是工程量"变成了类型系统层面的事实,看引脚就知道。这是典型的把隐式约定提升为显式接口的取舍:牺牲简洁,换确定性。工业现场的价值排序里,确定性通常排在简洁前面。

同样的思路贯穿到功能块设计。RS 和 SR 完全可以合并成一个带优先级参数的块,但这套库把它们拆成两个独立的块,图形上一眼就能看出这里是复位优先还是置位优先。代价是可能选错块,收益是评审时不需要点开属性面板就能判断优先级。计数器钳位在 16 位极值而不是回绕,也是同一个逻辑:宁可让故障以"卡住"的形式可见,也不要它静默翻转成看似合理的数值。

取舍点选择代价收益
模拟量引脚未标定与已标定分开引脚与转换节点翻倍量纲错误在接线上就暴露
触发器RS 与 SR 拆成两块可能选错优先级意图在图形上可见
计数器到极值钳位容量受限 16 位溢出不会静默回绕
边沿检测脉冲只宽一个周期依赖扫描顺序语义最简,无歧义

验证这类带时序语义的库,我用得最多的方法是打表:给定一串输入序列(比如 CLK 依次是 0、1、1、0、1),手工推出每个扫描周期的 Q 和 ET 应该是多少,写成一张表,然后在线监视逐拍比对。这个方法对边沿检测、定时器、计数器特别有效,因为它们的行为完全由扫描周期离散化,逐拍列表比波形图更容易发现"差一拍"的问题。

我早期吃过一次亏:一个中值滤波块和它的数据源放在同一个 POU 里,滤波器排在前面,采集更新排在后面,结果滤波永远在用上一周期的数据,滞后比我算的多了一拍,导致一个响应时间卡在临界值的回路一直调不好。查了两天才发现是调用顺序。从那以后我每次排时序逻辑,都强制先把 POU 内的调用顺序列一遍,确认状态更新的先后关系写下来,再动手连线。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进