最后一个,也是最有挑战性的一项 coding 任务,是所有前置 lab 的知识综合。
Preparation
切换到对应分支
$ git fetch
$ git checkout mmap
$ make clean
我们需要实现的 mmap()
与 munmap()
这两个是系统调用,那就要修改若干文件,这在之前的 lab 中已经操作过很多次了,跳过不聊。
从 mmap 开始
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
其作用是将某一文件 fd
从偏移量 offset
开始 length
个字节的长度映射到内存中。操作系统不仅要为其分配物理内存空间 pa
,还需要在进程的虚拟地址空间为其分配一块虚拟内存 va
,这样才能通过页表机制进行内存的访问。其中 addr
就是用户指定的虚拟地址。如果 addr
为 0,则由操作系统进行目标虚拟地址的挑选。
prot
指定了映射内存区域 pa
的访问权限,flags
指定了映射内存是否应当在 unmap 时将所有修改写回文件。这两者对应的标志位都定义在 kernel/fcntl.h
中。
本 lab 中,入参
addr
可以假设恒为 0。
如何映射?
假设,对于文件 f
,我们选择从 offset
处开始 2 个 PGSIZE 大小的内容进行映射,操作系统会为我们分配 2 个 PGSIZE 大小的虚拟空间,起始地址为 va
,长度 length = 2*PGSIZE
,并记录下对应文件的偏移量 offset
。操作系统还会在合适的时间分配内存,但要注意的是,虽然虚拟地址空间是连续的,但是分配的物理内存并不一定连续,这两页可能会分别映射到两个离散的地址 pa1
和 pa2
。其中 pa1
中的内容为 f
中 offset
开始的一个 PGSIZE 的数据,而 pa2
中的内容为 f
中 offset+PGSIZE
开始的一个 PGSIZE 的数据。
文件 | 虚拟地址 | 物理地址 |
---|---|---|
offset | va | pa1 |
offset+PGSIZE | va+PGSIZE | pa2 |
实现 VMA
根据手册,第一次调用 mmap()
时,我们不必为其分配物理内存,而是仅仅在虚拟空间划出一块区域并返回相应的 va
,等到通过 va
访问内存时,发现页表中没有相应 pte,这个时候再去分配物理内存。
这就是和 lab COW 一样利用了 page fault,防止映射大文件时一次 alloc 所有空间导致物理内存不够用的情况。
那么第一步我们就要解决「在虚拟空间划出一块区域」这个问题了。然而,内核中并没有虚拟地址空间的分配器,但是对我们而言,虚拟地址之所以「虚拟」,是因为我们只需要记住一定的数据,最后 usertrap()
里根据这些数据,在页表中建立页表项并且映射到物理地址即可。所以,进程结构体中可以增加这样一个字段,用于分配并追踪某个虚拟地址的使用情况。
这个字段除了标明某一对映射的虚拟地址、长度,还要有偏移量以及标志位等信息,以及对应的文件信息,那么就有:
struct vma {
int valid; // 该字段是否有效
uint64 va; // 虚拟起始地址
uint64 length; // 长度,可以不是 PGSIZE 的整数倍
int prot; // 权限标志位
int flags; // 脏数据处理标志位
int fd; // 文件描述符
struct file* f; // 文件
int offset; // 相对于文件的偏移量
};
如果 valid
为 1,那么就表明从 va
开始 length
个 bytes 的这段虚拟空间已经被使用了,下次要分配时也应当避开这段空间。一个进程很可能调用多次 mmap()
进行多个文件的映射,那么就不能只存一条记录,而是实现为数组的形式。手册中提示我们 16 是个合适的大小。
现在还有一个问题,该怎样为 va
赋值?回想之前写 lab Page Table 的时候,我们实现了一个功能,是通过固定的虚拟地址加快 getpid()
。当时分配的虚拟地址是位于虚拟空间最末端,TRAMPOLINE 和 TRAPFRAME 之前,这是因为虚拟空间非常大,完全可以把后面那点用不到的空间利用起来。
根据这一经验,我们完全可以在进程中建立一个字段 max_VMA
,它会在新建进程时初始化为 MAXVA-2*PGSIZE
,每次分配时往低地址增长:当调用 mmap()
时,max_VMA
会减去待映射的长度 length
,然后找到一个 valid=0
的 vma,进行 vma.va = max_VMA
的赋值。
struct proc {
...
#define NVMA 16
struct vma vma[NVMA]; // mmap record
uint64 max_VMA; // trampoline and trapframe
};
到此,虚拟内存的分配记录就完成了。
sys_mmap
读取参数,找到可用 vma 并设置数据即可。
uint64
sys_mmap(void)
{
uint64 addr;
int length;
int prot;
int flags;
int fd;
struct file *f;
int offset;
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_PRIVATE) == 0) { // 如果文件不可写,但是允许对 MAP_PRIVATE 模式映射的内存进行写操作,反之不行
return -1;
}
uint64 va = -1;
struct proc *p = myproc();
struct vma *vma;
for (int i = 0; i < NVMA; i++) {
if (!p->vma[i].valid) {
vma = &p->vma[i];
break;
}
}
vma->valid = 1;
if (addr == 0) {
p->max_VMA -= PGROUNDUP(length);
vma->va = va = p->max_VMA;
}
// else { // 现有测试不会跳到这一个分支
// vma->va = va = addr;
// }
vma->length = length;
vma->prot = prot;
vma->flags = flags;
vma->fd = fd;
vma->f = filedup(f); // 映射会增加文件引用计数
vma->offset = offset;
return va;
}
page fault
当出现读/写的 page fault 时,就需要检查是否是在 mmap 内存上引起的。如果是,那就进行物理内存的分配,页表项的建立,以及文件数据的拷贝。注意需要逐页进行物理内存分配。
void
usertrap(void)
{
...
if (r_scause() == 13 || r_scause() == 15) {
// page fault occured by reading or writing a mmap virtual address
// that hasn't been allocated any physical page
if(p->killed)
exit(-1);
uint64 va = PGROUNDDOWN(r_stval());
if (va >= MAXVA)
exit(-1);
// 找到相应的字段
struct vma *vma = 0;
for (int i = 0; i < NVMA; i++) {
if (p->vma[i].valid && p->vma[i].va <= va && p->vma[i].va + PGROUNDUP(p->vma[i].length) > va) {
vma = &p->vma[i];
break;
}
}
// 如果没有找到,说明出错的虚拟地址没有进行映射,直接退出
if (!vma) {
exit(-1);
}
// 完善页表项的标志位
int flags = PTE_U;
if (vma->prot & PROT_READ) {
flags |= PTE_R;
}
if (vma->prot & PROT_WRITE) {
flags |= PTE_W;
}
if (vma->prot & PROT_EXEC) {
flags |= PTE_X;
}
uint64 pa;
uint64 following = vma->va - va + PGROUNDUP(vma->length);
// 逐页进行映射+拷贝操作
ilock(vma->f->ip);
for (uint64 off = 0; off < following; off += PGSIZE) {
// 如果当前地址已有映射,跳过
if ((pa = walkaddr(p->pagetable, va+off)) != 0) {
continue;
}
// 无可用内存,报错
if ((pa = (uint64)kalloc()) == 0) {
panic("no free memory");
}
// 清空数据,并建立映射,再进行拷贝
memset((void*)pa, 0, PGSIZE);
if (mappages(p->pagetable, va+off, PGSIZE, pa, flags) != 0) {
panic("cannot map");
}
if (readi(vma->f->ip, 0, pa, vma->offset+off, PGSIZE) == -1) {
panic("read file failed");
}
}
iunlock(vma->f->ip);
}
...
}
进行 munmap
int munmap(void *addr, int length)
munmap()
是对指定地址 addr
上长度为 length
的地址解除映射,即释放对应内存,删除页表项,并且还要修改相应的 vma 字段。
比如对之前表格中进行 munmap(va+PGSIZE, PGSIZE)
,就会导致文件映射长度减少,那么 vma
的 length
字段就需要改为 PGSIZE
(原来是 2*PGSIZE)。
而如果进行 munmap(va, PGSIZE)
,那么还会额外导致 vma
的 va
字段改为 va+PGSIZE
,并且偏移量 offset
也会增加 PGSIZE
。
当然,调用 munmap(va, 2*PGSIZE)
会使 vma
的 length
和 offset
直接归零,此时意味着该文件的映射区域被完全解除,应降低该文件的引用计数,并且重置相应 vma
的所有字段(即归零)。
这很合理,毕竟解除了某一区域的映射,那么 vma 中文件的映射长度与偏移量肯定也会发生变化,不然就会出现不一致的问题。下次对已解除区域重新
mmap()
时,经查 vma 数组发现已经有映射了,这显然与现实矛盾。
sys_munmap
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 *vma = 0;
for (int i = 0; i < NVMA; i++) {
if (p->vma[i].valid && p->vma[i].va <= addr && p->vma[i].va + PGROUNDUP(p->vma[i].length) > addr) {
vma = &p->vma[i];
break;
}
}
if (vma) {
vmaunmap(p, vma, addr, length);
return 0;
}
return -1;
}
其中 vmaunmap()
就是做了所需要的全部工作——修改 page table、释放内存、修改 vma。
还不够
进程结束的时候,可能用户会忘记调用 munmap()
,而进程只会释放 TRAMPOLINE 和 TRAPFRAME 以及低虚拟地址处的内存,对于文件映射内存区域,还需要我们额外加入代码进行处理。手册提示我们加在 exit()
函数里,我想是因为所有进程正常退出时都会调用该函数,并且我尝试在 freeproc()
中进行内存释放和数据写回,发现会在 bcache
上产生死锁的问题,但并未深究。
以及还要在 allocproc()
与 freeproc()
里增加对 vma[]
与 max_VMA
的初始化与重置。
哦对,调用 fork()
创建子进程时,只需要拷贝 vma[]
与 max_VMA
即可,而不需要拷贝物理内存,这也是利用了 page fault 的 lazy allocation 策略——用到再分配。
当然,还要增加 vma[]
中记录的文件的引用计数。
static struct proc*
allocproc(void)
{
...
found:
...
memset(&p->vma, 0, sizeof(p->vma));
p->max_VMA = PGROUNDUP(MAXVA) - PGSIZE*2;
...
}
static void
freeproc(struct proc *p)
{
...
memset(&p->vma, 0, sizeof(p->vma));
p->max_VMA = 0;
}
void
exit(int status)
{
...
for (int i = 0; i < NVMA; i++) {
struct vma* vma = &p->vma[i];
if (vma->valid) {
vmaunmap(p, vma, vma->va, vma->length);
}
}
p->max_VMA = 0;
...
}
int
fork(void)
{
...
for (int i = 0; i < NVMA; i++) {
np->vma[i] = p->vma[i];
if (np->vma[i].f) {
np->vma[i].f->ref++;
}
np->max_VMA = p->max_VMA;
}
...
}
测试结果
$ make grade
...
== Test running mmaptest ==
$ make qemu-gdb
(3.9s)
== Test mmaptest: mmap f ==
mmaptest: mmap f: OK
== Test mmaptest: mmap private ==
mmaptest: mmap private: OK
== Test mmaptest: mmap read-only ==
mmaptest: mmap read-only: OK
== Test mmaptest: mmap read/write ==
mmaptest: mmap read/write: OK
== Test mmaptest: mmap dirty ==
mmaptest: mmap dirty: OK
== Test mmaptest: not-mapped unmap ==
mmaptest: not-mapped unmap: OK
== Test mmaptest: two files ==
mmaptest: two files: OK
== Test mmaptest: fork_test ==
mmaptest: fork_test: OK
== Test usertests ==
$ make qemu-gdb
usertests: OK (129.9s)
(Old xv6.out.usertests failure log removed)
== Test time ==
time: OK
Score: 140/140
最后的工作
git commit -am ""
将所有修改提交到本地;- 执行
make handin
。由于 lab0 保存了 APIKey,故直接成功提交;
可选的挑战再说吧,没有什么想做的欲望。