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.
- 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
| Desktop | Microcontroller | |
|---|---|---|
| RAM | 8–64 GB | 4 KB – 512 KB |
| Program storage | SSD, gigabytes | Flash, 16 KB – 2 MB |
| Clock | 3 GHz, several cores | 16–200 MHz, one core |
| Operating system | Linux, Windows | None, or a small RTOS |
| Memory protection | MMU; a bad pointer faults | Usually none; a bad pointer corrupts |
| Allocation | malloc freely | Often forbidden entirely |
| Output | printf to a terminal | A 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 --versionThe 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.
| Hosted | Freestanding | |
|---|---|---|
| Entry point | main, called for you | Implementation-defined; you write it |
| Guaranteed headers | All of them | <float.h> <limits.h> <stdarg.h> <stdbool.h> <stddef.h> <stdint.h> and a few more |
printf, malloc, fopen | Present | Not 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.
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 2Quit 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.elf1284 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 tableThe 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
| Mistake | What happens | Fix |
|---|---|---|
| Using the host toolchain on a target image | Nonsense output or a refusal | Prefix everything: arm-none-eabi-. |
Forgetting to copy .data | Initialized globals hold garbage | The copy loop in the reset handler. |
Forgetting to zero .bss | Zero-initialized globals do not start at zero | The clear loop. |
| Vector table not first in flash | The processor reads a bad stack pointer; nothing runs | KEEP(*(.vectors)) at the start of .text. |
Letting main return | Execution runs off into undefined memory | An infinite loop after the call. |
Expecting printf to work | Links, prints nothing | Provide _write, or write your own output. |
Omitting volatile on a register | Reads cached; the driver hangs | Week 50. |
| Assuming a bad pointer will fault | Silent corruption; no MMU | Treat 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.