Tutorial · 01 · AArch64

Let's blink

The smallest honest bare-metal program: make the green LED blink. No operating system, no library, no timer — just a pin and a loop.

[o-o] |=| /_\

Before a machine can speak, it can flash a light. Our first goal is the simplest proof of life there is: one blinking LED. If it blinks, the metal runs our code. That is the whole victory today.

Why not "Hello, World"?

On a normal computer, your first program prints text. On bare metal, printing text is surprisingly far away: there is no console, no driver, no printf. Before we can print anything, we need to bring up more hardware. That is useful, but it is not the smallest proof that our own code is running.

So we start one step lower. The Raspberry Pi 4 has a green activity LED wired to a GPIO pin. Toggling a pin needs nothing but a few memory writes. That makes blinking the LED the true "Hello, World" of bare metal.

How a Raspberry Pi 4 boots

Here is the surprising part: when you power on a Raspberry Pi 4, the ARM cores — the processor we think of as “the CPU” — stay switched off. The chip that actually boots the board is the VideoCore GPU. The BCM2711 packs both an ARM Cortex-A72 (four cores) and a VideoCore graphics processor onto one die, and it is the GPU that runs first and brings the whole system up. The ARM side only wakes at the very end, to run our code.

All of this happens on the GPU, before a single line of our code runs:

  1. A tiny boot ROM inside the GPU wakes up first. It is the only code guaranteed to already be there, burned into the silicon.
  2. It loads the second-stage bootloader from the board's SPI EEPROM (on the Pi 4 this lives in a flash chip, not on the SD card). The bootloader knows how to read a FAT partition on the SD card or USB.
  3. The bootloader reads config.txt and uses it to load the main GPU firmware, start4.elf.
  4. start4.elf — still running on the GPU — copies our kernel image into RAM at address 0x80000, then finally releases the ARM cores from reset so they start executing it.

That last image — the file the firmware loads and jumps to — is our entire program. For a 64-bit build the firmware looks for a file named kernel8.img. So our whole job today is to produce a kernel8.img whose first instruction lives at 0x80000 and blinks the LED.

One detail will shape our code: when the GPU lets the ARM side go, it starts all four CPU cores at 0x80000 at once. To blink a single LED we only want one core doing the work, so the very first thing our program does is send cores 1, 2 and 3 to sleep and keep only core 0 running.

The Raspberry Pi's boot ROM and GPU firmware are closed — we never see their source. We only get a contract: “put kernel8.img at 0x80000, and we will start the ARM cores on it.” That contract is all we need.

The plan

On the BCM2711 (the Pi 4 chip), peripherals are controlled by writing to fixed memory addresses. For our LED we need three registers in the GPIO block:

The green ACT LED on the Pi 4B used by Alquist is GPIO42. Driving it high turns it on. The recipe is: set GPIO42 to be an output, then forever turn it on, wait, turn it off, wait. Since we have no timer yet, "wait" is just a deliberately large loop that counts down — burning CPU cycles to pass visible time.

The code

This first program is only one assembly file. We do not need C yet: C would require a stack, zeroed globals, and a small runtime hand-off before the useful work begins. Today the useful work is just a few register writes and a delay loop, so we do it directly.

// blink.S - minimal bare-metal LED blink for Raspberry Pi 4 (BCM2711)

  .equ GPFSEL4, 0xFE200010   // function select for GPIO40-49
  .equ GPSET1,  0xFE200020   // set register for GPIO32-53
  .equ GPCLR1,  0xFE20002C   // clear register for GPIO32-53

  // .text.boot is a section name we invent here; the linker script places it
  // first, so _start ends up at 0x80000 - exactly where the firmware jumps.
  .section ".text.boot"
  .global _start

  _start:
    // All four ARM cores arrive here at once. We want only one core to blink
    // the LED, so each core asks "who am I?" and only core 0 continues.
    // The low bits of MPIDR_EL1 hold the core number (0, 1, 2 or 3).
    mrs     x0, mpidr_el1
    and     x0, x0, #3
    cbnz    x0, halt

    // GPIO42 sits in GPFSEL4. Its field index is 42 - 40 = 2,
    // so it occupies bits [8:6]. Writing 0b001 selects "output".
    ldr     x0, =GPFSEL4
    ldr     w1, [x0]
    mov     w2, #(7 << 6)
    bic     w1, w1, w2
    orr     w1, w1, #(1 << 6)
    str     w1, [x0]

    // In SET1/CLR1 the bit for GPIO42 is (42 - 32) = 10.
    mov     w3, #(1 << 10)

  blink:
    ldr     x0, =GPSET1
    str     w3, [x0]          // LED on
    bl      delay

    ldr     x0, =GPCLR1
    str     w3, [x0]          // LED off
    bl      delay

    b       blink

  // A primitive busy-wait, like the C delay loop would be: just count down.
  // No hardware timer, no interrupts, no precision - only visible time passing.
  delay:
    mov     x4, #0x200000
  1:  subs    x4, x4, #1
    bne     1b
    ret

  halt:
    wfe
    b       halt

The linker script

The firmware loads us at 0x80000, so the linker must place our entry there, with .text.boot first. There is no stack and no C runtime in this lesson, so the script stays deliberately small.

ENTRY(_start)

SECTIONS
{
    . = 0x80000;
    .text : { KEEP(*(.text.boot)) *(.text*) }
    .rodata : { *(.rodata*) }
    .data : { *(.data*) }
    .bss  : { *(.bss*) }
}

config.txt

These lines tell the firmware to boot in 64-bit mode, load our image, and avoid the rainbow splash screen:

arm_64bit=1
kernel=kernel8.img
disable_splash=1

Build it

With an AArch64 toolchain:

make

objcopy strips the ELF wrapper and leaves a raw binary — exactly the bytes the firmware expects to drop at 0x80000.

Example package

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

Put it on the card

Format an SD card as FAT32 and copy onto it:

The firmware files come from the official Raspberry Pi firmware boot/ folder.

What actually happens

When power arrives, the boot chain runs and jumps to 0x80000 with all four cores alive. We immediately read mpidr_el1; its low bits are the core number. Cores 1–3 branch to halt and sleep on wfe. Only core 0 continues.

We then write the GPIO function-select bits to make pin 42 an output, and enter the loop. Writing the GPIO42 bit to GPSET1 lights the LED; writing the same bit to GPCLR1 turns it off. Between them, delay simply decrements a register many times.

That delay is deliberately crude. It does not measure real time — its length depends on the CPU clock and how the chip feels today. For "is it alive?" that is perfectly fine. A proper, interrupt-driven timer comes several articles from now; the point here is that nothing is required to make hardware move except a loop and a memory write.

[^-^] |=| /_\

If your green light is pulsing, congratulations: a chip with no operating system is now running a program you wrote, byte for byte. Next we make the board report back instead of only blinking.

Try it

Insert the card, power the board, and watch the green ACT LED. It should blink steadily — on, off, on, off — after the firmware has loaded kernel8.img. If Linux no longer boots but the LED does not blink, the image probably started, but the observable part of this experiment failed; restore your Linux boot files before continuing.