How and when are signals?
Signals are one of the oldest and most fundamental parts of any UNIX-like system. They're the
kernel's primary way of tapping a process on the shoulder to tell it something important has
happened. This "something" could be a user hitting Ctrl+C (SIGINT), a child process terminating
(SIGCHLD), or the process trying to do something illegal, like accessing invalid memory
(SIGSEGV).
This is a classic form of asynchronous control flow. The thread is just minding its own business and it's suddenly interrupted to deal with a signal. But how does this magic really work? When does the kernel decide to deliver the signal, and what exactly happens on the stack when the signal handler is called?
A Look at the Defaults
For every different signal, a process has a "disposition," or a default action. This can be one of
five things: Ignoring the signal, Ign; Termination with and without dumping core, Term and
Core respectively; Stopping the process, Stop; and continuing the process, Cont.
These defaults are distributed by severity. SIGCHLD, which gets raised when a child process stops
or terminates, is by default ignored. SIGINT, for interrupting with Ctrl+C, terminates. And the
all time favorite SIGSEGV dumps core. The whole table is visible signal(7)
But of course, we're not stuck with the defaults.
Taking Control
You can change this disposition by handling the signal. The modern, POSIX-compliant way to do this
is with the sigaction(2) system call.
You might also see signal(2) used in older code. Avoid it. Its behavior varies wildly across UNIX
versions, and it's not well-defined for multithreaded programs. sigaction is what you want.
When you call sigaction, you fill out a struct sigaction. The most important field is how you
specify the handler.
For a simple handler you can set sa_handler to a function that just takes one int, being the
signal number.
For an extended handler, you need to set the SA_SIGINFO flag in sa_flags and the kernel will
call the sa_sigaction handler instead. This version is much more powerful and receives three
arguments:
int sig: The signal numbersiginfo_t *info: A struct full of useful data, like the sending process's PID (si_pid) or the faulting memory address forSIGSEGV(si_addr).void *ucontext: A pointer to aucontext_tstructure that contains the process's saved context (registers, stack pointer, etc.) from the moment it was interrupted.
Threads vs. Processes
Signals can be either process-directed or thread-directed.
Thread-directed signals are aimed at a specific thread. This happens from hardware exceptions (like
SIGFPE or SIGSEGV, which are caused by an instruction a specific thread ran) or by explicitly
targeting a thread with tgkill(2) or pthread_kill(3).
Process-directed signals are sent to the process as a whole (e.g., with kill(2) or sigqueue(3)).
The kernel will deliver it to any single thread in the process that does not have the signal
blocked.
Signals for thee, not for me
What if you're in the middle of a critical operation and really don't want to be interrupted? Or you want to designate a specific thread that should handle all process-directed signals? You can use a signal mask or this weird scheduling trick.
Every thread has its own signal mask, which defines the set of signals that it is currently blocking. If a signal is generated for a thread while it is blocked, that signal becomes "pending" and will be delivered only when (and if) the thread unblocks it.
You can manipulate this mask using pthread_sigmask(3) (or sigprocmask(2) if single-threaded).
Or use sigpending(2) to peek at the set of currently pending signals.
The exceptions are SIGKILL and SIGSTOP that can never be caught, blocked, or ignored. They do
exactly what they say, no exceptions.
When Does the Signal Actually Arrive?
This is a key question. A signal can be pending, but it isn't delivered until the kernel has a chance to do it. This check happens whenever a thread is about to transition from kernel mode back to user mode. Either after a system call returns or when a process is rescheduled onto the CPU after being preempted.
It's also possible to explicitly wait for signals to be delivered using calls like pause(2),
sigsuspend(2), sigwaitinfo(2), or signalfd(2).
For standard signals, if multiple instances of the same signal are generated while it's blocked, only one is kept pending. The order in which different pending signals are delivered is not specified.
How a Signal Handler is Invoked
Okay, so the kernel is transitioning to user mode, sees a pending, unblocked signal, and decides to deliver it. This is done in a few steps.
First removes it from the pending signals. Then the context is saved. For that the kernel creates a
new frame on the thread's current user-space stack. On this frame, it saves the thread's complete
execution context (this is the ucontext_t info you can get) . This includes the instruction
pointer, the general-purpose registers, the threads current signal mask, among other things.
Now the kernel modifies the thread's signal mask for the duration of the handler. It adds the signal
being delivered (to not recursively handle the same signal, unless SA_NODEFER is set) and
any other signals specified in the sa_mask field during the sigaction call.
The kernel constructs a new stack frame for the handler function itself. It then hijacks the thread's execution by setting the instruction pointer to the first instruction of the signal handler and setting the return address to a special piece of code called the signal trampoline.
Now that this is done it finally passes control back to user space. The thread, unaware it was ever in the kernel, simply starts executing instructions at the new instruction pointer, which is now the handler.
Returning from the Handler
When your signal handler function eventually returns, it returns to the signal trampoline due to the
address the kernel put on the stack. The only job of this trampoline code is to make a single system
call: sigreturn(2).
This call tells the kernel, "Handler finished!" The kernel then uses the context it saved on the
stack (that ucontext_t data) to restore everything: it restores all the registers, the original
signal mask, and the stack settings.
Finally, it restores the saved instruction pointer, and the process resumes execution exactly where it left off, as if nothing ever happened.
This trampoline code is crucial. It lives either in libc or in the vdso(7). When libc's
sigaction wrapper sets up your handler, it secretly fills in the sa_restorer field with the
trampoline's address and sets the SA_RESTORER flag to tell the kernel it's there.
What if your handler doesn't return (e.g., it uses siglongjmp(3) or execve(2))? Then the
trampoline is never called, and sigreturn doesn't happen. In that case, it's up to you to manually
restore the signal mask if you need to.
A Few More Wrinkles
This covers the main path, but there are a few other important details.
The Stack Problem
What happens if you get a SIGSEGV because the stack has overflowed? The kernel tries to push the
signal-handling frame onto the (already full) stack... which causes another SIGSEGV. The process
will be terminated, and your handler never gets to run.
The solution is the alternate signal stack. You can pre-allocate a separate area of memory and tell the kernel, "If a signal arrives, please use this stack instead".
This is done by calling sigaltstack(2) to register this memory as the alternate stack. And then
when registering the handler with sigaction, by adding the SA_ONSTACK flag.
Now, if a signal arrives, the kernel will switch to this new, safe stack before running the handler. This is essential for robustly handling stack overflows or other memory corruption signals.
fork and execve
When forking the child process inherits a copy of its parent's signal dispositions, signal mask, and alternate signal stack settings.
For execve this is different. Dispositions for handled signals are reset to the default while
ignored ones remain ignored. The signal mask is preserved and any alternate signal stack is removed.
Interrupted System Calls
What if a signal arrives while your process is blocked in a "slow" system call, like read(2) on a
socket?
The kernel runs your signal handler as described. Now, when the handler returns and sigreturn
restores context, the kernel has to decide what to do with the interrupted syscall.
It will either fail the syscall with the error EINTR, or, if you set the SA_RESTART flag in
sigaction, it will automatically restart the syscall. Some calls, like poll(2) or select(2),
never restart and always return EINTR.
Real-Time Signals
Finally, there's a whole separate class of real-time signals (numbered SIGRTMIN to SIGRTMAX). These behave differently from standard signals in a number of ways.
They queue. If you send 10 SIGRTMIN+5 signals, all 10 will be queued and delivered (unlike standard signals, where only one would be).
They are ordered. Different real-time signals are delivered in order from the lowest number to the highest. Multiple signals of the same type are delivered in the order they were sent.
They carry data. You can use sigqueue to send a real-time signal with an integer or a pointer,
which the handler can retrieve from the si_value field in its siginfo_t.
Linux gives priority to standard signals; it will deliver all pending standard signals before it starts on the real-time ones.
A parting signal
Signals are a core part of the UNIX philosophy, acting as the fundamental kernel-to-user-space notification system for asynchronous events. We've covered how they're sent, what their default actions are, and the kernel's dance of stack-switching and context-saving to give you control.
But this is just scratching the surface. Here are some more questions:
What happens when you call printf(3) or malloc(3) inside a signal handler? (Spoiler: Bad things.)
The list of "async-signal-safe" functions is short for a reason, which you can read about in
signal-safety(7).
Why deal with async handlers at all? Modern Linux provides signalfd(2), which lets you turn
signals into data you can read from a file descriptor, fitting them neatly into an event loop.
What happens if you get a signal while you're already in a signal handler? This is where flags like
SA_NODEFER and the sa_mask become critical for preventing chaos.
If you're managing critical operations, you might also be interested in topics beyond just signals, like managing CPU preemption with that weird scheduling trick.
Thanks for reading.