Sending and Intercepting a Signal in C
- Mia Combeau
- C
- November 11, 2022
Table of Contents
Anyone who’s ever been confronted to segfaults and bus error will already be familiar with the idea of signals. We get a SIGSEGV
or a SIGBUS
which ends our program’s execution without notice, without explanation and without appeal. But what really is a signal? Is it more than just the operating system’s police baton? And how can we send, block and even intercept one within our programs? That’s what we will discover in this article.
What is a Signal?
A signal is a standardized notification message used in Unix and POSIX-compliant operating systems. It is asynchronously sent to a running program to notify it of some event. The system interrupts the process’ normal execution to trigger a specific reaction like, among other things, terminating it. So signals are a sort of inter-process communication.
Let’s take a global look at how such a notification message is transferred to a destination process.
How a Signal is Sent
The operating system’s kernel can send a signal for one of the two following reasons:
- it has detected a system-wide event like a divide-by-zero error or the end of a child process,
- a process requested a signal to be sent with the kill system call (a process can send itself a signal in this way).
In reality, “sending” a signal is more like delivering it: the system updates the destination process’ context. Indeed, for each process, the kernel maintains two bit vectors: pending
, to monitor pending signals, and blocked
, to keep track of the blocked signals. When it sends a signal, the kernel simply sets the appropriate bit to 1 in the destination process’ pending
bit vector.
It’s important to realize that there can only ever be a single pending signal of any particular type. In the image above, the process already has a pending signal 17, SIGCHLD
. This means that the system cannot send it any other SIGCHLD
signals until this one is received. There is no queue for pending signals: as long as this signal has not been received, all other signals of the same type that should’ve been sent are discarded instead.
How a Signal is Received
The operating system seems to be able to multitask, but this is only an illusion. In truth, it is constantly switching from one process to the next at lightning speed. This is called “context switching”.
When the kernel resumes the execution of a process, for example after one of these context switches or after completing a system call, it checks the unblocked pending signal set. This is done with the bitwise operation pending & ~blocked
. If that set is empty, as is usually the case, it moves on to the program’s next instruction. However, if the set is not empty, the kernel chooses a signal (typically the smallest) and forces the process to react to it with an action. This is the moment we call “receiving” the signal. Depending on is type, the process will either:
- ignore the signal,
- terminate its own execution,
- intercept the signal by executing its own handler in response.
Once the signal received and one of these actions performed, the kernel resets the corresponding bit in the pending bit vector and moves on to the following instruction if the program has not yet terminated.
POSIX Signals and Their Default Actions
So what are all of these signals that the system can send to processes? The list below shows all of the signals available on Linux. Other POSIX-compatible operating systems will also have these, but they may have different associated numeric values. We can consult the list of available signals and their corresponding numbers with the following command:
kill -l
By default, a process receiving a signal will perform one of these four actions:
- Terminate: the process immediately terminates,
- Core: the process immediately terminates does a core dump (creates a file containing a copy of its memory and registers for future analysis),
- Ignore: the signal is simply ignored and the program carries on with its regular execution,
- Stop: suspends the process’ execution until it receives the
SIGCONT
signal.
Later, we will see how to change the default action associated with a signal. However, it is impossible to intercept, ignore, block or change the action of the SIGKILL
and SIGSTOP
signals.
List of Signals on Linux
Number | Name | Default Action | Description |
---|---|---|---|
1 | SIGHUP | Terminate | Terminal line hangup or parent process terminated |
2 | SIGINT | Terminate | Interrupt from keyboard ( ctrl-c ) |
3 | SIGQUIT | Terminate | Quit from keyboard ( ctrl-\ ) |
4 | SIGILL | Terminate | Illegal instruction |
5 | SIGTRAP | Core | Trace trap |
6 | SIGABRT | Core | Signal from abort function |
7 | SIGBUS | Terminate | Bus error |
8 | SIGFPE | Core | Floating-point exception |
9 | SIGKILL | Terminate | Kill program |
10 | SIGUSR1 | Terminate | User-defined signal 1 |
11 | SIGSEGV | Core | Invalid memory reference (segfault) |
12 | SIGUSR2 | Terminate | User-defined signal 2 |
13 | SIGPIPE | Terminate | Write in pipe with no reader |
14 | SIGALRM | Terminate | Timer signal from alarm function |
15 | SIGTERM | Terminate | Software termination signal |
16 | SIGSTKFLT | Terminate | Stack fault on coprocessor |
17 | SIGCHLD | Ignore | Child process has stopped or terminated |
18 | SIGCONT | Ignore | Continue process if stopped |
19 | SIGSTOP | Stop | Stop signal not from terminal |
20 | SIGTSTP | Stop | Stop signal from terminal ( ctrl-z ) |
21 | SIGTTIN | Stop | Background process read from terminal |
22 | SIGTTOU | Stop | Background process wrote to terminal |
23 | SIGURG | Ignore | Urgent condition on socket |
24 | SIGXCPU | Terminate | CPU time limit exceeded |
25 | SIGXFSZ | Terminate | File size limit exceeded |
26 | SIGVTALRM | Terminate | Virtual timer expired |
27 | SIGPROF | Terminate | Profiling timer expired |
28 | SIGWINCH | Ignore | Window size changed |
29 | SIGIO | Terminate | I/O now possible on a descriptor |
30 | SIGPWR | Terminate | Power failure |
31 | SIGSYS | Terminate | Bad system call |
Sending a Signal
In Unix-type systems, there are several mechanisms to send signals to processes. All of them use the concept of process groups.
Each process belongs to a group which is identified by a positive integer, the PGID
( p rocess g roup id entifier). It’s an easy way to recognize related processes. By default, all child processes belong to their parent’s process group. This allows the system to send a single signal to all of the processes within a group at once.
However, a process can change its own group or the group of another process. This is what our shell does when it creates child processes to execute the user’s input. Let’s display the process identifier ( PID
), the parent process identifier ( PPID
) and the process group identifier ( PGID
) of all the processes associated with our shell with the ps
command:
ps -eo "%c: [PID = %p] [PPID = %P] [PGID = %r]" | grep $$
Here, we can see that le shell, Bash, uses its own PID as its group identifier, not its parent’s. It also creates a new group identifier (5213) for its child processes, ps and grep (5213 and 5214 respectively). This allows Bash to send a single signal to all of its children at once, if need be. Of course, in this example, the child processes’ are very short-lived. But if that wasn’t the case, how would we send them a signal?
Sending a Signal From the Keyboard
From the shell in our terminal, there are three keyboard shortcuts that allow us to interrupt all of the running foreground processes:
ctrl-c
: sendsSIGINT
to interrupt them,ctrl-\
: sendsSIGQUIT
to kill them,ctrl-z
: sendsSIGTSTP
to suspend them.
Of course, these shortcuts do not affect background processes. But what if we want to send one of the 28 other signals instead?
Sending Signals with the Kill Command
To send another type of signal from our terminal, we will need to use the kill
command, even for a signal that does not terminate a process! Some shells have their own builtin kill command. Here, we will only be looking at the program found in /bin/kill
.
First, we must find the PID
or PGID
of the process or processes to send our signal to. To display the PID of a process, we can use the pidof
, pgrep
, top
, or, as we saw earlier, ps
commands.
Let’s say we want to send the termination signal SIGKILL
(number 9) to the process with the PID
4242. We can do this in one of three ways with the kill
command:
/bin/kill -9 4242
/bin/kill -KILL 4242
/bin/kill -SIGKILL 4242
Now, let’s say that the 4242 process has several children that belong to the 4242 group. How do we indicate that we mean the group 4242, not the process? For the kill
command, a negative number means a PGID
and not a PID
. So in order to kill all of the processes in the 4242 group, we can:
/bin/kill -9 -4242
/bin/kill -KILL -4242
/bin/kill -SIGKILL -4242
To signal all of the processes in the current group, we can put 0 as a PID
. To send a signal to all of the system’s processes except for kill
itself and init
( PID
1), we can indicate -1 as a PID
.
The /bin/kill
command also allows us to display a list of all signals with its -L
option. And with its -l
option, we can search for a signal by number ( /bin/kill -l 11
) or by name ( /bin/kill -l SIGSEGV
or /bin/kill -l SEGV
).
Sending a Signal with the Kill System Call in C
In a previous article about creating and killing child processes, we quickly went over the kill
system call from the <signal.h>
library, There are several other system calls to request a signal to be sent from our C programs, but this one is the most commonly used. Let’s recall its prototype:
int kill(pid_t pid, int sig);
This system call works in much the same way as the /bin/kill
command described above. Its parameters are:
- pid: the identifier of the process or process group to send the signal to. Here, we can specify:
- a positive integer: a process’
PID
, - a negative integer: a process group’s
PGID
, - 0: all of the processes in the calling process’ group,
- -1: all of the processes in the system (except process 1,
init
!) for which the calling process has the permission to send signals to. See the manual page for kill (2) about permissions.
- a positive integer: a process’
- sig: the signal to send to the process.
The kill
function returns 0 for success and -1 for failure, in which case it sets errno to indicate the error details.
Intercepting a Signal and Changing its Default Action in C
We saw the default actions associated with each signal in the table above. For example, by default, SIGKILL
triggers the process’ immediate termination, whereas the action associated with SIGCHLD
is to ignore it completely. However, we are not restricted to those default behaviors: a process can change the action with which to react thanks to one or more handlers. The only exceptions to this are SIGKILL
and SIGSTOP
, which cannot be intercepted or have their default actions modified.
Intercepting a Signal with Sigaction
The <signal.h>
library offers two functions to intercept a signal: signal
and sigaction
. Since the first is not recommended because of portability issues, let’s take a look at sigaction
, whose prototype is:
int sigaction(int signum, const struct sigaction *restrict act,
struct sigaction *restrict oldact);
This prototype is slightly intimidating, so let’s explain its obscure parameters:
- signum: the signal we want to modify the action of,
- act: a pointer to a
sigaction
-type structure that will allow us, among other things, to indicate a signal handler. We will examine this shortly. - oldact: a pointer to another
sigaction
-type structure in which to store the old behavior that was triggered upon receipt of the signal. If we don’t need to restore the old action later, then we can just putNULL
here.
In case of success, sigaction
returns 0. In case of error, it returns -1 and sets errno.
Providing a Signal Handler to the Sigaction Structure
Clearly, “sigaction” is the name of the function as well as of the structure type that the function needs to do its job. Let’s glance at this structure in the manual page for sigaction. The interesting variable here is sa_handler
. This is what specifies the action associated with the signal. We can indicate one of three things, here:
SIG_DFL
for the default action,SIG_IGN
to ignore the signal,- a pointer to a signal handler, which is a function that will trigger as a response to this signal. It must have the following prototype:
void function_name(int signal);
. As we can see, this function will take a signal as a parameter, which means we can use this same function to handle several different functions!
The sigaction
structure has another variable, sa_flags
, that offers different options to modify the signal action, but we will not linger on it in this article. The sa_mask
variable allows us to block specific signals during the handler’s execution. We will revisit this in the section on blocking signals.
Let’s also keep in mind that another variable in the structure ( sa_sigaction
) is incompatible with sa_handler
: we can’t specify both. Therefore, we should be careful and make sure all of the bits in the structure are initialized to 0 with a function like bzero
or memset
before filling it out.
Sigaction in Action
Let’s test all of this with a simple program that will only print a message each time we press ctrl-c
, meaning each time we send the SIGINT
signal:
#include <signal.h>
#include <stdio.h>
#include <strings.h>
// Signal handler for SIGINT
void sigint_handler(int signal)
{
if (signal == SIGINT)
printf("\nIntercepted SIGINT!\n");
}
void set_signal_action(void)
{
// Declare the sigaction structure
struct sigaction act;
// Set all of the structure's bits to 0 to avoid errors
// relating to uninitialized variables...
bzero(&act, sizeof(act));
// Set the signal handler as the default action
act.sa_handler = &sigint_handler;
// Apply the action in the structure to the
// SIGINT signal (ctrl-c)
sigaction(SIGINT, &act, NULL);
}
int main(void)
{
// Change SIGINT's associated action
set_signal_action();
// Infinite loop to give us time to do ctrl-c
// as much as we want!
while(1)
continue ;
return (0);
}
Output:
The message is printed whenever we press ctrl-c
, which means that the signal was correctly intercepted and triggers our handler. We can now quit the process with ctrl-\
( SIGQUIT
).
Securing Signal Handlers
Signals are asynchronous, which means that they can occur at any time during our program’s execution. When we intercept them with a handler, we never know where the program is in its execution. If the handler accesses a variable that the program is currently using, the results could be disastrous. And, we can’t forget that a handler can itself be interrupted by another handler (or itself!) if the process receives another signal in the meantime!
Signal handlers are a form of concurrent programming. Yet, as we’ve previously seen in the article about threads and mutexes, concurrent programming can cause unforeseeable and extremely difficult errors to debug. To avoid these kinds of errors, we should take a lot of care when formulating handlers, so that they are as safe as possible. In light of this, here are a few recommendations to keep in mind.
1. Keep signal handlers as simple and short as possible
Sometimes, all a handler has to do is set a flag and let the main program take care of processing the signal. The program will just have to periodically check the flag to know if any response is required.
2. Only use async-signal-safe functions in handlers
The signal (7) manual page maintains a list of safe functions to use in signal handlers. Let’s take note that most of the popular functions like printf
and even exit
are not on this list! This also means that we might need to reconsider our example in the previous section…
3. Save and restore errno
Most of the safe functions mentioned above update errno when they fail. The handler risks interfering with other parts of the program relying on errno. To avoid this, the handler can save a copy of errno at the beginning of its execution in order to restore it at the end.
4. Temporarily block all signals when accessing data shared between the main program and the handler
Reading from and writing to a variable or any data structure requires a sequences of behind-the-scenes instructions. If the handler interrupts the main program and accesses the same variable at the same time, it might find that data in an inconsistent state. Blocking signals while accessing the data guarantees that the handler cannot interrupt a previous access. We will discover how to block signals in the next section.
5. Declare shared global variables as volatile
If the handler updates a global variable that the main program reads from time to time, the compiler might think that the main program never updates the variable. For optimization’s sake, the compiler might choose to cache it, which means that the main program might never see the updated value. The keyword volatile
tells to the compiler never to cache the variable.
6. Declare a flag with sig_atomic_t type
If the signal handler updates a flag that the main program periodically checks to react to the signal before clearing, we can use the sig_atomic_t
type. For this integer type, reading and writing is atomic, meaning they are uninterruptible. This way, we don’t need to temporarily block all signals while accessing the flag.
Blocking a Signal in C
In order to block a signal, we must first add it to a set of signals to be blocked. Then, we will have to give this set either to the sa_mask
variable of the sigaction
structure that we will give to the sigaction
function, or to a dedicated function, sigprocmask
. The <signal.h>
library once again provides us will the variable types and the functions we need.
Before going any further, let’s remember that it is pointless to attempt to block the SIGKILL
or SIGSTOP
signal!
Creating a Set of Signals to be Blocked
First of all, we need a variable of type sigset_t
to store the set of signals we wish to block.
sigset_t signal_set;
Then, we need to initialize it with one of the following functions:
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
As its name suggests, sigemptyset
sets all of the set’s bits to 0, which means there are no signals in the set. Inversely, the sigfillset
function sets all of the set’s bits to 1, meaning it adds all signals to the set. Both of these functions return 0 on success or -1 on failure.
Then, we can add or remove a signal from the set with these two functions:
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
The sigaddset
function adds the signum signal to the set while sigdelset
removes the signum signal from the set. Both of these functions also return 0 for success and -1 for failure.
To determine if a signal is in a set or not, we can use the following function:
int sigismember(const sigset_t *set, int signum);
If the signum signal we give it is part of the provided set, the sigismember
function returns 1, if it is not, it returns 0. If an error occurred, it returns -1.
Blocking the Signals in the Set
Once we have our set of signals to block, we can use either sigaction
or sigprocmask
to block them.
Let’s note that sigaction
only allows us to block the signals in the set for the duration of the signal handler’s execution. For this, we must specify the set in sigaction
’s sa_mask
variable as well as a signal handler in its sa_handler
variable.
The sigprocmask
function allows us to block and unblock a signal at any time. It’s prototype is:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
Its parameters are:
- how: an integer representing the operation to perform with the given set. We can specify:
SIG_BLOCK
to add the set’s signals to the process’blocked
bit vector and block them. This represents the bitwiseblocked = blocked | set
operation.SIG_UNBLOCK
to remove the set’s signals from the process’blocked
bit vector to unblock them. This represents the bitwiseblocked = blocked & ~set
operation.SIG_SETMASK
to replace theblocked
bit vector with the given set. Simply representsblocked = set
.
- set: a pointer to the set or signals that should be added/removed or that should replace the
blocked
bit vector - oldset: a pointer to a memory area to store the old blocked bit vector that will be replaced. If we don’t need to restore the old signals that were blocked, we can indicate
NULL
here.
Signal Blocking Example
Let’s modify our previous test example. We will still intercept SIGINT
( ctrl-c
on the keyboard), but this time, with a secure signal handler. Now, the handler will only set a global flag that the main program will regularly check. To protect this global variable, we will block the SIGINT
signal whenever we read or write to it. At the beginning of the program, we will immediately block SIGQUIT
( ctrl-\
). It can only be unblocked following a SIGINT
signal.
#include <signal.h>
#include <strings.h>
#include <stdio.h>
#include <unistd.h>
// Global variable, shared between the main program and
// the SIGINT handler. The handler will set this variable to 1
// when we press ctrl-c.
// Declared volatile to avoid caching due to compiler optimization
volatile int g_unblock_sigquit = 0;
// Blocks the specified signal
void block_signal(int signal)
{
// Set of signals to block
sigset_t sigset;
// Initialize set to 0
sigemptyset(&sigset);
// Add the signal to the set
sigaddset(&sigset, signal);
// Add the signals in the set to the process' blocked signals
sigprocmask(SIG_BLOCK, &sigset, NULL);
if (signal == SIGQUIT)
printf("\e[36mSIGQUIT (ctrl-\\) blocked.\e[0m\n");
}
// Unblocks the given signal
void unblock_signal(int signal)
{
// Set of signals to unblock
sigset_t sigset;
// Initialize the set to 0
sigemptyset(&sigset);
// Add the signal to the set
sigaddset(&sigset, signal);
// Remove set signals from the process' blocked signals
sigprocmask(SIG_UNBLOCK, &sigset, NULL);
if (signal == SIGQUIT)
printf("\e[36mSIGQUIT (ctrl-\\) unblocked.\e[0m\n");
}
// SIGINT signal handler
void sigint_handler(int signal)
{
if (signal != SIGINT)
return ;
// Blocks other SIGINT signals to protect the global
// variable during access
block_signal(SIGINT);
g_unblock_sigquit = 1;
unblock_signal(SIGINT);
}
void set_signal_action(void)
{
// Declare sigaction structure
struct sigaction act;
// Initialize structure to 0.
bzero(&act, sizeof(act));
// Add new signal handler
act.sa_handler = &sigint_handler;
// Apply new signal handler to SIGINT (ctrl-c)
sigaction(SIGINT, &act, NULL);
}
int main(void)
{
// Change the default action for SIGINT (ctrl-c)
set_signal_action();
// Block the SIGQUIT signal (ctrl-\)
block_signal(SIGQUIT);
// Infinite loop to give us time to do ctrl-\ and ctrl-c
// as many times as we wish
while(1)
{
// Block the SIGINT signal while global variable is read
block_signal(SIGINT);
// If SIGINT signal handler set the global variable
if (g_unblock_sigquit == 1)
{
// SIGINT (ctrl-c) was received.
printf("\n\e[36mSIGINT detected. Unblocking SIGQUIT\e[0m\n");
// Unblock SIGINT and SIGQUIT
unblock_signal(SIGINT);
unblock_signal(SIGQUIT);
}
// Otherwise, unblock SIGINT and keep looping
else
unblock_signal(SIGINT);
sleep(1);
}
return (0);
}
In this output, we can see that when we press ctrl-\
, meaning when we send the SIGQUIT
signal, nothing happens (apart from the terminal displaying ^\
). So the signal is indeed blocked by our process. The instant we press on ctrl-c
to send the SIGINT
signal, the SIGQUIT
signal gets unblocked.
But ad we can see here, we don’t even have time to do another ctrl-\
to actually send SIGQUIT
again. We can’t even see our line 41 printf
that confirms SIGQUIT
’s unblocking! This is because the SIGQUIT
signal was pending ever since our first ctrl-\
. When it was unblocked, its default action (“Quit (code dumped)”) was triggered.
A little tip to share, a nagging question to ask, or a strange discovery to discuss about signals and their handlers? I’d love to read and respond to it all in the comments. Happy coding !
Sources and Further Reading
-
Bryant, R. E., O’Hallaron, D. R., 2016, Computer Systems: A Programmer’s Perspective, Third Edition, Chapter 8 : Exceptional Control Flow, pp. 792-817
-
Linux Programmer’s Manual:
-
Stack Exchange, Linux Process Group Example [stackexchange.com]