- How the Pieces Fit Together
- Startup Code
- Application Code
- Linker Script
- Building and Running on QEMU
- Conclusion
Bare-metal programming involves running software directly on a processor without an operating system or runtime layer. At reset, the CPU starts execution with minimal state, and all necessary initialisation must be handled by the program itself.
This post presents a minimal bare-metal program that uses an ARMv7 platform under QEMU to show how startup code, a linker script, and a simple C program work together to produce serial output "hello".
How the Pieces Fit Together
A bare-metal program is typically split into three parts, each with a distinct role:
- startup code to define the first instructions the CPU executes
- application code written in C
- linker script to describe how everything is placed in memory
Startup Code
After reset, the CPU has no stack and no notion of a C environment. Execution begins at a symbol named _start. The startup code establishes the minimum state required to run C code and then transfers control.
startup.s
.global _start
_start:
ldr sp, =stack_top
bl main
Two things happen here:
- The stack pointer is set to an address defined by the linker
- Control jumps to
main
From the processor’s point of view, this file defines how execution begins.
Application Code
There is no standard output or console abstraction. Output is produced by writing directly to a UART peripheral using Memory Mapped IO MMIO registers.
main.c
#define UART0_DR ((volatile unsigned int*)0x10009000)
#define UART0_FR ((volatile unsigned int*)0x10009018)
void main(void) {
const char *s = "hello\n";
while (*s) {
while (*UART0_FR & (1 << 5));
*UART0_DR = *s++;
}
while (1);
}
The UART registers live at fixed addresses in memory. Writing a value to the data register transmits a character. The flag register indicates whether the transmit FIFO can accept new data.
The program runs indefinitely after printing to avoid falling through into undefined execution.
Linker Script
Without an operating system, memory layout must be defined explicitly. The linker script describes where code lives and where the stack is placed.
linker.ld
ENTRY(_start)
SECTIONS
{
. = 0x60010000;
.text : { *(.text) }
. = ALIGN(8);
. = . + 0x1000; /* 4KB stack */
stack_top = .;
}
This file defines:
- The program entry point
- The address where code is loaded
- A region reserved for the stack
Building and Running on QEMU
The program is built using a bare-metal ARM toolchain and executed on the vexpress-a9 machine model.
arm-none-eabi-as -mcpu=cortex-a9 -o startup.o startup.s
arm-none-eabi-gcc -mcpu=cortex-a9 -c -O2 -nostdlib -nostartfiles -o main.o main.c
arm-none-eabi-ld -T linker.ld -o hello.elf startup.o main.o
arm-none-eabi-objcopy -O binary hello.elf hello.bin
To run:
qemu-system-arm -M vexpress-a9 -m 32M -nographic -kernel hello.bin
The serial output appears directly in the terminal:
hello
Conclusion
This example illustrates the basic mechanics of bare-metal execution: code starts at reset, control is handed to C, and peripherals are accessed directly through MMIO registers. Subsequent posts will explore the startup code, linker script, and application logic in greater depth.
Top comments (0)