ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103 AB分区OTA升级与Bootloader回滚机制完整实战

STM32F103 AB分区OTA升级与Bootloader回滚机制完整实战 自己搞了一段时间STM32F103的平台开发始终绕不开“怎么把固件安全地升级上去”这个麻烦。早期项目用ISP串口下载每次升级都得派人去现场拆壳子、跳BOOT0麻烦不说升级过程中如果断电机器直接变砖。后来尝试了单分区OTA也就是Bootloader下载固件到App区域下载完立刻跳转。方案倒是简洁但有个致命的尴尬新固件如果是坏的设备就彻底废了一点回旋余地都没有。踩过这个坑之后我开始研究AB分区OTA也就是把App区域拆成A、B两份Bootloader负责选择从哪一份启动。升级时往当前不用的那份区域写固件写完校验通过再切换启动分区。如果新固件跑不起来Bootloader还能自动回退到旧区域。这套方案在手机、路由器上早就用烂了但移植到STM32F103上关乎Flash分区、中断向量表重定位、Bootloader跳转、状态标志管理、Flash写操作等一系列细节工程上远比想象中琐碎。这篇文章就是我基于实际项目复现的一套完整记录。从Flash分区方案、链接脚本修改到Bootloader和App侧代码怎么写再到整机升级和回滚测试一步一步还原下来。想直接抄作业的朋友可以把我的分区表和代码当作起点再根据自己芯片容量和业务需求去调整。1. 先理解AB OTA到底比传统方式多干了什么很多刚接触OTA的朋友会问AB分区看起来只是把Flash砍成两半还多占了一半空间到底值不值我在做项目决策的时候也是反复权衡过这里把这套方案的核心逻辑讲清楚。1.1 单分区OTA为什么存在“不可逆风险”单分区OTA的升级链路是这样的Bootloader先运行收到完整的新固件后直接覆盖写进App区域写完校验CRC无误立刻跳转执行。看起来没毛病但真正跑产品的时候有两个隐患新固件本身有Bug比如跑起来就死机、外设初始化卡死。程序跳到App后根本跑不到正常主循环你又没有远程干预手段设备相当于刷死了。下载过程中出现异常比如网络中断、串口线被拔、电池掉电Flash里留下来的是一份残缺固件。Bootloader再去校验CRC会直接判断无效但你又没有第二份可用固件结果一样是变砖。这时候要是有一份“上一版能用的固件”留在Flash里情况就完全不同了。1.2 AB分区的整个生命周期里发生了什么事AB双分区的运行状态分成“静态分区”和“动态切换”两层静态分区物理上A区和B区各自独立App当前运行在哪一个区域由一个专门的标志位记录Bootloader启动时根据标志位选择跳转区域。动态切换升级时App把新固件写入“非当前活动区”写完后把标志位改成“待切换到新区”然后软复位重启。Bootloader上电后看到“待切换”标记先校验新区域固件校验通过后更新标志位、跳转过去并让App在运行成功后发送“确认升级成功”信号。这里“确认升级成功”是AB方案的一个精髓它把升级过程从“写完就算数”改成了“跑起来并确认没崩才算数”。1.3 从成本角度聊聊AB分区到底值不值当然AB分区不是没有代价。最直接的是Flash容量占用翻倍中断向量表、代码段、只读数据都要有两份的空间。对于STM32F103C8T6这种只有64KB Flash的芯片如果App代码超过20KB做AB分区就比较吃力了。我的建议是芯片型号Flash容量是否适合AB分区建议方案STM32F103C8T664KB勉强可以精简App逻辑A/B各留20KBSTM32F103CBT6128KB比较合适A/B各留40KBSTM32F103RCT6256KB非常合适A/B各留96KB以上STM32F103ZET6512KB很宽松可考虑同时存多版本固件我在实际项目中选型时会优先考虑至少128KB Flash的芯片。如果产品经理非要用64KB芯片又强制要求OTA安全回退那就要和需求方反复确认功能裁剪方案否则代码膨胀后AB分区根本装不下。2. Flash分区方案和链接脚本调整决定AB方案能不能成立一切的根基是Flash布局。只有在编译器层面让“A区固件”和“B区固件”生成在不同起始地址的可执行镜像Bootloader的跳转逻辑才有意义。这一章我把分区方案、Keil链接脚本改动和中断向量表重定位一次讲清楚。2.1 以64KB芯片为例的通用分区表我以一个实际项目使用的分区为例芯片是STM32F103C8T6Bootloader用16KBA区和B区各20KB尾部留8KB用来存状态标志和日志区。这个分区表的好处是每块区域起始地址都对齐到1KB边界而STM32F103的Flash擦除页正好是1KB操作比较方便。区域起始地址大小说明Bootloader0x0800000016KB上电启动、分区选择、固件校验App A0x0800400020KB出厂默认运行区App B0x0800900020KB升级备用运行区Flag区0x0800E0004KB存储启动标志、升级状态日志区0x0800F0004KB记录升级次数、错误信息便于排查实际生产环境下我还会在Flash末尾额外预留一个很小的“紧急恢复引导区”用串口收到特定指令时强制进入Bootloader手动刷写防止某些极端情况下的远程无法恢复问题。如果你用的是128KB或256KB的芯片完全可以按比例放大比如RCT6可以把A/B各设96KBBootloader用32KB剩余80KB留给标志、日志甚至第二份备份固件。原则是每个区域的起始地址必须在Flash页边界对齐Flash大小必须能容纳擦除操作否则擦除时会跨区域把别人的代码擦掉。2.2 Keil环境的分散加载文件.sct怎么改Keil MDK下App程序默认的分散加载文件起始地址是0x08000000也就是Flash的起始地址。做AB分区后App A的链接地址要改成0x08004000App B改成0x08009000。在Keil工程里Options窗口的Linker页签下面去掉“Use Memory Layout from Target Dialog”的勾选然后在Scatter File一栏指定自定义的.sct文件。文件内容模板如下; App A 区域的分散加载文件 LR_IROM1 0x08004000 0x00005000 { ER_IROM1 0x08004000 0x00005000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) .ANY (XO) } RW_IRAM1 0x20000000 0x00005000 { .ANY (RW ZI) } }这里有三个数值含义要理解0x08004000加载域和执行域起始地址就是App A在Flash里的物理地址。0x00005000ROM区域最大容量十进制是20480正好20KB。如果编译产物超过20KB链接器会报错这是好事至少不会悄悄越界。0x20000000RAM起始地址F103C8T6内部SRAM是0x20000000开始大小0x00005000也是20KB。编译App B时只需把起始地址改成0x08009000其他不动。编译Bootloader时起始地址保持默认0x08000000FLASH大小在Options的Target页里手动填0x400016KB。用GCC工具链的朋友对应的就是链接脚本里设置FLASH_ORIGIN和FLASH_LENGTH两个宏App A这样写MEMORY { FLASH (rx) : ORIGIN 0x08004000, LENGTH 20K RAM (rwx) : ORIGIN 0x20000000, LENGTH 20K }2.3 链接地址改完中断向量表不跟着改等于白干这里是我第一次复现时卡得最久的地方。链接地址虽然改了编译出来的代码确实被链接到了0x08004000但Cortex-M3内核上电或者复位后默认从中断向量表所在的地址读取初始堆栈指针和复位入口地址。如果不做中断向量表重定位App里的中断响应会依然走到Bootloader的中断向量表区域等于所有中断都“穿墙”跑了。重定位办法是设置SCB-VTOR寄存器指向App的向量表首地址。STM32F103的中断向量表基地址就是Flash区域地址比如App A就是0x08004000。在App的main函数最前面加上这句#define APP_BASE_ADDR 0x08004000UL int main(void) { SCB-VTOR APP_BASE_ADDR; // ...后续初始化代码 }用标准外设库的朋友需要注意F1标准库的system_stm32f10x.c中已经预留了VECT_TAB_ADDR宏默认定义的是0x08000000。你可以在编译选项里添加VECT_TAB_ADDR0x08004000来覆盖默认值也可以在SystemInit之后手动再设置一次SCB-VTOR。另外还有一个细节App的启动文件最开始存放的是初始堆栈指针和复位向量这也指向向量表起始处。Bootloader跳转到App时第一条执行的代码是从向量表偏移4字节处读出的复位入口而不是直接从链接地址执行。所以只要VTOR设置正确整个链路是通的。3. Bootloader侧代码跳转、标志位、校验逻辑一次写全Bootloader是整个AB OTA体系的大脑上电后判断该启动哪个区要不要进入下载模式新固件校验是否通过启动新固件后如何确认运行成功。这一章直接给出我落地验证过的完整代码设计。3.1 跳转函数拿到固件首地址后先做合法性校验从Bootloader跳转到App的本质是把App向量表首地址处的字作为新的MSP栈顶指针把向量表偏移4字节处的字作为PC入口地址然后执行跳转。为了防止跳到一个无效的Flash地址导致HardFault跳转前要检查栈指针是否落在SRAM范围内。#define SRAM_BASE 0x20000000UL #define SRAM_END 0x20005000UL // C8T6有20KB SRAM typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_msp *(volatile uint32_t *)app_addr; uint32_t app_pc *(volatile uint32_t *)(app_addr 4); // 简易合法性检查栈顶必须在SRAM范围内 if ((app_msp SRAM_BASE) || (app_msp SRAM_END)) { return; // 地址不合法不能盲目跳转 } // 确保跳转前关闭全局中断并且把外设复位到干净状态 __disable_irq(); // 重定位中断向量表到目标App区 SCB-VTOR app_addr; // 设置新的主堆栈指针 __set_MSP(app_msp); // 跳转 pFunction jump (pFunction)app_pc; jump(); // 正常不会执行到这里 while (1); }有几个细节值得展开app_msp的范围检查很重要。Flash里意外数据也可能碰巧形成一段像是地址的数字如果不检查直接跳很容易死机且不好排查。检查之后即使误跳也会因为数据非法而停在原处方便后续日志定位。跳之前最好把用到的外设复位。我的习惯是调用RCC_DeInit()把时钟恢复默认同时把UART、DMA、定时器全部关闭避免App初始化时受Bootloader残留状态影响。中断向量表重定位放在跳转前做App里其实已经设置了一遍双保险总不会有错。3.2 状态标志区用一个自定义结构体管理分区状态AB分区的核心状态信息都必须持久化保存掉电也不能丢。我在Flash末尾划分了4KB的标志区用自定义结构体存储typedef struct { uint32_t magic; // 魔数固定为0xA5A55A5A区域未初始化则为0xFFFFFFFF uint32_t active_region; // 当前活动区1表示A区2表示B区 uint32_t update_pending; // 是否有待切换的升级0无1有 uint32_t boot_count; // 新固件已启动的次数用于回滚判断 uint32_t app_crc32; // 待启动/已启动固件的CRC32 } ota_flag_t;读写Flash时建议先读整个页到临时变量修改后再整页擦写。因为STM32F103的Flash不支持像EEPROM那样按字节独立擦写最小擦除单位是1个页。#define FLAG_PAGE_ADDR 0x0800E000UL void ota_flag_write(ota_flag_t *new_flag) { FLASH_Unlock(); // 解锁Flash // 先擦除标志页 FLASH_ErasePage(FLAG_PAGE_ADDR); uint32_t *p (uint32_t *)FLAG_PAGE_ADDR; uint32_t *data (uint32_t *)new_flag; for (int i 0; i sizeof(ota_flag_t) / 4; i) { FLASH_ProgramWord(FLAG_PAGE_ADDR i * 4, data[i]); } FLASH_Lock(); }读取标志时直接做地址转换读内存即可不需要任何Flash控制器操作void ota_flag_read(ota_flag_t *flag) { uint32_t *p (uint32_t *)FLAG_PAGE_ADDR; memcpy(flag, p, sizeof(ota_flag_t)); }这里有个容易被坑的点第一次烧录Bootloader时标志页是0xFFFFFFFF所以bootloader在启动后要做一次初始化检查如果magic不是预期魔数说明是首次运行需要把出厂默认分区A区的配置写进去。3.3 Bootloader整体启动流程一图流逻辑拆解我在实际项目的Bootloader主流程就是一个顺序判断看起来简单但每个分支背后都有原因初始化时钟、串口、看门狗。读取标志区配置。magic不正确就执行出厂初始化设置active_region1update_pending0写入标志区。如果发现update_pending等于1说明上次App请求过升级切换。这时候要校验非活动区的固件CRC是否合法。校验通过后更新active_region指向新区域把update_pending清0boot_count置1然后跳转去执行新区域固件。校验失败或新区域固件不完整则维持active_region不变把update_pending清0继续从原来区域启动。如果没有待切换标志直接根据active_region跳转到对应区域。int main(void) { // 初始化 system_init(); uart_init(115200); ota_flag_t flag; ota_flag_read(flag); // 首次上电初始化 if (flag.magic ! OTA_MAGIC) { flag.magic OTA_MAGIC; flag.active_region REGION_A; flag.update_pending 0; flag.boot_count 0; ota_flag_write(flag); } uint32_t app_addr; if (flag.update_pending 1) { // 需要尝试切换区域 uint32_t target_addr (flag.active_region REGION_A) ? B_REGION_ADDR : A_REGION_ADDR; // 校验目标区域固件CRC if (crc32_verify_region(target_addr, MAX_APP_SIZE) 0) { // 校验成功切换 flag.active_region (flag.active_region REGION_A) ? REGION_B : REGION_A; flag.update_pending 0; flag.boot_count 1; ota_flag_write(flag); app_addr target_addr; } else { // 校验失败回退原区域 flag.update_pending 0; ota_flag_write(flag); app_addr (flag.active_region REGION_A) ? A_REGION_ADDR : B_REGION_ADDR; } } else { app_addr (flag.active_region REGION_A) ? A_REGION_ADDR : B_REGION_ADDR; } jump_to_app(app_addr); while (1); }这套流程里还缺一个运行确认机制新固件启动后直到App真正运行起来了才能通知Bootloader把状态固化下来。最简单的做法是App初始化完成后调用一个“标记成功”的函数把boot_count清零表示这个区域已经被验证可用。Bootloader端在每次启动时要检查boot_count如果大于某个阈值比如3次就认为新固件启动失败自动回滚。原因很直接你可能在新固件里初始化到一半就死机还没来得及标记成功。如果Bootloader傻傻地每次都跳到新区域就会陷入死循环。加入boot_count计数后连续启动超过3次仍未被App标记成功就强制使用旧区域。3.4 CRC32校验怎么做Bootloader和App共用同一套算法校验固件完整性用CRC32是最实用的方案。Bootloader和App两侧都得算同一套CRC32迁移到哪个区域就用哪个区域的数据算。uint32_t crc32_calc(uint8_t *data, uint32_t len) { uint32_t crc 0xFFFFFFFF; for (uint32_t i 0; i len; i) { crc ^ data[i]; for (int j 0; j 8; j) { crc (crc 1) ^ (0xEDB88320UL (0UL - (crc 1UL))); } } return ~crc; }这里有一个专门针对AB分区的优化。校验整个20KB区域要读Flash 20KB的数据耗时大约几毫秒对Bootloader启动时间影响很小可以放心做。但如果你把App做得很大比如96KB以上连续CRC计算时间会变长建议把CRC结果作为固件包头部信息存在包头里同时额外计算一小段关键区域而不是每次都全量校验整个区域。我在实际项目中就把CRC32结果放在固件包的元数据区升级时App传输完整固件包前先上行一个头部里面包含固件长度和CRC写完再校验一次。这样Bootloader只需要读包头就能决定是否跳转。4. App侧要做的几件事接包、写入、复位、确认Bootloader只是给OTA搭好了舞台真正执行升级动作的是App本身。它需要负责接收新固件、把固件写入非活动分区、更新状态标志、触发复位以及启动成功后给Bootloader一个“我活过来了”的信号。4.1 App接收固件以串口为例的帧协议设计STM32F103最常用的外设升级通道是串口。这里示例的帧协议我已经在项目中多次复用改动量很小大家根据实际传输通道扩展即可。帧格式如下字节偏移字段说明0-1帧头0xAA 0x552帧类型0x01握手0x02数据0x03结束3-4数据长度小端模式5-N数据长度由前两字节决定最后2字节CRC16对帧头到数据区末尾做CRC16握手阶段用于建立会话携带当前App的版本号、目标区域地址、接收缓冲大小。数据帧按固定长度分包传输比如一帧传256字节。结束帧里携带整个固件的CRC32App收到后会和当前已写入的数据逐块计算比对。App接收数据后立刻写入Flash写完一页立刻清零接收缓冲避免大数组占内存。F103的SRAM有限我一般只分配512字节的接收缓冲。协议代码这里不全部贴出重点讲设计思路每一帧都要有编号或者偏移量防止丢包后无法定位写入位置。我在数据帧里增加了16位偏移量字段每帧代表从某个偏移开始的256字节数据。接收方校验帧头、长度和CRC后直接在指定偏移地址写入Flash。4.2 写Flash的完整流程解锁、擦除、写入、上锁F103的Flash控制器有防止误写的保护机制直接对Flash地址写入是无效的必须先执行解锁序列。标准外设库已经封装好了但很多人不知道解锁的顺序和约束void flash_write_data(uint32_t addr, uint8_t *data, uint32_t len) { FLASH_Unlock(); // 写入KEY1和KEY2两次解锁 uint32_t end_addr addr len; // 逐半字写入F103的编程宽度是16位不能用32位连续写 while (addr end_addr) { uint16_t half_word *(uint16_t *)data; FLASH_ProgramHalfWord(addr, half_word); addr 2; data 2; } FLASH_Lock(); // 重新上锁防止误操作 }执行Flash写入时要注意写零地址所在的页之前必须先把页擦除成0xFF。我在写入数据时会预先按页判断地址是否已经擦过。这个问题在实际接手别人的代码时才反映出来最典型的报错是FLASH_ProgramHalfWord返回FLASH_COMPLETE但读回数据是0xFF实际是数据区落到未擦除的页上。一个让升级体验更好的细节擦除和写入期间不能让看门狗把MCU复位了。F103擦除一页大约20ms-40ms20KB完整写入差不多1秒如果看门狗超时只有几百毫秒就会中途复位。我通常把看门狗清狗操作放在每完成一页Flash编程之后或者临时停用看门狗直到升级完成。4.3 升级结束前的状态写入顺序差一步都可能翻车很多人在写完新固件后直接复位结果Bootloader不认还从旧区域启动了。原因就是状态标志的写入顺序和时机不对。我的做法是先写完整新固件并校验CRC无误后才把标志区更新为“待切换”。顺序如下App将新固件完整写入非活动区。App计算非活动区的CRC32和结束帧携带的CRC32比对。CRC校验通过后设置新标志update_pending1pending_region指向刚写入的区域原active_region保持不变。把新标志写入标志区确认写入无误后再软复位。顺序上最关键的是写入新固件过程中一旦发生异常比如传输中断、CRC失败绝不能更新update_pending标志。如果固件写了一半就标记待切换Bootloader启动时校验CRC大概率会失败虽然我的逻辑设计了回退到原区域但会白白增加一次启动延时而且会在日志里留下无意义的错误记录。4.4 App软复位与成功确认信号升级状态写完后App需要做一次软复位让Bootloader接管。Cortex-M3软复位有几种方式最简洁的是void system_soft_reset(void) { __disable_irq(); NVIC_SystemReset(); }注意复位前要关中断不然复位过程中可能被中断干扰导致脏乱状态。App启动后的“成功确认”函数是AB升级的收尾动作。我在App的main函数完成基本初始化后调用void ota_confirm_boot_success(void) { ota_flag_t flag; ota_flag_read(flag); if (flag.boot_count 0) { flag.boot_count 0; // 清零表示该区域已验证可用 ota_flag_write(flag); } }Bootloader会在启动时判断boot_count是否超过阈值如果App一直没标记成功超过3次就自动回退。这个机制能拯救“新固件启动后立即死机”的场景。有次我测试一个改动很大的固件上电没几秒就HardFault因为boot_count机制在整个板子自动回退到旧固件避免了返厂重刷。4.5 App侧需要额外注意“当前活动区”的读取App内部偶尔也需要知道自己运行在哪个区域比如要记录日志、判断升级目标地址、显示版本号。通常我定义一个全局变量uint8_t current_region 0; // 1A, 2B void app_init_region(void) { ota_flag_t flag; ota_flag_read(flag); // 根据App编译地址判断当前实际运行区域 uint32_t pc (uint32_t)main; if ((pc A_REGION_ADDR) (pc B_REGION_ADDR)) { current_region REGION_A; } else { current_region REGION_B; } }用PC地址判断更靠谱因为即使标志区数据损坏代码跑在哪个区域地址范围自然能反映出来。拿到当前区域后升级目标区域就是另一个uint32_t get_upgrade_target_addr(void) { return (current_region REGION_A) ? B_REGION_ADDR : A_REGION_ADDR; }5. 一次完整的AB OTA升级是怎么跑通的光把各模块讲清楚还不够我把实际从编译到回滚验证的完整过程也写出来大家在复现时可以照单操作。5.1 三个工程的编译配置我的项目里总共维护三个独立的Keil工程Bootloader工程输出Bootloader.hex链接地址0x08000000烧录时用ST-Link直接全片擦除写入。AppA工程输出AppA.hex链接地址0x08004000。AppB工程输出AppB.hex链接地址0x08009000。实际编译时AppA和AppB的源代码工程可以共用只需要通过宏或者预先定义好的分散加载文件切换链接地址即可。比如在Keil的C/C页里定义APP_REGIONB然后在system初始化代码里根据这个宏设置VTOR和需要的参数。如果嫌维护两个工程麻烦也可以用批处理方式改散列文件路径再调用Keil命令行编译C:\Keil_v5\UV4\UV4.exe -b AppProject.uvprojx -j0 -o build_AppA.log小项目用两个独立工程反而清晰不容易因为宏切换导致链接参数混乱。5.2 首次烧录与出厂设置用ST-Link把Bootloader烧进0x08000000AppA烧进0x08004000。此时标志区还是全0xFF首次上电Bootloader会检测到magic无效自动初始化出厂设置指向A区。串口打印观察日志看到“Boot: Region A”之类的输出就代表正常。这一步容易踩的坑是烧录Bootloader后如果忘了烧AppABootloader启动时App区域是空白的读取栈顶指针会得到0xFFFFFFFF栈指针检查不通过跳转函数会直接返回然后Bootloader停在原地。所以我在跳转失败时都加了错误提示信息至少要让现场调试的人知道卡在哪一步。5.3 发起一次OTA升级观察完整链路我在PC上位机写了一个简单的串口发送工具也测试过用现成的Xmodem/Ymodem协议。升级过程记录如下设备当前运行在A区版本号V1.0。PC发送“版本询问”帧设备回复当前版本和当前区域。PC开始分包发送V2.0固件目标区域为B区。每收到一帧设备写入B区对应偏移返回ACK。全部发送完成后PC发送结束帧包含B区固件CRC32。App重新读取B区完整数据算CRC32和结束帧比对一致。App更新标志区active_region保持Aupdate_pending置1pending_regionB。App执行软复位。Bootloader启动发现update_pending1校验B区CRC32通过将active_region切换为Bboot_count1跳转B区。AppB运行完成初始化后调用ota_confirm_boot_success()清boot_count。PC再次询问版本设备回复运行在B区版本V2.0。升级完成。整个过程串口日志应该清晰可追踪。我强烈建议在Bootloader和App的串口日志里加上统一的前缀比如[BL]和[APP]这样收到完整日志时一眼就能分辨当前是哪段程序在输出。5.4 模拟升级失败验证回滚机制回滚机制验证必须在真实环境下做。我的测试方法是当前运行B区V2.0。把一个新的“损坏固件”上传到A区故意在传输过程中中断。保险起见我还测试过另一种情况完整上传固件到A区但让CRC32校验失败。标志区被置为待切换后重启。Bootloader校验A区失败清除update_pending保持active_regionB。设备继续运行B区V2.0串口日志打印“Rollback to Region B”。这个实验证实了一个关键结论只要新固件尚未在Bootloader中通过校验旧的固件区域就永远保持可启动状态。另外还要测一下“新固件启动死机”的回滚场景。我在新固件init函数里故意写了个死循环让App永远执行不到确认成功的地方。重启后Bootloader按照boot_count1跳A区App卡死看门狗超时复位。Bootloader再次启动发现boot_count还是1又尝试跳A区连续3次后判断该区域无效自动回退到B区。这个测试验证了整个回滚闭环对我而言比升级成功测试还重要。5.5 升级过程中的看门狗和电源保护升级过程中最怕掉电掉电后Flash里留下残缺固件Bootloader会拒绝启动。如果你加了AB分区最坏情况也只是回退到旧版固件。但想让这个保证更稳固我有两个额外建议给MCU供电加一个欠压检测电路比如TLE7230或者简单电阻分压比较器电压低于阈值时禁止升级操作。实测中能很大程度降低升级中途掉电的概率。把“写入标志位”放在Flash写操作完成后、复位前。先把新固件完整写入再改标志最后复位。这样即使掉电发生在写新固件过程中由于update_pending还是0Bootloader依然从旧区域启动不会受影响。6. 复现过程中最容易踩的坑和排查思路这部分是项目交付后整理的经验。AB OTA本身逻辑不复杂但嵌入式开发的特点就是细节决定成败这里挑几个我实际踩过的坑详细讲讲排查过程。6.1 App跑飞死机先怀疑中断向量表没重定位到位现象从Bootloader跳转App后主循环能跑但一产生UART中断或者定时器中断系统立刻进入HardFault。排查思路用调试器在HardFault_Handler里打断点查看栈回溯。如果发现压栈地址全在0x08000000附近基本就能判定中断服务函数被引导到了Bootloader的向量表区域说明SCB-VTOR没有指向App区。我在项目里遇到这个问题的原因比较隐蔽App的main函数里虽然设置了SCB-VTOR但SystemInit函数在main之前就执行了里面没有VTOR重定位逻辑。某些外设库的中断处理在SystemInit阶段就会触发。正确做法是使用标准外设库时在system_stm32f10x.c的SystemInit末尾直接加SCB-VTOR或者在启动文件中进入main前就完成重定位。6.2 跳转后外设状态残留App初始化异常现象单独烧录AppA程序时运行正常但从Bootloader跳转后App的外设初始化偶发异常比如UART刚初始化就收到乱码或者GPIO状态不对。排查思路这是典型的Bootloader残留状态污染问题。Bootloader里用了UART接收升级指令跳转前UART的DMA、中断、FIFO都处于开启状态。App初始化UART时如果不做完整复位很可能继承一个脏状态。我的处理是在跳转函数里执行__disable_irq(); RCC_DeInit(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; NVIC-ICER[0] 0xFFFFFFFF; // 关闭所有中断 NVIC-ICPR[0] 0xFFFFFFFF; // 清零挂起中断然后再把外设时钟关闭。App端初始化时按“复位外设-配置时钟-配置功能”的顺序做基本不会受残留状态影响。6.3 链接脚本里ROM地址写错导致的“分区重叠”问题现象AppA和AppB分别编译烧录后运行一个分区时会偶尔出现跳转到另一个分区的代码。排查思路这种问题多半是链接脚本里的FLASH起始地址和实际烧录地址不一致。比如Keil工程Options里设置的ROM起始地址还是默认的0x08000000但分散加载文件指定了0x08004000两者不一致时Keil可能以Target页设置为准结果编译出来的App还是链接到了0x08000000覆盖了Bootloader。排查方法是用fromelf --text -a AppA.axf查看编译产物确认复位向量、Reset_Handler的链接地址。只要发现地址不是预期的0x08004000开头就立刻排查Keil配置。我从这个坑里总结出一个习惯任何和Flash地址相关的编译配置统一在分散加载文件里维护不再单独修改Target页的ROM设置这样可以避免两个地方相互冲突。6.4 Flash擦写耗时导致看门狗复位现象升级过程中程序反复复位串口日志中途中断Flash里留下一半数据。排查思路看门狗超时时间小于Flash整体写入时间。F103擦除一页就需要几十毫秒擦除20页就是数百毫秒再加上写入时间如果看门狗只有500ms肯定会在升级中途复位。解决方案有两种一是把升级过程中的看门狗喂狗间隔调短放在每个页擦除完成后立刻喂二是确认升级流程开始后临时关闭看门狗直到升级结束。我更倾向于前者因为如果升级过程卡死在某个状态看门狗还能发挥作用。6.5 下载固件时误写了当前运行区域导致设备变砖这个坑我见不少朋友踩过。写一个“自动选择目标区域”的逻辑时如果判断条件写反手一抖就把新固件覆盖到了当前运行区下一包写入就可能把自己正在执行的代码擦掉程序直接就飞了。解决办法是写入前做一次地址范围检查bool is_address_in_current_region(uint32_t addr) { uint32_t current_addr (current_region REGION_A) ? A_REGION_ADDR : B_REGION_ADDR; return (addr current_addr) (addr current_addr MAX_APP_SIZE); }任何一帧数据写入前都先做这个检查地址落在当前区域就拒绝写入并报错。程序虽然不能在运行时彻底避免物理擦写但至少能拦截掉绝大多数逻辑错误。6.6 常被忽略的Flash等待周期WS问题F103的Flash接口在72MHz主频下需要注意等待周期。如果系统时钟配置在72MHz但Flash等待周期配置成0Flash读取就会不稳定表现是程序时好时坏特别是OTA升级后新区域代码频繁随机死机。标准库的SystemInit已经帮你配置好了但如果你自己写时钟初始化千万记得把Flash等待周期配置为2个周期72MHz时FLASH-ACR | FLASH_ACR_LATENCY_2;这个方法针对的是系统时钟比较高的情况低频状态下可以放宽但统一配成2个等待周期最稳妥。7. 生产化过程中可以继续扩展的几个方向到这里AB OTA的完整通路已经跑通。如果你打算把这套方案沉淀到产品里去还有几件事值得继续投入。7.1 固件包加密与签名防止固件被篡改纯CRC校验只能发现固件“被改坏了”没法发现固件“被恶意替换”。要是产品有强安全要求建议在固件包里增加签名机制。常见做法是PC端用私钥对固件哈希做RSA签名MCU端用公钥验签。Bootloader验签通过后才允许写入App区。对F103来说公钥验签RSA-2048耗时可能较长大部分时间花在模幂运算上。如果Bootloader的实时性要求还可以这个方案是可行的。如果产品经常跑在电池供电低功耗场景建议改用HMAC对称方案只是密钥要小心保护。7.2 加一个远程上报通道形成升级闭环AB OTA只解决了“能升级、能回滚”的问题但设备升级后是成功还是失败产品侧往往需要远程感知。我在实际项目里增加了升级结果上报App启动成功后通过原有通信通道向服务器发送一个“当前版本当前区域升级成功/回退”的消息这样后台就能统计每一台设备的具体升级状态。这个闭环看起来只是个数据上报实际上对产品运营价值很大。你总会遇到某些设备的Bootloader回滚了但原因不明有了状态上报就能结合日志排查根因。7.3 引入Bootloader自身的OTA严格来说Bootloader本身一直不更新也有隐患。如果Bootloader有严重Bug而App新的业务需求又依赖新Bootloader功能整个升级链路就卡住了。STM32F103的Flash有限给Bootloader做OTA会压缩App区域空间。一个折中方案是在Bootloader内部集成一个简化的“Bootloader更新模式”通过特定指令触发后从App区接收数据并在RAM中暂存再通过跳转执行临时Ramfunction完成Flash擦写。我自己评估过对小规模项目Bootloader升级可以暂缓更多精力应该放在Bootloader的稳定性和回滚机制上。只要Bootloader永远不会变砖AB OTA的保障体系就还是成立的。7.4 用AT指令或者私有协议与通信模组配合F103最常见的OTA通道还是串口但如果产品里带了ESP8266、4G模组或者LoRa模块可以把OTA数据通过模组先收下来再转给MCU同一套AB写入逻辑。协议层面唯一的改动是把“串口收帧”改为“模组收帧”核心的Flash分区、跳转、回滚逻辑完全不用动。我从一开始就把协议层和应用层做了剥离帧重组和Flash写入拆成两个模块。后续从串口升级切到WiFi升级只替换了传输模块的接口实现其余代码一行没动。这种模块化设计对F103这种资源紧张的芯片也有好处因为各部分可以分别验证降低联调难度。7.5 对Flash寿命的评估F103的Flash擦写寿命一般是10,000次。AB分区状态下升级一次通常只擦写一个App区域加上标志页大约就是两个页的擦写。按每天升级一次计算能撑近30年基本不用担心寿命问题。但如果产品升级频率很高或者程序设计有频繁写Flash的错误就需要增加磨损均衡策略把标志页在4KB区域内循环使用避免同一页反复擦写导致提前老化。我自己的项目里加了一个简单的计数器把写标志区域的次数存到日志区每次超过10,000次就提示需要维护。虽然绝大多数设备可能永远达不到这个阈值但有这个机制在至少在后期排查问题时多了一个数据维度。8. 这块方案最终还是要在项目中验证AB OTA从理论到真正跑起来中间的距离比我预想的大得多。Flash分区哪怕差1KB链接器也会报错中断向量表漏掉一行重定位App看起来正常却会在某个中断里翻船标志区写时序没控制好设备就会来回横跳。这些细节不亲自跑一遍光看文档根本记不住。文章里的分区表是针对STM32F103C8T6设计的如果你用的是其他容量或者差异较大的芯片建议先把所有区域地址重新画一张地址表确认每块区域的边界都落在页边界上再动手改链接脚本。分区的准确性是整个AB OTA的地基地基错了后面的代码再漂亮也白搭。从实际项目经验来看这套方案做完之后最大的价值不是“能升级”而是“升级失败的代价变得可控”。过去一个不成熟固件发出去可能就让一批设备瘫痪现在最多回滚到上一个版本配合远程日志定位问题的路径也清晰得多。如果你手头正好也在给F103做OTA建议先按我踩过的坑清单走一遍能省不少查错时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进