本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

芯片唯一 ID 与程序绑定:
一个实用但容易反噬的做法

把固件从一块板子整包复制到另一块板子,是嵌入式产品最常见的一种“被顺走”方式。 一个便宜的对策是:用 MCU 内部唯一的 ID 算出几个校验字存起来,开机重算比对,不符就不干活。 这个做法我在两块板子上写过,它确实管用,但它带来的运维问题同样真实。这篇笔记把两边都写清楚。

唯一 ID 授权校验 出厂流程 风险分析 已发布

一、它要防的是什么

先把目标说清楚,因为目标决定了方案该有多重。它防的不是“破解”,而是“顺手复制”: 产品卖出去之后,拿到板子的人把固件读出来,烧到自己做的板子上,或者烧到同型号的板子上当替代品。 这类行为的特点是成本极低、技术门槛极低,所以只要把门槛抬高一点,绝大部分就挡住了。

与之相对的目标是“防有心破解”。那需要的是完全不同的手段: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”。

拿到三个字之后,要把它变成“这块板子独有的一串校验字”。这一步是整篇里唯一不展开写的部分: 它属于项目里的授权实现,我只讲设计上要考虑什么、怎么取舍,不给可以直接照搬的公式,也不给任何常量取值。 我当时的做法可以拆成五步:

  1. 凑齐两条信息。一条来自芯片(上一步读到的几个 ID 字), 一条来自项目自己(一个只存在于项目内部、每个项目都不相同的编译期信息)。 两条信息里任何一条变了,算出来的结果就必须跟着变。
  2. 把两条信息混合。用几步整数运算把它们搅在一起,让输出同时依赖两边, 而不是“ID 原样参与、常量原样参与”。
  3. 让几个字互相引用。算第二个字的时候用上第一个字的结果, 算第三个字的时候再用上前面的结果,形成一个环,而不是三个字各算各的。
  4. 把结果落进授权区。在产线工序里把这几个字写进 EEPROM 或 Flash 的一块独立小区域, 这块区域在任何“恢复出厂设置”里都不许擦。
  5. 开机复算并逐字比对。用同样的两条信息、同样的方法重算一遍,与授权区里的值逐字比; 只要有一个字不同,就认为这份程序不是为这块板子授权的。

第 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,所以不需要单向性。

把这五点连起来看,会发现这个方案的强度并不来自某一步运算,而来自“两条信息 + 交叉引用 + 授权区落地”这三件事的组合。 所以后面几节要谈的问题,也基本都不在运算本身,而在它和产线、售后流程的关系上。

上半:产线工序——读芯片唯一标识 → 与常量混合生成校验字 → 写入独立授权区,下半:每次开机——重新计算 → 与存储值
图 1 · 绑定与校验流程图

三、校验失败的三种处理方式,和它们的差别

这是我认为最值得讨论的一部分,因为我见过两种很不一样的处理, 而且它们带来的后果差别巨大。

做法一:死循环卡住

/* 错误示范:校验失败就原地死等,不做任何提示(下面会说明它为什么不能这样写) */
while (sig[0] != stored[0]) ;
while (sig[1] != stored[1]) ;
while (sig[2] != stored[2]) ;

这是最“干净”的做法:代码不往下走,什么功能都不会启动,看起来最安全。但它的代价是:

  • 现场完全无法判断发生了什么。屏幕不亮、通信不应答,看起来和“程序烧坏了” “电源坏了”“晶振没起振”一模一样。维修人员只能靠猜。
  • 看门狗要么形同虚设,要么不停复位。如果喂狗也在这段循环里, 看门狗永远不会触发,等于没有;如果喂狗在别处,设备会周期性复位, 表现为“反复重启”——这反而比静止更难查。
  • 可能把自己锁死成砖。如果校验代码跑在通信初始化之前, 那么产线或售后想通过总线重新写入授权数据也没有机会了,只能上编程器拆机处理。

做法二:置一个明确的故障码,进入受限状态

更稳的做法是把“未授权”当成一个普通的故障来处理: 置一个专属故障码,屏幕上显示出来,同时让通信保持可用,允许产线或售后重新下发授权。 这样设备是“不能用”,但不是“救不回来”。

做法三:分层——未授权时允许维护模式

如果产品确实需要较强的保护,可以再细一层:未授权时禁止所有业务功能, 但如果检测到某个特定条件(例如某个引脚被拉低、或者收到一条带特定口令的广播命令), 就进入维护模式,允许重新授权。这样既保住了保护效果,又留了一条恢复通道。

处理方式保护效果现场可诊断性能否远程恢复我的评价
死循环强极差不能不推荐,容易把自己锁死
故障码 + 受限状态较强好可以推荐,成本几乎为零
故障码 + 维护模式较强好可以产品有售后体系时推荐
并排三栏:死循环卡住 / 置故障码进入受限状态 / 置故障码并保留维护模式,每栏用图标标出现场可诊断性与能否远程恢复
图 2 · 三种失败处置方式对比图

四、真正的代价不在代码里,在流程里

写这段代码只花了我半天。真正麻烦的是它带来的流程约束,而且这些问题往往在项目后期才暴露。

4.1 产线换板

授权是绑在芯片上的。如果产线上某块板子焊坏了要换主控,或者调试时发现芯片有问题, 换上一颗新的芯片之后,这块板子就“未授权”了。所以:

  • 产线必须有一道显式的授权工序,而不是“烧完固件就能用”;
  • 这道工序要么由工装自动完成,要么由上位机工具手动完成,不能依赖“出厂时已经写过了”;
  • 返修工位也要有这个能力,否则返修回来的板子全都用不了。

4.2 售后维修

产品卖到用户手里之后,如果主控损坏需要更换,维修点就必须能重新授权。 如果做不到,这块板子只能报废或者返厂。这一条在方案阶段一定要想清楚: 你有没有一个能覆盖所有维修点的授权通道?

4.3 “恢复出厂设置”会不会把授权擦掉

这是我自己踩过的一个坑。授权数据存在 EEPROM 或 Flash 里,如果它恰好落在 “恢复出厂设置”会擦除的地址范围内,那么用户按一次恢复出厂,设备就永久未授权了。 解决办法很简单,但必须提前想到:

  • 把授权数据放在独立的地址段或独立扇区,并且明确列入“任何情况下不得擦除”的清单;
  • 在代码里把“参数区”和“授权区”的地址常量分开定义,不要共用一段连续的地址;
  • 恢复出厂设置的实现里,只擦参数区,绝不整片擦除。

4.4 版本与算法的可演进性

授权算法一旦发布,就已经在成千上万台设备上跑着了。将来如果想换算法(比如换成真正的 带密钥的摘要),必须能兼容老设备。所以建议在授权区里额外存一个 “算法版本号”,校验时先读版本号再选对应的算法。多存两个字节,能省掉将来一次大改。

五、它挡不住什么

为了不给自己和别人造成错误的安全感,我把它的边界明确写下来:

攻击方式这个方案能否挡住说明
把固件读出来烧到同型号板子上能新板子的唯一 ID 不同,校验必然失败——这是它的主要作用
连同 EEPROM 一起整包复制能校验字依赖唯一 ID,复制过来的数据在新芯片上算不对
反汇编后找到校验逻辑并改成永远通过不能这是混淆而非加密,绕过它只需要一点耐心
用调试器读出 Flash 内容不能需要靠 Flash 读保护,与本方案无关
替换整个 MCU 并重新烧写不能这已经不是“复制固件”,而是“照着做一份”

结论是:它挡住的是“顺手复制”,不是“有心破解”。 如果要提高上限,应该和 Flash 读保护一起用——读保护让固件拿不出来, 唯一 ID 绑定让拿出来也没用,两者叠加才比较有意义。

六、我现在的做法

如果今天再写一遍,我会按下面这个清单来:

  1. 授权区独立寻址,并且在代码里用专门的常量标注“永不擦除”。
  2. 校验失败进入明确故障状态,绝不用死循环;故障码写进状态寄存器, 上位机读得到。
  3. 保留通信能力,让产线和售后能重新下发授权。
  4. 授权写入做成产测流程的一步,并在产测记录里留痕(哪块板、什么时间、 用什么版本的算法写的)。
  5. 存一个算法版本号,为将来换算法留路。
  6. 和 Flash 读保护配合,并且明确写进设计文档:“这挡的是复制,不是破解”。
  7. 不要在方案里把它当卖点。它是降低风险的措施,不是安全承诺。

参考资料与说明

  • 各系列 MCU 参考手册中关于“器件电子签名 / 唯一设备 ID”的章节(不同厂商命名不同, 地址与长度也不同,使用前必须查对应型号的手册)。
  • Flash 读保护(RDP / 读写保护位)相关的手册章节。
  • 关于代码披露程度:出于对个人项目实现细节的保护,本文只公开方法与思路: 唯一 ID 的读取只给通用骨架(不含具体基址),授权校验字的生成只讲设计考量与取舍, 不给出可用公式与任何常量取值;涉及核心实现的部分以步骤与表格代替代码, 示例代码均为按个人理解重写的通用片段,不代表任何产品或交付代码。
  • 本文讨论的是防御性的固件保护与授权管理设计,不涉及任何绕过他人保护措施的方法; 文中不含任何真实密钥、口令或序列号。
  • 文中芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。