
Link-Time Optimization (LTO) is one of the most effective levers for reducing firmware footprint on flash-constrained microcontrollers — yet many embedded teams leave it disabled due to build complexity concerns. This article examines how LTO works, quantifies its impact on Cortex-M firmware, and provides a practical migration path for GCC and Clang toolchains.
Traditional compilation optimizes each translation unit (TU) in isolation. The compiler sees only the source file and its headers — cross-TU calls become opaque CALL instructions, inline candidates across files are missed, and dead code in other TUs remains linked.
+--------------------------+ +--------------------------+| Without LTO | | With LTO || (Per-TU Optimization) | | (Whole-Program Opt) |+--------------------------+ +--------------------------+| file_a.c -> file_a.o | | file_a.c -> file_a.o || file_b.c -> file_b.o | | file_b.c -> file_b.o || file_c.c -> file_c.o | | file_c.c -> file_c.o || | | | | || v | | v || Link: opaque CALLs | | Link: GIMPLE/IR merge || No cross-TU inlining | | Global inlining || No cross-TU const prop | | Global const prop || Dead code per TU only | | Whole-program DCE |+--------------------------+ +--------------------------+
LTO defers optimization to the link stage by embedding compiler intermediate representation (IR) — GIMPLE for GCC, LLVM IR for Clang — into object files. The linker invokes the compiler backend on the merged IR, enabling global analysis:
-fipa-icf pass (also available as --icf=safe in the gold/lld linkers)# Enable LTO globallyset(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)# Or per-target (preferred for mixed projects)target_link_options(firmware.elf PRIVATE -flto)# Partitioning for large projects (GCC 10+)target_link_options(firmware.elf PRIVATE -flto=auto)# Parallel LTO with 4 jobstarget_link_options(firmware.elf PRIVATE -flto=4)# Fat LTO (no partitioning, maximum optimization but slowest link)target_link_options(firmware.elf PRIVATE -flto -flto-partition=none)
# Compile with LTO-compatible optimization-flto -O2 -fno-fat-lto-objects# WHOPR partitioning (GCC 10+)-flto=auto -fno-fat-lto-objects# For maximum size reduction (trade compile time)-flto -Os -fno-fat-lto-objects# Note: -fno-fat-lto-objects saves compile time and disk space by emitting only IR.# It requires linker plugin support, which is standard in modern ARM GCC toolchains.
Ensure the linker plugin is active (automatic with gold/lld and GCC 8+):
# Verify plugin loadsarm-none-eabi-gcc -flto -v -o test.elf test.c 2>&1 | grep -i lto# Should show: liblto_plugin.so loaded
Clang’s ThinLTO is purpose-built for large codebases and incremental builds:
# ThinLTO (recommended for embedded)-flto=thin -O2# ThinLTO with explicit caching for CI-flto=thin -O2 -Wl,--thinlto-cache-dir=/path/to/cache# Full LTO — maximum optimization, slowest link-flto -O2
# GitHub Actions example- name: Setup ThinLTO cacheuses: actions/cache@v4with:path: ~/.thinlto_cachekey: thinlto-${{ hashFiles('**/*.c', '**/*.h', 'CMakeLists.txt') }}restore-keys: thinlto-- name: Build with ThinLTO cacherun: |cmake -DCMAKE_C_FLAGS="-flto=thin -O2 -Wl,--thinlto-cache-dir=${HOME}/.thinlto_cache" ..make -j$(nproc)
Test setup: STM32F407 (1 MB flash), FreeRTOS + application (~85 KB baseline), ARM GCC 12.2.
| Configuration | Flash Used | Reduction | Link Time | Notes |
|---|---|---|---|---|
-O2 (no LTO) | 85.2 KB | — | 3.2 s | Baseline |
-O2 -flto | 74.1 KB | 13% | 18.4 s | Fat LTO |
-O2 -flto=auto | 75.8 KB | 11% | 9.1 s | Partitioned |
-Os -flto | 69.8 KB | 18% | 19.2 s | Size-optimized |
Key observations:
-flto=auto) recovers ~50% of link time with minimal size penalty-Os (size-optimized) + LTO compounds — but verify runtime performanceweak Symbols// Without LTO: weak symbol overridden by strong definition in another TU// With LTO: both definitions visible, linker may pick wrong one__attribute__((weak)) void Default_Handler(void) { while(1); }// Fix: Avoid weak for critical symbols (use strong definitions), or enforce retentionvoid __attribute__((used)) Default_Handler(void) { while(1); }
// LTO may merge/collapse sections unexpectedly__attribute__((section(".my_section"))) const uint8_t firmware_key[32] = {0};// Fix: Force retention__attribute__((section(".my_section"), used, retain)) // retain requires GCC 11+ / Binutils 2.36+const uint8_t firmware_key[32] = {0};// Linker script: KEEP(*(.my_section))
# GCC: Preserve debug info for specific functions-fno-lto -fno-inline-functions # Per-file opt-out# Or in source:__attribute__((optimize("O0"))) void critical_timing_function(void) { ... }
LTO can eliminate “unused” interrupt handlers referenced only in the vector table:
// Vector table references these — LTO may not see the referencevoid HardFault_Handler(void);// Fix: Mark as used to prevent LTO from removing itvoid __attribute__((used)) HardFault_Handler(void);
# Specify nano.specs at link time WITH LTO-specs=nano.specs -flto -Wl,--gc-sections# Ensure libc LTO objects are available (arm-none-eabi-gcc 10+ includes them)
LTO’s monolithic optimization defeats traditional incremental builds. Mitigations:
| Approach | Toolchain | Trade-off |
|---|---|---|
-flto=auto (WHOPR) | GCC 10+ | Partitions IR, parallel link, significant parallel speedup |
| ThinLTO + cache | Clang 9+ | Best incremental, near-fat quality, requires cache dir |
-fno-lto on changed files only | Both | Hybrid: LTO for stable files, fast rebuild for active dev |
| Unity build + LTO | Both | Single TU, maximum optimization, no incrementality |
Recommended workflow for active development:
# Debug builds: no LTO, fast iterationset(CMAKE_C_FLAGS_DEBUG "-Og -g3 -fno-lto")# Release builds: LTO enabledset(CMAKE_C_FLAGS_RELEASE "-Os -flto=auto -fno-fat-lto-objects")
# 1. Size comparisonarm-none-eabi-size -A release/firmware.elf | grep -E "(text|data|bss)"# 2. Symbol audit — verify no unexpected removalsarm-none-eabi-nm -S --size-sort release/firmware.elf | grep " T " | head -20# 3. Call graph validation (requires LTO IR)arm-none-eabi-gcc -flto -fdump-ipa-cgraph -o firmware.elf ...# 4. Full test suite on target hardware# - Functional tests# - Timing-critical path verification# - Stack usage analysis (LTO may increase stack in some paths)# 5. Binary diff against non-LTO buildcmp -l release/firmware.elf debug/firmware.elf | wc -l
Link-Time Optimization delivers 5-20% flash reduction on typical Cortex-M firmware by enabling whole-program analysis across translation units. The cost is increased link time and debugging complexity — both manageable with modern tooling:
-flto=auto (partitioned WHOPR) for balanced link time, -fno-fat-lto-objects to save build disk space-flto=thin with a persistent cache directory for CI-friendly incremental builds__attribute__((used, retain)) (GCC 11+) to prevent aggressive DCEFor flash-constrained projects (STM32G0/G4 with 128-256 KB, Nordic nRF52 with 512 KB, RP2040 with 2 MB external), LTO is often the difference between fitting and requiring a larger MCU.
--gc-sections and section layout strategiesused, retain, section attributes for LTO safetyQuick Links
Legal Stuff





