简介:基于STM32F407的IAP现场升级例程,完整实现Bootloader引导程序与APP版本标记判断逻辑。IAP通过在Flash指定地址写入程序版本标志,APP启动时据此决定是否需要跳转升级,适用于需要现场固件更新、远程维护的工业控制与物联网设备。资源包含Keil MDK工程全部源码及编译输出,共910个文件,以105个C源文件和126个头文件为核心,附带编译中间文件(.o、.crf等)及工程配置,压缩包约50.67MB。APP部分采用FreeRTOS配合CAN1/CAN2总线,与IAP共同构成完整升级方案,工程结构清晰,便于学习IAP原理和实际移植。目前已有3745人学习下载,适合正在开发STM32固件升级功能的嵌入式工程师参考。
1. 现场升级不是玄学:STM32F407 IAP 要解决的三个问题
实验室里用调试器下载固件很容易,设备装到机柜、户外箱体或者产线末端后,再开盖接调试器就变得昂贵且危险。IAP 现场升级就是把这个下载动作搬到设备自己身上:运行中的 STM32F407 通过 UART、CAN 或者以太网接收到新固件,把它写进 Flash 的另一个区域,然后在重启后切换到新程序。围绕 STM32F407 做 IAP,核心其实只有三件事:一张 Bootloader/App 的 Flash 分区表、一段安全的跳转代码、一套能校验和回滚的升级协议。这里按“分区 → 协议 → 实现 → 可靠性 → 验证”的顺序展开,适合已经能驱动外设、正准备给产品增加升级能力的工程师。
2. 从启动流程看 IAP:Flash 分区、向量表偏移与跳转
2.1 STM32F407 扇区划分与 Boot/App 分区策略
STM32F407 的 1MB 版本 Flash 从 0x08000000 开始,按扇区组织。前四个扇区各 16KB,扇区 4 是 64KB,扇区 5 到 7 各 128KB。STM32CubeProgrammer 或者 Linker Script 里看到的地址并不连续等长,所以划分 Bootloader 和 App 前先要把扇区边界写死在工程文档里。
| 扇区 | 地址区间 | 容量 | 常见角色 | | S0 | 0x08000000 - 0x08003FFF | 16KB | Bootloader 起始 | | S1 | 0x08004000 - 0x08007FFF | 16KB | Bootloader | | S2 | 0x08008000 - 0x0800BFFF | 16KB | Bootloader | | S3 | 0x0800C000 - 0x0800FFFF | 16KB | Bootloader | | S4 | 0x08010000 - 0x0801FFFF | 64KB | App A 起始 | | S5 | 0x08020000 - 0x0803FFFF | 128KB | App A | | S6 | 0x08040000 - 0x0805FFFF | 128KB | App B 起始 | | S7 | 0x08060000 - 0x0807FFFF | 128KB | App B / 参数区 |
这个表是后续所有 IAP 参数的基础。Bootloader 放在 S0-S3,大小 64KB,足够容纳 HAL、串口驱动、Flash 驱动和简易协议栈。App A 从 S4 开始,使用 S4+S5 共 192KB;App B 从 S6 开始,使用 S6+S7 中的前 192KB,S7 尾部 64KB 留给升级标志或日志。为什么不把 Bootloader 压到 32KB 甚至 16KB?现场升级设备往往要兼容多种协议和故障恢复逻辑,Bootloader 超过预计容量时被迫重画分区,会牵连 App 的链接地址,所以按 Bootloader 64KB 起步比较稳妥。
2.2 向量表偏移:跳转前后最容易被忽略的环节
Cortex-M4 复位后从 0x08000000 读栈顶和复位向量,随后按向量表找中断入口。Bootloader 运行时向量表在 0x08000000,一旦跳进 App,中断拿到的是 App 向量表。如果不重定位SCB->VTOR,任何一个 UART 中断都会跳到 Bootloader 的地址,轻则功能错乱,重则 HardFault。部分工程师只在 App 的SystemInit()或main()开头写SCB->VTOR = 0x08010000,但跳转瞬间到 App 完成该赋值之间仍有一段空窗,任何挂起的中断都可能触发非法入口。我一般会把重定位放到 Bootloader 的跳转函数里,并在 App 侧也保留一次赋值,作为双保险。
STM32F407 的 VTOR 要求向量表地址按表的大小对齐,实际工程里只要把 App 起始地址对齐到 0x100 即可。使用 STM32CubeIDE 时,Linker Script 的FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 192K会把中断向量表放到新地址,代码里的宏必须与之一致。
2.3 跳转函数完整实现:先校验栈顶,再切换 MSP 和 VTOR
跳转不是把函数指针指过去那么简单。App 前 8 字节分别是初始栈顶 MSP 和复位向量 PC,Bootloader 先读出这两个值,校验它们落在合法的 SRAM 与 Flash 范围,再把 VTOR 切到 App 基地址,最后设置 MSP 并跳转。这样能避免 Flash 全 0xFF 或下载半截时直接飞掉。
#include "stm32f4xx.h" #include <stdint.h> #define APP_BASE 0x08010000U void jump_to_app(uint32_t app_base) { uint32_t app_sp = *(volatile uint32_t *)app_base; uint32_t app_pc = *(volatile uint32_t *)(app_base + 4); void (*app_reset)(void) = (void (*)(void))app_pc; /* 栈顶必须在 512KB SRAM 地址空间内 */ if ((app_sp & 0xFFF00000U) != 0x20000000U) { return; } /* 复位向量应落在 Flash 主存储区 */ if ((app_pc & 0xFFF00000U) != 0x08000000U) { return; } __disable_irq(); SCB->VTOR = app_base; __set_MSP(app_sp); app_reset(); }__disable_irq()把全局中断关掉,防止跳转过程中 SysTick 或外设中断抢先执行。__set_MSP(app_sp)必须在进入 App 栈结构前完成,复位函数第一条指令通常是从该地址弹栈。两个范围校验覆盖了最常见故障:扇区擦了一半、固件写错位置、固件包格式损坏。如果校验不通过,函数直接返回,Bootloader 还能继续等待下一次升级,不会让设备变砖。切记跳转前把 UART、DMA、定时器等已开启的中断源全部 DeInit,全局中断开关只是最后一道保险。
3. 设计升级协议:帧格式、校验与失败重传
3.1 为什么用 YMODEM,为什么也可以自己定义
YMODEM 是非常适合 STM32F407 串口升级的协议。它把固件切成长度 1024 字节的数据块,每个块带序号和 CRC16,接收端可以确认或请求重发,Bootloader 每收到一块就处理一块。YMODEM 的最大优势是上位机工具成熟,Tera Term、SecureCRT 等串口工具都内置支持,现场人员不需要额外开发发送端。但如果 IAP 的传输通道不是 UART,而是 CAN、RS485 或以太网,YMODEM 就不自然。这时用自绘私有帧反而更简单。另外,如果要约定固件版本、产品型号或加密信息,私有协议也更容易扩展,不会受 YMODEM 头部字段限制。
3.2 私有帧的最小定义:帧头、序号、长度与 CRC32
私有帧设计应留出扩展位,而不必像 YMODEM 那样兼容历史。最小推荐结构如下表,它同时覆盖开始、数据和结束三种语义,Bootloader 与上位机按同一张表解包。
| 偏移 | 字段 | 长度 | 说明 | | 0 | HEAD | 2 | 0xAA 0x55,同步字 | | 2 | CMD | 1 | 0x01:开始;0x02:数据;0x03:结束;0x10:ACK;0x11:NACK | | 3 | SEQ | 2 | 小端序号,发送方从 0 递增 | | 5 | LEN | 2 | 数据区长度,最大 1024 | | 7 | DATA | LEN | Payload,固件内容或升级参数 | | 7+LEN | CRC32 | 4 | 从 CMD 到 DATA 末尾的校验值 |
为什么用 CRC32 而不是 CRC16?STM32F407 片上带有 CRC 计算外设,计算 1KB 数据的开销远小于软件实现,且 CRC32 对全 0xFF 和连续错误的检测能力更好。CRC32 的初值建议固定为 0xFFFFFFFF,工程上不要混用 HAL 库默认初值和上位机常见的zlib.crc32初值,否则首字节就会校验失败。
3.3 接收侧状态机:按字节驱动,不卡 Flash
升级帧被串口一字节一字节地送进来。用阻塞式HAL_UART_Receive也能做,但 Bootloader 在擦除扇区时不能同时从 UART 接收,长帧会被丢弃。更稳的做法是 UART 中断或 DMA 每收到一个字节调用一次解析函数,状态机只把完整帧放到 RAM,数据校验通过后再进入 Flash 擦写。下面是一个最小状态机,用于同步帧头并收集完整帧。
void iap_rx_byte(uint8_t b) { static uint8_t pkt[1034]; static uint32_t idx = 0; static uint16_t data_len = 0; static uint8_t state = 0; if (state == 0) { if (b == 0xAA) { pkt[idx++] = b; state = 1; } return; } if (state == 1) { if (b == 0x55) { pkt[idx++] = b; state = 2; } else { idx = 0; state = 0; } return; } pkt[idx++] = b; /* 已收完 CMD/SEQ/LEN 共 7 字节,先解长度 */ if (state == 2 && idx == 7) { data_len = pkt[5] | ((uint16_t)pkt[6] << 8); if (data_len > 1024 || 7 + data_len + 4 > sizeof(pkt)) { idx = 0; state = 0; } else { state = 3; } return; } /* 状态 3:收满数据和 CRC 后进入上层处理 */ if (state == 3 && idx >= 7 + data_len + 4) { if (crc32_buf(pkt + 2, 5 + data_len) == *(uint32_t *)(pkt + 7 + data_len)) { iap_process_pkt(pkt, data_len); } idx = 0; state = 0; } }pkt缓存 1034 字节,等于 7 字节头加 1024 字节数据加 4 字节 CRC。data_len从帧头偏移 5、6 读出,先检查是否超过缓冲区,防止恶意帧把内存写穿。状态机只在完整帧到达后才调用iap_process_pkt,这一步才允许做扇区擦除和 Flash 写入。至于*(uint32_t *)(pkt + 7 + data_len)这种非对齐读取,HAL 库里没有问题,严谨一点可以用memcpy再解。
4. 落地:基于 UART 在 STM32F407 上跑通一次现场升级
4.1 最小 Bootloader 的 main 流程
IAP 的 Bootloader 不需要跑操作系统,也不初始化完整业务外设。它通常按“检查升级标志 → 进升级或跳转”两条路径执行。出厂时可以把 App 区预烧好,也可以等首包强制升级。最小 main 如下:
int main(void) { HAL_Init(); SystemClock_Config(); MX_USART2_UART_Init(); if (iap_flag_read() == IAP_MAGIC) { iap_flag_clear(); iap_download(); NVIC_SystemReset(); } jump_to_app(APP_BASE); while (1); }iap_flag_read读哪个地址?我一般读取 S7 尾部单独留出的 64KB 参数区,而不是普通 RAM 变量。Bootloader 和 App 都可能在复位后重新初始化 RAM,普通变量不可靠。标志值取固定魔数0xA55A5AA5,同时写入两次,读出来两次一致才认为有效,避免 Flash 写一半产生伪升级请求。iap_download内部按第 3 章的协议接收帧;每收到一个完整帧先校验 CRC,再写入目标地址。写入完成后把 App 起始地址和长度记录到参数区,方便 Bootloader 下一次做地址校验。
4.2 App 侧收到远程命令:置升级标志后软复位
App 已经运行在 S4-S5 区域,它不能直接擦写自己所在的 Flash 来接收新固件。即使能,升级中途断电也会把自己擦掉。规范做法是:App 只验证升级命令的合法性、写入升级魔数,然后调用NVIC_SystemReset()软复位,让 Bootloader 完成真正的固件接收和 Flash 写入。这样 App 侧代码量很小,也能独立测试。
void app_on_upgrade_command(uint8_t *cmd, uint16_t len) { if (!iap_validate_cmd(cmd, len)) { return; } flash_param_write(IAP_MAGIC, APP_VERSION_CURRENT); NVIC_SystemReset(); }远程命令在实际产品里可能来自 UART 转 4G 模块,也可能来自 RS485 总线。关键点是 App 在收到命令到复位之间,要先把当前外设断开、把未保存参数写掉。复位后 Bootloader 不会初始化 App 的外设,整个流程干净利落。若升级持续失败,Bootloader 判断超时后清除升级标志,再回到jump_to_app,设备还能继续跑旧固件。
4.3 生成升级包:objcopy 与头部 CRC
工程里生成升级包一般不在 IDE 里手动做,而是编译后跑脚本。先用 objcopy 把 ELF 转成 bin,再用 Python 脚本封装头部,否则 Bootloader 不知道固件长度和校验值。常见做法是生成“魔法字 + 版本 + 长度 + CRC32 + bin”的app_pkg.bin:
arm-none-eabi-objcopy -O binary build/app.elf build/app.bin python3 pack_fw.py build/app.bin build/app_pkg.binpack_fw.py里按与 Bootloader 一致的字段封装:
import struct, sys, zlib fw = open(sys.argv[1], 'rb').read() crc = zlib.crc32(fw) & 0xFFFFFFFF hdr = struct.pack('<HHII', 0xA55A, 0x0100, len(fw), crc) open(sys.argv[2], 'wb').write(hdr + fw)Bootloader 的iap_download收到首帧时先解头部:magic、版本、长度、CRC,然后才知道总帧数和目标地址。注意 zlib.crc32 与 STM32 硬件 CRC 输出字节序不同。我这里上位机直接按软件 CRC32 发给 Bootloader,Bootloader 也用同一个多项式计算,两端一致即可,不要混用两套 CRC 实现。
5. 可靠性坑:擦除时序、看门狗、断电与 IAP 回滚
5.1 跳转与复位前的现场清理
第 2 章的跳转函数只做了最基础的中断屏蔽,实际固件里还应在跳转前调用HAL_UART_DeInit、HAL_RCC_DeInit,并清掉 SysTick 等外设。为什么?App 启动后SystemInit很可能重新配置时钟,如果时钟外设仍由 Bootloader 事先配置,App 的 PLL 配置可能产生一段非预期频率,甚至触发异常。跳到 App 前把 RCC 恢复到复位值,能减少不确定因素。DMA 还在传输数据时跳转也会留下悬空总线事务,进入 App 后可能触发总线错误,所以现场清理顺序建议是:先关中断源,再 DeInit 外设,停 DMA,最后关全局中断。
void enter_app(void) { HAL_UART_DeInit(&huart2); HAL_DMA_DeInit(&hdma_usart2_rx); HAL_RCC_DeInit(); SysTick->CTRL = 0; __disable_irq(); SCB->VTOR = APP_BASE; __set_MSP(*(volatile uint32_t *)APP_BASE); ((void (*)(void))(*(volatile uint32_t *)(APP_BASE + 4)))(); }注意:跳转前看门狗如果已经启动,Bootloader 也必须持续喂狗,直到它把控制权交给 App 早期代码。App 侧一进 main 就要重新初始化 IWDG,否则新固件会被旧看门狗复位。
5.2 Flash 擦写期间的等待与中断
STM32F407 的 Flash 擦除和编程操作执行时,Flash 不能同时被 CPU 取指。HAL 库内部会把操作序列写入 FLASH 寄存器,然后轮询 BSY。如果你在升级中开着一个 1kHz 定时器中断,Flash 忙时中断入口无法取指,中断会一直挂起,直到 BSY 结束才继续。短时间无所谓,但扇区擦除最坏需要数秒,实时性要求高的系统会出现明显毛刺。常用缓解办法有两条:升级阶段关闭这类中断,或者把中断服务程序放到 SRAM 和 CCM RAM 中,让 CPU 不访问程序 Flash。F407 的 CCM RAM 没有数据总线,适合放紧急处理函数。
擦写代码本身也要求不访问 Flash,常见做法是把擦写循环放到 RAM 中执行。标准外设库里用FLASH_If_Write时,会通过__RAM_FUNC标识把关键函数放在 RAM。如果只用 HAL 库,只要 Bootloader 从 S0-S3 取指,而目标地址是 S4-S7,就不会擦到自己正在执行的代码。升级前还要确认FLASH_ClearError清掉上一次可能的操作错误,否则后续 Program 会一直报写保护。
5.3 双 A/B 区回滚:单 Bank 的 F407 只能软件解决
F407 没有像 F42x/F43x 那样的硬件双 Bank 切换。做 IAP 回滚只能靠多留一个 App 区域,并在分区表中建立 A/B 关系。第 2 章的分区方案可以落地为:Bootloader 放 S0-S3,AppA 放 S4-S5,AppB 放 S6-S7 的前 192KB,S7 尾部 64KB 用作参数和标志。升级时 Bootloader 把新固件写到非活跃区,写完做整体 CRC 校验,通过后修改标志并跳到新区;如果校验失败,旧区没有被碰过,Bootloader 继续跳旧区,这就是“IAP 如何备份和回滚”的常规做法。
| 回滚方案 | Flash 开销 | 断电安全性 | 工程复杂 | | 单 App+备份区 | 1 个 App + 1 个备份 | 断电时只能恢复到备份版本 | 中,需在升级前先把旧固件搬到备份区 | | 双 A/B 区 | 2 个 App 区 | 基本不依赖备份过程 | 低,标志位驱动切换 |
双 A/B 区不是没有代价。F407 扇区大小不均,两个区物理尺寸很难完全一致,实际使用以较小的 A 区 192KB 为限。固件一旦超过这个上限,只能退回“单 App+备份区”方案,升级前先备份旧固件,再擦写主区。
6. 现场升级的防呆验证:版本号、硬件 CRC 与日志
6.1 Bootloader 和 App 共用一套版本输出
如果升级以后设备黑屏,优先怀疑跳转失败而不是 App 功能错误。为了第一时间判断,Bootloader 和 App 都必须在启动阶段打印自己的版本号。定义版本宏时把目标 ID 和编译时间一起放进去,日志里出现BOOT 1.0.3 | APP 1.2.3 2025-06-01 10:00:00,说明跳转成功并且新区被激活。如果只有 BOOT 版本,大概率是 App 启动被卡住。版本信息建议固化在固件文件头,而不是只写在代码打印字符串里,否则日志和实际运行固件可能错位。
6.2 用硬件 CRC 校验新固件,别只信设备正常启动
下载完成后只做一次整个 App 区域的 CRC 校验,比依赖 App 自己运行正常更可靠。STM32F407 自带 CRC 外设,计算 192KB 的开销远小于每次串口重传。下列代码示意用 CRC 外设完成区域校验:
uint32_t iap_calc_crc(uint32_t addr, uint32_t len) { uint32_t crc; RCC->AHB1ENR |= RCC_AHB1ENR_CRCEN; CRC->CR = CRC_CR_RESET; for (uint32_t i = 0; i < len; i += 4) { CRC->DR = *(volatile uint32_t *)(addr + i); } crc = CRC->DR; RCC->AHB1ENR &= ~RCC_AHB1ENR_CRCEN; return crc; }注意读取CRC->DR得到的是位反序后的结果,上位机如果用zlib.crc32计算,需要把结果按字节翻转再写入固件头。实际产品里我通常让两端都先用软件 CRC,等协议稳定后再把 Bootloader 侧替换成硬件 CRC,减少升级包处理耗时。CRC 校验结果要和 A/B 切换标志一起写入同一个 32 位字,只有在标志与 CRC 同时匹配时才允许跳转,这是让 IAP 现场升级可靠收尾的最后一个细节。
本文还有配套的精品资源,点击获取