Case Study 38-2: MinOS on ARM64 — Porting to QEMU aarch64

Introduction

The skills you built writing MinOS for x86-64 transfer directly to other architectures — with important differences. Porting MinOS to ARM64 running in qemu-system-aarch64 illuminates those differences concretely: UEFI boot vs. BIOS MBR, exception levels vs. privilege rings, ARM64's GIC interrupt controller vs. the x86-64 PIC/APIC, and the ARM64 MMU with its TTBR registers.

This case study is a design exercise and architectural comparison. The goal is not a complete ARM64 MinOS implementation (that is an extension project) but a precise understanding of what changes and what stays the same.

Boot: UEFI vs. BIOS

x86-64 BIOS Boot

The x86-64 MinOS bootloader exploits the BIOS convention: load the 512-byte MBR into 0x7C00, verify 0x55AA signature, execute. This is the IBM PC architecture from 1981. It works, but: - Must fit in 512 bytes - Starts in 16-bit real mode - Requires A20 line management - No built-in disk abstraction beyond INT 13h

ARM64 UEFI Boot

Modern ARM64 systems use UEFI (Unified Extensible Firmware Interface). QEMU's qemu-system-aarch64 emulates UEFI via the AAVMF firmware (ARM64 OVMF).

UEFI differences: - Boots to a 64-bit environment (EL1 or EL2 on ARM64) — no mode transition needed - Provides standard services: memory map via GetMemoryMap(), file access via EFI file protocols - Applications are PE/COFF format ELF-like files with a specific entry point - No 512-byte constraint

A minimal UEFI application for MinOS ARM64:

/* efi_main.c — UEFI entry point for ARM64 MinOS */
#include <efi.h>
#include <efilib.h>

EFI_STATUS EFIAPI efi_main(EFI_HANDLE ImageHandle,
                            EFI_SYSTEM_TABLE *SystemTable)
{
    InitializeLib(ImageHandle, SystemTable);
    Print(L"MinOS ARM64 bootloader\n");

    /* Get memory map */
    EFI_MEMORY_DESCRIPTOR *MemMap;
    UINTN MapSize, MapKey, DescSize;
    UINT32 DescVer;
    GetMemoryMap(&MapSize, MemMap, &MapKey, &DescSize, &DescVer);

    /* Load kernel from disk */
    /* ... EFI file protocol calls ... */

    /* Exit boot services (hand over to kernel) */
    SystemTable->BootServices->ExitBootServices(ImageHandle, MapKey);

    /* Jump to kernel */
    void (*kernel_entry)(void) = (void *)KERNEL_LOAD_ADDRESS;
    kernel_entry();

    return EFI_SUCCESS;  /* unreachable */
}

The UEFI environment provides much more than BIOS: available memory map, ACPI tables, GOP (Graphics Output Protocol) for framebuffer, and more — at the cost of a more complex boot process.

Exception Levels vs. Privilege Rings

x86-64 Privilege Rings

x86-64 uses 4 privilege levels (rings 0-3), though only 0 and 3 are used in practice: - Ring 0: kernel, full privilege - Ring 3: user space, restricted - Transitions via: SYSCALL/SYSRET, INT/IRET, exceptions

ARM64 Exception Levels

ARM64 uses a different hierarchy of exception levels (EL0-EL3):

EL3: Secure Monitor (secure world)  — firmware/TrustZone
EL2: Hypervisor                     — virtualization
EL1: OS kernel                      — equivalent to ring 0
EL0: User space                     — equivalent to ring 3

For MinOS ARM64: - Kernel runs at EL1 - User processes run at EL0 - QEMU typically starts execution at EL1 (or EL2 if emulating a hypervisor)

The UEFI firmware (AAVMF) starts at EL2, drops to EL1 before calling the OS loader.

Exception Level Transitions

ARM64 enters EL1 from EL0 via: - SVC (Supervisor Call) — equivalent to x86-64 SYSCALL - Exceptions (data abort, undefined instruction) - IRQ/FIQ from EL0

The return path uses ERET (Exception Return) — equivalent to x86-64 IRETQ.

; ARM64: entering EL1 via SVC (system call)
; At EL0:
    mov x8, #1          ; syscall number
    mov x0, #1          ; arg1 (fd)
    adr x1, msg         ; arg2 (buffer)
    mov x2, #13         ; arg3 (length)
    svc #0              ; jump to EL1

; At EL1 (in exception handler):
    ; x0-x5: arguments preserved
    ; ELR_EL1: address of instruction after SVC
    ; SPSR_EL1: PSTATE at time of SVC (including EL and DAIF bits)

; Return to EL0:
    eret                ; restores PC from ELR_EL1, PSTATE from SPSR_EL1

Compare to x86-64:

; x86-64: entering ring 0 via SYSCALL
    mov rax, 1          ; syscall number (write)
    mov rdi, 1          ; arg1
    lea rsi, [msg]      ; arg2
    mov rdx, 13         ; arg3
    syscall             ; jump to kernel

; At ring 0:
    ; Arguments in rdi, rsi, rdx, r10, r8, r9
    ; RCX: return address (saved by CPU)
    ; R11: saved RFLAGS
    ; Return via sysret

The symmetry is clear: SVC/ERET on ARM64 parallels SYSCALL/SYSRET on x86-64.

ARM64 Interrupt Controller: GIC

x86-64: PIC/APIC

MinOS x86-64 uses the 8259A PIC (Programmable Interrupt Controller). IRQs 0-15 map to IDT vectors 0x20-0x2F. The APIC is more powerful but requires more setup.

ARM64: GIC (Generic Interrupt Controller)

ARM64 systems use the GIC (ARM Generic Interrupt Controller). QEMU's virt machine emulates a GICv3.

Key differences from the PIC: - 32+ interrupt IDs (vs. 16 in PIC) - Distributor (GICD) and CPU interface (GICC): separate registers - Supports SPI (Shared Peripheral Interrupts, like the PIC's IRQs), PPI (Per-Processor Interrupts, like timers), and SGI (Software-Generated Interrupts, for IPI) - No "EOI" to a separate port — write to GICC_EOIR register

/* ARM64 MinOS: GIC initialization */
#define GICD_BASE   0x08000000   /* GIC Distributor (QEMU virt) */
#define GICC_BASE   0x08010000   /* GIC CPU Interface */

#define GICD_CTLR       (GICD_BASE + 0x000)
#define GICD_ISENABLER  (GICD_BASE + 0x100)
#define GICC_CTLR       (GICC_BASE + 0x000)
#define GICC_PMR        (GICC_BASE + 0x004)   /* priority mask */
#define GICC_IAR        (GICC_BASE + 0x00C)   /* interrupt acknowledge */
#define GICC_EOIR       (GICC_BASE + 0x010)   /* end of interrupt */

void gic_init(void) {
    /* Enable distributor */
    *(volatile uint32_t *)GICD_CTLR = 1;

    /* Enable CPU interface, all priorities */
    *(volatile uint32_t *)GICC_PMR  = 0xFF;  /* allow all priorities */
    *(volatile uint32_t *)GICC_CTLR = 1;

    /* Enable timer interrupt (SPI 27 on QEMU virt for ARM timer) */
    *(volatile uint32_t *)(GICD_ISENABLER + (27/32)*4) |= (1 << (27 % 32));
}

void irq_handler(void) {
    uint32_t iar = *(volatile uint32_t *)GICC_IAR;
    uint32_t intid = iar & 0x3FF;

    if (intid == 27) {         /* ARM timer */
        timer_handler();
    } else if (intid == 33) {  /* PL011 UART receive (keyboard equivalent) */
        uart_handler();
    }

    *(volatile uint32_t *)GICC_EOIR = iar;   /* acknowledge */
}

ARM64 Timer: Generic Timer vs. PIT

x86-64 MinOS uses the PIT (Intel 8253) at I/O ports 0x40-0x43. ARM64 has no I/O ports — it uses Memory-Mapped I/O exclusively.

The ARM64 Generic Timer is a core architectural feature (not a platform peripheral):

/* ARM64 timer using the architectural timer */
void timer_init(void) {
    uint64_t freq;
    /* Read timer frequency from system register */
    __asm__ volatile("mrs %0, cntfrq_el0" : "=r"(freq));
    /* Typically 62.5 MHz on QEMU */

    /* Set timer to fire in (freq / HZ) ticks */
    uint64_t interval = freq / 100;   /* 100 Hz */
    __asm__ volatile("msr cntp_tval_el0, %0" :: "r"(interval));

    /* Enable the timer */
    __asm__ volatile("msr cntp_ctl_el0, %0" :: "r"(1ULL));
}

The ARM64 system registers (mrs/msr) replace x86-64 I/O port instructions (in/out).

ARM64 MMU and Page Tables

x86-64 uses CR3 to point to PML4. ARM64 uses two separate translation table registers: - TTBR0_EL1: user-space page tables (low virtual addresses) - TTBR1_EL1: kernel page tables (high virtual addresses)

ARM64 page tables are conceptually similar to x86-64 but with different field layouts and different names (PGD, PUD, PMD, PTE in Linux terminology).

/* ARM64 MMU initialization */
void mmu_init(void) {
    /* Set Memory Attribute Indirection Register */
    uint64_t mair = (0xFF << 0)   /* Attr0: Normal memory (WB, WA) */
                  | (0x00 << 8);  /* Attr1: Device memory (nGnRnE) */
    __asm__ volatile("msr mair_el1, %0" :: "r"(mair));

    /* Set Translation Control Register */
    uint64_t tcr = (16 << 0)   /* T0SZ: 48-bit VA for TTBR0 */
                 | (16 << 16)  /* T1SZ: 48-bit VA for TTBR1 */
                 | (0 << 14)   /* TG0: 4KB pages for TTBR0 */
                 | (2 << 30);  /* TG1: 4KB pages for TTBR1 */
    __asm__ volatile("msr tcr_el1, %0" :: "r"(tcr));

    /* Set kernel page table base */
    __asm__ volatile("msr ttbr1_el1, %0" :: "r"(kernel_pgtable));

    /* Enable MMU */
    uint64_t sctlr;
    __asm__ volatile("mrs %0, sctlr_el1" : "=r"(sctlr));
    sctlr |= 1;   /* M: MMU enable */
    __asm__ volatile("msr sctlr_el1, %0" :: "r"(sctlr));
    __asm__ volatile("isb");   /* instruction synchronization barrier */
}

What Stays the Same

Despite the architectural differences, MinOS's fundamental design is portable:

Component x86-64 MinOS ARM64 MinOS
Bootloader 512B MBR, real mode → long mode UEFI EFI application, already 64-bit
Kernel entry entry.asm, load GDT/IDT Entry with no GDT (ARM uses page tables), setup VBAR_EL1 for vectors
VGA output PIO to 0xB8000 ARM PL011 UART or ARM framebuffer (MMIO)
Interrupts IDT + PIC Exception vector table (VBAR_EL1) + GIC
Timer PIT via I/O ports ARM Generic Timer via system registers
Memory 4-level paging, CR3, CR4.PAE 4-level paging, TTBR0/TTBR1, MAIR
Syscalls SYSCALL/SYSRET MSRs SVC/ERET, exception level transition
Context switch Push/pop registers, iretq Push/pop registers, eret
Shell, scheduler, PMM Identical C code Identical C code

The C kernel logic — scheduler, memory allocator, shell, drivers (at a higher level of abstraction) — ports with minimal changes. Only the hardware-interface layer needs ARM64-specific rewriting.

Running ARM64 MinOS in QEMU

# Required QEMU packages:
# qemu-system-arm (or qemu-system-aarch64)
# AAVMF firmware: ovmf-aarch64 package

# Build command:
qemu-system-aarch64 \
    -machine virt \
    -cpu cortex-a72 \
    -m 128M \
    -bios /usr/share/qemu-efi-aarch64/QEMU_EFI.fd \
    -drive file=minOS-arm64.img,format=raw \
    -serial stdio \
    -nographic

The -machine virt flag uses QEMU's generic ARM64 virtual machine, which has the GICv3, ARM Generic Timer, PL011 UART, and VirtIO bus — all well-documented and suitable for educational OS development.

Lessons from the Comparison

Porting MinOS ARM64 teaches the most important lesson about systems programming: the concepts are universal; the specific registers and I/O mechanisms are architecture-specific.

Every operating system — Linux, Windows, macOS, FreeBSD — solves these same problems: 1. Boot: get from firmware to a stable execution environment 2. Privilege separation: kernel code protected from user code 3. Interrupts: hardware events interrupt normal execution flow 4. Memory management: virtual address spaces, page tables 5. Scheduling: multiple execution contexts sharing one processor

The names differ. The register numbers differ. The MMIO addresses differ. But the problems and their solutions are the same. Understanding them at the assembly level on x86-64 gives you the vocabulary to understand any architecture quickly — and that vocabulary is what makes the second architecture significantly easier than the first.