A Discussion on the Philosophy of Interrupts Based on Signal Processing in Linux

Introduction

In everyday programming, various exceptions are encountered, and this article will discuss how the operating system handles process interrupts and exceptions from the perspective of the operating system.

I am SharkChili, a Java developer and the maintainer of the Java Guide open-source project. Feel free to follow my public account: SharkChili Coding, and you can also learn about my open-source project mini-redis: https://github.com/shark-ctrl/mini-redis.

To facilitate communication with readers, a reader group has been created. Follow the public account below to get my contact information, and add a note to join the group.

Detailed Explanation of Exceptions and Interrupts

Starting with an Arithmetic Operation Error

Assuming we have a <span>java</span> program where the divisor is 0:

public static void main(String[] args) {
        int num = 10 / 0;
    }

Executing this will throw the following message:

Exception in thread "main" java.lang.ArithmeticException: / by zero
 at com.sharkchili.Main.main(Main.java:23)

Similarly, executing a similar arithmetic exception in <span>Go</span> will also throw a similar error:

func main() {
 num := 1 / 0 //./main.go:6:13: invalid operation: division by zero
 fmt.Println(num)
}

So the question arises, how does the operating system handle the same exceptions across different programming languages?

The answer lies in the Interrupt Descriptor Table, or <span>Interrupt Descriptor Table</span> (hereinafter referred to as IDT). In this example, since an arithmetic exception triggers an exception, it invokes a kernel call to the operating system kernel, which is handled by the <span>IDT</span> table. For this exception, the corresponding entry in the <span>IDT</span> table is <span>divide_error</span>, and the program will further locate the specific function that handles this exception based on this table entry:

A Discussion on the Philosophy of Interrupts Based on Signal Processing in Linux

As we can see, the process of triggering an exception interrupt in the operating system involves the following three concepts:

  1. Interrupt
  2. Exception
  3. IDT

First, let’s discuss the concept of an interrupt. An interrupt indicates that something important is happening in the operating system, requiring the current <span>CPU</span> to stop its work and handle a higher-priority task. Interrupts can be triggered not only by exceptions as mentioned in this article but also by user actions such as hardware input or network card packet reception.

Next, let’s talk about exceptions. In the operating system, an exception generally refers to a thread in a program executing erroneous code instructions, such as:

  1. Arithmetic exceptions
  2. Illegal access to an erroneous memory address

When the <span>CPU</span> executes an instruction that triggers an exception, it forcibly interrupts the execution flow and enters the kernel to handle the exception based on the <span>IDT</span>. Readers may think that interrupts and exceptions are similar, but there are some differences:

  1. An exception is a program error that actively triggers a forced interruption.
  2. An interrupt is asynchronously triggered by some signals during the execution of a thread.

Finally, the <span>IDT</span> table, as mentioned above, is essentially an array table agreed upon by software and hardware. Different indices represent different codes and contain corresponding handling functions, allowing processes to quickly locate the exception handling function based on the exception code:

A Discussion on the Philosophy of Interrupts Based on Signal Processing in Linux

At the same time, to improve the efficiency of the <span>CPU</span> in locating and handling exceptions, the operating system maintains a copy of the <span>idt</span> table in the <span>CPU core</span> using the <span>idt register</span>, also known as <span>idtr</span>, allowing the <span>CPU</span> to efficiently and quickly handle each interrupt and exception:

A Discussion on the Philosophy of Interrupts Based on Signal Processing in Linux

Signal Delivery

Taking our arithmetic exception as an example, after the thread locates the exception function <span>divide_error</span> function through the <span>idt</span>, it needs to send a signal to the process to handle this exception. For the arithmetic exception, this is the <span>SIGFPE</span>, which stands for <span>signal floating-point exception</span>. Due to historical compatibility reasons, this name is retained, but in fact, this signal covers all arithmetic exceptions for both integers and floating-point numbers. In addition, there are also keyboard inputs like <span>Ctrl+C</span> which correspond to the <span>SIGINT</span> signal and the <span>SIGREM</span> signal for killing processes.

Subsequently, the exception handling function will deliver the signal to the signal handling queue. For this arithmetic exception, <span>SIGFPE</span> is encapsulated as the value 8 and added to the queue using <span>sigqueue</span>, while also setting the corresponding bitmap for the process to 1:

A Discussion on the Philosophy of Interrupts Based on Signal Processing in Linux

Regarding the core implementation of sending this signal, I also provide the corresponding function <span>send_signal</span> code snippet. It can be seen that the underlying implementation of this function essentially encapsulates the signal as <span>sigqueue</span> and adds it to the queue while adjusting the signal bitmap:

static int send_signal(int sig, struct siginfo *info, struct sigpending *signals)
{
    struct sigqueue * q = NULL;
// Memory allocation
    if (atomic_read(&amp;nr_queued_signals) &lt; max_queued_signals) {
        q = kmem_cache_alloc(sigqueue_cachep, GFP_ATOMIC);
    }

    if (q) {
        atomic_inc(&amp;nr_queued_signals);
        q-&gt;next = NULL;
        // Add to the queue
        *signals-&gt;tail = q;
        signals-&gt;tail = &amp;q-&gt;next;
        // Encapsulate different signals based on signal value into corresponding sigqueue
        switch ((unsignedlong) info) {
            case0:
                q-&gt;info.si_signo = sig;
                q-&gt;info.si_errno = 0;
                q-&gt;info.si_code = SI_USER;
                q-&gt;info.si_pid = current-&gt;pid;
                q-&gt;info.si_uid = current-&gt;uid;
                break;
            case1:
              //......
                break;
            default:
                copy_siginfo(&amp;q-&gt;info, info);
                break;
        }
    } elseif (sig &gt;= SIGRTMIN &amp;&amp; info &amp;&amp; (unsignedlong)info != 1
           &amp;&amp; info-&gt;si_code != SI_USER) {
        return -EAGAIN;
    }
// Update process bitmap
    sigaddset(&amp;signals-&gt;signal, sig);
    return0;
}

Kernel Mode Return

When an exception is triggered, the operating system will record the next instruction after the exception division operation in the <span>EIP</span> register in the thread’s kernel stack, ensuring that after the thread handles the exception, it can return to the next execution point in user mode based on this address. The <span>CPU</span> will deliver the current instruction signal to the <span>pending bitmap</span>, and then execute the following steps:

  1. Handle the signals corresponding to the current thread’s process (including the signal sent by itself)
  2. After the thread has handled the signal, it will return to user mode to process the instruction after the exception using <span>iret (interrupt return)</span>:
A Discussion on the Philosophy of Interrupts Based on Signal Processing in Linux

This completes a closed loop of interrupt handling. In the following sections, we will discuss how threads handle these signals.

Handling Interrupts

The Unreliability of Unix Signals

As mentioned earlier, a signal sending function <span>send_signal</span> can notify the CPU that certain events have occurred, requiring it to stop its current work and prioritize handling these signals.

For exceptions or hardware interrupts, traditional <span>Unix</span> systems use signals to inform the operating system to respond to the interrupt, represented by a 32-bit bitmap array indicating the signal. When a signal needs to be sent, the corresponding index in the queue is set to 1. Although this method can correctly transmit signals, in the case of repeatedly sending the same signal, since the bitmap index is the same, there may be a risk of signal loss when the thread processes that signal:

A Discussion on the Philosophy of Interrupts Based on Signal Processing in Linux

Linux’s Optimization of the Signal Table

Considering this, the <span>Linux</span> system inherits the Unix signal table technology while increasing the signal types to 64 bits for finer differentiation of signal types. We can check this using the <span>kill -l</span> command:

1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

Additionally, to address the issue of repeated signal loss, the <span>Linux</span> system not only increases the signal types but also adds a queue <span>sigqueue</span> to accept repeated signal messages in an appended manner, maximizing the avoidance of signal loss:

struct sigpending {
 // Signal queue
 struct sigqueue *head, **tail;
 // Signal bitmap
 sigset_t signal;
};

Note that as mentioned earlier, this is to maximize the avoidance of signal loss, as the length of the signal queue is limited. If the current queue length exceeds the maximum value of the signal queue, an exception will be thrown, and signals will still be lost.

Correspondingly, we also provide the core code snippet of <span>send_signal</span> to verify this point. It can be seen that during memory allocation, it checks whether the queue length exceeds <span>max_queued_signals</span>. If it exceeds, it will not allocate memory for the structure, and the subsequent processing logic will directly throw an exception:

static int send_signal(int sig, struct siginfo *info, struct sigpending *signals)
{
    struct sigqueue * q = NULL;
// Memory allocation
    if (atomic_read(&amp;nr_queued_signals) &lt; max_queued_signals) {
        q = kmem_cache_alloc(sigqueue_cachep, GFP_ATOMIC);
    }
//......
if (q) {// Structure is not null, only then will the signal be added
        //......
    } elseif (sig &gt;= SIGRTMIN &amp;&amp; info &amp;&amp; (unsignedlong)info != 1
           &amp;&amp; info-&gt;si_code != SI_USER) {
        return -EAGAIN;
    }
}

Timing of Process Signal Handling

Generally, a thread will enter kernel mode from user mode due to system calls, exceptions, interrupts, etc. Upon returning, the thread will check whether the current process has signals that need to be handled, thus completing the signal response.

Some readers may wonder if a thread cannot handle these signals if it does not involve kernel mode calls. In fact, this is not a concern, as the operating system relies on clock interrupts to schedule processes, which means that the process clock will have a clock interrupt that needs to be responded to. Moreover, the delay of this interrupt is imperceptible to humans, so signals can generally be responded to in a timely manner.

We know that processes in the operating system are identified by <span>task_struct</span>, and its internal signal structure <span>signal_struct</span> contains a signal handling table (essentially an <span>action</span> array) that identifies different signals and their handling functions. The process can execute specific handling functions based on the signals received when returning to user mode:

A Discussion on the Philosophy of Interrupts Based on Signal Processing in Linux
struct task_struct {
    ...
   // Signal corresponding handling method sig
    struct signal_struct *sig;
    ...
}

// Contains action indicating different indices representing different signals and their corresponding handling functions
struct signal_struct {
atomic_t  count;
struct k_sigaction action[_NSIG];
spinlock_t  siglock;
};

Users can also customize the handling functions, which essentially modifies the handling logic corresponding to the <span>action</span>. This allows for significant flexibility in the implementation of many high-level language logics. For example, the <span>mini-redis</span> implemented in <span>Go</span> handles specific termination signals for processes:

// Listen for shutdown signals
 sigCh := make(chan os.Signal)
 signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT)
// Goroutine listens for these signals to directly shut down the server and handle all client connections
gofunc(server *redisServer) {
  sig := &lt;-sigCh
switch sig {
case syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT:
   closeRedisServer()
   // Close all clients
   server.done.Store(1)
  }
 }(&amp;server)

Before formally handling the signal, the thread is still in kernel mode. Therefore, to avoid damaging the system’s internal processes with custom functions, <span>Linux</span> follows the principles below when handling signals:

  1. If the signal handling function is the default function, it will be directly handled by the kernel.
  2. If the signal’s corresponding handling function is user-defined, a virtual interrupt will be used to return to user mode to complete the custom function handling, and then return to the kernel to complete the remaining work:
A Discussion on the Philosophy of Interrupts Based on Signal Processing in Linux

Correspondingly, we provide the signal handling function when the thread returns from the kernel to user mode, <span>do_signal</span>. The <span>get_signal</span> function is essentially the default handling function. We should not be misled by the function name; its internal logic will determine whether it is the default signal handling. If it is, it will complete directly; otherwise, it will return <span>true</span>.

For user-defined signal handling, <span>handle_signal</span> will save the user mode register context information (i.e., <span>regs</span>) and then modify it to the address of the user-defined signal handling function to return to user mode for execution, and then return to kernel mode by calling <span>restore_saved_sigmask</span> to formally return to user mode:

// has_signal value is (ti_work &amp; _TIF_SIGPENDING)
void arch_do_signal_or_restart(struct pt_regs *regs, bool has_signal)
{
    struct ksignal ksig;
// If it is a handling function, it will be completed directly in get_signal; otherwise, get_signal returns a value greater than 0, passing to handle_signal to jump to user mode for safe execution and then return
    if (has_signal &amp;&& get_signal(&amp;ksig)) {
        handle_signal(&amp;ksig, regs);
        return;
    }
    
    //......
    // Return to the original user mode context
    restore_saved_sigmask();
}

It should be noted that signals can be ignored under certain circumstances. We can adjust the <span>sigpromask</span> to block signals, but signals like <span>SIGKILL</span> or <span>SIGSTOP</span> cannot be blocked.

Basic Concepts of Thread Groups

In the early days of computer development, systems operated with processes as the smallest unit, meaning a process was the smallest unit and execution flow. As needs grew, the original single execution flow process evolved into multi-threaded programs. To maintain compatibility with the old design concepts, threads still follow the <span>task_struct</span> structure.

So the questions arise:

  1. How do we handle signals for a single process and independent signals for each thread?
  2. How do process signals, which are shared by all threads, notify and correctly allow a specific thread to handle them?
  3. How do we distinguish between process-level and thread-level signal sending?

We will explain each one. As mentioned earlier, threads follow the <span>task_struct</span> structure, so for private independent signals of threads, we allow each thread to maintain its signals using the <span>sigpending</span> queue. Similarly, these threads correspond to processes identified by <span>task_struct</span>, but the address space of this <span>task_struct</span> is shared among threads, thus ensuring that a single structure solves the management of signals at different levels.

Now, regarding process signals, since process signals are at an overall level, shared by the thread group of the current process, we can add a <span>sharded_pending</span> to the original <span>signal_struct</span> to specifically maintain and identify process-level signals.

The last question is essentially an identification issue. We can make the following stipulations:

  1. When the sent signal is for a thread, set the <span>group</span> to 0, indicating a non-group signal.
  2. If the signal to be sent is for the process, meaning any thread in the group can handle it, set the <span>group</span> to 1, indicating that the thread group can share and receive processing. Thus, the responding thread can see the process signal from <span>sharded_pending</span> and handle it.

To summarize the above statements, I also provide the following architecture diagram for readers to refer to for understanding:A Discussion on the Philosophy of Interrupts Based on Signal Processing in Linux

Conclusion

Thus, I have deeply analyzed how the operating system responds to process exceptions and interrupts through signal notifications, and how processes (threads) accurately, timely, and safely respond to these signals. I hope this is helpful to you.

I am SharkChili, a Java developer and the maintainer of the Java Guide open-source project. Feel free to follow my public account: SharkChili Coding, and you can also learn about my open-source project mini-redis: https://github.com/shark-ctrl/mini-redis.

To facilitate communication with readers, a reader group has been created. Follow the public account below to get my contact information, and add a note to join the group.

References

Interrupt Descriptor Table (IDT): https://www.cnblogs.com/qintangtao/p/3325985.html

Understanding ebp&esp registers, function calling process, function parameter passing, and stack balance from an assembly perspective: https://blog.csdn.net/song_lee/article/details/105297902

[Signal] Signal Saving: https://blog.csdn.net/zty857016148/article/details/133955816

Operating System GDT and IDT (III): https://blog.csdn.net/ice__snow/article/details/50654629

A panoramic analysis of the exception dispatch mechanism from CPU to kernel/user mode – Kernel takeover [1]: https://www.anquanke.com/post/id/230449

In-depth understanding of int, iret, and stack: https://zhuanlan.zhihu.com/p/379553031

Linux signal mechanism and its principle analysis: https://juejin.cn/post/7081189234245107742

Linux kernel signal handling mechanism – Explanation of do_signal function (applicable to MIPS architecture): https://www.freesion.com/article/1364736621/

Linux kernel signal handling mechanism – Explanation of do_signal function: https://blog.csdn.net/weixin_38669561/article/details/103920333

sigset_t operations: https://zhuanlan.zhihu.com/p/579973391

Linux signal handling: https://www.cnblogs.com/sunsky303/p/10838610.html

Linux signals and their issues (2) [Transfer]: https://www.cnblogs.com/sky-heaven/p/6844543.html

Leave a Comment