> This resurrects the hand run test script from d84842be6c5 (tests:
> remove test-gdbstub.py) and modernises it to use the shared test
> infrastructure for run-test.
>
> We then tweak the common tuxrun code to over an alternative "launch
> via gdbstub" helper which reuses the existing code but:
>
> - needs a vmlinux with symbols
> - drops kaslr because it doesn't play nice with symbols
> - extend qargs with stuff normally added by vm.launch
> - passes kargs directly
> - runs with the gdb run-test script
> - log the run-test output to the console log
> - makes --binary not mandatory
>
> Message-ID: <[email protected]>
> Signed-off-by: Alex Bennée <[email protected]>
>
> Message-ID: <[email protected]>
> ---
> v3
> - mention the nokaslr in the commit message
> - add a comment about it to the shared kernel args
> - drop unused failcount
> - remove stale comment about hbreak
> - always delete the watch point pass or fail
> - fix unbalanced tuple in sh4's direct call of fetch_tuxrun_assets
> - simplify the symbol to watchpoint logic
> - drop the mandatory --binary
> - make the command copy and paste friendly
> v2
> - add self.vm._machine to qargs
> - drop leading spaces in print () calls
> - add register read tests
> - use gdb.Breakpoint for hwbreak as for swbreak
> - drop flaky symbol evaluation, verify hit_count instead
> - explicit gdb.BP_BREAKPOINT for CatchBreakpoint
> ---
> .../functional/gdb-scripts/kernel_gdb_test.py | 162 ++++++++++++++++++
> tests/functional/qemu_test/tuxruntest.py | 97 ++++++++++-
> tests/functional/sh4/test_tuxrun.py | 4 +-
> tests/guest-debug/run-test.py | 14 +-
> 4 files changed, 261 insertions(+), 16 deletions(-)
> create mode 100644 tests/functional/gdb-scripts/kernel_gdb_test.py
>
> diff --git a/tests/functional/gdb-scripts/kernel_gdb_test.py
> b/tests/functional/gdb-scripts/kernel_gdb_test.py
> new file mode 100644
> index 000000000000..f457ccdcbf41
> --- /dev/null
> +++ b/tests/functional/gdb-scripts/kernel_gdb_test.py
> @@ -0,0 +1,162 @@
> +# SPDX-License-Identifier: GPL-2.0-or-later
> +#
> +# Generic Kernel GDB script to verify functionality:
> +# - Single-stepping
> +# - Hardware breakpoints
> +# - Software breakpoints
> +# - Watchpoints
> +#
> +# It relies on fairly common kernel points to break at and symbols for
> +# the kernel. Address space randomisation (kaslr) needs to be disabled.
> +#
> +# Copyright (c) 2026 Linaro Limited
> +#
> +# Author:
> +# Alex Bennée <[email protected]>
> +#
> +# This is launched by the tuxrun functional tests via
> tests/guest-debug/run-test.py
> +
> +import gdb
> +from test_gdbstub import main, report
> +
> +def parse_registers():
> + """Parse 'info registers' into a name -> value dictionary."""
> + regs = {}
> + for line in gdb.execute("info registers", to_string=True).splitlines():
> + parts = line.split()
> + if len(parts) >= 2:
> + regs[parts[0]] = parts[1]
> + return regs
> +
> +
> +def check_step():
> + "Step an instruction, check it moved."
> + start_pc = gdb.parse_and_eval('$pc')
> + gdb.execute("si")
> + end_pc = gdb.parse_and_eval('$pc')
> +
> + return not (start_pc == end_pc)
> +
> +
> +def check_reg_updates(regs_start):
> + regs_end = parse_registers()
> +
> + changed = [k for k in regs_start
> + if k in regs_end and regs_start[k] != regs_end[k]]
> +
> + report(len(changed) > 0, f"{len(changed)} registers changed.")
> +
> +
> +def check_break(sym_name):
> + "Setup breakpoint, continue and check we stopped."
> + bp = gdb.Breakpoint(sym_name)
> + gdb.execute("c")
> + res = bp.hit_count == 1
> + bp.delete()
> +
> + return res
> +
> +
> +def check_hbreak(sym_name):
> + "Setup hardware breakpoint, continue and check we stopped."
> + bp = gdb.Breakpoint(sym_name, type = gdb.BP_HARDWARE_BREAKPOINT)
> + gdb.execute("c")
> + res = bp.hit_count == 1
> + bp.delete()
> +
> + return res
> +
> +
> +class WatchPoint(gdb.Breakpoint):
> +
> + def __init__(self, sym_name, type):
> + super(WatchPoint, self).__init__(sym_name, gdb.BP_WATCHPOINT, type)
> +
> + def stop(self):
> + end_pc = gdb.parse_and_eval('$pc')
> + print("HIT WP @ %s" % (end_pc))
> + return True
> +
> +
> +def do_one_watch(sym, wtype, text):
> +
> + wp = WatchPoint(sym, wtype)
> + gdb.execute("c")
> + report_str = "%s for %s" % (text, sym)
> +
> + if wp.hit_count > 0:
> + report(True, report_str)
> + else:
> + report(False, report_str)
> +
> + wp.delete()
> +
> +
> +def check_watches(sym_name):
> + "Watch a symbol for any access."
> +
> + # Should hit for any read
> + do_one_watch(sym_name, gdb.WP_ACCESS, "awatch")
> +
> + # Again should hit for reads
> + do_one_watch(sym_name, gdb.WP_READ, "rwatch")
> +
> + # Finally when it is written
> + do_one_watch(sym_name, gdb.WP_WRITE, "watch")
> +
> +
> +class CatchBreakpoint(gdb.Breakpoint):
> + def __init__(self, sym_name):
> + super(CatchBreakpoint, self).__init__(sym_name, type =
> gdb.BP_BREAKPOINT)
> + self.sym, ok = gdb.lookup_symbol(sym_name)
> +
> + def stop(self):
> + end_pc = gdb.parse_and_eval('$pc')
> + print("CB: %s == %s" % (end_pc, self.sym.value()))
> + if end_pc == self.sym.value():
> + report(False, "Hit final catchpoint")
> +
> +
> +def run_test():
> + "Run through the tests one by one"
> +
> + # get the initial register state
> + regs_start = parse_registers()
> +
> + print("Checking we can step the first few instructions")
> + step_ok = 0
> + for i in range(3):
> + if check_step():
> + step_ok += 1
> +
> + report(step_ok == 3, "single step in boot code")
> +
> + # check some registers changed
> + print("Checking register access works")
> + check_reg_updates(regs_start)
> +
> + print("Checking HW breakpoint works")
> + break_ok = check_hbreak("kernel_init")
> + report(break_ok, "hbreak @ kernel_init")
> +
> + # Can't set this up until we are in the kernel proper
> + # if we make it to run_init_process we've over-run and
> + # one of the tests failed
> +
> + print("Setup catch-all for run_init_process")
> + cbp = CatchBreakpoint("run_init_process")
> +
> + # We should see wait_for_completion called right at the start of
> + # kernel_init
> + print("Checking Normal breakpoint works")
> + break_ok = check_break("wait_for_completion")
> + report(break_ok, "break @ wait_for_completion")
> +
> + print("Checking watchpoint works")
> + check_watches("system_state")
> +
> + # check we didn't hit the end
> + report(cbp.hit_count == 0, "CatchBreakpoint not hit")
> +
> +
> +main(run_test)
> diff --git a/tests/functional/qemu_test/tuxruntest.py
> b/tests/functional/qemu_test/tuxruntest.py
> index ca1589fea37d..6ad421e897d4 100644
> --- a/tests/functional/qemu_test/tuxruntest.py
> +++ b/tests/functional/qemu_test/tuxruntest.py
> @@ -9,7 +9,9 @@
> #
> # SPDX-License-Identifier: GPL-2.0-or-later
>
> +import logging
> import os
> +import subprocess
>
> from qemu_test import QemuSystemTest
> from qemu_test import exec_command_and_wait_for_pattern
> @@ -18,7 +20,8 @@
>
> class TuxRunBaselineTest(QemuSystemTest):
>
> - KERNEL_COMMON_COMMAND_LINE = 'printk.time=0'
> + # nokaslr is needed for gdb tests but it makes things simpler anyway
> + KERNEL_COMMON_COMMAND_LINE = 'printk.time=0 nokaslr'
> # Tests are ~10-40s, allow for --debug/--enable-gcov overhead
> timeout = 100
>
> @@ -55,17 +58,20 @@ def wait_for_console_pattern(self, success_message,
> vm=None):
> failure_message='Kernel panic - not
> syncing',
> vm=vm)
>
> - def fetch_tuxrun_assets(self, kernel_asset, rootfs_asset,
> dtb_asset=None):
> + def fetch_tuxrun_assets(self, kernel_asset, rootfs_asset,
> + dtb_asset=None, vmlinux_asset=None):
> """
> Fetch the TuxBoot assets.
> """
> kernel_image = kernel_asset.fetch()
> disk_image = self.uncompress(rootfs_asset)
> dtb = dtb_asset.fetch() if dtb_asset is not None else None
> + vmlinux = self.uncompress(vmlinux_asset) if vmlinux_asset is not
> None else None
>
> - return (kernel_image, disk_image, dtb)
> + return (kernel_image, disk_image, dtb, vmlinux)
>
> - def prepare_run(self, accel, kernel, disk, drive, dtb=None,
> console_index=0):
> + def prepare_run(self, accel, kernel, disk, drive,
> + dtb=None, console_index=0, append = True):
> """
> Setup to run and add the common parameters to the system
> """
> @@ -83,9 +89,15 @@ def prepare_run(self, accel, kernel, disk, drive,
> dtb=None, console_index=0):
> if accel:
> self.vm.add_args('-accel', accel)
>
> - self.vm.add_args('-kernel', kernel,
> - '-append', self.kcmd_line,
> - '-blockdev', blockdev)
> + if kernel:
> + self.vm.add_args('-kernel', kernel)
> +
> + # When using the gdb run script we might not want to include
> + # the kcmd line directly in the QEMU args.
> + if append:
> + self.vm.add_args('-append', self.kcmd_line)
> +
> + self.vm.add_args('-blockdev', blockdev)
>
> # Sometimes we need extra devices attached
> if self.extradev:
> @@ -132,10 +144,77 @@ def common_tuxrun(self,
> special with the command line we can process most things using
> the tag metadata.
> """
> - (kernel, disk, dtb) = self.fetch_tuxrun_assets(kernel_asset,
> rootfs_asset,
> - dtb_asset)
> + (kernel, disk, dtb, _) = self.fetch_tuxrun_assets(kernel_asset,
> + rootfs_asset,
> + dtb_asset)
>
> self.prepare_run(accel, kernel, disk, drive, dtb, console_index)
> self.vm.launch()
> self.run_tuxtest_tests(haltmsg)
> os.remove(disk)
> +
> + def common_tuxrun_gdb(self,
> + kernel_asset,
> + vmlinux_asset,
> + rootfs_asset,
> + accel=None,
> + dtb_asset=None,
> + drive="virtio-blk-device",
> + haltmsg="reboot: System halted",
haltmsg is unused, drop it?
> + console_index=0):
> + """
> + Common path for LKFT tests under gdb. Unlike the common_tuxrun
> + function we need to launch with the gdb test script and deal
> + with munging the arguments for that especially.
> + """
> + (kernel, disk, dtb, vmlinux) = self.fetch_tuxrun_assets(kernel_asset,
> + rootfs_asset,
> + dtb_asset,
> +
> vmlinux_asset)
> +
> + gdb_path = os.getenv("QEMU_TEST_GDB")
perhaps assert gdb_path is set (or raise SkipTest)?
> + run_script = self.data_file('..', 'guest-debug','run-test.py')
> + test_script = self.data_file('gdb-scripts', 'kernel_gdb_test.py')
> +
> + # Instead of launching QEMU via the functional test machinery
> + # we need to launch it with the gdb run script. We utilise the
> + # existing prepare_run machinery to build the command line but
> + # we pass the kernel args separately to avoid horrible string
> + # escaping.
> +
> + self.prepare_run(accel, kernel, disk, drive, dtb,
> + console_index, append = False)
> +
> + # the functional test machinery adds more stuff for handling
> + # console redirection which we need to do manually.
> +
> + qargs = self.vm.args
> + qargs.extend(["-display", "none", "-serial", "stdio"])
> +
> + # and the machine type
> + if self.vm._machine is not None:
> + qargs.extend(["-machine", self.vm._machine])
> +
> + cmd = [
> + str(run_script),
> + "--qemu", self.qemu_bin,
> + "--qargs", " ".join(qargs),
use shlex.join(qargs)
> + "--kargs", self.kcmd_line,
> + "--symbols", str(vmlinux),
> + "--test", str(test_script),
> + "--gdb", gdb_path,
> + ]
> +
> + log_cmd = " ".join(f'"{arg}"' if ' ' in arg else arg for arg in cmd)
> + self.log.info(f"run-test: {log_cmd}")
> +
> + res = subprocess.run(cmd, text=True,
> + stdout=subprocess.PIPE,
> + stderr=subprocess.STDOUT)
> +
> + if res.stdout:
> + console_logger = logging.getLogger('console')
> + console_logger.info(res.stdout)
> +
> + self.assertEqual(res.returncode, 0,
Include res.stdout in the assertion message (or call self.fail with it) so the
GDB/QEMU transcript is visible when this fails in CI?
> + f"run-test.py failed ({res.returncode}):\n")
Unlike common_tuxrun(), this helper never removes the decompressed
rootfs image. Add os.remove(disk) after the run?
> diff --git a/tests/functional/sh4/test_tuxrun.py
> b/tests/functional/sh4/test_tuxrun.py
> index 42b503585b2d..07c7ee6e03ba 100755
> --- a/tests/functional/sh4/test_tuxrun.py
> +++ b/tests/functional/sh4/test_tuxrun.py
> @@ -31,8 +31,8 @@ def test_sh4(self):
>
> # The test is currently too unstable to do much in userspace
> # so we skip common_tuxrun and do a minimal boot and shutdown.
> - (kernel, disk, dtb) = self.fetch_tuxrun_assets(self.ASSET_SH4_KERNEL,
> - self.ASSET_SH4_ROOTFS)
> + (kernel, disk, dtb, _) =
> self.fetch_tuxrun_assets(self.ASSET_SH4_KERNEL,
> +
> self.ASSET_SH4_ROOTFS)
>
> # the console comes on the second serial port
> self.prepare_run(None, kernel, disk,
> diff --git a/tests/guest-debug/run-test.py b/tests/guest-debug/run-test.py
> index e74bb489ee19..48a5261618de 100755
> --- a/tests/guest-debug/run-test.py
> +++ b/tests/guest-debug/run-test.py
> @@ -26,8 +26,7 @@ def get_args():
> parser.add_argument("--qargs", help="Qemu arguments for test")
> parser.add_argument("--pargs", help="Program arguments for test")
> parser.add_argument("--kargs", help="Kernel args to --append")
> - parser.add_argument("--binary", help="Binary to debug",
> - required=True)
> + parser.add_argument("--binary", help="Binary to debug")
Now that --binary is optional, the user-mode branch below still does
qemu_cmd.append(args.binary) unconditionally. If --binary is omitted for a
non-system QEMU, subprocess.Popen() will get None and raise TypeError.
assert args.binary in the user-mode branch?
--
Marc-André Lureau <[email protected]>