dynamic-config-embedded
Hot-reloadable configuration for no_std targets: no filesystem, no allocator,
no runtime.
[]
= { = "0.6.0", = false, = ["json"] }
use ;
use Deserialize;
static SETTINGS: = new;
// Compiled-in defaults, so the device is configured before anything arrives.
SETTINGS.store;
// A document from wherever this device gets one: a serial link, an MQTT
// message, a page of flash.
SETTINGS.apply?;
Why this is a separate crate
dynamic-config reads files, searches directories and merges layers with
figment. A microcontroller has no files, no directories and no allocator, and
figment is std — so this is not that crate with a feature switched off. It is
the same shape, built from what a device actually has.
What it keeps
- A snapshot in a
static, replaced whole. A reader never sees a half-applied configuration. - A bad document cannot take the device down. Parsing and validation happen before anything is installed; a failure leaves the previous configuration serving.
changes(), aFuturethat resolves on the next configuration — the same generation-counter-and-wakers design as thestdcrate, which is why it drives on Embassy, RTIC, or a hand-written executor.- Validation, through the same
Validateshape.
What it cannot keep, and why
| Files, directory search, profiles | there is no filesystem |
| Environment variables | there is no environment |
| Layered merging | figment is std, and merging needs a value tree that allocates |
Arc snapshots |
no allocator; readers clone the value out |
Provenance (source_of) |
there is one source, so the question does not arise |
A device gets one document at a time and replaces the whole configuration. That is not a reduced version of layering — it is what configuring a device looks like.
No allocator, and no lock a reader can block on
Storage is a critical-section around a plain slot: a handful of instructions
with interrupts masked, which is the primitive every embedded HAL provides and
the only one this crate needs. The section is held for a clone of the value and
nothing else.
That makes T: Clone the price of admission. For a configuration struct of
scalars — which is what a device's configuration is — the clone is a memcpy.
Awaiting a change, with no allocator
changes() needs somewhere to keep a waker, and a Vec<Waker> needs an
allocator. So there are four fixed slots — ConfigCell<Settings, 8> for eight —
chosen for the shape of the problem: a device has a handful of tasks that care
about configuration, not thousands. A fifth waiter replaces the occupant of a
slot rather than being dropped: a task that is never woken is a hang, and one
woken early merely polls again.
Past the budget the device stops idling. The evicted task wakes, polls, sees
no change and re-registers, displacing somebody else — so five tasks on a
four-slot cell trade wake-ups for as long as both are waiting, with no
configuration change between them. No wake-up is lost; the cost is entirely
power, and it is measured rather than feared
(one_task_past_the_budget_costs_the_device_its_idle_loop).
waiter_evictions() is how a firmware finds out, because the symptom otherwise
is a battery that empties early:
// On a bench, after the firmware has run everything it does.
assert_eq!;
There is no queue here and no plan for one. An intrusive list would lift the
cap without an allocator, at the price of unsafe in a crate that forbids it,
self-referential futures that must unlink on drop, and — measured — more RAM
per waiting task than a slot costs: a list node is a Waker plus its links,
where a slot is the Waker alone. A device knows its tasks at compile time, so
the compile-time budget is not a limitation to be worked around; it is the
right model, and the number belongs to the firmware.
The budget is not free either, and the sizes are small enough to state: on
thumbv7em-none-eabihf a ConfigCell<Settings, 4> is 56 bytes of RAM and a
ConfigCell<Settings, 8> is 88 — eight bytes per slot, for identical code
size. That is why the default is not simply larger.
Interrupts
Every read and write of the shared state happens inside a critical section, so
storing a configuration, reading one and registering a waker are all sound from
an interrupt handler. Two rules make that true, and the tests hold them: no
borrow outlives its critical section, and no waker is ever woken while one is
held — a wake belongs to the executor and may store a configuration or poll
the task it just woke before it returns.
The section is a scan of WAITERS slots and at most one Waker clone. Nothing
on that path parses, allocates or waits.
Features
| Feature | Default | Effect |
|---|---|---|
json |
✅ | Format::Json, via serde-json-core, which allocates nothing |
async |
changes() |
|
std |
the critical-section implementation a device gets from its HAL — for tests and host simulators |
A field the firmware does not know is ignored
serde skips it, which on a device is the difference between a rolling upgrade
and a fleet that stops taking configuration. A firmware that wants the opposite
says so with #[serde(deny_unknown_fields)].
Testing
Unit and integration tests run on a host with the std feature, which supplies
the critical-section implementation a device gets from its HAL — the same code
otherwise. CI also builds for thumbv7em-none-eabihf, because "it is no_std"
is a claim a host build cannot check.
The features matter: without std the integration tests compile to zero
tests, silently, and the run still reports green.
MSRV
1.83 — higher than the core crate's 1.71 because of language features, not
dependencies: core::error::Error in no_std needs 1.81 and inline const
blocks need 1.79. 1.83 is the floor CI verifies. Still far below anything a
device toolchain would struggle with.
License
MIT