简介:这是一份基于STM32F103单片机的程序加密保护实验软件例程源码,面向嵌入式初学者、高校实践课程以及希望为固件加入防复制措施的开发者。工程在标准外设库基础上,把软件加密、Bootloader安全加载与Flash保护配置作为主线,同时引入DS1302实时时钟、HS0038红外遥控和SysTick系统定时器,组成一个综合性的安全实验项目,既能演示时间相关密钥等扩展思路,也为后续产品固件安全设计提供可参考的代码框架。资源压缩包为rar格式,共包含122个文件,其中以58个头文件和56个C源文件为主体,另有Keil工程文件(uvprojx/uvoptx)、hex固件、汇编启动文件、辅助清理脚本及配置信息,整体大小约395KB;工程中各类外设驱动、实验主程序与安全相关代码分区明确,便于在MDK中直接打开、编译并下载到开发板逐模块观察运行效果。当前已有181人学习下载。借助该源码,既可以理解AES/DES等加密算法嵌入固件的基本方法,也能掌握DS1302时钟读取、HS0038红外信号解析、SysTick定时器使用的典型工程实现;对系统性学习STM32底层开发和嵌入式安全设计都有较强的参考价值。
1. 拿到这块板子,先回答“固件凭什么能被抄走”
如果你手里有一块 STM32F103 单片机开发板,烧录完程序之后直接交给产线,那这个程序基本等于裸奔。拿着 ST-Link 接上 SWD 四根线,用 STM32CubeProgrammer 点一下读取,整个 Flash 里的 hex 就能被完整拖出来,再烧到另一颗同型号芯片上,硬件换一颗、程序一分钱不花,抄板的人连原理图都省了。所以“基于 STM32f103 单片机程序加密保护实验软件例程源代码”这个标题真正要解决的问题,不是教你怎么把程序跑起来,而是教你怎么让程序跑起来之后别人读不走、抄不动、改不了。
这篇笔记适合三类人:刚把 F103 最小系统焊好、准备做产品的硬件工程师;在现有项目里被抄板困扰、想补一道防护的嵌入式开发;以及正在做毕业设计、需要写出“程序加密保护”章节的学生。下面的内容全部围绕 STM32F103 的 Flash 选项字节、唯一 ID 绑定和代码校验展开,每一步都跟着标准库 v3.5.0 的工程模板走,直接在现有工程里就能改。
先说结论:F103 的程序保护不是靠“隐藏代码”,而是靠芯片自带的读保护(RDP)、写保护(WRP)和 96 位唯一 ID 这三样东西组合起来。单独用哪一样都有短板,组合起来才是一道能抗住常见抄板手段的防线。
2. 加密保护的事实基础:F103 的 Flash 结构与读保护原理
2.1 为什么 hex 文件能被人轻易读走
STM32F103 的 Flash 容量从 32KB 到 512KB 不等,地址从 0x08000000 开始。正常情况下,CPU 通过 I-Code 总线读 Flash,调试器通过 SWD 接口也可以读。问题就在这里:STM32 的 Cortex-M3 内核默认不对调试接口做任何拦截,只要 SWD 引脚没有禁用,外部调试器就能直接访问整个 Flash 地址空间。所谓“加密”,本质上就是给这扇门加锁。
F103 在 Flash 模块里专门留了一块区域叫选项字节(Option Bytes),地址在 0x1FFFF800,里面放着两个最关键的配置:RDP(Read Protection,读保护)和 WRP(Write Protection,写保护)。RDP 控制的是“允不允许外部从调试口读 Flash”,WRP 控制的是“允不允许外部向指定页写 Flash”。这俩不是软件逻辑,是芯片硬件逻辑,一旦设置生效,即使你把程序里的所有变量都藏起来也没用,因为硬件层面就把门锁了。
这里要纠正一个常见误区:很多人以为“程序里加个密码、上位机输入正确才运行”就是加密保护。这确实是一种保护方式,但属于应用层保护,破解者通过反汇编找到判断分支,直接跳过去就完了。RDP 属于芯片级保护,它锁的是物理读取通道,破解难度不在一个量级。好的方案是 RDP + UID 绑定 + 密码校验三层叠加。
2.2 读保护的三级状态:Level 0、Level 1、Level 2
RDP 不是一个开关,是三个级别,对应选项字节里 RDP 位域的不同取值。这块必须记清楚,因为级别之间的切换有不可逆操作:
| 级别 | 取值(低字节) | 调试接口访问 | 从 RAM 启动 | 从系统存储器(Bootloader)启动 | 说明 |
|---|---|---|---|---|---|
| Level 0 | 0xA5 | 完全开放 | 允许 | 允许 | 出厂默认状态,程序随便读 |
| Level 1 | 0xBB 以外的值 | 禁止读 Flash | 允许 | 允许 | 最常见的保护状态,但可以降回 Level 0 |
| Level 2 | 0xCC | 永久禁止 | 禁止 | 禁止 | 不可逆,降不回去,慎用 |
Level 1 是大多数产品的选择。它允许程序正常执行,允许调试接口连接(可以查看内核寄存器、RAM、外设状态),但不允许读取 Flash 内容。也就是说,你插上 ST-Link 还是能暂停程序、看变量、设断点,但读 Flash 会拿到全 0xFF 或者全 0x00。对开发者调试友好,对抄板者致命。
但 Level 1 有个漏洞:它允许系统存储器启动,也就是 Boot0 拉高、复位后进入串口下载模式,这时可以通过串口把 Flash 里的内容读出来吗?答案是——在 Level 1 下,系统存储器模式同样被阻止读主 Flash。所以串口读这条路也被堵死了。
Level 2 是最高保护,一旦设置就永久生效,调试接口彻底废掉,后续想恢复只能换芯片。产品量产一般不用,除非你确定永远不需要返工。教学实验里通常演示到 Level 1 就够了,踩不了收不回来的坑。
2.3 写保护(WRP)与代码区锁定
RDP 防的是“读”,WRP 防的是“写”。WRP 可以按页锁定,F103 的 Flash 每页大小是 1KB(大容量型号)或 2KB(中容量型号),你可以把存放 Bootloader 的页锁死,防止程序运行时不小心把 boot 区覆盖;也可以把存放加密密钥、校准参数的页锁死,防止调试器直接改数据。
WRP 的典型应用场景是这样的:Bootloader 放在 0x08000000 开始的几页,应用程序从 0x08002000 开始。用户通过 IAP 升级时只会写应用区,写不到 Bootloader 区;但如果不做 WRP,调试器或者一个跑飞的程序照样能往 Bootloader 区写。把前几页 WRP 一锁,即使程序跑飞,也不会毁掉启动代码。
WRP 的设置同样写在选项字节里,它和 RDP 不冲突,可以同时开启。后面第三、四章里我会把代码写全,这里先记住一个概念:RDP 管读,WRP 管写,两者是正交的两道锁。
2.4 唯一 ID:一块芯片一个身份证
STM32F103 在 0x1FFFF7E8 地址处藏着 96 位唯一 ID,按 3 个 32 位字存放。这个 ID 是芯片出厂时激光写入的,全球唯一,应用程序可以读取它但不能修改。利用这一点,你可以把自己的产品序列号、加密密钥和这个 ID 绑定。
常见的做法是:程序第一次烧录时读取 UID,用某种算法生成一串校验码,把校验码存储到 Flash 的某个用户页里;以后每次启动都重新读 UID、重新算一遍,和存储的校验码比对。等于把程序和具体芯片绑死了——抄板的人就算把整个 hex 读走,烧到新芯片上,UID 变了,程序也解不了锁。
这里要提醒一句:UID 读取地址在参考手册上写得很清楚,但不同容量的 F103 地址都是同一个(0x1FFFF7E8)。我自己踩过的坑是拿 F103C8T6 和 F103ZET6 对比,地址没变,但如果你用的是国产替代的 APM32、GD32,地址可能就不一样了,必须查对应手册,不能直接抄代码。
3. 在标准库工程里实现读保护:Option Bytes 编程的完整流程
3.1 先用一个干净的标准库 v3.5.0 工程做底子
给标题里的 rar 说句公道话:这类“例程源代码”下载之后,九成的人第一反应是解压、打开工程、点编译,然后发现要么缺芯片库,要么缺启动文件,要么编译报错一堆。我建议你跳过这些,先搭一个能跑的最小工程,然后再把加密保护的代码加进去。这样分得清楚哪些代码是工程自带的、哪些是你加的,出了问题也好排查。
一个干净的最小工程需要这些东西:标准外设库(STM32F10x_StdPeriph_Lib_V3.5.0)、启动文件(startup_stm32f10x_hd.s 或 startup_stm32f10x_md.s,取决于你的芯片容量)、系统时钟配置文件(system_stm32f10x.c)、以及一个主函数。不需要用 CubeMX,做加密保护实验用手写寄存器或标准库更直观,也更容易说清楚每一步在干什么。
打开工程后,先把目标芯片型号选对。我的习惯是拿 STM32F103C8T6(中容量)做测试,因为它便宜、好买、最小系统电路简单;然后把编译优化等级调到 -O0,方便后面单步调试。这些准备工作做完,下面开始写保护代码。
3.2 通过标准库操作选项字节:解锁、擦除、编程三步走
操作选项字节不能直接写地址,必须遵守 Flash 模块的解锁时序。标准库里已经封装好了三个函数:FLASH_Unlock()、FLASH_EraseOptionBytes()、FLASH_ProgramOptionBytes()。对应源码如下:
#include "stm32f10x_flash.h" void Set_ReadProtection_Level1(void) { /* 1. 解锁 Flash 控制寄存器 */ FLASH_Unlock(); /* 2. 检查当前读保护状态,避免重复操作 */ if (FLASH_GetReadOutProtectionStatus() == DISABLED) { /* 3. 擦除整个选项字节区域,这一步会清掉所有 Option Bytes */ FLASH_EraseOptionBytes(); /* 4. 设置读保护级别为 Level 1(取值 0xBB 以外的任意值都行) */ FLASH_ProgramOptionBytes(OB_RDP_Level_1); } /* 5. 重新锁定 Flash */ FLASH_Lock(); }这个函数是最小可运行的设置代码,逻辑上就五步:解锁 Flash -> 查状态 -> 擦选项字节 -> 写 RDP -> 锁 Flash。但有三个细节必须说清楚。
第一,FLASH_ProgramOptionBytes的枚举值定义在stm32f10x_flash.h里,OB_RDP_Level_1对应的实际数值是 0xBB 被写入到 RDP 字节的高位和低位。你不需要记住具体数值,但要知道一旦 RDP 不为 0xA5,芯片就处于 Level 1 保护状态。第二,FLASH_EraseOptionBytes()会清掉所有选项字节配置,所以如果你之前设置了 WRP、硬件看门狗配置等,必须先擦除再重新写全,不能只写 RDP 一个。第三,这个函数一旦执行,芯片立即进入保护状态,之后你再想用 ST-Link 读 Flash 会读出一堆 0xFF,这是正常现象,不是芯片坏了。
3.3 写保护 WRP 的代码写法与页边界计算
WRP 的写入方式和 RDP 类似,只是参数从OB_RDP_Level_1换成OB_WRP_Pages系列的枚举值。标准库定义了从OB_WRP_Pages0to1到OB_WRP_Pages62to255的多个宏,用法如下:
void Set_WriteProtection(uint16_t start_page, uint16_t end_page) { uint16_t wrp_mask = 0; /* 计算页范围的掩码,标准库要求按“页对”传入 */ wrp_mask |= (1 << (start_page / 2)); wrp_mask |= (1 << (end_page / 2)); FLASH_Unlock(); if (FLASH_GetWriteProtectionStatus() == DISABLED) { FLASH_EraseOptionBytes(); /* 按页对设置写保护,每 bit 保护两页 */ FLASH_ProgramOptionBytes(OB_WRP_Pages0to1); FLASH_Lock(); } }注意这个函数里我偷了个懒,OB_WRP_Pages0to1写死了,实际工程里你需要根据芯片容量选择对应的枚举值。F103C8T6 是 64KB Flash,一共 64 页(每页 1KB),页地址从 0 到 63,对应的枚举要自己从stm32f10x_flash.h里挑。WRP 是按“页对”管理的,一个 bit 管两页,所以页数必须是偶数对齐。这个细节非常容易翻车:你想保护第 0 页和第 1 页,传页号 0 和 1,标准库认为你要保护 0~1 这一对,没问题;但如果你想保护第 1 页和第 2 页,标准库会把你这两个页计算进两个不同的页对,产生额外保护面。
WRP 的最大作用是锁住 Bootloader 区和密钥存储区。注意我上面的代码用了FLASH_GetWriteProtectionStatus()判断,这个函数标准库有,但它在读保护开启后返回值可能不准,因为状态读取本身依赖 Flash 内容。这句代码在实验环境里可以加,在产品代码里我建议直接无条件写入,避免状态误判跳过保护设置。
3.4 选项字节编程的时序与掉电风险
选项字节编程是 Flash 操作,需要时间,不能掉电。F103 的选项字节编程耗时大约几毫秒,如果在这几毫秒内掉电,选项字节可能处于半写入状态,轻则保护未生效,重则芯片异常。产品代码里应该在设置选项字节前关掉全局中断,并等待 Flash 操作完成标志。
标准做法是加while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET);等待忙标志清零,同时用一个状态标志位标记“加密保护已设置”,放在主循环开头检查。代码如下:
void Encrypt_Init(void) { /* 读取存储在 Flash 用户页的保护状态标志 */ uint8_t protect_flag = *(volatile uint8_t *)0x0800F000; if (protect_flag != 0x5A) /* 未初始化 */ { __disable_irq(); /* 关中断,防止编程过程中被打断 */ /* 检查 Flash 忙标志 */ while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET); Set_ReadProtection_Level1(); /* 写入已保护状态标志 */ *(volatile uint8_t *)0x0800F000 = 0x5A; __enable_irq(); } }这里注意一个关键问题:protect_flag我放在 0x0800F000,这是 Flash 用户区的最后一个页。F103C8T6 的 64KB Flash 地址范围是 0x08000000 到 0x0800FFFF,0x0800F000 处就是最后 4KB 区域,你可以自由使用,但要确保你的程序代码不会编译到这么靠后的地址。链接脚本里可以通过修改 scatter 文件或ld文件把这一页排除出代码区。如果程序也放在这里,写标志位时会覆盖代码,程序直接跑飞。这个细节就是“实验例程”和“产品级代码”的区别:实验例程随便放个地址就能演示,产品代码必须规划内存布局。
3.5 设置完读保护后第一次上电,用这几招确认生效
写完保护代码,烧录之后怎么确认生效?不要拿肉眼看,用调试器实测。三步确认法:
第一,用 ST-Link 连接芯片,打开 STM32CubeProgrammer,在 Option Bytes 页面看到 RDP 一栏显示 Level 1,说明读保护已经写入成功。第二,切换到 Memory 视图,点击读 0x08000000,结果应该全是 0xFF 或者读取失败——这就说明外部读通道被堵死了。第三,按复位键,板子跑起来看现象(LED 闪、串口打印等),确认程序在保护状态下正常执行。
这里有个容易误判的点:有些 ST-Link 版本在 Level 1 状态下连接时会报错“Error: No STM32 target found”,不是芯片坏了,也不是连接线松了,而是调试器纠结于尝试读取被保护芯片的 Flash。解决方案是先在 CubeProgrammer 里执行“Connect under reset”(复位下连接),再去看选项字节状态。
4. 用唯一 ID 做程序与芯片的绑定:把代码变成“一机一密”
4.1 读出 96 位 UID:直接指针操作,不依赖任何库函数
STM32F103 的 UID 不需要初始化外设,直接读取固定地址就行。三个 32 位寄存器分布在连续地址上,用指针一口气读出来。标准库没有内置 UID 读取函数,自己写最干净:
typedef struct { uint32_t uid0; uint32_t uid1; uint32_t uid2; } ChipUniqueID; ChipUniqueID Get_ChipUID(void) { ChipUniqueID uid; uid.uid0 = *(volatile uint32_t *)0x1FFFF7E8; uid.uid1 = *(volatile uint32_t *)0x1FFFF7EC; uid.uid2 = *(volatile uint32_t *)0x1FFFF7F0; return uid; }这段代码的核心就是三次指针解引用,没有任何外设依赖,可以在硬件初始化之前调用。注意别把地址写错:0x1FFFF7E8 是 UID 的起始地址,三个字依次往上排,有些人抄代码只抄第一句,后面两个地址写成 0x1FFFF7EA、0x1FFFF7EC,完全错位——UID 是 32 位对齐的,不能按字节递增。
拿到 UID 之后做什么?最直接的做法是把 UID 打印出来,看看是不是每块芯片都不同。我这里有一段调试代码,通过串口把 UID 发到上位机:
void UID_Print(ChipUniqueID uid) { char buf[64]; sprintf(buf, "UID: %08X %08X %08X\r\n", uid.uid0, uid.uid1, uid.uid2); UART_SendString(buf); }UART_SendString是你工程里已有的串口发送函数,没有的话自己写个轮询发送就行。F103 的串口在这个实验里还有个用处:很多抄板者会通过串口打印的信息推测程序逻辑,所以产品代码里尽量不要把 UID 明文打出来,演示时可以,量产产品慎用。
4.2 UID 校验的两种策略:一次性绑定与启动校验
拿到 UID 只是第一步,关键是怎么拿它做校验。我总结两种常用策略,你按产品阶段取舍。
第一种,一次性绑定。第一次上电时读取 UID,通过某个哈希算法生成 32 位校验值,写入 Flash 用户页。之后每次启动都重新计算 UID 校验值,和存储值比对。匹配则正常执行,不匹配则进入错误状态。这种策略的核心是“校验值必须由 UID 计算出来,且不能从代码里直接读出明文”。
第二种,编译期绑定。你把某块芯片的 UID 读出来,在编译前手动写进代码里的一个全局常量数组,程序运行时把芯片实际 UID 和常量数组比对。这种策略适合固定硬件平台的产品,换芯片必须重新编译。缺点是产线流程复杂,每块板子都要单独烧录对应程序,不适合批量。
我更推荐第一种。写一个简单的 XOR 散列作为示例,不引入复杂的加密库:
uint32_t UID_CalculateChecksum(ChipUniqueID uid) { uint32_t sum = uid.uid0 ^ uid.uid1 ^ uid.uid2; /* 加一点“搅动”,避免三个字段直接异或结果太简单 */ sum = (sum >> 16) ^ (sum & 0xFFFF); sum = sum * 2654435761u; /* KNUTH 乘数,做一个轻度混淆 */ sum = sum ^ (sum >> 16); return sum; }这里2654435761u是黄金比例哈希里常用的魔数,作用是让校验值分布更均匀。这个算法不是密码学级别的安全,但足以对付抄板的人。真正的产品级方案建议用 AES-128 或者 CRC32 + 随机盐,后面第六章再展开。
4.3 校验失败时的处理策略:别直接卡死,要做“降级”
一个人拿到抄出来的 hex,烧到新芯片上,UID 校验会失败。这时候你的程序怎么表现,直接决定对方能不能爆破解锁。
最差的处理方式:校验失败后程序什么都不跑,死等。这种一眼就能看出问题,抄板者会去查是哪里卡住了。比较好的方式是“假运行”——程序正常初始化外设、正常打印日志,但核心的逻辑不执行,或者执行一个假的数据表,让对方花大量时间去分析为什么功能异常。这种“降级运行”的迷惑性远比直接卡死强。
实现假运行其实不难,把核心计算函数换成空函数或者返回错误码就行。比如你的设备是电机控制器,校验失败后 PWM 正常初始化但不输出占空比变化;设备屏幕上显示“系统正常”,但按键全部无响应。这比蓝屏死机让抄板者头疼得多。
下面是一个降级运行的骨架:
void Application_Run(void) { uint32_t stored_crc; uint32_t calc_crc; stored_crc = *(volatile uint32_t *)0x0800F004; calc_crc = UID_CalculateChecksum(Get_ChipUID()); if (stored_crc == calc_crc) { /* 校验通过:正常业务逻辑 */ Motor_Start(); Sensor_Init(); } else { /* 校验失败:假装启动,实际上进入降级逻辑 */ Motor_Start(); // 只初始化,不输出 Display_Normal(); // 显示正常界面 while (1) { /* 空循环,什么都不做,或喂个看门狗 */ IWDG_ReloadCounter(); } } }这个策略的核心在于:失败路径和成功路径的外设初始化必须看起来一致,不能出现串口打印“UID ERROR”之类的破绽。降级逻辑怎么写,取决于你的产品功能,自己扩展就行。
4.4 把 UID 校验代码放到编译后的 .text 区,防止被整体搬移
还有一个高级技巧:抄板者如果发现你的程序绑了 UID,他可能会在反汇编里找到校验函数的入口,然后直接把跳转改成无条件的“通过”。为了对抗这种 patch,有两个实用手段。
第一个,把校验散落在多个位置。不要在启动时集中校验一次,而是在主循环、中断服务函数、定时器回调里各放一次校验点,任何一个校验点失败都触发降级。这样 patch 的工作量从改一个跳转变成查无数个。
第二个,校验点不要用函数调用,改成内联方式,编译后没有 CALL/RET 指令,反汇编时找不到清晰的函数边界。内联的写法就是直接把指针读取和异或运算写在你业务代码中间。缺点是代码可读性下降,但既然做加密保护,这点牺牲是值得的。
最后,一定要记住:UID 校验的强度不在于算法多复杂,而在于校验点分散、失败行为迷惑、代码边界模糊。三者配合,才能真正让抄板者觉得“这块板子不值得抄”。
5. 加密保护实验的 5 个高频坑:现象、原因与解决
5.1 坑一:设置读保护后 ST-Link 无法连接,右上角报错
现象:烧录完设置 RDP 的代码,点 ST-Link 连接,提示“Error: No STM32 target found”或者“Cannot access the target”。第一反应是芯片锁死了,马上想用全擦除恢复。
原因:Level 1 读保护下,调试器默认的连接流程(读 Flash 选项字节)会被拒绝,但内核本身还活着,只是不能通过常规握手读取内容。这不是芯片损坏,是被保护机制挡在了门外。
解决:在 STM32CubeProgrammer 里选“Connect under reset”,先按住复位再点连接;如果还不行,把 ST-Link 的接线从 4 线改成 3 线(只接 SWDIO、SWCLK、GND),降低干扰。连接成功后,在 Option Bytes 页面把 RDP 改成 Level 0,执行后会触发全片擦除,程序就没了。所以量产阶段踩这个坑会丢程序,我一般先在实验板上验证好所有代码再上产线。
5.2 坑二:把 RDP 设成 Level 2,芯片永久变砖
现象:有人做实验时把 RDP 枚举值改成了OB_RDP_Level_2,烧录后芯片再也不能通过任何方式连接,数据、程序全部无法找回,连 BOOT0 串口下载都失效。
原因:Level 2 的硬件设计就是“永久保护”,没有任何恢复路径。这是个单向门,进去就出不来。
解决:实验阶段不要碰 Level 2。真的不小心设了,唯一办法是换芯片。别试图用高压脉冲、紫外灯之类的方法去擦除,F103 用的是 Flash 工艺,没有这种民用恢复路子。这个坑我见过不止一个人踩,都是因为“想看看 Level 2 是啥效果”。
5.3 坑三:WRP 配置后程序运行正常,但第二次烧录直接失败
现象:代码里同时设置了 RDP Level 1 和 WRP 保护某些页,运行没问题。第二次用 ST-Link 烧录新版本程序时,提示“Flash download failed”,而且怎么擦除都报错。
原因:WRP 锁住的页不允许任何外部写入,包括调试器、包括 IAP 程序。你想更新程序,却发现锁住的页里有新代码,烧录就失败。
解决:更新程序前必须先把 WRP 关掉,烧录完成后再重新开启。这引出一个产品设计思路:Bootloader 在跳转 App 前调用一个“临时解除 WRP”函数,App 收到升级命令后先解除保护再跳回 Bootloader。如果 WRP 锁的是 Bootloader 区,那 App 自己无法解锁,必须预留一个“升级允许”标志。这个细节直接决定你的产品后期维护成本。
5.4 坑四:串口中断接收掉数据包,加密校验被误判成“芯片不对”
现象:程序里加了 UID 校验后,某块板子偶尔启动进不了正常逻辑,重新上电又好了。串口打印显示复位原因是外部复位,而不是上电复位。
原因:UID 校验没问题,问题出在你的校验时机太早,而程序里用了串口中断接收外设数据。初始化阶段串口中断乱入,抢占了校验代码的时机;或者更常见的,校验用的临时变量被定义在栈上,而栈初始值不可控,导致校验值不稳定。
解决:把 UID 校验放到系统时钟初始化之后、外设中断使能之前。另外,校验值一定要定义为volatile局部变量,防止编译器优化后从寄存器直接取值。真正常见的掉数据包问题,多半是串口中断没有清理RXNE标志,和加密保护本身无关,但两个问题撞在一起就很难排查。先解决串口,再谈加密。
5.5 坑五:产品返修时,自己人也读不了固件
现象:产品按 Level 1 保护发出去了,客户退回来一块故障板,你的工程师插上 ST-Link 发现读不了 Flash,想定位 bug 无从下手。寄回工厂,返修人员也看不懂程序状态。
原因:Level 1 设计时只考虑了防外部抄板,没考虑开发团队自身的维护需求。这是最容易被忽视的一个坑。
解决:产品阶段建议把 RDP 设置为 Level 1 但保留“开机后 30 秒内允许解除保护”的机制。具体做法:开机后读一个 GPIO 状态,如果检测到特定跳线(比如 PA5 接地),则运行一段临时代码把 RDP 降为 Level 0,再复位进正常程序。这样产线和售后在需要时能主动解除保护,而终端用户不知道这个机制的存在。这个“后门”要藏好,不能在源码注释里写“跳线恢复出厂”,最好做在 Bootloader 里,编译时不带调试符号。
以上五个坑,前三个是芯片级特性,后两个是工程实践。实验例程代码可以不管这些,产品开发一个都躲不掉。
6. 验证你的加密保护是否扛打:三板斧实测与补强手段
6.1 第一板斧:用 ST-Link 实际读一次,看能不能拿到有效 hex
验证保护有没有生效,最直接的办法就是模拟抄板者。把程序烧录进板子,设置好 RDP Level 1,断电再上电,然后用 ST-Link 连接,点“Read”按钮,尝试保存整个 Flash。正確结果是:读出来的文件全是 0xFF 或者 0x00,文件里找不到你的程序特征字符串(比如固件版本号、Guid 标识)。如果还能读到有效数据,说明保护根本没写上,或者你烧录时选项字节被调试器覆盖了。
这个测试有一个注意点:ST-Link 在连不上时会自动擦除芯片。所以在测试前,先把程序完整备份好。我一般在工程里做一个“恢复模式”,按住某个按键上电后跳过加密保护设置代码,方便重复测试。
6.2 第二板斧:换一颗同型号芯片,看程序能不能跑
把保护好的板子上的 hex 文件烧录到另一颗同型号芯片上。如果只开了 RDP,这个测试会“成功”——程序正常运行,因为 UID 校验没加。如果加了 UID 校验,程序会走降级逻辑,表现异常。这个测试能验证你的 UID 绑定是否真的生效。
这里有个数值细节:UID 校验值计算时要注意字节序。F103 是小端模式,uid0的低字节存在低地址。如果你的上位机工具(比如你写了个 Python 脚本算校验值)用的是大端字节序,两边算出来的校验值永远对不上。我自己写过一版校验逻辑,在板子上是对的,但在 Python 里重新实现时字节序搞反了,排查了半小时才发现是struct.unpack('<I')和'>I'的区别。复制代码时一定检查大小端。
6.3 第三板斧:对 hex 文件做字符串扫描,找“危险”特征
这是一个反直觉但非常有效的自检手段。你把 hex 文件转成 bin,用strings命令或者十六进制编辑器扫一遍,看看有没有这些内容:校验分支附近的连续 FF、明显的CMP+BEQ跳转序列、以及你在代码里写的“UID ERROR”提示字符串。任何能让逆向者快速定位校验逻辑的特征,都要从产品代码里删掉。
我见过一个真实案例:某团队在程序里写了uart_printf("UID check failed!\r\n"),抄板者拿到 hex,用字符串扫描直接找到这行字,顺藤摸瓜找到校验函数,把条件跳转改成无条件跳转,整个加密保护形同虚设。所以我的习惯是:凡是要保密的代码,不用字符串、不用明显的分支结构、编译时去掉调试信息。这比堆算法更值钱。
另外,.hex 和 .bin 文件的差异也值得一提:hex 是文本格式,用记事本就能打开,里面地址信息是明文的;bin 是纯二进制,更适合做产品发布。调试时用 hex,发布时把 bin 做一次 XOR 混淆再加密进 Flash,可以在一定程度上迷惑低级逆向者。
6.4 补强:把 CRC 校验加进 Bootloader,防止固件被篡改
如果你有 Bootloader,可以再加一道防线:每次从 Bootloader 跳转到 App 前,用硬件 CRC 模块计算 App 区域的 CRC32,对比存储在固定地址的期望值。F103 自带 CRC 外设,不需要软件查表,速度很快。这个机制不能防读,但能防改——抄板者把某段代码 patch 了,CRC 对不上,Bootloader 拒绝启动。
CRC 计算代码短小精悍:
uint32_t CRC_Calc(uint32_t *data, uint32_t len) { uint32_t i; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_CRC, ENABLE); CRC_ResetDR(); for (i = 0; i < len; i++) { CRC_SetDataRegister(data[i]); } return CRC_GetCRC(); }注意 F103 的 CRC 外设是 32 位数据寄存器,逐字写入后最终结果在CRC_DR里读出。计算的 CRC32 是标准以太网多项式(0x04C11DB7),不要和软件软算的 CRC32-MPEG2 混用。还有,CRC 计算时需要把 App 区的首地址强制转成uint32_t *,这意味着 App 区必须按 4 字节对齐,链接脚本里要把 App 起始地址约束好。我一般把对齐改成ALIGN(4096),既满足 CRC 逐字读取,又给 Flash 扇区擦除留了余量。
最后说个个人习惯:每次给产品升级加密保护时,我会做一个双人复核——一个人写代码,另一个人用串口监视器观察启动流程,确认降级逻辑不会误伤正常用户。自己写的保护代码自己测,容易陷入“我肯定写对了”的错觉。现在很多开源社区也提供基于 F103 的启动校验框架,但我更推荐从这几板斧自己测起。加密保护这件事,代码是死的,而抄板的人是活的;你的防护不需要做到军用级别,只需要做到让抄板者觉得“这块板子不值得花时间”。
希望这篇文章里从 RDP 到 UID 再到降级运行的整体思路,能帮你把“程序加密保护”真正落到自己的 F103 工程里。
本文还有配套的精品资源,点击获取