两年前我做了一套设备程序,发给客户不到两个月,尾款拖着不给,程序却被对方工程师研究得明明白白。后来我在程序里加了一个时间加密功能块,到期不付清款项,设备直接锁定停机。那次回款周期从拖了大半年变成两周内结清。
我不是教唆别人去耍手段,而是想说明一个现实问题——在设备租赁、分期付款、试用期授权、年度维护费这类商业模式里,PLC程序一旦交付到现场,作者就失去了物理控制权。你花几个月写的逻辑、调好的工艺参数,对客户来说只是一串可以复制转移的二进制。时间加密程序块就是把这些商业约定写进控制器本身,让一台设备到了约定时间就自动进入“待授权”状态。这篇文章就基于我为1200/1500系列PLC开发的加密功能块,把完整的思路、SCL代码、防绕过细节和实测坑点全部摊开讲。
1. 设备回收和尾款博弈:时间加密程序块解决的商业难题
1.1 三种高频应用场景
我接触过的需求里,时间加密块主要解决下面三类问题:
**分期付款设备。**很多非标设备是“首付30%发货,调试验收后付60%,质保到期付10%”。前两笔还好说,最后一笔最容易拖。程序锁定一到位,客户会在最后期限前主动联系你。这是我最常见的应用场景。
**租赁设备。**工程机械、测试设备、临时产线经常以月租形式合作。租赁时间到了,如果客户没有续费,设备不能用,这是硬需求。
**试用版程序。**设备商想给潜在客户试运行30天,体验好了再签合同。试用期到期之后自动降级为手动低速模式,比直接停机更有人情味,也更容易促成签单。
1.2 时间加密与普通密码保护的本质区别
普通密码保护,比如博图里设置CPU的访问密码、程序块的Know-how保护,解决的是“你能不能打开程序、能不能看到逻辑”的问题。它不解决“程序到期能不能继续运行”的问题。
时间加密块关注的是运行权——设备可以正常跑,但过了授权期,要么停机,要么降级,要么拒绝执行关键动作。它本质上是把“合同中的时间条款”翻译成一段PLC逻辑,让程序自己执行履约动作。
很多开发者会用触摸屏的登录权限来实现到期锁定,但那个方案有个致命弱点:上位机重装系统、换一个触摸屏、下载一个新工程,时间限制就消失了。把锁定逻辑放在PLC底层,才是真正不可绕过的位置——除非对方重新开发整套控制程序。
1.3 为什么锁定逻辑必须放在PLC内部
放在上位机或触摸屏里,破解成本太低。PLC内部则不同:普通的设备使用方没有博图软件,就算有,也不知道你存在哪个DB里、调的哪个块。即便对方读出了程序,面对的是加密后的数据和被打乱的逻辑结构,想改也要花大功夫。
更重要的是,PLC内部可以读取硬件时间戳和运行状态,能感知到“反向跳变”这类篡改行为。比如你记录上次运行时间,突然发现当前时间比上一次记录的时间早了三天,这明显是有人把系统时间改回去逃避锁定。这种智能判断,只有PLC侧逻辑才能做到。
2. 时间加密的技术骨架:时钟读取、密钥混淆和状态机设计
2.1 读懂1200/1500的时间读取机制
博图TIA Portal为1200/1500提供了两条读时指令:RD_SYS_T(读系统时间)和RD_LOC_T(读本地时间)。
RD_SYS_T读的是UTC标准时间,不考虑时区。如果你的客户在新疆或者海外,UTC时间和北京时间存在时差,直接拿它比较日期会差出8个小时,认定截止日就会出错。
RD_LOC_T读的是CPU内部设置的本地时间,也就是你在博图硬件配置或系统时间里设定的北京时间。客户现场调整过时区的,本地时间也能保持一致。所以我所有时间加密块都用RD_LOC_T,这是第一个经验。
还有一个细节:从指令返回值里拿到的DTL结构体包含年、月、日、时、分、秒、纳秒。我们比较日期时只需年、月、日。如果你把时分秒也参与比较,可能导致客户在截止日当天零点一过就被锁定,体验不好。我的做法是只比较到“日”,截止日当天24点之前都算有效。
2.2 授权日期如何安全存储:参数混淆与CRC校验
最傻的写法是把截止日期直接放在一个DB里,比如DB_Date.CurrentDate := DWORD#20261231。然后程序里比较当前日期是否大于这个值。这种写法等于把到期日写在脸上,客户用博图在线监控一眼就看到,改掉就能续命。
我采用的方案是“混淆存储+CRC校验”双保险。
混淆存储的逻辑如下:预置一个密钥KEY_1 = 0x5A3C9E71,把真实截止日期(比如20261231)转换成一个DWORD,然后做异或运算,得到的密文存进DB。程序运行时先把密文和KEY异或还原出真实日期,再与当前日期比较。
这样即使对方在监控表里看到DB的数值,那也只是一串毫无意义的数字。他没法区分哪个值是密钥、哪个值是截止日。
异或并不是牢不可破的加密,所以我再加一道CRC校验。CRC由截止日期、客户编号、设备序列号共同计算。启动时程序校验CRC,校验不过直接进入锁定状态,防止有人通过暴力枚举改日期。
2.3 状态机的核心状态划分
我把时间加密块设计成一个状态机,而不是简单的“超时锁机”。这样后期扩展权限、延长试用期都很方便。
状态划分如下:
- S_STARTUP:上电初始化,读取保持性存储的上次时间戳,进行篡改检测。
- S_RUN:正常运行状态,剩余天数大于0,设备全功能放开。
- S_WARN:剩余天数小于设定的预警阈值(比如7天),输出警告信号给触摸屏,提醒联系厂商续费。
- S_LOCK:剩余天数为0,设备进入锁定流程。
- S_FAULT:检测到时间篡改、CRC校验失败、密钥不匹配,强制锁定并触发报警。
状态转移采用OB1每次扫描循环调用FB。所有状态都伴随着一个“已运行分钟数”累积计数器,这个计数器使用掉电保持变量存,配合实时时钟一起做异常判断。
3. SCL实现一个可落地的授权锁定FB:关键代码逐段解析
3.1 功能块接口定义
直接给出我在博图里创建的FB接口定义,这个块名为“FB_TimeLock”。
VERSION : 0.1 VAR_INPUT bEnable : BOOL := FALSE; // 功能块总使能 bExecuteNow : BOOL := FALSE; // 手动执行一次状态刷新 iContexCode : UINT := 0; // 设备编号/合同编号,参与CRC iExpireLimit : UINT := 0; // 分月授权的月数,与加密截止日期二选一 END_VAR VAR_OUTPUT bLocked : BOOL := FALSE; // 是否处于锁定状态 bWarn : BOOL := FALSE; // 是否进入预警期 iRemainDays : INT := 0; // 剩余天数 wState : WORD := 0; // 当前状态值,便于上位机显示 wErrorCode : WORD := 0; // 错误代码 END_VAR VAR_IN_OUT auditTimer : TON; // 运行计时器,用于累计运行时间 END_VAR VAR // 内部变量,全部没有保持属性,但关键数据存储在保持性DB中 dtlNow : DTL; dwRawDeadline : DWORD; // 当前从加密区解密出来的截止日期 dwCrcValue : DWORD; // 计算出的CRC值 dwUpdateFlag : DWORD; stSystemTime : DTL; wYear : WORD; wMonth : WORD; wDay : WORD; lLastRunDate : LWORD; // 上一次运行时的日期,用于回拨检测 END_VAR VAR_CONSTANT KEY_XOR : DWORD := DW#16#5A3C9E71; CRC_POLY : DWORD := DW#16#EDB88320; WARN_MIN_DAYS : INT := 7; END_VAR出于篇幅,这里略去IO映射。实际使用时,把具体设备的截止日密文放在一个保持性DB中,例如:
DB_TimeAuth.EncDeadline : DWORD := DW#16#A1B2C3D4; DB_TimeAuth.CrcValue : DWORD := DW#16#98765432; DB_TimeAuth.LastRunDate : LWORD := 0;3.2 读取本地时间并转换成可比较的日期格式
RD_LOC_T是博图的标准系统指令,在SCL中直接调用:
#stSystemTime := "RD_LOC_T"(RET_VAL := #wErrorCode);更严谨的写法是把返回状态也接出来:
IF "RD_LOC_T".RET_VAL = 0 THEN #dtlNow := "RD_LOC_T".LOCAL_TIME; ELSE #wErrorCode := DW#16#8001; END_IF;得到DTL结构体后,我把它转换成一个长整数YYYYMMDD,方便和加密截止日期放在同一个数量级比较。
#lToday := INT_TO_LWORD(#dtlNow.YEAR) * 10000 + INT_TO_LWORD(#dtlNow.MONTH) * 100 + INT_TO_LWORD(#dtlNow.DAY);顺便说一下,DTL里的YEAR、MONTH、DAY都是UDInt类型,我编写时直接取数值参与计算,不需要额外转换。
也有人习惯把日期转换成Unix时间戳再比较。但1400年范围的DTL显然不是Unix时间戳能表达的,S7-1500支持64位时间戳,但1200有一些限制。用YYYYMMDD整数反而最简单——它天然就是字典序,20261231 > 20251231这个逻辑永远成立,无需任何转换函数。
3.3 截止日期的解密和CRC校验
每一步都要解释清楚,方便你改造成自己的方案。
// 1. 从保持性DB读取密文 #dwEncDeadline := "DB_TimeAuth".EncDeadline; // 2. 用固定密钥异或还原真实截止日期 #dwRawDeadline := #dwEncDeadline XOR KEY_XOR; // 3. 把还原出的DWORD转成字符串样式的日期,方便比较 // 例如还原后为 DW#16#20261231,转为INT时是20261231 #iDeadlineDate := DWORD_TO_INT(#dwRawDeadline); // 4. 计算CRC校验 #dwCrcValue := CRC32(DB_TimeAuth.EncDeadline, 参数2, 参数3); IF #dwCrcValue <> "DB_TimeAuth".CrcValue THEN #wState := 5; // S_FAULT #bLocked := TRUE; RETURN; END_IF;CRC32我是直接用博图自带的库函数CRC32实现的,不需要自己打表。如果手里没有这个库,也可以用异或和移位搭一个简化版本,但防护等级会低一些。
3.4 剩余天数计算与状态判断
这是整个块的核心判断逻辑:
#iRemainDays := #iDeadlineDate - #lToday; IF #iRemainDays <= 0 THEN #wState := 4; // S_LOCK #bLocked := TRUE; #bWarn := FALSE; ELSIF #iRemainDays <= WARN_MIN_DAYS THEN #wState := 3; // S_WARN #bLocked := FALSE; #bWarn := TRUE; ELSE #wState := 2; // S_RUN #bLocked := FALSE; #bWarn := FALSE; END_IF;注意,#iDeadlineDate - #lToday这两个变量一个是INT,一个是LWORD,直接比较会有类型提示错误。我这里为了可读性省略了类型转换,实际代码里我会统一用LWORD计算,然后截取高字给到输出。
3.5 主程序调用方式
FB里需要处理周期调用,所以我在OB1里这样写:
"FB_TimeLock_Instance"( bEnable := TRUE, bExecuteNow := "Tag_Trigger", iContexCode := 1001, iExpireLimit := 0, bLocked => "Tag_Locked", bWarn => "Tag_Warn", iRemainDays => "Tag_RemainDays", wState => "Tag_State" );调用频率没有特殊要求,我建议每500ms调用一次即可。不需要每个扫描周期都读时间,减少系统负担。可以用OB1里的全局时基计数器控制。
4. 防绕过设计:从日期篡改到存储区篡改的攻防细节
4.1 系统时间回拨检测
这是时间加密块最容易被攻击的一环。客户发现设备锁了,把CPU时间改回一个月前,再重新上电,锁定就失效了。所有只做日期比较的加密块都怕这一招。
我的应对是把“上次运行日期”保存在保持性DB里(非易失存储)。每次FB执行时做下面这个判断:
IF #lLastRunDate > #lToday THEN // 当前时间比上次运行时间还早,说明系统时间被回拨 #wState := S_FAULT; #bLocked := TRUE; END_IF;运行正常时,#lLastRunDate会刷新为当前日期。回拨检测的理论前提是:物理时间不可逆,PLC断电后时间只可能往前进,不可能往后退。除非对方把CPU的RTC电池断开、重新写时间,那已经属于物理破解范畴,不在逻辑防护讨论范围内。
4.2 运行时间累积计数器
还有一类绕过方式是频繁断电:到期前一天把设备停机,过完截止日再开机,因为PLC没有持续运行,RTC只在断电期间继续走,客户认为“我没用,凭什么扣时间”?这种需求在租赁场景很常见。
于是我在块里增加了一个运行时间累计器,用保持性变量存储累计分钟数:
#auditTimer(IN := #bEnable AND NOT #bLocked, PT := T#5M); IF #auditTimer.Q THEN #auditTimer(IN := FALSE); #lRunMinutes := #lRunMinutes + 5; END_IF;这样无论客户怎么断电开机,只要设备实际运行满X小时,累计时间一到就锁定。算是给“按自然日授权”的补充约束。
4.3 存储区篡改防护
客户如果拿到博图项目,最直接的做法就是在线监控DB,把EncDeadline的值改成一个很远的日期。所以要给关键变量套上两层防护:
第一层是CRC,改动密文后CRC对不上,锁死。
第二层是自动修复,在FB内部把密文和CRC同时读取回来,如果发现不一致,重新从另一个影子DB把原始值抄回来。这需要你在程序中存放两份——一份在可读写的DB里,一份位于受保护的程序块内部静态变量中。静态变量不像DB那样直接在线监控时容易定位,增加了破解难度。
4.4 针对“换卡”“换CPU”的招数
更高级的绕过方式:把程序从锁定的CPU拷贝到另一台同型号CPU,DB里的密文和CRC也随之拷贝,但设备编号不同,CRC校验里加了iContexCode,结果就是拷贝后的程序跑不起来。
这也解释了为什么我的CRC计算会把合同编号/设备编号作为参数参与计算。每台设备的授权数据都是独有的,无法一码多用。
5. 实测1200/1500的差异和调试经验汇总
5.1 1200与1500读时指令行为差异
在实际项目中,1200和1500对RD_LOC_T的处理有细微差别,在仿真和实体PLC上表现也不一样。
1200系列部分老固件版本,RD_LOC_T返回的是CPU内部时间,但首次上电未设置本地时间时,返回的可能是1970年1月1日或2000年1月1日这种默认值。这会导致所有设备在第一次使用时间加密块时直接判定到期。我的处理办法是在初始化OB100里做一次时间合理性检查:如果读取到的年份小于一个阈值(比如2020),就自动置一个“时间未设置”标志,让FB进入报警而不是锁定。等客户设置好时间后再恢复正常判断。
1500系列没有这个问题,它通常支持NTP时间同步,上位机或路由器可以自动校时。但是这也带来一个隐患——如果客户的路由器里有NTP服务器,他可以把时间调回。所以我在1500的项目里,NTP时间同步配置关闭,只允许通过博图或触摸屏手动设定,并且加设回拨检测。
5.2 仿真器调试的坑
博图的PLC仿真里,系统时间默认跟随电脑时间。我在仿真器里测试时发现一个问题:你把电脑系统时间改回去,仿真器里的RD_LOC_T也会跟着变,这本来是用来测回拨检测的便利条件,但仿真器并不支持真正的断点保持,你无法模拟“断电后RTC继续走”的场景。
所以回拨检测和累计运行时间这两个功能,我基本靠实体PLC做终测。仿真器只能验证正常到期、预警这两个状态流转。
5.3 功能块执行周期的选择
一开始我把FB放在每个OB1扫描周期执行,结果系统开销异常明显,因为RD_LOC_T的调用相对耗时。改成每500ms执行一次后,CPU负载明显下降。
我的建议是不要在中断OB里调用时间读取,尤其是硬件中断和定时中断OB里时间读取会导致中断周期延长。放在主循环里用500ms到1s的节拍触发,完全够用。
5.4 对程序文件本身的附加保护
时间加密块再强,程序被人整体读走也是白搭。所以我在交付时还会给博图项目设置几道关卡:
- CPU访问级保护:设置写保护密码,防止在线修改程序。
- 程序块Know-how保护:在FB属性里勾选“Block encryption with password”,防止别人直接打开块查看逻辑。
- 全局DB的写保护:关键授权DB设置成“Only write in safety program”或通过引用保护。
这三层叠加后,普通设备工程师基本没办法绕过时间锁定,直接破解的可能性会大幅降低。你会问,“既然块保护这么强,为什么还要做时间加密?”因为Know-how保护防的是“看懂”,时间加密块防的是“到期继续用”,两件事各管各的。
5.5 给客户的交付体验
最后分享一个体验上的细节:我给设备交付时,会在触摸屏上做一个“授权信息”页面,显示剩余天数、合同编号、下次续费联系人。客户看到剩余天数一天天减少,心里有数,就不会突然被停机搞蒙。同时我在报警区提醒“剩余天数不足7天,请联系设备商续费授权”,把人情味做足,避免买卖双方因为锁机闹得不愉快。
这个页面需要上位机从我们的FB里读取iRemainDays、bWarn、bLocked这几个变量,直接通过HMI变量映射即可,非常简单。如果你的设备没有触摸屏,也可以通过S7通讯把状态发给上位机,或者用PLC的报文发送到云端。
做时间加密块这些年,我最大的感受是:技术本身不难,难的是在“保护自己”和“不惹怒客户”之间找到平衡点。一个设计良好的授权块,应该像守时保安,而不是路障——提前提醒、到点锁门、但永远留好协商通道。我见过不少同行直接把到期逻辑写得异常激进,结果客户当场翻脸,设备被停产后生产线断货,闹到法务。反过来,如果你能在程序里多提供一个“紧急解锁3天”的功能,而且这个功能必须先获取动态验证码才能启用,那么你在客户面前的谈判空间会大很多。这也是我下一步准备在这个FB上继续扩展的方向。