泰晓科技 -- 聚焦 Linux - 追本溯源,见微知著!
网站地址:https://tinylab.org

泰晓Linux知识星球:1300+知识点,520+用户
请稍侯

QEMU 启动方式分析(3): QEMU 代码与 RISCV virt 平台 ZSBL 分析

ysyx_22040406-张炀杰 创作于 2022/11/23

Corrector: TinyCorrect v0.1-rc3 - [spaces images] Author: YJMSTR jay1273062855@outlook.com Date: 2022/09/11 Revisor: Bin Meng, Falcon Project: RISC-V Linux 内核剖析 Environment: Ubuntu22.04 LTS Sponsor: PLCT Lab, ISCAS

前言

在本系列 上一篇文章 中,我们介绍了在 QEMU RISC-V ‘virt’ 平台下使用 OpenSBI + U-Boot 引导 Linux 内核的流程。本文将根据 QEMU 启动 RISC-V ‘virt’ 设备的流程,简要介绍 QEMU 的参数解析过程与 QOM 模型,并进一步结合 QEMU 代码分析 QEMU 对 RISC-V ‘virt’ 设备的模拟以及其 Zeroth Stage Boot Loader(ZSBL)阶段的行为。

系统模式下 QEMU 程序的入口是 softmmu/main.c。该函数如下所示:

int main(int argc, char **argv, char **envp)
{
    qemu_init(argc, argv, envp);
    qemu_main_loop();
    qemu_cleanup();

    return 0;
}

其中 qemu_init 函数负责 QEMU 的初始化,包括参数解析、设备初始化并根据解析的参数进行配置。qemu_main_loop 是 QEMU 的主循环,它将循环监听事件并进行处理。qemu_cleanup 进行退出时的相关清理,释放占用的资源。

本文分析的目标 QEMU RISC-V ‘virt’ 机器的 ZSBL 阶段同样属于 qemu_init 函数处理的范围,因此,我们重点关注其中的 qemu_init 函数。本文使用的 QEMU 版本为 v7.0.0。

参数解析过程

参数解析过程的流程图如下所示:

qemu_opt.png

涉及的数据结构

include/qemu/queue.h 中定义了四种数据结构:单链表,双向链表,简单队列,尾队列。其中:

  • 单链表:相关的宏定义以 QSLIST 开头。以指向表头的指针表示。只能正向遍历,移除元素时需要遍历链表,只能在表头之后或是某个已存在的元素之后插入元素。
  • 双向链表:相关的宏定义以 QLIST 开头。以指向表头的指针表示。只能正向遍历,移除元素时不需要遍历链表,可以在表头和已存在元素的前面或后面插入元素。
  • 简单队列:相关的宏定义以 QSIMPLEQ 开头。简单队列以一对分别指向队首元素与队尾元素的指针表示。元素之间单向连接,只能在队首,队尾或已存在元素之后插入新元素,只支持删除队首元素,只能正向遍历。
  • 尾队列:相关的宏定义以 QTAILQ 开头。其同样以一对分别指向队首元素与队尾元素的指针表示。元素之间双向连接,因此可以在不遍历队列的情况下删除队列中的任意元素。可以在队首/队尾/已存在元素的前面或后面插入元素。支持双向遍历。

各种宏的详细定义可以参考代码。

在进入 qemu_init 函数后,QEMU 接着调用了 qemu_add_optsqemu_add_drive_opts 等函数。它们分别将作为参数传入的 QemuOptsList 数据结构加入 vm_config_groups[] 中与 drive_config_groups[] 中。

QemuOptsList 类型定义于 include/qemu/option.h

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[];
};

其中 QTAILQ_HEAD(, QemuOpts) head 表示其拥有的 QemuOpts 尾队列的队头,QemuOpts 类型定义于 include/qemu/option_int.h

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

其中 QTAILQ_HEAD(, QemuOpt) head 表示其拥有的 QemuOpt 类型的尾队列队头,QTAILQ_ENTRY(QemuOpts) next 中包含这一 QemuOpts 在所属尾队列中指向下一个元素的指针,以及指向前一个元素的该指针的二级指针。

QemuOpt 类型同样定义于 include/qemu/option_int.h 中:

struct QemuOpt {
    char *name;
    char *str;

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

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

其中的 QemuOpts *opts 指向其所属的 QemuOpts 实例,QTAILQ_ENTRY(QemuOpt) next 中包含这一 QemuOpt 在所属尾队列中指向下一个元素的指针,以及指向前一个元素的该指针的二级指针。

其中 QemuOptDesc 类型定义于 include/qemu/option.h:

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

其包含了用于描述选项的若干信息。

由上述分析可知,每个 QemuOptsList 其实包含了“尾队列套尾队列”的结构,表示一类选项。这些选项中的每一个用 QemuOpts 表示,又包含若干的子选项,用 QemuOpt 表示。

在将这些 QemuOptsList 加入对应的数组之后,QEMU 会调用 module_call_init(MODULE_INIT_OPTS) 进行参数模块类型链表 ModuleTypeList 的初始化。

参数解析

解析分为两个阶段,所有可用的参数以 QEMUOption 类型存储在 qemu_options 数组中,这个数组是根据 qemu-options.hx 文件进行填充的。在参数解析的第一阶段会通过 lookup_opt() 函数在该数组中进行查找,以确定是否是合法参数,并将其后跟随的参数(如果有)存入 poptarg。第二阶段同样使用 lookup_opt() 函数进行查找,判断模拟的目标是否支持该参数,并根据选项的类型执行不同的分支,具体实现在 softmmu/vl.c 中。

其中,-kernel 参数和 -bios 参数对应的分支代码如下:

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

此处并没有执行更进一步的操作,而是将解析出的选项与参数存至 machine_opts_dict 中。

随后在函数 qemu_validate_options() 中,QEMU 检查解析出的 -kernel, -initrd-append 参数及其组合是否合法。可以在其中看见如下代码:

static void qemu_validate_options(const QDict *machine_opts)
{
    ...
    if (kernel_filename == NULL) {
        if (kernel_cmdline != NULL) {
            error_report("-append only allowed with -kernel option");
            exit(1);
        }

        if (initrd_filename != NULL) {
            error_report("-initrd only allowed with -kernel option");
            exit(1);
        }
    }
    ...
}

可以看出,QEMU 的 -initrd-append 参数仅可在使用了 -kernel 参数的情况下使用。

RISC-V ‘virt’ 初始化

QOM 简介

QEMU 中通过 QOM(QEMU Object Model)实现了面向对象机制。它提供了一个框架用于注册用户可创建的类,并实例化这些类的对象。它提供了以下特性:

  • 一个支持动态地注册“类”的系统
  • “类”的单继承
  • “无状态接口”的多重继承

QEMU 中的机器组件(Machine)就是通过 QOM 进行抽象的,有关 QEMU QOM 以及使用 QOM 对机器进行抽象的更详细介绍,可以参考 QEMU 源码,QEMU 官方文档QEMU wiki,此处仅为了方便后续分析进行简要介绍。

QOM 中注册类的流程如下图所示:

qemu_type_register.png

QOM 中定义一个类 MyType 一般需要 TypeInfoMyTypeClassMyTypeStateTypeImpl 等结构,其中 TypeInfo 是用户定义类时提供的该类的信息,其会在注册到系统时被转换成 TypeImplMyTypeClassMyTypeState 两个结构体分别是类与该类的对象的结构体,前者中提供该类的虚函数列表供子类实现,后者记录了该类的对象的相关信息。

QOM 中注册的类在 main 函数执行之前就统一添加到了链表中。以 virt 设备为例,在 hw/riscv/virt.c 中,代码的最后通过 type_init(virt_machine_init_register_types) 将 virt 设备注册到了系统中。type_init 宏定义于 include/qemu/module.h 中:

#define type_init(function) module_init(function, MODULE_INIT_QOM)

其通过 module_init 宏进行操作:

#ifdef BUILD_DSO
void DSO_STAMP_FUN(void);
/* This is a dummy symbol to identify a loaded DSO as a QEMU module, so we can
 * distinguish "version mismatch" from "not a QEMU module", when the stamp
 * check fails during module loading */
void qemu_module_dummy(void);

#define module_init(function, type)                                         \
static void __attribute__((constructor)) do_qemu_init_ ## function(void)    \
{                                                                           \
    register_dso_module_init(function, type);                               \
}
#else
/* This should not be used directly.  Use block_init etc. instead. */
#define module_init(function, type)                                         \
static void __attribute__((constructor)) do_qemu_init_ ## function(void)    \
{                                                                           \
    register_module_init(function, type);                                   \
}
#endif

根据是否定义了 BUILD_DSO 宏,module_init 将有不同的实现。未定义 BUILD_DSO 宏的情况下,其将通过 register_module_init 注册到系统中。其中加上了 __attribute__((constructor)) 属性的函数 do_qemu_init ## function 将会早于 main 函数执行,即 QOM 类型注册是在 main 函数之前进行的。

register_module_init 的实现如下:

void register_module_init(void (*fn)(void), module_init_type type)
{
    ModuleEntry *e;
    ModuleTypeList *l;

    e = g_malloc0(sizeof(*e));
    e->init = fn;
    e->type = type;

    l = find_type(type);

    QTAILQ_INSERT_TAIL(l, e, node);
}

其通过 find_type 返回了一个链表(其实是“尾队列”),find_type 的实现如下:

static ModuleTypeList *find_type(module_init_type type)
{
    init_lists();

    return &init_type_list[type];
}

其中 init_lists() 的实现如下:

static void init_lists(void)
{
    static int inited;
    int i;

    if (inited) {
        return;
    }

    for (i = 0; i < MODULE_INIT_MAX; i++) {
        QTAILQ_INIT(&init_type_list[i]);
    }

    QTAILQ_INIT(&dso_init_list);

    inited = 1;
}

它会判断数组 init_type_list 中的尾队列是否进行过初始化,若没有,就进行初始化。随后 find_type 函数会返回参数的类型所对应的尾队列,register_module_init 会将新的类添加到该尾队列中,并设置对应的初始化函数与类型以完成注册。

Machine 类型传入 type_init 的初始化函数是 machine_register_types

static void machine_register_types(void)
{
    type_register_static(&machine_info);
}

其中 type_register_staticqom/object.c 中定义:

TypeImpl *type_register(const TypeInfo *info)
{
    assert(info->parent);
    return type_register_internal(info);
}

TypeImpl *type_register_static(const TypeInfo *info)
{
    return type_register(info);
}

其最终调用了 type_register_internal 进行注册,相关函数如下:

static void type_table_add(TypeImpl *ti)
{
    assert(!enumerating_types);
    g_hash_table_insert(type_table_get(), (void *)ti->name, ti);
}

static TypeImpl *type_table_lookup(const char *name)
{
    return g_hash_table_lookup(type_table_get(), name);
}

static TypeImpl *type_new(const TypeInfo *info)
{
    TypeImpl *ti = g_malloc0(sizeof(*ti));
    int i;

    g_assert(info->name != NULL);

    if (type_table_lookup(info->name) != NULL) {
        fprintf(stderr, "Registering `%s' which already exists\n", info->name);
        abort();
    }

    ti->name = g_strdup(info->name);
    ti->parent = g_strdup(info->parent);

    ti->class_size = info->class_size;
    ti->instance_size = info->instance_size;
    ti->instance_align = info->instance_align;

    ti->class_init = info->class_init;
    ti->class_base_init = info->class_base_init;
    ti->class_data = info->class_data;

    ti->instance_init = info->instance_init;
    ti->instance_post_init = info->instance_post_init;
    ti->instance_finalize = info->instance_finalize;

    ti->abstract = info->abstract;

    for (i = 0; info->interfaces && info->interfaces[i].type; i++) {
        ti->interfaces[i].typename = g_strdup(info->interfaces[i].type);
    }
    ti->num_interfaces = i;

    return ti;
}

static TypeImpl *type_register_internal(const TypeInfo *info)
{
    TypeImpl *ti;
    ti = type_new(info);

    type_table_add(ti);
    return ti;
}

type_register_internal 将传入的 TypeInfo 类型参数转换为 TypeImpl 类型,插入哈希表中并返回转换后的结果,即完成了类的注册。

Machine 类的信息位于 hw/core/machine.cstatic const TypeInfo machine_info 中:

static const TypeInfo machine_info = {
    .name = TYPE_MACHINE,
    .parent = TYPE_OBJECT,
    .abstract = true,
    .class_size = sizeof(MachineClass),
    .class_init    = machine_class_init,
    .class_base_init = machine_class_base_init,
    .instance_size = sizeof(MachineState),
    .instance_init = machine_initfn,
    .instance_finalize = machine_finalize,
};

QOM 通过 include/hw/boards.h 中的 OBJECT_DECLARE_TYPE(MachineState, MachineClass, MACHINE) 宏完成机器类定义的大部分工作,但机器类的虚方法需要再通过定义一个 struct MachineClass 来给出。对象结构体是同一个文件中的 struct MachineState。在 MachineClass 中定义了若干虚方法对 MachineState 进行操作:

struct MachineClass {
    ...
    void (*init)(MachineState *state);
    void (*reset)(MachineState *state);
    void (*wakeup)(MachineState *state);
    int (*kvm_type)(MachineState *machine, const char *arg);
    ...
};

在知道具体的机器类型后,QEMU 将会创建新的继承自 MachineClass 的类并给出这些虚方法的具体实现。

创建 virt 机器

创建 virt 机器涉及到的函数调用关系如下图所示:

qemu_create_machine.png

在解析完选项与参数并进行一些其它设置后,由于我们指定了 -M virt,QEMU 会在随后调用的 qemu_create_machine(machine_opts_dict) 函数中进行 virt 机器的创建。

其首先调用 select_machine 根据支持模拟的机器列表进行判断,若支持,则从中选出给定参数对应的机器类并提供相应的属性信息,其中的参数 MachineClass 即为 QEMU QOM 中定义的 Machine 类,select_machine 选出的是其各种具体的机器类型对应的派生类,MachineClass 是它们共同的基类。随后执行了如下语句:

GSList *machines = object_class_get_list(TYPE_MACHINE, false);

得到一个机器类型的链表,随后将通过 find_machine 从中找出我们指定的机器。

object_class_get_list 的定义如下:

GSList *object_class_get_list(const char *implements_type,
                              bool include_abstract)
{
    GSList *list = NULL;

    object_class_foreach(object_class_get_list_tramp,
                         implements_type, include_abstract, &list);
    return list;
}

object_class_foreach 定义如下:

void object_class_foreach(void (*fn)(ObjectClass *klass, void *opaque),
                          const char *implements_type, bool include_abstract,
                          void *opaque)
{
    OCFData data = { fn, implements_type, include_abstract, opaque };

    enumerating_types = true;
    g_hash_table_foreach(type_table_get(), object_class_foreach_tramp, &data);
    enumerating_types = false;
}

object_class_foreach_tramp 定义如下:

static void object_class_foreach_tramp(gpointer key, gpointer value,
                                       gpointer opaque)
{
    OCFData *data = opaque;
    TypeImpl *type = value;
    ObjectClass *k;

    type_initialize(type);
    k = type->class;

    if (!data->include_abstract && type->abstract) {
        return;
    }

    if (data->implements_type &&
        !object_class_dynamic_cast(k, data->implements_type)) {
        return;
    }

    data->fn(k, data->opaque);
}

object_class_get_list 会调用 object_class_foreach,而该函数会对哈希表 type_table 中的每个元素调用 object_class_foreach_trampobject_class_foreach_tramp 中又调用了 type_initialize 进行类型的初始化,type_initialize 中会判断 TypeImpl 参数是否有类的初始化函数,若有则会进行调用。

因此,在调用 select_machine 时 QEMU 就已经完成了 virt 机器对应类的初始化工作。virt 对应的机器类型的初始化函数是位于 hw/riscv/virt.c 中的 virt_machine_class_init((ObjectClass *oc, void *data)),在后文中将会对其进行具体分析。

接下来的 object_set_machine_compat_props() 设置 virt 机器的全局属性,随后通过调用 set_memory_options(machine_class) 设置内存大小,若在 QEMU 启动参数中没有指定内存大小,则该函数会根据 machine_class 中的信息将其设置为 virt 平台的默认大小。

随后,QEMU 根据选择出的机器类型创建一个该类的实例 current_machine

current_machine = MACHINE(object_new_with_class(OBJECT_CLASS(machine_class)));

其中 current_machine 是一个指向 MachineState 的指针,声明于 hw/core/machine.c 中。此时的 current_machine 即代表了 virt 机器,对 virt 的各类初始化操作即对 current_machine 进行操作。

QEMU 在进行一些其它初始化操作后退出 qemu_create_machine() 函数,并接着调用了 qemu_apply_legacy_machine_options(machine_opts_dict), qemu_apply_machine_options(machine_opts_dict) 等函数,按照之前解析出的设备相关参数 machine_opts_dict 进行配置。

随后有如下代码:

if (!preconfig_requested) {
    qmp_x_exit_preconfig(&error_fatal);
}

函数 qmp_x_exit_preconfig() 中进行了如下操作:

void qmp_x_exit_preconfig(Error **errp)
{
    if (phase_check(PHASE_MACHINE_INITIALIZED)) {
        error_setg(errp, "The command is permitted only before machine initialization");
        return;
    }

    qemu_init_board();
    qemu_create_cli_devices();
    qemu_machine_creation_done();
    ...
}

此处的“qmp”指 QEMU Machine Protocol,是一种基于 JSON 的协议,它允许应用程序控制 QEMU 实例。当启用了 --preconfig 参数后,QEMU 将会在完成创建初始虚拟机之前暂停,进入“preconfig”状态并允许通过 QMP 进行一些配置。if (!preconfig_requested) 判断是否要求进入“preconfig”状态,若没有,则会直接调用 qmp_x_exit_preconfig() 函数退出“preconfig”态并完成初始虚拟机的创建。

其中 qemu_init_board() 函数中调用了 machine_run_board_init() 进行机器(主板)的一些初始化操作。

machine_run_board_init() 中,首先通过 MACHINE_GET_CLASS 宏得到传入的 MachineState 对象对应的派生类,此处即 virt 对应的机器类:

MachineClass *machine_class = MACHINE_GET_CLASS(machine);

在完成一些初始化操作后,又调用了 machine_class->init(machine),来调用该机器类型的初始化方法对实例 machine 进行初始化。借助 gdb 等工具可知此处实际调用了 hw/riscv/virt.c 中的 virt_machine_init(MachineState *machine),来对 virt 设备进行初始化。

virt 代码与 ZSBL 分析

QEMU 模拟 RISC-V ‘virt’ 机器相关的代码位于 hw/riscv 目录下的 virt.cvirt.h 中。ZSBL 指 Zero Stage Bootloader,在这一阶段 virt 机器从 ROM 中取指,进行初始化并跳转到 FSBL 或是 Runtime,接下来我们将对这一过程进行分析。

virt.c 中,给出了 virt 平台的地址分布如下:

static const MemMapEntry virt_memmap[] = {
    [VIRT_DEBUG] =       {        0x0,         0x100 },
    [VIRT_MROM] =        {     0x1000,        0xf000 },
    [VIRT_TEST] =        {   0x100000,        0x1000 },
    [VIRT_RTC] =         {   0x101000,        0x1000 },
    [VIRT_CLINT] =       {  0x2000000,       0x10000 },
    [VIRT_ACLINT_SSWI] = {  0x2F00000,        0x4000 },
    [VIRT_PCIE_PIO] =    {  0x3000000,       0x10000 },
    [VIRT_PLIC] =        {  0xc000000, VIRT_PLIC_SIZE(VIRT_CPUS_MAX * 2) },
    [VIRT_APLIC_M] =     {  0xc000000, APLIC_SIZE(VIRT_CPUS_MAX) },
    [VIRT_APLIC_S] =     {  0xd000000, APLIC_SIZE(VIRT_CPUS_MAX) },
    [VIRT_UART0] =       { 0x10000000,         0x100 },
    [VIRT_VIRTIO] =      { 0x10001000,        0x1000 },
    [VIRT_FW_CFG] =      { 0x10100000,          0x18 },
    [VIRT_FLASH] =       { 0x20000000,     0x4000000 },
    [VIRT_IMSIC_M] =     { 0x24000000, VIRT_IMSIC_MAX_SIZE },
    [VIRT_IMSIC_S] =     { 0x28000000, VIRT_IMSIC_MAX_SIZE },
    [VIRT_PCIE_ECAM] =   { 0x30000000,    0x10000000 },
    [VIRT_PCIE_MMIO] =   { 0x40000000,    0x40000000 },
    [VIRT_DRAM] =        { 0x80000000,           0x0 },
};

可以观察到内存 VIRT_DRAM 是从 0x80000000 开始的,大小没有在此处指定,而是根据用户指定的参数进行设置。若用户未指定该参数,则会将其设置为默认值。

virt_machine 类的 TypeInfo 如下:

static const TypeInfo virt_machine_typeinfo = {
    .name       = MACHINE_TYPE_NAME("virt"),
    .parent     = TYPE_MACHINE,
    .class_init = virt_machine_class_init,
    .instance_init = virt_machine_instance_init,
    .instance_size = sizeof(RISCVVirtState),
};

其中指定了父类类型 TYPE_MACHINE,类初始化函数 virt_machine_class_init,并在之后通过 type_init(virt_machine_init_register_types) 注册了该类。type_init 宏已在前文进行分析,由于指定了 .class_initvirt_machine_class_init,因此在 type_initialize 时将会调用该函数进行 virt 对应的机器类的初始化。

virt_machine_class_init 中,为 virt 类的对象指定了初始化函数 virt_machine_init,它首先进行处理器等模拟硬件的相关初始化,以及设备树的创建。

首先是对 sockets 数量进行判断,判断其是否不超过支持的最大 socket 数。随后进行各 socket 及中断控制器的初始化,并设置支持的最大内存大小。

接下来的代码首先进行了 ROM 的初始化:

    /* boot rom */
    memory_region_init_rom(mask_rom, NULL, "riscv_virt_board.mrom",
                           memmap[VIRT_MROM].size, &error_fatal);
    memory_region_add_subregion(system_memory, memmap[VIRT_MROM].base,
                                mask_rom);

随后进行的是 -bios 参数指定的二进制文件的加载:

    /*
     * Only direct boot kernel is currently supported for KVM VM,
     * so the "-bios" parameter is ignored and treated like "-bios none"
     * when KVM is enabled.
     */
    if (kvm_enabled()) {
        g_free(machine->firmware);
        machine->firmware = g_strdup("none");
    }

    if (riscv_is_32bit(&s->soc[0])) {
        firmware_end_addr = riscv_find_and_load_firmware(machine,
                                    RISCV32_BIOS_BIN, start_addr, NULL);
    } else {
        firmware_end_addr = riscv_find_and_load_firmware(machine,
                                    RISCV64_BIOS_BIN, start_addr, NULL);
    }

在注释中提到,由于 KVM 目前仅支持直接引导内核,因此开启 KVM 时,-bios 参数一律视为 -bios none

接下来判断机器的位数并通过 riscv_find_and_load_firmware() 函数加载 -bios 参数指定的固件。该函数定义于 hw/riscv/boot.c 中:

target_ulong riscv_find_and_load_firmware(MachineState *machine,
                                          const char *default_machine_firmware,
                                          hwaddr firmware_load_addr,
                                          symbol_fn_t sym_cb)
{
    char *firmware_filename = NULL;
    target_ulong firmware_end_addr = firmware_load_addr;

    if ((!machine->firmware) || (!strcmp(machine->firmware, "default"))) {
        /*
         * The user didn't specify -bios, or has specified "-bios default".
         * That means we are going to load the OpenSBI binary included in
         * the QEMU source.
         */
        firmware_filename = riscv_find_firmware(default_machine_firmware);
    } else if (strcmp(machine->firmware, "none")) {
        firmware_filename = riscv_find_firmware(machine->firmware);
    }

    if (firmware_filename) {
        /* If not "none" load the firmware */
        firmware_end_addr = riscv_load_firmware(firmware_filename,
                                                firmware_load_addr, sym_cb);
        g_free(firmware_filename);
    }

    return firmware_end_addr;
}

其中第一个 if 对用户未指定 -bios 参数及用户指定了 -bios default 参数的情况进行处理,该情况下 QEMU 将会加载自带的 OpenSBI 二进制文件,并通过 riscv_find_firmware 函数直接查找用户输入的文件名。32 位和 64 位的默认文件分别是 opensbi-riscv32-generic-fw_dynamic.binopensbi-riscv64-generic-fw_dynamic.bin

第二个 if 中处理了用户通过 -bios 参数指定文件的情况。其通过 riscv_find_firmware 函数直接查找用户输入的文件名。

最后一个 if 根据之前解析出的文件名通过 riscv_load_firmware 进行固件的加载,该函数支持加载 ELF 格式文件和普通二进制文件。

随后回到 virt.c 中,若用户指定了 -kernel 参数,接下来的代码将进行对应的加载:

    if (machine->kernel_filename) {
        kernel_start_addr = riscv_calc_kernel_start_addr(&s->soc[0],
                                                         firmware_end_addr);

        kernel_entry = riscv_load_kernel(machine->kernel_filename,
                                         kernel_start_addr, NULL);

        if (machine->initrd_filename) {
            hwaddr start;
            hwaddr end = riscv_load_initrd(machine->initrd_filename,
                                           machine->ram_size, kernel_entry,
                                           &start);
            qemu_fdt_setprop_cell(machine->fdt, "/chosen",
                                  "linux,initrd-start", start);
            qemu_fdt_setprop_cell(machine->fdt, "/chosen", "linux,initrd-end",
                                  end);
        }
    } else {
       /*
        * If dynamic firmware is used, it doesn't know where is the next mode
        * if kernel argument is not set.
        */
        kernel_entry = 0;
    }

首先根据之前计算出的固件在内存中的结束地址 firmware_end_addr 与机器的位数对 kernel 的起始地址 kernel_start_addr 进行计算,根据机器位数的不同,内存的对齐方式也不同。32 位和 64 位机器分别按 4 MiB 和 2 MiB 进行对齐。

随后通过 riscv_load_kernel 函数加载内核并计算内核入口。该函数中除了用到了 riscv_load_firmware 加载 ELF 格式与普通二进制格式的两个函数外,还有 load_uimage_as 函数进行 uImage 格式文件的加载。

接下来的 if 判断用户是否通过 -initrd 参数指定了初始 RAM 磁盘文件,并进行加载及相关设置。

若用户没有指定 -kernel 参数,或是使用了 dynamic 类型的固件,则此处将不会设置内核入口。

接下来这段代码检测是否存在 PFLASH 设备,如果存在,就将起始地址从默认值 DRAM 起始位置 memmap[VIRT_DRAM].base 改成 FLASH 的起始地址 virt_memmap[VIRT_FLASH].base

    if (drive_get(IF_PFLASH, 0, 0)) {
        /*
         * Pflash was supplied, let's overwrite the address we jump to after
         * reset to the base of the flash.
         */
        start_addr = virt_memmap[VIRT_FLASH].base;
    }

随后进行 fw_cfg 的初始化与 fdt 在 dram 中加载地址的计算:

    /*
     * Init fw_cfg.  Must be done before riscv_load_fdt, otherwise the device
     * tree cannot be altered and we get FDT_ERR_NOSPACE.
     */
    s->fw_cfg = create_fw_cfg(machine);
    rom_set_fw(s->fw_cfg);

    /* Compute the fdt load address in dram */
    fdt_load_addr = riscv_load_fdt(memmap[VIRT_DRAM].base,
                                   machine->ram_size, machine->fdt);

fw_cfg 是指 firmware configuration。这是虚拟机获取 QEMU 提供数据的一种接口,此处配置了 virt 对象的该接口。

函数 riscv_load_fdt 中,将 fdt 地址设置为了内存结束地址与 3 GiB 中的较小值减去 fdt 的大小后按 16 MiB 对齐向下取整:

    temp = MIN(dram_end, 3072 * MiB);
    fdt_addr = QEMU_ALIGN_DOWN(temp - fdtsize, 16 * MiB);

随后会通过 rom_add_blob_fixed_as 宏调用 rom_add_blob 函数,拷贝 fdt 到 ROM 的 fdt_addr 处:

    rom_add_blob_fixed_as("fdt", fdt, fdtsize, fdt_addr,
                          &address_space_memory);

接下来的代码加载复位向量(reset vector)进 ROM:

    /* load the reset vector */
    riscv_setup_rom_reset_vec(machine, &s->soc[0], start_addr,
                              virt_memmap[VIRT_MROM].base,
                              virt_memmap[VIRT_MROM].size, kernel_entry,
                              fdt_load_addr, machine->fdt);

riscv_setup_rom_reset_vec 中复位向量如下所示:

    /* reset vector */
    uint32_t reset_vec[10] = {
        0x00000297,                  /* 1:  auipc  t0, %pcrel_hi(fw_dyn) */
        0x02828613,                  /* addi   a2, t0, %pcrel_lo(1b) */
        0xf1402573,                  /* csrr   a0, mhartid */
        0,
        0,
        0x00028067,                  /* jr     t0 */
        start_addr,                  /* start: .dword */
        start_addr_hi32,
        fdt_load_addr,               /* fdt_laddr: .dword */
        0x00000000,
                                     /* fw_dyn: */
    };
    if (riscv_is_32bit(harts)) {
        reset_vec[3] = 0x0202a583;   /* lw     a1, 32(t0) */
        reset_vec[4] = 0x0182a283;   /* lw     t0, 24(t0) */
    } else {
        reset_vec[3] = 0x0202b583;   /* ld     a1, 32(t0) */
        reset_vec[4] = 0x0182b283;   /* ld     t0, 24(t0) */
    }

其随后也通过 rom_add_blob_fixed_as 将复位向量拷贝至 ROM 中,复位向量中的代码被拷贝进了 ROM 的起始位置。virt 机器启动后最先执行的指令就是 ROM 起始位置处的这些指令。

复位向量中的指令将 fw_dyn 的地址存入 a2,fw_dyn 具体是什么将在后文进行分析。

运行当前代码的硬件线程的 id(mhartid)被存入 a0。随后,分别将 start_addrfdt_load_addr 的值读入 t0 和 a1 中,最后跳转到 t0 指向的位置,即 start_addr 指向的位置。在之前分析过的代码中:

    if (riscv_is_32bit(&s->soc[0])) {
        firmware_end_addr = riscv_find_and_load_firmware(machine,
                                    RISCV32_BIOS_BIN, start_addr, NULL);
    } else {
        firmware_end_addr = riscv_find_and_load_firmware(machine,
                                    RISCV64_BIOS_BIN, start_addr, NULL);
    }

已经将固件加载到了 virt 机器的 DRAM 的基地址。若没有添加 PFLASH,virt 机器此时将会跳转到 firmware 所处位置。

随后调用了 riscv_rom_copy_firmware_info 函数:

    riscv_rom_copy_firmware_info(machine, rom_base, rom_size, sizeof(reset_vec),
                                 kernel_entry);

从函数的注释中我们可以得知,存放在复位向量的汇编代码中存放进 a2 的是 dynamic 类型固件信息的地址。其中,结构体 fw_dynamic_info 的定义如下,其位于 include/hw/riscv/boot_opensbi.h 中:

/** Representation dynamic info passed by previous booting stage */
struct fw_dynamic_info {
    /** Info magic */
    target_long magic;
    /** Info version */
    target_long version;
    /** Next booting stage address */
    target_long next_addr;
    /** Next booting stage mode */
    target_long next_mode;
    /** Options for OpenSBI library */
    target_long options;
    /**
     * Preferred boot HART id
     *
     * It is possible that the previous booting stage uses same link
     * address as the FW_DYNAMIC firmware. In this case, the relocation
     * lottery mechanism can potentially overwrite the previous booting
     * stage while other HARTs are still running in the previous booting
     * stage leading to boot-time crash. To avoid this boot-time crash,
     * the previous booting stage can specify last HART that will jump
     * to the FW_DYNAMIC firmware as the preferred boot HART.
     *
     * To avoid specifying a preferred boot HART, the previous booting
     * stage can set it to -1UL which will force the FW_DYNAMIC firmware
     * to use the relocation lottery mechanism.
     */
    target_long boot_hart;
};

根据注释可知,这个结构体的作用是存储上一个引导阶段传入的信息,准备将其传递给 OpenSBI。要传递的信息包括魔数、版本信息、下一阶段的地址、下一阶段的模式、OpenSBI 库的选项和指定引导过程优先使用硬件线程的 id。

回到 riscv_rom_copy_firmware_info,它在完成 struct fw_dynamic_info 的初始化后,将会判断 ROM 剩余空间的大小,并将这个结构体拷贝进 ROM 中:

 if (dinfo_len > (rom_size - reset_vec_size))
     {
         error_report("not enough space to store dynamic firmware info");
         exit(1);
     }

     rom_add_blob_fixed_as("mrom.finfo", &dinfo, dinfo_len,
                           rom_base + reset_vec_size,
                           &address_space_memory);

小结

本文简要介绍了 QEMU 中常用的数据结构、参数的解析过程、QOM,以及 RISC-V virt 设备的初始化,并对 virt 设备 ZSBL 阶段的行为进行了分析。在后续篇章中我们将进一步介绍 OpenSBI 引入 fw_dynamic 这类 firmware 并取代 fw_jump 与 fw_payload 成为 QEMU 推荐的默认选择的原因,并分析 SBI 规范的 HSM 扩展在 RISC-V 启动路径上的实现。

参考资料

  1. gdb 文档
  2. QEMU 启动方式分析(1):QEMU 及 RISC-V 启动流程简介
  3. QEMU 启动方式分析(2): QEMU ‘virt’ 平台下通过 OpenSBI + U-Boot 引导 RISCV64 Linux Kernel
  4. QEMU 文档
  5. QEMU QOM 文档
  6. QEMU wiki
  7. QEMU QMP
  8. QEMU 源码分析-QOM
  9. QEMU virt 分析
  10. 《QEMU/KVM 源码解析与应用》,李强


Read Album:

Read Related:

Read Latest: