Writing A Simple ARM OS - Part 1
25 Feb 2025Introduction
In this series, we’ll build a small operating system for the ARM platform from the ground up. Along the way, we’ll explore fundamental OS concepts and incrementally add components, turning abstract ideas into working code. Each article will focus on a specific piece of the system, guiding you through the process step by step.
We’re going to use QEMU, an open-source emulator, so we can develop and test our code directly on a PC—no hardware required (for now).
In Part 1, we’ll first discuss ARM, then move on to the following:
- Installing prerequisites
- Setting up a project structure
- Creating a repeatable build environment
- Running your bootloader
The code for this article is available in my Github repository.
Let’s make a start.
What is ARM?
ARM, short for Advanced RISC Machine, is a family of Reduced Instruction Set Computing (RISC) architectures that power billions of devices, from smartphones and tablets to embedded systems and IoT devices. Originally known as Acorn RISC Machine, ARM has become a cornerstone of modern computing due to its energy efficiency and simplicity compared to Complex Instruction Set Computing (CISC) architectures like x86. Designed around the RISC philosophy, ARM processors use a small, highly optimized instruction set, enabling greater performance per watt and making them ideal for low-power and mobile environments.
Why Emulation?
While ARM assembly is usually executed on physical devices, emulation tools like QEMU allow you to:
- Test code without requiring hardware.
- Experiment with different ARM-based architectures and peripherals.
- Debug programs more effectively using tools like GDB.
Supported ARM Hardware
Before we begin coding, let’s take a brief look at some popular ARM-based platforms:
- Raspberry Pi: A widely used single-board computer.
- BeagleBone Black: A powerful option for embedded projects.
- STM32 Microcontrollers: Common in IoT and robotics applications.
Installing prerequisites
Before we begin, we need to setup our development and build environment. I’m using Manjaro so package names might be slightly different for your distro of choice.
To build our software, we’ll install the arm-none-eabi
toolchain, which provides the assembler (as
), linker (ld
),
and other essential utilities.
sudo pacman -S arm-none-eabi-binutils arm-none-eabi-gcc
We will also need a virtual machine / emulator to run the software that we build. We’ll use QEMU.
sudo pacman -S qemu-system-arm
With our toolchain and emulator installed, we’re ready to move forward.
Setup the Project
I’ve called my project armos
, and have created the following structure:
.
├── asm
├── build
├── docs
├── README.md
└── src
asm
will hold our assembly language modulesbuild
is where our binaries are built todocs
is for any documentation that we might havesrc
will hold our c language modules
Code!
Now that our project structure is in place, we can begin writing our first piece of assembly code: the bootloader.
If we add bootstrap.s
to the asm
folder we can make a start on the bootloader.
.section .text
.global _start
_start:
LDR sp, =stack_top @ initialize the stack pointer
BL kernel_main @ jump to the kernel main loop
kernel_main:
1: B 1b @ infinite loop to keep the OS running
B . @ fallback loop
.section .bss
.align 4
stack_top:
.space 1024 @ allocate 1kb for the stack
This is a pretty basic module to begin with. At the start we define our code with a .text
section and _start
is a
global symbol:
.section .text
.global _start
Next, we setup our stack pointer sp
by loading the address of our stack_top
. The equal sign preceeding stack_top
tells the assembler to load the immediate value of the address. We have stack_top
defined down a little further.
Then, we jump on to our kernel.
Interesting note, BL
which is Branch with Link works very much like a branch (B
) but it will store the address
from where we branched into the link register r14
.
_start:
LDR sp, =stack_top @ initialize the stack pointer
BL kernel_main @ jump to the kernel main loop
Now we have two endless loops setup. The first one loops back to the 1:
loop:
1: B 1b @ infinite loop to keep the OS running
If we do get an unexpected address sneak in for whatever reason, we’ve got a fallback loop that continually jumps to
itself using the shorthand .
.
B . @ fallback loop
Finally, we complete the module by defining our stack with the .bss
section. You’ll notice the stack_top
label that
we referenced earlier.
.section .bss
.align 4
stack_top:
.space 1024 @ allocate 1kb for the stack
Build environment
We need to make this easy to build, so we create a Makefile
in the root directory. The Makefile
will use the
toolchain that we installed earlier, building our binaries into the build
folder:
AS = arm-none-eabi-as
LD = arm-none-eabi-ld
OBJCOPY = arm-none-eabi-objcopy
# Files and directories
ASM_SRCS = asm/bootloader.s
BUILD_DIR = build
TARGET = armos.elf
all: $(BUILD_DIR)/$(TARGET)
$(BUILD_DIR)/$(TARGET): $(ASM_SRCS)
@mkdir -p $(BUILD_DIR)
$(AS) -o $(BUILD_DIR)/bootloader.o $(ASM_SRCS)
$(LD) -Ttext 0x0 -o $(BUILD_DIR)/$(TARGET) $(BUILD_DIR)/bootloader.o
$(OBJCOPY) -O binary $(BUILD_DIR)/$(TARGET) $(BUILD_DIR)/armos.bin
clean:
rm -rf $(BUILD_DIR)
Our call out to our assembler is pretty straight forward, trading our .s
files for .o
object files. We use
-Ttext 0x0
to explicitly tell the linker that our program should start at address 0x0
, which is necessary for
bare-metal environments.
Give it a build.
make
All going well you should see some output as follows:
arm-none-eabi-as -o build/bootloader.o asm/bootloader.s
arm-none-eabi-ld -Ttext 0x0 -o build/armos.elf build/bootloader.o
arm-none-eabi-objcopy -O binary build/armos.elf build/armos.bin
You should also find some built binaries in your build
folder.
First launch
We can give our new operating system a run via qemu with the following instruction:
qemu-system-arm -M versatilepb -kernel build/armos.elf -nographic
Here we have a few switches:
-M versatilepb
emulates a popular ARM development board-kernel build/armos.elf
loads our compiled bootloader/OS binary-nographic
runs qemu without a graphical user interface
If everything works, you won’t see much—your bootloader is running in an infinite loop, waiting for further development.
Debugging
Because we are running in a virtualised environment, we have a full debugger at our disposal. Having a debugger attached to your code when things aren’t quite going to plan can be very valuable to understand what’s happening in the internals of your program.
Using the -gdb
option, you can instruct qemu to open a debugging port.
qemu-system-arm -M versatilepb -kernel boot.elf -S -gdb tcp::1234
You can then connect to gdb with the following:
arm-none-eabi-gdb boot.elf
target remote :1234
Deployment
Finally, we’ll touch on deployment.
For deployment, we’ll use a Raspberry Pi as an example. This process is similar for other ARM-based boards.
Flashing
First, we need to convert the ELF file to a raw binary format suitable for booting:
arm-none-eabi-objcopy -O binary boot.elf boot.bin
Use a tool like dd
to write the binary to an SD card:
dd if=boot.bin of=/dev/sdX bs=512 seek=2048
Conclusion
In this post, we’ve built the foundation for armos. We’ve installed and configured the ARM toolchain, set up QEMU to emulate our target board, organized our project directory for clarity and scalability, and even wrote a simple bootloader to jumpstart our operating system. With these critical components in place, you’re now ready to embark on the next steps—enhancing the bootloader, adding essential kernel functionalities, and ultimately constructing a full-fledged minimalist OS.