Virtualization · Hypervisors

Paravirtualization and the Hypercall ABI: Trading Transparency for Fewer Traps

How a modified guest replaces trapping instructions with explicit calls into the hypervisor, what a hypercall looks like at the instruction level, and why the ABI has to be versioned and discoverable.

Trap-and-emulate asks the hardware a question: does this instruction fault when the guest is not entitled to it? Paravirtualization stops asking. The guest is modified so that instead of executing an instruction the hypervisor would have to intercept, it makes an explicit call into the hypervisor, passing its intent as arguments. The hypervisor handles the call directly, with no decode step and no attempt to reconstruct what the guest meant from instruction bytes.

The price is paid up front and permanently: the guest is no longer an unmodified operating system image. It is built against an interface, and that interface becomes a compatibility obligation for both sides. The gain is that the hypervisor no longer needs to be correct about the full instruction set, only about the calls it defines.

Why the interface is not just a system call

A system call enters the kernel from a user process. A hypercall enters the hypervisor from a guest kernel. The two look similar and differ in three ways that shape the design.

First, the caller is already privileged. The guest kernel is running at ring 0 under hardware assistance, or at a deprivileged level under trap-and-emulate, and it is calling not because it lacks the right but because the right belongs to someone else. The hypervisor therefore cannot assume the caller is untrusted in the way a user process is; it must validate the arguments, because a guest kernel with a bug or a malicious intent is exactly as dangerous as a user process with the same.

Second, the call may block. A guest kernel asking the hypervisor to map a foreign page, or to send a packet, may have to wait, and the hypervisor may need to run another virtual CPU while it waits. The ABI therefore has to specify whether a call is synchronous, whether it can be preempted, and what state the caller’s registers are in when it resumes. This is the part that is genuinely hard, and it is why paravirtualized interfaces tend to be asynchronous wherever possible: a call that queues a request and returns immediately has no resumption semantics to get wrong.

Third, the interface is versioned across an unbounded number of guests. A hypervisor written for one operating system version can be sloppy. A hypervisor that must run guests compiled years apart cannot, so every interface needs a version number and a way for the guest to discover what the current hypervisor supports.

The mechanics of a call

On x86, a hypercall is conventionally a software interrupt with a reserved vector, or a special instruction, or a write to a magic port. The choice is constrained by what the deprivileged guest can execute. A software interrupt with a vector works from ring 3 and from ring 1, and the hypervisor owns the corresponding gate, so it is the portable choice for a guest that has been moved down the privilege ladder.

The arguments are passed in registers, and the number of arguments is limited by how many registers the ABI reserves. Four to six is typical. Because a call can need more than that, the interface usually has two forms: a fast form that takes everything in registers, and a slow form that takes a pointer to an argument structure in guest memory. The slow form is where validation becomes serious, since the hypervisor must translate and bound-check every field of a structure whose layout the guest controls.

/* A guest-side hypercall stub: one instruction, arguments already in registers. */
static inline long hypercall0(unsigned long nr)
{
    long ret;
    register long r8 __asm__("r8") = nr;

    /* Vector 0x82 is the convention this interface reserves for hypercalls. */
    __asm__ volatile ("int $0x82"
                      : "=a"(ret)
                      : "r"(r8)
                      : "memory");
    return ret;
}

The return value convention matters more than it looks. A hypervisor that returns an error code in the same register as a successful result forces the guest to distinguish them by inspecting the sign, and every negative return becomes an errno the guest kernel maps into its own error space. Interfaces that instead write results into a shared structure and return only a status keep the register contract small and let the structure grow.

Replacing the instructions that trap

The interesting content of paravirtualization is not the call mechanism but the catalogue of things the guest used to do directly and now asks for.

Page tables are the largest item. A paravirtualized guest does not write hardware page tables at all. It maintains its own structures in a format the hypervisor understands, and it registers them with the hypervisor through a call. When it needs a mapping to become visible to the hardware, it asks the hypervisor to install the entry, which the hypervisor validates and writes into the real tables. This removes the shadow-table trap entirely, at the cost of a call per mapping batch. The batch is what makes it work: an interface that installs one entry per call would be worse than shadowing, so the interface takes a list.

Privileged registers are next. Reading and writing the page-table base, the global descriptor table base, the interrupt descriptor table base, and the control registers all become calls. The guest keeps a software copy of each and asks the hypervisor to update the hardware when the copy changes, which means the guest’s copy is authoritative and the hypervisor’s is a projection of it. That inversion is the source of a whole class of bugs: if the guest changes its copy without calling, the hardware and the software disagree, and only the hardware is walking the tables.

Interrupt masking becomes a call. This is a direct fix for the POPF problem described in the trap-and-emulate article. The guest no longer manipulates the interrupt flag with an instruction that silently fails; it tells the hypervisor whether it wants interrupts, and the hypervisor records the intent in its own per-virtual-CPU state. The guest’s belief and the hardware’s behaviour can no longer diverge, because there is only one place the belief is stored.

Time becomes a call. Reading the timestamp counter directly is not virtualizable in the strict sense: the guest can read it, the hypervisor cannot intercept every read cheaply, and a guest that compares two reads can detect that it was descheduled. Paravirtualized guests read time through an interface that returns both the counter and a scaling factor the hypervisor controls, so the guest’s notion of elapsed time can be made consistent with the time it was actually running.

Discovery, versioning, and the compatibility surface

A guest kernel compiled against a paravirtual interface has to find out at boot which version of the interface the hypervisor implements. The usual mechanism is a structure placed in guest memory before the guest starts, or a call that fills one in. The structure carries a version number, a set of feature flags, and often a pointer to a shared information page that the hypervisor updates asynchronously.

The feature flags are the compatibility surface. A hypervisor that adds a new call marks a flag, and the guest uses the call only if the flag is present. A guest that requires a call the hypervisor lacks must fall back, which means the guest carries two paths for the same operation and the fallback path is the one that is rarely exercised. This is the structural weakness of paravirtualization: every optimization adds a branch to the guest, and the branches accumulate.

The version number is separate from the flags and covers the meaning of the fields, not their presence. A field that changes meaning between versions is a breaking change, and the interface has to either define a new call or require the guest to negotiate a version it understands. Implementations that overload an existing field with a new meaning and gate it on a flag produce guests that read the field the old way and get a value that is not wrong but is not what they expect.

Where paravirtualization still wins

Under hardware assistance, most of the trapping reasons disappear, and the case for paravirtualization narrows to operations the hardware does not accelerate. Three remain substantial.

The first is interrupt delivery. Injecting a virtual interrupt through the hardware-assisted path costs an exit or a write to an in-memory structure the processor polls, depending on the feature set. A paravirtualized interface that delivers interrupts through a shared ring avoids the exit for the common case. The second is I/O, where the notification path between guest and hypervisor is not an instruction the processor can accelerate at all. The third is time and scheduling, where the guest’s cooperation lets the hypervisor make better decisions about when to run it.

The pattern is consistent: paravirtualization survives wherever the bottleneck is a software boundary rather than a privileged instruction. The virtio device model is the largest instance of that pattern, and it is the reason paravirtualized drivers outlive paravirtualized kernels. Meanwhile the instructions that hardware assistance did absorb, page tables and privilege transitions, are exactly the ones where a paravirtual interface is now mostly a historical artifact kept alive for compatibility with guests that predate the hardware.

There is a cost that never goes away: a paravirtualized guest cannot be an arbitrary guest. An image built for bare metal will not use the interface, and the hypervisor must support both paths. Every hypervisor that offers paravirtualized drivers also offers emulated devices, which means two device models, two interrupt paths, and two sets of bugs, indefinitely.

References

  1. Xen and the Art of Virtualization — Barham et al., SOSP 2003
  2. Denali: Lightweight Virtual Machines for Distributed and Networked Applications — Whitaker et al., University of Washington technical report, 2002
  3. The Linux Kernel Documentation: Xen — Linux kernel project