蜗窝科技

【博客投稿】一个走野路子的fork( )

蜗窝讨论区存档 · Linux kernel技术问答 · 楼主 karma303 · 2016-09-07 · 4 帖

本文是原「蜗窝讨论区」的历史存档(2016-09-07),来自版块「Linux kernel技术问答」,共 4 帖。讨论区已停止服务,此处仅供查阅。

karma303 · 2016-09-07 11:16

再过两三个月,就准备出去找工作了。因为是第一次找内核相关的工作,心里有些没谱,希望到时候能听听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) &regs + 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"
        :"=&amp;a" (retval), "=&amp;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) &regs + 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-&gt;ps &amp; 8))
    stack_offset = (PAGE_SIZE-1) &amp; (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看做正规军的话。

linuxer · 2016-09-07 14:59

很酷,自己写操作系统的人都很酷,^_^ wowo同学,要不给这位帅哥开一个博客帐号?

karma303 · 2016-09-07 19:52

得罪得罪,我这才投了一篇,就觉得分心了。 哎,我还是回去继续写我的内核算了。 打扰几位博主了。 以后一定有机会再交流。

wowo · 2016-09-07 21:42

karma303 写道: 得罪得罪,我这才投了一篇,就觉得分心了。 哎,我还是回去继续写我的内核算了。 打扰几位博主了。 以后一定有机会再交流。

不分心好,当前就缺能沉得住气的人,加油!!