神奇的vfork

一段神奇的代码

在论坛里看到下面一段代码:
int createproc();
int main()
{
pid_t pid=createproc();
printf(“%d\n”, pid);
exit(0);
}
int createproc()
{
pid_t pid;
if(!(pid=vfork())) {
printf(“child proc:%d\n”, pid);
return pid;
}
else return -1;
}
输出结果:
child proc:0
0
child proc:0
Killed

感觉非常奇怪,为什么vfork以后,父子进程都走了“子进程”的分支呢?

什么是vfork?

      什么是vfork,网络上介绍它的文档很多,随便一搜就是一大堆。简单来说,vfork和fork完成了基本上相同的功能,把进程做了一次复制,变成两个进程。
在shell中,执行命令时,shell程序就是通过“复制”形成了父子进程。子进程生成后,执行exec系列函数,载入新的可执行文件,开始执行。
由于复制完成后,子进程马上就要载入新的程序来运行了,在此之前从父进程那里复制来的内存空间都不需要了。所以,“复制”过程中,复制内存空间是件费力不讨好的事情。
所以,fork有了“写时复制”技术。“复制”的时候内存并没有被复制,而是共享的。直到父子进程之一去写某块内存时,它才被复制。(内核先将这些内存设为只读,当它们被写时,CPU出现访存异常。内核捕捉异常,复制空间,并改属性为可写。)

   上面说到的内存空间是实际存储用户数据的空间,利用“写时复制”避免了干前面提到的那件费力不讨好的事情。
但是,“写时复制”其实还是有复制,进程的mm结构、页表都还是被复制了(“写时复制”也必须由这些信息来支撑。否则内核捕捉到CPU访存异常,怎么区分这是“写时复制”引起的,还是真正的越权访问呢?)。而vfork就把事情做绝了,所有有关于内存的东西都不复制了,父子进程的内存是完全共享的。但是这样一来又有问题了,虽然用户程序可以设计很多方法来避免父子进程间的访存冲突。但是关键的一点,父子进程共用着栈,这可不由用户程序控制的。一个进程进行了关于函数调用或返回的操作,则另一个进程的调用栈(实际上就是同一个栈)也被影响了。这样的程序没法运行下去。

      所以,vfork有个限制,子进程生成后,父进程在vfork中被内核挂起,直到子进程有了自己的内存空间(exec**)或退出(_exit)。并且,在此之前,子进程不能从调用vfork的函数中返回(同时,不能修改栈上变量、不能继续调用除_exit或exec系列之外的函数,否则父进程的数据可能被改写)。
尽管限制很多,但并不妨碍实现前面提到的关于shell程序的那个“需求”。

问题的思考

      说到这里,可以看出文章开头的那段代码是存在问题的了。子进程不但调用了printf,还从createproc函数中返回了。但是,子进程的违规为什么会使父进程走上“child proc”这条路呢?父进程在子进程退出前被阻塞在vfork里面,vfork的返回值是如何变成0的呢?前面一直在说vfork,其实它是两个东西,库(libc)函数vfork和系统调用vfork。用户程序调用的是库函数,而库函数再去调用系统调用。用户程序中几乎所有的系统调用都是通过库函数去调用的。因为不同体系结构下(甚至相同体系结构),系统调用的指令和参数传递规则都可能不同,这些细节被库函数隐藏了。

前面提到,父进程被挂起在vfork中,这是指的系统调用vfork。在系统调用中,进程使用的是内核栈(每个进程有着自己独有的内核栈)。此时,父进程在内核里面是安全的,随便子进程怎么违规。内核会保证系统调用vfork的完整性,系统调用的返回值也不会有问题(它是通过寄存器传回用户空间的,跟栈无关)。代码中从系统调用vfork返回后。

     代码中从系统调用vfork返回后子进程调用printf函数并且调用return返回到main最终执行exit(0),然后系统知道子进程要结束了,切换到父进程返回,但是此时由于保存在栈上的父进程返回地址由于子进程的违规(调用return)已经被改写,从而导致父进程返回地址的不确定性。具体到本例就是栈上的返回地址被改写了,从函数createproc返回,返回到printf(“child proc”)这句话去了。也就是说系统调用vfork总是确保父进程能够返回到此时保存在栈上的返回地址处。

再深入一点

     库函数vfork调用系统调用vfork后,库函数是怎样保证父进程正确返回到调用库函数的地址处呢?库函数vfork本身就是一个函数呀,库函数vfork调用系统调用vfork后此时,库函数vfork接着又返回了,按照一般的函数调用准则此时调用系统调用vfork时保存的返回地址已经被改写了。这时,程序的正确性又是如何保证的呢?

     关于函数调用,一般而言:调用前-调用者将需要传递的参数放到栈上;调用时-调用者使用call指令,该指令自动将返回地址入栈;调用后,在被调用的函数中,第一件事是做调用栈的调整,如createproc函数如是做:
08048487 <createproc>:
8048487:       55                      push   %ebp
8048488:       89 e5                 mov    %esp,%ebp
804848a:       83 ec 28            sub    $0x28,%esp
……
其中ESP是当前栈的指针,而EBP是上一层调用栈的指针。调用栈调整之前,EBP保存着上上一层栈的指针,这个值不能丢,需要放在栈上,以便函数返回时恢复。

每层调用都有自己的调用栈,“深”的调用不会影响到之前的调用栈。所以,vfork后子进程调用其他函数应该是没有问题的(但是可能会改写掉属于父进程的某些数据,造成逻辑上的错误),只要它不从调用vfork的函数中返回就行了。但是,库函数vfork本身却不是这样做的。在这个函数中没有使用栈上的内存空间,它没有去进行调用栈的切换,如:

000983f0 <__vfork>:

   0x00007ffff7355fa0 <+0>:     pop    %rdi
   0x00007ffff7355fa1 <+1>:     mov    %fs:0x2d4,%esi
   0x00007ffff7355fa9 <+9>:     mov    $0x80000000,%ecx
   0x00007ffff7355fae <+14>:    mov    %esi,%edx
   0x00007ffff7355fb0 <+16>:    neg    %edx
   0x00007ffff7355fb2 <+18>:    cmove  %ecx,%edx
   0x00007ffff7355fb5 <+21>:    mov    %edx,%fs:0x2d4
   0x00007ffff7355fbd <+29>:    mov    $0x3a,%eax
   0x00007ffff7355fc2 <+34>:    syscall
   0x00007ffff7355fc4 <+36>:    push   %rdi
   0x00007ffff7355fc5 <+37>:    test   %rax,%rax
   0x00007ffff7355fc8 <+40>:    je     0x7ffff7355fd2 <vfork+50>
   0x00007ffff7355fca <+42>:    mov    %esi,%fs:0x2d4
   0x00007ffff7355fd2 <+50>:    cmp    $0xfffff001,%eax
   0x00007ffff7355fd7 <+55>:    jae    0x7ffff7355fda <vfork+58>
   0x00007ffff7355fd9 <+57>:    retq
   0x00007ffff7355fda <+58>:    mov    0x2e0fc7(%rip),%rcx        # 0x7ffff7636fa8
   0x00007ffff7355fe1 <+65>:    xor    %edx,%edx
   0x00007ffff7355fe3 <+67>:    sub    %rax,%rdx
   0x00007ffff7355fe6 <+70>:    mov    %edx,%fs:(%rcx)
   0x00007ffff7355fe9 <+73>:    or     $0xffffffffffffffff,%rax
   0x00007ffff7355fed <+77>:    jmp    0x7ffff7355fd9 <vfork+57>

……

    所以父进程在库函数中运行时,不用担心栈上的数据已经被子进程修改(它根本不去使用栈上的数据)。然而call/ret指令却不得不使用栈(因为返回地址自动会被CPU放在栈上),如果子进程在vfork后调用其他函数,会使得父进程在进入库函数vfork时通过call指令在栈上留下的“返回地址”被擦掉。
事情的确是这样。于是库函数vfork为了解决这个问题,做了一些手脚,它并没有让栈上的“返回地址”一直留在栈上。注意上面的汇编代码,进入库函数vfork的第一条指令就是“pop %rdi”,把放在栈上的“返回地址”弹到了rdi中去,保存起来。然后在系统调用vfork返回后(int 0x80是用于系统调用的指令),再“push %ecx”,把“返回地址”放回去。

     库函数vfork和普通函数的调用开始处汇编不同,它没有压入栈中数据,栈中只保存返回地址(虽然在系统调用时使用过但是最终会还原),可以从vfork库函数的汇编代码中看到,库函数进行系统调用vfork时先做pop    %rdi把返回地址保存下来,当进行完syscall 从新写入返回地址,然后根据系统调用vfork的返回值做一些处理然后最终都会执行到retq 指令 返回保存的地址。所以父子进程在不破坏返回地址的时候都能返回到正确的地址,如果子进程违规则会导致不确定的返回地址!

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

*