Linux虚拟化原理笔记
一、虚拟机1. 操作系统上的程序分为两种,一种是用户态的程序例如Word、Excel等,一种是内核态的程序例如内核代码、驱动程序等。为了区分内核态和用户态,CPU专门设置四个特权等级0、1、2、3。在虚拟化技术出现以前,内核态运行在第0等级,用户态运行在第3等级,占了两头中间的还没用。如果用户态程序做事情,就切换到第3等级,一旦要申请使用更多的资源,就需要到内核态第0等级,内核才能在高权限访问..
一、虚拟机
1. 操作系统上的程序分为两种,一种是用户态的程序例如Word、Excel等,一种是内核态的程序例如内核代码、驱动程序等。为了区分内核态和用户态,CPU专门设置四个特权等级0、1、2、3。在虚拟化技术出现以前,内核态运行在第0等级,用户态运行在第3等级,占了两头中间的还没用。如果用户态程序做事情,就切换到第3等级,一旦要申请使用更多的资源,就需要到内核态第0等级,内核才能在高权限访问这些资源,申请完资源返回到用户态,又回到第3等级。
如果安装VirtualBox在虚拟机里面安装一个Linux,VirtualBox这个虚拟化软件和物理机上的Excel一样都是一个普通的应用。当进入虚拟机的时候,虚拟机里面的Excel也是一个普通的应用。当虚拟机里面的Excel要访问网络的时候,虚拟机Linux就要努力地去操作网卡,但它没有这个权限,虚拟化层也就是Virtualbox会解决这个问题,它有三种虚拟化的方式:
(1)完全虚拟化(Full virtualization)。其实说白了这是一种“骗人”的方式。虚拟化软件会模拟假的CPU、内存、网络、硬盘给虚拟机,让它感觉自己像是物理机内核。但是真正的工作模式其实是当虚拟机内核申请内存和CPU时间片等资源时,由VirtualBox等虚拟机软件代劳,以物理机上的用户态向物理机内核申请资源再给虚拟机内核,虚拟机内核拿到VirtualBox申请的物理机资源后给虚拟机上的用户态软件运行,并且虚拟机的内存地址例如从0开始,但实际上在物理机上可能是从地址90开始。这种方式一个坏处就是非常慢。
(2)硬件辅助虚拟化(Hardware-Assisted Virtualization)。即VirtualBox让虚拟机意识到自己不是物理机,物理机资源的权限问题可以交给Intel的VT-x和AMD的AMD-V标志位。它们是ring 0到3以外的一个新的标志位,表示当前是在虚拟机状态下。对于虚拟机内核来讲,只要将该标志位设为虚拟机状态,就可以直接在物理CPU上执行大部分的指令,不需要虚拟化软件在中间转述,除非遇到特别敏感的指令,才需要将标志位设为物理机内核态运行,这样大大提高了效率。所以安装虚拟机的时候,务必要在BIOS中将物理CPU的这个标志位打开。想知道是否打开,对于Intel可以查看grep “vmx” /proc/cpuinfo;对于AMD可以查看grep “svm” /proc/cpuinfo。
(3)半虚拟化(Paravirtualization)。就是访问网络或者硬盘的时候,为了取得更高的性能,需要让虚拟机内核加载特殊的驱动,也是让虚拟机内核从代码层面就重新定位自己的身份,不能像访问物理机一样访问网络或者硬盘,而是用一种特殊的方式。虚拟机意识到可能和很多其他虚拟机共享物理资源,所以要学会排队,写硬盘其实写的是一个物理机上的文件,那么写文件的缓存方式可以变一下。虚拟机发送网络包根本就不是发给真正的网络设备,而是给虚拟的设备,可以直接在物理机内存里面拷贝给它等等。
2. 服务器上的虚拟化软件多使用qemu,其中关键字emu全称是 emulator。所以单纯使用qemu,采用的是完全虚拟化的模式。qemu向Guest OS模拟CPU,也模拟其他的硬件,GuestOS认为自己和硬件直接打交道,其实是同qemu模拟出来的硬件打交道,qemu会将这些指令转译给真正的硬件。由于所有的指令都要从qemu里面过一手,因而性能就会比较差,如下图所示:
因此,完全虚拟化是非常慢的,所以要使用硬件辅助虚拟化技术Intel-VT或AMD-V。当确认开始了标志位之后,通过KVM,GuestOS的CPU指令不用经过Qemu转译直接运行,大大提高了速度。所以,KVM在内核里面需要有一个模块,来设置当前CPU是Guest OS在用,还是Host OS在用。下面来查看内核模块中是否含有kvm,可以通过如下命令:
lsmod | grep kvm
KVM内核模块通过/dev/kvm暴露接口,用户态程序可以通过ioctl来访问这个接口。例如可以通过下面的流程编写程序:
Qemu将KVM整合进来,将有关CPU指令的部分交由内核模块来做,就是qemu-kvm (有的系统上叫qemu-system-XXX)。qemu和kvm整合之后,CPU的性能问题解决了。另外Qemu还会模拟其他的硬件如网络和硬盘。同样,完全虚拟化的方式也会影响这些设备的性能。于是,qemu采取半虚拟化的方式,让Guest OS加载特殊的驱动来做这件事情,例如网络需要加载virtio_net,存储需要加载virtio_blk,Guest需要安装这些半虚拟化驱动,GuestOS知道自己是虚拟机,所以数据会直接发送给半虚拟化设备,经过特殊处理(例如排队、缓存、批量处理等性能优化方式),最终发送给真正的硬件,这在一定程度上提高了性能。至此,整个关系如下图所示:
3. 在VirtualBox上创建虚拟机的过程中,虚拟机连外网需要桥接网络模式,在物理机上就会形成下面的实际结构:
每个虚拟机都会有虚拟网卡,在物理机上会发现多了几个网卡,其实是虚拟交换机,这个虚拟交换机将虚拟机连接在一起。在桥接模式下,物理网卡也连接到这个虚拟交换机上。如果使用桥接网络,当登录虚拟机里看IP地址时会发现,虚拟机的地址和你物理机的地址,以及你旁边其他物理机的网段是一个网段,这其实相当于将物理机和虚拟机放在同一个网桥上,相当于这个网桥上有三台机器是一个网段的,全部打平了,逻辑拓扑看起来就像下图所示的那样:
在数据中心里面采取的也是类似的技术,连接方式如下图所示,只不过是Linux在每台机器上都创建网桥br0,虚拟机网卡都连到br0上,物理网卡也连到br0上,所有的br0都通过物理网卡连接到物理交换机上:
同样换一个逻辑上的角度看待上面这个拓扑图,同样是假装将网络打平,虚拟机会和物理网络具有相同的网段,就相当于两个虚拟交换机、一个物理交换机,一共三个交换机连在一起,两组四个虚拟机和两台物理机都是在同一个二层网络里面的,如下图所示:
因此,虚拟化的本质是用qemu等软件模拟硬件,但是模拟方式比较慢需要加速;虚拟化主要模拟CPU、内存、网络、存储,分别有不同的加速办法;CPU和内存主要使用硬件辅助虚拟化进行加速,需要配备特殊的硬件才能工作;网络和存储主要使用特殊的半虚拟化驱动加速,需要加载特殊的驱动程序。
二、计算虚拟化之CPU
4. 上面讲了虚拟化的基本原理,以及qemu、kvm之间的关系(前者集成了后者)。这里就来看一下用户态的qemu和内核态的kvm如何一起协作来创建虚拟机,实现CPU和内存虚拟化。在qemu的源码中,main函数在vl.c下面。第一步是初始化所有的Module,调用下面的函数:
module_call_init(MODULE_INIT_QOM);
上面讲过qemu作为中间人其实挺累的,对虚拟机需要模拟各种各样的外部设备。当虚拟机真的要使用物理资源的时候,对下面物理机上的资源要进行请求,所以它的工作模式有点儿类似操作系统对接驱动。驱动要符合一定的格式,才能算操作系统的一个模块。同理,qemu为了模拟各种各样的设备,也需要管理各种各样的模块,这些模块也需要符合一定的格式。定义一个qemu模块会调用type_init,例如kvm的模块要在accel/kvm/kvm-all.c文件里面实现,在这个文件里面有下面的代码:
type_init(kvm_type_init);
#define type_init(function) module_init(function, MODULE_INIT_QOM)
#define module_init(function, type) \
static void __attribute__((constructor)) do_qemu_init_ ## function(void) \
{ \
register_module_init(function, type); \
}
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);
}
从代码的定义可以看出,type_init后面的参数是一个函数,调用type_init就相当于调用module_init,在这里函数就是kvm_type_init,类型就是MODULE_INIT_QOM,感觉和驱动有点儿像。module_init最终调用register_module_init。属于MODULE_INIT_QOM这种类型的,有一个Module列表ModuleTypeList,列表里面是一项一项的ModuleEntry,KVM就是其中一项,并且会初始化每一项的init函数为参数表示的函数fn,即KVM这个module的init函数就是kvm_type_init。当然MODULE_INIT_QOM这种类型会有很多的module,从后面的代码可以看到,所有调用type_init的地方都注册了一个MODULE_INIT_QOM类型的Module。
了解了Module的注册机制,继续回到main函数中module_call_init的调用,如下所示:
void module_call_init(module_init_type type)
{
ModuleTypeList *l;
ModuleEntry *e;
l = find_type(type);
QTAILQ_FOREACH(e, l, node) {
e->init();
}
}
在module_call_init中会找到MODULE_INIT_QOM这种类型对应的ModuleTypeList,找出列表中所有的ModuleEntry,然后调用每个ModuleEntry的init函数。这里需要注意的是,在module_call_init调用的这一步,所有Module的init函数都已经被调用过了。后面会看到很多的Module,当看到它们的时候需要意识到,它的init函数在这里也被调用过了。这里还是以对于kvm这个module为例子,看看它的init函数都做了哪些事情,会发现其实它调用的是kvm_type_init,如下所示:
static void kvm_type_init(void)
{
type_register_static(&kvm_accel_type);
}
TypeImpl *type_register_static(const TypeInfo *info)
{
return type_register(info);
}
TypeImpl *type_register(const TypeInfo *info)
{
assert(info->parent);
return type_register_internal(info);
}
static TypeImpl *type_register_internal(const TypeInfo *info)
{
TypeImpl *ti;
ti = type_new(info);
type_table_add(ti);
return ti;
}
static TypeImpl *type_new(const TypeInfo *info)
{
TypeImpl *ti = g_malloc0(sizeof(*ti));
int i;
if (type_table_lookup(info->name) != NULL) {
}
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->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 void type_table_add(TypeImpl *ti)
{
assert(!enumerating_types);
g_hash_table_insert(type_table_get(), (void *)ti->name, ti);
}
static GHashTable *type_table_get(void)
{
static GHashTable *type_table;
if (type_table == NULL) {
type_table = g_hash_table_new(g_str_hash, g_str_equal);
}
return type_table;
}
static const TypeInfo kvm_accel_type = {
.name = TYPE_KVM_ACCEL,
.parent = TYPE_ACCEL,
.class_init = kvm_accel_class_init,
.instance_size = sizeof(KVMState),
};
每一个Module既然要模拟某种设备,那应该定义一种类型TypeImpl来表示这些设备,这其实是一种面向对象编程的思路,只不过这里用的是纯C语言实现,所以需要变相实现一下类和对象。kvm_type_init会注册kvm_accel_type定义上面的代码,可以认为这样动态定义了一个类,这个类的名字是TYPE_KVM_ACCEL,这个类有父类TYPE_ACCEL,这个类的初始化应该调用函数kvm_accel_class_init。如果用这个类声明一个对象,对象的大小应该是instance_size,有点儿Java反射的意思,根据一些名称的定义一个类就定义好了。这里的调用链为:kvm_type_init->type_register_static->type_register->type_register_internal。
在type_register_internal中,会根据kvm_accel_type这个TypeInfo,创建一个TypeImpl来表示这个新注册的类,也就是说TypeImpl才是想要声明的那个class。在qemu里面,有一个全局的哈希表type_table,用来存放所有定义的类。在type_new里面,先从全局表里面根据名字找这个类,如果找到,说明这个类曾经被注册过,就报错;如果没有找到,说明这是一个新的类,则将TypeInfo里面信息填到TypeImpl里面,type_table_add会将这个类注册到全局的表里面。
到这里注意,class_init还没有被调用,即这个类现在还处于纸面上的状态,这点更加像Java的反射机制了。在Java里面对于一个类,首先写代码的时候要写一个class xxx的定义,编译好就放在.class文件中,这也是处于纸面的状态。然后Java会有一个Class对象,用于读取和表示这个纸面上的class xxx,可以生成真正的对象。
相同的过程在后面的代码中也可以看到,class_init会生成XXXClass,就相当于Java里面的Class对象,TypeImpl还会有一个instance_init函数,相当于构造函数,用于根据XXXClass生成Object,这就相当于Java反射里面最终创建的对象。和构造函数对应的还有instance_finalize,相当于析构函数。这一套反射机制放在qom文件夹下面,全称QEMU Object Model,即用C实现了一套面向对象的反射机制。
5. 说完了初始化Module,还回到main函数接着分析。第二步就要开始解析qemu的命令行了。qemu的命令行解析就是下面这样一长串,还记得之前写过一个解析命令行参数的程序,这里的opts是差不多的意思:
qemu_add_opts(&qemu_drive_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_machine_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_name_opts);
qemu_add_opts(&qemu_numa_opts);
这里贴一个开源云平台软件OpenStack创建出来的KVM参数,如下所示,并不需要全部看懂,只需要看懂一部分就行了:
qemu-system-x86_64
-enable-kvm
-name instance-00000024
-machine pc-i440fx-trusty,accel=kvm,usb=off
-cpu SandyBridge,+erms,+smep,+fsgsbase,+pdpe1gb,+rdrand,+f16c,+osxsave,+dca,+pcid,+pdcm,+xtpr,+tm2,+est,+smx,+vmx,+ds_cpl,+monitor,+dtes64,+pbe,+tm,+ht,+ss,+acpi,+ds,+vme
-m 2048
-smp 1,sockets=1,cores=1,threads=1
......
-rtc base=utc,driftfix=slew
-drive file=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/disk,if=none,id=drive-virtio-disk0,format=qcow2,cache=none
-device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1
-netdev tap,fd=32,id=hostnet0,vhost=on,vhostfd=37
-device virtio-net-pci,netdev=hostnet0,id=net0,mac=fa:16:3e:d1:2d:99,bus=pci.0,addr=0x3
-chardev file,id=charserial0,path=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/console.log
-vnc 0.0.0.0:12
-device cirrus-vga,id=video0,bus=pci.0,addr=0x2
(1)-enable-kvm:表示启用硬件辅助虚拟化。
(2)-name instance-00000024:表示虚拟机的名称。
(3)-machine pc-i440fx-trusty,accel=kvm,usb=off:machine其实就是计算机体系结构。qemu会模拟多种体系结构,常用的有x86的32位或者64位的体系结构、Mac电脑PowerPC的体系结构、Sun的体系结构、MIPS的体系结构、精简指令集等。如果使用KVM hardware-assisted virtualization,即BIOS中VD-T是打开的,则参数中accel=kvm。如果不使用hardware-assisted virtualization,用的是纯模拟,则有参数accel = tcg,-no-kvm。
(4)-cpu SandyBridge,+erms,+smep,+fsgsbase,+pdpe1gb,+rdrand,+f16c,+osxsave,+dca,+pcid,+pdcm,+xtpr,+tm2,+est,+smx,+vmx,+ds_cpl,+monitor,+dtes64,+pbe,+tm,+ht,+ss,+acpi,+ds,+vme:表示设置CPU,SandyBridge是Intel处理器型号,后面的加号都是添加的CPU参数,这些参数会显示在/proc/cpuinfo里面。
(5)-m 2048:表示内存。
(6)-smp 1,sockets=1,cores=1,threads=1:SMP叫对称多处理器和NUMA对应。qemu仿真了一个具有1个vcpu、一个socket、一个core、一个threads的处理器。socket就是主板上插cpu的槽的数目,即常说的“路”;core就是平时说的“核”,即双核、4核等;thread就是每个core的硬件线程数即超线程。例如某个服务器是2路4核超线程(超线程一个core一般有2个线程),通过cat /proc/cpuinfo命令看到的是2*4*2=16个processor,也习惯被称为16核。
(7)-rtc base=utc,driftfix=slew:表示系统时间由参数-rtc指定。
(8)-device cirrus-vga,id=video0,bus=pci.0,addr=0x2:表示显示器用参数-vga设置,默认为cirrus,它模拟了CL-GD5446PCI VGA card。
(9)有关网卡,使用-net参数和-device,分别对应物理机和虚拟机。从HOST角度:-netdev tap,fd=32,id=hostnet0,vhost=on,vhostfd=37。从GUEST角度:-device virtio-net-pci,netdev=hostnet0,id=net0,mac=fa:16:3e:d1:2d:99,bus=pci.0,addr=0x3。
(10)有关硬盘,使用-hda -hdb,或者使用-drive和-device,也对应物理机和虚拟机。从HOST角度:-drive file=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/disk,if=none,id=drive-virtio-disk0,format=qcow2,cache=none。从GUEST角度:-device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1。
(11)-vnc 0.0.0.0:12:设置VNC。
在main函数中,接下来的for循环和大量的switch case语句就是对于这些参数的解析,这里不一一解析,后面真的用到这些参数的时候再仔细看。
6. 回到main函数,接下来是初始化machine,如下所示:
machine_class = select_machine();
current_machine = MACHINE(object_new(object_class_get_name(
OBJECT_CLASS(machine_class))));
这里面的machine_class是什么呢?这还得从machine参数说起,如下所示:
-machine pc-i440fx-trusty,accel=kvm,usb=off
这里的pc-i440fx是x86机器默认的体系结构。在hw/i386/pc_piix.c中,它定义了对应的machine_class,如下所示:
DEFINE_I440FX_MACHINE(v4_0, "pc-i440fx-4.0", NULL,
pc_i440fx_4_0_machine_options);
#define DEFINE_I440FX_MACHINE(suffix, name, compatfn, optionfn) \
static void pc_init_##suffix(MachineState *machine) \
{ \
......
pc_init1(machine, TYPE_I440FX_PCI_HOST_BRIDGE, \
TYPE_I440FX_PCI_DEVICE); \
} \
DEFINE_PC_MACHINE(suffix, name, pc_init_##suffix, optionfn)
#define DEFINE_PC_MACHINE(suffix, namestr, initfn, optsfn) \
static void pc_machine_##suffix##_class_init(ObjectClass *oc, void *data
) \
{ \
MachineClass *mc = MACHINE_CLASS(oc); \
optsfn(mc); \
mc->init = initfn; \
} \
static const TypeInfo pc_machine_type_##suffix = { \
.name = namestr TYPE_MACHINE_SUFFIX, \
.parent = TYPE_PC_MACHINE, \
.class_init = pc_machine_##suffix##_class_init, \
}; \
static void pc_machine_init_##suffix(void) \
{ \
type_register(&pc_machine_type_##suffix); \
} \
type_init(pc_machine_init_##suffix)
为了定义machine_class,这里有一系列的宏定义,入口是DEFINE_I440FX_MACHINE,这个宏有几个参数,v4_0 是后缀,"pc-i440fx-4.0"是名字,pc_i440fx_4_0_machine_options是一个函数,用于定义machine_class相关的选项,这个函数定义如下:
static void pc_i440fx_4_0_machine_options(MachineClass *m)
{
pc_i440fx_machine_options(m);
m->alias = "pc";
m->is_default = 1;
}
static void pc_i440fx_machine_options(MachineClass *m)
{
PCMachineClass *pcmc = PC_MACHINE_CLASS(m);
pcmc->default_nic_model = "e1000";
m->family = "pc_piix";
m->desc = "Standard PC (i440FX + PIIX, 1996)";
m->default_machine_opts = "firmware=bios-256k.bin";
m->default_display = "std";
machine_class_allow_dynamic_sysbus_dev(m, TYPE_RAMFB_DEVICE);
}
先不看pc_i440fx_4_0_machine_options,先来看DEFINE_I440FX_MACHINE。这里面定义了一个pc_init_##suffix,也就是pc_init_v4_0,这里面转而调用pc_init1,注意这里这个函数只是定义了一下,没有被调用,但这个函数非常重要,后面会解析。
接下来,DEFINE_I440FX_MACHINE里面又定义了DEFINE_PC_MACHINE,它有四个参数,除了DEFINE_I440FX_MACHINE传进来的三个参数以外,多了一个initfn即初始化函数,指向刚才定义的pc_init_##suffix。在DEFINE_PC_MACHINE中,定义了一个函数pc_machine_##suffix##class_init。从函数的名字class_init可以看出,这是machine_class从纸面上的class初始化为Class对象的方法,在这个函数里面可以看到,它创建了一个MachineClass对象,这个就是Class对象。
MachineClass对象的init函数指向上面定义的pc_init##suffix,说明这个函数是machine这种类型初始化的一个函数,后面会被调用。接着看DEFINE_PC_MACHINE,它定义了一个pc_machine_type_##suffix的TypeInfo,这是用于生成纸面上的class的原材料,果真后面调用了type_init。看到了type_init,应该能够想到既然它定义了一个纸面上的class,那上面的那句module_call_init会和上面解析的type_init是一样的,在全局表里面注册了一个全局的名字是"pc-i440fx-4.0"的纸面上的class,即TypeImpl。
现在全局表中有这个纸面上的class了。回到select_machine,如下所示:
static MachineClass *select_machine(void)
{
MachineClass *machine_class = find_default_machine();
const char *optarg;
QemuOpts *opts;
......
opts = qemu_get_machine_opts();
qemu_opts_loc_restore(opts);
optarg = qemu_opt_get(opts, "type");
if (optarg) {
machine_class = machine_parse(optarg);
}
......
return machine_class;
}
MachineClass *find_default_machine(void)
{
GSList *el, *machines = object_class_get_list(TYPE_MACHINE, false);
MachineClass *mc = NULL;
for (el = machines; el; el = el->next) {
MachineClass *temp = el->data;
if (temp->is_default) {
mc = temp;
break;
}
}
g_slist_free(machines);
return mc;
}
static MachineClass *machine_parse(const char *name)
{
MachineClass *mc = NULL;
GSList *el, *machines = object_class_get_list(TYPE_MACHINE, false);
if (name) {
mc = find_machine(name);
}
if (mc) {
g_slist_free(machines);
return mc;
}
......
}
在select_machine中,有两种方式可以生成MachineClass。一种方式是find_default_machine,找一个默认的;另一种方式是machine_parse,通过解析参数生成MachineClass。无论哪种方式都会调用object_class_get_list获得一个MachineClass的列表,然后在里面找。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;
}
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;
}
在全局表type_table_get()中,对于每一项TypeImpl都执行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;
......
data->fn(k, data->opaque);
}
static void type_initialize(TypeImpl *ti)
{
TypeImpl *parent;
......
ti->class_size = type_class_get_size(ti);
ti->instance_size = type_object_get_size(ti);
if (ti->instance_size == 0) {
ti->abstract = true;
}
......
ti->class = g_malloc0(ti->class_size);
......
ti->class->type = ti;
while (parent) {
if (parent->class_base_init) {
parent->class_base_init(ti->class, ti->class_data);
}
parent = type_get_parent(parent);
}
if (ti->class_init) {
ti->class_init(ti->class, ti->class_data);
}
}
在object_class_foreach_tramp 中,会调用type_initialize,这里面会调用class_init将纸面上的class即TypeImpl变为ObjectClass,ObjectClass是所有Class类的祖先,MachineClass是它的子类。因为在machine的命令行里面指定了名字为"pc-i440fx-4.0",就肯定能够找到注册过了的TypeImpl,并调用它的class_init函数,因而pc_machine_##suffix##class_init会被调用,在这里面pc_i440fx_machine_options才真正被调用初始化MachineClass,并且将MachineClass的init函数设置为pc_init##suffix,即当select_machine执行完毕后,就有一个MachineClass了。
接着回到object_new,就很好理解了,MachineClass是一个Class类,接下来应该通过它生成一个Instance即对象,这就是object_new的作用,如下所示:
Object *object_new(const char *typename)
{
TypeImpl *ti = type_get_by_name(typename);
return object_new_with_type(ti);
}
static Object *object_new_with_type(Type type)
{
Object *obj;
type_initialize(type);
obj = g_malloc(type->instance_size);
object_initialize_with_type(obj, type->instance_size, type);
obj->free = g_free;
return obj;
}
object_new中,TypeImpl的instance_init会被调用创建一个对象,current_machine就是这个对象,它的类型是MachineState。至此绕了这么大一圈,有关体系结构的对象才创建完毕,接下来很多的设备的初始化包括CPU、内存,都是围绕着体系结构的对象来的,后面会常常看到current_machine。
7. 上面提到,虚拟机对于设备的模拟是一件非常复杂的事情,需要用复杂的参数模拟各种各样的设备。为了能够适配这些设备,qemu定义了自己的模块管理机制,只有了解了这种机制,后面看每一种设备的虚拟化的时候,才有一个整体的思路。这里的MachineClass是遇到的第一个,需要掌握它里面各种定义之间的关系,如下图所示:
每个模块都会有一个定义TypeInfo,会通过type_init变为全局的TypeImpl即纸面class。TypeInfo以及生成的TypeImpl有以下成员:name表示当前类型的名称;parent表示父类的名称;class_init用于将TypeImpl初始化为MachineClass;instance_init用于将MachineClass初始化为MachineState。
8. 上面qemu初始化的main函数,只解析了一个开头,得到了表示体系结构的MachineClass以及MachineState。接着回到main函数,接下来初始化的是块设备,调用的是configure_blockdev,这里需要重点关注上面参数中的硬盘,不过暂时只专注于计算方面,存储方面放到后面再解析。
初始化块设备后,接下来初始化的是计算虚拟化的加速模式,即要不要使用KVM。根据参数中的配置是启用KVM,这里调用的是configure_accelerator,如下所示:
configure_accelerator(current_machine, argv[0]);
void configure_accelerator(MachineState *ms, const char *progname)
{
const char *accel;
char **accel_list, **tmp;
int ret;
bool accel_initialised = false;
bool init_failed = false;
AccelClass *acc = NULL;
accel = qemu_opt_get(qemu_get_machine_opts(), "accel");
accel = "kvm";
accel_list = g_strsplit(accel, ":", 0);
for (tmp = accel_list; !accel_initialised && tmp && *tmp; tmp++) {
acc = accel_find(*tmp);
ret = accel_init_machine(acc, ms);
}
}
static AccelClass *accel_find(const char *opt_name)
{
char *class_name = g_strdup_printf(ACCEL_CLASS_NAME("%s"), opt_name);
AccelClass *ac = ACCEL_CLASS(object_class_by_name(class_name));
g_free(class_name);
return ac;
}
static int accel_init_machine(AccelClass *acc, MachineState *ms)
{
ObjectClass *oc = OBJECT_CLASS(acc);
const char *cname = object_class_get_name(oc);
AccelState *accel = ACCEL(object_new(cname));
int ret;
ms->accelerator = accel;
*(acc->allowed) = true;
ret = acc->init_machine(ms);
return ret;
}
在configure_accelerator中,看命令行参数里面的accel发现是kvm,则调用accel_find,根据名字得到相应的纸面上的class,并初始化为Class类。MachineClass是计算机体系结构的Class类,同理AccelClass就是加速器的Class类,然后调用accel_init_machine,通过object_new将AccelClass这个Class类实例化为AccelState,类似对于体系结构的实例是MachineState。在accel_find中会根据名字kvm找到纸面上的class,即kvm_accel_type,然后调用type_initialize,里面调用kvm_accel_type的class_init方法即kvm_accel_class_init,如下所示:
static void kvm_accel_class_init(ObjectClass *oc, void *data)
{
AccelClass *ac = ACCEL_CLASS(oc);
ac->name = "KVM";
ac->init_machine = kvm_init;
ac->allowed = &kvm_allowed;
}
在kvm_accel_class_init中创建AccelClass,将init_machine设置为kvm_init。在accel_init_machine中其实就调用了这个init_machine函数,即调用kvm_init方法,如下所示:
static int kvm_init(MachineState *ms)
{
MachineClass *mc = MACHINE_GET_CLASS(ms);
int soft_vcpus_limit, hard_vcpus_limit;
KVMState *s;
const KVMCapabilityInfo *missing_cap;
int ret;
int type = 0;
const char *kvm_type;
s = KVM_STATE(ms->accelerator);
s->fd = qemu_open("/dev/kvm", O_RDWR);
ret = kvm_ioctl(s, KVM_GET_API_VERSION, 0);
......
do {
ret = kvm_ioctl(s, KVM_CREATE_VM, type);
} while (ret == -EINTR);
......
s->vmfd = ret;
/* check the vcpu limits */
soft_vcpus_limit = kvm_recommended_vcpus(s);
hard_vcpus_limit = kvm_max_vcpus(s);
......
ret = kvm_arch_init(ms, s);
if (ret < 0) {
goto err;
}
if (machine_kernel_irqchip_allowed(ms)) {
kvm_irqchip_create(ms, s);
}
......
return 0;
}
这里面的操作就从用户态到内核态的KVM了。就像前面原理讲过的一样,用户态使用内核态KVM的能力,需要打开一个文件/dev/kvm,这是一个字符设备文件,打开一个字符设备文件的过程以前讲过,这里不再赘述,如下所示:
static struct miscdevice kvm_dev = {
KVM_MINOR,
"kvm",
&kvm_chardev_ops,
};
static struct file_operations kvm_chardev_ops = {
.unlocked_ioctl = kvm_dev_ioctl,
.compat_ioctl = kvm_dev_ioctl,
.llseek = noop_llseek,
};
KVM这个字符设备文件定义了一个字符设备文件的操作函数kvm_chardev_ops,这里面只定义了ioctl的操作。接下来用户态就通过ioctl系统调用,调用到kvm_dev_ioctl这个函数,如下所示:
static long kvm_dev_ioctl(struct file *filp,
unsigned int ioctl, unsigned long arg)
{
long r = -EINVAL;
switch (ioctl) {
case KVM_GET_API_VERSION:
r = KVM_API_VERSION;
break;
case KVM_CREATE_VM:
r = kvm_dev_ioctl_create_vm(arg);
break;
case KVM_CHECK_EXTENSION:
r = kvm_vm_ioctl_check_extension_generic(NULL, arg);
break;
case KVM_GET_VCPU_MMAP_SIZE:
r = PAGE_SIZE; /* struct kvm_run */
break;
......
}
out:
return r;
}
可以看到在用户态qemu中,调用KVM_GET_API_VERSION查看版本号,内核就有相应的分支返回版本号,如果能够匹配上,则调用KVM_CREATE_VM创建虚拟机。创建虚拟机,需要调用kvm_dev_ioctl_create_vm:
static int kvm_dev_ioctl_create_vm(unsigned long type)
{
int r;
struct kvm *kvm;
struct file *file;
kvm = kvm_create_vm(type);
......
r = get_unused_fd_flags(O_CLOEXEC);
......
file = anon_inode_getfile("kvm-vm", &kvm_vm_fops, kvm, O_RDWR);
......
fd_install(r, file);
return r;
}
在kvm_dev_ioctl_create_vm中,首先调用kvm_create_vm创建一个struct kvm结构,这个结构在内核里面代表一个虚拟机。从下面结构的定义里可以看到,这里面有vcpu、mm_struct 结构,这个结构本来是用来管理进程的内存的。虚拟机也是一个进程,所以虚拟机的用户进程空间也是用它来表示,虚拟机里面的操作系统以及应用的进程空间不归它管,如下所示:
struct kvm {
struct mm_struct *mm; /* userspace tied to this vm */
struct kvm_memslots __rcu *memslots[KVM_ADDRESS_SPACE_NUM];
struct kvm_vcpu *vcpus[KVM_MAX_VCPUS];
atomic_t online_vcpus;
int created_vcpus;
int last_boosted_vcpu;
struct list_head vm_list;
struct mutex lock;
struct kvm_io_bus __rcu *buses[KVM_NR_BUSES];
......
struct kvm_vm_stat stat;
struct kvm_arch arch;
refcount_t users_count;
......
long tlbs_dirty;
struct list_head devices;
pid_t userspace_pid;
};
static struct file_operations kvm_vm_fops = {
.release = kvm_vm_release,
.unlocked_ioctl = kvm_vm_ioctl,
.llseek = noop_llseek,
};
在kvm_dev_ioctl_create_vm中,第二件事情就是创建一个文件描述符和struct file关联起来,这个struct file的file_operations会被设置为kvm_vm_fops。kvm_dev_ioctl_create_vm结束之后,对于一台虚拟机而言只是在内核中有一个数据结构,对于相应的资源还没有分配。
9. 接下来,调用net_init_clients进行网络设备的初始化。可以解析net参数,也会在net_init_clients中解析netdev参数。这属于网络虚拟化的部分,这里先暂时放一下,如下所示:
int net_init_clients(Error **errp)
{
QTAILQ_INIT(&net_clients);
if (qemu_opts_foreach(qemu_find_opts("netdev"),
net_init_netdev, NULL, errp)) {
return -1;
}
if (qemu_opts_foreach(qemu_find_opts("nic"), net_param_nic, NULL, errp)) {
return -1;
}
if (qemu_opts_foreach(qemu_find_opts("net"), net_init_client, NULL, errp)) {
return -1;
}
return 0;
}
接下来要调用machine_run_board_init,这里面调用了MachineClass的init函数,这才调用了pc_init1,如下所示:
void machine_run_board_init(MachineState *machine)
{
MachineClass *machine_class = MACHINE_GET_CLASS(machine);
numa_complete_configuration(machine);
if (nb_numa_nodes) {
machine_numa_finish_cpu_init(machine);
}
......
machine_class->init(machine);
}
在pc_init1里面重点关注两件重要的事情,一个是CPU的虚拟化,主要调用pc_cpus_init;另外就是内存的虚拟化,主要调用pc_memory_init。这里先重点关注CPU的虚拟化,如下所示:
void pc_cpus_init(PCMachineState *pcms)
{
......
for (i = 0; i < smp_cpus; i++) {
pc_new_cpu(possible_cpus->cpus[i].type, possible_cpus->cpus[i].arch_id, &error_fatal);
}
}
static void pc_new_cpu(const char *typename, int64_t apic_id, Error **errp)
{
Object *cpu = NULL;
cpu = object_new(typename);
object_property_set_uint(cpu, apic_id, "apic-id", &local_err);
object_property_set_bool(cpu, true, "realized", &local_err);//调用 object_property_add_bool的时候,设置了用 device_set_realized 来设置
......
}
在pc_cpus_init中,对于每一个CPU都调用pc_new_cpu,在这里又看到了object_new,这又是一个从TypeImpl到Class类再到对象的一个过程,这个时候就要看CPU的类是怎么组织的了。在上面的参数里面,CPU的配置是这样的:
-cpu SandyBridge,+erms,+smep,+fsgsbase,+pdpe1gb,+rdrand,+f16c,+osxsave,+dca,+pcid,+pdcm,+xtpr,+tm2,+est,+smx,+vmx,+ds_cpl,+monitor,+dtes64,+pbe,+tm,+ht,+ss,+acpi,+ds,+vme
在这里看到,SandyBridge是CPU的一种类型,在hw/i386/pc.c中能看到这种CPU的定义:
{ "SandyBridge" "-" TYPE_X86_CPU, "min-xlevel", "0x8000000a" }
接下来就来看"SandyBridge",即TYPE_X86_CPU这种CPU的类,是一个什么样的结构,如下所示:
static const TypeInfo device_type_info = {
.name = TYPE_DEVICE,
.parent = TYPE_OBJECT,
.instance_size = sizeof(DeviceState),
.instance_init = device_initfn,
.instance_post_init = device_post_init,
.instance_finalize = device_finalize,
.class_base_init = device_class_base_init,
.class_init = device_class_init,
.abstract = true,
.class_size = sizeof(DeviceClass),
};
static const TypeInfo cpu_type_info = {
.name = TYPE_CPU,
.parent = TYPE_DEVICE,
.instance_size = sizeof(CPUState),
.instance_init = cpu_common_initfn,
.instance_finalize = cpu_common_finalize,
.abstract = true,
.class_size = sizeof(CPUClass),
.class_init = cpu_class_init,
};
static const TypeInfo x86_cpu_type_info = {
.name = TYPE_X86_CPU,
.parent = TYPE_CPU,
.instance_size = sizeof(X86CPU),
.instance_init = x86_cpu_initfn,
.abstract = true,
.class_size = sizeof(X86CPUClass),
.class_init = x86_cpu_common_class_init,
};
CPU这种类的定义是有多层继承关系的。TYPE_X86_CPU的父类是TYPE_CPU,TYPE_CPU的父类是TYPE_DEVICE,TYPE_DEVICE的父类是TYPE_OBJECT,到这里就到头了。这里面每一层都有class_init,用于从TypeImpl生产xxxClass,也有instance_init将xxxClass初始化为实例。在TYPE_X86_CPU这一层的class_init中,即x86_cpu_common_class_init中,设置了DeviceClass的realize函数为x86_cpu_realizefn,这个函数很重要,马上就能用到。x86_cpu_common_class_init的实现如下所示:
static void x86_cpu_common_class_init(ObjectClass *oc, void *data)
{
X86CPUClass *xcc = X86_CPU_CLASS(oc);
CPUClass *cc = CPU_CLASS(oc);
DeviceClass *dc = DEVICE_CLASS(oc);
device_class_set_parent_realize(dc, x86_cpu_realizefn,
&xcc->parent_realize);
......
}
在TYPE_DEVICE这一层的instance_init函数device_initfn,会为这个设备添加一个属性"realized",要设置这个属性,需要用函数device_set_realized,如下所示:
static void device_initfn(Object *obj)
{
DeviceState *dev = DEVICE(obj);
ObjectClass *class;
Property *prop;
dev->realized = false;
object_property_add_bool(obj, "realized",
device_get_realized, device_set_realized, NULL);
......
}
10. 回到pc_new_cpu函数,这里面就是通过object_property_set_bool设置这个属性为true,所以device_set_realized函数会被调用。在device_set_realized中,DeviceClass的realize函数x86_cpu_realizefn会被调用。这里面qemu_init_vcpu会调用qemu_kvm_start_vcpu,如下所示:
static void qemu_kvm_start_vcpu(CPUState *cpu)
{
char thread_name[VCPU_THREAD_NAME_SIZE];
cpu->thread = g_malloc0(sizeof(QemuThread));
cpu->halt_cond = g_malloc0(sizeof(QemuCond));
qemu_cond_init(cpu->halt_cond);
qemu_thread_create(cpu->thread, thread_name, qemu_kvm_cpu_thread_fn, cpu, QEMU_THREAD_JOINABLE);
}
在这里面,为这个vcpu创建一个线程,即虚拟机里面的一个vcpu对应物理机上的一个线程,然后这个线程被调度到某个物理CPU上。来看这个vcpu的线程执行函数,如下所示:
static void *qemu_kvm_cpu_thread_fn(void *arg)
{
CPUState *cpu = arg;
int r;
rcu_register_thread();
qemu_mutex_lock_iothread();
qemu_thread_get_self(cpu->thread);
cpu->thread_id = qemu_get_thread_id();
cpu->can_do_io = 1;
current_cpu = cpu;
r = kvm_init_vcpu(cpu);
kvm_init_cpu_signals(cpu);
/* signal CPU creation */
cpu->created = true;
qemu_cond_signal(&qemu_cpu_cond);
do {
if (cpu_can_run(cpu)) {
r = kvm_cpu_exec(cpu);
}
qemu_wait_io_event(cpu);
} while (!cpu->unplug || cpu_can_run(cpu));
qemu_kvm_destroy_vcpu(cpu);
cpu->created = false;
qemu_cond_signal(&qemu_cpu_cond);
qemu_mutex_unlock_iothread();
rcu_unregister_thread();
return NULL;
}
在qemu_kvm_cpu_thread_fn中,先是kvm_init_vcpu初始化这个 vcpu,如下所示:
int kvm_init_vcpu(CPUState *cpu)
{
KVMState *s = kvm_state;
long mmap_size;
int ret;
......
ret = kvm_get_vcpu(s, kvm_arch_vcpu_id(cpu));
......
cpu->kvm_fd = ret;
cpu->kvm_state = s;
cpu->vcpu_dirty = true;
mmap_size = kvm_ioctl(s, KVM_GET_VCPU_MMAP_SIZE, 0);
......
cpu->kvm_run = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, cpu->kvm_fd, 0);
......
ret = kvm_arch_init_vcpu(cpu);
err:
return ret;
}
在kvm_get_vcpu中,会调用kvm_vm_ioctl(s, KVM_CREATE_VCPU, (void *)vcpu_id),在内核里面创建一个vcpu。在上面创建KVM_CREATE_VM的时候,已经创建了一个struct file,它的file_operations被设置为kvm_vm_fops,这个内核文件也是可以响应ioctl的。如果切换到内核KVM,在kvm_vm_ioctl函数中,有对于KVM_CREATE_VCPU的处理,调用的是kvm_vm_ioctl_create_vcpu,如下所示:
static long kvm_vm_ioctl(struct file *filp,
unsigned int ioctl, unsigned long arg)
{
struct kvm *kvm = filp->private_data;
void __user *argp = (void __user *)arg;
int r;
switch (ioctl) {
case KVM_CREATE_VCPU:
r = kvm_vm_ioctl_create_vcpu(kvm, arg);
break;
case KVM_SET_USER_MEMORY_REGION: {
struct kvm_userspace_memory_region kvm_userspace_mem;
if (copy_from_user(&kvm_userspace_mem, argp,
sizeof(kvm_userspace_mem)))
goto out;
r = kvm_vm_ioctl_set_memory_region(kvm, &kvm_userspace_mem);
break;
}
......
case KVM_CREATE_DEVICE: {
struct kvm_create_device cd;
if (copy_from_user(&cd, argp, sizeof(cd)))
goto out;
r = kvm_ioctl_create_device(kvm, &cd);
if (copy_to_user(argp, &cd, sizeof(cd)))
goto out;
break;
}
case KVM_CHECK_EXTENSION:
r = kvm_vm_ioctl_check_extension_generic(kvm, arg);
break;
default:
r = kvm_arch_vm_ioctl(filp, ioctl, arg);
}
out:
return r;
}
在kvm_vm_ioctl_create_vcpu中,kvm_arch_vcpu_create调用kvm_x86_ops的vcpu_create函数来创建CPU,如下所示:
static int kvm_vm_ioctl_create_vcpu(struct kvm *kvm, u32 id)
{
int r;
struct kvm_vcpu *vcpu;
kvm->created_vcpus++;
......
vcpu = kvm_arch_vcpu_create(kvm, id);
preempt_notifier_init(&vcpu->preempt_notifier, &kvm_preempt_ops);
r = kvm_arch_vcpu_setup(vcpu);
......
/* Now it's all set up, let userspace reach it */
kvm_get_kvm(kvm);
r = create_vcpu_fd(vcpu);
kvm->vcpus[atomic_read(&kvm->online_vcpus)] = vcpu;
......
}
struct kvm_vcpu *kvm_arch_vcpu_create(struct kvm *kvm,
unsigned int id)
{
struct kvm_vcpu *vcpu;
vcpu = kvm_x86_ops->vcpu_create(kvm, id);
return vcpu;
}
static int create_vcpu_fd(struct kvm_vcpu *vcpu)
{
return anon_inode_getfd("kvm-vcpu", &kvm_vcpu_fops, vcpu, O_RDWR | O_CLOEXEC);
}
然后,create_vcpu_fd又创建了一个struct file,它的file_operations指向kvm_vcpu_fops。从这里可以看出,KVM的内核模块是一个文件,可以通过ioctl进行操作。基于这个内核模块创建的VM也是一个文件,也可以通过ioctl进行操作。在这个VM上创建的vcpu同样是一个文件,同样可以通过ioctl进行操作。
11. 回过头来看kvm_x86_ops的vcpu_create函数。kvm_x86_ops对于不同的硬件加速虚拟化指向不同的结构,如果是vmx则指向vmx_x86_ops;如果是svm则指向svm_x86_ops。这里看英特尔的vmx_x86_ops,这个结构很长,里面有非常多的操作,用到一个看一个:
static struct kvm_x86_ops vmx_x86_ops __ro_after_init = {
......
.vcpu_create = vmx_create_vcpu,
......
}
static struct kvm_vcpu *vmx_create_vcpu(struct kvm *kvm, unsigned int id)
{
int err;
struct vcpu_vmx *vmx = kmem_cache_zalloc(kvm_vcpu_cache, GFP_KERNEL);
int cpu;
vmx->vpid = allocate_vpid();
err = kvm_vcpu_init(&vmx->vcpu, kvm, id);
vmx->guest_msrs = kmalloc(PAGE_SIZE, GFP_KERNEL);
vmx->loaded_vmcs = &vmx->vmcs01;
vmx->loaded_vmcs->vmcs = alloc_vmcs();
vmx->loaded_vmcs->shadow_vmcs = NULL;
loaded_vmcs_init(vmx->loaded_vmcs);
cpu = get_cpu();
vmx_vcpu_load(&vmx->vcpu, cpu);
vmx->vcpu.cpu = cpu;
err = vmx_vcpu_setup(vmx);
vmx_vcpu_put(&vmx->vcpu);
put_cpu();
if (enable_ept) {
if (!kvm->arch.ept_identity_map_addr)
kvm->arch.ept_identity_map_addr =
VMX_EPT_IDENTITY_PAGETABLE_ADDR;
err = init_rmode_identity_map(kvm);
}
return &vmx->vcpu;
}
vmx_create_vcpu创建用于表示vcpu的结构struct vcpu_vmx,并填写里面的内容。例如guest_msrs,在讲系统调用的时候提过msr寄存器,虚拟机也需要有这样的寄存器。enable_ept是和内存虚拟化相关的,EPT全称Extended Page Table,顾名思义是优化内存虚拟化的,这个功能放到内存虚拟化部分再讲。
最重要的就是loaded_vmcs了。VMCS的全称是Virtual Machine Control Structure,它是来干什么呢?前面讲进程调度的时候讲过,为了支持进程在CPU上的切换,CPU硬件要求有一个TSS结构,用于保存进程运行时的所有寄存器的状态,进程切换的时候需要根据TSS恢复寄存器。
虚拟机也是一个进程也需要切换,而且切换更加的复杂,可能是两个虚拟机之间切换,也可能是虚拟机切换给内核,虚拟机因为里面还有另一个操作系统,要保存的信息比普通的进程多得多,那就需要有一个结构来保存虚拟机运行的上下文,VMCS就是是Intel实现CPU虚拟化,记录vCPU状态的一个关键数据结构。
12. VMCS数据结构主要包含以下信息:
(1)Guest-state area,即vCPU的状态信息,包括vCPU的基本运行环境,例如寄存器等。
(2)Host-state area,是物理CPU的状态信息。物理CPU和vCPU之间也会来回切换,所以VMCS中既要记录vCPU的状态,也要记录物理CPU的状态。
(3)VM-execution control fields,对vCPU的运行行为进行控制。例如发生中断怎么办,是否使用EPT(Extended Page Table)功能等。
接下来对于VMCS,有两个重要的操作:
(1)VM-Entry,称为从根模式切换到非根模式,即切换到guest上,这个时候CPU上运行的是虚拟机。
(2)VM-Exit称为CPU从非根模式切换到根模式,即从guest切换到宿主机。例如当要执行一些虚拟机没有权限的敏感指令时。
为了维护这两个动作,VMCS里面还有几项内容:
(1)VM-exit control fields,对VM Exit的行为进行控制,比如VM Exit的时候,对vCPU来说需要保存哪些MSR寄存器,对于主机CPU来说需要恢复哪些MSR寄存器。
(2)VM-entry control fields,对VM Entry的行为进行控制,比如需要保存和恢复哪些MSR寄存器等。
(3)VM-exit information fields,记录下VM Exit发生的原因及一些必要的信息,方便对VM Exit事件进行处理。
至此,内核准备完毕。
13. 再回到qemu的kvm_init_vcpu函数,这里面除了创建内核中的vcpu结构之外,还通过mmap将内核的vcpu结构,映射到qemu中CPUState的kvm_run中,为什么能用mmap呢,因为vcpu也是一个文件。再回到这个vcpu的线程函数qemu_kvm_cpu_thread_fn,它在执行kvm_init_vcpu创建vcpu之后,接下来是一个do-while循环即一直运行,并且通过调用kvm_cpu_exec运行这个虚拟机,如下所示:
int kvm_cpu_exec(CPUState *cpu)
{
struct kvm_run *run = cpu->kvm_run;
int ret, run_ret;
......
do {
......
run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);
......
switch (run->exit_reason) {
case KVM_EXIT_IO:
kvm_handle_io(run->io.port, attrs,
(uint8_t *)run + run->io.data_offset,
run->io.direction,
run->io.size,
run->io.count);
break;
case KVM_EXIT_IRQ_WINDOW_OPEN:
ret = EXCP_INTERRUPT;
break;
case KVM_EXIT_SHUTDOWN:
qemu_system_reset_request(SHUTDOWN_CAUSE_GUEST_RESET);
ret = EXCP_INTERRUPT;
break;
case KVM_EXIT_UNKNOWN:
fprintf(stderr, "KVM: unknown exit, hardware reason %" PRIx64 "\n",(uint64_t)run->hw.hardware_exit_reason);
ret = -1;
break;
case KVM_EXIT_INTERNAL_ERROR:
ret = kvm_handle_internal_error(cpu, run);
break;
......
}
} while (ret == 0);
......
return ret;
}
在kvm_cpu_exec中能看到一个循环,在循环中kvm_vcpu_ioctl(KVM_RUN)运行这个虚拟机,这个时候CPU进入VM-Entry即进入客户机模式。如果一直是客户机的操作系统占用这个CPU,则会一直停留在这一行运行,一旦这个调用返回了,就说明CPU进入VM-Exit退出客户机模式,将CPU交还给宿主机。在循环中会对退出的原因exit_reason进行分析处理,是因为有了I/O、还是有了中断等,并做相应的处理。处理完毕之后再次循环,再次通过VM-Entry进入客户机模式,如此循环直到虚拟机正常或者异常退出。
来看kvm_vcpu_ioctl(KVM_RUN)在内核做了哪些事情。上面也讲了vcpu在内核也是一个文件,也是通过ioctl进行用户态和内核态通信的,在内核中调用的是kvm_vcpu_ioctl,如下所示:
static long kvm_vcpu_ioctl(struct file *filp,
unsigned int ioctl, unsigned long arg)
{
struct kvm_vcpu *vcpu = filp->private_data;
void __user *argp = (void __user *)arg;
int r;
struct kvm_fpu *fpu = NULL;
struct kvm_sregs *kvm_sregs = NULL;
......
r = vcpu_load(vcpu);
switch (ioctl) {
case KVM_RUN: {
struct pid *oldpid;
r = kvm_arch_vcpu_ioctl_run(vcpu, vcpu->run);
break;
}
case KVM_GET_REGS: {
struct kvm_regs *kvm_regs;
kvm_regs = kzalloc(sizeof(struct kvm_regs), GFP_KERNEL);
r = kvm_arch_vcpu_ioctl_get_regs(vcpu, kvm_regs);
if (copy_to_user(argp, kvm_regs, sizeof(struct kvm_regs)))
goto out_free1;
break;
}
case KVM_SET_REGS: {
struct kvm_regs *kvm_regs;
kvm_regs = memdup_user(argp, sizeof(*kvm_regs));
r = kvm_arch_vcpu_ioctl_set_regs(vcpu, kvm_regs);
break;
}
......
}
kvm_arch_vcpu_ioctl_run会调用vcpu_run,这里面也是一个无限循环,如下所示:
static int vcpu_run(struct kvm_vcpu *vcpu)
{
int r;
struct kvm *kvm = vcpu->kvm;
for (;;) {
if (kvm_vcpu_running(vcpu)) {
r = vcpu_enter_guest(vcpu);
} else {
r = vcpu_block(kvm, vcpu);
}
....
if (signal_pending(current)) {
r = -EINTR;
vcpu->run->exit_reason = KVM_EXIT_INTR;
++vcpu->stat.signal_exits;
break;
}
if (need_resched()) {
cond_resched();
}
}
......
return r;
}
在这个循环中,除了调用vcpu_enter_guest进入客户机模式运行之外,还有对于信号的响应signal_pending,即一台虚拟机是可以被kill掉的,还有对于调度的响应,这台虚拟机可以被从当前的物理CPU上赶下来,换成别的虚拟机或者其他进程。这里重点看vcpu_enter_guest,如下所示:
static int vcpu_enter_guest(struct kvm_vcpu *vcpu)
{
r = kvm_mmu_reload(vcpu);
vcpu->mode = IN_GUEST_MODE;
kvm_load_guest_xcr0(vcpu);
......
guest_enter_irqoff();
kvm_x86_ops->run(vcpu);
vcpu->mode = OUTSIDE_GUEST_MODE;
......
kvm_put_guest_xcr0(vcpu);
kvm_x86_ops->handle_external_intr(vcpu);
++vcpu->stat.exits;
guest_exit_irqoff();
r = kvm_x86_ops->handle_exit(vcpu);
return r;
......
}
static struct kvm_x86_ops vmx_x86_ops __ro_after_init = {
......
.run = vmx_vcpu_run,
......
}
在vcpu_enter_guest中,会调用vmx_x86_ops的vmx_vcpu_run函数,进入客户机模式,如下所示:
static void __noclone vmx_vcpu_run(struct kvm_vcpu *vcpu)
{
struct vcpu_vmx *vmx = to_vmx(vcpu);
unsigned long debugctlmsr, cr3, cr4;
......
cr3 = __get_current_cr3_fast();
......
cr4 = cr4_read_shadow();
......
vmx->__launched = vmx->loaded_vmcs->launched;
asm(
/* Store host registers */
"push %%" _ASM_DX "; push %%" _ASM_BP ";"
"push %%" _ASM_CX " \n\t" /* placeholder for guest rcx */
"push %%" _ASM_CX " \n\t"
......
/* Load guest registers. Don't clobber flags. */
"mov %c[rax](%0), %%" _ASM_AX " \n\t"
"mov %c[rbx](%0), %%" _ASM_BX " \n\t"
"mov %c[rdx](%0), %%" _ASM_DX " \n\t"
"mov %c[rsi](%0), %%" _ASM_SI " \n\t"
"mov %c[rdi](%0), %%" _ASM_DI " \n\t"
"mov %c[rbp](%0), %%" _ASM_BP " \n\t"
#ifdef CONFIG_X86_64
"mov %c[r8](%0), %%r8 \n\t"
"mov %c[r9](%0), %%r9 \n\t"
"mov %c[r10](%0), %%r10 \n\t"
"mov %c[r11](%0), %%r11 \n\t"
"mov %c[r12](%0), %%r12 \n\t"
"mov %c[r13](%0), %%r13 \n\t"
"mov %c[r14](%0), %%r14 \n\t"
"mov %c[r15](%0), %%r15 \n\t"
#endif
"mov %c[rcx](%0), %%" _ASM_CX " \n\t" /* kills %0 (ecx) */
/* Enter guest mode */
"jne 1f \n\t"
__ex(ASM_VMX_VMLAUNCH) "\n\t"
"jmp 2f \n\t"
"1: " __ex(ASM_VMX_VMRESUME) "\n\t"
"2: "
/* Save guest registers, load host registers, keep flags */
"mov %0, %c[wordsize](%%" _ASM_SP ") \n\t"
"pop %0 \n\t"
"mov %%" _ASM_AX ", %c[rax](%0) \n\t"
"mov %%" _ASM_BX ", %c[rbx](%0) \n\t"
__ASM_SIZE(pop) " %c[rcx](%0) \n\t"
"mov %%" _ASM_DX ", %c[rdx](%0) \n\t"
"mov %%" _ASM_SI ", %c[rsi](%0) \n\t"
"mov %%" _ASM_DI ", %c[rdi](%0) \n\t"
"mov %%" _ASM_BP ", %c[rbp](%0) \n\t"
#ifdef CONFIG_X86_64
"mov %%r8, %c[r8](%0) \n\t"
"mov %%r9, %c[r9](%0) \n\t"
"mov %%r10, %c[r10](%0) \n\t"
"mov %%r11, %c[r11](%0) \n\t"
"mov %%r12, %c[r12](%0) \n\t"
"mov %%r13, %c[r13](%0) \n\t"
"mov %%r14, %c[r14](%0) \n\t"
"mov %%r15, %c[r15](%0) \n\t"
#endif
"mov %%cr2, %%" _ASM_AX " \n\t"
"mov %%" _ASM_AX ", %c[cr2](%0) \n\t"
"pop %%" _ASM_BP "; pop %%" _ASM_DX " \n\t"
"setbe %c[fail](%0) \n\t"
".pushsection .rodata \n\t"
".global vmx_return \n\t"
"vmx_return: " _ASM_PTR " 2b \n\t"
......
);
......
vmx->loaded_vmcs->launched = 1;
vmx->exit_reason = vmcs_read32(VM_EXIT_REASON);
......
}
在vmx_vcpu_run中出现了汇编语言的代码,比较难看懂,但是没有关系里面有注释,可以沿着注释来看:
(1)首先是Store host registers,要从宿主机模式变为客户机模式了,所以原来宿主机运行时候的寄存器要保存下来。
(2)接下来是Load guest registers,将原来客户机运行时的寄存器加载进来。
(3)接下来是Enter guest mode,调用ASM_VMX_VMLAUNCH进入客户机模型运行,或者ASM_VMX_VMRESUME恢复客户机模型运行。
(4)如果客户机因为某种原因退出,Save guest registers, load host registers,即保存客户机运行的时候的寄存器,就加载宿主机运行的时候的寄存器。
(5)最后将exit_reason保存在vmx结构中。
至此CPU虚拟化就解析完了。
14. CPU的虚拟化过程还是很复杂的,下面的一张图总结了一下:
(1)首先要定义CPU这种类型的TypeInfo和TypeImpl、继承关系,并且声明它的类初始化函数。
(2)在qemu的main函数中调用MachineClass的init函数,这个函数既会初始化CPU,也会初始化内存。
(3)CPU初始化的时候,会调用pc_new_cpu创建一个虚拟CPU,它会调用CPU这个类的初始化函数。
(4)每一个虚拟CPU会调用qemu_thread_create创建一个线程,线程的执行函数为qemu_kvm_cpu_thread_fn。
(5)在虚拟CPU对应的线程执行函数中,先是调用kvm_vm_ioctl(KVM_CREATE_VCPU),在内核的KVM里面创建一个结构struct vcpu_vmx,表示这个虚拟CPU。在这个结构里面有一个VMCS,用于保存当前虚拟机CPU运行时的状态,用于状态切换。
(6)在虚拟CPU对应的线程执行函数中,接着调用kvm_vcpu_ioctl(KVM_RUN),在内核的KVM里面运行这个虚拟机CPU,运行的方式是保存宿主机的寄存器,加载客户机的寄存器,然后调用__ex(ASM_VMX_VMLAUNCH)或者__ex(ASM_VMX_VMRESUME)进入客户机模式运行。一旦退出客户机模式,就会保存客户机寄存器,加载宿主机寄存器进入宿主机模式运行,并且会记录退出虚拟机模式的原因,大部分的原因是等待I/O,因而宿主机调用kvm_handle_io进行处理。
三、计算虚拟化之内存
15. 解析了计算虚拟化之CPU,可以看到CPU的虚拟化是用户态的qemu和内核态的KVM共同配合完成的,它们二者通过ioctl进行通信。对于内存管理来讲,也是需要这两者配合完成的。操作系统给每个进程分配的内存都是虚拟内存,需要通过页表映射,变成物理内存进行访问。当有了虚拟机之后,情况会变得更加复杂。因为虚拟机对于物理机来讲是一个进程,但是虚拟机里面也有内核,也有虚拟机里面跑的进程。所以有了虚拟机,内存就变成了四类:
(1)虚拟机里面的虚拟内存(Guest OS Virtual Memory,GVA),这是虚拟机里面的进程看到的内存空间;
(2)虚拟机里面的物理内存(Guest OS Physical Memory,GPA),这是虚拟机里面操作系统看到的内存,它以为这是物理内存;
(3)物理机的虚拟内存(Host Virtual Memory,HVA),这是物理机上的qemu进程看到的内存空间;
(4)物理机的物理内存(Host Physical Memory,HPA),这是物理机上操作系统看到的内存。在内存映射中,如果从GVA 到GPA,到HVA,再到HPA,这样几经转手计算机的性能就会变得很差。当然,虚拟化技术成熟的今天,有了一些优化的手段。先来看内存管理的部分,由于CPU和内存是紧密结合的,因而内存虚拟化的初始化过程,和CPU虚拟化的初始化是一起完成的。上面说CPU虚拟化初始化的时候会调用kvm_init函数,这里面打开了"/dev/kvm"这个字符文件,并且通过ioctl调用到内核kvm的KVM_CREATE_VM操作,除了这些CPU相关的调用,接下来还有内存相关的,来看一下:
static int kvm_init(MachineState *ms)
{
MachineClass *mc = MACHINE_GET_CLASS(ms);
......
kvm_memory_listener_register(s, &s->memory_listener,
&address_space_memory, 0);
memory_listener_register(&kvm_io_listener,
&address_space_io);
......
}
AddressSpace address_space_io;
AddressSpace address_space_memory;
这里面有两个地址空间AddressSpace,一个是系统内存的地址空间address_space_memory,一个是用于I/O的地址空间address_space_io,这里重点看address_space_memory,如下所示:
struct AddressSpace {
/* All fields are private. */
struct rcu_head rcu;
char *name;
MemoryRegion *root;
/* Accessed via RCU. */
struct FlatView *current_map;
int ioeventfd_nb;
struct MemoryRegionIoeventfd *ioeventfds;
QTAILQ_HEAD(, MemoryListener) listeners;
QTAILQ_ENTRY(AddressSpace) address_spaces_link;
};
对于一个地址空间,会有多个内存区域MemoryRegion组成树形结构,这里面root是这棵树的根。另外还有一个MemoryListener链表,当内存区域发生变化的时候需要做一些动作,使得用户态和内核态能够协同,就是由这些MemoryListener完成的。在kvm_init这个时候,还没有内存区域加入进来,root还是空的,但是可以先注册MemoryListener,这里注册的是KVMMemoryListener,如下所示:
void kvm_memory_listener_register(KVMState *s, KVMMemoryListener *kml,
AddressSpace *as, int as_id)
{
int i;
kml->slots = g_malloc0(s->nr_slots * sizeof(KVMSlot));
kml->as_id = as_id;
for (i = 0; i < s->nr_slots; i++) {
kml->slots[i].slot = i;
}
kml->listener.region_add = kvm_region_add;
kml->listener.region_del = kvm_region_del;
kml->listener.priority = 10;
memory_listener_register(&kml->listener, as);
}
在这个KVMMemoryListener中是这样配置的:当添加一个MemoryRegion时region_add会被调用,这个后面会用到。接下来在qemu启动的main函数中,会调用cpu_exec_init_all->memory_map_init,如下所示:
static void memory_map_init(void)
{
system_memory = g_malloc(sizeof(*system_memory));
memory_region_init(system_memory, NULL, "system", UINT64_MAX);
address_space_init(&address_space_memory, system_memory, "memory");
system_io = g_malloc(sizeof(*system_io));
memory_region_init_io(system_io, NULL, &unassigned_io_ops, NULL, "io",
65536);
address_space_init(&address_space_io, system_io, "I/O");
}
在这里,对于系统内存区域system_memory和用于I/O的内存区域system_io,都进行了初始化,并且关联到了相应的地址空间AddressSpace,如下所示:
void address_space_init(AddressSpace *as, MemoryRegion *root, const char *name)
{
memory_region_ref(root);
as->root = root;
as->current_map = NULL;
as->ioeventfd_nb = 0;
as->ioeventfds = NULL;
QTAILQ_INIT(&as->listeners);
QTAILQ_INSERT_TAIL(&address_spaces, as, address_spaces_link);
as->name = g_strdup(name ? name : "anonymous");
address_space_update_topology(as);
address_space_update_ioeventfds(as);
}
对于系统内存地址空间address_space_memory,需要把它里面内存区域的根root设置为system_memory。另外在这里,还调用了address_space_update_topology:
static void address_space_update_topology(AddressSpace *as)
{
MemoryRegion *physmr = memory_region_get_flatview_root(as->root);
flatviews_init();
if (!g_hash_table_lookup(flat_views, physmr)) {
generate_memory_topology(physmr);
}
address_space_set_flatview(as);
}
static void address_space_set_flatview(AddressSpace *as)
{
FlatView *old_view = address_space_to_flatview(as);
MemoryRegion *physmr = memory_region_get_flatview_root(as->root);
FlatView *new_view = g_hash_table_lookup(flat_views, physmr);
if (old_view == new_view) {
return;
}
......
if (!QTAILQ_EMPTY(&as->listeners)) {
FlatView tmpview = { .nr = 0 }, *old_view2 = old_view;
if (!old_view2) {
old_view2 = &tmpview;
}
address_space_update_topology_pass(as, old_view2, new_view, false);
address_space_update_topology_pass(as, old_view2, new_view, true);
}
/* Writes are protected by the BQL. */
atomic_rcu_set(&as->current_map, new_view);
......
}
可以看到,在AddressSpace里面除了树形结构的MemoryRegion之外,还有一个flatview结构,其实这个结构就是把这样一个树形的内存结构变成扁平的内存结构,因为树形内存结构比较容易管理,但是平的内存结构比较方便和内核里面通信,来请求物理内存。虽然操作系统内核里面也是用树形结构来表示内存区域的,但是用户态向内核申请内存的时候,会按照平的、连续的模式进行申请。这里qemu在用户态,所以要做这样一个转换。
在address_space_set_flatview中,将老的flatview和新的flatview进行比较,如果不同说明内存结构发生了变化,会调用 address_space_update_topology_pass->MEMORY_LISTENER_UPDATE_REGION->MEMORY_LISTENER_CALL,这里面调用所有的listener,但是这个逻辑这里不会执行的,这是因为这里内存处于初始化的阶段,全局的flat_views里面肯定找不到,因而generate_memory_topology第一次生成了FlatView,然后才调用了address_space_set_flatview。这里面老的flatview和新的flatview一定是一样的,但是到这里还没解析qemu有关内存的参数,所以这里添加的MemoryRegion虽然是一个根,但是是空的,是为了管理使用的,后面真的添加内存时,这个逻辑还会调用到。
16. 再回到qemu启动的main函数中。接下来的初始化过程会调用pc_init1,在这里对于CPU虚拟化会调用pc_cpus_init,这个上面已经讲过了。另外pc_init1还会调用pc_memory_init进行内存的虚拟化,这里解析这一部分,如下所示:
void pc_memory_init(PCMachineState *pcms,
MemoryRegion *system_memory,
MemoryRegion *rom_memory,
MemoryRegion **ram_memory)
{
int linux_boot, i;
MemoryRegion *ram, *option_rom_mr;
MemoryRegion *ram_below_4g, *ram_above_4g;
FWCfgState *fw_cfg;
MachineState *machine = MACHINE(pcms);
PCMachineClass *pcmc = PC_MACHINE_GET_CLASS(pcms);
......
/* Allocate RAM. We allocate it as a single memory region and use
* aliases to address portions of it, mostly for backwards compatibility with older qemus that used qemu_ram_alloc().
*/
ram = g_malloc(sizeof(*ram));
memory_region_allocate_system_memory(ram, NULL, "pc.ram",
machine->ram_size);
*ram_memory = ram;
ram_below_4g = g_malloc(sizeof(*ram_below_4g));
memory_region_init_alias(ram_below_4g, NULL, "ram-below-4g", ram,
0, pcms->below_4g_mem_size);
memory_region_add_subregion(system_memory, 0, ram_below_4g);
e820_add_entry(0, pcms->below_4g_mem_size, E820_RAM);
if (pcms->above_4g_mem_size > 0) {
ram_above_4g = g_malloc(sizeof(*ram_above_4g));
memory_region_init_alias(ram_above_4g, NULL, "ram-above-4g", ram, pcms->below_4g_mem_size, pcms->above_4g_mem_size);
memory_region_add_subregion(system_memory, 0x100000000ULL,
ram_above_4g);
e820_add_entry(0x100000000ULL, pcms->above_4g_mem_size, E820_RAM);
}
......
}
在pc_memory_init中,已经知道了虚拟机要申请的内存ram_size,于是通过memory_region_allocate_system_memory来申请内存。接下来的调用链为:memory_region_allocate_system_memory->allocate_system_memory_nonnuma->memory_region_init_ram_nomigrate->memory_region_init_ram_shared_nomigrate,如下所示:
void memory_region_init_ram_shared_nomigrate(MemoryRegion *mr,
Object *owner,
const char *name,
uint64_t size,
bool share,
Error **errp)
{
Error *err = NULL;
memory_region_init(mr, owner, name, size);
mr->ram = true;
mr->terminates = true;
mr->destructor = memory_region_destructor_ram;
mr->ram_block = qemu_ram_alloc(size, share, mr, &err);
......
}
static
RAMBlock *qemu_ram_alloc_internal(ram_addr_t size, ram_addr_t max_size, void (*resized)(const char*,uint64_t length,void *host),void *host, bool resizeable, bool share,MemoryRegion *mr, Error **errp)
{
RAMBlock *new_block;
size = HOST_PAGE_ALIGN(size);
max_size = HOST_PAGE_ALIGN(max_size);
new_block = g_malloc0(sizeof(*new_block));
new_block->mr = mr;
new_block->resized = resized;
new_block->used_length = size;
new_block->max_length = max_size;
new_block->fd = -1;
new_block->page_size = getpagesize();
new_block->host = host;
......
ram_block_add(new_block, &local_err, share);
return new_block;
}
static void ram_block_add(RAMBlock *new_block, Error **errp, bool shared)
{
RAMBlock *block;
RAMBlock *last_block = NULL;
ram_addr_t old_ram_size, new_ram_size;
Error *err = NULL;
old_ram_size = last_ram_page();
new_block->offset = find_ram_offset(new_block->max_length);
if (!new_block->host) {
new_block->host = phys_mem_alloc(new_block->max_length, &new_block->mr->align, shared);
......
}
}
......
}
这里面会调用qemu_ram_alloc创建一个RAMBlock用来表示内存块,这里面调用ram_block_add->phys_mem_alloc,phys_mem_alloc是一个函数指针指向函数qemu_anon_ram_alloc,这里面调用qemu_ram_mmap,在qemu_ram_mmap中调用mmap分配内存,如下所示:
static void *(*phys_mem_alloc)(size_t size, uint64_t *align, bool shared) = qemu_anon_ram_alloc;
void *qemu_anon_ram_alloc(size_t size, uint64_t *alignment, bool shared)
{
size_t align = QEMU_VMALLOC_ALIGN;
void *ptr = qemu_ram_mmap(-1, size, align, shared);
......
if (alignment) {
*alignment = align;
}
return ptr;
}
void *qemu_ram_mmap(int fd, size_t size, size_t align, bool shared)
{
int flags;
int guardfd;
size_t offset;
size_t pagesize;
size_t total;
void *guardptr;
void *ptr;
......
total = size + align;
guardfd = -1;
pagesize = getpagesize();
flags = MAP_PRIVATE | MAP_ANONYMOUS;
guardptr = mmap(0, total, PROT_NONE, flags, guardfd, 0);
......
flags = MAP_FIXED;
flags |= fd == -1 ? MAP_ANONYMOUS : 0;
flags |= shared ? MAP_SHARED : MAP_PRIVATE;
offset = QEMU_ALIGN_UP((uintptr_t)guardptr, align) - (uintptr_t)guardptr;
ptr = mmap(guardptr + offset, size, PROT_READ | PROT_WRITE, flags, fd, 0);
......
return ptr;
}
17. 回到pc_memory_init,通过memory_region_allocate_system_memory申请到内存以后,为了兼容过去32位版本,会分成两个MemoryRegion进行管理,一个是ram_below_4g一个是ram_above_4g。对于这两个MemoryRegion,都会初始化一个alias即别名,意思是说两个MemoryRegion其实都指向memory_region_allocate_system_memory分配的内存,只不过分成两个部分,起两个别名指向不同的区域。这两部分MemoryRegion都会调用memory_region_add_subregion,将这两部分作为子内存区域添加到system_memory这棵树上。接下来的调用链为:memory_region_add_subregion->memory_region_add_subregion_common->memory_region_update_container_subregions,如下所示:
static void memory_region_update_container_subregions(MemoryRegion *subregion)
{
MemoryRegion *mr = subregion->container;
MemoryRegion *other;
memory_region_transaction_begin();
memory_region_ref(subregion);
QTAILQ_FOREACH(other, &mr->subregions, subregions_link) {
if (subregion->priority >= other->priority) {
QTAILQ_INSERT_BEFORE(other, subregion, subregions_link);
goto done;
}
}
QTAILQ_INSERT_TAIL(&mr->subregions, subregion, subregions_link);
done:
memory_region_update_pending |= mr->enabled && subregion->enabled;
memory_region_transaction_commit();
}
在memory_region_update_container_subregions中会将子区域放到链表中,然后调用memory_region_transaction_commit,在这里面会调用address_space_set_flatview,因为内存区域变了flatview也会变。就像上面分析过的一样,listener会被调用,因为添加了一个MemoryRegion,region_add即kvm_region_add,如下所示:
static void kvm_region_add(MemoryListener *listener,
MemoryRegionSection *section)
{
KVMMemoryListener *kml = container_of(listener, KVMMemoryListener, listener);
kvm_set_phys_mem(kml, section, true);
}
static void kvm_set_phys_mem(KVMMemoryListener *kml,
MemoryRegionSection *section, bool add)
{
KVMSlot *mem;
int err;
MemoryRegion *mr = section->mr;
bool writeable = !mr->readonly && !mr->rom_device;
hwaddr start_addr, size;
void *ram;
......
size = kvm_align_section(section, &start_addr);
......
/* use aligned delta to align the ram address */
ram = memory_region_get_ram_ptr(mr) + section->offset_within_region + (start_addr - section->offset_within_address_space);
......
/* register the new slot */
mem = kvm_alloc_slot(kml);
mem->memory_size = size;
mem->start_addr = start_addr;
mem->ram = ram;
mem->flags = kvm_mem_flags(mr);
err = kvm_set_user_memory_region(kml, mem, true);
......
}
kvm_region_add调用的是kvm_set_phys_mem,这里面分配一个用于放这块内存的KVMSlot结构,就像一个内存条一样,当然这是在用户态模拟出来的内存条,放在KVMState结构里面,这个结构是上面创建虚拟机的时候创建的。接下来,kvm_set_user_memory_region就会将用户态模拟出来的内存条,和内核中的KVM模块关联起来,如下所示:
static int kvm_set_user_memory_region(KVMMemoryListener *kml, KVMSlot *slot, bool new)
{
KVMState *s = kvm_state;
struct kvm_userspace_memory_region mem;
int ret;
mem.slot = slot->slot | (kml->as_id << 16);
mem.guest_phys_addr = slot->start_addr;
mem.userspace_addr = (unsigned long)slot->ram;
mem.flags = slot->flags;
......
mem.memory_size = slot->memory_size;
ret = kvm_vm_ioctl(s, KVM_SET_USER_MEMORY_REGION, &mem);
slot->old_flags = mem.flags;
......
return ret;
}
终于在这里,又看到了可以和内核通信的kvm_vm_ioctl。来看内核收到KVM_SET_USER_MEMORY_REGION通知会做哪些事情:
static long kvm_vm_ioctl(struct file *filp,
unsigned int ioctl, unsigned long arg)
{
struct kvm *kvm = filp->private_data;
void __user *argp = (void __user *)arg;
switch (ioctl) {
case KVM_SET_USER_MEMORY_REGION: {
struct kvm_userspace_memory_region kvm_userspace_mem;
if (copy_from_user(&kvm_userspace_mem, argp,
sizeof(kvm_userspace_mem)))
goto out;
r = kvm_vm_ioctl_set_memory_region(kvm, &kvm_userspace_mem);
break;
}
......
}
接下来的调用链为:kvm_vm_ioctl_set_memory_region->kvm_set_memory_region->__kvm_set_memory_region,如下所示:
int __kvm_set_memory_region(struct kvm *kvm,
const struct kvm_userspace_memory_region *mem)
{
int r;
gfn_t base_gfn;
unsigned long npages;
struct kvm_memory_slot *slot;
struct kvm_memory_slot old, new;
struct kvm_memslots *slots = NULL, *old_memslots;
int as_id, id;
enum kvm_mr_change change;
......
as_id = mem->slot >> 16;
id = (u16)mem->slot;
slot = id_to_memslot(__kvm_memslots(kvm, as_id), id);
base_gfn = mem->guest_phys_addr >> PAGE_SHIFT;
npages = mem->memory_size >> PAGE_SHIFT;
......
new = old = *slot;
new.id = id;
new.base_gfn = base_gfn;
new.npages = npages;
new.flags = mem->flags;
......
if (change == KVM_MR_CREATE) {
new.userspace_addr = mem->userspace_addr;
if (kvm_arch_create_memslot(kvm, &new, npages))
goto out_free;
}
......
slots = kvzalloc(sizeof(struct kvm_memslots), GFP_KERNEL);
memcpy(slots, __kvm_memslots(kvm, as_id), sizeof(struct kvm_memslots));
......
r = kvm_arch_prepare_memory_region(kvm, &new, mem, change);
update_memslots(slots, &new);
old_memslots = install_new_memslots(kvm, as_id, slots);
kvm_arch_commit_memory_region(kvm, mem, &old, &new, change);
return 0;
......
}
在用户态每个KVMState有多个KVMSlot,在内核里面,同样每个 struct kvm 也有多个 struct kvm_memory_slot,两者是对应起来的,如下所示:
//用户态
struct KVMState
{
......
int nr_slots;
......
KVMMemoryListener memory_listener;
......
};
typedef struct KVMMemoryListener {
MemoryListener listener;
KVMSlot *slots;
int as_id;
} KVMMemoryListener
typedef struct KVMSlot
{
hwaddr start_addr;
ram_addr_t memory_size;
void *ram;
int slot;
int flags;
int old_flags;
} KVMSlot;
//内核态
struct kvm {
spinlock_t mmu_lock;
struct mutex slots_lock;
struct mm_struct *mm; /* userspace tied to this vm */
struct kvm_memslots __rcu *memslots[KVM_ADDRESS_SPACE_NUM];
......
}
struct kvm_memslots {
u64 generation;
struct kvm_memory_slot memslots[KVM_MEM_SLOTS_NUM];
/* The mapping table from slot id to the index in memslots[]. */
short id_to_index[KVM_MEM_SLOTS_NUM];
atomic_t lru_slot;
int used_slots;
};
struct kvm_memory_slot {
gfn_t base_gfn;//根据guest_phys_addr计算
unsigned long npages;
unsigned long *dirty_bitmap;
struct kvm_arch_memory_slot arch;
unsigned long userspace_addr;
u32 flags;
short id;
};
并且,id_to_memslot函数可以根据用户态的slot号得到内核态的slot结构。如果传进来的参数是KVM_MR_CREATE,表示要创建一个新的内存条,就会调用kvm_arch_create_memslot来创建kvm_memory_slot的成员kvm_arch_memory_slot。接下来就是创建kvm_memslots结构,填充这个结构,然后通过install_new_memslots将这个新的内存条,添加到struct kvm结构中。至此,用户态的内存结构和内核态的内存结构算是对应了起来。
18. 上面对于内存的管理,还只是停留在元数据的管理,对于内存的分配与映射还没有涉及,接下来就来看,页是如何进行分配和映射的。上面说了,内存映射对于虚拟机来讲是一件非常麻烦的事情,从GVA到GPA到HVA到HPA性能很差,为了解决这个问题有两种主要的思路:
(1)第一种方式就是软件的方式,影子页表(Shadow Page Table)。内存映射要通过页表来管理,页表地址应该放在cr3寄存器里面。本来的过程是客户机要通过cr3找到客户机的页表,实现从GVA到GPA的转换,然后在宿主机上要通过cr3找到宿主机的页表,实现从HVA到HPA的转换。
为了实现客户机虚拟地址空间到宿主机物理地址空间的直接映射,客户机中每个进程都有自己的虚拟地址空间,所以KVM需要为客户机中的每个进程页表都要维护一套相应的影子页表。在客户机访问内存时,使用的不是客户机原来的页表,而是这个页表对应的影子页表,从而实现了从客户机虚拟地址到宿主机物理地址的直接转换。
而且,在TLB和CPU缓存上缓存的是来自影子页表中客户机虚拟地址和宿主机物理地址之间的映射,也因此提高了缓存的效率。但是影子页表的引入也意味着KVM需要为每个客户机的每个进程的页表都要维护一套相应的影子页表,内存占用比较大,而且客户机页表和和影子页表也需要进行实时同步。
(2)第二种方式就是硬件的方式,Intel的 EPT(Extent Page Table,扩展页表)技术。EPT在原有客户机页表对客户机虚拟地址到客户机物理地址映射的基础上,又引入了EPT页表来实现客户机物理地址到宿主机物理地址的另一次映射。客户机运行时客户机页表被载入CR3,而EPT页表被载入专门的EPT页表指针寄存器EPTP。
有了EPT,在客户机物理地址到宿主机物理地址转换的过程中,缺页会产生EPT缺页异常,KVM首先根据引起异常的客户机物理地址,映射到对应的宿主机虚拟地址,然后为此虚拟地址分配新的物理页,最后KVM再更新EPT页表,建立起引起异常的客户机物理地址到宿主机物理地址之间的映射。
KVM只需为每个客户机维护一套EPT页表,也大大减少了内存的开销。因为使用了EPT之后,客户机里面的页表映射即从GVA到GPA的转换,还是用传统的方式,而EPT重点解决的就是从GPA到HPA的转换问题。因为要经过两次页表,所以EPT又称为 tdp(two dimentional paging),后面代码中的tdp指的就是EPT表。
EPT的页表结构也是分为四层,EPT Pointer(EPTP)指向PML4的首地址,如下图所示:
19. 管理物理页面的Page结构和内存管理部分是一样的。EPT页表也需要存放在一个页中,这些页要用kvm_mmu_page这个结构来管理。当一个虚拟机运行进入客户机模式时,它会调用 vcpu_enter_guest 函数,这里面会调用kvm_mmu_reload->kvm_mmu_load,如下所示:
int kvm_mmu_load(struct kvm_vcpu *vcpu)
{
......
r = mmu_topup_memory_caches(vcpu);
r = mmu_alloc_roots(vcpu);
kvm_mmu_sync_roots(vcpu);
/* set_cr3() should ensure TLB has been flushed */
vcpu->arch.mmu.set_cr3(vcpu, vcpu->arch.mmu.root_hpa);
......
}
static int mmu_alloc_roots(struct kvm_vcpu *vcpu)
{
if (vcpu->arch.mmu.direct_map)
return mmu_alloc_direct_roots(vcpu);
else
return mmu_alloc_shadow_roots(vcpu);
}
static int mmu_alloc_direct_roots(struct kvm_vcpu *vcpu)
{
struct kvm_mmu_page *sp;
unsigned i;
if (vcpu->arch.mmu.shadow_root_level == PT64_ROOT_LEVEL) {
spin_lock(&vcpu->kvm->mmu_lock);
make_mmu_pages_available(vcpu);
sp = kvm_mmu_get_page(vcpu, 0, 0, PT64_ROOT_LEVEL, 1, ACC_ALL);
++sp->root_count;
spin_unlock(&vcpu->kvm->mmu_lock);
vcpu->arch.mmu.root_hpa = __pa(sp->spt);
}
......
}
这里构建的是页表的根部即顶级页表,并且设置cr3来刷新TLB。mmu_alloc_roots会调用mmu_alloc_direct_roots,因为用的是EPT模式而非影子表。在mmu_alloc_direct_roots中,kvm_mmu_get_page会分配一个kvm_mmu_page来存放顶级页表项。接下来,当虚拟机真的要访问内存的时候,会发现有的页表没有建立,有的物理页没有分配,这都会触发缺页异常,在KVM里面会发送VM-Exit,从客户机模式转换为宿主机模式,来修复这个缺失的页表或者物理页。如下所示:
static int (*const kvm_vmx_exit_handlers[])(struct kvm_vcpu *vcpu) = {
[EXIT_REASON_EXCEPTION_NMI] = handle_exception,
[EXIT_REASON_EXTERNAL_INTERRUPT] = handle_external_interrupt,
[EXIT_REASON_IO_INSTRUCTION] = handle_io,
......
[EXIT_REASON_EPT_VIOLATION] = handle_ept_violation,
......
}
前面讲过,虚拟机退出客户机模式有很多种原因,例如接收到中断、接收到I/O等,EPT的缺页异常也是一种类型,称为EXIT_REASON_EPT_VIOLATION,对应的处理函数是handle_ept_violation,实现如下所示:
static int handle_ept_violation(struct kvm_vcpu *vcpu)
{
gpa_t gpa;
......
gpa = vmcs_read64(GUEST_PHYSICAL_ADDRESS);
......
vcpu->arch.gpa_available = true;
vcpu->arch.exit_qualification = exit_qualification;
return kvm_mmu_page_fault(vcpu, gpa, error_code, NULL, 0);
}
int kvm_mmu_page_fault(struct kvm_vcpu *vcpu, gva_t cr2, u64 error_code,
void *insn, int insn_len)
{
......
r = vcpu->arch.mmu.page_fault(vcpu, cr2, lower_32_bits(error_code),false);
......
}
在handle_ept_violation里面,从VMCS中得到没有解析成功的GPA即客户机的物理地址,然后调用kvm_mmu_page_fault,看为什么解析不成功。kvm_mmu_page_fault会调用page_fault函数,其实是tdp_page_fault 函数,tdp的意思就是EPT,如下所示:
static int tdp_page_fault(struct kvm_vcpu *vcpu, gva_t gpa, u32 error_code, bool prefault)
{
kvm_pfn_t pfn;
int r;
int level;
bool force_pt_level;
gfn_t gfn = gpa >> PAGE_SHIFT;
unsigned long mmu_seq;
int write = error_code & PFERR_WRITE_MASK;
bool map_writable;
r = mmu_topup_memory_caches(vcpu);
level = mapping_level(vcpu, gfn, &force_pt_level);
......
if (try_async_pf(vcpu, prefault, gfn, gpa, &pfn, write, &map_writable))
return 0;
if (handle_abnormal_pfn(vcpu, 0, gfn, pfn, ACC_ALL, &r))
return r;
make_mmu_pages_available(vcpu);
r = __direct_map(vcpu, write, map_writable, level, gfn, pfn, prefault);
......
}
既然没有映射就应该加上映射,tdp_page_fault就是干这个事情的。在tdp_page_fault这个函数开头,通过gpa即客户机的物理地址得到客户机的页号gfn,接下来要通过调用try_async_pf得到宿主机物理地址对应的页号,即真正的物理页的页号,然后通过__direct_map将两者关联起来。try_async_pf的实现如下所示:
static bool try_async_pf(struct kvm_vcpu *vcpu, bool prefault, gfn_t gfn, gva_t gva, kvm_pfn_t *pfn, bool write, bool *writable)
{
struct kvm_memory_slot *slot;
bool async;
slot = kvm_vcpu_gfn_to_memslot(vcpu, gfn);
async = false;
*pfn = __gfn_to_pfn_memslot(slot, gfn, false, &async, write, writable);
if (!async)
return false; /* *pfn has correct page already */
if (!prefault && kvm_can_do_async_pf(vcpu)) {
if (kvm_find_async_pf_gfn(vcpu, gfn)) {
kvm_make_request(KVM_REQ_APF_HALT, vcpu);
return true;
} else if (kvm_arch_setup_async_pf(vcpu, gva, gfn))
return true;
}
*pfn = __gfn_to_pfn_memslot(slot, gfn, false, NULL, write, writable);
return false;
}
在try_async_pf中,要想得到pfn即物理页的页号,会先通过kvm_vcpu_gfn_to_memslot,根据客户机的物理地址对应的页号找到内存条,然后调用__gfn_to_pfn_memslot根据内存条找到pfn,如下所示:
kvm_pfn_t __gfn_to_pfn_memslot(struct kvm_memory_slot *slot, gfn_t gfn,bool atomic, bool *async, bool write_fault,bool *writable)
{
unsigned long addr = __gfn_to_hva_many(slot, gfn, NULL, write_fault);
......
return hva_to_pfn(addr, atomic, async, write_fault,
writable);
}
在__gfn_to_pfn_memslot中会调用__gfn_to_hva_many,从客户机物理地址对应的页号,得到宿主机虚拟地址hva,然后从宿主机虚拟地址到宿主机物理地址,调用的是hva_to_pfn。hva_to_pfn会调用hva_to_pfn_slow,如下所示:
static int hva_to_pfn_slow(unsigned long addr, bool *async, bool write_fault,
bool *writable, kvm_pfn_t *pfn)
{
struct page *page[1];
int npages = 0;
......
if (async) {
npages = get_user_page_nowait(addr, write_fault, page);
} else {
......
npages = get_user_pages_unlocked(addr, 1, page, flags);
}
......
*pfn = page_to_pfn(page[0]);
return npages;
}
在hva_to_pfn_slow中,要先调用get_user_page_nowait得到一个物理页面,然后再调用page_to_pfn将物理页面转换成为物理页号。无论是哪一种get_user_pages_XXX,最终都会调用__get_user_pages 函数,这里面会调用faultin_page,在faultin_page中会调用handle_mm_fault,这就是内存管理部分讲过的缺页异常逻辑,分配一个物理内存。
至此,try_async_pf得到了物理页面,并且转换为对应的物理页号。接下来,__direct_map会关联客户机物理页号和宿主机物理页号,如下所示:
static int __direct_map(struct kvm_vcpu *vcpu, int write, int map_writable,
int level, gfn_t gfn, kvm_pfn_t pfn, bool prefault)
{
struct kvm_shadow_walk_iterator iterator;
struct kvm_mmu_page *sp;
int emulate = 0;
gfn_t pseudo_gfn;
if (!VALID_PAGE(vcpu->arch.mmu.root_hpa))
return 0;
for_each_shadow_entry(vcpu, (u64)gfn << PAGE_SHIFT, iterator) {
if (iterator.level == level) {
emulate = mmu_set_spte(vcpu, iterator.sptep, ACC_ALL,
write, level, gfn, pfn, prefault,
map_writable);
direct_pte_prefetch(vcpu, iterator.sptep);
++vcpu->stat.pf_fixed;
break;
}
drop_large_spte(vcpu, iterator.sptep);
if (!is_shadow_present_pte(*iterator.sptep)) {
u64 base_addr = iterator.addr;
base_addr &= PT64_LVL_ADDR_MASK(iterator.level);
pseudo_gfn = base_addr >> PAGE_SHIFT;
sp = kvm_mmu_get_page(vcpu, pseudo_gfn, iterator.addr,
iterator.level - 1, 1, ACC_ALL);
link_shadow_page(vcpu, iterator.sptep, sp);
}
}
return emulate;
}
__direct_map首先判断页表的根是否存在,当然存在因为刚才初始化了。接下来是for_each_shadow_entry这样一个循环,每一个循环中先是会判断需要映射的level,是否正是当前循环的这个iterator.level,如果是则说明是叶子节点,直接映射真正的物理页面pfn然后退出。接着是非叶子节点的情形,判断如果这一项指向的页表项不存在,就要建立页表项,通过kvm_mmu_get_page得到保存页表项的页面,然后将这一项指向下一级的页表页面。至此,物理内存映射就结束了。
20. 来总结一下,虚拟机的内存管理也是需要用户态的qemu和内核态的KVM共同完成。为了加速内存映射,需要借助硬件的EPT技术,如下图所示:
(1)在用户态qemu中,有一个结构AddressSpace address_space_memory来表示虚拟机的系统内存,这个内存可能包含多个内存区域struct MemoryRegion,组成树形结构,指向由mmap分配的虚拟内存。
(2)在AddressSpace结构中,有一个struct KVMMemoryListener,当有新的内存区域添加时,会被通知调用kvm_region_add来通知内核。
(3)在用户态qemu中,对于虚拟机有一个结构struct KVMState表示这个虚拟机,这个结构会指向一个数组的struct KVMSlot表示这个虚拟机的多个内存条,KVMSlot中有一个void *ram指针指向mmap分配的那块虚拟内存。
(4)kvm_region_add是通过ioctl来通知内核KVM的,会给内核KVM发送一个KVM_SET_USER_MEMORY_REGION消息,表示用户态qemu添加了一个内存区域,内核KVM也应该添加一个相应的内存区域。
(5)和用户态qemu对应的内核KVM,对于虚拟机有一个结构struct kvm表示这个虚拟机,这个结构会指向一个数组的struct kvm_memory_slot表示这个虚拟机的多个内存条,kvm_memory_slot中有起始页号、页面数目,表示这个虚拟机的物理内存空间。
(6)虚拟机的物理内存空间里面的页面当然不是一开始就映射到物理页面的,只有当虚拟机的内存被访问时,即mmap分配的虚拟内存空间被访问的时候,先查看EPT页表是否已经映射过,如果已经映射过,则经过四级页表映射就能访问到物理页面。
(7)如果没有映射过,则虚拟机会通过VM-Exit指令回到宿主机模式,通过handle_ept_violation补充页表映射。先是通过handle_mm_fault为虚拟机的物理内存空间分配真正的物理页面,然后通过__direct_map添加EPT页表映射。
四、存储虚拟化
21. 完全虚拟化是很慢的,而通过内核的KVM技术和EPT技术,加速虚拟机对于物理CPU和内存的使用,称为硬件辅助虚拟化。完全虚拟化这种每个指令都翻译的方式实在是太慢了,另外一种方式就是,虚拟机里面的操作系统不是一个通用的操作系统,它知道自己是运行在虚拟机里面的,使用的硬盘设备和网络设备都是虚拟的,应该加载特殊的驱动才能运行。这些特殊的驱动往往要通过虚拟机里面和外面配合工作的模式,来加速对于物理存储和网络设备的使用。
在虚拟化技术的早期,不同虚拟化技术会针对不同硬盘设备和网络设备实现不同的驱动,虚拟机里面的操作系统也要根据不同的虚拟化技术和物理存储与网络设备,选择加载不同的驱动。但是由于硬盘设备和网络设备太多了,驱动纷繁复杂,后来慢慢就形成了一定的标准,这就是virtio即虚拟化I/O设备。virtio负责对于虚拟机提供统一的接口。也就是说在虚拟机里面的操作系统加载的驱动,以后都统一加载virtio就可以了,如下图所示:
在虚拟机外,可以实现不同的virtio的后端,来适配不同的物理硬件设备。virtio的架构可以分为四层,如下图所示:
(1)首先,在虚拟机里面的virtio前端,针对不同类型的设备有不同的驱动程序,但是接口都是统一的。例如硬盘就是virtio_blk,网络就是virtio_net。
(2)其次,在宿主机的qemu里面,实现virtio后端的逻辑,主要就是操作硬件的设备。例如通过写一个物理机硬盘上的文件来完成虚拟机写入硬盘的操作,再比如向内核协议栈发送一个网络包完成虚拟机对于网络的操作。
(3)在virtio的前端和后端之间,有一个通信层里面包含virtio层和virtio-ring层。virtio这一层实现的是虚拟队列接口,算是前后端通信的桥梁,而virtio-ring则是该桥梁的具体实现。
virtio使用virtqueue进行前端和后端的高速通信,不同类型的设备队列数目不同。virtio-net使用两个队列,一个用于接收另一个用于发送;而virtio-blk仅使用一个队列。如果客户机要向宿主机发送数据,客户机会将数据的buffer添加到virtqueue中,然后通过写入寄存器通知宿主机,这样宿主机就可以从virtqueue中收到buffer里面的数据。
22. 了解了virtio的基本原理,接下来以硬盘写入为例,具体看一下存储虚拟化的过程。和CPU虚拟化时的类反射机制一样,Virtio Block Device也是一种类,它的继承关系如下:
static const TypeInfo device_type_info = {
.name = TYPE_DEVICE,
.parent = TYPE_OBJECT,
.instance_size = sizeof(DeviceState),
.instance_init = device_initfn,
.instance_post_init = device_post_init,
.instance_finalize = device_finalize,
.class_base_init = device_class_base_init,
.class_init = device_class_init,
.abstract = true,
.class_size = sizeof(DeviceClass),
};
static const TypeInfo virtio_device_info = {
.name = TYPE_VIRTIO_DEVICE,
.parent = TYPE_DEVICE,
.instance_size = sizeof(VirtIODevice),
.class_init = virtio_device_class_init,
.instance_finalize = virtio_device_instance_finalize,
.abstract = true,
.class_size = sizeof(VirtioDeviceClass),
};
static const TypeInfo virtio_blk_info = {
.name = TYPE_VIRTIO_BLK,
.parent = TYPE_VIRTIO_DEVICE,
.instance_size = sizeof(VirtIOBlock),
.instance_init = virtio_blk_instance_init,
.class_init = virtio_blk_class_init,
};
static void virtio_register_types(void)
{
type_register_static(&virtio_blk_info);
}
type_init(virtio_register_types)
Virtio Block Device这种类的定义是有多层继承关系的。TYPE_VIRTIO_BLK的父类是TYPE_VIRTIO_DEVICE,TYPE_VIRTIO_DEVICE的父类是TYPE_DEVICE,TYPE_DEVICE的父类是TYPE_OBJECT,这样就到头了。type_init用于注册这种类,这里面每一层都有class_init,用于从TypeImpl生产xxxClass。还有instance_init,可以将xxxClass初始化为实例。在TYPE_VIRTIO_BLK层的class_init函数virtio_blk_class_init中,定义了DeviceClass的realize函数为virtio_blk_device_realize,这一点在CPU虚拟化中也有类似的结构,如下所示:
static void virtio_blk_device_realize(DeviceState *dev, Error **errp)
{
VirtIODevice *vdev = VIRTIO_DEVICE(dev);
VirtIOBlock *s = VIRTIO_BLK(dev);
VirtIOBlkConf *conf = &s->conf;
......
blkconf_blocksizes(&conf->conf);
virtio_blk_set_config_size(s, s->host_features);
virtio_init(vdev, "virtio-blk", VIRTIO_ID_BLOCK, s->config_size);
s->blk = conf->conf.blk;
s->rq = NULL;
s->sector_mask = (s->conf.conf.logical_block_size / BDRV_SECTOR_SIZE) - 1;
for (i = 0; i < conf->num_queues; i++) {
virtio_add_queue(vdev, conf->queue_size, virtio_blk_handle_output);
}
virtio_blk_data_plane_create(vdev, conf, &s->dataplane, &err);
s->change = qemu_add_vm_change_state_handler(virtio_blk_dma_restart_cb, s);
blk_set_dev_ops(s->blk, &virtio_block_ops, s);
blk_set_guest_block_size(s->blk, s->conf.conf.logical_block_size);
blk_iostatus_enable(s->blk);
}
在virtio_blk_device_realize函数中,先是通过virtio_init初始化VirtIODevice结构,如下所示:
void virtio_init(VirtIODevice *vdev, const char *name,
uint16_t device_id, size_t config_size)
{
BusState *qbus = qdev_get_parent_bus(DEVICE(vdev));
VirtioBusClass *k = VIRTIO_BUS_GET_CLASS(qbus);
int i;
int nvectors = k->query_nvectors ? k->query_nvectors(qbus->parent) : 0;
if (nvectors) {
vdev->vector_queues =
g_malloc0(sizeof(*vdev->vector_queues) * nvectors);
}
vdev->device_id = device_id;
vdev->status = 0;
atomic_set(&vdev->isr, 0);
vdev->queue_sel = 0;
vdev->config_vector = VIRTIO_NO_VECTOR;
vdev->vq = g_malloc0(sizeof(VirtQueue) * VIRTIO_QUEUE_MAX);
vdev->vm_running = runstate_is_running();
vdev->broken = false;
for (i = 0; i < VIRTIO_QUEUE_MAX; i++) {
vdev->vq[i].vector = VIRTIO_NO_VECTOR;
vdev->vq[i].vdev = vdev;
vdev->vq[i].queue_index = i;
}
vdev->name = name;
vdev->config_len = config_size;
if (vdev->config_len) {
vdev->config = g_malloc0(config_size);
} else {
vdev->config = NULL;
}
vdev->vmstate = qemu_add_vm_change_state_handler(virtio_vmstate_change,
vdev);
vdev->device_endian = virtio_default_endian();
vdev->use_guest_notifier_mask = true;
}
从virtio_init中可以看出,VirtIODevice结构里面有一个VirtQueue数组,这就是virtio前端和后端互相传数据的队列,最多VIRTIO_QUEUE_MAX个。
回到virtio_blk_device_realize函数,接下来根据配置的队列数目num_queues,对于每个队列都调用virtio_add_queue来初始化队列,如下所示:
VirtQueue *virtio_add_queue(VirtIODevice *vdev, int queue_size,
VirtIOHandleOutput handle_output)
{
int i;
vdev->vq[i].vring.num = queue_size;
vdev->vq[i].vring.num_default = queue_size;
vdev->vq[i].vring.align = VIRTIO_PCI_VRING_ALIGN;
vdev->vq[i].handle_output = handle_output;
vdev->vq[i].handle_aio_output = NULL;
return &vdev->vq[i];
}
在每个VirtQueue中,都有一个vring用来维护这个队列里面的数据;另外还有一个函数virtio_blk_handle_output用于处理数据写入,这个函数后面会用到。至此,VirtIODevice、VirtQueue、vring之间的关系如下图所示,这是在qemu里面的对应关系,后面还能看到类似的结构:
23. 初始化过程解析完毕以后,接下来从qemu的启动过程看起。对于硬盘的虚拟化,qemu的启动参数里有关的是下面两行:
-drive file=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/disk,if=none,id=drive-virtio-disk0,format=qcow2,cache=none
-device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1
其中,第一行指定了宿主机硬盘上的一个文件,文件的格式是qcow2,这个格式这里不准备解析它,只要明白对于宿主机上的一个文件,可以被qemu模拟成为客户机上的一块硬盘就可以了(就像vmware的.vmdk)。而第二行说明了,使用的驱动是virtio-blk驱动,在qemu启动的main函数里面,初始化块设备是通过configure_blockdev调用开始的,如下所示:
static void configure_blockdev(BlockdevOptionsQueue *bdo_queue, MachineClass *machine_class, int snapshot)
{
......
if (qemu_opts_foreach(qemu_find_opts("drive"), drive_init_func,
&machine_class->block_default_type, &error_fatal)) {
.....
}
}
static int drive_init_func(void *opaque, QemuOpts *opts, Error **errp)
{
BlockInterfaceType *block_default_type = opaque;
return drive_new(opts, *block_default_type, errp) == NULL;
}
在configure_blockdev中,能看到对于drive这个参数的解析,并且初始化这个设备要调用drive_init_func函数,这里面会调用drive_new创建一个设备,如下所示:
DriveInfo *drive_new(QemuOpts *all_opts, BlockInterfaceType block_default_type, Error **errp)
{
const char *value;
BlockBackend *blk;
DriveInfo *dinfo = NULL;
QDict *bs_opts;
QemuOpts *legacy_opts;
DriveMediaType media = MEDIA_DISK;
BlockInterfaceType type;
int max_devs, bus_id, unit_id, index;
const char *werror, *rerror;
bool read_only = false;
bool copy_on_read;
const char *filename;
Error *local_err = NULL;
int i;
......
legacy_opts = qemu_opts_create(&qemu_legacy_drive_opts, NULL, 0,
&error_abort);
......
/* Add virtio block device */
if (type == IF_VIRTIO) {
QemuOpts *devopts;
devopts = qemu_opts_create(qemu_find_opts("device"), NULL, 0,
&error_abort);
qemu_opt_set(devopts, "driver", "virtio-blk-pci", &error_abort);
qemu_opt_set(devopts, "drive", qdict_get_str(bs_opts, "id"),
&error_abort);
}
filename = qemu_opt_get(legacy_opts, "file");
......
/* Actual block device init: Functionality shared with blockdev-add */
blk = blockdev_init(filename, bs_opts, &local_err);
......
/* Create legacy DriveInfo */
dinfo = g_malloc0(sizeof(*dinfo));
dinfo->opts = all_opts;
dinfo->type = type;
dinfo->bus = bus_id;
dinfo->unit = unit_id;
blk_set_legacy_dinfo(blk, dinfo);
switch(type) {
case IF_IDE:
case IF_SCSI:
case IF_XEN:
case IF_NONE:
dinfo->media_cd = media == MEDIA_CDROM;
break;
default:
break;
}
......
}
在drive_new里面会解析qemu的启动参数。对于virtio来讲会解析device参数,把driver设置为virtio-blk-pci;还会解析file参数,就是指向那个宿主机上的文件。接下来,drive_new会调用blockdev_init,根据参数进行初始化,最后会创建一个DriveInfo来管理这个设备,这里重点来看blockdev_init,在这里面如果file不为空,则应该调用blk_new_open打开宿主机上的硬盘文件,返回的结果是BlockBackend,对应上面讲原理时的virtio的后端。blk_new_open的实现如下所示:
BlockBackend *blk_new_open(const char *filename, const char *reference,
QDict *options, int flags, Error **errp)
{
BlockBackend *blk;
BlockDriverState *bs;
uint64_t perm = 0;
......
blk = blk_new(perm, BLK_PERM_ALL);
bs = bdrv_open(filename, reference, options, flags, errp);
blk->root = bdrv_root_attach_child(bs, "root", &child_root,
perm, BLK_PERM_ALL, blk, errp);
return blk;
}
接下来的调用链为:bdrv_open->bdrv_open_inherit->bdrv_open_common,如下所示:
static int bdrv_open_common(BlockDriverState *bs, BlockBackend *file,
QDict *options, Error **errp)
{
int ret, open_flags;
const char *filename;
const char *driver_name = NULL;
const char *node_name = NULL;
const char *discard;
QemuOpts *opts;
BlockDriver *drv;
Error *local_err = NULL;
......
drv = bdrv_find_format(driver_name);
......
ret = bdrv_open_driver(bs, drv, node_name, options, open_flags, errp);
......
}
static int bdrv_open_driver(BlockDriverState *bs, BlockDriver *drv,
const char *node_name, QDict *options,
int open_flags, Error **errp)
{
......
bs->drv = drv;
bs->read_only = !(bs->open_flags & BDRV_O_RDWR);
bs->opaque = g_malloc0(drv->instance_size);
if (drv->bdrv_open) {
ret = drv->bdrv_open(bs, options, open_flags, &local_err);
}
......
}
在bdrv_open_common中,根据硬盘文件的格式得到BlockDriver。因为虚拟机的硬盘文件格式有很多种,有qcow2、raw 、vmdk等,各有优缺点,启动虚拟机的时候可以自由选择。对于不同的格式打开的方式不一样,拿qcow2来解析,它的BlockDriver定义如下:
BlockDriver bdrv_qcow2 = {
.format_name = "qcow2",
.instance_size = sizeof(BDRVQcow2State),
.bdrv_probe = qcow2_probe,
.bdrv_open = qcow2_open,
.bdrv_close = qcow2_close,
......
.bdrv_snapshot_create = qcow2_snapshot_create,
.bdrv_snapshot_goto = qcow2_snapshot_goto,
.bdrv_snapshot_delete = qcow2_snapshot_delete,
.bdrv_snapshot_list = qcow2_snapshot_list,
.bdrv_snapshot_load_tmp = qcow2_snapshot_load_tmp,
.bdrv_measure = qcow2_measure,
.bdrv_get_info = qcow2_get_info,
.bdrv_get_specific_info = qcow2_get_specific_info,
.bdrv_save_vmstate = qcow2_save_vmstate,
.bdrv_load_vmstate = qcow2_load_vmstate,
.supports_backing = true,
.bdrv_change_backing_file = qcow2_change_backing_file,
.bdrv_refresh_limits = qcow2_refresh_limits,
......
};
根据上面的定义,对于qcow2来讲bdrv_open调用的是qcow2_open,如下所示:
static int qcow2_open(BlockDriverState *bs, QDict *options, int flags,
Error **errp)
{
BDRVQcow2State *s = bs->opaque;
QCow2OpenCo qoc = {
.bs = bs,
.options = options,
.flags = flags,
.errp = errp,
.ret = -EINPROGRESS
};
bs->file = bdrv_open_child(NULL, options, "file", bs, &child_file,
false, errp);
qemu_coroutine_enter(qemu_coroutine_create(qcow2_open_entry, &qoc));
......
}
在qcow2_open中,会通过qemu_coroutine_enter进入一个协程coroutine,协程可以简单地将它理解为用户态自己实现的线程。如果一个程序想实现并发可以创建多个线程,但是线程是一个内核的概念,创建的每一个线程内核都能看到,内核的调度也是以线程为单位的,这对于普通的进程没有什么问题,但是对于qemu这种虚拟机,如果在用户态和内核态切换来切换去,由于还涉及虚拟机的状态,代价比较大。
但是,qemu的设备也是需要多线程能力的,那就在用户态实现一个类似线程的东西,也就是协程,用于实现并发并且不被内核看到,调度全部在用户态完成。从后面的读写过程可以看出,协程在后端经常使用。这里打开一个qcow2文件就是使用一个协程,创建一个协程和创建一个线程很像,也需要指定一个函数来执行,qcow2_open_entry就是协程的函数,如下所示:
static void coroutine_fn qcow2_open_entry(void *opaque)
{
QCow2OpenCo *qoc = opaque;
BDRVQcow2State *s = qoc->bs->opaque;
qemu_co_mutex_lock(&s->lock);
qoc->ret = qcow2_do_open(qoc->bs, qoc->options, qoc->flags, qoc->errp);
qemu_co_mutex_unlock(&s->lock);
}
可以看到,qcow2_open_entry函数前面有一个coroutine_fn,说明它是一个协程函数。在qcow2_do_open中,qcow2_do_open根据qcow2的格式打开硬盘文件。
24. 来总结一下,存储虚拟化的过程分为前端、后端和中间的队列,如下图所示:
(1)前端有前端的块设备驱动Front-end driver,在客户机的内核里面,它符合普通设备驱动的格式,对外通过VFS暴露文件系统接口给客户机里面的应用,这一部分放在后面解析。
(2)后端有后端的设备驱动Back-end driver,在宿主机的qemu进程中,当收到客户机的写入请求的时候,调用文件系统的write函数,写入宿主机的VFS文件系统,最终写到物理硬盘设备上的qcow2文件。
(3)中间的队列用于前端和后端之间传输数据,在前端的设备驱动和后端的设备驱动,都有类似的数据结构virt-queue来管理这些队列,这一部分也放到后面解析。
25. 上面讲了qemu启动过程中的存储虚拟化。现在qemu启动了,硬盘设备文件已经打开了,那如果要往虚拟机的一个进程写入一个文件该怎么做呢?最终这个文件又是如何落到宿主机上的硬盘文件的呢?这里来看一看前端设备驱动virtio_blk。虚拟机里面的进程写入一个文件,当然要通过文件系统,整个过程和文件系统的过程没有区别,只是到了设备驱动层,看到的就不是普通的硬盘驱动了,而是virtio的驱动。virtio的驱动程序代码在Linux系统的源代码里面,文件名叫drivers/block/virtio_blk.c,如下所示:
static int __init init(void)
{
int error;
virtblk_wq = alloc_workqueue("virtio-blk", 0, 0);
major = register_blkdev(0, "virtblk");
error = register_virtio_driver(&virtio_blk);
......
}
module_init(init);
module_exit(fini);
MODULE_DEVICE_TABLE(virtio, id_table);
MODULE_DESCRIPTION("Virtio block driver");
MODULE_LICENSE("GPL");
static struct virtio_driver virtio_blk = {
......
.driver.name = KBUILD_MODNAME,
.driver.owner = THIS_MODULE,
.id_table = id_table,
.probe = virtblk_probe,
.remove = virtblk_remove,
......
};
以前介绍过设备驱动程序,从这里的代码中能看到非常熟悉的结构,它会创建一个workqueue注册一个块设备,并获得一个主设备号,然后注册一个驱动函数virtio_blk。当一个设备驱动作为一个内核模块被初始化的时候,probe函数会被调用,因而来看一下virtblk_probe:
static int virtblk_probe(struct virtio_device *vdev)
{
struct virtio_blk *vblk;
struct request_queue *q;
......
vdev->priv = vblk = kmalloc(sizeof(*vblk), GFP_KERNEL);
vblk->vdev = vdev;
vblk->sg_elems = sg_elems;
INIT_WORK(&vblk->config_work, virtblk_config_changed_work);
......
err = init_vq(vblk);
......
vblk->disk = alloc_disk(1 << PART_BITS);
memset(&vblk->tag_set, 0, sizeof(vblk->tag_set));
vblk->tag_set.ops = &virtio_mq_ops;
vblk->tag_set.queue_depth = virtblk_queue_depth;
vblk->tag_set.numa_node = NUMA_NO_NODE;
vblk->tag_set.flags = BLK_MQ_F_SHOULD_MERGE;
vblk->tag_set.cmd_size =
sizeof(struct virtblk_req) +
sizeof(struct scatterlist) * sg_elems;
vblk->tag_set.driver_data = vblk;
vblk->tag_set.nr_hw_queues = vblk->num_vqs;
err = blk_mq_alloc_tag_set(&vblk->tag_set);
......
q = blk_mq_init_queue(&vblk->tag_set);
vblk->disk->queue = q;
q->queuedata = vblk;
virtblk_name_format("vd", index, vblk->disk->disk_name, DISK_NAME_LEN);
vblk->disk->major = major;
vblk->disk->first_minor = index_to_minor(index);
vblk->disk->private_data = vblk;
vblk->disk->fops = &virtblk_fops;
vblk->disk->flags |= GENHD_FL_EXT_DEVT;
vblk->index = index;
......
device_add_disk(&vdev->dev, vblk->disk);
err = device_create_file(disk_to_dev(vblk->disk), &dev_attr_serial);
......
}
在virtblk_probe中,首先看到的是struct request_queue,这是每一个块设备都有的一个队列,它有两个函数,一个是make_request_fn函数用于生成request;另一个是request_fn函数用于处理request。这个request_queue的初始化过程在blk_mq_init_queue 中,它会调用blk_mq_init_allocated_queue->blk_queue_make_request,在这里面可以将make_request_fn函数设置为blk_mq_make_request,也就是说一旦上层有写入请求,就通过blk_mq_make_request这个函数,将请求放入request_queue队列中。另外在virtblk_probe中,会初始化一个gendisk。前面也讲过每一个块设备都有这样一个结构。在virtblk_probe中,还有一件重要的事情就是init_vq会初始化virtqueue,如下所示:
static int init_vq(struct virtio_blk *vblk)
{
int err;
int i;
vq_callback_t **callbacks;
const char **names;
struct virtqueue **vqs;
unsigned short num_vqs;
struct virtio_device *vdev = vblk->vdev;
......
vblk->vqs = kmalloc_array(num_vqs, sizeof(*vblk->vqs), GFP_KERNEL);
names = kmalloc_array(num_vqs, sizeof(*names), GFP_KERNEL);
callbacks = kmalloc_array(num_vqs, sizeof(*callbacks), GFP_KERNEL);
vqs = kmalloc_array(num_vqs, sizeof(*vqs), GFP_KERNEL);
......
for (i = 0; i < num_vqs; i++) {
callbacks[i] = virtblk_done;
names[i] = vblk->vqs[i].name;
}
/* Discover virtqueues and write information to configuration. */
err = virtio_find_vqs(vdev, num_vqs, vqs, callbacks, names, &desc);
for (i = 0; i < num_vqs; i++) {
vblk->vqs[i].vq = vqs[i];
}
vblk->num_vqs = num_vqs;
......
}
按照上面的原理来说,virtqueue是一个介于客户机前端和qemu后端的一个结构,用于在这两端之间传递数据。这里建立的struct virtqueue是客户机前端对于队列管理的数据结构,在客户机的linux内核中通过kmalloc_array进行分配。而队列的实体需要通过函数virtio_find_vqs查找或者生成,所以这里还把callback函数指定为virtblk_done,当buffer使用发生变化的时候,需要调用这个callback函数进行通知,如下所示:
static inline
int virtio_find_vqs(struct virtio_device *vdev, unsigned nvqs,
struct virtqueue *vqs[], vq_callback_t *callbacks[],
const char * const names[],
struct irq_affinity *desc)
{
return vdev->config->find_vqs(vdev, nvqs, vqs, callbacks, names, NULL, desc);
}
static const struct virtio_config_ops virtio_pci_config_ops = {
.get = vp_get,
.set = vp_set,
.generation = vp_generation,
.get_status = vp_get_status,
.set_status = vp_set_status,
.reset = vp_reset,
.find_vqs = vp_modern_find_vqs,
.del_vqs = vp_del_vqs,
.get_features = vp_get_features,
.finalize_features = vp_finalize_features,
.bus_name = vp_bus_name,
.set_vq_affinity = vp_set_vq_affinity,
.get_vq_affinity = vp_get_vq_affinity,
};
根据virtio_config_ops的定义,virtio_find_vqs会调用vp_modern_find_vqs,如下所示:
static int vp_modern_find_vqs(struct virtio_device *vdev, unsigned nvqs,
struct virtqueue *vqs[],
vq_callback_t *callbacks[],
const char * const names[], const bool *ctx,
struct irq_affinity *desc)
{
struct virtio_pci_device *vp_dev = to_vp_device(vdev);
struct virtqueue *vq;
int rc = vp_find_vqs(vdev, nvqs, vqs, callbacks, names, ctx, desc);
/* Select and activate all queues. Has to be done last: once we do
* this, there's no way to go back except reset.
*/
list_for_each_entry(vq, &vdev->vqs, list) {
vp_iowrite16(vq->index, &vp_dev->common->queue_select);
vp_iowrite16(1, &vp_dev->common->queue_enable);
}
return 0;
}
在vp_modern_find_vqs中,vp_find_vqs会调用vp_find_vqs_intx,如下所示:
static int vp_find_vqs_intx(struct virtio_device *vdev, unsigned nvqs,
struct virtqueue *vqs[], vq_callback_t *callbacks[],
const char * const names[], const bool *ctx)
{
struct virtio_pci_device *vp_dev = to_vp_device(vdev);
int i, err;
vp_dev->vqs = kcalloc(nvqs, sizeof(*vp_dev->vqs), GFP_KERNEL);
err = request_irq(vp_dev->pci_dev->irq, vp_interrupt, IRQF_SHARED,
dev_name(&vdev->dev), vp_dev);
vp_dev->intx_enabled = 1;
vp_dev->per_vq_vectors = false;
for (i = 0; i < nvqs; ++i) {
vqs[i] = vp_setup_vq(vdev, i, callbacks[i], names[i],
ctx ? ctx[i] : false,
VIRTIO_MSI_NO_VECTOR);
......
}
}
在vp_find_vqs_intx中,通过request_irq注册一个中断处理函数vp_interrupt,当设备的配置信息发生改变会产生一个中断,当设备向队列中写入信息时也会产生一个中断,称为vq中断,中断处理函数需要调用相应的队列的回调函数。然后,根据队列的数目依次调用vp_setup_vq,完成virtqueue、vring的分配和初始化,如下所示:
static struct virtqueue *vp_setup_vq(struct virtio_device *vdev, unsigned index,
void (*callback)(struct virtqueue *vq),
const char *name,
bool ctx,
u16 msix_vec)
{
struct virtio_pci_device *vp_dev = to_vp_device(vdev);
struct virtio_pci_vq_info *info = kmalloc(sizeof *info, GFP_KERNEL);
struct virtqueue *vq;
unsigned long flags;
......
vq = vp_dev->setup_vq(vp_dev, info, index, callback, name, ctx,
msix_vec);
info->vq = vq;
if (callback) {
spin_lock_irqsave(&vp_dev->lock, flags);
list_add(&info->node, &vp_dev->virtqueues);
spin_unlock_irqrestore(&vp_dev->lock, flags);
} else {
INIT_LIST_HEAD(&info->node);
}
vp_dev->vqs[index] = info;
return vq;
}
static struct virtqueue *setup_vq(struct virtio_pci_device *vp_dev,
struct virtio_pci_vq_info *info,
unsigned index,
void (*callback)(struct virtqueue *vq),
const char *name,
bool ctx,
u16 msix_vec)
{
struct virtio_pci_common_cfg __iomem *cfg = vp_dev->common;
struct virtqueue *vq;
u16 num, off;
int err;
/* Select the queue we're interested in */
vp_iowrite16(index, &cfg->queue_select);
/* Check if queue is either not available or already active. */
num = vp_ioread16(&cfg->queue_size);
/* get offset of notification word for this vq */
off = vp_ioread16(&cfg->queue_notify_off);
info->msix_vector = msix_vec;
/* create the vring */
vq = vring_create_virtqueue(index, num,
SMP_CACHE_BYTES, &vp_dev->vdev,
true, true, ctx,
vp_notify, callback, name);
/* activate the queue */
vp_iowrite16(virtqueue_get_vring_size(vq), &cfg->queue_size);
vp_iowrite64_twopart(virtqueue_get_desc_addr(vq),
&cfg->queue_desc_lo, &cfg->queue_desc_hi);
vp_iowrite64_twopart(virtqueue_get_avail_addr(vq),
&cfg->queue_avail_lo, &cfg->queue_avail_hi);
vp_iowrite64_twopart(virtqueue_get_used_addr(vq),
&cfg->queue_used_lo, &cfg->queue_used_hi);
......
return vq;
}
struct virtqueue *vring_create_virtqueue(
unsigned int index,
unsigned int num,
unsigned int vring_align,
struct virtio_device *vdev,
bool weak_barriers,
bool may_reduce_num,
bool context,
bool (*notify)(struct virtqueue *),
void (*callback)(struct virtqueue *),
const char *name)
{
struct virtqueue *vq;
void *queue = NULL;
dma_addr_t dma_addr;
size_t queue_size_in_bytes;
struct vring vring;
/* TODO: allocate each queue chunk individually */
for (; num && vring_size(num, vring_align) > PAGE_SIZE; num /= 2) {
queue = vring_alloc_queue(vdev, vring_size(num, vring_align),
&dma_addr,
GFP_KERNEL|__GFP_NOWARN|__GFP_ZERO);
if (queue)
break;
}
if (!queue) {
/* Try to get a single page. You are my only hope! */
queue = vring_alloc_queue(vdev, vring_size(num, vring_align),
&dma_addr, GFP_KERNEL|__GFP_ZERO);
}
queue_size_in_bytes = vring_size(num, vring_align);
vring_init(&vring, num, queue, vring_align);
vq = __vring_new_virtqueue(index, vring, vdev, weak_barriers, context, notify, callback, name);
to_vvq(vq)->queue_dma_addr = dma_addr;
to_vvq(vq)->queue_size_in_bytes = queue_size_in_bytes;
to_vvq(vq)->we_own_ring = true;
return vq;
}
在vring_create_virtqueue中会调用vring_alloc_queue,来创建队列所需要的内存空间,然后调用vring_init初始化结构struct vring,来管理队列的内存空间,然后调用__vring_new_virtqueue来创建struct vring_virtqueue,这个结构的一开始是struct virtqueue,它也是struct virtqueue的一个扩展,紧接着后面就是struct vring,如下所示:
struct vring_virtqueue {
struct virtqueue vq;
/* Actual memory layout for this queue */
struct vring vring;
......
}
至此可以发现,虚拟机里面virtio的前端是这样的结构:struct virtio_device里面有一个struct vring_virtqueue,在struct vring_virtqueue里面有一个struct vring。
26. 还记得上面讲qemu初始化时,virtio的后端有数据结构VirtIODevice、VirtQueue和vring一模一样,前端和后端对应起来,都应该指向刚才创建的那一段内存。现在的问题是,刚才分配的内存在客户机的内核里面,如何告知qemu来访问这段内存呢?qemu模拟出来的virtio block device只是一个PCI设备,对于客户机来讲这是一个外部设备,可以通过给外部设备发送指令的方式告知外部设备,这就是代码中vp_iowrite16的作用,它会调用专门给外部设备发送指令的函数iowrite,告诉外部的PCI设备。
告知的有三个地址virtqueue_get_desc_addr、virtqueue_get_avail_addr、virtqueue_get_used_addr。从客户机角度来看,这里面的地址都是物理地址即GPA(Guest Physical Address),因为只有物理地址才是客户机和qemu程序都认可的地址,本来客户机的物理内存也是qemu模拟出来的。在qemu中,对PCI总线添加一个设备的时候,会调用virtio_pci_device_plugged,如下所示:
static void virtio_pci_device_plugged(DeviceState *d, Error **errp)
{
VirtIOPCIProxy *proxy = VIRTIO_PCI(d);
......
memory_region_init_io(&proxy->bar, OBJECT(proxy),
&virtio_pci_config_ops,
proxy, "virtio-pci", size);
......
}
static const MemoryRegionOps virtio_pci_config_ops = {
.read = virtio_pci_config_read,
.write = virtio_pci_config_write,
.impl = {
.min_access_size = 1,
.max_access_size = 4,
},
.endianness = DEVICE_LITTLE_ENDIAN,
};
在这里面,对于这个加载的设备进行I/O操作,会映射到读写某一块内存空间,对应的操作为virtio_pci_config_ops即写入这块内存空间,这就相当于对于这个PCI设备进行某种配置。对PCI设备进行配置的时候,会有这样的调用链:virtio_pci_config_write->virtio_ioport_write->virtio_queue_set_addr,设置virtio的queue的地址是一项很重要的操作,如下所示:
void virtio_queue_set_addr(VirtIODevice *vdev, int n, hwaddr addr)
{
vdev->vq[n].vring.desc = addr;
virtio_queue_update_rings(vdev, n);
}
从这里可以看出,qemu后端的VirtIODevice的VirtQueue的vring的地址,被设置成了刚才给队列分配的内存的 GPA,如下图所示:
接着来看一下这个队列的格式,如下图所示:
/* Virtio ring descriptors: 16 bytes. These can chain together via "next". */
struct vring_desc {
/* Address (guest-physical). */
__virtio64 addr;
/* Length. */
__virtio32 len;
/* The flags as indicated above. */
__virtio16 flags;
/* We chain unused descriptors via this, too */
__virtio16 next;
};
struct vring_avail {
__virtio16 flags;
__virtio16 idx;
__virtio16 ring[];
};
/* u32 is used here for ids for padding reasons. */
struct vring_used_elem {
/* Index of start of used descriptor chain. */
__virtio32 id;
/* Total length of the descriptor chain which was used (written to) */
__virtio32 len;
};
struct vring_used {
__virtio16 flags;
__virtio16 idx;
struct vring_used_elem ring[];
};
struct vring {
unsigned int num;
struct vring_desc *desc;
struct vring_avail *avail;
struct vring_used *used;
};
vring包含三个成员:vring_desc指向分配的内存块,用于存放客户机和qemu之间传输的数据。avail->ring[]是发送端维护的环形队列,指向需要接收端处理的vring_desc。used->ring[]是接收端维护的环形队列,指向自己已经处理过了的vring_desc。
26. 接下来看,真的写入一个数据时会发生什么。按照上面virtio驱动初始化时的逻辑,blk_mq_make_request会被调用,这个函数比较复杂会分成多个分支,但是最终都会调用到request_queue的virtio_mq_ops的queue_rq函数,如下所示:
struct request_queue *q = rq->q;
q->mq_ops->queue_rq(hctx, &bd);
static const struct blk_mq_ops virtio_mq_ops = {
.queue_rq = virtio_queue_rq,
.complete = virtblk_request_done,
.init_request = virtblk_init_request,
.map_queues = virtblk_map_queues,
};
根据virtio_mq_ops的定义,现在要调用virtio_queue_rq,如下所示:
static blk_status_t virtio_queue_rq(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data *bd)
{
struct virtio_blk *vblk = hctx->queue->queuedata;
struct request *req = bd->rq;
struct virtblk_req *vbr = blk_mq_rq_to_pdu(req);
......
err = virtblk_add_req(vblk->vqs[qid].vq, vbr, vbr->sg, num);
......
if (notify)
virtqueue_notify(vblk->vqs[qid].vq);
return BLK_STS_OK;
}
在virtio_queue_rq中,会将请求写入的数据,通过virtblk_add_req放入struct virtqueue。因此,接下来的调用链为:virtblk_add_req->virtqueue_add_sgs->virtqueue_add,如下所示:
static inline int virtqueue_add(struct virtqueue *_vq,
struct scatterlist *sgs[],
unsigned int total_sg,
unsigned int out_sgs,
unsigned int in_sgs,
void *data,
void *ctx,
gfp_t gfp)
{
struct vring_virtqueue *vq = to_vvq(_vq);
struct scatterlist *sg;
struct vring_desc *desc;
unsigned int i, n, avail, descs_used, uninitialized_var(prev), err_idx;
int head;
bool indirect;
......
head = vq->free_head;
indirect = false;
desc = vq->vring.desc;
i = head;
descs_used = total_sg;
for (n = 0; n < out_sgs; n++) {
for (sg = sgs[n]; sg; sg = sg_next(sg)) {
dma_addr_t addr = vring_map_one_sg(vq, sg, DMA_TO_DEVICE);
......
desc[i].flags = cpu_to_virtio16(_vq->vdev, VRING_DESC_F_NEXT);
desc[i].addr = cpu_to_virtio64(_vq->vdev, addr);
desc[i].len = cpu_to_virtio32(_vq->vdev, sg->length);
prev = i;
i = virtio16_to_cpu(_vq->vdev, desc[i].next);
}
}
/* Last one doesn't continue. */
desc[prev].flags &= cpu_to_virtio16(_vq->vdev, ~VRING_DESC_F_NEXT);
/* We're using some buffers from the free list. */
vq->vq.num_free -= descs_used;
/* Update free pointer */
vq->free_head = i;
/* Store token and indirect buffer state. */
vq->desc_state[head].data = data;
/* Put entry in available array (but don't update avail->idx until they do sync). */
avail = vq->avail_idx_shadow & (vq->vring.num - 1);
vq->vring.avail->ring[avail] = cpu_to_virtio16(_vq->vdev, head);
/* Descriptors and available array need to be set before we expose the new available array entries. */
virtio_wmb(vq->weak_barriers);
vq->avail_idx_shadow++;
vq->vring.avail->idx = cpu_to_virtio16(_vq->vdev, vq->avail_idx_shadow);
vq->num_added++;
......
return 0;
}
在virtqueue_add函数中,能看到free_head指向整个内存块空闲链表的起始位置,用head变量记住这个起始位置。接下来,i也指向这个起始位置,然后是一个for循环,将数据放到内存块里面,放的过程中next不断指向下一个空闲位置,这样空闲的内存块被不断的占用。等所有的写入都结束了,i就会指向这次存放的内存块的下一个空闲位置,然后free_head就指向i,因为前面的都填满了。
至此,从head到i之间的内存块,就是这次写入的全部数据。于是,在vring的avail变量中,在ring[]数组中分配新的一项,在avail的位置,avail的计算是avail_idx_shadow & (vq->vring.num - 1),其中avail_idx_shadow是上一次的avail的位置,这里如果超过了ring[]数组的下标,则重新跳到起始位置,就说明是一个环,这次分配的新的avail的位置就存放新写入的从head到i之间的内存块。然后是avail_idx_shadow++,这说明这一块内存可以被接收方读取了。
27. 接下来回到virtio_queue_rq,调用virtqueue_notify通知接收方,而virtqueue_notify会调用vp_notify,如下所示:
bool vp_notify(struct virtqueue *vq)
{
/* we write the queue's selector into the notification register to
* signal the other end */
iowrite16(vq->index, (void __iomem *)vq->priv);
return true;
}
然后,写入一个I/O会触发VM exit,在解析CPU虚拟化的时候看到过这个逻辑,如下所示:
int kvm_cpu_exec(CPUState *cpu)
{
struct kvm_run *run = cpu->kvm_run;
int ret, run_ret;
......
run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);
......
switch (run->exit_reason) {
case KVM_EXIT_IO:
DPRINTF("handle_io\n");
/* Called outside BQL */
kvm_handle_io(run->io.port, attrs,
(uint8_t *)run + run->io.data_offset,
run->io.direction,
run->io.size,
run->io.count);
ret = 0;
break;
}
......
}
这次写入的也是一个I/O的内存空间,同样会触发virtio_ioport_write,这次会调用virtio_queue_notify,如下所示:
void virtio_queue_notify(VirtIODevice *vdev, int n)
{
VirtQueue *vq = &vdev->vq[n];
......
if (vq->handle_aio_output) {
event_notifier_set(&vq->host_notifier);
} else if (vq->handle_output) {
vq->handle_output(vdev, vq);
}
}
virtio_queue_notify会调用VirtQueue的handle_output函数,前面已经设置过这个函数了,是virtio_blk_handle_output。接下来的调用链为:virtio_blk_handle_output->virtio_blk_handle_output_do->virtio_blk_handle_vq,如下所示:
bool virtio_blk_handle_vq(VirtIOBlock *s, VirtQueue *vq)
{
VirtIOBlockReq *req;
MultiReqBuffer mrb = {};
bool progress = false;
......
do {
virtio_queue_set_notification(vq, 0);
while ((req = virtio_blk_get_request(s, vq))) {
progress = true;
if (virtio_blk_handle_request(req, &mrb)) {
virtqueue_detach_element(req->vq, &req->elem, 0);
virtio_blk_free_request(req);
break;
}
}
virtio_queue_set_notification(vq, 1);
} while (!virtio_queue_empty(vq));
if (mrb.num_reqs) {
virtio_blk_submit_multireq(s->blk, &mrb);
}
......
return progress;
}
在virtio_blk_handle_vq中有一个while循环,在循环中调用函数virtio_blk_get_request从vq中取出请求,然后调用virtio_blk_handle_request处理从vq中取出的请求。先来看virtio_blk_get_request:
static VirtIOBlockReq *virtio_blk_get_request(VirtIOBlock *s, VirtQueue *vq)
{
VirtIOBlockReq *req = virtqueue_pop(vq, sizeof(VirtIOBlockReq));
if (req) {
virtio_blk_init_request(s, vq, req);
}
return req;
}
void *virtqueue_pop(VirtQueue *vq, size_t sz)
{
unsigned int i, head, max;
VRingMemoryRegionCaches *caches;
MemoryRegionCache *desc_cache;
int64_t len;
VirtIODevice *vdev = vq->vdev;
VirtQueueElement *elem = NULL;
unsigned out_num, in_num, elem_entries;
hwaddr addr[VIRTQUEUE_MAX_SIZE];
struct iovec iov[VIRTQUEUE_MAX_SIZE];
VRingDesc desc;
int rc;
......
/* When we start there are none of either input nor output. */
out_num = in_num = elem_entries = 0;
max = vq->vring.num;
i = head;
caches = vring_get_region_caches(vq);
desc_cache = &caches->desc;
vring_desc_read(vdev, &desc, desc_cache, i);
......
/* Collect all the descriptors */
do {
bool map_ok;
if (desc.flags & VRING_DESC_F_WRITE) {
map_ok = virtqueue_map_desc(vdev, &in_num, addr + out_num,
iov + out_num,
VIRTQUEUE_MAX_SIZE - out_num, true,
desc.addr, desc.len);
} else {
map_ok = virtqueue_map_desc(vdev, &out_num, addr, iov,
VIRTQUEUE_MAX_SIZE, false,
desc.addr, desc.len);
}
......
rc = virtqueue_read_next_desc(vdev, &desc, desc_cache, max, &i);
} while (rc == VIRTQUEUE_READ_DESC_MORE);
......
/* Now copy what we have collected and mapped */
elem = virtqueue_alloc_element(sz, out_num, in_num);
elem->index = head;
for (i = 0; i < out_num; i++) {
elem->out_addr[i] = addr[i];
elem->out_sg[i] = iov[i];
}
for (i = 0; i < in_num; i++) {
elem->in_addr[i] = addr[out_num + i];
elem->in_sg[i] = iov[out_num + i];
}
vq->inuse++;
......
return elem;
}
可以看到virtio_blk_get_request会调用virtqueue_pop。在这里面能看到对于vring的操作,即从这里面将客户机里面写入的数据读取出来,放到VirtIOBlockReq结构中。接下来,就要调用virtio_blk_handle_request处理这些数据。所以接下来的调用链为:virtio_blk_handle_request->virtio_blk_submit_multireq->submit_requests,如下所示:
static inline void submit_requests(BlockBackend *blk, MultiReqBuffer *mrb,int start, int num_reqs, int niov)
{
QEMUIOVector *qiov = &mrb->reqs[start]->qiov;
int64_t sector_num = mrb->reqs[start]->sector_num;
bool is_write = mrb->is_write;
if (num_reqs > 1) {
int i;
struct iovec *tmp_iov = qiov->iov;
int tmp_niov = qiov->niov;
qemu_iovec_init(qiov, niov);
for (i = 0; i < tmp_niov; i++) {
qemu_iovec_add(qiov, tmp_iov[i].iov_base, tmp_iov[i].iov_len);
}
for (i = start + 1; i < start + num_reqs; i++) {
qemu_iovec_concat(qiov, &mrb->reqs[i]->qiov, 0,
mrb->reqs[i]->qiov.size);
mrb->reqs[i - 1]->mr_next = mrb->reqs[i];
}
block_acct_merge_done(blk_get_stats(blk),
is_write ? BLOCK_ACCT_WRITE : BLOCK_ACCT_READ,
num_reqs - 1);
}
if (is_write) {
blk_aio_pwritev(blk, sector_num << BDRV_SECTOR_BITS, qiov, 0,
virtio_blk_rw_complete, mrb->reqs[start]);
} else {
blk_aio_preadv(blk, sector_num << BDRV_SECTOR_BITS, qiov, 0,
virtio_blk_rw_complete, mrb->reqs[start]);
}
}
在submit_requests中看到了BlockBackend,这是在qemu启动时,打开qcow2文件的时候生成的,现在可以用BlockBackend来写入文件了,调用的是blk_aio_pwritev,如下所示:
BlockAIOCB *blk_aio_pwritev(BlockBackend *blk, int64_t offset,
QEMUIOVector *qiov, BdrvRequestFlags flags,
BlockCompletionFunc *cb, void *opaque)
{
return blk_aio_prwv(blk, offset, qiov->size, qiov,
blk_aio_write_entry, flags, cb, opaque);
}
static BlockAIOCB *blk_aio_prwv(BlockBackend *blk, int64_t offset, int bytes,
void *iobuf, CoroutineEntry co_entry,
BdrvRequestFlags flags,
BlockCompletionFunc *cb, void *opaque)
{
BlkAioEmAIOCB *acb;
Coroutine *co;
acb = blk_aio_get(&blk_aio_em_aiocb_info, blk, cb, opaque);
acb->rwco = (BlkRwCo) {
.blk = blk,
.offset = offset,
.iobuf = iobuf,
.flags = flags,
.ret = NOT_DONE,
};
acb->bytes = bytes;
acb->has_returned = false;
co = qemu_coroutine_create(co_entry, acb);
bdrv_coroutine_enter(blk_bs(blk), co);
acb->has_returned = true;
return &acb->common;
}
在blk_aio_pwritev中可以看到,又是创建了一个协程来进行写入。写入完毕之后调用virtio_blk_rw_complete->virtio_blk_req_complete,如下所示:
static void virtio_blk_req_complete(VirtIOBlockReq *req, unsigned char status)
{
VirtIOBlock *s = req->dev;
VirtIODevice *vdev = VIRTIO_DEVICE(s);
trace_virtio_blk_req_complete(vdev, req, status);
stb_p(&req->in->status, status);
virtqueue_push(req->vq, &req->elem, req->in_len);
virtio_notify(vdev, req->vq);
}
在virtio_blk_req_complete中,先是调用virtqueue_push更新vring中used变量,表示这部分已经写入完毕,空间可以回收利用了,但是这部分的改变仅仅改变了qemu后端的vring,还需要通知客户机中virtio前端的vring的值,因而要调用virtio_notify。virtio_notify会调用virtio_irq发送一个中断。
28. 还记得前面注册过一个中断处理函数vp_interrupt吗?它就是干这个事情的,如下所示:
static irqreturn_t vp_interrupt(int irq, void *opaque)
{
struct virtio_pci_device *vp_dev = opaque;
u8 isr;
/* reading the ISR has the effect of also clearing it so it's very
* important to save off the value. */
isr = ioread8(vp_dev->isr);
/* Configuration change? Tell driver if it wants to know. */
if (isr & VIRTIO_PCI_ISR_CONFIG)
vp_config_changed(irq, opaque);
return vp_vring_interrupt(irq, opaque);
}
就像前面说的一样,vp_interrupt这个中断处理函数一是处理配置变化,二是处理I/O结束。第二种的调用链为:vp_interrupt->vp_vring_interrupt->vring_interrupt,如下所示:
void *virtqueue_get_buf_ctx(struct virtqueue *_vq, unsigned int *len,
void **ctx)
{
struct vring_virtqueue *vq = to_vvq(_vq);
void *ret;
unsigned int i;
u16 last_used;
......
last_used = (vq->last_used_idx & (vq->vring.num - 1));
i = virtio32_to_cpu(_vq->vdev, vq->vring.used->ring[last_used].id);
*len = virtio32_to_cpu(_vq->vdev, vq->vring.used->ring[last_used].len);
......
/* detach_buf clears data, so grab it now. */
ret = vq->desc_state[i].data;
detach_buf(vq, i, ctx);
vq->last_used_idx++;
......
return ret;
}
在virtqueue_get_buf_ctx中,可以看到virtio前端的vring中的last_used_idx加一,说明这块数据qemu后端已经消费完毕,可以通过detach_buf将其放入空闲队列中,留给以后的写入请求使用。至此,整个存储虚拟化的写入流程才全部完成。
29. 下面来总结一下存储虚拟化的场景下,整个写入的过程,如下图所示:
(1)在虚拟机里面,应用层调用write系统调用写入文件。
(2)write系统调用进入虚拟机里面的内核,经过VFS、通用块设备层、I/O调度层,到达块设备驱动。
(3)虚拟机里面的块设备驱动是virtio_blk,它和通用的块设备驱动一样有一个request queue,另外有一个函数make_request_fn会被设置为blk_mq_make_request,这个函数用于将请求放入队列。
(4)虚拟机里面的块设备驱动是virtio_blk,会注册一个中断处理函数vp_interrupt。当qemu写入完成之后,它会通知虚拟机里面的块设备驱动。
(5)blk_mq_make_request最终调用virtqueue_add,将请求添加到传输队列virtqueue中,然后调用virtqueue_notify通知qemu。
(6)在qemu中,本来虚拟机正处于KVM_RUN的状态即处于客户机状态。
(7)qemu收到通知后,通过VM exit指令退出客户机状态进入宿主机状态,根据退出原因得知有I/O需要处理。
(8)qemu调用virtio_blk_handle_output,最终调用virtio_blk_handle_vq。
(9)virtio_blk_handle_vq里面有一个循环,在循环中virtio_blk_get_request函数从传输队列中拿出请求,然后调用virtio_blk_handle_request处理请求。
(10)virtio_blk_handle_request会调用blk_aio_pwritev,通过BlockBackend驱动写入qcow2文件。
(11)写入完毕之后,virtio_blk_req_complete会调用virtio_notify通知虚拟机里面的驱动,数据写入完成,刚才注册的中断处理函数vp_interrupt会收到这个通知。
五、网络虚拟化
30. 网络虚拟化有和存储虚拟化类似的地方,例如它们都是基于virtio 的,因而在看网络虚拟化的过程中,会看到和存储虚拟化很像的数据结构和原理。但是网络虚拟化也有自己的特殊性。例如,存储虚拟化是将宿主机上的文件作为客户机上的硬盘,而网络虚拟化需要依赖于内核协议栈进行网络包的封装与解封装。那怎么实现客户机和宿主机之间的互通呢?就来看一看解析初始化的过程。还是从Virtio Network Device这个设备的初始化讲起,如下所示:
static const TypeInfo device_type_info = {
.name = TYPE_DEVICE,
.parent = TYPE_OBJECT,
.instance_size = sizeof(DeviceState),
.instance_init = device_initfn,
.instance_post_init = device_post_init,
.instance_finalize = device_finalize,
.class_base_init = device_class_base_init,
.class_init = device_class_init,
.abstract = true,
.class_size = sizeof(DeviceClass),
};
static const TypeInfo virtio_device_info = {
.name = TYPE_VIRTIO_DEVICE,
.parent = TYPE_DEVICE,
.instance_size = sizeof(VirtIODevice),
.class_init = virtio_device_class_init,
.instance_finalize = virtio_device_instance_finalize,
.abstract = true,
.class_size = sizeof(VirtioDeviceClass),
};
static const TypeInfo virtio_net_info = {
.name = TYPE_VIRTIO_NET,
.parent = TYPE_VIRTIO_DEVICE,
.instance_size = sizeof(VirtIONet),
.instance_init = virtio_net_instance_init,
.class_init = virtio_net_class_init,
};
static void virtio_register_types(void)
{
type_register_static(&virtio_net_info);
}
type_init(virtio_register_types)
Virtio Network Device这种类的定义是有多层继承关系的,TYPE_VIRTIO_NET的父类是TYPE_VIRTIO_DEVICE,TYPE_VIRTIO_DEVICE的父类是TYPE_DEVICE,TYPE_DEVICE的父类是TYPE_OBJECT,继承关系就到头了。type_init用于注册这种类,这里面每一层都有class_init,用于从TypeImpl生成xxxClass,也有instance_init,会将xxxClass初始化为实例。TYPE_VIRTIO_NET层的class_init函数是virtio_net_class_init,它定义了DeviceClass的realize函数为virtio_net_device_realize,这一点和存储块设备是一样的,如下所示:
static void virtio_net_device_realize(DeviceState *dev, Error **errp)
{
VirtIODevice *vdev = VIRTIO_DEVICE(dev);
VirtIONet *n = VIRTIO_NET(dev);
NetClientState *nc;
int i;
......
virtio_init(vdev, "virtio-net", VIRTIO_ID_NET, n->config_size);
/*
* We set a lower limit on RX queue size to what it always was.
* Guests that want a smaller ring can always resize it without
* help from us (using virtio 1 and up).
*/
if (n->net_conf.rx_queue_size < VIRTIO_NET_RX_QUEUE_MIN_SIZE ||
n->net_conf.rx_queue_size > VIRTQUEUE_MAX_SIZE ||
!is_power_of_2(n->net_conf.rx_queue_size)) {
......
return;
}
if (n->net_conf.tx_queue_size < VIRTIO_NET_TX_QUEUE_MIN_SIZE ||
n->net_conf.tx_queue_size > VIRTQUEUE_MAX_SIZE ||
!is_power_of_2(n->net_conf.tx_queue_size)) {
......
return;
}
n->max_queues = MAX(n->nic_conf.peers.queues, 1);
if (n->max_queues * 2 + 1 > VIRTIO_QUEUE_MAX) {
......
return;
}
n->vqs = g_malloc0(sizeof(VirtIONetQueue) * n->max_queues);
n->curr_queues = 1;
......
n->net_conf.tx_queue_size = MIN(virtio_net_max_tx_queue_size(n),
n->net_conf.tx_queue_size);
for (i = 0; i < n->max_queues; i++) {
virtio_net_add_queue(n, i);
}
n->ctrl_vq = virtio_add_queue(vdev, 64, virtio_net_handle_ctrl);
qemu_macaddr_default_if_unset(&n->nic_conf.macaddr);
memcpy(&n->mac[0], &n->nic_conf.macaddr, sizeof(n->mac));
n->status = VIRTIO_NET_S_LINK_UP;
if (n->netclient_type) {
n->nic = qemu_new_nic(&net_virtio_info, &n->nic_conf,
n->netclient_type, n->netclient_name, n);
} else {
n->nic = qemu_new_nic(&net_virtio_info, &n->nic_conf,
object_get_typename(OBJECT(dev)), dev->id, n);
}
......
}
这里面创建了一个VirtIODevice,这一点和存储虚拟化也是一样的。virtio_init用来初始化这个设备。VirtIODevice结构里面有一个VirtQueue数组,这就是virtio前端和后端互相传数据的队列,最多有VIRTIO_QUEUE_MAX个。
刚才说的都是一样的地方,其实也有不一样的地方。会发现这里面有这样的语句n->max_queues * 2 + 1 > VIRTIO_QUEUE_MAX。为什么要乘以2呢?这是因为对于网络设备来讲,应该分发送队列和接收队列两个方向。接下来调用virtio_net_add_queue来初始化队列,可以看出这里面就有发送tx_vq和接收rx_vq两个队列,如下所示:
typedef struct VirtIONetQueue {
VirtQueue *rx_vq;
VirtQueue *tx_vq;
QEMUTimer *tx_timer;
QEMUBH *tx_bh;
uint32_t tx_waiting;
struct {
VirtQueueElement *elem;
} async_tx;
struct VirtIONet *n;
} VirtIONetQueue;
static void virtio_net_add_queue(VirtIONet *n, int index)
{
VirtIODevice *vdev = VIRTIO_DEVICE(n);
n->vqs[index].rx_vq = virtio_add_queue(vdev, n->net_conf.rx_queue_size, virtio_net_handle_rx);
......
n->vqs[index].tx_vq = virtio_add_queue(vdev, n->net_conf.tx_queue_size, virtio_net_handle_tx_bh);
n->vqs[index].tx_bh = qemu_bh_new(virtio_net_tx_bh, &n->vqs[index]);
n->vqs[index].n = n;
}
每个VirtQueue中,都有一个vring用来维护这个队列里面的数据;另外还有函数virtio_net_handle_rx用于处理网络包的接收;函数virtio_net_handle_tx_bh用于网络包的发送,这个函数后面会用到。接下来,qemu_new_nic会创建一个虚拟机里面的网卡,如下所示:
NICState *qemu_new_nic(NetClientInfo *info,
NICConf *conf,
const char *model,
const char *name,
void *opaque)
{
NetClientState **peers = conf->peers.ncs;
NICState *nic;
int i, queues = MAX(1, conf->peers.queues);
......
nic = g_malloc0(info->size + sizeof(NetClientState) * queues);
nic->ncs = (void *)nic + info->size;
nic->conf = conf;
nic->opaque = opaque;
for (i = 0; i < queues; i++) {
qemu_net_client_setup(&nic->ncs[i], info, peers[i], model, name, NULL);
nic->ncs[i].queue_index = i;
}
return nic;
}
static void qemu_net_client_setup(NetClientState *nc,
NetClientInfo *info,
NetClientState *peer,
const char *model,
const char *name,
NetClientDestructor *destructor)
{
nc->info = info;
nc->model = g_strdup(model);
if (name) {
nc->name = g_strdup(name);
} else {
nc->name = assign_name(nc, model);
}
QTAILQ_INSERT_TAIL(&net_clients, nc, next);
nc->incoming_queue = qemu_new_net_queue(qemu_deliver_packet_iov, nc);
nc->destructor = destructor;
QTAILQ_INIT(&nc->filters);
}
31. 初始化过程解析完毕以后,接下来从qemu的启动过程看起。对于网卡的虚拟化,qemu的启动参数里面有关的是下面两行:
-netdev tap,fd=32,id=hostnet0,vhost=on,vhostfd=37
-device virtio-net-pci,netdev=hostnet0,id=net0,mac=fa:16:3e:d1:2d:99,bus=pci.0,addr=0x3
qemu的main函数会调用net_init_clients进行网络设备的初始化,可以解析net参数,也可以解析netdev参数,如下所示:
int net_init_clients(Error **errp)
{
QTAILQ_INIT(&net_clients);
if (qemu_opts_foreach(qemu_find_opts("netdev"),
net_init_netdev, NULL, errp)) {
return -1;
}
if (qemu_opts_foreach(qemu_find_opts("nic"), net_param_nic, NULL, errp)) {
return -1;
}
if (qemu_opts_foreach(qemu_find_opts("net"), net_init_client, NULL, errp)) {
return -1;
}
return 0;
}
net_init_clients会解析参数。上面的参数netdev会调用net_init_netdev->net_client_init->net_client_init1。net_client_init1会根据不同的driver类型,调用不同的初始化函数,如下所示:
static int (* const net_client_init_fun[NET_CLIENT_DRIVER__MAX])(
const Netdev *netdev,
const char *name,
NetClientState *peer, Error **errp) = {
[NET_CLIENT_DRIVER_NIC] = net_init_nic,
[NET_CLIENT_DRIVER_TAP] = net_init_tap,
[NET_CLIENT_DRIVER_SOCKET] = net_init_socket,
[NET_CLIENT_DRIVER_HUBPORT] = net_init_hubport,
......
};
由于配置的driver类型是tap,因而这里会调用net_init_tap->net_tap_init->tap_open,如下所示:
#define PATH_NET_TUN "/dev/net/tun"
int tap_open(char *ifname, int ifname_size, int *vnet_hdr,
int vnet_hdr_required, int mq_required, Error **errp)
{
struct ifreq ifr;
int fd, ret;
int len = sizeof(struct virtio_net_hdr);
unsigned int features;
TFR(fd = open(PATH_NET_TUN, O_RDWR));
memset(&ifr, 0, sizeof(ifr));
ifr.ifr_flags = IFF_TAP | IFF_NO_PI;
if (ioctl(fd, TUNGETFEATURES, &features) == -1) {
features = 0;
}
if (features & IFF_ONE_QUEUE) {
ifr.ifr_flags |= IFF_ONE_QUEUE;
}
if (*vnet_hdr) {
if (features & IFF_VNET_HDR) {
*vnet_hdr = 1;
ifr.ifr_flags |= IFF_VNET_HDR;
} else {
*vnet_hdr = 0;
}
ioctl(fd, TUNSETVNETHDRSZ, &len);
}
......
ret = ioctl(fd, TUNSETIFF, (void *) &ifr);
......
fcntl(fd, F_SETFL, O_NONBLOCK);
return fd;
}
在tap_open中打开一个文件"/dev/net/tun",然后通过ioctl操作这个文件。这是Linux内核的一项机制,和KVM机制很像,其实这就是一种通过打开这个字符设备文件,然后通过ioctl操作这个文件和内核打交道,来使用内核的这么一种能力,如下图所示:
为什么需要使用内核的机制呢?因为网络包需要从虚拟机里面发送到虚拟机外面,发送到宿主机上的时候,必须是一个正常的网络包才能被转发。要形成一个网络包,那就需要经过复杂的协议栈。客户机会将网络包发送给qemu,qemu自己没有网络协议栈,现去实现一个也不可能,太复杂了,于是它就要借助内核的力量。qemu会将客户机发送给它的网络包转换成为文件流,写入"/dev/net/tun"字符设备,就像写一个文件一样。内核中TUN/TAP字符设备驱动会收到这个写入的文件流,然后交给TUN/TAP的虚拟网卡驱动,这个驱动会将文件流再次转成网络包,交给TCP/IP栈,最终从虚拟TAP网卡tap0发出来,成为标准的网络包。后面会看到这个过程。
现在到内核里面,看一看打开"/dev/net/tun"字符设备后,内核会发生什么事情。内核的实现在drivers/net/tun.c文件中,这是一个字符设备驱动程序,应该符合字符设备的格式,如下所示:
module_init(tun_init);
module_exit(tun_cleanup);
MODULE_DESCRIPTION(DRV_DESCRIPTION);
MODULE_AUTHOR(DRV_COPYRIGHT);
MODULE_LICENSE("GPL");
MODULE_ALIAS_MISCDEV(TUN_MINOR);
MODULE_ALIAS("devname:net/tun");
static int __init tun_init(void)
{
......
ret = rtnl_link_register(&tun_link_ops);
......
ret = misc_register(&tun_miscdev);
......
ret = register_netdevice_notifier(&tun_notifier_block);
......
}
这里面注册了一个tun_miscdev字符设备,从它的定义可以看出,这就是"/dev/net/tun"字符设备,如下所示:
static struct miscdevice tun_miscdev = {
.minor = TUN_MINOR,
.name = "tun",
.nodename = "net/tun",
.fops = &tun_fops,
};
static const struct file_operations tun_fops = {
.owner = THIS_MODULE,
.llseek = no_llseek,
.read_iter = tun_chr_read_iter,
.write_iter = tun_chr_write_iter,
.poll = tun_chr_poll,
.unlocked_ioctl = tun_chr_ioctl,
.open = tun_chr_open,
.release = tun_chr_close,
.fasync = tun_chr_fasync,
};
qemu的tap_open函数会打开这个字符设备PATH_NET_TUN。打开字符设备的过程这里不再重复,总之到了驱动这一层,调用的是tun_chr_open,如下所示:
static int tun_chr_open(struct inode *inode, struct file * file)
{
struct tun_file *tfile;
tfile = (struct tun_file *)sk_alloc(net, AF_UNSPEC, GFP_KERNEL,
&tun_proto, 0);
RCU_INIT_POINTER(tfile->tun, NULL);
tfile->flags = 0;
tfile->ifindex = 0;
init_waitqueue_head(&tfile->wq.wait);
RCU_INIT_POINTER(tfile->socket.wq, &tfile->wq);
tfile->socket.file = file;
tfile->socket.ops = &tun_socket_ops;
sock_init_data(&tfile->socket, &tfile->sk);
tfile->sk.sk_write_space = tun_sock_write_space;
tfile->sk.sk_sndbuf = INT_MAX;
file->private_data = tfile;
INIT_LIST_HEAD(&tfile->next);
sock_set_flag(&tfile->sk, SOCK_ZEROCOPY);
return 0;
}
在tun_chr_open的参数里面有一个struct file,它代表的就是打开的字符设备文件"/dev/net/tun",因而往这个字符设备文件中写数据,就会通过这个struct file写入。这个struct file里面的file_operations,按照字符设备打开的规则,指向的就是tun_fops。另外还需要在tun_chr_open创建一个结构struct tun_file,并且将struct file的private_data指向它,如下所示:
/* A tun_file connects an open character device to a tuntap netdevice. It
* also contains all socket related structures
* to serve as one transmit queue for tuntap device.
*/
struct tun_file {
struct sock sk;
struct socket socket;
struct socket_wq wq;
struct tun_struct __rcu *tun;
struct fasync_struct *fasync;
/* only used for fasnyc */
unsigned int flags;
union {
u16 queue_index;
unsigned int ifindex;
};
struct list_head next;
struct tun_struct *detached;
struct skb_array tx_array;
};
struct tun_struct {
struct tun_file __rcu *tfiles[MAX_TAP_QUEUES];
unsigned int numqueues;
unsigned int flags;
kuid_t owner;
kgid_t group;
struct net_device *dev;
netdev_features_t set_features;
int align;
int vnet_hdr_sz;
int sndbuf;
struct tap_filter txflt;
struct sock_fprog fprog;
/* protected by rtnl lock */
bool filter_attached;
spinlock_t lock;
struct hlist_head flows[TUN_NUM_FLOW_ENTRIES];
struct timer_list flow_gc_timer;
unsigned long ageing_time;
unsigned int numdisabled;
struct list_head disabled;
void *security;
u32 flow_count;
u32 rx_batched;
struct tun_pcpu_stats __percpu *pcpu_stats;
};
static const struct proto_ops tun_socket_ops = {
.peek_len = tun_peek_len,
.sendmsg = tun_sendmsg,
.recvmsg = tun_recvmsg,
};
在struct tun_file中有一个成员struct tun_struct,它里面有一个struct net_device,这个用来表示宿主机上的tuntap网络设备。在struct tun_file中,还有struct socket和struct sock,因为要用到内核的网络协议栈,所以就需要这两个结构,这在以前网络协议部分已经分析过了。所以按照struct tun_file的注释所说,这是一个很重要的数据结构,"/dev/net/tun"对应的struct file的private_data指向它,因而可以接收qemu发过来的数据。除此之外,它还可以通过struct sock来操作内核协议栈,然后将网络包从宿主机上的tuntap网络设备发出去,宿主机上的tuntap网络设备对应的struct net_device也归它管。
32. 在qemu的tap_open函数中,打开这个字符设备文件之后,接下来要做的事情是,通过ioctl来设置宿主机的网卡 TUNSETIFF。接下来,ioctl到了内核里面会调用tun_chr_ioctl,如下所示:
static long __tun_chr_ioctl(struct file *file, unsigned int cmd,
unsigned long arg, int ifreq_len)
{
struct tun_file *tfile = file->private_data;
struct tun_struct *tun;
void __user* argp = (void __user*)arg;
struct ifreq ifr;
kuid_t owner;
kgid_t group;
int sndbuf;
int vnet_hdr_sz;
unsigned int ifindex;
int le;
int ret;
if (cmd == TUNSETIFF || cmd == TUNSETQUEUE || _IOC_TYPE(cmd) == SOCK_IOC_TYPE) {
if (copy_from_user(&ifr, argp, ifreq_len))
return -EFAULT;
}
......
tun = __tun_get(tfile);
if (cmd == TUNSETIFF) {
ifr.ifr_name[IFNAMSIZ-1] = '\0';
ret = tun_set_iff(sock_net(&tfile->sk), file, &ifr);
......
if (copy_to_user(argp, &ifr, ifreq_len))
ret = -EFAULT;
}
......
}
在__tun_chr_ioctl中,首先通过copy_from_user把配置从用户态拷贝到内核态,调用tun_set_iff设置tuntap网络设备,然后调用copy_to_user将配置结果返回。tun_set_iff的实现如下所示:
static int tun_set_iff(struct net *net, struct file *file, struct ifreq *ifr)
{
struct tun_struct *tun;
struct tun_file *tfile = file->private_data;
struct net_device *dev;
......
char *name;
unsigned long flags = 0;
int queues = ifr->ifr_flags & IFF_MULTI_QUEUE ?
MAX_TAP_QUEUES : 1;
if (ifr->ifr_flags & IFF_TUN) {
/* TUN device */
flags |= IFF_TUN;
name = "tun%d";
} else if (ifr->ifr_flags & IFF_TAP) {
/* TAP device */
flags |= IFF_TAP;
name = "tap%d";
} else
return -EINVAL;
if (*ifr->ifr_name)
name = ifr->ifr_name;
dev = alloc_netdev_mqs(sizeof(struct tun_struct), name,
NET_NAME_UNKNOWN, tun_setup, queues,
queues);
err = dev_get_valid_name(net, dev, name);
dev_net_set(dev, net);
dev->rtnl_link_ops = &tun_link_ops;
dev->ifindex = tfile->ifindex;
dev->sysfs_groups[0] = &tun_attr_group;
tun = netdev_priv(dev);
tun->dev = dev;
tun->flags = flags;
tun->txflt.count = 0;
tun->vnet_hdr_sz = sizeof(struct virtio_net_hdr);
tun->align = NET_SKB_PAD;
tun->filter_attached = false;
tun->sndbuf = tfile->socket.sk->sk_sndbuf;
tun->rx_batched = 0;
tun_net_init(dev);
tun_flow_init(tun);
err = tun_attach(tun, file, false);
err = register_netdevice(tun->dev);
netif_carrier_on(tun->dev);
if (netif_running(tun->dev))
netif_tx_wake_all_queues(tun->dev);
strcpy(ifr->ifr_name, tun->dev->name);
return 0;
}
tun_set_iff创建了struct tun_struct和struct net_device,并且将这个tuntap网络设备通过register_netdevice注册到内核中,这样就能在宿主机上通过ip addr看到这个网卡了,如下图所示:
至此宿主机上的内核的数据结构也完成了。
33. 下面来解析关联前端设备驱动和后端设备驱动的过程。来看在客户机中发送一个网络包的时候,会发生哪些事情。虚拟机里面的进程发送一个网络包,通过文件系统和Socket调用网络协议栈到达网络设备层,只不过这个不是普通的网络设备,而是virtio_net的驱动。virtio_net的驱动程序代码在Linux操作系统的源代码里面,文件名为drivers/net/virtio_net.c,如下所示:
static __init int virtio_net_driver_init(void)
{
ret = register_virtio_driver(&virtio_net_driver);
......
}
module_init(virtio_net_driver_init);
module_exit(virtio_net_driver_exit);
MODULE_DEVICE_TABLE(virtio, id_table);
MODULE_DESCRIPTION("Virtio network driver");
MODULE_LICENSE("GPL");
static struct virtio_driver virtio_net_driver = {
.driver.name = KBUILD_MODNAME,
.driver.owner = THIS_MODULE,
.id_table = id_table,
.validate = virtnet_validate,
.probe = virtnet_probe,
.remove = virtnet_remove,
.config_changed = virtnet_config_changed,
......
};
在virtio_net的驱动程序的初始化代码中,需要注册一个驱动函数virtio_net_driver。当一个设备驱动作为一个内核模块被初始化的时候,probe函数会被调用,因而来看一下virtnet_probe:
static int virtnet_probe(struct virtio_device *vdev)
{
int i, err;
struct net_device *dev;
struct virtnet_info *vi;
u16 max_queue_pairs;
int mtu;
/* Allocate ourselves a network device with room for our info */
dev = alloc_etherdev_mq(sizeof(struct virtnet_info), max_queue_pairs);
/* Set up network device as normal. */
dev->priv_flags |= IFF_UNICAST_FLT | IFF_LIVE_ADDR_CHANGE;
dev->netdev_ops = &virtnet_netdev;
dev->features = NETIF_F_HIGHDMA;
dev->ethtool_ops = &virtnet_ethtool_ops;
SET_NETDEV_DEV(dev, &vdev->dev);
......
/* MTU range: 68 - 65535 */
dev->min_mtu = MIN_MTU;
dev->max_mtu = MAX_MTU;
/* Set up our device-specific information */
vi = netdev_priv(dev);
vi->dev = dev;
vi->vdev = vdev;
vdev->priv = vi;
vi->stats = alloc_percpu(struct virtnet_stats);
INIT_WORK(&vi->config_work, virtnet_config_changed_work);
......
vi->max_queue_pairs = max_queue_pairs;
/* Allocate/initialize the rx/tx queues, and invoke find_vqs */
err = init_vqs(vi);
netif_set_real_num_tx_queues(dev, vi->curr_queue_pairs);
netif_set_real_num_rx_queues(dev, vi->curr_queue_pairs);
virtnet_init_settings(dev);
err = register_netdev(dev);
virtio_device_ready(vdev);
virtnet_set_queues(vi, vi->curr_queue_pairs);
......
}
在virtnet_probe中会创建struct net_device,并且通过register_netdev注册这个网络设备,这样在客户机里面就能看到这个网卡了。在virtnet_probe中,还有一件重要的事情就是,init_vqs会初始化发送和接收的virtqueue,如下所示:
static int init_vqs(struct virtnet_info *vi)
{
int ret;
/* Allocate send & receive queues */
ret = virtnet_alloc_queues(vi);
ret = virtnet_find_vqs(vi);
......
get_online_cpus();
virtnet_set_affinity(vi);
put_online_cpus();
return 0;
}
static int virtnet_alloc_queues(struct virtnet_info *vi)
{
int i;
vi->sq = kzalloc(sizeof(*vi->sq) * vi->max_queue_pairs, GFP_KERNEL);
vi->rq = kzalloc(sizeof(*vi->rq) * vi->max_queue_pairs, GFP_KERNEL);
INIT_DELAYED_WORK(&vi->refill, refill_work);
for (i = 0; i < vi->max_queue_pairs; i++) {
vi->rq[i].pages = NULL;
netif_napi_add(vi->dev, &vi->rq[i].napi, virtnet_poll,
napi_weight);
netif_tx_napi_add(vi->dev, &vi->sq[i].napi, virtnet_poll_tx,
napi_tx ? napi_weight : 0);
sg_init_table(vi->rq[i].sg, ARRAY_SIZE(vi->rq[i].sg));
ewma_pkt_len_init(&vi->rq[i].mrg_avg_pkt_len);
sg_init_table(vi->sq[i].sg, ARRAY_SIZE(vi->sq[i].sg));
}
return 0;
}
按照之前的virtio原理,virtqueue是一个介于客户机前端和qemu后端的一个结构,用于在这两端之间传递数据,对于网络设备来讲有发送和接收两个方向的队列。这里建立的struct virtqueue是客户机前端对于队列管理的数据结构。队列的实体需要通过函数virtnet_find_vqs查找或者生成,这里还会指定接收队列的callback函数为skb_recv_done,发送队列的callback函数为skb_xmit_done。当buffer使用发生变化的时候,可以调用这个callback函数进行通知,如下所示:
static int virtnet_find_vqs(struct virtnet_info *vi)
{
vq_callback_t **callbacks;
struct virtqueue **vqs;
int ret = -ENOMEM;
int i, total_vqs;
const char **names;
/* Allocate space for find_vqs parameters */
vqs = kzalloc(total_vqs * sizeof(*vqs), GFP_KERNEL);
callbacks = kmalloc(total_vqs * sizeof(*callbacks), GFP_KERNEL);
names = kmalloc(total_vqs * sizeof(*names), GFP_KERNEL);
/* Allocate/initialize parameters for send/receive virtqueues */
for (i = 0; i < vi->max_queue_pairs; i++) {
callbacks[rxq2vq(i)] = skb_recv_done;
callbacks[txq2vq(i)] = skb_xmit_done;
names[rxq2vq(i)] = vi->rq[i].name;
names[txq2vq(i)] = vi->sq[i].name;
}
ret = vi->vdev->config->find_vqs(vi->vdev, total_vqs, vqs, callbacks, names, ctx, NULL);
......
for (i = 0; i < vi->max_queue_pairs; i++) {
vi->rq[i].vq = vqs[rxq2vq(i)];
vi->rq[i].min_buf_len = mergeable_min_buf_len(vi, vi->rq[i].vq);
vi->sq[i].vq = vqs[txq2vq(i)];
}
......
}
这里的find_vqs是在struct virtnet_info里的struct virtio_device里的struct virtio_config_ops *config里面定义的。根据virtio_config_ops的定义,find_vqs会调用vp_modern_find_vqs,到这一步和块设备是一样的了。在vp_modern_find_vqs 中,vp_find_vqs会调用vp_find_vqs_intx。在vp_find_vqs_intx 中,通过request_irq注册一个中断处理函数vp_interrupt,当设备向队列中写入信息时会产生一个中断,也就是vq中断。中断处理函数需要调用相应队列的回调函数,然后根据队列的数目,依次调用vp_setup_vq完成virtqueue、vring的分配和初始化。
同样,这些数据结构会和virtio后端的VirtIODevice、VirtQueue、vring对应起来,都应该指向刚才创建的那一段内存。客户机同样会通过调用专门给外部设备发送指令的函数iowrite告诉外部的pci设备,这些共享内存的地址。至此前端设备驱动和后端设备驱动之间的两个收发队列就关联好了,这两个队列的格式和块设备是一样的。
34. 接下来看当真的发送一个网络包的时候,会发生什么。当网络包经过客户机的协议栈到达virtio_net驱动时,按照net_device_ops的定义,start_xmit会被调用,如下所示:
static const struct net_device_ops virtnet_netdev = {
.ndo_open = virtnet_open,
.ndo_stop = virtnet_close,
.ndo_start_xmit = start_xmit,
.ndo_validate_addr = eth_validate_addr,
.ndo_set_mac_address = virtnet_set_mac_address,
.ndo_set_rx_mode = virtnet_set_rx_mode,
.ndo_get_stats64 = virtnet_stats,
.ndo_vlan_rx_add_vid = virtnet_vlan_rx_add_vid,
.ndo_vlan_rx_kill_vid = virtnet_vlan_rx_kill_vid,
.ndo_xdp = virtnet_xdp,
.ndo_features_check = passthru_features_check,
};
接下来的调用链为:start_xmit->xmit_skb-> virtqueue_add_outbuf->virtqueue_add,将网络包放入队列中,并调用virtqueue_notify通知接收方,如下所示:
static netdev_tx_t start_xmit(struct sk_buff *skb, struct net_device *dev)
{
struct virtnet_info *vi = netdev_priv(dev);
int qnum = skb_get_queue_mapping(skb);
struct send_queue *sq = &vi->sq[qnum];
int err;
struct netdev_queue *txq = netdev_get_tx_queue(dev, qnum);
bool kick = !skb->xmit_more;
bool use_napi = sq->napi.weight;
......
/* Try to transmit */
err = xmit_skb(sq, skb);
......
if (kick || netif_xmit_stopped(txq))
virtqueue_kick(sq->vq);
return NETDEV_TX_OK;
}
bool virtqueue_kick(struct virtqueue *vq)
{
if (virtqueue_kick_prepare(vq))
return virtqueue_notify(vq);
return true;
}
写入一个I/O会使得qemu触发VM exit,这个逻辑在解析CPU虚拟化的时候看到过。接下来会调用VirtQueue的handle_output函数,前面已经设置过这个函数了,其实就是virtio_net_handle_tx_bh,如下所示:
static void virtio_net_handle_tx_bh(VirtIODevice *vdev, VirtQueue *vq)
{
VirtIONet *n = VIRTIO_NET(vdev);
VirtIONetQueue *q = &n->vqs[vq2q(virtio_get_queue_index(vq))];
q->tx_waiting = 1;
virtio_queue_set_notification(vq, 0);
qemu_bh_schedule(q->tx_bh);
}
virtio_net_handle_tx_bh调用了qemu_bh_schedule,而在virtio_net_add_queue中调用qemu_bh_new,并把函数设置为virtio_net_tx_bh。virtio_net_tx_bh函数调用发送函数virtio_net_flush_tx,如下所示:
static int32_t virtio_net_flush_tx(VirtIONetQueue *q)
{
VirtIONet *n = q->n;
VirtIODevice *vdev = VIRTIO_DEVICE(n);
VirtQueueElement *elem;
int32_t num_packets = 0;
int queue_index = vq2q(virtio_get_queue_index(q->tx_vq));
for (;;) {
ssize_t ret;
unsigned int out_num;
struct iovec sg[VIRTQUEUE_MAX_SIZE], sg2[VIRTQUEUE_MAX_SIZE + 1], *out_sg;
struct virtio_net_hdr_mrg_rxbuf mhdr;
elem = virtqueue_pop(q->tx_vq, sizeof(VirtQueueElement));
out_num = elem->out_num;
out_sg = elem->out_sg;
......
ret = qemu_sendv_packet_async(qemu_get_subqueue(n->nic, queue_index),out_sg, out_num, virtio_net_tx_complete);
}
......
return num_packets;
}
virtio_net_flush_tx会调用virtqueue_pop,这里面能看到对于vring的操作,即从这里面将客户机里写入的数据读取出来,然后调用qemu_sendv_packet_async发送网络包。接下来的调用链为:qemu_sendv_packet_async->qemu_net_queue_send_iov->qemu_net_queue_flush->qemu_net_queue_deliver。在qemu_net_queue_deliver中会调用NetQueue的deliver函数,前面qemu_new_net_queue会把deliver函数设置为qemu_deliver_packet_iov,它会调用nc->info->receive_iov,如下所示:
static NetClientInfo net_tap_info = {
.type = NET_CLIENT_DRIVER_TAP,
.size = sizeof(TAPState),
.receive = tap_receive,
.receive_raw = tap_receive_raw,
.receive_iov = tap_receive_iov,
.poll = tap_poll,
.cleanup = tap_cleanup,
.has_ufo = tap_has_ufo,
.has_vnet_hdr = tap_has_vnet_hdr,
.has_vnet_hdr_len = tap_has_vnet_hdr_len,
.using_vnet_hdr = tap_using_vnet_hdr,
.set_offload = tap_set_offload,
.set_vnet_hdr_len = tap_set_vnet_hdr_len,
.set_vnet_le = tap_set_vnet_le,
.set_vnet_be = tap_set_vnet_be,
};
根据net_tap_info的定义调用的是tap_receive_iov,它会调用tap_write_packet->writev写入这个字符设备。在内核的字符设备驱动中,tun_chr_write_iter会被调用,如下所示:
当使用writev()系统调用向tun/tap设备的字符设备文件写入数据时,tun_chr_write函数将被调用,它会使用tun_get_user从用户区接收数据,将数据存入skb中,然后调用关键的函数netif_rx_ni(skb) ,将skb送给tcp/ip协议栈处理,最终完成虚拟网卡的数据接收。至此,从虚拟机内部到宿主机的网络传输过程才算结束。
35. 最后,把网络虚拟化场景下网络包的发送过程总结一下,如下图所示:
(1)在虚拟机里面的用户态,应用程序通过write系统调用写入socket。
(2)写入的内容经过VFS层、内核协议栈,到达虚拟机里面内核的网络设备驱动即virtio_net。
(3)virtio_net网络设备有一个操作结构struct net_device_ops,里面定义了发送一个网络包调用的函数为start_xmit。
(4)在virtio_net的前端驱动和qemu中的后端驱动之间,有两个队列virtqueue,一个用于发送一个用于接收。然后,需要在start_xmit中调用virtqueue_add,将网络包放入发送队列,然后调用virtqueue_notify通知qemu。
(5)qemu本来处于KVM_RUN的状态,收到通知后通过VM exit指令退出客户机模式,进入宿主机模式。发送网络包的时候,virtio_net_handle_tx_bh函数会被调用。
(6)接下来是一个for循环,需要在循环中调用virtqueue_pop,从传输队列中获取要发送的数据,然后调用qemu_sendv_packet_async进行发送。
(7)qemu会调用writev向字符设备文件写入,进入宿主机的内核。
(8)在宿主机内核中字符设备文件的file_operations里面的write_iter会被调用,即会调用tun_chr_write_iter。
(9)在tun_chr_write_iter函数中,tun_get_user将要发送的网络包从qemu拷贝到宿主机内核里面来,然后调用netif_rx_ni开始调用宿主机内核协议栈进行处理。
(10)宿主机内核协议栈处理完毕之后,会发送给tap虚拟网卡,完成从虚拟机里面到宿主机的整个发送过程。
简化的数据流转流程为:客户机用户态{write 数据} -> 客户机内核态{协议栈处理为网络包} -> qemu{循环从队列中获取网络包,再向字符设备写入数据流} -> 宿主机用户态{dev/net/tun字符设备接收数据流} -> 宿主机内核态{将网络包拷贝到宿主机内核里面来,协议栈处理} -> 宿主机用户态{虚拟网卡发送}。
更多推荐
所有评论(0)