一、它要防的是什么
先把目标说清楚,因为目标决定了方案该有多重。它防的不是“破解”,而是“顺手复制”: 产品卖出去之后,拿到板子的人把固件读出来,烧到自己做的板子上,或者烧到同型号的板子上当替代品。 这类行为的特点是成本极低、技术门槛极低,所以只要把门槛抬高一点,绝大部分就挡住了。
与之相对的目标是“防有心破解”。那需要的是完全不同的手段:Flash 读保护、加密固件、安全启动、 甚至外挂安全芯片。而这些手段的成本和复杂度是另一个量级。我在这篇里只讨论第一类目标, 并且会明确说明它的边界在哪。
二、实现:读三个字,算三个字,存三个字
大多数 32 位 MCU 都在某个只读区域提供一个出厂写入、不可修改的唯一 ID,通常是
96 位或 128 位,可以按 32 位分三次读出来。取 ID 这个动作本身没有任何技巧,
只有一个通用要点值得单独说:读它必须用 volatile。
/* 读唯一 ID 的三个 32 位字:基址按该型号参考手册里"器件电子签名 / 唯一设备 ID"
一节查得的只读 ID 基址(各型号都不同,本文不写出具体基址)
—— 按个人理解重写的最小片段,非工程原码 */
for (i = 0; i < 3; i++) {
id[i] = *(volatile uint32_t *)(CHIP_UID_BASE + 4u * i); /* volatile 不能省 */
}
如果不加 volatile,编译器可能认为这个地址的内容不会被外部改变,
于是把第一次读到的值缓存下来重复使用——这类“读出来永远是同一个值”的问题很难从现象上定位,
因为开优化和不开优化的表现完全不同。我当时还顺手把基址先取进一个局部变量再解引用,
目的只是不希望编译出来的二进制里干干净净地摆着一个常量地址,让人一眼看出“这里在读唯一 ID”。
拿到三个字之后,要把它变成“这块板子独有的一串校验字”。这一步是整篇里唯一不展开写的部分: 它属于项目里的授权实现,我只讲设计上要考虑什么、怎么取舍,不给可以直接照搬的公式,也不给任何常量取值。 我当时的做法可以拆成五步:
- 凑齐两条信息。一条来自芯片(上一步读到的几个 ID 字), 一条来自项目自己(一个只存在于项目内部、每个项目都不相同的编译期信息)。 两条信息里任何一条变了,算出来的结果就必须跟着变。
- 把两条信息混合。用几步整数运算把它们搅在一起,让输出同时依赖两边, 而不是“ID 原样参与、常量原样参与”。
- 让几个字互相引用。算第二个字的时候用上第一个字的结果, 算第三个字的时候再用上前面的结果,形成一个环,而不是三个字各算各的。
- 把结果落进授权区。在产线工序里把这几个字写进 EEPROM 或 Flash 的一块独立小区域, 这块区域在任何“恢复出厂设置”里都不许擦。
- 开机复算并逐字比对。用同样的两条信息、同样的方法重算一遍,与授权区里的值逐字比; 只要有一个字不同,就认为这份程序不是为这块板子授权的。
第 2 步“用哪几种运算”是可选的。我当时比过下面四种,最后是组合使用:
| 候选运算 | 在混合里起什么作用 | 我的取舍 |
|---|---|---|
| 按位异或 | 把两条信息的对应位“搅”在一起,一位变则结果变;一条指令就能完成 | 用。注意别取会让这一步退化成“原样透传”的取值 |
| 32 位无符号加法 / 减法(自然回绕) | 产生进位或借位,让低位的变化能影响到高位 | 用。代价是取值不当就会退化成恒等 |
| 32 位无符号乘法(自然回绕) | 对输入任何一位的变化都更敏感,扩散最快 | 用,它是四种里扩散效果最好的一种 |
| 移位 / 循环移位 | 把变化搬到相邻位,单独用扩散有限 | 只当辅助,不单独承担混合 |
第 3 步“交叉引用”是我觉得最值得说的一点,它解决的是一个概率问题:
| 组合方式 | 一位 ID 变化会影响几个校验字 | 后果 |
|---|---|---|
| 三个字各算各的 | 只影响它自己那一个 | 只要这一个字碰巧撞上,剩下两个字仍然全对,整体比对就有机会通过 |
| 三个字交叉引用(我当时的选择) | 任何一个字变化都会波及多个校验字 | 想绕过全部比对要同时撞上多处,“碰巧相等”的概率小得多 |
最后一点:这个方案里的那个项目侧常量不是一个密码,它的作用只是“让不同项目算出来的结果不一样”, 所以它不应该被写进任何公开文档,也不应该几个项目共用同一个值。 校验字在产线工序里写进 EEPROM(或 Flash 的一个独立小区域),开机后重算一遍再比对:
/* 开机校验:读存储 → 重算 → 逐字比对 → 返回结果
—— 按个人理解重写的最小片段,非工程原码;存储位置按各项目的授权区定义 */
static int uid_verify(void)
{
uint32_t id[3], sig[3], stored[3];
uid_read_id(id); /* 读芯片唯一 ID */
uid_mix(id, sig); /* 按本项目的方法重算 */
auth_area_read(stored); /* 读回授权区里当初写入的字 */
for (int i = 0; i < 3; i++)
if (sig[i] != stored[i]) { return VERIFY_FAIL; }
return VERIFY_OK;
}为什么用这种“混合”而不是哈希或加密
- 不需要任何库。上面这几种运算都是 CPU 一条指令的事,没有代码体积开销, 在只有 16 KB Flash 的小芯片上也放得下。
- 这是“指纹”而不是“密码”。我们要的不是“别人算不出来”,而是 “别人的板子算出来的结果不一样”。只要唯一 ID 不同,结果就不同,目的就达到了。
- 可逆性不重要。不需要从校验字反推 ID,所以不需要单向性。
把这五点连起来看,会发现这个方案的强度并不来自某一步运算,而来自“两条信息 + 交叉引用 + 授权区落地”这三件事的组合。 所以后面几节要谈的问题,也基本都不在运算本身,而在它和产线、售后流程的关系上。
三、校验失败的三种处理方式,和它们的差别
这是我认为最值得讨论的一部分,因为我见过两种很不一样的处理, 而且它们带来的后果差别巨大。
做法一:死循环卡住
/* 错误示范:校验失败就原地死等,不做任何提示(下面会说明它为什么不能这样写) */
while (sig[0] != stored[0]) ;
while (sig[1] != stored[1]) ;
while (sig[2] != stored[2]) ;这是最“干净”的做法:代码不往下走,什么功能都不会启动,看起来最安全。但它的代价是:
- 现场完全无法判断发生了什么。屏幕不亮、通信不应答,看起来和“程序烧坏了” “电源坏了”“晶振没起振”一模一样。维修人员只能靠猜。
- 看门狗要么形同虚设,要么不停复位。如果喂狗也在这段循环里, 看门狗永远不会触发,等于没有;如果喂狗在别处,设备会周期性复位, 表现为“反复重启”——这反而比静止更难查。
- 可能把自己锁死成砖。如果校验代码跑在通信初始化之前, 那么产线或售后想通过总线重新写入授权数据也没有机会了,只能上编程器拆机处理。
做法二:置一个明确的故障码,进入受限状态
更稳的做法是把“未授权”当成一个普通的故障来处理: 置一个专属故障码,屏幕上显示出来,同时让通信保持可用,允许产线或售后重新下发授权。 这样设备是“不能用”,但不是“救不回来”。
做法三:分层——未授权时允许维护模式
如果产品确实需要较强的保护,可以再细一层:未授权时禁止所有业务功能, 但如果检测到某个特定条件(例如某个引脚被拉低、或者收到一条带特定口令的广播命令), 就进入维护模式,允许重新授权。这样既保住了保护效果,又留了一条恢复通道。
| 处理方式 | 保护效果 | 现场可诊断性 | 能否远程恢复 | 我的评价 |
|---|---|---|---|---|
| 死循环 | 强 | 极差 | 不能 | 不推荐,容易把自己锁死 |
| 故障码 + 受限状态 | 较强 | 好 | 可以 | 推荐,成本几乎为零 |
| 故障码 + 维护模式 | 较强 | 好 | 可以 | 产品有售后体系时推荐 |
四、真正的代价不在代码里,在流程里
写这段代码只花了我半天。真正麻烦的是它带来的流程约束,而且这些问题往往在项目后期才暴露。
4.1 产线换板
授权是绑在芯片上的。如果产线上某块板子焊坏了要换主控,或者调试时发现芯片有问题, 换上一颗新的芯片之后,这块板子就“未授权”了。所以:
- 产线必须有一道显式的授权工序,而不是“烧完固件就能用”;
- 这道工序要么由工装自动完成,要么由上位机工具手动完成,不能依赖“出厂时已经写过了”;
- 返修工位也要有这个能力,否则返修回来的板子全都用不了。
4.2 售后维修
产品卖到用户手里之后,如果主控损坏需要更换,维修点就必须能重新授权。 如果做不到,这块板子只能报废或者返厂。这一条在方案阶段一定要想清楚: 你有没有一个能覆盖所有维修点的授权通道?
4.3 “恢复出厂设置”会不会把授权擦掉
这是我自己踩过的一个坑。授权数据存在 EEPROM 或 Flash 里,如果它恰好落在 “恢复出厂设置”会擦除的地址范围内,那么用户按一次恢复出厂,设备就永久未授权了。 解决办法很简单,但必须提前想到:
- 把授权数据放在独立的地址段或独立扇区,并且明确列入“任何情况下不得擦除”的清单;
- 在代码里把“参数区”和“授权区”的地址常量分开定义,不要共用一段连续的地址;
- 恢复出厂设置的实现里,只擦参数区,绝不整片擦除。
4.4 版本与算法的可演进性
授权算法一旦发布,就已经在成千上万台设备上跑着了。将来如果想换算法(比如换成真正的 带密钥的摘要),必须能兼容老设备。所以建议在授权区里额外存一个 “算法版本号”,校验时先读版本号再选对应的算法。多存两个字节,能省掉将来一次大改。
五、它挡不住什么
为了不给自己和别人造成错误的安全感,我把它的边界明确写下来:
| 攻击方式 | 这个方案能否挡住 | 说明 |
|---|---|---|
| 把固件读出来烧到同型号板子上 | 能 | 新板子的唯一 ID 不同,校验必然失败——这是它的主要作用 |
| 连同 EEPROM 一起整包复制 | 能 | 校验字依赖唯一 ID,复制过来的数据在新芯片上算不对 |
| 反汇编后找到校验逻辑并改成永远通过 | 不能 | 这是混淆而非加密,绕过它只需要一点耐心 |
| 用调试器读出 Flash 内容 | 不能 | 需要靠 Flash 读保护,与本方案无关 |
| 替换整个 MCU 并重新烧写 | 不能 | 这已经不是“复制固件”,而是“照着做一份” |
结论是:它挡住的是“顺手复制”,不是“有心破解”。 如果要提高上限,应该和 Flash 读保护一起用——读保护让固件拿不出来, 唯一 ID 绑定让拿出来也没用,两者叠加才比较有意义。
六、我现在的做法
如果今天再写一遍,我会按下面这个清单来:
- 授权区独立寻址,并且在代码里用专门的常量标注“永不擦除”。
- 校验失败进入明确故障状态,绝不用死循环;故障码写进状态寄存器, 上位机读得到。
- 保留通信能力,让产线和售后能重新下发授权。
- 授权写入做成产测流程的一步,并在产测记录里留痕(哪块板、什么时间、 用什么版本的算法写的)。
- 存一个算法版本号,为将来换算法留路。
- 和 Flash 读保护配合,并且明确写进设计文档:“这挡的是复制,不是破解”。
- 不要在方案里把它当卖点。它是降低风险的措施,不是安全承诺。
参考资料与说明
- 各系列 MCU 参考手册中关于“器件电子签名 / 唯一设备 ID”的章节(不同厂商命名不同, 地址与长度也不同,使用前必须查对应型号的手册)。
- Flash 读保护(RDP / 读写保护位)相关的手册章节。
- 关于代码披露程度:出于对个人项目实现细节的保护,本文只公开方法与思路: 唯一 ID 的读取只给通用骨架(不含具体基址),授权校验字的生成只讲设计考量与取舍, 不给出可用公式与任何常量取值;涉及核心实现的部分以步骤与表格代替代码, 示例代码均为按个人理解重写的通用片段,不代表任何产品或交付代码。
- 本文讨论的是防御性的固件保护与授权管理设计,不涉及任何绕过他人保护措施的方法; 文中不含任何真实密钥、口令或序列号。
- 文中芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。