先把话说在前头:如果你的产品已经上了远程OTA,但是方案还是“Bootloader直接擦掉旧App区、然后从头写新的”,我劝你认真看一下A/B分区。去年我有一台已经部署在现场的控制器,就因为一次OTA过程中用户那边意外断电,设备彻底变砖,最后是售后带着烧录器跑了几百公里才救回来。那次之后我把整个升级方案推翻,重新设计了基于STM32F103的A/B双分区OTA方案。
这篇文章就是那次重构的完整复现教程。我会从内存布局、Bootloader状态机、串口升级通道、固件制备发布链路、再到实际踩坑,把整个链路一条条拆开讲清楚。方案不依赖外部Flash,纯靠F103片内资源就能实现,适合正在做量产设备远程升级、又不想把可靠性寄托在“用户运气好”上的嵌入式工程师。
1. 为什么我放弃了“单分区直接刷”的OTA方案
1.1 一次断电让设备变砖的教训
先说那次事故的具体过程。设备是一个基于STM32F103ZET6的控制器,Flash 512KB,App固件大约180KB。当时的OTA流程很常规:上位机把整个固件包通过串口发给Bootloader,Bootloader收到后先擦除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本身只有64KB,App又占到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的地址
下面是我在F103ZET6(512KB Flash)上的实际分区方案,可以直接套用:
| 分区 | 起始地址 | 大小 | 说明 |
|---|---|---|---|
| Bootloader | 0x08000000 | 32KB | 启动决策、升级收发、回滚控制 |
| App_A | 0x08008000 | 224KB | A槽位固件,默认出厂写入 |
| App_B | 0x08040000 | 224KB | B槽位固件,升级目标 |
| Flag区 | 0x08078000 | 16KB | 启动标志、尝试计数、确认标志 |
| 保留/信息区 | 0x0807C000 | 16KB | 设备序列号、校准参数等 |
为什么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; // 期望启动的分区:1=App_A,2=App_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:上电初始状态,读取BootFlag;
- CHECK_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加1;
- App完成初始化、关键外设自检、通信协议栈起来之后,调用
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流程,我定义了下表这几个功能码:
| 功能码 | 方向 | 作用 |
|---|---|---|
| 0x50 | PC→设备 | 读取设备版本、运行分区、Boot版本 |
| 0x51 | PC→设备 | 擦除目标分区(非当前运行区) |
| 0x52 | PC→设备 | 写入一块固件数据 |
| 0x53 | PC→设备 | 整包CRC校验并触发跳转 |
| 0x54 | PC→设备 | 查询升级状态/进度 |
0x52是传输的核心。每个Modbus RTU帧的最大数据区是253字节,扣除块号、偏移等协议字段,我每块实际承载240字节固件数据。数据结构设计成这样:
| 字段 | 长度 | 说明 |
|---|---|---|
| 块序号 | 2字节 | 从0开始递增,高位在前 |
| 数据 | 240字节 | 固件内容 |
| 整包CRC | 4字节 | 放在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的起始地址是0x08008000,App_B的起始地址是0x08040000,这两份固件虽然源码一样,但编译后的跳转地址、常量地址都不同,必须分别编译。
5.2 同一份代码编译出A、B两个地址的固件
具体操作上,我用Keil MDK做了两个Target:App_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_ADDR=0x08008000,App_B的宏定义写上APP_BASE_ADDR=0x08040000。App初始化时把SCB->VTOR设成这个宏值,就能实现中断向量表自动适配。同时App里也能通过这个宏知道自己当前跑在A区还是B区,需要上报版本时直接把这个信息附带上。
5.3 固件包头的设计与CRC校验
裸的bin文件没法直接在OTA链路上用,我给它加了一个自定义包头,上位机生成固件包时把包头写在最前面:
| 偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0x00 | 魔数 | 4字节 | 0x4F544146,即“OTAF” |
| 0x04 | 版本号 | 4字节 | 主版本+次版本+修订+构建号 |
| 0x08 | 目标分区 | 1字节 | 1=App_A,2=App_B |
| 0x09 | 固件长度 | 4字节 | 不含包头 |
| 0x0D | 整包CRC32 | 4字节 | 对固件数据部分计算 |
| 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 0x00038000,App_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分区和单分区也就差一个“多存一份固件”的考究而已。如果你正在做类似的嵌入式远程升级,建议先照着文中的分区表和状态机搭一版最小可用实现,跑通一次完整升级和一次模拟断电回滚,再往自己的业务场景里加签名、加密、断点续传这些进阶能力。到时候你会发现,把升级做成一件“即使失败也不会造成损失”的事,心里会踏实很多。