Skip to content

10. Virtual Memory (MMU) - Enabling Caching and Protection

The Memory Management Unit (MMU) is a critical component of modern processors. It translates Virtual Addresses (VA) to Physical Addresses (PA) and controls memory attributes like caching and access permissions.

Why use the MMU?

  1. Caching: Without MMU, all memory access is treated as "Device Memory" (uncached) by default on ARMv8, which is extremely slow. Enabling MMU allows us to mark RAM as "Normal Memory" (Cacheable).
  2. Protection: We can prevent user programs from accessing kernel memory.
  3. Virtualization: Each process can have its own isolated address space.

Translation Tables (Page Tables)

ARMv8 uses a multi-level table structure. For a 4KB granule, we typically use 3 or 4 levels. We'll use 3 levels to map the first 1GB of memory.

  • Level 0 (PGD): Points to Level 1.
  • Level 1 (PUD): Each entry covers 1GB.
  • Level 2 (PMD): Each entry covers 2MB (Block Descriptor).
  • Level 3 (PTE): Each entry covers 4KB (Page Descriptor) - We won't use this yet.

Identity Mapping

To keep things simple, we will use Identity Mapping, meaning Virtual Address = Physical Address.

  • 0x00000000 - 0x3F000000: Normal Memory (RAM) - Cacheable
  • 0x3F000000 - 0x40000000: Device Memory (Peripherals) - Uncached

Implementation

1. Descriptors (include/mmu.h)

We define the bits for the table descriptors.

1
2
3
4
#define MM_TYPE_BLOCK        0x1
#define MM_ACCESS            (1 << 10)
#define MM_ATTR_NORMAL       (MT_NORMAL << 2)
#define MM_ATTR_DEVICE       (MT_DEVICE_nGnRnE << 2)

2. Table Setup (kernel/mmu.c)

We populate the tables in C.

void mmu_init(void) {
    // PGD -> PUD
    id_pgd[0] = (uint64_t)id_pud | MM_TYPE_PAGE_TABLE;

    // PUD -> PMD
    id_pud[0] = (uint64_t)id_pmd | MM_TYPE_PAGE_TABLE;

    // PMD (2MB Blocks)
    for (int i = 0; i < 512; i++) {
        uint64_t pa = i * 2 * 1024 * 1024;
        if (pa >= 0x3F000000) {
            id_pmd[i] = pa | MM_ATTR_DEVICE | ...;
        } else {
            id_pmd[i] = pa | MM_ATTR_NORMAL | ...;
        }
    }
}

3. Enabling MMU (boot/mmu.S)

We need to write to system registers: - TTBR0_EL1: Point to our PGD table. - TCR_EL1: Configure address size (T0SZ) and granule (4KB). - MAIR_EL1: Define memory attributes (0=Device, 1=Normal). - SCTLR_EL1: Turn on the MMU (M bit) and Caches (C and I bits).

1
2
3
4
5
6
7
8
enable_mmu_asm:
    mrs x0, sctlr_el1
    orr x0, x0, #1          /* Enable MMU */
    orr x0, x0, #(1 << 2)   /* Enable D-Cache */
    orr x0, x0, #(1 << 12)  /* Enable I-Cache */
    msr sctlr_el1, x0
    isb
    ret

Building and Testing

1. Build

1
2
3
cd simpian-os/build
cmake -DBUILD_EXAMPLES=ON ..
make

2. Deploy

Copy mmu_demo.img to SD card (rename to kernel8.img).

3. Expected Output

You should see a significant speedup in memory operations if you compare cached vs uncached (though this demo just confirms it works).

MMU Demo
========

1. Initializing MMU...
Setting up MMU tables...
Enabling MMU...
MMU Enabled!

2. Verifying Identity Mapping...
   UART access OK (Device Memory)
   RAM access OK (Normal Memory)

3. Performance Test (Cache Effect)...
   Wrote 8MB in 0x... ticks

Test Complete! System Halted.

Troubleshooting

  • System Hangs: If the MMU config is wrong (e.g., mapping UART as Cacheable, or invalid table pointers), the CPU will crash immediately.
  • Triple Fault: If the exception handlers themselves cause an exception (e.g., they are unmapped), the CPU resets.

What's Next?

With the MMU active, we have a high-performance foundation. The next major step is Multitasking (Context Switching), where we will use the MMU to isolate processes.