Skip to content

Practice Set 2

Handed out Tuesday, November 10 · Solutions posted Tuesday, November 17 · Preparation for Midterm 2, Thursday, November 19.

This set is ungraded, and it is the one piece of work in this course you do outside of class. The November 17 lecture doubles as the review: attempt these before it and arrive with specific questions.

Do it on paper, with no computer — that is the exam condition: closed book, by hand, one permitted reference, the Cheatsheet, printed. Wanting to run something in QEMU is the signal you do not yet understand it; write the prediction down first.

Each problem is labeled with its shape — Trace it, Decode it, Order it, Explain it — the same four the exam uses. Problem 8 is genuinely hard; budget twenty minutes.


Part A — Context switching and scheduling

Problem 1: A yield, end to end

(Trace it.) Process p calls proc_yield(p), which calls swtch(&p.context, &SCHED_CTX). Assume p.kstack == 0x8022_1000, and that sp is 0x8022_1FE0 when swtch is entered.

     process p (kernel side)                  swtch (assembly)
     ----------------------                   ----------------
  proc_yield(p)
    p.state = Runnable
    swtch(&p.context, &SCHED_CTX) ── A ──►  sd ra, 0(a0) … sd s11, 104(a0)
                                                          ── B ──
                                            ld ra, 0(a1) … ld s11, 104(a1)
                                                          ── C ──
                                            ret ──────────────► ??? (D)

Give a0, a1, ra, sp at A, B, C, and say where the ret at D lands. Then: the scheduler later calls swtch(&SCHED_CTX, &p.context). Where does that ret land, and with what sp?

Click to reveal solution
Point a0 a1 ra sp
A &p.context &SCHED_CTX into proc_yield, after the call 0x8022_1FE0
B unchanged unchanged unchanged unchanged
C unchanged unchanged SCHED_CTX.ra SCHED_CTX.sp

swtch never modifies a0 or a1; it only dereferences them. Between A and B nothing about the live CPU changes — 14 words have been stored to memory, that is all. Offsets run 0 to 104 because s11 is field 13, 13 × 8 = 104; the struct is 14 × 8 = 112 bytes.

D. The ret jumps to SCHED_CTX.ra: the instruction inside scheduler after its call to swtch. A function returns to a caller it never had, and it works only because sp moved with it, onto the stack where the scheduler's frame lives.

The second switch loads p.context.ra, written at point A, so that ret lands inside proc_yield with sp = 0x8022_1FE0 and p continues as if nothing happened. Every transition in rv6 is this double switch: process → scheduler → process, never process → process; the common wrong answer, "the ret returns to proc_yield", is true only of that second one.

Problem 2: Fourteen registers, and not one more

(Explain it.) Context has 14 fields: ra, sp, s0–s11. RISC-V has 32 general-purpose registers.

(a) Why save no t or a register — where do those values go? (b) ra is caller-saved in the ABI, yet swtch saves it. Why? (c) What breaks if you delete #[repr(C)]?

Click to reveal solution

(a) Before swtch's first instruction runs, the compiler has already spilled every caller-saved register it cared about onto the caller's stack, reachable through sp — which is one of the 14. Restore sp and they come back for free.

(b) ra is not preserved here, it is the resume point: swtch ends in ret, which jumps to ra, so loading ra from the new context is how control moves. It is the program counter in disguise — which is why init_context and ready set it to an entry function, letting a brand-new context "return" into code it has never run.

(c) The assembly hardcodes offsets (ra 0, sp 8, s0 16), and Rust may reorder fields freely — the compiler could put s7 where the assembly expects sp, and swtch would load a saved s7 into the stack pointer. No compile error and no clean crash: a jump to garbage on the first switch.

Problem 3: Round robin against the alternatives

(Trace it, then Order it.) RoundRobin::pick_next scans (0..n).map(|off| (self.next + off) % n).find(|&i| states[i] == Runnable), and sets self.next = (chosen + 1) % n.

(a) Three processes runnable at t = 0 needing 8, 4, 2 ticks; quantum 2; scan order P1, P2, P3. Give completion times and average turnaround, and compare with shortest-job-first. (b) A fourth slot holds a Sleeping process. Does round robin starve it, and is that the same thing as unfairness? (c) rv6 has timer interrupts but a cooperative scheduler. What one change makes it preemptive, and why was it left out?

Click to reveal solution

(a)

 t: 0---2---4---6---8--10------14
    P1  P2  P3  P1  P2  P1 (alone)

0–2 P1 (6 left); 2–4 P2 (2 left); 4–6 P3 done at 6; 6–8 P1 (4 left); 8–10 P2 done at 10; 10–14 P1 is the only Runnable slot, so pick_next returns it again and it finishes at 14 — the quantum expires at 12 but there is nobody to switch to.

All arrived at 0, so turnaround = completion: (14 + 10 + 6)/3 = 10 ticks; response 0, 2, 4 → average 2. SJF runs P3, P2, P1: completions 2, 6, 14, average 22/3 ≈ 7.33. SJF wins turnaround (optimal for a batch arriving together); round robin wins response, at 6 dispatches to SJF's 3.

(b) No. pick_next skips it because it is not Runnable, so it is not being passed over at all. Starvation means a runnable process whose selection can be deferred without bound, and round robin's rotating cursor makes that impossible: one full scan gives every runnable slot a turn. SJF starves long jobs whenever short ones keep arriving.

(c) One line in the timer branch: call proc_yield on a tick instead of counting it. It was left out because cooperative scheduling is deterministic — a wrong pick_next gives a wrong order, not a heisenbug once in fifty runs.


Part B — Concurrency and semaphores

Problem 4: The lock that is not a lock

(Explain it — find the bug.)

pub fn lock(&self) -> SpinLockGuard<'_, T> {
    while self.locked.load(Ordering::Acquire) {
        core::hint::spin_loop();
    }
    self.locked.store(true, Ordering::Release);
    SpinLockGuard { lock: self }
}

(a) Give an interleaving that breaks mutual exclusion. (b) rv6 uses compare_exchange(false, true, Acquire, Relaxed). What does test-and-set do differently, and why might you still want it?

Click to reveal solution

(a) The read and the write are two instructions, and another CPU fits between them:

   CPU A                     CPU B                  locked
   ----------------------    -------------------    ------
   load  -> false                                   false
                             load  -> false         false
   store true                                       true
                             store true             true
   returns a guard           returns a guard        <- BOTH hold it

Both hand out &mut T to the same data — exercise 37k's busy-flag race in atomic clothing. An AtomicBool does not help if you use it with two separate operations.

(b) Test-and-set is an unconditional atomic swap — write true, return the old value — so it always writes, and every spinner keeps stealing the cache line (hence test-and-test-and-set: spin on a plain load, then try the atomic). CAS writes only when it sees what it expected and reports what it saw, so it generalizes past booleans: "increment unless someone changed it" is a CAS loop, impossible with TAS. The Acquire on success pairs with the previous holder's Release in unlock, making their writes visible to you.

Problem 5: Two ways to deadlock a single CPU

(Explain it.) (a) Suppose console::intr and console::try_getc shared a spinlock. On one hart, construct the deadlock. (b) State the rule this implies, and why disabling interrupts while holding a spinlock enforces it. (c) One path takes FS then PROCS, another PROCS then FS. Show the deadlock, the standard fix, and how rv6 sidesteps (a) without any lock at all.

Click to reveal solution

(a) try_getc takes the lock; before it releases, a UART interrupt arrives. The hardware jumps to kernelvec → kerneltrap → console::intr, which spins on the same lock — forever, since only the interrupted try_getc can release it and it cannot resume until the handler returns. Structural, not a matter of waiting longer.

(b) A lock an interrupt handler may take must never be held with interrupts enabled. Disabling them for the critical section means the handler cannot run between acquire and release, so it can never find the lock held by the code it interrupted.

(c)

   CPU A                    CPU B
   lock(FS)     ok          lock(PROCS)  ok
   lock(PROCS)  spins  <->  lock(FS)     spins

Hold-and-wait plus a circular wait. The fix is a global lock order — always FS before PROCS — because a cycle is impossible if every edge points the same way. rv6 sidesteps (a) differently: BUF/HEAD/TAIL are a single-producer, single-consumer ring buffer, so the handler only advances TAIL and the reader only HEAD. Removing the need for a lock beats ordering one.

Problem 6: Semaphores, and the wakeup that got away

(Trace it, then Explain it.) A bounded buffer of N = 3 uses empty (3), full (0), mutex (1). Producer: P(empty); P(mutex); …put…; V(mutex); V(full). Consumer: P(full); P(mutex); …take…; V(mutex); V(empty).

(a) Trace empty and full through produce, produce, consume, produce, produce, produce. What invariant holds after each completed operation? (b) A producer swaps its first two lines to P(mutex); P(empty). Show the deadlock, and give the rule. (c) Explain the lost wakeup problem, and why rv6's try_wait cannot suffer it.

Click to reveal solution

(a)

Operation empty full sum
start 3 0 3
produce 2 1 3
produce 1 2 3
consume 2 1 3
produce 1 2 3
produce 0 3 3
produce blocks on P(empty) 3 —

The invariant is empty + full == N: every slot is free or occupied, never both and never neither. The two semaphores are one counter read from opposite ends, each blocking a different party; mutex protects the indices, not the occupancy.

(b) The producer holds mutex and blocks on P(empty) because the buffer is full; a consumer passes P(full) and blocks on P(mutex). Each waits for what only the other can supply. Take the counting semaphore before the mutex: never wait a long time while holding a lock others need to make progress.

(c) A blocking wait naively reads "if the count is 0, sleep." Between the test and the sleep another thread can post and call wake-up — but nobody is asleep yet, so the wake-up is discarded and the first thread sleeps forever with a permit sitting right there. The fix is making check-and-sleep atomic with respect to post: xv6's sleep(chan, lk) releases the lock only after marking the process Sleeping. rv6's try_wait never sleeps, so it has no window — a deferral, not a solution.


Part C — Virtual memory and the MMU

Problem 7: Decode a satp

(Decode it.) (a) The kernel root table is at physical 0x8021_5000. Compute what make_satp writes. (b) A debugger shows satp == 0x8000_0000_0008_0215. What mode, and where is the root table?

Click to reveal solution

(a) make_satp(root) = SATP_SV39 | (root >> 12):

  root       = 0x8021_5000
  root >> 12 = 0x8_0215                 (drop three hex digits = 12 bits)
  8 << 60    = 0x8000_0000_0000_0000
  satp       = 0x8000_0000_0008_0215

(b) MODE (63:60) = 0x8 = Sv39, on; ASID (59:44) = 0; PPN (43:0) = 0x8_0215, so the root table is at 0x8_0215 << 12 = 0x8021_5000 — append three zero digits. MODE 0 means paging off.

The classic error is forgetting the >> 12: 0x8000_0000_8021_5000 still fits the 44-bit PPN field, so nothing complains and the MMU looks for the root table about 8 TiB up, far past PHYSTOP. The kernel dies with no message — which is why exercise 39k checks make_satp before switching the MMU on.

Problem 8: A full Sv39 walk, by hand — this is the hard one

(Decode it.) A user page table, dumped. Entries not shown are zero.

  L2 root table @ PA 0x8021_5000
      [  0] = 0x2008_5801
      [255] = 0x2008_6001

  table @ PA 0x8021_6000                table @ PA 0x8021_8000
      [  0] = 0x2008_5C01                   [511] = 0x2008_6401

  table @ PA 0x8021_7000                table @ PA 0x8021_9000
      [  2] = 0x21D9_501B                   [510] = 0x2008_6C07
                                            [511] = 0x2008_680B

(a) Split VA 0x0000_0000_0000_2ABC into VPN[2], VPN[1], VPN[0], offset, and translate it. (b) Translate TRAMPOLINE = 0x3F_FFFF_F000. (c) Give the permissions of both leaves and say which user mode may touch. (d) The process loads from 0x0000_0000_0000_5000. What happens, and what is in scause?

Click to reveal solution

Two rules: px(level, va) = (va >> (12 + level*9)) & 0x1ff, and pa = (pte >> 10) << 12 — by hand, multiply the PTE by 4, then zero the last three hex digits.

(a)

  VPN[2] = (0x2ABC >> 30) & 0x1ff = 0
  VPN[1] = (0x2ABC >> 21) & 0x1ff = 0
  VPN[0] = (0x2ABC >> 12) & 0x1ff = 2
  offset = 0x2ABC & 0xFFF         = 0xABC

  root @ 0x8021_5000 [0] = 0x2008_5801   x4 = 0x8021_6004 -> table @ 0x8021_6000
  L1   @ 0x8021_6000 [0] = 0x2008_5C01   x4 = 0x8021_7004 -> table @ 0x8021_7000
  L0   @ 0x8021_7000 [2] = 0x21D9_501B   x4 = 0x8765_406C -> frame @ 0x8765_4000
  PA = 0x8765_4000 + 0xABC = 0x8765_4ABC

Cheap check: the last three hex digits of the virtual and physical address must match (ABC) — the offset is never translated.

(b)

  VPN[2] = (0x3F_FFFF_F000 >> 30) & 0x1ff = 0xFF  = 255
  VPN[1] = (0x3F_FFFF_F000 >> 21) & 0x1ff = 0x1FF = 511
  VPN[0] = (0x3F_FFFF_F000 >> 12) & 0x1ff = 0x1FF = 511

  root [255] = 0x2008_6001  x4 = 0x8021_8004 -> table @ 0x8021_8000
  L1   [511] = 0x2008_6401  x4 = 0x8021_9004 -> table @ 0x8021_9000
  L0   [511] = 0x2008_680B  x4 = 0x8021_A02C -> frame @ 0x8021_A000
  PA = 0x8021_A000

(c) flags = pte & 0x3ff. 0x21D9_501B → 0x1B = V | R | X | U (bits 0, 1, 3, 4): user code — readable, executable, not writable — and user mode may touch it. 0x2008_680B → 0x0B = V | R | X, no U: mapped in the user's table but off-limits to user mode, and it needs no U because by the time the first trampoline instruction is fetched the trap has already raised the privilege level to S.

(d) VPN[0] = 5, and the L0 table has nothing there, so PTE_V is clear. The MMU raises a load page fault: scause = 13 (0xD), top bit clear because a fault is an exception, stval = 0x5000. It reaches usertrap, matches neither scause == 8 nor the interrupt branch, and kills the process.

Common wrong answers. Indexing the root with VPN[0] — the walk starts at the most significant index. And using pte >> 12 for the frame: the PPN starts at bit 10, and two bits off scales the frame number by four.

Problem 9: Turning it on without falling off

(Explain it, with arithmetic.) (a) State the bootstrap paradox in a sentence and say how identity mapping resolves it. (b) Is sfence.vma required (i) after csrw satp in kvminithart; (ii) after mappages builds the kernel table with the MMU still off; (iii) around uservec's csrw satp? (c) How many page-table pages does the KERNBASE..PHYSTOP identity map cost?

Click to reveal solution

(a) The instant translation is enabled, the next instruction fetch is itself a translated access — so if the page holding the running code is unmapped, the CPU faults before the kernel can react. Identity mapping (va == pa) makes translation transparent: the PC, sp, and every live pointer keep meaning what they did a cycle earlier.

(b) (i) Required: you just changed which table is in force and the TLB may hold entries from the old regime, so csrw satp + sfence.vma zero, zero (all address spaces, all addresses) is the canonical pair. (ii) Not required: translation is off, nothing is cached, and no access has gone through these entries — which is why rv6 builds the whole table before writing satp. (iii) Required on both sides. uservec switches tables mid-instruction- stream: the fence before ensures no user translation is in flight, the one after that the next fetch resolves under the kernel table.

(c)

  (PHYSTOP - KERNBASE)/PGSIZE = 0x0800_0000 / 0x1000 = 0x8000 = 32,768 leaves
  32,768 / 512 entries per table                     =     64 L0 tables
  KERNBASE >> 30 = 2, (PHYSTOP-1) >> 30 = 2  -> all under root[2]: 1 L1 table
  total = 64 + 1 + 1 root = 66 pages = 270,336 bytes ~ 264 KiB

A quarter of a megabyte of tables to describe 128 MiB, entirely to say "every address maps to itself." That is the waste megapages remove — a leaf at level 1 maps 2 MiB in one entry — and rv6 skips them to keep walk one shape.


Part D — Traps, timers, and devices

Problem 10: What just happened?

(Decode it.) For each scause: interrupt or exception, the cause, what rv6 does, and whether the handler must advance sepc by 4.

# scause # scause
1 0x0000_0000_0000_0008 4 0x0000_0000_0000_0003
2 0x8000_0000_0000_0001 5 0x0000_0000_0000_000F
3 0x8000_0000_0000_0009 6 0x0000_0000_0000_0002
Click to reveal solution

rv6 computes scause >> 63 (1 = interrupt) and scause & 0xff (the code).

# Kind Code Meaning rv6 +4?
1 exception 8 ecall from U-mode every syscall; usertrap dispatches on a7 yes
2 interrupt 1 supervisor software the forwarded timer tick; clear sip.SSIP no
3 interrupt 9 supervisor external a device via the PLIC → console::intr no
4 exception 3 breakpoint (ebreak) the exercise 43k trap test yes
5 exception 15 store page fault bad user write: kill the process no
6 exception 2 illegal instruction user code touching a CSR: kill it no

The +4 rule. For a trap caused by deliberately executing an instruction — ecall, ebreak — sepc points at that instruction, which has done its job; returning without advancing re-executes it forever (exercise 43k fails as a timeout, not a crash). For an interrupt, sepc points at an instruction that has not run, so +4 skips real work; for a fault, at the instruction that failed, which a kernel able to fix the mapping would retry.

Problem 11: The handoff and the handshake

(Order it.) (a) start.rs does six jobs before mret, in eight CSR writes. Name them and say what breaks if each is omitted. (b) Order the PLIC steps for one keystroke, giving the register address rv6 touches at each, and say what goes wrong if complete is skipped.

Click to reveal solution

(a)

CSR write(s) Job If omitted
mstatus.MPP = 0b01 mret lands in S-mode the kernel runs on in M-mode, where satp is ignored
mepc = kmain where mret jumps mret jumps to whatever mepc held
satp = 0 paging off for now a stale satp translates the first S-mode fetch through a table that does not exist
medeleg, mideleg = 0xffff deliver traps to S-mode every trap goes to mtvec — which is timervec — so an ecall is handled by the timer vector
pmpaddr0, pmpcfg0 give S-mode all of physical memory S-mode is denied every address; the instruction after mret faults
mcounteren = 0xffffffff let S-mode read time reading time traps as an illegal instruction (scause = 2)

Two jobs are pairs, which is why six need eight writes. Without delegation, machine mode owns every trap and an S-mode kernel is impossible.

(b)

sequenceDiagram
    participant U as UART
    participant P as PLIC
    participant K as kerneltrap
    participant C as console::intr
    U->>P: byte arrives, IRQ 10 asserted
    P->>K: external interrupt, scause = 0x8000_0000_0000_0009
    K->>C: scause & 0xff == 9
    C->>P: claim(): read 0x0c20_1004 -> 10
    C->>U: getc() while LSR.DR is set
    C->>P: complete(10): write 10 to 0x0c20_1004

From PLIC = 0x0c00_0000:

  priority, IRQ 10   PLIC + 10*4      = 0x0c00_0028   write 1 (0 = disabled)
  S-mode enable      PLIC + 0x2080    = 0x0c00_2080   write 1<<10 = 0x400
  S-mode threshold   PLIC + 0x20_1000 = 0x0c20_1000   write 0
  claim / complete   PLIC + 0x20_1004 = 0x0c20_1004   read = claim, write = done

Claim and complete are the same register: reading asks "which device?", writing the IRQ back says "done." Skip complete and everything works for exactly one keystroke — the PLIC believes IRQ 10 is still being serviced and never delivers it again. No error, no fault; the console goes deaf.


Part E — Filesystem and user mode

(Trace it, then Explain it.) rv6 has NINODE = 64 inodes; ROOT is 1.

  inode 1  Dir    entries: "docs" -> 4,  "hello" -> 2
  inode 2  File   size 5     data "hello"
  inode 4  Dir    entries: "notes" -> 7, "todo" -> 9
  inode 7  File   size 12    data "meet at 3pm\n"
  inode 9  File   size 0
  (inodes 3, 5, 6, 8, 10..63 are Free)

(a) Resolve /docs/notes, naming the call at each step. (b) unlink(4, "notes") runs. State everything that changes, including which inode a new file in / would get next. (c) In Unix, unlink removes a name, not a file. What does rv6 do differently, and what breaks if you add hard links without changing anything else?

Click to reveal solution

(a)

  start at ROOT = 1
  dirlookup(1, "docs")  -> scans inode 1's 16 slots, matches -> 4
  dirlookup(4, "notes") -> scans inode 4's slots,    matches -> 7

dirlookup tests e.used && e.len == name.len() && e.name[..e.len] == name: names live in a fixed [u8; 14], so only the stored length separates "no" from "notes".

(b) Two things, unconditionally: the slot in inode 4 is marked used = false (the name is gone), and inodes[7] = Inode::new() — kind Free, size 0, data zeroed (the file is gone). dirlookup(4, "notes") now returns NotFound.

A new file in / then gets inode 3, not 7: alloc scans for i in ROOT..NINODE and takes the lowest Free slot, and nothing reserves a just-freed inode.

(c) A Unix inode carries a link count. A directory entry is one reference; unlink removes the entry and decrements the count, and the blocks go only at zero. That is what makes hard links possible: two names, one inode, one set of bytes. rv6 has no link count, so unlink conflates the two steps — add hard links and the first rm of any name detonates the file for every other name, since surviving entries point at an inode reset to Free that alloc may already have reused. The name lives in the directory, the file lives in the inode, and anything that pretends otherwise breaks the moment the mapping is not one-to-one.

Problem 13: The trampoline, and why it is where it is

(Order it and Explain it.) (a) Complete the user address space — both kernel-only virtual addresses and every permission field.

   virtual address     what lives there              perms
   ---------------     ---------------------------   ---------
   0x__________        TRAMPOLINE: uservec/userret   _ _
   0x__________        TRAPFRAME: saved registers    _ _
        ...                  (unmapped)
   0x0001_1000         <- initial user sp
   0x0001_0000         the stack page                _ _ _
        ...                  (unmapped guard gap)
   0x0000_0000 ..      the program image             _ _ _

(b) Why must the trampoline sit at the same virtual address in the kernel's table and every user table? Name the exact instruction that would fail. (c) The trapframe holds 31 user registers plus five other fields. Name them and say who writes each.

Click to reveal solution

(a)

   0x3F_FFFF_F000      TRAMPOLINE: uservec/userret   R X       (no U)
   0x3F_FFFF_E000      TRAPFRAME: saved registers    R W       (no U)
        ...                  (unmapped)
   0x0001_1000         <- initial user sp
   0x0001_0000         the stack page                R W U
        ...                  (unmapped guard gap)
   0x0000_0000 ..      the program image             R X U

TRAMPOLINE = MAXVA - PGSIZE = (1 << 38) - 0x1000 = 0x3F_FFFF_F000, and the trapframe is one page below. The guard gap is deliberate: run off the end of your memory and you take a clean fault instead of eating your stack.

(b) Entering or leaving user mode means writing satp mid-instruction- stream. The instruction that fails is the fetch of the instruction after csrw satp, t1: the PC does not change when satp does, so it is still the trampoline's virtual address, now resolved through the other table. Mapped elsewhere, the CPU fetches garbage; unmapped, it faults on its way to the trap handler. A page at the identical VA in both tables is the only ground you can stand on across that write — which is why kvmmake copies the trampoline onto a page of its own.

(c) The five fields at offsets 0–32 are notes the kernel leaves for the trampoline, which runs before any kernel Rust and cannot look anything up. (The fifth, kernel_hartid at 32, is unused in rv6; it keeps the xv6 layout.)

Off Field Written by Why it is needed
0 kernel_satp usertrapret the table to install on the way in
8 kernel_sp usertrapret (p.kstack + PGSIZE) a stack it can trust; the user's sp may point anywhere
16 kernel_trap usertrapret where to jump once the kernel table is live
24 epc usertrap from sepc, written back by usertrapret where the user resumes

The trapframe is mapped R W with no PTE_U for the obvious reason: it holds the kernel's satp and kernel stack pointer, and a user program able to write those two would own the machine.


After you have tried these

Compare answers with someone before the solutions go up: explaining a wrong answer out loud finds the misunderstanding faster than rereading a solution. If a whole part felt shaky, reread the source rather than the notes — swtch.rs and usermode.rs hold most of what this set asks about. See Exam Prep for studying a kernel on paper, Sv39 Paging for worked translations, and rv6 Architecture for the trap paths in full.