Skip to content

Practice Set 1

Distributed: Tuesday, October 6 · Solutions posted: Tuesday, October 13 · Prepares for: Midterm 1, Thursday, October 15.

Midterm 1 Practice Problems (PDF, 21 pages) · Solutions (PDF, 21 pages): the practice exam handed out in class, covering 00r_hello_rust through 12c_wc, and its answer key. Try the problems before you open the key.

Solution videos, one per problem, each working the answer key on the exam's own pages: Problem 1 · Short Questions (11:09) · Problem 2 · Process States (8:00) · Problem 3 · Scheduling Policies as Traits (7:27) · Problem 4 · Streaming with O(1) State (7:14) · Problem 5 · The Commands (8:18).

Midterm 1 Practice Problems 2 (PDF, 24 pages) · Solutions (PDF, 24 pages): a second practice exam, covering Weeks 6 and 7, 20a_asm_bridge through 33k_paging, and its answer key. Each problem prints the code and the reference facts it uses, so nothing in it has to be memorized. Try the problems before you open the key.

Solution videos, one per problem, each working the answer key on the exam's own pages: Problem 1 · Short Questions (11:27) · Problem 2 · The Assembly Bridge (12:13) · Problem 3 · From Reset to kmain (11:42) · Problem 4 · The Free List (10:36) · Problem 5 · Sv39 Page Tables (14:03).

This is the one piece of CS 326 work that happens outside the room, and it is deliberately pencil and paper, not programming — nothing to write, run, or submit. That is what makes it compatible with the Integrity Policy: the exercises are protected by being worked in the room, and a problem about Sv39 bit positions puts none at risk.

It is ungraded. Work it before the solutions go up on October 13, and bring what you got wrong to office hours or to that Tuesday's lecture.

Work it the way you will sit the exam. On paper, closed book, one reference open: the Cheatsheet, which you may print. No laptop, no QEMU, no calculator — every number here falls out of shifts and masks. The point is finding out which of these you cannot do without a compiler checking you. See Exam Prep for the three question shapes; each is labeled below.

Scope: all of Module 1, plus kernel exercises 30k–33k. Part F draws on the Oct 13 lecture (the process control block), which is lecture material before the exam even though 34k_processes itself is worked after it.


Part A — Rust semantics

Problem 1: What survives a move

(Trace it.) For each numbered line, say whether it compiles. If it does not, name the binding that was moved and where. Assume Vec is available.

let pages: Vec<usize> = vec![0x8004_1000, 0x8004_5000];
let count = pages.len();          // 1
let taken = pages;                // 2
let again = pages.len();          // 3
let n = count;                    // 4
let m = count;                    // 5

fn consume(v: Vec<usize>) -> usize { v.len() }
let k = consume(taken);           // 6
let j = consume(taken);           // 7
Click to reveal solution
Line Compiles? Why
1 yes len takes &self; a borrow is not a move
2 yes ownership of the heap buffer moves from pages to taken
3 no pages was moved on line 2 — error[E0382]: borrow of moved value
4 yes count is a usize, which is Copy
5 yes copying does not consume the original, so it can be copied again
6 yes passing by value moves taken into the parameter v
7 no taken was moved on line 6

The rule in one sentence: a non-Copy value has exactly one owner, and assignment or passing by value transfers it. A Vec owns a heap buffer, so two bindings would mean two drops — a double free. A usize owns nothing, so the bits are just copied.

The common wrong answer is that line 3 is fine "because len only reads it," which confuses what the method does with whether the value still exists. This is the discipline freeproc upholds by hand — one owner of the page table, freed once — except that there the compiler is not watching, because the table is a static mut reached through raw pointers.

Problem 2: Find the borrow error

(Explain it.) This will not compile. Say which rule it breaks, quote the two conflicting borrows, and fix it without cloning and without unsafe.

fn install(entries: &mut [usize], src: usize, dst: usize) {
    let from = &entries[src];
    let slot = &mut entries[dst];
    *slot = *from | 1;
}
Click to reveal solution

The aliasing rule: at any moment you may have either any number of &T, or exactly one &mut T, never both. Line 2 takes a shared borrow of entries; line 3 takes a mutable borrow of the same slice while the first is still live (it is used on line 4). error[E0502]: cannot borrow *entries as mutable because it is also borrowed as immutable.

The fix is to end the shared borrow before the mutable one begins, by copying out the value — which is free, because usize is Copy:

fn install(entries: &mut [usize], src: usize, dst: usize) {
    let from = entries[src];      // a usize, not a reference
    entries[dst] = from | 1;
}

What the compiler is protecting you from is not hypothetical: if src == dst the original reads and writes one slot through two paths — harmless here, a real bug in a walk where from is re-read after slot is written.

The common wrong answer is entries[src].clone(). It compiles, but clone on a Copy type is a copy with extra syllables, and reaching for it hides that you never needed a reference at all.

Problem 3: Option, Result, and exhaustive match

(Explain it.) walkaddr signals "not mapped" by returning physical address 0; mappages signals failure with Result<(), ()>.

(a) Why can walkaddr get away with 0 as a sentinel, and what would Option<usize> buy?

(b) ProcState has five variants. A student writes:

match p.state {
    ProcState::Unused => 0,
    ProcState::Runnable => 1,
    ProcState::Running => 2,
    _ => 3,
}

What does the _ arm cost them, concretely, when a sixth state is added?

(c) The process table is [Proc; NPROC], a fixed array of 64. Give two reasons a kernel prefers this to a Vec<Proc>.

Click to reveal solution

(a) RAM starts at KERNBASE = 0x8000_0000, so no page the allocator hands out lies at 0: the value is unrepresentable as a success and can safely mean failure. Option<usize> would put that in the type — None cannot be mistaken for an address, and the compiler would force every caller to handle it, where today a caller can forget the if pa == 0 check. rv6 keeps the sentinel because it is copying xv6's C.

(b) The _ arm makes the match compile forever. Add a sixth state and every exhaustive match becomes a compile error pointing at the line to update — that is the feature — but this one silently classifies it as 3. Exhaustiveness is worth something only if you refuse to opt out.

(c) First, there is no heap to grow into: Vec needs an allocator, and the allocator is something the kernel provides. Second, predictability — 64 × 40 bytes = 2,560 bytes of .bss fixed at compile time, no allocation on any path that creates a process, and a knowable limit instead of an out-of-memory failure at an awkward moment. The cost is exactly that limit: allocproc returns null at 64.


Part B — The numbers the kernel runs on

Problem 4: Hex, binary, and overflow

(Decode it.) No calculator.

(a) Write 0xB7 in binary, and say which of V, R, W, X, U a PTE with those low bits would have set (V=1, R=2, W=4, X=8, U=16).

(b) let x: u8 = 200; let y = x + 100; — what happens in a debug build, and what happens in a release build?

(c) Give the values of x.wrapping_add(100) and x.checked_add(100).

(d) 0x8004_2000 is how far above KERNBASE, in bytes and in pages?

Click to reveal solution

(a) 0xB = 1011, 0x7 = 0111, so 0xB7 = 0b1011_0111 = 183. Low five bits 1_0111: V set, R set, W set, X clear, U set — so V | R | W | U, a writable, non-executable user page. (Bits 5 and 7 are also set, G and D; rv6 never sets them.)

(b) 300 does not fit in a u8 (max 255). Debug: the overflow check is compiled in and the program panics — attempt to add with overflow. Release: the check is compiled out and the value wraps to 300 − 256 = 44. Same source, two behaviors, and you ship the release profile.

(c) wrapping_add(100) = 44 in both profiles — the intentional version of the release behavior. checked_add(100) = None, since it returns Option<u8>.

(d) 0x8004_2000 − 0x8000_0000 = 0x4_2000 = 270,336 bytes. Dividing by PGSIZE is a shift: 0x4_2000 >> 12 = 0x42 = 66 pages. Dropping three hex digits is dividing by 4096 — do that, never long division.

Problem 5: Alignment, and where free memory starts

(Trace it.) The linker places the end symbol at 0x8002_9C10. PGSIZE = 0x1000, PHYSTOP = 0x8800_0000.

fn pgroundup(addr: usize) -> usize { (addr + PGSIZE - 1) & !(PGSIZE - 1) }

(a) Compute pgroundup(end) and pgrounddown(end), showing both steps.

(b) free_range loops while p + PGSIZE <= stop. Give the first and last physical addresses passed to kfree, and the number of pages the loop builds.

(c) Sanity-check (b) against the size of RAM.

(d) Why does this pgroundup idiom require PGSIZE to be a power of two?

Click to reveal solution

(a)

pgroundup(0x8002_9C10) = (0x8002_9C10 + 0xFFF) & !0xFFF
                       =  0x8002_AC0F & 0xFFFF_F000
                       =  0x8002_A000
pgrounddown(0x8002_9C10) = 0x8002_9C10 & !0xFFF = 0x8002_9000

(b) The first kfree is at 0x8002_A000. The loop stops when a whole page no longer fits below PHYSTOP, so the last page freed starts at 0x8800_0000 − 0x1000 = 0x87FF_F000.

(0x8800_0000 − 0x8002_A000) >> 12 = 0x07FD_6000 >> 12 = 0x7FD6 = 32,726 pages

(c) RAM is 128 MiB = 0x0800_0000 bytes = 0x8000 pages = 32,768. The kernel image occupies 0x8002_A000 − 0x8000_0000 = 0x2_A000 = 0x2A = 42, and 32,768 − 42 = 32,726. The two agree — compute every answer twice, from opposite ends.

(d) !(PGSIZE − 1) is a clean low-bit mask only when PGSIZE − 1 is a run of 1s — that is, when PGSIZE is a power of two. The other half matters too: adding PGSIZE − 1 can never push an already-aligned address past its own boundary, so aligned addresses are fixed points — pgroundup(0x8002_A000) = 0x8002_A000.

The common wrong answer to (b) is 0x8800_0000. Read the loop condition: freeing a page that starts at PHYSTOP would hand out memory that does not exist.


Part C — RISC-V and the calling convention

Problem 6: Trace a byte-summing routine

(Trace it.) Memory at 0x8004_0000 holds the bytes 10 20 F0, and the routine is called as sum_bytes(0x8004_0000, 3).

.globl sum_bytes
sum_bytes:                 # a0 = ptr, a1 = n  ->  a0 = sum
    li   t1, 0
1:                         # A
    lb   t0, 0(a0)
    add  t1, t1, t0
    addi a0, a0, 1
    addi a1, a1, -1
    bnez a1, 1b            # B
    mv   a0, t1
    ret                    # C

(a) Give a0, a1, t0, t1 at A on the first iteration, at B on each of the three iterations, and at C.

(b) What does the function return?

(c) The routine never touches ra or sp, and has no prologue. Why is that legal?

Click to reveal solution

(a) The trap is lb: it sign-extends. 0xF0 has its top bit set, so it loads as 0xFFFF_FFFF_FFFF_FFF0 = −16.

Point a0 a1 t0 t1
A (iter 1) 0x8004_0000 3 — 0
B (iter 1) 0x8004_0001 2 0x10 0x10
B (iter 2) 0x8004_0002 1 0x20 0x30
B (iter 3) 0x8004_0003 0 0xFF..FF F0 0x20
C 0x20 0 0xFF..FF F0 0x20

(b) 0x20 = 32. Arithmetic: 16 + 32 + (−16) = 32.

The common wrong answer is 0x120 = 288, the bytes summed as unsigned — what lbu would give, and what almost everyone writes first. lb/lh/lw sign-extend; lbu/lhu/lwu zero-extend. A byte you meant as data is loaded with lbu.

(c) Every register it uses — a0, a1, t0, t1 — is caller-saved, so the caller has already spilled anything it cared about in them. A leaf function that stays inside those owes nobody a prologue, an epilogue, or a frame, and ret jumps to the ra it was called with, untouched.

Problem 7: The routine that clobbers s0

(Explain it.) A classmate rewrites the loop above using s0 as the accumulator instead of t1, changing nothing else. Their unit test passes, but called from a larger Rust function the kernel produces wrong results somewhere else entirely.

(a) What exactly did they break?

(b) Why did the unit test pass?

(c) Give the two instructions to add at the top and the two at the bottom that make it correct, and say what happens to sp.

Click to reveal solution

(a) s0–s11 and sp are callee-saved: a function that uses one must restore it. Theirs writes s0 and returns. The Rust caller had a live value there — its frame pointer, or a variable the compiler kept across the call precisely because s0 survives calls — and resumes with garbage, so the corruption surfaces arbitrarily far from its cause.

(b) The test calls it from a tiny wrapper with nothing live in s0. The bug is not "does it compute the right sum" — it does — but "does it honor the contract", and a test that checks only the return value cannot see that.

(c)

    addi sp, sp, -16       # make room (16, not 8: sp stays 16-byte aligned)
    sd   s0, 0(sp)         # save the caller's s0
    ...
    ld   s0, 0(sp)         # restore it
    addi sp, sp, 16        # give the room back
    ret

sp must be back to its entry value at ret — sp is itself callee-saved — and stay 16-byte aligned throughout, which is why the frame is 16 bytes for one 8-byte register. The same contract is why swtch saves exactly ra, sp, and s0–s11, the 14 callee-saved registers and only those: the convention has already guaranteed nothing else was worth preserving.


Part D — Bare metal and boot

Problem 8: no_std and volatile

(Explain it.) For each, one or two sentences.

(a) Which of these still work under #![no_std]: Option, Result, slices, iterators, Vec, String, Box, println!, PanicInfo?

(b) Why does #![no_std] force you to write a #[panic_handler], and why is its return type !?

(c) uart::putc is write_volatile(UART0 as *mut u8, c). Rewrite it with a plain *p = c and say precisely what the optimizer is allowed to do.

(d) Creating a raw pointer is safe; dereferencing one is unsafe. Why is that split the right place to draw the line?

Click to reveal solution

(a) core keeps Option, Result, slices, iterators, and PanicInfo — none need an OS. Gone are Vec, String, and Box (a heap allocator) and println! (stdout, which is a file, which is an OS service). Vec and Box return later via alloc, once we have written the allocator alloc needs.

(b) std supplies the panic handler; without it nothing defines what "panic" means, and the compiler demands exactly one function that does. The never type ! promises it does not return — honest, since there is no caller to go back to and no way to unwind. rv6 spins.

(c) unsafe { *(UART0 as *mut u8) = c; } writes a byte to 0x1000_0000 and never reads it back, so the optimizer may conclude the write is dead and delete it, or merge or reorder several such writes. For memory that is correct; for a device register the write is the effect, and the order of the writes is the order of the characters. write_volatile says: this access has a side effect you cannot see — perform it exactly once, exactly here.

(d) The address arithmetic is inert: 0x1000_0000 as *mut u8 computes a number and corrupts nothing. The dereference is where a claim about the world is made — that the address is mapped, aligned, and not illegally aliased — so putting unsafe there confines the audit to the lines that claim something. Note what unsafe does not do: it disables neither borrow checking nor type checking. It adds one power, dereferencing raw pointers (plus calling unsafe fns, reading static muts, and extern calls).

Problem 9: From reset to kmain

(Order it.) The linker gives this symbol table, and STACK_SIZE is 4096 * 4.

  0x8000_0000   _entry
  0x8000_0018   start
  0x8000_1A2C   kmain
  0x8002_5000   STACK0
  0x8002_9C10   end

(a) Put these in order and name the constraint that fixes each position: call start · QEMU loads the kernel ELF · add sp, sp, t0 · the machine jumps to 0x8000_0000 · la sp, STACK0 · li t0, 0x4000.

(b) What is sp when start begins?

(c) _entry carries #[link_section = ".entry"]. What in kernel.ld makes that matter, and what breaks without it?

(d) Why must sp be set before call start, and not inside start?

(e) The stack occupies 0x8002_5000–0x8002_9000, which is below end. Why is that essential, given Problem 5?

Click to reveal solution

(a)

# Step What forces its position
1 QEMU loads the kernel ELF -bios none: no firmware, no bootloader — QEMU places the image at the link address
2 the machine jumps to 0x8000_0000 the RAM base on virt, and the reset PC
3 la sp, STACK0 first instruction of _entry; nothing may use a stack before it
4 li t0, 0x4000 STACK_SIZE; t0 is caller-saved scratch
5 add sp, sp, t0 stacks grow down, so sp must start at the top
6 call start only now is there a stack for Rust to build a frame on

Steps 3 and 4 are interchangeable — t0 and sp are independent until step 5 — but nothing may move after step 6.

(b) sp = 0x8002_5000 + 0x4000 = 0x8002_9000.

(c) kernel.ld sets the location counter to 0x80000000 and opens .text with *(.entry) before *(.text .text.*), putting _entry's first instruction exactly at the address the machine jumps to. Without the attribute _entry lands wherever the linker likes, and the machine jumps into whatever sorted first — with sp still garbage.

(d) call is harmless; it writes a register. But start is compiled Rust, and its first instructions are a prologue: addi sp, sp, -N then sd ra, (sp). With reset garbage in sp that store goes to an arbitrary address. You cannot fix sp from inside a function whose prologue has already used it — which is the whole reason the trampoline is hand-written assembly.

(e) kalloc::init starts the free list at pgroundup(end) = 0x8002_A000 and never hands out anything below it. STACK0 is an ordinary .bss object inside the kernel image, so the boot stack is excluded automatically. Placed above end, it would be handed out — the page the kernel is running on.


Part E — Physical memory and Sv39

Problem 10: Complete the free list

(Trace it.) The free list starts in this state:

  FREELIST ──▶ [ 0x8004_5000 ]──▶ [ 0x8004_1000 ]──▶ [ 0x8004_9000 ]──▶ null
let a = kalloc();   // (i)
let b = kalloc();   // (ii)
kfree(a);
let c = kalloc();   // (iii)
kfree(b);
kfree(c);
let d = kalloc();   // (iv)

(a) Give the addresses returned at (i)–(iv).

(b) Redraw the final list, filling in the boxes:

  FREELIST ──▶ [ ________ ]──▶ [ ________ ]──▶ null

(c) After the last line, what is stored in the first 8 bytes of the page d points at, and why does that not matter?

Click to reveal solution

kfree pushes onto the front and kalloc pops from the front — a LIFO stack, not a queue.

Step Returns List afterwards
a = kalloc() 0x8004_5000 41000 → 49000
b = kalloc() 0x8004_1000 49000
kfree(a) — 45000 → 49000
c = kalloc() 0x8004_5000 49000
kfree(b) — 41000 → 49000
kfree(c) — 45000 → 41000 → 49000
d = kalloc() 0x8004_5000 41000 → 49000

(a) (i) 0x8004_5000 (ii) 0x8004_1000 (iii) 0x8004_5000 (iv) 0x8004_5000 — three of the four are the same page, because the most recently freed page is always the next allocated.

(b)

  FREELIST ──▶ [ 0x8004_1000 ]──▶ [ 0x8004_9000 ]──▶ null

(c) The first 8 bytes of 0x8004_5000 still hold 0x8004_1000 — the next pointer written by kfree(c), which kalloc read but never erased. Safe only because the list is intrusive: the pointer lives inside the page it describes, so once the page leaves the list nothing reads those bytes again. It is also why create_pagetable calls write_bytes(pt, 0, PGSIZE) — kalloc does not zero, and a page table full of stale pointers with bit 0 set would be a catastrophe.

The common wrong answer is a queue: (i) 45000, (ii) 41000, (iii) 49000. That is what a list with a tail pointer would do, and it is not what FREELIST = r does.

Problem 11: Encode and decode a PTE

(Decode it.) Pte::new(pa, flags) is Pte(((pa >> 12) << 10) | flags), and Pte::pa() is (self.0 >> 10) << 12.

(a) Build the leaf PTE for physical page 0x8009_C000 with R | W | V. Show each shift.

(b) Decode 0x2002_701F: flags, PPN, physical address, and a one-line verdict. Would rv6 ever build it?

(c) Decode 0x2002_7001. It has the same PPN. What is it?

(d) Why do the >> 12 and the << 10 not cancel?

Click to reveal solution

(a) Flags = 2 | 4 | 1 = 7.

0x8009_C000 >> 12  = 0x8_009C            (the PPN)
0x8_009C   << 10   = 0x2002_7000
             | 7   = 0x2002_7007

Shifting by 10 on paper: shift left two hex digits (<< 8), then double twice. 0x8009C → 0x8009C00 → 0x2002_7000.

(b) Flags = 0x2002_701F & 0x3FF = 0x01F = 0b1_1111 = V|R|W|X|U. PPN = 0x2002_701F >> 10 = 0x8_009C, physical address 0x8009_C000. Verdict: a user page that is writable and executable at once. rv6 never builds one — load_segment maps text R|X|U, map_user_stack maps the stack R|W|U. W and X together is the classic W⊕X violation: a program that can write its own code can be made to run anything.

(c) Flags = 1 = V only, with R, W, X all clear. That is not a leaf — it is an interior entry pointing at the next-level page table, which happens to sit at 0x8009_C000. This one bit pattern is how the hardware distinguishes "go deeper" from "you have arrived", and it is why the walk terminates.

(d) They are not inverses: >> 12 throws away the offset bits to leave a page number, and << 10 parks that number at bit 10, leaving bits 9:0 for the flags. The net shift is >> 2, and those two spare bits are why a 44-bit PPN and 10 flags fit in one 64-bit word. A PTE holds a page number, never an address.

Problem 12: Translate an address by hand (the hard one)

(Decode it.) This one is harder than anything else here, deliberately: it is the shape of the longest question on the midterm. Do not skip ahead.

satp holds 0x8000_0000_0008_0040. Dumping the reachable tables gives only these non-zero entries:

  table at 0x8004_0000:   [255] = 0x2001_1401
  table at 0x8004_5000:   [511] = 0x2001_1C01
  table at 0x8004_7000:   [510] = 0x2001_4407
                          [511] = 0x2001_240B

px(level, va) = (va >> (12 + 9 * level)) & 0x1FF.

(a) From satp, give the paging mode and the physical address of the root table.

(b) Translate virtual address 0x3F_FFFF_E2A8 to a physical address. Show VPN[2], VPN[1], VPN[0] and the offset.

(c) Name the two pages that entries [510] and [511] of the last table map, using the constants in memlayout.rs.

(d) walkaddr(root, 0x3F_FFFF_E2A8) returns 0, yet the MMU translates the address perfectly. Explain.

(e) How many physical pages does this page table occupy?

Click to reveal solution

(a) Mode is bits 63:60 = 0x8 = 8 = Sv39. The root PPN is the low 44 bits = 0x8_0040, so the root table is at 0x8_0040 << 12 = 0x8004_0000 — matching the first table in the dump.

(b) Split the address. 0x3F_FFFF_E2A8 is TRAPFRAME + 0x2A8:

Field Bits Value
VPN[2] 38:30 (va >> 30) & 0x1FF = 255
VPN[1] 29:21 (va >> 21) & 0x1FF = 511
VPN[0] 20:12 (va >> 12) & 0x1FF = 510
offset 11:0 0x2A8

Getting VPN[0] right is where people lose the question. va >> 12 = 0x3FF_FFFE; mask the low 9 bits: 0xFFE & 0x1FF = 0x1FE = 510, not 511. The address is one page below the top of the space.

Now walk:

root  0x8004_0000, entry [255] = 0x2001_1401
      flags 0x401 & 0x3FF = 0x001 = V only  -> a branch
      PPN = 0x2001_1401 >> 10 = 0x8_0045    -> next table 0x8004_5000

level-1 0x8004_5000, entry [511] = 0x2001_1C01
      flags = 0x001 = V only                -> a branch
      PPN = 0x8_0047                        -> next table 0x8004_7000

level-0 0x8004_7000, entry [510] = 0x2001_4407
      flags = 0x407 & 0x3FF = 0x007 = V|R|W -> a LEAF
      PPN = 0x2001_4407 >> 10 = 0x8_0051    -> frame 0x8005_1000

Physical address = frame + offset = 0x8005_1000 + 0x2A8 = 0x8005_12A8.

(c) TRAMPOLINE = MAXVA − PGSIZE = 0x3F_FFFF_F000 is index 511; TRAPFRAME = TRAMPOLINE − PGSIZE = 0x3F_FFFF_E000 is index 510. Entry [511] = 0x2001_240B has flags 0xB = V|R|X — code, not writable, not user: the trampoline. Entry [510] is V|R|W — data, not executable, not user: the trapframe. The permissions alone identify them.

(d) walkaddr is not the hardware. After walking it checks (*pte).flags() & PTE_U == 0 and returns 0 when the user bit is clear. The trapframe leaf is V|R|W with no U, deliberately: it is mapped in the user's page table but must be untouchable from user mode. So walkaddr — whose job is validating addresses a user program handed the kernel — correctly refuses it, while the MMU, running in supervisor mode, translates it on every trap. "Mapped" and "reachable by the user" are different questions, and this PTE is where they differ.

(e) Three: the root, the level-1 table, and the level-0 table (0x8004_0000, 0x8004_5000, 0x8004_7000). The frames they point at, 0x8005_1000 and 0x8004_9000, are mapped data, not part of the tree. Twelve KiB of tables to map two pages — the price of a sparse tree, and the reason mappings sharing a 2 MiB neighborhood are effectively free.


Part F — Processes

Problem 13: Complete the state machine

(Order it.) Here is the ProcState lifecycle with four edges left blank.

stateDiagram-v2
    [*] --> Unused: table cleared at boot
    Unused --> Runnable: ?
    Runnable --> Running: ?
    Running --> Runnable: ?
    Running --> Zombie: ?
    Zombie --> Unused: parent reaps the slot

(a) Label the four ? edges with the operation that performs each.

(b) Sleeping is one of the five variants but appears nowhere in the diagram. Why, and what would rv6 need in order to use it?

(c) For each, legal or not, and why: Zombie → Running · Unused → Running · Running → Unused.

Click to reveal solution

(a)

stateDiagram-v2
    [*] --> Unused: table cleared at boot
    Unused --> Runnable: allocproc claims a free slot
    Runnable --> Running: the scheduler picks this slot
    Running --> Runnable: proc_yield gives the CPU back
    Running --> Zombie: exit records xstate
    Zombie --> Unused: parent reaps the slot

(b) Nothing in rv6 ever writes ProcState::Sleeping — the variant is declared and never assigned. Using it needs a sleep(chan) that parks a process on a wait channel and a wakeup(chan) that returns every sleeper on that channel to Runnable. rv6 gets the same effect with yield-and-retry: correct, but a waiting process is rescheduled repeatedly only to find it still has nothing to do.

(c)

  • Zombie → Running: never. A zombie has exited and switched away for good, and its address space may already be freed. Running one would execute a process whose memory belongs to somebody else.
  • Unused → Running: no. allocproc produces Runnable; only the scheduler promotes Runnable → Running. The intermediate state matters — a fresh process has no initialized context yet, so it is not safe to switch into until something finishes building it. xv6 makes this explicit with a sixth state, USED: "slot claimed, process not yet built".
  • Running → Unused: only via teardown. The normal path is Running → Zombie → Unused, so the exit status survives long enough to be collected. A kernel-wide teardown that frees every non-Unused slot jumps directly — the exception, not the rule.

Problem 14: allocproc runs out of memory

(Find the bug.) The reference allocproc from exercise 34k. It passes all six checks.

pub unsafe fn allocproc() -> *mut Proc {
    for i in 0..NPROC {
        let p = ptr::addr_of_mut!(PROCS[i]);
        if (*p).state == ProcState::Unused {
            (*p).pid = alloc_pid();
            (*p).state = ProcState::Runnable;
            (*p).pagetable = create_pagetable();
            if (*p).pagetable.is_null() {
                return ptr::null_mut();
            }
            return p;
        }
    }
    ptr::null_mut()
}

(a) Describe the exact state of slot i when create_pagetable returns null.

(b) What does the next call to allocproc do, and what does the scheduler eventually do?

(c) Why does the exercise-04 test never catch this?

(d) The exercise-22 kernel replaces the early return with freeproc(p); return ptr::null_mut();. Say why that is the fix that generalizes, rather than just setting state = Unused.

Click to reveal solution

(a) The slot is left Runnable with a null page table, holding a freshly consumed pid — to every other part of the kernel, an ordinary process that is ready to run.

(b) The next allocproc scans for state == Unused, skips slot i, and the slot is lost for the rest of the boot. Worse, the scheduler finds a Runnable process and tries to run it — switching to a zero-filled context or dereferencing a null pagetable. An allocation failure has been converted into a crash somewhere else.

(c) The test allocates 64 processes out of 32,726 free pages, so create_pagetable never fails and the branch never executes. That is the ordinary condition of error paths in systems code: the branch you cannot easily provoke is the branch that is wrong, and reading it is the only way to test it.

(d) Setting state = Unused is enough in exercise 34k, where the page table is the only thing a Proc owns and it is null on this path. It stops being enough the moment allocproc acquires several resources — a page table, a trapframe page, a kernel stack, three console fds — because failure can then strike after some have succeeded. freeproc tolerates a half-built process (every field may still be null or zero), so one call unwinds however far construction got. Write one teardown function, make it tolerate partial state, and call it from every failure path. Inline cleanup is how the third resource gets leaked.


After you have worked it

Read the solutions only afterwards. A worked answer feels like learning and mostly is not — the value is in the twenty minutes you spent stuck. Bring your wrong answers to office hours; they are more useful than the right ones.

If a problem exposed a gap, reread the matching guide — Rust for Systems, Unsafe Rust and no_std, RISC-V, The Memory Map, Sv39 Paging, rv6 Architecture — then reread your own kalloc.rs, vm.rs, and proc.rs. You recover code you wrote faster than anything you merely read.