This post shows how to implement paging support in our kernel. It first explores different techniques to make the physical page table frames accessible to the kernel and discusses their respective advantages and drawbacks. It then implements an address translation function and a function to create a new mapping.

This blog is openly developed on GitHub. If you have any problems or questions, please open an issue there. You can also leave comments at the bottom. The complete source code for this post can be found in the post-09 branch.

Introduction

The previous post gave an introduction to 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.

The post ended with 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:

An example 4-level page hierarchy with each page table shown in physical memory

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:

A virtual and a physical address space with various virtual pages mapped to the physical frame with the same address

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. This is a similar fragmentation problem as with segmentation.

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:

The same figure as for the identity mapping, but each mapped virtual page is offset by 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.

Map the Complete Physical Memory

We can solve these problems by mapping the complete physical memory instead of only page table frames:

The same figure as for the offset mapping, but every physical frame has a mapping (at 10 TiB + X) instead of only page table frames.

This approach allows our kernel to access arbitrary physical memory, including page table frames of other address spaces. The reserved virtual memory range has the same size as before, with the difference that it no longer contains unmapped pages.

The disadvantage of this approach is that additional page tables are needed for storing the mapping of the physical memory. These page tables need to be stored somewhere, so they use up a part of physical memory, which can be a problem on devices with a small amount of memory.

On x86_64, however, we can use huge pages with a size of 2 MiB for the mapping, instead of the default 4 KiB pages. This way, mapping 32 GiB of physical memory only requires 132 KiB for page tables since only one level 3 table and 32 level 2 tables are needed. Huge pages are also more cache efficient since they use fewer entries in the translation lookaside buffer (TLB).

Temporary Mapping

For devices with very small amounts of physical memory, we could map the page table frames only temporarily when we need to access them. To be able to create the temporary mappings, we only need a single identity-mapped level 1 table:

A virtual and a physical address space with an identity mapped level 1 table, which maps its 0th entry to the level 2 table frame, thereby mapping that frame to the page with address 0

The level 1 table in this graphic controls the first 2 MiB of the virtual address space. This is because it is reachable by starting at the CR3 register and following the 0th entry in the level 4, level 3, and level 2 page tables. The entry with index 8 maps the virtual page at address 32 KiB to the physical frame at address 32 KiB, thereby identity mapping the level 1 table itself. The graphic shows this identity-mapping by the horizontal arrow at 32 KiB.

By writing to the identity-mapped level 1 table, our kernel can create up to 511 temporary mappings (512 minus the entry required for the identity mapping). In the above example, the kernel created two temporary mappings:

  • By mapping the 0th entry of the level 1 table to the frame with address 24 KiB, it created a temporary mapping of the virtual page at 0 KiB to the physical frame of the level 2 page table, indicated by the dashed arrow.
  • By mapping the 9th entry of the level 1 table to the frame with address 4 KiB, it created a temporary mapping of the virtual page at 36 KiB to the physical frame of the level 4 page table, indicated by the dashed arrow.

Now the kernel can access the level 2 page table by writing to page 0 KiB and the level 4 page table by writing to page 36 KiB.

The process for accessing an arbitrary page table frame with temporary mappings would be:

  • Search for a free entry in the identity-mapped level 1 table.
  • Map that entry to the physical frame of the page table that we want to access.
  • Access the target frame through the virtual page that maps to the entry.
  • Set the entry back to unused, thereby removing the temporary mapping again.

This approach reuses the same 512 virtual pages for creating the mappings and thus requires only 4 KiB of physical memory. The drawback is that it is a bit cumbersome, especially since a new mapping might require modifications to multiple table levels, which means that we would need to repeat the above process multiple times.

Recursive Page Tables

Another interesting approach, which requires no additional page tables at all, is to map the page table recursively. The idea behind this approach is to map an entry from the level 4 page table to the level 4 table itself. By doing this, we effectively reserve a part of the virtual address space and map all current and future page table frames to that space.

Let’s go through an example to understand how this all works:

An example 4-level page hierarchy with each page table shown in physical memory. Entry 511 of the level 4 page is mapped to frame 4KiB, the frame of the level 4 table itself.

The only difference to the example at the beginning of this post is the additional entry at index 511 in the level 4 table, which is mapped to physical frame 4 KiB, the frame of the level 4 table itself.

By letting the CPU follow this entry on a translation, it doesn’t reach a level 3 table but the same level 4 table again. This is similar to a recursive function that calls itself, therefore this table is called a recursive page table. The important thing is that the CPU assumes that every entry in the level 4 table points to a level 3 table, so it now treats the level 4 table as a level 3 table. This works because tables of all levels have the exact same layout on x86_64.

By following the recursive entry one or multiple times before we start the actual translation, we can effectively shorten the number of levels that the CPU traverses. For example, if we follow the recursive entry once and then proceed to the level 3 table, the CPU thinks that the level 3 table is a level 2 table. Going further, it treats the level 2 table as a level 1 table and the level 1 table as the mapped frame. This means that we can now read and write the level 1 page table because the CPU thinks that it is the mapped frame. The graphic below illustrates the five translation steps:

The above example 4-level page hierarchy with 5 arrows: "Step 0" from CR4 to level 4 table, "Step 1" from level 4 table to level 4 table, "Step 2" from level 4 table to level 3 table, "Step 3" from level 3 table to level 2 table, and "Step 4" from level 2 table to level 1 table.

Similarly, we can follow the recursive entry twice before starting the translation to reduce the number of traversed levels to two:

The same 4-level page hierarchy with the following 4 arrows: "Step 0" from CR4 to level 4 table, "Steps 1&2" from level 4 table to level 4 table, "Step 3" from level 4 table to level 3 table, and "Step 4" from level 3 table to level 2 table.

Let’s go through it step by step: First, the CPU follows the recursive entry on the level 4 table and thinks that it reaches a level 3 table. Then it follows the recursive entry again and thinks that it reaches a level 2 table. But in reality, it is still on the level 4 table. When the CPU now follows a different entry, it lands on a level 3 table but thinks it is already on a level 1 table. So while the next entry points to a level 2 table, the CPU thinks that it points to the mapped frame, which allows us to read and write the level 2 table.

Accessing the tables of levels 3 and 4 works in the same way. To access the level 3 table, we follow the recursive entry three times, tricking the CPU into thinking it is already on a level 1 table. Then we follow another entry and reach a level 3 table, which the CPU treats as a mapped frame. For accessing the level 4 table itself, we just follow the recursive entry four times until the CPU treats the level 4 table itself as the mapped frame (in blue in the graphic below).

The same 4-level page hierarchy with the following 3 arrows: "Step 0" from CR4 to level 4 table, "Steps 1,2,3" from level 4 table to level 4 table, and "Step 4" from level 4 table to level 3 table. In blue, the alternative "Steps 1,2,3,4" arrow from level 4 table to level 4 table.

It might take some time to wrap your head around the concept, but it works quite well in practice.

In the section below, we explain how to construct virtual addresses for following the recursive entry one or multiple times. We will not use recursive paging for our implementation, so you don’t need to read it to continue with the post. If it interests you, just click on “Address Calculation” to expand it.


Address Calculation

We saw that we can access tables of all levels by following the recursive entry once or multiple times before the actual translation. Since the indexes into the tables of the four levels are derived directly from the virtual address, we need to construct special virtual addresses for this technique. Remember, the page table indexes are derived from the address in the following way: Bits 0–12 are the page offset, bits 12–21 the level 1 index, bits 21–30 the level 2 index, bits 30–39 the level 3 index, and bits 39–48 the level 4 index Let’s assume that we want to access the level 1 page table that maps a specific page. As we learned above, this means that we have to follow the recursive entry once before continuing with the level 4, level 3, and level 2 indexes. To do that, we move each block of the address one block to the right and set the original level 4 index to the index of the recursive entry: Bits 0–12 are the offset into the level 1 table frame, bits 12–21 the level 2 index, bits 21–30 the level 3 index, bits 30–39 the level 4 index, and bits 39–48 the index of the recursive entry For accessing the level 2 table of that page, we move each index block two blocks to the right and set both the blocks of the original level 4 index and the original level 3 index to the index of the recursive entry: Bits 0–12 are the offset into the level 2 table frame, bits 12–21 the level 3 index, bits 21–30 the level 4 index, and bits 30–39 and bits 39–48 are the index of the recursive entry Accessing the level 3 table works by moving each block three blocks to the right and using the recursive index for the original level 4, level 3, and level 2 address blocks: Bits 0–12 are the offset into the level 3 table frame, bits 12–21 the level 4 index, and bits 21–30, bits 30–39 and bits 39–48 are the index of the recursive entry Finally, we can access the level 4 table by moving each block four blocks to the right and using the recursive index for all address blocks except for the offset: Bits 0–12 are the offset into the level l table frame and bits 12–21, bits 21–30, bits 30–39, and bits 39–48 are the index of the recursive entry We can now calculate virtual addresses for the page tables of all four levels. We can even calculate an address that points exactly to a specific page table entry by multiplying its index by 8, the size of a page table entry. The table below summarizes the address structure for accessing the different kinds of frames: Virtual Address for | Address Structure (octal) —————————- | ———————————————- Page | 0o_SSSSSS_AAA_BBB_CCC_DDD_EEEE Level 1 Table Entry | 0o_SSSSSS_RRR_AAA_BBB_CCC_DDDD Level 2 Table Entry | 0o_SSSSSS_RRR_RRR_AAA_BBB_CCCC Level 3 Table Entry | 0o_SSSSSS_RRR_RRR_RRR_AAA_BBBB Level 4 Table Entry | 0o_SSSSSS_RRR_RRR_RRR_RRR_AAAA

Whereas AAA is the level 4 index, BBB the level 3 index, CCC the level 2 index, and DDD the level 1 index of the mapped frame, and EEEE the offset into it. RRR is the index of the recursive entry. When an index (three digits) is transformed to an offset (four digits), it is done by multiplying it by 8 (the size of a page table entry). With this offset, the resulting address directly points to the respective page table entry. SSSSSS are sign extension bits, which means that they are all copies of bit 47. This is a special requirement for valid addresses on the x86_64 architecture. We explained it in the previous post.

We use octal numbers for representing the addresses since each octal character represents three bits, which allows us to clearly separate the 9-bit indexes of the different page table levels. This isn’t possible with the hexadecimal system, where each character represents four bits.

In Rust Code

To construct such addresses in Rust code, you can use bitwise operations: rust // the virtual address whose corresponding page tables you want to access let addr: usize = […]; let r = 0o777; // recursive index let sign = 0o177777 << 48; // sign extension // retrieve the page table indices of the address that we want to translate let l4_idx = (addr >> 39) & 0o777; // level 4 index let l3_idx = (addr >> 30) & 0o777; // level 3 index let l2_idx = (addr >> 21) & 0o777; // level 2 index let l1_idx = (addr >> 12) & 0o777; // level 1 index let page_offset = addr & 0o7777; // calculate the table addresses let level_4_table_addr = sign | (r << 39) | (r << 30) | (r << 21) | (r << 12); let level_3_table_addr = sign | (r << 39) | (r << 30) | (r << 21) | (l4_idx << 12); let level_2_table_addr = sign | (r << 39) | (r << 30) | (l4_idx << 21) | (l3_idx << 12); let level_1_table_addr = sign | (r << 39) | (l4_idx << 30) | (l3_idx << 21) | (l2_idx << 12); The above code assumes that the last level 4 entry with index 0o777 (511) is recursively mapped. This isn’t the case currently, so the code won’t work yet. See below on how to tell the bootloader to set up the recursive mapping. Alternatively to performing the bitwise operations by hand, you can use the RecursivePageTable type of the x86_64 crate, which provides safe abstractions for various page table operations. For example, the code below shows how to translate a virtual address to its mapped physical address: RecursivePageTable: <https://docs.rs/x86_64/0.14.2/x86_64/structures/paging/mapper/struct.RecursivePageTable.html> rust // in src/memory.rs use x86_64::structures::paging::{Mapper, Page, PageTable, RecursivePageTable}; use x86_64::{VirtAddr, PhysAddr}; /// Creates a RecursivePageTable instance from the level 4 address. let level_4_table_addr = […]; let level_4_table_ptr = level_4_table_addr as *mut PageTable; let recursive_page_table = unsafe { let level_4_table = &mut *level_4_table_ptr; RecursivePageTable::new(level_4_table).unwrap(); } /// Retrieve the physical address for the given virtual address let addr: u64 = […] let addr = VirtAddr::new(addr); let page: Page = Page::containing_address(addr); // perform the translation let frame = recursive_page_table.translate_page(page); frame.map(|frame| frame.start_address() + u64::from(addr.page_offset())) Again, a valid recursive mapping is required for this code. With such a mapping, the missing level_4_table_addr can be calculated as in the first code example.


Recursive Paging is an interesting technique that shows how powerful a single mapping in a page table can be. It is relatively easy to implement and only requires a minimal amount of setup (just a single recursive entry), so it’s a good choice for first experiments with paging.

However, it also has some disadvantages:

  • It occupies a large amount of virtual memory (512 GiB). This isn’t a big problem in the large 48-bit address space, but it might lead to suboptimal cache behavior.
  • It only allows accessing the currently active address space easily. Accessing other address spaces is still possible by changing the recursive entry, but a temporary mapping is required for switching back. We described how to do this in the (outdated) Remap The Kernel post.
  • It heavily relies on the page table format of x86 and might not work on other architectures.

Bootloader Support

All of these approaches require page table modifications for their setup. For example, mappings for the physical memory need to be created or an entry of the level 4 table needs to be mapped recursively. The problem is that we can’t create these required mappings without an existing way to access the page tables.

This means that we need the help of the bootloader, which creates the page tables that our kernel runs on. The bootloader has access to the page tables, so it can create any mappings that we need. In its current implementation, the bootloader crate has support for two of the above approaches, controlled through cargo features:

  • The map_physical_memory feature maps the complete physical memory somewhere into the virtual address space. Thus, the kernel has access to all physical memory and can follow the Map the Complete Physical Memory approach.
  • With the recursive_page_table feature, the bootloader maps an entry of the level 4 page table recursively. This allows the kernel to access the page tables as described in the Recursive Page Tables section.

We choose the first approach for our kernel since it is simple, platform-independent, and more powerful (it also allows access to non-page-table-frames). To enable the required bootloader support, we add the map_physical_memory feature to our bootloader dependency:

1. [dependencies]
2. bootloader = { version = "0.9.8", features = ["map_physical_memory"]}

With this feature enabled, the bootloader maps the complete physical memory to some unused virtual address range. To communicate the virtual address range to our kernel, the bootloader passes a boot information structure.

Boot Information

The bootloader crate defines a BootInfo struct that contains all the information it passes to our kernel. The struct is still in an early stage, so expect some breakage when updating to future semver-incompatible bootloader versions. With the map_physical_memory feature enabled, it currently has the two fields memory_map and physical_memory_offset:

  • The memory_map field contains an overview of the available physical memory. This tells our kernel how much physical memory is available in the system and which memory regions are reserved for devices such as the VGA hardware. The memory map can be queried from the BIOS or UEFI firmware, but only very early in the boot process. For this reason, it must be provided by the bootloader because there is no way for the kernel to retrieve it later. We will need the memory map later in this post.
  • The physical_memory_offset tells us the virtual start address of the physical memory mapping. By adding this offset to a physical address, we get the corresponding virtual address. This allows us to access arbitrary physical memory from our kernel.

The bootloader passes the BootInfo struct to our kernel in the form of a &'static BootInfo argument to our _start function. We don’t have this argument declared in our function yet, so let’s add it:

1. // in src/main.rs

3. use bootloader::BootInfo;

5. #[no_mangle]
6. pub extern "C" fn _start(boot_info: &'static BootInfo) -> ! { // new argument
7. […]
8. }

It wasn’t a problem to leave off this argument before because the x86_64 calling convention passes the first argument in a CPU register. Thus, the argument is simply ignored when it isn’t declared. However, it would be a problem if we accidentally used a wrong argument type, since the compiler doesn’t know the correct type signature of our entry point function.

The entry_point Macro

Since our _start function is called externally from the bootloader, no checking of our function signature occurs. This means that we could let it take arbitrary arguments without any compilation errors, but it would fail or cause undefined behavior at runtime.

To make sure that the entry point function always has the correct signature that the bootloader expects, the bootloader crate provides an entry_point macro that provides a type-checked way to define a Rust function as the entry point. Let’s rewrite our entry point function to use this macro:

1. // in src/main.rs

3. use bootloader::{BootInfo, entry_point};

5. entry_point!(kernel_main);

7. fn kernel_main(boot_info: &'static BootInfo) -> ! {
8. […]
9. }

We no longer need to use extern "C" or no_mangle for our entry point, as the macro defines the real lower level _start entry point for us. The kernel_main function is now a completely normal Rust function, so we can choose an arbitrary name for it. The important thing is that it is type-checked so that a compilation error occurs when we use a wrong function signature, for example by adding an argument or changing the argument type.

Let’s perform the same change in our lib.rs:

1. // in src/lib.rs

3. #[cfg(test)]
4. use bootloader::{entry_point, BootInfo};

6. #[cfg(test)]
7. entry_point!(test_kernel_main);

9. /// Entry point for `cargo test`
10. #[cfg(test)]
11. fn test_kernel_main(_boot_info: &'static BootInfo) -> ! {
12. // like before
13. init();
14. test_main();
15. hlt_loop();
16. }

Since the entry point is only used in test mode, we add the #[cfg(test)] attribute to all items. We give our test entry point the distinct name test_kernel_main to avoid confusion with the kernel_main of our main.rs. We don’t use the BootInfo parameter for now, so we prefix the parameter name with a _ to silence the unused variable warning.

Implementation

Now that we have access to physical memory, we can finally start to implement our page table code. First, we will take a look at the currently active page tables that our kernel runs on. In the second step, we will create a translation function that returns the physical address that a given virtual address is mapped to. As a last step, we will try to modify the page tables in order to create a new mapping.

Before we begin, we create a new memory module for our code:

1. // in src/lib.rs

3. pub mod memory;

For the module, we create an empty src/memory.rs file.

Accessing the Page Tables

At the end of the previous post, we tried to take a look at the page tables our kernel runs on, but failed since we couldn’t access the physical frame that the CR3 register points to. We’re now able to continue from there by creating an active_level_4_table function that returns a reference to the active level 4 page table:

1. // in src/memory.rs

3. use x86_64::{
4. structures::paging::PageTable,
5. VirtAddr,
6. };

8. /// Returns a mutable reference to the active level 4 table.
9. ///
10. /// This function is unsafe because the caller must guarantee that the
11. /// complete physical memory is mapped to virtual memory at the passed
12. /// `physical_memory_offset`. Also, this function must be only called once
13. /// to avoid aliasing `&mut` references (which is undefined behavior).
14. pub unsafe fn active_level_4_table(physical_memory_offset: VirtAddr)
15. -> &'static mut PageTable
16. {
17. use x86_64::registers::control::Cr3;

19. let (level_4_table_frame, _) = Cr3::read();

21. let phys = level_4_table_frame.start_address();
22. let virt = physical_memory_offset + phys.as_u64();
23. let page_table_ptr: *mut PageTable = virt.as_mut_ptr();

25. &mut *page_table_ptr // unsafe
26. }

First, we read the physical frame of the active level 4 table from the CR3 register. We then take its physical start address, convert it to a u64, and add it to physical_memory_offset to get the virtual address where the page table frame is mapped. Finally, we convert the virtual address to a *mut PageTable raw pointer through the as_mut_ptr method and then unsafely create a &mut PageTable reference from it. We create a &mut reference instead of a & reference because we will mutate the page tables later in this post.

We don’t need to use an unsafe block here because Rust treats the complete body of an unsafe fn like a large unsafe block. This makes our code more dangerous since we could accidentally introduce an unsafe operation in previous lines without noticing. It also makes it much more difficult to spot unsafe operations in between safe operations. There is an RFC to change this behavior.

We can now use this function to print the entries of the level 4 table:

1. // in src/main.rs

3. fn kernel_main(boot_info: &'static BootInfo) -> ! {
4. use blog_os::memory::active_level_4_table;
5. use x86_64::VirtAddr;

7. println!("Hello World{}", "!");
8. blog_os::init();

10. let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);
11. let l4_table = unsafe { active_level_4_table(phys_mem_offset) };

13. for (i, entry) in l4_table.iter().enumerate() {
14. if !entry.is_unused() {
15. println!("L4 Entry {}: {:?}", i, entry);
16. }
17. }

19. // as before
20. #[cfg(test)]
21. test_main();

23. println!("It did not crash!");
24. blog_os::hlt_loop();
25. }

First, we convert the physical_memory_offset of the BootInfo struct to a VirtAddr and pass it to the active_level_4_table function. We then use the iter function to iterate over the page table entries and the enumerate combinator to additionally add an index i to each element. We only print non-empty entries because all 512 entries wouldn’t fit on the screen.

When we run it, we see the following output:

QEMU printing entry 0 (0x2000, PRESENT, WRITABLE, ACCESSED), entry 1 (0x894000, PRESENT, WRITABLE, ACCESSED, DIRTY), entry 31 (0x88e000, PRESENT, WRITABLE, ACCESSED, DIRTY), entry 175 (0x891000, PRESENT, WRITABLE, ACCESSED, DIRTY), and entry 504 (0x897000, PRESENT, WRITABLE, ACCESSED, DIRTY)

We see that there are various non-empty entries, which all map to different level 3 tables. There are so many regions because kernel code, kernel stack, physical memory mapping, and boot information all use separate memory areas.

To traverse the page tables further and take a look at a level 3 table, we can take the mapped frame of an entry and convert it to a virtual address again:

1. // in the `for` loop in src/main.rs

3. use x86_64::structures::paging::PageTable;

5. if !entry.is_unused() {
6. println!("L4 Entry {}: {:?}", i, entry);

8. // get the physical address from the entry and convert it
9. let phys = entry.frame().unwrap().start_address();
10. let virt = phys.as_u64() + boot_info.physical_memory_offset;
11. let ptr = VirtAddr::new(virt).as_mut_ptr();
12. let l3_table: &PageTable = unsafe { &*ptr };

14. // print non-empty entries of the level 3 table
15. for (i, entry) in l3_table.iter().enumerate() {
16. if !entry.is_unused() {
17. println!("  L3 Entry {}: {:?}", i, entry);
18. }
19. }
20. }

For looking at the level 2 and level 1 tables, we repeat that process for the level 3 and level 2 entries. As you can imagine, this gets very verbose very quickly, so we don’t show the full code here.

Traversing the page tables manually is interesting because it helps to understand how the CPU performs the translation. However, most of the time, we are only interested in the mapped physical address for a given virtual address, so let’s create a function for that.

Translating Addresses

To translate a virtual to a physical address, we have to traverse the four-level page table until we reach the mapped frame. Let’s create a function that performs this translation:

1. // in src/memory.rs

3. use x86_64::PhysAddr;

5. /// Translates the given virtual address to the mapped physical address, or
6. /// `None` if the address is not mapped.
7. ///
8. /// This function is unsafe because the caller must guarantee that the
9. /// complete physical memory is mapped to virtual memory at the passed
10. /// `physical_memory_offset`.
11. pub unsafe fn translate_addr(addr: VirtAddr, physical_memory_offset: VirtAddr)
12. -> Option<PhysAddr>
13. {
14. translate_addr_inner(addr, physical_memory_offset)
15. }

We forward the function to a safe translate_addr_inner function to limit the scope of unsafe. As we noted above, Rust treats the complete body of an unsafe fn like a large unsafe block. By calling into a private safe function, we make each unsafe operation explicit again.

The private inner function contains the real implementation:

1. // in src/memory.rs

3. /// Private function that is called by `translate_addr`.
4. ///
5. /// This function is safe to limit the scope of `unsafe` because Rust treats
6. /// the whole body of unsafe functions as an unsafe block. This function must
7. /// only be reachable through `unsafe fn` from outside of this module.
8. fn translate_addr_inner(addr: VirtAddr, physical_memory_offset: VirtAddr)
9. -> Option<PhysAddr>
10. {
11. use x86_64::structures::paging::page_table::FrameError;
12. use x86_64::registers::control::Cr3;

14. // read the active level 4 frame from the CR3 register
15. let (level_4_table_frame, _) = Cr3::read();

17. let table_indexes = [
18. addr.p4_index(), addr.p3_index(), addr.p2_index(), addr.p1_index()
19. ];
20. let mut frame = level_4_table_frame;

22. // traverse the multi-level page table
23. for &index in &table_indexes {
24. // convert the frame into a page table reference
25. let virt = physical_memory_offset + frame.start_address().as_u64();
26. let table_ptr: *const PageTable = virt.as_ptr();
27. let table = unsafe {&*table_ptr};

29. // read the page table entry and update `frame`
30. let entry = &table[index];
31. frame = match entry.frame() {
32. Ok(frame) => frame,
33. Err(FrameError::FrameNotPresent) => return None,
34. Err(FrameError::HugeFrame) => panic!("huge pages not supported"),
35. };
36. }

38. // calculate the physical address by adding the page offset
39. Some(frame.start_address() + u64::from(addr.page_offset()))
40. }

Instead of reusing our active_level_4_table function, we read the level 4 frame from the CR3 register again. We do this because it simplifies this prototype implementation. Don’t worry, we will create a better solution in a moment.

The VirtAddr struct already provides methods to compute the indexes into the page tables of the four levels. We store these indexes in a small array because it allows us to traverse the page tables using a for loop. Outside of the loop, we remember the last visited frame to calculate the physical address later. The frame points to page table frames while iterating and to the mapped frame after the last iteration, i.e., after following the level 1 entry.

Inside the loop, we again use the physical_memory_offset to convert the frame into a page table reference. We then read the entry of the current page table and use the PageTableEntry::frame function to retrieve the mapped frame. If the entry is not mapped to a frame, we return None. If the entry maps a huge 2 MiB or 1 GiB page, we panic for now.

Let’s test our translation function by translating some addresses:

1. // in src/main.rs

3. fn kernel_main(boot_info: &'static BootInfo) -> ! {
4. // new import
5. use blog_os::memory::translate_addr;

7. […] // hello world and blog_os::init

9. let phys_mem_offset = VirtAddr::new(boot_info.physical_memory_offset);

11. let addresses = [
12. // the identity-mapped vga buffer page
13. 0xb8000,
14. // some code page
15. 0x201008,
16. // some stack page
17. 0x0100_0020_1a10,
18. // virtual address mapped to physical address 0
19. boot_info.physical_memory_offset,
20. ];

22. for &address in &addresses {
23. let virt = VirtAddr::new(address);
24. let phys = unsafe { translate_addr(virt, phys_mem_offset) };
25. println!("{:?} -> {:?}", virt, phys);
26. }

28. […] // test_main(), "it did not crash" printing, and hlt_loop()
29. }

When we run it, we see the following output:

0xb8000 -> 0xb8000, 0x201008 -> 0x401008, 0x10000201a10 -> 0x279a10, "panicked at 'huge pages not supported'

As expected, the identity-mapped address 0xb8000 translates to the same physical address. The code page and the stack page translate to some arbitrary physical addresses, which depend on how the bootloader created the initial mapping for our kernel. It’s worth noting that the last 12 bits always stay the same after translation, which makes sense because these bits are the page offset and not part of the translation.

Since each physical address can be accessed by adding the physical_memory_offset, the translation of the physical_memory_offset address itself should point to physical address 0. However, the translation fails because the mapping uses huge pages for efficiency, which is not supported in our implementation yet.

Using OffsetPageTable

Translating virtual to physical addresses is a common task in an OS kernel, therefore the x86_64 crate provides an abstraction for it. The implementation already supports huge pages and several other page table functions apart from translate_addr, so we will use it in the following instead of adding huge page support to our own implementation.

At the basis of the abstraction are two traits that define various page table mapping functions:

  • The Mapper trait is generic over the page size and provides functions that operate on pages. Examples are translate_page, which translates a given page to a frame of the same size, and map_to, which creates a new mapping in the page table.
  • The Translate trait provides functions that work with multiple page sizes, such as translate_addr or the general translate.

The traits only define the interface, they don’t provide any implementation. The x86_64 crate currently provides three types that implement the traits with different requirements. The OffsetPageTable type assumes that the complete physical memory is mapped to the virtual address space at some offset. The MappedPageTable is a bit more flexible: It only requires that each page table frame is mapped to the virtual address space at a calculable address. Finally, the RecursivePageTable type can be used to access page table frames through recursive page tables.

In our case, the bootloader maps the complete physical memory at a virtual address specified by the physical_memory_offset variable, so we can use the OffsetPageTable type. To initialize it, we create a new init function in our memory module:

1. use x86_64::structures::paging::OffsetPageTable;

3. /// Initialize a new OffsetPageTable.
4. ///
5. /// This function is unsafe because the caller must guarantee that the
6. /// complete physical memory is mapped to virtual memory at the passed
7. /// `physical_memory_offset`. Also, this function must be only called once
8. /// to avoid aliasing `&mut` references (which is undefined behavior).
9. pub unsafe fn init(physical_memory_offset: VirtAddr) -> OffsetPageTable<'static> {
10. let level_4_table = active_level_4_table(physical_memory_offset);
11. OffsetPageTable::new(level_4_table, physical_memory_offset)
12. }

14. // make private
15. unsafe fn active_level_4_table(physical_memory_offset: VirtAddr)
16. -> &'static mut PageTable
17. {…}

The function takes the physical_memory_offset as an argument and returns a new OffsetPageTable instance with a 'static lifetime. This means that the instance stays valid for the complete runtime of our kernel. In the function body, we first call the active_level_4_table function to retrieve a mutable reference to the level 4 page table. We then invoke the OffsetPageTable::new function with this reference. As the second parameter, the new function expects the virtual address at which the mapping of the physical memory starts, which is given in the physical_memory_offset variable.

The active_level_4_table function should only be called from the init function from now on because it can easily lead to alias

(本文超出站点单文档体积上限,已截断)