资讯动态

STM32F103 A/B分区OTA升级方案:从Bootloader设计到回滚机制完整解析

发布时间:2026/9/6 10:48:51 来源:尧图企业网站定制
先把话说在前头如果你的产品已经上了远程OTA但是方案还是“Bootloader直接擦掉旧App区、然后从头写新的”我劝你认真看一下A/B分区。去年我有一台已经部署在现场的控制器就因为一次OTA过程中用户那边意外断电设备彻底变砖最后是售后带着烧录器跑了几百公里才救回来。那次之后我把整个升级方案推翻重新设计了基于STM32F103的A/B双分区OTA方案。这篇文章就是那次重构的完整复现教程。我会从内存布局、Bootloader状态机、串口升级通道、固件制备发布链路、再到实际踩坑把整个链路一条条拆开讲清楚。方案不依赖外部Flash纯靠F103片内资源就能实现适合正在做量产设备远程升级、又不想把可靠性寄托在“用户运气好”上的嵌入式工程师。1. 为什么我放弃了“单分区直接刷”的OTA方案1.1 一次断电让设备变砖的教训先说那次事故的具体过程。设备是一个基于STM32F103ZET6的控制器Flash 512KBApp固件大约180KB。当时的OTA流程很常规上位机把整个固件包通过串口发给BootloaderBootloader收到后先擦除App区再一包一包写入写完后直接跳转新App。逻辑上没问题问题出在“新固件还没写完、现场突然断电”这个场景。单分区方案最致命的一点是擦除动作发生在写入之前而且是整片擦除。只要擦除完成后、新固件完整写入前的任意时刻断电设备就只剩一个空白App区上电后Bootloader找不到任何可运行固件直接变砖。那次现场断电发生在写入进度大约55%的位置非常尴尬既不能退回旧版本也没有新版本可用。事故之后我认真算了一笔账省掉的那一半Flash空间换来的是每一次升级都要赌供电稳定这在工业现场根本不成立。1.2 A/B分区和单分区的本质区别A/B方案的核心思想其实一句话就能概括任何时候Flash里都保留一份“上一次正常运行”的固件。具体做法是把App区划分为A、B两个独立分区Bootloader启动时根据标志位决定从A区还是B区加载。升级时永远只写“当前没在运行”的那个分区写完后也不立即切换而是等App自己确认“我跑起来了、功能正常”才正式把启动标志切过去。和单分区方案相比A/B有四个明显优势升级失败可以自动回滚不需要人工干预升级过程中断电顶多废掉一个空闲分区另一个分区仍然可启动不依赖外部备份芯片或额外存储介质F103片内Flash就能实现只要在线升级过一次下次升级失败时的自救能力是天然自带的不需要额外逻辑保障。代价也很直观Flash占用翻倍、Bootloader逻辑变复杂、编译和发布流程要多生成一个分区的固件包。但这笔账在“一次售后远程出差成本动辄上千”的现实下完全是划算的。1.3 什么场景下A/B方案才值得做A/B方案不是银弹。如果芯片Flash本身只有64KBApp又占到45KB以上强行划分两个App分区会让每个分区小到没法用。我的建议是A/B方案优先使用在Flash≥128KB、App固件小于单个分区容量一半左右的设备上。如果你的产品是超低成本的消费类小家电升级失败最坏情况就是返厂那单分区加CRC校验勉强也能用但如果是工控、电力、医疗、物联网网关这类“坏了就必须现场处理”的场景直接无脑选A/B。另外要提前确认一个心理预期A/B方案只是把“变砖概率”降到一个极低水平不是彻底为零。比如Bootloader本身写坏了、双标志区同时损坏这种极端情况仍然需要自救手段。所以这套方案落地时我还会在Bootloader里保留一段基于串口的强制恢复模式算是最后一道保险。2. Flash资源规划以F103ZE为蓝本地址怎么划分才够用2.1 先搞清楚F103的Flash结构再动手A/B方案的第一件事不是写代码而是把Flash地址规划好。F103的片内Flash是按页组织的不同容量的芯片页大小不一样中容量64KB每页1KB大容量512KB每页2KB。擦除操作必须以页为单位整页擦除写入操作最小单位是16位半字不能按单字节写。这个“页对齐”的要求直接影响分区边界的选择。如果你的Bootloader是32KB那么App_A的起始地址至少要落在0x08008000因为32KB正好是0x8000字节能对齐到2KB页边界也能对齐到1KB页边界。分区大小也尽量做成页大小的整数倍避免两个分区共享同一页、擦A区时误伤B区的数据。这种错误在地址规划时看不出来等实际升级擦写时会以非常诡异的方式出现。2.2 一张分区表讲清Boot、A、B、Flag的地址下面是我在F103ZET6512KB Flash上的实际分区方案可以直接套用分区起始地址大小说明Bootloader0x0800000032KB启动决策、升级收发、回滚控制App_A0x08008000224KBA槽位固件默认出厂写入App_B0x08040000224KBB槽位固件升级目标Flag区0x0807800016KB启动标志、尝试计数、确认标志保留/信息区0x0807C00016KB设备序列号、校准参数等为什么Bootloader只给32KB因为Bootloader做的事很纯粹串口协议栈、Flash擦写、启动跳转、状态判定这些都是基础功能哪怕加上FreeModbus移植和签名校验优化后也能控制在30KB以内。App区给224KB是因为我的实际固件大约180~190KB留出一些余量给后续功能迭代。如果你固件更大或更小按这个公式重新算就行App_A起始地址 Bootloader起始地址 Bootloader大小App_B起始地址 App_A起始地址 App_A大小Flag区起始地址 App_B起始地址 App_B大小只要保证所有分区大小都是Flash页大小的整数倍边界自然对齐。2.3 Flag区为什么要用两块冗余签名Flag区是整个A/B方案最容易翻车的地方因为每次升级都要往这里写“目标分区”和“确认状态”。如果写这个标志的过程中断电Flag数据可能处于半写状态轻则导致启动决策混乱重则两个App都启动不了。我的做法是设计一个BootFlag结构体在Flash里放两份备份typedef struct { uint32_t magic; // 固定魔数用于识别有效标志如 0xA5A5A5A5 uint32_t target; // 期望启动的分区1App_A2App_B uint32_t attempt_cnt; // 已尝试启动次数 uint32_t confirmed; // App运行成功后写入的确认值如 0x55AA55AA uint32_t crc32; // 以上字段的CRC32校验值 } BootFlag; #define BOOT_FLAG_PRIMARY_ADDR 0x08078000 #define BOOT_FLAG_BACKUP_ADDR 0x08078400写入策略是先写备份区读回校验通过后再写主标志区。读取时优先读主标志区如果主标志区CRC校验失败就换备份区并顺手把备份区数据恢复回主标志区。这样即使写入中途掉电最多损失一个区的数据另一个区还保留完整信息而且总能推导出“上次运行的分区是哪个”。2.4 中容量芯片做A/B的取舍如果你的主控是F103C8T6这种64KB Flash也不是完全不能做A/B但要非常克制。我建议这样分配Bootloader 16KB、App_A 24KB、App_B 24KB一共64KB刚好铺满。这种方案只适合固件压缩后小于22KB左右的设备功能受限明显编译优化要开速度优先而且基本没空间做签名验签。如果业务复杂度已经超出这个量级建议直接换大容量芯片别在64KB上硬撑。A/B方案的价值建立在“两个分区都足够装下功能完整的App”这个前提上分区缩得太厉害频繁迭代几次就装不下了。3. Bootloader到底干了什么启动决策、跳转与回滚的状态机3.1 启动流程三分钟看懂决策逻辑Bootloader上电后的逻辑比很多人想象中简单核心就是一个状态机总共五个状态IDLE上电初始状态读取BootFlagCHECK_APP校验目标分区的有效性和尝试次数TO_APP跳转到有效分区运行ENTER_UPGRADE进入升级模式等待上位机下发固件ROLLBACK尝试次数超限回滚到另一分区。判断流程用文字描述是这样读取主Flag区的配置如果magic不合法就读取备份Flag区如果目标分区指向App_B并且attempt_cnt小于等于3次就跳转到App_B如果attempt_cnt大于3次说明App_B连续多次启动失败自动切回App_A同时把Flag重置如果两个分区都不可用才进入串口强制升级模式等待外部干预。这个状态机看起来朴素但可靠性全部隐藏在细节里attempt_cnt的递增发生在Bootloader跳转App之前而不是App崩溃之后。也就是说Bootloader先把“我要启动B区”的次数记录下来再去启动B区这样即使B区启动后立刻崩溃复位Bootloader再次上电时能看到attempt_cnt已经增加。等App正常运行一段时间后由App主动调用确认函数把attempt_cnt清零并写confirmed标志这次启动才算真正成功。3.2 跳转App的核心代码VTOR与MSP是关键从Bootloader跳转到App最核心的动作是修改栈指针和中断向量表地址。直接上代码这段可以在Bootloader里复用typedef void (*AppEntry)(void); void boot_jump_to_app(uint32_t app_addr) { uint32_t app_msp *(volatile uint32_t *)app_addr; uint32_t app_entry *(volatile uint32_t *)(app_addr 4); AppEntry jump_func (AppEntry)app_entry; __disable_irq(); for (uint32_t i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; SCB-VTOR app_addr; __set_MSP(app_msp); jump_func(); while (1); }这里有几处必须说清楚。第一app_addr处的第一个32位数_MUST_是MSP初始值第二个32位数才是复位处理函数地址这是Cortex-M3的启动约定不能搞反。第二SCB-VTOR必须指向App所在分区的起始地址否则App里的每一个中断都会跑到Bootloader的中断向量表里去结果就是“App能跑主循环但一进中断就HardFault”。第三跳转前把已经使能的外设中断全部清掉不然App初始化之前残留的串口中断或定时器中断可能先触发导致App启动时序错乱。3.3 回滚不靠运气尝试计数确认标志的配合回滚机制是整个A/B方案最关键的部分也是最容易被做坏的部分。常见的错误做法是Bootloader启动App后开一个看门狗App在3秒内没喂狗就判定启动失败、回滚。这个方案的问题在于App卡在某个初始化流程但系统还在跑看门狗照样喂得动几次回滚机会就这样被白白浪费。所以我采用“显式确认”机制把回滚的判定权交给App业务逻辑本身Bootloader跳转前把BootFlag.target设为目标分区attempt_cnt加1App完成初始化、关键外设自检、通信协议栈起来之后调用ota_confirm_success()往Flash写入confirmed有效值并清零attempt_cnt如果App在启动过程中崩溃复位attempt_cnt不会清零Bootloader每次上电发现attempt_cnt超过阈值默认3就认为当前分区不可用自动切换并清空当前分区的pending标志。这个方案比看门狗可靠的地方在于App可以决定自己“什么时候算真正的启动成功”。比如某些设备需要等待传感器初始化完成、需要连上服务器才算可用那么App完全可以在连上服务器之后再调用确认函数这样回滚判断就和业务运行质量挂钩了。3.4 升级过程中断电数据到底会不会丢回到最初驱动我做这个方案的那个问题。在A/B方案下一次升级的完整时序是上位机先把新固件写入空闲分区比如当前运行的是App_A那就写入App_B全部写完并校验CRC后再写Flag把target改为App_B最后触发复位Bootloader从App_B启动。这个过程中断电分情况看如果断电发生在写App_B的途中App_A完整保留App_B是残包Flag.target仍指向App_A上电后设备正常运行旧固件升级任务下次重试如果断电发生在写完App_B、改Flag的途中可能出现Flag损坏但因为做了双区冗余Bootloader从备份区仍然能读出一个明确指向App_A或App_B的状态最关键的是App_A没有被碰过唯一需要担心的极端情况是两个Flag区同时损坏所以我在Bootloader里留了一个串口强制恢复命令这种极低概率事件也有后手。可以看到没有任何一个环节会导致设备完全不可用。这也正是A/B方案的核心价值把升级从“高风险操作”变成“低风险操作”断电从事故降级为一次普通的重试机会。4. 串口升级通道FreeModbus移植和私有功能码怎么设计4.1 为什么不直接写UART裸协议很多人做串口OTA喜欢自己定义一套简单协议帧头长度数据校验。小项目里这么干没毛病但一旦碰上超时重传、帧粘连、半包处理这些问题裸协议的实现成本会迅速膨胀。而且Modbus RTU本身就是工业现场最常用的串口协议用现成的FreeModbus协议栈相当于直接站在一个久经考验的框架上帧校验、超时判断、异常应答全都已经处理好了。在我的项目里FreeModbus v1.6通过RS232串口跑在STM32F103标准库v3.5上既承担正常运行时的参数读写也承担OTA升级时的数据块下发。协议栈是现成的重点工作在于移植和扩展功能码。4.2 FreeModbus v1.6在标准库v3.5上的移植要点FreeModbus的移植不算复杂但有几个容易出问题的点需要格外注意串口驱动层是FreeModbus和硬件之间的桥梁。需要在串口接收中断里调用prvvUARTTxReadyISR()和prvvUARTRxISR()之类的事件处理函数把收到的每个字节交给协议栈。标准库的串口中断写起来很直接但我建议不要在中断回调里做任何耗时操作FreeModbus本身对中断时延比较敏感。定时器层是FreeModbus判断帧结束的依据。Modbus RTU规定两个字节之间超过3.5个字符时间就认为一帧结束。所以需要一个硬件定时器波特率115200时3.5字符时间大约304微秒这个时间窗口非常短。我的做法是用TIM4做1微秒或10微秒的基础时基每次收到字节都重载定时器定时器溢出就触发帧结束回调。串口参数建议统一为115200, 8, N, 1。这是我做了多次对比后的选择115200在F103上误码率很低而且224KB固件全量传输大约30秒左右不算太慢。如果现场干扰严重也可以降到57600但传输时间会长一倍。4.3 私有功能码和帧格式设计FreeModbus支持用户自定义功能码这是我选择它的另一个重要原因。围绕着OTA流程我定义了下表这几个功能码功能码方向作用0x50PC→设备读取设备版本、运行分区、Boot版本0x51PC→设备擦除目标分区非当前运行区0x52PC→设备写入一块固件数据0x53PC→设备整包CRC校验并触发跳转0x54PC→设备查询升级状态/进度0x52是传输的核心。每个Modbus RTU帧的最大数据区是253字节扣除块号、偏移等协议字段我每块实际承载240字节固件数据。数据结构设计成这样字段长度说明块序号2字节从0开始递增高位在前数据240字节固件内容整包CRC4字节放在0x53命令里统一校验帧内不再重复收到0x52后Bootloader先把数据暂存到RAM缓冲区攒够一页2KB或者收到页结束标记再一次性写入Flash。不要每收到240字节就立刻写Flash否则Flash擦写次数会变得特别频繁而且每次擦写都有等待时间容易拖垮串口接收节奏。4.4 RS232上的传输时序要处理好RS232和RS485在Modbus场景下有个关键区别RS485是半双工需要控制收发方向RS232全双工不需要DE/RE切换接线也简单很多三根线就能干活TX、RX、GND。但RS232传输距离有限一般不要超过15米这在现场要提前和结构同事确认好。传输时序上上位机发一帧0x52后Bootloader写入Flash需要时间标准做法是从机处理完一帧才回响应主机只有收到上一帧的响应才发下一帧。这个一问一答的节奏天然做了流控不容易把从机打死。如果某个帧校验失败或者没有响应上位机连续重试3次仍然失败就中止传输保留现场日志方便排查是线缆干扰还是Bootloader卡死。5. 固件制备与发布链路从bin到A/B包的工程实践5.1 A/B包到底是个什么东西“A/B包”这个说法在不同的项目里意思不太一样有的指增量包/全量包有的指A/B分区对应的固件。在我这套方案里A/B包就是同一份App代码分别链接到不同Flash地址后生成的两个bin文件。为什么必须有两份因为F103没有像Cortex-M0那类芯片的硬件Flash重映射机制App跑在哪个地址代码里所有绝对地址就得按那个地址链接。App_A的起始地址是0x08008000App_B的起始地址是0x08040000这两份固件虽然源码一样但编译后的跳转地址、常量地址都不同必须分别编译。5.2 同一份代码编译出A、B两个地址的固件具体操作上我用Keil MDK做了两个TargetApp_A和App_B本质区别只有一句话——链接脚本里的ROM起始地址不同。在分散加载文件*.sct里; App_A LR_IROM1 0x08008000 0x00038000 { ER_IROM1 0x08008000 0x00038000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00010000 { .ANY (RW ZI) } }; App_B LR_IROM1 0x08040000 0x00038000 { ER_IROM1 0x08040000 0x00038000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00010000 { .ANY (RW ZI) } }编译App_A时的宏定义里写上APP_BASE_ADDR0x08008000App_B的宏定义写上APP_BASE_ADDR0x08040000。App初始化时把SCB-VTOR设成这个宏值就能实现中断向量表自动适配。同时App里也能通过这个宏知道自己当前跑在A区还是B区需要上报版本时直接把这个信息附带上。5.3 固件包头的设计与CRC校验裸的bin文件没法直接在OTA链路上用我给它加了一个自定义包头上位机生成固件包时把包头写在最前面偏移字段长度说明0x00魔数4字节0x4F544146即“OTAF”0x04版本号4字节主版本次版本修订构建号0x08目标分区1字节1App_A2App_B0x09固件长度4字节不含包头0x0D整包CRC324字节对固件数据部分计算0x11保留15字节预留升级策略字段0x20固件数据N字节真正的App代码CRC32计算范围只覆盖固件数据部分不含包头这样上位机可以先读包头、再校验数据逻辑清晰。不建议用Modbus帧自带的CRC16来替代整包CRC32因为帧CRC只保证单帧传输正确性不能保证固件内容的完整性和正确性。5.4 nginx托管与OTA提取器的配合固件包生成之后需要放到设备能访问到的地方。对于RS232串口链路其实设备本身不直接访问网络而是由一个PC侧工具“OTA提取器”去云端拉取固件再通过串口下发。我的发布目录结构是标准nginx静态服务器就能搞定的/usr/share/nginx/html/ota/ ├── version.json ├── app_A_v120.bin └── app_B_v120.binversion.json里存的是元信息{ version: 1.2.0, app_a_url: /ota/app_A_v120.bin, app_b_url: /ota/app_B_v120.bin, crc32_a: 7A3C91DE, crc32_b: 2B8F41A6, pub_date: 2025-01-20 }OTA提取器的工作流程是这样第一步向设备发0x50命令拿到当前运行分区和版本号第二步请求version.json对比版本第三步如果版本不同判断当前运行的是A还是B目标分区就是“另一个”第四步下载对应bin拆成带包头的数据块逐帧通过0x52下发第五步下发完发0x53由Bootloader做整包CRC校验然后写Flag复位进入新固件。如果后续对安全要求更高可以在这个链路里加入签名验签在包头后面追加签名段Bootloader内置公钥0x53阶段先验签再置Flag。F103做一次签名验签需要几百毫秒到几秒对启动时间有要求的话要提前评估。安全等级不高的内部项目CRC32加Modbus帧校验基本已经够用。6. 复现这套方案时踩过的坑完整排查链路6.1 App一启动就进HardFault根因在中断向量表第一次集成这套方案时Bootloader能正常跳转但App跑起来没几毫秒就进HardFault。一开始以为是栈溢出或者外设初始化冲突查了好久才突然意识到App里根本没设VTOR。F103的SCB-VTOR默认值是0x00000000如果不主动设置成App所在分区地址App一触发任何中断CPU就会去0x08000000找中断向量。而0x08000000在启动那一刻是Bootloader的向量表Bootloader已经被App覆盖结果就乱套了。解决办法很简单在所有App的main()执行真正初始化之前第一步就设置#define APP_BASE_ADDR 0x08008000 // 或 0x08040000由编译宏决定 void app_vector_init(void) { SCB-VTOR APP_BASE_ADDR; }关键是这个操作必须在任何中断使能之前执行尤其不能在NVIC_Configuration()之后。6.2 从Boot跳转后外设还在响怎么办另一个高频坑是Bootloader里用串口做完升级通信跳转到App后串口莫名其妙进中断或者定时器还在跑。原因是Bootloader跳转前没有把外设时钟、中断、引脚状态全部复位干净。我在跳转函数里加了中断屏蔽之后还要显式调一遍RCC复位RCC_DeInit(); NVIC_DeInit(); SysTick-CTRL 0;如果用了复用功能的引脚还得把GPIO配置重新梳理一遍。总之跳转前的外设环境要“白纸化”否则App初始化时假设的干净外设环境不成立各种诡异问题都会冒出来。6.3 擦写Flash时程序卡死BSY位和等待时延还有一次排障耗时最长现象是串口下发固件到某个块Bootloader就不回响应了。查到最后发现擦除Flash页的时候我在中断服务程序里直接调了Flash擦写函数。Flash控制器在擦写期间会置位BSY位CPU访问Flash会等待操作完成而中断服务程序里等待的这段时间其他中断被堵住FreeModbus的帧超时判定就乱了。解决方案是把Flash擦写动作移出中断上下文放到主循环的状态机里去做。上位机保证一问一答的节奏主循环每收到一块数据就攒着攒到一页才执行一次擦写。擦写期间关闭全局中断最多关几十毫秒不会把协议栈饿死。如果产品对看门狗敏感的话擦写循环里还要记得喂狗别让狗先把系统复位了。6.4 Map文件怎么帮我确认链接地址对了A/B两个App的编译地址靠肉眼很难核对我建议每次发布前养成看map文件的习惯。在Keil里编译输出会生成.map文件直接搜索Execution Region关键字看ER_IROM1的基地址和执行区域大小。如果App_A的工程里出现ER_IROM1 0x08008000 0x00038000App_B的工程里出现ER_IROM1 0x08040000 0x00038000说明链接地址对了。另一种更稳妥的自检方式是在App启动时把SCB-VTOR的值打包进版本信息通过0x50命令读回来跟预期地址做比对。这样即使现场的bin文件搞混了也能第一时间发现。6.5 升级后设备启动到了旧版本标志位被谁改了有位同事集成这套方案时遇到一个奇怪现象升级流程全部走完Bootloader也显示新固件校验通过但设备重新上电后跑的还是旧版本。查到最后发现App里没调用确认函数就复位了具体原因是他移植代码时删掉了一段负责写confirmed标志的代码。结果Bootloader每次上电都认为上一次启动“未确认”尝试计数逐步累加超过3次后自动回滚到旧分区表现就是“每次升级完都回到旧版本”。所以这里再次强调App在真正运行正常之后必须显式调用ota_confirm_success()写确认标志。确认函数要放在主循环已经稳定运行、关键外设自检通过的位置不要太早也不要太晚。太早的话App可能还没跑到真正出问题的地方就“假装成功”了太晚的话Bootloader可能已经在尝试计数用完前就回滚了。一般放在上电后2~5秒内的主循环稳定位置比较合适。6.6 从热词里顺带提醒不要跳过擦除直接写最后再提一个非常容易犯的低级错误。F103的Flash只能把1写成0不能把0写成1所以任何一次写入之前目标地址所在页必须先擦除。有的新手看到Bootloader已经擦过整个分区就在App里不做擦除直接写入结果下一次升级时旧数据残留App分区里既有新增固件又有旧固件内容CRC校验明明过了、跑起来却行为怪异。写Flash之前永远先调FLASH_ErasePage这应该是写入流程里的肌肉记忆。这套A/B OTA方案从设计到稳定运行前后迭代了差不多三周。现在回想起来最花时间的并不是Bootloader代码本身而是把Flash规划、状态机、确认时序这些看起来“简单”的细节想透。尤其回滚机制几乎是整个方案的灵魂没有它A/B分区和单分区也就差一个“多存一份固件”的考究而已。如果你正在做类似的嵌入式远程升级建议先照着文中的分区表和状态机搭一版最小可用实现跑通一次完整升级和一次模拟断电回滚再往自己的业务场景里加签名、加密、断点续传这些进阶能力。到时候你会发现把升级做成一件“即使失败也不会造成损失”的事心里会踏实很多。

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

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

免费获取报价