
简介在STM32F407平台上实现IAP现场升级核心思路是在Flash指定地址写入程序版本标志Bootloader启动后判断该标记决定是否跳转APP或进入升级流程。这份工程源码正是围绕这一机制展开面向需要掌握固件在线升级、Bootloader与APP分区设计的嵌入式开发者也适合有一定STM32基础、希望快速上手IAP的开发者。配套的APP部分采用FreeRTOS并集成CAN1/CAN2通信已单独上传两者配合可构成完整升级与业务运行闭环。压缩包共910个文件约50.67MB其中h头文件和c源文件是主要源码o目标文件、crf编译列表及uvoptx工程配置等保留了完整构建痕迹便于阅读逻辑和排查编译链接问题。已有3745人学习下载。读者可据此掌握Flash读写规划、程序跳转实现、版本标志管理等关键技能并可直接基于该工程修改移植减少底层重复开发工作量。1. 现场升级的第一道坎程序怎么自己写自己一台已经装进机箱、接好线缆的 STM32F407 设备某天发现算法需要更新。拆机接 JTAG 是备选方案但在工业现场更常见的诉求是“通过已有通信口把新固件发过去设备自己完成擦除和写入”。这就是 IAPIn-Application Programming其核心矛盾在于CPU 正在执行的程序能否改写自己所在的 Flash答案是不能直接改但可以通过分区和跳转间接实现——一段常驻的 Bootloader 负责接收数据并写 Flash另一段用户程序App负责跑实际业务两段代码在 Flash 中并存由前者决定何时把控制权交给后者。本文要讲的就是基于 STM32F407 如何把这套机制落地包括 Flash 分区、通信协议、跳转细节、回滚策略和现场验证方法。适合正在做 Bootloader、准备部署远程升级或维护已交付设备的工程师阅读读完能直接画原理图、写代码、上板调试。2. STM32F407 的 Flash 分区设计先想清楚谁住在哪2.1 为什么 1MB Flash 要分成四块用STM32F407 内置 1MB Flash按扇区划分前四个扇区各 16KB后面扇区 128KB。IAP 的第一件事就是决定 Bootloader、App、备份区、参数区各占多少地址。常见做法是把 Bootloader 放在 0x08000000 起始的 32KB 或 64KB 内App 紧随其后备份区放在最后。这样布局的核心原因是Bootloader 要足够小且稳定几乎不做业务逻辑只负责“接收固件 写 Flash 跳转”App 是迭代最频繁的部分给它足够空间备份区用于存放上一版可用的固件升级失败时回滚。典型分区表如下以 1MB Flash 为例区域起始地址大小说明Bootloader0x0800000064KB串口/CAN 接收固件写 FlashApp 主区0x08010000448KB业务代码跳转目标App 备份区0x08080000448KB上一版固件回滚用参数区0x080F000064KB升级标志、版本号、CRC 值参数区单独占一块是为了避免擦写 App 时把标志位一起抹掉。我一般会把升级状态是否需要进入升级模式、当前固件版本、固件 CRC 校验值放在参数区内因为这些数据在 Bootloader 和 App 中都要读写。2.2 Bootloader 怎么知道该升级还是该跳转上电后 CPU 从 0x08000000 执行 Bootloader此时它面临一个选择用户可能按下了升级按键也可能只是正常上电。判断依据通常有两种一是硬件上检测一个 GPIO 电平二是读取参数区中的“升级请求标志”。我会优先用标志位因为现场很多时候按不到按键远程下发一条“进入升级模式”的指令更可靠。但标志位有个坑如果 App 崩溃在设置标志之后、重启之前设备会一直停在 Bootloader 里所以标志位必须带超时或次数限制。2.2.1 一个可靠的上电分流判断代码#define APP_ADDR 0x08010000 #define FLAG_ADDR 0x080F0000 typedef struct { uint32_t upgradeFlag; // 0xA5A5A5A5 表示需要升级 uint32_t appVersion; // App 版本号 uint32_t appCrc; // App 固件 CRC } ParamBlock; uint8_t CheckUpgradeRequest(void) { ParamBlock *pParam (ParamBlock *)FLAG_ADDR; if (pParam-upgradeFlag 0xA5A5A5A5) { return 1; // 有升级请求留在 Bootloader } return 0; // 直接跳 App }参数区数据是上电后直接读取的所以把这个结构体指针强转到固定地址即可。注意升级标志不能只写一次Bootloader 在升级成功后要把它清零。若不清零每次上电都会重新进入 BootloaderApp 永远跑不起来。2.2.2 分区大小与扇区对齐的约束STM32F407 的 Flash 擦除是以扇区为单位扇区大小不均匀。Bootloader 占 64KB 意味着要擦 2 个 16KB 扇区加 1 个 32KB 扇区不同型号扇区划分不同F407 的扇区 0~3 为 16KB扇区 4 为 64KB后面为 128KB。分区时务必确认 App 起始地址所在的扇区边界与 App 的擦除粒度对齐否则擦除时可能误删 Bootloader。我遇到过一次把 App 定在 0x0800800032KB 处结果扇区 2 和扇区 3 被 Bootloader 占用App 写入时一擦就把 Bootloader 尾部擦掉了设备当场变砖。最后只能把 App 起始地址改为 0x0801000064KB 处对齐到 64KB 边界才稳定。3. 现场升级的通信协议串口分包、校验与应答3.1 为什么不直接传 .bin 文件要自己定协议很多人第一次做 IAP 喜欢用现成的 YModem 协议虽然省事但有两个问题一是 YModem 的包大小固定对于几十 KB 的固件传输效率偏低二是它没有断点续传和固件类型区分做多区域升级时不够灵活。我建议自己设计一个轻量分包协议帧结构清晰调试也方便。常见的做法是“帧头 帧类型 数据长度 数据 CRC16”帧头固定两个字节如 0xAA 0x55帧类型区分数据帧、结束帧、应答帧。协议格式设计如下表字节偏移字段长度说明0帧头11固定 0xAA1帧头21固定 0x552帧类型10x01 数据帧 / 0x02 结束帧 / 0x80 ACK / 0x81 NAK3数据长度2小端模式最大 5125数据N固件内容5NCRC162对帧头之后所有字节的 CRC 校验帧长限制在 512 字节以内是为了配合 STM32F407 的串口 DMA。收发缓冲区可以用环形队列DMA 接收到一帧完整数据后产生空闲中断在中断中解析。数据长度字段用 2 字节理论上支持 65535 字节一帧但没必要512 字节在 115200 波特率下约 45ms传输效率适中出错重传代价也可接受。3.1.1 帧解析函数实现#define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 #define FRAME_TYPE_DATA 0x01 #define FRAME_TYPE_END 0x02 typedef struct { uint8_t type; uint16_t len; uint8_t data[512]; uint16_t crc; } FramePacket; uint8_t ParseFrame(uint8_t *buf, uint16_t size, FramePacket *pkt) { if (size 7) return 0; // 帧头2 类型1 长度2 CRC2 最少7字节 if (buf[0] ! FRAME_HEAD1 || buf[1] ! FRAME_HEAD2) return 0; pkt-type buf[2]; pkt-len (uint16_t)(buf[3] | (buf[4] 8)); pkt-crc (uint16_t)(buf[5 pkt-len] | (buf[6 pkt-len] 8)); uint16_t calCrc CalcCRC16(buf[2], pkt-len 3); if (calCrc ! pkt-crc) return 0; // CRC 校验失败 memcpy(pkt-data, buf[5], pkt-len); return 1; }这段解析函数要求输入 buf 是完整的一帧数据。实际使用时应配合状态机逐字节接收或利用串口空闲中断把一帧 DMA 数据整体读入。注意 CRC 覆盖范围从帧类型开始到数据结束不包含帧头。这样设计的原因是帧头只是用于同步不参与校验解析时若 CRC 错可以直接丢弃并等待下一帧头。对 512 字节的数据长度CRC16 的碰撞概率已经足够低如果要更高可靠性可以换 CRC32但那样帧长度会变大权衡下来 CRC16 在主动应答重传的协议中够用。3.2 应答与超时重传让传输丢包可见发送端PC 上位机或另一个 MCU每发一帧数据就要等待接收端返回 ACK 或 NAK。等待超时时间我一般设在 200ms超时后重发当前帧连续重发 3 次仍无应答则中止升级并报错。接收端收到一帧后先做 CRC 校验通过则写入 Flash 并返回 ACK失败返回 NAK 且不写 Flash。这里有一个关键细节必须“先擦后写”还是“收到即写”一次擦一个扇区需要 100ms 级别的时间如果每收一帧都去擦扇区会严重拖慢速度。解决思路是在 Bootloader 端维护一个长度为 512 字节的页缓冲区收到一帧就先拷入缓冲区只有当缓冲区累积到超出当前 Flash 页边界时才触发一次擦写。Flash 编程粒度是 16 字节half-word 或 word 编程实际写入时以 8 字节或 16 字节为单位调用库函数。下面是写入 Flash 的代码#include stm32f4xx.h #define FLASH_PAGE_SIZE 2048 // F407 编程页大小按 word 算这里用 2KB 对齐 uint8_t WriteFlashPage(uint32_t addr, uint8_t *buf, uint16_t len) { FLASH_Unlock(); // 解锁 Flash // 擦除所在扇区 FLASH_Status s FLASH_EraseSector( GetSectorByAddr(addr), VoltageRange_3, BLOCK_ERASE); if (s ! FLASH_COMPLETE) { FLASH_Lock(); return 1; } // 按 32 位写入 for (uint16_t i 0; i len; i 4) { uint32_t word (uint32_t)buf[i] | ((uint32_t)buf[i1] 8) | ((uint32_t)buf[i2] 16) | ((uint32_t)buf[i3] 24); if (FLASH_ProgramWord(addr i, word) ! FLASH_COMPLETE) { FLASH_Lock(); return 1; } } FLASH_Lock(); return 0; }GetSectorByAddr 需要根据起始地址换算扇区号标准库提供了 FLASH_EraseSector 接口但参数是扇区编号而非地址所以要做一次映射。注意擦除操作非常耗时128KB 扇区可能需 2 秒左右这一段时间内要确保看门狗已经关闭或喂狗否则升级过程中设备会复位。我一般在 Bootloader 初始化阶段就把 IWDG 关掉等 App 启动后再重新配置现场升级到一半被狗咬断是新人最常踩的坑。4. 跳转 App 的坑向量表偏移与栈指针4.1 跳转不能只写一个函数指针Bootloader 接收完固件并完成 CRC 校验后准备跳转。看似只要把 PC 指向 0x08010000 就行实际上有两个必要步骤把 MSP 设置为 App 的初始栈顶地址再通过函数指针跳转。App 的初始栈顶地址存放在 App 起始地址的前 4 字节即 0x08010000 处复位向量Reset_Handler在紧接着的 4 字节处。如果直接跳转而不重新设置 MSP调用 App 中的函数时栈可能覆盖 Bootloader 的数据出现莫名其妙的硬件错误。跳转代码#define APP_ADDR 0x08010000 typedef void (*pFunction)(void); void JumpToApp(void) { uint32_t appStackAddr *(volatile uint32_t *)APP_ADDR; uint32_t appResetAddr *(volatile uint32_t *)(APP_ADDR 4); // 检查栈顶地址是否在 RAM 范围内 if ((appStackAddr 0xFFF00000) ! 0x20000000) { return; // 栈指针非法不跳转 } // 关闭全局中断避免中断向量错乱 __disable_irq(); // 设置主栈指针 __set_MSP(appStackAddr); // 跳转到 Reset_Handler pFunction jump (pFunction)appResetAddr; jump(); }跳转前一定要检查 App 栈顶地址是否落在 RAM 区间。很多情况下 Bootloader 收到了损坏的固件头栈顶地址变成一个随机值不加检查会导致跳转后直接 hardfault。__disable_irq 关闭中断也重要因为跳转的瞬间向量表还没切换到 App任何中断触发都会跳到 Bootloader 的中断向量里而 Bootloader 的向量表被 App 覆盖后行为不确定。跳转后 App 的第一件事就是重新配置中断向量表偏移。4.2 App 侧必须做的两处配置App 工程不止要写好代码链接脚本和启动文件都要改。第一处是链接脚本中 FLASH 的起始地址改为 0x08010000长度改为 448KB第二处是在 main 函数的最开始调用SCB-VTOR FLASH_BASE | 0x10000;把中断向量表指向 App 自己的位置。若不做第二处App 一旦产生中断比如定时器、串口就会跳回 Bootloader 的向量表执行由于 Bootloader 里没有对应中断服务函数程序会跑飞。标准的库函数写法是#define APP_FLASH_ADDR 0x08010000 void SystemInit(void) { SCB-VTOR APP_FLASH_ADDR; // 其他必要的时钟初始化 }注意 SCB-VTOR 要求 128 字节对齐0x08010000 是 64KB 对齐完全满足。如果使用 STM32CubeMX 生成的工程需要在选项里把 Flash 起始地址修改否则链接时变量和常量位置会指向 Bootloader 的地址范围运行时读 Flash 数据会错乱。5. 现场升级的可靠性备份、回滚与断电续传5.1 双区备份为什么说只做一套 App 是赌博现场升级最怕的场景新固件传输 90% 时断电或写入成功但 App 一运行就死机。如果没有备份机制设备只能返厂。双区方案的做法是把新固件先写到备份区写入完成后做整体 CRC 校验全部通过后才拷贝到主区。更快的办法是不拷贝而是设计一个“启动选择位”启动时 Bootloader 根据标志位决定从主区还是备份区加载 App这样可以省去一次 Flash 拷贝时间代价是主区和备份区需要有两份完整固件写入和校验逻辑要分别处理。我采用的是“先备份后切换”的做法新固件先擦写备份区校验通过后设置一个待切换标志重启后 Bootloader 发现待切换标志把备份区固件拷贝到主区再校验主区 CRC全部通过才清除标志并跳转。这样升级过程中任何一步失败设备仍能启动旧版本。下面的状态机描述了整个流程5.1.1 升级状态机定义typedef enum { UPGRADE_IDLE 0, // 空闲 UPGRADE_RECEIVING, // 正在接收固件到备份区 UPGRADE_RECEIVED, // 固件接收完成待校验 UPGRADE_COPYING, // 备份区拷贝到主区 UPGRADE_COPY_DONE, // 拷贝完成待跳转 UPGRADE_ROLLBACK // 回滚状态从主区或备份区恢复 } UpgradeState; typedef struct { UpgradeState state; uint32_t receivedBytes; uint32_t totalBytes; uint16_t frameErrorCnt; uint32_t upgradeTimeStamp; } UpgradeContext;Bootloader 每次上电读参数区中的 UpgradeContext根据 state 决定下一步。如果 state 是 UPGRADE_RECEIVING 但距离上次接收超过 5 分钟则认为升级中断自动回滚到上一版固件。这个超时判断需要依赖一个实时时钟或系统嘀嗒计数器没有 RTC 的单位可以用定时器计数Bootloader 运行期间维护一个 1ms 递增的变量即可。断电续传不是指从断点处继续传字节而是指“断电重启后能回到上一可用版本”对现场工程师来说设备不鼓包、能重新升级比续传速度更重要。5.2 写入后校验只有 CRC 不够还要读回比对写完 Flash 后必须读回数据与接收缓冲区比对。STM32 的 Flash 编程虽然按库函数返回状态但状态只能说明写入操作完成不能保证数据正确电压不稳、时钟配置异常都可能导致写入错误。读回比对的做法是每写完一页2KB立即读该页的前 64 字节和最后一页的 16 字节做比较降低校验成本。全量校验放在固件传输完成后对整个 App 区域计算 CRC与上位机下发的 CRC 值对比。读回比对代码如下uint8_t VerifyFlashPage(uint32_t addr, uint8_t *buf, uint16_t len) { for (uint16_t i 0; i len; i) { // 直接读取 Flash 映射地址 uint8_t flashData *(volatile uint8_t *)(addr i); if (flashData ! buf[i]) { return 0; // 不一致 } } return 1; }这里使用 volatile 指针读取是为了防止编译器把多次读取优化成寄存器缓存。另一处值得注意的是Flash 中读出的值对擦除过的扇区是 0xFF如果某字节仍为 0xFF 而缓冲区中也是 0xFF无法区分是“没写入”还是“本来就该是 0xFF”。因此在实际工程中发送端会把固件填充成至少包含一个非 0xFF 的校验特征值比如在固件末尾追加固定魔数 0xDEADBEEFApp 启动后也能通过检查这个魔数判断自身固件完整性。5.3 看门狗与升级互斥的处理现场设备通常都有独立看门狗或 IWDG。Bootloader 阶段如果开了 IWDG必须注意 Flash 擦写期间喂狗的时间点。擦一个 128KB 扇区在 F407 上约需 1~2 秒而 IWDG 默认超时大多在 0.25~1 秒若不在擦除前关狗或不提前喂狗整个升级过程无法完成。我一般做法是Bootloader 第一行代码就关闭 IWDG通过写键值进入接收循环后每成功处理一帧数据就重置一次独立看门狗的超时计数器。如果升级挂起看门狗兜底复位把设备拉回 Bootloader 重新等待指令。App 侧则相反启动时要重新初始化 IWDG确保业务主循环异常时能自动复位。App 内检测到自身固件 CRC 异常时不要启动业务逻辑直接软复位进入 Bootloader由 Bootloader 启动回滚流程。这种双保险能确保任何异常情况下设备都不会停留在“半死机”状态。6. 验证与调试把跳转失败和 Flash 写入错误快速定位6.1 用仿真器分别调试 Bootloader 和 App调试 IAP 最大的麻烦是Bootloader 和 App 是两个独立工程无法用一次仿真同时跟踪跳转边界。常见做法是分两次烧录调试第一次只烧 Bootloader在跳转代码处设置断点单步执行后确认 MSP 和 PC 是否正确设置第二次在 App 工程中设置断点确认 App 的 SystemInit 执行时向量表是否已切换。不要让仿真器直接烧录 App——它会把 App 放在 0x08000000 而非 0x08010000这样调试结果与实际不吻合。6.2 用串口日志辅助确认每帧状态在 Bootloader 中打印详细的接收调试信息比仿真器更接近现场真实环境。每收到一帧并正确处理输出当前帧序号和累计字节数CRC 错误时输出错误帧编号。现场升级失败时通过日志能立刻判断是通信链路丢包、Flash 写入失败还是跳转异常。针对日志做异常统计最多的是以下三类现象可能原因排查方向连续 NAK重传多次后中止串口波特率偏差大或干扰用示波器测波形校准波特率降低帧长写入完成校验失败Flash 电压异常或时钟配置错误检查 VDD 是否稳定Flash 等待周期是否设为 5跳转后 hardfault向量表未偏移或栈顶地址非法检查 SCB-VTOR确认 App 链接脚本地址6.3 离线模拟丢包与断电的测试方法升级完成后不要立刻交付我一般会做三轮测试第一轮用上位机在随机帧号处丢包确认重传机制生效且最终固件正确第二轮在写入备份区期间直接给板卡断电重启后确认 Bootloader 能自动回滚到旧版本第三轮在跳转瞬间断电重新上电后确认标志位状态机不会卡死。第三轮最常见的坑是断电瞬间参数区正处于写入状态EEPROM 模拟的扇区写了一半重启后读取到不确定标志位。解决办法是在参数区使用双备份区交替写入每次写之前先擦除另一个备份区重启后选版本号更大的那个。这个技巧能保证任何时候参数区都至少有一份有效配置代价是参数区占用翻倍到 128KB但 Flash 空间足够时值得。若 Flash 空间紧张还有一个变通方案不存升级进度只存一个引导次数计数器Bootloader 每次跳转 App 时递增如果连续两次跳转到同一 App 都立即复位则强制回滚这样既不需要参数区双备份也能兜底处理多数异常。本文还有配套的精品资源点击获取