QEMU study

admin 2023-11-29 20:02:11 AnQuanKeInfo 来源:ZONE.CI 全球网 0 阅读模式

 

前言

从理论方面来解析QEMU,是我学习QEMU的一些记录,本篇文章主要记录QEMU/KVM概述和QEMU基本组件。

本篇文章对应的QEMU源码版本为2.8.1,这里给出下载地址:https://git.qemu.org/

找到上面对应的2.8.1版本下载就好

 

QEMU/KVM概述

这是对于QEMU/KVM的一个总体概述,简单介绍了什么是CPU虚拟化,内存虚拟化,外设虚拟化。

虚拟机简介

模拟器是另一种形式的虚拟机,它可以视为一种硬件指令集(ISA),应用程序在源ISA上被编译出来,在模拟器帮助下,运行在不同的目标ISA上。简单来说,就是将所有源ISA指令翻译成目标ISA上的指令,如下图所示。

高级语言虚拟机则更进一步,将源ISA目标ISA完全分离开,虚拟机中并无任何具体物理ISA指令字节,而是自己定义虚拟的指令字节,这些指令字节通常叫字节码。虚拟机的作用就是将这种自定义的虚拟的指令字节转换成对应平台的物理ISA指令

系统虚拟化中,管理全局物理资源的软件叫做虚拟机监视器(VMM),其之于虚拟机就如同操作系统之于进程。

QEMU与KVM架构

QMEU-KVM:最开始KVM只负责最核心的CPU虚拟化和内存虚拟化部分,QEMU作为其用户态组件,负责完成大量外设的模拟。形象的来说,qemu模拟出整个电脑,kvm半途加入qemu架构里面,抢去了原本由qemu负责的cpu和内存虚拟化的工作。

CPU引入了支持硬件虚拟化的指令集VT-x之后出现了VMX root 和 VMX non-root。VMX root理解成宿主机模式,VMX non-root理解成虚拟机模式。
CPU在运行包括QEMU在内的普通进程和宿主机的操作系统内核时,处于VMX root模式,CPU在运行虚拟机中的用户程序和操作系统代码时处于VMX non-root模式。

cpu虚拟化

QEMU创建CPU线程,在初始化的时候设置好相应的虚拟CPU寄存器的值,然后调用KVM的接口,运行虚拟机,在物理CPU上执行虚拟机代码。

在虚拟机运行时,KVM会截获虚拟机中的敏感指令,当虚拟机中的代码是敏感指令或者满足了一定的退出条件时,CPU会从VMX non-root模式退出到KVM,这就是下图的VM exit。虚拟机的退出首先陷入到KVM进行处理,但是如果遇到KVM无法处理的事件,比如虚拟机写了设备的寄存器地址,那么KVM就会将这个操作交给QEMU处理。当QEMU/KVM处理好了退出事件后,又会将CPU置于VMX non-root模式,也就是下图的VM Entry。

KVM使用VMCS结构来保存VM Exit和VM Entry

内存虚拟化

QEMU初始化时调用KVM接口告知KVM,虚拟机所需要的物理内存,通过mmap分配宿主机的虚拟内存空间作为虚拟机的物理内存,QEMU在更新内存布局时会持续调用KVM通知内核KVM模块虚拟机的内存分布。

在CPU支持EPT(拓展页表)后,CPU会自动完成虚拟机物理地址宿主机物理地址的转换。虚拟机第一次访问内存的时候会陷入KVM,KVM逐渐建立起EPT页面。这样后续的虚拟机的虚拟CPU访问虚拟机虚拟内存地址时,会先被转换为虚拟机物理地址,接着查找EPT表,获取宿主机物理地址

外设虚拟化

设备模拟的本质是要为虚拟机提供一个与物理设备接口完全一致的虚拟接口。虚拟机中的操作系统与设备进行的数据交互由QEMU和KVM完成,有时又由宿主机上对应的后端设备完成。

现在QEMU一共有三种模拟方式:

  1. 完全虚拟化(纯软件模拟):虚拟机内核不做任何修改,每一次对设备的寄存器读写都会陷入到KVM,进而到QEMU,QEMU再对这些请求进行处理并模拟硬件行为。

    软件模拟会导致非常多的QEMU/KVM介入,效率不高

  2. 半虚拟化(virtio设备方案):virtio设备是一类特殊的设备,并没有对应的物理设备,所以需要虚拟机内存操作系统安装特殊的virtio驱动
  3. 设备直通:物理硬件直接挂载到虚拟机上,虚拟机直接与物理设备交互,尽可能在I/O路径上减少QEMU/KVM的参与。SRIOV技术经常与设备直通方案一起使用,SRIOV能够将单个的物理硬件高效的虚拟出多个虚拟硬件。

虚拟机创建过程

第一步,获取到kvm句柄
kvmfd = open("/dev/kvm", O_RDWR);
// 获取kvm的版本号,从而使应用层知道相关接口在内核是否有支持
ioctl (kvmfd, KVM_GET_API_VERSION,NULL);
第二步,创建虚拟机,获取到虚拟机句柄。
vmfd = ioctl(kvmfd, KVM_CREATE_VM, 0);
第三步,为虚拟机映射内存,还有其他的PCI,信号处理的初始化。
ioctl(kvmfd, KVM_SET_USER_MEMORY_REGION, &mem);
第四步,将虚拟机镜像映射到内存,相当于物理机的boot过程,把镜像映射到内存。
第五步,创建vCPU,并为vCPU分配内存空间。每一个vcpu都有一个struct_kvm_run结构,用来在用户态(qemu)和内核态(KVM)共享数据。
ioctl(kvmfd, KVM_CREATE_VCPU, vcpuid);
// 用户态程序需要将这段空间映射到用户空间,调用ioctl(KVM_GET_VCPU_MMAP_SIZE)得到这个结构大小
vcpu->kvm_run_mmap_size = ioctl(kvm->dev_fd, KVM_GET_VCPU_MMAP_SIZE, 0);
第五步,创建vCPU个数的线程并运行虚拟机。
ioctl(kvm->vcpus->vcpu_fd, KVM_RUN, 0);
第六步,线程进入循环,并捕获虚拟机退出原因,做相应的处理。
这里的退出并不一定是虚拟机关机,虚拟机如果遇到IO操作,访问硬件设备,缺页中断等都会退出执行,退出执行可以理解为将CPU执行上下文返回到QEMU。如果内核态的KVM不能处理就会交给应用层软件处理

 

QEMU基本组件

glib事件循环机制

前置知识

glibglibclibc之间的区别:
glib:用C写来的基于对象的事件循环和实用程序库
glibc:一个C语言库。它提供了像printf和fopen之类的东西
libc:实际上是一个泛指。凡是符合实现了 C 标准规定的内容,都是一种 libc ,glibc 是 GNU 组织对 libc 的一种实现。它是 unix/linux 的根基之一。

正文

glib的一个重要特点是能够定义新的事件源类型,事件源类型通过两种方式跟主上下文交互。

  1. GSourceFuncs中的prepare函数可以设置一个超时时间,以此来决定主事件循环中轮询的超时时间
  2. g_source_add_poll函数来添加fd

glib主上下文的一次循环包括prepare,query,check,dispatch四个过程。

  1. prepare: 通过g_main_context_prepare()会调用事件对应的prepare回调函数,进行准备工作,如果事件已经准备好监听了,返回true
  2. query: 通过g_main_context_query()可以获得实际需要调用poll的文件fd
  3. check: 当query之后获得了需要进行监听的fd,调用 poll 对 fd 进行监听,当 poll 返回的时候,调用g_main_context_check()将 poll 的结果返回给主循环,如果 fd 事件能够被分派就会返回 true
  4. dispatch: 通过g_main_context_dispatch()调用事件源对应事件的处理函数

    例子

#include <glib.h>
/*
函数打印标准输入中读到内容的长度
*/
gboolean io_watch(GIOChannel *channel,
                  GIOCondition condition,
                  gpointer data)
{
    gsize len = 0;
    gchar *buffer = NULL;

    g_io_channel_read_line(channel, &buffer, &len, NULL, NULL);

    if(len > 0)
        g_print("%d\n", len);

    g_free(buffer);

    return TRUE;
}

int main(int argc, char* argv[])
{
    GMainLoop *loop = g_main_loop_new(NULL, FALSE);    // 获取一个上下文的事件循环实例,context为NULL则获取默认的上下文循环
    GIOChannel* channel = g_io_channel_unix_new(1);    // 将标准输入描述符转化成GIOChannel,方便操作

    if(channel) {
        g_io_add_watch(channel, G_IO_IN, io_watch, NULL);    
        // 将针对channel事件源的回调注册到默认上下文,告诉Glib自己对channel的输入(G_IO_IN)感兴趣
        // 当输入准备好之后,调用自己注册的回调io_watch,并传入参数NULL。
        g_io_channel_unref(channel);
    }

    g_main_loop_run(loop);    // 执行默认上下文的事件循环
    g_main_context_unref(g_main_loop_get_context(loop));
    g_main_loop_unref(loop);

    return 0;
}

结果

hello world
12
Glib event loop
21

QEMU中的事件循环机制

QEMU在进行好所有的初始化工作后会调用函数 main_loop 来开始主循环

在/qemu/vl.c里main_loop函数中的代码

...
    do {
        ...
            last_io = main_loop_wait(nonblocking);
        ...
    } while {!main_loop_should_exit}

调用流程为 main_loop —> main_loop_wait —> os_host_main_loop_wait —> 自此处开始在os_host_main_loop_wait函数中依次调用 glib_pollfds_fillqemu_poll_nsglib_pollfds_poll

  • 主循环第一个函数是glib_pollfds_fill,主要工作是获取所有需要进行监听的fd,并计算一个最小的超时时间
  • 主循环第二个函数是qemu_poll_ns,其接收三个参数 int qemu_poll_ns(GPollFD *fds,guintnfds,int64_t timeout),第一个参数是要监听的fd数组,第二个参数是fds的长度,第三个参数表示g_poll最多阻塞的时间
    • g_poll是一个跨平台的poll函数,用来监听文件上发生的事件
    • qemu_poll_ns 的调用会阻塞主线程,当该函数返回后,要么表示文件 fd 上发生了事件,要么表示一个超时
  • 主循环第三个函数是glib_pollfds_poll,负责事件的分发处理
    • glib_pollfds_poll —> g_main_context_check (检测事件) —> g_main_context_dispatch (事件分发)

调试

以虚拟机的VNC连接来分析相应的函数调用过程。

vnc客户端下载地址:https://www.realvnc.com/download/file/viewer.files/VNC-Viewer-6.20.529-Linux-x64.deb

使用的命令是:![](https://p1.ssl.qhimg.com/t014abb26102dd25169.png)

断点下在vnc_listen_io函数,我们就可以看到vnc连接fd的事件处理函数堆栈了

QEMU自定义事件源

QEMU自定义了一个新事件源AioContext,有两类AioContext

  • 第一类用来监听各种各样的事件
  • 第二类用来处理块设备层的异步I/O请求

其扩展了glib中 source 的功能,不但支持fd的事件处理,还模拟内核中的下半部机制,实现了QEMU中的下半部以及定时器的管理

QEMU事件处理过程

signalfd 是linux的一个系统调用,可以将特定的信号与一个 fd 绑定起来,当有信号到达的时候 fd 就会产生对应的可读事件,此处以signalfd的处理为例来介绍

  • main —> qemu_init_main_loop (AioContext事件源初始化) —> qemu_signal_init —> qemu_set_handler
    • qemu_signal_init 负责将一个fd 和一组信号关联
    • qemu_set_handler 设置该 signalfd 对应的可读回调用函数为 sigfd_handler,该函数在首次调用时会调用iohandler_init创建全局的 iohandler_ctx 事件源,该事件源用于监听QEMU中的各种事件
    • 最终,在iohandler_init的aio_handlers 上挂一个 AioHandler节点,fd为此处的signalfd
  • qemu_init_main_loop接着调用 aio_context_new 创建全局事件源 qemu_aio_context,处理BH和块设备层的同步使用
  • 最后获取qemu_aio_context 和 iohandler_ctx 的 GSource,调用g_source_attach 将两个AioContext加入glib主循环
  • 加入完毕后,就会如上循环所示在while事件中进行事件监听

QOM

前置知识

在阅读这一小节前,需要先了解一下如何用C实现OOP的三个基本特性,即:封装,继承,多态。

这里给出参考链接:https://blog.csdn.net/onlyshi/article/details/81672279

用c实现面向对象,QEMU中体现这一思想的就是QOM。

QEMU中的QOM整体运作主要有三部分,如下图所示:

类型的注册

类型的注册是通过type_init完成的,type_init是一个宏,调用了module_init

#define type_init(function) module_init(function, MODULE_INIT_QOM)
// module_init转手调用了register_module_init,所以初始化全部由register_module_init来完成
#define module_init(function, type)                                         \
static void __attribute__((constructor)) do_qemu_init_ ## function(void)    \
{                                                                           \
    register_module_init(function, type);                                   \
}

所有的QOM类型注册在main执行之前就已经执行了。初始化函数register_module_init根据类型的初始化函数和所属类型构建出ModuleEntry,把它插入module对应的链表里,所有module链表放在一个init_type_list数组里。

在进入main函数后不久,就通过调用module_call_init,以MODULE_INIT_QOM为参数,将init_type_list[MODULE_INIT_QOM]链表上每一个ModuleEntry的init函数进行调用。也就是将MODULE_INIT_QOM链表上的所有函数都初始化

    static const TypeInfo edu_info = {
        .name          = "edu",                               // 名字
        .parent        = TYPE_PCI_DEVICE,                    // 父类型的名字
        .instance_size = sizeof(EduState),                    // 该类型对应的实例大小
        .instance_init = edu_instance_init,                    // 实例的初始化函数
        .class_init    = edu_class_init,                    // 该类型的类初始化
    };

struct TypeImpl
{
    const char *name;                                        // 类型姓名

    size_t class_size;                                        // 类大小

    size_t instance_size;                                    // 该类所属的实例大小

    void (*class_init)(ObjectClass *klass, void *data);           // 类初始化函数,这个函数的作用,个人理解类似于多态里面对于虚表的重定向
    void (*class_base_init)(ObjectClass *klass, void *data);   // 注释上说消除memcpy从父类到子类的影响,不懂
    void (*class_finalize)(ObjectClass *klass, void *data);        // 类的销毁函数

    void *class_data;                                        // 

    void (*instance_init)(Object *obj);                           // 类实例的初始化函数
    void (*instance_post_init)(Object *obj);
    void (*instance_finalize)(Object *obj);                        // 类实例的销毁函数

    bool abstract;

    const char *parent;
    TypeImpl *parent_type;

    ObjectClass *class;

    int num_interfaces;
    InterfaceImpl interfaces[MAX_INTERFACES];
};

总结一下就是:每个类型指定一个TypeInfo注册到系统中,接着在系统运行初始化的时候会把TypeInfo转变成TypeImple放到一个哈希表中

类型的初始化

类的初始化是通过type_initialize函数完成的,参数是表示类型信息的TypeImpl类型ti。

  • 先判断ti->class是否存在
  • 如果为空,就进行初始化,一共三件事
    • 设置相关的filed,确定该类所属的实例大小,类大小,类信息(class_size,instance_size,class)
    • 初始化所有父类类型的TypeImpl信息

      初始化前后的结构体变化

  • 依次调用所有父类型的class_base_init和自己的class_init,也就是调用所有父类的构造函数

总结一下:系统对这个哈希表中的每一个类型进行初始化,主要是设置TypeImpl的一些域和调用类型的class_init函数

类型的层次结构

也就是QOM通过这种层次结构实现了面向对象的继承。

这里需要注意的是类型的转换,因为object class为所有类的父类,所以类型转换都是从object class强转为所需要的类型。如果出现转换的类型与所需要的类型不匹配的情况,就需要调用type_is_ancestor来判断转出来的类型是否是所需要的类型的父类。

属性

属性由ObjectProperty表示

typedef struct ObjectProperty
{
    gchar *name;                        // 名字
    gchar *type;                        // 属性的类型(如字符串,bool)
    gchar *description;                     // 
    ObjectPropertyAccessor *get;          // 对属性进行操作
    ObjectPropertyAccessor *set;          // 对属性进行操作
    ObjectPropertyAccessor *resolve;      // 对属性进行操作
    ObjectPropertyAccessor *release;      // 对属性进行操作
    void *opaque;                        // 指向具体的属性,也就是对应属性类型的结构体
} ObjectProperty;

属性相关的结构体关系

属性的添加分为类属性的添加和对象属性的添加,对象属性的添加通过object_property_add实现

  • 先确认所插入属性是否存在
  • 分配ObjectProperty结构并使用参数进行初始化
  • 插入对象的properties域中

这里提一点关于realize属性,在对构造一个设备对象的时候,构造结束但是对应这个设备的一些域还没有初始化,也就代表现在这个设备是不可用的 如果说设置了realized属性的话,就会调用realized属性中的设置函数,给那些没有初始化的域进行初始化,从而导致设备可用 而且在构造对象的时候会回溯调用父类的instance_init函数,父类的instance_init函数也会对子类的属性进行操作

child属性表示对象之间的从属关系。

link属性表示一种连接关系,表示一种设备引用了另一种设备

 

参考资料

《QEMU/KVM源码解析与应用》

Resery师傅的QEMU笔记:

https://github.com/Resery/Virtualized_Learning/blob/master/QEMU/Qemu_2.md

weinxin
版权声明
本站原创文章转载请注明文章出处及链接,谢谢合作!
评论:0   参与:  0