Signal Concepts
Signals in Everyday Life
Traffic lights represent a familiar example of signals. They possess two key characteristics:
- We recognize their appearance and understand what each light indicates
- 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:
- User presses a key
- Keyboard sends an electrical signal to the CPU
- CPU writes the interrupt number to a register
- OS reads the interrupt number from the register
- OS invokes the keyboard driver via the interrupt vector table
- 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:
- 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.
- Signals 1-31: Standard signals (non-realtime)
- Signals 34-64: Realtime signals (not covered here)
- 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:
- Keyboard sends an electrical signal to the CPU
- CPU writes the interrupt number to a register
- OS reads the interrupt number and invokes the keyboard driver
- OS parses the input data
- Recognizing
Ctrl+Cas 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:
- The exception occurs, preventing further instruction execution
- OS sends SIGFPE to the process
- The custom handler executes instead of terminating
- 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
- All user actions manifest as process behavior in the OS
- The OS's role is scheduling processes and allocating resources effectively
- The CMOS hardware periodically generates clock interrupts
- Upon receiving clock interrupts, the CPU executes the OS scheduler via the interrupt vector table
- 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:
- Compile with
-gfor debug information - Run the program until it crashes
- Load the core dump:
gdb ./programthencore-file core.[pid] - 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:
- Default action (terminate, stop, ignore, etc.)
- Ignore the signal
- 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:
- Handler table: Function pointers indexed by signal number
- Pending bitmap: Indicates which signals have been received
- 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(¤tPending);
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:
00or01: Kernel mode11: User mode
When making a system call:
- CPU sets CPL to 00 (kernel mode)
- OS code executes in kernel space
- Up on completion, CPL returns to 11 (user mode)
Signal Processing Flow
Before returning to user mode, the OS checks for pending signals:
- Ignored signals: Clear pending bit, do nothing
- Default signals: Clear pending bit, execute default handler
- 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
- Clearing pending bits: The OS clears pending bits before invoking handlers
- Multiple pending signals: All pending bits clear first, then handlers execute in priority order before returning to user mode
- 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:
- Main execution calls
insert(&head, node1) - After step 1 (
node1->next = head), the process is preempted - A signal arrives; the kernel schedules the process back
- The signal handler calls
insert(&head, node2) - Steps 1-2 complete for
node2 - Main execution resumes, completing step 2 for
node1 - Result:
node1->nextstill points to the originalhead node2becomes 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