Understanding Linux Signal Masking and Delivery Architecture
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:
- Pending: The signal has arrived at the process but has not yet been executed.
- Blocked: The signal is currently prevented from being delivered, remaining in the pending state indefinitely until unmasked.
- 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:
pendingBittmap: Records signals that have arrived. A set bit indicates receipt.blockBitmap: Defines which incoming signals are masked. If a signal arrives and its bit is set here, it stays pending but cannot be delivered.handlerArray: 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 fromsetto the current mask (Union).SIG_UNBLOCK: Removes signals insetfrom the current mask (Difference).SIG_SETMASK: Replaces the current mask entirely withset.
set: Points to the targetsigset_tvariable.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(¤t_pending);
std::cout << "Pending after entry: ";
// ... helper omitted for brevity ...
displayBits(¤t_pending);
// Check mask at handler start
sigset_t current_blocked;
sigset_t temp;
sigemptyset(&temp);
sigprocmask(SIG_BLOCK, &temp, ¤t_blocked);
std::cout << "Block at handler: ";
displayBits(¤t_blocked);
exit(0);
}
int main() {
// Setup simple handler
signal(SIGINT, signal_callback);
while(true) {}
}
Observations:
- When entering the handler,
pendingshows '0' for that signal. This implies the kernel clears the pending bit before invoking the function. - The signal appears in
blockautomatically 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 executingsa_handler.sa_flags: Options likeSA_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:
- User Mode: Standard applications run here with limited privileges. Direct hardware access is prohibited.
- 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.
- No Signal: Return directly to User Mode.
- Default Action: If the action is built-in (e.g., Kill), handle inside Kernel Mode for efficiency.
- 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.