Lesson 2: Processes & Kernel Interface
After this lesson, you can write fork() + exec() + wait() from memory, trace every file descriptor through a pipeline, and explain the full path of Ctrl+C from keystroke to SIGINT delivery. You can read Ghostty's src/termio/Exec.zig and understand every line.
What is an Operating System?
The OS is a referee. Three jobs:
- Isolation: process A cannot read process B's memory (unless explicitly shared). The kernel enforces this with page tables and privilege rings.
- Multiplexing: 300 processes on 8 cores. The kernel switches between them (context switch: save registers, load new process's registers, switch page tables).
- Abstraction: files, sockets, pipes, processes — these are fictions. The hardware has disk blocks, network packets, and memory. The kernel gives you a coherent interface.
Kernel vs OS: the kernel is the privileged core (ring 0 on x86). The OS is the kernel plus userland (shell, init system, daemons, libraries). When we say "the kernel," we mean the part that runs in supervisor mode.
What is a Process?
A process is a task_struct (Linux) — a kernel data structure containing:
| Field | What it tracks |
|---|---|
pid |
Process ID |
mm_struct |
Address space (page tables, memory mappings) |
files_struct |
File descriptor table |
signal_struct |
Pending and blocked signals |
sched_entity |
Scheduling priority, CPU time, run queue position |
The kernel maintains exactly one task_struct per process. When a process is not running, its register state is saved in its task_struct. A context switch loads the next process's register state from its task_struct.
Process lifecycle
flowchart TD
A["created (fork)"] --> B["ready (run queue)"]
B --> C["running (on CPU)"]
C --> D["waiting (blocked on I/O)"]
D -->|"I/O done"| B
C --> E["terminated (exit)"]
E --> F["zombie (wait)"]
F --> G["reaped"]
fork()
pid_t pid = fork();
if (pid == 0) {
// child: pid is 0
} else {
// parent: pid is the child's PID
}
fork() clones the calling process. Both parent and child continue executing from the same point (the instruction after fork()). They are identical except:
fork()returns child PID to parent, 0 to child- Child gets a new PID
- File descriptors are shared (both point to the same kernel file table entries)
- Pending signals are not inherited
Why fork() is fast
Copy-on-write. The kernel copies the page table, not the pages. Both parent and child's page table entries point to the same physical pages, marked read-only. When either writes, the hardware triggers a page fault. The kernel allocates a new physical page, copies the data, updates the page table, marks it writable, and resumes. Only pages that actually diverge get copied.
The practical implication: fork() is cheap. fork() + exec() is the standard pattern for creating processes. The fork creates a clone, the exec replaces it. Between the two, the child sets up file descriptors (dup2, close) before the exec.
exec()
execl("/bin/ls", "ls", "-l", NULL); // list form, null-terminated
execvp("ls", argv); // p = search PATH
execve("/bin/ls", argv, envp); // ve = vector + environment
exec() replaces the current process image with a new program. It does NOT create a new process. The PID, file descriptors (unless marked CLOEXEC), and process group remain unchanged. What changes: the address space (new page tables), the program counter (entry point of the new binary), the stack, and the registers.
The ELF loader mmaps segments lazily:
.text(code): read-only, executable, demand-paged from the binary file.data(initialized globals): read-write, copy-on-write from the binary.bss(zero-initialized globals): anonymous read-write pages, zero-filled on first access- Stack and heap: anonymous pages
graph TD
A[fork] --> B{pid == 0?}
B -->|yes, child| C[close/setup FDs<br>dup2, close]
C --> D[exec new program]
B -->|no, parent| E[wait for child]
File Descriptors
File descriptors are integers that index into a per-process table. Three kernel tables are involved:
flowchart LR
subgraph P["Process A FD Table"]
fd0["fd 0 (stdin)"]
fd1["fd 1 (stdout)"]
fd2["fd 2 (stderr)"]
fd3["fd 3 (pipe)"]
end
subgraph OF["Kernel Open File Table"]
of0["entry: offset 0, read-only"]
of1["entry: offset 0, write-only"]
of2["entry: offset 42, read-write"]
end
subgraph V["Kernel Vnode Table"]
vn["/dev/pts/5"]
end
fd0 --> of0
fd1 --> of1
fd2 --> of1
fd3 --> of2
of0 --> vn
of1 --> vn
Three levels:
- Per-process fd table:
fd→ pointer to open file entry. Defaults: 0=stdin, 1=stdout, 2=stderr. - Open file table (kernel-global): tracks the current file offset and access mode. Multiple fd entries (across processes!) can point to the same open file entry.
- Vnode table: the actual file/inode. Multiple open file entries can point to the same vnode.
pipe()
int fds[2];
pipe(fds); // fds[0] = read end, fds[1] = write end
A pipe is a kernel buffer with a read end and write end. Writes append to the buffer. Reads consume from it. When the buffer is full, write() blocks. When the buffer is empty and the write end is closed, read() returns 0 (EOF).
dup2()
dup2(fds[1], STDOUT_FILENO); // close(1), then copy fds[1] → fd 1
dup2(oldfd, newfd) makes newfd point to the same open file entry as oldfd. If newfd was open, it's closed first. This is how redirection works.
Why open() returns 3
open() returns the lowest available file descriptor. 0, 1, and 2 are already taken (stdin, stdout, stderr). So open() returns 3.
Building a Pipeline (ls | wc)
int pipefd[2];
pipe(pipefd); // create pipe BEFORE forking
if (fork() == 0) {
// Child 1: ls
close(pipefd[0]); // close read end
dup2(pipefd[1], STDOUT_FILENO); // redirect stdout → pipe
close(pipefd[1]); // close original write fd
execlp("ls", "ls", NULL);
}
if (fork() == 0) {
// Child 2: wc
close(pipefd[1]); // close write end
dup2(pipefd[0], STDIN_FILENO); // redirect stdin → pipe
close(pipefd[0]); // close original read fd
execlp("wc", "wc", NULL);
}
// Parent: close BOTH ends
close(pipefd[0]);
close(pipefd[1]);
wait(NULL); wait(NULL);
Critical: the parent must close both pipe ends. If the parent holds the write end open, the reader (wc) will never see EOF and will hang forever. This is the #1 pipe bug.
wait() and Zombies
A process that has exited but not yet been waited on is a zombie. The kernel keeps the task_struct and exit status so the parent can collect it. A zombie consumes a PID and a small amount of kernel memory.
pid_t pid = wait(&status); // blocks until any child exits
pid_t pid = waitpid(pid, &status, WNOHANG); // non-blocking, specific child
When the parent calls wait(), the zombie is reaped — the kernel frees the task_struct. If the parent exits first, children are reparented to PID 1 (init), which reaps them.
SIGCHLD
Instead of blocking on wait(), the parent can register a SIGCHLD handler:
void sigchld_handler(int sig) {
int saved_errno = errno;
while (waitpid(-1, NULL, WNOHANG) > 0) {} // reap all zombies
errno = saved_errno;
}
signal(SIGCHLD, sigchld_handler);
The while loop is necessary because multiple children may have exited before the handler runs (signals are not queued — multiple SIGCHLDs can be coalesced into one delivery).
Signals
Signals are asynchronous notifications from the kernel to a process. Lifecycle:
pending (kernel sets a bit) → delivered (handler runs or default action)
A signal can be blocked (delivery deferred) via the signal mask. While blocked, the signal remains pending. Standard signals (< 32) are not queued — multiple deliveries of the same signal may be coalesced.
Key signals for terminals
| Signal | Default action | Terminal relevance |
|---|---|---|
SIGINT |
Terminate | Ctrl+C — interrupt foreground process group |
SIGQUIT |
Core dump | Ctrl+\ — quit with core dump |
SIGTSTP |
Suspend | Ctrl+Z — suspend foreground process group |
SIGCONT |
Continue | Resume a suspended process |
SIGCHLD |
Ignore | Child process state change |
SIGWINCH |
Ignore | Terminal window size changed |
SIGKILL |
Terminate (can't catch) | Force kill |
SIGTERM |
Terminate | Polite termination request |
How Ctrl+C works end-to-end
- User presses Ctrl+C on keyboard
- Ghostty receives the key event, writes
0x03(ASCII ETX) to the PTY master - The line discipline receives
0x03, checksISIGflag in termios (enabled by default) - Line discipline sends SIGINT to the foreground process group of the terminal
- Each process in the foreground group receives SIGINT
- Default action: terminate
The PTY is not the terminal. The PTY is a pair of character devices. The line discipline is kernel code attached to the slave. The terminal emulator (Ghostty) controls the master side. The shell and its children use the slave side. Ctrl+C travels through the emulator, into the PTY, where the line discipline interprets it.
minish Project
The incremental shell project spans this lesson. Each version adds one capability.
v1: Basic REPL (~30 lines)
while (1) {
printf("$ ");
fgets(line, sizeof(line), stdin);
if (fork() == 0) {
execvp(argv[0], argv);
perror("exec");
exit(1);
}
wait(NULL);
}
Purpose: feel the fork/exec/wait cycle in your fingers.
v2: PATH resolution
Parse $PATH (colon-separated directories). For each command, try <dir>/<command> with access(X_OK). Use the first match.
v3: Pipes
Parse |. Create one pipe per |. Fork N+1 children for N pipes. Wire stdin/stdout with dup2. Close all pipe ends in the parent.
v4: Redirection
Parse <, >, >>. open() with O_CREAT|O_TRUNC|O_APPEND. dup2 into stdin/stdout.
v5: Background jobs + signals
Parse &. Don't wait for background processes. Install SIGCHLD handler that reaps zombies with waitpid(WNOHANG). Block SIGCHLD during critical sections.
v6: Job control
Track jobs (pid, command, state: running/stopped). Implement fg and bg builtins. Use tcsetpgrp() to give terminal control to the foreground process. Handle SIGTSTP (Ctrl+Z) to suspend, SIGCONT to resume.
v7: PTY integration (see Lesson 3)
Replace pipes with a PTY pair. openpty() → fork → child dup2(slave, 0/1/2) → exec(zsh). Parent reads/writes master fd. Now minish has the same I/O architecture as Ghostty (minus the GUI).
Ghostty Source to Study
| File | What to study |
|---|---|
src/termio/Exec.zig |
Subprocess creation: fork, setsid, dup2, exec, CLOEXEC |
src/termio/Termio.zig |
Master FD read/write loop, signal handling |
src/terminfo.zig |
Terminfo database loading |
Self-Check
Can you:
- Write
fork()+exec()+wait()from memory - Draw the three kernel tables (fd, open file, vnode) and trace
dup2() - Explain why a zombie exists and how to prevent them
- Write a SIGCHLD handler that reaps all children
- Explain the full path of Ctrl+C: keystroke → Ghostty → PTY → kernel → SIGINT → process group
- Build a pipeline (
ls | grep foo | wc) with proper fd cleanup in the parent - Explain why
open()returns 3, not 0 (lowest available fd)