Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Understanding Linux Signal Masking and Delivery Architecture

Tech May 8 3

Signal States and Internals

In the Linux environment, non-real-time signals are not processed immediately upon arrival. Instead, a mechanism exists to queue these events until an appropriate execution point. To understand this, we must distinguish between three states a signal can occupy:

  1. Pending: The signal has arrived at the process but has not yet been executed.
  2. Blocked: The signal is currently prevented from being delivered, remaining in the pending state indefinitely until unmasked.
  3. Delivered: The processing phase where the default handler runs or the custom callback executes.

Note that ignoring a signal (SIG_IGN) differs from blocking it. Ignoring counts as delivery (processing), whereas blocking prevents the delivery entirely.

Internally, the Process Control Block (PCB) maintains bitmaps corresponding to these states:

  • pending Bittmap: Records signals that have arrived. A set bit indicates receipt.
  • block Bitmap: Defines which incoming signals are masked. If a signal arrives and its bit is set here, it stays pending but cannot be delivered.
  • handler Array: Pointers to functions handling specific signal numbers.

When delivering a signal, the kernel checks pending for set bits. If a bit is set and the corresponding bit in block is clear, the handler is invoked. Post-handling, the pending bit resets.

Non-real-time signals overwrite existing pending entries; sending the same signal multiple times only updates the bitmap once, unlike real-time signals which maintain a queue.

Manipulating Signal Sets

To control the pending and block states, developers interact with sigset_t, defined in <signal.h>. This type represents the internal bitmaps structurally.

typedef struct {
  unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;

typedef __sigset_t sigset_t;

Five primary API functions manage these sets:

  • sigemptyset: Clears all bits to zero.
  • sigfillset: Sets all bits to one.
  • sigaddset: Sets a specific signal bit.
  • sigdelset: Clears a specific signal bit.
  • sigismember: Queries if a bit is active.

These operations manipulate local variables initially. To apply changes to the process state, specific system calls are required.

Updating the Block Mask: sigprocmask

The sigprocmask function modifies the current blocked set (block).

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
  • how: Determines the operation mode.
    • SIG_BLOCK: Adds signals from set to the current mask (Union).
    • SIG_UNBLOCK: Removes signals in set from the current mask (Difference).
    • SIG_SETMASK: Replaces the current mask entirely with set.
  • set: Points to the target sigset_t variable.
  • oldset: Optional pointer to store the previous mask before modification.

Viewing Pending Signals: sigpending

This call retrieves the current pending signal set into a provided buffer.

int sigpending(sigset_t *set);

Implementation Experiments

Experiment 1: Verifying Blocking Mechanism

This example confirms that signals accumulate in pending when blocked.

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

void printMaskStatus(const sigset_t& sset) {
    std::cout << "\nPending Status:\n";
    for (int idx = 31; idx >= 1; --idx) {
        std::cout << (sigismember(&sset, idx) ? '1' : '0');
    }
}

int main() {
    sigset_t mask_set;
    sigset_t old_mask;

    // Initialize mask
    sigemptyset(&mask_set);
    sigaddset(&mask_set, SIGINT); // Bit 2

    // Apply blocking mask
    sigprocmask(SIG_BLOCK, &mask_set, &old_mask);

    while (true) {
        sigset_t pending_set;
        sigpending(&pending_set);
        printMaskStatus(pending_set);
        sleep(1);
    }
    return 0;
}

If triggered by Ctrl+C while running, the second bit should remain '1' continuously, demonstrating that delivery is halted despite reception.

Experiment 2: Default Mask Verification

Check the default behavior of the block table.

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

void displayBits(const sigset_t* pset) {
    for (int idx = 31; idx >= 1; --idx) {
        std::cout << (sigismember(pset, idx) ? '1' : '0');
    }
    std::cout << std::endl;
}

int main() {
    sigset_t full_set, stored_mask;

    sigfillset(&full_set);
    
    // Force full block and retrieve previous state
    sigprocmask(SIG_BLOCK, &full_set, &stored_mask);
    std::cout << "Previous Default: ";
    displayBits(&stored_mask);

    // Attempt full block again to see final result
    sigprocmask(SIG_BLOCK, &full_set, &stored_mask);
    std::cout << "Final Blocked State: ";
    displayBits(&stored_mask);
    
    return 0;
}

Results indicate the default mask is empty (all zeros). Additionally, note that SIGKILL (9) and SIGSTOP (19) cannot be blocked via standard APIs.

Experiment 3: Handler Execution Context

Verify what happens to pending and block during custom handler execution.

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

void signal_callback(int sig) {
    std::cout << "Captured Signal ID: " << sig << std::endl;

    sigset_t current_pending;
    sigpending(&current_pending);
    std::cout << "Pending after entry: ";
    // ... helper omitted for brevity ... 
    displayBits(&current_pending); 
    
    // Check mask at handler start
    sigset_t current_blocked;
    sigset_t temp;
    sigemptyset(&temp);
    sigprocmask(SIG_BLOCK, &temp, &current_blocked);
    
    std::cout << "Block at handler: ";
    displayBits(&current_blocked);
    exit(0);
}

int main() {
    // Setup simple handler
    signal(SIGINT, signal_callback);
    while(true) {} 
}

Observations:

  1. When entering the handler, pending shows '0' for that signal. This implies the kernel clears the pending bit before invoking the function.
  2. The signal appears in block automatically during handler execution. This prevents recursive nesting where the signal could trigger itself mid-execution.

Advanced Configuration: sigaction

The sigaction structure offers more granular control than signal(). It allows specifying both the handler and a temporary signal mask during execution.

struct sigaction {
    void (*sa_handler)(int);
    void (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t sa_mask;
    int sa_flags;
    void (*sa_restorer)(void);
};

Key members:

  • sa_handler: Pointer to user-defined function.
  • sa_mask: Signals to block temporarily while executing sa_handler.
  • sa_flags: Options like SA_NOCLDSTOP (set to 0 for default behavior).

Example Usage:

void safe_handler(int sig) {
    // Custom logic
    std::cout << "Handled " << sig << std::endl;
}

int main() {
    struct sigaction act;
    act.sa_handler = safe_handler;
    act.sa_flags = 0;
    
    // Configure to block signals 1 through 5 during handling
    sigemptyset(&act.sa_mask);
    for(int i=1; i<=5; ++i) sigaddset(&act.sa_mask, i);

    sigaction(SIGINT, &act, nullptr);
    
    while(true) {} // Wait
}

Here, sa_mask ensures that any signals within range [1,5] are masked specifically while safe_handler runs, independent of the global process mask.

User Mode vs Kernel Mode

Linux isolates memory and execution privileges into two modes:

  1. User Mode: Standard applications run here with limited privileges. Direct hardware access is prohibited.
  2. Kernel Mode: OS core routines execute here with unrestricted access to hardware and memory.

Context switching occurs frequently:

  • Trap/Exception: Software instructions requesting services (System Calls).
  • Interrupt: External hardware events (Network packets, Timer ticks).

A single process possesses separate page tables for user space and kernel space. Entering kernel mode grants authority to read/write protected memory regions.

Signal Delivery Interaction Signals bridge the gap between kernel events and user logic. Because user code is inherently less trusted than the OS, the timing of signal delivery is critical.

Delivery Timing Logic

Signals are typically checked when transitioning from Kernel Mode back to User Mode.

  1. No Signal: Return directly to User Mode.
  2. Default Action: If the action is built-in (e.g., Kill), handle inside Kernel Mode for efficiency.
  3. Custom Handler: Switch to User Mode, execute sa_handler, then re-enter Kernel Mode to finalize context restoration.

Why switch back to User Mode first? Running arbitrary user code in Kernel Mode poses security risks. By delegating the execution of the handler to User Mode, the system limits potential damage. However, because the original program counter needs restoration, the CPU returns to the OS kernel after the user handler finishes. The kernel then reads the saved context to resume the application precisely where it left off.

During this transition (Kernel -> User), the system validates the pending signal queue. If new signals arrive or existing ones become deliverable, they are dispatched before the user thread continues execution.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.