如何实现简单的POSIX信号处理

这是一篇关于POSIX信号实现机制的文章,主要是基于自己的思考、sos实现以及对早期linux内核(0.11和1.0版本)的分析。这里谈论的是古老的实现方式,我不确定是否还有系统在使用类似的方法。因为这里的实现技术具有很多缺陷,在描述实现细节之后我们再来看看一些现代linux的更好的实现方式。

我将不会描述什么是信号以及如何使用信号,这方面最好的参考是UNIX环境高级编程,也可以参考《Linux Programming Interface》这本书。信号的生命周期包括生成(generate),送达(deliver)。在这篇文章里我把重点放在deliver的实现上,更精确地说,我要通过一个具体而微的简单内核的实现来描述操作系统内核是怎么调用用户空间提前设置好的信号处理函数的。

我们都知道操作系统内核和用户程序分别运行于独立的特权级。用户空间通过受限的方式(系统调用、文件读写等)访问内核资源。用户通过使用signal或sigaction系统调用来设置信号的自定义处理函数。我们都知道,POSIX信号是异步执行的(不考虑实时信号),发送信号的时刻与该信号对应的处理可能会相隔很远,具体要看是什么信号以及进程所处的状态。当信号最终被送达目标进程并被处理时,内核并不能直接调用自定义的信号处理函数,因为它所处的内存页是用户级的(准确的说其实是可以的,高特权级的代码是可以调用低特权级代码的,但由于各种安全原因并不会直接调用)。为了执行一段用户态的代码,内核需要使用一些技巧。

在实现内核实施用户自定义函数时,需要考虑一些问题。首先,这种调用肯定是破坏性的。因为内核和函数处于不同的特权级(自然使用到的栈空间也不一样),没有简单的caller/callee关系,调用也不是通过通常的call指令发生的。所以要有一种方法保证执行信号处理函数前后的现场是一致的。另外,信号处理函数是可以嵌套的,用户完全可以在信号处理函数内部发送信号。我们设计的方案必须能处理这种情况。

在x86系统下,如果代码要从高特权级(内核)跳转到一个低特权级(用户态)的代码段最基本的方法是手工构造一个iret调用。基本上这是所有中断服务例程返回用户态的方法。而我们实现处理函数的执行就是利用了这个iret指令。基本思路是:

iret指令返回用户态时会从内核栈中弹出五个数据,分别是用户态的ss、esp、eflags,cs、eip。eip是执行系统调用的中断指令的下一条指令的地址。在正常情况下,iret将返回到eip地址处继续用户程序的执行。如果我们在iret执行前,修改了内核栈中对应eip位置的值,让其指向用户先前设置的信号处理函数的地址,那么iret返回后不就执行信号处理函数了吗。但是事情还没完,函数执行时,我们需要给它提供一个参数。而且这个函数执行完成后会返回哪里呢?合理的情况下当然是返回修改eip前,iret应该返回的位置。这么一来就清楚了,我们需要手动为信号处理函数在用户态的栈空间里建立一个栈帧,设置函数的参数和返回地址。这里有一个细节问题,那就是当我们手动建立栈帧时,势必修改了用户esp的值,而且信号处理函数有可能改动了某些寄存器的值。我们在处理函数ret之前要把现场恢复到原始状态,就好像iret是直接返回的一样。

根据上面的描述,我们来看一个具体的实现,也就是soshandle_signal。首先要说明的,因为内核在何时deliver(即检测当前进程是否有pending的信号等待并处理)有很大的灵活性。比如linux会在每次时钟中断时检测,同时在任何中断请求结束并返回用户空间前也会检测。目前sos的实现是只在系统调用返回用户空间前检测。

在实现上面的机制时,要如何保存和恢复现场,如何设置信号处理函数的调用环境等,有很多的选择性。我所使用的方法基本上跟linux 0.11版本一样(后来我看过几个其他的简单内核,其思路基本也是一样的)。这个函数目前实现不完整,但是表达了基本的要素。当检查到用户设置了自定义的处理函数时,就着手构造一个处理函数执行的环境。这段代码不长,我直接贴出来:

1
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
uint32_t oesp = regs->useresp;
uint32_t* ustack = (uint32_t*)((char*)oesp - sizeof(sigcontext_t));
sigcontext_t ctx;
ctx.eax = regs->eax;
ctx.ebx = regs->ebx;
ctx.ecx = regs->ecx;
ctx.edx = regs->edx;
ctx.esi = regs->esi;
ctx.edi = regs->edi;
ctx.ebp = regs->ebp;
ctx.uesp = regs->useresp;
ctx.eip = regs->eip;
ctx.sig_mask = current->sig.blocked;
memcpy(ustack, &ctx, sizeof(sigcontext_t));

// trampoline code
// movl SYS_sigreturn, %eax
// int $0x80
// ret
char* code = (char*)ustack - 1;
*code-- = 0xc3; // ret
*code-- = 0x80;
*code = 0xcd; // int $0x80
code -= 4;
*(uint32_t*)code = SYS_sigreturn;
code--;
*code = 0xb8;

signal_dbg("save at 0x%x, code at 0x%x, new eip 0x%x\n",
ustack, code, handler);
ustack = (uint32_t*)code - 2;
ustack[0] = (uint32_t)code;
ustack[1] = sig;
regs->eip = (uint32_t)handler;
regs->useresp = (uint32_t)ustack;

代码很短,但是干了很多事情,我们分几个部分来看:

  • 首先是保存原始环境。regs指向内核栈iret返回前的中断上下文。我在用户栈的栈顶位置开辟一个空间来保存(sigcontext_t结构)。
  • 然后是一段硬编码的x86 32位代码,如注释所示,它构造了一个函数,这个函数的功能是执行一个系统调用SYS_sigreturn。如果你去看sys_sigreturn的代码,你就知道它是与之相对的用来恢复现场的系统调用。可以看出来,这段代码存放在用户栈中。如图所示:
  • 最后修改了regs的eip,使其指向信号处理函数。而useresp指向新的ustack位置。ustack的栈底最后两个位置一个是信号处理函数的参数(即信号值)以及返回地址。返回地址指向了硬编码的那段代码的第一条指令的地址。于是在信号处理函数执行完成后,会跳转到上面的那段代码,该代码立即执行一个系统调用来恢复被信号处理函数改变的现场。如图所示:

在信号处理函数执行完成后,会返回到并执行栈中的trampoline,从而触发一个对sigreturn的系统调用。陷入内核后,此时的栈情况如图所示:

sigreturn的工作就是把保存在用户栈中上下文恢复到内核栈中对应的位置。注意,恢复后,regs里的eip指向了被信号处理函数打断的系统调用的返回地址。

需要注意的是,上面的方法是不安全的,而且也不具有可移植性。现代的操作系统应该不会允许用户栈具有可执行权限(在linux下可以用cat /proc/self/maps看看[stack]区域)。而硬编码指令的做法也无法在不同的体系结构下运行。所以现代linux在恢复信号处理后的现场时,采用了其他技术(比如利用vdso和c库配合),这个需要另外一篇文章详细描述。

PS:这有一个方便的查询linux系统调用的网址,挺方便的。

PPS:sos是我自己写的一个简单内核,极度不完善。