diff --git a/Documentation/config-yaml.rst b/Documentation/config-yaml.rst index 294f1ba..3b66322 100644 --- a/Documentation/config-yaml.rst +++ b/Documentation/config-yaml.rst @@ -310,6 +310,8 @@ configuration section: config: cwd: './external' build_dir: './build' # Build output directory + nuttx_dir: './external/nuttx' # Optional explicit NuttX tree + apps_dir: './external/nuttx-apps' # Optional explicit apps tree build_env: # Optional env vars for cmake configure/build CC: gcc-14 CXX: g++-14 @@ -350,6 +352,7 @@ Flash command can use special tags that are handled by NTFC: - ``$IMAGE_BIN`` is replaced by path to ``nuttx.bin``. - ``$IMAGE_HEX`` is replaced by path to ``nuttx.hex``. +- ``$IMAGE_ELF`` is replaced by path to the core image (``elf_path``). Example usage with ``st-flash`` tool: @@ -414,6 +417,8 @@ These fields are parsed by :class:`ntfc.coreconfig.CoreConfig`. - Human-readable core name * - ``device`` - Device type: ``sim``, ``qemu``, or ``serial`` + * - ``os`` + - Target shell type: ``nuttx`` (default) or ``linux`` * - ``exec_path`` - QEMU executable name or serial port device (``/dev/ttyACM0``, ``COM1``, etc.) @@ -426,6 +431,9 @@ These fields are parsed by :class:`ntfc.coreconfig.CoreConfig`. * - ``boot_timeout`` - (Optional) Seconds to wait for the first shell prompt after device start. Defaults to ``5`` + * - ``read_poll_interval`` + - (Optional) Console polling interval in seconds used when reading + device output. Must be positive. Defaults to ``0.1`` * - ``app_bindir`` - (Optional) Directory with kernel-mode application binaries. Defaults to the ``bin/`` directory next to the NuttX ELF for kernel-mode @@ -455,3 +463,34 @@ These fields are parsed by :class:`ntfc.coreconfig.CoreConfig`. * - ``kv`` - Per-core Kconfig overrides applied before build. Overrides matching keys from global ``config.kv`` + +Linux Targets +============= + +Setting ``os: linux`` on a core switches the shell abstraction from NuttX +to Linux: shell prompt (``#`` by default), command-not-found marker, +``poweroff``/``reboot``/``uname`` commands and kernel crash signatures. +This allows running the same test suites against Linux and NuttX, which is +useful for comparing the two systems (e.g. benchmarks). + +Linux images are pre-built, so NTFC does not build them: point ``elf_path`` +at the kernel image and pass boot arguments via ``exec_args`` (with the +``$IMAGE_ELF`` placeholder) or a ``flash`` command. ELF symbol parsing and +NuttX core topology discovery are skipped for Linux cores, which also means +the ``cmd_check`` pytest marker is not supported on them. + +Example QEMU Linux core: + +.. code-block:: yaml + + cores: + core0: + name: 'linux' + os: 'linux' + device: 'qemu' + exec_path: 'qemu-system-x86_64' + exec_args: '-M q35 -m 2G -nographic -kernel $IMAGE_ELF + -initrd ./initramfs.img -append "console=ttyS0"' + elf_path: './bzImage' + prompt: '# ' + boot_timeout: 60 diff --git a/src/ntfc/builder.py b/src/ntfc/builder.py index ace5eeb..6e104f5 100644 --- a/src/ntfc/builder.py +++ b/src/ntfc/builder.py @@ -39,6 +39,7 @@ class NuttXBuilder: IMAGE_BIN_STR = "$IMAGE_BIN" IMAGE_HEX_STR = "$IMAGE_HEX" + IMAGE_ELF_STR = "$IMAGE_ELF" _KCONFIG_DISABLED_RE = re.compile( r"^#\s+(CONFIG_[A-Za-z0-9_]+)\s+is not set" ) @@ -100,6 +101,12 @@ def _get_cmake_defines( {str(key): str(val) for key, val in custom_defines.items()} ) + apps_dir = self._cfg_values.get("config", {}).get("apps_dir") + if apps_dir: + defines["NUTTX_APPS_DIR"] = os.path.abspath( + os.path.expanduser(os.path.expandvars(str(apps_dir))) + ) + return defines def _get_kconfig_overrides( @@ -348,6 +355,20 @@ def _run_build( self._run_command(cmd, env=run_env) + def _run_build_target( + self, + build: str, + target: str, + env: Optional[Dict[str, str]] = None, + ) -> None: + """Run one named CMake build target.""" + cmd = ["cmake", "--build", str(Path(build)), "--target", target] + run_env = os.environ.copy() + if env: + run_env.update(env) + + self._run_command(cmd, env=run_env) + def _build_core( self, core: str, cores: Dict[str, Any], product: str ) -> None: @@ -371,13 +392,24 @@ def _build_core( if not cfg_cwd: # pragma: no cover raise BuilderConfigError("not found cwd in YAML configuration") + cfg_build_dir = os.path.expanduser( + os.path.expandvars(str(cfg_build_dir)) + ) build_path = os.path.join(cfg_build_dir, build_dir) build_cfg = cores[core]["defconfig"] logger.info( f"build image " f"conf: {build_cfg}, out: {build_path}" ) - nuttx_dir = os.path.join(cfg_cwd, "nuttx") + cfg_cwd = os.path.expanduser(os.path.expandvars(str(cfg_cwd))) + configured_nuttx_dir = self._cfg_values["config"].get("nuttx_dir") + nuttx_dir = ( + os.path.expanduser( + os.path.expandvars(str(configured_nuttx_dir)) + ) + if configured_nuttx_dir + else os.path.join(cfg_cwd, "nuttx") + ) nuttx_elf_path = os.path.join(build_path, "nuttx") nuttx_conf_path = os.path.join(build_path, ".config") @@ -410,6 +442,15 @@ def _build_core( nuttx_conf_path, kv_overrides, cfg_cwd ) + # Regenerate include/nuttx/config.h after changing .config. + # Otherwise CMake can relink an image built with stale Kconfig + # values while the saved .config claims the override applied. + + if kv_overrides: + self._run_build_target( + build_path, "olddefconfig", env=build_env + ) + # build self._run_build(build_path, env=build_env) @@ -439,6 +480,7 @@ def _flash_core( flash_cmd = flash_cmd.replace(self.IMAGE_BIN_STR, image_bin) flash_cmd = flash_cmd.replace(self.IMAGE_HEX_STR, image_hex) + flash_cmd = flash_cmd.replace(self.IMAGE_ELF_STR, str(img_path)) cmd = flash_cmd.split() diff --git a/src/ntfc/core.py b/src/ntfc/core.py index e51376a..239b541 100644 --- a/src/ntfc/core.py +++ b/src/ntfc/core.py @@ -334,7 +334,7 @@ def get_core_info(self) -> Tuple[str, ...]: def init(self) -> None: """Finish product initialization.""" - cores = self.get_core_info() + cores = self.get_core_info() if self._conf.os == "nuttx" else () self._core0 = cores[0] if cores else "core0" self._cur_core = self._core0 self._cores = cores if cores else ("core0",) diff --git a/src/ntfc/coreconfig.py b/src/ntfc/coreconfig.py index 9f7f99d..263725a 100644 --- a/src/ntfc/coreconfig.py +++ b/src/ntfc/coreconfig.py @@ -42,7 +42,7 @@ def __init__(self, cfg: Dict[str, Any]) -> None: self._load_core_config() elf_path = self._config.get("elf_path", None) - if elf_path: + if elf_path and self.os == "nuttx": # load ELF self._elf = ElfParser(elf_path) @@ -86,11 +86,24 @@ def uptime(self) -> Any: """Return core uptime.""" return self._config.get("uptime", 3) + @property + def read_poll_interval(self) -> float: + """Return console polling interval in seconds.""" + value = float(self._config.get("read_poll_interval", 0.1)) + if value <= 0: + raise ValueError("read_poll_interval must be positive") + return value + @property def device(self) -> Any: """Return core device.""" return self._config.get("device", None) + @property + def os(self) -> str: + """Return target operating system name.""" + return str(self._config.get("os", "nuttx")).lower() + @property def name(self) -> Any: """Return core name.""" diff --git a/src/ntfc/device/common.py b/src/ntfc/device/common.py index 1c714b4..d4d3dfc 100644 --- a/src/ntfc/device/common.py +++ b/src/ntfc/device/common.py @@ -72,7 +72,7 @@ def __init__(self, conf: "CoreConfig", echo: bool = True): ) self.clear_fault_flags() - self._read_all_sleep = 0.1 + self._read_all_sleep = conf.read_poll_interval self._has_echo = echo self._start_time: Optional[float] = None self._output_tail_buf = bytearray() @@ -273,7 +273,7 @@ def _read_until_pattern_loop( # noqa: C901 ret = CmdStatus.TIMEOUT while True: - chunk = self._read_all(0.1) + chunk = self._read_all(self._read_all_sleep) output += chunk self._console_log(chunk) diff --git a/src/ntfc/device/getos.py b/src/ntfc/device/getos.py index f387721..a2f621d 100644 --- a/src/ntfc/device/getos.py +++ b/src/ntfc/device/getos.py @@ -22,6 +22,7 @@ from typing import TYPE_CHECKING +from .linux import DeviceLinux from .nuttx import DeviceNuttx if TYPE_CHECKING: @@ -36,4 +37,11 @@ def get_os(conf: "CoreConfig") -> "OSCommon": """Get OS abstraction.""" - return DeviceNuttx(conf) # only NuttX supported now + operating_systems = { + "linux": DeviceLinux, + "nuttx": DeviceNuttx, + } + factory = operating_systems.get(conf.os) + if factory is None: + raise ValueError(f"unsupported operating system: {conf.os}") + return factory(conf) diff --git a/src/ntfc/device/linux.py b/src/ntfc/device/linux.py new file mode 100644 index 0000000..c9312f9 --- /dev/null +++ b/src/ntfc/device/linux.py @@ -0,0 +1,87 @@ +############################################################################ +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +"""Linux shell abstraction for serial and QEMU test devices.""" + +from typing import TYPE_CHECKING, Dict, List + +from ntfc.device.state import CrashType + +from .oscommon import OSCommon + +if TYPE_CHECKING: + from ntfc.coreconfig import CoreConfig + + +class DeviceLinux(OSCommon): + """Describe the Linux shell contract used by NTFC devices.""" + + _PROMPT = b"#" + _CRASH_SIGNATURES: Dict[CrashType, List[bytes]] = { + CrashType.PANIC: [ + b"Kernel panic - not syncing", + b"not syncing: Fatal exception", + ], + CrashType.ASSERTION: [b"BUG: unable to handle kernel"], + } + + def __init__(self, conf: "CoreConfig") -> None: + """Initialize the Linux shell abstraction.""" + self._prompt = conf.prompt.encode() if conf.prompt else self._PROMPT + + @property + def prompt(self) -> bytes: + """Get shell prompt.""" + return self._prompt + + @property + def no_cmd(self) -> str: + """Get command-not-found marker.""" + return "not found" + + @property + def help_cmd(self) -> bytes: + """Get shell help command.""" + return b"help" + + @property + def poweroff_cmd(self) -> bytes: + """Get shell poweroff command.""" + return b"poweroff" + + @property + def reboot_cmd(self) -> bytes: + """Get shell reboot command.""" + return b"reboot" + + @property + def uname_cmd(self) -> bytes: + """Get operating-system identification command.""" + return b"uname -s" + + @property + def crash_signatures(self) -> Dict[CrashType, List[bytes]]: + """Get Linux kernel crash signatures.""" + return self._CRASH_SIGNATURES + + @property + def panic_char(self) -> str: + """Linux does not define a console panic character here.""" + return "" diff --git a/tests/device/test_common.py b/tests/device/test_common.py index a8f848b..80c810e 100644 --- a/tests/device/test_common.py +++ b/tests/device/test_common.py @@ -33,10 +33,14 @@ class DeviceMock(DeviceCommon): - def __init__(self, _): + def __init__(self, config): """Mock.""" - DeviceCommon.__init__(self, _) + if not isinstance(config.os, str): + config.os = "nuttx" + if not isinstance(config.read_poll_interval, (int, float)): + config.read_poll_interval = 0.1 + DeviceCommon.__init__(self, config) def _read(self, _=0): """Mock.""" @@ -101,6 +105,11 @@ def test_device_common_init(): assert d.busyloop is False assert d.flood is False + config.os = "nuttx" + config.read_poll_interval = 0.01 + explicit = DeviceMock(config) + assert explicit._read_all_sleep == 0.01 + def test_device_common_send_cmd_pattern(): @@ -223,6 +232,19 @@ def test_device_common_read_until_pattern(): dev.read_until_pattern("PASS", 10) +def test_device_common_read_until_pattern_uses_configured_poll_interval(): + with patch("ntfc.envconfig.EnvConfig") as mockdevice: + config = mockdevice.return_value + config.read_poll_interval = 0.001 + dev = DeviceMock(config) + + with patch.object(dev, "_read_all", return_value=b"PASS") as read_all: + ret = dev.read_until_pattern(b"PASS", 1) + + assert ret.status == CmdStatus.SUCCESS + read_all.assert_called_once_with(0.001) + + def test_device_common_panic_char(): with patch("ntfc.device.common.get_os") as mock_get_os: diff --git a/tests/device/test_linux.py b/tests/device/test_linux.py new file mode 100644 index 0000000..d6a3a3b --- /dev/null +++ b/tests/device/test_linux.py @@ -0,0 +1,56 @@ +############################################################################ +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +import pytest + +from ntfc.coreconfig import CoreConfig +from ntfc.device.getos import get_os +from ntfc.device.linux import DeviceLinux +from ntfc.device.nuttx import DeviceNuttx +from ntfc.device.state import CrashType + + +def test_linux_shell_contract() -> None: + device = DeviceLinux( + CoreConfig({"name": "linux", "os": "linux", "prompt": "rtbench# "}) + ) + + assert device.prompt == b"rtbench# " + assert device.no_cmd == "not found" + assert device.help_cmd == b"help" + assert device.poweroff_cmd == b"poweroff" + assert device.reboot_cmd == b"reboot" + assert device.uname_cmd == b"uname -s" + assert device.panic_char == "" + assert CrashType.PANIC in device.crash_signatures + + +def test_get_os_defaults_to_nuttx_and_selects_linux() -> None: + assert isinstance(get_os(CoreConfig({"name": "default"})), DeviceNuttx) + assert isinstance( + get_os(CoreConfig({"name": "linux", "os": "linux"})), DeviceLinux + ) + + +def test_get_os_rejects_unknown_operating_system() -> None: + with pytest.raises( + ValueError, match="unsupported operating system: plan9" + ): + get_os(CoreConfig({"name": "unknown", "os": "plan9"})) diff --git a/tests/device/test_qemu.py b/tests/device/test_qemu.py index aed9d1c..ac062fb 100644 --- a/tests/device/test_qemu.py +++ b/tests/device/test_qemu.py @@ -30,6 +30,8 @@ def test_device_qemu_open(): with patch("ntfc.coreconfig.CoreConfig") as mockdevice: config = mockdevice.return_value + config.os = "nuttx" + config.read_poll_interval = 0.1 config.exec_path = "" config.exec_args = "" config.elf_path = "" diff --git a/tests/device/test_sim.py b/tests/device/test_sim.py index d722058..f9523c3 100644 --- a/tests/device/test_sim.py +++ b/tests/device/test_sim.py @@ -30,6 +30,8 @@ def test_device_sim_init(): with patch("ntfc.coreconfig.CoreConfig") as mockdevice: config = mockdevice.return_value + config.os = "nuttx" + config.read_poll_interval = 0.1 config.elf_path = "" sim = DeviceSim(config) @@ -42,6 +44,8 @@ def test_device_sim_init(): def test_device_sim_start_opens_host(): with patch("ntfc.coreconfig.CoreConfig") as mockdevice: config = mockdevice.return_value + config.os = "nuttx" + config.read_poll_interval = 0.1 config.elf_path = "/tmp/nuttx-sim" config.uptime = 3 sim = DeviceSim(config) @@ -63,6 +67,8 @@ def fake_host_open(cmd, uptime): def test_device_sim_write_adds_newline(): with patch("ntfc.coreconfig.CoreConfig") as mockdevice: config = mockdevice.return_value + config.os = "nuttx" + config.read_poll_interval = 0.1 config.elf_path = "/tmp/nuttx-sim" sim = DeviceSim(config) @@ -85,6 +91,8 @@ def send(self, data): def test_device_sim_write_no_extra_newline(): with patch("ntfc.coreconfig.CoreConfig") as mockdevice: config = mockdevice.return_value + config.os = "nuttx" + config.read_poll_interval = 0.1 config.elf_path = "/tmp/nuttx-sim" sim = DeviceSim(config) diff --git a/tests/test_builder.py b/tests/test_builder.py index 0ed6d65..1fb4ab8 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -105,6 +105,47 @@ def run_command_capture(cmd, env): assert calls[1][1]["CXX"] == "g++-14" +def test_builder_regenerates_config_after_kconfig_overrides() -> None: + config = copy.deepcopy(conf_dir) + config["product"]["cores"]["core0"]["defconfig"] = "dummy/path" + config["product"]["cores"]["core0"]["kv"] = { + "CONFIG_ARCH_X86_64_IDLE_NOP": True + } + calls = [] + + builder = NuttXBuilder(config) + builder._run_command = lambda command, env: calls.append(command) + builder._make_dir = builder_make_dir_dummy + builder._apply_kconfig_overrides = lambda *_args: None + builder.build_all() + + assert calls[1] == [ + "cmake", + "--build", + "bbb/product-xxx-dummy", + "--target", + "olddefconfig", + ] + assert calls[2] == ["cmake", "--build", "bbb/product-xxx-dummy"] + + +def test_builder_run_build_target_passes_env() -> None: + builder = NuttXBuilder(copy.deepcopy(conf_dir)) + calls = [] + builder._run_command = lambda command, env: calls.append((command, env)) + + builder._run_build_target("build-dir", "olddefconfig", {"CC": "gcc-13"}) + + assert calls[0][0] == [ + "cmake", + "--build", + "build-dir", + "--target", + "olddefconfig", + ] + assert calls[0][1]["CC"] == "gcc-13" + + def test_builder_ignores_invalid_build_env_types() -> None: config = copy.deepcopy(conf_dir) config["config"]["build_env"] = "CC=gcc-14 CXX=g++-14" @@ -147,6 +188,33 @@ def test_builder_get_cmake_defines_ignores_invalid_type() -> None: } +def test_builder_accepts_explicit_nuttx_and_apps_directories(tmp_path) -> None: + config = copy.deepcopy(conf_dir) + build_dir = tmp_path / "build" + nuttx_dir = tmp_path / "source" / "nuttx-custom" + apps_dir = tmp_path / "source" / "apps-custom" + nuttx_dir.mkdir(parents=True) + apps_dir.mkdir(parents=True) + config["config"].update( + { + "build_dir": str(build_dir), + "nuttx_dir": str(nuttx_dir), + "apps_dir": str(apps_dir), + } + ) + config["product"]["cores"]["core0"]["defconfig"] = "board:test" + calls = [] + + builder = NuttXBuilder(config) + builder._run_command = lambda command, env: calls.append(command) + builder._make_dir = lambda _path: None + builder.build_all() + + configure = calls[0] + assert f"-S{nuttx_dir}" in configure + assert f"-DNUTTX_APPS_DIR={apps_dir}" in configure + + def test_builder_kconfig_helpers() -> None: b = NuttXBuilder(copy.deepcopy(conf_dir)) core_cfg = {"kv": {"CONFIG_CORE": "m"}} @@ -454,9 +522,11 @@ def fake_apply_with_tool(conf_path, overrides, _cfg_cwd): with patch("ntfc.builder.logger.info", side_effect=logs.append): b.build_all() - assert len(calls) == 2 + assert len(calls) == 3 assert calls[0][0] == "cmake" assert calls[1][:2] == ["cmake", "--build"] + assert calls[1][-2:] == ["--target", "olddefconfig"] + assert calls[2][:2] == ["cmake", "--build"] assert any( "Applying Kconfig overrides before build:" == msg for msg in logs ) @@ -484,3 +554,19 @@ def test_builder_raises_when_cwd_missing() -> None: BuilderConfigError, match="not found cwd in YAML configuration" ): b.build_all() + + +def test_builder_flash_supports_elf_placeholder(tmp_path) -> None: + image = tmp_path / "nuttx" + image.write_bytes(b"elf") + core = { + "elf_path": str(image), + "flash": "pxe-stage $IMAGE_ELF", + } + commands = [] + builder = NuttXBuilder(copy.deepcopy(conf_dir)) + builder._run_command = lambda command, env: commands.append(command) + + builder._flash_core("core0", {"core0": core}) + + assert commands == [["pxe-stage", str(image)]] diff --git a/tests/test_coreconfig.py b/tests/test_coreconfig.py index f4e80f2..082d3f0 100644 --- a/tests/test_coreconfig.py +++ b/tests/test_coreconfig.py @@ -165,6 +165,36 @@ def test_core_config_app_bindir(tmp_path): assert CoreConfig(conf).app_bindir is None +def test_core_config_read_poll_interval() -> None: + assert CoreConfig({"name": "test"}).read_poll_interval == 0.1 + assert ( + CoreConfig( + {"name": "test", "read_poll_interval": 0.001} + ).read_poll_interval + == 0.001 + ) + with pytest.raises(ValueError, match="must be positive"): + _ = CoreConfig( + {"name": "test", "read_poll_interval": 0} + ).read_poll_interval + + +def test_core_config_os_defaults_to_nuttx() -> None: + assert CoreConfig({"name": "test"}).os == "nuttx" + assert CoreConfig({"name": "test", "os": "Linux"}).os == "linux" + + +def test_linux_kernel_image_does_not_require_elf_symbols(tmp_path) -> None: + image = tmp_path / "bzImage" + image.write_bytes(b"not-an-elf") + + core = CoreConfig({"name": "linux", "os": "linux", "elf_path": str(image)}) + + assert core.elf_path == str(image) + with pytest.raises(AttributeError): + core.cmd_check("rtbench") + + def test_core_config_prompt(): # Test with explicit prompt in YAML config conf = {