Signals

AGXAGX
9 min read

The Foundation: What Are Signals?

At the most basic level, a signal is a software interrupt. Think of an interrupt as someone tapping you on the shoulder while you're reading - it forces you to stop what you're doing and pay attention to something else. When a process receives a signal, the operating system interrupts whatever that process was doing and instructs it to "Handle this message right now."

The Historical Context That Shapes Everything

To understand why signals work the way they do, we need to step back to the early days of Unix in the 1970s. Back then, computers were shared by many users simultaneously, and the operating system needed simple, efficient ways to manage processes. The designers created signals as a lightweight communication mechanism that could work even when processes were in trouble or consuming too many resources.

This historical context explains some quirks you'll encounter. For example, why can't you catch or ignore SIGKILL? Because the original Unix designers knew that sometimes a process becomes so problematic that it needs to be terminated immediately, regardless of what it wants to do.

The Complete Signal Architecture

Linux defines exactly 64 signals, but they're not all created equal. Let me help you understand the structure:

Standard Signals (1-31): These are the original Unix signals that have been around for decades. Each one has a specific, well-defined purpose. They're reliable, portable across different Unix-like systems, and form the core of process communication.

Real-Time Signals (32-64): These were added later to address limitations in the original signal system. Unlike standard signals, real-time signals can be queued (meaning you won't lose them if multiple instances arrive quickly), and they can carry additional data beyond just "this signal happened."

How Signal Delivery Actually Works

When you understand the mechanics of signal delivery, the whole system becomes much clearer. Let's trace through what happens when one process sends a signal to another.

First, the sending process (or the kernel itself) identifies the target process and calls a system function to deliver the signal. The kernel then marks that signal as "pending" for the target process. This is crucial to understand: the signal doesn't interrupt the target process immediately. Instead, it's like placing a note in the process's mailbox.

The actual interruption happens the next time the target process transitions from kernel mode back to user mode. This might sound technical, but think of it this way: every time your process makes a system call (like reading a file or allocating memory), it briefly enters kernel mode to let the operating system handle the request. When that system call finishes and control returns to your process, the kernel checks for pending signals and delivers them.

This delay mechanism serves an important purpose. It ensures that signals are delivered at safe points in the process's execution, when the process state is consistent and can be safely interrupted.

The Three Ways Processes Handle Signals

Every signal can be handled in exactly three ways, and understanding these options is key to mastering signal behavior:

Default Action: If a process doesn't specify how to handle a signal, the kernel performs a predefined default action. For SIGTERM, the default is to terminate the process gracefully. For SIGSTOP, it's to suspend the process. For SIGCHLD (sent when a child process terminates), the default is to ignore it.

Ignore the Signal: A process can tell the kernel, "If this signal arrives, just throw it away." This works for most signals, but not for SIGKILL or SIGSTOP. The system designers made these two signals uncatchable because they represent the kernel's ultimate authority over processes.

Custom Handler: This is where signals become really powerful. A process can register a function to run whenever a specific signal arrives. When the signal is delivered, the kernel temporarily suspends the process's normal execution, runs the handler function, and then resumes normal execution where it left off.

Understanding Signal Numbers and Names

Each signal has both a number and a symbolic name, and both matter for different reasons. The numbers (like 9 for SIGKILL) are what the kernel actually uses internally. The symbolic names (like SIGKILL) are human-readable identifiers that make code more understandable.

Here's something important to grasp: while most signal numbers are standardized across Unix-like systems, there can be slight variations. This is why good programs always use the symbolic names rather than hard-coding numbers. When you write kill -TERM 1234 instead of kill -15 1234, your code becomes more portable and readable.

The Most Important Signals You'll Encounter

Let me walk you through the signals you'll interact with most frequently, explaining not just what they do but when and why you'd use them:

SIGINT (Signal 2): This is what gets sent when you press Ctrl+C. It's designed to be a polite interruption, giving the process a chance to clean up before exiting. Many interactive programs catch this signal to save work or ask if you really want to quit.

SIGTERM (Signal 15): This is the "please terminate" signal. It's what most system shutdown procedures use to ask processes to exit gracefully by pressing Ctrl + Z. Well-behaved programs catch this signal, finish critical operations, save data, and then exit cleanly.

SIGKILL (Signal 9): This is the nuclear option. When you send SIGKILL, you're telling the kernel, "remove this process immediately, regardless of what it's doing." The process gets no opportunity to clean up, save data, or respond in any way. Use this only when SIGTERM fails.

SIGSTOP and SIGCONT (Signals 19 and 18): These control process execution. SIGSTOP immediately pauses a process (like hitting pause on a video), while SIGCONT resumes it. Neither can be caught or ignored, giving the system reliable process control.

SIGCHLD (Signal 17): This gets sent to a parent process when one of its child processes terminates or stops. This mechanism allows parent processes to keep track of their children without constantly checking their status.

Those were the ones you would see often in the shell, but there are many more like SIGSEGV(segmentation violation) a signal sent to a process when it attempts to access memory that it's not allowed to access. This typically happens when your program tries to dereference a null pointer, access memory outside allocated bounds, or write to read-only memory.

The default action for SIGSEGV is to terminate the process and generate a core dump (if core dumps are enabled on your system). This abrupt termination helps prevent memory corruption from spreading, but obviously stops your program immediately.

You can absolutely alter this behavior using signal handlers.

Real-World Signal Usage Patterns

When you run a command in your shell and then press Ctrl+C, you're sending SIGINT to the foreground process group. If that process has children, they typically receive the signal too. This is why pressing Ctrl+C often stops an entire pipeline of commands.

Shell job control uses SIGSTOP and SIGCONT extensively. When you press Ctrl+Z to suspend a job, the shell sends SIGSTOP. When you type fg to resume it, the shell sends SIGCONT. This allows you to juggle multiple tasks in a single terminal session.

The Programming Perspective

If you're interested in how programs actually work with signals, the system provides several functions that let processes interact with the signal system. The signal() function allows a process to register a handler for a specific signal. The kill() function (despite its name) can send any signal to any process you have permission to signal.

Here's what makes signal programming tricky: signal handlers run asynchronously, meaning they can interrupt your program at almost any point. This creates potential race conditions and requires careful programming to avoid problems. Modern programs often use more sophisticated signal handling mechanisms like sigaction() which provides better control over signal behavior.

you can list all the signals available on your system using

 kill -l

also the man pages include an overview about signals that has tables that contain the default action for every signal and more.

man 7 signal

Debugging and Observing Signals

You can observe signal behavior in real-time using various tools. The strace command shows you every system call a process makes, including signal-related calls. Running strace -e signal kill -TERM 1234 would show you the exact system calls involved in sending a SIGTERM signal.

The /proc filesystem also provides signal information. Looking at /proc/[pid]/status shows you which signals are pending, blocked, or being caught by a specific process.

this is the output related to the signals for the bash process running on my machine

SigQ:   1/31206
SigPnd: 0000000000000000
ShdPnd: 0000000000000000
SigBlk: 0000000000010000
SigIgn: 0000000000384004
SigCgt: 000000004b813efb

of course, this does not only show you the signals information, I only showed the info related to the signals here

maybe I’ll explore the other parts in another article

SigQ: 1/31206

  • Meaning:

    • The process has 1 queued signal (likely a pending real-time signal).

    • It can have up to 31,206 queued signals in total.

  • Format: queued_signals / max_queued_signals.

SigPnd: 0000000000000000

  • Meaning:

    • No pending signals for this process (signals sent but not yet delivered because the process is blocked or handling others).

ShdPnd: 0000000000000000

  • Meaning:

    • No shared (thread group) pending signals—applies to multithreaded programs. Since bash is single-threaded; this is usually 0.

SigBlk: 0000000000010000

  • Meaning:

    • Bitmask of blocked signals.

    • 0x0000000000010000 means signal 17 (SIGCHLD) is currently blocked by the process.

    • SIGCHLD is sent to a parent when a child process terminates.

SigIgn: 0000000000384004

  • Meaning:

    • Bitmask of ignored signals.

    • This value shows which signals the process has chosen to ignore.

    • Common for bash to ignore SIGPIPE, SIGXFSZ, SIGTTIN, etc.

SigCgt: 000000004b813efb

  • Meaning:

    • Bitmask of signals with user-defined handlers.

    • These signals will invoke custom behavior instead of the default.

Hands-on

Try writing a box that gets resized in the terminal when resizing the terminal window

This is a challenge I saw on CSPrimer, it’s an amazing website. You should check it out for sure.

My solution

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/ioctl.h>

void handle_winch(int sig) {
    printf("\033[H\033[J");
}


void draw_unicode_box(int width, int height) {
    // Unicode box-drawing characters
    const char* horizontal = "─";    // Horizontal line
    const char* vertical = "│";      // Vertical line
    const char* top_left = "┌";      // Top-left corner
    const char* top_right = "┐";     // Top-right corner
    const char* bottom_left = "└";   // Bottom-left corner
    const char* bottom_right = "┘";  // Bottom-right corner


    for (int j = 0; j < height / 2; j++) {
            printf("\n");
    }

    for (int j = 0; j < width / 2; j++) {
            printf(" ");
    }
    // Draw the top border
    printf("%s", top_left);
    for (int i = 0; i < width - 2; i++) {
        printf("%s", horizontal);
    }
    printf("%s\n", top_right);

    // Draw the middle rows
    for (int i = 0; i < height - 2; i++) {
        for (int j = 0; j < width / 2; j++) {
                printf(" ");
        }
        printf("%s", vertical); // Left border

        // Fill the middle with spaces
        for (int j = 0; j < width - 2; j++) {
            printf(" ");
        }

        printf("%s\n", vertical); // Right border
    }

    for (int j = 0; j < width / 2; j++) {
            printf(" ");
    }
    // Draw the bottom border
    printf("%s", bottom_left);
    for (int i = 0; i < width - 2; i++) {
        printf("%s", horizontal);
    }
    printf("%s\n", bottom_right);
}

int main() {

    signal(SIGWINCH, handle_winch);

    struct winsize w;


    do {
        if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &w) == -1) {
            perror("ioctl");
            return 1;
        }
        draw_unicode_box(w.ws_col / 2, w.ws_row / 2);
        pause();  // Wait for signals
    } while (1);

    return 0;
}
0
Subscribe to my newsletter

Read articles from AGX directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

AGX
AGX