10 - Virtual Memory
Previously we introduced the concept of paging. It motivated paging by comparing it with
segmentation, explained how paging and page tables work, and then introduced the
4-level page table design of x86_64. We found out that the bootloader already
set up a page table hierarchy for our kernel, which means that our kernel
already runs on virtual addresses. This improves safety since illegal memory
accesses cause page fault exceptions instead of modifying arbitrary physical
memory.
We had the problem that we can't access the page tables from our kernel because they are stored in physical memory and our kernel already runs on virtual addresses. This post explores different approaches to making the page table frames accessible to our kernel. We will discuss the advantages and drawbacks of each approach and then decide on an approach for our kernel.
To implement the approach, we will need support from the bootloader, so we'll configure it first. Afterward, we will implement a function that traverses the page table hierarchy in order to translate virtual to physical addresses. Finally, we learn how to create new mappings in the page tables and how to find unused memory frames for creating new page tables.
Accessing Page Tables
Accessing the page tables from our kernel is not as easy as it may seem. To understand the problem, let's take a look at the example 4-level page table hierarchy from the previous post again:
The important thing here is that each page entry stores the physical address of the next table. This avoids the need to run a translation for these addresses too, which would be bad for performance and could easily cause endless translation loops.
The problem for us is that we can't directly access physical addresses from our
kernel since our kernel also runs on top of virtual addresses. For example, when
we access address 4 KiB we access the virtual address 4 KiB, not the
physical address 4 KiB where the level 4 page table is stored. When we want
to access the physical address 4 KiB, we can only do so through some virtual
address that maps to it.
So in order to access page table frames, we need to map some virtual pages to them. There are different ways to create these mappings that all allow us to access arbitrary page table frames.
Identity Mapping
A simple solution is to identity map all page tables:
In this example, we see various identity-mapped page table frames. This way, the physical addresses of page tables are also valid virtual addresses so that we can easily access the page tables of all levels starting from the CR3 register.
However, it clutters the virtual address space and makes it more difficult to
find continuous memory regions of larger sizes. For example, imagine that we
want to create a virtual memory region of size 1000 KiB in the above
graphic, e.g., for memory-mapping a
file. We can't start the
region at 28 KiB because it would collide with the already mapped page at
1004 KiB. So we have to look further until we find a large enough unmapped
area, for example at 1008 KiB.
Equally, it makes it much more difficult to create new page tables because we
need to find physical frames whose corresponding pages aren't already in use.
For example, let's assume that we reserved the virtual 1000 KiB memory
region starting at 1008 KiB for our memory-mapped file. Now we can't use any
frame with a physical address between 1000 KiB and 2008 KiB anymore,
because we can't identity map it.
Map at a Fixed Offset
To avoid the problem of cluttering the virtual address space, we can use a separate memory region for page table mappings. So instead of identity mapping page table frames, we map them at a fixed offset in the virtual address space. For example, the offset could be 10 TiB:
By using the virtual memory in the range 10 TiB..(10 TiB + physical memory size) exclusively for page table mappings, we avoid the collision problems of
the identity mapping. Reserving such a large region of the virtual address space
is only possible if the virtual address space is much larger than the physical
memory size. This isn't a problem on x86_64 since the 48-bit address space is
256 TiB large.
This approach still has the disadvantage that we need to create a new mapping whenever we create a new page table. Also, it does not allow accessing page tables of other address spaces, which would be useful when creating a new process.