为什么需要 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 == 0PTE_COW == 0:说明这是真的只读页(情况 A),杀进程。如果 PTE_W == 0PTE_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 的声明

1
void            kref_inc(void*);

修改 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.cusertrap 中捕获它。

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
1
$ usertests -q

lazy_alloc会报错,本人代码能力有限,尚未找到bug,望大佬在评论区指出