Signals and Process Lifecycle
Type this right now
// save as catcher.c — compile: gcc -o catcher catcher.c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig) {
printf("\nCaught signal %d (SIGINT)! Not dying today.\n", sig);
}
int main() {
signal(SIGINT, handler);
printf("Try pressing Ctrl+C... (PID: %d)\n", getpid());
for (int i = 0; i < 30; i++) {
printf("Running... %d\n", i);
sleep(1);
}
printf("Finished normally.\n");
return 0;
}
$ ./catcher
Try pressing Ctrl+C... (PID: 12345)
Running... 0
Running... 1
^C
Caught signal 2 (SIGINT)! Not dying today.
Running... 2
Running... 3
^C
Caught signal 2 (SIGINT)! Not dying today.
Running... 4
You pressed Ctrl+C twice. Normally that kills the process. But your signal handler intercepted it. The process kept running. You just took control of how your program responds to external events.
Now try kill -9 12345 from another terminal. That sends SIGKILL. No handler can catch it.
The process dies immediately.
What is a signal?
A signal is an asynchronous notification delivered to a process. It's the kernel's way of saying "something happened that you might care about."
┌──────────────────────────────────────────────────────┐
│ Sources of Signals │
│ │
│ Keyboard: Ctrl+C → SIGINT Ctrl+\ → SIGQUIT │
│ Hardware: Bad address → SIGSEGV Bad math → SIGFPE│
│ Kernel: Child exited → SIGCHLD Timer → SIGALRM│
│ Other process: kill(pid, SIGTERM) │
│ Your code: raise(SIGABRT), abort() │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Kernel sets signal pending │
│ on the target process │
└────────────────────────┬─────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Next time process returns to user space: │
│ → kernel checks pending signals │
│ → delivers the signal by running the handler │
└──────────────────────────────────────────────────────┘
Signals are not interrupts. They don't execute immediately when sent. The kernel marks a signal as pending, and the process sees it the next time it transitions from kernel mode back to user mode (after a syscall, or after being scheduled).
The important signals
Signal Number Default Action Meaning
───────── ────── ────────────── ─────────────────────────────
SIGINT 2 Terminate Ctrl+C — polite interrupt
SIGQUIT 3 Core dump Ctrl+\ — quit with dump
SIGABRT 6 Core dump abort() called
SIGFPE 8 Core dump Arithmetic error (div by 0)
SIGKILL 9 Terminate Unconditional kill (CAN'T CATCH)
SIGSEGV 11 Core dump Invalid memory access
SIGPIPE 13 Terminate Write to pipe with no reader
SIGTERM 15 Terminate Polite "please exit"
SIGCHLD 17 Ignore Child process changed state
SIGSTOP 19 Stop process Pause (CAN'T CATCH)
SIGCONT 18 Continue Resume stopped process
SIGUSR1 10 Terminate User-defined
SIGUSR2 12 Terminate User-defined
SIGBUS 7 Core dump Bus error (misaligned access)
Two signals cannot be caught or ignored: SIGKILL (9) and SIGSTOP (19). These are the kernel's absolute authority — no process can resist them.
💡 Fun Fact:
kill -9is called "kill dash nine" and has become part of programmer folklore. There's even a haiku: "No, your process is / not important. SIGKILL / does not negotiate."
Signal delivery mechanics
Here's what happens when signal delivery is triggered:
Process in user space, executing normally
│
│ Syscall (read, write, etc.) or timer interrupt
▼
Process enters kernel mode
│
│ Kernel does its work (I/O, scheduling, etc.)
│
│ Before returning to user space, kernel checks:
│ "Are there any pending signals for this process?"
│
├── No pending signals → return to user space normally
│
└── Yes, signal S is pending:
│
├── Is there a custom handler for S?
│ │
│ Yes → Modify user-space stack:
│ │ push signal frame (saved registers)
│ │ set instruction pointer to handler function
│ │ return to user space → handler runs
│ │ handler returns → sigreturn syscall
│ │ kernel restores original registers
│ │ process resumes where it was interrupted
│ │
│ No → Execute default action:
│ Terminate, core dump, stop, or ignore
│
└── Is S blocked (signal mask)?
Yes → remains pending, delivered later
The kernel modifies the process's user-space stack to run the handler. This is why signal handlers must be careful — they're running on the interrupted code's stack.
Writing a proper signal handler
signal() is the simple API, but sigaction() is what you should actually use:
// save as proper_handler.c — compile: gcc -o proper_handler proper_handler.c
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
volatile sig_atomic_t got_signal = 0;
void handler(int sig) {
// RULE: only call async-signal-safe functions here!
// printf is NOT safe. write() IS safe.
got_signal = sig;
}
int main() {
struct sigaction sa;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
printf("PID: %d — send me SIGINT or SIGTERM\n", getpid());
while (!got_signal) {
printf("Working...\n");
sleep(1);
}
printf("Received signal %d. Cleaning up...\n", got_signal);
// Do your cleanup here: close files, flush buffers, etc.
return 0;
}
Critical rule: Inside a signal handler, you can only call async-signal-safe functions.
printf(), malloc(), and most of the standard library are NOT safe. Use write() if you
must output something. The safe pattern is: set a flag in the handler, check it in your main
loop.
Rust and signals
Rust doesn't have built-in signal handling in the standard library. Panic is Rust's primary error mechanism for things like bounds check failures:
fn main() { let v = vec![1, 2, 3]; println!("{}", v[10]); // Panics — not a signal, a Rust panic }
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 10'
note: run with `RUST_BACKTRACE=1` for a backtrace
For actual Unix signal handling, use the signal-hook crate:
// Cargo.toml: signal-hook = "0.3" use signal_hook::consts::SIGINT; use signal_hook::iterator::Signals; fn main() -> Result<(), Box<dyn std::error::Error>> { let mut signals = Signals::new(&[SIGINT])?; println!("Press Ctrl+C..."); for sig in signals.forever() { match sig { SIGINT => { println!("Caught SIGINT! Exiting gracefully."); break; } _ => unreachable!(), } } Ok(()) }
SIGSEGV from unsafe code still kills a Rust process the same way it kills a C process. The
Rust runtime does not catch segfaults.
Process lifecycle
Every process on your system follows this lifecycle:
Parent process
│
│ fork()
├──────────────────────────┐
│ │
│ Parent continues │ Child is a COPY of parent
│ │
│ │ exec() — optional
│ │ Replace child's memory with
│ │ a new program (e.g., /bin/ls)
│ │
│ │ ... child runs ...
│ │
│ │ exit(status)
│ │ Child terminates
│ │
│ ▼
│ ┌──────────────┐
│ │ ZOMBIE │ Child's entry stays in
│ │ (defunct) │ process table until parent
│ └──────┬───────┘ calls wait()
│ │
│ wait(&status) │
│ Parent collects ◄───────┘
│ child's exit status
│
▼
Child's process table entry is finally freed
fork(): clone the process
pid_t pid = fork();
// After this line, TWO processes are running
if (pid == 0) {
// Child process — fork returned 0
printf("I'm the child, PID %d\n", getpid());
} else {
// Parent process — fork returned child's PID
printf("I'm the parent, child is %d\n", pid);
}
exec(): replace with a new program
// In the child:
execvp("ls", (char *[]){"ls", "-la", NULL});
// If exec succeeds, this line NEVER runs — the entire address space is replaced
perror("exec failed");
wait(): collect the child's exit status
int status;
pid_t child = wait(&status);
if (WIFEXITED(status)) {
printf("Child %d exited with code %d\n", child, WEXITSTATUS(status));
}
Zombie processes
If a child exits but the parent never calls wait(), the child becomes a zombie. It has
no memory, no open files, no running code — but its process table entry remains so the parent
can eventually collect the exit status.
$ ps aux | grep Z
USER PID ... STAT COMMAND
user 5678 ... Z [child] <defunct>
Zombies consume almost no resources (just one entry in the process table), but if a parent spawns thousands of children without waiting, you can exhaust the PID space.
Solution: Call wait() or waitpid() for every child. Or set SIGCHLD to SIG_IGN — this
tells the kernel to automatically reap children:
signal(SIGCHLD, SIG_IGN); // Auto-reap children. No zombies.
🧠 What do you think happens?
If a parent process exits while a child is still running, what happens to the child? Who becomes its new parent? (Hint: it's PID 1.)
Core dumps
When a process crashes with SIGSEGV, SIGABRT, or SIGQUIT (among others), the kernel can write the process's entire memory image to a file: the core dump.
$ ulimit -c unlimited # Enable core dumps
$ ./crash
Segmentation fault (core dumped)
$ file core
core: ELF 64-bit LSB core file, x86-64
$ gdb ./crash core
(gdb) bt # Full backtrace at crash time
(gdb) info registers # All register values
(gdb) x/10x $rsp # Stack contents
(gdb) print *pointer_var # Examine variables
A core dump contains: all mapped memory regions (stack, heap, data), register values for every thread, signal information, and the memory map. It's a complete snapshot of the dying process.
🔧 Task: Build a Ctrl+C counter
Write a C program that:
- Installs a SIGINT handler using
sigaction() - Counts how many times Ctrl+C is pressed
- After 3 presses, prints "OK fine, exiting." and terminates
- Uses only
volatile sig_atomic_tfor the counter (signal safety) - Uses
write()in the handler, notprintf()
// save as counter.c — compile: gcc -o counter counter.c
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
volatile sig_atomic_t count = 0;
void handler(int sig) {
count++;
const char *msg = "Caught SIGINT!\n";
write(STDOUT_FILENO, msg, 15);
}
int main() {
struct sigaction sa = { .sa_handler = handler };
sigemptyset(&sa.sa_mask);
sigaction(SIGINT, &sa, NULL);
printf("Press Ctrl+C three times to quit (PID: %d)\n", getpid());
while (count < 3) {
pause(); // Sleep until a signal arrives
}
printf("OK fine, exiting after %d SIGINTs.\n", count);
return 0;
}
Bonus: Send other signals from another terminal and observe the behavior:
$ kill -SIGTERM $(pgrep counter) # Not caught — default action kills
$ kill -SIGUSR1 $(pgrep counter) # Not caught — default action kills
$ kill -SIGSTOP $(pgrep counter) # Pauses the process (can't catch)
$ kill -SIGCONT $(pgrep counter) # Resumes it