其他的对实验未涉及的思考
由mkfs引发的对虚拟机的学习
懂了!VMware/KVM/Docker原来是这么回事儿这篇文章对虚拟化、虚拟机技术讲解很到位,写得通俗易懂,非常值得一看
KVM 的「基于内核的虚拟机」是什么意思?这篇文章对QEMU-KVM架构进行了详细的介绍。还有这篇文章对应的知乎问题下面的高赞回答有机会也可以去看看。
QEMU/KVM原理概述这篇文章前面的原理和上面那个差不多,后面有使用kvm做一个精简内核的实例,有兴趣/有精力/有需要可以看看。
以前只是知道,xv6是运行在qemu提供的虚拟环境之上的。qemu是什么,怎么虚拟的,虚拟机和宿主机是怎么交互的,这些一概不通。今天心血来潮想研究下qemu,虚拟机啥的到底是什么玩意,虽然看得有些猪脑过载,但还是写下一些个人的整理。
qemu
是什么
在了解qemu之前,可以先了解一下虚拟化的思想。
虚拟化
虚拟化的主要思想是,通过分层将底层的复杂、难用的资源虚拟抽象成简单、易用的资源,提供给上层使用。
本质上,计算机的发展过程也是虚拟化不断发展的过程,底层的资源或者通过空间的分割,或者通过时间的分割,将下层的资源通过一种简单易用的方式转换成另一种资源,提供给上层使用。
虚拟化可分为以下几方面:
- CPU抽象:机器码、汇编语言到C语言、再到高级语言的不断虚拟的过程
- 存储抽象:操作系统通过文件和目录抽象
- 网络抽象:TCP/IP协议栈模型将网卡设备中传递的二进制数据,经过网络层、传输层的抽象后,为应用程序提供了便捷的网络包处理接口,而无需关心底层的IP路由、分片等细节
- 进程抽象:操作系统通过进程抽象为不同的应用程序提供了安全隔离的执行环境,并且有着独立的CPU和内存等资源
虚拟化的思想实际上就是我以前一直称为“抽象”的思想,以接口的形式逐层向上服务。
虚拟机
虚拟机的核心能力在于提供一个执行环境(隐藏底层细节),并在其中完成用户的指定任务。
虚拟机有多种不同的形式,包括提供指令执行环境的进程、模拟器和高级语言虚拟机,或者是提供一个完整的系统环境的系统虚拟机。
进程
进程实际上就是一种虚拟机。
进程可以看作是一组资源的集合,有自己独立的进程地址空间以及独立的CPU和寄存器,执行程序员编写的指令,完成一定的任务。
操作系统可以创建多个进程,每一个进程都可以看成一个独立的虚拟机,它们在执行指令、访问内存的时候并不会相互影响影响。
模拟器
高级语言虚拟机
系统虚拟机
通过系统虚拟化技术,能够在单个的宿主机硬件平台上运行多个虚拟机,每个虚拟机都有着完整的虚拟机硬件,如虚拟的CPU、内存、虚拟的外设等,并且虚拟机之间能够实现完整的隔离。
在系统虚拟化中,管理全局物理资源的软件叫作虚拟机监控器(Virtual Machine Monitor,VMM),VMM之于虚拟机就如同操作系统之于进程,VMM利用时分复用或者空分复用的办法将硬件资源在各个虚拟机之间进行分配。
qemu
可以看到,qemu就是一种虚拟机。它可以模拟虚拟机硬件,为操作系统提供虚拟硬件环境,从而能够让不同的操作系统能够在不同主机硬件上执行。
qemu-kvm架构
诞生的原因
其对于虚拟化技术的优化,以及发展的前因后果,具体可以看懂了!VMware/KVM/Docker原来是这么回事儿这篇文章。
概括来讲,大致有以下几个要点:
两种虚拟化方案
实现上述的虚拟化方案
一个典型的做法是——陷阱 & 模拟
技术
什么意思?简单来说就是正常情况下直接把虚拟机中的代码指令放到物理的CPU上去执行,一旦执行到一些敏感指令,就触发异常,控制流程交给VMM,由VMM来进行对应的处理,以此来营造出一个虚拟的计算机环境。
x86架构的问题
x86架构使得上述做法用不了了。因为它引入了四种权限
解决方法
全虚拟化
VMware的二进制翻译技术、QEMU的模拟指令集
半虚拟化
硬件辅助虚拟化
硬件辅助虚拟化细节较为复杂,简单来说,新一代CPU在原先的Ring0-Ring3四种工作状态之下,再引入了一个叫工作模式的概念,有
VMX root operation
和VMX non-root operation
两种模式,每种模式都具有完整的Ring0-Ring3四种工作状态,前者是VMM运行的模式,后者是虚拟机中的OS运行的模式。qemu-kvm架构正是借助于此实现的。
kvm
kvm就是借助硬件辅助虚拟化诞生的。可以把kvm看作是一堆系统调用。
KVM本身是一个内核模块,它导出了一系列的接口到用户空间,用户态程序可以使用这些接口创建虚拟机。
具体而言,KVM 可帮助您将 Linux 转变为虚拟机监控程序,使主机计算机能够运行多个隔离的虚拟环境,即虚拟客户机或虚拟机(VM)。【也即,虚拟机—进程,KVM—操作系统】
在虚拟化底层技术上,KVM和VMware后续版本一样,都是基于硬件辅助虚拟化实现。不同的是VMware作为独立的第三方软件可以安装在Linux、Windows、MacOS等多种不同的操作系统之上,而KVM作为一项虚拟化技术已经集成到Linux内核之中,可以认为Linux内核本身就是一个HyperVisor,这也是KVM名字的含义,因此该技术只能在Linux服务器上使用。
qemu-kvm
KVM本身基于硬件辅助虚拟化,仅仅实现CPU和内存的虚拟化,但一台计算机不仅仅有CPU和内存,还需要各种各样的I/O设备,不过KVM不负责这些。这个时候,QEMU就和KVM搭上了线,经过改造后的QEMU,负责外部设备的虚拟,KVM负责底层执行引擎和内存的虚拟,两者彼此互补,成为新一代云计算虚拟化方案的宠儿。
qemu-kvm总体架构
KVM只负责最核心的CPU虚拟化和内存虚拟化部分;QEMU作为其用户态组件,负责完成大量外设的模拟。
VMX root和VMX non root
VMX root是宿主机模式,此时CPU在运行包括QEMU在内的普通进程和宿主机的操作系统内核;
VMX non-root是虚拟机模式,此时CPU在运行虚拟机中的用户程序和操作系统代码。
也就是说,虚拟机的程序,包括用户程序和内核程序,都运行在non-root模式。宿主机的所有程序,包括用户程序【包括qemu】和内核程序【包括kvm】,都运行在root模式。
qemu层(左上)
上面说到,qemu负责的是大量外设的模拟。它具体要做以下几件事:
初始化虚拟机:
创建模拟的芯片组
创建CPU线程来表示虚拟机的CPU
QEMU在初始化虚拟机的CPU线程时,首先设置好相应的虚拟CPU寄存器的值,然后调用KVM的接口将虚拟机运行起来,这样CPU线程就会被调度在物理CPU上执行虚拟机的代码。
在QEMU的虚拟地址空间中分配空间作为虚拟机的物理地址
根据用户在命令行指定的设备为虚拟机创建对应的虚拟设备【如各种IO设备】
虚拟机运行时:
监听多种事件
包括虚拟机对设备的I/O访问、用户对虚拟机管理界面、虚拟设备对应的宿主机上的一些I/O事件(比如虚拟机网络数据的接收)等
调用函数处理
可以看到,qemu确实利用了宿主机的各种资源,提供了一个很完美的硬件环境。其资源对应关系为:
虚拟机的CPU——宿主机的一个线程
虚拟机的物理地址——qemu在宿主机的虚拟地址
虚拟机对硬件设备的访问 —→ 对qemu的访问
kvm层(下方)
它大概做了两件事:
给qemu提供运行时的参数
通过“/dev/kvm”设备,比如CPU个数、内存布局、运行等。
截获VM Exit事件【下面会讲,用来完成虚拟机和硬件环境的交互】并进行处理。
虚拟机层(右上)
CPU——QEMU进程中的一个线程
通过QEMU和KVM的相互协作,虚拟机的线程会被宿主机操作系统正常调度,直接执行虚拟机中的代码
物理地址——QEMU进程中的虚拟地址
设备——QEMU实现
在运行过程中,虚拟机操作系统通过设备的I/O端口(Port IO、PIO)或者MMIO(Memory Mapped I/O)进行交互,KVM会截获这个请求【也即VM Exit,下面会讲】,大多数时候KVM会将请求分发到用户空间的QEMU进程中,由QEMU处理这些I/O请求
虚拟机在QEMU-KVM架构的执行方法
状态管理虚拟化
虚拟机肯定是会与它的硬件环境进行交互的,它的硬件环境也就是QEMU—KVM。
虚拟机的用户程序和内核程序都是直接由宿主机的操作系统正常调度,我们可以将其看作虚拟态。QEMU—KVM可以看作是宿主机的进程,我们可以将其看作宿主态。因而,当虚拟机一些事情希望由QEMU—KVM来做,我们就需要从虚拟态转移到宿主态。
听起来有没有感觉很耳熟?是的,“从用户态陷入内核态”,跟这个的原理是一样的。
因而,虚拟机与硬件环境交互,实际上是虚拟态和宿主态状态的转换,如下图:
VM Exit
当虚拟机中的代码是敏感指令或者说满足了一定的退出条件时,CPU会从虚拟态退出到KVM,这叫作VM Exit。
这就像在用户态执行指令陷入内核一样。
VM Exit首先陷入到KVM中进行处理,如果KVM无法处理,比如说虚拟机写了设备的寄存器地址,那么KVM会将这个写操作分派到QEMU中进行处理。
VM Entry
当KVM或者QEMU处好了退出事件之后,又可以将CPU置于虚拟态以运行虚拟机代码,这叫作VM Entry。
内存管理虚拟化
QEMU在初始化的时候会通过
mmap
分配虚拟内存空间作为虚拟机的物理内存,【感觉思路打开,物理内存与文件对应了起来】QEMU在不断更新内存布局的过程中会持续调用KVM接口通知内核KVM模块虚拟机的内存分布。
虚拟机在运行过程中,首先需要将虚拟机的虚拟地址(Guest Virtual Address,GVA)转换成虚拟机的物理地址(Guest Physical Address,GPA),然后将虚拟机的物理地址转换成宿主机的虚拟地址(Host Virtual Address,HVA),最终转换成宿主机的物理地址(Host Physical Address,HPA)。
整个寻址过程由硬件实现,具体实现方式为扩展页表(Extended Page Table,EPT)。
在支持EPT的环境中,虚拟机在第一次访问内存的时候就会陷入到KVM,KVM会逐渐建立起所谓的EPT页面【lazy思想贯穿始终,还是该叫自适应?】。这样虚拟机的虚拟CPU在后面访问虚拟机虚拟内存地址的时候,首先会被转换为虚拟机物理地址,接着会查找EPT页表,然后得到宿主机物理地址。【有种TLB的感觉】
外设管理虚拟化
设备模拟的本质是要为虚拟机提供一个与物理设备接口完全一致的虚拟接口。
虚拟机中的操作系统与设备进行的数据交互或者由QEMU和(或)KVM完成,或者由宿主机上对应的后端设备完成。
QEMU在初始化过程中会创建好模拟芯片组和必要的模拟设备,包括南北桥芯片、PCI根总线、ISA根总线等总线系统,以及各种PCI设备、ISA设备等。
外设虚拟化主要有如下几种方式:
纯软件模拟(完全虚拟化)
QEMU最早的方案,虚拟机内核不用做任何修改,每一次对设备的寄存器读写都会陷入到KVM,进而到QEMU,QEMU再对这些请求进行处理并模拟硬件行为。
软件模拟会导致非常多的QEMU/KVM接入,效率低下。
virtio设备(半虚拟化)
virtio设备是一类特殊的设备,并没有对应的物理设备,所以需要虚拟机内部操作系统安装特殊的virtio驱动。
相比软件模拟,virtio方案提高了虚拟设备的性能。
设备直通
将物理硬件设备直接挂到虚拟机上,虚拟机直接与物理设备交互,尽可能在I/O路径上减少QEMU/KVM的参与。
设备直通经常搭配硬件虚拟化支持技术SRIOV(Single Root I/O Virtualization,单根输入/输出虚拟化)使用,SRIOV能够将单个的物理硬件高效地虚拟出多个虚拟硬件。
中断处理虚拟化
操作系统通过写设备的I/O端口或者MMIO地址来与设备交互,设备通过发送中断来通知操作系统事件。
QEMU/KVM一方面需要完成这项中断设备的模拟,另一方面需要模拟中断的请求处理。
QEMU支持单CPU的Intel 8259中断控制器以及SMP的I/O APIC(I/O Advanced Programmable Interrupt Controller)和LAPIC(Local Advanced Programmable Interrupt Controller)中断控制器。在这种方式下,虚拟外设通过QEMU向虚拟机注入中断,需要先陷入到KVM,然后由KVM向虚拟机注入中断,这是一个非常费时的操作。
为了提高虚拟机的效率,KVM自己也实现了中断控制器Intel 8259、I/O APIC以及LAPIC。用户可以有选择地让QEMU或者KVM模拟全部中断控制器,也可以让QEMU模拟Intel 8259中断控制器和I/O APIC,让KVM模拟LAPIC。
xv6的全启动运行过程梳理
介绍完上述的qemu虚拟化,接下来就可以对xv6的全启动进行一个梳理了。
首先,在宿主机执行make qemu
。
在Makefile
中可以看到:
1 | qemu: $K/kernel fs.img |
在log中可以看到:
1 | ... |
具体的Makefile
相关内容我不大了解,但结合输出,我想大概是先通过riscv64-linux-gnu-gcc
编译链接完所有文件,然后再执行mkfs
产生fs.img
镜像(mkfs
后面那些东西应该是文件参数,对应于源码中的读取可执行程序进磁盘的部分),最后再运行qemu-system-riscv64
开始对虚拟机进行boot。
boot直至启动后的所有代码,都是通过QEMU-KVM架构处理,直接运行在宿主机的CPU上的。其余的各种管理,可以详见小标题虚拟机在QEMU-KVM架构的执行方法
。
mkfs的作用及源码解读
作用
上面的知识表明,操作系统的启动在于文件系统初始化之后,这是因为操作系统本身的启动代码,放在磁盘映像fs.img
中,而fs.img
正是由文件系统初始化时弄出来的。也就是说,文件系统是操作系统的爸爸。【我以前一直以为是反过来的】
图中的boot块就是操作系统的引导扇区。
而mkfs
的作用,正是把宿主机提供的虚拟地址空间作为虚拟磁盘,把虚拟地址空间划分为如上图所示的地址结构。它是运行在宿主机当中的。有了mkfs
,才能有我们的虚拟机。
代码解读
yysy这个就写得很好了。
user mem-allocator
linux的堆管理
那么malloc到底是怎么实现的呢?不是每次要申请内存就调一下系统调用,而是程序向操作系统申请⼀块适当⼤⼩的堆空间,然后由程序⾃⼰管理这块空间,⽽具体来讲,管理着堆空间分配的往往是程序的运⾏库。
也就是说,malloc本质上是以运行库而非系统调用形式出现的。它里面用到的是sbrk和mmap这两个系统调用来进货。
glibc的malloc函数是这样处理⽤户的空间请求的:对于⼩于128KB的请求来说,它会在现有的堆空间⾥⾯,按照堆分配算法为它分配⼀块空间并返回;对于⼤于128KB的请求来说,它会使⽤mmap()函数为它分配⼀块匿名空间,然后在这个匿名空间中为⽤户分配空间。
在内核态中,我们使用kalloc
和kfree
来申请和释放内存页。在用户态中,我们使用malloc
和free
来对动态内存进行管理。【也就是说这个实现的是堆管理】
内核中的最小单位只能是页,但user mem-allocator对外提供的申请内存服务的最小单位不是页,而是sizeof(Header)
。因而,这就需要我们的user mem-allocator进行数据结构的管理,来统一这二者的实现。
数据结构
环形链表
user mem-allocator的数据结构是环形链表,起始结点为一个空数据载体。
地址从低到高
链表的头结点的存储地址/所代表的内存地址的地址数值最小,并且其余结点按遍历顺序地址递增。
具体实现
user mem-allocator由三个主要函数组成,分别是morecore
、malloc
和free
。一个一个地来说未免有点不符合正常人的思路,所以我接下来会以用户初次调用malloc
为例,来整理user mem-allocator的具体实现。
malloc
当用户初次调用malloc
,此时freep仍为空指针,因而会进入如下分支:
1 | if((prevp = freep) == 0){ |
也即初始化为这种情况:
随后,由于prevp->ptr == freep
,故而会在循环中进入该分支:
1 | for(p = prevp->s.ptr; ; prevp = p, p = p->s.ptr){ |
调用morecore
。
morecore
进入morecore
后,首先会对堆内存进行扩容:
1 | if(nu < 4096) |
其中,nu表示要申请的内存单元数,一个内存单元为sizeof(Header)
,因而nu在malloc
中计算如下:
1 | nunits = (nbytes + sizeof(Header) - 1)/sizeof(Header) + 1; |
为了满足内核以一页为最小内存单位的需求,以及避免过多陷入内核态,它每次会申请至少4096*内存单元的堆空间。
对堆内存进行扩容完之后,morecore
会手动调用一次free
,将新申请到的内存加入数据结构中。【此处类似于在knit
中调用kfree
的原理】
free
1 | void free(void *ap){ |
由于此时freep == freep->str == base
,并且我们在morecore
中新申请的内存空间ap
满足ap > base
,故而会跳出循环。
为什么
ap > base
呢?别忘了我们扩容的原理。我们是以
proc->size
为起始地址扩容的。ap处在扩容内存中,因而ap>旧size;base处在扩容前内存内,因而base<=旧size。故而有ap>base。
1 | if(bp + bp->s.size == p->s.ptr){ |
跳出循环后,我们会进入第一个if的第二个分支,以及第二个if的第二个分支。经过这些指针操作后,此时我们的数据结构如下图所示:
也即形成了一个两节点的环形链表。
malloc
经历完上述调用后,我们回到malloc
的循环中:
1 | for(p = prevp->s.ptr; ; prevp = p, p = p->s.ptr){ |
由morecore
的返回值可知,此时我们的p应该指向freep。本轮循环结束后执行 p = p->s.ptr
,此时我们的p指向了我们刚在morecore
中扩容出来的那一大段内存。
在下一轮循环中,由于我们刚刚通过morecore
申请了至少nunits
的空间,因而我们将进入该分支:
1 | if(p->s.size >= nunits){ |
当nunits >= 4096
,也即p->s.size == nunits
,p所指向的地址恰好就是我们接下来会用的地址。因而,我们就将这部分内存空间从我们的freelist中剔除,在之后返回p的地址即可。
当nunits < 4096
,也即p->s.size != nunits
,说明p所指向的这块内存空间比我们需要的大,那么我们就仅将该段内存空间切割出需要的那一小部分,再把p指向那一小部分开头的地方,返回p地址即可,如图所示。
这样一来,我们就成功给用户它所需要的内存空间了。
free
进行malloc之后,用户还需要调用free来手动释放内存,防止内存泄漏。
1 | for(p = freep; !(bp > p && bp < p->s.ptr); p = p->s.ptr) |
由于ap > base
且ap > 旧p->size = base->ptr
且base < base->ptr
,故而首先会进行一轮循环。再然后,由于p = 旧p->size
,并且p > p->ptr = base
,并且ap > 旧size
,故而跳出循环。
此处循环中,循环语句内部的这个循环实际上是对遍历到环形链表尾部,即将从头开始遍历,这个边界情况的处理。比较符合逻辑的还是循环语句内的那个条件。
1 | if(bp + bp->s.size == p->s.ptr){ |
此时会进入第二个if的第一个分支。具体情况看图就行,不多bb。
总结
主要就是这个数据结构用得很巧妙但也很复杂。它吸取了内核态中分配内存使用一个freelist的特点,同时又巧妙地利用了内存地址有序的特点,从而实现碎片内存管理。我的建议是多画图。
还有其实有一点我不是很理解。我觉得freep
这个变量的用意非常不明,它似乎并不是指代整个freelist的头,因为它在很多个地方都诡异地赋值了一次。我想,它也许始终指向上一次被alloc/被free的内存的前一个吧。。。我猜测这样设计是为了蕴含一些LRU的思想。不大明白。
m-s-u权限切换
由os知识可知,机器态、内核态、用户态分别有三种不同的操作权限。xv6是如何对权限切换进行管理的呢?
这部分知识我在正文的一个小地方记录了下来,详见 chapter2 - Code: starting xv6 and the fifirst process - xv6 - 感想 的第二点。
Lock实验的评测机制
在xv6该次实验中,为了实现评测可视化,引入了statistics机制对结果进行评估。下面,我将通过源码简单介绍其实现机制。
来讲讲这玩意是怎么实现用户态读取锁争用次数的。我们从statistics
函数可看出,它的本质是通过读取“文件”,来从内核中读取争用次数的相关数据:
1 | int statistics(void *buf, int sz) { |
那么安装以前所学的内容,我们很容易联系到这玩意应该并不是个文件,而是类似于proc文件系统那样的虚拟文件。它应该会在open、read中根据其特有的文件类型进行转发。在init.c
中,我们可以看到:
1 | main(void) |
这玩意的文件结点实际上是在创建console时整的,并且其有一个特殊的文件类型“STATS”。我们可以进一步追溯到kernel中的main.c
:
1 | void main() |
1 | void |
可以看到,它给这个STATS
文件类型注册了这两个函数。当我们调用read和write时,实际上就是在调用这俩玩意。我们可以看下这两个handler都干了啥。
1 |
|
1 | int statswrite(int user_src, uint64 src, int n) { // WARNING: READ ONLY!!! |
可以看到其本质就是把statslock
返回的东西copy到用户空间了。我们来结合最后的输出效果看看statslock
的具体实现:
1 | int statslock(char *buf, int sz) { |
可以看到其争用本质计算是通过spinlock::nts
字段记录。我们来看看这玩意的引用:
1 | void initlock(struct spinlock *lk, char *name) { |
很好,逻辑很简单,就是记录acquire时等待的次数,非常简单粗暴(((
总的来说这个思路还是挺酷的,而且这个“一切皆文件”的思想再次震撼了我,一个小小的xv6确实能做到那么多。