文章

双分区 OTA:先拿到 offer 再辞职的升级法(以 Cortex-M4 为例)

字段工程师在客户机房点下「升级」。进度条走到 87%,机房的 UPS 跳闸了。重启之后,那台设备再也没起来——Flash 里躺着半截新程序、半截旧程序,谁都不完整。寄回工厂,重走一遍烧录产线,客户体验归零。

为什么一次升级能把设备「升死」?因为最常见的升级方式,本质是一场原地装修:先把旧的擦掉,再写新的。擦和写之间有个窗口,窗口里断电,旧房已拆、新房没盖完。更糟的是,这个窗口和下载速度成正比——固件越大、网越慢,裸奔在「半截子程序」里的时间越长。这不是一句「下次小心点」,是方案的结构性缺陷。

本篇讲一个把窗口堵死的升级法:双分区(A/B)OTA。它把「先拆后建」改成「先建后拆」——正在运行的旧程序从头到尾都不碰,新程序在旁边写好了、验收过了,再「切」过去。代价是 Flash 容量减半。链接脚本篇结尾埋过一句伏笔——「bootloader 的双槽设计,那是要同时管两张竣工图的棋局」——本篇就是把它摆开。读完你会发现,那块板子上「升级」二字,从赌命变成了切指针。

先交代一个诚实的前提:Cortex-M4 是跑程序的 CPU 内核,「双分区」本身是 Flash 存储和 bootloader 软件共同搭出来的方案。有些 MCU(STM32G4/H7 等)在 Flash 控制器里做了「硬件双 bank」,本篇先讲软件划分的 A/B 双槽——这才是任何一颗 Cortex-M 芯片都能用的通用版本,硬件双 bank 放到 L3 当替代方案来比。

L1:先让它跑起来——双分区长什么样

你在第一层。目标:看清一张「三份固件共存」的 Flash 地图,搞懂升级为什么不再覆盖旧程序。

把一块 512K 的 Flash 切成四块,就是双分区的最小形态:

1
2
3
4
5
6
7
8
9
0x08000000 ┌────────────────┐
           │   Bootloader    │  32K   选槽 + 校验 + 跳转,自己几乎不升级
0x08008000 ├────────────────┤
           │    App 槽 A     │  224K  出厂就在这,一直在跑的那一版
0x08040000 ├────────────────┤
           │    App 槽 B     │  224K  升级时,新固件先写到这里
0x08078000 ├────────────────┤
           │  标志 / 元数据   │  32K   A/B 状态、镜像头、版本号、CRC
0x08080000 └────────────────┘

关键在中间两条各 224K 的槽:它们装的是两份完整、可独立运行的应用固件,一份叫 A,一份叫 B。平时只有一份「在位」,另一份空着当车库。整个系统的运行规则是 Bootloader 定的:

  1. 上电 → 永远由 Bootloader 抢占第一行(它是 0x08000000 处的天经地义的主人)。
  2. Bootloader 读标志区,判断该启动 A 还是 B,校验通过后跳过去。
  3. 用户现场点「升级」→ 新固件下载到空闲的那个槽(比如正在跑 A,就写进 B)。
  4. 写完整、校验通过 → 置一个「下次启动我」的标志 → 复位。
  5. 复位后 Bootloader 看见标志,跳到 B。若 B 那版上线后发现不对,把标志拨回 A,再复位。

看到第 4、5 步了吗:旧程序 A 从头到尾没被擦过一个字节。升级做的最接近「危险」的动作,是最后改了一个标志位——一个字节的写入,掉电也掉不出半截落体。整个「拆房」的危险彻底消失了。

拿一个对照把这事说透。单分区升级的生命周期是这样一条险路:用 A → 擦 A → 写 A',中间那步一断就砖。双分区把它扶正成一条平路:用 A → 写 B → 切 B →(出问题→切 A)。同样是升级,前者是「边拆边住」,后者是「隔壁先把新房盖好、验收入住、再把钥匙交给新人」。

到这里你已经「会用」了——至少能对着这张图讲清楚双分区在干什么。但一堆「为什么」悬着:Bootloader 到底是怎么「跳」到 B 的,不是一条 jmp 的事吗?中断怎么办?那个标志位凭什么能决定生死?

L2:懂原理——每次切换底下发生了什么

到这一层,问题变了:不再是「哪块是什么」,而是「每一个决定为什么这样下」。

两个槽,是两个链接脚本

链接脚本篇说过,.ld 是「把无名碎片变成有址地图的分房方案」。双分区意味着要画两张图:同一批 .o,得链出两份地址完全不同的固件。办法简单到朴素——两份几乎一样、只有 ORIGIN 不同的链接脚本:

1
2
3
4
5
/* app_a.ld —— 住 0x08008000 */
FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 224K

/* app_b.ld —— 住 0x08040000 */
FLASH (rx) : ORIGIN = 0x08040000, LENGTH = 224K

这一改,牵出整条链:向量表(.isr_vector)搬去了新家、所有绝对地址重算、.data 的货架号(LMA)跟着挪、.map 符号表重新登记。.map 篇那句「同一批 .o 可以链成 Flash 布局、RAM 布局、甚至链进 bootloader 的 app 槽,换的只是图,不动的是货」,在这里第一次真正派上用场——你要同时维护两张竣工图,还得保证对得上账。这也是为什么 bootloader 工程和 app 工程要分开管理:一个仓库里躺两份 .ld、两份 .map,糊了就是事故。

中断是最大的坑:VTOR

跳过去之后,你的中断处理函数还在吗?——这是双分区翻车率第一高的地方。

Cortex-M 响应中断时,要去一个叫向量表的电话簿里查「这个中断号该打给哪个函数」。电话簿的地址存在一个寄存器里:SCB->VTOR。上电复位时,硬件把 VTOR 归零,CPU 默认去 0x00000000(芯片通常会把这整段映射到 Flash 基址 0x08000000)取向量——所以裸机单槽固件几乎没人显式碰过 VTOR,它一直「恰好在正确的位置」。

一旦跳到槽 B,正确位置变了:B 的向量表在 0x08040000,而如果你跳过去之前忘了改 VTOR,VTOR 还指着 0x08008000(A 的地盘)。后果是:平时啥事没有,但只要来一个中断——SysTick 到点了、串口收到了一字节——CPU 就跑去 A 的向量表里取入口,拿 A 的旧函数地址往 B 的代码里冲,直接 HardFault 或乱飞。最阴的是这个 bug 能活很久:如果 B 版的中断恰好开得不多,它可能稳定运行好几天才炸一次。

所以跳转之前必须做这一步:

1
SCB->VTOR = app_addr;   /* 把电话簿也搬到新家 */

两个细节。其一,别只让 bootloader 设一次:稳妥做法是 app 自己启动早期再设一遍(ST 启动文件的 SystemInit 里那个 VECT_TAB_OFFSET 宏就是干这个的,槽 B 的工程把它改成 0x40000 - 0x08000)。双保险,因为「跳转后第一件事」的执行环境越少依赖上一家越好。其二,槽基址要满足向量表对齐:地址的二进制低位必须能让向量表完整铺下(128 字节起步,实际按芯片手册的页/扇区对齐来取整)。好在 0x08008000、0x08040000 这些槽基址天生对齐到扇区边界,不用操心。

跳转不是一条 jmp,是「伪造一次上电」

把握住了 VTOR,再看 bootloader 那三行最神秘的代码:

1
2
3
4
5
6
7
8
9
typedef void (*entry_t)(void);

void boot_jump(uint32_t app_addr) {
    uint32_t *vt = (uint32_t *)app_addr;     /* 新固件的向量表 */
    __disable_irq();                          /* ① 关全局中断 */
    SCB->VTOR = app_addr;                     /* ② 电话簿搬家 */
    __set_MSP(vt[0]);                         /* ③ 把栈顶换成新家的 */
    ((entry_t)vt[1])();                       /* ④ 取出 Reset_Handler 并调它 */
}

逐行读。向量表前两项是芯片和启动文件的约定:启动文件篇讲过,第 0 项存栈顶 _estack(vt[0]),第 1 项存 Reset_Handler(vt[1])。所以第 ③④ 行等于把上电时硬件做的两件事用软件手动重演一遍:先取 SP、再取 PC。你看,所谓「跳过去」,根本不是一条 BL 0x08040200,而是——喂给 CPU 一个新栈顶 + 一个新入口地址,让它误以为自己刚刚复位,只是省掉了真正的上电。

①为什么不能省?因为跳转这一瞬间,你的旧程序可能还有中断排着队(一个没清掉的 UART 中断、一颗没好透的定时器)。若不先 __disable_irq,CPU 可能在 SCB->VTOR 已改、MSP 还没换好的节骨眼上响应一个中断——用半新半旧的状态去查电话簿、压栈,稳稳 HardFault。这一行是「跳转三行代码」里最容易被新手删掉、又最致命的一行。讲究的 bootloader 还会在跳转前把外设时钟、NVIC 挂起位逐个复位,但底线永远是这条 __disable_irq。

标志位:一字节的生死簿

最后是那个决定「下次启动谁」的标志。它不能放在普通 RAM 里——RAM 断电就没了,而标志必须在掉电后仍然存活,才能保证升级「先写 B、断电、再开机还认 B」。所以它得烙在非易失存储里:Flash 的末尾、芯片的 backup 寄存器(有独立供电时)、或外挂的 EEPROM。

一个健壮的标志区通常不止一个 bit,而是一段「镜像头」:给每个槽配一份元数据——版本号、固件大小、CRC32 或签名。Bootloader 开机的判定逻辑随之变成一条严谨的链:

1
2
3
4
5
6
7
读 A 镜像头 → 校验 A 的 CRC
              ├─ 通过 → 看「是否有待切换的 B」
              │           ├─ 无 → 启动 A
              │           └─ 有,且 B 也校验通过 → 启动 B
              └─ 失败 → 看 B
                          ├─ B 校验通过 → 启动 B(A 已废,靠 B 续命)
                          └─ B 也失败 → 进「恢复模式」(等着重新下载)

注意这段逻辑的硬核之处:它假设任何一份都可能是坏的。因为坏的那一份,可能正是上次升级写到一半断电的产物——CRC 对不上,Bootloader 一眼识破,扭头启动完好那一份。写到 99% 断电这件事,从「变砖」降级成了「这次升级作废,用旧版再来一次」。这才是「备份」两个字的全部含义:不是存两份一模一样的,而是让任意一份坏了,另一份都能独立扛起整个系统。

L3:想得透——为什么是这个方案

到这一层,问题变成:换个设计行不行?代价是什么?

本质:升级,其实是切指针

去掉所有术语,双分区升级做的是这件事:程序的内容从头到尾没改过一个字节,改的只有「开机该启动谁」这一个元信息。

这是全文最反直觉的一步。你以为「升级 = 改程序」,其实「升级 = 改一个指向程序的指针」。固件本身是不可变的(写到 Flash 里它就一直躺在那),真正的「升级」发生在 Bootloader 的决策里:它根据标志位,决定这次把 CPU 交给 A 还是 B。所谓「回滚」,也不是把程序「降回来」,而是把那个指向 B 的指针轻轻地拨回 A——旧程序一直在原地等着,一秒都不差。

这个心智模型和写代码的经典操作同构:git checkout 切分支。仓库里所有 commit 都躺在 .git/objects 里,切分支改的只是 HEAD 这一个指针,从来不重写历史。双分区就是给固件做了一份「可以现场 checkout 的 git」:A、B 是两条永远不动的分支,Bootloader 是那个摆弄 HEAD 的人。理解了「切指针」而不是「改内容」,回滚为什么能在一息之间完成、断电为什么伤不到正本,就都不需要解释了。

为什么不是这些显然的替代方案

「两个抄一份」听起来浪费,为什么不省钱?把候选方案摆上桌,代价就现形了:

方案做法为什么被否掉 / 什么时候反而选它
单分区原地升级(先拆后建)覆盖旧固件断电窗口 = 变砖。省 Flash,但等于裸奔,只剩「加个恢复模式」能续命
单分区 + 恢复模式覆盖式,但留一个 mini 自举区能重新下载不死,但「设备趴窝等你重下」仍是停机,不是无缝升级
外部 SPI Flash 存镜像新固件存片外,再搬回主 Flash省了主 Flash,但搬运那一刻又回到「拆后建」的窗口;多一颗芯片的成本
硬件双 bankFlash 控制器物理分两半,硬件交换映射意想之外地省事:软件连「搬」都不用,直接切 bank;但只有部分芯片有,且换芯片就没了
差分升级(delta OTA)只下发差异,现场「打补丁」带宽省到极致,但算法复杂、要求现场固件版本和基线严格匹配,容错面更窄

看清楚这张表的分野:单分区系列都卡在「某个瞬间系统没有完整可用的程序」这个死穴上;双分区(软件 A/B 或硬件双 bank)用「永远保留一份整的」绕开了它,代价是 Flash 减半。减半不是浪费,是买保险——买的是「任何时刻掉电,都至少有一版能跑」。何时该买这份保险,看一个数:设备是「能远程 OTA 却去不了现场」的(户外的表计、下井的探头、装在客户机房的网关),减半换「从不断电变砖」,稳赚;是「随时能派人拿调试器插一下」的开发板,那单分区 + 恢复模式就够,别为省下的空间付设计复杂度。

硬件双 bank 值得单独说一句。它把「写 B、切 B」里的「写」也外包给了 Flash 控制器:两个 bank 在芯片里物理独立,一条 bank swap 指令就能让 CPU 从 bank1 无缝改到 bank2,软件不用操心两份 .ld 两张图。听起来更省事,代价是被芯片厂商锁死——今天在 STM32G4 上写顺了,换一颗没有 dual bank 的 M4 芯片,这套逻辑整个作废。所以工程上的常见选择是:哪怕芯片有硬件双 bank,bootloader 也按软件 A/B 的流程写,把「换芯片」的迁移成本压到只改一个扇区号。

剩下的一个诚实的漏洞

说了这么多「掉电也不怕」,得补一句实话:双分区把「变砖」的概率压到了极小,但没压到零。它还留着两个窗口——标志位写入的那一瞬间,和逻辑上「两份校验都过、该切不该切」的边界条件。前者用「先算后写、写坏重写、原子扇区写」能基本消解;后者(B 升上去才发现是个「能启动但功能坏了」的版本)属于「校验过了、人没验过」,双分区救不了,只能靠「连续 N 次启动失败自动回退」这类更高层的看门狗策略兜底。把话说全,才敢在产线上点那个「升级」。

动手环节:亲手摸到这些机制

光看不算数,来两件事,都用 arm-none-eabi 工具链就能做。

实验一(改一张图,看另一张图怎么说):链出双胞胎固件。 拿一个能构建的裸机工程(GPIO 篇那个就行),复制它的链接脚本,改两处 ORIGIN:A 版 0x08008000、B 版 0x08040000,分别链两份:

1
2
arm-none-eabi-gcc ... -T app_a.ld -Wl,-Map=app_a.map -o app_a.elf
arm-none-eabi-gcc ... -T app_b.ld -Wl,-Map=app_b.map -o app_b.elf

先猜,两问:第一问,arm-none-eabi-nm app_a.elf | grep Reset_Handler 和 B 版相比,地址差多少?(提示:想想两个 ORIGIN 差多少。)第二问,diff app_a.map app_b.map,会看到哪些不同——只有地址列,还是连段长、符号个数都变了?

预期答案:两版 Reset_Handler 差 0x40000 - 0x08000 = 0x38000(224K),其他符号同此规律;而段长、符号个数不变——因为换的只是图,货一模一样。这一个 diff 就是你亲手验证的「同一批 .o,两张竣工图」。顺手把 A 版的 map 和 B 版的 map 都留着,下次 .map 篇那套「哪个地址是哪个函数」的查账,就能对两份账了。

实验二(无硬件也能做,纯推理题):三行跳转,删哪行会怎么死。 回到 boot_jump 那四行。先预测下面三个「忘记做」的版本,各会有什么症状,再对下文:

  1. 忘了 __disable_irq():症状是「跳过去看起来正常,偶尔 HardFault,且越来越频繁」——为什么是「偶尔」?(想想到底什么条件下才会撞上那个窗口。)
  2. 忘了 SCB->VTOR = app_addr:症状是什么,为什么「平时都很稳、某天突然崩」?(什么事件会触发去查错误的那本电话簿。)
  3. 忘了 __set_MSP(vt[0]):为什么可能跳过去第一行就崩?(CPU 此刻站在谁的栈上?)

答案指向文中的「伪造上电」和 VTOR 两节。能说清这三个「忘了会怎样」,比能背下那四行代码值钱得多——因为产线上 99% 的翻车,都是「某一行被悄没声地删掉了」。

自测三题,能答出来说明真懂了:一是为什么标志位不能放普通 RAM?(掉电后它还得活着,答案在「生死簿」一节。)二是单分区升级的「死亡窗口」具体在哪两个动作之间?(擦旧与写完新之间,L1 那段对照。)三是双分区到底是「改程序」还是「改指针」,一句话说给同事听,不用任何术语?(L3 第一节那句本质。)

收尾地图

把整篇收进一张速查表——出问题时去哪看:

症状 / 问题去哪看一句话理由
跳过去之后中断一来就 HardFaultSCB->VTOR电话簿没跟着搬,CPU 还查旧表
升级到一半断电,设备变砖有没有分成 A/B 两槽原地覆盖的死亡窗口没堵上
Bootloader 每次开机都进恢复模式标志区/镜像头的校验逻辑两份都校验失败,或标志写坏
跳转瞬间概率性崩__disable_irq 还在不在半新半旧状态接了中断
两版固件差了多少、是不是只差地址diff 两份 .map换图不换货,只有地址列不同
想升级但 Flash 放不下两份换外部 SPI Flash / 单分区+恢复双分区省不了空间,只能买保险
芯片支持硬件双 bank仍按软件 A/B 写流程bank swap 换芯片就作废,别锁死

再留三张「知道就好、用到再来」的小抄:一是给槽基址和大小用宏或顶层 Makefile 统一定义,别在两个 .ld 里手写两遍数字,进化版是让链接脚本 INCLUDE 一份公共的 memory.inc;二是标志区留一个「镜像头 + CRC32」的最小结构,Bootloader 永远先校验再跳,把「两份都坏」当默认假设来写;三是「连续 N 次启动失败自动回退」的看门狗策略,是双分区之外补「能启动但功能坏」这个盲点的终极兜底。

下一站,两条路任选。往安全的深处走:给镜像头加签名、Bootloader 里验签,就是「安全启动(Secure Boot)」的雏形——双分区管「别变砖」,验签管「别让人塞进来的假固件上位」,两者叠加才是完整的 OTA 安全。往带宽的深处走:差分升级(delta OTA)——双分区解决了「怎么安全地换」,差分解决「怎么省着换」,那份现场打补丁的复杂,是 A/B 稳定落地之后的下一个山头。先把「切指针」这个心智模型拿稳了:往后无论看 MCUboot、各家芯片厂商的私有 bootloader 还是自研方案,剥开来都是同一局棋——两份永不改写的正文,加一个摆弄指针的 bootloader。

本文由作者按照 CC BY 4.0 进行授权