Tutorial · 02 · AArch64

Hello, World

Last time the metal flashed a light from one assembly file. Now it speaks. We add the smallest C hand-off and a tiny transmit-only UART driver.

[o-o] |=| /_\

A blinking light says "I am alive." Text says "I have something to tell you." Today we earn a voice — one character at a time, out a single pin, into your terminal. From here on, the machine can report what it is doing. That changes everything.

Goal

Make the board print Hello, World on a serial terminal running on your computer. The blinking example stayed entirely in assembly; this time we cross the line into C. That means our assembly start file must clear .bss, create a stack, and call a C function. After that, C adds the first real peripheral driver: a transmit-only UART.

What a UART is

A UART is the oldest, simplest way for a chip to talk to the outside world: it shifts the bits of a byte out one at a time over a single wire, at an agreed speed (the baud rate). On the other end, a USB-to-serial adapter turns those pulses back into bytes your computer can read. No screen, no keyboard, no networking — just two pins and a common clock rate. That simplicity is exactly why it is every bare-metal programmer's first window into a running system.

We only need to transmit today, so we wire and configure just one direction: TX out of the board, into your adapter's RX. Receiving comes later.

Two UARTs, pick the simple one

The BCM2711 actually has two serial blocks: a full-featured PL011 (UART0) and a cut-down mini UART (UART1). The mini UART is the easier one to bring up — fewer registers, no fuss — so that is where we start. It lives in the auxiliary peripheral block and drives GPIO14 as its transmit pin.

The mini UART's baud rate is derived from the VPU core clock. If that clock can change, your characters turn to garbage. On this Raspberry Pi 4 setup the firmware reports a 500 MHz core clock, so we pin core_freq=500 in config.txt and use the matching divisor below.

Entering C

In Tutorial 01 we did not need C, so we did not need a stack or C-style global initialisation. Now we want ordinary functions and string literals, so boot.S becomes a tiny runtime entry point: park cores 1–3, clear .bss, set a stack pointer, and call kernel_main.

// boot.S - the tiny hand-off from firmware to C.

.section ".text.boot"
.global _start

_start:
  // Keep only core 0 running.
  mrs x0, mpidr_el1
  and x0, x0, #3
  cbz x0, core0

secondary_core:
    wfe
  b secondary_core

core0:
  // QEMU raspi4b enters at EL3. Real Raspberry Pi firmware enters at EL2.
  mrs x1, CurrentEL
  cmp x1, #0xC
  b.ne core0_not_el3

  ldr x2, =core0_not_el3
  msr elr_el3, x2
  mov x2, #0x3C9
  msr spsr_el3, x2
  mov x2, #(1 << 10)
  orr x2, x2, #(1 << 8)
  orr x2, x2, #1
  msr scr_el3, x2
  eret

core0_not_el3:
  // Clear the .bss section before entering C.
  ldr x0, =__bss_start
  ldr x1, =__bss_end

clear_bss:
  cmp x0, x1
  b.hs bss_done
  str xzr, [x0], #8
  b clear_bss

bss_done:
  // Set up a simple boot stack and jump into C.
  ldr x0, =__stack_top
  mov sp, x0
  bl kernel_main

halt:
    wfe
    b       halt

The driver

Here is the whole transmit-only mini UART. Three jobs: route the pins, configure the port, and push out one byte at a time. Every peripheral on this chip is just memory addresses, so the driver is mostly careful reads and writes.

// uart.c — minimal transmit-only mini UART driver, Raspberry Pi 4 (BCM2711)
#include <stdint.h>

#define MMIO_BASE 0xFE000000UL

#define GPIO_BASE (MMIO_BASE + 0x200000)
#define AUX_BASE  (MMIO_BASE + 0x215000)

#define GPFSEL1   (GPIO_BASE + 0x04)
#define GPPUPPDN0 (GPIO_BASE + 0xE4)

#define GPIO14_FUNC_SHIFT 12u
#define GPIO14_PULL_SHIFT 28u
#define GPIO_FUNC_MASK    7u
#define GPIO_FUNC_ALT5    2u
#define GPIO_PULL_MASK    3u

#define AUX_ENABLES (AUX_BASE + 0x04)
#define AUX_MU_IO   (AUX_BASE + 0x40)
#define AUX_MU_IER  (AUX_BASE + 0x44)
#define AUX_MU_LCR  (AUX_BASE + 0x4C)
#define AUX_MU_MCR  (AUX_BASE + 0x50)
#define AUX_MU_LSR  (AUX_BASE + 0x54)
#define AUX_MU_CNTL (AUX_BASE + 0x60)
#define AUX_MU_BAUD (AUX_BASE + 0x68)

#define AUX_MU_LSR_TX_READY   (1u << 5)
#define AUX_MU_CNTL_TX_ENABLE 2u
#define AUX_MU_BAUD_115200    541u

static inline void mmio_write(uintptr_t reg, uint32_t val)
{
    *(volatile uint32_t *)reg = val;
}

static inline uint32_t mmio_read(uintptr_t reg)
{
    return *(volatile uint32_t *)reg;
}

void uart_init(void)
{
    uint32_t value;

    value = mmio_read(GPFSEL1);
    value &= ~(GPIO_FUNC_MASK << GPIO14_FUNC_SHIFT);
    value |= GPIO_FUNC_ALT5 << GPIO14_FUNC_SHIFT;
    mmio_write(GPFSEL1, value);

    value = mmio_read(GPPUPPDN0);
    value &= ~(GPIO_PULL_MASK << GPIO14_PULL_SHIFT);
    mmio_write(GPPUPPDN0, value);

    mmio_write(AUX_ENABLES, 1u);
    mmio_write(AUX_MU_CNTL, 0u);
    mmio_write(AUX_MU_IER, 0u);
    mmio_write(AUX_MU_LCR, 3u);
    mmio_write(AUX_MU_MCR, 0u);
    mmio_write(AUX_MU_BAUD, AUX_MU_BAUD_115200);
    mmio_write(AUX_MU_CNTL, AUX_MU_CNTL_TX_ENABLE);
}

void uart_putc(char c)
{
    while ((mmio_read(AUX_MU_LSR) & AUX_MU_LSR_TX_READY) == 0u) {
    }

    mmio_write(AUX_MU_IO, (uint32_t)(uint8_t)c);
}

void uart_puts(const char *text)
{
    while (*text != '\0') {
        if (*text == '\n') {
            uart_putc('\r');
        }

        uart_putc(*text++);
    }
}

And the program itself is now refreshingly boring:

// main.c — our first words

void uart_init(void);
void uart_puts(const char *s);

void kernel_main(void) {
    uart_init();
    uart_puts("Hello, World\n");
    uart_puts("Alquist is awake.\n");

    for (;;) {
    }
}

The linker script

Same as before: entry at 0x80000 and .text.boot first. The new symbols mark the .bss range and the stack top used by boot.S.

/* kernel.ld */
SECTIONS
{
    . = 0x80000;
    .text : { KEEP(*(.text.boot)) *(.text*) }
    .rodata : { *(.rodata*) }
    .data : { *(.data*) }
    .bss : {
      . = ALIGN(16);
      __bss_start = .;
      *(.bss*)
      *(COMMON)
      . = ALIGN(16);
      __bss_end = .;
    }

    . = ALIGN(16);
    . += 0x4000;
    __stack_top = .;
}

config.txt

These lines select our image, keep the UART clock stable, and avoid the rainbow splash screen:

arm_64bit=1
kernel=kernel8.img
enable_uart=1
core_freq=500
disable_splash=1

kernel=kernel8.img names the image explicitly, enable_uart=1 tells the firmware to leave the serial clocks alone for us, and core_freq=500 pins the clock that sets our baud rate.

Build it

With an AArch64 toolchain:

make

Example package

The full package includes the source files, config.txt, a Makefile, and a prebuilt kernel8.img: download hello-uart-example.zip.

Put it on the card

Onto a FAT32 SD card, copy:

What actually happens

The boot chain drops us at 0x80000 on all four cores. We keep core 0, prepare the stack C needs, and call kernel_main. uart_init points GPIO14 at the mini UART, enables the block, sets 8-bit words and the baud divisor, and switches on the transmitter.

Then uart_putc does the one essential trick of polled I/O: before writing a byte, it waits. The line-status register has a bit that means "there is room in the transmit FIFO." We spin on that bit until it is set, then drop the character into the data register and the hardware shifts it out the pin, bit by bit. No interrupts, no buffering — the CPU simply stands and watches each byte leave.

After the two lines are sent, kernel_main enters an empty loop and stays there. There is no LED diagnostic in this lesson; Tutorial 02 is deliberately about one thing: bringing up transmit-only UART, writing bytes, and proving that the board can speak.

That is wasteful, and later we will let interrupts do the waiting for us. But as a first driver it is perfect: every line is something you can point at and explain.

[^-^] |=| /_\

There it is — words on a screen, sent by a chip with no operating system but the one we are building. From now on the machine can talk back: print a value, announce a step, confess a crash. Our debugging just stopped being blind. Next we figure out exactly where the firmware dropped us — which privilege level we woke up in, and why it matters.

Try it

Wire up a USB-to-serial adapter (3.3 V logic) to the Pi's GPIO header:

Open a serial terminal on the adapter's port at 115200 baud, 8N1, insert the card, and power the board. Within a second you should see:

Hello, World
Alquist is awake.

If those lines appear, your operating system just spoke for the first time. Everything from here — every log, every panic message, every diagnostic — rides on the little driver you just wrote.