mail archive of the barebox mailing list
 help / color / mirror / Atom feed
* [PATCH 1/5] scripts: duplicate conftest.py as qemu_interactive.py
@ 2026-06-26 12:45 Ahmad Fatoum
  2026-06-26 12:45 ` [PATCH 2/5] test: move interactive QEMU launcher out of pytest Ahmad Fatoum
                   ` (3 more replies)
  0 siblings, 4 replies; 5+ messages in thread
From: Ahmad Fatoum @ 2026-06-26 12:45 UTC (permalink / raw)
  To: barebox; +Cc: Ahmad Fatoum

To make the change splitting pytest --interactive into its own script
easier to follow, duplicate the existing code under the new name.

No functional change.

Signed-off-by: Ahmad Fatoum <a.fatoum@barebox.org>
---
Change can be squashed into follow-up commit. This doesn't yet work,
but doesn't break anything.
---
 scripts/qemu_interactive.py | 304 ++++++++++++++++++++++++++++++++++++
 1 file changed, 304 insertions(+)
 create mode 100644 scripts/qemu_interactive.py

diff --git a/scripts/qemu_interactive.py b/scripts/qemu_interactive.py
new file mode 100644
index 000000000000..6f76586e010b
--- /dev/null
+++ b/scripts/qemu_interactive.py
@@ -0,0 +1,304 @@
+# SPDX-License-Identifier: GPL-2.0-only
+import pytest
+import os
+import argparse
+from labgrid.exceptions import NoDriverFoundError
+from test.py import helper
+
+
+def transition_to_barebox(request, strategy):
+    try:
+        strategy.transition('barebox')
+    except Exception as e:
+        # If a normal strategy transition fails, there's no point in
+        # continuing the test. Let's print stderr and exit.
+        capmanager = request.config.pluginmanager.getplugin("capturemanager")
+        with capmanager.global_and_fixture_disabled():
+            _, stderr = capmanager.read_global_capture()
+            pytest.exit(f"{type(e).__name__}(\"{e}\"). Standard error:\n{stderr}",
+                        returncode=3)
+
+
+@pytest.fixture(scope='function')
+def barebox(request, strategy, target):
+    transition_to_barebox(request, strategy)
+    return target.get_driver('BareboxDriver')
+
+
+@pytest.fixture(scope='function')
+def shell(strategy, target):
+    strategy.transition('shell')
+    return target.get_driver('ShellDriver')
+
+
+@pytest.fixture(scope="session")
+def barebox_config(request, strategy, target):
+    transition_to_barebox(request, strategy)
+    command = target.get_driver("BareboxDriver")
+    return helper.get_config(command)
+
+
+def get_enabled_arch(config):
+    # Get the absolute path to the directory containing this script
+    base_dir = os.path.dirname(os.path.abspath(__file__))
+
+    # Path to the 'arch' directory relative to this script
+    arch_dir = os.path.join(base_dir, "arch")
+
+    if not os.path.isdir(arch_dir):
+        return None
+
+    # Optional mapping from directory names to config key suffixes
+    arch_map = {"powerpc": "ppc"}
+
+    for entry in os.listdir(arch_dir):
+        path = os.path.join(arch_dir, entry)
+        if os.path.isdir(path):
+            key_suffix = arch_map.get(entry, entry).upper()
+            config_key = f"CONFIG_{key_suffix}"
+            if config.get(config_key):
+                return entry
+
+    return None
+
+
+def guess_lg_env():
+    config_file = helper.open_config_file(os.environ['LG_BUILDDIR'] + "/.config")
+    config = helper.parse_config(config_file)
+    if not config or not config.get('CONFIG_NAME'):
+        return None
+    arch = get_enabled_arch(config)
+    if not arch:
+        return None
+    filename = os.path.join("test", arch, f"{config['CONFIG_NAME']}.yaml")
+    if os.path.exists(filename):
+        return filename
+    return None
+
+
+lg_env = lg_builddir = None
+
+
+def pytest_configure(config):
+    if 'LG_BUILDDIR' not in os.environ:
+        if 'KBUILD_OUTPUT' in os.environ:
+            os.environ['LG_BUILDDIR'] = os.environ['KBUILD_OUTPUT']
+        elif os.path.isdir('build'):
+            os.environ['LG_BUILDDIR'] = os.path.realpath('build')
+        else:
+            os.environ['LG_BUILDDIR'] = os.getcwd()
+
+    if os.environ['LG_BUILDDIR'] is not None:
+        os.environ['LG_BUILDDIR'] = os.path.realpath(os.environ['LG_BUILDDIR'])
+
+    global lg_env
+    global lg_builddir
+
+    lg_builddir = os.environ['LG_BUILDDIR']
+    lg_env = config.option.lg_env
+    if lg_env is None:
+        lg_env = os.environ.get('LG_ENV')
+    if lg_env is None:
+        if lg_env := guess_lg_env():
+            os.environ['LG_ENV'] = lg_env
+
+
+def pytest_report_header(config):
+    report = []
+    if lg_builddir is not None:
+        report += [f"Build diectory: {lg_builddir}"]
+    if lg_env is not None:
+        report += [f"Labgrid Environment: {lg_env}"]
+    return "\n".join(report)
+
+
+def pytest_addoption(parser):
+    def assignment(arg):
+        return arg.split('=', 1)
+
+    parser.addoption('--interactive', action='store_const', const='qemu_interactive',
+                     dest='lg_initial_state',
+                     help=('(for debugging) skip tests and just start Qemu interactively'))
+    parser.addoption('--dry-run', action='store_const', const='qemu_dry_run',
+                     dest='lg_initial_state',
+                     help=('(for debugging) skip tests and just print Qemu command line'))
+    parser.addoption('--dump-dtb', action='store_const', const='qemu_dump_dtb',
+                     dest='lg_initial_state',
+                     help=('(for debugging) skip tests and just dump the Qemu device tree'))
+    parser.addoption('--graphic', '--graphics', action='store_true', dest='qemu_graphics',
+                     help=('enable QEMU graphics output'))
+    parser.addoption('--rng', action='count', dest='qemu_rng',
+                     help=('instantiate Virt I/O random number generator'))
+    parser.addoption('--console', action='count', dest='qemu_console', default=0,
+                     help=('Pass an extra console (Virt I/O or ns16550_pci) to emulated barebox'))
+    parser.addoption('--fs', action='append', dest='qemu_fs',
+                     default=[], metavar="[tag=]DIR", type=assignment,
+                     help=('Pass directory trees to emulated barebox. Can be specified more than once'))  # noqa
+    parser.addoption('--blk', action='append', dest='qemu_block',
+                     default=[], metavar="FILE",
+                     help=('Pass block device to emulated barebox. Can be specified more than once'))  # noqa
+    parser.addoption('--env', action='append', dest='qemu_fw_cfg', type=assignment,
+                     default=[], metavar="[envpath=]content | [envpath=]@filepath",
+                     help=('Pass barebox environment files to barebox. Can be specified more than once'))  # noqa
+    parser.addoption('--qemu', dest='qemu_arg', nargs=argparse.REMAINDER, default=[],
+                     help=('Pass all remaining options to QEMU as is'))
+    parser.addoption('--bootarg', action='append', dest='bootarg', default=[],
+                     help=('Pass boot arguments to barebox for debugging purposes'))
+    parser.addoption('--port-forward', metavar="PORT", action='append', dest='qemu_port', default=[],
+                     help=('Forward incoming TCP or UDP connections on specified PORT'))
+
+
+@pytest.fixture(scope="session")
+def strategy(request, target, pytestconfig):  # noqa: max-complexity=30
+    try:
+        strategy = target.get_driver("Strategy")
+    except NoDriverFoundError as e:
+        pytest.exit(e)
+
+    try:
+        main = target.env.config.data["targets"]["main"]
+        features = main["features"]
+    except KeyError:
+        features = []
+
+    try:
+        main = target.env.config.data["targets"]["main"]
+        yaml_env = main["env"]
+    except KeyError:
+        yaml_env = {}
+
+    try:
+        main = target.env.config.data["targets"]["main"]
+        qemu_bin = main["drivers"]["QEMUDriver"]["qemu_bin"]
+        features.append("qemu")
+    except KeyError:
+        pass
+
+    virtio = None
+
+    if "virtio-mmio" in features:
+        virtio = "device"
+        strategy.append_qemu_args('-global virtio-mmio.force-legacy=false')
+    if "virtio-pci" in features:
+        virtio = "pci,disable-modern=off"
+        features.append("pci")
+
+    if virtio and pytestconfig.option.qemu_rng:
+        for i in range(pytestconfig.option.qemu_rng):
+            strategy.append_qemu_args("-device", f"virtio-rng-{virtio}")
+
+    for i in range(pytestconfig.option.qemu_console):
+        if virtio and i == 0:
+            strategy.append_qemu_args(
+                "-device", f"virtio-serial-{virtio}",
+                "-chardev", f"pty,id=virtcon{i}",
+                "-device", f"virtconsole,chardev=virtcon{i},name=console.virtcon{i}"
+            )
+            continue
+
+        # ns16550 serial driver only works with x86 so far
+        if 'pci' in features:
+            strategy.append_qemu_args(
+                "-chardev", f"pty,id=pcicon{i}",
+                "-device", f"pci-serial,chardev=pcicon{i}"
+            )
+        else:
+            pytest.exit("barebox currently supports only a single extra virtio console\n", 1)
+
+    if "qemu" in features:
+        if not pytestconfig.option.qemu_graphics:
+            graphics = '-nographic'
+        elif qemu_bin == "qemu-system-x86_64":
+            graphics = '-device isa-vga'
+        elif 'pci' in features:
+            graphics = '-device VGA'
+        elif virtio:
+            graphics = '-vga none -device ramfb'
+            graphics += f' -device virtio-keyboard-{virtio}'
+        else:
+            pytest.exit("--graphics unsupported for target\n", 1)
+
+        if graphics is not None and \
+                pytestconfig.option.lg_initial_state != 'qemu_interactive':
+            graphics += ' -display none'
+
+        strategy.append_qemu_args(graphics)
+
+    for i, blk in enumerate(pytestconfig.option.qemu_block):
+        if virtio:
+            strategy.append_qemu_args(
+                "-drive", f"if=none,format=raw,id=hd{i},file={blk}",
+                "-device", f"virtio-blk-{virtio},drive=hd{i}"
+            )
+        else:
+            pytest.exit("--blk unsupported for target\n", 1)
+
+    envopts = {}
+
+    for i, fw_cfg in enumerate(pytestconfig.option.qemu_fw_cfg):
+        value = fw_cfg.pop()
+        envpath = fw_cfg.pop() if fw_cfg else f"data/fw_cfg{i}"
+
+        envopts[envpath] = value
+
+    for envpath, value in (yaml_env | envopts).items():
+        if virtio:
+            if isinstance(value, str) and value.startswith('@'):
+                source = f"file='{value[1:]}'"
+            else:
+                source = f"string='{value}'"
+
+            strategy.append_qemu_args(
+                '-fw_cfg', f'name=opt/org.barebox.env/{envpath},{source}'
+            )
+        else:
+            pytest.exit("env unsupported for target\n", 1)
+
+    if len(pytestconfig.option.bootarg) > 0:
+        strategy.append_qemu_bootargs(pytestconfig.option.bootarg)
+
+    for arg in pytestconfig.option.qemu_arg:
+        strategy.append_qemu_args(arg)
+
+    qemu_nic = "user,id=net0"
+
+    for port in pytestconfig.option.qemu_port:
+        qemu_nic += f",hostfwd=udp:127.0.0.2:{port}-:{port}"
+        qemu_nic += f",hostfwd=tcp:127.0.0.2:{port}-:{port}"
+
+    if "testfs" in features:
+        if not any(fs and fs[0] == "testfs" for fs in pytestconfig.option.qemu_fs):
+            testfs_path = os.path.join(os.environ["LG_BUILDDIR"], "testfs")
+            pytestconfig.option.qemu_fs.append(["testfs", testfs_path])
+            os.makedirs(testfs_path, exist_ok=True)
+            qemu_nic += f",tftp={testfs_path}"
+
+    if "qemu" in features:
+        strategy.append_qemu_args("-nic", qemu_nic)
+
+    for i, fs in enumerate(pytestconfig.option.qemu_fs):
+        if virtio:
+            path = fs.pop()
+            tag = fs.pop() if fs else f"fs{i}"
+
+            strategy.append_qemu_args(
+                "-fsdev", f"local,security_model=none,id=fs{i},path={path}",
+                "-device", f"virtio-9p-{virtio},id=fs{i},fsdev=fs{i},mount_tag={tag}"
+            )
+        else:
+            pytest.exit("--fs unsupported for target\n", 1)
+
+    state = request.config.option.lg_initial_state
+    if state is not None:
+        strategy.force(state)
+
+    return strategy
+
+
+@pytest.fixture(scope="session")
+def testfs(strategy, env):
+    if "testfs" not in env.get_target_features():
+        pytest.skip("testfs not supported on this platform")
+
+    path = os.path.join(os.environ["LG_BUILDDIR"], "testfs")
+    return path
-- 
2.47.3




^ permalink raw reply	[flat|nested] 5+ messages in thread

* [PATCH 2/5] test: move interactive QEMU launcher out of pytest
  2026-06-26 12:45 [PATCH 1/5] scripts: duplicate conftest.py as qemu_interactive.py Ahmad Fatoum
@ 2026-06-26 12:45 ` Ahmad Fatoum
  2026-06-26 12:45 ` [PATCH 3/5] usb: xhci-hcd: add XHCI over PCI driver Ahmad Fatoum
                   ` (2 subsequent siblings)
  3 siblings, 0 replies; 5+ messages in thread
From: Ahmad Fatoum @ 2026-06-26 12:45 UTC (permalink / raw)
  To: barebox; +Cc: Ahmad Fatoum

pytest --interactive apparently starts two QEMUs, the interactive one in
addition to the one started directly by Labgrid. This leads to issues
when QEMU does something mutually exclusive like listening on a port or
taking a file lock.

Arguably, running an interactive QEMU with pytest as a runner was
a strange idea in the first place. Let's stop doing that and add a
dedicated qemu_interactive.py script.

The new script is imported by conftest.py to get support for custom
options like --blk or --rng. --interactive becomes an error.

To avoid cross-dependencies between test suite and the new script, the
CONFIG_NAME resolution is reimplemented without test/py/helper.py, but
apart from that it's mostly copy-paste and adding some boilerplate.

In the future[1], we can adapt this to use the new QEMUDriver.interact().

[1]: https://github.com/labgrid-project/labgrid/pull/1892

Assisted-by: Codex:gpt-5.5
Reported-by: Marc Kleine-Budde <mkl@pengutronix.de>
Signed-off-by: Ahmad Fatoum <a.fatoum@barebox.org>
---
 Documentation/boards/emulated.rst        |  10 +-
 Documentation/devel/contributing.rst     |   8 +-
 Documentation/user/security-policies.rst |   2 +-
 conftest.py                              | 243 ++---------
 scripts/qemu_interactive.py              | 534 +++++++++++++++--------
 5 files changed, 375 insertions(+), 422 deletions(-)
 mode change 100644 => 100755 scripts/qemu_interactive.py

diff --git a/Documentation/boards/emulated.rst b/Documentation/boards/emulated.rst
index 0d6dd85dc759..bc3e2566eaec 100644
--- a/Documentation/boards/emulated.rst
+++ b/Documentation/boards/emulated.rst
@@ -23,15 +23,15 @@ VirtIO over PCI.
 Running in QEMU
 ---------------
 
-Emulated targets can be started interactively with ``pytest --interactive``::
+Emulated targets can be started interactively with ``scripts/qemu_interactive.py``::
 
   # Run x86 VM runnig the EFI payload from efi_defconfig
-  pytest --lg-env test/x86/efi_defconfig.yaml --interactive
+  scripts/qemu_interactive.py test/x86/efi_defconfig.yaml
 
   # Identical to above, provided the CONFIG_NAME=efi_defconfig
-  pytest --interactive
+  scripts/qemu_interactive.py
 
-The test suite can be run by omitting the ``--interactive``.
+The test suite can be run with ``pytest`` instead.
 For more information, see the :ref:`labgrid` section in the
 :ref:`contributing` guide.
 
@@ -43,7 +43,7 @@ be used in combination with QEMU. With user-mode networking (SLIRP),
 guest-to-host UDP works via NAT out of the box,
 but unsolicited host-to-guest UDP requires an explicit port forward::
 
-  pytest --lg-env test/arm/multi_v8_defconfig.yaml --interactive \
+  scripts/qemu_interactive.py test/arm/multi_v8_defconfig.yaml    \
     --env nv/dev.netconsole.ip=10.0.2.2                          \
     --env nv/dev.netconsole.port=6666                            \
     --env init/netconsole="ifup -a1; netconsole.active=ioe"      \
diff --git a/Documentation/devel/contributing.rst b/Documentation/devel/contributing.rst
index cb3820f23c63..8b92c6f483aa 100644
--- a/Documentation/devel/contributing.rst
+++ b/Documentation/devel/contributing.rst
@@ -134,7 +134,7 @@ it directly from PyPI instead of your distro's package repositories::
 Example usage::
 
   # Run x86 VM runnig the EFI payload from efi_defconfig
-  pytest --lg-env test/x86/efi_defconfig.yaml --interactive
+  scripts/qemu_interactive.py test/x86/efi_defconfig.yaml
 
   # Run the test suite against the same
   pytest --lg-env test/x86/efi_defconfig.yaml
@@ -145,10 +145,10 @@ built out-of-tree, the build directory must be pointed at by
 ``LG_BUILDDIR``, ``KBUILD_OUTPUT`` or a ``build`` symlink.
 
 Additional QEMU command-line options can be added by specifying
-them after the ``--qemu`` option::
+them after ``--qemu``::
 
   # appends -device ? to the command line. Add --dry-run to see the final result
-  pytest --lg-env test/riscv/rv64i_defconfig.yaml --interactive --qemu -device '?'
+  scripts/qemu_interactive.py test/riscv/rv64i_defconfig.yaml --qemu -device '?'
 
 Some of the QEMU options that are used more often also have explicit
 support in the test runner, so paravirtualized devices can be added
@@ -158,7 +158,7 @@ more easily::
   pytest --lg-env test/arm/virt@multi_v8_defconfig.yaml --blk=rootfs.ext4
 
   # Run interactively with graphics output
-  pytest --lg-env test/mips/qemu-malta_defconfig.yaml --interactive --graphics
+  scripts/qemu_interactive.py test/mips/qemu-malta_defconfig.yaml --graphics
 
 For testing, the QEMU fw_cfg and virtfs support is particularly useful::
 
diff --git a/Documentation/user/security-policies.rst b/Documentation/user/security-policies.rst
index 8d515508d106..296191c4f8e5 100644
--- a/Documentation/user/security-policies.rst
+++ b/Documentation/user/security-policies.rst
@@ -97,7 +97,7 @@ policy development and evaluation. ``images/barebox-dt-2nd.img`` that results
 from building it can be passed as argument to ``qemu-system-arm -M virt -kernel``.
 
 The easiest way to do this is probably installing labgrid and running
-``pytest --interactive`` after having built the config.
+``scripts/qemu_interactive.py`` after having built the config.
 
 Differences from Kconfig
 ------------------------
diff --git a/conftest.py b/conftest.py
index 6f76586e010b..4ab76b295e59 100644
--- a/conftest.py
+++ b/conftest.py
@@ -3,6 +3,7 @@ import pytest
 import os
 import argparse
 from labgrid.exceptions import NoDriverFoundError
+from scripts import qemu_interactive
 from test.py import helper
 
 
@@ -38,68 +39,24 @@ def barebox_config(request, strategy, target):
     return helper.get_config(command)
 
 
-def get_enabled_arch(config):
-    # Get the absolute path to the directory containing this script
-    base_dir = os.path.dirname(os.path.abspath(__file__))
-
-    # Path to the 'arch' directory relative to this script
-    arch_dir = os.path.join(base_dir, "arch")
-
-    if not os.path.isdir(arch_dir):
-        return None
-
-    # Optional mapping from directory names to config key suffixes
-    arch_map = {"powerpc": "ppc"}
-
-    for entry in os.listdir(arch_dir):
-        path = os.path.join(arch_dir, entry)
-        if os.path.isdir(path):
-            key_suffix = arch_map.get(entry, entry).upper()
-            config_key = f"CONFIG_{key_suffix}"
-            if config.get(config_key):
-                return entry
-
-    return None
-
-
-def guess_lg_env():
-    config_file = helper.open_config_file(os.environ['LG_BUILDDIR'] + "/.config")
-    config = helper.parse_config(config_file)
-    if not config or not config.get('CONFIG_NAME'):
-        return None
-    arch = get_enabled_arch(config)
-    if not arch:
-        return None
-    filename = os.path.join("test", arch, f"{config['CONFIG_NAME']}.yaml")
-    if os.path.exists(filename):
-        return filename
-    return None
-
-
 lg_env = lg_builddir = None
 
 
 def pytest_configure(config):
-    if 'LG_BUILDDIR' not in os.environ:
-        if 'KBUILD_OUTPUT' in os.environ:
-            os.environ['LG_BUILDDIR'] = os.environ['KBUILD_OUTPUT']
-        elif os.path.isdir('build'):
-            os.environ['LG_BUILDDIR'] = os.path.realpath('build')
-        else:
-            os.environ['LG_BUILDDIR'] = os.getcwd()
-
-    if os.environ['LG_BUILDDIR'] is not None:
-        os.environ['LG_BUILDDIR'] = os.path.realpath(os.environ['LG_BUILDDIR'])
+    removed_mode = getattr(config.option, 'qemu_interactive_removed', None)
+    if removed_mode is not None:
+        pytest.exit(qemu_interactive.replacement_message(removed_mode),
+                    returncode=2)
 
     global lg_env
     global lg_builddir
 
-    lg_builddir = os.environ['LG_BUILDDIR']
+    lg_builddir = qemu_interactive.resolve_lg_builddir()
     lg_env = config.option.lg_env
     if lg_env is None:
         lg_env = os.environ.get('LG_ENV')
     if lg_env is None:
-        if lg_env := guess_lg_env():
+        if lg_env := qemu_interactive.guess_lg_env(lg_builddir):
             os.environ['LG_ENV'] = lg_env
 
 
@@ -113,39 +70,22 @@ def pytest_report_header(config):
 
 
 def pytest_addoption(parser):
-    def assignment(arg):
-        return arg.split('=', 1)
+    parser.addoption('--interactive', action='store_const',
+                     const='--interactive', dest='qemu_interactive_removed',
+                     help=argparse.SUPPRESS)
+    parser.addoption('--dry-run', action='store_const',
+                     const='--dry-run', dest='qemu_interactive_removed',
+                     help=argparse.SUPPRESS)
+    parser.addoption('--dump-dtb', action='store_const',
+                     const='--dump-dtb', dest='qemu_interactive_removed',
+                     help=argparse.SUPPRESS)
 
-    parser.addoption('--interactive', action='store_const', const='qemu_interactive',
-                     dest='lg_initial_state',
-                     help=('(for debugging) skip tests and just start Qemu interactively'))
-    parser.addoption('--dry-run', action='store_const', const='qemu_dry_run',
-                     dest='lg_initial_state',
-                     help=('(for debugging) skip tests and just print Qemu command line'))
-    parser.addoption('--dump-dtb', action='store_const', const='qemu_dump_dtb',
-                     dest='lg_initial_state',
-                     help=('(for debugging) skip tests and just dump the Qemu device tree'))
-    parser.addoption('--graphic', '--graphics', action='store_true', dest='qemu_graphics',
-                     help=('enable QEMU graphics output'))
-    parser.addoption('--rng', action='count', dest='qemu_rng',
-                     help=('instantiate Virt I/O random number generator'))
-    parser.addoption('--console', action='count', dest='qemu_console', default=0,
-                     help=('Pass an extra console (Virt I/O or ns16550_pci) to emulated barebox'))
-    parser.addoption('--fs', action='append', dest='qemu_fs',
-                     default=[], metavar="[tag=]DIR", type=assignment,
-                     help=('Pass directory trees to emulated barebox. Can be specified more than once'))  # noqa
-    parser.addoption('--blk', action='append', dest='qemu_block',
-                     default=[], metavar="FILE",
-                     help=('Pass block device to emulated barebox. Can be specified more than once'))  # noqa
-    parser.addoption('--env', action='append', dest='qemu_fw_cfg', type=assignment,
-                     default=[], metavar="[envpath=]content | [envpath=]@filepath",
-                     help=('Pass barebox environment files to barebox. Can be specified more than once'))  # noqa
-    parser.addoption('--qemu', dest='qemu_arg', nargs=argparse.REMAINDER, default=[],
-                     help=('Pass all remaining options to QEMU as is'))
-    parser.addoption('--bootarg', action='append', dest='bootarg', default=[],
-                     help=('Pass boot arguments to barebox for debugging purposes'))
-    parser.addoption('--port-forward', metavar="PORT", action='append', dest='qemu_port', default=[],
-                     help=('Forward incoming TCP or UDP connections on specified PORT'))
+    group = parser.getgroup('barebox-qemu', 'barebox QEMU options')
+    qemu_interactive.register_shared_options(group)
+
+
+def _pytest_exit(message, returncode=1):
+    pytest.exit(message, returncode=returncode)
 
 
 @pytest.fixture(scope="session")
@@ -155,142 +95,9 @@ def strategy(request, target, pytestconfig):  # noqa: max-complexity=30
     except NoDriverFoundError as e:
         pytest.exit(e)
 
-    try:
-        main = target.env.config.data["targets"]["main"]
-        features = main["features"]
-    except KeyError:
-        features = []
-
-    try:
-        main = target.env.config.data["targets"]["main"]
-        yaml_env = main["env"]
-    except KeyError:
-        yaml_env = {}
-
-    try:
-        main = target.env.config.data["targets"]["main"]
-        qemu_bin = main["drivers"]["QEMUDriver"]["qemu_bin"]
-        features.append("qemu")
-    except KeyError:
-        pass
-
-    virtio = None
-
-    if "virtio-mmio" in features:
-        virtio = "device"
-        strategy.append_qemu_args('-global virtio-mmio.force-legacy=false')
-    if "virtio-pci" in features:
-        virtio = "pci,disable-modern=off"
-        features.append("pci")
-
-    if virtio and pytestconfig.option.qemu_rng:
-        for i in range(pytestconfig.option.qemu_rng):
-            strategy.append_qemu_args("-device", f"virtio-rng-{virtio}")
-
-    for i in range(pytestconfig.option.qemu_console):
-        if virtio and i == 0:
-            strategy.append_qemu_args(
-                "-device", f"virtio-serial-{virtio}",
-                "-chardev", f"pty,id=virtcon{i}",
-                "-device", f"virtconsole,chardev=virtcon{i},name=console.virtcon{i}"
-            )
-            continue
-
-        # ns16550 serial driver only works with x86 so far
-        if 'pci' in features:
-            strategy.append_qemu_args(
-                "-chardev", f"pty,id=pcicon{i}",
-                "-device", f"pci-serial,chardev=pcicon{i}"
-            )
-        else:
-            pytest.exit("barebox currently supports only a single extra virtio console\n", 1)
-
-    if "qemu" in features:
-        if not pytestconfig.option.qemu_graphics:
-            graphics = '-nographic'
-        elif qemu_bin == "qemu-system-x86_64":
-            graphics = '-device isa-vga'
-        elif 'pci' in features:
-            graphics = '-device VGA'
-        elif virtio:
-            graphics = '-vga none -device ramfb'
-            graphics += f' -device virtio-keyboard-{virtio}'
-        else:
-            pytest.exit("--graphics unsupported for target\n", 1)
-
-        if graphics is not None and \
-                pytestconfig.option.lg_initial_state != 'qemu_interactive':
-            graphics += ' -display none'
-
-        strategy.append_qemu_args(graphics)
-
-    for i, blk in enumerate(pytestconfig.option.qemu_block):
-        if virtio:
-            strategy.append_qemu_args(
-                "-drive", f"if=none,format=raw,id=hd{i},file={blk}",
-                "-device", f"virtio-blk-{virtio},drive=hd{i}"
-            )
-        else:
-            pytest.exit("--blk unsupported for target\n", 1)
-
-    envopts = {}
-
-    for i, fw_cfg in enumerate(pytestconfig.option.qemu_fw_cfg):
-        value = fw_cfg.pop()
-        envpath = fw_cfg.pop() if fw_cfg else f"data/fw_cfg{i}"
-
-        envopts[envpath] = value
-
-    for envpath, value in (yaml_env | envopts).items():
-        if virtio:
-            if isinstance(value, str) and value.startswith('@'):
-                source = f"file='{value[1:]}'"
-            else:
-                source = f"string='{value}'"
-
-            strategy.append_qemu_args(
-                '-fw_cfg', f'name=opt/org.barebox.env/{envpath},{source}'
-            )
-        else:
-            pytest.exit("env unsupported for target\n", 1)
-
-    if len(pytestconfig.option.bootarg) > 0:
-        strategy.append_qemu_bootargs(pytestconfig.option.bootarg)
-
-    for arg in pytestconfig.option.qemu_arg:
-        strategy.append_qemu_args(arg)
-
-    qemu_nic = "user,id=net0"
-
-    for port in pytestconfig.option.qemu_port:
-        qemu_nic += f",hostfwd=udp:127.0.0.2:{port}-:{port}"
-        qemu_nic += f",hostfwd=tcp:127.0.0.2:{port}-:{port}"
-
-    if "testfs" in features:
-        if not any(fs and fs[0] == "testfs" for fs in pytestconfig.option.qemu_fs):
-            testfs_path = os.path.join(os.environ["LG_BUILDDIR"], "testfs")
-            pytestconfig.option.qemu_fs.append(["testfs", testfs_path])
-            os.makedirs(testfs_path, exist_ok=True)
-            qemu_nic += f",tftp={testfs_path}"
-
-    if "qemu" in features:
-        strategy.append_qemu_args("-nic", qemu_nic)
-
-    for i, fs in enumerate(pytestconfig.option.qemu_fs):
-        if virtio:
-            path = fs.pop()
-            tag = fs.pop() if fs else f"fs{i}"
-
-            strategy.append_qemu_args(
-                "-fsdev", f"local,security_model=none,id=fs{i},path={path}",
-                "-device", f"virtio-9p-{virtio},id=fs{i},fsdev=fs{i},mount_tag={tag}"
-            )
-        else:
-            pytest.exit("--fs unsupported for target\n", 1)
-
-    state = request.config.option.lg_initial_state
-    if state is not None:
-        strategy.force(state)
+    qemu_interactive.apply_shared_options(
+        strategy, target, pytestconfig.option, interactive=False,
+        fail=_pytest_exit)
 
     return strategy
 
diff --git a/scripts/qemu_interactive.py b/scripts/qemu_interactive.py
old mode 100644
new mode 100755
index 6f76586e010b..a8605d23b132
--- a/scripts/qemu_interactive.py
+++ b/scripts/qemu_interactive.py
@@ -1,195 +1,218 @@
+#!/usr/bin/env python3
+# /// script
+# requires-python = ">=3.9"
+# dependencies = ["labgrid"]
+# ///
 # SPDX-License-Identifier: GPL-2.0-only
-import pytest
-import os
+
 import argparse
-from labgrid.exceptions import NoDriverFoundError
-from test.py import helper
+import glob
+import os
+import re
+import subprocess
+import sys
 
 
-def transition_to_barebox(request, strategy):
-    try:
-        strategy.transition('barebox')
-    except Exception as e:
-        # If a normal strategy transition fails, there's no point in
-        # continuing the test. Let's print stderr and exit.
-        capmanager = request.config.pluginmanager.getplugin("capturemanager")
-        with capmanager.global_and_fixture_disabled():
-            _, stderr = capmanager.read_global_capture()
-            pytest.exit(f"{type(e).__name__}(\"{e}\"). Standard error:\n{stderr}",
-                        returncode=3)
+MODE_INTERACTIVE = "qemu_interactive"
+MODE_DRY_RUN = "qemu_dry_run"
+MODE_DUMP_DTB = "qemu_dump_dtb"
 
 
-@pytest.fixture(scope='function')
-def barebox(request, strategy, target):
-    transition_to_barebox(request, strategy)
-    return target.get_driver('BareboxDriver')
+class QemuInteractiveError(Exception):
+    def __init__(self, message, returncode=1):
+        super().__init__(message)
+        self.returncode = returncode
 
 
-@pytest.fixture(scope='function')
-def shell(strategy, target):
-    strategy.transition('shell')
-    return target.get_driver('ShellDriver')
+def _add_option(parser, *opts, **attrs):
+    if hasattr(parser, "addoption"):
+        return parser.addoption(*opts, **attrs)
+
+    return parser.add_argument(*opts, **attrs)
 
 
-@pytest.fixture(scope="session")
-def barebox_config(request, strategy, target):
-    transition_to_barebox(request, strategy)
-    command = target.get_driver("BareboxDriver")
-    return helper.get_config(command)
+def _assignment(arg):
+    return arg.split("=", 1)
 
 
-def get_enabled_arch(config):
-    # Get the absolute path to the directory containing this script
-    base_dir = os.path.dirname(os.path.abspath(__file__))
-
-    # Path to the 'arch' directory relative to this script
-    arch_dir = os.path.join(base_dir, "arch")
-
-    if not os.path.isdir(arch_dir):
-        return None
-
-    # Optional mapping from directory names to config key suffixes
-    arch_map = {"powerpc": "ppc"}
-
-    for entry in os.listdir(arch_dir):
-        path = os.path.join(arch_dir, entry)
-        if os.path.isdir(path):
-            key_suffix = arch_map.get(entry, entry).upper()
-            config_key = f"CONFIG_{key_suffix}"
-            if config.get(config_key):
-                return entry
-
-    return None
+def register_shared_options(parser):
+    _add_option(parser, "--graphic", "--graphics", action="store_true",
+                dest="qemu_graphics", help="enable QEMU graphics output")
+    _add_option(parser, "--rng", action="count", dest="qemu_rng",
+                help="instantiate Virt I/O random number generator")
+    _add_option(parser, "--console", action="count", dest="qemu_console",
+                default=0,
+                help="Pass an extra console (Virt I/O or ns16550_pci) to emulated barebox")
+    _add_option(parser, "--fs", action="append", dest="qemu_fs",
+                default=[], metavar="[tag=]DIR", type=_assignment,
+                help="Pass directory trees to emulated barebox. Can be specified more than once")
+    _add_option(parser, "--blk", action="append", dest="qemu_block",
+                default=[], metavar="FILE",
+                help="Pass block device to emulated barebox. Can be specified more than once")
+    _add_option(parser, "--env", action="append", dest="qemu_fw_cfg",
+                type=_assignment, default=[],
+                metavar="[envpath=]content | [envpath=]@filepath",
+                help="Pass barebox environment files to barebox. Can be specified more than once")
+    _add_option(parser, "--qemu", dest="qemu_arg",
+                nargs=argparse.REMAINDER, default=[],
+                help="Pass all remaining options to QEMU as is")
+    _add_option(parser, "--bootarg", action="append", dest="bootarg",
+                default=[],
+                help="Pass boot arguments to barebox for debugging purposes")
+    _add_option(parser, "--port-forward", metavar="PORT", action="append",
+                dest="qemu_port", default=[],
+                help="Forward incoming TCP or UDP connections on specified PORT")
 
 
-def guess_lg_env():
-    config_file = helper.open_config_file(os.environ['LG_BUILDDIR'] + "/.config")
-    config = helper.parse_config(config_file)
-    if not config or not config.get('CONFIG_NAME'):
-        return None
-    arch = get_enabled_arch(config)
-    if not arch:
-        return None
-    filename = os.path.join("test", arch, f"{config['CONFIG_NAME']}.yaml")
-    if os.path.exists(filename):
-        return filename
-    return None
+def register_interactive_options(parser):
+    _add_option(parser, "--lg-env", dest="lg_env", metavar="LG_ENV",
+                help="labgrid environment config file")
+
+    if hasattr(parser, "add_mutually_exclusive_group"):
+        mode_parser = parser.add_mutually_exclusive_group()
+    else:
+        mode_parser = parser
+
+    _add_option(mode_parser, "--dry-run", action="store_const",
+                const=MODE_DRY_RUN, default=MODE_INTERACTIVE,
+                dest="qemu_mode",
+                help="print the QEMU command line instead of executing it")
+    _add_option(mode_parser, "--dump-dtb", action="store_const",
+                const=MODE_DUMP_DTB, dest="qemu_mode",
+                help="run QEMU only to dump the device tree")
 
 
-lg_env = lg_builddir = None
+def repo_root():
+    return os.path.realpath(os.path.join(os.path.dirname(__file__), os.pardir))
 
 
-def pytest_configure(config):
-    if 'LG_BUILDDIR' not in os.environ:
-        if 'KBUILD_OUTPUT' in os.environ:
-            os.environ['LG_BUILDDIR'] = os.environ['KBUILD_OUTPUT']
-        elif os.path.isdir('build'):
-            os.environ['LG_BUILDDIR'] = os.path.realpath('build')
+def resolve_lg_builddir():
+    if "LG_BUILDDIR" not in os.environ:
+        if "KBUILD_OUTPUT" in os.environ:
+            os.environ["LG_BUILDDIR"] = os.environ["KBUILD_OUTPUT"]
+        elif os.path.isdir("build"):
+            os.environ["LG_BUILDDIR"] = os.path.realpath("build")
         else:
-            os.environ['LG_BUILDDIR'] = os.getcwd()
+            os.environ["LG_BUILDDIR"] = os.getcwd()
 
-    if os.environ['LG_BUILDDIR'] is not None:
-        os.environ['LG_BUILDDIR'] = os.path.realpath(os.environ['LG_BUILDDIR'])
-
-    global lg_env
-    global lg_builddir
-
-    lg_builddir = os.environ['LG_BUILDDIR']
-    lg_env = config.option.lg_env
-    if lg_env is None:
-        lg_env = os.environ.get('LG_ENV')
-    if lg_env is None:
-        if lg_env := guess_lg_env():
-            os.environ['LG_ENV'] = lg_env
+    os.environ["LG_BUILDDIR"] = os.path.realpath(os.environ["LG_BUILDDIR"])
+    return os.environ["LG_BUILDDIR"]
 
 
-def pytest_report_header(config):
-    report = []
-    if lg_builddir is not None:
-        report += [f"Build diectory: {lg_builddir}"]
-    if lg_env is not None:
-        report += [f"Labgrid Environment: {lg_env}"]
-    return "\n".join(report)
-
-
-def pytest_addoption(parser):
-    def assignment(arg):
-        return arg.split('=', 1)
-
-    parser.addoption('--interactive', action='store_const', const='qemu_interactive',
-                     dest='lg_initial_state',
-                     help=('(for debugging) skip tests and just start Qemu interactively'))
-    parser.addoption('--dry-run', action='store_const', const='qemu_dry_run',
-                     dest='lg_initial_state',
-                     help=('(for debugging) skip tests and just print Qemu command line'))
-    parser.addoption('--dump-dtb', action='store_const', const='qemu_dump_dtb',
-                     dest='lg_initial_state',
-                     help=('(for debugging) skip tests and just dump the Qemu device tree'))
-    parser.addoption('--graphic', '--graphics', action='store_true', dest='qemu_graphics',
-                     help=('enable QEMU graphics output'))
-    parser.addoption('--rng', action='count', dest='qemu_rng',
-                     help=('instantiate Virt I/O random number generator'))
-    parser.addoption('--console', action='count', dest='qemu_console', default=0,
-                     help=('Pass an extra console (Virt I/O or ns16550_pci) to emulated barebox'))
-    parser.addoption('--fs', action='append', dest='qemu_fs',
-                     default=[], metavar="[tag=]DIR", type=assignment,
-                     help=('Pass directory trees to emulated barebox. Can be specified more than once'))  # noqa
-    parser.addoption('--blk', action='append', dest='qemu_block',
-                     default=[], metavar="FILE",
-                     help=('Pass block device to emulated barebox. Can be specified more than once'))  # noqa
-    parser.addoption('--env', action='append', dest='qemu_fw_cfg', type=assignment,
-                     default=[], metavar="[envpath=]content | [envpath=]@filepath",
-                     help=('Pass barebox environment files to barebox. Can be specified more than once'))  # noqa
-    parser.addoption('--qemu', dest='qemu_arg', nargs=argparse.REMAINDER, default=[],
-                     help=('Pass all remaining options to QEMU as is'))
-    parser.addoption('--bootarg', action='append', dest='bootarg', default=[],
-                     help=('Pass boot arguments to barebox for debugging purposes'))
-    parser.addoption('--port-forward', metavar="PORT", action='append', dest='qemu_port', default=[],
-                     help=('Forward incoming TCP or UDP connections on specified PORT'))
-
-
-@pytest.fixture(scope="session")
-def strategy(request, target, pytestconfig):  # noqa: max-complexity=30
+def get_config_name(builddir):
     try:
-        strategy = target.get_driver("Strategy")
-    except NoDriverFoundError as e:
-        pytest.exit(e)
-
-    try:
-        main = target.env.config.data["targets"]["main"]
-        features = main["features"]
-    except KeyError:
-        features = []
-
-    try:
-        main = target.env.config.data["targets"]["main"]
-        yaml_env = main["env"]
-    except KeyError:
-        yaml_env = {}
-
-    try:
-        main = target.env.config.data["targets"]["main"]
-        qemu_bin = main["drivers"]["QEMUDriver"]["qemu_bin"]
-        features.append("qemu")
-    except KeyError:
+        with open(os.path.join(builddir, ".config")) as config:
+            for line in config:
+                match = re.match(r'^CONFIG_NAME="(.*)"$', line)
+                if match:
+                    return match.group(1)
+    except OSError:
         pass
 
+    return None
+
+
+def guess_lg_env(builddir=None):
+    if builddir is None:
+        builddir = os.environ.get("LG_BUILDDIR")
+    if builddir is None:
+        return None
+
+    config_name = get_config_name(builddir)
+    if config_name is None:
+        return None
+
+    matches = glob.glob(os.path.join(repo_root(), "test", "*",
+                                     f"{config_name}.yaml"))
+    if matches:
+        return matches[0]
+
+    return None
+
+
+def resolve_lg_env(lg_env=None):
+    if lg_env is None:
+        lg_env = os.environ.get("LG_ENV")
+    if lg_env is None:
+        lg_env = guess_lg_env()
+
+    if lg_env is not None:
+        os.environ["LG_ENV"] = lg_env
+
+    return lg_env
+
+
+def _get_option(options, name, default=None):
+    if isinstance(options, dict):
+        return options.get(name, default)
+
+    return getattr(options, name, default)
+
+
+def _get_list_option(options, name):
+    value = _get_option(options, name, [])
+    if value is None:
+        return []
+    return list(value)
+
+
+def _fail(message, returncode=1):
+    raise QemuInteractiveError(message, returncode)
+
+
+def _append_qemu_args(strategy, fail, *args):
+    if strategy.qemu is None:
+        fail("Qemu option supplied for non-Qemu target")
+
+    for arg in args:
+        strategy.console.extra_args += " " + arg
+
+
+def _append_qemu_bootargs(strategy, fail, args):
+    if strategy.qemu is None:
+        fail("Qemu option supplied for non-Qemu target")
+    if strategy.console.boot_args is None:
+        strategy.console.boot_args = ""
+    strategy.console.boot_args += " ".join(args)
+
+
+def apply_shared_options(strategy, target, options, *, interactive, fail=None):  # noqa: max-complexity=30
+    if fail is None:
+        fail = _fail
+
+    try:
+        main = target.env.config.data["targets"]["main"]
+    except KeyError:
+        main = {}
+
+    features = list(main.get("features", []))
+    yaml_env = dict(main.get("env", {}))
+    qemu_driver = main.get("drivers", {}).get("QEMUDriver")
+    qemu_bin = None
+
+    if qemu_driver is not None:
+        qemu_bin = qemu_driver.get("qemu_bin")
+        features.append("qemu")
+
     virtio = None
 
     if "virtio-mmio" in features:
         virtio = "device"
-        strategy.append_qemu_args('-global virtio-mmio.force-legacy=false')
+        _append_qemu_args(strategy, fail, '-global virtio-mmio.force-legacy=false')
     if "virtio-pci" in features:
         virtio = "pci,disable-modern=off"
         features.append("pci")
 
-    if virtio and pytestconfig.option.qemu_rng:
-        for i in range(pytestconfig.option.qemu_rng):
-            strategy.append_qemu_args("-device", f"virtio-rng-{virtio}")
+    qemu_rng = _get_option(options, "qemu_rng", 0) or 0
+    for _ in range(qemu_rng):
+        if virtio:
+            _append_qemu_args(strategy, fail, "-device", f"virtio-rng-{virtio}")
 
-    for i in range(pytestconfig.option.qemu_console):
+    qemu_console = _get_option(options, "qemu_console", 0) or 0
+    for i in range(qemu_console):
         if virtio and i == 0:
-            strategy.append_qemu_args(
+            _append_qemu_args(
+                strategy, fail,
                 "-device", f"virtio-serial-{virtio}",
                 "-chardev", f"pty,id=virtcon{i}",
                 "-device", f"virtconsole,chardev=virtcon{i},name=console.virtcon{i}"
@@ -198,15 +221,16 @@ def strategy(request, target, pytestconfig):  # noqa: max-complexity=30
 
         # ns16550 serial driver only works with x86 so far
         if 'pci' in features:
-            strategy.append_qemu_args(
+            _append_qemu_args(
+                strategy, fail,
                 "-chardev", f"pty,id=pcicon{i}",
                 "-device", f"pci-serial,chardev=pcicon{i}"
             )
         else:
-            pytest.exit("barebox currently supports only a single extra virtio console\n", 1)
+            fail("barebox currently supports only a single extra virtio console\n")
 
     if "qemu" in features:
-        if not pytestconfig.option.qemu_graphics:
+        if not _get_option(options, "qemu_graphics", False):
             graphics = '-nographic'
         elif qemu_bin == "qemu-system-x86_64":
             graphics = '-device isa-vga'
@@ -216,89 +240,211 @@ def strategy(request, target, pytestconfig):  # noqa: max-complexity=30
             graphics = '-vga none -device ramfb'
             graphics += f' -device virtio-keyboard-{virtio}'
         else:
-            pytest.exit("--graphics unsupported for target\n", 1)
+            fail("--graphics unsupported for target\n")
 
-        if graphics is not None and \
-                pytestconfig.option.lg_initial_state != 'qemu_interactive':
+        if graphics is not None and not interactive:
             graphics += ' -display none'
 
-        strategy.append_qemu_args(graphics)
+        _append_qemu_args(strategy, fail, graphics)
 
-    for i, blk in enumerate(pytestconfig.option.qemu_block):
+    for i, blk in enumerate(_get_list_option(options, "qemu_block")):
         if virtio:
-            strategy.append_qemu_args(
+            _append_qemu_args(
+                strategy, fail,
                 "-drive", f"if=none,format=raw,id=hd{i},file={blk}",
                 "-device", f"virtio-blk-{virtio},drive=hd{i}"
             )
         else:
-            pytest.exit("--blk unsupported for target\n", 1)
+            fail("--blk unsupported for target\n")
 
     envopts = {}
 
-    for i, fw_cfg in enumerate(pytestconfig.option.qemu_fw_cfg):
+    for i, fw_cfg in enumerate(_get_list_option(options, "qemu_fw_cfg")):
         value = fw_cfg.pop()
         envpath = fw_cfg.pop() if fw_cfg else f"data/fw_cfg{i}"
 
         envopts[envpath] = value
 
-    for envpath, value in (yaml_env | envopts).items():
+    for envpath, value in {**yaml_env, **envopts}.items():
         if virtio:
             if isinstance(value, str) and value.startswith('@'):
                 source = f"file='{value[1:]}'"
             else:
                 source = f"string='{value}'"
 
-            strategy.append_qemu_args(
+            _append_qemu_args(
+                strategy, fail,
                 '-fw_cfg', f'name=opt/org.barebox.env/{envpath},{source}'
             )
         else:
-            pytest.exit("env unsupported for target\n", 1)
+            fail("env unsupported for target\n")
 
-    if len(pytestconfig.option.bootarg) > 0:
-        strategy.append_qemu_bootargs(pytestconfig.option.bootarg)
+    bootargs = _get_list_option(options, "bootarg")
+    if bootargs:
+        _append_qemu_bootargs(strategy, fail, bootargs)
 
-    for arg in pytestconfig.option.qemu_arg:
-        strategy.append_qemu_args(arg)
+    for arg in _get_list_option(options, "qemu_arg"):
+        _append_qemu_args(strategy, fail, arg)
 
     qemu_nic = "user,id=net0"
 
-    for port in pytestconfig.option.qemu_port:
+    for port in _get_list_option(options, "qemu_port"):
         qemu_nic += f",hostfwd=udp:127.0.0.2:{port}-:{port}"
         qemu_nic += f",hostfwd=tcp:127.0.0.2:{port}-:{port}"
 
+    qemu_fs = [list(fs) for fs in _get_list_option(options, "qemu_fs")]
+
     if "testfs" in features:
-        if not any(fs and fs[0] == "testfs" for fs in pytestconfig.option.qemu_fs):
+        if not any(fs and fs[0] == "testfs" for fs in qemu_fs):
             testfs_path = os.path.join(os.environ["LG_BUILDDIR"], "testfs")
-            pytestconfig.option.qemu_fs.append(["testfs", testfs_path])
+            qemu_fs.append(["testfs", testfs_path])
             os.makedirs(testfs_path, exist_ok=True)
             qemu_nic += f",tftp={testfs_path}"
 
     if "qemu" in features:
-        strategy.append_qemu_args("-nic", qemu_nic)
+        _append_qemu_args(strategy, fail, "-nic", qemu_nic)
 
-    for i, fs in enumerate(pytestconfig.option.qemu_fs):
+    for i, fs in enumerate(qemu_fs):
         if virtio:
             path = fs.pop()
             tag = fs.pop() if fs else f"fs{i}"
 
-            strategy.append_qemu_args(
+            _append_qemu_args(
+                strategy, fail,
                 "-fsdev", f"local,security_model=none,id=fs{i},path={path}",
                 "-device", f"virtio-9p-{virtio},id=fs{i},fsdev=fs{i},mount_tag={tag}"
             )
         else:
-            pytest.exit("--fs unsupported for target\n", 1)
-
-    state = request.config.option.lg_initial_state
-    if state is not None:
-        strategy.force(state)
-
-    return strategy
+            fail("--fs unsupported for target\n")
 
 
-@pytest.fixture(scope="session")
-def testfs(strategy, env):
-    if "testfs" not in env.get_target_features():
-        pytest.skip("testfs not supported on this platform")
+def replacement_message(mode):
+    return (f"pytest {mode} is no longer supported. Use "
+            "scripts/qemu_interactive.py instead.")
 
-    path = os.path.join(os.environ["LG_BUILDDIR"], "testfs")
-    return path
+
+def _split_qemu_args(argv):
+    for i, arg in enumerate(argv):
+        if arg in ("--qemu", "--"):
+            return argv[:i], argv[i + 1:]
+        if arg.startswith("--qemu="):
+            return argv[:i], [arg.split("=", 1)[1]] + argv[i + 1:]
+
+    return argv, []
+
+
+def build_arg_parser():
+    parser = argparse.ArgumentParser(
+        description="Start a barebox labgrid QEMU target interactively",
+        epilog="Raw QEMU arguments may follow either --qemu or --."
+    )
+
+    register_interactive_options(
+        parser.add_argument_group("qemu_interactive.py options"))
+    register_shared_options(parser.add_argument_group("QEMU options"))
+    parser.add_argument("lg_env_pos", nargs="*", metavar="LG_ENV",
+                        help="labgrid environment config file")
+
+    return parser
+
+
+def parse_args(argv=None):
+    if argv is None:
+        argv = sys.argv[1:]
+
+    parser = build_arg_parser()
+    argv, qemu_args = _split_qemu_args(list(argv))
+    args = parser.parse_args(argv)
+
+    if len(args.lg_env_pos) > 1:
+        parser.error("only one positional labgrid environment may be specified")
+
+    if args.lg_env_pos:
+        if args.lg_env is not None:
+            parser.error("labgrid environment specified both positionally and with --lg-env")
+        args.lg_env = args.lg_env_pos[0]
+
+    del args.lg_env_pos
+    args.qemu_arg = qemu_args
+
+    return args
+
+
+def quote_cmd(cmd):
+    quoted = map(lambda s: s if s.find(" ") == -1 else "'" + s + "'", cmd)
+    return " ".join(quoted)
+
+
+def build_qemu_command(strategy, mode):
+    if strategy.qemu is None:
+        raise QemuInteractiveError(f"Can't enter {mode} for non-QEMU target")
+
+    if mode != MODE_DRY_RUN:
+        strategy.transition("off")
+
+    if mode == MODE_DUMP_DTB:
+        strategy.qemu.machine += f",dumpdtb={strategy.target.name}.dtb"
+
+    cmd = strategy.qemu.get_qemu_base_args()
+    cmd.extend(["-serial", "mon:stdio", "-trace", "file=/dev/null"])
+
+    return cmd
+
+
+def run_qemu(strategy, mode):
+    if mode not in (MODE_INTERACTIVE, MODE_DRY_RUN, MODE_DUMP_DTB):
+        raise QemuInteractiveError(
+            "Can only force to: qemu_dry_run, qemu_interactive, qemu_dump_dtb")
+
+    cmd = build_qemu_command(strategy, mode)
+    command = "running: \n{}\n".format(quote_cmd(cmd))
+
+    if mode == MODE_DRY_RUN:
+        print(command, end="")
+        return 0
+
+    with open("/dev/tty", "r+b", buffering=0) as tty:
+        tty.write(bytes(command, "UTF-8"))
+        return subprocess.run(cmd, stdin=tty, stdout=tty, stderr=tty).returncode
+
+
+def main(argv=None):
+    from labgrid import Environment
+    from labgrid.exceptions import NoDriverFoundError
+
+    args = parse_args(argv)
+    env = None
+
+    try:
+        resolve_lg_builddir()
+        lg_env = resolve_lg_env(args.lg_env)
+        if lg_env is None:
+            raise QemuInteractiveError(
+                "no labgrid environment specified; pass --lg-env, "
+                "a positional LG_ENV, set LG_ENV, or build a config with CONFIG_NAME",
+                2)
+
+        env = Environment(lg_env)
+        target = env.get_target("main")
+        if target is None:
+            raise QemuInteractiveError("labgrid environment has no main target")
+
+        try:
+            strategy = target.get_driver("Strategy")
+        except NoDriverFoundError as e:
+            raise QemuInteractiveError(str(e)) from e
+
+        apply_shared_options(strategy, target, args,
+                             interactive=args.qemu_mode != MODE_DUMP_DTB)
+
+        return run_qemu(strategy, args.qemu_mode)
+    except QemuInteractiveError as e:
+        print(f"error: {e}", file=sys.stderr)
+        return e.returncode
+    finally:
+        if env is not None:
+            env.cleanup()
+
+
+if __name__ == "__main__":
+    sys.exit(main())
-- 
2.47.3




^ permalink raw reply	[flat|nested] 5+ messages in thread

* [PATCH 3/5] usb: xhci-hcd: add XHCI over PCI driver
  2026-06-26 12:45 [PATCH 1/5] scripts: duplicate conftest.py as qemu_interactive.py Ahmad Fatoum
  2026-06-26 12:45 ` [PATCH 2/5] test: move interactive QEMU launcher out of pytest Ahmad Fatoum
@ 2026-06-26 12:45 ` Ahmad Fatoum
  2026-06-26 12:45 ` [PATCH 4/5] scripts: qemu_interactive.py: add new --usbblk option Ahmad Fatoum
  2026-06-26 12:45 ` [PATCH 5/5] scripts: qemu_interactive.py: add new --nvmeblk option Ahmad Fatoum
  3 siblings, 0 replies; 5+ messages in thread
From: Ahmad Fatoum @ 2026-06-26 12:45 UTC (permalink / raw)
  To: barebox; +Cc: Ahmad Fatoum

In order to support USB in QEMU Virt, add support for registering over
PCI as well. The probe function looks nearly identical to the DT-probed
xhci_probe with the difference that we map a PCI MMIO region.

To make it readily usable, also enable it in multi_v8_defconfig.

Signed-off-by: Ahmad Fatoum <a.fatoum@barebox.org>
---
 arch/arm/configs/multi_v8_defconfig |  1 +
 drivers/usb/host/Kconfig            |  7 +++
 drivers/usb/host/Makefile           |  1 +
 drivers/usb/host/xhci-pci.c         | 97 +++++++++++++++++++++++++++++
 drivers/usb/host/xhci.h             |  1 +
 5 files changed, 107 insertions(+)
 create mode 100644 drivers/usb/host/xhci-pci.c

diff --git a/arch/arm/configs/multi_v8_defconfig b/arch/arm/configs/multi_v8_defconfig
index e62dbc96fd03..9712113bf1cd 100644
--- a/arch/arm/configs/multi_v8_defconfig
+++ b/arch/arm/configs/multi_v8_defconfig
@@ -213,6 +213,7 @@ CONFIG_USB_IMX_CHIPIDEA=y
 CONFIG_USB_DWC3=y
 CONFIG_USB_DWC3_DUAL_ROLE=y
 CONFIG_USB_EHCI=y
+CONFIG_USB_XHCI_PCI=y
 CONFIG_USB_STORAGE=y
 CONFIG_USB_ONBOARD_DEV=y
 CONFIG_USB_GADGET=y
diff --git a/drivers/usb/host/Kconfig b/drivers/usb/host/Kconfig
index 58f276cdb45a..21a99dd85b28 100644
--- a/drivers/usb/host/Kconfig
+++ b/drivers/usb/host/Kconfig
@@ -41,3 +41,10 @@ config USB_XHCI
 
 	  This driver currently only supports virtual USB 2.0 ports, if you
 	  plan to use USB 3.0 devices, use a USB 2.0 cable in between.
+
+config USB_XHCI_PCI
+	tristate "xHCI over PCI driver"
+	depends on USB_XHCI
+	help
+	  Say y here if your USB 3.0 controller is connected via PCI and
+	  you wish to access the USB devices on the bus from barebox.
diff --git a/drivers/usb/host/Makefile b/drivers/usb/host/Makefile
index cbddfbe9232e..ed446d0d801c 100644
--- a/drivers/usb/host/Makefile
+++ b/drivers/usb/host/Makefile
@@ -6,3 +6,4 @@ obj-$(CONFIG_USB_EHCI_ZYNQ)	+= ehci-zynq.o
 obj-$(CONFIG_USB_OHCI)		+= ohci-hcd.o
 obj-$(CONFIG_USB_OHCI_AT91)	+= ohci-at91.o
 obj-$(CONFIG_USB_XHCI)		+= xhci.o xhci-mem.o xhci-ring.o
+obj-$(CONFIG_USB_XHCI_PCI)	+= xhci-pci.o
diff --git a/drivers/usb/host/xhci-pci.c b/drivers/usb/host/xhci-pci.c
new file mode 100644
index 000000000000..ebd64b347430
--- /dev/null
+++ b/drivers/usb/host/xhci-pci.c
@@ -0,0 +1,97 @@
+// SPDX-License-Identifier: GPL-2.0
+
+#include <linux/pci.h>
+#include <linux/slab.h>
+#include <linux/module.h>
+#include <linux/reset.h>
+#include <module.h>
+
+#include "xhci.h"
+
+static const char hcd_name[] = "xhci_hcd";
+
+static int xhci_pci_common_probe(struct pci_dev *pdev, const struct pci_device_id *id)
+{
+	struct device *dev = &pdev->dev;
+	struct xhci_ctrl *ctrl;
+	void __iomem *base;
+	int ret;
+
+	(void)id;
+
+	ret = pci_enable_device(pdev);
+	if (ret)
+		return ret;
+
+	pci_set_master(pdev);
+
+	base = pci_iomap(pdev, 0);
+	if (!base) {
+		ret = dev_err_probe(dev, -EBUSY, "failed to map BAR0\n");
+		goto err_clear_master;
+	}
+
+	ctrl = xzalloc(sizeof(*ctrl));
+	ctrl->dev = dev;
+	ctrl->hccr = base;
+	ctrl->hcor = (struct xhci_hcor *)((uintptr_t)ctrl->hccr +
+		HC_LENGTH(xhci_readl(&(ctrl->hccr)->cr_capbase)));
+
+	dev->priv = ctrl;
+
+	ret = xhci_register(ctrl);
+	if (ret)
+		goto err_free_ctrl;
+
+	return 0;
+
+err_free_ctrl:
+	dev->priv = NULL;
+	free(ctrl);
+err_clear_master:
+	pci_clear_master(pdev);
+	return ret;
+}
+
+static int xhci_pci_probe(struct pci_dev *dev, const struct pci_device_id *id)
+{
+	return xhci_pci_common_probe(dev, id);
+}
+
+static void xhci_pci_remove(struct pci_dev *dev)
+{
+	struct xhci_ctrl *ctrl = dev->dev.priv;
+
+	xhci_deregister(ctrl);
+	pci_clear_master(dev);
+	free(ctrl);
+}
+
+/*-------------------------------------------------------------------------*/
+
+/* PCI driver selection metadata; PCI hotplugging uses this */
+static const struct pci_device_id pci_ids[] = {
+	/* handle any USB 3.0 xHCI controller */
+	{ PCI_DEVICE_CLASS(PCI_CLASS_SERIAL_USB_XHCI, ~0),
+	},
+	{ /* end: all zeroes */ }
+};
+MODULE_DEVICE_TABLE(pci, pci_ids);
+
+/* pci driver glue; this is a "new style" PCI driver module */
+static struct pci_driver xhci_pci_driver = {
+	.name =		hcd_name,
+	.id_table =	pci_ids,
+
+	.probe =	xhci_pci_probe,
+	.remove =	xhci_pci_remove,
+};
+
+static int __init xhci_pci_init(void)
+{
+	return pci_register_driver(&xhci_pci_driver);
+}
+module_init(xhci_pci_init);
+
+MODULE_DESCRIPTION("xHCI PCI Host Controller Driver");
+MODULE_LICENSE("GPL");
diff --git a/drivers/usb/host/xhci.h b/drivers/usb/host/xhci.h
index 37e8cee843cf..e5feb4f3d5a1 100644
--- a/drivers/usb/host/xhci.h
+++ b/drivers/usb/host/xhci.h
@@ -20,6 +20,7 @@
 #include <io.h>
 #include <io-64-nonatomic-lo-hi.h>
 #include <linux/list.h>
+#include <linux/usb/usb.h>
 
 #define MAX_EP_CTX_NUM		31
 #define XHCI_ALIGNMENT		64
-- 
2.47.3




^ permalink raw reply	[flat|nested] 5+ messages in thread

* [PATCH 4/5] scripts: qemu_interactive.py: add new --usbblk option
  2026-06-26 12:45 [PATCH 1/5] scripts: duplicate conftest.py as qemu_interactive.py Ahmad Fatoum
  2026-06-26 12:45 ` [PATCH 2/5] test: move interactive QEMU launcher out of pytest Ahmad Fatoum
  2026-06-26 12:45 ` [PATCH 3/5] usb: xhci-hcd: add XHCI over PCI driver Ahmad Fatoum
@ 2026-06-26 12:45 ` Ahmad Fatoum
  2026-06-26 12:45 ` [PATCH 5/5] scripts: qemu_interactive.py: add new --nvmeblk option Ahmad Fatoum
  3 siblings, 0 replies; 5+ messages in thread
From: Ahmad Fatoum @ 2026-06-26 12:45 UTC (permalink / raw)
  To: barebox; +Cc: Ahmad Fatoum

To be able to easily exercise the new xhci-pci support, add into
qemu_interactive.py a new --usbblk option that behaves like --blk, but
instead of creating a Virt I/O block device, creates a removable USB
storage.

As the ARM 64-bit Virt machine has no USB host by default, enable
xhci-pci there, so it can be readily used.

Signed-off-by: Ahmad Fatoum <a.fatoum@barebox.org>
---
 scripts/qemu_interactive.py           | 10 ++++++++++
 test/arm/virt@multi_v8_defconfig.yaml |  1 +
 2 files changed, 11 insertions(+)

diff --git a/scripts/qemu_interactive.py b/scripts/qemu_interactive.py
index a8605d23b132..827a831725ba 100755
--- a/scripts/qemu_interactive.py
+++ b/scripts/qemu_interactive.py
@@ -49,6 +49,9 @@ def register_shared_options(parser):
     _add_option(parser, "--blk", action="append", dest="qemu_block",
                 default=[], metavar="FILE",
                 help="Pass block device to emulated barebox. Can be specified more than once")
+    _add_option(parser, "--usbblk", action="append", dest="qemu_usbblock",
+                default=[], metavar="FILE",
+                help="Pass USB block device to emulated barebox. Can be specified more than once")
     _add_option(parser, "--env", action="append", dest="qemu_fw_cfg",
                 type=_assignment, default=[],
                 metavar="[envpath=]content | [envpath=]@filepath",
@@ -257,6 +260,13 @@ def apply_shared_options(strategy, target, options, *, interactive, fail=None):
         else:
             fail("--blk unsupported for target\n")
 
+    for i, blk in enumerate(_get_list_option(options, "qemu_usbblock")):
+        _append_qemu_args(
+            strategy, fail,
+            "-drive", f"if=none,format=raw,id=usbstorage{i},file={blk}",
+            "-device", f"usb-storage,drive=usbstorage{i},bus=usb-bus.0,removable=on"
+        )
+
     envopts = {}
 
     for i, fw_cfg in enumerate(_get_list_option(options, "qemu_fw_cfg")):
diff --git a/test/arm/virt@multi_v8_defconfig.yaml b/test/arm/virt@multi_v8_defconfig.yaml
index 4eb75da4610e..60707c951c2e 100644
--- a/test/arm/virt@multi_v8_defconfig.yaml
+++ b/test/arm/virt@multi_v8_defconfig.yaml
@@ -8,6 +8,7 @@ targets:
         memory: 1024M
         bios: barebox-qemu-virt.img
         display: qemu-default
+        extra_args: '-device qemu-xhci'
       BareboxDriver:
         prompt: 'barebox@[^:]+:[^ ]+ '
         bootstring: 'commandline:'
-- 
2.47.3




^ permalink raw reply	[flat|nested] 5+ messages in thread

* [PATCH 5/5] scripts: qemu_interactive.py: add new --nvmeblk option
  2026-06-26 12:45 [PATCH 1/5] scripts: duplicate conftest.py as qemu_interactive.py Ahmad Fatoum
                   ` (2 preceding siblings ...)
  2026-06-26 12:45 ` [PATCH 4/5] scripts: qemu_interactive.py: add new --usbblk option Ahmad Fatoum
@ 2026-06-26 12:45 ` Ahmad Fatoum
  3 siblings, 0 replies; 5+ messages in thread
From: Ahmad Fatoum @ 2026-06-26 12:45 UTC (permalink / raw)
  To: barebox; +Cc: Ahmad Fatoum

To be able to test the recent non-512-byte block support, add a
--nvmeblk option that behaves like --blk, but appears to the guest as
block device.

Signed-off-by: Ahmad Fatoum <a.fatoum@barebox.org>
---
 arch/arm/configs/multi_v8_defconfig |  2 ++
 scripts/qemu_interactive.py         | 10 ++++++++++
 2 files changed, 12 insertions(+)

diff --git a/arch/arm/configs/multi_v8_defconfig b/arch/arm/configs/multi_v8_defconfig
index 9712113bf1cd..5ea63372d050 100644
--- a/arch/arm/configs/multi_v8_defconfig
+++ b/arch/arm/configs/multi_v8_defconfig
@@ -103,6 +103,7 @@ CONFIG_CMD_MAGICVAR_HELP=y
 CONFIG_CMD_SAVEENV=y
 CONFIG_CMD_FILETYPE=y
 CONFIG_CMD_LN=y
+CONFIG_CMD_STAT=y
 CONFIG_CMD_MD5SUM=y
 CONFIG_CMD_SHA1SUM=y
 CONFIG_CMD_SHA224SUM=y
@@ -284,6 +285,7 @@ CONFIG_PHY_ROCKCHIP_USBDP=y
 CONFIG_ROCKCHIP_IODOMAIN=y
 CONFIG_ROCKCHIP_PM_DOMAINS=y
 CONFIG_TI_SCI_PM_DOMAINS=y
+CONFIG_BLK_DEV_NVME=y
 CONFIG_NVMEM_REBOOT_MODE=y
 CONFIG_VIRTIO_MMIO=y
 CONFIG_VIRTIO_PCI=y
diff --git a/scripts/qemu_interactive.py b/scripts/qemu_interactive.py
index 827a831725ba..77ff6f9fc8b4 100755
--- a/scripts/qemu_interactive.py
+++ b/scripts/qemu_interactive.py
@@ -49,6 +49,9 @@ def register_shared_options(parser):
     _add_option(parser, "--blk", action="append", dest="qemu_block",
                 default=[], metavar="FILE",
                 help="Pass block device to emulated barebox. Can be specified more than once")
+    _add_option(parser, "--nvmeblk", action="append", dest="qemu_nvmeblock",
+                default=[], metavar="FILE",
+                help="Pass NVMe block device with 4K sector size to emulated barebox. Can be specified more than once")
     _add_option(parser, "--usbblk", action="append", dest="qemu_usbblock",
                 default=[], metavar="FILE",
                 help="Pass USB block device to emulated barebox. Can be specified more than once")
@@ -260,6 +263,13 @@ def apply_shared_options(strategy, target, options, *, interactive, fail=None):
         else:
             fail("--blk unsupported for target\n")
 
+    for i, blk in enumerate(_get_list_option(options, "qemu_nvmeblock")):
+        _append_qemu_args(
+            strategy, fail,
+            "-drive", f"if=none,format=raw,id=nvme{i},file={blk}",
+            "-device", f"nvme,drive=nvme{i},serial=0ba2eb08,logical_block_size=4096,physical_block_size=4096"
+        )
+
     for i, blk in enumerate(_get_list_option(options, "qemu_usbblock")):
         _append_qemu_args(
             strategy, fail,
-- 
2.47.3




^ permalink raw reply	[flat|nested] 5+ messages in thread

end of thread, other threads:[~2026-06-26 12:47 UTC | newest]

Thread overview: 5+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-06-26 12:45 [PATCH 1/5] scripts: duplicate conftest.py as qemu_interactive.py Ahmad Fatoum
2026-06-26 12:45 ` [PATCH 2/5] test: move interactive QEMU launcher out of pytest Ahmad Fatoum
2026-06-26 12:45 ` [PATCH 3/5] usb: xhci-hcd: add XHCI over PCI driver Ahmad Fatoum
2026-06-26 12:45 ` [PATCH 4/5] scripts: qemu_interactive.py: add new --usbblk option Ahmad Fatoum
2026-06-26 12:45 ` [PATCH 5/5] scripts: qemu_interactive.py: add new --nvmeblk option Ahmad Fatoum

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox