Home › Embedded, industrial & energy › Firmware & peripherals
algorithm medium

A/B partition updates

Problem A maker of devices in homes, vehicles or plants must replace the software on units it cannot reach by hand. A power cut, a bad download or a faulty release in the middle must never leave a unit dead.

Two bootable slots and a small atomic metadata block: write the new image to the idle slot, try it once, and fall back by itself unless it reports healthy.

update O(image) flash writes plus 3-4 metadata writes; boot O(1) metadata reads plus one image hash; flash cost 2x image
otafirmware-updaterollbackbootloaderpower-fail-safeanti-rollback
Overview

A device overwritten in place is bricked by any power cut mid-write. Keep two slots: the running image never changes while the idle slot is rewritten, and the switch is one tiny metadata write. The new image boots on trial with a counter and becomes permanent only when it reports success. Crash, hang or bad build: the counter runs out and the bootloader flips back.

Key facts

Use when

  • Devices that cannot be reflashed on site: phones and laptops (Android, ChromeOS), microcontroller sensors (MCUboot), embedded Linux gateways and industrial controllers (Mender, RAUC, SWUpdate).
  • Downtime must be one reboot, with the update applied while the device is in use.
  • Paired with a staged rollout, the device-side fallback limits a release that escapes testing.

Avoid when

  • Flash cannot hold two images: use one copy plus a small recovery image.
  • The part cannot be duplicated, like the bootloader on most SoCs: that update stays one-way.

Deep dive

How it works

Parts:

  • slot A, slot B — each a complete bootable image: header (version, length), payload, hash/signature trailer
  • metadata block — two copies of {seq, active, tries_left, success, CRC} in separate flash sectors
  • bootloader — read-only in the field; picks the slot, counts trial boots, rolls back
  • updater — runs in the active image; writes the idle slot and flips the metadata
  • version fuse — monotonic counter in trusted non-volatile storage (MCUboot: security counter); refuses older versions

Operations:

  1. Check. Refuse an image whose version is below the fuse or whose signature fails (code signing).
  2. Write the idle slot in blocks; the active slot is never touched. A cut leaves a slot with a bad hash while the metadata still points at the old one.
  3. Verify the written slot (hash, signature). Only a verified slot may be marked.
  4. Flip. Write {seq+1, active=idle, tries_left=N, success=false} into the older metadata copy. A torn write fails its CRC, so the other copy (old pointer) wins.
  5. Boot. Take the valid copy with the highest seq. If success is false, persist tries_left - 1 and boot that slot; at 0, switch back to the previous slot and set success.
  6. Confirm. The new image runs health checks (network up, sensors read, watchdog fed for a while), then writes success=true. The version fuse rises only after this.
  7. Report. After a fallback the updater tells the server; the server decides whether to retry.
 start            after flip (1 metadata write)     trial boots
 A [v1] active    A [v1]                            tries 3 -> 2 -> 1 -> 0 while v2 runs
 B [ ]            B [v2] active, tries=3, ok=0      confirm: ok=1, v2 permanent
                                                    no confirm: next boot sets A active

Code

Simulated flash with torn writes, double-buffered metadata, updater, bootloader and a power cut at every flash write. FNV-1a stands in for SHA-256 plus signature; the CRC is CRC-32 (IEEE).

const SLOT: usize = 64; // bytes per slot
const BLK: usize = 16; // flash write unit
const MAXTRIES: u8 = 3;
fn u32le(b: &[u8]) -> u32 { u32::from_le_bytes(b[..4].try_into().unwrap()) }
fn crc32(d: &[u8]) -> u32 {
    !d.iter().fold(!0u32, |c, &b| {
        (0..8).fold(c ^ b as u32, |c, _| (c >> 1) ^ (0xEDB8_8320 & (c & 1).wrapping_neg()))
    })
}
fn fnv64(d: &[u8]) -> u64 { 
    d.iter().fold(0xcbf2_9ce4_8422_2325, |h, &b| (h ^ b as u64).wrapping_mul(0x100_0000_01b3))
}
// Image = ver:u32 | len:u32 | payload | hash:u64
pub fn make_image(ver: u32, payload: &[u8]) -> Vec<u8> {
    let mut v = [&ver.to_le_bytes()[..], &(payload.len() as u32).to_le_bytes(), payload].concat();
    v.extend(fnv64(&v).to_le_bytes());
    v
}
#[derive(Clone, Copy)]
pub struct Meta { seq: u32, active: u8, tries: u8, ok: bool }
impl Meta {
    fn enc(&self) -> [u8; 12] {
        let mut b = [&self.seq.to_le_bytes()[..], &[self.active, self.tries, self.ok as u8, 0]].concat();
        b.extend(crc32(&b).to_le_bytes());
        b.try_into().unwrap()
    }
    fn dec(b: &[u8; 12]) -> Option<Meta> {
        (crc32(&b[..8]) == u32le(&b[8..]))
            .then(|| Meta { seq: u32le(b), active: b[4], tries: b[5], ok: b[6] == 1 })
    }
}
#[derive(Debug)] pub struct PowerCut;
pub struct Flash { slot: [[u8; SLOT]; 2], meta: [[u8; 12]; 2], fuse: u32, pub ops: usize, pub cut_at: Option<usize> }
impl Flash {
    pub fn new(img: &[u8]) -> Self { // factory: image in slot 0
        let mut f = Flash { slot: [[0xFF; SLOT]; 2], meta: [[0xFF; 12]; 2], fuse: 0, ops: 0, cut_at: None };
        f.slot[0][..img.len()].copy_from_slice(img);
        f.meta[0] = Meta { seq: 1, active: 0, tries: 0, ok: true }.enc(); f
    }
    fn program(&mut self, target: usize, off: usize, src: &[u8]) -> Result<(), PowerCut> { // 1 write = 1 op; a cut lands half the bytes
        let cut = self.cut_at == Some(self.ops); self.ops += 1;
        let n = if cut { src.len() / 2 } else { src.len() };
        let dst = if target < 2 { &mut self.slot[target][off..] } else { &mut self.meta[target - 2][off..] };
        dst[..n].copy_from_slice(&src[..n]);
        if cut { Err(PowerCut) } else { Ok(()) }
    }
    fn newest(&self) -> Option<Meta> { self.meta.iter().filter_map(Meta::dec).max_by_key(|m| m.seq) }
    fn put_meta(&mut self, m: Meta) -> Result<(), PowerCut> { // overwrite the OLDER copy
        let newest = self.newest().unwrap().seq;
        let i = if Meta::dec(&self.meta[0]).map_or(false, |x| x.seq == newest) { 1 } else { 0 };
        self.program(2 + i, 0, &m.enc())
    }
    fn image_ver(&self, s: usize) -> Option<u32> { // version if hash and fuse pass
        let b = &self.slot[s];
        let len = u32le(&b[4..]) as usize;
        if len > SLOT - 16 || fnv64(&b[..8 + len]).to_le_bytes() != b[8 + len..16 + len] { return None; }
        Some(u32le(b)).filter(|&v| v >= self.fuse)
    }
}
pub fn update(f: &mut Flash, img: &[u8]) -> Result<bool, PowerCut> { // inactive slot, verify, then flip
    let m = f.newest().unwrap();
    let t = 1 - m.active as usize;
    if u32le(img) < f.fuse { return Ok(false); } // downgrade refused
    for (i, c) in img.chunks(BLK).enumerate() { f.program(t, i * BLK, c)?; }
    if f.image_ver(t) != Some(u32le(img)) { return Ok(false); }
    f.put_meta(Meta { seq: m.seq + 1, active: t as u8, tries: MAXTRIES, ok: false })?;
    Ok(true)
}
pub fn boot(f: &mut Flash) -> Result<Option<u32>, PowerCut> { // newest valid meta picks the slot
    let Some(mut m) = f.newest() else { return Ok(None) };
    if !m.ok {
        if m.tries > 0 { m.tries -= 1 } else { m.active = 1 - m.active; m.ok = true } // rollback
        m.seq += 1; f.put_meta(m)?;
    }
    let v = f.image_ver(m.active as usize).or_else(|| f.image_ver(1 - m.active as usize));
    if let (true, Some(v)) = (m.ok, v) { f.fuse = f.fuse.max(v); } // fuse follows the confirmed image
    Ok(v)
}
pub fn confirm(f: &mut Flash) -> Result<(), PowerCut> { // called by the new image once healthy
    let m = f.newest().unwrap(); f.put_meta(Meta { seq: m.seq + 1, ok: true, ..m })
}
#[cfg(test)]
mod tests {
    use super::*;
    fn run(f: &mut Flash) -> Result<(), PowerCut> { // update, reboot, confirm
        update(f, &make_image(2, &[2; 40]))?;
        if boot(f)? == Some(2) { confirm(f)?; }
        Ok(())
    }
    #[test]
    fn power_cut_at_every_step() {
        let fresh = || Flash::new(&make_image(1, &[1; 40]));
        let mut f = fresh(); run(&mut f).unwrap();
        for cut in 0..f.ops { // cut at each flash write
            let mut f = fresh();
            f.cut_at = Some(cut); assert!(run(&mut f).is_err()); f.cut_at = None;
            let v = boot(&mut f).unwrap().expect("bricked");
            assert!(v == 1 || v == 2, "cut {cut}: booted {v}");
        }
        let mut f = fresh(); // never confirms: rolls back when tries_left hits 0
        assert!(update(&mut f, &make_image(2, &[2; 40])).unwrap());
        for _ in 0..MAXTRIES { assert_eq!(boot(&mut f).unwrap(), Some(2)); }
        assert_eq!(boot(&mut f).unwrap(), Some(1));
        run(&mut f).unwrap();
        boot(&mut f).unwrap(); // next boot of the confirmed v2 raises the fuse
        assert!(!update(&mut f, &make_image(1, &[1; 40])).unwrap()); // downgrade refused
    }
}

Compiled and tested: a clean run is 7 flash writes, so 7 cut points, each followed by a boot that must give v1 or v2. Writing metadata to the newest copy instead of the older one fails the sweep with "bricked".

Complexity and constants

  • Update time is image size over sustained flash write bandwidth, plus the download and one verification pass.
  • Metadata: 12 bytes in the model, two copies, each in its own erase sector on real flash. Writes per update: flip, one per trial boot, confirm. U-Boot's file-based bootcount is saved only while upgrade_available is set, sparing flash on ordinary reboots.
  • Hashing the image on every boot costs a full read; block-level checks such as dm-verity (named in the AOSP docs) avoid that.
  • Measured on this Mac (Apple M4 Pro), release build, memory-only simulation: about 0.58 µs per update+boot+confirm and 2.3 µs for the whole 7-point cut sweep. This says nothing about real flash speed.

Efficiency

  • Cost: one update writes the image once into the idle slot (flash-write bound), reads it once to verify, and writes a few 12-byte metadata records. Standing cost: about 2x image flash.
  • Levers:
    • delta updates — less download; AOSP notes a client can retry with a full package if a diff fails — images that change little, see binary delta diff
    • Virtual A/B snapshots with compression — snapshot size about 45% smaller for full OTAs, 55% for incremental (AOSP Virtual A/B docs) — phones with large partitions
    • single copy plus recovery image — SWUpdate reports 2.5-8 MB stand-alone systems — tiny flash
  • Naive → standard → state of the art:
    • overwrite in place · 1x flash, any power cut bricks
    • two slots, try counter, health confirmation · 2x flash, survives cuts, crashes and bad builds
    • snapshot/merge, signed delta downloads, rollback counter, staged rollout with halt on error rate · about 1x plus a snapshot area

Implementation notes

  • The flip is the last write; everything before it must be harmless if lost.
  • Metadata: a sequence number that cannot wrap in the device lifetime, a CRC over every field, and erased flash (0xFF) reading as invalid.
  • Confirm after the checks that matter (server reachable, application started), not at boot.
  • Pair with a watchdog: a hang counts as a failed trial only if something reboots the device. Persisting tries_left before booting makes boot loops end.
  • Data shared by both slots must stay readable by both versions, or a rollback meets data it cannot parse.

Pitfalls

  • Confirming too early turns a bad release permanent.
  • Never resetting the counter after a good boot: U-Boot expects an application to reset bootcount, otherwise good images get rolled back later.
  • Raising the fuse before confirmation: the old slot then fails the version check and the fallback bricks.
  • A version check inside the image is no security control; the counter must live where an attacker cannot reset it (MCUboot: software and hardware-based prevention).
  • Assuming the bootloader is covered: most SoCs hold one copy (SWUpdate docs).

Variants and alternatives

Swap with revert (MCUboot test swap)microcontrollers: the new image is swapped in and reverted at the next reset unless made permanent.
Overwrite-only / direct-XIPMCUboot modes without swapping; direct-XIP runs the image from either slot.
Virtual A/BAndroid's snapshot and merge instead of a full second copy.
Recovery layoutRAUC and SWUpdate also support recovery plus normal system.
Hot standbytwo live units for availability; A/B is two stored versions for recoverability.

In production

  • Phones and laptops: Android A/B updates through update_engine, now Virtual A/B; the AOSP docs name ChromeOS as another A/B user.
  • Microcontrollers: MCUboot test swaps, signed images, security counter.
  • Embedded Linux: Mender writes the inactive partition, checks the checksum and flips a bootloader flag; RAUC and SWUpdate work with U-Boot, GRUB, barebox or EFI.
  • Vehicles: Uptane specifies a per-image release counter against rollback of ECU images.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
MCUbootCmcu-tools/mcubootpip install imgtool (signing tool)————bootloader built into the firmware, e.g. via Zephyr; swap, overwrite, direct-xip
MenderC++mendersoftware/mendersee docs.mender.io client installation————A/B rootfs, checksum check, bootloader flag
RAUCCrauc/raucdistro package or build from source (see docs)————A/B or recovery layouts; U-Boot, GRUB, barebox, EFI
SWUpdateCsbabic/swupdateYocto layer or build from source————double copy or stand-alone recovery image; U-Boot, GRUB, EFI Boot Guard
U-Boot bootcountCu-boot/u-bootCONFIG_BOOTCOUNT_LIMIT————bootcount, bootlimit, altbootcmd, upgrade_available
toughRustawslabs/toughcargo add tough————TUF library: signed metadata, rollback protection

Sources

  • Android Open Source Project, A/B (seamless) system updates. docs
  • Android Open Source Project, Virtual A/B overview. docs
  • MCUboot, Design (swap types, image trailer, downgrade prevention). docs
  • Das U-Boot, Boot Count Limit. docs
  • Mender, Introduction. docs
  • RAUC documentation. docs
  • SWUpdate, Overview. docs
  • The Update Framework, Specification (rollback attacks, metadata versions). spec
  • Uptane, Standard 2.1.0. spec

Builds on

Used by

Systems (27):

Alternatives and related

Hot-standby redundancyA controller whose stop or restart is unacceptable (PLCs, gateways, servers in a control loop) needs a second unit that takes over seamlessly after a crash, while never letting two units drive the outputs.
code signing · plannedSigning binaries/apps with certificate chains, entitlements, timestamping and notarization
Digital signatures (Ed25519)Services, release pipelines and protocols need recipients to verify who produced a message and that it was not altered, without sharing a secret with every verifier (tokens, releases, webhooks).
staged rollout · plannedRolling, blue-green and canary rollout with health gates and automatic rollback
atomic file replacement · plannedWrite temp file, fsync, rename over target, fsync directory: crash-safe whole-file updates
crash consistency ordering · plannedOrdered writes, barriers and FUA so on-disk state is valid after power loss at any point