This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,286 @@
<audio id="audio" title="49 | 虚拟机:如何成立子公司,让公司变集团?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fb/ea/fb3977764c78de633ef1a1f5df9342ea.mp3"></audio>
我们前面所有章节涉及的Linux操作系统原理都是在一台Linux服务器上工作的。在前面的原理阐述中我们一直把Linux当作一家外包公司的老板来看待。想要管理这么复杂、这么大的一个公司需要配备咱们前面讲过的所有机制。
Linux很强大Linux服务器也随之变得越来越强大了。无论是计算、网络、存储都越来越牛。例如内存动不动就是百G内存网络设备一个端口的带宽就能有几十G甚至上百G存储在数据中心至少是PB级别的一个P是1024个T一个T是1024个G
公司大有大的好处,自然也有大的毛病,也就是咱们常见的“大公司病”——**不灵活**。这里面的不灵活,有下面这几种,我列一下,你看看你是不是都见过。
- **资源大小不灵活**有时候我们不需要这么大规格的机器可能只想尝试一下某些新业务申请个4核8G的服务器试一下但是不可能采购这么小规格的机器。无论每个项目需要多大规格的机器公司统一采购就限制几种全部是上面那种大规格的。
- **资源申请不灵活**:规格定死就定死吧,可是每次申请机器都要重新采购,周期很长。
- **资源复用不灵活**反正我需要的资源不多和别人共享一台机器吧这样不同的进程可能会产生冲突例如socket的端口冲突。另外就是别人用过的机器不知道上面做过哪些操作有很多的历史包袱如果重新安装则代价太大。
这些是不是和咱们在大公司里面遇到的问题很像?按说,大事情流程严谨没问题,很多小事情也要被拖累走整个流程,而且很容易出现资源冲突,每天跨部门的协调很累人,历史包袱严重,创新没有办法轻装上阵。
很多公司处理这种问题采取的策略是成立独立的子公司,独立决策,独立运营,往往用于创新型的项目。
Linux也采取了这样的手段就是在物理机上面创建虚拟机。每个虚拟机有自己单独的操作系统、灵活的规格一个命令就能启动起来。每次创建都是新的操作系统很好地解决了上面不灵活的问题。
但是要使用虚拟机,还有一些问题需要解决一下。
我们知道操作系统上的程序分为两种一种是用户态的程序例如Word、Excel等一种是内核态的程序例如内核代码、驱动程序等。
为了区分内核态和用户态CPU专门设置四个特权等级0、1、2、3来做这个事情。
当时写Linux内核的时候估计大牛们还不知道将来虚拟机会大放异彩。大牛们想一共两级特权一个内核态一个用户态却有四个等级好奢侈、好富裕于是就敞开了用。内核态运行在第0等级用户态运行在第3等级占了两头中间的都不用太不会过日子了。
大牛们在写Linux内核的时候如果用户态程序做事情就将扳手掰到第3等级一旦要申请使用更多的资源就需要申请将扳手掰到第0等级内核才能在高权限访问这些资源申请完资源返回到用户态扳手再掰回去。
这个程序一直非常顺利地运行着,直到虚拟机出现了。
## 三种虚拟化方式
如果你安装VirtualBox桌面版你可以用这个虚拟化软件创建虚拟机在虚拟机里面安装一个Linux外面的操作系统也可以是Linux。VirtualBox这个虚拟化软件和你的Excel一样都是在你的任务栏里面并排放着是一个普通的应用。
当你进入虚拟机的时候虚拟机里面的Excel也是一个普通的应用。
这个时候麻烦的事情出现了,当你设身处地地站在虚拟机的内核角度,去思考一下人生,你就会出现困惑了,会想,我到底是啥?
在硬件上的操作系统来看我是一个普通的应用只能运行在用户态。可是大牛们“生“我的时候我的每一行代码都告诉我我是个内核啊应该运行在内核态。当虚拟机里面的Excel要访问网络的时候向我请求我的代码就要努力地去操作网卡。尽管我努力但是我做不到啊我没有权限
我分裂了……
怎么办呢虚拟化层也就是Virtualbox会帮你解决这个问题它有三种虚拟化的方式。
我们先来看第一种方式,**完全虚拟化**Full virtualization。其实说白了这是一种“骗人”的方式。虚拟化软件会模拟假的CPU、内存、网络、硬盘给到我让我自我感觉良好感觉自己终于又像个内核了。
但是,真正的工作模式其实是下面这样的。
>
<p>虚拟机内核说我要在CPU上跑一个指令<br>
虚拟化软件说:没问题,你是内核嘛,可以跑!<br>
虚拟化软件转过头去找物理机内核说报告我管理的虚拟机里面的一个要执行一个CPU指令帮忙来一小段时间空闲的CPU时间让我代它跑个指令。<br>
物理机内核说:你等着,另一个跑着呢。(过了一会儿)它跑完了,该你了。<br>
虚拟化软件说:我代它跑,终于跑完了,出来结果了。<br>
虚拟化软件转头给虚拟机内核说:哥们儿,跑完了,结果是这个。我说你是内核吧,绝对有权限,没问题,下次跑指令找我啊!<br>
虚拟机内核说:看来我真的是内核呢,可是,哥,好像这点儿指令跑得有点慢啊!<br>
虚拟化软件说:这就不错啦,好几个排着队跑呢!</p>
内存的申请模式是下面这样的。
>
<p>虚拟机内核说我启动需要4G内存我好分给我上面的应用。<br>
虚拟化软件说没问题才4G你是内核嘛我马上申请好。<br>
虚拟化软件转头给物理机内核说报告我启动了一个虚拟机需要4G内存给我4个房间呗。<br>
物理机内核怎么又一个虚拟机啊好吧给你90、91、92、93四个房间。<br>
虚拟化软件转头给虚拟机内核说哥们内存有了0、1、2、3这个四个房间都是你的。你看你是内核嘛独占资源从0编号的就是你的。<br>
虚拟机内核说看来我真的是内核啊能从头开始用。那好我就在房间2的第三个柜子里面放个东西吧<br>
虚拟化软件说要放东西啊没问题。但是它心里想我查查看这个虚拟机是90号房间开头的它要在房间2放东西那就相当于在房间92放东西。<br>
虚拟化软件转头给物理机内核说报告我上面的虚拟机要在92号房间的第三个柜子里面放个东西。</p>
好了说完了CPU和内存的例子网络和硬盘就不细说了情况也是类似的都是虚拟化软件模拟一个给虚拟机内核看的其实啥事儿都需要虚拟化软件转一遍。
这种方式一个坏处就是,慢,而且往往慢到不能忍受。
于是,虚拟化软件想,我能不能不当传话筒,要让虚拟机内核正视自己的身份。别说你是内核,你还真喘上了。你不是物理机,你是虚拟机!
但是怎么解决权限等级的问题呢于是Intel的VT-x和AMD的AMD-V从硬件层面帮上了忙。当初谁让你们这些写内核的大牛用等级这么奢侈用完了0就是3也不省着点儿用没办法只好另起炉灶弄一个新的标志位表示当前是在虚拟机状态下还是在真正的物理机内核下。
对于虚拟机内核来讲只要将标志位设为虚拟机状态我们就可以直接在CPU上执行大部分的指令不需要虚拟化软件在中间转述除非遇到特别敏感的指令才需要将标志位设为物理机内核态运行这样大大提高了效率。
所以安装虚拟机的时候我们务必要将物理CPU的这个标志位打开。想知道是否打开对于Intel你可以查看grep “vmx” /proc/cpuinfo对于AMD你可以查看grep “svm” /proc/cpuinfo
这叫作**硬件辅助虚拟化**Hardware-Assisted Virtualization
另外就是访问网络或者硬盘的时候,为了取得更高的性能,也需要让虚拟机内核加载特殊的驱动,也是让虚拟机内核从代码层面就重新定位自己的身份,不能像访问物理机一样访问网络或者硬盘,而是用一种特殊的方式。
我知道我不是物理机内核,我知道我是虚拟机,我没那么高的权限,我很可能和很多虚拟机共享物理资源,所以我要学会排队,我写硬盘其实写的是一个物理机上的文件,那我的写文件的缓存方式是不是可以变一下。我发送网络包,根本就不是发给真正的网络设备,而是给虚拟的设备,我可不可以直接在内存里面拷贝给它,等等等等。
一旦我知道我不是物理机内核,痛定思痛,只好重新认识自己,反而能找出很多方式来优化我的资源访问。
这叫作**半虚拟化**Paravirtualization
对于桌面虚拟化软件我们多采用VirtualBox如果使用服务器的虚拟化软件则有另外的选型。
服务器上的虚拟化软件多使用qemu其中关键字emu全称是emulator模拟器。所以单纯使用qemu采用的是完全虚拟化的模式。
qemu向Guest OS模拟CPU也模拟其他的硬件GuestOS认为自己和硬件直接打交道其实是同qemu模拟出来的硬件打交道qemu会将这些指令转译给真正的硬件。由于所有的指令都要从qemu里面过一手因而性能就会比较差。
<img src="https://static001.geekbang.org/resource/image/05/fa/058be86de5a43a782392aff3cb8a1ffa.png" alt="">
按照上面的介绍完全虚拟化是非常慢的所以要使用硬件辅助虚拟化技术Intel-VTAMD-V所以需要CPU硬件开启这个标志位一般在BIOS里面设置。
当确认开始了标志位之后通过KVMGuestOS的CPU指令不用经过Qemu转译直接运行大大提高了速度。
所以KVM在内核里面需要有一个模块来设置当前CPU是Guest OS在用还是Host OS在用。
下面我们来查看内核模块中是否含有kvm, lsmod | grep kvm。
KVM内核模块通过/dev/kvm暴露接口用户态程序可以通过ioctl来访问这个接口。例如你可以通过下面的流程编写程序。
<img src="https://static001.geekbang.org/resource/image/f5/62/f5ee1a44d7c4890e411c2520507ddc62.png" alt="">
Qemu将KVM整合进来将有关CPU指令的部分交由内核模块来做就是qemu-kvm (qemu-system-XXX)。
qemu和kvm整合之后CPU的性能问题解决了。另外Qemu还会模拟其他的硬件如网络和硬盘。同样全虚拟化的方式也会影响这些设备的性能。
于是qemu采取半虚拟化的方式让Guest OS加载特殊的驱动来做这件事情。
例如网络需要加载virtio_net存储需要加载virtio_blkGuest需要安装这些半虚拟化驱动GuestOS知道自己是虚拟机所以数据会直接发送给半虚拟化设备经过特殊处理例如排队、缓存、批量处理等性能优化方式最终发送给真正的硬件。这在一定程度上提高了性能。
至此,整个关系如下图所示。
<img src="https://static001.geekbang.org/resource/image/f7/22/f748fd6b6b84fa90a1044a92443c3522.png" alt="">
## 创建虚拟机
了解了qemu-kvm的工作原理之后下面我们来看一下如何使用qemu-kvm创建一个能够上网的虚拟机。
如果使用VirtualBox创建过虚拟机通过界面点点就能创建一个能够上网的虚拟机。如果使用qemu-kvm就没有这么简单了。一切都得自己来做不过这个过程可以了解KVM虚拟机的创建原理。
首先我们要给虚拟机起一个名字在KVM里面就是-name ubuntutest。
<img src="https://static001.geekbang.org/resource/image/93/a8/93d2df627d6dfb09cf1c13fee16629a8.png" alt="">
设置一个内存大小在KVM里面就是-m 1024。
<img src="https://static001.geekbang.org/resource/image/db/76/db13672936d08b2c2c28115f16937876.png" alt="">
创建一个虚拟硬盘对于VirtualBox是VDI格式对于KVM则不同。
<img src="https://static001.geekbang.org/resource/image/13/dd/136d17009a7481cbd472036e4ee83ddd.png" alt="">
硬盘有两种格式,一个是动态分配,也即开始创建的时候,看起来很大,其实占用的空间很少,真实有多少数据,才真的占用多少空间。一个是固定大小,一开始就占用指定的大小。
<img src="https://static001.geekbang.org/resource/image/c2/66/c201a260cb24b2f39d6ba0ac7b548a66.png" alt="">
比如我这台电脑硬盘的大小为8G。
<img src="https://static001.geekbang.org/resource/image/9e/6d/9ed4e7712a6fe9f43dae5d1c1ecb9e6d.png" alt="">
在KVM中创建一个虚拟机镜像大小为8G其中qcow2格式为动态分配raw格式为固定大小。
```
qemu-img create -f qcow2 ubuntutest.img 8G
```
我们将Ubuntu的ISO挂载为光盘在KVM里面-cdrom [ubuntu-xxx-server-amd64.iso](http://ubuntu-xxx-server-amd64.iso)。
<img src="https://static001.geekbang.org/resource/image/09/b0/091d934004d10550a87b4d635546d5b0.png" alt="">
创建一个网络有时候会选择桥接网络有时候会选择NAT网络这个在KVM里面只有自己配置了。
<img src="https://static001.geekbang.org/resource/image/6d/5b/6d8733d73192bd1b114df03f01c6275b.png" alt="">
接下来Virtualbox就会有一个界面可以看到安装的整个过程在KVM里面我们用VNC来做。参数为-vnc :19
于是我们也可以创建KVM虚拟机了可以用下面的命令
```
qemu-system-x86_64 -enable-kvm -name ubuntutest -m 2048 -hda ubuntutest.img -cdrom ubuntu-14.04-server-amd64.iso -boot d -vnc :19
```
启动了虚拟机后连接VNC我们也能看到安装的过程。
<img src="https://static001.geekbang.org/resource/image/6a/1e/6afde27eb9bf29b566c47bf60fd56f1e.png" alt="">
按照普通安装Ubuntu的流程安装好Ubuntu然后shutdown -h now关闭虚拟机。
接下来我们可以对KVM创建桥接网络了。这个要模拟virtualbox的桥接网络模式。
如果在桌面虚拟化软件上选择桥接网络,在你的笔记本电脑上,就会形成下面的结构。
<img src="https://static001.geekbang.org/resource/image/2b/47/2b49867c473162d4706553e8cbb5f247.png" alt="">
每个虚拟机都会有虚拟网卡,在你的笔记本电脑上,会发现多了几个网卡,其实是虚拟交换机。这个虚拟交换机将虚拟机连接在一起。在桥接模式下,物理网卡也连接到这个虚拟交换机上。物理网卡在桌面虚拟化软件的“界面名称”那里选定。
如果使用桥接网络当你登录虚拟机里看IP地址时会发现你的虚拟机的地址和你的笔记本电脑的地址以及你旁边的同事的电脑的网段是一个网段。这是为什么呢这其实相当于将物理机和虚拟机放在同一个网桥上相当于这个网桥上有三台机器是一个网段的全部打平了。
<img src="https://static001.geekbang.org/resource/image/78/47/7899a96aaa0b91c165f867d3ec42e947.png" alt="">
在数据中心里面采取的也是类似的技术连接方式如下图所示只不过是Linux在每台机器上都创建网桥br0虚拟机的网卡都连到br0上物理网卡也连到br0上所有的br0都通过物理网卡连接到物理交换机上。
<img src="https://static001.geekbang.org/resource/image/da/a7/da83bb01b7ed63ac0062b5cc835099a7.png" alt="">
同样我们换一个角度看待这个拓扑图。同样是将网络打平,虚拟机会和物理网络具有相同的网段,就相当于两个虚拟交换机、一个物理交换机,一共三个交换机连在一起。两组四个虚拟机和两台物理机都是在一个二层网络里面的。
<img src="https://static001.geekbang.org/resource/image/8e/c6/8e471a287e0181f1b7af56b60b84adc6.png" alt="">
qemu-kvm如何才能创建一个这样的桥接网络呢
1.在Host机器上创建bridge br0。
```
brctl addbr br0
```
2.将br0设为up。
```
ip link set br0 up
```
3.创建tap device。
```
tunctl -b
```
4.将tap0设为up。
```
ip link set tap0 up
```
5.将tap0加入到br0上。
```
brctl addif br0 tap0
```
6.启动虚拟机, 虚拟机连接tap0、tap0连接br0。
```
qemu-system-x86_64 -enable-kvm -name ubuntutest -m 2048 -hda ubuntutest.qcow2 -vnc :19 -net nic,model=virtio -nettap,ifname=tap0,script=no,downscript=no
```
7.虚拟机启动后网卡没有配置所以无法连接外网先给br0设置一个ip。
```
ifconfig br0 192.168.57.1/24
```
8.VNC连上虚拟机给网卡设置地址重启虚拟机可ping通br0。
9.要想访问外网在Host上设置NAT并且enable ip forwarding可以ping通外网网关。
```
# sysctl -p
net.ipv4.ip_forward = 1
sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
```
10.如果DNS没配错可以进行apt-get update。
在这里请记住qemu-system-x86_64的启动命令这里面有CPU虚拟化KVM有内存虚拟化、硬盘虚拟化、网络虚拟化。接下来的章节我们会看内核是如何进行虚拟化的。
## 总结时刻
今天我们讲了虚拟化的基本原理,并且手动创建一个可以上网的虚拟机。请记住下面这一点,非常重要,理解虚拟机启动的参数就是理解虚拟化技术的入口。学会创建虚拟机,在后面做内核相关实验的时候就会非常方便。
具体到知识点上,这一节你需要需要记住下面的这些知识点:
- 虚拟化的本质是用qemu的软件模拟硬件但是模拟方式比较慢需要加速
- 虚拟化主要模拟CPU、内存、网络、存储分别有不同的加速办法
- CPU和内存主要使用硬件辅助虚拟化进行加速需要配备特殊的硬件才能工作
- 网络和存储主要使用特殊的半虚拟化驱动加速,需要加载特殊的驱动程序。
## 课堂练习
请你务必自己使用qemu按照上面我写的步骤创建一台虚拟机。
欢迎留言和我分享你的疑惑和见解,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,541 @@
<audio id="audio" title="50 | 计算虚拟化之CPU如何复用集团的人力资源" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/86/8d/864b26555d79e4635efeac6b4580bb8d.mp3"></audio>
上一节我们讲了一下虚拟化的基本原理以及qemu、kvm之间的关系。这一节我们就来看一下用户态的qemu和内核态的kvm如何一起协作来创建虚拟机实现CPU和内存虚拟化。
这里是上一节我们讲的qemu启动时候的命令。
```
qemu-system-x86_64 -enable-kvm -name ubuntutest -m 2048 -hda ubuntutest.qcow2 -vnc :19 -net nic,model=virtio -nettap,ifname=tap0,script=no,downscript=no
```
接下来,我们在[这里下载](https://www.qemu.org/)qemu的代码。qemu的main函数在vl.c下面。这是一个非常非常长的函数我们来慢慢地解析它。
## 1.初始化所有的Module
第一步初始化所有的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-&gt;init = fn;
e-&gt;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-&gt;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(&amp;kvm_accel_type);
}
TypeImpl *type_register_static(const TypeInfo *info)
{
return type_register(info);
}
TypeImpl *type_register(const TypeInfo *info)
{
assert(info-&gt;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-&gt;name) != NULL) {
}
ti-&gt;name = g_strdup(info-&gt;name);
ti-&gt;parent = g_strdup(info-&gt;parent);
ti-&gt;class_size = info-&gt;class_size;
ti-&gt;instance_size = info-&gt;instance_size;
ti-&gt;class_init = info-&gt;class_init;
ti-&gt;class_base_init = info-&gt;class_base_init;
ti-&gt;class_data = info-&gt;class_data;
ti-&gt;instance_init = info-&gt;instance_init;
ti-&gt;instance_post_init = info-&gt;instance_post_init;
ti-&gt;instance_finalize = info-&gt;instance_finalize;
ti-&gt;abstract = info-&gt;abstract;
for (i = 0; info-&gt;interfaces &amp;&amp; info-&gt;interfaces[i].type; i++) {
ti-&gt;interfaces[i].typename = g_strdup(info-&gt;interfaces[i].type);
}
ti-&gt;num_interfaces = i;
return ti;
}
static void type_table_add(TypeImpl *ti)
{
assert(!enumerating_types);
g_hash_table_insert(type_table_get(), (void *)ti-&gt;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这里已经直接叫类class了。如果用这个类声明一个对象对象的大小应该是instance_size。是不是有点儿Java语言反射的意思根据一些名称的定义一个类就定义好了。
这里的调用链为kvm_type_init-&gt;type_register_static-&gt;type_register-&gt;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实现了一套面向对象的反射机制。
说完了初始化Module我们还回到main函数接着分析。
## 2.解析qemu的命令行
第二步我们就要开始解析qemu的命令行了。qemu的命令行解析就是下面这样一长串。还记得咱们自己写过一个解析命令行参数的程序吗这里的opts是差不多的意思。
```
qemu_add_opts(&amp;qemu_drive_opts);
qemu_add_opts(&amp;qemu_chardev_opts);
qemu_add_opts(&amp;qemu_device_opts);
qemu_add_opts(&amp;qemu_netdev_opts);
qemu_add_opts(&amp;qemu_nic_opts);
qemu_add_opts(&amp;qemu_net_opts);
qemu_add_opts(&amp;qemu_rtc_opts);
qemu_add_opts(&amp;qemu_machine_opts);
qemu_add_opts(&amp;qemu_accel_opts);
qemu_add_opts(&amp;qemu_mem_opts);
qemu_add_opts(&amp;qemu_smp_opts);
qemu_add_opts(&amp;qemu_boot_opts);
qemu_add_opts(&amp;qemu_name_opts);
qemu_add_opts(&amp;qemu_numa_opts);
```
为什么有这么多的opts呢这是因为我们上一节给的参数都是简单的参数实际运行中创建的kvm参数会复杂N倍。这里我们贴一个开源云平台软件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
```
<li>
-enable-kvm表示启用硬件辅助虚拟化。
</li>
<li>
-name instance-00000024表示虚拟机的名称。
</li>
<li>
<p>-machine pc-i440fx-trusty,accel=kvm,usb=offmachine是什么呢其实就是计算机体系结构。不知道什么是体系结构的话可以订阅极客时间的另一个专栏《深入浅出计算机组成原理》。<br>
qemu会模拟多种体系结构常用的有普通PC机也即x86的32位或者64位的体系结构、Mac电脑PowerPC的体系结构、Sun的体系结构、MIPS的体系结构精简指令集。如果使用KVM hardware-assisted virtualization也即BIOS中VD-T是打开的则参数中accel=kvm。如果不使用hardware-assisted virtualization用的是纯模拟则有参数accel = tcg-no-kvm。</p>
</li>
<li>
-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表示设置CPUSandyBridge是Intel处理器后面的加号都是添加的CPU的参数这些参数会显示在/proc/cpuinfo里面。
</li>
<li>
-m 2048表示内存。
</li>
<li>
<p>-smp 1,sockets=1,cores=1,threads=1SMP我们解析过叫对称多处理器和NUMA对应。qemu仿真了一个具有1个vcpu一个socket一个core一个threads的处理器。<br>
socket、core、threads是什么概念呢socket就是主板上插cpu的槽的数目也即常说的“路”core就是我们平时说的“核”即双核、4核等。thread就是每个core的硬件线程数即超线程。举个具体的例子某个服务器是2路4核超线程一般默认为2个线程通过cat /proc/cpuinfo我们看到的是2**4**2=16个processor很多人也习惯成为16核了。</p>
</li>
<li>
-rtc base=utc,driftfix=slew表示系统时间由参数-rtc指定。
</li>
<li>
-device cirrus-vga,id=video0,bus=pci.0,addr=0x2表示显示器用参数-vga设置默认为cirrus它模拟了CL-GD5446PCI VGA card。
</li>
<li>
有关网卡,使用-net参数和-device。
</li>
<li>
从HOST角度-netdev tap,fd=32,id=hostnet0,vhost=on,vhostfd=37。
</li>
<li>
从GUEST角度-device virtio-net-pci,netdev=hostnet0,id=net0,mac=fa:16:3e:d1:2d:99,bus=pci.0,addr=0x3。
</li>
<li>
有关硬盘,使用-hda -hdb或者使用-drive和-device。
</li>
<li>
从HOST角度-drive file=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/disk,if=none,id=drive-virtio-disk0,format=qcow2,cache=none
</li>
<li>
从GUEST角度-device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1
</li>
<li>
-vnc 0.0.0.0:12设置VNC。
</li>
在main函数中接下来的for循环和大量的switch case语句就是对于这些参数的解析我们不一一解析后面真的用到这些参数的时候我们再仔细看。
## 3.初始化machine
回到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, &quot;pc-i440fx-4.0&quot;, 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-&gt;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(&amp;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-&gt;alias = &quot;pc&quot;;
m-&gt;is_default = 1;
}
static void pc_i440fx_machine_options(MachineClass *m)
{
PCMachineClass *pcmc = PC_MACHINE_CLASS(m);
pcmc-&gt;default_nic_model = &quot;e1000&quot;;
m-&gt;family = &quot;pc_piix&quot;;
m-&gt;desc = &quot;Standard PC (i440FX + PIIX, 1996)&quot;;
m-&gt;default_machine_opts = &quot;firmware=bios-256k.bin&quot;;
m-&gt;default_display = &quot;std&quot;;
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, &quot;type&quot;);
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-&gt;next) {
MachineClass *temp = el-&gt;data;
if (temp-&gt;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, &amp;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, &amp;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-&gt;class;
......
data-&gt;fn(k, data-&gt;opaque);
}
static void type_initialize(TypeImpl *ti)
{
TypeImpl *parent;
......
ti-&gt;class_size = type_class_get_size(ti);
ti-&gt;instance_size = type_object_get_size(ti);
if (ti-&gt;instance_size == 0) {
ti-&gt;abstract = true;
}
......
ti-&gt;class = g_malloc0(ti-&gt;class_size);
......
ti-&gt;class-&gt;type = ti;
while (parent) {
if (parent-&gt;class_base_init) {
parent-&gt;class_base_init(ti-&gt;class, ti-&gt;class_data);
}
parent = type_get_parent(parent);
}
if (ti-&gt;class_init) {
ti-&gt;class_init(ti-&gt;class, ti-&gt;class_data);
}
}
```
在object_class_foreach_tramp中会调用将type_initialize这里面会调用class_init将纸面上的class也即TypeImpl变为ObjectClassObjectClass是所有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-&gt;instance_size);
object_initialize_with_type(obj, type-&gt;instance_size, type);
obj-&gt;free = g_free;
return obj;
}
```
object_new中TypeImpl的instance_init会被调用创建一个对象。current_machine就是这个对象它的类型是MachineState。
至此绕了这么大一圈有关体系结构的对象才创建完毕接下来很多的设备的初始化包括CPU和内存的初始化都是围绕着体系结构的对象来的后面我们会常常看到current_machine。
## 总结时刻
这一节我们学到虚拟机对于设备的模拟是一件非常复杂的事情需要用复杂的参数模拟各种各样的设备。为了能够适配这些设备qemu定义了自己的模块管理机制只有了解了这种机制后面看每一种设备的虚拟化的时候才有一个整体的思路。
这里的MachineClass是我们遇到的第一个我们需要掌握它里面各种定义之间的关系。
<img src="https://static001.geekbang.org/resource/image/07/30/078dc698ef1b3df93ee9569e55ea2f30.png" alt="">
每个模块都会有一个定义TypeInfo会通过type_init变为全局的TypeImpl。TypeInfo以及生成的TypeImpl有以下成员
- name表示当前类型的名称
- parent表示父类的名称
- class_init用于将TypeImpl初始化为MachineClass
- instance_init用于将MachineClass初始化为MachineState
所以,以后遇到任何一个类型的时候,将父类和子类之间的关系,以及对应的初始化函数都要看好,这样就一目了然了。
## 课堂练习
你可能会问这么复杂的qemu命令我是怎么找到的当然不是我一个字一个字打的这是著名的云平台管理软件OpenStack创建虚拟机的时候自动生成的命令行。所以给你留一道课堂练习题请你看一下OpenStack的基本原理看它是通过什么工具来管理如此复杂的命令行的。
欢迎留言和我分享你的疑惑和见解,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,887 @@
<audio id="audio" title="51 | 计算虚拟化之CPU如何复用集团的人力资源" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6e/f6/6e9eb376b3f8ef84f8af74b699194cf6.mp3"></audio>
上一节qemu初始化的main函数我们解析了一个开头得到了表示体系结构的MachineClass以及MachineState。
## 4.初始化块设备
我们接着回到main函数接下来初始化的是块设备调用的是configure_blockdev。这里我们需要重点关注上面参数中的硬盘不过我们放在存储虚拟化那一节再解析。
```
configure_blockdev(&amp;bdo_queue, machine_class, snapshot);
```
## 5.初始化计算虚拟化的加速模式
接下来初始化的是计算虚拟化的加速模式也即要不要使用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(), &quot;accel&quot;);
accel = &quot;kvm&quot;;
accel_list = g_strsplit(accel, &quot;:&quot;, 0);
for (tmp = accel_list; !accel_initialised &amp;&amp; tmp &amp;&amp; *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(&quot;%s&quot;), 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-&gt;accelerator = accel;
*(acc-&gt;allowed) = true;
ret = acc-&gt;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-&gt;name = &quot;KVM&quot;;
ac-&gt;init_machine = kvm_init;
ac-&gt;allowed = &amp;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-&gt;accelerator);
s-&gt;fd = qemu_open(&quot;/dev/kvm&quot;, O_RDWR);
ret = kvm_ioctl(s, KVM_GET_API_VERSION, 0);
......
do {
ret = kvm_ioctl(s, KVM_CREATE_VM, type);
} while (ret == -EINTR);
......
s-&gt;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 &lt; 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,
&quot;kvm&quot;,
&amp;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这个函数。这个过程我们在[字符设备](https://time.geekbang.org/column/article/100068)那一节也讲了。
```
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(&quot;kvm-vm&quot;, &amp;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结构。这个结构本来用来管理进程的内存的。虚拟机也是一个进程所以虚拟机的用户进程空间也是用它来表示。虚拟机里面的操作系统以及应用的进程空间不归它管。
在kvm_dev_ioctl_create_vm中第二件事情就是创建一个文件描述符和struct file关联起来这个struct file的file_operations会被设置为kvm_vm_fops。
```
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结束之后对于一台虚拟机而言只是在内核中有一个数据结构对于相应的资源还没有分配所以我们还需要接着看。
## 6.初始化网络设备
接下来调用net_init_clients进行网络设备的初始化。我们可以解析net参数也会在net_init_clients中解析netdev参数。这属于网络虚拟化的部分我们先暂时放一下。
```
int net_init_clients(Error **errp)
{
QTAILQ_INIT(&amp;net_clients);
if (qemu_opts_foreach(qemu_find_opts(&quot;netdev&quot;),
net_init_netdev, NULL, errp)) {
return -1;
}
if (qemu_opts_foreach(qemu_find_opts(&quot;nic&quot;), net_param_nic, NULL, errp)) {
return -1;
}
if (qemu_opts_foreach(qemu_find_opts(&quot;net&quot;), net_init_client, NULL, errp)) {
return -1;
}
return 0;
}
```
## 7.CPU虚拟化
接下来我们要调用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-&gt;init(machine);
}
```
在pc_init1里面我们重点关注两件重要的事情一个的CPU的虚拟化主要调用pc_cpus_init另外就是内存的虚拟化主要调用pc_memory_init。这一节我们重点关注CPU的虚拟化下一节我们来看内存的虚拟化。
```
void pc_cpus_init(PCMachineState *pcms)
{
......
for (i = 0; i &lt; smp_cpus; i++) {
pc_new_cpu(possible_cpus-&gt;cpus[i].type, possible_cpus-&gt;cpus[i].arch_id, &amp;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, &quot;apic-id&quot;, &amp;local_err);
object_property_set_bool(cpu, true, &quot;realized&quot;, &amp;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的定义。
```
{ &quot;SandyBridge&quot; &quot;-&quot; TYPE_X86_CPU, &quot;min-xlevel&quot;, &quot;0x8000000a&quot; }
```
接下来,我们就来看"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_CPUTYPE_CPU的父类是TYPE_DEVICETYPE_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。这个函数很重要马上就能用到。
```
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,
&amp;xcc-&gt;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-&gt;realized = false;
object_property_add_bool(obj, &quot;realized&quot;,
device_get_realized, device_set_realized, NULL);
......
}
```
我们回到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-&gt;thread = g_malloc0(sizeof(QemuThread));
cpu-&gt;halt_cond = g_malloc0(sizeof(QemuCond));
qemu_cond_init(cpu-&gt;halt_cond);
qemu_thread_create(cpu-&gt;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-&gt;thread);
cpu-&gt;thread_id = qemu_get_thread_id();
cpu-&gt;can_do_io = 1;
current_cpu = cpu;
r = kvm_init_vcpu(cpu);
kvm_init_cpu_signals(cpu);
/* signal CPU creation */
cpu-&gt;created = true;
qemu_cond_signal(&amp;qemu_cpu_cond);
do {
if (cpu_can_run(cpu)) {
r = kvm_cpu_exec(cpu);
}
qemu_wait_io_event(cpu);
} while (!cpu-&gt;unplug || cpu_can_run(cpu));
qemu_kvm_destroy_vcpu(cpu);
cpu-&gt;created = false;
qemu_cond_signal(&amp;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-&gt;kvm_fd = ret;
cpu-&gt;kvm_state = s;
cpu-&gt;vcpu_dirty = true;
mmap_size = kvm_ioctl(s, KVM_GET_VCPU_MMAP_SIZE, 0);
......
cpu-&gt;kvm_run = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, cpu-&gt;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-&gt;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(&amp;kvm_userspace_mem, argp,
sizeof(kvm_userspace_mem)))
goto out;
r = kvm_vm_ioctl_set_memory_region(kvm, &amp;kvm_userspace_mem);
break;
}
......
case KVM_CREATE_DEVICE: {
struct kvm_create_device cd;
if (copy_from_user(&amp;cd, argp, sizeof(cd)))
goto out;
r = kvm_ioctl_create_device(kvm, &amp;cd);
if (copy_to_user(argp, &amp;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-&gt;created_vcpus++;
......
vcpu = kvm_arch_vcpu_create(kvm, id);
preempt_notifier_init(&amp;vcpu-&gt;preempt_notifier, &amp;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-&gt;vcpus[atomic_read(&amp;kvm-&gt;online_vcpus)] = vcpu;
......
}
struct kvm_vcpu *kvm_arch_vcpu_create(struct kvm *kvm,
unsigned int id)
{
struct kvm_vcpu *vcpu;
vcpu = kvm_x86_ops-&gt;vcpu_create(kvm, id);
return vcpu;
}
static int create_vcpu_fd(struct kvm_vcpu *vcpu)
{
return anon_inode_getfd(&quot;kvm-vcpu&quot;, &amp;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进行操作。
我们回过头来看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-&gt;vpid = allocate_vpid();
err = kvm_vcpu_init(&amp;vmx-&gt;vcpu, kvm, id);
vmx-&gt;guest_msrs = kmalloc(PAGE_SIZE, GFP_KERNEL);
vmx-&gt;loaded_vmcs = &amp;vmx-&gt;vmcs01;
vmx-&gt;loaded_vmcs-&gt;vmcs = alloc_vmcs();
vmx-&gt;loaded_vmcs-&gt;shadow_vmcs = NULL;
loaded_vmcs_init(vmx-&gt;loaded_vmcs);
cpu = get_cpu();
vmx_vcpu_load(&amp;vmx-&gt;vcpu, cpu);
vmx-&gt;vcpu.cpu = cpu;
err = vmx_vcpu_setup(vmx);
vmx_vcpu_put(&amp;vmx-&gt;vcpu);
put_cpu();
if (enable_ept) {
if (!kvm-&gt;arch.ept_identity_map_addr)
kvm-&gt;arch.ept_identity_map_addr =
VMX_EPT_IDENTITY_PAGETABLE_ADDR;
err = init_rmode_identity_map(kvm);
}
return &amp;vmx-&gt;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状态的一个关键数据结构。
VMCS数据结构主要包含以下信息。
- Guest-state area即vCPU的状态信息包括vCPU的基本运行环境例如寄存器等。
- Host-state area是物理CPU的状态信息。物理CPU和vCPU之间也会来回切换所以VMCS中既要记录vCPU的状态也要记录物理CPU的状态。
- VM-execution control fields对vCPU的运行行为进行控制。例如发生中断怎么办是否使用EPTExtended Page Table功能等。
接下来对于VMCS有两个重要的操作。
VM-Entry我们称为从根模式切换到非根模式也即切换到guest上这个时候CPU上运行的是虚拟机。VM-Exit我们称为CPU从非根模式切换到根模式也即从guest切换到宿主机。例如当要执行一些虚拟机没有权限的敏感指令时。
<img src="https://static001.geekbang.org/resource/image/1e/dc/1ec7600be619221dfac03e6ade67f7dc.png" alt="">
为了维护这两个动作VMCS里面还有几项内容
- VM-exit control fields对VM Exit的行为进行控制。比如VM Exit的时候对vCPU来说需要保存哪些MSR寄存器对于主机CPU来说需要恢复哪些MSR寄存器。
- VM-entry control fields对VM Entry的行为进行控制。比如需要保存和恢复哪些MSR寄存器等。
- VM-exit information fields记录下发生VM Exit发生的原因及一些必要的信息方便对VM Exit事件进行处理。
至此,内核准备完毕。
我们再回到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-&gt;kvm_run;
int ret, run_ret;
......
do {
......
run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);
......
switch (run-&gt;exit_reason) {
case KVM_EXIT_IO:
kvm_handle_io(run-&gt;io.port, attrs,
(uint8_t *)run + run-&gt;io.data_offset,
run-&gt;io.direction,
run-&gt;io.size,
run-&gt;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, &quot;KVM: unknown exit, hardware reason %&quot; PRIx64 &quot;\n&quot;,(uint64_t)run-&gt;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-&gt;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-&gt;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-&gt;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-&gt;run-&gt;exit_reason = KVM_EXIT_INTR;
++vcpu-&gt;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-&gt;mode = IN_GUEST_MODE;
kvm_load_guest_xcr0(vcpu);
......
guest_enter_irqoff();
kvm_x86_ops-&gt;run(vcpu);
vcpu-&gt;mode = OUTSIDE_GUEST_MODE;
......
kvm_put_guest_xcr0(vcpu);
kvm_x86_ops-&gt;handle_external_intr(vcpu);
++vcpu-&gt;stat.exits;
guest_exit_irqoff();
r = kvm_x86_ops-&gt;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-&gt;__launched = vmx-&gt;loaded_vmcs-&gt;launched;
asm(
/* Store host registers */
&quot;push %%&quot; _ASM_DX &quot;; push %%&quot; _ASM_BP &quot;;&quot;
&quot;push %%&quot; _ASM_CX &quot; \n\t&quot; /* placeholder for guest rcx */
&quot;push %%&quot; _ASM_CX &quot; \n\t&quot;
......
/* Load guest registers. Don't clobber flags. */
&quot;mov %c[rax](%0), %%&quot; _ASM_AX &quot; \n\t&quot;
&quot;mov %c[rbx](%0), %%&quot; _ASM_BX &quot; \n\t&quot;
&quot;mov %c[rdx](%0), %%&quot; _ASM_DX &quot; \n\t&quot;
&quot;mov %c[rsi](%0), %%&quot; _ASM_SI &quot; \n\t&quot;
&quot;mov %c[rdi](%0), %%&quot; _ASM_DI &quot; \n\t&quot;
&quot;mov %c[rbp](%0), %%&quot; _ASM_BP &quot; \n\t&quot;
#ifdef CONFIG_X86_64
&quot;mov %c[r8](%0), %%r8 \n\t&quot;
&quot;mov %c[r9](%0), %%r9 \n\t&quot;
&quot;mov %c[r10](%0), %%r10 \n\t&quot;
&quot;mov %c[r11](%0), %%r11 \n\t&quot;
&quot;mov %c[r12](%0), %%r12 \n\t&quot;
&quot;mov %c[r13](%0), %%r13 \n\t&quot;
&quot;mov %c[r14](%0), %%r14 \n\t&quot;
&quot;mov %c[r15](%0), %%r15 \n\t&quot;
#endif
&quot;mov %c[rcx](%0), %%&quot; _ASM_CX &quot; \n\t&quot; /* kills %0 (ecx) */
/* Enter guest mode */
&quot;jne 1f \n\t&quot;
__ex(ASM_VMX_VMLAUNCH) &quot;\n\t&quot;
&quot;jmp 2f \n\t&quot;
&quot;1: &quot; __ex(ASM_VMX_VMRESUME) &quot;\n\t&quot;
&quot;2: &quot;
/* Save guest registers, load host registers, keep flags */
&quot;mov %0, %c[wordsize](%%&quot; _ASM_SP &quot;) \n\t&quot;
&quot;pop %0 \n\t&quot;
&quot;mov %%&quot; _ASM_AX &quot;, %c[rax](%0) \n\t&quot;
&quot;mov %%&quot; _ASM_BX &quot;, %c[rbx](%0) \n\t&quot;
__ASM_SIZE(pop) &quot; %c[rcx](%0) \n\t&quot;
&quot;mov %%&quot; _ASM_DX &quot;, %c[rdx](%0) \n\t&quot;
&quot;mov %%&quot; _ASM_SI &quot;, %c[rsi](%0) \n\t&quot;
&quot;mov %%&quot; _ASM_DI &quot;, %c[rdi](%0) \n\t&quot;
&quot;mov %%&quot; _ASM_BP &quot;, %c[rbp](%0) \n\t&quot;
#ifdef CONFIG_X86_64
&quot;mov %%r8, %c[r8](%0) \n\t&quot;
&quot;mov %%r9, %c[r9](%0) \n\t&quot;
&quot;mov %%r10, %c[r10](%0) \n\t&quot;
&quot;mov %%r11, %c[r11](%0) \n\t&quot;
&quot;mov %%r12, %c[r12](%0) \n\t&quot;
&quot;mov %%r13, %c[r13](%0) \n\t&quot;
&quot;mov %%r14, %c[r14](%0) \n\t&quot;
&quot;mov %%r15, %c[r15](%0) \n\t&quot;
#endif
&quot;mov %%cr2, %%&quot; _ASM_AX &quot; \n\t&quot;
&quot;mov %%&quot; _ASM_AX &quot;, %c[cr2](%0) \n\t&quot;
&quot;pop %%&quot; _ASM_BP &quot;; pop %%&quot; _ASM_DX &quot; \n\t&quot;
&quot;setbe %c[fail](%0) \n\t&quot;
&quot;.pushsection .rodata \n\t&quot;
&quot;.global vmx_return \n\t&quot;
&quot;vmx_return: &quot; _ASM_PTR &quot; 2b \n\t&quot;
......
);
......
vmx-&gt;loaded_vmcs-&gt;launched = 1;
vmx-&gt;exit_reason = vmcs_read32(VM_EXIT_REASON);
......
}
```
在vmx_vcpu_run中出现了汇编语言的代码比较难看懂但是没有关系呀里面有注释呀我们可以沿着注释来看。
- 首先是Store host registers要从宿主机模式变为客户机模式了所以原来宿主机运行时候的寄存器要保存下来。
- 接下来是Load guest registers将原来客户机运行的时候的寄存器加载进来。
- 接下来是Enter guest mode调用ASM_VMX_VMLAUNCH进入客户机模型运行或者ASM_VMX_VMRESUME恢复客户机模型运行。
- 如果客户机因为某种原因退出Save guest registers, load host registers也即保存客户机运行的时候的寄存器就加载宿主机运行的时候的寄存器。
- 最后将exit_reason保存在vmx结构中。
至此CPU虚拟化就解析完了。
## 总结时刻
CPU的虚拟化过程还是很复杂的我画了一张图总结了一下。
<img src="https://static001.geekbang.org/resource/image/c4/67/c43639f7024848aa3e828bcfc10ca467.png" alt="">
- 首先我们要定义CPU这种类型的TypeInfo和TypeImpl、继承关系并且声明它的类初始化函数。
- 在qemu的main函数中调用MachineClass的init函数这个函数既会初始化CPU也会初始化内存。
- CPU初始化的时候会调用pc_new_cpu创建一个虚拟CPU它会调用CPU这个类的初始化函数。
- 每一个虚拟CPU会调用qemu_thread_create创建一个线程线程的执行函数为qemu_kvm_cpu_thread_fn。
- 在虚拟CPU对应的线程执行函数中我们先是调用kvm_vm_ioctl(KVM_CREATE_VCPU)在内核的KVM里面创建一个结构struct vcpu_vmx表示这个虚拟CPU。在这个结构里面有一个VMCS用于保存当前虚拟机CPU的运行时的状态用于状态切换。
- 在虚拟CPU对应的线程执行函数中我们接着调用kvm_vcpu_ioctl(KVM_RUN)在内核的KVM里面运行这个虚拟机CPU。运行的方式是保存宿主机的寄存器加载客户机的寄存器然后调用__ex(ASM_VMX_VMLAUNCH)或者__ex(ASM_VMX_VMRESUME)进入客户机模式运行。一旦退出客户机模式就会保存客户机寄存器加载宿主机寄存器进入宿主机模式运行并且会记录退出虚拟机模式的原因。大部分的原因是等待I/O因而宿主机调用kvm_handle_io进行处理。
## 课堂练习
在咱们上面操作KVM的过程中出现了好几次文件系统。不愧是“Linux中一切皆文件”。那你能否整理一下这些文件系统之间的关系呢
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,843 @@
<audio id="audio" title="52 | 计算虚拟化之内存:如何建立独立的办公室?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e0/29/e09a498824c7c5dc63a27ed518a2f929.mp3"></audio>
上一节我们解析了计算虚拟化之CPU。可以看到CPU的虚拟化是用户态的qemu和内核态的KVM共同配合完成的。它们二者通过ioctl进行通信。对于内存管理来讲也是需要这两者配合完成的。
咱们在内存管理的时候讲过,操作系统给每个进程分配的内存都是虚拟内存,需要通过页表映射,变成物理内存进行访问。当有了虚拟机之后,情况会变得更加复杂。因为虚拟机对于物理机来讲是一个进程,但是虚拟机里面也有内核,也有虚拟机里面跑的进程。所以有了虚拟机,内存就变成了四类:
- **虚拟机里面的虚拟内存**Guest OS Virtual MemoryGVA这是虚拟机里面的进程看到的内存空间
- **虚拟机里面的物理内存**Guest OS Physical MemoryGPA这是虚拟机里面的操作系统看到的内存它认为这是物理内存
- **物理机的虚拟内存**Host Virtual MemoryHVA这是物理机上的qemu进程看到的内存空间
- **物理机的物理内存**Host Physical MemoryHPA这是物理机上的操作系统看到的内存。
咱们内存管理那一章讲的两大内容一个是内存管理它变得非常复杂另一个是内存映射具体来说就是从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, &amp;s-&gt;memory_listener,
&amp;address_space_memory, 0);
memory_listener_register(&amp;kvm_io_listener,
&amp;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-&gt;slots = g_malloc0(s-&gt;nr_slots * sizeof(KVMSlot));
kml-&gt;as_id = as_id;
for (i = 0; i &lt; s-&gt;nr_slots; i++) {
kml-&gt;slots[i].slot = i;
}
kml-&gt;listener.region_add = kvm_region_add;
kml-&gt;listener.region_del = kvm_region_del;
kml-&gt;listener.priority = 10;
memory_listener_register(&amp;kml-&gt;listener, as);
}
```
在这个KVMMemoryListener中是这样配置的当添加一个MemoryRegion的时候region_add会被调用这个我们后面会用到。
接下来在qemu启动的main函数中我们会调用cpu_exec_init_all-&gt;memory_map_init.
```
static void memory_map_init(void)
{
system_memory = g_malloc(sizeof(*system_memory));
memory_region_init(system_memory, NULL, &quot;system&quot;, UINT64_MAX);
address_space_init(&amp;address_space_memory, system_memory, &quot;memory&quot;);
system_io = g_malloc(sizeof(*system_io));
memory_region_init_io(system_io, NULL, &amp;unassigned_io_ops, NULL, &quot;io&quot;,
65536);
address_space_init(&amp;address_space_io, system_io, &quot;I/O&quot;);
}
```
在这里对于系统内存区域system_memory和用于I/O的内存区域system_io我们都进行了初始化并且关联到了相应的地址空间AddressSpace。
```
void address_space_init(AddressSpace *as, MemoryRegion *root, const char *name)
{
memory_region_ref(root);
as-&gt;root = root;
as-&gt;current_map = NULL;
as-&gt;ioeventfd_nb = 0;
as-&gt;ioeventfds = NULL;
QTAILQ_INIT(&amp;as-&gt;listeners);
QTAILQ_INSERT_TAIL(&amp;address_spaces, as, address_spaces_link);
as-&gt;name = g_strdup(name ? name : &quot;anonymous&quot;);
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-&gt;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-&gt;root);
FlatView *new_view = g_hash_table_lookup(flat_views, physmr);
if (old_view == new_view) {
return;
}
......
if (!QTAILQ_EMPTY(&amp;as-&gt;listeners)) {
FlatView tmpview = { .nr = 0 }, *old_view2 = old_view;
if (!old_view2) {
old_view2 = &amp;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(&amp;as-&gt;current_map, new_view);
......
}
```
这里面会生成AddressSpace的flatview。flatview是什么意思呢
我们可以看到在AddressSpace里面除了树形结构的MemoryRegion之外还有一个flatview结构其实这个结构就是把这样一个树形的内存结构变成平的内存结构。因为树形内存结构比较容易管理但是平的内存结构比较方便和内核里面通信来请求物理内存。虽然操作系统内核里面也是用树形结构来表示内存区域的但是用户态向内核申请内存的时候会按照平的、连续的模式进行申请。这里qemu在用户态所以要做这样一个转换。
在address_space_set_flatview中我们将老的flatview和新的flatview进行比较。如果不同说明内存结构发生了变化会调用address_space_update_topology_pass-&gt;MEMORY_LISTENER_UPDATE_REGION-&gt;MEMORY_LISTENER_CALL。
这里面调用所有的listener。但是这个逻辑这里不会执行的。这是因为这里内存处于初始化的阶段全局的flat_views里面肯定找不到。因而generate_memory_topology第一次生成了FlatView然后才调用了address_space_set_flatview。这里面老的flatview和新的flatview一定是一样的。
但是请你记住这个逻辑到这里我们还没解析qemu有关内存的参数所以这里添加的MemoryRegion虽然是一个根但是是空的是为了管理使用的后面真的添加内存的时候这个逻辑还会调用到。
我们再回到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, &quot;pc.ram&quot;,
machine-&gt;ram_size);
*ram_memory = ram;
ram_below_4g = g_malloc(sizeof(*ram_below_4g));
memory_region_init_alias(ram_below_4g, NULL, &quot;ram-below-4g&quot;, ram,
0, pcms-&gt;below_4g_mem_size);
memory_region_add_subregion(system_memory, 0, ram_below_4g);
e820_add_entry(0, pcms-&gt;below_4g_mem_size, E820_RAM);
if (pcms-&gt;above_4g_mem_size &gt; 0) {
ram_above_4g = g_malloc(sizeof(*ram_above_4g));
memory_region_init_alias(ram_above_4g, NULL, &quot;ram-above-4g&quot;, ram, pcms-&gt;below_4g_mem_size, pcms-&gt;above_4g_mem_size);
memory_region_add_subregion(system_memory, 0x100000000ULL,
ram_above_4g);
e820_add_entry(0x100000000ULL, pcms-&gt;above_4g_mem_size, E820_RAM);
}
......
}
```
在pc_memory_init中我们已经知道了虚拟机要申请的内存ram_size于是通过memory_region_allocate_system_memory来申请内存。
接下来的调用链为memory_region_allocate_system_memory-&gt;allocate_system_memory_nonnuma-&gt;memory_region_init_ram_nomigrate-&gt;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-&gt;ram = true;
mr-&gt;terminates = true;
mr-&gt;destructor = memory_region_destructor_ram;
mr-&gt;ram_block = qemu_ram_alloc(size, share, mr, &amp;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-&gt;mr = mr;
new_block-&gt;resized = resized;
new_block-&gt;used_length = size;
new_block-&gt;max_length = max_size;
new_block-&gt;fd = -1;
new_block-&gt;page_size = getpagesize();
new_block-&gt;host = host;
......
ram_block_add(new_block, &amp;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-&gt;offset = find_ram_offset(new_block-&gt;max_length);
if (!new_block-&gt;host) {
new_block-&gt;host = phys_mem_alloc(new_block-&gt;max_length, &amp;new_block-&gt;mr-&gt;align, shared);
......
}
}
......
}
```
这里面我们会调用qemu_ram_alloc创建一个RAMBlock用来表示内存块。这里面调用ram_block_add-&gt;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;
}
```
我们回到pc_memory_init通过memory_region_allocate_system_memory申请到内存以后为了兼容过去的版本我们分成两个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-&gt;memory_region_add_subregion_common-&gt;memory_region_update_container_subregions。
```
static void memory_region_update_container_subregions(MemoryRegion *subregion)
{
MemoryRegion *mr = subregion-&gt;container;
MemoryRegion *other;
memory_region_transaction_begin();
memory_region_ref(subregion);
QTAILQ_FOREACH(other, &amp;mr-&gt;subregions, subregions_link) {
if (subregion-&gt;priority &gt;= other-&gt;priority) {
QTAILQ_INSERT_BEFORE(other, subregion, subregions_link);
goto done;
}
}
QTAILQ_INSERT_TAIL(&amp;mr-&gt;subregions, subregion, subregions_link);
done:
memory_region_update_pending |= mr-&gt;enabled &amp;&amp; subregion-&gt;enabled;
memory_region_transaction_commit();
}
```
在memory_region_update_container_subregions中我们会将子区域放到链表中然后调用memory_region_transaction_commit。在这里面我们会调用address_space_set_flatview。因为内存区域变了flatview也会变就像上面分析过的一样listener会被调用。
因为添加了一个MemoryRegionregion_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-&gt;mr;
bool writeable = !mr-&gt;readonly &amp;&amp; !mr-&gt;rom_device;
hwaddr start_addr, size;
void *ram;
......
size = kvm_align_section(section, &amp;start_addr);
......
/* use aligned delta to align the ram address */
ram = memory_region_get_ram_ptr(mr) + section-&gt;offset_within_region + (start_addr - section-&gt;offset_within_address_space);
......
/* register the new slot */
mem = kvm_alloc_slot(kml);
mem-&gt;memory_size = size;
mem-&gt;start_addr = start_addr;
mem-&gt;ram = ram;
mem-&gt;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-&gt;slot | (kml-&gt;as_id &lt;&lt; 16);
mem.guest_phys_addr = slot-&gt;start_addr;
mem.userspace_addr = (unsigned long)slot-&gt;ram;
mem.flags = slot-&gt;flags;
......
mem.memory_size = slot-&gt;memory_size;
ret = kvm_vm_ioctl(s, KVM_SET_USER_MEMORY_REGION, &amp;mem);
slot-&gt;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-&gt;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(&amp;kvm_userspace_mem, argp,
sizeof(kvm_userspace_mem)))
goto out;
r = kvm_vm_ioctl_set_memory_region(kvm, &amp;kvm_userspace_mem);
break;
}
......
}
```
接下来的调用链为kvm_vm_ioctl_set_memory_region-&gt;kvm_set_memory_region-&gt;__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-&gt;slot &gt;&gt; 16;
id = (u16)mem-&gt;slot;
slot = id_to_memslot(__kvm_memslots(kvm, as_id), id);
base_gfn = mem-&gt;guest_phys_addr &gt;&gt; PAGE_SHIFT;
npages = mem-&gt;memory_size &gt;&gt; PAGE_SHIFT;
......
new = old = *slot;
new.id = id;
new.base_gfn = base_gfn;
new.npages = npages;
new.flags = mem-&gt;flags;
......
if (change == KVM_MR_CREATE) {
new.userspace_addr = mem-&gt;userspace_addr;
if (kvm_arch_create_memslot(kvm, &amp;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, &amp;new, mem, change);
update_memslots(slots, &amp;new);
old_memslots = install_new_memslots(kvm, as_id, slots);
kvm_arch_commit_memory_region(kvm, mem, &amp;old, &amp;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结构中。
至此,用户态的内存结构和内核态的内存结构算是对应了起来。
## 页面分配和映射
上面对于内存的管理,还只是停留在元数据的管理。对于内存的分配与映射,我们还没有涉及,接下来,我们就来看看,页面是如何进行分配和映射的。
上面咱们说了内存映射对于虚拟机来讲是一件非常麻烦的事情从GVA到GPA到HVA到HPA性能很差为了解决这个问题有两种主要的思路。
### 影子页表
第一种方式就是软件的方式,**影子页表** Shadow Page Table
按照咱们在内存管理那一节讲的内存映射要通过页表来管理页表地址应该放在cr3寄存器里面。本来的过程是客户机要通过cr3找到客户机的页表实现从GVA到GPA的转换然后在宿主机上要通过cr3找到宿主机的页表实现从HVA到HPA的转换。
为了实现客户机虚拟地址空间到宿主机物理地址空间的直接映射。客户机中每个进程都有自己的虚拟地址空间所以KVM需要为客户机中的每个进程页表都要维护一套相应的影子页表。
在客户机访问内存时使用的不是客户机的原来的页表而是这个页表对应的影子页表从而实现了从客户机虚拟地址到宿主机物理地址的直接转换。而且在TLB和CPU 缓存上缓存的是来自影子页表中客户机虚拟地址和宿主机物理地址之间的映射,也因此提高了缓存的效率。
但是影子页表的引入也意味着 KVM 需要为每个客户机的每个进程的页表都要维护一套相应的影子页表,内存占用比较大,而且客户机页表和和影子页表也需要进行实时同步。
### 扩展页表
于是就有了第二种方式就是硬件的方式Intel的EPTExtent Page Table扩展页表技术。
EPT在原有客户机页表对客户机虚拟地址到客户机物理地址映射的基础上又引入了 EPT页表来实现客户机物理地址到宿主机物理地址的另一次映射。客户机运行时客户机页表被载入 CR3而EPT页表被载入专门的EPT 页表指针寄存器 EPTP。
有了EPT在客户机物理地址到宿主机物理地址转换的过程中缺页会产生EPT 缺页异常。KVM首先根据引起异常的客户机物理地址映射到对应的宿主机虚拟地址然后为此虚拟地址分配新的物理页最后 KVM 再更新 EPT 页表,建立起引起异常的客户机物理地址到宿主机物理地址之间的映射。
KVM 只需为每个客户机维护一套 EPT 页表,也大大减少了内存的开销。
这里我们重点看第二种方式。因为使用了EPT之后客户机里面的页表映射也即从GVA到GPA的转换还是用传统的方式和在内存管理那一章讲的没有什么区别。而EPT重点帮我们解决的就是从GPA到HPA的转换问题。因为要经过两次页表所以EPT又称为tdptwo dimentional paging
EPT的页表结构也是分为四层EPT Pointer EPTP指向PML4的首地址。
<img src="https://static001.geekbang.org/resource/image/02/30/02e4740398bc3685f366351260ae7230.jpg" alt="">
管理物理页面的Page结构和咱们讲内存管理那一章是一样的。EPT页表也需要存放在一个页中这些页要用kvm_mmu_page这个结构来管理。
当一个虚拟机运行进入客户机模式的时候我们上一节解析过它会调用vcpu_enter_guest函数这里面会调用kvm_mmu_reload-&gt;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-&gt;arch.mmu.set_cr3(vcpu, vcpu-&gt;arch.mmu.root_hpa);
......
}
static int mmu_alloc_roots(struct kvm_vcpu *vcpu)
{
if (vcpu-&gt;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-&gt;arch.mmu.shadow_root_level == PT64_ROOT_LEVEL) {
spin_lock(&amp;vcpu-&gt;kvm-&gt;mmu_lock);
make_mmu_pages_available(vcpu);
sp = kvm_mmu_get_page(vcpu, 0, 0, PT64_ROOT_LEVEL, 1, ACC_ALL);
++sp-&gt;root_count;
spin_unlock(&amp;vcpu-&gt;kvm-&gt;mmu_lock);
vcpu-&gt;arch.mmu.root_hpa = __pa(sp-&gt;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-&gt;arch.gpa_available = true;
vcpu-&gt;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-&gt;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 &gt;&gt; PAGE_SHIFT;
unsigned long mmu_seq;
int write = error_code &amp; PFERR_WRITE_MASK;
bool map_writable;
r = mmu_topup_memory_caches(vcpu);
level = mapping_level(vcpu, gfn, &amp;force_pt_level);
......
if (try_async_pf(vcpu, prefault, gfn, gpa, &amp;pfn, write, &amp;map_writable))
return 0;
if (handle_abnormal_pfn(vcpu, 0, gfn, pfn, ACC_ALL, &amp;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将两者关联起来。
```
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, &amp;async, write, writable);
if (!async)
return false; /* *pfn has correct page already */
if (!prefault &amp;&amp; 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-&gt;arch.mmu.root_hpa))
return 0;
for_each_shadow_entry(vcpu, (u64)gfn &lt;&lt; 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-&gt;stat.pf_fixed;
break;
}
drop_large_spte(vcpu, iterator.sptep);
if (!is_shadow_present_pte(*iterator.sptep)) {
u64 base_addr = iterator.addr;
base_addr &amp;= PT64_LVL_ADDR_MASK(iterator.level);
pseudo_gfn = base_addr &gt;&gt; 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得到保存页表项的页面然后将这一项指向下一级的页表页面。
至此,内存映射就结束了。
## 总结时刻
我们这里来总结一下虚拟机的内存管理也是需要用户态的qemu和内核态的KVM共同完成。为了加速内存映射需要借助硬件的EPT技术。
在用户态qemu中有一个结构AddressSpace address_space_memory来表示虚拟机的系统内存这个内存可能包含多个内存区域struct MemoryRegion组成树形结构指向由mmap分配的虚拟内存。
在AddressSpace结构中有一个struct KVMMemoryListener当有新的内存区域添加的时候会被通知调用kvm_region_add来通知内核。
在用户态qemu中对于虚拟机有一个结构struct KVMState表示这个虚拟机这个结构会指向一个数组的struct KVMSlot表示这个虚拟机的多个内存条KVMSlot中有一个void *ram指针指向mmap分配的那块虚拟内存。
kvm_region_add是通过ioctl来通知内核KVM的会给内核KVM发送一个KVM_SET_USER_MEMORY_REGION消息表示用户态qemu添加了一个内存区域内核KVM也应该添加一个相应的内存区域。
和用户态qemu对应的内核KVM对于虚拟机有一个结构struct kvm表示这个虚拟机这个结构会指向一个数组的struct kvm_memory_slot表示这个虚拟机的多个内存条kvm_memory_slot中有起始页号页面数目表示这个虚拟机的物理内存空间。
虚拟机的物理内存空间里面的页面当然不是一开始就映射到物理页面的只有当虚拟机的内存被访问的时候也即mmap分配的虚拟内存空间被访问的时候先查看EPT页表是否已经映射过如果已经映射过则经过四级页表映射就能访问到物理页面。
如果没有映射过则虚拟机会通过VM-Exit指令回到宿主机模式通过handle_ept_violation补充页表映射。先是通过handle_mm_fault为虚拟机的物理内存空间分配真正的物理页面然后通过__direct_map添加EPT页表映射。
<img src="https://static001.geekbang.org/resource/image/01/9b/0186c533b7ef706df880dfd775c2449b.jpg" alt="">
## 课堂练习
这一节,影子页表我们没有深入去讲,你能自己研究一下,它是如何实现的吗?
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,440 @@
<audio id="audio" title="53 | 存储虚拟化(上):如何建立自己保管的单独档案库?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0c/b5/0cea069cfe1898557329f1b2e780f1b5.mp3"></audio>
前面几节我们讲了CPU和内存的虚拟化。我们知道完全虚拟化是很慢的而通过内核的KVM技术和EPT技术加速虚拟机对于物理CPU和内存的使用我们称为硬件辅助虚拟化。
对于一台虚拟机而言除了要虚拟化CPU和内存存储和网络也需要虚拟化存储和网络都属于外部设备这些外部设备应该如何虚拟化呢
当然一种方式还是完全虚拟化。比如有什么样的硬盘设备或者网卡设备我们就用qemu模拟一个一模一样的软件的硬盘和网卡设备这样在虚拟机里面的操作系统看来使用这些设备和使用物理设备是一样的。当然缺点就是qemu模拟的设备又是一个翻译官的角色。虽然这个时候虚拟机里面的操作系统意识不到自己是运行在虚拟机里面的但是这种每个指令都翻译的方式实在是太慢了。
另外一种方式就是,虚拟机里面的操作系统不是一个通用的操作系统,它知道自己是运行在虚拟机里面的,使用的硬盘设备和网络设备都是虚拟的,应该加载特殊的驱动才能运行。这些特殊的驱动往往要通过虚拟机里面和外面配合工作的模式,来加速对于物理存储和网络设备的使用。
## virtio的基本原理
在虚拟化技术的早期,不同的虚拟化技术会针对不同硬盘设备和网络设备实现不同的驱动,虚拟机里面的操作系统也要根据不同的虚拟化技术和物理存储和网络设备,选择加载不同的驱动。但是,由于硬盘设备和网络设备太多了,驱动纷繁复杂。
后来慢慢就形成了一定的标准,这就是**virtio**,就是**虚拟化I/O设备**的意思。virtio负责对于虚拟机提供统一的接口。也就是说在虚拟机里面的操作系统加载的驱动以后都统一加载virtio就可以了。
<img src="https://static001.geekbang.org/resource/image/1e/33/1e13ffd5ac846c52739291cb489d0233.png" alt="">
在虚拟机外我们可以实现不同的virtio的后端来适配不同的物理硬件设备。那virtio到底长什么样子呢我们一起来看一看。
virtio的架构可以分为四层。
- 首先在虚拟机里面的virtio前端针对不同类型的设备有不同的**驱动程序**但是接口都是统一的。例如硬盘就是virtio_blk网络就是virtio_net。
- 其次在宿主机的qemu里面实现virtio后端的逻辑主要就是**操作硬件的设备**。例如通过写一个物理机硬盘上的文件来完成虚拟机写入硬盘的操作。再如向内核协议栈发送一个网络包完成虚拟机对于网络的操作。
- 在virtio的前端和后端之间有一个通信层里面包含**virtio层**和**virtio-ring层**。virtio这一层实现的是虚拟队列接口算是前后端通信的桥梁。而virtio-ring则是该桥梁的具体实现。
<img src="https://static001.geekbang.org/resource/image/2e/f3/2e9ef612f7b80ec9fcd91e200f4946f3.png" alt="">
virtio使用virtqueue进行前端和后端的高速通信。不同类型的设备队列数目不同。virtio-net使用两个队列一个用于接收另一个用于发送而 virtio-blk仅使用一个队列。
如果客户机要向宿主机发送数据客户机会将数据的buffer添加到virtqueue中然后通过写入寄存器通知宿主机。这样宿主机就可以从virtqueue 中收到的buffer里面的数据。
了解了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(&amp;virtio_blk_info);
}
type_init(virtio_register_types)
```
Virtio Block Device这种类的定义是有多层继承关系的。TYPE_VIRTIO_BLK的父类是TYPE_VIRTIO_DEVICETYPE_VIRTIO_DEVICE的父类是TYPE_DEVICETYPE_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](https://time.geekbang.org/column/article/109335)那一节也有类似的结构。
```
static void virtio_blk_device_realize(DeviceState *dev, Error **errp)
{
VirtIODevice *vdev = VIRTIO_DEVICE(dev);
VirtIOBlock *s = VIRTIO_BLK(dev);
VirtIOBlkConf *conf = &amp;s-&gt;conf;
......
blkconf_blocksizes(&amp;conf-&gt;conf);
virtio_blk_set_config_size(s, s-&gt;host_features);
virtio_init(vdev, &quot;virtio-blk&quot;, VIRTIO_ID_BLOCK, s-&gt;config_size);
s-&gt;blk = conf-&gt;conf.blk;
s-&gt;rq = NULL;
s-&gt;sector_mask = (s-&gt;conf.conf.logical_block_size / BDRV_SECTOR_SIZE) - 1;
for (i = 0; i &lt; conf-&gt;num_queues; i++) {
virtio_add_queue(vdev, conf-&gt;queue_size, virtio_blk_handle_output);
}
virtio_blk_data_plane_create(vdev, conf, &amp;s-&gt;dataplane, &amp;err);
s-&gt;change = qemu_add_vm_change_state_handler(virtio_blk_dma_restart_cb, s);
blk_set_dev_ops(s-&gt;blk, &amp;virtio_block_ops, s);
blk_set_guest_block_size(s-&gt;blk, s-&gt;conf.conf.logical_block_size);
blk_iostatus_enable(s-&gt;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-&gt;query_nvectors ? k-&gt;query_nvectors(qbus-&gt;parent) : 0;
if (nvectors) {
vdev-&gt;vector_queues =
g_malloc0(sizeof(*vdev-&gt;vector_queues) * nvectors);
}
vdev-&gt;device_id = device_id;
vdev-&gt;status = 0;
atomic_set(&amp;vdev-&gt;isr, 0);
vdev-&gt;queue_sel = 0;
vdev-&gt;config_vector = VIRTIO_NO_VECTOR;
vdev-&gt;vq = g_malloc0(sizeof(VirtQueue) * VIRTIO_QUEUE_MAX);
vdev-&gt;vm_running = runstate_is_running();
vdev-&gt;broken = false;
for (i = 0; i &lt; VIRTIO_QUEUE_MAX; i++) {
vdev-&gt;vq[i].vector = VIRTIO_NO_VECTOR;
vdev-&gt;vq[i].vdev = vdev;
vdev-&gt;vq[i].queue_index = i;
}
vdev-&gt;name = name;
vdev-&gt;config_len = config_size;
if (vdev-&gt;config_len) {
vdev-&gt;config = g_malloc0(config_size);
} else {
vdev-&gt;config = NULL;
}
vdev-&gt;vmstate = qemu_add_vm_change_state_handler(virtio_vmstate_change,
vdev);
vdev-&gt;device_endian = virtio_default_endian();
vdev-&gt;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-&gt;vq[i].vring.num = queue_size;
vdev-&gt;vq[i].vring.num_default = queue_size;
vdev-&gt;vq[i].vring.align = VIRTIO_PCI_VRING_ALIGN;
vdev-&gt;vq[i].handle_output = handle_output;
vdev-&gt;vq[i].handle_aio_output = NULL;
return &amp;vdev-&gt;vq[i];
}
```
在每个VirtQueue中都有一个vring用来维护这个队列里面的数据另外还有一个函数virtio_blk_handle_output用于处理数据写入这个函数我们后面会用到。
至此VirtIODeviceVirtQueuevring之间的关系如下图所示。这是在qemu里面的对应关系请你记好后面我们还能看到类似的结构。
<img src="https://static001.geekbang.org/resource/image/e1/6d/e18dae0a5951392c4a8e8630e53a616d.jpg" alt="">
## qemu启动过程中的存储虚拟化
初始化过程解析完毕以后我们接下来从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模拟称为客户机上的一块硬盘就可以了。
而第二行说明了使用的驱动是virtio-blk驱动。
```
configure_blockdev(&amp;bdo_queue, machine_class, snapshot);
```
在qemu启动的main函数里面初始化块设备是通过configure_blockdev调用开始的。
```
static void configure_blockdev(BlockdevOptionsQueue *bdo_queue, MachineClass *machine_class, int snapshot)
{
......
if (qemu_opts_foreach(qemu_find_opts(&quot;drive&quot;), drive_init_func,
&amp;machine_class-&gt;block_default_type, &amp;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(&amp;qemu_legacy_drive_opts, NULL, 0,
&amp;error_abort);
......
/* Add virtio block device */
if (type == IF_VIRTIO) {
QemuOpts *devopts;
devopts = qemu_opts_create(qemu_find_opts(&quot;device&quot;), NULL, 0,
&amp;error_abort);
qemu_opt_set(devopts, &quot;driver&quot;, &quot;virtio-blk-pci&quot;, &amp;error_abort);
qemu_opt_set(devopts, &quot;drive&quot;, qdict_get_str(bs_opts, &quot;id&quot;),
&amp;error_abort);
}
filename = qemu_opt_get(legacy_opts, &quot;file&quot;);
......
/* Actual block device init: Functionality shared with blockdev-add */
blk = blockdev_init(filename, bs_opts, &amp;local_err);
......
/* Create legacy DriveInfo */
dinfo = g_malloc0(sizeof(*dinfo));
dinfo-&gt;opts = all_opts;
dinfo-&gt;type = type;
dinfo-&gt;bus = bus_id;
dinfo-&gt;unit = unit_id;
blk_set_legacy_dinfo(blk, dinfo);
switch(type) {
case IF_IDE:
case IF_SCSI:
case IF_XEN:
case IF_NONE:
dinfo-&gt;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的后端。
```
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-&gt;root = bdrv_root_attach_child(bs, &quot;root&quot;, &amp;child_root,
perm, BLK_PERM_ALL, blk, errp);
return blk;
}
```
接下来的调用链为bdrv_open-&gt;bdrv_open_inherit-&gt;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-&gt;drv = drv;
bs-&gt;read_only = !(bs-&gt;open_flags &amp; BDRV_O_RDWR);
bs-&gt;opaque = g_malloc0(drv-&gt;instance_size);
if (drv-&gt;bdrv_open) {
ret = drv-&gt;bdrv_open(bs, options, open_flags, &amp;local_err);
}
......
}
```
在bdrv_open_common中根据硬盘文件的格式得到BlockDriver。因为虚拟机的硬盘文件格式有很多种qcow2是一种raw是一种vmdk是一种各有优缺点启动虚拟机的时候可以自由选择。
对于不同的格式打开的方式不一样我们拿qcow2来解析。它的BlockDriver定义如下
```
BlockDriver bdrv_qcow2 = {
.format_name = &quot;qcow2&quot;,
.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-&gt;opaque;
QCow2OpenCo qoc = {
.bs = bs,
.options = options,
.flags = flags,
.errp = errp,
.ret = -EINPROGRESS
};
bs-&gt;file = bdrv_open_child(NULL, options, &quot;file&quot;, bs, &amp;child_file,
false, errp);
qemu_coroutine_enter(qemu_coroutine_create(qcow2_open_entry, &amp;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-&gt;bs-&gt;opaque;
qemu_co_mutex_lock(&amp;s-&gt;lock);
qoc-&gt;ret = qcow2_do_open(qoc-&gt;bs, qoc-&gt;options, qoc-&gt;flags, qoc-&gt;errp);
qemu_co_mutex_unlock(&amp;s-&gt;lock);
}
```
我们可以看到qcow2_open_entry函数前面有一个coroutine_fn说明它是一个协程函数。在qcow2_do_open中qcow2_do_open根据qcow2的格式打开硬盘文件。这个格式[官网](https://github.com/qemu/qemu/blob/master/docs/interop/qcow2.txt)就有,我们这里就不花篇幅解析了。
## 总结时刻
我们这里来总结一下,存储虚拟化的过程分为前端、后端和中间的队列。
- 前端有前端的块设备驱动Front-end driver在客户机的内核里面它符合普通设备驱动的格式对外通过VFS暴露文件系统接口给客户机里面的应用。这一部分这一节我们没有讲放在下一节解析。
- 后端有后端的设备驱动Back-end driver在宿主机的qemu进程中当收到客户机的写入请求的时候调用文件系统的write函数写入宿主机的VFS文件系统最终写到物理硬盘设备上的qcow2文件。
- 中间的队列用于前端和后端之间传输数据在前端的设备驱动和后端的设备驱动都有类似的数据结构virt-queue来管理这些队列这一部分这一节我们也没有讲也放到下一节解析。
<img src="https://static001.geekbang.org/resource/image/1f/4b/1f0c3043a11d6ea1a802f7d0f3b0b34b.jpg" alt="">
## 课堂练习
对于qemu-kvm来讲qcow2是一种常见的文件格式。它有精妙的格式设计从而适应虚拟化的场景请你研究一下这个文件格式。
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,925 @@
<audio id="audio" title="54 | 存储虚拟化(下):如何建立自己保管的单独档案库?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/9a/96ae957ba80acda102c44d661ea0be9a.mp3"></audio>
上一节我们讲了qemu启动过程中的存储虚拟化。好了现在qemu启动了硬盘设备文件已经打开了。那如果我们要往虚拟机的一个进程写入一个文件该怎么做呢最终这个文件又是如何落到宿主机上的硬盘文件的呢这一节我们一起来看一看。
## 前端设备驱动virtio_blk
虚拟机里面的进程写入一个文件,当然要通过文件系统。整个过程和咱们在[文件系统](https://time.geekbang.org/column/article/97876)那一节讲的过程没有区别。只是到了设备驱动层我们看到的就不是普通的硬盘驱动了而是virtio的驱动。
virtio的驱动程序代码在Linux操作系统的源代码里面文件名叫drivers/block/virtio_blk.c。
```
static int __init init(void)
{
int error;
virtblk_wq = alloc_workqueue(&quot;virtio-blk&quot;, 0, 0);
major = register_blkdev(0, &quot;virtblk&quot;);
error = register_virtio_driver(&amp;virtio_blk);
......
}
module_init(init);
module_exit(fini);
MODULE_DEVICE_TABLE(virtio, id_table);
MODULE_DESCRIPTION(&quot;Virtio block driver&quot;);
MODULE_LICENSE(&quot;GPL&quot;);
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-&gt;priv = vblk = kmalloc(sizeof(*vblk), GFP_KERNEL);
vblk-&gt;vdev = vdev;
vblk-&gt;sg_elems = sg_elems;
INIT_WORK(&amp;vblk-&gt;config_work, virtblk_config_changed_work);
......
err = init_vq(vblk);
......
vblk-&gt;disk = alloc_disk(1 &lt;&lt; PART_BITS);
memset(&amp;vblk-&gt;tag_set, 0, sizeof(vblk-&gt;tag_set));
vblk-&gt;tag_set.ops = &amp;virtio_mq_ops;
vblk-&gt;tag_set.queue_depth = virtblk_queue_depth;
vblk-&gt;tag_set.numa_node = NUMA_NO_NODE;
vblk-&gt;tag_set.flags = BLK_MQ_F_SHOULD_MERGE;
vblk-&gt;tag_set.cmd_size =
sizeof(struct virtblk_req) +
sizeof(struct scatterlist) * sg_elems;
vblk-&gt;tag_set.driver_data = vblk;
vblk-&gt;tag_set.nr_hw_queues = vblk-&gt;num_vqs;
err = blk_mq_alloc_tag_set(&amp;vblk-&gt;tag_set);
......
q = blk_mq_init_queue(&amp;vblk-&gt;tag_set);
vblk-&gt;disk-&gt;queue = q;
q-&gt;queuedata = vblk;
virtblk_name_format(&quot;vd&quot;, index, vblk-&gt;disk-&gt;disk_name, DISK_NAME_LEN);
vblk-&gt;disk-&gt;major = major;
vblk-&gt;disk-&gt;first_minor = index_to_minor(index);
vblk-&gt;disk-&gt;private_data = vblk;
vblk-&gt;disk-&gt;fops = &amp;virtblk_fops;
vblk-&gt;disk-&gt;flags |= GENHD_FL_EXT_DEVT;
vblk-&gt;index = index;
......
device_add_disk(&amp;vdev-&gt;dev, vblk-&gt;disk);
err = device_create_file(disk_to_dev(vblk-&gt;disk), &amp;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-&gt;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-&gt;vdev;
......
vblk-&gt;vqs = kmalloc_array(num_vqs, sizeof(*vblk-&gt;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 &lt; num_vqs; i++) {
callbacks[i] = virtblk_done;
names[i] = vblk-&gt;vqs[i].name;
}
/* Discover virtqueues and write information to configuration. */
err = virtio_find_vqs(vdev, num_vqs, vqs, callbacks, names, &amp;desc);
for (i = 0; i &lt; num_vqs; i++) {
vblk-&gt;vqs[i].vq = vqs[i];
}
vblk-&gt;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-&gt;config-&gt;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, &amp;vdev-&gt;vqs, list) {
vp_iowrite16(vq-&gt;index, &amp;vp_dev-&gt;common-&gt;queue_select);
vp_iowrite16(1, &amp;vp_dev-&gt;common-&gt;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-&gt;vqs = kcalloc(nvqs, sizeof(*vp_dev-&gt;vqs), GFP_KERNEL);
err = request_irq(vp_dev-&gt;pci_dev-&gt;irq, vp_interrupt, IRQF_SHARED,
dev_name(&amp;vdev-&gt;dev), vp_dev);
vp_dev-&gt;intx_enabled = 1;
vp_dev-&gt;per_vq_vectors = false;
for (i = 0; i &lt; 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-&gt;setup_vq(vp_dev, info, index, callback, name, ctx,
msix_vec);
info-&gt;vq = vq;
if (callback) {
spin_lock_irqsave(&amp;vp_dev-&gt;lock, flags);
list_add(&amp;info-&gt;node, &amp;vp_dev-&gt;virtqueues);
spin_unlock_irqrestore(&amp;vp_dev-&gt;lock, flags);
} else {
INIT_LIST_HEAD(&amp;info-&gt;node);
}
vp_dev-&gt;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-&gt;common;
struct virtqueue *vq;
u16 num, off;
int err;
/* Select the queue we're interested in */
vp_iowrite16(index, &amp;cfg-&gt;queue_select);
/* Check if queue is either not available or already active. */
num = vp_ioread16(&amp;cfg-&gt;queue_size);
/* get offset of notification word for this vq */
off = vp_ioread16(&amp;cfg-&gt;queue_notify_off);
info-&gt;msix_vector = msix_vec;
/* create the vring */
vq = vring_create_virtqueue(index, num,
SMP_CACHE_BYTES, &amp;vp_dev-&gt;vdev,
true, true, ctx,
vp_notify, callback, name);
/* activate the queue */
vp_iowrite16(virtqueue_get_vring_size(vq), &amp;cfg-&gt;queue_size);
vp_iowrite64_twopart(virtqueue_get_desc_addr(vq),
&amp;cfg-&gt;queue_desc_lo, &amp;cfg-&gt;queue_desc_hi);
vp_iowrite64_twopart(virtqueue_get_avail_addr(vq),
&amp;cfg-&gt;queue_avail_lo, &amp;cfg-&gt;queue_avail_hi);
vp_iowrite64_twopart(virtqueue_get_used_addr(vq),
&amp;cfg-&gt;queue_used_lo, &amp;cfg-&gt;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 &amp;&amp; vring_size(num, vring_align) &gt; PAGE_SIZE; num /= 2) {
queue = vring_alloc_queue(vdev, vring_size(num, vring_align),
&amp;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),
&amp;dma_addr, GFP_KERNEL|__GFP_ZERO);
}
queue_size_in_bytes = vring_size(num, vring_align);
vring_init(&amp;vring, num, queue, vring_align);
vq = __vring_new_virtqueue(index, vring, vdev, weak_barriers, context, notify, callback, name);
to_vvq(vq)-&gt;queue_dma_addr = dma_addr;
to_vvq(vq)-&gt;queue_size_in_bytes = queue_size_in_bytes;
to_vvq(vq)-&gt;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。
## 中间virtio队列的管理
还记不记得我们上面讲qemu初始化的时候virtio的后端有数据结构VirtIODeviceVirtQueue和vring一模一样前端和后端对应起来都应该指向刚才创建的那一段内存。
现在的问题是我们刚才分配的内存在客户机的内核里面如何告知qemu来访问这段内存呢
别忘了qemu模拟出来的virtio block device只是一个PCI设备。对于客户机来讲这是一个外部设备我们可以通过给外部设备发送指令的方式告知外部设备这就是代码中vp_iowrite16的作用。它会调用专门给外部设备发送指令的函数iowrite告诉外部的PCI设备。
告知的有三个地址virtqueue_get_desc_addr、virtqueue_get_avail_addrvirtqueue_get_used_addr。从客户机角度来看这里面的地址都是物理地址也即GPAGuest 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(&amp;proxy-&gt;bar, OBJECT(proxy),
&amp;virtio_pci_config_ops,
proxy, &quot;virtio-pci&quot;, 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-&gt;virtio_ioport_write-&gt;virtio_queue_set_addr。设置virtio的queue的地址是一项很重要的操作。
```
void virtio_queue_set_addr(VirtIODevice *vdev, int n, hwaddr addr)
{
vdev-&gt;vq[n].vring.desc = addr;
virtio_queue_update_rings(vdev, n);
}
```
从这里我们可以看出qemu后端的VirtIODevice的VirtQueue的vring的地址被设置成了刚才给队列分配的内存的GPA。
<img src="https://static001.geekbang.org/resource/image/25/d0/2572f8b1e75b9eaab6560866fcb31fd0.jpg" alt="">
接着,我们来看一下这个队列的格式。
<img src="https://static001.geekbang.org/resource/image/49/db/49414d5acc81933b66410bbba102b0db.jpg" alt="">
```
/* Virtio ring descriptors: 16 bytes. These can chain together via &quot;next&quot;. */
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-&gt;ring[]是发送端维护的环形队列指向需要接收端处理的vring_desc。
- used-&gt;ring[]是接收端维护的环形队列指向自己已经处理过了的vring_desc。
## 数据写入的流程
接下来,我们来看,真的写入一个数据的时候,会发生什么。
按照上面virtio驱动初始化的时候的逻辑blk_mq_make_request会被调用。这个函数比较复杂会分成多个分支但是最终都会调用到request_queue的virtio_mq_ops的queue_rq函数。
```
struct request_queue *q = rq-&gt;q;
q-&gt;mq_ops-&gt;queue_rq(hctx, &amp;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-&gt;queue-&gt;queuedata;
struct request *req = bd-&gt;rq;
struct virtblk_req *vbr = blk_mq_rq_to_pdu(req);
......
err = virtblk_add_req(vblk-&gt;vqs[qid].vq, vbr, vbr-&gt;sg, num);
......
if (notify)
virtqueue_notify(vblk-&gt;vqs[qid].vq);
return BLK_STS_OK;
}
```
在virtio_queue_rq中我们会将请求写入的数据通过virtblk_add_req放入struct virtqueue。
因此接下来的调用链为virtblk_add_req-&gt;virtqueue_add_sgs-&gt;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-&gt;free_head;
indirect = false;
desc = vq-&gt;vring.desc;
i = head;
descs_used = total_sg;
for (n = 0; n &lt; 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-&gt;vdev, VRING_DESC_F_NEXT);
desc[i].addr = cpu_to_virtio64(_vq-&gt;vdev, addr);
desc[i].len = cpu_to_virtio32(_vq-&gt;vdev, sg-&gt;length);
prev = i;
i = virtio16_to_cpu(_vq-&gt;vdev, desc[i].next);
}
}
/* Last one doesn't continue. */
desc[prev].flags &amp;= cpu_to_virtio16(_vq-&gt;vdev, ~VRING_DESC_F_NEXT);
/* We're using some buffers from the free list. */
vq-&gt;vq.num_free -= descs_used;
/* Update free pointer */
vq-&gt;free_head = i;
/* Store token and indirect buffer state. */
vq-&gt;desc_state[head].data = data;
/* Put entry in available array (but don't update avail-&gt;idx until they do sync). */
avail = vq-&gt;avail_idx_shadow &amp; (vq-&gt;vring.num - 1);
vq-&gt;vring.avail-&gt;ring[avail] = cpu_to_virtio16(_vq-&gt;vdev, head);
/* Descriptors and available array need to be set before we expose the new available array entries. */
virtio_wmb(vq-&gt;weak_barriers);
vq-&gt;avail_idx_shadow++;
vq-&gt;vring.avail-&gt;idx = cpu_to_virtio16(_vq-&gt;vdev, vq-&gt;avail_idx_shadow);
vq-&gt;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 &amp; (vq-&gt;vring.num - 1)其中avail_idx_shadow是上一次的avail的位置。这里如果超过了ring[]数组的下标则重新跳到起始位置就说明是一个环。这次分配的新的avail的位置就存放新写入的从head到i之间的内存块。然后是avail_idx_shadow++,这说明这一块内存可以被接收方读取了。
接下来我们回到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-&gt;index, (void __iomem *)vq-&gt;priv);
return true;
}
```
然后我们写入一个I/O会触发VM exit。我们在解析CPU的时候看到过这个逻辑。
```
int kvm_cpu_exec(CPUState *cpu)
{
struct kvm_run *run = cpu-&gt;kvm_run;
int ret, run_ret;
......
run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);
......
switch (run-&gt;exit_reason) {
case KVM_EXIT_IO:
DPRINTF(&quot;handle_io\n&quot;);
/* Called outside BQL */
kvm_handle_io(run-&gt;io.port, attrs,
(uint8_t *)run + run-&gt;io.data_offset,
run-&gt;io.direction,
run-&gt;io.size,
run-&gt;io.count);
ret = 0;
break;
}
......
}
```
这次写入的也是一个I/O的内存空间同样会触发virtio_ioport_write这次会调用virtio_queue_notify。
```
void virtio_queue_notify(VirtIODevice *vdev, int n)
{
VirtQueue *vq = &amp;vdev-&gt;vq[n];
......
if (vq-&gt;handle_aio_output) {
event_notifier_set(&amp;vq-&gt;host_notifier);
} else if (vq-&gt;handle_output) {
vq-&gt;handle_output(vdev, vq);
}
}
```
virtio_queue_notify会调用VirtQueue的handle_output函数前面我们已经设置过这个函数了是virtio_blk_handle_output。
接下来的调用链为virtio_blk_handle_output-&gt;virtio_blk_handle_output_do-&gt;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, &amp;mrb)) {
virtqueue_detach_element(req-&gt;vq, &amp;req-&gt;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-&gt;blk, &amp;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-&gt;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-&gt;vring.num;
i = head;
caches = vring_get_region_caches(vq);
desc_cache = &amp;caches-&gt;desc;
vring_desc_read(vdev, &amp;desc, desc_cache, i);
......
/* Collect all the descriptors */
do {
bool map_ok;
if (desc.flags &amp; VRING_DESC_F_WRITE) {
map_ok = virtqueue_map_desc(vdev, &amp;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, &amp;out_num, addr, iov,
VIRTQUEUE_MAX_SIZE, false,
desc.addr, desc.len);
}
......
rc = virtqueue_read_next_desc(vdev, &amp;desc, desc_cache, max, &amp;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-&gt;index = head;
for (i = 0; i &lt; out_num; i++) {
elem-&gt;out_addr[i] = addr[i];
elem-&gt;out_sg[i] = iov[i];
}
for (i = 0; i &lt; in_num; i++) {
elem-&gt;in_addr[i] = addr[out_num + i];
elem-&gt;in_sg[i] = iov[out_num + i];
}
vq-&gt;inuse++;
......
return elem;
}
```
我们可以看到virtio_blk_get_request会调用virtqueue_pop。在这里面我们能看到对于vring的操作也即从这里面将客户机里面写入的数据读取出来放到VirtIOBlockReq结构中。
接下来我们就要调用virtio_blk_handle_request处理这些数据。所以接下来的调用链为virtio_blk_handle_request-&gt;virtio_blk_submit_multireq-&gt;submit_requests。
```
static inline void submit_requests(BlockBackend *blk, MultiReqBuffer *mrb,int start, int num_reqs, int niov)
{
QEMUIOVector *qiov = &amp;mrb-&gt;reqs[start]-&gt;qiov;
int64_t sector_num = mrb-&gt;reqs[start]-&gt;sector_num;
bool is_write = mrb-&gt;is_write;
if (num_reqs &gt; 1) {
int i;
struct iovec *tmp_iov = qiov-&gt;iov;
int tmp_niov = qiov-&gt;niov;
qemu_iovec_init(qiov, niov);
for (i = 0; i &lt; tmp_niov; i++) {
qemu_iovec_add(qiov, tmp_iov[i].iov_base, tmp_iov[i].iov_len);
}
for (i = start + 1; i &lt; start + num_reqs; i++) {
qemu_iovec_concat(qiov, &amp;mrb-&gt;reqs[i]-&gt;qiov, 0,
mrb-&gt;reqs[i]-&gt;qiov.size);
mrb-&gt;reqs[i - 1]-&gt;mr_next = mrb-&gt;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 &lt;&lt; BDRV_SECTOR_BITS, qiov, 0,
virtio_blk_rw_complete, mrb-&gt;reqs[start]);
} else {
blk_aio_preadv(blk, sector_num &lt;&lt; BDRV_SECTOR_BITS, qiov, 0,
virtio_blk_rw_complete, mrb-&gt;reqs[start]);
}
}
```
在submit_requests中我们看到了BlockBackend。这是在qemu启动的时候打开qcow2文件的时候生成的现在我们可以用它来写入文件了调用的是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-&gt;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(&amp;blk_aio_em_aiocb_info, blk, cb, opaque);
acb-&gt;rwco = (BlkRwCo) {
.blk = blk,
.offset = offset,
.iobuf = iobuf,
.flags = flags,
.ret = NOT_DONE,
};
acb-&gt;bytes = bytes;
acb-&gt;has_returned = false;
co = qemu_coroutine_create(co_entry, acb);
bdrv_coroutine_enter(blk_bs(blk), co);
acb-&gt;has_returned = true;
return &amp;acb-&gt;common;
}
```
在blk_aio_pwritev中我们看到又是创建了一个协程来进行写入。写入完毕之后调用virtio_blk_rw_complete-&gt;virtio_blk_req_complete。
```
static void virtio_blk_req_complete(VirtIOBlockReq *req, unsigned char status)
{
VirtIOBlock *s = req-&gt;dev;
VirtIODevice *vdev = VIRTIO_DEVICE(s);
trace_virtio_blk_req_complete(vdev, req, status);
stb_p(&amp;req-&gt;in-&gt;status, status);
virtqueue_push(req-&gt;vq, &amp;req-&gt;elem, req-&gt;in_len);
virtio_notify(vdev, req-&gt;vq);
}
```
在virtio_blk_req_complete中我们先是调用virtqueue_push更新vring中used变量表示这部分已经写入完毕空间可以回收利用了。但是这部分的改变仅仅改变了qemu后端的vring我们还需要通知客户机中virtio前端的vring的值因而要调用virtio_notify。virtio_notify会调用virtio_irq发送一个中断。
还记得咱们前面注册过一个中断处理函数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-&gt;isr);
/* Configuration change? Tell driver if it wants to know. */
if (isr &amp; VIRTIO_PCI_ISR_CONFIG)
vp_config_changed(irq, opaque);
return vp_vring_interrupt(irq, opaque);
}
```
就像前面说的一样vp_interrupt这个中断处理函数一是处理配置变化二是处理I/O结束。第二种的调用链为vp_interrupt-&gt;vp_vring_interrupt-&gt;vring_interrupt。
```
irqreturn_t vring_interrupt(int irq, void *_vq)
{
struct vring_virtqueue *vq = to_vvq(_vq);
......
if (vq-&gt;vq.callback)
vq-&gt;vq.callback(&amp;vq-&gt;vq);
return IRQ_HANDLED;
}
```
在vring_interrupt中我们会调用callback函数这个也是在前面注册过的是virtblk_done。
接下来的调用链为virtblk_done-&gt;virtqueue_get_buf-&gt;virtqueue_get_buf_ctx。
```
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-&gt;last_used_idx &amp; (vq-&gt;vring.num - 1));
i = virtio32_to_cpu(_vq-&gt;vdev, vq-&gt;vring.used-&gt;ring[last_used].id);
*len = virtio32_to_cpu(_vq-&gt;vdev, vq-&gt;vring.used-&gt;ring[last_used].len);
......
/* detach_buf clears data, so grab it now. */
ret = vq-&gt;desc_state[i].data;
detach_buf(vq, i, ctx);
vq-&gt;last_used_idx++;
......
return ret;
}
```
在virtqueue_get_buf_ctx中我们可以看到virtio前端的vring中的last_used_idx加一说明这块数据qemu后端已经消费完毕。我们可以通过detach_buf将其放入空闲队列中留给以后的写入请求使用。
至此,整个存储虚拟化的写入流程才全部完成。
## 总结时刻
下面我们来总结一下存储虚拟化的场景下,整个写入的过程。
- 在虚拟机里面应用层调用write系统调用写入文件。
- write系统调用进入虚拟机里面的内核经过VFS通用块设备层I/O调度层到达块设备驱动。
- 虚拟机里面的块设备驱动是virtio_blk它和通用的块设备驱动一样有一个request queue另外有一个函数make_request_fn会被设置为blk_mq_make_request这个函数用于将请求放入队列。
- 虚拟机里面的块设备驱动是virtio_blk会注册一个中断处理函数vp_interrupt。当qemu写入完成之后它会通知虚拟机里面的块设备驱动。
- blk_mq_make_request最终调用virtqueue_add将请求添加到传输队列virtqueue中然后调用virtqueue_notify通知qemu。
- 在qemu中本来虚拟机正处于KVM_RUN的状态也即处于客户机状态。
- qemu收到通知后通过VM exit指令退出客户机状态进入宿主机状态根据退出原因得知有I/O需要处理。
- qemu调用virtio_blk_handle_output最终调用virtio_blk_handle_vq。
- virtio_blk_handle_vq里面有一个循环在循环中virtio_blk_get_request函数从传输队列中拿出请求然后调用virtio_blk_handle_request处理请求。
- virtio_blk_handle_request会调用blk_aio_pwritev通过BlockBackend驱动写入qcow2文件。
- 写入完毕之后virtio_blk_req_complete会调用virtio_notify通知虚拟机里面的驱动。数据写入完成刚才注册的中断处理函数vp_interrupt会收到这个通知。
<img src="https://static001.geekbang.org/resource/image/79/0c/79ad143a3149ea36bc80219940d7d00c.jpg" alt="">
## 课堂练习
请你沿着代码仔细分析并牢记virtqueue的结构以及写入和读取方式。这个结构在下面的网络传输过程中还要起大作用。
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,927 @@
<audio id="audio" title="55 | 网络虚拟化:如何成立独立的合作部?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/17/dc/17dd6491d3f574bceea1b2d7ce0029dc.mp3"></audio>
上一节,我们讲了存储虚拟化,这一节我们来讲网络虚拟化。
网络虚拟化有和存储虚拟化类似的地方例如它们都是基于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(&amp;virtio_net_info);
}
type_init(virtio_register_types)
```
Virtio Network Device这种类的定义是有多层继承关系的TYPE_VIRTIO_NET的父类是TYPE_VIRTIO_DEVICETYPE_VIRTIO_DEVICE的父类是TYPE_DEVICETYPE_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, &quot;virtio-net&quot;, VIRTIO_ID_NET, n-&gt;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-&gt;net_conf.rx_queue_size &lt; VIRTIO_NET_RX_QUEUE_MIN_SIZE ||
n-&gt;net_conf.rx_queue_size &gt; VIRTQUEUE_MAX_SIZE ||
!is_power_of_2(n-&gt;net_conf.rx_queue_size)) {
......
return;
}
if (n-&gt;net_conf.tx_queue_size &lt; VIRTIO_NET_TX_QUEUE_MIN_SIZE ||
n-&gt;net_conf.tx_queue_size &gt; VIRTQUEUE_MAX_SIZE ||
!is_power_of_2(n-&gt;net_conf.tx_queue_size)) {
......
return;
}
n-&gt;max_queues = MAX(n-&gt;nic_conf.peers.queues, 1);
if (n-&gt;max_queues * 2 + 1 &gt; VIRTIO_QUEUE_MAX) {
......
return;
}
n-&gt;vqs = g_malloc0(sizeof(VirtIONetQueue) * n-&gt;max_queues);
n-&gt;curr_queues = 1;
......
n-&gt;net_conf.tx_queue_size = MIN(virtio_net_max_tx_queue_size(n),
n-&gt;net_conf.tx_queue_size);
for (i = 0; i &lt; n-&gt;max_queues; i++) {
virtio_net_add_queue(n, i);
}
n-&gt;ctrl_vq = virtio_add_queue(vdev, 64, virtio_net_handle_ctrl);
qemu_macaddr_default_if_unset(&amp;n-&gt;nic_conf.macaddr);
memcpy(&amp;n-&gt;mac[0], &amp;n-&gt;nic_conf.macaddr, sizeof(n-&gt;mac));
n-&gt;status = VIRTIO_NET_S_LINK_UP;
if (n-&gt;netclient_type) {
n-&gt;nic = qemu_new_nic(&amp;net_virtio_info, &amp;n-&gt;nic_conf,
n-&gt;netclient_type, n-&gt;netclient_name, n);
} else {
n-&gt;nic = qemu_new_nic(&amp;net_virtio_info, &amp;n-&gt;nic_conf,
object_get_typename(OBJECT(dev)), dev-&gt;id, n);
}
......
}
```
这里面创建了一个VirtIODevice这一点和存储虚拟化也是一样的。virtio_init用来初始化这个设备。VirtIODevice结构里面有一个VirtQueue数组这就是virtio前端和后端互相传数据的队列最多有VIRTIO_QUEUE_MAX个。
刚才我们说的都是一样的地方,其实也有不一样的地方,我们下面来看。
你会发现这里面有这样的语句n-&gt;max_queues * 2 + 1 &gt; VIRTIO_QUEUE_MAX。为什么要乘以2呢这是因为对于网络设备来讲应该分发送队列和接收队列两个方向所以乘以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-&gt;vqs[index].rx_vq = virtio_add_queue(vdev, n-&gt;net_conf.rx_queue_size, virtio_net_handle_rx);
......
n-&gt;vqs[index].tx_vq = virtio_add_queue(vdev, n-&gt;net_conf.tx_queue_size, virtio_net_handle_tx_bh);
n-&gt;vqs[index].tx_bh = qemu_bh_new(virtio_net_tx_bh, &amp;n-&gt;vqs[index]);
n-&gt;vqs[index].n = n;
}
```
每个VirtQueue中都有一个vring用来维护这个队列里面的数据另外还有函数virtio_net_handle_rx用于处理网络包的接收函数virtio_net_handle_tx_bh用于网络包的发送这个函数我们后面会用到。
```
NICState *qemu_new_nic(NetClientInfo *info,
NICConf *conf,
const char *model,
const char *name,
void *opaque)
{
NetClientState **peers = conf-&gt;peers.ncs;
NICState *nic;
int i, queues = MAX(1, conf-&gt;peers.queues);
......
nic = g_malloc0(info-&gt;size + sizeof(NetClientState) * queues);
nic-&gt;ncs = (void *)nic + info-&gt;size;
nic-&gt;conf = conf;
nic-&gt;opaque = opaque;
for (i = 0; i &lt; queues; i++) {
qemu_net_client_setup(&amp;nic-&gt;ncs[i], info, peers[i], model, name, NULL);
nic-&gt;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-&gt;info = info;
nc-&gt;model = g_strdup(model);
if (name) {
nc-&gt;name = g_strdup(name);
} else {
nc-&gt;name = assign_name(nc, model);
}
QTAILQ_INSERT_TAIL(&amp;net_clients, nc, next);
nc-&gt;incoming_queue = qemu_new_net_queue(qemu_deliver_packet_iov, nc);
nc-&gt;destructor = destructor;
QTAILQ_INIT(&amp;nc-&gt;filters);
}
```
接下来qemu_new_nic会创建一个虚拟机里面的网卡。
## qemu的启动过程中的网络虚拟化
初始化过程解析完毕以后我们接下来从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参数也可以在net_init_clients中解析netdev参数。
```
int net_init_clients(Error **errp)
{
QTAILQ_INIT(&amp;net_clients);
if (qemu_opts_foreach(qemu_find_opts(&quot;netdev&quot;),
net_init_netdev, NULL, errp)) {
return -1;
}
if (qemu_opts_foreach(qemu_find_opts(&quot;nic&quot;), net_param_nic, NULL, errp)) {
return -1;
}
if (qemu_opts_foreach(qemu_find_opts(&quot;net&quot;), net_init_client, NULL, errp)) {
return -1;
}
return 0;
}
```
net_init_clients会解析参数。上面的参数netdev会调用net_init_netdev-&gt;net_client_init-&gt;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-&gt;net_tap_init-&gt;tap_open。
```
#define PATH_NET_TUN &quot;/dev/net/tun&quot;
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(&amp;ifr, 0, sizeof(ifr));
ifr.ifr_flags = IFF_TAP | IFF_NO_PI;
if (ioctl(fd, TUNGETFEATURES, &amp;features) == -1) {
features = 0;
}
if (features &amp; IFF_ONE_QUEUE) {
ifr.ifr_flags |= IFF_ONE_QUEUE;
}
if (*vnet_hdr) {
if (features &amp; IFF_VNET_HDR) {
*vnet_hdr = 1;
ifr.ifr_flags |= IFF_VNET_HDR;
} else {
*vnet_hdr = 0;
}
ioctl(fd, TUNSETVNETHDRSZ, &amp;len);
}
......
ret = ioctl(fd, TUNSETIFF, (void *) &amp;ifr);
......
fcntl(fd, F_SETFL, O_NONBLOCK);
return fd;
}
```
在tap_open中我们打开一个文件"/dev/net/tun"然后通过ioctl操作这个文件。这是Linux内核的一项机制和KVM机制很像。其实这就是一种通过打开这个字符设备文件然后通过ioctl操作这个文件和内核打交道来使用内核的能力。
<img src="https://static001.geekbang.org/resource/image/24/d3/243e93913b18c3ab00be5676bef334d3.png" alt="">
为什么需要使用内核的机制呢?因为网络包需要从虚拟机里面发送到虚拟机外面,发送到宿主机上的时候,必须是一个正常的网络包才能被转发。要形成一个网络包,我们那就需要经过复杂的协议栈,协议栈的复杂咱们在[发送网络包](https://time.geekbang.org/column/article/106490)那一节讲过了。
客户机会将网络包发送给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(&quot;GPL&quot;);
MODULE_ALIAS_MISCDEV(TUN_MINOR);
MODULE_ALIAS(&quot;devname:net/tun&quot;);
static int __init tun_init(void)
{
......
ret = rtnl_link_register(&amp;tun_link_ops);
......
ret = misc_register(&amp;tun_miscdev);
......
ret = register_netdevice_notifier(&amp;tun_notifier_block);
......
}
```
这里面注册了一个tun_miscdev字符设备从它的定义可以看出这就是"/dev/net/tun"字符设备。
```
static struct miscdevice tun_miscdev = {
.minor = TUN_MINOR,
.name = &quot;tun&quot;,
.nodename = &quot;net/tun&quot;,
.fops = &amp;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,
&amp;tun_proto, 0);
RCU_INIT_POINTER(tfile-&gt;tun, NULL);
tfile-&gt;flags = 0;
tfile-&gt;ifindex = 0;
init_waitqueue_head(&amp;tfile-&gt;wq.wait);
RCU_INIT_POINTER(tfile-&gt;socket.wq, &amp;tfile-&gt;wq);
tfile-&gt;socket.file = file;
tfile-&gt;socket.ops = &amp;tun_socket_ops;
sock_init_data(&amp;tfile-&gt;socket, &amp;tfile-&gt;sk);
tfile-&gt;sk.sk_write_space = tun_sock_write_space;
tfile-&gt;sk.sk_sndbuf = INT_MAX;
file-&gt;private_data = tfile;
INIT_LIST_HEAD(&amp;tfile-&gt;next);
sock_set_flag(&amp;tfile-&gt;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因为要用到内核的网络协议栈所以就需要这两个结构这在[网络协议](https://time.geekbang.org/column/article/105338)那一节已经分析过了。
所以按照struct tun_file的注释说的这是一个很重要的数据结构。"/dev/net/tun"对应的struct file的private_data指向它因而可以接收qemu发过来的数据。除此之外它还可以通过struct sock来操作内核协议栈然后将网络包从宿主机上的tuntap网络设备发出去宿主机上的tuntap网络设备对应的struct net_device也归它管。
在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-&gt;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(&amp;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(&amp;tfile-&gt;sk), file, &amp;ifr);
......
if (copy_to_user(argp, &amp;ifr, ifreq_len))
ret = -EFAULT;
}
......
}
```
在__tun_chr_ioctl中我们首先通过copy_from_user把配置从用户态拷贝到内核态调用tun_set_iff设置tuntap网络设备然后调用copy_to_user将配置结果返回。
```
static int tun_set_iff(struct net *net, struct file *file, struct ifreq *ifr)
{
struct tun_struct *tun;
struct tun_file *tfile = file-&gt;private_data;
struct net_device *dev;
......
char *name;
unsigned long flags = 0;
int queues = ifr-&gt;ifr_flags &amp; IFF_MULTI_QUEUE ?
MAX_TAP_QUEUES : 1;
if (ifr-&gt;ifr_flags &amp; IFF_TUN) {
/* TUN device */
flags |= IFF_TUN;
name = &quot;tun%d&quot;;
} else if (ifr-&gt;ifr_flags &amp; IFF_TAP) {
/* TAP device */
flags |= IFF_TAP;
name = &quot;tap%d&quot;;
} else
return -EINVAL;
if (*ifr-&gt;ifr_name)
name = ifr-&gt;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-&gt;rtnl_link_ops = &amp;tun_link_ops;
dev-&gt;ifindex = tfile-&gt;ifindex;
dev-&gt;sysfs_groups[0] = &amp;tun_attr_group;
tun = netdev_priv(dev);
tun-&gt;dev = dev;
tun-&gt;flags = flags;
tun-&gt;txflt.count = 0;
tun-&gt;vnet_hdr_sz = sizeof(struct virtio_net_hdr);
tun-&gt;align = NET_SKB_PAD;
tun-&gt;filter_attached = false;
tun-&gt;sndbuf = tfile-&gt;socket.sk-&gt;sk_sndbuf;
tun-&gt;rx_batched = 0;
tun_net_init(dev);
tun_flow_init(tun);
err = tun_attach(tun, file, false);
err = register_netdevice(tun-&gt;dev);
netif_carrier_on(tun-&gt;dev);
if (netif_running(tun-&gt;dev))
netif_tx_wake_all_queues(tun-&gt;dev);
strcpy(ifr-&gt;ifr_name, tun-&gt;dev-&gt;name);
return 0;
}
```
tun_set_iff创建了struct tun_struct和struct net_device并且将这个tuntap网络设备通过register_netdevice注册到内核中。这样我们就能在宿主机上通过ip addr看到这个网卡了。
<img src="https://static001.geekbang.org/resource/image/98/fd/9826223c7375bec19bd13588f3875ffd.png" alt="">
至此宿主机上的内核的数据结构也完成了。
## 关联前端设备驱动和后端设备驱动
下面,我们来解析在客户机中发送一个网络包的时候,会发生哪些事情。
虚拟机里面的进程发送一个网络包通过文件系统和Socket调用网络协议栈到达网络设备层。只不过这个不是普通的网络设备而是virtio_net的驱动。
virtio_net的驱动程序代码在Linux操作系统的源代码里面文件名为drivers/net/virtio_net.c。
```
static __init int virtio_net_driver_init(void)
{
ret = register_virtio_driver(&amp;virtio_net_driver);
......
}
module_init(virtio_net_driver_init);
module_exit(virtio_net_driver_exit);
MODULE_DEVICE_TABLE(virtio, id_table);
MODULE_DESCRIPTION(&quot;Virtio network driver&quot;);
MODULE_LICENSE(&quot;GPL&quot;);
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-&gt;priv_flags |= IFF_UNICAST_FLT | IFF_LIVE_ADDR_CHANGE;
dev-&gt;netdev_ops = &amp;virtnet_netdev;
dev-&gt;features = NETIF_F_HIGHDMA;
dev-&gt;ethtool_ops = &amp;virtnet_ethtool_ops;
SET_NETDEV_DEV(dev, &amp;vdev-&gt;dev);
......
/* MTU range: 68 - 65535 */
dev-&gt;min_mtu = MIN_MTU;
dev-&gt;max_mtu = MAX_MTU;
/* Set up our device-specific information */
vi = netdev_priv(dev);
vi-&gt;dev = dev;
vi-&gt;vdev = vdev;
vdev-&gt;priv = vi;
vi-&gt;stats = alloc_percpu(struct virtnet_stats);
INIT_WORK(&amp;vi-&gt;config_work, virtnet_config_changed_work);
......
vi-&gt;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-&gt;curr_queue_pairs);
netif_set_real_num_rx_queues(dev, vi-&gt;curr_queue_pairs);
virtnet_init_settings(dev);
err = register_netdev(dev);
virtio_device_ready(vdev);
virtnet_set_queues(vi, vi-&gt;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 &amp; 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-&gt;sq = kzalloc(sizeof(*vi-&gt;sq) * vi-&gt;max_queue_pairs, GFP_KERNEL);
vi-&gt;rq = kzalloc(sizeof(*vi-&gt;rq) * vi-&gt;max_queue_pairs, GFP_KERNEL);
INIT_DELAYED_WORK(&amp;vi-&gt;refill, refill_work);
for (i = 0; i &lt; vi-&gt;max_queue_pairs; i++) {
vi-&gt;rq[i].pages = NULL;
netif_napi_add(vi-&gt;dev, &amp;vi-&gt;rq[i].napi, virtnet_poll,
napi_weight);
netif_tx_napi_add(vi-&gt;dev, &amp;vi-&gt;sq[i].napi, virtnet_poll_tx,
napi_tx ? napi_weight : 0);
sg_init_table(vi-&gt;rq[i].sg, ARRAY_SIZE(vi-&gt;rq[i].sg));
ewma_pkt_len_init(&amp;vi-&gt;rq[i].mrg_avg_pkt_len);
sg_init_table(vi-&gt;sq[i].sg, ARRAY_SIZE(vi-&gt;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 &lt; vi-&gt;max_queue_pairs; i++) {
callbacks[rxq2vq(i)] = skb_recv_done;
callbacks[txq2vq(i)] = skb_xmit_done;
names[rxq2vq(i)] = vi-&gt;rq[i].name;
names[txq2vq(i)] = vi-&gt;sq[i].name;
}
ret = vi-&gt;vdev-&gt;config-&gt;find_vqs(vi-&gt;vdev, total_vqs, vqs, callbacks, names, ctx, NULL);
......
for (i = 0; i &lt; vi-&gt;max_queue_pairs; i++) {
vi-&gt;rq[i].vq = vqs[rxq2vq(i)];
vi-&gt;rq[i].min_buf_len = mergeable_min_buf_len(vi, vi-&gt;rq[i].vq);
vi-&gt;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设备这些共享内存的地址。
至此前端设备驱动和后端设备驱动之间的两个收发队列就关联好了,这两个队列的格式和块设备是一样的。
## 发送网络包过程
接下来,我们来看当真的发送一个网络包的时候,会发生什么。
当网络包经过客户机的协议栈到达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-&gt;xmit_skb-&gt; virtqueue_add_outbuf-&gt;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 = &amp;vi-&gt;sq[qnum];
int err;
struct netdev_queue *txq = netdev_get_tx_queue(dev, qnum);
bool kick = !skb-&gt;xmit_more;
bool use_napi = sq-&gt;napi.weight;
......
/* Try to transmit */
err = xmit_skb(sq, skb);
......
if (kick || netif_xmit_stopped(txq))
virtqueue_kick(sq-&gt;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 = &amp;n-&gt;vqs[vq2q(virtio_get_queue_index(vq))];
q-&gt;tx_waiting = 1;
virtio_queue_set_notification(vq, 0);
qemu_bh_schedule(q-&gt;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-&gt;n;
VirtIODevice *vdev = VIRTIO_DEVICE(n);
VirtQueueElement *elem;
int32_t num_packets = 0;
int queue_index = vq2q(virtio_get_queue_index(q-&gt;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-&gt;tx_vq, sizeof(VirtQueueElement));
out_num = elem-&gt;out_num;
out_sg = elem-&gt;out_sg;
......
ret = qemu_sendv_packet_async(qemu_get_subqueue(n-&gt;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-&gt;qemu_net_queue_send_iov-&gt;qemu_net_queue_flush-&gt;qemu_net_queue_deliver。
在qemu_net_queue_deliver中我们会调用NetQueue的deliver函数。前面qemu_new_net_queue会把deliver函数设置为qemu_deliver_packet_iov。它会调用nc-&gt;info-&gt;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-&gt;writev写入这个字符设备。
在内核的字符设备驱动中tun_chr_write_iter会被调用。
```
static ssize_t tun_chr_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
struct file *file = iocb-&gt;ki_filp;
struct tun_struct *tun = tun_get(file);
struct tun_file *tfile = file-&gt;private_data;
ssize_t result;
result = tun_get_user(tun, tfile, NULL, from,
file-&gt;f_flags &amp; O_NONBLOCK, false);
tun_put(tun);
return result;
}
```
当我们使用writev()系统调用向tun/tap设备的字符设备文件写入数据时tun_chr_write函数将被调用。它会使用tun_get_user从用户区接收数据将数据存入skb中然后调用关键的函数netif_rx_ni(skb) 将skb送给tcp/ip协议栈处理最终完成虚拟网卡的数据接收。
至此,从虚拟机内部到宿主机的网络传输过程才算结束。
## 总结时刻
最后,我们把网络虚拟化场景下网络包的发送过程总结一下。
- 在虚拟机里面的用户态应用程序通过write系统调用写入socket。
- 写入的内容经过VFS层内核协议栈到达虚拟机里面的内核的网络设备驱动也即virtio_net。
- virtio_net网络设备有一个操作结构struct net_device_ops里面定义了发送一个网络包调用的函数为start_xmit。
- 在virtio_net的前端驱动和qemu中的后端驱动之间有两个队列virtqueue一个用于发送一个用于接收。然后我们需要在start_xmit中调用virtqueue_add将网络包放入发送队列然后调用virtqueue_notify通知qemu。
- qemu本来处于KVM_RUN的状态收到通知后通过VM exit指令退出客户机模式进入宿主机模式。发送网络包的时候virtio_net_handle_tx_bh函数会被调用。
- 接下来是一个for循环我们需要在循环中调用virtqueue_pop从传输队列中获取要发送的数据然后调用qemu_sendv_packet_async进行发送。
- qemu会调用writev向字符设备文件写入进入宿主机的内核。
- 在宿主机内核中字符设备文件的file_operations里面的write_iter会被调用也即会调用tun_chr_write_iter。
- 在tun_chr_write_iter函数中tun_get_user将要发送的网络包从qemu拷贝到宿主机内核里面来然后调用netif_rx_ni开始调用宿主机内核协议栈进行处理。
- 宿主机内核协议栈处理完毕之后会发送给tap虚拟网卡完成从虚拟机里面到宿主机的整个发送过程。
<img src="https://static001.geekbang.org/resource/image/e3/44/e329505cfcd367612f8ae47054ec8e44.jpg" alt="">
## 课堂练习
这一节我们解析的是发送过程,请你根据类似的思路,解析一下接收过程。
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">