HomeAbout UsContact Us

Linker Garbage Collection for Faster STM32 Builds

By Jithin Tom
September 16, 2026
5 min read
Linker Garbage Collection for Faster STM32 Builds

Table Of Contents

01
Problem: Firmware Bloat and Flashing Latency
02
Root Cause: Monolithic ELF Sections in GNU Toolchains
03
Solution: Step-by-Step Implementation
04
Production Linker Script (STM32F407VGTx_FLASH.ld)
05
Complete Working Build System Configurations
06
Verification and Diagnostics
07
Deep Dive: Build Time vs. Flashing Throughput Trade-offs
08
Summary
09
Related Reading
10
References
11
Frequently Asked Questions

When building embedded firmware for STM32 microcontrollers using vendor hardware abstraction layers (STM32Cube HAL/LL), standard CMSIS packs, and third-party middleware (such as FreeRTOS, LwIP, or FatFS), binary size often inflates rapidly. Pulling in a single peripheral driver—such as stm32f4xx_hal_uart.c—can drag dozens of unused polling, interrupt, and DMA helper routines into your output image.

This article examines the underlying mechanisms of GNU Linker Garbage Collection (--gc-sections), dissects how the compiler-linker contract works on ARM Cortex-M microcontrollers, explains why --gc-sections accelerates day-to-day firmware iterations despite minor link-time analysis overhead, and provides production-ready Makefile and CMake configurations with correct linker script preservation rules.


Problem: Firmware Bloat and Flashing Latency

In standard C compilation models, the translation unit (.c file) is compiled into a single relocatable object file (.o). By default, GCC emits all executable instructions into a monolithic .text section, all initialized global/static variables into .data, and all uninitialized variables into .bss.

When your application references a single symbol in an object file, the GNU linker (arm-none-eabi-ld) includes the entire object file’s sections into the final executable. In an STM32 project, this leads to significant resource overhead:

  • Flash Bloat: Unused peripheral APIs, fallback handlers, and formatting routines consume tens of kilobytes of internal Flash.
  • RAM Waste: Unreferenced global state, ring buffers, and driver control blocks consume static SRAM in .bss and .data.
  • Target Flashing Latency: Programming 150 KB over a 4 MHz Serial Wire Debug (SWD) or JTAG probe takes substantially longer than programming a 95 KB optimized image. In high-iteration development loops, flash programming overhead dominates build turnaround times.
+--------------------+ +--------------------+ +--------------------+
| SOURCE CODE | | COMPILER | | OBJECT FILES |
| - main.c | -----> | arm-none-eabi-gcc | -----> | .text.main |
| - stm32f4xx_hal.c | | -ffunction-sections| | .text.HAL_GPIO_Init|
| - middleware.c | | -fdata-sections | | .text.Unused_Func |
+--------------------+ +--------------------+ +--------------------+
|
v
+--------------------+ +--------------------+ +--------------------+
| FINAL BINARY | | DISCARDED BY GC | | LINKER |
| - Flash: .text | <----- | .text.Unused_Func | <----- | arm-none-eabi-ld |
| (used functions) | | .data.Unused_Var | | -Wl,--gc-sections |
| - RAM: .data/.bss| | (zero flash waste) | | -T stm32f4.ld |
+--------------------+ +--------------------+ +--------------------+

Root Cause: Monolithic ELF Sections in GNU Toolchains

The fundamental cause of binary bloat is section granularity. Traditional Unix linkers operate on sections, not individual functions or variables. If HAL_UART_Init() and HAL_UART_AbortReceive() share the same .text section inside stm32f4xx_hal_uart.o, the linker cannot remove HAL_UART_AbortReceive() without bisecting the section and corrupting local branch relocations.

To solve this, the toolchain requires a two-stage collaboration:

  1. Compiler Stage: The compiler must generate an isolated ELF section for every independent function and data object.
  2. Linker Stage: The linker must construct a directed call-and-reference graph, identify roots (such as the Reset Vector), traverse all reachable nodes, and discard disconnected subgraphs.

Solution: Step-by-Step Implementation

Step 1: Enforce Section Granularity in the Compiler

Add the following compiler flags to your build system:

-ffunction-sections -fdata-sections
  • -ffunction-sections: Instructs GCC to emit each function into its own unique section named .text.<function_name> (e.g., .text.HAL_GPIO_Init, .text.main).
  • -fdata-sections: Instructs GCC to emit each global or static variable into .data.<var_name>, .bss.<var_name>, or .rodata.<var_name>.

Makefile Syntax

CFLAGS += -ffunction-sections -fdata-sections
CXXFLAGS += -ffunction-sections -fdata-sections

Modern CMake Syntax (CMake 3.13+)

target_compile_options(${PROJECT_NAME} PRIVATE
-ffunction-sections
-fdata-sections
)

[!NOTE] Compiling with -ffunction-sections and -fdata-sections increases intermediate .o file sizes on your host disk because each section requires its own entry in the ELF section header table and relocation tables. This is normal and does not impact target Flash memory.


Step 2: Enable Linker Garbage Collection

Instruct the GNU linker driver to prune unreferenced sections during final resolution:

-Wl,--gc-sections

The prefix -Wl, instructs the compiler driver (arm-none-eabi-gcc) to pass the subsequent option (--gc-sections) directly to the linker (arm-none-eabi-ld).

Makefile Syntax

LDFLAGS += -Wl,--gc-sections

Modern CMake Syntax (CMake 3.13+)

target_link_options(${PROJECT_NAME} PRIVATE
-Wl,--gc-sections
)

To view an exact log of which sections the linker removes during garbage collection, optionally append the diagnostic flag -Wl,--print-gc-sections.


Step 3: Safeguard Hardware Roots with KEEP()

On ARM Cortex-M processors, the hardware Nested Vectored Interrupt Controller (NVIC) fetches the Initial Main Stack Pointer (_estack) and the Reset_Handler address directly from physical Flash addresses 0x08000000 and 0x08000004 upon exiting reset. Subsequent peripheral interrupt vectors (SysTick, USART, DMA) are invoked directly by the NVIC hardware via hardware vector dispatch.

Because these vector table entries are triggered by hardware interrupts rather than software branch instructions (BL/BLX), standard static call-graph traversal sees no incoming software references to the vector table.

[!WARNING] If the interrupt vector table is placed in an ordinary input section without protection, --gc-sections will treat the vector table as dead code and prune it. The resulting binary will lack an initial stack pointer and reset address, triggering an immediate HardFault or unbootable device.

Important Distinction: Standard GNU ld does not have a --keep-section command-line flag. Attempting to use -Wl,--keep-section will trigger a fatal linker error (unrecognized option '--keep-section'). Section retention must be declared in the linker script or via source code attributes.

1. Linker Script Protection (KEEP)

Wrap critical section patterns inside the KEEP() directive in your linker script (.ld file). This instructs GNU ld to treat the section as an immutable root node during mark-and-sweep garbage collection:

SECTIONS
{
/* Interrupt vector table must be preserved and placed first in FLASH */
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector)) /* Startup vector table */
. = ALIGN(4);
} > FLASH
/* ... remaining sections ... */
}

2. Source Code Protection (__attribute__((used)))

In CMSIS-compliant startup code, ensure the vector table array is declared with the used attribute:

__attribute__((section(".isr_vector"), used))
const pFunc __VECTOR_TABLE[] = {
(pFunc)(&_estack),
Reset_Handler,
NMI_Handler,
HardFault_Handler,
/* ... peripheral vectors ... */
};

The __attribute__((used)) directive tells the compiler to emit the definition even if it appears unused within the translation unit (preventing the compiler from discarding it during optimization). However, used alone does not prevent the linker from garbage-collecting the section — you still need KEEP() in the linker script for full protection. Starting with GCC 11 and Binutils 2.36, the separate __attribute__((retain)) sets the SHF_GNU_RETAIN ELF section flag, which instructs the linker to preserve the section unconditionally even under --gc-sections. For maximum portability, combine used (compiler-side) with KEEP() (linker-side).

3. Command-Line Symbol Roots (-u / --undefined)

If you must preserve an entry point from the command line without modifying the linker script, use the -u (--undefined) linker flag:

LDFLAGS += -Wl,-u,Reset_Handler

This forces the linker to treat Reset_Handler as an unresolved reference at the start of linking, forcing its containing section into the reachability graph.


Production Linker Script (STM32F407VGTx_FLASH.ld)

Below is a complete, syntactically correct GNU linker script for an STM32F407 microcontroller (1024 KB Flash, 128 KB SRAM, 64 KB CCMRAM) demonstrating proper section placement, alignment, symbol export for C startup runtime initialization, and KEEP() directives:

/* Entry Point */
ENTRY(Reset_Handler)
/* Highest address of the user mode stack */
_estack = ORIGIN(RAM) + LENGTH(RAM); /* End of 128KB SRAM */
_Min_Heap_Size = 0x200; /* Required heap: 512B */
_Min_Stack_Size = 0x400; /* Required stack: 1024B */
/* Memory Spaces Definitions */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
CCMRAM (rw) : ORIGIN = 0x10000000, LENGTH = 64K
}
/* Sections Definitions */
SECTIONS
{
/* The startup code goes first into FLASH */
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector)) /* Startup vector table must be preserved */
. = ALIGN(4);
} > FLASH
/* Program code and read-only data into FLASH */
.text :
{
. = ALIGN(4);
*(.text) /* Standard .text sections */
*(.text*) /* Subsections generated by -ffunction-sections */
*(.glue_7) /* ARM-to-Thumb interworking code */
*(.glue_7t) /* Thumb-to-ARM interworking code */
*(.eh_frame)
KEEP (*(.init))
KEEP (*(.fini))
. = ALIGN(4);
_etext = .; /* Global symbol marking end of code */
} > FLASH
/* Constant read-only data into FLASH */
.rodata :
{
. = ALIGN(4);
*(.rodata) /* Standard .rodata sections */
*(.rodata*) /* Subsections generated by -fdata-sections */
. = ALIGN(4);
} > FLASH
/* ARM exception handling tables */
.ARM.extab :
{
*(.ARM.extab* .gnu.linkonce.armextab.*)
} > FLASH
.ARM :
{
__exidx_start = .;
*(.ARM.exidx*)
__exidx_end = .;
} > FLASH
/* Used by the startup to initialize data */
_sidata = LOADADDR(.data);
/* Initialized data sections into RAM, loaded from FLASH */
.data :
{
. = ALIGN(4);
_sdata = .; /* Symbol marking start of data */
*(.data) /* Standard .data sections */
*(.data*) /* Subsections generated by -fdata-sections */
. = ALIGN(4);
_edata = .; /* Symbol marking end of data */
} > RAM AT > FLASH
/* Uninitialized data section into RAM */
. = ALIGN(4);
.bss :
{
_sbss = .; /* Symbol marking start of bss */
__bss_start__ = _sbss;
*(.bss)
*(.bss*) /* Subsections generated by -fdata-sections */
*(COMMON)
. = ALIGN(4);
_ebss = .; /* Symbol marking end of bss */
__bss_end__ = _ebss;
} > RAM
/* User stack and heap validation section */
._user_heap_stack :
{
. = ALIGN(8);
PROVIDE ( end = . );
PROVIDE ( _end = . );
. = . + _Min_Heap_Size;
. = . + _Min_Stack_Size;
. = ALIGN(8);
} > RAM
/* Discard debug-only sections to save Flash */
/DISCARD/ :
{
*(.ARM.attributes)
*(.comment)
*(.note.gnu.build-id)
}
}

Complete Working Build System Configurations

1. Production Makefile

# Toolchain definitions
CC = arm-none-eabi-gcc
CXX = arm-none-eabi-g++
OBJCOPY = arm-none-eabi-objcopy
SIZE = arm-none-eabi-size
# Target MCU configuration (STM32F407 - Cortex-M4F)
MCU_FLAGS = -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard
# Optimization and Garbage Collection compiler flags
OPT = -O2
CFLAGS = $(MCU_FLAGS) $(OPT) -Wall -Wextra
CFLAGS += -ffunction-sections -fdata-sections
# Linker flags: pass --gc-sections to prune dead code
LDFLAGS = $(MCU_FLAGS) --specs=nano.specs
LDFLAGS += -T STM32F407VGTx_FLASH.ld
LDFLAGS += -Wl,--gc-sections
LDFLAGS += -Wl,-Map=build/app.map,--cref
# Source files
SRCS = src/main.c \
src/system_stm32f4xx.c \
startup/startup_stm32f407xx.s \
Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal.c \
Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_gpio.c \
Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_rcc.c
OBJS = $(SRCS:.c=.o)
OBJS := $(OBJS:.s=.o)
all: build/app.elf build/app.bin size
build/app.elf: $(OBJS)
@mkdir -p $(dir $@)
$(CC) $(OBJS) $(LDFLAGS) -o $@
build/app.bin: build/app.elf
$(OBJCOPY) -O binary $< $@
size: build/app.elf
@echo "=== Firmware Memory Consumption ==="
$(SIZE) -B $<
clean:
rm -rf build $(OBJS)
.PHONY: all clean size

2. Modern CMakeLists.txt (CMake 3.16+)

cmake_minimum_required(VERSION 3.16)
project(stm32_firmware C CXX ASM)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 14)
# Architecture Definitions
set(ARM_OPTIONS -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard)
add_executable(firmware.elf
src/main.c
src/system_stm32f4xx.c
startup/startup_stm32f407xx.s
Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal.c
Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_gpio.c
Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_rcc.c
)
# Compiler section isolation
target_compile_options(firmware.elf PRIVATE
${ARM_OPTIONS}
-O2
-Wall
-Wextra
-ffunction-sections
-fdata-sections
)
# Linker garbage collection and script configuration
target_link_options(firmware.elf PRIVATE
${ARM_OPTIONS}
--specs=nano.specs
-T${CMAKE_CURRENT_SOURCE_DIR}/STM32F407VGTx_FLASH.ld
-Wl,--gc-sections
-Wl,-Map=${CMAKE_CURRENT_BINARY_DIR}/firmware.map,--cref
)
# Post-build size reporting and binary generation
add_custom_command(TARGET firmware.elf POST_BUILD
COMMAND arm-none-eabi-size -B $<TARGET_FILE:firmware.elf>
COMMAND arm-none-eabi-objcopy -O binary $<TARGET_FILE:firmware.elf> ${CMAKE_CURRENT_BINARY_DIR}/firmware.bin
COMMENT "Building binary and reporting memory layout"
)

Verification and Diagnostics

1. Quantifying Flash and RAM Reductions

Run arm-none-eabi-size before and after enabling -ffunction-sections -fdata-sections -Wl,--gc-sections:

arm-none-eabi-size -B build/app.elf

Typical Benchmark Comparison (STM32F4 HAL Project)

Configuration.text (Flash).data (Flash + RAM).bss (RAM)Total Flash (text + data)Total RAM (data + bss)Flash Savings
Without GC124,512 B2,048 B32,768 B126,560 B34,816 BBaseline
With GC87,296 B1,536 B28,416 B88,832 B29,952 B-29.8%

In this benchmark, 37,728 bytes (~37 KB) of unreferenced HAL drivers and dead buffers were stripped from the final Flash payload.


2. Identifying Discarded Sections (--print-gc-sections)

To confirm which symbols and sections were culled by the linker, pass -Wl,--print-gc-sections during the link step. The linker prints removals directly to standard error:

arm-none-eabi-ld: removing unused section '.text.HAL_UART_Abort' in 'Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_uart.o'
arm-none-eabi-ld: removing unused section '.text.HAL_UART_AbortTransmit' in 'Drivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_uart.o'
arm-none-eabi-ld: removing unused section '.data.s_unused_driver_buffer' in 'src/main.o'
arm-none-eabi-ld: removing unused section '.bss.debug_telemetry_cache' in 'src/telemetry.o'

Alternatively, open the generated .map file and search for the header Discarded input sections.


3. Inspecting the Retained Symbol Hierarchy

Use arm-none-eabi-nm to audit remaining symbol allocations in descending order of size:

arm-none-eabi-nm --size-sort --print-size -C build/app.elf | grep -E " [tTdDbB] " | tail -25

This ensures that only expected, active application routines and active RTOS primitives remain in your memory map.


Deep Dive: Build Time vs. Flashing Throughput Trade-offs

A common point of confusion is whether linker garbage collection speeds up compilation and linking itself:

  1. Compiler Overhead: Generating individual ELF section headers and relocation entries for hundreds of individual functions slightly increases GCC’s compilation pass duration (typically 2% to 5%).
  2. Linker Overhead: GNU ld must parse a significantly larger section header table, resolve more global symbols, build the reachability graph, and execute the sweep phase. This increases pure link time by 5% to 15%.
  3. Flashing and Iteration Speedup: In embedded development, developers rarely build without flashing. Flashing 88 KB over SWD at 4 MHz takes approximately 1.5 seconds, compared to 3.2 seconds for 126 KB (factoring in sector erase times and verification passes). Over a development day with 60 flash cycles, linker garbage collection saves several minutes of developer wait time.
  4. Link-Time Optimization (LTO) Comparison: Unlike -flto, which performs cross-module inlining and whole-program code regeneration (often adding 10 to 45 seconds to link time), --gc-sections completes in milliseconds because it only drops pre-compiled sections without modifying bytecode.

Summary

  • Combine Compiler and Linker Flags: Linker GC requires -ffunction-sections -fdata-sections during compilation and -Wl,--gc-sections during linking.
  • Protect Hardware Roots: The Cortex-M NVIC reads vectors directly via hardware. Guard .isr_vector using KEEP(*(.isr_vector)) in your linker script. Use __attribute__((retain)) (GCC 11+) for linker-level retention from source code.
  • Avoid Imaginary Flags: GNU ld does not have a --keep-section option; rely exclusively on KEEP() inside the linker script or -Wl,-u,<symbol>.
  • Diagnostic Verification: Audit savings using arm-none-eabi-size -B and -Wl,--print-gc-sections.


References

  1. GNU ld (GNU Binutils) Linker Options: https://sourceware.org/binutils/docs/ld/Options.html#index-_002d_002dgc_002dsections
  2. GNU ld (GNU Binutils) Linker Script KEEP Directive: https://sourceware.org/binutils/docs/ld/Input-Section-Keep.html
  3. GCC Code Generation Options (-ffunction-sections): https://gcc.gnu.org/onlinedocs/gcc/Options-Code-Gen-Options.html#index-ffunction-sections
  4. Armv7-M Architecture Reference Manual (DDI0403E.e): https://developer.arm.com/documentation/ddi0403/latest/
  5. STMicroelectronics RM0090 Reference Manual (STM32F405/407): https://www.st.com/content/ccc/resource/technical/document/reference_manual/group0/d3/9b/38/32/40/43/25/pdf/DM00031020.pdf/jcr:content/translations/en.DM00031020.pdf
  6. CMake Link Options Documentation (target_link_options): https://cmake.org/cmake/help/latest/command/target_link_options.html

Frequently Asked Questions

What is linker garbage collection and how does it optimize STM32 firmware?

Linker garbage collection (--gc-sections) instructs the GNU linker to construct a call-and-data reference graph starting from designated entry points and eliminate all unreferenced ELF sections. Combined with -ffunction-sections and -fdata-sections, it purges unused HAL drivers, middleware routines, and dead variables, reducing Flash usage by 15% to 30%.

Does linker garbage collection make linking faster or slower?

Pure link time slightly increases (typically 5% to 15%) because GNU ld must process significantly more ELF section headers and perform graph reachability analysis. However, the resulting 15% to 30% reduction in binary size drastically cuts SWD/JTAG flash programming time, yielding a substantially faster end-to-end build-flash-debug development cycle.

How do I enable linker garbage collection in GCC and CMake for STM32?

Compile with -ffunction-sections -fdata-sections to place each function and variable into its own ELF section, and pass -Wl,--gc-sections to the compiler driver during the link stage. In modern CMake (3.13+), use target_compile_options(app PRIVATE -ffunction-sections -fdata-sections) and target_link_options(app PRIVATE -Wl,--gc-sections).

How do I prevent critical sections like the vector table from being discarded?

Hardware-referenced structures like the Cortex-M interrupt vector table (.isr_vector) lack incoming software call relocations. They must be explicitly protected in the linker script using the KEEP() directive (e.g., KEEP(*(.isr_vector))). The C attribute __attribute__((used)) prevents compiler-side removal but does not prevent linker garbage collection; for linker-side retention without a linker script change, use __attribute__((retain)) (GCC 11+/Binutils 2.36+) or force the symbol as a root via -Wl,-u,<symbol>. GNU ld does not provide a --keep-section command-line flag.

How can I verify which sections the linker discarded?

Pass -Wl,--print-gc-sections to the linker. The linker will print every discarded section to stderr during the build. You can also generate a map file (-Wl,-Map=build/app.map) and inspect the Discarded input sections table.

Tags

stm32buildlinkergccoptimizationarm-cortex-m

Share


Previous Article
Debugging Intermittent I2C Bus Hangs in Embedded Systems
Jithin Tom

Jithin Tom

A Closer Look at C/C++, RTOS, and Embedded Systems

Related Posts

Debugging Intermittent I2C Bus Hangs in Embedded Systems
Debugging Intermittent I2C Bus Hangs in Embedded Systems
September 15, 2026
4 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media