MIT 6.S081 lab8:mmap | 内存映射文件

MIT 6.S081 Lab: mmap

mmap 是操作系统中特别重要的一个特性。它允许我们将文件映射到进程的虚拟地址空间,使得我们可以像操作数组一样操作文件。在本次 Lab 中,我们需要在 xv6 中实现一个简化版的 mmap 系统调用。

核心难点在于:懒加载(Lazy Allocation) 的实现以及 VMA(Virtual Memory Area) 的管理。


1. 实验目标

实现 mmapmunmap: - mmap:不立即分配物理内存,只在进程的 proc 结构体中记录一段虚拟地址区间(VMA)。 - Page Fault:当访问映射地址触发缺页异常时,再分配物理内存并将文件内容读入。 - munmap:解除映射。如果是 MAP_SHARED,需要将修改过的数据写回磁盘。


2. 核心数据结构:VMA

xv6 默认没有 VMA 的概念。我们需要在 kernel/proc.h 中定义它。每个进程可以拥有多个映射区,实验要求至少支持 16 个。

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
// kernel/proc.h

struct vma {
int valid; // 槽位是否有效
uint64 addr; // 映射起始地址
int length; // 映射长度
int prot; // 权限 (PROT_READ, PROT_WRITE)
int flags; // 标志 (MAP_SHARED, MAP_PRIVATE)
struct file *f; // 映射的文件指针
int offset; // 文件偏移
};

struct proc {
// ... 其他字段
struct vma vma[16]; // VMA 数组
};

## 3. 实现系统调用

### 3.1 sys_mmap 系统调用

该函数的主要任务是:参数校验 -> 寻找空闲 VMA -> 增加文件引用计数。
```C
// kernel/sysfile.c

uint64
sys_mmap(void)
{
uint64 addr;
int length, prot, flags, fd, offset;
struct file *f;
struct proc *p = myproc();

if(argaddr(0, &addr) < 0 || argint(1, &length) < 0 || argint(2, &prot) < 0 ||
argint(3, &flags) < 0 || argfd(4, &fd, &f) < 0 || argint(5, &offset) < 0)
return -1;

// 权限检查:文件必须可读;如果是写共享,文件必须可写
if(!f->readable && (prot & PROT_READ)) return -1;
if(!f->writable && (prot & PROT_WRITE) && (flags & MAP_SHARED)) return -1;

// 寻找可用 VMA
struct vma *v = 0;
for(int i = 0; i < 16; i++) {
if(p->vma[i].valid == 0) {
v = &p->vma[i];
break;
}
}
if(!v) return -1;

v->valid = 1;
v->addr = p->sz; // 从堆顶开始分配
v->length = length;
v->prot = prot;
v->flags = flags;
v->f = filedup(f); // 重要:增加文件引用计数
v->offset = offset;

p->sz += PGROUNDUP(length); // 更新进程空间大小
return v->addr;
}

3.2 Usertrap (缺页处理)

当用户尝试读写 mmap 的地址时,硬件由于找不到页表项会触发 scause == 1315。此时我们需要在 kernel/trap.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
// kernel/trap.c -> usertrap()

else if(r_scause() == 13 || r_scause() == 15) {
uint64 va = r_stval();
if(va >= p->sz || va < p->trapframe->sp) { // 基本越界检查
p->killed = 1;
} else {
// 查找该地址属于哪个 VMA
struct vma *v = 0;
for(int i = 0; i < 16; i++) {
if(p->vma[i].valid && va >= p->vma[i].addr && va < p->vma[i].addr + p->vma[i].length) {
v = &p->vma[i];
break;
}
}

if(v) {
uint64 pa = (uint64)kalloc();
if(pa == 0) p->killed = 1;
else {
memset((void*)pa, 0, PGSIZE);
// 读取文件到物理内存
ilock(v->f->ip);
readi(v->f->ip, 0, pa, PGROUNDDOWN(va) - v->addr, PGSIZE);
iunlock(v->f->ip);

// 构建页表映射
int pte_flags = PTE_U | ((v->prot & PROT_READ) ? PTE_R : 0) | ((v->prot & PROT_WRITE) ? PTE_W : 0);
if(mappages(p->pagetable, PGROUNDDOWN(va), PGSIZE, pa, pte_flags) != 0) {
kfree((void*)pa);
p->killed = 1;
}
}
} else {
p->killed = 1;
}
}
}

3.3 sys_munmap

解除映射时,如果设置了 MAP_SHARED,必须利用 filewrite 将脏数据同步回磁盘。

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
// kernel/sysfile.c

uint64
sys_munmap(void)
{
uint64 addr;
int length;
if(argaddr(0, &addr) < 0 || argint(1, &length) < 0) return -1;

struct proc *p = myproc();
struct vma *v = 0;
// 查找对应的 VMA (略) ...

if(v->flags & MAP_SHARED && (v->prot & PROT_WRITE)) {
// 写回磁盘逻辑:需遍历地址区间,检查每页是否已映射(PTE_V)
// 然后调用 filewrite(v->f, addr, length);
}

// 解除映射并释放物理页
uvmunmap(p->pagetable, addr, PGROUNDUP(length)/PGSIZE, 1);

// 如果全部取消映射,释放 VMA 槽位
// v->valid = 0; fileclose(v->f);
return 0;
}

3.4 适配 fork 中的 VMA 复制

fork() 时,子进程需要继承父进程的所有映射关系。由于我们实现了懒加载,子进程不需要立即复制父进程的物理内存页,只需要将父进程的 vma 结构体数组完整拷贝一份。

注意: 必须调用 filedup(v->f),因为子进程现在也拥有了对该映射文件的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// kernel/proc.c -> fork()

int
fork(void)
{
// ... 前面是分配 PID 和页表的逻辑 ...

// 复制 VMA 结构
for(int i = 0; i < 16; i++) {
if(p->vma[i].valid) {
// 直接拷贝 VMA 结构体内容
np->vma[i] = p->vma[i];
// 关键:增加文件引用计数,防止父进程 close 后文件被销毁
filedup(np->vma[i].f);
}
}

// ... 后面是设置返回值的逻辑 ...
return pid;
}

3.5 exit 中自动卸载

果用户程序在调用 munmap 之前就退出了,内核必须负责清理该进程占用的所有 VMA。这包括:

  • 写回脏数据:如果是 MAP_SHARED,同步回磁盘。

  • 释放物理页:解除页表映射。

  • 关闭文件:释放文件引用。

为了复用代码,我们在 kernel/sysfile.c 中封装一个 vma_unmap 函数,供 sys_munmapexit 共同调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// kernel/sysfile.c (或者新建一个位置)

void
vma_unmap(struct proc *p, struct vma *v, uint64 addr, int sz)
{
// 1. 如果是共享映射且可写,写回磁盘
if((v->flags & MAP_SHARED) && (v->prot & PROT_WRITE)) {
// 只有已经分配了物理页的地址才需要写回
// 这里简单起见直接调用 filewrite,filewrite 内部会处理
filewrite(v->f, addr, sz);
}

// 2. 解除页表映射并释放物理内存 (最后一个参数 1 表示释放物理页)
// 注意:uvmunmap 会检查 PTE_V,如果还没分配物理页,它会跳过或报错
// 在实验中,你可能需要修改 uvmunmap 允许它跳过未映射的页
uvmunmap(p->pagetable, addr, PGROUNDUP(sz) / PGSIZE, 1);

// 3. 维护 VMA 状态
// 如果是全额卸载,关闭文件并清空 VMA
if(addr == v->addr && sz == v->length) {
fileclose(v->f);
v->valid = 0;
}
}

以及对应的 exit 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// kernel/proc.c -> exit()

void
exit(int status)
{
struct proc *p = myproc();

// 在销毁页表之前,清理所有已分配的 VMA
for(int i = 0; i < 16; i++) {
if(p->vma[i].valid) {
vma_unmap(p, &p->vma[i], p->vma[i].addr, p->vma[i].length);
}
}

// ... 后面是原有的进程资源释放逻辑 ...
}

3.6 修改 uvmunmap:

xv6 原生的 uvmunmap 在发现某个虚拟地址没有对应的有效 PTE(页表项)时,会触发 panic。但在 mmap 的懒加载场景下,用户可能映射了 10 页但只访问了 1 页,此时卸载整个区域时,有 9 页是没被映射的。

需要修改 kernel/vm.c 中的 uvmunmap

1
2
3
4
5
6
7
8
9
10
11
12
// kernel/vm.c -> uvmunmap()

void
uvmunmap(pagetable_t pagetable, uint64 va, uint64 npages, int do_free)
{
// ... 循环逻辑 ...
if((pte = walk(pagetable, a, 0)) == 0)
continue; // 修改点:不再 panic,直接跳过
if((*pte & PTE_V) == 0)
continue; // 修改点:不再 panic,直接跳过
// ... 释放物理页逻辑 ...
}


MIT 6.S081 lab8:mmap | 内存映射文件
https://acmicpc.top/2024/03/23/MIT-6.S081-lab08/
作者
江欣婷
发布于
2024年3月23日
许可协议