Skip to content

About

Try to protect e.g. embedded Zig code from conflicts around accessing resources

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

26 Commits

Folders and files

Repository files navigation

Comptime capability-based resource management in Zig

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:

  1. Singleton acquisition — a peripheral is acquired once. A second acquisition is rejected.
  2. Single writer, many readers — a peripheral has at most one read-write Writer at a time, but any number of copyable read-only Readers.
  3. No accidental escalation — a subsystem holding a Reader cannot 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.

TL;DR — what is enforced where

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.

The comptime/runtime boundary (why two mechanisms)

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), a comptime var registry does persist. So if your peripheral assignment is comptime-known, we can record what has been claimed and emit a hard @compileError on a duplicate. This is src/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.zig uses a runtime singleton guard (take() flips an atomic bool, second call @panics). This is the Rust-embedded model.

Applying the comptime mechanism to MCU registers

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.

The release model (lifecycle)

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/Reader a compile error to use after a point. This is the same root cause as why take() itself is a runtime guard rather than a comptime one.

  • Runtime release (what you get). Owner.release() clears the guards so the resource can be take()n again; Owner.releaseWriter() / Writer.release() free just the single-writer slot. Scope ergonomics come from defer:

    const owner = Timer0.take();
    defer owner.release();   // released at end of scope
  • Opt-in use-after-release trap (runtime). When enabled, using a Writer/Reader after Owner.release() is a runtime @panic (not a compile error). It is gated by Options.checked_release: ?bool: null (default) follows std.debug.runtime_safety — on in Debug/ReleaseSafe, off in ReleaseFast/ReleaseSmall, so production firmware pays nothing — and true/ false pin it. The decision is made once at the resource's definition site, so it is stable for the type (a per-scope @setRuntimeSafety elsewhere can't move it). When off, the flag and checks fold away entirely.

  • Comptime release — but only in the ClaimRegistry plan. The comptime mechanism does get release with compile-time teeth, because it tracks claims as comptime state within one evaluation: releaseWriter/release free 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.

Prior art

  • Rust svd2rust / cortex-m Peripherals::take() — the canonical embedded pattern. Peripherals are singletons; take() returns Some once then None/panics. Crucially, take()'s singleton check is a runtime guard (an Option/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/write methods" (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.

Layout

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.

Building and running

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 compile

Expected 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'

Honest limits

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 impl wrapper field (Reader.impl.store / Writer.impl.store) carrying a /// "not public API" doc comment — the strongest convention-level discouragement Zig offers (it is the same Managed/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 casual r.store path does not compile (fail_plain_store.zig); the Reader's pointer is additionally *const volatile so the backing store cannot be written even via r.impl.store. What remains possible is a deliberate r.impl.store read on a Writer — a bypass that now reads as an obvious, auditable act. This is directly analogous to Rust unsafe: the model protects against mistakes, not against a programmer who sets out to defeat it. (The original fail_raw_escape test 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 @ptrCast it 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 Rust unsafe.
  • Runtime, not comptime, for the ergonomic flow. As explained above, the Rust-style take() singleton and single-writer rules are runtime traps in runtime.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 accepted label 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.
  • Mock backing stores. Backing structs are plain struct globals so the examples run on the host. For the volatile (MMIO) case on a real target you would overlay a packed struct on 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.

Possible future work

  • 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/ and comptime/), each with its own build.zig module, so a consumer can depend on just one. Kept as a single capabilities module for now because the project is a single cohesive experiment.

  • Generality of the backing store. Resource is now parameterized over Options.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 to Options if a use case appears.

  • MicroZig integration. Probed, not built: the mechanisms can wrap MicroZig's generated *volatile register blocks today (the Writer hands back a register, MicroZig's Mmio does the I/O), with two small backward-compatible fixes to this repo (accept an already-volatile backing pointer via anytype; add a register-oriented reg("CR") accessor alongside scalar read/write). Making the capability path first-class would want a tiny opt-in regz enhancement (a generated Singleton/owned namespace), not a MicroZig dependency on this repo. See docs/microzig-integration.md.

  • Factor an internal Singleton primitive. Resource's one-time take() guard and the standalone Singleton shown in examples/microzig/src/regz_singleton.zig are the same idea implemented twice. Resource could be built on a small internal Singleton(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's regz_singleton.zig is 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 a Writer only 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 a Writer at all. Open questions to investigate: does MicroZig's generated Mmio / 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, and read of 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.

About

Try to protect e.g. embedded Zig code from conflicts around accessing resources

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages