前几天帮一位朋友排查包装设备的运行时长统计程序,凌晨跨整点之后数据差了17秒。我第一反应是查传感器、查信号,后来发现根本不是硬件问题,而是统计逻辑本身藏了坑。程序里混用了两种实现:一种用T#5S自复位定时器做周期累加,另一种用R_TRIG检测脉冲边沿计数。问题正好落到标题里那句话上:跨小时统计,T#5S相比R_TRIG,到底哪种稳?
这篇内容不是纯理论,我会把这次排查的完整链路、实测数据、以及我现在实际使用的方案都写出来。适合正在做PLC累计统计、运行时长归档、产量累计的人,尤其是那些需要跨越整点、凌晨清零、断电恢复的现场。
1. 跨小时统计为什么能让T#5S和R_TRIG打起来
1.1 真实需求:按小时归档的运行时长
先说清楚"跨小时统计"到底是个什么场景。设备运行时长统计,最常见的要求不是简单显示一个总运行时间,而是要按小时归档:每小时结束的时候,把这一小时内的运行秒数存进历史记录,下一个小时从零开始重新累计。这样就能看出哪个班次、哪个时段设备利用率高,或者哪台设备经常在某个整点前后停机。
这种需求里有个很容易被忽略的难点:设备是连续运行的,它的启动和停止时刻不会乖乖落在整点边界上。可能09:59:30还在运行,10:02:20才停下来。那么这2分50秒就要拆成两段——09:59:30到09:59:59这29秒归9点,10:00:00到10:02:20这140秒归10点。边界拆不对,对账就出问题。
这类程序在PLC里通常有两种典型写法。第一种是T#5S定时累加:用TON定时器做出一个5秒周期的脉冲,每个周期让累计值加5秒,整点时把累计值归档清零。第二种是R_TRIG脉冲计数:用R_TRIG检测一个周期脉冲的上升沿,沿来了就让累计值加一个固定步长。
很多新手会把R_TRIG方案当成更高级的做法,理由是"边沿检测比定时器准"。但这次排查之后我的结论是:R_TRIG单独拿出来和T#5S比,没有意义。必须看它的CLK信号从哪来。
1.2 两种写法的逻辑差异
先把两种写法摆出来看。T#5S的典型结构:
TON_5S(IN := RunSignal AND NOT TON_5S.Q, PT := T#5S); IF TON_5S.Q THEN RunSeconds := RunSeconds + 5.0; END_IF;逻辑很简单:设备运行时,定时器开始走5秒,走满之后Q置TRUE,累计值加5,同时NOT Q让定时器复位,开始下一个周期。
R_TRIG的典型结构:
Trig_5S(CLK := BasePulse_5S); IF Trig_5S.Q THEN RunSeconds := RunSeconds + 5.0; END_IF;注意这里的差异:T#5S自己产生时间基准,R_TRIG本身不产生时间。R_TRIG就是一个上升沿检测器,它只负责回答"CLK信号是否从FALSE变成了TRUE"这个问题。至于这个CLK是硬件秒脉冲、软件定时器脉冲、还是上位机通讯过来的脉冲,R_TRIG根本不关心。
所以真正要比的,不是"T#5S vs R_TRIG",而是"软件定时器做周期基准"和"外部脉冲源做周期基准"哪个更稳。很多人忽略了这层关系,导致排查方向从一开始就是错的。
2. T#5S定时累加的软肋:扫描抖动、整点边界与累计漂移
2.1 扫描周期抖动会把5秒拉成6秒
T#5S方案里,时间基准来自TON定时器的运行。但TON定时器的计时精度是和CPU扫描周期强相关的,这一点在现场经常被忽视。
PLC执行程序是周期扫描的,扫描周期不是恒定值。程序复杂度变化、通信任务插入、HMI读写、甚至某个子程序偶尔多跑几个分支,都会让扫描周期抖动。TON的底层实现通常是"本次调用时的累计时间加上扫描周期时间",也就是说,它的计时步长就是扫描周期本身。
我测试的现场,CPU平均扫描周期约12ms,但通信高峰时会跳到40ms以上。T#5S名义上走5秒,实际可能走5.05秒,甚至5.2秒。单看一个周期差异不大,但一天有多少个5秒周期?86400秒除以5,约有17280个周期。假如平均每个周期多出2ms,一天就会多出约34秒。我们用了一台测试台跑36小时,T#5S方案最终多出了31秒,和这个估算比较接近。
更麻烦的是,这种误差不是均匀分布的。它会集中在通信繁忙时段一次一次地累积,你很难在事后用固定系数去修正。T#5S适合做"大致统计",比如设备运行了大概几个小时,但如果拿这个数去对账、考核、计费,一定会出问题。
2.2 整点归档时的相位冲突与丢数问题
跨小时统计的真正难点不在计时精度,而在整点归档那一刻的相位冲突。
很多程序的归档逻辑是这样的:系统时间走到整点时,把当前小时的累计值写入历史,然后清零重新累计。这个逻辑配合T#5S会出现几种很尴尬的情况:
第一种,定时器刚好走到一半时遇到整点清零。比如09:59:58清零,当时T#5S已经走了3秒,还剩2秒触发。清零之后,定时器从0重新开始,那已经走过的3秒就这么丢了。一次丢3秒不明显,但一天24次整点,每次都可能丢0到5秒,长时间下来就是一笔糊涂账。
第二种,更隐蔽。整点清零的瞬间,T#5S的Q刚好置TRUE,IF分支执行+5,然后清零。这个5秒就被记到下一小时,但实际时间可能属于上一小时的最后几秒。严格对账时,两边都说不清楚。
第三种,不是逻辑问题,是数据保持问题。T#5S方案里,累计值和定时器的剩余时间是否做了断电保持?很多程序只用普通BOOL,断电重启后定时器从零开始,累计值如果没保持也是零。停机这段时间如果设备实际没运行,那清零问题不大;但如果是短暂断电马上恢复,设备还在运行,重新从零累计就会把一部分运行时间算丢。
这些问题叠加在一起,T#5S方案在跨小时边界上的表现只能用"碰运气"来形容,稳定性无从谈起。
3. R_TRIG脉冲累加的稳定边界:时钟源质量决定一切
3.1 时钟源质量决定"稳不稳"
回到R_TRIG方案。前面说过,R_TRIG只是边沿检测器,稳不稳完全取决于CLK信号的质量。我把CLK来源分成三类,稳定性差异很大:
第一类是硬件时钟派生的脉冲,比如用RTC秒脉冲、分钟脉冲作为基础信号。这类信号来自晶振,精度高,不随扫描周期漂移。R_TRIG配合这种时钟源,计数精度可以做到很高,长时间运行基本没有累积误差。
第二类是软件定时器生成的脉冲,比如用一个TON定时器产生5秒周期信号,再喂给R_TRIG。这种情况就是"换了个姿势用T#5S"——时间基准仍然是软件定时器,该有的扫描抖动、相位漂移一点没少。用R_TRIG只能让逻辑好看一点,对精度没有任何帮助。
第三类是上位机或者通讯链路给的脉冲。现场有时为了和上位机保持同步,会用通讯周期作为脉冲来源。这种信号的稳定性取决于网络质量,偶发延迟、丢包都会导致脉冲宽度变化。R_TRIG对这种抖动是照单全收的。
所以判断R_TRIG方案是否稳定,第一件事是看CLK的源头。如果CLK来自硬件时钟,R_TRIG确实比T#5S稳;如果CLK来自软件定时器,那就别指望它能救精度。
3.2 漏脉冲与重复计数的触发条件
R_TRIG方案还有两个非常容易踩的坑:漏脉冲和重复计数。
漏脉冲的触发条件是:PLC扫描周期大于脉冲宽度,或者R_TRIG所在的任务OB在多处同时调用同一个实例。举个例子,秒脉冲宽度是1秒,正常情况下扫描周期20ms,稳稳能检测到上升沿。但如果某轮扫描被通信任务卡到1.5秒,这期间上升沿过去之后又恢复成FALSE,下一次扫描看到的仍然是FALSE,边沿就丢了。
重复计数的情况更隐蔽。如果同一个R_TRIG实例被两个不同优先级的OB调用,比如OB1里调一次,定时中断OB里又调一次。第一次调用已经把沿状态记录下来,第二次调用如果CLK仍然是TRUE且沿没有被重置,它就又返回一次Q=TRUE。两次判断,加了两笔,实际只发生了一次脉冲。
解决方法是:R_TRIG实例每个任务独占一个,别共享;脉冲信号要保证宽度远大于任务扫描周期,一般建议脉冲宽度至少是扫描周期的5倍以上;跨任务用脉冲时,加一个中间变量做传递,只由一个任务来判定沿。
即使这些都做对了,R_TRIG方案还有一个绕不开的问题——整点附近的脉冲相位。假如秒脉冲的上升沿恰好落在59:59.998,而整点清零发生在59:59.999,那么这一秒会被记到上一小时,下一秒的上升沿在00:00.001,又会被记到下一小时。一秒的归属误差虽小,但每天都固定在某个时段出现,对账时就能被看出来。
4. 36小时实测:偏差数据与排查链路
4.1 实测平台与测试规则
为了把问题说清楚,我在内部测试平台上做了一组对比测试。平台是一台主流PLC,三个独立的测试任务分别跑三种方案:
- 方案A:T#5S自复位定时器累加,5秒周期
- 方案B:R_TRIG检测硬件RTC派生的秒脉冲上升沿,每秒计量一次,内部计数器满5秒加5秒
- 方案C:时间戳差值方案,后面会详细讲
测试规则统一:同一组运行信号驱动三套逻辑,运行信号设置为每5分钟运行4分钟、停止1分钟的循环模式。外部用高精度时钟作为基准,每6小时记录一次累计值。整个测试跑了36小时。
结果如下表:
| 方案 | 时间基准 | 36小时理论运行时长 | 实际累计值 | 偏差 |
|---|---|---|---|---|
| A:T#5S | 软件TON | 21600秒 | 21631秒 | +31秒 |
| B:R_TRIG+硬件秒脉冲 | RTC晶振 | 21600秒 | 21602秒 | +2秒 |
| C:时间戳差值 | RTC时间戳 | 21600秒 | 21600.4秒 | +0.4秒 |
T#5S的31秒偏差正是扫描周期放大累积出来的。R_TRIG方案的2秒偏差主要来自整点清零时刻和秒脉冲相位的错位,以及启动/停止瞬间半个计数周期的边界误差。时间戳差值方案的偏差则来源于读取系统时间的量化误差和类型转换误差,基本可以忽略。
4.2 差异触目惊心的地方
T#5S方案的偏差不是均匀分布在36小时里的,这是最需要注意的。刚启动的前6小时,偏差只有约3秒,看起来很正常。但到第18小时后,通信任务被上位机轮询频繁占用,扫描周期从12ms逐渐跳到25ms,偏差就开始加速增长。最后6小时多了约10秒,几乎占了总偏差的三分之一。这说明T#5S方案的误差随负载波动而波动,不是一个固定可修正的系统误差,无法在程序里做补偿。
排查时我还发现一个细节:T#5S方案在设备停止的那1分钟里,即使定时器没有累加,它的相位仍然在变化。设备再次启动时,定时器可能正好处于一个"半周期"状态,于是启动后的第一个5秒实际上不到5秒就触发了累加。这种相位错乱在T#5S自复位结构里几乎是无法避免的,除非每次启动时把定时器彻底复位,但复位本身又会导致启动瞬间丢失时间片。
R_TRIG方案的2秒偏差则集中在整点前后。36小时里共经历了36次整点,每次整点都可能发生清零和脉冲边沿的相位冲突,个别小时出现1秒的错位,累计起来就是2秒左右。这种偏差虽然小,但如果你用这个数据做小时报表,某几个小时的运行时间会显示成59分59秒或者60分01秒,看起来就很怪。
T#5S的31秒偏差则直接导致了对账失败。凌晨对账时,实际运行时长21600秒,累计值却是21631秒,多了31秒。你说多就多吧,关键是客户那边用外部电能表做交叉验证,电能表显示运行时间约21600秒,对不上。问题就从这里暴露出来。
5. 跨小时统计的稳妥方案:时间戳差值替代数脉冲
5.1 不再数脉冲,改算时间戳
经过这次排查,我现在的习惯是:凡是跨小时、跨天、跨月的运行时长统计,一律用时间戳差值方案,不用T#5S,也不依赖R_TRIG去数周期。
核心思路很简单:R_TRIG只用来检测"启动信号上升沿"和"停止信号下降沿"这两个关键动作,真正的运行时长用系统RTC时间戳相减得到。
VAR DevRunning : BOOL; StartRising : R_TRIG; StopFalling : F_TRIG; RunStartTS : LDT; RunStopTS : LDT; RunDurationSec : LREAL; END_VAR StartRising(CLK := DevRunning); IF StartRising.Q THEN RunStartTS := ReadSystemTime(); END_IF; StopFalling(CLK := DevRunning); IF StopFalling.Q THEN RunStopTS := ReadSystemTime(); RunDurationSec := (RunStopTS - RunStartTS) / 1000000000.0; AddRunTimeToHourBucket(RunStartTS, RunStopTS, RunDurationSec); END_IF;这个方案为什么稳?三个原因。
第一,不依赖扫描周期。读取时间戳的动作在扫描周期里执行,但读到的值本身是PLC RTC的绝对时间,不受扫描周期长短影响。哪怕某轮扫描卡了1秒,读出来的时间戳仍然是真实时间。
第二,不累积误差。每次启动和停止只做一次时间戳相减,误差来自单次读取的量化精度,不会像T#5S那样一个周期一个周期地累积。时间戳本身有硬件晶振支撑,长时间运行不会漂移。
第三,整点边界处理干净。因为手里有完整的开始时间和结束时间,跨小时拆分可以精确到毫秒级,不会出现"定时器半周期被清零"这类尴尬。
5.2 跨小时拆分归档的写法与避坑
时间戳方案的核心难点在于跨小时拆分。我给你一个可以直接套用的思路。
假设设备从09:59:30运行到10:02:20。拆分的逻辑是:
- 先取开始时间的小时边界,即下一个整点:10:00:00
- 如果结束时间小于下一个整点,说明没有跨小时,整段时间归当前小时
- 如果结束时间大于下一个整点,把开始时间到整点之间的秒数归入9点,然后把整点作为新的开始时间,继续往下拆,直到结束时间落在一个小时段内
写成伪代码:
// 伪代码:把[StartTS, StopTS]按小时拆分归档 RemainStart := StartTS; WHILE StopTS > NextHourBoundary(RemainStart) DO BucketHour := GetHour(RemainStart); BucketSecs := Seconds(NextHourBoundary(RemainStart) - RemainStart); AddToHour(BucketHour, BucketSecs); RemainStart := NextHourBoundary(RemainStart); END_WHILE; // 剩余部分归入最后的小时段 BucketHour := GetHour(RemainStart); BucketSecs := Seconds(StopTS - RemainStart); AddToHour(BucketHour, BucketSecs);这里有几个非常容易踩的坑,我逐一说明。
**坑一:断电重启后的启动时刻丢失。**设备断电后重新上电,如果运行信号仍然是TRUE,R_TRIG不会触发上升沿,RunStartTS就不会更新。解决办法是:在CPU首次扫描时检查DevRunning状态,如果为TRUE,立即用当前时间初始化RunStartTS。这样即使设备在你断电期间一直运行,恢复后也能从恢复时刻继续累计。
**坑二:时间戳相减的数据类型转换。**不同品牌的指令库差别很大,有的返回纳秒,有的返回微秒,有的返回100纳秒单位。我在测试中因为单位搞错,差点把0.4秒的偏差算成400秒。建议在使用前先用固定时间间隔做一次校验,确认转换系数正确再投入使用。
**坑三:时区与夏令时问题。**时间戳方案最怕系统时钟被人为调整。如果现场操作员在整点前后手动改了系统时间,或者PLC处于夏令时切换地区,时间戳差值可能变成负数。稳妥的做法是:检测到时间戳差值为负数时,强制归零并记录报警;或者全部使用UTC时间戳运算,显示时再转本地时间。
**坑四:归档动作本身的时机。**整点清零这个动作建议延后执行,比如整点后500ms再归档清零。如果零点时脉冲边沿恰好到来,延后清零可以避免上一秒的归属漂移。但延后不能太久,否则下一小时的初始累计值会混入上一小时的尾部数据。
时间戳方案的代码量比T#5S方案多不少,从"十几行"变成"几十行",还要处理数据类型转换和数据保持。但换来的是长时间运行可对账、跨边界不丢数、断电恢复可追溯,对现场运维来说价值非常高。
6. 选型结论与我的实操习惯
6.1 一张决策表说清楚怎么选
先给一张策表,这是我在实际项目里用的选型逻辑:
| 应用场景 | 推荐方案 | 原因 |
|---|---|---|
| 设备运行状态联锁、超时报警、短周期判断 | T#5S | 代码简单,响应快,精度要求低 |
| 运行时长展示,不跨整点归档,仅参考 | T#5S | 省资源,足够用 |
| 按小时/天归档,跨整点对账,长期运行 | 时间戳差值 | 边界干净,无累积漂移 |
| 有现成硬件秒脉冲,且归档逻辑已经成熟 | R_TRIG+硬件秒脉冲 | 精度高,但注意整点相位处理 |
| 停机断电频繁,需恢复数据 | 时间戳差值+保持型开始时间 | 重启后能正确恢复累计 |
这里多说一句:R_TRIG并不是不能用于跨小时统计,前提是CLK必须来自硬件时钟,同时你必须处理好两个问题——整点清零和脉冲相位冲突、以及不同任务调用带来的重复计数。如果这两个问题都能搞定,R_TRIG方案也能做到秒级精度。但它的调试成本并不比时间戳方案低,所以我个人不会把它列为跨小时统计的首选。
6.2 几个实测后总结的习惯
最后分享几个我这几年做累计统计养成的习惯,都是从踩坑里换来的。
**习惯一:做任何累计统计前,先问三句话。**归档粒度是什么?跨不跨整点?断电后要不要恢复?这三个问题的答案基本就定死了方案选型。问完之后再写代码,能避免一半以上的返工。
**习惯二:T#5S方案的定时器复位一定要检查。**有些PLC的TON没有单独复位输入端,自复位写法五花八门。我见过有人写IN := TRUE且没有复位逻辑,结果定时器Q永远置位,累计值每个扫描周期都加5秒,一晚上加了十几万秒。这种低级错误排查起来非常浪费时间,写完之后最好强制监视定时器Q的翻转周期,确认和设定周期一致。
**习惯三:R_TRIG实例绝不共享。**每一个沿检测任务用独立的实例,绝对不在两个OB里复用同一个R_TRIG。多任务传递脉信号时,加一个中间BOOL做交接,只让一个任务消费沿。这条规则能直接消除重复计数的隐患。
**习惯四:CPU负荷过高的时候,不用数脉冲方案做统计。**扫描周期超过50ms,或者CPU负荷经常超过70%,T#5S和R_TRIG的稳定性都会明显下降。这种情况下要么优化程序结构降低负荷,要么直接上时间戳差值方案。硬撑着用简单方案,最后对账时还是要还债。
**习惯五:归档清零时间点统一延后100到500毫秒。**延后清零可以让整点边界的脉冲相位错位问题大幅减少,代价只是下一小时的初始值多包含几百毫秒的尾部数据。对于小时级报表来说,这个误差完全可控,但对账时明显更干净。
我现在做跨小时统计,基本上不再纠结T#5S和R_TRIG谁稳了。R_TRIG还是用的,只是用来检测启动和停止动作;T#5S也很好用,只是用在联锁和报警这些短周期场景。真正涉及跨小时、跨天的运行时长归档,我全部改成时间戳差值。这套组合拳用到现在,凌晨对账再也没有因为统计逻辑本身出过问题。