Part X — Signals, non-local jumps, and memory-mapped files
Three topics that didn't fit anywhere else in this guide because they're not "beginner," "advanced," or "hardware" specifically — they're general-purpose systems C that shows up in interviews, code reviews, and real production incidents regardless of what you're building. Unlike Part IX's kernel-space code, every example here is portable POSIX C, compiled and run on the same machine this whole guide was written on.
55. Signals: handling asynchronous events safely
55.1 What a signal actually is
A signal is the kernel interrupting your process's normal control flow
to say "something happened," asynchronously — a user pressed Ctrl+C
(SIGINT), a child process exited (SIGCHLD), your process wrote to a
closed pipe (SIGPIPE), or it dereferenced bad memory (SIGSEGV). By
default, most signals terminate the process (some also dump core); you
can install a handler to run your own code instead.
55.2 sigaction, not signal — and why
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
static volatile sig_atomic_t shutdown_requested = 0;
void handle_sigint(int sig) {
(void)sig;
shutdown_requested = 1; // only touch a sig_atomic_t here — nothing else is safe (55.3)
}
int main(void) {
struct sigaction sa = {0};
sa.sa_handler = handle_sigint;
sigemptyset(&sa.sa_mask); // don't block any additional signals while this handler runs
sa.sa_flags = 0;
if (sigaction(SIGINT, &sa, NULL) != 0) { perror("sigaction"); return 1; }
while (!shutdown_requested) {
printf("working...\n");
sleep(1);
}
printf("shutting down cleanly\n");
return 0;
}
The older signal() function still exists but its behavior around
whether a handler stays installed after firing, and whether interrupted
syscalls auto-restart, historically differed between BSD and System V —
sigaction pins down every one of those choices explicitly
(sa_flags), which is why every serious codebase uses it instead. This
mirrors §2.2's advice to pin -std= explicitly rather than rely on
"whatever the default happens to be": signal()'s portability gaps are
exactly that kind of implicit, compiler/platform-dependent default.
55.3 Async-signal-safety: the rule that makes most of Part II illegal
A handler can run at any point — including in the middle of malloc
updating its internal free list, or printf mid-write to a
partially-flushed buffer. If the handler itself then calls malloc or
printf, it can deadlock against the very state the interrupted call
was in the middle of modifying. POSIX defines a short list of
async-signal-safe functions safe to call from a handler (write,
_exit, sig* functions, and a few others) — malloc, free,
printf, and most of the C standard library are not on that list.
// BAD — printf is not async-signal-safe; can deadlock or corrupt output
void handle_sigint_bad(int sig) { (void)sig; printf("caught!\n"); }
// GOOD — write() to a raw fd is async-signal-safe; sig_atomic_t needs no library call at all
void handle_sigint_good(int sig) {
(void)sig;
const char msg[] = "caught!\n";
write(STDOUT_FILENO, msg, sizeof msg - 1);
}
The pattern from 55.2 — set a volatile sig_atomic_t flag in the
handler, check it in your normal control flow — sidesteps the whole
problem: the handler does the absolute minimum, and all real work
happens back in ordinary, non-signal context. This is precisely Part
IX §52.2's "top half does the minimum, defer the real work" rule,
applied to signals instead of hardware interrupts — the same
constraint, the same fix, a different trigger.
55.4 Reaping children with SIGCHLD
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
void reap_children(int sig) {
(void)sig;
int saved_errno = errno; // waitpid can clobber errno; the handler must restore it
pid_t pid;
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
// a real handler would just record `pid` somewhere async-signal-safe;
// it must NOT log/printf here (55.3)
}
(void)pid;
errno = saved_errno;
}
int main(void) {
struct sigaction sa = {0};
sa.sa_handler = reap_children;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; // let interrupted syscalls (e.g. a blocking read) auto-retry
sigaction(SIGCHLD, &sa, NULL);
for (int i = 0; i < 3; i++) {
if (fork() == 0) { _exit(0); } // each child exits immediately
}
sleep(1); // give SIGCHLD handlers time to reap all three before main exits
printf("parent done\n");
return 0;
}
Without a SIGCHLD handler (or an explicit waitpid call), an exited
child becomes a zombie — gone from execution but still occupying a
process-table slot until its parent collects its exit status. Section
26's mini-shell called waitpid synchronously after every fork, which
works for a shell that blocks on each foreground command; a server that
spawns background workers needs exactly this asynchronous
SIGCHLD-driven reaping instead, since it can't afford to block waiting
for any one child.
55.5 War story, and a real technique to know (not to casually run)
Installing a handler for SIGSEGV/SIGBUS paired with sigsetjmp/
siglongjmp (Section 56.3) — turning a hardware fault into a
recoverable error instead of a crash — is a genuine production
technique: it's how some embedded-scripting-language sandboxes and
guard-page-based memory checkers turn an out-of-bounds access into a
catchable error rather than taking the whole process down. It's also
exactly the kind of code that behaves differently across environments in
ways that matter: on this guide's own development machine, deliberately
triggering a SIGSEGV inside a sandboxed shell caused the process to
hang rather than reach the handler — almost certainly the OS's own
crash-reporting/debugging hooks intercepting the fault first, ahead of
the process's own handler. The lesson isn't "don't do this" — it's
test signal-based fault recovery on a plain terminal on real hardware,
never inside a CI runner or sandboxed environment, because the exact
thing you're trying to observe (a raw hardware trap reaching your
handler) is precisely what crash-reporting tooling is also trying to
intercept first.