Merge branch 'feat/ulp_custom_linker_support' into 'master'

ULP: Support custom LP Core linker layouts via the LINKER option

Closes IDF-12902

See merge request espressif/esp-idf!49991
This commit is contained in:
Sudeep Mohanty
2026-08-05 10:10:32 +02:00
46 changed files with 1501 additions and 125 deletions
+10
View File
@@ -320,6 +320,16 @@ elseif(ULP_TYPE STREQUAL "lp_core")
# via FLAGS.
idf_component_get_property(esp_system_dir esp_system COMPONENT_DIR)
set(lp_core_ld_flags "${ulp_ld_flags} -I\"${esp_system_dir}/ld\" -I\"${esp_system_dir}/ld/${target}\"")
# A user-supplied LINKER layout replaces the default one composed into
# lp_core_riscv.ld.in. It reaches this child project as LP_CORE_LINKER_SCRIPT
# (passed by __setup_ulp_project, same as the CMake v1 path). Hand it to the
# wrapper as the LP_CORE_LINKER_INCLUDE macro it #includes. The escaped quotes
# survive separate_arguments() in the linker-script preprocessor so the macro
# expands to a quoted header-name token ("/abs/path.ld").
if(LP_CORE_LINKER_SCRIPT)
message(STATUS "Using custom LP-core linker layout: ${LP_CORE_LINKER_SCRIPT}")
string(APPEND lp_core_ld_flags " -DLP_CORE_LINKER_INCLUDE=\\\"${LP_CORE_LINKER_SCRIPT}\\\"")
endif()
target_linker_script(${COMPONENT_LIB} INTERFACE "ld/lp_core_riscv.ld.in" MEMORY
FLAGS "${lp_core_ld_flags}")
+33 -5
View File
@@ -32,9 +32,14 @@ function(__ulp_create_arg_file arguments output_file)
endfunction()
function(__ulp_add_preprocessed_linker_script ulp_app_name ld_template ld_script ld_script_target)
# Use the C preprocessor so sdkconfig and SoC constants can shape the
# linker template before the ULP executable is linked.
set(preprocessor_args -D__ASSEMBLER__ -E -P -xc -o ${ld_script} ${ARGN} ${ld_template})
# Use the C preprocessor so sdkconfig and SoC constants can shape the linker
# template before the ULP executable is linked. -MD -MF -MT records every file
# the preprocessor reads (sdkconfig.h, SoC headers, the base/layout/checks
# parts and a custom LINKER_LAYOUT with its own includes) into a depfile, so
# the script regenerates when any of them changes.
set(ld_depfile ${CMAKE_CURRENT_BINARY_DIR}/${ld_script}.d)
set(preprocessor_args -D__ASSEMBLER__ -E -P -xc -MD -MF ${ld_depfile} -MT ${ld_script}
-o ${ld_script} ${ARGN} ${ld_template})
set(compiler_arguments_file ${CMAKE_CURRENT_BINARY_DIR}/${ld_script}_args.txt)
__ulp_create_arg_file("${preprocessor_args}" "${compiler_arguments_file}")
@@ -42,13 +47,21 @@ function(__ulp_add_preprocessed_linker_script ulp_app_name ld_template ld_script
COMMAND ${CMAKE_C_COMPILER} @${compiler_arguments_file}
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
MAIN_DEPENDENCY ${ld_template}
DEPENDS ${SDKCONFIG_HEADER}
# The response file is a dependency too: a change visible only
# in the preprocessor flags (e.g. a different LINKER layout
# path) must regenerate the script even though no file recorded
# in the depfile changed. __ulp_create_arg_file rewrites it
# only when its content actually changes, so this does not
# retrigger on every reconfigure.
DEPENDS ${compiler_arguments_file}
DEPFILE ${ld_depfile}
COMMENT "Generating ${ld_script} linker script..."
VERBATIM)
add_custom_target(${ld_script_target} DEPENDS ${ld_script})
add_dependencies(${ulp_app_name} ${ld_script_target})
target_link_options(${ulp_app_name} PRIVATE SHELL:-T ${CMAKE_CURRENT_BINARY_DIR}/${ld_script})
set_property(TARGET ${ulp_app_name} APPEND PROPERTY LINK_DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/${ld_script})
endfunction()
function(ulp_apply_default_options ulp_app_name)
@@ -85,7 +98,10 @@ function(ulp_apply_default_sources ulp_app_name)
target_include_directories(${ulp_app_name} PRIVATE ${COMPONENT_INCLUDES} ${sdkconfig_dir})
# Pre-process the linker script
# Pre-process the linker script. The LP-core script is assembled from
# base + layout + checks. A user-supplied LINKER_LAYOUT (LP_CORE_LINKER_SCRIPT)
# replaces the layout, swapped in by the wrapper. This is supported for the
# LP-core type only.
if(BUILD_RISCV)
set(ULP_LD_TEMPLATE ${IDF_PATH}/components/ulp/ld/ulp_riscv.ld.in)
elseif(BUILD_LP_CORE)
@@ -96,6 +112,18 @@ function(ulp_apply_default_sources ulp_app_name)
message(FATAL_ERROR "Unable to determine ULP type. ")
endif()
if(LP_CORE_LINKER_SCRIPT)
# The LINKER_LAYOUT-is-LP-core-only check is enforced once in
# ulp_embed_binary/ulp_add_project, so LP_CORE_LINKER_SCRIPT only ever
# reaches an LP-core child.
message(STATUS "Using custom LP-core linker layout: ${LP_CORE_LINKER_SCRIPT}")
# The inner escaped quotes are required: the wrapper does
# `#include LP_CORE_LINKER_INCLUDE`, so the macro must expand to a quoted
# header-name token ("/abs/path.ld"). __ulp_create_arg_file() additionally
# escapes spaces in the path, keeping it a single preprocessor token.
list(APPEND ULP_PREPRO_ARGS "-DLP_CORE_LINKER_INCLUDE=\\\"${LP_CORE_LINKER_SCRIPT}\\\"")
endif()
# Strip the .in suffix so the generated script keeps its .ld name.
get_filename_component(ULP_LD_SCRIPT ${ULP_LD_TEMPLATE} NAME_WLE)
__ulp_add_preprocessed_linker_script(${ulp_app_name} ${ULP_LD_TEMPLATE} ${ULP_LD_SCRIPT}
+8 -112
View File
@@ -1,120 +1,16 @@
/*
* SPDX-FileCopyrightText: 2023-2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*
* LP-core RISC-V linker script, assembled from base + layout + checks.
* A custom layout replaces the default via LP_CORE_LINKER_INCLUDE.
*/
#include "sdkconfig.h"
#include "soc/soc.h"
#include "ld.common"
#if CONFIG_ULP_COPROC_RUN_FROM_HP_MEM
#include "ld.hp_mem_defs"
#endif
#include "lp_core_riscv_base.ld.in"
#if CONFIG_ESP_ROM_HAS_LP_ROM
/* With LP-ROM memory layout is different due to LP ROM stack/data */
/* For P4 ECO5 we also reserve some RTC MEM at the first for MSPI workaround */
#define ULP_MEM_START_ADDRESS SOC_RTC_DRAM_LOW + RESERVE_RTC_MEM + MSPI_WORKAROUND_SIZE
#ifdef LP_CORE_LINKER_INCLUDE
#include LP_CORE_LINKER_INCLUDE
#else
#define ULP_MEM_START_ADDRESS (SOC_RTC_DRAM_LOW)
#include "lp_core_riscv_default_layout.ld.in"
#endif
#define ALIGN_DOWN(SIZE, AL) (SIZE & ~(AL - 1))
/* Ensure the end where the shared memory starts is aligned to 8 bytes
if updating this also update the same in ulp_lp_core_memory_shared.c
*/
#define ALIGNED_COPROC_MEM ALIGN_DOWN(CONFIG_ULP_COPROC_RESERVE_MEM, 0x8)
ENTRY(reset_vector)
MEMORY
{
/* First 128 bytes for exception/interrupt vectors */
vector_table(RX) : ORIGIN = ULP_MEM_START_ADDRESS, LENGTH = 0x80
lp_ram(RWX) : ORIGIN = ULP_MEM_START_ADDRESS + 0x80, LENGTH = ALIGNED_COPROC_MEM - 0x80 - CONFIG_ULP_SHARED_MEM
shared_mem_ram(RW) : ORIGIN = ULP_MEM_START_ADDRESS + ALIGNED_COPROC_MEM - CONFIG_ULP_SHARED_MEM, LENGTH = CONFIG_ULP_SHARED_MEM
#if CONFIG_ULP_COPROC_RUN_FROM_HP_MEM
hp_ram(RWX) : ORIGIN = ULP_HP_MEM_START, LENGTH = ULP_HP_MEM_SIZE
#endif
}
#if CONFIG_ULP_COPROC_RUN_FROM_HP_MEM
REGION_ALIAS("default_app_seg", hp_ram);
#else
REGION_ALIAS("default_app_seg", lp_ram);
#endif
SECTIONS
{
.vector.text :
{
/* Exception/interrupt vectors */
__mtvec_base = .;
KEEP (*(.init.vector .init.vector.*))
} > vector_table
. = ORIGIN(lp_ram);
/* Interrupt/exception handlers stay in LP RAM (reachable on wakeup before HP SRAM is up). */
.rtc_text ALIGN(4):
{
_lp_text_start = .;
*(.text.vectors) /* Default reset vector must link to offset 0x80 */
*(.text.handlers)
*(.text.handlers.*)
} > lp_ram
#if CONFIG_ULP_COPROC_RUN_FROM_HP_MEM
/* End of LP-resident code (handlers only); align to 128 bytes. */
. = ALIGN(128);
_lp_text_end = .;
. = ORIGIN(hp_ram);
#endif
.text ALIGN(4):
{
*(.text)
*(.text*)
} > default_app_seg
.rodata ALIGN(4):
{
*(.rodata)
*(.rodata*)
} > default_app_seg
/* 128-byte alignment required for PMP TOR granularity (SOC_CPU_PMP_REGION_GRANULARITY) */
. = ALIGN(128);
#if !CONFIG_ULP_COPROC_RUN_FROM_HP_MEM
_lp_text_end = .;
#endif
_lp_data_start = .;
.data ALIGN(4):
{
_data_start = .;
*(.data)
*(.data*)
*(.sdata)
*(.sdata*)
_data_end = .;
} > default_app_seg
.bss ALIGN(4) :
{
_bss_start = .;
*(.bss)
*(.bss*)
*(.sbss)
*(.sbss*)
PROVIDE(end = .);
_bss_end = .;
} > default_app_seg
__stack_top = ORIGIN(lp_ram) + LENGTH(lp_ram);
. = ORIGIN(shared_mem_ram);
.shared_mem (ALIGN(4)) :
{
KEEP(*(.shared_mem))
} > shared_mem_ram
}
#include "lp_core_riscv_checks.ld.in"
+120
View File
@@ -0,0 +1,120 @@
/*
* SPDX-FileCopyrightText: 2023-2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* Base part of the LP-core linker script (base + layout + checks). It defines:
* - the usable LP-RAM window bounds LP_CORE_USER_MEMORY_REGION_START and
* LP_CORE_USER_MEMORY_REGION_END;
* - ENTRY, the interrupt vector table, the HP/LP shared-memory section and a
* default __stack_top;
* - the section-boundary macros a custom layout composes: LP_CORE_TEXT_START,
* LP_CORE_TEXT_END, LP_CORE_DATA_START and LP_CORE_DATA_END, plus the
* LP_CORE_DEFAULT_TEXT / LP_CORE_DEFAULT_DATA convenience macros used by the
* default layout.
*/
#include "sdkconfig.h"
#include "soc/soc.h"
#include "ld.common"
#if CONFIG_ULP_COPROC_RUN_FROM_HP_MEM
#include "ld.hp_mem_defs"
#endif
#if CONFIG_ESP_ROM_HAS_LP_ROM
/* With LP-ROM memory layout is different due to LP ROM stack/data */
/* For P4 ECO5 we also reserve some RTC MEM at the first for MSPI workaround */
#define ULP_MEM_START_ADDRESS SOC_RTC_DRAM_LOW + RESERVE_RTC_MEM + MSPI_WORKAROUND_SIZE
#else
#define ULP_MEM_START_ADDRESS (SOC_RTC_DRAM_LOW)
#endif
#define ALIGN_DOWN(SIZE, AL) (SIZE & ~(AL - 1))
/* Ensure the end where the shared memory starts is aligned to 8 bytes
if updating this also update the same in ulp_lp_core_memory_shared.c
*/
#define ALIGNED_COPROC_MEM ALIGN_DOWN(CONFIG_ULP_COPROC_RESERVE_MEM, 0x8)
#define ULP_VECTOR_TABLE_LENGTH 0x80
/* Free LP-RAM window a layout may use, between the vector table and shared memory. */
#define LP_CORE_USER_MEMORY_REGION_START (ULP_MEM_START_ADDRESS + ULP_VECTOR_TABLE_LENGTH)
#define LP_CORE_USER_MEMORY_REGION_END (ULP_MEM_START_ADDRESS + ALIGNED_COPROC_MEM - CONFIG_ULP_SHARED_MEM)
/*
* Section-boundary macros a custom layout composes at each region boundary.
* ESP-IDF injects any config-dependent handling here, so the layout composes
* them the same way regardless of the active configuration.
*/
/* Reset vector and early handlers at the boot offset, placed first in `region`
(which must start at LP_CORE_USER_MEMORY_REGION_START). */
#define LP_CORE_TEXT_START(region) \
. = ORIGIN(region) ; \
.rtc_text ALIGN(4) : { \
*(.text.vectors) /* Reset vector must link to offset 0x80 */ \
*(.text.handlers) \
*(.text.handlers.*) \
} > region
/* End of the executable region in LP RAM: the RX/RW boundary the PMP setup uses
under memory protection. (Memory protection and running from HP RAM are
mutually exclusive, so the boundary always follows the LP-RAM .text here.) */
#if CONFIG_ULP_LP_CORE_MEMPROT
#define LP_CORE_TEXT_END() . = ALIGN(128) ; _lp_text_end = . ;
#else
#define LP_CORE_TEXT_END()
#endif
/* Start of the writable data region. */
#define LP_CORE_DATA_START() . = ALIGN(128) ; _lp_data_start = . ;
/* End of the writable data region, before the stack. Marks the lowest address
the stack may descend to: the framework derives the default __stack_size from
it and bounds-checks the stack against it. A layout must compose this (or
assign _lp_data_end directly). */
#define LP_CORE_DATA_END() . = ALIGN(4) ; _lp_data_end = . ;
/* Convenience: standard executable and data section placement into `region`. */
#define LP_CORE_DEFAULT_TEXT(region) \
.text ALIGN(4) : { *(.text) *(.text*) } > region \
.rodata ALIGN(4) : { *(.rodata) *(.rodata*) } > region
#define LP_CORE_DEFAULT_DATA(region) \
.data ALIGN(4) : { _data_start = . ; *(.data) *(.data*) *(.sdata) *(.sdata*) _data_end = . ; } > region \
.bss ALIGN(4) : { _bss_start = . ; *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .) ; _bss_end = . ; } > region
/* Default stack top, at the top of the usable window. A layout whose data does
not reach the window end may override it to the top of its own data region.
The companion __stack_size defaults to __stack_top - _lp_data_end (computed in
the checks part, once the layout has placed its data); a layout may override
either. Both are weak defaults, so a custom layout can take ownership. */
PROVIDE(__stack_top = LP_CORE_USER_MEMORY_REGION_END);
ENTRY(reset_vector)
MEMORY
{
/* First 128 bytes for exception/interrupt vectors */
vector_table(RX) : ORIGIN = ULP_MEM_START_ADDRESS, LENGTH = ULP_VECTOR_TABLE_LENGTH
shared_mem_ram(RW) : ORIGIN = LP_CORE_USER_MEMORY_REGION_END, LENGTH = CONFIG_ULP_SHARED_MEM
#if CONFIG_ULP_COPROC_RUN_FROM_HP_MEM
hp_ram(RWX) : ORIGIN = ULP_HP_MEM_START, LENGTH = ULP_HP_MEM_SIZE
#endif
}
SECTIONS
{
.vector.text :
{
/* Exception/interrupt vectors */
__mtvec_base = .;
KEEP (*(.init.vector .init.vector.*))
} > vector_table
/* Shared memory, agreed with ulp_lp_core_memory_shared.c. */
. = ORIGIN(shared_mem_ram);
_lp_shared_start = ORIGIN(shared_mem_ram);
.shared_mem (ALIGN(4)) : { KEEP(*(.shared_mem)) } > shared_mem_ram
/* Position the location counter at the window start so the layout part's
sections flow into their region. A layout that omits LP_CORE_TEXT_START
is then caught by the checks part instead of an obscure overflow. */
. = LP_CORE_USER_MEMORY_REGION_START;
}
@@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2023-2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* Checks part (base + layout + checks). Link-time ASSERTs that catch
* misplacement, overflow, and missing symbols in the layout part.
*/
ASSERT(DEFINED(reset_vector),
"LP core linker: reset_vector is not defined")
ASSERT(__mtvec_base == ULP_MEM_START_ADDRESS,
"LP core linker: vector table is not at the reserved LP base")
ASSERT(reset_vector == LP_CORE_USER_MEMORY_REGION_START,
"LP core linker: reset vector is not at the required offset")
/* The layout must mark the end of writable data so the framework can size and
bounds-check the stack. */
ASSERT(DEFINED(_lp_data_end),
"LP core linker: end of writable data is not marked; compose LP_CORE_DATA_END() (or assign _lp_data_end) in the layout")
/* Default stack size: all free space between the data end and the stack top.
A layout that assigns __stack_size wins (PROVIDE yields and does not evaluate
the expression). */
PROVIDE(__stack_size = __stack_top - _lp_data_end);
ASSERT(__stack_top <= _lp_shared_start,
"LP core linker: application overruns into the reserved shared-memory region")
ASSERT(__stack_top <= ULP_MEM_START_ADDRESS + ALIGNED_COPROC_MEM,
"LP core linker: image exceeds the reserved LP coprocessor memory")
/* The stack grows down from __stack_top for __stack_size bytes; its lowest
address must not descend into the data region. */
ASSERT(__stack_top - __stack_size >= _lp_data_end,
"LP core linker: stack region overlaps the data region (__stack_size too large or __stack_top too low)")
#if CONFIG_ULP_LP_CORE_MEMPROT
ASSERT(DEFINED(_lp_text_end),
"LP core linker: _lp_text_end must be defined when CONFIG_ULP_LP_CORE_MEMPROT is enabled (it marks the RX/RW boundary used by the PMP setup)")
#endif
@@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2023-2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* Default layout part (base + layout + checks). Replaced by a custom layout
* when LP_CORE_LINKER_INCLUDE is set via the LINKER_LAYOUT option. Routes the
* application to HP RAM when CONFIG_ULP_COPROC_RUN_FROM_HP_MEM is enabled.
*/
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START
}
#if CONFIG_ULP_COPROC_RUN_FROM_HP_MEM
REGION_ALIAS("default_app_seg", hp_ram);
#else
REGION_ALIAS("default_app_seg", lp_ram);
#endif
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
#if CONFIG_ULP_COPROC_RUN_FROM_HP_MEM
. = ORIGIN(hp_ram);
#endif
LP_CORE_DEFAULT_TEXT(default_app_seg)
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
LP_CORE_DEFAULT_DATA(default_app_seg)
LP_CORE_DATA_END()
}
+41 -7
View File
@@ -7,7 +7,7 @@ endif()
# Create ULP binary and embed into the application.
function(__setup_ulp_project app_name project_path prefix prefix_append_bin_name type binary_names
s_sources exp_dep_srcs)
s_sources exp_dep_srcs linker_script)
if(NOT CMAKE_BUILD_EARLY_EXPANSION)
set(ulp_cmake_dir "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/cmake")
@@ -189,6 +189,9 @@ function(__setup_ulp_project app_name project_path prefix prefix_append_bin_name
-DADD_PICOLIBC_SPECS=${CONFIG_LIBC_PICOLIBC}
-DULP_VAR_PREFIX=${prefix}
-DULP_TYPE=${type}
# Read by the LP-core child to swap in a custom layout;
# empty and ignored otherwise.
-DLP_CORE_LINKER_SCRIPT=${linker_script}
${ulp_project_args}
-DIDF_TARGET=${idf_target}
-DIDF_PATH=${idf_path}
@@ -276,26 +279,52 @@ function(__ulp_resolve_type out_var type)
set(${out_var} "${resolved_type}" PARENT_SCOPE)
endfunction()
# Resolve a user-supplied LINKER_LAYOUT for a ULP of the given (already resolved)
# type: reject it for non-LP-core types, since only the LP-core linker script has
# a swappable layout part, then resolve the layout to an absolute path and verify
# it exists. The result is written back to the variable named by out_var. A
# relative path is resolved against CMAKE_CURRENT_LIST_DIR, the same base used for
# relative ULP source files in __setup_ulp_project, so that sources and the linker
# layout in one call are resolved consistently. Enforcing this here, at the public
# API, gives a single check that covers every ULP child build style.
function(__resolve_lp_core_linker linker type out_var)
if(linker)
if(NOT type STREQUAL "lp_core")
message(FATAL_ERROR "A custom LINKER_LAYOUT script is only supported for the LP-core ULP type.")
endif()
get_filename_component(linker "${linker}" ABSOLUTE BASE_DIR ${CMAKE_CURRENT_LIST_DIR})
if(NOT EXISTS "${linker}")
message(FATAL_ERROR "LINKER_LAYOUT linker script not found: ${linker}")
endif()
endif()
set(${out_var} "${linker}" PARENT_SCOPE)
endfunction()
function(ulp_embed_binary app_name s_sources exp_dep_srcs)
cmake_parse_arguments(ULP "" "PREFIX;TYPE" "" ${ARGN})
cmake_parse_arguments(ULP "" "PREFIX;TYPE;LINKER_LAYOUT" "" ${ARGN})
if(NOT ULP_PREFIX)
set(ULP_PREFIX "ulp_")
endif()
validate_ulp_type("${ULP_TYPE}")
# Resolve the effective ULP type (from TYPE or the project config) so the
# LINKER_LAYOUT guard is applied uniformly; the full-subproject child also
# consumes the resolved type.
__ulp_resolve_type(ulp_resolved_type "${ULP_TYPE}")
if(IDF_BUILD_V2)
__ulp_resolve_type(ULP_TYPE "${ULP_TYPE}")
set(ULP_TYPE "${ulp_resolved_type}")
endif()
__resolve_lp_core_linker("${ULP_LINKER_LAYOUT}" "${ulp_resolved_type}" LP_CORE_LINKER)
set(ulp_cmake_dir "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/cmake")
__setup_ulp_project("${app_name}" "${ulp_cmake_dir}"
"${ULP_PREFIX}" FALSE "${ULP_TYPE}"
"${app_name}"
"${s_sources}" "${exp_dep_srcs}")
"${s_sources}" "${exp_dep_srcs}" "${LP_CORE_LINKER}")
endfunction()
function(ulp_add_project app_name project_path)
cmake_parse_arguments(ULP "" "PREFIX;TYPE" "BINARIES" ${ARGN})
cmake_parse_arguments(ULP "" "PREFIX;TYPE;LINKER_LAYOUT" "BINARIES" ${ARGN})
if(NOT ULP_BINARIES)
set(ULP_BINARIES "${app_name}")
endif()
@@ -311,10 +340,15 @@ function(ulp_add_project app_name project_path)
endif()
validate_ulp_type("${ULP_TYPE}")
# Resolve the effective ULP type (from TYPE or the project config) so the
# LINKER_LAYOUT guard is applied uniformly; the full-subproject child also
# consumes the resolved type.
__ulp_resolve_type(ulp_resolved_type "${ULP_TYPE}")
if(IDF_BUILD_V2)
__ulp_resolve_type(ULP_TYPE "${ULP_TYPE}")
set(ULP_TYPE "${ulp_resolved_type}")
endif()
__resolve_lp_core_linker("${ULP_LINKER_LAYOUT}" "${ulp_resolved_type}" LP_CORE_LINKER)
__setup_ulp_project("${app_name}" "${project_path}" "${ULP_PREFIX}"
"${ULP_PREFIX_APPEND_BIN_NAME}" "${ULP_TYPE}" "${ULP_BINARIES}" "" "")
"${ULP_PREFIX_APPEND_BIN_NAME}" "${ULP_TYPE}" "${ULP_BINARIES}" "" "" "${LP_CORE_LINKER}")
endfunction()
@@ -1,5 +1,17 @@
# Documentation: .gitlab/ci/README.md#manifest-file-to-control-the-buildtest-apps
components/ulp/test_apps/lp_core/host_tests/lp_core_custom_linker:
enable:
- if: SOC_LP_CORE_SUPPORTED == 1
depends_components:
- ulp
components/ulp/test_apps/lp_core/host_tests/lp_core_custom_linker_v2:
enable:
- if: SOC_LP_CORE_SUPPORTED == 1
depends_components:
- ulp
components/ulp/test_apps/lp_core/lp_core_basic_tests:
disable:
- if: SOC_LP_CORE_SUPPORTED != 1
@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
idf_build_set_property(MINIMAL_BUILD ON)
project(lp_core_custom_linker)
@@ -0,0 +1,10 @@
| Supported Targets | ESP32-C5 | ESP32-C6 | ESP32-P4 | ESP32-S31 |
| ----------------- | -------- | -------- | -------- | --------- |
# LP Core Custom Linker Host Tests
Build-only host tests for the ULP custom linker feature. No target board is required: every check is resolved at configure or link time on the build host.
`test_lp_core_custom_linker.py` builds this app with different layouts supplied via the `LINKER_LAYOUT` option and asserts on the outcome (both the positive paths and the layouts that must fail the build).
Being a build-time test, it drives `idf.py` itself and is run as plain pytest by the dedicated `test_lp_core_custom_linker_on_host` host-test job, outside the idf-ci flow (see `exclude_dirs` in `.idf_ci.toml`). Run it locally with `pytest_for_ut .` from this directory, or directly with `pytest -p no:idf-ci -p no:pytest_embedded .`.
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
idf_component_register(SRCS "test_main.c"
INCLUDE_DIRS "."
REQUIRES ulp)
# Pytest overrides CUSTOM_ULP_LAYOUT per test case via -DCUSTOM_ULP_LAYOUT=...
if(NOT DEFINED CUSTOM_ULP_LAYOUT)
set(CUSTOM_ULP_LAYOUT "${CMAKE_CURRENT_LIST_DIR}/ulp/layouts/good.ld")
endif()
set(ulp_app_name ulp_${COMPONENT_NAME})
ulp_embed_binary(${ulp_app_name} "ulp/main.c" "test_main.c"
LINKER_LAYOUT "${CUSTOM_ULP_LAYOUT}")
@@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include <stdint.h>
typedef struct {
volatile uint32_t marker;
volatile uint32_t lp_heartbeat;
} custom_linker_shared_t;
@@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/* Stub app_main — build-only test. The pytest harness checks the ULP ELF. */
void app_main(void)
{
}
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* NEGATIVE: omits LP_CORE_DATA_END() and never assigns _lp_data_end, so the
* framework cannot size or bounds-check the stack. The _lp_data_end contract
* ASSERT must fire.
*/
PROVIDE(_custom_linker_marker = 0xCAFEBABE);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START
}
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
.text ALIGN(4): { *(.text) *(.text*) } > lp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > lp_ram
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > lp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > lp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > lp_ram
/* Intentionally missing: LP_CORE_DATA_END() (marks _lp_data_end) */
}
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* NEGATIVE: omits LP_CORE_TEXT_START entirely (it places the reset vector).
* reset_vector then lands away from the boot offset and the position ASSERT
* must fire.
*/
PROVIDE(_custom_linker_marker = 0xCAFEBABE);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START
}
SECTIONS
{
/* Intentionally missing: LP_CORE_TEXT_START(lp_ram) (places the reset vector) */
.text ALIGN(4): { *(.text) *(.text*) } > lp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > lp_ram
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > lp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > lp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > lp_ram
LP_CORE_DATA_END()
}
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* NEGATIVE: omits LP_CORE_TEXT_END(). Built with CONFIG_ULP_LP_CORE_MEMPROT=y,
* where lp_core_pmp.c needs the RX/RW boundary for the PMP split.
* Must fail with the MEMPROT ASSERT.
*/
PROVIDE(_custom_linker_marker = 0xCAFEBABE);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START
}
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
.text ALIGN(4): { *(.text) *(.text*) } > lp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > lp_ram
/* Intentionally missing: LP_CORE_TEXT_END() */
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > lp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > lp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > lp_ram
LP_CORE_DATA_END()
}
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* NEGATIVE: the region starts at LP_CORE_USER_MEMORY_REGION_START + 0x180
* instead of LP_CORE_USER_MEMORY_REGION_START, so LP_CORE_TEXT_START places the
* reset vector at the wrong address and the reset-vector position ASSERT fires.
*/
PROVIDE(_custom_linker_marker = 0xCAFEBABE);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START + 0x180, LENGTH = LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START - 0x180
}
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
.text ALIGN(4): { *(.text) *(.text*) } > lp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > lp_ram
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > lp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > lp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > lp_ram
LP_CORE_DATA_END()
}
@@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* NEGATIVE: lp_ram LENGTH == ALIGNED_COPROC_MEM (too large). The __stack_top
* override lands past the reserved coprocessor memory, so the sizing ASSERT
* must fire.
*/
PROVIDE(_custom_linker_marker = 0xCAFEBABE);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = ALIGNED_COPROC_MEM
}
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
.text ALIGN(4): { *(.text) *(.text*) } > lp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > lp_ram
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > lp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > lp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > lp_ram
LP_CORE_DATA_END()
__stack_top = ORIGIN(lp_ram) + LENGTH(lp_ram);
}
@@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* NEGATIVE: lp_ram extends to the end of the coprocessor reservation (no
* shared-mem gap), so the __stack_top override exceeds _lp_shared_start. The
* image still fits the absolute reservation, but the shared-overrun ASSERT
* must fire.
*/
PROVIDE(_custom_linker_marker = 0xCAFEBABE);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = ALIGNED_COPROC_MEM - ULP_VECTOR_TABLE_LENGTH
}
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
.text ALIGN(4): { *(.text) *(.text*) } > lp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > lp_ram
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > lp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > lp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > lp_ram
LP_CORE_DATA_END()
__stack_top = ORIGIN(lp_ram) + LENGTH(lp_ram);
}
@@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* NEGATIVE: overrides __stack_size to a value larger than the free space above
* the data end, so the stack's lowest address falls inside the data region.
* The bottom-bounds ASSERT must fire.
*/
PROVIDE(_custom_linker_marker = 0xCAFEBABE);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START
}
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
.text ALIGN(4): { *(.text) *(.text*) } > lp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > lp_ram
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > lp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > lp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > lp_ram
LP_CORE_DATA_END()
/* Stack larger than the whole window: bottom lands below _lp_data_end. */
__stack_size = (LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START) + 0x100;
}
@@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* POSITIVE: full-replacement layout. Composes LP_CORE_TEXT_START and the
* section-boundary macros; shared memory and __stack_top are owned by the base.
*/
PROVIDE(_custom_linker_marker = 0xCAFEBABE);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START
}
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
.text ALIGN(4): { *(.text) *(.text*) } > lp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > lp_ram
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > lp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > lp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > lp_ram
LP_CORE_DATA_END()
}
@@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* POSITIVE: custom layout under CONFIG_ULP_COPROC_RUN_FROM_HP_MEM. Keeps the
* reset vector in LP RAM and routes the application to hp_ram, which is
* declared by the base part. __stack_top defaults to the top of the window.
*/
PROVIDE(_custom_linker_marker = 0xCAFEBABE);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START
}
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
. = ORIGIN(hp_ram);
.text ALIGN(4): { *(.text) *(.text*) } > hp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > hp_ram
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > hp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > hp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > hp_ram
LP_CORE_DATA_END()
}
@@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* POSITIVE: multi-region layout. Splits the window into custom-named regions
* using only LP_CORE_USER_MEMORY_REGION_START / _END. __stack_top defaults to
* the top of the window (the data region reaches it).
*/
PROVIDE(_custom_linker_marker = 0xCAFEBABE);
_fixed_addr = LP_CORE_USER_MEMORY_REGION_START + 0x700;
MEMORY
{
code(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = _fixed_addr - LP_CORE_USER_MEMORY_REGION_START
fixed(RWX) : ORIGIN = _fixed_addr, LENGTH = 0x100
data(RW) : ORIGIN = _fixed_addr + 0x100, LENGTH = LP_CORE_USER_MEMORY_REGION_END - (_fixed_addr + 0x100)
}
SECTIONS
{
LP_CORE_TEXT_START(code)
.text ALIGN(4): { *(.text) *(.text*) } > code
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > code
LP_CORE_TEXT_END()
. = ORIGIN(fixed);
.fixed ALIGN(4): { KEEP(*(.fixed_region .fixed_region.*)) } > fixed
. = ORIGIN(data);
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > data
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > data
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > data
LP_CORE_DATA_END()
}
@@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* POSITIVE: overrides __stack_top and __stack_size to values that differ from
* the base defaults (default __stack_top is the window end, default
* __stack_size is all the free space above the data). The stack top is pulled
* 0x40 below the window end and the size is pinned to 0x200, so the test can
* confirm the layout's assignments win over the base PROVIDE() defaults.
*/
PROVIDE(_custom_linker_marker = 0xCAFEBABE);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START
}
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
.text ALIGN(4): { *(.text) *(.text*) } > lp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > lp_ram
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > lp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > lp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > lp_ram
LP_CORE_DATA_END()
/* Distinct, valid overrides: reserve 0x40 at the top of the window and cap
the stack at 0x200 instead of taking all the remaining space. */
__stack_top = LP_CORE_USER_MEMORY_REGION_END - 0x40;
__stack_size = 0x200;
}
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* POSITIVE: esp-bist-style layout that takes ownership of the stack window.
* It ends its data with a NOLOAD stack-overflow guard band, marks the data end
* with _lp_data_end, and overrides both __stack_top and __stack_size (the size
* being all the space left between the data end and the stack top). The
* framework's bottom-bounds ASSERT confirms the stack does not reach the data.
*/
PROVIDE(_custom_linker_marker = 0xCAFEBABE);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START
}
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
.text ALIGN(4): { *(.text) *(.text*) } > lp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > lp_ram
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > lp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > lp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > lp_ram
/* Stack-overflow guard band, kept out of the loadable image. */
.stack_guard (NOLOAD):
{
. = ALIGN(4);
. += 0x8;
_stack_overflow_protection_start = .;
. += 0x4;
_stack_overflow_protection_end = .;
} > lp_ram
LP_CORE_DATA_END()
/* Take ownership of the stack window: stack starts at the top of lp_ram and
may grow down over all the remaining free space above the data end. */
__stack_top = ORIGIN(lp_ram) + LENGTH(lp_ram);
__stack_size = __stack_top - _lp_data_end;
}
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* POSITIVE: same shape as good.ld but with a distinct marker value. Used by the
* incremental-switch test to tell which layout the image was linked against
* after the LINKER option is changed without wiping the build directory.
*/
PROVIDE(_custom_linker_marker = 0xD00DFEED);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START
}
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
.text ALIGN(4): { *(.text) *(.text*) } > lp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > lp_ram
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > lp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > lp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > lp_ram
LP_CORE_DATA_END()
}
@@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
#include "shared.h"
/* Defined by the custom layout via PROVIDE(); reference keeps it from --gc-sections. */
extern const uint32_t _custom_linker_marker;
custom_linker_shared_t shared_state __attribute__((used, section(".custom_state")));
int main(void)
{
shared_state.marker = (uint32_t)(uintptr_t)&_custom_linker_marker;
while (1) {
shared_state.lp_heartbeat++;
for (volatile uint32_t i = 0; i < 10000; i++) {
__asm__ volatile("nop");
}
}
return 0;
}
@@ -0,0 +1,3 @@
CONFIG_ULP_COPROC_ENABLED=y
CONFIG_ULP_COPROC_TYPE_LP_CORE=y
CONFIG_ULP_COPROC_RESERVE_MEM=4096
@@ -0,0 +1,262 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
#
# Host build tests for custom LP-core linker layouts. Each test builds this app
# with a different layout via the LINKER_LAYOUT option and checks the result:
# positive cases verify the build succeeds and the marker symbol resolves;
# negative cases verify that the expected ASSERT fires and the build fails.
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
import pytest
from esp_bool_parser import parse_bool_expr
from esp_bool_parser.constants import PREVIEW_TARGETS
from esp_bool_parser.constants import SUPPORTED_TARGETS
APP_DIR = os.path.dirname(os.path.abspath(__file__))
LAYOUTS = os.path.join(APP_DIR, 'main', 'ulp', 'layouts')
def _targets(soc_expr: str) -> list[str]:
# Evaluate a SoC-caps expression against every known target, so the set
# tracks the config as the LP core lands on new targets.
expr = parse_bool_expr(soc_expr)
return [t for t in (*SUPPORTED_TARGETS, *PREVIEW_TARGETS) if expr.get_value(t, '')]
LP_CORE_TARGETS = _targets('SOC_LP_CORE_SUPPORTED == 1')
# MEMPROT needs the LP core PMP.
LP_CORE_MEMPROT_TARGETS = _targets('SOC_LP_CORE_SUPPORTED == 1 and SOC_LP_CORE_HAS_PMP == 1')
HP_MEM_DEFAULTS = 'CONFIG_ULP_COPROC_RUN_FROM_HP_MEM=y\nCONFIG_ULP_COPROC_RESERVE_HP_MEM_BYTES=0x4000'
def _build(tag: str, custom_layout: str, target: str, extra_defaults: str | None = None) -> tuple[int, str, str]:
build_dir = os.path.join(APP_DIR, f'build_{tag}_{target}')
shutil.rmtree(build_dir, ignore_errors=True)
# --preview keeps the command uniform across released and preview targets.
# Keep sdkconfig inside the build dir so parametrized targets never share (or
# stale-contaminate) the app-dir sdkconfig.
common = [
'idf.py',
'--preview',
'-B',
build_dir,
f'-DSDKCONFIG={os.path.join(build_dir, "sdkconfig")}',
f'-DCUSTOM_ULP_LAYOUT={custom_layout}',
]
combined: str | None = None
if extra_defaults:
fd, combined = tempfile.mkstemp(suffix='.defaults')
with os.fdopen(fd, 'w') as out_f:
with open(os.path.join(APP_DIR, 'sdkconfig.defaults')) as base_f:
out_f.write(base_f.read())
out_f.write('\n' + extra_defaults + '\n')
common.append(f'-DSDKCONFIG_DEFAULTS={combined}')
try:
cfg = subprocess.run(common + ['set-target', target], cwd=APP_DIR, capture_output=True, text=True)
out = cfg.stdout + cfg.stderr
if cfg.returncode != 0:
return cfg.returncode, out, build_dir
bld = subprocess.run(common + ['build'], cwd=APP_DIR, capture_output=True, text=True)
return bld.returncode, out + bld.stdout + bld.stderr, build_dir
finally:
if combined:
os.unlink(combined)
def _rebuild(build_dir: str, custom_layout: str) -> tuple[int, str]:
# Incremental reconfigure + build of an existing build dir with a different
# LINKER_LAYOUT: no wipe, no re-run of set-target. This is the path that must
# still pick up the new layout.
r = subprocess.run(
[
'idf.py',
'-B',
build_dir,
f'-DSDKCONFIG={os.path.join(build_dir, "sdkconfig")}',
f'-DCUSTOM_ULP_LAYOUT={custom_layout}',
'build',
],
cwd=APP_DIR,
capture_output=True,
text=True,
)
return r.returncode, r.stdout + r.stderr
def _nm(build_dir: str) -> str:
# The ULP ELF is emitted at a fixed path under the build dir.
elf = os.path.join(build_dir, 'esp-idf', 'main', 'ulp_main', 'ulp_main.elf')
assert os.path.isfile(elf), 'ULP elf was not produced'
return subprocess.run(['riscv32-esp-elf-nm', elf], capture_output=True, text=True).stdout
def _symbols(build_dir: str) -> dict[str, int]:
# nm prints "<hex value> <type> <name>"; keep the defined symbols we can resolve.
syms: dict[str, int] = {}
for line in _nm(build_dir).splitlines():
parts = line.split()
if len(parts) == 3 and all(c in '0123456789abcdefABCDEF' for c in parts[0]):
syms[parts[2]] = int(parts[0], 16)
return syms
def _assert_marker(syms: dict[str, int]) -> None:
assert '_custom_linker_marker' in syms, 'custom marker symbol absent'
assert syms['_custom_linker_marker'] == 0xCAFEBABE, 'custom marker not resolved to its PROVIDE value'
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_positive_custom_layout(target: str) -> None:
rc, out, build_dir = _build('good', os.path.join(LAYOUTS, 'good.ld'), target)
assert rc == 0, f'custom layout should build:\n{out[-2000:]}'
_assert_marker(_symbols(build_dir))
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_positive_multiregion_layout(target: str) -> None:
rc, out, build_dir = _build('good_multiregion', os.path.join(LAYOUTS, 'good_multiregion.ld'), target)
assert rc == 0, f'multi-region custom layout should build:\n{out[-2000:]}'
_assert_marker(_symbols(build_dir))
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_positive_hp_mem_custom_layout(target: str) -> None:
rc, out, build_dir = _build(
'good_hp_mem', os.path.join(LAYOUTS, 'good_hp_mem.ld'), target, extra_defaults=HP_MEM_DEFAULTS
)
assert rc == 0, f'custom layout routing to HP RAM should build:\n{out[-2000:]}'
_assert_marker(_symbols(build_dir))
@pytest.mark.parametrize('target', LP_CORE_MEMPROT_TARGETS)
def test_positive_custom_layout_memprot(target: str) -> None:
rc, out, build_dir = _build(
'good_memprot', os.path.join(LAYOUTS, 'good.ld'), target, extra_defaults='CONFIG_ULP_LP_CORE_MEMPROT=y'
)
assert rc == 0, f'custom layout defining _lp_text_end should build under MEMPROT:\n{out[-2000:]}'
_assert_marker(_symbols(build_dir))
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_positive_stack_window_override(target: str) -> None:
rc, out, build_dir = _build('good_stack_window', os.path.join(LAYOUTS, 'good_stack_window.ld'), target)
assert rc == 0, f'layout overriding __stack_top/__stack_size should build:\n{out[-2000:]}'
syms = _symbols(build_dir)
_assert_marker(syms)
for sym in ('__stack_top', '__stack_size', '_lp_data_end'):
assert sym in syms, f'{sym} symbol absent from the linked image'
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_positive_stack_override_takes_effect(target: str) -> None:
# A layout that sets __stack_top/__stack_size to values distinct from the
# base defaults must win: the linked symbols resolve to the layout's values,
# proving the base PROVIDE() defaults yield.
rc, out, build_dir = _build('good_stack_override', os.path.join(LAYOUTS, 'good_stack_override.ld'), target)
assert rc == 0, f'layout with distinct stack overrides should build:\n{out[-2000:]}'
syms = _symbols(build_dir)
_assert_marker(syms)
for sym in ('__stack_top', '__stack_size', '_lp_shared_start', '_lp_data_end'):
assert sym in syms, f'{sym} symbol absent from the linked image'
window_end = syms['_lp_shared_start'] # the base default for __stack_top
# __stack_top overridden to window_end - 0x40 (not the default window end).
assert syms['__stack_top'] == window_end - 0x40, (
f'__stack_top override ignored: {syms["__stack_top"]:#x} != {window_end - 0x40:#x}'
)
assert syms['__stack_top'] != window_end, '__stack_top still at the default (override did not win)'
# __stack_size overridden to 0x200 (not the free-space default).
assert syms['__stack_size'] == 0x200, f'__stack_size override ignored: {syms["__stack_size"]:#x} != 0x200'
default_size = syms['__stack_top'] - syms['_lp_data_end']
assert syms['__stack_size'] != default_size, '__stack_size still at the default (override did not win)'
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_incremental_layout_switch_picks_up_new_layout(target: str) -> None:
# The LINKER_LAYOUT path reaches the preprocessor only as a flag inside the
# response file, and a committed layout file's mtime is normally older than
# the already-generated script. Switching the layout on an incremental build
# (no wipe) must still regenerate the linker script; otherwise the image
# silently links against the previous layout.
rc, out, build_dir = _build('switch', os.path.join(LAYOUTS, 'good.ld'), target)
assert rc == 0, f'initial build with good.ld should succeed:\n{out[-2000:]}'
assert _symbols(build_dir)['_custom_linker_marker'] == 0xCAFEBABE, 'initial layout not linked'
# Age the target layout so it is older than the script the first build just
# generated -- the mtime relationship that hides the change from the build.
switch_layout = os.path.join(LAYOUTS, 'good_switch.ld')
old = 1600000000 # 2020-09-13, safely older than any build artifact
os.utime(switch_layout, (old, old))
rc, out = _rebuild(build_dir, switch_layout)
assert rc == 0, f'incremental rebuild after switching the layout should succeed:\n{out[-2000:]}'
marker = _symbols(build_dir)['_custom_linker_marker']
assert marker == 0xD00DFEED, (
f'stale linker script: switching the LINKER_LAYOUT on an incremental build did not '
f'regenerate the script (marker still {marker:#x}, expected 0xd00dfeed)'
)
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_negative_stack_size_too_large(target: str) -> None:
rc, out, _ = _build('bad_stack_oversize', os.path.join(LAYOUTS, 'bad_stack_oversize.ld'), target)
assert rc != 0, 'a __stack_size that overruns the data region must fail the build'
assert 'stack region overlaps the data region' in out, 'bottom-bounds stack check did not fire'
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_negative_missing_data_end(target: str) -> None:
rc, out, _ = _build('bad_no_data_end', os.path.join(LAYOUTS, 'bad_no_data_end.ld'), target)
assert rc != 0, 'a layout not marking _lp_data_end must fail the build'
assert 'end of writable data is not marked' in out, 'the _lp_data_end contract check did not fire'
@pytest.mark.parametrize('target', LP_CORE_MEMPROT_TARGETS)
def test_negative_missing_text_end_under_memprot(target: str) -> None:
rc, out, _ = _build(
'bad_no_text_end',
os.path.join(LAYOUTS, 'bad_no_text_end.ld'),
target,
extra_defaults='CONFIG_ULP_LP_CORE_MEMPROT=y',
)
assert rc != 0, 'a MEMPROT layout missing _lp_text_end must fail the build'
assert '_lp_text_end must be defined' in out, 'the _lp_text_end MEMPROT check did not fire'
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_negative_reset_vector_check_fails_build(target: str) -> None:
rc, out, _ = _build('bad_origin', os.path.join(LAYOUTS, 'bad_origin.ld'), target)
assert rc != 0, 'a misplaced lp_ram origin must fail the build'
assert 'reset vector is not at the required offset' in out, 'reset-vector check did not fire'
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_negative_sizing_check_fails_build(target: str) -> None:
rc, out, _ = _build('bad_oversize', os.path.join(LAYOUTS, 'bad_oversize.ld'), target)
assert rc != 0, 'an oversized lp_ram must fail the build'
assert 'exceeds the reserved LP coprocessor memory' in out, 'sizing check did not fire'
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_negative_shared_overrun_check_fails_build(target: str) -> None:
rc, out, _ = _build('bad_shared', os.path.join(LAYOUTS, 'bad_shared_overrun.ld'), target)
assert rc != 0, 'a stack overrunning the shared region must fail the build'
assert 'overruns into the reserved shared-memory region' in out, 'shared-overrun guardrail did not fire'
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_negative_missing_reset_vector(target: str) -> None:
rc, out, _ = _build('bad_no_rv', os.path.join(LAYOUTS, 'bad_no_reset_vector.ld'), target)
assert rc != 0, 'omitting LP_CORE_TEXT_START must fail the build'
assert 'reset vector is not at the required offset' in out, 'reset-vector position check did not fire'
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_negative_missing_linker_rejected_at_configure(target: str) -> None:
rc, out, _ = _build('missing', '/nonexistent/custom_layout.ld', target)
assert rc != 0, 'a non-existent LINKER_LAYOUT path must be rejected'
assert 'LINKER_LAYOUT linker script not found' in out, 'API path validation did not fire'
@@ -0,0 +1,12 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
cmake_minimum_required(VERSION 3.22)
# v2-native parent: the ULP program is built as a full CMake v2 subproject
# (ulp_project_default), so the custom LINKER layout is exercised through the
# buildv2 linker-script path rather than the legacy CMake v1 one.
include($ENV{IDF_PATH}/tools/cmakev2/idf.cmake)
project(lp_core_custom_linker_v2 C CXX ASM)
idf_project_default()
@@ -0,0 +1,12 @@
| Supported Targets | ESP32-C5 | ESP32-C6 | ESP32-P4 | ESP32-S31 |
| ----------------- | -------- | -------- | -------- | --------- |
# LP Core Custom Linker Host Tests (CMake v2 full subproject)
Build-only host tests for the ULP custom linker feature under the **CMake v2** build system. The parent app is v2-native (its `CMakeLists.txt` includes `tools/cmakev2/idf.cmake`) and the ULP program is built as a full subproject via `ulp_project_default()`, so these tests exercise the buildv2 linker-script path (`components/ulp/CMakeLists_v2.txt`) rather than the CMake v1 one covered by `../lp_core_custom_linker`.
No target board is required: every check is resolved at build time on the host.
`test_lp_core_custom_linker_v2.py` builds this app with layouts supplied via the `LINKER_LAYOUT` option of `ulp_add_project` and asserts on the linked ULP image — that a custom layout is applied, and that switching the layout on an incremental build relinks against the new one.
Being a build-time test, it drives `idf.py` itself and is run as plain pytest by the dedicated `test_lp_core_custom_linker_buildv2_on_host` host-test job, outside the idf-ci flow (see `exclude_dirs` in `.idf_ci.toml`). Run it locally with `pytest_for_ut .` from this directory, or directly with `pytest -p no:idf-ci -p no:pytest_embedded .`.
@@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
idf_component_register(SRCS "test_main.c"
REQUIRES ulp)
# Pytest overrides CUSTOM_ULP_LAYOUT per test case via -DCUSTOM_ULP_LAYOUT=...
if(NOT DEFINED CUSTOM_ULP_LAYOUT)
set(CUSTOM_ULP_LAYOUT "${CMAKE_CURRENT_LIST_DIR}/ulp/layouts/good.ld")
endif()
# Build the ULP program as a full CMake v2 subproject (see ulp/CMakeLists.txt),
# supplying the custom memory layout through the LINKER_LAYOUT option.
ulp_add_project("ulp_main" "${CMAKE_CURRENT_LIST_DIR}/ulp/"
TYPE lp_core
LINKER_LAYOUT "${CUSTOM_ULP_LAYOUT}")
@@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
/* Stub app_main -- build-only test. The pytest harness checks the ULP ELF. */
void app_main(void)
{
}
@@ -0,0 +1,9 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
cmake_minimum_required(VERSION 3.22)
include(${IDF_PATH}/components/ulp/cmake/ulp_project.cmake)
project(${IDF_DEFAULT_PROJECT_NAME} C CXX ASM)
ulp_project_default()
@@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* POSITIVE: full-replacement layout. Composes LP_CORE_TEXT_START and the
* section-boundary macros; shared memory and __stack_top are owned by the base.
*/
PROVIDE(_custom_linker_marker = 0xCAFEBABE);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START
}
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
.text ALIGN(4): { *(.text) *(.text*) } > lp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > lp_ram
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > lp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > lp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > lp_ram
LP_CORE_DATA_END()
}
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*
* POSITIVE: same shape as good.ld but with a distinct marker value. Used by the
* incremental-switch test to tell which layout the image was linked against
* after the LINKER option is changed without wiping the build directory.
*/
PROVIDE(_custom_linker_marker = 0xD00DFEED);
MEMORY
{
lp_ram(RWX) : ORIGIN = LP_CORE_USER_MEMORY_REGION_START, LENGTH = LP_CORE_USER_MEMORY_REGION_END - LP_CORE_USER_MEMORY_REGION_START
}
SECTIONS
{
LP_CORE_TEXT_START(lp_ram)
.text ALIGN(4): { *(.text) *(.text*) } > lp_ram
.rodata ALIGN(4): { *(.rodata) *(.rodata*) } > lp_ram
LP_CORE_TEXT_END()
LP_CORE_DATA_START()
.data ALIGN(4): { *(.data) *(.data*) *(.sdata) *(.sdata*) } > lp_ram
.bss ALIGN(4): { *(.bss) *(.bss*) *(.sbss) *(.sbss*) PROVIDE(end = .); } > lp_ram
.custom_state ALIGN(4): { KEEP(*(.custom_state)) } > lp_ram
LP_CORE_DATA_END()
}
@@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
idf_component_register(SRCS "main.c"
REQUIRES ulp)
@@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdint.h>
/* Defined by the custom layout via PROVIDE(); reference keeps it from --gc-sections. */
extern const uint32_t _custom_linker_marker;
volatile uint32_t custom_marker_copy __attribute__((used, section(".custom_state")));
int main(void)
{
custom_marker_copy = (uint32_t)(uintptr_t)&_custom_linker_marker;
while (1) {
custom_marker_copy++;
for (volatile uint32_t i = 0; i < 10000; i++) {
__asm__ volatile("nop");
}
}
return 0;
}
@@ -0,0 +1,3 @@
CONFIG_ULP_COPROC_ENABLED=y
CONFIG_ULP_COPROC_TYPE_LP_CORE=y
CONFIG_ULP_COPROC_RESERVE_MEM=4096
@@ -0,0 +1,96 @@
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
# SPDX-License-Identifier: Apache-2.0
#
# Host build tests for custom LP-core linker layouts under the CMake v2
# full-subproject build. The ULP program is built through ulp_project_default(),
# so these exercise the buildv2 linker-script path
# (components/ulp/CMakeLists_v2.txt), the counterpart of the CMake v1 tests in
# ../lp_core_custom_linker.
from __future__ import annotations
import os
import shutil
import subprocess
import pytest
from esp_bool_parser import parse_bool_expr
from esp_bool_parser.constants import PREVIEW_TARGETS
from esp_bool_parser.constants import SUPPORTED_TARGETS
APP_DIR = os.path.dirname(os.path.abspath(__file__))
LAYOUTS = os.path.join(APP_DIR, 'main', 'ulp', 'layouts')
# Evaluate the SoC-caps expression against every known target so the set tracks
# the config as the LP core lands on new targets.
_expr = parse_bool_expr('SOC_LP_CORE_SUPPORTED == 1')
LP_CORE_TARGETS = [t for t in (*SUPPORTED_TARGETS, *PREVIEW_TARGETS) if _expr.get_value(t, '')]
def _build_dir(target: str) -> str:
return os.path.join(APP_DIR, f'build_{target}')
def _build(target: str, custom_layout: str, clean: bool) -> tuple[int, str]:
# The parent app is CMake v2-native (its CMakeLists includes cmakev2/idf.cmake
# directly), so a plain build with -DIDF_TARGET drives the buildv2 path.
build_dir = _build_dir(target)
if clean:
shutil.rmtree(build_dir, ignore_errors=True)
# Keep sdkconfig inside the build dir: the buildv2 flow refuses to switch
# IDF_TARGET while a mismatched app-dir sdkconfig exists, so per-target
# isolation is required for the parametrized targets.
r = subprocess.run(
[
'idf.py',
'--preview',
'-B',
build_dir,
f'-DIDF_TARGET={target}',
f'-DSDKCONFIG={os.path.join(build_dir, "sdkconfig")}',
f'-DCUSTOM_ULP_LAYOUT={custom_layout}',
'build',
],
cwd=APP_DIR,
capture_output=True,
text=True,
)
return r.returncode, r.stdout + r.stderr
def _marker(target: str) -> int:
# The ULP executable of a full subproject is emitted under build/subprojects/.
elf = os.path.join(_build_dir(target), 'subprojects', 'ulp_main', 'ulp_main.elf')
assert os.path.isfile(elf), 'ULP elf was not produced'
out = subprocess.run(['riscv32-esp-elf-nm', elf], capture_output=True, text=True).stdout
for line in out.splitlines():
parts = line.split()
if len(parts) == 3 and parts[2] == '_custom_linker_marker':
return int(parts[0], 16)
raise AssertionError('custom marker symbol absent from the linked ULP image')
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_v2_positive_custom_layout(target: str) -> None:
rc, out = _build(target, os.path.join(LAYOUTS, 'good.ld'), clean=True)
assert rc == 0, f'custom layout should build under buildv2:\n{out[-2000:]}'
assert _marker(target) == 0xCAFEBABE, 'custom layout marker not linked under buildv2'
@pytest.mark.parametrize('target', LP_CORE_TARGETS)
def test_v2_incremental_layout_switch(target: str) -> None:
# Frantisek's concern, verified for buildv2: switch the LINKER_LAYOUT on an
# incremental build (no wipe) with the new layout aged older than the
# previously generated script; the image must relink against the new layout.
rc, out = _build(target, os.path.join(LAYOUTS, 'good.ld'), clean=True)
assert rc == 0, f'initial buildv2 build should succeed:\n{out[-2000:]}'
assert _marker(target) == 0xCAFEBABE
switch_layout = os.path.join(LAYOUTS, 'good_switch.ld')
os.utime(switch_layout, (1600000000, 1600000000)) # 2020-09-13, older than the built script
rc, out = _build(target, switch_layout, clean=False)
assert rc == 0, f'incremental buildv2 rebuild after switching layout should succeed:\n{out[-2000:]}'
marker = _marker(target)
assert marker == 0xD00DFEED, (
f'stale linker script under buildv2: switching the LINKER_LAYOUT on an incremental '
f'build did not relink (marker still {marker:#x}, expected 0xd00dfeed)'
)