Week 15 · fork, wait, and the User Shell¶
Thu Dec 3
49k_exec,50k_file_descriptors· Fri Dec 451k_fork_wait,52k_userland,53k_ship_your_commands· extra credit54k_elf_loaderThursday's exercises were taught Tue Nov 24, in Week 15 · exec and File Descriptors. This page is for Friday. Read Essentials before Tuesday: it is what Friday assumes. Going deeper is optional and is not on the exam.
Slides Thursday prep Friday prep
This week¶
The final, Tue Dec 8, always asks one long question: walk one command through the whole system, from a keypress to the next prompt. This week supplies the last layers of that walk.
Thursday rests on the Nov 24 lecture, where exec starts a program by name
and a file descriptor indexes a table of open files. Reread
its Essentials
before Thursday.
On Friday, fork copies a running process, exit and wait hand its result
back, and the round robin from 36k finally has two processes to choose
between. Then exec becomes a system call and the shell leaves the kernel. By
Friday night, $ mycat notes.txt runs the cat you wrote in September on your
own kernel.
Essentials¶
Friday · 51k fork, exit, and wait¶
In 52k your shell copies itself for every command, then waits for the copy.
51k_fork_wait builds the copy, the ending, and the wait.
One call, two returns¶
fork creates a child: a new process that is a copy of the calling
parent. Both then return from the same call, with the same memory. Only the
return value differs: the parent gets the child's pid, and the child gets 0.
Zero is safe because no process has pid 0. With no free slot or no memory,
fork makes nothing and returns -1. A program branches on all three answers:
let pid = fork();
match pid {
0 => run_as_child(), // only the copy sees 0
p if p > 0 => wait_for(p), // the parent holds the child's pid
_ => complain("no free slot"), // -1: nothing was copied
}
Read the match as one question answered twice: each process takes its own
arm, because the return value is all that differs.
What the child gets¶
The program in the child must not be able to tell it is a copy, except by one value. So the kernel copies everything the program can see, and makes new what it keeps about the process itself:
| The child's… | Is | Because |
|---|---|---|
| user pages | copied, at the same virtual addresses | writes stay private |
| open files | copied | fd 1 still means what it meant |
| saved user registers | copied, except one | it resumes where the parent will |
| pid | new | a pid names one run, forever |
| kernel stack and trapframe page | new | each process traps on its own |
| parent | the caller | its exit status needs an addressee |
The memory copy walks the parent's page table, giving the child a fresh page for every user page, same address, same permissions. Only the trampoline is shared. Open files are copied by value, offset included; a Unix child instead shares each open file and its offset with its parent, a difference the final asks about.
The copied program counter puts the child just past the ecall, like the
parent. The one register that must differ is the child's saved a0: the kernel
plants 0 there. The parent's a0 gets the pid the ordinary way: the trap
handler stores every system call's return value in the caller's saved a0.
exit, the zombie, and wait¶
exit(status) ends a process, but the kernel cannot free its slot yet. The
status is addressed to the parent, who may not be listening. So the process
becomes a zombie: it never runs again, and its slot keeps the pid and the
status. Week 9
drew that state.
wait reaps a zombie: it copies the status out and frees the slot. It has
three outcomes:
| The caller has… | wait |
|---|---|
| a zombie child | reaps it and returns its pid |
| children, none finished | yields, then looks again |
| no children at all | returns -1 at once |
The middle row is how rv6 blocks: the parent, Running while it scans, marks
itself Runnable (never Sleeping), yields, and rescans the table whenever it
is picked.
The scheduler finally has work¶
After a fork the running parent has a Runnable child, and your 36k round
robin finally has a choice. The loop asks your policy for a slot, switches in
with your 35k switch, and gets the CPU back only when that process lets go of
it.
It gives it up only by yielding inside wait or by exiting. rv6 is
cooperative: the 44k timer still ticks, but nothing takes the CPU away. A
process that has never run starts at forkret, which takes the trap-return
path into user mode.
In forktest the parent forks, writes parent, and waits; the child writes
child and exits 7. The parent, in slot 0, then exits with that status plus
10. States are as each turn ends; during its own turn a process is Running:
turn what happens slot 0 slot 1 prints
parent forks, writes, wait finds no zombie Runnable Runnable parent
child fork returned 0; writes; exit(7) Runnable Zombie child
parent wait reaps slot 1, status 7; exit(17) Zombie Unused
parent prints first: nothing preempts the parent until it waits.
The one thing to get right:
[fail] only the parent ran, and the output showsparenttwice. The child's registers were an exact copy, so its saveda0still held the parent's value from before the call, and it took the parent's branch. A copy that differs in nothing is not yet a child.
Friday · 52k exec as a system call¶
In 53k your September commands run as programs your shell starts.
52k_userland makes exec a system call and moves the shell out of the
kernel.
Replace the program, keep the process¶
After fork, the child is running the shell's code, and mycat is nowhere in
it. exec brings it in: it replaces the caller's program and keeps the
process. 49k loaded a program into a fresh process; 52k loads one into a
process already running.
Survives exec |
Replaced by exec |
|---|---|
| pid, parent, process slot | the page table, and every user page with it |
| open files | code, data, stack, and argv |
| kernel stack, trapframe page | the four starting registers from 49k |
The surviving open files are how a shell hands a program its input and output
(For the exam). Nothing the CPU is using lives in the old
pages: from the trap until the trampoline's return path, satp holds the
kernel's table (why no fence is needed), so the kernel may give
them back mid-call.
exec does not return on success¶
Only a failed exec comes back, with -1, to a caller that is still itself. A
successful one resumes somewhere else entirely:
exec("mycat", argv) succeeds next instruction: mycat's first, at address 0
exec("mycta", argv) fails next instruction: the one after the ecall, a0 = -1
Read every line after an exec as its failure path, where a shell's child
prints exec: not found.
The shell leaves the kernel¶
Since 46k the shell has lived in the kernel. sh is a user program instead,
started by the kernel shell's run sh, with the prompt $. It loops forever:
write "$ ", then read a line from fd 0 one byte per call, echoing each to fd 1
split the line into words: argv
if argv[0] is "exit": exit(0)
fork; the child calls exec(argv[0], argv), printing "exec: not found" if it returns
the parent waits, then loops
Read every line but the split as system calls: sh has no other way to touch
anything. exit is a builtin, a command the shell runs itself, because its
effect is on the shell.
The one thing to get right:
[fail] execfail did not finish cleanly. The program execs a name that does not exist and should go on toexit(7). The failedexechad already given back the old program's pages, so its -1 returned into memory that was gone. Let go of nothing old until everything new exists.
Friday · 53k Ship your commands¶
53k_ship_your_commands runs the echo, cat, wc and grep you wrote in
September on your own kernel, without editing a line.
One source, two backends¶
In 10c–13c you called ulib, never std. It has two private
backends, and the compile target picks one: std on your laptop, system
calls into rv6 on riscv64gc-unknown-none-elf. Apart from one cfg_attr line
that turns on no_std for the kernel target, your command contains no cfg.
The switch is a fact about the target, and rustc prints it (one line of many,
on a Mac):
$ rustc --print cfg --target riscv64gc-unknown-none-elf
target_os="none"
$ rustc --print cfg
target_os="macos"
Read target_os = "none" as "no operating system underneath"; it follows from
the target, so it cannot disagree with the build. On rv6 each ulib
system-call wrapper is one ecall, in 48k's convention:
fn getpid() -> isize {
let pid: isize;
unsafe { core::arch::asm!("ecall", in("a7") 11usize, lateout("a0") pid) };
pid
}
Read in("a7") 11usize as "the call number goes in a7", and
lateout("a0") pid as "afterward, a0 holds the answer".
What oslings ship does¶
rv6 cannot read ELF, so oslings ship makes each command something it can
load:
your September cat.rs unchanged
built for riscv64gc-unknown-none-elf, in release mode
an ELF file linked at address 0
loadable segments laid out by address; gaps become zeros
a flat image copied to address 0, run from its first byte
embedded in the kernel with include_bytes!
mycat a name exec can find
Inside the kernel they are myecho, mycat, mywc and mygrep, since rv6's
own echo and cat demos would otherwise run instead. Each image is a few
kilobytes at most, far inside the loader's 64 KiB: no allocator, no formatting
machinery, no runtime came along.
The one thing to get right: a command that passed
cargo testin September stopsoslings shipwithcannot find macro `format` in this scope. On your laptop thestdprelude supplied it; on the kernel target the crate isno_std, with no allocator, soformat!,println!,VecandStringdo not exist.
The long question, one layer per line¶
The final's long question, for $ mycat notes.txt, from Enter to the next
prompt:
UART, PLIC Enter: IRQ 10 while sh waits in read; trap to stvec, scause 0x80…09
console the handler claims 10, moves the byte into the ring, completes
sh read(0) returns 1; sh echoes it on fd 1, sees Enter, splits words
fork ecall, a7 = 1: a copy of sh, saved a0 = 0; sh's wait yields
scheduler round robin picks the child: forkret, then sret into its copy of sh
exec ecall, a7 = 7: finds mycat's shipped image, builds a new page table
user mode sepc = 0, satp = the new table, SPP = 0; sret, a0 = 2, a1 = argv
files open returns 3; read(3), write(1) trap with scause 8, a7 = 5 and 16
UART the console fd hands each byte of write(1) to the UART driver
exit, wait exit(0) leaves a zombie; sh reaps it, writes "$ ", reads again
The past form, rv6$ ls, is shorter: the kernel shell runs ls itself, so its
walk has no fork, exec or user mode.
Extra credit ·
54k_elf_loaderoslings shipdoes rv6's loading ahead of time, and the flat image it leaves behind forgets what the ELF file knew.54kmoves that job into the kernel, reading the file's own headers atexectime. Done outside class; not on the exam.
For the exam¶
Not needed on Friday. On the final.
The process tree and init · Final. Processes form a process tree
rooted at init, pid 1, which starts the shell. An exiting process's children
become orphans, reparented to init, whose wait loop reaps them. rv6 has
no init; the kernel frees leftovers when your run ends.
The stale parent pointer · Final. rv6's parent record names a slot, and slots recycle: reap a parent before its child, and the slot's next occupant inherits a child it never forked (Problem 2). Record the pid, or reparent at exit.
Why fork and exec are two calls · Final. Between them, the child runs
the shell's code on its own file table. On Unix, 2>&1 is close(2), then
dup(1), which takes the lowest free descriptor, 2; exec keeps the table.
The child can also close descriptors or change its directory or limits. One
spawn needs each as a parameter, as posix_spawn does.
Copy-on-write keeps it cheap.
The shell is unprivileged · Final. sh is an ordinary user program; its
only door is ecall, where the kernel checks every request. So cd must be a
builtin: a child that changes directory changes only itself.
Why exec needs no sfence.vma · Final. exec changes only which table
the process will use; satp changes in the trampoline, between two
sfence.vmas.
Cumulative retrieval · Final. Practice Set 3 Problem 12 needs
scause,
the Sv39 split
and the trampoline's one address.
The long question · Final. Tell every layer aloud: the
component, the CSR or table, and what the alternative would cost (polling for
the key; an exec that destroys first).
Going deeper¶
Optional. Nothing here is needed on Thursday or Friday, and nothing here is on the exam.
Copied or shared: rv6 and Unix¶
rv6 copies memory eagerly: copy_level() (vm.rs) duplicates every user page
before the child runs. Unix copies it logically: both processes map the same
frames read-only, and a page is copied only when one of them writes it and
faults. That is copy-on-write, and it makes fork then exec copy almost
nothing.
Open files go the other way on Unix (What the child gets): memory is copied because processes must stay isolated, and offsets are shared because a redirected stream must stay coherent.
The cost of a copy¶
Before copy-on-write, BSD tried vfork (1979), which copies nothing: the
child borrows the parent's memory, parent suspended, until it calls exec or
exits, so a child that writes a variable writes the parent's.
Threads make fork worse. The child gets only the calling thread, but every
lock in whatever state it was in, so a lock another thread held stays locked
forever. POSIX therefore allows only async-signal-safe calls between fork and
exec in a threaded program.
The status word¶
rv6 keeps an isize of exit status and copies four bytes of it to the parent.
Unix, Linux included, packs several answers into one int: bits 15–8 hold the
low 8 bits of the exit status, and the low 7 bits hold the signal that killed
the process, or 0; WEXITSTATUS and WTERMSIG unpack them. So on Linux
exit(256) reaches the parent as 0 and exit(-1) as 255, which is why shell
exit codes stay small.
Waiting without polling¶
rv6's wait polls: a parent with a running child yields, rejoins the rotation,
and rescans all 64 slots whenever it is picked. That is correct on one
cooperative CPU, but a parent can spend many turns finding nothing. xv6 sleeps
instead: wait parks the parent on a channel, its own address, and exit calls
wakeup on its parent. Linux puts the parent on a wait queue and sends it
SIGCHLD. Either way, looking again becomes being told, and the unused
Sleeping state
is where such a parent would wait.
Orphans on purpose¶
A daemon, a service with no terminal, wants no parent waiting on it, and
its classic launch orphans it deliberately. The launcher forks; the child forks
again and exits at once; the launcher reaps that child. The grandchild is now an
orphan, adopted by init, and nobody else's concern.
One call instead of two¶
Fold fork and exec into one spawn, and every adjustment made in the
window becomes a parameter: descriptors, the working directory, credentials,
signal settings, the process group, resource limits, and on Linux namespaces
and cgroups. The window needs none, because it is not an interface but a place
to run code, so an adjustment invented later needs no new call.
posix_spawn (POSIX 2001) shows the cost. Beside the path and argv it takes a
list of file actions (addopen, adddup2, addclose) and an attributes
object: a small language for what the window would have done. The strongest
modern case against fork is Baumann and colleagues' "A fork() in the road"
(HotOS 2019).
What a flat image costs¶
Two of oslings ship's refusals exist only because its output is flat. The
image must start at 0 and its entry must be 0, since a flat image has nowhere
to record either; ulib's entry macro and the user linker script arrange both.
The loader maps every image page alike, R X U, so the only page a shipped
command can store to is its stack page (Problem 4). 54k lifts
both limits by reading the ELF file instead.
What rv6 still lacks¶
ls at rv6$ is still a kernel-shell command, and it cannot become a user
program yet: rv6 offers no system call to read, make, or change into a
directory. That is the price of a userland: every power the kernel keeps must
be handed out again, one system call at a time. Also missing are dup,
signals, copy-on-write, preemption, and a second hart.
Practice problems¶
Problem 1: Predict the output¶
This program runs alone on rv6's cooperative scheduler. Each write puts one
letter on fd 1:
write "a"
p = fork()
if p == 0 { write "b"; q = fork(); if q == 0 { write "c"; exit(1) }; write "d"; exit(2) }
write "e"
wait(&s)
write "f"
exit(s)
(a) What prints, and with what status does the first process exit? (b) Which
process is never reaped by its own parent, and who frees it? (c) On Linux, with
preemption, which letters race, and can any letter follow f?
Click to reveal solution
(a) aebdcf, status 2. Call the processes P, A (P's child) and B (A's
child), in slots 0, 1 and 2:
turn prints what happens
P a, e forks A; wait finds no zombie, yields
A b, d forks B; exit(2): Zombie
B c exit(1): Zombie, its parent A already a zombie
P f wait reaps A (status 2); exit(2): the run is over
Round robin goes from slot 0 to 1 to 2 and back to 0, and nothing runs until the one before it yields or exits.
(b) B: its parent A exited without waiting. rv6 frees it with every other
leftover when the first process finishes; on Unix, init would adopt and reap
it.
(c) e races with b, c and d, since the child may run first; c races
with d. And c can print after f, even after P exits: nothing waits for
B.
Problem 2: A child it never forked¶
Program P starts alone, as pid 1 in slot 0 of an empty table. A claim takes the
lowest Unused slot:
P: a = fork()
if a == 0 { if fork() == 0 { exit(3) }; exit(5) } // A, and its child G
s1 = wait(&x); s2 = wait(&y)
c = fork()
if c == 0 { if wait(&z) < 0 { exit(0) }; exit(z + 10) } // C
wait(&w); exit(w)
Give each process's slot and pid, what every wait returns, and P's exit
status on rv6. Then say what Unix does differently.
Click to reveal solution
process slot pid parent pointer names
A 1 2 slot 0 (P)
G 2 3 slot 1 (A)
C 1 4 slot 0 (P) C reuses A's slot
P forks A and yields in its first wait. A forks G and exits 5; G exits 3. P's
first wait reaps A: s1 = 2, x = 5, and slot 1 is Unused. G's parent
pointer still names slot 1, not P, and P has no other child, so s2 = -1.
P forks C into slot 1. C's wait finds G, a zombie whose parent pointer names
slot 1, which is now C's: it reaps G, z = 3, and C exits 13. P reaps C and
exits 13.
On Unix, pid 1 is init itself, so let P be an ordinary process there. A's
exit hands G to init, which reaps it. s1 and s2 come out the same, but C
has no children, so its wait returns -1, C exits 0, and P exits 0.
Problem 3: Redirect in the window¶
notes.txt holds the cat sat\n. Your shell learns <: for
mycat < notes.txt, its child points fd 0 at the file
in the window. (a) mycat is never told about the
redirect. Why does it still read the file? (b) A classmate moves the redirect
before the fork. mycat still prints the file. What goes wrong afterward, on
rv6 and on Unix?
Click to reveal solution
(a) Open files survive exec, so mycat starts with fd 0 already naming
notes.txt. With no file argument it reads fd 0, whatever that is, and never
learns anything changed. The shell's fd 0, in its own table, is still the
console.
(b) Moved before the fork, the redirect changes the shell itself. Its fd 0 is
now notes.txt, so its next command line comes from the file, and the keyboard
is gone. On rv6 the shell's copy of the offset is still 0, so it reads
the cat sat as a command and tries to run the. Then every read returns 0,
which rv6's sh takes for a blank line, so it prints $ forever. On Unix
mycat moved the shared offset to the end, so the shell reads end of file and
exits, as if you had pressed Ctrl-D. The window exists so that only the child changes.
Problem 4: Size a shipped command¶
oslings ship produces a 9,500-byte image for a command you ship as
mytally. (a) How many pages hold the image, and how many zero bytes end the
last one? (b) List every page the running process maps, with its permissions.
(c) You add static mut HITS: u32 = 0;, increment it on every match, and print
it at the end. The command now dies at its first match. Why, and what happens to
the $ shell that ran it?
Click to reveal solution
(a) Three pages, since 8,192 < 9,500 ≤ 12,288. The last holds 9,500 − 8,192 = 1,308 image bytes and 4,096 − 1,308 = 2,788 zeros, because each page is zeroed before the copy. The image uses 14.5% of the 64 KiB budget.
(b)
| Virtual address | Contents | Flags |
|---|---|---|
0x0000, 0x1000, 0x2000 |
the image | R X U |
0x1_0000 |
the stack page; argv at its top, below 0x1_1000 |
R W U |
0x3F_FFFF_E000 |
the trapframe | R W |
0x3F_FFFF_F000 |
the trampoline | R X |
Pages 3 through 15 stay unmapped, so running off the image faults instead of reaching the stack.
(c) HITS starts at zero, so it lives in .bss, which oslings ship folded
into the image as zeros. Image pages have no W, so the first increment is a
store page fault, scause 15. A fault ends the whole run in rv6: the scheduler
frees every process but the root, the kernel shell then frees sh, and rv6$
prints run: the program faulted.
Key terms¶
| Term | Meaning | Where you use it |
|---|---|---|
fork |
Copy the caller; the parent gets the child's pid, the child 0 | 51k, 52k |
| Parent, child | The process that called fork, and its copy |
51k |
| Zombie | An exited process holding its pid and status until reaped | 51k |
| Reap | Collect a zombie's status and free its slot | 51k |
| Cooperative scheduling | A process leaves the CPU only by yielding or exiting | 51k |
exec |
Replace the caller's program and keep the process | 52k |
| User shell | sh: a user program looping read, fork, exec, wait |
52k |
| Builtin | A command the shell runs itself, because it changes the shell | 52k, Final |
| Backend | One of ulib's two implementations, chosen by the target |
53k |
| Flat image | Bytes copied to address 0 and run from byte 0 | 53k, 54k |
| Orphan | A live process whose parent has exited | Final |
init |
Pid 1, root of the process tree: starts the shell, adopts and reaps orphans | Final |
Further reading¶
- rv6 Architecture: Two shells and the program table.
- ulib and the Command Set: the rv6 backend,
oslings ship, and the budget. - Key Concepts: the Unix process API,
from
forktoinit. - Cheatsheet: system calls and processes.
- Exam Prep: how to prepare.
- Week 9: process states,
round robin,
and why
Zombieexists. - The xv6 book,
chapter 1, "Operating system interfaces", and the
sleepandwakeupsection of chapter 7. - Arpaci-Dusseau and Arpaci-Dusseau, Operating Systems: Three Easy Pieces, chapter 5, "Process API".
- A. Baumann, J. Appavoo, O. Krieger, T. Roscoe, A fork() in the road, HotOS 2019.
- The Linux
elf(5)manual page, for54k.