feat(build): add options to enable link-time optimization (LTO)

Link-time optimization (LTO) lets the compiler inline and optimize across
translation units. ESP-IDF relies heavily on linker-script placement rules
that match object files by name, which LTO does not preserve, so LTO cannot
be enabled for the whole framework. This change adds two opt-in options that
side-step that conflict:

- CONFIG_COMPILER_LTO_LINKTIME tells the linker to perform LTO on any object
  files that carry LTO information (compiled with -flto). On its own this is
  safe: users can add -flto to specific components (e.g. their own libraries)
  to shrink them, without affecting components that use linker fragments.

- CONFIG_COMPILER_LTO_COMPILETIME automatically compiles most
  components with -flto. A component is excluded when it has its own linker
  fragments, when it opts out via the NO_LTO component property, or when its
  object code is placed by *another* component's linker fragment (matched by
  archive name). The last case is handled by tools/cmake/lto.cmake, which
  scans linker fragments for explicit "archive: libNAME.a" placement and
  excludes those components. Without it, functions that must run from IRAM
  while the flash cache is disabled (e.g. the spi_flash / GDMA HAL routines,
  placed in IRAM by spi_flash/esp_driver_dma fragments) would be moved to
  flash by LTO and the device would panic with a cache error at run time.

Both options are disabled for the bootloader and ESP-TEE builds, which depend
on object-file-name based placement. LTO is also gated off for Clang
(needs LLD, IDF-8286) and host builds.

LTO works together with CONFIG_APP_REPRODUCIBLE_BUILD, but needs extra
flags: LTO defers code generation and most debug-info emission from compile
time to link time, where the reproducible-build path remapping (applied to
compile_options only) does not take effect. When both options are enabled,
three extra flags keep the .elf, .bin and .map byte-identical across build
directories (verified on esp32 / GCC 16.1):

- the -f*-prefix-map options are passed to the linker as well, so the LTO
  code generator remaps DW_AT_comp_dir (otherwise the build dir leaks into
  .debug_str, and cascades into esp_app_desc_t.app_elf_sha256 in the .bin);
- -save-temps makes lto-wrapper use stable LTRANS object names in the build
  dir instead of random $TMPDIR paths that leak into the .map;
- -frandom-seed=1 makes LTO GIMPLE bytecode objects byte-identical (a
  shared seed was verified not to collide, including for C++ file-local
  static variables and anonymous namespaces promoted by LTO).

The gcc-ar / gcc-ranlib wrappers are selected in the GCC toolchain file so
that the LTO plugin is loaded when creating and indexing static archives;
plain ar/ranlib do not record LTO symbols in the archive index.

Note: LTO, like other inlining, can also increase binary size. Enable it
together with CONFIG_COMPILER_OPTIMIZATION_SIZE to get a code-size benefit.

Related: IDF-71, IDF-8286

Closes https://github.com/espressif/esp-idf/issues/18741

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ivan Grokhotkov (bot)
2026-07-14 18:59:46 +02:00
co-authored by Claude Opus 4.8
parent 8bf9c476cf
commit d20a986999
4 changed files with 134 additions and 1 deletions
+37 -1
View File
@@ -291,7 +291,22 @@ if(CONFIG_ESP_SYSTEM_USE_FRAME_POINTER)
endif() endif()
endif() endif()
list(APPEND link_options "-fno-lto") if(CONFIG_COMPILER_LTO_LINKTIME AND NOT BOOTLOADER_BUILD AND NOT ESP_TEE_BUILD)
list(APPEND link_options "-flto=auto")
if(CONFIG_APP_REPRODUCIBLE_BUILD)
# LTO generates code at link time, where the path remapping applied to
# compile_options doesn't take effect, so pass it to the linker as well.
# -save-temps keeps LTRANS objects out of $TMPDIR, and a pinned random
# seed makes LTO bytecode byte-identical. See the commit message for
# details.
list(APPEND link_options ${prefix_map_compile_options})
list(APPEND link_options "-save-temps")
list(APPEND compile_options "-frandom-seed=1")
endif()
else()
list(APPEND compile_options "-fno-lto")
endif()
if(CONFIG_IDF_TARGET_LINUX AND CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") if(CONFIG_IDF_TARGET_LINUX AND CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin")
# Not all versions of the MacOS linker support the -warn_commons flag. # Not all versions of the MacOS linker support the -warn_commons flag.
@@ -383,6 +398,27 @@ foreach(component_target ${build_component_targets})
set(__idf_component_context 0) set(__idf_component_context 0)
endforeach() endforeach()
if(CONFIG_COMPILER_LTO_COMPILETIME AND NOT BOOTLOADER_BUILD AND NOT ESP_TEE_BUILD)
include("${CMAKE_CURRENT_LIST_DIR}/tools/cmake/lto.cmake")
idf_build_get_property(build_components BUILD_COMPONENTS)
# Components whose object code is placed by a linker fragment (their own or
# another component's) must not be compiled with LTO, otherwise the placement
# stops matching. See tools/cmake/lto.cmake for details.
__lto_collect_fragment_placed_components(lto_placed_components ${build_components})
# For each component, enable LTO unless it has its own linker fragments, is
# placed by some fragment, has opted out via NO_LTO, or is not a static library.
foreach(component_name ${build_components})
idf_component_get_property(ldfragment ${component_name} LDFRAGMENTS)
idf_component_get_property(no_lto ${component_name} NO_LTO)
idf_component_get_property(component_lib ${component_name} COMPONENT_LIB)
get_target_property(type ${component_lib} TYPE)
if(NOT ldfragment AND NOT no_lto AND NOT component_name IN_LIST lto_placed_components
AND type STREQUAL "STATIC_LIBRARY")
target_compile_options(${component_lib} PRIVATE -flto=auto)
endif()
endforeach()
endif()
# Run component validation checks after all components have been processed # Run component validation checks after all components have been processed
# Only run validation for the main project, not subprojects like bootloader # Only run validation for the main project, not subprojects like bootloader
idf_build_get_property(bootloader_build BOOTLOADER_BUILD) idf_build_get_property(bootloader_build BOOTLOADER_BUILD)
+41
View File
@@ -787,6 +787,47 @@ mainmenu "Espressif IoT Development Framework Configuration"
default "gcc" if COMPILER_RT_LIB_GCCLIB default "gcc" if COMPILER_RT_LIB_GCCLIB
default "" if COMPILER_RT_LIB_HOST default "" if COMPILER_RT_LIB_HOST
config COMPILER_LTO_LINKTIME
bool "Enable LTO at link time"
default n
# LTO for host builds is probably not needed at all
# LTO with Clang needs LLD (IDF-8286)
# LTO works with APP_REPRODUCIBLE_BUILD (see top-level CMakeLists.txt)
depends on !IDF_TARGET_LINUX && !IDF_TOOLCHAIN_CLANG
help
If enabled, ESP-IDF build system will tell the linker to use LTO
(Link Time Optimization). This option may increase linking time but
will also produce smaller binaries.
Note that to see some code size reduction, you should also enable LTO
at compile time for some components.
You can do this either selectively for specific components, or use
the option CONFIG_COMPILER_LTO_COMPILETIME below to enable
it for most of the components by default.
Note that LTO can also increase task stack usage, because inlining
across translation units produces fewer but larger stack frames. After
enabling LTO, check the stack high water mark of your tasks and raise the
stack sizes of the affected tasks if necessary.
config COMPILER_LTO_COMPILETIME
bool "Enable LTO by default at compile time"
depends on COMPILER_LTO_LINKTIME
default n
help
If enabled, ESP-IDF build system will compile most of the components with
LTO (link time optimizations) enabled.
Components which have link-time sections placement requirements will not
have LTO enabled. This includes components which specify linker fragment
files, as well as components whose object code is placed by a linker
fragment of another component.
Note that enabling LTO for all components does not always reduce the
binary size, and in some cases can increase it. For a more predictable
result, consider enabling LTO selectively for specific components by
adding the -flto=auto compile option to them.
choice COMPILER_ORPHAN_SECTIONS choice COMPILER_ORPHAN_SECTIONS
prompt "Orphan sections handling" prompt "Orphan sections handling"
default COMPILER_ORPHAN_SECTIONS_ERROR default COMPILER_ORPHAN_SECTIONS_ERROR
+50
View File
@@ -0,0 +1,50 @@
# Helpers for link-time optimization (LTO) support.
#
# These helpers are shared between the legacy build system (tools/cmake) and the
# build system v2 (tools/cmakev2). They only rely on the component property API
# (idf_component_get_property) and the COMPONENT_DIR / LDFRAGMENTS component
# properties, which are available in both build systems.
include_guard(GLOBAL)
# __lto_collect_fragment_placed_components(<output_var> <component_name>...)
#
# Returns, in output_var, the subset of the given components whose object code is
# placed by an explicit "archive: libNAME.a" directive in some linker fragment
# (.lf) file - whether that fragment belongs to the component itself or to a
# different component.
#
# Such components rely on section placement that is matched by object file /
# archive name. LTO merges and renames the object files of a component, so the
# placement rules silently stop matching and the affected functions (for example
# the spi_flash / GDMA HAL routines that must execute from IRAM while the flash
# cache is disabled) end up in flash. Compiling these components without LTO keeps
# their placement intact.
#
# Wildcard directives ("archive: *") are intentionally ignored: they match by
# section name rather than by object file name and are therefore not affected by
# LTO.
function(__lto_collect_fragment_placed_components output_var)
set(placed "")
foreach(component_name ${ARGN})
idf_component_get_property(component_dir ${component_name} COMPONENT_DIR)
idf_component_get_property(ldfragments ${component_name} LDFRAGMENTS)
foreach(ldfragment ${ldfragments})
get_filename_component(ldfragment_abs "${ldfragment}" ABSOLUTE BASE_DIR "${component_dir}")
if(EXISTS "${ldfragment_abs}")
file(READ "${ldfragment_abs}" ldfragment_contents)
string(REGEX MATCHALL "archive:[ \t]*lib[A-Za-z0-9_-]+\\.a"
archive_matches "${ldfragment_contents}")
foreach(archive_match ${archive_matches})
string(REGEX REPLACE "archive:[ \t]*lib([A-Za-z0-9_-]+)\\.a" "\\1"
archive_name "${archive_match}")
list(APPEND placed ${archive_name})
endforeach()
endif()
endforeach()
endforeach()
if(placed)
list(REMOVE_DUPLICATES placed)
endif()
set(${output_var} "${placed}" PARENT_SCOPE)
endfunction()
+6
View File
@@ -43,6 +43,12 @@ else()
set(CMAKE_C_COMPILER ${_CMAKE_TOOLCHAIN_PREFIX}gcc) set(CMAKE_C_COMPILER ${_CMAKE_TOOLCHAIN_PREFIX}gcc)
set(CMAKE_CXX_COMPILER ${_CMAKE_TOOLCHAIN_PREFIX}g++) set(CMAKE_CXX_COMPILER ${_CMAKE_TOOLCHAIN_PREFIX}g++)
set(CMAKE_ASM_COMPILER ${_CMAKE_TOOLCHAIN_PREFIX}gcc) set(CMAKE_ASM_COMPILER ${_CMAKE_TOOLCHAIN_PREFIX}gcc)
# Use the gcc-ar/gcc-ranlib wrappers so that the LTO plugin is loaded when
# creating and indexing static archives. This is required for link-time
# optimization (CONFIG_COMPILER_LTO_LINKTIME) to work across static libraries;
# plain ar/ranlib do not record the LTO symbols in the archive index.
set(CMAKE_AR ${_CMAKE_TOOLCHAIN_PREFIX}gcc-ar)
set(CMAKE_RANLIB ${_CMAKE_TOOLCHAIN_PREFIX}gcc-ranlib)
endif() endif()
# Handle different execution contexts for the toolchain file. # Handle different execution contexts for the toolchain file.