神奇的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”,把“返回地址”放回去。
在论坛里看到下面一段代码:
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”,把“返回地址”放回去。