From 45189ef44564cfd39571ce2d24257fb19be3f093 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 28 Sep 2026 19:49:54 +0200 Subject: [PATCH] [nrf52] Precompile the core headers on the sdk-nrf build (#19826) --- esphome/components/nrf52/__init__.py | 71 ++++++++++ tests/unit_tests/components/nrf52/test_pch.py | 134 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 tests/unit_tests/components/nrf52/test_pch.py diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 0d1cfd6c1e..488984ff70 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -8,6 +8,7 @@ import shutil import subprocess from esphome import pins +from esphome.build_helpers import pch import esphome.codegen as cg from esphome.components.zephyr import ( add_extra_script, @@ -781,6 +782,67 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> return False +# GCC only loads a precompiled header ahead of every other forced header, and +# Zephyr forces two with -imacros. They hold macros only, so the C++ sources +# of the app get them through the precompiled header. +_PCH_CMAKE_LINES = [ + "", + "# ESPHome precompiled header", + "get_property(esphome_options TARGET zephyr_interface", + " PROPERTY INTERFACE_COMPILE_OPTIONS)", + "set(esphome_kept_options)", + "set(esphome_pch_headers)", + "foreach(option IN LISTS esphome_options)", + ' if(option MATCHES "imacros> (.+)$")', + ' list(APPEND esphome_pch_headers "${CMAKE_MATCH_1}")', + " list(APPEND esphome_kept_options", + ' "$<$,$,app>>>:${option}>")', + " else()", + ' list(APPEND esphome_kept_options "${option}")', + " endif()", + "endforeach()", + "if(NOT esphome_pch_headers)", + ' message(FATAL_ERROR "ESPHome: the headers Zephyr forces were not found, so "', + ' "the precompiled header would not load (set ESPHOME_PCH_ENABLE=0)")', + "endif()", + "set_property(TARGET zephyr_interface", + ' PROPERTY INTERFACE_COMPILE_OPTIONS "${esphome_kept_options}")', + *( + f'list(APPEND esphome_pch_headers "${{CMAKE_CURRENT_LIST_DIR}}/../src/{header}")' + for header in pch.PCH_DEFAULT_HEADERS + ), + 'list(TRANSFORM esphome_pch_headers REPLACE "(.+)" "$<$:\\\\1>")', + "target_precompile_headers(app PRIVATE ${esphome_pch_headers})", +] +# Where CMake puts the .gch of the app, below its binary dir +_PCH_SUM_PATH = "CMakeFiles/app.dir/cmake_pch.hxx.gch.sum" + + +def _write_pch_checksum(build_dir: Path, source_dir: Path) -> None: + """Write the checksum ccache reads in place of the .gch. The app binary + dir only exists after the first configure; sysbuild nests it.""" + app_dir = build_dir / "zephyr" + if not (app_dir / "CMakeCache.txt").is_file(): + app_dir = build_dir + if not (app_dir / "CMakeCache.txt").is_file(): + return + checksum = pch.pch_checksum( + CORE.relative_src_path(), + pch.PCH_DEFAULT_HEADERS, + ( + str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), + zephyr_data()[KEY_BOARD], + # What the Zephyr configuration is generated from + *( + path.read_text(encoding="utf-8") + for path in sorted(source_dir.iterdir()) + if path.suffix in (".conf", ".overlay") + ), + ), + ) + write_file_if_changed(app_dir / _PCH_SUM_PATH, checksum + "\n") + + def _generate_cmake_lists() -> bool: """Write the project CMakeLists.txt, returning True if it changed.""" compile_flags = get_project_compile_flags() @@ -824,6 +886,9 @@ def _generate_cmake_lists() -> bool: ")", ] + if pch.pch_enabled(): + lines += _PCH_CMAKE_LINES + if link_flags: lines += [ "", @@ -897,6 +962,12 @@ def run_compile(args, config: ConfigType) -> bool: _LOGGER.info("Build inputs changed, cleaning %s", build_dir) rmtree(build_dir) + if pch.pch_enabled(): + pch.log_pch_in_use() + # Zephyr turns ccache on by itself when it is installed + env.update(pch.ccache_pch_env()) + _write_pch_checksum(build_dir, source_dir) + west_cmd = _west_build_command( paths["python_executable"], board, build_dir, source_dir ) diff --git a/tests/unit_tests/components/nrf52/test_pch.py b/tests/unit_tests/components/nrf52/test_pch.py new file mode 100644 index 0000000000..0052df0772 --- /dev/null +++ b/tests/unit_tests/components/nrf52/test_pch.py @@ -0,0 +1,134 @@ +"""nrf52 sdk-nrf precompiled header: the CMake block and the ccache checksum.""" + +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from esphome.components import nrf52 +from esphome.components.zephyr.const import KEY_BOARD +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain +from esphome.core import CORE, EsphomeError + +SUM = "CMakeFiles/app.dir/cmake_pch.hxx.gch.sum" + + +def _generate_cmake(tmp_path: Path) -> str: + CORE.config_path = tmp_path / "test.yaml" + CORE.build_path = tmp_path / "build" + CORE.name = "livingroom" + with ( + patch( + "esphome.components.zephyr.library.generate_zephyr_modules", + return_value=[], + ), + patch.object(nrf52, "get_project_compile_flags", return_value=["-Os"]), + patch.object(nrf52, "get_project_link_flags", return_value=[]), + ): + nrf52._generate_cmake_lists() + return (tmp_path / "build" / "zephyr" / "CMakeLists.txt").read_text() + + +def test_cmake_lists_precompile_the_core_headers(tmp_path: Path) -> None: + text = _generate_cmake(tmp_path) + assert "target_precompile_headers(app PRIVATE ${esphome_pch_headers})" in text + assert "/../src/esphome/core/pch_prefix.h" in text + # The headers Zephyr forces leave the C++ compiles of the app only + assert ( + "$<$," + "$,app>>>:${option}>" + ) in text + assert r'REPLACE "(.+)" "$<$:\\1>"' in text + # A Zephyr that spells them another way must not go unnoticed + assert "if(NOT esphome_pch_headers)\n message(FATAL_ERROR" in text + + +def test_cmake_lists_pch_block_disabled( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + text = _generate_cmake(tmp_path) + assert "precompile" not in text + assert "zephyr_interface" not in text + + +def _write_checksum(tmp_path: Path, app: str, conf: str = "CONFIG_X=y\n") -> Path: + """Write the checksum for a build dir whose app image sits in ``app``.""" + CORE.build_path = tmp_path + header = tmp_path / "src" / "esphome" / "core" / "pch_prefix.h" + header.parent.mkdir(parents=True, exist_ok=True) + header.write_text("#define M 1\n") + source_dir = tmp_path / "zephyr" + source_dir.mkdir(exist_ok=True) + (source_dir / "prj.conf").write_text(conf) + (source_dir / "CMakeLists.txt").write_text("not part of the checksum\n") + build_dir = tmp_path / ".pioenvs" / "livingroom" + (build_dir / app).mkdir(parents=True, exist_ok=True) + (build_dir / app / "CMakeCache.txt").write_text("") + with ( + patch.dict(CORE.data, {KEY_CORE: {KEY_FRAMEWORK_VERSION: "2.9.2"}}), + patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}), + ): + nrf52._write_pch_checksum(build_dir, source_dir) + return build_dir / app / SUM + + +@pytest.mark.parametrize("app", ["zephyr", "."]) +def test_pch_checksum_is_written_next_to_the_gch(tmp_path: Path, app: str) -> None: + """Sysbuild nests the app image; without it the build dir is the app.""" + sum_path = _write_checksum(tmp_path, app) + assert len(sum_path.read_text().strip()) == 64 + + +def test_pch_checksum_tracks_the_zephyr_configuration(tmp_path: Path) -> None: + first = _write_checksum(tmp_path, "zephyr").read_text() + assert _write_checksum(tmp_path, "zephyr", "CONFIG_X=n\n").read_text() != first + + +def test_pch_checksum_waits_for_the_first_configure(tmp_path: Path) -> None: + CORE.build_path = tmp_path + build_dir = tmp_path / ".pioenvs" / "livingroom" + nrf52._write_pch_checksum(build_dir, tmp_path / "zephyr") + assert not build_dir.exists() + + +@pytest.fixture +def run_cmd(tmp_path: Path) -> Mock: + CORE.config_path = tmp_path / "test.yaml" + CORE.build_path = tmp_path / "build" + CORE.name = "livingroom" + CORE.toolchain = Toolchain.SDK_NRF + with ( + patch.object(nrf52, "check_and_install"), + patch.object(nrf52, "_generate_cmake_lists", return_value=False), + patch.object( + nrf52, + "get_build_paths", + return_value={"python_executable": "python3", "framework_path": tmp_path}, + ), + patch.object(nrf52, "get_build_env", return_value={}), + patch.object(nrf52, "zephyr_data", return_value={KEY_BOARD: "board"}), + patch.object(nrf52, "run_command_ok", return_value=False) as run, + ): + yield run + + +def test_ccache_pch_settings_reach_west(run_cmd: Mock) -> None: + # clear=True also drops ambient CCACHE_* and ESPHOME_PCH_* overrides + with ( + patch.dict("os.environ", {}, clear=True), + pytest.raises(EsphomeError, match="nRF52 native build failed"), + ): + nrf52.run_compile(None, {}) + env = run_cmd.call_args.kwargs["env"] + assert env["CCACHE_PCH_EXTSUM"] == "true" + assert env["CCACHE_SLOPPINESS"] == "pch_defines,time_macros" + + +def test_disabled_leaves_the_west_environment_alone( + run_cmd: Mock, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") + with pytest.raises(EsphomeError, match="nRF52 native build failed"): + nrf52.run_compile(None, {}) + assert "CCACHE_PCH_EXTSUM" not in run_cmd.call_args.kwargs["env"]