xv6 fork的实现 | Blurred code

xv6 fork的实现

2020/11/23

Updated:2020/11/23

Categories: xv6 Linux

Posix中规定的fork的签名很简单,这个函数的作用是复制一个新的进程,子进程和父进程被复制出来是一样的,Linux的实现还会采用cow复制,也就是共享一份物理地址空间,直到有写入发生的时候才实际上复制被污染的页。xv6的一个lab也是要实现cow复制。这个函数最有意思的一个特点是,该函数会返回两个值,对父进程返回子进程的pid,而对子进程返回0,所以常见的fork的编程范式是

//pid_t fork(void);
pid_t pid = fork();
if(pid) // parent process
{
    //do something in parent process
} else
{
    //do something in child process
}

简单的函数签名下蕴含了相当深入的知识———进程调度,不妨问一个问题:为什么fork函数能够返回"两个值”?

xv6的进程调度

进程调度的时机

在xv6中,进程调度由时间中断(timer interrupt)控制,时间中断发生后,内核在usertrap函数中捕获该中断,然后跳转到yield函数,yield的函数主要作用是把进程的状态从running设置到runable,代表进程让出此cpu,并且跳跃到sched函数,sched函数是实际进行上下文切换的函数。

//usertrap
if(which_dev == 2) yield(); //如果是时间中断,yield

//proc.c
void yield(void)
{
    //...
    p->state = RUNNABLE;
    sched();
    //...
}

上下文切换

所谓上下文切换其实在xv6中我们已经见过很多了,从用户态进入到内核态需要保护所有用户态的寄存器,从内核态恢复到用户态需要恢复所有的寄存器。进程切换也是一样,从A进程切换到B进程需要保护A进程的现场,从其他进程切换回A进程的时候需要恢复A进程的寄存器,并且从切换走的地方继续执行。

在xv6中,保护进程切换寄存器的位置位于内核的进程结构体proc中,有一个专门的proc->context结构。

在xv6中,内核初始化的时候会给每一个核心绑定一个程序scheduler,该程序的内容很简单,死循环所有的进程列表,找到runnable的进程,切换过去。 由此xv6的进程切换可以实现成如下,

假设只有A,B两个进程,单个核心,当前A进程在运行

fork的实现

在xv6中的fork的实现是

int fork(void)
{
    int child_pid = allocpid();
    // copy memory page table...
    // copy fp and other properties
    child_process->state = RUNNABLE;
    child_process->trapframe->a0 = 0;//return value is 0 for child_process fork()
    return child_pid;
}

而父进程的a0寄存器是

void syscall(void)
{
    //...
    p->trapframe->a0 = fork(); //return value for parent is child_pid
    //...
}

fork的实现并没有违反c语言的基本规律,在调用fork()函数的父进程中确实返回子进程的pid,那么子进程的返回值0是从哪里冒出来的呢?玄机在于child_process->state = RUNNABLE。这里把创建出来的子进程的状态设置成可以被调度的子进程,可以理解,因为创建子进程本来就是为了运行。

玄妙之处在于,在创建子进程的proc结构体的时候,其p->context.ra,也就是context结构体中的一个寄存器被设置为forkret函数。forkret函数的唯一作用调用usertrapret从内核态返回用户态。

当子进程被创建的时候,它把context的返回地址设置为forkret函数。而当scheduler切换到这个进程的时候,自然会跳转到forkret函数,而forkret函数什么也没有做,直接从内核态返回到了用户态。 我们之前在fork函数中修改了子进程的a0寄存器,也就是用户态看到的返回值。由此,父进程看到的返回值是pid,子进程看到的返回值是0。

由此我们可以回答为什么fork进程会返回"两个值”,因为在调用这个函数的会触发系统调用,进入内核以后进程分裂成了两个,并且从内核态返回的时候父进程和子进程携带不同的返回值。