Hw alloc - #387
Draft
JesseMelon wants to merge 4 commits into
Draft
Conversation
JesseMelon
force-pushed
the
hw-alloc
branch
2 times, most recently
from
July 31, 2026 21:20
13f2f83 to
2b5dc68
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A walkthrough of the linear chain of authority the
hw-allocbranch adds to reach hardwarewithout hand-written
unsafe— told as one story, from the problem to the proof — followed bya 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 aredereferencing a raw pointer to an address the compiler knows nothing about.
The old style scattered that
unsafeeverywhere. Any driver that wanted a pin reached for aglobal SCU handle and poked bits by number. Two problems fall out of that:
unsafe— dozens of audit points, each of which could bewrong.
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
unsafepromise, 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 inhal/src/resource.rsmints that universe. Here is what onerow expands into:
pub struct PinScu41430(())— a zero-sized type (ZST): it holds a single()field, soit occupies no memory at runtime. It is a pure compile-time marker.
()field has nopub, so it is private to the module the macro expands in(
scu::pins). Outside code cannot writePinScu41430(())— the field name is unreachable.This is what makes the token unforgeable: only the defining module can construct one.
#[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 Pingives the token aconst CAPS— a compile-time set of the roles this pin mayplay. We will use it in Step 3 to reject nonsense at compile time.
The whole universe is bundled and handed out once:
mintis the oneunsafe fnthe entire pin system rests on. Its safety contract has twoclauses: 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.
mintis the sole constructor and it isunsafe, the boot code makes this promiseexactly once, and from then on every token in existence is a proof that the promise was
kept. Owning a
PinScu41430is a certificate: "this names real, exclusively-owned hardware."This is the whole reason the chain reduces
unsafe: instead of N drivers each making anunaudited 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:FieldWriteis a declarative register edit: "at this offset, OR in these bits, AND outthose." 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
unsafewrite isever written by hand.
const fnmeansFieldWrite::set(0x414, 30)is computed at compile time, so a pin's routeis baked into the binary as constant data.
Each role then wraps its route plus whatever else that role needs to reach hardware. For I2C:
routeis the pin's mux recipe — the exact SCU bits that select the I2C function on thispin and no other.
controllercarries 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.
idis a plain integer identity for the controller. Raw pointers cannot be compared in aconstcontext in stable Rust, butusizes can — soidexists purely so that two pinscan be proven at compile time to name the same controller (Step 5).
const DATA: I2cPinDataon theI2cRoutetrait is how the token surfaces this.PinScu41430implements
I2cRoute, soPinScu41430::DATA.routeis a compile-time constant.GPIO has the analogous
GpioPinData { route, bit }, wherebitis the pin's slot index withinthe 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:Notice that
scu414_30's route touches only bit 30 of register0x414. Nothing in the typesystem 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
unsafewas spent atmint. Fromscu/pinmux.rs:route_i2cis notunsafe. To call it you must hand it&P— a reference to a mintedtoken. Possessing that token is itself the authority, so no further
unsafeis warranted.const { assert!(...) }is a compile-time assertion.P::CAPSis 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 andreturns an
Mmioover the whole SCU block. This is the subtle heart of the design: thetoken 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 isno parameter through which a caller could inject arbitrary bits. The blast radius of a
route_i2ccall is fixed at compile time by the pin's own const.route_gpiois identical in shape, gated onCaps::GPIO. The important property of both: theonly safe entry points hard-wire
P::DATA.route. There is no safe way to say "route pin A butwrite 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:Gpio<R, P>consumes the pin token (_pin: P, moved in by value) and holds it for thehandle's whole life. Because the token is non-
Copy, that pin can never be turned into asecond
Gpio— open-once, enforced by move semantics.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 indexfrom
P::DATA.bitand shifts every op into that pin's bit range. A verb literally cannot beapplied 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 aconstassertion. Its own doc-comment statesthe theorem plainly:
w, thenpin
k's writes live in[k*w, (k+1)*w), the read-modify-write only rewrites the maskedbits, and distinct pins get disjoint ranges.
widths_consistentwalks the chip's whole verbset and
assert!s the one-width-per-register premise — so if the chip's map ever declared aregister with mixed widths (which would let ranges overlap), the build fails.
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— whichis, again, the
minttable-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:from_pinsis safe, and takes the two pin tokens by reference. Holding both is theauthority for their controller.
const { assert!(... id == id ...) }is whyid: usizeexisted back in Step 2: it provesat compile time that both pins belong to the same controller. Mismatched pins → build error.
Scl::DATA.controller— the pin's own authored addresses. Thedriver 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 entireunsafesurface ofthe I2C path:
mint, at boot, single-threaded, exclusive access — the promise made once.i2c_bus(scl, sda)(in the backend) then callsroute_i2c(&scl); route_i2c(&sda);andAst1060I2cRegisters::from_pins(&scl, &sda), moving both tokens into the returnedI2cBus.From here on, holding the
I2cBusis the exclusive claim on that bus and its pins — all insafe code.
Step 6 — the one place raw pointers are actually dereferenced
Every safe function above eventually funnels into a single confined applier,
Mmio<B>, whoseconstructor and volatile accesses are macro-authored once (
mmio_applier!infield_mux.rs)so no target file hand-writes them:
PhantomData<B>gives the applier a block identity with no runtime cost: aMmio<ScuBlock>and aMmio<GpioBlock>are different types, so you cannot accidentallyfeed the GPIO applier where the SCU applier is wanted, even though both are just a pointer.
from_rawispub(crate)and notunsafe. That looks alarming but is justified: itdischarges no new obligation. The
basealways originates from a minted pin's authoredDATA(or a boot-gated singleton), and the promise that the address names real hardware wasalready made at
mint. Restricting it topub(crate)means no outside crate can wrap anaddress of its choosing.
unsafeis confined to the tworead_volatile/write_volatilecalls, and their SAFETY comments point straight back to the
mintpromise. That is the wholeirreducible
unsafeof 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.
minted once (
unsafe mint). So reaching any of these functions already proves authority.route_*apply exactlyP::DATA.route;Gpioops fold at exactlyP::DATA.bit;from_pinsuses exactlyScl::DATA.controller. A holder of pin A's token has no safe expression that reachespin B's bits.
P::DATA.bitand
widths_consistentproves the per-pin ranges are a disjoint partition. This is thestrongest link.
holds because each pin's
route/controller/bitin thepins!table describes only thatpin. That is not proven by types — it is the second clause of
mint's safety contract. Thewin is that this assumption is now centralized in one auditable table instead of smeared
across every driver's
unsafeblock. 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 realand large reduction in
unsafesurface, and it satisfies the practical-safety bar: the safeAPI 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: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.
ScuRegisters, which comes from anunsafenew_global_unlocked(). ButScuRegistersis#[derive(Copy)]and flows freely onceminted —
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.Ast10x0Board::initappliesdescriptor.pinctrl_groupsthroughit;
board/src/spim_wiring.rsuses it for SPIM/GPIOL wiring; and several tests(
smc/*,spimonitor/*,sgpiom_irq,i2c_slave_rx_ipc) call it directly. The example inthe 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_groupis
puband safe, aScuRegisterscopy is a license to reconfigure any pin, and theconfinement 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
ScuRegistersmux surface) private or removed.One narrower observation, not a hole: even in the new path,
scu_muxandfrom_rawhand backan applier over the whole block, so
Mmio<ScuBlock>is a whole-SCU capability, not a per-pinone. The per-pin discipline lives entirely in which
FieldWrites get applied. That is finegiven the safe surface never exposes arbitrary writes — but it means the confinement is a
property of the functions, not of the
Mmiotype. If a future safe function took anMmio<ScuBlock>plus caller-chosen writes, the guarantee would evaporate. Worth guarding.The through-line
One
unsafe mintturns a truthfulpins!table into a universe of unforgeable, non-Copypin tokens; each token carries its own
FieldWriteroute and register bases as compile-timedata;
route_i2c/route_gpiomux a pin using only its route,Gpio<R,P>operates only onits
bit(withwidths_consistentproving the bits partition), andfrom_pinsbinds acontroller only when both tokens prove the same
id— all funnelling into the single confinedMmio<B>applier whose two volatile pokes are the only hardwareunsafeleft. The chaindelivers real, practically-safe confinement for I2C and GPIO — with the caveat that the older,
unconfined
apply_pinctrl_groupstill sits alongside it and must be retired before theguarantee 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:ENABLE_FALLINGis one verb that expands (ingpio/map.rs) toclear SENSE0, clear SENSE1, clear SENSE_BOTH, set INT_ENABLE, all folded onto this pin's slotin a single pass.
Split kernel/userspace variant (what the
gpio_irqtest does): the kernel side callsroute_gpio(&pins.scu410_0)because it holds the SCU grant; the userspace side mints its owntoken universe and uses
pins.scu410_0.bind_gpio()instead ofinto_gpio()—bind_gpioskipsthe 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?
mint(): writes no registers. The hazard is theunsafecontract itself (callonce; table truthful), not cross-pin corruption.
into_gpio/route_gpio: no. Applies onlyP::DATA.route; the bits are fixed bythe token and there is no parameter to inject other bits.
apply(INPUT)/apply(ENABLE_FALLING): no, and this is the strongest link.Folds at
P::DATA.bit;widths_consistentproves at compile time the per-pin ranges aredisjoint, so the masked RMW preserves every neighbor's bit in the shared SENSE/INT_ENABLE
registers.
ack/read(INT_STATUS): no. Both mask1 << P::DATA.bit;Reg<true>typingmeans
ackonly 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 readingan
UNSUPPORTEDregister (Reg::newconst-panics). The only place another pin could actually betouched is the
pins.rstable being wrong (mint's second clause) — one auditable file, not Ndriver
unsafeblocks. And note the escape hatch this flow avoids: reaching those same sharedSENSE/INT_ENABLE registers through the old
apply_pinctrl_grouppath would drop the confinemententirely.