Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

Step 46 of 56

Block devices and wear levelling

One abstraction the filesystems talk to, thin adapters underneath it, and a filter in the middle that stops a hot metadata block from killing one sector of your flash.

A vtable, not a tagged type

type Sector       is array (0 .. 511) of Interfaces.Unsigned_8;
type Sector_Index is new Interfaces.Unsigned_64;

type Read_Proc  is access procedure (Ctx : System.Address; LBA : Sector_Index; Data : out Sector);
type Write_Proc is access procedure (Ctx : System.Address; LBA : Sector_Index; Data : Sector);
type Count_Func is access function  (Ctx : System.Address) return Sector_Index;

ESP32S3.Block_Dev is a record of access-to-subprogram plus an opaque context — mirroring lwext4's ext4_blockdev vtable. No tagging, no finalization, swappable at run time. That keeps it usable from anywhere and makes a device something you can substitute in a test.

Behind it sit thin adapters: SD_SPI_Source and SDMMC_Source for SD cards, W25Q_Source for SPI NOR flash, and a file-backed device in the host test harness — which is what lets the whole filesystem stack be developed and tested on a PC.

The error model is Ada's: the primitive Read/Write raise Ada.IO_Exceptions.Device_Error on a hardware failure, with the adapters converting the SD driver's Status enum into that raise. So a media error propagates rather than being quietly returned and ignored.

The wear-levelling filter

Flash wears out per erase block. A filesystem writes some blocks far more often than others — an ext4 metadata block, say — so without intervention one physical sector dies long before the rest of the chip.

Block_Dev.WL is a Block_Dev over a Block_Dev: it takes the raw medium and presents a smaller logical device whose sectors are remapped so that, over time, every logical 4 KB block visits every physical 4 KB block. Because it is a plain filter it carries no flash-specific code and runs unchanged on the host, where it is brute-force tested.

--  a typical stack, bottom to top
W25Q flash  ->  Block_Dev.W25Q_Source  ->  Block_Dev.WL  ->  Ext4

O(1) state: just a move counter — there is no per-block map to keep in RAM or rebuild at mount. A move erases the destination block in one shot through the lower device's optional Erase_Sectors before copying, so each move costs exactly one erase.

This is dynamic wear levelling only. It bounds how unevenly write activity wears the chip, but it does not actively relocate cold, never-written blocks — so data you write once and never touch keeps its physical block out of rotation. That is sufficient for a 32 MB part; it is not the same guarantee as static wear levelling.

Config blocks are rewritten once per move, which is their wear cost. Raising Update_Rate trades levelling aggressiveness for less move and config overhead.