为什么需要 COW(Copy-On-Write)?
典型的fork-exec模式:
1 2 3 4 5 6 7 8 9 // 大多数情况下的使用模式 if(fork() == 0) { // 子进程:立即执行新程序 exec("new_program", args); exit(0); } else { // 父进程继续 wait(0); }
80-90%的fork()后立即调用exec(),exec.c里有如下代码:
1 2 3 4 5 6 7 //替换进程 oldpagetable = p->pagetable; p->pagetable = pagetable; p->sz = sz; p->trapframe->epc = elf.entry; // initial program counter = ulib.c:start() p->trapframe->sp = sp; // initial stack pointer proc_freepagetable(oldpagetable, oldsz);
而proc_freepagetable调用了uvmfree,实现了内存的清除
1 2 3 4 5 6 7 proc_freepagetable // 1. 取消映射并释放用户内存页面 uvmunmap(pagetable, TRAMPOLINE, 1, 0); // 蹦床页面(不释放物理页) uvmunmap(pagetable, TRAPFRAME, 1, 0); // 陷阱帧(不释放物理页) // 2. 关键:释放用户空间的所有内存页面! uvmfree(pagetable, sz);
1 2 3 4 5 6 7 时间线: t0: 父进程运行(占用100MB内存) t1: 调用fork() → 复制100MB内存到子进程 t2: 子进程调用exec() → 丢弃刚复制的100MB内存 t3: 加载新程序 → 分配新内存 结果:100MB的复制操作完全浪费!
真正需要复制的情况: 父进程和子进程都需要修改数据
为了能够检测何时需要真正的“复制”,内核将父子进程的所有用户内存页(原本可写的页)在页表中的条目(PTE)都标记为 只读。之后,当父进程或子进程尝试向这些共享的页面写入数据时,由于页面被标记为只读,CPU 会产生一个页错误。内核的页错误处理程序会介入,识别出这个错误是由 COW 页面引起的(而不是真正的访问违规)。内核此时才真正行动: 分配一个新的物理页。将旧共享页的内容复制到新页中。修改发生写入错误的进程的页表,使其 PTE 指向这个新分配的页,并将权限重新设置为可写。这样,只有真正被写入的页面才会被复制,其他页面继续共享。
本实验最核心的部分在于计数,因为一个物理页现在可能被多个进程共享,你不能在第一个进程退出时就释放它,否则其他进程会访问到已释放的内存,导致崩溃。所以只有在计数为0的时候才能释放内存。
首先在底层通过修改 kalloc/kfree 引入引用计数机制 (kref)以支持多进程共享同一物理页;接着修改 uvmcopy ,使其在 fork 时不再复制内存,而是建立只读映射并打上 PTE_COW 标记;当进程尝试写入触发缺页异常(Page Fault)时,由 usertrap 捕获并调用核心函数 cow_alloc 完成新页分配、数据拷贝及权限恢复(变回可写);最后必须修改 copyout ,防止内核在执行系统调用(如 read)写用户内存时无视页表权限从而破坏共享数据的隔离性。
下面开始写代码:
在 kernel/riscv.h 中添加:
1 #define PTE_COW (1L << 8) // 使用保留位第8位
该标记位用来区分真正的只读页面和暂时只读的 COW 页面。在 fork() (uvmcopy) 时:我们将原本可写的页设为只读,并设置 PTE_COW = 1。在 trap() (写异常) 时:如果 PTE_W == 0 且 PTE_COW == 0:说明这是真的只读页 (情况 A),杀进程 。如果 PTE_W == 0 且 PTE_COW == 1:说明这是COW 页 (情况 B),执行拷贝逻辑 。
物理页引用计数部分:
freelist 是 xv6 内核用来管理 空闲物理内存页 的核心数据结构,利用了“空闲内存本身”来存储管理数据,从而实现了 0 内存开销 的管理。把下一个空闲内存的地址存在当前空闲内存里
完整的kalloc.c代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 // Physical memory allocator, for user processes, // kernel stacks, page-table pages, // and pipe buffers. Allocates whole 4096-byte pages. #include "types.h" #include "param.h" #include "memlayout.h" #include "spinlock.h" #include "riscv.h" #include "defs.h" void freerange(void *pa_start, void *pa_end); extern char end[]; // first address after kernel. // defined by kernel.ld. struct run { struct run *next; }; struct { struct spinlock lock; struct run *freelist; } kmem; struct { struct spinlock lock; int count[PHYSTOP / PGSIZE]; } kref; // 获取物理地址对应的索引 int pa_index(uint64 pa) { return (pa) / PGSIZE; } // 增加引用计数 (Fork 时调用) void kref_inc(void *pa) { acquire(&kref.lock); kref.count[pa_index((uint64)pa)]++; release(&kref.lock); } // 减少引用计数 (Free 时调用) // 返回减少后的值,如果为 0 表示需要真正释放 int kref_dec(void *pa) { int c; acquire(&kref.lock); c = --kref.count[pa_index((uint64)pa)]; release(&kref.lock); return c; } // 初始化引用计数 (kalloc 时调用) void kref_init(void *pa) { acquire(&kref.lock); kref.count[pa_index((uint64)pa)] = 1; release(&kref.lock); } void kinit() { initlock(&kmem.lock, "kmem"); initlock(&kref.lock, "kref"); // 初始化锁 freerange(end, (void*)PHYSTOP); } void freerange(void *pa_start, void *pa_end) { char *p; p = (char*)PGROUNDUP((uint64)pa_start); for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE) kfree(p); } // Free the page of physical memory pointed at by pa, // which normally should have been returned by a // call to kalloc(). (The exception is when // initializing the allocator; see kinit above.) void kfree(void *pa) { struct run *r; if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP) panic("kfree"); // === 修改开始 === // 引用计数减1,如果还有别人在用,直接返回,不释放内存 if(kref_dec(pa) > 0) return; // === 修改结束 === // 当计数为0时,真正的释放逻辑... memset(pa, 1, PGSIZE); r = (struct run*)pa; acquire(&kmem.lock); r->next = kmem.freelist; kmem.freelist = r; release(&kmem.lock); } void * kalloc(void) { struct run *r; acquire(&kmem.lock); r = kmem.freelist; if(r) kmem.freelist = r->next; release(&kmem.lock); if(r) { memset((char*)r, 5, PGSIZE); kref_init((void*)r); // === 新增:初始化引用计数为 1 === } return (void*)r; }
在 kernel/defs.h 中添加 kref_inc 的声明
修改 uvmcopy 实现浅拷贝:只复制页表项(指针),不复制物理内存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 // kernel/vm.c int uvmcopy(pagetable_t old, pagetable_t new, uint64 sz) { pte_t *pte; uint64 pa, i; uint flags; //遍历父进程(old)的虚拟地址空间。 for(i = 0; i < sz; i += PGSIZE){ if((pte = walk(old, i, 0)) == 0) panic("uvmcopy: pte should exist"); if((*pte & PTE_V) == 0) panic("uvmcopy: page not present"); //如果页面存在且有效,提取出它指向的物理地址 (pa) 和权限标志 (flags) pa = PTE2PA(*pte); flags = PTE_FLAGS(*pte); // === 核心逻辑 === // 如果页是可写的,则清除写权限,并标记为 COW if(flags & PTE_W) { // 必须清除父进程的写权限! *pte &= ~PTE_W; *pte |= PTE_COW; // 更新 flags 给子进程用 flags = PTE_FLAGS(*pte); } // 将父进程的物理页映射给子进程 (不分配新内存) // 权限为 (PTE_W 被清除, PTE_COW 被设置) if(mappages(new, i, PGSIZE, pa, flags) != 0){ goto err; } // 增加物理页的引用计数 kref_inc((void*)pa); // =============== } return 0; err: uvmunmap(new, 0, i / PGSIZE, 1); return -1; }
当进程尝试写入被我们标记为只读 (COW) 的页时,CPU 会产生缺页异常 (scause = 15)。我们需要在 kernel/trap.c 的 usertrap 中捕获它。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 // kernel/vm.c // 检查并处理 COW 页错误 // 返回 0 成功,-1 失败(比如内存不足) int cow_alloc(pagetable_t pagetable, uint64 va) { uint64 pa; pte_t *pte; uint flags; if(va >= MAXVA) return -1; // 1. 查找 PTE pte = walk(pagetable, va, 0); if(pte == 0 || (*pte & PTE_V) == 0 || (*pte & PTE_U) == 0) return -1; // 2. 检查是否是 COW 页 if((*pte & PTE_COW) == 0) return -1; // 不是 COW 页引起的写错误,那就是真正的非法访问 pa = PTE2PA(*pte); flags = PTE_FLAGS(*pte); // 3. 分配新内存 char *mem = kalloc(); if(mem == 0) return -1; // 内存不足需要杀进程 // 4. 拷贝旧数据 memmove(mem, (char*)pa, PGSIZE); // 5. 更新 PTE:指向新内存,允许写,清除 COW 标志 *pte = PA2PTE(mem) | ((flags | PTE_W) & ~PTE_COW); *pte |= PTE_V; // 确保有效 // 6. 旧物理页引用计数减 1 kfree((void*)pa); return 0; }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 // kernel/trap.c uint64 usertrap(void) { int which_dev = 0; if((r_sstatus() & SSTATUS_SPP) != 0) panic("usertrap: not from user mode"); w_stvec((uint64)kernelvec); struct proc *p = myproc(); // save user program counter. p->trapframe->epc = r_sepc(); if(r_scause() == 8){ // system call if(killed(p)) kexit(-1); p->trapframe->epc += 4; intr_on(); syscall(); } else if((which_dev = devintr()) != 0){ // ok } else if(r_scause() == 13 || r_scause() == 15) { // === Page Fault 处理逻辑 === // 13: Load Page Fault (读错误) -> 只能是 Lazy // 15: Store Page Fault (写错误) -> 可能是 COW 或 Lazy uint64 va = r_stval(); int cow_ret = -2; // 默认 -2 表示 "不是 COW" // 1. 如果是写异常 (15),优先尝试 COW if(r_scause() == 15) { cow_ret = cow_alloc(p->pagetable, va); } // 2. 根据 COW 的结果分流 if(cow_ret == 0) { // 成功:COW 处理完毕,物理页已分配且可写 } else if(cow_ret == -1) { // 失败:确认为 COW 页,但内存不足 (OOM) setkilled(p); } else { // cow_ret == -2 (不是 COW 页) 或者 scause == 13 (读异常) // === 转交给 Lazy Allocation (vmfault) === // 参数3: (r_scause() == 13) ? 1 : 0 表示是否为读操作 if(vmfault(p->pagetable, va, (r_scause() == 13) ? 1 : 0) != 0) { setkilled(p); } } } else { printf("usertrap(): unexpected scause 0x%lx pid=%d\n", r_scause(), p->pid); printf(" sepc=0x%lx stval=0x%lx\n", r_sepc(), r_stval()); setkilled(p); } if(killed(p)) kexit(-1); if(which_dev == 2) yield(); prepare_return(); uint64 satp = MAKE_SATP(p->pagetable); return satp; }
copyout 函数负责将数据从内核空间写入用户内存。在标准 xv6 实现中,它首先通过软件查找页表(walkaddr)获取目标虚拟地址对应的物理地址 。一旦拿到物理地址,内核就会利用其特权身份,直接调用 memmove 对物理内存进行写入。
关键问题在于: 这个写入过程直接操作物理地址,绕过了 CPU 对用户页表权限(如 PTE_W 只读位)的硬件检查。在 COW 机制下,父子进程共享同一个只读物理页。如果 copyout 不经检查直接写入该物理页,就会导致共享的物理页被就地修改。这就意味着,父进程并没有执行任何操作,却会发现自己的内存数据被子进程(通过内核)篡改了,从而严重破坏了进程间的内存隔离性。
父子进程共享物理页 Frame A(标记为 COW 只读)。子进程调用 read(),触发内核执行 copyout。
漏洞 :用户直接写 Frame A —-被 MMU 拦截(触发缺页异常,安全)。
内核 copyout 写 Frame A—- 直接操作物理地址—-无视只读标记—- 写入成功。
因此,Frame A 变了 ,父进程读 Frame A 读到了脏数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 // kernel/vm.c int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) { uint64 n, va0, pa0; pte_t *pte; while(len > 0){ va0 = PGROUNDDOWN(dstva); if(va0 >= MAXVA) return -1; // COW 处理逻辑 === // 1. 先查找 PTE pte = walk(pagetable, va0, 0); // 2. 如果是 COW 页面 (有效且有 COW 标志),则触发分配 if(pte && (*pte & PTE_V) && (*pte & PTE_COW)) { // 调用 cow_alloc 分配新物理页并映射为可写 // 如果内存不足分配失败,返回 -1 if(cow_alloc(pagetable, va0) < 0) return -1; } // 3. 获取物理地址 (此时如果是 COW 页,已经变成了新的可写页) pa0 = walkaddr(pagetable, va0); if(pa0 == 0) return -1; // 4. 再次获取 PTE 检查权限 pte = walk(pagetable, va0, 0); // 如果此时依然不可写 (说明是真的只读页,如代码段),则报错 if(pte == 0 || (*pte & PTE_W) == 0) return -1; n = PGSIZE - (dstva - va0); if(n > len) n = len; memmove((void *)(pa0 + (dstva - va0)), src, n); len -= n; src += n; dstva = va0 + PGSIZE; } return 0; }
记得在defs.h中添加int cow_alloc(pagetable_t, uint64);
然后测试:
1 2 3 4 5 6 7 8 $ cowtest simple: ok simple: ok three: ok three: ok three: ok file: ok forkfork: ok
lazy_alloc会报错,本人代码能力有限,尚未找到bug,望大佬在评论区指出