Linux Signals: A Complete Guide to Generation, Storage, and Handling

Signal Concepts

Signals in Everyday Life

Traffic lights represent a familiar example of signals. They possess two key characteristics:

  1. We recognize their appearance and understand what each light indicates
  2. We know what action to take when a specific light activates

From this analogy, we derive three fundamental properties:

  • Recognition: We've learned about signals beforehand, so we know how to respond when they arrive
  • Asynchrony: Signal arrival timing is unpredictable relative to our current activities
  • Deferred Handling: Signals don't require immediate processing; we can save them for later

What Is a Signal?

A signal constitutes a mechanism for sending notification messages to a target process. The receiving process must be capable of recognizing the signal and knowing how to respond appropriately.

Signal Generation

Foreground vs Background Processes

Processes divide into foreground and background categories (appended with &). A terminal permits exactly one foreground process but multiple background processes.

The critical distinction lies in input handling: foreground processes accept user keyboard input, which explains why only one can exist simultaneously. Pressing Ctrl+C terminates the foreground process by delivering a signal.

When launching a foreground process, the shell becomes suspended in the background. Terminating the foreground process resumes the shell to foreground operation.

Process Management Commands:

Command Function
Ctrl+C Terminate foreground process
jobs List background processes
fg [n] Bring job n to foreground
Ctrl+Z Suspend foreground process
bg [n] Resume suspended job in background
kill -9 pid Forcefully terminate process

Interrupts

How does the operating system detect keyboard input?

CPUs connect to peripheral devices through dedicated pins. Each pin carries a unique identifier called an interrupt number. When a device has data ready, it sends an electrical signal to its designated pin. The CPU detects this signal, writes the interrupt number to a register, and the OS reads it.

During system boot, the OS loads an interrupt vector table—a function pointer array indexed by interrupt numbers. When an interrupt occurs, the OS stops current execution, reads the interrupt number, looks up the appropriate handler in the table, and transfers data from the peripheral to memory.

Keyboard Input Processing:

  1. User presses a key
  2. Keyboard sends an electrical signal to the CPU
  3. CPU writes the interrupt number to a register
  4. OS reads the interrupt number from the register
  5. OS invokes the keyboard driver via the interrupt vector table
  6. Driver copies data from the keyboard to a buffer

Signals represent software-based interrupt simulation. While hardware interrupts facilitate communication between peripherals and the OS, signals enable communication between processes.

Signals in the Operating System

Consult the signal reference with man 7 signal.

Signals can be referenced by number or name—both are valid, as signal names are simply macro definitions.

Key Details:

  1. Signal 0 does not exist: Exit status consists of both a signal number and exit code. A zero signal indicates normal termination without signal intervention.
  2. Signals 1-31: Standard signals (non-realtime)
  3. Signals 34-64: Realtime signals (not covered here)
  4. Each process contains a function pointer array indexed by signal numbers

Four Methods of Signal Generation

Keyboard-Generated Signals

Pressing Ctrl+C triggers this sequence:

  1. Keyboard sends an electrical signal to the CPU
  2. CPU writes the interrupt number to a register
  3. OS reads the interrupt number and invokes the keyboard driver
  4. OS parses the input data
  5. Recognizing Ctrl+C as a control sequence, the OS sends signal 2 (SIGINT) to the foreground process

Common Keyboard Signals:

Key Combination Signal Default Action
Ctrl+C SIGINT (2) Terminate
Ctrl+Z SIGTSTP (19) Suspend
Ctrl+\ SIGQUIT (3) Terminate with core dump

System Calls for Signal Generation

Two primary functions send signals:

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <string>
using namespace std;

void displayUsage(const string& programName)
{
    cerr << "Usage: " << programName << " <signal_number> <process_id>" << endl;
}

int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        displayUsage(argv[0]);
        return 1;
    }

    int signalNum = stoi(argv[1]);
    int targetPid = stoi(argv[2]);
    
    if (kill(targetPid, signalNum) == -1)
    {
        perror("kill failed");
        return 1;
    }
    
    return 0;
}

The raise() function sends signals to the current process:

#include <signal.h>

// Send signal to current process
int raise(int sig);

Hardware Exception-Generated Signals

Division by Zero:

When the CPU executes code like 10 / 0, the overflow flag in the status register activates. The CPU notifies the OS of the exception, which translates it into signal 8 (SIGFPE - Floating Point Exception) sent to the offending process.

#include <iostream>
#include <signal.h>
using namespace std;

void exceptionHandler(int signalNumber)
{
    cout << "Caught signal: " << signalNumber << endl;
}

int main()
{
    signal(SIGFPE, exceptionHandler);
    
    int dividend = 100;
    int divisor = 0;
    int result = dividend / divisor;  // Triggers SIGFPE
    
    return 0;
}

This code enters a loop because:

  1. The exception occurs, preventing further instruction execution
  2. OS sends SIGFPE to the process
  3. The custom handler executes instead of terminating
  4. When rescheduled, the same exception recurs

Null Pointer Dereference:

Address 0 in a process's address space has no mapping in the page table. When the CPU attempts to access this virtual address, the MMU (Memory Management Unit) fails to translate it, generating a fault. The OS converts this into signal 11 (SIGSEGV - Segmentation Violation).

Key Insight:

Process exceptions are independent of programming language—they relate to OS and hardware behavior. The OS, as the resource manager, terminates the errant process, clearing its hardware context and restoring hardware health.

Custom signal handlers for exceptions should perform cleanup tasks like logging errors; simply suppressing termination leads to infinite loops.

Software Condition-Generated Signals

Broken Pipe (SIGPIPE, Signal 13):

When a pipe's read end closes and the write end continues writing, the OS sends SIGPIPE to the writer, terminating it by default.

Timer (SIGALRM, Signal 14):

The alarm() system call schedules a future signal:

#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;

void alarmHandler(int signalNumber)
{
    cout << "Timer triggered (signal " << signalNumber << ")" << endl;
    alarm(2);  // Reschedule alarm
}

int main()
{
    signal(SIGALRM, alarmHandler);
    alarm(2);
    
    while (true)
    {
        sleep(1);
        cout << "Running... PID: " << getpid() << endl;
    }
    
    return 0;
}

alarm() Behavior:

  • Each process can only have one active alarm
  • Setting a new alarm replaces the existing one
  • Returns the remaining seconds from the previous alarm

OS Time Management

  1. All user actions manifest as process behavior in the OS
  2. The OS's role is scheduling processes and allocating resources effectively
  3. The CMOS hardware periodically generates clock interrupts
  4. Upon receiving clock interrupts, the CPU executes the OS scheduler via the interrupt vector table
  5. The OS runs as a hardware-interrupt-driven event loop

Core Dumps vs Termination

Both SIGTERM and actions producing core dumps terminate processes, but core dumps additionally save process state to disk as core.[pid] for debugging.

Using Core Dumps:

  1. Compile with -g for debug information
  2. Run the program until it crashes
  3. Load the core dump: gdb ./program then core-file core.[pid]
  4. GDB shows the crash location

Signal Storage

Pending, Delivery, and Blocking

Definitions:

  • Delivery: Executing the signal's handler function
  • Pending: State between signal generation and delivery
  • Blocking: Delayed delivery; blocked signals cannot be delivered until unblocked

Signal Disposition Options:

  1. Default action (terminate, stop, ignore, etc.)
  2. Ignore the signal
  3. Custom handler (user-provided function)

Example Demonstrating Dispositions:

#include <iostream>
#include <signal.h>
using namespace std;

void customHandler(int signalNumber)
{
    cout << "Custom handler for signal: " << signalNumber << endl;
}

int main()
{
    signal(2, customHandler);  // Install custom handler
    signal(2, SIG_IGN);        // Ignore signal 2
    signal(2, SIG_DFL);        // Restore default behavior
    
    while (true)
    {
        cout << "Process running with PID: " << getpid() << endl;
        sleep(1);
    }
    
    return 0;
}

Analogy:

Consider an emperor reviewing memorials:

  • Pending: Memorial arrives but waits on the desk
  • Blocked: Emperor dislikes the official, orders the memorial placed aside
  • Unblocked: Emperor changes mind, immediately reviews the memorial
  • Delivery methods: Writting "acknowledged" (default), filing without response (ignore), or sending a detailed reply (custom)

Three Signal Tables in the Kernel

The OS maintains three per-process data structures in the PCB:

  1. Handler table: Function pointers indexed by signal number
  2. Pending bitmap: Indicates which signals have been received
  3. Block bitmap (mask): Indicates which signals are currently blocked

When sending a signal, the OS sets the corresponding bit in the pending table.

Modifying and Querying Signal Sets

sigset_t Type:

Block and pending tables are bitmaps, making direct manipulation difficult. The kernel provides sigset_t as an abstraction with associated system calls.

sigset_t Operations:

#include <signal.h>

// Initialize set to empty
int sigemptyset(sigset_t *set);

// Initialize set to all signals
int sigfillset(sigset_t *set);

// Add signal to set
int sigaddset(sigset_t *set, int signo);

// Remove signal from set
int sigdelset(sigset_t *set, int signo);

// Test signal membership
int sigismember(const sigset_t *set, int signo);

sigprocmask - Modifying Block Mask:

#include <signal.h>

int sigprocmask(int how, const sigset_t *newset, sigset_t *oldset);

// how values:
//   SIG_BLOCK   - Block signals in newset (mask |= newset)
//   SIG_UNBLOCK - Unblock signals in newset (mask &= ~newset)
//   SIG_SETMASK - Replace mask entirely (mask = newset)

sigpending - Querying Pending Signals:

#include <signal.h>

int sigpending(sigset_t *set);
// Returns current pending signal set via 'set' parameter

Critical Restriction: Signal 9 (SIGKILL) and signal 19 (SIGSTOP) cannot be blocked, ignored, or custom-caught.

Demonstration Code:

#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;

void signalHandler(int signalNumber)
{
    cout << "Handling signal: " << signalNumber << endl;
}

void dumpPendingSignals(const sigset_t& pending)
{
    cout << "Pending bits: ";
    for (int s = 31; s >= 1; s--)
    {
        cout << (sigismember(const_cast<sigset_t*>(&pending), s) ? '1' : '0');
    }
    cout << endl;
}

int main()
{
    signal(2, signalHandler);
    
    sigset_t blockMask, previousMask;
    sigemptyset(&blockMask);
    sigaddset(&blockMask, 2);
    sigprocmask(SIG_BLOCK, &blockMask, &previousMask);
    
    int elapsed = 0;
    while (true)
    {
        if (elapsed == 15)
        {
            cout << "Unblocking signal 2..." << endl;
            sigprocmask(SIG_SETMASK, &previousMask, nullptr);
        }
        
        cout << "PID: " << getpid() << " | Elapsed: " << elapsed << "s" << endl;
        
        sigset_t currentPending;
        sigpending(&currentPending);
        dumpPendingSignals(currentPending);
        
        sleep(1);
        elapsed++;
    }
    
    return 0;
}

Send signal 2 to this process from another terminal to observe pending table behavior.

Signal Handling

When are signals processed? During the transition from kernel mode to user mode.

User Mode vs Kernel Mode

User Mode: Restricted execution environment with limited resource access.

Kernel Mode: Privileged OS state with access to system resources. System calls involve mode transitions.

User-Level and Kernel-Level Page Tables:

On 32-bit systems, the adress space divides into:

  • [0, 3GB): User space (code, data, libraries, stack, heap)
  • [3GB, 4GB]: Kernel space (system calls, kernel data structures)

Each process maintains its own user-level page table for user space mapping. However, all processes share a single kernel-level page table because kernel memory exists at the same virtual address in every process. This enables the CPU to locate OS structures regardless of the current process.

Mode Indicators:

The CPU's CS register contains a 2-bit CPL field:

  • 00 or 01: Kernel mode
  • 11: User mode

When making a system call:

  1. CPU sets CPL to 00 (kernel mode)
  2. OS code executes in kernel space
  3. Up on completion, CPL returns to 11 (user mode)

Signal Processing Flow

Before returning to user mode, the OS checks for pending signals:

  1. Ignored signals: Clear pending bit, do nothing
  2. Default signals: Clear pending bit, execute default handler
  3. Custom handlers:
    • Clear pending bit
    • Switch to user mode
    • Execute user handler
    • Return to kernel mode via sigreturn()
    • Resume interrupted system call

The transition to user mode for custom handlers is critical because the OS cannot trust user code with kernel privileges.

sa_mask

When handling a signal, the kernel automatically blocks that signal to prevent nested handling. Users can additionally specify extra signals to block during handler execution via sa_mask in struct sigaction.

Implementation Details

  1. Clearing pending bits: The OS clears pending bits before invoking handlers
  2. Multiple pending signals: All pending bits clear first, then handlers execute in priority order before returning to user mode
  3. Automatic blocking: The current signal automatically blocks itself during handling

Additional Topics

Function Reentrancy

Example - Linked List Insert:

struct Node {
    int value;
    struct Node* next;
};

void insert(struct Node** head, struct Node* newNode)
{
    newNode->next = *head;  // Step 1
    *head = newNode;         // Step 2
}

Race Condition Scenario:

  1. Main execution calls insert(&head, node1)
  2. After step 1 (node1->next = head), the process is preempted
  3. A signal arrives; the kernel schedules the process back
  4. The signal handler calls insert(&head, node2)
  5. Steps 1-2 complete for node2
  6. Main execution resumes, completing step 2 for node1
  7. Result: node1->next still points to the original head
  8. node2 becomes unreachable—a memory leak

Definitions:

  • Reentrant function: Can be safely called by multiple execution contexts simultaneously
  • Non-reentrant function: Contains shared state that causes problems when called recursively or concurrently

This is a property, not a quality judgment. Most functions using global state are non-reentrant.

The volatile Keyword

Compiler optimizations can cause unexpected behavior with signals:

#include <iostream>
#include <signal.h>
using namespace std;

volatile sig_atomic_t runningFlag = 0;

void interruptHandler(int signalNumber)
{
    cout << "Signal received: " << signalNumber << endl;
    runningFlag = 1;
    cout << "Flag changed to: " << runningFlag << endl;
}

int main()
{
    signal(SIGINT, interruptHandler);
    cout << "Process PID: " << getpid() << endl;
    
    while (!runningFlag)
    {
        // Compiler might cache runningFlag in a register
    }
    
    cout << "Process exiting normally" << endl;
    return 0;
}

The Problem:

With optimization flags (-O1, -O2, -O3), the compiler may cache runningFlag in a register after the first read. Subsequent reads come from the register, not memory, so changes made by the signal handler go unnoticed.

The Solution:

volatile sig_atomic_t runningFlag = 0;

volatile ensures every access goes directly to memory, preventing register caching and maintaining CPU-memory visibility.

SIGCHLD Signal

When a child process terminates, it sends signal 17 (SIGCHLD) to its parent.

Signal-Based Child Reaping

#include <iostream>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
using namespace std;

void childReaper(int signalNumber)
{
    cout << "Reaping child process" << endl;
    waitpid(-1, nullptr, 0);
}

int main()
{
    signal(SIGCHLD, childReaper);
    
    pid_t childPid = fork();
    if (childPid == 0)
    {
        cout << "Child process executing" << endl;
        sleep(5);
        exit(0);
    }
    
    for (int i = 0; i < 10; i++)
    {
        sleep(1);
    }
    
    return 0;
}

Handling Multiple Children:

When multiple children exit simultaneously, pending signals can merge, causing only one reaping. Solution: non-blocking loop in the handler:

void reaperWithLoop(int signalNumber)
{
    pid_t pid;
    while ((pid = waitpid(-1, nullptr, WNOHANG)) > 0)
    {
        cout << "Reaped process: " << pid << endl;
    }
}

Ignoring SIGCHLD

signal(SIGCHLD, SIG_IGN);

This tells the OS to:

  • Not send SIGCHLD when children exit
  • Automatically reap zombie children
  • This approach is Linux-specific

Tags: Linux signals operating-system process-management interrupts

Posted on Tue, 30 Jun 2026 16:25:41 +0000 by frosero