做 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 校验,对不上别奇怪。解决办法有两个:
- PC 端也用 MPEG-2 多项式算(Python 可以自己实现)
- 用 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 绑定拦住了),实际落地效果很好。
这两个外设不需要额外硬件,不需要额外成本,唯一的门槛是你得知道它们在哪。