Unlike desktop applications that run inside an operating system, embedded firmware must be compiled, linked, and precisely located in memory before it can be flashed to a target device. Every stage of the build pipeline has a direct effect on how the final binary executes on hardware.
The Embedded Build Pipeline
The full process from source code to executable binary involves six distinct stages:
+------------------+
| Source Files | (.c, .h)
| (.c files) |
+--------+---------+
|
v
+--------+---------+
| Preprocessor | Expands macros, includes, conditionals
+--------+---------+
|
v
+--------+---------+
| Compiler | Produces assembly or object files
+--------+---------+
|
v
+--------+---------+
| Assembler | Converts assembly to machine code (.o)
+--------+---------+
|
v
+--------+---------+
| Linker | Combines object files and libraries
+--------+---------+
|
v
+--------+---------+
| Locator | Places segments into memory using linker script
+--------+---------+
|
v
+--------+---------+
| Binary Image | (.elf, .hex, .bin)
+------------------+
Each stage has a specific responsibility, and failures at any stage produce different types of errors.
Stage 1: Preprocessor
The preprocessor is the first transformation applied to source files. It takes .c and .h files and processes all preprocessor directives — lines starting with #.
What the preprocessor does:
#include— replaces with the contents of the referenced header file#define— substitutes macro names with their values#ifdef/#ifndef/#endif— includes or excludes code blocks based on conditions#pragma— passes hints to the compiler
The output is still C code — just a fully expanded .c file with no directives remaining.
// Before preprocessing
#include <avr/io.h>
#define LED_PIN 5
PORTB |= (1 << LED_PIN);
// After preprocessing (conceptually)
// avr/io.h contents inserted here...
PORTB |= (1 << 5);
Stage 2: Compiler
The compiler takes the preprocessed C source and transforms it into either:
- Assembly code (
.asm) — human-readable processor instructions - Object code (
.o) directly, depending on the toolchain
The compiler performs:
- Syntax checking — reports any C syntax errors
- Semantic analysis — validates types, scopes, and declarations
- Optimization — removes dead code, inlines functions, optimizes loops
- Code generation — emits target-specific instructions
For embedded targets, a cross-compiler is used. It runs on the host machine (e.g., x86 Linux) but generates code for the target architecture (e.g., ARM Cortex-M).
# Cross-compile for ARM Cortex-M
arm-none-eabi-gcc -c main.c -o main.o -mcpu=cortex-m3 -mthumb
Stage 3: Assembler
The assembler converts assembly language into machine code — binary sequences that the processor understands directly.
If the compiler already produces object code directly, the assembler step is implicit. For projects that include hand-written .asm files (interrupt vectors, startup code, optimized routines), the assembler processes these files explicitly.
Assembly Instruction Machine Code (hex)
-------------------- ------------------
MOV R0, #1 ---> 0x2001
LDR R1, [R0] ---> 0x6801
BX LR ---> 0x4770
The output is one or more object files (.o), each containing machine code and symbol tables.
Stage 4: Linker
The linker combines all object files (.o) and libraries (.a, .lib) into a single output file.
main.o --------+
gpio.o --------> [ Linker ] ---> output.elf
uart.o --------+
libhal.a ------+
Linking modes:
| Mode | Description |
|---|---|
| Static linking | Library code is copied directly into the final executable |
| Dynamic linking | A path reference to the shared library is embedded (not common in bare-metal embedded) |
In embedded systems, static linking is almost always used because there is no dynamic loader or shared library support at runtime.
The linker also:
- Resolves symbol references (function calls, global variable addresses)
- Reports unresolved symbol errors when a function is called but not defined
- Reports duplicate symbol errors when a name is defined in multiple places
Stage 5: Locator
The locator is the stage that makes embedded builds unique compared to desktop builds.
It uses a linker script (.ld file) to tell the linker exactly where to place each section of code and data in the target's memory map.
Linker Script Memory Layout
----------------------------
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 64K
RAM : ORIGIN = 0x20000000, LENGTH = 20K
}
SECTIONS
{
.text --> placed in FLASH (program code)
.data --> placed in RAM (initialized variables)
.bss --> placed in RAM (uninitialized variables)
.stack --> placed in RAM (stack memory)
}
Standard Memory Sections
| Section | Contents | Location |
|---|---|---|
.text | Program instructions | Flash |
.data | Initialized global variables | RAM (copied from Flash at boot) |
.bss | Uninitialized global variables (zeroed at boot) | RAM |
.isr_vectors | Interrupt vector table | Flash (start of address space) |
.stack | Function call stack | RAM |
The startup code copies .data from Flash to RAM and zeroes out .bss before main() is called.
Stage 6: Binary Image
The final output of the build pipeline is a binary image — the file that gets flashed to the target device.
The most common format is ELF (Executable and Linkable Format), used by most Linux-based toolchains:
+----------------------------+
| ELF Header | Architecture, entry point, flags
+----------------------------+
| .text section | Compiled machine code
+----------------------------+
| .data section | Initial values of global variables
+----------------------------+
| .bss section | Size of zero-initialized memory
+----------------------------+
| .isr_vectors section | Interrupt vector table
+----------------------------+
| Symbol table | Names and addresses (for debugging)
+----------------------------+
| Debug information (DWARF) | Source-level debug info
+----------------------------+
From the ELF file, additional formats are derived:
.hex— Intel HEX format, widely used by flashing tools.bin— Raw binary, direct memory image
# Convert ELF to HEX
arm-none-eabi-objcopy -O ihex output.elf output.hex
# Convert ELF to BIN
arm-none-eabi-objcopy -O binary output.elf output.bin
Complete Build Example
A typical ARM Cortex-M build using GCC:
# Step 1: Compile source files
arm-none-eabi-gcc -c main.c -o main.o -mcpu=cortex-m3 -mthumb -O2
arm-none-eabi-gcc -c gpio.c -o gpio.o -mcpu=cortex-m3 -mthumb -O2
arm-none-eabi-gcc -c uart.c -o uart.o -mcpu=cortex-m3 -mthumb -O2
# Step 2: Link with linker script
arm-none-eabi-gcc main.o gpio.o uart.o \
-T linker_script.ld \
-o firmware.elf \
-mcpu=cortex-m3 -mthumb \
--specs=nosys.specs
# Step 3: Generate flash image
arm-none-eabi-objcopy -O ihex firmware.elf firmware.hex
arm-none-eabi-size firmware.elf
The arm-none-eabi-size command reports flash and RAM usage:
text data bss dec hex filename
4256 128 512 4896 1320 firmware.elf
Practical Implications
Understanding the build process helps you:
- Diagnose linker errors — undefined references, section overflow
- Optimize memory — reduce
.textsize, minimize.dataand.bss - Control startup behavior — customize what runs before
main() - Debug precisely — map addresses back to source lines using the ELF file
Error: region 'FLASH' overflowed by 2048 bytes
This linker error means your code is too large for flash. The build process tells you exactly how much space each section uses, so you can target the right optimizations.
Final Thoughts
The Embedded C build process is not just a technicality — it is a fundamental part of embedded engineering. Every firmware developer must understand:
- What each stage transforms
- How the linker script controls memory layout
- How the binary image maps to physical hardware memory
The build process is the bridge between source code and silicon.