浅谈Linux基于信号处理中断的哲学
# 引言
任何编程语言日常都会遇到各种各样的异常,而本文将从操作系统的角度来聊聊操作系统是如何处理进程中断和异常的。
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)。
为方便与读者交流,现已创建读者群。关注下方公众号获取我的联系方式,添加时备注加群即可加入。
# 详解异常与中断过程
# 从一个算数运算错误开始
假设我们现在有一个java程序,对应的除数是0:
public static void main(String[] args) {
int num = 10 / 0;
}
2
3
执行后就会抛出如下提示:
Exception in thread "main" java.lang.ArithmeticException: / by zero
at com.sharkchili.Main.main(Main.java:23)
2
而同理go语言执行类似的算数异常也会抛出类似的错误:
func main() {
num := 1 / 0 //./main.go:6:13: invalid operation: division by zero
fmt.Println(num)
}
2
3
4
那么问题来了,操作系统是如何处理不同编程语言中相同的异常呢?
答案是中断描述符表即Interrupt Descriptor Table(以下简称IDT),以本例说明,因为算数异常引发异常,于是触发内核调用来到操作系统内核中,由IDT表处理本次异常中断,以本次异常为例,对应IDT表就是divide_error,程序就会基于此表项进一步定位到具体处理该异常的函数:

可以看到,在操作系统触发异常中断过程中涉及到了如下三个概念:
- 中断
- 异常
- IDT
我们先来说说中断的概念,中断在意味着操作系统中存有重要的事情发生,需要打断当前CPU的工作,让的CPU处理优先级更高的任务。触发中断的方式并非只有本文的异常,用户针对硬件输入、网卡数据包接收等实时行为也都会优先触发中断。
我们再来说说异常,在操作系统中异常泛指程序中的线程执行了错误代码指令,例如:
- 算数异常
- 非法访问错误内存地址
当CPU执行指令触发异常时,就会强制打断执行流,进入内核根据IDT处理异常,可能读到这里读者会认为中断与异常类似,不过二者还是有些差异的:
- 异常是程序错误主动触发被迫打断的
- 中断都是线程执行过程中被某些信号通知后异步触发的。
最后就是IDT表,如上文所说,它本质就是异软硬件协定后的一个数组表格,不同索引代表不同的编码并内置对应处理函数,进程根据异常编码快速定位到异常处理函数:

同时,为提升CPU定位和处理异常的效率,操作系统都会在CPU核心内部用idt寄存器也就是idtr中维护一份idt表让CPU能够高效快速的处理每一份中断和异常:

# 信号投递
以我们算数异常为例,线程通过idt定位到异常函数divide_error函数后,就需要向进程发送信号处理该异常,以算数异常为例则是SIGFPE也就是signal floating-point exception即浮点异常因为历史兼容原因保留这个名字,实际上这个信号覆盖处理所有整数和浮点数的算术异常。除此之外还有键盘键入Ctrl+C的SIGINT以及杀死进程的SIGREM信号。
随后异常处理函数会将信号投递到信号的处理队列,以本次算术异常为例,SIGFPE即数值8封装成sigqueue追加到队列中,同时也会针对该进程对应位图signal对应标志为设置为1:

针对这一点的核心信号发送的实现,笔者也给出对应函数send_signal的代码段,可以看到该函数底层实现本质就是将信号封装为sigqueue追加到队列中,并调整信号位图:
static int send_signal(int sig, struct siginfo *info, struct sigpending *signals)
{
struct sigqueue * q = NULL;
//内存分配
if (atomic_read(&nr_queued_signals) < max_queued_signals) {
q = kmem_cache_alloc(sigqueue_cachep, GFP_ATOMIC);
}
if (q) {
atomic_inc(&nr_queued_signals);
q->next = NULL;
//追加到队列中
*signals->tail = q;
signals->tail = &q->next;
//根据信号值封装不同封装成对应的sigqueue
switch ((unsigned long) info) {
case 0:
q->info.si_signo = sig;
q->info.si_errno = 0;
q->info.si_code = SI_USER;
q->info.si_pid = current->pid;
q->info.si_uid = current->uid;
break;
case 1:
//......
break;
default:
copy_siginfo(&q->info, info);
break;
}
} else if (sig >= SIGRTMIN && info && (unsigned long)info != 1
&& info->si_code != SI_USER) {
return -EAGAIN;
}
//更新进程位图
sigaddset(&signals->signal, sig);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 内核态返回
触发异常时,操作系统会往线程的内核堆栈中的EIP寄存器,记录的异常除法运算指令下一条指令,确保线程处理完异常后基于该地址回到用户态下一个执行点,CPU在将当前指令信号投递到pending位图后,就会执行如下步骤:
- 处理当前线程对应的进程的信号(包含本次自己发送的信号)
- 线程处理完信号之后,通过
iret(即interrupt return)指返回用户态处理异常之后的指令,也就是上述的EIP寄存器的位置:

由此完成一次中断处理闭环,后文我们就展开聊聊线程如何处理这些信号
# 中断的处理
# unix信号的不可靠性
上文提及一个信号发送函数send_signal,通过该函数就可以通知CPU发生了某些事件需要触发中断,让其停下手里的工作优先处理这些信号。
针对异常亦或者是硬件中断,传统unix系统采用信号的方式告知操作系统响应该中断,即32位的位图数组标识信号,需要发送信号的时候针对队列中对应的索引位置设置为1即可,这种方案虽然能够正确传递信号,但是针对重复发送相同信号的情况,因为针对的位图索引都是相同的,所以线程在处理该信号时就可能存在信号丢失的情况:

# Linux针对信号表的优化
考虑到这一点,对此Linux系统在继承unix信号表技术同时,将信号增加达到了64位更细维度的区分信号的种类,对应我们可以通过kill -l指令查阅:
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
2
3
4
5
6
7
8
9
10
11
12
13
同时针对重复信号丢失的问题,Linux系统除了增加信号类型以外还补充了一个队列sigqueue以追加的方式接纳重复的信号消息,以最大限度避免信号丢失问题:
struct sigpending {
//信号队列
struct sigqueue *head, **tail;
//信号位图
sigset_t signal;
};
2
3
4
5
6
注意笔者上文所说的,最大限度避免信号丢失,因为信号队列的长度是有限制,如果当前队列长度超过信号队列的最大值,则会抛出异常无法添加信号,还是会导致信号丢失问题。
对应的我们还是给出send_signal的核心代码段印证这一点,可以看到在进行内存分配的时候会判断队列长度是否超过max_queued_signals,如果超出就不会进行结构体内存分配,后续处理逻辑就会直接抛出异常:
static int send_signal(int sig, struct siginfo *info, struct sigpending *signals)
{
struct sigqueue * q = NULL;
//内存分配
if (atomic_read(&nr_queued_signals) < max_queued_signals) {
q = kmem_cache_alloc(sigqueue_cachep, GFP_ATOMIC);
}
//......
if (q) {//结构体非空,才会添加信号
//......
} else if (sig >= SIGRTMIN && info && (unsigned long)info != 1
&& info->si_code != SI_USER) {
return -EAGAIN;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 进程对于信号的处理时机
一般情况下,线程会因为系统调用、异常、中断等时机从用户态进入内核态,在返回时线程就会查看当前进程是否有需要处理的信号,从而完成信号的响应。
可能会有读者认为如果线程没有涉及内核态调用是否就无法处理这些信号了,实际大可不必担心这一点,操作系统本质就是依靠时钟中断调度进程,这使得进程时钟会有一个时钟中断需要响应,同时这个中断的延迟对于人类而言基本没有任何延迟的感知,所以信号基本是可以及时的被响应。
我们都知道进程在操作系统用以task_struct标识,其内部信号结构体signal_struct对信号和处理器内置了一份信号处理表(本质就是action数组),这份表格标识了不同信号及其处理函数,进程可以根据返回用户态时收到的信号执行特定的处理函数:

struct task_struct {
...
//信号对应处理方法 sig
struct signal_struct *sig;
...
}
//内置了action标识不同索引即代表不同信号,及信号对应处理函数
struct signal_struct {
atomic_t count;
struct k_sigaction action[_NSIG];
spinlock_t siglock;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
同时用户也可以通过自定义需要标识处理函数这本质就是修改上述action对应的处理逻辑,这使得很多高级语言的逻辑实现有了很大的灵活性,例如笔者go语言实现的mini-redis就针对进程的一些终止信号进行特定的处理:
//监听关闭信号
sigCh := make(chan os.Signal)
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT)
//协程监听到这些信号直接关闭服务端并处理所有客户端连接
go func(server *redisServer) {
sig := <-sigCh
switch sig {
case syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT:
closeRedisServer()
//关闭所有客户端
server.done.Store(1)
}
}(&server)
2
3
4
5
6
7
8
9
10
11
12
13
因为在正式处理信号前,线程还是处理内核态,所以为了避免处理自定义函数对系统内部造成破坏,Linux在进行信号处理时遵循如下原则:
- 如果信号处理函数为默认函数,则直接内核处理完成
- 如果信号对应处理函数为用户自定义,则会通过一个虚拟中断将调用返回用户态完成自定义函数处理,再返回内核完成剩余收尾工作:

对应我们给出线程从内核返回用户态时的信号处理函数do_signal,get_signal本质就是默认处理函数,我们不要被这个函数名误导了,其内部逻辑就会判断是否是默认信号处理,如果是则直接完成,反之返回true。
针对用户自定义信号处理handle_signal,Linux内核会将用户态寄存器上下文信息即regs保存下来,然后修改为自定义信号处理函数的地址回到用户态完成调用,然后返回内核态调用restore_saved_sigmask正式回到用户态:
//has_signal的值为 (ti_work & _TIF_SIGPENDING)
void arch_do_signal_or_restart(struct pt_regs *regs, bool has_signal)
{
struct ksignal ksig;
//如果是处理函数则直接在get_signal中完成,反之get_signal返回大于0的数,交给handle_signal中断跳转到用户态安全执行再回来
if (has_signal && get_signal(&ksig)) {
handle_signal(&ksig, regs);
return;
}
//......
//返回原始用户态上下文
restore_saved_sigmask();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
需要补充说明的是,信号可以在特定情况下不接收,我们可以通过刚调整sigpromask将信号屏蔽,但是SIGKILL或者SIGSTOP这些信号就无法被屏蔽。
# 线程组的基本概念
计算机发展初期系统都是以进程为最小子单位运行,即一个进程是最小的单元和执行流,这使得上述的信号可以在单进程中正确的处理。随着需要的增长,原有的单执行流进程变为多线程程序,为了兼容和适配旧有的设计理念,线程还是沿袭了task_struct这个结构体。
那么问题来:
- 我们如何处理单进程信号和各个线程独立信号
- 进程信号是所有线程共享要如何通知并正确的让某个线程处理
- 如何区分进程和线程级别的信号发送
我们逐个说明,上文已告知线程沿袭task_struct结构体,所以针对线程的私有独立信号我们完全让各个线程利用sigpending这个队列维护自己的信号,同理这些线程对应进程也用task_struct标识,只不过这个task_struct地址空间线程共享,由此保证的一个结构体解决不同维度的信号管理。
再来说说进程的信号,因为进程信号是整体维度,即当前进程的线程组共享的,所以我们可以在原有signal_struct基础上增加一个sharded_pending专门维护标识进程级别的信号。
最后一个问题,这本质就是一个标识的问题,我们可以做出如下规定:
- 当发送的信号是给线程的,则设置
group为0,即非分组信号。 - 如果要发送的给进程即全局任意线程可以处理的,则
group分组设置为1,告知线程组可以共享接收处理,由此出于响应中断的线程就可以从sharded_pending看到进程信号并处理。
总结一下上述的说法,笔者也给出如下架构图,读者可以参考理解一下:

# 小结
自此笔者自底层结合源码深入剖析的操作系统的进程异常与中断的响应如何通过信号发送通知,以及进程(线程)如何准确及时且安全的响应这个信号,希望对你有帮助。
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)。
为方便与读者交流,现已创建读者群。关注下方公众号获取我的联系方式,添加时备注加群即可加入。
# 参考
中断描述符表(Interrupt Descriptor Table,IDT):https://www.cnblogs.com/qintangtao/p/3325985.html (opens new window)
从汇编角度理解 ebp&esp 寄存器、函数调用过程、函数参数传递以及堆栈平衡:https://blog.csdn.net/song_lee/article/details/105297902 (opens new window)
【信号】信号保存:https://blog.csdn.net/zty857016148/article/details/133955816 (opens new window)
操作系统之GDT和IDT(三):https://blog.csdn.net/ice__snow/article/details/50654629 (opens new window)
从CPU到内核/到用户态全景分析异常分发机制——内核接管[1]:https://www.anquanke.com/post/id/230449 (opens new window)
对int、iret和栈的深入理解 :https://zhuanlan.zhihu.com/p/379553031 (opens new window)
Linux信号机制及其原理分析 :https://juejin.cn/post/7081189234245107742 (opens new window)
linux内核信号处理机制--do_signal函数讲解 (适用mips架构) :https://www.freesion.com/article/1364736621/ (opens new window)
linux内核信号处理机制--do_signal函数讲解:https://blog.csdn.net/weixin_38669561/article/details/103920333 (opens new window)
sigset_t 操作 :https://zhuanlan.zhihu.com/p/579973391 (opens new window)
Linux信号处理:https://www.cnblogs.com/sunsky303/p/10838610.html (opens new window)
Linux signal 那些事儿(2)【转】:https://www.cnblogs.com/sky-heaven/p/6844543.html (opens new window)