资讯动态

STM32 AB OTA实战:从Bootloader到A/B分区升级与回滚

发布时间:2026/9/6 8:44:48 来源:尧图企业网站定制
1. 为什么要折腾AB OTA从单分区升级的痛点说起先说个我自己的经历。去年做一款基于STM32F103的采集设备固件升级用的是最传统的方式——Bootloader 单App分区串口接收完整个固件擦除整片Flash写入跳转。逻辑很简单但真正上线之后问题就来了升级过程中一旦断电或者串口传输中间被干扰导致校验失败设备直接变砖。虽然Bootloader保留着但App区已经被擦掉或者写了半截设备起不来只能返厂用SWD重新烧录。那种场景下你会特别想要一个机制升级失败我能退回去跑老版本。这就是AB OTA方案的核心价值——同时保留两个可运行的App副本当前运行一个另一个用来接收新固件。新固件写入完成后通过标志位切换启动目标如果切换后系统无法正常运行自动回滚到旧版本继续跑。这期从零复现的教程基于STM32F103标准库std v3.5梳理完整链路Flash分区规划、Bootloader跳转逻辑、App侧改写与中断向量偏移、升级包传输设计与校验机制、异常回滚策略。全部用最基础的外设做完不依赖复杂的RTOS也不依赖商业库Cortex-M3内核这一套跑明白了换到其他M3/M4芯片一样能迁移。适合什么基础的人看熟悉STM32基本开发GPIO、USART、Flash操作想给产品加上远程升级能力或者想理解商用OTA方案底层原理的工程师。看完以后除了能照着复现更重要的是理解方案取舍的原因——为什么Bootloader只能做固定跳转、为什么标志位要放在固定地址、为什么校验必须在写入过程中做而不能只靠传输层校验——这些想通了换平台、改协议都只是体力活。2. 复现前必须想清楚的区域划分和启动链路2.1 STM32F103的Flash容量约束与AB分区的现实取舍STM32F103系列Flash容量从64KB到512KB不等。AB分区方案对Flash占用是双份App的量加上Bootloader区域。以最常见的STM32F103C8T6为例64KB Flash实际可用通常是64KBC8T6非互联型区域起始地址大小用途Bootloader0x0800000016KB固件接收、校验、跳转、回滚App A0x0800400024KB当前主运行版本App B0x0800A00024KB备用/新版本写入区注意这里Flash分区不是随便画个表就行地址必须是Flash擦除粒度的整数倍。F103的Flash扇区是1KB小容量或2KB中容量建议按照最大扇区对齐这样擦除操作才不会跨分区。实际项目里如果App编译出来超过24KB怎么办两个选择压缩业务代码或者换大Flash型号比如F103RET6512KB。AB方案的第一约束条件就是双份App空间这一点在选型时就得算进去。2.2 启动链路从冷启动到App跳转的完整状态流冷启动上电/复位 │ ▼ Bootloader 入口 │ ├─ 检查 App_A 的 Magic 标志有效/无效 ├─ 检查 App_B 的 Magic 标志 ├─ 启动计数器判断当前启动是否成功 │ ├─ 场景1当前分区有效且启动成功计数正常 │ └─ 跳转到当前 App 执行 │ ├─ 场景2当前分区升级后启动失败计数器超限 │ └─ 切换另一分区回滚 │ └─ 场景3两个分区均无效 └─ 进入串口升级等待模式Bootloader常驻跳转的核心不是简单的函数指针调用而是重置中断向量表并重新定位栈指针。Cortex-M3内核上电后从0x08000000读取初始SP从0x08000004读取复位向量。同理跳转到App时Bootloader必须从App区的偏移地址取出对应的SP和PCtypedef void (*pFunction)(void); pFunction jumpToApp; // 从App分区起始地址获取栈顶指针和复位向量 uint32_t app_sp *(volatile uint32_t *)(APP_A_ADDR); uint32_t app_pc *(volatile uint32_t *)(APP_A_ADDR 4); // 检查栈顶指针是否在RAM范围内简单合法性校验 if ((app_sp 0xFFF00000) ! 0x20000000) { // 栈指针非法说明该分区没有有效App return; } jumpToApp (pFunction)app_pc; __set_MSP(app_sp); // Cortex-M3内核专用设置主栈指针 // 跳转前必须关闭所有已开启的中断 __disable_irq(); jumpToApp();2.3 App侧必须改的三件事中断向量偏移、链接脚本、编译地址很多人第一次做OTA失败问题出在App侧只改了烧录地址却没有改中断向量偏移。STM32F103中断向量表默认定位在0x08000000App跑在0x08004000如果不改中断一发生CPU跳到Flash起始处执行的是Bootloader代码后果就是中断错乱、设备卡死或者莫名复位。标准库常用的做法是在main函数最开头调用NVIC_SetVectorTableNVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x4000);但是注意这个函数在较新的标准库版本里有更新替代方案是通过SCB-VTOR直接设置SCB-VTOR APP_A_ADDR; // 0x08004000我建议直接用后者。原因有两个第一SCB-VTOR是Cortex-M3内核标准寄存器移植到mbed、HAL库环境同样适用第二少了中间层封装时序上更可靠。另外两个必须同步修改的地方链接脚本分散加载文件。使用Keil MDK时在Options for Target - Target页签把IROM1的Start地址改为0x08004000Size改为0x600024KB。使用GCC工具链则修改链接脚本中FLASH的ORIGIN为0x08004000。编译下载地址与Bootloader跳转地址一致性。App编译链接的基地址必须是0x08004000Bootloader跳转时读的也是0x08004000处的向量表两个不一致跳了就飞。还有一个容易忽略的点如果在App里使用了外部中断、定时器中断、DMA中断等需要在启动早期完成向量表重定位。最好的位置是在SystemInit之后、所有外设初始化之前。曾见过有人把SCB-VTOR放在main函数最后结果系统启动过程中一个串口中断到达程序立刻跑飞——这种Bug查起来极其隐蔽。3. Bootloader完整实现:所有标志位、接收与校验逻辑3.1 升级标志位布局与Magic值设计AB方案的核心是Bootloader需要回答三个问题当前从哪个分区启动另外一个分区是否可用上次启动是否成功我用固定地址的非易失标志来管理。在Bootloader区域的末尾0x08003F00~0x08003FFF预留256字节参数区地址偏移内容大小说明0x08003F00Magic_A4字节固定值0xA5A5A5A5表示App A已写入0x08003F04Magic_B4字节同上App B0x08003F08Boot_A_Count4字节当前启动分区计数0x08003F0CBoot_B_Count4字节同上0x08003F10Upgrade_Flag4字节0x01表示A→B0x02表示B→A为什么不把标志位放在App分区内部因为App区域每次升级都会被擦除数据不可靠。放在Bootloader区域Bootloader自身不擦除从设计上保证了标志位的稳定性。3.2 启动计数与自动回滚的容错判断逻辑升级完新版本系统启动后应该运行稳定。但如果新版本存在致命Bug一引导起来就崩溃Bootloader如何知道答案是启动计数机制。#define APP_RUN_OK_COUNT 5 void bootloader_main(void) { // 启动次数累加 if (current_boot_count APP_RUN_OK_COUNT) { current_boot_count; save_flag(current_boot_count); } if (current_boot_count APP_RUN_OK_COUNT) { // 启动稳定继续正常模式 jump_to_app(current_app); } else { // 启动计数未满说明引导过程有问题或App运行不稳定 rollback_to_another_app(); } }同时需要App侧主动上报运行状态。我在App里放一个简单的运行健康检查——每正常运行10秒就清理一次启动计数把计数清零。App崩溃或无法启动时计数不会清零Bootloader在下一次上电时发现计数达到阈值自动切换分区。这里有一个坑启动计数不能在Bootloader一进去就清零否则App一启动就崩溃计数已经被清掉了Bootloader会误判为“上次运行正常”而再次启动崩溃的App。正确做法计数在Bootloader阶段累加App健康运行后才清零。伪代码流程// Bootloader阶段 void perform_boot_count(void) { uint32_t cnt read_flag(current_boot_count_addr); cnt; write_flag(current_boot_count_addr, cnt); if (cnt FAIL_THRESHOLD) { // 切换分区并清零计数 switch_partition(); write_flag(current_boot_count_addr, 0); jump_to_other_partition(); } else { jump_to_current_partition(); } } // App阶段App代码内经过健康检查后主动调用 void app_running_ok(void) { write_flag(current_boot_count_addr, 0); // 清零 }阈值设置不是死的。项目要求开机后10秒内完成联网、日志初始化、核心业务加载那么健康检查窗口设置为20秒Bootloader的阈值设置为3~5次这样一个明显跑不起来的App最多造成2~3次自动重启尝试之后就被旧版本替换。3.3 Flash擦写操作中容易被忽视的时序细节STM32F103的Flash写入有几个操作顺序要求标准库的FLASH_ProgramHalfWord已经封装好了但有些细节还是要自己注意void flash_write_word(uint32_t addr, uint32_t data) { FLASH_Unlock(); // 解锁Flash控制寄存器 FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); // F103按16位半字写入 FLASH_ProgramHalfWord(addr, (uint16_t)(data 0xFFFF)); FLASH_ProgramHalfWord(addr 2, (uint16_t)(data 16)); FLASH_Lock(); // 写完加锁 }单个半字编程时间大约几十微秒擦除一个扇区1KB/2KB约几十毫秒。如果升级过程中串口还在持续接收数据F103单核处理器的CPU被Flash操作占住时串口接收中断会得不到及时响应导致USART_DR溢出。所以实际的接收流程要设计成双缓冲接收 Flash操作在空闲间隙执行或者直接采用“接收完整帧到RAM缓存再统一写入Flash”的批次策略。我这里采用的批次策略是每接收一帧256字节数据先放到RAM缓冲等累积到4帧1KB后统一进行擦写。这样既避免频繁擦写Flash又不会长时间占用CPU导致串口丢数据。#define FRAME_DATA_LEN 256 #define FLASH_SECTOR 1024 // 1KB对齐 uint8_t ram_buffer[FLASH_SECTOR]; uint16_t ram_buffer_pos 0; void on_frame_received(uint8_t *data, uint16_t len) { memcpy(ram_buffer[ram_buffer_pos], data, len); ram_buffer_pos len; if (ram_buffer_pos FLASH_SECTOR) { // 写入Flash前先擦除目标扇区 FLASH_ErasePage(target_address); for (int i 0; i FLASH_SECTOR; i 2) { FLASH_ProgramHalfWord(target_address i, *(uint16_t *)ram_buffer[i]); } ram_buffer_pos 0; target_address FLASH_SECTOR; } }另外注意写入时不能操作Flash控制寄存器所在的中断向量表区否则会触发硬件错误。写入过程中要关闭全局中断或者至少保证中断处理函数不会访问Flash操作相关代码。3.4 Bootloader中的看门狗与死循环兜底商用OTA最怕的不是升级过程慢而是Bootloader“沉默”了——既没跳转到App也没进入升级状态卡在某个循环里。用IWDG独立看门狗做一个保底。Bootloader运行时每500ms喂狗一旦进入升级接收流程喂狗任务交给主循环。如果发生串口长时间不传数据、死循环等异常看门狗复位系统重新走Bootloader主流程保持可升级状态。这个兜底在调试阶段可能觉得多余但在产品长期运行时非常关键。已接入的设备可能无人值守一旦Bootloader死掉只能返厂代价极高。4. 上位机升级包设计、传输协议与断点续传细节4.1 升级包结构划分头部 一包 一CRC用Bootloader全量接收整包固件时先定义统一的升级包格式字节偏移 内容 0x00 文件魔数2字节0xAA55 0x02 版本号4字节如0x00010002 0x06 固件长度4字节 0x0A 固件CRC324字节 0x0E Reserved2字节 0x10 固件数据长度由固件长度字段决定版本号可以做版本比较防止低版本覆盖高版本。CRC32用作整体校验确保传输过程中不会出现数据错误。4.2 串口传输帧设计:为什么每帧必须携带CRC16底层串口传输使用Modbus RTU的帧格式做参考毕竟F103和FreeModbus是经典组合但略有调整字段长度内容帧头2字节0xAA55包序号2字节当前帧序号从0递增总包数2字节所有数据包总数数据长度2字节本帧数据字节数≤256数据体N字节固件数据CRC162字节前序所有字节的CRC16-Modbus关于校验做AB方案初期我有一种“反正最后有整个固件CRC校验单帧校验没必要”的想法结果被现实狠狠打脸。串口传输过程受干扰产生的单个bit翻转不影响最终的文件长度但会导致固件内容被悄悄篡改。虽然在写入Flash前整个固件也会再算一次CRC32但如果要等整个固件接收完才发现这一帧数据错了重传整包的成本太高。所以每帧传输必须携带CRC16Bootloader收到后立即校验错误则丢弃并请求重传。// 上位机发送后的重传机制 void handle_crc_error(uint16_t frame_index) { // 发送重传请求帧 send_retransmit_request(frame_index); // Bootloader等待重传超时后继续等待带看门狗保底 }利用包序号总包数Bootloader还可以实现断点续传。比如传了100包突然断电重启后Bootloader检查Flash中已有的数据偏移让上位机从断点处继续发送。这个功能一开始没做但考虑到项目实际使用场景嵌入式设备经常在恶劣供电环境运行后来补上了。4.3 上位机Python发包脚本的核心思路上位机我用Python实现了一个简单的命令行工具核心逻辑分为下面几步import serial import struct import crcmod def build_packet(seq, total, data): frame bytearray() frame b\xAA\x55 frame struct.pack(H, seq) frame struct.pack(H, total) frame struct.pack(H, len(data)) frame data crc16 crcmod.predefined.mkCrcFun(modbus)(frame) frame struct.pack(H, crc16) return frame def send_firmware(port, baudrate, bin_path): with open(bin_path, rb) as f: firmware f.read() total_packets (len(firmware) 255) // 256 for seq in range(total_packets): chunk firmware[seq * 256: (seq 1) * 256] packet build_packet(seq, total_packets, chunk) ser.write(packet) # 等待ACK或重传请求 response ser.read(2) if response bOK: continue elif response bRT: # 重传当前帧最多重试5次 retry_count 0 while retry_count 5 and response ! bOK: ser.write(packet) response ser.read(2) retry_count 1实际工程中这个脚本还应支持从二进制文件读取版本号、在固件末尾追加签名信息等。不在本文展开先把传输链路跑通才是正题。5. 上电实测从Bootloader引导到A/B分区切换的完整验证5.1 最小验证环境搭建材料清单STM32F103C8T6最小系统板蓝丸/黑丸均可USB转TTL模块注意F103串口电平是3.3VSWD调试器ST-Link V2或DAP-Link若干杜邦线接线方式STM32F103 USB转TTL PA9 (USART1_TX) --- RXD PA10 (USART1_RX) --- TXD GND --- GND调试时Bootloader使用USART1波特率1152008N1。5.2 分步验证流程Bootloader跳转、AB切换、异常回滚分步验证很重要千万不要一上来就做完整AB链路测试出了问题很难定位。第一步验证Bootloader能稳定跳转到App A把App A通过SWD直接烧录到0x08004000地址。注意烧录时要选择“擦除扇区”或“全片擦除”而不是只烧录App。然后在Bootloader中设置当前启动分区为A复位观察串口是否打印App A的启动日志。预期结果App A正常启动串口打印APP A RUNNING。问题排查没有输出检查跳转前是否关闭了中断。串口输出乱码检查波特率不一致。跳转后HardFault检查App侧是否有正确的SCB-VTOR设置以及编译链接地址是否为0x08004000。第二步验证升级流程A运行B区写入新版本在App A运行时通过上位机往Bootloader发升级指令Bootloader接收完整个固件包写入App B分区然后把启动标志切到B。复位后观察是否运行的是App B。预期结果复位后串口打印APP B RUNNING说明B区固件写入成功并正常引导。第三步验证回滚机制B区固件故意搞成起不来编译一个App B版本故意在main函数里加一个死循环或者直接触发HardFault。升级进去复位观察第一次上电Bootloader启动B区B区崩溃复位看门狗或HardFault复位。第二次上电Bootloader发现启动计数超标自动切回A区正常运行。记录一下验证结果我在F103上的实测数据是Bootloader判断回滚的典型耗时在100ms以内从发现启动计数超标到跳转到A区用户几乎无感知。5.3 实测中需要盯住的几个数据指标指标实测值说明Bootloader代码占用约12KB包含升级接收、校验、跳转全部逻辑24KB App分区传输时间约12.5KB/s115200bps实际约2秒Flash擦写时间24KB约1.2秒含扇区擦除写入回滚切换耗时100ms冷启动时快速判断切换6. 复现过程中最值得记录的五个坑6.1 中断标志位残留导致升级失败升级接收过程中会开启串口接收中断、DMA中断。写Flash期间我选择关闭全局中断等写完了再打开。问题来了关闭中断期间到达的串口数据不会自动丢失但相关中断标志位会置1重新打开中断后ISR会立即被触发一次读取早已过时的数据造成帧错位。解决方法是打开中断前清空接收缓冲和错误标志__disable_irq(); // Flash 擦写 __enable_irq(); USART_ClearFlag(USART1, USART_FLAG_RXNE | USART_FLAG_ORE); // 再恢复接收状态 USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);6.2 App链接地址与实际跳转地址的不一致这个坑是最隐蔽的。用Keil开发时修改了IROM1起始地址为0x08004000但如果工程里还引入了某个静态库.lib而该库是在默认地址0x08000000下编译的链接时就会生成错误的地址引用。App跳转后一执行到该库函数地址错乱直接HardFault。查这个问题的办法并不高端——看Map文件。在Keil里面编译后打开.map文件搜索0x08004000检查所有代码段是否都定位在App地址空间内。如果出现0x08000000地址的段检查对应的库文件是否需要重新用偏移地址编译。6.3 Flash写入过程中串口数据丢失的并发问题前文提过双缓冲批次写入但还是有人图省事一帧一写。实际测试下来115200bps下每帧间隔时间约22ms单帧写入Flash256字节半字编程约2毫秒。看起来来得及但这种计算忽略了一次擦除整页和连续写入的累积时间。如果收到一帧就擦写一页Flash操作总耗时可能超过100ms串口FIFO只有1字节深度的USART_DR根本扛不住。最稳妥的还是批量收满1KB统一擦写一轮。用批量策略后实测串口长时间传输不再丢数据。6.4 升级过程断电的恢复能力测试AB方案的最大价值是掉电恢复。为了验证这一点我在升级过程中人为拔电测试了多次第一次拔电B区只写了1KB标志位还没改。重启后A区正常运行B区数据无效。第二次拔电B区已经写完但标志位写了一半字节序被打断。重启后Bootloader检查Magic失败按无有效B处理依旧跑A区。第三次拔电B区写完且标志位已设置但新固件FileCRC无效。Bootloader在跳转前检测CRC失败清掉标志位跳回A区。这类测试非常重要。实际工程中可能发生更奇特的掉电点——刚好在Flash写标志位那一条指令执行一半时掉电可能导致标志位处于“半写”状态。为此我采用了双字段冗余校验每个标志写两次读取时两个值一致才认为有效。成本只是一点Flash空间换来的是可靠性。6.5 启动计数与App健康检查交互的超时设定启动计数的逻辑看着简单实际调参环节很多Bug。比如健康检查的超时时间设置得多长设太短App还在初始化外设比如等待网络模块就绪尚未执行到健康检查计数被误判为失败导致不必要的回滚。设太长真正有问题的App会反复重启多次才回滚用户等待时间变长。我的经验是以App中最慢的一个初始化动作为基准外加20%余量。比如设备等待网络模块超时时间为10秒健康检查就设在12秒左右。F103主频72MHz跑这些逻辑毫无压力重点是根据业务场景灵活调整。7. 总结下来AB OTA的关键决策点回头看AB OTA并不是一个特别复杂的方案真正决定它成败的是几个决策点的取舍决策点推荐选择备选方案Flash分区制式A/B双分区支持回滚单分区外置备份Flash占用少但逻辑复杂标志位存储位置Bootloader区域末尾独立EEPROM/Flash模拟EEPROM传输校验粒度帧级CRC16 整体CRC32仅整体CRC32出错时整体重传启动计数阈值3~5次无计数直接靠看门狗升级包存储直接写入备用分区先存外部Flash再搬运空间占用大这些决策没有绝对的对错而是基于项目约束Flash容量、可靠性要求、现场维护成本做的平衡。比如Flash有128KB以上的芯片完全可以在两个分区基础上保留第三块存储区用于暂存升级包进一步增加容错。在我复现这个方案后给我最大的启发是OTA不是“下完固件重启”这么简单而是一个涉及存储规划、异常处理、用户行为预期的完整状态机设计。把这个状态机想清楚剩余的工作量其实都在写代码——这反而是最简单的部分。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价