Developing a bare-metal kernel in Rust for the Raspberry Pi 3B+ (AArch64) is an incredibly rewarding journey. However, many developers encounter a frustrating roadblock: the kernel works flawlessly in QEMU, but hangs or throws a Data Abort (EC=0x25) on real hardware the moment it jumps to the high-address space (ttbr1_el1) and performs its first data access.

If your instruction fetch succeeds (meaning your br to the ttbr1 region executes) but the very first data read or write triggers a Synchronous Data Abort, you are dealing with a classic hardware-vs-emulator discrepancy. In this guide, we will break down why this happens and how to fix it.

Understanding the Symptom: QEMU vs. Real Hardware

QEMU is an excellent emulator, but it is highly forgiving. By default, QEMU:

  • Does not strictly emulate CPU cache coherency.
  • Often ignores memory attribute mismatches (such as treating Device memory as Normal memory).
  • May bypass alignment checks that physical ARMv8-A cores (like the Cortex-A53 on the RPi3B+) strictly enforce.

When your code jumps to ttbr1_el1, the instruction cache might successfully fetch the next instructions, but the first memory access (such as zeroing out the .bss section, writing to the stack, or accessing a global variable) fails. This triggers a Data Abort from the same Exception Level (EC=0x25).

Here are the four most common culprits behind this issue and how to resolve them.

---

1. Missing or Misconfigured MAIR_EL1 (Memory Attribute Indirection Register)

This is the most common cause of Data Aborts on real hardware. In your page table descriptors, you assign an attribute index (AttrIndx) to define whether a region is Normal Memory (cacheable) or Device Memory (non-cacheable, strict alignment).

These indices point to entries in the MAIR_EL1 register. If you do not explicitly configure MAIR_EL1, it contains garbage or default values. On real hardware, this can cause your RAM to be treated as Device Memory.

Why this causes a Data Abort: Rust and LLVM aggressively optimize data operations (like zeroing memory or copying structs) using multi-register instructions (like stp and ldp) or unaligned accesses. While unaligned access is tolerated on Normal Memory, any unaligned access to Device Memory triggers an immediate Alignment/Data Abort on real hardware.

The Fix: Configure MAIR_EL1

Before enabling the MMU, ensure you define your memory attributes in MAIR_EL1 and reference them correctly in your page tables:

// Define MAIR attributes
// Attr 0: Device-nGnRE (0x04)
// Attr 1: Normal Memory, Outer & Inner Write-Back Non-transient (0xFF)
mov x0, #0x00FF04
msr mair_el1, x0
isb

In your page table generation code, ensure that your RAM mappings use index 1 (Normal Memory) and your MMIO/QA7 mappings use index 0 (Device Memory).

---

2. Cache Coherency and Page Table Flushing

When you write your translation tables in RAM prior to enabling the MMU, those writes go through the CPU's data cache.

When the MMU is turned on, the hardware Translation Table Walker reads directly from physical memory (or L2 cache, depending on shareability settings). If your dirty page table writes are still sitting in the L1/L2 data cache and have not been flushed to RAM, the MMU walker will read stale or uninitialized data, leading to translation faults.

The Fix: Clean and Invalidate Caches

You must clean your page tables to the Point of Coherency (PoC) before turning on the MMU. In your assembly boot code, loop through the memory range of your page tables and clean them:

// Clean page tables to Point of Coherency (PoC)
// x0 = start of page tables, x1 = end of page tables
1:
    dc cvac, x0
    add x0, x0, #64 // Assuming 64-byte cache line
    cmp x0, x1
    b.lo 1b
    dsb sy
    isb
---

3. The Access Flag (AF) Fault

In the ARMv8-A architecture, descriptor bit 10 is the Access Flag (AF). If this bit is 0, accessing the page will trigger an Access Flag Fault (a sub-type of Data Abort) unless your hardware supports and has enabled automatic hardware management of the Access Flag.

QEMU often ignores a 0 in the AF bit, but real hardware will strictly throw a synchronous exception.

The Fix: Set the Access Flag to 1

When constructing your page table entries in Rust, ensure that bit 10 is explicitly set to 1:

const ACCESS_FLAG: u64 = 1 << 10;

// When building your descriptors:
let descriptor = physical_address | ACCESS_FLAG | OTHER_FLAGS;
---

4. TCR_EL1 Configuration Mismatch

The Translation Control Register (TCR_EL1) determines how the MMU behaves for both ttbr0_el1 and ttbr1_el1. If the shareability and cacheability settings in TCR_EL1 do not match your actual page table attributes, the hardware table walker can fail.

The Fix: Align TCR_EL1 with MAIR_EL1

Ensure your TCR_EL1 is configured for Inner/Outer Write-Back cacheability and Inner Shareability for both regions:

// Example TCR_EL1 configuration:
// T0SZ/T1SZ = 25 (39-bit VA space)
// TG0/TG1 = 4KB granule
// SH0/SH1 = Inner Shareable
// ORGN0/IRGN0/ORGN1/IRGN1 = Write-Back Read-Allocate Write-Allocate
ldr x0, =((25 << 0) |   /* T0SZ */  \
          (25 << 16) |  /* T1SZ */  \
          (1 << 8) |    /* Outer Shareable or Inner Shareable */ \
          (3 << 12) |   /* Inner Shareable SH0 */ \
          (3 << 28) |   /* Inner Shareable SH1 */ \
          (1 << 10) |   /* Outer WB WA IRGN0 */ \
          (1 << 8) |    /* Inner WB WA ORGN0 */ \
          (1 << 26) |   /* Outer WB WA IRGN1 */ \
          (1 << 24))    /* Inner WB WA ORGN1 */
msr tcr_el1, x0
isb
---

How to Diagnose the Exact Cause

If you are still getting a Data Abort, use your exception vector handler to read the ESR_EL1 (Exception Syndrome Register) and FAR_EL1 (Fault Address Register). This will tell you exactly what went wrong:

  • FAR_EL1: Holds the exact virtual address that caused the abort. If it points to your stack or BSS, you know exactly which data access failed.
  • ESR_EL1 (DFSC bits [5:0]): Tells you the specific fault type:
    • 0b000100 to 0b000111: Translation Fault (Page table mapping is missing or unreadable).
    • 0b001000 to 0b001011: Access Flag Fault (Bit 10 is 0).
    • 0b001100 to 0b001111: Permission Fault (e.g., trying to write to a read-only page).
    • 0b100001: Alignment Fault (Unaligned write to Device memory).

By configuring your MAIR_EL1, ensuring the Access Flag is set, and flushing your page tables from the cache to physical RAM, your Rust kernel will transition seamlessly from QEMU to real Raspberry Pi 3B+ hardware!