简介:针对单片机程序被复制、被逆向的痛点,这份PDF技术文章给出了一套利用芯片出厂唯一ID进行加固的加密思路,适合关心固件安全、正在做产品防抄板的嵌入式工程师阅读。文章不依赖简单的if比较,而是把ID经不可逆算法F1映射成IDX并存入EEPROM/FLASH,再用自定义函数GET_IDX()读出,配合getmy_1()、getmy_0()等函数替代程序中的1和0,并在函数传参时将变量叠加F1(ID)、在被调函数内减去GET_IDX(),使加密逻辑分散在程序各处,破解者很难定位解密入口。资源为单个PDF文件,大小仅109KB,方便快速阅读与保存;目前已有2384人学习下载。文中还对算法混淆、内联展开、硬件CRC隐藏特征、合法用户与盗版者的攻防差异等做了讨论,能帮助开发者理解唯一ID加密的优缺点,从而在量产编程器支持下设计出更难破解的固件保护方案。
1. 单片机唯一ID加密:为什么把0和1换掉就能拦住抄板
做产品的人大概都经历过:固件被读出来,板子被抄走,方案公司拿着同样的硬件卖半价。传统做法是加一堆if判断读唯一ID,不对就死循环,但反汇编的人只要搜到读ID那两条指令,一个patch下去全白干。这篇聊的单片机唯一ID加密思路,把验证从if判断里彻底摘出来,改成用ID派生值替代程序常量0和1,每个芯片的HEX内容都不同,抄走固件只对那一颗芯片有效。实现需要两个前提:芯片有真正唯一的ID,量产编程器支持按ID变换写入数据。它防不住专业逆向,但能把抄板成本抬到不值得动手的程度,适合ROM有余量、小批量出货的产品。
2. 唯一ID加密的三件套:F1(ID)、IDX与GET_IDX()怎么落地
2.1 唯一ID能做什么:防的不是读取,是复制
先厘清一个概念。带唯一ID的芯片,出厂时在某个固定地址固化了几字节序列号,让每颗芯片可以被区分。前面说的加密思路,核心是把“这颗芯片能不能跑这套程序”这件事,从运行时的判断前置到烧录阶段去固化:编程器读到的ID不同,写进程序区的字节就不同。这个思路防的不是程序被读出来,而是读出来以后没法用。破解者花力气把HEX完整Dump出来,换到任何一颗新芯片上都执行不了,因为新ID算不出对应的IDX。这比单纯开读保护更有价值,读保护可以被电压攻击绕过,但ID做在晶圆物理层,绕过了读保护也绕不过ID。
前提条件要反复确认:芯片ID必须是真正唯一、不可改写的。某些芯片所谓的“唯一ID”其实是可配置字,烧录时想怎么写就怎么写,那种方案选不得,因为破解者可以把新芯片的ID也改成和目标一致。选型时看数据手册的描述,确认ID由出厂测试写入,不可被用户修改,最好还带上晶圆坐标或批次信息,这些物理特征间接说明了它不在软件层可伪造的范围内。
2.2 F1函数怎么设计:不可逆、雪崩、不把底牌亮出来
F1是整个加密链路的第一环,也是编程器插件和单片机固件之间唯一的共识。代码上它不复杂,但设计上有两条硬指标。第一是不可逆,从IDX推不出ID,这其实有难度,因为ID只有几十位,暴力搜索空间不大,所以F1内部要多做几轮非线性变换,不要只做移位和异或。第二是雪崩效应,ID任意一位翻转,IDX至少一半位发生变化,否则破解者可以逐位逼近、分段还原。下面是一个骨架实现:
// 以8字节唯一ID为例,F1把它压缩成一个32位整数IDX static uint32_t f1_calc(const uint8_t id[8]) { uint32_t h = 0x811C9DC5u; // 散列初始值,属于私有约定 uint32_t m = 0x9E3779B9u; // 黄金分割常数,用于乘数混淆 for (int i = 0; i < 8; i++) { h ^= id[i]; h *= m; h ^= (h >> 13); // 高位反馈搅动,保证雪崩 h += (id[7 - i] << ((i & 3) * 8)); // 逆序字节再混一轮 } return h; }这段F1只是演示用骨架,生产环境还要加入产品系列号、私有常数表和字节重排。逻辑说明:h的初值和乘数m属于私有约定,不要用公开库函数直接映射,公开算法容易被识别出特征。参数说明:传入的id指针必须是完整8字节,取部分字节会让碰撞概率上升;返回32位整数,高16位和低16位都会作为后续派生值的来源。
2.3 IDX存储与读取:放哪里、怎么读都有讲究
F1算出的IDX要落到非易失存储里,常见位置是EEPROM尾部偏移固定地址,或主Flash末尾扇区。写入时先做一个异或掩码,让IDX不以明文形态出现,增加搜索特征的难度。
// 把IDX写入EEPROM,写入前做一层异或混淆 uint16_t idx_stored = (uint16_t)(f1_calc(chip_id) ^ 0xA5A5u); eeprom_write_bytes(EEPROM_IDX_ADDR, (uint8_t*)&idx_stored, 2);逻辑说明:异或掩码0xA5A5的本身不需要保密,但多这一层变换,自动搜索固定特征值IDX的脚本就失效了。参数说明:EEPROM_IDX_ADDR不要选整片地址空间的头部或尾部,选在中间偏后的位置,更容易混在正常业务数据里。读取侧的GET_IDX()要带缓存,避免每次调用都访问EEPROM,频繁访问同一个存储地址在反汇编里也是明显特征。
static uint16_t g_idx_cache; static uint8_t g_idx_ready; uint16_t get_idx(void) { if (!g_idx_ready) { uint16_t raw; eeprom_read_bytes(EEPROM_IDX_ADDR, (uint8_t*)&raw, 2); g_idx_cache = raw ^ 0xA5A5u; g_idx_ready = 1; } return g_idx_cache; }逻辑说明:g_idx_ready标志保证上电后只读一次EEPROM,之后所有派生值都从RAM取用。这里有个隐藏设计:F1(ID)的计算放在其他调用点,不在get_idx内部,这样反汇编时两个关键计算点分布在不同区域,破解者无法在一个函数里同时看到IDX的来源和去向。
2.4 读ID寄存器:固定地址既是入口也是突破口
芯片手册会给出UID基地址,常见写法是直接指针读取。
#define UID_BASE 0x1FFF7A10u // 某Cortex-M核芯片的UID首地址,以手册为准 uint32_t read_uid_word(int idx) { return *(volatile uint32_t *)(UID_BASE + idx * 4); }逻辑说明:用宏定义常量地址编译后,反汇编里必然出现0x1FFF7A10这个立即数,搜索这个数就能定位到读ID点。这是后续所有加密动作的总源头,也是整个方案最脆弱的位置。参数说明:idx取0到2,对应3个32位字;如果你只用一个读函数,破解者的patch目标就只有一个。正式的方案要在不同模块里放多个读函数,它们之间做一致性校验,这个放到第5章详细展开。
提示:确认芯片厂商手册里写的是“出厂固化、不可修改”的唯一ID,而不是可重写的配置字。这是方案成立的第一道门槛。
3. 混淆实战:getmy_0与getmy_1替换常量,inline是命门
3.1 理论根基:合法烧录时它们是恒等式
加密链路的骨架拉通后,接下来是让程序“自然地”依赖这套派生关系。原始思路给出两个函数,合法烧录时恒等于0和1,但烧录不匹配时它们的值是随机数。
static inline int getmy_1(void) { return (int)f1_calc(get_chip_id()) - (int)get_idx() + 1; } static inline int getmy_0(void) { return (int)f1_calc(get_chip_id()) - (int)get_idx(); }逻辑说明:合法情况下F1(ID)等于GET_IDX(),getmy_0等于0,getmy_1等于1。这两个函数的全部意义在于把真假判断从if中拿出来,变成算术里的0和1,再铺散到程序各处。参数说明:返回值用int,因为后续要参与指针运算和循环边界比较,int在Cortex-M核上是32位,与指针宽度一致,避免类型转换引入额外指令。
3.2 替换清单:程序里每个0和1都可以变成函数调用
常量替换覆盖面很广,下面列一个常用的对照表:
| 原始写法 | 替换写法 | 说明 |
|---|---|---|
| for(i=0;i<N;i++) | for(i=getmy_0();i<N;i++) | 循环起始值动态化 |
| p++ | p=p+getmy_1() | 指针后移一位 |
| if(flag==1) | if(flag==getmy_1()) | 比较目标值动态化 |
| x=1 | x=getmy_1() | 赋值常量替换 |
| i<=count-1 | i<=count-getmy_1() | 边界替换 |
| buffer[i]=0 | buffer[i]=buffer[i]-buffer[i] | 清零变成恒等计算 |
实际使用时的代码示例:
// 原本的数组清零 for (i = getmy_0(); i < ARRAY_LEN; i = i + getmy_1()) { buffer[i] = buffer[i] * getmy_1() - buffer[i]; } // 原本的指针自增 ptr = ptr + getmy_1(); // 原本的掩码 & 0xFF uint8_t low_byte = value & (getmy_1() + 0xFE);逻辑说明:buffer[i] * 1 - buffer[i]恒等于0,这是利用恒等变换把赋值变成计算。ptr整改后不再出现增量立即数1,反汇编里指针移动的语义变模糊。掩码那行getmy_1()+0xFE恒等于0xFF,如果ID校验不过,掩码变成0xFF±1,数据直接错位。参数说明:getmy_1()+0xFE这种写法把常量拆成两部分,一部分固定、一部分派生,比直接写getmy_1()更有迷惑性,因为反汇编者看到0xFE会误以为是普通掩码。
3.3 垃圾代码:用恒等式拖慢反汇编节奏
原始思路里有一句x=(x+1-getmy_1())*getmy_1(),值得单独拆开讲。x+1-getmy_1()在合法时等于x,乘以getmy_1()还是x,整行什么都没做。这类恒等垃圾代码对程序功能没有影响,但对反汇编者是实打实的麻烦:逐行分析时必须解开每个恒等式才能看清原始逻辑,工作量全耗在无意义的运算上。更狠的用法是交错嵌套:先算出一个“看似有意义”的中间变量,再经过几次恒等变换后恢复原值,中间插几行真正会改变状态的代码,混淆和实际逻辑混在一起。
反汇编里看到这种代码,最常用的方法是搜索立即数。比如掩码0xFE高频出现,自动脚本会认为这里有业务逻辑,可以把0xFE拆成0x80+0x7E,再把0x7E进一步拆成getmy_1()相关表达式,让自动搜索继续失效。
3.4 inline是命门:不内联等于白做
原始讨论里多次强调inline的必要性,这里把账算清楚。不内联时,反汇编图上所有调用箭头都会指向getmy_1和getmy_0两个函数地址,破解者第一眼就能看出这两个是校验函数。内联后每个调用点都复制一份完整函数体,调用关系消失,取而代之的是散布在程序各处的几十段相同计算指令。代价有三个:代码体积膨胀、执行效率下降、寄存器压力增大。对主流Cortex-M芯片,inline后的体积膨胀通常在2到3倍,如果叠加垃圾代码可能到5倍。
实操上建议给两个函数加上强制内联和优化属性:
static inline __attribute__((always_inline)) int getmy_1(void) { return (int)f1_calc(get_chip_id()) - (int)get_idx() + 1; }逻辑说明:always_inline告诉编译器不许犹豫,任何优化等级下都必须展开。参数说明:f1_calc和get_idx本身也要inline,否则即便getmy_1展开了,内部仍保留函数调用指令。如果编译器不支持always_inline属性,就用宏替代实现,这是最保险的兜底。
提示:inline不解决读ID点被定位的问题,它解决的是getmy_1函数被定位的问题。读ID点是另一个维度的命门,后面专门讲。
4. 穿透调用链:函数传参与全局变量的加盐/脱盐设计
4.1 传参加盐:把派生值打进数据流
替换常量的做法有一个弱点:数值本身还能被追踪。派生值被算出来之后,最终调用处仍然能看到它参与了运算。更强的做法是把派生值作为偏移量压进实际业务数据里,接收方再减去,让数据流被拦腰截断。原始思路里的函数传参就是这样的一层设计。
// 原始代码 void f1(void) { int i = 3; int j = f2(i); // j = 6 } int f2(int i) { return i * 2; } // 加密后 void f1(void) { int i = 3; int j = f2(i + (int)f1_calc(get_chip_id())); // 调用方加盐 } int f2(int i) { return (i - (int)get_idx()) * 2; // 被调函数脱盐 }逻辑说明:合法烧录时f1传入i+F1(ID),f2开头减掉GET_IDX(),两者相等,业务结果不变。但在反汇编里,f1里出现了F1(ID)的计算,f2开头出现了GET_IDX()的计算,两处代表不同语义的代码被隔开了。破解者单看f1不知道传进去的是业务值,单看f2也不知道减掉的是派生值,必须同时还原两个函数才能恢复数据流。参数说明:减法点必须放在被调函数最靠前的位置,在参数第一次被使用前完成。如果f2有多个入参,选一两个加盐就够,全加会明显增加指令数。
4.2 全局变量上的分摊抵消:加盐脱盐分开放
全局变量比函数参数更值得处理,因为全局变量生命周期长、访问点多,派生值可以分散在各个模块里。做法是:一部分函数写入时加上F1(ID),另一部分函数读取时减掉GET_IDX(),两个操作落在不同源文件、不同编译单元。
// A模块写入,混入派生值 g_speed = raw_speed + (int)f1_calc(get_chip_id()); // B模块读取,抵消派生值 speed = g_speed - (int)get_idx();逻辑说明:A模块和B模块的代码距离可能达到几十KB,反汇编者要把两处的变换同时还原才能恢复g_speed的原始值。这个方案的失败模式也很明确:只要有一个模块读取g_speed时忘了抵消,错误数据就会扩散到业务逻辑里。规避方法是把加盐和脱盐封装成两个函数,所有全局变量的读写都强制走这两个入口,不允许裸访问。参数说明:如果业务变量是16位,F1(ID)的高16位会被截断,混入时要保证F1的低16位一致性;或者干脆取F1的低16位做加盐值,避免类型转换时产生歧义。
4.3 自适应派生值:让破解者的搜索工具失效
固定两字节的IDX很容易在EEPROM里被搜索到,因为长度和存储位置都是常量。更稳的做法是让IDX存储本身依赖ID:长度可变、位置错开。
// 可变长IDX写入示意,长度和写入地址都由F1算出来 uint8_t len = 2 + (uint8_t)(f1_calc(chip_id) & 0x03); for (uint8_t i = 0; i < len; i++) { uint8_t seg = (idx_value >> (8 * ((i + 1) % len))) & 0xFF; uint16_t addr = EEPROM_IDX_BASE + (uint8_t)(f1_calc(chip_id) >> (8 * (i + 1))) % 16; eeprom_write_byte(addr, seg ^ (uint8_t)f1_calc(chip_id)); }逻辑说明:每个写出的字节都用F1(ID)的低八位做了异或,写入地址也由F1的分段结果错开,不会落在连续的固定地址区间。读取时按同样的算法反推地址和掩码,在启动初始化里执行一次,之后只保留摘要结果。参数说明:len为2到5字节,比固定2字节多一点存储开销,但换来的是自动搜索特征失效。
4.4 启动时机选择:读取失败的默认行为比读取本身更重要
派生值必须在任何业务逻辑使用前算好,这个顺序容易被轻视。常见做法是把get_idx()的调用放在main函数最开始、时钟初始化之后。但更关键的是读取失败时的默认行为,这里有一个反直觉的设计:不要进入死循环,也不要立刻关停所有功能。正确做法是带着错误的派生值继续跑,让功能随机地错。例如电机转速乱跳、显示数据偶发错误、通信校验失败,这类症状在一台设备上被快速发现,但殃及的是“所有用到常量替换的地方”,不是某个if判断。破解者必须先判断是硬件故障还是加密生效,这个判断过程会消耗大量时间。
5. 避坑指南:读ID点是命门、优化掉与复用性
5.1 读ID的地址是整个方案的总命门
现象:破解者用反汇编工具搜索UID地址附近的引用,找到后把返回值改成固定值,固件在任意芯片上都能跑。原因:所有派生值最终都由读ID这个单一入口产生,patch一点、全局失效。解决:做至少三个分散的UID读取函数,分布在不同的源文件和中断上下文,它们之间做一致性校验;校验失败时,不同位置的派生值被改成不同错误状态,而不是统一的死循环。读ID函数本身不要共用同一个底层实现,可以一个直接指针读取、一个通过DMA搬运、一个从BootLoader留下的内存镜像里取,让搜索特征分散。
5.2 编译器把恒等式优化掉了
现象:x=(x+1-getmy_1())*getmy_1()在O2优化下直接消失,垃圾代码全被编译器删了。原因:getmy_1被inline后成为常量1,编译器做常量折叠,识别出整行恒等。解决:把getmy_1()返回值声明为volatile,或者用内联汇编插入一条编译器屏障指令,阻止优化器进行常量传播。更彻底的做法是让垃圾代码的结果参与一个副作用路径,比如写入volatile变量或作为后续CRC计算的输入。血泪经验是:写完必须看反汇编确认优化效果,不能拿源代码脑补执行过程。
5.3 启动早期EEPROM未就绪导致跑飞
现象:上电后程序随机死机、复位,有时候低温环境更容易触发,查了半天找不到逻辑错误。原因:get_idx()在EEPROM控制器初始化完成前被调用,读到全0xFF,IDX错乱,F1(ID)-GET_IDX()变成一个巨随机数,所有运算全部崩盘。解决:启动代码里先初始化时钟,再用带超时的状态标志确认EEPROM可读,最后才调用get_idx()。把IDX缓存到RAM后,后续所有使用都走缓存,不再访问存储介质。调试阶段加一个编译期选项:在main开头对IDX做一次自检,失败则点亮错误灯,方便区分是硬件故障还是派生值异常。
5.4 中小ROM芯片塞不下整套方法
现象:ROM只有8KB或16KB,inline加垃圾代码后链接直接报错,或者勉强塞下导致启动速度明显变慢。原因:每个调用点都复制一份完整函数体,几十个调用点叠加后体积爆炸。解决:按模块挑选加密点,只加密最关键的业务校验、认证参数和通信协议处理,普通LED控制、按键扫描保留明文常量。如果必须全量加密,退一步用非inline但给函数做多份弱符号副本,让反汇编者看到几个名字不同、功能相同的函数,虽然不如inline隐蔽但体积可控。选型阶段的判断标准只有一个:你的ROM余量能不能承受3到5倍膨胀,不能就别上这套方案。
5.5 量产端跑偏:编程器不支持ID变换写入
现象:算法设计完成、代码调试通过,量产时发现编程器只会原样烧录HEX文件,IDX写不进去。原因:选的烧录器不支持唯一ID变换或自定义插件机制,方案在最后一公里断掉。解决:选型时确认编程器支持按ID变换写入数据或插件功能,这类工具市场上有不少,关键看能不能运行自定义脚本。如果预算受限,退一步用BootLoader方案:出厂先烧一个BootLoader,BootLoader里内置F1算法,首次启动时读取芯片ID、计算IDX、把应用区按ID变换后重写,后续每次启动先解密再执行。BootLoader本身用ID加密保护,量产时只需一次普通烧录,代价是启动时间增加几百毫秒。
提示:第5.1条和第5.5条是方案落地时最容易翻车的两个位置,一个是安全闭环断裂,一个是量产流程断裂,都必须在项目启动时确认,不要到样机阶段才补。
6. 进阶自测:换芯验证与可能的升级方向
评估这套加密是否真的生效,唯一可信的验收方法是换芯验证。我每次做完都会强制自己做一遍:把固件烧到另一颗新芯片上,跑全部功能测试。正确的加密结果应该是程序跑不出正确结果,或者出现随机错误,而不是稳定进入某个“错误状态”。如果换了芯片还能跑完所有功能,说明派生链路在某处失效,得回头检查是不是还有地方用了明文常量,或者编译器把某个恒等式优化掉了。这一条要写进量产前的测试用例,不能只靠看代码。
第二层自测是反汇编抽查。用反汇编工具搜一次UID地址引用数量,再统计get_idx()调用点的分布。如果引用点只有一两处,方案不合格;如果内联展开后几十处,才算达到分散目标。我习惯在O2优化下编一版,专门拿IDA打开对照检查,重点看三个位置:UID基地址出现了几次、get_idx对应逻辑出现在几个函数里、垃圾代码是否还存在于反汇编结果中。这三个检查项都有明确数字,可以量化到测试记录里。
最后一个升级方向是代码动态解码。把最敏感的一段业务逻辑加密成数据表,固件里只存密文,启动时用F1(ID)作为密钥XOR解码到RAM里执行。这样反汇编者看到的就是一堆密文数据,连getmy_1都不用找。硬件上有硬件CRC或排列置换外设的芯片更适合这个做法,因为这些外设的输入输出关系不在普通指令流里,反汇编难度进一步提高。要注意的是RAM开销和启动时间,解码后的代码段通常要占用一块连续RAM区。
在那之后我每次做方案评审,都会拿出新芯片烧一版固件跑一遍全功能回归,确认它没有静默通过,只有行为错乱才能说明加密在工作。这套方案不是不可破解,它的价值在于把破解成本从一条patch指令,抬升到买一台支持插件变换的量产编程器加做一次完整逆向分析的程度,对小批量、低成本的产品来说,这个门槛大概率已经够用了。希望帮到你。
本文还有配套的精品资源,点击获取