MIT 6.s081 Lab10: mmap

实现原理

mmap

这次 lab 是要给 xv6 添加 mmap 和 munmap 系统调用。

mmap 的好处在于可以将一个文件直接映射到进程的地址空间中,从而避免了不必要的数据复制,提高了文件操作的效率。与使用 read 和 write 系统调用不同,mmap 操作不需要将文件数据从内核缓冲区复制到用户缓冲区,也不需要将用户缓冲区中的数据复制回内核缓冲区。相反,它通过映射文件的方式,将文件数据直接映射到了进程的地址空间中,因而可以提高文件操作的效率。

同时 mmap 也避免了由于使用 read 和 write 系统调用而造成的在用户空间和内核空间的上下文切换,节省了系统调用的开销。

系统调用声明

mmap 系统调用的函数声明为:

1
void *mmap(void *addr, uint64 len, int prot, int flags, int fd, uint64 offset);
  • addr 为文件在用户地址空间的起始地址,一般传入 0,由内核设置;
  • len 为要映射的字节数量;
  • prot 为权限字段,指明该文件是可读(PROT_READ)、可写(PROT_WRITE)或可执行(PROT_EXEC)的;
  • flags 为标记位,标记映射的模式,MAP_SHARED 模式标识在 munmap 的时候需要把改动写回磁盘,MAP_PRIVATE 模式则不需要;
  • fd 是文件的描述符;
  • offset 为文件起始位置到开始映射的位置的偏移量。

munmap 系统调用的函数声明为:

1
int munmap(void *addr, uint64 len);
  • addr 为从哪里开始解除映射;
  • len 为解除映射的字节数。

代码实现

添加 mmap 和 munmap 系统调用的过程这里就省略了。直接来看实现。

首先,为了能够让用户进程知道关于文件映射的信息,需要在 proc 结构体记录下。新增 vma 结构体,来存储文件映射的相关信息:

1
2
3
4
5
6
7
8
9
10
struct vma {
int valid; // 该 vma 是否有效
uint64 addr; // 文件在进程地址空间中的起始地址
uint64 len; // 文件映射了多少字节
int prot; // 文件权限
int flags; // 映射模式标识
int fd; // 文件标识符
struct file *file; // 指向对应的文件结构体
uint64 offset; // 文件映射的偏移
};

并且在 proc 结构体中添加一个 vma 数组,根据 hint,大小为 16 即可:

1
struct vma vmatable[NVMA]; // NVMA 为定义在 kernel/param.h 中的宏

接着在 kernel/sysfile.c 中实现 sys_mmap 函数。大致流程如下:

  1. 接收 mmap 系统调用传递的参数;
  2. 判断参数是否可以满足映射条件:
    1. 只读文件在 MAP_PRIVATE 模式下,是可写的;
    2. 只读文件在 MAP_SHARED 模式下,是不可写的。
  1. 从进程中记录的 vma 中找出一个空闲的 vma,并在进程的 heap 中找出一段可用的内存,将这段内存的起始地址作为系统调用的返回值。注意在这里是不进行内存分配的,只是标记,跟 lazy alloction 是一样的,这样可以让映射比内存空间更大的文件成为可能。为了和进程正在使用的地址空间区分开,选择从 heap 的高位置开始向下扩展来映射文件,即从 TRAPFRAME 开始。
  2. 设置 vma 的值;
  3. filedup 对应文件;

mmap should increase the file’s reference count so that the structure doesn’t disappear when the file is closed.

close 系统调用关闭是的一个打开的文件描述符,只是减少该文件的打开引用数,在这里增加一次引用后,就算调用了 close 也不会影响到对已经映射的内存。

  1. 返回映射的起始地址;

实现如下:

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
uint64
sys_mmap(void) {

uint64 len, offset;

int prot, flags, fd;

if(argaddr(1, &len) < 0 || argint(2, &prot) < 0 || argint(3, &flags) < 0 || argint(4, &fd) < 0 || argaddr(5, &offset) < 0) {
return -1;
}

struct proc *p = myproc();
struct file *file = p->ofile[fd];

if ((file->readable && !file->writable) && (prot & PROT_WRITE) && (flags & MAP_SHARED)) {
return -1;
}

struct vma *vma = 0;

int found = 0;
uint64 addr = TRAPFRAME;

for(int i = 0; i < 16; i++) {
if(!p->vmatable[i].valid && !found) {
found = 1;
vma = &p->vmatable[i];
} else if (p->vmatable[i].valid && p->vmatable[i].addr < addr) {
addr = p->vmatable[i].addr;
}
}

if (!found) {
return -1;
}

addr = addr - len;

vma->valid = 1;
vma->fd = fd;
vma->file = file;
vma->len = len;
vma->offset = offset;
vma->prot = prot;
vma->flags = flags;
vma->addr = addr;

filedup(vma->file);

return addr;
}

完成这一步后,在用户程序中调用 mmap 就会返回一个正确的映射后的起始地址了,但是当进行访问的时候,由于并没有分配内存,就会触发 page fault,所以跟 lazy alloction 一样,在 kernel/trap.c#usertrap 中处理 page fault。

1
2
3
4
5
6
7
8
9
10
// ...
} else if((which_dev = devintr()) != 0){
// ok
} else if(r_scause() == 13 || r_scause() == 15) {
uint64 va = r_stval();
if (mmaphandler(va) == -1) {
p->killed = 1;
}
} else {
// ...

kernel/vm.c#mmaphandler 函数接收一个虚拟内存地址(发生 page fault 的地址),来处理 pagefault。

在 mmaphandler 中,我们需要做以下事情:

  1. 找出 va 是映射在哪个页中,也就是需要找出对应的 vma;
  2. 给 vma 正式分配内存;
  3. 根据 vma 中记录的 prot 来设置 PTE 的 flags;
  4. 将物理地址和虚拟地址进行映射;
  5. 使用 readi 将文件读到刚分配的内存中。在进行操作的时候要开启事务,并且对 inode 上锁。
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
int
mmaphandler(uint64 va) {
int i;
struct proc *p = myproc();
struct vma *vma = 0;
struct inode *ip;
for(i = 0; i < NVMA; i++) {
struct vma *v = &p->vmatable[i];
if (v->valid) {
if (va >= v->addr && va < (v->addr + v->len * PGSIZE)) {
vma = v;
break;
}
}
}
if (vma == 0) {
return -1;
}
uint64 ka = (uint64)kalloc();
if (ka == 0) {
return -1;
}
memset((void *) ka, 0, PGSIZE);
va = PGROUNDDOWN(va);
pte_t * pte;
// avoid remap panic.
if ((pte = walk(p->pagetable, va, 0)) != 0 && (*pte & PTE_V) != 0) {
kfree((void *) ka);
return -1;
}
int flags = PTE_FLAGS(*pte);
if (vma->prot & PROT_READ) {
flags |= PTE_R;
}
if (vma->prot & PROT_WRITE) {
flags |= PTE_W;
}
if (vma->prot & PROT_EXEC) {
flags |= PTE_X;
}
if(mappages(p->pagetable, va, PGSIZE, ka, flags | PTE_U) != 0) {
kfree((void *) ka);
return -1;
}
ip = vma->file->ip;
begin_op();
ilock(ip);
if (readi(ip, 0, ka, PGROUNDDOWN(vma->offset + (va - vma->addr)), PGSIZE) < 0) {
return -1;
}
iunlock(ip);
end_op();
return 0;
}

到这里就可以访问我们映射到内存中的文件了。

接下来要实现 munmap 系统调用(kernel/sysfile.c#sys_munmap),注意根据文档,munmap 可以是一部分,但是不会是在中间。

An munmap call might cover only a portion of an mmap-ed region, but you can assume that it will either unmap at the start, or at the end, or the whole region (but not punch a hole in the middle of a region).

在 sys_munmap 函数中我们要处理以下事情:

  1. 接收 addr 和 len 参数;
  2. 找出 addr 对应的 vma;
  3. 判断 vma 是否是 MAP_SHARED 模式,如果是就调用 filewrite 将文件写回磁盘;
  4. 取消 munmap 部分的映射;
  5. 调整 vma 的长度和起始地址。
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
uint64
sys_munmap(void) {

uint64 addr, len;

if(argaddr(0, &addr) < 0 || argaddr(1, &len) < 0) {
return -1;
}

int i;
struct vma *vma = 0;
struct proc *p = myproc();
for (i = 0; i < NVMA; i++) {
struct vma *v = &p->vmatable[i];
if (v->valid && (v->addr <= addr && addr < (v->addr + len))) {
vma = v;
}
}

if (!vma) {
return -1;
}

if (vma->flags & MAP_SHARED && vma->file->writable) {
filewrite(vma->file, addr, len);
}

uvmunmap(p->pagetable, addr, len / PGSIZE, 1);

vma->len -= len;
if(vma->len == 0) vma->valid = 0;
else {
if (vma->addr == addr) vma->addr += len;
}

return 0;
}

注意修改 uvmunmap,否则会报 panic。

当进程退出的时候,即调用 kernel/proc.c#exit,我们需要将它映射的所有文件都 munmap 掉,就像调用 munmap 系统调用。由于我的实现是父子进程并不共享物理内存,所以直接释放掉即可。

1
2
3
4
5
6
7
8
9
10
11
12
// ... kernel/proc.c#exit
int i;
for(i = 0; i < NVMA; i++) {
struct vma *v = &p->vmatable[i];
if (v->valid) {
if (v->flags & MAP_SHARED && v->file->writable) {
filewrite(v->file, v->addr, v->len);
}
uvmunmap(p->pagetable, v->addr, v->len/PGSIZE, 1);
v->valid = 0;
}
}

最后修改 kernel/proc.c#fork,在子进程复制父进程的内存时,可能会复制到没有映射或无效的条目,也要修改 uvmcopy 将 panic 去掉。在 fork 函数中只需要将 vma 复制一份给子进程就可以了。

1
2
3
4
// ...kernel/proc.c#fork
for(i = 0; i < NVMA; i++) {
np->vmatable[i] = p->vmatable[i];
}

到这里 mmaptest 和 fork test 就都可以通过了。

运行结果

这次的 grader 倒是顺利跑过了。

总结

这个 lab 是对 file system 的进一步深入,不过我感觉跟虚拟内存可能更加相关?难点主要是在 mmap 系统调用,要考虑如何给 vma 找到一块合适的内存空间,想清楚这里之后其它的就比较简单了。page fault 的处理跟 lazy alloction 是一样的。munmap 系统调用就相当于做了一次反操作。