Signal Handling and Software Interrupts in C
This article explains how the C programming language captures and processes software interrupts through signal handling. Readers will learn the underlying mechanics of how the operating system generates signals, how the runtime intercepts standard execution to run custom handlers, the primary differences between legacy and modern implementation functions, and the best practices required to write safe, asynchronous code.
In Unix-like operating systems, a signal is an asynchronous
notification delivered by the kernel to a process to inform it of an
event or hardware condition. These software interrupts can originate
from hardware exceptions (such as SIGFPE for division by
zero or SIGSEGV for invalid memory access), the terminal
(such as SIGINT via Ctrl+C), or other processes (such as
SIGTERM). Without explicit intervention, the operating
system applies a default action for each signal, which typically
terminates the process, ignores the event, or generates a core dump.
Signal handling allows a C program to override these default
behaviors by registering a custom callback function—known as a signal
handler—using the <signal.h> library. When an event
occurs, the kernel pauses the normal control flow of the thread, saves
its current execution context on the call stack, and creates a new stack
frame pointing to the registered handler function. Once the handler
finishes executing, the kernel restores the saved context, allowing the
program to resume from the exact instruction it was executing prior to
the interruption.
C provides two primary mechanisms for registering signal handlers:
the traditional signal() function and the POSIX-compliant
sigaction() system call.
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
volatile sig_atomic_t keep_running = 1;
void handle_sigint(int sig) {
keep_running = 0;
}
int main(void) {
struct sigaction sa;
sa.sa_handler = handle_sigint;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);
while (keep_running) {
pause(); // Wait for signals
}
printf("Exited cleanly after interrupt.\n");
return 0;
}The older signal() function is simple to call but
exhibits undefined or non-portable behavior across different operating
systems. In some implementations, a handler reverts to the default
action immediately after being triggered once, creating a race condition
if another signal arrives before the handler can re-register itself.
The sigaction() interface is the standard approach for
production code because it provides deterministic behavior. Through the
struct sigaction, developers can specify a mask of
additional signals that the kernel should block while the handler is
executing (sa_mask), preventing nested interrupts from
compounding execution issues. It also offers flags
(sa_flags) to control restart behaviors for interrupted
system calls (such as SA_RESTART) or to receive extended
metadata about the origin of the signal via SA_SIGINFO.
Because a software interrupt can occur between any two CPU
instructions, signal handlers execute in an asynchronous context that
requires strict programming constraints. Functions that maintain
internal state or utilize shared locks—including memory allocation
routines like malloc() and standard I/O functions like
printf()—are not async-signal-safe. Calling non-reentrant
functions inside a signal handler can lead to deadlocks, memory
corruption, or undefined behavior.
To ensure safety, handlers should perform minimal work. The standard
practice involves setting a flag of type
volatile sig_atomic_t, which guarantees that read and write
operations are atomic and visible immediately to the main program loop.
The main thread can then safely evaluate the flag, perform necessary
cleanup operations, and manage a controlled shutdown or state change
without risking memory inconsistency.