神奇的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的返回值变成0的问题,则是在库函数vfork中产生的。既然子进程已经违规了,库函数没办法保证程序的正确性。而库函数vfork是否返回0也是不确定的,可能不同版本的libc、不同的程序上下文、不同的系统、等等、都会有不同的返回值(或者就直接“段错误”了)。还有可能是,父进程中库函数vfork并没有返回0,但是栈上的返回地址被改写了,从函数createproc返回,返回到printf("child proc")这句话去了。

再深入一点

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>:
983f0: 59 pop %ecx
983f1: 65 8b 15 6c 00 00 00 mov %gs:0x6c,%edx
983f8: 89 d0 mov %edx,%eax
983fa: f7 d8 neg %eax
......
9840e: cd 80 int $0x80
98410: 51 push %ecx
......

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


目录
相关文章
|
C语言
C语言 父进程fork()出的多个子进程在结束后,父进程如何回收?
我在网上找了半天都是在说wait()和waitpid()的详解或者是单个子进程的回收。答非所问。 很简单,根据wait()或者waitpid()的函数特性。没有子进程时返回-1。
85 0
|
7月前
|
存储 算法 Unix
【创建进程】fork函数与写时拷贝
【创建进程】fork函数与写时拷贝
|
7月前
|
算法 Unix Linux
Linux系统编程(vfork和fork)
Linux系统编程(vfork和fork)
103 0
|
存储 编译器 调度
fork函数、进程退出、进程等待(2)
 return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返 回值当做 exit的参数。
151 0
|
缓存 Linux 调度
fork函数、进程退出、进程等待(1)
在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
218 0
|
算法 物联网 Linux
|
算法 物联网 Linux
创建进程 fork 函数|学习笔记
快速学习创建进程 fork 函数
创建进程 fork 函数|学习笔记
|
Linux
2.7~2.9 进程退出、孤儿进程、僵尸进程、wait
2.7~2.9 进程退出、孤儿进程、僵尸进程、wait
116 0
2.7~2.9 进程退出、孤儿进程、僵尸进程、wait
|
Linux 调度
vfork() 函数
vfork() 函数
81 0