Learn › Learn C › C in Practice
sigaction - a hands-on Linux lab on a real virtual machine.
Signals are the kernel's asynchronous messages to a process: SIGINT from Ctrl-C, SIGTERM from kill, SIGKILL from kill -9. Watch the default action destroy a program, then install a sigaction handler that sets a volatile sig_atomic_t flag so main exits cleanly with status 0, the clean-exit pattern behind every well-behaved daemon. Prove why SIGKILL can never be caught and read death-by-signal exit codes with the 128 plus N rule.
You have done this a hundred times since Linux Foundations. A program runs too long, or loops forever, and you press Ctrl-C. It stops. Instantly.
Now that you write C, that should bother you. You know what is inside a running program: your main, your loops, your function calls. Nothing in your code reads the keyboard while a loop spins. No if statement checks for Ctrl-C. Your program never agreed to stop.
So what reached inside a running process and ended it?
The answer is a signal: a message the kernel delivers to a process, whether the process is expecting one or not. By the end of this lesson you will watch a program die to a signal, then rewrite it so it catches the same signal and shuts down cleanly on its own terms. That clean-exit pattern is how every serious Linux service, from web servers to databases, handles being told to stop.
The black boxes below are a practice terminal: a safe sandbox that checks the one command each step teaches. Every output line was captured live on the real lab machine (Debian 12, gcc 12.2.0), the same image your VM labs run. One value is per-run: the process id. The capture happened to get pid 1064; your program will print a different number, and you use the number YOURS prints. Everything else is exact.
A signal is a small numbered message the kernel sends to a process. It carries no data beyond its number. It just says: this event happened to you, deal with it now.
The real-world analogy is a tap on the shoulder. You are deep in a task. Someone taps you. You did not schedule the tap and you cannot predict when it comes. But when it lands you must react: look up, or shrug it off, or, if it is the fire alarm, drop everything and leave.
The word for that timing is asynchronous: the signal arrives whenever it arrives, with no regard for what line of your code is executing at that moment. Your program could be in the middle of a printf, halfway through a loop, or asleep. The kernel does not wait for a polite moment.
Signals have names and numbers. Three matter most in daily work:
Every signal has a default action, what happens if the program does nothing special. For all three above, the default action is the same: the kernel terminates the process. That is why Ctrl-C has always worked for you. Your programs never handled SIGINT; they simply took the default and died.
Signals are how Linux tells a running program about the outside world. The two big jobs:
First, control. When you reboot a server, something has to tell every running service to stop. The service manager (systemd on modern Linux) sends each process SIGTERM, waits a grace period, and sends SIGKILL to anything still standing. When you press Ctrl-C, the terminal sends SIGINT to the program in the foreground. The kill command you met in Linux Foundations sends signals from the shell. Its name is misleading: kill sends ANY signal, not just fatal ones; it just started life as a process killer and the name stuck.
Second, cleanup. A real program usually holds things that should not just vanish. A half-written file. A temporary file it meant to delete. Buffered output that has not reached the disk yet (remember buffering from the file I/O lesson). If the process is destroyed mid-write, that work is lost or left as garbage. So well-written programs catch the polite signals, finish or undo their in-flight work, and then exit by choice, with exit code 0.
That is the whole game of this lesson: turn "the kernel destroyed my process" into "my process was asked to stop, cleaned up, and exited 0."
First, the victim. This program prints its own process id, then waits forever. Two small new pieces: getpid() returns the process id of the calling process (the same pid you have seen in ps since Foundations), and pause() puts the process to sleep until a signal arrives. Both come from unistd.h, a header you met in the strace lesson.
#include <stdio.h> #include <unistd.h>
int main(void) { printf("pid %d waiting for SIGTERM...\n", getpid()); for (;;) /* loop forever */ pause(); /* sleep for signals */ }
There is no signal handling here at all. The program announces its pid and sleeps. Now the question that matters:
>>> The process dies. wait0 never installed any handling for SIGTERM, so the default action applies: the kernel terminates the process. Being asleep in pause() does not protect it; signals are delivered by the kernel, not by the program's own code, so a sleeping process is just as reachable as a running one. And kill does not need special powers here: any of your processes can be signaled by you.
Compile it and run it. It prints its pid and then sits there; your prompt does not come back, because the program is still alive and waiting. Leave it running.
prompt: operator@camp:~$ answer: gcc -Wall wait0.c -o wait0 && ./wait0 output: pid 1064 waiting for SIGTERM... hint: Compile with gcc -Wall wait0.c -o wait0 && ./wait0
Now open a second terminal. This is the part of the lesson where you play the operator on the other side: the process is over THERE, and you signal it from HERE. Send SIGTERM with kill and the pid the program printed. kill PID with no options sends SIGTERM, signal 15.
prompt: operator@camp:~$ answer: kill 1064|||kill -15 1064|||kill -TERM 1064 output: hint: kill and then the pid: kill 1064 (use the pid YOUR program printed)
kill prints nothing, which means it delivered the signal. Back in the first terminal, the program is gone: the shell reports it was terminated and gives you the prompt back. Notice what did NOT happen: wait0 printed no goodbye, closed nothing, cleaned up nothing. The kernel destroyed it mid-sleep. If it had held a half-written file, that file would now be garbage.
One more piece of evidence, and it connects back to the exit codes you learned in the hello lesson. Ask the first shell for the dead program's exit status:
prompt: operator@camp:~$ answer: echo $? output: 143 hint: The exit status of the last command lives in $?: echo $?
143 is not a number wait0 chose; it never reached any return. When a process dies to a signal, the shell reports 128 plus the signal number. SIGTERM is 15, and 128 + 15 = 143. This rule is worth memorizing because it turns mystery exit codes into confessions. 143 means killed by SIGTERM. 137 means killed by SIGKILL (128 + 9). 139 means killed by SIGSEGV (128 + 11), the segmentation fault signal you crashed into during the strings lesson. Any exit status above 128 means a signal ended the process, not a return.
Now the fix. To react to a signal instead of dying to it, you install a handler: a function of yours that the kernel calls when the signal arrives. A handler always has the same shape: it takes one int (the signal number) and returns nothing.
The installer is sigaction. You fill in a struct sigaction (a struct, exactly like the ones you built in the heap lesson) describing what to do, then hand it to the kernel with sigaction(SIGNAL, &sa, NULL). Three fields matter:
sa_handler: the function to call. You assign the function's name, no parentheses; a function name without parentheses is its address, the same idea as the pointers lesson.sa_mask: other signals to hold back while your handler runs. sigemptyset(&sa.sa_mask) says hold back none, the normal starting point.sa_flags: behavior options. 0 means defaults.You may see older code call signal() instead. It is the original 1970s installer, and its exact behavior varies between systems. sigaction is the precise, portable one, and it is what engineers reach for. This lesson uses sigaction only.
Here is wait0.c grown into a program that catches SIGTERM. The highlighted lines are everything that changed:
#include <signal.h> #include <stdio.h> #include <unistd.h>
volatile sig_atomic_t got_term = 0;
void on_term(int sig) { (void)sig; /* we know which signal this is */ got_term = 1; /* set the flag, nothing else */ }
int main(void) { struct sigaction sa; sa.sa_handler = on_term; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; sigaction(SIGTERM, &sa, NULL);
printf("pid %d waiting for SIGTERM...\n", getpid()); while (!got_term) pause();
printf("caught SIGTERM, exiting cleanly\n"); return 0; }
Read the flow before you predict. The handler on_term does exactly one thing: it sets a flag. All the real work, the goodbye message and the return 0, happens back in main, AFTER the loop notices the flag. That division of labor is the clean-exit pattern, and the next step explains why it has to be this way.
>>> The handler wins. When SIGTERM arrives, the kernel interrupts pause(), runs on_term, and got_term becomes 1. Here is the subtle part: pause() then RETURNS, because its job is to sleep only until a signal has been handled. The while (!got_term) check runs again, finds the flag set, and the loop exits. main prints the goodbye and returns 0. That is also why the loop exists at all: pause() wakes on ANY handled signal, so you always recheck your flag instead of assuming.
prompt: operator@camp:~$ answer: gcc -Wall wait.c -o wait && ./wait output: pid 1064 waiting for SIGTERM... hint: Compile with gcc -Wall wait.c -o wait && ./wait
prompt: operator@camp:~$ answer: kill 1064|||kill -15 1064|||kill -TERM 1064 output: hint: Same as before: kill and the pid your program printed
Same kill, completely different ending. The first terminal now reads:
pid 1064 waiting for SIGTERM...
caught SIGTERM, exiting cleanly
The program spoke on its way out, which means YOUR code ran after the signal arrived. Confirm it exited by choice:
prompt: operator@camp:~$ answer: echo $? output: 0 hint: echo $? reads the exit status of the last command
Exit status 0, not 143. That difference is the entire point of the lesson. wait0 was destroyed by the kernel and the shell reported 128 + 15. wait was ASKED to stop, ran its own shutdown code, and returned 0 like any successful program. To everything watching it (the shell, a service manager, a script chained with && from Foundations), a caught signal looks like a normal, successful exit. This exact run, goodbye line and exit 0, is the captured ground truth from the real lab machine.
Strip the diff to its skeleton and the whole upgrade is three additions. Here they are isolated (a fragment of wait.c, not a full program):
volatile sig_atomic_t got_term = 0; /* 1. the flag */
void on_term(int sig) { /* 2. the handler */ (void)sig; got_term = 1; }
struct sigaction sa; /* 3. the install */ sa.sa_handler = on_term; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; sigaction(SIGTERM, &sa, NULL);
The flag's type looks strange, so take it apart. It is two keywords doing two different jobs.
volatile is a promise-breaker aimed at the compiler. Normally the compiler assumes only your visible code changes a variable, and it optimizes on that assumption. Inside while (!got_term), no code writes the flag, so the compiler could read got_term from memory once and reuse that value forever. But a handler changes the flag from OUTSIDE the visible flow, at a moment the compiler cannot see. volatile says: this variable can change behind your back, so actually read it from memory every single time.
sig_atomic_t handles a different danger. A signal can land in the middle of anything, including in the middle of a variable being written. sig_atomic_t is an integer type the system guarantees is read or written in one uninterruptible step, so the main code can never observe a half-written value. Together, volatile sig_atomic_t is the one flag type that is safe to share between a handler and main. Most engineers get confused here at least once: a plain int seems to work, until an optimized build quietly never exits its loop.
This is also why the handler only sets a flag. Most library functions, including printf, keep internal state, and they are not safe to call while they might ALREADY be running underneath you. If a signal lands mid-printf and the handler calls printf again, that internal state can be corrupted. The full list of what is safe in a handler is short. Setting a volatile sig_atomic_t flag is always on it. So the rule: handler sets the flag, main does the talking.
Here is the flag as gdb shows the memory, before and after the signal:
{ "kind": "memory", "cells": [ { "addr": "0x555555558044", "bytes": "00 00 00 00", "label": "got_term before the signal (0)" }, { "addr": "0x555555558044", "bytes": "01 00 00 00", "label": "got_term after on_term runs (1)" } ] }
Same address, one byte changed. The value 1 shows up in the lowest byte first because this machine stores integers little-endian, exactly as you saw raw bytes in the hexdump lesson. That single byte flipping is the entire conversation between the kernel's signal delivery and your main loop.
The address 0x5555... is what gdb shows, because gdb loads programs at a fixed address by default. Outside gdb, address randomization (ASLR) moves your program every run, so the address differs; the before-and-after picture does not.
If a program can catch SIGTERM, an obvious question follows: could a hostile or buggy program catch EVERYTHING and become unkillable? Try it. This program asks the kernel to install a handler for SIGKILL and checks whether the kernel agreed. sigaction returns 0 on success and -1 on refusal, the same error-return convention you checked on fopen in the file I/O lesson.
#include <signal.h> #include <stdio.h>
void on_kill(int sig) { (void)sig; }
int main(void) { struct sigaction sa; sa.sa_handler = on_kill; sigemptyset(&sa.sa_mask); sa.sa_flags = 0;
if (sigaction(SIGKILL, &sa, NULL) == -1) { printf("cannot catch SIGKILL\n"); return 1; } printf("installed a SIGKILL handler\n"); return 0; }
>>> The kernel refuses at runtime: sigaction returns -1 and the program prints cannot catch SIGKILL. The compiler cannot help here; to gcc, SIGKILL is just the number 9, and the code is perfectly legal C. The rule lives in the kernel, and POSIX (the standard Linux follows) guarantees it: SIGKILL and its cousin SIGSTOP can never be caught, blocked, or ignored, by any process, ever.
prompt: operator@camp:~$ answer: gcc -Wall catchkill.c -o catchkill && ./catchkill output: cannot catch SIGKILL hint: gcc -Wall catchkill.c -o catchkill && ./catchkill
The refusal is by design, and the reason is trust. If a process could catch SIGKILL, a runaway or malicious program could make itself unremovable, and the only fix for a wedged machine would be the power button. So the kernel keeps one guaranteed way to reclaim any process. SIGKILL never reaches your code at all; no handler runs, no cleanup happens. The kernel simply removes the process from existence.
Your wait program proudly catches SIGTERM. See what its handler is worth against SIGKILL. Start it again in the first terminal:
prompt: operator@camp:~$ answer: ./wait output: pid 1064 waiting for SIGTERM... hint: The binary is already built: ./wait
>>> The handler never runs. wait installed a handler for SIGTERM, signal 15, but kill -9 sends SIGKILL, signal 9, a different signal and the uncatchable one. The kernel removes the process directly: no flag is set, no goodbye is printed, and the shell reports a killed process. Check echo $? in the first terminal afterward and you get 137, which is 128 + 9 confessing exactly which signal did it.
prompt: operator@camp:~$ answer: kill -9 1064|||kill -KILL 1064|||kill -SIGKILL 1064 output: hint: Signal 9 by number: kill -9 and the pid your program printed
Silence from kill, and in the first terminal the process is simply gone: no caught SIGTERM line, prompt back, exit status 137. Put the two experiments side by side and you have the operational rule every Linux engineer carries. kill (SIGTERM) is the polite knock that lets a program clean up. kill -9 (SIGKILL) is the demolition order that skips every handler. Always knock first. Reach for -9 only when the knock was ignored, because -9 guarantees whatever cleanup the program intended never happens.
Three messages you will meet while writing signal code, each captured verbatim on this lab image.
1. The compile error that is not about signals at all
broken.c: In function 'main':
broken.c:5:13: error: expected ';' before '}' token
5 | return 7
| ^
What it means: a missing semicolon, here on a return, and just as easily on your got_term = 1 or a struct sigaction field assignment. What to check first: the line the caret points NEAR; the missing semicolon is usually at the end of the line above the reported position.
2. The runtime death that is secretly a signal
/tmp/gtc.sh: line 100: 1009 Segmentation fault ./crash
What it means: the program touched memory it does not own, so the kernel sent it SIGSEGV, sig
Practice Signals in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.