HomeAbout UsContact Us

Reducing Firmware Size with CMake Dead Code Elimination

By Jithin Tom
October 08, 2026
8 min read
Reducing Firmware Size with CMake Dead Code Elimination

Table Of Contents

01
Problem: Firmware Bloat from Unused Code
02
Root Cause: Linker Behavior with Combined Sections
03
Solution: Section-Per-Function + Linker Garbage Collection
04
Verification: Measuring the Impact
05
ASCII Art: Before/After Comparison
06
Common Pitfalls and Troubleshooting
07
Extended Verification: Map File Analysis
08
Impact on Build Times and Debugging
09
Power Consumption Benefits
10
Conclusion
11
Related Reading
12
References
13
Frequently Asked Questions

Problem: Firmware Bloat from Unused Code

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:

  • Using middleware stacks (USB, TCP/IP, graphics) where only a subset of features is needed
  • Including debug features or diagnostic code that gets compiled into release builds
  • Leveraging large peripheral libraries where most peripheral instances remain unused
  • Developing hardware-agnostic code that supports multiple variants but only uses a subset on each target
  • Incorporating open-source libraries that offer extensive functionality beyond immediate requirements

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.

Root Cause: Linker Behavior with Combined Sections

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:

Compilation Phase

During compilation, each source file is translated into an object file containing:

  • Symbol table: Lists all functions and variables with their addresses
  • Relocation information: Describes how to adjust addresses when linking
  • Sections: Blocks of code and data with specific attributes (executable, writable, etc.)

By default, the GNU toolchain places:

  • All functions from a single .c file into a single .text section
  • All initialized global/static variables into a single .data section
  • All uninitialized global/static variables into a single .bss section

Linking Phase

The linker combines object files into an executable by:

  1. Resolving symbols between object files
  2. Combining like sections (all .text sections become one .text section in the output)
  3. Assigning final memory addresses
  4. Handling library archives (extracting only needed object files)

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.

Solution: Section-Per-Function + Linker Garbage Collection

The solution involves two complementary techniques:

  1. Compiler: Place each function and data item in its own separate section
  2. Linker: Remove unused sections during the linking process

Step 1: Compiler Flags for Section Separation

Add these flags to your CMake configuration to instruct the compiler to place each function and variable in its own section:

# For GCC/Clang
target_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)

Step 2: Linker Flags for Garbage Collection

Configure the linker to remove unused sections:

# For GCC/Clang
target_link_options(${PROJECT_NAME} PRIVATE
-Wl,--gc-sections
)
# For MSVC
if(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)

Complete CMake Example

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 linking
set(ARCH_FLAGS
-mcpu=${CPU}
-mthumb
-mfloat-abi=${FLOAT_ABI}
-mfpu=${FLOAT_TYPE}
)
# Source files
add_executable(${PROJECT_NAME}
src/main.c
src/drivers/uart.c
src/drivers/timer.c
src/utils/crc.c
)
# Target compile options: separate sections and optimization
target_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 multilibs
target_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 build
add_custom_target(size-report
COMMAND ${CMAKE_SIZE_UTIL} -A -d $<TARGET_FILE:${PROJECT_NAME}>
DEPENDS ${PROJECT_NAME}
COMMENT "Generating size report..."
)

Verification: Measuring the Impact

To verify that dead code elimination is working, compare the memory usage before and after enabling these flags.

Before (without section separation):

text data bss dec hex filename
124560 8192 4096 136848 216a0 firmware.elf

After (with -ffunction-sections -fdata-sections —gc-sections):

text data bss dec hex filename
82304 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.

Advanced Verification Techniques

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 linking
target_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 file
grep -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.

ASCII Art: Before/After Comparison

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) |
+------------------------------------------------------------------+

Another Perspective: Peripheral Driver Example

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.

Common Pitfalls and Troubleshooting

Despite its effectiveness, dead code elimination can fail silently if not configured correctly. Here are common issues to watch for:

Pitfall 1: Missing Compiler Flags

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.

Pitfall 2: Static Libraries

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:

  • Ensure all static libraries and third-party SDK archives are compiled with -ffunction-sections and -fdata-sections.
  • When section separation is present, --gc-sections will successfully discard unused functions even from extracted archive members.
  • For libraries lacking section separation, split large source files into fine-grained object files so only the required symbols are extracted.

Pitfall 3: Linker Script Conflicts & Missing KEEP Directives

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:

  • Review linker scripts for unnecessary KEEP() directives on application code and remove them to allow section garbage collection.
  • Critical Safety Rule: Never remove 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.

Pitfall 4: Compiler Optimizations

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.

Pitfall 5: References from Used Code

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:

  • Function pointers in jump tables or callback arrays
  • Debug logging format strings that reference variables
  • Non-weak default interrupt vectors pointing to handlers in peripheral drivers
  • HAL initialization structures containing function pointer tables

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 link
arm-none-eabi-gcc ... -Wl,-y,UART_DeInit
# Inspect the cross-reference table generated by --cref
grep -A 5 "UART_DeInit" firmware_size_optimization.map

Pitfall 6: Section Alignment Requirements

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.

Extended Verification: Map File Analysis

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 0x1a2c0
0x08004000 main.c.obj
0x00000000 main
0x00000024 SystemInit
0x00000048 app_run
0x08004100 drivers/uart.obj
0x00000000 UART_Init
0x00000064 UART_DeInit
0x00000084 UART_SendString
0x000000c8 UART_ReceiveByte
0x000000e4 UART_GetLineStatus
0x00000100 UART_SetBaudRate
0x0000011c UART_EnableInterrupt
0x00000140 UART_DisableInterrupt
0x00000160 UART_GetFlagStatus
0x0000017c UART_ClearFlag
0x08004300 drivers/adc.obj
0x00000000 ADC_Init
0x00000060 ADC_ReadChannel
0x000000a0 ADC_Calibrate
0x080043c0 drivers/usb.obj
0x00000000 USB_Init
0x000000c0 USB_HandleInterrupt
0x00000180 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.obj
0x08004000 main
.text.SystemInit
0x08004024 0x24 main.c.obj
0x08004024 SystemInit
.text.UART_Init
0x08004048 0x5c drivers/uart.obj
0x08004048 UART_Init
.text.UART_SendString
0x080040a4 0x38 drivers/uart.obj
0x080040a4 UART_SendString
.text.UART_ReceiveByte
0x080040dc 0x1c drivers/uart.obj
0x080040dc UART_ReceiveByte

Notice how:

  1. The total .text size reduced from 0x1a2c0 (107,200 bytes) to 0xc350 (50,000 bytes)
  2. Each retained function now appears as its own named .text.<function> subsection with an explicit size
  3. The entire drivers/adc.obj and drivers/usb.obj contributions are absent — all their sections were garbage collected since no application code referenced them
  4. From drivers/uart.obj, only the three actually-called functions (UART_Init, UART_SendString, UART_ReceiveByte) survived; the remaining seven UART functions were discarded

This confirms that the linker successfully removed unused sections at the granularity of individual functions.

Impact on Build Times and Debugging

While dead code elimination primarily reduces binary size, it can also affect other aspects of the development process:

Build Time Impact

  • Compile time: Slight increase due to more sections being created and processed
  • Link time: Potential increase as the linker must process more sections and perform garbage collection analysis
  • Overall impact: Usually negligible (<5% increase) for typical embedded projects

Debugging Considerations

  • Symbol availability: Symbols for removed functions and data are discarded from the ELF file. The debugger will not be able to find them.
  • Stack traces: May show fewer functions if unused code was removed, which accurately reflects the runtime state.
  • Breakpoints: You cannot set breakpoints by name on functions that were eliminated as dead code (the debugger will report the symbol is missing), which is expected since the code does not exist in the binary.
  • Memory layout: Functions may be placed differently in memory due to section reordering, but relative order within retained sections is typically preserved.

To ensure debugging works correctly:

  1. Keep debug symbols (-g3) separate from optimization settings
  2. Verify that -ggdb or equivalent debug format is used
  3. Confirm that the debugger can load symbols from the ELF file before flashing

Power Consumption Benefits

Reducing firmware size isn’t just about flash memory—it can also impact power consumption:

Static Power

  • Leakage current: Larger flash arrays may have slightly higher leakage, though this is usually negligible compared to other sources
  • Cache effectiveness: For microcontrollers with instruction cache, smaller code footprints improve cache hit rates, reducing power spent on cache misses

Dynamic Power

  • Flash access frequency: Smaller code means fewer flash fetches for the same workload, reducing dynamic power during code execution
  • Execution efficiency: Better cache locality can reduce the number of wait states needed for flash access

Measurement Approach

To quantify power benefits:

  1. Measure sleep/current consumption with original firmware
  2. Measure with size-optimized firmware
  3. Ensure identical workload and peripheral usage
  4. Account for any performance differences (faster execution may reduce active time)

Typical power savings from code size reduction alone are modest (<5%) but can be meaningful in battery-operated devices where every microwatt counts.

Conclusion

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:

  1. Both compiler and linker flags are required—omitting either prevents the optimization from working
  2. The technique is safe—only provably unreachable code is removed
  3. Verification is straightforward—compare section sizes in map files or use size utilities
  4. Benefits extend beyond size—can improve cache efficiency and reduce power consumption
  5. Works with any toolchain—GCC, Clang, and MSVC all support equivalent functionality

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:

References

  1. ARM Limited. “Arm Compiler armlink User Guide.” ARM Developer Documentation. https://developer.arm.com/documentation/100070/latest
  2. FreeRTOS. “Kernel Configuration & Customization.” FreeRTOS Documentation. https://www.freertos.org/Documentation/02-Kernel/03-Supported-devices/02-Customization
  3. GCC Project. “Optimize Options: -ffunction-sections, -fdata-sections.” GCC Online Documentation. https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
  4. SEGGER. “SEGGER Linker Script Files.” SEGGER Knowledge Base. https://kb.segger.com/SEGGER_Linker_Script_Files
  5. STMicroelectronics. “STM32F4 Reference Manual RM0090.” ST.com. https://www.st.com/resource/en/reference_manual/dm00031020.pdf
  6. GNU Binutils. “ld: The GNU Linker — Command-Line Options.” GNU Documentation. https://sourceware.org/binutils/docs/ld/Options.html
  7. LLVM Project. “Link Time Optimization: Design and Implementation.” LLVM Documentation. https://llvm.org/docs/LinkTimeOptimization.html
  8. Microsoft. “/OPT (Optimizations).” MSVC Documentation. https://learn.microsoft.com/en-us/cpp/build/reference/opt-optimizations?view=msvc-170

Frequently Asked Questions

What is dead code and why does it matter in embedded firmware?

Dead code refers to functions, variables, or code sections that are never executed but still consume valuable flash memory in embedded systems. In resource-constrained devices, every byte matters, and eliminating dead code can significantly reduce firmware size without affecting functionality.

How does CMake enable dead code elimination during linking?

CMake configures the linker to use --gc-sections (GCC/Clang) or /OPT:REF (MSVC) which removes unused sections. Combined with -ffunction-sections and -fdata-sections compiler flags, this allows the linker to identify and discard unused code and data at the section level.

What compiler and linker flags are needed for dead code elimination?

For GCC/Clang, compile with -ffunction-sections -fdata-sections and link with -Wl,--gc-sections. For MSVC, compile with /Gy (function-level linking) and /Gw (global data optimization), and link with /OPT:REF. These flags work in tandem to place symbols in isolated sections and discard unreachable sections.

How much size reduction can typically be achieved?

Size reduction varies by project but typically ranges from 20-50% for firmware with unused library code, HAL drivers, or debug features. Projects using large peripheral libraries often see the biggest gains as many peripheral drivers remain unused in a given application.

Tags

cmakedead-code-eliminationfirmware-sizelinker-optimizationembedded-c

Share


Previous Article
Reduce FreeRTOS Tick Rate to Extend Battery Life
Jithin Tom

Jithin Tom

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

Related Posts

Effective Embedded Firmware Code Review Checklist
Effective Embedded Firmware Code Review Checklist
September 03, 2026
9 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media