lab4中do_fork()函数具体如何产生一个新的内核线程的gdb过程展示
匿名2023/07/31 19:51:36提问
    课堂问答lab4studentunanswered
260

根据向勇老师的指示,在这里分享一下我跟踪do_fork()及相关创建线程代码的过程。主要内容分为do_fork()主要涉及的子函数功能分析,以及完成do_fork()之后如何启动新的线程init。

  1. 首先把/tools/gdbinit加一条break do_fork()
  2. 可以把makefile里TERMINAL := gnome-terminal改为自己合适的终端,默认的ubuntu不需要改。
  3. 在项目根目录下运行make debug,开始gdb调试。
    1. Breakpoint 2, do_fork (clone_flags=256, stack=0, tf=0xc0126f54) at kern/process/proc.c:279
      279     do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) {
      (gdb) l
      274      * @clone_flags: used to guide how to clone the child process
      275      * @stack:       the parent's user stack pointer. if stack==0, It means to fork a kernel thread.
      276      * @tf:          the trapframe info, which will be copied to child process's proc->tf
      277      */
      278     int
      279     do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) {
      280         int ret = -E_NO_FREE_PROC;
      281         struct proc_struct *proc;
      282         if (nr_process >= MAX_PROCESS) {
      283             goto fork_out;
      (gdb) n
      这是初始界面,用list命令查看代码上下文,用next进行下一条代码(不进入函数),用step进行下一条代码(会进入函数)。
    2. 首先会初始化返回值ret,如果进程总数nr_process比最大进程数大,则直接退出,返回-E_NO_FREE_PROC
      (gdb) n
      280         int ret = -E_NO_FREE_PROC;
      (gdb) p ret
      $1 = 1211072
    3. 然后设置ret为-E_NO_MEM,接着分配TCB,调用alloc_proc(),初始化TCB,具体可以查看alloc_proc()函数,如下
          struct proc_struct *proc = kmalloc(sizeof(struct proc_struct));
          if (proc != NULL) {
              proc->state = PROC_UNINIT;
              proc->pid = -1;
              proc->runs = 0;
              proc->kstack = 0;
              proc->need_resched = 0;
              proc->parent = NULL;
              proc->mm = NULL;
              memset(&(proc->context), 0, sizeof(struct context));
              proc->tf = NULL;
              proc->cr3 = boot_cr3;
              proc->flags = 0;
              memset(proc->name, 0, PROC_NAME_LEN);
          }
          return proc;
      可以看出是对TCB的变量进行初始设置。
    4. 之后分配内核堆栈
          struct Page *page = alloc_pages(KSTACKPAGE);
          if (page != NULL) {
              proc->kstack = (uintptr_t)page2kva(page);
              return 0;
          }
          return -E_NO_MEM;
      这里很重要的一点是调用之前我们实现的alloc_pages,并且把proc->kstack指向新的页面,page2kva会算出page指针相对于pages这个起始地址的偏移,进而可以得到其相对的内核虚拟地址。如果页面内存不足,则返回失败。
    5. 接着会调用copy_mm(),但是lab4不会涉及到内存空间的切换(因为都是内核线程),lab5涉及用户进程才会用到,所以这里面只是一个简单的assert,便返回了。
    6. 接着是最重要的copy_thread(proc, stack, tf);
      static void
      copy_thread(struct proc_struct *proc, uintptr_t esp, struct trapframe *tf) {
          proc->tf = (struct trapframe *)(proc->kstack + KSTACKSIZE) - 1;
          *(proc->tf) = *tf;
          proc->tf->tf_regs.reg_eax = 0;
          proc->tf->tf_esp = esp;
          proc->tf->tf_eflags |= FL_IF;
      

          proc->context.eip = (uintptr_t)forkret;
          proc->context.esp = (uintptr_t)(proc->tf);
      }


      利用kstack得到上面我们分配的空间,将proc->tf指针的地址设置好,我没有弄明白的地方在于为什么偏移之后还要再减一呢?之后会复制传进来的参数——父进程的trapframe指针,然后设置子进程中断栈帧中保存的eax,这是子进程开始运行之后eax会被还原的值。需要明确的是,这一系列操作都是在父进程中进行的,通过父进程设置好子进程中一系列环境之后,再会有相应的函数让子进程开始运行。之后会设置esp、flag等。然后是:

          proc->context.eip = (uintptr_t)forkret;
          proc->context.esp = (uintptr_t)(proc->tf);

      这里十分重要,会设置指令指针eip为forkret函数,esp为父进程的中断栈帧,这样进入子进程后就会调用forkret函数,具体等我们后面跟踪到子进程启动再说。

    7. 之后返回到do_fork()里,分配pid,值得一提的是这里用了hash_proc,使得根据pid得到相应TCB会更快。具体分配算法这里就不细讲了...
    8. 然后会加入proc_list。然后再wakeup_proc(),设置新创建的线程为RUNNABLE。
    9. 然后我们来看如何启动这个我们创建的子线程:
      1. 继续跟踪,我们发现do_fork()依次会返回到kernel_thread()再到proc_init(),最后到kern_init(),也就是内核初始化里面,再proc_init()之后有

            ide_init();                 // init ide devices
            swap_init();                // init swap
        
            clock_init();               // init clock interrupt
            intr_enable();              // enable irq interrupt
            
            cpu_idle();                 // run idle process

        这一系列初始化,而cpu_idle()看名字就知道是我们所关心的了,进去之后,

        void
        cpu_idle(void) {
            while (1) {
                if (current->need_resched) {
                    schedule();
                }
            }
        }

        我们发现他会循环判断当前进程/线程current->need_resched是否为真。在之前跟踪的过程中,在alloc_pro()函数里,我们初始化的子线程的need_resched设置的为0,而在proc_init()函数里:

            idleproc->pid = 0;
            idleproc->state = PROC_RUNNABLE;
            idleproc->kstack = (uintptr_t)bootstack;
            idleproc->need_resched = 1;
            set_proc_name(idleproc, "idle");
            nr_process ++;
            current = idleproc;

        我们可以看到idleproc也即空闲进程的need_resched初始是为1的,所current->need_resched为1,所以会在cpu_idle()里判断为真,进入schedule()函数。

      2. 而在schedule()函数里,首先会通过一个do-while loop在proc_list里找到第一个RUNNABLE的进程,如果找到了就break。事实上,循环退出有两种情况:一是找到了一个非空的RUNNABLE的进程,那么会接着往下走;还有一种情况是遍历完了,又回到队列头然后退出循环,此时指针指向的不是一个有效的可以运行的进程,所以它做了判断,将指针换成idleproc也就是空闲进程。这样一来如果队列里没有其他就绪进程的话,便会一直运行idleproc空闲进程。
      3. 如果找到了其他非current的就绪的进程,就会调用proc_run(),这就开始运行我们之前创建的init进程了。

        load_esp0(next->kstack + KSTACKSIZE);
        lcr3(next->cr3);
        switch_to(&(prev->context), &(next->context));

        这三条语句就是proc_run()的核心,第一条语句修改TSS任务状态栈,将TSS的ts_esp0(stack pointers and segment selectors)指向下一个进程的堆栈空间(不知这里和copy_thread()里设置proc->tf = (*trap_frame)(proc->kstack + KSTACKSIZE) - 1 有什么关联?暂时没有理解)。第二条语句修改cr3,即页表基址。第三条语句进行切换,这里便是IDE无法继续查看调用的地方了,而用gdb仍可以很方便地跟踪。

      4. 从进入switch.S到在新进程里运行init_main()的过程如下:

        switch_to () at kern/process/switch.S:6
        6           movl 4(%esp), %eax          # eax points to from
        (gdb) s
        7           popl 0(%eax)                # save eip !popl
        switch_to () at kern/process/switch.S:8
        8           movl %esp, 4(%eax)
        9           movl %ebx, 8(%eax)
        10          movl %ecx, 12(%eax)
        11          movl %edx, 16(%eax)
        12          movl %esi, 20(%eax)
        13          movl %edi, 24(%eax)
        14          movl %ebp, 28(%eax)
        17          movl 4(%esp), %eax          # not 8(%esp): popped return address already
        19          movl 28(%eax), %ebp
        switch_to () at kern/process/switch.S:20
        20          movl 24(%eax), %edi
        21          movl 20(%eax), %esi
        22          movl 16(%eax), %edx
        23          movl 12(%eax), %ecx
        24          movl 8(%eax), %ebx
        25          movl 4(%eax), %esp
        27          pushl 0(%eax)               # push eip
        switch_to () at kern/process/switch.S:29
        29          ret
        forkret () at kern/process/proc.c:193
        193     forkret(void) {
        forkret () at kern/process/proc.c:194
        194         forkrets(current->tf);
        forkrets () at kern/trap/trapentry.S:48
        48          movl 4(%esp), %esp
        (gdb) l
        43          iret
        44
        45      .globl forkrets
        46      forkrets:
        47          # set stack to this new process's trapframe
        48          movl 4(%esp), %esp
        49          jmp __trapret
        (gdb) s
        forkrets () at kern/trap/trapentry.S:49
        49          jmp __trapret
        (gdb) 
        __trapret () at kern/trap/trapentry.S:33
        33          popal
        (gdb) 
        __trapret () at kern/trap/trapentry.S:36
        36          popl %gs
        37          popl %fs
        38          popl %es
        39          popl %ds
        42          addl $0x8, %esp
        43          iret
        5           pushl %edx              # push arg
        6           call *%ebx              # call fn
        (gdb) s
        init_main (arg=<error reading variable: Unknown argument list address for `arg'.>)
            at kern/process/proc.c:359
        359     init_main(void *arg) {

        可以看到首先是我们讨论很多的两段代码,取esp+4(movl 4(%esp), %eax),然后保存现场,存当前一系列寄存器到第一个参数的内存空间里,然后取esp+8(movl 4(%esp), %eax),这里由于已经pop过了,所以只需继续+4,然后将第二个参数的内存空间里的值赋给一系列寄存器。事实上两个参数就是&(prev->context), &(next->context),保存了各自的上下文。

      5. 然后ret就是奇妙的地方了,按理说本来调用proc_run()返回也应该返回到这个函数里去,但是由于上下文切换,我们就返回到第二个参数也就是新进程里设置好的上下文中指定的地方去了。我们指定的哪呢?如果还记得的话,我们在copy_thread()里将context的eip变量设为了(uintptr_t)forkret,所以返回应该返回到这去。这个函数又会跳到trapentry.S文件里的forkrets,设置好中断栈帧,再跳到__trapret,进行一系列中断完成前的准备,包括清空栈帧,执行中断前的指令:

        42          addl $0x8, %esp
        (gdb) 
        43          iret
        (gdb) 
        5           pushl %edx              # push arg
        (gdb) 
        6           call *%ebx              # call fn

        这里将esp加8,执行中断返回iret,跳转到文件第5、6行,但是并不是原文件的5、6行...后来我发现是entry.S的5、6行,这里如何自动跳转的我并不明了。总之call *%ebx就进入了init_main(),就开始执行子进程的代码了。

      6. 当执行完之后,return 0,接着上面entry.S中call之后的代码运行,执行call do_exit,结束了整个程序,跟踪也结束了。

        (gdb) s
        kernel_thread_entry () at kern/process/entry.S:8
        8           pushl %eax              # save the return value of fn(arg)
        (gdb) 
        9           call do_exit            # call do_exit to terminate current thread
        (gdb) 
        do_exit (error_code=<error reading variable: Unknown argument list address for `error_code'.>)
            at kern/process/proc.c:353
        353     do_exit(int error_code) {
        (gdb) 
        do_exit (error_code=0) at kern/process/proc.c:354
        354         panic("process exit!!.\n");
        (gdb) 
  4. 所以整个流程就是这样,比较繁琐,大家可以就粗体字来一起讨论一下~欢迎回复指出我描述错误或者不明确的地方~
回答(2
    推荐问答
      Simple Empty
      暂无数据