【博客投稿】一个走野路子的fork( )
本文是原「蜗窝讨论区」的历史存档(2016-09-07),来自版块「Linux kernel技术问答」,共 4 帖。讨论区已停止服务,此处仅供查阅。
再过两三个月,就准备出去找工作了。因为是第一次找内核相关的工作,心里有些没谱,希望到时候能听听wowo的建议。我准备以后往这里投一些文章,这样博主能对我的水平有一个了解。 当然,也不全是自私的目的~ 我很欣赏这个博客,希望能给它添砖加瓦。只不过有时候写文章不爱发出来。 这是第一篇。
==============正文的分割线============
写在前面: 我对linux的研究远不及各位深入,但因为我一直在写自己的os(名字叫papaya),有时看问题的角度会不同,加之papaya的设计意图就是linux的简化版,所以分享出来一些心得。所谓他山之石,可以攻玉,希望能帮助各位更好的理解linux,当然,也欢迎大家批评指正。 另外,因为是突然想到给wowo投稿,手上没有现成的文章,所以就从papaya的src/document/log里面找出来一篇日志:ebp-when-when-fork-kernel_thread.txt,是前几天调试完fork(函数里的一个bug后写的。
=================================================
这两天在调试fork(的代码,不断的修改,逐渐发现fork(是很有意思的:我以前只知道它能镜像一个用户进程,原来它也可以镜像一个内核线程。
乍眼看上去,fork一个内核线程的工作量要小得多。因为不用考虑用户空间, 通共不到8K的堆栈,吃住都在这里。
下面是papaya sys_fork(里的一段代码(有删改),用来处理"镜像线程“返回的路线。 ================================================================= int pcb2pcb = (unsigned)(p) - (u32)(current); //两个task_struct的距离 stack_frame float_regs = (void )((unsigned) ®s + pcb2pcb); //float_regs->ebp += pcb2pcb;
p->thread.esp = (unsigned)float_regs;
p->thread.eip = (u32)ret_from_sys_call;
float_regs->eax = 123;
return 12345;
=================================================================)
首先,镜像线程和原线程重新投入运行(也就是从这次系统调用出去)的方式是不同的。 原线程很简单,直接从这次sys_fork( return,然后pop刚才int 0x80时候压栈的寄存器现场,再iret就好了。 镜像线程需要等一会儿。它投入运行的方式是”被调度“,被挑中之后,走的是schedule里的__switch(路线。 所以处理它的返回路线,就是在task->thread里做文章。其实就是伪造一个“进程切换上下文”,因为从原线程复制过来的thread成员没有意义(you should know why..)。 papaya把thread->esp变量被设置成float_regs,即"浮动regs"。看float_regs的命名和赋值,能发现它不是传统的8K堆栈底部的那个struct pt_regs, 而是刚才int 0x80时候,所“就地压栈”的“cpu寄存器现场”。 内核线程当然该这么做,它被硬中断时,就是“就地”做寄存器压栈的。它不像用户进程那样,有硬件的堆栈切换,总能自动定位到8K堆栈的最底部。
所以最底部那个struct pt_regs对内核线程是没有意义的,只有一种内核线程需要它,就是那种通过create_process(创建的(而非fork( 第一个进程。 即使那样,也只是在创建后启动时用一次, 以后就用不到了,不仅用不到,而且会覆盖掉。(因为启动之后,esp就在8K堆栈的最低部了,然后往上生长。。。)
【第二部分】 fork出来的内核线程,其实是不太稳定的。无法保证它绝对不崩溃。为什么呢?看下面一个例子: ================================================================= void func0(void){ int x; asm volatile(".intel_syntax prefix\n\t" "int 0x80\n\t" ".att_syntax prefix\n\t" :"=a"(x) :"a"(1) ; while(1){ oprintf("%u ", x); schedule_timeout(1000); }
=================================================================)
基本上这也是我测试时用的代码。
func0是一个内核线程。它通过int 0x80创建一个镜像线程。
我期望的是在它俩在屏幕上竞相打印12345 和123.但屏幕上只输出123。
通过gdb能断出来到两个线程确实都在运行。
很明显,func0的堆栈里的x变量被修改了。
看原因。
下面是这段代码的反汇编:
=================================================================
...
int $0x80
mov %eax, -0xc(%ebp)
sub $0x8(%esp)
pushl -0xc(%ebp)
push $0xc001e263
call 0xc001c21c<oprintf>
...
=================================================================)
问题出在ebp上。因为fork时float_regs也是完全拷贝过去的,所以新线程在被调度后,也就是,从float_regs resume后,ebp也就指向了原进程的堆栈。
解决方式是在sys_fork(里加上一句代码:
float_regs->ebp += pcb2pcb;
在这个例子中,这句代码能奏效。但是不严密。因为还有esi,ebx这样的寄存器,你不确定它们存储的是一个地址,还是一个常规的数值。没办法也做调整。
【第三部分】 但是"fork内核线程"仍然是有用的。 因为我们通常不会傻乎乎的fork一个线程,让它镜像的运行下去, 而是会让它立刻执行某个函数。 看linux的kernel_thread(函数,它就是通过fork实现的。 ================================================================= / * Create a kernel thread / int kernel_thread(int (fn)(void ), void * arg, unsigned long flags) { long retval, d0;
__asm__ __volatile__(
"movl %%esp,%%esi\n\t"
"int $0x80\n\t" /* Linux/i386 system call */
"cmpl %%esp,%%esi\n\t" /* child or parent? */
"je 1f\n\t" /* parent - jump */
/* Load the argument into eax, and push it. That way, it does
* not matter whether the called function is compiled with
* -mregparm or not. */
"movl %4,%%eax\n\t"
"pushl %%eax\n\t"
"call *%5\n\t" /* call fn */
"movl %3,%0\n\t" /* exit */
"int $0x80\n"
"1:\t"
:"=&a" (retval), "=&S" (d0)
:"0" (__NR_clone), "i" (__NR_exit),
"r" (arg), "r" (fn),
"b" (flags | CLONE_VM)
: "memory");
return retval;
}
=================================================================)
它在int 0x80调用sys_fork之后,立刻又调用了fn。这样ebp等寄存器就会被这个新的函数刷新。 这很像是一个进程vfork之后,立刻调用execve(,跟旧的生命告别。
【第四部分】 这篇文章的主要部分讲完了。 在最后,我想专门对比一下papaya在fork的设计上,跟linux的一个差异: 为子进程安排返回线路的差异。
返回线路肯定都是通过task->thread。区别在于设置task->thread->esp。
papaya是把esp设置的跟原进程的相对位置一样: int pcb2pcb = (unsigned)(p) - (u32)(current); stack_frame float_regs = (void )((unsigned) ®s + pcb2pcb); p->thread.esp = (unsigned)float_regs;
linux就比较激进了,它是让esp指向8K堆栈的底部的pt_regs: childregs = ((struct pt_regs ) (2PAGE_SIZE + (unsigned long) p)) - 1;
你可能会问, 内核堆栈底部的pt_regs不是被覆盖了,怎么还让从那儿返回?
所以linux紧接着是这样做的:
childregs = regs;
不要小瞧了这句代码,对ring3进程来说,这个拷贝是镜像的,但是对ring0线程来说,这个拷贝通常是这样:
linux这样做,其实就是临时搭建起来一个底部的pt_regs。新线程照从这里resume,是可以回到int 0x80的下一条指令的,但接下来是无法照旧路走下去的¹,因为原线程之前的堆栈,它没有。
但papaya就可以,虽然不那么保险。
我觉得linux这么做,是为了提高fork的速度,因为不需要复制上图中routine stack那一片区域。
PS: linux的arch/alpha目录下的kernel/process.c里的copy_thread的行为有点儿像papaya:
=================================================================
stack_offset = PAGE_SIZE - sizeof(struct pt_regs);
if (!(regs->ps & 8))
stack_offset = (PAGE_SIZE-1) & (unsigned long) regs;
childregs = (struct pt_regs *) (stack_offset + PAGE_SIZE + (long)p);
*childregs = *regs;
=================================================================)
-------------Fri Sep 2 11:17:38 CST 2016---------
【注释】 ⁰ 以上的linux源码基于linux2.4 。 ¹ linux也没打算让它继续走旧路。
最后最后,我似乎要解释一下题目。我想说的是papaya的fork(的路子比较野,如果把linux看做正规军的话。
很酷,自己写操作系统的人都很酷,^_^ wowo同学,要不给这位帅哥开一个博客帐号?
得罪得罪,我这才投了一篇,就觉得分心了。 哎,我还是回去继续写我的内核算了。 打扰几位博主了。 以后一定有机会再交流。
karma303 写道: 得罪得罪,我这才投了一篇,就觉得分心了。 哎,我还是回去继续写我的内核算了。 打扰几位博主了。 以后一定有机会再交流。
不分心好,当前就缺能沉得住气的人,加油!!
