Section 6
Today’s section is about creating new processes in WeensyOS.
fork
Every operating system needs to support process creation—some way for one
process to start another one. Process creation always involves the kernel.
QUESTION. Why must process creation involve the kernel?
Process isolation. Distinct processes occupy isolated memory and run
independently of one another. Once the second process starts running, the
first process shouldn’t be able to modify it. Only the kernel can enforce
that kind of isolation, which requires (for example) page table
modifications.
In UNIX-derived operating systems, the basic process creation system call is called
fork. Here’s its description (based on the macOS and Linux manual pages):
fork() creates a new process by duplicating the calling process. The
new process is referred to as the child process. The calling process is
referred to as the parent process.
The child process and the parent process run in separate memory spaces. At
the time of fork() both memory spaces have the same content. Memory
writes performed by one process do not affect the other, though, so the
memory spaces quickly diverge.
In most other ways, the processes are identical, but the child process has
its own unique process ID that doesn’t match any other existing process.
(We’ll learn about other differences in the next unit.)
If fork() succeeds, it returns a value of 0 to the child process and
returns the process ID of the child process to the parent process.
Otherwise, a value of −1 is returned to the parent process and no child
process is created. Errors that might cause fork() to fail include
ENOMEM (fork() failed to allocate the necessary kernel structures
because memory is tight) and EAGAIN (the maximum number of process IDs was
reached).
This example program uses fork to create a child process. The child prints
its ID and immediately exits.
#include <print>
#include <unistd.h>
int main() {
std::print(stderr, "Hello from parent process with PID {}\n", getpid());
pid_t p = fork();
if (p == 0) {
std::print(stderr, "Hello from child process with PID {}\n", getpid());
exit(0);
} else if (p > 0) {
std::print(stderr, "Parent thinks child process has PID {}\n", p);
} else {
std::print(stderr, "fork failed\n");
}
}
Example output in Docker:
cs61-user@bfa9437376b4:~/cs61-sections/fork$ c++ -std=gnu++23 fork.cc
cs61-user@bfa9437376b4:~/cs61-sections/fork$ ./a.out
Hello from parent process with PID 60
Parent thinks child process has PID 61
Hello from child process with PID 61
QUESTION. What would happen if you reordered the function so fork()
happened before the first std::print?
The “Hello from parent process” message would appear twice, with different IDs! Example:
Hello from parent process with PID 170
Parent thinks child process has PID 171
Hello from parent process with PID 171
Hello from child process with PID 171
Note that the two “Hello from parent process” messages report different
PIDs! This is because they execute in different processes.
QUESTION. Can this program ever print “I am my own child”? Why or why
not?
#include <print>
#include <unistd.h>
int main() {
pid_t original_pid = getpid();
pid_t p = fork();
if (p == original_pid) {
std::print(stderr, "I am my own child!!!\n");
}
}
No, it can’t. fork() returns either the child process’s PID (to the
parent), or 0 (to the child), or a negative number (on error). The child
process’s PID is guaranteed to be unique, so it’ll never equal the
parent’s; and process IDs are always positive numbers.
QUESTION. Can this program ever print “I am my own child”? Why or why
not?
#include <print>
#include <unistd.h>
int main() {
pid_t original_pid = getpid();
fork();
pid_t p = getpid();
if (p == original_pid) {
std::print(stderr, "I am my own child!!!\n");
}
}
Sure it can! fork() doesn’t change the parent process’s ID, so in the
parent, original_pid and p will have the same value. Note that the
child will never print “I am my own child,” however.
WeensyOS sys_fork
In pset 3b, you’re asked to implement sys_fork() for WeensyOS.
This involves three broad steps:
- Allocate a new process table entry and PID.
- Allocate a new page table, and copy the parent’s page table into the
child’s. Kernel and reserved mappings are shared, but any user-accessible
memory (except the console) must be copied. This ensures that “memory
writes performed by one process do not affect the other”.
- Initialize the child process’s registers, including its return register,
and return the child process ID to the parent process.
Steps 1 and 2 can fail—but exactly how can they fail? In groups, work
through all the different ways in which a sys_fork call can fail. Be as
specific as you can.
- There might not be any unallocated space in the process table (the OS is
temporarily out of process IDs).
- There might not be enough space to allocate a page table
kalloc_pagetable().
- An attempt to map a physical page might fail.
- An attempt to allocate a page for a copy of one of the parent’s
user-accessible pages might fail.
Assuming that a sys_fork system call fails, what should the kernel do?
Discuss!
It is critically important that a failed attempt to sys_fork clean up
everything that was allocated as part of the attempt. If a process table
entry was allocated, it should be freed. If any memory was allocated, it
should be freed. The kernel’s memory state must be exactly as it was before
the system call. The same holds for most system calls: When a system call
fails, it does so without modifying kernel state.
Why is this so important? Isolation and robustness. The kernel must keep the
computer running regardless of what processes do. That means it must track
the ownership of every important resource, including every PID and every
memory page. If a failed sys_fork left a trail of unfreed pages and
unused, but unfreeable, process IDs, WeensyOS would quickly grid to a halt.
Of course the failed system call must also return a negative error code to
its caller.
Memory copy helpers
Let’s look at a couple kernel helper functions that resemble functions you’ll
need in the problem set. These functions also will give you practice with
vmiter. The function specifications are:
int addrspace_copy(x86_64_pagetable* dst, x86_64_pagetable* src)
- Copy all virtual memory mappings from
src into dst in the range [0, MEMSIZE_VIRTUAL).
Return 0 on success and a negative error code on error. If this function completes
successfully, then for any va with 0 <= va < MEMSIZE_VIRTUAL, dst and src
should map that va identically (to the same physical address, if any, and with the same permissions).
void addrspace_free_user(x86_64_pagetable* pt)
- Free all user-accessible memory mapped by
pt in the range [0,
MEMSIZE_VIRTUAL), not including the page mapped at CONSOLE_ADDR. (Free a
page by passing its kernel virtual address—that is, the vmiter::kptr()—to
the kfree function.)
Implement these functions in groups and then discuss.
int addrspace_copy(x86_64_pagetable* dst, x86_64_pagetable* src) {
vmiter dstit(dst, 0);
vmiter srcit(src, 0);
while (dstit.va() < MEMSIZE_VIRTUAL) {
if (dstit.try_map(srcit.pa(), srcit.perm()) < 0) {
return E_NOMEM;
}
dstit += PAGESIZE;
srcit += PAGESIZE;
}
return 0;
}
void addrspace_free_user(x86_64_pagetable* pt) {
// Use the fact that all user-accessible memory is mapped above PROC_START_ADDR
vmiter it(pt, PROC_START_ADDR);
while (it.va() < MEMSIZE_VIRTUAL) {
if (it.user()) {
kfree(it.kptr());
}
it += PAGESIZE;
}
}
QUESTION. A function like addrspace_free_user could be used to clean
up a partially-forked child process after a full copy attempt failed. But is
that function enough? What other resources would need to be freed to clean
up after the failed fork? If there are any, write a function that frees them.
It’s not enough. The process table slot needs to be cleaned up, and in
addition, the process’s page table needs to be freed too. The page table
occupies memory, but addrspace_free_user won’t free it, because (for
isolation reasons) none of the pages that make up the process’s page table
are marked as user-accessible within the process’s page table! We need a
separate function—specifically, one that uses
ptiter. Read the ptiter
description and then come back for the missing
function.
Here’s the missing function:
void free_pagetable(x86_64_pagetable* pt) {
for (ptiter pit(pt); pit.va() < MEMSIZE_VIRTUAL; pit.next()) {
kfree(pit.kptr());
}
kfree(pt);
}
The loop frees pt’s level-3, level-2, and level-1 page table pages; the
kfree after the loop frees pt (the level-4 page table page) itself.
From helpers to sys_fork
Discuss how to use the helpers in an implementation of sys_fork. How would
you change addrspace_copy to handle sys_fork’s requirements? When would
you call addrspace_free_user and any other functions? Sketch your
implementations and discuss potential pitfalls.
Spawn
If you have time, let’s look at a different interface for process creation.
The sys_spawn system call starts a process by name, rather than by copying
the current process. Its interface is as follows:
pid_t sys_spawn(const char* program)
- Start a new process running a named
program. program must equal one of
the processes supported by WeensyOS; in the lecture repository, for
instance, it can be "alice", "eve", "hello", "pipereader",
"pipewriter", "recurse", or "spawn". On success returns the ID of the
child process; on failure returns a negative error code.
EXERCISE. The p-spawn.cc process tries to start a copy of the p-eve
program—by calling sys_spawn("eve"). Update the lecture repository, cd kernel within Docker, and use make run-spawn to run this program. What’s
going on?
We see these messages:
USER PANIC: sys_spawn not implemented yet!
#0 0x1010ea
#1 0x100015
It sure sounds like sys_spawn isn’t implemented yet. By looking at
obj/p-spawn.asm, or searching for the sys_spawn not implemented yet
message, we can track the problem down to u-lib.cc’s implementation of
sys_spawn, which actually calls sys_panic instead:
pid_t sys_spawn(const char* program) {
(void) program;
make_syscall(SYSCALL_PANIC, (uintptr_t) "sys_spawn not implemented yet!");
while (true) {
}
}
EXERCISE. Complete the unprivileged portion of the system call
implementation for sys_spawn.
We must define a new system call number, and then use that number in the system call wrapper function:
// in lib.hh
#define SYSCALL_SPAWN 12
// in u-lib.cc
pid_t sys_spawn(const char* program) {
return make_syscall(SYSCALL_SPAWN, (uintptr_t) program);
}
EXERCISE. Now complete the privileged portion of the system call
implementation for sys_spawn. You don’t need to handle error cases, but make
it so that make run-spawn successfully runs both “Sheng” and “Eve”. (Hint:
You may be able to delegate most work to process_setup!)
// in kernel.cc:
// before syscall
pid_t syscall_spawn(const char* program);
// in syscall()’s `switch`
case SYSCALL_SPAWN:
return syscall_spawn(
reinterpret_cast<const char*>(current->regs.reg_rdi)
);
// at top level
pid_t syscall_spawn(const char* program) {
// find a free PID
pid_t p = 1;
while (p != MAXNPROC && ptable[p].state != P_FREE) {
++p;
}
if (p == MAXNPROC) { // no free slots
return E_AGAIN;
}
// use that slot
process_setup(p, program);
return p;
}
EXERCISE. Change p-spawn.cc to call sys_spawn("nonexistent") rather
than sys_spawn("eve"). What happens? Why? Is this behavior sensible, and
if not, how can you fix it?
I see this:
Process 2 page fault on 0x0 (read missing page, rip=0x0)!
PANIC: Unhandled exception 14 (rip=0x0)!
Process 2 is the new process, started by sys_spawn; so the new process has
immediately faulted. The fault was at a null-pointer address, and that address
was the instruction pointer (rip=0x0).
The explanation: process_setup initializes a new process from a
program_image, which enumerates the segments corresponding to a program. But
program_image for a nonexistent program has no segments, and returns 0 for its
entry() (the entry point %rip where the process should start executing). So
when we supply a nonexistent process, we fault at 0!
This is not sensible behavior, because the sys_spawn system call caused an
error, but was not informed that the error occurred. System call implementations
should check for errors and return error codes when they are found. We can fix
it by checking for the error; for instance, sys_spawn could check whether the
program_image is empty before calling process_setup:
...
program_image pgm(program);
if (pgm.empty()) {
return E_INVAL;
}
process_setup(p, program);
return p;
EXERCISE. Change p-spawn.cc to call sys_spawn("alice") rather
than sys_spawn("eve"). What happens? Why? Is this behavior sensible, and
if not, how can you fix it?
I see this:
kernel.cc:124: kernel assertion 'physpages[a / PAGESIZE].refcount == 0' failed
#1 0x40c70 <_ZL13process_setupiPKc>
#2 0x40fc1 <_Z13syscall_spawnPKc>
#3 0x411be <_Z7syscallP8regstate>
#4 0x40b28 <_Z13syscall_entryv>
A kernel assertion is failing during syscall_spawn’s call to process_setup.
The explanation: In the lecture code’s handout kernel, all processes share the
same page table (kernel_pagetable) and use identity mappings (the
only page mappable at virtual address X is the page with physical address X).
spawn and eve worked because spawn and eve are compiled to use different
ranges of the virtual address space (0x100000–0x13FFFF for spawn,
0x140000–0x17FFFF for eve). But spawn and alice use the same virtual
addresses, so in this simple kernel, they conflict!
The solution is to implement true process isolation (WeensyOS phase
2) with general address spaces (phase
3)—not too much work, but too much for section :)
DISCUSSION. What other potential errors remain? Can we make
syscall_spawn more robust?
The most serious remaining error is that the pointer argument to
syscall_spawn isn’t validated: the kernel isn’t checking whether
sys_spawn’s caller is allowed to access the memory they passed. This is a
variant of a confused deputy attack,
and could be used by an unscrupulous caller to steal kernel secrets. A fix
would involve checking the pointer using vmiter’s perm() function.