Fadli Arsani
↑↓ to navigate · Enter to open · Esc to close

xv6 Series: Why xv6's Trampoline Must Be Mapped Identically in Both Page Tables

In a previous post, I explained why xv6’s kernel page table does not map the TRAPFRAME virtual address. The kernel accesses each process’s trapframe through p->trapframe, an identity-mapped pointer, making the TRAPFRAME VA redundant from the kernel’s perspective. The trapframe exhibits what I called “one physical page, two virtual addresses”: the kernel and user page tables use different VAs to reach the same physical page.

The trampoline has a similar property, but with the following difference: both page tables map the trampoline to the same VA. Given xv6’s design choice of separate page tables, this is not optional (as I will explain in this post); otherwise, the trampoline would not work as intended.

The Problem: Code Execution Across a Page Table Switch

In xv6, the trampoline is the code that handles the transition between user mode and kernel mode. It lives in trampoline.S and contains two entry points: uservec (user-to-kernel) and userret (kernel-to-user). When a trap occurs in user mode, the CPU jumps to uservec. When the kernel returns to user mode, it jumps to userret.

Because xv6 uses separate page tables for user and kernel, the trampoline code must switch page tables mid-execution. At the start of uservec, the user page table is active. By the end of uservec, the kernel page table must be active. If the trampoline’s VA changes across this switch, the CPU will fault on the very next instruction fetch. What do we mean by that?

Well, consider what happens when the CPU executes an instruction. The program counter (PC) holds a virtual address. The CPU fetches the instruction at that VA by translating it through the currently active page table (determined by satp). If the page table changes and the VA is no longer mapped, or maps to different physical memory, execution fails.

The trampoline switches satp with this instruction (from trampoline.S):

csrw satp, t1

Before this instruction, satp points to the user page table (because we’re entering from user space, so the currently active page table has to be the user page table). After, it points to the kernel page table. The instruction immediately following this csrw will be fetched using the kernel page table. If the kernel page table doesn’t map the trampoline at the same VA, the fetch faults.

The Solution: Map the Trampoline at the Same VA in Both Page Tables

xv6 maps the trampoline at VA TRAMPOLINE in both the kernel page table and every user page table. From memlayout.h:

#define TRAMPOLINE (MAXVA - PGSIZE)

MAXVA is (1L << (9 + 9 + 9 + 12 - 1)), which equals 0x4000000000 (256 GiB, the maximum virtual address in Sv39). Subtracting PGSIZE (4096) gives TRAMPOLINE = 0x3FFFFFF000.

In kvmmake(), the kernel page table is constructed:

// map the trampoline for trap entry/exit to
// the highest virtual address in the kernel.
kvmmap(kpgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);

The trampoline symbol is the physical address where trampoline.S is linked, somewhere in the kernel’s text segment, e.g., 0x80007000. This address is determined at link time: the linker reads kernel.ld, places the trampsec section at a page-aligned location within the .text segment, and assigns the trampoline label that address. At runtime, (uint64)trampoline evaluates to this link-time constant. This creates the mapping: VA TRAMPOLINE → PA trampoline.

In proc_pagetable(), each user page table gets the same mapping:

// map the trampoline code (for system call return)
// at the highest user virtual address.
// only the supervisor uses it, on the way
// to/from user space, so not PTE_U.
if (mappages(pagetable, TRAMPOLINE, PGSIZE, (uint64)trampoline,
             PTE_R | PTE_X) < 0) {
  // error handling
}

Same VA (TRAMPOLINE), same PA (trampoline), in both page tables. Therefore, when uservec switches from user to kernel page table, the PC still contains an address in the TRAMPOLINE range. Since both page tables map that VA to the same PA, the next instruction fetch succeeds.

Contrast with the Trapframe

The trapframe and trampoline both have the “one physical page accessible via two page tables” property, but the similarity ends there. To make this clearer, take a look at the following comparison table:

Property Trapframe Trampoline
VA in kernel page table p->trapframe (identity-mapped, e.g., 0x80456000) TRAMPOLINE (0x3FFFFFF000)
VA in user page table TRAPFRAME (0x3FFFFFE000) TRAMPOLINE (0x3FFFFFF000)
VAs match? No Yes
Why? Accessed after page table switch is complete Accessed during page table switch

The trapframe is data. It’s read and written by code that runs entirely under one page table at a time. In uservec, the trapframe is accessed while the user page table is active (using VA TRAPFRAME). In usertrap() and other kernel C code, the trapframe is accessed while the kernel page table is active (using VA p->trapframe). The page table switch happens between these accesses. The trapframe never needs to be accessible at the same VA in both contexts because no single instruction needs to access it across the switch.

The trampoline is code. The CPU’s instruction fetch mechanism continuously reads from it. The page table switch happens while the trampoline is executing. So, the VA must remain valid across the switch, which means both page tables must agree on where the trampoline lives.

Why Not Identity-Map the Trampoline?

The kernel page table identity-maps physical RAM from KERNBASE to PHYSTOP. The trampoline code resides in this region (it’s part of the kernel text). So, theoretically, the kernel could actually access the trampoline via its identity-mapped address (e.g., 0x80007000) as well (more on this in the next section).

But the user page table does not identity-map kernel memory. User processes have no mapping for addresses like 0x80007000. If the trampoline ran at its identity-mapped address, it would only work while the kernel page table is active. The moment we switch to the user page table (in userret), or try to enter the trampoline from user mode (in uservec), execution would fault.

The TRAMPOLINE VA solves this: it’s a high address that xv6 reserves specifically for this purpose, mapped in both page tables. The trampoline code is position-independent (it doesn’t rely on its own address), so it works correctly regardless of where it’s mapped in the virtual address space.

Two Routes to One Page in the Kernel Page Table

As I have mentioned in the previous section: theoretically, the kernel could actually access the trampoline via its identity-mapped address (e.g., 0x80007000) as well. This creates an interesting situation: the kernel page table has two virtual addresses that reach the same physical trampoline page.

The first route comes from identity mapping. In kvmmake():

kvmmap(kpgtbl, KERNBASE, KERNBASE, (uint64)etext - KERNBASE, PTE_R | PTE_X);

This maps all kernel text with VA == PA. Since the trampoline is part of kernel text (the linker places trampsec before etext), the physical address where trampoline.S lives, say 0x80007000, is accessible at VA 0x80007000.

The second route comes from the explicit TRAMPOLINE mapping that we have been discussing:

kvmmap(kpgtbl, TRAMPOLINE, (uint64)trampoline, PGSIZE, PTE_R | PTE_X);

This maps VA 0x3FFFFFF000 to the same physical page.

Both routes lead to the same physical bytes:

Route Virtual Address Physical Address
Identity mapping 0x80007000 0x80007000
TRAMPOLINE mapping 0x3FFFFFF000 0x80007000

From the kernel’s perspective, the TRAMPOLINE mapping is redundant as it could execute the trampoline code at its identity-mapped address. But that address doesn’t exist in user page tables. The TRAMPOLINE VA is what enables the page table switch to work: it’s the one address that both page tables agree on.

So to reiterate this duality (as it often confuses readers like me): remember that the user page table has only one route to the trampoline (the TRAMPOLINE VA). The kernel page table has two. This asymmetry is a direct consequence of identity mapping in the kernel and its absence in user space.

The Comment in trampoline.S

The xv6 source states this explicitly in the comments at the top of trampoline.S:

# the kernel maps the page holding this code
# at the same virtual address (TRAMPOLINE)
# in user and kernel space so that it continues
# to work when it switches page tables.

This comment basically describes xv6’s design constraint. Given that xv6 uses separate page tables, the trampoline must “continue to work when it switches page tables”. The only way to achieve this is to map it at the same VA in both (there is an alternative design that avoids this complexity entirely, which I’ll discuss in the next section).

An Alternative Design: Map the Kernel into User Page Tables

Everything I’ve described so far is how xv6 handles the user/kernel transition. But there’s another approach that many other operating systems use: map the entire kernel into every user page table.

If you mapped kernel memory into every user’s page table (with PTE_U clear so user code can’t access it), you wouldn’t need the trampoline at all. When a trap occurs, there’s no page table switch. The user page table already contains the kernel mappings, so the trap handler can just start executing kernel code directly. The kernel can even dereference user pointers directly since user memory is still mapped.

This design has a lot of benefits:

  • No trampoline complexity
  • No page table switch overhead on every syscall/interrupt
  • Kernel can directly access user memory without copyin/copyout

So why doesn’t xv6 do this? Two reasons, as mentioned in the xv6 book (section 4.6):

  1. Security. If the kernel accidentally dereferences a user pointer without validation, it just works, which is a bug. In xv6’s design, the kernel must explicitly use copyin/copyout to access user memory, making such bugs impossible (the kernel page table simply doesn’t map user addresses).

  2. Complexity of non-overlapping address spaces. If kernel and user share one page table, their virtual addresses must not overlap. The kernel would need to be careful about which VAs it uses. xv6 avoids this by giving kernel and user completely separate page tables.

Linux historically used the “map kernel into user page tables” approach for performance. Then Meltdown happened (Lipp et al., 2018), a hardware vulnerability that allowed user code to speculatively read kernel memory even with PTE_U clear. The mitigation (KPTI - Kernel Page Table Isolation) essentially moved Linux closer to xv6’s model: separate page tables for user and kernel, switched on every trap. The performance cost was significant.

xv6’s conservative design turned out to be accidentally robust against an entire class of hardware vulnerabilities.

Summary

The trampoline and trapframe both involve one physical page accessible through two page tables, but they differ in the following ways:

  • Trapframe: Different VAs in each page table (identity-mapped address in kernel, TRAPFRAME constant in user). This works because the trapframe is data accessed entirely before or after the page table switch.
  • Trampoline: Same VA (TRAMPOLINE) in both page tables. This is required because the trampoline is code that executes across the page table switch. The CPU’s PC must remain valid through the csrw satp instruction.

If we take some time to ponder what constraint drove xv6’s design, it actually stems from a very fundamental concept: instruction fetch uses the currently active page table. Given xv6’s choice to use separate page tables, when the page table changes mid-execution, the PC’s VA must still translate to the same physical instruction. The only way to guarantee this (without mapping the kernel into user page tables) is to map the trampoline code at the same VA in both page tables.

References

Cox, R., Kaashoek, F., & Morris, R. (n.d.). xv6-riscv. GitHub. https://github.com/mit-pdos/xv6-riscv

Lipp, M., Schwarz, M., Gruss, D., Prescher, T., Haas, W., Fogh, A., Horn, J., Mangard, S., Kocher, P., Genkin, D., Yarom, Y., & Hamburg, M. (2018). Meltdown: Reading Kernel Memory from User Space. 27th USENIX Security Symposium (USENIX Security 18), 973–990. https://www.usenix.org/conference/usenixsecurity18/presentation/lipp