简介:面向嵌入式开发者,这份压缩包以STM32F429为核心,实现了基于串口IAP的在线固件升级,并引入MD5校验确保传输完整性。工程基于HAL库搭建,覆盖Flash分区、IAP引导跳转、串口接收与校验流程,适合需要学习固件远程更新机制的初中级开发者。包内共306个文件、12.61MB。94个.h头文件与82个.c源文件构成完整工程源码,另有.o/.d/.crf等编译中间文件、.axf/.hex/.map构建输出,以及.uvprojx工程配置,便于直接编译、烧录和查看链接分布。MD5算法已集成到升级流程中,可对照源码理解文件校验在真实项目中的落地方式。已有452人浏览学习。通过该工程可掌握STM32F429片内Flash擦写、引导程序与应用程序共存、UART接收分包固件、MD5摘要对比等关键知识点,尤其适合为工业设备或物联网终端设计可靠的远程升级方案。
1. IAP方案的整体设计思路
手头这个STM32F429的IAP工程,最早是从一个量产项目里抽出来的。当时产品用到了W25Q256这颗外部SPI Flash来存固件和字库,又要支持现场升级,于是就把Bootloader、App、外部Flash、MD5校验串成了一条完整的升级链路。压缩包里主要有Bootloader工程、App示例、MD5算法移植代码、以及Keil的SPI Flash下载算法工程,拿来改一改就能用在不少F429板子上。
做IAP第一个要想明白的问题是:Bootloader放哪、App放哪、升级包放哪。STM32F429内部虽然有2MB Flash,但做产品时经常要同时存Bootloader、App、字库、配置文件,内部Flash不一定够用,于是外部SPI Flash就成了很自然的选择。W25Q256有32MB容量,存几个版本的固件都绰绰富余,而且SPI接口实现起来比SD卡和USB简单得多,可靠性也足够。
1.1 为什么选STM32F429 + W25Q256
STM32F429系列主频最高能到180MHz,带FPU和LCD控制器,适合做界面和算法兼有的场景。它的内部Flash最大2MB,但对需要OTA升级的产品来说,Bootloader、App、备份区一划,空间就比较紧张。外挂W25Q256后,可以把升级固件先接收到外部Flash,校验通过再拷入内部Flash,也可以把字库、图片这类大块数据直接放外部Flash,需要时再读入内存。
W25Q256使用标准SPI协议,四线制,数据速率在几MHz到几十MHz之间,除非要边跑边读码流,否则性能完全够用。它的操作指令和W25Q64、W25Q128几乎一致,代码换芯片时基本不用动。唯一的坑是4字节地址:容量超过16MB后需要发送0xB7进入4字节地址模式,否则寻址会绕回低16MB。我在下载算法里就因为这个卡了半个下午。
1.2 Bootloader与App的内存划分
以F429的2MB Flash举例,推荐这样划分:
| 区域 | 起始地址 | 大小 | 说明 |
|---|---|---|---|
| Bootloader | 0x08000000 | 128KB | 升级入口、Flash读写、MD5校验 |
| App | 0x08020000 | 1.5MB+ | 实际业务代码 |
| 参数区 | 0x081E0000 | 32KB | 升级标记、版本号、配置 |
| 备份区 | 0x081E8000 | 剩余空间 | 可作为App备份或日志区 |
Bootloader放最前面,上电后先检查是否有升级请求,有就进入升级流程;没有就校验App区的有效性,有效则直接跳转。App工程里需要把VECT_TAB_OFFSET改成0x20000偏移量,否则中断向量表还在0x08000000,一旦发生中断就会跑飞。
1.3 固件包的组成与MD5的作用
我习惯把固件包定义一个固定头,再跟原始固件数据。头部建议用结构体,依次是魔数、固件长度、固件版本、MD5摘要、保留字段和CRC32。魔数用于快速识别固件是否合法,长度决定了要读取多少数据,MD5则是校验数据完整性的关键。
MD5在整个IAP链路里不是用来加密的,而是用来确认“收到的固件和发布时一模一样”。升级文件经过串口、网络或U盘拷贝,过程中可能丢字节、错位,或者被人为改写,MD5能在刷入Flash之前就把问题挡住。虽然MD5已经被证明不适合做安全认证,但作为完整性校验仍然足够,而且实现简单、速度快、代码占空间小。
2. MD5校验:单片机端和上位机端必须对齐
不少初学者在做IAP时,MD5校验总栽跟头。其实不是算法写错,而是单片机端算出的摘要和上位机生成的摘要对不上。这个现象在我收到的提问里出现频率相当高。
要想MD5对得上,必须搞清楚三件事:算法输入是什么、输出格式是什么、比对方式是什么。
2.1 单片机端MD5的计算方式
MD5算法本质是分块哈希,固定以512位为一个分组,最后填充数据长度到64字节的整数倍。标准C语言实现一般包含MD5_Init、MD5_Update、MD5_Final三个函数。在IAP场景中,固件可能几十KB甚至上MB,一次性读入内存再算摘要不现实,所以要用分块方式:
MD5_CTX ctx; uint8_t buf[1024]; uint32_t remaining = app_len; uint32_t offset = 0; MD5_Init(&ctx); while (remaining > 0) { uint32_t chunk = remaining > sizeof(buf) ? sizeof(buf) : remaining; spi_flash_read_data(app_storage_addr + offset, buf, chunk); MD5_Update(&ctx, buf, chunk); offset += chunk; remaining -= chunk; } uint8_t digest[16]; MD5_Final(&ctx, digest);要点是MD5_Update可以反复调用,每次喂进去的块大小不用相同,最终摘要一致。所以在计算时不要自己另外拼一个大缓冲区,直接边读边算,内存占用可以压得很低。
MD5_Final输出的digest是16字节的原始二进制数据,不是字符串。比对时建议直接memcmp,不要转成十六进制字符串再比较,否则容易出现大小写、空格、换行符的问题。
2.2 上位机MD5与嵌入式MD5的匹配
很多人习惯在PC上先算出固件的MD5值,打印成常见的32位十六进制字符串,比如e10adc3949ba59abbe56e057f20f883e。上位机这边用Python、Java、PHP算出来的都一样,因为MD5是公开标准算法。但到了单片机上比对时,有几个细节容易出问题:
- 摘要大小端顺序。标准MD5算法内部使用小端序,但部分库或工具的实现可能在输出时做了字节序转换。如果比对不过,先把16字节按字节打印成十六进制,再和上位机的32位字符串逐字节核对,看是整体翻转还是局部错位。
- 长度计算不准确。固件长度多一个字节、少一个字节,MD5直接变。这里要特别注意:计算MD5时输入的是整个固件文件/固件数据区,不要把头部里的长度字段也算进去。
- 文件本身的问题。上位机生成MD5时如果针对的是“固件.bin”,而嵌入式端把“升级包.bin”整包拿来算,摘要必不相等。先搞清楚要校验的范围是裸固件还是带头的整包,并在代码里用宏固定下来。
2.3 MD5校验的常见误区
网上经常有人问“MD5彩虹表查询能不能破解固件”“MD5加密会不会被拦截解析”。这里要澄清一下:MD5不是加密,是摘要,彩虹表适用于破解短密码,而固件动辄几十KB,用彩虹表没有任何意义。IAP里用MD5只是为了确认数据完整性,不需要把它当成安全防护手段。真要防篡改,建议在固件包里再加签名,比如ECDSA或RSA,但通常产品升级场景下MD5已经够用。
另外一个常见误区是:只校验一次MD5就够了。正规做法应该是在固件写入外部Flash前先算一遍接收数据的MD5,全部写完后再从Flash读出来算一遍,两次都通过才允许Copy到内部Flash。这样能同时排除传输过程和Flash写入过程中的数据错误。
3. W25Q256的SPI下载算法与固件存储
做这个工程时,最花时间的不是IAP跳转,而是给Keil写一个W25Q256的SPI下载算法。没有这个算法,你在MDK里点下载时只能往内部Flash写,外部Flash只能通过程序一小块一小块地烧,调试效率特别低。
所谓SPI下载算法,本质是一个被Keil加载到芯片内部RAM里运行的小程序,通过SPI接口操作外部Flash。Keil下载时会调用其中几个固定函数,达到“像操作内部Flash一样操作外部Flash”的效果。
3.1 Keil的FLM下载算法原理
打开Keil的Options for Target -> Utilities -> Settings,能看到Flash Download区域。默认列表里是STM32F4内部Flash的FLM文件。要做外部SPI Flash下载,需要先编译生成一个新的FLM文件,并通过外部加载算法添加进去。
FLM工程的结构很固定,主要实现这些函数:
| 函数 | 作用 |
|---|---|
Init | 初始化SPI和W25Q256,可以读ID做自检 |
UnInit | 释放SPI引脚状态 |
EraseChip | 整片擦除,一般量产时才用 |
EraseSector | 擦除指定4KB扇区 |
Program | 将数据写入指定地址,通常按页写入 |
这些函数会在RAM里运行,所以FLM工程有一个专门的分散加载文件,把代码放在0x20000000区域。调试时Download会先加载FLM到RAM,再执行擦写操作。要注意的是SPI时钟源和分频要在FLM里重新配置,不能依赖App代码的初始化。
3.2 W25Q256的擦写与读取要点
W25Q256最常用的指令是这几个:
0x9F读JEDEC ID,返回3字节,可以判断芯片型号和厂商。0x06写使能,每次写操作前必须发送。0x02页编程,一次最多写256字节,跨页边界会回绕,所以要手动切页。0x20扇区擦除,一次擦除4KB。0x03普通读,传输速率要求不高时用它最省事。
页编程是新手最容易踩坑的地方。你要写的起始地址如果不在页边界,长度又超过了当前页剩余空间,必须拆成多次写。我习惯封装三个函数:w25q_page_program、w25q_sector_erase、w25q_read,底层全部通过SPI收发完成。下载算法里Program会收到任意长度和任意对齐的数据块,所以在里面要循环按页处理。
还有一个非常隐蔽的点:W25Q256超过16MB后,地址需要4字节。写入0xB7进入4字节地址模式后掉电不保存,上电默认回到3字节模式。如果板子上没有外部电池给Flash供电,需要在FLM的Init和Bootloader初始化代码里每次都调用一次进入4字节地址模式的指令,不然后续读写全部错乱。
3.3 下载算法里如何配合MD5
下载算法本身不涉及MD5计算,因为PC端生成固件时已经确定了摘要。但Bootloader从外部Flash读取固件并计算MD5时,要有意识地确保读取地址和写入地址一致,否则就是拿着A处的数据算B处的摘要,结果自然不匹配。
我在工程里做了个约定:固件存在W25Q256的偏移0x100000处,头部256字节存元信息,实际固件数据从0x100100开始,而MD5只对0x100100之后的固件数据计算。上位机生成升级包时也用同样的偏移把头剥离掉后再算MD5。这个约定写进文档,换了人维护代码也不容易搞混。
4. IAP跳转后卡死HAL_Delay的完整排查
这个问题的网络热词里出现过,说明碰到的人不在少数。现象很典型:Bootloader执行完跳转,App的main函数进了,初始化串口能打印,但一调用HAL_Delay就卡死,或者程序直接跑进HardFault_Handler。
网上说法很多,什么“要关总中断”“要重定向向量表”“要把SysTick清零”,说得都对,但很少有人讲清楚为什么。
4.1 跳转流程和中断向量表的处理
先看跳转代码。很多人的跳转函数长这样:
#define APP_ADDR 0x08020000 typedef void (*pFunction)(void); void jump_to_app(void) { uint32_t msp_value = *(volatile uint32_t *)APP_ADDR; pFunction jump = (pFunction)(*(volatile uint32_t *)(APP_ADDR + 4)); __disable_irq(); HAL_RCC_DeInit(); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; SCB->VTOR = APP_ADDR; __set_MSP(msp_value); jump(); }这段代码基本没问题,但有个容易被忽略的前提:App工程必须在系统初始化早期设置中断向量表偏移。如果你用的是STM32CubeMX生成的工程,里面已经有了SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET;这行,只要把VECT_TAB_OFFSET宏改成0x20000即可。如果用的是标准库或者自己搭的启动文件,千万记得加上这一句,否则CPU发生任何中断都会去0x08000000查向量表,而那里此刻是Bootloader的代码,行为不可预测。
4.2 根因分析:HAL_Delay为什么卡死
HAL_Delay依赖SysTick定时器的中断。正常情况下,App初始化时会调用HAL_Init,里面会把SysTick配置好并使能中断。但问题出在Bootloader跳转前,SysTick可能已经处于开启状态,中断请求已经挂起。App还没来得及执行HAL_Init,SysTick中断就触发了,程序跳转到App的向量表。
这时如果App的向量表偏移已经设置好,中断服务函数会指向App的SysTick_Handler,但App的SysTick_Handler里会调HAL_IncTick,而HAL_Init都没执行,时钟状态混乱,就卡在某个while循环里。如果向量表偏移也没设置,中断直接跳到0x08000000附近取向量,大概率进HardFault。
简单说,卡死是因为跳转环境“既不干净,也不完整”。干净是指中断和外设状态没有复位,完整是指App侧向量表和时钟初始化没有准备好。
4.3 解决方法和预防措施
最稳妥的做法是:在跳转前把所有Bootloader用到的外设全部DeInit,关闭SysTick,关闭全局中断,把中断向量表设置为0,清掉Pending位,然后再跳转。跳转后App从启动文件开始执行,会重新走一遍复位向量、堆栈初始化、时钟初始化,等于“冷启动”。
预防措施方面,建议在Bootloader里写跳转函数时增加一个反汇编判断:__set_MSP后,清零通用寄存器,再调用App的Reset_Handler,而不是直接调用main。直接调用main可以跑,但不严谨,因为C运行时环境没有重新初始化。我在工程里是用jump()指向App启动文件的Reset_Handler,这样最接近断电再上电的效果。
5. 常见问题与避坑台账
最后把我在这个项目里遇到的高频问题按表格整理一下,基本可以当排查手册用。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| MD5校验失败 | 摘要字节序反转、长度字段错误 | 逐字节打印16字节原始摘要,和上位机核对 |
| 跳转后卡在HAL_Delay | SysTick中断残留 | 跳转前SysTick->CTRL=0并清Pending,App设置VTOR |
| 跳转后串口输出乱码 | 时钟配置被DeInit | App的Reset_Handler里重新初始化RCC,不要在Bootloader里对App做多余配置 |
| 外部Flash写入后校验不一致 | 页写入跨边界、4字节地址模式未开启 | 按页拆分写入,每次初始化后发0xB7 |
| Keil下载时报FLM错误 | FLM加载地址和芯片RAM不匹配 | 确认FLM分散加载文件里的RAM基地址,STM32F429一般用0x20000000 |
| 升级包可以接收但无法启动 | Bootloader未校验App完整性或魔数错误 | 跳转前先读App头部魔数和栈指针合法性 |
5.1 MD5不一致
这个排查优先级最高。我的建议是先在Bootloader里把计算出的MD5原文打印成十六进制字符串,再和上位机比较。如果只有首尾几字节能对上,优先怀疑字节序;如果完全对不上,再检查输入范围。曾经有个同事把固件长度写成了头部长度,算出来的MD5永远不对,查了一天才发现。
5.2 跳转后外设状态残留
即使跳转成功,Bootloader初始化过的GPIO、DMA、UART、定时器还会保持原有状态。App如果复用这些引脚,一定要重新初始化。有些外设不重新DeInit会产生奇怪的电流消耗,特别是DMA、PWM这类。所以Bootloader进跳转函数前,把用到的外设都调一遍HAL_XXX_DeInit,再加HAL_RCC_DeInit复位整个时钟树。
5.3 Flash擦写失败
W25Q256的擦除是按扇区来的,如果Bootloader写完固件后没有擦除干净,下个版本固件长度比之前短,末尾会残留旧数据。这虽然不影响MD5校验,但会导致固件实际运行的数据区域有垃圾数据。建议升级完成后对整片Flash做一次“读回-比对”,或者干脆每次升级前先整片擦除外部Flash。
5.4 调试技巧
调试IAP最痛苦的是程序跑飞后不知道去哪了。我给新手的建议是:在Bootloader的跳转点放一个调试IO翻转,跳转前拉高,正常跳转后App第一个初始化动作拉低。如果看到引脚一直是高电平,说明压根没跳到App;如果是低电平后卡死,就可以去查App的初始化代码。这个方法比仿真器更好用,尤其现场没有调试器的时候。
最后再分享一个小技巧:做这类IAP工程,先把MD5校验和Flash读写调通,再去做跳转和启动。很多人的误区是一上来就写跳转,结果程序能跑但不敢升级,因为不知道固件到底有没有写全。把“下载固件到外部Flash -> 计算MD5 -> 比对摘要 -> 复制到内部Flash -> 校验复位跳转”这条链路完整测通后,整个项目的稳定性会提升一大截。
本文还有配套的精品资源,点击获取