Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

How to read this book

Building Kernelets on Linux explains the design and implementation of the kernelet: a complete OS kernel that runs inside another kernel, and does so safely.

The book has two parts:

  • Accepted Design describes the design the project currently endorses, its implementation in prototype/, and its limitations. These chapters build up the working system step by step.
  • Design Proposals is the place to develop and review optimizations, alternative designs, and new material. A proposal is an idea under discussion, not a promise about the accepted design or the prototype’s behavior. Anyone can contribute a proposal, including the book’s author.

An accepted proposal is incorporated into Accepted Design and, where applicable, the prototype. Declined or withdrawn proposals remain available with the reasons for the decision, so future contributors can learn from the discussion. Acceptance is what separates the two parts; authorship does not.

Start with Accepted Design to understand the system. To suggest a change, read Contributing a design proposal and use the proposal template. Each proposal’s title and filename begin with its original submission date, such as 2026-10-27 Improve syscall performance and 2026-10-27-improve-syscall-performance.md.

The companion prototype patches and extends Linux. With those changes small Rust kernels written against OSTD, the framework at the bottom of the Rust kernel Asterinas, can run inside Linux, scheduled and stopped by Linux without Linux’s own code needing to understand the kernel it runs.

By the end you will know how every part of that prototype works, down to the C and Rust that do it. You will also have run it; the prototype’s README says how.

Who this is for

The book assumes three things: what one operating-systems course teaches, the ability to read C, and a fair command of Rust. From the course you know what a process is, what virtual memory and a page table are, what a system call does, and what an interrupt and a context switch are, in the abstract.

It assumes nothing about the Linux source. Background knowledge beyond the course, whether about Linux, ELF, compiler behavior or processor details, is explained the first time it is needed in a background box. Each box begins with a subject and a topic, such as Linux: control groups or ELF: relocations. It explains the concept, the names used for it in the relevant code or format, and why it matters to the kernelet design.

Linux: control groups. A control group is a set of tasks that Linux accounts and limits together. The prototype puts a sandbox’s carrier tasks in one group so that Linux can account for their resource use together.

If you already know a box’s topic, you can continue with the main text. The same box style is used for every subject; the label tells you which background knowledge it introduces.

Code excerpts

Code excerpts in Accepted Design come from the prototype/ directory of this repository. Source paths in their captions are relative to the repository root, even though the book’s source lives in book/. Design proposals may also show proposed code; they must distinguish it from code already in the prototype.

A caption under each excerpt names the file it comes from and, for a simplified excerpt, the functions it shows, like this:

Simplified from prototype/linux/endovisor/switch.S: klet_switch.

The caption’s first word says how the block differs from the file. “Simplified” means that logging, counters, error paths and comments have been cut so that the mechanism fits on the page. “Unchanged” means that the block is copied from the file verbatim.

The Accepted Design chapters

Each chapter uses only what the ones before it have built.

Some words in the list below are defined only in the next chapter; skim past them.

  1. Why a kernel of your own says what a kernelet is and why it can be safe.
  2. The endovisor UAPI, its user-space interface, is what a program on the host may ask of the endovisor: the image as a file, the device node and its descriptors, and how user space creates the carriers.
  3. The first kernel we will run reads the hundred-line kernel that runs throughout the book, and lists what it needs from a machine.
  4. The kernelet ABI, its binary interface, is how a kernelet talks to its host from the inside: the tables passed between host and kernelet, the grant, and the first services.
  5. Carrier hooks and patch overview adds the two carrier event hooks, where a carrier enters and leaves kernelet code, and lists the rest of the small patch the prototype makes to Linux.
  6. Carriers puts a kernelet on the Linux tasks it runs on.
  7. Two stacks is how a kernelet keeps its own stacks on a Linux task.
  8. Into user mode and back is the round trip of a system call, after which the hundred-line kernel prints its greeting.
  9. Tasks and virtual CPUs gives the kernelet its own tasks and its own scheduler.
  10. Virtual interrupts gives it a timer.
  11. Sharing a processor politely keeps it from holding a processor against Linux.
  12. Stopping a kernelet removes it, even when it will not cooperate.
  13. Tenant memory on Linux’s page tables puts the tenant’s memory in a Linux address space that the kernelet edits only by asking.
  14. What vOSTD adds on top is the bookkeeping that keeps that arrangement memory-safe, or sound, in chapter 1’s term.
  15. What it costs gives the numbers.

Chapters 13 and 14, where the tenant’s memory moves onto Linux’s page tables without giving up soundness, are the climax; everything before them prepares for them.

Conventions

The book keeps three conventions.

  • A few words are used in exactly one sense throughout; the word table at the end of the next chapter collects the first of them, and the chapter named beside a word there defines it in full.
  • Linux functions are linked to the version 6.12 source on elixir.bootlin.com the first time they are named, so that you can read the real code.
  • Most figures are diagrams written as text in the page source; a few are drawings.

Why a kernel of your own

A cloud provider, the operator of its machines, must keep its customers, called tenants, from reading or corrupting each other’s data, or starving each other of the machine. The business is profitable only because one physical computer runs programs from many tenants. Tenants do not trust each other, and the operator trusts none of them.

There are two established ways to keep them apart. This chapter introduces a third.

Two ways to share a machine

A virtual machine separates tenants strongly, at the price of a second layer of everything. It gives each tenant a whole simulated computer, in which the tenant installs a kernel and runs programs on that kernel. A layer underneath, the hypervisor, shares the real machine among the simulated ones. The separation is the hardware’s own: the processor refuses to let a guest touch anything outside its simulated machine. The second layer is a second kernel per tenant, a second translation of every memory address (once through the guest’s page table, once through the hypervisor’s), and a trap into the hypervisor whenever a guest touches a device the hypervisor simulates.

A container costs almost nothing, but separates tenants only as deeply as the shared kernel is correct. It gives each tenant a slice of one kernel; what separates tenants is bookkeeping inside it, which hides one tenant’s processes and files from another’s. There is one kernel and one translation of every address, which is why it is cheap. But every tenant can call nearly every system call of the shared kernel, in practice Linux, which is built from tens of millions of lines of C, and a bug anywhere in that surface is a hole between tenants.

flowchart LR
    subgraph KL["Kernelets"]
        direction TB
        b3["tenant B's<br/>programs"] --> bkl
        a3["tenant A's<br/>programs"] --> akl
        subgraph hk["the host kernel"]
            direction TB
            bkl["tenant B's<br/>kernelet"] --> endo["the endovisor"]
            akl["tenant A's<br/>kernelet"] --> endo
        end
        endo --> hw3[hardware]
    end
    subgraph CT["Containers"]
        direction TB
        a2["tenant A's<br/>programs"] --> sk["one shared<br/>kernel"]
        b2["tenant B's<br/>programs"] --> sk
        sk --> hw2[hardware]
    end
    subgraph VM["Virtual machines"]
        direction TB
        a1["tenant A's<br/>programs"] --> ak["tenant A's<br/>kernel"]
        b1["tenant B's<br/>programs"] --> bk["tenant B's<br/>kernel"]
        ak --> hv[hypervisor]
        bk --> hv
        hv --> hw1[hardware]
    end
    VM ~~~ CT ~~~ KL

In each panel an arrow points from code to the layer that serves its requests; a kernelet’s requests go to the endovisor, introduced below.

A kernel inside the kernel

The third panel is the kernelet: a tenant’s own kernel, loaded into the kernel that runs the machine, the host kernel, and run there in kernel mode beside the host’s own code. Each tenant gets a kernel of its own, as with a virtual machine, but with no hypervisor and no simulated hardware: a kernelet is an ordinary piece of code that the host loads into its own memory.

A component of the host kernel, the endovisor, creates, serves and destroys kernelets, as a hypervisor does its guests, but from inside the host rather than beneath it.

A kernel of its own means one that no other tenant shares, not one the tenant wrote. The operator, not the tenant, compiles every kernelet. Code in kernel mode can do anything: read any memory, write any page table, halt the machine. Loading a binary a tenant supplied would hand the tenant the whole computer. So the operator builds each kernelet from source, with a compiler the operator trusts, and offers tenants a choice among those kernels.

A kernelet must keep even its own bugs inside its tenant. Compiling the kernelet does not make it correct, and the tenant’s programs are hostile. A kernelet has two layers, described in the next section: a large one, the kernel proper, which implements the system calls the tenant’s programs make and certainly has bugs, and a small one underneath it. What has to hold is that even a kernel proper that a tenant’s program has taken over through such a bug cannot touch anything outside its own tenant: a bug in a kernelet should hurt only the tenant it serves. The next two sections show why the way a kernelet’s source is written makes that true.

A kernel in two layers

A kernelet’s source is written the way Asterinas is: as a framekernel, split into two layers with a hard rule between them. Asterinas is a Rust kernel that aims to run the programs Linux runs.

  • OSTD, the framework, is the small part of the kernel that may use Rust’s unsafe. It wraps the machine (page tables, tasks, interrupts, entry to user mode) in an interface that cannot be misused to corrupt memory.
  • The kernel proper is everything else: system calls, file systems, the network stack, process and memory management. It is written entirely in safe Rust against OSTD’s interface, in every one of its crates.

So the kernel proper can reach the machine only through OSTD’s interface. It cannot read through a forged address, because safe Rust cannot dereference one. It cannot execute a privileged instruction, because that takes inline assembly, which is unsafe. It can change a page table only by asking OSTD, which checks every request before carrying it out.

The libraries the kernel proper uses must not give it a way around OSTD either. Every crate from outside the kernel proper must offer no safe function that does what only unsafe code should, such as running a privileged instruction or handing the hardware an unchecked physical address. Rust’s core, alloc and compiler_builtins are the exception: they contain unsafe code that every Rust program trusts.

Asterinas’s own kernel proper does not yet keep that rule in every crate it links. Its default build pulls in a confidential-computing crate whose safe functions hand the hardware an unchecked physical address, which Asterinas’s own source marks as a fault to fix, and a processor-support crate whose safe functions run privileged instructions. A kernelet build must leave both out.

This book’s kernels link nothing beyond core, alloc, compiler_builtins, one small helper crate shared with OSTD, and vOSTD.

The kernel proper’s confinement to OSTD’s interface rests on a property of OSTD called soundness: no safe Rust code written against its interface can break memory safety, whatever that code does. Soundness is the foundation of kernelets, as the next section shows.

OSTD’s soundness is argued, not proved. Its authors lay out the argument in a written soundness analysis and rely on review; research on Asterinas adds fuzzing, model checking and a verified memory-management design.

The same kernel, a different framework

Now give the kernel proper a different OSTD, one that confines every request it passes to the host to one tenant’s resources. It keeps the same interface, so the kernel proper needs no change at all. Underneath, most operations that would touch hardware become requests to the host, and the few that are harmless to it, such as reading the cycle counter, the framework still does itself. The book calls that framework vOSTD, virtual OSTD, as a virtual machine is a virtual computer. The kernel proper is linked with vOSTD into one image, which is what runs as a kernelet.

Whether a kernelet can corrupt the host, and through it another tenant, then comes down to four things being right: vOSTD, which is new code that uses unsafe as OSTD does; the unsafe code inside core, alloc and the library crates; the compiler; and the host’s handling of each request. OSTD’s soundness analysis does not carry over to vOSTD, so the chapters that build vOSTD argue its soundness piece by piece.

That covers corruption, and reads the program asks for. Side channels, by which code learns what it may not read through timing or speculative execution, are not stopped by the language; this book treats them as out of scope.

A kernelet reaches the host in only a few fixed ways, because its boundary is the language, not a hardware mode. What the kernel proper can call or refer to is only what is linked into its image: its own crates; Rust’s core, alloc and compiler_builtins; the library crates it depends on; and vOSTD. The image calls no symbol of the host’s. What vOSTD can ask of the host is a fixed list of functions, the service table, that it is handed when it starts. Besides those calls, vOSTD touches the host’s memory only where the host hands it a place: the arguments it is started with; the memory its services hand it; a few words of the host’s own; one shared record per virtual CPU (one of the fixed number of processors a kernelet is given) and per tenant address space; the tenant’s page tables, which the host keeps; and the host’s direct map, its mapping of all physical memory, through which vOSTD reaches the tenant’s pages and must reach nothing else. The chapter on the kernelet ABI lists them.

The language confines what a kernelet can touch, not how long it holds a processor. Keeping a kernelet from starving its neighbors is the host’s job: the endovisor’s per-processor timer takes a processor back even from kernelet code running in kernel mode. The kernel proper can hold that off only through vOSTD’s guards, which the timer overrides after about two ticks. The chapters on virtual interrupts and on sharing a processor show how.

What the book builds

Any kernel that implements the service table and hands over those places could host kernelets; in this book the host is Linux. Hosting kernelets costs it a small patch, two configuration settings, and the endovisor as one kernel module. The operator rebuilds Linux 6.12 for x86-64 with the patch’s option on and with vsyscall emulation off, which would otherwise let a tenant have Linux run a system call the endovisor never sees; runs it with four-level page tables and without indirect branch tracking, since the image carries no branch-target markers; and loads the module.

The book runs five small teaching kernels as kernelets inside that Linux. Through them it exercises the hardest parts of OSTD’s interface with the real Linux mechanisms. The first is a teaching example from the Asterinas project, run with its source unchanged: a kernel of about a hundred lines of safe Rust that runs one user program and serves its two system calls, write and exit. The other four, written for the prototype, exercise what the hundred lines do not reach: a scheduler of the kernelet’s own, the host taking a processor back from kernel code, exceptions and being stopped, and a tenant address space under load. A sixth kernel tests the PIE image format by running concurrent instances with shared code and private state.

The prototype gives each sandbox a fixed grant of 2 or 4 MiB of physical memory and has no devices. Its PIE image format lets sandboxes of the same kind run concurrently, sharing physical code pages through separate virtual mappings while keeping their writable data private. The tests exercise this separation; they do not constitute a proof of isolation against every possible kernel bug.

Its kernels and vOSTD are smaller than Asterinas’s. Its vOSTD offers the part of OSTD’s interface that its kernels use, plus a small instrumentation module, outside OSTD’s interface, that one test kernel uses to read counters and to slow one service down. None of its kernels is Asterinas’s full, Linux-compatible kernel proper, which has not yet run as a kernelet.

The words

These are used in one sense throughout the book.

wordmeaning
tenantwhoever’s programs run in a sandbox; assumed hostile
operatorwhoever runs the machine: builds the kernelets, configures the host, and is trusted
kerneletone instance of a kernel proper written in safe Rust against OSTD’s interface (in principle Asterinas’s full kernel proper; in this book, a hundred-line example kernel from the Asterinas project and four small test kernels), linked with vOSTD and running in kernel mode inside the host kernel
sandboxone tenant’s environment: a kernelet plus the user-mode programs it serves. What a virtual machine or a container is to its tenant
host kernel, or hostthe kernel that owns the machine. In this book, Linux
endovisorthe component of the host kernel that creates, serves and destroys kernelets. Endo- because it sits inside the host kernel, beside the kernelets, where a hypervisor sits beneath its guests. In this book, a Linux kernel module
vOSTDthe framework inside a kernelet: OSTD’s interface with a different implementation underneath
service tablethe fixed list of host functions vOSTD may call; its only way to call the host
grantthe physical memory a kernelet has been given, from which it maps its tenant’s pages
virtual CPUone of the fixed number of processors a kernelet is given when it is created; the kernelet schedules its tasks on them as a kernel does on real processors (chapter 9)
carriera Linux task (Linux’s word for a thread) that carries one of a kernelet’s virtual CPUs: Linux schedules the task, and the kernelet runs its own code and the tenant’s programs on it (chapters 2 and 6)
kernelet runtimethe host program that creates and configures sandboxes, as a container runtime does (chapter 2)
carrier event hooksthe two functions Linux calls for a carrier, at a system call and on the way back to user mode, which is where a carrier enters and leaves kernelet code; the core of the one small patch to Linux (chapter 5)

A term with a chapter beside it is defined in full there.

Recap. A kernelet is a tenant’s own kernel: a kernel proper in safe Rust, compiled by the operator, over a framework, vOSTD, that offers the same interface as the real framework but confines every request to the tenant’s own resources. A bug in it should hurt only the tenant it serves. It reaches the host only through the service table and the memory the host hands it, and the host’s timer, not the language, keeps it from holding a processor. Linux is the host in this book; the endovisor is the Linux module that serves kernelets.

The endovisor UAPI

The endovisor’s user-space interface, its UAPI, is one header, prototype/linux/endovisor/uapi/endovisor.h: one device node, three kinds of file descriptor, and one rule about who creates the tasks. Linux calls the set of system calls, device nodes and file formats that user space may use its UAPI, the user-space application programming interface, and keeps the headers that define it in a directory of that name. Everything the rest of the book builds is behind this interface, and nothing a tenant does ever touches it.

This chapter first shows the interface in use, by the prototype’s own holder program, then says why it has the shape it has, and then specifies every operation.

A prototype kernelet runtime

The program that drives a kernelet sandbox is its holder: the one process of the kernelet runtime that creates the sandbox, keeps its descriptor open for as long as the sandbox lives, and closes it to release the sandbox. A container runtime has such a process per container; the prototype’s is holder.c, a static C program of two commands, register and run, around the client functions of sandbox.c, which the prototype’s test drivers share. The prototype’s tests run through the interface: the holder registers each kernel’s image and runs the sandbox, and a self-test run before the hello kernel boots exercises every operation.

Linux: device nodes, ioctl and anonymous files. A device node is a file under /dev that stands for a driver rather than for data; opening it gives a descriptor on which the driver answers read, write and, for everything that is neither, ioctl: one system call that takes a descriptor, a command number and an argument, and hands both to whatever code is behind the descriptor. A driver’s interface is therefore a list of command numbers, and a module may define its own.

A descriptor need not come from a file system at all: a module can make an anonymous file, with operations of its choosing, and hand out a descriptor on it. The sandbox descriptor below is one of those; the exec descriptor is a third sort, a descriptor on a file that exists only in memory.

The operator’s step. Once per boot, someone with the right to load kernel code registers each kernelet image the machine will run, one file holding a compiled kernelet (its format is given with KLET_KIND_REGISTER below), and names it:

Simplified from prototype/host/runtime/sandbox.c: open_system, register_kind.

int open_system(void)
{
	int sys_fd = open("/dev/endovisor", O_RDWR | O_CLOEXEC);

	if (ioctl(sys_fd, KLET_GET_API_VERSION) != KLET_API_VERSION)
		exit(127);                                  /* an interface this program does not know */
	return sys_fd;
}

int register_kind(int sys_fd, const char *name, const char *image_path)
{
	struct klet_kind_register reg = { .image_fd = open(image_path, O_RDONLY | O_CLOEXEC) };

	snprintf(reg.name, sizeof(reg.name), "%s", name);  /* the name KLET_KIND_LIST reports; unique */
	return ioctl(sys_fd, KLET_KIND_REGISTER, &reg);    /* the kind's identifier */
}

A registered image is a kind, and a kind is what sandboxes are made of. Registering it is the privileged act, and it happens once; creating sandboxes of it is not privileged and happens as often as the runtime likes.

Linux: control groups. A control group is a set of tasks that Linux accounts and limits together: their share of the processors, their memory, and more. Groups form one tree, the unified hierarchy of cgroup v2, which this book means throughout. A process joins a group when its identifier is written into the group’s cgroup.procs file, and a child starts in its parent’s group. Containers are built on them, and so is a sandbox: because every task of the sandbox is in the sandbox’s group, the processor time the kernelet burns and the memory Linux allocates for it are charged to the tenant by machinery Linux already has.

The holder’s step. To run a sandbox, the holder creates it, gives it its console and log, and then makes one carrier per virtual CPU:

Simplified from prototype/host/runtime/sandbox.c: run_sandbox, wait_sandbox.

int run_sandbox(int sys_fd, const char *name, unsigned int nr_vcpus, const char *cmdline,
		const struct sandbox_opts *opt)
{
	struct klet_create create = {
		.kind = find_kind(sys_fd, name),               /* KLET_KIND_LIST, matched by name */
		.nr_vcpus = nr_vcpus,
		.memory_initial = opt->memory_initial,        /* 4 MiB unless asked otherwise */
		.memory_max = opt->memory_max,
		.max_tasks = opt->max_tasks,
		.cmdline = (__u64)(unsigned long)cmdline,
		.cmdline_len = strlen(cmdline),
	};
	struct sandbox sb = { .sys_fd = sys_fd, .nr_vcpus = nr_vcpus };
	int out = 1, err = 2;

	if (opt->cgroup)
		join_cgroup(opt->cgroup);                   /* before the create: this group becomes the sandbox's */
	sb.fd = ioctl(sys_fd, KLET_CREATE, &create);        /* the sandbox descriptor */
	ioctl(sb.fd, KLET_SET_CONSOLE, &out);               /* the sandbox's console: our standard output, or a file */
	ioctl(sb.fd, KLET_SET_LOG, &err);                   /* its kernel log: our standard error, or a file */
	/* a disk would be attached here: KLET_ATTACH_DEVICE with its open file */

	for (i = 0; i < nr_vcpus; i++) {
		int exec_fd = ioctl(sb.fd, KLET_GET_VCPU_EXEC_FD, &i);

		sb.pids[i] = fork();
		if (sb.pids[i] == 0)
			start_carrier(exec_fd, i, opt);         /* the child; does not return */
		close(exec_fd);
	}

	wait_sandbox(sb.fd, sb.pids, nr_vcpus, &sb.ex);     // Poll, then read the exit record.
	// The helper also watches the children: a child that dies before its exec
	// never became a carrier, so the holder kills the sandbox itself if one does.
	for (i = 0; i < nr_vcpus; i++)
		waitpid(sb.pids[i], NULL, 0);               /* the carriers died by SIGKILL */
	close(sb.fd);                                  // The last close releases the exited sandbox.

	switch (sb.ex.how) {
	case KLET_END_EXITED:   return sb.ex.code;      /* the kernel proper's exit code */
	case KLET_END_PANICKED: return 128;
	default:                return 129;             /* killed */
	}
}

The loop in the middle is the heart of the interface. A carrier, chapter 1’s term for a Linux task that carries one of the sandbox’s virtual CPUs and may run the tenant’s programs, is not made by the kernel. The holder forks a child for it, the child arranges itself with ordinary system calls, and then the child executes a descriptor the sandbox handed out:

Simplified from prototype/host/runtime/sandbox.c: start_carrier, join_cgroup.

void start_carrier(int exec_fd, unsigned int vcpu, const struct sandbox_opts *opt)
{
	char *argv[] = { "carrier", NULL }, *envp[] = { NULL };

	if (opt->pin) {                                 /* virtual CPU i on processor i */
		cpu_set_t set;
		CPU_ZERO(&set);
		CPU_SET(vcpu, &set);
		sched_setaffinity(0, sizeof(set), &set);
	}
	prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);         /* the endovisor insists on it */
	if (opt->seccomp_kill_nr >= 0)                  /* a filter bounds what the tenant may ask of its kernelet */
		install_seccomp_kill(opt->seccomp_kill_nr);
	execveat(exec_fd, "", argv, envp, AT_EMPTY_PATH);   /* the endovisor takes it from here */
	die_child("execveat");                           /* reached only if the exec failed */
}

void join_cgroup(const char *dir)                    /* write our process identifier into cgroup.procs */
{
	mkdir(dir, 0755);                                /* made if it does not exist: a runtime's job, done here */
	snprintf(path, sizeof(path), "%s/cgroup.procs", dir);
	fd = open(path, O_WRONLY | O_CLOEXEC);
	n = snprintf(pid, sizeof(pid), "%d\n", getpid());
	write(fd, pid, n);
	close(fd);
}

Between the fork and the execveat the child is an ordinary process of the holder’s user, and anything Linux lets a process set on itself can go there: the processor it is pinned to, as here; its priority or scheduling class; the user it runs as; the namespaces it is in; its resource limits; a seccomp filter, which bounds what the tenant may ask of its kernelet. All of it survives the exec, because these are properties of the task and not of the program it runs, and all of it is per carrier, so two virtual CPUs can be pinned to two processors and given two priorities.

In summary, the whole life of a sandbox is, in order:

  1. When the machine is set up, the operator registers each kind with KLET_KIND_REGISTER.
  2. The runtime makes a control group for the sandbox with the limits the operator chose, and starts the holder in it, or the holder moves itself in before it creates anything, as the prototype’s does when given a group (join_cgroup).
  3. The holder creates the sandbox with KLET_CREATE, opens the disk image and whatever else the sandbox is to have and passes each with KLET_ATTACH_DEVICE, and passes its console and log with KLET_SET_CONSOLE and KLET_SET_LOG.
  4. For each virtual CPU, the holder asks for the exec descriptor with KLET_GET_VCPU_EXEC_FD and forks a child, which arranges itself and executes the descriptor. The first of those execs loads the kind’s image as this sandbox’s instance. From now on, the carrier no longer executes the holder’s code, but the kernelet’s.
  5. The sandbox runs. Linux schedules each carrier with the processors and the class its creator gave it; the kernelet schedules its own tasks on them.
  6. The kernel proper powers off, or the holder calls KLET_KILL, or a carrier is killed. The endovisor records why, marks the kernelet dying and stops every carrier; when the last of them is gone, the sandbox has exited. The sandbox descriptor becomes readable. The holder reads the exit record, reaps the carriers, closes the sandbox descriptor to release its remaining resources, and exits; the runtime removes the group.

Why it is shaped so

The endovisor UAPI is designed with four requirements in mind:

  1. Every carrier task is created and configured by user space just like an ordinary Linux task. A carrier is a special task, since its system calls go to the kernelet and not to Linux, but it is still a task, and the host’s tools for tasks, sched_setaffinity, setpriority, sched_setattr, control groups, namespaces, resource limits, /proc, must apply to it as to any other. And only user space can apply them before the task carries a virtual CPU.
  2. Resources reach a sandbox as descriptors. A disk image, a network interface, a console and a log file are things the runtime opens itself, under its own user’s permissions, and passes down. Nothing is named by path inside the kernel, and a sandbox can reach exactly what its runtime could open. This is good for both security and flexibility.
  3. A sandbox dies whole, and dies with its runtime. Nothing a crashed runtime held keeps a sandbox alive.
  4. An image contains privileged kernel code and one copy of it should serve every sandbox of it. Loading an image is a privilege of the admin who ought to make sure that the image is obtained from a trusted origin. The process of image loading involves format validation and thus should be done once.

Learning from the KVM UAPI

Linux already has one interface that meets the first three requirements, that of KVM, Linux’s hypervisor, and it is the one to draw on. The KVM module runs virtual machines whose virtual processors are ordinary user threads: a program such as QEMU opens /dev/kvm, asks it for a descriptor that stands for a virtual machine, asks that for a descriptor per virtual processor, and runs each virtual processor from a thread of its own by calling an operation on its descriptor (KVM_CREATE_VM, KVM_CREATE_VCPU, KVM_RUN). The threads are QEMU’s: it created them, it can pin them and renice them, ps shows them, and the machine dies when QEMU does, because closing the last descriptor destroys it.

A kernelet’s interface has the same three levels: a system descriptor, a sandbox descriptor, and a descriptor per virtual CPU, each made from the one above it using ioctl, as shown by the diagram below:

flowchart TB
    dev["/dev/endovisor<br/>the system descriptor"] -- "KLET_CREATE" --> sandbox_fd["a sandbox descriptor<br/>held by the sandbox's holder"]
    dev -- "KLET_KIND_REGISTER<br/>(needs the right to load kernel code)" --> kind["a kind: one image,<br/>its code shared by every sandbox of it"]
    kind -. "of a kind" .-> sandbox_fd
    sandbox_fd -- "KLET_GET_VCPU_EXEC_FD(i)" --> ex["an exec descriptor<br/>for virtual CPU i, used once"]
    ex -- "fork, configure, execveat" --> car["the carrier of virtual CPU i<br/>a child of the holder"]

Differing from the KVM UAPI

The UAPI of the endovisor differs from that of KVM in two major aspects.

First, the endovisor has a dedicated API for image registration, whereas KVM leaves loading the guest’s kernel to user space or to the guest’s own boot loader. KVM is given guest memory and virtual CPU state; the kernel image is just bytes in that memory, and hardware virtualization confines the guest even if those bytes are hostile. A kernelet’s image, by contrast, becomes executable code inside the host kernel. Its isolation rests on safe Rust and vOSTD, so the operator must trust how the image was built; checking its format cannot establish that trust. KLET_KIND_REGISTER therefore requires the right to load kernel code, while KLET_CREATE lets an unprivileged runtime create a sandbox from an already registered kind. Separating the two also lets the endovisor validate the image once and retain one copy of its code for all sandboxes of that kind. Each sandbox maps those shared code pages at its own virtual address and gets private data pages, whose pointers are relocated for that address.

Second, the endovisor uses the execveat syscall to run a vCPU, whereas KVM does the KVM_RUN ioctl. The reason is that a carrier must start with an address space that holds nothing of the host’s, and only an exec gives it one. A carrier is to the kernelet what a processor is to an ordinary kernel: the kernelet runs its tenant’s processes on it, one at a time. Those processes run in Linux’s own user mode, on Linux’s own page tables, so at every switch the kernelet switches the carrier’s address space to the current process’s, and the carrier’s address space is always one of the tenant’s. It must therefore start holding nothing of the host’s: not the holder’s program, not its heap, not a stack with the holder’s secrets on it. The one operation in Linux that gives a process a fresh address space is execve. So the exec descriptor is not called; it is executed.

Linux: what exec keeps. A new program gets a new address space, and begin_new_exec() resets what belongs to the old one: signal handlers, timers, the descriptors marked close-on-exec, and a few registrations by which Linux would later write into user memory. It does not touch the task’s control group, its user and group identities, its namespaces (bar a time namespace chosen for its children, which it enters), its processor mask, priority, resource limits, the no-new-privileges bit or the seccomp filter; those are properties of the task, not of the program, and the two last are kept by design so that a program cannot shed them by executing another. Three things it does recompute: the task’s capabilities, of which a process that is not root keeps only those it marked ambient; whatever domain the host’s security module assigns the new program; and whether the process is dumpable, below. That is what makes the holder’s child work: everything it set on itself is still set when the endovisor sees it, and nothing it could not have had is added.

The image format and its requirements

The image passed to KLET_KIND_REGISTER contains the kernel proper and vOSTD in one file. Its format tells the endovisor how to load it; its code and data layout determine whether it can serve independent sandboxes. Those are separate concerns: a file being relocatable does not make its code position-independent, and neither property alone gives each sandbox private state.

A self-contained PIE image

The image must be a little-endian, 64-bit x86 position-independent executable, or PIE, marked ET_DYN in its ELF header. It contains the kernel proper and vOSTD, fully linked together. The build compiles position-independent code and performs the final link before registration, fixing the relative positions of code and data. The endovisor loads this image itself; it does not execute a user-space ELF interpreter or resolve symbols from Linux or other libraries.

The image contract has four requirements:

  • A fixed relative layout. The ELF program headers describe page-aligned loadable segments, PT_LOAD, with separate executable, read-only and writable regions. Each sandbox maps the same layout at its own base address. No segment may be both writable and executable, and segments must not overlap.
  • Shared pages need no runtime patches. The linker resolves references within the shared code and read-only data. Runtime relocations may modify only private writable data, including pointer tables. Data containing instance-dependent pointers belongs in that private region even if an ordinary executable might make it read-only after relocation.
  • Only relative runtime relocations. Each relocation is an x86-64 R_X86_64_RELATIVE record with an explicit addend. It writes the sandbox’s load base plus the addend into a private 64-bit field. Its symbol index must be zero, its destination must lie wholly within writable image data, and its resulting pointer must refer to the image. There is no external symbol lookup.
  • An explicit entry table. By this image ABI’s convention, ELF’s e_entry identifies kernelet_entry_table, a data object rather than an instruction to execute. After relocation, its function pointers must point into that sandbox’s executable segment, and its ABI version must match the endovisor’s. The table is described in the kernelet ABI.

The prototype rejects images requiring an ELF interpreter, external libraries, TLS, or automatic constructor and destructor execution. It also rejects unsupported dynamic relocation formats and relocations into shared pages. Common symbols must have been assigned storage by the final linker. TLS and automatic initialization are implementation restrictions rather than requirements of kernelet isolation: initialization currently runs explicitly during sandbox boot, and registration runs no image code.

At registration, the loader in prototype/linux/endovisor/image.c validates the image and retains its shared pages and pristine data template. When a sandbox starts, it creates a mapping at the segment offsets specified by the linker, using the kind’s physical pages for code and read-only data and fresh physical pages for writable data. It copies the data template, zeroes uninitialized storage, applies the private relocations and sets the mapping’s permissions. The kind’s shared pages stay alive until every sandbox of that kind has been destroyed and the kind is unregistered.

These checks establish compatibility with the image contract. They do not establish that arbitrary machine code obeys the safe-Rust and vOSTD boundary. That still depends on the trusted build, which is why registering an image requires the right to load kernel code.

ELF: relocations. ELF, the Executable and Linkable Format, records how code and data refer to addresses that may not yet be known. A relocation is an instruction to a linker or loader to patch a field. A call between two sections, a reference to a global variable, or a function pointer stored in a table may need such a patch. Build-time relocations and runtime relocations do different work: the final linker can resolve distances within the image, while the loader must still fill in absolute pointers that depend on the chosen load address.

Let S be a symbol’s address, P the address of the field being patched, A an explicit addend, and B the image’s load base. Some relevant x86-64 relocation calculations are:

RelocationValue writtenRole in this image format
R_X86_64_PC32S + A - P, signed 32 bitsResolved by the build-time linker for references within the image
R_X86_64_64S + A, 64 bitsAn absolute reference the linker must resolve or convert into a supported runtime relocation
R_X86_64_RELATIVEB + A, 64 bitsThe only runtime relocation the endovisor accepts; it patches private data

The name RELATIVE here means relative to the load base, not relative to the instruction pointer. It produces an absolute pointer, so its result differs between sandbox mappings. It is suitable for private pointer tables, not shared code pages. Instruction-relative references such as PC32 instead encode distances: when the target and the field being patched move by the same amount, their distance stays unchanged.

Requirements for sharing code across sandboxes

The endovisor maps the same physical code pages at a different kernel virtual address for each sandbox, with private data at the offsets established by the final linker. This requires instruction bytes that work at every mapping, without patches that differ between sandboxes: position-independent code, or PIC.

ELF: PIC. Position-independent code can execute at different load addresses without changing its instruction bytes to suit each address. On x86-64, one technique is to address code or data relative to the instruction pointer, RIP. When the instruction and its target move by the same amount, the encoded displacement remains valid. The same instructions can therefore reach a different instance’s private data when that data has the same position relative to its code mapping. Position independence does not itself allocate or separate those instances’ data.

A global offset table, or GOT, provides indirection for addresses that cannot be reached using a fixed displacement alone. Instead of embedding the target’s absolute address in an instruction, the code reads it from a table entry. On x86-64, the code can reach that entry relative to RIP; the loader fills the entry with the target’s address. The instruction bytes can stay shared while the table entries differ between instances, provided each code mapping reaches its own table. Entries that depend on an instance must be private, even if the table becomes read-only after loading. A GOT is one technique used by PIC, not a requirement for every reference: references within an image can often use relative addressing directly.

PIC can still need relocations during linking and when initializing private data. It is a property of the generated code, separate from the ELF file type: an ET_REL object can contain PIC. A position-independent executable, PIE, is a linked executable that can run at different load addresses. It is another possible way to package the code, rather than an additional requirement for sharing.

The precise sharing rule is that every relocation applied to a physically shared page must produce identical bytes for every mapping. An absolute relocation whose value depends on the sandbox’s load address violates that rule. Absolute relocations can still appear in private data, such as a table of pointers to that sandbox’s code mapping, or in shared pages when the resulting value is identical for all instances. Relative relocations remain identical only when the referenced target stays at the same distance from the field being patched. Merely banning the absolute relocation types from the whole image would therefore be both too restrictive for private data and insufficient to guarantee sharing.

The PIE format makes those relative positions part of the image. The endovisor preserves them when mapping each sandbox and puts every runtime relocation in private pages. Consequently, concurrent sandboxes of the same kind can execute the same physical code while changing their globals and pointer tables independently. Their carrier entry points, interrupt upcalls and executable-address checks all use the individual sandbox’s mapping.

A sandbox’s mapping remains valid throughout its carriers’ execution. Destroying that sandbox releases its mapping and private pages; it does not release shared pages still owned by the registered kind. The shared pages can be released only after all sandboxes are destroyed and the kind is unregistered.

The operations

The header prototype/linux/endovisor/uapi/endovisor.h is the interface’s definition, and its comments are written to be the reference: for each operation they say what it does, what its arguments are, what it returns and how it fails. This section quotes the header unchanged, one operation at a time, and adds only what a header cannot carry: a figure, a Linux mechanism, and the reasons. The header opens with the three descriptors and the conventions every operation follows:

Simplified from prototype/linux/endovisor/uapi/endovisor.h: the opening comment.

// The endovisor UAPI defines what host user space may ask of a Linux that runs kernelets.
// It has one device node, `/dev/endovisor`, and three kinds of descriptor:
//
// - The system descriptor, from `open("/dev/endovisor")`, registers kinds and creates sandboxes.
// - A sandbox descriptor, returned by `KLET_CREATE`, controls one sandbox.
//   Closing its last copy kills the sandbox and destroys it once it has exited.
// - An exec descriptor, returned by `KLET_GET_VCPU_EXEC_FD`, starts one virtual CPU.
//   Executing it with `execveat` and `AT_EMPTY_PATH` makes the caller that virtual CPU's carrier.
//
// The descriptors support the ioctls defined below;
// a sandbox descriptor also supports `read` and readiness notification.
// They return -1 with `errno` set on failure,
// and otherwise return what their comments specify, or zero when nothing is specified.

On the system descriptor

The system descriptor is open("/dev/endovisor"). The node is owned by root; an operator who runs sandboxes as an unprivileged user gives that user access to it, as is done for /dev/kvm.

KLET_GET_API_VERSION

Unchanged from prototype/linux/endovisor/uapi/endovisor.h: KLET_API_VERSION, KLET_GET_API_VERSION.

// Returned by `KLET_GET_API_VERSION`; a runtime refuses any other value.
#define KLET_API_VERSION	2

// The ioctl type byte is chosen for the prototype.
// `0xEB` is unassigned in `Documentation/userspace-api/ioctl/ioctl-number.rst` at v6.12.
#define KLET_IOC		0xEB

#define KLET_NAME_LEN	32
#define KLET_MESSAGE_LEN	128
#define KLET_CMDLINE_MAX	4096

// ------------------------------------------------------------------
// The system descriptor
// ------------------------------------------------------------------

// The ioctl for querying the endovisor UAPI version.
//
// The ioctl takes no argument and returns `KLET_API_VERSION`.
// This operation cannot fail.
#define KLET_GET_API_VERSION	_IO(KLET_IOC, 0x00)

KLET_KIND_REGISTER

Unchanged from prototype/linux/endovisor/uapi/endovisor.h: klet_kind_register, KLET_KIND_REGISTER.

// The ioctl for registering a new kernelet kind by providing a new image.
//
// `image_fd` is an open descriptor on a self-contained x86-64 PIE image (`ET_DYN`)
// holding the kernel proper and vOSTD, with no interpreter or external dependencies.
// Its loadable segments fix the relative layout of shared code and private data.
// Its only runtime relocations are `R_X86_64_RELATIVE` records targeting private data;
// shared pages need no runtime patches.
// `e_entry` identifies the `kernelet_entry_table` object, not an executable entry point.
// The endovisor validates the image and refuses incompatible files (`EINVAL`, `ENOEXEC`);
// it also refuses an image whose ABI version is not its own (`EPROTO`).
// It retains one copy of the shared pages for the kind's life.
// Each sandbox maps those pages at its own virtual address,
// receives fresh writable data, and relocates its pointers for that address.
//
// The name of the new kind is given in `name`, which `KLET_KIND_LIST` reports.
// This name must be unique among all registered kinds (`EEXIST` otherwise).
// Currently, `flags` must be zero (`EINVAL` otherwise).
//
// The ioctl returns the kind's identifier, a small non-negative integer.
//
// # Security
//
// An image is kernel code,
// so registering asks what loading a module asks (`EPERM` otherwise):
// `CAP_SYS_MODULE` in the initial user namespace
// and module loading not disabled (`kernel.modules_disabled`).
// Because images carry no signature,
// neither signature enforcement (`module.sig_enforce`) nor lockdown may be in force.
// The file is read as a module file is read,
// so that the host's security modules see it.
struct klet_kind_register {
	__s32	image_fd;
	__u32	flags;				// must be 0 (`EINVAL`)
	char	name[KLET_NAME_LEN];	// NUL-terminated
};
#define KLET_KIND_REGISTER		_IOW(KLET_IOC, 0x01, struct klet_kind_register)

The image format and its requirements above explain the accepted relocations and distinguish the prototype’s loading scheme from the requirements for sharing code across concurrent sandboxes.

Linux: capabilities and lockdown. Root’s powers are divided into named capabilities, and a privileged operation asks for the one it needs with capable(). Loading a module asks for CAP_SYS_MODULE and that module loading has not been switched off (init_module checks both), reads the file in a way the host’s security modules can veto, and, for a module without a valid signature, asks that signatures are not being enforced and that the kernel is not locked down, a mode in which nothing unsigned may be added to the running kernel (security_locked_down()). The switch that turns module loading off is not visible to a module; the prototype’s patch exports it.

KLET_KIND_LIST

Unchanged from prototype/linux/endovisor/uapi/endovisor.h: klet_kind_info, klet_kind_list, KLET_KIND_LIST.

// The ioctl for listing registered kinds.
//
// The caller supplies an array of `capacity` entries at `kinds`.
// The endovisor fills in up to that many entries
// and sets `count` to the number of registered kinds.
// If `count` exceeds `capacity`, call again with a larger array.
//
// The ioctl returns zero on success.
struct klet_kind_info {
	__u32	id;
	__u32	nr_sandboxes;			// sandboxes of this kind that exist now
	__u64	text_bytes;			// the shared code's size
	char	name[KLET_NAME_LEN];
};
struct klet_kind_list {
	__u64	kinds;				// `struct klet_kind_info *`, user memory
	__u32	capacity;			// entries available at `kinds`
	__u32	count;				// out: kinds registered
};
#define KLET_KIND_LIST		_IOWR(KLET_IOC, 0x02, struct klet_kind_list)

KLET_KIND_UNREGISTER

Unchanged from prototype/linux/endovisor/uapi/endovisor.h: KLET_KIND_UNREGISTER.

// The ioctl for unregistering a kind and returning its pages to Linux.
//
// The argument points to the kind's identifier.
// An unknown kind is refused with `ENOENT`;
// a kind with any sandbox still in existence is refused with `EBUSY`.
//
// The ioctl returns zero on success.
//
// # Security
//
// Unregistering requires the same rights as `KLET_KIND_REGISTER`.
#define KLET_KIND_UNREGISTER	_IOW(KLET_IOC, 0x03, __u32)

Unchanged from prototype/linux/endovisor/uapi/endovisor.h: klet_create, KLET_CREATE.

KLET_CREATE

// The ioctl for creating a sandbox of a registered kind.
//
// `kind` identifies the registered image (`ENOENT` for an unknown kind).
// `nr_vcpus` must be between one and `KLET_MAX_VCPUS`.
// `memory_initial` is what the kernelet is given before it boots;
// `memory_max` is the ceiling for memory granted in response to the kernelet's own requests.
// Both are in bytes, rounded up to whole 2 MiB units.
// `max_tasks` bounds the kernelet's own tasks,
// each of which costs the host a kernelet stack.
// The command line at `cmdline`, of length `cmdline_len`,
// is passed to the kernel proper as a machine's boot loader would pass it.
// Currently, `flags` must be zero.
//
// The ioctl fails with `EINVAL` if `nr_vcpus` is out of range,
// `memory_initial` exceeds `memory_max`, `cmdline_len` exceeds `KLET_CMDLINE_MAX`,
// or `flags` is nonzero.
// It fails with `ENOSPC` if the endovisor's table of sandboxes is full.
//
// The ioctl returns the sandbox descriptor, marked close-on-exec.
// Nothing is allocated for the kernelet itself until its first carrier arrives.
//
// # Security
//
// The calling task's control group, on the unified (v2) hierarchy, becomes the sandbox's.
// Every later exec of one of its exec descriptors, and every `KLET_ATTACH_DEVICE`,
// must come from a task in that group.
// The root group is refused (`EPERM`).
// The calling thread's group leader is held, by a task reference,
// as the owner of every address space the kernelet will create,
// so that the memory controller charges their pages to this group.
//
// Creating needs no privilege beyond access to the device node:
// a sandbox can reach nothing its creator could not,
// since its memory is charged to the creator's group
// and the files it is given are files its creator opened.
//
// # Prototype limitations
//
// `memory_initial` must round to 2 or 4 MiB,
// the grant being one physically contiguous run (`EINVAL` otherwise).
struct klet_create {
	__u32	kind;
	__u32	nr_vcpus;			// 1 to `KLET_MAX_VCPUS`
	__u64	memory_initial;
	__u64	memory_max;
	__u64	max_tasks;
	__u64	cmdline;			// `const char *`, user memory; need not be NUL-terminated
	__u32	cmdline_len;
	__u32	flags;				// must be 0
};
#define KLET_MAX_VCPUS	64
#define KLET_CREATE			_IOW(KLET_IOC, 0x04, struct klet_create)

On the sandbox descriptor

KLET_GET_VCPU_EXEC_FD

Unchanged from prototype/linux/endovisor/uapi/endovisor.h: KLET_GET_VCPU_EXEC_FD.

// The ioctl for obtaining a virtual CPU's exec descriptor.
//
// The argument points to the virtual CPU's index, `i`.
// The ioctl returns its exec descriptor, marked close-on-exec.
// It fails with `EINVAL` for an `i` beyond `nr_vcpus`,
// `EBUSY` for a virtual CPU already started,
// or `ESRCH` once the sandbox has been killed.
//
// The descriptor refers to a file that lives only in memory, one per virtual CPU.
// The endovisor identifies the sandbox and virtual CPU by the file's identity,
// not by its contents.
// Execute it with `execveat(fd, "", argv, envp, AT_EMPTY_PATH)`;
// the arguments and the environment are discarded.
// The process becomes the carrier of virtual CPU `i`, named `kernelet/<slot>/<i>`,
// where `slot` is the sandbox's index in the endovisor's table,
// given at `KLET_CREATE` and reported by `KLET_GET_STATS`.
//
// The sandbox starts at the first such exec,
// which loads the kind's image as this sandbox's instance
// and grants `memory_initial`, charged to the sandbox's control group.
// The kernelet boots when every virtual CPU's carrier has arrived,
// because a kernel that starts its other processors expects each of them to answer.
// Until then, `KLET_GET_STATS` reports the sandbox as `KLET_STATE_STARTING`.
// A holder that never starts one of its virtual CPUs has a sandbox that never boots;
// `KLET_KILL` ends it.
// A carrier that dies while the others are still arriving ends the sandbox,
// as any carrier's death does.
//
// # Security
//
// The file is owned by root and execute-only.
// The descriptor is an `O_PATH` descriptor, which `execveat` accepts;
// it cannot be used to read or write the file.
// A process that executes it is made non-dumpable.
//
// The exec is refused with `EPERM` unless the executing process
// is in the sandbox's control group and has no-new-privileges set,
// which guarantees that it gains nothing by the exec.
// It fails with `EBUSY` if this virtual CPU has already been started,
// by this descriptor or by another copy of it,
// and with `ESRCH` if the sandbox has been killed.
#define KLET_GET_VCPU_EXEC_FD	_IOW(KLET_IOC, 0x10, __u32)

KLET_ATTACH_DEVICE

Unchanged from prototype/linux/endovisor/uapi/endovisor.h: klet_device, KLET_ATTACH_DEVICE.

// The ioctl for attaching a virtio device backed by an open file.
//
// `type` selects the device type, and `fd` is a file the caller opened.
// The endovisor takes its own reference to the file;
// the caller may close `fd` afterwards.
// Attaching is legal until the sandbox starts (`EBUSY` after).
//
// The ioctl returns the device's index,
// which the kernelet sees as the order of devices on its command line.
//
// # Security
//
// The calling task must be in the sandbox's control group (`EPERM` otherwise).
// The device is backed by a file the caller opened under its own permissions.
//
// # Prototype limitations
//
// The prototype has no devices, so this ioctl fails with `EOPNOTSUPP`.
enum klet_device_type {
	KLET_DEVICE_BLOCK	= 1,		// `fd`: a regular file or a block device
	KLET_DEVICE_NET	= 2,		// `fd`: a TAP interface
};
#define KLET_DEVICE_READONLY	0x1
struct klet_device {
	__u32	type;				// `enum klet_device_type`
	__s32	fd;
	__u32	flags;				// `KLET_DEVICE_*`
	__u32	pad;				// must be 0
};
#define KLET_ATTACH_DEVICE		_IOW(KLET_IOC, 0x11, struct klet_device)

KLET_SET_CONSOLE and KLET_SET_LOG

Unchanged from prototype/linux/endovisor/uapi/endovisor.h: KLET_SET_CONSOLE, KLET_SET_LOG.

// The ioctls for setting the sandbox's console and kernel log files.
//
// The argument points to a file descriptor.
// The endovisor takes its own reference to the file.
// `KLET_SET_CONSOLE` sets the file serving the sandbox's console;
// `KLET_SET_LOG` sets the file receiving its kernel log.
// Both operations are legal until the sandbox starts (`EBUSY` after)
// and return zero on success.
//
// The console carries both directions, what the tenant writes and what it reads,
// so its file must be one that does: a terminal or a socket.
// A pipe carries output only.
// Without a log file, the log is kept only in a bounded ring;
// `KLET_GET_STATS` counts drops from that ring.
//
// # Prototype limitations
//
// The console carries output only.
// A console or log not given goes to the host's kernel log, prefixed `kernelet:`.
#define KLET_SET_CONSOLE		_IOW(KLET_IOC, 0x12, __s32)
#define KLET_SET_LOG		_IOW(KLET_IOC, 0x13, __s32)

KLET_KILL

Unchanged from prototype/linux/endovisor/uapi/endovisor.h: KLET_KILL.

// The ioctl for marking a kernelet dying and recording the reason.
//
// The argument points to the caller's reason, which the exit status carries.
// It must be below `0x80000000`;
// values with the high bit set are reserved for the endovisor and refused with `EINVAL`.
//
// The ioctl returns zero without waiting for the carriers to stop.
// Stopping is asynchronous; `read` waits for completion and reports the exit reason.
// A sandbox that has no carrier exits at once.
// The operation is idempotent: a second kill changes nothing.
//
// Closing the last copy of the sandbox descriptor also requests termination.
// The endovisor releases the sandbox's remaining memory, mappings, and records
// once both its last descriptor is closed and every carrier has stopped.
// Closing an exited sandbox releases those resources;
// closing a live sandbox arranges cleanup after exit without waiting for its carriers.
// Copies made with `dup` or inherited through `fork` keep the descriptor alive
// until they too are closed.
//
// # Security
//
// Automatic cleanup releases a crashed holder's sandbox
// once no other process holds a copy of its descriptor.
#define KLET_KILL			_IOW(KLET_IOC, 0x15, __u32)

Closing the last copy of the sandbox descriptor requests the same stop if the sandbox is still live, using the endovisor’s reason KLET_KILLED_REQUESTED. That close does not wait for the carriers to stop. Once the sandbox has exited, the endovisor releases its remaining resources automatically; if it had already exited, the last close releases them then. Duplicated or inherited copies keep the descriptor alive until they too are closed. This ties cleanup to the holder’s lifetime, including when it crashes.

Reading and polling the sandbox descriptor

After a termination request, the sandbox exits when every carrier is gone; if it has no carriers, it exits immediately. Reading the sandbox descriptor then returns a struct klet_exit describing the outcome. Before exit, read waits interruptibly; with O_NONBLOCK set, it fails with EAGAIN instead. A zero-length read returns zero immediately. A nonzero buffer smaller than the record fails with EINVAL; a buffer large enough receives exactly sizeof(struct klet_exit) bytes.

The record is a persistent result, not a stream: reads do not consume it or advance to an end-of-file condition. The descriptor is not seekable; lseek fails with ESPIPE. Each successful read returns the same complete record. The descriptor becomes readable to poll, select and epoll after exit and stays readable. A runtime can therefore wait for the sandbox alongside its carriers’ process descriptors, then read its final result. KLET_GET_STATS also includes this record once its reported state is KLET_STATE_EXITED.

Unchanged from prototype/linux/endovisor/uapi/endovisor.h: klet_exit, klet_end.

// The exit record returned by `read` on a sandbox descriptor.
//
// After a termination request, the sandbox exits when every carrier is gone;
// if it has no carriers, it exits immediately.
// A blocking `read` waits interruptibly for that state;
// with `O_NONBLOCK`, it fails with `EAGAIN` until the sandbox has exited.
// A caught signal can interrupt the wait with `EINTR`.
//
// A read buffer must hold at least one `struct klet_exit` (`EINVAL` otherwise).
// A successful read copies one complete record and returns its size in bytes.
// A zero-length read returns zero immediately.
// An invalid destination fails with `EFAULT`.
// Reads do not consume the record: repeated reads return the same result.
// The descriptor is not seekable (`ESPIPE` from `lseek`).
//
// `poll`, `select`, and `epoll` report readability once the sandbox has exited.
// `poll` reports `POLLIN | POLLRDNORM`; readiness persists after a read.
// After readiness notification, either `read` or `KLET_GET_STATS` retrieves the exit record.
// `how` identifies which of three things ended the sandbox:
//
// - `KLET_END_EXITED`: `code` is the kernel proper's exit code.
// - `KLET_END_PANICKED`: `message` is the panic message.
// - `KLET_END_KILLED`: `reason` is the caller's reason from `KLET_KILL`,
//   `KLET_KILLED_REQUESTED` if the last copy of the sandbox descriptor closed,
//   or another `KLET_KILLED_*` reason if the endovisor killed it.
//
// `message` is empty when there is no panic message.
//
// # Prototype limitations
//
// The prototype reports a panic as `KLET_END_EXITED`, as a power-off, with no message.
// Of the endovisor's own reasons, it detects only `KLET_KILLED_CARRIER_LOST`.
enum klet_end {
	KLET_END_EXITED	= 1,		// the kernel proper powered off
	KLET_END_PANICKED	= 2,		// a panic nothing in it caught
	KLET_END_KILLED	= 3,		// the host decided
};
#define KLET_KILLED_REQUESTED	0x80000000u	// the last copy of the descriptor closed
#define KLET_KILLED_CARRIER_LOST	0x80000001u	// a carrier died: `SIGKILL`, the OOM killer
#define KLET_KILLED_FAULT		0x80000002u	// a fault in kernelet code
#define KLET_KILLED_STACK		0x80000003u	// a kernelet stack nearly exhausted
#define KLET_KILLED_HOST_COUNT	0x80000004u	// Linux's preemption count found wrong
struct klet_exit {
	__u32	how;				// `enum klet_end`
	__s32	code;
	__u32	reason;
	__u32	pad;
	char	message[KLET_MESSAGE_LEN];
};

KLET_GET_STATS

Unchanged from prototype/linux/endovisor/uapi/endovisor.h: klet_stats, klet_vcpu_stat, KLET_GET_STATS.

// The ioctl for reading a sandbox's state, exit record, counters, and virtual CPU statistics.
//
// The caller supplies an array of `capacity` rows at `vcpus`.
// The endovisor sets `nr_vcpus` to the sandbox's number of virtual CPUs
// and fills in `min(capacity, nr_vcpus)` rows.
// It also reports the sandbox's `slot`, `state`, counters, and `exit` record.
// `exit` is zero until `state` is `KLET_STATE_EXITED`;
// it then holds the same final record as `read`, without blocking.
// The rows let a tool find the sandbox's carriers without walking its control group;
// `pid` is the process identifier that `taskset` and `chrt` take.
//
// The ioctl returns zero on success.
enum klet_state {
	KLET_STATE_CREATED	= 1,		// no carrier yet
	KLET_STATE_STARTING	= 2,		// some carriers have arrived
	KLET_STATE_RUNNING	= 3,
	KLET_STATE_DYING	= 4,		// marked; carriers being stopped
	KLET_STATE_EXITED	= 5,		// no carrier of the sandbox is left
};
enum klet_vcpu_state {
	KLET_VCPU_UNSTARTED	= 0,		// its exec descriptor has not been executed
	KLET_VCPU_WAITING	= 1,		// arrived; waiting for the others, or for `vcpu_boot`
	KLET_VCPU_RUNNING	= 2,
	KLET_VCPU_GONE	= 3,
};
struct klet_vcpu_stat {
	__s32	pid;				// the carrier's process identifier, 0 if unstarted
	__u32	state;				// `enum klet_vcpu_state`
	__u64	run_ns;				// time the carrier has run
	__u64	stolen_ns;			// time it was runnable but not running
};
struct klet_stats {
	__u32	state;				// `enum klet_state`
	__u32	nr_vcpus;			// out
	__u64	memory_granted;			// bytes
	__u64	service_calls;
	__u64	virqs_raised;
	__u64	log_dropped;
	__u64	vcpus;				// `struct klet_vcpu_stat *`, user memory
	__u32	capacity;			// rows available at `vcpus`
	__u32	slot;				// out: the sandbox's index in the endovisor's table; names its carriers
	struct klet_exit exit;			// out: final exit record, zero before exit
};
#define KLET_GET_STATS		_IOWR(KLET_IOC, 0x17, struct klet_stats)

On the exec descriptor

The exec descriptor is returned by KLET_GET_VCPU_EXEC_FD on the sandbox descriptor and identifies one virtual CPU of that sandbox. Its operation is execveat(fd, "", argv, envp, AT_EMPTY_PATH): the calling process becomes that virtual CPU’s carrier. The holder passes the descriptor to a child it has forked and configured, and the child executes it. A successful exec never returns to the holder’s code; the arguments and environment are discarded.

The descriptor grants the right to start that virtual CPU once, subject to the checks below. It refers to a file that exists only in memory, owned by root and execute-only. The endovisor recognizes the file by its identity, not its contents: copying its bytes into another file grants no right to start a virtual CPU. The descriptor is a path-only one (O_PATH), which execveat accepts but read, write and fchmod refuse. Duplicating it does not grant another start: once the virtual CPU has started, executing any copy fails with EBUSY.

Executing the descriptor. The caller must be in the sandbox’s control group and have no new privileges set, or the exec fails with EPERM. A killed sandbox refuses the exec with ESRCH. The endovisor implements these checks in a handler registered with Linux as an executable format. On success, the handler gives the process a fresh address space and empties it, including the pages holding the arguments, so that it holds only what the kernelet puts there. It marks the task as a carrier and gives it a lifeline, an open file whose closing tells the endovisor that the carrier has died. The first carrier starts the sandbox’s instance; the kernelet boots once every virtual CPU has a carrier. On the way back from the exec to user mode, the endovisor takes control and starts running kernelet code on the task.

Controlling the resulting carrier. Once the exec has succeeded, the runtime controls the virtual CPU through its Linux task. The task is named kernelet/<slot>/<i>, identifying the sandbox’s slot and the virtual CPU’s index; KLET_GET_STATS on the sandbox descriptor reports its process identifier. ps and top list the carriers, and taskset and chrt change their processor affinity and scheduling class while the sandbox runs. The host sees the holder and one child per virtual CPU in the sandbox’s control group. The tenant’s own processes are known only to the kernelet.

The exec changes two aspects of how those task interfaces work. First, the carrier is not dumpable: sharing its user identity does not permit attaching a debugger or reading its memory through /proc, which could expose the tenant’s memory. Linux normally makes a process non-dumpable when it executes a file it cannot read (begin_new_exec()), but root can read an execute-only file, and the rule also depends on fs.suid_dumpable. The handler therefore clears the dumpable bits itself, for the carrier and for every address space later made for the tenant. Root can still inspect and profile the carrier.

Second, signals control the carrier’s execution but do not deliver events to the tenant. SIGSTOP stops the virtual CPU until SIGCONT; SIGKILL to any carrier ends the whole sandbox. Other signals are discarded. The runtime can therefore stop the sandbox through kill -KILL on a carrier or cgroup.kill on its group, as well as through KLET_KILL on the sandbox descriptor.

What this book’s prototype leaves out. The interface is written for a full endovisor, and the header’s comments say where this book’s falls short of it. It has no devices, so KLET_ATTACH_DEVICE fails with EOPNOTSUPP. Its console carries output only, so the input half of KLET_SET_CONSOLE does not exist, and a sandbox given no console or log file has both printed to the host’s kernel log. Its memory is one fixed grant of 2 or 4 MiB, selected by memory_initial at creation; memory_max is recorded, but there is no mechanism to grow the grant. A panic ends the kernelet as a power-off does, with no message in the exit record, and of the endovisor’s own kill reasons it detects only a lost carrier. Each of those is named where the book reaches the place it would go.

Recap. The endovisor’s user-space interface is one device node and three kinds of descriptor. The system descriptor, from /dev/endovisor, is where an operator with the right to load kernel code registers an image file, one that can name nothing outside itself, as a kind whose code every sandbox of it shares, and where anyone with the node creates a sandbox of a kind. The sandbox descriptor is what the holder keeps: through it the holder attaches files, asks for one exec descriptor per virtual CPU, reads the exit record and closes the descriptor. Closing its last copy requests termination if needed and releases the remaining resources once the sandbox has exited. An exec descriptor is executed, not called, by a child the holder forked and configured with Linux’s ordinary system calls, because a carrier needs an address space that holds nothing of the host’s; the endovisor checks the child’s control group and its no-new-privileges bit, starts each virtual CPU once, and turns the child into its carrier. The holder’s control group is the sandbox’s, its main thread owns the tenant’s address spaces, and every task of a sandbox is one that user space made and can govern.

The first kernel we will run

This chapter reads the kernel the book is built around: Asterinas’s teaching example, a kernel in about a hundred lines of safe Rust.

The kernel is never edited. It is in this repository as prototype/kernels/hello/src/lib.rs. Before every build of the image, the build compares it byte for byte with a copy of the original, taken from the Asterinas tree at commit ab9a4cfdc. That it needs no edit to run inside Linux is the claim the rest of the book makes good.

The excerpts below drop only the file’s comments and imports; of what the imports bring, align_up comes from the small align_ext crate, and println! is OSTD’s, writing to the console.

The file opens with #![no_std] and #![deny(unsafe_code)], so the compiler rejects any unsafe in it.

First comes the user program the kernel runs, then the kernel itself in four pieces. After each piece, the chapter says what it asks of the machine, because that list is what vOSTD and the endovisor have to provide.

The program it runs

The kernel runs one user program, a few lines of assembly, given here in full apart from its license line:

Unchanged: prototype/kernels/hello/hello.S.

.global _start                      # entry point
.section .text                      # code section
_start:
    mov     $1, %rax                # syscall number of write
    mov     $1, %rdi                # stdout
    mov     $message, %rsi          # address of message
    mov     $message_end, %rdx
    sub     %rsi, %rdx              # calculate message len
    syscall
    mov     $60, %rax               # syscall number of exit, move it to rax
    mov     $0, %rdi                # exit code, move it to rdi
    syscall

.section .rodata                    # read only data section
message:
    .ascii  "Hello, world\n"
message_end:

It makes two system calls with the syscall instruction, using Linux’s numbers, as Asterinas does: write is 1 and exit is 60.

The kernel places the program in memory rather than loading it. A real kernel reads an executable’s headers and maps each segment where they say; this kernel reads no headers. include_bytes!("../hello") embeds the assembled executable, headers and all, and the kernel copies the whole file into fresh frames and maps them from 0x400000. That works because the program is linked as a static executable at 0x400000, with its code at 0x401000, the first page after the headers, and the linker placed each segment at its file offset from that address, so the code lands at 0x401000 and the message at 0x402000. The kernel hard-codes the first two, 0x400000 and 0x401000.

Piece 1: main

Unchanged: prototype/kernels/hello/src/lib.rs.

#[ostd::main]
pub fn main() {
    let program_binary = include_bytes!("../hello");
    let vm_space = Arc::new(create_vm_space(program_binary));
    vm_space.activate();
    let user_task = create_user_task(vm_space);
    user_task.run();
}

main builds an address space holding the program, makes it current on this processor with activate, creates a task to run the program, and puts the task on the scheduler’s queue with run(). #[ostd::main] marks it as the function the framework calls once it has booted, and generates the entry, __ostd_main, that calls it.

The address space main activates is still active when the task runs. main runs before any task exists; the task first runs when main returns, on the same processor, and nothing in between switches address spaces.

What it asks of the machine: an entry called after boot; a heap; a notion of current address space; and a way to start a task.

Piece 2: building the address space

Simplified from prototype/kernels/hello/src/lib.rs: create_vm_space.

fn create_vm_space(program: &[u8]) -> VmSpace {
    let nbytes = program.len().align_up(PAGE_SIZE);
    let user_pages = {
        let segment = FrameAllocOptions::new()
            .alloc_segment(nbytes / PAGE_SIZE)
            .unwrap();
        segment.write_bytes(0, program).unwrap();
        segment
    };

    let vm_space = VmSpace::new();
    const MAP_ADDR: Vaddr = 0x0040_0000;
    let preempt_guard = disable_preempt();
    let mut cursor = vm_space
        .cursor_mut(&preempt_guard, &(MAP_ADDR..MAP_ADDR + nbytes))
        .unwrap();
    let map_prop = PageProperty::new_user(PageFlags::RWX, CachePolicy::Writeback);
    for frame in user_pages {
        cursor.map(frame.into(), map_prop);
    }
    drop(cursor);
    vm_space
}

Three framework ideas appear here.

Frames. The kernel holds physical memory only through handles; a physical address gives it no way to touch the frames. FrameAllocOptions::new().alloc_segment(n) allocates n physically contiguous pages of RAM and returns a Segment, a handle to them. write_bytes copies the program into them. Iterating a Segment yields its frames one by one, and .into() turns each into an untyped frame, one holding plain bytes rather than a kernel object, the only kind a user mapping may hold.

The address space. An address space, VmSpace, is edited only through a cursor over a range of virtual addresses. VmSpace::new() creates an empty one; on a machine, that is, when OSTD runs the hardware itself, that is a fresh page table. cursor_mut opens a cursor, and each map call puts one frame at the cursor’s address, with the given permissions, and advances the cursor one page. PageProperty::new_user with PageFlags::RWX makes the pages readable, writable and executable from user mode. CachePolicy::Writeback makes them ordinary cached memory.

The cursor holds the range locked while it exists, so two parts of a kernel cannot map the same page at once; dropping it releases the range.

The preemption guard. disable_preempt() returns a guard; while it lives, the framework’s scheduler will not switch this processor to another task. OSTD calls code running under such a guard atomic mode, and the cursor demands it. OSTD frees an unmapped page-table page only after every processor has passed a point where it might be switched to another task, so under the guard the pages the cursor walks stay alive, and the cursor can hold the page-table locks of the paragraph above without being switched out. No other task exists yet, but cursor_mut’s signature demands the guard regardless.

What it asks of the machine: physical frames to allocate; a page table per address space, which the kernel edits one page at a time through a cursor; and a way to switch off task preemption.

Piece 3: the task and the loop

Simplified from prototype/kernels/hello/src/lib.rs: create_user_task, create_user_context.

fn create_user_task(vm_space: Arc<VmSpace>) -> Arc<Task> {
    fn user_task() {
        let current = Task::current().unwrap();
        let mut user_mode = {
            let user_ctx = create_user_context();
            UserMode::new(user_ctx)
        };

        loop {
            let return_reason = user_mode.execute(&DummyUserHooks);
            let user_context = user_mode.context_mut();
            if ReturnReason::UserSyscall == return_reason {
                let vm_space = current.data().downcast_ref::<Arc<VmSpace>>().unwrap();
                handle_syscall(user_context, &vm_space);
            }
        }
    }

    Arc::new(TaskOptions::new(user_task).data(vm_space).build().unwrap())
}

fn create_user_context() -> UserContext {
    let mut user_ctx = UserContext::default();
    const ENTRY_POINT: Vaddr = 0x0040_1000;
    user_ctx.set_rip(ENTRY_POINT);
    user_ctx
}

A task is the framework’s unit of execution, like a thread: a function, a stack, and saved registers. TaskOptions::new(user_task) describes one that will run the function user_task; .data(vm_space) attaches the address space to it so the function can find it later; build() creates it.

Inside the function, the task finds its address space again: Task::current() is the running task, and data() returns what was attached, recovered as the Arc<VmSpace> by downcast_ref.

A UserContext is a user program’s register file. Its register part is the sixteen general registers, the instruction pointer and the flags, eighteen words; it also records which exception, if any, ended the last run.

The task sets the instruction pointer and leaves the rest zero. Left zero, the flags would run the program with interrupts off, so execute sets the interrupt flag itself. The stack pointer stays zero, which is harmless because the program never uses a stack.

The loop calls user_mode.execute(...), which enters user mode and returns each time the processor comes back into the kernel with something for the kernel to handle: a system call, or a CPU exception such as a page fault. Interrupts it handles itself; then, after giving the scheduler a chance to switch tasks, it goes back to user mode. DummyUserHooks is the empty set of hooks: it asks to be told of no other event, so execute returns for nothing else.

When execute returns, the kernel has the reason and the program’s registers. The return value says what happened. The context now holds the program’s registers as they were at that moment. The kernel reads and changes them through context_mut() before calling execute again. On an exception this loop ignores the reason and calls execute again, so a faulting program would fault forever; this one never faults.

So a user program, to this kernel, is a function call that returns now and then. The code that serves the program, user_task, lives inside a loop, on a kernel stack, and its local variables survive every trip the program makes to user mode and back. Keep that picture: Linux does not share it.

Linux: the empty kernel stack. In Linux a task’s kernel stack is empty whenever the task is in user mode. A system call builds frames on it, and returning to user mode discards them. Reconciling that with the picture above is the work of the chapters on two stacks and on the round trip into user mode.

sequenceDiagram
    participant K as kernel task (user_task)
    participant U as user program
    K->>U: execute(): load the registers, enter user mode
    U-->>K: syscall: execute() returns UserSyscall
    K->>K: handle_syscall(context)
    K->>U: execute() again, with the updated registers
    U-->>K: syscall: execute() returns UserSyscall
    K->>K: handle_syscall: exit

What it asks of the machine: tasks with their own stacks; a way to enter user mode that returns on a system call or an exception, with the program’s registers in hand.

Piece 4: the system calls

Simplified from prototype/kernels/hello/src/lib.rs: handle_syscall.

fn handle_syscall(user_context: &mut UserContext, vm_space: &VmSpace) {
    const SYS_WRITE: usize = 1;
    const SYS_EXIT: usize = 60;

    match user_context.rax() {
        SYS_WRITE => {
            let (_, buf_addr, buf_len) =
                (user_context.rdi(), user_context.rsi(), user_context.rdx());
            let buf = {
                let mut buf = vec![0u8; buf_len];
                let mut reader = vm_space.reader(buf_addr, buf_len).unwrap();
                reader
                    .read_fallible(&mut VmWriter::from(&mut buf as &mut [u8]))
                    .unwrap();
                buf
            };
            println!("{}", str::from_utf8(&buf).unwrap());
            user_context.set_rax(buf_len);
        }
        SYS_EXIT => poweroff(ExitCode::Success),
        _ => unimplemented!(),
    }
}

The handler dispatches on the system call number, which is in rax by Linux’s convention; the arguments are in rdi, rsi, rdx. write prints the program’s buffer and returns the byte count in rax; the file descriptor in rdi is ignored, since every write goes to the console. exit powers the machine off, which in a kernelet stops the sandbox, not the host.

The kernel never dereferences a user address. For write it must read buf_len bytes of the program’s memory at buf_addr, so it asks the address space for a reader over that range; reader refuses a range outside user space, and refuses an address space that is not the one active on this processor, which is why piece 1’s activate matters. read_fallible copies through the reader into a kernel buffer, wrapped as a VmWriter.

A bad buffer ends in a panic, never in a stray access. Under vOSTD an unmapped page makes read_fallible return an error rather than fault; the kernel then calls unwrap on it and panics. On a machine, OSTD turns the page fault into that error only if the kernel has registered a page-fault handler, which this one has not, so OSTD panics in its fault handler instead. A kernelet’s panic, like poweroff, ends only its sandbox.

What it asks of the machine: a safe way to copy from a user address, which may be bad; a console; and power-off.

The list

Here is everything the hundred-line kernel needs, with what OSTD does on a machine and what vOSTD and the endovisor will have to do instead. Each row names, in italics, the chapter that builds it.

the kernel useson a machine, OSTDinside Linux, vOSTD must
#[ostd::main]boots, then calls main and yields to the first taskvOSTD’s entry, which the endovisor calls, sets up and then calls the same generated __ostd_main (The kernelet ABI)
Arc, vec! (the heap)a heap in the machine’s RAMask Linux for memory (The kernelet ABI)
unwrap, unimplemented! (a panic)prints and haltsprint to the host and stop the sandbox (The kernelet ABI)
FrameAllocOptions, Segment, write_bytesallocates from the machine’s RAM and writes through its own map of itallocate from a grant of memory Linux hands it, and write through Linux’s direct map, the range of kernel addresses through which all physical memory is reachable (The kernelet ABI)
VmSpace::new, cursor_mut, mapwrites a hardware page table the processor walksask Linux to put the page in a Linux page table (Tenant memory on Linux’s page tables)
VmSpace::activateloads the page-table registerremember which address space this virtual CPU is in, and switch the carrier to its Linux address space (Tenant memory on Linux’s page tables)
TaskOptions, Task::run, Task::currentits own tasks, stacks and schedulerOSTD’s code, ported with small changes, on stacks the endovisor allocates for the kernelet’s tasks (Two stacks; Tasks and virtual CPUs)
disable_preempta per-processor count the scheduler checkskeep the count in the virtual CPU’s record, and raise Linux’s own preemption count while any guard is held (Sharing a processor politely)
UserMode::executethe processor’s instruction for entering user mode, and its trap handlersa carrier, a Linux task that enters user mode on the kernelet’s behalf, with a patch to Linux that routes the system call back (Carrier hooks through Into user mode and back)
VmSpace::reader, read_falliblereads the user address; a fault goes to the trap handler, which needs a page-fault handler the kernel registers (this one registers none, so it panics)walk the page table itself and copy through Linux’s direct map (What vOSTD adds on top)
println!, poweroffthe serial port; QEMU’s exit port, or a halttwo calls into the host (The kernelet ABI)

Recap. The hundred-line kernel allocates frames, builds an address space with a cursor, creates a task, and in that task loops over execute, which enters user mode and returns on each system call. It copies the program’s buffer through a fallible reader rather than touching the address. The table above is the contract that vOSTD and the endovisor must fulfill. The rest of the book fulfills it, chapter by chapter.

The kernelet ABI

The previous chapter was the view from above the framework: a kernel that allocates frames, maps pages and enters user mode. This chapter is the view from below: what is in the image file that the holder registered, and how the code in it talks to the host. Nothing in it runs yet; it is the shape that the next chapters fill in.

A kernelet image is two layers of code compiled into one file, held together by a third thing, an interface.

  1. The kernel proper, the hundred-line kernel of the last chapter: safe Rust, never edited.
  2. vOSTD, a Rust crate whose library name is ostd, so that the kernel proper’s use ostd::... lines resolve to it. It offers every type and function the kernel proper uses, with the same names and signatures as the real OSTD. It is in prototype/vostd/.
  3. The image ABI (an application binary interface is the exact layout of what two separately compiled pieces of code pass each other), a few C-layout structures that both vOSTD and the endovisor know: what the host tells the kernelet at start, which functions the kernelet may call, and which functions the host may call. It is in prototype/vostd/src/abi.rs on the Rust side and prototype/linux/endovisor/kernelet.h on the C side, and the two must agree byte for byte.

The first two are the image’s contents; the third is this chapter’s subject.

Registering an image validates the restricted PIE format described in the endovisor UAPI. The build links the kernel proper and vOSTD into one self-contained ET_DYN image, whose program headers fix the relative layout of code and data. The image imports no host symbols; it calls Linux through the service table supplied at entry. The ELF header’s e_entry identifies the entry-table object below, which the endovisor reads rather than executing as an instruction.

The registered kind owns one physical copy of the code and read-only data and a pristine template for writable data. Each sandbox gets a separate virtual mapping of that layout: shared physical pages for code and read-only data, fresh pages for writable data and zero-initialized storage. R_X86_64_RELATIVE relocations initialize its private pointers using its own load base. The entry table therefore contains addresses within that sandbox’s code mapping. Multiple sandboxes of one kind can run concurrently; the code is shared, while globals, pointer tables and vOSTD’s state are private.

Linux: modules and exported symbols. A kernel module, code loaded into the running kernel with insmod, may call only the kernel functions that the kernel marks as exported (EXPORT_SYMBOL in the source); the rest are private to the kernel even though they are in the same address space. The endovisor is a module. A few functions it needs are not exported in a stock kernel, which is one of the two reasons for the patch in the next chapter.

The three tables

The image ABI has three structures. Here they are, trimmed to what this chapter can explain; the fields left out belong to later chapters.

Simplified from prototype/vostd/src/abi.rs: BootArgs, Services, EntryTable.

/// What the endovisor tells the kernelet about the world it was given.
#[repr(C)]
pub struct BootArgs {
    pub abi_version: u64,
    pub grant_paddr: u64,        // the physical address of its memory
    pub grant_len: u64,          // and how much: 2 or 4 MiB here
    pub linear_map_base: u64,    // where Linux maps all of physical memory
    pub nr_vcpus: u64,           // how many virtual CPUs the sandbox has
    // ... how to find the virtual CPU record, and the floating-point area's size
    pub tsc_khz: u64,            // the cycle counter's frequency
    // ... the command line, and more
}

/// The host functions the kernelet may call. Its only way out.
#[repr(C)]
pub struct Services {
    pub abi_version: u64,
    pub log_write: unsafe extern "C" fn(ptr: *const u8, len: u64),
    // ... console_write, added later in the table
    pub mem_alloc: unsafe extern "C" fn(size: u64, align: u64) -> *mut u8,
    pub mem_free: unsafe extern "C" fn(ptr: *mut u8, size: u64, align: u64),
    pub user_run: unsafe extern "C" fn(ctx: *mut RawUserContext) -> u64,
    pub vm_create: unsafe extern "C" fn() -> i64,
    // ... vm_destroy, vm_activate
    pub vm_map: unsafe extern "C" fn(space: u64, va: u64, paddr: u64, prot: u32) -> i64,
    // ... vm_unmap, vm_protect, vm_flush, vm_wait, vm_wake
    pub kstack_alloc: unsafe extern "C" fn() -> u64,
    pub kstack_free: unsafe extern "C" fn(base: u64),
    pub vcpu_boot: unsafe extern "C" fn(id: u64),
    pub vcpu_kick: unsafe extern "C" fn(id: u64),
    pub vcpu_idle: unsafe extern "C" fn(deadline_ns: u64),
    pub vcpu_yield: unsafe extern "C" fn(),
    // ... vcpu_on_spin, fpu_save, fpu_load
    pub stop: unsafe extern "C" fn(code: u64) -> !,
}

/// The kernelet functions the host may call.
#[repr(C)]
pub struct EntryTable {
    pub abi_version: u64,
    pub kernelet_entry: unsafe extern "C" fn(boot: *const BootArgs, svc: *const Services),
    pub vcpu_entry: unsafe extern "C" fn(id: u64),
    pub virq_entry: unsafe extern "C" fn(),
}

vOSTD is the only crate in the image that can reach the service table, which it keeps private, so only vOSTD can call a service.

Two rules shape every service. Arguments are integers or pointers into the kernelet’s own memory: a space number, a virtual address, a physical address, a buffer to log. The one exception is mem_free, whose pointer is into host memory that mem_alloc returned, below; the endovisor checks it against a table of the blocks it handed out, kept where the kernelet cannot reach, reads the block’s number below the pointer with a read that tolerates a fault, and refuses what it does not recognize (a stale pointer to a block that was freed and handed out again at the same address is the one case it cannot tell apart). And the host writes into the kernelet only through the two pointers vOSTD hands it for that purpose, the user context that user_run is given and the area that fpu_save is given, and keeps no other. Both keep a buggy vOSTD from steering the endovisor into writing wherever the kernelet says.

vOSTD defines the entry table under the symbol kernelet_entry_table; the final linker records its address in ELF’s e_entry field:

Simplified from prototype/vostd/src/abi.rs: kernelet_entry_table.

#[no_mangle]
pub static kernelet_entry_table: EntryTable = EntryTable {
    abi_version: KLET_ABI_VERSION,
    kernelet_entry: crate::kernelet_entry,
    vcpu_entry: crate::vcpu_entry,
    virq_entry: crate::vcpu::klet_virq_entry,
};

The endovisor checks the entry table’s abi_version against its own when an image is registered, and refuses the image if the two halves were built from different ABIs.

The grant and the direct map

The endovisor gives a kernelet a grant: one physically contiguous block of RAM, the memory_initial of the holder’s create request (4 MiB from the prototype’s holder; a request is rounded up to 2 MiB and may not exceed 4 MiB), taken from Linux’s page allocator when the sandbox starts, charged to the sandbox’s control group, and described in the boot arguments by its physical address and length.

Simplified from prototype/linux/endovisor/sandbox.c: klet_sandbox_start.

int klet_sandbox_start(struct klet_inst *inst)
{
	unsigned int order = get_order(inst->memory_initial);       /* 2^10 pages for 4 MiB */

	int err = klet_image_start(inst);                          // Shared code, private data and relocated pointers.
	if (err)
		return err;
	pages = alloc_pages(GFP_KERNEL_ACCOUNT | __GFP_ZERO | __GFP_NOWARN, order);
	if (!pages)
		return -ENOMEM;
	inst->grant_pages = pages;
	inst->boot.abi_version = KLET_ABI_VERSION;
	inst->boot.grant_paddr = page_to_phys(pages);
	inst->boot.grant_len = PAGE_SIZE << order;
	inst->boot.linear_map_base = (u64)(unsigned long)__va(0);
	inst->boot.nr_vcpus = inst->nr_vcpus;
	/* ... the FPU area's size, the cycle counter's rate, the image's text range, and more in later chapters */
	return 0;
}

Linux: the direct map. Linux maps all of physical memory, in order, into one region of the kernel’s address space, called the direct map or linear map. Turning a physical address into a pointer the kernel can dereference is therefore one addition: __va(paddr) is paddr + base. kmalloc and the page allocator hand out direct-map memory, so for them a kernel pointer and a physical address are two names for the same bytes; vmalloc, from which the endovisor takes kernelet stacks, is the exception, because it maps scattered pages into a separate region. The line __va(0) above is simply the base.

vOSTD’s frame allocator plays over the grant the part OSTD’s plays over a machine’s RAM, more simply: where the allocator shipped with OSTD is a buddy system, vOSTD’s is a bitmap with one bit per 4 KiB frame, a first-fit search for n contiguous free frames, and a reference count per frame (the count matters once tenant pages sit on Linux’s page tables). When the kernel proper’s FrameAllocOptions asks for a segment, this is what answers.

Simplified from prototype/vostd/src/mm/frame.rs: alloc, FrameAllocOptions.

pub(crate) fn alloc(nframes: usize) -> Option<Paddr> {
    let guard = Guard::lock();                 // a spin lock over the bitmap
    let bits = guard.bits();
    let mut start = 0;
    'outer: while start + nframes <= total {
        for i in start..start + nframes {
            if test(bits, i) { start = i + 1; continue 'outer; }   // in use: look further
        }
        for i in start..start + nframes {
            bits[i / 64] |= 1u64 << (i % 64);
            REFS[i].store(1, Ordering::Relaxed);                     // one reference: the handle
        }
        return Some(BASE + start * PAGE_SIZE);
    }
    None
}

And the translation from a frame’s physical address to something the kernelet can read and write is the same one addition Linux uses, with the base the boot arguments supplied:

Unchanged from prototype/vostd/src/mm/mod.rs: paddr_to_vaddr.

pub(crate) fn paddr_to_vaddr(paddr: Paddr) -> usize {
    crate::abi::linear_map_base() + paddr
}

This is how the hundred-line kernel’s segment.write_bytes(0, program) lands in RAM: vOSTD adds the base to the segment’s physical address and copies there. No page table is touched, because Linux mapped that memory before the kernelet existed.

Three small services

Three of the services need no more than a sentence each, and the hundred-line kernel uses all three.

The heap. Rust’s Vec, Box and Arc need a heap. vOSTD’s is the host’s: a global allocator that forwards each allocation to the services mem_alloc and mem_free, which the endovisor implements with Linux’s kmalloc and kfree.

Simplified from prototype/vostd/src/mm/heap.rs: HostHeap.

struct HostHeap;

unsafe impl GlobalAlloc for HostHeap {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        (svc().mem_alloc)(layout.size() as u64, layout.align() as u64)
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        (svc().mem_free)(ptr, layout.size() as u64, layout.align() as u64)
    }
}

#[global_allocator]
static HEAP: HostHeap = HostHeap;

The console and the log. println! collects a line and hands it to console_write, the sandbox’s console; vOSTD’s own lines, among them a panic’s message, go to log_write, the sandbox’s kernel log. Each arrives at the file the holder set for it, through a ring that a work item drains, because a service may not block on a terminal or a pipe while the kernel proper may hold one of its spin locks. A sandbox given no file for one of the two has its lines printed with printk, prefixed kernelet:, which is why they then appear in the serial log beside Linux’s own messages.

Power-off. poweroff is the service stop, which never returns. The endovisor ends the sandbox with it, never the host.

How the kernelet starts

The endovisor enters the image at kernelet_entry with the two tables. vOSTD records them, sets up what the chapter has described, and calls the kernel proper’s main.

Simplified from prototype/vostd/src/lib.rs: kernelet_entry.

pub(crate) unsafe extern "C" fn kernelet_entry(boot: *const BootArgs, svc: *const Services) {
    abi::init_tables(boot, svc);          // keep the pointers; check the ABI version
    let args = &*boot;
    cpu::init(args.nr_vcpus as usize);
    time::init(args.tsc_khz);
    mm::frame::allocator::init(args.grant_paddr as usize, args.grant_len as usize);
    mm::heap::init();
    boot::init(cmdline);                   // the command line, copied out of the boot arguments
    __ostd_main()                          // generated by #[ostd::main]: calls main(), then yields
}

__ostd_main calls main and then Task::yield_now(), which, since main started a task, switches to it and never returns. That is how the hundred-line kernel goes from piece 1 to piece 3: main built a task and returned, and the framework’s scheduler ran it.

What remains unexplained is where kernelet_entry is called from: on which Linux task, on which stack, and how that task later enters user mode for the kernelet. Those are the next four chapters.

Recap. A kernelet talks to the host through three C-layout tables: boot arguments in, a service table of function pointers out, and an entry table the host calls. Its memory is a grant of a few MiB whose frames it reaches through Linux’s direct map, its heap is kmalloc behind a service, and its console and log go to files the holder sets, or to printk if it sets none. The endovisor starts it by calling kernelet_entry, which ends in the kernel proper’s main.

Carrier hooks and patch overview

Left alone, Linux answers a tenant’s system calls itself. A kernelet’s user program runs in user mode on a real processor. When it executes syscall, the processor jumps into whatever the hardware’s system-call entry register points at, and on a Linux machine that is Linux’s entry code. Linux would then look the call number up in its own table and run write. Nothing in the hundred-line kernel would ever hear of it.

So the design needs a way to hand a system call to a kernelet before Linux handles it. It also needs, for reasons this chapter builds up to, a way to run kernelet code just before one of its tasks enters user mode, whatever brought the task into the kernel. Linux has no such interface. This chapter adds one that does both: two hooks, which Linux calls only for the tasks that carry a kernelet.

To Linux, a carrier is a task with one new field: a pointer to its carrier event hooks, a table of two functions. A carrier is a Linux task that carries one of a sandbox’s virtual CPUs: it runs the kernelet’s code and enters user mode for it (chapter 1’s table; chapter 2 showed how one is made). Linux calls the two functions at the two events through which every crossing between a carrier’s user mode and the kernel passes: a system call from the carrier’s user mode, and the carrier’s return to user mode with work pending. For an ordinary task the pointer is null, and nothing in the patch is reached.

The table is the whole interface by which Linux’s core calls into the endovisor. The core knows the type of the two functions and the two places to call them, and nothing of what is behind them; the endovisor implements the functions, puts a table into the record it keeps for each carrier, and finds that record from the table’s address when a hook is called.

Where Linux can be hooked

Linux: tasks and the entry path. Linux’s unit of execution is the task (struct task_struct); a thread is a task, a single-threaded process is a task.

While a task is in the kernel, its user registers sit in pt_regs. When a task enters the kernel from user mode, the entry code saves the user registers at the top of the task’s kernel stack in a structure called pt_regs, and restores them from there on the way back. To change what a task does when it returns to user mode, change its pt_regs.

On x86-64 every system-call instruction, after a few lines of assembly and one architecture-specific C function, reaches architecture-independent C in kernel/entry/common.c, the generic entry layer. On the way in, if the task has any bit set in a per-task mask called its syscall work, the layer calls syscall_trace_enter(), which runs Linux’s interception mechanisms in order: Syscall User Dispatch, ptrace, and seccomp, the filter by which a process restricts which system calls it and its descendants may make. Any of them may declare the call answered, in which case Linux skips its own handler. On the way out, if the task has pending work such as a signal, the layer runs exit_to_user_mode_loop() until none is left, then restores pt_regs.

Linux thus already has three stages that can intercept a system call. All three are controlled from user space, and none lets kernel code answer a call. The syscall hook is a fourth stage that does, and the resume hook is its counterpart on the way out.

Two hooks, one for each direction

On the way in. The hook answers the question the chapter opened with: the tenant made a system call, so give it to the kernelet. It runs last among the interceptors, and if the kernelet answers the call, Linux’s table is skipped.

flowchart TB
    u["user program<br/>executes syscall"] --> e["entry assembly<br/>saves pt_regs"]
    e --> w{"any syscall<br/>work bit set?"}
    w -- no --> t["Linux's<br/>system-call table"]
    w -- yes --> s["syscall_trace_enter:<br/>dispatch, ptrace, seccomp"]
    s --> g{"the KERNELET<br/>bit set?"}
    g -- no --> t
    g -- "yes, but<br/>hooks->on_syscall(regs)<br/>returned false" --> t
    g -- "yes, and<br/>it returned true" --> x["Linux's system-call<br/>table is skipped"]

On the way out. A system call is the only reason a tenant asks for its kernel, but it is not the only reason the kernel must run before the tenant does. Three more appear in later chapters:

  • A carrier has just been created. It is a fresh Linux task heading for user mode, and nothing of the kernelet has run on it yet. The kernelet must boot, build the tenant’s address space and choose the registers the task will enter user mode with, all before that first entry, and there has been no system call to hook.
  • The tenant has faulted. It executed an instruction it may not, or touched a page that is not mapped. Linux handles the exception and, as for every process, turns it into a signal to deliver on the way back to user mode. The kernelet must take that exception for itself before Linux delivers the signal.
  • Someone else wants the kernelet. A timer fired, or another virtual CPU has a task for this one, while the carrier is in user mode. It must be brought into the kernel and then into kernelet code before it continues.

In every case the task is inside the kernel, on its way back to user mode, and someone has set a flag saying there is work to do before it gets there. Linux already has exactly that moment: the exit loop, which runs whenever a task with pending work is about to return to user mode. The way-out hook is placed at its top. The job it gives the kernelet is the same every time: the tenant is about to run; find out why you were called, and run kernelet code first if there is any to run.

flowchart TB
    p{"work pending?<br/>(a signal, a flag,<br/>a reschedule)"} -- no --> back["restore pt_regs,<br/>return to user mode"]
    p -- yes --> loop[exit_to_user_mode_loop]
    loop --> r{"a carrier?<br/>(current->carrier_hooks)"}
    r -- yes --> h["the task's hooks:<br/>on_resume(regs)"]
    h --> sig[signals, reschedule]
    r -- no --> sig
    sig --> p

The patch, hunk by hunk

A table of hooks and a pointer per task. A new header defines the table, two function pointers, and struct task_struct gains a pointer to one. A task whose pointer is null is unaffected by anything else in the patch.

Simplified from prototype/linux/patches/0001-carrier-hooks.patch: carrier_event_hooks.

/* include/linux/kernelet.h (new) */
struct carrier_event_hooks {
	/* Called from syscall_trace_enter(), after seccomp, for a system call
	 * issued by a carrier's user mode; regs hold the number and the
	 * arguments. Returns true if the kernelet serviced the call: regs then
	 * hold what the task returns to user mode with, return value included,
	 * and Linux's own dispatch is skipped. Returns false to let Linux
	 * dispatch the call as for any other task. */
	bool (*on_syscall)(struct pt_regs *regs);

	/* Called from exit_to_user_mode_loop(), after the TIF_NOTIFY_RESUME
	 * work and before signals are delivered, whenever a carrier is about
	 * to return to user mode with exit work pending: for a reason of the
	 * kernelet's own (a carrier's first entry, a tenant's fault, a virtual
	 * interrupt) or for one of Linux's (a reschedule, a signal). No reason
	 * is passed, and several may be pending at once: whoever requested the
	 * call recorded why in the endovisor's record for the task, and the
	 * hook reads it there. The loop may call it more than once on one
	 * return.
	 *
	 * Runs in process context with no lock held; it may sleep, and may run
	 * kernelet code for as long as it likes. It may rewrite regs, which are
	 * what the task returns to user mode with, and must leave them alone
	 * when it has nothing to do. */
	void (*on_resume)(struct pt_regs *regs);
};

/* include/linux/sched.h, inside struct task_struct */
	struct carrier_event_hooks	*carrier_hooks;	/* NULL for every ordinary task */

One bit of syscall work. The patch adds a syscall-work bit, SYSCALL_WORK_KERNELET, so that Linux calls syscall_trace_enter() for a carrier. Linux calls that function only for tasks with a bit set in their syscall-work mask, and the new bit is included in the set that triggers the call on entry. A task that is not a carrier pays nothing: the fast path does not even test the pointer.

The syscall hook. Inside syscall_trace_enter(), directly after seccomp:

Simplified from prototype/linux/patches/0001-carrier-hooks.patch: SYSCALL_WORK_KERNELET.

/* kernel/entry/common.c, syscall_trace_enter() */
	if (work & SYSCALL_WORK_KERNELET) {
		if (current->carrier_hooks->on_syscall(regs))
			return -1L;
	}

Returning -1 from this function is Linux’s existing convention for “an earlier stage has answered this call; do not dispatch it”, and do_syscall_64() then skips the table.

Seccomp comes first. Because the hook is placed after seccomp, the filter a sandbox was started with runs on every one of the tenant’s system calls before the kernelet sees them. That is a tool for the kernelet runtime, the host program that creates sandboxes (chapter 2), with a sharp edge. A filter that allows a call lets the kernelet serve it. One that returns an error answers the call in Linux, and the kernelet never hears of it. One that kills ends a carrier, and with it, by the endovisor’s rule that a lost carrier kills its sandbox, the whole sandbox. A filter for a sandbox therefore bounds what the tenant may ask of its kernelet, and must be written for that, not copied from a host process.

The prototype’s holder installs a filter only in one boot of make probe, which checks that a killing filter ends the sandbox.

The resume hook. The patch’s second call is on the way out. A task reaches exit_to_user_mode_loop() only when it has exit work pending, and the hook sits at the top of that loop, after interrupts are enabled and before Linux delivers signals. The loop, and the flag that most often sends a task into it, are Linux’s own:

Linux: the exit work flags and TIF_NOTIFY_RESUME. A task carries a word of thread flags, and a few of them mean “do something before this task next returns to user mode”: TIF_SIGPENDING (a signal to deliver), TIF_NEED_RESCHED (the scheduler wants the processor back), and TIF_NOTIFY_RESUME, the general-purpose one, which stands for any other deferred work. The entry layer tests the whole set at once (EXIT_TO_USER_MODE_WORK) and enters exit_to_user_mode_loop() only if one of them is set.

TIF_NOTIFY_RESUME stands for deferred work, run by resume_user_mode_work(), which clears the flag first. It is set by set_notify_resume(), and by task_work_add(), which queues a function for a task to run on its own way out to user mode. The flag’s work, resume_user_mode_work(), clears the flag, runs the queued task work, reclaims memory for a control group over its high limit, throttles block I/O if the task owes some, and tells a restartable sequence that it was interrupted. That it clears the flag first is what the excerpt below depends on.

The hook, with Linux’s own work for that flag placed before it:

Simplified from prototype/linux/patches/0001-carrier-hooks.patch: resume_user_mode_work.

/* kernel/entry/common.c, exit_to_user_mode_loop() */
		if (unlikely(current->carrier_hooks)) {
			if (ti_work & _TIF_NOTIFY_RESUME) {          /* Linux's own notify-resume work first, since it clears the flag: */
				resume_user_mode_work(regs);             /* a flag set while the hook runs then survives to the loop's */
				ti_work &= ~_TIF_NOTIFY_RESUME;          /* next look at the flags, and the hook runs again */
			}
			current->carrier_hooks->on_resume(regs);
		}

Linux’s own notify-resume work goes first because its first act is to clear the flag that requested it. Run after the hook, it would clear a request the endovisor made from another processor while the hook was looking elsewhere, and the carrier would enter user mode with that request pending.

The hook runs before signal delivery so that the kernelet can take a tenant’s exceptions away from Linux, when the round trip into user mode is built.

The hooks are inherited. Nothing in the patch copies the pointer into child tasks, and nothing needs to: Linux creates a task by copying the parent’s whole task_struct (dup_task_struct()), the new field included, and the syscall-work bits are inherited the same way. A child of a carrier therefore has the hooks before it executes its first instruction, and there is no moment at which it could make a system call that Linux would answer. A carrier never forks through Linux, since the kernelet answers its system calls, so a child that shared its parent’s record never arises. This is the difference between a field Linux does not know about, which it copies, and a setting Linux knows about, such as Syscall User Dispatch, which it deliberately resets at fork and exec.

Two helper functions. kernel/fork.c gains two functions that the endovisor cannot write itself because everything they touch is private to the core kernel: kernelet_mm_alloc(), which creates an empty address space with a chosen owner, and kernelet_switch_mm(), which makes the calling task run in a different address space. Both belong to tenant memory, built on Linux’s page tables later in the book; they are mentioned here only so that the patch’s contents are complete.

Six exports. Functions the endovisor needs that Linux does not already export to modules, each one EXPORT_SYMBOL_GPL line. flush_tlb_mm_range is for vm_flush, the flush of a tenant’s address space. Four are for the image loader of chapter 4: set_memory_rox, which makes shared code mappings read-only and executable; set_memory_ro, which makes shared data mappings read-only; set_memory_rw, which restores backing-page writability before the pages are freed; and modules_disabled, the setting by which an operator forbids module loading, which registering an image checks as init_module does. kernel_clone is exported too; nothing in the endovisor calls it.

A configuration option. CONFIG_KERNELET guards the hooks, the field, the helpers and two of the exports, and selects CONFIG_PREEMPT_NOTIFIERS, which the endovisor uses. The prototype’s kernel configuration turns it on. A kernel built without it differs from a stock kernel only in exporting the four symbols of the image loader, whose export lines are unconditional; every other line the patch adds to a function is inside #ifdef CONFIG_KERNELET, and what remains outside are the include lines, a forward declaration, the new header (the hooks’ type and two prototypes), the bit’s name and a macro that expands to zero.

A precondition. The kernel must be built without the legacy vsyscall page (CONFIG_X86_VSYSCALL_EMULATION unset). A program that jumps to that page’s fixed address gets three system calls answered by Linux’s own page-fault handler, without ever reaching the entry path, so the syscall hook would never see them. The module refuses to load on a kernel built with it.

What the patch does not do

It adds no new system call, no boot parameter, and no change to Linux’s scheduler or memory manager. For a task whose carrier_hooks pointer is null, which is every task on a machine without a loaded endovisor, the two hooks are never reached. Its whole effect is to let some kernel code, named by a pointer in the task, see a task’s system calls before Linux does and run once more before the task returns to user mode. Who installs that pointer, and on which tasks, is the next chapter.

Recap. The carrier event hooks are two functions that Linux’s generic entry layer calls for a carrier, selected by a pointer in task_struct and a syscall-work bit: on_syscall runs after seccomp and can answer the call so that Linux’s table is skipped; on_resume runs on the way back to user mode, before signals are delivered, and gives the kernelet the last word before the tenant runs, for everything that is not a system call: a carrier’s first entry, a tenant’s fault, a timer or another virtual CPU wanting it. A child inherits the pointer because Linux copies the task structure. The rest of the patch is two helpers and six exports for other chapters.

Carriers

Chapter 5 attached two hooks to a Linux task. This chapter is about those tasks: where they come from, what they inherit, and how the endovisor learns when one dies.

A carrier is a Linux task that carries one of a sandbox’s virtual CPUs: it runs the kernelet’s code and enters user mode on the kernelet’s behalf, and a virtual CPU is one of the processors the kernelet believes it has. Linux schedules carriers like any tasks; it does not know what they carry. A sandbox has a fixed number of them, one per virtual CPU, chosen when it is created, and nothing else of the sandbox is a task: the holder of chapter 2, which created the sandbox, is a process of the runtime and runs no kernelet code. What a virtual CPU is in full comes with the kernelet’s own scheduler; for now, a sandbox with one virtual CPU, like the hundred-line kernel’s, has one carrier.

Why a carrier is not a kernel thread

A carrier is an ordinary user task, not a kernel thread, because it must enter user mode. Linux has a kind of task that runs only kernel code, the kernel thread, and it is the natural first thought for “a task that runs the kernelet”. It is the wrong one. A kernel thread has no user address space and can never return to user mode, and a carrier must: entering user mode to run the tenant’s program is the one thing the kernelet cannot do for itself. So a carrier has an address space and a pt_regs, and happens to spend most of its time in kernel mode.

A carrier is born by fork and exec

Linux: how tasks are made. Almost every task is created by one function, kernel_clone(), which underlies fork, clone and kernel-thread creation. It copies the parent’s task structure and then, depending on flags, shares or copies the address space, open files and so on. A child made by fork is therefore a copy of its parent in every property Linux keeps on the task: control group, credentials, namespaces, processor mask, priority, resource limits, seccomp filter, and the hooks pointer the patch added.

A task gets a fresh address space only from execve. Linux offers the file to each registered binary-format handler (struct linux_binfmt) in turn, and the one that recognizes the file replaces the process’s address space and sets its starting registers. A module can register a handler of its own. What exec keeps of the task and what it resets is the box of chapter 2.

A carrier has whatever its process set on itself before the exec, because exec keeps the task’s properties and replaces only its program. For each virtual CPU the holder asks the sandbox for an exec descriptor, forks a child, and the child, after arranging itself with ordinary system calls, executes the descriptor with execveat. What the child set is what the carrier has: its control group, which the holder joined before creating the sandbox; its processor mask and priority; its seccomp filter, if the runtime installed one. The prototype’s child sets the no-new-privileges bit, which the endovisor insists on, and pins itself or installs a filter if its options ask.

A sandbox is charged through its control group: every carrier is in it, so the kernelet’s processor time and accounted memory are the tenant’s. Chapter 2’s box introduced control groups, and KLET_CREATE records the holder’s group and refuses the exec of an exec descriptor from any other. A child starts in its parent’s group, and exec does not change it. The processor time the kernelet burns is therefore charged to the tenant by machinery Linux already has, and so is any memory allocated on a carrier with the flag that asks for it (__GFP_ACCOUNT). Linux charges the tenant’s page tables that way by itself, to the carrier whose fault or mapping causes them; the endovisor charges the grant, the pages of the kernelet’s stacks and its heap with the same flag (GFP_KERNEL_ACCOUNT), all of them allocated on a carrier of the sandbox.

The endovisor’s half is the binary-format handler, where the child becomes the carrier:

Simplified from prototype/linux/endovisor/main.c: klet_load_binary, klet_empty_mm, klet_ignore_signals.

static int klet_load_binary(struct linux_binprm *bprm)
{
	if (memcmp(bprm->buf, KLET_EXEC_MAGIC, KLET_EXEC_MAGIC_LEN) != 0)
		return -ENOEXEC;                        /* not ours: the next handler's turn */
	inst = klet_exec_lookup(bprm, &vcpu);       /* which sandbox and virtual CPU, by the file's inode */
	if (!inst)
		return -ENOEXEC;
	err = klet_exec_claim(inst, vcpu);          /* not killed; not started; the sandbox's group; no new privileges */
	if (err)
		return err;                             /* ESRCH, EBUSY or EPERM: the exec fails and the process lives on */

	begin_new_exec(bprm);                       /* the point of no return: a fresh address space, signal handlers reset */
	klet_personality_64bit();
	setup_new_exec(bprm);
	klet_empty_mm();                            /* unmap the one area exec left: the argument stack */
	set_binfmt(&klet_format);
	finalize_exec(bprm);
	start_thread(current_pt_regs(), 0, 0);      /* valid 64-bit user registers, though it never uses them */
	klet_ignore_signals();                      /* every signal that can be ignored is: chapter 8 says why */
	set_mask_bits(&current->mm->flags, MMF_DUMPABLE_MASK, SUID_DUMP_DISABLE);   /* not dumpable, whoever the holder is */
	snprintf(comm, sizeof(comm), "kernelet/%d/%u", inst->slot, vcpu);          /* the name ps shows */
	strscpy_pad(current->comm, comm, sizeof(current->comm));

	c = klet_carrier_new(inst, vcpu);
	klet_carrier_attach(c);                     /* the hooks and the lifeline */
	mmget(current->mm);                         /* keep a reference to the empty address space ... */
	c->blank_mm = current->mm;                  /* ... dropped when a tenant's replaces it, with tenant memory */
	inst->vcpus[vcpu] = c;

	if (!inst->started)                         /* the first arrival starts the sandbox: */
		klet_sandbox_start(inst);               // The private image mapping, the grant, the boot arguments.
	inst->arrived++;
	wake_up_all(&inst->arrive_wq);              /* virtual CPU 0 may be waiting for this one */

	set_thread_flag(TIF_NOTIFY_RESUME);         /* make the resume hook run on the way out */
	return 0;
}

The calls from begin_new_exec to start_thread are what every program loader makes, with one difference. The ELF loader would now map the program and its stack into the new address space; this handler maps nothing, and even unmaps the temporary page that holds the execveat arguments, so that the address space is empty. Nothing of the host’s is left in it for a tenant to read.

One line undoes what the holder’s identity would otherwise leave: the carrier is made non-dumpable. Linux makes a process that executed a file it cannot read dumpable only as fs.suid_dumpable allows, which by default is not at all, but root can read an execute-only file, so for a root holder the rule would leave the carrier dumpable; the handler clears the dumpable bits itself, for the carrier and, later, for every address space the kernelet creates.

start_thread gives the task a valid 64-bit user register frame, as every loader must; the resume hook will overwrite it before the task ever reaches user mode.

The last line hands the carrier to the resume hook. The carrier returns from the handler and heads for user mode, as every execve does. On the way it passes through the exit loop, and because the flag is set, the resume hook runs. The carrier never arrives in user mode on its own account: from here on it goes there only when the kernelet sends it. Setting TIF_NOTIFY_RESUME on a carrier, by a plain flag-set on the current task or by set_notify_resume() on another, which also nudges the processor that task is on, is what the book calls flagging the carrier.

flowchart TB
    h["the holder<br/>(a process of the runtime)"] -- "fork" --> ch["a child: sets no-new-privileges,<br/>pins itself and installs<br/>a filter if asked"]
    ch -- "execveat(exec fd i)" --> hd["the binary-format handler:<br/>checks the group and the bit,<br/>empties the address space"]
    hd --> c["the carrier of virtual CPU i<br/>named kernelet/slot/i"]
    subgraph kept["kept across the exec, because they belong to the task"]
        direction TB
        k1[control group] --- k2[seccomp filter] --- k3[processor mask, priority] --- k4[credentials, namespaces]
    end
    subgraph set["set by the handler"]
        direction TB
        s1["an empty address space"] --- s2["every signal ignored"] --- s3["not dumpable"] --- s4["the hooks pointer and a lifeline"]
    end
    ch -.-> kept
    hd -.-> set

Entering the kernelet

The resume hook is where a new carrier enters the kernelet, once its virtual CPU may start. Its first trip through the hook finds the carrier in the state NEW:

Simplified from prototype/linux/endovisor/carrier.c: klet_on_resume, klet_vcpu_await.

		if (c->state == KLET_NEW) {                 /* the carrier's first way out to user mode, from the exec */
			if (!klet_vcpu_await(c)) {              /* wait until this virtual CPU may enter the image */
				klet_carrier_die(c);                /* the sandbox ended, or the carrier was killed, while it waited */
				return;
			}
			klet_enter(c, KLET_RUN_EVENT);          /* switch to a kernelet stack and run kernelet code */
			continue;
		}

static bool klet_vcpu_await(struct klet_carrier *c)
{
	if (c->vcpu == 0)                           /* virtual CPU 0 boots the kernelet, once every carrier has arrived */
		wait_event_state(inst->arrive_wq, inst->arrived == inst->nr_vcpus || inst->dying,
				 TASK_KILLABLE | TASK_NOLOAD);
	else                                        /* the others wait for the kernel proper to start their processor */
		wait_event_state(c->idle_wq, c->boot_requested || inst->dying,
				 TASK_KILLABLE | TASK_NOLOAD);
	return !inst->dying && !fatal_signal_pending(current) &&
	       (c->vcpu == 0 ? inst->arrived == inst->nr_vcpus : c->boot_requested);
}

The kernelet boots only when every virtual CPU’s carrier has arrived, because a kernel that starts its other processors expects each of them to answer.

Carriers wait in a killable, no-load sleep. wait_event_state is Linux’s ordinary way to sleep until a condition holds: a waker wakes the queue the sleeper named, and the sleeper checks its condition again. The sleep is killable, ended only by SIGKILL, because a SIGSTOP sent to a carrier stays pending until the carrier next heads for user mode and must not end the wait early; and no-load, so that a waiting carrier is not counted in the load average.

klet_enter runs the kernelet until it asks to run a user program, or leaves for good. For virtual CPU 0 it leads to kernelet_entry from chapter 4, and so to the kernel proper’s main; for the others, to vcpu_entry, once the kernel proper has asked for that processor through the service vcpu_boot. When the carrier returns from klet_enter with a program to run, the hook’s loop puts the right registers in place and lets Linux return to user mode. klet_enter itself is the subject of the next chapter.

Lifelines

The endovisor must hear of a carrier’s death however it happens, because a sandbox cannot continue without one of its processors. A carrier can die without the endovisor’s involvement: the operator kills it, or Linux’s out-of-memory killer does. Losing a carrier is losing a processor with whatever the kernelet was doing on it. Linux offers a clean way to hear of it.

Linux: descriptors and release. When a task exits, Linux closes its open files, and the last close of a file runs the file’s release operation. Every process has a table of open file descriptors, and a task’s exit closes them all, whatever the reason for the exit. A module can make an open file of its own with anon_inode_getfile(), with whatever operations it likes.

So each carrier holds, in its descriptor table, one open file of the endovisor’s that nothing ever reads or writes: its lifeline. Its release is the death notice.

Simplified from prototype/linux/endovisor/carrier.c and prototype/linux/endovisor/main.c: klet_carrier_attach, klet_lifeline_release.

int klet_carrier_attach(struct klet_carrier *c)
{
	struct file *file = anon_inode_getfile("[kernelet-carrier]", &klet_lifeline_fops, c, O_RDWR);
	int fd = get_unused_fd_flags(0);
	fd_install(fd, file);                       /* into this task's own table, which the exec made sure no other task shares */

	current->carrier_hooks = &c->hooks;         /* attach the hooks ... */
	set_syscall_work(KERNELET);                 /* ... and ask for the syscall hook */
	return 0;
}

static int klet_lifeline_release(struct inode *inode, struct file *file)
{
	klet_carrier_lifeline_closed(file->private_data);   /* the carrier is gone */
	return 0;
}

Only the carrier’s death can close its lifeline. It is installed after the exec, so no other task holds a copy of it: the exec made sure the child’s table is shared with no other task. A tenant cannot close it, because closing a descriptor is a Linux system call, and the syscall hook takes those. A copy taken later with pidfd_getfd, which needs CAP_SYS_PTRACE against a non-dumpable task, delays the notice until that copy closes.

A closed lifeline ends the sandbox, and the last one to close marks it exited. When a lifeline closes, the endovisor takes the carrier off its lists. If the sandbox was still running, whichever carrier’s lifeline it was, the sandbox is over: the endovisor marks the kernelet dying and ends every other carrier. When the last lifeline closes, no carrier is left and the sandbox has exited, which makes the sandbox descriptor readable and wakes the holder waiting for its exit record.

Recap. A carrier is an ordinary Linux user task that runs kernelet code and enters user mode for it. The holder forks one child per virtual CPU, and each child executes that virtual CPU’s exec descriptor, which the endovisor’s binary-format handler claims: it checks the child’s control group and its no-new-privileges bit, gives it an empty address space, ignores every signal that can be ignored, makes it non-dumpable, names it, and attaches the hooks and a lifeline. The child keeps what it set on itself before the exec, by the ordinary rules of exec. On its way out the resume hook holds it until every carrier has arrived, then enters the kernelet. Every carrier holds a lifeline, an open file whose release tells the endovisor that the carrier has died.

Two stacks

Chapter 3 drew a picture: the kernel proper’s handler for a user program is a loop inside a function, and its local variables live on a kernel stack across every trip to user mode. This chapter is about why Linux cannot give a carrier such a stack, and how the endovisor gives it one anyway.

Two kernels, two opinions about the stack

Linux: a task’s kernel stack. A Linux task’s kernel stack, 16 KiB on x86-64, is empty whenever the task is in user mode. The stack is used whenever the task runs in kernel mode, and it is empty while the task is in user mode: a system call builds a few frames on it, and returning to user mode discards them. Linux’s exit path relies on this; nothing in Linux can be “in the middle of a function” while its task runs user code. The saved user registers, pt_regs, sit at the very top of the stack and are the one thing that survives.

The kernel proper is built the other way around. Its execute call must return when the program makes a system call, into a function whose frame was there before the program ran. If kernelet code used the carrier’s Linux stack, that frame would be discarded the moment the carrier returned to user mode.

The resolution is to give kernelet code stacks of its own. A kernelet stack is a block of kernel memory that the endovisor allocates and that the kernelet’s tasks run on. The carrier switches onto a kernelet stack to run kernelet code and back onto its Linux stack to let Linux return to user mode. Linux’s stack is empty exactly when Linux expects it to be, and the kernelet’s stack holds the suspended loop exactly as the kernel proper expects.

flowchart TB
    subgraph L[" "]
        direction TB
        l0["<b>the carrier's Linux stack, 16 KiB</b>"] ~~~ l1["pt_regs: the user registers"] --> l2["entry code frames"] --> l3["the hook's frame"] --> l4["klet_enter, parked in klet_switch"]
    end
    subgraph B[" "]
        direction TB
        b0["<b>the virtual CPU's first kernelet stack</b>"] ~~~ b1["kernelet_entry"] --> b2["main"] --> b3["Task::run: switched away,<br/>never resumed"]
    end
    subgraph K[" "]
        direction TB
        k0["<b>the task's kernelet stack, 256 KiB</b>"] ~~~ k1["user_task: the loop"] --> k2["execute()"] --> k3["user_run: parked in klet_switch"]
    end
    L -- "klet_switch: into the kernelet" --> B
    B -. "the kernelet's own task switch" .-> K
    K -- "klet_switch: back to Linux" --> L

Linux tolerates one task with two stacks better than one might fear:

  • Linux’s scheduler saves and restores only the stack pointer when it switches tasks, so a carrier can be preempted while on a kernelet stack and resumed there.
  • Linux finds the current task through a per-processor pointer, not through the stack.
  • An interrupt that arrives while the carrier is on a kernelet stack pushes its frame there and then moves to Linux’s per-processor interrupt stack, as it would from any kernel stack.
  • pt_regs are found from the top of the Linux stack, which does not move.

What Linux does not tolerate is kernelet code calling into Linux while on a kernelet stack, for any length of time. Linux’s code assumes its own stack: its unwinder and backtraces, its checks that a buffer lies on the task’s stack, and its idea of how much room is left. So the rule is: kernelet code runs on a kernelet stack, and nothing the kernelet or the endovisor calls of Linux runs there; only Linux’s own interrupt and exception entry, and preemption on their way out, use it, and the endovisor’s own code runs there only in the few lines that switch.

The switch

The switch is a few lines of assembly, the oldest trick in the book of coroutines: save the registers the calling convention makes the callee responsible for, swap the stack pointer, restore the other side’s registers, and return. Because ret pops from the new stack, the function returns into whatever code was suspended there.

Simplified from prototype/linux/endovisor/switch.S: klet_switch.

/* void klet_switch(void **save_sp, void *new_sp) */
klet_switch:
	pushq	%rbp
	pushq	%rbx
	pushq	%r12
	pushq	%r13
	pushq	%r14
	pushq	%r15
	movq	%rsp, (%rdi)      /* remember where this stack stopped */
	movq	%rsi, %rsp        /* and continue on the other one */
	popq	%r15
	popq	%r14
	popq	%r13
	popq	%r12
	popq	%rbx
	popq	%rbp
	RET

The six pushed registers are the ones the x86-64 calling convention says a called function must preserve; the others may be clobbered by any call, so the compiler never expects them to survive one. To the compiler, klet_switch is an ordinary function that happens to return much later, on another stack.

A fresh kernelet stack has to look as if klet_switch had already been called on it once, so that the first switch “returns” into the right place. The endovisor plants that frame by hand:

Simplified from prototype/linux/endovisor/carrier.c: klet_coro_prepare.

static void klet_coro_prepare(struct klet_carrier *c)
{
	u64 *sp = (u64 *)((char *)c->stack + KLET_STACK_SIZE);   /* the top of the stack */

	*--sp = 0;                              /* a fake return address */
	*--sp = (u64)klet_coro_trampoline;      /* what klet_switch's `ret` will jump to */
	*--sp = 0;                              /* rbp */
	*--sp = 0;                              /* rbx */
	*--sp = (u64)c;                         /* r12: the carrier, for the trampoline */
	*--sp = 0;                              /* r13 */
	*--sp = 0;                              /* r14 */
	*--sp = 0;                              /* r15 */
	c->coro_sp = sp;
}

The first klet_switch onto a new stack lands in the kernelet’s entry point, through a trampoline. The trampoline moves the carrier pointer from r12 into the first-argument register and calls klet_coro_main, which for virtual CPU 0 is the call to kernelet_entry from chapter 4, on a stack the kernelet owns.

The endovisor wraps the switch in two functions, one for each direction:

Simplified from prototype/linux/endovisor/carrier.c: klet_enter, klet_leave.

/* On the Linux stack: give control to the kernelet. */
static void klet_enter(struct klet_carrier *c, u64 run_ret)
{
	c->run_ret = run_ret;                   /* what user_run will return, if that is where it stopped */
	c->state = KLET_RUNNING;
	c->depth = 0;
	klet_mirror_in(c);                      /* note Linux's preemption count, for the mirror built later */
	klet_watch_arm(c);                      /* the watch timer on this processor, built later */
	klet_switch(&c->linux_sp, c->coro_sp);  /* returns when the kernelet leaves */
}

/* On a kernelet stack: give control back to Linux. */
static void klet_leave(struct klet_carrier *c, enum klet_cstate state)
{
	klet_mirror_out(c, state == KLET_DEAD); /* what the kernelet lent Linux's preemption count, taken back */
	c->state = state;
	klet_switch(&c->coro_sp, c->linux_sp);  /* returns when the kernelet is next entered */
}

klet_enter is called from exactly two places, the two hooks: the resume hook, which also makes a new carrier’s first entry on its way out of the exec (chapter 6), and the syscall hook. Each of those is a frame on the Linux stack, and klet_switch parks that frame until the kernelet leaves.

Where the stacks come from

Linux: vmalloc. Linux has two kinds of kernel memory: the direct map, physically contiguous, from kmalloc and the page allocator; and the vmalloc area, where the kernel builds virtually contiguous ranges out of scattered pages with vmalloc(). Each range is followed by an unmapped guard page, so running off the end of one faults instead of silently overwriting its neighbor.

A kernelet stack is 256 KiB from vmalloc, with an unmapped page before and after it, since every vmalloc range is followed by one.

What the guard page buys is less than it seems. A stack that runs off its end faults with a stack pointer that is already bad; delivering a fault pushes a frame onto the very stack that has run out, so the processor cannot deliver it, and Linux halts the machine on the double fault that results. A check at every function entry of the image would catch it before the end; this prototype has no such check, so a kernel proper with unbounded recursion would halt the host, and the test kernels are written not to recurse.

The endovisor keeps a pool of stacks per sandbox: a freed stack goes back to the pool, not to Linux, and the pool is returned whole when the sandbox’s last carrier is gone. The reason is a Linux detail a tenant could otherwise turn against the machine: freeing vmalloc memory is batched, and the batch ends in a TLB flush on every processor, so a kernel that created and ended tasks in a loop would tax processors it is not allowed to run on.

Simplified from prototype/linux/endovisor/carrier.c: klet_kstack_alloc, klet_kstack_put.

u64 klet_kstack_alloc(struct klet_inst *inst)
{
	list_for_each_entry(k, &inst->kstack_all, link)     /* a free one in the pool? */
		if (!k->busy) { k->busy = true; return (u64)k->base; }

	if (inst->kstack_made >= inst->kstack_max)          /* the pool is bounded: max_tasks + nr_vcpus stacks, from KLET_CREATE */
		return 0;                                       /* vOSTD turns 0 into an out-of-memory error */
	inst->kstack_made++;
	base = __vmalloc(KLET_STACK_SIZE, GFP_KERNEL_ACCOUNT);   /* else grow the pool; charged to the sandbox (chapter 6) */
	k = kzalloc(sizeof(*k), GFP_KERNEL);
	k->base = base; k->busy = true;
	list_add_tail(&k->link, &inst->kstack_all);
	return (u64)base;
}

void klet_kstack_put(struct klet_inst *inst, u64 base)
{
	list_for_each_entry(k, &inst->kstack_all, link)
		if ((u64)k->base == base) { k->busy = false; return; }
}

The real function holds the instance’s lock around the search and the reservation, so that two virtual CPUs cannot both take the last stack.

Every kernelet stack comes from this pool: the carrier’s first one and each task’s. The kernelet reaches the pool through two services, kstack_alloc and kstack_free. The carrier’s own first stack, the one klet_coro_prepare sets up, comes from the pool; so does the stack of every task the kernel proper creates.

Services run on the Linux stack

A crossing, in this book, is one service call: from kernelet code into the endovisor and back, switching stacks twice. A service call is kernelet code calling the endovisor, and by the rule above it must switch stacks. It cannot use klet_enter and klet_leave, which hand control over; it needs to call a function and come back. The endovisor has a second, smaller switch for that:

Simplified from prototype/linux/endovisor/switch.S: klet_run_on_linux_stack.

/* void klet_run_on_linux_stack(void *linux_sp, struct klet_svc_args *args) */
klet_run_on_linux_stack:
	pushq	%rbp
	movq	%rsp, %rbp            /* remember the kernelet stack */
	movq	%rdi, %rsp            /* jump onto the Linux stack, below the parked frame */
	andq	$-16, %rsp
	movq	%rsi, %rdi
	call	klet_svc_body         /* the service, on Linux's stack */
	movq	%rbp, %rsp            /* and back */
	popq	%rbp
	RET

A service runs on the Linux stack just below the frame klet_enter parked. linux_sp is where klet_enter’s klet_switch parked the Linux stack, that is, the lowest byte of it in use; everything below is free, and the service runs there.

Every service in the table except one, user_run, goes through a stub: a stub packs the arguments into a small structure, calls klet_run_on_linux_stack, and unpacks the result.

Simplified from prototype/linux/endovisor/carrier.c: klet_svc_enter, klet_svc_log_write, klet_svc_body.

static u64 klet_svc_enter(u64 op, u64 a0, u64 a1, u64 a2)
{
	struct klet_carrier *c = klet_current_carrier();
	struct klet_svc_args args = { .op = op, .a0 = a0, .a1 = a1, .a2 = a2 };

	klet_run_on_linux_stack(c->linux_sp, &args);
	return args.ret;
}

static void klet_svc_log_write(const char *ptr, u64 len)     /* what the service table points at */
{
	klet_svc_enter(KLET_SVC_LOG_WRITE, (u64)ptr, len, 0);
}

void klet_svc_body(struct klet_svc_args *args)                /* runs on the Linux stack */
{
	struct klet_carrier *c = klet_current_carrier();

	c->depth++;                      /* "inside the host": the timer and the sweep read this */
	switch (args->op) {
	case KLET_SVC_LOG_WRITE:
		klet_do_out_write(c->inst, &c->inst->log, (const char *)args->a0, args->a1);   /* to the holder's log file, through a ring */
		break;
	case KLET_SVC_MEM_ALLOC:
		args->ret = (u64)klet_do_mem_alloc(c->inst, args->a0, args->a1);
		break;
	/* ... every other service ... */
	}
	c->depth--;
}

The endovisor finds the calling carrier from Linux’s current task and the hooks pointer in it: klet_current_carrier() is container_of(current->carrier_hooks, struct klet_carrier, hooks) (given a pointer to a field, container_of subtracts the field’s offset to get the enclosing structure). No service takes a “which kernelet” argument, so a kernelet cannot claim to be another.

The depth counter is 1 while a service body runs on the kernelet’s behalf, when the carrier may hold Linux’s locks. The endovisor’s per-processor timer and the sweep that takes a processor back from a stopped sandbox, both built later, read it, with the carrier’s state, before touching a carrier’s instruction pointer.

Two services do not simply return: user_run, which leaves the kernelet stack and does not come back until the next hook brings the carrier in, as the next chapter shows; and stop, which uses the stub like any service and then leaves forever.

Recap. Linux empties a task’s kernel stack on every return to user mode; the kernel proper keeps a loop’s frame alive across them. So kernelet code runs on kernelet stacks, 256 KiB each, from a per-sandbox pool over vmalloc, and the carrier switches between them and its Linux stack with klet_switch, a six-register coroutine switch. klet_enter hands control to the kernelet and klet_leave hands it back; every service call hops onto the Linux stack with klet_run_on_linux_stack, runs klet_svc_body there with the depth counter raised, and hops back.

Into user mode and back

Everything is now in place for the hundred-line kernel to run its program. The kernel proper’s task calls execute, which in vOSTD is the service user_run. This chapter follows that call out of the kernelet, through Linux, into user mode, and back in again on the program’s first system call. At the end the kernel prints Hello, world.

Leaving: the user_run service

UserContext::execute in vOSTD, which UserMode::execute calls, keeps OSTD’s signature and loop: where OSTD enters user mode with its own assembly and decodes the trap number, vOSTD calls user_run with a pointer to the context, and interprets the number it returns.

Simplified from prototype/vostd/src/arch/cpu/context.rs: execute.

fn execute<T: UserModeHooks>(&mut self, hooks: &T) -> ReturnReason {
    self.user_context.general.rflags |= RFLAGS_IF | RFLAGS_ID;
    loop {
        match self.user_context.run() {         // the user_run service
            0 => return ReturnReason::UserSyscall,
            1 => {
                self.exception = CpuException::new(trap_num, error_code, fault_addr);
                return ReturnReason::UserException;
            }
            _ => {                              // 2: something to look at, then try again
                crate::vcpu::deliver_no_switch();
                if hooks.has_kernel_event() { return ReturnReason::KernelEvent; }
            }
        }
    }
}

So user_run returns 0 for a system call, 1 for an exception and 2 for “an event happened; the kernel may want to know, then call me again”. The third case is how a virtual interrupt reaches the kernel while its program runs.

The context it is given is a RawUserContext: the eighteen registers of chapter 3, in a fixed order, followed by three fields for exceptions.

On the endovisor’s side, user_run is the one service that does not use the stub of the last chapter. It records the context, switches to the Linux stack, and does not come back until a hook brings the carrier into the kernelet again:

Simplified from prototype/linux/endovisor/carrier.c: klet_svc_user_run.

static u64 klet_svc_user_run(struct klet_user_ctx *ctx)
{
	struct klet_carrier *c = klet_current_carrier();

	c->ctx = ctx;                               /* where the registers are, and where to put them later */
	WRITE_ONCE(c->state, KLET_USER);            /* "in, or on the way to, user mode" */
	smp_mb();                                   /* the state is published before the pending word is read: see virtual interrupts */
	if (atomic_read(&c->vrec.pending)) {        /* a virtual interrupt arrived meanwhile */
		WRITE_ONCE(c->state, KLET_RUNNING);
		return KLET_RUN_EVENT;
	}
	klet_mirror_out(c, false);                  /* give Linux its preemption count back: the mirror, built later */
	klet_switch(&c->coro_sp, c->linux_sp);      /* to the Linux stack, inside whichever hook entered us */
	return c->run_ret;                          /* ... much later, with the reason the hook recorded */
}

The switch lands inside the klet_enter that last entered the kernelet on this carrier, which returns into its caller. That caller is always one of the two hooks: on the very first trip it is the resume hook, running on the carrier’s way out of the execveat that made it (chapter 6), and on every later trip it is whichever hook brought the carrier in.

Putting the registers where Linux will find them

Whoever klet_enter returns into now has one job: copy the context into the carrier’s pt_regs, so that when Linux returns to user mode, it returns to the tenant’s program.

Simplified from prototype/linux/endovisor/carrier.c: klet_ctx_to_regs.

static void klet_ctx_to_regs(struct klet_carrier *c, struct pt_regs *regs)
{
	struct klet_user_ctx *x = c->ctx;

	regs->ax = x->rax; regs->bx = x->rbx; regs->cx = x->rcx; regs->dx = x->rdx;
	regs->si = x->rsi; regs->di = x->rdi; regs->bp = x->rbp; regs->sp = x->rsp;
	regs->r8 = x->r8;  /* ... through r15 ... */
	regs->ip = x->rip;
	regs->flags = (x->rflags & KLET_RFLAGS_USER) | X86_EFLAGS_IF | X86_EFLAGS_FIXED;
	regs->cs = __USER_CS;
	regs->ss = __USER_DS;
	regs->orig_ax = -1;      /* no system call in progress: Linux must not try to restart one */
}

Three details keep this safe. The flags are sanitized: the interrupt-enable bit is forced on and the privilege-level bits are cleared, so a tenant cannot talk its kernel into giving it a program that runs with interrupts off. The code and stack segment selectors are fixed to Linux’s user segments. And orig_ax, Linux’s record of which system call is in progress, is set to -1, which switches off Linux’s system-call restart logic; restarting is the kernel proper’s business.

The first trip needs nothing special. The carrier arrived by a system call, so the registers Linux will restore are the pt_regs of that call, and the resume hook overwrites them as it will on every later trip; the registers the exec had prepared for the carrier’s own program, which does not exist, are simply never used.

Coming back by a system call

The tenant runs, and executes syscall. Linux saves its registers in pt_regs, finds the task’s hooks, and calls the syscall hook:

Simplified from prototype/linux/endovisor/carrier.c: klet_on_syscall, klet_regs_to_ctx.

static bool klet_on_syscall(struct pt_regs *regs)
{
	struct klet_carrier *c = klet_current_carrier();

	if (c->state != KLET_USER || !c->ctx)
		return false;                           /* not a carrier in user mode: not ours */
	if (regs->cs != __USER_CS || in_compat_syscall()) {
		/* a 32-bit entry instruction: report roughly what a machine would raise, a #GP (general-protection fault) */
		c->exc_trap = X86_TRAP_GP; c->exc_pending = true;
		set_thread_flag(TIF_NOTIFY_RESUME);
		return true;
	}

	klet_regs_to_ctx(c, regs);                  /* pt_regs -> the kernelet's context */
	c->user_live = false;                       /* the context is now the authority on the registers */
	regs->orig_ax = -1;
	klet_enter(c, KLET_RUN_SYSCALL);            /* run the kernelet: user_run returns 0 there */

	/* ... the kernelet handled the call and called user_run again ... */
	if (c->state == KLET_USER) {
		klet_space_adopt(c, c->vrec.space);     /* the right address space: built with tenant memory */
		klet_ctx_to_regs(c, regs);              /* its new registers */
		c->user_live = true;                    /* from here on pt_regs is the authority, until the next hook */
	} else {
		klet_carrier_die(c);                    /* the kernelet stopped instead */
	}
	return true;                                /* Linux: do not dispatch this call */
}

static void klet_regs_to_ctx(struct klet_carrier *c, struct pt_regs *regs)
{
	x->rax = regs->orig_ax;                     /* the call number as issued; ax itself holds -ENOSYS by now */
	x->rbx = regs->bx; /* ... every register ... */ x->rip = regs->ip; x->rflags = regs->flags;
	x->trap_num = 0; x->error_code = 0; x->fault_addr = 0;
}

The klet_enter in the middle of the hook runs the kernel proper’s whole handling of the call. klet_enter switches to the kernelet stack, where user_run is waiting inside klet_switch; user_run returns 0; execute returns UserSyscall; the kernel proper’s loop handles the call and calls execute again; user_run switches back to the Linux stack, which lands here, just after klet_enter. Then the hook copies the kernelet’s new registers out, returns true, and Linux returns to user mode with them, skipping its own system-call table entirely.

The kernel proper sees exactly the registers it would see on a machine. The syscall instruction itself overwrites two registers on the way in: it puts the return address in rcx and the flags in r11. pt_regs holds them as the instruction left them, and the call number is in orig_ax, where Linux’s entry code put it.

sequenceDiagram
    participant U as tenant
    participant L as Linux
    participant E as endovisor
    participant K as kernelet
    U->>L: syscall
    L->>E: hooks->on_syscall(regs)
    E->>E: pt_regs -> ctx
    E->>K: klet_enter
    Note over K: user_run returns 0
    K->>K: handle_syscall
    K->>E: user_run again
    E->>E: ctx -> pt_regs
    E->>L: true: skip the table
    L->>U: return to user mode

Note what the sequence does not say: that the task which comes back is the one that left. Between the two switches the kernelet’s own scheduler may have run another task, and then it is that task’s execute that leaves, with another thread’s registers. Nothing in Linux’s exit path minds, because everything Linux will restore from the stack is in pt_regs, and the hook overwrites all of it.

The thread-pointer base is not in pt_regs, so in the prototype every tenant thread on a carrier shares the carrier’s. A program keeps a pointer to its thread’s private variables in a segment register (%fs on x86-64), and Linux saves that register’s base per task, in its own record, not on the stack. The prototype’s context carries no such base; a full vOSTD would carry one per thread and have the endovisor set it on each trip.

Coming back by an exception

A tenant’s fault must reach the kernel proper as execute returning UserException, with the trap number and, for a page fault, the address. A tenant’s instruction can fault: divide by zero, an illegal instruction, a touch of an address with no mapping. On a machine, OSTD’s trap handler would fill those in from the hardware.

Linux: exceptions become signals. When a user instruction faults, Linux’s exception handler records the trap number, error code and address in the task’s thread structure, queues a signal on the task (SIGSEGV, SIGILL, SIGFPE, and so on) with force_sig(), and heads for user mode. For a fault, Linux’s handlers queue the signal with force_sig_fault(), whose si_code says what kind of fault it was.

Signals are acted on in the exit loop: Linux either kills or stops the task or arranges for a handler in the program to run. Two signals cannot be caught or blocked: SIGKILL and SIGSTOP.

The endovisor takes the signals off the queue itself, in the resume hook, which runs at the top of that loop before Linux delivers anything:

Simplified from prototype/linux/endovisor/carrier.c: klet_drain_signals.

static bool klet_drain_signals(struct klet_carrier *c)
{
	sigset_t mask = current->blocked;
	sigaddset(&mask, SIGKILL);                  /* the two signals nobody may take are left in the queue, */
	sigaddset(&mask, SIGSTOP);
	/* ... and so is any signal Linux has made fatal and immutable (SA_IMMUTABLE): a seccomp kill's SIGSYS */

	spin_lock_irq(&current->sighand->siglock);
	while ((signr = dequeue_signal(&mask, &info, &type)) != 0) {
		spin_unlock_irq(&current->sighand->siglock);
		if (!sig_kernel_only(signr))            /* Linux forced it, un-ignoring it on the way: ignore it again */
			kernel_sigaction(signr, SIG_IGN);   /* (which takes the lock itself) */
		if (klet_is_fault_signal(signr, info.si_code) && !exception) {   /* raised by a trap handler for this task's own fault */
			c->exc_trap = current->thread.trap_nr;
			c->exc_error = current->thread.error_code;
			c->exc_addr = (unsigned long)info.si_addr;
			c->exc_pending = true;
			exception = true;
		}
		/* anything else Linux forced (a seccomp trap's SIGSYS) is dropped */
		spin_lock_irq(&current->sighand->siglock);
	}
	spin_unlock_irq(&current->sighand->siglock);
	return exception;
}

A fault signal is recognized by its number and its si_code together. The number is one of SIGSEGV, SIGBUS, SIGILL, SIGFPE and SIGTRAP, and the code is one a trap handler uses: positive, and below SI_KERNEL, the code Linux puts on signals it sends on its own account. Four more signals are accepted with SI_KERNEL, because some of x86’s trap handlers have no finer code to give and send SI_KERNEL themselves (arch/x86/kernel/traps.c): SIGSEGV for a general-protection fault, an invalid task-state segment, an overflow trap (int 4), a bounds-check fault from 32-bit code, and the fault a processor raises on a disabled extended-state feature; SIGBUS for a segment-not-present and a stack fault; SIGTRAP for a breakpoint (int3); and SIGFPE for the coprocessor segment overrun that no current processor raises. In the extended-state case the trap number Linux left in the thread structure is stale, and the kernel proper sees whatever the last exception was. No process can forge that code, since kill sends SI_USER.

Every other signal the drain finds is discarded, with one exception left for Linux. A signal sent by a process is dropped: on the host side that is a process allowed to signal the carrier, since a tenant’s kill is a system call and the syscall hook takes those. So is a signal Linux sends for a reason of its own, once a fault has reset its action. One kind is left alone: a signal Linux has made fatal and marked immutable, which is what a seccomp filter’s kill action does to its SIGSYS; the drain leaves it in the queue, Linux delivers it on the same pass, and the carrier’s death ends the sandbox.

SIGKILL and SIGSTOP are left in the queue. The first is how the operator and the endovisor end a carrier, and Linux acts on it in the same loop. The second takes effect at the carrier’s next return to user mode, where Linux stops it as it stops any task; a stopped carrier is a stopped virtual CPU until SIGCONT, and a virtual CPU that does not return to user mode, because it is idle or runs only kernel tasks, keeps running with the stop pending.

Most signals never reach the queue at all, and that is arranged earlier. The binary-format handler of chapter 6 sets every signal but SIGKILL and SIGSTOP, which cannot be, to be ignored, and clears the signal mask, in every carrier, since each arrives by an exec of its own. An ignored signal is discarded the moment it is sent, so an operator’s SIGTERM, SIGQUIT or SIGTSTP to a carrier does nothing.

The drain alone would not do, for two reasons. A signal whose default action is to end the task without a core dump, SIGTERM, SIGINT, SIGPIPE and their kind, is fatal at the moment it is sent: the sender sees that nothing will catch it and turns it into a SIGKILL on the spot (complete_signal()), before any drain. And the drain runs at the top of one pass of the exit loop, while Linux delivers signals later in the same pass if one was pending when the pass began, so a SIGQUIT that arrived between the two would dump core and end the carrier.

The fault signals are ignored too, and still arrive. Linux sends a fault with force_sig_info_to_task(), which first sets an ignored signal back to its default action, so the signal is queued and the drain sees it. The drain then ignores it again, so that the next fault is forced the same way and a SIGSEGV a process sends in between is discarded.

The resume hook brings the exception into the kernelet:

Simplified from prototype/linux/endovisor/carrier.c: klet_on_resume.

static void klet_on_resume(struct pt_regs *regs)
{
	struct klet_carrier *c = klet_current_carrier();

	for (;;) {
		if (c->state == KLET_DEAD || c->inst->stopped || c->inst->dying) {
			klet_carrier_die(c);                                 /* stopping a kernelet, later */
			return;
		}
		if (c->state == KLET_NEW) {                              /* the first time, out of the exec: wait for the sandbox's start, then enter (chapter 6) */
			if (!klet_vcpu_await(c)) { klet_carrier_die(c); return; }
			klet_enter(c, KLET_RUN_EVENT);
			continue;
		}
		if (c->state == KLET_USER && !c->exc_pending && klet_drain_signals(c) && c->user_live) {
			klet_regs_snapshot(c, regs);                         /* the faulting registers, into ctx */
			c->user_live = false;                                /* ctx is the authority again */
		}
		if (c->exc_pending) {                                    /* from a signal, or from the endovisor's own fault handler, later */
			c->exc_pending = false;
			if (c->state == KLET_USER && c->user_live) {
				klet_regs_snapshot(c, regs);                     /* same: ctx must hold the registers of the faulting instruction */
				c->user_live = false;
			}
			klet_ctx_set_exception(c);                           /* trap, error code, address -> ctx */
			klet_enter(c, KLET_RUN_EXCEPTION);                   /* user_run returns 1 */
			continue;
		}
		if (atomic_read(&c->vrec.pending)) {                     /* a virtual interrupt: the next chapter but one */
			if (c->state == KLET_USER && c->user_live) {
				klet_regs_snapshot(c, regs);                     /* the interrupted registers, into ctx */
				c->user_live = false;
			}
			klet_enter(c, KLET_RUN_EVENT);                       /* user_run returns 2 */
			continue;
		}
		if (c->state == KLET_USER && c->user_live)               /* nothing for the kernelet, and the tenant's registers */
			return;                                              /* are live in pt_regs: work of Linux's own brought us here */
		/* KLET_USER with a context the kernelet has just handed over: it wants user mode */
		klet_space_adopt(c, c->vrec.space);
		klet_ctx_to_regs(c, regs);
		c->user_live = true;
		return;
	}
}

The loop shape matters: each klet_enter returns when the kernelet calls user_run again, and the hook then goes around once more to see whether anything else is pending before it finally applies the registers and returns to Linux.

Before an exception is reported, by either route, the hook copies the current pt_regs into the context, so that the kernel proper sees the faulting instruction’s registers. The copy, klet_regs_snapshot, is needed because the context still holds the registers of the previous trip. user_live says which of the two copies is the authority: it is set when the context is applied to pt_regs, and cleared when pt_regs is copied back into the context, by the syscall hook or by a snapshot; so the snapshot is taken exactly once per trip, and never over registers the kernelet has already seen.

The same flag keeps the hook from rewinding a tenant when it runs for Linux’s own work. The exit loop runs the hook for work of Linux’s own as well, a reschedule, a signal the drain dropped, a stop and a continue, and then nothing is pending for the kernelet and the registers in pt_regs are the tenant’s live ones. Applying the context over them would send the tenant back to the return of its last system call, a rewind of everything it had done since, so the hook leaves them alone and returns. The hook therefore runs for every kind of exit work, not only the kernelet’s, and is written to find nothing of its own to do.

One of the memory tests, make mem-kill, runs a tenant that keeps a count in a register and the same count in memory and reports the first moment they part, while it is preempted, signaled, and stopped and continued.

Page faults on tenant memory take a shorter path through the endovisor’s own fault handler, which sets exc_pending directly, as the chapter on tenant memory shows. What the kernel proper sees is the same in both cases: execute returns UserException, with a CpuException::PageFault carrying the address.

Hello, world

With chapters 4 to 8 built, make hello boots Linux in QEMU, loads the module, runs the holder, and this appears in the serial log (the run is prototype/.build/serial-hello.log; one line of Linux’s own about an out-of-tree module, the lines of a self-test of the interface that the test runs first, and the counters printed at unload are left out):

INIT: loading endovisor.ko
kernelet: endovisor loaded: /dev/endovisor, API 2, image ABI 6, watch on, mirror on, fpu area 1088 B, tsc 3791992 kHz
INIT: running /tests/10-hello
RUN-TEST: running /holder register hello /hello.image
kernelet: PIE image loaded: 155648 bytes, 94208 shared, 61440 private per instance, 358 relative relocations
kernelet: kind 0 "hello" registered: an image of 144984 bytes, PIE text offsets [0x0, 0xec05), 94208 bytes shared
holder: kind 0 registered: hello from /hello.image
RUN-TEST: /holder register hello /hello.image exited 0
RUN-TEST: running /holder run -c /sys/fs/cgroup/sandbox --console /hello.console --log /hello.log hello 1
kernelet: sandbox 0 created: kind 0 "hello", 1 vcpu(s), memory 4096 KiB (max 65536 KiB), max_tasks 256, cmdline ""
holder: sandbox 0 created: kind hello, 1 vcpu(s), cmdline ""
kernelet: sandbox 0: vcpu 0 carrier (pid 50) arrived as kernelet/0/0: address space emptied, every signal ignored, dumpable 1 -> 0
kernelet: sandbox 0: PIE mapping [0xffffac31c009b000, 0xffffac31c00c1000), shared 94208 bytes, private 61440 bytes
kernelet: sandbox 0: host offsets: gs:current 0x1fac0, gs:preempt_count 0x1fac8, task->carrier_hooks +1968, hooks->vrec +16
kernelet: sandbox 0 started: kind "hello" loaded as its instance (61440 bytes of private data), grant 4096 KiB at 0xc00000, charged to its control group
kernelet: sandbox 0: every virtual CPU's carrier has arrived (1); booting the kernelet
kernelet: service stub: 71 cycles through the stack switch, 42 for the service body alone (least of 7 rounds of 2000, rdtsc, interrupts off)
kernelet: space 1 created: root 0x7c9000, area [0x10000, 0x7ffffffff000), its own inode, mm owner a task
kernelet: stopped, exit code 0
kernelet: arming the eviction sweep every 1000 us on 2 cpu(s)
kernelet: sandbox 0: vcpu 0 carrier (pid 50) dying
kernelet: sandbox 0: vcpu 0 carrier (pid 50) lifeline closed, the last one
kernelet: sandbox 0 is gone: 7 heap block(s) the kernelet still held freed (752 bytes), 2 kernelet stack(s) returned to vmalloc (1 still in use)
kernelet: sandbox 0 exited: the kernel proper powered off, exit code 0
holder: sandbox 0 at its end: state 5 (exited), 1 vcpu(s), 4096 KiB granted, 14022 service call(s), 0 virtual interrupt(s), 0 line(s) dropped
holder: sandbox 0 at its end: vcpu 0 pid 50 state 3 (gone), ran 34 ms, waited 0 ms runnable
kernelet: sandbox 0 destroyed
holder: sandbox 0 exited: the kernel proper powered off, exit code 0
holder: no carrier left (module references: 0)
RUN-TEST: /holder run -c /sys/fs/cgroup/sandbox --console /hello.console --log /hello.log hello 1 exited 0
RUN-TEST: /hello.console: Hello, world
RUN-TEST: /hello.console: 
RUN-TEST: /hello.console: 2 line(s)
RUN-TEST: /hello.log: vOSTD up: grant 0xc00000..0x1000000, linear map 0xffff93d1c0000000, 1 vCPU(s), tsc 3791992 kHz, fpu area 1088 B
RUN-TEST: /hello.log: vOSTD: heap: a 64-byte allocation and its free, 748 cycles the pair (least of 7 rounds of 1000, rdtsc)
RUN-TEST: /hello.log: 2 line(s)
RUN-TEST: done
INIT: /tests/10-hello exited 0
INIT: unloading endovisor
kernelet: endovisor unloaded
INIT: done

The log follows one sandbox from registration to destruction. The holder registers the image as a kind, and the endovisor validates its PIE layout and retains its shared pages (chapter 2); the holder creates the sandbox with a console file and a log file, and forks and execs its one carrier, which the handler turns into one (chapter 6); the endovisor maps the shared code with private relocated data, publishes the offsets by which vOSTD will find its way around Linux, and allocates the grant (chapters 2 and 4), and, every carrier having arrived, boots the kernelet, which enters the image and, before anything else, times a heap allocation and its free and then, at its first log line, a service call that does nothing (the numbers are the subject of the last chapter); the kernel proper creates its address space; the program’s write arrives by the syscall hook and the kernel proper prints the message through console_write to the console file, followed by the message’s own newline as an empty line; its exit becomes the stop service, which arms the sweep that takes a processor back from a virtual CPU left spinning; the carrier dies and its lifeline closes, and the last release returns what the kernelet held, the stack the stopping carrier was still standing on included; the holder reads the sandbox’s statistics and closes its descriptor, releasing the remaining resources; the test prints the two files; the module’s reference count reaches zero and it unloads.

The build checks that the console file holds the greeting, that the serial log has the exit code, a clean unload, and no Linux error report.

Recap. execute is the service user_run, which records the context, marks the carrier as in user mode, and switches to the Linux stack inside whatever hook last entered the kernelet. That hook copies the context into pt_regs and returns, and Linux returns to user mode. A system call brings the carrier back through the syscall hook, which copies pt_regs into the context and enters the kernelet, where user_run returns 0. An exception becomes a signal, which the resume hook takes off the queue before Linux delivers it and reports as user_run returning 1. The hundred-line kernel now prints its greeting.

Tasks and virtual CPUs

This chapter gives the kernelet what a real kernel has: thousands of tasks and a scheduler that decides which runs where. The hundred-line kernel has one task. A real kernel has a thread per tenant process, kernel threads for its own work, and a scheduler over them all. The chapter is shorter than you might expect, because the kernelet brings its own.

Two levels

The design has two levels. Linux schedules a sandbox’s virtual CPUs, a handful per sandbox, and knows nothing else about it. On each virtual CPU, the kernelet’s own scheduler decides which kernelet task runs, for how long, and what preempts what.

flowchart TB
    subgraph K[" "]
        k0["<b>level 2: inside the sandbox, the kernelet's scheduler</b>"] ~~~ rq0
        k0 ~~~ rq1
        rq0["run queue of vCPU 0<br/>task A · task B · task C"] --> pick0["picks one"]
        rq1["run queue of vCPU 1<br/>task D · task E"] --> pick1["picks one"]
    end
    subgraph L[" "]
        l0["<b>level 1: Linux's scheduler</b>"] ~~~ c0
        l0 ~~~ c1
        l0 ~~~ o
        c0["carrier of vCPU 0<br/>(a Linux task)"]
        c1["carrier of vCPU 1<br/>(a Linux task)"]
        o["every other Linux task"]
    end
    pick0 ~~~ l0
    pick1 ~~~ l0
    pick0 -. runs on .-> c0
    pick1 -. runs on .-> c1

A virtual CPU is one of the processors a sandbox is given when it is created; a carrier carries one of them for the sandbox’s whole life. Whatever the virtual CPU does, that Linux task does it: it runs the kernelet’s tasks one after another, enters user mode for whichever tenant thread the kernelet picked, and sleeps in Linux when the virtual CPU has nothing to run. Which physical processor the carrier is on is Linux’s business and changes without notice; the virtual CPU moves with it.

The kernelet’s tasks are its own

vOSTD’s task layer is the real OSTD’s code: a task is a function, a stack and a saved register set; switching tasks saves one set of callee-saved registers and loads another; waiting is a matter of OSTD’s own wait queues. None of it involves Linux, which never sees a kernelet task at all. The two places where the machine shows through are the stack and the context switch, and both are familiar from chapter 7.

Simplified from prototype/vostd/src/task/mod.rs and prototype/vostd/src/task/kernel_stack.rs: build, KernelStack.

pub fn build(self) -> Result<Task> {
    let kstack = KernelStack::new_with_guard_page()?;          // the kstack_alloc service
    let mut ctx = TaskContext::new();
    ctx.set_instruction_pointer(kernel_task_entry_wrapper as usize);   // where the first switch lands
    ctx.set_stack_pointer(kstack.end_vaddr() - 16);
    Ok(Task { func: ..., data: ..., ctx, kstack, ... })
}

impl KernelStack {
    pub(super) fn new_with_guard_page() -> Result<Self> {
        let base = unsafe { (svc().kstack_alloc)() } as Vaddr;  // from the endovisor's pool
        if base == 0 { return Err(Error::NoMemory); }
        Ok(Self { base, size: KERNEL_STACK_SIZE })
    }
}

The context switch is OSTD’s switch.S, byte for byte: save eight words (the stack pointer, six callee-saved registers and the return address), load eight, return on the other stack. It is the same idea as the endovisor’s klet_switch, written on the Rust side, and it switches between two kernelet stacks, which is why the endovisor neither sees nor cares which of the kernelet’s tasks is current. The endovisor remembers only where the kernelet was when it last left, and resumes there.

The kernel proper chooses the policy. It implements OSTD’s Scheduler trait, a run queue per CPU with enqueue, pick_next and update_current, and registers it with inject_scheduler before it creates any task; OSTD calls those functions without knowing the type, which is how a scheduler written for a bare-metal kernel runs unchanged in a kernelet. If the kernel proper registers none, OSTD’s own first-in-first-out scheduler is used, which is what the hundred-line kernel gets.

A test shows that the policy is the kernelet’s and not Linux’s. The test kernel in prototype/kernels/sched/src/lib.rs injects a strict-priority scheduler with round-robin inside each level, a policy that Linux’s fair scheduler, under which every carrier runs, would never produce, and the test make sched-policy checks every one of its switches against a model of the policy.

The virtual CPU record

A carrier and the kernelet need a small amount of shared state per virtual CPU, and they need the kernelet to find it fast, since “which CPU am I on” is on the hottest path a kernel has. The virtual CPU record is that state: a C-layout structure the endovisor owns and both sides write.

Simplified from prototype/linux/endovisor/kernelet.h: klet_vcpu_rec.

struct klet_vcpu_rec {
	u32 vcpu_id;            /* which virtual CPU this is: CpuId::current() reads it */
	u32 nr_vcpus;
	atomic_t pending;       /* virtual interrupts raised and not yet taken: the next chapter */
	u32 preempt_info;       /* OSTD's preemption word: guard count and need-preempt bit */
	u32 irq_off;            /* the virtual interrupt flag, inverted */
	u32 mirrored;           /* the mirror of Linux's preemption count, two chapters on */
	u64 virq_ip;            /* where an upcall interrupted the kernelet: the next chapter */
	u64 space;              /* the tenant address space this vCPU has activated: see tenant memory */
	u64 stack_limit;        /* the bottom of the current task's kernelet stack */
	u64 stolen_ns;          /* how long Linux kept this vCPU off a processor */
};

The record’s place is what makes it fast to find: it is embedded in the endovisor’s per-carrier structure, directly after the table of hooks that task_struct points at.

Linux: per-processor data and %gs. Linux keeps a block of per-processor variables, and on x86-64 the segment register %gs points at the current processor’s block, so that reading one of them is a single mov with a %gs: prefix, whichever processor the code is on. Two of those variables matter here: the pointer to the current task, and the preemption count, the nesting depth of preempt_disable(), which Linux raises around code that must not be preempted. The block’s layout is Linux’s own, so a module learns the offsets at run time.

When a sandbox starts, the endovisor publishes the offsets that lead from %gs to the record. It computes the %gs-relative offset of the current-task pointer, checks it by reading the pointer back and comparing it with current, and publishes it in the boot arguments together with the offset of the hooks pointer inside task_struct, the offset of the record from the hooks, and the offset of Linux’s preemption count. vOSTD then finds its record with two dependent loads and no call:

Simplified from prototype/vostd/src/vcpu.rs: vrec.

pub(crate) fn vrec() -> &'static VcpuRec {
    let task: usize;
    unsafe { asm!("mov {t}, gs:[{o}]", t = out(reg) task, o = in(reg) GS_CURRENT_TASK) };
    let hooks = unsafe { core::ptr::read((task + TASK_HOOKS_OFF) as *const usize) };
    unsafe { &*((hooks + HOOKS_VREC_OFF) as *const VcpuRec) }
}

The two loads are right even if the carrier moves between them: whichever processor the carrier is on, %gs names that processor’s block, the block’s current task is this carrier, and this carrier’s hooks lead to this virtual CPU’s record.

Everything OSTD keeps per CPU, such as the scheduler’s run-queue locks and the timer callbacks, is in vOSTD an array with one slot per virtual CPU, indexed by the vcpu_id in the record. The one per-CPU word that must be updated with single instructions, OSTD’s preemption word (a guard count and a need-preempt bit meaning “switch tasks at the next chance”), lives in the record itself, for a reason the next chapter gives.

Starting, idling, kicking

A machine’s processors are started at boot, halted when idle, and interrupted when another processor wants their attention. A virtual CPU does the same three things through three services.

Starting

Virtual CPU 0 starts with the sandbox, and the others start when the kernel proper asks for them, as secondary processors do on a machine. The carrier of virtual CPU 0 enters the image at kernelet_entry. The others wait in the resume hook’s first trip (chapter 6) until the kernel proper, as it would on a machine, registers an entry function for the secondary processors with register_ap_entry. vOSTD then calls vcpu_boot(i) for each, the endovisor wakes the waiting carrier, and it enters the image at vcpu_entry, runs the registered function, and falls into the idle loop below.

Idling

A virtual CPU with nothing to run calls vcpu_idle, and its carrier sleeps in Linux until something is pending. Spinning would burn the sandbox’s share of a real processor for nothing. The sleep is killable, so that SIGKILL ends it, which is how a sandbox is stopped.

Simplified from prototype/linux/endovisor/carrier.c: klet_do_vcpu_idle.

static void klet_do_vcpu_idle(struct klet_carrier *c, u64 deadline_ns)
{
	set_current_state(TASK_KILLABLE | TASK_FREEZABLE | TASK_NOLOAD);   /* 1. announce the sleep: killable, may be frozen for suspend, not counted in the load average */
	WRITE_ONCE(c->idle, true);                            /* 2. mark the vCPU idle */
	smp_mb();
	if (!atomic_read(&c->vrec.pending) && !inst->dying)   /* 3. only then look */
		schedule_hrtimeout(&timeout, HRTIMER_MODE_REL);   /*    and sleep, at most until the deadline */
	__set_current_state(TASK_RUNNING);
	WRITE_ONCE(c->idle, false);
	atomic_or(KLET_VIRQ_TICK, &c->vrec.pending);          /* time passed: the timer callbacks are owed a run */
}

The order of the first three lines is the one every Linux sleep uses so that no wake-up is lost: the sleeper marks itself, then checks; the waker sets the bit, then checks the mark. One of the two always sees the other.

The scheduler’s code shows where both of the remaining services are called: reschedule idles this virtual CPU when its run queue is empty, and set_need_preempt kicks another. reschedule takes the scheduling decision as a closure, so that yield_now, park_current and the others share its loop and differ only in the decision.

Simplified from prototype/vostd/src/task/scheduler/mod.rs: reschedule, set_need_preempt.

fn set_need_preempt(cpu_id: CpuId) {
    let guard = disable_preempt();
    if guard.current_cpu() == cpu_id {
        cpu_local::set_need_preempt();           // this vCPU: just set the flag
    } else {
        crate::vcpu::kick(cpu_id.into());        // another: the vcpu_kick service
    }
}

fn reschedule<F>(mut f: F)                       // the heart of every scheduling decision
where F: FnMut(&mut LocalRunQueue) -> ReschedAction {
    open_resched_window();                       // clears need-preempt; the next chapter says what else it does
    let next_task = loop {
        match f(local_run_queue) {
            ReschedAction::DoNothing => return,
            ReschedAction::Retry => {            // nothing runnable here
                spins += 1;
                if spins >= IDLE_SPINS { crate::vcpu::idle(0); } else { spin_loop(); }
            }
            ReschedAction::SwitchTo(next) => break next,
        }
    };
    processor::switch_to_task(next_task);
}

Retry means the run queue is empty: the virtual CPU spins a little, in case a sibling is about to enqueue something, and then calls vcpu_idle (a deadline of zero means at most one millisecond, the length of a tick). When the sleep ends, vcpu_idle has marked a tick pending, so the timer callbacks run, the closure runs again, and either finds a task or sleeps again.

Kicking

When the kernelet’s scheduler puts a task on another virtual CPU’s run queue, that virtual CPU should notice soon, as a machine would be told by an inter-processor interrupt. vOSTD calls vcpu_kick(i), which sets a KICK bit in the target’s record and does whichever of three things gets it seen: wakes the carrier if it is asleep in vcpu_idle, flags it if it is in user mode, or, if it is running kernelet code on another processor right now, sends that processor an interrupt that makes the kick be delivered at once instead of at the watch timer’s next tick. set_need_preempt above is where the scheduler calls it.

One carrier, many threads

A carrier runs many tenant threads in turn, so two things a thread owns must change with it: its address space and its floating-point and vector registers.

A virtual CPU runs the threads of many tenant processes in turn, and each process has its own address space. The kernel proper, as on a machine, activates the right VmSpace before it runs a thread; in vOSTD, activate records the space in the virtual CPU’s record (the space field above), and the carrier switches to that process’s Linux address space on its next trip to user mode. The switch itself is klet_space_adopt, which the chapter on tenant memory builds, and which both hooks of chapter 8 call just before they apply the registers.

The floating-point and vector registers belong to a tenant thread too. To Linux they are the carrier’s user state: Linux saves them when it switches the carrier out and restores them, lazily, before the carrier next returns to user mode, and it does not know that the kernelet has put a different thread on the carrier in between.

OSTD already has the type for a thread’s own copy, FpuContext, which the kernel proper saves when a thread leaves user mode and loads before it returns. vOSTD implements its save and load with two services, fpu_save and fpu_load. Each first makes the registers live, if Linux had deferred restoring them, and then executes the same xsave or xrstor instruction a machine would.

The instruction never touches the kernelet’s buffer directly. xrstor reads a header from the buffer and acts on it, and the kernelet’s buffer may lie in a frame that the kernelet has also mapped into a tenant, which could rewrite it between a check and the instruction. So the endovisor copies the buffer to a page of its own, validates the copy, and runs the instruction on that.

Recap. Linux schedules a sandbox’s virtual CPUs, each carried by one Linux task; on each, the kernelet’s own scheduler, injected by the kernel proper, picks which kernelet task runs. Tasks, stacks and the context switch are OSTD’s own code; only the stack memory comes from the endovisor. Each virtual CPU has a record that vOSTD finds through Linux’s per-processor current-task pointer in two loads. A virtual CPU is started with vcpu_boot, sleeps in Linux with vcpu_idle under a lost-wake-up-proof protocol, and is prodded with vcpu_kick.

Virtual interrupts

The kernelet’s scheduler needs interrupts, and Linux leaves it none; this chapter gives it virtual ones. A time slice ends because a timer interrupt fires, and a wake-up on another processor is noticed because an interrupt arrives there. But Linux owns the interrupt controller, the vector table and every physical line, and nothing in vOSTD can register a handler for any of them.

Bits in a record

A virtual interrupt is a bit in the pending word of a virtual CPU’s record (chapter 9). The endovisor sets it from interrupt context or from another processor. The kernelet’s handler for it runs as kernelet code on that virtual CPU’s carrier, never in Linux’s interrupt context, soon after. Two bits are raised in this prototype; a third, LINES, is reserved for devices:

bitmeaningset by
TICKthis processor’s watch period (the watch timer’s one-millisecond period, below) expired while this carrier was current; also raised whenever the virtual CPU wakes from vcpu_idlethe watch timer, below; vcpu_idle on waking
KICKanother virtual CPU wants this one to look at its run queuethe vcpu_kick service

Each bit stands in for a hardware interrupt the kernel proper already handles. The kernel proper’s timer callbacks, functions it registers with OSTD to run on every timer interrupt, run from TICK as they would from a hardware timer interrupt. One of them, registered by OSTD when the kernel proper calls enable_preemption_on_cpu, calls the injected run queue’s update_current(Tick) and sets need-preempt, a bit in OSTD’s preemption word meaning switch tasks at the next chance, if the run queue asks for it. KICK does what a reschedule interrupt does on a machine: it makes the virtual CPU ask its scheduler whether something more urgent has become runnable.

vOSTD computes the kernelet’s jiffies, its count of ticks since boot, from the cycle counter rather than by counting ticks, because a virtual CPU that Linux has not scheduled gets none.

When the handler runs depends on what the virtual CPU is doing when the bit is set, and above all on whether it is in a critical section. A virtual CPU is in a critical section when the kernelet task on it holds at least one preemption guard or spin lock, or has its virtual interrupts masked. The chapter’s closing section lists the paths by which a bit reaches its handler; each path has a section of its own first.

The watch timer

Linux: high-resolution timers and preemption notifiers. An hrtimer is a timer whose callback runs in interrupt context, normally on the processor that armed it. A pinned timer is guaranteed to stay there. A hard one runs in the interrupt itself even on a real-time kernel, a kernel built to run most interrupt work in threads. A callback returns HRTIMER_RESTART to run again, after moving its expiry forward by whole periods past now with hrtimer_forward_now(), or HRTIMER_NORESTART to stop. hrtimer_active() says whether a timer is queued or its callback is running, hrtimer_cb_get_time() gives the time a callback runs at, and hrtimer_set_expires() sets the next expiry directly.

A timer callback can make the interrupted code resume somewhere else. The timer interrupt publishes the registers of whatever it interrupted, and a callback reads them with get_irq_regs(), which is how Linux’s profiler samples programs; a callback that changes the saved instruction pointer makes the interrupt return somewhere else.

A preemption notifier (preempt_notifier_register()) is a pair of callbacks Linux runs for one task every time it switches that task out of a processor and in to one; KVM, Linux’s own hypervisor, uses it to load a virtual CPU’s state onto the processor it is switched in on and to save it when it is switched out. Notifiers are compiled in only if another configuration option requires them (CONFIG_PREEMPT_NOTIFIERS cannot be turned on by itself); the patch’s CONFIG_KERNELET requires it.

The watch timer, a pinned, hard, one-millisecond hrtimer on each processor, is armed while the task running there is the carrier of a virtual CPU, and for at most one more period after it leaves. Each carrier registers a preemption notifier when it starts. The notifier’s switch-in callback arms the timer of whatever processor the carrier has just landed on, and so does every entry into kernelet code and every return from a service, which no switch-in need precede. The timer’s own callback re-arms itself if it interrupted the carrier of a virtual CPU of a live sandbox, and stops otherwise. So a carrier that Linux migrates is watched wherever it lands, and a processor that has no carrier is soon unwatched.

Simplified from prototype/linux/endovisor/watch.c and prototype/linux/endovisor/sched.c: klet_watch_arm, klet_sched_in.

static void klet_sched_in(struct preempt_notifier *pn, int cpu)   /* Linux just put this carrier on a processor */
{
	struct klet_carrier *c = container_of(pn, struct klet_carrier, pnotifier);
	klet_watch_arm(c);
}

void klet_watch_arm(struct klet_carrier *c)
{
	struct hrtimer *timer = this_cpu_ptr(&klet_watch_timer);
	if (!hrtimer_active(timer))
		hrtimer_start(timer, ns_to_ktime(KLET_WATCH_INTERVAL_NS), HRTIMER_MODE_REL_PINNED_HARD);
}

Every time a period expires, the callback raises TICK and then decides how to deliver whatever is pending:

Simplified from prototype/linux/endovisor/watch.c: klet_watch_fn.

static enum hrtimer_restart klet_watch_fn(struct hrtimer *timer)
{
	struct pt_regs *regs = get_irq_regs();      /* the interrupted registers; NULL if whatever ran this callback did not publish them */
	struct klet_carrier *c;
	bool tick = true;

	if (this_cpu_read(klet_watch_requested)) {  /* a kick asked for this firing (below): it counts as a tick only if the period it cut short has run out */
		this_cpu_write(klet_watch_requested, false);
		tick = !ktime_before(hrtimer_cb_get_time(timer), this_cpu_read(klet_watch_period_end));
	}

	if (!current->carrier_hooks)                     /* not a carrier any more: stop */
		return HRTIMER_NORESTART;
	c = klet_current_carrier();

	if (tick)
		klet_virq_raise(c, KLET_VIRQ_TICK);     /* 1. the tick */

	if (!regs || user_mode(regs) || !klet_in_image_text(regs->ip) || !klet_in_kernelet(c)) {
		/* no registers, user mode (klet_virq_raise flagged the carrier), a service, or Linux: leave it pending */
	} else if (!klet_critical(c)) {
		klet_virq_redirect(c, regs);            /* 2. in kernelet code, not in a critical section: deliver now */
	} else {
		/* in a critical section: the kernelet delivers it itself when the section ends */
	}
	/* 3. the bound on critical sections: the next chapter */

	if (!tick) {                                /* a requested firing goes back to its period's own expiry */
		hrtimer_set_expires(timer, this_cpu_read(klet_watch_period_end));
		return HRTIMER_RESTART;
	}
	hrtimer_forward_now(timer, ns_to_ktime(KLET_WATCH_INTERVAL_NS));
	return HRTIMER_RESTART;
}

The callback’s two tests keep it from redirecting anything but kernelet code. klet_in_image_text asks whether the interrupted instruction lies in the kernelet image’s code; Linux’s code and the endovisor’s never do. klet_in_kernelet asks whether the carrier is running kernelet code and not a service: state RUNNING, depth 0, not asleep in vcpu_idle, and not evicted, that is, not already being forced out of kernelet code by the endovisor, as happens when the sandbox is being stopped. A carrier inside a service call is never redirected, because it may hold Linux’s locks.

klet_virq_raise sets the bit and does the two cheap things that get it noticed. It wakes the carrier if it is asleep in vcpu_idle. It flags the carrier (chapter 6) if it is in user mode, so that it comes back through the resume hook and user_run returns 2.

When the carrier is in kernelet code outside a critical section, the endovisor delivers at once, as a machine would, by changing one saved register:

Simplified from prototype/linux/endovisor/watch.c: klet_virq_redirect.

bool klet_virq_redirect(struct klet_carrier *c, struct pt_regs *regs)
{
	if (user_mode(regs) || !klet_in_image_text(regs->ip) || !klet_in_kernelet(c))
		return false;
	if (klet_irq_off(c))                         /* interrupts masked: wait */
		return false;
	limit = READ_ONCE(c->vrec.stack_limit);
	if (!limit || regs->sp < limit || regs->sp - limit < KLET_UPCALL_HEADROOM)
		return false;                            /* no limit published, or not enough stack for the handler */

	WRITE_ONCE(c->vrec.virq_ip, regs->ip);       /* where we interrupted the kernelet */
	WRITE_ONCE(c->vrec.irq_off, 1);              /* mask, as hardware clears IF when it takes an interrupt */
	regs->ip = (unsigned long)kernelet_entry_table.virq_entry;   /* return there instead */
	return true;
}

The redirect is refused when interrupts are masked or when the task’s kernelet stack has too little room. vOSTD publishes each task’s stack limit at every task switch. The endovisor requires 32 KiB below the interrupted stack pointer (KLET_UPCALL_HEADROOM): 16 KiB for the upcall and the callbacks it runs, and 16 KiB reserved for Linux, which runs on whatever stack it interrupts until it switches to its interrupt stack, runs its scheduler there when it preempts the carrier on the way out of an interrupt, and runs the whole handler of a kernel-mode exception there.

The upcall

When the timer interrupt returns, it lands not on the interrupted instruction but in the kernelet’s upcall stub: the kernelet’s own interrupt handler, which only a redirected interrupt enters.

The stub, in vOSTD’s assembly, saves everything on the current task’s kernelet stack, calls a Rust function, and returns to the interrupted instruction. It is assembly because it is entered between two arbitrary instructions and must put every general register and the flags back exactly.

Two build settings keep the stub short. The kernelet is built without vector instructions, so there is no vector state to save. The kernelet, like Linux, is compiled without a red zone, the 128 bytes below the stack pointer that user-mode code may use without moving it, so the stub may push below the interrupted rsp.

The excerpt is in Intel syntax, destination first, as Rust’s global_asm! has it; chapter 7’s switch was in AT&T syntax, the GNU assembler’s default, source first.

Simplified from prototype/vostd/src/vcpu.rs: klet_virq_entry, klet_virq_body.

klet_virq_entry:
    lea rsp, [rsp - 8]       ; a slot for the interrupted instruction pointer (lea, not sub: sub would change the flags)
    pushfq                   ; the flags, exactly as the interrupted code left them
    push rax ... push r15    ; every general register but rsp
    mov rbp, rsp
    and rsp, -16             ; align for the call
    call klet_virq_body      ; returns the instruction pointer to resume at
    mov rsp, rbp
    mov [rsp + 128], rax     ; into the slot: 15 registers and the flags (128 bytes) above rsp
    pop r15 ... pop rax
    popfq
    ret                      ; to the interrupted instruction
extern "C" fn klet_virq_body() -> u64 {
    let rec = vrec();
    let ip = unsafe { read_volatile(rec.virq_ip.get()) } as usize;   // copied to this frame first: the record's field may be reused
    assert!(IMAGE_TEXT_LO <= ip && ip < IMAGE_TEXT_HI);   // never resume at an address the host made up

    let owns_mirror = get_guard_count() == 0;            // this upcall begins a critical section of its own
    if owns_mirror { host_preempt_inc(); }               // the mirror of the next chapter: tell Linux so

    run_handlers(rec);                                   // TICK: the timer callbacks; KICK: a look at the run queue

    let host_wants_cpu = if owns_mirror { host_preempt_dec_and_test() } else { false };
    unsafe { write_volatile(rec.irq_off.get(), 0) };     // unmask: what a machine's interrupt return would do
    if host_wants_cpu { host_yield(); }                  // Linux was waiting for this processor
    if get_guard_count() == 0 {                          // kernel-mode preemption: switch if the scheduler wants to
        crate::task::scheduler::might_preempt();
    }
    ip as u64
}

fn run_handlers(rec: &VcpuRec) {
    loop {
        let pending = rec.pending.swap(0, Ordering::AcqRel);
        if pending == 0 { break; }
        if pending & VIRQ_TICK != 0 { crate::timer::call_timer_callback_functions(); }
        if pending & VIRQ_KICK != 0 { cpu_local::set_need_preempt(); }
    }
}

The check against the image’s text range catches a bug in the endovisor’s bookkeeping, so that a wrong address stops the kernelet instead of being jumped to. The host is trusted, so this is not a defense against it.

Four lines raise Linux’s preemption count while the handlers run, so that Linux does not take the processor in the middle; the next chapter explains them. owns_mirror is always true, since the endovisor redirects only when no guard is held (below); the test keeps the function correct if that ever changes.

The might_preempt at the end adds kernel-mode preemption, which OSTD on a machine lacks: the handler may switch to another task before returning. That is how a tick ends a time slice in the middle of a kernel task’s computation, as it does on a machine with a preemptible kernel.

A switch inside the upcall is safe for the interrupted address because the body’s first act copies virq_ip into a local variable, so the address lives in this frame, on the task’s own stack. The record’s field is free for the next upcall on this virtual CPU. The stub copies the local into the slot only once the body has returned, perhaps much later. The interrupted task resumes from the same frame.

vOSTD assumes that a preempted task resumes on the virtual CPU that preempted it. On a machine a per-CPU word is one gs: instruction, so it cannot straddle a switch; vOSTD finds the slot first and touches it second, so a task preempted between the two and resumed on another virtual CPU would write the first virtual CPU’s slot. The schedulers of this book’s kernels, like Asterinas’s, never move a task they preempted, but OSTD allows a scheduler that balances load, and nothing in vOSTD enforces the assumption: it is a requirement vOSTD places on the kernel proper’s scheduler.

Kernel-mode preemption has a hazard of its own with floating-point state. From a thread’s FpuContext::load until user_run, and from execute’s return until save, the thread’s floating-point and vector registers are live on the processor, and a switch there, by an upcall or by a guard drop that delivers a bit, would let the next task’s restore of its own registers overwrite them. A kernel proper must therefore save them in its pre-schedule handler, as Asterinas does; this book’s kernels do not, so those windows are open in the prototype, and no test shows them closed.

Preemption inside a scheduling decision

An upcall must not preempt inside reschedule, between its decision and its switch. reschedule holds no preemption guard, since on a machine nothing preempts OSTD’s kernel code, so an upcall can land between the two. By then the run queue’s bookkeeping already names the chosen task current and has put the still-running task back among the waiting. A second decision could pick the running task. Before switching to a task, OSTD waits until the processor that last ran it has finished saving it, so a task that waits for itself waits forever and the processor hangs.

So vOSTD flags that window and defers any preemption to its end. open_resched_window, the first thing reschedule does, runs whatever virtual interrupts are already pending, without switching, so that whatever they ask for is answered by the decision about to be made; then it clears need-preempt and sets a flag in the virtual CPU’s local storage. While the flag is set, might_preempt only records a note, because need-preempt alone would wait for the next upcall, the bit that asked for it having been consumed. Whoever closes the window (the completed switch, the decision to do nothing, or the retry that decides again) acts on the note at once, by preempting or, for the retry, by deciding again instead of sleeping. A preemption is deferred by the window and never by more.

The window costs one reschedule each time a kick is taken inside it. When virtual CPU A puts a task on B’s run queue and kicks B while B is already deciding, B often picks the task before the kick is taken; the kick is then taken inside the window, by the guard drop that releases the run queue or by an upcall, and leaves a note. If B was idle, the note costs one reschedule that finds nothing to do; if B was switching away from a running task, it costs a switch back.

Interrupts off, and guards

Two separate fields in the record stand for the two ways a kernel defers work. On a machine, masking interrupts holds off the handler, and a preemption guard holds off only the task switch; in vOSTD both hold off the upcall, because the upcall cannot know in advance whether it will switch. The fields stay separate because OSTD’s interface has two guards.

  • irq_off is the virtual interrupt flag, inverted. vOSTD sets it where OSTD’s interrupts-off guard, irq::disable_local(), would clear the processor’s flag, and clears it when the guard drops; the endovisor sets it when it redirects into the upcall, and the upcall clears it at the end. While it is set, nothing is delivered.
  • The guard count, the low bits of preempt_info, is how many preemption guards are held, counting spin locks that disable preemption; a lock that disables interrupts sets irq_off instead. What a guard means is that no task switch happens on this virtual CPU while it is held; how that is enforced is that the watch timer never redirects a task that holds one, so the upcall, the one place a switch could happen underneath kernelet code, is never entered under a guard.

irq_off has two writers, but plain stores are enough. The endovisor writes a 1 only when it finds a 0, only from an interrupt on the carrier’s own processor, so the two writers never run at once, and the upcall it diverts into always clears that 1 again before the interrupted code resumes.

The guard word is updated only by its own virtual CPU, with single read-modify-write instructions (add, sub, and, or), because the guard count shares its word with the need-preempt bit, which the upcall’s handlers set. If a guard’s increment compiled to a load, an add and a store, an upcall could land between the load and the store, because the count was still zero, and the store would erase the need-preempt bit the upcall had just set; a single add to memory cannot be split by an interrupt.

Delivery at the end of a critical section

When a critical section ends, vOSTD delivers whatever was left pending in it, whenever it arrived. Two places check: the interrupts-off guard when it unmasks, and the drop of the last preemption guard, for a bit that arrived under the guard, in kernelet code or in a service. Only the upcall checks the stack’s headroom; these paths run the same handlers as a function call on whatever stack the task is on. The unmask:

Simplified from prototype/vostd/src/task/preempt/cpu_local.rs and prototype/vostd/src/vcpu.rs: irq_unmask, deliver_if_pending, handle_virq.

pub(crate) fn irq_unmask() {
    let leaving = get_guard_count() == 0;       // the end of a critical section?
    /* ... the next chapter's mirror ... */
    set_irq_off(false);
    if leaving {
        vcpu::deliver_if_pending();             // a few loads and a branch
    }
}

pub(crate) fn deliver_if_pending() {
    if has_pending() { handle_virq(); }
}

pub(crate) fn handle_virq() {                   // the same handlers, called as a function
    if IN_VIRQ.load() { return; }               // already inside (one flag per virtual CPU): the guard's own drop below calls back here; the outer loop takes any bit raised before its last swap
    IN_VIRQ.store(true);
    {
        let _guard = crate::irq::disable_local();
        run_handlers(vrec());
    }
    IN_VIRQ.store(false);                       // cleared before the switch below, which may not come back here
    crate::task::scheduler::might_preempt();
}

Delivery on the return from user mode

A carrier in user mode takes its virtual interrupts on the way back, without a task switch. execute’s loop runs the handlers before it re-enters user mode, through deliver_no_switch: the same handle_virq as above without the final might_preempt, the call that switches tasks if need-preempt is set and no guard is held.

vOSTD does not switch at that point, unlike OSTD, because the tenant thread’s floating-point and vector registers are still live on the processor, and this book’s kernels save them only after execute returns and not at a switch, so another task’s restore of its own would overwrite them.

So under vOSTD a tenant thread that stays in user mode is never preempted. Its slice ends and need-preempt is set, but nothing acts on it until the thread enters the kernel and its kernel code reaches a call to might_preempt: an upcall, a guard drop or unmask that finds a bit pending, a spawn, or the kernel proper’s next yield or sleep. OSTD on a machine does preempt such a thread, by calling might_preempt before every entry to user mode, and Asterinas’s kernel proper relies on that; its has_kernel_event hook reports only signals. This is a difference from OSTD’s documented behavior, which the prototype has not closed.

Neither the flag nor the bit can be lost in a race. A flag set while the hook is already running is not lost, as chapter 5 arranged. user_run publishes KLET_USER and then reads pending, while klet_virq_raise sets the bit and then reads the state; as with idling, one of the two always sees the other, so a bit raised just as the carrier leaves is never stranded.

The kick’s interrupt

A kick to kernelet code on another processor makes that processor’s watch timer fire at once, through an inter-processor interrupt, instead of waiting up to a millisecond for its next tick. The endovisor sends it with smp_call_function_single(), which runs a function on a named processor, in interrupt context there, through an inter-processor interrupt; the last argument, 0, means the caller does not wait. The function it runs there cannot redirect anything itself, because that kind of interrupt does not publish the interrupted registers; what it can do is restart the watch timer with zero delay (klet_watch_fire_now), so that the timer interrupt, whose callback does see the registers, arrives as soon as the cross-processor call returns, and delivers the kick:

Simplified from prototype/linux/endovisor/sched.c: klet_kick_remote, klet_kick_ipi.

void klet_kick_remote(struct klet_carrier *c)
{
	if (c->state != KLET_RUNNING || c->depth != 0 || !klet_task_on_cpu(c->task))
		return;                                 /* not in kernelet code on a processor: the bit will be found */
	smp_call_function_single(task_cpu(c->task), klet_kick_ipi, c, 0);
}

static void klet_kick_ipi(void *arg)            /* runs in interrupt context on that processor */
{
	struct klet_carrier *c = arg;
	if (current->carrier_hooks == &c->hooks)          /* still the same carrier there? */
		klet_watch_fire_now();                  /* then make the watch timer fire, and it will redirect */
}

A requested firing is not a tick, unless the period it cut short has already run out: the callback raises no TICK for it, does not count toward the next chapter’s bound on critical sections, and puts the timer back on the expiry of the period it cut short, so a kick neither raises an extra tick nor delays the next one.

A kick whose bit is already set does nothing at all, so a thousand kicks before the bit is taken cost one delivery.

When a bit is delivered

A virtual interrupt reaches its handler by whichever of four paths comes first: the upcall, at a watch firing that finds the kernelet in kernelet code outside a critical section with stack to spare; the end of a critical section, at the unmask or the drop of the last guard; the return from user mode; or the start of the next scheduling decision. A bit left pending for any reason, because the carrier was in a service that held no guard, had too little stack, or was off its processor, is delivered by the first of those that comes, usually the next guard drop, or by a later watch firing that can redirect.

The firing comes within one period if the carrier is on its processor when that period expires; there is no bound while Linux keeps the carrier off a processor, or takes it off before each period ends.

flowchart TB
    t["watch timer fires<br/>(interrupt context)"] --> raise["set TICK in the record<br/>(unless a kick's firing);<br/>flag the carrier if in user mode"]
    raise --> where{"what was interrupted?"}
    where -- "user mode" --> flag["resume hook, user_run returns 2,<br/>handlers run, no switch"]
    where -- "kernelet code, not in a critical section,<br/>stack to spare" --> redir["saved ip := virq_entry<br/>irq_off := 1"]
    where -- "a critical section, too little stack,<br/>a service, Linux, or no registers" --> pend["leave the bit pending"]
    redir --> stub["virq_entry: push all,<br/>klet_virq_body"]
    stub --> h["TICK: timer callbacks;<br/>KICK: set need-preempt"]
    h --> pre["irq_off := 0;<br/>might_preempt()"]
    pre --> ret["pop all, ret"]
    pend --> later["delivered at unmask, guard drop,<br/>the return from user mode, the next<br/>scheduling decision, or the next firing"]

Recap. Virtual interrupts are bits in the virtual CPU’s record: TICK from the watch timer, KICK from vcpu_kick. The watch timer is a pinned, hard, one-millisecond hrtimer per processor, armed by each carrier’s preemption notifier whenever Linux puts the carrier on that processor. When it finds the carrier in kernelet code outside a critical section, with room on the task’s stack, it points the interrupted frame at the image’s upcall stub, which saves everything on the task’s stack, runs the handlers, and may switch tasks before returning: kernel-mode preemption, held off by a guard and, inside a scheduling decision, by the window. A carrier in user mode is flagged and runs the handlers on its way back without switching; one in a critical section delivers the bit itself when the section ends; the next scheduling decision runs whatever is pending; a kick to another processor makes its timer fire at once; anything else waits for the next firing that can deliver it.

Sharing a processor politely

This chapter resolves three conflicts between a kernelet and Linux over who holds a processor, and adds one backstop. Linux wants a processor back from a carrier whenever its own policy says so, because the sandbox’s turn is over or a host task with a better claim woke up, but it cannot always take it. The watch timer gives the processor up on behalf of a kernelet that cannot give it up itself (Problem 1). vOSTD copies the kernelet’s critical sections into Linux’s own preemption count (Problem 2). The watch timer bounds how long that copy may hold Linux off (Problem 3). A last section keeps a waiting virtual CPU from spinning on a lock whose holder Linux has descheduled.

Problem 1: a Linux that does not preempt kernel code

Linux: preemption models. Linux’s preemption models differ in when a task that should give way does so. Linux decides that a task should give way by setting a flag on it, need_resched, which the scheduler does at a timer tick when the task’s slice is used up, or when a task with a better claim wakes; the models differ in when the flag is acted on. A kernel is built with one of several models; one built with the full model and the option PREEMPT_DYNAMIC, as this book’s is, can be switched at boot to none, voluntary or full with the parameter preempt=. Under full a task running kernel code can be rescheduled at almost any instruction, on return from an interrupt. Under voluntary and none, common on servers, a task in kernel mode keeps its processor until it sleeps, returns to user mode, or reaches one of the many places where Linux’s own code calls cond_resched(), the voluntary preemption point.

Under preempt=none, a kernelet task that computes holds its processor for as long as it computes, against every other task that wants it, because kernelet code contains no call to cond_resched(): it cannot call Linux.

The remedy is for the watch timer to do what a call to cond_resched() would: give the processor up on the kernelet’s behalf when Linux wants it. The two forms below exist for the configurations in which Linux does not preempt kernel code; on a Linux that does, Linux reschedules by itself on the way out of the interrupt, and neither has anything left to do.

The first form is the upcall of chapter 10: on its way out, if Linux has set need_resched meanwhile, vOSTD calls vcpu_yield, which reschedules in Linux. vOSTD notices without calling Linux, by the decrement-and-test of its mirror, under Problem 2; with the mirror off, only the stub below yields. The Linux built for this book is configured for full preemption and booted with preempt=none for this test, so it still keeps its preemption count, and it folds need_resched into that count (the box under Problem 2).

The second form is a yield stub in the endovisor, for when the upcall cannot be taken because the interrupted task’s stack has too little room left, or its bound is unknown. The timer points the interrupted frame at the stub by rewriting the frame’s instruction pointer, as it does for the upcall.

The stub fits where the upcall does not. The upcall needs 32 KiB left on the stack, 16 KiB for its own frames and 16 KiB reserved for a Linux interrupt on the same stack. The stub needs a few dozen words, the slot it reserves, the sixteen words it pushes and one small C frame, and the reschedule itself runs on the Linux stack. Nothing checks for those few words; running out halts the host on a double fault, as chapter 7 explains.

The watch callback’s non-critical branch becomes the following, which replaces chapter 10’s call to klet_virq_redirect. again is the callback’s last lines, which re-arm the timer and return:

Simplified from prototype/linux/endovisor/watch.c: klet_watch_fn.

	if (!klet_critical(c)) {
		if (klet_virq_redirect(c, regs))       /* an upcall was possible: it will yield on its way out */
			goto again;
		if (need_resched()) {                  /* no room for an upcall, and Linux wants the processor */
			c->yield_ip = regs->ip;
			c->forced = false;                 /* forced only selects a counter; Problem 3 sets it */
			regs->ip = (unsigned long)klet_yield_stub;
		}
		goto again;
	}

The stub saves every register and the flags on the kernelet stack, as the upcall does, then hops to the Linux stack exactly like a service call and reschedules there:

Simplified from prototype/linux/endovisor/switch.S and prototype/linux/endovisor/carrier.c: klet_yield_stub, klet_yield_body, klet_do_yield.

klet_yield_stub:
	leaq	-8(%rsp), %rsp    /* a slot for the return address; lea, not sub, which would change the flags */
	pushfq
	pushq	%rax ... pushq %r15
	movq	%rsp, %rbp
	andq	$-16, %rsp
	call	klet_yield_body   /* 0: the kernelet died meanwhile; else the ip to resume at */
	movq	%rbp, %rsp
	testq	%rax, %rax
	jz	.Lleave
	movq	%rax, 128(%rsp)   /* into the slot */
	popq	%r15 ... popq %rax
	popfq
	ret
unsigned long klet_yield_body(void)
{
	struct klet_carrier *c = klet_current_carrier();
	struct klet_svc_args args = { .op = KLET_SVC_VCPU_YIELD };
	unsigned long ip = c->yield_ip;

	klet_run_on_linux_stack(c->linux_sp, &args);   /* klet_svc_body -> klet_do_yield, on the Linux stack */
	if (args.ret || c->doomed)                      /* the kernelet stopped, or this carrier was killed */
		return 0;
	return ip;
}

static void klet_do_yield(struct klet_carrier *c)
{
	if (!cond_resched() && need_resched())
		schedule();
}

The stub never returns into a kernelet that died while the carrier was away. klet_svc_body sets ret for this service when the sandbox is stopped or dying, or this carrier has been evicted. .Lleave then abandons the kernelet stack and returns to Linux.

klet_do_yield must yield under every model, and it serves every yield: the upcall’s, the stub’s, and the forced one of Problem 3. cond_resched() does nothing under the full model. So when it has not rescheduled and Linux still wants the processor, schedule() is called directly.

The test make yield shows the remedy working. The test kernel in prototype/kernels/yield/src/lib.rs computes for seconds. The test runs it twice on a kernel booted with preempt=none, with a competing host process pinned to the same processor: once with the watch timer turned off (the module parameter klet_watch=0) and once with it on. With the timer off, the competitor gets nothing for the kernelet’s whole computation, 4.4 s in the last run. The competitor prints a progress line about every 250 ms, so the test resolves nothing finer; with the timer on, the longest gap between two lines was 271 ms in the last run, barely more than the print interval, and the test asserts under a second.

The test also shows that the upcall loses no register: a checksum the kernel computes over a long run in ten live variables comes out identical, and equal to a user-space reference, whether the kernelet was diverted through the upcall thousands of times or never.

The stub’s unforced path, taken only when the stack is short, runs in no test: the yield kernel’s computation is shallow, so every tick that finds it in kernelet code outside a critical section finds room for the upcall. The stub itself runs, in the forced form of Problem 3, in make sched-coop, where no checksum is taken.

Problem 2: a lock holder loses its processor

If Linux preempts a carrier while the kernelet task on it holds a spin lock, every other virtual CPU that wants that lock spins until Linux runs the first one again, which may be a whole time slice later. This is lock-holder preemption. There are two families of remedy: tell the host when not to preempt, and make waiting cheap when it preempted anyway. This section is the first remedy; the backstop at the end of the chapter is the second.

Linux: the preemption count. Linux keeps a per-processor preemption count, which preempt_disable() and every spin lock raise and their counterparts lower; on return from an interrupt, Linux reschedules kernel code only if the count is zero. The word is a set of bit fields: the low eight bits are the preemption depth, and the bits above count software- and hardware-interrupt nesting, which is how Linux knows it is in an interrupt. It lives in the per-processor block that %gs points at.

On x86-64 one decrement tests both conditions at once, because a copy of the current task’s need_resched flag is folded, inverted, into the top bit of the same word (PREEMPT_NEED_RESCHED): a single decrement yields zero exactly when the count is zero and a reschedule is wanted, which is what preempt_enable() tests. In numbers: with one guard held and no reschedule wanted the word is 0x80000001, and a decrement gives 0x80000000, not zero; if Linux has cleared the top bit to ask for the processor, the word is 0x00000001, and the decrement gives zero.

The rule, then: the kernelet’s critical sections are Linux’s critical sections. vOSTD records in Linux’s count, as one extra unit, whether the kernelet is in a critical section; the book calls that unit the mirror.

vOSTD mirrors entry and exit with Linux’s own instructions: an increment on entry, a decrement and zero test on exit. When the kernel proper enters a critical section, by taking a preemption guard, a spin lock or an interrupts-off guard while it held none, vOSTD executes the same single instruction Linux’s preempt_disable() does, an increment of the per-processor word through %gs, at the offset the boot arguments published. When the section ends, it executes the matching decrement. If that leaves the word at zero, Linux is waiting for this processor, and vOSTD hands it over at once with the service vcpu_yield: the same decrement and zero test as Linux’s preempt_enable(), except that vOSTD yields whatever the preemption model.

Every processor’s count gets back exactly what it was given. Linux does not move a task whose count is raised. Every path on which the carrier can leave the processor takes the increment out first and puts it back wherever the carrier resumes.

Simplified from prototype/vostd/src/vcpu.rs and prototype/vostd/src/task/preempt/cpu_local.rs: host_preempt_inc, host_preempt_dec_and_test, inc_guard_count, dec_guard_count.

pub(crate) fn host_preempt_inc() {
    if mirrored() { return; }                                    // already up: idempotent
    unsafe { asm!("inc dword ptr gs:[{o}]", o = in(reg) GS_PREEMPT_COUNT) };
    set_mirrored(true);                                          // after, so the flag never claims more than the count holds
}

pub(crate) fn host_preempt_dec_and_test() -> bool {
    if !mirrored() { return false; }
    set_mirrored(false);                                         // before, for the same reason
    let zero: u8;
    unsafe { asm!("dec dword ptr gs:[{o}]", "setz {z}", o = in(reg) GS_PREEMPT_COUNT, z = out(reg_byte) zero) };
    zero != 0                                                    // zero: no guards left, and Linux wants the processor
}

pub(crate) fn inc_guard_count() {
    let was_critical = in_critical_section();
    add_assign(1);                                               // the kernelet's own count
    if !was_critical { vcpu::host_preempt_inc(); }               // the first guard raises Linux's
}

pub(crate) fn dec_guard_count() {
    let leaving = get_guard_count() == 1 && !irq_is_off();
    let host_wants_cpu = if leaving { vcpu::host_preempt_dec_and_test() } else { false };
    sub_assign(1);
    if host_wants_cpu { vcpu::host_yield(); }                    // the vcpu_yield service: schedule()
    if leaving { vcpu::deliver_if_pending(); }                   // a virtual interrupt that arrived meanwhile
}

Linux’s count rises by one however deeply the kernelet’s guards nest, and that is deliberate. Linux’s count has only eight bits for the preemption depth, and a kernel proper that nested more guards than that would spill into the fields that say “in an interrupt” and make Linux misjudge its own state.

vOSTD raises and lowers Linux’s count according to a flag, mirrored, in the virtual CPU’s record, rather than by pairing each guard’s entry with its exit: the increment happens when a critical section begins and the flag is clear; the decrement when one ends and the flag is set. Raising twice is a no-op, and so is lowering when nothing is raised.

A pairing would not do, because the code that ends a critical section is often not the code that began it: a section can begin with a guard and end with an unmask, or begin in the upcall, and OSTD’s switch_to_task takes a guard that the next task, not the one that took it, drops. We could not show that every lowering meets exactly one raising on all those paths. A single unmatched decrement would wrap Linux’s word below zero, borrowing into the interrupt-nesting fields, so that Linux would believe it is in an interrupt for as long as the kernelet runs there; the endovisor’s measurement, below, catches that only when the carrier next leaves kernelet code. With the flag, the worst a mistake costs is one preemption Linux was not asked to defer.

The flag is set after the increment and cleared before the decrement, so that it never claims more than the count holds: whenever the flag is set, Linux’s count is raised.

The mirror exists only while kernelet code runs. A service may sleep. Linux forbids sleeping with the preemption count raised. OSTD holds a preemption guard while it edits a page table, and the service that maps a page under it may have Linux allocate memory. So the service body, on the Linux stack, takes out of Linux’s count what the kernelet’s guards added, and puts it back on return.

The endovisor measures the amount itself: it recorded Linux’s count when the carrier entered kernelet code, and the excess over that is the kernelet’s. It does not ask the kernelet.

Simplified from prototype/linux/endovisor/carrier.c: klet_mirror_in, klet_mirror_out, klet_svc_body.

static void klet_mirror_in(struct klet_carrier *c)     /* on the way into kernelet code */
{
	c->pc_base = preempt_count();
	if (c->excess > 0) { preempt_count_add(c->excess); c->excess = 0; }
}

static void klet_mirror_out(struct klet_carrier *c, bool forever)   /* on the way out, in task context */
{
	int excess = preempt_count() - c->pc_base;         /* measured, never read from the kernelet */

	if (excess < 0 || excess > 1) {                    /* the kernelet corrupted host state: re-normalize */
		preempt_count_set(c->pc_base);
		c->excess = 0;
		return;
	}
	if (excess > 0) preempt_count_sub(excess);
	c->excess = forever ? 0 : excess;
}

void klet_svc_body(struct klet_svc_args *args)
{
	c->depth++;
	klet_mirror_out(c, false);     /* the service may sleep now */
	switch (args->op) { /* ... */ }
	klet_watch_arm(c);             /* back in kernelet code soon: make sure this processor is watched (defensive) */
	klet_mirror_in(c);
	c->depth--;
}

A kernelet guard therefore promises only that no kernelet task switch happens on this virtual CPU until it drops. Across a service, which may sleep, and at a forced yield (Problem 3, below), the carrier can still lose the processor while a kernelet guard is held. The kernelet is correct under that weaker promise, as a guest kernel is correct when its hypervisor deschedules a virtual CPU. The memory services need to sleep under a guard, which only this weaker promise allows. The chapter that builds them states it precisely.

The test make sched-coop shows the mirror working. It runs four kernelet tasks hammering one spin lock for ten seconds, under the full model, while two competitors in a sibling control group make Linux preempt the carriers constantly. It counts, in the preemption notifier, every time a carrier is switched out involuntarily, outside a service, while vOSTD’s mirrored flag says it is in a critical section. It counts by the flag rather than by the guard count because Linux’s count goes up just after the kernelet’s first guard and comes down just before its last one, and a switch in those few instructions is Linux’s right; since Linux’s count is raised whenever the flag is set, every switch the test counts is a real failure. With the mirror on, the test asserts that the count is zero. With it off (the module parameter klet_mirror=0, under which vOSTD still keeps the flag but skips the increment), the last run counted 2,025.

Problem 3: a critical section that never ends

The mirror lets a kernelet defer Linux’s preemption, so a kernel proper whose logic the tenant controls could defer it forever by entering a critical section and never leaving.

The watch timer bounds the deferral at two of its one-millisecond periods when ticks arrive on time, three when one is late, from interrupt context whatever the count. A tick that finds a carrier in kernelet code in a critical section with need_resched set notes the time; if the next tick finds the same, and at least a millisecond has passed since the note (now is the monotonic clock), it forces the yield. A tick that arrives late, because Linux had interrupts off or the machine under the test VM was busy, lengthens the grace, and a second strike that comes less than a millisecond after a late first one is skipped, so the grace can reach three ticks; the second strike is skipped whenever the first strike’s callback ran later than the second’s, which any jitter can cause.

In the callback, the bound replaces chapter 10’s last branch. Before it, a requested firing goes straight to again, and so does a tick that finds no need_resched, which first drops the note:

Simplified from prototype/linux/endovisor/watch.c: klet_watch_fn.

	/* a critical section, and Linux wants the processor */
	if (!c->strike1_ns) { c->strike1_ns = now; goto again; }     /* strike one */
	if (now - c->strike1_ns < NSEC_PER_MSEC) goto again;           /* less than a tick ago */
	c->strike1_ns = 0;
	c->yield_ip = regs->ip;
	c->forced = true;                                              /* strike two: yield anyway */
	regs->ip = (unsigned long)klet_yield_stub;

Nothing in the stub changes for a forced yield: the service body lifts the mirror for every service, so here, where the kernelet is inside a critical section, it takes the kernelet’s increment out of Linux’s count before schedule() and puts it back after.

The count of strikes restarts whenever the carrier stops deferring Linux: both strikes must come from ticks that each find the carrier holding Linux off, with no tick in between that found otherwise. The note of the first strike is dropped whenever a tick finds the carrier out of a critical section or Linux no longer wanting the processor, and when the carrier leaves the processor, since a carrier that has given the processor back is deferring nothing.

make sched-coop’s third boot measures the bound. It adds an overstayer, a task that holds a preemption guard for about 8 ms at a time (nominally 10). It was forced to yield over a thousand times, and its longest deferral, measured from the first strike, was about 2 ms, against the 4 ms the test allows (measured on the booted prototype).

Time a kernelet keeps the processor past Linux’s wish delays its neighbors but does not shrink their share. Linux accounts the extra run time to the sandbox’s control group like any other. make sched-fair boots the sandbox three times, with 1 kernelet task, with 50, and with 50 plus an overstayer, each against one competitor of equal weight in a sibling group on the same two processors, so the competitor’s due is one processor. Its share was 0.994 and 0.995 of a processor in the boots without the overstayer; with it, the only boot with forced yields, 0.884 in the last bench and 0.986 and 0.990 in two reruns, a variance the last chapter discusses. The test asserts a spread under 3 % and the last bench failed it (measured on the booted prototype). What a neighbor can lose is at most the grace, per preemption.

Forced yields are counted and reported in the kernel log only when the endovisor unloads, since a print from the timer would lengthen the very deferrals it reports, so the operator sees after the fact which carriers, by process identifier and virtual CPU, collected them.

The rule and the bound, together:

flowchart TB
    subgraph rule["the rule"]
        direction TB
        a["kernelet takes a lock:<br/>guards 1, Linux's count +1"] --> b["Linux marks the carrier<br/>need_resched, and waits"]
        b --> c["kernelet drops the lock:<br/>count reaches 0 and Linux wants the processor"]
        c --> d["vcpu_yield: schedule()"]
    end
    subgraph bound["the bound"]
        direction TB
        e["tick: critical and need_resched<br/>(strike one)"] --> f["tick, 1 ms later:<br/>still so (strike two)"]
        f --> g["forced yield:<br/>lift the mirror, schedule(), restore"]
    end
    b -- "still waiting at the next tick" --> e

The backstop: do not spin on an absent lock holder

A spin lock that keeps failing may be waiting for a lock holder that is off its processor: after a forced yield, or while the lock holder’s carrier is in a service it called under the lock, asleep or preempted. After a thousand failed attempts the lock says so with the service vcpu_on_spin, and keeps spinning.

Linux: yield_to(). yield_to() asks the scheduler to run a named runnable task next in place of the caller, a hint the scheduler may decline. With the scheduler’s default features it pushes the caller’s deadline back, if other tasks share its run queue, and reschedules; it does not make Linux pick the named task. Its second argument says whether to reschedule the named task’s processor as well; KVM passes true. So with the argument false, the call only gives up the caller’s own turn.

The endovisor answers the hint with a directed yield in the shape of KVM’s, kept cheap. It applies a limit of one call per jiffy, Linux’s own scheduler tick (4 ms in this build, HZ=250), per virtual CPU. It picks the first sibling carrier of the same sandbox that is runnable but not running and passes it to yield_to() with the second argument false, and failing that calls cond_resched(), which gives the processor up only if Linux already wants it, and under the full model does nothing.

Simplified from prototype/vostd/src/sync/spin.rs: acquire_lock.

fn acquire_lock(&self) {
    let mut spins: u32 = 0;
    while !self.try_acquire_lock() {
        core::hint::spin_loop();
        spins += 1;
        if spins == SPINS_BEFORE_ON_SPIN {       // 1000
            spins = 0;
            crate::vcpu::on_spin();              // "my lock holder may not be running"
        }
    }
}

The lock is correct without the service: whether or not the endovisor finds a sibling to run, the loop goes on trying. A lock holder asleep in a service is not runnable, so for it the hint finds nothing.

A spinning waiter holds Linux off like any critical section, because it spins with its lock’s guard held and Linux’s count raised. At most one vcpu_on_spin call per jiffy can yield, so in practice the forced yield takes the processor first, within two or three ticks.

No test of this book exercises the path; in every make sched-coop boot, the counters of spins that called the service and of calls to yield_to() were zero.

Recap. On a Linux that does not preempt kernel code, the watch timer gives the processor up for a carrier that Linux wants, through the upcall when it can and otherwise through a yield stub, which saves everything, reschedules on the Linux stack, and resumes the kernelet exactly where it was. To keep Linux from preempting a carrier inside a critical section, vOSTD mirrors whether the kernelet is in one into Linux’s own preemption count, as one increment, with the same instruction preempt_disable() uses. It yields the moment the section ends if Linux was waiting. Every service lifts the mirror for its duration so that it may sleep. A section that outlasts two ticks while Linux waits, three if a tick arrives late, is forced to yield. A waiter that spins too long tells the endovisor, which gives up the waiter’s turn with yield_to, naming a sibling that Linux may ignore, or else calls cond_resched(); the lock is correct without it.

Stopping a kernelet

However a kernelet ends, every carrier stops, everything the kernelet held goes back, and Linux is left exactly as it was. A kernelet ends in one of three ways: its kernel asks to, by powering off; its kernel fails, with a panic nothing catches; or the host decides, because the operator killed it. The hard case is a carrier that is running kernelet code and will not come out.

The shape of a death

  1. The kernelet is marked stopped or dying, which is a single store.
  2. Every carrier is stopped, at a point where it holds nothing of Linux’s.
  3. When no carrier is left, the sandbox has exited: what the kernelet held goes back and the holder can read the exit record from the sandbox descriptor.

A kernelet’s state is abandoned, not tidied: nothing is unwound and no destructor runs, because tidying would mean running the tenant’s kernel on the host’s behalf at the moment it is least trusted. Once a carrier has left the image for good, no code from it runs on that carrier again, and the endovisor never runs kernelet code on the host’s behalf. What the endovisor gave the kernelet it takes back as a whole, without looking inside: the address spaces, every heap block the kernelet still held, which the endovisor tracks in a table of its own, and the pool of kernelet stacks.

Stopping from inside

Both the kernel proper’s poweroff and a panic in the kernelet end in the service stop, which marks the instance dying and kills every carrier, its caller included. On the endovisor’s side it records the exit code for the holder to read from the sandbox descriptor, marks the instance as dying, sends SIGKILL to every carrier, the caller included, and arms the eviction sweep of the end of this chapter; then the calling carrier leaves its kernelet stack for the last time.

Simplified from prototype/linux/endovisor/carrier.c: klet_stop_instance, klet_svc_stop, klet_carrier_die.

void klet_stop_instance(struct klet_inst *inst, u64 code)
{
	inst->stopped = true;
	pr_info("kernelet: stopped, exit code %llu\n", code);
	klet_end(inst, KLET_END_EXITED, code, 0);    /* every end of a sandbox goes through klet_end: the next section shows it */
}

static void klet_svc_stop(u64 code)              /* the service, called by the kernelet */
{
	klet_svc_enter(KLET_SVC_STOP, code, 0, 0);   /* klet_stop_instance, on the Linux stack */
	for (;;)
		klet_leave(c, KLET_DEAD);                /* abandon this kernelet stack for good */
}

static void klet_carrier_die(struct klet_carrier *c)
{
	klet_detach(c);                              /* current->carrier_hooks = NULL; free the stack and the records */
	if (!fatal_signal_pending(current))
		force_sig(SIGKILL);                      /* a user task can only end by Linux's own exit path */
}

A carrier that leaves for good dies as any process does, by a fatal signal Linux acts on. klet_leave with the state DEAD switches to the Linux stack and lands, as always, inside whichever hook entered the kernelet. That hook sees the state and calls klet_carrier_die, which clears the task’s hooks pointer and lets Linux kill it. Linux offers a module no direct way to end a task that is not a kernel thread, so a carrier ends as any process does, by a fatal signal acted on in the exit loop; clearing it first keeps the hooks from running on a task that no longer carries anything.

Where each carrier is at the moment of the mark decides what stops it:

the carrier iswhat stops it
in user modeLinux’s signal handling: the carrier enters the exit loop, the resume hook sees the mark and dies
asleep in a service (vcpu_idle, or vm_wait, where a memory service waits for a lock)the sleep is killable, so SIGKILL wakes it; on the way back, the service body finds the signal pending and dooms the carrier, and the stub leaves for good instead of returning into kernelet code
running a servicethe same: the service completes, and the stub leaves for good
running kernelet codenothing in Linux’s exit path can reach it: eviction, below

Stopping from outside

A carrier killed from outside is noticed through its lifeline, which Linux closes when the carrier’s process exits. The operator kills a carrier, or the out-of-memory killer does, or the holder does it (make probe, at the end of this chapter): kill(pid, SIGKILL) on a carrier’s process id. The carrier dies in Linux’s exit path if the signal finds it in user mode, and Linux closes its descriptors, among them its lifeline; where Linux’s exit path cannot reach it, the endovisor looks for the signal itself, as the end of this section shows. The lifeline’s release is the endovisor’s notice:

Simplified from prototype/linux/endovisor/carrier.c: klet_carrier_lifeline_closed.

void klet_carrier_lifeline_closed(struct klet_carrier *c)
{
	/* release what a carrier killed in user mode never detached: its stack, its empty address space */
	list_del_init(&c->link);
	live = !inst->dying;                         /* the sandbox was running when this carrier died */
	last = atomic_dec_and_test(&inst->nr_carriers);
	if (!live)
		klet_check_exited_locked(inst, &claim);  /* the last carrier of a dying sandbox: its exit is this caller's to run */
	if (live)
		klet_end(inst, KLET_END_KILLED, 0, KLET_KILLED_CARRIER_LOST);   /* a carrier's death is the sandbox's death */
	else if (claim)
		klet_sandbox_exited(inst);               // Release the address spaces, heap blocks and stack pool; wake exit-record readers.
	if (c->task == current) {                    /* undo its attachment, which only its own task may do */
		current->carrier_hooks = NULL;
		klet_sched_detach(c);
	}
	if (!c->pnotifier_on && c->task->carrier_hooks != &c->hooks)
		klet_carrier_put(c);                     /* the carrier's own reference; the lifeline file holds the other */
	else
		pr_warn("kernelet: carrier record released away from its task; kept\n");
}

Simplified from prototype/linux/endovisor/sandbox.c: klet_end.

void klet_end(struct klet_inst *inst, u32 how, s32 code, u32 reason)
{
	if (!inst->exit.how) {                       // The first end records the final exit reason.
		inst->exit.how = how;
		inst->exit.code = code;
		inst->exit.reason = reason;
	}
	first_mark = !inst->dying;                   /* a test-and-set under the lock */
	inst->dying = true;
	klet_check_exited_locked(inst, &claim);      /* no carrier left? then the sandbox has exited already */
	if (first_mark) {
		klet_kill_carriers(inst);                /* SIGKILL to every carrier, and every sleep of theirs ended */
		klet_evict_arm(inst);                    /* for whoever is inside kernelet code */
	}
	if (claim)
		klet_sandbox_exited(inst);
}

Any carrier’s death while the sandbox is running ends it: a sandbox that had lost a virtual CPU would otherwise run on, with the others spinning on whatever locks the lost one held.

Every end of a sandbox goes through klet_end, which records why, marks it dying, kills every carrier and arms the eviction sweep. The sweep is armed even for the kernelet’s own stop, for a reason the next section makes clear: a virtual CPU that the stop left spinning in kernel mode never passes through a hook again to die, and only eviction gets it out.

The last carrier’s death is the sandbox’s exit. It releases what the kernelet held, makes the sandbox descriptor readable, and wakes the holder waiting for its exit record.

A carrier killed in user mode before any mark never detaches, so its lifeline’s release is where its records are freed and the sandbox is told to die. Such a carrier passes through the resume hook once more, since the signal counts as pending work, but the hook, finding no mark and (usually) no virtual interrupt pending, steps aside; Linux kills the task, and klet_detach never runs for it. (With a tick pending, the hook enters the kernelet, a service dooms the carrier, and it dies by the route of the next paragraph instead.) The lifeline’s release frees what the detach would have: this is why the death notice, not the detach, is the place where a carrier’s records are finally released, and, since the sandbox was running, where the rest of the sandbox is told to die.

A carrier’s record is freed only when nothing can still reach it through its task. The record holds two references, the carrier’s own, dropped here, and the lifeline file’s, dropped when the file is released, and goes when both are gone. The carrier’s own is dropped only if its task no longer points at the record: a lifeline released by some other task, through a copy of the descriptor, would leave the dying task’s hooks pointer and preemption notifier aimed at freed memory, so such a record is kept instead, with a warning; the endovisor counts records made and freed at unload, and every test checks that none is left.

Killed anywhere else, the carrier is out of Linux’s reach, since Linux acts on SIGKILL only on the way to user mode, and the endovisor has to notice the signal itself. Every service body, on its way back to the kernelet stack, tests for a pending fatal signal. If it finds one, it marks the sandbox dying, which kills the others, and marks the carrier doomed, and the stub then leaves the kernelet for good, through klet_leave, instead of returning into it. That covers a carrier asleep in vcpu_idle or vm_wait, whose sleeps are killable and so end at once, and one in the middle of any other service. A carrier running kernelet code and calling no service is caught by the watch timer of chapter 10: its callback, finding a fatal signal pending on a carrier, points the interrupted instruction at the eviction sweep’s exit stub (below), exactly as the sweep does, and the carrier’s death then ends the sandbox through its lifeline. With the watch timer turned off, such a carrier stays out of reach until it next calls a service.

Eviction

Linux: signals and kernel mode. Linux acts on a signal, even SIGKILL, only when the task is about to return to user mode. A task that stays in kernel mode is never killed by a signal, however long it stays. Linux’s own kernel code is written to come out: it sleeps in killable waits and passes voluntary preemption points. Code that Linux did not write does neither.

A carrier looping in kernelet code can be stopped only by force. Say it loops in a loop {} reached through a logic bug a tenant found: it never returns to user mode, Linux by itself would never kill it, and the sandbox could never be destroyed. The endovisor has the means from chapter 10: a timer interrupt on the carrier’s processor, and the right to change the interrupted instruction pointer.

The eviction sweep is one hrtimer per processor, pinned and hard like the watch timer, armed on every processor when a kernelet is marked dying, and re-armed every millisecond until no dying sandbox has a carrier left. Its callback checks four things about what it interrupted: that the task is a carrier of a dying kernelet, that the interrupted context is kernel mode, that the interrupted instruction lies inside the kernelet image’s text, and that the carrier’s service-call depth is zero. If all four hold, it overwrites the saved instruction pointer with the address of an exit stub, and nothing else.

Simplified from prototype/linux/endovisor/evict.c: klet_sweep.

static enum hrtimer_restart klet_sweep(struct hrtimer *timer)
{
	struct pt_regs *regs = get_irq_regs();

	if (atomic_read(&klet_sweeping) == 0)           /* no dying sandbox has a carrier left */
		return HRTIMER_NORESTART;
	if (!regs || user_mode(regs) || !klet_in_image_text(regs->ip) || !current->carrier_hooks)
		goto again;
	c = klet_current_carrier();
	if (!inst->dying || c->depth != 0 || c->evicted || c->state != KLET_RUNNING)
		goto again;

	c->evicted = true;
	regs->ip = (unsigned long)klet_exit_stub;    /* the only register the endovisor touches */
again:
	hrtimer_forward_now(timer, ns_to_ktime(KLET_SWEEP_INTERVAL_NS));
	return HRTIMER_RESTART;
}

The interrupt then returns to the stub instead of the loop. The stub realigns the stack pointer, because an interrupt can land between any two instructions, and leaves through the ordinary stack switch, never to come back:

Simplified from prototype/linux/endovisor/switch.S and prototype/linux/endovisor/carrier.c: klet_exit_stub, klet_evict_leave.

klet_exit_stub:
	andq	$-16, %rsp
	call	klet_evict_leave
	ud2                        /* never reached */
void klet_evict_leave(void)
{
	struct klet_carrier *c = klet_current_carrier();
	c->evicted = true;
	for (;;)
		klet_leave(c, KLET_DEAD);   /* to the Linux stack; the hook there calls klet_carrier_die */
}
flowchart TB
    a["carrier in kernelet code<br/>loop {} at depth 0"] -- "timer interrupt" --> b["klet_sweep:<br/>dying? kernel mode? ip in the image? depth 0?"]
    b -- "all four" --> c["saved ip := klet_exit_stub"]
    c -- "interrupt returns" --> d["klet_exit_stub: realign,<br/>klet_evict_leave: klet_leave(DEAD)"]
    d --> e["the hook on the Linux stack:<br/>klet_carrier_die, SIGKILL"]

Why this is safe to do at an arbitrary instruction: at depth 0, in kernelet text, a carrier holds nothing of Linux’s. It cannot hold a Linux lock, because kernelet code cannot call Linux; the only thing it may hold is the one increment of Linux’s preemption count from chapter 11, and klet_leave with the state DEAD runs klet_mirror_out with forever set, which measures and removes it. The kernelet’s own half-finished state, its locks and its data, does not matter: the kernelet is dying, and no kernelet code runs afterwards. Kernelet code never masks hardware interrupts: the kernel proper is safe Rust, and vOSTD, the only code beneath it, has no interface that would; so there is no state in which the timer cannot land.

The sweep is needed only for a carrier that was in kernelet code; every other carrier is caught on another path. A carrier that is not on a processor at that instant is interrupted by nothing; it is caught by the sweep when it next runs, since the sweep is on every processor. A carrier inside a service call is left alone by the depth test, and finishes the service; by then the SIGKILL the mark sent is pending on it, so the service body dooms it and the stub leaves for good, as the previous section said.

The test

make probe boots one probe kernel three times and ends its sandbox a different way each time. The kernel, in prototype/kernels/probe/src/lib.rs, has a handler for system call number 1000 that is loop {}. In the first boot the holder waits a second and then sends SIGKILL to the carrier of virtual CPU 0, which is spinning in kernelet code; the log shows the watch timer finding the signal on it and killing it there, its lifeline closing and ending the sandbox, and the module unloading with its reference count at zero. In the second boot the tenant runs under a seccomp filter that kills on its endless system call, and the sandbox ends by that signal. In the third the holder asks the endovisor instead, with KLET_KILL: the log then shows the sweep being armed and the spinning carrier evicted by an early sweep, at an address the endovisor places a few kilobytes into the kernelet image’s text. The build checks those lines and that Linux printed no warning, a check that means something only because the Linux the tests boot is built with CONFIG_BUG on, which the minimal configuration it starts from turns off.

The same test exercises the other mechanisms of chapter 8 that the hundred-line kernel never reaches: a demand-paging fault, which the next chapter builds, an illegal instruction (ud2, twice, so that the drain’s re-ignoring of chapter 8 is exercised), a breakpoint (int3) and a privileged instruction (hlt), the last two being exceptions Linux reports with SI_KERNEL; the kernel proper steps past the three instructions, and the test reads the carrier’s signal dispositions back and checks that everything but SIGKILL and SIGSTOP is ignored. The tenant also asks for a 4 MiB write, which the kernel proper refuses for want of memory rather than letting the host warn.

One of the memory tests, make mem-kill, kills a virtual CPU’s carrier while its sibling is taking page faults in the same address space, and checks that the whole sandbox ends cleanly, with the module’s reference count at zero.

The same test checks that the signals chapter 8 discards never reach the carrier. On the way it sends that carrier SIGTERM and SIGUSR1, and then two thousand rounds of SIGQUIT, SIGABRT, SIGTSTP, SIGTTIN and SIGTTOU, which chapter 8 said are discarded, and checks that it lives and that none of them reached its queue.

Two more boots of the same test kill the carrier where Linux by itself would never act on the signal: once while it is idle in vcpu_idle with nothing to run, and once while it runs a kernel task that loops and calls no service. The log must show the sandbox ending within a bounded time after the kill, the endovisor’s counter saying which path caught it, no stall report from Linux, and a clean unload.

Recap. A kernelet ends by the stop service or by the death of any of its carriers; either marks the instance and sends SIGKILL to every carrier. A carrier in user mode dies by Linux’s own exit path, after a hook sees the mark and detaches it; one asleep in a killable service is woken and leaves for good on its way back, as does one that was running a service. A carrier running kernelet code is evicted: a timer on its processor finds the interrupted instruction in the image’s text at depth 0 and points it at an exit stub, which leaves through the stack switch for good. The last lifeline’s release is the sandbox’s end, and returns everything the kernelet held.

Tenant memory on Linux’s page tables

This chapter and the next are what the book has been building toward. The hundred-line kernel’s VmSpace::new(), cursor.map(frame, prop) and vm_space.activate() are the kernel proper’s whole view of a tenant’s memory, and they are the same calls a kernel makes on a machine. What they do inside Linux is the central mechanism of the design.

The problem

On a machine, a VmSpace is a page table. The kernel proper writes its entries, through OSTD’s cursor, and the processor walks it when the tenant’s program runs. OSTD’s job is to write the entries in hardware format and load the table’s root into the page-table register at activate.

Inside Linux, none of that is available. A carrier runs on the page table of its Linux address space; the processor walks that table and no other; and only Linux writes it, under its own locks, with its own bookkeeping of what every entry means. vOSTD cannot write a Linux page-table entry, and it cannot make the processor walk a table of its own.

So the design turns the relationship around. A VmSpace is a Linux address space. Its page table is the tenant process’s Linux page table, the one the processor walks; there is no other. vOSTD never writes an entry of it. Every change the kernel proper’s cursor makes becomes a memory service, in which the endovisor checks what it is asked for against the kernelet’s grant and then makes the change with Linux’s own functions, under Linux’s own locks. vOSTD reads the table, through the direct map, as a processor would; that is the next chapter.

flowchart TB
    subgraph K["the kernelet"]
        kp["kernel proper:<br/>cursor.map / unmap / protect_next, flusher"] --> v["vOSTD: VmSpace<br/>never writes an entry"]
    end
    v -- "service calls:<br/>vm_map · vm_unmap · vm_protect · vm_flush" --> e["endovisor, inside the service:<br/>check the frame against the grant,<br/>then call Linux"]
    e -- "vmf_insert_pfn_prot · zap_vma_ptes ·<br/>apply_to_page_range · flush_tlb_mm_range" --> pt["the tenant process's Linux page table<br/>(the only one)"]
    u["tenant program, user mode"] -- "the processor walks it" --> pt
    v -. "reads it through the direct map (next chapter)" .-> pt

That is a stronger position than the one a kernel has on a machine, where a bug in the framework could map any frame into a tenant. Here, no translation reaches the hardware without trusted host code having checked the frame.

Linux: address spaces, areas, and page faults. A process’s user memory is described by an address space, struct mm_struct, which holds the page table’s root (mm->pgd) and a set of virtual memory areas (VMAs): contiguous ranges with uniform permissions, each optionally backed by a file.

Linux fills page tables lazily: touching an address with no entry raises a page fault, Linux finds the VMA, and asks it for the page, by calling the fault function of the VMA’s operations table (struct vm_operations_struct). A driver that implements mmap on its device file supplies those operations, and so decides what appears at each address of its area.

A VMA can be marked as holding raw frame numbers (VM_PFNMAP). Linux then maps exactly the frames it is told to, with vmf_insert_pfn_prot(), and takes no further interest in them: no reference counting, no swapping, no migration. The driver removes entries again with zap_vma_ptes(). This is how device drivers give a process a window onto their memory, and it is the right contract here, because the frames’ lifetime belongs to the kernelet.

Every change to an address space is made under its lock, mmap_lock. An address space is freed, page tables included, in exit_mmap() when its last user lets go; a user reference, taken with mmget() and dropped with mmput(), is what keeps that from happening.

Creating a space

VmSpace::new() is the service vm_create. The endovisor makes three things: an empty Linux address space, a file to own the area, and the area itself, one VMA over the whole user range.

Simplified from prototype/linux/endovisor/space.c: klet_do_vm_create, klet_space_make_area, klet_space_mmap.

s64 klet_do_vm_create(struct klet_carrier *c)
{
	sp = a free slot in inst->spaces[];                       /* at most 64 spaces per kernelet */
	sp->mm = kernelet_mm_alloc(inst->owner);                  /* an empty address space; owner: the holder's main thread */
	sp->file = anon_inode_create_getfile("[kernelet-space]", &klet_space_fops, sp, O_RDWR, NULL);
	klet_space_make_area(sp);
	WRITE_ONCE(sp->rec->root_paddr, __pa(sp->mm->pgd));       /* so that vOSTD can read the table */
	sp->live = true;
	return sp->id;                                             /* 1-based; 0 means "no space" */
}

static int klet_space_make_area(struct klet_space *sp)
{
	/* vm_mmap() maps into the *calling* task's address space, so adopt the new one for a moment */
	mmget(sp->mm);
	prev = kernelet_switch_mm(sp->mm);
	ret = vm_mmap(sp->file, KLET_AREA_START, KLET_AREA_END - KLET_AREA_START,    /* [0x10000, 2^47 - 4 KiB) */
		      PROT_READ | PROT_WRITE | PROT_EXEC, MAP_SHARED | MAP_FIXED, KLET_AREA_START);
	back = kernelet_switch_mm(prev);                           /* and switch back */
	mmput(back);
	sp->start = KLET_AREA_START;
	sp->end = KLET_AREA_END;
	return 0;
}

static int klet_space_mmap(struct file *file, struct vm_area_struct *vma)   /* the file's mmap: sets up the area */
{
	vm_flags_set(vma, VM_PFNMAP | VM_IO | VM_DONTEXPAND | VM_DONTDUMP | VM_DONTCOPY);
	vma->vm_ops = &klet_space_vm_ops;                          /* fault and pfn_mkwrite, below */
	return 0;
}

Four choices in this code each close a hole.

The owner. Each space’s owner is the holder’s main thread, never the carrier that created it. Linux records in each address space an owner task, through which the memory controller finds the control group to charge faults and reclaim to. The function Linux uses to make an address space would store the creating task there, without a reference, and that task is a carrier that may die while the space lives on; every later fault would then read a freed task. So the patch’s kernelet_mm_alloc() takes the owner as an argument, and the endovisor passes the main thread of the holder, the process that created the sandbox, which the endovisor pins with a task reference from the sandbox’s creation to its exit, after it is cleared from every space.

The file’s inode. Each space gets an inode of its own. The plain anonymous-inode helper hands every caller one shared inode, and Linux keeps, per inode, the list of areas that map it, so every space’s area would sit in one list, and an operation on one space’s file could reach the others’.

The area’s flags. Two of the area’s flags are there for the host’s sake. VM_SHARED, which the MAP_SHARED in the vm_mmap call sets, is required for the host’s sake: inserting a raw frame number into a private, copy-on-write area is a BUG_ON in Linux, an assertion whose failure (an oops) kills the calling task with the address-space lock held, and halts the machine if the host is configured to panic on an oops, rather than returning an error. VM_DONTCOPY keeps a fork of a carrier while it is in a tenant’s space, which never happens but could, from copying entries that belong to the kernelet.

The area’s extent. The area covers exactly the range the kernel proper can map, so that a vm_map outside it can be refused, and zap_vma_ptes(), which silently does nothing for a range not wholly inside its area, is always inside.

The address-space switch

A carrier must run in the address space of the process whose thread it carries, and kernelet_switch_mm(), the first of the patch’s two helpers, is how it changes space. A carrier is born with an empty address space of its own; to run a tenant thread it must run in that process’s space. Two of the lines above call the helper, and both hooks call it, through klet_space_adopt below, before they apply the registers on a trip to user mode in which the virtual CPU has activated a different process than the carrier is in (chapter 9).

Linux: changing a task’s address space. Linux switches a task’s address space in two places, neither usable by a user task, and a module cannot write a third. Linux has the operation twice: for a kernel thread that borrows a user address space (kthread_use_mm()), and for execve (exec_mmap()), which replaces a process’s address space with a fresh one. Neither will do for a user task that wants to swap between address spaces it holds references to: the first warns if its caller is not a kernel thread and takes the references a kernel thread needs, which a user task must not, and the second is private to exec and drops the task’s reference to the old space. And a module cannot write its own, because everything the switch touches is private to the core kernel.

The helper does what the two have in common: under the task’s lock and with interrupts off, set the task’s mm and active_mm to the new space, load the new page table with the architecture’s switch_mm_irqs_off(), and keep Linux’s per-space bookkeeping in step. It consumes the caller’s reference on the new space and hands back the old one, for the caller to drop in a context that may sleep.

Simplified from prototype/linux/patches/0001-carrier-hooks.patch: kernelet_switch_mm, kernelet_mm_alloc.

struct mm_struct *kernelet_switch_mm(struct mm_struct *mm)
{
	struct task_struct *tsk = current;
	struct mm_struct *old_mm;

	if (tsk->mm == mm)
		return mm;
	sched_mm_cid_before_execve(tsk);      /* release the old space's concurrency id ... */
	task_lock(tsk);
	local_irq_disable();
	old_mm = tsk->active_mm;
	tsk->active_mm = mm;
	tsk->mm = mm;
	membarrier_update_current_mm(mm);
	switch_mm_irqs_off(old_mm, mm, tsk);  /* load the page table */
	local_irq_enable();
	task_unlock(tsk);
	lru_gen_use_mm(mm);
	sched_mm_cid_after_execve(tsk);       /* ... and take one in the new */
	return old_mm;
}

struct mm_struct *kernelet_mm_alloc(struct task_struct *owner)
{
	struct mm_struct *mm = mm_alloc();
	WRITE_ONCE(mm->owner, owner);
	lru_gen_add_mm(mm);                   /* once, here; never per switch */
	return mm;
}

The two lru_gen_* calls are for Linux’s multi-generational page reclaim, which keeps a list of the address spaces it may scan: a new space is put on it once, and a switch marks the space as in use.

The two sched_mm_cid_* brackets are not optional. Linux gives each address space a small pool of concurrency ids, one per processor currently running one of its tasks, which programs may use to index per-processor data; a task that changes address space must return its id to the old space and take one from the new. Without the brackets a carrier would leak one concurrency id per switch, invisibly, until the pool ran dry and the scheduler spun forever under a run-queue lock. That is the kind of bookkeeping that makes the helper belong in Linux rather than in a module.

Simplified from prototype/linux/endovisor/space.c: klet_space_adopt.

int klet_space_adopt(struct klet_carrier *c, u64 id)       /* called by both hooks before user mode */
{
	sp = klet_space_get(c->inst, id);                      /* the space must be this kernelet's */
	if (current->mm != sp->mm) {
		mmget(sp->mm);                                    /* a reference for the helper to consume */
		old = kernelet_switch_mm(sp->mm);
		mmput(old);                                       /* the one it handed back */
		if (c->blank_mm) { mmput(c->blank_mm); c->blank_mm = NULL; }   /* the empty one is never needed again */
	}
	klet_space_put(sp);
	return 0;
}

Every carrier holds one reference on the space it is in, which the endovisor drops when the carrier leaves it and Linux when the carrier exits; the endovisor holds one more on every live space, from vm_create to vm_destroy. That second reference is what pins the page tables for the next chapter’s lockless reads.

The four edits

Everything the kernel proper’s cursor does to a space is one of four services. Each of the first three takes the address space’s lock, finds the area afresh (the discipline Linux’s own code follows, rather than trusting a stored pointer), checks its arguments, and calls one Linux function.

vm_map(space, va, paddr, prot). The protection crosses as three bits, readable, writable, executable; the endovisor builds Linux’s page protection from its own constants and never from bits the kernelet supplies, because one such bit, global, would make a translation visible to every process on the machine.

Simplified from prototype/linux/endovisor/space.c: klet_prot_to_pgprot, klet_space_insert, klet_do_vm_map.

static int klet_prot_to_pgprot(u32 prot, pgprot_t *out)
{
	unsigned long vm_flags = VM_SHARED | VM_READ;
	if (!(prot & KLET_PROT_READ)) return -KLET_INVALID;     /* a page without read permission has no entry: next chapter */
	if (prot & KLET_PROT_WRITE) vm_flags |= VM_WRITE;
	if (prot & KLET_PROT_EXEC)  vm_flags |= VM_EXEC;
	*out = vm_get_page_prot(vm_flags);                         /* Linux's own idea of a user page with these rights */
	return 0;
}

static s64 klet_space_insert(struct klet_space *sp, struct vm_area_struct *vma, u64 va, u64 paddr, pgprot_t pgprot)
{
	if (!klet_in_grant(sp->inst, paddr, PAGE_SIZE))            /* THE check: the frame must be the kernelet's */
		return -KLET_NOT_OWNED;
	if (va < sp->start || va >= sp->end)
		return -KLET_INVALID;
	ret = vmf_insert_pfn_prot(vma, va, paddr >> PAGE_SHIFT, pgprot);
	if (ret & VM_FAULT_OOM) return -KLET_LIMIT;                /* Linux could not allocate a page-table page */
	/* insert_pfn() leaves an existing entry alone and still reports success, so look: */
	if (!slot_holds(sp->mm, va, paddr)) return -KLET_INVALID;  /* a vOSTD bug: two maps of one page */
	return 0;
}

s64 klet_do_vm_map(struct klet_carrier *c, u64 id, u64 va, u64 paddr, u32 prot)
{
	klet_prot_to_pgprot(prot, &pgprot);
	sp = klet_space_get(c->inst, id);
	vma = klet_space_lock_area(sp);                            /* mmap_read_lock, then vma_lookup */
	err = klet_space_insert(sp, vma, va, paddr, pgprot);
	mmap_read_unlock(sp->mm);
	klet_space_put(sp);
	return err;
}

vmf_insert_pfn_prot() may sleep, when a page-table page must be allocated; the next chapter says why that is allowed although the kernel proper calls map under a preemption guard.

vm_unmap(space, va, len) clips the range to the area and calls zap_vma_ptes(), which clears every present entry and flushes the TLB before it returns, through Linux’s own gather-and-finish. It reports no count; the number of pages that were mapped is vOSTD’s own, from a walk it makes before the call.

vm_protect(space, va, prot) rewrites one present entry in place. Linux’s helpers for taking a page-table lock are not exported to modules, so the endovisor uses apply_to_page_range(), which is exported and takes that lock itself, here over the one entry, and in its callback replaces the entry with one atomic compare-and-exchange:

Simplified from prototype/linux/endovisor/space.c: klet_pte_protect.

static int klet_pte_protect(pte_t *pte, unsigned long addr, void *data)
{
	pte_t old = ptep_get(pte), new;
	if (!pte_present(old))
		return -EINVAL;
	do {
		new = pte_modify(old, ctx->prot);      /* keeps the frame number, the special bit, and the accessed and dirty bits */
	} while (!try_cmpxchg64((u64 *)&pte->pte, (u64 *)&old.pte, pte_val(new)));
	return 0;
}

pte_modify() matters: an entry rebuilt from the frame number would lose the special bit that marks a raw-frame-number entry, and the next zap would then treat a grant frame as a Linux page and drop a reference it never took.

The rewrite is one store, so a lockless reader on another virtual CPU never sees the entry absent. It does not flush; the kernel proper issues its flushes separately, as on a machine.

vm_flush(space, va, len) is flush_tlb_mm_range() over the range, or over the whole space, on every processor where the address space is loaded, which switch_mm_irqs_off() keeps track of. It is not exported in a stock kernel, which is why the patch exports it. It never sleeps, and so may be called while the kernel proper still holds a cursor.

Two more services complete the set.

vm_activate(space) only records the space in the virtual CPU’s record, where the hooks of chapter 8 find it, and never sleeps, because a full kernel proper, such as Asterinas’s, calls activate from the handler it runs right after a task switch, with interrupts off, and vOSTD must allow for that.

vm_destroy(space) clears the whole area, waits for calls in flight, and drops the endovisor’s reference on the address space.

Faults

A tenant’s fault on the area reaches an endovisor handler whose whole job is to report it to the kernel proper. The tenant touches an address with no entry, or writes a page whose entry is read-only. Linux calls the area’s fault for the first and pfn_mkwrite for the second, on the carrier, in kernel mode, with the address-space lock held; a write to a present read-only entry reaches the handler only because the area supplies pfn_mkwrite, and without it Linux would make the entry writable on its own authority. The handler has nothing to look up: Linux’s table is the only table, and a fault on this area means the kernel proper has not mapped the page.

Simplified from prototype/linux/endovisor/space.c: klet_space_report.

static vm_fault_t klet_space_report(struct vm_fault *vmf, bool mkwrite)
{
	struct klet_carrier *c = klet_current_carrier();
	bool user = !!(vmf->flags & FAULT_FLAG_USER);

	if (!user)                                        /* Linux's own access to a tenant address (a get_user) */
		return VM_FAULT_SIGBUS;                       /*   fails cleanly, as an -EFAULT in that code */

	c->exc_trap = X86_TRAP_PF;                        /* for the kernel proper: a page fault ... */
	c->exc_addr = vmf->address;                       /*   at this address ... */
	c->exc_error = (mkwrite ? 1 : 0) | (write ? 2 : 0) | 4 | (exec ? 16 : 0);   /*   with the processor's error-code bits: 1 present, 2 write, 4 user, 16 fetch */
	c->exc_pending = true;
	set_thread_flag(TIF_NOTIFY_RESUME);               /* run the resume hook on the way back */
	return VM_FAULT_NOPAGE;                           /* "handled": Linux returns to user mode without installing anything */
}

The handler cannot run the kernel proper where it stands, inside Linux’s fault path with Linux’s lock held. So it records the fault, flags the carrier, and tells Linux the fault is handled. Linux heads back to user mode to retry the instruction, passes through the exit loop, and the resume hook of chapter 8 enters the kernelet with user_run returning an exception. The kernel proper’s own fault handler runs as a normal task, free to sleep, maps the page with its ordinary cursor, which is a vm_map, and calls execute again. The retried instruction finds the entry where the processor looks and does not fault a second time.

sequenceDiagram
    participant U as tenant
    participant L as Linux
    participant E as endovisor
    participant K as kernel proper
    U->>L: page fault
    L->>E: vm_ops->fault
    E->>E: record it, flag the carrier
    E-->>L: VM_FAULT_NOPAGE
    L->>E: the resume hook
    E->>K: user_run returns 1
    K->>K: allocate a frame
    K->>E: vm_map
    K->>E: user_run again
    E->>L: ctx -> pt_regs
    L->>U: retry, no fault

One case never reaches the handler. An instruction fetch from a present entry marked no-execute raises SIGSEGV inside Linux directly (short of protection keys, which the design never sets, a present x86-64 entry cannot forbid a read), and the resume hook turns that signal into an exception as it does for any other (chapter 8).

A fault from Linux’s own kernel-mode access to tenant memory is failed, never reported, because no return to user mode would deliver it. Linux itself sometimes reads a task’s user memory from kernel mode, for instance on a 32-bit system-call entry. A handler that reported such a fault and waited for the return to user mode would wait forever, since the access is not in user mode; failing it cleanly lets Linux’s own error handling turn it into -EFAULT, which that code is written to expect.

What the kernel proper’s calls become

the kernel proper callsvOSTD doesthe endovisor does, in Linux
VmSpace::new()vm_createan address space, a file, the area; publishes the table’s root
cursor.map(frame, prop)vm_map; the frame’s reference passes to the entrygrant check, vmf_insert_pfn_prot()
cursor.unmap(len)reads the entries first (next chapter); vm_unmapzap_vma_ptes(), which flushes
cursor.protect_next(len, op)vm_protect for the one page, if the bits changedapply_to_page_range() with pte_modify(); no flush
flusher.dispatch_tlb_flush()vm_flush per rangeflush_tlb_mm_range()
vm_space.activate()vm_activaterecords it; the carrier adopts the space at the next user_run
a tenant page faultnothing until user_run returnsfault or pfn_mkwrite records and returns
the space is droppedvm_destroy, later (next chapter)zap everything, drop the reference

make probe shows the fault path: the probe kernel maps only its program’s text, the program stores to an unmapped address, and the log shows one Linux fault, the kernel proper’s handler mapping a fresh frame, and the program reading its data back. make mem-func, mem-r2, mem-conc and mem-pressure run the tests of the next chapter over the four edits.

Recap. A VmSpace is a Linux address space: an mm the endovisor creates with the patch’s kernelet_mm_alloc(), owned by the holder’s main thread, holding one raw-frame-number area over the whole user range, backed by a file with an inode of its own. The kernel proper’s cursor edits become vm_map, vm_unmap, vm_protect and vm_flush, in which the endovisor checks every frame against the grant and calls Linux’s own page-table functions under Linux’s own lock. A carrier adopts a process’s address space with the patch’s kernelet_switch_mm() on its way to user mode. A tenant’s fault is recorded by the area’s handler and delivered to the kernel proper through the resume hook, whose vm_map puts the entry where the retried instruction will find it.

What vOSTD adds on top

This chapter is about what vOSTD must do for the kernel proper that Linux does not, now that the last chapter has put the tenant’s page table in Linux’s hands: read the table, copy to and from the tenant’s memory, keep track of which frame each entry holds, wait for copies in progress before a frame is freed, lock a range for the kernel proper’s cursor, hold the one kind of page Linux cannot represent, dispose of an address space at a moment when sleeping is not allowed, and say when a service may sleep under a guard. Each of these is a few dozen lines. The walk, the copy, the frame count, the grace period and the withheld set are what keep the design sound; the rest keep the kernel proper correct or the host responsive.

Reading the table

vOSTD answers a cursor’s questions by reading Linux’s page table itself, through the direct map, from the root whose physical address the space’s record holds. The kernel proper asks a cursor what is at an address (query), where the next mapped page is (find_next), and how many pages an unmap removed; on a machine OSTD reads its own page table for these. It is the walk a processor does, in software, four dependent loads:

Simplified from prototype/vostd/src/mm/page_table.rs: read_entry, child, leaf_of, walk.

fn read_entry(node: Paddr, idx: usize) -> u64 {
    unsafe { read_volatile((paddr_to_vaddr(node) as *const u64).add(idx)) }   // one 64-bit load
}

fn child(e: u64) -> Option<Paddr> {                       // the next table down, if the entry points at one
    if e & PRESENT == 0 || e & HUGE != 0 { None } else { Some((e & ADDR_MASK) as Paddr) }
}

fn leaf_of(e: u64) -> Option<Leaf> {                      // a present, user-accessible page
    if e & PRESENT == 0 || e & USER == 0 { return None; }
    let mut flags = PageFlags::R;
    if e & WRITE != 0      { flags |= PageFlags::W; }
    if e & NO_EXECUTE == 0 { flags |= PageFlags::X; }
    Some(Leaf { paddr: (e & ADDR_MASK) as Paddr, flags })
}

pub(crate) fn walk(root: Paddr, va: Vaddr) -> Option<Leaf> {
    let mut node = root;
    for level in (2..=4).rev() {                          // four levels; vOSTD refuses a five-level host at start
        node = child(read_entry(node, index(va, level)))?;
    }
    leaf_of(read_entry(node, index(va, 1)))
}

The walk takes no lock, and two facts about Linux make that safe. First, a page the walk reads is never freed under it. Second, Linux writes an entry with a single 64-bit store and clears it with an atomic exchange, so a load never sees half an entry.

Linux frees an area’s page-table pages in four cases, and none applies here. (1) When an area is unmapped or moved, which only a system call to Linux does; the tenant’s system calls go to the kernel proper, not to Linux. (2) At exec, which never runs in this address space. (3) When khugepaged, Linux’s background thread that replaces a last-level table of 4 KiB entries with one 2 MiB huge entry, collapses a range, which it refuses for raw-frame-number areas. (4) In exit_mmap(), which the endovisor’s reference on the address space (the last chapter’s second reference) holds off. zap_vma_ptes() clears entries but leaves the tables.

The walk need not handle huge entries. The area holds only 4 KiB entries, since vm_map inserts one page at a time, so child treats a huge entry as absent.

Linux: page-table isolation. To blunt Meltdown, Linux gives each address space a second top-level page for user mode. Whenever it writes a user entry into mm->pgd, it writes the same entry into the second page, so both point at the same lower-level tables; below the top level there is one table, the one the last chapter described. In the kernel-mode copy only, Linux sets no-execute on those top-level user entries, so that a return to user mode on the wrong table faults. mm->pgd is the kernel-mode copy, and it is the root the endovisor publishes for vOSTD.

Permissions are taken from the leaf only, never combined down the levels. Linux makes every upper-level user entry readable, writable and user-accessible, so only the leaf restricts anything, with one exception: the no-execute bit on the kernel-mode table’s top-level user entries, which vOSTD reads. Combined down the levels, that bit would make every tenant page non-executable. The prototype’s Linux is built without the mitigation, so this case is argued, not tested.

Copying to and from the tenant

Linux: copying from user memory. Linux’s copy_from_user rests on two mechanisms. The processor feature SMAP, supervisor-mode access prevention, faults any kernel-mode access to a user page unless the kernel has opened a window for it with stac, which it closes again with clac. And the copying instruction is listed in the exception table, the list of instructions allowed to fault and where to resume, so that a fault inside it resumes at a fixup that returns an error; Linux keeps a table for its own code and for each module’s.

Inside Linux, vOSTD may not dereference a tenant address as OSTD does on a machine. On a machine OSTD dereferences the address and lets the fault handler catch a bad one; for example, the hundred-line kernel’s write handler reads the program’s buffer through vm_space.reader(addr, len). vOSTD could open an SMAP window and could list an assembly copy in an exception table, but two deeper obstacles remain. First, the address space the processor is in is not necessarily the one being copied, because a carrier adopts its virtual CPU’s space only on the way to user mode. Second, a kernel-mode fault in the area never reaches the kernel proper: the endovisor’s fault handler fails it (the last chapter’s -EFAULT), and Linux then resumes at the instruction’s exception-table fixup or, for an instruction with none, oopses the host, so the copy would fail where on a machine the kernel proper’s handler would have mapped the page.

Instead, a copy walks the page table from the tenant address to the frame, and copies through the frame’s direct-map alias, an ordinary kernel address.

On a bad address, vOSTD returns the page-fault error at once, where OSTD would first call the kernel proper’s own page-fault handler and retry. An unmapped or read-only address is a result of the walk, not a fault.

Returning the error is enough for kernels that map eagerly, not for one that maps on demand. Asterinas’s full kernel proper maps on demand, so before it can run as a kernelet vOSTD must call its handler outside the read section described below and walk again. This prototype does not do that.

Simplified from prototype/vostd/src/mm/io.rs: Region, resolve, copy_regions, copy_opaque.

enum Region {
    Kernel { cursor: usize, end: usize },                  // a kernel buffer
    User { root: Paddr, cursor: Vaddr, end: Vaddr },       // tenant memory, to be walked through `root`
}

impl Region {
    fn resolve(&self, write: bool) -> Option<(*mut u8, usize)> {   // the head of the region, as a kernel pointer
        match *self {
            Region::Kernel { cursor, end } => Some((cursor as *mut u8, end - cursor)),
            Region::User { root, cursor, end } => {
                let leaf = page_table::walk(root, cursor & !(PAGE_SIZE - 1))?;    // None: a page fault
                if write && !leaf.flags.contains(PageFlags::W) { return None; }
                let offset = cursor % PAGE_SIZE;
                let len = (PAGE_SIZE - offset).min(end - cursor);               // up to the end of this page
                Some(((paddr_to_vaddr(leaf.paddr) + offset) as *mut u8, len))
            }
        }
    }
}

fn copy_regions(dst: &mut Region, src: &mut Region) -> (usize, bool) {
    while copied < total {
        let done = rcu::read(|| {                                      // run inside a read section (below)
            let (dptr, dlen) = dst.resolve(true)?;
            let (sptr, slen) = src.resolve(false)?;
            let n = dlen.min(slen).min(total - copied);
            unsafe { copy_opaque(dptr, sptr, n) };
            Some(n)
        });
        let Some(n) = done else { return (copied, false) };            // a fault: how far we got
        dst.advance(n); src.advance(n); copied += n;
    }
    (copied, true)
}

unsafe fn copy_opaque(dst: *mut u8, src: *const u8, n: usize) {
    asm!("rep movsb", inout("rcx") n => _, inout("rdi") dst => _, inout("rsi") src => _);
}

The copy is one rep movsb in assembly rather than a Rust copy, so that the compiler cannot assume tenant memory holds still. Tenant memory may change under the copy, since another virtual CPU’s thread may be writing it; a plain copy of memory that changes is a data race in Rust’s terms, and the optimizer is then free to assume the source did not change. It could, for instance, compile “copy a length out, check it, use it” into a check of the copy and a re-read of the source for the use, and a tenant that changed the length in between would have passed the check with a value it never had. An assembly copy is opaque to the compiler, so the code after the copy can read only the destination, never the source again.

Who holds the frame

Inside Linux, vOSTD counts each Linux entry as holding the reference that OSTD’s page table holds on a machine; Linux knows nothing of the count. A full kernel proper such as Asterinas’s relies on it: its copy-on-write path reuses a frame in place when the count is one.

The reference changes hands with the entry. map turns the handle into a raw address after the service succeeds, so the reference passes to the entry only once the entry exists. unmap reads the entries back before the service clears them. It turns each address into a handle again and drops it after the wait described below.

Simplified from prototype/vostd/src/mm/vm_space.rs and prototype/vostd/src/mm/frame.rs: CursorMut::map, CursorMut::unmap, Frame::into_raw, Frame::from_raw.

pub fn map(&mut self, frame: UFrame, prop: PageProperty) {
    let va = self.core.va;
    /* ... a page without read permission goes to the withheld set, below ... */
    space.backend().map(va, frame.paddr(), prot_bits(&prop));   // the vm_map service; panics the kernelet on failure
    let _ = frame.into_raw();                                    // the entry owns the reference now
    self.core.va += PAGE_SIZE;
}

pub fn unmap(&mut self, len: usize) -> usize {
    let mut frames: Vec<Paddr> = Vec::new();
    page_table::for_each_present(space.root(), start, end, &mut |_, leaf| frames.push(leaf.paddr));  // read first
    if !frames.is_empty() {
        space.backend().unmap(start, len);                       // the vm_unmap service: clears and flushes
    }
    /* ... plus the withheld set's pages in the range ... */
    rcu::synchronize();                                          // every copy in progress is done
    let n = frames.len();
    for p in frames { drop(UFrame::from_raw(p)); }               // now the frames may go back
    self.flusher.dispatch_tlb_flush();                           // issue flushes the kernel proper queued earlier on this cursor; this range was flushed by the zap
    n
}

pub(crate) fn into_raw(self) -> Paddr { let p = self.paddr; core::mem::forget(self); p }

pub(crate) fn from_raw(paddr: Paddr) -> Self {
    assert!(allocator::refcount(paddr).unwrap() > 0);          // the frame is the grant's and in use
    Self::from_raw_unchecked(paddr)
}

This is sound only if Linux never clears an entry of the area on its own initiative, and for this area it does not. The entries are marked special, so Linux finds no page of its own behind them. Every scanner that walks address spaces skips the area by its flags: the multi-generational LRU walk, khugepaged, same-page merging, NUMA balancing, the out-of-memory reaper. Reclaim finds pages to evict through its LRU lists and the entries that map a page through its reverse mapping; the grant’s frames are ordinary RAM on no LRU list, and no reverse mapping leads from a frame to this area, so swap-out never sees them. Compaction, which moves pages to make free memory contiguous, does pass over them and skips them as neither on an LRU list nor movable.

Memory pressure leaves the area’s entries alone: make mem-pressure tries to drive the Linux under test into swap with a host process while a tenant has 512 pages mapped, and afterwards every entry is present with the same frame and the same contents. The log reports how much was swapped; the check does not require it.

Freeing a frame a copy may be reading

unmap waits for every copy in progress before it frees a frame. A copy takes no cursor, since that would serialize every copy against every cursor, so it may still be reading the frame whose entry the unmap just cleared. The wait is a minimal RCU (read-copy-update: readers take no lock, and a writer waits until every reader in progress has finished before it frees anything): the rcu::read around each page of a copy, and the rcu::synchronize() in unmap before the frames are freed, which waits a grace period, until every reader in progress has finished.

synchronize waits for every virtual CPU that was inside a read section when it was called. Each virtual CPU has a sequence word, odd while it is inside one. synchronize skips its own virtual CPU: its caller is not in a read section, and since a read section holds a preemption guard, no other task on this virtual CPU can have been switched out partway through one.

The wait is short, one page’s walk and copy, unless Linux has descheduled the reader’s carrier; then the waiter spins, telling the host through vcpu_on_spin, until that carrier runs again.

The grace period holds in the test: make mem-conc runs copies without pause on one virtual CPU against map-and-unmap cycles on the other and finds no torn page, that is, no copy still reading a frame when the test rewrote it after its unmap returned (the rewrite stands in for reuse). A second phase frees the frames for real while copies write into the space, reallocates them, and finds none overwritten; in this run no grace period of that phase found a copy to wait for.

The range lock

vOSTD keeps a range lock of its own per space, because no Linux lock can stand in for a cursor’s locks. On a machine, a cursor holds the page table’s node locks for its whole life, and a full kernel proper such as Asterinas’s relies on that in two places: its page-fault handler queries, allocates and maps under one cursor, so two threads faulting on one page never both map it; and its page cache commits a page and maps it atomically under one cursor while its eviction and truncation unmap through another. A Linux lock cannot stay held while the carrier is back in kernelet code, where a tenant-controlled kernel could hold it forever against Linux’s own users of the address space.

The lock is sixteen 64-bit words in the space’s shared record. Each 2 MiB region, the span of one last-level table, is guarded by word (region number mod 16), so sixteen words stand for every region: a striped lock. Each word holds a count of readers in its low bits (READERS is their mask) and three flags: WRITER, set by an exclusive holder; WRITER_WAITING, which holds new readers off while a writer waits; and WAITERS, which says that someone is asleep in vm_wait on this word.

A cursor takes every word its range touches, exclusively for a cursor_mut and shared for a cursor. It takes them in ascending order, a total order, so two cursors cannot deadlock. It releases them when it is dropped. Uncontended, taking a word is one compare-and-swap on the shared record and no crossing.

Simplified from prototype/vostd/src/mm/vm_space.rs: lock_word, unlock_word.

fn lock_word(&self, idx: usize, exclusive: bool) {
    let w = &self.backend().words()[idx];
    loop {
        let cur = w.load(Ordering::Acquire);
        let free = if exclusive { cur & (WRITER | READERS) == 0 } else { cur & (WRITER | WRITER_WAITING) == 0 };
        if free {
            let new = if exclusive { (cur | WRITER) & !WRITER_WAITING } else { cur + 1 };
            if w.compare_exchange(cur, new, Ordering::Acquire, Ordering::Relaxed).is_ok() { return; }
            continue;
        }
        // Contended: say so in the word, then sleep in the host until it changes. Nothing spins.
        let want = cur | WAITERS | if exclusive { WRITER_WAITING } else { 0 };
        if want != cur && w.compare_exchange(cur, want, Ordering::Relaxed, Ordering::Relaxed).is_err() { continue; }
        self.backend().wait(idx, want);                       // the vm_wait service
    }
}

fn unlock_word(&self, idx: usize, exclusive: bool) {
    let (old, last) = if exclusive { (w.fetch_and(!(WRITER | WAITERS), Ordering::Release), true) }
                      else { /* decrement the readers, clearing WAITERS with the last; last: was this the last reader? */ };
    if old & WAITERS != 0 && last { self.backend().wake(idx); }   // the vm_wake service
}

A contended cursor sleeps rather than spins, because the lock’s holder may itself be asleep in Linux inside a vm_map. The waiter’s whole virtual CPU sleeps with it, which costs no more than spinning on a machine’s page-table lock would.

The slow path is a futex, Linux’s “sleep only if this word still holds this value” primitive. It is rebuilt for carriers because Linux’s futex keys on a user address in the caller’s address space and this word lives in kernel memory. A waiter sets the WAITERS bit and calls vm_wait(space, word, expected); the endovisor takes the space’s wait-queue lock, reads the word, returns at once if it no longer equals expected, and otherwise puts the carrier to sleep, killably. A releasing cursor clears the bit with one atomic read-modify-write and, if it was set and no reader remains, calls vm_wake, which wakes every waiter under the same lock.

No wake-up can be lost, by the argument chapter 9 made for vcpu_idle: one side marks, then checks; the other changes, then checks the mark; so one of the two always sees the other. Here the mark is the WAITERS bit, the check is the read under the lock, and the change is the single read-modify-write on release.

A cursor that waits behind a sleeping lock holder sleeps too, and takes the lock after the release without a single kernelet task switch: make mem-conc has one virtual CPU hold a cursor while its vm_map sleeps (a test hook makes it), and the other virtual CPU’s cursor on the same region sleeps in vm_wait and takes the lock after the release.

A page without read permission

A page without read permission has no Linux entry; vOSTD keeps it in a per-space withheld set instead. Such pages are of two kinds: PROT_NONE pages, with every permission cleared, and write-only pages, which x86 cannot express because a present entry is always readable. To the kernel proper both are mapped; on a machine OSTD encodes them as valid but not-present entries.

Linux has no usable encoding for them in this area. On a Linux built with NUMA balancing, Linux periodically makes entries no-access, so that the next touch faults and tells it which processor uses the page. Its fault handler therefore treats every no-access entry in an accessible area as such a marker, even in an area the balancer never scans, and rewrites it with the area’s page protection: read and execute here, because the area’s pfn_mkwrite makes Linux hold write back, plus write if the entry is dirty. A page encoded that way would become readable on its first touch. Without NUMA balancing, as in the prototype’s Linux, the entry is no better: Linux takes it for present and a read fault on it for spurious, so the access faults forever without reaching the area’s fault handler.

The set stands in for the table wherever a page has no entry. It is an ordered map from address to frame and property, in the kernelet’s heap (Linux’s kmalloc, charged to the sandbox). It holds the frame’s reference exactly as an entry would. It is under the range lock, which orders cursors on one region, and a spin lock of its own, because cursors on different regions share one map; the lock is taken per statement, never across a service. query, find_next, protect_next and unmap consult the set beside the table:

Simplified from prototype/vostd/src/mm/vm_space.rs: Slot, slot, protect_next.

enum Slot { Table(page_table::Leaf), Withheld(Paddr, PageProperty) }

fn slot(&self, va: Vaddr) -> Option<Slot> {
    if let Some(leaf) = page_table::walk(self.space.root(), va) { return Some(Slot::Table(leaf)); }
    self.space.withheld.lock().get(&va).map(|&(p, prop)| Slot::Withheld(p, prop))
}

pub fn protect_next(&mut self, len: usize, mut op: impl FnMut(&mut PageFlags, &mut CachePolicy)) -> Option<Range<Vaddr>> {
    let va = self.core.next_mapped(self.core.va, end)?;        // the table merged with the set
    let (paddr, old) = /* from the slot */;
    let mut new = old; op(&mut new.flags, &mut new.cache);
    match (in_table, new.flags.contains(PageFlags::R)) {
        (true, true)   => if rwx(new.flags) != rwx(old.flags) { backend.protect(va, prot_bits(&new)) },  // vm_protect
        (true, false)  => { backend.unmap(va, PAGE_SIZE); withheld.lock().insert(va, (paddr, new)); }     // into the set
        (false, true)  => { withheld.lock().remove(&va); backend.map(va, paddr, prot_bits(&new)); }       // out of the set
        (false, false) => { withheld.lock().insert(va, (paddr, new)); }
    }
    Some(va..va + PAGE_SIZE)
}

A tenant access to a withheld page faults as an absent page does, and the kernel proper’s own permission check turns that into a SIGSEGV for the program, as on a machine. make mem-func moves 512 pages into the set with protect_next clearing all three permissions, as a kernel proper’s mprotect(PROT_NONE) would, checks that a read of one is refused, moves them back, and reads them all intact with no fault.

A space that is dropped at the wrong moment

The last reference to a VmSpace can be dropped with virtual interrupts off, where Drop may do nothing that may sleep, yet vm_destroy sleeps. The drop can come from activate, which keeps one reference per virtual CPU to the space active there and drops the previous one’s when a new space is activated. A full kernel proper, such as Asterinas’s, activates with virtual interrupts off, from the handler it runs right after a task switch, so that drop can be the last; the test kernels of this book activate from main, but vOSTD is not written for them alone.

A helper task to do the work later is not available. A task spawned before the kernel proper calls inject_scheduler would make OSTD install its default scheduler, and the injection would then fail. A task spawned afterwards is never run by a kernel proper like Asterinas’s, whose scheduler enqueues only its own threads; a kernel proper that did accept it would still schedule it by its own policy, at no moment vOSTD can choose, so a busy higher-priority task could hold the frames and the Linux address space indefinitely.

Drop pushes the space onto a per-kernelet retire list, lock-free and without allocating, since an allocation is mem_alloc, which may sleep (the list node was allocated at new).

The list is drained at the next point where sleeping is allowed: every sleeping memory service’s wrapper (vm_create, vm_map, vm_unmap, vm_protect, vm_wait, vm_destroy) and vcpu_idle, whenever the caller holds no guard and no spin lock, has virtual interrupts on and is not in an upcall. A guard is excluded although a memory service may sleep under one (rule 1, below), because the drain may take long and the caller did not ask for it.

The drain is not prompt, but it is bounded. A sandbox that never idles keeps its retired spaces, but vm_create is a drain point, so a new space never waits behind retired ones, and the backlog is at most the 64 spaces a kernelet may hold, all charged to the sandbox’s own grant and to page tables Linux already counts.

The drain unmaps the whole space, which flushes and releases every frame, the withheld set’s included, and then calls vm_destroy.

Simplified from prototype/vostd/src/mm/vm_space.rs: Drop, retire::drain_if_safe, retire::destroy.

impl Drop for VmSpace {
    fn drop(&mut self) {                                   // may run with interrupts off: push, nothing else
        node.backend = self.backend.take();
        node.withheld = core::mem::take(&mut *self.withheld.lock());
        retire::push(node);
    }
}

pub(crate) fn drain_if_safe() {                            // called from sleeping stubs and vcpu_idle
    if HEAD.load(Ordering::Relaxed).is_null() || !safe() { return; }   // safe: no guard, no spin lock, interrupts on, not in an upcall
    let mut p = HEAD.swap(core::ptr::null_mut(), Ordering::Acquire);
    while !p.is_null() { let node = unsafe { Box::from_raw(p) }; p = node.next; destroy(*node); }
}

fn destroy(mut node: Node) {
    let backend = node.backend.take().unwrap();
    page_table::for_each_present(backend.root(), 0, MAX_USERSPACE_VADDR, &mut |_, leaf| frames.push(leaf.paddr));
    backend.unmap(0, MAX_USERSPACE_VADDR);
    frames.extend(node.withheld.values().map(|&(p, _)| p));
    rcu::synchronize();
    for p in frames { drop(UFrame::from_raw(p)); }
    backend.destroy();                                     // vm_destroy
}

The rule about sleeping

Inside Linux, a memory service may sleep even when the kernel proper calls it under a preemption guard, which OSTD forbids on a machine. The kernel proper calls map holding a preemption guard, because every cursor takes one. Every memory service except vm_flush, vm_activate and vm_wake may sleep in Linux: vm_map, vm_unmap and vm_protect wait for the address-space lock; vm_map may wait for a page-table page; vm_unmap’s zap may be preempted between page-table pages; and vm_wait sleeps by design. The design states the rule in four parts:

  1. A service may sleep whenever its work needs it. Its prologue lifts the mirror of chapter 11, so Linux sees nothing wrong, and its epilogue restores it.
  2. A service that may sleep must not be called while the caller holds a kernelet spin lock or has virtual interrupts off; under a plain preemption guard it may. The reason for the spin lock is lock-holder preemption: a spin lock held across a Linux sleep makes every other virtual CPU that wants it spin for the sleep’s length. The reason for the interrupts is a promise: with interrupts off the kernel proper is promised that no handler runs before it unmasks, so a kick sent during the sleep would be delayed past the carrier’s return until the unmask, where rule 3’s delay ends with the return.
  3. A sleep delays this virtual CPU’s pending virtual interrupts until the carrier returns, exactly as a Linux preemption does; the design accepts that.
  4. The mirror remains a latency optimization and is load-bearing for correctness nowhere.

Rule 1 is sound because a kernelet’s critical section promises only that no kernelet task switch happens on this virtual CPU until the guard drops, as Problem 2 of chapter 11 argued. A sleep in Linux is the same event as a Linux preemption, which the kernelet already survives at any instruction. On a machine a guard has a second job: OSTD frees page-table pages only after every processor has switched tasks, so a guard keeps them alive. Here no table is freed, and vOSTD’s grace period watches each virtual CPU’s sequence word rather than task switches, so a guard held across a Linux sleep changes nothing.

Rule 2 is the one place where vOSTD asks more of the kernel proper than OSTD does: a kernel proper that maps under a spin lock or with virtual interrupts off runs on a machine and is stopped here.

vOSTD asserts the rule before every sleeping memory service, with a count of spin locks held that is separate from the guard count, and stops the kernelet rather than counting violations, so a kernel proper under the tenant’s control cannot break the rule through the memory services; only the sandbox’s own virtual CPUs would spin, within its own share. Whether Asterinas’s full kernel proper keeps the rule everywhere has not been checked.

The heap services break rule 2, a known gap that costs latency, not soundness. They may sleep inside kmalloc, carry no assertion, and are called under spin locks, the withheld set’s insert among them. By rule 4 that costs latency only: a virtual CPU that wants the lock spins, and hands its turn on through vcpu_on_spin, for as long as kmalloc sleeps.

Simplified from prototype/vostd/src/mm/backend.rs and prototype/vostd/src/sync/spin.rs: sleeping, Backend::map, assert_may_sleep.

fn sleeping(name: &str) {                                  // every sleeping memory service's stub begins here
    r2::assert_may_sleep(name);                            // rule 2
    retire::drain_if_safe();                               // and a drain point for the retire list
}

pub(crate) fn map(&self, va: Vaddr, paddr: Paddr, prot: u32) {
    sleeping("vm_map");
    let r = unsafe { (svc().vm_map)(self.id, va as u64, paddr as u64, prot) };
    if r < 0 { map_failed(va, paddr, r); }                 // as on a machine: a page-table node it cannot get is a panic
}

pub(crate) fn assert_may_sleep(service: &str) {
    if held() != 0 || irq_is_off() {
        panic!("R2 violated: the sleeping service {} was called holding {} kernelet spin lock(s)", service, held());   // R2 is the code's name for rule 2
    }
}

Two tests exercise the rule. make mem-func runs on a Linux built with CONFIG_DEBUG_ATOMIC_SLEEP, which complains whenever Linux code might sleep with Linux’s preemption count raised. Inside a preemption guard it maps a page into a fresh 2 MiB region, which makes Linux allocate a page-table page, and Linux reports nothing. make mem-r2 does the same under a spin lock, and vOSTD stops the kernelet before the call crosses.

Recap. vOSTD reads Linux’s page table through the direct map with a four-level walk that needs no lock, because the endovisor’s reference keeps the table alive and Linux writes entries with single stores. Copies walk to the frame and go through its direct-map alias with an opaque rep movsb, inside an RCU read section, and unmap waits a grace period before it frees a frame. vOSTD counts the Linux entry as holding the frame’s reference. A cursor takes a striped range lock whose waiters sleep in Linux through vm_wait and vm_wake. Pages without read permission live in a withheld set, not in the table. A dropped space goes on a retire list and is destroyed at the next point where sleeping is allowed. A memory service may sleep under a preemption guard, never under a spin lock or with virtual interrupts off; the heap services break that rule, a known gap that costs latency.

What it costs

Every mechanism in this book has a price, and this chapter gives the handful of numbers that say whether the design is worth it. All of them were measured on the companion prototype, built from this repository, by make bench and make hello, except one figure from an earlier build, named where it appears. The numbers depend on the machine, so run make bench yourself and compare.

Measured on: the prototype at the commit this book was built from, in a Linux 6.12 guest under QEMU with KVM, two processors and 512 MiB, on a host with an Intel Xeon E3-1270 v6 processor whose cycle counter runs at 3.79 GHz, so one cycle is 0.26 ns. The host was shared with other work, which shows in the worst cases. Unless a row says otherwise, a figure is the median over five boots of each boot’s own median over its samples; the last column is the worst 99th percentile any boot reported.

The crossing

The endovisor’s stub times the crossing, an indirect function call plus two stack switches (chapter 7), at every sandbox’s start. It times it with interrupts off, in seven rounds of 2,000 calls of a service that does nothing, and keeps the cheapest round, since the machine beneath the guest can take the processor away in the middle of one; the line is in every make hello log, and these three rows are from the one boot quoted in chapter 8.

cycles
the service body alone, called directly24
the same through the service stub, both stack switches included36
a 64-byte heap allocation and its free, mem_alloc and mem_free together449

So the stack switches cost about ten cycles a call, a few nanoseconds; the two figures move by several cycles from boot to boot, and their difference with them.

The heap pair is two crossings, Linux’s kmalloc and kfree, and the endovisor’s table of blocks (chapter 4). It is timed by vOSTD with interrupts on in seven rounds of 1,000 pairs, the cheapest kept. The table costs about a hundred and twenty of those cycles: on an earlier build without the table (prototype/.build/r5-before-serial-hello2.log), the pair took 328.

Tasks

Measured by the scheduler test kernel on its two virtual CPUs (make sched-perf), with the sandbox in a control group of its own.

medianworst 99th percentile
two kernelet tasks yielding to each other on one virtual CPU, one round trip3,600 cycles (949 ns)5,392
a blocked high-priority task woken by a lower-priority one on the same virtual CPU, until it runs2,250 cycles (593 ns)4,510
the same, woken from the other virtual CPU: vcpu_kick, the kick’s interrupt and the upcall (chapter 10)5,404 cycles (1.42 µs)72,852
two tenant threads of two processes alternating on one virtual CPU through a yield system call, one round trip13,230 cycles (3.49 µs)40,184
for scale: two Linux processes on one processor calling sched_yield(), one round trip5,016 cycles (1.32 µs)9,418
for scale: a Linux process woken through a pipe by one on the other processor41,208 cycles (10.9 µs)110,156

A kernelet task switch, the scheduler’s decision included, is therefore under 2,000 cycles; the boots’ own medians for the round trip ranged from 2,254 to 3,788 cycles, and the Linux rows moved as much, since the host was shared.

The Linux sched_yield row is a scale for it, not a baseline: the Linux pair makes two system calls and changes address space, and the kernelet pair does neither, so the kernelet round trip is an upper bound on what the kernelet’s own scheduler costs and not a speed-up.

The tenant-thread row is the honest comparison with it: both are a user thread entering the kernel and another user thread, in another address space, leaving it, timed from the same point in user mode, and the kernelet’s costs between two and three times as much. The difference is what each thread pays per trip: the two hooks, in and out, the kernelet’s own switch, the adoption of the other address space through kernelet_switch_mm (chapter 13), and the kernel proper’s save and load of the floating-point registers (chapter 9).

The wake-up across virtual CPUs takes about a microsecond and a half when the kick’s interrupt finds the other carrier running kernelet code, of which roughly half a microsecond is the reschedule window of chapter 10 (measured on an earlier build by taking the window’s note away: the median fell to about 3,500 cycles): the kick usually lands after the woken virtual CPU has already picked the task, and the note it leaves costs one reschedule that finds nothing to do. Its tail is the widest of any row relative to its median, because the kick may instead find that carrier asleep in vcpu_idle, where waking it is a Linux wake-up with Linux’s own latency (chapter 9).

Fairness

The sandbox’s control group and a sibling group with equal weight, both confined to the same two processors; the sibling runs busy loops for ten seconds (make sched-fair).

the sandbox runsthe sibling’s share, in processorsrunnable tasks of the sandbox that Linux saw at once
1 task0.9942
50 tasks0.9952
50 tasks and one that holds a guard for 10 ms at a time0.884 in the bench run; 0.986 and 0.990 in two reruns (sched-fair50o-retry1.log, -retry2.log)2

Linux never saw more than two of the sandbox’s tasks runnable at once, its two carriers, whether the kernelet ran one task or fifty: that is the two-level argument of chapter 9, and the reason chapter 11’s fairness holds.

One run of the third row fell short, for a reason never found. In the bench run of that row, and in two runs on earlier builds, the sibling got less than nine tenths of a processor; the two reruns of the row are given beside it, and the claim rests on no more than that. The one thing the test measures about interference, the time the sandbox’s carriers spent descheduled by the guest’s own Linux while running kernelet code, which it reports as stolen, was no higher in the odd run of this build than in the passing ones, and the test does not read how long the host kept the whole guest off a processor. A measurement of fairness on a shared host means little, so the test is in make bench, which reports and does not fail.

Memory

Measured by the memory test kernel (make mem-perf), with a tenant program that times each access with the cycle counter.

medianworst 99th percentile
first touch of a page the kernel mapped ahead82 cycles (22 ns), 0 faults310
map, one page per call780 cycles (206 ns)1,324
map of 512 pages under one cursor, per page817 cycles (215 ns)1,595
protect 2 MiB read-only, with its flush422,516 cycles (111 µs)648,652
a read of each page after that protect80 cycles (21 ns), 0 faults298
copy 64 bytes out of tenant memory184 cycles (49 ns)270
copy 4 KiB out of tenant memory406 cycles (107 ns)2,392
the kernel proper’s fork of a process with 64 MiB mapped (16,384 pages over 256 frames, each mapped 64 times): a protect of the parent, a map into the child38.7 M cycles (10.2 ms)45.0 M
the child’s first read pass over the 64 MiB of mappings2.0 M cycles (0.52 ms), 0 faults2.1 M
the parent’s re-read of the 64 MiB of mappings after the fork1.9 M cycles (0.51 ms), 0 faults2.0 M
the kernel proper’s unmap of the 64 MiB, under one cursor1.8 M cycles (0.48 ms)2.5 M

What the rows say, one at a time.

A page the kernel mapped costs nothing at its first touch. The entry is in Linux’s page table from the moment vm_map returns, so the tenant’s first access is a TLB miss and a hardware walk, 82 cycles, with no fault. That is a container’s cost, and it is the design’s whole reason: any scheme that let Linux’s page table be filled lazily, on demand, would pay a fault, many times the cost of a walk, at the first touch of every page.

A map costs a crossing. 780 cycles is one service call: the stub, the address-space lock, the grant check, and Linux’s insert. A kernel that maps 512 pages under one cursor pays it 512 times. That is the price of letting trusted code check every frame, paid once per page, where the first-touch saving is collected once per page too.

A protect costs a crossing per page and saves a refault per page. 2 MiB read-only is 512 vm_protect calls and one flush, about 111 µs; the reads afterwards take 80 cycles and no fault, because the entries were rewritten in place and never removed.

Copies cost a walk. 184 cycles for 64 bytes is the software walk of chapter 14 plus the copy; a direct copy on a machine would be a fraction of that. For 4 KiB the walk disappears into the copy.

fork is the trade-off. The fork and unmap rows are the test kernel doing what Asterinas’s full kernel proper does for those system calls, not Linux’s. A fork of a process with 64 MiB mapped costs two crossings per page, a protect of the parent and a map into the child, and takes 10 ms. In exchange, neither the child’s first read pass over the memory nor the parent’s re-read takes a single fault: half a millisecond each, which is the cost of the TLB misses and walks alone, since the grant is small and the 64 MiB are 256 frames mapped over and over, so the data never leaves the cache. (A write to a shared page after the fork still faults, as copy-on-write requires.) A design that made the fork cheap by deferring the work to faults would pay it back, with interest, the first time either process touched its memory. A kernel that forks and never touches the memory, as a shell does before exec, loses by it; everything else gains.

Recap. The stack switches of a crossing cost a few nanoseconds; a memory service, with Linux’s lock and its work, costs several hundred cycles. A kernelet task switch costs under two thousand cycles and never involves Linux. A sandbox’s share of the processors does not depend on what its kernel does, in all but one unexplained run of this build and two of earlier ones. A page costs a crossing to map and nothing to touch; a copy costs a walk; a fork costs two crossings per page and makes every later read free.

Contributing a design proposal

Design Proposals is the book’s place for ideas that have not yet become part of the accepted design. A proposal can suggest an optimization, explore an alternative design, or develop new explanatory material. It should help readers understand the problem, assess the evidence, and discuss the tradeoffs. Working code is useful evidence, but it does not by itself make a proposal accepted.

Starting a proposal

Copy the proposal template into a new file in book/src/design-proposals/. Replace the placeholders with the proposal’s details and add a link under Design Proposals in book/src/SUMMARY.md. Use the same dated title for the page and its table-of-contents entry.

Both the title and filename start with the original submission date in YYYY-MM-DD form:

  • Title: 2026-10-27 Improve syscall performance
  • File: 2026-10-27-improve-syscall-performance.md

Use a short, descriptive filename with hyphens between words. Keep the original date when revising the proposal so that its identity and links remain stable. Different proposals submitted on the same day have different descriptive filenames. This guide and the reusable template are not proposals and do not need date prefixes.

Link to the affected Accepted Design chapters, relevant code, experiments, and discussion. Links from a proposal to an accepted chapter use ../accepted-design/<chapter>.md. Source paths written in prose are relative to the repository root. Label proposed code clearly; reserve “Unchanged from” captions for excerpts that match an existing source file exactly.

Run make check and make build from the repository root, or use the same commands from book/, before submitting the change for discussion and review. The checker verifies proposal dates and links as well as source excerpts. Proposals can cite external research and discussion; the checker does not verify those external pages.

Review and decisions

Use one of these statuses in the proposal:

StatusMeaning
DraftThe author is developing the idea and identifying open questions.
Under reviewThe proposal is ready for discussion and evaluation.
AcceptedThe agreed changes have been incorporated into Accepted Design and, where applicable, the prototype.
DeclinedReview concluded that the proposal should not be incorporated.
WithdrawnThe author has stopped pursuing the proposal.

During review, revise the proposal as evidence and discussion resolve questions. Record the decision and its rationale, including any differences between the original idea and the change actually adopted. For an accepted proposal, link to the resulting chapters and implementation. Keep the proposal in this part as a record of the reasoning, with its status visible; the updated Accepted Design chapters describe the resulting system.

A proposal may need an experiment or prototype implementation before a decision can be made. State what was implemented and tested, what remains hypothetical, and which results would support or undermine the proposal. For new explanatory material, show where it belongs and how it improves the reader’s understanding. The amount of detail should match the size of the change.

Design proposal template

Copy the Markdown below into a dated proposal file. Replace the date and title, fill in the metadata, and remove the writing prompts as you complete each section. The date is the original submission date. A proposal for new material can adapt the implementation and validation sections to explain the planned text and how it will be reviewed.

# YYYY-MM-DD Proposal title

- Status: Draft
- Author(s): Name(s)
- Discussion: Link to the review or discussion, when available
- Affected chapters: Links to the relevant Accepted Design chapters
- Implementation or experiments: Links, or “Not yet implemented”

## Summary

Describe the proposed change and the outcome it should produce.

## Problem and motivation

Explain the limitation in the accepted design or the gap in the book.
Give a concrete example, workload, or reader need.
Distinguish observations and measurements from assumptions.

## Proposed change

Describe the design or material in enough detail to review it.
Explain how it fits the existing system and what would change.
Clearly label proposed code and behavior that the prototype does not yet implement.

## Alternatives and tradeoffs

Compare plausible alternatives, including keeping the accepted design as it is.
Discuss relevant effects on safety, correctness, performance, complexity,
compatibility, and the reader's understanding.

## Implementation and integration

Identify affected code and chapters, dependencies, and any migration needed.
Explain how the accepted changes would be incorporated into the book and prototype.

## Validation and evidence

Describe the tests, experiments, measurements, or editorial review needed.
Report available results with enough context to reproduce or assess them.
State the acceptance criteria and the limitations of the evidence.

## Open questions

List unresolved choices and the evidence or feedback needed to resolve them.

## Decision and follow-up

Pending review.
When a decision is made, record its date, outcome, and rationale.
If accepted, link to the updated Accepted Design chapters and implementation.
If declined or withdrawn, explain why and any conditions worth revisiting.

2026-11-08 TypedIO

TypedIO is a proposed standard interface for virtual devices in kernelets, serving the role that VirtIO plays in virtual machines. Kernelets can also use traditional VirtIO devices, but TypedIO aims to deliver better performance by exploiting the kernelet architecture.

Motivation and Problem

Kernelets need devices to run useful workloads: block storage for filesystems and vsocks for communication with host applications or other kernelets. Linux already provides the underlying devices and services. The missing piece is a common interface between a kernelet driver, the frontend, and a Linux-side service, the backend, covering discovery, configuration, data transfer, and completion.

Why VirtIO is a poor fit

VirtIO is a natural candidate. It standardizes virtual devices across operating systems, and Asterinas already has VirtIO drivers and queue support. Reusing them could reduce frontend work and preserve compatibility with existing backends.

VirtIO: queues and backend placement. VirtIO defines binary device interfaces independently of the implementation language. Its shared queues, called virtqueues, contain descriptors specifying buffer addresses, lengths, and access directions. Feature negotiation selects optional behavior supported by both sides. See the VirtIO 1.2 specification, especially §§1.4, 2, 3.3, and 5.

Backends process the requests. QEMU can consume virtqueues and issue host I/O in userspace. Linux also provides kernel backends for particular devices, including vhost-net, vhost-vsock, and vhost-scsi; these are not replacements for every QEMU device backend. See the Linux 6.12 vhost drivers.

With kernel vhost, QEMU handles device and queue setup while a Linux backend processes ordinary I/O. This separates setup—the control plane—from routine processing—the data plane. Scheduling and notification costs remain. The vhost interface exposes that setup to userspace.

vhost-user instead places the queue-processing backend in a separate userspace process. QEMU shares queue memory and notification handles with that process; ordinary I/O can bypass QEMU while still requiring userspace backend execution. See the QEMU vhost-user documentation.

Kernelets offer a different starting point from a general-purpose VM: safe Rust drivers, a trusted OS development framework, and an existing service interface to Linux. For this setting, adopting VirtIO as the native interface has two drawbacks:

  • VirtIO loses Rust’s ownership and type contracts at the boundary. A VirtIO block request is a binary operation description plus buffer descriptors; a descriptor’s address and write flag do not express ownership of a Rust buffer. Safe Rust wrappers can enforce such contracts, but they must translate them into the separately specified protocol and reconstruct them at the backend.
  • VirtIO limits optimizations enabled by the kernelet architecture. Kernelets execute alongside the host and one another in a shared kernel address space, with Rust’s type and ownership rules constraining what safe code can access. This combination creates opportunities to reduce copying and coordination overhead. Virtual device interfaces should exploit these opportunities, but VirtIO’s existing interfaces, designed for communication across VM boundaries, limit how fully they can do so.

Design Goals and Requirements

Device generality. Support a broad range of virtual devices, including block, network, vsock, and filesystem devices, without being limited to VirtIO’s device types. Provide common mechanisms for discovery, configuration, data transfer, and notification that simplify both frontend and backend development. Each device protocol defines its operations, message formats, and execution/completion rules in Rust.

Memory safety. Support frontend and backend drivers written entirely in safe Rust, with only small additions to the trusted computing base (TCB), the code relied on for memory safety: minimal vOSTD extensions and a small host framework with audited Linux wrappers. This trusted code must retain and safely reclaim cross-boundary resources despite buggy, stalled, or terminated kernelets, without relying on their destructors or cooperation. Bound and account for retained resources, and reclaim them only after all accesses have ended.

High performance. Exploit the shared kernel address space and Rust’s safety guarantees to reduce data movement and coordination costs. Process backend I/O in the host kernel, and eliminate stream forwarding copies by sharing a kernel buffer directly between endpoints. Explore direct host page-cache access without adding a VM’s second address-translation layer, and avoid duplicate per-page metadata where host records can be reused. VMs also support shared buffers, and virtiofs direct access (DAX) exposes host-cached file data; evaluate TypedIO against these mechanisms and in-kernel vhost backends as well as QEMU.

Asterinas integration. Prefer existing OSTD memory and I/O interfaces, including IrqLine for notifications and DmaStream for making kernelet buffers accessible to the host. Existing Asterinas network, socket, and block I/O stacks should support TypedIO frontends alongside VirtIO frontends, with minimal changes to their callers. Breaking API changes are a last resort.

TypedIO design

Overview

A frontend submits Rust values describing an operation; its backend performs the operation through Linux. vOSTD and the host framework validate access, retain buffers, and deliver results, leaving device behavior to the drivers.

Consider a frontend reading 4 KiB from a disk into an existing OSTD buffer. Both sides use the same BlockProtocol definition, which groups the device’s configuration, request, and reply types through the Protocol trait. Those types implement Message, the contract that permits their values to cross the kernelet–host boundary. A read is expressed as a BlockRequest::Read value containing a sector and destination buffers.

The frontend first finds an authorized disk with discover::<BlockProtocol>(). It examines each Device through config() and opens a bounded request Queue on the selected device.

OSTD: DMA buffers. Direct memory access (DMA) lets a device access memory without a CPU payload copy. OSTD’s DmaCoherent and DmaStream own storage prepared for device access: coherent buffers need no explicit cache synchronization, while streaming buffers provide synchronization methods. Both expose byte access through VmReader and VmWriter. Asterinas’s Slice<Arc<DmaStream>> describes a bounded range while sharing ownership of the DMA object.

For the block read, the frontend supplies a destination range as a Slice<Arc<DmaStream>>. Submitting this value lets the framework retain the buffer independently and give the host write access for the read operation.

A queue supports multiple outstanding requests. Each carries a request tag identifying frontend state; the driver matches arriving completions to their callers. The example uses the frontend’s synchronous wait_reply helper.

Proposed frontend example. The caller supplies queue options, a DMA buffer of at least 4 KiB, and a valid sector. BlockFrontend::new installs the interrupt/completion worker; submit records local state before submission, and wait_reply waits on that state.

let devices = discover::<BlockProtocol>()?;
let device = select_device(&devices)?;
let queue = device.open(options)?;
let frontend = BlockFrontend::new(queue)?;

let destination = Slice::new(Arc::clone(&buffer), 0..4096);
let pending = frontend.submit(BlockRequest::Read {
    sector,
    buffers: [destination].try_into()?, // A bounded list of existing DMA buffer ranges.
})?;
let reply = wait_reply(pending)?;

The host accepts the request into a HostRequest, which retains the destination independently of frontend handles. The registered backend passes it to a Linux adapter. The adapter reports completion only after every host access ends, making the final result available to the frontend.

Completion triggers a virtual interrupt, a software interrupt delivered to a kernelet virtual CPU (vCPU). vOSTD invokes the driver’s IrqLine callback; the driver drains tagged results and chooses which callers to notify. After a successful read, it calls buffer.sync_from_device(0..4096) before reading the bytes. Trusted records retain storage until host access ends even if a caller abandons the request or the kernelet dies.

Streams use byte pipes, shared storage carrying ordered bytes. A connection handshake delivers Producer and Consumer endpoints; each reserves a range, transfers bytes, and commits progress. Pipes reuse the same discovery, typed messages, resource checks, and interrupts as block I/O.

Architecture

The block and stream paths separate driver policy from trusted transfer mechanisms:

Layer organization. Established stream payload travels directly through pipe endpoints.

%%{init: {"htmlLabels": false, "flowchart": {"htmlLabels": false}}}%%
flowchart TB
    A["Application I/O"] --> K["Safe Asterinas<br/>frontend drivers"]
    K --> P["Device protocols:<br/>Rust requests and replies"]
    P --> V["vOSTD: queues, DMA buffers,<br/>byte pipes, IRQ delivery"]
    V --> H["Trusted host framework:<br/>ownership and lifecycle records"]
    H --> B["Safe Linux<br/>device backends"]
    B --> L["Trusted Linux wrappers<br/>and existing drivers"]

The trusted components are vOSTD, the host framework and wrappers, the message-derivation machinery, the loader, and the underlying Linux kernel. Device policies remain in safe frontend and backend code. Safe Rust does not itself prove those policies correct or ensure that a device makes progress.

LayerResponsibilityExcluded responsibility
vOSTDValidate DMA ranges, retain local storage, expose bounded access, dispatch IRQ callbacksPorts, connection policy, block ordering policy
Kernel properSocket state, block frontend, completion dispatch and task wakeupsDirect host-memory access or Linux calls
Host frameworkValidate resource access, retain storage, account for requests, handle teardownDevice-specific interpretation of data
Safe backendAuthorization and device protocol, Linux adapter selectionUnchecked pointer access, device-access lifetimes

Protocol and message types

The frontend and backend need to agree on operations without sharing heap ownership or assuming that objects owned by a kernelet instance remain valid after that instance terminates. Protocol groups a device’s configuration, request, and reply types; each must implement Message so the framework can transfer it safely. Both sides know every shared protocol and generated transfer-view definition; private fields restrict construction and access, not knowledge of the type.

A type is exchangeable in TypedIO when the framework can transfer its value between a kernelet instance and the host, or between instances, without giving safe receiver code access to unretained memory or depending on the sending instance to run cleanup. The proposed unsafe trait Message expresses this contract. A trusted derive macro checks ordinary structs and enums recursively; vOSTD supplies audited implementations for primitives, supported containers, and resource handles. Application and device code use the trait through safe APIs. Each resource’s audited hooks specify its permitted transfer directions.

Data representation: schemas. A schema specifies a message’s variants, fields, types, and size limits. A schema digest is a hash identifying a schema; equality detects agreement, not whether a particular message is valid or authorized.

Proposed type contracts; generated transfer code and routine definitions are omitted.

/// Marks a type whose generated or audited transfer satisfies the exchange contract.
///
/// # Safety
/// Implementations must satisfy the documented exchangeability requirements.
/// The schema must describe the transfer representation and resource hooks exactly.
pub unsafe trait Message: Sized + 'static {
    const SCHEMA: SchemaDigest;
}

/// Groups a device's configuration, request, and reply types.
pub trait Protocol {
    type Config: Message;
    type Request: Message;
    type Reply: Message;
    const ID: ProtocolId;
}

Exchangeable message types

Rust: copying and representation. Copy permits duplicating a value within Rust’s ownership rules; shared references and raw pointers can also be Copy. An application binary interface (ABI) specifies how compiled components represent and pass data. #[repr(C)] specifies a layout convention, but permits padding and does not change the representation of nested fields. Neither property keeps referenced storage alive after its owner releases it. See the Copy contract and layout rules.

An acceptable message must satisfy all of these requirements:

PropertyRequirement
Recursive admissibilityEvery field belongs to the supported set below, including fields of inactive enum variants and generic type arguments used as fields. No blanket implementation for Copy types is allowed.
Bounded transferThe schema bounds metadata size and container lengths. Transfer cannot follow arbitrary object graphs or depend on unbounded allocation.
Independent lifetimeThe receiver owns its local value. Any usable external resource has framework-managed retention; no sender-local reference, allocator, callback, or destructor is needed to use or retire it.
Stable, valid captureSubmission captures a valid typed value while its storage is live and protected from mutation. Ordinary message fields have no interior mutability; concurrent access to DMA payload is governed separately.
Compatible representationGenerated transfer code preserves field values and type invariants, handles padding safely, and agrees on layout wherever it copies a representation directly. A schema digest alone does not prove these properties.

The framework initially supports these types and compositions:

TypeAdmission rule
Scalars(), integers, floats, bool, and char; target-dependent widths such as usize are part of the compatibility check.
Structs, newtypes, enums, tuples, and arraysEvery field or element is exchangeable. User-defined types derive Message and have no custom Drop; a type need not implement Copy.
Option<T> and Result<T, E>Their contained types are exchangeable. The generated transfer preserves the active variant.
Framework BoundedVec<T>Elements are exchangeable and the schema fixes a maximum length. Only live elements transfer; a local allocation pointer never does. Ordinary Vec, String, Box, and arbitrary Arc<T> have no implementation; supported DMA owners are the explicit exception below.
Thin raw pointers, *const T and *mut T for sized TTransfer only the opaque address. The pointee is neither traversed nor retained and need not be exchangeable. No host access authority follows from the address.
DMA resourcesDmaCoherent, DmaStream<D>, their Arc forms, and bounded Slice views permit request-scoped transfer from a kernelet to host-local access handles. They do not import DMA owners into another kernelet or into replies. Neither the DMA object’s representation nor its Arc pointer crosses the boundary.
Pipe endpoints: Producer, ConsumerAudited hooks move the producer or consumer role and retain its pipe storage. These types are neither Copy nor Clone; transfer is rejected while a reservation is outstanding. ProducerReservation<'_> and ConsumerReservation<'_> are local borrows and are not exchangeable.

References, function pointers, trait objects, unions, MaybeUninit, and interior-mutable types have no implementation in this version. References and safe function pointers would let safe receiver code access data or execute code whose lifetime has not been established. Other unsupported types require a separate audited extension; wrapping them in a Copy or #[repr(C)] struct does not make them admissible.

Raw pointers are permitted as data because safe code cannot dereference them. Transfer preserves their addresses without promising that they remain valid for memory access, even if echoed back to the sender. Trusted host adapters obtain buffer access through retained DMA resources, never by trusting a raw-pointer field. This distinction follows Rust’s separation between carrying a pointer and using it to access memory.

Proposed admission examples; Message is the trusted derive macro.

#[repr(C)]
#[derive(Clone, Copy, Message)]
struct ReadRange { sector: u64, bytes: u64 } // Exchangeable; direct-copy candidate.

#[derive(Message)]
struct ReadRequest {
    range: ReadRange,
    destination: Slice<Arc<DmaStream>>, // Exchangeable through framework retention.
}

#[repr(C)]
#[derive(Clone, Copy)]
struct Borrowed<'a> { bytes: &'a [u8] } // Cannot derive Message.

Proposed block protocol declaration. BlockConfig and BLOCK_PROTOCOL_ID belong to the shared definition; request and reply types appear in the block device design.

pub struct BlockProtocol;

impl Protocol for BlockProtocol {
    type Config = BlockConfig;
    type Request = BlockRequest;
    type Reply = BlockReply;
    const ID: ProtocolId = BLOCK_PROTOCOL_ID;
}

Transfer representation

Shared Rust types define message structure; generated transfer code handles representation differences between independently compiled components. The trusted macro derives Message, a schema descriptor, and transfer code from its types. Generated field bounds and compile-time assertions check nested types and concrete generic instantiations; the macro does not guess their properties from names. The macro and its support code belong to the TCB.

Exchangeability does not require field-by-field serialization on every call. A Copy value with a verified layout, no uninitialized padding, and no fields requiring resource-transfer hooks can use a direct metadata copy, including opaque raw-pointer fields with compatible representations. For example, ReadRange can use this path after layout checks. #[repr(C)] alone does not establish these conditions. Default-layout structs, tuples, and enums remain admissible through generated field transfer, without assuming their native representations match across builds.

Resource-bearing messages need additional work even when their scalar fields can be copied directly. A block read transfers its sector and buffer descriptors while trusted hooks retain the backing and create host-local access handles. Bounded lists transfer their elements into reserved receiver storage. These operations still cost CPU time; the design eliminates unnecessary representation conversion, not ownership accounting or metadata capture.

The kernelet–host boundary uses a small versioned service ABI carrying scalars and bounded transfer records. Metadata, the operation description and resource identifiers, is captured into host-owned storage before asynchronous dispatch; payload, the actual I/O bytes, stays in retained storage. Records contain initialized fields with padding omitted or initialized, and no sender-local owning objects. Raw-pointer fields travel as addresses; worker queues carry these records rather than granting arbitrary message types Send.

Build and loader checks must admit only trusted generated or audited implementations, including their transitive dependencies and macro expansions; a top-level unsafe lint alone does not establish the transfer invariants. Protocol definitions remain above vOSTD, so adding an operation requires no handwritten device-specific unsafe code.

Submission captures a valid typed value while it remains stable, preserving bool, char, and enum validity. Byte-oriented ingress must instead validate before constructing Rust values. Runtime checks still enforce sizes, device state, rights, and retention. Capture is synchronous and bounded; the host retains no caller argument address afterward.

Device discovery and configuration

A frontend must find a device it is allowed to use and learn its limits before submitting I/O. discover<P> returns matching Device<P> handles; config exposes an attachment’s configuration, and open creates its request queue.

Protection: capabilities. A capability is an unforgeable handle granting specified access to a particular resource, such as permission to read a file. Possession confers that access; a resource identifier alone does not. Systems that reuse handle slots can attach an object generation to distinguish a removed resource from its replacement and reject stale handles.

The holder, the host process that creates and controls a kernelet, attaches devices before start, assigning a device instance and restricting its resources, peers, and access rights. Discovery returns a snapshot of those attachments. Live reconfiguration uses removal followed by a new generation; removal rejects new submissions, signals the driver through its IRQ line, and lets existing accesses finish. Handles from an old generation cannot name the replacement.

TypedIO’s device handles and DMA objects serve as capabilities. vOSTD constructs them with private fields through discovery and OSTD-compatible DMA APIs; a bounded buffer range limits the bytes a request may access. When a request reaches Linux, the host checks the handle’s owning kernelet, object generation, access rights, and permitted use with that device.

Discovery checks the protocol identity/version, framework ABI version, and supported features. It requires exact agreement on the generated schemas of configuration, request, and reply types; their digests cover field types, variants, limits, target properties, and any directly copied layouts. The checked binding is retained in Queue<P>; submission obtains type information from Message, never from a caller-provided digest.

Opening reserves queue storage and completion credits, one reply slot for each request the queue can accept. It runs in a sleepable context, where the calling task may block while waiting for memory or locks. A credit is reserved before accepting a request so a full reply queue cannot block cleanup.

Proposed discovery and opening APIs.

/// An attachment authorized for the current kernelet.
pub struct Device<P: Protocol> { /* private capability and config */ }

/// Returns matching attachments after validating their schema and generation.
/// Unsupported schemas return an error rather than an unchecked typed device.
pub fn discover<P: Protocol>() -> Result<Vec<Device<P>>, DiscoverError>;

impl<P: Protocol> Device<P> {
    /// Returns the validated configuration snapshot for this attachment generation.
    pub fn config(&self) -> &P::Config;

    /// Opens a bounded queue with negotiated features and completion credits.
    /// May allocate and sleep; must not run under a spin-lock or interrupt-disable guard.
    pub fn open(&self, options: QueueOptions) -> Result<Queue<P>, OpenError>;
}

The block configuration supplies capacity, logical-block size (the required transfer unit), transfer limits, and read-only policy. Selecting a device does not bypass those limits: the host checks each submitted operation against the attachment.

DMA buffers

An asynchronous request needs device-accessible storage that remains allocated until the device finishes. OSTD already provides the public buffer abstractions for this purpose. TypedIO uses DmaCoherent and DmaStream<D> directly, adding trusted request retention inside vOSTD and the host framework.

Memory: DMA storage lifetime. Retaining or pinning pages keeps their storage available, whereas Rust Pin restricts object movement; neither guarantees exclusive access or device completion. A bounce buffer is separate storage used when direct device access is unsuitable. See Linux’s DMA guide and user-page pinning.

OSTD: DMA allocation and synchronization. DmaCoherent::alloc and DmaStream<D>::alloc allocate DMA storage. DmaStream<D>::map takes ownership of an existing USegment, a contiguous run of untyped frames: bytes that may change through userspace or devices, rather than typed kernel objects. Keeping this storage allocated prevents its reuse for typed objects.

Streaming buffers require sync_to_device before device reads and sync_from_device after device writes. Their sealed direction types—implementable only by OSTD—are ToDevice, FromDevice, and FromAndToDevice (the default); they restrict permitted CPU access and synchronization. The zeroing alloc constructor requires a CPU-writable direction, so it is unavailable for FromDevice; map can use that direction.

Both DMA types release their mappings on drop. Their byte-access APIs permit concurrent access without providing ordinary Rust references to the bytes. See the inspected coherent DMA API, streaming DMA API, slice API, and memory-access contract.

For vOSTD, the device side of these DMA abstractions is the TypedIO host service. Allocation or mapping prepares storage for that service; submission authorizes a particular request to access a particular range. A disk read needs host write access, corresponding to FromDevice; a disk write needs host read access, corresponding to ToDevice. Bidirectional DMA buffers support either operation, while coherent buffers have no direction parameter. The framework checks the operation’s access requirements against the buffer’s allowed directions before accepting work.

Asterinas: block-I/O requests. An Asterinas block-I/O request (BIO) groups an operation and its buffer segments. A BioSegment wraps a DMA-buffer range; a SubmittedBio owns the submitted request and reports its final status through complete. These are frontend objects, separate from Linux’s bio objects. See Asterinas’s BIO types.

The block protocol uses the existing bidirectional Slice<Arc<DmaStream>> found in BioSegment. Its Read and Write variants specify the host access required for each request. This preserves the current block interface without converting buffers into new direction-specific wrappers. Protocols with one-way buffers can instead use DmaStream<FromDevice> or DmaStream<ToDevice>.

Proposed frontend use of existing OSTD constructors. buffer is a USegment of at least 4 KiB; the selected vOSTD service accesses coherent memory. The request reuses the allocation; frontend is the initialized driver from the overview.

let dma = Arc::new(DmaStream::map(buffer, true)?);
let request = BlockRequest::Read {
    sector,
    buffers: [Slice::new(Arc::clone(&dma), 0..4096)].try_into()?,
};
let reply = wait_reply(frontend.submit(request)?)?;
if reply.status == BlockStatus::Success {
    dma.sync_from_device(0..4096)?;
    // Read the result through dma.reader().
}

A caller needing new storage can use DmaStream::alloc or, for a protocol accepting coherent buffers, DmaCoherent::alloc. A frontend with an existing BioSegment can reuse its DMA slice without allocating or mapping again. Slice::new checks its range and panics on invalid input; drivers must validate externally supplied ranges before calling it.

Transfer and request retention

A request retains its local DMA owner and its grant backing, the host allocation containing its bytes. The book’s grants supply the storage for kernelet frames. The lifetime section explains how these independent records survive early drop and instance death.

The proposed vOSTD DMA constructors register the prepared storage with the host framework and keep its identity private inside the DMA object. The Message support layer uses audited hooks to transfer that identity, range, and permitted access, retain the local owner, and construct a host-local buffer handle. Neither the guest’s Arc pointer nor its reference count or destructor crosses the boundary.

The initial DMA hooks grant request-scoped host access; they do not transfer DMA ownership between kernelets or import DMA owners in replies. Peer resource transfer supports host-owned pipe endpoints. Importing another instance’s DMA storage would need a separate lifetime and mapping contract.

Before dispatch, the host validates the owning instance, generation, range, direction, device authorization, and quota. The device address returned by daddr() is neither a host pointer nor sufficient authority. Rejection returns the original request and rolls back provisional retention.

This support is specific to the recognized DMA types, not a blanket implementation for Arc<T> or Slice<T>. The existing Slice lives in Asterinas’s aster-util library; its audited integration belongs in the shared message-support layer, using vOSTD’s resource hooks, rather than making vOSTD depend on the kernel library. Backends receive host-local counterparts with bounded byte-access operations, not guest DMA objects.

Synchronization and host access

The baseline is Asterinas/OSTD revision ab9a4cfdc726263b3f41ccea0337633e24b443fc, vOSTD’s documented API reference. Its DMA module and TypedIO retention hooks are proposed additions to vOSTD.

The initial vOSTD implementation uses coherent grant-backed storage: the CPU-visible and service-visible bytes are the same allocation. map retains an existing supported segment; alloc obtains supported storage. Unsupported backing is rejected during preparation, allowing the frontend to copy into a supported DMA buffer. The caller’s is_cache_coherent argument cannot authorize an unsafe mapping: vOSTD must establish the actual access properties. DMA preparation requires interrupts enabled under the existing OSTD contract; drivers prepare buffers before entering the non-sleeping submission path.

The frontend keeps the usual streaming synchronization sequence: sync_to_device before submitting a write, and sync_from_device after read completion and before CPU consumption. These calls need no cache maintenance for the initial coherent storage, but do not replace queue publication ordering or waiting for completion. Coherent buffers omit these calls. Neither API promises that other aliases have stopped accessing the bytes.

OSTD can also maintain separate CPU-visible and device-visible storage. Supporting that configuration later must preserve its synchronization semantics: the host accesses the registered device-visible storage, and the stream’s sync methods transfer data as required. Selecting the CPU-visible storage merely because it is easier to access would bypass that contract. A separate Linux-side bounce buffer is the host adapter’s responsibility. Its read copy-back must finish copying data into the registered DMA range before completion.

Compilers: opaque byte copies. Ordinary Rust loads and stores assume its aliasing and data-race rules hold. An opaque copy uses an assembly routine to access concurrently mutable bytes without creating Rust references to them or letting the compiler replace those accesses with ordinary loads and stores. This technique does not by itself establish compatibility with other concurrent access methods.

Reusing OSTD DMA types does not by itself make arbitrary Linux CPU accesses safe. TypedIO adds a trusted host byte-access adapter using bounded opaque copies compatible with vOSTD’s assembly-copy model. Safe host APIs expose copying operations, not slices into shared storage. Host parsers validate private snapshots of control data; mutable payload bytes never become unchecked addresses or allocation lengths. Cloned frames, userspace mappings, or pooled aliases may corrupt a faulty frontend’s I/O, but must not induce host Rust undefined behavior.

Direct hardware DMA is allowed only through an audited target whose entire data path preserves this access model; other targets use host-private bounce buffers. The Linux adapter performs any physical-device DMA mapping separately: vOSTD preparing a buffer for a TypedIO service does not prove that a particular Linux device can access it directly. Application memory initially enters through existing VmReader/VmWriter copying or owned untyped frames; neither an arbitrary userspace address nor a Vec<u8> becomes a DMA resource automatically.

Request submission and completion

A device queue must handle many requests without making the frontend wait for them in submission order. Queue::try_submit accepts a request and a frontend-chosen RequestTag; try_pop_completion returns whichever completed operation is next available. A completion identifies its request and carries either the protocol reply or a framework error. Each accepted request reserves completion space, so publication never depends on the frontend consuming another result first.

Proposed queue APIs. RequestId is an opaque framework identity containing the queue generation and a non-reused request sequence; RequestTag is a correlation value, not memory authority. The bitflags! macro defines a set that can contain several notification causes at once.

/// A bounded request/completion queue for one device attachment.
/// Created by `Device::open`; its protocol and attachment generation are fixed.
pub struct Queue<P: Protocol> { /* private queue and attachment state */ }

bitflags::bitflags! {
    /// Pending reasons to service a queue, returned by `Queue::take_events`.
    pub struct QueueEvents: u8 {
        /// One or more completions were published.
        /// Drain them with `try_pop_completion`; this flag is not a count.
        const COMPLETION_AVAILABLE = 1 << 0;

        /// Submission capacity changed from full to having room for a request.
        /// Retry `try_submit`; another submitter may have taken that capacity.
        const CAPACITY_AVAILABLE = 1 << 1;

        /// The attachment was removed and no longer accepts new requests.
        /// Accepted requests still require final completion after access ends.
        const REMOVED = 1 << 2;
    }
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub struct RequestTag(pub u64);

pub struct Completion<R> {
    pub id: RequestId,
    pub tag: RequestTag,
    pub result: Result<R, RequestError>,
}

impl<P: Protocol> Queue<P> {
    /// Accepts an owned request, or returns it intact on rejection.
    /// Does not sleep or allocate; a full queue returns `WouldBlock`.
    pub fn try_submit(&self, request: P::Request, tag: RequestTag)
        -> Result<RequestId, SubmitError<P::Request>>;

    /// Removes one available completion, regardless of submission order.
    /// Returns `None` when no completion is currently available.
    pub fn try_pop_completion(&self) -> Option<Completion<P::Reply>>;

    /// Requests cancellation without establishing that buffer access has ended.
    pub fn cancel(&self, id: RequestId) -> Result<(), CancelError>;
}

Event flags are latched: each remains set until take_events atomically returns and clears it, and repeated occurrences coalesce into one bit. Taking events neither removes completion records nor clears the attachment’s permanent removed state. The IRQ methods use these flags to coordinate draining and notification delivery.

The frontend installs its tag-to-request entry before calling try_submit, because completion can arrive before submission returns. A tag must be unique among that queue’s accepted requests and unread completions; the framework rejects duplicates. The driver also keeps its local slot reserved until it has dispatched the completion. The returned RequestId supports cancellation; it need not be installed before matching a completion, because the frontend already knows the tag.

If requests A, B, and C finish in the order C, A, B, draining returns C, A, B and the driver completes each corresponding local request immediately. One interrupt can cover all three. The service loop dispatches results to individual callers or a batch without waiting on a particular request.

An accepted request completes exactly once when host access ends, including requests that fail because of cancellation or device removal. If host access cannot be stopped, its resources remain retained and completion remains pending. Removal can notify the driver earlier, but cannot complete an active request prematurely. Rejection produces no completion and returns the unaccepted request. Dropping a queue handle or forgetting a request ID does not cancel accepted work or release its buffers; trusted records retain the resources independently.

Submission copies bounded metadata and retains prepared DMA objects in preallocated slots. Resource-transfer failure rolls back provisional ownership before rejection. Queue capacity, retention slots, and completion records are reserved before acceptance; a full queue returns WouldBlock. DMA allocation or mapping, device opening, and large control-message preparation occur outside this path, with interrupts enabled and in a sleepable context as required.

Backend framework

The TypedIO host framework delivers authorized requests, retains buffers, and returns asynchronous results. Safe backends implement device behavior using this framework and Linux adapters.

Registration and dispatch

The host registers a backend for an authorized device attachment before the kernelet starts. The registration binds the device’s protocol and configuration to its handler; frontend discovery and queue opening refer to this same attachment. The framework dispatches each accepted operation through a HostRequest, described below.

Linux: worker context. A work queue schedules deferred work on Linux worker tasks. A control group groups tasks for resource accounting and limits. Shared workers keep their own group membership; queuing work does not automatically charge it to the submitting task’s group. See the book’s CPU accounting discussion.

Handlers run on bounded Linux work queues after the framework releases its service locks. This lets the carrier, the Linux task running a kernelet vCPU, return to kernelet execution; see Tasks and virtual CPUs. A handler may submit asynchronous Linux I/O, but must not wait for the frontend to make progress. Removal stops new dispatch while the framework retains the backend and adapter state needed by outstanding work.

The backend request handle

The framework constructs one HostRequest<R> per accepted operation. It owns the captured message, authenticated caller identity, retained resources, and reserved reply slot, independently of frontend request state.

A host-local message preserves the protocol’s variants and ordinary fields while replacing kernelet resource owners with bounded host handles. The generated companion host::BlockRequest, for example, replaces DMA owners with access to retained host records. These views come from one shared protocol definition; no guest Arc or reference count transfers.

Proposed common host APIs. R is a host-local request type; Caller identifies the authenticated originating instance and attachment.

/// Handles accepted operations for a registered device.
pub trait Backend<R>: Send + Sync {
    fn handle(&self, request: HostRequest<R>);
}

/// Owns an accepted operation and its right to complete.
pub struct HostRequest<R> { /* private message and framework record */ }

impl<R> HostRequest<R> {
    /// Returns the identity established by the framework during admission.
    pub fn caller(&self) -> Caller;

    /// Borrows the captured message and its bounded host resource handles.
    pub fn message(&self) -> &R;
}

message() permits inspection of the operation without separating it from its lifetime record. Buffer handles provide checked byte-copy operations; they do not expose ordinary Rust slices into concurrently mutable kernelet storage. An asynchronous Linux adapter must consume the entire HostRequest, so its buffer access cannot outlive the retained resources.

The protocol’s generated completion methods, finish and finish_error, consume the request and supply a typed result. The framework publishes that result only after all registered accesses end, then appends a tagged completion and signals the queue’s IRQ line if delivery is enabled. A request handle cannot be cloned or completed twice.

Dropping a handle before handing it to an asynchronous adapter requests error completion; forgetting one leaves it charged against bounded outstanding work. Neither action releases storage beneath active Linux I/O, and teardown follows the same lifetime rules.

Example: a block backend

The block backend combines a safe per-attachment sequencer with a BlockTarget for an authorized Linux block-device range. The sequencer implements operation selection, range policy, and flush ordering. The target’s audited substrate provides Linux BIO construction, device/DMA lifetime retention, and completion after access ends. The registered handler feeds the sequencer, which submits permitted operations without waiting in the handler; see the block design.

Proposed safe backend and registration. Host setup supplies registry, an authorized attachment, and a matching target; attach binds the protocol’s generated host request type to its backend and returns a registration handle retained by host setup.

struct BlockBackend {
    sequencer: BlockSequencer,
}

impl Backend<host::BlockRequest> for BlockBackend {
    fn handle(&self, request: HostRequest<host::BlockRequest>) {
        self.sequencer.enqueue(request);
    }
}

let registration = registry.attach::<BlockProtocol>(
    attachment,
    Arc::new(BlockBackend { sequencer: BlockSequencer::new(target) }),
)?;

The sequencer owns the request until it submits it or completes it with an error. Once ordering permits, it calls BlockTarget::submit, moving the request into the adapter’s outstanding-I/O record on success. The adapter finishes the request after Linux I/O and any copy-back end.

If submission fails before taking ownership, it returns a Rejected value containing the intact request and error. The sequencer calls finish_error() on that rejection to complete the request without starting I/O.

The host submission section specifies the adapter’s obligations. The Vsock example shows a different backend using the same request/completion mechanism to route control messages.

Byte pipes and reservations

A stream needs to preserve byte order across calls and reuse storage as the reader consumes data. A byte pipe supplies Producer and Consumer endpoints for one direction; a connection uses two pipes. A safe backend requests their creation through the host framework, which reserves capacity and transfers the data endpoints in a connection event. The backend retains generic pipe-close authority, described below.

A pipe uses a circular buffer with host-owned, untyped, zero-initialized storage and trusted indexes. A reservation claims a contiguous range while bytes are transferred.

Each pipe has its own metadata lock and readiness state, avoiding the experiment’s global wake queue. Shared IRQ lines can still schedule several sources’ service routines. Registry lookup and accounting remain bounded operations whose contention must be measured separately.

ProducerReservation::produce transfers bytes into that range; ConsumerReservation::consume transfers bytes out. Both use commit to finalize recorded progress: the producer publishes a prefix, and the consumer releases a prefix for reuse. The direct transfer helpers report partial faults so correct callers can commit only the transferred bytes.

try_reserve never sleeps or waits for space or data. Its limit is an upper bound: any nonempty contiguous range can satisfy the call, even if it is shorter than requested. A zero limit is invalid. A full producer or empty open consumer returns WouldBlock; an empty closed consumer reports end-of-file. Wraparound is handled with successive reservations.

Each reservation holds &mut access to its endpoint until it is committed or dropped. Endpoints are move-only, so safe code cannot create a second reservation or move an endpoint while that borrow is live. The frontend serializes concurrent application calls; vOSTD and the host still enforce reservation ownership at runtime, including when safe code forgets a reservation.

Proposed common byte-pipe APIs. Private fields also track the claimed range and transferred prefix. produce and consume may be called repeatedly within the reservation; each advances through its remaining range.

impl Producer {
    /// Reserves up to `limit` bytes without waiting; returns `WouldBlock` if full.
    pub fn try_reserve(&mut self, limit: usize)
        -> Result<ProducerReservation<'_>, PipeError>;
}

pub struct ProducerReservation<'a> {
    producer: &'a mut Producer,
    // Private range and progress state.
}

impl ProducerReservation<'_> {
    /// Produces bytes directly from `src`, reporting partial progress on a fault.
    pub fn produce(&mut self, src: &mut VmReader)
        -> Result<usize, (Error, usize)>;

    /// Publishes a produced prefix and releases unused capacity.
    /// Rejects a length beyond the progress recorded by transfer operations.
    pub fn commit(self, bytes: usize) -> Result<(), PipeError>;
}

impl Consumer {
    /// Reserves available data without waiting; `Ok(None)` means end-of-file.
    /// Returns `WouldBlock` if empty but still open.
    pub fn try_reserve(&mut self, limit: usize)
        -> Result<Option<ConsumerReservation<'_>>, PipeError>;
}

pub struct ConsumerReservation<'a> {
    consumer: &'a mut Consumer,
    // Private range and progress state.
}

impl ConsumerReservation<'_> {
    /// Consumes bytes directly into `dst`, reporting partial progress on a fault.
    pub fn consume(&mut self, dst: &mut VmWriter)
        -> Result<usize, (Error, usize)>;

    /// Releases a consumed prefix; the remaining bytes stay available.
    /// Rejects a length beyond the progress recorded by transfer operations.
    pub fn commit(self, bytes: usize) -> Result<(), PipeError>;
}

Proposed production through a connected, mutable tx: Producer. reader is an OSTD VmReader; partial_result separates the transferred count from an optional memory fault.

let mut reservation = tx.try_reserve(reader.remain())?;
let (n, fault) = partial_result(reservation.produce(reader));
reservation.commit(n)?;

The caller reports n and any fault using its device API’s partial-I/O rules. After commit returns, the mutable borrow ends and tx can be used again. The consumer follows the same sequence with try_reserve, consume, and commit, handling Ok(None) as end-of-file. Nonblocking reservation does not imply that accessing application memory cannot fault; no pipe metadata lock is held during the payload transfer.

Vectored I/O

Asterinas socket callers supply MultiRead and MultiWrite, which operate on collections of application-memory ranges. Their existing methods need a bounded OSTD writer or reader, rather than exposing one underlying buffer. The following reservation-scoped callbacks bridge those interfaces without a staging copy or a dependency from vOSTD on kernel-local traits.

Proposed generic reservation extensions. Infallible describes the retained pipe-side memory; application-memory access can still report a fault.

impl ProducerReservation<'_> {
    /// Lends bounded pipe storage to a local producer and records its progress.
    pub fn produce_with<F>(&mut self, transfer: F) -> Result<usize, (Error, usize)>
    where
        F: for<'a> FnOnce(&mut VmWriter<'a, Infallible>)
            -> Result<usize, (Error, usize)>;
}

impl ConsumerReservation<'_> {
    /// Lends bounded pipe data to a local consumer and records its progress.
    pub fn consume_with<F>(&mut self, transfer: F) -> Result<usize, (Error, usize)>
    where
        F: for<'a> FnOnce(&mut VmReader<'a, Infallible>)
            -> Result<usize, (Error, usize)>;
}

The callback cannot retain its VM view beyond the reservation’s borrow. The framework checks reported progress against the offered range, then records that count for commit; the direct produce/consume helpers measure their own transfer progress. A faulty callback can misreport progress and corrupt its stream, but cannot access beyond the reservation or expose uninitialized storage. Pipe storage is initialized before first exposure to a new pipe or protection scope; returned counts never grant memory authority.

Proposed Asterinas adapter fragment. source: &mut dyn MultiRead is kernel-local; zero-length I/O returns before reservation. The adapter holds no socket-state spin lock during application-memory access.

let mut reservation = tx.try_reserve(source.sum_lens())?;
let (n, fault) = partial_result(reservation.produce_with(|dst| source.read(dst)));
reservation.commit(n)?;

The receive adapter similarly uses consume_with(|src| destination.write(src)). It commits the reported prefix, or commits zero when peeking without consuming. A driver serializes use of each endpoint with a sleepable lock and releases it before waiting for pipe readiness.

Closing a pipe

Closing a connection needs authority that remains with its backend after the data endpoints move. Generic host pipe creation therefore also returns a host-local PipeControl, retained by the creator and not exchangeable. It identifies only that pipe and can be obtained only through checked creation; the safe Vsock backend owns the mapping from a flow to its pipe controls.

Proposed host-only control API; these methods return without waiting for active transfers.

impl PipeControl {
    /// Stops new production and later publication; published bytes drain to EOF.
    pub fn close_producer(&self);
    /// Stops further reservations and production; notifies the producer of closure.
    pub fn close_consumer(&self);
    /// Stops both endpoints and discards queued data.
    pub fn reset(&self);
}

Close, reset, reserve, and commit are ordered under the pipe’s host lock. Closing production rejects new producer reservations and later producer commits; closing consumption also rejects new consumer reservations and producer publication. Reset rejects new producer and consumer reservations and their commits.

An already-running transfer may finish, but its reservation still retains storage and can be finalized for cleanup. Unpublished production is discarded, so a forgotten reservation cannot delay logical closure, although it can retain charged storage until access is known to have ended.

Dropping a reservation cancels uncommitted progress: a producer returns capacity, while a consumer leaves its data available. Both paths reevaluate readiness, including cancellation of a zero-byte prefix. Forgetting a reservation keeps it charged and prevents another reservation or endpoint transfer; explicit close/reset can still signal terminal state while retaining storage.

Dropping a live Producer, including one discarded with a completion, closes production and relinquishes its role; dropping a Consumer closes consumption and relinquishes its role. Successful transfer disarms the sender’s wrapper without closing the transferred role; rejection restores ownership.

Dropping PipeControl releases only that control handle and does not reset live endpoints. These handle drops are bounded and non-sleeping; deferred reclamation uses already reserved records and never releases storage beneath a reservation.

Forgotten handles remain charged. Instance death revokes its roles through host records after stopping carriers, without running Drop; active accesses still retain storage.

Notifications and readiness

Notifications should announce possible progress to an interested driver. Reserving bytes publishes neither data nor returned capacity and sends no readiness notification. This follows the Kernelet Vsock notification experiment: avoiding needless wakeups matters even when payload already uses shared storage.

Readiness means that an operation can proceed without waiting. SpaceAvailable means the producer can reserve at least one byte; DataAvailable means the consumer can reserve published data. These predicates include reservation ownership, not just ring positions. Readiness is advisory: callers still handle WouldBlock and a prefix shorter than requested.

Every commit or cancellation checks both endpoints and latches an interested cause when readiness changes from false to true. This includes releasing an occupied role with a zero-byte commit or reservation drop, even though no byte counter changes. Closure and errors have separate terminal causes.

I/O: readiness notification and polling. Linux poll and epoll let applications wait for readiness, such as readable data, without issuing a blocking read. Their subscriptions can therefore exist independently of blocked readers or writers. Busy polling instead repeatedly checks for work while consuming CPU time, rather than sleeping for notification.

A frontend supplies an interest mask selecting the readiness causes it needs. It combines local blocking demand with independent poll/epoll subscriptions; the absence of a blocked read or write never proves that nobody is polling. vOSTD sees only the aggregate mask and an IRQ binding, not socket policy or task-wakeup registrations. An empty mask suppresses ordinary readiness delivery, while terminal causes remain pending for a bound driver.

Proposed endpoint notification APIs. ProducerInterest selects SPACE_AVAILABLE; ConsumerInterest selects DATA_AVAILABLE. Both support empty(). Event sets contain those readiness causes plus closure and errors.

impl Producer {
    /// Binds a line with empty readiness interest and delivery suppressed.
    pub fn bind_irq(&mut self, line: &IrqLine) -> Result<(), BindError>;
    /// Replaces readiness interest and suppresses delivery until service/rearm.
    pub fn set_interest(&mut self, interest: ProducerInterest)
        -> Result<(), PipeError>;
    /// Takes pending causes without reserving bytes or changing interest.
    pub fn take_events(&mut self) -> ProducerEvents;
    /// Enables delivery only if no eligible cause remains; otherwise returns false.
    pub fn rearm_irq(&mut self) -> bool;
}

impl Consumer {
    /// Binds a line with empty readiness interest and delivery suppressed.
    pub fn bind_irq(&mut self, line: &IrqLine) -> Result<(), BindError>;
    /// Replaces readiness interest and suppresses delivery until service/rearm.
    pub fn set_interest(&mut self, interest: ConsumerInterest)
        -> Result<(), PipeError>;
    /// Takes pending causes without reserving bytes or changing interest.
    pub fn take_events(&mut self) -> ConsumerEvents;
    /// Enables delivery only if no eligible cause remains; otherwise returns false.
    pub fn rearm_irq(&mut self) -> bool;
}

Interest updates, reservation state changes, and rearming share the pipe’s host lock. Enabling a previously disabled cause checks current readiness and latches it if already true; binding or updating interest also observes terminal state. Disabling a cause removes its obsolete readiness bit but preserves closure and errors. rearm_irq checks pending eligible causes, rather than repeatedly testing an already-ready level, so a continuously ready endpoint cannot cause an interrupt loop.

Proposed initial Asterinas receive setup. Keep read interest enabled for sockets exposed to polling until subscriber lifetimes can be tracked reliably. The preallocated worker services pending causes and rearms; the producer uses SPACE_AVAILABLE in the same way.

rx.set_interest(ConsumerInterest::DATA_AVAILABLE)?;
service.schedule();

A frontend with reliable subscription tracking updates the aggregate mask only when demand changes. It may select empty() only when neither blocking callers nor poll subscribers need ordinary readiness; every update schedules service before rearming.

A blocking frontend installs demand before its final readiness check. Its local wait primitive registers before retrying the entire reserve–transfer–commit operation, then sleeps only if progress remains impossible. The Asterinas adapter preserves signal-aware waits and balances each caller’s demand without removing other waiters or poll subscriptions.

Publication before interest installation is found by the installation check or retry; publication afterward leaves a pending cause. The worker updates local readiness and wakes waiters before rearming.

The endpoint lock serializes interest updates with local I/O and is released before sleeping. IRQ callbacks only schedule work; pending causes survive suppression and a reservation’s mutable borrow.

The shared IRQ rules still govern delivery and teardown. Terminal notification is never conditional on ordinary readiness interest. Removal racing registration or rearming must either be observed synchronously or leave a pending terminal cause; it cannot require another data commit. Instance death must publish its terminal state without waiting for a lock held by an evicted carrier, with retained host records supporting any deferred cleanup.

Interrupt delivery and completion draining

An interrupt tells a driver that a device needs service; it need not identify a waiting task. TypedIO delivers queue and pipe notifications through OSTD’s IrqLine callback interface. The frontend driver drains results or checks readiness, then chooses whether to run a completion callback, schedule work, or wake a task. Neither the host nor vOSTD maintains task-wakeup registrations for TypedIO requests.

OSTD: IRQ callbacks. IrqLine::alloc allocates an interrupt line, and on_active registers a callback taking &TrapFrame, the saved processor context. Callbacks execute with local interrupts disabled and should do only short, non-blocking work; this is the top half of interrupt handling. A driver can defer the remaining work to a worker or a bottom half. The line handle owns its callback registrations: dropping it removes those callbacks, while cloning a line shares the line identity but does not copy its registrations. See OSTD’s IRQ model and IrqLine implementation.

Binding a queue to an IRQ line

The frontend allocates a line, registers its callback, and binds the queue to that line. vOSTD translates the binding into an authorized host notification route containing the instance, resource generation, line identity, and target vCPU. The host sees no Rust callback address. Delivery enters vOSTD, which looks up the local line and invokes its callbacks.

Proposed IRQ methods for Queue, using the QueueEvents flags defined above.

impl<P: Protocol> Queue<P> {
    /// Binds this queue to a local IRQ line with notification delivery disabled.
    ///
    /// May allocate. After binding, schedule the driver worker to drain completions
    /// and handle pending notification causes, then call `rearm_irq`.
    /// Repeat that work if `rearm_irq` returns false.
    /// This queue will not request an interrupt until rearming succeeds.
    pub fn bind_irq(&self, line: &IrqLine) -> Result<(), BindError>;

    /// Atomically takes and clears pending `QueueEvents` flags.
    /// Leaves completion records and the attachment's removed state unchanged.
    pub fn take_events(&self) -> QueueEvents;

    /// Enables notifications only if no completions or unhandled causes remain.
    /// Returns false, leaving notifications suppressed, if more service is needed.
    ///
    /// Notifications stay disabled while the worker drains the queue, allowing
    /// one interrupt to cover several completions. A completion can arrive just
    /// after `try_pop_completion` returns `None`, while delivery is still disabled.
    /// Returning at that point could leave the completion queued with no interrupt
    /// to wake the worker. Always finish draining with this handshake.
    ///
    /// The check and enabling step share the host lock used for publication.
    /// Pending work makes it return false, requiring another drain pass.
    /// Otherwise it enables delivery before releasing the lock, so work published
    /// after successful rearming requests an interrupt.
    ///
    /// # Example
    ///
    /// Run this loop in one serialized driver worker after binding the queue
    /// and whenever its IRQ callback schedules work. `pending.complete` delivers
    /// each tagged result. `handle_state_changes` handles `CAPACITY_AVAILABLE`
    /// by prompting submission retries and `REMOVED` by stopping new submissions;
    /// it does not complete requests whose host accesses are still active.
    ///
    /// ```rust
    /// loop {
    ///     let events = queue.take_events();
    ///     // Drain records even if COMPLETION_AVAILABLE was already cleared.
    ///     while let Some(done) = queue.try_pop_completion() {
    ///         pending.complete(done.tag, done.result);
    ///     }
    ///     handle_state_changes(events);
    ///     if queue.rearm_irq() {
    ///         break; // Later work will request an interrupt.
    ///     }
    ///     // Work arrived during draining; retry without waiting for an IRQ.
    /// }
    /// ```
    ///
    /// Scheduling must preserve an IRQ-triggered reschedule even if this worker
    /// has not returned yet. If a processing budget expires before rearming,
    /// schedule another run before returning; do not wait for an interrupt
    /// while delivery remains disabled.
    pub fn rearm_irq(&self) -> bool;
}

Proposed frontend setup. service is an existing driver worker with preallocated work state; schedule is safe in interrupt context, coalesces requests, and preserves a reschedule arriving while the worker runs.

let mut irq = IrqLine::alloc()?;
let weak_service = Arc::downgrade(&service);
irq.on_active(move |_| {
    if let Some(service) = weak_service.upgrade() {
        service.schedule();
    }
});
queue.bind_irq(&irq)?;
service.schedule(); // Start draining explicitly; this queue cannot notify yet.
// Keep `irq`, `queue`, and `service` in the frontend until shutdown.

Binding leaves notification delivery disabled so the frontend can process existing work before enabling interrupts. Completions or removal events may already be pending; waiting for the first interrupt would leave them unprocessed. The worker runs the loop documented on rearm_irq, enabling delivery only when no pending work remains.

The initial implementation uses a fixed target vCPU for each line. Several queues can share one line; its callbacks schedule the corresponding service routines, which inspect their own queues. A driver retains the original line handle carrying the registrations, not just a clone. Polling drivers may leave a queue unbound and use the same completion-draining API.

Queue and pipe bindings are bounded resources; the host accounts for bindings and retained notification state. A request completion does not get its own IRQ line.

The current prototype has virtual-interrupt pending bits and upcalls, but prototype/vostd/src/irq/mod.rs does not yet provide IrqLine; prototype/vostd/src/vcpu.rs treats VIRQ_LINES as a rescheduling signal. Implementing OSTD-compatible IRQ allocation and callbacks, source routing, and the publication/rearm handshake is proposed work. The framework must retain pending source identities independently of the coalesced vCPU bit and recheck them during dispatch, so acknowledgement cannot erase a concurrent notification.

Draining and re-enabling notifications

The host publishes the result with release/acquire synchronization before marking its notification cause pending; the interrupt itself is not a memory barrier. When delivery is enabled, it also suppresses further notifications for that source, sets the virtual-interrupt pending bit, and wakes an idle carrier. Thus an IRQ is a coalesced prompt to inspect state, not one message per completion. Completion records remain queued until consumed, even if interrupts are masked.

A single serialized service routine drains each queue. Its interrupt callback may do a small bounded amount of work directly, or schedule a worker as above. The rearm_irq example runs in that worker; the local pending table selects the appropriate completion action for each tag.

Draining alone is insufficient because finding the queue empty and returning from the worker are separate steps. Suppose try_pop_completion returns None, then the host publishes a completion before the worker returns. Notifications are still suppressed, so that publication does not request an interrupt; returning without rearming could leave the completion unprocessed indefinitely.

rearm_irq closes this gap by checking the completion queue and pending causes under the same host lock used for publication. If work remains, it returns false and the worker drains again; otherwise it enables delivery before releasing the lock. Publication therefore either leaves work for another drain pass or requests a new interrupt after rearming. Merely clearing the vCPU’s interrupt bit never consumes a completion or a source’s cause bits.

take_events, completion draining, and rearming use bounded, non-sleeping service operations. A production worker limits work per pass; if its budget expires, it keeps notifications suppressed and schedules another pass before returning. It must not wait for another interrupt while work remains.

Completion consumption returns submission credits; capacity notifications prompt retry of try_submit, without promising that another producer has not already consumed those credits. Pipe readiness interest must not gate request retirement or completion recording on application waiters: asynchronous drivers still drain abandoned requests. A polling worker can suppress IRQ delivery while actively draining, then use the same rearm handshake before becoming idle.

Waiting belongs to the frontend

A synchronous driver wrapper can still return a local Pending<R> handle, but that handle is ordinary frontend state. It contains a result slot and an OSTD WaitQueue; neither is registered with the host. Before submission the driver inserts the slot into its tag table. Its completion worker stores the result under the slot’s lock, releases the lock, then calls wake_all. It removes the table entry only after dispatching that result.

Proposed frontend-only, uninterruptible helper. Pending::try_take takes the stored result under a lock; the initialized driver services the queue independently of this waiting task.

fn wait_reply<R>(pending: Pending<R>) -> Result<R, RequestError> {
    pending.waiters.wait_until(|| pending.try_take())
}

OSTD’s WaitQueue::wait_until registers a local waiter and rechecks the condition before sleeping, so completion between the initial check and sleep is not lost. No queue lock is held while waiting.

For a batch, the same completion worker updates a shared completed list or remaining-count and wakes a batch wait queue; the caller can wait for any result or all results without serializing device completion. Abandoning a local waiting handle does not prevent the worker from draining that request’s completion.

Removing devices and interrupt bindings

On removal, the framework preserves the removed state, records a notification cause, and rejects new operations while outstanding accesses finish. The driver keeps draining their final completions.

Unbinding disables host routing and invalidates its generation before line reuse; stale notifications cannot reach a replacement binding. Frontend shutdown unbinds the source, unregisters callbacks, and synchronizes with queued worker execution before dropping driver state. Callback removal must synchronize with dispatch and must not run from inside that callback.

Kernelet death detaches routing and stops carriers before abandoning local callbacks; host resource retirement requires no callback into the dead instance.

Lifetime and resource bounds

Dropping frontend objects cannot be the cleanup protocol: a kernelet may forget them or stop executing entirely. Trusted vOSTD and host records therefore track accepted work independently. Drivers need no additional cleanup API to keep host accesses safe.

Retaining and retiring requests

Two retention records protect the same bytes. A trusted vOSTD pending reference retains the DMA owner, preventing local frame reuse or retyping; a host-owned lease keeps the containing grant backing alive independently of instance teardown. Both records must be established before host acceptance, independently of frontend handles. Host records contain page/range descriptions and host-owned state, never a pointer to a kernelet Arc, destructor, callback, or allocator.

Retirement records that backend access has ended. The live kernelet then drains those records: vOSTD processes them, releases its pending references, and makes replies available. Detachment disconnects a dead kernelet from host notifications and completion delivery.

stateDiagram-v2
    [*] --> Prepared: retain backing and reserve quota
    Prepared --> Active: host accepts and retains allocation
    Prepared --> Released: rejected or dropped before acceptance
    Active --> Retired: all backend accesses end
    Retired --> Released: live vOSTD processes final record
    Active --> Detached: kernelet dies
    Retired --> Detached: kernelet dies
    Detached --> Released: no backend access remains

After death, release also waits for carrier eviction and tenant mapping teardown.

For the overview’s 4 KiB read, abandoning the frontend’s local pending handle leaves both retention records active at least until host access ends. Final completion waits for all CPU accesses, DMA, and any read copy-back. Only then may live vOSTD release its pending reference and publish the reply.

Internal retirement processing is independent of a caller waiting for its result. A live vOSTD can release its pending DMA reference after processing retirement; the bounded completion record remains until the driver consumes it or the instance is torn down. If the kernelet stops servicing retirement or completions, it exhausts its credits rather than gaining unlimited retained memory.

Death changes who performs reclamation. The host rejects new work, stops carriers using the existing eviction mechanism, detaches notifications, and destroys tenant mappings before releasing their allocations. The instance’s private state and heap can then be abandoned without invoking vOSTD or kernel-proper destructors. The grant allocation itself stays in a host-owned retired-allocation record until every host lease ends; no completion after detachment touches vOSTD’s reference counts.

The current prototype frees the grant as a unit, so even one retained page can retain the whole grant. Accounting must charge that full extent.

Acceptance, removal, and teardown share a lock-protected decision about whether the instance can accept new work. Host ownership is recorded before work is dispatched, including work that completes immediately. Every failure before acceptance rolls back prepared references; after acceptance, exactly one final completion path owns retirement. Generation counters never wrap into reuse: exhaustion permanently disables the affected set of handle slots. Locks protect records, not payload copies, device calls, or waits.

Cancellation does not establish quiescence, the condition in which all current accesses have ended and no new access can begin. Unstarted work can terminate immediately; submitted Linux I/O retains its storage until completion or a backend-specific reset proves no future access. Safe backend APIs cannot release a retained DMA buffer early: borrowed host views cannot outlive their request, and asynchronous Linux wrappers consume the whole request into a framework-owned record of outstanding I/O. Completion publication waits for all such accesses, even if the backend asks to finish earlier.

Resource bounds

Admission accepts work only after reserving its resources. It charges DMA registration and retention slots, request and completion records, pipe capacity, the full retained allocations, and queued/running worker slots to the originating kernelet. Limits are reserved before acceptance; completions and teardown need no new allocation. Per-instance limits are backed by host-wide limits, so repeatedly killing and restarting kernelets cannot accumulate unbounded retired grants. Shared pipes are charged once to an explicitly designated attachment owner; a peer cannot evade its own request limits by sending work through them.

Worker limits bound each instance’s queued and concurrently executing handlers, backed by host-wide limits. Host-private adapter allocations and outstanding I/O records retain the originating attachment’s TypedIO accounting owner until retirement. These quotas do not enforce per-instance CPU time or Linux memory/I/O control-group limits on shared workers: worker execution does not inherit the carrier’s controller membership. Preserving that attribution requires explicit adapter support and is outside the initial guarantee.

A timeout can fail a peer operation or request cancellation, but cannot authorize freeing memory still accessible by a device. There is no unconditional wall-clock reclamation bound for arbitrary Linux I/O or faulting host user-copy operations. Backends with a bounded reset contract can provide one; otherwise the host quarantines the allocation, keeping it charged and unavailable for reuse. It refuses further admission as needed and reports the outstanding resource. This preserves memory safety while making the availability limitation explicit.

Device designs

The following devices combine the common APIs with device-specific policy and existing Asterinas interfaces.

TypedIO Vsock

Overview

TypedIO Vsock multiplexes many socket connections over a device attachment. A flow is identified by its source and destination addresses, each containing a context identifier (CID) and port. The Asterinas frontend owns its local port bindings, listeners, accept backlogs, and socket state machines. The safe Vsock backend routes control messages between contexts and arranges generic byte pipes for established flows. vOSTD provides typed queues, resource transfer, byte pipes, and IRQ delivery; it does not interpret Vsock addresses or maintain socket tables.

VirtIO: Vsock flows. VirtIO Vsock identifies traffic using src_cid, src_port, dst_cid, and dst_port. Connection establishment uses REQUEST and RESPONSE; RST rejects or resets a connection, and SHUTDOWN communicates direction closure. Binding a port, listening, and accepting are local socket operations rather than messages in that transport protocol. Payload packets and per-socket buffer credits carry the established stream. See VirtIO 1.2, §5.10.6.

TypedIO follows this flow-addressed control model. Once a connection is established, its payload uses two byte pipes instead of packets carrying a repeated flow header. Port allocation and connection policy remain in the frontend and safe backend; generic storage retention remains in vOSTD and the host framework.

Consider endpoint A at 42:5000 connecting to a listener at B’s 7:8000. B’s frontend admits the connection, and the backend obtains two pipes from the generic framework. Each endpoint receives a Producer for sending and a Consumer for receiving: A’s producer feeds B’s consumer, and B’s producer feeds A’s consumer.

Successful connection setup. Both frontends have posted receive slots; transport-send acknowledgements are omitted.

sequenceDiagram
    participant A as Endpoint A<br/>42:5000
    participant H as Vsock backend
    participant F as Generic pipe framework
    participant B as Endpoint B<br/>7:8000

    Note over B: Local listener is ready
    A->>H: ConnectOffer: 42:5000 to 7:8000
    H->>B: Deliver ConnectOffer
    Note over B: Check listener and reserve backlog space
    B->>H: ConnectAccept: 7:8000 to 42:5000
    H->>F: Reserve two pipes and delivery resources
    F-->>H: A-to-B: P_ab, C_ab<br/>B-to-A: P_ba, C_ba
    H-->>A: Established<br/>tx = P_ab, rx = C_ba
    H-->>B: Established<br/>tx = P_ba, rx = C_ab
    Note over A,B: Each side now owns a Producer and a Consumer
    A->>B: A-to-B pipe<br/>A.tx produces, B.rx consumes
    B->>A: B-to-A pipe<br/>B.tx produces, A.rx consumes

P_ab and C_ab are the A-to-B pipe’s producer and consumer; P_ba and C_ba form the reverse pipe. Established attaches these roles to the local sockets.

Protocol types and addressing

VsockConfig supplies the attachment’s assigned CID and limits on flows, pipe capacity, and control queues. Each local socket selects or binds a port within that CID. One attachment can therefore carry, for example, both (42:5000 → 7:8000) and (42:5001 → 7:8000). The destination listener distinguishes these connections by their full address pairs.

If an instance has several Vsock attachments, the frontend also selects the attachment whose configured CID owns the local address.

VsockRequest selects a device operation; VsockControlKind describes a peer handshake or state change.

Proposed shared protocol types, known to both frontend and backend. Address and control types are ordinary exchangeable values; they require no Vsock-specific resource hooks in vOSTD.

/// An operation submitted to the TypedIO Vsock device.
/// Sending control and receiving events use separate queues.
#[derive(Message)]
pub enum VsockRequest {
    /// Submits a control message for validation and routing to its peer.
    SendControl(
        /// The addressed message and connection attempt it concerns.
        VsockControlMessage,
    ),
    /// Posts one receive slot, completed when an incoming event is available.
    ReceiveEvent,
}

/// The successful result of a device operation; failures use the error path.
#[derive(Message)]
pub enum VsockReply {
    /// The backend accepted the control message; connection setup may be pending.
    ControlSent,
    /// Delivers one incoming event and consumes its posted receive slot.
    Event(
        /// The event to dispatch to the frontend’s local socket state.
        VsockEvent,
    ),
}

/// An incoming control message or the data endpoints of an established flow.
#[derive(Message)]
pub enum VsockEvent {
    /// Delivers peer control to the recipient’s socket state machine.
    Control(
        /// The message’s destination identifies the local socket.
        VsockControlMessage,
    ),
    /// Completes the handshake and transfers this side’s two pipe roles.
    Established {
        /// The source is the peer; the destination is this event’s recipient.
        flow: Flow,
        /// Identifies the handshake that created these pipes.
        attempt: AttemptId,
        /// Produces bytes from the local destination toward the peer source.
        tx: Producer,
        /// Consumes bytes produced by the peer source for the local destination.
        rx: Consumer,
    },
}

/// A control message exchanged between two socket addresses.
/// Its flow and attempt identify the connection whose state may change.
/// Stream payload travels through the established byte pipes.
#[derive(Clone, Copy, Message)]
pub struct VsockControlMessage {
    /// Identifies this message’s sender and recipient.
    pub flow: Flow,
    /// Distinguishes this handshake from earlier uses of the same address pair.
    pub attempt: AttemptId,
    /// Specifies the connection-state transition being communicated.
    pub kind: VsockControlKind,
}

/// The meaning of a control message, independent of queue submission.
#[derive(Clone, Copy, Message)]
pub enum VsockControlKind {
    /// Proposes a connection to the destination’s listener.
    ConnectOffer,
    /// Admits the peer’s offer; the backend must still establish the pipes.
    /// This is a protocol response, not the application’s `accept` operation.
    ConnectAccept,
    /// Announces permanent closure of one or both sender-side directions.
    Shutdown(
        /// The directions being closed, from the control sender’s perspective.
        ShutdownDirection,
    ),
    /// Rejects a pending offer or aborts an existing connection attempt.
    Reset,
}

/// Selects which directions the sender of a shutdown message is closing.
#[derive(Clone, Copy, Message)]
pub enum ShutdownDirection {
    /// The sender will produce no more bytes.
    Send,
    /// The sender will consume no more bytes from its peer.
    Receive,
    /// The sender ends both production and consumption.
    Both,
}

/// The ordered address pair carried by a control message or incoming event.
/// A peer’s response reverses the pair; socket lookup also uses `AttemptId`.
#[derive(Clone, Copy, PartialEq, Eq, Message)]
pub struct Flow {
    /// The sender of control, or the peer of an established-event recipient.
    pub source: Address,
    /// The recipient of the control message or established event.
    pub destination: Address,
}

/// A socket address within a host-assigned communication context.
/// Addresses identify sockets but do not grant permission to access them.
#[derive(Clone, Copy, PartialEq, Eq, Message)]
pub struct Address {
    /// The communication context containing the socket.
    pub cid: ContextId,
    /// The socket’s port within that context.
    pub port: Port,
}

/// Identifies a communication context assigned to a Vsock attachment or the host.
#[derive(Clone, Copy, PartialEq, Eq, Message)]
pub struct ContextId(
    /// The host-assigned numeric context identifier.
    pub u64,
);

/// Identifies a local socket binding within one communication context.
/// A connection is distinguished by both local and peer addresses.
#[derive(Clone, Copy, PartialEq, Eq, Message)]
pub struct Port(
    /// The port number selected or bound by the local socket implementation.
    pub u32,
);

/// Identifies one handshake and the connection established from it.
/// The initiator chooses it and peers echo it throughout the connection.
#[derive(Clone, Copy, PartialEq, Eq, Message)]
pub struct AttemptId(
    /// A value never reused for this address pair within an attachment generation.
    pub u64,
);

VsockProtocol implements the common Protocol trait with Config = VsockConfig, Request = VsockRequest, and Reply = VsockReply. These definitions belong to the device protocol; vOSTD uses their generic Message implementations without interpreting their fields.

A reply from the peer reverses the control message’s source and destination and echoes its AttemptId. Each side indexes its connection state by (local address, peer address, attempt).

Attempt IDs prevent delayed messages from changing a later connection. The initiator must not reuse an attempt ID for the same address pair within an attachment generation; stale messages cannot update a new attempt. The backend checks the current attempt and connection state, and attachment removal invalidates its routes. These rules belong to the safe Vsock drivers.

The backend obtains the sender’s attachment identity from HostRequest::caller, verifies that the source CID belongs to it, and applies the attachment’s peer and port restrictions. A caller-supplied address is routing data, not authority. An unknown destination, disallowed route, or invalid state transition is rejected without allocating an unbounded flow record. Framework resource checks still apply independently when a message transfers pipe endpoints.

Control delivery and connection setup

Incoming connection attempts must reach a frontend even when it has sent no request for that flow. The driver keeps a bounded set of VsockRequest::ReceiveEvent operations posted on a receive queue. The backend holds each accepted operation until an event is available, then completes it with VsockReply::Event(event); the driver drains that queue through its IRQ callback and replenishes receive slots. This uses the common request/completion mechanism and never blocks a host worker waiting for the kernelet.

Control sends use a separate queue with independently reserved credits. Completing a send with ControlSent means that the backend accepted the control message; it does not mean that a connection is established. A receive queue occupied by pending receives cannot exhaust the send queue’s credits. Backend pending events, flow records, and pipe storage are bounded and charged; admission reserves space for terminal events so a new connection attempt cannot consume the resources needed to reset an existing flow.

Connection setup proceeds as follows:

  1. The initiating frontend selects its local address, installs a connecting socket entry, and sends ConnectOffer with both addresses and a fresh attempt ID.
  2. The backend validates the source and routes the request to the destination context. The destination frontend looks up its local listener and reserves backlog space. It sends ConnectAccept if the connection can be admitted, or Reset otherwise. No application-level accept call is required to process this handshake.
  3. On a valid ConnectAccept, the safe backend reserves two generic byte pipes and event capacity for both peers. It publishes an Established event to each side, with the appropriate producer and consumer roles. Pipe creation and endpoint transfer use framework mechanisms; flow lookup and the decision to establish the connection belong to the Vsock backend.
  4. Each frontend associates the received endpoints with its socket entry. The initiator completes its local connect operation; the listener places the established socket on its local accept queue. An application’s accept dequeues that socket locally.

Preparation publishes no pipe endpoints until both event deliveries have reserved storage. If allocation fails, the backend rejects the attempt and releases unpublished resources. If a peer disappears after publication, the backend resets the surviving side and closes the associated pipe directions; outstanding reservations retain their storage until access ends. Bounded timeouts can abandon protocol setup, but cannot override those storage lifetimes.

Shutdown describes the sender’s direction closure. Ending production fences new reservations and publication and exposes end-of-file after already-published bytes drain; ending consumption informs the peer that further production cannot succeed. Reset aborts the flow; the backend applies these transitions through generic pipe closure.

The backend rejects late messages for a retired attempt. It retains bounded retirement state until queued control and teardown work is resolved before permitting address-pair reuse.

Frontend and backend integration

Asterinas already separates its local port, listener, and connection tables in the Vsock transport state. Its current implementation is tied to VirtIO and supports one Vsock device.

TypedIO integration introduces a transport beneath those socket state machines. TypedIO puts socket operations above vOSTD’s generic mechanisms. Supporting multiple device attachments additionally requires extending device selection. Multiple sockets on one attachment are part of the flow model from the outset.

Proposed frontend receive dispatch. sockets is an Asterinas-side table; its helpers process control transitions or attach pipe endpoints to the identified local socket.

match event {
    VsockEvent::Control(control) => sockets.on_control(control)?,
    VsockEvent::Established { flow, attempt, tx, rx } => {
        sockets.establish(
            flow.destination, flow.source, attempt, tx, rx,
        )?;
    }
}

The established socket’s send path uses tx.try_reserve, produce, and commit; its receive path uses the corresponding consumer operations. The frontend preserves the existing MultiRead/MultiWrite buffer interfaces, partial-I/O rules, and Pollee readiness notifications. Local connect and accept waiters are awakened by socket-state transitions, not by transport-send completion. See the existing connected-stream layer.

Proposed safe backend sketch. route_control implements the validation, routing, handshake, and bounded event publication above. receivers.enqueue uses the authenticated caller to select its incoming-event queue and retains the receive until it can complete with an event, or an error on removal. Both helpers validate the queue’s configured send/receive role.

impl Backend<host::VsockRequest> for VsockBackend {
    fn handle(&self, request: HostRequest<host::VsockRequest>) {
        match request.message() {
            host::VsockRequest::SendControl(control) => {
                let result = self.route_control(request.caller(), *control);
                match result {
                    Ok(()) => request.finish(host::VsockReply::ControlSent),
                    Err(error) => request.finish_error(error),
                }
            }
            host::VsockRequest::ReceiveEvent => self.receivers.enqueue(request),
        }
    }
}

The backend’s route and flow tables remain ordinary safe Rust state above TypedIO’s generic resource and notification mechanisms.

Linux: files and descriptors. A kernel file object holds an open file or endpoint’s state and operations. A file descriptor is an integer in a particular task’s descriptor table referring to that object. Creating a file on a worker does not install a descriptor in the application’s table.

For host applications, a host-local socket adapter owns the host context’s port bindings, listeners, and accept backlog and participates in the same flow protocol. A trusted Linux file adapter exposes accept/read/write/poll, installs descriptors in the calling task, and retains file and pipe resources across asynchronous work. Failure to expose an established connection to an application resets that flow and closes its pipe endpoints safely. This proposed interface preserves Vsock addressing and stream behavior but does not support unchanged host AF_VSOCK binaries.

The file adapter reports partial user-copy progress and commits only transferred prefixes; a faulting host copy may sleep while its reservation retains storage. A kernelet-to-host or kernelet-to-kernelet stream copies once from the sender’s application memory into a pipe and once from the pipe to the receiver’s memory, without an intermediate backend payload copy. The Vsock backend does not forward established stream payloads. Zero-copy here means eliminating a copy at that transport boundary, not eliminating application-memory copies.

Data-path and waiting policy

The default Vsock path uses direct pipe transfers, interest-gated IRQ delivery, and task-level waits. Waiting inside a Linux carrier would stop unrelated kernelet tasks on that vCPU; the frontend instead parks only the calling task. Busy polling remains an optional policy to evaluate.

The Asterinas adapter uses Pollee, its readiness registration/cache object, and preserves its contract: register before checking, notify when readiness arrives, and invalidate cached readiness when it disappears. It exposes no subscriber-count API, so the initial adapter keeps poll interest enabled for exposed sockets, as in the common example. More precise tracking belongs above vOSTD and must account for subscription lifetimes, not just blocked callers.

The socket frontend keeps Asterinas’s signal-aware SocketPrivate::block_on / Pollable::wait_events path and timeout handling; a new wait queue would use the kernel’s Pause extension. Bare OSTD WaitQueue::wait_until is uninterruptible. Frontend guards balance demand on every return, honoring partial-I/O rules before reporting interruption.

The host file adapter keeps blocking waits separate from poll/epoll delivery. A wait generation counts blocking notifications and terminal changes. The adapter snapshots it before testing the condition, registers demand before unlocking, and waits only while the generation is unchanged.

The active file operation retains its endpoint through lock reacquisition and balances demand on every return. Independent poll delivery and terminal wakeups preserve progress across zero-byte cancellation and peer-death races. Linux wait queues and socket policy remain outside vOSTD.

A large application operation can transfer several available prefixes within a bounded byte/time budget, amortizing service and scheduling overhead. Each prefix is committed promptly, including partial progress before a fault or wait; the driver never waits to fill a batch before publishing a small write. Pipe capacity is negotiated and charged through VsockConfig, not fixed by the prototype’s 64 KiB setting. Measure capacity, batching, and core placement together: larger buffers retain more memory and do not remove request/reply dependencies.

TypedIO Block

Linux: BIO submission and completion. Linux’s separate block-I/O object (bio) describes a block operation with page ranges and an end-I/O callback, bi_end_io. A request submitted through submit_bio may complete asynchronously or even before that function returns. See the Linux 6.12 block API and submission implementation.

The block protocol uses distinct read and write variants so the required host access is visible in Rust; both reuse the bidirectional DMA buffers already held by Asterinas BIOs. The host attaches an authorized block-device range with fixed capacity, logical-block size, transfer limits, and read-only policy. Both frontend and backend validate alignment and reject arithmetic overflow; the host derives the real target from attachment authority, never from a request-supplied Linux device number.

A flush asks the device to make earlier writes survive a loss of power before reporting success. BoundedVec below is a framework list bounded by both the schema maximum and the negotiated segment limit.

Proposed block protocol types.

/// A block operation within an attached device's authorized sector range.
#[derive(Message)]
pub enum BlockRequest {
    /// Reads into buffers writable by the host.
    Read { sector: Sector, buffers: BoundedVec<Slice<Arc<DmaStream>>> },
    /// Writes bytes readable by the host.
    Write { sector: Sector, buffers: BoundedVec<Slice<Arc<DmaStream>>> },
    /// Persists preceding accepted writes before completing.
    Flush,
}

/// A final status published only after every buffer access has ended.
#[derive(Message)]
pub struct BlockReply {
    pub status: BlockStatus,
}

The initial attachment has one ordered submission queue. At successful admission, the framework assigns the queue-local sequence in RequestId and records it before scheduling workers. The safe per-attachment sequencer follows that accepted order independently of handler invocation order. Each accepted entry reaches it as a request or a terminal record, so cancellation before dispatch leaves no gap; rejected submissions create no entry. This bookkeeping uses the already reserved request slots.

Data operations may complete out of order, but Flush waits for all earlier accepted writes and their final status, submits the underlying device flush, and completes only after that flush. Later writes are held until that ordering barrier completes.

Unsupported flush is reported during negotiation and never silently treated as success. Failed or cancelled writes are not made successful by a later flush; their errors remain visible.

The frontend implements Asterinas’s existing block interface, BlockDevice::enqueue(SubmittedBio). It obtains each segment’s Slice<Arc<DmaStream>> through inner_dma_slice(); BioSegment::new_from_segment already accepts USegment. Page-cache callers keep using those objects.

Proposed frontend sketch. request_from_bio checks ranges and direction, creates owned slices by cloning the existing DMA Arcs, and synchronizes write buffers before submission. finish_bio maps the completion result to BIO status and synchronizes successful read buffers before calling SubmittedBio::complete. The pending table is a bounded frontend structure; its helpers roll back ownership on rejection. The IRQ service loop calls drain_completed, then handles notification causes and rearms delivery as described above.

fn enqueue(&self, bio: SubmittedBio) -> Result<(), BioEnqueueError> {
    let request = request_from_bio(&bio)?;
    // Publish local state before the host can complete, even on another vCPU.
    let tag = self.pending.insert(bio)?;
    match self.queue.try_submit(request, tag) {
        Ok(_id) => Ok(()),
        Err(error) => self.pending.reject(tag, error),
    }
}

fn drain_completed(&self) {
    while let Some(done) = self.queue.try_pop_completion() {
        let bio = self.pending.remove(done.tag);
        finish_bio(bio, done.result);
    }
}

BIOs already contain prepared DMA storage. Request construction uses bounded, preallocated metadata; any path needing new DMA storage or other sleeping preparation uses a bounded staging queue and an Asterinas worker.

The driver keeps SubmittedBio until retirement, including its BioSegment objects, so normal pooled-buffer reuse follows existing completion ownership. The trusted pending table additionally retains the DMA owner if a buggy driver drops the BIO early. Holding the whole DmaStream does not prevent its safe pool from reusing a subrange; such reuse can corrupt this kernelet’s I/O, but remains legal untyped access and cannot permit storage retyping.

Synchronous enqueue rejection drops owned segments and the closure, rolls back provisional buffer retention, and returns BioEnqueueError; Bio::submit resets its retained status metadata. The error does not return a BIO. Once enqueue succeeds, every later failure—including preparation or host rejection in a staging worker—calls complete exactly once.

The I/O path is application or filesystem → existing block/page-cache machinery → BIO segments → TypedIO block frontend → host backend → Linux BIO.

The inspected page-cache backend maps an existing cache page for reads, but copies a stable snapshot for writeback. TypedIO preserves that snapshot copy.

After enqueue succeeds, lower-level failure or device removal becomes a final BIO error after every segment is quiescent. No completion closure crosses the kernelet–host boundary; the frontend calls the original closure only while its kernelet instance is alive.

Safe host submission

The block backend example hands its accepted request to the adapter below. This is the ownership boundary between safe device code and the trusted Linux submission machinery.

Proposed trusted adapter’s safe API; host::BlockRequest is generated from the shared request definition.

impl BlockTarget {
    /// Validates and submits a request within this target's authorized range.
    /// Consumes all resources on success; rejection returns the intact request.
    /// Publishes completion only after all BIOs, DMA, and copy-back have ended.
    pub fn submit(&self, request: HostRequest<host::BlockRequest>)
        -> Result<(), Rejected<host::BlockRequest>>;
}

The trusted wrapper builds Linux BIOs from validated host records and establishes ownership before calling submit_bio. It splits requests to respect device limits on separate memory ranges (scatter/gather), combines their final statuses, and defers cleanup that may sleep until execution leaves the interrupt handler.

The wrapper retains pages, the backing device, and module code until bi_end_io and all follow-up work are done. Abandoning frontend request state or dropping a safe host request cannot free storage still owned by a submitted BIO. Allocation failures preserve the same ownership rules.

Direct submission is restricted to coherent, audited DMA paths that do not make incompatible CPU accesses to shared payload. The wrapper selects this mode from trusted target capabilities. For other Linux targets it copies through private bounce storage using the host byte-access adapter; read completion includes the final copy-back. A device mapping or successful Linux page reference alone does not establish that every layer of a storage target permits concurrent shared-byte access.

These wrappers are proposed work. Linux 6.12’s Rust block module exposes the multi-queue driver module mq, not the complete safe consumer-side submission and retained-shared-buffer interface above. Its network module likewise does not supply this stream file adapter. Safe backends are feasible only after implementing and auditing the BIO, byte-access, file/poll, accounting, and work-lifetime wrappers; their code belongs in the TCB budget.

TypedIO Filesystem

A filesystem device can reuse typed requests, DMA buffers, completion credits, and capability-scoped handles. Its protocol must still define lookup, metadata, directory iteration, permissions, and cancellation semantics. Keeping host and kernelet page caches consistent, marking stale cached data invalid, and ordering their locks are additional protocol obligations, not consequences of the queue types. A first implementation should use explicit reads/writes and bounded server requests; host requests to an unresponsive frontend time out without allowing retained memory to be freed prematurely.

TypedIO Persistent Memory

Storage: persistent memory. Persistent memory retains data across power loss and can be accessed with CPU loads and stores. A write visible to another CPU may still reside only in a volatile cache, so durability requires explicit persistence operations and ordering.

Persistent memory needs a longer-lived mapping capability, beyond request-scoped DMA retention. Such a capability must remain authoritative across mappings, withdraw access rights (revoke the capability) only after access has stopped, and define persistence ordering and failure behavior. CPU visibility does not imply durability. TypedIO discovery and accounting apply, but this proposal does not claim that byte pipes or block completion already provide safe direct-access persistent memory.

Evaluation and next steps

The alternatives below are judged against the design goals and requirements, especially safety, integration, trusted-code size, and efficiency.

AlternativeWhy it is not the initial designWhen to revisit
Keep only existing per-device servicesAvoids new framework work but duplicates isolation and lifecycle rules across devicesIf only console output is needed
Transfer arbitrary native Rust objectsCouples heaps, layouts, destructors, and unload lifetimes; only checked exchangeable types are supportedAdditional types with audited transfer contracts
Serialize every message fieldUnnecessary for types with a verified direct-copy representation; retained for composites whose layouts differMeasurements or deployment needs favor a uniform encoding
Require exclusive moves of new TypedIO buffersOffers stronger no-mutation semantics but does not fit cloned/mapped OSTD and BIO storageWorkloads that need immutable snapshots or exclusive zero-copy objects
Use VirtIO as the native device interfacePreserves compatibility, but keeps the binary protocol separate from Rust type contracts and constrains device data paths; see motivationCompatibility with existing VirtIO drivers and backends is the primary goal
Use shared metadata without locks immediatelyCould reduce service calls, but requires proving safe index validation, publication, and withdrawal of accessMeasurements show metadata operations dominate
Copy every block payloadSimplifies access compatibility, but adds bandwidth and latency costsUnsupported target/backing; retained as the fallback

RedLeaf (OSDI 2020), §3.1.1–3.1.5, is the closest ownership baseline. Its exchangeable set includes selected scalar types, recursive composites, tracked shared-heap references (RRef<T>), and supported trait interfaces mediated by proxies. TypedIO adopts recursive admission and tracked resource transfer, but retains existing DMA buffers and device handles rather than a general shared object heap or cross-domain trait calls. Unlike RedLeaf’s pointer exclusion, TypedIO permits raw pointers as opaque addresses without memory access authority.

RedLeaf’s retry design relies on inputs remaining unchanged across failure. A later analysis of Rust-system guarantees, §3, shows how interior mutability can violate that assumption. TypedIO excludes interior-mutable metadata and makes no unchanged-input retry claim for its mutable payloads. RedLeaf already demonstrates zero-copy exchange between components isolated by language rules. TypedIO proposes adapting those principles to Linux-backed kernelets and existing OSTD storage.

Theseus (OSDI 2020), §4.1–4.3, motivates preserving types and ownership across interfaces. Its shared runtime and forced stack unwinding, which runs cleanup for stopped tasks, differ from this book’s destructor-abandoning teardown, so its reclamation argument cannot be imported.

Tock (SOSP 2017), §4.2–4.4, informs per-client accounting and bounded access; its immediate grant reclamation relies on execution assumptions that concurrent Linux I/O does not satisfy.

State spill (EuroSys 2017), §§4 and 7, examines state that one component retains on behalf of another. It explains why moving policy out of a module does not remove outstanding callbacks and shared state. TypedIO deliberately retains a small lifecycle record in trusted host storage.

Performance hypotheses and validation

TypedIO avoids device-emulation machinery on the kernelet side, but still pays for service entry, metadata copying, validation, locks, queueing, and wakeups. Reducing those costs is plausible; superiority over a tuned in-kernel VirtIO backend is not established. The inter-component communication study (PLOS 2021), §§2–3, also motivates measuring register, cache, and abstraction costs rather than counting privilege transitions alone.

Lessons from Kernelet Vsock

The earlier experiment compared three notification policies, numbered 0, 1, and 2 in the implementation. The following medians come from 14 benchmark runs per case of serialized tenant echo, counting payload in both directions, with 64 KiB rings. They measure Kernelet Vsock, not TypedIO.

ModeNotification policy4 KiB (MiB/s)16 KiB (MiB/s)64 KiB (MiB/s)
0Reserve and commit notify the original shared wait queue257.3644.91,510.4
1Suppress reserve notifications321.4709.02,240.8
2Also separate poll delivery and gate blocking commit wakes on ring demand321.11,155.13,320.8

Mode 2 improved goodput over mode 0 by about 1.25×, 1.79×, and 2.20×. At 4 KiB it did not improve median goodput over mode 1, but the median of the per-run 95th-percentile (p95) round-trip latencies fell from 37.77 to 28.69 µs.

Mode 2 changes poll separation and demand gating together, so this comparison cannot isolate their individual contributions. Copies, service count, carrier parking, and memory protection were unchanged.

Separate 64 KiB diagnostic runs reduced unsuccessful retries from 20,608 to 1 while copied volume stayed constant. The counters count notification calls, not delivered interrupts; measured reserve/copy/commit intervals include preemption and are not a partition of CPU cost. The retained evidence, including the benchmark summary and profiling counters, supports eliminating needless notifications before changing handles or memory protection.

These results motivate TypedIO’s notification contract and Vsock waiting policy. Per-pipe locking, subscriber tracking, and task-level IRQ delivery remain unmeasured extensions of that experiment.

These results establish a design priority, not an optimal configuration or a TypedIO speedup. The measured vhost-vsock reference was faster at 4 KiB, while optimized Kernelet Vsock won at 16 and 64 KiB. Its peer ran outside the VM on a different Linux version; the other peers ran inside. The direct-API diagnostic was only about 1–5% faster than the tenant peer at these sizes and changed both syscall entry and memory access, so it does not identify an isolated syscall cost. The experiment did not exercise TypedIO’s proposed task-level IRQ path or establish multi-connection scalability.

Costs and validation

PathPotential benefitCosts and fair baseline
Shared stream pipesOne payload store shared by endpoints; no forwarding copyTwo application-boundary copies, reservation calls, lock/cache traffic; compare matched virtio-vsock/vhost paths
Direct block DMAExisting BIO storage reaches DMA without an added payload copyBuffer-retention bookkeeping and Linux BIO work; VirtIO block can also avoid payload copies
Bounced block I/OSafe compatibility with more targetsExtra copies may erase any transition savings
Host schedulingNo separate userspace backend processIn-kernel vhost already avoids that process; endpoint syscalls and worker scheduling remain
Batched requests/eventsFewer service calls and wakeups per operationLatency tradeoffs, queue contention, and readiness bookkeeping

TypedIO itself has not been executed. Its integration constraints come from source inspection; the preceding measurements concern Kernelet Vsock. The prototype baseline is repository revision 9cb55eccba6a0afe7ec162b286752ccc5ff267c8, with Linux 6.12 and the Asterinas revision cited above. Existing frame, memory-access, BIO, and socket APIs establish integration constraints; none establishes the soundness or speed of the proposed wrappers.

Implementation validation must audit every unsafe wrapper against its access and lifetime contract, then test the stated requirements:

AreaCases and acceptance criterion
Message transferAudit nested types, layout mismatches, padding, opaque pointers, dependency admission, stale device/DMA/IRQ generations, and resource-transfer rollback. No invalid Rust value or unauthorized access may be constructed.
Request lifetimeExercise submit-versus-kill, inline/late completion, abandoned requests, early BIO drop, and partial copy-back. No access may reach reclaimed storage; each retired request has one final result and no callback enters a detached instance.
Queues and IRQsExercise out-of-order completion, shared lines, drain/rearm races, interest installation/removal versus publication or death, polling, and batch waits. Retained work must remain discoverable without a lost wake or unbounded interrupt loop.
Vsock and pipesExercise concurrent flows, incoming handshakes, delayed controls/address reuse, receive-credit exhaustion, partial/vectored transfers, full-to-available and reservation-release transitions, zero-byte cancellation, poll/epoll-only listeners, interrupted waits, peeking, endpoint drop/transfer, discarded completions, forgotten reservations, and shutdown races. Preserve ordering and partial-I/O behavior; no endpoint may access beyond retained storage.
Block integrationRun existing BIO/page-cache callers through pooled buffers, DMA direction/range checks, streaming synchronization, and flush barriers. Preserve completion ownership, writeback snapshots, and persistence ordering.
Resource boundsExercise saturation, quarantine, and repeated death/restart under per-instance and host-wide quotas. Charges remain until resources can be reclaimed, including whole grants retained by small requests.

Performance comparisons must match backing devices, payload sizes, queue depths, core placement, polling, and batching across userspace and in-kernel VirtIO baselines. A minimal per-device native service with the same storage, scheduling, and copy policy isolates common-framework overhead. Compare direct metadata copying with generated field transfer where both apply; measure throughput, tail latency (delays among the slowest operations), CPU time, payload copies, service/notification counts, and retained memory.

Keep instrumented diagnostics separate from timing runs; count reservation/commit service calls, eligible and suppressed notifications, IRQ deliveries, worker activations, unsuccessful retries, and lock contention. Use benchmark-only controls to compare reserve-and-commit notifications, commit-only notifications, and independent poll/demand gating within TypedIO, holding storage, locking, and task scheduling fixed. Then isolate subscriber tracking, task-level waits, and batching.

Cover small-message echo, sustained streaming, multiple connections, and several runnable tasks per vCPU; sweep pipe capacity and verify notification savings do not merely move work into polling or worsen tail latency.

Include asynchronous backend CPU within a stated accounting scope without adding overlapping endpoint and VM totals. A win in one configuration does not establish a general ordering.

Integration work

Implementation would package the shared protocol definitions in Rust libraries (crates), add frontend adapters in Asterinas and common primitives in vOSTD, and place a Rust host framework beside the endovisor. The image ABI needs versioned discovery, submission, retirement, pipe, and IRQ-routing services. The host grant teardown must defer backing release independently of teardown of the instance’s private state and heap; the accepted ABI and stopping chapters would need updates only after approval.

The remaining implementation obligations are implementing OSTD-compatible DMA and IRQ interfaces, proving request retention and notification rearming, proving the message-derivation and transfer contracts, auditing each direct-DMA target, proving the cross-kernel byte-access wrapper, and implementing the identified safe Linux wrappers. Failure of those audits requires a copying path or a narrower supported target set, not weakening memory safety.