Settings store¶
ww_settings (STD-32) keeps a device's settings in its own NOR flash: a plain struct with a default for every
field, stored as a record of the fields that differ from their default. It survives a power cut at any point of a
write or an erase. Old and new firmware read each other's records without losing anything, and the fields are
served as ordinary rw properties.
use ww_settings::{Debounce, Key, Settings, Store};
#[derive(ww_settings::Settings, Clone, Debug, PartialEq)]
#[settings(retired(3))] // slots of removed fields, never used again
pub struct UserSettings {
#[setting(slot = 0, default = ArrayString::new())]
pub label: ArrayString<32>,
#[setting(slot = 1, default = false)]
pub plug_vbus_at_boot: bool,
#[setting(slot = 2, default = ButtonAction::Toggle)]
pub button_action: ButtonAction,
#[setting(slot = 4, default = 100)]
pub meter_window_ms: u16,
}
The derive writes Default (every field at its default) and ww_settings::SettingsGroup: the slot table with
field names, the crate's version, and per field the code to compare it with its default, write it and read it.
- Slots are
0..=255, unique, in any order. A slot belongs to its field for good: renaming the field is fine, giving the slot to another field is not. A removed field's slot goes intoretired(..), and the derive refuses to reuse it. - Field types are owned values with ShrinkWrap serdes and
PartialEq: numbers,bool, enums and structs with#[derive_shrink_wrap], arrays, andshrink_wrap::ArrayString<N>/ArrayVec<T, N>(SW-57) for text and lists without an allocator. The same types work for properties, so a field and the property that serves it share one type. - Defaults are expressions:
default = 100,default = ButtonAction::Toggle, aconst.
On the device¶
// a range at the end of bank 2 of an STM32G0B1 (4 pages of 2 KiB), see "How many pages" below
let mut store: Store<_, 512> = Store::new(flash, 0x0807_8000..0x0808_0000)?;
let mut user: Settings<UserSettings, 128> = Settings::new(Key::USER, Debounce::default());
let report = user.load(&mut store).await?; // defaults for anything not stored
// apply them: here to the server's value_on_changed fields
server.button_action = user.get().button_action;
Settings<G, N> holds the value in RAM, the stored record (up to N bytes) and whether something changed since.
Store<F, B, K> holds the flash, a buffer of B bytes and the location of the current record of up to K keys
(groups). Each group has its own key: Key::USER (0) for what users change, Key::FACTORY (1) for calibration and
production data, written through a debug API only.
Served as properties. The API declares the settings as plain rw properties; the server keeps them in
value_on_changed fields (properties), and each changed_<name> handler
hands the new value to the settings, which only changes RAM:
impl Server {
fn changed_settings_button_action(&mut self, _cx: &mut Context<'_, impl BlockingEventOut>) {
let v = self.settings_button_action;
self.user.set(now_ms(), |s| s.button_action = v);
}
}
Written when it settles. set starts a Debounce: the record is written once the value was left alone for
quiet_ms (default 1 s), or max_ms (default 10 s) after the first change not stored yet, so a slider dragged for
a minute is written every 10 s, not on every step. The device's loop writes it:
loop {
let wake = user.due_at(); // None: nothing to write
match select(server.wait(), timer_at(wake)).await {
Either::First(ev) => server.handle(ev).await,
Either::Second(()) => {
user.persist_if_due(&mut store, now_ms()).await?;
}
}
}
A write happens only when the record changed: setting a value and setting it back before the write costs nothing, writing the same value again costs nothing.
Written before answering. Where an Ok must mean "survives a power cut", as ww_user_info promises for a
device name, the handler calls persist right away (an async handler, or a deferred reply):
Factory reset. user.reset_to_defaults(now) and persist: every field of the group at its default, an empty
record written. Other keys (Key::FACTORY) are not touched.
What load reports. LoadReport says whether a record was found, which version wrote it (writer) and the
highest version whose data is in it (newest, above the running firmware's after a downgrade), and how many fields
were read, how many slots belong to a newer firmware (kept on the next write) or to removed fields (dropped). A
record in an unknown format (a newer ww_settings) is left alone: is_read_only(), every field at its default,
persist refuses with Error::ReadOnly until a factory reset.
Dropping a write. A persist or maintain future dropped half way (a select! with a timeout) leaves the
flash as a power cut at that point would; the store goes on from there.
What is stored¶
A page holds a log of records, not one struct: each write appends a new record for its key, and the newest one wins. The record is the group's version and the fields that are not at their default, each as its own ShrinkWrap bytes:
record: | format: u8 = 1 | writer: 3 × UNib32 | newest: 3 × UNib32 | pad | slots: OptionAnyVec of byte blobs |
Every slot of the OptionAnyVec holds the field's
bytes as a blob (a length and the bytes). That is what lets a firmware skip a slot it doesn't know, keep it when it
writes the record again, and drop a retired one, which a plain sparse
struct can't do (its values carry no length). The cost is a length nibble and byte alignment per stored field.
In flash each record is an item: a 12-byte header (length, key, a 32-bit sequence number per key, CRC-32) and the
record, padded to the flash word. Sizes for a Donguru-like group (label, a bool, an enum, two u16, a UART
configuration), measured with record::encode and Store::footprint:
| Record | Bytes | G0 item / per 2 KiB page | F4 item / per 16 KiB | H7 item / per 128 KiB | SPI NOR item / per 4 KiB |
|---|---|---|---|---|---|
| nothing set | 4 | 16 / 127 | 16 / 1023 | 32 / 4095 | 16 / 255 |
one bool set |
8 | 24 / 84 | 20 / 818 | 32 / 4095 | 20 / 204 |
label "bench-3" |
14 | 32 / 63 | 28 / 584 | 32 / 4095 | 26 / 157 |
| all six set, 16-char label | 39 | 56 / 36 | 52 / 314 | 64 / 2047 | 51 / 80 |
(G0: 8-byte words, F4: 4-byte, H7: 32-byte, SPI NOR: 1-byte; a page loses 8 to 32 bytes to its header.)
When it compacts. When a record doesn't fit the current page any more, the store moves on to the next page (which is always erased or erasable), writes its header, and copies into it the current records that sit on the page after that one, so that page holds none and can be erased. On the G0 above, with every field set, that is every 36th write; on the H7 every 2047th. Each page is erased once per round, so the pages wear evenly.
How many pages¶
At least 2: the store always keeps the page after the current one free of current records, so it has a page to
compact into, and with only one page there would be nowhere to put the current records while the page is erased.
Store::new refuses fewer. The current records of all keys, plus the one being written, must fit into one page:
otherwise a write fails with StoreError::Full and what is stored stays readable. Keep them under half a page, so a
cut copy (which wastes its space until the page is erased) can't fill it.
More pages spread the erases: with p pages, n records per page and an endurance of E cycles (10 000 on the
STM32 parts below), the flash lasts about p · n · E writes. On the G0 with 4 pages and every field set:
4 · 36 · 10 000 ≈ 1.4 million writes, plenty with the debounce. On the G0, 4 pages (8 KiB) at the end of bank
2 is the recommendation: 2 would work, 4 halve the wear and leave room for records to grow.
What an erase stalls¶
A write programs a few words, an erase takes milliseconds to seconds, and on a single-bank part the CPU can't fetch code from flash while either runs: every interrupt, the USB stack and the executor stop until it is done (code and interrupt handlers in RAM keep running). From the datasheets:
| Part | Erase unit | Erase, typ / max | Program | Banks |
|---|---|---|---|---|
| STM32G0B1 (DS13560) | 2 KiB page | 25 / 40 ms | 8 bytes: 56 / 90 µs | 2 on the 512 KiB parts: no stall from the other bank |
| STM32F405/407/411 (DS8626, DS10314), x32 | 16 / 64 / 128 KiB sectors | 250/500 ms, 550/1100 ms, ½ s | 4 bytes: 16 / 100 µs | 1 (F42x/F43x: 2) |
| STM32H743/753 (DS12110) | 128 KiB sector | x8: 2 / 4 s, x16: 1.8 / 3.6 s | 32 bytes: 100 / 200 µs (x64) | 2 |
| STM32H723/725/730/733/735 (DS13313) | 128 KiB sector, 8 of them | as H743 | as H743 | 1 |
| STM32H7A3/7B3 (DS13195) | 8 KiB sector | max 2.2 ms | 16 bytes: max 20 µs | 2 |
| W25Q128JV SPI NOR | 4 KiB sector | 45 / 400 ms | 256 bytes: 0.4 / 3 ms | separate chip: no stall |
What that means on a single bank: USB keeps NAKing in hardware, so the host sees a pause, not a disconnect, but a request with a timeout shorter than the erase fails; the watchdog period has to be longer than the longest erase; anything with a deadline (a control loop, a UART without DMA) misses it. A G0 page erase (25 ms) is a hiccup; a 128 KiB sector (1 to 4 s) is not.
Store::maintain() moves the stall to a time the device picks: it erases the page the next compaction will
move to, if it needs it, so the write that starts a new page doesn't erase. Store::erase_pending() says whether
one is due. A device can call it when the host is idle, at boot, or answer a request with Busy meanwhile.
Parts with big sectors¶
On an STM32F4 or H7 the store needs two whole sectors, and on a single-bank part each erase stops the CPU for one to four seconds. The options, best first:
- The other bank. On a dual-bank part (H743/753, H7A3/7B3, F42x/F43x with
DB1M), put the store in the bank the code doesn't run from: erases don't stall anything. On the H743 that is 2 × 128 KiB of bank 2; on the H7A3 2 × 8 KiB; on an F42x in dual-bank mode bank 2 starts with 16 KiB sectors (12 to 15), the same as bank 1. -
The small sectors at the start of an F4. Sectors 0 to 3 are 16 KiB each. Sector 0 has to hold the vector table (the part boots from
0x0800_0000), so the store takes sectors 1 and 2 (or 1 to 3), and the code starts after them. Withcortex-m-rtthat is one symbol inmemory.x:MEMORY { FLASH : ORIGIN = 0x08000000, LENGTH = 1024K RAM : ORIGIN = 0x20000000, LENGTH = 128K } /* sector 0: vector table; sectors 1-2 (0x0800_4000..0x0800_C000): ww_settings; code from sector 3 */ _stext = 0x0800C000;The vector table stays at
0x0800_0000(no VTOR change); the rest of sector 0, about 15.5 KiB, stays empty (or holds a bootloader). An erase still stalls the CPU (250 / 500 ms for 16 KiB), usemaintain(). Flashing a new firmware with probe-rs erases only the sectors the image covers, so the settings survive it; a chip erase doesn't. 3. External SPI / QSPI NOR. 4 KiB sectors, about 45 ms per erase that doesn't touch the CPU (the bus is separate), 100 000 cycles; anyembedded-storage-asyncNorFlashdriver works. Not on a QSPI flash the code runs from (XIP): that stalls like internal flash. 4. Two of the big sectors withmaintain(). On a single-bank H723/H733 (8 sectors of 128 KiB) there is no way around a 2 s stall per erase; with 64-byte records it happens every ~2000 writes, andmaintain()puts it where it hurts least. 5. One sector with a RAM copy (not supported). A store in a single sector has to erase the sector that holds the only copy of every record, then write them back from RAM. A power cut from the start of that erase until the last record is written back loses every group, user settings and factory data alike: the device boots on its defaults. That window is the whole erase plus the rewrite, 1 to 4 s on a 128 KiB sector, once per sector fill. The RAM copy survives a soft reset, not a power cut.ww_settingsdoesn't offer this mode (STD-33); a device with exactly one sector to spare is better served by external NOR.
Power cuts¶
What the store guarantees, at any point of any write or erase:
loadreturns, for each key, either the record from before the interrupted write or the one being written, never anything else, and never an error.- After the cut, the next write works, and finishes what the interrupted one left (a half done compaction).
- Loading never writes: a device that boots in a brown-out loop wears nothing.
- Every flash word is written once between erases, and only words that read erased are written (STM32G0/H7 ECC flash refuses or corrupts a second write).
How: every item carries a sequence number per key and a CRC-32, so the current record of a key is the valid item
with the highest number wherever it is; a cut write leaves an item that fails its CRC and is skipped. A page carries a
sequence number in a header with its own CRC, which says which page is current. Before a page is erased, the current
records on it are copied away, so a cut erase can leave only older records and copies behind, in whatever order the
erase cleared the bytes. The layout is in the ww_settings::store module docs.
The tests cut the power at every word written and every byte erased of a run that goes several times around the
pages, on G0-like (8-byte ECC words, 2 KiB pages), H7-like (32-byte ECC words) and F4-like (4-byte words, 16 KiB
sectors) flash, with erases that clear the bytes front to back, back to front or all at once, and cut again while
recovering (ww_settings/tests/power_cut.rs; just test-power-cut runs every step, just test a sample).
ECC flash needs an ECC-aware driver. On the G0 and H7 a word whose write was cut has an uncorrectable ECC error,
and reading it raises an NMI (G0) or a bus fault (H7): a plain flash driver turns a power cut into a boot loop. The
driver has to return such a word as zeros, which the store skips like any damaged item (SRV-19, mcu/ww_ecc_flash).
Why not sequential-storage. Its map was the first choice. Under the same tests it ends permanently
Corrupted, or returns an older value, when an erase is cut while the end-of-page marker already reads erased but
the start marker doesn't: it assumes the start marker clears first, which a sector erase doesn't promise.
Evolution¶
The rules for changing a settings struct between firmware versions are in evolution rules. In short: add fields at new slots (also in gaps), retire removed ones, never change a slot's type except by that type's own rules, and know that changing a default changes the value of every device that never set the field.