Skip to main content
Version: 3.0.0-rc.2

Use FAT and exFAT on a microcontroller

Firmware without an allocator uses the embedded API of hadris-fat: hadris::fat::embedded::sync::Fat reads and writes FAT12, FAT16 and FAT32, and hadris::fat::exfat::embedded::sync::ExFat reads exFAT. Both have an async_ twin (r#async is an alias) for single-threaded executors such as Embassy. They are built on the hadris-fat-raw device primitives rather than on FatFs, keep one 512-byte block buffer and a fixed number of file slots, and need neither std nor alloc.

[dependencies.hadris]
version = "3.0.0-rc.2"
default-features = false
features = ["sync", "write", "fat"]

write adds format; drop it if the firmware only mounts cards that are already formatted. Use async in place of sync for the async API.

Provide a device​

The embedded API takes a device whose blocks are 512 bytes. Sync firmware implements hadris::storage::sync::BlockDevice; async firmware implements hadris::storage::async_::BlockDevice with poll hooks and owned operation state. Its futures need not be Send; the local path remains an alias. Adapt a custom device shows the trait. An SD card driver maps read_blocks and write_blocks onto its block commands.

Mount, write and read​

use core::ops::ControlFlow;
use hadris::fat::embedded::{MountToken, Options, sync::Fat};
use hadris::fs::{DirCursor, OpenOptions};

let mut token = MountToken::new();
let mut fat: Fat<'_, _> = Fat::mount_with(card, &mut token, Options::new())?;
let root = fat.root();
let logs = fat.create_dir_all(root, "data/logs")?;
let log = fat.open(logs, "boot.txt", OpenOptions::new().write().create().append())?;
fat.write(&log, b"booted\n")?;
fat.close(log)?;
fat.list(logs, DirCursor::START, |entry| {
// entry.chars() and entry.name_utf16() borrow the name for this call only.
ControlFlow::Continue(())
})?;
let card = fat.unmount()?;
  • A Dir is a Copy handle. Names are passed one component per call; create_dir_all is the one method that takes a path.
  • A File is a slot index that close consumes. Fat<'mount, D, FILES> has FILES slots, 4 by default. A separate MountToken identifies each mount and stays borrowed while the volume or any of its files remain usable. Foreign files fail with InvalidHandle, even for identical images. A dropped File keeps its slot until unmount, so close files you are done with.
  • list lends each entry to a callback, so no name is kept in the driver. Entry::node with open_node opens a listed file without a second lookup.
  • Options sets the clock (with_clock(fn() -> DateTime)), the UTC offset, the code page (CP437 by default), read-only mounting and the name fold.

Names fold ASCII case by default, so no Unicode case tables are linked. Options::new().with_fold(hadris_fat_raw::fold_unicode) compares names as Windows and FatFs do, for about 2 KB of flash.

ExFat::mount(dev, &mut token) works the same way for reading: open_dir, list, open, open_node, read, seek, close, metadata, label and stats. It never writes. It is a separate type, so FAT-only firmware does not link it.

Power loss and cancelled futures​

The drivers order writes and retain some recovery state in memory for failed or cancelled operations. A later write or sync on the same mounted driver can finish that recovery. This state does not survive a reset: power loss can leave lost clusters, torn metadata or partially completed renames. The API provides no transaction or rollback guarantee; see the known durability limits.

A file's size reaches its entry at flush, close, sync and unmount. Call flush after a record that must be durable, and close files before unmounting. The device's flush must reach persistent storage. Async DMA poll hooks must stop accessing caller buffers before returning, including Pending. DMA continuing between polls needs owned stable transfer buffers; device requirements describe this boundary.

Budget​

The examples/firmware package builds firmware-shaped sessions for three targets, and CI measures them with scripts/firmware-size.py at opt-level = "s" with fat LTO:

  • fat-log: a data logger. It creates a directory and a short-named file, appends, lists and reads back.
  • fat: every kind of call. It adds long names, nested directories, rename and remove_dir_all.
  • fat-unicode: fat with fold_unicode.
  • fat-async: fat on the async API, polled by a minimal executor.
  • exfat: the exFAT reader. It reads the label and free space, lists the root and reads a file.

Measured on 2026-09-26 with nightly-2026-09-04. Sizes are in bytes and exclude the device driver:

TargetSessionFlashDriver stateMount stackWorst stack
thumbv6m-none-eabi (Cortex-M0+)fat-log4026893619444632
fat4521693619444608
fat-unicode4728893619444608
fat-async68728936-11432
exfat13828101619524440
thumbv7em-none-eabihf (Cortex-M4F, M7)fat-log3994093618804408
fat4482493618804384
fat-unicode4690893618804384
fat-async63848936-10720
exfat13680101618644312
riscv32imc-unknown-none-elf (ESP32-C3 class)fat-log4662493618564288
fat5353093618564304
fat-unicode5564893618564304
fat-async73748936-10624
exfat15994101618564288
  • Flash is text, rodata and data of the whole image, which is the session and the driver.
  • Driver state is size_of::<Fat<'_, D>>() or size_of::<ExFat<'_, D>>() with 4 file slots. Keep it in a static or on the stack; the driver has no other RAM and no statics of its own.
  • Mount stack is the deepest stack below mount, without the driver state it returns.
  • Worst stack is the deepest stack from reset through the session. It includes the driver state and the session's own buffers, and for fat-async the pinned future of the whole session.

The stack figures follow the direct calls in the binary. Calls through a function pointer, such as the clock and the fold, and compiler builtins such as memcpy count as 0.

What fits, going by the thumbv6m and thumbv7em rows:

PartFits
Cortex-M0+, 32 KB flashThe exFAT reader, not FAT
Cortex-M0+, 64 KB flash, 8 KB RAMSync FAT read and write, with about 3.5 KB of RAM left beside the worst stack
Cortex-M4F, 256 KB flash, 64 KB RAMSync or async FAT and the exFAT reader together

CI fails when a mount stack reaches 2 KB or driver state reaches 2 KB (NF-STACK-01 in docs/v3/actions.md), when a Hadris stack frame exceeds 1 KB (NF-STACK-02), or when the fat-log flash on thumbv7em grows past its 44 KB ceiling. The 20 KB flash target of NF-FLASH-01 is a goal for 3.x, not a 3.0 requirement.

To measure locally:

rustup toolchain install nightly-2026-09-04 --component llvm-tools \
--target thumbv6m-none-eabi,thumbv7em-none-eabihf,riscv32imc-unknown-none-elf
RUSTUP_TOOLCHAIN=nightly-2026-09-04 scripts/firmware-size.py --check

The sessions also run on the host against a freshly formatted memory device: cargo run -p hadris-example-firmware --bin fat-log.