Skip to content

Practice Set 3

Distributed: Friday, December 4 · Solutions posted: Monday, December 7 · Prepares for: the Final Exam, Tuesday, December 8.

This set is ungraded, and with the exam on the last day of class there is no session in which we work it together. Attempt it first, then read the solutions when they go up on December 7 — that is the review.

Do it on paper, with no computer. That is the exam condition: closed book, by hand, with one permitted reference, the printed Cheatsheet. Every solution shows its arithmetic.

Cumulative, weighted toward 49k–53k. Each problem is labeled with its shape: trace it, decode it, or order it / explain it.


Part A · exec and address-space construction

Problem 1: The order of exec (order it)

exec_into (exec.rs) does six things, listed out of order. Put them in the only correct order, and say what breaks if you violate (i) c before a, (ii) e before f, (iii) b before the next return to user mode.

  a. free_user_pagetable(old)
  b. (*tf).epc = USER_CODE; (*tf).sp = built.sp; (*tf).a0 = built.argc
  c. let built = build_addrspace((*p).trapframe as usize, name, args)?
  d. return Ok(built.argc)
  e. let old = (*p).pagetable
  f. (*p).pagetable = built.pagetable
Click to reveal solution

Order: c, e, f, b, a, d.

(i) ? on c returns Err without touching the caller, so a failed exec leaves the old program running — what execfail tests. Free first, and a mere lookup failure leaves the process with no memory and nothing to return to.

(ii) After f the only pointer to the old root table is gone, so every physical page of the old image and stack leaks.

(iii) The process resumes through userret, which reloads every register from the trapframe and srets to sepc, taken from tf.epc. A stale epc jumps into whatever byte of the new image sits at the old address.

Why a is safe: a syscall runs on the kernel page table, so the old user table is merely data; and free_pt frees only PTE_U leaves, so the trampoline and trapframe pages survive to be returned through.

Common wrong answer: putting a right after e. It happens to work, but one allocation failure inside build_addrspace then destroys the process.

Problem 2: argv on the new stack (trace it)

The shell runs echo hi, so push_argv gets name = "echo", args = ["hi"]. USER_STACK_TOP = 0x1_1000; strings are rounded with sp &= !7, the pointer array with sp &= !15. Give argc and sp after each push, then fill the four ???? boxes.

  0x1_1000  +--------------------------+  USER_STACK_TOP
     ????   |  "echo\0"   (5 bytes)    |
     ????   |  "hi\0"     (3 bytes)    |
            +--------------------------+
     ????   |  argv[0] = ????          |
            |  argv[1] = 0x0001_0FF0   |
            |  argv[2] = 0x0000_0000   |
            +--------------------------+  <- sp, and a1
Click to reveal solution

argc = 1 + args.len() = 2 — argv[0] is the program name, which exec adds.

  start                sp = 0x1_1000
  push "echo" (4+1=5)  0x1_1000 - 5 = 0x1_0FFB
                       &!7:  0xFFB = 1111_1111_1011 -> clear low 3 bits
                       sp = 0x1_0FF8        uargv[0] = 0x1_0FF8
  push "hi"   (2+1=3)  0x1_0FF8 - 3 = 0x1_0FF5
                       &!7:  0xFF5 -> 0xFF0
                       sp = 0x1_0FF0        uargv[1] = 0x1_0FF0
  push the array       argc + 1 = 3 pointers = 24 bytes = 0x18
                       0x1_0FF0 - 0x18 = 0x1_0FD8
                       &!15: 0xFD8 = ...1101_1000 -> clear low 4 bits
                       sp = 0x1_0FD0

Boxes: 0x1_0FF8, 0x1_0FF0, 0x1_0FD0, argv[0] = 0x0001_0FF8. So tf.a0 = 2, tf.a1 = tf.sp = 0x1_0FD0, tf.epc = 0.

Two alignments because the array is what sp finally holds, and the ABI wants sp 16-byte aligned. Every stored pointer is a user address: push_argv writes with copyout, so argv[1] names where the string lives in the program's world, not the physical page the kernel wrote through.

Common wrong answer: argc = 1, from counting args only. Then echo prints nothing: its loop starts at i = 1 and immediately hits i >= argc.

Problem 3: Decode the new address space (decode it)

Decode each entry — physical address, flags, leaf or interior, which page — and satp. Then: echo stores a byte to 0x0. What happens?

  A. 0x0000_0000_2008_101B      C. 0x0000_0000_2008_0C07
  B. 0x0000_0000_2008_1417      D. 0x0000_0000_2008_1801
  satp = 0x8000_0000_0008_0202
Click to reveal solution

pa = (pte >> 10) << 12, flags = pte & 0x3ff; leaf iff any of R/W/X is set.

  A  flags 0x01B = 0b0001_1011 = V|R|X|U   leaf
     >>10 = 0x8_0204 ; <<12 = 0x8020_4000  -> user CODE page, VA 0x0
  B  flags 0x017 = 0b0001_0111 = V|R|W|U   leaf
     >>10 = 0x8_0205 ; <<12 = 0x8020_5000  -> user STACK page, VA 0x1_0000
  C  flags 0x007 = 0b0000_0111 = V|R|W     leaf, no U
     >>10 = 0x8_0203 ; <<12 = 0x8020_3000  -> TRAPFRAME, 0x3F_FFFF_E000
  D  flags 0x001 = V only, R=W=X=0         INTERIOR: a next-level table
     >>10 = 0x8_0206 ; <<12 = 0x8020_6000
  satp  MODE = 0x8000...0202 >> 60 = 8 = Sv39
        root PPN = 0x8_0202 ; root table at 0x8_0202 << 12 = 0x8020_2000

Check A forward: 0x80204 << 8 = 0x8020400, << 2 = 0x20081000, | 0x1B. ✓

The store to 0x0: U is set, so the page is reachable from user mode, but W is clear: a store page fault, scause = 15. usertrap falls to its else branch, sets FAULTED, and calls exit_current(-1); the scheduler reports RunOutcome::Faulted(15).

Common wrong answer: "nothing, it's the program's own memory." Ownership is not permission: load_segment maps code R|X|U deliberately.


Part B · File descriptors

Problem 4: Two tables, one offset (trace it)

The arrangement the final asks about; rv6 simplifies it (Problem 5) and has no dup, so read this as the design. An fd indexes the per-process fd table; each entry points into the system-wide open-file table, which holds the offset, the mode, and a reference count. dup and fork make new pointers to an existing entry. Give both fd tables and F's count and offset at each mark, then say what the two writes produce.

   fd = open("log", O_CREATE|O_WRONLY);   // (1)
   dup(fd);                               // (2)
   pid = fork();                          // (3)
   if (pid) close(fd);                    // (4), parent only
   // parent then writes 5 bytes, child writes 3 bytes, both through F
Click to reveal solution

O_CREATE | O_WRONLY = 0x200 | 0x001 = 0x201, so F is write-only, off = 0.

  (1) lowest free fd = 3
      parent: 3 -> F                          F: count 1, off 0
  (2) dup -> fd 4, the SAME F
      parent: 3 -> F  4 -> F                  F: count 2, off 0
  (3) fork copies the table: every pointer duplicated, every count bumped
      parent: 3 -> F  4 -> F
      child:  3 -> F  4 -> F                  F: count 4, off 0
  (4) parent close(3)
      parent:         4 -> F
      child:  3 -> F  4 -> F                  F: count 3, off 0

close tears the entry down only at count 0; three descriptors still name F, so nothing is freed.

The writes. Both reach the same F and share off: 5 bytes at 0..5, then 3 at 5..8. The file is 8 bytes, neither write clobbered, whichever order the scheduler picks. A count and not a boolean, because pipe EOF is "the write end's count hit zero" (Problem 11).

Common wrong answer: count 2 after fork, from thinking fork shares the table. It copies it: one new pointer per open fd, so the count doubles.

Problem 5: Where the offset lives (explain it)

(a) Why does off live in the open-file table, not the per-process fd table? Give the one-line consequence for cmd >> log. (b) rv6 diverges: File is Copy, stored by value in Proc::ofile, and sys_fork does (*child).ofile = (*parent).ofile;. What does Problem 4's experiment produce on rv6, and why?

Click to reveal solution

(a) Because the offset must be shared between descriptors derived from one another and private between descriptors opened independently. Two separate open("log") calls make two entries and two offsets — they are unrelated. dup and fork name the same stream, coherent only if they advance one cursor. So for cmd >> log the child's writes advance the offset the shell's descriptor sees: every write appends.

(b) The rv6 child gets its own copy of the 16-entry array, each off included. Both start at 0: the parent's 5 bytes go to 0..5, and the child's 3 bytes also start at 0 and overwrite the first three. The file is 5 bytes, and its contents depend on scheduling order. Likewise (*p).ofile[fd] = File::none() in one process cannot affect the other, so there is no count to keep and nothing to tear down.

That holds only while no two descriptors share a stream — the assumption pipes and redirection break. Adding pipes forces the xv6 arrangement: ofile becomes an array of pointers to refcounted objects.

Common wrong answer: "the offset is per-process." It is per open file description, which is neither per-process nor per-fd — the whole point of having two tables.

Problem 6: Find the bug (explain it)

oslings run 50k_file_descriptors reports a timeout on the cat step. Find the bug, explain the mechanism, and name a second bug in the same excerpt.

let file = match getfile(p, fd) {
    Some(f) if f.writable => f,
    _ => return -1,
};
// ... FileKind::Inode arm:
let n = match FS.lock().read_at(file.inum, file.off, &mut kbuf[..want]) {
    Ok(n) => n,
    Err(_) => return -1,
};
if n > 0 && vm::copyout((*p).pagetable, buf, &kbuf[..n]).is_err() {
    return -1;
}
n as isize
Click to reveal solution

Bug 1 — the offset is never advanced. The missing line is (*p).ofile[fd].off += n; before the return.

file is a copy of the stored File (File is Copy), so the cursor must be written back through (*p).ofile[fd]. Without it file.off is 0 on every call, every read returns the same first chunk, and read never returns 0. cat's "read, write, repeat until 0" loop never terminates and the watchdog fires at ~3 s. A timeout rather than wrong bytes is the clue.

Bug 2 — the wrong permission. f.writable should be f.readable: a file opened O_RDONLY has readable = true, writable = false, so this guard rejects exactly the reads that should succeed. cat treats the -1 as "stop" (blez a0), so that bug alone gives silent empty output rather than a hang.

Common wrong answer: "kbuf is too small." Short reads are legal — the caller loops, and cat asks for only 64 bytes at a time.


Part C · fork, exit, and wait

Problem 7: What the child gets (trace it)

Fill in the child's value for each field immediately after sys_fork, and mark it fresh, a copy, or shared. Then: where does the parent's a0 get set to the child's pid?

Fields: pid, pagetable, the pages behind VA 0x0, the trapframe page, trapframe.epc, trapframe.a0, kstack, ofile, context.ra.

Click to reveal solution
Field Child's value which
pid next NEXTPID fresh (alloc_pid)
pagetable new root table fresh (create_pagetable)
pages behind VA 0x0 new physical pages, identical bytes copy (uvmcopy)
trapframe (page) a new kalloc page fresh
trapframe.epc the parent's copy
trapframe.a0 0 fresh — overwritten after the copy
kstack a new page fresh
ofile the parent's array, by value copy
context.ra forkret fresh (ready)

The one genuinely shared page is the trampoline: proc_pagetable maps vm::trampoline_page() into every table at the same VA. uvmcopy copies only PTE_U leaves, same VA and flags, so the child sees an identical address space with different physics.

The parent's a0 is not set in sys_fork, which merely returns (*child).pid. usertrap then does (*tf).a0 = ret on the parent's trapframe, and userret loads it into the register. The child is never in usertrap for this call, so sys_fork plants 0 in the child's trapframe by hand — that one assignment is the whole "returns twice" trick.

Common wrong answer: "the child resumes at the start of the program." It resumes at the instruction after the ecall: epc was copied, and usertrap had already advanced it by 4.

Problem 8: Three processes (trace it)

forks2 forks child A (exits 3), then child B (exits 4), then waits twice and exits with the sum. The parent is pid 4, so A is 5 and B is 6. Give every process's ProcState at each mark, and the parent's exit status.

  A: both forks have returned in the parent; wait #1 not yet called
  B: the parent is inside wait #1; no child has exited
  C: child A has called exit(3); the scheduler has control
  D: both waits have returned
Click to reveal solution
     mark    pid 4 (parent)   pid 5 (A)    pid 6 (B)
      A      Running          Runnable     Runnable
      B      Runnable         Runnable     Runnable
      C      Runnable         Zombie(3)    Runnable
      D      Running          Unused       Unused

A. rv6's scheduler is cooperative: nothing preempts the parent, which runs straight through both forks. Neither child has executed an instruction.

B. sys_wait scans all NPROC slots, finds no Zombie child, confirms has_children, and calls proc_yield, which marks the parent Runnable and swtches to the scheduler. Not Sleeping: rv6's wait blocks by yielding and re-scanning, not on a channel.

C. exit_current(3) sets xstate = 3 and state = Zombie and swtches away for good; the slot survives so the status can be collected, and pick_next never picks a Zombie.

D. Each sys_wait finds the Zombie, copyouts xstate as four little-endian bytes to the address in a0, then freeprocs the slot. Status: 0 + 3 = 3, then 3 + 4 = 7, so RunOutcome::Exited(7).

Common wrong answer: Sleeping at B, or "A runs immediately at the fork." Both import preemptive-kernel habits; rv6 has a timer but does not preempt.

Problem 9: Zombies, reaping, and orphans (explain it)

(a) Why does exit leave a Zombie instead of freeing the slot? (b) In real Unix, what happens to a child whose parent exits first, and who cleans it up? (c) What does rv6 do instead, and what latent bug lurks in rv6's parent field?

Click to reveal solution

(a) The exit status must outlive the process that produced it, so the slot persists until someone reads it. Both alternatives are worse: discard the status and wait reports nothing; block exit until the parent waits and you deadlock whenever a parent exits without waiting. A zombie is a bounded leak with a defined collector.

(b) The child is reparented to init (pid 1), which loops calling wait, so an orphaned zombie is reaped instead of holding a slot forever. That is the real reason init exists: root of the process tree, reaper of last resort.

(c) rv6 has neither. When the root process finishes, the scheduler's done calls cleanup_except(root), which freeprocs every slot that is not Unused — orphans and leftover zombies alike. It works because every run is bounded by one root process.

The latent bug: Proc::parent is a raw *mut Proc into the fixed PROCS array, and has_children compares those pointers. Once a parent is freeproced, allocproc can hand its slot to an unrelated process — and a surviving child's stale pointer now aims at that one, which would find itself with a child it never forked. A correct implementation stores the parent's pid (never reused), or reparents to init at exit as xv6 does.


Part D · Composition: fork + exec

Problem 10: Why two calls (explain it)

Unix could have offered one call, spawn(path, argv). It offers fork and exec. What does the split make possible that spawn would not? Name the specific window, two things a shell does inside it, and the Cheatsheet line that makes it work.

Click to reveal solution

The window is between fork returning 0 and the child's ecall to exec: there the child is an ordinary process running the parent's code with the parent's descriptors, so it can adjust its own state using only syscalls that already exist.

  1. Redirection. For cmd > out the child opens out, dup2s it onto fd 1, closes the extra fd, then execs. cmd needs no knowledge of redirection.
  2. Pipeline wiring. For a | b the child dups its pipe end onto fd 0 or 1 and closes both raw pipe fds before exec'ing (Problem 11 — off-exam enrichment).

The cheatsheet line: "Open fds survive exec, which is how a redirected stdout persists." exec replaces memory and never touches ofile — that is what turns "arrange your descriptors, then become the program" into a mechanism.

Each half is independently useful: forks2 forks without exec'ing, execself execs without forking. The cost of one spawn is that every adjustment becomes a parameter — which is what happened: posix_spawn takes a file-actions array, a list of the opens, dups, and closes you would otherwise just write.

Common wrong answer: "fork is for concurrency, exec is for performance." Copying an address space to discard it microseconds later is the expensive choice; copy-on-write exists to hide that cost. The split buys expressiveness.

Problem 11: Pipes — the hard one (explain it) · not on the exam

Optional enrichment. Pipes are not examinable — they are never lectured, and no exam question depends on them. Work this one because it is the idea that ties fork, dup, and exec into a single mechanism, not because it is on the exam. rv6 has no pipes; the design below is xv6's:

const PIPESIZE: usize = 512;
struct Pipe {
    data: [u8; PIPESIZE],
    nread: u32,      // total bytes ever read
    nwrite: u32,     // total bytes ever written
    readopen: bool,  // some fd still refers to the read end
    writeopen: bool, // some fd still refers to the write end
}

(a) Give the empty and full conditions and where byte nwrite is stored. (b) State the exact condition under which read returns 0. (c) For ls | wc, list every close the shell must perform, and say precisely what happens if it forgets its own copy of the write end.

flowchart LR
    SH["shell: pipe(p)"] --> C1["child 1: dup p1 onto fd 1\nclose p0, p1\nexec ls"]
    SH --> C2["child 2: dup p0 onto fd 0\nclose p0, p1\nexec wc"]
    C1 -->|"writes fd 1"| P[("512-byte ring")]
    P -->|"reads fd 0"| C2
Click to reveal solution

(a) The counters are monotonic totals, never reset; the ring position is the total modulo the buffer size.

  empty:  nread == nwrite
  full:   nwrite == nread + PIPESIZE          (512 bytes outstanding)
  byte nwrite lives at data[nwrite % PIPESIZE]

Totals rather than head/tail indices remove the ambiguity in which a full ring and an empty ring look identical. A writer on a full pipe sleeps until a reader consumes — or fails if readopen is false: the broken-pipe case. A reader on an empty pipe sleeps until a writer produces or the write end goes.

(b) read returns 0 exactly when

  nread == nwrite   AND   writeopen == false

— empty and the write end's reference count has fallen to zero, so no future write is possible. Empty alone is not EOF but "wait"; write-end-closed alone is not EOF either, since buffered bytes must be delivered first.

(c) Each fork copies both ends, so with two children six descriptors name the pipe.

  child 1 (ls):  dup p1 onto fd 1;  close p0;  close p1
  child 2 (wc):  dup p0 onto fd 0;  close p1;  close p0
  shell:         close p0;  close p1;   then wait twice

If the shell forgets close(p[1]): ls exits and its descriptors close, but the shell still holds a write end, so writeopen stays true and wc's final read sees an empty buffer with a live writer. It sleeps forever, while the shell blocks in wait for wc. The pipeline hangs silently, and nothing in ls or wc is wrong. This is a reference-counting bug: EOF is defined by a count, so every stray copy of the descriptor postpones it.

Common wrong answer: "read returns 0 when the writer exits." Exiting matters only because it closes descriptors — and only that process's copies.


Part E · Cumulative retrieval (Modules 1 and 2)

Problem 12: A cause, a fence, and two translations (decode it)

(a) For each scause — 0x0000_0000_0000_0008, 0x8000_0000_0000_0009, 0x0000_0000_0000_000D — give interrupt or exception, which one, which rv6 handler, and whether sepc must advance by 4.

(b) exec_into installs a whole new page table and never executes sfence.vma. Why is that not a stale-TLB bug?

(c) Split 0x0001_0FD0 (the sp from Problem 2) and TRAPFRAME = 0x3F_FFFF_E000 into VPN[2], VPN[1], VPN[0], offset. Explain the trapframe's VPN[2], and say why the trampoline must sit at the same VA in the kernel's table and in every user table.

Click to reveal solution

(a) rv6 tests scause >> 63 for interrupt-vs-exception, scause & 0xff for the code.

  0x...0008   bit 63 = 0 -> EXCEPTION, code 8 = ecall from U-mode.
              usertrap's `scause == 8` branch -> syscall::dispatch.
              MUST advance: (*tf).epc += 4, or the ecall re-executes forever.
  0x8...0009  bit 63 = 1 -> INTERRUPT, code 9 = supervisor external, a device
              via the PLIC -> console::intr() (from usertrap in user mode, or
              kernelvec -> kerneltrap inside a syscall that enabled interrupts).
              MUST NOT advance: sepc is the interrupted instruction.
  0x...000D   bit 63 = 0 -> EXCEPTION, code 13 = load page fault.
              usertrap's else branch: FAULTED, exit_current(-1) ->
              RunOutcome::Faulted(13). Advancing would be meaningless.

The rule: exceptions raised by an instruction that must not be retried advance sepc; interrupts and faults do not.

(b) The flush happens on the way out, in the trampoline: sfence.vma zero, zero / csrw satp, a0 / sfence.vma zero, zero. A process cannot reach user mode except through userret, so the new satp is always installed between fences. exec_into only writes a field of a Proc; the CPU still translates through the kernel table until usertrapret computes make_satp. The common wrong answer — "the MMU notices" — is false: the TLB is not coherent with memory writes, which is why kvminithart also pairs csrw satp with a fence.

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

  va = 0x1_0FD0        offset 0xFD0   VPN[0] = 16   VPN[1] = 0   VPN[2] = 0
  va = 0x3F_FFFF_E000  offset 0
     VPN[0] = 0x3F_FFFFE & 0x1FF = 0x1FE = 510
     VPN[1] = 0x1_FFFF  & 0x1FF = 0x1FF = 511
     VPN[2] = (va >> 30) & 0x1FF = 255

VPN[0] = 16 is not a coincidence: USER_STACK = MAX_PROG_PAGES * PGSIZE = 16 * 4096 = 0x1_0000, so the stack is page 16 and the image holds pages 0–15; anything smaller leaves the entries between unmapped, and that gap is the guard region.

VPN[2] = 255, not 511, because MAXVA = 1 << 38 — one bit short of Sv39's 39 — so rv6 lives in the lower half of the root table and no address needs sign extension. The trampoline one page up is entry 511 of the same level-0 table.

The trampoline must be mapped identically in both tables because uservec executes csrw satp, t1 and the very next instruction must still be fetchable; the PC does not change across csrw. Common wrong answer: "so the kernel can find it" — the kernel could find it anywhere; the constraint is on the instruction fetch straddling the satp write.


Part F · The long question

Problem 13: $ echo hi, end to end (trace it)

This is the hard one, and it is the shape of the long question on the final. sh is at its prompt. You type echo hi and press Enter. Narrate everything from the first keypress to the reappearance of the $ prompt, naming at each stage which component acts and which CSR or data structure is involved. Give yourself twenty minutes on paper before revealing.

Click to reveal solution

1 — the keypress. sh is blocked in read(0, cursor, 1); sys_read, for a Console fd, called trap::intr_on() then console::getc(), spinning on wfi. The UART receives 'e' and raises IRQ 10; the PLIC delivers a supervisor external interrupt. stvec points at kernelvec -> kerneltrap: scause >> 63 == 1, scause & 0xff == 9 -> console::intr() -> plic::claim() = 10 -> uart::getc() drains the byte into the 256-byte ring buffer -> plic::complete(10), without which it re-fires forever.

2 — back to the shell. try_getc pops the byte, sys_read copyouts it and returns 1. usertrap stores tf.a0 = 1; usertrapret sets sepc from tf.epc, clears sstatus.SPP, sets SPIE, and jumps to userret: satp switched between two sfence.vmas, 31 registers reloaded, sret.

3 — line assembly. sh echoes the byte with write(1, ...) and loops — for c h o ␣ h i. On '\n' it stores a NUL and splits the line in place: spaces become NULs, argv[0] = "echo", argv[1] = "hi", argv[2] = NULL, argc = 2. argv[0] is not "exit", so it proceeds.

4 — fork. a7 = 1, ecall. sys_fork calls allocproc, then proc_pagetable (trampoline R|X, the child's own trapframe R|W), then uvmcopy, which copies every PTE_U leaf — the shell's image and stack pages — into fresh physical pages at the same VAs. It copies the parent's trapframe, sets the child's a0 = 0, copies ofile, records parent, and calls ready (context.ra = forkret).

5 — the parent blocks. usertrap plants the child pid in the parent's tf.a0; bnez a0 sends the shell to wait(0). sys_wait finds no Zombie, confirms has_children, and calls proc_yield -> swtch to the scheduler.

6 — the child runs. pick_next selects it and swtches in. Because context.ra = forkret, the ret inside swtch lands in forkret -> usertrapret -> userret -> sret. The child resumes at the instruction after its ecall, with a0 = 0, and falls into the exec branch.

7 — exec. a7 = 7, ecall. sys_exec copyinstrs "echo", then fetch_argv walks the user's pointer array with copyin and copyinstrs each string into the static ARGV_STORE — static, not stack, because a kernel stack is one page. It drops argv[0] (exec re-adds it) and calls exec_into(p, "echo", ["hi"]).

8 — the new address space. build_addrspace: lookup("echo"); a fresh zeroed root table; TRAMPOLINE R|X and TRAPFRAME R|W, neither with U; load_segment copies the image at VA 0 as R|X|U and issues fence.i; map_user_stack maps one R|W|U page at 0x1_0000; push_argv returns argc = 2, sp = argv = 0x1_0FD0 (Problem 2). Then the swap: save old, install the new table, set tf.epc = 0, tf.sp, tf.a0, tf.a1, and free_user_pagetable(old) — safe because we run on the kernel page table. usertrapret recomputes make_satp; userret srets to PC 0 in U-mode.

9 — the output. echo reads a0 = 2, a1 = 0x1_0FD0, loads argv[1] = 0x1_0FF0, measures 2 bytes, and calls write(1, 0x1_0FF0, 2). sys_write sees fd 1 is Console and writable, copyins into a kernel buffer, and emits through uart::putc. A second write sends "\n". hi appears.

10 — exit. a0 = 0, a7 = 2, ecall -> exit_current(0): xstate = 0, state = Zombie, swtch to the scheduler, never to return.

11 — reaping. The scheduler picks the root (sh), and the swtch resumes it inside proc_yield, which returns into sys_wait's loop. The scan finds the Zombie; status_addr is 0, so nothing is copied out; freeproc returns the trapframe page, the kernel stack, and the user page table — freeing echo's image and stack pages, dropping the trampoline mapping without freeing the page — and marks the slot Unused. sys_wait returns the pid, and the shell writes "$ " and blocks in read — back at step 1.

One thing per layer: hardware sets sepc/scause/sstatus and jumps to stvec; the trampoline swaps satp because it is mapped at the same VA in both tables; usertrap adds 4 to epc and dispatches on a7; fork copies memory and plants a0 = 0; exec builds before it destroys; exit leaves a Zombie; wait reaps it.


After you have tried it

Work it before the solutions go up on December 7. Then reread rv6 Architecture, redraw the fork and exec paths from memory, and check your constants against the Cheatsheet. Working these with a classmate is encouraged and within the Integrity Policy; nothing here is submitted.