Deep Dive into Linux Signals: Mechanisms, Handling, and Process Control

Understanding Linux Signals

Signals in Linux serve as notifications for processes to handle asynchronous events. Conceptually, they function like interrupts sent by the operating system or other processes to a target process, indicating that a specific event has occurred. The process has the option to handle the event immediately, defer it, or ignore it entirely.

Signal Generation and Transmission

When a signal is generated, the kernel updates the target process's metadata. Specifically, it modifies a bitfield within the process's task_struct to indicate which signal has been received. The operating system is the sole entity capable of altering this data structure, ensuring that signal generation is a controlled operation.

For example, the key combination Ctrl+C sends the SIGINT (Signal Interrupt) to the foreground process. By default, this terminates the process. However, we can alter this behavior using the signal function in C++.

#include <iostream>
#include <csignal>
#include <unistd.h>

void handle_signal(int signal_id) {
    std::cout << "Caught signal: " << signal_id << std::endl;
}

int main() {
    // Register custom handler for SIGINT (2)
    std::signal(SIGINT, handle_signal);

    while (true) {
        std::cout << "Process running..." << std::endl;
        sleep(1);
    }
    return 0;
}

In the code above, pressing Ctrl+C triggers the handle_signal function instead of terminating the program immediately, demonstrating the ability to intercept and modify default signal behaviors.

Methods of Signal Generation

1. Terminal Shortcuts

Aside from SIGINT (Ctrl+C), Ctrl+\ sends SIGQUIT (Signal Quit). While both terminate the process by default, SIGQUIT often triggers a Core Dump. A core dump saves the process's memory image to disk for post-mortem debugging.

To enable core dumps, the shell limit must be adjusted:

ulimit -c unlimited

After running a program that crashes (e.g., due to a segmentation fault), a file named core is generated. Debuggers like gdb can load this file to inspect the state of the program at the moment of the crash.

2. System Calls

Programs can send signals programmatically using system calls such as kill, raise, and abort.

  • kill(pid, sig): Sends a signal to a specific process ID.
  • raise(sig): Sends a signal to the calling process itself.
  • abort(): Sends SIGABRT to terminate the process and generate a core dump.

Below is a simple implementation of a custom mykill command:

#include <iostream>
#include <csignal>
#include <cstdlib>
#include <unistd.h>

int main(int argc, char* argv[]) {
    if (argc != 3) {
        std::cerr << "Usage: " << argv[0] << " <pid> <signal>" << std::endl;
        return 1;
    }

    pid_t target_pid = std::atoi(argv[1]);
    int signal_num = std::atoi(argv[2]);

    if (kill(target_pid, signal_num) == 0) {
        std::cout << "Signal sent successfully." << std::endl;
    } else {
        perror("kill failed");
    }
    return 0;
}

3. Software Conditions

Certain library functions or software states trigger signals automatically.

SIGALRM is sent by the kernel after a timer set by the alarm() function expires. This is often used for timeouts.

#include <iostream>
#include <csignal>
#include <unistd.h>

void timeout_handler(int sig) {
    std::cout << "Timer expired!" << std::endl;
}

int main() {
    std::signal(SIGALRM, timeout_handler);
    alarm(1); // Timer set for 1 second
    
    // Perform a heavy calculation
    long long counter = 0;
    while(true) {
        counter++;
    }
    return 0;
}

SIGPIPE is sent when a process attempts to write to a pipe or socket that has no reader. This prevents processes from writing data into a void where no one is listening.

4. Hardware Exceptions

Hardware errors detected by the CPU are translated into signals by the OS.

  • SIGFPE (Floating Point Exception): Triggered by division by zero or invalid arithmetic operations. The CPU detects the overflow and notifies the OS.
  • SIGSEGV (Segmentation Violation): Triggered by invalid memory access. The Memory Management Unit (MMU) detects that the process is trying to access an unmapped virtual address.

Signal Blocking and Pending States

A signal can be generated but not immediately delivered. The standard signal processing flow involves three states:

  1. Block: The process holds a mask (block set) indicating which signals should be held back.
  2. Pending: A signal that has been sent but is blocked remains in a pending state.
  3. Delivery: The actual execution of the signal's action (default, ignore, or custom handler).

Manipulating Signal Sets

The data type sigset_t is used to represent signal sets. Several system calls operate on this type:

#include <csignal>

int main() {
    sigset_t set;
    // Initialize empty set
    sigemptyset(&set);
    // Add SIGINT (2) to set
    sigaddset(&set, SIGINT);
    
    // Apply this set to the process's block mask
    sigprocmask(SIG_BLOCK, &set, nullptr);
    
    // Now, SIGINT is blocked. If pressed, it will be pending.
    
    return 0;
}

The function sigpending(sigset_t *set) can be used to check which signals are currently pending for the process.

Signal Capture Mechanisms

Kernel vs. User Mode

Process execution alternates between User Mode (restricted access) and Kernel Mode (full access). Signal delivery typically occurs when the process transitions from Kernel Mode back to User Mode (e.g., after a system call or time slice expiration).

Before returning to user code, the kernel checks the pending signals. If a signal is not blocked and has a custom handler, the kernel executes a complex transition:

  1. Kernel switches to User Mode to execute the custom handler.
  2. After the handler finishes, a special system call (sigreturn) returns control to the Kernel.
  3. Kernel cleans up the stack and switches back to User Mode to resume the main execution flow.

The sigaction System Call

While signal() is simple, sigaction() is the preferred, more robust method for defining signal handlers. It allows for finer control, such as blocking other signals during the execution of the current handler.

#include <iostream>
#include <csignal>
#include <unistd.h>

void safe_handler(int sig) {
    std::cout << "Handling signal " << sig << " safely." << std::endl;
}

int main() {
    struct sigaction sa;
    sa.sa_handler = safe_handler;
    sa.sa_flags = 0;
    sigemptyset(&sa.sa_mask);

    // Block SIGQUIT while handling SIGINT
    sigaddset(&sa.sa_mask, SIGQUIT);

    sigaction(SIGINT, &sa, nullptr);

    while(true) {
        pause(); // Wait for signals
    }
    return 0;
}

Reentrancy and Volatile

Reentrant Functions

A function is reentrant if it can be interrupted and called again ("re-entered") before the previous invocation completes, without causing data corruption. This is critical for signal handlers because they interrupt the main execution flow.

Functions that modify static or global data (like malloc or strtok) are generally non-reentrant. Using them in signal handlers can lead to race conditions and memory corruption.

Volatile Keyword

When a global variable is modified by a signal handler, it must be declared volatile. This prevents the compiler from optimizing away memory reads, ensuring the main loop sees the updated value immediately.

#include <iostream>
#include <csignal>
#include <unistd.h>

volatile sig_atomic_t stop_flag = 0;

void signal_handler(int sig) {
    stop_flag = 1;
}

int main() {
    std::signal(SIGINT, signal_handler);

    while (!stop_flag) {
        // Perform work
    }
    
    std::cout << "Graceful exit." << std::endl;
    return 0;
}

Child Process Management: SIGCHLD

When a child process terminates, it sends a SIGCHLD signal to its parent. The parent should catch this signal and call waitpid() to retrieve the child's exit status, preventing the child from becoming a zombie process.

#include <iostream>
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>

void reap_child(int sig) {
    int status;
    pid_t pid;
    // Loop to reap multiple terminated children
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        std::cout << "Reaped child " << pid << std::endl;
    }
}

int main() {
    std::signal(SIGCHLD, reap_child);
    
    if (fork() == 0) {
        // Child process
        std::cout << "Child exiting..." << std::endl;
        exit(0);
    }

    // Parent continues working
    sleep(5);
    return 0;
}

Alternatively, on Linux, the parent can explicitly ignore SIGCHLD using signal(SIGCHLD, SIG_IGN). This tells the kernel to automatically reap child processes, preventing zombies without requiring the parent to handle the signal explicitly.

Tags: Linux operating-systems C++ signals systems-programming

Posted on Mon, 08 Jun 2026 16:26:18 +0000 by reagent