零、写在前面
mmap 的目的
- 传统 I/O 读写方式开销过大
- 传统的文件 I/O 需要在 用户空间 和 内核空间 之间多次拷贝数据。
- 大文件处理时,内存的开销比较大。
- 映射文件到内存
- 将文件的内容直接映射到进程的虚拟地址空间,使得访问文件就像访问普通内存一样高效:
- 替代
read
/write
等系统调用; - 节省一次内核空间和用户空间的拷贝(零拷贝);
- 适合处理大文件或频繁访问的文件数据。
- 替代
- 将文件的内容直接映射到进程的虚拟地址空间,使得访问文件就像访问普通内存一样高效:
- 实现进程间共享内存(IPC)
- 使用
MAP_SHARED
标志,多个进程可以映射同一个文件区域,从而共享这段内存,实现快速通信。
- 使用
- 延迟加载/按需加
- 当用
mmap
映射大文件时,数据并不会立即加载,而是通过 缺页异常(Page Fault)在访问时才加载,对资源消耗更加友好。
- 当用
- 支持匿名映射
- 即映射一块没有文件关联的内存(例如堆或共享内存),常用于运行时分配内存(可替代
malloc
)。
- 即映射一块没有文件关联的内存(例如堆或共享内存),常用于运行时分配内存(可替代
mmap 的实现原理(以 Linux 为例,xv6 中实现思路类似但更简化)
- 虚拟地址空间管理(VMA)
- 每个进程都有一个**虚拟内存区域(Virtual Memory Area, VMA)**链表或树,记录当前有哪些区域被映射(包括堆、栈、
mmap
)。 - 每次调用
mmap
,内核会分配一段空闲的虚拟地址,并创建一个新的 VMA 节点,插入进程的 VMA 列表中。
- 页表未立即映射(懒加载)
mmap
调用时并不分配物理内存或读取文件数据,而是 延迟到第一次访问该区域 时:- CPU 触发缺页异常;
- 内核捕捉异常,在页表中创建映射;
- 分配物理页;
- 若是文件映射,则从文件读取数据填充物理页;
- 最后更新页表,恢复执行。
这也是为什么
mmap
能够高效地映射超大文件 ------ 因为不会一次性加载全部内容。
- 文件共享与写回
MAP_SHARED
: 修改映射内存会影响原始文件,页被修改(置 Dirty Bit)后,在munmap
或msync
时写回文件;MAP_PRIVATE
: 写时拷贝(Copy-on-Write, COW),不会修改原文件(通常用于 fork 时父子进程共享内存);
- munmap 实现
- 查找 VMA 链表中目标地址;
- 释放对应的物理页;
- 如果是共享映射且页被修改,则写回文件;
- 更新页表,清除对应页;
- 移除 VMA 或裁剪 VMA 区间。
记得切换到 mmap 分支
一、mmap
1.1 说明
mmap
和 munmap
系统调用允许 UNIX 程序对其地址空间进行详细控制。**它们可以用于在进程之间共享内存、将文件映射到进程的地址空间中,以及用于用户级缺页处理机制,**比如在课堂上讨论的垃圾回收算法。
在这个实验中,你将为 xv6 添加 mmap
和 munmap
,重点是实现内存映射文件功能。
mmap
可以有很多种使用方式,但这个实验只需要支持与文件内存映射相关的一小部分功能:
c
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
- 你可以假设
addr
总是为 0,表示应由内核决定映射文件的虚拟地址。mmap
返回这个地址,或者在失败时返回0xffffffffffffffff
。 length
是要映射的字节数,可能不同于文件的长度。prot
指示内存应是否可读、可写和/或可执行;你可以假设prot
是PROT_READ
或PROT_WRITE
或两者都有。flags
将是MAP_SHARED
(表示对映射内存的修改应写回文件)或MAP_PRIVATE
(不写回)。你不需要处理flags
的其他位。fd
是要映射的文件的打开文件描述符。- 你可以假设
offset
为 0(即映射从文件的起始位置开始)。
如果多个进程映射了同一个 MAP_SHARED
文件,它们不共享物理页面也没有问题。
munmap(addr, length)
应移除该地址范围内的 mmap
映射。如果进程修改了这段内存,并且是以 MAP_SHARED
映射的,那么这些修改应写回文件。一个 munmap
调用可能只覆盖一个 mmap
区域的一部分,但你可以假设它只会取消映射开头、结尾,或整个区域(不会在中间打洞)。
你应实现足够的 mmap
和 munmap
功能以使 mmaptest
测试程序能够工作。mmaptest
没有使用的功能你就不必实现。
官网的一些提示
- 先在
UPROGS
中添加_mmaptest
,并实现mmap
和munmap
系统调用,使user/mmaptest.c
能够编译通过。此时先让mmap
和munmap
返回错误。我们已在kernel/fcntl.h
中定义了PROT_READ
等常量。运行mmaptest
,它会在第一次调用mmap
时失败。 - 像懒分配实验那样,懒惰地填充页表,也就是说,
mmap
不应该立即分配物理内存或读取文件内容。相反,应在页错误处理代码(usertrap
或其调用函数)中完成这一步。这样做的原因是为了使大文件的mmap
操作足够快,且可以支持映射大于物理内存的文件。 - 跟踪每个进程通过
mmap
映射了什么。定义一个结构体表示虚拟内存区域(VMA,Lecture 15中讲过),记录地址、长度、权限、文件等信息。由于 xv6 内核没有动态内存分配器,可以使用一个固定大小的 VMA 数组,动态分配其中的元素。大小为 16 应该足够。 - 实现
mmap
:在进程地址空间中找到一个未使用的区域用于映射文件,并将一个 VMA 添加到进程的映射区域表中。VMA 中应包含指向映射文件的struct file
指针;mmap
应增加文件的引用计数(提示:参见filedup
)。此时运行mmaptest
,第一个mmap
应成功,但第一次访问映射内存会触发缺页错误并导致程序被杀死。 - 添加代码,在访问
mmap
区域时触发页错误后分配物理页、从文件中读取 4096 字节到该页,并将其映射到用户空间。用readi
读取文件,它接受偏移参数(但你需要加锁/解锁传给readi
的 inode)。别忘了为该页设置正确的权限。运行mmaptest
,应该会执行到第一个munmap
。 - 实现
munmap
:查找指定地址范围的 VMA,取消映射指定的页面(提示:使用uvmunmap
)。如果这次munmap
移除了之前mmap
的所有页面,应减少相关文件结构的引用计数。如果取消映射的页被修改过,且是以MAP_SHARED
映射的,应将该页写回文件。可参考filewrite
的实现。 - 理想情况下,只有在页面被实际修改后,才写回
MAP_SHARED
页面。RISC-V 页表项中的 D(dirty)位表明该页是否被写过。不过,mmaptest
并不检查未修改页面是否被写回,因此即使不检查 D 位,写回所有页也能通过测试。 - 修改
exit
,让进程退出时像调用了munmap
一样取消所有映射区域。此时运行mmaptest
,mmap_test
应该能通过,但fork_test
可能仍然失败。 - 修改
fork
,确保子进程拥有和父进程一样的映射区域。不要忘了增加每个 VMA 所指struct file
的引用计数。在子进程页错误处理器中,为页面分配新的物理页是可以的(不与父进程共享)。共享物理页更酷,但实现更复杂。此时运行mmaptest
,应能通过mmap_test
和fork_test
。 - 最后运行
usertests
,确保所有功能仍然正常工作。
1.2 实现
1.2.1 准备工作
添加系统调用 mmap, munmap
添加系统调用也是常规操作了:
c
// kernel/syscall.h
// ...
#define SYS_close 21
#define SYS_mmap 22
#define SYS_munmap 23
c
// kernel/syscall.c
// ...
extern uint64 sys_uptime(void);
extern uint64 sys_mmap(void);
extern uint64 sys_munmap(void);
// ...
[SYS_close] sys_close,
[SYS_mmap] sys_mmap,
[SYS_munmap] sys_munmap,
};
c
// kernel/sysfile.c
uint64 sys_mmap(void)
{
return 0;
}
uint64 sys_munmap(void)
{
return 0;
}
c
// user/user.h
// ...
void *memcpy(void *, const void *, uint);
void *mmap(void *addr, int length, int prot, int flags, int fd, int offset);
int munmap(void *addr, int length);
c
// user/user.pl
// ...
entry("uptime");
entry("mmap");
entry("munmap");
先在makefile 里面添加上 mmaptest 看看能不能跑:
c
// ...
$U/_wc\
$U/_zombie\
$U/_mmaptest\

可以跑,接下来进行实现
根据官网提示,我们接下来要定义一个结构体VMA,用来记录 mmap 创建的虚拟内存区域,包括 地址、长度、权限、文件等,然后为进程创建长度 16 的 vma
数组
c
// kernel/proc.h
#define NVMA 16
struct VMA{
uint64 addr; // address
uint64 length; // length
int prot; // (PROT_READ, PROT_WRITE)
int flags; // MAP_SHARED or MAP_PRIVATE
struct file *file; // mapped file
uint64 offset; // offset
};
// Per-process state
struct proc {
// ...
struct VMA vmas[NVMA]; // process's table of mapped regions
};
1.2.2 sys_mmap
c
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
- 从 vmas 中找到一个空闲的 VMA
- 记录 申请对象到 该VMA
- 增加 file 的引用计数
- 内存分配为 lazy alloc
c
uint64 sys_mmap(void)
{
// parameters for mmap
uint64 addr;
int len, prot, flags, fd, offset;
struct file* file;
struct VMA* vma = 0;
// get parameters
if(argaddr(0, &addr)<0 || argint(1, &len)<0
|| argint(2, &prot)<0 || argint(3, &flags)<0
|| argfd(4, &fd, &file)<0 || argint(5, &offset)<0)
return -1;
// is valid ?
if(len <= 0)
return -1;
if((prot & (PROT_READ|PROT_WRITE|PROT_EXEC)) == 0) // only PROT_READ, PROT_WRITE, PROT_EXEC
return -1;
if((prot & PROT_WRITE) && !file->writable && flags == MAP_SHARED) // writable holds when MAP_SHARED
return -1;
if((prot & PROT_READ) && !file->readable) // MAP_PRIVATE holds when readable
return -1;
struct proc* p = myproc();
len = PGROUNDUP(len);
// size overflowed
if(p->sz+len > MAXVA)
return -1;
if(offset<0 || offset%PGSIZE)
return -1;
// find a vacant vma
for(int i = 0; i < NVMA; ++ i) {
if(p->vmas[i].addr)
continue;
vma = &p->vmas[i];
break;
}
// fail to find
if(!vma)
return -1;
if(addr == 0)
vma->addr = p->sz; // if address not assigned, then use process's sz
else
vma->addr = addr; // addr assigned
vma->length = len;
vma->prot = prot;
vma->flags = flags;
vma->offset = offset;
vma->file = file;
p->sz += len;
// file refCnt
filedup(file);
return vma->addr;
}
1.2.3 munmap
c
munmap(addr, length)
- 查表找到 要解除映射的 vma
- 找到后 uvmunmap() 掉
- 然后 fileclose
- 如果 是
MAP_SHARED
并且 有修改,那么写回文件(此处可参考 filewrite)
c
uint64 sys_munmap(void)
{
uint64 addr;
int len;
struct VMA* vma = 0;
struct proc* p = myproc();
if(argaddr(0, &addr) < 0 || argint(1, &len) < 0)
return -1;
// check parameter
if(len <= 0 || addr + len > p->sz)
return -1;
addr = PGROUNDDOWN(addr);
len = PGROUNDUP(len);
// find vma
for(int i = 0; i < NVMA; ++ i) {
if(p->vmas[i].addr && addr >= p->vmas[i].addr
&& addr + len <= p->vmas[i].addr + p->vmas[i].length) {
vma = &p->vmas[i];
break;
}
}
// addr not valid
if(!vma || addr != vma->addr)
return -1;
// if shared
if(vma->flags & MAP_SHARED)
filewrite(vma->file, addr, len);
// unmap
uvmunmap(p->pagetable, addr, len/PGSIZE, 1);
// if unmap completely
if(len == vma->length) {
fileclose(vma->file);
memset(vma, 0, sizeof(*vma));
} else {
// otherwise just change addr and length
vma->addr += len;
vma->length -= len;
}
// if umap the end of process addr, the adjust process size
if(addr + len == p->sz)
p->sz -= len;
return 0;
}
1.2.4 trap handler
- 因为我们前面只是懒分配,因此要在缺页异常的时候物理分配
- 如果是文件映射,则从文件读取数据填充物理页;
- 为 mem 和 va 建立映射
1.2.5 完善 exit
- 只需添加进程退出时对于 映射区域的 清空逻辑
c
void exit(int status)
{
// ...
for (int i = 0; i < 16; ++ i) {
if(p->vmas[i].length > 0) {
uvmunmap(p->pagetable, p->vmas[i].addr, p->vmas[i].length / PGSIZE, 1);
if (p->vmas[i].file) {
fileclose(p->vmas[i].file);
}
p->vmas[i].length = 0;
}
} // ...
}
1.2.6 fork
- 根据官网提示,fork 的时候,copy 父进程的 映射区域 给 子进程
- 复制vma 的时候,需要增加 file 的引用计数
c
int fork(void) {
// ...
np->state = RUNNABLE;
for (int i = 0; i < 16; ++ i) {
if (p->vmas[i].length > 0) {
memmove(&np->vmas[i], &p->vmas[i], sizeof(struct VMA));
if (p->vmas[i].file) {
filedup(p->vmas[i].file);
}
}
}
release(&np->lock);
return pid;
}
到这里我开心的测试了下,然后就释怀的似了


看了下新加的 uvmunmap 的地方,很快就反应过来是lazy alloc 的页面没有被访问,自然有可能是无效的,所以我们 改为 continue 即可

然后再次运行,又似了:

因为 uvmcopy 只在fork那里添加了,原因和前面类似,就是copy 了 没有实际分配的 lazy 页面
那么把 uvmcopy 中的panic 改成 continue 即可
然后就过了

跑一下 usertests:
