Linux 信号处理模型

作者:Moshe Bar

信号用于通知进程或线程特定事件的发生。许多计算机科学研究人员将信号与硬件中断进行比较,硬件中断发生在硬件子系统(例如磁盘 I/O(输入/输出)接口)在 I/O 完成时向处理器生成中断时。此事件反过来导致处理器进入中断处理程序,以便可以在操作系统中根据中断的来源和原因完成后续处理。

UNIX 大师 W. Richard Stevens 恰如其分地将信号描述为软件中断。当信号发送到进程或线程时,可能会进入信号处理程序(取决于信号的当前处置),这类似于系统由于接收到中断而进入中断处理程序。

操作系统信号实际上在信号代码和 UNIX 的各种实现中有着相当长的设计变更历史。这部分是由于早期信号实现中的一些缺陷,以及主要在 BSD UNIX 和 AT&T System V 的不同版本上完成的并行开发工作。James Cox、Berny Goodheart 和 W. Richard Stevens 在他们各自著名的书中涵盖了这些细节,因此无需在此重复。

正确且可靠的信号的实现已经到位多年,其中安装的信号处理程序保持持久性,并且不会被内核重置。POSIX 标准为在代码中使用信号提供了一组相当明确定义的接口,如今 Linux 信号的实现完全符合 POSIX 标准。请注意,可靠的信号需要使用较新的 sigaction 接口,而不是传统的 signal 调用。

信号的发生可能是同步的或异步的,这取决于信号的来源以及根本原因。同步信号是执行指令流的直接结果,其中不可恢复的错误(例如非法指令或非法地址引用)需要立即终止进程。此类信号定向到执行流导致错误的线程。由于这种类型的错误会导致陷入内核陷阱处理程序,因此同步信号有时被称为陷阱。

异步信号在当前执行上下文之外(在某些情况下,与当前执行上下文无关)。一个明显的例子是通过 kill(2)、_lwp_kill(2) 或 sigsend(2) 系统调用,或 thr_kill(3T)、pthread_kill(3T) 或 sigqueue(3R) 库调用从另一个进程或线程向进程发送信号。异步信号也被恰如其分地称为中断。

每个信号都有一个唯一的信号名称,一个以 SIG 开头的缩写(例如,中断信号为 SIGINT)和一个对应的信号编号。此外,对于所有可能的信号,系统定义了信号发生时要采取的默认处置或操作。有四种可能的默认处置

  • 退出:强制进程退出。

  • 核心转储:强制进程退出并创建核心转储文件。

  • 停止:停止进程。

  • 忽略:忽略信号;不采取任何操作。

信号在进程上下文中的处置定义了当信号传递时系统将代表进程采取的操作。进程中的所有线程和 LWP(轻量级进程)共享信号处置,信号处置是进程范围的,并且在同一进程中的线程之间不能是唯一的。

表 1

表 1 提供了完整的信号列表,以及描述和默认操作。内核中支持 Linux 信号的数据结构可以在任务结构中找到。以下是与信号相关的所述结构的最常见元素

  • current-->sig 是信号处理程序。

  • sigmask_lock 是每个线程的自旋锁,用于保护信号队列和其他信号操作的原子性。

  • current-signalcurrent-blocked 包含待处理和永久阻塞信号的位掩码(当前为 64 位长,但可以自由扩展)。

  • sigqueuesigqueue_tail 是待处理信号的双向链表——Linux 具有可以排队的 RT 信号。“传统”信号在内部映射到 RT 信号。

信号描述和默认操作

信号的处置可以从其默认值更改,并且进程可以安排捕获信号并调用其自己的信号处理例程,或者忽略可能没有 Ignore 默认处置的信号。唯一的例外是 SIGKILLSIGSTOP;它们的默认处置无法更改。用于定义和更改信号处置的接口是 signal 和 sigset 库以及 sigaction 系统调用。信号也可以被阻塞,这意味着进程暂时阻止了信号的传递。生成已被阻塞的信号将导致信号保持为进程的待处理状态,直到它被显式解除阻塞或处置更改为 Ignoresigprocmask 系统调用将设置或获取进程的信号掩码,内核检查该位数组以确定信号是否被阻塞。thr_setsigmaskpthread_sigmask 是用于在用户线程级别设置和检索信号掩码的等效接口。

我之前提到过,信号可能出于多种不同的原因来自多个不同的地方。表 1 中列出的前三个信号——SIGHUPSIGINTSIGQUIT——是由控制终端的键盘输入(SIGINTSIGHUP)或控制终端断开连接时生成的(SIGHUP——使用 nohup 命令使进程“免受”挂断的影响,方法是将 SIGHUP 的处置设置为 Ignore)。

其他与终端 I/O 相关的信号包括 SIGSTOPSIGTTINSIGTTOUSIGTSTP。对于源自键盘命令的信号,生成信号的实际按键序列(通常是 CTRL-C)在终端会话的参数中定义,通常通过 stty(1),这会导致 SIGINT 被发送到进程,并且默认处置为 Exit

Linux 中的用户任务,通过显式调用 thr_createpthread_create 创建,都具有自己的信号掩码。Linux 线程调用带有 CLONE_SIGHANDclone;这通过共享 current->sig 指针在线程之间共享所有信号处理程序。传递的信号对于线程是唯一的。

在某些操作系统(例如 Solaris 7)中,由于陷阱(SIGFPESIGILL 等)而生成的信号被发送到导致陷阱的线程。异步信号被传递到找到的第一个未阻塞信号的线程。在 Linux 中,几乎完全相同。在给定线程上下文中发生的同步信号被传递到该线程。

异步内核内信号(例如,异步网络 I/O)被传递到生成异步 I/O 的线程。显式的用户生成的信号也被传递到正确的线程。但是,如果使用 CLONE_PID,则所有使用 PID 传递信号的地方都会以“奇怪”的方式运行;信号会随机传递到 pidhash 中的第一个线程。Linux 线程不使用 CLONE_PID,因此如果您使用 pthreads.h 线程 API,则不会出现此类问题。

当信号发送到用户任务时,例如,当用户空间程序访问非法页面时,会发生以下情况

  • 低级缺页错误处理程序中的 page_fault (entry.S)。

  • do_page_fault (fault.c) 获取故障的 i386 特定参数,并对涉及的内存范围进行基本验证。

  • handle_mm_fault (memory.c) 是通用的 MM(内存管理)代码(与 i386 无关),仅当内存范围 (VMA) 存在时才调用。MM 读取页表项并使用 VMA 来确定内存访问是否合法。

列表 1

我们现在感兴趣的情况是访问非法的情况(例如,尝试写入只读映射):在这种情况下,handle_mm_fault 返回 0 给 do_page_fault。正如您从列表 1 中看到的那样,MM 的锁定非常细粒度(最好是这样);使用 per-MM 信号量 mm->mmap_sem(通常因进程而异)。

force_sig(SIGBUS,current) 用于在发生故障的任务上“强制” SIGBUS 信号。即使进程试图忽略 SIGBUS,force_sig 也会传递信号。

force_sig 填写信号事件结构并将其排队到进程的信号队列中(current->sigqueuecurrent->sigqueue_tail)。信号队列保存无限数量的排队信号。“经典”信号的语义是后续信号被忽略——这在信号代码 kernel/signal.c 中模拟。“通用”(或 RT)信号可以任意排队;信号队列的长度存在合理的限制。

信号已排队,并且 current-signal 已更新。现在到了棘手的部分:内核返回到用户空间。从 do_page_fault=>page_fault (entry.S) 返回到用户空间,然后在 entry.S 中的低级退出代码按此顺序执行

page_fault=>(called do_page_fault)=>error_code=>
ret_from_exception=>(checks if return to user space)=>
ret_with_reschedule=>(sees that current->signal is nonzero)
=>calls do_signal

接下来,do_signal 将要执行的信号出队。在本例中,它是 SIGBUS

然后调用 handle_signal 并带有“出队”信号(如果存在实时信号/消息,则可能包含额外的事件信息)。

接下来调用的是 setup_frame,其中保存所有用户空间寄存器,并将内核堆栈帧返回地址修改为指向已安装信号处理程序的处理程序。在用户堆栈上放置一小段代码跳转器(显然,代码首先确保用户堆栈有效),一旦信号处理程序完成,它将返回到内核空间。(请参阅列表 2。)

列表 2

注意:这个区域是 Linux 内核中最不为人所知的部分之一,这是有充分理由的;它确实是难以阅读和理解的代码。

popl %eax ; movl $,%eax ; int $0x80 x86 汇编序列调用 sys_sigret,稍后它会将内核堆栈帧返回地址恢复为指向原始(发生故障的)用户地址。

所有这些魔法有什么好处呢?好吧,首先内核必须保证信号处理程序被正确调用并且原始状态被恢复。内核还必须处理二进制兼容性问题。Linux 保证在 IA-32 (Intel x86) 架构上,我们可以运行任何符合 iBC86 标准的二进制代码。速度也是一个问题。

列表 3

最后,我们再次返回到 entry.S,但 current-signal 已被清除,因此我们不执行 do_signal,而是跳转到列表 3 中所示的 restore_allrestore.all 执行 “iret”,将我们带入用户空间。突然,我们神奇地执行了信号处理程序。

您迷路了吗?没有?这里还有一些魔法。一旦信号处理程序完成(它像所有表现良好的函数一样执行汇编 “ret”),它将执行我们在用户堆栈上设置的小跳转器函数。我们再次返回到内核,但现在我们执行 sys_sigreturn 系统调用,它也位于 arch/i386/kernel/signal.c 中。它本质上执行以下代码段

if (restore_sigcontext(regs, &frame->sc, &eax))
     goto badframe;
return eax;

上面的代码将确切的用户寄存器内容恢复到内核堆栈帧中(包括返回地址和标志寄存器),并执行正常的 ret_from_syscall,将我们带回到原始的发生故障的代码。希望 SIGBUS 处理程序已经解决了我们发生故障的原因。

现在,在阅读以上描述时,您可能会认为这非常复杂且缓慢。实际上并非如此;lmbench 表明,在所有正在运行的 UNIX 中,Linux 具有最快的信号处理程序安装和执行性能

moon:~/l> ./lat_sig install
Signal handler installation: 1.688 microseconds
moon:~/l> ./lat_sig catch
Signal handler overhead: 3.186 microseconds

最棒的是,它在 SMP 上呈线性扩展

moon:~/l> ./lat_sig catch & ./lat_sig catch &
Signal handler overhead: 3.264 microseconds
Signal handler overhead: 3.248 microseconds
moon:~/l> ./lat_sig install & ./lat_sig install &
Signal handler installation: 1.721 microseconds
Signal handler installation: 1.689 microseconds
信号和中断,天生一对

信号可以从系统调用、中断和底半部处理程序(请参阅侧边栏)发送;没有区别。换句话说,Linux 信号队列是中断安全的,尽管这听起来很奇怪和递归,因此它非常灵活。

底半部处理程序

然而,一个有趣的信号传递案例是在 SMP 上。想象一下,一个线程在一个处理器上执行,并且它从另一个 CPU 上的 IRQ 处理程序(或另一个进程)获得异步事件(例如,同步套接字 I/O 信号)。在这种情况下,我们向正在运行的进程发送跨 CPU 消息,因此信号传递没有延迟。(在 Pentium II 350MHz 上,跨 CPU 传递的速度约为 5 微秒。)

结论

我们再次注意到,Linux 实际上是调度、中断处理和信号处理等重要内核方面的技术领导者。这也证明了Linux 开发人员社区的集体能力和资源比任何私营公司的研发部门都更强大、更丰富的推测。

资源

Moshe Bar (moshe@moelabs.com) 是一位以色列系统管理员和操作系统研究员,他从 1981 年开始在配备 AT&T UNIX Release 6 的 PDP-11 上学习 UNIX。他拥有计算机科学硕士学位。访问 Moshe 的网站:http://www.moelabs.com/

加载 Disqus 评论