
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.
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:
.bss and .data.+--------------------+ +--------------------+ +--------------------+| 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 |+--------------------+ +--------------------+ +--------------------+
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:
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>.CFLAGS += -ffunction-sections -fdata-sectionsCXXFLAGS += -ffunction-sections -fdata-sections
target_compile_options(${PROJECT_NAME} PRIVATE-ffunction-sections-fdata-sections)
[!NOTE] Compiling with
-ffunction-sectionsand-fdata-sectionsincreases intermediate.ofile 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.
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).
LDFLAGS += -Wl,--gc-sections
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.
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-sectionswill treat the vector table as dead code and prune it. The resulting binary will lack an initial stack pointer and reset address, triggering an immediateHardFaultor unbootable device.Important Distinction: Standard GNU
lddoes not have a--keep-sectioncommand-line flag. Attempting to use-Wl,--keep-sectionwill trigger a fatal linker error (unrecognized option '--keep-section'). Section retention must be declared in the linker script or via source code attributes.
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 ... */}
__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).
-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.
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 = 1024KRAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128KCCMRAM (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)}}
# Toolchain definitionsCC = arm-none-eabi-gccCXX = arm-none-eabi-g++OBJCOPY = arm-none-eabi-objcopySIZE = 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 flagsOPT = -O2CFLAGS = $(MCU_FLAGS) $(OPT) -Wall -WextraCFLAGS += -ffunction-sections -fdata-sections# Linker flags: pass --gc-sections to prune dead codeLDFLAGS = $(MCU_FLAGS) --specs=nano.specsLDFLAGS += -T STM32F407VGTx_FLASH.ldLDFLAGS += -Wl,--gc-sectionsLDFLAGS += -Wl,-Map=build/app.map,--cref# Source filesSRCS = 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.cOBJS = $(SRCS:.c=.o)OBJS := $(OBJS:.s=.o)all: build/app.elf build/app.bin sizebuild/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
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 Definitionsset(ARM_OPTIONS -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard)add_executable(firmware.elfsrc/main.csrc/system_stm32f4xx.cstartup/startup_stm32f407xx.sDrivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal.cDrivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_gpio.cDrivers/STM32F4xx_HAL_Driver/Src/stm32f4xx_hal_rcc.c)# Compiler section isolationtarget_compile_options(firmware.elf PRIVATE${ARM_OPTIONS}-O2-Wall-Wextra-ffunction-sections-fdata-sections)# Linker garbage collection and script configurationtarget_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 generationadd_custom_command(TARGET firmware.elf POST_BUILDCOMMAND 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.binCOMMENT "Building binary and reporting memory layout")
Run arm-none-eabi-size before and after enabling -ffunction-sections -fdata-sections -Wl,--gc-sections:
arm-none-eabi-size -B build/app.elf
| Configuration | .text (Flash) | .data (Flash + RAM) | .bss (RAM) | Total Flash (text + data) | Total RAM (data + bss) | Flash Savings |
|---|---|---|---|---|---|---|
| Without GC | 124,512 B | 2,048 B | 32,768 B | 126,560 B | 34,816 B | Baseline |
| With GC | 87,296 B | 1,536 B | 28,416 B | 88,832 B | 29,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.
--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.
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.
A common point of confusion is whether linker garbage collection speeds up compilation and linking itself:
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%.-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.-ffunction-sections -fdata-sections during compilation and -Wl,--gc-sections during linking..isr_vector using KEEP(*(.isr_vector)) in your linker script. Use __attribute__((retain)) (GCC 11+) for linker-level retention from source code.ld does not have a --keep-section option; rely exclusively on KEEP() inside the linker script or -Wl,-u,<symbol>.arm-none-eabi-size -B and -Wl,--print-gc-sections.-ffunction-sections): https://gcc.gnu.org/onlinedocs/gcc/Options-Code-Gen-Options.html#index-ffunction-sectionstarget_link_options): https://cmake.org/cmake/help/latest/command/target_link_options.htmlQuick Links
Legal Stuff





