Linux System Programming · intermediate · ~12 min
- By the end you can explain why `signal()` is unreliable across platforms and name the specific race it can open. - By the end you can install a signal handler with `sigaction()` and fill out a `struct sigaction` correctly (handler, mask, flags). - By the end you can choose between the 1-argument (`sa_handler`) and 3-argument (`sa_sigaction`, via `SA_SIGINFO`) handler forms. - By the end you can use `SA_RESTART`, `SA_NOCLDSTOP`, and `sa_mask` deliberately instead of accepting whatever a libc default gives you. - By the end you can save and restore a previous handler using the `oldact` argument.
You already met the standard signals in the previous lesson — SIGINT from Ctrl+C, SIGTERM from kill, SIGCHLD when a child exits, and so on — and you know a signal is an asynchronous notification the kernel delivers to your process. This lesson is about the mechanism for reacting to them: how you tell the kernel "when signal N arrives, call this function of mine." There are two APIs for that job. signal() is the ancient one from the original C standard; sigaction() is the POSIX one that replaced it.
They look interchangeable in a five-line demo, and that is exactly the trap. signal()'s behaviour was never nailed down precisely, so the same call does subtly different things on different systems — sometimes even a different thing on the same system depending on which compatibility mode libc is in. sigaction() makes every one of those behaviours an explicit flag you set yourself. Building on your knowledge of what signals are, this lesson is about installing handlers for them correctly and portably.
A signal handler is the one piece of your program that can run at literally any instruction boundary, so getting its installation wrong produces bugs that only appear under load or on a customer's machine. The classic signal() footgun — the handler resetting to the default (which for most signals is "terminate the process") after firing once — means a server that catches SIGTERM to shut down cleanly can be killed outright by a second SIGTERM that races in before it re-arms. Long-running daemons, network servers, and any program that must flush buffers or release locks before exiting depend on handlers that stay installed and don't corrupt in-flight system calls. Using sigaction() with explicit flags is how you get deterministic, auditable behaviour instead of "works on my box."
signal()signal() has one job — install a handler — but its contract is famously underspecified. Two behaviours were never standardised:
SIG_DFL (the default action). Your handler catches the signal once, then the next occurrence does the default thing — usually killing the process.read() or accept() is silently restarted or returns the error EINTR after a signal is also platform-dependent.The reset behaviour forces a horrible workaround: re-install the handler as the first thing inside the handler.
Time ->
signal arrives --> handler runs
| (disposition already reset to SIG_DFL here!)
|
*** 2nd signal *** --+--> DEFAULT ACTION = process killed
|
v signal() called to re-arm (too late)
That gap between "handler entered" and "handler re-armed" is a real, exploitable race window. On glibc, signal() happens to give you the safer BSD semantics (persistent handler, restarted calls) — but you cannot rely on that if your code ever moves to another libc or another OS.
sigaction(): everything is explicitsigaction() replaces the guesswork with a struct you fill in yourself. Nothing is implied; every behaviour is a flag.
struct sigaction {
void (*sa_handler)(int); /* simple handler */
void (*sa_sigaction)(int, siginfo_t *, void *); /* rich handler */
sigset_t sa_mask; /* extra signals blocked while handler runs */
int sa_flags; /* SA_RESTART, SA_SIGINFO, SA_NOCLDSTOP, ... */
};
sa_handler and sa_sigaction overlap in a union — you use one of them. Which one is selected by whether SA_SIGINFO is set in sa_flags. The handler stays installed across deliveries by default (no one-shot reset), so the entire re-arm race disappears.
| Flag | Effect | Typical use |
|---|---|---|
SA_RESTART |
Auto-restart interruptible slow syscalls instead of failing with EINTR |
You don't want read()/accept() to spuriously fail |
SA_SIGINFO |
Use the 3-arg sa_sigaction handler; deliver a siginfo_t |
You need the sender's PID, the faulting address, si_code, etc. |
SA_NOCLDSTOP |
For SIGCHLD, don't deliver when a child merely stops/continues |
You only care about children that actually exit |
SA_NOCLDWAIT |
For SIGCHLD, don't turn dead children into zombies |
Fire-and-forget children |
SA_RESETHAND |
Deliberately opt in to one-shot reset | You genuinely want catch-once behaviour |
SA_NODEFER |
Don't auto-block the signal being handled during the handler | Rare; usually a bug magnet |
Note SA_RESTART vs the old default cuts both ways. Sometimes you want EINTR — for example, a handler that sets a "please quit" flag needs the blocking read() to return so your loop can notice. Choosing the flag is a design decision, which is precisely why you want it visible.
Knowledge check: You install a handler with signal() on a plain SysV-style system and your program keeps running after the first signal but dies on the second. What happened, and what one flag would you set under sigaction() if — unusually — you actually wanted that die-on-second behaviour?
The SysV
signal()reset the disposition toSIG_DFLafter the first delivery, so the second signal took the default action (terminate). To reproduce that reset intentionally undersigaction()you'd setSA_RESETHAND. To avoid it (the normal case), you usesigaction()with the flag unset — the handler then persists automatically.
sa_mask: what's blocked while the handler runsWhile your handler executes, the kernel automatically blocks the signal being handled (unless SA_NODEFER), so a SIGINT handler won't be re-entered by another SIGINT. sa_mask lets you additionally block other signals for the duration — useful when two handlers touch the same data and you don't want one interrupting the other. You must initialise it with sigemptyset(&sa.sa_mask) (start empty) or sigfillset (block everything), then optionally sigaddset specific signals. Leaving it uninitialised means blocking a random set of signals.
oldact out-parametersigaction()'s third argument, if non-NULL, receives the previous disposition. This lets a library install a handler and restore the caller's on the way out — good manners, and essential for code that must not clobber an application's own handlers.
Never use signal() in new code. Use sigaction(), zero the struct, initialise sa_mask, and set your flags deliberately.
#include <signal.h>
int sigaction(int signum,
const struct sigaction *act,
struct sigaction *oldact);
signum — the signal to configure (e.g. SIGINT). You cannot catch or ignore SIGKILL or SIGSTOP; sigaction() returns -1/EINVAL for those.act — the new disposition to install. If NULL, nothing is changed (query-only).oldact — if non-NULL, filled with the previous disposition so you can restore it later. Safe to pass NULL if you don't care.0 on success, -1 on error with errno set (check EINVAL, EFAULT). Always check the return value.struct sigaction {
void (*sa_handler)(int signo);
void (*sa_sigaction)(int signo, siginfo_t *info, void *ucontext);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void); /* obsolete; do not use */
};
sa_handler — 1-arg handler, OR the constants SIG_DFL (default) / SIG_IGN (ignore). Used when SA_SIGINFO is not set.sa_sigaction — 3-arg handler used when SA_SIGINFO is set. sa_handler and sa_sigaction share storage (a union) — set only one.sa_mask — extra signals blocked during the handler. Must be initialised with sigemptyset/sigfillset before use; never assume it starts empty.sa_flags — bitwise-OR of SA_RESTART, SA_SIGINFO, SA_NOCLDSTOP, etc.Helpers for signal sets — all return 0/-1:
int sigemptyset(sigset_t *set); /* clear all */
int sigfillset(sigset_t *set); /* set all */
int sigaddset(sigset_t *set, int signo); /* add one */
int sigdelset(sigset_t *set, int signo); /* remove one */
No dynamic memory is involved — nothing to free, no fd to close. The struct is caller-owned; the kernel copies what it needs.
The old way to catch a signal is signal(signo, handler).
It works, but it has a serious trap. On some older Unix systems, the handler is reset to the default action after it fires once.
To keep catching the signal, you would have to re-install the handler from inside the handler itself. That creates a race window: if a second signal arrives before you re-install, it escapes your handler and may kill the program.
POSIX (the portable Unix standard) defines a safer replacement: sigaction().
It takes a struct sigaction filled with explicit flags, so you control the behavior precisely.
SA_RESTART — automatically restart blocking system calls (read, accept, and similar) instead of returning the EINTR ("interrupted") error.SA_NOCLDSTOP — for SIGCHLD, deliver the signal only when a child exits, not when it stops or continues.SA_SIGINFO — use the 3-argument sa_sigaction handler form, which receives a siginfo_t with extra detail (the sending process ID, the faulting address, and more).Never use signal() in new code. Always use sigaction().
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
/* Only touched from the handler and the main loop: must be volatile
sig_atomic_t so reads/writes are atomic w.r.t. signal delivery. */
static volatile sig_atomic_t hits = 0;
static volatile sig_atomic_t last_sender = 0;
/* 3-argument handler form, selected by SA_SIGINFO. siginfo_t carries
who sent the signal (si_pid) and why (si_code). */
static void on_usr1(int sig, siginfo_t *info, void *ucontext) {
(void)sig; (void)ucontext;
hits++;
last_sender = (info != NULL) ? (sig_atomic_t)info->si_pid : -1;
}
int main(void) {
struct sigaction sa;
memset(&sa, 0, sizeof sa); /* zero every field, portably */
sa.sa_sigaction = on_usr1; /* use the 3-arg handler ... */
sa.sa_flags = SA_SIGINFO | SA_RESTART; /* ... and restart slow syscalls */
sigemptyset(&sa.sa_mask); /* block no extra signals during handler */
if (sigaction(SIGUSR1, &sa, NULL) != 0) {
perror("sigaction");
return 1;
}
pid_t me = getpid();
printf("pid=%d installing SIGUSR1 handler via sigaction\n", (int)me);
/* Deliver the same signal three times. With sigaction the handler
stays installed for every one of them -- no re-arming needed. */
for (int i = 0; i < 3; i++) {
if (raise(SIGUSR1) != 0) { perror("raise"); return 1; }
}
printf("handler fired %d time(s); last sender pid=%d\n",
(int)hits, (int)last_sender);
puts(hits == 3 ? "OK: handler survived repeated delivery" : "FAIL");
return 0;
}
static volatile sig_atomic_t hits / last_sender — the only variables shared between the handler and main. sig_atomic_t guarantees a read or write is a single, uninterruptible operation, and volatile stops the compiler caching the value in a register (otherwise main might never see the handler's update). This is the correct type for handler↔mainline communication.static void on_usr1(int sig, siginfo_t *info, void *ucontext) — the 3-argument handler shape required by SA_SIGINFO. info gives structured detail; ucontext is the machine context at interruption (rarely used).(void)sig; (void)ucontext; — silence unused-parameter warnings without changing behaviour.hits++; — the whole body is intentionally tiny; the handler does no I/O and calls nothing async-signal-unsafe.last_sender = info->si_pid — reads the sending process's PID straight out of siginfo_t, the payload you only get with SA_SIGINFO.memset(&sa, 0, sizeof sa) — zeroes the whole struct portably before touching individual fields, so no field is left indeterminate.sa.sa_sigaction = on_usr1 — installs the 3-arg handler. Because sa_handler and sa_sigaction share storage, we set only this one.sa.sa_flags = SA_SIGINFO | SA_RESTART — SA_SIGINFO selects the 3-arg form (must match the field we set); SA_RESTART means any interrupted slow syscall resumes instead of failing with EINTR.sigemptyset(&sa.sa_mask) — start the extra-block set empty. Mandatory: an uninitialised sa_mask blocks a random signal set.if (sigaction(SIGUSR1, &sa, NULL) != 0) — install for SIGUSR1; NULL oldact because we don't need the previous disposition. The return value is checked, as it always must be.raise(SIGUSR1) in a loop — sends the signal to our own process three times. The point of the demo: with sigaction, all three are caught (hits == 3) because the handler is never reset — under one-shot signal() semantics you'd catch one and die.printf/puts — observable proof: the count is 3 and the sender PID equals our own PID.1. Relying on signal() staying installed.
signal(SIGTERM, cleanup); /* WRONG in portable code */
Why it breaks: on SysV-style systems the disposition resets to SIG_DFL after the first delivery, so a second SIGTERM kills the process before it can clean up — a race with a fatal outcome.
struct sigaction sa; memset(&sa, 0, sizeof sa);
sa.sa_handler = cleanup; sigemptyset(&sa.sa_mask);
sigaction(SIGTERM, &sa, NULL); /* stays installed */
2. Forgetting to initialise sa_mask.
struct sigaction sa; /* garbage on the stack */
sa.sa_handler = h;
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL); /* WRONG: sa_mask uninitialised */
Why it breaks: the indeterminate sa_mask blocks an unpredictable set of signals whenever the handler runs, causing signals to be mysteriously delayed or lost.
struct sigaction sa; memset(&sa, 0, sizeof sa);
sa.sa_handler = h;
sigemptyset(&sa.sa_mask); /* explicit */
sigaction(SIGINT, &sa, NULL);
3. Setting the wrong handler field for the flag.
sa.sa_flags = SA_SIGINFO; /* promises 3-arg form */
sa.sa_handler = on_int; /* WRONG: set the 1-arg field */
Why it breaks: the union means you've stored a 1-arg pointer where the kernel will call a 3-arg signature — undefined behaviour, often a crash. Match the field to the flag:
sa.sa_flags = SA_SIGINFO;
sa.sa_sigaction = on_int; /* 3-arg field */
4. Doing real work inside the handler.
void on_int(int s){ (void)s; printf("bye\n"); free(buf); } /* WRONG */
Why it breaks: printf/malloc/free are not async-signal-safe; if the signal interrupts one of them mid-update, the handler re-enters it and corrupts internal state or deadlocks. Set a flag and act in the main loop:
volatile sig_atomic_t quit = 0;
void on_int(int s){ (void)s; quit = 1; }
/* main loop: if (quit) { printf("bye\n"); free(buf); } */
cat /proc/<pid>/status and read the SigCgt (caught) bitmask — bit n-1 set means signal n is being caught. SigBlk shows currently blocked signals.strace -e trace=signal ./prog prints each rt_sigaction, and each --- SIGxxx --- as signals arrive, so you can see whether your handler ran and whether syscalls returned EINTR.EINTR: if a blocking read()/accept() returns -1 with errno == EINTR, either add SA_RESTART, or wrap the call in a retry loop (while ((n=read(...))<0 && errno==EINTR);). Decide which on purpose.handle SIGUSR1 nostop pass lets the signal reach your program while you break inside the handler; set a breakpoint on the handler function to inspect siginfo_t.sigaction returning -1 for SIGKILL/SIGSTOP or a bad signum; perror("sigaction") immediately reveals it.printf-debugging a handler is itself unsafe; if you must trace from inside, use write(2, msg, len) with a fixed string — write is async-signal-safe, printf is not.malloc/free/printf. Calling those from a handler risks heap corruption and deadlock. POSIX defines a small list of safe functions (e.g. write, _exit, signal, sigaction, most raw syscalls). Keep handlers to setting a volatile sig_atomic_t flag and, at most, async-signal-safe I/O.volatile sig_atomic_t for shared flags. Any plain variable shared between handler and mainline is a data race: volatile forces re-reads from memory and sig_atomic_t guarantees the access is a single atomic unit. Larger types can be torn (partially updated) when a signal lands mid-write.memset(&sa, 0, sizeof sa) plus sigemptyset(&sa.sa_mask) avoids acting on indeterminate sa_flags/sa_mask bits — reading an uninitialised object is undefined behaviour.errno is shared state. If your handler calls anything that might set errno, save and restore it (int saved = errno; ... ; errno = saved;) or the main thread will observe a corrupted errno.sigwait() rather than relying on handlers.nginx, redis, postgres and countless services catch SIGTERM/SIGINT with sigaction to flush data, close listening sockets, and exit cleanly; the persistent-handler guarantee means a repeated signal won't kill them mid-flush.SIGHUP to re-read a config file is a Unix convention; sigaction ensures the handler survives the first reload.fork() workers catch SIGCHLD (often with SA_NOCLDSTOP) and waitpid() in a loop to reap zombies.SIGSEGV/SIGBUS installed with SA_SIGINFO read si_addr (the faulting address) to log a minidump before exiting — used by crash reporters like Breakpad.sigaction, never signal; keep handlers trivial (set a flag); prefer the self-pipe trick or signalfd/sigwait to turn async signals into synchronous, easily-handled events in your main loop; and in threaded code, block signals everywhere and handle them in one dedicated thread.Rewrite a signal(SIGINT, handler) call as an equivalent sigaction() call that produces a persistent handler. Verify with strace -e trace=signal that rt_sigaction is issued and the handler survives two Ctrl+C presses.
Install a SIGINT handler that sets volatile sig_atomic_t quit = 1;, then run a loop that blocks in read(STDIN_FILENO, ...). Install it once with SA_RESTART and once without, and describe how the read() behaves differently (restart vs EINTR) in each case.
Convert a 1-argument handler to the 3-argument SA_SIGINFO form and print info->si_pid and info->si_code. Send the signal from another terminal with kill -USR1 <pid> and confirm the reported sender PID matches that shell.
Use the oldact parameter to save the current SIGTERM disposition, install your own handler, do some work, then restore the original disposition with a second sigaction() call. Prove the restore worked by checking behaviour before and after.
Deliberately reproduce the one-shot signal() bug: install a handler with sigaction using SA_RESETHAND, raise the signal twice, and observe the process taking the default action on the second delivery. Then remove SA_RESETHAND and show both deliveries are caught.
signal() is underspecified: on some systems it resets the handler to the default after one delivery, opening a race where a second signal kills the process. Its syscall-interruption behaviour is also platform-dependent. Avoid it in new code.sigaction() is the POSIX standard: every behaviour is an explicit flag, the handler persists by default, and it exposes richer information.memset it to zero, set exactly one of sa_handler/sa_sigaction (matching SA_SIGINFO), and always sigemptyset(&sa.sa_mask).SA_RESTART (restart slow syscalls vs EINTR), SA_SIGINFO (3-arg handler + siginfo_t), SA_NOCLDSTOP (SIGCHLD on exit only), SA_RESETHAND (opt in to one-shot).volatile sig_atomic_t flag and do the real work in the main loop; save/restore errno if needed.oldact to restore a previous handler. Always check the return value.