diff --git a/docs/en/api-guides/build-system-v2/multiple-binaries.rst b/docs/en/api-guides/build-system-v2/multiple-binaries.rst index 23789e2275b..421734e0e5b 100644 --- a/docs/en/api-guides/build-system-v2/multiple-binaries.rst +++ b/docs/en/api-guides/build-system-v2/multiple-binaries.rst @@ -30,6 +30,17 @@ Each executable then gets its own binary, flash, and configuration targets, name A single build now produces both ``app1.bin`` and ``app2.bin``, each with its own ``appN-flash`` and ``appN-menuconfig`` targets. +To emit the ``project_description.json`` and gdbinit files that tooling such as ``idf.py gdb`` consumes, call :cmakev2:ref:`idf_build_generate_metadata` for each executable. These outputs are written to project-wide default paths, so every executable past the first must be given a distinct ``OUTPUT_FILE`` and ``GDBINIT_DIR`` to keep its metadata and gdbinit files from overwriting the others': + +.. code-block:: cmake + + idf_build_generate_metadata(BINARY app1_binary) + idf_build_generate_metadata(BINARY app2_binary + OUTPUT_FILE "${CMAKE_BINARY_DIR}/project_description_app2.json" + GDBINIT_DIR "${CMAKE_BINARY_DIR}/gdbinit/app2") + +Each executable's ``project_description.json`` then references its own ELF and points ``gdbinit_files`` at its own directory, so a debugger loads the symbols of the executable it was launched for. + .. note:: A multi-binary project has a single project-wide ``sdkconfig``, and a diff --git a/examples/build_system/cmakev2/features/multi_binary/CMakeLists.txt b/examples/build_system/cmakev2/features/multi_binary/CMakeLists.txt index f10efa6ee52..b30eba01388 100644 --- a/examples/build_system/cmakev2/features/multi_binary/CMakeLists.txt +++ b/examples/build_system/cmakev2/features/multi_binary/CMakeLists.txt @@ -32,6 +32,8 @@ idf_flash_binary(app1_binary # Generate flasher_args.json and metadata for the primary app1 binary. # app1 is the only app registered in the global 'flash' target (app2 omits FLASH), # so flasher_args.json correctly contains app1 as the sole app binary. +# app1 uses the default output paths (build/project_description.json and +# build/gdbinit). app2 below must use distinct paths so it does not overwrite them. idf_build_generate_flasher_args() idf_build_generate_metadata(BINARY app1_binary) @@ -52,6 +54,14 @@ idf_flash_binary(app2_binary TARGET app2-flash NAME "app2") +# Generate metadata for app2. idf_build_generate_metadata writes the +# project_description.json and the gdbinit files to shared default paths, so +# pass a distinct OUTPUT_FILE and GDBINIT_DIR to keep app2's files from +# overwriting app1's. +idf_build_generate_metadata(BINARY app2_binary + OUTPUT_FILE "${CMAKE_BINARY_DIR}/project_description_app2.json" + GDBINIT_DIR "${CMAKE_BINARY_DIR}/gdbinit/app2") + # Create menuconfig and confserver targets for app2 binary idf_create_menuconfig(app2.elf TARGET app2-menuconfig) idf_create_confserver(app2.elf TARGET app2-confserver) diff --git a/tools/cmakev2/build.cmake b/tools/cmakev2/build.cmake index 102a94af1c2..e707f2de4b5 100644 --- a/tools/cmakev2/build.cmake +++ b/tools/cmakev2/build.cmake @@ -932,7 +932,8 @@ endfunction() idf_build_generate_metadata([BINARY ] [EXECUTABLE ] [OUTPUT_FILE ] - [HINTS_OUTPUT_FILE ]) + [HINTS_OUTPUT_FILE ] + [GDBINIT_DIR ]) *BINARY[in,opt]* @@ -954,6 +955,15 @@ endfunction() behaviour prevents hint files from different binaries overwriting each other in multi-binary projects. + *GDBINIT_DIR[in,opt]* + + Optional directory for the generated gdbinit files. If not provided, + the default location ``/gdbinit`` is used. Multi-executable + projects should pass a distinct directory per executable so their + gdbinit files do not overwrite each other. Only the per-executable + ``symbols``, ``connect`` and ``py_extensions`` files are written here; + the project-wide ``prefix_map`` file stays under ``/gdbinit``. + Generate metadata for the specified ``binary`` or ``executable`` target and store it in the specified ``OUTPUT_FILE``. If no ``OUTPUT_FILE`` is provided, the default location ``/project_description.json`` will be @@ -961,7 +971,7 @@ endfunction() #]] function(idf_build_generate_metadata) set(options) - set(one_value OUTPUT_FILE BINARY EXECUTABLE HINTS_OUTPUT_FILE) + set(one_value OUTPUT_FILE BINARY EXECUTABLE HINTS_OUTPUT_FILE GDBINIT_DIR) set(multi_value) cmake_parse_arguments(ARG "${options}" "${one_value}" "${multi_value}" ${ARGN}) @@ -1037,11 +1047,17 @@ function(idf_build_generate_metadata) idf_build_get_property(component_interfaces COMPONENT_INTERFACES) __get_components_metadata(COMPONENTS "${component_interfaces}" OUTPUT all_component_info_json) - __generate_gdbinit() + if(NOT DEFINED ARG_GDBINIT_DIR) + set(ARG_GDBINIT_DIR "${BUILD_DIR}/gdbinit") + endif() + + # Each executable produces its own gdbinit output referencing its own ELF. + # __generate_gdbinit resolves the ELF path from the executable target. + __generate_gdbinit("${ARG_EXECUTABLE}" "${ARG_GDBINIT_DIR}" + gdbinit_files_symbols gdbinit_files_py_extensions gdbinit_files_connect) + # prefix_map is project-wide (set once in project.cmake), unlike the + # per-executable files returned by __generate_gdbinit above. idf_build_get_property(gdbinit_files_prefix_map GDBINIT_FILES_PREFIX_MAP) - idf_build_get_property(gdbinit_files_symbols GDBINIT_FILES_SYMBOLS) - idf_build_get_property(gdbinit_files_py_extensions GDBINIT_FILES_PY_EXTENSIONS) - idf_build_get_property(gdbinit_files_connect GDBINIT_FILES_CONNECT) __get_openocd_options(debug_arguments_openocd) if(NOT DEFINED ARG_OUTPUT_FILE) diff --git a/tools/cmakev2/gdbinit.cmake b/tools/cmakev2/gdbinit.cmake new file mode 100644 index 00000000000..8b8a47605a4 --- /dev/null +++ b/tools/cmakev2/gdbinit.cmake @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 + +#[[ + __generate_gdbinit( + ) + + *executable[in]* + + Executable target whose ELF the debugger loads the application symbols + from. The ELF path is resolved from the target. + + *gdbinit_dir[in]* + + Directory into which the generated gdbinit files are written. + + *out_symbols[out]*, *out_py_extensions[out]*, *out_connect[out]* + + Names of variables set in the caller's scope to the paths of the + generated ``symbols``, ``py_extensions`` and ``connect`` files. These + paths are per-executable, so they are returned to the caller instead of + stored in global build properties, which in a multi-executable build + would only ever hold the last executable's paths. + + Prepare the gdbinit files (``symbols``, ``connect``, ``py_extensions`` and + the combined ``gdbinit``) passed to the debugger. Taking the executable + target and output directory as arguments lets a project that builds several + executables produce a distinct set of gdbinit files per executable instead + of overwriting a single shared directory. +#]] +function(__generate_gdbinit executable gdbinit_dir out_symbols out_py_extensions out_connect) + if(NOT TARGET "${executable}") + idf_die("The executable '${executable}' is not a cmake target") + endif() + + # The output paths derive from gdbinit_dir alone, so return them on every + # call, including the repeat calls that skip regeneration below. + set(symbols_gdbinit_path "${gdbinit_dir}/symbols") + set(py_extensions_gdbinit_path "${gdbinit_dir}/py_extensions") + set(connect_gdbinit_path "${gdbinit_dir}/connect") + set(${out_symbols} "${symbols_gdbinit_path}" PARENT_SCOPE) + set(${out_py_extensions} "${py_extensions_gdbinit_path}" PARENT_SCOPE) + set(${out_connect} "${connect_gdbinit_path}" PARENT_SCOPE) + + # CMake permits only one file(GENERATE) per output path, and the same + # executable can be passed to idf_build_generate_metadata more than once + # (e.g. as EXECUTABLE and again as its BINARY). Generate the gdbinit files + # only the first time a directory is used, keyed by the hash of its absolute + # path so that differently-spelled paths resolving to the same directory are + # matched. A different executable reusing the same directory is a collision. + get_filename_component(gdbinit_dir_abs "${gdbinit_dir}" ABSOLUTE + BASE_DIR "${CMAKE_CURRENT_BINARY_DIR}") + string(MD5 gdbinit_dir_key "${gdbinit_dir_abs}") + idf_build_get_property(gdbinit_dir_owner __GDBINIT_OWNER_${gdbinit_dir_key}) + if(gdbinit_dir_owner) + if(NOT gdbinit_dir_owner STREQUAL executable) + idf_die("Executables '${gdbinit_dir_owner}' and '${executable}' both generate gdbinit " + "files into '${gdbinit_dir}'. Pass a distinct GDBINIT_DIR per executable.") + endif() + return() + endif() + idf_build_set_property(__GDBINIT_OWNER_${gdbinit_dir_key} "${executable}") + + idf_build_get_property(idf_path IDF_PATH) + idf_build_get_property(python PYTHON) + file(MAKE_DIRECTORY "${gdbinit_dir}") + + # Resolve the ELF path from the target instead of reconstructing it from + # OUTPUT_NAME and SUFFIX. + set(application_elf "$") + + # Define static gdbinit commands + if(CONFIG_IDF_TARGET_LINUX) + set(gdbinit_connect + "# Run the application and stop on app_main()\n" + "break app_main\n" + "run\n") + else() + set(gdbinit_connect + "# Connect to the default openocd-esp port and stop on app_main()\n" + "set remotetimeout 10\n" + "target remote :3333\n" + "monitor reset halt\n" + "maintenance flush register-cache\n" + "thbreak app_main\n" + "continue\n") + endif() + + set(gdbinit_py_extensions + "# Add Python GDB extensions\n" + "python\n" + "import sys\n" + "try:\n" + " import freertos_gdb\n" + "except ModuleNotFoundError:\n" + " print('warning: python extension \"freertos_gdb\" not found.', file=sys.stderr)\n" + "try:\n" + " import idf_drivers_gdb\n" + "except ModuleNotFoundError:\n" + " print('warning: python extension \"idf_drivers_gdb\" not found.', file=sys.stderr)\n" + "end\n") + + # Get ROM ELFs gdbinit part + if(CONFIG_IDF_TARGET_LINUX) + set(rom_symbols) + else() + execute_process( + COMMAND ${python} "${idf_path}/components/esp_rom/gen_gdbinit.py" ${IDF_TARGET} + OUTPUT_VARIABLE rom_symbols + RESULT_VARIABLE result + ) + if(NOT result EQUAL 0) + set(rom_symbols) + message(WARNING "Error while generating esp_rom gdbinit") + endif() + endif() + + # Check if bootloader ELF is defined and set symbol-file accordingly + if(DEFINED BOOTLOADER_ELF_FILE) + set(add_bootloader_symbols " add-symbol-file ${BOOTLOADER_ELF_FILE}") + else() + set(add_bootloader_symbols " # Bootloader elf was not found") + endif() + + # application_elf is a generator expression, which configure_file() does not + # expand, so the configured template is routed through file_generate. + configure_file("${idf_path}/tools/cmake/symbols.gdbinit.in" "${symbols_gdbinit_path}.templ") + file(READ "${symbols_gdbinit_path}.templ" symbols_gdbinit_templ) + file(REMOVE "${symbols_gdbinit_path}.templ") + file_generate("${symbols_gdbinit_path}" CONTENT "${symbols_gdbinit_templ}") + file(WRITE "${py_extensions_gdbinit_path}" ${gdbinit_py_extensions}) + file(WRITE "${connect_gdbinit_path}" ${gdbinit_connect}) + + file(WRITE "${gdbinit_dir}/gdbinit" "source ${symbols_gdbinit_path}\n") + file(APPEND "${gdbinit_dir}/gdbinit" "source ${connect_gdbinit_path}\n") +endfunction() diff --git a/tools/cmakev2/idf.cmake b/tools/cmakev2/idf.cmake index b1af1474dbf..12c998646fa 100644 --- a/tools/cmakev2/idf.cmake +++ b/tools/cmakev2/idf.cmake @@ -21,10 +21,11 @@ set(CMAKE_MODULE_PATH # for both cmakev1 and cmakev2. include(${CMAKE_CURRENT_LIST_DIR}/../cmake/version.cmake) -# The gdbinit.cmake file from cmakev1 contains a single function, -# __generate_gdbinit, which is used in the generation of -# project_description.json. -include(${CMAKE_CURRENT_LIST_DIR}/../cmake/gdbinit.cmake) +# gdbinit.cmake provides __generate_gdbinit, used to produce the per-executable +# gdbinit files referenced from project_description.json. It takes the +# application ELF path and output directory as arguments so multi-executable +# projects do not overwrite a single shared gdbinit directory. +include(${CMAKE_CURRENT_LIST_DIR}/gdbinit.cmake) # The openocd.cmake file from cmakev1 contains a single function, # __get_openocd_options, which is used in the generation of diff --git a/tools/test_build_system/buildv2/test_gdbinit.py b/tools/test_build_system/buildv2/test_gdbinit.py new file mode 100644 index 00000000000..00c5182eba8 --- /dev/null +++ b/tools/test_build_system/buildv2/test_gdbinit.py @@ -0,0 +1,152 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +import json +import logging +import re +import subprocess +from pathlib import Path + +import pytest +from test_build_system_helpers import IdfPyFunc +from test_build_system_helpers import replace_in_file + + +@pytest.mark.usefixtures('test_app_copy') +def test_gdbinit_single_binary(idf_py: IdfPyFunc) -> None: + """A single-executable build writes gdbinit into build/gdbinit/ referencing the app elf.""" + logging.info('Testing gdbinit: single-binary default output directory and elf reference') + + idf_py('build') + + symbols = Path('build/gdbinit/symbols') + assert symbols.exists(), 'build/gdbinit/symbols was not generated for a single-binary build' + content = symbols.read_text() + assert 'build_test_app.elf' in content, f'gdbinit symbols must reference the application elf, got: {content!r}' + + +def _create_app2_component(base_dir: Path) -> None: + """Create a second application component for multi-binary tests.""" + app2_dir = base_dir / 'components' / 'app2' + app2_dir.mkdir(parents=True, exist_ok=True) + (app2_dir / 'CMakeLists.txt').write_text('idf_component_register(SRCS "app2.c")\n') + (app2_dir / 'app2.c').write_text('void app_main(void) {}\n') + + +def _write_two_executable_cmakelists(app1_extra: str = '', app2_extra: str = '') -> None: + """Rewrite the app CMakeLists to build app1 and app2 and generate metadata for each. + + app1_extra and app2_extra are appended inside the respective + idf_build_generate_metadata() calls (e.g. a ``GDBINIT_DIR`` argument). + """ + _create_app2_component(Path('.')) + replace_in_file( + 'CMakeLists.txt', + 'idf_project_default()', + 'idf_project_init()\n' + 'idf_build_executable(app1 COMPONENTS main)\n' + 'idf_build_executable(app2 COMPONENTS app2)\n' + 'add_custom_target(app ALL DEPENDS app1 app2)\n' + f'idf_build_generate_metadata(EXECUTABLE app1{app1_extra})\n' + 'idf_build_generate_metadata(EXECUTABLE app2\n' + ' OUTPUT_FILE "${CMAKE_BINARY_DIR}/project_description_app2.json"' + f'{app2_extra})\n', + ) + + +@pytest.mark.usefixtures('test_app_copy') +def test_gdbinit_multi_binary_per_executable(idf_py: IdfPyFunc) -> None: + """Each executable in a multi-binary build gets its own gdbinit dir referencing its own elf.""" + logging.info('Testing gdbinit: per-executable output directories in a multi-binary build') + + _write_two_executable_cmakelists( + '\n GDBINIT_DIR "${CMAKE_BINARY_DIR}/gdbinit/app1"', + '\n GDBINIT_DIR "${CMAKE_BINARY_DIR}/gdbinit/app2"', + ) + + idf_py('build') + + app1_symbols = Path('build/gdbinit/app1/symbols') + app2_symbols = Path('build/gdbinit/app2/symbols') + + assert app1_symbols.exists(), 'per-executable gdbinit for app1 was not generated' + assert app2_symbols.exists(), 'per-executable gdbinit for app2 was not generated' + + app1_content = app1_symbols.read_text() + app2_content = app2_symbols.read_text() + + # Each executable's gdbinit must reference its own elf, proving the outputs + # did not overwrite each other in the shared build/gdbinit/ directory. + assert 'app1.elf' in app1_content, f'app1 gdbinit must reference app1.elf, got: {app1_content!r}' + assert 'app2.elf' in app2_content, f'app2 gdbinit must reference app2.elf, got: {app2_content!r}' + assert 'app2.elf' not in app1_content, 'app1 gdbinit was overwritten with app2 symbols' + assert 'app1.elf' not in app2_content, 'app2 gdbinit was overwritten with app1 symbols' + + # Each executable's project_description.json must point gdbinit_files at that + # executable's GDBINIT_DIR, which is what idf.py gdb/debug consumes. + app1_desc = json.loads(Path('build/project_description.json').read_text()) + app2_desc = json.loads(Path('build/project_description_app2.json').read_text()) + app1_symbols_path = app1_desc['gdbinit_files']['01_symbols'] + app2_symbols_path = app2_desc['gdbinit_files']['01_symbols'] + assert app1_symbols_path.endswith('gdbinit/app1/symbols'), app1_symbols_path + assert app2_symbols_path.endswith('gdbinit/app2/symbols'), app2_symbols_path + + +@pytest.mark.usefixtures('test_app_copy') +@pytest.mark.test_app_copy('examples/build_system/cmakev2/features/idf_as_lib', 'idf_as_lib') +def test_gdbinit_add_executable_elf_target(idf_py: IdfPyFunc) -> None: + """A plain add_executable(.elf) target references the ELF that exists on disk.""" + logging.info('Testing gdbinit: add_executable(.elf) target ELF resolution (idf_as_lib pattern)') + + idf_py('build') + + symbols = Path('build/gdbinit/symbols') + assert symbols.exists(), 'build/gdbinit/symbols was not generated' + match = re.search(r'^file (\S+)$', symbols.read_text(), re.MULTILINE) + assert match, f'no application "file" line in gdbinit symbols: {symbols.read_text()!r}' + elf_ref = match.group(1) + + # The executable target is named idf_as_lib.elf (add_executable, no OUTPUT_NAME + # or SUFFIX): the ELF on disk is idf_as_lib.elf, not idf_as_lib.elf.elf, so the + # gdbinit must reference a file that actually exists. + assert Path(elf_ref).name == 'idf_as_lib.elf', f'gdbinit must reference the real ELF, got: {elf_ref!r}' + assert Path(elf_ref).exists(), f'gdbinit references a non-existent ELF: {elf_ref!r}' + + +@pytest.mark.usefixtures('test_app_copy') +@pytest.mark.parametrize( + 'app1_extra, app2_extra', + [ + pytest.param( + '\n GDBINIT_DIR "${CMAKE_BINARY_DIR}/gdbinit/shared"', + '\n GDBINIT_DIR "${CMAKE_BINARY_DIR}/gdbinit/shared"', + id='explicit-shared-dir', + ), + pytest.param('', '', id='both-default-dir'), + ], +) +def test_gdbinit_shared_dir_rejected(idf_py: IdfPyFunc, app1_extra: str, app2_extra: str) -> None: + """Two different executables generating gdbinit into the same directory is rejected at configure time.""" + logging.info('Testing gdbinit: two executables sharing a GDBINIT_DIR is rejected') + + _write_two_executable_cmakelists(app1_extra, app2_extra) + + with pytest.raises(subprocess.CalledProcessError) as exc_info: + idf_py('reconfigure') + err_output = (exc_info.value.stdout or '') + (exc_info.value.stderr or '') + assert 'both generate gdbinit' in err_output, f'expected a GDBINIT_DIR collision error, got: {err_output!r}' + + +@pytest.mark.usefixtures('test_app_copy') +def test_gdbinit_similar_dir_names_not_conflated(idf_py: IdfPyFunc) -> None: + """GDBINIT_DIRs that differ only by punctuation are distinct directories, not a false collision.""" + logging.info('Testing gdbinit: dirs differing only by punctuation are not conflated') + + _write_two_executable_cmakelists( + '\n GDBINIT_DIR "${CMAKE_BINARY_DIR}/gdbinit/app-x"', + '\n GDBINIT_DIR "${CMAKE_BINARY_DIR}/gdbinit/app_x"', + ) + + idf_py('reconfigure') + + assert Path('build/gdbinit/app-x/symbols').exists(), 'gdbinit for app-x was not generated' + assert Path('build/gdbinit/app_x/symbols').exists(), 'gdbinit for app_x was not generated' diff --git a/tools/test_build_system/buildv2/test_multi_binary.py b/tools/test_build_system/buildv2/test_multi_binary.py index 22cf80772f0..ab03542c555 100644 --- a/tools/test_build_system/buildv2/test_multi_binary.py +++ b/tools/test_build_system/buildv2/test_multi_binary.py @@ -48,11 +48,14 @@ def test_multi_binary_all_features(idf_py: IdfPyFunc) -> None: # Build both binaries 'add_custom_target(app ALL DEPENDS app1_bin app2_bin)\n' # Generate metadata for both executables and binaries - 'idf_build_generate_metadata(EXECUTABLE app1)\n' + 'idf_build_generate_metadata(EXECUTABLE app1\n' + ' GDBINIT_DIR "${CMAKE_BINARY_DIR}/gdbinit/app1")\n' 'idf_build_generate_metadata(EXECUTABLE app2\n' - ' OUTPUT_FILE "${CMAKE_BINARY_DIR}/project_description_app2.json")\n' + ' OUTPUT_FILE "${CMAKE_BINARY_DIR}/project_description_app2.json"\n' + ' GDBINIT_DIR "${CMAKE_BINARY_DIR}/gdbinit/app2")\n' 'idf_build_generate_metadata(BINARY app1_bin\n' - ' OUTPUT_FILE "${CMAKE_BINARY_DIR}/project_description_bin.json")\n' + ' OUTPUT_FILE "${CMAKE_BINARY_DIR}/project_description_bin.json"\n' + ' GDBINIT_DIR "${CMAKE_BINARY_DIR}/gdbinit/app1_bin")\n' # Create menuconfig and confserver targets 'idf_create_menuconfig(app1 TARGET menuconfig-app1)\n' 'idf_create_menuconfig(app2 TARGET menuconfig-app2)\n'