Nether bring-up notes (hard-won, verified on bare-metal KVM)¶
Field notes from taking Nether from a KVM_RUN skeleton to a Linux 6.12 guest
that boots to an interactive shell with working virtio-blk, on a real KVM host.
Almost every item here cost a live debug cycle to find. Keep this current.
KVM / x86 VMM gotchas (the expensive ones)¶
-
VMCS segment limit is byte-granular, not the 20-bit descriptor value. A flat 4 GiB segment needs
kvm_segment.limit = 0xFFFFFFFF, NOT0xFFFFF. KVM does not re-expand the limit by the G bit. With0xFFFFFyou get a literal 1 MiB limit; a kernel loaded at 16 MiB faults on the first instruction fetch (#GP -> triple fault, RIP stuck at entry). -
CPUID must be programmed (
KVM_GET_SUPPORTED_CPUID->KVM_SET_CPUID2). Without it the guest sees no long-mode bit and the kernel'sEFER.LMEwrmsr#GPs in early head_64.S. -
GET_SUPPORTED_CPUID leaks the host core's APIC ID. Rewrite leaf 1 EBX[31:24] and leaf 0xB/0x1F EDX to the vCPU id. Otherwise the guest aims MSIs at the host's APIC id (e.g. 8) while the vCPU LAPIC is id 0, and every MSI is silently dropped (
KVM_SIGNAL_MSIreturns 0). Symptom: device "works" but completions never arrive. Watch for[Firmware Bug]: APIC ID mismatchin dmesg. -
PVH magic is
0x336ec578(XEN_HVM_START_MAGIC_VALUE). Wrong value tripsxen_prepare_pvh()'sBUG()(ud2-> triple fault, no IDT yet). -
PVH entry state: 32-bit protected mode, paging off, flat CS/DS/ES/SS/FS/GS,
EBX= hvm_start_info paddr, GDT set. The kernel builds its own GDT, page tables, and enters long mode itself. Boot a PVH ELFvmlinux(has theXEN_ELFNOTE_PHYS32_ENTRYnote, readelf -n type 0x12), NOT a bzImage. -
Split irqchip (KVM_CAP_SPLIT_IRQCHIP) puts the IOAPIC/PIC in userspace. Without a userspace IOAPIC the guest reads all-ones from 0xFEC00000 -> "I/O APIC ... registers return all ones, skipping" -> it disables legacy IRQ routing entirely -> serial IRQ4 never fires.
-
With an IOAPIC present, the kernel runs a boot-time IO-APIC+timer routing check that panics ("IO-APIC + timer doesn't work!") when there is no i8254 PIT. Fix:
no_timer_checkon the cmdline. The kernel then uses the in-kernel LAPIC timer and the IOAPIC just routes serial. -
Serial: console printk is polled (works with no IRQ); the TTY is interrupt-driven. So early boot logs print fine, but
/init/getty output stalls after exactly 16 bytes (the 16550 TX FIFO) waiting for a THRE interrupt. The 16-byte stall is the tell. Need IRQ4 (THRE + RX-data) via the IOAPIC. Implement IER/IIR so the driver's ISR can identify the source. -
HLT with in-kernel LAPIC (split irqchip) does not exit to userspace - KVM blocks the vCPU in-kernel until an interrupt. Good: a running OS HLTs constantly; do NOT treat
KVM_EXIT_HLTas "guest done" once an OS is booting. (The real-mode smoke test uses HLT as "done"; an OS does not.) -
MSI-X needs no IOAPIC (messages go straight to the LAPIC). virtio-blk completion via MSI-X worked before the IOAPIC existed.
signalMsireturns 1 if delivered, 0 if dropped - log it; a 0 means a destination/vector mismatch. -
IOAPIC redirection -> MSI translation:
addr = 0xFEE00000 | (dest<<12) | (dest_mode<<2),data = vector | (delivery<<8) | (trigger<<15) | (trigger<<14). Level entries: clear remote IRR onKVM_EXIT_IOAPIC_EOI. -
ACPI minimal set is enough: RSDP/XSDT/FADT/FACS/MADT/MCFG/DSDT. The PM timer (FADT PM_TMR) carries TSC calibration - the kernel's quick-PIT calibration fails and falls back to PMTIMER, so no i8254 is needed to boot.
-
PCIe BARs: author the host bridge
_SB.PCI0with a_CRS(bus range + the pci-mmio32 window) in ASL, compile withiasl,@embedFilethe AML. Pre-assign the device BAR inside that window and Linux claims it instead of reassigning. ECAM config comes via MCFG, not_CRS. -
Unclaimed PIO/MMIO must be SILENT on the hot path (reads float high all-ones, writes drop). A print per unclaimed access floods the console and starves the guest during device probing (the kernel hammers absent ports).
-
virtio-blk "writes not submitted" was a red herring -
/initwas stalling on the 16-byte console before it could issue the write. Once the console worked (IOAPIC), reads AND writes worked end to end (guest write lands on the mmap'd host disk image).
Resolved: continuous stdin (I/O thread + per-device lock)¶
The old design polled host stdin only on a serial register access, so an idle
shell stopped getting bytes mid-input. Fixed with a dedicated I/O thread
(stdinPump in main.zig) that blocks on stdin and calls Serial.pushRx, which
enqueues into an RX FIFO and raises IRQ4 via the IOAPIC -> signalMsi. The
in-kernel LAPIC wakes the (possibly HLT-blocked) vCPU, so input flows even when
the guest is idle. Notes:
KVM_SIGNAL_MSIis a vm-fd ioctl and is designed to be called from a thread other than the one inKVM_RUN; that is how async interrupts get injected.- This is the first real instance of the D3 per-device lock. Two devices are
now touched from two threads: serial (RX ring) and the IOAPIC (redir table read
on raise). Each got a lock. Lock order is serial -> ioapic, enforced by
raising the IRQ only after releasing the serial lock, and by reading the redir
entry under the ioapic lock then releasing it before the
signalMsisyscall. No lock is ever held across a blocking/slow syscall (the serial TXwriteand the IRQ raise both happen after the serial unlock). - Host terminal goes into raw mode (termios: clear ICANON/ECHO/ISIG/IEXTEN,
IXON/ICRNL; VMIN=1/VTIME=0) so each keystroke reaches the guest and the guest
does its own echo.
oflagis left alone (stdout shares the tty and relies on ONLCR). Restored on exit viadefer. With ISIG off, Ctrl-C reaches the guest; exit nether by powering off the guest. A SIGKILL leaves the tty raw (reset). - The thread is detached and reclaimed at process exit (it is blocked in
read).
Methodology that worked¶
- Offline-first. Build + unit-test every component by cross-compiling to
x86_64-linuxand running tests on the host. The live boot is the only thing that needs hardware; each live bug was unfindable offline but the components were de-risked there. Pre-building the IOAPIC/MSI-X/ACPI offline turned the box session into "wire and confirm + fix 2 things". - Trace gated by a marker file (
nether-tracein cwd): runtime toggle, no rebuild, no env/args (which 0.16 dropped). Trace points on PCI config, virtio feature/queue programming, notify, MSI, IOAPIC raise. - Separate stdout (serial) from stderr (trace). They interleave at the byte
level in one file (
NETHER-INIT-STAR[trace]...); always> ser.log 2> tr.log. - vCPU state dump on triple-fault/SHUTDOWN (regs+sregs: rip, cr0/3/4, efer,
cs fields). Then disassemble
vmlinuxat the faulting vaddr to find the instruction (map phys->vaddr via the PT_LOAD with that paddr).
Zig 0.16 specifics¶
- Use
std.os.linuxdirectly for syscalls: open/mmap/munmap/read/write/ioctl/ errno/eventfd/clock_gettime/lseek/fcntl.std.posix.open/getenvare gone or churned; the file API now needs anIo. Env vars unavailable -> marker file. linux.errno(r), notE.init.PROTis a packed struct now (.{ .READ = true, .WRITE = true });mmaptakes typedPROT/MAP.timespechas.sec/.nsec.build.zig:addExecutable{ .root_module = b.createModule(...) }. Tests target the host (b.resolveTargetQuery(.{})) sozig build testruns; the exe defaults to crossx86_64-linux.@embedFile("dsdt.aml")for the AML.- ArrayList API is mid-churn; fixed-capacity arrays sidestep it.
- macOS host: native linking needs
DEVELOPER_DIR=/Library/Developer/CommandLineToolswhen xcode-select points into Xcode.app. Cross-compile is unaffected.zigitself was at/etc/paths.d/zig(root-owned). - Pin the stable toolchain. This code targets 0.16.0 stable
(
~/Library/zig/0.16.0/zig), but several dev nightlies are also installed and/etc/paths.d/zigpointszigat one of them. The APIs diverge in both directions, so a wrong toolchain fails to build: - stable:
std.os.linux.PROTis a packed struct (.{ .READ = true }); nightly 2135 made it a namespace of bit constants (PROT.READ). - stable: there is no
std.Thread.Mutex(it moved); onlystd.atomic.Mutex(atryLock-only spinlock primitive) andstd.Io.Mutex(needs anIo). nightly 2135 still hasstd.Thread.Mutex. We use a tiny spinLock(src/lock.zig) overstd.atomic.Mutexso the freestanding device models need neither. Build/test with the stable path explicitly until/etc/paths.d/zigis repointed at~/Library/zig/0.16.0.
AWS box workflow¶
- KVM requires bare metal (only
*.metalexposes/dev/kvm+ VT-x). Smallest x86 metal is 96 vCPUs (c5.metal). The on-demand/spot vCPU quota must be >=96 (defaults are far lower): on-demandL-1216C47A, spotL-34B43A08, region us-west-2. Spotc5.metal~$1-1.5/hr vs ~$4 on-demand. - Metal takes 5-15 min to boot; SSH/
/dev/kvmaren't up immediately. - zsh does not word-split unquoted
$VAR-$SSH "cmd"runs the whole string as one command. Use an array (ssh "${SSHK[@]}" ...) or inline ssh. - Kernel:
x86_64_defconfig+scripts/config -e PVH SERIAL_8250_CONSOLE BLK_DEV_INITRD DEVTMPFS DEVTMPFS_MOUNT KVM_GUEST PARAVIRT PCI PCI_MSI VIRTIO VIRTIO_PCI VIRTIO_BLKthenmake olddefconfig && make -j96 vmlinux(~5-8 min on 96 cores). Use the resultingvmlinux(ELF, has the PVH note). - initramfs: static busybox (busybox.net musl binary) +
/initscript,chmod +x /init(forgetting it -> "No working init found"), thenfind . | cpio -o -H newc | gzip. - Cache
vmlinux/initramfsin S3 (scripts/artifact-cache.sh) to skip the rebuild next session;disk.imgis trivial to recreate (dd+ a marker). - Always tear down: terminate instance, then delete the SG (after the metal host
fully terminates, or DeleteSecurityGroup hits a DependencyViolation), delete
the key pair, remove the local
.pem.