xv6 Series: Page Faults as an Interception Mechanism & Lazy Allocation
What is a Page Fault?
Before we get into lazy allocation, we need to understand what a page fault is.
When the CPU executes a load or store instruction, it takes the virtual address and translates it through the currently active page table (determined by satp). If translation succeeds, the memory access proceeds. If translation fails, the CPU raises an exception called a page fault. Okay, so a page fault is an exception that the CPU raises when a translation fails. Great. Now we should ask: but when does a translation fails?
Translation can fail for two reasons:
-
The page table entry doesn’t exist or is invalid. If the CPU walks the page table and finds a PTE with
PTE_V = 0(the valid bit cleared), the translation fails immediately. A clearedPTE_Vmeans “this entry is not a valid mapping”. -
The access violates permissions. If the PTE exists with
PTE_V = 1but the access type doesn’t match the permissions (e.g., writing to a page withoutPTE_W), that also causes a page fault.
RISC-V distinguishes three types of page faults based on what caused them:
| scause | Type | Cause |
|---|---|---|
| 12 | Instruction page fault | CPU tried to fetch an instruction from an invalid/inaccessible address |
| 13 | Load page fault | A load instruction tried to read from an invalid/inaccessible address |
| 15 | Store page fault | A store instruction tried to write to an invalid/inaccessible address |
When a page fault occurs, the CPU sets scause to indicate the type and stval to the faulting virtual address. Then it traps to the kernel, just like any other exception.
So when you hear “intercept loads and stores,” this is basically what it means: the CPU stops the user program mid-instruction and transfers control to the kernel. The kernel can then examine what happened and decide what to do. Maybe it kills the process. Maybe it does something more interesting, like allocating memory on demand.
Background: PTE Flags
A quick note on PTE flags since they come up throughout this post.
A Page Table Entry (PTE) in RISC-V Sv39 is a 64-bit value. The upper bits store the physical page number, and the lower 10 bits store flags. xv6 defines these in kernel/riscv.h:1
#define PTE_V (1L << 0) // valid
#define PTE_R (1L << 1) // readable
#define PTE_W (1L << 2) // writable
#define PTE_X (1L << 3) // executable
#define PTE_U (1L << 4) // user can access
The PTE_V flag is very important for understanding page faults in the sense that the CPU doesn’t look at any other bits if PTE_V = 0. It just faults.
The Naive Approach: Eager Allocation
To understand why page faults are useful, let’s first look at what happens without them.
When a user program calls sbrk(n) to grow its heap, the naive approach is to immediately allocate physical memory and create page table mappings. This is what xv6’s growproc() does (Cox, Kaashoek, & Morris, 2025):
// From kernel/proc.c
int
growproc(int n)
{
uint64 sz;
struct proc *p = myproc();
sz = p->sz;
if(n > 0){
if(sz + n > TRAPFRAME){
return -1;
}
if((sz = uvmalloc(p->pagetable, sz, sz + n, PTE_W)) == 0) {
return -1;
}
}
// ...
p->sz = sz;
return 0;
}
And uvmalloc() in kernel/vm.c does the actual work:
uint64
uvmalloc(pagetable_t pagetable, uint64 oldsz, uint64 newsz, int xperm)
{
char *mem;
uint64 a;
if(newsz < oldsz)
return oldsz;
oldsz = PGROUNDUP(oldsz);
for(a = oldsz; a < newsz; a += PGSIZE){
mem = kalloc(); // allocate physical page NOW
if(mem == 0){
uvmdealloc(pagetable, a, oldsz);
return 0;
}
memset(mem, 0, PGSIZE);
if(mappages(pagetable, a, PGSIZE, (uint64)mem, PTE_R|PTE_U|xperm) != 0){
kfree(mem);
uvmdealloc(pagetable, a, oldsz);
return 0;
}
}
return newsz;
}
For every page in the requested range, uvmalloc() calls kalloc() to get a physical page and mappages() to create the PTE. After sbrk() returns, the memory is fully allocated and mapped.
This works, but it’s wasteful. A program might call sbrk(100 * 1024 * 1024) to request 100 MiB but only ever use 4 KiB of it.2 With eager allocation, you’ve just consumed 25,600 physical pages (100 MiB / 4 KiB) for no reason.
Lazy Allocation: Deferring the Work
The idea behind lazy allocation is simple: don’t allocate physical memory until the program actually uses it.
xv6 implements this in sys_sbrk(). Looking at kernel/sysproc.c:
uint64
sys_sbrk(void)
{
uint64 addr;
int t;
int n;
argint(0, &n);
argint(1, &t);
addr = myproc()->sz;
if(t == SBRK_EAGER || n < 0){
if(growproc(n) < 0){
return -1;
}
} else {
// Lazily allocate memory for this process: increase its memory
// size but don't allocate memory. If the processes uses the
// memory, vmfault() will allocate it.
if(addr + n < addr)
return -1;
if(addr + n > TRAPFRAME)
return -1;
myproc()->sz += n;
}
return addr;
}
With lazy allocation (when t != SBRK_EAGER), the kernel just increments p->sz and that’s it, i.e.: there’s no kalloc(), mappages(), etc. The user program gets back an address that looks valid, but there’s no physical memory behind it and no page table entry for it.
Notice that shrinking (n < 0) always uses the eager path via growproc(). Lazy allocation only applies when growing. But why though? Because when shrinking, you want to actually free memory immediately, not defer it.
Now, we should ask what happens when the program tries to use this lazily-allocated memory?
The Page Fault Handler
Suppose the user program does:
char *p = sbrk(4096); // returns, say, 0x4000
*p = 'X'; // tries to store to address 0x4000
When the CPU executes *p = 'X':
- CPU tries to translate VA
0x4000using the page table - But there’s no PTE for
0x4000because lazy allocation didn’t actually create one - CPU raises a store page fault (scause = 15) and sets stval =
0x4000 - CPU traps to the kernel via
uservec→usertrap()
In usertrap() at kernel/trap.c, there’s this check:
} else if((r_scause() == 15 || r_scause() == 13) &&
vmfault(p->pagetable, r_stval(), (r_scause() == 13) ? 1 : 0) != 0) {
// page fault on lazily-allocated page
}
The condition checks: is this a load (13) or store (15) page fault? If so, call vmfault(). Note that vmfault() here isn’t just a passive check; it’s doing the actual work3 (we’ll talk about this in the next section). When C evaluates this condition, it first calls vmfault(), which allocates memory and creates the PTE. Then it checks the return value. If vmfault() returns non-zero (success), the body of this else if is empty, so there’s nothing more to do for this case. usertrap() continues to the end of the function, eventually returning to user space via prepare_return() and userret. If vmfault() returns 0 (failure), the condition fails and we fall through to the else branch which prints an error and kills the process.
Note that vmfault() returns the physical address of the allocated page on success, and 0 on failure. So “non-zero” means success here. This is different from most xv6 functions which return 0 for success and -1 for failure. The reason for this convention will become clear when we look at copyin() later.
vmfault(): Allocating Memory on Demand
Now let’s take a look at the actual work that happens in vmfault() at kernel/vm.c:
uint64
vmfault(pagetable_t pagetable, uint64 va, int read)
{
uint64 mem;
struct proc *p = myproc();
if(va >= p->sz)
return 0;
va = PGROUNDDOWN(va);
if(ismapped(pagetable, va)){
return 0;
}
mem = (uint64)kalloc();
if(mem == 0)
return 0;
memset((void*)mem, 0, PGSIZE);
if(mappages(p->pagetable, va, PGSIZE, mem, PTE_W|PTE_U|PTE_R) != 0){
kfree((void*)mem);
return 0;
}
return mem;
}
Let’s trace through what happens:
-
va >= p->sz: Check if the faulting address is within the process’s declared size. Remember,sys_sbrk()incrementedp->szeven without allocating memory. If the program accesses an address beyondp->sz, that’s an invalid access, not a lazy allocation fault. -
ismapped(pagetable, va): Check if the page is already mapped. If it is, this isn’t a lazy allocation situation. Maybe it’s a permission violation or some other kind of fault we don’t handle here. -
kalloc(): Now we actually allocate a physical page. -
memset(): Zero the page. -
mappages(): Create the PTE, mapping VA0x4000to the newly allocated physical page with read, write, and user permissions.4
After vmfault() returns, usertrap() eventually returns to user space. When the CPU resumes execution, it retries the faulting instruction (*p = 'X').5 This time:
- CPU tries to translate VA
0x4000 - The PTE now exists (created by
vmfault()) PTE_Vis set,PTE_Wallows writes- Translation succeeds, store completes
The user program never knows any of this happened. It called sbrk(), got an address, wrote to it, and the write worked. The page fault and kernel intervention were invisible.
copyin() and copyout()
There’s a case that user-mode page faults don’t cover.
When the kernel needs to copy data to/from user space, it uses copyin() and copyout(). For example, fstat()6 copies a struct to user memory:
// Recall copyout signature:
// Copy len bytes from src to virtual address dstva in a given page table.
// Return 0 on success, -1 on error.
// int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len);
// From kernel/file.c - filestat()
if (copyout(p->pagetable, addr, (char *)&st, sizeof(st)) < 0)
return -1;
But recall that by the time we’re inside a syscall handler, satp points to the kernel page table. The kernel can’t just dereference user virtual addresses directly: they’d be translated through the kernel page table, which doesn’t map user memory.
So copyin()/copyout() take the user’s page table as a parameter and manually walk it with walkaddr() to translate VA → PA. Then they access physical memory directly. But if the user passed a pointer to lazily-allocated memory, there’s no PTE in the user’s page table. walkaddr() returns 0.
Since this isn’t a hardware page fault, i.e.: it’s just a software function returning “no mapping found”, copyin() and copyout() explicitly call vmfault() when this happens:
// From kernel/vm.c - copyin()
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0){
if((pa0 = vmfault(pagetable, va0, 0)) == 0){
return -1;
}
}
If walkaddr() returns 0, call vmfault() to allocate the page. This is why vmfault() returns the physical address on success: copyin() and copyout() need to know the PA to do the copy.
There’s an inconsistency here worth noting. copyinstr() (which copies a null-terminated string from user space) does NOT call vmfault(). If you look at the code:
// From kernel/vm.c - copyinstr()
pa0 = walkaddr(pagetable, va0);
if(pa0 == 0)
return -1;
It just returns -1 if the page isn’t mapped. So if a user passes a lazily-allocated string buffer to a syscall that uses copyinstr(), it will fail even though the address is technically valid. This is a gap in the lazy allocation implementation.
What the Kernel is Actually Doing Here
To recap what happens during a lazy allocation fault: before the fault, VA 0x4000 didn’t refer to any physical memory. There was no PTE, no backing page, nothing. After vmfault() returned, VA 0x4000 refers to a specific physical page (the one returned by kalloc()).
The kernel decided, at the moment of access, what physical memory that virtual address should map to. Before the fault, *p = 'X' would have been an invalid memory access. After the fault, *p = 'X' writes to a specific physical location. The kernel established that relationship on demand.
By deliberately leaving page table entries unmapped, the kernel arranges to be notified when the program first accesses certain memory regions. The page table becomes not just “what virtual addresses map where” but also “what should the kernel do when the program accesses this region”.
xv6 only uses this for lazy allocation. Real operating systems use the same mechanism for copy-on-write fork, demand paging from disk, memory-mapped files, and more. All of these follow the same pattern: leave the page table incomplete or restricted on purpose, handle the resulting fault, and fix up the page table to let the access succeed (or kill the process if it’s a genuine bug).
This is the same “lazy evaluation” pattern that shows up throughout programming: defer work until it’s actually needed. Lazy loading in web apps, lazy initialization in OOP, generators in Python, copy-on-write, etc. All hinges on the same idea. Basically the point is if you might never need the work at all, why do it upfront?
Summary
Page faults in xv6 serve as an interception mechanism. By deliberately leaving page table entries unmapped, the kernel arranges to be notified when the program first accesses certain memory regions.
For lazy allocation:
sys_sbrk()incrementsp->szwithout allocating memory or creating PTEs- When the program accesses the region, a page fault occurs
usertrap()catches the fault and callsvmfault()vmfault()allocates a physical page, creates the PTE- The program retries the instruction, which now succeeds
The kernel intercepts the access via the page fault, then specifies what it should mean by creating the mapping. This lets the kernel defer decisions until the moment they’re needed.
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
-
The
1L << nsyntax is a bit shift.1Lis the long integer1, and<< nshifts it left bynbit positions. So1L << 0=0b00001= 1,1L << 1=0b00010= 2,1L << 2=0b00100= 4, and so on. Each flag occupies a different bit position in the PTE. To set multiple flags, you OR them together:PTE_R | PTE_W | PTE_U=0b10110. To check if a flag is set, you AND the PTE with the flag:if (pte & PTE_V)is true if bit 0 is set. ↩︎ -
The memory region that
sbrk()grows is traditionally called the “heap”. In real systems, userspace allocators likemallocusesbrk()ormmap()to get memory from the kernel, then manage that memory to serve individual allocation requests. glibc’s malloc, for example, usessbrk()for small allocations andmmap()for large ones. xv6 doesn’t havemalloc, so programs callsbrk()directly. ↩︎ -
This is a common C pattern: embedding a function call with side effects inside a condition. The function runs first (with all its side effects), then the return value is tested. By the time we check
!= 0, the page table has already been modified (or the attempt has already failed). ↩︎ -
You might have noticed the
readparameter invmfault(). It’s passed fromusertrap()as(r_scause() == 13) ? 1 : 0, so it’s 1 for load faults and 0 for store faults. But the function body never uses it. I’m guessing it’s a useless legacy footprint? Clearly we can see that the function always maps the page withPTE_W|PTE_U|PTE_Rregardless of whether the fault was a load or store. ↩︎ -
Recall that for syscalls,
usertrap()doesp->trapframe->epc += 4so we return to the instruction afterecall. For page faults, there’s no such increment. Theepcstays pointing at the faulting instruction, so whensretexecutes, the CPU re-executes it. ↩︎ -
fstat()is a syscall that returns file metadata (size, type, inode number, etc.) into a user-provided struct. ↩︎