ARM GCC Build Process: Compile, Assemble & Link-Embedded C Training for Freshers

Need Help or More Information?

Questions about this free C programming course, embedded C training or career guidance? Get in touch.

← PREV_LECNEXT_LEC →

ARM GCC Build Process: Compile, Assemble & Link

Understand every stage of the ARM bare-metal build pipeline — from C source to flashable binary — with real command-line examples.

Pages 281–290GCC Build PipelinePreprocessingAssemblerELF Object Files

1. Build Process Overview

When you hit “Build” in an IDE, a lot happens behind the scenes. Understanding each stage helps you debug build errors, optimize binary size, and write correct Makefiles. The entire process is driven by a single command — arm-none-eabi-gcc — which internally calls the preprocessor, compiler, assembler, and linker.

Complete ARM GCC Build Pipeline
Source
main.c
C code
→
Preprocess
main.i
cpp
→
Compile
main.s
cc1
→
Assemble
main.o
as
→
Link
final.elf
ld
→
Convert
final.bin
objcopy

You can run all stages with one command, or run each individually to inspect intermediate output. Running stages individually is extremely useful for debugging.

2. Stage 1: Preprocessing (.c → .i)

The preprocessor handles all directives that begin with #. It expands macros, inserts included headers, and evaluates conditional compilation blocks. The output is a pure C file with no # directives remaining.

What the Preprocessor Does

  • #include — inserts the full content of the header file inline.
  • #define — performs text substitution of macros.
  • #ifdef/#ifndef — includes/excludes code blocks based on conditions.
  • Strips all comments.

C — Input: led.c

#include "stm32f4xx.h"
#define LED_PIN  12U

void led_init(void)
{
    /* Enable GPIOD clock */
    RCC->AHB1ENR |= (1U << 3U);
    /* Set pin 12 as output */
    GPIOD->MODER |= (1U << (LED_PIN * 2U));
}

Shell — Run Preprocessor Only

# -E flag: stop after preprocessing, output .i file
arm-none-eabi-gcc -E led.c -o led.i

# Inspect the output (can be very large due to header expansion)
# grep for led_init to find the relevant section
grep -A 10 "led_init" led.i

Tip: The preprocessed .i file is plain text C. If you are getting mysterious macro expansion errors, inspect the .i file to see exactly what the preprocessor produced.

3. Stage 2: Code Generation (.i → .s)

The compiler’s core takes the preprocessed C code and translates it into ARM assembly language. This is the most intellectually complex stage — the parser builds an abstract syntax tree (AST), an optimizer transforms the tree, and the code generator emits ARM Thumb-2 assembly mnemonics.

What Happens Inside the Compiler

1. Parser
Reads tokens, builds syntax tree, checks grammar
2. Optimizer
Dead code elimination, constant folding, inlining
3. Code Generator
Emits ARM Thumb-2 assembly mnemonics

Shell — Compile to Assembly

# -S flag: stop after compilation, produce .s assembly file
arm-none-eabi-gcc \
    -mcpu=cortex-m4 \
    -mthumb \
    -O0 \          # no optimization for readable output
    -S led.c \
    -o led.s

# View the generated assembly
cat led.s

Sample Generated Assembly

ARM Assembly Output (led.s fragment)

; led_init function compiled to Thumb-2 instructions
led_init:
    PUSH    {R4, LR}          ; save link register
    LDR     R3, =0x40023830   ; address of RCC->AHB1ENR
    LDR     R2, [R3]          ; read current value
    ORR     R2, R2, #0x8      ; set bit 3 (GPIOD clock)
    STR     R2, [R3]          ; write back
    LDR     R3, =0x40020C00   ; address of GPIOD->MODER
    LDR     R2, [R3]
    ORR     R2, R2, #0x1000000 ; set pin 12 as output
    STR     R2, [R3]
    POP     {R4, PC}          ; restore and return
Suggested images
📷 Suggested image: Side-by-side comparison of C source code and its generated ARM assembly output, with annotations showing the mapping from C statements to instructions.

4. Stage 3: Assembly (.s → .o)

The assembler converts human-readable ARM assembly mnemonics into binary machine code (opcodes). The output is a relocatable object file in ELF (Executable and Linkable Format). “Relocatable” means the code has no fixed memory addresses yet — those are assigned by the linker.

What is an ELF Object File?

ELF is a standard binary format. A relocatable .o file contains:

  • .text — machine code instructions
  • .data — initialized global/static variables
  • .bss — uninitialized global/static variables (size only, no data)
  • .rodata — read-only data (string literals, const arrays)
  • Symbol table — names and (relative) addresses of functions and variables
  • Relocation entries — placeholders for addresses to be filled in by the linker

Shell — Assemble to Object File

# -c flag: compile and assemble but do not link
arm-none-eabi-gcc \
    -mcpu=cortex-m4 \
    -mthumb \
    -c led.c \
    -o led.o

# Inspect sections in the object file
arm-none-eabi-objdump -h led.o

# Expected output (section headers):
# Idx Name    Size     VMA      LMA      Offset   Align
#   0 .text   00000018 00000000 00000000 00000034 2**1
#   1 .data   00000000 00000000 00000000 0000004c 2**0
#   2 .bss    00000000 00000000 00000000 0000004c 2**0

# Disassemble — shows machine opcodes with mnemonics
arm-none-eabi-objdump -d led.o

# Show symbol table
arm-none-eabi-nm led.o

Notice: All addresses in the .o file are relative (starting from 0). They are not the final MCU addresses. The linker assigns absolute addresses using the linker script.

5. Stage 4: Linking (.o → .elf)

The linker combines multiple .o files, resolves cross-file symbol references (e.g., main.c calls led_init() defined in led.c), and assigns absolute addresses to all sections using the linker script.

Linker: Merging Object Files
main.o
.text, .data, .bss, .rodata
led.o
.text, .data, .bss, .rodata
startup.o
.text (vector table)
→
linker
+ linker.ld

→
firmware.elf
.text @ 0x08000000
.data @ 0x20000000
.bss @ after .data

Shell — Link Object Files

# Link all .o files using a linker script
arm-none-eabi-gcc \
    -mcpu=cortex-m4 \
    -mthumb \
    -T linker.ld \       # provide linker script
    -nostdlib \          # no standard library (bare metal)
    -o firmware.elf \    # output ELF file
    main.o led.o startup.o

# Check section sizes and addresses
arm-none-eabi-objdump -h firmware.elf

# Convert ELF to raw binary for flashing
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin

# Or Intel hex format
arm-none-eabi-objcopy -O ihex firmware.elf firmware.hex

6. Important GCC Flags for ARM Embedded

FlagPurposeExample
-mcpu=cortex-m4Specify target CPU; enables architecture-specific optimizations-mcpu=cortex-m4
-mthumbGenerate Thumb-2 instructions (required for Cortex-M)-mthumb
-mfloat-abi=hardUse hardware FPU registers for floating point (M4 with FPU)-mfloat-abi=hard -mfpu=fpv4-sp-d16
-O0No optimization — best for debugging-O0 -g3
-O2Standard optimization — good balance for production-O2
-OsOptimize for code size — important for flash-constrained MCUs-Os
-g3Include maximum debug info in the ELF (does not affect .bin)-g3
-Wall -WextraEnable comprehensive compiler warnings-Wall -Wextra -Werror
-nostdlibDo not link with standard C library (bare metal)-nostdlib
-T linker.ldSpecify custom linker script-T stm32f4.ld
-Wl,-Map=out.mapGenerate linker map file showing symbol addresses-Wl,-Map=firmware.map
-cCompile and assemble only, do not link-c main.c -o main.o
-SStop after compilation, keep assembly output-S main.c -o main.s
-EStop after preprocessing-E main.c -o main.i

7. Programming Exercises

Exercise 1 — Inspect Each Build Stage

Write a simple C function and walk through all four stages manually. At each stage, inspect the output file.

C — Source File (blink.c)

#define GPIOD_ODR  (*((volatile uint32_t*)0x40020C14))
#define LED_BIT    (1U << 12U)

static uint32_t blink_count = 0;

void toggle_led(void)
{
    GPIOD_ODR ^= LED_BIT;
    blink_count++;
}

Shell — Step-by-Step Build

# Step 1: Preprocess
arm-none-eabi-gcc -E blink.c -o blink.i
wc -l blink.i      # count lines after expansion

# Step 2: Compile to assembly
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O0 -S blink.c -o blink.s
cat blink.s

# Step 3: Assemble to object file
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -c blink.c -o blink.o
arm-none-eabi-objdump -h blink.o   # inspect sections
arm-none-eabi-nm blink.o           # list symbols (blink_count, toggle_led)

# Note: blink_count should appear as a .data symbol
# toggle_led should appear as a .text symbol

Exercise 2 — Compare Optimization Levels

Compile the same function with -O0, -O1, -O2, and -Os. Compare the generated assembly and object file sizes. Record your findings.

Shell

for OPT in O0 O1 O2 Os; do
    arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -$OPT -c blink.c -o blink_$OPT.o
    echo "=== -$OPT ==="
    arm-none-eabi-size blink_$OPT.o
done

Exercise 3 — Find Undefined Symbols

Create a file main.c that calls toggle_led() but does NOT include blink.c during linking. Observe the linker error. Then add blink.o to the link command and verify it succeeds.

8. Frequently Asked Questions

What is the difference between -O0, -O2, and -Os?

-O0 disables all optimization — slowest code, largest binary, best for debugging because variables and statements map 1:1 with source lines. -O2 enables most optimizations (inlining, loop unrolling, dead code elimination) — much faster but harder to debug. -Os optimizes specifically for code size by avoiding size-increasing optimizations like loop unrolling. For flash-constrained MCUs, -Os is often the best choice.

Why is the .o file address 0x00000000 but the .elf has real addresses?

Object files are “relocatable” — they contain code with placeholder addresses starting at 0. The linker reads your linker script (which specifies FLASH starts at 0x08000000 and SRAM at 0x20000000) and applies those base addresses to each section. The result is the fully addressed .elf file.

Do I need -nostdlib for all bare-metal projects?

Not always. Without -nostdlib, GCC links in the standard C runtime (crt0) and libc. On bare metal this fails because the startup code assumes an OS environment. Use -nostdlib when you are writing your own startup code (startup.s), OR use --specs=nosys.specs to provide stub implementations of OS calls. If you want printf and malloc, use newlib-nano with --specs=nano.specs.

What does the linker map file (.map) tell me?

The map file lists every function and variable with its exact address, section, and size in the final binary. It also shows which object file each symbol came from, and the total memory usage per section. This is the primary tool for diagnosing “binary too large” problems — you can see exactly which functions are consuming the most flash.

Quick Knowledge Check

1. Which GCC flag stops after preprocessing and keeps the .i file?

  • a) -S
  • b) -c
  • c) -E
  • d) -o
Check Answer
Correct answer: c) -E. -E stops after preprocessing. -S stops after compilation (assembly). -c stops after assembling (object file).

2. What type of addresses does a relocatable .o file contain?

  • a) Absolute flash addresses (e.g., 0x08000000)
  • b) Virtual addresses starting from 0 (relative)
  • c) Physical RAM addresses
  • d) JTAG debug addresses
Check Answer
Correct answer: b) Virtual addresses starting from 0 (relative). Object files contain relative addresses starting from 0. The linker assigns final absolute addresses.

3. Which optimization flag is best for minimizing flash memory usage?

  • a) -O0
  • b) -O3
  • c) -Os
  • d) -Og
Check Answer
Correct answer: c) -Os. -Os (optimize for size) tells GCC to avoid size-increasing optimizations like loop unrolling.

EmbeddedPathashala — Embedded Systems Programming on ARM Cortex-M3/M4

← PREV_LECNEXT_LEC →

Need Help or More Information?

Questions about this free C programming course, embedded C training or career guidance? Get in touch.

2 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *