
简介针对单片机程序被复制、被逆向的痛点这份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等于0getmy_1等于1。这两个函数的全部意义在于把真假判断从if中拿出来变成算术里的0和1再铺散到程序各处。参数说明返回值用int因为后续要参与指针运算和循环边界比较int在Cortex-M核上是32位与指针宽度一致避免类型转换引入额外指令。3.2 替换清单程序里每个0和1都可以变成函数调用常量替换覆盖面很广下面列一个常用的对照表原始写法替换写法说明for(i0;iN;i)for(igetmy_0();iN;i)循环起始值动态化pppgetmy_1()指针后移一位if(flag1)if(flaggetmy_1())比较目标值动态化x1xgetmy_1()赋值常量替换icount-1icount-getmy_1()边界替换buffer[i]0buffer[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(x1-getmy_1())*getmy_1()值得单独拆开讲。x1-getmy_1()在合法时等于x乘以getmy_1()还是x整行什么都没做。这类恒等垃圾代码对程序功能没有影响但对反汇编者是实打实的麻烦逐行分析时必须解开每个恒等式才能看清原始逻辑工作量全耗在无意义的运算上。更狠的用法是交错嵌套先算出一个“看似有意义”的中间变量再经过几次恒等变换后恢复原值中间插几行真正会改变状态的代码混淆和实际逻辑混在一起。反汇编里看到这种代码最常用的方法是搜索立即数。比如掩码0xFE高频出现自动脚本会认为这里有业务逻辑可以把0xFE拆成0x800x7E再把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传入iF1(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(x1-getmy_1())*getmy_1()在O2优化下直接消失垃圾代码全被编译器删了。原因getmy_1被inline后成为常量1编译器做常量折叠识别出整行恒等。解决把getmy_1()返回值声明为volatile或者用内联汇编插入一条编译器屏障指令阻止优化器进行常量传播。更彻底的做法是让垃圾代码的结果参与一个副作用路径比如写入volatile变量或作为后续CRC计算的输入。血泪经验是写完必须看反汇编确认优化效果不能拿源代码脑补执行过程。5.3 启动早期EEPROM未就绪导致跑飞现象上电后程序随机死机、复位有时候低温环境更容易触发查了半天找不到逻辑错误。原因get_idx()在EEPROM控制器初始化完成前被调用读到全0xFFIDX错乱F1(ID)-GET_IDX()变成一个巨随机数所有运算全部崩盘。解决启动代码里先初始化时钟再用带超时的状态标志确认EEPROM可读最后才调用get_idx()。把IDX缓存到RAM后后续所有使用都走缓存不再访问存储介质。调试阶段加一个编译期选项在main开头对IDX做一次自检失败则点亮错误灯方便区分是硬件故障还是派生值异常。5.4 中小ROM芯片塞不下整套方法现象ROM只有8KB或16KBinline加垃圾代码后链接直接报错或者勉强塞下导致启动速度明显变慢。原因每个调用点都复制一份完整函数体几十个调用点叠加后体积爆炸。解决按模块挑选加密点只加密最关键的业务校验、认证参数和通信协议处理普通LED控制、按键扫描保留明文常量。如果必须全量加密退一步用非inline但给函数做多份弱符号副本让反汇编者看到几个名字不同、功能相同的函数虽然不如inline隐蔽但体积可控。选型阶段的判断标准只有一个你的ROM余量能不能承受3到5倍膨胀不能就别上这套方案。5.5 量产端跑偏编程器不支持ID变换写入现象算法设计完成、代码调试通过量产时发现编程器只会原样烧录HEX文件IDX写不进去。原因选的烧录器不支持唯一ID变换或自定义插件机制方案在最后一公里断掉。解决选型时确认编程器支持按ID变换写入数据或插件功能这类工具市场上有不少关键看能不能运行自定义脚本。如果预算受限退一步用BootLoader方案出厂先烧一个BootLoaderBootLoader里内置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指令抬升到买一台支持插件变换的量产编程器加做一次完整逆向分析的程度对小批量、低成本的产品来说这个门槛大概率已经够用了。希望帮到你。本文还有配套的精品资源点击获取