这两年接过不少西门子S7-1200/1500的项目,十有八九的客户都会提同一个需求:能不能做一套动态加密功能块程序,让设备只能在指定的PLC上跑,授权到期自动锁机,程序被拷走也跑不起来。这个需求听起来玄乎,拆开看其实就是一套自研的授权与校验逻辑。这篇文章我就把这个功能块从设计思路、SCL代码到踩过的坑完整抖出来,想给自家设备加“防盗锁”的,按这个路子走基本能落地。
1. 先拆概念:S7-1200/1500动态加密功能块到底在加密什么
1.1 “动态加密”不是西门子标准库,而是一类自研授权逻辑
很多人一听到“动态加密”四个字,以为博途里有个现成的加密功能块,拖进来设置个密码就能用。这里必须先澄清一个认知:西门子官方并没有提供名为“Dynamic Encryption”的标准功能块。博途自带的块保护叫“专有技术保护”(Know-how protection),它解决的是“上载后别人能不能看到你的梯形图/SCL代码”的问题,本质是静态密码保护。
而行业里常说的“动态加密功能块程序”,是工程师基于SCL/STL自己写的一套授权校验逻辑,典型包含:设备唯一标识采集(通常是CPU序列号)、注册码/解锁码的动态生成与比对、运行许可控制三大部分。之所以叫“动态”,是因为每次校验的输入不是写死在程序里的固定常量,而是CPU序列号、当前时间、运行次数这类会变化的变量。同样的注册码,换一台PLC、过了授权期限,统统失效。
所以接下来所有内容,都是围绕如何手写这套自研逻辑展开的。搞清楚这个前提,你才不会被网上那些玄乎的“加密神器”带偏。
1.2 三个典型业务场景:锁机、收尾款、防抄方案
我接触过的项目里,动态加密功能块基本都在解决三种现实问题:
第一个是设备分期付款。客户只付了首付,设备先拉去用,但到了约定时间点设备自动锁定,想继续用就得联系厂家付清尾款换解锁码。很多做非标设备的兄弟应该深有体会,设备发出去收不回钱的情况太常见了,机械锁容易被拆,程序锁相对更体面。
第二个是方案防抄。整套设备程序里最值钱的是工艺参数和核心逻辑,你用专有技术保护把代码藏起来之后,别人确实看不到了,但人家可以把整个项目上载下载到另一台同型号PLC里照跑不误。动态加密把程序“绑定”到特定CPU的序列号上,换一台PLC跑不起来,才能拦住这种整体搬运。
第三个是临时授权。试机、借用、展会演示的设备给一个固定使用期限,到期自动失效。省得派人去现场“拆机”,也能防止客户把试用设备当正式设备一直拖下去。
1.3 S7-1200和S7-1500的能力边界,决定方案怎么选
实话实说,S7-1200和S7-1500都能做动态加密,但能力边界差异明显,方案选型必须提前想清楚。
从指令集看,S7-1200没有官方AES/DES这类对称加密库,S7-1500同样没有现成的加密指令,所以常规做法是自研CRC32或者XXTEA这类轻量级算法。S7-1500的字符串处理和数组运算性能更强,可以做更复杂的多轮混淆,而S7-1200的CPU资源有限,算法规模要控制,尤其不能把大数组查表这种操作放在每个扫描周期里跑。
从读取设备标识看,S7-1200从V4.0固件开始可以用Get_IM_Data指令读IM0数据,里面带CPU序列号;S7-1500同样支持,读取速度和数据结构完整度都更好。如果项目里用的是很老的S7-1200固件版本,得先确认是否有这个指令。
从实时时钟看,S7-1200断电后靠超级电容维持RTC,一般能撑十几天到二十天,时间太长会丢;S7-1500加电池模块或缓冲模块会更可靠。这一点直接决定“时间锁”方案敢不敢用。后面第4节我会专门讲这个坑。
还有一个容易被忽略的点:扫描周期。CRC32加时间比对如果每个扫描周期都跑一遍,S7-1200的扫描周期会被明显拉长。所以动态加密功能块绝不应该放在OB1的每个循环里全程执行,要么放到定时中断OB里节流执行,要么在程序里加一个“每N秒执行一次”的判断。
2. 动态授权FB总体设计:三层结构和一整套授权状态机
2.1 三层架构:设备身份层、授权校验层、运行控制层
动手写代码之前,先把架构理清楚。我建议把整个动态加密功能块拆成三层,各管各的事,调试的时候能少掉一半头发。
第一层是设备身份层。它的职责是拿到“这台PLC是谁”的证据。最通用的做法是用Get_IM_Data读取CPU序列号,如果现场条件不允许读序列号,也可以用厂家自己写入保持区的一个设备编号。前者好处是换PLC自动失效,防挪机;后者好处是逻辑简单,但防不了别人把整个项目连DB一起搬走。我的经验是优先用CPU序列号。
第二层是授权校验层。厂家的离线授权工具拿到序列号和授权截止日期之后,用特定算法生成注册码;PLC内的功能块用同样的算法算一遍,两者比对一致才放行。这一层的关键是算法一致性,后面第3节详细写。
第三层是运行控制层。校验结果不是一个简单的布尔量就完了,它要管理一套授权状态机,分别处理未授权、已授权、试用期、授权过期、非法篡改等状态。不同状态对应不同动作:未授权可以跑30分钟试机,授权过期每小时停机5分钟并亮黄灯,非法篡改直接锁机并记录次数。状态机的好处是把逻辑边界划清楚,不会出现“到底要不要停机”这种模糊状态。
2.2 授权状态机的设计细节
状态机的状态定义我一般这样枚举:
| 状态 | 含义 | 触发条件 | 设备行为 |
|---|---|---|---|
| 0 未授权 | 从未输入过有效注册码 | 上电且授权标志位为0 | 允许试机N小时,HMI提示输入注册码 |
| 1 已授权 | 注册码校验通过且在有效期内 | 校验通过且当前日期<=截止日期 | 全功能开放 |
| 2 试用期 | 没有正式授权但处于首次试机时间内 | 首次上电时间+试用时长>=当前时间 | 全功能开放,HMI显示剩余试用时间 |
| 3 授权过期 | 超过截止日期 | 当前日期>截止日期且曾经授权过 | 锁定工艺功能,每小时解锁5分钟 |
| 4 非法篡改 | 检测到时钟回拨或数据异常 | 历史最大时间戳>当前时间 | 立即锁机,需厂家远程解锁码恢复 |
| 5 永久锁定 | 连续多次非法篡改 | 非法篡改次数>=3 | 永久锁机,只能返厂处理 |
状态转换的逻辑集中在授权FB内部,输出一个枚举或整数型状态值给HMI显示。设备的行为动作则由主程序根据这个状态值去控制,不要在FB内部直接写一堆M输出,那样复用性会很差。
2.3 授权数据块的设计原则:公开区、隐藏区、保持区
授权FB配套的数据块是整个方案的命脉,布局我习惯分三个区:
公开区给HMI读写,包括:输入序列号显示(或者自动读取后的显示)、输入注册码的变量、授权状态值、剩余天数、剩余试机时间。这些变量HMI能读能写,方便现场人员操作。
隐藏区是FB静态变量或者私有DB,包括:盐值数组、历史最大时间戳、失败尝试次数、锁定标志、授权校验通过标志。这些变量只允许程序内部访问,HMI不需要看到。在博途里,如果用的是一般DB且没有勾选“从HMI可见”,HMI就访问不到;如果用的是优化访问DB,注意在变量属性里把“HMI可见”关掉。
保持区是必须掉电不丢的变量:授权激活标志、授权截止日期、历史最大时间戳、试机首次上电时间、非法篡改次数。这些变量必须在DB属性里设置为保持性(Retain)。如果忘记设保持性,客户现场一断电,授权状态全丢,每次开机都要重新输注册码,这体验不用我多说。
3. CRC32校验函数与注册码生成器的SCL实现
3.1 为什么选CRC32而不是AES或MD5
严格从密码学角度看,CRC32不是加密算法,它是校验算法,用做授权码生成在密码学上有先天弱点。但在PLC这个特殊环境里,它反而是最务实的方案。
原因有三:第一,S7-1200/1500没有现成的MD5/SHA/AES算法库,自己实现AES需要几百行查表代码,占用大量DB空间,而且在S7-1200上跑得慢;第二,授权码生成的场景不是网络通信,不需要对抗高强度恶意攻击,我们需要的是“生成一个够长、够随机、不可批量推导的注册码”;第三,CRC32的运算量小,S7-1200完全跑得动,配合加盐、多轮异或、字节反转这些混淆手段,即使别人拿到几组注册码也推不出原始密钥。
简单说,CRC32在这里承担的是“把一串输入变成一串看上去没规律的输出”的散列职责,目的不是防密码学攻击,而是防止现场的客户或者同行轻松算出注册码。
3.2 CRC32逐位运算的SCL实现
我直接用逐位运算法,不搞查表法。虽然查表法速度快,但需要256字的常量表,在S7-1200上占DB空间,而且调试时想单步看中间结果比较麻烦。逐位法代码量小,逻辑直观,几十上百字节的数据算一次耗时在毫秒级,对授权这种低频操作完全够用。
FC代码如下,输入是一个Byte数组和有效长度,输出CRC32值:
FUNCTION "FC_CRC32_ByteArray" : DWord { S7_Optimized_Access := 'TRUE' } VERSION : 0.1 VAR_INPUT aData : Array[0..63] of Byte; // 待计算数据的缓冲区 iLen : Int; // 有效数据长度,不能超过64 END_VAR VAR dwCRC : DWord; dwByte : DWord; i : Int; j : Int; END_VAR BEGIN // 长度保护:非法长度直接返回0,避免数组越界 IF iLen <= 0 THEN FC_CRC32_ByteArray := 16#00000000; RETURN; END_IF; IF iLen > 64 THEN iLen := 64; END_IF; dwCRC := 16#FFFFFFFF; FOR i := 0 TO iLen - 1 DO dwByte := BYTE_TO_DWORD(aData[i]); dwCRC := dwCRC XOR dwByte; FOR j := 1 TO 8 DO IF (dwCRC AND 16#00000001) <> 16#00000000 THEN dwCRC := (dwCRC SHR 1) XOR 16#EDB88320; ELSE dwCRC := dwCRC SHR 1; END_IF; END_FOR; END_FOR; FC_CRC32_ByteArray := dwCRC XOR 16#FFFFFFFF; END_FUNCTION提示一下:不同TIA版本里Byte转DWord的语法略有差异,如果编译器提示找不到BYTE_TO_DWORD,就改成先转USINT再赋值,或者直接写dwByte := aData[i];,SCL在部分版本下会做隐式扩展。这种适配在真实工程里很常见,别被编译器的报错吓住。
3.3 加盐与多轮混淆:让注册码不可批量推导
裸算CRC32有个问题:如果把序列号和CRC结果对照几组,理论上可以通过暴力枚举碰撞推出规则。所以必须加盐和混淆。我项目里用的流程是:
#!/usr/bin/env python3 # 厂家离线注册码生成工具 import zlib def generate_regcode(serial: str, expire_date: str) -> str: # 盐值固定写在算法里,和PLC侧保持一致 SALT = b"S7-AUTH-KEY-2024" # 第一步:序列号 + 盐值 + 授权截止日期 raw = serial.upper().encode('ascii') + SALT + expire_date.encode('ascii') crc1 = zlib.crc32(raw) & 0xFFFFFFFF # 混淆1:异或固定掩码 crc1 ^= 0xA5A5A5A5 # 混淆2:字节序反转 crc1 = int.from_bytes(crc1.to_bytes(4, 'little'), 'big') # 第二步:用混淆后的值和序列号再算一次 raw2 = crc1.to_bytes(4, 'big') + serial.upper().encode('ascii') crc2 = zlib.crc32(raw2) & 0xFFFFFFFF # 注册码 = 16位字符串:前8位CRC结果,后8位截止日期 return f"{crc2:08X}{expire_date}" if __name__ == "__main__": serial = input("请输入CPU序列号: ").strip() expire = input("请输入授权截止日期(YYYYMMDD): ").strip() print("注册码:", generate_regcode(serial, expire))PLC侧校验的逻辑完全对应:先取用户输入的注册码字符串,截出后8位作为截止日期,然后把序列号、盐值、这个截止日期拼起来算CRC,再做一次异或和字节反转,再算第二次CRC,最后把结果转成8位十六进制字符串和注册码前8位比对。
这里最容易被忽略的是字节序。Python里int.from_bytes(..., 'little')和PLC侧DWord的存储顺序要保持一致,不然两边算出来的结果永远对不上。我第一次联调时就在这上面卡了一个下午,最后把中间值逐个打印出来对比才发现是字节序问题。
3.4 注册码比对与防暴力尝试
比对注册码时有几个实操细节值得注意:
不要在PLC侧直接把整个长字符串比较,正确做法是算完CRC后比较DWord数值,避免大小写、空格等字符串差异。如果实在要比较字符串,统一转大写再比。
HMI上要加防暴力尝试机制。我见过现场操作工闲着没事乱输注册码,输错十几次把CPU搞进STOP的。我的做法是:连续输错3次,锁定输入框10分钟,用保持变量存失败次数和锁定起始时间。第4次再错,锁定时间翻倍。这个机制在亚控、西门子精简面板、WinCC上都很好实现。
校验通过之后,立刻置位“授权激活”保持标志,并把截止日期写入保持区。之后每次扫描只需要查这个标志和日期,不需要重复跑CRC计算。既省CPU,又避免“同一个注册码每次上电都重新算一遍”带来的潜在不一致问题。
4. 动态时间锁:让设备按授权周期运行并防时钟回拨
4.1 读系统时钟:RD_LOC_T指令和DTL数据类型
时间锁的核心是读取PLC实时时钟。TIA Portal里S7-1200/1500用RD_LOC_T指令读本地时间,输出是DTL数据类型的变量。调用方式:
"RD_LOC_T"(EN := #bTrigger, RET_VAL := #wRetVal, OUT := #dtlNow);DTL类型是一个结构体,里面的YEAR、MONTH、DAY、HOUR、MINUTE、SECOND字段可以直接访问。我习惯把日期转成一个整数再比较:
#iToday := #dtlNow.YEAR * 10000 + #dtlNow.MONTH * 100 + #dtlNow.DAY;这样20250115就是一个整数,跟授权截止日期比大小非常直观。
4.2 授权截止日期的两种判断方式
第一种方式,精确到天的简单判断。把截止日期存成YYYYMMDD格式的DInt,每次上电和每天第一次运行OB时,用当前日期和它比对。当前日期大于截止日期就切换为“授权过期”状态。这种方式代码量小,完全够用。
第二种方式,精确到分钟。把DTL转成“从2000年1月1日0点开始的分钟数”,这需要自己写一个公历日期换算函数,处理闰年和每月天数。代码会翻一倍,但在按小时收费的设备租赁场景里值得做。
我个人建议:绝大多数设备锁机场景用第一种就够了,真需要精确到分钟的,再上第二种。别一开始就整复杂,现场维护的人会骂娘。
4.3 时钟回拨检测:用历史最大时间戳防篡改
只做“当前日期和截止日期比大小”有一个致命漏洞:现场的人把PLC时钟改回2020年,设备就永远不过期了。所以必须增加一个“历史最大时间戳”检测。
具体实现:在保持区定义一个变量hMaxDate,存放设备运行至今见过的最大日期。每次做时间检查时,如果当前日期大于hMaxDate,就更新它为当前日期;如果当前日期小于hMaxDate,说明时钟被人往回拨了,立即把授权状态切换为“非法篡改”,锁机并记录篡改次数。
这个逻辑相当于给时间锁装了一个“只进不退”的棘轮,只要拨回去一次就会被发现。
但这里有个连锁坑:如果S7-1200断电超过超级电容的维持时间,RTC会回到出厂默认值,比如2000年1月1日。这时当前日期远小于hMaxDate,会被误判为非法篡改。所以启用时间锁的项目,设备说明书或者现场标识一定要写清楚:长期断电超过两周需要重新上电,或者干脆配一个不间断电源给PLC保持供电。如果是S7-1500,优先选带电池的缓冲模块,这个钱不能省。
另外,防止别人把整个保持区DB初始化来绕过时间锁的招法:如果授权激活标志被清零,我建议把设备行为设定为“需要重新激活”,而不是自动恢复未授权试机状态。这样即使对方操作失误或者故意清DB,设备也不会悄悄变成可免费试用的状态,至少会暴露问题。
5. 叠加专有技术保护:从“防看代码”升级到“防复制”
5.1 专有技术保护的正确设置
动态加密功能块解决的是“程序换个PLC还能跑”的问题,但它本身也是代码,如果不做代码保护,别人上载项目之后直接看SCL,你的盐值、算法、密钥就全暴露了。所以第一步还是得叠上博途的专有技术保护。
设置方法很简单:在项目树里右键你要保护的程序块(FB、FC、DB都支持),选择属性,在“保护”选项卡里勾选“专有技术保护”,设置密码。然后重新编译,勾选“支持专有技术保护”。编译下载后,别人上载这个块只能看到接口定义,看不到内部实现。
这里有几个操作层面的提醒:
- 设置了专有技术保护的DB块,同样会被隐藏内容,所以不要把授权盐值这种敏感信息放在一个完全公开的DB里,最好放在FB的静态变量里,随FB一起保护。
- 密码一定要找安全的地方存着,最好由两个人分开保管。忘了密码之后,正规途径无法找回,只能把块删了重写。
- 块的保护级别是“防君子不防小人”,网上确实有工具能处理掉专有技术保护,所以它只能当第一道门。
5.2 双保险组合:专有技术保护加动态加密
专有技术保护负责“代码不可见”,动态加密负责“代码不可异地运行”,两者叠加才是完整方案。
我的做法是:核心工艺FB全部加专有技术保护;动态加密授权FB作为独立块,输出一个bAuthOK位给主程序;主程序里所有关键工艺段的使用使能都引用这个bAuthOK位。一旦授权校验失败,bAuthOK为0,设备关键动作全部禁止,只有面板灯和HMI提示还活着。
注意一个细节:不要在程序里只用一个M点或者D点做授权标志,因为别人上载程序后监控变量,直接把那个点的状态改成1,就能绕过校验。我的做法是授权状态用枚举值加多个位组合表示,比如bAuthOK必须为1,同时bLockFlag必须为0,同时bTampered必须为0,三个条件在多个位置交叉检查。虽说不算真正的防逆转,但至少让“找点改值”没那么容易。
5.3 关键OB分散校验:防止启动逻辑被跳过
还有一个容易被忽视的攻击路径:很多人把授权初始化放在OB100里,OB100是启动组织块,只在CPU启动时执行一次。如果别人把OB100的调用删了,或者整个项目下载时故意不加载OB100,那么授权初始化逻辑就不跑了,设备可能直接进入一种“未初始化”状态。
针对这个,我设计了一套“心跳互检”机制:
OB100里调用FB_AuthInit,做的事情是:读取序列号、算CRC、校验注册码、把心跳标志H1置1、记录本次上电时间。
OB1每个扫描周期里调用FB_AuthCheck,做的事情是:检查H1是否为1,如果H1为0说明OB100没跑,直接判非法篡改;同时检查当前时间是否在授权期内;然后置心跳标志H2。
OB10定时中断里调用FB_AuthLoop,每1000毫秒检查一次H1和H2是否都为1,只要发现心跳断链,就把授权状态改成锁定。
三层互相看着,谁被删除或者跳过,另外两层马上能发现。虽然不是绝对安全,但破解成本又高了一截。对付现场客户和一般同行,已经足够了。
6. 项目实测踩坑记录:四个最容易翻车的地方
6.1 坑一:字符串长度和类型转换,最容易把CPU搞进STOP
S7-1200默认的String类型是256字节,这个长度对注册码来说太大了,而且HMI变量连接时长度不一致会导致字符串被截断或者乱码。我的建议是用受限字符串,比如String[16],同时在功能块接口定义里严格规定长度。
另一个高频崩溃点是SCL里处理String和Byte数组的转换。我的经验是:不要在FB内部反复截取字符串,而是在HMI侧就把序列号和注册码都准备好,PLC侧只做一次字符串到字节缓冲区的转换,然后交给CRC函数。转换时特别注意循环边界,数组越界在S7-1200上不是报警,是直接STOP。生产设备在运行中CPU突然STOP,这个后果想想就头疼。
安全写法是先判断长度,再做循环。我在CRC函数开头已经加了长度保护,这个习惯请务必保留。
6.2 坑二:断电后RTC丢失,时间锁瞬间失效
这个前面提到过,但值得单独拿出来说,因为太容易踩了。S7-1200断电后靠超级电容维持RTC,一般能撑十几天到二十天。如果设备停机超过这个时间再上电,RTC会回到出厂默认时间,比如2000年1月1日。
后果有两个方向:如果做了时钟回拨检测,设备会误报非法篡改锁机;如果没做,设备可能因为“当前日期早于截止日期”而继续运行,等于时间锁白做了。
我处理这个问题的思路是分两步。第一步,在程序里判断:如果读取到的时间年份小于2020年,且授权激活标志为1,就判定为“RTC异常”,设备进入受限模式,允许维持基本功能运行,但核心工艺功能锁定。第二步,和设备说明书配合,规定长期停机的设备每隔两周必须上电一次。如果实在做不到定期上电,那就换S7-1500加电池模块。
另外,现场调试时有一个低级的坑:新出厂的S7-1200,RTC时间可能是错的,必须先校准时间再激活授权,不然注册码里附带的截止日期和实际日期对不上。我第一次做样机测试时就被坑过,后来习惯性在设备出厂前统一校准PLC时钟。
6.3 坑三:优化访问DB的保持性设置,细节多到防不胜防
TIA Portal从V13开始推荐使用优化访问DB,它对符号名和数据结构做了优化,但也带来一些新问题。保持性设置上,优化DB是在变量属性里单独勾选“保持性”,而不是像传统DB那样在DB级别设置。
我踩过的坑是:有些数据类型在保持性上有长度限制,比如超长的String或者大数据数组,在部分CPU型号上配置保持性会导致编译不通过。解决办法是把需要保持的数据拆成多个小变量,或者改用Array[0..n] of Byte这种基础类型。
还有一点,下载程序时如果勾选了“初始化保持性存储器”,所有保持变量都会被清掉。这个操作往往发生在调试阶段,一不小心的操作。有一次我在现场调程序,顺手点了初始化,客户设备授权状态被清了,后来重新输注册码才恢复。从那以后,我在授权保持机制里加了一个“授权初始化标记”,一旦发现保持区被清空,HMI就会弹出一个明确的提示,而不是让设备在未知状态下裸奔。
6.4 反破解边界:动态加密能做什么,做不到什么
写到最后必须说几句实在话。PLC端的动态加密没有任何一种方案能做到绝对安全。博途的专有技术保护可以被专业工具移除,SCL逻辑上载后可以被分析,注册码在HMI和PLC通信的变量区里可以被监控抓包,甚至有些人不看程序,直接把授权判断结果改掉。
所以我一直跟客户强调:动态加密功能块的目标不是“绝对无法破解”,而是“破解成本高于重新开发一套设备方案的成本”。让想抄的人觉得麻烦、让想赖账的人觉得不划算,这套程序就值回票价了。
商业层面的防线可能比程序更有效:合同里约定破解和绕过授权的违约责任、法律层面的知识产权保护、以及提供比破解更省心的正规售后授权服务。程序给技术兜底,合同给商业兜底,两边同时上锁,设备商的权益才能真正立得住。
最后再分享一个体会:这套动态加密方案我最自豪的不是CRC和状态机设计得多巧妙,而是它让设备商在面对“客户拖着尾款不付”的时候,多了一个理直气壮又不用撕破脸的谈判筹码。技术解决不了所有商业问题,但能给商业问题多一个优雅的解法。