前言

QEMU 使用命令行来配置模拟器的启动参数,本文将以 8.0.0 版本的 QEMU RISC-V (qemu-system-riscv64) 为例,简要梳理 QEMU 常用参数的含义与用法,重点分析 QEMU 的参数解析机制。

常用参数

QEMU 各选项的具体用法可以通过指令 qemu-system-riscv64 [options] help 查看。下面以在 QEMU 中启动 RISC-V Linux 内核的命令为例,简要介绍 QEMU 常用参数的含义:

qemu-system-riscv64 -M virt -m 256M -nographic \
    -kernel linux-kernel/arch/riscv/boot/Image \
    -drive file=rootfs.img,format=raw,id=hd0  \
    -device virtio-blk-device,drive=hd0 \
    -append "root=/dev/vda rw console=ttyS0"
  • -M: 指定模拟器运行时的设备类型,在上面的例子中指定了 qemu-system-riscv64 的模拟设备为 virt,即 RISC-V 架构下的“通用虚拟平台”
  • -m: 指定虚拟机内部的内存大小
  • -kernel: 指定内核镜像
  • -append: 指定内核命令行代码
  • -drive: 定义磁盘,file 表示磁盘镜像的文件路径,format 表示磁盘格式,id 表示磁盘号
  • -device: 添加设备到虚拟机中

参数解析

流程概述

QEMU 的全部可用参数定义于项目目录下的 qemu-options.hx 文件中,而参数解析的主体逻辑位于 softmmu/vl.c 文件的 qemu_init 函数中,主要分为两个阶段,第一阶段负责检查传入参数的合法性,并不具体解析,第二阶段执行具体的解析操作,并通过 switch 语句跳转不同为分支完成对应选项的设置。QEMU 在不同的抽象层次上定义了多种数据结构来对不同的参数进行描述,这些数据结构都在参数解析之前完成初始化。

数据结构及初始化

QEMU 在 softmmu/vl.c 文件中定义了 QEMUOption 结构体来描述不同的命令行参数,其代码如下:

typedef struct QEMUOption {
    const char *name;
    int flags;
    int index;
    uint32_t arch_mask;
} QEMUOption;

其中 name 表示参数名称,flags 表示参数属性,为 1 表示拥有子参数,为 0 则表示无子参数,index 表示命令索引 (QEMU_OPTION_cmd),arch_mask 表示参数支持的架构。在 softmmu/vl.c 文件中还定义了一个全局 QEMUOption 数组 qemu_options 来描述 QEMU 的全部可用参数,具体如下:

static const QEMUOption qemu_options[] = {
    { "h", 0, QEMU_OPTION_h, QEMU_ARCH_ALL },

#define DEF(option, opt_arg, opt_enum, opt_help, arch_mask)     \
    { option, opt_arg, opt_enum, arch_mask },
#define DEFHEADING(text)
#define ARCHHEADING(text, arch_mask)

#include "qemu-options.def"
    { /* end of list */ }
};

可以看到,qemu_options 数组中首先定义了一个参数 h,其使用方法为 qemu-system-riscv64 -h,作用是打印帮助信息。其余所有的可用参数都都通过 DEF 宏定义在 <qemu_build_dir>/qemu-options.def 文件中。需要注意的是,qemu-options.def 文件是由 scripts/hxtool 脚本在编译时根据 qemu-options.hx 文件生成的,因此不在 QEMU 源代码目录中。

对于含有多个子选项的参数,QEMU 在文件 include/qemu/option_int.h 中定义了两个结构体 QemuOpt 对子参数进行描述:

struct QemuOpt {
    char *name;
    char *str;

    const QemuOptDesc *desc;
    union {
        bool boolean;
        uint64_t uint;
    } value;

    QemuOpts     *opts;
    QTAILQ_ENTRY(QemuOpt) next;
};

其中 QemuOpt 用于存储子选项,每个 QemuOpt 结构体都有一个 QemuOptDesc 类型的成员变量来描述子选项的信息。QEMU 在文件 include/qemu/option.h 中定义了 QemuOptsList 结构体用于进一步抽象描述一个参数的所有子选项,有关代码如下:

enum QemuOptType {
    QEMU_OPT_STRING = 0,  /* no parsing (use string as-is) */
    QEMU_OPT_BOOL,        /* on/off */
    QEMU_OPT_NUMBER,      /* simple number */
    QEMU_OPT_SIZE,        /* size, accepts (K)ilo, (M)ega, (G)iga, (T)era postfix */
};

typedef struct QemuOptDesc {
    const char *name;
    enum QemuOptType type;
    const char *help;
    const char *def_value_str;
} QemuOptDesc;

struct QemuOptsList {
    const char *name;
    const char *implied_opt_name;
    bool merge_lists;  /* Merge multiple uses of option into a single list? */
    QTAILQ_HEAD(, QemuOpts) head;
    QemuOptDesc desc[];
};

上述代码中,首先定义了枚举类型 QemuOptType 用于描述不同的参数类型,包括字符串、布尔值、数字和空间大小四种。接着定义了结构体 QemuOptDesc 用于存放对参数的描述信息,包括名称、类型、帮助信息和参数默认值。最后定义了结构体 QemuOptsList,每一个 QemuOptsList 结构体就对应了一个参数。但是,这里需要特别注意的是,QemuOptsList 并不直接与 QemuOpt 联系,中间需要通过结构体 QemuOpts 进行转接:

struct QemuOpts {
    char *id;
    QemuOptsList *list;
    Location loc;
    QTAILQ_HEAD(, QemuOpt) head;
    QTAILQ_ENTRY(QemuOpts) next;
};

这样设计的目的在于避免发生参数混淆,QEMU 可以通过命令行指定创建两个相同的设备,这个时候这两个设备的选项都挂在 QemuOptsList 上,但是分别属于两个独立的 QemuOptsid 不同),每个 QemuOpts 都维护自己的 QemuOpt 链表,成员变量 headQemuOpts 下的 QemuOpt 链表头,成员变量 next 用来连接同一 QemuOptsList 下的其他 QemuOpts,具体如下图所示:

QEMU 在 util/qemu-config.c 中定义了一个全局的 QemuOptsList 数组 vm_config_groups 来储存所有可用的参数:

static QemuOptsList *vm_config_groups[48];
static QemuOptsList *drive_config_groups[5];

这两行代码说明了 QEMU 最多支持 48 个参数,5 个驱动器参数。这两个全局数组由位于 softmmu/vl.c 文件的 qemu_init 函数负责初始化:

    qemu_add_opts(&qemu_drive_opts);
    qemu_add_drive_opts(&qemu_legacy_drive_opts);
    qemu_add_drive_opts(&qemu_common_drive_opts);
    qemu_add_drive_opts(&qemu_drive_opts);
    qemu_add_drive_opts(&bdrv_runtime_opts);
    qemu_add_opts(&qemu_chardev_opts);
    qemu_add_opts(&qemu_device_opts);
    qemu_add_opts(&qemu_netdev_opts);
    qemu_add_opts(&qemu_nic_opts);
    qemu_add_opts(&qemu_net_opts);
    qemu_add_opts(&qemu_rtc_opts);
    qemu_add_opts(&qemu_global_opts);
    qemu_add_opts(&qemu_mon_opts);
    qemu_add_opts(&qemu_trace_opts);
    qemu_plugin_add_opts();
    qemu_add_opts(&qemu_option_rom_opts);
    qemu_add_opts(&qemu_accel_opts);
    qemu_add_opts(&qemu_mem_opts);
    qemu_add_opts(&qemu_smp_opts);
    qemu_add_opts(&qemu_boot_opts);
    qemu_add_opts(&qemu_add_fd_opts);
    qemu_add_opts(&qemu_object_opts);
    qemu_add_opts(&qemu_tpmdev_opts);
    qemu_add_opts(&qemu_overcommit_opts);
    qemu_add_opts(&qemu_msg_opts);
    qemu_add_opts(&qemu_name_opts);
    qemu_add_opts(&qemu_numa_opts);
    qemu_add_opts(&qemu_icount_opts);
    qemu_add_opts(&qemu_semihosting_config_opts);
    qemu_add_opts(&qemu_fw_cfg_opts);
    qemu_add_opts(&qemu_action_opts);

其中,qemu_add_opts 函数的实现位于 util/qemu-config.c 文件中,该函数主要负责将参数中传入的 OemuOptsList 添加到全局数组 vm_config_groups 中:

void qemu_add_opts(QemuOptsList *list)
{
    int entries, i;

    entries = ARRAY_SIZE(vm_config_groups);
    entries--; /* keep list NULL terminated */
    for (i = 0; i < entries; i++) {
        if (vm_config_groups[i] == NULL) {
            vm_config_groups[i] = list;
            return;
        }
    }
    fprintf(stderr, "ran out of space in vm_config_groups");
    abort();
}

第一阶段

QEMU 参数解析的第一阶段,遍历参数数组,通过 lookup_opt 函数来得到一个 QEMUOption,判断这个 QEMUOption 是否在之前初始化的的数组中。这一步骤的主要作用验证参数的合法性,下面结合源码具体分析:

    /* first pass of option parsing */
    optind = 1;
    while (optind < argc) {
        if (argv[optind][0] != '-') {
            /* disk image */
            optind++;
        } else {
            const QEMUOption *popt;

            popt = lookup_opt(argc, argv, &optarg, &optind);
            switch (popt->index) {
            case QEMU_OPTION_nouserconfig:
                userconfig = false;
                break;
            }
        }
    }

    machine_opts_dict = qdict_new();
    if (userconfig) {
        qemu_read_default_config_file(&error_fatal);
    }

首先按照下标顺序依次读取终端传入的参数数组,跳过子选项,调用 lookup_opt 函数查询读取的参数名称,然后初始化 machine_opts_dict 数组,并根据实际情况加载用户配置。这里的 machine_opts_dict 是一个字典结构,主要用于存储终端传入的参数数组中的虚拟机选项和参数,包括 CPU 数量、内存大小、设备配置等。machine_opts_dict 的存在使得参数解析机制能够以一种结构化的方式管理和访问虚拟机参数,而不是使用分散的单独变量或者凌乱的数据结构。

下面给出 lookup_opt 函数的定义:

static const QEMUOption *lookup_opt(int argc, char **argv, const char **poptarg, int *poptind)
{
    const QEMUOption *popt;
    int optind = *poptind;
    char *r = argv[optind];
    const char *optarg;

    loc_set_cmdline(argv, optind, 1);
    optind++;
    /* Treat --foo the same as -foo. */
    if (r[1] == '-')
        r++;
    popt = qemu_options;
    for(;;) {
        if (!popt->name) {
            error_report("invalid option");
            exit(1);
        }
        if (!strcmp(popt->name, r + 1))
            break;
        popt++;
    }
    if (popt->flags & HAS_ARG) {
        if (optind >= argc) {
            error_report("requires an argument");
            exit(1);
        }
        optarg = argv[optind++];
        loc_set_cmdline(argv, optind - 2, 2);
    } else {
        optarg = NULL;
    }

    *poptarg = optarg;
    *poptind = optind;

    return popt;
}

lookup_opt 函数首先调用 loc_set_cmdline 函数在终端传入的命令行参数中根据参数索引 optind 进行定位,然后通过参数名称的比较在全局数组 qemu_options 中寻找对应的 QEMUOption。同时,lookup_opt 函数会将参数后面的子选项保存到 optarg 中。

至此,我们不难发现,第一阶段的工作并没有涉及实际的参数解析,而是完成了从命令行读取用户的配置参数并对所有参数进行有效性验证,为第二阶段的正式解析做准备。QEMU 参数解析机制的两步设计,实现了验证逻辑和执行逻辑的有效分离,减少了出错的风险,使代码逻辑更加清晰。

第二阶段

QEMU 参数解析的第二阶段,是真正解析具体参数并执行相应设置的阶段,主要逻辑如下:

    /* second pass of option parsing */
    optind = 1;
    for(;;) {
        if (optind >= argc)
            break;
        if (argv[optind][0] != '-') {
            loc_set_cmdline(argv, optind, 1);
            drive_add(IF_DEFAULT, 0, argv[optind++], HD_OPTS);
        } else {
            const QEMUOption *popt;

            popt = lookup_opt(argc, argv, &optarg, &optind);
            if (!(popt->arch_mask & arch_type)) {
                error_report("Option not supported for this target");
                exit(1);
            }
            switch(popt->index) {
            case QEMU_OPTION_cpu:
                ...
                break;
            ...
            default:
                    ...
            }
        }
    }

第二阶段的逻辑就是按照下标顺序依次遍历终端传入的参数数组,调用 lookup_opt 函数找到对应的 QEMUOption,然后检查对应选项在当前架构下是否支持,最后使用 switch 语句根据 QEMUOption 的成员变量 index 的不同来执行不同的分支完成具体的设置。

下面将以不同的参数为例,详细说明参数解析第二阶段 switch 语句中的分支执行流程。首先关注最为简单的 -version 参数,其含义就是打印 QEMU 版本信息,然后退出程序,有关代码如下:

            case QEMU_OPTION_version:
                version();
                exit(0);
                break;

可以看到,由于其含义最为简单,因此解析实现也非常简洁,直接调用 version 函数打印版本信息,随后退出程序。接着分析 -kernel 参数,通过对 -kernel 参数解析流程的考察,我们可以一窥与机器设置有关的参数的解析机制。首先给出 -kernel 参数在解析第二阶段分支执行的主要代码:

            case QEMU_OPTION_kernel:
                qdict_put_str(machine_opts_dict, "kernel", optarg);
                break;

我们可以发现,与机器设置有关的参数在整个解析过程中并不会真正执行具体设置,而是将对应的参数选项添加到 machine_opts_dict 中,最终参数设置的落实工作由机器创建及初始化阶段的 qemu_apply_legacy_machine_options 函数和 qemu_apply_machine_options 函数完成,这一部分将在“QEMU 机器创建及初始化机制”的分析文章中具体阐述,本文不再赘述。下图给出了与与机器设置有关的参数从解析到落实的一般流程:

最后,观察 -device 参数,同样给出分支执行代码:

            case QEMU_OPTION_device:
                if (optarg[0] == '{') {
                    QObject *obj = qobject_from_json(optarg, &error_fatal);
                    DeviceOption *opt = g_new0(DeviceOption, 1);
                    opt->opts = qobject_to(QDict, obj);
                    loc_save(&opt->loc);
                    assert(opt->opts != NULL);
                    QTAILQ_INSERT_TAIL(&device_opts, opt, next);
                } else {
                    if (!qemu_opts_parse_noisily(qemu_find_opts("device"),
                                                 optarg, true)) {
                        exit(1);
                    }
                }
                break;

分支执行的主要逻辑如下:首先检查 optarg-device 选项的参数)是否以 { 开始。如果是,说明 optarg 是一个 JSON 对象,那么就需要调用 qobject_from_json 函数将 JSON 对象转换为一个 QObject 对象。然后,创建一个新的 DeviceOption 对象,并将 QObject 对象转换为一个 QDict 并存储在 DeviceOption 中,最后将 DeviceOption 添加到 device_opts 队列中。如果 optarg 不是一个 JSON 对象,那么就调用 qemu_opts_parse_noisily 函数将 optarg 解析为一个 QemuOpts 对象,并将这个对象添加到 "device" 选项的列表中。如果解析失败,则退出程序。下面我们重点关注 else 分支的部分,首先给出 qemu_find_opts 函数的定义,它位于 util/qemu-config.c 文件中:

QemuOptsList *qemu_find_opts(const char *group)
{
    QemuOptsList *ret;
    Error *local_err = NULL;

    ret = find_list(vm_config_groups, group, &local_err);
    if (local_err) {
        error_report_err(local_err);
    }

    return ret;
}

qemu_find_opts 函数从全局数组 vm_config_groups 中找到刚才插入的 -device 选相对应的 QemuOptsList 并返回,而 qemu_opts_parse_noisily 函数只是简单调用了 opts_parse 函数,后者会解析出一个 QemuOpts,每一个大类的参数都会在相应的 QemuOptsList 中构造 QEMUOpts。继续分析 opts_parse 函数:

static QemuOpts *opts_parse(QemuOptsList *list, const char *params,
                            bool permit_abbrev,
                            bool warn_on_flag, bool *help_wanted, Error **errp)
{
    const char *firstname;
    char *id = opts_parse_id(params);
    QemuOpts *opts;

    assert(!permit_abbrev || list->implied_opt_name);
    firstname = permit_abbrev ? list->implied_opt_name : NULL;

    opts = qemu_opts_create(list, id, !list->merge_lists, errp);
    g_free(id);
    if (opts == NULL) {
        return NULL;
    }

    if (!opts_do_parse(opts, params, firstname,
                       warn_on_flag, help_wanted, errp)) {
        qemu_opts_del(opts);
        return NULL;
    }

    return opts;
}

opts_parse 函数代码中最重要的两行是对 qemu_opts_create 函数和 opts_do_parse 函数的调用,前者用来创建 QemuOpts 并将它插入到对应的 QemuOptsList 上,后者则负责解析出 QemuOpt。函数 opts_do_parse 的作用是解析参数的值,如本文开头例子中的命令行参数 -device virtio-blk-device,drive=hd0。QEMU 的参数可能存在多种情况,比如上面的例子中 virtio-blk-device 表示开启一个标志,也有可能类似于 drive=hd0 的参数赋值语句。opts_do_parse 函数需要处理各种情况,并对每一个值生成一个 QemuOpt,关键代码如下:

static bool opts_do_parse(QemuOpts *opts, const char *params,
                          const char *firstname,
                          bool warn_on_flag, bool *help_wanted, Error **errp)
{
    char *option, *value;
    const char *p;
    QemuOpt *opt;

    for (p = params; *p;) {
        p = get_opt_name_value(p, firstname, warn_on_flag, help_wanted, &option, &value);
        if (help_wanted && *help_wanted) {
            g_free(option);
            g_free(value);
            return false;
        }
        firstname = NULL;

        if (!strcmp(option, "id")) {
            g_free(option);
            g_free(value);
            continue;
        }

        opt = opt_create(opts, option, value);
        g_free(option);
        if (!opt_validate(opt, errp)) {
            qemu_opt_del(opt);
            return false;
        }
    }

    return true;
}

opts_do_parse 函数是 QEMU 参数解析过程的核心部分,它接受如下六个参数:

  • opts 是一个 QemuOpts 对象,用于存储解析后的选项
  • params 是一个字符串,包含需要解析的参数
  • firstname 是第一个选项的名字
  • warn_on_flag 是一个布尔值,如果为 true,那么当遇到一个标志选项时,函数会打印一个警告消息
  • help_wanted 是一个指向布尔值的指针,如果函数解析到一个 help 选项,则设置 *help_wantedtrueerrp 是一个错误指针,如果函数遇到错误,则设置 *errp
  • errp 是一个错误指针,如果函数遇到错误,那么它会设置 *errp

opts_do_parse 函数使用一个 for 循环一次遍历解析 params 中的每个选项:首先调用 get_opt_name_value 函数来获取下一个选项的名字和值,然后创建一个新的 QemuOpt 对象并添加到 opts 中,如果解析到一个 help 选项,则会设置 *help_wantedtrue,并立即返回 false。注意,如果解析到的选项的名称是 id,则会跳过这个选项,因为参数 id 是用于设置 QemuOpts 对象的 ID 的,不能当作普通参数解析为 QemuOpt。最后,调用 opt_validate 函数来验证新创建的 QemuOpt 的有效性并返回解析结果。

仍然以命令行参数 -device virtio-blk-device,drive=hd0 为例,经过上述过程将解析出 2 个 QemuOpt 并形成如下图所示的链表:

总结

本文以 QEMU 中引导 RISC-V 架构 Linux 内核启动的指令为例,总结归纳了 QEMU 常用参数的用法与含义,分析阐述了描述不同参数的各种数据结构的定义、作用以及相互关系,并按照 QEMU 的执行顺序详细梳理了数据结构初始化、参数解析第一阶段、参数解析第二阶段的代码逻辑。

参考资料