diff --git a/.gitlab/CODEOWNERS b/.gitlab/CODEOWNERS index b92f91b3f3c..623605c8bf3 100644 --- a/.gitlab/CODEOWNERS +++ b/.gitlab/CODEOWNERS @@ -245,6 +245,8 @@ /tools/test_apps/**/*.py @esp-idf-codeowners/ci @esp-idf-codeowners/tools /tools/test_build_system/ @esp-idf-codeowners/tools @esp-idf-codeowners/build-config +/tools/test_build_system/buildv2/test_blockdev_ioctl.py @esp-idf-codeowners/storage +/tools/test_build_system/test_blockdev_ioctl.py @esp-idf-codeowners/storage /tools/tools.json @esp-idf-codeowners/tools @esp-idf-codeowners/toolchain @esp-idf-codeowners/debugging diff --git a/.gitlab/ci/host-test.yml b/.gitlab/ci/host-test.yml index bbaf9afcf93..cbed45ef28f 100644 --- a/.gitlab/ci/host-test.yml +++ b/.gitlab/ci/host-test.yml @@ -91,6 +91,14 @@ test_partition_table_on_host: - cd components/partition_table/test_gen_esp32part_host - pytest_for_ut ./gen_esp32part_tests.py +test_blockdev_ioctl_overlap_checker: + extends: + - .host_test_template + - .rules:build:check + script: + - cd components/esp_blockdev/test + - pytest_for_ut ./test_check_ioctl_overlap.py + test_ldgen_on_host: extends: - .host_test_template diff --git a/components/esp_blockdev/CMakeLists.txt b/components/esp_blockdev/CMakeLists.txt index eafb7ac220e..0bbb453a29d 100644 --- a/components/esp_blockdev/CMakeLists.txt +++ b/components/esp_blockdev/CMakeLists.txt @@ -1 +1,8 @@ idf_component_register(INCLUDE_DIRS include) + +# Register this component's own ioctl definitions for overlap checking +idf_build_set_property( + ESP_BLOCKDEV_IOCTL_DEF_FILES + "${CMAKE_CURRENT_LIST_DIR}/include/esp_blockdev.h" + APPEND +) diff --git a/components/esp_blockdev/README.md b/components/esp_blockdev/README.md index d90e22d751f..80cc467978e 100644 --- a/components/esp_blockdev/README.md +++ b/components/esp_blockdev/README.md @@ -97,6 +97,31 @@ and auxiliary operations like getting device statistics or debugging information All the APIs are optional, so if any API function is not available for given device the corresponding pointer is set NULL. +### Ioctl reservation overlap checking + +Components that define ioctl commands can also reserve command values or inclusive ranges for build-time validation. The reservation markers are real macro invocations; comments are ignored. + +Add reservation markers in a public header or other definition file: + +```c +#include "esp_blockdev.h" + +ESP_BLOCKDEV_RESERVE_CMD_RANGE(nand_flash, ESP_BLOCKDEV_CMD_SYSTEM_BASE + 10, ESP_BLOCKDEV_CMD_SYSTEM_BASE + 20); +ESP_BLOCKDEV_RESERVE_CMD(sdcard, ESP_BLOCKDEV_CMD_USER_BASE + 1); +``` + +Register each definition file with the build system: + +```cmake +idf_build_set_property( + ESP_BLOCKDEV_IOCTL_DEF_FILES + "${CMAKE_CURRENT_LIST_DIR}/include/foo_ioctl_defs.h" + APPEND +) +``` + +The esp_blockdev checker component preprocesses every registered file during the build and fails if any reservations overlap or are invalid. + ### Error handling BDL interface doesn't define specific requirements for return values and/or error codes of the API functions declared below. The only expectation is returning ESP_OK on successful run and any sort of ESP_ERR_* on failure (to stay compatible with ESP_ERROR_CHECK and other standard IDF error validation helpers). diff --git a/components/esp_blockdev/include/esp_blockdev.h b/components/esp_blockdev/include/esp_blockdev.h index 96c9532f64d..ef6717c8f62 100644 --- a/components/esp_blockdev/include/esp_blockdev.h +++ b/components/esp_blockdev/include/esp_blockdev.h @@ -25,6 +25,55 @@ extern "C" { * See the in-code comments and README.md for more details. */ +/** + * @brief Register a reserved ioctl command value or inclusive range for overlap checking + * + * These macros are machine-readable markers for the build-time checker. They do not affect + * normal builds unless @c ESP_BLOCKDEV_CHECK_CMD_OVERLAP is defined. + * + * Register reservation declarations in headers or other files that are appended to + * @c ESP_BLOCKDEV_IOCTL_DEF_FILES. + * + * @code{c} + * ESP_BLOCKDEV_RESERVE_CMD_RANGE(nand_flash, ESP_BLOCKDEV_CMD_SYSTEM_BASE + 10, ESP_BLOCKDEV_CMD_SYSTEM_BASE + 20); + * ESP_BLOCKDEV_RESERVE_CMD(sdcard, ESP_BLOCKDEV_CMD_USER_BASE + 1); + * @endcode + */ +/** @cond */ +#ifdef ESP_BLOCKDEV_CHECK_CMD_OVERLAP + +#define ESP_BLOCKDEV_RESERVE_CMD_RANGE(component, start, end) \ + ESP_BLOCKDEV_RESERVE_MARKER(component, (start), (end)) + +#define ESP_BLOCKDEV_RESERVE_CMD(component, start) \ + ESP_BLOCKDEV_RESERVE_CMD_RANGE(component, start, start) + +#else +/** @endcond */ + +/** + * @brief Reserve an inclusive range of ioctl command values for build-time overlap checking + * + * @param component Unquoted identifier naming the owning component + * @param start First command value in the range (0x00–0xFF) + * @param end Last command value in the range (0x00–0xFF, >= start) + */ +#define ESP_BLOCKDEV_RESERVE_CMD_RANGE(component, start, end) + +/** + * @brief Reserve a single ioctl command value for build-time overlap checking + * + * Equivalent to ``ESP_BLOCKDEV_RESERVE_CMD_RANGE(component, start, start)``. + * + * @param component Unquoted identifier naming the owning component + * @param start The command value to reserve (0x00–0xFF) + */ +#define ESP_BLOCKDEV_RESERVE_CMD(component, start) + +/** @cond */ +#endif +/** @endcond */ + /** * @defgroup esp_blockdev_ioctl_cmds Block device ioctl commands * @@ -34,6 +83,8 @@ extern "C" { * @{ */ +/* --- ESP_BLOCKDEV_CMD_ reservation section begin --- */ + #define ESP_BLOCKDEV_CMD_SYSTEM_BASE 0x00 /*!< System commands base value */ #define ESP_BLOCKDEV_CMD_USER_BASE 0x80 /*!< User commands base value */ @@ -76,6 +127,13 @@ extern "C" { */ #define ESP_BLOCKDEV_CMD_ERASE_CONTENTS (ESP_BLOCKDEV_CMD_SYSTEM_BASE + 1) +/** @cond */ +/** Reserve the core esp_blockdev commands for overlap checking */ +ESP_BLOCKDEV_RESERVE_CMD_RANGE(esp_blockdev, ESP_BLOCKDEV_CMD_SYSTEM_BASE, ESP_BLOCKDEV_CMD_SYSTEM_BASE + 1); +/** @endcond */ + +/* --- ESP_BLOCKDEV_CMD_ reservation section end --- */ + /** @} */ /** diff --git a/components/esp_blockdev/project_include.cmake b/components/esp_blockdev/project_include.cmake new file mode 100644 index 00000000000..9dddcad68e9 --- /dev/null +++ b/components/esp_blockdev/project_include.cmake @@ -0,0 +1,52 @@ +set(ESP_BLOCKDEV_COMPONENT_DIR ${CMAKE_CURRENT_LIST_DIR}) + +# Skip bootloader builds — ioctl checking is only relevant for the app. +if(BOOTLOADER_BUILD) + return() +endif() + +# Define the callback function that performs the actual check. +function(__esp_blockdev_ioctl_check_post_elf target) + idf_build_get_property(python PYTHON) + idf_build_get_property(idf_path IDF_PATH) + idf_build_get_property(ioctl_def_files ESP_BLOCKDEV_IOCTL_DEF_FILES) + + if(NOT ioctl_def_files) + return() + endif() + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND ${python} + "${ESP_BLOCKDEV_COMPONENT_DIR}/tools/check_ioctl_overlap.py" + --compiler ${CMAKE_C_COMPILER} + --include-dir "${ESP_BLOCKDEV_COMPONENT_DIR}/include" + --include-dir "${idf_path}/components/esp_common/include" + --include-dir "${CMAKE_BINARY_DIR}/config" + --files ${ioctl_def_files} + COMMENT "Checking ioctl definition overlaps" + VERBATIM + ) +endfunction() + +# Register with the build system. CMakev2 provides a proper build-event API; +# CMakev1 uses cmake_language(DEFER) as a fallback. +if(COMMAND idf_component_register_build_event_callback) + # cmakev2: use the official callback mechanism + idf_component_register_build_event_callback( + EVENT POST_ELF + CALLBACK __esp_blockdev_ioctl_check_post_elf + ) +else() + # cmakev1: defer until the ELF target exists + cmake_language(DEFER DIRECTORY ${CMAKE_SOURCE_DIR} CALL __esp_blockdev_ioctl_check_deferred) +endif() + +function(__esp_blockdev_ioctl_check_deferred) + set(project_elf ${CMAKE_PROJECT_NAME}.elf) + if(NOT TARGET ${project_elf}) + return() + endif() + __esp_blockdev_ioctl_check_post_elf(${project_elf}) +endfunction() diff --git a/components/esp_blockdev/test/test_check_ioctl_overlap.py b/components/esp_blockdev/test/test_check_ioctl_overlap.py new file mode 100644 index 00000000000..8c8ec5cead4 --- /dev/null +++ b/components/esp_blockdev/test/test_check_ioctl_overlap.py @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +COMPONENT_DIR = Path(__file__).resolve().parents[1] +TOOLS_DIR = COMPONENT_DIR / 'tools' +ESP_BLOCKDEV_H = COMPONENT_DIR / 'include' / 'esp_blockdev.h' +ESP_COMMON_INCLUDE = COMPONENT_DIR.parent / 'esp_common' / 'include' + +sys.path.insert(0, str(TOOLS_DIR)) +import check_ioctl_overlap as checker # noqa: E402 + +COMPILER = shutil.which('gcc') or shutil.which('cc') +INCLUDE_DIRS = [str(ESP_COMMON_INCLUDE), str(ESP_BLOCKDEV_H.parent)] + +# The header itself reserves [0x00..0x01] for esp_blockdev core commands. +# Tests that include esp_blockdev.h will see that reservation. +CORE_RESERVATION_END = 0x01 + + +def run_check(contents: str) -> list[checker.Reservation]: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + (tmp_path / 'sdkconfig.h').write_text('/* test stub */\n') + header = tmp_path / 'defs.h' + header.write_text(contents) + include_dirs = [tmp, *INCLUDE_DIRS] + return checker.check_files([header], compiler=COMPILER, include_dirs=include_dirs) # type: ignore[no-any-return] + + +class TestIoctlOverlapChecker(unittest.TestCase): + @unittest.skipUnless(COMPILER, 'C compiler required for preprocessing') + def test_no_overlap(self) -> None: + # Use ranges that don't overlap with core [0x00..0x01] + reservations = run_check( + f'''#include "{ESP_BLOCKDEV_H}" + +#define BASE 0x10 +ESP_BLOCKDEV_RESERVE_CMD_RANGE(nand_flash, BASE + 0, BASE + 0x0F); +ESP_BLOCKDEV_RESERVE_CMD_RANGE(sdcard, BASE + 0x10, BASE + 0x1F); +ESP_BLOCKDEV_RESERVE_CMD(system, 0x40); +''' + ) + # 3 from this file + 1 from esp_blockdev.h core + self.assertEqual(len(reservations), 4) + system_reservations = [r for r in reservations if r.component == 'system'] + self.assertEqual(len(system_reservations), 1) + self.assertEqual(system_reservations[0].start, 0x40) + self.assertEqual(system_reservations[0].end, 0x40) + + @unittest.skipUnless(COMPILER, 'C compiler required for preprocessing') + def test_direct_overlap(self) -> None: + with self.assertRaises(checker.ReservationError) as ctx: + run_check( + f'''#include "{ESP_BLOCKDEV_H}" + +ESP_BLOCKDEV_RESERVE_CMD_RANGE(nand_flash, 0x10, 0x20); +ESP_BLOCKDEV_RESERVE_CMD_RANGE(sdcard, 0x1F, 0x30); +''' + ) + msg = str(ctx.exception) + self.assertIn('ioctl reservation overlap:', msg) + self.assertIn('nand_flash', msg) + self.assertIn('sdcard', msg) + + @unittest.skipUnless(COMPILER, 'C compiler required for preprocessing') + def test_adjacent_non_overlap(self) -> None: + reservations = run_check( + f'''#include "{ESP_BLOCKDEV_H}" + +ESP_BLOCKDEV_RESERVE_CMD_RANGE(a, 0x10, 0x1F); +ESP_BLOCKDEV_RESERVE_CMD_RANGE(b, 0x20, 0x2F); +''' + ) + # 2 from this file + 1 core + self.assertEqual(len(reservations), 3) + + @unittest.skipUnless(COMPILER, 'C compiler required for preprocessing') + def test_single_command_reservation(self) -> None: + reservations = run_check( + f'''#include "{ESP_BLOCKDEV_H}" + +ESP_BLOCKDEV_RESERVE_CMD(single_cmd, 0x42); +ESP_BLOCKDEV_RESERVE_CMD_RANGE(other, 0x43, 0x44); +''' + ) + single = [r for r in reservations if r.component == 'single_cmd'] + self.assertEqual(len(single), 1) + self.assertEqual(single[0].start, 0x42) + self.assertEqual(single[0].end, 0x42) + + @unittest.skipUnless(COMPILER, 'C compiler required for preprocessing') + def test_reversed_range(self) -> None: + with self.assertRaises(checker.ReservationError) as ctx: + run_check( + f'''#include "{ESP_BLOCKDEV_H}" + +ESP_BLOCKDEV_RESERVE_CMD_RANGE(reversed, 0x20, 0x10); +''' + ) + self.assertIn('start 0x20 > end 0x10', str(ctx.exception)) + + @unittest.skipUnless(COMPILER, 'C compiler required for preprocessing') + def test_out_of_range_values(self) -> None: + with self.assertRaises(checker.ReservationError) as ctx: + run_check( + f'''#include "{ESP_BLOCKDEV_H}" + +ESP_BLOCKDEV_RESERVE_CMD_RANGE(big, 0x100, 0x101); +''' + ) + self.assertIn('out of range', str(ctx.exception)) + + @unittest.skipUnless(COMPILER, 'C compiler required for preprocessing') + def test_macro_expanded_constants(self) -> None: + reservations = run_check( + f'''#include "{ESP_BLOCKDEV_H}" + +#define BASE ESP_BLOCKDEV_CMD_SYSTEM_BASE +ESP_BLOCKDEV_RESERVE_CMD_RANGE(range_a, BASE + 10, BASE + 20); +ESP_BLOCKDEV_RESERVE_CMD(single, BASE + 2); +''' + ) + # These overlap with core [0x00..0x01], but single=2 is adjacent (OK), + # range_a=[10..20] is non-overlapping with core. However single=2 > core end=1, OK. + range_a = [r for r in reservations if r.component == 'range_a'] + single = [r for r in reservations if r.component == 'single'] + self.assertEqual(len(range_a), 1) + self.assertEqual(range_a[0].start, 10) + self.assertEqual(range_a[0].end, 20) + self.assertEqual(len(single), 1) + self.assertEqual(single[0].start, 2) + self.assertEqual(single[0].end, 2) + + @unittest.skipUnless(COMPILER, 'C compiler required for preprocessing') + def test_overlap_with_core_commands(self) -> None: + """Reserving a range that overlaps with core [0x00..0x01] must fail.""" + with self.assertRaises(checker.ReservationError) as ctx: + run_check( + f'''#include "{ESP_BLOCKDEV_H}" + +ESP_BLOCKDEV_RESERVE_CMD_RANGE(my_comp, 0x00, 0x05); +''' + ) + msg = str(ctx.exception) + self.assertIn('esp_blockdev', msg) + self.assertIn('my_comp', msg) + + +if __name__ == '__main__': + unittest.main() diff --git a/components/esp_blockdev/tools/check_ioctl_overlap.py b/components/esp_blockdev/tools/check_ioctl_overlap.py new file mode 100644 index 00000000000..63ef47ae032 --- /dev/null +++ b/components/esp_blockdev/tools/check_ioctl_overlap.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +"""Check ESP-Blockdev ioctl reservation overlaps. + +Each registered file is preprocessed with ESP_BLOCKDEV_CHECK_CMD_OVERLAP defined. +The preprocessor expands reservation macros into sentinel marker calls, which are +then parsed and validated here. +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from collections.abc import Iterable +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +MARKER_TOKEN = 'ESP_BLOCKDEV_RESERVE_MARKER' + + +@dataclass(frozen=True) +class Reservation: + component: str + start: int + end: int + source_file: str + + +class ReservationError(Exception): + pass + + +class ExpressionError(ReservationError): + pass + + +class MarkerParseError(ReservationError): + pass + + +def format_hex(value: int) -> str: + return f'0x{value:02X}' + + +def preprocess_file(path: str | Path, compiler: str, include_dirs: Iterable[str]) -> str: + cmd = [compiler, '-E', '-P', '-DESP_BLOCKDEV_CHECK_CMD_OVERLAP'] + for include_dir in include_dirs: + cmd.extend(['-I', include_dir]) + cmd.append(str(path)) + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + raise ReservationError(f'preprocessor failed for {path}:\n{proc.stderr.strip() or proc.stdout.strip()}') + return proc.stdout + + +def _iter_marker_payloads(text: str) -> Iterator[str]: + idx = 0 + while True: + hit = text.find(MARKER_TOKEN, idx) + if hit < 0: + return + pos = hit + len(MARKER_TOKEN) + while pos < len(text) and text[pos].isspace(): + pos += 1 + if pos >= len(text) or text[pos] != '(': + raise MarkerParseError(f'found {MARKER_TOKEN} without argument list') + + depth = 0 + start = pos + 1 + pos = start + while pos < len(text): + ch = text[pos] + if ch == '(': + depth += 1 + elif ch == ')': + if depth == 0: + yield text[start:pos] + idx = pos + 1 + break + depth -= 1 + pos += 1 + else: + raise MarkerParseError(f'unterminated {MARKER_TOKEN} call') + + +def _split_top_level_commas(payload: str) -> list[str]: + parts: list[str] = [] + depth = 0 + start = 0 + for idx, ch in enumerate(payload): + if ch == '(': + depth += 1 + elif ch == ')': + depth -= 1 + if depth < 0: + raise MarkerParseError(f'unbalanced parentheses in {payload!r}') + elif ch == ',' and depth == 0: + parts.append(payload[start:idx].strip()) + start = idx + 1 + parts.append(payload[start:].strip()) + if depth != 0: + raise MarkerParseError(f'unbalanced parentheses in {payload!r}') + return parts + + +_TOKEN_RE = re.compile(r'\s*(0[xX][0-9a-fA-F]+|\d+|[()+-])') + + +class _ExprParser: + def __init__(self, text: str) -> None: + self.text = text + self.pos = 0 + + def parse(self) -> int: + value = self._parse_expr() + self._skip_ws() + if self.pos != len(self.text): + raise ExpressionError(f'unexpected trailing input: {self.text[self.pos :]!r}') + return value + + def _skip_ws(self) -> None: + while self.pos < len(self.text) and self.text[self.pos].isspace(): + self.pos += 1 + + def _peek(self) -> str | None: + self._skip_ws() + if self.pos >= len(self.text): + return None + return self.text[self.pos] + + def _consume(self, ch: str) -> bool: + self._skip_ws() + if self.pos < len(self.text) and self.text[self.pos] == ch: + self.pos += 1 + return True + return False + + def _parse_expr(self) -> int: + value = self._parse_factor() + while True: + if self._consume('+'): + value += self._parse_factor() + elif self._consume('-'): + value -= self._parse_factor() + else: + return value + + def _parse_factor(self) -> int: + if self._consume('+'): + return self._parse_factor() + if self._consume('-'): + return -self._parse_factor() + if self._consume('('): + value = self._parse_expr() + if not self._consume(')'): + raise ExpressionError(f"missing ')' in {self.text!r}") + return value + return self._parse_int() + + def _parse_int(self) -> int: + self._skip_ws() + match = _TOKEN_RE.match(self.text, self.pos) + if not match: + raise ExpressionError(f'expected integer expression near: {self.text[self.pos :]!r}') + token = match.group(1) + self.pos = match.end() + return int(token, 0) + + +def eval_int_expr(expr: str) -> int: + return _ExprParser(expr).parse() + + +def extract_reservations(text: str, source_file: str) -> list[Reservation]: + reservations: list[Reservation] = [] + for payload in _iter_marker_payloads(text): + parts = _split_top_level_commas(payload) + if len(parts) != 3: + raise MarkerParseError(f'expected 3 arguments in {MARKER_TOKEN}, got {len(parts)}: {payload!r}') + component, start_expr, end_expr = parts + start = eval_int_expr(start_expr) + end = eval_int_expr(end_expr) + reservations.append(Reservation(component=component, start=start, end=end, source_file=source_file)) + return reservations + + +def validate_reservations(reservations: Iterable[Reservation]) -> list[str]: + errors: list[str] = [] + items = list(reservations) + + # Deduplicate identical reservations (same component and range from different source files) + seen: set[tuple[str, int, int]] = set() + unique_items: list[Reservation] = [] + for item in items: + key = (item.component, item.start, item.end) + if key not in seen: + seen.add(key) + unique_items.append(item) + items = unique_items + + for item in items: + if item.start > item.end: + errors.append( + f' {item.component}: start {format_hex(item.start)} > end' + f' {format_hex(item.end)} from {item.source_file}' + ) + if not 0 <= item.start <= 0xFF: + errors.append( + f' {item.component}: start {format_hex(item.start)} out of range (0x00..0xFF) from {item.source_file}' + ) + if not 0 <= item.end <= 0xFF: + errors.append( + f' {item.component}: end {format_hex(item.end)} out of range (0x00..0xFF) from {item.source_file}' + ) + + sorted_items = sorted(items, key=lambda r: (r.start, r.end, r.component, r.source_file)) + if not sorted_items: + return errors + + active = sorted_items[0] + for current in sorted_items[1:]: + if current.start <= active.end: + errors.append('ioctl reservation overlap:') + width = max(len(active.component), len(current.component)) + pad_a = ' ' * (width - len(active.component) + 1) + pad_c = ' ' * (width - len(current.component) + 1) + errors.append( + f' {active.component}:{pad_a}' + f'{format_hex(active.start)}..{format_hex(active.end)}' + f' from {active.source_file}' + ) + errors.append( + f' {current.component}:{pad_c}' + f'{format_hex(current.start)}..{format_hex(current.end)}' + f' from {current.source_file}' + ) + if current.end > active.end: + active = current + elif current.end > active.end: + active = current + return errors + + +def check_files(files: Iterable[str | Path], compiler: str, include_dirs: Iterable[str]) -> list[Reservation]: + reservations: list[Reservation] = [] + for file_path in files: + text = preprocess_file(file_path, compiler=compiler, include_dirs=include_dirs) + reservations.extend(extract_reservations(text, source_file=str(file_path))) + errors = validate_reservations(reservations) + if errors: + raise ReservationError('\n'.join(errors)) + return reservations + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + '--compiler', + default=os.environ.get('CC', 'gcc'), + help='C compiler executable used for preprocessing (default: $CC or gcc)', + ) + parser.add_argument( + '--include-dir', action='append', default=[], help='Additional include directory for preprocessing (repeatable)' + ) + parser.add_argument('--files', nargs='+', required=True, help='Reservation definition files to check for overlaps') + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_arg_parser() + args = parser.parse_args(argv) + try: + check_files(args.files, compiler=args.compiler, include_dirs=args.include_dir) + except ReservationError as exc: + print(str(exc), file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/en/api-reference/storage/blockdev.rst b/docs/en/api-reference/storage/blockdev.rst index 1204acbb1d8..c34c2655a69 100644 --- a/docs/en/api-reference/storage/blockdev.rst +++ b/docs/en/api-reference/storage/blockdev.rst @@ -155,6 +155,76 @@ Validation Implementations should include tests that cover alignment checks, flag-driven behaviour (read-only, erase-before-write, NAND-style writes), and correct propagation of errors through stacked devices. Middleware that wraps lower handles must also verify that handle lifetime management remains consistent across the stack. +.. _blockdev-ioctl-management: + +Ioctl Command Management +------------------------ + +The ioctl command space (``0x00``–``0xFF``) is shared across all components in an ESP-IDF project. To prevent silent collisions at integration time, the Block Device Layer provides a build-time overlap checker that validates command reservations across the entire component tree. + +Command Ranges +^^^^^^^^^^^^^^ + +============= ==================================== ============================== +Range Base macro Purpose +============= ==================================== ============================== +``0x00–0x7F`` ``ESP_BLOCKDEV_CMD_SYSTEM_BASE`` ESP-IDF system commands +``0x80–0xFF`` ``ESP_BLOCKDEV_CMD_USER_BASE`` User / component extensions +============= ==================================== ============================== + +Reservation Macros +^^^^^^^^^^^^^^^^^^ + +Components declare their ioctl command allocations using the following macros (defined in ``esp_blockdev.h``): + +.. code-block:: c + + // Reserve an inclusive range of command values + ESP_BLOCKDEV_RESERVE_CMD_RANGE(component, start, end); + + // Reserve exactly one command value (shorthand for start == end) + ESP_BLOCKDEV_RESERVE_CMD(component, start); + +Both ``start`` and ``end`` are inclusive and must satisfy: + +* ``0 <= start <= end <= 255`` + +Example +""""""" + +.. code-block:: c + + #include "esp_blockdev.h" + + /* Reserve system commands 0x0A..0x14 for nand_flash */ + ESP_BLOCKDEV_RESERVE_CMD_RANGE(nand_flash, + ESP_BLOCKDEV_CMD_SYSTEM_BASE + 10, + ESP_BLOCKDEV_CMD_SYSTEM_BASE + 20); + + /* Reserve a single user command for sdcard */ + ESP_BLOCKDEV_RESERVE_CMD(sdcard, ESP_BLOCKDEV_CMD_USER_BASE + 1); + +Registering Definition Files +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Each component that owns ioctl definitions must register the file(s) containing reservation macros with the build system. This is done in the component's ``CMakeLists.txt``: + +.. code-block:: cmake + + idf_build_set_property( + ESP_BLOCKDEV_IOCTL_DEF_FILES + "${CMAKE_CURRENT_LIST_DIR}/include/my_ioctl_defs.h" + APPEND + ) + +The ``esp_blockdev`` component itself registers its own header, so core commands (``ESP_BLOCKDEV_CMD_MARK_DELETED``, ``ESP_BLOCKDEV_CMD_ERASE_CONTENTS``) are always checked. + +When an overlap or invalid range is detected, the build fails with a diagnostic showing the conflicting component names, hex ranges, and source file paths. + +.. note:: + + Adjacent ranges are valid. For example ``0x10..0x1F`` and ``0x20..0x2F`` do **not** overlap. + .. _blockdev-apis: API Reference diff --git a/tools/test_build_system/buildv2/test_blockdev_ioctl.py b/tools/test_build_system/buildv2/test_blockdev_ioctl.py new file mode 100644 index 00000000000..dc398d1be5b --- /dev/null +++ b/tools/test_build_system/buildv2/test_blockdev_ioctl.py @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +"""Build-system integration tests for the esp_blockdev ioctl overlap checker (cmakev2).""" + +from pathlib import Path + +from test_build_system_helpers import IdfPyFunc +from test_build_system_helpers import replace_in_file + +COMP_A_DEFS_NO_OVERLAP = """\ +#include "esp_blockdev.h" + +ESP_BLOCKDEV_RESERVE_CMD_RANGE(comp_a, 0x10, 0x1F); +""" + +COMP_B_DEFS_NO_OVERLAP = """\ +#include "esp_blockdev.h" + +ESP_BLOCKDEV_RESERVE_CMD_RANGE(comp_b, 0x20, 0x2F); +""" + +COMP_B_DEFS_OVERLAP = """\ +#include "esp_blockdev.h" + +ESP_BLOCKDEV_RESERVE_CMD_RANGE(comp_b, 0x18, 0x2F); +""" + +COMPONENT_CMAKELISTS = """\ +idf_component_register() + +idf_build_set_property( + ESP_BLOCKDEV_IOCTL_DEF_FILES + "${{CMAKE_CURRENT_LIST_DIR}}/include/{filename}" + APPEND +) +""" + + +def _add_component(app_path: Path, name: str, defs_content: str) -> None: + comp_dir = app_path / 'components' / name + comp_dir.mkdir(parents=True, exist_ok=True) + include_dir = comp_dir / 'include' + include_dir.mkdir(exist_ok=True) + + filename = f'{name}_ioctl_defs.h' + (include_dir / filename).write_text(defs_content) + (comp_dir / 'CMakeLists.txt').write_text(COMPONENT_CMAKELISTS.format(filename=filename)) + + +def _wire_components(app_path: Path) -> None: + replace_in_file( + app_path / 'main' / 'CMakeLists.txt', + '# placeholder_inside_idf_component_register', + 'REQUIRES esp_blockdev comp_a comp_b\n # placeholder_inside_idf_component_register', + ) + + +def test_ioctl_overlap_checker_passes_clean_build_v2(idf_py: IdfPyFunc, test_app_copy: Path) -> None: + """Build succeeds when registered ioctl ranges do not overlap (cmakev2).""" + _add_component(test_app_copy, 'comp_a', COMP_A_DEFS_NO_OVERLAP) + _add_component(test_app_copy, 'comp_b', COMP_B_DEFS_NO_OVERLAP) + _wire_components(test_app_copy) + + ret = idf_py('build') + assert ret.returncode == 0 + + +def test_ioctl_overlap_checker_fails_on_overlap_v2(idf_py: IdfPyFunc, test_app_copy: Path) -> None: + """Build fails when registered ioctl ranges overlap (cmakev2).""" + _add_component(test_app_copy, 'comp_a', COMP_A_DEFS_NO_OVERLAP) + _add_component(test_app_copy, 'comp_b', COMP_B_DEFS_OVERLAP) + _wire_components(test_app_copy) + + ret = idf_py('build', check=False) + assert ret.returncode != 0 + output = ret.stdout + ret.stderr + assert 'ioctl reservation overlap' in output + assert 'comp_a' in output + assert 'comp_b' in output diff --git a/tools/test_build_system/test_blockdev_ioctl.py b/tools/test_build_system/test_blockdev_ioctl.py new file mode 100644 index 00000000000..6de3981d086 --- /dev/null +++ b/tools/test_build_system/test_blockdev_ioctl.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +"""Build-system integration tests for the esp_blockdev ioctl overlap checker.""" + +import logging +from pathlib import Path + +import pytest +from test_build_system_helpers import IdfPyFunc +from test_build_system_helpers import replace_in_file + +COMP_A_DEFS_NO_OVERLAP = """\ +#include "esp_blockdev.h" + +ESP_BLOCKDEV_RESERVE_CMD_RANGE(comp_a, 0x10, 0x1F); +""" + +COMP_B_DEFS_NO_OVERLAP = """\ +#include "esp_blockdev.h" + +ESP_BLOCKDEV_RESERVE_CMD_RANGE(comp_b, 0x20, 0x2F); +""" + +COMP_B_DEFS_OVERLAP = """\ +#include "esp_blockdev.h" + +ESP_BLOCKDEV_RESERVE_CMD_RANGE(comp_b, 0x18, 0x2F); +""" + +COMPONENT_CMAKELISTS = """\ +idf_component_register() + +idf_build_set_property( + ESP_BLOCKDEV_IOCTL_DEF_FILES + "${{CMAKE_CURRENT_LIST_DIR}}/include/{filename}" + APPEND +) +""" + + +def _add_component(app_path: Path, name: str, defs_content: str) -> None: + comp_dir = app_path / 'components' / name + comp_dir.mkdir(parents=True, exist_ok=True) + include_dir = comp_dir / 'include' + include_dir.mkdir(exist_ok=True) + + filename = f'{name}_ioctl_defs.h' + (include_dir / filename).write_text(defs_content) + (comp_dir / 'CMakeLists.txt').write_text(COMPONENT_CMAKELISTS.format(filename=filename)) + + +def _wire_components(app_path: Path) -> None: + replace_in_file( + app_path / 'main' / 'CMakeLists.txt', + '# placeholder_inside_idf_component_register', + 'REQUIRES esp_blockdev comp_a comp_b\n # placeholder_inside_idf_component_register', + ) + + +@pytest.mark.usefixtures('test_app_copy') +def test_ioctl_overlap_checker_passes_clean_build(idf_py: IdfPyFunc, test_app_copy: Path) -> None: + """Build succeeds when registered ioctl ranges do not overlap.""" + logging.info('Testing ioctl overlap checker with non-overlapping ranges') + _add_component(test_app_copy, 'comp_a', COMP_A_DEFS_NO_OVERLAP) + _add_component(test_app_copy, 'comp_b', COMP_B_DEFS_NO_OVERLAP) + _wire_components(test_app_copy) + + ret = idf_py('build') + assert ret.returncode == 0 + + +@pytest.mark.usefixtures('test_app_copy') +def test_ioctl_overlap_checker_fails_on_overlap(idf_py: IdfPyFunc, test_app_copy: Path) -> None: + """Build fails when registered ioctl ranges overlap.""" + logging.info('Testing ioctl overlap checker detects overlapping ranges') + _add_component(test_app_copy, 'comp_a', COMP_A_DEFS_NO_OVERLAP) + _add_component(test_app_copy, 'comp_b', COMP_B_DEFS_OVERLAP) + _wire_components(test_app_copy) + + ret = idf_py('build', check=False) + assert ret.returncode != 0 + output = ret.stdout + ret.stderr + assert 'ioctl reservation overlap' in output + assert 'comp_a' in output + assert 'comp_b' in output