An experiment in capability-style resource management for Zig 0.17: acquire a resource (e.g. an embedded MCU peripheral) in one place, hand out restricted tokens to the rest of the program, and have the compiler — or, as a fallback, a guaranteed runtime trap — reject programming errors where two parts of the code touch the same resource incorrectly.
The protected rules are:
- Singleton acquisition — a peripheral is acquired once. A second acquisition is rejected.
- Single writer, many readers — a peripheral has at most one read-write
Writerat a time, but any number of copyable read-onlyReaders. - No accidental escalation — a subsystem holding a
Readercannot write, and no public API hands back the raw register pointer. (See Honest limits for the important caveat here.)
The design follows the capability discipline: main() is the root of authority.
It alone mints capabilities from raw resources and hands tokens downward.
Subsystems receive only tokens and cannot manufacture more authority than they
were given.
| Rule | Comptime-known flow (comptime.zig) |
Runtime flow (runtime.zig) |
|---|---|---|
| Singleton acquire | hard @compileError |
runtime @panic on 2nd take() |
| Single-writer | hard @compileError |
runtime @panic on 2nd writer() |
| Reader cannot write | compile error (Reader has no write) |
same |
| No public raw accessor | compile error (no raw() method) |
same |
The two mechanisms exist because of a hard fact about Zig (below): full comptime
enforcement is only possible when the whole acquisition flow is itself evaluated
at comptime. For the ergonomic Rust-style runtime flow, singleton/exclusivity
fall back to a runtime trap — exactly as Rust's own Peripherals::take() does.
Zig's comptime is powerful, but it has no persistent mutable global across
independent evaluations. A container-level var is runtime storage, not a
comptime counter — trying to mutate one from a comptime block is an error
("unable to evaluate comptime expression; operation is runtime due to this
operand"). And Zig has no borrow checker, so it cannot track that a by-value
token was "moved" and must not be used again.
Consequences:
-
Within a single comptime evaluation (one
comptime { ... }block or one comptime function-call tree), acomptime varregistry does persist. So if your peripheral assignment is comptime-known, we can record what has been claimed and emit a hard@compileErroron a duplicate. This issrc/comptime.zig. Zero runtime state, zero runtime cost; the tokens are zero-sized types. -
Across arbitrary separately-compiled functions at runtime, "this peripheral is claimed exactly once, program-wide" cannot be a comptime check — there is no comptime state that spans those evaluations, and no move checker. So
src/runtime.ziguses a runtime singleton guard (take()flips an atomic bool, second call@panics). This is the Rust-embedded model.
The comptime mechanism never touches a register — it is a compile-time
allocation plan. ClaimRegistry decides, and the compiler checks, which
subsystem owns write access to which peripheral (one writer, many readers),
emitting a hard @compileError on a double-assignment. The runtime
VolatileResource is what actually performs the volatile register I/O.
You fuse the two with a tiny gate, a method on the runtime Owner:
owner.openWriter(token) / owner.openReader(token) (see
examples/combined.zig). It hands out a runtime Writer/Reader only when
presented with the matching comptime claim token, asserting at comptime that
the token names this resource and carries sufficient access. Because the
resource is the Owner, there is no separate resource argument. It is
essentially:
// a method on Resource(...).Owner; `name` is the resource's own name
pub fn openWriter(self: Owner, comptime token: anytype) Writer {
comptime {
if (!std.mem.eql(u8, @TypeOf(token).resource_name, name))
@compileError("claim token does not match resource");
if (!std.mem.eql(u8, @TypeOf(token).access, "rw"))
@compileError("need a write claim to open a Writer");
}
return self.writer();
}What this buys, precisely: the assignment (one writer per peripheral) is now
checked at comptime by the plan — the upgrade over the runtime-only example,
where single-writer is only a runtime panic. Presenting a TIMER0 claim to open
the UART0 writer is a compile error (negative/fail_combined_mismatch.zig).
Two caveats, so this is not oversold: take() (claimed-once) remains a
runtime guard, and the token→accessor link is the gate convention — the
type system does not forbid calling Timer0.take().writer() directly and
skipping the plan; the gate makes the sanctioned path checkable, not the raw
path unreachable.
Acquisition has a counterpart: release, so a resource can be handed back and re-acquired rather than held for the whole program. What is enforced splits the same way as acquisition — and, candidly, the strongest option is not achievable in Zig:
-
Scope-based, comptime-enforced ("leaving scope ⇒ use is a compile error"). Not possible for the runtime flow. Zig has no borrow checker and no move/ linearity tracking, so nothing can make an already-handed-out
Writer/Readera compile error to use after a point. This is the same root cause as whytake()itself is a runtime guard rather than a comptime one. -
Runtime release (what you get).
Owner.release()clears the guards so the resource can betake()n again;Owner.releaseWriter()/Writer.release()free just the single-writer slot. Scope ergonomics come fromdefer:const owner = Timer0.take(); defer owner.release(); // released at end of scope
-
Opt-in use-after-release trap (runtime). When enabled, using a
Writer/ReaderafterOwner.release()is a runtime@panic(not a compile error). It is gated byOptions.checked_release: ?bool:null(default) followsstd.debug.runtime_safety— on inDebug/ReleaseSafe, off inReleaseFast/ReleaseSmall, so production firmware pays nothing — andtrue/falsepin it. The decision is made once at the resource's definition site, so it is stable for the type (a per-scope@setRuntimeSafetyelsewhere can't move it). When off, the flag and checks fold away entirely. -
Comptime release — but only in the
ClaimRegistryplan. The comptime mechanism does get release with compile-time teeth, because it tracks claims as comptime state within one evaluation:releaseWriter/releasefree a claim so it can be re-claimed later in the same comptime flow, and releasing something not held is a hard@compileError(negative/fail_release_unheld.zig). This governs the assignment plan, not runtime register access.
In short: release is a runtime lifecycle (with an opt-in runtime trap for use-after-release), plus true compile-time release within the comptime plan. "Use-after-scope is a compile error" is the one thing Zig cannot give here.
- Rust
svd2rust/cortex-mPeripherals::take()— the canonical embedded pattern. Peripherals are singletons;take()returnsSomeonce thenNone/panics. Crucially,take()'s singleton check is a runtime guard (anOption/flag), not compile-time — even Rust can't catch a double-take()at compile time. Rust's compile-time safety comes afterwards, from move semantics and the borrow checker on the token you now own. https://doc.rust-lang.org/stable/embedded-book/peripherals/singletons.html - Zig has no borrow checker, so the "after you own it" compile-time half of
the Rust model doesn't transfer. The Zig community idiom for register safety is
"hide the address, expose only
read/writemethods" (ziggit, Enforcing read only struct fields). This project builds on that idiom and adds the comptime-registry mechanism for the comptime-known case. - MicroZig (the main Zig embedded framework) does not enforce peripheral ownership — it exposes register definitions directly. So the comptime enforcement here is filling a real gap, not reimplementing an existing Zig HAL feature.
- C++ emulates this with
std::move-based ownership and template type-state, but — like Zig — cannot detect double-acquire at compile time without a move checker; it relies on runtime guards or discipline.
src/runtime.zig Runtime mechanism: Resource(name, backing, opts) ->
take() singleton guard, Owner, Writer (exclusive),
Reader (shared, read-only). Facades expose scalar
read/write plus reg("CR") (a pointer to a register
sub-object, for method-bearing registers like MicroZig's
Mmio). `backing` is any pointer (an already-`volatile`
one is accepted). Wrappers: VolatileResource
(opts.volatile_access = true) for MMIO; PlainResource
(false) for ordinary in-memory state.
src/comptime.zig Comptime mechanism: ClaimRegistry with claimWriter /
claimReader / take (and claim*For(Res), which derive the
name from a resource's `resource_name`) producing hard
@compileError on duplicate. Zero-sized tokens.
src/root.zig Module root re-exporting both mechanisms' public API
flat as the `capabilities` module.
examples/runtime.zig Runtime mechanism only: main() mints caps, hands Writer
to a driver and Reader to telemetry; telemetry
structurally cannot write.
examples/comptime.zig Comptime mechanism only: whole assignment resolved at
comptime; tokens are zero-sized; no I/O.
examples/combined.zig Both mechanisms together on MCU registers: a comptime
ClaimRegistry plan plus runtime VolatileResource I/O,
tied by a token-checking gate (name/access verified at
comptime). See "Applying the comptime mechanism...".
negative/fail_*.zig Six files that MUST NOT COMPILE, each declaring its
expected compiler-error substring.
scripts/negative_tests.sh Harness: compiles each fail_*.zig and asserts it
fails with the expected error.
Requires Zig 0.17.0 on PATH.
zig build test # library unit tests + compile the examples
zig build run-runtime # run the runtime-mechanism example
zig build run-comptime # run the comptime-mechanism example
zig build run-combined # comptime plan + runtime I/O on (mock) registers
zig build negative # assert all negative tests fail to compileExpected run-comptime output shows the tokens are zero-sized:
timer_writer: TIMER0 access=rw size=0
timer_reader: TIMER0 access=ro size=0
uart_writer: UART0 access=rw size=0
timer_writer2:TIMER0 access=rw size=0 (re-claimed after release)
zig build negative compiles each negative/fail_*.zig and checks it fails with
the error documented in that file's header, e.g.:
ok fail_second_writer.zig: failed to compile with expected error
expected: second Writer claimed for peripheral 'TIMER0'
This experiment is deliberately candid about what Zig can and cannot guarantee:
-
No private struct fields — and that is deliberate in Zig. Zig has no field-level visibility modifiers at all; every struct field is public by design. The Zig position (see the ziggit "Enforcing read only struct fields" thread and the language designers' comments) is that private-fields-plus- getters/setters is a Java-style anti-pattern, and that fields are data you should name carefully and document rather than wall off. So the facade's internal backing-store pointer is technically reachable from outside the module. To make that reach conspicuous rather than casual, the pointer lives behind an
implwrapper field (Reader.impl.store/Writer.impl.store) carrying a///"not public API" doc comment — the strongest convention-level discouragement Zig offers (it is the sameManaged/Unmanaged-style signalling the standard library uses). The guarantees we actually make — and test — are that no public method (raw(),ptr(), …) exposes the pointer (fail_raw_escape.zig) and that the casualr.storepath does not compile (fail_plain_store.zig); the Reader's pointer is additionally*const volatileso the backing store cannot be written even viar.impl.store. What remains possible is a deliberater.impl.storeread on a Writer — a bypass that now reads as an obvious, auditable act. This is directly analogous to Rustunsafe: the model protects against mistakes, not against a programmer who sets out to defeat it. (The originalfail_raw_escapetest tried to assert field privacy, compiled, and that is how we discovered and documented the limit rather than shipping a false claim.)If you need true hiding (not just a conspicuous name), the sanctioned Zig idioms make the pointee type unnameable rather than the field private:
opaque {}— define the real register layout as an opaque type, hand out*Opaque, and only module-internal code can@ptrCastit back to a usable type.@fieldParentPtr— expose a tiny public handle struct, keep the private data in an enclosing struct, and recover it internally via@fieldParentPtr. Both cost one cast at the module boundary. Even then, a determined caller can reconstruct access via@ptrFromInt/byte casting — hiding the name is the realistic goal, exactly as with Rustunsafe.
-
Runtime, not comptime, for the ergonomic flow. As explained above, the Rust-style
take()singleton and single-writer rules are runtime traps inruntime.zig. They become compile errors only in the comptime-known flow (comptime.zig). -
No accepted language proposal would change this (audited). The runtime fallback is not a gap awaiting a feature — it is a standing language-design position. An audit of the accepted proposal set on both trackers found nothing providing affine/linear types, move/consume-once semantics, a borrow checker, or ownership tracking:
- Codeberg (the current tracker since Zig's 2025 GitHub→Codeberg move):
the
acceptedlabel carries 15 proposals; none are ownership/lifetime related. - GitHub archive (the historical corpus, ~533 accepted issues): the only ownership-adjacent hit, #733 "add another way of passing non-copyable things as parameters", is completed but is a calling-convention feature (pass-by-const-reference, compiler chooses by-value vs by-ref for perf) — it has no use-after-move or linearity semantics.
- The two proposals that would have been relevant were both abandoned:
move semantics (#190, closed, never
accepted) and pinned structs (#7769, closed as not planned) — and even #7769 was scoped only to preventing accidental relocation of self-referential values ("not related to RAII… only prevents you from accidentally relocating an existing object"), not consume-once/use-after-release checking. So "use-after-scope/release is a compile error" is something Zig cannot give today and has no planned path to giving.
- Codeberg (the current tracker since Zig's 2025 GitHub→Codeberg move):
the
-
Mock backing stores. Backing structs are plain
structglobals so the examples run on the host. For the volatile (MMIO) case on a real target you would overlay apacked structon a fixed address via@ptrFromInt; for the non-volatile (PlainResource) case the backing is just an ordinary global. The capability mechanism is unchanged either way. -
No threads. Concurrency is out of scope for this proof-of-concept. The runtime guards use atomics so they are at least not racy, but cross-thread aliasing of a resource is not modelled.
-
Split into two independent modules. The runtime and comptime mechanisms share no code and no types — only the conceptual rule set. They could live as two separate modules in two subdirectories (e.g.
runtime/andcomptime/), each with its ownbuild.zigmodule, so a consumer can depend on just one. Kept as a singlecapabilitiesmodule for now because the project is a single cohesive experiment. -
Generality of the backing store.
Resourceis now parameterized overOptions.volatile_access, covering both MMIO (VolatileResource) and ordinary in-memory state (PlainResource). The comptime mechanism (ClaimRegistry) needs no such parameter: it validates an assignment plan purely by name and never touches a backing store, so it is already backing-agnostic. Further axes (alignment, atomics mode) could be added toOptionsif a use case appears. -
MicroZig integration. Probed, not built: the mechanisms can wrap MicroZig's generated
*volatileregister blocks today (theWriterhands back a register, MicroZig'sMmiodoes the I/O), with two small backward-compatible fixes to this repo (accept an already-volatilebacking pointer viaanytype; add a register-orientedreg("CR")accessor alongside scalarread/write). Making the capability path first-class would want a tiny opt-inregzenhancement (a generatedSingleton/ownednamespace), not a MicroZig dependency on this repo. Seedocs/microzig-integration.md. -
Factor an internal
Singletonprimitive.Resource's one-timetake()guard and the standaloneSingletonshown inexamples/microzig/src/regz_singleton.zigare the same idea implemented twice.Resourcecould be built on a small internalSingleton(name, ptr)primitive (acquire a backing pointer exactly once, then wrap it in the Reader/Writer facade), removing the duplication and giving the library a named, reusable guard. Crucially, this primitive would live entirely in this library and depend on nothing from MicroZig — current or improved. The example'sregz_singleton.zigis only an illustrative copy of what MicroZig could choose to generate; the two are independent, and this refactor must not introduce any build- or source-level dependency on MicroZig. -
Force Writer for registers that actually mutate hardware (SVD access info). SVD/ATDF data (and thus
regz) carries per-register/per-field access attributes — read-only, write-only, read-write, and side-effecting kinds like read-to-clear (interrupt/status flags). Idea: use that to make the capability distinction match the hardware — require aWriteronly for registers whose access actually changes state (RW/WO/read-clears), while purely read-only registers could be read by anyone, any time, without taking aWriterat all. Open questions to investigate: does MicroZig's generatedMmio/ regz output expose the access kind at the type level (so this can be enforced at comptime), or only in the SVD source? If exposed,reg("X")could return a read-only vs read-write handle based on the register's declared access, andreadof a read-to-clear register would be classified as a write (it has a side effect). This stays an application/adapter concern layered over MicroZig — no dependency added to this library.