简介:一套基于STM32F103单片机的程序加密保护实验例程源码包,面向嵌入式开发初学者与进阶者,围绕程序加密与系统安全展开,结合DS1302实时时钟(RTC)、HS0038红外遥控、SysTick系统定时器等外设实验,展示软件加密、固件保护与安全启动的整体思路。压缩包共122个文件,以58个.h头文件与56个.c源文件为主体,另含UVprojx/UVoptx工程配置、Hex烧录文件、启动汇编及批处理脚本等,整体约395KB,结构清晰,便于在Keil环境中直接打开、编译与下载。已有181人浏览学习。通过分析和修改源码,可掌握在STM32中集成AES/DES等加密算法、定制Bootloader加载校验流程,以及利用RTC生成时间相关密钥、处理红外遥控信号、实现SysTick精确延时等方法;内容预览中涉及的STM32标准外设驱动(如Flash、RCC、ADC、I2C等)覆盖了较全面的底层接口,也为扩展学习提供了基础,适合作为课程设计或安全通信项目的参考实例。
1. 程序加密保护不是烧个读保护就完事:这份例程包到底教了什么
打样送样是每个做单片机产品的人都绕不过去的坎,最怕的不是代码写得烂,而是固件被人直接从 Flash 里读走,半个月后市场上多了个一模一样的方案。STM32F103 的程序加密保护,常规做法是在下载器上勾一个 Read Out Protection,但真正能立住的保护通常由三件事组成:读保护等级、芯片唯一 ID 软校验、校验失败后的自锁行为。这份《基于STM32f103单片机程序加密保护实验软件例程源代码》把这三件事写成可编译的完整工程,还带上了 SysTick、DS1302 实时时钟、HS0038 红外遥控三个配套实验。适合正在给设备做防拷贝、想从标准库入门 STM32 的工程师,也适合拿开发板练外设的新手照着一步步烧。
2. 认识例程包:目录结构、加密保护原理与选型理由
2.1 解压后你会看到什么:标准库工程的目录骨架
拿到 .rar 先别急着打开 Keil,先把目录结构过一遍。这套例程用的是 STM32 标准外设库 3.5.0,不是寄存器版,也不是后来 STM32Cube 的 HAL 库。很多人在网上跟着视频学到的是寄存器直接操作,到了这套代码里会发现 GPIO 配置全是GPIO_Init结构体,别慌,这不是两套东西,只是封装层次不同。
目录通常长这样:
| 目录/文件 | 职责 |
|---|---|
| Project/xxx.uvprojx | Keil MDK 工程入口 |
| User/main.c | 主逻辑:加密校验流程、外设初始化 |
| User/stm32f10x_it.c | 中断服务函数集中地 |
| SYSTEM/sys.c / delay.c | SysTick 延时与系统节拍 |
| SYSTEM/usart.c | 串口打印,用于调试输出 |
| Hardware/ds1302.c/h | DS1302 时间芯片驱动 |
| Hardware/hs0038.c/h | HS0038 红外接收解码驱动 |
| Core/ | core_cm3.c 等内核文件 |
| STM32F10x_FWLib/ | 标准外设库源码 |
这个骨架是网上流传最广的标准库工程模板,核心思路是按外设拆文件:System 放公共延时,Hardware 放板载芯片驱动,主循环只负责调度。拆包后如果发现文件结构略有出入,比如有人把 delay 放进 System,或者把红外解码直接写在 main.c,不影响阅读,重点抓 main.c 和三个硬件驱动文件。
改动工程的第一步是确认芯片宏定义和启动文件匹配。中容量的 C8T6 要配置STM32F10X_MD,用startup_stm32f10x_md.s;如果你的是 ZET6 大容量,宏要改STM32F10X_HD,启动文件也要换startup_stm32f10x_hd.s。这个不匹配最典型的翻车现象是编译能过,烧进去却跑飞,因为向量表偏移不对。
2.2 第一层加密:选项字节与读保护等级
STM32F103 在 Flash 里有一块独立的选项字节区域,地址0x1FFFF800,其中低 8 位就是 RDP。RDP 分三级:
| 等级 | 含义 | 调试接口访问 |
|---|---|---|
| Level 0 | 无读保护 | 可随意读 Flash、RAM |
| Level 1 | 读保护开启 | 不能读 Flash,只能读 RAM |
| Level 2 | 永久保护,不可回退 | 彻底锁死调试口 |
实验例程里用的是 Level 1,因为 Level 2 一旦烧进去,厂家调试工具都救不回来,适合绝对不允许被读的产品,开发阶段用它等于自杀。设置 Level 1 的代码很简单:
/* 开启 Flash 读保护,调用一次即可 */ void enable_flash_read_protect(void) { FLASH_Unlock(); /* 必须先解锁 Flash 控制器 */ FLASH_ReadOutProtection(ENABLE); /* 将 RDP 提到 Level 1 */ FLASH_Lock(); /* 上锁,防止后续误写 */ }逻辑说明:FLASH_Unlock内部会往 Flash 密钥寄存器写两个固定值,等于给控制区开了写权限;FLASH_ReadOutProtection修改的是选项字节而不是用户代码区;FLASH_Lock重新上锁。标准库内部对选项字节也会做解锁,外部再调一次是为了流程清晰,重复调用不会报错。参数说明:ENABLE对应标准库里的FunctionalState枚举,传DISABLE就是关闭读保护,但关闭行为伴随整片擦除,你自己的代码也会一起没掉。
顺着这个函数往下说,必须在程序上电早期调用它,最好在 main 最开始就执行。因为读保护一旦生效,调试器不能通过 SWD 读 Flash,意味着下一次想烧录程序,必须先整片擦除。这是设计保护逻辑时最需要想清楚的一个决策:不要在开发阶段提前打开 RDP,把这一步留到所有功能测试通过之后。
2.3 第二层加密:芯片唯一 ID 软校验
每一颗 STM32F103 出厂都有一个 96 位唯一 ID,地址固定在0x1FFFF7E8,F1 全系列都是这个位置。把这 96 位读出来,和编译时写进固件的期望值比对,就能做到“固件只认一颗芯片”。实验例程的 UID 校验核心代码是这样:
#define UID_BASE 0x1FFFF7E8 uint32_t g_aUid[3]; /* 拆成三个 32 位存放 */ /* 读取并校验芯片唯一 ID */ uint8_t check_uid(void) { uint32_t *pUid = (uint32_t *)UID_BASE; g_aUid[0] = pUid[0]; g_aUid[1] = pUid[1]; g_aUid[2] = pUid[2]; /* 这里的三个常量必须换成你自己芯片实际读出的值 */ if (g_aUid[0] != 0x12345678u || g_aUid[1] != 0x87654321u || g_aUid[2] != 0xA5C3F001u) { return 0; /* 校验失败 */ } return 1; /* 校验通过 */ }逻辑说明:pUid是常量指针强转,直接映射到芯片信息区的物理地址,读它不需要解锁任何外设,上电就能读。校验失败返回 0,主函数拿到 0 之后就进入自锁分支:要么while(1)空转,要么把关键外设全部禁用,再配合看门狗反复复位,让程序没法正常跑业务流程。
参数说明:三个常量是占位符,必须改成实际芯片的 UID。获取自己芯片 UID 的最直接办法:先用串口把这 96 位打出来,或者用调试器内存窗口看0x1FFFF7E8地址。实验包里的比较逻辑有时会做一层变形再比较,比如和某个魔数异或,或者按位交换后再比对,原理都是同一个:把明文 UID 经过变换后存进固件,运行时变换后再比。
2.4 实验级为什么选 RDP + UID 双保险
假如只有 UID 软校验,固件本身可被任何调试器读出来,攻击者把几个 if 语句 Patch 掉就能绕过,所以“防读”必须由 RDP 承担。反过来,仅有 RDP 的板子能防读,但固件说白了还是能被人整片搬去另一块空芯片跑,因为 STM32 没有安全启动校验。双保险的思路是:
| 保护层次 | 防什么 | 失效代价 |
|---|---|---|
| RDP Level 1 | 防止 SWD/JTAG 直接读 Flash | 回退 Level 0 必须整片擦除 |
| UID 软校验 | 防止固件被搬到另外的芯片 | 换芯片必须重写期望值 |
| 自锁分支 | 防止校验失败后程序继续跑 | 无,纯软件行为 |
这套组合在国内的开发板、小家电、控制器产品里很常见,成本几乎为零,不需要额外加密芯片,所以实验例程把它作为主实验。至于更硬核的方案,比如外挂加密芯片、固件分段加密、运行时解密,那是产品量产后预算充足的玩法,这套例程没涉及,也不建议新手一开始就碰。
3. 从 RAR 到跑起来:Keil 工程导入、烧录与最小系统注意事项
3.1 工程导入与标准库版本匹配
第一次打开工程,先确认 Keil 的 Device Pack 装没装。MDK 5 版本如果不装 STM32F1 的 DFP 包,打开工程会提示缺少器件选项,编译更是直接报错。打开方式是菜单 Pack Installer 里搜 STM32F1,把Keil::STM32F10x_DFP装好。这个工程是标准库 3.5.0,代码里大量使用外设库 API,别拿 CubeMX 生成的工程往里面套,二者的外设初始化思路完全不一致。
接下来编译前逐个检查三个位置:
- 器件型号:Options for Target → Device,C8T6 选 STM32F103C8,ZET6 选 STM32F103ZE。选错型号直接导致 Flash 容量判断错误,烧录时会提示地址越界。
- 宏定义:Options → C/C++ → Define 填
STM32F10X_MD。C8T6 是中容量,ZET6 改成STM32F10X_HD,这个宏决定了标准库编译时包含哪些外设支持代码。 - 启动文件:工程树里 Startup 目录下,中容量用
startup_stm32f10x_md.s,大容量用 hd。换芯片后忘了换启动文件,程序大概率死在启动前。
网上很多教程用的是寄存器版或者旧工程模板,而这套例程本身就是标准库 3.5.0 的完整工程模板,如果你已经有一份稳定的工程骨架,直接把 Hardware 里的ds1302.c、hs0038.c和 System 里的delay.c拷贝过去也能用,注意 GPIO 引脚宏定义和你的板子对上就行。
3.2 烧录方式选择:SWD 与串口 ISP 的分水岭
F103 支持三种启动模式,由 BOOT0 和 BOOT1 两个引脚决定:
| BOOT0 | BOOT1 | 启动位置 | 典型用途 |
|---|---|---|---|
| 0 | X | 主 Flash 0x08000000 | 正常运行用户程序 |
| 1 | 0 | 系统存储器 0x1FFF0000 | 串口 ISP 下载 |
| 1 | 1 | SRAM 0x20000000 | 调试用 |
开发阶段最常用的是 SWD 四线下载:3.3V、GND、SWDIO、SWCLK,有时候把复位引脚也接上,防止软件死锁后调试器连不上。SWD 比 JTAG 省两根线,很多 ST-Link V2 的小板子直接印了 SWD 接口顺序,照着接就行。用 J-Link 的同学注意,F103 的 SWD 接口在 J-Flash 里选 SWD 模式,速度 4MHz 往上容易受杜邦线长度影响,我一般锁 1MHz 保证稳定。
串口 ISP 则是把 BOOT0 拉高后上电,芯片内置的 Bootloader 会通过 USART1 接收数据,PA9、PA10 分别作为 TX、RX,需要 BOOT0=1、BOOT1=0。ISP 的好处是不需要调试器,任意 USB 转串口都能刷;坏处是速度慢,而且这套例程如果已经开启了读保护,ISP 会受限。所以我的习惯是:开发阶段全部用 SWD,最后要交付只读固件前,才在代码里打开 RDP,烧完再跑一遍全功能测试。
这里提醒一个常见误解:F103 的最小系统板,也就是网上那种蓝板 C8T6,板上 BOOT0 已经接了下拉电阻,默认主 Flash 启动,别在代码烧不进的时候反复怀疑芯片坏了,先看 BOOT0 是不是被哪根杜邦线拉高了。其实更大的坑是板上没有给 SWD 单独留三针,得从排针上飞线,这种情况下优先检查 GND 是否可靠共地。
3.3 首次运行验证流程
程序烧进去之后,别急着看加密逻辑,先把基础链路验证一遍。我的顺序是这样:
- 打开串口助手,115200、8N1,复位开发板,确认能看到一行 UID 打印,说明串口驱动、时钟初始化、fputc 重映射全都正常。
- 暂时把 main 里
check_uid的返回值打印出来,确认当前芯片 UID 与固件中期望值一致。不一致的话,把打印出来的三个 32 位值更新进固件再编译烧录。 - 把 main 的分支逻辑跑通:校验通过时 LED 按 SysTick 延时闪烁;校验失败时 LED 熄灭并进入自锁循环。
- 最后再打开 RDP 选项。
这份例程的串口初始化一般长这样,粘贴到工程里直接能用:
/* USART1 初始化:PA9=TX, PA10=RX,115200-8-N-1 */ void usart1_init(void) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Mode = GPIO_Mode_AF_PP; /* TX 复用推挽输出 */ gpio.GPIO_Pin = GPIO_Pin_9; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; /* RX 浮空输入 */ gpio.GPIO_Pin = GPIO_Pin_10; GPIO_Init(GPIOA, &gpio); usart.USART_BaudRate = 115200; usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; usart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &usart); USART_Cmd(USART1, ENABLE); }逻辑说明:先打开 USART1 和 GPIOA 的时钟,再把 PA9 配成复用推挽、PA10 配成浮空输入,最后填 USART 参数结构体。fputc 重映射是让 printf 往 USART1 走,标准库版本里重写 fputc 函数加上发送寄存器等待即可。参数说明:波特率 115200 是例程默认值,如果板载晶振不是 8MHz,SystemInit会调整 SYSCLK,波特率需要按实际时钟重新计算。
4. 配套实验拆解:SysTick 延时、DS1302 实时时钟与 HS0038 红外解码
4.1 SysTick:把延时做成系统节拍,也给加密自锁当计时器
SysTick 是 Cortex-M3 内核自带的 24 位递减定时器,不需要外部引脚,也无关于 GPIO,任何 STM32F103 工程都能直接用。标准库的SystemInit已经把时钟配置到 72MHz,SysTick_Config一行就能启动 1ms 中断:
static volatile uint32_t g_uiTicks = 0; /* 初始化 SysTick,1ms 产生一次中断 */ void systick_init(void) { SysTick_Config(SystemCoreClock / 1000); } /* 中断服务函数,每次中断节拍数加一 */ void SysTick_Handler(void) { g_uiTicks++; } /* 阻塞式毫秒延时 */ void delay_ms(uint32_t uiMs) { uint32_t uiStart = g_uiTicks; while ((g_uiTicks - uiStart) < uiMs) { } }逻辑说明:SysTick_Config接受换算后的重装载值,SystemCoreClock / 1000等于 72000,这意味着计数器从 72000 递减到 0 正好 1ms,中断后自动重装。g_uiTicks必须声明成 volatile,否则编译器可能把它优化进寄存器,导致 while 循环永远跳不出去。延时函数用“结束时刻减起始时刻”的差值判断,能够避免在延时中途被更高优先级中断打扰时产生累计误差——不过 SysTick 中断本身会补上节拍,所以这个延时的精度取决于中断响应的及时性。
参数说明:需要 1us 级延时就把除数改成 1000000,但 SysTick 是 24 位计数器,72MHz 下重装值超过0xFFFFFF就溢出,微秒延时建议改用 DWT 或通用定时器。加密保护实验里,SysTick 还有一个特殊责任:校验失败后的自锁循环往往依赖 delay 函数制造一个“假运行”状态,让分析者以为程序在正常初始化,实际在等待一个永远不会到达的条件。这种小技巧在实验包里能看到,理解它就行,别把它当生产级混淆方案。
4.2 DS1302:三线接口时序的读写与 BCD 码换算
DS1302 是开发板实时时钟实验的标准答案,三线接口:CE、SCLK、IO。它跟 I2C 不同,跟 SPI 也不同,时序是自成一派的三线协议,所以例程里全部用 GPIO 模拟。
写一个字节的代码:
/* DS1302 写字节函数:LSB first,SCLK 上升沿采样 */ static void ds1302_write_byte(uint8_t uiByte) { uint8_t i; for (i = 0; i < 8; i++) { DS1302_IO = (uiByte >> i) & 0x01; /* 先发最低位 */ DS1302_SCLK = 0; DS1302_SCLK = 1; /* 上升沿,芯片锁存数据 */ } }逻辑说明:DS1302 要求低字节在前,所以循环从第 0 位开始取。SCLK 先拉低再拉高,制造一个上升沿,芯片在上升沿把 IO 上的电平锁存进去。写完 8 位后不会有额外的停止条件,靠的是 CE 电平控制整个访问窗口:CE 拉高开始通信,所有字节传完再把 CE 拉低。
读一个字节更需要注意引脚方向,因为 IO 是双向的,写完命令字节后必须立刻把 IO 从输出切回输入:
/* DS1302 读字节函数:LSB first,SCLK 下降沿输出数据 */ static uint8_t ds1302_read_byte(void) { uint8_t i, uiByte = 0; GPIO_InitTypeDef gpio; gpio.GPIO_Pin = DS1302_IO_PIN; gpio.GPIO_Mode = GPIO_Mode_IPU; /* 切到上拉输入 */ gpio.GPIO_Speed = GPIO_Speed_2MHz; GPIO_Init(DS1302_IO_PORT, &gpio); for (i = 0; i < 8; i++) { uiByte >>= 1; /* 先接收最低位 */ DS1302_SCLK = 1; DS1302_SCLK = 0; /* 下降沿,芯片输出数据 */ if (GPIO_ReadInputDataBit(DS1302_IO_PORT, DS1302_IO_PIN)) { uiByte |= 0x80; /* 把读到的 1 放到最高位 */ } } /* 用完后切回推挽输出,方便下一次写操作 */ gpio.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_Init(DS1302_IO_PORT, &gpio); return uiByte; }逻辑说明:读之前把 IO 配为内部上拉输入,避免引脚悬空读到随机值;下降沿之后芯片把数据放到 IO 上,MCU 立刻采样。uiByte先右移再按位或0x80,配合 LSB first 的顺序,最终还原出原始字节。
参数说明:DS1302 的寄存器全是 BCD 编码,秒地址是0x80、分0x82、时0x84,写命令和读命令只差最低 0 位:写是0x80系列,读是0x81系列。0x8E是写保护寄存器,想改时间必须先写0x00关掉写保护,否则数据写不进去。
说到坑,DS1302 通讯时钟在几百 kHz 量级,用 GPIO 模拟时别把 SCLK 翻转做得太快,实验板上常见问题就是初始化时序太快导致偶尔读回全0xFF。另外别想着用硬件 SPI 去驱动它,两者时序不兼容,例程里全部 GPIO 模拟就是避开这个坑。
4.3 HS0038 红外遥控:外部中断结合微秒计数解码 NEC 协议
HS0038 是集成的红外接收头,内部完成 38kHz 载波解调,输出直接是 TTL 电平。空闲时输出高电平,收到红外载波时输出低电平,所以它是反逻辑。NEC 协议一帧数据包含:9ms 引导低电平 + 4.5ms 空闲 + 32 位数据,每个位用不同的高电平时间区分 0 和 1。
解码的核心思路:外部中断检测下降沿,每次中断记录当前微秒计数,相邻两次下降沿的时间差就是协议里的电平周期。因为 SysTick 只有 1ms 分辨率,这里我用 DWT 做微秒基准:
/* DWT 微秒基准初始化,72MHz 下 CYCCNT 每 72 周期为 1us */ void dwt_us_init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } #define DWT_US() (DWT->CYCCNT / 72)/* PB15 下降沿触发,外部中断解码 NEC */ static uint32_t g_uiLastUs = 0; static uint8_t g_uiBitCnt = 0; static uint32_t g_uiCmd = 0; void EXTI15_10_IRQHandler(void) { uint32_t uiNowUs; uint32_t uiWidth; if (EXTI_GetITStatus(EXTI_Line15) == RESET) { return; } uiNowUs = DWT_US(); uiWidth = uiNowUs - g_uiLastUs; g_uiLastUs = uiNowUs; if ((uiWidth > 12000) && (uiWidth < 15000)) { /* 引导码后的间隔约 13.5ms,准备开始接收数据 */ g_uiBitCnt = 0; g_uiCmd = 0; } else if ((uiWidth > 1800) && (uiWidth < 2600)) { /* 数据位 1 的周期约 2.25ms */ g_uiCmd |= (1u << g_uiBitCnt); g_uiBitCnt++; } else if ((uiWidth > 700) && (uiWidth < 1600)) { /* 数据位 0 的周期约 1.125ms */ g_uiBitCnt++; } else { /* 干扰脉冲或重复码,重置接收状态 */ g_uiBitCnt = 0; } EXTI_ClearITPendingBit(EXTI_Line15); }逻辑说明:相邻两次下降沿的间隔约等于“上一段低电平时间 + 上一段高电平时间”。引导码是 9ms 低加 4.5ms 高,所以引导码末尾到第一位数据起始的下降沿间隔约 13.5ms;数据位 0 的周期约 1.125ms,数据位 1 约 2.25ms。按这个阈值把间隔归类,就能按 LSB first 的顺序拼出 32 位数据。重复码是 9ms 低加 2.25ms 高的特殊帧,间隔约 11.25ms,代码里被归到 else 分支直接忽略,等于是手动屏蔽了长按连发。这里为了精简省掉了命令反码校验,实验例程里通常会在g_uiBitCnt == 32时校验反码再对外抛按键值。
参数说明:阈值留了 ±400us 左右的余量,这是考虑到 HS0038 解调输出本身有抖动,以及 DWT 计数在中断抢占时可能差几个周期。PB15 的 EXTI 配置在stm32f10x_it.c里,记得在 GPIO 初始化时把引脚配成上拉输入、下降沿触发。如果板子把红外接收头接到别的引脚,中断号要跟着改,比如 PA0 就是EXTI0_IRQHandler。
开发阶段最容易踩的坑是:红外和串口都开了中断,串口收数据时被红外中断抢占导致掉包。后面避坑章会专门展开,这里先记得一个原则:红外中断只记录时间戳,别在中断里做太多数学运算,更别调用 printf。
5. 程序加密保护实验的避坑与排查:现象、原因、解决
这一章把我在这个实验套件上反复撞过的墙整理出来。每条都是三个要素:你怎么发现的、为什么发生、怎么收拾。
5.1 开了读保护后,下一次烧录直接失败
现象:程序里调用过FLASH_ReadOutProtection(ENABLE)并且已经正常运行,再次用 ST-Link 下载新固件时,Keil 报错 Flash Download failed,换了个调试器也一样。
原因:RDP 已经处于 Level 1,调试器连接时还能识别内核,但想访问 Flash 区域会被芯片拒绝。开发阶段如果没想清楚这一点,等于自己把自己锁在门外。
解决:用 ST-Link Utility 或 J-Flash 执行 Mass Erase,也就是整片擦除,这时芯片会把 RDP 回退到 Level 0,但用户代码全部清空。擦完再烧。从那以后我把开启读保护的动作放在所有开发测试通过之后,绝不提前开。
5.2 换了块芯片程序直接死机,还以为是芯片坏了
现象:同一份固件,在开发板上正常,焊到新做的板子上指示灯不闪,代码像卡死一样。
原因:UID 校验失败。开发板那颗芯片的 UID 和期望值不一样,main 里校验函数返回值是 0,程序进了自锁死循环。新板子的芯片根本没机会表达自己有问题,看起来就是黑屏。
解决:先确认自锁分支是不是while(1)空转。如果是,串口打印的 UID 是定位最快的办法:在check_uid之后、自锁之前打印三组 32 位值,和期望值对比。量产时不要手动去改固件里的 UID 常量,应该把期望值放到产线流程里自动写入,或者干脆做成“首次开机自动绑定 UID”的逻辑,不然每换一颗料都要重新编译一版固件。
5.3 DS1302 读到的秒数一直不动,写时间也无效
现象:上电后显示时间 00:00:00,秒位卡住不变化,或者用串口命令写时间后复位又变回初始值。
原因:大概率是0x8E写保护寄存器没关。DS1302 上电默认写保护使能,地址0x8E默认0x80,此时所有写操作都被芯片忽略。另一个常见原因是 IO 方向切换太晚,读命令发完后 IO 还没切输入,读回来的全是高电平。
解决:写时间前先向0x8E写入0x00关闭写保护,写完时间后顺手再打开写保护防止误改写。IO 方向切换要在命令字节发送完毕的下一个 SCLK 变化之前完成,我一般把切换动作直接放在ds1302_read_byte函数入口,读完之后再恢复输出。如果秒是奇数跳到偶数、但跳得很慢,检查晶振 32.768kHz 是否焊接正常,用示波器看晶振起振波形比改代码快得多。
5.4 红外遥控按一次键,却解码出两个不同的值
现象:测试遥控器时,轻轻按一下按键,串口打印出两次不同的地址命令值,甚至开机没人按也会偶尔收到错误帧。
原因:NEC 协议按下按键不松开时会持续发送重复码,重复码结构比普通数据帧短,如果没有单独判断,会被当成一组新数据。加上 HS0038 对电源纹波敏感,电源滤波不好时解调输出会有毛刺,产生假下降沿。
解决:硬件上在 HS0038 的 VCC 与 GND 之间放一个 4.7uF 电解电容和 100nF 陶瓷电容,越靠近接收头越好。软件上把重复码间隔 11.25ms 单独识别,不做业务处理,把上一帧的命令值原样保留。如果遥控器本身有问题,可以先用逻辑分析仪抓接收头输出波形,正常 NEC 帧引导码宽度应该在 8.8ms 到 9.5ms 之间,超过这个范围基本是遥控器或接收头硬件问题。
5.5 串口与红外中断同时开,串口接收掉数据包
现象:红外遥控功能加上之后,串口接收的数据偶尔会缺字节,数据量稍大都掉包,调试信息打印也变乱。
原因:外部中断里如果做耗时操作,或者它的抢占优先级比串口中断高,USART 接收寄存器在下一字节到来之前没被读走,溢出标志置位,新数据直接丢失。ST 的 USART 没有 FIFO,丢了就是丢了。
解决:把串口中断优先级提到红外之前,NVIC_Init里设置 PreemptionPriority 时串口给更小的数字。更彻底的方案是把红外中断函数精简到只记录时间戳,数据位拼接、阈值判断全部放到主循环里处理,中断服务函数里不允许出现延时节拍判断和位运算之外的逻辑。我个人的习惯是:接收类 IRQ 一律只做“保存计数器、清标志”,解析放 main loop,这样即使优先级配错,最坏结果也是解码慢半拍,不会在串口数据上翻车。
6. 验证加密是否真的生效:三个从实验走向量产前的实测技巧
最直接的验证方法是让 RDP 生效后,用 ST-Link Utility 做一次全片 Read。Level 1 保护下,读回来的用户区要么全0xFF,要么工具直接报“读保护使能”之类的提示。这两种结果都说明防读这一层起作用了。别指望打印出来的都是乱码——如果还能读出完整的程序二进制,说明你的 RDP 函数根本没执行,先查FLASH_ReadOutProtection是不是被条件编译或者宏定义偷偷屏蔽了。
换片测试是更接近真实抄板场景的验证:把固件烧到第二块完全一样的板子上,上电。如果程序停在自锁状态、LED 不闪、外设不工作,UID 绑定逻辑就是生效的。注意换片测试要在不开调试器的情况下做,因为开了调试器等于给了仿真环境,行为可能有假象。量产前我会专门留出三片料做这个测试,分别验证“同板重烧”“异板烧录”“读保护开启后再烧”三条路径。
如果自锁分支是while(1)空转,判断加密是否生效只需要看外设是否工作;如果像实验例程里那样加入了延时混淆,比如让 LED 正常闪三秒再熄灭,那么验证时要记录完整时间线,确认自锁不是“看起来像死机”而是一个可控的软件行为。这套例程包的价值,在于把 RDP、UID、自锁分支和三个外设实验放在同一个可编译工程里,你按上面的顺序跑通一遍,才能真正理解加密保护是怎么闭环的。我第一次做这类实验时,只验证了擦除和读回,没有验证换片,结果有一版固件把 UID 地址宏定义错了一位,烧在开发板上怎么都对,量产后换了料全部不启动,折腾了整整两天才定位到是地址写成了0x1FFFF7E0。从那以后我每次交付加密固件,都强制按“开 RDP→读回验证→换片测试”三关走一遍,三关全过才敢进产线。希望帮到你。
本文还有配套的精品资源,点击获取