[MIT 6.s081] Xv6 Lab 11: Mmap Record
The content below was generated entirely by machine translation. Please verify its accuracy. If anything is unclear, consult the Chinese source version.
Update on 2022/9/14: I recently put the lab code on GitHub. If you need a reference, you can find it here:
https://github.com/ttzytt/xv6-riscv
The different branches contain the different labs.
This is the final lab. I have finally finished them all!
Lab 11: mmap
Description
This lab implements a subset of the mmap() and munmap() system calls commonly found in UNIX operating systems. These system calls map a file into user-space memory, allowing the user to modify and access the file directly through memory, which is much more convenient.
The definition of mmap() is:
1 | void *mmap(void *addr, size_t length, int prot, int flags, |
It maps the first length bytes of the file whose descriptor is fd into memory beginning at addr, with an additional offset, meaning that the mapping does not necessarily start at the beginning of the file.
If addr is zero, the system automatically allocates an unused memory region for the mapping and returns that address.
In this lab, we only need to support cases in which both addr and offset are zero, so we do not need to consider a user-specified memory address or file offset at all.
Both prot and flags are sets of flags. More specifically, prot has the following options:
- PROT_NONE
- PROT_READ
- PROT_WRITE
- PROT_EXEC
They specify which operations may be performed on the mapped file.
The flags argument determines whether changes made through the memory mapping must be written back to the file when the mapping is removed.
Its two options are MAP_SHARED and MAP_PRIVATE.
The definition of munmap() is:
1 | int munmap(void *addr, size_t length); |
It removes a file mapping of length bytes beginning at addr. One important restriction is that the function cannot “punch a hole” in the middle of a mapped range. It may remove only a portion at the beginning or end, or the complete mapping.
That may not be entirely clear. Suppose the mapped range is . If we want to unmap the range , it must satisfy either or .
Overall approach
First, we must decide where in a user process to place memory-mapped files. The memory layout of a user process is shown below:

Initially, I wanted to allocate memory for mappings in the same way as sbrk():
1 | uint64 |
In other words, allocate additional heap space to the process and put the file there. Although this would be easy to implement, closer consideration reveals many problems. We assume that all memory below myproc()->sz is freely available to the user, and that is precisely the memory allocated by malloc().
If a mapped file were placed there, the same space could easily be allocated by malloc() and then overwritten.
Furthermore, after the file is unmapped, the PTE for the mapped location is set to zero. If the user later accesses the corresponding memory, another page fault occurs and must be handled, which is clearly rather complicated.
We can avoid conflicts with the process heap by allocating memory for file mappings in the opposite direction. That is, begin at the trapframe and allocate mapping regions downward.
Following the lab hints, we can add a VMA (virtual memory area) structure to the kernel’s process structure. A VMA stores metadata about one file mapping, such as its starting address, length, and mapped file. This metadata makes the mappings much easier to manage.
To support a certain number of simultaneous file mappings, struct proc must contain that many VMAs. The hint recommends sixteen.
File mappings must also use lazy allocation; otherwise, copying a large file all at once would be very expensive. The file is copied into memory only after the user process triggers a page fault.
Finally, mapped files must remain available after fork(). This part is relatively simple: we only need to copy the VMA. The child page table does not contain the corresponding mapping, so accessing an address recorded in the VMA triggers a page fault. At that point, the required portion of the file can be copied into memory.
Code
Note: this lab does not register the system calls or mmaptest for us. Follow the same procedure as in Lab 2. I will not repeat it here; if necessary, see this article.
struct mmap_vma:
1 | // in proc.h |
sys_mmap():
This call does not allocate physical memory. It calls get_mmap_space() to find an unused entry in mmap_vams and an available virtual region for the file mapping, then initializes the VMA structure.
It must also increment the reference count of the mapped file. Without this increment, the file would be closed when its reference count reached zero, leaving us unable to copy its contents into memory during lazy allocation.
1 | // in sysfile |
get_mmap_space():
This function must locate an available memory region for a new file mapping, so we need to choose an allocation strategy. The safest method is to find the lowest virtual address used by all existing VMAs and use that position as the end of the new mapped region. This can never create a collision, but it also has a drawback:
First, to simplify unmapping, we do not allow two file mappings to share one page frame; otherwise, kfree() would release both at once.
Second, always allocating below the lowest virtual address may cause the mapping region to continue growing downward even when a usable hole exists. In some uncommon situations this strategy could reduce the memory available to the user heap. In an extreme case it could cause a problem, although this is very unlikely because MAXVA is normally enormous and at least larger than physical memory.
In any case, I had time to spare and wrote code that handles this situation. It uses two nested loops, each traversing all VMAs. See the comments for the details.
1 | // in sysfile.c |
All mappings created so far are lazily allocated, so we need a function that handles page faults.
mmap_fault_handler():
There is a slightly troublesome corner case here. If the mapping size requested by the user exceeds the size of the file itself, the remaining mapped region must be filled with zeroes; otherwise, mmaptest() will not pass.
Another point is that after a page fault we allocate and map only one page, rather than mapping the entire file at once.
1 | // in trap.c |
get_vma_by_addr():
This helper is used by the preceding fault handler and returns the VMA containing a given address:
1 | struct mmap_vam* |
usertrap():
1 | // in trap.c |
We can now attempt to implement munmap(). If the VMA has the MAP_SHARED flag, modifications made in memory must be copied back to the file while the mapping is removed.
Because this process is relatively complicated, I wrote a separate mmap_writeback() function for it. We use the PTE_D flag in a PTE to determine whether a page of the file mapping has been modified. A modified page needs to be copied back.
This flag is not already defined, so define it in riscv.h according to the RISC-V manual:
1 |
If the unmap address and length are not multiples of PGSIZE, this function becomes particularly complicated:
- The region being unmapped may not cross a page-frame boundary; all of the removed memory then lies within one frame. That frame cannot be released, but the changed memory still needs to be copied back to the file.
- If the ending address lies in the middle of a page frame, we need another case distinction. If that frame is the final page of the mapped region, it must be written back and then released. If it is not the final page, it cannot be released.
Possibly because of this complexity, every munmap() and mmap() call in mmaptest.c uses addr and len values that are multiples of PGSIZE. The lab hints also say that supporting the features used by mmaptest.c is sufficient. The following version therefore does not support nonmultiples of PGSIZE. I also wrote a version that does, but it has not been tested at all because I was too lazy to write an enhanced mmaptest.c. Perhaps I will do so when I have time.
Normal version:
1 | // in vm.c |
Version supporting values that are not multiples of PGSIZE (untested):
1 | //in vm.c |
By comparison, munmap() itself is fairly simple. One detail remains important: if no part of the mapped region remains after unmapping, the corresponding file is no longer needed. We therefore call fileclose() to decrement its reference count and close it.
We also must not forget the restriction on removing a mapping: it can be removed only from its beginning or end, not by punching a hole in the middle, as described at the start of this article.
1 | // in sysfile.c |
You may notice that this function is not written as a system call. That is because we will also need to invoke it from inside the kernel. The system-call wrapper is:
1 | uint64 |
The kernel needs to call munmap() because some processes may exit without unmapping their files. We must forcibly clean up those mappings to prevent memory leaks, and this cleanup can be placed in exit().
Why place it in exit() rather than in freeproc(), which actually releases the process slot? Observe that a process is passed to freeproc() by wait() as follows:
1 | // in proc.c wait(): |
If the parent process never calls wait(), these mapped files remain indefinitely and are never written back. Of course, a parent process ought to call wait(). The main reason I used exit() is that the lab hint says to do so, but the behavior just described may be why the hint makes that recommendation.
1 | // in proc.c exit(): |
The final step of the lab is allowing a child process to access mapped files after fork(). As mentioned earlier, we only need to copy the VMA. Its sta_addr is a virtual address. When the child attempts to access it, a page fault occurs because that virtual address has not been mapped to a physical address.
In mmap_fault_handler(), we then find that the faulting address belongs to a file-mapped region. The handler allocates a physical page for that virtual page and copies the corresponding file data into it.
Of course, after fork() another process is using the mapped file, so filedup() must increment its reference count.
fork():
1 | // in proc.c |
Initially I had a small question here. The earlier call to uvmcopy() had already copied the memory, so would it not copy the VMAs too? If we copied them afterward, would that duplicate the mappings?
Reading the implementation resolved the question: uvmcopy() copies only memory below myproc()->sz:
1 | // in vm.c |
After completing these changes, the lab passes. I wish everyone currently working on it an early AC as well:

Complaints
I absolutely have to complain about a bug here, although I do not even know where the bug belongs: xv6, QEMU, or the Makefile.
While debugging with GDB, I wanted to use macros, mainly PGROUNDDOWN() and PGROUNDUP(). I therefore added the -g3 compilation option to the Makefile as follows:
1 | CFLAGS = -Wall -O -g3 -fno-omit-frame-pointer -ggdb -UFDEBUG |
This caused one of the tests in usertest.c to fail with an immediate panic:
1 | usertests writebig |
Removing -g3 somehow made everything work normally. I could never have imagined that a compiler option might affect the number of blocks on the virtual disk. I spent an entire day debugging this because who would expect a compiler option to have such an effect? Eventually I used Git to compare the files on this branch with other branches and tested the differences one by one until I found it.
I reported the problem on the xv6-riscv GitHub repository. While browsing the issue tracker, I found something even more absurd:
https://github.com/mit-pdos/xv6-riscv/issues/59
Adding -O3 to the compiler options can apparently cause the same problem. I simply do not understand it.




![[Stanford CS144] Lab 4 Record](/img/CS144/tcp%E7%8A%B6%E6%80%81%E6%B5%81%E8%BD%AC%E5%9B%BE.jpg)