← Home Few Bits
Cover image for QuietOTA — Tiny, Signed Firmware Updates Over Serial

QuietOTA — Tiny, Signed Firmware Updates Over Serial

Alex Solis Alex Solis ·

Why QuietOTA?

OTA for tiny MCUs is often overkill: Wi‑Fi stacks, HTTP servers, and huge crypto libraries. But many projects still need field updates—sensors, routers, tiny appliances—where you can plug in a UART or a USB serial adapter and push a new image. QuietOTA is a deliberately small protocol and implementation pattern for that scenario: chunked transfer, small RAM footprint, atomic commit, and optional signature verification for tamper protection.

Design overview

The goals are simple:

  • Work over a raw byte stream (UART, USB CDC, Single‑wire serial).
  • Use fixed‑size chunks so you can stream without loading the whole image in RAM.
  • Write to a secondary flash partition (swap area) then flip a byte to commit.
  • Verify integrity with CRC32 and authenticity with an optional signature verification step.
  • Handle power loss and corrupted transfers cleanly: device should boot previous image until commit is complete and valid.

Protocol in brief

Keep it small. The sender streams a header, then fixed 512‑byte payload frames. Receiver responds with ACK/NACK per frame. A final footer contains CRC32 and an optional signature. The device only switches to the new image after footer verification.

  1. Sender: send HELLO (image length, chunk size, version, flags).
  2. Receiver: respond READY or REJECT (space/compatibility).
  3. Sender: stream frame 0..N: each frame = frame_header + payload. Receiver writes payload to swap flash and replies ACK/NACK.
  4. Sender: send FOOTER (CRC32, version, signature if present). Receiver verifies CRC and signature. If OK, mark new image as committed.

Packet formats

Use tiny fixed headers. Example (text uses code tag):

HELLO = 0x01 | u32(image_len) | u16(chunksz) | u32(version) | u8(flags)

FRAME = 0x02 | u32(seq) | payload[chunksz]

FOOTER = 0x03 | u32(crc32) | signature[len]?

ACK/NACK can be single bytes: 0x06 for ACK and 0x15 for NACK (or custom codes). Keep timeouts conservative for flaky USB‑serial links.

Implementation steps (pragmatic)

  1. Reserve a swap region in flash: size equal to max firmware image. Layout: primary, swap, metadata page.
  2. Write a small metadata page structure in flash with these fields: magic, active_version, swap_pending, swap_len, swap_crc. Make it aligned to a flash page so updates are atomic (erase+write semantics).
  3. On HELLO, check that swap area has enough space and that there is no in‑progress incompatible state. Return READY.
  4. For each FRAME: write payload directly to swap at offset = seq * chunksz. Compute running CRC32; you can also compute CRC after full write by reading swap back in pages if you prefer flash‑friendly patterns.
  5. For FOOTER: compare calculated CRC to sent CRC. If signature flag is set, verify signature using a small library or your bootloader routine. If valid, set swap_pending=true and update metadata to indicate pending commit and version number. Reply COMMIT_OK.
  6. On next boot, bootloader checks metadata. If swap_pending is true and checksum/signature checks, swap primary pointers (or copy swap to primary) and clear swap_pending. Otherwise keep running current image.

Signature options

If you want authenticity, add a signature over the entire image (or over CRC + version). Ed25519 is compact and fast on many MCUs, but the library cost may be nontrivial. Alternatives:

  • Keep a small, optimized Ed25519 verify only implementation (no keygen). There are tiny ports that fit into a few KB of flash.
  • Rotate to HMAC‑SHA256 keyed by a manufacturing secret if symmetric trust is acceptable.
  • Skip signatures for fully offline trusted environments; rely on CRC + physical access control.

Atomicity and power loss

Two rules keep you sane:

  • Never overwrite the primary image until the new image is fully verified and committed.
  • Keep metadata updates idempotent and page‑aligned so a partial write leaves the device in a known state.

If power dies mid‑transfer, the bootloader sees swap_pending=false and boots primary. If power dies after writing footer but before metadata is fully updated, design the metadata so the bootloader can validate the swap area directly (CRC + signature check) and complete the swap safely.

Resource budget (typical)

Target devices with 32–256KB flash and 8–64KB RAM. Minimal implementation costs:

  • Protocol parser and state machine: a few hundred bytes of flash.
  • CRC32: tiny (a few dozen bytes).
  • Flash write/erase helper: depends on HAL; reuse existing driver.
  • Optional Ed25519 verify: ~6–12KB flash on well‑optimized ports; RAM < 2KB if streaming verify is available.

Testing & troubleshooting

  1. Start locally: use a small test image that prints startup text and a distinct version number. Iterate the protocol tool (Python script) until sequencing and ACK/NACK handling are solid.
  2. Simulate bad frames by introducing bit errors in the sender; ensure receiver NACKs and transfer resumes from the last good sequence.
  3. Simulate brownouts: unplug power mid‑transfer at several points and observe bootloader behavior. Ensure no corruption of primary image.
  4. Measure time for flash erase + write for your MCU and choose chunk size to match page boundaries where possible—this reduces write amplification and simplifies recovery.

Wrap up

QuietOTA trades the bells and whistles of full OTA stacks for reliability, predictability, and tiny resource use. It fits a common reality: you have a serial cable and a fleet of small devices. With chunked streaming, atomic commit, and optional signatures you get safe field updates without pulling in a heavy network stack or a full crypto suite. As always: test power‑loss cases and keep a rescue path (bootloader serial console) on every device—it's what will save you at 3AM when an update goes sideways.

Tip: keep a small recovery command in your bootloader to erase swap and clear pending flags. It’s the single most useful thing you’ll wish you had when debugging updates.