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

xv6 Series: One Physical Page, Two Virtual Addresses: Why xv6's Kernel Doesn't Map TRAPFRAME

If you look at xv6’s kernel memory layout (Cox, Kaashoek, & Morris, 2025), you’ll notice the trapframe is not there. Why?

Check memlayout.h and you’ll find TRAPFRAME defined as TRAMPOLINE - PGSIZE, but if you trace through kvmmake(), which is the function that builds the kernel page table, you’d see that there’s no mapping for TRAPFRAME. Yet the kernel clearly needs to access the trapframe during traps (if you don’t know why, then you’ll need to read up on traps and syscalls, the xv6 book covers that and I might write a blog post on this as well). So, what’s going on?

In short, it’s because of a fundamental concept about virtual memory: a single physical page can have multiple virtual addresses, and different page tables can map different VAs to the same PA. This is what the xv6 kernel leverages and why the kernel page table does not need to map the TRAPFRAME (well, it could but that’d would be redundant as you’ll see later in this post).

I mean everybody knows that fact if you have learned about virtual memory and how it works, but it’s kind of easy to miss this if you don’t stop and question things as you read through the xv6 kernel codebase.

Background: What Happens When You Dereference a Pointer

This is an assumed knowledge but just as a quick refresher, let’s briefly go over it, why not.

When the CPU executes code that dereferences a pointer, it takes the virtual address stored in that pointer and translates it to a physical address using the currently active page table. The currently active page table is determined by the satp CSR (Control and Status Register). Whatever page table satp points to is what the CPU uses for address translation.

This means the same pointer value can translate to different physical addresses (or fault entirely) depending on which page table is active. Take note of this as this is an important fact that makes everything else make sense.

Again, as you can see, many things that we already know are often the explanation to interesting (and often confusing) questions that we have. Deriving things from first principles or what we already know will usually lead to the answer. However, I think the challenge often lies on the ability to ask the right questions, which comes by experience, meaning it’s a skill built through pattern recognition, as the right questions come from having seen things break (maybe more on this in a future post; let’s not context switch too much).

The Kernel Page Table and Identity Mapping

At boot, kvminit() calls kvmmake() to construct the kernel’s page table. The relevant mappings are:

// map kernel text executable and read-only.
kvmmap(kpgtbl, KERNBASE, KERNBASE, (uint64)etext - KERNBASE, PTE_R | PTE_X);

// map kernel data and the physical RAM we'll make use of.
kvmmap(kpgtbl, (uint64)etext, (uint64)etext, PHYSTOP - (uint64)etext, PTE_R | PTE_W);

Notice that the VA argument equals the PA argument. This is called identity mapping (or direct mapping). For the entire physical RAM region from KERNBASE (0x80000000) to PHYSTOP (0x88000000), the kernel page table maps VA X to PA X.

After kvmmake() returns, kvminithart() loads this page table into satp. From this point on, whenever the kernel is running, any VA in the range [KERNBASE, PHYSTOP) translates to the same PA. Remember this fact.

What kalloc() Returns

kalloc() allocates a 4096-byte page from the free memory pool. This pool consists of physical pages between end (where kernel code/data ends) and PHYSTOP. When kalloc() returns a pointer, that pointer is a virtual address, but because of identity mapping, for the kernel (or more precisely: for the kernel’s page table) that VA equals the PA of the allocated page.

For example, if kalloc() allocates a page at physical address 0x80456000, it returns the pointer value 0x80456000. When the kernel dereferences this pointer, the kernel page table translates VA 0x80456000 to PA 0x80456000. Identity mapping makes the VA and PA numerically identical.

Trapframe Allocation

In allocproc(), a trapframe is allocated for each new process:

if ((p->trapframe = (struct trapframe *)kalloc()) == 0) {
  // error handling
}

Suppose kalloc() returns 0x80456000. Now p->trapframe holds the value 0x80456000. This is a kernel VA that, due to identity mapping, corresponds to PA 0x80456000.

Later, in proc_pagetable(), that same physical page is mapped into the user’s page table:

if (mappages(pagetable, TRAPFRAME, PGSIZE,
             (uint64)(p->trapframe), PTE_R | PTE_W) < 0) {
  // error handling
}

This maps VA TRAPFRAME (a high virtual address like 0x3FFFFFE000) to PA (uint64)(p->trapframe) (which is 0x80456000).

Now the same physical page has two virtual addresses:

Context Virtual Address Physical Address
Kernel (via p->trapframe) 0x80456000 0x80456000
User page table (via TRAPFRAME) 0x3FFFFFE000 0x80456000

One physical page. Two virtual addresses. Different page tables map different VAs to reach the same physical memory.

How Each Context Accesses the Trapframe

Kernel code runs with satp pointing to kernel_pagetable. When the kernel executes p->trapframe->epc = sepc, it dereferences the pointer value 0x80456000. The kernel page table translates this: VA 0x80456000 → PA 0x80456000. The kernel never uses the VA TRAPFRAME; that constant is irrelevant to the kernel.

Trampoline code is trickier. In uservec (in trampoline.S), when this code runs, it runs while the currently active page table is still the user’s page table, i.e.: the trap has occurred but satp hasn’t been switched yet. So, the trampoline accesses the trapframe via the VA TRAPFRAME. The user’s page table then maps TRAPFRAME → PA 0x80456000. After saving registers, uservec switches satp to the kernel page table.

Why the Kernel Doesn’t Need TRAPFRAME Mapped

Finally we can get to the answer for our question: why the kernel doesn’t need TRAPFRAME mapped?

The kernel doesn’t need TRAPFRAME mapped because it has another route to the same destination: p->trapframe, an identity-mapped VA. Mapping TRAPFRAME in the kernel would be redundant because that would mean you’d have two VAs pointing to the same PA, but the kernel already has a perfectly good VA. Why would you do that, right?

Thus, the TRAPFRAME VA exists solely for trampoline.S code that must run while the user page table is still active as we have discussed earlier. That code cannot use p->trapframe because:

  1. It doesn’t know which process is running and that’s because it hasn’t reached C code yet, so it has no access to p
  2. Plus, even if it did, identity-mapped kernel VAs aren’t mapped in the user page table

In other words, the trampoline needs a VA that works in the user’s page table. That’s what TRAPFRAME provides.

A Common Misconception: “Kernel Memory” vs “User Memory”

This is a good place to address a related confusion. When kalloc() allocates a page for the trapframe, is it “stealing from kernel memory”? Does the user process get memory from a different pool?

No. This framing conflates two distinct concepts: physical memory allocation and virtual address space mappings.

Physical RAM in xv6 is a single, undifferentiated pool of pages. The physical address range from end to PHYSTOP contains all free physical pages. kalloc() is the sole allocator for this pool. There is no “kernel heap” vs “user heap” at the physical level. When you call kalloc(), you get a physical page. The page has no inherent “owner”.

Everything that needs a physical page goes through kalloc():

Allocated For Who Requests It
Kernel page table kvmmake()
User page tables proc_pagetable(), uvmalloc()
Kernel stacks proc_mapstacks()
Trapframes allocproc()
User code/data/heap uvmalloc(), exec()

All from the same pool.

The illusion of “kernel memory” vs “user memory” is created entirely by virtual address mappings. A page table defines which virtual addresses map to which physical addresses, and with what permissions. The separation is enforced by:

  1. Different page tables. The kernel has kernel_pagetable. Each user process has its own page table. When satp changes, the entire virtual address space changes.

  2. Permission bits. Each PTE has PTE_U (user-accessible). Pages without this bit can only be accessed in supervisor mode.

  3. Non-overlapping VA ranges. The kernel uses high VAs; user processes use low VAs. However, this is just a convention, not hardware requirement.

When we say “this page belongs to user process P…” we mean the page is mapped in P’s page table and the kernel tracks it as part of P’s address space. And when we say “this page belongs to the kernel…” we mean it’s mapped in the kernel page table and not user-accessible. But physically, at the end of the day, all pages sit in the same RAM, obviously.

Summary

When allocproc() creates a new process:

  1. kalloc() allocates a physical page at PA X
  2. kalloc() returns X as a VA (identity mapping: VA X → PA X)
  3. p->trapframe stores X
  4. proc_pagetable() maps TRAPFRAME → X in the user’s page table

The physical page at X is now accessible via two routes:

  • The kernel uses VA X (dereferencing p->trapframe)
  • The trampoline uses VA TRAPFRAME (while user page table is active)

Both reach the same physical bytes. The kernel doesn’t map TRAPFRAME because it doesn’t need to since identity mapping already provides access to every physical page in RAM.

The statement “trapframe is not mapped in the kernel page table” means the VA constant TRAPFRAME is not mapped in the kernel page table. But the physical trapframe page is accessible to the kernel via a different VA: the identity-mapped address returned by kalloc() and stored in p->trapframe.

This design emerges from the following two facts:

  1. The kernel identity-maps all of RAM, so it can access any physical page via VA == PA
  2. The trampoline code runs with the user page table active, so it needs a VA that works in that context

Now we have understood what we mean from the phrase “One Physical Page, Two Virtual Addresses” in the title of this blog: it means the trapframe is one physical page (say, at PA 0x80456000) in physical RAM. That single physical page is accessible via two different virtual addresses:

  1. VA 0x80456000: used by the kernel (via p->trapframe), works because of identity mapping
  2. VA TRAPFRAME (0x3FFFFFE000): mapped in the user’s page table, used by trampoline code

So basically same physical bytes, but two ways to reach these bytes. To end this post, the main idea explaining why the kernel doesn’t need TRAPFRAME mapped is because it already has a route to that physical page through a different VA.

References

Cox, R., Kaashoek, F., & Morris, R. (2025). xv6: a simple, Unix-like teaching operating system. https://pdos.csail.mit.edu/6.1810/2025/xv6/book-riscv-rev5.pdf

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