STM32 硬件 CRC 与 UID 实战:固件校验防变砖,UID 防抄板,这两个外设白用太可惜 做 IAP 升级的时候遇到一个问题新固件写进 Flash 之后怎么确认它没被损坏读出来逐字节对比最直观但 Flash 读速度慢32KB 的固件要对比好几毫秒。后来发现 STM32 自带一个 CRC 硬件计算单元一条指令就能算出一个字的 CRC 值比软件查表快十倍以上。另一个被忽略的外设是 96 位唯一设备标识符UID。每颗芯片出厂时烧录了全球唯一的 ID不重复、不可改。拿它做设备绑定、加密密钥派生、防抄板验证比自己在 EEPROM 里烧序列号靠谱得多。硬件 CRC 单元怎么用STM32 的 CRC 外设是一个基于多项式除法的计算器。你往CRC_DR寄存器里写数据可以按字节、半字或字写入它自动把当前 CRC 值和新数据合并算出新的 CRC整个过程不需要 CPU 干预运算只是搬运数据到寄存器而已。HAL 库封装得很简单/* 初始化清零 CRC 寄存器 */__HAL_RCC_CRC_CLK_ENABLE();CRC_HandleTypeDef hcrc;hcrc.InstanceCRC;HAL_CRC_Init(hcrc);/* 计算传入数据指针和长度单位是 32-bit 字 */uint32_tcrcHAL_CRC_Calculate(hcrc,(uint32_t*)data,len_words);len_words是以 32 位字为单位的长度。如果你有 100 字节数据要传 25100 / 4。如果最后多出几个字节不够凑成一个字要么补零对齐要么改用HAL_CRC_Calculate_ByteData按字节喂。一个容易翻车的坑喂法不同结果不同这是 CRC 硬件单元最容易踩的坑没有之一。同一个数据数组你按字喂和按字节喂算出来的 CRC 不一样。不是差一点点是完全不同的两个值。原因是 CRC 硬件的内部移位寄存器是按 32 位宽度工作的。当你写一个 32 位字进去它把整个字一次性送进移位寄存器当你按字节写它每次只送 8 位进去中间的移位过程不一样最终余数自然不同。uint8_tbuf[8]{0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08};/* 按字喂2 个字 */uint32_tcrc_wordHAL_CRC_Calculate(hcrc,(uint32_t*)buf,2);/* 结果假设为 0xAABBCCDD *//* 按字节喂8 个字节 */uint32_tcrc_byteHAL_CRC_Calculate_ByteData(hcrc,buf,8);/* 结果完全不同的值比如 0x11223344 */所以发送端和接收端必须用相同的喂法。IAP 场景下Bootloader 里算 CRC 的方式和 PC 端生成固件时用的方式必须一致否则永远校验失败你会以为固件坏了其实是算法没对齐。多项式问题MPEG-2 和 zlib 不是一回事STM32F1/F4 的 CRC 硬件固定使用CRC-32/MPEG-2多项式0x04C11DB7输入输出都不反转无反射。但很多上位机工具比如 Python 的binascii.crc32、zlib 库用的是CRC-32/ISO-HDLC多项式0xEDB88320这是 MPEG-2 的反射版输入输出位都反转。两者算出来的值完全不同。如果你在 PC 端用 Python 算了一个 CRC 写进固件头然后 Bootloader 用硬件 CRC 校验对不上别奇怪。解决办法有两个PC 端也用 MPEG-2 多项式算Python 可以自己实现用 STM32L4 系列它的 CRC 外设支持配置多项式可以直接改成 ISO-HDLCSTM32L4 的 CRC 还支持自定义初始值和输入/输出反转基本能兼容所有常见 CRC 变体/* L4: 配置成 ISO-HDLC 兼容模式 */MODIFY_REG(hcrc.Instance-CR,CRC_CR_POLYSIZE,CRC_POLYLENGTH_32B);hcrc.Instance-POL0x04C11DB7;/* 或 0xEDB88320 取决于是否反射 */hcrc.Instance-INIT0xFFFFFFFF;/* ISO-HDLC 初始值 */hcrc.Instance-CR|CRC_CR_REV_IN_Msk;/* 输入反转 */hcrc.Instance-CR|CRC_CR_REV_OUT_Msk;/* 输出反转 */固件完整性校验实战IAP 里最常见的用法App 固件在编译链接后算一次 CRC烧录前把这个值写在固定偏移处比如 App 区倒数第四个字。Bootloader 收到新固件写完 Flash 后对整个 App 区跑一遍硬件 CRC和存储的预期值比对。#defineAPP_ADDR0x08008000#defineAPP_SIZE0x18000/* 96KB */#defineCRC_OFFSET(APP_ADDRAPP_SIZE-4)intverify_app_crc(void){__HAL_RCC_CRC_CLK_ENABLE();CRC_HandleTypeDef hcrc;hcrc.InstanceCRC;HAL_CRC_Init(hcrc);uint32_tcomputedHAL_CRC_Calculate(hcrc,(uint32_t*)APP_ADDR,APP_SIZE/4);uint32_texpected*(uint32_t*)CRC_OFFSET;return(computedexpected)?0:-1;}注意APP_SIZE / 4必须整除。如果 App 大小不是 4 的倍数尾部补零的字节也会参与 CRC 计算PC 端生成预期值的时候也要同样补零。这个校验能挡住传输错误和 Flash 写入坏块但挡不住恶意篡改。如果攻击者修改了固件内容并重新算了 CRC 写回去这个检查就形同虚设。需要更高安全性的话要用 SHA-256 之类的哈希算法纯软件实现慢一些但安全得多或者利用 UID 做绑定。96 位 UID每颗芯片的身份证STM32 每颗芯片在出厂时烧录了一组 96 位的唯一标识符存在特定的内存地址里。它不属于外设寄存器就是映射在 Flash 地址空间里的一段只读数据直接用指针读取就行/* F1/F4 系列 UID 地址 */#defineUID_BASE0x1FFF7A10typedefstruct{uint32_tU_ID[3];/* 96 bits 3 x 32-bit words */}UID_TypeDef;#defineUID((UID_TypeDef*)UID_BASE)voidprint_uid(void){printf(UID: %08X-%08X-%08X\r\n,UID-U_ID[0],UID-U_ID[1],UID-U_ID[2]);}不同系列的 UID 基址不一样系列UID 基址备注F0 / F1 / F3 / F4 / F70x1FFF7A1012 字节连续L0 / L1 / L40x1FF80050同上G0 / G40x1FFF7590同上H70x1FF1E800同上代码里最好做成宏或者条件编译换芯片系列只需要改一处。UID 能做什么设备序列号。不用自己维护编号表直接用 UID 当 SN。云端注册设备时传 UID天然去重。96 位随机空间大到碰撞概率可以忽略不计。加密密钥派生。拿 UID 做 AES 密钥的种子比如用 SHA-256 对 UID 做哈希取前 16 字节当 AES-128 密钥每台设备的通信密钥都不同。即使一台设备被提取了密钥其他设备不受影响。这比所有设备共用一把硬编码密钥安全几个数量级。防抄板验证。程序启动时读 UID 和 Flash 里存的一个合法 UID 列表比对不在列表里就拒绝运行。抄板者复制了 Flash 内容但换了芯片UID 对不上功能锁死。当然防抄板不是绝对安全的。攻击者可以读出你的合法 UID 列表然后把自己的 UID 加进去或者 patch 掉比对指令。但 UID 至少提高了抄袭成本让简单复制粘贴行不通。/* 简单的 UID 绑定示例 */intcheck_uid_binding(void){/* 合法 UID 存在 Flash 用户扇区末尾 */constuint32_t*allowed(constuint32_t*)(FLASH_USER_END-12);uint32_tmy_uid[3]{UID-U_ID[0],UID-U_ID[1],UID-U_ID[2]};if(my_uid[0]allowed[0]my_uid[1]allowed[1]my_uid[2]allowed[2]){return0;/* 合法 */}return-1;/* 非法拒绝运行 */}RDP 读保护配合 UID 用效果更好光靠 UID 绑定还不够因为攻击者可以用 SWD 读出 Flash 内容看到你的比对逻辑和合法列表。加上 RDPRead Out Protection读保护之后SWD/JTAG 被禁掉Flash 内容不可读攻击难度大幅提升。RDP 有三个等级Level 0无保护开放调试Level 1禁止调试访问 Flash/SRAM但允许从 SRAM 启动部分攻击向量仍可用Level 2不可逆保护调试永久禁用且无法降级回 Level 0/1。慎用一旦设置无法解除/* 设置 Level 1 保护 */HAL_FLASHEx_OBProgramInitTypeDef ob;ob.OptionTypeOPTIONBYTE_RDP;ob.RDPLevelOB_RDP_LEVEL_1;HAL_FLASHEx_OBProgram(ob);设置完 RDP 要复位才生效。生效后再连 SWD 会提示目标连接失败说明保护已启用。十个坑一、CRC 喂法和上位机不一致。按字喂和按字节喂结果完全不同。IAP 场景下 Bootloader 和 PC 工具必须统一喂法否则永远校验失败。二、多项式搞混了 MPEG-2 和 ISO-HDLC。F1/F4 硬件 CRC 固定 MPEG-2Python/zlib 默认 ISO-HDLC。两边算出来的值天差地别不要以为都是 “CRC-32” 就一样。三、长度没对齐到字。HAL_CRC_Calculate的长度参数是 32 位字数。传字节数会多算三倍数据结果错。尾部多余字节要补零。四、CRC 没重新初始化就复用。连续两次调用HAL_CRC_Calculate第二次会在第一次的结果上继续算不会从头开始。如果需要独立计算两段数据要在两次之间调HAL_CRC_DeInitHAL_CRC_Init重置。五、UID 地址写死了 F1 的基址。换到 L4 或 G4 系列就读错了拿到的是别的数据而非真实 UID。一定要查对应数据手册确认基址。六、UID 当唯一索引但没考虑字节序。UID 的三个 32 位字在不同平台上的解释可能因大小端而异。建议统一转成字节流再比较或做哈希。七、防抄板只用 UID 没加 RDP。攻击者可以通过 SWD 读出 Flash 看到 UID 比对逻辑和合法列表轻松绕过。UID RDP 才是完整方案。八、RDP Level 2 设上去后悔。这个等级不可逆。开发阶段用 Level 0量产烧录时再切 Level 1。Level 2 只在你确定永远不会需要现场调试的情况下才用。九、CRC 校验放在可被跳过的位置。如果攻击者能修改固件他也能 NOP 掉你的 CRC 检查函数。关键校验逻辑放在启动早期且分散多处增加 patch 成本。十、UID 派生密钥用了弱哈希。直接截取 UID 的低 128 位当 AES 密钥是不安全的熵只有 96 位而且分布可能不均匀。至少做一次 SHA-256 哈希再截取。小结CRC 硬件单元和 96 位 UID 是 STM32 上两个免费送但你可能一直没用的外设。CRC 让你在几个时钟周期内完成大块数据的完整性校验IAP 升级、通信协议校验都能用UID 给每颗芯片一个不可伪造的身份做设备管理、密钥派生、防抄板都比自建方案省心。我后来把 IAP 流程改成PC 端用 MPEG-2 多项式算 CRC 写入固件头Bootloader 用硬件 CRC 校验通过后才跳转执行同时读 UID 派生 AES 密钥做 OTA 包解密再加 Level 1 RDP 锁住调试口。这套组合下来固件不会被刷坏CRC 拦住了也不会被抄走RDP UID 绑定拦住了实际落地效果很好。这两个外设不需要额外硬件不需要额外成本唯一的门槛是你得知道它们在哪。