Jump to content

When Rust Meets the MMU: How Page Tables and Ownership Collide

From JOHNWICK

There’s a quiet moment every OS developer in Rust eventually hits.
You’ve written your kernel, built your bootloader, maybe even printed “Hello from ring 0!” to the VGA buffer. Then comes the real monster: 
The Memory Management Unit (MMU). The MMU is that invisible piece of silicon that translates virtual addresses to physical ones — it’s the wall between process isolation and total chaos. And here’s the paradox: Rust’s ownership model was built to prevent unsafe memory access… but the MMU’s job is to literally remap memory arbitrarily. So what happens when Rust’s strict ownership meets hardware that lies about memory itself?

Let’s find out.

What the MMU Actually Does Every modern CPU (x86, ARM, RISC-V) uses an MMU to control how virtual memory maps to physical RAM. In simple terms:

Virtual address → Page table → Physical frame

This lets your OS:

  • Protect processes from each other
  • Implement demand paging and swapping
  • Mark memory as read-only or executable
  • Isolate kernel space from user space

Here’s the classic 4-level x86_64 page table chain:

CR3 → PML4 → PDPT → PD → PT → Page Frame

Each level maps a small chunk of the address space, like nested directories in a filesystem.

But here’s the catch: 
Every mapping, every table, every “safe” pointer is ultimately a lie told by the MMU — it decides what address points where. And Rust doesn’t like lies.

The Problem: Rust Thinks Ownership Is Static

Rust’s ownership model assumes that if you have a reference:

let x: &mut T

then you — and only you — own that memory region until it goes out of scope. But when you’re writing an OS or kernel, you’re literally changing the meaning of memory addresses while running.

Example: 
You might map a physical frame into two different virtual addresses:

// Pseudocode for mapping map_page(0xFFFF800000000000, 0x1000); // Kernel maps physical 0x1000 map_page(0x0000000000400000, 0x1000); // User maps same frame

Now both 0xFFFF... and 0x0000... point to the same memory.
In Rust’s eyes, that’s two mutable references to the same data — instant undefined behavior.

But in the OS world, that’s perfectly normal. Welcome to the paradox.

Ownership vs. Page Ownership

The core of the problem is who “owns” a page — the Rust compiler, or the MMU? Let’s visualize this conflict:

+---------------------+          +----------------------+
| Rust Borrow Checker |          |    Hardware MMU      |
+---------------------+          +----------------------+
| &mut Page           |   vs.    | Page -> Frame Mapping |
| Lifetime control     |          | Translation & Access  |
| Compile-time safety  |          | Run-time safety rules |
+---------------------+          +----------------------+

The Rust side enforces ownership at compile time.
The MMU enforces memory mapping at runtime. 
The two worlds don’t naturally align — one is static, the other dynamic. The Real Architecture: Safe Page Table Abstractions The key to writing an MMU-safe kernel in Rust isn’t fighting the borrow checker — it’s teaching it to understand page tables through abstractions. Let’s look at how the x86_64 crate and the Rust OSDev community approach it. They define safe abstractions on top of unsafe memory operations. 
For example:

use x86_64::{

   VirtAddr,
   structures::paging::{Mapper, Page, PhysFrame, Size4KiB}

};

fn map_page_example(mapper: &mut impl Mapper<Size4KiB>) {

   let page = Page::containing_address(VirtAddr::new(0xdeadbeef));
   let frame = PhysFrame::containing_address(PhysAddr::new(0x1000));
   
   unsafe {
       mapper.map_to(page, frame, PageTableFlags::PRESENT, &mut FrameAllocator)
           .expect("map_to failed")
           .flush();
   }

}

Even though this uses unsafe, the safety is contained.
The Mapper abstraction guarantees:

  • Each mapping is flushed to the TLB properly.
  • You can’t alias pages accidentally.
  • Permissions (read/write/execute) are encoded in Rust types.

That’s how Rust makes peace with the MMU — by reframing unsafe logic as typed contracts.

Diagram: Rust MMU Abstraction Flow

┌─────────────────────────────┐
 │    Safe Kernel Code         │
 │  (Uses `Mapper` interface)  │
 └───────────────┬─────────────┘
                 │
                 ▼
 ┌─────────────────────────────┐
 │  Paging Abstraction Layer   │
 │  - map_to(), unmap()        │
 │  - FrameAllocator trait     │
 └───────────────┬─────────────┘
                 │
                 ▼
 ┌─────────────────────────────┐
 │   Unsafe Hardware Access    │
 │  - Writes to PTEs           │
 │  - Updates CR3 register     │
 │  - Flushes TLB              │
 └─────────────────────────────┘

Every unsafe operation lives under a strongly typed interface.
The outer kernel code never directly touches page table bits — it just talks in abstractions.

Example: A Safe Frame Allocator

A frame allocator is how your kernel decides “which physical page can I use?”

pub struct FrameAllocator {

   next: u64,

}


unsafe impl x86_64::structures::paging::FrameAllocator<Size4KiB> for FrameAllocator {

   fn allocate_frame(&mut self) -> Option<PhysFrame<Size4KiB>> {
       let frame = PhysFrame::containing_address(PhysAddr::new(self.next));
       self.next += 4096;
       Some(frame)
   }

}

This FrameAllocator is simple, but safe.
It guarantees no overlapping physical frames because it hands them out sequentially — no two references point to the same memory frame.

Rust’s type system doesn’t need to understand MMUs — it just needs to ensure you can’t violate the logical ownership contract you’ve defined. Why unsafe Still Exists Here Because the MMU operates below Rust’s worldview. 
You can’t rewrite page tables without violating the compiler’s assumptions. That’s why every kernel written in Rust has small, isolated unsafe blocks — places where you promise the compiler: “I’m taking full responsibility here.” It’s the price of power. 
And it’s still infinitely safer than letting C handle it. The Real Reason This Works The brilliance of Rust in OS dev isn’t that it forbids unsafe.
It’s that it localizes it.

In C, everything is potentially unsafe.
In Rust, only what must be unsafe is, and everything else stays provably correct. So instead of hundreds of pages of code touching raw pointers, you have maybe five unsafe functions that manipulate the MMU — and thousands of lines that never can.

That’s the quiet win that makes Rust-based kernels (like Redox OS) possible.

The Emotional Bit

When I first tried mapping my own page tables in Rust, I thought the compiler was my enemy.
It yelled at every mutable borrow, every unsafe pointer cast, every lifetime I couldn’t justify.

But then it clicked:
The compiler wasn’t fighting me — it was teaching me the boundaries between logical ownership and physical mapping.

The MMU lies. Rust doesn’t.
And when those two meet halfway, you get something beautiful — an OS that can manage memory safely, in a world built on lies.

Final Thoughts

Rust and the MMU will always have creative tension.
One is a hardware liar, the other a software truth-teller.
But when you bridge them through clean abstractions, you get the best of both worlds — a system that manipulates raw memory without losing its mind. In a sense, that’s what Rust’s entire philosophy is about: “Control without chaos. Power without panic.” When you finally get your first page table mapped in Rust, and it boots cleanly — no segfaults, no corruption — you realize you’ve done something that C never made feel safe.

You’ve stared into the memory abyss…
…and Rust stared back with ownership.

Read the full article here: https://medium.com/@theopinionatedev/inside-rusts-cooperative-multitasking-the-secret-behind-tokio-s-fairness-7dd06ef23ecd