feat(esp_blockdev): add build-time ioctl command reservation overlap checker

(cherry picked from commit a9f545bcea)
This commit is contained in:
Tomáš Rohlínek
2026-06-15 13:40:34 +02:00
committed by Martin Vychodil
parent 93056b5253
commit 0188e1fe7b
10 changed files with 805 additions and 0 deletions

View File

@@ -71,6 +71,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
script:

View File

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

View File

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

View File

@@ -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 (0x000xFF)
* @param end Last command value in the range (0x000xFF, >= 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 (0x000xFF)
*/
#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 --- */
/** @} */
/**

View File

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

View File

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

View File

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

View File

@@ -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
============= ==================================== ==============================
``0x000x7F`` ``ESP_BLOCKDEV_CMD_SYSTEM_BASE`` ESP-IDF system commands
``0x800xFF`` ``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

View File

@@ -0,0 +1,68 @@
# 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
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 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)
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)
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

View File

@@ -0,0 +1,74 @@
# 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
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))
@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)
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)
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