Skip to content

Hw alloc - #387

Draft
JesseMelon wants to merge 4 commits into
OpenPRoT:mainfrom
JesseMelon:hw-alloc
Draft

Hw alloc#387
JesseMelon wants to merge 4 commits into
OpenPRoT:mainfrom
JesseMelon:hw-alloc

Conversation

@JesseMelon

@JesseMelon JesseMelon commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

A walkthrough of the linear chain of authority the hw-alloc branch adds to reach hardware
without hand-written unsafe — told as one story, from the problem to the proof — followed by
a correctness audit.


The problem we're solving

On a microcontroller, every peripheral is a block of memory-mapped registers at a fixed
address. To turn pin SCU414[30] into an I2C clock line you read a System Control Unit (SCU)
register, flip one bit, and write it back. In Rust that write is unsafe: you are
dereferencing a raw pointer to an address the compiler knows nothing about.

The old style scattered that unsafe everywhere. Any driver that wanted a pin reached for a
global SCU handle and poked bits by number. Two problems fall out of that:

  1. Every driver carries its own unsafe — dozens of audit points, each of which could be
    wrong.
  2. Nothing stops a driver from poking the wrong pin. A function that takes "the SCU" and
    a bit number can flip any bit. A GPIO driver could, by a one-digit typo, reconfigure an
    I2C bus mid-transaction. The compiler would say nothing. That function is typed safe but
    is practically unsafe: it can corrupt hardware it was never handed.

The branch's goal is to collapse all of that into one unsafe promise, made once at boot,
and make every downstream hardware touch both safe and confined — confined meaning a function
can only ever reach the exact pin whose token it was given. The rest of this note builds that
chain link by link, then checks whether the confinement really holds.


Step 1 — a pin needs an unforgeable name

The foundation is a token: a value whose mere existence is the right to touch one specific
pin. It must be impossible to conjure one out of thin air, and there must be exactly one per
physical pin. The pins! macro in hal/src/resource.rs mints that universe. Here is what one
row expands into:

/// Owned singleton token for one physical pin.
pub struct [<Pin $field:camel>](());

impl $crate::resource::Pin for [<Pin $field:camel>] {
    const CAPS: $crate::resource::Caps =
        $crate::resource::Caps::NONE
        $( .union($crate::pins!(@flag $cap)) )+;
}
  • pub struct PinScu41430(()) — a zero-sized type (ZST): it holds a single () field, so
    it occupies no memory at runtime. It is a pure compile-time marker.
  • That () field has no pub, so it is private to the module the macro expands in
    (scu::pins). Outside code cannot write PinScu41430(()) — the field name is unreachable.
    This is what makes the token unforgeable: only the defining module can construct one.
  • The struct has no #[derive(Copy)]. That is deliberate: a token has move semantics,
    so handing it to a function consumes it. You cannot route a pin, keep a copy, and route it
    again behind someone's back — the value is gone once moved.
  • impl Pin gives the token a const CAPS — a compile-time set of the roles this pin may
    play. We will use it in Step 3 to reject nonsense at compile time.

The whole universe is bundled and handed out once:

pub struct PinTokens {
    pub $field: [<Pin $field:camel>]
    /* one field per pin */
}

impl PinTokens {
    /// # Safety
    /// - This is called **once** per process. A second mint aliases every pin [...]
    /// - The `pins!` table is truthful: each pin's `DATA` names the correct, valid register
    ///   bases, mux bits, and slot for real silicon on this chip.
    pub unsafe fn mint() -> Self {
        Self { $( $field: [<Pin $field:camel>](()) ),+ }
    }
}
  • mint is the one unsafe fn the entire pin system rests on. Its safety contract has two
    clauses: called once (so no two owners of the same pin exist), and the table is truthful
    (each pin's associated data really does describe that pin's silicon). Hold onto that second
    clause — the correctness of everything downstream reduces to it.
  • Because mint is the sole constructor and it is unsafe, the boot code makes this promise
    exactly once, and from then on every token in existence is a proof that the promise was
    kept
    . Owning a PinScu41430 is a certificate: "this names real, exclusively-owned hardware."

This is the whole reason the chain reduces unsafe: instead of N drivers each making an
unaudited hardware promise, there is one promise, in one place, that everything else
inherits by holding a token.


Step 2 — a token needs to carry what it's allowed to do

A token that only names a pin is not enough; the safe functions downstream need to know
which register bits realize that pin's role, without any driver hand-coding them. So each
capability rides its own data on the token, as an associated const.

First, the shape of a single register edit — FieldWrite:

#[derive(Clone, Copy)]
pub struct FieldWrite {
    pub offset: u32, // register offset the modification targets
    pub set: u32,    // bits to OR in
    pub clr: u32,    // bits to AND out
}

impl FieldWrite {
    pub const fn set(offset: u32, bit: u8) -> Self {
        Self { offset, set: 1 << bit, clr: 0 }
    }
    pub const fn clear(offset: u32, bit: u8) -> Self {
        Self { offset, set: 0, clr: 1 << bit }
    }
}
  • A FieldWrite is a declarative register edit: "at this offset, OR in these bits, AND out
    those." It is data, not code — which is the key move. A pin's routing becomes a small array
    of these, and a generic applier (Step 6) executes them. No pin-specific unsafe write is
    ever written by hand.
  • const fn means FieldWrite::set(0x414, 30) is computed at compile time, so a pin's route
    is baked into the binary as constant data.

Each role then wraps its route plus whatever else that role needs to reach hardware. For I2C:

pub struct I2cControllerRegs {
    pub id: usize,        // controller index — a const-comparable identity
    pub i2c: *const (),   // base of the controller's I2C register block
    pub buff: *const (),  // base of the controller's I2CBUFF register block
}

pub struct I2cPinData {
    pub route: &'static [FieldWrite],   // mux writes that wire this pin to its controller
    pub controller: I2cControllerRegs,  // the controller this pin belongs to
}

pub trait I2cRoute {
    const DATA: I2cPinData;
}
  • route is the pin's mux recipe — the exact SCU bits that select the I2C function on this
    pin and no other
    .
  • controller carries the register base addresses of the I2C controller this pin feeds.
    Crucially the base rides on the pin: there is no separate "controller token." Holding the
    bus's two pins is the authority to touch that controller's registers.
  • id is a plain integer identity for the controller. Raw pointers cannot be compared in a
    const context
    in stable Rust, but usizes can — so id exists purely so that two pins
    can be proven at compile time to name the same controller (Step 5).
  • const DATA: I2cPinData on the I2cRoute trait is how the token surfaces this. PinScu41430
    implements I2cRoute, so PinScu41430::DATA.route is a compile-time constant.

GPIO has the analogous GpioPinData { route, bit }, where bit is the pin's slot index within
the GPIO register group. We will need that slot in Step 4.

Here is where the truthfulness clause becomes concrete. The AST1060's real table (in
target/ast10x0/peripherals/scu/pins.rs) reads:

openprot_hal::pins! {
    scu414_30 = pin { i2c_scl: I2cPinData { route: &[Fw::set(0x414, 30)], controller: I2C_CONTROLLERS[1] } },
    scu414_31 = pin { i2c_sda: I2cPinData { route: &[Fw::set(0x414, 31)], controller: I2C_CONTROLLERS[1] } },
    /* ... */
    scu410_0 = pin { gpio: GpioPinData { route: &[Fw::clear(0x410, 0), Fw::clear(0x4b0, 0), Fw::clear(0x690, 0)], bit: 0 } },
    /* ... */
}

Notice that scu414_30's route touches only bit 30 of register 0x414. Nothing in the type
system
forces that — the route is free-form data. The guarantee "this pin's route only edits
this pin's bits" is a property of this table being written correctly, and that is exactly the
second clause of mint's safety contract. This is the single trust anchor for the whole scheme.


Step 3 — routing a pin, safely, using only its own data

Now we can write the function a driver actually calls to mux a pin — and it needs no unsafe,
because all the unsafe was spent at mint. From scu/pinmux.rs:

fn scu_mux<P: Pin>(_pin: &P) -> Mmio<ScuBlock> {
    Mmio::from_raw(ast1060_pac::Scu::ptr().cast())
}

pub fn route_i2c<P: I2cRoute + Pin>(pin: &P) {
    const {
        assert!(
            P::CAPS.intersects(Caps::I2C_SCL.union(Caps::I2C_SDA)),
            "pin is not I2C-capable"
        )
    };
    let scu = scu_mux(pin);
    unlock_scu_writes(&scu);
    apply_config(&scu, &[P::DATA.route]);
}
  • route_i2c is not unsafe. To call it you must hand it &P — a reference to a minted
    token. Possessing that token is itself the authority, so no further unsafe is warranted.
  • const { assert!(...) } is a compile-time assertion. P::CAPS is known at compile time,
    so wiring a pin that lacks any I2C role is a build error, not a runtime panic. You cannot
    even name the mistake.
  • scu_mux(pin) takes the pin &P — but look closely: it ignores the pin's identity and
    returns an Mmio over the whole SCU block. This is the subtle heart of the design: the
    token is the gate (you needed one to get here), and the route data is the confinement
    (what actually gets written). The accessor can physically reach the entire SCU; the
    discipline is that we only ever feed it this pin's own P::DATA.route.
  • apply_config(&scu, &[P::DATA.route]) executes only the writes in this pin's route. There is
    no parameter through which a caller could inject arbitrary bits. The blast radius of a
    route_i2c call is fixed at compile time by the pin's own const.

route_gpio is identical in shape, gated on Caps::GPIO. The important property of both: the
only safe entry points hard-wire P::DATA.route. There is no safe way to say "route pin A but
write pin B's bits."


Step 4 — a GPIO pin needs to poke its bit and only its bit

Routing is one-shot. A GPIO pin also needs ongoing operations — set direction, drive high, read
level, acknowledge an interrupt — and every one of those touches a shared register where each
pin owns one bit
. If pin 3's "drive high" could stomp pin 4's bit, we would be right back to
practical-unsafe. This is where the branch earns its strongest, genuinely type-enforced
guarantee.

The per-pin handle, from hal/src/field_mux.rs:

pub struct Gpio<R: RegBlock, P: GpioPin + Pin> {
    block: R,
    _pin: P, // owned for its lifetime so the pin can't be re-claimed
}

impl<R: RegBlock, P: GpioPin + Pin> Gpio<R, P> {
    pub fn new(block: R, pin: P) -> Self {
        const { assert!(P::CAPS.contains(Caps::GPIO), "pin is not GPIO-capable") };
        Self { block, _pin: pin }
    }

    pub fn apply(&self, verb: &[RegOp]) {
        apply(&self.block, &fold_verb(verb, P::DATA.bit));
    }
}
  • Gpio<R, P> consumes the pin token (_pin: P, moved in by value) and holds it for the
    handle's whole life. Because the token is non-Copy, that pin can never be turned into a
    second Gpio — open-once, enforced by move semantics.
  • A "verb" is a role-neutral list of RegOps ("set the direction bit", "clear the level bit").
    The verb does not mention which pin — it is pin-agnostic data supplied by the chip's map.
  • fold_verb(verb, P::DATA.bit) is the confinement step: it takes the pin's own slot index
    from P::DATA.bit and shifts every op into that pin's bit range. A verb literally cannot be
    applied at any slot other than the one the token names.

What guarantees that pin 3's shifted range never overlaps pin 4's? A compile-time proof called
widths_consistent, run once per chip inside a const assertion. Its own doc-comment states
the theorem plainly:

/// [...] This is the keystone of pin-level confinement. `fold_verb` already restricts
/// every write for pin `k` to the bit range `[k*w, (k+1)*w)`, and the applier's RMW
/// (`(reg & !clr) | set`) touches only those bits. One consistent width per register makes
/// those per-pin ranges a clean partition, so pin `i`'s writes provably cannot land on pin
/// `j`'s bits — for *any* verb, not just the well-formed ones.
const fn widths_consistent(verbs: &[&[RegOp]]) -> bool { /* ... */ }
  • The argument is airtight: if every op on a given register uses the same field width w, then
    pin k's writes live in [k*w, (k+1)*w), the read-modify-write only rewrites the masked
    bits, and distinct pins get disjoint ranges. widths_consistent walks the chip's whole verb
    set and assert!s the one-width-per-register premise — so if the chip's map ever declared a
    register with mixed widths (which would let ranges overlap), the build fails.
  • The chip triggers this in gpio/map.rs:
    const _: () = assert!(verbs_ok::<AstGpio>(), "AstGpio has a self-conflicting GPIO verb");
    — zero runtime cost, pure compile-time.

So for GPIO operations, confinement is not a matter of trust: it is proven by the type of the
handle (the slot comes from P::DATA.bit, nothing else) plus a compile-time partition proof.
The one residual assumption is that two different pins do not declare the same bit — which
is, again, the mint table-truthfulness clause.


Step 5 — a bus needs both its pins to name the same controller

I2C is the interesting case because a bus is two pins (SCL and SDA) that must feed one
controller. Hand the driver a mismatched pair and it would drive a controller neither pin is
wired to. The branch makes that a compile error. From i2c/registers.rs:

pub fn from_pins<Scl: I2cRoute, Sda: I2cRoute>(_scl: &Scl, _sda: &Sda) -> Self {
    const {
        assert!(
            Scl::DATA.controller.id == Sda::DATA.controller.id,
            "SCL and SDA pins name different I2C controllers",
        )
    };
    Self {
        i2c: Mmio::from_raw(Scl::DATA.controller.i2c as *const u8),
        buff: Mmio::from_raw(Scl::DATA.controller.buff as *const u8),
    }
}
  • from_pins is safe, and takes the two pin tokens by reference. Holding both is the
    authority for their controller.
  • The const { assert!(... id == id ...) } is why id: usize existed back in Step 2: it proves
    at compile time that both pins belong to the same controller. Mismatched pins → build error.
  • The register bases come from Scl::DATA.controller — the pin's own authored addresses. The
    driver never names an address; it inherits it from the token it was given.

The board ties the knot in board/src/board.rs, and this is the entire unsafe surface of
the I2C path:

pub unsafe fn take() -> Self {
    let scu = unsafe { ScuRegisters::new_global_unlocked() };
    ast10x0_peripherals::i2c::bringup(scu, crate::delay_us);
    let pins = unsafe { PinTokens::mint() };   // sole mint site
    Self {
        i2c1: i2c_bus(pins.scu414_30, pins.scu414_31),
        i2c2: i2c_bus(pins.scu418_0, pins.scu418_1),
    }
}
  • One mint, at boot, single-threaded, exclusive access — the promise made once.
  • i2c_bus(scl, sda) (in the backend) then calls route_i2c(&scl); route_i2c(&sda); and
    Ast1060I2cRegisters::from_pins(&scl, &sda), moving both tokens into the returned I2cBus.
    From here on, holding the I2cBus is the exclusive claim on that bus and its pins — all in
    safe code.

Step 6 — the one place raw pointers are actually dereferenced

Every safe function above eventually funnels into a single confined applier, Mmio<B>, whose
constructor and volatile accesses are macro-authored once (mmio_applier! in field_mux.rs)
so no target file hand-writes them:

pub struct Mmio<B> {
    base: *const u8,
    _block: ::core::marker::PhantomData<B>,
}

pub(crate) const fn from_raw(base: *const u8) -> Self { /* ... */ }

pub(crate) fn write_reg(&self, offset: u32, val: u32) {
    // SAFETY: `base` is a minted pin's authored block base [...] `mint` vouched it names a
    // valid register block sized for `u32` access at every offset used [...]
    unsafe { (self.base.add(offset as usize) as *mut u32).write_volatile(val) }
}
  • PhantomData<B> gives the applier a block identity with no runtime cost: a
    Mmio<ScuBlock> and a Mmio<GpioBlock> are different types, so you cannot accidentally
    feed the GPIO applier where the SCU applier is wanted, even though both are just a pointer.
  • from_raw is pub(crate) and not unsafe. That looks alarming but is justified: it
    discharges no new obligation. The base always originates from a minted pin's authored
    DATA (or a boot-gated singleton), and the promise that the address names real hardware was
    already made at mint. Restricting it to pub(crate) means no outside crate can wrap an
    address of its choosing.
  • The genuine hardware-touch unsafe is confined to the two read_volatile/write_volatile
    calls, and their SAFETY comments point straight back to the mint promise. That is the whole
    irreducible unsafe of the data path — two volatile pokes, justified by one boot-time vow.

Correctness — can a safe function touch a pin it doesn't own?

This was the real question. Split it by path.

The new authority chain (route_i2c / route_gpio / Gpio ops / from_pins): confined.

  • Every entry point requires a pin token, and tokens are unforgeable (private field) and
    minted once (unsafe mint). So reaching any of these functions already proves authority.
  • None of them expose a parameter for arbitrary register writes. route_* apply exactly
    P::DATA.route; Gpio ops fold at exactly P::DATA.bit; from_pins uses exactly
    Scl::DATA.controller. A holder of pin A's token has no safe expression that reaches
    pin B's bits.
  • For GPIO operations the confinement is type-and-const-enforced: the slot is P::DATA.bit
    and widths_consistent proves the per-pin ranges are a disjoint partition. This is the
    strongest link.
  • For mux routing and for which bits a route names, confinement is data-enforced: it
    holds because each pin's route/controller/bit in the pins! table describes only that
    pin. That is not proven by types — it is the second clause of mint's safety contract. The
    win is that this assumption is now centralized in one auditable table instead of smeared
    across every driver's unsafe block. One place to get right, one place to review.

So within the new chain, the answer to "can a safe function mangle the wrong pin?" is no,
conditional on the single, localized, human-checked truthfulness of pins.rs. That is a real
and large reduction in unsafe surface, and it satisfies the practical-safety bar: the safe
API cannot be aimed at hardware it wasn't handed.

The still-present old path (ScuRegisters::apply_pinctrl_group): NOT confined — flag this.

The branch adds the confined chain but does not remove the ambient-authority mux path. In
scu/pinctrl.rs:

pub fn apply_pinctrl_group(&self, pins: &[PinctrlPin]) {
    let regs = self.regs();
    for pin in pins {
        match pin.offset {
            0x410 => modify_reg!(regs.scu410(), pin.bit, pin.clear),
            0x414 => modify_reg!(regs.scu414(), pin.bit, pin.clear),
            /* ... every SCU mux register ... */
            _ => {} // Unknown offset, silently ignore
        }
    }
}
  • This is a safe method that takes an arbitrary slice of PinctrlPin { offset, bit, clear } and will set or clear any bit in any SCU mux register. It is precisely the
    "typed-safe but practically-unsafe" function the whole branch is meant to retire: nothing
    ties the bits it writes to any capability the caller holds.
  • Its only gate is that you need a ScuRegisters, which comes from an unsafe
    new_global_unlocked(). But ScuRegisters is #[derive(Copy)] and flows freely once
    minted — i2c::bringup(scu, ...) takes it, the SPI-monitor controller stores a copy, etc.
    So after one unsafe, ambient authority to mux every pin is in circulation.
  • It is still on live paths: Ast10x0Board::init applies descriptor.pinctrl_groups through
    it; board/src/spim_wiring.rs uses it for SPIM/GPIOL wiring; and several tests
    (smc/*, spimonitor/*, sgpiom_irq, i2c_slave_rx_ipc) call it directly. The example in
    the doc-comment even demonstrates poking a raw CLR_PIN_SCU41C_0.

Verdict. The new chain is sound and genuinely confined for I2C and GPIO. But the branch has
two mux models coexisting, and the old one is an open hole: as long as apply_pinctrl_group
is pub and safe, a ScuRegisters copy is a license to reconfigure any pin, and the
confinement guarantee is only global if nobody calls it. The honest one-line summary: I2C
and GPIO are now confined behind pin tokens; SPIM/SGPIOM/FMC and the pinctrl-group tests are
not.
Closing the gap means porting those onto pin tokens and making apply_pinctrl_group
(and/or the ambient ScuRegisters mux surface) private or removed.

One narrower observation, not a hole: even in the new path, scu_mux and from_raw hand back
an applier over the whole block, so Mmio<ScuBlock> is a whole-SCU capability, not a per-pin
one. The per-pin discipline lives entirely in which FieldWrites get applied. That is fine
given the safe surface never exposes arbitrary writes — but it means the confinement is a
property of the functions, not of the Mmio type. If a future safe function took an
Mmio<ScuBlock> plus caller-chosen writes, the guarantee would evaporate. Worth guarding.


The through-line

One unsafe mint turns a truthful pins! table into a universe of unforgeable, non-Copy
pin tokens; each token carries its own FieldWrite route and register bases as compile-time
data; route_i2c/route_gpio mux a pin using only its route, Gpio<R,P> operates only on
its bit (with widths_consistent proving the bits partition), and from_pins binds a
controller only when both tokens prove the same id — all funnelling into the single confined
Mmio<B> applier whose two volatile pokes are the only hardware unsafe left. The chain
delivers real, practically-safe confinement for I2C and GPIO — with the caveat that the older,
unconfined apply_pinctrl_group still sits alongside it and must be retired before the
guarantee is chip-wide.


Worked example — falling-edge interrupt on one GPIO pin, end to end

To make the chain concrete, here is every runtime call needed to arm a falling-edge interrupt
on GPIOA0 (scu410_0), in a single privileged context that has SCU access:

// 1. Mint the whole pin universe — the one unsafe call, once at boot.
let pins = unsafe { PinTokens::mint() };

// 2. Consume this pin's token: route its SCU mux to GPIO, get a per-pin handle.
let a0 = pins.scu410_0.into_gpio();          // = route_gpio(&self) + bind_gpio()

// 3. Falling edge is an input sense — make it an input.
a0.apply(AstGpio::INPUT);

// 4. Clear any stale pending bit before arming.
a0.ack(AstGpio::INT_STATUS);

// 5. Arm falling-edge sensitivity + enable the interrupt.
a0.apply(AstGpio::ENABLE_FALLING);

// 6. In the IRQ path, later:
if a0.read(AstGpio::INT_STATUS) {
    // ...handle...
    a0.ack(AstGpio::INT_STATUS);
}

ENABLE_FALLING is one verb that expands (in gpio/map.rs) to
clear SENSE0, clear SENSE1, clear SENSE_BOTH, set INT_ENABLE, all folded onto this pin's slot
in a single pass.

Split kernel/userspace variant (what the gpio_irq test does): the kernel side calls
route_gpio(&pins.scu410_0) because it holds the SCU grant; the userspace side mints its own
token universe and uses pins.scu410_0.bind_gpio() instead of into_gpio()bind_gpio skips
the SCU write and just returns the handle. Steps 3–6 are identical. Userspace has no SCU grant,
so it physically cannot re-mux; the MPU enforces that on top of the type system.

Could a different pin be misconfigured at each step?

  • Step 1 — mint(): writes no registers. The hazard is the unsafe contract itself (call
    once; table truthful), not cross-pin corruption.
  • Step 2 — into_gpio/route_gpio: no. Applies only P::DATA.route; the bits are fixed by
    the token and there is no parameter to inject other bits.
  • Steps 3,5 — apply(INPUT)/apply(ENABLE_FALLING): no, and this is the strongest link.
    Folds at P::DATA.bit; widths_consistent proves at compile time the per-pin ranges are
    disjoint, so the masked RMW preserves every neighbor's bit in the shared SENSE/INT_ENABLE
    registers.
  • Steps 4,6 — ack/read(INT_STATUS): no. Both mask 1 << P::DATA.bit; Reg<true> typing
    means ack only works on write-1-to-clear registers.

So through this API, at no step can a safe call be aimed at a different pin. A few mistakes are
rejected at compile time outright: routing a non-GPIO pin (const assert Caps::GPIO) and reading
an UNSUPPORTED register (Reg::new const-panics). The only place another pin could actually be
touched is the pins.rs table being wrong (mint's second clause) — one auditable file, not N
driver unsafe blocks. And note the escape hatch this flow avoids: reaching those same shared
SENSE/INT_ENABLE registers through the old apply_pinctrl_group path would drop the confinement
entirely.

@JesseMelon
JesseMelon force-pushed the hw-alloc branch 2 times, most recently from 13f2f83 to 2b5dc68 Compare July 31, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant