diff --git a/.gitignore b/.gitignore index b8bf959..ef91c63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ qemu-binaries/ *.pyc +shared/ +.vfsd.* diff --git a/boot-qemu.py b/boot-qemu.py index eaa86a6..5878e6f 100755 --- a/boot-qemu.py +++ b/boot-qemu.py @@ -2,6 +2,8 @@ # pylint: disable=invalid-name import argparse +import contextlib +import grp import os from pathlib import Path import platform @@ -13,6 +15,7 @@ import utils base_folder = Path(__file__).resolve().parent +shared_folder = base_folder.joinpath('shared') supported_architectures = [ "arm", "arm32_v5", "arm32_v6", "arm32_v7", "arm64", "arm64be", "m68k", "mips", "mipsel", "ppc32", "ppc32_mac", "ppc64", "ppc64le", "riscv", @@ -83,6 +86,12 @@ def parse_arguments(): help= # noqa: E251 "Number of processors for virtual machine. By default, only machines spawned with KVM will use multiple vCPUS." ) + parser.add_argument( + "--share-folder", + action='store_true', + help= # noqa: E251 + f"Share {shared_folder} with the guest using virtiofs (requires interactive, not supported with gdb)." + ) parser.add_argument( "-t", "--timeout", @@ -145,6 +154,42 @@ def can_use_kvm(can_test_for_kvm, guest_arch): return False +def get_config_val(kernel_arg, config_key): + """ + Attempt to get configuration value from a .config relative to + kernel_location. + + Parameters: + kernel_arg (str): The value of the '--kernel' argument. + + Returns: + The configuration value if it can be found, None if not. + """ + # kernel_arg is either a path to the kernel source or a full kernel + # location. If it is a file, we need to strip off the basename so that we + # can easily navigate around with '..'. + if (kernel_dir := Path(kernel_arg)).is_file(): + kernel_dir = kernel_dir.parent + + # If kernel_location is the kernel source, the configuration will be at + # /.config + # + # If kernel_location is a full kernel location, it could either be: + # * /.config (if the image is vmlinux) + # * /../../../.config (if the image is in arch/*/boot/) + # * /config (if the image is in a TuxMake folder) + config_locations = [".config", "../../../.config", "config"] + if (config_file := utils.find_first_file(kernel_dir, + config_locations, + required=False)): + config_txt = config_file.read_text(encoding='utf-8') + if (match := re.search(f"^{config_key}=(.*)$", config_txt, + flags=re.M)): + return match.groups()[0] + + return None + + def get_smp_value(args): """ Get the value of '-smp' based on user input and kernel configuration. @@ -169,41 +214,16 @@ def get_smp_value(args): if args.smp: return args.smp - # kernel_location is either a path to the kernel source or a full kernel - # location. If it is a file, we need to strip off the basename so that we - # can easily navigate around with '..'. - kernel_dir = Path(args.kernel_location) - if kernel_dir.is_file(): - kernel_dir = kernel_dir.parent - - # If kernel_location is the kernel source, the configuration will be at - # /.config - # - # If kernel_location is a full kernel location, it could either be: - # * /.config (if the image is vmlinux) - # * /../../../.config (if the image is in arch/*/boot/) - # * /config (if the image is in a TuxMake folder) - config_file = None - for config_name in [".config", "../../../.config", "config"]: - config_path = kernel_dir.joinpath(config_name) - if config_path.is_file(): - config_file = config_path - break - # Choose a sensible default value based on treewide defaults for # CONFIG_NR_CPUS then get the actual value if possible. - config_nr_cpus = 8 - if config_file: - with open(config_file, encoding='utf-8') as file: - for line in file: - if "CONFIG_NR_CPUS=" in line: - config_nr_cpus = int(line.split("=", 1)[1]) - break + if not (config_nr_cpus := get_config_val(args.kernel_location, + 'CONFIG_NR_CPUS')): + config_nr_cpus = 8 # Use the minimum of the number of usable processors for the script or # CONFIG_NR_CPUS. usable_cpus = os.cpu_count() - return min(usable_cpus, config_nr_cpus) + return min(usable_cpus, int(config_nr_cpus)) def setup_cfg(args): @@ -223,6 +243,7 @@ def setup_cfg(args): * interactive: Whether or not the user is going to be running the machine interactively. * kernel_location: The full path to the kernel image or build folder. + * share_folder_with_guest: Share a folder on the host with a guest. * smp_requested: Whether or not the user specified a value with '--smp'. * smp_value: The value to use with '-smp' (will be used when @@ -248,6 +269,7 @@ def setup_cfg(args): "gdb": args.gdb, "gdb_bin": args.gdb_bin, "interactive": args.interactive or args.gdb, + "share_folder_with_guest": args.share_folder, "smp_requested": args.smp is not None, "smp_value": get_smp_value(args), "timeout": args.timeout, @@ -380,13 +402,11 @@ def get_efi_args(guest_arch): Path("edk2/aarch64/QEMU_EFI.fd"), # Arch Linux (current) Path("edk2-armvirt/aarch64/QEMU_EFI.fd"), # Arch Linux (old) Path("qemu-efi-aarch64/QEMU_EFI.fd"), # Debian and Ubuntu - None # Terminator ], "x86_64": [ Path("edk2/x64/OVMF_CODE.fd"), # Arch Linux (current), Fedora Path("edk2-ovmf/x64/OVMF_CODE.fd"), # Arch Linux (old) Path("OVMF/OVMF_CODE.fd"), # Debian and Ubuntu - None # Terminator ] } # yapf: disable @@ -396,12 +416,8 @@ def get_efi_args(guest_arch): ) return [] - for efi_img_location in efi_img_locations[guest_arch]: - if efi_img_location is None: - raise Exception(f"edk2 could not be found for {guest_arch}!") - efi_img = Path("/usr/share", efi_img_location) - if efi_img.exists(): - break + usr_share = Path('/usr/share') + efi_img = utils.find_first_file(usr_share, efi_img_locations[guest_arch]) if guest_arch == "arm64": # Sizing the images to 64M is recommended by "Prepare the firmware" section at @@ -424,15 +440,8 @@ def get_efi_args(guest_arch): efi_vars_locations = [ Path("edk2/x64/OVMF_VARS.fd"), # Arch Linux and Fedora Path("OVMF/OVMF_VARS.fd"), # Debian and Ubuntu - None # Terminator ] - for efi_vars_location in efi_vars_locations: - if efi_vars_location is None: - raise Exception("OVMF_VARS.fd could not be found!") - efi_vars = Path('/usr/share', efi_vars_location) - if efi_vars.exists(): - break - + efi_vars = utils.find_first_file(usr_share, efi_vars_locations) efi_vars_qemu = base_folder.joinpath("images", guest_arch, efi_vars.name) shutil.copyfile(efi_vars, efi_vars_qemu) @@ -700,7 +709,7 @@ def pretty_print_qemu_info(qemu): qemu_version_string = get_qemu_ver_string(qemu) utils.green(f"QEMU location: \033[0m{qemu_dir}") - utils.green(f"QEMU version: \033[0m{qemu_version_string}\n") + utils.green(f"QEMU version: \033[0m{qemu_version_string}") def pretty_print_qemu_cmd(qemu_cmd): @@ -725,76 +734,201 @@ def pretty_print_qemu_cmd(qemu_cmd): qemu_cmd_pretty += f' {element.split("/")[-1]}' else: qemu_cmd_pretty += f" {element}" - print(f"$ {qemu_cmd_pretty.strip()}", flush=True) + print(f"\n$ {qemu_cmd_pretty.strip()}", flush=True) + +def launch_qemu_gdb(cfg): + """ + Spawn QEMU in the background with '-s -S' and call gdb_bin against + 'vmlinux' with the target remote command. This is repeated until the user + quits. -def launch_qemu(cfg): + Parameters: + cfg (dict): The configuration dictionary generated with setup_cfg(). """ - Runs the QEMU command generated from get_qemu_args(), depending on whether - or not the user wants to debug with GDB. + gdb_bin = cfg["gdb_bin"] + kernel_location = cfg["kernel_location"] + qemu_cmd = cfg["qemu_cmd"] + ['-s', '-S'] + + if cfg['share_folder_with_guest']: + utils.yellow( + 'Shared folder requested during a debugging session, ignoring...') - If debugging with GDB, QEMU is called with '-s -S' in the background then - gdb_bin is called against 'vmlinux' connected to the target remote. This - can be repeated multiple times. + # Make sure necessary commands are present + utils.check_cmd(gdb_bin) + utils.check_cmd('lsof') - Otherwise, QEMU is called with 'timeout' so that it is terminated if there - is a problem while booting, passing along any error code that is returned. + # Generate gdb command and add necessary arguments to QEMU command + gdb_cmd = [ + gdb_bin, + kernel_location.joinpath('vmlinux'), + '-ex', 'target remove :1234' + ] # yapf: disable + + while True: + lsof = subprocess.run(['lsof', '-i:1234'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False) + if lsof.returncode == 0: + utils.die("Port 1234 is already in use, is QEMU running?") + + utils.green("Starting QEMU with GDB connection on port 1234...") + with subprocess.Popen(qemu_cmd, preexec_fn=os.setpgrp) as qemu_process: + utils.green("Starting GDB...") + with subprocess.Popen(gdb_cmd) as gdb_process: + try: + gdb_process.wait() + except KeyboardInterrupt: + pass + + utils.red("Killing QEMU...") + qemu_process.kill() + + answer = input("Re-run QEMU + gdb? [y/n] ") + if answer.lower() == "n": + break + + +def find_virtiofsd(qemu_prefix): + """ + Find virtiofsd relative to qemu_prefix. + + Parameters: + qemu_prefix (Path): A Path object pointing to QEMU's installation prefix. + + Returns: + The full path to virtiofsd. + """ + virtiofsd_locations = [ + Path('libexec', 'virtiofsd'), # Default QEMU installation, Fedora + Path('lib', 'qemu', 'virtiofsd'), # Arch Linux, Debian, Ubuntu + ] + return utils.find_first_file(qemu_prefix, virtiofsd_locations) + + +def get_and_call_sudo(): + """ + Get the full path to a sudo binary and call it to gain sudo permission to + run virtiofsd in the background. virtiofsd is spawned in the background so + we cannot interact with it; getting permission beforehand allows everything + to work properly. + + Returns: + The full path to a suitable sudo binary. + """ + if not (sudo := shutil.which('sudo')): + raise Exception( + 'sudo is required to use virtiofsd but it could not be found!') + utils.green( + 'Requesting sudo permission to run virtiofsd in the background...') + subprocess.run([sudo, 'true'], check=True) + return sudo + + +def get_virtiofsd_cmd(qemu_path, socket_path): + """ + Generate a virtiofsd command suitable for running through + subprocess.Popen(). + + This is the command as recommended by the virtio-fs website: + https://virtio-fs.gitlab.io/howto-qemu.html + + Parameters: + qemu_path (Path): An absolute path to the QEMU binary being used. + socket_path (Path): An absolute path to the socket file virtiofsd + will use to communicate with QEMU. + + Returns: + The virtiofsd command as a list. + """ + sudo = get_and_call_sudo() + virtiofsd = find_virtiofsd(qemu_path.resolve().parent.parent) + + return [ + sudo, + virtiofsd, + f"--socket-group={grp.getgrgid(os.getgid()).gr_name}", + f"--socket-path={socket_path}", + '-o', f"source={shared_folder}", + '-o', 'cache=always', + ] # yapf: disable + + +def get_virtiofs_qemu_args(mem_path, qemu_mem, socket_path): + """ + Generate a list of arguments for QEMU to use virtiofs. + + These are the arguments as recommended by the virtio-fs website: + https://virtio-fs.gitlab.io/howto-qemu.html + + Parameters: + mem_path (Path): An absolute path to the memory file that virtiofs will + be using. + qemu_mem (str): The amount of memory QEMU will be using. + socket_path (Path): An absolute path to the socket file virtiofsd + will use to communicate with QEMU. + """ + return [ + '-chardev', f"socket,id=char0,path={socket_path}", + '-device', 'vhost-user-fs-pci,queue-size=1024,chardev=char0,tag=shared', + '-object', f"memory-backend-file,id=shm,mem-path={mem_path},share=on,size={qemu_mem}", + '-numa', 'node,memdev=shm', + ] # yapf: disable + + +def launch_qemu_fg(cfg): + """ + Spawn QEMU in the foreground, using 'timeout' if running non-interactively + to see if the kernel successfully gets to userspace. Parameters: cfg (dict): The configuration dictionary generated with setup_cfg(). """ interactive = cfg["interactive"] - gdb = cfg["gdb"] - gdb_bin = cfg["gdb_bin"] kernel_location = cfg["kernel_location"] - qemu_cmd = cfg["qemu_cmd"] + qemu_cmd = cfg["qemu_cmd"] + ["-serial", "mon:stdio"] + share_folder_with_guest = cfg["share_folder_with_guest"] timeout = cfg["timeout"] - # Print information about the QEMU binary - pretty_print_qemu_info(qemu_cmd[0]) + if share_folder_with_guest and not interactive: + utils.yellow( + 'Shared folder requested without an interactive session, ignoring...' + ) + share_folder_with_guest = False - if gdb: - utils.check_cmd(gdb_bin) - qemu_cmd += ["-s", "-S"] - - while True: - utils.check_cmd("lsof") - lsof = subprocess.run(["lsof", "-i:1234"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False) - if lsof.returncode == 0: - utils.die("Port 1234 is already in use, is QEMU running?") - - utils.green("Starting QEMU with GDB connection on port 1234...") - with subprocess.Popen(qemu_cmd, - preexec_fn=os.setpgrp) as qemu_process: - utils.green("Starting GDB...") - gdb_cmd = [gdb_bin] - gdb_cmd += [kernel_location.joinpath("vmlinux")] - gdb_cmd += ["-ex", "target remote :1234"] - - with subprocess.Popen(gdb_cmd) as gdb_process: - try: - gdb_process.wait() - except KeyboardInterrupt: - pass - - utils.red("Killing QEMU...") - qemu_process.kill() - - answer = input("Re-run QEMU + gdb? [y/n] ") - if answer.lower() == "n": - break - else: - qemu_cmd += ["-serial", "mon:stdio"] + if share_folder_with_guest: + if not get_config_val(kernel_location, 'CONFIG_VIRTIO_FS'): + utils.yellow( + 'CONFIG_VIRTIO_FS may not be enabled in your configuration, shared folder may not work...' + ) + + # Print information about using shared folder + utils.green('To mount shared folder in guest (e.g. to /mnt/shared):') + utils.green('\t/ # mkdir /mnt/shared') + utils.green('\t/ # mount -t virtiofs shared /mnt/shared') + + shared_folder.mkdir(exist_ok=True, parents=True) - if not interactive: - timeout_cmd = ["timeout", "--foreground", timeout] - stdbuf_cmd = ["stdbuf", "-oL", "-eL"] - qemu_cmd = timeout_cmd + stdbuf_cmd + qemu_cmd + virtiofsd_log = base_folder.joinpath('.vfsd.log') + virtiofsd_mem = base_folder.joinpath('.vfsd.mem') + virtiofsd_socket = base_folder.joinpath('.vfsd.sock') - pretty_print_qemu_cmd(qemu_cmd) + virtiofsd_cmd = get_virtiofsd_cmd(Path(qemu_cmd[0]), virtiofsd_socket) + + qemu_mem_val = qemu_cmd[qemu_cmd.index('-m') + 1] + qemu_cmd += get_virtiofs_qemu_args(virtiofsd_mem, qemu_mem_val, + virtiofsd_socket) + + if not interactive: + timeout_cmd = ["timeout", "--foreground", timeout] + stdbuf_cmd = ["stdbuf", "-oL", "-eL"] + qemu_cmd = timeout_cmd + stdbuf_cmd + qemu_cmd + + pretty_print_qemu_cmd(qemu_cmd) + null_cm = contextlib.nullcontext() + with open(virtiofsd_log, 'w', encoding='utf-8') if share_folder_with_guest else null_cm as vfsd_log, \ + subprocess.Popen(virtiofsd_cmd, stderr=vfsd_log, stdout=vfsd_log) if share_folder_with_guest else null_cm as vfsd_process: try: subprocess.run(qemu_cmd, check=True) except subprocess.CalledProcessError as ex: @@ -802,7 +936,20 @@ def launch_qemu(cfg): utils.red("ERROR: QEMU timed out!") else: utils.red("ERROR: QEMU did not exit cleanly!") + # If virtiofsd is dead, it is pretty likely that it was the + # cause of QEMU failing so add to the existing exception using + # 'from'. + if vfsd_process and vfsd_process.poll(): + vfsd_log_txt = virtiofsd_log.read_text(encoding='utf-8') + raise Exception( + f"virtiofsd failed with: {vfsd_log_txt}") from ex sys.exit(ex.returncode) + finally: + if vfsd_process: + vfsd_process.kill() + # Delete the memory to save space, it does not have to be + # persistent + virtiofsd_mem.unlink(missing_ok=True) if __name__ == '__main__': @@ -812,4 +959,10 @@ def launch_qemu(cfg): config = setup_cfg(arguments) config = get_qemu_args(config) - launch_qemu(config) + # Print information about the QEMU binary + pretty_print_qemu_info(config['qemu_cmd'][0]) + + if config['gdb']: + launch_qemu_gdb(config) + else: + launch_qemu_fg(config) diff --git a/utils.py b/utils.py index ddba68e..012696a 100755 --- a/utils.py +++ b/utils.py @@ -30,6 +30,36 @@ def die(string): sys.exit(1) +def find_first_file(relative_root, possible_files, required=True): + """ + Attempts to find the first option available in the list of files relative + to a specified root folder. + + Parameters: + relative_root (Path): A Path object containing the folder to search for + files within. + possible_files (list): A list of Paths that may be within the relative + root folder. They will be automatically appended + to relative_root. + required (bool): Whether or not the requested file is required for the + script to work properly. + Returns: + The full path to the first file found in the list. If none could be + found, an Exception is raised. + """ + for possible_file in possible_files: + if (full_path := relative_root.joinpath(possible_file)).exists(): + return full_path + + if required: + files_str = "', '".join([str(elem) for elem in possible_files]) + raise Exception( + f"No files from list ('{files_str}') could be found within '{relative_root}'!" + ) + + return None + + def get_full_kernel_path(kernel_location, image, arch=None): """ Get the full path to a kernel image based on the architecture and image