ES: Embedded C Build Process

The Embedded C build process transforms human-readable source code into a binary image that runs directly on a microcontroller. Understanding each stage — from preprocessing through locating — is essential for debugging compilation errors, optimizing firmware size, and controlling memory layout.

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:

md
+------------------+
|  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.

c
// 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).

bash
# 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.

md
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.

md
main.o --------+
gpio.o --------> [ Linker ] ---> output.elf
uart.o --------+
libhal.a ------+

Linking modes:

ModeDescription
Static linkingLibrary code is copied directly into the final executable
Dynamic linkingA 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.

md
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

SectionContentsLocation
.textProgram instructionsFlash
.dataInitialized global variablesRAM (copied from Flash at boot)
.bssUninitialized global variables (zeroed at boot)RAM
.isr_vectorsInterrupt vector tableFlash (start of address space)
.stackFunction call stackRAM

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:

md
+----------------------------+
|  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

bash
# 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:

bash
# 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:

bash
   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 .text size, minimize .data and .bss
  • Control startup behavior — customize what runs before main()
  • Debug precisely — map addresses back to source lines using the ELF file

md
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.