References

Introduction

Review the Submission section and Milestones and Testing Strategy first so you know what you have to create, and in what order it is sane to build it.

There are two levels of host systems that drive these devices. The first layer is the PCI layer, and the second is the VirtIO layer. Each of these devices is a virtual device using the VirtIO protocol. This is why we drove the UART and RTC in the last lab, and we will drive all of the VirtIO devices in this lab.

The initialization of each device will be done in handoff. The handoff starts with PCI to VirtIO and then VirtIO to the underlying device.

Finally, you will log all of the devices under the global kernel data device registry. Since you have a heap, you can use the alloc library.

Anatomy of a Request

This lab has a lot of moving pieces, and it is very easy to lose the forest for the trees. So before we look at a single structure, let’s trace one entropy request from the moment your kernel boots to the moment a random byte lands in a buffer. Every device in this lab is a variation on this one path.

pci virtio handoff

Discovery.

  1. ACPI gives you the MCFG table, which gives you the ECAM physical base address and the start/end bus numbers.

  2. You loop over every (bus, slot) in that range and compute an ECAM offset. At each offset you read a VendorId. If it is 0xFFFF, nothing is there.

  3. When VendorId == 0x1AF4, you have a VirtIO device. The DeviceId tells you which one.

Configuration.

  1. You walk the device’s capability list. Two kinds of capability matter: MSI-X (0x11), which tells you how this device will interrupt you, and vendor-specific (0x09), which is how VirtIO advertises itself.

  2. Each VirtIO capability says "my data lives in BAR n at offset o." So you need get_bar(n) to turn a BAR index into an MMIO address.

  3. Through the common configuration you reset the device, negotiate features, and set up queues.

Queue setup.

  1. You allocate three regions of memory: a descriptor table, a driver (available) ring, and a device (used) ring.

  2. You write the physical addresses of those three regions into the device’s queue registers. There is no IOMMU. The device does not know what a virtual address is.

Interrupt wiring.

  1. You pick an EIID (this lab: 100-110), write the target hart’s IMSIC interrupt file physical address and that EIID into the device’s MSI-X table entry, and clear the per-vector mask.

  2. You register that same EIID in your kernel’s IRQ registry so that when it arrives, you know which driver to call.

  3. You set DriverOk. The device is now live.

The request.

  1. You take a descriptor, put the physical address and length of an 8-byte buffer in it, and mark it device-writable.

  2. You publish that descriptor’s index into the driver ring and bump the driver ring index.

  3. You fence, then write to the device’s notify register.

The response.

  1. The device DMAs random bytes directly into your buffer.

  2. The device performs a memory write to the MSI-X address you gave it. That address is a hart’s IMSIC interrupt file, so this write is the interrupt.

  3. sip.SEIP goes high, the hart traps to stvec, and you land in the trap handler you wrote in the last lab.

  4. Your handler claims from stopei, which pops the EIID.

  5. You look up the EIID in the IRQ registry and dispatch to the entropy driver.

  6. The driver drains the device ring, finds the descriptor it submitted, reclaims it, and matches it against a wait queue entry.

  7. The wait queue entry tells you who was waiting for those bytes.

Steps 17 through 19 are code you already wrote in the Device Platform lab. That is the whole point of having done that lab first. Everything new in this lab sits in steps 1 through 16 and 20 through 21.

The block, input, and GPU devices differ from entropy only in what the descriptors point at and how many are chained together. The discovery, configuration, queue setup, interrupt wiring, notify, and completion paths are identical for all four. If you can do entropy, you have already done most of all four. This is why entropy is the reference device in this write-up, and why you should build it first.

Milestones and Testing Strategy

Standing up a PCI device driver has a nasty property: nothing works until almost everything works, and when the screen stays blank you have no idea which of fifteen things is wrong.

The way out is to order your work so that exactly one thing is new at each step, and so that each step is independently verifiable. Two of these milestones do not involve a VirtIO device at all. They exist purely to prove your interrupt path is sound before you make it carry real traffic.

# Milestone How you verify it What is new

0

Enumerate every (bus, slot); print vendor, device, class, header type

Compare against QEMU monitor info pci

ECAM address math only

1

Walk capabilities; decode BARs; print each VirtIO capability’s type/BAR/offset

Print-only. BAR addresses should match info pci

No DMA, no interrupts

2

Map a hart’s IMSIC interrupt file and write a u32 EIID into it yourself

Your existing trap handler fires

Proves the MSI path with zero devices involved

3

Write an EIID to the APLIC genmsi register

Handler fires

Proves APLIC → IMSIC

4

Entropy device, polled: spin on the device ring index

Buffer contains non-zero bytes that change each boot

Virtqueue correctness, no interrupts

5

Entropy device, MSI-X: delete the poll loop

Same bytes, now delivered from an IRQ handler

Only MSI-X is new

6

Block device: read sector 0

hexdump -C hdd.dsk matches your buffer

Descriptor chaining

7

Block device: write, then re-read

Host file contents change

Direction / Write flag semantics

8

Input: keyboard and tablet events

Key presses and coordinates print

Pre-posted queue, reposting

9

GPU: solid color, then text

The QEMU window

Multi-entry backing, transfer + flush

These milestones are advisory. I am not collecting them and they are not graded. They exist because this is the order in which the lab is debuggable.

Milestones 2 and 3 are worth the half hour they cost. Recall from the last lab that an MSI is nothing but a memory write. If you map the interrupt file into virtual memory and write an EIID into it, you should take an external interrupt. If milestone 2 works and milestone 5 does not, the bug is in your MSI-X table setup and nowhere else. If milestone 4 works and milestone 5 does not, same conclusion. That is a very small haystack.

Suggested Pacing

You have over a month. A workable division, entirely advisory:

Week Milestones Notes

1

0 - 3

PCI enumeration and proving the interrupt path

2

4 - 5

Entropy, polled then interrupt-driven

3

6 - 7

Block read and write, registries, wait queues

4

8 - 9

Input and GPU

The next lab depends on the block driver, the device and IRQ registries, and the wait queues. It does not depend on the GPU or input drivers. If you mismanage the month, you want to be losing the fun points at the end, not the load-bearing ones in the middle. Build in this order.

Debugging Tools

Learn these now. They will save you more time than any amount of debugln!.

Tool What it gets you

info pci (QEMU monitor)

Every device, its BARs, and its IRQ routing. Your enumeration output should match this exactly.

info register <hartid>

sip, sie, sstatus, stvec. Is the interrupt even pending?

-d guest_errors

QEMU tells you when you did something wrong: illegal VirtIO status transitions, malformed descriptors, out-of-range queue indices. Turn this on and leave it on.

-trace enable=virtio*

Every VirtIO transaction QEMU processes. Verbose, but definitive.

xp/8xb <paddr>

Examine physical memory from the monitor. Use it to read back a descriptor table and confirm the device sees what you think you wrote.

-d guest_errors will catch the status-bit ordering mistake described in VirtIO Initialization (Virtio § 3.1.1) immediately, instead of after two days.

Conventions Used In This Write-Up

Listing Conventions

Every code listing in this lab is labeled one of two ways.

Design Sketch

Shows the shape of a solution and the contract it has to satisfy. It may not compile. It may be missing bodies. It may contain a design decision I would like you to disagree with. You are expected to redesign these.

Working Example

Copy-paste viable. Usually something that is tedious but not instructive, or something with a subtle calculation I do not want you to lose a day to.

If a listing has no label, treat it as a Design Sketch.

You have design freedom in this lab. Where I show a structure, I am showing you one way to satisfy a constraint imposed by the hardware, not the only way. The constraints that are not negotiable are the ones the device imposes: #[repr(C)] layouts that the device reads or writes, physical addresses, alignments, and orderings. Everything that lives purely on your side of the DMA boundary is yours to design.

Naming Conventions

The VirtIO and PCI specifications are written in C and use snake_case with long prefixes: virtio_gpu_resource_create_2d, virtio_pci_common_cfg. We are writing Rust. We will use PascalCase, and we will let the module path carry the context that the C prefix was carrying.

Specification name Ours

virtio_pci_common_cfg

virtio::CommonCfg

virtio_pci_cap

virtio::Capability

virtio_gpu_ctrl_hdr

gpu::CtrlHeader

virtio_gpu_resource_create_2d

gpu::ResourceCreate2d

virtio_gpu_mem_entry

gpu::MemEntry

virtio_gpu_rect

gpu::Rect

virtio_blk_req

block::RequestHeader

virtio_blk_config

block::Config

virtio_input_event

input::Event

virtio_input_absinfo

input::AbsInfo

At the use site, gpu::CtrlHeader reads better than VirtioGpuCtrlHdr, and if you have use crate::gpu; it is just gpu::CtrlHeader.

So that you can still cross-reference the specification in both directions, adopt this convention: every structure defined by a specification carries its specification name and section in a doc comment.

Working Example
/// Spec: `virtio_gpu_ctrl_hdr` (Virtio v1.3 § 5.7.6.8)
///
/// Common header on every GPU control-queue command *and* on every
/// response the device writes back.
#[repr(C)]
pub struct CtrlHeader {
    // ...
}

Now grep virtio_gpu_ctrl_hdr finds your Rust, and reading your Rust tells you what to look up in the PDF.

Peripheral Component Interconnect (PCI)

Table 1. Read This
Specification What you need from it

PCI § 6.1

Configuration space header layout, type 0 vs type 1

PCI § 6.2.5.1

Base Address Register decoding

PCI § 6.2.2

Command register bit assignments

PCI § 6.2.3

Status register bit assignments

PCI § 6.7

Capabilities list structure and traversal

PCI § 6.8.2

MSI-X capability, table, and PBA

Your main device bus is PCI, which has a comprehensive configuration and access mechanism called the "enhanced configuration access mechanism" or ECAM. It was enhanced for PCI express.

PCI-X is PCI "extended" and is not PCI express. PCI express is shortened to PCIe. PCI-X is just an enhancement over the original PCI using the same basic parallel architecture. PCI express is a serial, point-to-point architecture.

The starting point is to find the ECAM, which comes from ACPI from the MCFG table. This tells you the ECAM physical address as well as the starting and ending bus numbers. These will be important when we loop through the busses to find devices we can drive.

PCI Address Structure

The PCI physical memory addresses are broken down as follows.

Address Configuration Space

A[(20+n-1):20]

Bus number (n = \$\log_{2}(\text{end bus number}+1)=[1..8\$])

A[19:15]

Device number

A[14:12]

Function number

A[11:8]

Extended register number

A[7:2]

Register number

A[1:0]

Byte enables

The bus numbers are given to you in the MCFG ACPI table. Do not exceed the end bus number. Recall that it is inclusive in the MCFG table.

The PCI ECAM address can be a 32-bit or 64-bit address. Only the lower 28 bits contribute to the bus, device, function, and register numbers. The upper bits locate the ECAM MMIO space.

Example of 0x3076b000
31 28 27      20  19   15 14     12  11         8  7       2 1 0
0011  [00000111]  [01101] [  011  ]  [   0000   ]  [0000 00] 0 0
      [ Bus #  ]  [Dev #] [Func # ]  [Ext Reg # ]  [ Reg # ]

Working outward from the bit positions: a function occupies \$2^{12}\$ = 4,096 bytes of configuration space, a device (all eight of its possible functions) occupies \$2^{15}\$ = 32,768 bytes, and a bus occupies \$2^{20}\$ = 1,048,576 bytes (1 MiB).

Working Example
/// Calculate the ECAM offset given a bus and slot number
const fn ecam_offset(bus: u8, slot: u8) -> usize {
    let bus = bus as usize;
    let slot = slot as usize;
    debug_assert!(slot >> 5 == 0, "Slot (device) is more than 5 bits");
    // We don't need to assert bus since it's 8 bits to start with.
    bus << 20 | slot << 15
}
Every device in our machine is single-function, so we ignore the function field. If you wanted to support multi-function devices you would add func << 12.

ECAM Structure (PCI § 6.1)

The ECAM device index is encoded in the MMIO address with the bus and slot encoded to find the top of the ECAM for that device.

There are three things you are looking for at the very beginning. First, look for the VendorId. If this is 0xFFFF, then it is telling you that there is nothing connected here. Otherwise, a VirtIO device will have a vendor id of 0x1AF4. The device id will be the particular VirtIO device. Device IDs are encoded starting with 0x1040 plus the VirtIO device number.

You are also looking at the header type. A type 0 header is a device whereas type 1 is a bridge. Luckily, EDK2 has already set up all of the bridges for us, so we only care about type 0 devices.

ECAM Header

pci ecam header

You can see above the "Header Type" field, which is either 0 (device) or 1 (bridge) as well as the 16-bit Device ID and Vendor ID fields.

ECAM in Rust

Rust does not like unions (even though it still has them) as well as structures without a specific size. We will write methods to locate data instead and do type conversions. We will see that we will need to do this again in VirtIO.

Just like a lot of the ACPI tables, we can wrap the ECAM in a structure. The first thing we need to check is the header type, since type 0 has different fields than type 1. However, both types are the same size since the ECAM address is packed with the bus, slot, and register numbers.

The first 16 bytes of the ECAM give us all of the information we need for: (1) determining whether a device is actually connected (check VendorId == 0xFFFF) and (2) the layout of the rest of the structure (based on the header type).

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/// Spec: PCI § 6.1 -- the first 16 bytes, common to type 0 and type 1.
#[repr(C)]
pub struct CommonEcam {
    pub vendor_id: u16,
    pub device_id: u16,
    pub command: u16,
    pub status: u16,
    pub revision_id: u8,
    pub class_code: [u8; 3],
    pub cache_line_size: u8,
    pub latency_timer: u8,
    pub header_type: u8,
    pub bist: u8, // built-in self test
}
impl CommonEcam {
    /// Wrap a PCI ECAM base (from ACPI) at the given bus and slot
    pub fn new(base: VirtualAddress, bus: u8, slot: u8) -> &'static mut Self {
        let ptr = base.add(ecam_offset(bus, slot)).as_mut_ptr();
        unsafe { ptr.as_mut_unchecked() }
    }

    pub fn is_present(&self) -> bool {
        self.vendor_id != 0xFFFF
    }

    pub fn is_device(&self) -> bool {
        // Bit 7 is the multi-function flag; the type is bits [6:0].
        (self.header_type & 0x7F) == 0
    }
}

Type 0 Header and Base Address Registers (PCI § 6.2.5.1)

Once you know you have a type 0 header, the rest of the first 64 bytes becomes meaningful. The fields you need:

Offset Size Field

0x10 - 0x27

6 x u32

BAR[0..6] — Base Address Registers

0x2C

u32

Subsystem vendor / subsystem id

0x34

u8

Capabilities pointer (byte offset into this device’s config space)

0x3C

u8

Interrupt line (legacy; we will not use it)

0x3D

u8

Interrupt pin (legacy; we will not use it)

A BAR is not just an address. Its low bits are flags, and you must mask them off before you use it.

  • Bit 0: 0 = memory space, 1 = I/O space. All of ours are memory space.

  • Bits [2:1]: 0b00 = 32-bit BAR, 0b10 = 64-bit BAR.

  • Bit 3: prefetchable.

  • The address is the remaining upper bits, so mask off the low 4 bits.

A 64-bit BAR consumes two consecutive BAR slots. BAR[n] holds the low 32 bits and BAR[n+1] holds the high 32 bits, and BAR[n+1] is not a BAR in its own right. If you iterate BAR indices naively you will read a garbage address out of the high half of a 64-bit BAR.

Your get_bar(x) method takes a BAR index and returns the MMIO address that index refers to, doing the masking and the 64-bit combination for you. Every VirtIO capability you read will hand you a BAR index and an offset, so this method gets called constantly. Get it right early.

Remember that the BAR addresses are physical addresses. You will need to convert them to virtual addresses by adding (not ORing) the KERNEL_IO_ADDR (0xffff_beef_0000_0000). If you OR the address, it will not get enough bits. Lastly, make sure you map using your SV48 page table.

EDK2 has already assigned and programmed the BARs for us, so you only need to read them.

Enabling the Device (PCI § 6.2.2)

A PCI device comes out of reset largely inert. Before it will respond to MMIO reads or perform DMA, you must set bits in its command register (byte offset 0x04 in config space).

Bit Name Why you need it

1

Memory Space Enable

Without this, MMIO reads of the device’s BAR space return all `1`s and writes are dropped.

2

Bus Master Enable

Without this, the device cannot perform DMA. Every request you submit will be silently ignored.

10

Interrupt Disable

Set this to disable legacy interrupt delivery, so MSI-X is used instead.

Bus Master Enable is the single most common reason a student’s first VirtIO request appears to do nothing at all. The device accepts your configuration, reports good status, takes your notify write, and then never touches your buffer, because it is not permitted to be a bus master. There is no error anywhere. Set bit 2.

Capabilities (PCI § 6.7)

Capabilities are device-specific, but they are written in PCI ECAM space. This is a linked list with a capability ID and a next pointer. The capability ID also determines the size of the capability.

A device only has capabilities if bit 4 of the status register is 1. Otherwise, there are no capabilities, and reading these fields is an error.

The status register is at byte offset 0x06 in configuration space, and the command register is at byte offset 0x04. These are offsets, not bit positions.
Design Sketch
1
2
3
4
5
6
7
8
9
/// Spec: PCI § 6.7 -- the first two bytes of every capability.
///
/// Capabilities extend beyond this, but `id` and `next` are always
/// the first two fields, which is enough to walk the list.
#[repr(C)]
pub struct Capability {
    pub id: u8,   // Capability identifier
    pub next: u8, // Byte offset (from config space base) of the next capability
}

The capabilities are where we will find the configuration space for the VirtIO devices. The id gives you the structure of the capability itself; the Capability structure above is just the common first two fields. The next field is a byte offset from the start of configuration space to find the next capability. It is not a memory pointer (it is only 8 bits), but you can think of it as a next pointer in a linked list. A next of 0 terminates the list.

Walking the List

The traversal itself is four lines, but there are three ways to get it wrong:

  1. The head of the list is at config space offset 0x34, and is a byte offset, not a pointer.

  2. The low two bits of the capabilities pointer and of every next field are reserved. Mask them off (& 0xFC) before dereferencing.

  3. Nothing in the specification guarantees the list is acyclic, sorted, or in ascending order. A malformed list will hang your kernel in an infinite loop.

A device’s configuration space is 4,096 bytes and each capability is at least 2 bytes, so there cannot be more than a couple of hundred capabilities. Cap your iteration count and debugln! if you hit the cap. You are writing a kernel; an infinite loop here is a hang with no diagnostic.

You will walk this list for two reasons: to find the MSI-X capability (0x11), and to find every vendor-specific capability (0x09). A single VirtIO device has several vendor-specific capabilities, so do not stop at the first one.

MSI-X Capability (PCI § 6.8.2)

All responses from your PCI devices will be signaled using MSI-X. This is the bridge between this lab and the Device Platform lab: an MSI-X interrupt is a memory write, and the address it writes to is a hart’s IMSIC interrupt file.

The MSI-X capability’s ID is 0x11. The older MSI is 0x05. They are not the same and are not configured the same way. We want MSI-X.

The capability itself is 12 bytes: the common id and next, a 16-bit message control register, and two 32-bit words that locate the table and the pending bit array (PBA).

You need four things out of it, all of which are described in PCI § 6.8.2.1 through § 6.8.2.6. Go read those pages; here is what you are looking for:

  1. Table size. The low 11 bits of message control, plus one. A field that encodes "N-1" is a classic specification trap.

  2. Which BAR the table lives in. The low 3 bits of the table offset word are the BIR (BAR Indicator Register) which is an index you feed to get_bar().

  3. Where in that BAR. The rest of the table offset word, with the low 3 bits masked off, is a byte offset into that BAR.

  4. The same two things for the PBA, from the second word. We will not need the PBA for this lab, but you should know what it is.

The MSI-X Table

Each table entry is 16 bytes: a 64-bit message address (as two 32-bit halves), a 32-bit message data, and a 32-bit vector control.

For our machine, the mapping to what you built in the last lab is direct:

  • Message address = the physical address of the target hart’s IMSIC interrupt file. Recall that this is imsic_base + (hartid << 12).

  • Message data = the EIID you want delivered. This is the value your claim() will pop out of stopei.

  • Vector control bit 0 is the per-vector mask.

Working Example
/// # Compute the MSI-X message address for a target hart.
///
/// An MSI is just a memory write. The address we hand the device is the
/// *physical* address of that hart's IMSIC interrupt file, and the data we
/// hand it is the EIID. When the device writes, the IMSIC raises SEIP on
/// that hart, and we land in the trap handler from the previous lab.
///
/// Each hart's interrupt file is exactly one page from the next.
const fn msix_message_address(imsic_base_paddr: usize, hartid: usize) -> u64 {
    (imsic_base_paddr + (hartid << 12)) as u64
}
Every vector in the MSI-X table is masked at reset (PCI § 6.8.2.9). If you configure the address and data correctly, enable MSI-X, set DriverOk, submit a request, and receive nothing, this is very likely why. You must clear bit 0 of vector_ctrl for each vector you intend to use.

Bringing MSI-X Up

MSI-X is initially disabled at boot time. Enabling it is, in order:

  1. Set Interrupt Disable (command register bit 10) so legacy delivery is off.

  2. Program the table entry: message address, message data, and clear the vector mask.

  3. In message control, clear the function mask (bit 14) and set MSI-X enable (bit 15).

Read back message control after you write it. If MSI-X enable does not stick, you are probably writing to the wrong offset in config space.

msix to imsic

MSI-X writes go directly to the IMSIC interrupt file. They do not pass through the APLIC. There is no sourcecfg and no target register to configure for a PCI device. You do, however, still have to enable the EIID in the IMSIC’s eieX registers, and you still need sie.SEIE and sstatus.SIE set. The APLIC is simply not in this path.

Virtual IO (VirtIO)

Table 2. Read This
Specification What you need from it

Virtio § 2.1

Device status field and the bits you write into it

Virtio § 2.2

Feature bits and how negotiation works

Virtio § 2.6

Split virtqueues: the three regions and their alignments

Virtio § 2.7.5

Descriptor table layout and flag semantics

Virtio § 2.7.6

Available (driver) ring

Virtio § 2.7.8

Used (device) ring

Virtio § 2.7.13

The exact procedure for supplying buffers to a device

Virtio § 3.1.1

Device initialization sequence

Virtio § 4.1.4

VirtIO-over-PCI capability layout, all five types

Virtio § 6

Reserved feature bits, including VIRTIO_F_VERSION_1

VirtIO devices are paravirtualized devices. Since paravirtualized devices know they are virtual, they are easier to configure, and they are more efficient to drive. All VirtIO devices have the same request/response system, but there are subtle differences between busses. Since all of our VirtIO devices will be connected via PCI, we will use the PCI bus for discovery and configuration.

VirtIO Capabilities (Virtio § 4.1.4)

If the capability id is 0x09, then this is a vendor specific capability. There are five defined vendor-specific capabilities for VirtIO. When you know you have a VirtIO device (VendorId == 0x1AF4) and a vendor-specific capability (id == 0x09), then the capability takes the following structure, including the id and next pointer.

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/// Spec: `virtio_pci_cap` (Virtio § 4.1.4)
#[repr(C)]
pub struct Capability {
    pub id: u8,           // PCI_CAP_ID_VNDR (0x09)
    pub next: u8,         // Same as the PCI next pointer
    pub cap_length: u8,   // Length of this capability, including id and next
    pub cfg_type: u8,     // VirtIO configuration type (1 through 5)
    pub bar: u8,          // BAR *index*. Feed this to get_bar().
    pub padding: [u8; 3], // Pad to a full dword
    pub offset: u32,      // Byte offset within that BAR
    pub length: u32,      // Length of the structure at that offset
}

The configuration type may be one of the following.

Working Example
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/// Common configuration for all VirtIO devices
pub const COMMON_CFG: u8 = 1;
/// Notification configuration
pub const NOTIFY_CFG: u8 = 2;
/// Interrupt service routine (ISR) configuration
pub const ISR_CFG: u8 = 3;
/// Device-specific configuration (different for every VirtIO device)
pub const DEVICE_CFG: u8 = 4;
/// Alternate PCI configuration access
pub const PCI_CFG: u8 = 5;

A single device will advertise several of these. You will need all of types 1 through 4. Store the resolved address (get_bar(cap.bar) + cap.offset) for each type as you walk the list.

Common Configuration (cfg_type = 1)

This is the control panel for the device: feature negotiation, status, and queue setup. It is described in Virtio § 4.1.4.3, and you should read that section rather than take my word for the field order.

The fields, in order, are: two feature-select/feature-value pairs (device then driver, each u32), a u16 config MSI-X vector, a u16 number of queues, a u8 device status, a u8 config generation, then the per-queue window: queue_select, queue_size, queue_msix_vector, queue_enable, queue_notify_offset (all u16), then three u64 physical addresses for the descriptor table, driver ring, and device ring. Newer fields follow for optional features we are not using.

The per-queue fields work exactly like the RISC-V indirect CSR access you wrote for the IMSIC: you write a queue number into queue_select, and then every subsequent per-queue field refers to that queue.

0xFFFF written into an MSI-X vector field means "no vector," that is, do not interrupt for this queue. That is the reset value, so you must write your actual vector index there or the queue will never interrupt you.
This entire structure is MMIO. Every access to it must be read_volatile or write_volatile. See Sidebar: Memory Ordering Without an IOMMU.

Notification (cfg_type = 2)

The notification structure is a combination of the capability and the BAR. The capability extends the common VirtIO capability by one field:

Design Sketch
1
2
3
4
5
6
7
8
/// Spec: `virtio_pci_notify_cap` (Virtio § 4.1.4.4)
///
/// This is a *capability*, not the register in the BAR.
#[repr(C)]
pub struct NotifyCapability {
    pub cap: Capability,
    pub notify_offset_multiplier: u32,
}

The common VirtIO capability comes first, and then the notify_offset_multiplier. This is the value you multiply the queue’s notify offset by in order to locate the notification register for that queue.

Working Example
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/// # Byte offset within the notify BAR for a given queue.
///
/// ## Arguments
///
/// * `offset` -- `offset` from the notify `Capability`.
/// * `queue_notify_offset` -- `queue_notify_offset` read from `CommonCfg`
///   *after* selecting the queue.
/// * `multiplier` -- `notify_offset_multiplier` from `NotifyCapability`.
pub const fn notify_offset(offset: u32, queue_notify_offset: u32, multiplier: u32) -> usize {
    offset as usize + queue_notify_offset as usize * multiplier as usize
}

You then add that to the BAR address given by the notify capability’s bar field. Writing the queue index to the resulting address is what tells the device to go look at your available ring.

If the multiplier is 0, every queue shares one notify register, and you write the queue index into it. If it is nonzero, each queue has its own register. Both are legal. Handle both.

Interrupt Service Routine (cfg_type = 3)

A single u32. Bit 0 means "a queue has activity," bit 1 means "the device configuration changed," and the upper 30 bits are reserved.

We are using MSI-X, so we do not strictly need this. It is useful for two things: confirming during bring-up that this device really is the one that interrupted you, and detecting configuration changes (for example, the GPU display being resized).

Reading this register clears it. It is a read-to-clear register. Do not read it twice and expect the same answer.

Device Specific (cfg_type = 4)

Each device ID has a different device-specific structure, and this is how you query and control the specific device itself. The block device’s capacity, the input device’s event bitmaps, and the GPU’s scanout count all live here. Each is documented in the per-device sections of the specification.

PCI Configuration (cfg_type = 5)

We will not need this configuration type because we can get everything we need from the device itself. It is just a mirror of the PCI configuration space (ECAM), for environments that cannot map BARs directly.

Device Status and Feature Bits

Two small sets of constants that you will use constantly, and which I would rather you did not mistype.

Working Example
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Spec: Virtio § 2.1 -- device status bits.
pub const STATUS_RESET: u8       = 0;
pub const STATUS_ACKNOWLEDGE: u8 = 1 << 0;
pub const STATUS_DRIVER: u8      = 1 << 1;
pub const STATUS_DRIVER_OK: u8   = 1 << 2;
pub const STATUS_FEATURES_OK: u8 = 1 << 3;
pub const STATUS_NEEDS_RESET: u8 = 1 << 6;
pub const STATUS_FAILED: u8      = 1 << 7;

// Spec: Virtio § 6 -- reserved feature bits. Note these are *bit numbers*,
// and they are above 31, so they live in feature word 1.
pub const F_INDIRECT_DESC: u32   = 28;
pub const F_EVENT_IDX: u32       = 29;
pub const F_VERSION_1: u32       = 32;
pub const F_ACCESS_PLATFORM: u32 = 33;
pub const F_RING_PACKED: u32     = 34;
pub const F_IN_ORDER: u32        = 35;

Negotiating Features

Feature bits are read and written through a 32-bit window. You write a window index into device_feature_select and then read device_feature to get bits \$32 \times \text{index}\$ through \$32 \times \text{index} + 31\$. The driver side works the same way with driver_feature_select and driver_feature.

So reading the full 64-bit feature set is two reads, and writing your accepted subset is two writes. Feature bit 32 lives in window 1, bit 0.

Three feature decisions matter for this lab:

  • VIRTIO_F_VERSION_1 (bit 32) must be accepted. This is what tells the device you are a modern (non-legacy) driver. If you do not set it, the device may operate in legacy mode with a different queue layout and different endianness rules, and nothing will work the way this write-up describes. This bit is in the second feature window, which is exactly why students who only negotiate window 0 have a bad week.

  • Do not accept VIRTIO_F_RING_PACKED (bit 34). We are implementing split virtqueues. A packed ring is an entirely different layout.

  • Do not accept VIRTIO_F_ACCESS_PLATFORM (bit 33). We have no IOMMU, and we are handing the device raw physical addresses.

You must only ever accept a subset of what the device offered. Offering a feature the device did not advertise is a specification violation, and FEATURES_OK will not stick when you check it. The correct operation is a bitwise AND of what the device offers with what you understand, and then optionally clearing bits you understand but do not want.

VirtIO Initialization (Virtio § 3.1.1)

All VirtIO devices are initialized as follows.

  1. Reset the device by writing 0 into the device status field.

  2. Set the ACKNOWLEDGE status bit. This tells the device you noticed it.

  3. Set the DRIVER status bit, if it is a device you can drive.

  4. Read the device feature bits, and write the subset you understand back into the driver feature bits.

    1. During this step you may read (but must not write) the device-specific configuration to decide whether you can support the device at all.

  5. Set the FEATURES_OK status bit. You must not accept new features after this point.

  6. Re-read device status and confirm FEATURES_OK is still set.

    1. If it is not, the device rejected your feature selection and is unusable as configured.

  7. Perform device-specific setup: discover and configure the virtqueues, read device-specific configuration, pre-post buffers if the device needs them.

  8. Set the DRIVER_OK status bit. The device is now live.

The bit values are not written in ascending order. You write 1, then 2, then 8, then finally 4. The bit numbers (0, 1, 3, 2) are what is out of order, because FEATURES_OK was added to the specification after DRIVER_OK. Writing 4 before 8 is one of the errors -d guest_errors will catch for you.
Step 6 is not optional and not decorative. It is the only feedback the device gives you about feature negotiation. Check it, and debugln! loudly if it fails.

Virtual Queues (Virtio § 2.6)

A virtqueue is memory used to make device requests and receive device responses. The size of the queue is configured in software, and the queue size controls the size of all three regions.

All addresses you put in a descriptor, or in the queue registers, are physical. We have no IOMMU. The device has no idea what your virtual addresses mean. Use translate() from your MMU code.

A split virtqueue has three parts: a descriptor table, a driver (available) ring, and a device (used) ring. A descriptor is the fundamental unit of a request. The driver ring is where you publish descriptor indices to make requests. The device ring is where the device publishes completions.

Part

Alignment

Size

Descriptor table

16

\$16\times\text{Queue Size}\$

Driver ring

2

\$6 + 2\times\text{Queue Size}\$

Device ring

4

\$6 + 8\times\text{Queue Size}\$

The specification uses two names for each ring. The driver ring is also called the available ring, and the device ring is also called the used ring, for available buffers and used buffers respectively. I will use both names interchangeably, and so does the specification, so get comfortable with it.
  1. Each descriptor is exactly 16 bytes, and there are queue size of them.

  2. The driver ring has three u16 fields and then a ring of u16 elements, queue size of them.

  3. The device ring has three u16-worth of header and then a ring of 8-byte elements, queue size of them.

Rust does not like runtime-sized data, so you will need methods that locate the ring arrays rather than structures that contain them. You will notice below that I declare the fixed header fields and leave the variable-length tail as a comment.

For PCI devices, the queue size is configured through the common configuration structure (cfg_type 1). You write your desired size into queue_size after reading the device’s maximum.

Graphical View

virtqueue layout

Descriptors (Virtio § 2.7.5)

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/// Spec: `virtq_desc` (Virtio § 2.7.5)
#[repr(C)]
pub struct Descriptor {
    /// *Physical* address of the buffer.
    pub address: u64,
    /// Length of the buffer in bytes.
    pub length: u32,
    /// Flag bits. See the DESC_* constant (this is a bitfield, not an enum)
    pub flags: u16,
    /// Index of the next descriptor in the chain, if DESC_NEXT is set.
    pub next: u16,
}

// Spec: Virtio § 2.7.5 -- descriptor flags. These are *bits*, and they
// combine, which is precisely why `flags` above is a u16 and not an enum.
pub const DESC_NEXT: u16     = 1 << 0;
pub const DESC_WRITE: u16    = 1 << 1;
pub const DESC_INDIRECT: u16 = 1 << 2;

The DESC_WRITE flag sets a direction for the DMA transfer. If you set it, the buffer is device-write-only. This is important to internalize, because it looks like it is merely granting the device permission to write.

DESC_WRITE is a direction, not a permission. If DESC_WRITE is set, the device may only write to that buffer. If it is clear, the device may only read from it. A device read from a DESC_WRITE buffer fails. A device write to a non-DESC_WRITE buffer fails. Getting this backwards is the single most common block-driver bug.

Driver (Available) Ring (Virtio § 2.7.6)

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/// Spec: `virtq_avail` (Virtio § 2.7.6)
#[repr(C)]
pub struct DriverRing {
    /// Bit 0 is "no interrupt", meaning ask the device not to interrupt on completion.
    pub flags: u16,
    /// Monotonically increasing. Wraps at 2^16. Do *not* mask this.
    pub index: u16,
    // The following depend on queue_size and must be located by method:
    // ring: [u16; queue_size],
    // used_event: u16,   // only with F_EVENT_IDX
}

The index field is a free-running counter from 0 to 65,535. When you increment it, the device notices, looks at index % queue_size to find the ring slot, reads the u16 there, and treats it as an index into the descriptor table. It then reads that descriptor, and any descriptors chained from it, and performs the operation.

What the descriptors describe depends on the device. For entropy, a descriptor points at a buffer the device fills with random bytes. For block, a three-descriptor chain describes a header (what and where), a data buffer, and a status byte.

You write the descriptor index into ring[your_index % queue_size], but you increment index itself without masking. The device does the modulo. If you mask index, the device cannot tell the difference between "no new requests" and "65,536 new requests."

Device (Used) Ring (Virtio § 2.7.8)

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/// Spec: `virtq_used` (Virtio § 2.7.8)
#[repr(C)]
pub struct DeviceRing {
    /// Bit 0 is "no notify", meaning the device is asking you not to notify it.
    pub flags: u16,
    /// Incremented by the *device* on each completion. Wraps at 2^16.
    pub index: u16,
    // The following depend on queue_size and must be located by method:
    // ring: [DeviceRingElement; queue_size],
    // avail_event: u16,  // only with F_EVENT_IDX
}

/// Spec: `virtq_used_elem` (Virtio § 2.7.8)
#[repr(C)]
pub struct DeviceRingElement {
    /// Index of the *head* descriptor of the chain that completed.
    pub id: u32,
    /// Total bytes written by the device into that chain.
    pub length: u32,
}
Do not implement methods on the raw VirtIO structures. These are the exact memory layouts the device reads and writes; keep them dumb. Put your logic on the structure that holds them.

The id field is the index of the head descriptor of the chain. For a block request with three chained descriptors, you get back one used element naming the header descriptor. That head index is your handle for the whole chain, which makes it the natural key for your wait queue.

Sidebar: Memory Ordering Without an IOMMU

Discussion

The device is a concurrent peer. It executes at the same time as you, it reads and writes the same memory you do, and you have no control over its caches or its ordering. Every rule you know about shared-memory concurrency applies, plus a few more.

Three separate concerns, which students routinely conflate:

1. Volatility. Everything in the common configuration structure, the notify register, the ISR register, and the device-specific configuration is MMIO. The compiler does not know that reading the same address twice can give two different answers, or that writing an address you never read has an effect. If you do not use read_volatile and write_volatile, the optimizer is entitled to coalesce, reorder, hoist, or delete your accesses. It will. Release builds will break in ways debug builds do not.

2. Ordering. The descriptors, the ring contents, and the ring indices live in ordinary RAM, not MMIO. Here the problem is not volatility but visibility order. You fill in a descriptor, write its index into the available ring, then increment the available ring index. The moment the device observes that increment, it is entitled to read the descriptor. If the increment becomes visible before the descriptor contents do, the device reads garbage.

This is exactly the acquire/release problem, and the fix is the standard one. Publish the available index with a release store, so everything you wrote before it is visible first. Read the used index with an acquire load, so everything the device wrote before incrementing it is visible to you after. This is why the index field is a natural candidate for AtomicU16 rather than a plain u16.

3. The notify write. The notify register is MMIO, and MMIO writes are ordered against other MMIO writes but not necessarily against your RAM writes. A fence before the notify write is the conservative and correct choice.

Now the uncomfortable part. QEMU on x86-64 or on a coherent host will very likely let you get all of this wrong and still work, every time, for the entire month. sfence-free, volatile-free, fence-free code will pass your tests. It is still wrong, and "it works on QEMU" is not evidence of correctness. It is evidence that you have not yet run on hardware with a weaker memory model, which is to say, most real RISC-V silicon.

So: decide deliberately what your ordering discipline is, write it down in a comment at the top of your queue module, and apply it consistently. I would rather read a driver with a wrong-but-stated ordering model than one with no model at all.

Sidebar: Why #[repr(u32)] Enums Are Unsound in Device Memory

Discussion

It is tempting to write this:

#[repr(u32)]
enum CtrlType {
    CmdGetDisplayInfo = 0x0100,
    OkNoData = 0x1100,
    ErrUnspec = 0x1200,
    // ...
}

#[repr(C)]
struct CtrlHeader {
    ctrl_type: CtrlType,   // <-- the problem
    // ...
}

It reads beautifully. It is also undefined behavior, and the GPU driver is where it will bite you.

A Rust enum has a validity invariant: the only legal bit patterns are its declared discriminants. Constructing or even reading an enum whose value is not a declared discriminant is instant undefined behavior (UB), not "returns a weird value," but UB, with the optimizer entitled to assume it cannot happen and delete your error handling accordingly.

Now consider CtrlHeader. You write a command into it and hand it to the GPU. The GPU writes a response into the header of the response descriptor. Suppose a future QEMU adds a response code, or the device is buggy, or you made an off-by-one in a descriptor address and the header now overlaps pixel data. You then read ctrl_type. If the bits are not one of your declared variants, you have UB in your kernel, at the worst possible place, with no diagnostic.

The general rule: any field the device writes must be typed as a raw integer. The device is not bound by your type system.

The general fix: store the wire representation, and convert at an explicit boundary.

#[repr(C)]
pub struct CtrlHeader {
    /// Raw wire value. Convert with `CtrlType::try_from` at the read boundary.
    pub ctrl_type: u32,
    // ...
}

…​with a TryFrom<u32> that returns an error for unknown values, so an unexpected response code becomes a debugln! instead of a miscompile.

You have design freedom in where you put that boundary. Some options, all defensible:

  • TryFrom at every read, propagating errors outward.

  • A newtype wrapper with associated constants (CtrlType::OK_NO_DATA) and no validity invariant at all. This is what the bitflags pattern does, and it is why I typed Descriptor::flags as a u16.

  • Enums only for values that travel driver-to-device (where you are the only writer), raw integers for anything device-to-driver.

That last one is subtle and worth thinking about: a #[repr(u32)] enum is perfectly sound for a command you construct and the device only reads. The unsoundness is specifically in the read-back direction.

Pick a discipline, and be prepared to defend it. The fields where this matters in this lab: Descriptor::flags, DriverRing::flags, DeviceRing::flags, block::Status, and above all gpu::CtrlHeader::ctrl_type.

Descriptor Allocation and Reclamation

"Allocate the next descriptor" is doing a lot of hand-waving, and it is where a working single-request driver becomes a broken multi-request driver.

You have queue size descriptors. A block request consumes three of them. Requests complete out of order, that is the entire point of having a queue, so you cannot simply bump a counter and wrap. You will eventually hand the device a descriptor that is still in flight.

You need two capabilities:

  1. Allocate n descriptors, which may be non-contiguous, and chain them.

  2. Free an entire chain when its head index appears in the used ring.

Some approaches, in rough order of increasing sophistication:

  • Free list threaded through the next field. The classic. Initialize next on every descriptor to point at its successor, keep a head index, and pop from the front. Freeing a chain is walking to its tail and splicing the whole thing back on. Costs you nothing but a u16.

  • A separate bitmap or Vec<bool>. Simpler to reason about, wastes a little memory, easier to assert against.

  • Bump-and-wrap with a high-water mark. Works only if you never have more than one request in flight. Fine for milestone 4. Not fine for the rubric.

Whichever you choose, you also need somewhere to record why you submitted a chain, keyed by its head index, so that when the used ring names that head you know what to do. The Queue superstructure below is the natural home for that.

The rubric requires your block driver to handle multiple requests asynchronously in flight. That requirement is really a requirement about this section. A driver that allocates descriptor 0, 1, 2 every time and polls for completion will pass milestone 6 and fail the rubric.

Draining the Device Ring

This loop is the same in all four drivers, and it is the one piece of queue logic I will write out in full, because the wrapping arithmetic is fiddly and there is nothing to learn from getting it wrong.

Working Example
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/// # Drain every completion the device has published.
///
/// Called from the IRQ handler. `seen_device` is *our* record of how far
/// into the used ring we have looked; the device's `index` is how far it
/// has written. The gap between them is the work to do.
///
/// Both counters are free-running u16s that wrap at 65,536, so the
/// comparison must be a plain inequality and the increment must wrap.
/// Never `%` the counters themselves
pub fn drain(&mut self) {
    // Acquire load: everything the device wrote before publishing this
    // index must be visible to us now. See the memory ordering sidebar.
    let device_index = self.device_ring_index();

    while self.seen_device != device_index {
        let slot = (self.seen_device as usize) % (self.size as usize);
        let element = self.device_ring_element(slot);

        // `element.id` is the *head* descriptor of the completed chain,
        // and `element.length` is how many bytes the device wrote into it.
        let head = element.id as u16;
        let written = element.length as usize;

        // Hand off to whatever was waiting on this chain, then reclaim
        // the descriptors. Order matters: the completion handler may still
        // need to read the buffers the descriptors point at.
        self.complete(head, written);
        self.free_chain(head);

        self.seen_device = self.seen_device.wrapping_add(1);
    }
}
Drain in a loop until you have caught up, every single time you are interrupted. Do not process one completion per interrupt. The device may publish several completions and coalesce them into one MSI, and if you only take one you will stall forever waiting for an interrupt that already happened.

Queue Super Structure

You need to hold your bookkeeping alongside the addresses of the three device regions. Since this structure is yours and not the device’s, this is where your methods live.

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// Our bookkeeping for one split virtqueue.
///
/// The three addresses are the *virtual* addresses of regions whose
/// *physical* addresses we wrote into the device's queue registers.
#[derive(Debug)]
pub struct Queue {
    pub desc: VirtualAddress,
    pub driver: VirtualAddress,
    pub device: VirtualAddress,
    pub size: u16,

    /// Next slot in the driver ring we will publish into.
    pub next_driver: u16,
    /// How far into the device ring we have consumed.
    pub seen_device: u16,

    /// Head of our descriptor free list.
    pub free_head: u16,

    /// Per-head-descriptor record of work in flight, indexed by descriptor
    /// index. This is what lets the IRQ handler answer "what was this?"
    /// when the used ring hands it back a bare head index.
    pub inflight: Vec<Option<InFlight>>,
}

desc, driver, and device correspond to the three physical addresses you wrote into queue_descriptor, queue_driver, and queue_device.

The inflight field is the important addition, and it is the seam between this section and Wait Queues. Size it to queue_size at setup, index it by head descriptor index, and put in it whatever your completion handler needs: the request type, the buffers to copy out of and then free, and a key into the wait queue. What exactly goes in InFlight is a design decision, and it is one the rubric asks you to describe.

Setting Up a Virtqueue

Pulling the above together, per queue:

  1. Write the queue number into queue_select. Everything after this refers to that queue.

  2. Read queue_size to learn the device’s maximum. A zero means the queue does not exist.

  3. Write back the size you actually want. It must be a power of two, and no larger than the maximum.

  4. Allocate and zero memory for the three regions, respecting the alignments in the table above.

  5. translate() each region’s virtual address to a physical address and write them into queue_descriptor, queue_driver, and queue_device.

  6. Write your MSI-X vector index into queue_msix_vector, so this queue actually interrupts you.

  7. Compute and cache this queue’s notify address.

  8. Initialize your own bookkeeping: descriptor free list, next_driver, seen_device, inflight.

  9. Write 1 into queue_enable.

Because our page allocator has no physical contiguity guarantee, allocating one page per region is the path of least resistance. A 4,096-byte page holds a 256-entry descriptor table exactly (\$256 \times 16 = 4096\$), so a queue size of 256 is convenient. Smaller is fine and easier to debug; start at 8 or 16.
Zero the regions. The device reads the ring flags, and a nonzero "no interrupt" flag that you never intentionally set is a miserable bug to find.

Handoff

The individual devices go through several subsystems prior to being initialized. Each device has to be specifically initialized, since initializing a GPU is not the same as initializing a block device.

To get to the device:

  1. Enumerate through pci_init.

  2. Hand off every device with VendorId == 0x1AF4 to virtio_pci_init.

    1. This is where the common configuration is located, features are negotiated, and the descriptor table and both rings are created.

  3. Hand off each configured VirtIO device to its specific driver, based on DeviceId:

    1. Entropy

    2. Block

    3. Input

    4. GPU

The division of labor is a design decision worth making deliberately. The generic layer should do everything that is identical across devices: reset, negotiate, allocate queues, wire MSI-X, register the EIID. The device layer should do only what is device-specific: read cfg_type 4, pre-post buffers, set DRIVER_OK. Where exactly you draw that line is up to you, but draw it consciously; otherwise, you will write the same forty lines four times.

The block and GPU drivers are the most involved. The GPU has the most initialization commands; the block driver has the most subtle descriptor semantics.

Device and IRQ Registries

You added an IRQ registry in the last lab. Now it has to carry real traffic, and it gains a sibling.

  • The device registry is the answer to "what devices does this machine have?" Every device you discover and successfully initialize gets logged here. Later labs will consult it to find, say, the block device backing the filesystem.

  • The IRQ registry is the answer to "an EIID just arrived; who wants it?" It maps EIID to whatever you need in order to dispatch.

You have a heap, so the alloc collections are available. A BTreeMap keyed by EIID is a reasonable start.

The dispatch target is a design decision with real tradeoffs:

  • Function pointers or Box<dyn FnMut>. Flexible, and the obvious object-oriented answer. Also the hardest to debug: a corrupt registry entry becomes a jump to a garbage address, and you get a page fault with no useful backtrace.

  • An enum over device types. enum Device { Block(usize), Gpu(usize), Entropy(usize), Input(usize) }, dispatched with a match. Less clever, but a corrupt entry is an unmatched discriminant you can print. In a kernel, with no debugger to speak of, I lean toward this.

Whichever you choose, the EIID assignment policy for this lab is fixed by the rubric: UART is IRQ 10, RTC is IRQ 11, and PCI devices take EIIDs from the range 100 to 110, first-come first-served as you enumerate them. A small counter starting at 100 is all this needs.

Registering the EIID in your registry and enabling it in the IMSIC’s eieX registers are two different actions, and you need both. The registry is your software routing table; eieX is the hardware gate. Forgetting the second is why milestone 5 fails for people whose milestone 2 worked.

Entropy Device

Table 3. Read This
Specification What you need from it

Virtio § 5.4

Entropy device: queues, feature bits, request semantics

This is the reference device for the entire lab. It has exactly one queue, no device-specific configuration worth reading, no feature bits you care about, and a request that is a single unchained descriptor. Everything else in this lab is this, plus complications.

Build this one first, get it working polled, then get it working on MSI-X, and then go read the block device section. If you start with block you will be debugging queue setup, descriptor chaining, direction flags, and MSI-X simultaneously.

Making a Request

  1. Allocate a descriptor from your free list.

  2. Set its address to the physical address of the buffer you want filled. Use translate().

  3. Set its length to the number of bytes you want.

  4. Set DESC_WRITE, because the device writes and you read. Clear DESC_NEXT; this is a chain of one.

  5. Record the in-flight entry, keyed by this descriptor index.

  6. Write the descriptor index into driver_ring.ring[next_driver % queue_size].

  7. Increment driver_ring.index (the free-running one, not masked).

  8. Fence, then write the queue index to the notify address.

The device fills your buffer by DMA, publishes a used-ring element naming your descriptor, and sends an MSI-X write to the IMSIC interrupt file. That write raises SEIP on the target hart, your trap handler claims the EIID from stopei, the IRQ registry routes it to this driver, and you drain the device ring.

Testing

For milestone 4, skip the interrupt entirely: after the notify write, spin on the device ring index until it moves, then read your buffer. If it contains non-zero bytes that differ across boots, your entire queue implementation is correct. That is a large chunk of this lab proven with no interrupt handling involved.

For milestone 5, delete the spin loop and let the handler do it. If milestone 4 worked and milestone 5 does not, your bug is in MSI-X setup, eieX, or the IRQ registry and nowhere else. That is the whole reason for doing it in this order.

Request a small number of bytes at first, 8 or 16, and print them as hex. Random data is self-evidently random; a buffer of zeros is self-evidently not.

Block Device

Table 4. Read This
Specification What you need from it

Virtio § 5.2

Block device: configuration layout, request types, status codes

Virtio § 5.2.6

Device operation: the three-part request structure

The block device will be the most important for your next lab, which loads processes and schedules them. You can test the next lab without a working block driver, but you will want one.

A block request is three descriptors chained together: a header describing what and where, a data buffer, and a one-byte status field.

There are several ways to get this wrong that all produce the same symptom: a request that fails or silently does nothing. If you try to read without setting DESC_WRITE on the data descriptor, the request fails. If you write with DESC_WRITE set on the data descriptor, the request fails. If your length is not a multiple of the sector size, the request fails.
Once more, because it is the defining bug of this section: DESC_WRITE is a direction, not a permission. Set it when the device will write (a read request). Clear it when the device will read (a write request). The flag’s name describes the device’s action, not yours.

Device Configuration

The block device’s cfg_type 4 structure is documented in Virtio § 5.2.4. The field you actually need is capacity, a u64 count of 512-byte sectors. It is how you reject a request for a sector that does not exist, rather than finding out via IoError. The structure also carries geometry, topology, maximum segment counts, and discard limits, most of which are only meaningful with feature bits we are not negotiating.

Print the capacity during initialization and check it against the size of your hdd.dsk. If it does not match, you are reading the wrong address, and you will have learned that before writing a single request.

Block Request Header

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
pub const SECTOR_SIZE: usize = 512;
pub const SECTOR_MASK: usize = SECTOR_SIZE - 1;

// Spec: Virtio § 5.2.6 -- request types. These go driver-to-device, so an
// enum is defensible here; see the enum sidebar for why the *status* byte
// coming back is a different story.
pub const BLK_T_IN: u32           = 0;  // read
pub const BLK_T_OUT: u32          = 1;  // write
pub const BLK_T_FLUSH: u32        = 4;
pub const BLK_T_GET_ID: u32       = 8;
pub const BLK_T_GET_LIFETIME: u32 = 10;
pub const BLK_T_DISCARD: u32      = 11;
pub const BLK_T_WRITE_ZEROES: u32 = 13;
pub const BLK_T_SECURE_ERASE: u32 = 14;

/// Spec: `virtio_blk_req` header (Virtio § 5.2.6)
///
/// Pointed at by the *first* descriptor of the chain. Device-read-only,
/// so `DESC_WRITE` must be clear on that descriptor.
#[repr(C)]
pub struct RequestHeader {
    pub request_type: u32,
    pub reserved: u32,
    /// Byte offset divided by SECTOR_SIZE.
    pub start_sector: u64,
}

The request_type field describes the direction, where IN is a read and OUT is a write. This must agree with the DESC_WRITE flag on your data descriptor.

The start_sector field is the starting sector. A sector is 512 bytes, so you can only read or write in multiples of 512 bytes, starting at a multiple of 512 bytes. If you want the byte at offset 530, you must read all of sector 1 (since \$\lfloor 530 \div 512 \rfloor = 1\$) and then copy out the byte at offset 18 within that sector.

Data Descriptor

The data descriptor points at the physical address of a buffer that either supplies data to the device (write/OUT) or receives data from it (read/IN).

Physical address. No IOMMU. translate().

The descriptor’s length is the size of the transfer in bytes, and it must be a multiple of the sector size. A non-multiple gets you Unsupported.

Buffers, Alignment, and Physical Contiguity

Two constraints collide here.

  1. The device requires sector-aligned, sector-multiple transfers.

  2. Your page allocator is a free list, so two pages you allocate are not necessarily physically adjacent.

The consequence of the second one is easy to miss: you cannot allocate three pages and hand the device a single 12,288-byte descriptor. Their physical addresses may be scattered. Each physically contiguous run needs its own descriptor, chained, or, more simply, you cap a single request at one page (\$4096 \div 512 = 8\$ sectors) and issue multiple requests for anything larger.

So a request for 778 bytes starting at byte 1,000 becomes: align the start down to sector 1 (byte 512), align the end up past byte 1,778 to sector 4 (byte 2,048), which is 3 sectors of 512 bytes, which fits in one page. You then hand the device 1,536 bytes and copy out the 778 the caller actually asked for.

This means you must keep the original request alongside the aligned request, because they are different. That bookkeeping is what a buffer abstraction is for. Later, when you have processes, this is what lives in a resource control block.

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/// Pages backing one block request, plus the original unaligned request.
///
/// # Contract
///
/// * The pages collectively cover `[aligned_start, aligned_end)`, where
///   both bounds are sector-aligned and the total is a sector multiple.
/// * `start` and `end` are the caller's *original*, unaligned byte range,
///   with `end` exclusive. These are what `copy_to` must honor.
/// * This structure *owns* its pages and returns them to the page
///   allocator on drop. Nothing else may free them.
/// * Pages are not assumed to be physically contiguous with each other.
///   Each needs its own descriptor, or its own request.
struct Buffer {
    pages: Vec<PageAllocation>,
    start: usize,
    end: usize,
}

impl Buffer {
    /// Allocate enough zeroed pages to service `[start, start + size)`
    /// after sector alignment. Returns `None` if pages are unavailable --
    /// a kernel that panics on allocation failure is not a kernel.
    fn new(start: usize, size: usize) -> Option<Self> {
        todo!("align the range, allocate ceil(aligned_size / PAGE_SIZE) pages")
    }

    /// Number of bytes the caller originally asked for.
    fn len(&self) -> usize {
        self.end - self.start
    }

    /// Copy the caller's requested range out of the page-aligned pages into
    /// `dst`, returning bytes copied.
    ///
    /// The interesting part: the first useful byte sits at
    /// `start - aligned_start` *within the first page*, subsequent pages
    /// start at offset 0, and the last page may be partially used. Copy no
    /// more than `min(self.len(), dst.len())` bytes in total, and advance
    /// your destination pointer as you go.
    fn copy_to(&self, dst: &mut [u8]) -> usize {
        todo!("walk self.pages, honoring the intra-page offset and the total cap")
    }
}

impl Drop for Buffer {
    fn drop(&mut self) {
        // Return every page. Note that your page allocator's dealloc is an
        // `unsafe fn`, so this needs an unsafe block and a comment
        // justifying it.
        todo!()
    }
}

I have deliberately left the bodies out. copy_to in particular has four separate off-by-one opportunities: (1) the intra-page start offset, (2) the total byte cap, (3) advancing the destination, and (4) the exclusive end bound. Working through them is more instructive than reading my version. Write it, then write a test that copies a known pattern across a three-page buffer with a non-zero start offset.

Status Descriptor

The status descriptor is a single byte that the device writes to tell you the outcome. DESC_WRITE must be set, and DESC_NEXT must be clear, since this is the tail of the chain.

The defined values are 0 (ok), 1 (I/O error), and 2 (unsupported).

Since it is entirely possible that the device never writes the status byte at all, because your chain was malformed, or Bus Master Enable is clear, or the device rejected the request outright, initialize it to a sentinel value that is not a defined status. I use 111. Without a sentinel, "the device never responded" and "the device said success" are the same byte, and you will spend an afternoon believing a request succeeded when it never happened.
Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
pub const BLK_S_OK: u8          = 0;
pub const BLK_S_IOERR: u8       = 1;
pub const BLK_S_UNSUPPORTED: u8 = 2;
/// Not a device value. Ours, to detect "never written".
pub const BLK_S_SENTINEL: u8    = 111;

/// Spec: the status byte of `virtio_blk_req` (Virtio § 5.2.6)
///
/// Typed as `u8` and not as an enum, deliberately: the *device* writes
/// this field, so it can contain a value with no valid discriminant.
/// See the enum sidebar.
#[repr(C)]
pub struct Status {
    pub status: u8,
}

Interpreting what comes back:

  • Ok means the request succeeded. Note that it is also the value 0, which is why the sentinel matters.

  • I/O error means a problem at the device: it went away, it hiccuped, or you asked for a sector outside its capacity.

  • Unsupported means you asked for something it cannot do. Very often this is a length that is not a sector multiple, or a request type you did not negotiate the feature for.

  • Your sentinel means the device never touched it. That is a driver bug, not a device error, and it is a different debugging path entirely: check Bus Master Enable, check that your addresses are physical, check -d guest_errors.

Request Cycle

  1. Fill in the header descriptor.

    1. Header is device-read-only: DESC_WRITE clear.

    2. next points at the data descriptor: DESC_NEXT set.

  2. Fill in the data descriptor.

    1. DESC_WRITE set for a read (IN), clear for a write (OUT).

    2. next points at the status descriptor: DESC_NEXT set.

  3. Fill in the status descriptor.

    1. DESC_WRITE set. DESC_NEXT clear.

    2. Initialize the byte to your sentinel.

  4. Record the in-flight entry keyed by the head (header) descriptor index.

  5. Publish only the head descriptor index into the driver ring.

  6. Increment the driver ring index by one.

    1. Not three. The index counts ring entries, not descriptors. One chain is one entry.

  7. Fence, then notify.

The moment you increment the driver ring index, the device may begin reading. Everything, all three descriptors, the header contents, the sentinel, must be fully written and visible before that increment. This is the release-store discussion from Sidebar: Memory Ordering Without an IOMMU, and it is not hypothetical.

The completion arrives in the device ring as a single element naming your head descriptor. Read the status byte. If it is Ok, your buffer holds the data (read) or the disk holds your data (write). Then free all three descriptors and drop the buffer.

Multiple Virtqueues

The block device can expose multiple virtqueues for performance, serviced round-robin. You can enable this by negotiating VIRTIO_BLK_F_MQ and reading num_queues.

You are not required to implement multiple queues. Clear VIRTIO_BLK_F_MQ during negotiation and work with queue 0.

Testing

Create a disk image full of known-random data:

dd if=/dev/urandom of=hdd.dsk bs=1M count=32

Then find out what sector 0 should look like:

head -c 64 hdd.dsk | hexdump -C -
00000000  9b f7 74 13 aa d6 73 a8  91 8f ea be f7 d1 27 c1  |..t...s.......'.|
00000010  70 eb e8 81 4b 6d 46 ac  dc ad 25 b1 c1 db 16 52  |p...KmF...%....R|
00000020  2a 95 41 f4 71 34 fc 9e  39 c1 d2 77 a3 a6 62 73  |*.A.q4..9..w..bs|
00000030  0e 62 84 88 9a 99 96 4d  5a c9 da e3 ac 98 7e 4c  |.b.....MZ.....~L|
00000040

Your bytes will differ, but a read of sector 0 must produce exactly what hexdump shows. Then seek elsewhere and confirm those sectors too. The offsets on the left of hexdump are hex, and sectors are 512 bytes, so you are looking at multiples of 0x200.

For milestone 7, write a recognizable pattern to a sector, re-read it to confirm the device agrees, then exit QEMU and hexdump the host file. If the file changed, your write path is real.

Write to a high sector, not sector 0. If you have a bug in your sector arithmetic, you would rather corrupt sector 60,000 of a scratch file than the part of it you were using as your read test.

Input Devices

Table 5. Read This
Specification What you need from it

Virtio § 5.8

Input device: config select/subsel, event queue, status queue

input-event-codes.h

EV_* types and KEY_* / ABS_* codes

The input driver handles all input types. For our OS that means keyboard and tablet. We use a tablet rather than a mouse so we get absolute coordinates instead of relative ones, which makes the screen mapping arithmetic straightforward.

Two Queues

The input device has two virtqueues: the event queue (index 0), which the device writes events into, and the status queue (index 1), which you would use to send things like LED state back to the device. You must set up queue 0. Queue 1 you can leave alone for this lab, but be aware it exists. If you assume one queue you will mis-index.

Device Configuration

Input devices are queried through cfg_type 4, using a select/subsel mechanism documented in Virtio § 5.8.4. You write a select and a subsel, and the device populates a union with the answer. This is the same indirect-access idea as the IMSIC’s siselect/sireg.

The selects you care about:

Select Subsel What you get

0x01

0

Device name, as a string. This is how you tell the keyboard from the tablet.

0x11

an EV_* type

Bitmap of which codes of that event type the device supports.

0x12

an ABS_* axis

An AbsInfo: min, max, fuzz, flat, res for that axis.

Only the event-bits and abs-info selects use subsel. For everything else write 0 into subsel, or the device will not update its registers.
The configuration structure ends in a union, and unions are inherently unsafe in Rust. If you model it as a union you will be writing unsafe at every access and thinking hard about alignment. An alternative worth considering: model the header fields, and read the payload as a [u8; 128] that you interpret based on which select you just wrote. Less elegant, considerably less UB surface.
You can identify the two devices by name string, or by which EV_* types they advertise (i.e., devids.product), and the keyboard will claim EV_KEY, the tablet EV_ABS. Either is fine, but do it during initialization and record it in the device registry.

Event System

There are no requests made of an input device. Instead you supply it with memory, and it writes events into that memory as they occur.

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/// Spec: `virtio_input_event` (Virtio § 5.8.6)
///
/// Exactly 8 bytes. Every descriptor you post to the event queue points
/// at one of these.
#[repr(C)]
#[derive(Copy, Clone, Debug, Default)]
pub struct Event {
    pub event_type: u16,
    pub code: u16,
    pub value: u32,
}

The types you will see, all defined in input-event-codes.h in the template:

  • EV_SYN — a synchronization marker, emitted after a group of related events.

  • EV_KEY — a key press or release. code is the KEY_* code, value is 1 for press and 0 for release.

  • EV_ABS — an absolute axis update from the tablet. code is ABS_X or ABS_Y, value is the position.

A mouse, unlike a tablet, reports EV_REL (relative) instead.

Pre-Posting the Event Queue

This is the structural difference between input and every other device in this lab, and it is easy to miss: the device has nothing to write into until you give it buffers. There is no request to submit. If you set DRIVER_OK with an empty available ring, the device has nowhere to put the first keypress, and it is dropped.

So before you set DRIVER_OK:

  1. Allocate memory for some number of Event structures. One page holds 512 of them, which is plenty.

  2. Allocate that many descriptors, each pointing at the physical address of one 8-byte Event, each with DESC_WRITE set and DESC_NEXT clear.

  3. Publish all of them into the available ring and advance the index by that count.

  4. Then set DRIVER_OK.

After you consume an event, that descriptor is now empty and the device will not reuse it until you give it back. Repost it: publish its index into the available ring again and bump the index. If you forget, you will receive exactly as many events as you pre-posted and then silence forever, which looks a lot like a broken interrupt, and is not.

Absolute Axis Scaling

The tablet reports positions in its own coordinate space, not in pixels. The AbsInfo you read for ABS_X and ABS_Y gives you min and max for each axis, and you map linearly onto your framebuffer:

\$\text{pixel}_x = \frac{(\text{value} - \text{min}_x) \times \text{screen width}}{\text{max}_x - \text{min}_x}\$

Read AbsInfo once at initialization and cache it. Do the multiply before the divide, in a wide enough integer, or you will lose precision and your cursor will move in jumps.

QEMU’s tablet typically reports a range of 0 to 32,767 on both axes regardless of window size, so do not assume the range matches your resolution.

Draining and Buffering

Events fire fast and a thousand a second is easy with a moving tablet. Two rules:

  1. Drain everything before returning from the handler. Same loop as every other device. One event per interrupt will put you permanently behind.

  2. Copy each event into a bounded local buffer, and repost the descriptor immediately. The descriptor is a scarce shared resource; your local buffer is not.

Your local buffer must have a size limit, and when it is full you must drop events. An unbounded queue fed by a device that can produce a thousand events a second is a slow-motion out-of-memory. Dropping input events is a completely acceptable engineering decision since it is likely the user will press the key again. Running the kernel out of heap is not.
A fixed-size ring buffer with an overwrite-oldest or drop-newest policy is the usual answer. Which one you choose changes the user-visible behavior; think about which is less surprising for a keyboard, and whether the answer differs for the tablet.

GPU

Table 6. Read This
Specification What you need from it

Virtio § 5.7

GPU device: queues, configuration, command set

Virtio § 5.7.6.8

Control header, command and response codes

Virtio § 5.7.6.3

RESOURCE_CREATE_2D

Virtio § 5.7.6.5

RESOURCE_ATTACH_BACKING and memory entries

Virtio § 5.7.6.9

SET_SCANOUT

Virtio § 5.7.6.11

TRANSFER_TO_HOST_2D

Virtio § 5.7.6.12

RESOURCE_FLUSH

The GPU has the longest bring-up sequence in this lab. The device has its own framebuffer, and transfers into it happen by DMA. So you have two framebuffers: (1) one in your RAM that you draw into and (2) one on the device that gets scanned out to the screen. You will command the GPU to DMA copy the first into the second.

Two Queues

The GPU has a control queue (index 0) and a cursor queue (index 1). Every command in this lab goes on the control queue. The cursor queue exists for low-latency hardware cursor updates and you can ignore it, but again: do not assume one queue.

Device Configuration

The GPU’s cfg_type 4 structure (Virtio § 5.7.4) has four u32 fields: events_read, events_clear, num_scanouts, and a reserved word. num_scanouts is the only one you need, and it is how many displays exist. events_read is how the device tells you the display was resized, which you can safely ignore for this lab.

Command Structure

Every command on the control queue begins with a common header, and every response the device sends back also begins with that same header.

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/// Spec: `virtio_gpu_ctrl_hdr` (Virtio § 5.7.6.8)
///
/// Prefixes every control-queue command *and* every response.
///
/// `ctrl_type` is a raw u32 and not an enum precisely because the device
/// writes it in the response direction. See the enum sidebar.
#[repr(C)]
pub struct CtrlHeader {
    pub ctrl_type: u32,
    pub flags: u32,
    pub fence_id: u64,
    pub ctx_id: u32,
    pub padding: u32,
}

// Spec: Virtio § 5.7.6.8. 2D commands.
pub const CMD_GET_DISPLAY_INFO: u32        = 0x0100;
pub const CMD_RESOURCE_CREATE_2D: u32      = 0x0101;
pub const CMD_RESOURCE_UNREF: u32          = 0x0102;
pub const CMD_SET_SCANOUT: u32             = 0x0103;
pub const CMD_RESOURCE_FLUSH: u32          = 0x0104;
pub const CMD_TRANSFER_TO_HOST_2D: u32     = 0x0105;
pub const CMD_RESOURCE_ATTACH_BACKING: u32 = 0x0106;
pub const CMD_RESOURCE_DETACH_BACKING: u32 = 0x0107;
// Cursor commands begin at 0x0300.
// Success responses begin at 0x1100; OK_NODATA is 0x1100,
// OK_DISPLAY_INFO is 0x1101.
// Error responses begin at 0x1200.
Because success and error responses are in disjoint ranges, a quick "did that work?" check is response >= 0x1100 && response < 0x1200. Log the raw value when it is not and the specific error code (invalid resource id, invalid scanout id, out of memory, invalid parameter) tells you exactly which field you got wrong.

Initialization

  1. GET_DISPLAY_INFO to learn the display dimensions.

  2. Allocate driver-side framebuffer memory in your RAM.

  3. RESOURCE_CREATE_2D to create a device-side resource of the same dimensions and format.

  4. RESOURCE_ATTACH_BACKING to associate your RAM with that resource.

  5. SET_SCANOUT to bind the resource to a display.

  6. Draw, then TRANSFER_TO_HOST_2D and RESOURCE_FLUSH.

The commands you need for the middle steps:

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/// Spec: `virtio_gpu_resource_create_2d` (Virtio § 5.7.6.3)
#[repr(C)]
pub struct ResourceCreate2d {
    pub header: CtrlHeader,
    pub resource_id: u32,
    pub format: u32,
    pub width: u32,
    pub height: u32,
}

/// Spec: `virtio_gpu_set_scanout` (Virtio § 5.7.6.9)
#[repr(C)]
pub struct SetScanout {
    pub header: CtrlHeader,
    pub rect: Rect,
    pub scanout_id: u32,
    pub resource_id: u32,
}

/// Spec: `virtio_gpu_resource_attach_backing` (Virtio § 5.7.6.5)
///
/// Followed in the *data* descriptor by `num_entries` `MemEntry` values.
#[repr(C)]
pub struct ResourceAttachBacking {
    pub header: CtrlHeader,
    pub resource_id: u32,
    pub num_entries: u32,
}

/// Spec: `virtio_gpu_mem_entry` (Virtio § 5.7.6.5)
#[repr(C)]
pub struct MemEntry {
    /// *Physical* address of one physically contiguous run of framebuffer.
    pub addr: u64,
    pub length: u32,
    pub padding: u32,
}
resource_id is chosen by you, and it must be nonzero. Zero is reserved to mean "no resource." You allocate and track resource ids yourself. A SET_SCANOUT referring to a resource id you never created gets you ERR_INVALID_RESOURCE_ID, which at least tells you where to look.

Why num_entries Is Plural

This is the part of the GPU section that catches people, and it follows directly from your own page allocator.

RESOURCE_ATTACH_BACKING tells the device where your framebuffer is. A 1024x768 RGBA framebuffer is \$1024 \times 768 \times 4 = 3145728\$ bytes (768 pages). Your page allocator is a free list with no physical contiguity guarantee, so those 768 pages are scattered across physical memory in arbitrary order.

So you cannot send one MemEntry covering 3 MiB. You must send one MemEntry per physically contiguous run, and with a free-list allocator the safe assumption is one entry per page. 768 entries, each 16 bytes, is 12,288 bytes of memory entries, which is itself three pages, which are themselves not contiguous, which means the data descriptor carrying them needs chaining too.

Merging Physical Pages

This is a genuine consequence of a design decision we made two labs ago, and it is worth sitting with. A kernel with a freelist allocator or a dedicated contiguous DMA pool would hand the device a handful of large entries instead.

You must detect adjacent pages and coalesce them into a single entry. This optimization is called merging and is typically done in non-IOMMU systems. Since the memory addresses will not change once allocated for the device, this merging must be done once. Do not detect and merge every time your driver transfers the framebuffer to the GPU.

Rust Page Run Example
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const PIXEL_SIZE: usize = core::mem::size_of::<Pixel>();

/// A maximal run of physically contiguous page frames.
///
/// `paddr` is computed once, at allocation time. Transfer time never
/// walks a page table or re-checks adjacency.
struct PageRun {
    /// Starting physical address
    paddr: usize,
    /// All of the pages that follows from `paddr` above
    pages: Vec<PageAllocation>,
}

impl PageRun {
    pub fn paddr(&self) -> usize { self.paddr }
    pub fn len(&self) -> usize { self.pages.len() * PAGE_SIZE }
}

/// Allocate framebuffer memory. This will help us keep physically contiguous pages
/// in the same [`PageRun`] structure.
fn allocate_framebuffer_memory(width: usize, height: usize) -> Vec<PageRun> {
    let bytes = width * height * PIXEL_SIZE;
    let num_pages = (bytes + PAGE_SIZE - 1) / PAGE_SIZE;

    // Reserve BEFORE taking the allocator lock so no push inside the
    // closure can reenter the heap and then the page allocator.
    let mut frames: Vec<(usize, PageAllocation)> = Vec::with_capacity(num_pages);

    kdata().with_page_allocator(|palloc| {
        for _ in 0..num_pages {
            let p = palloc.alloc_zeroed().expect("out of pages for framebuffer");
            // Arithmetic, not a page walk, and no second lock.
            frames.push((va_to_pa(p.start_addr()), p));
        }
    });

    // Adjacent frames are only adjacent in the list if we sort them.
    // sort_unstable_by_key is in core and allocates nothing.
    frames.sort_unstable_by_key(|(pa, _)| *pa);

    let mut runs: Vec<PageRun> = Vec::new();
    for (pa, page) in frames {
        match runs.last_mut() {
            Some(run) if run.paddr + run.len() == pa => run.pages.push(page),
            _ => runs.push(PageRun { paddr: pa, pages: vec![page] }),
        }
    }
    runs
}

Screen Format

You can specify several pixel formats. The one you want is RGBA, which is the value 67.

Working Example
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Spec: `virtio_gpu_formats` (Virtio § 5.7.6.3)
pub const FORMAT_B8G8R8A8: u32 = 1;
pub const FORMAT_B8G8R8X8: u32 = 2;
pub const FORMAT_A8R8G8B8: u32 = 3;
pub const FORMAT_X8R8G8B8: u32 = 4;
pub const FORMAT_R8G8B8A8: u32 = 67; // use this one
pub const FORMAT_X8B8G8R8: u32 = 68;
pub const FORMAT_A8B8G8R8: u32 = 121;
pub const FORMAT_R8G8B8X8: u32 = 134;

pub const DEFAULT_FORMAT: u32 = FORMAT_R8G8B8A8;

R8G8B8A8 means red first, which is 8 bits, then green, then blue, then alpha, each also 8 bits (1 byte). All pixel formats are 32 bits (4 bytes). A channel can be turned off using X8, but for padding purposes, it’s still factored in to the size of one pixel.

Example Pixel Formats

pixel formats

Working Example
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#[repr(C)]
#[derive(Debug, Copy, Clone, Default, Eq, PartialEq)]
pub struct Pixel {
    pub r: u8,
    pub g: u8,
    pub b: u8,
    pub a: u8,
}
impl Pixel {
    pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
        Self { r, g, b, a }
    }
}

/// Spec: `virtio_gpu_rect` (Virtio § 5.7.6.2)
#[repr(C)]
#[derive(Debug, Copy, Clone, Default, Eq, PartialEq)]
pub struct Rect {
    pub x: u32,
    pub y: u32,
    pub width: u32,
    pub height: u32,
}
impl Rect {
    pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
        Self { x, y, width, height }
    }
    /// Get the coordinates as (x1, y1), (x2, y2)
    pub fn get_coords(&self) -> ((u32, u32), (u32, u32)) {
        let x1y1 = (self.x, self.y);
        let x2y2 = (self.width + self.x, self.height + self.y)
        (x1y1, x2y2)
    }
}
The format is fixed when you create the 2D resource.

The Framebuffer

The rubric asks for a Framebuffer structure, and the reason is that passing (&mut [Pixel], screen_width, screen_height) to every drawing function separately is both tedious and error-prone, and nothing stops you from passing a width that does not match the slice.

virtio gpu framebuffer coords

A Framebuffer should own or borrow the pixel memory and know its own dimensions, so that a pixel write can be bounds-checked in one place rather than at every call site. What else it carries is a design decision:

  • The pages backing it, so it can free them on drop.

  • The resource id it is attached to, so transfer and flush are methods on it.

  • A dirty rectangle, accumulated as you draw, so flush knows the minimum region to transfer.

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// Driver-side framebuffer. The device-side resource is its mirror.
pub struct Framebuffer {
    pages: Vec<PageAllocation>,
    width: u32,
    height: u32,
    resource_id: u32,
    /// Accumulated dirty region, or None if nothing has changed.
    dirty: Option<Rect>,
}

impl Framebuffer {
    /// Bounds-checked single pixel write. Every drawing primitive should
    /// go through here, or through something that has already clipped.
    pub fn set(&mut self, x: u32, y: u32, p: Pixel) { todo!() }

    /// Union `r` into the dirty region.
    pub fn invalidate(&mut self, r: Rect) { todo!() }

    /// TRANSFER_TO_HOST_2D + RESOURCE_FLUSH over the dirty region, then
    /// clear it. No-op if nothing is dirty.
    pub fn flush(&mut self) { todo!() }
}

The pixel memory is the same memory you attached as backing, and it is page-scattered. If you model it as one &mut [Pixel] you are asserting contiguity you do not have. Either map the pages contiguously in virtual memory, which you can do, since virtual contiguity is cheap and physical contiguity is what you lack or have set() do the page arithmetic. The first is much less painful, and is a nice demonstration of what an MMU is actually for.

Redrawing the Screen

Two commands, and you need both:

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/// Spec: `virtio_gpu_transfer_to_host_2d` (Virtio § 5.7.6.11)
#[repr(C)]
pub struct TransferToHost2d {
    pub header: CtrlHeader,
    pub rect: Rect,
    pub offset: u64,
    pub resource_id: u32,
    pub padding: u32,
}

/// Spec: `virtio_gpu_resource_flush` (Virtio § 5.7.6.12)
#[repr(C)]
pub struct ResourceFlush {
    pub header: CtrlHeader,
    pub rect: Rect,
    pub resource_id: u32,
    pub padding: u32,
}
TRANSFER_TO_HOST_2D copies your RAM into the device’s resource. RESOURCE_FLUSH copies the device’s resource onto the screen. Both are required to see anything. A missing flush is the most common reason the GPU output shows the "guest has not initialized the display" placeholder despite every command returning success.

The Transfer Offset

The transfer rectangle is genuinely confusing, and getting it wrong produces striped or diagonally-sheared output rather than an error.

rect says which region of the resource to update. offset says where in your memory the device should start reading. The device then copies row by row, taking \$\text{rect.width} \times \text{sizeof(Pixel)}\$ bytes per row, advancing through your memory by the resource stride each time.

So offset must be the byte offset within your framebuffer of the pixel at (rect.x, rect.y):

Working Example
/// Byte offset into the driver framebuffer of the pixel at (left, top),
/// for a framebuffer whose row stride is `width` pixels.
const fn transfer_offset(top: u64, left: u64, width: u64) -> u64 {
    const PIXEL: u64 = core::mem::size_of::<Pixel>() as u64;
    left * PIXEL + top * width * PIXEL
}
The width in that calculation is the full framebuffer width, not rect.width. Using rect.width produces an image that shears progressively down the screen, which is at least a distinctive enough symptom to recognize.

Invalidating Pixels

Do not transfer the whole screen on every change. A 1024x768 RGBA framebuffer is 3 MiB; doing that per keystroke is megabytes of DMA to update eight pixels' worth of glyph.

Instead, track which region actually changed and transfer only that. This is what the dirty rectangle in Framebuffer is for, and it is why draw_str below returns a Rect: it is telling you the bounding box of what it touched, so you can union it into the dirty region and transfer once.

The design question worth thinking about: a single bounding rectangle is simple, but if you change one pixel in the top-left and one in the bottom-right, its union is the entire screen. Alternatives include a small list of dirty rectangles, or a coarse tile grid. There is a real tradeoff between transfer volume and bookkeeping complexity, and the right answer depends on your access pattern. Pick one and say why.

Primitive Drawing

You will not draw directly into the GPU. You draw into your RAM framebuffer, then transfer and flush.

The listings below are Working Examples because deriving Bresenham’s line algorithm is not what this course is about. They are also deliberately light on error checking. The point is to get something on screen so you can verify the rest of the pipeline. Adapt them to whatever Framebuffer interface you settle on.

Working Example — drawing a line
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/// # Draw a line from (x1,y1) to (x2,y2)
///
/// Bresenham's algorithm. Tracks an accumulated error term and steps
/// whichever axis keeps the error smallest.
///
/// ## Arguments
///
/// * `x1`, `y1` -- starting column and row.
/// * `x2`, `y2` -- ending column and row.
/// * `screen_width` -- row stride of `framebuffer`, in pixels.
/// * `framebuffer` -- the pixel array to draw into.
/// * `color` -- the line color.
pub fn draw_line(
    x1: usize,
    y1: usize,
    x2: usize,
    y2: usize,
    screen_width: usize,
    framebuffer: &mut [Pixel],
    color: Pixel,
) {
    let mut start_x = x1 as isize;
    let end_x = x2 as isize;
    let mut start_y = y1 as isize;
    let end_y = y2 as isize;
    let dx = x2.abs_diff(x1) as isize;
    let dy = -(y2.abs_diff(y1) as isize);

    // Step direction on each axis: right-to-left or left-to-right,
    // top-to-bottom or bottom-to-top.
    let sx = if x1 >= x2 { -1_isize } else { 1 };
    let sy = if y1 >= y2 { -1_isize } else { 1 };

    // Accumulated error in dx + dy.
    let mut error = dx + dy;
    loop {
        let pixel_location = start_y as usize * screen_width + start_x as usize;
        if pixel_location >= framebuffer.len() {
            debugln!(
                "Pixel location {} exceeded framebuffer size {}.",
                pixel_location,
                framebuffer.len()
            );
            break;
        }
        framebuffer[pixel_location] = color;

        let e2 = 2 * error;
        // Error exceeds the y delta, so stepping x keeps us closest.
        if e2 >= dy {
            if start_x == end_x {
                break;
            }
            error += dy;
            start_x += sx;
        }
        // Error is within the x delta, so step y as well.
        if e2 <= dx {
            if start_y == end_y {
                break;
            }
            error += dx;
            start_y += sy;
        }
    }
}
Working Example — filled rectangle
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/// # Fill a rectangle
///
/// ## Arguments
///
/// * `screen_width`, `screen_height` -- full drawing surface dimensions.
/// * `buffer` -- the pixel buffer.
/// * `rect` -- the region to fill.
/// * `fill` -- the fill color.
pub fn fill_rect(
    screen_width: u32,
    screen_height: u32,
    buffer: &mut [Pixel],
    rect: Rect,
    fill: Pixel,
) {
    let width = screen_width as usize;
    let height = screen_height as usize;

    let top = rect.y as usize;
    let bottom = core::cmp::min((rect.y + rect.height) as usize, height);
    let left = rect.x as usize;
    let right = core::cmp::min((rect.x + rect.width) as usize, width);

    for row in top..bottom {
        for col in left..right {
            buffer[row * width + col] = fill;
        }
    }
}
Working Example — rectangle outline
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/// # Stroke (outline) a rectangle
///
/// Drawn as four filled rectangles, one per edge.
///
/// ## Arguments
///
/// * `screen_width`, `screen_height` -- full drawing surface dimensions.
/// * `buffer` -- the pixel buffer.
/// * `rect` -- the region to outline.
/// * `line_color` -- the edge color.
/// * `line_size` -- edge thickness in pixels.
pub fn stroke_rect(
    screen_width: u32,
    screen_height: u32,
    buffer: &mut [Pixel],
    rect: Rect,
    line_color: Pixel,
    line_size: u32,
) {
    // Top edge
    let r = Rect::new(rect.x, rect.y, rect.width, line_size);
    fill_rect(screen_width, screen_height, buffer, r, line_color);

    // Bottom edge
    let r = Rect::new(rect.x, rect.y + rect.height, rect.width, line_size);
    fill_rect(screen_width, screen_height, buffer, r, line_color);

    // Left edge
    let r = Rect::new(rect.x, rect.y, line_size, rect.height);
    fill_rect(screen_width, screen_height, buffer, r, line_color);

    // Right edge
    let r = Rect::new(
        rect.x + rect.width,
        rect.y,
        line_size,
        rect.height + line_size,
    );
    fill_rect(screen_width, screen_height, buffer, r, line_color);
}
Working Example — a test pattern
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
pub fn test(screen_width: u32, screen_height: u32, framebuffer: &mut [Pixel]) {
    let r1 = Rect::new(0, 0, screen_width, screen_height);
    let r2 = Rect::new(100, 100, screen_width - 150, screen_height - 150);

    let bg = Pixel::new(255, 130, 0, 255); // UT orange
    let fg = Pixel::new(88, 89, 91, 255);  // Smokey gray

    fill_rect(screen_width, screen_height, framebuffer, r1, bg);
    stroke_rect(screen_width, screen_height, framebuffer, r2, fg, 10);
}

Testing the GPU

Until the GPU is initialized and a framebuffer has been transferred and flushed, QEMU shows this:

guest not initialized

Milestone 9 is really two milestones. First get a solid orange screen that proves display info, resource creation, backing attachment, scanout, transfer, and flush all work. Only then draw text.

GPU Terminal Font

The template contains a terminal font, terminal.fnt, imported in src/main.rs. Each character is 8 pixels wide and 16 tall, one byte per row, characters in ASCII order.

Example Bitmap Font

font grid

A bitmap font is exactly what it sounds like: for each bit, draw the foreground color if it is 1 and the background color if it is 0.

Working Example
// Assumes `terminal.fnt` is in `src/`
pub static TERMINAL_FONT: &[u8] = include_bytes!("terminal.fnt");
Working Example — drawing one character
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
use crate::TERMINAL_FONT;

const GLYPH_WIDTH: usize = 8;
const GLYPH_HEIGHT: usize = 16;
const STRING_SCALE_WIDTH: usize = 5;
const STRING_SCALE_HEIGHT: usize = 5;

/// # Draw a single character into a framebuffer
///
/// ## Arguments
///
/// * `c` -- the ASCII byte to draw.
/// * `row`, `col` -- position in *character cells*, not pixels.
/// * `screen_width` -- row stride of `framebuffer`, in pixels.
/// * `framebuffer` -- the pixel buffer.
/// * `fgcolor` -- color for set bits.
/// * `bgcolor` -- color for clear bits.
pub fn draw_char(
    c: u8,
    row: u32,
    col: u32,
    screen_width: u32,
    framebuffer: &mut [Pixel],
    fgcolor: Pixel,
    bgcolor: Pixel,
) {
    // One byte per glyph row, GLYPH_HEIGHT rows per glyph, ASCII order.
    let pos = c as usize * GLYPH_HEIGHT;

    let row = (row as usize) * GLYPH_HEIGHT * STRING_SCALE_HEIGHT;
    let col = (col as usize) * GLYPH_WIDTH * STRING_SCALE_WIDTH;
    let width = screen_width as usize;

    for i in 0..GLYPH_HEIGHT {
        for j in 0..GLYPH_WIDTH {
            // Bit 7 is the leftmost pixel, so index from the high bit.
            let bit = 1 << (7 - j);
            let color = if 0 != TERMINAL_FONT[pos + i] & bit {
                fgcolor
            } else {
                bgcolor
            };
            // Scale by replicating each font pixel into a block.
            for k in 0..STRING_SCALE_WIDTH {
                for l in 0..STRING_SCALE_HEIGHT {
                    let at = (row + i * STRING_SCALE_HEIGHT + l) * width
                           + (col + j * STRING_SCALE_WIDTH + k);
                    if at < framebuffer.len() {
                        framebuffer[at] = color;
                    }
                }
            }
        }
    }
}
Working Example — drawing a string
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/// # Draw a string into a framebuffer
///
/// ## Arguments
///
/// * `s` -- the string to draw.
/// * `start_row`, `start_col` -- starting position in character cells.
/// * `screen_width` -- row stride of `framebuffer`, in pixels.
/// * `framebuffer` -- the pixel buffer.
/// * `fgcolor`, `bgcolor` -- font and background colors.
///
/// ## Returns
///
/// The invalidated rectangle: the bounding box, in pixels, of everything
/// drawn. Union this into your framebuffer's dirty region so that
/// `flush()` transfers the minimum necessary area.
pub fn draw_str(
    s: &str,
    start_row: u32,
    start_col: u32,
    screen_width: u32,
    framebuffer: &mut [Pixel],
    fgcolor: Pixel,
    bgcolor: Pixel,
) -> Rect {
    // Wrap on *character cells*, not pixels. A scaled glyph is
    // GLYPH_WIDTH * STRING_SCALE_WIDTH pixels wide.
    let cells_per_row =
        screen_width / (GLYPH_WIDTH as u32 * STRING_SCALE_WIDTH as u32);

    let mut r = start_row;
    let mut c = start_col;

    for &byte in s.as_bytes() {
        draw_char(byte, r, c, screen_width, framebuffer, fgcolor, bgcolor);
        c += 1;
        if c >= cells_per_row {
            r += 1;
            // Wrapping back to start_col rather than 0 keeps a block of
            // text left-aligned with itself.
            c = start_col;
        }
    }

    Rect::new(
        start_col * STRING_SCALE_WIDTH as u32 * GLYPH_WIDTH as u32,
        start_row * STRING_SCALE_HEIGHT as u32 * GLYPH_HEIGHT as u32,
        (s.len() as u32 % cells_per_row) * GLYPH_WIDTH as u32 * STRING_SCALE_WIDTH as u32,
        (r - start_row + 1) * GLYPH_HEIGHT as u32 * STRING_SCALE_HEIGHT as u32,
    )
}

UART Revisited

You wrote a UART driver last lab, but you only used it for output. Now it becomes an input device, and it is the simplest possible instance of the pattern every other device in this lab uses.

The UART is on APLIC IRQ 10, so unlike the PCI devices, it does go through the APLIC, which in turn writes an MSI. This means you need to configure the sourcecfg and target registers in the APLIC.

What is new:

  1. Register a UART handler in the IRQ registry for EIID 10.

  2. Enable receive interrupts in the UART’s IER. Bit 0 is "data ready".

  3. In the handler, pop bytes from RBR and push them into a ring buffer.

  4. Provide a function that pops from the ring buffer, returning None when it is empty.

The ring buffer is the interesting part, and it is the same problem as the input device’s event buffer at a smaller scale. It is written by an interrupt handler and read by ordinary kernel code, so it is shared mutable state across a concurrency boundary. It must be bounded. When it is full you drop characters.

Drain the UART in a loop inside the handler and keep reading while LSR says data is ready. The 16550 has a FIFO, and if you take one byte per interrupt with a fast typist or pasted input, you will fall behind and the FIFO will overrun.
Do not println! from inside the UART receive handler using the same UART. It works right up until it does not, and then you have a hard-to-diagnose reentrancy problem. Buffer the character and print it from ordinary kernel code.

RTC Revisited

Likewise, the RTC is on APLIC IRQ 11, and you already have a working RealTime and GoldFishRtc from the last lab.

Reuse the API you already wrote. Do not refactor it to match any naming in this document. If your method is get_clock_hours() and this write-up says something else, keep yours and record it in the self-assessment.

What is new:

  1. Register an RTC handler in the IRQ registry for EIID 11.

  2. In the handler, acknowledge the alarm and then keep it disabled.

The Goldfish alarm is one-shot: it fires, and unless you clear the interrupt and set a new alarm, you will never hear from it again. And if you clear the alarm but do not acknowledge the interrupt, a level-triggered source will re-assert immediately and you will interrupt forever. Both halves are required, and the order matters.

A periodic RTC interrupt is the foundation of a tickless kernel instead of a fixed timer tick, you schedule the next interrupt for exactly when you next need one. We are not building that here, but re-arming the alarm is the mechanism that would make it possible, which is why it is in the rubric.

Wait Queues

Every device in this lab completes asynchronously. You submit a request and the answer arrives later, in an interrupt handler, in a completely different call stack, with none of the context you had at request submission time. The used ring hands you a bare descriptor index and a byte count. That is all it tells you. It does not tell you who asked, what they asked for, where the answer should go, or what should happen next.

Polling hides this problem. When you spin on the device ring index, the code that submitted the request is still on the stack, still holding its local variables, and can just carry on. That is exactly why polling is the right way to start and exactly why deleting the poll loop is a bigger change than it looks.

A wait queue is the structure that carries context across that gap. At request submission time you record everything the eventual completion will need, keyed by something the completion will hand you. At completion time you look it up and finish the job.

The natural key is the head descriptor index of the chain, possibly paired with the device or queue, since the used ring gives you exactly that.

Design Sketch
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/// What kind of work a wait queue entry represents.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
pub enum RequestType {
    Block,
    Entropy,
    Gpu,
    KeyboardInput,
    TabletInput,
    Rtc,
    Timer,
    Uart,
}

/// One unit of work in flight.
///
/// `device` is scratch space for the driver that owns the request;
/// `requester` is scratch space for whoever asked for it. For example,
/// `requester[0]` might be a PID and `requester[1]` a destination address,
/// while `device[0]` holds the head descriptor index.
///
/// Fixed-size arrays keep this Copy and heap-free, at the cost of being
/// completely untyped. That is a real tradeoff. (See below)
#[derive(Debug)]
pub struct WaitQueueEntry {
    pub device: [usize; 8],
    pub requester: [usize; 8],
    pub request_type: RequestType,
}

An entry is created at submission, not at completion. The sequence:

  1. Build the descriptor chain. Note the head index.

  2. Create a WaitQueueEntry describing the request, keyed by (device, head index).

  3. Insert it into the wait queue.

  4. Then publish to the available ring and notify.

That order is not arbitrary. Once you bump the available index, the device may complete before your next instruction retires. If the entry is not in the queue yet, the completion handler will find nothing and drop the response.

At completion, in the IRQ handler:

  1. Drain the device ring.

  2. For each used element, take the head index and look up the entry.

  3. Dispatch on request_type: copy the block data to its destination, stash the random bytes, push the input event, mark the GPU command complete.

  4. Free the descriptor chain and drop the buffers.

  5. Remove the entry.

I have deliberately given you the least-typed possible design above. Two untyped [usize; 8] arrays will absolutely work, and they are heap-free and Copy, which has real value in a kernel. They are also completely opaque: nothing stops you from reading requester[3] when you meant requester[2], and the compiler cannot help you. An enum carrying per-variant payloads: Block { pid, dest, buffer }, Entropy { dest }. It costs you a match and gives you exhaustiveness checking. Decide which tradeoff you want and be ready to defend it. The rubric asks you to describe your design, and this is the part I will be reading.

Looking Ahead

In the next lab, processes enter the picture, and this structure becomes load-bearing. A process issues a read, gets put in the WAITING state, and the scheduler runs something else. When the block device interrupts, your handler finds the wait queue entry, copies the data into the process’s address space, and sets the process back to RUNNING.

That is the point of the requirement that you can match a PID to completed data. Right now there are no PIDs and it looks like over-engineering. It is much easier to add the field now than to retrofit the concept later.

Troubleshooting

In the spirit of the last lab’s "can’t hear IRQs" section.

Nothing Enumerates

Symptom Likely cause

Every slot reads VendorId == 0xFFFF

Wrong ECAM base. Re-check MCFG parsing, and check you converted the physical address to a virtual one.

Only the first bus has devices

You are not iterating to the end bus number, or you treated it as exclusive. It is inclusive.

Garbage vendor IDs

ecam_offset shift amounts. Bus is << 20, slot is << 15.

Faults on the first ECAM read

ECAM space is not mapped, or is mapped without the IO memory flag.

Device Found, But Unusable

Symptom Likely cause

BAR reads are all 0xFFFF_FFFF

Memory Space Enable (command bit 1) is clear.

A BAR address is nonsense

You read the high half of a 64-bit BAR as if it were its own BAR, or you did not mask the low 4 flag bits.

Capability walk hangs

Unbounded loop on a malformed or cyclic list, and you did not mask the low 2 bits of next.

No capabilities found

Status register bit 4 is clear — or you read the wrong byte offset. Status is at 0x06.

Only one vendor-specific capability found

You stopped at the first 0x09. There are several.

Device Configured, Nothing Happens

Symptom Likely cause

FEATURES_OK does not stay set

You accepted a feature the device did not offer. AND your mask with the device’s offer.

Device works but layout is wrong

VIRTIO_F_VERSION_1 (bit 32) not accepted — you negotiated only feature window 0.

Used ring index never moves

Bus Master Enable (command bit 2) is clear. Check this first, always.

Used ring index never moves, BME is set

You wrote virtual addresses into the queue registers or descriptors. translate() them.

-d guest_errors complains about status

You wrote DRIVER_OK before FEATURES_OK. The order is 1, 2, 8, 4.

Device ignores the notify write

Wrong notify address — check the offset multiplier, and that you read queue_notify_offset after selecting the queue.

Works once, then stalls

You masked the available ring index, or you are not reclaiming descriptors.

Polling Works, Interrupts Do Not

This is the good failure mode, and it means your queue is correct and the problem is confined to interrupt delivery.

Symptom Likely cause

No interrupt, ever

MSI-X vector still masked. vector_ctrl bit 0 is set at reset; clear it.

No interrupt, ever

MSI-X enable (message control bit 15) not set, or function mask (bit 14) not cleared.

No interrupt, ever

queue_msix_vector still 0xFFFF, which means "do not interrupt."

No interrupt, ever

EIID not enabled in the IMSIC eieX registers. The registry is not the hardware gate.

Interrupt arrives, handler does nothing

EIID not in your IRQ registry, or registered under a different number than you programmed into the MSI-X table.

claim() returns 0

Spurious, or eithreshold is filtering your EIID. Recall the threshold is a wall: EIIDs at or above it are not heard.

Interrupt once, then never again

You did not drain the used ring in a loop. Completions coalesce.

Faults inside the handler

You are writing MMIO without volatile, or your handler is not reentrancy-safe.

If you are stuck here, go back and redo milestone 2, and write an EIID directly into the interrupt file. If that still fires, the IMSIC side is fine and the bug is in the MSI-X table. If it does not, you have regressed something in your last-lab code.

Block Device Specific

Symptom Likely cause

Status is Unsupported (2)

Length is not a multiple of 512, or a request type whose feature you did not negotiate.

Status is IoError (1)

Sector beyond capacity, or a malformed chain.

Status is still your sentinel

The device never wrote it. DESC_WRITE is not set on the status descriptor, or BME is clear, or the chain is broken.

Read returns all zeros

DESC_WRITE is clear on the data descriptor. The device read your empty buffer instead of writing to it.

Write does nothing

DESC_WRITE is set on the data descriptor. The device tried to write where it should have read.

Data is right but shifted

Sector arithmetic. You are not accounting for the offset of your requested range inside the aligned range.

Works for one request, corrupts on many

Descriptor reclamation. You are handing out descriptors that are still in flight.

GPU Specific

Symptom Likely cause

"Guest has not initialized the display" forever

Missing RESOURCE_FLUSH. Transfer alone is not enough.

Black screen, all commands succeeded

Missing SET_SCANOUT, or you drew before attaching the backing.

ERR_INVALID_RESOURCE_ID

resource_id of 0, or referring to a resource you never created.

ERR_INVALID_PARAMETER on attach backing

num_entries does not match the number of MemEntry values you actually sent.

Image sheared diagonally

Transfer offset computed with rect.width instead of the full framebuffer width.

Only the first page of the image is right

You sent one MemEntry for a multi-page framebuffer. You need one per contiguous run.

Text mirrored

Font bit order. Flip 1 << j and 1 << (7 - j).

Extremely slow redraw

You are transferring the whole screen instead of the dirty rectangle.

Input Specific

Symptom Likely cause

No events, ever

Event queue was not pre-posted before DRIVER_OK.

Exactly N events, then silence

You pre-posted N descriptors and never reposted them.

Events are all zeros

DESC_WRITE clear on the event descriptors.

Coordinates are wildly out of range

You did not scale from AbsInfo min/max to screen pixels.

Heap exhausted after a minute

Unbounded event buffer. Bound it and drop.

Submission

Rubric Self Assessment

Please make sure that you do the self assessment on Canvas. The self assessment is at the top of the assignment as a markdown (.md) file.

You will need to mark each of the following and where I can find your implementation. The rubric is broken down by file.

Where this rubric names a structure or method, it is naming a requirement, not mandating an identifier. If you designed something differently or named it differently, including methods you wrote in a previous lab, check the box and record your actual name in the self-assessment. Do not refactor working code to match my wording.

Rubric Criteria

The files below are in alphabetical order, not in the order you should write them. See Milestones and Testing Strategy.
  1. src/block.rs — VirtIO block driver

    1. Fully commented code describing your understanding of all the code.

    2. Enable MSI-X interrupts for block devices.

    3. All block request/response structures.

      1. Request header.

      2. Status, initialized to a sentinel so that "no response" is distinguishable from "success".

      3. A structure holding the requested data and all buffers.

        1. Implement Drop so all buffers are freed when the request completes.

    4. Three-descriptor chain constructed correctly: header, data, status.

    5. DESC_WRITE used as a direction on the data descriptor, not as a permission.

    6. IRQ handler registered with the IRQ registry.

    7. Your block driver must be able to handle multiple requests asynchronously in flight.

  2. src/data.rs

    1. Device registry and IRQ registry. The IRQ registry maps an EIID to the device that should receive it.

    2. Add wait queues to the global kernel data. You may design these as you see fit, but they must:

      1. Track work in flight asynchronously.

      2. Carry enough information about a completion to finish a waiting job.

      3. Match a process PID with its completed data. When we write processes, a process will be placed in WAITING until its data arrives, then moved back to RUNNING.

    3. Briefly describe your wait queue design and how an entry is keyed.

  3. src/entropy.rs — VirtIO entropy driver

    1. Fully commented code describing your understanding of all the code.

    2. Enable MSI-X interrupts for entropy devices.

    3. IRQ handler registered with the IRQ registry.

  4. src/gpu.rs — VirtIO GPU driver

    1. Fully commented code describing your understanding of all the code.

    2. Enable MSI-X interrupts for GPU devices.

    3. IRQ handler registered with the IRQ registry.

    4. Pixel structure in RGBA byte order.

    5. Framebuffer structure and methods.

    6. Drawing primitives.

      1. draw_line

      2. fill_rect

      3. stroke_rect

      4. draw_char

      5. draw_str

    7. Display info retrieved and dimensions used.

    8. 2D resource created and backing attached, with one memory entry per contiguous run.

    9. Scanout set.

    10. Transfer-to-host and resource flush both implemented.

      1. Merge physically contiguous pages.

    11. Your GPU should be drawable.

      1. If I add drawing code to your main, I should see it.

  5. src/input.rs — VirtIO input driver

    1. Fully commented code describing your understanding of all the code.

    2. Enable MSI-X interrupts for input devices.

    3. IRQ handler registered with the IRQ registry.

    4. Event queue pre-posted with buffers before the device goes live.

    5. Descriptors reposted after each event is consumed.

    6. Keyboard events (EV_KEY) handled.

    7. Tablet / absolute position events (EV_ABS) handled.

    8. Bounded local event buffer — events dropped when full, never unbounded growth.

  6. src/main.rs

    1. Initialization of all PCI devices.

    2. Initialization of all VirtIO devices.

    3. Registration of all device IRQ handlers.

      1. UART at IRQ 10.

      2. RTC at IRQ 11.

      3. PCI devices assigned EIIDs from 100 - 110, first-come first-served.

    4. UART, driven from initialization:

      1. UART IRQ handler added to the IRQ registry.

      2. UART IRQs enabled and used to read keyboard input.

      3. Input popped from UART[RBR] and pushed to a ring buffer.

      4. A function to pop from the ring buffer, returning None when empty.

    5. RTC, driven from initialization:

      1. RTC IRQ handler added to the IRQ registry.

      2. Alarm acknowledged and re-armed inside the handler.

      3. Nanosecond time available, reusing the RealTime API from the previous lab.

  7. src/pci.rs — PCI bus driver

    1. Fully commented code describing your understanding of all the code.

    2. ecam_offset compile-time function for a (bus, slot).

    3. ECAM base and bus range obtained from the MCFG ACPI table, treating the end bus as inclusive.

    4. Device presence and header type checked.

    5. get_bar(x) returning the MMIO address of a given BAR.

    6. 64-bit BARs handled — two slots combined, low flag bits masked.

    7. Capability list walked, with the status bit checked and a bounded loop.

    8. Command register: Memory Space Enable and Bus Master Enable set.

    9. Legacy Interrupt Disable set so that MSI-X is used.

    10. Handoff to the VirtIO code for vendor 0x1AF4.

  8. src/rtc.rs — Goldfish Real Time Clock

    1. Fully commented code describing your understanding of all the code.

    2. Configure the ALARM to interrupt via EIID #11.

    3. Write and register an IRQ handler function.

      1. When the IRQ fires, clear the ALARM.

    4. Write a function that allows the kernel to set a new alarm.

  9. src/uart.rs — NS6550A UART

    1. Fully commented code describing your understanding of all the code.

    2. Create a ring buffer for UART data.

      1. You will need a global pop and a local push.

      2. If the ring buffer is full, discard the newest value (i.e., saturating_push)

    3. Configure the UART to send interrupts via the IER register through the APLIC using EIID #10.

    4. Write and register an IRQ handler function.

      1. Interrutps will read from the RBR and push the character to the ring buffer.

      2. Drain the RBR before returning by looking at the line status register (LSR)

  10. src/virtio.rs — Common API for VirtIO devices

    1. Fully commented code describing your understanding of all the code.

    2. VirtIO structures.

      1. Descriptor

      2. DriverRing (available ring)

      3. DeviceRing (used ring)

      4. Queue superstructure

        1. You are implementing a split queue, so the descriptor table, driver ring, and device ring each get their own memory.

    3. PCI capabilities reader for MSI-X (0x11) and vendor specific (0x09).

      1. Vendor specific must handle common configuration (type 1), notify (type 2), ISR (type 3), and device specific (type 4). Type 5 is not required.

    4. MSI-X table programmed with the IMSIC interrupt file address and an EIID, and the per-vector mask cleared.

    5. Device initialization sequence, including the FEATURES_OK read-back check.

    6. VIRTIO_F_VERSION_1 negotiated.

    7. Handoff to specific device code (entropy, block, GPU, input).

      1. The handoff enumerates the descriptor table, driver ring, and device ring, allocating the pages required.

Make sure you enable IRQs through the entire chain: sstatus, sie, the IMSIC, the APLIC, and the device itself. PCI MSI-X writes bypass the APLIC, but you still must enable the EIID in the IMSIC, and you still must clear the MSI-X per-vector mask.