1. 为什么你的单片机程序容易被克隆?
最近有个做智能硬件的朋友跟我吐槽,他花了大半年开发的STM32产品,上市不到三个月就被山寨了。对方不仅抄了电路板,连程序都原封不动地复制过去。这让我想起刚入行时,我也遇到过类似问题——当时做的工业控制器,程序被人用J-Link直接读出来烧到别的芯片上,防伪措施形同虚设。
其实单片机程序被克隆主要有两个漏洞:一是芯片唯一ID没被利用,二是Flash存储区没做写保护。就像你家大门既没装锁也没装监控,小偷当然来去自如。我后来在多个项目里验证过,通过芯片ID绑定+Flash加密存储的方案,能有效阻止90%的初级克隆行为,而且完全不需要增加硬件成本。
2. 芯片唯一ID:你的硬件指纹
2.1 获取唯一ID的正确姿势
所有STM32/GD32芯片出厂时都烧录了96位的唯一ID(UID),这个ID就像芯片的身份证号。以STM32F103为例,获取ID的代码其实很简单:
uint32_t GetID[3]; GetID[0] = *(uint32_t*)(0x1FFFF7E8); GetID[1] = *(uint32_t*)(0x1FFFF7EC); GetID[2] = *(uint32_t*)(0x1FFFF7F0);但这里有个坑:不同系列芯片的UID地址可能不同。比如GD32E230的UID在0x1FFFF7AC,STM32H7则分成了UID和DEV_ID两部分。建议在项目初期就查清楚芯片手册,我习惯用宏定义来处理差异:
#ifdef STM32F1 #define UID_ADDR 0x1FFFF7E8 #elif defined(GD32E23) #define UID_ADDR 0x1FFFF7AC #endif2.2 ID存储的黄金位置
很多开发者随便找个Flash地址就存ID,这是非常危险的。我有次产品返修,发现客户用编程器擦除了整个芯片,连带ID存储区也清空了。正确做法是:
- 查看链接脚本(.ld文件)确认程序占用空间
- 选择最后一个扇区的中间位置存储(避开擦除边界)
- 最好跨两个扇区存储备份(防局部擦除)
比如512KB Flash的STM32,程序用到480KB,可以这样规划:
0x08000000 - 0x08078000 // 程序区 0x08078000 - 0x0807C000 // ID主存储 0x0807C000 - 0x08080000 // ID备份存储3. 防克隆的核心逻辑实现
3.1 首次上电的"烧指纹"机制
第一次运行程序时,需要完成ID写入。这里有个精妙的设计点:不能简单判断全0xFF就认为是新芯片,因为擦除后的Flash本来就是0xFF。我的做法是:
void CheckFirstBoot(void) { uint32_t storedID[3]; Flash_Read(UID_STORE_ADDR, storedID, 3); // 检查特定魔术字 if(storedID[0] == 0x55AA55AA && storedID[1] != 0xFFFFFFFF) { // 正常模式 VerifyID(); } else { // 首次运行模式 WriteUID(); WriteMagicNumber(); } }这个魔术字(0x55AA55AA)有两个作用:一是标记存储区已被初始化,二是作为校验码防止误判。记得要在写入ID后立即写魔术字,顺序不能错。
3.2 动态校验策略
如果每次上电都固定校验ID,黑客可能会通过修改判断逻辑绕过防护。我推荐三种动态校验方式:
- 延时校验:正常运行5分钟后突然校验
- 事件触发校验:当检测到关键操作时校验
- 分块校验:将ID拆分成三部分在不同时机校验
实测最有效的是结合RTC的随机校验:
void RTC_Alarm_IRQHandler(void) { static uint8_t check_cnt = 0; if(++check_cnt > 3) { check_cnt = 0; if(!VerifyID()) SelfDestruct(); } }4. Flash防护的进阶技巧
4.1 FMC写保护配置
光靠ID校验还不够,必须防止攻击者修改Flash内容。STM32的FMC(闪存控制器)可以锁定关键区域:
void LockFlashSectors(void) { FLASH_OBProgramInitTypeDef OBInit; HAL_FLASHEx_OBGetConfig(&OBInit); OBInit.OptionType = OPTIONBYTE_WRP; OBInit.WRPState = OB_WRPSTATE_ENABLE; OBInit.WRPSector = OB_WRP_SECTOR_7; // 保护最后一个扇区 HAL_FLASHEx_OBProgram(&OBInit); }注意GD32的写法略有不同,需要先解锁选项字节:
FMC_OB_Unlock(); FMC_OB_WriteProtectionEnable(OB_WP_7); FMC_OB_Lock();4.2 自毁机制设计
对于高价值产品,我会在检测到篡改时启动自毁:
- 擦除关键算法区域
- 永久禁用调试接口
- 写入错误配置导致芯片无法工作
void SelfDestruct(void) { __disable_irq(); FLASH_Erase_Sector(FLASH_SECTOR_0, VOLTAGE_RANGE_3); FLASH_Erase_Sector(FLASH_SECTOR_1, VOLTAGE_RANGE_3); while(1); // 死循环 }5. 对抗高级破解的手段
5.1 混淆ID存储位置
高手可能会直接搜索内存中的UID特征码。我们可以这样做:
- 将原始ID与RTC计数器异或存储
- 分散存储在不同扇区
- 加入伪随机填充字节
void ObfuscateUID(uint32_t* uid) { uint32_t mask = HAL_GetTick() ^ 0xDEADBEEF; uid[0] ^= mask; uid[1] ^= (mask >> 16) | (mask << 16); }5.2 结合硬件特性增强防护
即使没有加密芯片,也可以利用:
- 内部RC振荡器校准值:每颗芯片的校准值不同
- ADC基准电压误差:测量VREFINT值作为特征
- SRAM上电随机值:捕获初始随机模式
这些值组合起来能形成更强的"设备指纹":
float GetChipSignature(void) { uint16_t vref = ReadVREFINT(); uint32_t sram_sign = *(uint32_t*)0x20000000; return (vref * HAL_GetUID()[0]) ^ sram_sign; }6. 实际项目中的经验教训
去年有个医疗设备项目,客户要求必须通过FDA认证。我们最初方案只是简单校验UID,结果被审计团队用逻辑分析仪抓到了校验时序。后来改进的方案包含:
- 校验时序随机化
- 关键函数地址动态跳转
- 在RAM中解密部分代码
- 校验失败不立即死机,而是引入随机故障
最有趣的是,我们故意在检测到调试器时,让设备"正常"工作但悄悄降低精度。直到三个月后的一次校准中,山寨厂商才发现数据始终有0.5%的偏差。