Skip to content

Two compiler flags cut the firmware by 64%: a field record of function-granularity reclamation with -ffunction-sections ​

Introduction: a counterintuitive starting point ​

The libestdx linker script has had -Wl,--gc-sections hanging on it all along — "linker garbage collection" looked like it had been on for ages. Yet when we wired the logging module into the build and, in passing, recorded each example firmware's size as a baseline, the numbers were perfectly calm: blinky sat steadily at 5500 bytes. The problem is that --gc-sections was on yet had almost nothing to do, because its unit of reclamation is the section, and under the default layout the code of an entire .o file is squeezed into a single .text section — as long as any one function in that object file is referenced, all of them board the firmware together. STM32's HAL driver files routinely pack dozens of functions, of which perhaps three to five are actually used; the rest just keep them company into flash.

Getting --gc-sections to actually pull its weight takes only two compiler flags:

cmake
set(MCU_FLAGS "-mcpu=cortex-m3 -mthumb -ffunction-sections -fdata-sections")

The mechanism: partitioning the "room" into "cells" ​

-ffunction-sections has the compiler place every function into its own section (-fdata-sections does the same for data). When the linker garbage-collects, it starts from the entry point and the KEEP-marked sections, runs reachability analysis along the reference chains, and discards unreachable cells at function granularity. The GNU ld manual and the various toolchain vendors' documents describe this pairing consistently (GNU ld documentation, Microchip's function-sections notes); two engineering caveats are also on record: critical sections such as the vector table need protection via KEEP() or SHF_GNU_RETAIN (AdaCore documentation, MaskRay's analysis), plus the leftover-debug-info problem for collected sections (LLVM forum discussion).

Measured: the slimming ledger of five firmwares ​

Record the baseline before touching anything — that is this article's first methodological point: before changing build flags, archive every artifact's arm-none-eabi-size output, then compare after the change. "Optimization" without a baseline is fortune-telling.

FirmwareBaseline (text)With the flagsChange
blinky55003468−37%
gpio_example55003468−37%
led_example55203476−37%
button_example55523484−37%
uart_example152685476−64%

blinky only uses HAL_Init/HAL_Delay, but every other HAL function inside the linked object files such as stm32f1xx_hal.c was dragged in — now those cells get reclaimed whole. uart_example was hit hardest and benefited most: in the UART-related HAL and the fine-grained RCC paths, half the functions were never reached at all — 15268 → 5476, nearly 10KB cut.

The pitfall: the toolchain file was edited, yet not a single number budged ​

After the first MCU_FLAGS edit and a fresh cmake --build, all five firmwares came out byte-for-byte identical to the baseline — the flags had not taken effect at all. The reason: the CMAKE_C_FLAGS_INIT family in the CMake toolchain file is written into the cache only when the build directory is first configured; later edits to the toolchain file do not propagate to the existing CMAKE_C_FLAGS. The verification is dead simple: look up CMAKE_C_FLAGS:STRING in build/CMakeCache.txt and you will still see the old value. The fix is to delete the build directory and reconfigure. This lesson deserves a page in any project's build documentation: editing the toolchain file = reconfigure, not an incremental build.

While we were at it, we also slipped in -ffile-prefix-map=${CMAKE_SOURCE_DIR}=. (with a $<$<COMPILE_LANGUAGE:C,CXX>:...> generator expression to keep it away from the assembler): by default source_location and __FILE__ bake the build machine's absolute paths into the firmware, so every log site was paying flash for them; prefix-map turns them into relative paths and is likewise a prerequisite for reproducible builds.

Regression: flags cannot be judged by size alone ​

Once the reclamation granularity goes finer, you must confirm that no section that ought to stay alive was collected along with the dead ones. We ran two layers of regression: a full rebuild of the five examples (zero warnings and zero failures across compile and link), plus the Renode automated check check_uart_renode — the serial console's welcome banner, command responses, line editing, and backspace were all verified byte for byte. Size optimization must always be handed over together with its functional regression report.

Caveats and common mistakes ​

SymptomCauseFix
Flags added, size does not budgetoolchain *_FLAGS_INIT only enters the cache at first configureDelete the build directory and reconfigure, or manually clear the CMAKE_*_FLAGS cache
Firmware slims down then fails to boot / HardFaultCritical sections such as the vector table got collectedKEEP() in the linker script, or SHF_GNU_RETAIN
Debugger line-stepping goes haywireLeftover debug info for collected sectionsRegenerate debug symbols; strip release builds
-ffile-prefix-map warns on .S filesThe assembler does not recognize the flagRestrict it to C/CXX with a generator expression

Wrap-up ​

  • --gc-sections hanging there alone is basically a decoration; -ffunction-sections -fdata-sections is what drops the reclamation granularity down to function level;
  • Measured across the five examples, sizes slimmed down 37%–64%, with the uart example losing nearly 10KB — the big HAL object files are the biggest beneficiaries;
  • Toolchain file edits only take effect after the build directory is reconfigured; when "the numbers did not change", check the cache first;
  • Record the baseline before changing flags and run functional regression afterwards — two small habits that turn "optimization" into engineering.

This batch of data doubles as the logging module's seed capital: in the next article, the logger's own firmware will be measured for size standing on this freshly trimmed baseline.

References ​

pdf-latest-4-g85128cc · 85128cc · 2026-10-05