HW2: A VT-x hypervisor for xv6 (part 2, “Booting xv6”)

This is the second homework in the series in which you build a hypervisor for xv6-64 that runs inside xv6 itself. In HW1 your hypervisor ran a small “hello world” guest. In this homework it runs a complete copy of xv6 as the guest: the guest boots, mounts its own file system from an emulated disk, and gives you an interactive shell, all inside a VM that your xv6 kernel created.

$ runxv6 xv6kernel xv6fs.img
runxv6: booting xv6kernel (type Ctrl-] to stop)
xv6...
cpu0: starting 0
cpu0: no VMX support
sb: size 2000 nblocks 1941 ninodes 200 nlog 30 logstart 2 inodestart 32 bmap start 58
init: starting sh
$ ls
.              1 1 512
..             1 1 512
README         2 2 1688
cat            2 3 17736
...

Everything after the first line is printed by the guest. cpu0: no VMX support is the guest’s own vmxinit() running, and finding that your hypervisor hides VT-x from it.

HW1 was about the CPU side of virtualization: VMX operation, the VMCS, the EPT, entering and leaving the guest. HW2 is about everything a real operating system needs besides a CPU:

As in HW1, the work is split between the kernel (vmx.c), which does what only the kernel can do, and a user program, now runxv6, which plays the role QEMU plays on top of KVM: it pretends to be the PC around the guest.

How to use this handout

The handout has the same shape as HW1’s. Each part has an introduction, a table of what is given and what you write, steps with code to read or paste (each with the sections of the Intel manual it relies on), a few Your turn steps where you write the code yourself, a checkpoint, and questions whose answers go in answers-hw2.md.

Two things are new:

The section numbers are those of the Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3 (order number 325384-092, June 2026), written “SDM”, as in HW1. This edition renamed two fields you will use in Part 4: the “IDT-vectoring information” is now the “original-event identification”, and the “VM-entry interruption information” is now the “injected-event identification”. The handout gives both names.

Questions that came up while this homework was being built are answered where they arise, often under a heading or in bold phrased as the question: “Where is the guest kernel loaded?” (Part 3), “Why the highest vector first?” (Part 4), “How does the guest’s keyboard work?” and “Are we ignoring the guest’s screen?” (Part 5), and so on.

Getting the code

HW2 continues your HW1 repository. The starter code for HW2 is the hw2-assign branch of the course repository; merge it into your HW1 work:

$ cd xv6-vmx-homeworks            # your HW1 clone
$ git commit -am "HW1 final"      # if you have uncommitted work
$ git fetch origin
$ git merge origin/hw2-assign

hw2-assign adds new files (runxv6.c, timertest.c) and new functions, and changes given files (the file system, the console, the Makefile, the headers). It does not change the functions you wrote in HW1, so the merge should go through without conflicts. If git reports a conflict, keep your own version of the function and apply the HW2 step for it from this handout by hand.

Your hypervisor from HW1 must work: run runvm helloguest first. If you did not finish HW1, ask the course staff before you start.

Where to run it

Exactly as in HW1: QEMU with KVM on the CADE lab machines, or Bochs anywhere else (see the HW1 handout for the setup). Under Bochs, everything runs inside an emulated CPU, so the guest xv6 is emulated twice over; it is still quick enough, a few seconds to boot.

The new commands:

$ runxv6 xv6kernel xv6fs.img      # boot the guest

In the guest, use the shell as usual. Type Ctrl-] to stop the guest and return to the host’s shell. (Ctrl-C does nothing in xv6, host or guest.)

xv6kernel is the guest kernel and xv6fs.img its disk image. Both are built by make fs.img and stored in the host’s file system; Part 1 explains how.

Debugging

The shared debugging guide applies unchanged: you debug the host kernel, with gdb attached to QEMU or with the Bochs debugger. The guest is just code that runs between vmx_enter and vmx_exit; to see what it is doing, print from runxv6 (every exit it sees) or from vmxhandle in the kernel. When the guest stops at an address, look it up in the guest kernel, not the host’s (they are built from the same source but are different binaries):

$ x86_64-elf-objdump -S xv6kernel > xv6kernel.asm     # then search for the address

The big picture

Two xv6s, one CPU

Two xv6s, one CPU: the host xv6 kernel runs on the PC and contains the hypervisor module (vmx.c). The runxv6 process calls into it with system calls and gets struct vmexit back. Next to runxv6, the guest xv6 runs in its own VM: the hypervisor module enters it with VMLAUNCH and gets VM exits back.

The guest kernel is the same xv6-64 source as the host, compiled a second time for a smaller machine (32 MB of memory, a 2000-block disk).

A run, step by step

  1. Load. runxv6 reads xv6kernel, an ELF file, and copies its segments into an empty 32 MB VM with vmmem() (Part 3). There is no BIOS and no boot sector: xv6-64’s entry point expects 32-bit protected mode with paging off, which is exactly the state your HW1 vmcsinit already sets up.
  2. Firmware tables. runxv6 writes the few bytes a BIOS would have left in memory, most importantly the MP tables that describe one CPU and one I/O APIC (Part 3).
  3. Boot. The guest kernel runs. It switches to 64-bit mode (the kernel emulates its EFER write, as in HW1), sets up its page tables and its IDT, and starts talking to “hardware”. Every port I/O instruction exits to runxv6, which emulates the device (Part 5). CPUID exits to the kernel, which hides VT-x (Part 3).
  4. Interrupts. The guest’s timer, its serial port and its disk need interrupts. runxv6 decides when one happens and asks the kernel to inject it; the kernel delivers it through the guest’s IDT at the next VM entry, as soon as the guest can take it (Part 4).
  5. Disk. The guest reads its file system with rep insl, which moves 512 bytes straight into a buffer at a guest-virtual address. The kernel decodes the instruction, and runxv6 copies the data through the guest’s own page tables (Parts 2 and 5).
  6. Console. What the guest prints arrives at runxv6 one character at a time through the serial port; what you type goes the other way, into the guest’s serial port, with an interrupt (Part 5).

What the guest’s PC has

Device Where In this homework
Serial port COM1 (16550) ports 0x3F8-0x3FF emulated in runxv6: output and input, IRQ 4
IDE disk, drive 1 ports 0x1F0-0x1F7, 0x3F6 emulated in runxv6 from xv6fs.img, IRQ 14
CRT controller (cursor) ports 0x3D4, 0x3D5 emulated in runxv6 (two registers)
Timer (local APIC) runxv6 injects vector 32 once per host tick
Local APIC, I/O APIC 0xFEE00000, 0xFEC00000 zeroed memory pages: enough for xv6 to boot
CPUID, the EFER MSR instructions emulated in the kernel
VGA text screen memory at 0xB8000 ordinary guest memory, not displayed
PIC, PS/2 keyboard, CMOS ports absent: reads return all ones

The parts

Part Topic Where You write
1 The host side: two kernels, large files, raw console given (read, answer questions)
2 Reading and writing guest memory vmx.c gva2gpa()
3 Booting xv6 in a VM vmx.c, runxv6.c vmxcpuid(), mptables(), and small changes to your HW1 code
4 Interrupts vmx.c vmxinject(), and changes to your vmrun/vmxhandle
5 Devices and string I/O vmx.c, runxv6.c idestring(), and a change to your vmxhandle/vmrun

Part 1: The host side: what changes in xv6 itself

Before your hypervisor can boot xv6, the xv6 it runs on has to change. Three things are missing from the HW1 tree:

All of this is given in the starter tree. Nothing in this part is yours to write: read the code, make sure you understand why it is there, build it, and answer the questions at the end. The later parts depend on it, and so does your debugging: when something goes wrong in Part 5, it helps to know what consraw() does to your terminal.

The part ends with a tour of the new system-call interface in vmxapi.h, which Parts 2 to 5 fill in behind.

What is given and what you write

File What changed Status
Makefile builds xv6kernel (objects in guest/), mkfsguest and xv6fs.img; fs.img now holds both given, complete
memlayout.h, param.h PHYSTOP and FSSIZE can be overridden with -D; the host disk is 4000 blocks given, complete
fs.h, fs.c (bmap(), itrunc()), mkfs.c (iappend()) addrs[NDIRECT] is doubly indirect; a file can be about 8 MB given, complete
dot-bochsrc, dot-bochsrc-nox the Bochs disk is 4000 blocks, to match FSSIZE given, complete
console.c (consraw(), consready(), raw branches in consoleintr() and consoleread()) a raw mode for the console given, complete
pipe.c (pipeready()) “would a read block?” for pipes given, complete
sysfile.c (sys_fdready(), sys_consraw()) two new system calls given, complete
sysvmx.c (sys_vmmem(), sys_vminject()) two new system calls; the kernel functions behind them are Parts 2 and 4 given, complete
vmxapi.h VMMEM_* flags, EXIT_INTR_WINDOW, the string-I/O fields of struct vmexit, VM_LAPIC_ADDR, VM_IOAPIC_ADDR given, complete
syscall.h, syscall.c, usys.S, user.h, defs.h the usual plumbing for the four calls (numbers 26 to 29) given, complete
timertest.c a test program for the guest’s timer (Part 4) given, complete

You write nothing in this part.

Where to look things up. Most of this part is ordinary xv6. The xv6 book’s file system chapter (the inode layer and its “inode content” section) describes the stock block map that Steps 4 to 7 change. Step 13 cites the SDM, as before: the Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3 (order number 325384-092, June 2026).


Step 1: Two kernels from one source: the guest’s parameters

Reference: Makefile (the “xv6 guest” section), memlayout.h, param.h.

The guest is xv6 itself, compiled a second time. Almost nothing in the kernel has to differ between the host and the guest, because your hypervisor makes the guest’s machine look like a PC. Two numbers do differ: how much physical memory the machine has, and how big its disk is. The Makefile names them once:

# Size of the guest's disk in blocks. The guest kernel (ide.c) and
# mkfsguest are both compiled with it. 2000 leaves room to run stressfs
# and usertests2 in the guest; 1000 started almost full.
GUESTFSSIZE = 2000
# PHYSTOP 0x2000000 is 32 MB, the guest memory runxv6 asks vmcreate()
# for (GUESTMEM in runxv6.c); the two must agree. memlayout.h and
# param.h use these values instead of their defaults.
GUESTDEFS = -DPHYSTOP=0x2000000 -DFSSIZE=$(GUESTFSSIZE)

and the two headers let a -D on the compiler’s command line win over their defaults:

// HW2: xv6 assumes it has physical memory up to PHYSTOP (224 MB here)
// and hands all of it to kalloc(). A guest has only what runxv6 gives
// it (GUESTMEM, 32 MB), and the EPT maps nothing above that, so the
// guest kernel is built with -DPHYSTOP=0x2000000 (Makefile GUESTDEFS);
// otherwise its kinit would touch unmapped guest-physical memory and
// cause an EPT violation. The #ifndef lets the -D value win.
#ifndef PHYSTOP                     // the guest kernel is built smaller
#define PHYSTOP 0xE000000           // Top physical memory
#endif
// HW2: FSSIZE is the size of the disk that holds the root file system.
// mkfs builds an image of exactly FSSIZE blocks, and ide.c panics on a
// block number at or past it. The host disk grew from stock xv6's 1000
// blocks to 4000, so that it can also hold xv6kernel and xv6fs.img
// (each 1 MB or less); dot-bochsrc gives Bochs a disk of the same size.
// The #ifndef lets the Makefile build the guest kernel and mkfsguest
// with -DFSSIZE=$(GUESTFSSIZE), for the guest's smaller disk; both must
// agree with the image runxv6 serves.
#ifndef FSSIZE                      // the guest file system is smaller
#define FSSIZE       4000  // size of file system in blocks
#endif

PHYSTOP: why the guest kernel must know its memory size. xv6 does not probe for memory. It assumes RAM from 0 up to PHYSTOP, and at boot kinit1() and kinit2() put every page below PHYSTOP on the free list. kfree() fills each page with junk (memset(v, 1, PGSIZE)) as it does so, so the kernel writes to every one of those pages early in boot, before it starts its first process. The guest’s “RAM” is exactly what runxv6 asked vmcreate() for:

#define GUESTMEM (32*1024*1024)

Your EPT maps guest-physical 0 to GUESTMEM and nothing above it except the two APIC pages. If the guest kernel kept the host’s PHYSTOP (224 MB), its kinit1() would walk up through memory and, at the first page past 32 MB, touch a guest-physical address the EPT does not map. The CPU exits with an EPT violation, and runxv6 stops with guest touched unmapped guest-physical 0x2000000. The opposite mistake (a PHYSTOP smaller than GUESTMEM) is harmless: the guest simply never uses the rest. The two numbers live in different files (Makefile and runxv6.c), and nothing checks that they agree, so if you change one, change the other.

Why 32 MB? It has to be at most VM_MAXMEM (64 MB), or vmcreate() refuses it. Every guest page is a host page from kalloc(), so 32 MB is 8192 of the host’s pages, out of the host’s 224 MB; you can still run other programs next to the guest. xv6’s programs are tiny, so 32 MB is plenty for them. (The one exception is usertests1, whose sbrktest wants 100 MB; it fails in the guest, as expected.) And memory costs time: vmcreate() zeroes every page and the guest’s kinit writes every page again, which you notice under Bochs.

FSSIZE: why the guest kernel must know its disk size. FSSIZE is used in two places. mkfs builds an image of exactly FSSIZE blocks, and the kernel’s IDE driver refuses any larger block number:

  if(b->blockno >= FSSIZE)
    panic("incorrect blockno");

So the guest kernel, mkfsguest, and the image runxv6 serves must all agree on 2000.

Why 2000 blocks? The guest’s disk holds the same programs as the host’s, minus two (Step 3), so it starts with about 820 blocks in use. The first version used stock xv6’s 1000 blocks, and the disk started with fewer than 200 free blocks: running stressfs and then usertests2 in the guest ran out of blocks. With 2000 blocks the guest prints sb: size 2000 nblocks 1941 ... at boot, and has room for the tests. The host’s disk grew too, from 1000 to 4000 blocks, because it now holds both guest files (Step 3); dot-bochsrc and dot-bochsrc-nox describe a 4000-sector disk to match.

What is stressfs? One of xv6’s test programs: it forks five processes, each of which writes a 10 KB file and reads it back, all at the same time. Nothing runs it unless you type it (or the autograder does). In the guest it is a good test of the disk emulation under concurrency: several processes wait for the disk at once, and every request must get its interrupt (Parts 4 and 5).


Step 2: Two kernels from one source: the guest/ directory

Reference: Makefile; the GNU make manual on pattern rules.

The guest kernel is built from the same .c and .S files as the host kernel, but with GUESTDEFS. Its object files go in a directory of their own:

# The guest kernel's objects go in their own directory, guest/. They
# come from the same sources as the host kernel's but are compiled with
# other -D values, so they must not share file names: in one directory,
# building one kernel would overwrite the other's bio.o and so on, and
# make, which judges only by timestamps, would link a kernel from
# objects built for the other one.
GUESTOBJS = $(addprefix guest/,$(OBJS))
# The guest's programs: the host's, minus the two VT-x monitors. The
# guest has no VMX (vmxcpuid() hides it), so they could only fail.
GUESTUPROGS = $(filter-out _runvm _runxv6,$(UPROGS))

# The guest kernel's C files. "$(OBJS): CFLAGS += ..." above applies
# only to the targets in OBJS (bio.o, ...), not to guest/bio.o, so the
# kernel flags are repeated here. CFLAGS has -MD, so gcc also writes
# guest/%.d, the headers each object depends on. make does not create
# directories itself, hence mkdir -p (@ keeps make from echoing it).
guest/%.o: %.c
	@mkdir -p guest
	$(CC) $(CFLAGS) -m64 -mcmodel=large -DX86_64 $(GUESTDEFS) -c -o $@ $<

# The guest kernel's assembly files (entry.S includes memlayout.h and
# param.h), with the same -D values as the C files.
guest/%.o: %.S
	@mkdir -p guest
	$(CC) $(ASFLAGS) $(GUESTDEFS) -c -o $@ $<

Why not build the guest kernel in the same directory, say with make kernel GUEST=1? Because make decides what to rebuild only by comparing timestamps. kalloc.o built for the host and kalloc.o built for the guest have the same name and the same sources, so make cannot tell them apart: after building one kernel, it would consider the other one’s objects up to date, and quietly link a guest kernel with the host’s PHYSTOP (or the reverse). Separate file names (guest/kalloc.o) make the two builds independent, and both kernels are always correct after a plain make.

Two details. -include guest/*.d at the end of the Makefile reads the header dependencies gcc -MD writes, so editing param.h rebuilds the guest objects too. And entry.S is compiled twice as well, since it includes memlayout.h.


Step 3: xv6kernel, mkfsguest, xv6fs.img and the new fs.img

Reference: Makefile.

# Linked like kernel, with the same linker script, from the guest
# objects. initcode and entryother come from the host build: they do
# not use PHYSTOP or FSSIZE, so the same binaries serve both kernels.
# runxv6 loads the ELF file itself, so no boot block or xv6.img-style
# disk is needed.
xv6kernel: $(GUESTOBJS) guest/entry.o entryother initcode kernel.ld
	$(LD) $(LDFLAGS) -T kernel.ld -o xv6kernel guest/entry.o $(GUESTOBJS) -b binary initcode entryother

# mkfs for the guest's disk. mkfs takes the image size from FSSIZE at
# compile time, so the guest's needs its own binary, built with
# GUESTFSSIZE. It depends on the Makefile because GUESTFSSIZE is set
# here: changing it must rebuild mkfsguest.
mkfsguest: mkfs.c fs.h param.h Makefile
	gcc -Werror -Wall -DFSSIZE=$(GUESTFSSIZE) -o mkfsguest mkfs.c

# The guest's disk image: GUESTFSSIZE blocks with the guest's programs.
xv6fs.img: mkfsguest README $(GUESTUPROGS)
	./mkfsguest xv6fs.img README $(GUESTUPROGS)

# The host's disk. HW2 adds xv6kernel and xv6fs.img as ordinary files,
# so that, inside xv6, "runxv6 xv6kernel xv6fs.img" can read them.
# Both are larger than stock xv6's 70 KB file limit, hence the
# doubly-indirect block in fs.h.
fs.img: mkfs README $(UPROGS) helloguest xv6kernel xv6fs.img
	./mkfs fs.img README $(UPROGS) helloguest xv6kernel xv6fs.img

Four products, nested like Russian dolls:

Why the guest gets no runvm or runxv6. GUESTUPROGS filters them out. The guest’s CPU has no VMX: your vmxcpuid() (Part 3) clears the VMX bit, so the guest’s own vmxinit() prints cpu0: no VMX support, and every vmcreate() in the guest would fail. The two monitors could only print an error, and they would cost about 60 KB of a small disk. (Running a hypervisor inside the guest is nested virtualization: your hypervisor would have to emulate VMX instructions for the guest. That is far beyond this homework.)

timertest, new in UPROGS, is on both disks: it is a test for Part 4, meant to be run in the guest.


Step 4: Large files: the limit and the new inode layout

Reference: fs.h; the xv6 book, file system chapter (inode content); figures/fs-layout.svg.

A stock xv6 inode holds 13 block addresses. addrs[0] to addrs[11] point straight at data blocks; addrs[12] points at one indirect block, a block full of addresses. A block is 512 bytes and an address 4, so an indirect block lists 128 blocks. The largest file is therefore 12 + 128 = 140 blocks, or 71,680 bytes: 70 KB.

That is far too small. xv6kernel is about 270 KB (273,608 bytes in the reference build, 535 blocks) and xv6fs.img is 1,024,000 bytes (2000 blocks). Without a change, mkfs stops on its assert(fbn < MAXFILE) while building fs.img, and even if it did not, xv6 could not read past the 70 KB mark.

HW2 replaces the singly-indirect block with a doubly-indirect one:

// Block addresses in an inode (HW2): NDIRECT direct blocks, then one
// doubly-indirect block. That block lists NINDIRECT indirect blocks,
// each of which lists NINDIRECT data blocks, so a file can hold a whole
// guest kernel or disk image (up to about 8 MB). Stock xv6 had a
// singly-indirect block in the same slot, which limited a file to
// 12 + 128 blocks (70 KB). See figures/fs-layout.svg.
//
// NDIRECT stays 12, and addrs[NDIRECT] is still the only pointer to
// more blocks, so struct dinode keeps its 13 addresses and its 64
// bytes: 8 inodes still fit exactly in a 512-byte block (IPB below,
// and mkfs asserts that BSIZE is a multiple of the inode size), and the
// in-memory struct inode (file.h) is unchanged. The cost is that a file
// just past 12 blocks needs two pointer blocks (the doubly-indirect one
// and its first indirect one) instead of one.
#define NDIRECT 12
// Block addresses per indirect block: 512 / 4 = 128.
#define NINDIRECT (BSIZE / sizeof(uint))
// Data blocks reached through the doubly-indirect block: 128 * 128.
#define NDINDIRECT (NINDIRECT * NINDIRECT)
// Largest file in blocks: 12 + 16384 = 16396 blocks, 8,394,752 bytes.
#define MAXFILE (NDIRECT + NDINDIRECT)

The xv6 disk layout, a 64-byte inode with 12 direct addresses and one doubly-indirect address, and the tree of pointer blocks below it.

The figure shows the whole path. The disk (top) is unchanged except for its size. The inode (middle) is byte for byte the stock one: 12 bytes of header and 13 four-byte addresses, 64 bytes, so 8 inodes still fill a 512-byte block exactly. Only the meaning of addrs[12] changed (bottom): it now points to a block of 128 addresses of indirect blocks, each of which lists 128 data blocks.

To find block bn of a file (counting from 0):

So file blocks 12 to 139 go through indirect block 0, blocks 140 to 267 through indirect block 1, and so on, up to 12 + 128 x 128 = 16,396 blocks (8,394,752 bytes, about 8 MB). On a 4000-block disk, no file can get anywhere near that.

Only three pieces of code know the shape of this tree: bmap() and itrunc() in the kernel, and iappend() in mkfs. The next three steps show them. readi(), writei() and everything above them call bmap() and never see the difference. file.h does not change, because struct inode keeps addrs[NDIRECT+1].


Step 5: bmap(): finding (and allocating) a block

Reference: fs.c.

bmap(ip, bn) returns the disk address of block bn of the file, allocating the block, and any pointer block on the way to it, if it does not exist yet. The direct case is unchanged. This is the new indirect case:

  bn -= NDIRECT;

  // From here on bn counts blocks past the direct ones, 0 to
  // NDINDIRECT-1. Write it as bn = i * NINDIRECT + j: i = bn / NINDIRECT
  // picks the indirect block (the entry in the doubly-indirect block),
  // and j = bn % NINDIRECT picks the data block within it. So blocks
  // 12-139 of the file go through indirect block 0, blocks 140-267
  // through indirect block 1, and so on.
  if(bn < NDINDIRECT){
    // Load the doubly-indirect block, allocating if necessary, then the
    // indirect block it lists for this bn, then the data block.
    // A new doubly-indirect block only changes the in-memory inode; the
    // caller (writei) writes the inode to disk with iupdate().
    //
    // A write here can log one more pointer block than in stock xv6
    // (two at a 128-block boundary). It still fits: filewrite() writes
    // at most 1536 bytes per transaction, so at worst 4 data blocks,
    // the inode, the bitmap, this block and 2 indirect blocks, 9 of the
    // MAXOPBLOCKS (10) a transaction may write.
    if((addr = ip->addrs[NDIRECT]) == 0)
      ip->addrs[NDIRECT] = addr = balloc(ip->dev);
    // balloc() zeroes new blocks, so a new pointer block lists nothing.
    bp = bread(ip->dev, addr);
    a = (uint*)bp->data;
    if((addr = a[bn / NINDIRECT]) == 0){
      a[bn / NINDIRECT] = addr = balloc(ip->dev);
      // The new pointer is only in the buffer cache. log_write() makes
      // it part of the current transaction: it is written to disk at
      // commit, together with the bitmap change in balloc(), and the
      // buffer stays cached until then. Without it, the pointer would
      // be lost once the buffer was reused, and the block would stay
      // allocated but unreachable.
      log_write(bp);
    }

The first level. If the file has no doubly-indirect block yet, allocate one. balloc() returns a zeroed block, so all 128 entries of a new pointer block are 0, which means “not allocated yet”. Then read it and look up entry bn / NINDIRECT; if it is 0, allocate that indirect block and record it with log_write().

log_write() is the step people forget. Changing a[...] changes only the copy in the buffer cache. Unless the buffer is logged, the change never reaches the disk: when the cache reuses the buffer, the new pointer is gone, and the block that balloc() marked as used is allocated but unreachable, a leak that itrunc() can never free. The new address in the inode (ip->addrs[NDIRECT]) needs no log_write() here, because writei() calls iupdate() after every write that touches the inode.

    // Release the doubly-indirect block before reading the next level:
    // nothing more is needed from it, and holding two buffers at once
    // would only use up more of the small buffer cache.
    brelse(bp);
    bp = bread(ip->dev, addr);
    a = (uint*)bp->data;
    if((addr = a[bn % NINDIRECT]) == 0){
      a[bn % NINDIRECT] = addr = balloc(ip->dev);
      // Same reason as above, for the pointer to the data block.
      log_write(bp);
    }
    brelse(bp);
    return addr;
  }

  panic("bmap: out of range");
}

The second level is the same pattern, one block further down: read the indirect block, look up entry bn % NINDIRECT, allocate the data block if needed, log the pointer block. The code releases the doubly-indirect buffer before reading the indirect one, so it never holds more than one buffer: xv6’s cache has only NBUF (30) buffers, shared by every process.

Does a write still fit in one log transaction? xv6 bounds every transaction by MAXOPBLOCKS (10) distinct blocks, and filewrite() splits large writes into pieces small enough to respect it. With one more level of pointer blocks, a piece can now dirty one more block than before, so the comment in the code checks the arithmetic: a 1536-byte piece touches at most 4 data blocks, plus the inode, the bitmap, the doubly-indirect block and (at a 128-block boundary) two indirect blocks: 9 blocks, still within 10.


Step 6: itrunc(): freeing the tree

Reference: fs.c.

When the last link to a file goes away and nothing holds it open, itrunc() frees all its blocks. The direct blocks are freed as before; the indirect part now walks two levels:

  // HW2: the doubly-indirect tree. Free it bottom up: the data blocks,
  // then the indirect block that listed them, and the doubly-indirect
  // block last. A pointer block is the only record of its children, so
  // it must stay allocated while it is being read: once freed, another
  // process's balloc() could take it and zero it before we got to its
  // entries, and those blocks would never be freed. A zero entry means
  // no block was allocated there; skip it.
  //
  // bfree() changes only the bitmap, and log_write() logs a block once
  // per transaction however often it changes, so truncating even an
  // 8 MB file logs just the bitmap and the inode: one transaction.
  if(ip->addrs[NDIRECT]){
    // Free each indirect block's data blocks, then the indirect block.
    bp = bread(ip->dev, ip->addrs[NDIRECT]);
    a = (uint*)bp->data;
    for(j = 0; j < NINDIRECT; j++){
      if(a[j]){
        // Indirect block j: read it, free the data blocks it lists,
        // and release its buffer before freeing the block itself.
        bp2 = bread(ip->dev, a[j]);
        a2 = (uint*)bp2->data;
        for(k = 0; k < NINDIRECT; k++)
          if(a2[k])
            bfree(ip->dev, a2[k]);
        brelse(bp2);
        bfree(ip->dev, a[j]);
      }
    }
    // Last, the doubly-indirect block. The pointer blocks themselves are
    // not cleared: once freed they are unreachable, and balloc() zeroes
    // a block when it hands it out again.
    brelse(bp);
    bfree(ip->dev, ip->addrs[NDIRECT]);
    ip->addrs[NDIRECT] = 0;
  }

Two rules make this correct.

Here the code holds two buffers at once (bp and bp2), unlike bmap(): it needs the doubly-indirect block for the whole outer loop. Two of 30 buffers is fine.

Why does this not overflow the log? An 8 MB file has over 16,000 blocks. But bfree() writes only the bitmap block, and the log records each block once per transaction no matter how often it changes. With a 4000-block disk, the whole bitmap is one block. So the transaction is the bitmap plus the inode.


Step 7: mkfs: building files with the same tree

Reference: mkfs.c.

mkfs runs on your development machine, not in xv6. It writes the files given on its command line into a new image, so it must lay out large files exactly as bmap() expects to find them. Its iappend() gets the same two-level lookup:

      // Doubly indirect (HW2): addrs[NDIRECT] lists indirect blocks,
      // which list data blocks. The same tree as bmap() in fs.c, which
      // must be able to read what mkfs writes.
      //
      // dbn is the block number past the direct blocks; dbn / NINDIRECT
      // selects the indirect block, dbn % NINDIRECT the entry in it.
      uint dbn = fbn - NDIRECT;
      uint ind;  // block number of that indirect block, host byte order
      // mkfs allocates blocks in order from freeblock; main() marks
      // everything below freeblock in use in the bitmap at the end.
      if(xint(din.addrs[NDIRECT]) == 0){
        din.addrs[NDIRECT] = xint(freeblock++);
      }
      // Every sector was zeroed at the start, so a new pointer block
      // reads back as all zeros: no entries yet. Comparing with 0
      // needs no xint(), since 0 is 0 in either byte order.
      rsect(xint(din.addrs[NDIRECT]), (char*)indirect);
      if(indirect[dbn / NINDIRECT] == 0){
        indirect[dbn / NINDIRECT] = xint(freeblock++);
        wsect(xint(din.addrs[NDIRECT]), (char*)indirect);
      }
      ind = xint(indirect[dbn / NINDIRECT]);
      // Reuse the indirect[] buffer for the second level: the
      // doubly-indirect block is on disk already and is read again for
      // the next block of the file.
      rsect(ind, (char*)indirect);
      if(indirect[dbn % NINDIRECT] == 0){
        indirect[dbn % NINDIRECT] = xint(freeblock++);
        wsect(ind, (char*)indirect);
      }
      x = xint(indirect[dbn % NINDIRECT]);

The logic mirrors bmap(), with mkfs’s simpler allocator: blocks are handed out in order from freeblock, and the bitmap is written once at the end. xint() converts between the host’s byte order and xv6’s little-endian disk format; on an x86 or ARM machine it does nothing, but the code keeps it correct everywhere.

If bmap() and iappend() disagreed, for example if one used bn / NINDIRECT where the other used bn % NINDIRECT, mkfs would build a disk that the kernel reads as garbage past the first 6 KB of every large file, and nothing would complain until runxv6 tried to boot a scrambled kernel. That is why the Checkpoint reads both large files from inside xv6 with wc.

When mkfs builds the host disk, it now reports something like balloc: first 3532 blocks have been allocated out of 4000: the two guest files take most of the disk.


Step 8: Large files: why this design

The change above is deliberately the smallest one that works. Two other designs are worth knowing, and Question 1.4 asks you about one of them.

Why not add a 14th address? The obvious fix is uint addrs[NDIRECT+2]: keep the singly-indirect block and add a doubly-indirect one after it. But that makes struct dinode 12 + 14 x 4 = 68 bytes. 512 / 68 = 7.5 inodes per block: inodes would straddle block boundaries, which xv6’s IBLOCK() and IPB arithmetic does not handle (mkfs asserts BSIZE % sizeof(struct dinode) == 0 for exactly this reason). You would have to pad the inode to 128 bytes, or waste part of every inode block, and change struct inode in file.h to match. Keeping 13 addresses avoids all of that.

Why not keep three tiers? The classic Unix layout keeps the 13 addresses but uses them differently: 11 direct addresses, one singly-indirect, one doubly-indirect. (The well-known xv6 “large files” exercise does exactly this.) That is strictly better for medium files: a file between about 6 KB and 70 KB (blocks 11 to 138) needs only one pointer block, as in stock xv6, while in our layout any file past 12 blocks needs two, the doubly-indirect block and its first indirect block. It costs one extra disk block per such file and, on a cold cache, one extra block read per bmap().

We chose the simpler two-tier tree anyway. The code has one indirect case instead of two, in three places (bmap(), itrunc(), iappend()), and the address arithmetic is just / and %. The performance cost is mostly hidden by the buffer cache: when a file is read or written sequentially, the same doubly-indirect and indirect blocks are looked up again and again, and they stay in the cache, so the extra read happens once per 128 blocks, not once per block.

Is the doubly-indirect block another layer of inodes? No. It is an ordinary data block, allocated by balloc() like any other, that happens to hold 128 block addresses instead of file data; so is each indirect block below it. The file still has exactly one inode, and addrs[12] in it points to the top of the tree. The inode table, IPB and the on-disk inode format are unchanged (figures/fs-layout.svg).

A side effect on usertests1. Its writetest1 (“big files test”) writes MAXFILE blocks to one file. With the new MAXFILE, that is 16,396 blocks, four times the whole host disk, so the test cannot pass on any disk this small: the kernel panics with balloc: out of blocks. (usertests1 failed on the small disk of unmodified xv6-64 too.) Use usertests2 instead; its bigfile test writes 24 blocks, enough to go through the doubly-indirect tree.


Step 9: The console problem

runxv6 is one xv6 process with one thread. Its main loop must keep calling vmrun(), or the guest stops. In between, it must pass the keys you type to the guest’s serial port (Part 5). With stock xv6, it cannot do both:

The fix has two halves, like a Unix terminal: a raw mode for the console (stty raw on Unix), and a way to ask whether a read would block (a tiny poll()). The next steps show both.


Step 10: Raw mode and consready()

Reference: console.c.

// HW2: raw mode (consraw), for runxv6. Characters are not echoed or
// edited, and read() returns each one as soon as it arrives.
//
// runxv6 passes every key to the guest's serial port, and the guest
// xv6 runs its own console code on it: the guest echoes, handles
// backspace and Ctrl-U, and buffers lines. If the host did that too,
// every key would be echoed twice, and the guest would get keys only
// after Enter. Unix terminals have the same mode (stty raw).
static int rawmode;

// Set by the consraw() system call (sysfile.c). Taking cons.lock
// orders the change with consoleintr() and consoleread(), which test
// rawmode while holding it.
void
consraw(int on)
{
  acquire(&cons.lock);
  rawmode = on;
  release(&cons.lock);
}

// HW2: would a read of the console return without blocking?
// consoleread() sleeps while input.r == input.w, so a read returns at
// once if and only if some committed input is waiting. Characters of a
// line still being edited (between input.w and input.e) do not count.
// Used by the fdready() system call.
int
consready(void)
{
  int r;

  acquire(&cons.lock);
  r = input.r != input.w;
  release(&cons.lock);
  return r;
}

Recall xv6’s console input buffer: a circular buffer with three indices. input.r is where read() takes the next character, input.w is the end of committed input (whole lines), and input.e is the end of what has been typed, including a line still being edited. consoleread() sleeps while input.r == input.w.

consready() simply tests that condition, under the same lock. It is the exact negation of “a read would sleep”, which is what makes it safe to use: if it says yes, a read() returns without sleeping. And the answer stays true until the caller reads, as long as no other process reads the console at the same time, because input only accumulates.

rawmode is a single global flag, set under cons.lock so that it changes atomically with respect to the interrupt handler and readers, which test it while holding the lock.


Step 11: Raw mode in consoleintr() and consoleread()

Reference: console.c.

The keyboard and serial interrupt handlers both call consoleintr() with a function that returns the next input character. In raw mode, every character goes straight into the buffer:

  acquire(&cons.lock);
  while((c = getc()) >= 0){
    if(rawmode){
      // HW2 raw mode: no echo, no editing; every character is
      // available to read() at once.
      //
      // No consputc(): the guest echoes what it wants shown. Control
      // characters are not interpreted: Ctrl-P, Ctrl-U, backspace and
      // Ctrl-D reach the guest like any other byte. '\r' is not turned
      // into '\n', as on a real serial line; the guest's own console
      // code does that conversion.
      //
      // Keep the character only if the buffer has room; otherwise drop
      // it, as line mode does.
      if(input.e-input.r < INPUT_BUF){
        input.buf[input.e++ % INPUT_BUF] = c;
        // Commit it at once (line mode waits for the end of the line);
        // this also commits any half-edited line from before raw mode.
        input.w = input.e;
        // Wake a reader sleeping in consoleread().
        wakeup(&input.r);
      }
      continue;
    }

Three things are skipped: the echo (consputc()), the special keys, and the wait for a newline. Setting input.w = input.e commits each character the moment it arrives, so consready() turns true right away. Even Ctrl-P (xv6’s process listing) is passed on: in raw mode, it lists the guest’s processes, not the host’s.

Reading needs two changes:

    c = input.buf[input.r++ % INPUT_BUF];
    // In raw mode Ctrl-D is data for the guest, not end of file.
    if(c == C('D') && !rawmode){  // EOF
      if(n < target){
        // Save ^D for next time, to make sure
        // caller gets a 0-byte result.
        input.r--;
      }
      break;
    }
    *dst++ = c;
    --n;
    if(c == '\n')
      break;
    // HW2: in raw mode, return as soon as the committed input runs out,
    // even if fewer than n bytes were read. A raw-mode reader wants
    // keys as they are typed, and a single key (with no '\n' to end
    // the line) would otherwise leave read() asleep until n arrived.
    // Since the loop only gets here after taking at least one byte, a
    // raw read still blocks until there is something to return.
    if(rawmode && input.r == input.w)
      break;  // raw mode: return what has arrived, not a whole line
  }

First, Ctrl-D is just a byte, so read() never reports end of file in raw mode. Second, and more important: in line mode, consoleread() loops until it has n bytes or a newline. A raw reader that asks for 64 bytes after one keystroke would sleep, waiting for 63 more, and fdready()’s promise that the read will not block would be broken. So in raw mode the loop stops as soon as the committed input runs out, and read() returns what has arrived: often a single byte.


Step 12: pipeready(), fdready() and consraw()

Reference: pipe.c, sysfile.c.

fdready() should answer for any file descriptor, not only the console. runxv6 reads its keys from file descriptor 0, which is a pipe if you run, say, echo ls | runxv6 xv6kernel xv6fs.img. A pipe gets the same kind of test:

// HW2: would a read of p return without blocking? It does if there is
// data, or if the write end is closed (the read returns 0).
// This is the negation of the condition piperead() sleeps on. Used by
// the fdready() system call.
int
pipeready(struct pipe *p)
{
  int r;

  // Take the lock so that nread, nwrite and writeopen are read as one
  // consistent snapshot, not halfway through a pipewrite().
  acquire(&p->lock);
  // nread and nwrite count bytes ever read and written; they differ
  // exactly when the pipe holds unread data.
  r = p->nread != p->nwrite || !p->writeopen;
  release(&p->lock);
  return r;
}

Note the second half of the test: a pipe whose writers are all gone does not block, it returns 0 (end of file). “Ready” means “a read() returns at once”, not “there is data”.

The system call dispatches on the file’s type:

// int fdready(int fd)
//
// HW2. Returns 1 if a read() of fd would not block, 0 if it would, or
// -1 for a bad fd. runxv6 uses it to poll the console between runs of
// the guest, since xv6 has no non-blocking I/O.
//
// runxv6 is one process with one thread, and it must keep calling
// vmrun() to run the guest. xv6 has no O_NONBLOCK, select() or poll(),
// and read() of the console sleeps until input arrives, so a plain
// read() would stop the guest until the next key. With fdready(),
// runxv6 reads only when a read cannot block. The answer stays true
// until the caller reads: input only accumulates, as long as no other
// process reads the same file.
uint64
sys_fdready(void)
{
  struct file *f;

  // argfd() checks that fd is open in this process.
  if(argfd(0, 0, &f) < 0)
    return -1;
  // A pipe blocks while it is empty and its write end is open.
  if(f->type == FD_PIPE)
    return pipeready(f->pipe);
  // The console blocks until input is committed. Only the console
  // device can block; other devices would need their own test.
  if(f->type == FD_INODE && f->ip->type == T_DEV && f->ip->major == CONSOLE)
    return consready();
  // A regular file or directory never waits for input: readi() returns
  // at once (it may wait for the disk, but that always finishes).
  // f->readable is not checked: on an fd not open for reading, read()
  // fails at once whatever fdready() says.
  return 1;
}

A regular file always counts as ready: reading it may wait for the disk, but that wait always ends, which is the kind of blocking runxv6 does not mind. The other system call just sets the flag:

// int consraw(int on)
//
// HW2. Turn the console's raw mode on or off. In raw mode, typed
// characters are not echoed or edited, and read() returns them as soon
// as they arrive instead of a line at a time. runxv6 turns it on so that
// every key goes straight to the guest, which does its own echoing.
//
// The mode belongs to the console, not to the process: it stays on
// until someone turns it off, so runxv6 turns it off before it exits
// (die() and the Ctrl-] quit), or the shell would get raw input. If
// runxv6 is killed, the console stays raw.
uint64
sys_consraw(void)
{
  int on;

  if(argint(0, &on) < 0)
    return -1;
  // Any nonzero value turns raw mode on.
  consraw(on);
  return 0;
}

This is how runxv6 uses them (you will meet this code again in Part 5): it turns raw mode on before the first vmrun(), and after every return from vmrun() it drains whatever keys are waiting, without ever blocking:

  while(fdready(0) > 0){
    if((n = read(0, buf, sizeof(buf))) <= 0)
      return;

A pitfall: raw mode outlives runxv6. The mode belongs to the console, not to the process, and xv6 has no terminal session to reset it. runxv6 turns it off on every normal way out: when you type Ctrl-] (runxv6: stopped (Ctrl-])) and in die(). But if runxv6 is killed, or crashes in a way that skips die(), the console stays raw. The host shell then gets your keys without echo, backspace no longer edits, and on a serial console (make qemu-nox, make bochs-nox) Enter sends '\r', which raw mode no longer turns into '\n', so the shell never sees the end of a line. Reboot xv6 if that happens. While developing Part 5, quit runxv6 with Ctrl-].


Step 13: The new system calls and vmxapi.h

SDM: 30.2.1 “Basic VM-Exit Information”, Table 30-5 “Exit Qualification for I/O Instructions”; 27.9.1 (the guest-linear address field); Table 27-6 (the “interrupt-window exiting” control); Appendix C, Table C-1 “Basic Exit Reasons”.

HW2 adds four system calls, numbers 26 to 29. The plumbing (syscall.h, syscall.c, usys.S, user.h, defs.h) and the sys_ wrappers are given. Two of them are hypervisor calls, whose kernel halves you write in later parts; two are the console calls of Steps 10 to 12.

Call Returns What it does Kernel side
vmmem(vm, addr, buf, len, flags) len, or -1 Copy len bytes between the user buffer buf and guest memory at addr. Into the guest with VMMEM_WRITE (1), out of it otherwise. addr is guest-physical, or guest-virtual with VMMEM_VIRT (2), which translates through the guest’s own page tables. The flags combine. vmmem() and gva2gpa() in vmx.c: Part 2
vminject(vm, vector) 0, or -1 Make external interrupt vector (32 to 255) pending for the guest. A later vmrun() delivers it once the guest can take it, highest vector first. Pending the same vector twice delivers it once. vminject() and vmxinject() in vmx.c: Part 4
fdready(fd) 1, 0, or -1 1 if a read(fd, ...) would return without blocking, 0 if it would block, -1 for a bad fd. sys_fdready(), given (Step 12)
consraw(on) 0 Turn the console’s raw mode on (nonzero) or off (0). sys_consraw(), given (Step 12)

runxv6 uses vmmem() to load the guest kernel, to write the BIOS data area and the MP tables (Part 3), and to move disk sectors (Part 5); vminject() for the guest’s timer (vector 32), COM1 (36) and IDE (46) interrupts.

vmxapi.h also gains a few constants:

Finally, struct vmexit gets four fields for string I/O, the INS and OUTS instructions that xv6’s IDE driver uses to move whole sectors (rep insl, rep outsl):

    uchar string;    /* 1 for INS/OUTS (exit qualification bit 4) */
    uchar rep;       /* 1 if it has a REP prefix (qualification bit 5) */
    uint64 count;    /* units to transfer: RCX with REP, else 1 */
    /*
     * The VMCS guest-linear address (GUEST_LINEAR_ADDRESS in vmx.h):
     * RDI for INS, RSI for OUTS, since 64-bit mode ignores the segment
     * bases. It goes through the guest's page tables, not the EPT.
     */
    uint64 addr;     /* guest-virtual address of the memory operand */

For an ordinary IN or OUT, the data is in RAX, and HW1’s io.data and vmsetreg(VMREG_RAX, ...) were enough. For INS and OUTS the data is in guest memory, at a guest-virtual address. The kernel decodes the exit (Part 5): string and rep are bits 4 and 5 of the exit qualification (Table 30-5), count is the guest’s RCX when there is a REP prefix, and addr is the VMCS guest-linear address field, which the CPU fills in for INS and OUTS exits (27.9.1). runxv6 then copies the data with vmmem(..., VMMEM_VIRT), which is why Part 2 has to translate guest-virtual addresses.

struct vmexit describes the CPU, not the devices. Look at what the structure contains: a port number, a size, a direction, a value or a memory address. Nothing in it says “disk” or “serial port”. The kernel reports only what the CPU trapped, in the CPU’s own terms, and it is runxv6 that knows port 0x3F8 is COM1’s data register and 0x1F0 the IDE data port. This is the same split as in KVM, where the kernel fills in an I/O exit in the shared kvm_run structure (direction, size, port, count, data) and QEMU’s device models give the ports a meaning. It keeps the kernel small, and it means that adding a device to the guest changes only the user program. (KVM goes one step further than we do for string I/O: it copies the data between guest memory and the kvm_run page itself, so QEMU never translates guest addresses.)


Checkpoint

Nothing in this part is yours, so the checkpoint checks that the starter tree builds and that the host side works before you start changing the hypervisor.

1. Build. In the starter tree:

make
make fs.img

Watch the output: you should see the guest objects compiled into guest/ (with -DPHYSTOP=0x2000000 -DFSSIZE=2000), ld linking xv6kernel, mkfsguest building xv6fs.img with total 2000, and then mkfs building fs.img:

nmeta 59 (boot, super, log blocks 30 inode blocks 26, bitmap blocks 1) blocks 3941 total 4000
balloc: first 3532 blocks have been allocated

(The exact count of allocated blocks depends on your compiler, but it should be well under 4000.) Check the host-side files: ls -l xv6kernel xv6fs.img fs.img shows about 270 KB, exactly 1,024,000 bytes and exactly 2,048,000 bytes.

2. Boot the host and look at the large files. Boot xv6 (make qemu-nox, or make bochs-nox; see “Where to run it”). At boot it prints sb: size 4000 nblocks 3941 .... At the shell:

$ ls
.              1 1 512
..             1 1 512
README         2 2 1688
...
timertest      2 19 16952
runvm          2 20 21528
runxv6         2 21 35784
helloguest     2 22 16414
xv6kernel      2 23 273608
xv6fs.img      2 24 1024000
console        3 25 0
$ wc xv6kernel
2181 8247 273608 xv6kernel
$ wc xv6fs.img
1921 11968 1024000 xv6fs.img

ls shows the two files with their full sizes (your xv6kernel, built by your compiler, may differ a little in size and in its line and word counts; xv6fs.img is always 1,024,000 bytes). wc reads every byte of each file, through bmap()’s doubly-indirect path for everything past the first 6 KB, so it is a real test of Steps 4 to 7: it must finish, and the last number before the name (the byte count) must equal the size ls shows.

3. Run the file system tests on the host.

$ usertests2
usertests 2 starting
rmdot test
rmdot ok
fourteen test
fourteen ok
bigfile test
bigfile test ok
...
uio test
pid 69 usertests2: trap 13 err 0 on cpu 1 rip 0x170c addr 0x0--kill proc
uio test done
exec test
ALL TESTS PASSED

The trap 13 line is expected: the uio test checks that a user program cannot execute I/O instructions (outb, inb), and the kernel kills the child with a general-protection fault. bigfile writes and reads back 24 blocks, so it crosses from the direct blocks into the doubly-indirect tree, and other tests create and delete many files, which exercises itrunc(). usertests2 refuses to run twice on the same disk (already ran user tests -- rebuild fs.img): run make fs.img again, after deleting fs.img, to start fresh. Do not run usertests1 here: its big-file test cannot pass on this disk (Step 8).

4. HW1 still works.

$ runvm helloguest
Hello from a VT-x guest
runvm: guest halted at rip 0x10004E

runxv6 xv6kernel xv6fs.img will not boot yet: that is Parts 2 to 5.

If something fails:

Symptom Likely cause
mkfs stops on a failed assertion fbn < MAXFILE while building fs.img fs.h still has the stock MAXFILE (12 + 128): the large-file change is missing.
mkfs stops on a failed assertion (BSIZE % sizeof(struct dinode)) == 0 Someone added an address to struct dinode (Step 8).
ls shows xv6kernel, but wc xv6kernel prints a smaller byte count than ls, or garbage, or the kernel panics bmap() and mkfs’s iappend() disagree about the tree (Step 7), or bmap() uses % where it means /.
usertests2: already ran user tests -- rebuild fs.img The disk image was used before. Delete fs.img and run make fs.img.
usertests1 panics with balloc: out of blocks Expected: writetest1 writes an 8 MB file (Step 8).
After running runxv6 the shell does not echo what you type The console was left in raw mode: runxv6 was killed instead of quitting with Ctrl-] (Step 12). Reboot xv6.

Questions

Answer these in answers-hw2.md, two or three sentences each (show your arithmetic where there is some).

1.1. What is the largest file, in blocks and in bytes, that xv6 can hold with the layout of Step 4? How many disk blocks does xv6fs.img (1,024,000 bytes) occupy as a file in fs.img, counting its pointer blocks? How large could a file be if BSIZE were 1024 bytes instead of 512?

1.2. Suppose someone changes GUESTMEM in runxv6.c to 16 MB and forgets the Makefile. What happens when the guest boots, at about which guest-physical address, and why? What happens in the opposite case (GUESTMEM 64 MB, PHYSTOP still 32 MB)?

1.3. runxv6 must pass your keystrokes to the guest while it keeps the guest running. Explain why it needs both fdready() and consraw(): what would go wrong with fdready() alone (console still in line mode), and what would go wrong with consraw() alone (no fdready())?

1.4. Why did we turn addrs[12] into a doubly-indirect block instead of adding a 14th address to the inode? Give one advantage the classic 11 direct + singly-indirect + doubly-indirect layout has over ours, and explain why the difference matters little in practice.

1.5. The Makefile builds the guest kernel’s objects in guest/ instead of next to the host kernel’s. Describe what could go wrong, step by step, if both kernels were built in the same directory, and which kernel would end up broken.


Part 2: Reading and writing guest memory

In this part you give the kernel a way to copy bytes between a user buffer in runxv6 and the memory of a VM. It is one new system call, vmmem(), and the kernel functions behind it. When you finish, nothing new appears on the screen: vmmem() is a tool, and the next parts use it. Part 3 loads the guest kernel and writes the firmware tables with it, and Part 5 moves disk data with it. The checkpoint at the end of this part shows you how to test it on its own anyway.

In HW1 the kernel wrote guest memory only once, in vmcreate(), when it copied the image in. Booting xv6 needs much more:

So vmmem() takes either kind of address, and a flag says which.

Three address spaces

Three kinds of address meet in this part. Keep them apart, because most bugs here are one used in place of another.

Three address spaces: a guest-virtual address goes through the guest page tables to a guest-physical address, through our EPT to a host-physical address, and through P2V() to a kernel-virtual address. gva2gpa() does the first step, gpa2kva() the other two, and vmmem() uses one or both.

  1. Guest-virtual addresses are what guest instructions use. The guest translates them with its own 4-level page tables, rooted at its own CR3. We did not build these tables and we do not control them: the guest kernel builds them in its own memory, exactly as on a real machine. For the guest kernel’s data, such as b->data, these addresses are KERNBASE (0x80000000000) plus the guest-physical address, because xv6 maps all of its physical memory there.
  2. Guest-physical addresses are what the guest believes is RAM: 0 to 32 MB (GUESTMEM), plus the two zeroed pages at the local APIC and I/O APIC addresses. Our EPT translates them. The guest cannot see or change the EPT.
  3. Host-physical addresses are real RAM. The host kernel cannot dereference a physical address; it uses P2V(pa), its own kernel-virtual mapping of all physical memory, as everywhere in xv6.

You saw the first two stages in the HW1 figure, now from the CPU’s point of view:

Two-stage address translation: the guest page tables turn a guest virtual address into a guest physical address; the EPT turns that into a host physical address.

When the guest runs, the CPU does both stages in hardware on every memory access. When runxv6 asks the kernel to touch guest memory, the guest is not running, so the kernel has to do the same walks in software. That is what this part builds:

Where to look things up. As in HW1, “SDM” is the Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3 (order number 325384-092, June 2026). The guest’s page tables are ordinary x86-64 paging, which this edition describes in Chapter 5 of Volume 3A (“Paging”); the EPT is in Chapter 31 of Volume 3C.

What is given and what you write

These pieces are given in the starter code:

You write:

Function Stub you start with You write
vmrun() (your HW1 code) your own three lines that save the guest’s control registers (Step 1)
gpa2kva(vm, gpa) the header and return 0; paste Step 2
gva2gpa(vm, gva, gpa) the header, the declarations of table, e, size, p and level, and return -1; Your turn (Step 3)
vmmem(id, addr, buf, len, flags) the header, the declarations of vm, gpa, k, n and off, and return -1; paste Steps 4 and 5

The three functions are next to each other in vmx.c, in the order gpa2kva(), gva2gpa(), vmmem(), after vmcreate().


Step 1: Save the guest’s control registers at every exit

SDM: 30.3.1 “Saving Control Registers, Debug Registers, and MSRs”; Chapter 33, “VMCLEAR–Clear Virtual-Machine Control Structure” and “VMREAD–Read Field from Virtual-Machine Control Structure”.

To walk the guest’s page tables, the kernel needs three facts about the guest: whether paging is on (CR0.PG), where its top-level table is (CR3), and which paging mode it uses (EFER.LMA). vmrun() already saves EFER after every exit (HW1). Now it saves the control registers too.

In your vmrun(), find the block that reads the guest state after a successful exit. It ends like this:

      /* Saved because of EXIT_SAVE_IA32_EFER; picks up a new LMA. */
      vm->efer = vmread(GUEST_IA32_EFER);
      e.rip = vm->rip;

Add these lines right after e.rip = vm->rip;, still inside the same else branch, before the VMCLEAR:

      /* HW2: state for vmmem(), interrupt injection and string I/O. */
      /*
       * The guest's own control registers, as the exit saved them (SDM
       * 30.3.1): the real values, not the read shadows (GUEST_CR4 has
       * VMXE set).  vmmem() runs after vmrun() has VMCLEARed the VMCS,
       * when VMREAD is impossible, so keep copies.
       */
      vm->cr0 = vmread(GUEST_CR0);
      vm->cr3 = vmread(GUEST_CR3);
      vm->cr4 = vmread(GUEST_CR4);

(Parts 4 and 5 add more lines after these, under the same HW2 comment.)

What the exit saves. While the guest runs, its CR0, CR3 and CR4 are loaded in the real registers. At every VM exit the CPU writes them into the guest-state fields GUEST_CR0, GUEST_CR3 and GUEST_CR4 before it loads the host’s (SDM 30.3.1). So after an exit these fields hold the guest’s registers as they were at the moment it exited. If the guest has switched page tables (xv6 does, on every context switch), GUEST_CR3 has the new one.

Why copies? vmmem() is called by runxv6 through a system call, between two calls to vmrun(). By then vmrun() has executed VMCLEAR, as the HW1 invariant requires (a VMCS must be cleared on the CPU where it was loaded, before the process can move to another CPU). VMCLEAR writes the VMCS out to its page and makes the current-VMCS pointer invalid, and VMREAD with no current VMCS fails (see the operation section of VMREAD in Chapter 33). So vmmem() cannot read the VMCS. It could load it again with VMPTRLD, but then it would have to keep the process on one CPU and clear it again, all to read three values that vmrun() could save for the cost of three VMREADs. So we keep copies in struct vm.

The copies are never stale when vmmem() uses them. A process in xv6 has one thread, and while it is in vmmem() it is not in vmrun(), so the guest is not running and its registers cannot change. runxv6 handles an exit and then calls vmmem(): it sees the registers of exactly the exit it is handling.

The copies are also never written back. The VMCS keeps its own GUEST_CR3 across VMCLEAR and VMPTRLD, and only the guest changes it.

Real values, not read shadows. In HW1 you made CR4.VMXE a host-owned bit, so the guest reads its CR4 through a read shadow without VMXE. GUEST_CR4 is the real register, with VMXE set. For the page walk we want the real values, which is what the guest-state fields hold. gva2gpa() uses cr0 and cr3; cr4 is saved along with them because it is the third control register paging depends on (CR4.PAE and CR4.LA57 select the paging mode), although xv6 never changes those bits.


Step 2: gpa2kva(), guest-physical to kernel-virtual

SDM: 31.3.2 “EPT Translation Mechanism”, Table 31-7 “Format of an EPT Page-Table Entry that Maps a 4-KByte Page”.

Paste this as the body of gpa2kva():

  uint64 *pte;

  /*
   * Walk the EPT in software, as the CPU does for every guest access.
   * alloc = 0: never create tables; eptwalk() returns 0 when a table
   * on the way is missing (gpa is above memsz and is not an APIC page).
   * A leaf entry may exist but be empty, if its page table was created
   * for a neighboring page; no R/W/X bit means "not present".
   */
  if((pte = eptwalk(vm->ept, gpa, 0)) == 0 || !(*pte & EPT_RWX))
    return 0;
  /*
   * EPT_ADDR() keeps the host-physical address of the 4 KB page (bits
   * 51:12), dropping the permission and memory-type bits; P2V() turns it
   * into the kernel's mapping of that page, and the low 12 bits of gpa
   * are the offset in it.  The result is good only up to the end of
   * this page: the next guest page may be anywhere in host memory.
   */
  return (char*)P2V(EPT_ADDR(*pte)) + gpa % PGSIZE;

This reuses the eptwalk() you wrote in HW1, with alloc = 0, so it only looks and never builds. There are two ways for a guest-physical address to be unmapped, and the function checks both:

If the entry is present, EPT_ADDR() gives the host-physical address of the 4 KB page, P2V() the kernel’s address for it, and the low 12 bits of gpa the byte inside it. The EPT maps 4 KB pages, and a page keeps its offset through translation, so gpa % PGSIZE is also the offset in the host page.

The pointer is good for one page only. In vmcreate() every 4 KB of guest memory got its own kalloc() page. Guest-physical pages 0x100000 and 0x101000 are neighbors for the guest, but their host pages can be anywhere. A caller that wants more than the rest of this page must call gpa2kva() again. vmmem() does.

Why not just P2V(gpa)? Because guest-physical address 0x100000 is not host-physical address 0x100000. P2V(0x100000) is the host kernel’s own code. Every guest-physical address must go through the EPT, the same way the CPU sends every guest access through it.


Step 3: gva2gpa(), the guest page-table walk (Your turn)

SDM: 5.1.1 “Four Paging Modes”; 5.5.2 “Use of CR3 with Ordinary 4-Level Paging and 5-Level Paging” (Table 5-12); 5.5.4 “Linear-Address Translation with 4-Level Paging and 5-Level Paging” (Figures 5-8, 5-9 and 5-10, and Tables 5-15 to 5-20, the entry formats; Figure 5-11 puts all the formats on one page); 31.3.1 “EPT Overview” (how the CPU walks the guest’s tables through the EPT).

You start with:

/*
 * HW2. Translate a guest-virtual address to a guest-physical one, the way
 * the guest's MMU would: walk the guest's own page tables, which live in
 * guest-physical memory, starting at the guest's CR3 (saved at the last
 * exit).  With paging off, the two addresses are the same.  Handles
 * 4-level (IA-32e) paging with 4 KB, 2 MB and 1 GB pages (SDM Vol. 3A,
 * 5.5).  Returns -1 if the address is not mapped.
 */
static int
gva2gpa(struct vm *vm, uint64 gva, uint64 *gpa)
{
  uint64 table, e, size;
  uint64 *p;
  int level;

  return -1;
}

Goal. Translate the guest-virtual address gva to a guest-physical address, the way the guest’s own MMU would at this moment, and store it in *gpa. Return 0 on success and -1 if the address has no translation. The guest is not running, so use the registers saved at the last exit: vm->cr0, vm->cr3 and vm->efer.

(The SDM calls these addresses linear, not virtual. In 64-bit mode the segment bases that turn a “logical” address into a linear one are 0, and the address the CPU gives us for INS and OUTS already includes the base, so for us the two words mean the same thing.)

The contract. Your function must do exactly this:

  1. Paging off (CR0.PG = 0): there is no translation. The CPU uses a linear address directly as a physical one (SDM 5.1.1; 31.3.1 says the same for a guest: “each linear address is treated as a guest-physical address”). Set *gpa = gva and return 0. The guest runs like this at the start of entry.S, before it turns paging on.
  2. Paging on, but not in long mode (EFER.LMA = 0): that would be 32-bit or PAE paging, whose tables have other formats. xv6 turns on paging and long mode in the same instruction, so it never uses them. Return -1.
  3. Paging on and long mode: 4-level paging. Walk it:
    • The PML4’s address is bits 51:12 of CR3 (Table 5-12). The low 12 bits are flags (PWT, PCD), and the top bits can hold other controls. Mask with 0x000FFFFFFFFFF000ULL.
    • There are four levels: PML4, page-directory-pointer table (PDPT), page directory (PD) and page table (PT). If you number them 3, 2, 1, 0, as eptwalk() does, the index into the table at level level is the 9 bits of gva starting at bit 12 + 9*level: bits 47:39, 38:30, 29:21 and 20:12 (Figure 5-8). Each entry is 8 bytes.
    • An entry is present if bit 0 (P, PTE_P) is 1. If it is 0, there is no translation: return -1. (The CPU would raise a page fault in the guest. We just report failure.)
    • In a PDPT entry (level 2) and a PD entry (level 1), bit 7 (PS, PTE_PS) set means the entry maps a large page, 1 GB or 2 MB, and the walk stops there (Tables 5-16 and 5-18). In a PML4 entry and a PT entry, bit 7 means something else, so do not test it there.
    • Otherwise, bits 51:12 of the entry are the guest-physical address of the next table. Mask with 0x000FFFFFFFFFF000ULL again.
    • After the PT entry, the page’s address plus the low 12 bits of gva is the answer.
  4. The tables are guest-physical. CR3 and every table address inside an entry are guest-physical addresses. To read an entry, compute the guest-physical address of the entry and turn it into a kernel pointer with gpa2kva(). If that returns 0 (the guest’s CR3 or an entry points outside guest memory), return -1.

You do not check access rights (writable, user, execute-disable; SDM 5.6), and you do not set the accessed and dirty bits (SDM 5.8). This walk serves the monitor, which acts like a device moving data for the guest kernel, not like guest code.

Hints.

Pitfalls.

How to check. Nothing calls gva2gpa() until Part 5. The checkpoint below gives you a test you can run as soon as the guest boots in Part 3.


Step 4: vmmem(), find the VM

SDM: none; this is our own interface (vmxapi.h).

vmmem(id, addr, buf, len, flags) copies len bytes between buf and guest memory at addr. With VMMEM_WRITE the copy goes into the guest (from buf), without it out of the guest (into buf). With VMMEM_VIRT, addr is guest-virtual; without it, guest-physical. It returns len, or -1. Paste the first block, after the declarations:

  /*
   * Only the owner may touch the VM's memory.  The guest is not running
   * now: xv6 processes have one thread, and this one is in a system
   * call, not in vmrun().  buf is the caller's user buffer, which
   * sys_vmmem() checked with argptr(); the kernel can use it directly
   * because it runs on the process's page table.
   */
  if((vm = getvm(id)) == 0 || len < 0)
    return -1;

getvm() is the HW1 helper: it turns the id into a struct vm * and checks that the calling process owns the VM. sys_vmmem() (given) fetches the arguments: addr with argint64(), since a guest address is 64 bits, and buf with argptr(), which checks that all len bytes lie inside the calling process. During a system call the process’s own page table is loaded, so the kernel can use that user pointer directly with memmove().

Nobody else can touch the guest’s memory or its saved registers while we copy: the guest runs only inside vmrun(), and this process is busy in vmmem().


Step 5: vmmem(), the copy, page by page

SDM: none beyond Steps 2 and 3.

Paste the rest of the body:

  /*
   * One page per iteration.  Contiguous guest addresses need not be
   * contiguous in host memory: each 4 KB guest-physical page is its own
   * kalloc() page, and consecutive guest-virtual pages may map to
   * unrelated guest-physical ones.  So translate again at every page
   * boundary.  Even inside a 2 MB or 1 GB guest page, the EPT maps
   * 4 KB pages, so the copy still stops at each 4 KB boundary.
   */
  for(off = 0; off < len; off += n){
    if(flags & VMMEM_VIRT){
      /* Through the guest's page tables, as the guest's MMU would. */
      if(gva2gpa(vm, addr + off, &gpa) < 0)
        return -1;
    } else {
      gpa = addr + off;
    }
    /* Then through the EPT to the host page; 0 if outside the guest. */
    if((k = gpa2kva(vm, gpa)) == 0)
      return -1;
    /* Stay within one guest page: the next one may be elsewhere. */
    /*
     * Translation keeps the low 12 bits, so the page offset of addr +
     * off is also that of gpa and of k.
     */
    n = PGSIZE - (addr + off) % PGSIZE;
    if(n > len - off)
      n = len - off;
    /*
     * A failure on a later page returns -1 with the earlier pages
     * already copied; runxv6 treats any failure as fatal anyway.
     */
    if(flags & VMMEM_WRITE)
      memmove(k, buf + off, n);
    else
      memmove(buf + off, k, n);
  }
  return len;

Why page by page. A buffer that is contiguous for the guest is not contiguous for us, for two reasons, one per translation:

So each iteration translates addr + off, copies up to the end of that page, and translates again. Every translation keeps the low 12 bits of the address, so the offset in the page is the same for the guest-virtual, the guest-physical and the kernel address, and n can be computed from addr + off. The first iteration may copy less than a page (from the middle of a page to its end), the last may stop short.

The flags. VMMEM_VIRT adds the first translation; gpa2kva() is always used. VMMEM_WRITE only chooses the direction of memmove(). runxv6 uses three combinations:

Caller Flags Address
ELF loader, BIOS data area, MP tables (Part 3) VMMEM_WRITE guest-physical (ph->paddr, 0x413, 0xF0000)
INS, from the disk into the guest (Part 5) VMMEM_VIRT | VMMEM_WRITE guest-virtual (RDI)
OUTS, from the guest to the disk (Part 5) VMMEM_VIRT guest-virtual (RSI)

Failure halfway. If a later page is unmapped, the earlier pages have already been copied when vmmem() returns -1. A real rep insl that hits an unmapped page would also have stored the bytes before it, and then page-faulted. runxv6 does not try to emulate that page fault: it stops the guest.


Checkpoint

This part has no visible effect on its own. Build and make sure nothing broke:

  1. make and make fs.img must build without warnings (-Werror).
  2. Boot the host and run runvm helloguest. HW1 must still work: Hello from a VT-x guest and runvm: guest halted at rip 0x10004E. If it does not, look at Step 1: the three new lines must be inside the branch for a successful exit, before the VMCLEAR.

The real tests come later:

An optional early test of gva2gpa(). You do not have to wait for Part 5. The guest kernel maps its physical memory at KERNBASE (0x80000000000, the same value as in the host, since both are built from the same memlayout.h), so guest-virtual KERNBASE + 0x100000 must translate to guest-physical 0x100000, where the guest kernel is loaded. Once you have finished Part 3 and the guest starts printing, add a temporary check to your vmrun(), right after the three lines from Step 1:

      /* TEMPORARY test of gva2gpa() (Part 2); remove it afterwards. */
      if(vm->cr0 & CR0_PG){
        if(gva2gpa(vm, KERNBASE + 0x100000, &t) < 0 || t != 0x100000)
          cprintf("gva2gpa: bad translation of KERNBASE+1MB\n");
        else if(!gvaok){
          cprintf("gva2gpa: KERNBASE+1MB -> 0x%x\n", t);
          gvaok = 1;
        }
      }

and declare uint64 t; and static int gvaok; with the other declarations at the top of vmrun(). Run runxv6 xv6kernel xv6fs.img. You should see, mixed in with the guest’s output, exactly one line:

gva2gpa: KERNBASE+1MB -> 0x100000

and never the bad translation line. The test runs at every exit with paging on, so it follows the guest through its boot page table (entrypml4 in main.c, which maps KERNBASE with one 1 GB page; an exit there, such as a host timer tick, tests your large-page case) and then through the page tables kvmalloc() and every process build with 4 KB pages. To see the failure case too, try an address that the guest does not map, such as KERNBASE + 0x10000000000, and check that you get -1. Remove the test when you are done.

If something fails:

Symptom Likely cause
runvm helloguest no longer works after Step 1 The three lines are outside the branch for a successful exit, or after the VMCLEAR: VMREAD with no current VMCS fails.
runxv6: cannot write guest memory (Part 3), although the segment fits in 32 MB gpa2kva() returns 0 for a mapped address: eptwalk() called with the wrong arguments, or the EPT_RWX test inverted.
The guest crashes at once, or runs into garbage, in Part 3 The loader wrote the right bytes to the wrong place: the gpa % PGSIZE offset is missing in gpa2kva(), or n is wrong in vmmem(), so a copy runs past the end of a host page.
The host kernel panics, or host programs misbehave, after runxv6 ran vmmem() wrote outside guest memory: gpa2kva() does not check the R/W/X bits, so an empty leaf entry gives P2V(0) plus the offset, which is host memory; or a copy crosses a page boundary.
runxv6: INS to an unmapped guest address (Part 5) gva2gpa() returns -1 for a mapped address: cr0 or cr3 not saved in Step 1 (still 0), the flags of CR3 or of an entry not masked, the PTE_P test applied to the address instead of the entry, or the index taken from the wrong bits.
The test above prints bad translation, or the guest’s file system reads garbage in Part 5 gva2gpa() returns a wrong address: entries read with P2V() instead of gpa2kva(), flags left in an address, the PS bit tested at levels 3 or 0, or a large page with a 12-bit offset.
The test works at first, then prints bad translation once the guest runs processes A stale CR3: the copy is taken once instead of at every exit, so you walk an old page table.

Questions

Answer these in answers-hw2.md, two or three sentences each.

2.1. gva2gpa() tests bit 7 (PS) of the entry only at the PDPT and PD levels. What does bit 7 mean in a PML4 entry and in a page-table entry (SDM Tables 5-15 and 5-20)? Describe a concrete guest mapping that a walk testing PS at every level would translate wrongly, and what it would return.

2.2. gva2gpa() reads every page-table entry through gpa2kva(), that is, through the EPT. Why can it not use P2V() on the table address, as xv6’s own walkpglevel() (in vm.c) does for its page tables? Which memory would P2V(table) read? How does the CPU solve the same problem when the guest itself accesses memory (SDM 31.3.1)?

2.3. xv6’s IDE driver reads a sector with insl(0x1f0, b->data, BSIZE/4), 512 bytes into b->data. Suppose b->data starts 256 bytes before the end of a guest page. Describe, iteration by iteration, what vmmem() does with this request. Why would one translation followed by one 512-byte memmove() be wrong, even though the guest kernel maps its data to consecutive guest-physical pages?

2.4. vmrun() saves GUEST_CR3 into vm->cr3 after every exit. Why can vmmem() not simply call vmread(GUEST_CR3) when it needs the value? And why is the saved value never out of date when vmmem() uses it?


Part 3: Booting xv6 in a VM

In this part the guest stops being a hello-world program and becomes xv6 itself. You load the xv6 kernel, an ordinary ELF file, into an empty VM, give it the few things a PC would give it before it starts, and let it boot. When you finish, the guest kernel gets as far as starting its scheduler:

$ runxv6 xv6kernel xv6fs.img
runxv6: booting xv6kernel (type Ctrl-] to stop)
xv6...
cpu0: starting 0
cpu0: no VMX support

and then waits. The first thing its first process does is read the disk, and that needs interrupts (Part 4) and string I/O (Part 5).

xv6 is a much more demanding guest than helloguest. It expects a PC: it looks for the tables a BIOS leaves in memory to learn how many CPUs there are and where their interrupt controllers are, it programs a local APIC and an I/O APIC, it asks CPUID whether the CPU supports VMX (our xv6 enters VMX operation on every CPU, as you made it do in HW1), and it takes its own page faults and system calls through its own IDT. This part handles each of these, in the smallest way that works:

xv6 expects What we do Where
to handle its own exceptions exception bitmap 0 your vmcsinit(), Step 1
a local APIC and an I/O APIC at their usual addresses a zeroed page of memory at each your vmcreate(), Step 2
CPUID to describe the CPU run the real CPUID, hide VMX vmxcpuid(), Step 3
a boot loader that loads the kernel loadkernel() in runxv6 Step 4
MP tables left by the BIOS mptables() in runxv6 Step 5

Where to look things up. As before, “SDM” is the Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3 (order number 325384-092, June 2026). Section numbers are for that edition. The MP tables come from a different document, the Intel MultiProcessor Specification, version 1.4 (1997), which we call “the MP spec”. xv6’s mp.c cites it too.

What is given and what you write

Piece Status What you start with
vmcsinit(): exception bitmap 0, save the processor-based controls in vm->cpuctl you change your HW1 code (Step 1) your HW1 function
vmcreate(): map the local APIC and I/O APIC pages you change your HW1 code (Step 2) your HW1 function
vmxhandle(): case EXIT_CPUID you add the case (Step 3) your HW1 function
vmxcpuid() in vmx.c you write (Step 3, “Your turn”) static void vmxcpuid(struct vm *vm) { } with an empty body
VM_LAPIC_ADDR, VM_IOAPIC_ADDR and EXIT_CPUID in vmxapi.h; CPUID1_ECX_VMX in vmx.h given complete
runxv6.c: readfile(), loadkernel(), checksum(), main() given complete; Steps 4 and 6 walk through them
runxv6.c: the COM1, IDE and CRT controller emulation given (except idestring()) explained in Part 5
mptables() in runxv6.c you write (Step 5, “Your turn”) static void mptables(void) { } with an empty body
The Makefile rules for xv6kernel, mkfsguest and xv6fs.img given complete

The guest kernel, xv6kernel, is built from the same sources as the host kernel, with two differences passed on the compiler command line: PHYSTOP is 32 MB (-DPHYSTOP=0x2000000), the memory runxv6 gives the VM, and its file system has 2000 blocks (-DFSSIZE=2000). Its object files go in the directory guest/, so that the two builds do not overwrite each other. make fs.img builds xv6kernel and the guest’s disk image xv6fs.img, and copies both into the host’s fs.img, where runxv6 reads them.


Step 1: Your vmcsinit(): let the guest handle its own exceptions

SDM: 27.6.3 “Exception Bitmap”; 28.2 “Other Causes of VM Exits” (exceptions and triple faults); 29.6 “Event Injection”.

Two changes to the function you wrote in HW1 Part 3. The first one saves the primary processor-based controls in the VM. Find the lines that write the control fields and add the vm->cpuctl line right after the write of CPU_BASED_VM_EXEC_CONTROL:

  vmwrite(PIN_BASED_VM_EXEC_CONTROL, pin);
  vmwrite(CPU_BASED_VM_EXEC_CONTROL, cpu);
  /*
   * HW2: keep the base value.  vmxinject() writes this field again
   * before every entry, as cpuctl plus, when needed, interrupt-window
   * exiting; it needs the value without that bit to turn it off.
   */
  vm->cpuctl = cpu;  /* vmrun() adds interrupt-window exiting to it */
  vmwrite(SECONDARY_VM_EXEC_CONTROL, cpu2);

vmxinject(), which you write in Part 4, rewrites this VMCS field before every entry: the base value, plus “interrupt-window exiting” when it needs to wait for the guest to enable interrupts. To turn that bit off again it needs the value without it, and that is the value computed here by adjustctl(). cpuctl is a field of struct vm (uint cpuctl;): if your struct vm does not have it yet, add it. Nothing uses it until Part 4.

The second change replaces HW1’s exception bitmap. Replace the comment and the vmwrite(EXCEPTION_BITMAP, 0xFFFFFFFF) of HW1 Part 3, Step 6 (keep the two page-fault lines) with:

  /*
   * The exception bitmap has one bit per exception vector 0-31; a set
   * bit makes that exception cause a VM exit instead of being
   * delivered through the guest's IDT (SDM 27.6.3).  HW1's hello-world
   * guest had no IDT, so it set every bit: each guest exception then
   * exited with its vector, error code and RIP instead of escalating to
   * a triple fault.  An xv6 guest handles its own exceptions (page
   * faults, the system-call trap), so here no exception exits.  A guest
   * without an IDT that faults now triple faults (EXIT_TRIPLE_FAULT).
   */
  /*
   * With a bit set, the hypervisor would have to hand each such
   * exception back to the guest by injecting it (SDM 29.6), at the cost
   * of a VM exit every time.  With the bitmap clear, the CPU delivers
   * exceptions itself, through the IDT that the guest loaded with LIDT.
   * (INT $64, xv6's system call, is a software interrupt, not an
   * exception, so the bitmap never applies to it; SDM 28.2.)
   */
  vmwrite(EXCEPTION_BITMAP, 0);
  /*
   * For page faults the mask/match pair further filters which error
   * codes exit; with bit 14 clear in the bitmap and mask = match = 0,
   * no page fault exits (SDM 27.6.3).
   */
  vmwrite(PAGE_FAULT_ERROR_CODE_MASK, 0);
  vmwrite(PAGE_FAULT_ERROR_CODE_MATCH, 0);

In HW1 the bitmap was all ones because helloguest had no IDT: any exception it raised would have become a double fault and then a triple fault, with no clue left about the cause. With every bit set, an exception became a VM exit with its vector, error code and RIP.

xv6 is different. It loads its own IDT (idtinit() in mpmain()), and it relies on its exceptions: a user program that dereferences a bad pointer page faults, and xv6’s trap() kills it (a fault in the kernel makes it panic). With a bit set, the exception would exit to the hypervisor instead of reaching the guest’s handler, and the hypervisor would have to inject it back into the guest (SDM 29.6), paying for a VM exit every time. With the bitmap 0, the CPU delivers every exception through the guest’s IDT, as on a real machine, and the hypervisor never hears about it.

What about xv6’s system calls? They use INT $64 (T_SYSCALL in traps.h). That is a software interrupt, not an exception, and the exception bitmap does not apply to it at all (SDM 28.2 lists the instructions it does cover, INT1, INT3, INTO, BOUND and the UD instructions, and adds in a footnote that INT1 and INT3 are the opcodes F1 and CC, “not … INT n”). It never exited, even in HW1.

The price: a guest that faults before it has loaded its IDT now triple faults (EXIT_TRIPLE_FAULT, reason 2), and runxv6 can only tell you the RIP. You will meet this in the Checkpoint.


Step 2: Your vmcreate(): back the APIC pages

SDM: 13.4.1 “The Local APIC Block Diagram” (the registers live at FEE00000H); Table 13-1 “Local APIC Register Address Map”; 13.4.6 “Local APIC ID”; 13.6.1 “Interrupt Command Register (ICR)” (the delivery status bit); 13.8.5 “Signaling Interrupt Servicing Completion” (EOI). For the I/O APIC: Intel’s 82093AA I/O APIC datasheet.

xv6 talks to its interrupt controllers through memory-mapped registers: loads and stores to fixed physical addresses, the local APIC at 0xFEE00000 and the I/O APIC at 0xFEC00000 (VM_LAPIC_ADDR and VM_IOAPIC_ADDR in vmxapi.h). In the VM those are guest-physical addresses, and your vmcreate() maps only guest RAM, 0 to 32 MB. The first access would be an EPT violation.

Add i to the declarations at the top of vmcreate():

  int id, n, off, r, i;

and add this loop right after your loop that maps guest memory (the one that ends with *pte = V2P(mem) | EPT_RWX | EPT_MEMTYPE_WB;), before the code that copies the image in:

  /*
   * HW2: xv6 programs its local APIC and I/O APIC through memory-mapped
   * registers at these guest-physical addresses.  Back each with a zeroed
   * page of ordinary memory: writes are kept and read back, which is all
   * xv6 needs to boot.  This is not an emulation of either device;
   * interrupts are delivered by injection instead (vminject()).
   */
  /*
   * Without these pages, xv6's first access to its local APIC would hit
   * an unmapped guest-physical address and cause an EPT violation exit,
   * which runxv6 treats as fatal.
   */
  for(i = 0; i < 2; i++){
    /* Page 0 is the local APIC, page 1 the I/O APIC. */
    gpa = i == 0 ? VM_LAPIC_ADDR : VM_IOAPIC_ADDR;
    /*
     * Both addresses lie just below 4 GB, far above memsz (at most
     * VM_MAXMEM), so the loop above did not reach them: eptwalk() with
     * alloc = 1 creates the EPT tables on the way to them.
     */
    if((pte = eptwalk(vm->ept, gpa, 1)) == 0 || (mem = kalloc()) == 0)
      goto bad;
    /*
     * Zeroed, every location reads as 0 until xv6 writes it, and then
     * as the value written.  So the local APIC ID reads 0, the APIC id
     * that runxv6's MP table gives the one CPU, and the I/O APIC ID
     * reads 0, the id in the MP table's I/O APIC entry.  The
     * delivery-status bit (12) of the interrupt command register, which
     * xv6 polls after writing that register, reads back as the 0 that
     * xv6 wrote, so the poll ends at once.
     */
    memset(mem, 0, PGSIZE);
    /*
     * A real APIC page is uncached device memory.  This one is ordinary
     * write-back memory: nothing reacts to the guest's stores, so
     * caching them does no harm.
     */
    *pte = V2P(mem) | EPT_RWX | EPT_MEMTYPE_WB;
  }

This maps one zeroed page of ordinary memory at each address. It is not an emulation of either device: nothing reacts to the guest’s stores. It works because of what xv6 actually does with these registers:

A real hypervisor emulates these devices properly (VT-x even has hardware support for a virtual local APIC, SDM Chapter 32, “APIC Virtualization and Virtual Interrupts”). For booting xv6, plain memory is enough.

vmfree() needs no change: it frees every page the EPT maps, these two included.


Step 3 (Your turn): Emulate CPUID

SDM: 28.1.2 “Instructions That Cause VM Exits Unconditionally”; 28.1 (exits are “fault-like”); 30.3.3 “Saving RIP, RSP, RFLAGS, and SSP”; 30.2.5 “Information for VM Exits Due to Instruction Execution” (the instruction length); Appendix C, Table C-1 (reason 10); 26.6 “Discovering Support for VMX”; Vol. 2A, the CPUID instruction.

Boot the guest after Steps 1 and 2 and it stops right after cpu0: starting 0, with runxv6: unhandled exit 10. Exit reason 10 is CPUID (Table C-1). Look at SDM 28.1.2: CPUID is on the short list of instructions that cause a VM exit every time they run in VMX non-root operation, whatever the VMCS says. The hypervisor has to execute it on the guest’s behalf. The guest runs it in vmxinit(), the function you wrote in HW1 Part 1, which our xv6 calls on every CPU.

First, add the case to your vmxhandle(). Put it after the EXIT_HLT case:

  case EXIT_HLT:
    /* Skip the HLT so a later vmrun() continues after it. */
    vm->rip += inslen;
    return 0;

and before case EXIT_IO::

  case EXIT_CPUID:
    /* HW2: emulate it for the guest; see vmxcpuid(). */
    vmxcpuid(vm);
    /*
     * The exit is fault-like: CPUID did not execute, and the saved RIP
     * points at it (SDM 28.1, 30.3.3).  Skip its 2 bytes (0F A2);
     * otherwise the guest would execute it again and exit forever.
     */
    vm->rip += inslen;
    return 1;

It is handled in the kernel and never reaches runxv6, like RDMSR of EFER. As for every fault-like exit, the guest’s RIP still points at the CPUID (SDM 30.3.3), so the case advances it by the instruction length, and return 1 resumes the guest.

Then write vmxcpuid(). The starter has it, with an empty body, just above vmxhandle().

Goal. Do what CPUID would have done in the guest, with one change: the guest must see a CPU without VMX.

  1. The guest’s EAX holds the leaf (which information it wants), and for some leaves (4, 7, 0xB, 0xD, …) ECX holds a subleaf. Both are 32-bit values. Where are the guest’s registers right now? (Not in the CPU: vmx_exit saved them.)
  2. Execute the real CPUID instruction with that EAX and ECX. You are in VMX root operation, where CPUID does not exit; it returns the host CPU’s answer. Passing it on means the guest sees the real processor’s features.
  3. If the leaf is 1, clear bit 5 of ECX, CPUID1_ECX_VMX in vmx.h. That is the bit HW1’s vmxinit() tests first (SDM 26.6).
  4. Store the four results in the guest’s RAX, RBX, RCX and RDX. CPUID writes 32-bit registers, and in 64-bit mode writing a 32-bit register clears bits 63:32 of the 64-bit one. So the upper halves must become 0, not keep their old values.

Why hide VMX? Our hypervisor does not support nested virtualization: it cannot run a hypervisor inside the guest. If the guest saw the VMX bit, its vmxinit() would go on to RDMSR of IA32_FEATURE_CONTROL. There is no MSR bitmap, so every RDMSR exits (SDM 28.1.3; “use MSR bitmaps” in Table 27-6), your vmxmsr() knows only EFER, and runxv6 would stop with unhandled exit 31. With the bit clear, the guest prints cpu0: no VMX support and boots on, which is exactly how HW1 behaves on a CPU without VMX. This is also why the Makefile leaves runvm and runxv6 out of the guest’s disk: they could only fail.

Hints.

How to check. xv6 executes CPUID exactly once, with leaf 1, in vmxinit(). A temporary cprintf("cpuid %x\n", (uint64)leaf) in vmxcpuid() should print once per guest boot. The guest should then print cpu0: no VMX support. If it prints unhandled exit 31 instead, the VMX bit got through.


Step 4: Load the guest kernel (given)

References: the ELF specification (program headers); xv6’s bootmain.c, entry.S and kernel.ld; the Multiboot Specification (version 0.6.96), its section on the machine state at boot.

runvm loaded a flat binary: vmcreate() copied the image to VM_LOAD_ADDR and that was it. The xv6 kernel is an ELF file, and the kernel does not parse ELF. runxv6 does all the loading in user space. This is the start of its main(), after readfile() has read both files into memory:

  /* An empty VM: the image is loaded from the ELF file instead. */
  /*
   * With len 0, vmcreate() copies nothing to VM_LOAD_ADDR (that is for
   * flat binaries such as helloguest). It allocates GUESTMEM bytes of
   * zeroed guest RAM plus the local APIC and I/O APIC pages, and sets
   * the guest up in flat 32-bit protected mode with paging off, the
   * state a multiboot loader leaves and entry.S expects.
   */
  if((vm = vmcreate(kernel, 0, GUESTMEM)) < 0)
    die("vmcreate failed");
  entry = loadkernel(kernel, ksize);
  /* The segments are in guest memory now; the file is not needed. */
  free(kernel);
  mptables();

readfile() (given) is the obvious function: fstat() for the size, one malloc() buffer, and read() in a loop until it has the whole file. The kernel is about 270 KB, the disk image 1,024,000 bytes (2000 blocks of 512 bytes). Both are bigger than the 70 KB maximum file of stock xv6, which is why HW2 lets files grow larger (the doubly-indirect block in fs.h).

vmcreate(kernel, 0, GUESTMEM) passes a length of 0, so your vmcreate() copies nothing (kernel is just a valid pointer). It creates an empty machine: 32 MB of zeroed guest RAM, the two APIC pages of Step 2, and a VMCS with the guest state of HW1. Then loadkernel() fills the memory:

static uint64
loadkernel(char *image, int size)
{
  struct elfhdr *elf = (struct elfhdr*)image;
  struct proghdr *ph;
  int i;

  /* Check that the header fits in the file before reading it. */
  if(size < sizeof(*elf) || elf->magic != ELF_MAGIC)
    die("the kernel is not an ELF file");
  for(i = 0; i < elf->phnum; i++){
    /* The program headers form an array at file offset phoff. */
    ph = (struct proghdr*)(image + elf->phoff + i * sizeof(*ph));
    /*
     * Skip what is not loadable, and segments with nothing in the file
     * (pure bss): their memory is zero already.
     */
    if(ph->type != ELF_PROG_LOAD || ph->filesz == 0)
      continue;
    /*
     * The file bytes must lie inside the file, and the whole segment,
     * bss included, inside guest memory: the guest would use all memsz
     * bytes of it.
     */
    if(ph->off + ph->filesz > size || ph->paddr + ph->memsz > GUESTMEM)
      die("a kernel segment does not fit");
    /*
     * Without VMMEM_VIRT, the address is guest-physical: copy filesz
     * bytes from the file into guest memory at paddr.
     */
    if(vmmem(vm, ph->paddr, image + ph->off, ph->filesz, VMMEM_WRITE) < 0)
      die("cannot write guest memory");
  }
  /*
   * entry.S sets _start = V2P_WO(entry), the physical address of
   * entry, so the entry point is valid with paging off, as RIP needs.
   */
  return elf->entry;
}

An ELF file starts with a header (struct elfhdr in elf.h). It gives the entry point and the location (phoff) and number (phnum) of the program headers, which describe the segments, the pieces of the file to place in memory. loadkernel() walks them and, for each ELF_PROG_LOAD (PT_LOAD) segment, copies filesz bytes from file offset off to guest-physical address paddr, with vmmem() (Part 2; without VMMEM_VIRT the address is guest-physical). On our build there are two:

  Type  Offset    VirtAddr           PhysAddr   FileSiz  MemSiz
  LOAD  0x001000  0x0000080000100000 0x00100000 0x0c370  0x0c370  R E
  LOAD  0x00e000  0x000008000010d000 0x0010d000 0x04eba  0x14100  RW

(readelf -l xv6kernel shows yours.) The first is the code and read-only data, the second the data and the bss.

Where is the guest kernel loaded?

All of the loading happens in user space, in runxv6.c. The kernel’s part is generic: vmcreate() makes empty memory, and vmmem() copies bytes into it. A few details are worth understanding:

Notice what we do not give the guest: a memory map. A real BIOS reports which physical memory is RAM (the E820 table) and a boot loader passes it on. xv6 never asks. It assumes RAM from 0 up to PHYSTOP and gives all of it to kalloc(). That is why the guest kernel is built with PHYSTOP equal to GUESTMEM: with a larger PHYSTOP, the guest would sooner or later use memory the EPT does not map.


Step 5 (Your turn): The MP tables

References: the Intel MultiProcessor Specification 1.4, Chapter 4, “MP Configuration Table” (4.1 the floating pointer, 4.2 the configuration table header, 4.3.1 processor entries, 4.3.3 I/O APIC entries); xv6’s mp.c, mp.h and acpi.c.

With Steps 1 to 4 done and mptables() still empty, the guest dies at once: runxv6: guest triple fault at rip 0x8000010...., before it prints anything. (Read on for why.) Even once it gets further, xv6’s main() calls mpinit() third, and mpinit() panics with “Expect to run on an SMP” unless it finds a description of the machine’s CPUs and interrupt controllers in memory.

Where xv6 looks. mpinit() first calls acpiinit(), which scans for an ACPI “RSD PTR " signature (in the EBDA, then 0xE0000 to 0xFFFFF). Our guest memory has none, so it returns 0 and mpinit() falls back to the older MP tables. mpsearch() looks for an MP floating pointer structure, signature _MP_, on a 16-byte boundary, in the three places the MP spec allows, in order:

  1. the first KB of the extended BIOS data area (EBDA), whose segment is the 16-bit word at physical 0x40E, in the BIOS data area;
  2. if that word is 0, the last KB of base memory, whose size in KB is the 16-bit word at 0x413;
  3. the BIOS ROM area, 0xF0000 to 0xFFFFF. (The comment in mp.c says 0xE0000, but the code searches from 0xF0000.)

Our guest’s memory is all zeros, so the word at 0x40E is 0 and xv6 goes to case 2. If the word at 0x413 is also 0, base memory is “0 KB” and xv6 computes its last KB as starting at 0 - 1024: as a 64-bit address that is just below 2^64, and P2V() turns it into an unmapped address just below KERNBASE. The first read page faults, and since xv6 has not loaded its IDT yet (that happens in mpmain(), much later), the fault becomes a triple fault. That is what the empty mptables() produces, with RIP in memcmp(). So the first thing to write is the base memory size: 640 (KB), at 0x413, as a PC BIOS does. Then xv6 searches 0x9FC00 to 0x9FFFF, finds zeros, and moves on to the ROM area.

Guest-physical memory with the BIOS data area, the MP floating pointer at 0xF0000, the MP configuration table at 0xF0010 and the two APIC pages; and the fields of each structure

What to build. Three structures, as the figure shows. Their layouts are in mp.h (runxv6.c includes it), and they are the ones the guest’s mp.c parses, so read mpsearch1(), mpconfig() and mpinit() to see which fields matter.

A real table also has bus entries and interrupt assignment entries, which say which I/O APIC pin each device uses. mpinit() skips them (it assumes IRQ n is pin n), and we inject interrupts by vector anyway, so you need none.

The ids must be 0. The processor entry’s APIC id must be the value the guest reads from its local APIC’s ID register, because mycpu() finds the current CPU by looking lapicid() up in cpus[], and panics with “unknown apicid” if nothing matches. The local APIC is the zeroed page of Step 2, so the ID reads 0. Likewise the I/O APIC entry’s id should be 0, the value ioapicinit() reads from the zeroed I/O APIC page.

Checksums. Both structures carry a checksum byte, chosen so that all their bytes add up to 0 modulo 256: the floating pointer’s 16 bytes, and the configuration table’s length bytes (header plus entries). mpsearch1() and mpconfig() both check. The helper checksum(p, len) (given) returns the right byte provided the checksum field is still 0 when you call it. So fill in everything else first and compute the checksum last.

Writing it. Build the structures in local variables in runxv6, then copy them into the guest with vmmem(vm, addr, buf, len, VMMEM_WRITE) (guest-physical, since there is no VMMEM_VIRT). vm is the global that main() set. die() if a vmmem() fails. 0xF0000 is plain RAM in our VM, with no ROM there, and xv6 never hands memory below 1 MB to kalloc(), so the tables stay intact while the guest runs.

Hints.

How to check. Before you boot, convince yourself the tables are right: in a temporary debug block in runxv6, read them back with vmmem() (without VMMEM_WRITE), and check that the 16 bytes at 0xF0000 and the length bytes at 0xF0010 each add up to 0. Then boot (see the Checkpoint). cpu0: starting 0 means mpinit() and mycpu() were happy. If there is no output at all after runxv6: booting, see the Checkpoint for why that is what an mpinit() panic looks like.


Step 6: Start the guest (given)

SDM: as in HW1 Parts 5 and 6.

The rest of main() is given. After mptables():

  /*
   * Start at the kernel's entry point. The other registers start at 0;
   * a multiboot loader would put a magic number in EAX and an info
   * pointer in EBX, but xv6's entry.S does not look at them.
   */
  vmsetreg(vm, VMREG_RIP, entry);

  printf(1, "runxv6: booting %s (type Ctrl-] to stop)\n", argv[1]);
  /*
   * Raw mode: every key goes straight to us, and on to the guest,
   * whose own console echoes and edits lines. In normal mode, our
   * console would echo each key a second time and give us only whole
   * lines.
   */
  consraw(1);

The devices, including the CRT controller, which matters even though nobody looks at the guest’s screen, are explained in Part 5. For this part, two facts are enough: the guest’s cprintf() output reaches us through COM1, and only after uartinit() has found the UART (its line xv6... is the sign).


Checkpoint

Build the host’s disk, which now includes xv6kernel and xv6fs.img, and boot the host:

make fs.img
make qemu-nox        # or: make bochs-nox

At the host’s shell prompt, type:

$ runxv6 xv6kernel xv6fs.img
runxv6: booting xv6kernel (type Ctrl-] to stop)
xv6...
cpu0: starting 0
cpu0: no VMX support

Under Bochs this can take a minute. Note what is printed by whom: runxv6: booting comes from runxv6; the other three lines are the guest kernel’s, sent through its emulated serial port. Then nothing more happens. The guest is not hung: its scheduler is running, and its first process, init, is waiting for the disk interrupt of its first read. Type Ctrl-] to stop it (runxv6: stopped (Ctrl-])). If runxv6 does not react, quit the emulator (Ctrl-A x) and boot again.

helloguest must still work: runvm helloguest prints its greeting. It never faults, so the exception bitmap does not matter to it, and it does not touch the APIC pages.

If something fails:

Symptom Likely cause
runxv6: guest triple fault at rip 0x8000010... right after runxv6: booting, with nothing from the guest The guest faulted before loading its IDT. Most often the base memory word at 0x413 is 0 (Step 5): mpsearch() then reads an unmapped address. Look the RIP up with nm -n xv6kernel (or objdump -d xv6kernel); memcmp means mpsearch1().
runxv6: unhandled exit 0 An exception exited to the hypervisor: the exception bitmap is still HW1’s all ones (Step 1).
Nothing at all after runxv6: booting, and the guest seems hung (under Bochs even Ctrl-] may not respond) The guest panicked in mpinit() or seginit(). Its panic message went only to the CGA screen memory at 0xB8000, which nobody displays, because uartinit() had not run yet. “Expect to run on an SMP”: no floating pointer found or the table was rejected (signature, a checksum, version not 1 or 4, physaddr 0, not on a 16-byte boundary, wrong place). “unknown apicid”: the processor entry’s APIC id is not 0. Check your tables as Step 5 suggests.
runxv6: guest touched unmapped guest-physical 0xFEE000F0 The local APIC page is missing (Step 2); 0xF0 is the first register lapicinit() writes. 0xFEC00000 instead: the I/O APIC page is missing.
runxv6: unhandled exit 10 after cpu0: starting 0 No case EXIT_CPUID in vmxhandle() (Step 3).
runxv6: unhandled exit 31, qualification 0x0 after cpu0: starting 0 VMX is not hidden: the guest’s vmxinit() saw the bit and read IA32_FEATURE_CONTROL (Step 3, point 3).
The guest stops making progress after cpu0: starting 0, with no message The EXIT_CPUID case does not advance RIP: the guest runs CPUID forever, and each exit is handled in the kernel.
runxv6: cannot write guest memory, cannot write the BIOS data area or cannot write the MP tables vmmem() (Part 2) fails for a guest-physical address below 32 MB: check it, and check that your vmcreate() maps all of memsz.
The guest prints lllll... forever A recursive panic in cgaputc(): reads of the CRT controller ports return all ones. The emulation is given (crt() in runxv6.c, Part 5); this happens only if doio() no longer sends ports 0x3D4 and 0x3D5 to it.

Questions

Answer these in answers-hw2.md, two or three sentences each.

3.1. Before Step 1, the exception bitmap was all ones. Suppose you had kept it that way for the xv6 guest. What would happen the first time a user program in the guest dereferences a null pointer, and what would the hypervisor have to do to make the guest behave exactly as on a real machine? Why does INT $64 not raise the same issue?

3.2. CPUID causes a VM exit unconditionally, while instructions such as HLT exit only if the VMCS asks for it (SDM 28.1.2 and 28.1.3). Why would a CPU designer make CPUID always exit? Use what you did in Step 3 as an example, and give one more reason a hypervisor might want to change what the guest sees.

3.3. loadkernel() copies each segment to p_paddr. What would happen, exactly, if it used p_vaddr instead? And what would go wrong if vmcreate() did not zero guest memory, given that loadkernel() copies only filesz bytes of each segment?

3.4. Your MP table describes one processor with APIC id 0. (a) If its APIC id were 1, where exactly would the guest stop, and why would you see no message? (b) Suppose instead you listed two processors, with ids 0 and 1. Read startothers() in main.c and lapicstartap() in lapic.c: what would the guest do, and why can our zeroed APIC page never make it work?


Part 4: Interrupts

At the end of Part 3 the guest boots far enough to print its first lines, reaches its scheduler, starts its first process, and then goes silent. That process asked the disk for the superblock and went to sleep until the disk interrupt arrives. Nobody delivers it: so far your hypervisor has no way to interrupt the guest. The timer is missing too, so the guest has no clock, no sleep() and no preemption.

In this part you add interrupts for the guest. runxv6 plays the hardware and decides when an interrupt happens; it calls vminject(vm, vector), which only marks the vector pending. Your kernel then delivers it through the guest’s own IDT, at a VM entry, as soon as the guest can take it. When you finish, the guest gets a timer interrupt on every host tick and its first disk interrupt, and it stops one step later, at the first disk read, which needs the string I/O of Part 5.

Where to look things up. As before, “SDM” is the Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3 (order number 325384-092, June 2026). This edition renamed the two fields this part is about. The field that VM entry reads to inject an event, called “VM-entry interruption information” in older editions and in most hypervisor code (and in our vmx.h), is now the injected-event identification. The field that a VM exit fills when it interrupts the delivery of an event, called “IDT-vectoring information”, is now the original-event identification. The handout gives both names.


Two interrupt stories

Two quite different things are called “interrupts” in a hypervisor. Keep them apart.

Story 1: host interrupts while the guest runs (HW1, plus one change). The host’s own devices, most importantly each CPU’s local APIC timer, keep interrupting while the guest runs. In HW1 you set “external-interrupt exiting”, so every such interrupt causes a VM exit (reason 1, EXIT_EXTERNAL_INTR; SDM 28.2). The exit does not acknowledge the interrupt: it stays pending in the host’s local APIC (SDM 30.1), and the host takes it through its normal IDT and trap() when vmrun() calls popcli(). The guest never sees host interrupts.

HW2 adds one thing to this story. runxv6 is an ordinary single-threaded process, and while vmrun() loops in the kernel, runxv6 does not run. In HW1 that was fine, because the hello-world guest did I/O all the time. An xv6 guest is different: when it has nothing to do, it spins in its scheduler loop, with no I/O and no HLT, so it never exits to user space. But runxv6 is the guest’s timer and keyboard: it must run regularly to inject the timer interrupt and to look for typed keys. So from now on, vmrun() returns to user space at the first external-interrupt exit after the host’s tick count (ticks) has changed. vmrun() records ticks in vm->starttick when it is called, and the EXIT_EXTERNAL_INTR case of vmxhandle() compares. runxv6 therefore runs at least once per host tick, however quiet the guest is.

Story 2: interrupts for the guest (new). Every interrupt the guest receives is invented by runxv6, which emulates the guest’s hardware, and delivered by your kernel with VMX event injection (SDM 29.6). There are exactly three:

Vector xv6 name Raised by runxv6 when
32 T_IRQ0 + IRQ_TIMER the host tick count changes (once per host tick)
36 T_IRQ0 + IRQ_COM1 a key arrives for the guest’s serial port and the guest has set IER bit 0 (“received data available”), or the guest sets IER bit 0 while keys are waiting
46 T_IRQ0 + IRQ_IDE a disk read command has its data ready, or a disk write has received all its data

The keyboard part and the disk part are Part 5. This part builds the path that all three take from vminject() to the guest’s trap().

How a PC delivers an interrupt, and what our guest has instead

On a real PC (SDM 13.1 gives the overview), a device raises an interrupt like this:

 device --IRQ pin--> I/O APIC --message--> local APIC --> CPU core
                     redirection table:    IRR, ISR, TPR:    takes it when
                     vector, destination,  picks the         RFLAGS.IF = 1,
                     mask, trigger mode    highest-priority  through IDT[vector]
                                           pending vector

The I/O APIC turns IRQ pin n into a message that carries a vector and a destination CPU; xv6’s ioapicenable(irq, cpu) programs vector 32 + irq. The local APIC of that CPU records the vector in its interrupt request register (IRR, one bit per vector, SDM 13.8.4), picks the highest-priority one (SDM 13.8.3), and hands it to the core when the core has interrupts enabled. The handler’s write to the EOI register tells the local APIC that the interrupt is done. The local APIC also has its own timer, which xv6 programs to fire vector 32 periodically. The old 8259A PIC is still there on a PC, but xv6’s picinit() masks it with two outbs and never uses it.

Our guest has none of these devices. It has:

What this gives up:

A later homework replaces the zeroed pages with real emulation: leave the APIC pages out of the EPT and emulate each access in the EPT-violation exit, or use the VMX features made for this, “virtualize APIC accesses” (APIC-access exits, SDM 32.4) and virtual-interrupt delivery (SDM 32.2), described in SDM Chapter 32, “APIC Virtualization and Virtual Interrupts”.

What is given and what you write

Piece Where Status Starts as
New fields in struct vm: intrstate, idtvec, idterr, idtlen, cpuctl, pending, starttick vmx.c given (Step 1) complete
vm->cpuctl = cpu; in vmcsinit() your vmcsinit() from Part 3; check it (Step 1)
vminject(), nextpending(), sys_vminject() vmx.c, sysvmx.c given (Step 2) complete
Constants: CPU_INTR_WINDOW_EXITING, VM_ENTRY_EXCEPTION_ERROR_CODE, VM_ENTRY_INSTRUCTION_LEN, IDT_VECTORING_INFO, IDT_VECTORING_ERROR_CODE, INTR_INFO_VALID, INTR_TYPE_*, INTERRUPTIBILITY_*, EXIT_INTR_WINDOW vmx.h, vmxapi.h given complete
starttick, the call to vmxinject(), and the reads after each exit your vmrun() you add (Step 3) your HW1 code
EXIT_EXTERNAL_INTR returns once per tick; new case EXIT_INTR_WINDOW your vmxhandle() you add (Step 4) your HW1 code
vmxinject() vmx.c you write (Step 5) the stub below
The timer, COM1 and IDE injections runxv6.c given (Step 6) complete

The stub of vmxinject() in the starter tree injects nothing and never asks for an interrupt-window exit:

static void
vmxinject(struct vm *vm)
{
  vmwrite(VM_ENTRY_INTR_INFO_FIELD, 0);
  vmwrite(CPU_BASED_VM_EXEC_CONTROL, vm->cpuctl);
}

Steps 3 and 4 are the “paste these lines into your own function” kind. Step 5 is a Your turn: you get the rules, the fields and the hints, but no code.


Step 1: The injection state in struct vm

SDM: 27.4.2 “Guest Non-Register State” (Table 27-3); 27.9.3 “Information for VM Exits That Occur During Event Delivery” (Table 27-21); 13.8.4 “Interrupt Acceptance for Fixed Interrupts” (the IRR).

The starter tree adds these fields to struct vm in vmx.c, each with a long comment that you should read there. They hold what vmxinject() needs on the next entry, saved at the last exit, because after vmrun() VMCLEARs the VMCS no vmread() is possible.

Field What it holds Written by
uint intrstate the guest interruptibility state at the last exit: bit 0 blocking by STI, bit 1 blocking by MOV SS vmrun(), after each exit (Step 3)
uint idtvec the original-event identification (IDT-vectoring information) of the last exit: bit 31 set means the exit interrupted the delivery of that event vmrun(); vmxinject() uses it
uint idterr the original-event error code, if idtvec has bit 11 vmrun()
uint idtlen the VM-exit instruction length of that exit, needed to re-inject a software interrupt vmrun()
uint cpuctl the primary processor-based controls computed by vmcsinit(), without interrupt-window exiting vmcsinit()
uint64 pending[4] 256 bits, one per vector, like a local APIC’s IRR vminject() sets, vmxinject() clears
uint starttick the host’s ticks when vmrun() was called vmrun() (Step 3)

cpuctl is set by one line in vmcsinit(), right after the write of the primary controls. You added it with the other vmcsinit() changes in Part 3; check that it is there:

  vmwrite(CPU_BASED_VM_EXEC_CONTROL, cpu);
  /*
   * HW2: keep the base value.  vmxinject() writes this field again
   * before every entry, as cpuctl plus, when needed, interrupt-window
   * exiting; it needs the value without that bit to turn it off.
   */
  vm->cpuctl = cpu;  /* vmrun() adds interrupt-window exiting to it */

Without it vm->cpuctl is 0, and the stub’s first VM entry fails with VM-instruction error 7: the required 1-bits of the primary controls are missing.


Step 2: vminject() and nextpending()

SDM: 13.8.3 “Interrupt, Task, and Processor Priority”; 13.8.4 “Interrupt Acceptance for Fixed Interrupts”.

Both are given. vminject() is the system call behind runxv6’s vminject(vm, vector). After checking the VM and the vector, it does one thing:

  vm->pending[vector / 64] |= 1ULL << (vector % 64);

It does not touch the VMCS. It cannot: the caller is runxv6, in a system call, so the guest is not running and no VMCS is current. The interrupt is only recorded, and the next VM entry picks it up. Vectors 0 to 31 are rejected: they are reserved for exceptions (SDM 13.8.3), and the guest’s IDT handles them as such.

nextpending() chooses which pending vector goes first:

/*
 * The highest pending vector, or -1 if none.  A local APIC also delivers
 * the highest-priority pending interrupt first, and an interrupt's
 * priority is its vector: the higher the vector, the higher the priority
 * (SDM 13.8.3, "Interrupt, Task, and Processor Priority").  We have no
 * task priority or in-service register, so this is all of our priority.
 */
static int
nextpending(struct vm *vm)
{
  int i, b;

  /*
   * Scan from the top: word 3 holds vectors 192-255, word 0 vectors
   * 0-63.  Skip empty words, then look for the highest set bit.
   */
  for(i = 3; i >= 0; i--)
    if(vm->pending[i])
      for(b = 63; b >= 0; b--)
        if(vm->pending[i] & (1ULL << b))
          return 64*i + b;  /* bit b of word i is vector 64*i + b */
  return -1;
}

Why the highest vector first? That is what a local APIC does. SDM 13.8.3: the priority class of an interrupt is bits 7:4 of its vector, and within a class, the higher bits 3:0 win. Together that is simply “the higher vector has the higher priority”. So with the disk (46), a key (36) and the timer (32) all pending, the disk goes first. A real local APIC would also compare with its task priority register (TPR) and with the vector in service (ISR) and hold lower-priority interrupts back until the handler’s EOI. We have neither, so the vector order is all the priority we model.

A vector pending twice is delivered once. The set has one bit per vector, so calling vminject(vm, 32) twice before the guest takes the first one leaves one pending timer interrupt. A local APIC behaves the same way: requests for a vector that is already in the IRR collapse into its one bit (SDM 13.8.4). For a device that is harmless, because the handler reads the device’s state and finds everything that happened (uartintr() reads until the receive queue is empty). For the timer it means a lost tick.


Step 3: Additions to your vmrun()

SDM: 30.3.4 “Saving Non-Register State” (the interruptibility state is saved on every exit); 27.9.3 and 30.2.4 “Information for VM Exits During Event Delivery” (the original-event fields); 30.2.5 “Information for VM Exits Due to Instruction Execution” (the instruction length for such exits).

Three additions, in the vmrun() you fixed in HW1.

(a) Before the loop, record the host’s tick count:

  if((vm = getvm(id)) == 0)
    return -1;
  /*
   * HW2: once per call, not per entry: vmxhandle() returns to user
   * space at the first external-interrupt exit after ticks moves on.
   */
  vm->starttick = ticks;

  for(;;){

ticks is the host’s tick counter from trap.c, declared in defs.h. It is set once per call, not per entry: the loop may enter and leave the guest many times, and we want to return when a tick has passed since runxv6 last ran.

(b) Just before the entry, after the VM-entry controls are written:

    if(vm->efer & EFER_LMA)
      entryctl |= ENTRY_IA32E_MODE_GUEST;
    vmwrite(VM_ENTRY_CONTROLS, entryctl);
    /*
     * HW2: set up event injection last, right before the entry, with
     * the VMCS current and interrupts off.  It depends on the RFLAGS
     * written above, and on anything vminject() queued since the last
     * entry.
     */
    vmxinject(vm);

The comment and the call r = vmx_enter(&vm->regs, 0); follow, as before. The position matters: vmxinject() uses vmwrite(), so the VMCS must be current (after vmptrld()), and it decides based on vm->rflags, which must be the value this entry loads into GUEST_RFLAGS.

(c) After each exit, in the branch that handles a real exit (not a failed VMLAUNCH), next to the control registers you save since Part 2:

      vm->cr4 = vmread(GUEST_CR4);
      /*
       * STI and MOV SS blocking at the time of the exit (SDM 30.3.4),
       * for vmxinject() on the next entry.
       */
      vm->intrstate = vmread(GUEST_INTERRUPTIBILITY_INFO);
      /*
       * The original-event identification.  Read it after every exit,
       * before VMCLEAR: the next exit overwrites it, and a valid event
       * not re-injected is lost (see vmxinject()).
       */
      vm->idtvec = vmread(IDT_VECTORING_INFO);
      /*
       * The error code and instruction length mean something only for
       * a valid event (SDM 30.2.4): the error code if bit 11 is set,
       * the length for software events.  For an exit during delivery
       * of a software interrupt or exception, the VM-exit instruction
       * length is that of the INT instruction (SDM 30.2.5).
       */
      if(vm->idtvec & INTR_INFO_VALID){
        vm->idterr = vmread(IDT_VECTORING_ERROR_CODE);
        vm->idtlen = inslen;
      }
    }

The closing brace is the end of that branch, just before vmclear(). (Part 5 adds two more lines here.)

What each read is for:


Step 4: Additions to your vmxhandle()

SDM: 28.2 “Other Causes of VM Exits” (external interrupts, interrupt window); 30.1 “Architectural State Before a VM Exit” (the interrupt is not acknowledged); 30.3.3 “Saving RIP, RSP, RFLAGS, and SSP”; Appendix C, Table C-1 (reason 7, “Interrupt window”).

(a) The external-interrupt case returns to user space once per tick. Your HW1 case only returned 1. Add the if and the two comments above it, so that the case reads:

  case EXIT_EXTERNAL_INTR:
    /*
     * HW2: once the host timer has ticked since vmrun() was called,
     * return to user space, so that runxv6 gets to run at least once per
     * tick: it polls for console input and injects the guest's timer
     * interrupt.  Otherwise just resume the guest.
     */
    /*
     * runxv6 is an ordinary single-threaded process: while vmrun()
     * loops in the kernel, it cannot run.  If vmrun() returned only on
     * I/O or HLT, a guest computing in a loop would get no timer
     * interrupts (no preemption, no sleep() wakeups) and no typed keys.
     * Returning on every host interrupt would also work, but would
     * cost a trip to user space each time; once per tick is enough.
     * (Each CPU's local APIC timer interrupt causes one of these exits,
     * so one comes about once per tick even in an idle host.)
     * Host scheduling does not depend on it: the timer interrupt taken
     * at popcli() in vmrun() can already make this process yield().
     *
     * ticks is advanced by CPU 0's timer interrupt (trap.c), possibly
     * just now at popcli().  It is read without tickslock: a stale value
     * only delays the return to a later exit.
     */
    if(ticks != vm->starttick)
      return 0;
    /*
     * The interrupt is still pending in the local APIC (we don't
     * acknowledge it on exit), so the host took it at popcli().
     * Nothing to do but resume the guest.
     */
    return 1;

Note the order of events: the exit happens, vmrun() VMCLEARs and calls popcli(), the host takes the timer interrupt (which may increment ticks), and only then is vmxhandle() called. So if the interrupt behind this exit was the tick that advanced ticks (only CPU 0’s timer does that), the test already sees it. On another CPU, CPU 0 advances ticks meanwhile, and the next external-interrupt exit sees the change. Returning 0 hands the exit to runxv6, whose main loop treats EXIT_EXTERNAL_INTR as “nothing to emulate, but you get to run now” (Step 6).

(b) A new case for the interrupt-window exit. Add it inside the switch, before default (the reference puts it after EXIT_HLT):

  case EXIT_INTR_WINDOW:
    /*
     * HW2: the guest can take an interrupt now.  Resume it; vmrun()
     * injects the pending interrupt on this entry.
     */
    /*
     * The CPU took this exit before the first instruction at which
     * RFLAGS.IF = 1 and there is no blocking by STI or MOV SS (SDM 28.2).
     * The saved RIP is that instruction, which has not run (SDM 30.3.3),
     * so RIP is not advanced.  The control itself stays set until
     * vmxinject() writes the base controls back on the next entry;
     * left on, it would exit before every instruction.  No trip to user
     * space: the kernel has everything needed to inject.
     */
    return 1;

This exit (reason 7) happens only while vmxinject() has turned on “interrupt-window exiting”. It means “the guest could take an interrupt now”. There is nothing to emulate and nothing to skip: SDM 30.3.3 says the saved RIP is “that which would be in the register had the VM exit not occurred”, the instruction that is about to run. Returning 1 loops back in vmrun(), whose next vmxinject() finds the window open and injects. Without this case, default sends the exit to runxv6, which stops with unhandled exit 7.


Step 5: Your turn: vmxinject()

SDM: 27.8.3 “VM-Entry Controls for Event Injection” (Table 27-18, the injected-event identification); 27.9.3 (Table 27-21, the original-event identification); 30.2.4 “Information for VM Exits During Event Delivery”; 30.2.5 (the instruction length); 29.6 “Event Injection”, 29.6.1.1 “Details of Event Injection”, 29.6.1.2 “VM Exits During Event Injection”; 28.2 and 29.7.5 “Interrupt-Window Exiting and Virtual-Interrupt Delivery”; 27.4.2 (Table 27-3); the entry checks in 29.2.1.3, 29.3.1.4 and 29.3.1.5; Vol. 3A 7.12.1.3 (an interrupt gate clears IF).

Goal. vmrun() calls vmxinject(vm) before every VM entry, with the VMCS current, host interrupts off, and GUEST_RFLAGS already written from vm->rflags. It decides two things for this entry:

  1. which event, if any, VM entry injects (at most one per entry), written to the injected-event identification field;
  2. whether the guest should exit as soon as it can take an interrupt, written as the “interrupt-window exiting” bit of the primary processor-based controls.

The fields and constants (all in vmx.h; FL_IF is in mmu.h):

Field (this SDM’s name / older name) Constant Encoding
Injected-event identification / VM-entry interruption information VM_ENTRY_INTR_INFO_FIELD 0x4016 (Table B-8)
Injected-event error code / VM-entry exception error code VM_ENTRY_EXCEPTION_ERROR_CODE 0x4018 (Table B-8)
VM-entry instruction length VM_ENTRY_INSTRUCTION_LEN 0x401A (Table B-8)
Primary processor-based VM-execution controls CPU_BASED_VM_EXEC_CONTROL 0x4002 (Table B-8)
Original-event identification / IDT-vectoring information IDT_VECTORING_INFO 0x4408 (Table B-9), saved in vm->idtvec
Original-event error code / IDT-vectoring error code IDT_VECTORING_ERROR_CODE 0x440A (Table B-9), saved in vm->idterr
VM-exit instruction length VM_EXIT_INSTRUCTION_LEN 0x440C (Table B-9), saved in vm->idtlen
Guest interruptibility state GUEST_INTERRUPTIBILITY_INFO 0x4824 (Table B-10), saved in vm->intrstate

The injected-event identification (Table 27-18) and the original-event identification (Table 27-21) have the same layout:

Bits Meaning Constants
7:0 vector
10:8 type: 0 external interrupt, 2 NMI, 3 hardware exception, 4 software interrupt (INT n), 5 privileged software exception (INT1), 6 software exception (INT3, INTO) INTR_INFO_TYPE(x), INTR_TYPE_EXT_INTR, INTR_TYPE_HW_EXCEPTION, INTR_TYPE_SOFT_INTR, INTR_TYPE_PRIV_SW_EXC, INTR_TYPE_SOFT_EXC
11 deliver an error code (injected) / error code valid (original) INTR_INFO_ERROR_VALID
12 reserved, must be 0 (injected) / undefined (original, SDM 30.2.4)
31 valid INTR_INFO_VALID

Other constants: INTERRUPTIBILITY_STI and INTERRUPTIBILITY_MOVSS (bits 0 and 1 of the interruptibility state), CPU_INTR_WINDOW_EXITING (bit 2 of the primary controls, Table 27-6), FL_IF (bit 9 of RFLAGS).

The rules, in priority order.

  1. Re-inject an event whose delivery the last exit interrupted. If vm->idtvec has the valid bit, the exit happened while the CPU was delivering that event to the guest, and the event did not reach its handler (SDM 30.2.4; SDM 30.1: CS, RIP, RSP and RFLAGS were not updated for it). Inject it again:

    • the identification is the original one with bit 12 cleared (bit 12 is undefined in the original-event field but reserved in the injected one);
    • if bit 11 is set, also write the saved error code to the injected-event error-code field;
    • if the type is 4, 5 or 6 (a software interrupt or exception), also write the saved instruction length to the VM-entry instruction-length field.

    This comes first because this event happened first, and only one event can be injected per entry.

  2. Otherwise, inject the highest pending vector, if the guest can take it now. “Can take it” means exactly what it means for a real CPU: RFLAGS.IF is 1 in vm->rflags, and vm->intrstate shows no blocking by STI or by MOV SS. Then the identification is the vector, type 0 (external interrupt) in bits 10:8, and the valid bit 31. No error code and no instruction length. Clear that vector’s bit in vm->pending: it has left the pending set now.

  3. Ask for an interrupt-window exit whenever an interrupt is still pending after this decision. That covers two cases: an interrupt is pending but the guest cannot take one now (rule 2 did not inject), and this entry injects something but more interrupts wait behind it. Set CPU_INTR_WINDOW_EXITING in the value you write to the primary controls. The CPU then exits (reason 7) before the first instruction at which IF is 1 and nothing blocks interrupts (SDM 28.2), and the next entry injects. This is safe even when this entry injects an event: the window exit comes only after the injected event has been delivered (SDM 29.7.5, “These events occur after any event injection”), and delivery through an interrupt gate clears IF, so the window opens only when the guest’s handler returns with iretq.

Finally, write both fields on every entry: the identification (0 when nothing is injected) and the primary controls, computed from vm->cpuctl plus, when rule 3 says so, CPU_INTR_WINDOW_EXITING. Writing the controls every time is what turns window exiting off again once nothing is pending.

What is really lost without rule 1? Look at what each kind of event leaves behind when its delivery is interrupted:

When can such an exit happen here? SDM 30.2.4 lists the cases: an exception that exits because of the exception bitmap, a task switch, an APIC-access exit, or an EPT violation or misconfiguration during delivery. Our exception bitmap is 0, xv6 uses no task gates, and all of guest memory is mapped from the start, so in this hypervisor rule 1 almost never fires. A hypervisor that sets exception-bitmap bits, or that maps guest memory lazily on EPT violations, as KVM does, meets it all the time. Write it anyway: it is the rule that keeps the hypervisor correct after you change something else.

Hints.

How to check. See the Checkpoint: with a temporary cprintf you should see vector 32 injected about once per host tick and vector 46 after the guest’s first disk command.


Step 6: The guest’s timer in runxv6

Reference: xv6’s trap.c (IRQ_TIMER) and lapic.c; no SDM.

Given. The guest’s local APIC timer is never going to fire (it is a page of memory), so runxv6 plays its part. Before its main loop it records the host’s tick count with uptime():

  /* uptime() counts host timer ticks; see the end of the loop. */
  lasttick = uptime();

In the loop, an EXIT_EXTERNAL_INTR that reaches user space (Step 4a) has nothing to emulate:

    case EXIT_EXTERNAL_INTR:
      /* The host timer ticked: just a chance to run below. */
      break;

and after every exit, whatever its reason, the end of the loop checks the clock:

    /* Once per host tick: the guest's timer interrupt. */
    /*
     * The guest's local APIC timer is not emulated (its registers are a
     * plain page), so we play its part: one vector 32 per host tick.
     * The guest's trap() counts ticks on it and preempts the running
     * process, as it would at each local APIC timer interrupt, and its
     * lapiceoi() just writes the plain page. If the guest has not taken
     * the previous one yet, the two merge into one pending interrupt.
     */
    if(uptime() != lasttick){
      lasttick = uptime();
      vminject(vm, VEC_TIMER);
    }

VEC_TIMER is 32 + 0, T_IRQ0 + IRQ_TIMER in xv6’s traps.h. Because vmrun() returns at least once per host tick (Step 4a), this runs at least once per tick, so the guest gets one timer interrupt per host tick, or fewer when runxv6 waits for the CPU and ticks merge. The other two vminject() calls are in the device emulation, which Part 5 covers: in rxpush() and in the IER write of com1() (vector 36), and in ide() and idedone() (vector 46).


One timer tick, end to end

Here is everything that happens for one guest timer interrupt, in the common case where the guest happens to have interrupts off when the tick comes. Say the guest is in its scheduler loop, scanning the process table while it holds ptable.lock, so its RFLAGS.IF is 0.

  1. Host timer. The host CPU’s local APIC timer fires while the guest runs. External-interrupt exiting causes a VM exit, reason 1. The interrupt is not acknowledged; it stays pending in the host’s local APIC (SDM 30.1). The exit leaves host interrupts off.
  2. Read and clear. vmrun() reads the exit fields, including intrstate and idtvec (Step 3c), VMCLEARs the VMCS and calls popcli().
  3. The host takes its interrupt. With interrupts on, the pending timer interrupt goes through the host’s IDT to trap(); on CPU 0 it increments ticks. (The host may also reschedule runxv6 here.)
  4. Back to user space. vmxhandle() sees ticks != vm->starttick and returns 0; vmrun() returns the exit to runxv6.
  5. runxv6 injects. Its switch does nothing for EXIT_EXTERNAL_INTR; then uptime() != lasttick, so it calls vminject(vm, 32), which sets bit 32 of vm->pending. It polls the console and calls vmrun() again.
  6. Entry with window exiting. vmrun() sets starttick = ticks, loads the VMCS and calls vmxinject(). No interrupted event; vector 32 is pending; but the guest’s RFLAGS.IF is 0, so nothing is injected and CPU_INTR_WINDOW_EXITING is turned on. VMLAUNCH.
  7. The guest opens the window. The guest finishes its scan and calls release(&ptable.lock), whose popcli() executes sti. STI blocking holds interrupts off for one more instruction; before the instruction after that, IF is 1 and nothing blocks, so the CPU exits with reason 7 (EXIT_INTR_WINDOW). The saved RIP is the instruction that has not run yet.
  8. Inject. vmxhandle() returns 1, without a trip to user space. vmrun() loops, and vmxinject() now finds IF = 1 and no blocking: it writes 0x80000020 (valid, type 0, vector 32) to the injected-event identification, clears the pending bit, and, with nothing else pending, writes the controls without window exiting. VMLAUNCH.
  9. Delivery. As the last step of VM entry, the CPU delivers vector 32 through the guest’s IDT (SDM 29.6.1), exactly as if it had come from a local APIC: it pushes the guest’s SS, RSP, RFLAGS, CS and the RIP saved in step 7, and, since xv6’s vector 32 is an interrupt gate, clears IF. The guest runs vector32, alltraps and trap().
  10. The guest’s handler. trap() matches T_IRQ0 + IRQ_TIMER: cpuid() is 0, so it increments the guest’s ticks and wakes up sleepers. lapiceoi() writes the EOI register, which is a store to the zeroed page: no exit, and nobody notices. If a process was running, trap() calls yield(). Finally iretq restores IF = 1, and the guest goes on until the next exit.

If the guest already had IF = 1 at step 6, steps 7 and 8 disappear: the first entry injects at once.


Checkpoint

Build and boot as in Part 3 (make, make fs.img, then make qemu-nox on CADE or make bochs-nox elsewhere). The guest still cannot boot all the way: its first disk read uses rep insl, string I/O that your kernel handles only after Part 5. But you can watch the interrupts go in.

Add two temporary cprintfs. One goes at the top of vminject(), after its checks, and shows each request from runxv6:

  cprintf("vminject %d\n", vector);

The other goes in vmxinject(), just before the final two vmwrite()s, and shows each injection, whenever this entry injects something:

  if(info & INTR_INFO_VALID)
    cprintf("inject 0x%x\n", (uint64)info);

(use the name of your own variable; %x reads 64 bits, so cast). Then run runxv6 xv6kernel xv6fs.img. You should see something like this (the vminject 32 lines are interleaved with the guest’s boot messages, and their number depends on how fast your machine boots the guest):

$ runxv6 xv6kernel xv6fs.img
runxv6: booting xv6kernel (type Ctrl-] to stop)
xv6...
vminject 32
cpu0: starting 0
cpu0: no VMX support
vminject 32
inject 0x80000020
vminject 46
inject 0x8000002e
vminject 32
vminject 32
vminject 32
...

What to check:

  1. vminject 32 comes about once per host tick, for the whole run, including after the guest has stopped making progress: runxv6 keeps running and keeps playing the timer.
  2. The first inject 0x80000020 (valid, type 0, vector 32) comes only after cpu0: no VMX support. The guest boots with IF = 0, so the ticks requested during boot merge into one pending vector 32, which waits, with window exiting on, until the guest’s scheduler executes its first sti. (On a very fast machine no tick may fall in that window; then the first inject line is the disk’s.)
  3. vminject 46 comes right after the guest’s first IDE read command, and inject 0x8000002e (vector 46) shortly after it, once the guest has gone to sleep in iderw(), released its locks, and enabled interrupts.
  4. After that there are no more inject lines and no sb: size 2000 ... line. The guest’s IDE handler executes rep insl, string I/O that your HW1 vmxhandle() does not handle, so runxv6 gets the same I/O exit over and over, while the guest sits with IF = 0 inside its handler. The timer requests pile up as one pending vector. That is expected until Part 5.
  5. Type Ctrl-]: runxv6 must stop and return to the host’s shell.

Remove both cprintfs when you are done; at one line per host tick they swamp the console.

The once-per-tick return of Step 4a cannot really be seen yet, because this guest stops before it ever idles. You will see it in Part 5: at the guest’s shell prompt the guest only spins in its scheduler, and without Step 4a nothing would ever bring runxv6 back to deliver your keys.

If something fails:

Symptom Likely cause
vminject 32 and vminject 46 appear, but no inject line at all vmxinject() is still the stub, or it is not called (Step 3b).
vminject 46 appears, but inject 0x8000002e comes much later or never; the guest hangs forever with IF = 0 most of the time No interrupt-window exiting. The guest spends most of its time with IF = 0 (its scheduler holds ptable.lock), so an interrupt that is pending at entry waits for some unrelated exit that happens to find IF = 1. Rule 3.
runxv6: unhandled exit 7, ... The EXIT_INTR_WINDOW case is missing from vmxhandle() (Step 4b).
(In Part 5, at the guest’s shell prompt:) typed keys never reach the guest, timertest never finishes, and Ctrl-] does nothing vmrun() never returns to user space while the guest idles: the ticks != vm->starttick test is missing from the EXIT_EXTERNAL_INTR case (Step 4a). runxv6 never runs, so it can neither inject the timer nor read the keyboard.
runxv6: VMLAUNCH failed, error 7 right after an injection, or now and then Bad bits in the identification (SDM 29.2.1.3): bit 12 copied from the original-event field (an intermittent failure, since rule 1 is rare), the type in the wrong bits, bit 11 set for an external interrupt, or an instruction length above 15. Also check Step 1: vm->cpuctl must not be 0.
runxv6: VM entry failed, reason 33, ... after an injection You injected an external interrupt while RFLAGS.IF was 0 or with STI or MOV SS blocking (SDM 29.3.1.4, 29.3.1.5).
The guest crawls, and exits with reason 7 constantly Window exiting is never turned off: you set it in vm->cpuctl, or you do not write CPU_BASED_VM_EXEC_CONTROL on every entry.
inject 0x80000020 prints without end, much faster than the host ticks The pending bit is not cleared when the vector is injected.
inject lines appear, but the guest never handles them (the guest hangs waiting for the disk) The valid bit (31) is missing or the vector is in the wrong bits: the entry injects nothing, and the pending bit is already cleared.
The guest panics with unexpected trap 78 (or another odd number) Wrong vector: vminject() already takes the vector (32 + IRQ), not the IRQ; do not add 32 again.

Questions

Answer these in answers-hw2.md, two or three sentences each.

4.1. nextpending() delivers the highest pending vector first. Which rule of a local APIC does this imitate (SDM 13.8.3), and what does a real local APIC also take into account that we ignore? With the timer (32) and the disk (46) both pending, which goes first, and what happens to a second timer tick that arrives before the guest has taken the first? 4.2. Rule 1 of vmxinject() re-injects an event whose delivery was interrupted by a VM exit. For each of (a) a timer interrupt we injected, (b) a page fault raised by a guest instruction, (c) an int $64 system call: is the event lost if we do not re-inject it, and why? For (c), what goes wrong if we re-inject it but forget the instruction length? 4.3. Why must vmrun() return to user space once per host tick? Describe what the guest would do, and what you would see at the terminal, if it returned only on I/O and HLT exits while the guest sits at an idle shell prompt and you type a key. Why not return on every external-interrupt exit instead? 4.4. Why does vmxinject() not inject an external interrupt when the guest has RFLAGS.IF = 0? Give two reasons: one from the VM-entry checks, one from what xv6 is doing when IF is 0. Why is waiting for the next exit of any kind not good enough, so that we need interrupt-window exiting? 4.5. Our guest has one CPU. List what the hypervisor and runxv6 would need in order to give it two. Think about the local APIC ID each CPU reads from the same guest-physical page, how xv6 starts the second CPU (lapicstartap()), where each interrupt should go, the VMCS, and the fact that an xv6 process has a single thread.


Part 5: Devices and string I/O

Parts 1 to 4 gave the guest memory, firmware tables and interrupts. What it still lacks is hardware: a console to print to and read from, and a disk that holds its file system. In this part you make the guest’s disk work. When you finish, the whole homework works end to end: runxv6 xv6kernel xv6fs.img boots xv6 inside your hypervisor to a shell prompt, and you can type commands at it.

Almost all the device code is in runxv6.c and is given to you. You add one kernel step, so that the kernel decodes string I/O (INS and OUTS, which xv6’s disk driver uses to move whole sectors), and you write one function in runxv6, idestring(), that carries out such a transfer.

Which devices does the guest have

The guest believes it runs on a PC. This table lists everything of that PC that xv6 touches, and who plays each part.

Device Where Played by
Emulated devices (in runxv6)
Serial port COM1 (16550 UART) ports 0x3F8-0x3FF, IRQ 4 (vector 36) com1(): transmit to our standard output, receive from our console, receive interrupts
IDE disk, drive 1 (primary channel) ports 0x1F0-0x1F7 and 0x3F6, IRQ 14 (vector 46) ide(): PIO reads and writes, with rep insl/rep outsl for the data; the medium is xv6fs.img, read into memory
CRT controller (cursor position only) ports 0x3D4, 0x3D5 crt(): a register file that reads back what was written
CPU features (in the kernel)
CPUID instruction vmxcpuid() (Part 3), which hides VMX
The EFER MSR RDMSR/WRMSR vmxmsr() (HW1)
Interrupt delivery through the guest’s IDT vmxinject() (Part 4)
Stand-ins
Local APIC, I/O APIC 0xFEE00000, 0xFEC00000 zeroed pages (vmcreate()): registers read back what xv6 wrote, nothing else
Timer (the local APIC timer) runxv6 injects vector 32 once per host tick
BIOS none runxv6 loads the kernel and writes the BIOS data area and MP tables (Part 3)
VGA text screen memory at 0xB8000 plain guest RAM that nobody displays
Absent
8259 PIC ports 0x20, 0x21, 0xA0, 0xA1 xv6 only masks it; writes are dropped
PS/2 keyboard ports 0x60, 0x64 never interrupts, so kbdintr() never runs
CMOS clock ports 0x70, 0x71 not used by the guest
IDE drive 0 ports 0x1F0-0x1F7 with the drive bit clear commands fail with ERR
PCI, other MSRs an access to another MSR stops runxv6 (“unhandled exit”)

Any other port reads as all ones, and writes to it are ignored, as on a real bus where no device answers.

Are we ignoring the guest’s screen? Yes, and nothing is lost. xv6’s consputc() sends every character to both cgaputc() (the screen) and uartputc() (the serial port). runxv6 shows the serial copy: this is the same thing make qemu-nox does for the host xv6. The screen’s characters land in ordinary guest RAM at 0xB8000, and only the CRT controller’s cursor registers need emulating, so that cgaputc() does not panic (Step 6).

Does struct vmexit describe the devices? No. For port I/O it says what the CPU trapped: the port, the size, the direction, the value of an OUT, and (new in this part) four fields for string instructions. It never says “this was the disk”. runxv6’s doio() decides that, with a switch on the port number. This is the KVM and QEMU split again: KVM reports KVM_EXIT_IO with a port number, and QEMU knows which device model lives at that port.

What is given and what you write

Function Status Stub you start with
vmxhandle(), the EXIT_IO case you edit (your HW1 code) your HW1 version, which returns early on string I/O (Step 1)
vmrun() you edit (your HW1 code, with Parts 2 and 4) your version; you add two lines (Step 1)
struct vmexit fields string, rep, count, addr (vmxapi.h); GUEST_LINEAR_ADDRESS (vmx.h) given complete
vmmem() with VMMEM_VIRT given, but it calls your gva2gpa() from Part 2 complete
doio(), com1(), rxpush(), pollconsole(), crt() given complete
idecommand(), idedone(), ide() given complete
idestring() you write die("idestring not written");

The given device code is still worth reading closely: Steps 3 to 7 walk through it, because idestring() has to fit into it.


Step 1: Decode string I/O in the kernel

SDM: 30.2.1 “Basic VM-Exit Information” (Table 30-5 “Exit Qualification for I/O Instructions”, and the item “Guest linear address”); 27.9.1 (the guest-linear address field among the VM-exit information fields); Appendix B.4.2, Table B-13 (its encoding, 0x640A); 28.1 (I/O exits are fault-like); 30.2.5 (the VM-exit instruction length covers INS and OUTS).

In HW1 your EXIT_IO case gave up on string instructions with a line like this:

    if(e->qual & (1 << 4))
      return 0;  /* string I/O (INS/OUTS) is not supported */

Remove it, and put this in its place, at the top of the EXIT_IO case, after the comment about the exit qualification and before the line that sets e->io.port:

    /*
     * HW2: string I/O (INS/OUTS, as in xv6's insl/outsl for the IDE
     * disk).  Bit 5 says it has a REP prefix.  vmrun() has already read
     * the guest-virtual address of the memory operand into e->io.addr.
     * runxv6 does the whole transfer at once (all RCX iterations of a
     * REP) and updates RCX and RSI or RDI, so the RIP can be advanced
     * here, as for any other I/O exit.
     */
    /*
     * Why that is right: the exit is fault-like (SDM 28.1), so no
     * iteration has run yet and RCX still holds the full count.  Once
     * runxv6 has done all of them and set RCX to 0 and RSI or RDI past
     * the buffer, the guest is in the state the CPU leaves at the end of
     * the instruction, and the next one must run.  A monitor that did
     * only part of the work would instead leave RIP alone: the CPU
     * would run the REP instruction again (exiting again) for the
     * iterations that remain.
     */
    if(e->qual & (1 << 4)){
      /* Bit 4 of the exit qualification: INS or OUTS (Table 30-5). */
      e->io.string = 1;
      e->io.rep = (e->qual >> 5) & 1;
      /*
       * A REP prefix repeats the transfer RCX times (xv6's insl and
       * outsl load RCX with the count); without one, a single unit
       * moves.  RCX is the guest's value, saved by vmx_exit.
       */
      e->io.count = e->io.rep ? vm->regs.rcx : 1;
    }

Table 30-5 gives the two bits we need: bit 4 is 1 for a string instruction, and bit 5 is 1 if it has a REP prefix. The rest of the qualification (size in bits 2:0, direction in bit 3, port in bits 31:16) means the same for INS/OUTS as for IN/OUT, so your HW1 decoding below this block stays as it is. In particular, for rep insl the size field is 3 and your (e->qual & 7) + 1 gives 4.

The count is not in the qualification. A REP instruction repeats RCX times, and RCX is a general-purpose register, which vmx_exit saved in vm->regs. Without REP, the instruction moves one unit.

Then, further down in the same case, change the condition on the line that records the data of an OUT:

    /*
     * HW2: not for OUTS, whose data is in guest memory at e->io.addr;
     * runxv6 reads it there with vmmem().
     */
    if(!e->io.in && !e->io.string)
      e->io.data = vm->regs.rax & iomask(e->io.size);

OUTS does not send RAX: it sends bytes from memory at DS:RSI. RAX holds something unrelated, and io.data stays 0. Leave the vm->rip += inslen and the return 0 that follow as they are: the RIP is advanced for string I/O too. The instruction length includes the REP prefix (rep insl is the two bytes F3 6D).

The second edit is in vmrun(). The kernel must tell runxv6 where in guest memory the data goes or comes from. The CPU computes that address for us and stores it in the VMCS field “guest-linear address”. Like every VMCS field, it can only be read while the VMCS is current, so the read goes with the others, before vmclear(). Add these lines at the end of the else branch that reads the exit information, after the reads you added in Part 4 and before the brace that closes the branch:

      if(vm->idtvec & INTR_INFO_VALID){
        ...                              /* from Part 4 */
      }
      /*
       * For INS and OUTS (bit 4 of the qualification), the CPU saves the
       * linear address of the memory operand: the ES base plus RDI for
       * INS, the DS (or override) base plus RSI for OUTS (SDM 30.2.1).
       * xv6 runs in 64-bit mode, where those bases are 0, so it is RDI
       * or RSI itself.  For other exits the field is not meaningful.
       */
      if(e.reason == EXIT_IO && (e.qual & (1 << 4)))
        e.io.addr = vmread(GUEST_LINEAR_ADDRESS);
    }
    /*
     * Clear the VMCS so no CPU caches it: once interrupts are back on,
     * the timer may move this process to another CPU.  This must happen
     * before popcli(), and on every path out of the entry.
     */
    vmclear(V2P(vm->vmcs));

Why not simply use the guest’s RDI or RSI from vm->regs? In 64-bit mode the answer would be the same, but the VMCS field is the general one: it adds the segment base, it honors a segment override prefix on OUTS, and it clears bits 63:32 if the guest was not in 64-bit mode (SDM 30.2.1). It is a linear (guest-virtual) address, translated by the guest’s page tables, not a guest-physical one. That is why runxv6 will pass it to vmmem() with VMMEM_VIRT, which walks the guest’s page tables with your gva2gpa() from Part 2.

vmrun() clears e at the top of every pass, so for all other exits the four new fields are 0.


Step 2: What the REP prefix means for the hypervisor

SDM Vol. 2: the INS/INSB/INSW/INSD, OUTS/OUTSB/OUTSW/OUTSD and REP/REPE/REPZ/REPNE/REPNZ instruction pages. Vol. 1: the EFLAGS register (DF, bit 10). Vol. 3: 28.1 (fault-like exits), 28.1.3 (I/O instructions).

This is xv6’s insl(), in x86.h:

static inline void
insl(int port, void *addr, int cnt)
{
    asm volatile("cld; rep insl" :
            "=D" (addr), "=c" (cnt) :
            "d" (port), "0" (addr), "1" (cnt) :
            "memory", "cc");
}

ideintr() calls it as insl(0x1f0, b->data, BSIZE/4). So RDX = 0x1F0, RDI = b->data, and RCX = 128. cld clears the direction flag (DF), and then rep insl does this, until RCX is 0:

  1. read 4 bytes from port DX and store them at ES:RDI;
  2. RDI += 4 (it would be RDI -= 4 if DF were 1);
  3. RCX -= 1.

That is 128 iterations, 512 bytes, one sector, in one instruction. outsl() is the mirror image: cld; rep outsl loads 4 bytes from DS:RSI, writes them to port DX, and advances RSI.

Our VMCS sets “unconditional I/O exiting” (HW1), so every INS and OUTS exits. The exit is fault-like (SDM 28.1): the instruction has not executed at all, not even the first iteration. At the exit, RCX still holds the full count, 128, and RDI (or RSI) still points to the start of the buffer. (You can check this with a temporary printf of getreg(VMREG_RCX) in idestring(): it prints 128 every time.)

There are two ways to emulate it:

Doing all the iterations at once rests on four assumptions, all true for xv6:


Step 3: The dispatch, doio() (given)

SDM: Table 30-5 (the kernel has decoded it into e->io).

/*
 * Called for every EXIT_IO, after the kernel has decoded the exit
 * qualification (SDM 30.2.1, Table 30-5: size, direction, string, REP
 * and port number) into e->io and moved the guest's RIP past the
 * instruction. Choose the device by port number.
 */
static void
doio(struct vmexit *e)
{
  uint p = e->io.port;

  if(p >= COM1 && p < COM1 + 8){
    com1(e);
  } else if((p >= 0x1F0 && p < 0x1F8) || p == 0x3F6){
    ide(e);
  } else if(p == 0x3D4 || p == 0x3D5){
    crt(e);
  } else if(e->io.string){
    /*
     * A string instruction would also need memory, RCX, and RSI or RDI
     * updated; rather than fake that for a device we do not know, stop.
     */
    die("string I/O to an unknown port");
  } else if(e->io.in){
    /*
     * No device: the PICs (0x20, 0x21, 0xA0, 0xA1), which xv6 only
     * masks; the keyboard (0x60, 0x64), which never interrupts here; the
     * CMOS clock (0x70, 0x71). Reads float high.
     *
     * On a PC, a read from a port that no device answers returns all
     * ones; completein() keeps the low size bytes. An OUT to such a
     * port is simply dropped, as on a real bus.
     */
    completein(e->io.size, 0xFFFFFFFF);
  }
}

This is runvm’s doio() from HW1 grown into a small bus: each range of ports belongs to one device function. completein() is the runxv6 version of the function you wrote in HW1 Part 6.

Note the string case for unknown ports. A plain IN from nowhere is easy to fake (all ones in RAX), but a string instruction would also have to fill memory and update RCX and RDI, and no guest has a reason to do that to a port with no device. Stopping makes such a bug visible at once.


Step 4: The serial port, COM1 (given)

Reference: the PC16550D UART datasheet (National Semiconductor, now TI), for the registers and their bits; xv6’s uart.c for how they are used.

COM1 is eight byte-wide registers at ports 0x3F8 to 0x3FF. com1() models the ones xv6 uses:

Offset Read Write What runxv6 does
0 RBR, received character THR, character to send OUT: print the byte; IN: next queued key
1 IER, interrupt enable IER keeps it; bit 0 enables receive interrupts
0, 1 with LCR.DLAB = 1 baud-rate divisor divisor kept, never used
2 IIR, interrupt identification FCR, FIFO control IIR: 0x04 (data available) or 0x01 (none); FCR ignored
3 LCR, line control LCR kept (it holds DLAB)
4 MCR, modem control MCR kept
5 LSR, line status 0x60 (transmitter empty), plus bit 0 if a key is waiting
7 scratch scratch kept

The transmit side is HW1’s serial port: an OUT to offset 0 goes to our standard output, and the line status always says “transmitter empty”, so uartputc() never waits.

The receive side is new. Keys go into a 256-character queue, and the guest is interrupted:

static void
rxpush(char c)
{
  if(rxw - rxr < sizeof(rxbuf))
    rxbuf[rxw++ % sizeof(rxbuf)] = c;
  if(ier & 0x01)
    vminject(vm, VEC_COM1);
}

vminject(vm, 36) asks the kernel to deliver vector 36 (IRQ 4) at the next VM entry at which the guest can take it (Part 4). The guest’s trap() calls uartintr(), which calls consoleintr(uartgetc). uartgetc() reads the LSR, sees bit 0 (data ready), and reads the character from RBR, which removes it from the queue. It repeats until the LSR says the queue is empty, so one interrupt drains every key that has arrived. The interrupt is only raised if the guest has enabled it (IER bit 0); com1() also raises it when the guest enables it while keys are already waiting, as a 16550 would.

How does the guest’s keyboard work? It does not: the guest’s PS/2 keyboard never interrupts (IRQ 1 is never injected) and its ports are not emulated, so kbdintr() never runs. Your keys reach the guest through its serial port instead. xv6 has two input paths, and both end in consoleintr():

runxv6 gets characters from the host’s console, not scancodes. And the host xv6 itself gets your keys through its serial port: under make qemu-nox or make bochs-nox the terminal is connected to the emulated PC’s COM1. So the serial path is the natural one, with no translation in either direction. Here is the whole path of one key:

your terminal
  -> QEMU or Bochs: the host PC's COM1 receives the byte, IRQ 4
  -> host xv6: uartintr() -> consoleintr()  (raw mode: no echo, no editing)
  -> runxv6: pollconsole(): fdready(0), read(0)
  -> runxv6: rxpush(): queue it, vminject(vm, 36)
  -> kernel: vmxinject() delivers vector 36 at the next VM entry
  -> guest xv6: uartintr() -> consoleintr()  (echo, line editing)
  -> guest sh: read() returns the line

The echo you see on the screen comes from the guest: its consoleintr() echoes each key with consputc(), which goes out through the guest’s COM1 and comes back to your terminal through runxv6’s write(1, ...).


Step 5: Reading the console without stopping the guest, pollconsole() (given)

Reference: the fdready() and consraw() system calls (Part 1; sysfile.c, console.c).

/*
 * Read the keys typed on our console, if any, without blocking.
 *
 * xv6 has no non-blocking read, so fdready() asks first whether a
 * read() would return at once. The console is in raw mode (consraw), so
 * read() returns each key as soon as it is typed, without waiting for a
 * whole line.
 */
static void
pollconsole(void)
{
  char buf[64];
  int i, n;

  while(fdready(0) > 0){
    if((n = read(0, buf, sizeof(buf))) <= 0)
      return;
    for(i = 0; i < n; i++){
      /* Our own key: never passed to the guest. */
      if(buf[i] == QUITKEY){
        consraw(0);
        printf(1, "\nrunxv6: stopped (Ctrl-])\n");
        exit();
      }
      /* Everything else arrives at the guest's COM1 receiver. */
      rxpush(buf[i]);
    }
  }
}

runxv6 is one process with one thread, and that thread spends its life in vmrun(). A plain read(0, ...) would sleep until you type, and the guest would stop running while it slept. xv6 has no O_NONBLOCK, select() or poll(), so Part 1 added fdready(fd), which says whether a read() would return at once. pollconsole() reads only then.

Three more details:


Step 6: The CRT controller (given)

Reference: the Motorola 6845 CRT controller (registers 14 and 15, the cursor address); xv6’s cgaputc() in console.c.

static uchar crtindex, crtregs[256];  /* any uchar index is in range */

static void
crt(struct vmexit *e)
{
  if(e->io.port == 0x3D4){
    /* The index port: which register 0x3D5 refers to. */
    if(e->io.in)
      completein(e->io.size, crtindex);
    else
      crtindex = e->io.data;
  } else {
    /* The data port: the selected register. */
    if(e->io.in)
      completein(e->io.size, crtregs[crtindex]);
    else
      crtregs[crtindex] = e->io.data;
  }
}

Nobody looks at the guest’s screen, so why emulate any of it? Because of how cgaputc() finds the cursor. It writes 14 to port 0x3D4 and reads the high byte of the position from 0x3D5, then does the same with register 15 for the low byte. The position is 80 * row + column. Then it checks it:

  if(pos < 0 || pos > 25*80)
    panic("pos under/overflow");

With no device at 0x3D5, both reads return 0xFF, the position is 0xFFFF, and cgaputc() panics. Now look at panic(): it prints "lapicid %d: panic: " with cprintf(), which calls consputc(), which calls uartputc('l') (you see an l) and then cgaputc('l'), which reads the cursor again, gets 0xFFFF again, and panics again. Each nested panic prints one more l, until the kernel stack overflows and the guest triple-faults. The first console line goes through this path (the banner xv6... does not: uartinit() prints it with uartputc() alone), so right after the banner you see the first letter of cpu0: starting 0, then a row of lllllll, then runxv6: guest triple fault.

This really happened while the reference solution was being built, before crt() existed. The fix is the smallest device that satisfies cgaputc(): registers that read back what was written, starting at 0 (the top-left corner). The characters themselves go to 0xB8000, which is ordinary guest RAM.


Step 7: The IDE disk registers (given)

Reference: the ATA/ATAPI command set and its PIO data-in and data-out protocols (we do not cite section numbers: they differ between the ATA revisions); xv6’s ide.c.

The primary IDE channel’s registers are at 0x1F0 to 0x1F7: data (0), sector count (2), the LBA bytes (3, 4, 5), drive/head (6), and status/command (7). Port 0x3F6 is the alternate status (read) and device control (write). The comment above idecommand() in runxv6.c lists them all.

Status. Four bits matter. BSY (0x80): the drive is busy, the other bits are not valid. DRDY (0x40): ready for a command. DRQ (0x08): ready to move data through the data port. ERR (0x01): the last command failed. Our medium is memory, so the drive is never busy.

idestatus starts out as DRDY, and that is not a detail. ideinit() first spins in idewait() until the status has BSY clear and DRDY set. An absent device, reading 0xFF, has BSY set, so the guest would spin forever. Then ideinit() selects drive 1 and decides the disk is present if the status is nonzero. Our status does not depend on the drive selected, so it is.

A command. idestart() writes the sector count, the three LBA bytes and drive/head, then the command. idecommand() reassembles the LBA28 sector number (bits 7:0, 15:8 and 23:16 from ports 0x1F3 to 0x1F5, bits 27:24 from the low four bits of drive/head) and checks it:

On a real drive, READ SECTORS has a data phase and an interrupt per sector, and READ MULTIPLE per group of sectors. ideintr() reads a whole block with one insl() after one interrupt, so both are treated alike: all the sectors are ready at once. (With BSIZE = 512, xv6 always sends READ SECTORS and WRITE SECTORS anyway.)

Interrupt timing. For a read, the interrupt comes when the command is written, since the data is ready immediately. iderw() holds idelock at that moment, so the guest has interrupts off; the kernel keeps vector 46 pending and delivers it as soon as the guest sets IF again (Part 4). For a write, the interrupt comes when the last byte has arrived, in idedone():

/* n more bytes of the transfer went through the data port. */
static void
idedone(int n)
{
  idepos += n;
  if(idepos < idelen)
    return;
  /* The last byte moved: clear DRQ, as no more data is expected. */
  idestatus = IDE_DRDY;
  /*
   * A read is complete now; its interrupt came at the start. A write
   * has all its data: "write it to the medium" and raise the
   * completion interrupt. ideintr() sees B_DIRTY, skips reading, and
   * wakes the process sleeping in iderw().
   */
  if(idewriting){
    memmove(disk + idesector * 512, idebuf, idelen);
    vminject(vm, VEC_IDE);  /* the write is done */
  }
}

idepos and idelen are the position in, and the size of, the current transfer in idebuf. Every path that moves data through the data port must end with idedone(): the single inl/outl cases in ide() do, and so must your idestring(). If a write never reaches idedone(), it is never “written” and never interrupts, and the guest process that started it sleeps forever.

Writes stay in memory. idedone() writes into disk, runxv6’s copy of xv6fs.img, never into the file. Writing one block back would mean rewriting the whole image, since xv6 has no lseek(). So every run of runxv6 starts from the same clean disk; a file you create in the guest is gone after Ctrl-].


Step 8: Your turn: idestring()

SDM: 30.2.1 (the guest-linear address); 28.1 (fault-like exits). SDM Vol. 2: the INS and OUTS instruction pages and the REP prefix. The vmmem() comment in vmxapi.h for the VMMEM_* flags.

The stub in your tree is

static void
idestring(struct vmexit *e)
{
  die("idestring not written");
}

ide() calls it for every string instruction on the data port 0x1F0 (any other IDE port makes ide() stop). That is rep insl in ideintr(), reading a block, and rep outsl in idestart(), writing one.

Goal. Carry out the whole string instruction in one call, as Step 2 describes: move all the bytes between the guest’s buffer and idebuf, and leave the guest’s registers as the CPU would have left them at the end of the instruction. The kernel has already advanced RIP past it (Step 1).

The contract.

Hints.

How to check. With the stub, the guest stops at its first disk read, just after cpu0: no VMX support, with runxv6: idestring not written. With INS done but not OUTS, it reads its superblock, prints the sb: line, and then stops at its first write, which the log does at boot. With both, it boots to the shell: see the Checkpoint.


Checkpoint

This is the checkpoint for the whole homework. Build and boot the host as in HW1 (make qemu-nox on a CADE machine, or make bochs-nox), then run the guest at the host’s shell:

$ runxv6 xv6kernel xv6fs.img
runxv6: booting xv6kernel (type Ctrl-] to stop)
xv6...
cpu0: starting 0
cpu0: no VMX support
sb: size 2000 nblocks 1941 ninodes 200 nlog 30 logstart 2 inodestart 32 bmap start 58
init: starting sh
$

Everything after the first line comes from the guest. Now use it. Typing must work, with backspace, and each of these must do what it does on the host:

$ ls
$ cat README
$ echo hi > f
$ cat f
hi
$ mkdir d
$ ls d
$ stressfs
$ forktest
$ timertest
timertest: slept 50 ticks
timertest: preemption ok
$ usertests2
...
ALL TESTS PASSED

stressfs and usertests2 exercise the disk hard, writes included. timertest checks that the timer you inject in Part 4 ticks and preempts. Finally type Ctrl-]: runxv6 prints runxv6: stopped (Ctrl-]) and you are back at the host’s $ prompt, with a normal (not raw) console.

usertests1 fails in its sbrk test, which asks for about 100 MB of memory; the guest has 32 MB. That is expected. (usertests2 refuses to run twice on the same disk, but since every run of runxv6 starts from a fresh copy of xv6fs.img, you can run it again after restarting the guest.)

If something fails:

Symptom Likely cause
Stops after cpu0: no VMX support with runxv6: idestring not written Expected until Step 8 is done.
Hangs after cpu0: no VMX support, no sb: line, runxv6 busy Your HW1 return 0 for string I/O is still in vmxhandle() (Step 1): RIP never advances, and the rep insl exits forever.
Hangs after cpu0: no VMX support, and idestring() is never called Vector 46 is never delivered, so ideintr() never runs: check vmxinject() and the interrupt window (Part 4).
vmmem() fails in idestring() (your message, such as “INS to an unmapped guest address”) VMMEM_VIRT is missing (the address is then taken as guest-physical, far above 32 MB), or gva2gpa() (Part 2) is wrong.
sb: shows garbage, the guest panics while mounting, or files read back with wrong contents Wrong offset into idebuf (use idepos), count used as a byte count instead of count * size, or e->io.addr never set (Step 1, the vmrun() edit): the data goes to guest address 0.
Stops right after the sb: line, before init: starting sh The first write never completes: OUTS is not handled, or idedone() is not called after it, so there is no completion interrupt.
IDE string transfer larger than the sectors requested (or your own message for it) The count is wrong: e->io.count not taken from RCX (Step 1), or the size field misdecoded.
Prints a row of lllll and runxv6: guest triple fault The CRT ports 0x3D4/0x3D5 do not reach crt() (Step 6), so they read all ones.
Output works, but keys do nothing Vector 36 never reaches the guest: check vmxinject() (Part 4). If you changed the given code: IER bit 0 in rxpush(), or fdready() in pollconsole().
Every key appears twice, or keys arrive only after Enter The host console is not in raw mode: consraw(1) was not called, or the raw-mode code in the host’s console.c (Part 1) is missing.
After Ctrl-], the host shell does not echo consraw(0) was not called before exiting.

A note on RCX: leaving it at 128 after a REP transfer changes nothing you can see with today’s xv6, because insl() and outsl() ignore their outputs. But it is not what the CPU does, and a guest that looked at RCX afterwards (to count how much was transferred, for example) would be wrong. Leave the registers exactly as the hardware would.


Questions

Answer these in answers-hw2.md, two or three sentences each.

5.1. With our design, reading one 512-byte sector costs one VM exit for the data instead of 128. Explain why the kernel can advance RIP past a rep insl that has not executed a single iteration. What would the kernel and runxv6 have to do differently if runxv6 moved only one dword per exit?

5.2. Suppose a guest executed std before rep insl (DF = 1). What would the CPU do with RDI, and which bytes of memory would it fill? What would our idestring() do instead? Sketch the change that would handle it (which register tells you DF, and how would the address and the RDI update change?).

5.3. The guest’s keys arrive as serial input, never as PS/2 keyboard interrupts. Why is the serial port the natural choice here? What would runxv6 need in order to emulate the keyboard instead (which ports, which vector, and what would it have to do to each character)?

5.4. Nobody displays the guest’s VGA memory at 0xB8000, and yet the guest’s output is complete. Why? And why, even so, do the CRT controller’s two ports have to be emulated?

5.5. struct vmexit has no field that names a device: there is nothing like “disk read” or “serial write”. Where does the knowledge that port 0x1F0 is a disk live, and why is it better there than in the kernel? Name the matching pieces in KVM and QEMU.


What to hand in

Upload these files to Gradescope:

Submitting on Gradescope

Upload exactly four files: vmx.c, vmxasm.S, runxv6.c and answers-hw2.md. Do not upload the rest of your tree. The autograder uses only vmx.c, vmxasm.S and runxv6.c, and the hw2-assign version of every other file, so any change you make to another file (a header, the Makefile, the file system, and so on) is not part of your submission. Your code must work with all the other files unchanged.

Submit the files from your tree after you merged hw2-assign. A vmx.c from HW1 alone lacks the functions the HW2 starter code calls (vmmem, vminject, …), so it does not build, and every test fails.

The autograder builds your code and runs it under Bochs, the same way you test it: the host boots, runs runvm helloguest, then runxv6, and the guest runs a small CPUID check, ls, stressfs, timertest and usertests2. As in HW1, it grades each part on its own: for Part N, it takes only Part N’s functions from you and the reference solution for everything else, so a mistake in one part does not cost you the points of the others. Then it runs all of your code together, which is where the HW2 changes inside your HW1 functions (vmcsinit, vmcreate, vmxhandle, vmrun) are tested. It also checks that your tree builds with -Werror.

Grading takes longer than in HW1, since every test boots two kernels, one inside the other, in an emulator: expect up to half an hour. A test whose guest hangs (for example, waiting for an interrupt that never comes) stops after five minutes without output, so a broken submission can take longer. You see the result of every test when grading finishes, with the output of the failing guest program and the end of the console, and you can resubmit as often as you like before the deadline. Test under QEMU or Bochs first rather than using the autograder as your test loop. Your answers-hw2.md is graded by hand.

Before you submit, check that a clean tree builds with -Werror, that runvm helloguest still works (HW1), and that in a fresh runxv6:

$ runxv6 xv6kernel xv6fs.img
...
init: starting sh
$ timertest
timertest: slept 50 ticks
timertest: preemption ok
$ usertests2
...
ALL TESTS PASSED
$ stressfs
...

and that Ctrl-] brings you back to the host’s shell.

Where this goes next

The guest has one CPU and no real interrupt controller: its local APIC and I/O APIC are memory pages, interrupts arrive by injection, and its timer ticks at the host’s rate. A natural next step is a virtual local APIC: either by trapping the guest’s accesses to its page and emulating the registers, or with VT-x’s APIC virtualization, where the CPU handles most of the work (SDM chapter 32). With a real local APIC, the guest can start a second CPU, which means a second VMCS and a second vmrun loop, one per virtual CPU. Other directions: keeping the VMCS loaded across exits and using VMRESUME (as KVM does), VPID to keep the guest’s TLB entries, and making the guest’s disk writes persistent.