ARM64 OS Handbook
🔍

Chapter 9: Kernel Entry Point

What You Will Learn in This Chapter
  • What happens inside the kernel entry point before C code runs
  • How to install the exception vector table
  • How to enable the instruction and data caches
  • How to configure the GIC (interrupt controller)
  • The initialization sequence from _start to kernel_main
  • How to structure kernel initialization for clarity and correctness

9.1 The Entry Point and Initialization Sequence

The kernel entry point is the first code that runs when the kernel gains control of the CPU. In our kernel, this is the _start label in start.S. The entry point must prepare the CPU so that C code can run safely.

The full initialization sequence, from entry to running the first user-space process, follows a strict order:

  1. Early assembly setup (start.S): stack, BSS, EL drop, save boot params
  2. Early C init (kernel_main): UART, exception vectors, GIC, system timers
  3. Memory init: physical memory map, page tables, MMU enable
  4. Subsystem init: scheduler, heap allocator, device drivers
  5. First process: spawn init process, drop to EL0

In this chapter, we focus on steps 1 and 2: everything that happens inside _start and kernel_main before memory management is set up.

9.2 Early Assembly Setup Recap

From Chapter 7, our start.S does these things before calling kernel_main:

_start:
    mov x20, x0               /* save device tree address */
    mov x21, x1               /* save CPU ID */
    mrs x0, CurrentEL         /* check EL */
    lsr x0, x0, #2
    cmp x0, #2
    b.ne 1f
    /* Drop from EL2 to EL1 */
    mov x0, #0x3C5
    msr SPSR_EL2, x0
    adr x0, 1f
    msr ELR_EL2, x0
    eret
1:  ldr x0, =_stack_end       /* set stack */
    mov sp, x0
    /* Clear BSS */
    ldr x0, =_bss_start
    ldr x1, =_bss_end
    mov x2, xzr
2:  cmp x0, x1
    b.ge 3f
    str x2, [x0], #8
    b 2b
3:  mov x0, x20               /* restore dtb address */
    mov x1, x21               /* restore CPU ID */
    bl kernel_main

After this code runs, the CPU is at EL1 with a valid stack pointer, zeroed BSS, and the device tree address and CPU ID in registers ready for the C function.

9.3 Exception Vector Installation

Before we can enable interrupts, we must install the exception vector table. This is a table of 16 entries (each 128 bytes) that tells the CPU where to jump when exceptions occur. Without this table, any exception (including the timer interrupt) would cause the CPU to jump to an undefined address and crash.

The exception vector table must be aligned to 2 KB (2048 bytes). We install it by writing its address to the VBAR_EL1 register:

.section .text.vectors
.align 11                  /* 2^11 = 2048 byte alignment */

.global exception_vectors
exception_vectors:
    /* EL1t (SP_EL0), synchronous */
    .align 7
    b el1t_sync
    .align 7
    b el1t_irq
    .align 7
    b el1t_fiq
    .align 7
    b el1t_serror

    /* EL1h (SP_EL1), synchronous */
    .align 7
    b el1h_sync
    .align 7
    b el1h_irq
    .align 7
    b el1h_fiq
    .align 7
    b el1h_serror

    /* EL0 (AArch64), synchronous */
    .align 7
    b el0_sync_64
    .align 7
    b el0_irq_64
    .align 7
    b el0_fiq_64
    .align 7
    b el0_serror_64

    /* EL0 (AArch32), synchronous */
    .align 7
    b el0_sync_32
    .align 7
    b el0_irq_32
    .align 7
    b el0_fiq_32
    .align 7
    b el0_serror_32

In our C code, we install the table early in kernel_main:

extern char exception_vectors[];

void install_exception_vectors(void) {
    __asm__ volatile("msr VBAR_EL1, %0" : : "r" (exception_vectors));
    __asm__ volatile("isb");
}

After this call, any exception that occurs will jump to one of these vectors. The ISB (instruction synchronization barrier) ensures the new vector address is used by subsequent instructions.

9.4 Enabling Caches

When the CPU starts, both the instruction cache (I-cache) and data cache (D-cache) are disabled. This means every memory access goes directly to main memory, which is very slow.

We can enable the caches before the MMU is turned on, but only the instruction cache is safe to enable without MMU. The data cache requires the MMU to be active because cacheable memory regions must be defined in the page tables.

enable_caches:
    mrs x0, SCTLR_EL1

    /* Enable instruction cache (bit 12) */
    orr x0, x0, #(1 << 12)

    /* Enable branch prediction (bit 11) */
    orr x0, x0, #(1 << 11)

    msr SCTLR_EL1, x0
    isb
    ret

The data cache and MMU are enabled together later, after we have set up page tables. Until then, all data accesses go directly to physical memory.

9.5 GIC Initialization

The Generic Interrupt Controller (GIC) is the hardware component that manages interrupts on ARM64 systems. It receives interrupt signals from devices, prioritizes them, and delivers them to the CPU.

On QEMU virt, the GIC is a GICv3 at physical address 0x08000000. It consists of two main interfaces:

  • Distributor (GICD): global interrupt configuration, enable/disable, priority grouping
  • Redistributor / CPU Interface (GICR / GICC): per-CPU interrupt handling, priority masking, interrupt acknowledgment

Minimal GIC initialization for the distributor:

#define GICD_BASE     0x08000000
#define GICR_BASE     0x080A0000

/* GIC distributor registers */
#define GICD_CTLR     (GICD_BASE + 0x0000)
#define GICD_TYPER    (GICD_BASE + 0x0004)
#define GICD_IGROUPR  (GICD_BASE + 0x0080)
#define GICD_ISENABLER (GICD_BASE + 0x0100)
#define GICD_ICENABLER (GICD_BASE + 0x0180)
#define GICD_ICPEND   (GICD_BASE + 0x0280)
#define GICD_ICFGR    (GICD_BASE + 0x0C00)

/* GIC redistributor registers */
#define GICR_WAKER    (GICR_BASE + 0x0014)

void gic_init(void) {
    volatile uint32_t *gicd_ctlr     = (uint32_t *)GICD_CTLR;
    volatile uint32_t *gicd_icenabler = (uint32_t *)GICD_ICENABLER;
    volatile uint32_t *gicd_icpend   = (uint32_t *)GICD_ICPEND;
    volatile uint32_t *gicd_igroupr  = (uint32_t *)GICD_IGROUPR;
    volatile uint32_t *gicd_icfgr    = (uint32_t *)GICD_ICFGR;
    volatile uint32_t *gicr_waker    = (uint32_t *)GICR_WAKER;

    /* Disable the distributor */
    *gicd_ctlr = 0;

    /* Disable all interrupts (set bits in ICENABLER) */
    for (int i = 0; i < 32; i++) {
        gicd_icenabler[i] = 0xFFFFFFFF;
    }

    /* Clear all pending interrupts */
    for (int i = 0; i < 32; i++) {
        gicd_icpend[i] = 0xFFFFFFFF;
    }

    /* Set all interrupts to group 1 (non-secure) */
    for (int i = 0; i < 32; i++) {
        gicd_igroupr[i] = 0xFFFFFFFF;
    }

    /* Set all interrupts to level-sensitive */
    for (int i = 0; i < 32; i++) {
        gicd_icfgr[i] = 0x00000000;
    }

    /* Wake up the redistributor (clear the "processor sleep" bit) */
    *gicr_waker &= ~(1 << 1);

    /* Enable the distributor */
    *gicd_ctlr = 1;
}

After this initialization, the GIC is ready to receive interrupts. We enable specific interrupts (like the timer interrupt) later when we initialize each device.

9.6 System Timer Initialization

ARM64 provides a system timer that can generate periodic interrupts. This is essential for the scheduler, which needs a timer tick to preempt running processes.

The system timer has a counter (CNTPCT_EL0) that increments at a fixed frequency, and a timer (CNTP_TVAL_EL1) that generates an interrupt when the count reaches a certain value.

#define CNTP_TVAL_EL1  "S3_1_C14_C2_0"   /* Timer value register */
#define CNTP_CTL_EL1   "S3_1_C14_C2_1"   /* Timer control register */
#define CNTPCT_EL0     "S3_3_C14_C0_0"   /* Counter register (read-only) */

/* Read the counter frequency */
uint64_t timer_get_freq(void) {
    uint64_t freq;
    __asm__("mrs %0, CNTFRQ_EL0" : "=r" (freq));
    return freq;
}

/* Read the current counter value */
uint64_t timer_get_count(void) {
    uint64_t count;
    __asm__("mrs %0, " CNTPCT_EL0 : "=r" (count));
    return count;
}

/* Set the timer to fire after 'us' microseconds */
void timer_set_us(uint64_t us) {
    uint64_t freq = timer_get_freq();
    uint64_t ticks = (freq * us) / 1000000;

    /* Set the timer value (how many ticks from now) */
    __asm__("msr " CNTP_TVAL_EL1 ", %0" : : "r" (ticks));

    /* Enable the timer: bit 0 = enable, bit 1 = mask interrupt */
    __asm__("msr " CNTP_CTL_EL1 ", %0" : : "r" (1UL));

    /* Enable the timer interrupt in the GIC (PPI interrupt ID 30) */
    volatile uint32_t *isenabler = (uint32_t *)(0x08000000 + 0x0100);
    isenabler[0] = (1 << 30);
}

/* Handle the timer interrupt (called from exception handler) */
void timer_handler(void) {
    /* Acknowledge the timer interrupt */
    __asm__("msr " CNTP_CTL_EL1 ", %0" : : "r" (0));

    /* Clear the interrupt in the GIC */
    volatile uint32_t *eoir = (uint32_t *)(0x08000000 + 0x4000 + 0x0010);
    *eoir = 30;  /* interrupt ID 30 */
}

The timer frequency on QEMU virt is typically 62.5 MHz (62,500,000 Hz), so each tick is 16 nanoseconds. We use this to implement timer_set_us for microsecond granularity.

9.7 The Complete Initialization Sequence

Here is the full kernel_main function with all the initialization steps we have discussed:

kernel.c
#include 

extern void install_exception_vectors(void);
extern void enable_caches(void);
extern void gic_init(void);
extern void timer_set_us(uint64_t us);
extern void uart_init(void);
extern void uart_puts(const char *s);

/* Print a hex number */
static void puthex(uint64_t n) {
    const char *hex = "0123456789abcdef";
    uart_puts("0x");
    for (int i = 15; i >= 0; i--)
        uart_putc(hex[(n >> (i * 4)) & 0xf]);
}

void kernel_main(uint64_t dtb_addr, uint64_t cpu_id) {
    /* Step 1: Micro-architecture setup */
    install_exception_vectors();
    enable_caches();

    /* Step 2: Interrupt controller setup */
    if (cpu_id == 0) {
        gic_init();
    }

    /* Step 3: Console setup (for debug output) */
    uart_init();
    uart_puts("\r\n[KERNEL] Boot CPU: ");
    puthex(cpu_id);
    uart_puts("\r\n");

    /* Step 4: System timer setup */
    uint64_t freq = timer_get_freq();
    uart_puts("[KERNEL] Timer frequency: ");
    puthex(freq);
    uart_puts(" Hz\r\n");

    /* Step 5: Memory subsystem (future chapters) */
    uart_puts("[KERNEL] Device tree at: ");
    puthex(dtb_addr);
    uart_puts("\r\n");

    /* Step 6: Enable interrupts for this CPU */
    __asm__("msr DAIFClr, #2");   /* unmask IRQs */

    /* Step 7: Set a periodic timer (every 10 ms for scheduler) */
    timer_set_us(10000);

    uart_puts("[KERNEL] Initialization complete. Waiting for interrupts...\r\n");

    /* Main loop: wait for interrupts */
    while (1) {
        __asm__("wfi");
    }
}

Each step builds on the previous one. The exception vectors must be installed before any interrupt can occur. The GIC must be initialized before device interrupts can be delivered. The timer must be configured before the scheduler can use it.

9.8 Startup Order and Dependencies

Initialization order matters. This table shows the dependencies between subsystems:

#SubsystemDepends OnWhy
1Exception vectorsNothingMust be ready before any interrupt
2Caches (I-cache)NothingSafe without MMU
3GICException vectorsGIC sends interrupts to CPU
4UARTNothingFor debug messages
5System timerGIC, exception vectorsTimer interrupt must be handled
6Memory mapUART (for debug)Must know physical memory layout
7Page tablesMemory mapMust know which memory to map
8MMU + D-cachePage tablesMMU needs page tables
9Heap allocatorMMU, memory mapNeeds virtual memory
10SchedulerTimer, heap, MMUNeeds timer + dynamic memory

We will implement each of these subsystems in the order shown. The next several chapters cover the memory management stack (steps 6-8), which is the most complex part of the kernel.

9.9 Our Implementation

The complete kernel entry point implementation for our kernel consists of these files:

FileContents
start.SEarly assembly: stack, BSS, EL drop, jump to kernel_main
vectors.SException vector table with 16 entries
kernel.ckernel_main and all initialization functions
uart.cUART initialization and I/O
gic.cGIC initialization (distributor + redistributor)
timer.cSystem timer setup and handler

The build process combines these into a single kernel ELF:

# Assemble
aarch64-none-elf-as start.S -o start.o
aarch64-none-elf-as vectors.S -o vectors.o

# Compile
aarch64-none-elf-gcc -c -ffreestanding -O2 -Wall -Wextra kernel.c -o kernel.o
aarch64-none-elf-gcc -c -ffreestanding -O2 -Wall -Wextra uart.c -o uart.o
aarch64-none-elf-gcc -c -ffreestanding -O2 -Wall -Wextra gic.c -o gic.o
aarch64-none-elf-gcc -c -ffreestanding -O2 -Wall -Wextra timer.c -o timer.o

# Link
aarch64-none-elf-ld -T kernel.ld start.o vectors.o kernel.o uart.o gic.o timer.o -o kernel.elf

# Run
qemu-system-aarch64 -M virt -cpu cortex-a72 -nographic -kernel kernel.elf

9.10 Exercises

Exercise 1: Verify Exception Vectors

After installing the exception vectors, read back VBAR_EL1 and print its value. Verify it matches the address of your exception_vectors symbol. Use objdump -t kernel.elf | grep exception_vectors to find the address.

Exercise 2: Trigger a Synchronous Exception

Add an undefined instruction (.word 0xDEAD1234) somewhere in your code and observe what happens. The CPU should jump to the el1h_sync vector. Add code there that prints "Synchronous exception occurred" and then halts.

Exercise 3: Measure Cache Performance

Write a function that reads 1 MB of memory sequentially and measures the time using CNTPCT_EL0. Run it with the instruction cache disabled and then with it enabled. Compare the times and compute the speedup.

Exercise 4: Timer Accuracy

Set the system timer to fire after 1 second. In the timer handler, increment a counter. After 10 seconds (check using CNTPCT_EL0), print the counter value. It should be 10. If not, explain the discrepancy.

Exercise 5: GIC Interrupt Count

After GIC initialization, read the GICD_TYPER register and print the number of interrupt lines supported. The TYPER register encodes this in bits 4:0 (ITLinesNumber). The number of interrupts is 32 * (ITLinesNumber + 1).

Exercise 6: Unexpected Interrupt Handler (Challenge)

Create a single "unexpected interrupt" handler that all unused exception vectors point to. The handler should print the exception type (sync, IRQ, FIQ, SError), the exception level (EL1 or EL0), and the value of ESR_EL1 (the exception syndrome register). Then halt the CPU.

9.11 Summary

In this chapter, we covered the critical initialization that happens inside the kernel entry point before the main kernel subsystems are started.

The initialization sequence follows a strict order: exception vectors first (so interrupts can be handled), then caches (for performance), then the GIC (interrupt controller), then the system timer, and finally individual device drivers.

We installed the exception vector table by writing its address to VBAR_EL1. We enabled the instruction cache and branch prediction via SCTLR_EL1. We initialized the GIC distributor and redistributor to accept interrupts. We configured the system timer to generate periodic interrupts for the scheduler.

The dependency chain between subsystems determines the initialization order. Getting this order wrong causes subtle bugs that are hard to debug. Always initialize from the CPU outward: CPU features first, then interrupt controller, then devices.

In the next chapter, we will dive deeper into exception levels and how the kernel manages transitions between EL1 (kernel) and EL0 (user space).