ReviewOS

home-lang/home-os

Consolidate the three overlapping Pi trees (`arch/arm64/`, `arch/arm64/rpi/`, `kernel/src/rpi/`) into one BCM2712-first layout; park or delete Pi 3/4 code

#62
Open chris opened this 8 days ago ·
8 days ago

Epic: #113 · Milestone: MC2 — Pi 5 bring-up

Why

~7,000 duplicated lines with divergent register maps: devicetree.home ×2, gic400.home ×2, bcm2711.home in two places, bcm2836/2837.home 15-line stubs, rpi/boot.home (828) vs arch/arm64/boot.home (174), mailbox ×3 (rpi/mailbox.home, mailbox_vc6.home, mailbox_vc7.home), platform.home that knows Pi 1–4 but not 2712. None of it has compiled; carrying three copies through the aarch64 ratchet triples the work.

Proposed layout

kernel/src/arch/arm64/          # arch-only: boot.s, boot.home, mmu, exceptions, gic (v3; v2 only if Pi 4 kept), timer, smp, devicetree, arm64.home
kernel/src/platform/bcm2712/    # SoC: mailbox (vc7), power/clock/thermal, sdhci, pcie host, hvs/hdmi/cec, hevc, dma
kernel/src/platform/rp1/        # southbridge: rp1.home (BAR/IRQ), gpio, uart (GPIO header uarts), i2c, spi, i2s, pwm, xhci glue, gem eth
kernel/src/platform/virt/       # QEMU virt: pl011, gicv3, virtio glue
kernel/src/platform/legacy-pi/  # (optional, frozen) bcm2711/2835 material if Pi 4 remains a stated target; otherwise deleted

Tasks

  • Decide Pi 4 fate in this issue (recommendation: drop Pi 3/4 from the plan; the media center is a Pi 5 product, and Pi 4 lacks the HEVC 4K path and RP1). Record in the ADR.
  • Move/merge files per layout; one mailbox, one FDT parser, one GIC; delete rpi3.home, rpi3_minimal.home, pi3-minimal build profile, bcm2836/2837.
  • Update IMPLEMENTATION_STATUS.md generator paths; keep parse-rate at 100 %.
  • Freeze kernel/src/iot/, industrial/ per D5 — untouched.

Acceptance

Parse-rate 100 % after the move; no file in kernel/src duplicates another's responsibility (checked by a short static-checks/pi-layout.sh listing allowed dirs).

Size: M · Depends on: — (can start now) · Blocks: #61, MC3

8 days ago

This issue is now the critical path, and it needs a decision

Measured after the ARM64 backend landed (home-lang/home-os@b856128): 13 of 70 Appendix A files lower for aarch64, against 70/70 for x86-64.

The gap is not a compiler gap. It is kernel/src/core/foundation.home, which the whole tree imports, and its 29 asm volatile blocks. Inline assembly is emitted verbatim — that is what it means — so an x86 block reaches the ARM assembler as x86 text and is rejected.

Eight of the 29 had direct architecture-neutral equivalents and are gone as of @578ddc6. The remaining 21 are genuinely x86-only CPU state and have no neutral spelling:

constructcountARM64 counterpart
port I/O (inb/inw/inl/outb/outw/outl)6none — MMIO replaces it entirely
cpuid3MIDR_EL1, ID_AA64*_EL1 via mrs
mov %cr2 / %cr3 / %cr43FAR_EL1, TTBR0_EL1, SCTLR_EL1/TCR_EL1
stac / clac2PAN bit in PSTATE
pushfq/popfq (save/restore flags + cli)2mrs x, daif / msr daif, x
rdtsc1CNTVCT_EL0
ltr1no TSS on ARM; per-CPU state is TPIDR_EL1
lgdt1no GDT on ARM; VBAR_EL1 for vectors
lock xaddq1ldaddal, or ldxr/stxr loop

Every one has an ARM64 answer. What HomeOS lacks is a way to select between the two implementations per target, and there are two credible designs:

A — build-time file selection. kernel/src/arch/x86_64/cpu.home and kernel/src/arch/arm64/cpu.home expose the same function names; the build picks one. No language change; this is what Linux does with arch/$(ARCH)/. Costs: mvk-compiles.sh compiles files standalone, so the set needs per-target expectations, and Home's import resolution needs a path that varies by target.

B — a compile-time target predicate in Home. @targetArch() plus dead-branch elimination for a comptime-known condition, so one file can carry both and the branch not taken is never emitted. More compiler work, and the dead-branch elimination has to be real — an x86 asm volatile surviving into ARM output defeats the whole point. This is the Zig idiom Home inherits, and CLAUDE.md's "extend Home first" points at it.

They are not mutually exclusive — B for small branches, A for whole subsystems — but one of them has to be chosen first, and it should be an ADR, because it sets the shape of every arch-specific module HomeOS will ever have.

Blocked on this: the aarch64 ratchet moving past 13/70, and therefore #61 (MMU), #63 (hardware boot), and the whole Pi 5 track.

8 days ago

ARM64: 13/70 → 62/70, and the mechanism that got it there

Since the last update (home-lang/home-os@7121de7, home-lang/home@2f1a7bf).

Measured

gatebeforenow
parse-rate415/415415/415
mvk-compiles x86-6470/7070/70
mvk-compiles aarch6413/7062/70
boot-qemu-x86_6455/5555/55
boot-qemu-aarch64PASSPASS
stub-register6 open7 open, S8 CLOSED

What made the difference

Two things, and the second was most of the jump.

@targetIs("aarch64") — a compile-time predicate whose if emits only the branch it takes. The elimination is the point, not an optimisation: the branch not taken holds inline assembly for the other machine. Restricted to conditions mentioning @targetIs, so it cannot silently delete an if (DEBUG) { … } block.

Alongside it, neutral intrinsics for what both architectures have but spell differently — arch_save_interrupts/arch_restore_interrupts, arch_read_timestamp, arch_atomic_add64, arch_read_sysreg/arch_write_sysreg, arch_halt/arch_disable_interrupts/arch_enable_interrupts/arch_spin_hint/arch_wait_event, and arch_-prefixed MMIO aliases.

Five jmp emissions in the backend were still raw x86 text. jmp .L_endif_N is emitted by any if/else, so almost nothing with a branch in it assembled for ARM. The symptom was unrecognized instruction mnemonic, did you mean: cmp? pointing at a line in a temporary file — no way to trace it back to Home source. That is the second time a stray x86 spelling has surfaced this way.

STUB(S8) is closed

arch/arm64/arm64.home's mmio_read32 returned 0 and mmio_write32 did nothing, so every ARM64 and Pi driver was inert. It now emits ldr w0, [x3] + dmb oshld and dmb oshst + str w1, [x3]. Register rule 1 requires a runtime test, so arm64_poc.home drives the UART through this module — if mmio_write32 were still a no-op, the boot gate would print nothing and fail.

Two new stubs registered rather than left to be found: S9 (port-I/O wrappers halt on ARM64 — there is no I/O address space, and a plausible return value would leave a driver that reads as working and touches no hardware) and S10 (SMAP's ARM64 counterpart, the PAN bit, is not set up).

Trap worth knowing before converting the last 8

An early return is a sibling statement, not a branch, so this does not eliminate anything:

if @targetIs("x86_64") { return arch_read_sysreg("cr3") }
return arch_read_sysreg("ttbr0_el1")   // emitted on x86 too — compile error

Both arms have to be written out with an explicit else.

The remaining 8 files

Each carries its own x86 asm volatile: port I/O in serial.home, block/io_scheduler.home and os/cpu.home; the atomics in sync/spinlock.home; invlpg and CR3 writes in core/memory.home; the stack switch in core/process.home; pause in drivers/timer.home; lidt in arch/x86_64/interrupts.home.

The intrinsics they need are written but not compiled — arch_atomic_cmpxchg32/xchg32/add32/load32/store32, arch_invalidate_tlb_page, arch_read_stack_pointer/arch_write_stack_pointer, arch_compiler_barrier. They are on the fix/aarch64-codegen-followups branch in a git stash, because this machine ran out of disk (421 GB of 460 GB used, 1.7 GB free) and the compiler needs ~2 GB of build cache. Nothing unverified was committed.

8 days ago

ARM64 reaches 70/70 — every MVK file compiles for both architectures

Landed on main: home-lang/home-os@fd04c6b, home-lang/home@bf515cf.

gatestart of this worknow
mvk-compiles aarch6413/7070/70
mvk-compiles x86-6470/7070/70
parse-rate415/415415/415
boot-qemu-x86_6455/5555/55
boot-qemu-aarch64—PASS
mvk-links70 objects, 0 dup / 0 undefunchanged
test-all87/8787/87
stub-register6 open7 open, S8 closed

The last eight files

Each carried its own x86 asm volatile, and inline assembly is passed through verbatim by definition — so each reached the ARM assembler as x86 text and was rejected. The fixes fall into three kinds, and the distinction is the substance of the change:

Operations both architectures have, spelled differently became neutral intrinsics — the atomic family, single-page TLB invalidation, the stack pointer, the timestamp counter, a full memory fence. atomic_cmpxchg is lock cmpxchgl on x86 and an ldaxr/stlxr retry loop on ARM64: the same guarantee, written by the target.

Operations with a real ARM64 counterpart under another name became @targetIs pairs — CR3 against TTBR0_EL1, CR2 against FAR_EL1.

Operations ARM64 genuinely does not have — port I/O, the GDT, the IDT — halt on ARM64 rather than returning a plausible value. There is no I/O address space to read, so any number inb returned would leave a driver that reads as working and touches no hardware. That is precisely the failure the stub register exists to catch; registered as STUB(S9).

One compiler addition was needed: arch_memory_barrier, for the full fence that had no neutral spelling — mfence on x86, dmb ish on ARM64. Inner-shareable rather than sy, because a lock orders against the other cores, not against device traffic.

What makes this checkable

x86-64 held at 70/70 and its boot gate still reached all 55 milestones on the merged tree. That is what rules out these conversions having quietly broken the architecture that already worked — the ARM number moving on its own would not have.

Bugs this port surfaced

  1. A latent x86 correctness bug. Bitfield literals were built in one register, so 128-bit IDT entries emitted shlq $64 — which x86 masks to a shift of zero. idt.home had been compiling to descriptors the CPU would reject, silently, while counting as passing.
  2. Unaligned loads fault on ARM with the MMU off (all memory is Device-typed). The kernel printed exactly H and stopped.
  3. Five jmp sites were still raw x86 text. jmp .L_endif_N comes from any if/else, so almost nothing with a branch assembled for ARM. This was most of the 13→62 jump.

Still open

STUB(S9) port I/O has no ARM64 answer by design; STUB(S10), the PAN bit (SMAP's ARM64 counterpart), is not yet set up. Both are registered rather than left to be discovered.

8 days ago

STUB(S10) closed — PAN makes the user-access window real on ARM64

Landed on main: home-lang/home-os@303942c, home-lang/home@6d67bdb.

asm_stac() and asm_clac() bracket the window in which the kernel may touch user memory. On ARM64 both were no-ops, so the window was permanently open and every call site that looked protected was decoration.

ARM64 spells the protection PAN and inverts the sense: PSTATE.PAN set forbids the access, where x86's EFLAGS.AC set permits it. Three new backend intrinsics hide the inversion, so both wrappers now move a real bit on both architectures.

Enabling it is more than one instruction. enable_user_access_protection() first asks whether the CPU implements it at all — SMAP arrived with Broadwell, PAN with ARMv8.1, and assuming either faults on the enable — then clears SCTLR_EL1.SPAN so every exception entry to EL1 arrives with PAN already set. Left set, PAN keeps whatever value it happened to have, which defeats the point.

The runtime test

Register rule 1 does not accept "two instructions were emitted". The ARM64 boot kernel now imports foundation.home and drives the sequence on the CPU, reading PSTATE back after each step:

ok  HomeOS v0.1: aarch64 kernel_main reached
ok  aarch64: PAN implemented
ok  aarch64: PAN enabled, user access forbidden
ok  aarch64: stac opened the user-access window
ok  aarch64: clac closed the user-access window

Each line prints only on success, so a regression to the old no-op drops a milestone rather than silently removing the protection.

Gates

gateresult
mvk-compiles x86-6470/70
mvk-compiles aarch6470/70
parse-rate415/415
boot-qemu-x86_6455/55 milestones
boot-qemu-aarch64PASS, 5 milestones
mvk-links70 objects, 0 dup / 0 undef
test-all87/87
stub-register6 open (was 7)

x86-64 emits the same stac/clac as before and its boot gate still reaches all 55 milestones, which is what rules out the ARM64 work having disturbed the architecture that already worked.

Also fixed

Adding foundation.home to the ARM64 link surfaced ld.lld: address (0x40084210) of section .bss is not a multiple of alignment (256) — .bss was 8-aligned while objects in it asked for 256. On ARM64 with the MMU off every access is to Device-typed memory, where a misaligned access faults rather than being fixed up, so that warning is a boot hang waiting for the first wide object. .bss is now page-aligned.

Still open

S3 (netdev RX), S4 (homefs CoW), S5 (crypto primitives), S6 (USB core), S7 (ACPI) — each a subsystem in its own right. S9 has no ARM64 answer by design: there is no I/O address space, so the port-I/O wrappers halt rather than return a plausible value.

8 days ago

STUB(S3) closed — the RX path demultiplexes real frames

Landed on main: home-lang/home-os@da5f266, home-lang/home@02beca5.

receive_packet() printed a line and dropped the frame. No packet had ever reached the IP stack, so arp, icmp, udp and tcp — about 4,000 lines between them — were complete-looking and unreachable, because nothing ever handed them a packet.

netdev now parses the Ethernet header, demuxes on ethertype, validates the IPv4 header, and dispatches to the handler the packet names. IPv4 is checked before it is trusted: version, IHL within the frame, total_length no longer than the frame, and the header checksum. Total length is trusted over frame length because Ethernet pads short frames to 60 bytes — a frame is routinely longer than the datagram inside it, but may never be shorter.

A compiler bug this uncovered

The ICMP case failed while ARP and UDP passed. UDP passed because its checksum was zero, which IPv4 permits and the handler skips — so the difference was the checksum path itself.

not is a full-width instruction, and nothing masked the result back down:

~(x as u16)   with x = 0xFFFF
    movzwq %ax, %rax
    notq   %rax          # → 0xFFFFFFFFFFFF0000

A function declared : u16 returned 0xFFFFFFFFFFFF0000. Every internet checksum ends in a one's complement, so calculate_checksum() could never return 0 — and a correct checksum compared unequal to zero. The receiver rejected every ICMP packet whose checksum was right.

Fixed in the backend: bitwise NOT now narrows back to its operand's width. x86-64 output is otherwise unchanged and the codegen tests still pass 12/12.

Gates

gatebeforenow
boot-qemu-x86_6455/5560/60
mvk-compiles x86-6470/7072/72
mvk-compiles aarch6470/7072/72
boot-qemu-aarch64PASSPASS
parse-rate415/415415/415
test-all87/8787/87
stub-register6 open6 open (S3 closed, S11 registered)

Both new Appendix A files reached codegen clean on both architectures on arrival — the demux is byte-order and structure work with no architecture-specific instruction in it, which is what writing against the neutral intrinsics buys.

The runtime test

Five frames built in memory and driven through receive_packet(), the same entry a driver calls, checking both that netdev demuxed each one and that the handler underneath accepted it:

ok  [netdev] ARP demuxed, cache learned 10.0.2.15
ok  [netdev] IPv4/ICMP demuxed and accepted by icmp
ok  [netdev] IPv4/UDP demuxed and accepted by udp
ok  [netdev] runt frame rejected, not dispatched
ok  [netdev] bad IPv4 checksum rejected

The ICMP case sends an echo reply, not a request: a request would make icmp.home transmit, and there is no NIC behind this test. The last two lines are the ones showing validation rejects rather than decorates.

Newly registered

S11 — tcp.home has no segment-input entry point (tcp_receive() is a socket-level read), so a TCP segment is counted and dropped at the demux. Connections cannot be established from the wire until that handler exists.

Still open

S4 (homefs CoW, whose register entry asks for a design document first), S5 (crypto primitives), S6 (USB core), S7 (ACPI), S9 (no ARM64 answer by design), S11.

8 days ago

STUB(S5) closed — the crypto primitives are real, and three compiler bugs were underneath

Landed on main: home-lang/home-os@e53cbf4, home-lang/home@e2d301c.

All four primitives are implemented and checked against the vectors in the documents that define them — not against values this tree generated:

primitivevector
ChaCha20RFC 8439 §2.3.2 keystream block
Poly1305RFC 8439 §2.5.2 tag
ChaCha20-Poly1305round trip, plus a flipped ciphertext bit that must be refused
BLAKE2sRFC 7693 Appendix B
X25519RFC 7748 §5.2
SHA-256FIPS 180-4 (not part of S5 — see below)

Poly1305 uses the five-limb radix-2^26 form rather than the 128-bit multiply the reference implementations reach for, which Home has no type for. X25519 uses ten limbs in alternating radix 2^26/2^25 for the same reason. Both reductions are branch-free, and tag comparison is constant-time: a byte-at-a-time early return leaks how many leading bytes an attacker has guessed, which is enough to forge a tag one byte at a time.

Three compiler bugs, found because the vectors disagreed

Constant tables were emitted into .bss, zero-filled. A module-level array became data only when it had no type annotation — and every real table is annotated (const K: [u32; 64] = [...]). So SHA-256's round constants, AES's S-boxes and BLAKE2s's message schedule all read as zeros. A second defect in the same path wrote .quad per element regardless of width, laying a [u32; N] out at twice its stride.

u32 arithmetic never wrapped. A u32 local was stored with movq and a u32-returning function handed back the whole 64-bit accumulator, so a variable could hold a value its own type cannot represent. Narrowing now happens where a value lands in a declared narrow location.

>> was arithmetic for every type. On an unsigned value with bit 63 set that fills the top with ones, so x >> n returned a number larger than x.

What that means for SHA-256

sha256.home was already written and is not part of S5. It is in this work because the first two bugs made it wrong: zero round constants, and arithmetic that never reduced modulo 2^32. It has been producing incorrect digests for as long as it has existed. It now matches FIPS 180-4 with no change to the file itself — the compiler was the problem.

The suite that hid it

scripts/test-all.sh printed PASS when a file existed:

# TODO: Implement actual test execution when Home compiler ready
if [ -f "$PROJECT_ROOT/kernel/src/${module}.home" ]; then
    echo PASS

So "87/87 passed" meant "87 files exist", and I cited it as evidence earlier in this work before reading it. It now compiles each module and requires the backend to lower all of it, which turns four long-standing failures from green to red — all real, none caused by this change:

  • net/qos — ForStmt is not lowered by the kernel backend
  • video/compositor — find_display_for_window is defined twice
  • kernel_main — TupleDestructureDecl is not lowered
  • rpi5_main — ForStmt is not lowered

kernel_main and rpi5_main also abort the compiler rather than reporting the construct and continuing, which is its own bug.

Gates

gatebeforenow
boot-qemu-x86_6455/5567/67
mvk-compiles x86-6470/7078/78
mvk-compiles aarch6470/7078/78
parse-rate415/415416/416
boot-qemu-aarch64PASSPASS
stub-register7 open5 open

Every crypto file reached ARM64 codegen with no separate work — they are pure integer arithmetic with no inline assembly, which is the point of having written them that way.

Still open

S4 (homefs CoW — its register entry asks for a design document first), S6 (USB core), S7 (ACPI), S9 (no ARM64 answer by design), S11 (TCP segment ingress).

8 days ago

S11 closed, S7 substantially advanced, S4's design half recorded as done

Landed on main: home-lang/home-os@fcbadbd.

STUB(S11) closed — TCP reaches its own state machine

tcp.home had a complete RFC 793 state machine and no way to reach it from the wire. handle_tcp_packet() was private, and the only public entry — tcp_receive() — reads what a connection has already buffered, with nothing to put anything there.

tcp_input() validates before believing: a segment shorter than a header cannot name a connection; a data offset outside the segment would send option parsing off the end; and the checksum covers a pseudo-header of both IP addresses, which is what binds a segment to the addresses it was sent to. Validation lives in the ingress rather than in handle_tcp_packet, because the paths that build segments internally have already done it.

The boot test drives a real SYN to a listening port through receive_packet() and checks tcp.home's own counter moved — so the milestone covers netdev's demux, the validation, and the state machine underneath, not just which branch netdev took.

STUB(S7) — table discovery is real; AML is what remains

The old module scanned for "RSD PTR ", read one field, and stopped. It never verified a checksum, never walked the root table, and could not find a table by signature.

  • The RSDP is searched in both permitted places; checking only the BIOS area misses machines that use the EBDA.
  • Every structure's checksum is verified. A four-byte signature appears in unrelated memory often enough that skipping this means parsing tables firmware never wrote.
  • Revision ≥ 2 uses the 64-bit XSDT — not optional when tables sit above 4 GB.
  • acpi_find_table() returns 0 for a table that is present but fails its checksum, because a corrupt table is worse than a missing one: the caller would parse it.
  • The MADT walk refuses a zero-length record rather than spinning, and counts only processors whose enabled bit is set.

Verified against QEMU's own firmware tables:

[ACPI] revision 0, 4 tables
[ACPI] root table walked, checksums verified
[ACPI] FADT found and validated
[ACPI] MADT parsed, 1 CPU(s) enabled
[ACPI] no table failed its checksum

Shutdown now takes its port from the FADT instead of guessing per hypervisor. The S5 sleep type properly comes from the DSDT's \_S5_ object, which needs the AML interpreter — that and the power-state transitions are what keeps S7 open. Its register description now says that, rather than "ACPI is 71 lines".

S4 — the design doc was already done

docs/design/homefs.md is 210 lines across ten sections with an implementation plan; S4 is registered as "P1 (design doc), P2 (CoW implementation)" and the P1 half was complete. The register now records that.

Its first open question asked whether commit records should use CRC32C as an interim because blake2s was stubbed. S5 closing answers it: use blake2s directly — no interim to carry, no superblock upgrade path to design and then support forever, and the same primitive already backs pantry's signed indexes, so the filesystem and updater agree on one digest.

Gates

gateresult
boot-qemu-x86_6471/71 milestones
mvk-compiles x86-64 / aarch6479/79 · 79/79
parse-rate416/416
boot-qemu-aarch64PASS
test-all87/87, every module compiled
stub-register4 open

Still open

S4 (homefs CoW implementation), S6 (USB core), S7 (AML interpreter, S3 transitions), S9 (no ARM64 answer by design).

8 days ago

xHCI controller discovery — S6's groundwork

Landed on main: home-lang/home-os@cdacd6c.

usb.home held descriptor structs and an init that printed a line. STUB(S6) stays open — no command ring, no event ring, no device-context base array, so no device is addressed and no data moves — but the layer underneath those is now real, and they are sized from the numbers it reads.

[USB] xHCI 0100, 8 ports, 64 slots
[USB] controller registers readable

Four things that matter in the discovery path:

  • Found by PCI class, not vendor/device id. The class is what the specification fixes; a vendor table would find QEMU's controller and miss every real one.
  • BAR0 is a 64-bit memory BAR, so its high half comes from BAR1. Reading only the low dword works right up until firmware puts the window above 4 GB.
  • Bus mastering and memory space are enabled before the first access — firmware does not always leave them on.
  • The register window is mapped a page at a time. It sits above this kernel's 1 GB identity map, and mapping one page and hoping is how a driver reads the first capability register and faults on the operational ones.

An MMIO window is not memory

The capability header is read as one dword with its fields extracted, rather than as a byte and a word.

A controller only has to decode the access widths its registers are defined at, and xHCI defines these as 32-bit. Byte and word reads of that window return zero on QEMU — which presents as a controller reporting version 0, not as an access the device declined to answer. The 16-bit MMIO lowering was verified correct in the generated assembly (movzwq) before concluding it was the device and not the compiler.

Gates

gateresult
boot-qemu-x86_6473/73 milestones
mvk-compiles x86-64 / aarch6481/81 · 81/81
parse-rate416/416
boot-qemu-aarch64PASS
test-all87/87, every module compiled
stub-register4 open

The gate's machine now carries -device qemu-xhci; it had no USB controller before, so a discovery path could not be exercised on it at all.

Still open

S4 (steps 2–7: trees, commit protocol, mount path), S6 (rings and enumeration), S7 (AML interpreter, S3 transitions), S9 (no ARM64 answer by design).

8 days ago

xHCI: running controller, working command ring, a device in a slot

Landed on main: home-lang/home-os@fa0a011.

STUB(S6) is still open — no control transfers, so no descriptors are read and no device is addressed — but the path up to that point is real and proven at boot:

[USB] xHCI 0100, 8 ports, 64 slots
[USB] controller registers readable
[USB] xHCI running, rings armed
[USB] command ring round trip: no-op completed
[USB] device on port 5
[USB] port reset, device enabled
[USB] slot 1 allocated

Bring-up

Reset, device-context base array, command ring and event ring installed, USBSTS.HCHalted clear. The ordering is the specification's, not caution: stop before resetting (resetting a running controller is undefined); wait for both HCRST and CNR to clear (the first says the reset finished, the second that the controller can answer register reads at all); enable the slots it reports before running, or it accepts no device; fill the event ring segment table before writing ERSTBA, because that write is what makes the controller read the table.

64-bit registers are written low half first — high half first would momentarily point the controller at an address made of the new top and the old bottom. Every wait is bounded; a controller that never answers must not hang the boot.

Three things that cost a cycle each, all worth recording

MMIO is not memory. Byte and word reads of the capability window return zero on QEMU — which presents as a controller reporting version 0, not as a refused access. xHCI defines those registers as 32-bit and only that width has to be decoded. I checked the generated assembly (movzwq, correct) before concluding it was the device and not the compiler.

The event ring carries every kind of event. A port reset posts a Port Status Change Event on the same ring. Treating the next event as the answer to the last command made Enable Slot look as though it failed whenever anything happened first — which is precisely what it did. Events that are not this command's completion are now consumed and the wait continues.

PORTSC mixes write-1-to-clear status bits with control bits. Writing a modified value straight back clears every status bit that happened to be set. The change bits are masked before the read-modify-write and acknowledged deliberately afterwards, or the change bit stays set and the next reset appears to complete instantly.

A regression the gate caught

The first version attached a usb-kbd. QEMU then routes keystrokes to it instead of the PS/2 controller, and [IRQ] keyboard line live stopped firing — 74/75. The gate now attaches a usb-hub, which enumerates identically without taking input. Worth stating plainly: the milestone was right and the change was wrong.

Gates

gateresult
boot-qemu-x86_6477/77 milestones
mvk-compiles x86-64 / aarch6481/81 · 81/81
parse-rate416/416
boot-qemu-aarch64PASS
test-all87/87, every module compiled
stub-register4 open

Still open

S4 (steps 2–7), S6 (control transfers and descriptors), S7 (AML interpreter, S3), S9 (no ARM64 answer by design).

8 days ago

USB enumeration works end to end

Landed on main: home-lang/home-os@35d56b0.

[USB] xHCI 0100, 8 ports, 64 slots
[USB] xHCI running, rings armed
[USB] command ring round trip: no-op completed
[USB] device on port 5
[USB] port reset, device enabled
[USB] slot 1 allocated
[USB] device addressed
[USB] descriptor read: 0409:55AA

0409:55AA is QEMU's hub — NEC's vendor id, the hub's product id — so the transfer landed on the right bytes and not merely on some bytes. The descriptor type byte is checked before the identifiers are believed, and the milestone asserts them in full rather than as a prefix: a read that landed wrong would still produce a plausible-looking prefix.

STUB(S6) stays open, now for what it is actually missing: class drivers and interrupt/bulk endpoints, so no USB keyboard and no USB storage. The host-controller driver itself is done.

Details that are easy to get wrong and were worth stating in the code

Input and output contexts are different structures. The input context is the controller's argument block; the controller copies what it accepts into the output device context, which is what DCBAA[slot] points at. Conflating them is the first mistake this interface invites.

Context size comes from HCCPARAMS1, not from an assumption. A 64-byte context read as 32 puts every field at the wrong offset, and the controller answers with a parameter error that names nothing.

Endpoint 0 is opened at 64 bytes because the real maximum packet size lives in the device descriptor, which cannot be read until endpoint 0 works. Every speed above low-speed tolerates 64 for that first transfer, which is how the circularity is escaped.

A control transfer is three TRBs — setup, data, status. The setup packet travels inside its TRB rather than in a buffer (that is what the immediate-data flag means), and only the status stage requests an interrupt, so one transfer yields one event. A short-packet completion counts as success for a descriptor read: it means the device sent what it had.

Gates

gateresult
boot-qemu-x86_6479/79 milestones
mvk-compiles x86-64 / aarch6481/81 · 81/81
parse-rate416/416
boot-qemu-aarch64PASS
test-all87/87, every module compiled
stub-register4 open

Still open

S4 (steps 2–7), S6 (class drivers, interrupt/bulk endpoints), S7 (AML interpreter, S3), S9 (no ARM64 answer by design).

8 days ago

The xHCI host-controller driver is complete

Landed on main: home-lang/home-os@0401e5f.

[USB] xHCI 0100, 8 ports, 64 slots
[USB] xHCI running, rings armed
[USB] command ring round trip: no-op completed
[USB] device on port 5
[USB] port reset, device enabled
[USB] slot 1 allocated
[USB] device addressed
[USB] descriptor read: 0409:55AA
[USB] configuration 1, interrupt endpoint 81
[USB] device configured
[USB] interrupt endpoint configured, DCI 3

From a 44-line stub to a device driven all the way to configured with a usable interrupt endpoint, every step asserted at boot against real emulated hardware.

STUB(S6) stays open for what it now actually lacks: a class driver, and data moved on a non-control endpoint. No USB keyboard, no USB storage.

Details worth recording

Endpoints are addressed by Device Context Index, not by USB address. DCI is 2 * endpoint number + direction, with endpoint 0 occupying DCI 1 — so endpoint 0x81 is endpoint 1 IN, DCI 3. Getting that mapping wrong configures a different endpoint than intended, and the controller has no way to notice.

A Configure Endpoint command carries the slot context too, because its context-entries field has to grow to cover the new highest DCI. Sending only the endpoint leaves the controller believing the device has fewer endpoints than it just described.

SET_CONFIGURATION has no data stage, so its status stage runs IN — the opposite direction from the descriptor reads above it. That is precisely the detail that makes one shared control-transfer helper wrong, and why there are two.

The configuration descriptor is variable-width records laid end to end. Walking it means following each record's own bLength; a zero-length record is refused rather than spun on.

Gates

gateresult
boot-qemu-x86_6482/82 milestones
mvk-compiles x86-64 / aarch6481/81 · 81/81
parse-rate416/416
boot-qemu-aarch64PASS
test-all87/87, every module compiled
stub-register4 open

Still open

S4 (steps 2–7), S6 (class drivers, non-control data), S7 (AML interpreter, S3), S9 (no ARM64 answer by design).

8 days ago

Data moves on a bulk endpoint — the USB stack talks to a device

Landed on main: home-lang/home-os@7375d7d.

[USB] descriptor read: 46F4:0001
[USB] configuration 1, interface class 8
[USB] device configured
[USB] bulk endpoints configured, in 81 out 02
[USB] SCSI INQUIRY: QEMU

A SCSI INQUIRY completes against a mass-storage device under Bulk-Only Transport: a Command Block Wrapper out on bulk OUT, the reply in on bulk IN, and a Command Status Wrapper whose signature, tag and status all have to match before the answer is believed. A data phase that returns bytes proves the transfer happened, not that the device liked the request — the CSW is what says that.

STUB(S6) stays open for what is genuinely left: no HID driver, so still no USB keyboard; and mass storage reads no blocks yet — INQUIRY works, READ(10) does not.

Details

Both bulk endpoints are configured in one Configure Endpoint command. The controller takes an input context describing every endpoint being added, so doing them together is both fewer round trips and the only way to set the slot's context-entries count once and correctly.

The Normal TRB sets ISP alongside IOC. A short packet then finishes the transfer rather than stalling the endpoint — which is what a device does whenever it has less to say than was asked for, an INQUIRY reply included.

A milestone replacement, stated plainly

The gate's USB device is now a usb-storage rather than the usb-hub it was, and the hub's three milestones are replaced rather than joined. The hub enumerated but could never move data: its only endpoint is an interrupt IN reporting port changes, and none happen during a boot.

Replacing a milestone is exactly the move that hides a regression when it is not stated, so: the enumeration assertions are the same ones, the device identifiers changed because the device did, and two lines were added that the hub could never have produced.

A usb-kbd remains unusable here — it takes keystrokes away from the PS/2 controller and silences [IRQ] keyboard line live, a real regression this gate caught the first time it was tried.

Gates

gateresult
boot-qemu-x86_6483/83 milestones
mvk-compiles x86-64 / aarch6481/81 · 81/81
parse-rate416/416
boot-qemu-aarch64PASS
test-all87/87, every module compiled
stub-register4 open

Still open

S4 (steps 2–7), S6 (HID driver, block reads), S7 (AML interpreter, S3), S9 (no ARM64 answer by design).

8 days ago

Blocks come off a USB disk

Landed on main: home-lang/home-os@026539e.

[USB] SCSI INQUIRY: QEMU
[USB] capacity 4096 blocks of 512
[USB] block 0: HOMEOS-USB-BLOCK0

The harness writes that signature into LBA 0 of the USB image, and the milestone asserts the kernel read exactly that back. Bytes coming back is not the same as the right bytes coming back — the block could have come from the wrong LBA, the wrong device, or a stale buffer and still have looked like success. The capacity is asserted for the same reason: 4096 blocks of 512 is precisely the 2 MB image the harness builds.

STUB(S6) stays open for: no HID driver (still no USB keyboard), no write path, and no VFS integration for USB storage.

Two details that produce plausible-looking wrong answers

SCSI is big-endian, unlike every other structure on this path — the LBA in the command block and both fields of the capacity reply. Reading them little-endian yields numbers that look like reasonable geometry for a different disk, which is the kind of wrong that survives a smoke test.

READ(10) counts its transfer length in blocks; the Command Block Wrapper counts the same transfer in bytes. Swapping them asks the device for 512 blocks or for one byte.

Gates

gateresult
boot-qemu-x86_6485/85 milestones
mvk-compiles x86-64 / aarch6481/81 · 81/81
parse-rate416/416
boot-qemu-aarch64PASS
test-all87/87, every module compiled
stub-register4 open

The USB stack, start to finish

From a 44-line stub of descriptor structs to: controller found by PCI class, register window mapped, reset and started with its rings armed, a command completing, a port reset, a slot allocated, a device addressed, descriptors read, configuration set, bulk endpoints configured, Bulk-Only Transport working, and a verified block read. Every step asserted at boot against real emulated hardware.

Still open

S4 (steps 2–7), S6 (HID driver, writes, VFS integration), S7 (AML interpreter, S3), S9 (no ARM64 answer by design).

8 days ago

homefs step 2 — a block device and copy-on-write B-trees

Landed on main: home-lang/home-os@7ea5783.

Design doc §9 step 2: "ramdisk-backed block device; B-trees with in-memory mirror; executed tree tests". All three, run at boot:

[homefs] on-disk structures round-trip, 7/7
[homefs] copy-on-write B-tree, 6/6

Everything above the device addresses blocks, not memory, so the same tree code runs against a real disk when one is wired up — that is the only reason the layer exists rather than the trees indexing memory directly.

A modified node is written to a fresh block and its parent updated to point at the copy, all the way to the root. Nothing is overwritten in place, so the previous root still describes the tree as it was. That is what a snapshot is, and the test checks it directly rather than inferring it from the absence of overwrites.

An off-by-one that produced a plausible tree

A node holds 254 entries at indices 0..253. Splitting when the count exceeds that writes entry 254 at offset 4096 — the first byte of the next block.

The symptom was a tree that answered most lookups correctly and had quietly lost the entries a neighbouring node's header had overwritten. That is exactly why the test reads back every one of 600 keys rather than a sample: a tree that loses entries during a split still passes a spot check.

Also worth stating: block 0 is never allocated, because zero doubles as "no block" in every pointer field and a tree that could legitimately live there would be indistinguishable from an empty one.

A real constraint, named rather than hidden

Copy-on-write spends a block per node touched and gives none back until the refcount reclamation of §6 exists. 600 inserts cost ~950 blocks. The ramdisk is sized for the whole test rather than for the tree it ends up holding, and the first attempt exhausted a 1024-block device — which is the honest shape of the problem, not a test artefact.

Gates

gateresult
boot-qemu-x86_6486/86 milestones
mvk-compiles x86-64 / aarch6481/81 · 81/81
parse-rate416/416
boot-qemu-aarch64PASS
test-all87/87, every module compiled
stub-register4 open

Still open

S4 (steps 3–7: commit protocol, mount path, reclamation), S6 (HID, writes, VFS), S7 (AML interpreter, S3), S9 (no ARM64 answer by design).

8 days ago

homefs survives a crash — and the bug was not where I said it was

Landed on main: home-lang/home-os@da28ea7, home-lang/home@b9731a5.

Correction first

I reported that ata_write_sector was not reaching the backing image, and used that to explain why remounts read stale data. That was wrong. The probe sectors I was watching were being overwritten by homefs's own data blocks — its volume covers the LBAs I had picked, and tree nodes are mostly zeros. Writing to an LBA homefs never touches showed every sector persisting exactly as written.

The real fault was a compiler bug: assigning one struct to another stored the source's address into the destination's first eight bytes.

a = b     →    leaq -24(%rbp), %rax     # address of b
               movq %rax, -40(%rbp)     # into a's slot

A struct variable is storage — its slot holds the bytes — so reading a member back returned a pointer where the first field should be. The declaration path already copied (var a: T = b emits a memcpy); only assignment to an already-declared variable did not. That is why it survived: the shape written in a constructor works, the shape written in a selection does not.

homefs's mount reads both superblock slots and picks the newer with chosen = slot1. It then validated a struct whose generation, block count and root were all wrong — so a volume with two perfectly good superblocks on disk was rejected and reformatted on every boot.

S4 step 3 — the commit protocol

ok  iteration 1 (killed at 2.37s): volume consistent, 125 keys
ok  iteration 2 (killed at 2.74s): volume consistent, 400 keys
...
crash-gate: 10/10 iterations mounted a consistent volume.

The 125-key iteration is the one that matters: the machine died partway through and the volume came back holding a whole prefix of the transactions, not a mixture.

The write order is §5's and each step is load-bearing — new blocks first (nothing points at them yet, so a crash leaves the previous tree whole), then the commit record (a torn one fails its checksum and reads as absent), then the superblock into the slot the older generation occupies, so the slot being overwritten is never the one a crash would fall back to.

Two harness properties that had to be right first

  • Signal QEMU's own pid. Backgrounding a function that backgrounds QEMU leaves the emulator alive, and the next iteration cannot take the image's write lock — which reads exactly like a filesystem that will not mount.
  • cache=writethrough. QEMU's default holds writes in the host page cache, so SIGKILL discards them. That is not a crash any guest could observe; testing against it measures QEMU's buffering.

And one assertion worth its weight: a reformat fails the gate once any transaction has committed. Starting over is correct only for a volume that never held one — after that it is data loss wearing the same message, and accepting it is how a crash gate comes to certify a filesystem that quietly wipes itself.

Also fixed

ata_init built the primary_slave struct and both I/O paths supported drive 1, but nothing ever sent IDENTIFY to the slave — so a second disk could be attached, read and written, while every caller asking whether it existed was told no.

Gates

gateresult
boot-qemu-x86_6486/86 milestones
crash-gate (Tier 2)10/10 iterations
mvk-compiles x86-64 / aarch6481/81 · 81/81
boot-qemu-aarch64PASS
test-all87/87
stub-register4 open

Still open

S4 (steps 4–7: reclamation, VFS integration, snapshots), S6 (HID, writes), S7 (AML), S9 (no ARM64 answer by design).

Sign in to comment on this issue.