Chapter 2: Mastering the Build: Compiler, Linker, and Memory Maps
Most C tutorials stop at the compiler. You write gcc main.c -o program, run it, and move on. Embedded development demands more. You must understand what happens after compilation: how the linker places code and data into specific memory regions, how the startup code initializes your variables before main() runs, and why a "successful build" does not guarantee a working firmware image.
Cross-Compilation: Building for a Different Target
When you compile a program on your PC, the compiler generates machine code for your CPU (x86-64, ARM64, etc.). The resulting binary runs on the same architecture. In embedded development, you are almost always cross-compiling: building on a host machine (your PC) for a target architecture (ARM Cortex-M, RISC-V, AVR, etc.).
The toolchain for this is typically prefixed with the target architecture. For ARM Cortex-M:
arm-none-eabi-gcc # compiler
arm-none-eabi-ld # linker
arm-none-eabi-objcopy # binary format converter
arm-none-eabi-objdump # disassembler
arm-none-eabi-size # section size analyzer
The prefix arm-none-eabi tells you three things: the architecture (ARM), the vendor (none, meaning generic), and the ABI (EABI, the Embedded Application Binary Interface). Different MCU vendors may ship their own toolchain builds, but the underlying tools are the same.
Misconception: "If it compiles on my PC, it will compile on the target."
Reality: Cross-compilation means a completely different instruction set, different standard library implementation, and different memory model. Code that works on x86 may use assumptions (like unaligned access being safe) that fail or behave differently on ARM Cortex-M.
The Four Steps of Building
A C build involves four distinct stages. Most IDEs hide these behind a single "Build" button, but understanding them is essential for debugging embedded issues.
1. Preprocessing
The preprocessor handles #include, #define, #ifdef, and other directives. It produces a single translation unit with all headers expanded.
arm-none-eabi-gcc -E main.c -o main.i
2. Compilation
The compiler translates the preprocessed C into assembly for the target architecture. This is where optimization (-O0, -Og, -Os, -O2) happens.
arm-none-eabi-gcc -S main.i -o main.s -mcpu=cortex-m4 -O2
3. Assembly
The assembler converts assembly into machine code, producing an object file.
arm-none-eabi-gcc -c main.s -o main.o
4. Linking
The linker combines multiple object files, resolves symbol references, and places code and data into memory sections according to a linker script.
arm-none-eabi-gcc main.o startup.o -o firmware.elf -T linker_script.ld
The final output is an ELF file (Executable and Linkable Format), which contains the binary code plus metadata about sections, symbols, and debug information.
Object Files and Symbols
Each .c file compiles into an independent object file. The object file contains machine code and data, but with unresolved symbol references. If main.c calls uart_init() defined in uart.c, the main.o object file has a placeholder for uart_init that the linker must resolve.
Symbols are names for addresses. Functions and global variables become symbols. The linker's job is to assign each symbol a final address and patch all references to it.
This is why you need declarations across files:
// uart.h
void uart_init(uint32_t baud); // declaration
// main.c
#include "uart.h"
int main(void) {
uart_init(115200); // reference to symbol uart_init
return 0;
}
// uart.c
void uart_init(uint32_t baud) { // definition
// ... configure UART registers
}
Without the declaration in uart.h, main.c would not know the function signature, and the compiler would either error or (worse) make incorrect assumptions about arguments and return types.
Linker Scripts: The Memory Map
The linker script is the blueprint for where everything goes in memory. It defines the memory regions available on the target and assigns sections to those regions.
Here is a simplified linker script for an STM32F4 with 1 MB flash and 192 KB RAM:
/* linker_script.ld */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 192K
}
SECTIONS
{
/* Code and read-only data go to flash */
.text : {
KEEP(*(.isr_vector)) /* interrupt vector table first */
*(.text) /* all function code */
*(.rodata) /* const variables and string literals */
. = ALIGN(4);
_etext = .; /* end of text section */
} > FLASH
/* Initialized data: values stored in flash, copied to RAM at startup */
.data : {
_sdata = .; /* start of data in RAM */
*(.data)
. = ALIGN(4);
_edata = .; /* end of data in RAM */
} > RAM AT > FLASH
/* Zero-initialized data: no values stored, just zeroed at startup */
.bss : {
_sbss = .;
*(.bss)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > RAM
}
Key observations:
.textcontains code and read-only data. It lives in flash (0x08000000 on STM32)..datacontains initialized globals (e.g.,int count = 42;). The initial values are stored in flash and copied to RAM at startup..bsscontains zero-initialized globals (e.g.,static int buffer[100];). No initial values are stored; the startup code just zeros this region.- Symbols like
_etext,_sdata,_edata,_sbss, and_ebssare exported by the linker script. The startup code uses them to know what to copy and zero.
Misconception: "My global variable
int x = 5;is stored directly in RAM."Reality: The value
5is stored in flash as part of the.datasection's initial values. At startup, beforemain()is called, the startup code copies that value from flash to RAM. Your variable lives in RAM, but its initial value lives in flash.
The Startup File: Before main()
Many developers assume main() is the first thing that runs. In embedded systems, the startup code runs first. It is typically written in assembly or C with naked functions, and it performs these tasks:
-
Initialize the stack pointer. The first word of the vector table contains the initial stack pointer value. The hardware loads this on reset.
-
Copy
.datafrom flash to RAM. Using the linker script symbols_etext,_sdata, and_edata. -
Zero the
.bsssection. Using_sbssand_ebss. -
Call
main(). After this, your code runs.
Here is a simplified startup file in C (real ones are often in assembly):
// startup.c
extern uint32_t _etext, _sdata, _edata, _sbss, _ebss;
void Reset_Handler(void) {
uint32_t *src = &_etext;
uint32_t *dst = &_sdata;
// Copy .data section from flash to RAM
while (dst < &_edata) {
*dst++ = *src++;
}
// Zero .bss section
for (dst = &_sbss; dst < &_ebss; dst++) {
*dst = 0;
}
main();
while (1) {
// Should never reach here
}
}
The vector table is an array of function pointers placed at the very beginning of flash (address 0x08000000 on STM32, or 0x00000000 on some other MCUs):
// vector_table.c
void (* const vector_table[])(void) __attribute__((section(".isr_vector"))) = {
(void (*)(void))0x20030000, // initial stack pointer (top of RAM)
Reset_Handler, // reset
NMI_Handler, // non-maskable interrupt
HardFault_Handler, // hard fault
// ... more interrupt handlers
};
When the MCU resets, the hardware reads the first word (stack pointer), writes it to the SP register, then reads the second word (reset handler address) and jumps to it. The startup code runs, prepares memory, and calls main().
The Output Files: ELF, HEX, and BIN
The linker produces an ELF file. This is not directly loadable onto a microcontroller. You need to convert it to a raw binary or Intel HEX format:
# Convert ELF to raw binary (exact bytes to flash)
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
# Convert ELF to Intel HEX (addresses included, good for bootloaders)
arm-none-eabi-objcopy -O ihex firmware.elf firmware.hex
The .bin file is a raw image starting at the flash origin address. The .hex file is ASCII with address records, useful for tools that need to know where to place each chunk.
Use arm-none-eabi-size to check memory usage:
arm-none-eabi-size firmware.elf
# Output:
# text data bss dec hex filename
# 12345 256 4096 16697 4139 firmware.elf
text= code + read-only data (flash usage)data= initialized variables (flash + RAM usage for initial values)bss= zero-initialized variables (RAM usage)
Total flash usage = text + data. Total RAM usage = data + bss.
Build Systems: Make, CMake, and Toolchain Files
Real embedded projects rarely invoke arm-none-eabi-gcc directly. They use build systems that manage dependencies, compiler flags, and multiple source files.
Make
The traditional choice. A Makefile defines rules for building targets:
CC = arm-none-eabi-gcc
CFLAGS = -mcpu=cortex-m4 -mthumb -O2 -Wall
LDFLAGS = -T linker_script.ld -nostdlib
firmware.elf: main.o uart.o startup.o
$(CC) $(LDFLAGS) $^ -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
CMake
Modern embedded projects increasingly use CMake with a toolchain file. The toolchain file tells CMake which compiler to use and what target flags to apply:
# toolchain-arm-none-eabi.cmake
set(CMAKE_SYSTEM_NAME Generic)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_C_FLAGS "-mcpu=cortex-m4 -mthumb -O2")
set(CMAKE_EXE_LINKER_FLAGS "-T ${CMAKE_SOURCE_DIR}/linker_script.ld")
The CMakeLists.txt defines the project:
cmake_minimum_required(VERSION 3.20)
project(firmware C)
add_executable(firmware
src/main.c
src/uart.c
src/startup.c
)
target_compile_definitions(firmware PRIVATE
STM32F407xx
USE_HAL_DRIVER
)
compile_commands.json
Modern IDEs and editors (VS Code, CLion, Vim) use compile_commands.json for IntelliSense and navigation. CMake generates this with:
cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ..
This file contains the exact compiler invocation for each source file, enabling the editor to understand include paths, defines, and flags without running the full build.
The -nostdlib Flag and Minimal C Runtime
Desktop programs link against the full C standard library: printf, malloc, file I/O, and more. Embedded systems often use -nostdlib or link against a minimal embedded C library like Newlib-nano or picolibc.
This means many standard functions are unavailable or require system calls (syscalls) to be implemented. A bare-metal printf requires you to implement _write() to route characters to a UART:
// syscalls.c
int _write(int file, char *ptr, int len) {
for (int i = 0; i < len; i++) {
uart_send_byte(ptr[i]);
}
return len;
}
Without this, calling printf will either fail to link or silently do nothing.
What to Actually Learn vs. Look Up
You do not need to memorize linker script syntax. You need to understand the concepts: memory regions, sections, and how the startup code uses linker symbols. When you encounter a specific linker script in a project, you should be able to read it and understand where each section goes.
You do not need to write a startup file from scratch for every project. Most vendors provide one. But you must understand what it does, because debugging startup issues (variables not initialized, code not running) requires knowing what happens before main().
You do need to understand the memory map of your target MCU. The datasheet and reference manual define flash and RAM addresses. The linker script must match these. A mismatch means your code tries to execute from nonexistent memory or write to invalid addresses.
Key Takeaways
- Cross-compilation is the norm. Your build machine and target are different architectures.
- The build has four stages: preprocessing, compilation, assembly, and linking. Understanding each helps debug build issues.
- The linker script is the memory map. It defines where code, constants, initialized data, and zero-initialized data live.
- The startup code runs before
main(). It copies initialized data from flash to RAM and zeros.bss. - The vector table is the entry point. The hardware reads it on reset to find the stack pointer and reset handler.
- ELF, BIN, and HEX serve different purposes. You flash BIN or HEX, not ELF.
Chapter 3 will build on this foundation by showing how to access hardware peripherals through memory-mapped I/O.