Signal Fundamentals

Signals are asynchronous notifications delivered by the kernel to a process. They interrupt whatever the process is doing -- right now, at any instruction boundary. They are Unix's oldest form of inter-process communication, and they are everywhere: Ctrl+C, child process death, illegal memory access, broken pipes. You cannot write robust systems code without understanding them.

What Signals Are

A signal is a small integer sent from the kernel (or another process) to a target process. When a signal arrives, the process can:

  1. Run a handler function (custom code).
  2. Accept the default action (terminate, core dump, ignore, or stop).
  3. Block the signal temporarily (it stays pending).
  Kernel / Other Process
         |
         | signal (e.g., SIGINT)
         v
  +-----------------+
  | Target Process  |
  |                 |
  |  Normal code    |  <-- interrupted
  |  ...            |
  |  Handler runs   |  <-- if installed
  |  ...            |
  |  Normal code    |  <-- resumes
  +-----------------+

Common Signals

SignalNumberDefault ActionTrigger
SIGHUP1TerminateTerminal hangup
SIGINT2TerminateCtrl+C
SIGQUIT3Core dumpCtrl+\
SIGILL4Core dumpIllegal instruction
SIGABRT6Core dumpabort()
SIGFPE8Core dumpDivide by zero (integer)
SIGKILL9TerminateUncatchable kill
SIGSEGV11Core dumpBad memory access
SIGPIPE13TerminateWrite to broken pipe
SIGALRM14Terminatealarm() timer
SIGTERM15TerminatePolite termination request
SIGCHLD17IgnoreChild process stopped/exited
SIGCONT18ContinueResume stopped process
SIGSTOP19StopUncatchable stop
SIGTSTP20StopCtrl+Z
SIGUSR110TerminateUser-defined
SIGUSR212TerminateUser-defined

Caution: SIGKILL (9) and SIGSTOP (19) cannot be caught, blocked, or ignored. The kernel enforces this. Do not waste time trying to handle them.

Default Actions

There are four possible default actions:

  • Terminate: Process exits.
  • Core dump: Process exits and writes a core file (if enabled).
  • Ignore: Signal is silently discarded.
  • Stop: Process is suspended (like Ctrl+Z).

SIGCHLD and SIGURG default to ignore. Most signals default to terminate.

Sending Signals

From the shell:

$ kill -TERM 1234       # Send SIGTERM to PID 1234
$ kill -9 1234          # Send SIGKILL (uncatchable)
$ kill -SIGUSR1 1234    # Send SIGUSR1
$ kill -0 1234          # Test if process exists (no signal sent)

From C:

/* send_signal.c */
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>

int main(void)
{
    pid_t pid = fork();

    if (pid == 0) {
        printf("Child %d: waiting for signal...\n", getpid());
        pause();  /* Suspend until any signal arrives */
        printf("Child: this line never reached (default SIGTERM kills)\n");
        _exit(0);
    }

    sleep(1);
    printf("Parent: sending SIGTERM to child %d\n", pid);
    kill(pid, SIGTERM);

    int status;
    waitpid(pid, &status, 0);

    if (WIFSIGNALED(status))
        printf("Child killed by signal %d\n", WTERMSIG(status));

    return 0;
}

The signal() Function (And Why You Should Not Use It)

The original BSD/POSIX signal() function installs a handler:

/* signal_old.c -- for demonstration only */
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

static void handler(int sig)
{
    /* UNSAFE: printf is not async-signal-safe -- demo only */
    printf("Caught signal %d\n", sig);
}

int main(void)
{
    signal(SIGINT, handler);

    printf("PID %d: Press Ctrl+C (3 times to exit via default)\n", getpid());

    for (int i = 0; i < 30; i++) {
        printf("tick %d\n", i);
        sleep(1);
    }

    return 0;
}

Caution: signal() has portability problems. On some systems it resets the handler to SIG_DFL after each delivery (System V behavior). On others it does not (BSD behavior). The behavior of signal() is implementation-defined by POSIX. Always use sigaction() instead (covered in the next chapter).

A Signal Demo: Handling SIGINT and SIGTERM

Here is a proper pattern using a flag (still using signal() for simplicity -- we will fix this with sigaction() in the next chapter):

/* graceful_shutdown.c */
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

static volatile sig_atomic_t got_signal = 0;

static void handler(int sig)
{
    got_signal = sig;
}

int main(void)
{
    signal(SIGINT,  handler);
    signal(SIGTERM, handler);

    printf("PID %d: Running... (Ctrl+C to stop)\n", getpid());

    while (!got_signal) {
        /* Main work loop */
        printf("Working...\n");
        sleep(2);
    }

    printf("Received signal %d, shutting down gracefully.\n", got_signal);
    /* Cleanup code here */

    return 0;
}

The key type is volatile sig_atomic_t -- an integer type guaranteed to be read and written atomically with respect to signal delivery.

Main thread:              Signal delivery:

while (!got_signal) {     handler(SIGINT) {
    work();                   got_signal = SIGINT;
    sleep(2);             }
}
  |                         |
  +---- reads got_signal ---+

Try It: Modify graceful_shutdown.c to count how many times Ctrl+C is pressed. After 3 presses, exit. Print the count in the main loop.

SIGCHLD: Child Process Notifications

When a child process exits, the kernel sends SIGCHLD to the parent. This is how servers avoid blocking on waitpid():

/* sigchld_demo.c */
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <errno.h>

static void sigchld_handler(int sig)
{
    (void)sig;
    int saved_errno = errno;

    /* Reap all dead children (non-blocking) */
    while (waitpid(-1, NULL, WNOHANG) > 0)
        ;

    errno = saved_errno;
}

int main(void)
{
    signal(SIGCHLD, sigchld_handler);

    /* Spawn 3 children that exit at different times */
    for (int i = 0; i < 3; i++) {
        pid_t pid = fork();
        if (pid == 0) {
            sleep(i + 1);
            printf("Child %d (PID %d) exiting\n", i, getpid());
            _exit(0);
        }
        printf("Spawned child %d: PID %d\n", i, pid);
    }

    /* Parent does other work */
    for (int i = 0; i < 5; i++) {
        printf("Parent working (tick %d)...\n", i);
        sleep(2);
    }

    return 0;
}

Caution: The SIGCHLD handler must call waitpid() in a loop with WNOHANG. Multiple children can exit before the handler runs, but signals are not queued (standard signals, at least). One SIGCHLD delivery might represent multiple dead children.

SIGPIPE: Broken Pipes

When you write to a pipe or socket whose read end is closed, the kernel sends SIGPIPE. The default action kills the process.

/* sigpipe_demo.c */
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>

int main(void)
{
    /* Ignore SIGPIPE -- check write() return instead */
    signal(SIGPIPE, SIG_IGN);

    int pipefd[2];
    pipe(pipefd);

    /* Close the read end immediately */
    close(pipefd[0]);

    /* Write to the broken pipe */
    const char *msg = "Hello, pipe!\n";
    ssize_t n = write(pipefd[1], msg, strlen(msg));

    if (n < 0) {
        printf("Write failed: %s (errno=%d)\n", strerror(errno), errno);
        /* errno == EPIPE */
    } else {
        printf("Wrote %zd bytes\n", n);
    }

    close(pipefd[1]);
    return 0;
}

Driver Prep: Kernel drivers handle signals indirectly. When a user-space process receives a signal while blocked in a system call, the kernel returns EINTR (or ERESTARTSYS internally). Driver code must check for signal_pending(current) and return -ERESTARTSYS so the VFS layer can restart or abort the system call.

Rust: The signal-hook Crate

Rust has no built-in signal handling in std. The signal-hook crate provides safe abstractions.

// signal_hook_demo.rs
// Cargo.toml:
//   [dependencies]
//   signal-hook = "0.3"

use signal_hook::consts::{SIGINT, SIGTERM};
use signal_hook::flag;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;

fn main() {
    let running = Arc::new(AtomicBool::new(true));

    // Register signal handlers that set the flag to false
    flag::register(SIGINT, Arc::clone(&running)).expect("register SIGINT");
    flag::register(SIGTERM, Arc::clone(&running)).expect("register SIGTERM");

    println!("PID {}: Running... (Ctrl+C to stop)", std::process::id());

    while running.load(Ordering::Relaxed) {
        println!("Working...");
        thread::sleep(Duration::from_secs(2));
    }

    println!("Signal received, shutting down.");
}

Rust Note: signal-hook uses atomic flags internally, avoiding the async-signal-safety pitfalls of C handlers. The flag::register function installs a minimal handler that just flips an AtomicBool. No unsafe code is needed in user code.

Rust: Using nix for Signals

The nix crate wraps the POSIX signal API:

// nix_signal_demo.rs
// Cargo.toml: nix = { version = "0.29", features = ["signal", "process"] }
use nix::sys::signal::{self, Signal, SigHandler};
use nix::unistd::Pid;
use std::thread;
use std::time::Duration;

extern "C" fn handler(sig: libc::c_int) {
    // Minimal handler -- only async-signal-safe operations
    // We just use write() to fd 1 (not println!)
    let msg = b"Signal caught!\n";
    unsafe { libc::write(1, msg.as_ptr() as *const libc::c_void, msg.len()); }
    let _ = sig;
}

fn main() {
    // Install handler for SIGUSR1
    unsafe {
        signal::signal(Signal::SIGUSR1, SigHandler::Handler(handler))
            .expect("signal");
    }

    let pid = nix::unistd::getpid();
    println!("PID {}: send SIGUSR1 to me", pid);
    println!("  kill -USR1 {}", pid);

    // Also send it to ourselves
    thread::sleep(Duration::from_secs(1));
    signal::kill(Pid::this(), Signal::SIGUSR1).expect("kill");

    thread::sleep(Duration::from_secs(1));
    println!("Done.");
}

Listing Signals on Your System

/* list_signals.c */
#include <stdio.h>
#include <string.h>
#include <signal.h>

int main(void)
{
    for (int i = 1; i < NSIG; i++) {
        const char *name = strsignal(i);
        if (name)
            printf("%2d  %s\n", i, name);
    }
    return 0;
}
$ gcc -o list_signals list_signals.c && ./list_signals
 1  Hangup
 2  Interrupt
 3  Quit
 ...

Try It: Run kill -l in your shell to see the full signal list. Compare it with the output of list_signals. Note the real-time signals at the end (32+).

Signal Delivery Flow

Event occurs (Ctrl+C, child dies, bad memory access, kill())
         |
         v
Kernel sets signal as "pending" for target process
         |
         v
Process is scheduled to run (or already running)
         |
         v
Kernel checks: is signal blocked?
    |                    |
   YES                  NO
    |                    |
    v                    v
Signal stays       Check disposition:
pending            SIG_DFL / SIG_IGN / handler
                        |
              +---------+----------+
              |         |          |
           SIG_DFL    SIG_IGN   handler()
              |         |          |
           Default   Discard   Run handler,
           action              then resume

Knowledge Check

  1. Name two signals that cannot be caught or ignored.

  2. What is the default action for SIGCHLD? Why is this important for servers that fork child processes?

  3. Why is volatile sig_atomic_t required for variables shared between a signal handler and the main program?

Common Pitfalls

  • Ignoring SIGPIPE: Network servers must ignore SIGPIPE or they will die when a client disconnects mid-write. Use signal(SIGPIPE, SIG_IGN) and check write() return values.

  • Not saving/restoring errno in handlers: Signal handlers can clobber errno. Save it on entry, restore on exit.

  • Assuming signals are queued: Standard signals (1-31) are not queued. If two SIGCHLD signals arrive before the handler runs, you get one delivery. Always loop in waitpid() with WNOHANG.

  • Using printf in handlers: It is not async-signal-safe. Use write() to a file descriptor if you must produce output.

  • Forgetting that sleep() is interrupted: sleep(), read(), write(), and other blocking calls return early with EINTR when a signal is caught. Always retry or handle the short return.

  • Catching SIGSEGV to "handle" crashes: You can catch it, but you cannot safely resume. The faulting instruction will re-execute and fault again unless you fix the underlying memory issue (which you almost certainly cannot do portably).