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:
- Check. Refuse an image whose version is below the fuse or whose signature fails (code signing).
- 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.
- Verify the written slot (hash, signature). Only a verified slot may be marked.
- 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. - Boot. Take the valid copy with the highest
seq. Ifsuccessis false, persisttries_left - 1and boot that slot; at 0, switch back to the previous slot and setsuccess. - 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. - 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_availableis 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_leftbefore 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
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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| MCUboot | C | mcu-tools/mcuboot | pip install imgtool (signing tool) | — | — | — | — | bootloader built into the firmware, e.g. via Zephyr; swap, overwrite, direct-xip |
| Mender | C++ | mendersoftware/mender | see docs.mender.io client installation | — | — | — | — | A/B rootfs, checksum check, bootloader flag |
| RAUC | C | rauc/rauc | distro package or build from source (see docs) | — | — | — | — | A/B or recovery layouts; U-Boot, GRUB, barebox, EFI |
| SWUpdate | C | sbabic/swupdate | Yocto layer or build from source | — | — | — | — | double copy or stand-alone recovery image; U-Boot, GRUB, EFI Boot Guard |
| U-Boot bootcount | C | u-boot/u-boot | CONFIG_BOOTCOUNT_LIMIT | — | — | — | — | bootcount, bootlimit, altbootcmd, upgrade_available |
| tough | Rust | awslabs/tough | cargo 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