除了前一博文 Linux 内核学习笔记:进程通信之“管道”介绍的管道,Linux 0.11 支持的进程通信方式还有信号。
按照《Linux 内核设计的艺术》,信号机制是 Linux 0.11 为进程提供的一套“局部的类中断机制”,即在进程执行的过程中,如果系统发现某个进程接收到了信号,就暂时打断进程的执行,转而去执行该进程的信号处理程序,处理完毕后,再从进程被打断处继续执行。
下边我们先来看一下信号相关的基础知识。
信号相关的基础知识
在进程描述符 task_struct 中,有 3 个字段是跟信号相关的:
1 | // include/linux/sched.h ----------------------- |
信号位图
Linux 0.11 用 long 类型的变量 signal
来表示信号位图,总共能表示 32 种。但 Linux 0.11 只用到了 22 种:
1 | // include/signal.h ---------------------------- |
信号号为 signo 的信号在 signal
中占第 signo-1 位(从 0 开始算起),如 SIGCHLD 就占 signal 中的第 17-1=16 位。所以,当要将某信号添加到信号位图时,只需要如此操作即可:
1 | signal |= (1 << (signo - 1)) |
信号屏蔽码
信号屏蔽码 blocked
跟信号位图 signal
位位对应。信号屏蔽码中被置为 1 的位表示,在信号位图中占据同一位置的信号被屏蔽。但 SIGKILL
和 SIGSTOP
是不能屏蔽的。
跟信号屏蔽码相关的程序摘写如下:
1 | // kernel/sched.c ------------------------------ |
信号执行属性结构
信号执行属性结构 struct sigaction
定义如下:
1 | // include/signal.h ---------------------------- |
信号操作
信号操作的完整例子可参考《Linux 内核设计的艺术》第 8.2 节“信号机制”。不过下文提炼了该例的重要过程并对这些过程进行阐述。
信号处理函数绑定
信号处理函数绑定是由 signal
函数来实现的:
1 | // include/signal.h ---------------------------- |
signal 函数经 int 0x80 软中断,映射到 sys_signal
函数:
1 | // kernel/signal.c ----------------------------- |
函数执行效果如下图所示:
图片来源:《Linux 内核设计的艺术》
为了能连续地捕获一个指定的信号,signal
函数的通常使用方式如下:
1 | void sig_handler(int sig) // 信号处理函数 |
其实 signal
函数是一个不可靠的函数,因为当信号已经发生而且已经转到信号处理函数中执行,在重新再一次设置信号处理函数之前,有可能又有一个信号发生,不过此时系统已经把信号处理函数设置成默认值(一般是终止进程),因此有可能造成信号丢失。为了可靠起见,可以使用 sigaction
函数。
发送信号
发送信号的函数是 send_sig
和 tell_father
:
1 | // kernel/exit.c ------------------------------- |
信号检测
信号检测是在 schedule
函数:
1 | // kernel/sched.c ------------------------------ |
信号处理
do_signal
函数是内核系统调用(int 0x80)中断处理程序中对信号的预处理
程序。在进程每次调用系统调用时,若进程已经收到信号,则该函数就会把信号处理函数句柄插入到用户进程堆栈中。这样,在当前系统调用结束后就会立刻执行该信号处理函数,然后再执行用户的程序。如下图所示:
图片来源:《Linux 内核完全注释》
调用 do_signal 前
我们先来看下调用 do_signal 前的(用户进程内核栈)压栈情况:
1 | // kernel/system_call.s ------------------------ |
用图可以表示如下:
图片来源:《Linux 内核完全注释》
这些压栈的数据将作为 do_signal 函数的参数。
调用 do_signal
do_signal 函数虽然由内核执行,当该函数却有修改“进程用户栈”的行为。因为内核有能力访问到所有物理内存,所以修改“进程用户栈”不成问题。
do_signal 函数定义如下:
1 | // kernel/signal.c ----------------------------- |
上述代码可用图表示如下:
图片来源:《Linux 内核完全注释》
iret 之后,被修改后的 eip、cs、eflags、被修改后的 esp、ss 依次恢复给各寄存器。进程从 cs:eip,即信号处理函数开始执行。
调用 restorer
当信号处理函数执行完毕,ret
后就将用户栈顶的数弹出给 eip,开始从 sa_restorer 处执行。sa_restorer 绑定的是库(libc)函数 restorer
的地址。restorer 函数定义如下:
1 | .globl ____sig_restore |
restorer
函数执行完之后,ret
会导致现在栈顶的 old_eip 弹出给 eip,进程从系统调用前的位置开始执行。
注意:iret
和 ret
是不一样的:
iret
是中断返回,而在发生中断时 CPU 会将 ss esp eflags cs eip 依次入栈(内核栈),所以中断返回后会将这些压栈的值恢复ret
是常规调用(如函数调用)返回,而且 ret 实现的是近转移,只需恢复 eip 的值(retf 实现的是远转移,会同时修改 cs 和 eip 的值)