MIT6.828 Part B: Copy-on-Write Fork

本文深入探讨了写时拷贝(fork)机制的工作原理,包括子进程创建时的内存映射、用户级页错误处理及异常栈管理等内容。特别介绍了如何通过COW机制高效地实现进程复制,减少了不必要的内存开销。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

  子进程在被创建之后很可能立刻执行exec()了,之前做的一系列的拷贝就是无用功了。
  所以说,当创建一个新的子进程的时候,只需要拷贝父进程的内存映射(页表)就可以了,而且将父进程所有的内存映射页都标记为只读的,这样,当子进程或者父进程尝试去读的时候是安全的,而当尝试去写的时候,就会出发page fault,而在page fault处理例程中,单独将被写入的页(比如说栈)拷贝一份,修改掉发出写行为的进程的页表相应的映射就可以了。

User-level page fault handling
  1个用户级写时拷贝的fork函数需要在写保护页时触发page fault,所以我们第一步应该先规定或者确立一个page fault处理例程,每个进程需要向内核注册这个处理例程,只需要传递一个函数指针即可,sys_env_set_pgfault_upcall函数将当前进程的page fault处理例程设置为func指向的函数。
  Exercise 8:
  实现sys_env_set_pgfault_upcall函数。
  回答:

static int
sys_env_set_pgfault_upcall(envid_t envid, void *func)
{
        struct Env *env;

        if (envid2env(envid, &env, 1) < 0)
                return -E_BAD_ENV;
        env->env_pgfault_upcall = func;
        return 0;
}

Normal and Exception Stacks in User Environments
  在正常运行期间,用户进程运行在用户栈上,栈顶寄存器ESP指向USTACKTOP处,堆栈数据位于USTACKTOP-PGSIZE 与USTACKTOP-1之间的页。当在用户模式发生1个page fault时,内核将在专门处理page fault的用户异常栈上重新启动进程。
  而异常栈则是为了上面设置的异常处理例程设立的。当异常发生时,而且该用户进程注册了该异常的处理例程,那么就会转到异常栈上,运行异常处理例程。
  到目前位置出现了三个栈:
  [KSTACKTOP, KSTACKTOP-KSTKSIZE]
  内核态系统栈

  [UXSTACKTOP, UXSTACKTOP - PGSIZE]
  用户态错误处理栈

  [USTACKTOP, UTEXT]
  用户态运行栈

  内核态系统栈是运行内核相关程序的栈,在有中断被触发之后,CPU会将栈自动切换到内核栈上来,而内核栈的设置是在kern/trap.c的trap_init_percpu()中设置的。

void
trap_init_percpu(void)
{
        // Setup a TSS so that we get the right stack
        // when we trap to the kernel.
        thiscpu->cpu_ts.ts_esp0 = KSTACKTOP - cpunum() * (KSTKGAP + KSTKSIZE);
        thiscpu->cpu_ts.ts_ss0 = GD_KD;

        // Initialize the TSS slot of the gdt.
        gdt[(GD_TSS0 >> 3) + cpunum()] = SEG16(STS_T32A, (uint32_t) (&thiscpu->cpu_ts),
                                        sizeof(struct Taskstate) - 1, 0);
        gdt[(GD_TSS0 >> 3) + cpunum()].sd_s = 0;

        // Load the TSS selector (like other segment selectors, the
        // bottom three bits are special; we leave them 0)
        ltr(GD_TSS0 + sizeof(struct Segdesc) * cpunum());

        // Load the IDT
        lidt(&idt_pd);
}

  用户定义注册了自己的中断处理程序之后,相应的例程运行时的栈,整个过程如下:
  首先陷入到内核,栈位置从用户运行栈切换到内核栈,进入到trap中,进行中断处理分发,进入到page_fault_handler()
当确认是用户程序触发的page fault的时候(内核触发的直接panic了),为其在用户错误栈里分配一个UTrapframe的大小
把栈切换到用户错误栈,运行响应的用户中断处理程序中断处理程序可能会触发另外一个同类型的中断,这个时候就会产生递归式的处理。处理完成之后,返回到用户运行栈。
Invoking the user page fault handle
  可以将用户自己定义的用户处理进程当作是一次函数调用看待,当错误发生的时候,调用一个函数,但实际上还是当前这个进程,并没有发生变化。所以当切换到异常栈的时候,依然运行当前进程,但只是运行的中断处理函数,所以说此时的栈指针发生了变化,而且程序计数器eip也发生了变化,同时还需要知道的是引发错误的地址在哪。这些都是要在切换到异常栈的时候需要传递的信息。和之前从用户栈切换到内核栈一样,这里是通过在栈上构造结构体,传递指针完成的。
  这里新定义了一个结构体用来记录出现用户定义错误时候的信息Utrapframe:

struct UTrapframe {
        /* information about the fault */
        uint32_t utf_fault_va;  /* va for T_PGFLT, 0 otherwise */
        uint32_t utf_err;
        /* trap-time return state */
        struct PushRegs utf_regs;
        uintptr_t utf_eip;
        uint32_t utf_eflags;
        /* the trap-time stack to return to */
        uintptr_t utf_esp;
} __attribute__((packed));

  相比于UTrapframe,这里多了utf_fault_va,因为要记录触发错误的内存地址,同时还少了es,ds,ss等。因为从用户态栈切换到异常栈,或者从异常栈再切换回去,实际上都是一个用户进程,所以不涉及到段的切换,不用记录。在实际使用中,Trapframe是作为记录进程完整状态的结构体存在的,也作为函数参数进行传递;而UTrapframe只在处理用户定义错误的时候用。到
  整体上讲,当正常执行过程中发生了页错误,那么栈的切换是
  用户运行栈—>内核栈—>异常栈
  而如果在异常处理程序中发生了也错误,那么栈的切换是
  异常栈—>内核栈—>异常栈
  Exercise 9:
  实现page_fault_handler函数来分发page fault到用户模式的处理函数。
  回答:
  如果当前已经在用户错误栈上了,那么需要留出4个字节,否则不需要,具体和跳转机制有关系。简单说就是在当前的错误栈顶的位置向下留出保存UTrapframe的空间,然后将tf中的参数复制过来。修改当前进程的程序计数器和栈指针,然后重启这个进程,此时就会在用户错误栈上运行中断处理程序了。当然,中断处理程序运行结束之后,需要再回到用户运行栈中,这个就是异常处理程序需要做的了。

void
page_fault_handler(struct Trapframe *tf)
{
        uint32_t fault_va;

        // Read processor's CR2 register to find the faulting address
        fault_va = rcr2();

        // Handle kernel-mode page faults.
        if (tf->tf_cs == GD_KT)
                panic("page_fault in kernel mode, fault address: %d\n", fault_va);

        struct UTrapframe *utf;

        if (curenv->env_pgfault_upcall) {
                if (UXSTACKTOP - PGSIZE <= tf->tf_esp && tf->tf_esp <= UXSTACKTOP - 1)
                        utf = (struct UTrapframe *)(tf->tf_esp - sizeof(struct UTrapframe) - 4);
                else
                        utf = (struct UTrapframe *)(UXSTACKTOP - sizeof(struct UTrapframe));
                user_mem_assert(curenv, (void *)utf, sizeof(struct UTrapframe), PTE_U | PTE_W);

                utf->utf_fault_va = fault_va;
                utf->utf_err = tf->tf_trapno;
                utf->utf_eip = tf->tf_eip;
                utf->utf_eflags = tf->tf_eflags;
                utf->utf_esp = tf->tf_esp;
                utf->utf_regs = tf->tf_regs;
                tf->tf_eip = (uint32_t)curenv->env_pgfault_upcall;
                tf->tf_esp = (uint32_t)utf;
                env_run(curenv);
        }

        // Destroy the environment that caused the fault.
        cprintf("[%08x] user fault va %08x ip %08x\n",
                curenv->env_id, fault_va, tf->tf_eip);
        print_trapframe(tf);
        env_destroy(curenv);
}

  如果异常栈发生了overflow怎么办?看一下memlayout.h就知道了。用户异常栈就一页的大小,一旦溢出,访问的就是内核都没有访问权限的空间,会发生内核空间中的page fault,此时会直接panic,不会造成更严重的后果。
  
User-mode Page Fault Entrypoint
  接下来,就需要汇编实现功能:当从用户定义的处理函数返回之后,如何从用户错误栈直接返回到用户运行栈。
  Exercise 10:
  实现在lib/pfentry.S中的_pgfault_upcall调用。
  回答:
  _pgfault_upcall是所有用户页错误处理程序的入口,在这里调用用户自定义的处理程序,并在处理完成后,从错误栈中保存的UTrapframe中恢复相应信息,然后跳回到发生错误之前的指令,恢复原来的进程运行。

.text
.globl _pgfault_upcall
_pgfault_upcall:
        // Call the C page fault handler.
        pushl %esp                      // function argument: pointer to UTF
        movl _pgfault_handler, %eax
        call *%eax
        addl $4, %esp                   // pop function argument

        movl 48(%esp), %ebp
        subl $4, %ebp
        movl %ebp, 48(%esp)
        movl 40(%esp), %eax
        movl %eax, (%ebp)

        // Restore the trap-time registers.  After you do this, you can no longer modify any general-purpose registers.
        addl $8, %esp
        popal

        // Restore eflags from the stack.  After you do this, you can
        // no longer use arithmetic operations or anything else that
        // modifies eflags.
        addl $4, %esp
        popfl

        // Switch back to the adjusted trap-time stack.

        popl %esp

        // Return to re-execute the instruction that faulted.
        ret

  分析:
  重点是调用_pgfault_handler返回时的操作,此时的异常栈结构如下:
  这里写图片描述
  这里trap-time esp上的空间有1个4字节的保留空间,是做为中断递归的情形。
  然后将栈中的trap-time esp取出减去4,再存回栈中。此时如果是中断递归中,esp-4即是保留的4字节地址;如果不是则是用户运行栈的栈顶。
  再将原来出错程序的EIP(即trap-time eip)取出放入保留的4字节,以便后来恢复运行。此时的异常栈布局如下:
  这里写图片描述
  紧接着恢复通用寄存器和EFLAG标志寄存器,此时的异常栈结构如下:
  这里写图片描述
  然后pop esp切换为原来出错程序的运行栈,最后使用ret返回出错程序。
  
  Exercise 11:
  实现C库用户态的page fault处理函数。
  回答:
  进程在运行前注册自己的页错误处理程序,重点是申请用户异常栈空间,最后添加上系统调用号。

void
set_pgfault_handler(void (*handler)(struct UTrapframe *utf))
{
        int r;

        if (_pgfault_handler == 0) {
                // First time through!
                if ((r = sys_page_alloc(thisenv->env_id, (void *)(UXSTACKTOP - PGSIZE), PTE_P | PTE_W | PTE_U)) < 0)
                        panic("set_pgfault_handler: %e", r);
                sys_env_set_pgfault_upcall(thisenv->env_id, _pgfault_upcall);

        }

        // Save handler pointer for assembly to call.
        _pgfault_handler = handler;
}

Implementing Copy-on-Write Fork
  接下来就是最重要的部分:实现copy-on-write fork。
  与之前的dumbfork不同,fork出一个子进程之后,首先要进行的就是将父进程的页表的全部映射拷贝到子进程的地址空间中去。这个时候物理页会被两个进程同时映射,但是在写的时候是应该隔离的。采取的方法是在子进程映射的时候,将父进程空间中所有可以写的页表的部分全部标记为可读且COW。而当父进程或者子进程任意一个发生了写的时候,因为页表现在都是不可写的,所以会触发异常,进入到我们设定的page fault处理例程,当检测到是对COW页的写操作的情况下,就可以将要写入的页的内容全部拷贝一份,重新映射。
  Exercise 12:
  实现在lib/fork.c的fork,duppage和pgfault。
  回答:
  首先是pgfault处理page fault时的写时拷贝。

static void
pgfault(struct UTrapframe *utf)
{
        int r;
        void *addr = (void *) utf->utf_fault_va;
        uint32_t err = utf->utf_err;

        if ((err & FEC_WR) == 0 || (uvpt[PGNUM(addr)] & PTE_COW) == 0)
                panic("pgfault: it's not writable or attempt to access a non-cow page!");
        // Allocate a new page, map it at a temporary location (PFTEMP),
        // copy the data from the old page to the new page, then move the new
        // page to the old page's address.

        envid_t envid = sys_getenvid();
        if ((r = sys_page_alloc(envid, (void *)PFTEMP, PTE_P | PTE_W | PTE_U)) < 0)
                panic("pgfault: page allocation failed %e", r);

        addr = ROUNDDOWN(addr, PGSIZE);
        memmove(PFTEMP, addr, PGSIZE);
        if ((r = sys_page_unmap(envid, addr)) < 0)
                panic("pgfault: page unmap failed %e", r);
        if ((r = sys_page_map(envid, PFTEMP, envid, addr, PTE_P | PTE_W |PTE_U)) < 0)
                panic("pgfault: page map failed %e", r);
        if ((r = sys_page_unmap(envid, PFTEMP)) < 0)
                panic("pgfault: page unmap failed %e", r);
}

  在pgfault函数中先判断是否页错误是由写时拷贝造成的,如果不是则panic。借用了一个一定不会被用到的位置PFTEMP,专门用来发生page fault的时候拷贝内容用的。先解除addr原先的页映射关系,然后将addr映射到PFTEMP映射的页,最后解除PFTEMP的页映射关系。
  接下来是duppage函数,负责进行COW方式的页复制,将当前进程的第pn页对应的物理页的映射到envid的第pn页上去,同时将这一页都标记为COW。

static int
duppage(envid_t envid, unsigned pn)
{
        int r;

        void *addr;
        pte_t pte;
        int perm;

        addr = (void *)((uint32_t)pn * PGSIZE);
        pte = uvpt[pn];
        perm = PTE_P | PTE_U;
        if ((pte & PTE_W) || (pte & PTE_COW))
                perm |= PTE_COW;
        if ((r = sys_page_map(thisenv->env_id, addr, envid, addr, perm)) < 0) {
                panic("duppage: page remapping failed %e", r);
                return r;
        }
        if (perm & PTE_COW) {
                if ((r = sys_page_map(thisenv->env_id, addr, thisenv->env_id, addr, perm)) < 0) {
                        panic("duppage: page remapping failed %e", r);
                        return r;
                }
        }

        return 0;
}

  最后是fork函数,将页映射拷贝过去,这里需要考虑的地址范围就是从UTEXT到UXSTACKTOP为止,而在此之上的范围因为都是相同的,在env_alloc的时候已经设置好了。

envid_t
fork(void)
{
        uint32_t addr;
        int i, j, pn, r;
        extern void _pgfault_upcall(void);
        if ((envid = sys_exofork()) < 0) {
                panic("sys_exofork failed: %e", envid);
                return envid;
        }
        if (envid == 0) {
                thisenv = &envs[ENVX(sys_getenvid())];
                return 0;
        }

        for (i = PDX(UTEXT); i < PDX(UXSTACKTOP); i++) {
                if (uvpd[i] & PTE_P) {
                        for (j = 0; j < NPTENTRIES; j++) {
                                pn = PGNUM(PGADDR(i, j, 0));
                                if (pn == PGNUM(UXSTACKTOP - PGSIZE))
                                        break;
                                if (uvpt[pn] & PTE_P)
                                        duppage(envid, pn);
                        }
                }

        }

        if ((r = sys_page_alloc(envid, (void *)(UXSTACKTOP - PGSIZE), PTE_P | PTE_U | PTE_W)) < 0) {
                panic("fork: page alloc failed %e", r);
                return r;
        }
        if ((r = sys_page_map(envid, (void *)(UXSTACKTOP - PGSIZE), thisenv->env_id, PFTEMP, PTE_P | PTE_U | PTE_W)) < 0) {
                panic("fork: page map failed %e", r);
                return r;
        }
        memmove((void *)(UXSTACKTOP - PGSIZE), PFTEMP, PGSIZE);
        if ((r = sys_page_unmap(thisenv->env_id, PFTEMP)) < 0) {
                panic("fork: page unmap failed %e", r);
                return r;
        }
        sys_env_set_pgfault_upcall(envid, _pgfault_upcall);
        if ((r = sys_env_set_status(envid, ENV_RUNNABLE)) < 0) {
                panic("fork: set child env status failed %e", r);
                return r;
        }

        return envid;
}

  首先需要为父进程设定错误处理例程。这里调用set_pgfault_handler函数是因为当前并不知道父进程是否已经建立了异常栈,没有的话就会建立一个,而sys_env_set_pgfault_upcall则不会建立异常栈。
  调用sys_exofork准备出一个和父进程状态相同的子进程,状态暂时设置为ENV_NOT_RUNNABLE。然后进行拷贝映射的部分,在当前进程的页表中所有标记为PTE_P的页的映射都需要拷贝到子进程空间中去。但是有一个例外,是必须要新申请一页来拷贝内容的,就是用户异常栈。因为copy-on-write就是依靠用户异常栈实现的,所以说这个栈要在fork完成的时候每个进程都有一个,要硬拷贝过来。
  主要流程就是:
  1、申请新的物理页,映射到子进程的(UXSTACKTOP-PGSIZE)位置上去。
  2、父进程的PFTEMP位置也映射到子进程新申请的物理页上去,这样父进程也可以访问这一页。
  3、在父进程空间中,将用户错误栈全部拷贝到子进程的错误栈上去,也就是刚刚申请的那一页。
  4、然后父进程解除对PFTEMP的映射。
  5、最后把子进程的状态设置为可运行。
  至此,Lab4的part B的写时拷贝就完成了。
  

### 回答1: 在xv6中,copy-on-write fork是一种优化技术,它可以在子进程创建时避免不必要的内存复制。具体来说,当父进程调用fork()创建子进程时,子进程会共享父进程的内存页表,而不是复制一份父进程的内存。只有当子进程尝试修改共享的内存时,才会发生实际的复制操作。这种技术可以减少内存使用和复制时间,提高系统性能。 ### 回答2: xv6是一个操作系统教学项目,这是一个现代化风格的UNIX第六版。copy-on-write fork是xv6中实现的一种机制,它与fork系统调用有关。这种机制可减少在进行进程复制时所涉及的空间和时间开销,从而增加操作系统的效率。 在fork系统调用中,操作系统会复制原始进程,创建一个独立的进程。传统方法是,操作系统会将原有进程的内存空间全部复制一份给新进程,并在新进程中对地址进行修正。这样做会消耗大量的空间和时间,尤其是当进程较大时,复制整个内存空间会非常耗时。 copy-on-write fork的实现与传统方法不同。当原始进程需要创建新进程时,操作系统会将进程的内存空间标记为只读状态,并保留原内存页的映射关系。这样,当进程尝试写入内存时,操作系统将会产生一个缺页异常。在此时,操作系统会创建一个新页,将原内存页的内容复制到新页中,并在新页上进行写入操作。这样可以减少空间和时间开销,因为新页仅在需要写入时被复制,而不是在进程创建时。 copy-on-write fork有许多优点。首先,这种机制使系统更高效。使用copy-on-write fork可以显著降低进程复制的时间和空间开销。其次,这种机制还可以提高系统的可扩展性。当进程需要更多内存时,操作系统会重新映射新的内存页,而不是将整个进程复制一次。因此,系统可以更轻松地扩展。 总之,copy-on-write fork是xv6中非常有用的一个机制。它可以减少进程复制所需的时间和空间开销,从而提高操作系统的效率和可扩展性。 ### 回答3: 在操作系统课程xv6中,实现了一种名为“copy-on-write fork”的操作,这种操作可以让父进程和子进程在初始时共享相同的物理内存。当父进程或子进程试图修改内存时,内存页会被复制并分配新的物理内存,以避免父进程和子进程之间的竞争条件。 这种“copy-on-write”技术可以减少系统中的内存浪费,并且在分配内存时减少了复制操作,从而提高了系统的性能。在实现中,当父进程调用fork()创建一个新的子进程时,子进程将直接引用父进程的地址空间。父进程和子进程都共享相同的物理内存,但是它们各自有自己的页目录和页表来管理地址空间和虚拟内存。 当父进程或子进程尝试读取数据时,它们可以访问共享的物理内存。然而,当父进程或子进程试图修改数据时,操作系统会将所涉及的内存页复制到另一个物理内存地址,并使涉及的进程引用新的物理内存地址。这样,父进程和子进程将各自拥有自己的数据副本,一个进程修改数据不会影响另一个进程。 这种技术在许多操作系统中都有广泛应用,因为它可以提供更高效的内存管理和更好的性能。实现“copy-on-writefork操作在操作系统课程中具有教育意义,因为它可以让学生更深入了解xv6的内部机制和操作系统的基本理论。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值