All documentation
How it works
The hypervisor
docs/hypervisor.mdView on GitHubA type-1 hypervisor for nos, written as Rust crates and loaded into a
running kernel as a module:
$ insmod /hv.ko
module: hv loaded at 0xFFFF800031000000
$ hv info
hv: AMD-V (SVM) -- ready
cpu AuthenticAMD, itself under a hypervisor
revision 1, 16 ASIDs
nested paging yes -- guest physical addresses translated by the CPU
next-RIP save no -- where an intercepted instruction ends
decode assists no
flush by ASID no
VMCB clean bits no
virtual VMSAVE no
virtual GIF yes
AVIC no
pause filter no
SVM lock no
x2APIC yes -- the host's; a guest's is MSR exits either way
1 GiB pages yes -- guest memory in one nested entry a gigabyte
hv: AMD-V (SVM) on for cpu none of 0-3
$ hv on
hv: turned on for cpu 0-3
hv: AMD-V (SVM) on for cpu 0-3 of 0-3
$ hv run hypercall 2
hv: guest hypercall -- memory above 4 GiB through its own page table, then every register across a hypercall
ran on cpu 2, 634 us
said "nos: long mode"
exits 0 port in, 14 port out, 0 cpuid, 1 hypercall, 0 host interrupt
stopped hlt at 0x812b
checked guest physical 4 GiB holds what it wrote; 15 registers went out at the hypercall and 15 answers came back
hv: guest hypercall ok
$ rmmod hv
module: hv unloaded
This is stage 3 of the roadmap, and all four of
its steps are here: the machine's virtualization extension, found, reported,
and turned on and off for the CPUs that will run guests; a VM -- guest memory
behind a nested page table, one CPU under AMD-V, its exits decoded -- that
runs guests of a few bytes in long mode and checks that each did what it was
told; and, over an emulated 8250, PIT, RTC and 8259 PIC, a real Linux
bzImage booted to an interactive BusyBox shell (hv boot, and A Linux
guest below).
Why a module
Nothing about a hypervisor has to be in the kernel image, and three things argue for keeping it out.
A machine that runs no guests should carry none of it. Not the code, and
above all not the CPU state: EFER.SVME and MSR_VM_HSAVE_PA on AMD,
CR4.VMXE and VMX root operation on Intel are the CPU's, not a task's, and
a kernel that never runs a guest has no business touching them. Loaded, the
module turns them on; unloaded, it turns them off, and the machine is in the
state it booted in.
It is the subsystem least pleasant to debug by rebooting. insmod, try
it, rmmod, change one field, insmod again is a loop of seconds;
VMCB and VMCS setup is where most of the time in this stage goes
(plans/03-hypervisor.md says so), and a rebuild-and-boot loop would spend
that time twice over.
Unloading is a gate of its own. A hypervisor that can be taken out has
to prove, every time, that it turned the extension off before its code was
freed -- and the check for that is the same check live update (stage 5) will
need, years earlier than it would otherwise be written. scripts/hv-test.py
loads, turns it on for every CPU, unloads without turning it off, and
loads again to ask every CPU what it has. If the first unload left anything
on, the second load says so.
What it costs is stated in Loadable modules: a module cannot
register a block device or a NIC, because those register with their layer as
Rust trait objects from inside the image and there is no C name for a module
to bind. That is a limit on how a guest's virtual devices will be served,
not on the hypervisor: a guest's disks and NICs are emulated by this module
over the kernel's own (kcore::block::Disk, kcore::net::Nic), which is
what a hypervisor wants anyway.
The crates
hvarch/ the CPU's extension: the instructions, the control MSRs,
the structures the hardware reads -- the VMCB, laid out
in x86/svm/vmcb.rs -- and entering a guest. All the
unsafe.
hv/ everything else: the machine (machine.rs), guest memory
and its nested page table (memory.rs, npt.rs), the
VMCB as this hypervisor fills it in and the exits it
comes back with (svm.rs), the VM (vm.rs), and the
built-in guests (guests.rs).
modules/hv/ the module: the shell command, the task a guest's CPU
runs on, and the lifetime of it all.
hv and hvarch are workspace members but not default members: a plain
cargo build, and so the kernel's own build, does not compile them. They
exist only inside hv.ko. A kernel built without the module has no
hypervisor in it at all -- not a symbol.
The split is the point, and it is measurable:
$ scripts/unsafe-count.py hv hvarch
hv 1917 lines 3 blocks 0 unsafe fn 0 unsafe impl = 3
hvarch 1991 lines 40 blocks 13 unsafe fn 1 unsafe impl = 54
The long-term goal of this kernel is other people's Linux guests on this
machine, and the bug class that goal cannot survive is a guest reaching host
memory. So the line is drawn once, in the crate graph, where a script can
count it rather than a reviewer having to remember it. The three sites in
hv are the two calls into hvarch from the IPI handlers that turn the
extension on and off, and the one call that enters a guest (Vm::enter),
whose two preconditions are invariants of types in hv rather than
promises at the call: guest memory owns every page its nested table maps,
and the machine never names a host save area that has been freed. Guest
memory, the nested page table, the VMCB's policy, the exit decoder and the
guests above them -- 1450 lines -- have none.
AMD-V first, Intel VT-x second
The order is decided by where the code is iterated on, not by the hardware.
QEMU's TCG emulates SVM, nested paging included, and emulates no VMX at
all. On the development machine -- a Mac, where the x86 kernel runs under
TCG -- AMD-V is the only extension under which a guest can be brought up at
all. It is also the simpler of the two: the VMCB is a plain structure in
memory, where VMX needs vmread/vmwrite for every field, and a VM-entry
failure there reports itself through one error number that means a dozen
things.
What QEMU gives, and what it does not, is worth knowing before debugging against it:
$ qemu-system-x86_64 -cpu max ...
svm yes npt yes vgif yes x2apic yes (10.2; 8.2 has none)
nrip-save no decodeassists no flushbyasid no v-vmsave-vmload no vmx no
Before QEMU 9.2, TCG does not put a guest with paging off through the
nested page table. get_physical_address (target/i386/tcg/.../excp_helper.c)
translates through stage 2 only from inside a guest page-table walk; with
CR0.PG clear it takes the guest physical address for a host physical one,
and the guest runs out of the host's memory. 9.2 fixed it
(env->cr[0] & CR0_PG_MASK || use_stage2). It was found here: the first
real-mode guests all stopped at the same #UD with the same registers
whatever their code was, and the fault guest's write past its memory did
not fault. Real hardware has no such hole, but the development host's QEMU
is 8.2 and the build container's 6.2, so every built-in guest starts in long
mode with paging on -- which is how a 64-bit Linux kernel is started anyway.
Two of those absences matter. Without nrip-save the hypervisor has to
work out for itself where an intercepted instruction ended -- which is a
short table of lengths (cpuid is 2 bytes, vmmcall 3, rdmsr/wrmsr 2),
not an instruction decoder, because every instruction that is intercepted on
purpose is one whose length is known; and an IOIO intercept hands over the
next instruction's address in the VMCB regardless. Without decodeassists
the same holds for the rest. Both are present on real AMD parts.
Use -cpu max. The default qemu64 model reports SVM without nested
paging, and a hypervisor that will not walk the guest's page tables itself
-- this one, deliberately -- has no use for that.
Intel VT-x is written for the hardware this kernel actually runs on: the
Hetzner EX44 and the Dell laptop are both Intel, where it is native and
fast. It is the whole of a backend now -- the VMCS, EPT, the launch stub and
the exit decoder -- and every built-in guest runs under it, brought up and
debugged under nested KVM on the Intel dev box (below). TCG has no VMX at
all, so the gate uses KVM there; on an AMD host -cpu host gives AMD-V, and
under TCG anywhere -cpu max gives AMD-V, so the two backends are covered
between the three.
One bit stands between this kernel and vmxon on those machines, and it
is not in the hypervisor. VMX operation requires CR0.NE (bit 5; it is in
IA32_VMX_CR0_FIXED0 on every Intel part), and nos never sets it. The APs
come out of INIT with CR0 = 0x60000010, NE clear, and the boot path adds
PE and PG (boot64.asm) and WP (EnableWxSupport) and nothing else; the BSP
has whatever GRUB and the firmware left, which Multiboot2 calls undefined.
vmxon does not fail on that -- it faults, and this kernel panics on a #GP
-- so vmx::enable checks the fixed bits on each CPU before it, the error
is HostState, hv on names the CPU that refused, and hv info names the
bit:
host CR0/CR4 no -- on this CPU vmxon would fault: CR0 needs NE
The check is per CPU and not part of the machine-wide verdict, because the
registers are per CPU: a verdict that changed with the CPU insmod happened
to run on would be no verdict. The fix is one bit on every CPU, beside where
EnableWxSupport sets WP -- what every other x86-64 kernel does. NE chooses
native #MF over the PC's FERR#/IRQ 13 for x87 errors, and this kernel has
no x87 code to raise one: the C++ is built with -mno-80387, so there is no
x87 instruction in the image and none can appear, and the Rust targets are
soft-float. It is
still a change to how every x86 machine boots, the two whose only console is
the network among them, so it came with the VMX backend, where a guest shows
it working -- boot64.asm's enable_paging sets NE beside where it clears
CD/NW after INIT, on the BSP and every AP, and hv info reads host CR0/CR4 yes on each.
The VMX backend, and where it differs from AMD-V
The two backends are one shape above hvarch: hv::vm::Backend is Svm or
Vmx, and the run loop and the built-in guests call it without an svm or a
vmx in them. The guest state both keep is the AMD save-area layout
(vmcb::Save plus the general registers) -- for VMX a shadow, since the
real state lives in the VMCS and is reached only through vmread/vmwrite.
Guest::run in hvarch::x86::vmx syncs the shadow into the VMCS before an
entry and reads it back after, so the policy above -- CPUID, MSRs, the
loader -- is written once.
That sync is lazy, and has to be: a VMCS holds sixty-odd fields, and eight
segments of four fields each are most of them, so a full round-trip every
exit is sixty vmread/vmwrite -- and under a nested hypervisor, where each
one traps to the L0 kernel, that is most of the cost of running the guest.
But the guest owns nearly all of it: CR0/3/4, the segments, GDTR/IDTR, RSP,
RFLAGS it changes in the VMCS itself, with no exit (no CR or segment
interception), so the shadow need not carry them at all. The first entry
writes the whole state; after that only what the policy changes between
entries goes in -- RIP past an instruction, the system MSRs, the injected
event -- and only what it reads comes back: where the guest stopped, its
flags, and FS/GS base, which it does change through an intercepted wrmsr
(the per-CPU base, as this kernel keeps its own) as well as un-intercepted.
The control registers and segments are read only when a guest is being
stopped and dumped. A full sync every exit was ten times the work, and
turned a real distribution's boot from seconds into minutes. An EPT
violation, which is mostly an MMIO access performed for the guest
(MMIO), reads what performing it takes and no more: CR0, CR3 and
CR4 for the walk to its instruction, CS's rights for the mode (and CS's
base outside 64-bit code), RSP, the CPL. One that ends the guest after all
has the rest read for its report while the VMCS is still current on that
CPU (Guest::read_whole_state), and a report that could not read it says
so, rather than show the shadow's older values as the guest's.
And within the light set it is dirty-tracked: Guest keeps what the VMCS
last had of each field it may write between entries (Synced) and writes
only what the policy changed -- RIP after a skip, the interruptibility a
skip clears, a system MSR after a wrmsr, the interrupt-window control when
it toggles, the injected event when there is one (the CPU clears the field's
valid bit as it takes the event, so with none there is nothing to write).
That is exact for every field the guest cannot change without an exit. The
FS and GS bases it can -- wrfsbase, swapgs -- so a copy older than the
last exit must never be written back (Alpine wedged on exactly that once):
they are read on the exits where the policy may touch them, rdmsr and
wrmsr, and what it leaves is compared with that read alone. On the way out
the exit reason is read first and the rest by reason: the host's interrupt
needs nothing more, an instruction its length, port I/O its qualification
too, and only an exit that may have happened during an event's delivery --
an exception, an EPT violation -- or one nobody foresaw reads the
interruption and vectoring words and the guest-physical address. Twelve
vmwrites and fourteen vmreads an exit became one or two and four.
The differences that are not hidden by the shadow at all:
- The VMCS is opaque, and per CPU. It is made as plain memory (a
privileged instruction at construction would fault, since a VM is made
where VMX may be off and on a CPU that will not run the guest); the first
entry
vmclears it once, where VMX is known on, into the clear launch statevmlaunchneeds. From there the VMCS is left current after an exit, notvmcleared, and the next entry on the same CPU is avmresume, which does not reload it -- 8.6% fewer nanoseconds an exit under nested KVM (18.6 -> 17.0 µs,hv bench exits=50000on the Intel dev box), where avmclearflushes the whole shadow VMCS, and proportionally more on real silicon, where it is a memory flush the earlier per-exitvmclearpaid every time. The one invariant a resume needs is that a VMCS is never current on two CPUs at once, kept two ways: before a guest's task runs on another CPU its VMCS isvmcleared off the old one, and when a guest is dropped its VMCS isvmcleared off its CPU before the page is freed -- each avmclearrun on that CPU by an IPI sent from task context, never with interrupts off, that waits for the CPU to answer. Without the drop one a freed VMCS page would take avmptrld's write of cached state on that CPU's next entry; the migration one is a safety net, since a guest's task is pinned to one CPU and does not in fact move. Nested KVM willvmlaunchan uncleared VMCS, so the first-entryvmclearand this whole ordering are for the hardware, not the gate. (The AMD VMCB is plain memory and had none of this to arrange.) - Host state is the hypervisor's to save. AMD-V's
vmsave/vmloadmove the host's segments and MSRs aroundvmrun; VMX restores the host from the VMCS host area, whichHostRegs::capturefills on the CPU the entry runs on -- its CR3, its GS base (where its per-CPU data is), its TR, its GDTR and IDTR, and the host's five syscall MSRs into the exit MSR-load list (fiverdmsrs, and under nested KVM four exits to it, that were paid on every entry before). Rewritten when the guest's task has moved CPU, not every entry -- and only then is the VMCSvmptrlded: each CPU remembers which VMCS is current on it (CURRENT_VMCS, kept exact by the VMPTRLD, VMCLEAR and VMXOFF that are the only things to change it), and an entry whose VMCS is current already skips the instruction. The CR4 the exit loads back from the host area has to be the one an entry runs under, with OSFXSR: captured without it -- as it once was, the bit set around each entry and captured before -- the FXSAVE after the exit could leave the XMM registers out and the XSETBV after it was an undefined opcode. Now the bit is the CPU's for as long as VMX is on for it (below), and the capture is right by construction. - The host's NMI is delivered by hand. Under AMD-V an intercepted NMI
stays pending until
stgiand the host takes it then; under VT-x the exit is its delivery -- the NMI is consumed, and NMIs stay blocked until an IRET. So an NMI exit ends withint 2into the host's own handler, whose IRET unblocks the next, the way#MCis raised withint 0x12on both sides and the way KVM does it. What would otherwise be lost is the panic path collecting this CPU's stack from another (Cpu N did not answer the NMI, exactly when the backtrace was wanted) and the profiler's counter overflow, after which its LVT stays masked and the profiler is dead on that CPU. - CR2 is nobody's on Intel.
vmrunkeeps a guest CR2 in the VMCB; VMX keeps none, sorunsaves the host's and restores the guest's around the world switch by hand. The x87/SSE and XCR0 switch is the same as AMD-V -- neither extension switches it, and the guest is given x87 alone. - CR8 is the host's on Intel. In 64-bit mode CR8 is the local APIC's
task priority register, and under VT-x a guest's
mov cr8reaches the real one unless told not to: 15 there keeps every interrupt but an NMI off the host's CPU -- the tick, the kick, the IPI a TLB shootdown waits for, which would panic the machine ten seconds later -- for as long as the guest likes, and after it has gone, since the exit restores no TPR. AMD-V gives the guestV_TPRunderV_INTR_MASKINGand never exits. So the VMX controls stop the guest at every CR8 access (Exit::Cr8), and the policy answers from a shadow TPR of the guest's own, a #GP for a value CR8 cannot hold. Thetprbuilt-in guest checks both backends. - CR0's PE and PG are the host's to watch. Every entry checks that the
"IA-32e mode guest" control agrees with EFER.LMA and that CR0 is one VMX
allows, so a guest that turned paging off or on behind the host's back
would fail its next entry. So PE and PG are in CR0's guest/host mask,
with NE and the bits VMX fixes: a
movto CR0 or anlmswthat would change one of them exits (Exit::Cr0Write), and the policy does what the CPU would -- a #GP where the architecture has one, EFER.LMA set or cleared as paging goes on or off with LME set, the entry control made to follow at the next entry, and the translations cached under the guest's VPID dropped. Every CPU a start-up IPI starts climbs from real mode this way, which also takes unrestricted guest: a CPU without it runs guests of one CPU only (More than one CPU). - An NMI waits for the guest's IRET. One of a guest's CPUs can send another an NMI, and a second must wait until the handler of the first has returned. Under VT-x that is the "virtual NMIs" pin control: the guest's NMI blocking is its own, set as one is injected and ended by its IRET, and the NMI-window exit comes the moment a held one can go in. AMD-V has no such control; it intercepts the IRET instead, whose exit comes before the IRET has run, so the blocking ends once the guest's RIP has moved past it -- what KVM does.
- The syscall MSRs are switched through load lists.
STAR,LSTAR,CSTAR,FMASKandKERNEL_GS_BASEare not VMCS fields, and a guest run with the host's would be catastrophic -- a userspaceSYSCALLjumps to the host'sLSTAR, and it was exactly this that a real Linux oopsed on atRIP: 0x0the moment it ran/init, the kernel itself having booted whole in ring 0 without them. AMD-V moves these withvmsave/vmload; VMX has no such instruction, soGuestkeeps a VM-entry MSR-load list (the guest's values, from the shadow) and a VM-exit MSR-load list (the host's, off the CPU), and the CPU loads each in turn.KERNEL_GS_BASEneeds one more list:swapgschanges it without awrmsrthe host hears, so a VM-exit MSR-store list saves the guest's live value back into the shadow -- otherwise the guest'sswapgs-established kernel GS base is lost each round, and the nextswapgsreturns junk. That was a real distribution kernel double-faulting on a garbage RSP the moment an interrupt returned to user mode; a purpose-built guest that never took one from user mode did not show it. With all three lists, an unmodified Linux -- a tinyconfig to a BusyBox shell, and a full Alpine 3.24 to a root login with its clock,apk, disk and network -- runs under VT-x. - Controls, and one bitmap. Every port exits by the processor-based
controls (unconditional I/O exiting), not by the 12 KiB
iopma VMCB points at; every MSR exits by a 4 KiB bitmap with every bit set but three -- the FS, GS and KERNEL_GS bases, the guest's own and nobody else's, which VT-x saves and loads itself (two VMCS fields, and the exit MSR-store and entry MSR-load lists) and which a Linux guest writes at every context switch: two exits a switch, the most frequent a busy guest made, gone -- on both sides, since the AMDmsrpmlets the same three through, the stub'svmsaveandvmloadcarrying them (hvarch::x86::PASSTHROUGH_MSRSis the one list). The rest of AMD-V's intercept set is controls too, since VT-x stops a guest at none of them unasked: CR8 (above), MONITOR, MWAIT and RDPMC, which CPUID says the guest has not got and which run on the host's CPU otherwise; WBINVD, which otherwise writes back and drops the package's whole shared cache -- every core stalled for milliseconds -- as often as the guest cares to; and PAUSE, so that a guest spinning comes out to be handed what is due to it (Time); CPUID, INVD, VMCALL, XSETBV and a triple fault exit unconditionally, and RDTSCP, INVPCID and XSAVES are#UDin the guest because the secondary controls that would enable them are off. Each control field is written throughadjust, which forces on the bitsIA32_VMX_*_CTLSsays must be 1 and off the ones it forbids -- a value that ignored them fails entry with nothing named. Guest CR4.VMXE is forced set (the fixed MSRs demand it) and masked to read 0, since the guest is told it has no VMX. - A refused entry is the CPU's
VMEXIT_INVALID, not a software check. AMD-V's VMCB is checked in software first (Vcpu::check) so a bad one is named before the CPU sees it; VMX has no such check -- a bad guest state is the CPU's to refuse, atvmlaunch, reported as an entry failure and shown asExit::Invalidwith the instruction error. An event an exit interrupted the delivery of is given back rebuilt from its vector, type and error code, not copied: bit 12 of what the exit wrote is undefined, and the entry field wants bits 30:12 clear. - VPIDs, and INVEPT and INVVPID once per CPU a guest runs on. A VPID is
VT-x's ASID: the tag the TLB keeps a guest's linear and combined mappings
under, so that a VM entry or exit need drop neither the guest's nor the
host's, which are tagged 0. Without one -- as the backend first ran --
every transition dropped both, the host's included: every exit cost the
host its translations, as every AMD-V entry did before ASIDs (-22% to -34%
of an exit on the AX41). Here a VPID is the guest's for its life, the
lowest free one from a lock-free bitmap of 65535 (
vpid_take), given back when the guest is dropped; AMD's ASIDs are per CPU and per generation, and the third VM of theasidbuilt-in guest, under VT-x, is checked to have been given a VPID one of the first two had. What must then never happen is a number's translations from an earlier holder served to the next: a guest's first entry on each CPU runs a single-context INVVPID for its number, andenablean all-context one aftervmxon, for what an earlier load's guests left. EPT has the same hazard one level down: guest-physical mappings, the EPT's own translations, which the CPU tags with the EPT's address and keeps across transitions and across VMXOFF and VMXON. A guest destroyed frees its EPT; the next guest's EPT is likely made in the same page (the allocator hands back what it was last given), so its tag is the old one's, and the CPU would serve the old guest's translations -- to pages that are the host's again -- for the new guest's addresses, which are the same low addresses every kernel touches first. Nested KVM keeps its own shadow EPT and shows none of this. Soenablefollowsvmxonwith an all-context INVEPT, dropping what an earlier load's guests left, and a guest's first entry on each CPU (the moment its host state is captured there) is preceded by a single-context INVEPT for its EPT -- once per CPU per guest, which costs nothing, and after which the only translations under that tag are this guest's, an EPT that only grows never making a stale one.usablerequires the instruction. This is the counterpart of the AMD side's ASID generations, which begin run out at every load for the same reason.
The hypercall built-in guest is the one whose machine code is a vendor's:
vmmcall (0F 01 D9) is AMD's and an invalid opcode on Intel, so the loader
patches it to vmcall (0F 01 C1) where the guest runs under VT-x. The
refused and asid guests test AMD-only mechanisms, and each has a VMX form
that tests the Intel equivalent -- the CPU refusing a non-canonical guest
RIP, and three VMs isolated by their EPTs; tpr runs unchanged, and is
stopped twice under VT-x and not at all under AMD-V.
What AMD-V's entry keeps whatever the VMCB says,
hvarch::x86::vmx::Guest keeps whatever the policy asked -- its own way,
since VT-x's controls are not AMD-V's intercepts:
- CR4.MCE is the host's. The CR4 guest/host mask holds MCE and VMXE
whatever
hvasks, and every bithvdoes not let the guest own (Guest::new'scr4_own: VME, PVI, TSD, DE, PSE, PAE, PGE, PCE, OSFXSR and OSXMMEXCPT, where its CPUID offers them): the VMCS's CR4 keeps MCE set and the guest reads the read shadow's. Amovto CR4 that changes a bit of the mask exits, naming its register and length, andcrate::crperforms it from there -- a bit its CPUID does not offer the #GP a CPU without the feature gives, so PKE, and with it PKRU, stays the guest's to have only where it is offered -- and writes the register and the shadow at the next entry. A read never exits, the shadow answering it, and a write of the guest's own bits never does either: Linux's global TLB flush turns PGE over twice, with no exit, where AMD-V stops everymovof CR4. PAE cleared in long mode is then the CPU's own #GP. - The debug registers are the guest's. Every
movof one exits ("MOV-DR exiting"), named by the exit -- register, direction, general-purpose register -- andcrate::dranswers it: DR0-3 from four of the guest's own, made the CPU's before each entry through the same per-CPU record AMD-V uses (svm::LoadedDebugAddrs); DR6 from the guest's own, which is no VMCS field and is never put in the register -- every read of it exits -- and DR7 the VMCS's, which "load debug controls" puts in the register at the entry and the exit takes back out, leaving the host's with all four off. DR4 and DR5 are DR6 and DR7 again, or a #UD under CR4.DE; amovabove CPL 0 the #GP, under general detect the #DB, and a DR6 or DR7 written with a bit above 31 the #GP, which the exit comes before the CPU's check of. - A #DB of the guest's own goes back to it. VT-x writes no DR6 at a #DB
that exits -- the exit qualification has what it would have written -- so
the exit puts it into the guest's DR6 as the CPU would have (B0-B3 cleared
first, RTM's bit the other way up; KVM's
kvm_deliver_exception_payload), and the run loop gives the #DB back with GD cleared from DR7, as a CPU entering the handler clears it; a single step taken out of a STI or MOV SS shadow with TF set is marked pending, which the CPU does at every exit but this one and the entry checks. ICEBP's #DB exits at the instruction, as a privileged software exception: stepped past, and its #DB given as the trap it is, as AMD-V's intercept of it is. - The controls the host needs are required.
adjustdrops a control the CPU -- or a hypervisor under this one -- does not allow; one the host cannot do without dropped refuses the guest, naming the field and the bits (Refusal::Controls): the exits for its interrupts and NMIs, its ports, CR8, MONITOR, MWAIT, RDPMC, everymovof a debug register and WBINVD, nested paging, the 64-bit host, the switch of EFER, the PAT and DR7 -- and what the guest was promised, unrestricted guest and its VPID. A guest run without one of them would be the host's to lose. - The world switch keeps a guest's speculation off the host
(
hvarch::x86::spec), what CPUID and IA32_ARCH_CAPABILITIES say decided for each CPU as the extension is turned on there, with KVM's as the reference: after every exit the guest's values cleared out of every register the stub returns with and the return stack refilled before its firstret, as AMD-V's stub does; an IBPB between two guests on a CPU; IBRS in IA32_SPEC_CTRL while the extension is on -- enhanced, set once, on the EX44, and legacy, a barrier only when written, written again after every exit on a CPU without it (Skylake) -- with BHI_DIS_S beside it where the CPU has the control; VERW, the CPU's buffers cleared, the last thing before every entry where MDS, TAA or RFDS apply (the EX44's E-cores have RFDS); and the L1 data cache flushed before every entry where L1TF lets a guest read it (Skylake).hvsays which of them the CPU it runs on has, and reads every CPU's IA32_SPEC_CTRL back, warning where it no longer has whathv onset there. What each costs an exit on the EX44 is in What an exit costs.
What the VMX backend does not do yet, and says so rather than pretends:
- Branch history cleared by hand. A CPU with enhanced IBRS that is open
to branch history injection and has no BHI_DIS_S -- Ice Lake, Tiger Lake,
or an Alder Lake and later under a hypervisor that does not pass the
control through, as QEMU 8.2's
-cpu hostdoes not -- wants the software sequence that clears the history at every exit;hvnames such a CPU instead. None of the three machines here is one.
What it does today
hv what the machine has, and which CPUs it is on for
hv info the whole of what the CPU said, a line a feature
hv on [cpu|all] turn the extension on
hv off [cpu|all] turn it off
hv run <guest|all> [cpu] run a built-in guest, or all of them, on a
task of its own -- bound to cpu when one is named
hv boot <bzImage> [mem=MiB] [cpus=N] [secs=N] [cpu=N] [xapic] [ioapic] [apicv|noapicv] [rxmod=US] [nooffload] [initrd=path] [disk=path[:ro]]... [input=...] [cmdline=...]
load a Linux bzImage and run it on a vCPU --
on cpus of them, each on a host CPU of its
own -- for secs, then print its console and
how it ended
hv start <bzImage> [mem=MiB] [cpus=N] [cpu=N] [xapic] [ioapic] [apicv|noapicv] [rxmod=US] [nooffload] [initrd=path] [disk=path[:ro]]... [input=...] [log] [restart] [net] [cmdline=...]
the same, left running until it is stopped;
restart boots it again when it resets itself
hv list the started guests: running or how they ended,
their CPU, uptime, restarts and exits
hv console <id> [bytes=N] the end of one's console
hv attach <id> its console, live and typed at, over ssh -t;
^] detaches
hv send <id> <text> type at it, \n for a newline
hv exec <id> [secs=N] <line>
type a line, print what comes back up to the
prompt after it
hv wait <id> [secs=N] [boot=N] <text>
until this boot's console shows text -- with
boot=N, once it has restarted N times -- or it
stops
hv restart <id> boot it again from its files, running or stopped
hv stop <id|all> [secs=N] press its power button and give it secs (30)
to turn itself off, then stop it; say how it
ended, take it off the list
hv forward [add <port> <vm> <guest-port> | del <port>]
a port of nos's relayed to a guest's
hv help all of these, a line each
Turning it on allocates one page per CPU -- AMD's host state save area,
Intel's VMXON region -- and then sends that CPU an IPI that does nothing but
write MSRs. The page is allocated before the IPI and never inside it: a page
allocation shoots down every other CPU's TLB and waits for every CPU to
answer, and a CPU inside an interrupt handler has interrupts off and cannot
(the spinlock rule in CLAUDE.md).
The status line asks the CPUs, not the module. hv sends each CPU an IPI
that reads EFER or CR4 and reports what came back, and says so loudly if
that disagrees with what the module thinks it turned on. Nothing else in
this kernel touches those bits, so a disagreement can only be a bug here --
and it is exactly the kind that is invisible to code that reads only its own
bookkeeping.
Turning it off checks first, on the CPU itself. vmxoff on a CPU that is
not in root operation is an undefined-opcode fault, and this kernel's
handler panics; so the IPI handler reads the register and acts on what it
finds. That also makes hv off the way back from a CPU some earlier load
left the extension on for -- the page it was using is gone, but turning it
off needs no page. And on Intel it checks one thing more: that no guest's
VMCS is current on the CPU. A VMCS is left current after an exit (the next
entry resumes it), and VMXOFF under one leaves the CPU's cached copy of it
nowhere good -- the manual wants every active VMCS vmcleared first -- so
hvarch counts the VMCSs current on each CPU, up where one is made current
and down where it is cleared off, and vmxoff is refused while the count is
not zero: the CPU stays on, keeps its page, and hv off names it (a guest's VMCS is still current on cpu 3 -- left on; stop the guest first). The CPU
clears its own slot in the host-area table as it goes off, in the same IPI,
so there is no moment at which an entry is turned away from a CPU that then
stays on. rmmod never meets this: it drops every guest before it turns
anything off, and a dropped guest's VMCS is current nowhere.
A guest
A VM is guest memory behind a nested page table, the permission maps it runs
under, and one CPU (hv::vm::Vm) -- or, for a Linux guest, as many as it is
given (More than one CPU). A guest's CPU is a task:
hv run spawns one, bound to a CPU when asked, and waits for it, and the
task enters the guest over and over from task context, handling each exit
between entries.
Its memory
hv::GuestMemory is regions of guest physical addresses, each backed by
host pages that are mapped nowhere (kcore::frame::Frame): pages of RAM the
kernel hands out by their address and puts in no table of its own, so that
a guest of any size costs pages and no kernel address space, and the
guest's nested table is the only mapping they have. They are zeroed before
the guest can see them, so that what a guest finds in its memory is what it
was given and never what the host left there.
Two rules make it the one place that decides what a guest can reach:
- Nothing hands out a reference into it. The guest writes its memory
whenever it runs, on whatever CPU, and a Rust reference to memory that
changes under the compiler is undefined behaviour however it is used
(
plans/01-rust-strategy.mdcalls this the most important decision of the hypervisor). Every access is a copy --read,write,read_obj::<T: Pod>,write_obj-- bounds-checked against the region it falls in, and made by the kernel through its temporary window onto each page it touches -- the CPU's own slot of it, mapped for that page's part of the copy and nowhere after -- a page's part one call of the architecture's memcpy, which the compiler sees nothing of; an address a guest gave, whatever it is, is at worstUnmapped. A queue's rings are the one exception to the window, and not to the rule: a device reads and writes them at every request, and a copy maps and unmaps a page -- a TLB flush -- at every access, a dozen a frame. So they are mapped while the queue is up (GuestMemory::map, three pages a queue, throughMm::MapPages) and still reached a value at a time -- one volatile load or store at a checked, aligned offset, an index with acquire or release (kcore::frame::Mapped). The mapping holds a share of its region's frames (kcore::frame::Run), so none is freed while it is mapped, whichever of the guest and its devices goes last. Undoing one shoots its address down on every host CPU, so a device makes eight and then one a second (virtio::Maps): a guest that set its queues up over and over would otherwise have every CPU of the host interrupted at will, and past the budget its rings are copied, as before, and the report says so. On the EX44, between two guests of two CPUs:dd | nc2.72 s where it took 4.15, and 2.83 where it took 4.45 with the offloads, five runs of each; BusyBox'shttpdserving 100 MB five times over 0.29 s where it took 0.47 -- 1.8 GB/s -- and 0.62 where it took 1.63-1.68 without the offloads. A dozen pages mapped and flushed a frame had been most of what the host spent on one. - The nested page table is inside it. A guest reaches exactly what its
nested table maps, and this table maps nothing but pages the same value
owns: every page is owned before it is mapped, and freed only with the
table. "The guest can reach host memory it was not given" is then not a
mistake a caller can make, and the one
unsafecall that enters a guest has that as a type's invariant rather than a comment's promise.
The nested page table
AMD's nested table has the host's own long-mode format, four levels of 512
entries (hv/src/npt.rs), with one difference that bites: a nested walk is
a user-mode access at every level, so every entry has U/S set or the guest
faults on memory it was given. It is built from the host's side only and
walked by software only through its own indices -- an arena of tables, each
knowing the index of the table under each entry -- so an entry the CPU reads
is written here and never read back to find the next level. Accessed and
dirty are set from the start, so the CPU has nothing to write into it. The
tables a region needs are all made before any of its pages is mapped, so
mapping cannot fail halfway for want of memory, and a page already mapped is
refused rather than moved.
What entering a guest cannot be told to do
The VMCB is laid out in hvarch/src/x86/svm/vmcb.rs field by field from
appendix B of the AMD manual, and every field anything touches has its
offset checked against the manual at compile time.
What goes in it is policy, and lives in hv::svm: which instructions stop
the guest, what state it starts in. None of that can hand the guest the
host, because hvarch::x86::svm::Guest::run sets the part the host depends
on on every entry, whatever the VMCB says:
- intercepts of INTR, NMI, SMI and INIT (the host's interrupts end the
guest's turn and are taken by the host), SHUTDOWN (a guest's triple fault
would otherwise shut the CPU down), IOIO and MSR (without them every port
and MSR is the guest's, whatever the maps say), INVD (discards the host's
dirty cache lines), INVLPGA, VMRUN, VMLOAD and VMSAVE (which take host
physical addresses), STGI and CLGI (the physical GIF), SKINIT, XSETBV
(XCR0 is the CPU's and
vmrundoes not switch it); - intercepts of #DB and #AC, because a guest can make delivering either raise it again -- a data breakpoint on the stack its own #DB frame goes to -- and the CPU then loops inside the delivery, where no instruction ever ends and no interrupt, NMI included, is ever taken: the host has lost the CPU (CVE-2015-8104 and CVE-2015-5307, which KVM closes the same way). Giving them back to a guest that expects them is the policy's to do;
- the intercept of #MC -- and since an intercepted machine check is not
delivered to the host by the CPU,
runraises vector 18 itself before interrupts come back on, into the kernel's own handler, which panics; - CR4.MCE set in the CR4 the guest runs with, and the intercept of every
write of CR4, so that the guest cannot clear it in the register either:
with MCE clear a machine check is no exception but a shutdown -- the
guest's triple fault, as the exit has it -- and the host never hears of
it. What the guest believes CR4 holds is the policy's to keep:
hvintercepts its reads of CR4 as well, and answers them, and performs its writes, from the CR4 the guest wrote (hv/src/cr.rs,crate::cr) -- the instruction fetched by the guest's paging and decoded, as an MMIO access's is, since a CPU without decode assists (QEMU's, a nested hypervisor's) does not name the register. A write of a bit the guest's CPUID does not offer is the #GP a CPU without the feature gives, so PKE, LA57, OSXSAVE and CET stay off; one a CPU would not take where the guest is -- PAE cleared in long mode, PCIDE with a PCID in CR3 -- is a #GP too; and a write that changes a bit a CPU translates by flushes the guest's TLB, as the CPU would. QEMU's TCG never stops a guest at a read of CR4, whatever the VMCB asks: its decoder checks a read of any control register against CR0's read intercept, adding the register's number to the exit code only when the code is not 0 -- and CR0's read is (decode_op,target/i386/tcg/decode-new.c.inc, QEMU 10.2). There the guest reads the register, MCE in it, and its writes are the host's all the same. Intercepting CR0's reads too would catch them there, and on a real CPU catch SMSW, which a guest's user process may run; - what of the guest's speculation the world switch can keep from the host
and from the next guest (
hvarch::x86::spec): after the exit, the guest's values cleared out of every register the run stub returns with, and the return stack refilled before the host's firstret-- a guest's return addresses left there would have the host's returns predicted to them; before the entry, an IBPB where the guest is another than the last on the CPU, told apart by its nested table's id, where the CPU has IBPB; and automatic IBRS turned on with the extension, where the CPU has it; - the intercept of every read and write of DR0-DR3, and the CPU's four made
the guest's own before each entry. The breakpoint addresses are the
CPU's, not the VMCB's --
vmrunswitches DR6 and DR7, not them -- so without both a guest would read whatever the last guest on its CPU left there, and a breakpoint it set would be moved by the next guest's write, or left behind when its vCPU moved to another CPU. EachGuestkeeps its own four, from which the policy answers the guest'smovs and into which it performs them (hv/src/dr.rs,crate::dr, the instruction decoded as CR4's is); its own DR7, whichvmrunloads, arms them. A record per CPU of what its four hold (LoadedDebugAddrs) has an entry write only the ones that differ -- nothing else in the kernel writes them, the guest cannot, and neithervmrunnor#vmexitdoes -- so a guest that never sets a breakpoint costs no write at all, where four writes of a debug register, each serializing, would cost every entry. Amovabove CPL 0 is the #GP a CPU gives, and one under DR7's general detect the #DB -- DR6.BD set, GD cleared, the move not made -- where a CPU checks either after the intercept rather than before. QEMU's TCG, whose slip with CR0's read code is above, adds the register's number to a debug register's code, which is never 0: there the intercepts are each register's own; - physical interrupts masked by the host's flag, not the guest's
(
V_INTR_MASKING), so a guest sitting with interrupts off cannot keep the host's out; - nested paging on, over the table the caller's memory owns; SEV, AVIC and virtual VMSAVE off, since each has the CPU work at physical addresses the VMCB gives it;
- I/O and MSR permission maps with every bit set but the three of the
guest's own base MSRs (
hvarch::x86::PASSTHROUGH_MSRS, which the stub'svmsave/vmloadcarry), and no way to clear another: a port the guest reaches directly is one of the host's devices; - nothing assumed clean;
- the ASID, and what the TLB is told on the way in, given by the CPU the entry is on (Address space identifiers): a translation left over from another guest -- or from a guest that has gone, to a page since handed back to the host -- would be the host's memory in a guest's hands, so which ASID a guest runs under is not the policy's to say.
Entering and leaving
Guest::run turns interrupts off, checks on the CPU it is on that SVM is
on and that VM_HSAVE_PA is the page this hypervisor gave that CPU -- not
one an earlier load left behind -- and only then enters, so that what was
checked is still so: turning the extension off is an IPI, which waits. The
machine keeps each CPU's page address in an atomic beside its table for
exactly this check, set once the CPU has taken the page and cleared before
the CPU is told to let it go -- so before the page is freed, and before the
extension is off there.
Address space identifiers
The TLB tags every translation a guest makes with the ASID it ran under and
keeps it until something flushes it. Every entry flushed the whole TLB at
first, because every guest ran under ASID 1: always right, and every exit
cost the guest its translations and the host its own. Now each CPU hands its
ASIDs out itself, the way KVM does -- Asids in hvarch/src/x86/svm.rs,
the CPU's own, in the one CpuLocal both Guest::runs take once an entry
with interrupts off (Entries in hvarch/src/x86.rs, beside the CPU's
debug addresses and its speculation state):
- each ASID is handed out at most once a generation, and a generation
ends when they run out -- 32767 on a Zen 2, 15 under TCG -- with a flush of
every entry of every ASID at that CPU's next entry into any guest. Until an
entry the CPU accepted has made that flush, every entry there asks for it:
one the CPU refuses (
VMEXIT_INVALID) flushes nothing; - a guest keeps its ASID, and its translations with it, only while it keeps entering on the CPU, in the generation and over the nested table it was given it for; anything else -- another CPU, a generation over, another table -- and it is given the next one. So no ASID is live for two guests, or for a guest and one that has gone, without a flush in between;
- every CPU starts each load of the module run out, so its first entry flushes whatever an earlier load's guests left in its TLB.
What lets a guest keep its translations at all is that its nested table only
grows: an entry is written once, where there was none, and stays until the
table goes (hv/src/npt.rs), so a translation the TLB still holds is one
the table would still make. Every table has an id no other table has, and an
entry that took a mapping away or narrowed it would give the table a new one
-- moving its guest onto a fresh ASID, clear of the old translation. None
does today.
The asid built-in guest is the check: two VMs on one CPU taking turns, an
exit each, each reading its own page at the same address, and a third made
once they have gone, with the ASIDs a generation limited to two so that it
is given one they had, after the flush that ended their generation. A missed
flush, or two guests under one ASID, reads another guest's page -- for the
third, a page back with the host. Under TCG every entry flushes whatever the
VMCB says, so the check can fail only on a CPU that keeps translations.
It also refuses a CPU with CR4.LA57 set. A nested table is walked in the
host's own paging mode, and the one here has four levels: walked as five,
its top level would be taken for a fifth and the guest's own memory for the
last, and the guest would choose its own host physical addresses. Nothing
in this kernel turns five-level paging on; the check is where that would be
found out, rather than by a guest.
The entry is a naked function, vmrun_stub, and GIF is clear for all of
it, so nothing -- no interrupt, no NMI -- runs until the host's state is back
whole:
clgi nothing gets in from here
vmsave host page FS, GS, TR, LDTR, the syscall MSRs: vmrun switches none of them
vmload guest VMCB
load the guest's registers
sti; vmrun; cli the host's IF, which vmrun saves, decides that an interrupt exits
store the guest's registers
vmsave guest VMCB
vmload host page
stgi with IF still clear: the interrupt that ended the turn waits
The vmsave/vmload of the host's state is not optional here: this
kernel keeps its per-CPU data at the GS base. Taken out on purpose, the
first thing the host does after a guest's exit is a page fault at address 0
in Hal::GetCurrentCpuHwId() -- the gate's hv run panics the kernel.
The exits
hv::svm::Vcpu::exit decodes what #vmexit wrote -- port I/O with its
size and direction, HLT, CPUID, MSR, VMMCALL, a nested page fault with its
address and error code, an exception with its error code, a machine check,
a shutdown, and the host's own interrupts, after which the guest goes
straight back in. Where an instruction ends comes from the CPU when it
saves next RIP; otherwise from the length of the one instruction each
intercept is for (HLT 1, CPUID 2, RDMSR/WRMSR 2, VMMCALL 3), and for port
I/O from the address every CPU hands over. Stepping past an instruction
also steps out of the interrupt shadow it was in.
An exit can cut an event's delivery short -- the host's interrupt arriving
while the guest was taking one of its own, a nested fault on its IDT or
stack -- and the event is then in exit_int_info, lost unless the next
entry injects it. After every exit it is put back into event_inj (the two
fields share a format), except a software interrupt or INT3 or INTO, whose
instruction the guest's RIP still points at and which running again raises
again.
When the CPU says no
A VMCB vmrun refuses comes back as VMEXIT_INVALID and nothing more: no
field, no rule. hv::svm::Vcpu::check is the manual's list of
consistency checks that a VMCB filled in here could fail -- EFER.SVME,
CR0.NW without CD, CR0 above bit 31, CR3 above bit 51, reserved CR4 and EFER
bits, DR6 and DR7 above bit 31, long mode without PAE or PE, a 64-bit code
segment with D set, G_PAT's memory types, the injected event's
type and vector -- and one of this hypervisor's own that the CPU does not
make, LMA against LME and PG. Every entry asks it first: a VMCB it finds
wrong is never handed to the CPU, and the refusal names the rule. It is the
SVM side of the "VM-instruction-error decoder" the plan asks for before it
is needed. When vmrun refuses anyway, the report dumps the guest's state.
The built-in guests
Each is a handful of instructions, assembled once -- with NASM, or with
clang for mmio and ioapic -- and kept in hv/src/guests.rs as bytes
beside the source they came from, run in a VM of its own and checked
against what it was told to do. All start in long mode
at CPL 0 with paging on, 1 MiB of memory with a GDT, a TSS and a page table
in it that maps the first 2 MiB to themselves -- the second of them with no
memory behind it -- and 1 GiB to guest physical 4 GiB; all but the second
CPU of smp, which starts where a start-up IPI starts every CPU, in real
mode:
| Guest | Does | Shows |
|---|---|---|
exits |
reads and writes the debug port 0xE9, asks CPUID a leaf only this hypervisor answers, writes what it got to its memory | port I/O both ways; CPUID answered by the host; the answers read back out of guest memory |
hypercall |
writes through its own page table to guest physical 4 GiB, puts a value of its own in every register, makes a hypercall, writes every register the host answered to its memory | long mode under nested paging, above 4 GiB; the run stub's register save and restore, all 15 registers both ways |
uart |
brings up an 8250 the way a driver does -- divisor behind DLAB, 8N1, FIFO, a scratch-register presence test -- and sends a line polled out of LSR.THRE | the emulated serial port, over port I/O alone |
fault |
loads a null DS, then writes to 0x1FF000, which its page table maps and the nested one does not | stopped at the nested table, at that address and that instruction: a guest reaches nothing it was not given -- and the state its report shows is the guest's at the fault, the null DS in it, which under VT-x only reading the whole state for the report gives |
absent |
reads AMD's FCH reset-status register at 0xFED803C0 twice, then writes to it | a read of a device the platform does not have finds all ones -- what Linux on a Zen CPU reads there -- through one read-only page; the write stops the guest |
mmio |
stores to and loads from 32 bytes of a device's registers in the MMIO window, by every form of mov Linux's accessors are -- an immediate of each size, REX's registers and a high byte, movzx and movsx |
each access's instruction fetched through the guest's paging, decoded and performed, every value where it belonged (MMIO) |
triple |
int3 with no IDT |
a triple fault stops the guest, not the CPU |
refused |
starts with CR0.NW set and CD clear | a VMCB that breaks a rule is refused before the CPU sees it, the rule named |
spin |
cli; jmp $ |
the host's interrupts still get through -- about a hundred a second -- and the host stops it when its 300 ms are up |
tpr |
writes 15 to CR8 -- the task priority register, which masks every interrupt priority -- and reads it back | the guest gets a shadow TPR of its own (AMD-V's V_TPR; under VT-x the virtual APIC's VTPR, or where the CPU has none, a mov cr8 stops the guest and is answered from a shadow), and the host's CR8, read afterwards on the CPU the guest ran on, is still 0: a guest cannot hold the host's interrupts off its CPU |
asid |
three VMs on one CPU read a page of their own at one address: two taking turns, and a third given an ASID one of them had | no guest reads another's page through the TLB, and a reused ASID is reused after a flush (Address space identifiers) |
smp |
two CPUs, on two host CPUs: the first sends the second INIT and a start-up IPI; the second comes up in real mode at the vector's page, climbs through protected mode to long mode, writes down its x2APIC ID and CPUID's, and sends the first an IPI; the first, woken by it, arms its APIC timer for one shot and halts until it fires | an application processor started the way Linux starts one, an IPI between two CPUs, and the local APIC timer (More than one CPU) |
ioapic |
on the machine of smp with an IO-APIC, its first CPU alone, in xAPIC mode: reads the IO-APIC's version, then wires the serial port's pin to itself -- as an edge, the THR-empty interrupt enabled and a byte written, then one written while masked; as a level, its handler ending it without reading IIR twice, and the third time reading it |
edges sent as the line rises and lost while masked; a level sent again at each EOI for as long as its line is up, its remote IRR set until then; every IO-APIC access and every EOI a decoded MMIO access (The IO-APIC) |
posted |
on the machine of smp, asking for its interrupts to be the CPU's: the second CPU, started as smp's is, sends the first a thousand fixed IPIs, one at a time, while the first spins with interrupts on and no HLT, counting them |
where the CPU has posted interrupts each is posted and notified and taken by the first CPU in its guest, which leaves it a handful of times rather than once an IPI; elsewhere the host hands each over (Interrupts the CPU delivers itself) |
apicv |
on the machine of ioapic, its first CPU's local APIC taken to x2APIC mode by the base MSR: with CR8 at 5 sends itself 0x50 and 0x30 and reads both in IRR; with CR8 at 0 takes 0x50, then 0x30, whose handler sends itself 0x70 and takes it nested, each handler reading ISR; then the serial port's pin through the IO-APIC as a level, three times, each ended by the x2APIC's EOI; and ISR read whole at the end |
the same to the guest whoever delivers its interrupts: under VT-x with APIC virtualization the CPU delivers all six and takes their EOIs, stopping the guest after the three level-triggered ones for the IO-APIC; elsewhere the host injects them (Interrupts the CPU delivers itself) |
debug |
one CPU, through the run loop a Linux guest's go through, with an IDT of its own: sets TF with POPFQ and single-steps a NOP, then runs an ICEBP (int1) |
each a #DB of the guest's own -- intercepted for the delivery loops a guest could make of it -- given back to its handler, the single step's with DR6.BS -- under VT-x, which writes no DR6 at a #DB that exits, the DR6 its exit said, and every mov of DR6 the host's |
dr |
two VMs of one CPU each, one after the other on the CPU the guests run on, through the run loop a Linux guest's go through, with an IDT of its own: each reads DR0-DR3, writes a pattern of its own into them through four registers and reads it back through a fifth, then points DR0 at a ret of its own -- each VM's elsewhere -- arms it in DR7 for execution and calls it, its #DB handler turning the breakpoint off |
every mov of DR0-DR3 the host's: the second VM reads 0 where the first left its own, each reads back its pattern, and each one's breakpoint fires -- DR6.B0, at its ret -- which only its own DR0, made the CPU's before its entry, can do. An execution breakpoint and not a data one: QEMU 10.2's TCG never gets past a store a data breakpoint watches in a nested guest, retrying it for good with no interrupt taken, whoever's DR0-DR3 are the CPU's (What entering a guest cannot be told to do); under VT-x, which stops a guest at every mov of a debug register, the same, its DR6 and DR7 the host's too |
cr4 |
one CPU, through the run loop a Linux guest's go through, with an IDT of its own: reads CR4, writes PAE alone -- MCE cleared -- then PAE and PKE, 0, PAE and PGE through R9 (read back through R13), and bit 32, reading CR4 after each | every mov of CR4 performed by the host: the guest reads back what it wrote while the CPU's CR4 keeps MCE, and PKE -- which its CPUID never offers -- PAE cleared in long mode and a reserved bit each a #GP, CR4 as it was (What entering a guest cannot be told to do); on QEMU's TCG, which stops no read of CR4, its reads are the register's, MCE in it, and the writes the host's all the same; under VT-x its reads are the read shadow's, and three of its writes -- those that change a bit the host keeps -- the host's, PAE cleared the CPU's own #GP |
$ hv run all 3
...
hv: guest fault -- a write to memory its page table maps and the nested one does not
ran on cpu 3, 51 us
exits 0 port in, 0 port out, 0 cpuid, 0 hypercall, 0 host interrupt
stopped nested page fault at gpa 0x1ff000, error 0x100000006, rip 0x8009
checked stopped at the nested table, at the address and the instruction it was told to, its state read whole
hv: guest fault ok
...
hv: guest spin -- cli; jmp $ -- for as long as the host lets it
ran on cpu 3, 307051 us
exits 0 port in, 0 port out, 0 cpuid, 0 hypercall, 31 host interrupt
stopped by the host, after 300 ms
checked the host's interrupts got through 31 times with the guest's off, and the host stopped it
hv: guest spin ok
hv: guest asid -- three VMs on one CPU, and none reads another's page through the TLB
ran on cpu 3, 13708 us
exits 24 cpuid, 0 host interrupt
checked A and B each read their own page taking turns; vm C was given ASID 1, which vm A had, after 2 generation(s) ended, and read its own too
hv: guest asid ok
hv: guest smp -- two CPUs: the first starts the second -- INIT and a start-up IPI, into real mode -- which climbs to long mode and sends it an IPI; then its APIC timer
ran on cpu 3 and 0, 18192 us
exits cpu 0: 15 (9 wrmsr, 2 hlt, 2 apic irq); cpu 1: 6 (2 wrmsr, 0 hlt)
stopped both CPUs halted with interrupts off, the last at 0x80c3
checked cpu 1 started by INIT and a start-up IPI, came up in real mode and reached long mode, x2APIC ID 1 and CPUID's the same; its IPI reached cpu 0, whose one-shot APIC timer then ran out and interrupted it
hv: guest smp ok
hv: guest ioapic -- an IO-APIC, through its page: the serial port's pin as an edge, then as a level ended by EOI and sent again while its line stays up
ran on cpu 3, 23669 us
exits 50 (1 port in, 21 port out, 22 MMIO accesses performed, 3 hlt); the IO-APIC sent 5, 3 of them levels, 3 ended by EOI
stopped cpu 0 halted with interrupts off at 0x80d4, cpu 1 never started
checked version 0x20's 24 pins; two edges taken, none while masked; three levels, each sent again at its EOI while the line stayed up, its remote IRR set in service and clear after
hv: guest ioapic ok
hv: 15 of 15 guests ok
A guest bound to a CPU the extension is not on for is not run, and says
which CPU: hv: guest exits not run -- the extension is not on for cpu 2.
What the extension costs while it is on
- On Intel, a CPU in VMX root operation ignores INIT. It cannot be
brought back up through the INIT/SIPI sequence
CpuTable::StartAllstarts APs with untilvmxoff. Nothing innosre-INITs a running CPU today; kexec-style live update (stage 5) will have to. - On AMD,
EFER.SVMEonly enables the instructions, and costs nothing else.MSR_VM_HSAVE_PApoints at a page that must stay allocated for as long as it does -- which is whatCpuPageis, and why the table that owns the pages and the mask of enabled CPUs are set and cleared together under one lock. - Either way the page is freed only after the CPU it belonged to has said it is done with it.
- One Intel write is not given back. If firmware left
IA32_FEATURE_CONTROLunlocked, the firsthv onlocks it with VMXON allowed -- the CPU refuses VMXON otherwise -- and the lock holds until the next reset, whateverrmmoddoes. It is how every other OS leaves the MSR and how almost every firmware hands it over already, and it is done only after every check that could refuse, so a refusal leaves no trace; but it is the one exception to "unloaded, the machine is as it booted". - On AMD, GIF is set before SVM goes off. With the global interrupt flag
clear, INIT and NMI stay blocked, and
stgiis an undefined opcode onceEFER.SVMEis clear -- sosvm::disablesets it first, as KVM does. The run stub clears GIF only for its own length and sets it before it returns; the off switch is written to be right whatever the stub does.
And while a guest runs:
- The TLB is flushed only when a CPU's ASIDs run out (Address space
identifiers) -- once every 32767 guests, or
guests moved between CPUs, on a Zen 2 -- rather than on every entry, the
host's entries included, as it was.
hv bench ... flushstill does that, for what it costs (What an exit costs). - The x87 and SSE state is the guest's, switched at the exit and, when
it has to be, at the entry. FXSAVE after every exit into an area each
guest owns; FXRSTOR before an entry only when the CPU's registers are not
this guest's already -- each CPU remembers whose FXRSTOR was the last on
it (
fp::OWNER, by a number no two guests ever share), and the host uses none of the registers (its C++ is built without SSE and x87, its Rust is soft-float), so between a guest's exit on a CPU and its next entry there they hold what its FXSAVE saved unless another guest's entry came between. CR4.OSFXSR, without which FXSAVE leaves the XMM registers out, is set for a CPU when its extension is turned on and cleared when it is turned off (hvarch::x86::fp), not around each entry as before: that was two serializing writes of CR4 and an XGETBV an exit, and under a nested hypervisor an exit to it for each. What is given up is that, while the module is loaded, an SSE instruction in the kernel would run on those CPUs instead of faulting -- a guard against a toolchain slip, not a guest. XCR0 is made the x87 alone at the same moment, if firmware left it with more, and put back when the extension goes off; guests are given no XSAVE, so AVX and above fault in a guest even if it turns OSXSAVE on itself. - A halted guest costs its CPU nothing. A HLT with interrupts on is
stepped past, as a CPU an interrupt wakes resumes after it, and the vCPU
is not entered again until the PIC has an interrupt for it; meanwhile its
task sleeps to the timer's next edge, and is woken at it: the host CPU's
one-shot timer is armed for the deadline as the task blocks (the local
timer, Time). It used to be woken
at its CPU's first scheduling point after the edge -- the host tick, at
the latest -- so a guest's tick arrived up to 10 ms late. None is lost: every periodic edge that
elapsed is owed to the guest and handed over one at a time, each once it
has taken the last (
Pit::ch0_fire), up to a second's worth. A guest at 250 Hz was given one edge for every two or three periods before, and a guest keeping time in jiffies counted 40% of real time (see On real hardware). Measured on the Linux guest over 120 s of the same boot and a typedid: 5,633,131 exits, 5.56 million of them HLTs, became 76,334 and 11,410; the guest's timer ticks went from 11,973 to 11,943; the vCPU's task slept 89% of the run; and the QEMU process under it went from 106% of a host CPU to 46%. - Speculation, AMD-V's half of it (
hvarch::x86::spec): after every exit the guest's values are cleared out of the registers the run stub returns with, and the return stack is refilled before the host's firstret-- 32 calls, KVM's fill; an IBPB goes between two guests on a CPU where the CPU has one; and automatic IBRS is on while the extension is, where the CPU has that (Zen 4 and later).hvcounts the guests switched on a CPU and the IBPBs between them. What is left of it, SRSO and Retbleed among it, is item 1 of Before other people's guests. - Each entry reads
EFERandVM_HSAVE_PAon the CPU, and checks the VMCB against the manual's rules, beforevmrun.
What an exit costs
hv bench runs a guest that makes nothing but exits -- CPUIDs, each after a
read of each of pages pages 4 KiB apart -- on a task bound to one CPU, and
times it:
hv bench [cpu=N] [exits=N] [pages=N] [flush] [profile] [mmio]
flush makes every entry flush the whole TLB, as every entry did before
ASIDs, so the two can be set side by side on one boot (AMD-V only: under
VT-x there is no such entry to ask for); pages is what a flush costs the
guest after it -- each read a translation to walk again -- where with none
it is what it costs the host. profile times each entry in its parts with
the time-stamp counter: the checks and the ASID; the x87/SSE registers put
in; vmrun to #vmexit, the stub's VMSAVE and VMLOAD and the guest's few
instructions included; the registers taken back out; and the rest, which is
the exit handled and the loop round to the next entry. Under VT-x the parts
are the checks and VMPTRLD, the shadow written into the VMCS, the registers
in, vmresume to the exit, the registers out, and the exit and guest state
read out of the VMCS:
$ hv bench exits=20000 profile
hv: 20000 exits on cpu 3, 0 pages read between, ASIDs kept: 342 ms, 17130 ns an exit, 58374 a second (12 host interrupts among them)
hv: an exit, in ns: checks and ASID 127, x87/SSE in 605, vmrun to #vmexit 9012, x87/SSE out 731; the rest -- the exit handled, the loop -- 6655
Those are TCG's numbers, and say nothing about a CPU: TCG flushes its own
TLB on every vmrun whatever the VMCB asks, and emulates every instruction
of the host's side as well as the guest's. On the AX41 (Zen 2, 2026-09-24,
two rounds, the same to a few ns):
| Between exits | ASIDs kept | TLB flushed every entry | |
|---|---|---|---|
| nothing | 731 ns an exit, 1.37 million a second | 940 ns, 1.06 million | -22% |
| a read of 64 pages | 873 ns | 1321 ns | -34% |
| a read of 240 pages | 2180 ns | 2448 ns | -11% |
and the profile of an exit (with its own ~40 ns in it):
| ASIDs kept | flushed | |
|---|---|---|
| checks and ASID | 90 ns | 90 ns |
| x87/SSE and XCR0 in | 119 ns | 120 ns |
vmrun to #vmexit |
314 ns | 424 ns |
| x87/SSE out | 102 ns | 105 ns |
| the exit handled, the loop | 148 ns | 249 ns |
The flush cost the world switch 110 ns and the host's side after it 100 ns more, its own translations walked again; with 64 pages the guest's as well. What is left to take is on the host's side of the switch: the x87/SSE state and CR4 around it, 220 ns an exit, and the checks' two RDMSRs.
Under VT-x the first numbers were nested KVM's on the Intel dev box
(-cpu host, hv bench exits=50000), where every unshadowed vmread or
vmwrite, every mov cr4, VMPTRLD and intercepted rdmsr is an exit to
the outer kernel, and so where the host's side of an exit weighs far more
than on the silicon; but the parts they took away are the same parts:
| Step, 2026-09-25 | ns an exit | -- |
|---|---|---|
| after the review's fixes (CR8, INVEPT, NMI, the intercept set) | 17,900 | |
host MSRs cached, VMPTRLD skipped, OSFXSR/XCR0 set once at hv on |
12,500 | -30% |
| VPIDs, FXRSTOR only when another guest ran in between | 11,700 | -7% |
| the VMCS sync dirty-tracked, the exit read by reason | 5,800 | -50% |
and the profile of the last (hv bench exits=20000 profile): checks and
VMPTRLD 13 ns, VMCS written 22, x87/SSE in 38, vmresume to exit 4,610,
x87/SSE out 53, VMCS read 51, the exit handled and the loop 1,010. The
4.6 µs in the middle is the outer kernel's round trip for the nested exit,
not this code's; of the 12 vmwrites and 14 vmreads an exit, 4,700 and
1,500 ns before the dirty tracking, 73 ns are left. On real Intel silicon
the same instructions are tens of cycles each, so the shape of the gain is
the same and its size smaller: measured there is what is left to do.
On the silicon -- the EX44's i5-13500, 2026-10-20, nos booted beside Ubuntu
from its system image (On real hardware), hv bench exits=200000, each number the same to a few ns over two to six loads of
the module -- an exit costs 1.69 µs on a P-core (CPU 0) and 2.07 µs on an
E-core (CPU 62) without the speculation mitigations (What a review
found, item 1), and 1.82 and 2.49 µs with them. Each
one's share, from a module built without it:
| An exit, ns | P-core | E-core |
|---|---|---|
| without the mitigations (master's module, f3b0285) | 1,690 | 2,065 |
| with all of them | 1,818 | 2,490 |
| -- the return stack refilled with 2 entries, not 32 | -18 | -30 |
| -- no BHI_DIS_S | -22 | -287 |
| -- no VERW | -30 | -106 |
| -- no IBRS, and so no BHI_DIS_S | -50 | -303 |
| -- none of the four | -97 | -431 |
All of it is 7% of an exit on a P-core and 20% on an E-core, two thirds of
that BHI_DIS_S: a cost not of the switch but of every indirect branch the
host takes there while the extension is on, which the profile puts in "the
rest" (625 ns without the mitigations, 903 with). Linux on the same machine
runs its whole kernel under BHI_DIS_S and pays it everywhere. What is left
between none of the four and master, 30 ns on the P-core and nothing to
measure on the E-core, is the rest of the review's fixes: DR7 switched by
the entry and the exit themselves, the registers cleared, the record of
the CPU's debug registers. One thing not explained: the first load of the
module after a boot runs its E-core exits at 2.2 µs, BHI_DIS_S's share
gone, though hv, reading every CPU's IA32_SPEC_CTRL back, finds it set
there; every later load is at 2.49.
The P-core's profile, without the mitigations and with them:
| without | with | |
|---|---|---|
| checks and VMPTRLD | 17 ns | 21 ns |
| VMCS written | 20 | 21 |
| x87/SSE in | 63 | 63 |
vmresume to exit |
950 | 1,041 |
| x87/SSE out | 86 | 86 |
| VMCS read | 20 | 20 |
| the exit handled, the loop | 550-573 | 605 |
Those are numbers at the base clock, 2.5 GHz, where firmware left every
core and nos kept it until it turned hardware P-states on (Boot,
the clock): with the cores up to their turbo an exit costs 1,018 ns on a
P-core and 1,512 on an E-core, an MMIO access 2.5 us, and the profile reads
checks 12 ns, the VMCS written 12, x87/SSE in 35, vmresume to exit 587,
x87/SSE out 49, the VMCS read 11, the rest 339.
The first build with them took three CpuLocals an entry -- the CPU's
debug registers, the last guest it entered, its mitigations -- and its
checks were 418 ns, each with being calls into the kernel to turn
interrupts off and on and to ask which CPU this is. They are one now
(Entries in hvarch/src/x86.rs), which the entry takes once as what
holds it on its CPU, as AMD-V's did with its ASIDs alone.
mmio has the guest make MMIO loads instead -- mov eax, [rdi] from a
device's page in the MMIO window, each a nested fault whose instruction the
host walks to, fetches, decodes and performs (MMIO) -- and with
profile says what of an exit the access itself took. Under nested KVM,
2026-09-29, 200,000 of each on one CPU (the access's share from a run of
20,000 under profile):
| a CPUID exit | an MMIO load | the access itself | |
|---|---|---|---|
| AX41 (Zen 2, AMD-V) | 10.3 µs | 11.7 µs | 1.1 µs |
| rserver (i5-13500, VT-x) | 5.8 µs | 8.7 µs | 2.4 µs |
| rserver, the whole guest state read at every EPT violation | 5.8 µs | 30.8 µs |
The last line is the first measurement, and why an EPT violation now reads only what performing an access takes (The VMX backend): of the forty-odd fields a report wants, most are ones a nested KVM keeps out of the shadow VMCS it gives L1, each an exit to it, and 25 µs of every access went there. What is left above a CPUID exit is L0's longer way through a nested fault (0.3 µs under VT-x, none to measure under AMD-V) and the access itself -- three reads of the guest's page tables (the bench's code is on a 2 MiB page; four for a 4 KiB one) and one of its instruction, each a copy through the frame window with a mapping of its own made and dropped, and the decoding, which is a few dozen branches. The copies are where to look if an MMIO-heavy guest makes it matter: on the EX44's silicon (2026-10-20, a P-core) a CPUID exit is 1.8 µs, an MMIO load 4.5, and the access 2.5 of that -- no less than under nested KVM, being the host's own work, and so there most of what an MMIO access costs.
arm64
On Arm a hypervisor is not an extension a kernel turns on -- it is an
exception level a kernel runs at. nos is handed the machine at EL2 when
firmware boots it there and drops straight to EL1 in its first hundred
instructions (arch/arm64/boot.S), and a module cannot climb back: EL2 is
entered by taking an exception to it, and a kernel already at EL1 has no way
to ask.
So the arm64 backend reports and does not run:
$ hv info
hv: Arm virtualization (EL2) -- not implemented on this architecture
running at EL1
-- boot.S drops EL2 to EL1; a module cannot climb back
VMIDs 8 bits
VHE no
stage-2 output 40 bits
The EL2 hypervisor is a change to the boot path -- stay at EL2, run the
kernel there with the virtualization host extensions, install stage-2
translation -- and that is where it will be written. Everything above
hvarch is unchanged by it, which is the leverage the
roadmap's reordering bought by
doing the HAL and arm64 before the hypervisor rather than after.
Running it
The module is built with the kernel (make modules, or any make), and
goes on a root filesystem the way any module does:
make nocheck # out/x86_64/modules/hv.ko
scripts/mkrootfs.sh root.img 64 <dir> 1024 # with hv.ko in <dir>
qemu-system-x86_64 -cpu max -smp 4 -m 1G -cdrom nos.iso \
-drive file=root.img,format=raw,id=d0,if=none \
-device virtio-blk-pci,drive=d0,disable-legacy=on,disable-modern=off
Under KVM on a Linux host, -enable-kvm -cpu host gives the guest the host's
own extension (nested virtualization: kvm_intel nested=1 or
kvm_amd nested=1), which is far faster than TCG and is how the VMX path is
exercised before it reaches real hardware. scripts/hv-test.py uses it on
both: AMD-V on an AMD host, Intel VT-x (nested) on an Intel one, and AMD-V
under TCG's -cpu max where there is no KVM -- TCG has no VMX, so the Intel
backend is reached only through KVM. The two guests whose verdict differs by
vendor (refused, asid) have their expected output chosen from hv info.
The gate is scripts/hv-test.py (Tests and gates):
scripts/hv-test.py # x86-64: the extension on and off and off again, and every guest
scripts/hv-test.py --arch aarch64 # arm64: that it reports and refuses
A Linux guest
hv boot loads an unmodified 64-bit Linux bzImage by the boot protocol and
runs it on a vCPU. The kernel and the initrd are streamed from a file
straight into guest memory a chunk at a time, so neither has to fit in one
allocation; then hv::linux lays the guest out -- the zero page from the
setup header, the command line, an e820 map, identity page tables and a GDT,
all written through GuestMemory's copying accessors -- and puts the vCPU at
the kernel's 64-bit entry with RSI at the zero page. It holds no reference
into guest memory: everything it writes the guest could have written itself.
Three things stand between that entry and a running kernel, and all three are here:
- A CPU cut down to what is emulated (
hv::policy), and named whole. CPUID is an allow-list: a leaf the policy does not name is all zeros, as is every leaf past the tops it reports -- 0x16 and 0x8000_0021, or the host's if lower -- and of the leaves it names a guest is told the host's bits it lets through: no XSAVE and so no AVX -- the state switch aroundvmrunmoves only the FXSAVE registers, so a guest is given nothing it could put in the part that is not switched -- no paravirtualisation, no speculation control (none has its MSR behind it yet); of the structured leaf, 7, what runs as it is and owns no state of the host's -- SMEP, SMAP and UMIP for the guest's kernel, FSGSBASE over bases the VMCS and the VMCB keep, BMI, ADX, ERMS and FSRM, RDSEED, SHA, CLFLUSHOPT and CLWB, SERIALIZE -- with MD_CLEAR, VERW clearing the CPU's buffers in a guest as anywhere, and ARCH_CAPABILITIES, whose MSR reads as the host's but for the bits that are not facts of the CPU (IBRS_ALL, whose SPEC_CTRL is not the guest's, and those naming an MSR nothing answers); PCID, but not INVPCID, which VT-x would need a control for; of the power leaf ARAT alone, and for a guest of one CPU no local APIC and no x2APIC (one of more has an emulated x2APIC: More than one CPU); and the hypervisor bit, since there is one, which has Linux leave the microcode loader and the bare-metal chicken bits alone. Each of its CPUs is a package of one, whichever host CPU its vCPU runs on: leaf 1 says one logical CPU, with that CPU's APIC ID -- 0 for a guest of one -- leaves 4 and 0x80000008 one core, and the topology leaves (0xB, 0x1F, 0x8000001E), the SVM leaf and the memory-encryption leaf are blank. Every MSR is intercepted but the three segment bases, and every one is an allow-list's too: the system MSRs (EFER, the PAT, the segment bases, the SYSCALL and SYSENTER registers) are the guest's own state and are served from the VMCB save area -- a PAT entry that is no memory type, an address that is not canonical, an EFER bit its CPUID does not offer each a #GP, as the CPU gives -- the x2APIC's by the local APIC of a guest that has one, the TSC as the host's; and a few answer as a CPU with nothing behind them would, each taking a write that leaves it as it is and no other: the MTRRs (none, and off) and the machine-check registers (no banks, nothing to report), which leaf 1 offers and Linux reads with no fallback for a fault -- a fault on a machine-check register is a panic to it -- the microcode revision, none, and of each vendor what Linux reads of every such CPU without asking: Intel's platform ID and MISC_ENABLE, its fast strings as the host has them; ARCH_CAPABILITIES where leaf 7 offers it, read-only; AMD's HWCR, DE_CFG and NB_CFG, with the bits a Linux on AMD checks set as the hardware has them. Every other MSR is a #GP, read or written -- what a guest'srdmsr_safeprobes are ready for, and a line in the VM's report. One a guest's user can reach is refused on purpose: DEBUGCTL, whose BTFptrace's block-step sets -- Linux says so once ("unchecked MSR access error") and steps by instruction instead, where a write kept nowhere would have it believe it steps by branch. LBR virtualization in the VMCB would let BTF work. - The devices a guest cannot boot without (
hv::devices): the 8250 serial port the console writes to (which raises IRQ4 for the transmitter, so the driver sends past its first byte); an 8254 PIT whose counter counts down at 1.193182 MHz off the host clock, whose channel-2 output goes high at its terminal count (whatpit_calibrate_tscwaits on), and whose channel 0 raises IRQ0 -- the system tick a guest cannot schedule without: an edge a period in the periodic modes 2 and 3, and one when a loaded count runs out in the one-shot modes 0 and 4, which a kernel's high-resolution timers drive a count at a time; an MC146818 RTC that answers the host's wall clock with the update bit clear; and a pair of 8259 PICs the guest takes its interrupts from -- every one of them, for a guest of one CPU, which runs with no local APIC. - The exit loop (
hv::run) that answers all of the above, injects the highest-priority interrupt the PIC has when the guest can take one (and asks the CPU, through SVM's virtual-interrupt window, to exit the moment it can when it cannot), and halts the vCPU when the guest does: an idleHLTis stepped past -- a CPU an interrupt wakes resumes after it, so Linux'ssti; hlt; clireturns to its idle loop, and out of the STI's interrupt shadow -- and the vCPU is not entered again until an interrupt is pending for it, its task asleep until the timer's next edge. It streams the guest's console to the kernel log a line at a time -- a boot's worth, a thousand lines, and then fifty a second, what it leaves out said once a second at most (... N lines not logged), so that a guest printing as fast as it can does not have the host's log, serial line and netconsole for its own; the console itself, which the report andhv consoleshow, keeps every byte -- and stops with a reason and a register dump.
And what a PC's firmware leaves a kernel, which it reads instead of asking a BIOS: the e820 map, an MP table for a guest of more than one CPU (More than one CPU), and the ACPI tables, with the fixed hardware they describe (ACPI).
What this reaches today, on a tinyconfig Linux 6.18 under AMD-V (QEMU's TCG, where the guest is twice emulated and slow), is a full boot to an interactive shell:
$ hv on
$ hv boot /bzImage initrd=/initrd cmdline=console=ttyS0 nolapic rdinit=/init
--- ttyS0 ---
[ 0.000000] Linux version 6.18.53 ...
[ 0.000000] NX (Execute Disable) protection: active
...
[ 0.540000] Run /init as init process
nos-guest: init is up, / on ramfs, busybox v1.36.1
Linux (none) 6.18.53 #1 x86_64 GNU/Linux
BusyBox v1.36.1 built-in shell (ash)
~ # id
uid=0 gid=0
~ #
--- end ttyS0 ---
The shell reads what is typed at it: hv boot ... input='id\n' hands the
guest a line once it is at a prompt, and the 8250's receive path -- with the
answer to the cursor-position query (ESC[6n) a line editor sends first --
carries it. The gate is scripts/hv-linux-test.py, a manual one (a bzImage
is megabytes and CI cannot build one in its time), pointed at a kernel by
hand; with --initrd it checks the guest reaches its init and a shell, and
--input 'id\n' --expect uid=0 checks it runs the command.
A disk
disk=<path>, on hv boot or hv start and as many times as there are
disks, gives the guest a virtio block device over a file of nos's: vda,
vdb, ... in that order, each the size of its file. The guest mounts it,
ext4 and all, as it would a disk of QEMU's:
$ hv start /bzImage initrd=/initrd disk=/disk.img cmdline=console=ttyS0 nolapic
$ hv exec 0 mount -t devtmpfs devtmpfs /dev
$ hv exec 0 mount /dev/vda /mnt
$ hv exec 0 cat /mnt/hello.txt
An image is one writer's, or any number of readers'. A guest's disk
claims its image as it opens it, by the file rather than the path -- its
mount and inode, so two names of one image are one: a disk that writes it
has it alone, and disks that only read it (:ro) share it. Two guests'
filesystems on one set of blocks would each wreck the other's with nothing
to say so; a start or a boot that cannot have its claim is refused, saying
whose the image is --
$ hv start /bzImage initrd=/initrd disk=/disk.img:ro cmdline=console=ttyS0 nolapic
hv: vm 1 not started -- /disk.img: vm 0's disk writes it
-- and the claim goes with the disk, when its VM stops. It holds among the
VMs only: nos's own shell is not held to it (modules/hv/src/disk.rs).
Legacy virtio over PCI, reached by port I/O -- so still no instruction
decoded anywhere. PCI's configuration space is the PC's mechanism #1, an
address written to 0xCF8 and the data at 0xCFC, for bus 0: a host bridge at
00:00.0, which is what Linux's sanity check of the mechanism looks for, then
a function for each disk (hv::devices::pci). Only a dword access at 0xCF8
reaches the address -- a byte at 0xCF9 is the chipset's reset control, which
the run loop sees first. Each disk's registers are in an I/O BAR, placed as a
BIOS would place it and movable as a BAR is; its interrupt is INTA, on IRQ 11
of the emulated slave PIC, raised as an edge when the device's interrupt
status goes from clear to set -- the PIC here is edge-triggered, and a line
re-raised while the driver has yet to look would be interrupts it finds
nothing for, which Linux ends up disabling a line over. (That exposed a slip
in the PIC: acknowledging a slave interrupt dropped the cascade line even
with another slave request waiting.)
The rings are the guest's; the host trusts none of them (hv::devices:: virtio). A queue's descriptors, its available ring and its used ring sit in
guest memory, where the guest may write any of them at any moment. Every
index, address and length read from them is checked before it is used: a
descriptor past the queue, a chain longer than the queue -- a loop --, more
chains than the queue holds, an indirect descriptor (not offered), or a
buffer outside guest memory stop the device, which takes nothing more until
the driver resets it. A request's header, data and status are taken as
streams across its readable and writable buffers, whatever the layout; its
sector range is checked against the disk's size, and its size against what
the driver was told a request may carry.
The disk works beside the guest. A request used to be served before the
notify that made it available returned, the vCPU waiting for its disk -- and
on the AX41 a guest writing back what apt-get update had fetched stalled
for seconds at a time (On real hardware). Now the notify
only takes the requests off the ring -- a write's data copied out of guest
memory into a buffer of the device's -- and hands them to the backend, which
serves them in order on a task of its own, on a CPU other than the vCPU's.
Each time round the run loop what it has served is given back: a read's
data copied into the guest, the status written, the chain on the used ring,
an interrupt; and as it finishes each the task wakes a halted vCPU or kicks
a running one out of its guest, the NICs' way (A network).
Guest memory is touched on the vCPU's task and nowhere else. At most eight
requests are out at once, each in a 256 KiB buffer taken when the disk is
made -- the driver is told a request carries at most 64 segments of a page
(seg_max, size_max), and the ring's 256 entries hold three of the
largest -- so nothing is allocated on the way, and a guest that keeps its
ring full waits for its disk rather than growing anything of nos's.
The disk's bytes are a trait's (hv::disk::Backend, which is handed a
request and gives it back served): the module's FileDisk holds the image
open (kcore::fs::File, whose handle the kernel looks up on every call; the
file cannot be removed, nor its filesystem unmounted, while the disk has
it), reads and writes it where the guest asks -- within the file's size,
never growing it -- and for a guest's flush, offered and taken by ext4,
syncs the filesystem it is on. The report counts each disk's reads, writes,
flushes and errors.
What a guest's write costs nos's ext2 (Filesystems):
one into a hole of the image flushes nos's disk once and writes the pointers
to what it filled after that, where it used to cost a flush and four FUA
writes, and every block it took a write of its indirect block besides; one
over blocks the image has flushes nothing, and writes its inode only when
the second of its mtime changed. The image is also no longer looked up by
its path for every 64 KiB. The same 48 MiB dd if=/dev/zero ... conv=fsync
in the guest under TCG, as the guest timed it, and what nos asked of its own
disk over the whole run, its boot's hundred-odd flushes included (QEMU's
info blockstats):
| the image on nos's root | before | after |
|---|---|---|
| with holes: the guest's writes allocate | 11.3 s; 32,640 writes, 134 MB, 4,716 flushes | 2.2 s; 13,530 writes, 55 MB, 317 flushes |
| whole: the guest's writes overwrite | 3.4 s; 15,534 writes, 64 MB, 1,978 flushes | 0.83 s; 14,559 writes, 60 MB, 112 flushes |
On the AX41 a Debian guest writes at 1.2 GB/s and reads at 1.6 GB/s, and
what apt-get update leaves dirty goes down in a few hundredths of a
second where it took half a minute (On real hardware).
scripts/hv-linux-test.py --disk is its gate: an ext4 image with a file in
it on nos's root, the guest mounting it, reading the file, writing 4 MiB and
a file of its own, syncing and reading back after a remount -- and then
nos's root judged by e2fsck, and the image, taken back out of it, judged
too and holding what the guest wrote. It needs a guest kernel with PCI,
legacy virtio-pci, virtio-blk and ext4.
A network
net, on hv start, gives the guest a NIC on the guests' switch, and with
it an address: 10.0.100.2 for the first port, .3 for the next, handed to its
kernel on the command line (ip=). nos is 10.0.100.1 on the same subnet, so
the guests reach it and each other, and it reaches them -- ping 10.0.100.2
from nos's shell goes out of the device whose subnet the address is on:
$ hv start /bzImage initrd=/initrd net cmdline=console=ttyS0 nolapic
$ hv start /bzImage initrd=/initrd net cmdline=console=ttyS0 nolapic
$ hv exec 0 ping -c 3 10.0.100.1 # nos
$ hv exec 0 ping -c 3 10.0.100.3 # the other guest
$ ping 10.0.100.2 # from nos
$ hv forward add 8080 1 80 # nos's 8080, to guest 1's 80
The guest's NIC is virtio-net, legacy, on the same transport as its
disks (hv::devices::net): two queues, a MAC and a link status offered,
and offloads both ways (below), legacy's header in front of every frame
either way -- ten bytes, twelve once the driver takes mergeable buffers. A
send is cut, or handed on whole, before the notify returns; what waits for
the guest is put into the buffers it has posted each time round the run
loop, one frame held by the device while it has posted too few.
Offloads, both ways. The device completes a checksum the driver leaves
to it (VIRTIO_NET_F_CSUM) and cuts TCP over IPv4 the driver hands it as
one send of up to 64 KiB into frames (HOST_TSO4; hv::devices::offload):
the headers of the send in front of each, its IP length, ID and checksum,
its sequence number and its TCP checksum its own, FIN and PSH the last
one's, CWR the first's. What the driver says about either -- where a
checksum goes, where the headers end, the segment size -- is checked against
the frame before it is used, and a send that does not hold up is dropped,
counted. Not offered: ECN on a cut send, IPv6, UDP; and a segment under 256
bytes is refused, so that one notify is 256 frames at most and not 65,000
that a guest could ask for at no cost to itself.
Between two guests, the switch does neither where it need not. A guest
whose driver takes checksums left to finish (GUEST_CSUM) is handed a
frame its sender left so as it was sent, the header saying where -- Linux
takes it as checked, as it does what comes over its own loopback; one that
takes sends whole too (GUEST_TSO4, with mergeable buffers to spread one
over, MRG_RXBUF) is handed a send of up to 64 KiB as its sender made it,
the header saying the segment size to cut it by should it be forwarded.
Everyone else -- nos's stack through hv0, NAT, a guest whose driver takes
neither -- is handed whole frames, finished and cut on the way out of the
switch, as before. The device tells the switch what its driver takes when
it says so; a frame put in before a driver took other features is the
device's to finish, and a send its driver no longer takes whole, to drop.
A port keeps room for eight sends of 64 KiB beside its frames, taken when
it is claimed.
nooffload on hv boot or hv start offers the device without any of
them. On the EX44 two guests of two CPUs, one serving a file of 100 MB five
times over with BusyBox's httpd -- sendfile, sends of 64 KiB -- and the
other fetching it: 0.47 s with them, 1.63-1.68 without, 1.1 GB/s against
320 MB/s -- the fetching guest took 8,500 sends whole where it had taken
362,000 frames. With the sending side's alone, the sends cut in the switch,
it was 1.11 s. A sender whose TCP hands the device a segment at a time,
dd | nc, is 7% slower with them: 4.45 s against 4.15, five runs each.
Offered one at a time, most of that is the sending side's checksum (4.34 s;
TSO adds nothing to it, and mergeable buffers alone cost nothing, 4.16),
with the exits and the guest's IPIs alike either way: a Linux driver that
leaves its checksums to the device gathers each frame from pages too --
scatter-gather comes on with them -- three or four descriptors a frame
where it was two, and every descriptor and buffer the device reads is a
guest page mapped and unmapped on the host, one access at a time: the
likeliest cost, and the rings' pages mapped once took most of it away: 2.83
s against 2.72, the two within their spread (Its memory). A checksum the device finishes it sums eight
bytes at a time, since two bytes at a time made that 3-5% slower than the
guest's own, taken as it copies. hv-fuzz's offload target holds every
send, well made and not, to a model of its own, frame by frame; two mutants
-- PSH on every frame, one IP ID for all -- each failed within three inputs.
Its netrx target holds what goes into the driver's buffers to another,
and switch where each send and frame goes, whole or finished; six mutants
-- the buffer count one too many, a partial checksum not finished for a
driver that takes none, a send whole to one that takes none, the switch not
told what a driver takes, a partial checksum or a whole send handed to
every port -- each failed within 17 inputs.
nos's end is a virtual NIC, hv0, in its own stack (net/src/vnic.rs).
A module cannot register a NIC -- there is no C name for a driver to be bound
by -- so the device is registered from inside the image, the first time a
module asks for it by name, and stays; the module trades frames with it
through kcore::vnic. What the stack sends out of hv0 goes to the sink the
module attached, from the transmit path with interrupts off, and what the
module hands in arrives as if received, on the next receive pass. A detach
waits out any call of the sink still running, so the module can go after --
the UDP listeners' way.
The switch is the module's (modules/hv/src/net.rs). A guest's NIC is a
port, and a port's number is its address and its MAC (02:00:00:00:64:NN), so
frames go by their destination MAC with nothing learned: to a port, to hv0,
or to everyone for a broadcast or a MAC no port has. A port's inbox holds
256 frames, the guest's receive ring's worth, and is filled from any CPU --
hv0's sink among them, interrupts off -- so it is a spin lock with
interrupts off over storage taken when the port is first claimed, and kept
for the next VM on it; a full one drops, counted in hv list. A frame put in wakes the guest's vCPU:
a halted guest waits on its VM's event, until its timer's next edge or
something for it -- a frame, a key typed at hv attach or hv send --
whichever comes first (Event::WaitFor, the scheduler).
What the guests share is shared out. hv0's backlog -- the 256 frames
handed in and not yet taken by the stack's receive pass
(kcore::vnic::BACKLOG), which every guest's traffic to nos and through NAT
goes on -- takes from a port, between two of the stack's passes over it, no
more than its share: the backlog over the ports a VM has, so a guest alone
has all of it and one of several sending as fast as it can leaves the rest
theirs. The switch learns of a pass from the count of them the virtual NIC
keeps (Vnic::drains) and counts each port's share afresh from it. And what
a guest sends to every port -- a broadcast, a multicast, a MAC no port has
-- is a copy into every inbox and a wake or a kick of every VM: 64 at once
and 256 a second after, a token bucket a port. What either keeps out is
dropped and counted against the guest in hv list.
What a guest sends has to be its own. A frame is checked where it comes
in at a port (ingress), as a cloud's virtual switch checks it: IPv4 or ARP
-- nothing of nos's speaks anything else, and nothing the switch cannot
check goes through it -- from the port's MAC; an IPv4 packet from the
port's address; an ARP message with the port's MAC as its sender's, and
the port's address as its sender's or none yet, as a probe has. The one
IPv4 packet with no address yet is a DHCP client's, which the switch
answers itself. The rest is dropped: a frame sent as another guest counted
against the guest that sent it in hv list, one of another protocol --
IPv6, which every Linux sends at boot -- on hv0's line. Without it a guest
could take another's flows through NAT, which answers a flow at the MAC its
last packet came from -- one packet with the other's address and the flow's
ports was enough -- and tell hv0's ARP table another's address was its
own. hv-fuzz's guestnet target found that
(testing).
A guest that is running is kicked out of it. Its turn ends when its
CPU takes an interrupt, and the device model hands frames over only between
turns, so a frame for a busy guest used to wait for the host's next tick --
ten milliseconds, while at line rate the port's 64-frame inbox fills in
under one. On the AX41 a guest that apt-get updated, fetching over several
connections while it decompressed, lost some 1,400 frames that way and took
39 s over 28.5 MB. Now the switch kicks the vCPU as KVM does
(hvarch::x86::svm::Kick): the vCPU marks itself on its way in before its
last look at the inbox, a sender that finds it so marks it exiting and
interrupts its CPU (kernel_cpu_kick, straight to the interrupt controller,
from any context), and Guest::run looks at the mark once more with
interrupts off: a kick from before that point turns the entry back
(Exit::Kicked), one after it is an interrupt held pending, which ends the
guest's turn as it begins. One interrupt per entry at most, however many
frames come; hv list counts them (kicks).
Measured on the AX41 in one boot, the same Debian guest and three builds
of the module loaded in turn, a cold apt-get update (28.5 MB) and a
gigabyte from Hetzner's speed-test server each:
| build | apt-get update |
frames dropped | 1 GB |
|---|---|---|---|
| no kick, 64-frame inbox | 16 s, 10 s | 100, 44 | 52, 42 MB/s |
| kick, 64 frames | 7 s, 8 s | 437, 141 | 61, 60 MB/s |
| kick, 256 frames | 9 s, 7 s | 0, 0 | 68, 56 MB/s |
The kick halves the fetch and lifts the bulk rate by a fifth to two fifths (about 220,000 kicks for the gigabyte, one per three frames). With frames flowing that much faster the 64-frame inbox overflowed more on apt's bursts than it had without the kick, and at 256 -- the guest's own ring -- none was lost. What the gigabyte still loses (500 to 800 frames) is the guest's own ceiling: one vCPU takes each frame through a legacy virtio interrupt and the 8259's, several exits apiece, at 55 to 70 MB/s, and TCP paces to it. That is the next thing to take out: fewer exits per frame, not more room for them.
Receive interrupts, moderated. A guest that takes its frames faster than
they come takes an interrupt for every one and acknowledges every one or
two: two guests of two CPUs sending TCP to each other through the switch got
1.1 frames an interrupt, and the receiver sent 140,000-180,000 ACKs for 500
MB. So a NIC's receive interrupts are held at least 100 us apart
(Net::set_rx_moderation; rxmod=US on hv boot and hv start, 0 for
none), as a real NIC's interrupt throttling holds them: what arrives goes
into the guest's buffers at once, its interrupt waits until the time since
the last is up, and then one takes all that came; a frame after a quiet
spell is interrupted for at once. Meanwhile the switch kicks nobody for a
frame -- a kick each would hand each over alone -- unless the port's inbox
is half full (Backend::quiet_until), and the vCPU that does the devices'
work comes back at the end of the window on its own (Net::rx_due): slept
to when its guest is halted, and armed on its host CPU's timer before each
entry otherwise, one more timer edge (Time). On the EX44, the TCP
pair, three runs at each window, taken in turns:
| window | frames an interrupt | receiver's ACKs | 500 MB |
|---|---|---|---|
| none | 1.12-1.17 | 137,000-176,000 | 4.57-5.15 s (4.88) |
| 25 us | 2.6-2.8 | 140,000-168,000 | 4.47-5.22 s (4.72) |
| 50 us | 4.7-5.0 | 102,000-144,000 | 4.13-4.80 s (4.57) |
| 100 us | 9.7-10.2 | 63,000-87,000 | 3.94-4.07 s (4.01) |
| 200 us | 20.0-20.5 | 33,000-47,000 | 3.70-3.86 s (3.77) |
The cost is latency where frames come close together: a hundred small HTTP fetches between the two, each its own connection, took 0.07 s with none, 0.05-0.07 s at 50 and 100 us and 0.08-0.09 s at 200 us; a ping's round trip, 0.24-0.30 ms on average, was the same at every window. Hence 100 us. hv-fuzz holds the NIC to raising what it owes by the time it said it would look again -- which found a queue taken down owing for good, a round the run loop would have made without a pause -- the switch's wakes to a model that knows each port's window, and runs whole machines with it on and off. What it did not see is a window opened behind that vCPU's back: a driver refills its receive ring from the CPU its interrupt is taken on, and that CPU's notify delivers what waited and raises the interrupt itself -- so the first CPU, asleep with the old window's end in mind, slept to its 10 ms cap while the frames that came in the new one woke nobody. Pings between two guests had a 10 ms answer every few hundred; a notify from another CPU that moves the window now rings the first CPU, and 2,400 pings had none, 0.19 ms on average and 0.28 at most.
hv forward is how a guest is reached from outside. The guests have
addresses only on their switch; a forward listens on a port of nos's and,
for each connection, opens one to the guest's port through hv0
(kcore::tcp::TcpStream::connect, new for this) and relays the two with a
task of its own. The kernel has 64 TCP connections and a forwarded one takes
two, so a forward carries eight at once and refuses the rest. The other way,
the guests reach the world through NAT (next).
The way out: NAT, DHCP and DNS
$ hv exec 0 wget -q -O - http://10.0.2.2:8000/ # a server beyond nos
$ hv exec 0 udhcpc -i eth0 -q # the switch answers
$ nat
nat: on, hv0 (10.0.100.1) out through eth0 (10.0.2.15); 4 of 4096 mappings, ports 32768-36863
tcp 10.0.100.2:47410 -> 10.0.2.2:8000 as 32768, 7190 s left
udp 10.0.100.2:37716 -> 10.0.2.3:53 as 32771, 297 s left
out 53 back 744 mapped 4 taken from the most 0 no room 0 no next hop 0 no frame 0 refused 0 denied 0
NAT is the network layer's (net/src/nat.rs), not the module's: it is
forwarding between two of the stack's devices, and it has to look at every
packet either of them receives before the protocols do. While the switch
exists it holds NAT on (kcore::net::Nat, a guard: dropped with the switch,
it turns NAT off) from hv0 out through the device nos's default route is
on -- eth0, with the gateway its DHCP lease named. A guest's packet for an
address off its subnet, and not one of nos's, arrives on hv0 sent to its
gateway, 10.0.100.1; instead of the protocols it goes to NAT, which gives its
flow a mapping -- an external port of nos's, 32768 up -- rewrites its source
to eth0's address and that port, one hop less to live, and sends it out of
eth0 to the next hop. What eth0 receives for a mapped port, from the address
and port the flow went to and nothing else, is rewritten back and sent to the
guest; so is an ICMP error quoting one of the flow's packets, the packet it
quotes made the one the guest sent. Whatever else arrives is the stack's, as
before -- a port hv forward listens on is not NAT's unless a guest's flow
has it, which it cannot, being below 32768.
What it takes: TCP from a SYN on (a segment of a flow it has no mapping for
is dropped, not mapped mid-stream), UDP, ICMP echo; not fragments, not other
protocols, and nothing inbound that a guest did not start -- that is
hv forward's.
Where a guest may go is the world past nos's edge, not what is behind
it. NAT keeps the guests from link-local (169.254/16, where a cloud's
metadata server answers at 169.254.169.254, the host VM's credentials
behind it), the private networks (10/8, 172.16/12, 192.168/16), carrier-grade
NAT's shared space (100.64/10), where some clouds keep theirs, and the
uplink's own subnet -- the host's LAN, its neighbours -- whatever that is. A
guest's packet for any of them is not mapped: it is counted (denied) and
answered with an ICMP destination unreachable, communication
administratively prohibited, from 10.0.100.1, quoting it -- so its connect
fails at once, "No route to host", rather than waiting out its retries. The
DNS server the guests are given is theirs to ask at port 53, wherever it is
(10.0.2.3 under QEMU's user network; on a cloud, the metadata server's
address). nat allow <net> lets a network through -- the test machine,
under QEMU, is on the uplink's subnet at 10.0.2.2 -- and nat deny <net>
takes that back; up to 16, kept while NAT goes off and on, as the host's
word. nat says what is kept and what is let through. The rule is a pure
function (kept) the boot self-test checks; hv-fuzz's nat target holds
every packet to a model of its own (nothing to a kept network out, every
reject right, the list as nat allow and nat deny left it). A mapping lives while its flow is seen and RFC 5382's and
4787's times after: two hours for a TCP connection that has been answered,
four minutes before that and after a FIN, ten seconds after a reset, five
minutes for UDP, one for an echo. The checksums are updated for what changed
rather than summed again (RFC 1624), so a packet corrupted on the way stays
detectably corrupt; an ICMP error, whose whole message changes, is checked
before it is rewritten. The boot self-test (rust_net_selftest) checks every
rewrite against checksums summed from scratch, and the table's filling,
expiry and reuse.
The table is 4096 mappings and a hash of the guests' side of them, taken
whole when NAT goes on, so the datapath allocates nothing but the frame a
packet goes out in, from the pool. It is under a spin lock held for the
lookup only; the frame is built and sent with it down. When all 4096 are
live, the next flow takes one: the one used least lately of the guest that
holds the most -- its own, when the asker holds as many as any (taken from the most). Each guest's mappings are a list in the order they were last
used, in either direction, so the one to give up is found at once; a guest
that opens flows as fast as it can takes them from itself, and cannot have
every mapping there is, as it could while a full table refused every new
flow. The table keeps a share for 64 guests at once, the switch having 16
ports; a packet of a guest past them is refused (no room). It is swept for
flows past their time no sooner than the first of them can be -- the soonest
of every flow's time, kept as each is made or kept alive, since a UDP flow's
five minutes can run out under a TCP flow's two hours. The next hop's address comes from the ARP cache without waiting
-- NAT runs on the receive path, which cannot sleep: an entry past its time is
still used while it is asked for again, as Linux uses a stale neighbour, and
the gateway and an on-link DNS server are asked for when NAT goes on, so a
guest's first packet does not find them missing (no next hop counts those
that did). A request goes out at most every 100 ms whatever a guest floods.
The switch answers DHCP (modules/hv/src/dhcp.rs). A guest's DHCP
message never leaves its port: the switch answers it in place with the
port's address -- the same as ip= gives -- a day's lease, the /24 mask, nos
as the router, and nos's DNS server. Nothing is remembered, because there is
nothing to choose: a request for any other address is refused (NAK) and the
client starts again. A distribution that configures its ethernet by DHCP, as
most do, needs nothing on its command line; one that takes ip= gets the
same address from there.
The DNS server the guests are given is nos's own -- its resolver's, or
the one its lease named (kcore::net::dns_server): 10.0.2.3 under QEMU's user
network, Hetzner's resolvers on the Hetzner boxes. ip= carries it as dns0,
which the kernel shows in /proc/net/pnp and Alpine's initramfs writes to
resolv.conf, and DHCP as option 6, which systemd-resolved takes. The guests
ask it through NAT like any other server; nos runs no DNS server of its own.
scripts/hv-linux-test.py --net is the network's gate: two guests, their
addresses, pings between each guest and nos and between the guests, nos's
ping out of hv0, and a page from one guest's httpd fetched from outside
the machine through hv forward; then the way out -- dns0 in the guest's
/proc/net/pnp, a page and a megabyte (md5 compared) from a web server on the
test machine through NAT, a ping there, udhcpc given its port's address,
nos as its router and nos's DNS server, and nat and hv list saying so.
With --internet, a name looked up through the DNS server it was given.
Guests that stay up
hv boot is a guest for a set time, run from start to end inside one
command. hv start is the same guest left running: the command builds it,
hands it to a vCPU task of its own and returns, and the guest is reached from
then on by the commands above -- from the shell, over ssh or over the UDP
shell alike. They are the lifecycle stage 4's HTTP API is to put on the
network (plans/04-control-plane.md); the
commands come first because they need nothing but a shell.
$ hv start /bzImage initrd=/initrd cmdline=console=ttyS0 nolapic
hv: vm 0 started on cpu 3 -- /bzImage, 256 MiB, cmdline "console=ttyS0 nolapic"
$ hv exec 0 secs=60 id
... the rest of its boot ...
~ # id
uid=0 gid=0
~ #
$ hv exec 0 uname -r
uname -r
6.18.53
~ #
$ hv send 0 echo nos$((6*7))nos\n
hv: vm 0: 20 bytes queued
$ hv wait 0 nos42nos
hv: vm 0 printed "nos42nos", 58 ms in
$ hv list
vm 0 running cpu 3 256 MiB 1 s exits 18237 irq 284 hlt 92 /bzImage
$ hv stop 0
hv: vm 0 stopped -- on request -- nothing in it listens to its power button
stopped on request, after 1925 ms
exits ...
(From the gate's run under TCG, where the guest is at its shell a second after it starts.)
A VM is a task and a shared record (modules/hv/src/vms.rs). The guest
-- its memory, its vCPU, its devices -- belongs to the vCPU task (hv/vm<N>)
alone, and is freed there when the guest stops. What the task and the
commands share is the console, its last 64 KiB in a ring; the bytes waiting to
be typed at it, 4 KiB at most; a stop flag; and the loop's counters, which it
publishes at every halt and every 4096 exits. The ring and the input queue
are taken whole when the VM is made, so nothing on the guest's datapath
allocates. Each vCPU is bound to one CPU the extension is on for: cpu= if
given, else the one with fewest running VMs -- the highest of a tie, so the
boot CPU's own work is the last to share its CPU with a guest.
hv exec knows where its answer starts. It types the line and a newline
and waits for a prompt -- but only one printed after the line's last byte was
handed to the guest's UART: the loop notes where the console was at that
moment, and only what comes after it can end the wait. So a line typed at a
guest that is still booting waits for its shell and comes back with its
answer, not with the first prompt the shell printed before it read the line;
and a line the guest never reads says so rather than timing out in silence.
It prints everything the guest printed since the line was typed.
What reaches a terminal is made safe for one. hv console, hv exec and
the report print the guest's console with escape sequences taken out whole,
carriage returns dropped and anything above ASCII shown as ?: a guest
shell's ESC [ 6 n, printed to the terminal a person is reading nos on,
would make that terminal type its answer into whatever reads it next.
The cursor query is answered with the cursor. BusyBox's line editor asks
where the cursor is after it prints its prompt, and takes the column it is
told as the prompt's end. It was once told column 80 whatever the prompt, and
wrapped what was typed at the edge it thought it had reached -- id came back
as i, a newline, d. The UART now follows the guest's output as a terminal
would -- the column each byte leaves the cursor at, escape sequences moving
it nowhere -- and answers with that, from a buffer of its own: a guest that
asks and never reads the answer costs the host no more than one that asks
once, where the answer used to be appended to a queue without bound.
Stopping is a question, then a flag and a join. hv stop takes the VM
off the list first and presses its power button, and a guest that shuts
down when it is pressed is given secs to (ACPI); the one above,
a kernel without ACPI, never hears the button, and is stopped at once. Then
the flag: the loop checks it before every entry, and a halted guest's task
sleeps at most 10 ms at a time, so the stop comes within a tick or so, and
the command joins the task after, with no lock held while it waits. A guest
that stops by itself -- a triple fault, a HLT with interrupts off, a reset, a
power-off -- stays on the list as stopped, with its reason, until hv stop takes it off.
A VM's task lives as long as the VM. When its guest stops, the vCPU task
gives the guest's memory back and parks on the VM's event, for hv restart --
which builds the guest again from its files and boots it, a stopped guest or
a running one, as a reset button would -- or hv stop, which ends it. With
restart, a guest that resets itself or triple-faults is booted again by the
task straight away: a reboot. Five of those within a minute are taken for a
loop -- a guest that panics at boot with panic=1 would otherwise have its
CPU for good -- and it is left stopped, reset 6 times in 60 s, left stopped. hv list counts the restarts; the console runs on across them,
one log of every boot; a line hv exec typed at a boot that ended says so
rather than waiting for a prompt that will not come.
hv attach is the console, live. From an SSH session with a terminal
(ssh -t <host> hv attach 0) it shows the last kilobyte of the console, then
what the guest prints as it prints it, and types what is pressed as it is
pressed -- the guest's line editor, ^C and all -- until ^] detaches, the guest
stops, or the session ends. What it passes to the terminal is the guest's
output as it is, colours and cursor movement included, but for what a
terminal would answer or act on: a status or attributes query (ESC [ 6 n,
ESC [ c), whose answer would be typed into the guest after the UART's own,
and the string sequences (ESC ] and its kind), which set a terminal's
title, clipboard or palette -- nothing a guest should reach on the person's
machine. It takes a session that can type: a command reads what is typed
through its output (Output::read_input), and only an SSH session's has
anyone behind it -- from the console, the UDP shell or /etc/rc it says so
and returns. Keys typed at an attached guest are not held back for a prompt
the way a script's line is: the person decides when to type.
A guest that reboots stops, and says so. Linux reboots -- a reboot, a
panic with panic=N -- by the reset register its ACPI tables name, when it
has them (the chipset's reset control, 0x06 to port 0xCF9); then by
pulsing the 8042's reset line (0xFE to port 0x64); then by the chipset's
reset control where it knows of one; then by a triple fault. Nothing here emulates an 8042 or a
chipset, and on the AX41 the first guest that rebooted spun at 100% of its
CPU for as long as anyone let it -- 1.3 million reads of the 8042's status in
48 seconds, waiting for a controller that is not there. Both writes now stop
the VM (Stop::Reset), the guest asked for a reset, 0xfe to port 0x64 (the 8042's reset line); its ports still read as all ones, as on a PC without
one, so Linux spends its kb_wait polling them first -- 65536 reads, a
couple of seconds -- and then asks. What a reset should become -- the guest
booted again -- is the control plane's to decide.
Idle guests cost their CPU next to nothing, sharing one or not. A halted
vCPU sleeps until its guest's next timer edge, and Sleep() blocks
(the scheduler): it used to be a loop
around Schedule(), and two halted guests on one CPU of the AX41 handed it to
each other without end -- 46% of it each, the idle task never running -- where
one alone took 0.1%. Blocking, the same two take 0.1% each, and sixteen idle
guests on the AX41's twelve CPUs -- four of them carrying two -- keep the
machine 1.6% busy, 0.1% a vCPU.
The extension is not pulled from under a guest. hv off refuses while a
started guest runs on one of the CPUs it names. That is a courtesy and not
what keeps the CPU safe: every entry checks, with interrupts off until the
guest is running, that the extension is on and its save area is the one this
module gave that CPU, so a guest racing an hv off finds it off and stops,
not entered -- on AMD-V; on Intel the CPU itself refuses to go off while a
guest's VMCS is current on it (above),
so an hv boot or hv run the courtesy check does not see keeps its CPU
too. rmmod hv stops every guest before it turns the extension off -- where
it is, without pressing its power button; hv stop all before it shuts them
down -- the guests first, an hv boot under way among them, so that an hv exec
or hv wait waiting on one returns; then the command, whose unregistration
waits out every call still running (an hv start still reading its files
finds the module going when it comes to add its guest, and stops it itself);
and only then the extension, on every CPU.
The gate is scripts/hv-linux-test.py with --initrd: two VMs started side
by side and one stopped mid-boot, a line typed at the other before its shell
is up and another at its prompt, send, wait, console, the refused
hv off, and an rmmod that stops the guest itself and leaves nothing on for
the next load.
ACPI
A PC's firmware leaves its OS tables that say what the machine is, and a
kernel with ACPI reads them before anything else: where its CPUs are, how to
turn the machine off and reset it, where its PCI bus is and which interrupt
each device has. Every Linux guest is given them (hv::acpi), and the fixed
hardware they describe (hv::devices::pm). A kernel without ACPI, or told
acpi=off, finds nothing of it in its way and boots as it always did.
[ 0.039122] ACPI: RSDP 0x00000000000E0000 000024 (v02 NOS )
[ 0.042362] ACPI: XSDT 0x00000000000E0240 00002C (v01 NOS HV 00000001 NOS 00000001)
[ 0.047220] ACPI: FACP 0x00000000000E0120 000114 (v06 NOS HV 00000001 NOS 00000001)
[ 0.051699] ACPI: DSDT 0x00000000000E0080 00009B (v02 NOS HV 00000001 NOS 00000001)
[ 0.056005] ACPI: FACS 0x00000000000E0040 000040
[ 0.102725] ACPI: PM-Timer IO Port: 0x608
...
[ 0.545105] ACPI: Interpreter enabled
[ 0.545535] ACPI: PM: (supports S0 S5)
[ 0.549186] ACPI: Using PIC for interrupt routing
[ 0.565105] ACPI: PCI Root Bridge [PCI0] (domain 0000 [bus 00-ff])
[ 0.577561] acpi PNP0A03:00: fail to add MMCONFIG information, can't access extended configuration space under this bridge
[ 0.589327] pci_bus 0000:00: root bus resource [io 0x0000-0x0cf7 window]
[ 0.593165] pci_bus 0000:00: root bus resource [io 0x0d00-0xffff window]
[ 0.597229] pci_bus 0000:00: root bus resource [bus 00-ff]
[ 0.631608] PCI: Using ACPI for IRQ routing
[ 0.657669] clocksource: acpi_pm: mask: 0xffffff max_cycles: 0xffffff, max_idle_ns: 2085701024 ns
[ 0.769271] input: Power Button as /devices/LNXSYSTM:00/LNXPWRBN:00/input/input0
[ 0.773949] ACPI: button: Power Button [PWRF]
(The gate's guest of one CPU under TCG: a Linux 6.18 with ACPI, its power button and evdev.)
- The tables go in the BIOS area from 0xE0000, which the e820 map
reserves: the RSDP first, where a kernel scanning for it finds it, and the
zero page's
acpi_rsdp_addrpointing at it for one that looks there. An XSDT names the FADT and, for a guest whose CPUs have local APICs, a MADT; the FADT names the FACS and the DSDT. They are ACPI 6.3's, laid out with their checksums into a buffer and copied into guest memory as the MP table is -- nothing of the guest's is kept. - The fixed hardware is all on ports (0x600-0x60B, as an ICH lays it
out): the PM1 event block's status and enable registers, the PM1 control
register, and the PM timer -- 24 bits at 3.579545 MHz, counting the host's
time from when the guest was made. The host keeps its time by its TSC --
under KVM by kvmclock, the TSC scaled -- and the guest reads that TSC and
is told its rate, so the two agree; Linux, which checks its TSC against
the PM timer every half second once it has one, finds them agreeing. The SCI is the 8259's IRQ 9, and a level: requested while an
enabled event is set, requested again if one still is when the guest ends
the interrupt, withdrawn when none is. There is no SMI command port -- the
machine is in ACPI mode from the start, SCI_EN reading set -- and no GPE
block. The edge/level control registers beside the 8259s (0x4D0) keep what
is written to them, and start with IRQ 9 level-triggered: what a kernel
sets them to, and, finding them so, need not warn that it did. The reset
register is the chipset's 0xCF9, trapped already: a kernel with ACPI
reboots through it first,
the guest asked for a reset, 0x06 to port 0xcf9. - The MADT lists a local APIC for each CPU and NMI on every LINT1 -- and, for a guest given one, the IO-APIC, with the two overrides a PC's has: the timer's IRQ 0 on its pin 2, and the SCI a level, active high (The IO-APIC). The MP table is still there: a kernel with ACPI finds its CPUs in the MADT, one without in the MP table, and both are told the same.
- The DSDT is the one table in AML, and small:
\_S5, and the PCI host bridge. The bridge has to be there. A Linux with ACPI finds PCI through the namespace alone and never probes the bus --pci_legacy_initis not even called -- so without one the guest would have no disks and no NICs. Its_CRSgives bus 0-255, the configuration ports, the I/O either side of them and the window its functions' MSI-X pages are in (a BAR outside every window is one an OS moves, and these are fixed); its_PRTroutes each device's INTA to the 8259 IRQ its interrupt line register names. As ACPICA'siasldisassembles it, for a guest of several CPUs with a NIC and two disks:
Name (_S5, Package (0x04) { 0x05, 0x05, Zero, Zero })
Scope (\_SB)
{
Device (PCI0)
{
Name (_HID, EisaId ("PNP0A03") /* PCI Bus */)
Name (_UID, Zero)
Name (_CRS, ResourceTemplate ()
{
WordBusNumber (ResourceProducer, MinFixed, MaxFixed, PosDecode,
0x0000, 0x0000, 0x00FF, 0x0000, 0x0100,,,)
IO (Decode16, 0x0CF8, 0x0CF8, 0x01, 0x08, )
WordIO (ResourceProducer, MinFixed, MaxFixed, PosDecode, EntireRange,
0x0000, 0x0000, 0x0CF7, 0x0000, 0x0CF8,,, , TypeStatic, DenseTranslation)
WordIO (ResourceProducer, MinFixed, MaxFixed, PosDecode, EntireRange,
0x0000, 0x0D00, 0xFFFF, 0x0000, 0xF300,,, , TypeStatic, DenseTranslation)
DWordMemory (ResourceProducer, PosDecode, MinFixed, MaxFixed, NonCacheable, ReadWrite,
0x00000000, 0xFE000000, 0xFE007FFF, 0x00000000, 0x00008000,,, , AddressRangeMemory, TypeStatic)
})
Name (_PRT, Package (0x03)
{
Package (0x04) { 0x0001FFFF, Zero, Zero, 0x0A },
Package (0x04) { 0x0002FFFF, Zero, Zero, 0x0B },
Package (0x04) { 0x0003FFFF, Zero, Zero, 0x0B }
})
}
}
What is left out is MMIO this does not emulate yet (MMIO) -- the HPET and PCIe's ECAM, whose absence Linux names in the line above; the IO-APIC is there for a guest given one -- and what a guest does without: processor objects (there are no C- or P-states to describe), the ISA devices (a kernel finds a PC's serial port, RTC and timer where they always are), GPEs, and any sleep state but S5. A guest of one CPU gets no MADT: it has no APIC.
hv stop asks first. It presses the guest's power button -- sets the
fixed button's status, and raises the SCI if the guest's OS has enabled the
event -- and gives the guest secs (30 unless told) to turn itself off; one
that does ends the guest powered itself off, S5 by its ACPI PM1 control register. A kernel hears the button as a key, KEY_POWER on its input
device, and what answers it is userspace's: systemd-logind shuts a
distribution down, BusyBox's acpid runs /etc/acpi/PWRF/00000080. A guest
that hears it and does nothing is stopped when its time is up, its power button went unanswered for 30 s. One whose OS never enabled the event -- no
ACPI, acpi=off, not booted that far -- cannot hear it, and the first CPU's
loop, which takes the press from the command, says so: it is stopped at
once, nothing in it listens to its power button. secs=0 stops a guest
without asking, and hv stop all presses every button at once and waits for
the guests together. rmmod does not ask: an unload is no place to wait on
guests -- a module's exit has five seconds before rmmod goes on without
it -- so hv stop all first is the way to shut guests down before one.
A power-off is a write to a port, as a reset is: SLP_EN with S5's sleep
type in PM1a's control register. ACPICA writes the type first and the enable
bit second, and the second stops the VM where the guest wrote it --
Stop::PowerOff, which is not a reset: a guest started with restart that
turns itself off stays off. A sleep type the DSDT does not offer is left
alone, and the report says the guest asked for it.
The gate is scripts/hv-linux-test.py --acpi, with a kernel built with
ACPI, its button and evdev: the boot's lines above and nothing of a firmware
bug, a TSC the watchdog marked unstable or a PM timer that failed its checks;
reboots through 0xCF9; the SCI on IRQ 9 and the PM timer a clocksource;
hv stop answered by acpid with a power-off; and a guest that hears the
button and does nothing, stopped when its time is up. The tables themselves
were checked with iasl as they were written. scripts/hv-distro-test.py
boots Alpine and Debian on their ACPI, and has hv stop shut Debian down --
or, with --acpi-off, boots them as before.
A distribution
The guests above run a kernel built for the purpose, with a BusyBox
initramfs. A distribution's is built for every machine instead -- SMP, ACPI,
KASLR, high-resolution timers, virtio as modules its initramfs loads -- and
Alpine's virt ISO boots under hv start as it ships: its kernel and
initramfs taken out of the ISO, and the ISO itself the guest's read-only
disk.
$ hv start /alpine/vmlinuz-virt mem=512 initrd=/alpine/initramfs-virt disk=/alpine/alpine.iso:ro net restart cmdline=console=ttyS0 nolapic acpi=off modules=loop,squashfs,sd-mod,usb-storage
$ hv wait 0 secs=600 login:
hv: vm 0 printed "login:", 36004 ms in
$ hv send 0 root\n
$ hv exec 0 cat /etc/alpine-release; uname -r
3.24.2
6.18.52-0-virt
localhost:~#
$ hv exec 0 date
Wed Sep 23 21:32:50 UTC 2026
(Under TCG, twice emulated: 36 s to the login prompt -- recorded before the
machine had ACPI, when a distribution was booted acpi=off.) Its initramfs
loads virtio_blk, finds the ISO on vda, mounts it and installs the base
system from the ISO's packages into a tmpfs, and OpenRC brings it up to a
getty on ttyS0. nolapic makes it a PC of one CPU with no local APIC,
taking its interrupts from the 8259s. With acpi=off, as above, it finds
its PCI devices through configuration mechanism 1 and each virtio device's
interrupt line in its configuration space; without, as the gate boots it
now, it takes both from its ACPI tables -- the DSDT's host bridge, and its
_PRT (ACPI) -- and is at its login prompt 14 s after the VM
starts, nested under KVM on the i5-13500. On the switch (net) its
initramfs configures eth0 from the ip= the VM is given, and writes the
dns0 there to resolv.conf; through NAT it reaches what nos reaches --
apk update from Alpine's own mirror, the name looked up through nos's DNS
server -- and apk add openssh-server installs from the ISO, its own sshd
reached from outside nos through hv forward. reboot resets it -- through
the reset register its FADT names, or with acpi=off the keyboard
controller -- and with restart it boots again.
What it took that the purpose-built kernel did not:
- The PIT's one-shot modes. A kernel with high-resolution timers and a
clocksource good enough for them -- the TSC, which the guest calibrates
against the PIT by itself on a real CPU -- moves its tick from the PIT's
periodic mode 2 to one-shot mode 4, a count at a time. Channel 0 raised
IRQ0 only in the periodic modes, so the guest's timers would have stopped
at the switch. Under TCG an exit costs more than the calibration loop
allows and the guest stays on jiffies and a periodic tick; forced onto the
TSC (
tsc_early_khz=... tsc=reliable) it runs its tick through mode 4, andsleep 2takes 2.03 s. - A clock. The RTC answered a fixed 2026-01-01, and OpenRC took every file of the ISO for one from the future, and said so at every service it started. It answers the host's wall clock now, read when the guest is made and counted on by the host's clock since boot; what the guest writes to the time registers does not stick.
- One CPU of its own. The topology CPUID gave was the host's: the guest took the APIC ID of the host CPU its vCPU ran on for its own ("APIC ID mismatch") and listed SVM's features for an extension it is not given.
- Typing at a login prompt.
hv execandinput=wait for a shell's prompt -- BusyBox's line editor asking where the cursor is -- and a getty asks for nothing, and throws away what was typed before it printed its prompt.hv sendtypes now, as at a terminal, and from then on for that boot whatexectypes goes in as it is typed too;hv waitis what finds the moment. hv waitin the current boot. It searched the whole console, so after a reboot it found the last boot'slogin:; it looks from where the current boot began.disk=path:ro: virtio's read-only feature, and a write the device refuses itself before any reaches the file.- An
ip=that names no device. A kernel toldeth0waits twelve seconds for it to appear (DEVICE_WAIT_MAX), and a distribution's NIC driver is a module its initramfs loads after that; with the field empty the kernel finds no device at once and leavesip=to the initramfs, whose own parser takes the first interface that comes up.
Debian
Debian 13's nocloud cloud image boots the same way: its kernel
(6.12.107+deb13-amd64, the generic one) and initrd read out of the image's
own /boot, and the image itself -- 3 GiB, a raw file on nos's root -- the
guest's disk, whose root partition its initramfs-tools mounts read-write:
$ hv start /debian/vmlinuz mem=768 initrd=/debian/initrd disk=/debian/debian.raw net restart cmdline=root=/dev/vda1 ro console=ttyS0 nolapic acpi=off systemd.set_credential=passwd.plaintext-password.root:nos systemd.set_credential=firstboot.locale:C.UTF-8 systemd.set_credential=firstboot.keymap:us systemd.set_credential=firstboot.timezone:UTC
$ hv wait 0 secs=600 login:
hv: vm 0 printed "login:", 38931 ms in
$ hv send 0 root\n
$ hv send 0 nos\n
$ hv exec 0 cat /etc/debian_version; uname -r; systemctl is-system-running
13.7
6.12.107+deb13-amd64
running
Its root is locked (!unprovisioned) until systemd-firstboot sets it, and
firstboot asks at the console for the root password, the locale, the keymap
and the timezone -- a typed line that is not one is "Invalid data" -- so
systemd.set_credential= on the kernel command line gives it all four, as
systemd provisions a machine nobody sits at. systemd then reaches running
with no unit failed, 38 s after the VM starts under TCG, and a file written
to its root is still there after a reboot. Its networkd has no .network
for an ethernet interface -- the image expects cloud-init or the like to
write one -- and one more credential is that file:
systemd.set_credential_binary=network.network.50-nos:<base64> of
[Match] Type=ether / [Network] DHCP=ipv4, which systemd-network-generator
puts in /run/systemd/network. networkd then asks the switch's DHCP server,
takes its port's address, nos as its router and nos's DNS server for
systemd-resolved, and the guest reaches the world through NAT. That command
line runs past the 255 characters nos's /etc/rc allowed a line; it allows
1023.
After typing reboot, a script cannot wait for the next login: with a
plain hv wait: the boot going down still has the last one on its console,
and systemd's reboot gives the shell its prompt back before the system
goes. hv wait 0 boot=1 login: waits for the VM's first restart, and then
for the text in the boot after it.
(The transcript above is from before the machine had ACPI, booted acpi=off
under TCG.) On its ACPI, as the gate boots it now, systemd-logind hears its
power button, and hv stop shuts it down -- its services stopped, its root
unmounted -- and it turns itself off, nested under KVM on the i5-13500:
$ hv stop 0 secs=300
hv: vm 0 stopped -- the guest powered itself off, S5 by its ACPI PM1 control register, at 0xffffffff8f1d5ae3
stopped the guest powered itself off, S5 by its ACPI PM1 control register, at 0xffffffff8f1d5ae3, after 7624 ms
...
acpi taken by the guest's OS; 2 SCIs, the power button pressed 1 times
vda 3072 MiB: 2499 reads (75589 KiB), 215 writes (3805 KiB), 36 flushes, 0 errors
-- 7.6 s for that whole boot, from its start through the login, the gate's commands and the shutdown.
And one thing in the kernel. Rebuilding the rebooted guest under TCG held
the page allocator's lock for more than ten seconds, with every other CPU
idle, and the watchdog panicked the machine: each page was zeroed under the
lock, and QEMU throws away its translations of a page that held a guest's
code as the page is written -- a guest that had run for half a minute took
36 s to rebuild. The zeroing is done off the lock now (PageTable::AllocPage).
The gate is scripts/hv-distro-test.py --iso alpine-virt-*-x86_64.iso [--debian debian-*-nocloud-amd64.raw] [--internet] (Tests and
gates): manual, since the images are downloads; --internet
adds what needs the test machine's own way out, a name looked up and a
mirror fetched from.
More than one CPU
cpus=N on hv boot and hv start gives a guest N CPUs, up to 16. Each is
a task of its own on a host CPU of its own, and where they go is
hv::place's to say, over hv::topology -- which reads a CPU's core and
cache from its APIC ID and CPUID, and on a hybrid part asks each CPU which
kind of core it is (leaf 0x1A) -- by the rule hv cores sets. Two CPUs of
one guest never share a host CPU -- a guest CPU spinning while it waits for
another would hold the host CPU the other needs to finish -- so a guest has
at most as many CPUs as hv on has turned the extension on for.
A guest's cores are its own (hv cores own, the default). Two CPUs on
one core's two threads share its execution units, its caches and its
buffers -- what every SMT side channel stands on, MDS, L1TF and port
contention among them -- so a guest is given whole cores no other guest has
a CPU on, its vCPUs or the task that serves its disks, and its vCPUs fill
their threads: a vCPU a thread, as a cloud sells them, where a vCPU a core
would leave a thread idle beside each. On a hybrid part all of a guest's
vCPUs are of one kind of core where one kind has room for all of them --
the faster, or the kind of the CPU cpu= names -- since a guest's kernel
takes its CPUs for equals, and an EX44 E-core runs a busy loop 16% slower
than a P-core at one clock and 58% slower at their turbos; both kinds only
where neither has room alone. The first where cpu= says, or on the free
core with the most threads -- CPU 0's last, where the boot CPU's own work
runs, and the higher threads of a core first, CPU 0 the last of all -- then
the threads left on it, then on cores sharing its last-level cache, the
faster kind first. Its disks' task goes on a spare thread of its own cores,
then on a core no guest has, of the slower kind where its cache has one --
the task waits on the disk, and a fast core is a guest's; it then holds that
core as well -- then beside a vCPU of its own. A guest
that would need a core another has -- cpu= on one, or more CPUs than the
free cores have threads -- is refused, saying so. The place and the hold on
it are taken under one lock (modules/hv/src/cores.rs), so two guests
started at once from two shells cannot both be given a core; an hv start
guest holds its cores until hv stop -- parked too, for the restart that
runs it there again -- and an hv boot guest until its run is over. hv cores says the rule and who holds which CPUs; hv list says where each
guest's disks are served.
What it costs: a guest's own CPUs share their core. On one core's two
threads (13 and 12 on the AX41) two busy loops in a Debian guest took 14.7 s
where one alone took 9.7; on two cores of one CCX (13 and 11) they took 9.7,
and four loops on a guest of four CPUs (13, 11, 9 and 5) 10.0 -- the
placement hv cores shared gives: the first on the least loaded core, of
the faster kind where loads tie, each other on a core none of the guest's
CPUs is on yet where there is one, sharing the first's last-level cache
where it can, of the first's kind, the least loaded of those, whoever else
is there. That is for a machine whose guests are all one's own.
$ hv boot /bzImage mem=256 secs=60 cpus=2 initrd=/initrd cmdline=earlyprintk=serial,ttyS0,115200 console=ttyS0 no_timer_check
--- ttyS0 ---
[ 0.000000] Linux version 6.18.54 ...
[ 0.000000] x2apic: enabled by BIOS, switching to x2apic ops
[ 0.000000] MPTABLE: APIC at: 0xFEE00000
...
[ 0.004000] APIC: Switched APIC routing to: cluster x2apic
...
[ 0.388000] smp: Bringing up secondary CPUs ...
[ 0.388000] smpboot: x86: Booting SMP configuration:
[ 0.412000] smp: Brought up 1 node, 2 CPUs
...
nos-guest: init is up, / on ramfs, BusyBox v1.30.1
nos-guest: 2 cpus, online 0-1
--- end ttyS0 ---
stopped by the host, its time up, after 60006 ms
...
apic 30963 interrupts from the local APICs, 29663 of their timers; 1305 IPIs sent, 1300 taken
cpus 2, 1 started by the guest (1 INITs, 1 start-up IPIs taken)
cpu 0 60310 exits, 15477 apic irq, 679 ipi sent, 6593 hlt, slept 97% -- halted, on host cpu 3
cpu 1 31923 exits, 15486 apic irq, 626 ipi sent, 6629 hlt, slept 97% -- halted, on host cpu 2
$ hv start /bzImage mem=256 cpus=2 initrd=/initrd cmdline=earlyprintk=serial,ttyS0,115200 console=ttyS0 no_timer_check
hv: vm 0 started on cpus 3,2 -- /bzImage, 256 MiB, 2 cpus, cmdline "earlyprintk=serial,ttyS0,115200 console=ttyS0 no_timer_check"
$ hv list
vm 0 running cpus 3,2 256 MiB 0 s exits 0 irq 0 hlt 0 kicks 0 /bzImage
$ hv exec 0 grep -e LOC -e RES -e CAL /proc/interrupts
LOC: 266 223 Local timer interrupts
RES: 11 15 Rescheduling interrupts
CAL: 125 144 Function call interrupts
(A Linux 6.18 built for the gate -- hv-linux-test.py --cpus 2 on the
AX41, nested under KVM; its output trimmed. noapic goes on the command
line by itself, below.)
A guest of one CPU is given exactly the machine it always was -- no APIC in
CPUID, no MP table, nolapic on its default command line -- so a kernel that
booted before boots the same. What a guest of more is given, and how its CPUs
run, is below. hv::run holds the loop and the platform, hv::lapic the
local APIC, hv::smp the mailboxes and doorbells, and hv::linux the MP
table.
The machine it sees
A local APIC for every CPU, as an x2APIC unless told (hv::lapic).
The x2APIC is its registers as MSRs 0x800-0x8FF, and an MSR access is an
exit whose register and value the CPU hands over; the xAPIC is the same
registers as a page of MMIO at 0xFEE00000, each access a nested fault whose
instruction has to be decoded (MMIO). So the APIC comes out of
reset with x2APIC mode already on, IA32_APIC_BASE saying EN and EXTD, as
firmware leaves it on a machine with more than 255 CPUs, and a kernel that
keeps it never touches the page -- or, with xapic, in xAPIC mode, as most
PCs' firmware leaves it. A kernel may move between the modes as the
architecture allows -- off and back, as nox2apic has Linux do -- and the
APIC answers in the mode it is in. The nested table never maps the page: a
guest that reaches for it in x2APIC mode, when it is none of the APIC's, is
stopped and told why rather than answered wrongly. The boot CPU's APIC starts in virtual-wire mode, with
the spurious-vector register enabled, LINT0 ExtINT and LINT1 NMI, as
firmware hands it over. What the model has is the register file and what
each register does to the others: the ID and the logical ID made from it
(x2APIC cluster mode), the task and processor priorities, the in-service,
request and trigger-mode registers, EOI -- a level-triggered interrupt's
going back to the IO-APIC it came from (The IO-APIC) --
the error register, the local vector table, the interrupt command
register, and the timer (below). A write of the spurious
register that turns the APIC off masks every LVT entry, as the silicon does.
An MP table (hv::linux, spec 1.4) in the last kilobyte of low
memory, at 0x9FC00, where the e820 map has ended RAM and where Linux looks:
a processor entry for each CPU, the first marked the boot CPU; the PCI bus
and an ISA bus; an IO-APIC entry, marked usable only for a guest given
one; and the interrupt assignments -- the 8259's output on the IO-APIC's
pin 0 and on LINT0 of the first CPU, each ISA IRQ but the cascade on the
IO-APIC's pin of its own number but the timer's, which is on pin 2, each
PCI function's INTA on the pin of its line, level-triggered and active low
(the ISA entry of that line left out), and NMI to LINT1 of every CPU.
Without the assignments Linux complains of a BIOS bug and makes up its
own. The MP table is the least a kernel finds
its CPUs by; one with ACPI finds them in the MADT (ACPI), which
says the same, and takes the MP table only for what the MADT leaves out.
No IO-APIC, unless asked (The IO-APIC). The 8259
pair stays wired to the first CPU's LINT0 in virtual-wire mode, so every
device interrupt -- the PIT's tick, the serial port's, the disks' and the
NICs' -- is the first CPU's, and its task does the devices' work between
its guest's turns, as it did for a guest of one CPU. noapic is added to
the command line when it is not there already. Without it, x86-64 Linux
looks for an IO-APIC whatever the table says -- it ignores the table's
pic_mode -- and on the way setup_local_APIC masks LINT0 on the boot
CPU, and the 8259 is cut off from the only CPU it reaches. With ioapic
the machine has one, and the command line is left as it is.
CPUID. Leaf 1 says the local APIC is there, and the x2APIC, and gives each CPU its APIC ID in EBX[31:24]; leaf 6 says the timer keeps running in deep C-states (ARAT), which is true of one counted off the host's clock; and where the host's own leaf 0x15 gives its TSC's frequency -- an Intel host's does -- the guest's gives the timer's clock, a crystal of 1 GHz, and the TSC's ratio to it, so a kernel need not measure either. Passed through, the leaf would name the host's crystal, tens of MHz, and a kernel that took the timer's rate from it would have its timer fire tens of times too soon; where the host has no frequency to give, the leaf is blank and the kernel measures. The topology leaves stay blank, as for one CPU. Of leaf 0x80000007 only the invariant TSC is left: its RAS bits (SUCCOR among them) sent Linux's machine-check code to set up AMD's deferred-error interrupt through the APIC's extended LVT -- MSR 0x852, a register this APIC has not got -- and the #GP came back as an "unchecked MSR access" and two firmware bugs in its log. A guest on an AMD host reads DE_CFG's LFENCE-serializing bit set, as KVM answers it, and HWCR's TscFreqSel set, as every AMD CPU since family 10h has it; read as zero, that one was a "TSC doesn't count with P0 frequency!" firmware bug of its own.
What it needs of the guest's kernel: Linux 6.6 or later. Before 6.6, a
kernel given an MP table and an x2APIC already on reads its APIC ID through
the xAPIC's page before it switches to x2APIC operations
(register_lapic_address -> read_apic_id -> native_apic_mem_read), and
faults on the page the guest does not have -- a 5.15 kernel oopses there
before its first line of SMP output. And CONFIG_SMP, CONFIG_X86_X2APIC
(which needs CONFIG_IRQ_REMAP or CONFIG_HYPERVISOR_GUEST) and
CONFIG_X86_MPPARSE. A distribution's kernel has all of them: Alpine's
virt 6.18 brings up two CPUs as it ships. An older kernel still boots on
one CPU.
Starting the others
The first CPU starts at the kernel's 64-bit entry, as a guest of one CPU does. The others start the way a PC's do: waiting for a start-up IPI. INIT puts a CPU back in that state -- its registers the reset state's, anything it was about to be injected dropped, its local APIC reset -- and a start-up IPI starts one that waits, in real mode at the vector times 4 KiB, CS the vector shifted left by eight. A second start-up IPI finds the CPU running and is ignored, as the silicon ignores it: Linux sends two. From there Linux's trampoline climbs to long mode itself, through protected mode, PAE and paging, and joins the kernel.
- Under AMD-V real mode needs nothing new: with nested paging the CPU translates the guest's physical addresses through the nested table whatever the guest's own paging is doing. A CPU that is started is given a fresh ASID, so nothing translated for it before the reset is used.
- Under VT-x real mode needs unrestricted guest, the secondary control
that lets a guest run with paging, or protection, off: a CPU without it
refuses a guest of more than one CPU (
NoUnrestrictedGuest) rather than starts one whose other CPUs could never start. And the climb is a guest changing CR0.PE and CR0.PG, which VMX will not let a guest do behind the host's back where the "IA-32e mode guest" entry control has to agree with EFER.LMA. So PE and PG are in CR0's guest/host mask with NE, amovto CR0 or anlmswthat would change one of them exits (Exit::Cr0Write), and the policy does what the CPU would: the checks that make it a #GP -- bits 63:32 set, NW without CD, PG without PE, paging on in long mode without PAE, paging off from 64-bit code or with PCIDs on -- LMA set or cleared as paging goes on or off with LME set, the entry control made to follow at the next entry, and what the TLB holds under the guest's VPID dropped, as themovwould drop it. That also lifts what the VMX backend could not do before: a guest can leave long mode, the waykexecdoes -- which nothing here has run yet.
QEMU's TCG before 9.2 does not put the accesses of a guest with paging off
through the nested table at all -- it takes a guest physical address for a
host one -- so a CPU started in real mode there runs out of nos's own memory
and corrupts it. hv-test.py and hv-linux-test.py --cpus refuse an older
QEMU under TCG; KVM, and a CPU's own extension, have always done it right.
What the CPUs share, and how they reach each other
Each CPU's own state -- its registers, its local APIC -- is its task's alone, and may be inside the CPU under a VMCB or a VMCS at any moment. What the CPUs share is guarded by what it is:
- Memory is copied into and out of by every CPU at once, as the guest's
own CPUs write it:
GuestMemory's accessors take&self, and each copy goes through the calling CPU's own slot with interrupts off (kcore::frame), with no reference into a guest page anywhere. The nested table is one for all of a guest's CPUs, and grows under a lock -- the read-only page answering a read of a device the platform does not have (map_absent) is mapped once, whichever CPUs fault on it together. - The devices are one platform under one lock, taken for each port access and for each round of the first CPU's device work.
- What one CPU sends another -- a fixed interrupt, an NMI, an INIT, a
start-up IPI -- goes into the target's mailbox (
hv::smp), lock-free: a bitmap of the vectors sent and a word each for an NMI, an INIT and a start-up IPI, taken whole by the target before it next enters. The sender then rings the target's doorbell, which wakes its task if it sleeps and, if it is in its guest, interrupts the host CPU it runs on so that it leaves (the sameKickthe switch uses for a frame). The vCPU marks itself on its way in before its last look at the mailbox, and a sender posts before it rings, so a message is either found by that look or rings a vCPU that is in its guest or about to be.
The interrupt command register sends to a physical or a logical
destination -- a cluster and a bitmap of its CPUs, x2APIC's only logical
mode -- or by shorthand, to itself, to all, or to all but itself; fixed and
lowest-priority interrupts (the latter to the first CPU it names), NMIs,
INIT and start-up IPIs. The first CPU takes interrupts from two places, the
8259 through LINT0 and its own APIC, and when both have one they take turns,
so that neither can keep the other out. The APIC's task priority is CR8's,
under AMD-V through the VMCB's V_TPR, and under VT-x the virtual APIC's
VTPR (below) -- or, on a CPU without one, through the CR8 exits that keep
the host's own CR8 out of the guest's reach.
An NMI one CPU sends another needs NMI blocking to be the guest's own: set as the NMI is injected, ended by the guest's IRET, and the next one held until then. Under VT-x that is virtual NMIs, with the NMI-window exit to inject a held one the moment the guest's IRET unblocks it; under AMD-V it is the IRET intercept, whose exit comes before the IRET has run, so blocking ends once the guest's RIP has moved past it -- what KVM does.
Halting. A CPU that halts with interrupts on is not entered again until
an interrupt it can take is pending, its task asleep meanwhile, as for one
CPU. A CPU halted with interrupts off, or waiting for a start-up IPI, can
be woken only by another CPU. It sets its bit in a mask of such CPUs as it
goes to wait -- and looks at its mailbox once more after, for mail that
came meanwhile -- and clears the bit as it wakes, before it takes anything.
So a set bit is a CPU that has sent nothing since it last found its mailbox
empty, and a CPU whose own bit makes the mask every CPU's, and that then
finds every mailbox empty and the mask still whole, knows nobody is left
awake to send anybody anything: the guest has stopped -- halted in its
report, as a guest of one CPU that runs cli; hlt is. The order is the
point: with the bit cleared by whoever sent the mail instead, a CPU that
took its mail before the sender got there would run with its bit set, and
the last of the others to halt could stop a guest that is running.
Interrupts the CPU delivers itself: VT-x's APIC virtualization
Where the CPU has the TPR shadow, x2APIC virtualization and
virtual-interrupt delivery -- hv info's APIC virtualization; the EX44's
i5-13500 has them, and KVM hands them to a nested hypervisor -- each of a
guest's CPUs under VT-x has a virtual APIC: a page of its own
(hvarch::x86::vmx::Guest) whose task priority, request and in-service
registers (VTPR, VIRR, VISR) the CPU itself works. CR8 is VTPR, for every
guest, and no mov of it exits.
By default where the CPU has posted interrupts too -- hv info says --
and for a guest that asks for it (apicv on hv boot or hv start;
noapicv turns it off), while the guest's local APIC is in x2APIC mode
and on, the run loop turns virtual-interrupt delivery on for each entry
(hv::vmx::Vcpu::load_apic): where the APIC stands -- what it has
requested and in service, and its task priority -- goes into the page, and
the CPU, not the run loop, delivers the highest request the priorities let
through as soon as the guest can take it, and performs the guest's EOIs,
its writes of the task priority and its self-IPIs on the page, none of
them an exit. At the exit the run loop takes the page back into the APIC
before anything looks at it (Lapic::from_cpu), so the rest of the loop --
a halted CPU's wake-up, a read of IRR, an IPI's acceptance -- sees what the
CPU did. The MSR bitmap lets the guest's writes of those three registers
through only while this is on; every read of an x2APIC register still
exits, since without APIC-register virtualization a read let through would
be of the CPU's own APIC. An EOI of a level-triggered vector -- an
IO-APIC's, which it has to hear of to send again while its line is up --
the CPU still stops the guest after (the EOI-exit bitmap, from the APIC's
trigger-mode register: Exit::Eoi). In xAPIC mode, with the APIC off or
software-disabled, on a CPU without APIC virtualization, under AMD-V
(whose AVIC is not used) and for a guest that turned it off, the run loop
injects every interrupt as before.
Posted interrupts. What comes from outside the CPU -- another CPU's
IPI, a device's MSI -- each guest CPU takes posted: its fixed, edge-triggered
interrupts go into a descriptor of its own (hvarch::x86::vmx::PostedInterrupts,
64 bytes the VMCS names), and if it is in its guest, or on its way in, the
sender sends the CPU it runs on the notification vector, 0xFC
(kcore::cpu::notify, Kick::notify): the CPU takes what was posted into
VIRR there and delivers it, no exit. A CPU out of its guest is rung as
before, and folds what was posted into its APIC before it next looks at it
(take_posted); one that left its guest just then takes the notification
as a host interrupt that only ends itself. Level-triggered interrupts, NMIs,
INITs and start-up IPIs still go through the mailbox and a kick. Posted
interrupts need every external interrupt's exit to acknowledge the
interrupt -- the host's, delivered to nothing yet -- so hvarch hands each
to the host by hand, by an int of its vector through the host's own IDT,
once the CPU's entries are let go of, since its handler may switch tasks
(deliver_host_interrupt).
Why the default waits for posted interrupts. Without them an interrupt for a CPU in its guest has to kick it out to be handed over, and with no EOI exit for the run loop to find the next one's mail at, more of them do: a kick, an IPI and an exit, is dearer than the EOI's exit it replaced. On the EX44, two guests of two CPUs sending 500 MB of TCP to each other through the switch, 13 runs each way: without posted interrupts the guests exited 25-50% less but took 5.88 s on average (4.3-6.8 s) against 5.70 s (5.5-5.8 s) -- 4.3 s when the sending guest took its NIC's interrupts on its first CPU, whose task does the devices' work and posts the MSI to itself, and 5.9-6.8 s when it took them on its second, its host-interrupt exits going from 132,000 to 179,000. With posted interrupts, 10 runs each way, taken in turns: 3.7-5.9 s (4.85 on average) against 5.6-5.8 s (5.72), faster in 9 of the 10, the two guests exiting 0.67-1.58 million times against 2.3-2.6 million.
Halt polling -- a halted CPU watching for an interrupt a while before its task sleeps -- is not a lever on that load; what looked like one was interrupt moderation, by accident. Measured apart, posted interrupts on, two runs each: a 200 us watch of a CPU's mailbox and its doorbell took the transfer from 3.9-5.1 s to 6.3-6.5 s, a 50 us one to 4.7-6.0 s. A watch hands each frame's interrupt over at once, so the receiving guest takes one frame an interrupt (1.00, against 1.4-2.0 with no watch) and acknowledges every one or two, 265,000-277,000 ACKs against 111,000-151,000, and the sender's TCP, answered that often, sends more and smaller frames -- each one more exit on both sides. The 200 us watch that had taken the transfer from 5.7 s to 3.9 s watched the mailbox alone: on the receiving guest's first CPU, halted, it left the doorbell the switch rings for the devices' work unanswered until it ran out, so the frames for that guest waited up to 200 us and went into its ring together -- 18.9 an interrupt, 41,000 ACKs, 3.9 s; where that CPU was not halted nothing waited, 1.8 an interrupt and 4.9 s. Adaptive watches came to 5-7 s for the same reason: they shrank the first CPU's watch to nothing, and answered the rest at once. Moderation done on purpose -- an interrupt held a bounded while for the frames behind it -- needs a host timer finer than the tick, which there is now (the local timer): the same wait slept rather than spun, which the 100 Hz tick made 5-10 ms, held 260-290 frames an interrupt and took 12.5-14.6 s, the TCP window run dry. It is in the tree now, at 100 us (A network); halt polling is not.
The apicv built-in guest asks for it, and holds both ways to one result:
self-IPIs held by CR8 and taken by priority, one nested, IRR and ISR read
back, and three levels from the IO-APIC ended by the x2APIC's EOI. The
posted guest's second CPU sends its first a thousand IPIs while it spins:
taken in its guest, each notified, where the CPU has posted interrupts --
on the EX44 the first CPU left its guest 9 times -- and handed over by the
host elsewhere. The hv fuzzer's platform target models the CPU's side --
delivery by priority, the EOI, task priority and self-IPI writes it
performs, the EOI exit, a posted interrupt taken in the guest and the
notification's exit where it is not -- and holds the run loop to losing
nothing the CPU left in the page between an exit and the next entry, to
taking what was posted before every entry and to never waiting with it
untaken.
Time
A timer's edge, on time. A guest's timers -- the PIT's channel 0 and
each CPU's local APIC timer -- have their next edge armed on the host CPU
the vCPU runs on: as its task sleeps, halted, until the edge
(Event::WaitFor, whose deadline arms the CPU's one-shot timer -- the
local timer), and before each entry into
the guest (kcore::cpu::timer_at), so that a guest computing with no exit
is taken out of it at the edge, by a host interrupt, and handed its own then.
Both used to wait for the host's next tick, at 100 Hz: on the EX44 a halted
guest's usleep 1000 took 10 ms, a hundred of them 0.99 s, and a CPU
computing with no exit was given its ticks in bursts of up to ten. Now a
hundred take 0.16 s -- halted, and with a CPU-bound loop running in the
guest -- on one CPU, whose timer is the PIT, and on two, each with its APIC
timer delivered by the CPU itself; twenty of usleep 100000 2.01 s, where
they took 2.19. The owed periods below keep the count right whatever the
host does, and PAUSE exits keep a spinning CPU prompt. hv-fuzz's stand-in
CPU leaves its guest at the deadline it was armed for, as a host interrupt.
Each local APIC has its timer: one-shot and periodic, counting down at a bus clock of 1 GHz -- KVM's, and one that makes a count nanoseconds -- with the divide configuration a guest writes. A periodic timer that fell behind is owed its periods, handed over one at a time as the guest takes each, a second's worth at most, as the PIT's channel 0 is. There is no TSC-deadline mode, and CPUID says so.
A kernel measures the timer against the PIT before it trusts it
(calibrate_APIC_clock), and while it measures it spins, cpu_relax() --
PAUSE -- round a loop that watches the PIT's ticks go by. A spinning guest
made no exit, so it left its guest only at the host's own tick, every 10 ms,
and took the PIT's ticks owed since in a burst there: Linux counted ten
ticks' worth of APIC timer as one tick, reported a bus of 10005 MHz where
there is one of 1000, then saw its APIC timer run ten times too slow and
refused it (APIC timer disabled due to verification failure, jiffies delta = 1001). So PAUSE exits now -- under VT-x at a loop of them in the
guest's kernel that has spun 65536 TSC ticks (pause-loop exiting, 26 us at
the EX44's 2.5 GHz, each PAUSE within 1024 ticks of the last), and at every
one where the CPU has no pause-loop exiting, as a nested KVM may not pass it
on; under AMD-V every 128th where the CPU has a pause filter and every one
where it has not -- and each exit goes round the loop that hands a tick over
as it falls due. An exit at every PAUSE cost a spinning CPU an exit's 1.8
us for each PAUSE of tens of cycles. The gap is wider than KVM's 128 ticks:
a loop's turn on Skylake and later, whose PAUSE alone is some 140 cycles of
a core that may run below its TSC's clock, is longer than that, and a gap
too short finds no loop at all. Measured on the AX41, the guest finds a bus of 999 MHz and keeps
its timer (jiffies delta = 100, jiffies result ok), as it does on an
Intel host (an i5-13500, nested under KVM). A CPU spinning on a lock
another of the guest's CPUs holds comes out the same way, and is handed its
tick when it falls due rather than at the host's next one.
Under TCG a guest refused it once more before it was told its TSC's rate (below). The check allows two ticks of 100, and a guest there is slow enough that the PIT's ticks owed from the lines it printed with interrupts off just before -- through a UART that is itself exits -- were still being handed over when the check started: it counted 106. It then kept its PIT tick on the first CPU and broadcast it to the others by IPI, which works, and costs an IPI a tick per CPU. Told the rate, it has kept its timer in both runs since.
The TSC's rate, handed over. A kernel on an AMD host measures its TSC
against the PIT, every read an exit, and gives up if one read takes ten
times the fastest -- which a vCPU sharing its host CPU with the host's own
work (sshd, the network stack; cpu 0's, on the AX41) did one boot in two,
before SMP and after, and ran on jiffies. So every Linux guest's command
line gets tsc_early_khz= with the host's TSC rate, which is the guest's:
nothing offsets or scales the TSC a guest reads. Linux takes it as
authoritative from 5.7 on; hv leaves one the command line gives alone.
Two CPUs' TSCs agree. Placed on two CCXs of the AX41 (0 and 13), Debian
measured its CPUs' TSCs 2052 cycles apart -- "turning off TSC clock" -- where
Ubuntu, on the same CPUs, found them in step. Linux's check reads the TSC
with lfence; rdtsc after taking a lock the other CPU held, and trusts the
LFENCE to hold the RDTSC back until the load before it has completed. On an
AMD CPU of families 10h to 17h LFENCE does that only with DE_CFG bit 1 set,
which Linux sets on every CPU and nos did not, and the firmware had not:
the RDTSC ran ahead of a cache line on its way from the other CCX, and the
time that took looked like a warp. nos sets the bit on every CPU now, the
BSP's boot log saying when the firmware left it clear
(Hal::SetupSerializingLfence); a guest of four CPUs across both CCXs (13,
11, 9 and 5) keeps its TSC.
Device interrupts, by MSI-X
A guest with local APICs has its disks and NICs interrupt it by MSI-X, as a
PCI device on a real machine does, and not through the 8259. Each virtio
function has an MSI-X capability (devices/pci.rs) and a memory BAR 1 of
one page: the vector table at its start, the pending bits after it. That
page is guest memory -- a region of its own, at 0xFE000000 and a page a PCI
slot, in the MMIO window and not in the e820 map -- so a guest writes its
table with plain movs and makes no exit doing it, and no instruction has
to be decoded. A guest's RAM ends below these pages, at 4064 MiB at most
(run::MAX_MEM_BYTES, what mem= is bounded by): more would run over them,
and over the IO-APIC's and the local APIC's, and the guest would read and
write its devices' registers as memory, none of them hearing of it -- an
APIC in xAPIC mode that is not there, and nothing to say so. The device
reads an entry when it has an interrupt to send:
the message's address and data (lapic::msi), a fixed or lowest-priority
vector to an x2APIC ID or, in cluster mode, to the first cluster's CPUs,
the eight an 8-bit field can name -- which is where Linux puts them without
interrupt remapping. It goes as an IPI would: into the APIC of the CPU
doing the device's work, or another CPU's mailbox, with a ring. An entry
masked, or the whole function, sets its pending bit and is sent once it is
unmasked. With MSI-X on, virtio's legacy header grows the two vector
registers, and the device's configuration moves from 0x14 to 0x18
(devices/virtio.rs). The BAR stays where "firmware" put it: sizing it
reads back its size, and another address written reads back the old one.
What that changes is the cost of an interrupt. Through the 8259 each costs the guest six exits -- a mask, two EOIs, an unmask, the interrupt status read and a dummy one -- and at a gigabit a receive interrupt came every other frame; by MSI-X it costs one, the EOI's MSR write. And the guest places each vector itself: Debian put its NIC's receive queue on its first CPU and its transmit queue and its disk on its second. On the AX41 a Debian of two CPUs fetched a gigabyte three times at 117 MB/s -- what TCP carries over gigabit Ethernet -- with no frame dropped, its first vCPU's host CPU 88% busy and its second's 21%, where through the 8259 the first was 96.5% busy, the second idle, the fetch 113 MB/s and a few hundred frames dropped a gigabyte. A guest of one CPU has no APIC, and keeps the 8259 and that ceiling: 111 MB/s and 586 frames dropped.
What it does not do yet
- The first CPU's task does every device's work. Between its guest's
turns it serves a disk's answer and a NIC's frames, with or without an
IO-APIC; only the interrupts that follow go where the guest steers them
-- through MSI-X, and with
ioapicthe PIT's and the serial port's too. Without one, those two reach the first CPU only. - No TSC-deadline timer. A kernel built with neither
X86_MPPARSEnor ACPI finds one CPU.
Gates
Both run under AMD-V -- QEMU's TCG, and nested KVM on the AX41 -- and under VT-x, nested KVM on an i5-13500, where the second CPU's climb from real mode is the two CR0 writes the policy emulates.
scripts/hv-test.pyruns the built-insmpguest on the first and the last CPU: two CPUs, the second started by INIT and a start-up IPI into real mode, climbing to long mode and sending the first an IPI, whose one-shot APIC timer then interrupts it. A run with the start-up IPI ignored, and one with the timer never firing, both fail it.scripts/hv-linux-test.py --cpus Nboots a real Linux with N CPUs --smp: Brought up 1 node, N CPUs, and the report'sN-1 started by the guest-- then runs the VM phase with them: every CPU online, each taking its own local timer's interrupts, rescheduling IPIs between them, andhv listnaming the host CPU of each.--disk,--disk --nvme-root,--netand--attachtake--cpustoo. The guest kernel needs what is above, plus POSIX timers for BusyBox'sping, which paces itself withalarm()(a tinyconfig leaves them out).
MMIO
A device whose registers are memory -- the xAPIC's page and the IO-APIC's
first, and next the HPET's, a PCIe function's BARs -- is reached by instructions
that fault on a page the nested table does not map, and the exit says where
and whether it wrote: not the register, not the size, not the value, not the
instruction's length. So a device like that is emulated by decoding the
instruction, which is three pieces here, none with any unsafe:
- The walk (
hv::walk): the guest's RIP to a guest physical address by its own paging -- long mode's four or five levels and their 1 GiB and 2 MiB pages, or none -- and the instruction's up to fifteen bytes read from guest RAM a page at a time: an instruction may cross into a page mapped elsewhere, or not at all. The permissions are not checked: the CPU fetched the instruction already, and faulted on its data. - The decoder (
hv::insn): what Linux's MMIO accessors are.readl,writeland their kind aremovs in inline assembly -- a register loaded from memory or stored to it, an immediate stored, andmovzx/movsxfor the narrow reads -- the set Linux itself decodes when it emulates MMIO for a confidential guest (insn_decode_mmio). The memory operand may take any form a compiler picks (a fixmap address is an absolute displacement through a SIB byte), so every ModRM and SIB form is stepped over; the address is not worked out, the fault having given it. Anything else -- another opcode, a string move, a lock, 16-bit addressing -- is refused. It was checked against whatclangassembles for 1054 instructions (every opcode over sixteen addressing forms, the registers REX and the high bytes name, immediates of each size), each also cut short at every length -- refused, never mistaken for a shorter instruction -- and against two million random byte strings: no panic, and no length past what was there. - The emulator (
hv::mmio), in two steps, the device being the caller's.beginfetches and decodes, and checks the instruction against the fault -- that the fault was an instruction's at all, and not an interrupt's delivery pushing to a stack or reading an IDT on the page; a load for a read and a store for a write, which an instruction another of the guest's CPUs changed under the fault need not be; the access inside the page; no load into RSP, which VT-x writes back only on its full sync -- and hands back the access and a store's value. The caller reads or writes its device.finishputs a load's value in its register -- zero- or sign-extended, a 32-bit destination clearing the top half as the CPU's does -- and steps RIP past. Nothing in the guest changes beforefinish, and an access that cannot be performed stops the guest with its bytes:an access of guest physical 0x... at rip 0x... not performed -- ...; its bytes ....
hv run's mmio guest checks the three together on each backend: a device
of 32 bytes of registers in the MMIO window, stored to by an immediate of
each size, a 64-bit register, REX's and BH, and loaded from by mov into
32-, 64- and 16-bit registers and AH, movzx and movsx -- fourteen
accesses, each value checked where it went. What an access costs, beside an
exit the CPU decodes itself, is under What an exit
costs: hv bench mmio.
The xAPIC
The first device on it is the local APIC's page. Its APIC comes out of
reset in x2APIC mode, and a kernel that keeps it there -- Linux does --
never touches the page; one that leaves the mode, or whose firmware left
the APIC in xAPIC mode, now reaches the same register file through the page
(Lapic::mmio_read, mmio_write), where the two modes differ as the
architecture has them differ: the ID in bits 31:24, a writable logical ID
and destination format -- flat or cluster, which the other CPUs' IPIs and a
device's MSIs are matched against, each CPU publishing its own -- the ICR
in two halves, and what x2APIC mode faults xAPIC mode ignoring. The page
stays where firmware put it: a write of IA32_APIC_BASE moving it is a
#GP, since a page that is a device to one CPU and RAM to another is no
machine. Two guests use it:
$ hv boot /bzImage cpus=2 xapic initrd=/initrd cmdline=console=ttyS0 acpi=off
--- ttyS0 ---
...
[ 0.622101] smp: Brought up 1 node, 2 CPUs
...
apic 59776 interrupts from the local APICs, 59486 of their timers; 294 IPIs sent, 290 taken; 0 MSIs
mmio 60828 accesses performed for the guest, its instructions decoded: the xAPIC page's
Linux 6.1 -- Debian 12's -- booted without ACPI, xapic having its APICs
come out of reset in xAPIC mode, brings up both its CPUs with every EOI,
IPI and timer write through the page. Without xapic it cannot: its MP
table path reads the page before it looks at the mode firmware left, and
with x2APIC on it had not mapped the page and page-faulted in its own
register_lapic_address. With its ACPI it needs neither -- the MADT has it
choose the x2APIC driver before anything reads the page -- so a 6.1 of a
distribution boots SMP here as it is. And a Linux 6.18 told nox2apic,
which takes its APIC out of x2APIC mode itself ("x2apic disabled"), does
the same through the page.
The IO-APIC
The second device on it is the IO-APIC (hv::devices::ioapic), for a
guest of more than one CPU given ioapic: 24 pins at 0xFEC00000, version
0x20, reached through the page's select register and window, and its EOI
register. Each pin's redirection entry says what an interrupt on it is --
a vector, a delivery mode, a destination, physical or logical, masked or
not, edge- or level-triggered -- and a device drives its pin as it drives
the 8259's line: the PIT's tick on pin 2, the serial port's interrupt on
pin 4, the SCI on pin 9, each PCI function's INTx on the pin of its line,
10 or 11; the 8259's own output is pin 0's, as on a PC, and its interrupts
go to the first CPU as they always did, while its lines are unmasked there.
The tables say so: the MP table marks the IO-APIC usable and routes each
PCI function's INTA, level and active low, and the MADT lists it with the
timer's and the SCI's overrides (ACPI); noapic is not added.
An edge-triggered pin sends as it rises; a level-triggered one while its line is up and its last interrupt has been ended -- its remote IRR set as it sends, and cleared by the EOI of the local APIC that took it, which the APIC broadcasts for a vector it took as a level (its trigger-mode register), or by a write of the IO-APIC's EOI register; a line still up then sends again. A message goes as a device's MSI does: into the local APIC of the CPU whose loop sent it, or through another's mailbox, the level bit with it. The serial port had to become a 16550 for this: its transmitter's interrupt pending until IIR reports it or a byte is written, and each byte written a pulse of its line -- the edge an edge-triggered pin sees, where the 8259 was asked again for as long as the line was up. And the PIT's next edge waits for the last to be taken where it went: on the 8259 while its IRQ 0 is unmasked there, and through the IO-APIC while its vector is still requested in the first CPU's APIC.
Its local APICs come out of reset in xAPIC mode. Linux keeps a firmware-enabled x2APIC with an IO-APIC only with interrupt remapping, or under a hypervisor it knows to support x2APIC without -- KVM, Xen, Hyper-V -- and this one names none (CPUID's hypervisor range is blank). Finding its firmware left x2APIC on, a Linux 6.1 or 6.18 turns it off, reading its APIC's ID through a page it has not mapped, and faults. So a machine with an IO-APIC is the PC most firmware makes, in xAPIC mode, and the kernel takes it as it is:
$ hv boot /bzImage cpus=2 ioapic initrd=/initrd cmdline=console=ttyS0
...
[ 0.081741] IOAPIC[0]: apic_id 2, version 32, address 0xfec00000, GSI 0-23
[ 0.225009] APIC: Switch to symmetric I/O mode setup
[ 0.232303] x2apic: IRQ remapping doesn't support X2APIC mode
[ 0.260088] ..TIMER: vector=0x30 apic1=0 pin1=2 apic2=0 pin2=0
...
mmio 65657 accesses performed for the guest, its instructions decoded: the xAPIC page's and the IO-APIC's
ioapic 431 interrupts sent, 0 of them level-triggered, 0 ended by EOI, 0 of no delivery mode sent here
Every access of its APICs is then a decoded MMIO access (What an exit
costs); keeping x2APIC mode with an IO-APIC would
take an IOMMU's interrupt remapping, or a name Linux trusts. Linux checks
the timer through the IO-APIC itself (..TIMER, and its count of ticks
unless told no_timer_check), and routes the ISA lines where it likes --
the serial port's, here, to the second CPU, which through the 8259 it
could never have been:
0: 65 0 IO-APIC 2-edge timer
4: 0 219 IO-APIC 4-edge ttyS0
With ACPI it routes interrupts Using IOAPIC, takes its SMP configuration
from the MADT with the overrides (INT_SRC_OVR (bus 0 bus_irq 9 global_irq 9 high level)), and the SCI arrives on pin 9 as a level,
IO-APIC 9-fasteoi acpi: the power button's one SCI a level-triggered
interrupt, ended by EOI, and the guest off by acpid. Told pci=nomsi, a
virtio device's INTx comes the same way, fasteoi on pin 11 or 10, each
interrupt ended and sent again while the device's status is unread.
hv run's ioapic guest checks it without a kernel: the serial port's pin
as an edge -- the enable an edge, a byte's pulse another, one while masked
lost, unmasking sending nothing -- then as a level, sent again at each EOI
until the handler reads IIR, the entry's remote IRR set in service and
clear after; every access of the IO-APIC and every EOI through the pages.
On real hardware
The AX41 (a Ryzen 5 3600, Zen 2) has the whole of AMD-V -- 32768 ASIDs,
next-RIP save, decode assists, flush by ASID, VMCB clean bits, AVIC -- and
checks a VMCB as QEMU does not. There every built-in guest passes on the
first CPU and the last, the extension turns on and off for all twelve, and
the Linux guest boots to its shell and runs a typed id, at native speed:
Run /init as init process 18 ms into the guest's clock, against half a
second under TCG. The PIT calibrates the guest's TSC (3599.5 MHz), the guest
takes the TSC as its clocksource, its timer delivers a hundred ticks a
second, and idle at its prompt the vCPU's task sleeps 99% of the time.
The first run there stopped twice where TCG had never gone, which is what a run on the real thing is for:
- CPUID leaf 0x80000001 went through whole but for SVM, and a Zen 2 has
MWAITX in it. The guest's
udelaybecamemonitorx, whose intercept had no answer. The leaf is an allowlist now, as leaf 1 is, and an intercepted instruction CPUID did not offer is answered with the #UD a CPU without it gives. - Linux on a Zen CPU reads AMD's FCH at a fixed address -- the
reset-status register, 0xFED803C0 -- whether or not there is one, and takes
all ones for "no such device". A read of the platform's MMIO window that
nothing answers is now answered with a read-only page of all ones; the
absentbuilt-in guest keeps that under TCG, which is no Zen and would never read it.
The second run, 2026-09-24 (8928493, one boot of nos): every built-in
guest on CPU 0 and CPU 13 again, asid among them -- two VMs taking turns
on one CPU under ASIDs 1 and 2, and a third given 1 again after its
generation's flush, each reading its own page, on a CPU that keeps
translations; the exit costs above; and both distributions (A
distribution) as they ship:
- Alpine at its login prompt 22 s after the VM starts. It calibrates
its TSC against the emulated PIT (3600.143 MHz), takes the TSC as its
clocksource and drives its tick through the PIT's one-shot mode 4
(
hrtimer_interrupt) -- the path TCG never takes by itself;sleep 2takes 2.00 s. On the switch it pings nos in 0.05 ms, andssh -p 2222 root@65.109.93.213, from the dev host over the internet, lands in its own sshd through nos's I210,hv forwardand the switch. - Debian at its login prompt 8.5 s after the VM starts (systemd-analyze:
1.0 s kernel, 3.5 s userspace),
running, no unit failed, its root on the 3 GiB image on nos'snosenvext2 -- whiche2fsckfound clean after -- a file written there still there afterreboot, and on the switch it reaches nos and the Alpine guest. - Idle, the two cost nos 0.1% of its twelve CPUs (
top), 560 and 700 exits a second.
And one thing only the real thing showed. Debian's clocksource watchdog,
which checks the TSC against jiffies, found them 76 ms apart over 512 ms,
called the TSC unstable and fell back to jiffies -- and then kept 25 s of
time in 62: at 250 Hz its idle vCPU, woken at the host's 100 Hz tick, was
given one timer edge for every two or three periods (a hundred a second),
the rest thrown away. Alpine had kept the TSC and its time. The PIT now owes
every period that elapsed and hands them over one at a time, as KVM's does
(What the extension costs),
and hv-distro-test checks each guest's idle clock against nos's: 1.000
with the fix, 0.514 without it under TCG. Over SSH the command line was
also cut at 255 characters, too short for Debian's hv start; it takes
1023, as /etc/rc does.
Booted again with the fix (768a56b), the same day: Debian keeps the TSC --
Switched to clocksource tsc at 1.1 s, no watchdog complaint -- and counts
62.37 s of 62.4, Alpine 62.27; both dates the host's. The two boots were
faster for it too, Debian at its login prompt in 3.8 s and Alpine in 8.9 s
(8.5 and 22 s before): the guests' own timers had been waiting on ticks
that never came. Debian's 343-character hv start went through over SSH;
an exit cost 742 ns with ASIDs and 959 ns flushed, as before.
The way out, on the AX41 the same day (45df223): with both guests on the
switch, NAT went out through the I210 from the machine's own address, and
each guest was handed Hetzner's resolver (185.12.64.1) by ip= and by the
switch's DHCP. Alpine resolved its mirror, apk updated from it, and
fetched 100 MB from Hetzner's Helsinki speed-test server in 0.93 s; Debian
took its address and route by DHCP, synchronised its clock over NTP, pinged
1.1.1.1 in 1.2 ms, apt-get updated over HTTPS (28.5 MB), and fetched
100 MB over HTTPS in 1.0 s and 1 GB in 16.6 s (65 MB/s). A connection from
another host to a mapped port was refused by nos's own stack and never
reached a guest. Over the session NAT carried 10,841 packets out and
943,241 back, and needed no next hop, frame or mapping it lacked.
Two things it showed that are not NAT's. apt-get update fetched those
28.5 MB in 39 s where one download of the same index took 0.2 s, and the
guest's port dropped some 1,400 frames meanwhile: apt downloads over several
connections at once while it decompresses, and a frame for a guest that is
running -- not halted -- waits in the port's 64-frame inbox until its next
exit. The switch woke a halted guest and had nothing to hurry a running
one; at line rate the inbox fills in under a millisecond. It kicks a running
one now, and the inbox is the guest's ring's size (A network).
And a command run
through nos's sshd for longer than the client's keepalive (ssh -o ServerAliveInterval=) was cut off, the server answering no keepalive while
the command ran; commands run beside their session now, which tends the
connection meanwhile (sshd).
Booted again with both fixed (50746e4): a 40 s hv wait over ssh with
ServerAliveInterval=5 ServerAliveCountMax=2 ran its full 40 s, where it
had been cut off at 14; and a gigabyte through NAT into the Debian guest
took 9.8 s -- 109.8 MB/s, the link's rate. Not every time: right after a
cold apt-get update the same download took 44 to 47 s. The guest had 125
MB of apt's lists to write back, a sync of them took 35 s -- 3.5 MB/s --
and the download after that ran at the link's rate again: the guest's disk
writes stall its vCPU, a virtio-blk request being served on the vCPU's own
task, synchronously, through nos's ext2 to the NVMe. Its clocksource
watchdog saw the stalls too ("Long readout interval", gaps of up to 10 s).
That is the disk's next thing to take out, as fewer exits per frame is the
network's.
Booted again with the disk working beside the guest and ext2's writes costing what they change (2c72334, A disk), the same guest with 1 GiB, the same afternoon:
| in the Debian guest | before | after |
|---|---|---|
a cold apt-get update, 28.5 MB |
fetched in 15 to 26 s, 47 to 52 s in all | fetched in 2 s, 3.8 s in all |
a gigabyte to /dev/null right after it |
44 to 47 s, 23 to 25 MB/s | 9.9 s, 108 MB/s -- the link's rate |
sync of what apt left dirty |
125 MB in 35 s | 90 MB in 0.36 s |
dd 1 GiB, conv=fsync, a new file / over it again |
261 / 284 MB/s | |
| reading it back, the cache dropped | 231 MB/s | |
| a gigabyte fetched to the guest's disk | 13.1 s, 82 MB/s, and sync 0.2 s after |
"Long readout interval" came up in none of it. Afterwards e2fsck found
nos's nosenv clean, and the guest's ext4 in its image too, taken
read-only through a loop device; the image had 0.3 GiB of holes filled on
the way. What bounded the rate then was ext2 writing a request's blocks one
4 KiB command at a time, each synchronous: the disk's task was at a whole
CPU while the guest wrote, profile putting most of it in the NVMe
driver's WaitGroup::Wait yielding until the command completed.
Booted once more with a file's blocks going to the disk in batches, 32 commands in flight at once (63a7bb2, Filesystems):
| in the Debian guest | a block at a time | in batches |
|---|---|---|
dd 1 GiB, a new file / over it again |
261 / 284 MB/s | 363 / 358 MB/s |
| reading it back, the cache dropped | 231 MB/s | 397 MB/s |
the disk's task, while dd writes |
a whole CPU | 11% of one |
sync of what apt-get update left dirty |
~90 MB in 0.36 s | 102 MB in 0.25 s |
| a gigabyte fetched to the guest's disk | 82 MB/s | 87 MB/s |
and the guest at its login prompt in 3.5 s, its disk's task having spent a
third as much CPU reading the boot in. e2fsck found nosenv and the
guest's ext4 clean again. What bounds the guest now is its vCPU, at 85% of
its CPU while dd writes: two thirds of that is copying each request's
data out of guest memory, a page at a time through the kernel's temporary
window and eight bytes at a time inside it (FrameCopy, a MemCpy call a
word), and a fifth the guest itself running. A page's part of a copy is
one MemCpy now: under TCG the guest's 48 MiB dd ... conv=fsync onto a
whole image takes 0.42 s where it took 0.64, and reading it back 0.24 s
where it took 0.44, on virtio-blk and on NVMe alike.
On the AX41 with it (6bf9e84), the same guest:
| in the Debian guest | a word a call | a page a call |
|---|---|---|
dd 1 GiB, a new file / over it again |
363 / 358 MB/s | 966 / 932 MB/s |
| reading it back, the cache dropped | 397 MB/s | 1.3 GB/s |
| a gigabyte fetched to the guest's disk | 87 MB/s | 102 MB/s |
and its NIC dropped no frame over the downloads, where it had dropped a
few hundred a gigabyte. While dd writes 1.5 GiB at 925 MB/s the vCPU is
at 99.9% of its CPU and the disk's task at 31%; half of the vCPU's time is
now the guest itself running, and most of the rest the copies and the
mapping of the temporary window for each page. e2fsck found nosenv and
the guest's ext4 clean.
One thing that boot showed is not nos's: apt-get update took 31 s where
it had taken 2 -- a flat 30 s of it on one request, then a new connection
that had the answer in 20 ms. apt pipelines its requests, and the CDN
behind deb.debian.org left the last of them on a connection unanswered: a
capture in the guest has every byte the CDN sent acknowledged, and nothing
more from it, data or FIN or RST, until the client gave up and closed --
then the CDN's answer went on from the very byte after the last one. From
outside nos the same pipelining has the CDN close the connection in the
middle of an answer. Whether the faster guest meets it at a new point or
the CDN changed that afternoon, apt with -o Acquire::http::Pipeline-Depth=0 takes 2 s again.
Then the mapping itself: a copy's page went through the temporary window's shared slots, under their lock, with a reference taken and two TLB flushes, and that cost the vCPU as much as the copy. Each CPU has a slot of its own for it now (Paging), a page still mapped only for the length of its copy. On the AX41 (56b6da8):
| in the Debian guest | shared slots | a slot per CPU |
|---|---|---|
dd 1 GiB, a new file / over it again |
966 / 932 MB/s | 1.2 / 1.2 GB/s |
| reading it back, the cache dropped | 1.3 GB/s | 1.6 GB/s |
dd 1.5 GiB, and the vCPU meanwhile |
925 MB/s, 99.9% | 1.2 GB/s, 70% |
a gigabyte fetched to /dev/null / to the guest's disk |
110 / 102 MB/s | 117 / 107 MB/s |
117 MB/s is what TCP carries over gigabit Ethernet at an MTU of 1500. The
vCPU's time is two thirds the guest itself now, the copy an eighth, and
what is left of the mapping -- the flush when a slot is cleared -- under
4%. The boot's frame self-test passed on all twelve CPUs, each through its
own slot; apt-get update took 2 s with pipelining on and off; e2fsck
found nosenv and the guest's ext4 clean.
Then guests of more than one CPU (More than one CPU),
the same Debian image with cpus=2 and without nolapic: at its login
prompt in 3.2 s, both CPUs online, systemd running with no unit failed, a
gigabyte fetched at 113 to 116 MB/s, 1 GiB written with fsync at 1.2 GB/s
and read back at 1.5; its reboot came back with both CPUs, a CPU taken
offline and back was started again by INIT and a start-up IPI, and its
poweroff ended as it should -- "hlt with interrupts off, on every CPU it
has". What the first boot of it showed, and the next one fixed:
| in the Debian guest | 14a03e0 | with the fixes |
|---|---|---|
where cpus=2 put it |
13, 12: one core's two threads | 13, 11: two cores, one CCX |
| two busy loops, one alone taking 9.7 s | 14.7 s | 9.7 s |
| its CPUs on two CCXs | a 2052-cycle TSC "warp", jiffies | four CPUs on 13, 11, 9, 5: TSC kept, four loops 10.0 s |
| one CPU, on host cpu 0 | TSC lost one boot in two | TSC kept, three boots of three |
| its log | an unchecked MSR access (0x852), three firmware bugs | none |
The frames its port dropped on the way were not new: the kernel and module
before these, booted once more for it, dropped 431 and 608 a gigabyte for a
guest of one CPU, the module of 14a03e0 on the same kernel 139 and none,
and with the fixes a guest of two dropped 256, 500 and 129 -- the ceiling
of the vCPU that took every receive interrupt through the 8259, six exits
apiece. Its sampled profile over that fetch: 44% of its host CPU the guest
running, and the rest the exits, spread thin. MSI-X took the ceiling away
for a guest of more than one CPU: three gigabytes at 117 MB/s and not a
frame dropped (Device interrupts, by MSI-X).
e2fsck found nosenv and the guest's ext4 clean.
How to repeat it -- the kernel, the modules and the guest on the machine's
nosenv partition, one boot of nos by nosboot, the shell over ssh -- is
in Real hardware for the machine and in the gate's own
header for the guest; the session's recipe is short:
# on the AX41, in Ubuntu: build, and put the modules and the guest beside the kernel
make nocheck && cp out/x86_64/modules/*.ko /nosenv/ && mkdir -p /nosenv/hvguest
# bzImage and initrd into /nosenv/hvguest/, then one boot of nos
nosboot -n && reboot
# from outside, once nos is up
ssh root@<box> 'insmod /hv.ko' ; ssh root@<box> 'hv on'
ssh root@<box> 'hv boot /hvguest/bzImage initrd=/hvguest/initrd secs=20 input=id\n cmdline=console=ttyS0 nolapic rdinit=/init'
ssh root@<box> reboot # the one-shot is spent: back to Ubuntu
The EX44, under VT-x
The EX44 (an i5-13500: six P-cores of two threads and eight E-cores, with EPT, VPIDs and unrestricted guest) is the first Intel silicon VT-x has run on; everything before it was nested KVM. nos booted there from its system image, beside Ubuntu, on 2026-10-20 (Real hardware).
The first run (master's module, f3b0285): hv info ready, every
built-in guest passed on a P-core (CPU 0) and an E-core (62), and an exit
cost 1.67 and 2.07 µs on them -- against 5.8 µs nested. Linux 6.18 ran with
one, two and four CPUs; a virtio disk took writes with fsync at 1.0 to 1.2
GB/s and gave reads at 1.5; a guest fetched a gigabyte through NAT in 9.1 s
(118 MB/s) and served a page to the internet through hv forward; Alpine
3.24.2 came to its login prompt in 13 s and rebooted through 0xCF9 into a
restart; and rmmod with five VMs running stopped them all and left VMX off
on all twenty CPUs. It also showed, on the silicon, the VT-x halves of the
review that were still open -- a guest's #DB not given back, its CR4 not
intercepted, DR0-DR3 readable by the next VM, no speculation control -- and
two things that are not bugs of the extension: a guest of four CPUs placed
on one P-core's two threads and two E-cores (where its CPUs are placed knows
nothing of P and E yet), and the first write into a sparse disk image's
holes at 170 to 180 MB/s, against a gigabyte a second once its blocks are
there.
The second (the review's VT-x halves done -- item 1, 7 and 8, bugs 2 and
5): seventeen of seventeen built-in guests on a thread of each of two
P-cores (CPUs 0 and 41) and on two E-cores (48 and 62), debug, cr4 and
dr among them; every CPU deciding its own mitigations -- enhanced IBRS
with BHI_DIS_S, VERW for RFDS, no L1D flush, which is Linux's reading of the
same CPU -- and an IBPB between guests, 159 in the session; Linux with one
CPU and with two, both online and each taking its own timer, and a disk
written at 1.1 GB/s with fsync and read at 1.5; Alpine at its login prompt
in 13 s with the TSC its clock and not a line of its log an error, fetching
its package index through NAT; rmmod and a load again with nothing left
on. What the mitigations cost an exit is in What an exit
costs: 7% on a P-core, 20% on an E-core.
To repeat it there: build in Ubuntu, mount nos's root by its UUID, copy
hv.ko and the guests onto it, grub-editenv <root>/grubenv set nos_next=nos and reboot -- nos for one boot, Ubuntu after it (Install:
nos on a server) -- then insmod, hv on and the rest over
ssh as above.
What comes next
From plans/03-hypervisor.md, in order, each a
thing that can be shown in half a minute:
a VM object with nested page tables, and a guest of a few bytes that exits where it was told to-- under AMD-V,hv run exits;a guest in long mode under NPT/EPT-- NPT,hv run hypercall;an emulated 8250 on port I/O exits, and a--bzImageprinting its early consolehv boot, and the guest of A Linux guest below;a full boot to a shell over that UART, with an initramfs, on one vCPU-- with an initramfs, a BusyBox shell.
All four demos are done, and a guest runs a command typed at its console
(hv boot ... input='id\n' → uid=0 gid=0) -- under TCG and on the AX41's
real AMD-V (On real hardware). Guests also run until
they are stopped, reached from the shell (Guests that stay
up): the lifecycle the control plane will serve. The
VMX backend is there beside AMD-V, with CR0.NE set on every CPU (The VMX
backend).
The TLB is no longer flushed whole on every entry (Address space
identifiers), guests have disks and a network
over legacy virtio (A disk, A network) with a way
out through NAT (The way out), a
distribution boots as it ships (A distribution), and a
guest has as many CPUs as it is given, each with a local APIC (More than
one CPU) -- under AMD-V and VT-x both.
A guest of more than one CPU takes its devices' interrupts by MSI-X
(Device interrupts, by MSI-X), and every
guest has ACPI's tables and fixed hardware -- its power button what hv stop presses (ACPI). MMIO is emulated -- the instruction that
faulted fetched by the guest's paging, decoded and performed -- and the
xAPIC page is its first device, the IO-APIC its second (MMIO);
next on it are the HPET and PCIe's ECAM the tables leave out today.
Beyond stage 3: device work off the first CPU's task, modern virtio, and the
control plane's HTTP API (stage 4) -- and before any of it, what a review of
the whole found (What a review found).
Two constraints from stage 5 (live update) hold from the first line of it:
all VM state is serializable plain data -- the vCPU register set, every
emulated device, and one table mapping guest physical pages to the VM that
owns them -- and no emulated device holds a pointer into arbitrary kernel
memory, only indices and handles that survive a re-init. So far the VMCB
and the registers vmrun leaves to software are plain words, and guest
memory is regions of pages whose nested table is derived from them.
What a review found
A review of the whole of it on 2026-10-10 -- hvarch, hv and the module,
at cf87887 -- by five readers at once, each finding read again in the code
before it was written down here, left the list below. Each item says where,
what goes wrong and what would fix it, and is struck through when it is
done, as the steps above are. Where an item rests on what Linux does rather
than on this tree it says so: that part was read in Linux's source, not run.
The order to take them in: the bugs first -- each small, each with the fuzz target or the gate that would have caught it; then the CPUID and MSR policy as an allow-list, which is isolation and speed at once; then the guests' network kept off the host's services and the world's metadata; then the speculative-execution work and whole cores per guest, before any guest is someone else's; then speed.
Bugs
A guest can hold one of its CPUs' tasks in the host for good.Done: a pass takes a queue's worth of chains at most -- all a driver can have available at once -- and leaves the rest to the next round of the devices (PASSindevices/net.rsanddevices/blk.rs); hv-fuzz's racing CPU (Racerinfuzz/hv/src/memory.rs) tops a ring up while a notify or a round runs, and found both old loops in its first ten inputs. What it was: the virtio-net transmit loop (Net::send) and the virtio-blk one (Blk::fill) go round untilQueue::popfinds nothing, andpopreads the driver'savailindex afresh each time -- with no budget, no look at the stop flag, and the platform's lock held (the doorbell's port write runs them under it, as does the first CPU's round of the devices). A second vCPU that keeps the ring topped up, by plain writes to its own memory and no exit, keeps the one in the loop there forever:hv stopwaits on its task (finish), and so doesrmmod; the host CPU spins, every other vCPU that touches a port waits on the lock, and every frame goes into the switch at memcpy speed. Fix: at most a queue's worth of chains a pass, the rest left for the next round.An unprivileged process stops its whole VM.Done: the run loop gives a guest its own #AC and #DB back on both backends -- a single step, and ICEBP, stepped past -- once nothing an exit cut short is on its way in (GuestCpu::exception); an instruction of VMX's, SVM's or GETSEC is answered #UD, RDPMC #GP. Under VT-x, whose exit writes no DR6, the DR6 its qualification says goes into the guest's at the exit, and a single step out of an interrupt shadow is marked pending for the entry (The VMX backend). Thedebugbuilt-in guest single-steps and runs anint1and its own handler must see both, DR6 right, on both backends; hv-fuzz's platform target gives the run loop the guest's #DB and #AC, now and then over an event still on its way in. What it was: every intercepted exception was a stop (Stop::Exceptioninrun.rs), #DB and #AC are intercepted always (CVE-2015-5307 and 8104), and an exit the run loop does not know is a stop too (Stop::Unexpected). A single step -- gdb'sstepi, ptrace, a uprobe --int1, an unaligned access with EFLAGS.AC set (Linux runs with CR0.AM), or under VT-x any VMX instruction (vmxoffat CPL 3: in non-root operation it exits before the CPL is checked) ends the VM; and since that is not a reset,restartdoes not bring it back. Fix: keep the intercepts and give the events back -- #DB with its DR6 (the VMCB's under AMD-V, made from the exit qualification under VT-x), #AC with error code 0, ICEBP as a #DB, #UD for the VMX instructions and for RDPMC. Half of the job the VMX backend's debug-register item names.A virtqueue's indices are copied a byte at a time.Done: a ring's indices and flags are read and written as one aligned access --GuestMemory::read_u16andwrite_u16, overFrame::load_u16andstore_u16and the kernel'skernel_frame_load16andstore16-- the load an acquire and the store a release. What it was:read_u16ofavail.idxandQueue::push's store ofused.idxgo throughGuestMemory::readandwritetoFrameCopy, whoseMemCpyis arep movsbof two bytes: nothing makes the two one access. When a driver on another CPU moves the index across a multiple of 256 while the host reads it, 0x0100 can be read as 0x01FF --Broken::Overrun, the device dead until a reset Linux never does -- and the guest can read a used index half written. Fix: an aligned load and store of 2, 4 and 8 bytes as one access inkcore::frame, through the CPU's slot, for the ring's indices and flags.A CPU's x87/SSE owner goes stale when a vCPU moves.Done: a guest that loads its registers on a CPU takes its mark off every other (fp::registers_loaded), so its number is on one CPU at most -- the last it loaded on -- andregisters_aremeans what it says. What it was:fp::OWNERsays whose registers a CPU holds, and an entry skips its FXRSTOR when they are the guest's own; nothing takes the mark away when the same guest loads them on another CPU. A task that runs on A, then B, then A again resumes on A with the registers of its first stay there. A Linux guest's tasks are bound to a CPU each, so today onlyhv runwithout a CPU can move; anything that moves a vCPU later meets it. Fix: skip the FXRSTOR only on the CPU the guest last loaded its registers on.CR4.MCE is the guest's.Done -- under AMD-V:hvarch'sGuest::runsets MCE in the CR4 the guest runs with and intercepts every write of CR4, whatever the policy asks;hvintercepts the reads too, keeps the CR4 the guest wrote and answers from it (hv/src/cr.rs: the instruction fetched and decoded,insn::decode_mov_cr, since QEMU's and a nested hypervisor's CPUs give no decode assists), refuses with #GP a bit the guest's CPUID does not offer -- PKE among them, which closes the PKRU half of item 7 below under AMD-V -- and a write no CPU takes in long mode, and flushes the guest's TLB where the CPU would. Thecr4built-in guest, hv-fuzz's platform target (every read and write held to what it should have done, at the CPU's next entry) and insn-test's control registers are its gates; a Linux guest of one CPU and of two boots through it. The cost is an exit for everymovof CR4 -- two for each of Linux's global TLB flushes, which toggle PGE. QEMU's TCG, where the gates run, stops no read of CR4 (a bug of its own, above): there the reads are checked by the fuzzer alone, and thecr4guest says which it saw. And under VT-x, where the CR4 guest/host mask was VMXE alone: MCE in the mask with every bit the guest does not own, the guest's value in the read shadow, the register keeping MCE, and a write that changes a bit of the mask performed bycrate::cras AMD-V's are -- thecr4guest read back what it wrote while the CPU's kept MCE, on the EX44 and under nested KVM. What it was: AMD-V intercepted no CR at all and VT-x's mask was VMXE alone, so a guest could clear MCE -- a kernel built withoutX86_MCEdoes, from Linux's source -- and a machine check on that CPU while it ran was a shutdown: the host saw the guest's triple fault, restarted it, and never heard of the #MC. KVM keeps the host's MCE in the hardware CR4 under both vendors.#GP goes in with an error code whatever the CPU's mode.Done:inject_gpgives the error code only with CR0.PE set, on both backends. What it was: both backends'inject_gpset the error code. A CPU in real mode -- an AP between its start-up IPI and PE -- takes none: VT-x refuses the entry (the guest stops asInvalid) and AMD-V pushes a word its handler does not expect. Fix: no error code with CR0.PE clear.- In the module.
-- done: what a new boot throws away is counted as fed and noted as lost (hv execstops working for good after a boot that threw away unread inputInput::lost), and anhv execwaiting on a line of it says the line went with that boot. What it was:Shared::requeuecleared the queue butfednever caught up withqueued, sofed_atwasNonefrom then on.Forwards and NAT's mappings outlive their VM-- done:hv stoptakes a VM's forwards away with it (Forwards::drop_vm), and the switch has NAT forget its address's flows as it gives the port back (Switch::release,Nat::forget,kernel_net_nat_forget); hv-fuzz'snattarget forgets guests and holds the table to it, andhv-linux-test.py --netstops a VM with a forward and flows and looks. What it was:hv stopgave the port back to the switch, and the nexthv start ... nettook the lowest free one -- the same MAC and the same address (port_ip) -- and with it the old VM's forwards and the answers to its flows.Two VMs can open one image read-write-- done: a guest's disk claims its image as it opens it (disk::Claims), by the file -- its mount and inode (kernel_file_mount,kernel_file_key), so two names of one image are one -- a disk that writes it alone and any number that only read it together, and a start or a boot that cannot have its claim is refused saying whose the image is; the claim goes with the disk. Among the VMs only: nos's own shell is not held to it.hv-linux-test.pyrefuses a writer and a reader beside a writer, and a writer beside two readers;fs-fuzz.pyholds every open file's identity to the model's. What it was:FileDisk::openopened the file read-write for every VM that named it, and the VFS has no exclusive writer -- two ext4s on one set of blocks.Nothing keeps host memory back-- done: a frame is not taken past a reserve the kernel keeps for itself, a sixteenth of the machine and no less than 64 MiB (kernel_frame_alloc, overPageTable::AllocPageKeeping), so a guest too large is refused out of memory rather than taking the last page; andguest::buildreads and plans the image before it takes any of the guest's memory. The size of the reserve is a guess to revisit. What it was:mem=was checked against 4064 MiB alone, for up to 16 VMs, guest memory taken a frame at a time from the free list everything else uses -- a start could empty it, and the next infallible allocation anywhere panicked the host.-- done: it stays on the list,hv stoptakes the VM off the table before it waits for the guest to shut downstopping, while its power button has its time -- its CPUs counted,hv offand an unload seeing it, a second stop and a restart refused -- and an unload ends the wait. What it was: for up tosecs,rmmoddid not see it,hv listdid not show it, and its CPUs could be given to a new VM.An unload that finds a guest's VMCS still current on a CPU says so -- and then frees that CPU's VMXON page anyway-- done: the machine's drop leaks such a page, and says so, rather than leave a CPU in VMX operation over freed RAM. What it was:Machine::disablekept the page and the machine's drop freed it.
The safe half enters a guest over any table it is handed.Done: bothenters are the crate's own and take the&GuestMemorywhose table they enter over -- a table comes only from the memory that owns its pages, and the module cannot reach either. What it was:svm::Vcpu::enterandvmx::Vcpu::enterare safepub fns taking aNestedwhoserootandidare public, andVm::vcpu_mutreaches them: the invariant the crate split is for -- a guest's nested table maps its own pages -- is a promise of the one caller,Cpu::enter, not a property of the types. Fix:entercrate-private and taking&GuestMemory, aNestedthat onlynptandeptcan make.An unchecked add on a guest's RIP.Done: it wraps, as the CPU does. What it was: the VT-x I/O exit'snext_ripisrip + lengthwith no wrap, where everything else wraps: aninat the top of the address space is a host panic in aRUSTUB=1build. The fuzzer does not compilehv/src/vmx.rs, which is why it went unseen.
Before other people's guests
- Speculative-execution mitigation: the world switch done, under both
extensions; what is not the world switch's, not. Done under AMD-V
(What entering a guest cannot be told to
do): after every exit the
guest's values are cleared out of the registers the run stub returns
with, and the return stack is refilled before the host's first
ret; an IBPB goes between two guests on a CPU where the CPU has one; and automatic IBRS is on while the extension is where the CPU has that. hv-test runs every built-in guest through the first two on CPU 0 and on CPU 3, and holdshvto at least two switches between guests a guest, each with an IBPB where the CPU offers one. QEMU's TCG offers neither IBPB nor automatic IBRS, so those two writes have run on no machine yet; the AX41's Zen 2, which has IBPB, is where the first will. What it was: none of it, the guest's values left in the caller-saved registers the stub returned with. Left: on Zen 1 to 4 (the AX41), SRSO and Retbleed, which want the host's everyretthrough an untrained return thunk -- a compiler's job, for the whole kernel -- or an IBPB at every exit, and BTI where there is no automatic IBRS, which wants the host built with retpolines; and a guest's own speculation control (IBRS, STIBP, SSBD, IBPB), offered to none until its MSR is switched with it. Done under VT-x (The VMX backend), decided for each CPU from what CPUID and IA32_ARCH_CAPABILITIES say of it, with KVM's as the reference: the registers cleared and the return stack refilled after every exit, as AMD-V's stub does; the IBPB between guests; IBRS while the extension is on -- enhanced, set once, and on a CPU without it written again after every exit -- with BHI_DIS_S where the CPU has the control; VERW before every entry where MDS, TAA or RFDS apply; and the L1 data cache flushed before every entry where L1TF does. On the EX44's i5-13500 that is enhanced IBRS, BHI_DIS_S and VERW -- RFDS, by its E-cores -- what Linux reports for it ("Enhanced / Automatic IBRS; BHI: BHI_DIS_S", "Clear Register File"); on the Dell's Skylake it would be legacy IBRS, VERW and the L1D flush. hv-test holds VT-x to the switches and IBPBs as it does AMD-V, andhvreads every CPU's IA32_SPEC_CTRL back, warning where it lost whathv onset. On the EX44 they cost an exit 7% on a P-core and 20% on an E-core, most of that BHI_DIS_S (What an exit costs). Left under VT-x: the software sequence that clears the branch history, for a CPU with enhanced IBRS open to BHI and without BHI_DIS_S -- none of the machines here, but nos under nested KVM on QEMU 8.2, which does not pass BHI_DIS_S through, andhvsays so -- and L1TF with hyper-threading, which an L1D flush does not reach: the sibling's guest reads the cache meanwhile, and only a guest's whole cores with the host's work kept off them would. The CPUID and MSR policy is a block-list, and it swallows.Done: both are allow-lists (hv/src/policy.rs, A Linux guest). A leaf the policy does not name is all zeros, as is every leaf past the tops it reports, 0x16 and 0x8000_0021; of leaf 6 a guest is told ARAT alone, of 0x80000008 the address widths, CLZERO, the FP error pointers and two attacks the CPU is not open to, of 0x80000021 what is true of the silicon and asks nothing of the host. No speculation control is offered -- none has its MSR behind it yet (item 1) -- nor INVLPGB or AutoIBRS; the hypervisor bit is set. An MSR the policy does not name is a #GP both ways; what it names is the guest's own state, the TSC, or a constant that takes a write leaving it as it is and refuses any other: the MTRRs off, machine checks with no banks, the microcode revision none, and of each vendor what Linux reads of every such CPU without asking. The guest's own state takes what the CPU would: a PAT entry that is no memory type, an address that is not canonical and an EFER bit the CPUID does not offer (FFXSR) are #GPs, where they were a VMCBvmrunrefused; and since NB_CFG says AMD's extended configuration is on, a CONFIG_ADDRESS that names a register past 255 reads all ones. hv-fuzz'spolicytarget holds the policy to a model written from the manuals, with a table of what each CPUID bit promises: nine mutants, the old swallowing and IBPB offered among them, each failed within 1,600 inputs.hv-test.py'smsrguest has the run loop refuse SPEC_CTRL, PRED_CMD, an MSR nobody names and MTRRs turned on, and answer the machine-check registers and the PAT; a build that refused MCG_STATUS failed it.hv-linux-test.pyholds every boot's console to no "unchecked MSR access error" and no "MCA architectural violation", run with a guest kernel that has machine checks, MTRRs and PAT, as a distribution's does, on QEMU'smax, on a Zen 2 (--qemu-cpu EPYC-Rome-v1) and on an Intel CPU given AMD-V (--qemu-cpu Skylake-Client-v1,+svm,+npt,+nrip-save): the Zen 2's kernel probed LS_CFG, SYSCFG and the legacy PMU and was refused each, which it was ready for -- it no longer takes LS_CFG for an SSBD that wrote into nothing -- and the Intel's no longer turns its fast strings off or calls its microcode old; with MCG_STATUS refused, the Zen 2's guest panicked at boot ("MCA architectural violation!") and the check failed. What it was: every MSR the policy did not name read 0 and took any write without a #GP -- thoughrdmsr's comment promised aNonefor one -- and every leaf it did not name was the host's. Extended leaf 0x80000008 EBX went through but for two bits, so a guest on AMD was told of IBPB, IBRS, STIBP and SSBD and wrote PRED_CMD and SPEC_CTRL into nothing while its sysfs said it was mitigated: a failure that passes in silence. On AMD newer than the AX41 it was worse, from Linux's source: Zen 3's INVLPGB was offered while the VMCB does not enable it (a #UD in Linux 6.15's TLB flushes), and Zen 4's AutoIBRS was offered while the EFER bit that turns it on was refused -- which Linux does not notice, and ran with neither AutoIBRS nor retpolines.The guests reach the host's own TCP services.Done: a TCP listener and a connection belong to their device (net/src/tcp.rs, a slot'sdevice) -- a listener takes a SYN only from the device it was opened on, askcore::tcp::TcpListeneralways said it did, and a connection a segment or an ICMP error only from its own -- so sshd andhv forward's ports, on eth0, refuse a guest onhv0, at nos's address there and at eth0's, and an ICMP error from a guest cannot abort a connection of nos's over eth0. UDP's listeners were each device's already. net-fuzz'stcptarget has a guest send SYNs from a second device;hv-linux-test.py --nethas two guestsncnos's sshd and the test machine reach it from outside. What it was:find_listenermatched the address and the port, not the device, and sshd listened at every address: a guest connected to 10.0.100.1:22 -- the control run's guests had the banner both ways -- and four connections that never logged in held every slot for logging in, on the Hetzner boxes the only console there is. Forwards still draw on the same 64 connections the machine has (item 5).NAT lets a guest go anywhere nos can.Done: NAT keeps the guests from link-local, the private networks, the shared space and the uplink's own subnet unlessnat allowlets a network through, the DNS server they are given at port 53 aside, and answers a guest's packet for any of them with an ICMP "administratively prohibited" (The way out). hv-fuzz'snattarget holds every packet to a model of the rule -- control runs with nothing denied, the DNS server not let through,nat allowignored and the reject's code wrong each failed within thirty inputs -- andhv-linux-test.py --nethas a guest refused at the test machine and at 169.254.169.254 beforenat allow 10.0.2.2/32; on the stack before, the guest had the test machine's page and NAT mapped its connection to the metadata address. What it was:routablerefused 0/8, 127/8, multicast and the subnet's broadcast and nothing else, so 169.254.169.254 -- the host VM's credentials -- the private networks and the host's own LAN were all open.What the guests share has no quota.Done. NAT's half: a full table gives a new flow the mapping used least lately of the guest that holds the most -- the asker's own when it holds as many as any -- where it refused every new flow, so one guest that filled it kept every other from the world (The way out). The boot self-test holds the shares to the rule -- a guest's own flow, another guest's taken from the one holding the most, a tie, every slot held -- and hv-fuzz'snattarget floods the table and holds each flow taken to it; refusing when full, as before, failed it at once. And the soonest a sweep may find a flow run out is kept as each flow's time is, where a flow made after the last sweep could run out unseen and be counted live for up to two hours. And the switch's: a port takes no more ofhv0's 256-frame backlog, between two of the stack's passes over it, than the backlog over the ports in use, and sends to every port at 64 at once and 256 a second (A network); hv-fuzz'sswitchtarget bursts a guest's frames past both and holds every port's counts to a model of its own -- no share at all, no rate, a share not divided and one never counted afresh each failed within 40 inputs. What it was: one NAT table of 4096 mappings that refused a new flow when full rather than take the flooder's; one 256-frame backlog forhv0that a guest sending as fast as it could filled before the others' frames came; a broadcast copied to every port, each a wake or a kick, at any rate.Two VMs can share a core.Done: a guest's cores are its own (More than one CPU) -- whole cores no other guest has a CPU on, its vCPUs or its disks' task, its vCPUs filling their threads, its disks' task on a spare thread of its own cores, then on a core no guest has; refused where there is none, andhv cores sharedfor a machine whose guests are all one's own. The place and the hold on it are taken under one lock, so two starts at once are given cores apart. hv-fuzz'splacetarget holds every placement and every refusal to a model of its own over made-up hosts -- threads a core, caches, CPUs missing and off -- and five mutants (later cores not checked, one vCPU a core, the disks' task on a held core,cpu='s core not checked, the disks' task elsewhere before a spare thread of its own) each failed within two inputs; hv-linux-test puts vm 0 and vm 1 on cores apart, refuses a third while both are held, and serves a guest's disk on its own core. What it was:pick_cpuskept a guest's own CPUs off each other's cores, for speed, and nothing kept another guest off them, nor the task serving another guest's disk off the sibling thread. Left: the host's own tasks -- the network stack among them, which carries every guest's frames -- still run on any core; a core of the host's own for them is core scheduling, not placement.Debug registers and PKRU, under VT-x.Done, under both. Under AMD-V a guest's DR0-3 are its own, everymovof them intercepted and performed on a copy per vCPU, and the CPU's made that copy before each entry -- writing only what differs from what the CPU holds (What entering a guest cannot be told to do). hv-test'sdrguest runs two VMs on one CPU, the second reading 0 where the first left its own and each one's breakpoint firing at its own address -- the world switch before this failed it, the second VM reading the first's breakpoint address and pattern, and one without the load, or loading only when the guest's DR7 enabled a breakpoint, never fired the first's breakpoint; hv-fuzz's platform target holds everymovof them -- a read, a write, at CPL 3, under general detect -- to a model of its own, and seven mutants ofhv/src/dr.rseach failed within 145 inputs. What it was: DR0-3 switched by neither backend, one guest's breakpoint addresses the next's to read and to move. Under VT-x everymovof a debug register exits andcrate::dranswers it -- DR0-3 from the guest's own four, made the CPU's before its entries as AMD-V's are, DR6 from the guest's own, DR7 the VMCS's, which the entry loads (above); thedrguest's second VM reads 0 there too, each breakpoint firing at its own address, all eighteenmovs of a debug register the host's, and the platform target holds DR4-DR7 to its model as well -- three mutants ofdr.rs(DR6 written whole, the #UD of DR4 and DR5 checked after the CPL, no #GP for a bit above 31) each failed at once. And PKRU: a guest's write of CR4.PKE is a #GP under both now (item 5), its CPUID not offering PKU, and WRPKRU is then the #UD it is without PKE -- under VT-x it was the guest's to set, and nothing switched the register.VT-x's controls are taken on trust.Done: a control the host needs thatadjustdropped -- the exits for interrupts and NMIs, ports, CR8, MONITOR, MWAIT, RDPMC, everymovof a debug register and WBINVD, EPT, the 64-bit host, EFER's, the PAT's and DR7's switch -- or one the guest was promised, unrestricted guest and its VPID, refuses the guest, naming the field and the bits (Refusal::Controls). What it was:adjustdropped a control the CPU, or an outer hypervisor, did not allow, and only the MSR bitmap was looked at again: a CR8, WBINVD, I/O, external-interrupt or NMI exit gone missing would have gone unnoticed.A guest's console is the host's log.Done: a guest's console goes to the kernel log a boot's worth at once -- a thousand lines -- and fifty lines a second after that, per VM (modules/hv/src/guest.rs,LogLine::admit); what is left out is counted, and said once a second at most, before the next line logged, and when the boot ends -- a notice for every line logged after a gap would have doubled the rate -- and the console itself keeps every byte.hv-linux-test.pyfloods one guest's console with twenty thousand lines and holds the log to fewer than half of them -- by dmesg's count of the lines it pushed out, read before the flood and after -- every notice to the gaps in the numbers it came between, and the guest's console to all of them; on the stack before, the log took all of them and said nothing. What it was:hv boot, andhv start ... log, put every console line into the kernel's log at level 0 with no limit -- dmesg, the serial console, netconsole.
The CPU a guest is shown
- SSE4.2, and nothing after it. Half done: leaf 7 is open bit by bit -- SMEP, SMAP, UMIP, FSGSBASE, BMI, ADX, ERMS and FSRM, RDSEED, SHA, CLFLUSHOPT and CLWB, SERIALIZE, MD_CLEAR -- PCID is offered, and ARCH_CAPABILITIES reads as the host's but for what is not a fact of the CPU (A Linux guest). Alpine's kernel on the EX44 said before "meltdown: Mitigation: PTI" -- its page tables isolated, a whole TLB flush at every system call, no PCID -- MDS and RFDS "Vulnerable: no microcode", L1TF mitigated by PTE inversion, and none of SMEP, SMAP or UMIP in its flags; after, Meltdown, MDS, L1TF and the iTLB multihit "Not affected", RFDS "Mitigation: Clear Register File", and every one of those in its flags. PCID it turns off itself there ("Incomplete global flushes", an Alder Lake and Raptor Lake erratum); on another CPU it is used. Left: XSAVE, with XCR0 switched and leaf 0xD made up for it -- no AVX, so a distribution built for x86-64-v3 (RHEL 10 and its rebuilds) does not start -- and the speculation controls, SPEC_CTRL switched with the guest: Spectre v2 retpolines in the guest, its store bypass open. (The hypervisor bit, clear until the policy became an allow-list -- the guest believing it was on bare metal -- is set now: above, item 2.)
- MSRs that read 0 and should not. Intel's MISC_ENABLE without fast strings, which Linux answered by turning REP MOVS's fast paths off ("Disabled fast string operations"), has them as the host has now. Left: MTRR offered but MTRRcap and the default type 0 -- MTRRs "disabled by BIOS" -- which a Linux 6.1 (Debian 12) answers by leaving PAT off, from its source; a default type of write-back, written back as the guest sets it, would close it, with the MTRRs kept per CPU.
- Devices, smaller. The 16550 never sets IIR's FIFO bits, so Linux takes it for a 16450 and writes it a byte an interrupt; the RTC never interrupts (status B is read-only, C always 0, IRQ 8 never raised), so no update, alarm or periodic interrupt ever comes; the 8259 takes ICW4 and ignores it, auto-EOI with it; virtio-net offers no MTU, so a guest that raises its own past 1500 loses those frames; MSI-X entries start unmasked, their table being zeroed guest memory; VT-x never loads the PDPTE fields, so a 32-bit PAE guest that turns paging on faults.
- The guest's kernel is nos's file.
hv startboots the bzImage and initrd it is given, at every restart again, so a distribution's own kernel updates are never booted. A small loader that reads the guest disk's/boot-- its BLS entries, orgrub.cfg-- would close it.
Speed
- The CPU a guest is shown, above, is the largest lever on Intel.
- The disk is one request at a time, machine-wide.
Vfs::read_atandwrite_withinhold the VFS's one lock across the whole synchronous ext2 read or write, and a guest's FLUSH is a sync of the whole filesystem under it: every guest's disks and nos's own files go in single file. Fix: the image's block map resolved under the lock, the I/O through the block layer's asynchronous path, more thanIN_FLIGHT(8) in flight. - Fewer exits a request. No EVENT_IDX and no NO_NOTIFY, though both work on legacy virtio; and a guest of one CPU has no local APIC, so no MSI-X -- six exits an interrupt through the 8259.
The EX44's E-cores first, with shared cores.Done:hv::topologyasks each CPU of a hybrid part which kind of core it is (leaf 0x1A), and placement puts a guest's vCPUs on one kind where one has room for them all, the faster first; a tie underhv cores sharedgoes to the faster kind; and a disks' task given a core of its own takes a slow one. The hv fuzzer'splacetarget holds it to hybrid hosts made up an input, and three mutants -- no kind kept, the slower first, a disks' task on a fast core -- each failed at its first input. What it was: no rule knew a P-core from an E-core;hv cores sharedgave a tie to the highest CPU, which on the EX44 is an E-core, and a guest of four CPUs went on one P-core's two threads and two E-cores.PAUSE.Done under VT-x: pause-loop exiting where the CPU has it (More than one CPU), every PAUSE where it has not. On the EX44 a guest of two CPUs booting for twenty seconds exited 99,297 times where it had exited 299,508, its APIC timer calibrated as before (jiffies result ok). Left: AMD-V sets the pause filter's count and leaves its threshold unused.- The larger ones.
APICv and posted interruptsdone under VT-x, in x2APIC mode (Interrupts the CPU delivers itself);a NIC's receive interrupts moderateddone (A network), halt polling having turned out to do it by accident; left: the xAPIC page's virtualization, and AMD's AVIC; device work off the first CPU's task, with a lock a device (the ring pages mapped once rather than at every accessdone, Its memory) -- tried for a NIC, a task of its own on its guest's spare thread, polling with the driver's notifies off, and left out: for a guest of one CPU, the only kind its own cores leave a thread for, whose interrupts come through the 8259 and take it out of its guest all the same, no faster --dd | nc2.7-2.8 s against 2.66, BusyBox'shttpd0.42-0.59 s against 0.21; guests with MSI-X and a CPU to spare are where it could pay; nested leaves of 2 MiB and 1 GiB, over guest memory taken in runs that size; the VMX preemption timer, which would take a computing guest out at its timer's edge without the host interrupt that does it now (Time);virtio-net's offloadsdone both ways (A network): a stream between two guests is a fortieth of the frames it was, and a driver's scatter-gather, which cost a sender of one segment at a time 7% while every ring access mapped a page, 4% since -- inside the spread. - What has not been measured. The time each kind of exit takes
inside
LinuxGuest::run; fio and iperf in a guest. (An exit under VT-x on silicon is measured now: What an exit costs.)
Lifecycle, and stages 4 and 5
- A host
powerofforrebootstops every guest asrmmoddoes (stop_allwith no grace): no power button is pressed. finishandFileDisk::dropwait for their tasks with no deadline: a disk task stuck in a read hangshv stop,rmmodand the host's shutdown with nothing said. Wanted: a wait that names what did not answer, as the TLB shootdown does.- Stage 4 needs what the shell's strings stand in for today: a typed
start(Spec) -> Result<Id>and status, names, VM definitions kept across a reboot, images handled -- claims, copies -- and quotas. - Stage 5's constraints, above, do not hold yet. Guest memory is frames of
4 KiB from the general allocator, with no machine-wide table of who owns
them; the devices' timers keep absolute
boot_time_nsstamps a new kernel's clock would not continue; the guests run on the host's TSC with no offset; there is no pause and no snapshot of a guest. A first milestone that needs all of it and no kexec:rmmod hvandinsmod hvwith the guests kept running.
Tests
- CI runs
insn-test.pyandhv-fuzz.pyon the host and never loads the module.hv-test.py --arch aarch64can go in as it is; x86 under TCG wants QEMU 9.2 or later for thesmpguest, or a way to skip it. hv-linux-test.pyandhv-distro-test.pyare run by hand; a pinned tinyconfig bzImage and a BusyBox cpio, built once and cached, would put the first in CI.fuzz/hvcompiles neitherhv/src/{svm,vmx,vm,machine,npt,ept}.rsnor any ofhvarchbut the VMCB's layout, nor the module'svms,guest,diskandforward, and puts its ownmemory.rsin the real one's place. Three targets would have caught this review's bugs: the exit decoders over made-up VMCBs and VMCSs (bug 9), a model of the input queue'stype_in,requeueandfed_at(bug 7'shv exec), and a second vCPU that keeps re-posting chains (bug 1 -- there now, in theblkandnettargets).- No gate covers leaks over many starts and stops, a
mem=past free memory, sixteen guests,rmmodorpoweroffwith I/O in flight, a port or a forward used again, one image given twice, or a run of hours. - Hardware: VT-x has run on the EX44's silicon by hand (On real hardware), and its gates run under nested KVM; the Dell's Skylake, whose paths -- legacy IBRS written after every exit, the L1D flush -- no other machine here takes, never; the arm64 backend reports and does not enter.