test(ulp): add custom linker host tests

Add build-only host tests for LP-core custom linker layouts supplied through the
LINKER option. Cover the positive paths (a full-replacement layout, a
multi-region layout with a fixed-address section, a run-from-HP-mem layout, and
memory protection) and the link-time and configure-time checks (reset-vector
placement, sizing, shared-memory overrun, the MEMPROT RX/RW boundary, a missing
reset vector, and a missing LINKER script).
This commit is contained in:
Sudeep Mohanty
2026-06-24 10:10:07 +02:00
parent 7c3b82562a
commit 06963baf0b
37 changed files with 1107 additions and 0 deletions

View File

@@ -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

View File

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

View File

@@ -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 .`.

View File

@@ -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}")

View File

@@ -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;

View File

@@ -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)
{
}

View File

@@ -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) */
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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()
}

View File

@@ -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;
}

View File

@@ -0,0 +1,3 @@
CONFIG_ULP_COPROC_ENABLED=y
CONFIG_ULP_COPROC_TYPE_LP_CORE=y
CONFIG_ULP_COPROC_RESERVE_MEM=4096

View File

@@ -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'

View File

@@ -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()

View File

@@ -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 .`.

View File

@@ -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}")

View File

@@ -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)
{
}

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -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()
}

View File

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

View File

@@ -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;
}

View File

@@ -0,0 +1,3 @@
CONFIG_ULP_COPROC_ENABLED=y
CONFIG_ULP_COPROC_TYPE_LP_CORE=y
CONFIG_ULP_COPROC_RESERVE_MEM=4096

View File

@@ -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)'
)