Introduction
I’ve been passionate about low-level systems and cybersecurity for years. Recently, I’ve felt the urge to go deeper — to build something from scratch. Inspired by the QEMU AArch64 Bare Bones guide, I decided to take on a new challenge: writing an operating system targeting the AArch64 architecture.
But there’s a twist — instead of sticking with C, I’ve chosen to use Rust.
I’m not a Rustacean (yet). My background is in C, so diving into Rust is going to be part of the learning experience. This blog series will document everything: the mistakes, the learning curve, and the progress. I hope this can be useful if you’re interested in operating systems, AArch64, or you’re a C developer looking to dip your toes into Rust.
Why I’m Doing This
After years in C, I started feeling that itch — the one that says learn something new. I wanted a challenge that would push my skills and give me fresh perspectives on systems development. That’s where Rust comes in.
Rust has been gaining serious ground in the systems programming world, and for good reason. Between its safety guarantees, powerful tooling, and growing adoption (even the Linux kernel is adding support!), it feels like a good time to jump in.
Writing an OS is already an intense technical journey. Doing it in a language I’m still learning? That’s the kind of challenge I like. And if I can document my missteps and discoveries along the way, maybe I can help others who are on a similar path.
Why AArch64?
AArch64 (64-bit Arm) is everywhere now — from phones to SBCs (like the Raspberry Pi) to Apple Silicon. It’s not just an architecture for embedded systems anymore. Writing an OS for AArch64 is a great way to learn more about its internals and prepare for future systems development.
Why Rust?
- Memory safety without garbage collection
- Modern tooling and compiler diagnostics
- No undefined behavior by default
- Actively growing ecosystem in OS and embedded development
- Used in major projects (Linux, Windows, Firefox, etc.)
Development Environment Setup
To get started, I need a development environment that could:
- Compile AArch64 code
- Emulate AArch64 hardware
- Cross-compile with both GCC and Rust
Since I’m working on a different platform (x86_64), I use a VirtualBox VM running Ubuntu. Any recent version (22.04 LTS or later) should work — I’m using Ubuntu 24.04 in this series.
Option 1: Install Required Packages (Simpler)
sudo apt update && sudo apt upgrade
# QEMU for emulation
sudo apt install qemu-system-aarch64
# Binutils (objdump, etc.)
sudo apt install binutils-aarch64-linux-gnu
# Cross-compiler
sudo apt install gcc-14-aarch64-linux-gnu
Option 2: Build From Source (More Control)
# Binutils
git clone https://github.com/bminor/binutils-gdb.git
cd binutils-gdb
./configure --target=aarch64-elf --prefix=/opt/aarch64 --disable-nls
make -j$(nproc)
sudo make install
# GCC
git clone https://gcc.gnu.org/git/gcc.git
cd gcc
./contrib/download_prerequisites
mkdir build && cd build
../configure --target=aarch64-elf --prefix=/opt/aarch64 --disable-nls --enable-languages=c --without-headers
make all-gcc -j$(nproc)
sudo make install-gcc
Installing Rust
Install Rust using the official installer:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
After installation, restart your terminal or run:
source $HOME/.cargo/env
After installation, reload your shell or open a new terminal, then add the AArch64 bare-metal target:
rustup target add aarch64-unknown-none
This lets us compile Rust code for a freestanding, no-OS environment.
Hello World – First Bare-Metal Boot
Now that the development environment is set up, let’s build our very first bare-metal kernel for AArch64 in Rust.
This first milestone is simple: we’ll boot the kernel in QEMU and print a message using direct memory-mapped I/O (MMIO). No operating system, no standard library — just a bit of assembly and some Rust talking directly to the UART.
Project Layout
Here’s a breakdown of the core files:
aarch64_kernel/
├── Cargo.toml
├── linker.ld
├── Makefile
└── src/
├── asm/
│ └── boot.s
└── lib.rs
Cargo.toml
This file defines our Rust project and tells Cargo to build a static library (not an executable).
[package]
name = "aarch64_kernel" # Crate name (like a module name or lib name in C)
version = "0.1.0"
authors = ["Josep Comes "]
edition = "2024" # Rust language edition
[profile.dev]
opt-level = 0 # No optimizations for debug builds
debug = true # Include debug info for GDB or logging
[profile.release]
opt-level = 3 # Highest optimization for performance (release builds)
lto = true # Link-time optimization
debug = false
[lib]
crate-type = ["staticlib"] # Build this crate as a static library
linker.ld
This linker script defines how memory is laid out in our final ELF binary. It ensures our code starts at an appropriate address and that sections are properly aligned.
ENTRY(_start)
SECTIONS {
. = 0x40100000;
.text.init : {
boot.o(.text)
}
.text : {
*(.text)
}
.data : {
*(.data)
}
.bss : {
*(.bss COMMON)
}
. = ALIGN(8);
. += 0x1000;
stack_top = .;
}
We’re placing our kernel at physical address 0x40100000, which works well with QEMU’s virt machine model. We explicitly include our boot.o first, which contains the _start symbol.
Makefile
This is the build orchestration file. It compiles the boot assembly, builds the Rust kernel as a staticlib, and links them into an ELF binary using rust-lld.
# Target architecture and toolchain
TARGET := aarch64-unknown-none
AS := aarch64-linux-gnu-as
CC := aarch64-linux-gnu-gcc-14
CFLAGS := -Wall -ggdb -ffreestanding -nostdlib -I./include
LD := rust-lld
QEMU := qemu-system-aarch64
VERSION := debug
# File paths
SRC_DIR := src
ASM_DIR := $(SRC_DIR)/asm
BOOT_ASM := $(ASM_DIR)/boot.s
KERNEL_RS := $(SRC_DIR)/lib.rs
LINKER_SCRIPT := linker.ld
# Output filenames
BOOT_OBJ := $(ASM_DIR)/boot.o
CRATE_NAME := $(shell cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].name')
KERNEL_OBJ := target/$(TARGET)/$(VERSION)/lib$(CRATE_NAME).a
KERNEL_ELF := kernel.elf
# QEMU options
QEMU_FLAGS := -machine virt -cpu cortex-a57 -nographic -kernel $(KERNEL_ELF)
# Build the kernel
all: $(KERNEL_ELF)
# Assemble the boot.s to boot.o
$(BOOT_OBJ): $(BOOT_ASM)
$(AS) $< -o $@
# Compile the Rust kernel to an object file
$(KERNEL_OBJ): $(KERNEL_RS)
cargo build --target $(TARGET)
# Link the kernel object and boot object into an ELF
$(KERNEL_ELF): $(BOOT_OBJ) $(KERNEL_OBJ) $(LINKER_SCRIPT)
$(LD) -flavor gnu -o $(KERNEL_ELF) -T $(LINKER_SCRIPT) -o $@ $(BOOT_OBJ) $(KERNEL_OBJ)
# Run the kernel with QEMU
run: $(KERNEL_ELF)
$(QEMU) $(QEMU_FLAGS)
# Clean up build artifacts
clean:
cargo clean
rm -rf target
rm -f $(BOOT_OBJ) $(KERNEL_ELF)
.PHONY: all run clean
boot.s
This is the first code that runs when the kernel is loaded. It sets up a stack and jumps into our Rust code.
.section .text
.global _start
_start:
ldr x30, =stack_top
mov sp, x30
bl kmain
b .
_start is our entry point, declared in the linker script. After setting the stack, it calls kmain() — which is written in Rust!
lib.rs
This is our Rust "kernel" — just enough to send a message to the UART via MMIO.
#![no_main]
#![no_std]
use core::panic::PanicInfo;
const UART: *mut u8 = 0x0900_0000 as *mut u8;
#[no_mangle]
pub extern "C" fn kmain() {
print(b"Hello, from Rust!\n");
}
fn putchar(c: u8) {
unsafe {
*UART = c;
}
}
fn print(s: &[u8]) {
for &c in s {
putchar(c);
}
}
#[panic_handler]
fn panic(_: &PanicInfo) -> ! {
print(b"Panic!\n");
loop {}
}
This is freestanding Rust — no standard library, no runtime. It talks directly to the UART at address 0x0900_0000, which QEMU maps to a PL011 UART on the virt machine.
Run It
With everything in place, just run:
make run
And you should see the output:
At this point, you’ve bootstrapped a minimal OS kernel targeting AArch64, written in Rust, running in QEMU. Not bad for a first step!

Next Steps
This was a minimal first step, but it laid the foundation. From here, I’ll start building out more complex parts of the system — memory initialization, exception handling, and eventually setting up paging and a basic kernel structure.
In the next post, I’ll go into the memory layout, the AArch64 exception levels, and the early steps toward entering EL1 properly.
As always, I’ll be documenting everything along the way — both the progress and the mistakes.
You can follow the code and progress on the GitHub repository, where I’ll be pushing all changes as the project evolves.
See you on the next post!!