mirror of
https://github.com/espressif/esp-idf.git
synced 2026-09-22 13:01:16 +03:00
Merge branch 'feat/bdl_ioctl_checker' into 'master'
feat(esp_blockdev): add build-time ioctl command reservation overlap checker Closes IDF-15799 See merge request espressif/esp-idf!49670
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 --- */
|
||||
|
||||
/** @} */
|
||||
|
||||
/**
|
||||
|
||||
52
components/esp_blockdev/project_include.cmake
Normal file
52
components/esp_blockdev/project_include.cmake
Normal 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()
|
||||
157
components/esp_blockdev/test/test_check_ioctl_overlap.py
Normal file
157
components/esp_blockdev/test/test_check_ioctl_overlap.py
Normal 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()
|
||||
286
components/esp_blockdev/tools/check_ioctl_overlap.py
Normal file
286
components/esp_blockdev/tools/check_ioctl_overlap.py
Normal 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())
|
||||
@@ -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
|
||||
|
||||
79
tools/test_build_system/buildv2/test_blockdev_ioctl.py
Normal file
79
tools/test_build_system/buildv2/test_blockdev_ioctl.py
Normal file
@@ -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
|
||||
85
tools/test_build_system/test_blockdev_ioctl.py
Normal file
85
tools/test_build_system/test_blockdev_ioctl.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user