A static-partitioning separation kernel for AArch64/EL2, built so that its claims can be checked rather than believed.
The last of ③-b1’s three declared residues, and the one whose declaration turned out to understate itself. Read §1 even if nothing else: the defect is small, the shape it came in is not.
OptionGICD_CTLR.ARE_NS is forced on by this model, and the architecture then makes the distributor’s
copies of INTIDs 0..31 RES0 — they are banked per redistributor, and reads of the distributor’s
copies return zero. ⚠ Asserted from the architecture, not measured here (docs/QEMU-AND-METAL.md
§5’s column discipline).
The model’s bank decode returned Option<usize>, and its None meant two different things:
| the offset is… | dist_word_index |
what the caller did |
|---|---|---|
| a word of SPI state | Some(w) |
serve it |
| a redistributor-banked copy — RES0 here | None |
refuse → RETIRE THE GUEST |
| not a word of this bank at all | None |
refuse → retire the guest |
The caller’s policy is right for the third row — since the retire rung, a register this model does not have stops the guest, because a silently-ignored write leaves a guest believing something took effect. It is wrong for the second, and nothing distinguished them.
★ The reusable shape: an Option from a decode is a two-way answer, and a decode with three cases
will quietly borrow one of them. The tell is a caller that gives None a policy meaning — here
“retire” — which can only be correct for one of the things None stood for. The repair is
DistWord { Spi(w), BankedRes0, Elsewhere }.
gicv3.rs’s residue list said pending/active, and named four registers. MEASURED on the deployed
shape, ten offsets refused a conforming read and so retired the guest:
GICD_IGROUPR0 GICD_ISENABLER0 GICD_ICENABLER0 GICD_ISPENDR0 GICD_ICPENDR0
GICD_ISACTIVER0 GICD_ICACTIVER0 GICD_ICFGR0 GICD_ICFGR1 GICD_IPRIORITYR bytes 0..31
IGROUPR0, ISENABLER0 and IPRIORITYR0 are not pending/active state at all — they were never in
the declaration. ★ A declaration that understates its own scope is worse than no declaration,
because it is read as an inventory. This one had been read that way for three arcs.
Reads of the banked copies return zero; writes are ignored — accepted and applied to nothing.
Every change is refuse → answer, never the reverse.
⚠ GICD_SGIR is RES0 under ARE_NS too and stays REFUSED, on purpose. It is an action
register: silently ignoring a write drops an interrupt the guest believes it sent, which is worse
than a loud refusal. The RES0 treatment covers the state copies only. That boundary — state
copies read zero, action registers stay loud — is the actual decision in this rung; the code change
is mechanical.
GICD_ITARGETSR is likewise RES0 under ARE_NS and likewise still refused: the model has no arrays
for it, has never recognised it, and a guest using GICv2 targeting under affinity routing is doing
something the model should be loud about.
There is no guest witness, and none is available. MEASURED: /dev/mem exists in the guest
(crw------- 1,1) but busybox has no devmem applet, and dd if=/dev/mem over the GIC window
returns Bad address — the read is refused by the kernel and never reaches EL2. The shipped
kernel touches none of these registers either, which is why the defect survived three arcs. So the
∀-offset evidence is Kani’s, exactly as ⑱-2 established for this surface (“every property below
is invisible to the boot gate and the proofs are the whole of the evidence”).
Beside it, a boot-side count — EL2 sweeping its own model, in the shape mmu.rs’s page sweep took:
baleen: gicdsurface OK: of 16384 word offsets in dom 1's GICD frame, 414 are answered
and 15970 RETIRE the guest — and all 17 redistributor-banked RES0 copies read ZERO
★ That first number is new information nobody had. “A register this model does not have retires the guest” was policy; 15,970 of 16,384 word offsets is its size. The verdict carries the counts because a predicate over a set that collapsed passes vacuously (design-lesson #214).
MEASURED, five probes × four harnesses:
| probe | reads-zero | write-inert | still-refuses | partition |
|---|---|---|---|---|
| P0 unmodified — the CONTROL | PASS | PASS | PASS | PASS |
P1 dist_banked_res0 always false (pre-⑲) |
RED | PASS | PASS | PASS |
P2 dist_banked_res0 always true |
PASS | PASS | RED | PASS |
P3 no banked exclusion at all (the ARE_NS aliasing bug) |
RED | PASS | PASS | PASS |
P4 ICFGR as 1 bit/INTID, so ICFGR1 is not RES0 |
RED | PASS | PASS | PASS |
⚠ The write-inert column is killed by nothing, and that refuted what its own doc claimed. The
harness said it was what stops GICD_ISENABLER0 writes reaching a guest’s PPI enables. P3 restores
exactly that historical bug and the harness still passes — because ⑱-2 gave each redistributor its
own bank, and is_enabled reads that for INTIDs 0..31. The distributor’s word 0 is write-only
dead storage. The storage split carries that property; the decode never did. The doc now says
so (design-lesson #222: a guard you believe is backed up is where to look for a single point of
failure — and here it was the reverse, a guard claiming credit for something else’s work).
⚠ P4 is not hypothetical. ICFGR is two bits per INTID, so INTIDs 0..31 span words 0 and 1 —
and this model’s decode shipped with only word 0 excluded once already, leaving GICD_ICFGR1
aliasing GICR_ICFGR1. The same off-by-one in the RES0 set retires a conforming guest on exactly one
register.
See also: hv-vdev/src/gicv3.rs (DistWord, dist_banked_res0, and the residue list this closes),
hv-verify/src/lib.rs H4d (the harnesses, the independently-derived RES0 predicate, and the measured
probe matrix), hv-metal/src/vgic.rs (survey_gicd, banked_res0_read_zero).