Signals and interrupts are fundamental concepts in systems programming, particularly when writing C applications that must interact with hardware, respond to user actions, or maintain robust operation in unpredictable environments. Signals are asynchronous notifications sent to a process by the operating system to indicate that an event has occurred—for example, a user pressing Ctrl+C or a program exceeding its memory limit. Handling these signals correctly allows your program to perform cleanup, log diagnostics, or simply exit gracefully rather than crashing or leaving resources locked. This article provides a comprehensive guide to signal handling in C, covering the basics of signal registration, advanced techniques with sigaction, best practices for writing safe and reliable handlers, and common pitfalls to avoid.

Understanding Signals in C

In the POSIX model, a signal is a short message sent to a process or a thread. Signals are identified by symbolic constants like SIGINT, SIGTERM, and SIGKILL. When a signal is delivered, the process can take one of several default actions:

  • Terminate – The process exits immediately (e.g., SIGKILL).
  • Terminate and core dump – The process terminates and writes a memory dump for debugging (e.g., SIGSEGV).
  • Stop – The process is paused (e.g., SIGSTOP).
  • Continue – A stopped process resumes (e.g., SIGCONT).
  • Ignore – The signal is silently discarded (e.g., SIGCHLD).

Most signals, however, can be caught or handled by the program. By installing a custom signal handler, you override the default behaviour and define your own response. The signals that cannot be caught or ignored are SIGKILL and SIGSTOP; they are reserved for system-level control.

Setting Up Signal Handlers

C provides two primary functions for installing a signal handler: the older signal() and the more reliable sigaction(). Understanding both is important, as signal() appears in many legacy codebases, but modern applications should prefer sigaction() for its portability and fine-grained control.

Using signal()

The signal() function is part of the ISO C standard and is the simplest way to assign a handler. Its prototype is:

void (*signal(int sig, void (*func)(int)))(int);

Here is a complete example that catches SIGINT and performs cleanup before exiting:

#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

volatile sig_atomic_t got_interrupt = 0;

void handle_sigint(int sig) {
    got_interrupt = 1;
}

int main(void) {
    if (signal(SIGINT, handle_sigint) == SIG_ERR) {
        perror("signal");
        exit(EXIT_FAILURE);
    }

    while (!got_interrupt) {
        printf("Running... Press Ctrl+C to interrupt.\n");
        sleep(1);
    }

    printf("Performing graceful shutdown...\n");
    /* Cleanup code would go here */
    return 0;
}

Notice the use of volatile sig_atomic_t for the flag. This type is guaranteed to be read and written atomically, and it prevents the compiler from optimising away the check in the main loop. Although signal() works, its semantics differ across Unix implementations. In some historical versions, the handler is reset to SIG_DFL after handling the first signal, requiring the handler to reinstall itself—a behaviour that can introduce race conditions. Therefore, many production systems avoid signal() in favour of sigaction().

Using sigaction()

The sigaction() function provides a richer API and is specified by POSIX. It uses a struct sigaction to describe the handler, give a signal mask, and set flags. Its prototype is:

int sigaction(int sig, const struct sigaction *restrict act,
              struct sigaction *restrict oact);

The structure fields are:

  • sa_handler – the handler function (or SIG_IGN / SIG_DFL).
  • sa_mask – a set of signals to block while the handler executes.
  • sa_flags – flags that modify signal behaviour (e.g., SA_RESTART, SA_SIGINFO).
  • sa_sigaction – an alternative handler used if SA_SIGINFO is set in sa_flags; otherwise sa_handler is used.

Below is a robust example that handles SIGTERM and SIGINT with blocking additional signals to avoid reentrancy:

#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

volatile sig_atomic_t quit_flag = 0;

void handler(int sig) {
    quit_flag = 1;
}

int main(void) {
    struct sigaction sa;
    sa.sa_handler = handler;
    sigfillset(&sa.sa_mask);   // block all signals while handler runs
    sa.sa_flags = SA_RESTART;  // restart interrupted system calls

    if (sigaction(SIGINT, &sa, NULL) == -1) {
        perror("sigaction");
        exit(EXIT_FAILURE);
    }
    if (sigaction(SIGTERM, &sa, NULL) == -1) {
        perror("sigaction");
        exit(EXIT_FAILURE);
    }

    while (!quit_flag) {
        printf("Waiting for SIGINT or SIGTERM...\n");
        pause();   // wait for any signal
    }

    printf("Caught signal, cleaning up...\n");
    return 0;
}

The SA_RESTART flag is especially useful: it tells the kernel to automatically restart any interrupted system calls (like read() or write()) instead of returning an EINTR error. This simplifies programs that perform I/O in loops.

Advanced Signal Handling

Signal Masks and Blocking Signals

Every process (and thread) has a signal mask that defines which signals are currently blocked. A blocked signal is not delivered; it remains pending until the mask is cleared. You can manipulate the mask using sigprocmask() (or pthread_sigmask() in threaded programs). The sa_mask field in struct sigaction specifies an additional set of signals to block temporarily while the handler runs. This prevents a second delivery of the same signal or related signals from interrupting your handler.

sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigprocmask(SIG_BLOCK, &mask, NULL);  // block SIGINT
/* critical section */
sigprocmask(SIG_UNBLOCK, &mask, NULL); // unblock

Reentrancy and Async-Signal Safety

A signal handler runs asynchronously: it can interrupt the main program at any point. Therefore, you must use only async-signal-safe functions inside a handler. The list of safe functions (defined in the POSIX standard) is limited and includes write(), read(), _exit(), signal(), and a few others. Functions like printf(), malloc(), and fopen() are not safe because they may use internal non‑reentrant data structures. To communicate information from a handler to the main program, the recommended pattern is to set a volatile sig_atomic_t flag or (with more care) use a pipe to send a byte. In modern POSIX systems, sigwaitinfo() or signalfd (Linux) can be used to handle signals synchronously, avoiding the need for handler functions altogether.

Using volatile sig_atomic_t

When sharing data between a signal handler and the rest of the program, declare the flag as volatile sig_atomic_t. The volatile qualifier prevents the compiler from caching the variable in a register, ensuring that the main loop sees the updated value. The sig_atomic_t type is an integer that can be read or written atomically even when interrupted by a signal. For flags or simple counters this is sufficient; for more complex state, consider the pipe trick or using self-pipe.

volatile sig_atomic_t caught_sig = 0;

void handler(int sig) {
    caught_sig = sig;  // atomic assignment
}

Common Use Cases

Graceful Shutdown

The most frequent use of signal handling is to clean up before termination. For example, a server that opens sockets, creates temporary files, or allocates shared memory should catch SIGINT and SIGTERM to release those resources. The handler sets a flag, and the main loop checks the flag between operations, allowing an orderly shutdown without corrupting data files.

Ignoring Signals

You can ignore certain signals by setting the handler to SIG_IGN. For instance, a daemon might ignore SIGPIPE (broken pipe) to prevent unexpected termination when writing to a closed socket. Alternatively, you may wish to ignore SIGHUP to avoid reinitialisation when the terminal closes.

Timers and Real-Time Signals

POSIX real-time signals (SIGRTMIN to SIGRTMAX) can carry an integer value and are queued, unlike standard signals. They are often used with timer functions like timer_create() to implement periodic tasks. The handler receives the value via the si_value union when using SA_SIGINFO. For example:

void rt_handler(int sig, siginfo_t *info, void *context) {
    int val = info->si_value.sival_int;
    printf("Timer expiry with value %d\n", val);
}

int main() {
    struct sigaction sa;
    sa.sa_sigaction = rt_handler;
    sa.sa_flags = SA_SIGINFO;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGRTMIN, &sa, NULL);
    /* set up timer ... */
}

Pitfalls and Best Practices

Effective signal handling requires discipline. Here are the most critical guidelines:

  • Keep handlers minimal. A signal handler should do the absolute minimum necessary—usually just setting a flag. Avoid calling non‑async‑signal‑safe functions like printf, malloc, or free. Use _exit() rather than exit() if you must terminate inside a handler.
  • Use sigaction() instead of signal(). signal() has inconsistent behaviour across systems; sigaction() is portable and offers fine control.
  • Manage signal masks carefully. Block signals during critical sections to avoid unexpected interruptions. Use sigprocmask() (or pthread_sigmask() in threads) to protect atomic operations that span multiple statements.
  • Restore default handlers when needed. If your program needs to revert to the default behaviour after a certain point, save the old sigaction and restore it later.
  • Test with real signals. Simulate SIGINT, SIGTERM, and SIGUSR1 during development. Use tools like kill -s SIGNAL from another terminal to verify correctness.
  • Consider using synchronous signal delivery. On Linux, you can create a signalfd() and read signals from a file descriptor, integrating them into an event loop. This avoids the complexities of asynchronous handlers entirely.
  • Be aware of reentrancy in multi‑threaded programs. Signal delivery in threaded programs is per‑thread; use pthread_sigmask() to set masks for individual threads and designate one thread to handle all signals with sigwait().

External Resources

To deepen your understanding, consult the following authoritative references:

Conclusion

Mastering signal and interrupt handling is essential for writing robust C programs that interact with the operating system and users. By using sigaction(), respecting async‑signal safety, and keeping handlers simple, you can build applications that respond gracefully to termination requests, ignore irrelevant signals, and integrate signal handling cleanly into event loops. Test your signal handling code thoroughly under various scenarios to ensure reliability in production environments.