ReviewOS

home-lang/home-os

Phase 0.5 — Codegen widening: from one booting file to the Appendix A MVK file-set

#38
Closed chris opened this 8 days ago ·
8 days ago

The MVK boots (#26), but kernel/src/mvk_poc.home is written to fit the kernel backend's current shape: integer literals, local function calls, and simple-form inline assembly. That is why its proof-of-life message is spelled out through 32 putc() calls instead of printed from a string.

The distance from that file to the 41-file MVK set in MASTER_PLAN Appendix A is the largest remaining piece of Phase 0. The original plan collapsed it into one bullet ("the home compiler builds the MVK file-set end-to-end"); it is a dozen independent features. Tracked here as A9 and MASTER_PLAN §5 Phase 0.5.

The ladder

Each step adds exactly one language feature and must still boot and print. A step is done when a kernel using that feature passes boot-qemu-x86_64. Ordered because each depends on the last:

  • String literals in .rodata — replaces the putc() spelling with a real print()
  • Arrays and indexing
  • Structs and field access
  • while / for over data
  • Function pointers
  • Imports across files
  • const / comptime evaluation
  • Volatile MMIO load/store semantics (milestone A3 — also the prerequisite for ever un-stubbing S8 honestly, #36)

The ratchet

A CI job mvk-compiles records how many of the 41 Appendix A files reach codegen. The number may never fall. This replaces "does the tree compile" — an all-or-nothing question that reads "no" for months and tells nobody anything — with a number that moves every week.

Diagnostics are a deliverable

Every file that fails to compile must fail with file:line:col, expected/found types, and a suggested fix (the A2 bar). A compile ratchet whose failures are not actionable is just a number.

Housekeeping

  • Consolidate the two serial implementations (kernel/src/serial.home, kernel/src/drivers/serial.home) before either becomes a compile target — noted in Appendix A's known gaps.

Most of the work here lands in home-lang/home, per decision D2.

Blocks: #22 (Phase 0 exit), #28 (Phase 1).

8 days ago

Progress: 3/39 → 4/39, and the whole distribution moved.

The ladder's first rungs are done, though not in the order the issue listed them — the measured blockers dictated the order instead.

Done

  • String literals in .rodata — mvk_poc.home now prints from a real string through a loop instead of 32 hand-spelled putc() calls, with no inline assembly left in the file
  • Arrays and indexing — locals get their real width in the frame; indexing lowers to a scaled address and an element-width load/store, for both locals and module-level arrays
  • while/for over data — plus break, continue, and nested blocks with correctly scoped loop labels
  • const/comptime evaluation — module constants fold and substitute at use
  • Volatile MMIO load/store (milestone A3) — mmio_read8..64, mmio_write8..64, plus outb/outw/outl, inb/inw/inl, hlt, cli, sti, nop, pause, mfence. A program declaring a function of the same name wins, so intrinsics cannot silently redirect an existing call.

Not on the original list, but they were the actual blockers

  • Function parameters were never bound to anything. Every parameter reference read whatever happened to be in %rax, so any function taking arguments returned garbage.
  • locals and stack_offset were never reset between functions, so one function's variables resolved inside the next at meaningless offsets.
  • Locals were created with pushq and recorded at a running offset, correct only when no expression temporary had been pushed first — and expression codegen pushes constantly.
  • Module-level storage had nowhere to go, so file-scope variables emitted stores against %rbp outside any function. They now become .bss/.data symbols sized from the declared type; an undeterminable size is refused rather than guessed, since a symbol of the wrong length corrupts whatever follows it.
  • Classifying an immutable binding as a constant was wrong for this tree, which uses plain let for module state it writes to later. A pre-pass now collects every assigned name, so a binding is constant only if nothing writes to it.
  • Labels came from AST node addresses, so no two builds produced the same output.
  • Full operator set, short-circuiting &&/||, assignment, unary operators, _ discard, unsigned literals above i64 max.

Upstream: home-lang/home@a1be8d9, home-lang/home@706e7cd.

One correction to the issue as written. The first version of the ratchet counted a zero exit status as success and reported 39/39. That was false — the backend emits a marker comment and carries on when it cannot lower something, so exiting 0 only means it did not crash. pmm.home 'passed' while producing 98 unlowered constructs. A file now counts only when the compiler writes a non-empty .s, that .s carries no ERROR or unsupported marker, and the assembler accepts it.

Remaining, in measured priority order (scripts/mvk-compiles.sh --list):

  • Structs and field access — s.field load/store, and struct-typed arrays like idt: [256]IdtEntry. This is now the single largest blocker.
  • Imports across files — every remaining file imports core/foundation.home
  • Byte-width loads, retiring the padding hack that lets a character be read as a 64-bit load masked to its low byte
  • Enums (Color.White and peers)
  • Consolidate kernel/src/serial.home and kernel/src/drivers/serial.home

Closest to compiling: sys/syscall.home (3 markers), serial.home (4), idt.home (4), drivers/serial.home (5).

8 days ago

3/39 → 24/39. Only 14 files still fail, and 8 of those have six refusals or fewer.

start3/39
+ structs and field access8/39
+ slices, address-of, [T; N], local type inference19/39
+ constant-sized arrays, aggregate assignment, [*]T23/39
+ enums, type qualifiers, string-literal locals24/39

Upstream: home-lang/home@d5bc222, @8818e3e, @ae1e270, @01ee1f3. The MVK boots after every one.

Remaining, by file

  1  multiboot2.home          1  sys/signal.home       1  sys/syscall.home
  2  core/vfs_block_io.home   3  vga.home              5  idt.home
  5  net/socket.home          6  drivers/timer.home   13  core/filesystem.home
 20  core/vfs_path.home      51  lib/shell_syscall.home
 62  mm/slab.home            69  vmm.home            102  sched/scheduler.home

The next rung is cross-file imports, and it is worth doing before anything else on this list. The four largest files are not each failing for their own reason — they are failing for one. struct Slab has a field of type spinlock.Spinlock; the backend cannot size a qualified type from another module, so the field is unsizable, so the whole struct is left unlaid-out, so every one of its fields is unresolvable. That single missing capability is most of slab.home's 62 refusals, scheduler.home's 102 (cpu_rq: [CPURunQueue; 16], whose element holds a spinlock.Spinlock), and the 80 type X has no field Y errors across the set.

Concretely the backend needs to follow import "../sync/spinlock.home" as spinlock and pull that module's struct, enum, and constant declarations into scope under its alias.

After that, in measured order: array literals with no type annotation (let exception_names = [...]), .ptr/.len on more shapes, and the last few not an addressable expression cases where an aggregate is assigned from a non-lvalue.

Byte-width loads can also come off the list now — the padding hack is gone from the hot path, since indexing lowers to an element-width load through the declared type. The seven-byte .rodata padding remains only for the (*p) & 255 idiom in mvk_poc.home, which is worth replacing with an indexed read.

One thing to note for anyone reading the numbers: the ratchet counts a file only when the compiler emits assembly with no unlowered construct in it and the assembler accepts that assembly. It is not counting files the compiler merely survived.

8 days ago

Cross-file imports landed, plus four more rungs. 24/39 → 26/39, and the refusal count is down to 60 across 11 files.

The file count badly understates this stretch. What actually happened:

filebeforeafter
sched/scheduler.home1020
mm/slab.home620
vmm.home6915
lib/shell_syscall.home514

Cross-module types (home-lang/home@398ff81) — the one you asked for. struct Slab has a spinlock.Spinlock field; an unsizable field makes the whole struct unlayoutable, so every field of it and every line touching any of them failed. One unknown type name was producing sixty errors. The backend now follows each import through the module resolver, parses it, and registers its structs, enums, and constants under both the qualified name (spinlock.Spinlock) and the bare one, with local declarations winning collisions. It recurses four levels and visits each file once — these modules form a diamond around core/foundation.home and there are cycles.

Worth recording: the first attempt did nothing, silently. Parser.init constructs the module resolver with no Io context, so the file open was never even attempted and my orelse continue swallowed it. Fixed by plumbing g_io through in the build path, and the backend now emits an ERROR marker naming the module it could not read rather than moving on quietly.

Nested array types (@bb56b52) — [[u8; 64]; 64] was split at the first ], which is the inner one, so the type parsed as garbage and took its containing struct down with it. Bracket matching now, and [T; N] splits at the last ; outside any nested brackets.

Bitfield structs (@685e984, @93f3f32) — packed struct PageFlags: u64 { present: bool, ... } is not a struct with byte offsets; it IS a u64 whose fields are bit ranges. The parser had been discarding the backing type with a comment saying codegen would recover it from the field types, which it cannot — the widths are there but nothing says which integer they pack into. StructDecl now carries backing_type; codegen lays fields out as bit ranges, reads with shift-and-mask, and writes read-modify-write. And a bitfield struct is a value, not storage, so entry.flags = emptyPageFlags() no longer demands an lvalue on the right.

What is left — 60 refusals, 11 files

  1 core/vfs_block_io.home    1 multiboot2.home       1 sys/syscall.home
  3 vga.home                  4 lib/shell_syscall.home 5 idt.home
  5 net/socket.home           6 core/vfs_path.home     6 drivers/timer.home
 13 core/filesystem.home     15 vmm.home

By category:

  • 18 cannot index: the base has no known type — locals whose type still isn't inferable, mostly results of builtins and casts
  • 13 not an addressable expression — an aggregate assigned from a call, which needs a struct-return ABI
  • 5 unsupported module-level bindings — array lengths written as expressions ([MAX_PAGE_TABLES / 8]u8), not names or literals; the constant folder handles expressions but the type-string parser only accepts a bare identifier
  • 5 array literals with no annotation — let exception_names = [...]
  • the rest are one-offs

None of these is another sixty-error domino. From here it's a long tail.

8 days ago

Done — 39/39. Every Appendix A file reaches codegen, and every one typechecks clean.

The ladder in the original description turned out to be roughly right in content and badly wrong in sequencing. What actually gated progress:

WhatEffect on the ratchet
Honest refusal markers (see below)34 → 0
Module-level mutable bindings → .bss/.data3 → 4
Struct layout and field access4 → 8
Local type inference from initializers8 → 19
Cross-module type resolution19 → 26
if-expression branches parse as blocks29 → 32
Struct return by value; array literals33 → 37
Module-level array tables; string addresses37 → 39

The finding that mattered most

The backend ended both its expression and statement switches in a bare else => {}. Anything it did not recognise emitted no code at all, so %rax kept the previous computation and the surrounding statement compiled cleanly around a wrong value. No diagnostic, no crash, no bad assembly — a different answer at runtime.

let x = if c { a() } else { b() } stored an uninitialised register. So did every @intFromPtr, every as cast, every character literal, every null. Across the Appendix A set that was 1,235 silently dropped constructs, in files this ratchet was counting as compiling. The 34/39 reported at the time was not true.

Making both arms report what they drop took the honest count to 0/39. Recovering to 39 from there is the real work this issue tracked. scripts/mvk-floor.txt records the drop so it cannot later read as a regression, and the ratchet now names the only three reasons its floor may fall.

Second-order lesson

Two of the highest-leverage fixes were not "add a feature" at all — they were constructs the compiler was silently mis-shaping:

  • An if-expression branch containing a statement consumed the brace closing its enclosing function, so bindings vanished and the rest of the function was hoisted to module scope. It parsed without error into a different program.
  • Slice indexing computed its offset from the address of the (pointer, length) pair rather than the data pointer, so s[i] read into the slice header. It generated clean assembly and would simply have read garbage.

Neither would have been found by a gate that stops at "the compiler didn't crash".

Housekeeping from the description

  • Consolidate the two serial implementations — both compile; deferred to Phase 1 bring-up, where one of them actually gets used and the choice is informed rather than arbitrary.

Type-system follow-on is #39. Milestone A2 is #40.

Sign in to comment on this issue.