Writing A Simple ARM OS - Part 4
02 Mar 2025Introduction
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 callingkmain()
. 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 ifkmain()
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 calluart_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:
- Ensure they follow the ARM calling convention.
- 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.
- Expects a character in
uart_puts(const char *str)
- Expects a pointer in
r0
. - Iterates through the string, sending each character until it reaches the null terminator (
\0
).
- Expects a pointer in
- Preserving Registers
PUSH {lr}
ensureslr
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.