Cogs and Levers A blog full of technical stuff

Writing A Simple ARM OS - Part 4

Introduction

In Part 3, we explored ARM calling conventions, debugging, and cleaned up our UART driver. While assembly has given us fine control over the hardware, writing an entire OS in assembly would be painful.

It’s time to enter C land.

This post covers:

  • Modifying the bootloader to transition from assembly to C
  • Updating the UART driver to be callable from C
  • Writing our first C function (kmain())
  • Adjusting the Makefile and linker script for C support

Booting into C

We still need a bit of assembly to set up the stack and call kmain(). Let’s start by modifying our bootloader.

Updated bootloader.s

.section .text
.global _start

_start:
    LDR sp, =stack_top  @ Set up the stack
    BL kmain            @ Call the C kernel entry function
    B .                 @ Hang forever

.section .bss
.align 4
stack_top:
.space 1024

What’s changed?

  • We load the stack pointer (sp) before calling kmain(). This ensures C has a valid stack to work with.
  • We branch-and-link (BL) to kmain(), following ARM’s calling conventions.
  • The infinite loop (B .) prevents execution from continuing into unknown memory if kmain() ever returns.

With this setup, execution will jump to kmain()—which we’ll define next.

Our First C Function: kmain()

Now that we can transition from assembly to C, let’s create our first function.

kmain.c

#include "uart.h"

void kmain() {
    uart_puts("Hello from C!\n");
    while (1);
}

What’s happening?

  • We include our uart.h header so we can call uart_puts().
  • kmain() prints "Hello from C!" using our UART driver.
  • The infinite while(1); loop prevents execution from continuing into unknown territory.

At this point, our OS will boot from assembly, call kmain(), and print text using our UART driver—but we need to make a few more changes before this compiles.

Making the UART Driver Callable from C

Right now, uart_puts and uart_putc are assembly functions. To call them from C, we need to:

  1. Ensure they follow the ARM calling convention.
  2. Declare them properly in a header file.

uart.h (Header File)

#ifndef UART_H
#define UART_H

void uart_putc(char c);
void uart_puts(const char *str);

#endif

Updated uart.s

.section .text
.global uart_putc
.global uart_puts

uart_putc:
    PUSH {lr}
    ldr r1, =0x101f1000  @ UART0 Data Register
    STRB r0, [r1]        @ Store byte
    POP {lr}
    BX lr

uart_puts:
    PUSH {lr}

next_char:
    LDRB r1, [r0], #1    @ Load byte from string
    CMP r1, #0
    BEQ done

wait_uart:
    LDR r2, =0x101f1018  @ UART0 Flag Register
    LDR r3, [r2]
    TST r3, #0x20        @ Check if TX FIFO is full
    BNE wait_uart

    LDR r2, =0x101f1000  @ UART0 Data Register
    STR r1, [r2]         @ Write character
    B next_char

done:
    POP {lr}
    BX lr

How this works:

  • Function names are declared .global so they are visible to the linker.
  • uart_putc(char c)
    • Expects a character in r0 (following ARM’s C calling convention).
    • Writes r0 to the UART data register.
  • uart_puts(const char *str)
    • Expects a pointer in r0.
    • Iterates through the string, sending each character until it reaches the null terminator (\0).
  • Preserving Registers
    • PUSH {lr} ensures lr is restored before returning.

Updating the Build System

To compile both assembly and C, we need to adjust the Makefile and linker script.

Updated Makefile

# Makefile for the armos project

# Cross-compiler tools (assuming arm-none-eabi toolchain)
AS = arm-none-eabi-as
CC = arm-none-eabi-gcc
LD = arm-none-eabi-ld
OBJCOPY = arm-none-eabi-objcopy

# Compiler and assembler flags
CFLAGS = -ffreestanding -nostdlib -O2 -Wall -Wextra
ASFLAGS =
LDFLAGS = -T linker.ld -nostdlib

# Files and directories
BUILD_DIR = build
TARGET = armos.elf
BIN_TARGET = armos.bin

# Source files
ASM_SRCS = asm/bootloader.s asm/uart.s
C_SRCS = src/kmain.c
OBJS = $(BUILD_DIR)/bootloader.o $(BUILD_DIR)/uart.o $(BUILD_DIR)/kmain.o

# Build rules
all: $(BUILD_DIR)/$(BIN_TARGET)

$(BUILD_DIR):
	@mkdir -p $(BUILD_DIR)

# Assemble the bootloader and UART
$(BUILD_DIR)/bootloader.o: asm/bootloader.s | $(BUILD_DIR)
	$(AS) $(ASFLAGS) -o $@ $<

$(BUILD_DIR)/uart.o: asm/uart.s | $(BUILD_DIR)
	$(AS) $(ASFLAGS) -o $@ $<

# Compile the C source file
$(BUILD_DIR)/kmain.o: src/kmain.c | $(BUILD_DIR)
	$(CC) $(CFLAGS) -c -o $@ $<

# Link everything together
$(BUILD_DIR)/$(TARGET): $(OBJS)
	$(LD) $(LDFLAGS) -o $@ $(OBJS)

# Convert ELF to binary
$(BUILD_DIR)/$(BIN_TARGET): $(BUILD_DIR)/$(TARGET)
	$(OBJCOPY) -O binary $< $@

# Clean build artifacts
clean:
	rm -rf $(BUILD_DIR)

Updating the Linker Script

The linker script ensures that kmain() and our code are properly loaded in memory.

Updated linker.ld

ENTRY(_start)

SECTIONS
{
    . = 0x10000; /* Load address of the kernel */

    .text : {
        *(.text*)
    }

    .rodata : {
        *(.rodata*)
    }

    .data : {
        *(.data*)
    }

    .bss : {
        *(COMMON)
        *(.bss*)
    }
}

Key Changes:

  • Code starts at 0x10000, ensuring it is loaded correctly.
  • .text, .rodata, .data, and .bss sections are properly defined.

Build

Now that all of these changes in place, we can make our kernel and run it. If everything has gone to plan, you should see our kernel telling us that it’s jumped to C.

Hello from C!

Conclusion

We’ve successfully transitioned from pure assembly to using C for higher-level logic, while keeping low-level hardware interaction in assembly.

The code for this article is available in the GitHub repo.

Getting Started Developing for the LILYGO T-Deck

Introduction

The LILYGO T-Deck is a compact, powerful handheld development device based on the ESP32-S3 microcontroller. It features a 2.8-inch touchscreen, keyboard, trackball, microphone, speaker, and optional LoRa/GPS support, making it ideal for portable embedded systems, IoT applications, and even cybersecurity projects.

T-Deck

In this post, we’ll explore:

  • What the ESP32 microcontroller is.
  • The ESP32-S3 architecture and why it’s powerful.
  • How to set up Arduino IDE for development
  • How to set up ESP-IDF for development
  • Writing and flashing your first ESP-IDF program to print output to the serial monitor.
  • Troubleshooting common setup issues.

What is the ESP32?

The ESP32 is a family of low-cost, low-power system-on-chip (SoC) microcontrollers developed by Espressif Systems. It is widely used for IoT, wireless communication, embedded systems, and AI applications due to its feature-rich architecture.

Some of the key features from the ESP32 are:

  • Dual-core Xtensa LX6 (ESP32) or RISC-V (ESP32-C3/S3) processors.
  • Wi-Fi 802.11 b/g/n and Bluetooth 4.2/5.0 support.
  • Ultra-low power consumption with deep sleep modes.
  • Rich peripherals: SPI, I2C, I2S, UART, ADC, DAC, PWM, and capacitive touch.
  • On-chip SRAM and external PSRAM support.
  • Real-time processing with FreeRTOS.

This alone is an awesome platform to put your development projects together.

ESP32-S3

The ESP32-S3 features a dual-core 32-bit Xtensa LX7 CPU with AI acceleration support and integrated USB, making it ideal for IoT, edge computing, and AI-powered applications.

Development Environment

We need a way to be able to develop software for this chip, so we have some things to install.

You can use a lot of different tools in order to write your software. Each have their own plugins that you can use to get the code flashed onto hardware. I use the Arduino IDE as it’s just simple to use.

Arduino IDE

The quickest way to get started is to follow the steps on the Xinyuan-LilyGO / T-Deck instructions up on GitHub. I’ve summarised those steps here for reference.

First up, get Arduino IDE installed.

Once you’ve got Arduino IDE running, open up “Preferences” to the “Settings” tab. We need to add an additional board manager URL for the ESP32 series of boards: https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json. I had quite a few issues running code that included TFT_eSPI unless I ran version 2.0.14 of these boards.

After this step you should be able to select ESP32S3 Dev Module as your board. This is what we’ll be deploying to.

From the Xinyuan-LilyGO / T-Deck repository, take all of the libraries under the lib folder and copy them into your Arduino libraries folder. You should end up with something similar to this in your Arduino folder:

└── libraries
    ├── AceButton
    ├── Arduino_GFX
    ├── es7210
    ├── ESP32-audioI2S
    ├── lvgl
    ├── RadioLib
    ├── SensorsLib
    ├── TFT_eSPI
    ├── TinyGPSPlus
    └── TouchLib

To finish the configuration, the “Tools” menu should have the following settings:

Setting Value
Board ESP32S3 Dev Module
USB CDC On Boot Enabled
CPU Frequency 240 MHz (WiFi)
Core Debug Level None
USB DFU On Boot Disabled
Erase All Flash Before Sketch Upload Disabled
Events Run On Core 1
Flash Mode QIO 80MHz
Flash Size 16MB (128Mb)
JTAG Adapter Disabled
Arduino Runs On Core 1
USB Firmware MSC On Boot Disabled
Partition Scheme 16M Flash (3MB APP/9.9MB FATFS)
PSRAM OPI PSRAM
Upload Mode UART0 / Hardware CDC
Upload Speed 921600
USB Mode Hardware CDC and JTAG

We should be ready to go now.

First Program

Let’s write some super simple code, just to prove that we’re able to flash this device with the software that we’re writing.

void setup()
{
  Serial.begin(115200);

  delay(1000);
  Serial.println("T-DECK: Setup");
}

void loop()
{
  Serial.println("T-DECK: Loop");
  delay(1000);
}

The functions setup and loop should be very familiar to anyone who has written Arduino code.

The setup function is executed once, at the start. It’s normally used to set the board up. The loop function is executed repeatedly from there, until the board is turned off.

To setup, we use Serial.begin to set the rate of data for serial transmission. A delay is used to let the board settle, and then we write our first message out.

Our loop simply writes the T-DECK: Loop string once every second.

You should see something like this in your serial monitor:

T-DECK: Setup
T-DECK: Loop
T-DECK: Loop
T-DECK: Loop
T-DECK: Loop

Arduino-ESP32 is ideal for newcomers and hobby project as it’s quite simple to get running and just generally has a lower barrier to entry. You can get basic applications achieved quickly.

ESP-IDF

To unlock more power of your board, ESP-IDF (the Espressif IoT Development Framework) is available. ESP-IDF allows you to break out of the setup() and loop() structures and allows you to write task-based applications.

You’ll get some better debugging and error handling, it is FreeRTOS-based, and you’ll also get immediate updates and bug fixes.

The process to get up and running can vary depending on the chip that you’re developing for. Espressif have pretty good documentation on their site with the Getting Started guide being available for all of their chip sets.

ESP32S3 which is what I’m using is really easy to get started with.

Dependencies

First are some operating system dependencies. As above, I’m on Arch Linux so the following dependencies are what I needed:

sudo pacman -S --needed gcc git make flex bison gperf python cmake ninja ccache dfu-util libusb

ESP-IDF

The installation of ESP-IDF is quite simple. It’s just grabbing their github repository at a given version into a well known directory on your machine:

cd ~
git clone -b v5.2.5 --recursive https://github.com/espressif/esp-idf.git

Tools

You can now use install.sh bundled with the github repository to install any extra tooling required for your board.

cd ~/esp-idf
./install.sh esp32s3

Integration

Finally, you’re going to need a way to drop into the ESP-IDF environment whenever you want. You can always just remember to do this anytime you want to do any development; but I prefer to make an alias in my ~/.zshrc file.

alias get_idf='source $HOME/esp-idf/export.sh'

Now, anytime I want to drop into that environment; I simply issue get_idf at the shell.

Ready

You’re just about ready to start development. So, let’s start a new project.

Get a copy of the hello_world example from the ~/esp-idf/examples/get-started folder, and put it into your source folder somewhere (where ever you normally work from):

cp -r ~/esp-idf/examples/get-started/hello_world ~/src/tmp/hw

cd ~/src/tmp/hw

Code

Let’s take a quick look at the hello world example code:

void app_main(void)
{
    printf("Hello world!\n");

    /* Print chip information */
    esp_chip_info_t chip_info;
    uint32_t flash_size;
    esp_chip_info(&chip_info);
    printf("This is %s chip with %d CPU core(s), %s%s%s%s, ",
           CONFIG_IDF_TARGET,
           chip_info.cores,
           (chip_info.features & CHIP_FEATURE_WIFI_BGN) ? "WiFi/" : "",
           (chip_info.features & CHIP_FEATURE_BT) ? "BT" : "",
           (chip_info.features & CHIP_FEATURE_BLE) ? "BLE" : "",
           (chip_info.features & CHIP_FEATURE_IEEE802154) ? ", 802.15.4 (Zigbee/Thread)" : "");

    unsigned major_rev = chip_info.revision / 100;
    unsigned minor_rev = chip_info.revision % 100;
    printf("silicon revision v%d.%d, ", major_rev, minor_rev);
    if(esp_flash_get_size(NULL, &flash_size) != ESP_OK) {
        printf("Get flash size failed");
        return;
    }

    printf("%" PRIu32 "MB %s flash\n", flash_size / (uint32_t)(1024 * 1024),
           (chip_info.features & CHIP_FEATURE_EMB_FLASH) ? "embedded" : "external");

    printf("Minimum free heap size: %" PRIu32 " bytes\n", esp_get_minimum_free_heap_size());

    for (int i = 10; i >= 0; i--) {
        printf("Restarting in %d seconds...\n", i);
        vTaskDelay(1000 / portTICK_PERIOD_MS);
    }
    printf("Restarting now.\n");
    fflush(stdout);
    esp_restart();
}
  • We’re printing "Hello world!"
  • We gather and print some chipset information
  • We gather and print some memory information
  • We countdown from 10, and restart

This program will continue in a loop, restarting the device.

Running

Connect your device to the machine now. When I connect mine, it uses /dev/tty:

ls /dev/tty*

/dev/ttyACM0

You’ll need to find yours on your machine, as you’ll use this reference to flash software onto.

Configure

idf.py set-target esp32s3
idf.py menuconfig

The set-target step will setup the necessary configurations for that specific board type. The menuconfig step will allow you to customise any of those configs. I’ve always been fine to leave those configs, save and quit menuconfig.

Build

Now we can build.

idf.py build

After a bit of console scrolling, you should be left with some completion notes:

Executing action: all (aliases: build)
Running make in directory /home/michael/src/tmp/hw/build
Executing "make -j 10 all"...
[  0%] Built target memory.ld
[  0%] Built target sections.ld.in

. . .
. . . lots of text here
. . .

[100%] Built target hello_world.elf
[100%] Built target gen_project_binary
hello_world.bin binary size 0x2bd40 bytes. Smallest app partition is 0x100000 bytes. 0xd42c0 bytes (83%) free.
[100%] Built target app_check_size
[100%] Built target app

Project build complete. To flash, run:
idf.py flash
or
idf.py -p PORT flash
or
python -m esptool --chip esp32s3 -b 460800 --before default_reset --after hard_reset write_flash --flash_mode dio --flash_size 2MB --flash_freq 80m 0x0 build/bootloader/bootloader.bin 0x8000 build/partition_table/partition-table.bin 0x10000 build/hello_world.bin
or from the "/home/michael/src/tmp/hw/build" directory
python -m esptool --chip esp32s3 -b 460800 --before default_reset --after hard_reset write_flash "@flash_args"

Now we can flash this onto our device.

idf.py -p /dev/ttyACM0 flash

Your device should now be running your software.

You can confirm this (for this particular program) by monitoring the serial output:

idf.py -p /dev/ttyACM0 monitor

You should see some output like this:

This is esp32s3 chip with 2 CPU core(s), WiFi/BLE, silicon revision v0.2, 2MB external flash
Minimum free heap size: 393180 bytes
Restarting in 10 seconds...
Restarting in 9 seconds...
Restarting in 8 seconds...
Restarting in 7 seconds...
Restarting in 6 seconds...
Restarting in 5 seconds...
Restarting in 4 seconds...
Restarting in 3 seconds...
Restarting in 2 seconds...
Restarting in 1 seconds...
Restarting in 0 seconds...
Restarting now.
ESP-ROM:esp32s3-20210327
Build:Mar 27 2021

. . .
. . . lots of text here
. . . 

As we saw when we looked through the code, this is exactly what was expected.

Conclusion

We’ve explored two different ways to set up and develop software for ESP32-based chips: Arduino-ESP32 for quick prototyping and ESP-IDF for professional-grade development. The LILYGO T-Deck, with its touchscreen, keyboard, and connectivity options, makes an excellent platform for embedded applications, whether you’re experimenting with IoT, cybersecurity tools, or custom handheld devices.

If you’re new to embedded development, starting with Arduino-ESP32 is a great way to get familiar with the hardware. But to unlock the full power of the ESP32-S3, including multi-threading, advanced debugging, and FreeRTOS integration, consider diving deeper into ESP-IDF.

I hope to use the information in this article as a base platform for writing more posts in the future.

Writing A Simple ARM OS - Part 3

Introduction

In Part 2, we implemented a basic UART driver to send text output from our OS. We put some functions together that deal with single characters and strings.

Today’s article has a focus on some calling conventions, debugging, and hopefully making that UART driver a little more robust.

Let’s take a hot lap of the ARM architecture first.

Registers

ARM processors have a well-defined set of registers, which can be categorized based on their usage. Below is a breakdown of all the ARM registers, including their general purpose, special purpose, and system control registers.

General purpose registers

These registers are used for storing temporary values and passing function arguments.

Register Purpose
r0r11 Function arguments & return values (general purpose)
r12 Intra-procedure scratch register (IP, sometimes used as a temporary register)
Note: ARM conventions say that r0-r3 and r12 can be freely modified by functions, and the caller must save them if needed. r4-r11 should be restored by the function before returning.

Special purpose registers

These registers serve specific roles in function calls, memory access, and system control.

Register Purpose
r13 sp (Stack Pointer) Points to the top of the current stack
r14 lr (Link Register) Stores return address for function calls
r15 pc (Program Counter) Holds the address of the next instruction to execute
  • lr (r14) is used for storing return addresses when calling functions.
  • pc (r15) is automatically incremented as instructions execute. You can branch by writing directly to pc.

Program Status Registers

The Current Program Status Register (CPSR) and Saved Program Status Register (SPSR) store flags and mode-related information.

Register Purpose
CPSR Holds flags, processor mode, and interrupt status
SPSR Stores CPSR when entering an exception mode

Key CPSR flags (Condition Flags):

  • N (Negative) – Set if the result of an operation is negative.
  • Z (Zero) – Set if the result of an operation is zero.
  • C (Carry/Borrow) – Set if an operation results in a carry/borrow.
  • V (Overflow) – Set if an arithmetic operation overflows.

Processor Mode Bits (M[4:0]):

  • 0b10000 – User mode
  • 0b10001 – FIQ (Fast Interrupt) mode
  • 0b10010 – IRQ (Normal Interrupt) mode
  • 0b10011 – Supervisor mode
  • 0b10111 – Abort mode
  • 0b11011 – Undefined instruction mode
  • 0b11111 – System mode (privileged user mode)

Banked Registers (Mode-Specific Registers)

ARM has banked registers that are only accessible in specific processor modes (e.g., IRQ, FIQ). These registers allow fast context switching between different execution states.

Mode Extra Registers Available
FIQ Mode r8_fiqr14_fiq (separate registers for FIQ context)
IRQ Mode r13_irq, r14_irq (separate SP and LR for IRQ)
Supervisor Mode r13_svc, r14_svc (separate SP and LR for SVC)
Abort Mode r13_abt, r14_abt
Undefined Mode r13_und, r14_und

Why banked registers?

  • Interrupt handlers can run without disturbing normal user-space registers.
  • Faster execution because it eliminates the need to save/restore shared registers.

Debug Registers (ARMv7+)

ARM processors often include special registers for debugging, including breakpoints and watchpoints.

Register Purpose
DBGDSCR Debug Status and Control Register
DBGBVR Breakpoint Value Register
DBGBCR Breakpoint Control Register
DBGWVR Watchpoint Value Register
DBGWCR Watchpoint Control Register

Understanding ARM Calling Conventions

ARM assembly follows a convention for passing function parameters and preserving registers:

  • Caller-saved registers (r0-r3, r12): These are freely used by functions and must be saved by the caller if needed.
  • Callee-saved registers (r4-r11, lr): A function must preserve and restore these if it modifies them.
  • Return values: r0 holds the return value.

Understanding this is key to writing reliable functions.

Upgrading uart_puts

We’re going to upgrade our uart_puts to “behave” a little nicer for us.

uart_puts:
    push {lr}              @ Save return address

next_char:
    ldrb r1, [r0], #1      @ Load byte from string and increment pointer
    cmp r1, #0             @ Check if null terminator
    beq done               @ If so, return

wait_uart:
    ldr r2, =UART0_FR      @ Load address of UART flag register
    ldr r3, [r2]           @ Read UART flag register
    tst r3, #TXFF          @ Check if TX FIFO is full
    bne wait_uart          @ If full, wait

    ldr r2, =UART0_DR      @ Load address of UART data register
    str r1, [r2]           @ Write character to UART
    b next_char            @ Process next character

done:
    pop {lr}               @ Restore return address
    bx lr                  @ Return

Let’s break this down piece by piece.

We save off lr (the link register) which is our return address from where we were called.

uart_puts:
    push {lr}              @ Save return address

ldrb takes the next source byte from our string, and we check if we’re finished. next_char is the loop point that we come back to, to process the remainder of the string.

next_char:
    ldrb r1, [r0], #1      @ Load byte from string and increment pointer
    cmp r1, #0             @ Check if null terminator
    beq done               @ If so, return

Next we wait for the UART buffer in case it’s full

wait_uart:
    ldr r2, =UART0_FR      @ Load address of UART flag register
    ldr r3, [r2]           @ Read UART flag register
    tst r3, #TXFF          @ Check if TX FIFO is full
    bne wait_uart          @ If full, wait

Using str we write that source byte out to the UART data register, and continue to the next character in the loop.

    ldr r2, =UART0_DR      @ Load address of UART data register
    str r1, [r2]           @ Write character to UART
    b next_char            @ Process next character

We finish up by restoring lr before returning to where we were called from.

done:
    pop {lr}               @ Restore return address
    bx lr                  @ Return

Debugging ARM Assembly

Debugging low-level assembly can be challenging. Here are some useful techniques to diagnose function issues.

One of the simplest ways to trace execution is to print special debug characters in the UART output:

MOV r0, #'!'
BL uart_putc    @ Print a debug marker to trace execution

If your code stops working after a certain point, inserting markers helps pinpoint where execution breaks.

Step Through Execution in QEMU

QEMU provides debugging features that let us step through execution. Start QEMU with GDB support:

qemu-system-arm -M versatilepb -kernel build/armos.elf -S -gdb tcp::1234 -nographic

Then, in another terminal, start GDB:

gdb-multiarch -ex "target remote localhost:1234" -ex "symbol-file build/armos.elf"

You can now use GDB to step through instructions, inspect register values, and find where execution goes wrong:

  • layout asm – View assembly.
  • info registers – Check register values.
  • si – Step instruction-by-instruction.

Dump Memory to Check String Pointers

If your function is reading garbage characters, your pointer might be wrong. Dump memory to check:

x/20xb 0x100000  # View first 20 bytes at memory location 0x100000

This helps verify if r4 is pointing to the correct string.

Conclusion

In this part, we focused a little bit more on assembly language and some of the calling conventions.

The code for this article can be found up in the github repo.

Writing A Simple ARM OS - Part 2

Introduction

In the first article of this series, we built a basic ARM bootloader and ran it in QEMU. However, debugging without output can be frustrating. In this part, we’ll set up UART (Universal Asynchronous Receiver-Transmitter) to send simple text messages from our OS.

UART is a fundamental interface used for serial communication in embedded systems. It allows us to send and receive characters over a hardware interface, making it useful for early-stage debugging before more complex peripherals like displays or networking are available.

By the end of this article, we’ll have a basic UART driver that prints text to the terminal using QEMU.

What is UART?

UART is a hardware component that enables serial communication by sending and receiving data one bit at a time. It typically operates over two wires:

  • TX (Transmit): Sends data.
  • RX (Receive): Receives data.

In most ARM-based systems, UART is memory-mapped, meaning we can control it by writing to specific memory addresses.

Configuring UART in QEMU

We’ll use PL011 UART, a common ARM serial interface. QEMU provides an emulated PL011 UART at a known memory address, allowing us to send text output to the terminal.

To enable UART output, we need to run QEMU with the following command:

qemu-system-arm -M versatilepb -kernel build/armos.elf -serial stdio -nographic
  • -serial stdio redirects UART output to our terminal.
  • -nographic ensures we run without a graphical display.

You may receive an error message like the following:

qemu-system-arm: -serial stdio: cannot use stdio by multiple character devices
qemu-system-arm: -serial stdio: could not connect serial device to character backend 'stdio'

This is just telling you that you’re already redirecting the serial output because of the -nographic switch. If you do see this, you’re free to simply drop the -serial stdio.

With this setup, once we implement our UART driver, we’ll see printed text appear in the terminal.

Writing a UART Driver

PL011 UART Memory-Mapped Registers

The PL011 UART controller is accessible via memory-mapped I/O registers. The key register we need for output is at address 0x101f1000 and is called the UART Data Register (DR). Writing a byte to this register sends a character over UART.

Implementing UART Functions

We create a new file, uart.s, in the asm/ directory:

.equ UART0_DR, 0x101f1000

.section .text
.global uart_putc
.global uart_puts

uart_putc:
    STRB r0, [r1]        @ Store byte from r0 into UART data register
    BX lr

uart_puts:
    LDR r1, =UART0_DR
1:
    LDRB r0, [r2], #1   @ Load byte from string, increment pointer
    CMP r0, #0          @ Check for null terminator
    BEQ 2f              @ Branch to 2 if we are done
    BL uart_putc        @ If not, call putc
    B 1b                @ Keep looping
2:
    BX lr               @ Return to caller
  • uart_putc(char): Sends a single character to the UART register.
  • uart_puts(string): Iterates through a null-terminated string and sends each character.

Printing a Message from the Bootloader

Now that we have a UART driver, we can modify our bootloader (bootstrap.s) to print a message.

.section .text
.global _start

_start:
    LDR sp, =stack_top
    LDR r2, =hello_msg
    BL uart_puts
    B .

.data
hello_msg:
    .asciz "Hello, ARM World!\n"

.section .bss
.align 4
stack_top:
.space 1024

Here’s what’s happening:

  • We load the stack pointer (sp).
  • We load the address of the string hello_msg into r2.
  • We call uart_puts to print the message.
  • The program enters an infinite loop (B .).

Updating the Makefile

We need to update our Makefile to include uart.s:

AS = arm-none-eabi-as
LD = arm-none-eabi-ld
OBJCOPY = arm-none-eabi-objcopy

# Files and directories
ASM_SRCS = asm/bootstrap.s asm/uart.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/bootstrap.s
	$(AS) -o $(BUILD_DIR)/uart.o asm/uart.s
	$(LD) -Ttext 0x0 -o $(BUILD_DIR)/$(TARGET) $(BUILD_DIR)/bootloader.o $(BUILD_DIR)/uart.o
	$(OBJCOPY) -O binary $(BUILD_DIR)/$(TARGET) $(BUILD_DIR)/armos.bin

clean:
	rm -rf $(BUILD_DIR)

Building

We can give our new modifications a build now.

make

If successful, you should see:

arm-none-eabi-as -o build/bootloader.o asm/bootstrap.s
arm-none-eabi-as -o build/uart.o asm/uart.s
arm-none-eabi-ld -Ttext 0x0 -o build/armos.elf build/bootloader.o build/uart.o
arm-none-eabi-objcopy -O binary build/armos.elf build/armos.bin

Running

We can run our new image now:

qemu-system-arm -M versatilepb -kernel build/armos.elf -nographic

Expected output:

Hello, ARM World!

Conclusion

We now have basic UART output working in our OS! This is a critical step because it allows us to debug our OS by printing messages. As always you find the code up in my GitHub repository.

With this foundational work in place, we’re one step closer to a functional ARM-based OS. Stay tuned for Part 3!

Writing A Simple ARM OS - Part 1

Introduction

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 modules
  • build is where our binaries are built to
  • docs is for any documentation that we might have
  • src 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:

Caution: Be very careful with the dd command! Double-check /dev/sdX before running it to avoid overwriting important data.
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.