Chapter 8. Exceptional Control Flow
Control flow: the sequence of instruction addresses executed by the processor. Smooth flow means each instruction is adjacent in memory to the previous one; jumps, calls, and returns cause abrupt changes in response to program state. Systems must also react to changes in system state not captured by program variables (timer expiry, packet arrival, I/O completion, child termination). Abrupt control changes in response to such events are exceptional control flow (ECF).
ECF exists at every level:
| Level | Mechanism |
|---|---|
| Hardware | Exceptions → exception handlers |
| Operating system | Context switches between processes |
| Application ↔ OS | Traps / system calls; signals |
| Application | Nonlocal jumps (setjmp/longjmp) |
ECF is the basic mechanism behind I/O, processes, and virtual memory; system calls are the interface through which applications request OS services; and ECF is the first form of concurrency (handlers, processes, signal handlers all interleave with other flows). Software exceptions (try/catch/throw) are an application-level analogue of nonlocal jumps.
8.1 Exceptions
An exception is an abrupt change in control flow in response to a change in processor state. The state change is an event. On detecting an event, the processor makes an indirect procedure call through an exception table to an OS subroutine, the exception handler. After handling, one of three things happens:
- Return to the current instruction
I_curr(the one executing when the event occurred). - Return to
I_next(the instruction that would have executed next). - Abort the interrupted program.
8.1.1 Exception handling
- Each exception type has a unique nonnegative exception number: some assigned by the processor designers (divide by zero, page faults, protection faults, breakpoints, overflow), others by the OS kernel designers (system calls, I/O device signals).
- At boot, the OS allocates and initializes the exception table: entry k holds the address of the handler for exception k.
- At run time, the processor determines the exception number k and calls indirectly through entry k. The table's start address sits in a special CPU register, the exception table base register; the handler address is formed by adding the base to k scaled by the entry size.
Differences from a procedure call:
- The pushed return address is either
I_currorI_next, depending on the exception class. - The processor also pushes additional state (e.g., the EFLAGS register with current condition codes) needed to restart the interrupted program.
- On a user→kernel transfer, all of these items are pushed onto the kernel's stack, not the user's stack.
- Handlers run in kernel mode: complete access to all system resources.
The handler returns via a special "return from interrupt" instruction that pops the saved state back into the control and data registers, restores user mode if a user program was interrupted, and resumes the interrupted program.
8.1.2 Classes of exceptions
| Class | Cause | Async/sync | Return behavior |
|---|---|---|---|
| Interrupt | Signal from I/O device | Async | Always returns to next instruction |
| Trap | Intentional exception | Sync | Always returns to next instruction |
| Fault | Potentially recoverable error | Sync | Might return to current instruction |
| Abort | Nonrecoverable error | Sync | Never returns |
Synchronous exceptions result from executing the current instruction, called the faulting instruction; interrupts are not caused by any particular instruction.
Interrupts. I/O devices (timer chips, disk controllers, network adapters) raise a pin on the processor and place the exception number of the device on the system bus. After the current instruction finishes, the processor reads the number and calls the interrupt handler; on return, execution continues at the next instruction as if the interrupt never happened.
Traps and system calls. Traps are intentional exceptions, produced by executing an instruction; the handler returns to the next instruction. Their most important use is the system call: a procedure-like interface between user programs and the kernel. A syscall n instruction traps to a handler that decodes the argument and dispatches to the appropriate kernel routine. From the programmer's view a system call looks like a function call, but a regular function runs in user mode on the caller's stack, while a system call runs in kernel mode, may execute privileged instructions, and uses a stack private to the kernel.
Faults. A fault handler either corrects the error and returns to the faulting instruction, re-executing it, or returns to a kernel abort routine that terminates the program. Classic example: page fault — the referenced virtual page (typically 4 KB) is not resident; the handler loads it from disk and restarts the faulting instruction, which then completes.
Aborts. Unrecoverable fatal errors, typically hardware (e.g., parity errors from corrupted DRAM/SRAM bits). The handler never returns to the application; control passes to a routine that terminates it.
8.1.3 Exceptions in Linux/x86-64
Up to 256 exception types. Numbers 0–31 are defined by the Intel architects, identical on any x86-64 system; 32–255 are OS-defined interrupts and traps.
| # | Exception | Class | Notes |
|---|---|---|---|
| 0 | Divide error | Fault | Divide by zero, or quotient too big for destination. Unix does not recover; it aborts the program. Shells report it as "Floating point exception". |
| 13 | General protection fault | Fault | Reference to an undefined area of virtual memory, or write to a read-only text segment. Not recovered; shells report "Segmentation fault". |
| 14 | Page fault | Fault | Handler maps the page into physical memory and restarts the faulting instruction. |
| 18 | Machine check | Abort | Fatal hardware error detected during the faulting instruction; never returns. |
System calls. Each has a unique number, an offset into a jump table in the kernel (distinct from the exception table). Popular examples:
| # | Name | # | Name |
|---|---|---|---|
| 0 | read |
37 | alarm |
| 1 | write |
39 | getpid |
| 2 | open |
57 | fork |
| 3 | close |
59 | execve |
| 4 | stat |
60 | _exit |
| 9 | mmap |
61 | wait4 |
| 12 | brk |
62 | kill |
| 33 | dup2 |
34 | pause |
CSAPP Figure 8.10 lists
dup2= 32 andpause= 33; the actual Linux x86-64 table hasdup= 32,dup2= 33,pause= 34.
The C standard library provides wrapper functions for most system calls; wrappers package arguments, trap with syscall, and pass back the return status. System calls and their wrappers are referred to interchangeably as system-level functions.
Register convention (x86-64):
%rax: syscall number; also holds the return value.- Arguments (up to six, in order):
%rdi,%rsi,%rdx,%r10,%r8,%r9. All arguments pass through registers, never the stack. %rcxand%r11are destroyed by the call.- A return value in −4,095 … −1 indicates an error: it is the negated
errno.
hello implemented directly with system calls:
movq $1, %rax # write is system call 1
movq $1, %rdi # arg1: stdout has descriptor 1
movq $string, %rsi # arg2: string address
movq $len, %rdx # arg3: string length
syscall
movq $60, %rax # _exit is system call 60
movq $0, %rdi # arg1: exit status 0
syscall
8.2 Processes
A process is an instance of a program in execution. Every program runs in the context of some process; the context is the state the program needs to run correctly: code and data in memory, stack, general-purpose register contents, program counter, environment variables, open file descriptors.
Key abstractions a process provides:
- An independent logical control flow — illusion of exclusive use of the processor.
- A private address space — illusion of exclusive use of the memory system.
8.2.1 Logical control flow
The sequence of program-counter values corresponding exclusively to instructions in the program's executable (or dynamically linked shared objects) is its logical control flow, or flow. The single physical control flow of the processor is partitioned among processes; each runs a portion of its flow and is then preempted (temporarily suspended) while others take turns. The only evidence against exclusive processor use is that precise timing would reveal periodic stalls — after which execution resumes with memory and registers unchanged.
8.2.2 Concurrent flows
Flows X and Y are concurrent iff X begins after Y begins and before Y finishes, or Y begins after X begins and before X finishes (i.e., their executions overlap in time). Exception handlers, processes, signal handlers, and threads are all logical flows.
- The general phenomenon of multiple overlapping flows: concurrency.
- Processes taking turns: multitasking (time slicing); each execution period is a time slice.
- Concurrency is independent of the number of cores: overlapping flows on one processor are concurrent.
- Parallel flows are the proper subset of concurrent flows running at the same time on different cores or computers.
8.2.3 Private address space
On a machine with n-bit addresses, the address space is the 2ⁿ addresses 0 … 2ⁿ−1. Memory associated with an address in a process's private space cannot in general be read or written by any other process. Classic x86-64 Linux organization, bottom to top:
- Read-only code segment, starting at
0x400000(.init,.text,.rodata) - Read/write segment (
.data,.bss) - Run-time heap, grown by
malloc; top marked bybrk - Memory-mapped region for shared libraries
- User stack, growing downward from
%rsp - Kernel virtual memory at the top — code, data, and stack the kernel uses when executing on the process's behalf; invisible to user code
8.2.4 User and kernel modes
A mode bit in a control register defines current privilege:
- Kernel (supervisor) mode: any instruction, any memory location.
- User mode: no privileged instructions (halt, change mode bit, initiate I/O) and no direct reference to kernel code or data; any attempt causes a fatal protection fault. Kernel services are reached only via the system call interface.
A process starts in user mode; the only way into kernel mode is an exception (interrupt, fault, or trapping system call). The handler runs in kernel mode; returning to application code switches back to user mode.
The /proc filesystem exports kernel data structures as a hierarchy of text files readable by user-mode processes (e.g., /proc/cpuinfo, /proc/<pid>/maps); /sys exports low-level information about system buses and devices.
8.2.5 Context switches
The kernel maintains a context per process: general-purpose and floating-point registers, program counter, user's stack, status registers, kernel's stack, and kernel data structures (page table, process table, file table). Scheduling — the kernel's decision to preempt the current process and restart a previously preempted one — is done by the scheduler. A context switch (1) saves the context of the current process, (2) restores the saved context of a previously preempted process, (3) passes control to it.
Switches occur:
- During a system call, if it blocks (e.g.,
readawaiting a disk transfer, orsleep) — the kernel may also switch even if the call does not block. - On interrupts, notably periodic timer interrupts (every 1 ms or 10 ms), when the kernel decides the current process has run long enough.
There is no separate kernel process: the kernel executes in kernel mode on behalf of some process, and mid-switch it begins executing on behalf of the next one.
8.3 System call error handling
Unix system-level functions typically return −1 on error and set the global integer errno; strerror(errno) yields a descriptive string. Idiomatic reporting:
Error-handling wrappers (Stevens): for base function foo, wrapper Foo has identical arguments, calls foo, checks for errors, and terminates on failure:
Text convention: functions are discussed by their lowercase base names regardless of wrapper use.
8.4 Process control
8.4.1 Process IDs
pid_t is defined as int on Linux (types.h).
8.4.2 Creating and terminating processes
Process states from the programmer's view:
- Running — executing on the CPU or waiting to be scheduled.
- Stopped — suspended, not scheduled; entered on SIGSTOP, SIGTSTP, SIGTTIN, or SIGTTOU; leaves on SIGCONT.
- Terminated — stopped permanently, by (1) a signal whose default action is termination, (2) return from
main, or (3) callingexit(status)(which does not return).
The child gets an identical but separate copy of the parent's user-level virtual address space (code, data, heap, shared libraries, user stack) and identical copies of the parent's open file descriptors. The processes differ in PID. Properties:
- Called once, returns twice: once in the parent (child's PID) and once in the child (0). Since a child's PID is always nonzero, the return value disambiguates parent from child.
- Concurrent execution: parent and child run concurrently; the kernel interleaves their instructions arbitrarily. No assumption about interleaving is valid.
- Duplicate but separate address spaces: immediately after
fork, both spaces are identical (same local/global variable values, heap, code); subsequent changes are private to each process. - Shared files: the child inherits the parent's open files (e.g., an open
stdoutdirected to the screen).
Process graphs: vertices are statement executions; edge a → b means a happens before b; edges may be labeled with variable values, printf vertices with output. For a program on a single processor, any topological sort of the graph is a feasible total ordering of its statements; orderings that are not topological sorts are infeasible.
8.4.3 Reaping child processes
A terminated process is kept in a terminated state until reaped by its parent: the kernel passes the child's exit status to the parent, then discards the process. A terminated-but-unreaped process is a zombie; zombies consume system memory even though they do not run.
The init process (PID 1, created at boot, never terminates, ancestor of every process) becomes the adopted parent of orphaned children and reaps zombies whose parent terminated without reaping them. Long-running programs (shells, servers) must reap their own zombies.
pid_t waitpid(pid_t pid, int *statusp, int options);
/* Returns: PID of child if OK, 0 (if WNOHANG), -1 on error */
By default (options = 0), suspends the caller until a child in the wait set terminates; if one has already terminated, returns immediately. Returns the PID of the reaped child.
Wait set:
pid > 0— the single child with that PID.pid = -1— all of the parent's children. (Other wait sets involving process groups exist.)
Options (combinable by OR):
WNOHANG— return 0 immediately if no child in the wait set has terminated yet.WUNTRACED— also return for stopped children (default reports only terminated ones).WCONTINUED— also return when a stopped child resumes on SIGCONT.
Status macros (wait.h) on *statusp:
| Macro | Meaning |
|---|---|
WIFEXITED(status) |
True if child terminated normally (exit or return) |
WEXITSTATUS(status) |
Exit status; defined only if WIFEXITED |
WIFSIGNALED(status) |
True if child terminated by an uncaught signal |
WTERMSIG(status) |
Number of the terminating signal; only if WIFSIGNALED |
WIFSTOPPED(status) |
True if the returned-for child is stopped |
WSTOPSIG(status) |
Number of the stopping signal; only if WIFSTOPPED |
WIFCONTINUED(status) |
True if child was restarted by SIGCONT |
Errors: no children → −1, errno = ECHILD; interrupted by a signal → −1, errno = EINTR.
Reaping in no particular order:
while ((pid = waitpid(-1, &status, 0)) > 0) {
if (WIFEXITED(status))
printf("child %d terminated normally with exit status=%d\n",
pid, WEXITSTATUS(status));
else
printf("child %d terminated abnormally\n", pid);
}
if (errno != ECHILD)
unix_error("waitpid error");
The reap order is nondeterministic; no ordering assumption is correct. To reap in creation order, store child PIDs in an array and call waitpid(pid[i++], &status, 0) in a loop.
8.4.4 Putting processes to sleep
unsigned int sleep(unsigned int secs);
/* Returns: 0 if the full time elapsed, else seconds left to sleep
(possible when interrupted by a signal) */
int pause(void); /* Sleeps until a signal is received. Always returns -1 */
8.4.5 Loading and running programs
int execve(const char *filename, const char *argv[], const char *envp[]);
/* Does not return if OK; returns -1 on error */
Loads and runs executable filename with argument list argv and environment list envp. Called once and never returns (contrast fork: once, twice). Both lists are null-terminated arrays of string pointers; argv[0] is by convention the executable's name; environment strings have the form name=value.
After loading, the startup code sets up the stack and passes control to main(int argc, char **argv, char **envp). Stack organization at entry, from bottom (highest address) to top: argument and environment strings; null-terminated envp[] array (global environ points to envp[0]); null-terminated argv[] array; stack frame for libc_start_main. Arguments to main: argc in %rdi, argv in %rsi, envp in %rdx.
char *getenv(const char *name);
/* Returns: pointer to value if name exists, NULL otherwise */
int setenv(const char *name, const char *newvalue, int overwrite);
/* Adds name=newvalue, or replaces existing value iff overwrite is nonzero.
Returns: 0 on success, -1 on error */
void unsetenv(const char *name); /* Deletes name=value if present */
8.4.6 Using fork and execve to run programs
Program vs. process: a program is a collection of code and data (an object file, or segments in an address space); a process is a specific instance of a program in execution. fork runs the same program in a new (duplicate) process. execve loads and runs a new program in the context of the current process — it overwrites the address space but does not create a new process; the PID is unchanged and open file descriptors are inherited.
A shell is an interactive application-level program that runs programs on behalf of the user (sh, csh, tcsh, ksh, bash) via a read/evaluate loop:
- Read a command line from stdin.
- Evaluate:
parselinesplits the space-separated words into anargvvector; a trailing&means run the job in the background (shell does not wait), otherwise foreground (shell waits). Ifargv[0]is a built-in command, interpret it immediately. Otherwiseforka child,execve(argv[0], argv, environ)inside it (printing "Command not found" and exiting if it fails); for a foreground job the parent blocks inwaitpid, for a background job it prints the PID and command line and continues.
A shell with only this logic is flawed: it never reaps background children. Fixing that requires signals.
8.5 Signals
A signal is a small message notifying a process that an event of some type occurred. Signals expose low-level hardware exceptions (normally processed invisibly by the kernel) to user processes, and also convey higher-level events: e.g., SIGSEGV for a page-fault-detected illegal reference; SIGINT to each process in the foreground process group on Ctrl+C; SIGKILL to forcibly terminate a process; SIGCHLD to a parent when a child terminates or stops.
The 30 Linux signal types:
| # | Name | Default action | Event |
|---|---|---|---|
| 1 | SIGHUP | Terminate | Terminal line hangup |
| 2 | SIGINT | Terminate | Interrupt from keyboard |
| 3 | SIGQUIT | Terminate + dump core | Quit from keyboard |
| 4 | SIGILL | Terminate + dump core | Illegal instruction |
| 5 | SIGTRAP | Terminate + dump core | Trace trap |
| 6 | SIGABRT | Terminate + dump core | Abort signal from abort |
| 7 | SIGBUS | Terminate | Bus error |
| 8 | SIGFPE | Terminate + dump core | Floating-point exception |
| 9 | SIGKILL | Terminate¹ | Kill program |
| 10 | SIGUSR1 | Terminate | User-defined signal 1 |
| 11 | SIGSEGV | Terminate + dump core | Invalid memory reference |
| 12 | SIGUSR2 | Terminate | User-defined signal 2 |
| 13 | SIGPIPE | Terminate | Wrote to a pipe with no reader |
| 14 | SIGALRM | Terminate | Timer signal from alarm |
| 15 | SIGTERM | Terminate | Software termination signal |
| 16 | SIGSTKFLT | Terminate | Stack fault on coprocessor |
| 17 | SIGCHLD | Ignore | Child process stopped or terminated |
| 18 | SIGCONT | Ignore | Continue process if stopped |
| 19 | SIGSTOP | Stop until next SIGCONT¹ | Stop signal not from terminal |
| 20 | SIGTSTP | Stop until next SIGCONT | Stop signal from terminal |
| 21 | SIGTTIN | Stop until next SIGCONT | Background process read from terminal |
| 22 | SIGTTOU | Stop until next SIGCONT | Background process wrote to terminal |
| 23 | SIGURG | Ignore | Urgent condition on socket |
| 24 | SIGXCPU | Terminate | CPU time limit exceeded |
| 25 | SIGXFSZ | Terminate | File size limit exceeded |
| 26 | SIGVTALRM | Terminate | Virtual timer expired |
| 27 | SIGPROF | Terminate | Profiling timer expired |
| 28 | SIGWINCH | Ignore | Window size changed |
| 29 | SIGIO | Terminate | I/O now possible on a descriptor |
| 30 | SIGPWR | Terminate | Power failure |
¹ Cannot be caught or ignored. ("Dumping core" writes an image of the code and data memory segments to disk; "core" is a historical term for main memory.)
8.5.1 Signal terminology
Signal transfer has two distinct steps:
- Sending (delivering): the kernel updates state in the destination process's context, either because it detected a system event (divide-by-zero, child termination) or because a process invoked
kill(a process can signal itself). - Receiving: the destination process is forced by the kernel to react — ignore, terminate, or catch the signal by executing a user-level signal handler.
A sent-but-not-yet-received signal is pending. There is at most one pending signal of each type per process: the pending bit vector has one bit per type, set on delivery and cleared on receipt. Additional signals of an already-pending type are discarded, not queued. A process may block receipt of selected signals: a blocked signal can be delivered and stays pending, but is not received until unblocked. The blocked bit vector is also called the signal mask.
8.5.2 Sending signals
All Unix sending mechanisms rely on process groups. Every process belongs to exactly one group, identified by a positive integer process group ID; a child belongs to its parent's group by default.
pid_t getpgrp(void); /* group ID of caller */
int setpgid(pid_t pid, pid_t pgid); /* 0 on success, -1 on error */
setpgid(pid, pgid) moves process pid to group pgid; pid = 0 means the caller, pgid = 0 means use the PID of the process named by pid as the group ID (so setpgid(0, 0) creates a new group whose ID is the caller's PID).
From the shell: /bin/kill -9 15213 sends SIGKILL to process 15213; a negative PID (/bin/kill -9 -15213) sends it to every process in group 15213. (Full path because some shells have a built-in kill.)
From the keyboard: shells represent each evaluated command line as a job; at any time there is at most one foreground job and zero or more background jobs, each in its own process group (typically ID taken from one of the job's parent processes). Ctrl+C makes the kernel send SIGINT, Ctrl+Z SIGTSTP, to every process in the foreground process group.
From a program:
pid > 0: signal to processpid.pid = 0: signal to every process in the caller's process group, including the caller.pid < 0: signal to every process in group|pid|.
unsigned int alarm(unsigned int secs);
/* Returns: seconds remaining on any previously pending alarm, else 0 */
Arranges for SIGALRM to be sent to the caller in secs seconds; secs = 0 schedules no new alarm. Any pending alarm is canceled.
8.5.3 Receiving signals
When the kernel switches a process from kernel to user mode (return from a system call, or completion of a context switch), it checks the set of unblocked pending signals (pending & ~blocked). If empty, control passes to the next instruction of the process's logical flow. Otherwise the kernel chooses some signal k in the set (typically the smallest) and forces the process to receive it; after the resulting action, control passes to the next instruction. Each type has one of four predefined default actions: terminate; terminate and dump core; stop until SIGCONT; ignore.
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
/* Returns: pointer to previous handler if OK, SIG_ERR on error (errno not set) */
handler may be SIG_IGN (ignore signum), SIG_DFL (restore default action), or the address of a user function — installing the handler; invoking it is catching, executing it is handling the signal. The defaults of SIGSTOP and SIGKILL cannot be changed. The handler receives the signal number as its argument, allowing one function to catch several types. When the handler returns, control (usually) passes back to the instruction where the process was interrupted — on some systems interrupted system calls instead return immediately with an error.
Handlers are a form of concurrency: a handler can itself be interrupted by a handler for a different signal type, which runs to completion before the first resumes.
8.5.4 Blocking and unblocking signals
- Implicit: the kernel blocks pending signals of the type currently being handled.
- Explicit:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigemptyset(sigset_t *set); /* set := {} */
int sigfillset(sigset_t *set); /* set := all signals */
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
/* All return 0 if OK, -1 on error */
int sigismember(const sigset_t *set, int signum); /* 1 if member, 0 if not, -1 on error */
how: SIG_BLOCK (blocked |= set), SIG_UNBLOCK (blocked &= ~set), SIG_SETMASK (blocked = set). If oldset is non-NULL, the previous blocked vector is stored there.
Temporarily blocking SIGINT:
sigset_t mask, prev_mask;
Sigemptyset(&mask);
Sigaddset(&mask, SIGINT);
Sigprocmask(SIG_BLOCK, &mask, &prev_mask);
/* ... code not interruptible by SIGINT ... */
Sigprocmask(SIG_SETMASK, &prev_mask, NULL);
8.5.5 Writing signal handlers
Handlers run concurrently with the main program and with each other; concurrent access to shared global data yields unpredictable results. Conservative guidelines:
- G0. Keep handlers as simple as possible. E.g., set a global flag and return; the main program periodically checks and resets the flag and does the processing.
- G1. Call only async-signal-safe functions. A function is async-signal-safe if it is reentrant (accesses only local variables) or cannot be interrupted by a handler. Linux guarantees a specific list (
man 7 signal), including_exit,write,wait,waitpid,sleep,kill,signal,sigprocmask— but notprintf,sprintf,malloc, orexit. The only safe way to produce output from a handler iswrite; the Sio (Safe I/O) package wraps it:
ssize_t sio_puts(char s[]); /* emit string to stdout */
ssize_t sio_putl(long v); /* emit long to stdout */
void sio_error(char s[]); /* emit message, _exit(1) */
- G2. Save and restore
errno. Many safe functions seterrnoon error, which can interfere with code that relies on it. Save it to a local on handler entry and restore before returning; unnecessary if the handler ends with_exit. - G3. Protect accesses to shared global data structures by blocking all signals (in both handlers and the main program) around the access. Accessing a structure usually takes several instructions; a handler interrupting mid-sequence may see it in an inconsistent state.
- G4. Declare global variables
volatile. Otherwise an optimizing compiler may cache a variable in a register, and the main routine never sees handler updates;volatileforces every reference to read memory. - G5. Declare flags
sig_atomic_t. Reads and writes of such flags are guaranteed atomic and need no signal blocking — but only individual reads and writes;flag++orflag = flag + 1may take multiple instructions and is not covered.
These are conservative: e.g., if a handler provably cannot modify errno, saving it is unnecessary — but such proofs are hard in general.
Correct handling — signals are not queued. A pending signal only means at least one signal of that type has arrived. Consequence: signals cannot be used to count events in other processes. Flawed pattern: a SIGCHLD handler that reaps exactly one child per invocation. If three children terminate in quick succession, the first SIGCHLD is caught; the second becomes pending (SIGCHLD is implicitly blocked during the handler); the third is discarded because one is already pending. Only two children ever get reaped; the third remains a zombie (<defunct> in ps). Fix — reap as many zombies as possible per invocation:
void handler2(int sig)
{
int olderrno = errno;
while (waitpid(-1, NULL, 0) > 0) {
Sio_puts("Handler reaped child\n");
}
if (errno != ECHILD)
Sio_error("waitpid error");
Sleep(1);
errno = olderrno;
}
Portable handling. Older Unix variants differ: some reset the action for signal k to the default after it is caught, requiring the handler to reinstall itself; on some, slow system calls (read, wait, accept, …) interrupted by a caught signal do not resume but return −1 with errno = EINTR, requiring manual restart code. Posix sigaction lets the user specify the semantics explicitly; since it is unwieldy, use the Stevens-style wrapper:
handler_t *Signal(int signum, handler_t *handler)
{
struct sigaction action, old_action;
action.sa_handler = handler;
sigemptyset(&action.sa_mask); /* Block sigs of type being handled */
action.sa_flags = SA_RESTART; /* Restart syscalls if possible */
if (sigaction(signum, &action, &old_action) < 0)
unix_error("Signal error");
return (old_action.sa_handler);
}
Semantics installed: only signals of the type being handled are blocked; signals are not queued; interrupted system calls restart automatically whenever possible; the handler stays installed until Signal is called with SIG_IGN or SIG_DFL.
8.5.6 Synchronizing flows to avoid nasty concurrency bugs
Race example — a shell that addjobs each child in the main routine and deletejobs it in the SIGCHLD handler:
- Parent calls
fork; the kernel schedules the child first. - The child terminates before the parent runs again; the kernel delivers SIGCHLD to the parent.
- When the parent next becomes runnable, the kernel notices the pending SIGCHLD and the parent receives it before executing further.
- The handler reaps the child and calls
deletejob— which does nothing, since the child was never added. - The parent resumes after
forkand callsaddjobfor a child that no longer exists; the entry is never removed.
deletejob can thus run before addjob — a race. Other interleavings (parent scheduled first) are correct; correctness must hold for all interleavings. Fix — block SIGCHLD before fork and unblock only after addjob, guaranteeing the child is reaped after it is added:
Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); /* Block SIGCHLD */
if ((pid = Fork()) == 0) { /* Child */
Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
Execve("/bin/date", argv, NULL);
}
Sigprocmask(SIG_BLOCK, &mask_all, NULL); /* Parent */
addjob(pid);
Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* Unblock SIGCHLD */
Children inherit their parent's blocked set, so the child must unblock SIGCHLD before execve.
8.5.7 Explicitly waiting for signals
A main program sometimes must wait for a handler to run (e.g., a shell waiting for a foreground job to be reaped by the SIGCHLD handler). With SIGCHLD unblocked and a global volatile sig_atomic_t-style pid set by the handler:
| Approach | Verdict |
|---|---|
while (!pid) ; |
Correct, but the spin loop wastes processor resources |
while (!pid) pause(); |
Race: if SIGCHLD is received after the !pid test but before pause, pause sleeps forever. (The loop is still needed because pause may be interrupted by other signals, e.g., SIGINT.) |
while (!pid) sleep(1); |
Correct but too slow — up to a full second of delay per receipt; no principled interval exists for nanosleep variants (too small → wasteful, too large → slow) |
while (!pid) sigsuspend(&prev); |
Correct and efficient |
Atomically (uninterruptibly) equivalent to:
It temporarily replaces the blocked set with mask, then suspends the process until a signal is received whose action is to run a handler (sigsuspend returns after the handler returns, with the blocked set restored) or to terminate the process (the process exits without returning). Atomicity closes the test–pause window. Usage: block SIGCHLD, fork; in the parent set pid = 0, then while (!pid) sigsuspend(&prev); — each call temporarily unblocks SIGCHLD and sleeps; on exit from the loop SIGCHLD is blocked again and may optionally be unblocked.
8.6 Nonlocal jumps
A nonlocal jump transfers control directly from one function to another currently executing function, bypassing the normal call-and-return discipline.
int setjmp(jmp_buf env); /* Returns: 0 from setjmp,
nonzero from longjmps */
int sigsetjmp(sigjmp_buf env, int savesigs);
void longjmp(jmp_buf env, int retval); /* Never returns */
void siglongjmp(sigjmp_buf env, int retval);
setjmp saves the current calling environment (program counter, stack pointer, general-purpose registers) in env and returns 0. Its return value must not be assigned to a variable (rc = setjmp(env) is wrong); it may be used as a test in a switch or conditional. longjmp restores the environment from env and triggers a return from the most recent setjmp on env, which then returns the nonzero value retval. setjmp is called once but returns multiple times; longjmp is called once but never returns.
Applications:
- Immediate return from deeply nested calls on an error condition, jumping straight to a localized error handler instead of unwinding the stack. Caveat: intermediate functions' cleanup code is skipped — e.g., deallocations never run, leaking memory.
- Branching out of a signal handler to a specific code location instead of returning to the interrupted instruction — e.g., a soft restart on Ctrl+C using
sigsetjmp/siglongjmp, the handler-safe variants that additionally save and restore the signal context (pending and blocked bit vectors). Two subtleties: the handler must be installed after the initialsigsetjmp, or it could run before the environment is set up; and sincesiglongjmpcan jump into arbitrary code, everything reachable from it must call only async-signal-safe functions (sigsetjmpandsiglongjmpthemselves are not on the safe list).
C++/Java exception mechanisms are a higher-level, more structured version of this: catch clauses act like setjmp, throw like longjmp.
8.7 Tools for manipulating processes
| Tool | Function |
|---|---|
strace |
Traces each system call of a running program and its children (compile with -static for a cleaner trace) |
ps |
Lists processes, including zombies |
top |
Resource usage of current processes |
pmap |
Memory map of a process |
/proc |
Virtual filesystem exporting kernel data structures as text (e.g., cat /proc/loadavg) |
8.8 Summary
ECF occurs at all levels. Hardware: four exception classes — interrupts (async, from I/O devices, return to next instruction), traps (intentional; implement system calls, the controlled entry points into the OS), faults (handler restarts the faulting instruction or aborts), aborts (never return). OS: the kernel uses ECF for context switches, implementing processes, whose two key illusions are exclusive processor use (logical control flows) and exclusive memory use (private address spaces). OS/application boundary: process creation (fork), reaping (waitpid), program loading (execve), and signals — whose semantics are subtle and system-dependent, with Posix sigaction allowing explicit specification. Application level: nonlocal jumps bypass the call/return discipline entirely.