STM32 硬件 CRC 与 UID 实战:固件校验防变砖,UID 防抄板,这两个外设白用太可惜

做 IAP 升级的时候遇到一个问题:新固件写进 Flash 之后怎么确认它没被损坏?读出来逐字节对比最直观,但 Flash 读速度慢,32KB 的固件要对比好几毫秒。后来发现 STM32 自带一个 CRC 硬件计算单元,一条指令就能算出一个字的 CRC 值,比软件查表快十倍以上。

另一个被忽略的外设是 96 位唯一设备标识符(UID)。每颗芯片出厂时烧录了全球唯一的 ID,不重复、不可改。拿它做设备绑定、加密密钥派生、防抄板验证,比自己在 EEPROM 里烧序列号靠谱得多。

硬件 CRC 单元怎么用

STM32 的 CRC 外设是一个基于多项式除法的计算器。你往 CRC_DR 寄存器里写数据(可以按字节、半字或字写入),它自动把当前 CRC 值和新数据合并算出新的 CRC,整个过程不需要 CPU 干预运算,只是搬运数据到寄存器而已。

HAL 库封装得很简单:

c 复制代码
/* 初始化:清零 CRC 寄存器 */
__HAL_RCC_CRC_CLK_ENABLE();
CRC_HandleTypeDef hcrc;
hcrc.Instance = CRC;
HAL_CRC_Init(&hcrc);

/* 计算:传入数据指针和长度(单位是 32-bit 字) */
uint32_t crc = HAL_CRC_Calculate(&hcrc, (uint32_t *)data, len_words);

len_words 是以 32 位字为单位的长度。如果你有 100 字节数据,要传 25(100 / 4)。如果最后多出几个字节不够凑成一个字,要么补零对齐,要么改用 HAL_CRC_Calculate_ByteData 按字节喂。

一个容易翻车的坑:喂法不同结果不同

这是 CRC 硬件单元最容易踩的坑,没有之一。

同一个数据数组,你按字喂和按字节喂,算出来的 CRC 不一样。不是差一点点,是完全不同的两个值。

原因是 CRC 硬件的内部移位寄存器是按 32 位宽度工作的。当你写一个 32 位字进去,它把整个字一次性送进移位寄存器;当你按字节写,它每次只送 8 位进去,中间的移位过程不一样,最终余数自然不同。

c 复制代码
uint8_t buf[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};

/* 按字喂:2 个字 */
uint32_t crc_word = HAL_CRC_Calculate(&hcrc, (uint32_t *)buf, 2);
/* 结果:假设为 0xAABBCCDD */

/* 按字节喂:8 个字节 */
uint32_t crc_byte = HAL_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 校验,对不上别奇怪。解决办法有两个:

  1. PC 端也用 MPEG-2 多项式算(Python 可以自己实现)
  2. 用 STM32L4+ 系列,它的 CRC 外设支持配置多项式,可以直接改成 ISO-HDLC

STM32L4+ 的 CRC 还支持自定义初始值和输入/输出反转,基本能兼容所有常见 CRC 变体:

c 复制代码
/* L4+: 配置成 ISO-HDLC 兼容模式 */
MODIFY_REG(hcrc.Instance->CR, CRC_CR_POLYSIZE, CRC_POLYLENGTH_32B);
hcrc.Instance->POL = 0x04C11DB7;          /* 或 0xEDB88320 取决于是否反射 */
hcrc.Instance->INIT = 0xFFFFFFFF;           /* 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,和存储的预期值比对。

c 复制代码
#define APP_ADDR     0x08008000
#define APP_SIZE     0x18000       /* 96KB */
#define CRC_OFFSET   (APP_ADDR + APP_SIZE - 4)

int verify_app_crc(void)
{
    __HAL_RCC_CRC_CLK_ENABLE();
    CRC_HandleTypeDef hcrc;
    hcrc.Instance = CRC;
    HAL_CRC_Init(&hcrc);

    uint32_t computed = HAL_CRC_Calculate(
        &hcrc,
        (uint32_t *)APP_ADDR,
        APP_SIZE / 4
    );

    uint32_t expected = *(uint32_t *)CRC_OFFSET;

    return (computed == expected) ? 0 : -1;
}

注意 APP_SIZE / 4 必须整除。如果 App 大小不是 4 的倍数,尾部补零的字节也会参与 CRC 计算,PC 端生成预期值的时候也要同样补零。

这个校验能挡住传输错误和 Flash 写入坏块,但挡不住恶意篡改。如果攻击者修改了固件内容并重新算了 CRC 写回去,这个检查就形同虚设。需要更高安全性的话,要用 SHA-256 之类的哈希算法(纯软件实现,慢一些但安全得多),或者利用 UID 做绑定。

96 位 UID:每颗芯片的身份证

STM32 每颗芯片在出厂时烧录了一组 96 位的唯一标识符,存在特定的内存地址里。它不属于外设寄存器,就是映射在 Flash 地址空间里的一段只读数据,直接用指针读取就行:

c 复制代码
/* F1/F4 系列 UID 地址 */
#define UID_BASE   0x1FFF7A10

typedef struct {
    uint32_t U_ID[3];   /* 96 bits = 3 x 32-bit words */
} UID_TypeDef;

#define UID   ((UID_TypeDef *)UID_BASE)

void print_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 / F7 0x1FFF7A10 12 字节连续
L0 / L1 / L4 0x1FF80050 同上
G0 / G4 0x1FFF7590 同上
H7 0x1FF1E800 同上

代码里最好做成宏或者条件编译,换芯片系列只需要改一处。

UID 能做什么

设备序列号。不用自己维护编号表,直接用 UID 当 SN。云端注册设备时传 UID,天然去重。96 位随机空间大到碰撞概率可以忽略不计。

加密密钥派生。拿 UID 做 AES 密钥的种子(比如用 SHA-256 对 UID 做哈希取前 16 字节当 AES-128 密钥),每台设备的通信密钥都不同。即使一台设备被提取了密钥,其他设备不受影响。这比所有设备共用一把硬编码密钥安全几个数量级。

防抄板验证。程序启动时读 UID 和 Flash 里存的一个"合法 UID 列表"比对,不在列表里就拒绝运行。抄板者复制了 Flash 内容但换了芯片,UID 对不上,功能锁死。

当然,防抄板不是绝对安全的。攻击者可以读出你的合法 UID 列表然后把自己的 UID 加进去,或者 patch 掉比对指令。但 UID 至少提高了抄袭成本,让简单复制粘贴行不通。

c 复制代码
/* 简单的 UID 绑定示例 */
int check_uid_binding(void)
{
    /* 合法 UID 存在 Flash 用户扇区末尾 */
    const uint32_t *allowed = (const uint32_t *)(FLASH_USER_END - 12);
    uint32_t my_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]) {
        return 0;   /* 合法 */
    }
    return -1;      /* 非法,拒绝运行 */
}

RDP 读保护:配合 UID 用效果更好

光靠 UID 绑定还不够,因为攻击者可以用 SWD 读出 Flash 内容看到你的比对逻辑和合法列表。加上 RDP(Read Out Protection)读保护之后,SWD/JTAG 被禁掉,Flash 内容不可读,攻击难度大幅提升。

RDP 有三个等级:

  • Level 0:无保护,开放调试
  • Level 1:禁止调试访问 Flash/SRAM,但允许从 SRAM 启动(部分攻击向量仍可用)
  • Level 2:不可逆保护,调试永久禁用且无法降级回 Level 0/1。慎用,一旦设置无法解除
c 复制代码
/* 设置 Level 1 保护 */
HAL_FLASHEx_OBProgramInitTypeDef ob;
ob.OptionType = OPTIONBYTE_RDP;
ob.RDPLevel = OB_RDP_LEVEL_1;
HAL_FLASHEx_OBProgram(&ob);

设置完 RDP 要复位才生效。生效后再连 SWD 会提示"目标连接失败",说明保护已启用。

十个坑

一、CRC 喂法和上位机不一致。 按字喂和按字节喂结果完全不同。IAP 场景下 Bootloader 和 PC 工具必须统一喂法,否则永远校验失败。

二、多项式搞混了 MPEG-2 和 ISO-HDLC。 F1/F4 硬件 CRC 固定 MPEG-2,Python/zlib 默认 ISO-HDLC。两边算出来的值天差地别,不要以为都是 "CRC-32" 就一样。

三、长度没对齐到字。 HAL_CRC_Calculate 的长度参数是 32 位字数。传字节数会多算三倍数据,结果错。尾部多余字节要补零。

四、CRC 没重新初始化就复用。 连续两次调用 HAL_CRC_Calculate,第二次会在第一次的结果上继续算,不会从头开始。如果需要独立计算两段数据,要在两次之间调 HAL_CRC_DeInit + HAL_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 绑定拦住了),实际落地效果很好。

这两个外设不需要额外硬件,不需要额外成本,唯一的门槛是你得知道它们在哪。

相关推荐
嵌入式阿蔡2 小时前
CoAP / LwM2M 轻量协议:受限设备为什么不用 MQTT
单片机·物联网·嵌入式
wuyk5553 小时前
107.FreeRTOS 链表深度解析:从原理到面试满分答案
c语言·开发语言·数据结构·stm32·单片机·链表·面试
hongmai6668883 小时前
ESP32-WROVER-IE-N4R8:外接天线+8MB内存,专治信号焦虑
笔记·单片机·嵌入式硬件·microsoft·risc-v
恒锐丰科技林技术员3 小时前
EG11722:高耐压 150V 降压 DC‑DC 电源芯片,工业宽压电源优选方案
经验分享·嵌入式硬件
安河桥。3 小时前
MIPI CSI-2物理层接口:C-PHY与D-PHY技术规格说明
嵌入式硬件·视频
雾削木3 小时前
GPIO基本概念
单片机·嵌入式硬件
LCG元4 小时前
STM32 USB CDC 虚拟串口开发实战:从 CubeMX 配置到 printf 重定向与稳定收发
stm32·单片机·嵌入式硬件
zcmodeltech5 小时前
源网荷储一体化沙盘模型多场景控制系统设计——基于STM32与Modbus RTU的源网荷储、多能互补、冷热电三联供全场景联动方案,服务范围覆盖全国
数据库·人工智能·stm32·单片机·嵌入式硬件
一条破秋裤5 小时前
17_ADC原理与转换模式
笔记·stm32·学习