ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32F103 AB OTA双分区升级实战:从Bootloader到App完整实现

STM32F103 AB OTA双分区升级实战:从Bootloader到App完整实现 我最早想做这个是被一次现场事故逼的。设备已经发到客户那边用的STM32F103功能跑得挺好结果发现一个通信协议的边界条件需要改固件。按老办法只能让客户把设备寄回来或者派个人带着ST-Link过去拆壳刷机。那时候我就在想要是当初就把OTA做好这次就是远程发个包的事。后来我把方案从常见的单分区升级改成了A/B双分区也就是很多人说的AB OTA把整个流程从Bootloader到App侧全部自己写了一遍这才算彻底摆脱了“升级就心惊胆战”的日子。这篇文章就把我从零复现STM32F103 AB OTA的过程完整记录下来。你可以把它当成一份带思路的实操笔记里面包括了方案对比、Flash分区计算、Bootloader代码拆解、App侧改动、串测流程以及我实际踩过的一堆坑。适合准备给F103老项目加OTA功能的人也适合刚学Bootloader想找一份能跑通的工程参考的嵌入式开发者。1. 为什么是AB分区OTA方案的演进与选型真相1.1 先看传统单分区升级为什么让人不放心很多人在F103上做的第一版OTA是这么设计的Flash里划一块区域专门存下载好的固件叫临时区或者下载区。要升级的时候先把新固件下载到临时区校验通过后再由Bootloader把临时区的数据复制到App区复制完了再跳转。这套方案的优点是Flash占用小F103C8T6这种64KB的芯片也能勉强做。缺点也明摆着——复制过程一旦断电或者程序跑飞App区可能只写了一半整个设备就直接变砖。而且没有远程修复的手段因为新固件已经在临时区里了但App区坏了Bootloader又不知道该怎么救你。有人会想那我在临时区校验通过之后再做一次备份或者先擦App再往App里写之前先做个标记是不是就行了实操过的人都知道这些只能降低风险没法消除风险。Flash擦除和写入都是物理操作不可能做到原子性而升级过程中最不可控的就是断电。单分区方案做得再精细本质上也是在“覆盖正在运行的程序”这件事上赌运气。1.2 AB双分区到底解决了什么问题AB分区的思路完全绕开了“擦了再写”的风险模型。它把App区域划成两个独立的分区我用A和B来命名。当前真正在运行的是A新的固件可以完整地下载到B下载完成后校验整个B分区的数据。校验通过Bootloader把启动标志从A切到B然后复位芯片就从B启动。如果B启动失败或者运行了很短时间就死机Bootloader还可以根据策略自动回滚到A继续跑。这套机制的核心价值是整个升级过程里正在运行的App没有被触碰过只有等到新固件完整落盘并且验证OK才会切换启动方向。A分区随时可以作为“保底版本”存在。升级失败最坏的结果就是回退到旧版本而不是躺在那里开不了机。另外AB分区还有一个好处升级包不需要再经过一次“临时区复制到正式区”的过程下载完之后直接就变成了可启动的版本省掉了拷贝这一步不说也少了一个会出错的环节。1.3 在F103这种老MCU上做AB OTA真的合理吗有人可能觉得AB分区是ESP32、Linux A/B系统那种资源充裕平台玩的东西F103一个小Cortex-M3做AB有点小题大做。我在做之前也有这个疑虑但实际算下来对于大容量的F103完全是可行的。STM32F103最常见的有几个型号C8T6是64KB FlashRBT6是128KBVET6是512KBZET6也是512KB。做AB分区至少要能装下两个完整的App以及一个Bootloader。如果App编译出来只有30KB那C8T6理论上也能分成两个30KB的区再加一个4KB的Bootloader只是非常吃紧任何功能迭代都可能撑破。我的建议是F103做AB分区最起码用128KB以上的型号512KB会比较舒服。从另一个角度讲F103目前在很多工业控制、仪器仪表、传感器采集设备里还有巨大的存量这些设备普遍没有网络接入能力最多就是个串口或者RS485。给它们做OTA目的不是为了追新而是为了让售后不用出差。只要Flash装得下AB方案的成本也就是Bootloader多写几百行代码的事。如果你手里只有C8T6这种64KB的小容量芯片也不是完全不能做AB但要把App压得很小或者把新版本固件放在外部SPI Flash上通过Bootloader从外部Flash启动——这就涉及到XIP或者拷贝到内部RAM执行复杂度完全不同。这篇文章后面讲方案的时候我默认以128KB以上的F103为例。2. 硬件与存储空间规划先把F103的Flash算明白2.1 硬件准备和开发环境做这个项目不需要什么高级硬件我用的就是一套很常见的东西主控板STM32F103VET6最小系统板板上引出了USART1、USART2和SWD调试口下载器ST-Link V2用来烧录Bootloader和最初的AppUSB转串口模块CH340方案用来做OTA下载通道按键两个一个进Bootloader的强制升级键一个复位键LED两个一个指示当前启动分区一个指示升级状态电源5V USB供电板载AMS1117转3.3V开发环境方面有人用Keil MDK有人用STM32CubeMX加HAL库还有人坚持标准库。我的建议是Bootloader部分最好用寄存器操作或者标准库因为Bootloader要处理的启动逻辑非常接近底层用HAL库虽然能写但那一堆初始化过程在Bootloader里反而显得碍事。App部分用什么库都无所谓不影响整体方案。我自己是Bootloader用标准库App用HAL库两边互不干扰。如果你还没有单独下载过STM32F103的启动文件和标准库直接去ST官网找“STM32F10x standard peripheral library”那个3.5版本的压缩包就行网上也有人整理过单独的启动文件。F103的中文参考手册建议手上常备一份RM0008后面查Flash编程、NVIC这些寄存器会非常频繁。2.2 Flash分区表设计以512KB的F103VET6为例我最终用的分区表长这样分区名称起始地址大小说明Bootloader0x0800000032KB存放Bootloader固件App_A0x08008000224KBA分区默认启动分区App_B0x08040000224KBB分区OTA升级目标分区Meta0x0807800032KB启动标志、版本号、状态记录为什么Bootloader只给32KB因为我这个Bootloader功能非常单一串口接收、CRC校验、Flash写入、双区切换判断。用标准库编译下来不到20KB32KB绰绰有余。如果你想把固件加密、压缩解压、SD卡升级这些功能都塞进Bootloader建议留64KB。两个App分区各224KB对于绝大多数F103工程来说足够了。我的示例App编译出来大约是80KB就算以后功能翻倍224KB也能撑住。Meta区用来存放启动控制信息这个区域不跑代码只存结构体数据。这里有一个非常重要的原则分区的边界必须和Flash扇区对齐。F103大容量型号的Flash扇区大小不一致前4个扇区是16KB一个后面的扇区都是64KB一个。也就是说0x08008000刚刚好是第三个16KB扇区的起点而0x08040000正好是第7个64KB扇区的起点。如果分区地址没有落在扇区边界上擦除的时候就会把邻居分区一起擦掉这是新手最容易踩的坑。2.3 元数据区里到底要存什么Meta区是整个AB OTA方案的中枢Bootloader每次上电第一件事就是读它然后决定从哪个分区启动。我设计了一个结构体来管理这些信息typedef struct { uint32_t magic; // 固定为 0xA5A5A5A5用于判断数据是否有效 uint32_t active_side; // 当前要启动的分区0 表示 A1 表示 B uint32_t app_version; // 当前App版本号 uint32_t boot_count; // 当前分区启动次数计数 uint32_t crc32; // 结构体CRC校验 } ota_meta_t;设计的时候有几点需要考虑。首先这个结构体不能直接只存一份因为写入过程中一旦断电可能写了一半下次读出来不知道哪几个字节是对的。我的做法是存两份镜像第一份在扇区头部第二份在扇区中部Bootloader读的时候先比较两份的magic和crc32如果有一份是完整的就用那份。两份都坏了强制进入恢复模式。这相当于给元数据做了一个简易RAID 1成本只是多占几十字节空间。另外不要在Meta区频繁写入。Flash的擦写次数虽然有1万次左右但毕竟不是RAM。我的策略是只在以下时机更新Meta升级包校验完成准备切换启动分区时、App启动成功上报时、App启动失败触发回滚时。正常运行时绝不写。2.4 小容量芯片的替代方案如果芯片是128KB的F103RBT6一个Bootloader加两个App分区就会非常紧张。我试过一种变通方案Bootloader压缩到16KB每个App分区只分配48KBMeta区用最后的16KB扇区。这样App必须严格控制代码量不能上浮点运算库不能塞一堆调试打印。但即便如此48KB跑一个带FreeRTOS的简单应用也是够的。如果连128KB都没有我建议不要硬做双分区改用外部SPI Flash存固件把升级包下载到W25Q64里Bootloader校验通过后再擦写内部App区。这种做法本质上回到了单分区模型只是把“临时区”从内部Flash挪到了外部虽然没有解决覆盖过程中断电变砖的风险但至少不用在内部Flash里挤空间了。3. Bootloader侧核心实现协议、Flash写入与启动切换3.1 Bootloader的整体工作流程我的Bootloader启动流程是固定的六步代码里就是一个简洁的状态机系统初始化时钟、串口、LED、按键读取Meta区判断启动标志如果需要回滚则清除失效分区的标志检查强制升级按键按下则留在Bootloader等待上位机超时等待升级指令收到则进入下载流程没有指令则跳转对应分区关键点在第5步和第6步之间的配合。Bootloader不能无限等待升级指令否则每次开机都要卡那几百毫秒。我的做法是等到300ms如果串口收到一帧合法的升级握手指令就进入升级模式如果没有直接跳转到当前有效分区。这样正常启动只多等300ms几乎无感。强制升级按键的优先级最高。有些场景下App已经跑挂了但Bootloader上电还是会尝试跳转如果App因为硬件故障反复崩溃你也进不了升级流程。这时候按键就是最后的保险——按住按键再复位Bootloader不跳转老老实实待在升级模式里等固件。3.2 自定义升级协议设计别直接用YModem做OTA第一个摆在面前的问题是固件怎么传进去。很多人第一反应是YModem因为它现成SecureCRT就能发。但我在实际用了几次之后放弃了原因有两个YModem的包大小和应答机制是固定的我想改造包序号和校验方式会很别扭另外YModem对大数据包的断点续传支持不好传一个150KB的固件中间串口稍微抖一下就要重来。所以我设计了一个非常简单的自定义协议只适合这个场景但完全够用帧头命令字包序号数据长度数据区CRC322字节 0xAA551字节2字节2字节0~256字节4字节命令字一共定义了三种其余全部保留0x01握手请求上位机询问Bootloader是否在线0x02固件数据包携带一个分片的固件内容0x03升级完成确认Bootloader做最终CRC校验并切换分区上位机每发一包Bootloader写完Flash之后回一帧ACKACK里带上包序号方便上位机做重发判断。我用的包序号是16位从0开始递增回绕也自然。数据长度我限制为256字节也就是一包最多传256字节固件数据。为什么取256因为内部Flash写入是以半字16位为单位的256字节是128次半字写入操作时间比较适中。更重要的是Bootloader接收时用的是一个256字节的缓冲区DMA收到一包就可以直接写入Flash不需要再做二次拷贝。3.3 Flash擦除和写入的代码要点F103的Flash操作流程说简单也简单但每一步都要等状态寄存器。我直接贴一下核心代码用的是标准库的Flash接口void flash_write_buf(uint32_t addr, uint8_t *buf, uint32_t len) { uint16_t *p (uint16_t *)buf; uint32_t i, half_word_count len / 2; FLASH_Unlock(); for (i 0; i half_word_count; i) { while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) SET); if (FLASH_ProgramHalfWord(addr i * 2, p[i]) FLASH_COMPLETE) { // 写完之后立即读回验证 if ((*(volatile uint16_t *)(addr i * 2)) ! p[i]) { // 写失败记录错误并停止 } } } FLASH_Lock(); }几个细节值得注意。FLASH_Unlock()之前必须确保没有中断会调用Flash操作否则解锁状态会被打断。擦除操作放在接收一整个扇区之前做我是在收到第一包数据时检查当前写入地址是否跨扇区边界如果是就先擦除目标扇区。擦除操作在F103上需要几十到几百毫秒这段时间上位机那边要等待ACK超时重发所以协议中ACK超时时间要设置得比擦除时间长我用的500ms实测F103擦除64KB扇区大概在200ms左右。还有一点写入地址必须是偶数。Flash写入半字要求地址2字节对齐如果你发来的固件大小是奇数最后一包要填充一个字节补成偶数否则会触发HardFault。这些小细节都是我第一次跑升级流程时一个一个发现并修掉的。3.4 启动切换逻辑先“试运行”再“转正”AB OTA切换启动标志如果只是简单地把Meta里的active_side改一下那AB方案的安全性就体现不出来了。我采用的是更保守的“三态切换”策略状态一A分区正常运行状态二新固件已下载到B分区Meta记录pending等待重启验证状态三B分区验证通过正式切换为下次启动优先B详细的流程是升级包下载完之后Bootloader对B分区做全片CRC32校验。校验通过后把Meta里的active_side写成B然后强制复位从B启动。此时App_B启动后先做自检确认RAM、外设、通信这些核心模块工作正常然后通过串口向上位机发送一条“启动成功”上报指令。这条上报指令同时会通过一个约定好的RAM地址通知Bootloader之后把Meta写入“confirme”状态。如果App_B运行5秒后仍然没有上报Bootloader会在下一次复位时回滚到A分区。这套机制比单纯靠硬件看门狗更可控。看门狗只能解决程序跑飞、死循环的问题解决不了“程序没死但外设初始化失败”这类半死不活的状态。让App自己上报成功实际上是对整体系统做了一次健康确认。3.5 跳转App的具体代码从Bootloader跳转到App不是简单的一个函数指针调用需要先把系统状态恢复干净。最容易被忽略的是中断向量表位置和中断使能状态。void jump_to_app(uint32_t app_addr) { uint32_t app_sp *(volatile uint32_t *)app_addr; void (*app_reset)(void) (void (*)(void))(*(volatile uint32_t *)(app_addr 4)); // 跳转前关闭全局中断关闭外设时钟 __disable_irq(); SysTick-CTRL 0; // 把向量表指针移到App区 SCB-VTOR app_addr; __set_MSP(app_sp); app_reset(); }这段代码里有一个关键点就是SCB-VTOR必须设置为App分区的起始地址。STM32F103的Cortex-M3是支持VTOR的但有些库版本里没有定义这个寄存器需要手工加一行#define SCB_VTOR (*(volatile uint32_t *)0xE000ED08)我在第一次调试时忽略了这一点导致Bootloader跳转之后直接进HardFault后来才发现是App里所有中断都没法响应因为向量表还在Bootloader区App的中断服务函数地址对不上。跳转前是否需要把所有外设时钟关闭严格来说复位处理器会重新跑SystemInit大部分外设状态无所谓但前提是你在App里不要再依赖哪个外设是初始化好的。为了稳妥跳转前把用到的DMA、串口、定时器全部DeInit然后喂一次看门狗再跳避免App跑起来之后被Bootloader遗留的看门狗咬死。4. App侧改造向量表重映射与升级握手4.1 App的编译地址和向量表重定位App和普通工程最大的不同在于它不再是链接到0x08000000而是要链接到分区对应的地址。我以App_A为例假设分区起始是0x08008000在Keil MDK里Options for Target - Target - IROM1起始地址填0x08008000大小填0x00038000224KB如果是GCC链接脚本中的FLASH ORIGIN设为0x08008000LENGTH设为0x00038000这一步如果不改App编译出来默认运行地址还是0x08000000从Bootloader跳过去之后取指全错根本跑不起来。App上电之后的初始化代码里要在进入main函数后的最早位置设置向量表void app_init_vector_table(void) { SCB-VTOR 0x08008000; }必须在任何外设中断使能之前设置否则中断请求一旦到来CPU还是去老地址找向量表直接崩。另外要注意如果App里用了lwip、文件系统这类带很多回调的库它们的中断处理依然遵循同一个向量表所以VTOR设置好之后所有中断源都能统一找到入口。4.2 App与Bootloader的升级握手方式App运行当中通过网络、串口或者其他通信方式收到上位机的升级指令后不能直接擦写Flash因为App本身所在的Flash分区正在被执行不能一边跑代码一边擦自己。正确做法是App把升级指令的合法性验证一遍然后把一个标志写入RAM中的约定地址然后软复位。Bootloader上电后检查这个RAM标志如果有升级意图就进入升级模式等待固件包。RAM地址怎么约定简单粗暴的方式是直接用SRAM末尾的一个地址比如0x20000000之后的某个区域。但更稳妥的做法是定义一个没有被系统启动文件占用的位置。我在工程里用的是0x20000000 SRAM大小 - 16这种接近末尾的地址因为这个区域通常不会出现在正常程序的堆栈中。App写入一个结构体typedef struct { uint32_t app_flag; // 0x4F544100 OTA\0 uint32_t target_side; // 0为Bootloader自动选择1为强制升级到B } ota_handshake_t;Bootloader在跳转App之前会先把RAM中这个标志清除防止上一次升级意图在下次复位时又被误判。有人会问为什么不用串口命令让Bootloader一直等着因为App可能正跑在一个通信链路上上位机不一定能直接访问Bootloader的串口。由App接收升级指令然后自己复位交给Bootloader这种方式对链路层完全透明TCP、MQTT、RS485都可以只要App能把一条“升级确认”传到底层就行。4.3 启动成功上报与回滚触发前面提到过App_B启动后要上报启动成功。这个上报可以用两种方式串口主动发一条指令给上位机上位机记录并显示升级成功同时写一个RAM标志告诉Bootloader“我已经健康运行了几秒”我更推荐第二种方式作为内部依据。具体做法是App_B在main开头设置一个状态变量表示“正在验证”然后启动一个5秒的定时器。如果5秒内系统没有发生HardFault、没有看门狗复位、主循环也没卡死就调用一个函数把Meta区更新为“App_B验证通过”。这里的更新通过Flash写入完成相当于给AB状态“转正”。如果App_B在5秒内崩溃了看门狗会复位系统Bootloader再次上电时发现Meta区里active_side还是B但验证状态没有变成confirmed于是启动计数加一。如果启动计数超过3次仍然没有confirmedBootloader就认定App_B有问题自动把active_side切回A并把启动计数清零。这个3次限制是为了防止“明明能启动但是上报链路有问题导致永远切不过去”的极端情况。实际用下来3次足够区分“一次偶然失败”和“固件真的有问题”。5. 全流程串测记录与问题复盘5.1 从编译产物到升级包的生成链路升级包不能直接用编译出来的hex文件上位机那边需要的是一个带头部信息的bin文件。我的打包流程是四步从Keil/GCC编译出App的hex文件用fromelf --bin或者arm-none-eabi-objcopy -O binary把hex转成bin对bin文件做对齐补零到256字节整数倍追加一个40字节的固件头包含固件长度、CRC32、版本号、目标分区等信息固件头很重要。Bootloader在接收前先读固件头里的CRC32然后在写入过程中边写边算最后全片比较。如果头部CRC和实际数据对不上直接拒绝启动切换。我用的固件头结构体长这样typedef struct { uint32_t magic; // 0x4F544146 OTAF uint32_t version; // 固件版本号比如 0x01010001 uint32_t length; // 固件有效数据长度 uint32_t crc32; // 固件数据CRC32 uint32_t target; // 目标分区0x41 表示A0x42 表示B uint8_t reserved[16]; // 保留 } fw_header_t;实际发送固件的时候上位机先发固件头接着按256字节分片往后面发数据。Bootloader收到后把固件头里的元信息存到自己的Meta区预备区域等整个固件Data发完再做一次全量CRC对比。5.2 双区交替升级实测用例工程搭好后我按下面的用例跑了一轮完整测试整个过程都在串口日志里留了详细记录用例一从A分区升级到B分区Bootloader当前启动的是A分区版本号v1.0。上位机发送v1.1固件目标是B。Bootloader接收完成后校验CRC然后将active_side置为B复位后从B启动App_B上报成功Meta区更新为“B已确认”。此时再断电重新上电仍然启动B。用例二B分区验证失败自动回滚在App_B里故意加一个while(1)死循环看门狗5秒后复位。Bootloader第一次检测到B启动但未确认启动计数1第二次、第三次同样如此第四次Bootloader把active_side切回A启动A分区串口打印“Rollback to A”。这个流程全程无需人工干预证明AB机制的抗崩溃能力是有效的。用例三传输过程中断点断电在升级数据传到一半的时候直接给板子断电。重新上电后Bootloader发现Meta区中active_side仍然指向AB分区虽然有一部分新数据但未被标记为有效于是直接启动A。这种场景下单分区方案基本九死一生但AB方案下只是掉了一次升级而已没有任何副作用。用例四从B分区再升级回A当B已经在运行时上位机再发一个v1.2固件目标是A。Bootloader会把v1.2写入A分区校验通过后active_side切回A复位后从A启动。这样就实现了AB两个分区交替升级整个过程和第一次升级完全对称。四个用例跑下来整个机制的稳定性我心里才有底了。5.3 串测过程中抓到的一个典型Bug有一个Bug在第二版测试时反复出现偶发性的固件校验失败但每次失败的字节位置都不同。排查了很久最后定位到是接收缓冲区覆盖问题。我的Bootloader接收数据用的是串口DMADMA每收到256字节就触发一次传输完成中断中断里把DMA缓冲区的内容写入Flash。问题出在如果上位机发下一包的速度太快第一包还在Flash写入的半字循环里第二包就已经通过DMA把缓冲区内容覆盖了。由于我只有一块256字节的接收缓冲区DMA一旦搬入新数据上一包残留的数据就被冲掉导致写入Flash的数据是混合的。解决办法有两个一是加双缓冲一块DMA在接收另一块在Flash写入两块交替使用二是给上位机加上ACK流控Bootloader每写完一包才回一个ACK上位机收到ACK再发下一包。我实际用的是方案二因为实现起来简单而且对115200波特率下的升级效率影响极小。算下来发一个256字节的包串口传输本身要22msFlash写入256字节约5ms加上ACK往返每包总耗时约40ms传一个200KB的固件也就30秒左右完全可接受。5.4 升级耗时到底花在哪里升级耗时是很多人会在项目评审时问的问题。我拿115200波特率、8N1的配置给一个150KB的App做了实测CPU主频72MHz阶段耗时说明串口传输150KB约15秒115200bps理论值14.7秒全片CRC32计算约1.2秒对224KB空间逐字节计算约0.4秒Flash擦除约0.4秒4个64KB扇区擦除Flash写入约3秒150KB半字写入总耗时约20秒含握手、确认、复位等开销20秒对一次现场升级来说不算快但完全可以接受。如果换成921600波特率传输时间可以压到2秒左右但前提是你的串口线质量和上位机调度都够稳否则误码率上升会让重传次数变多反而更慢。6. 另一批坑中断偏移、看门狗、擦除对齐与服务器配置6.1 中断向量表偏移的坑我最终怎么绕开的前面在跳转代码里提到了VTOR但这只是其中一个坑。更隐蔽的是App本身的启动文件startup_stm32f10x_hd.s里面定义了初始向量表并且在系统复位后会把__initial_sp和Reset_Handler放在最前面。如果你的App编译地址是0x08008000但某个中断服务函数地址被编译器做成了绝对地址引用那在中断响应时CPU会从向量表里读取这个函数的地址。向量表在Flash里位置跟着VTOR走所以VTOR不设置中断进不去这是最经典的坑。但还有一层如果App里用了相对寻址或者Keil的“微库”优化某些编译器版本可能默认生成直接从0x08000000读取向量表的代码导致即使设置了VTOR中断向量还是错乱。我当时试过在SystemInit函数里加VTOR赋值也试过在Reset_Handler里手工调整最后确认最可靠的做法是在SystemInit最后一行调用一个自己写的app_set_vector_table()并且在编译器层面关闭微库优化。如果你不想在App里写任何VTOR相关的代码还有一个土办法在Bootloader跳转之前直接把App分区的起始地址写到NVIC_VTORApp里面就完全不需要管了。但这个方案有个问题如果App上电之后又发生了什么异常或者再次复位VTOR会被复位到0x08000000所以还是建议App自己再设置一次双保险。6.2 看门狗在OTA升级期间到底要不要喂这个问题我纠结了很久也翻了不少论坛。F103如果开了IWDG默认喂狗时间可能只有1秒左右。Bootloader在擦除Flash时会有几百毫秒的停顿如果擦除期间看门狗复位了升级就中断了。我当时一开始想在整个接收过程中关闭看门狗后来发现这会让AB机制的安全回滚意义大打折扣——如果升级过程中系统死了看门狗不工作那就永远卡死在那里了。我的最终策略是Bootloader里也开看门狗但把溢出时间设置成5秒并且在擦除Flash之前特意先喂一次狗。擦除64KB扇区实测在200ms内完成5秒的窗口完全够。App里维持原来的1秒看门狗这样App_B如果跑飞最多5秒内就会被Bootloader带回回滚流程。如果不想在Bootloader里维护看门狗还可以通过跳转前把看门狗寄存器改掉来实现但我觉得不如直接设置独立看门狗可靠。这里有个细节写Flash的过程中CPU的Flash控制器会被占用看门狗时钟来自独立的LSI不会受Flash操作影响所以看门狗在擦写Flash期间是正常计数的。你千万别以为写Flash时CPU卡住看门狗就暂停了恰恰相反它跑得比程序还勤快。6.3 Flash扇区边界不对齐后果很直接前面在分区表那里提到过扇区对齐但实际操作中还有一个容易错的地方就是Bootloader在接收固件时按256字节一包写入连续写三四个包之后地址可能从一个扇区越到下一个扇区。如果你的写入流程是“先擦目标扇区再一包一包写”那么跨边界的时候必须提前擦除下一个扇区否则写入会失败。我在代码里维护了一个当前扇区变量每次调用写入函数之前都检查if ((write_addr ~0xFFFF) ! (last_sector_start)) { FLASH_ErasePage(write_addr); last_sector_start write_addr ~0xFFFF; }注意F103大容量型号的扇区不是统一的0x10000前4个扇区是0x400016KB所以更好的做法是先判断当前地址落在哪个扇区索引里再决定擦除起点。这也是为什么我把Bootloader放在0x08000000到0x08008000因为这样前4个16KB扇区全部给Bootloader用从0x08008000开始都是64KB大扇区逻辑简单很多。6.4 固件下载服务器和上位机的隐藏坑虽然F103大部分场景还是走串口升级但我在整体方案里加了网络服务器下载固件的选项——设备端把固件从服务器拉回来放到串口Bootloader的握手通道里。做这部分的时候踩了一个“上线即失败”的经典坑我用nginx作为固件服务器配置了gzip压缩结果App从服务器拉下来的bin文件在本地校验一直CRC错误。排查到最后发现nginx会自动对.bin后缀的文件做gzip压缩传输虽然HTTP协议里有Content-Encoding头但设备端的HTTP客户端没有解压gzip的能力拿到的直接是压缩后的数据。解决办法是在nginx配置里对.bin和.fw后缀关闭压缩location /fw/ { gzip off; proxy_buffering off; add_header Cache-Control no-store; }另外升级服务器上的固件版本管理也要讲究。不要直接覆盖同名文件应该按版本号和分区命名比如app_v1.2_a.bin、app_v1.2_b.bin。我在测试时因为文件名没区分目标分区差点把A分区的固件发到B去了好险。Nginx这类服务器的超时设置也要注意设备端每包都会等待ACK但服务器和应用层之间可能有TCP代理代理超时设置太短会把长连接断掉导致升级中途掉线。我直接把相关超时参数调到300秒稳定多了。6.5 调试技巧没有逻辑分析仪也能高效定位最后分享几个调试AB OTA时的土办法对没有高价仪器的人特别有用。第一个是善用现有串口打印。我在Bootloader里做了等级化的调试输出用不同的前缀区分正常状态、警告和故障。比如[BOOT] Ready, activeA、[BOOT] CRC check fail, sector 4、[BOOT] Rollback to A due to boot count3。这些日志在串口测试时非常直观比用调试器单步跟踪快得多。第二个是做一个升级状态LED。Bootloader在擦除Flash时让LED快速闪烁写入时慢速闪烁CRC校验时熄灭校验通过后常亮2秒。不用开终端都能知道当前卡在哪一步。第三个是写一个上位机脚本用Python做完整的升级流程测试自动发握手、分片发送、等待ACK、统计重传次数和总耗时。这个脚本我后来直接变成了生产环境用的升级工具只是换了个GUI壳。实践下来这类自动化脚本的价值不亚于Bootloader本身的代码尤其是做异常注入测试的时候——你可以让脚本在任意包序号处停止发送甚至模拟中途断电来验证Bootloader的容错逻辑。整个AB OTA从设计到稳定跑通我前前后后改了四版其中两版都是因为在真机上跑了一遍才发现方案里某个假设不成立。但也正是这些实测中暴露出来的问题让这套机制从“看起来挺安全”变成了“真正敢拿去给客户远程升级”。目前这套方案已经在几个现场正常服役远程升级了多次没有一次需要返厂救砖。对我个人来说这就是做这个项目最大的回报。
返回列表