
Embedded firmware frequently grows beyond necessary size due to unused code from libraries, HAL drivers, and legacy features. This bloat consumes precious flash memory, increases costs, and can prevent deployment on lower-cost hardware variants.
Consider a typical scenario: You’re using a vendor HAL library that provides drivers for all peripherals on a microcontroller, but your application only uses UART, timers, and GPIO. The unused SPI, I2C, ADC, and USB drivers still occupy flash memory, even though they’re never called.
This problem is exacerbated when:
The financial impact is significant: each additional 100KB of flash memory can increase the bill-of-materials cost by $0.10-$0.50 per unit in high-volume production. For a product shipping 100,000 units annually, this translates to $10,000-$50,000 in unnecessary component costs. Moreover, firmware bloat can force migration to more expensive microcontrollers with larger flash, creating a cascade of increased costs including higher power consumption, larger PCB footprint, and more expensive tooling.
By default, compilers place all functions and global variables into large combined sections (like .text for code and .data for initialized data). When the linker processes these sections, it must keep or discard the entire section as a unit. If a single function in a section is used, the entire section remains, bringing along all unused functions in that section.
This means that even if you only use one function from a large peripheral driver file, the entire driver file’s code gets linked into your firmware.
To understand this at a deeper level, consider how the compiler and linker work together:
During compilation, each source file is translated into an object file containing:
By default, the GNU toolchain places:
The linker combines object files into an executable by:
The critical limitation appears in step 2: the linker operates at the section level, not the symbol level. When combining sections, it cannot selectively remove individual functions or variables from within a section—it must keep the entire section if any symbol within it is referenced.
This creates the “all-or-nothing” problem: if a section contains both used and unused code, the linker must keep the entire section to preserve the used portions, thereby carrying along the unused code as dead weight.
The solution involves two complementary techniques:
Add these flags to your CMake configuration to instruct the compiler to place each function and variable in its own section:
# For GCC/Clangtarget_compile_options(${PROJECT_NAME} PRIVATE-ffunction-sections-fdata-sections)# For MSVC (if needed)if(MSVC)target_compile_options(${PROJECT_NAME} PRIVATE/Gy/Gw/Zc:threadSafeInit-)endif()
-ffunction-sections: Each function goes into its own .text.* section (e.g., .text.main, .text.UART_Init)-fdata-sections: Each global/static variable goes into its own .data. or .bss. section (e.g., .data.rx_buffer, .bss.system_state)/Gy (MSVC): Enable function-level linking (equivalent to -ffunction-sections)/Gw (MSVC): Enable global data optimization (equivalent to -fdata-sections)/Zc:threadSafeInit-: Disable thread-safe initialization for smaller code (MSVC-specific optimization)Configure the linker to remove unused sections:
# For GCC/Clangtarget_link_options(${PROJECT_NAME} PRIVATE-Wl,--gc-sections)# For MSVCif(MSVC)target_link_options(${PROJECT_NAME} PRIVATE/OPT:REF)endif()
--gc-sections: Enable garbage collection of unused sections (GNU ld)/OPT:REF: Remove unused references (MSVC linker)Here’s a complete example showing how to configure these options in a CMakeLists.txt file for an embedded project:
cmake_minimum_required(VERSION 3.15)project(firmware_size_optimization LANGUAGES C ASM)# Set target MCU architecture (example for STM32F4 Cortex-M4)set(CPU cortex-m4)set(FLOAT_ABI hard)set(FLOAT_TYPE fpv4-sp-d16)# Hardware architecture flags required for both compilation and linkingset(ARCH_FLAGS-mcpu=${CPU}-mthumb-mfloat-abi=${FLOAT_ABI}-mfpu=${FLOAT_TYPE})# Source filesadd_executable(${PROJECT_NAME}src/main.csrc/drivers/uart.csrc/drivers/timer.csrc/utils/crc.c)# Target compile options: separate sections and optimizationtarget_compile_options(${PROJECT_NAME} PRIVATE${ARCH_FLAGS}-ffunction-sections-fdata-sections-Os # Size optimization (-Og for debug)-g3 # Retain debug symbols)# Target linker options: garbage collect unused sections and select matching multilibstarget_link_options(${PROJECT_NAME} PRIVATE${ARCH_FLAGS}-Wl,--gc-sections-Wl,--print-memory-usage-Wl,-Map=${PROJECT_NAME}.map-Wl,--cref-T${CMAKE_CURRENT_SOURCE_DIR}/linker_script.ld)# Optional: Create size report after buildadd_custom_target(size-reportCOMMAND ${CMAKE_SIZE_UTIL} -A -d $<TARGET_FILE:${PROJECT_NAME}>DEPENDS ${PROJECT_NAME}COMMENT "Generating size report...")
To verify that dead code elimination is working, compare the memory usage before and after enabling these flags.
text data bss dec hex filename124560 8192 4096 136848 216a0 firmware.elf
text data bss dec hex filename82304 8192 4096 94496 17120 firmware.elf
This shows a reduction from 124.5KB to 82.3KB in the text section - a 34% size reduction achieved solely through dead code elimination.
For more detailed analysis, examine the linker map file to see which sections were removed. You can generate a map file by adding these flags to your CMake configuration:
# Generate a detailed map file during linkingtarget_link_options(${PROJECT_NAME} PRIVATE-Wl,-Map=${PROJECT_NAME}.map-Wl,--cref)
After building, look for removed sections in the generated map file:
# Search for discarded sections in the map filegrep -E "^\s*\.text\." firmware_size_optimization.map | grep -v ".*[^ ]"
The map file will show sections like .text.USBD_* or .text.GPIO_* that are absent after enabling garbage collection, confirming they were removed.
You can also use size utilities to get a section-by-section breakdown:
arm-none-eabi-size -A firmware.elf
This produces output showing each section’s size, making it easy to identify which specific functions or variables contributed to the size reduction.
Here’s a visual representation of how dead code elimination works:
+------------------------------------------------------------------+| FIRMWARE LINKING |+------------------------------------------------------------------+| BEFORE SECTION PER FUNCTION || +-----------------------+ +-----------------------+ || | .text section | | .data section | || | +-----------------+ | | +-----------------+ | || | | Function A | | | | Variable X | | || | | Function B | | | | Variable Y | | || | | Function C |<-Used| | Variable Z |<-Used || | | Function D | | | +-----------------+ | || | | Function E | | | +-----------------+ | || | | ... | | | | ... | | || | +-----------------+ | | +-----------------+ | || +-----------------------+ +-----------------------+ || ^ ^ || | Used functions/data | || +----------------------------+-----------------------+| KEPT || (Entire sections kept because they contain used items) |+------------------------------------------------------------------+| || AFTER SECTION PER FUNCTION || +-----------------------+ +-----------------------+ || | .text.A section | | .data.X section | || | .text.B section | | .data.Y section | || | .text.C section <-Keep| .data.Z section <-Keep || | .text.D section XXDrop| .data.W section XXDrop || | .text.E section XXDrop| .data.V section XXDrop || | .text.F section XXDrop| .data.U section XXDrop || | .text.G section XXDrop| .data.T section XXDrop || | .text.H section XXDrop| .data.S section XXDrop || +-----------------------+ +-----------------------+ || ^ ^ || | Used sections | || +----------------------------+-----------------------+| KEPT || (Only used sections kept; unused sections discarded) |+------------------------------------------------------------------+
Consider a UART driver file with multiple functions:
+--------------------------------------------------+| UART DRIVER OBJECT FILE |+--------------------------------------------------+| BEFORE -ffunction-sections || .text section || +--------------------------------+ || | UART_Init |<-Used | || | UART_DeInit | | || | UART_Send |<-Used | || | UART_Receive |<-Used | || | UART_EnableInterrupt | | || | UART_DisableInterrupt | | || | UART_GetFlagStatus | | || | UART_ClearFlag | | || +--------------------------------+ || || AFTER -ffunction-sections || .text.UART_Init |<-Keep || .text.UART_DeInit |XXXXXX || .text.UART_Send |<-Keep || .text.UART_Receive |<-Keep || .text.UART_EnableInterrupt |XXXXXX || .text.UART_DisableInterrupt |XXXXXX || .text.UART_GetFlagStatus |XXXXXX || .text.UART_ClearFlag |XXXXXX |+--------------------------------------------------+
With --gc-sections, the linker keeps only .text.UART_Init, .text.UART_Send, and .text.UART_Receive, discarding the unused interrupt and flag functions.
Despite its effectiveness, dead code elimination can fail silently if not configured correctly. Here are common issues to watch for:
If you only set linker flags without the corresponding compiler flags, the linker cannot remove individual functions because they remain grouped in large sections.
Symptom: No size reduction despite using --gc-sections.
Fix: Ensure both -ffunction-sections and -fdata-sections are present in compile options.
Object files in static libraries (.a) are only extracted if they resolve an undefined symbol. If an extracted library member was compiled without -ffunction-sections and -fdata-sections, all functions within that object file will be pulled into the final firmware regardless of whether they are executed.
Solution:
-ffunction-sections and -fdata-sections.--gc-sections will successfully discard unused functions even from extracted archive members.Custom linker scripts can interfere with garbage collection if they overuse KEEP() or omit it where strictly required by microcontroller hardware.
Symptom: Certain unused sections remain, or the firmware hard-faults immediately upon reset.
Fix:
KEEP() directives on application code and remove them to allow section garbage collection.KEEP() from the interrupt vector table (e.g., KEEP(*(.isr_vector))) or boot headers. Because the ARM Cortex-M hardware core fetches initial stack pointer and reset handler addresses via memory-mapped hardware mechanisms rather than software symbol references, omitting KEEP() on the vector table causes the linker to discard it, rendering the microcontroller unbootable.Some compiler optimizations (like -flto or specific inline decisions) can affect section placement or create references that prevent garbage collection.
Symptom: Inconsistent size reduction between builds.
Fix: Test with and without link-time optimization (-flto) to see if it interferes. When using LTO, ensure the linker plugin supports garbage collection.
If any part of your code (even indirectly) references a function or variable, the entire section containing it will be kept.
Common sources of accidental references:
Detection: To find out why a particular section or function was retained, instruct the linker to trace symbol references or inspect the cross-reference map:
# Trace why a symbol was pulled into the linkarm-none-eabi-gcc ... -Wl,-y,UART_DeInit# Inspect the cross-reference table generated by --crefgrep -A 5 "UART_DeInit" firmware_size_optimization.map
Some linkers require sections to be aligned to specific boundaries. If alignment padding creates references between sections, it can prevent garbage collection.
Symptom: Sections remain despite appearing unused.
Fix: Check linker map for alignment gaps and adjust section placement or alignment requirements.
Let’s walk through a practical example of analyzing a linker map file to confirm dead code elimination is working.
Consider this excerpt from a map file before enabling garbage collection:
.text 0x08004000 0x1a2c00x08004000 main.c.obj0x00000000 main0x00000024 SystemInit0x00000048 app_run0x08004100 drivers/uart.obj0x00000000 UART_Init0x00000064 UART_DeInit0x00000084 UART_SendString0x000000c8 UART_ReceiveByte0x000000e4 UART_GetLineStatus0x00000100 UART_SetBaudRate0x0000011c UART_EnableInterrupt0x00000140 UART_DisableInterrupt0x00000160 UART_GetFlagStatus0x0000017c UART_ClearFlag0x08004300 drivers/adc.obj0x00000000 ADC_Init0x00000060 ADC_ReadChannel0x000000a0 ADC_Calibrate0x080043c0 drivers/usb.obj0x00000000 USB_Init0x000000c0 USB_HandleInterrupt0x00000180 USB_SendData
After enabling -ffunction-sections -fdata-sections --gc-sections, the same region might look like:
.text 0x08004000 0xc350.text.main 0x08004000 0x24 main.c.obj0x08004000 main.text.SystemInit0x08004024 0x24 main.c.obj0x08004024 SystemInit.text.UART_Init0x08004048 0x5c drivers/uart.obj0x08004048 UART_Init.text.UART_SendString0x080040a4 0x38 drivers/uart.obj0x080040a4 UART_SendString.text.UART_ReceiveByte0x080040dc 0x1c drivers/uart.obj0x080040dc UART_ReceiveByte
Notice how:
.text.<function> subsection with an explicit sizedrivers/adc.obj and drivers/usb.obj contributions are absent — all their sections were garbage collected since no application code referenced themdrivers/uart.obj, only the three actually-called functions (UART_Init, UART_SendString, UART_ReceiveByte) survived; the remaining seven UART functions were discardedThis confirms that the linker successfully removed unused sections at the granularity of individual functions.
While dead code elimination primarily reduces binary size, it can also affect other aspects of the development process:
To ensure debugging works correctly:
-g3) separate from optimization settings-ggdb or equivalent debug format is usedReducing firmware size isn’t just about flash memory—it can also impact power consumption:
To quantify power benefits:
Typical power savings from code size reduction alone are modest (<5%) but can be meaningful in battery-operated devices where every microwatt counts.
Dead code elimination through CMake linker optimization is a powerful, non-intrusive technique for reducing embedded firmware size. By combining -ffunction-sections -fdata-sections compiler flags with --gc-sections linker flags, developers can achieve 20-50% size reductions without modifying source code.
Key takeaways:
For embedded systems where flash memory is precious and costs are sensitive, this optimization should be considered a standard practice in build configuration. The minimal configuration overhead and significant potential savings make it one of the highest-impact, lowest-effort optimizations available for embedded firmware development.
For further optimization techniques, see these related embeddedSoft articles:
Quick Links
Legal Stuff



