Procedural Programming with C · Professional · Week 49

Embedded C and Cross-Compilation

No operating system, no malloc, no printf, sixty-four kilobytes of RAM, and a compiler producing code for a processor it is not running on. This is where C's original purpose is still its dominant one — and where every assumption of the previous forty-eight weeks has to be re-examined.

By the end of this week you can
  • Describe a microcontroller's memory and peripherals, and how it differs from a desktop.
  • Install a cross-compiler and explain what a target triple names.
  • Say what a freestanding environment provides and what it does not.
  • Trace execution from the reset vector to the first line of main.
  • Build a bare-metal image and run it under QEMU, with no hardware.

1The machine

DesktopMicrocontroller
RAM8–64 GB4 KB – 512 KB
Program storageSSD, gigabytesFlash, 16 KB – 2 MB
Clock3 GHz, several cores16–200 MHz, one core
Operating systemLinux, WindowsNone, or a small RTOS
Memory protectionMMU; a bad pointer faultsUsually none; a bad pointer corrupts
Allocationmalloc freelyOften forbidden entirely
Outputprintf to a terminalA UART, if you write the driver

Two consequences deserve emphasis now. Without an MMU there is no segmentation fault — a wild pointer writes over another variable, or over a peripheral register, and the program continues doing something else. Week 12's silent corruption is the normal failure mode here, not the unlucky one.

And code executes from flash while data lives in RAM, which is why the startup sequence in section 4 has work to do before main can run at all.

Harvard versus von Neumann

A desktop keeps code and data in one address space. Many microcontrollers separate them — flash at 0x08000000, RAM at 0x20000000 on a typical Cortex-M — which is why a const table can stay in flash and cost no RAM, and why a string literal is addressed differently from a buffer.

2Cross-compilation

A cross-compiler runs on one architecture and produces code for another. Its name encodes the target as a triple:

arm-none-eabi-gcc
│   │    │
│   │    └── ABI: embedded ABI
│   └─────── vendor/OS: none — bare metal
└─────────── architecture: ARM

x86_64-linux-gnu-gcc        # the compiler you have been using
riscv64-unknown-elf-gcc     # bare-metal RISC-V
# Debian/Ubuntu
sudo apt install gcc-arm-none-eabi qemu-system-arm gdb-multiarch

# Fedora
sudo dnf install arm-none-eabi-gcc-cs qemu-system-arm

arm-none-eabi-gcc --version

The whole toolchain is prefixed: arm-none-eabi-objdump, -size, -nm, -gdb. Running the host objdump on an ARM image is a common early confusion — it will refuse, or print nonsense.

3Freestanding versus hosted

The standard defines two conformance modes, and this is the distinction that matters most this week.

HostedFreestanding
Entry pointmain, called for youImplementation-defined; you write it
Guaranteed headersAll of them<float.h> <limits.h> <stdarg.h> <stdbool.h> <stddef.h> <stdint.h> and a few more
printf, malloc, fopenPresentNot guaranteed
arm-none-eabi-gcc -ffreestanding -nostdlib …

Note what is guaranteed: the language itself, the fixed-width types, and the limits. Everything week 37 taught about <stdint.h> applies directly, and it matters more here because int may genuinely be 16 bits on a small part.

In practice a toolchain like arm-none-eabi ships newlib, which provides a subset — including a printf that will link but calls _write, which you must supply. That is why a first embedded printf so often links cleanly and produces nothing.

4From reset to main

On a desktop the loader does this work. Bare metal, you write it.

resetpower on load SPvector[0] copy .dataflash → RAM zero .bssweek 17 main() nothing above happens by itself; the reset handler is code you write

Week 17's .data and .bss regions, initialized by hand.

On a Cortex-M the processor reads two words from address 0 at reset: the initial stack pointer, then the address of the reset handler. Everything after that is your code.

.data — initialized globals — exists in two places. The initial values live in flash, because RAM is empty at power-on; the variables live in RAM. The startup code copies one to the other. .bss — zero-initialized globals — occupies no flash at all and is simply cleared, which is exactly what week 17's size output showed from the other side.

Omit either step and the symptoms are memorably strange: globals hold garbage, or hold their values only until something else uses that RAM.

5Worked example: bare metal under QEMU

The target is lm3s6965evb, a Cortex-M3 machine QEMU emulates and whose UART is trivial to drive. No hardware is required.

The linker script

/* firmware.ld — week 52 covers this properly */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x00000000, LENGTH = 256K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}

_stack_top = ORIGIN(RAM) + LENGTH(RAM);     /* stack grows down from the top */

SECTIONS
{
    .text : {
        KEEP(*(.vectors))       /* the vector table must be first */
        *(.text*)
        *(.rodata*)             /* constants stay in flash */
    } > FLASH

    _data_load = LOADADDR(.data);

    .data : {
        _data_start = .;
        *(.data*)
        _data_end = .;
    } > RAM AT> FLASH           /* lives in RAM, stored in FLASH */

    .bss : {
        _bss_start = .;
        *(.bss*)
        *(COMMON)
        _bss_end = .;
    } > RAM
}

> RAM AT> FLASH is the line that expresses the two-places problem: the section is addressed in RAM but stored in flash.

The startup code

/* startup.c */
#include <stdint.h>

extern uint32_t _stack_top;
extern uint32_t _data_load, _data_start, _data_end;
extern uint32_t _bss_start, _bss_end;

int main(void);

void reset_handler(void)
{
    /* 1. copy .data from flash to RAM */
    uint32_t *src = &_data_load;
    for (uint32_t *dst = &_data_start; dst < &_data_end; ) {
        *dst++ = *src++;
    }

    /* 2. zero .bss */
    for (uint32_t *p = &_bss_start; p < &_bss_end; ) {
        *p++ = 0;
    }

    /* 3. only now may C code with globals run */
    main();

    for (;;) { }                 /* main must never return */
}

static void default_handler(void) { for (;;) { } }

/* The vector table: the processor reads [0] and [1] at reset. */
__attribute__((section(".vectors"), used))
void (* const vector_table[])(void) = {
    (void (*)(void))&_stack_top,   /* [0] initial stack pointer */
    reset_handler,                 /* [1] reset                 */
    default_handler,               /* NMI                       */
    default_handler,               /* hard fault                */
};

The program

/* main.c */
#include <stdint.h>

/* The UART, straight from the machine's memory map. Week 50
   explains volatile properly; note it is not optional here. */
#define UART0_BASE 0x4000C000u
#define UART_DR   (*(volatile uint32_t *)(UART0_BASE + 0x000))
#define UART_FR   (*(volatile uint32_t *)(UART0_BASE + 0x018))
#define UART_FR_TXFF (1u << 5)          /* transmit FIFO full */

/* Initialized: lives in .data, copied from flash by the startup code. */
static uint32_t boot_marker = 0xDEADBEEF;

/* Zero-initialized: lives in .bss, cleared by the startup code. */
static uint32_t counter;
static uint8_t  buffer[256];

static void uart_putc(char c)
{
    while (UART_FR & UART_FR_TXFF) { }   /* wait for space */
    UART_DR = (uint32_t)c;
}

static void uart_puts(const char *s)
{
    for (; *s != '\0'; s++) {
        if (*s == '\n') uart_putc('\r');  /* terminals want CR LF */
        uart_putc(*s);
    }
}

/* No printf here: write what you need. */
static void uart_put_hex(uint32_t v)
{
    static const char digits[] = "0123456789ABCDEF";
    uart_puts("0x");
    for (int shift = 28; shift >= 0; shift -= 4) {
        uart_putc(digits[(v >> shift) & 0xFu]);
    }
}

static void uart_put_uint(uint32_t v)
{
    char tmp[11];
    int i = 0;
    if (v == 0) { uart_putc('0'); return; }
    while (v > 0) { tmp[i++] = (char)('0' + v % 10); v /= 10; }
    while (i > 0) uart_putc(tmp[--i]);
}

int main(void)
{
    uart_puts("\n=== bare metal, no operating system ===\n");

    uart_puts(".data was copied : ");
    uart_put_hex(boot_marker);
    uart_puts(boot_marker == 0xDEADBEEF ? "  correct\n" : "  WRONG\n");

    uart_puts(".bss was zeroed  : counter = ");
    uart_put_uint(counter);
    uart_puts(counter == 0 ? "  correct\n" : "  WRONG\n");

    uart_puts("buffer[0]        : ");
    uart_put_uint(buffer[0]);
    uart_puts("\n");

    uart_puts("stack address    : ");
    uint32_t local = 0;
    uart_put_hex((uint32_t)(uintptr_t)&local);
    uart_puts("  (RAM, near the top)\n");

    uart_puts("code address     : ");
    uart_put_hex((uint32_t)(uintptr_t)main);
    uart_puts("  (flash, low)\n");

    uart_puts("\nsuperloop starts; Ctrl-A X to quit QEMU\n");
    for (;;) {
        counter++;
        if (counter % 2000000u == 0) {
            uart_puts("tick ");
            uart_put_uint(counter / 2000000u);
            uart_puts("\n");
        }
    }
}

Build and run

arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb \
    -ffreestanding -nostdlib -O2 -Wall -Wextra -g \
    -T firmware.ld -o firmware.elf startup.c main.c

arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
arm-none-eabi-size firmware.elf

qemu-system-arm -M lm3s6965evb -nographic -kernel firmware.elf
=== bare metal, no operating system ===
.data was copied : 0xDEADBEEF  correct
.bss was zeroed  : counter = 0  correct
buffer[0]        : 0
stack address    : 0x2000FFE8  (RAM, near the top)
code address     : 0x000000C1  (flash, low)

superloop starts; Ctrl-A X to quit QEMU
tick 1
tick 2

Quit with Ctrl-A then X.

Four things the output proves

The startup code works. boot_marker holds its initializer only because reset_handler copied it out of flash; counter is zero only because the same function cleared .bss. Comment out either loop, rebuild, and watch the corresponding line report WRONG — with genuinely arbitrary values, since nothing initializes that RAM.

The memory map is visible. The stack sits near 0x20010000, the top of the 64 KB RAM region, and grows down; main lives at a low address in flash. Week 17's diagram, with the numbers from the linker script.

There is no printf. Formatting a number took twelve lines. That is the ordinary situation on a small target, and it is why embedded code tends to have hand-written output helpers.

The odd address for main. 0x000000C1 is odd because the low bit of a Cortex-M function pointer indicates Thumb instruction encoding — a detail invisible from C and instantly visible here.

Inspect the image

arm-none-eabi-size firmware.elf
   text    data     bss     dec     hex filename
   1284       4     264    1552     610 firmware.elf

1284 bytes of code and constants in flash, 4 bytes of initialized data, 264 bytes of zeroed data. Compare with the same program built for the host: a hosted hello world links megabytes of C library. Here the entire image is smaller than this paragraph's HTML.

arm-none-eabi-objdump -h firmware.elf          # section addresses
arm-none-eabi-objdump -d firmware.elf | head -30
arm-none-eabi-nm -n firmware.elf | head -20
xxd -l 16 firmware.bin                          # the vector table

The first eight bytes of firmware.bin are the initial stack pointer and the reset handler address, little-endian — the two words the processor reads at power-on. Seeing them is worth more than any description.

Debug it with GDB

# terminal 1
qemu-system-arm -M lm3s6965evb -nographic -kernel firmware.elf -S -s

# terminal 2
gdb-multiarch firmware.elf
(gdb) target remote :1234
(gdb) break reset_handler
(gdb) continue
(gdb) next
(gdb) print _data_start
(gdb) break main
(gdb) continue
(gdb) print boot_marker

-S halts the machine at reset and -s opens a GDB server on port 1234. You can now single-step the startup code, watch .data being copied word by word, and stop at main — the same debugger workflow as week 35, on a machine that does not exist. Week 55 develops this.

6Common mistakes

MistakeWhat happensFix
Using the host toolchain on a target imageNonsense output or a refusalPrefix everything: arm-none-eabi-.
Forgetting to copy .dataInitialized globals hold garbageThe copy loop in the reset handler.
Forgetting to zero .bssZero-initialized globals do not start at zeroThe clear loop.
Vector table not first in flashThe processor reads a bad stack pointer; nothing runsKEEP(*(.vectors)) at the start of .text.
Letting main returnExecution runs off into undefined memoryAn infinite loop after the call.
Expecting printf to workLinks, prints nothingProvide _write, or write your own output.
Omitting volatile on a registerReads cached; the driver hangsWeek 50.
Assuming a bad pointer will faultSilent corruption; no MMUTreat every pointer as unchecked.

7Check yourself

What does the none in arm-none-eabi-gcc mean?

It is the vendor or operating-system field of the target triple, and none means bare metal — there is no operating system. That is what selects a freestanding environment: no process loader, no system calls, and no guarantee of the hosted standard library.

Why must the startup code copy .data before calling main?

Because initialized globals live in RAM, which is uninitialized at power-on, while their initial values are stored in flash. Nothing copies one to the other automatically on bare metal. Skip the copy and every initialized global holds whatever the RAM happened to contain.

Why does .bss occupy no space in the image?

Because every byte is zero, so only the size needs recording — the startup code clears that much RAM. This is the same property week 17 observed with size on a hosted program; here you write the loop that does it rather than relying on the loader.

What does a freestanding implementation guarantee?

The language itself and a small set of headers — <stddef.h>, <stdint.h>, <limits.h>, <stdbool.h>, <stdarg.h>, <float.h> and a few more. It does not guarantee printf, malloc, file I/O, or even that main is the entry point.

Why is a wild pointer more dangerous on a microcontroller than on a desktop?

Because there is usually no memory management unit, so there is no invalid address to fault on. The write lands on another variable, on the stack, or on a peripheral register, and execution continues. What is an immediate segmentation fault on a desktop becomes silent corruption with a symptom somewhere else entirely.

8Where this leads

The UART macros in this week's example were used without explanation. Week 50 justifies them: what a memory-mapped register is, why volatile is mandatory rather than advisable, and how to turn a reference manual's register table into correct C.