目录
PCI/PCIe ExpansionOption ROM启动
推荐几本书:《系统虚拟化:原理与实现》、《深度探索Linux系统虚拟化》和《QEMU/KVM源码解析与应用》。
x86体系结构简介
虚拟化是提供虚拟机的技术,非常有必要在讨论虚拟化技术之前对x86体系架构进行一些简单的回顾。当前计算机的体系机构与1945年提出的冯诺依曼结构并没有太大的变化,主要包含三个部分:
-
中央处理器单元CPU:主要负责运算和逻辑控制,按照程序中的指令进行计算,根据程序指令进行跳转或者是顺序执行
-
存储器Memory:负责存储程序的指令和数据,来保存程序执行的中间结果和最终结果。现代计算机中通常包括寄存器、CPU缓存、内存等。
-
输入输出IO:负责与外部交互,从外部获得输入,并将结果进行输出。比如键盘、鼠标、显示器、打印机、网卡、磁盘都是此类设备。
CPU简介
指令集架构
指令集架构(Instruction Set Architecture,ISA)是CPU和软件之间的桥梁,ISA包含指令集、特权级、寄存器、执行模式、安全扩展、性能加速等诸多方面。
-
指令集:作为ISA的重要组成部分,通常包含一系列不同功能的指令,用于数据搬迁、计算、内存访问、过程调用等。指令集分为RISC精简指令集(Reduced Instruction Set)和CISC复杂指令集(Complex Instruction Set),其中ARM属于RISC,x86属于CISC。
-
特权级:x86从80286开始引入保护模式和特权级Ring 0-3(之前只有实模式),其中应用程序跑在Ring3,操作系统跑在Ring0。
-
寄存器:x86的寄存器分为通用寄存器、数据寄存器、指针寄存器、变址寄存器、控制寄存器、段寄存器。下面举例说明,RIP是指令指针寄存器,保存即将要执行的指令地址;RBP是栈基地址寄存器,保存当前帧的栈底地址;RSP是栈指针寄存器,保存当前栈顶。CR3是页目录基址寄存器,保存页目录表的物理地址;CR2是页故障线性地址寄存器,保存上一次页故障的线性地址。
物理内存与CPU缓存
CPU使用物理内存的方式很简单,将其看作是有字节组成的大数组,每个字节都拥有一个物理地址,CPU可以通过总线存取其中的数据。然而相对于CPU处理的速度,内存访问速度时非常缓慢的,一次访存需要花费上百个CPU Cycle。为了降低CPU访存的开销,现在的CPU中通常引入高速缓存,用于存放一部分物理内存中的数据,根据应用程序访存的时空局部性,提升CPU访问内存速度。高速缓存硬件上使用了速度较快的SRAM,减少了从速度较慢的DRAM读写的时间。当CPU向物理内存中写入数据时,可以直接写入高速缓存中;当CPU从物理内存中读取数据时,可以先从高速缓存中查找,如果没有找到再去物理内存中获取,并且将取回的数据放入高速缓存中,以便加快下次读取速度。
CPU高速缓存由若干个缓存行组成,每个缓存行包括:一个有效位用于表示其是否有效,一个标记地址用于标识其对应的物理地址,以及一些其他状态信息。通常CPU以缓存行(常见是64字节)为单位把物理内存中的数据读取到CPU高速缓存中,即使读写的长度小于缓存行长度也会按照缓存行对齐进行读写。
典型的CPU高速缓存结构如下图所示。为了通过内存的物理地址找到对应的高速缓存,物理地址在逻辑上划分为Tag、Set(也称为Index)以及Offset三段。组(Set)与路(Way)是高速缓存的经典概念,物理地址中的Set段能表示的最大数目成为组。同一组(即Set段相等)下,支持的最大Tag数则称为路,即同一组下最多缓存行数目。下图的示例为4路组相联(在Set相同的情况下,缓存最多支持4个不同的Tag):
资源调配技术RDT
传统Linux使用cgroup来进行进程的资源限制,但是cgroup限制的资源粒度较粗。在虚拟化场景中宿主机资源(包括CPU、内存、CPU Cache和内存带宽)都是共享的,cgroup可以对CPU和内存进行限制,但是无法对CPU Cache和内存带宽进行限制,如果一个应用快速消耗L3缓存,或者是一个应用消耗大量的内存带宽,会影响其他虚拟机的服务稳定性,这就是"noisy neighbor "问题。为此Intel推出了资源调配技术RDT(Resource Director Technology),通过一系列的CPU指令从而允许用户直接对每个CPU核心(附加了HT技术后为每个逻辑核心)的L2缓存、L3缓存(LLC)以及内存带宽进行监控和分配,从而提升应用程序、虚拟机 (VM) 和容器使用共享资源的可见性和可控性。
RDT技术针对的是缓存和内存带宽,分别又分为监控和控制,就形成了4个功能模块,再加上代码和数据优先级(控制技术),合起来形成5个功能模块,具体功能模块如下:
-
Cache Monitoring Technology (CMT) 缓存检测技术:借助监控单个线程、应用程序或 VM 对最后一级缓存 (LLC) 的利用率所获得的新洞察,CMT 改进了工作负载表征,实现了先进的资源感知型调度决策,能帮助检测“吵闹的邻居”,并改进性能调试。
-
Cache Allocation Technology (CAT) 缓存分配技术:CAT 支持软件引导的缓存容量重新分配,使重要的数据中心 VM、容器或应用程序能够从提升缓存容量和减少缓存争用中受益。CAT 可用于增强运行时确定性,并且在各种优先级工作负载的资源争用中优先考虑重要的应用程序,如虚拟交换机或数据平面开发套件 (DPDK) 数据包处理应用程序。
-
Memory Bandwidth Monitoring (MBM) 内存带宽监测:内存带宽监控 (MBM) 可以独立跟踪多台虚拟机或多个应用程序,同时对每个运行线程进行内存带宽监控。优点包括:对“吵闹的邻居”的检测,对带宽敏感型应用程序的性能界定和调试的检测,以及对更高效的非一致性内存访问 (NUMA) 感知型调度的检测。
-
Memory Bandwidth Allocation (MBA) 内存带宽分配:MBA 可对工作负载可用的内存宽带进行近似和间接控制,从而为系统中存在的“吵闹的邻居”提供全新水平的干扰抑制和带宽整形。
-
Code and Data Prioritization (CDP) 代码和数据优先级:代码和数据优先级 (CDP) 作为 CAT 的专用扩展,实现了对最新级别 (L3) 高速缓存中代码和数据放置的独立控制。某些特殊类型的工作负载可从增加的运行时决策中受益,从而提高应用程序性能的可预测性。
RDT允许OS或VMM监控线程、应用或VM使用的cache/内存带宽,通过分析cache/内存带宽使用率,OS或VMM可以优化调度策略提高效能,使得高级优化技术可以实现。
OS或VMM会给每个应用或虚拟机标记一个软件定义的ID,叫做RMID(Resource Monitoring ID),通过RMID可以同时监控运行在多处理器上相互独立的线程。此外线程可以被独立监控,也可以按组的方式进行监控:
-
多个线程可以标记为相同的RMID
-
同一个虚拟机下的所有线程可以标记为相同的RMID
-
同样一个应用下的所有线程可以标记为相同的RMID
RDT中核心的技术是CAT,CAT通过配置IA32_PQR_ASSOC MSR寄存器(也称为PQR)定义服务分类CLOS(Class of Service)可以使用的缓存空间capacity bitmasks (CBMs)。一般在Task启动的时候进行设置,但是也可以在Task运行时进行动态设置,操作系统调度执行Task的时候Task的CLOSID会被加载到PQR MSR,CPU通过PQR MSR中的索引找到对应的Cache分配区域,通过硬件实现缓存分配的QoS。一个Task分配的CLOSID不仅可以用于CAT限制末级缓存空间,也可以限制内存带宽等。下图就是一个示例,CLOSID为1的应用,CBM被设置为00FF00,内存延迟数被设置为10。
Linux内核从4.10开始支持L3 CAT、L3 CDP和L2 CAT,从4.12开始支持MBA。为了支持RDT,内核中新增了resctrl文件系统指定资源组的资源限制。
RDT的使用比较受限,CAT表项只有16个,也就是只能为16个资源组进行资源控制。资源组的CAT BitMask可以重叠,比如0x01/0x3/0x7,区域重叠的话会造成隔离效果不好。如果人为设置不重叠,不做调度器优化就会导致Cache可用空间减少,比如某个资源组占用了部分L3缓存,但是其中的进程睡眠了其他资源组中的进程无法使用这部分已经分配出去的L3缓存。理想情况应该改进调度器,任务调出的时候并可以将对应的BitMask临时授予给其他资源组,任务调入的时候重新设置CAT Bitmask(之前授予给其他资源组也需要重新设置),实现L3缓存更高效的利用。
百度内部将RDT Backport到3.10内核上,并在在离线混部中限制离线业务的L3缓存使用。考虑到CAT表项有限,可以将业务进行分类,不同类业务设置到不同的CAT资源组中使用不同大小的L3缓存,比如将业务分为在线和离线,分别设置100%和20%的L3缓存量,虽然L3 Cache有一定的Overlap但是依然会比之前离线任务在100%的Cache空间内抢占L3 Cache。实际上线之后,在线业务的Cycle Per Instruction降低了5%。
内存管理
操作系统在应用程序和物理内存之间引入了虚拟内存的抽象,来解决不同应用程序高效又安全的共同使用物理内存资源。应用程序是面向虚拟内存进行编程的,运行过程中使用虚拟地址,操作系统负责设置虚拟地址和物理地址之间的映射,确保每个进程都有独立的虚拟地址空间;CPU负责将虚拟地址自动翻译为物理地址,确保整个地址翻译过程的高效。
MMU
内存管理单元MMU是CPU中的重要部件,负责虚拟地址到物理地址的转换。当应用程序需要访问内存的时候,MMU会自动将虚拟地址翻译为物理地址,并通过总线传输给相应的物理内存设备,从而完成物理内存的读写请求。具体内存的访问有代码访问也有数据访问。
MMU将虚拟地址翻译为物理地址的主要机制有两种:分段机制和分页机制。内存控制单元(MMU)由分段单元和分页单元组成。分段单元实现逻辑地址到线性地址的转换,给一个进程分配不同的线性地址空间;分页单元实现线性地址(虚拟地址)到物理地址的转换。
分段相比分页在某种程度上是多余的,分段可以为每个进程分配不同的线性地址空间,分页可以将同一线性空间映射到不同的物理地址空间。但是为了考虑兼容性Intel依然保留了分段机制。
分段机制
x86物理CPU重置后都是从实模式开始运行,然后再切入保护模式。所以boot loader和操作系统内核都是从实模式开始运行的,实模式下没有页表概念,都是按照分段机制进行段式寻址的。Intel的第一款16位微处理器8086,这款处理器有20根地址线,可以支持1M地址空间。但是这款处理器的数据总线宽度是16位,也就是指令寄存器IP和其他通用寄存器都是16位的,那么指令只能寻址64KB的地址空间。为了解决地址空间的Gap问题,Intel引入了段的概念,8086中有4个段基地址寄存器cs、ds、es和ss,用于存储段的起始地址,其他寄存器存储的是段内偏移。
分段机制下逻辑地址表示为:段基地址+段内偏移,处理器的段单元(也叫地址加法器)通过如下公式将逻辑地址转为线性地址:段基地址<<4 + 段内偏移,这样就生成了20位物理地址,此时指令即可寻址1M地址空间了。
在实模式下,一个主要的问题是没有内存保护,一个程序可以访问整个1M地址空间中其他程序的代码或数据。另外实模式下对程序访问的资源、执行的指令也没有保护,任何程序都可以执行一些可能导致系统崩溃的CPU指令。从80286开始,Intel逐步引入保护模式,到80386时保护模式得到了全面应用。保护模式引入了段描述符表,段描述符表中每个描述符对应一个段,其中包括段的基地址、长度和权限等属性。段描述符分为GDT和LDT两种,GDT存储全局的段,每个任务都有自己的LDT存储自己的段,通过段选择子作为索引选择段描述符中的段。相比实模式下的段寄存器,每个段描述符有64位,可以存储更多的段属性,可以实现对内存的保护。保护模式下,段寄存器不再保存段基地址,而是一个索引,用于从段描述符表中索引具体的段。
IA-32 中,保护模式下的内存管理分为分段和分页,分段是强制的,分页是可选的,分页机制建立在分段的基础上。分段机制将代码、数据和堆栈分开,当处理器上运行多个程序时,每个程序拥有一系列自己的段,使得不同程序间不会互相影响;分页机制将物理内存以页为单位进行分割,并按需调度,可提高内存的使用效率。在未使用分页机制时,段处理单元将段基址加上段内偏移得到的线性地址即为物理地址;而使用分页机制之后,段处理单元产生的线性地址不再是物理地址,此时的线性地址也称为虚拟地址,线性地址经过页部件可转换为物理地址。
分段机制的流程如下:
-
根据段选择符的 TI 字段,决定从 GDT(gdtr 寄存器) 还是 LDT(ldtr 寄存器) 中取段描述符,获得一个4字节表头地址。
-
将段选择符的 index 字段(13位,表示第index个)乘以 8(段描述符大小)获得相对地址,再与 gdtr 或 ldtr 寄存器中的内容相加,得到段描述符地址。
-
逻辑地址的偏移量与段描述符的 Base 字段值相加,获得对应线性地址,即虚拟地址。
Intel为了考虑向后兼容,为系统设计者建议了一种平坦内存模型来兼容段式内存。平坦模型创建4个段用于特权级3的用户代码段、数据段以及用于特权级0的内核代码段、数据段,Linux中这些段描述符的定义如下:
这4个段描述符对应的段选择子定义如下:
从上面段描述符的定义可以看出这几个段的BASE都是0x00000000,LIMIT都是0xfffff,并且G为1即以4K为单位。这意味着用户代码段、用户数据段、内核代码段、内核数据段这四个段它们的寻址地址都是0x00000000~0xffffffff,也就是地址0-4G,从而所有进程都可以使用同一个用户代码段和用户数据段。根据上面的分段机制流程,当Base为0时逻辑地址=线性地址(其实这两个地址都是Offset的值),这样几乎完全隐藏了分段机制,全部应用程序都在同一个段地址空间内,逻辑地址=线性地址=虚拟地址,保护模式下应用程序内存管理就像跳过分段机制直接走分页机制了。
Linux中所有的用户进程都是使用同一个用户代码段描述符和用户数据段描述符,它们是__USER_CS和__USER_DS,也就是每个进程处于用户态时,它们的CS寄存器和DS寄存器中的值是相同的。当任何进程或者中断异常进入内核后,都是使用相同的内核代码段描述符和内核数据段描述符,它们是__KERNEL_CS和__KERNEL_DS。Linux禁止用户态访问内核态的数据,但是用户态可以通过系统调用与中断和异常访问内核态。
分页机制
分页机制的基本思想是将应用程序的虚拟地址空间划分成连续的、等长的物理页,虚拟页和物理页的页长固定且相等。操作系统为每个应用程序构造页表,保存虚拟页和物理页之间的映射。在分页机制下虚拟地址表示为:虚拟页号+页内偏移,MMU首先解析得到虚拟地址中的虚拟页号,之后查询页表基地址寄存器中保存的当前应用程序的页表,找到虚拟页对应的物理页地址,叠加页内偏移得到最终物理地址。分页机制相比分段机制可以实现更细粒度的内存管理,任意的虚拟页可以映射到任意的物理页,大大的缓解了分段机制中的外部碎片问题。
页表是分页机制中的关键部分,如果使用一张简单的单级页表来记录映射关系,对于64位的虚拟地址空间,每个进程都需要维护2^64/4KB*8=33554432GB。使用这么多内存来维护页表是不现实的,为了压缩页表大小,操作系统中引入了多级页表结构。多级页表中虚拟地址依然是虚拟页号+页内偏移,只不过虚拟页号被划分为k个部分。虚拟页号i对应该虚拟地址在第i级页表中的索引。这样当任意一级页表中某一个条目为空的时候,该条目对应的下一级页表及其之后的页表就不需要存在了,这种多级页表的设计极大的减少了页表占用的内存空间。实际应用程序的虚拟地址空间绝大部分都是未分配状态,只需要为分配状态的地址分配相应的页表,所需内存很少。
x86从最开始的2级页表(32位CPU,10:10:12,每个4K页可以保存2^10个4字节条目),再到4级页表(64位CPU,9:9:9:9:12,每个4K页可以保存2^9个8字节条目),4K页内偏移需要12个比特位描述。
Linux内核中使用了一种适合32位和64位结构的通用分页模型,该模型使用四级分页机制,即
-
页全局目录(Page Global Directory)
-
页上级目录(Page Upper Directory)
-
页中间目录(Page Middle Directory)
-
页表(Page Table)
-
页内偏移(Page Offset)
最新的IceLake已经将寻址所用的位数从48扩展到57,使用5级页表,支持虚拟内存空间从256TB到128PB。
每个Page都有时间戳和权限控制,有些Page是进程独享的,有些Page是进程间共享的(比如shared library),有些Page是可读可写的,有些Page是只读的。
保护模式下,由逻辑地址转换为物理地址的详细过程如下图所示,地址转换需要经过逻辑地址转换和线性地址空间映射。根据上面Linux中段描述符中Base为0,逻辑地址=线性地址=虚拟地址。
TLB
多级页表结构能够显著的压缩页表大小,但是会使得MMU在翻译虚拟地址的过程中多次查找页表,触发多次物理内存访问,从而导致地址翻译时长的增加。为了减少MMU地址翻译过程中访问内存的次数,加速地址翻译的过程,现代CPU都引入了转址旁路缓存(Translation Lookaside Buffer),简称TLB。TLB是属于MMU内部的单元,其缓存了虚拟页号和物理页号的映射关系,可以简单将TLB理解为一个键值对哈希表,其中键是虚拟页号,值是物理页号。
作为CPU的内部硬件部件,TLB的体积需要很小,这就意味着其缓存项数量是极其有限的。当TLB未命中时,硬件将通过页表基地址查询页表,找到对应的页表项并将翻译结果写到TLB中;若TLB已满,则根据硬件预定的策略替换掉其中的一项。之后再次翻译相同的虚拟页号,硬件就会迅速的从TLB中直接命中查询到对应的物理页号。虽然TLB缓存项有限,但是有雨内存访问的时间局部性和空间局部性,TLB缓存项在将会很可能会被多次查询,即发生TLB命中的可能性较大。TLB命中的查询耗时大概1个cpu cycle。
引入TLB之后带来了一个新的挑战就是如何保证TLB中的内容与当前页表内容的一致性,比如进程发生了切换导致页表也发生了切换,这个时候再去查询TLB可能会命中返回上一个进程的虚拟页和物理页的映射。由于TLB是使用虚拟地址进行查询的,所以操作系统在进行页表切换的时候需要主动刷新TLB。x86体系结构中只有一个页表基地址寄存器CR3,操作系统没有单独的页表,而是把自己映射到应用程序页表的高地址部分,从而实现系统调用过程中也不需要切换页表,也能避免TLB刷新的开销。
在应用程序切换过程中刷新TLB,会导致切换执行的进程总是发生TLB未命中,不可避免的造成性能下降。现代CPU设计了一种为TLB缓存项打上"标签"的设计来避免这种情况。x86-64上对应的功能叫PCID,Process Context Identifier,这样操作系统可以为不同的应用程序分配不同的PCID作为应用程序的身份标签,在将这个标签写入应用程序的页表基地址寄存器的空闲位,这样TLB缓存项也会包含这个PCID标签,从而使得切换页表时不再需要清空TLB缓存项。不过在页表内容修改之后,操作系统还是需要主动刷新TLB用来保证TLB缓存项和页表项内容一致。
换页与缺页异常
虚拟页分配之后不一定在物理页中映射,这主要是考虑两种场景:多个应用程序运行所需的内存超过物理内存,应用程序分配的内存并没有完全使用到。虚拟内存中的换页机制就是为了透明的解决这样的场景,操作系统会在磁盘上划分专门的Swap分区,用于存储没有与虚拟页映射的物理页。比如第一种场景中,内存紧张的时候操作系统会回收进程A的虚拟页V对应的物理页P,将其写入到磁盘上,并在页表中去除虚拟页V的映射,同时记录物理页在磁盘上的位置。这个过程称之为物理页P的换出,物理页P可以分配给其他应用程序使用,这个时候虚拟页V就处于已分配但未映射至物理内存的状态。
缺页异常是与换页机制密不可分的,也是换页机制能够工作的前提。当应用程序访问已分配但未映射至物理内存的虚拟页时,就会触发缺页异常。此时CPU会运行操作系统预先设置的缺页异常处理函数,该函数会找到一个空闲物理页(也有可能是换页换出来的一个空闲页),将之前写到磁盘上的物理页P重新加载到新的空闲页中,并在页表中填写虚拟地址到新空闲物理页的映射,这个过程称之为换入。之后CPU回到发生缺页异常的地方继续执行。在x86-64体系结构中,缺页异常会触发13号异常(#PF),并且访问出错的虚拟地址会被放在CR2寄存器中。
换页机制在不修改应用程序的前提下解决了场景一中物理内存不足的问题,但是换页导致内存访问性能下降。考虑到应用程序访存具有时空局部性,可以在缺页异常处理函数中采用预取(Prefetching)机制,这样即节约内存又能减少缺页异常次数。虚拟内存的按需页分配机制就是解决场景二的,应用程序分配内存的时候,操作系统将分分配的虚拟页标记未已分配但未映射至物理页,等应用程序访问的时候再触发缺页异常分配对应的物理页并设置页表中的虚拟页和物理页映射。
一个虚拟页处于"未分配"和"已分配但未映射至物理页"的状态时,应用程序访问该虚拟页都会触发缺页异常,操作系统在缺页异常中需要针对两种状态进行区分。Linux中应用程序的虚拟地址空间被划分为多个虚拟内存区域(Virtual Memory Area,VMA),缺页异常处理函数会判断虚拟页是否属于某个VMA来判断是否属于已分配状态,如果属于就说明该页属于"已分配但未映射至物理页"状态,否则则说明该页处于"未分配"状态。
设备管理
设备通过总线与CPU相连,常见的设备总线有PCI/PCIe。符合PCI总线定义规范的设备,都能够通过PCI插槽与CPU进行通信。每个PCI设备都有唯一识别码用于在PCI设备枚举阶段快速识别设备,包括总线号、设备号、功能号。PCIe总线使用基于数据包的串行连接协议解决PCI总线在数据传输速率过高时的线路间干扰问题,提高了传输带宽同时保持了PCI设备驱动的兼容性。
CPU通常以读写设备寄存器的方式与设备进行通信。设备中有控制寄存器用于接收驱动命令,状态寄存器反馈设备工作状态,输入输出寄存器用于数据交互,这些寄存器可以在内存地址空间也可以在IO地址空间中被访问。CPU与设备进行数据交互有两种方式:
-
可编程IO:将外设的寄存器和内存跟物理内存放在统一地址空间,CPU通过in/out或者是load/store指令进行读写,消耗CPU时钟周期和数据量成正比,适合于简单小型的设备。
-
直接内存访问DMA:外设可直接访问总线,DMA与内存互相传输数据,传输依赖于DMA控制器不需要CPU参与,适合于高吞吐量I/O。
DMA传输
DMA的发起者可以时处理器也可以时IO设备。以处理器发起DMA为例,设备驱动首先在内存中分配一块DMA缓冲区,随后发起DMA请求,设备收到请求后通过DMA机制将数据传输至DMA缓冲区。DMA操作完成后,设备触发中断通知处理器对DMA缓冲区中的数据进行处理。
设备寻址
通常CPU执行代码中进行内存访问使用的都是虚拟地址,通过MMU翻译成物理地址。设备进行DMA的时访问的内存地址是总线地址,其不同于虚拟地址和物理地址。当操作系统向DMA控制器注册DMA内存缓冲区时,需要填写的是总线地址。设备和内存之间的输入输出内存内存管理单元IOMMU会负责将总线地址翻译为物理地址。
设备识别
计算机系统需要提供一定的机制帮助操作系统识别已经连接的设备,当前常见的方案是是使用高级配置与电源接口(Advanced Configuration and Power Interface),简称ACPI。ACPI为设备和操作系统之间提供了一层抽象,统一向操作系统呈现硬件设备情况并提供管理设备的接口和方法,其分为两部分:
-
ACPI表:描述当前计算机系统的各种运行状态,包括多处理器信息、NUMA的配置信息等。
-
ACPI运行时:ACPI提供了ACPI机器语言AML,操作系统通过执行AML代码和底层固件进行交互,进而完成对设备的识别和配置功能。
设备中断
CPU可以通过MMIO方式对设备进行读写,但是因为CPU不不知道设备的状态,需要不断轮询设备的状态寄存器来决定是否进行下一次操作。为了解决这种CPU效率低下的问题,引入了设备中断允许设备主动告知CPU一个外部事件的发生。为了响应中断,操作系统中实现了中断处理函数(IRQ Handler)对中断进行处理,在Linux中分为上半部的硬中断(中断服务例程 Interrupt Service Routine)和下半部的软中断Tasklet。
中断管理
中断控制器
早期CPU使用特定物理地址段作为中断引脚来接收中断,但是这种设计限制了设备的接入种类和数量,而且不能够实现中断的优先级来优先响应更重要的中断信号。现代计算机架构中都引入了中断控制器进行管理中断。中断控制器发展至今经历了PIC(Programmable Interrupt Controller,可编程中断控制器)和APIC(Advanced Programmable Interrupt Controller,高级可编程中断控制器)两个阶段。
PIC
可编程中断控制器(Programmable Interrupt Controller)简称PIC,早期通常由两片8259A以级联的方式连接在一起,可用 IRQ 线的个数可以达到 15 个。8259A除了起到向CPU引入多个外部中断源的作用外,还起到如中断分级、中断屏蔽,中断管理(存储中断向量)等功能。PIC中有三个重要的寄存器:
-
IRR(Interrupt Request Register,中断请求寄存器):共8位,对应IR0-IR7这8个中断管脚,某位置一代表收到对应管脚的中断但还没有提交给CPU。
-
ISR(In Service Register,服务中寄存器):共8位,某位置一代表对应管脚的中断已经提交CPU处理,但是CPU还没有处理完。
-
IMR(Interrupt Mask Register,中断屏蔽寄存器):共8位,某位置一代表对应的中断管脚被屏蔽。
除此之外,PIC还有一个EOI位,当CPU处理完一个中断时,通过写该位置告知PIC中断处理完成。PIC向CPU递交中断的时候,将中断信息写入IRR寄存器,并通过拉高INT管脚电平通知CPU,CPU通过INTA管脚通知PIC收到中断,PIC清除IRR中断寄存器中对应位并置位ISR中断寄存器中的位,等待CPU写EOI之后将ISR寄存器对应位清除。
APIC
PIC可以在单处理器平台上工作,但是无法用于多处理器平台。随着SMP架构的发展,Intel在2000年左右引入了高级可编程中断控制器的新组件(Advanced Programmable Interrupt Controller),简称APIC来替代老式的 8259A 可编程中断控制器。APIC包括两部分:一是“本地 APIC(Local APIC)”,主要负责传递中断信号到指定的处理器, 本地APIC通常集成到CPU内部,之所以成为Local,是相对CPU而言。 另外一个重要的部分是主板南桥中的 I/O APIC,主要是收集来自 I/O 设备的 Interrupt 信号且将中断时发送信号到本地 APIC。Local APIC通常集成在CPU内部, 外部与I/O APIC相连,内部与CPU管脚LINT0和LINT1相连。
本地APIC除了接收来自IO APIC的中断信号,还可以接收其他来源的中断, 比如接在CPU LINT0和LINT1管脚上的中断、IPI中断(核间中断)、APIC定时器产生中断、性能监视计数器中断、 热传感器中断、APIC内部错误中断等。APIC主要解决两个核心问题:
-
CPU对多个外设的中断的管理
-
多CPU的中断管理
IOAPIC通常有24个不具有优先级的管脚用来连接外部设备,当收到某个管脚的中断信号后,IOAPIC根据操作系统设置的PRT(Programmable Redirection Table)表查找到管脚对应的RTE(Redirection Table Entry),通过RTE的各个字段格式化出一条包含该中断全部信息的中断消息,经由系统总线发送给特定CPU的LAPIC。LAPIC收到中断消息后择机将中断递交给CPU处理。LAPIC中也有IRR、ISR和EOI寄存器,只有对应的位数不同。
在SMP平台上多个CPU要协同工作,处理器间中断(Inter-Processor Interrupt,IPI)提供了CPU间互相通信的机制,CPU可以通过LAPIC的ICR(Interrupt Command Register,中断命令寄存器)向指定的一个或多个CPU发送中断。操作系统通常使用IPI来完成诸如进程转移、中断平衡和TLB刷新等工作。
中断可以简单的分为:LAPIC内部中断/IPI中断/外设中断:
-
LAPIC内部中断如local timer,就在CPU内产生的,直接就打断CPU执行。
-
IPI中断是不同CPU间中断,本CPU把中断目的CPU的LAPIC编号写到自己的LAPIC中,然后写自己LAPIC的ICR,通过APIC BUS或者系统总线就把中断送到目的CPU的LAPIC,目的CPU的LAPIC再打断自己CPU的执行。
-
外设中断先到了IOAPIC,它根据Redirection Table Entry把中断路由到不同CPU的LAPIC,可以静态配置或者动态调整,它根据所有CPU的TPR选择优先级最低的CPU。外设还有一种方式就是用MSI方式触发中断,直接写到CPU的LAPIC,跳过了IOAPIC。驱动给外设的PCI配置空间写MSI的信息,外设有Message Address Register和Message Data Register,写这两个寄存器就能把中断投递到LAPIC中。
由于CPU的核心越来越多,APIC的编号位数不够用了,出了升级版的xAPIC和x2APIC,xAPIC用memory mapping方式访问,x2APIC用读写MSR寄存器的方式访问。
MSI
MSI(Message Signaled Interrupts)中断模式在1999年作为可选方式引入PCI规格中,随着2004年PCIe设备出现MSI成为PCIe设备必须要实现的一个标准,MSI中断模式绕过了IO APIC,允许设备直接写LAPIC。MSI模式支持224个中断,由于中断数目较多,不再允许中断共享。MSI模式下中断的分发和处理的流程如下:
-
设备初始化时向设备的PCI配置空间写入LAPIC的物理地址。
-
如果设备需要发出一个中断,那么它通过PCI配置空间的初始化LAPIC物理地址直接将中断向量号写入对应CPU的LAPIC。
-
被中断的CPU开始执行与该中断向量号对应的中断处理例程。
MSI和INTx是PCI/PCIe总线的两种中断方式。PCI总线里INTx中断是由4条可选的中断线决定的,INTx中断是共享的,多个PCI设备的中断通过中断控制器发到同一条中断线上,CPU收到中断信号之后再查询中断控制器具体是哪个设备发生了中断,具体的中断向量号是多少。PCIe总线没有了实体的INTx物理中断线,使用专门的Message事务来实现INTx中断来兼容以前的PCI软件。
对于PCI设备来讲,INTx和MSI中断两者智能选择一种。INTx是共享式的,需要CPU和中断控制器来交互确认中断源,效率比较低。除了共享的区别之外,传统中断到达时数据可能并没有准备好,MSI能够确保数据在中断时已经Ready;另外对于一些多功能设备,MSI可以允许更多的中断类型,而传统中断每个设备最多只能有4个中断引脚。
MSI方式的中断对应PCI 2.2规范,单个MSI地址,支持32个消息,而且要求中断向量连续;而MSI-X(Extension to MSI)方式的中断对应PCI3.0规范,支持2048个消息且每个消息可对应一个独立的MSI地址,而且并不要求中断向量连续。
中断与异常
-
中断(Interrupt)是由外部硬件设备所产生的信号,是异步的(产生原因和当前执行指令无关,如程序被磁盘读打断)。有些中断可以通过中断控制器进行屏蔽,如键盘、磁盘和网络事件;有些不可屏蔽,如关键的内存校验错误等。
-
异常(Exception)是软件的程序执行而产生的事件(比如系统调用),是同步的(产生和当前执行或试图执行的指令相关)。
-
错误Fault:缺页异常(可恢复)、段错误(不可恢复),异常处理程序返回地址是产生Fault的指令(CPU需要保存产生Fault之前的状态)。
-
陷阱Trap:无需恢复,如断点Int 3、系统调用Int 80,异常处理程序返回地址是产生Trap的指令之后的那条指令。
-
中止Abort:严重的错误,不可恢复
-
中断向量和IDT表
为了区别这些中断和异常,x86中使用中断向量(Interrupt Vector)来区分中断和异常, 中断向量号的范围是0~255,0~31号为处理器保留用作异常和中断标识, 32~255为User Defined Interrupt通常分配给外部IO设备。
中断向量是中断向量表/中断描述符表(IDT)的索引,而中断向量表存在于内存的某个位置, 由IDTR寄存器记录其基址(线性地址)和大小。实模式下称之为IVT中断向量表,保护模式下称之为IDT中断描述符表。IDT表中保存门描述符,包括任务门、中断门和陷阱门描述符。这些门描述符中包含了操作系统中注册的外部IO中断的处理程序的入口地址以及其他操作系统实现的架构相关的中断和异常的处理函数入口地址(这些地址又存放在所谓的gate destribtor中)。 INTR和IDT的关系如下图所示:
中断处理过程
x86提供了中断向量和中断描述符表(IDT)的机制来处理中断和异常。 中断按照下列逻辑触发并被执行:
-
外设事件发生(如键盘敲击、网络报文到达等);
-
电平信号变化通知中断控制器发生了一次中断;
-
中断控制器通知CPU此次中断的中断向量号;
-
CPU判定是否要处理此次中断,如果要处理,转5,否则退出;
-
从IDTR寄存器读取IDT的基址+中断向量号,获得其对应的门描述符,找到对应的中断处理函数入口地址;
-
CPU按照某种软件策略执行该中断处理函数;
具体流程如下图所示:
时钟管理
在现代计算机架构中,时钟有着重要的低位,操作系统中很多事件都是由时钟驱动的,例如进程调度、定时器等。时钟根据工作方式不同,可以分为两类:
-
周期性时钟:这是最常见的方式,时钟以固定频率产生时钟中断。比如PIT计数器从固定值递减到0产生中断,HPET固定增长超过阈值产生中断并自动增加阈值。
-
单次计时时钟:大多数时钟都可以配置成这种方式,例如PIT、HPET,跟到达阈值产生中断的周期性时钟类似,不同是的阈值的变更需要软件介入设置,允许软件动态调整下一次时钟到来时间的能力。
x86平台有如下几种时钟:
-
PIT(Programmable Interrupt Timer或Programmable Interval Timer,可编程中断/间隔时钟):其频率位1000HZ,即每次中断间隔1ms,精度较低,支持周期性和单次计时两种模式。
-
RTC(Real Time Clock,实时时钟):集成在CMOS中,由CMOS电池供电,支持周期性和单次计时两种模式。由于断点后继续计时,通常被被用于为操作系统提供时间。
-
TSC(Time Stamp Counter,时间戳计数器):单调递增计数器,时钟频率与CPU频率相关,操作系统使用前需要计算其频率。通过rdtsc指令读取当前TSC值,不产生时钟中断,即没有周期性和单次计时模式。
-
LAPIC Timer:根据LAPIC所在总线频率产生的,其中断是对于本地CPU的,同时可以设置寄存器实现不同频率的时钟中断。
-
HPET(High Precision Event Timer,高精度时间时钟):Intel和Microsoft共同开发的高精度时钟,最低频率为10MHZ,可以提供最多8个时钟,可以代替PIT和RTC,支持周期性和单次计时两种模式。
从操作系统的视角来看,时钟的作用分为两类:
-
提供统计值及驱动事件:提供统计值是指操作系统利用时钟维护一些必要数据,比如一个进程在用户态和系统态的时间、系统的日期和时间等;驱动事件是指驱动以时间为资源的程序,典型的就是进程,比如操作系统为进程分配时间片,调度时间片耗尽的进程睡眠,唤醒分配到新的时间片的进程运行。
-
维护定时器:操作系统中大量使用定时器在某个制定时间到达后执行特定的操作,比如IO超时定时器、为应用程序提供的定时器接口等。
Intel主板
Intel 440FX
Intel 440FX(i440fx)是Intel在1996年发布的用来支持Pentium II的主板,是一代经典的架构。下图是i440fx芯片架构:
i440fx北桥:包括PMC(PCI Bridge and Memory Controller)以及DBX(Data Bus Accelerator),向上连接多个处理器,向下连接内存和PCI根总线,PCI根总线可以衍生出一个PCI设备树。
piix3南桥:用于连接慢速设备,包括IDE控制器、USB控制器,还会连接ISA总线,传统的ISA设备(比如软驱、串口)可以借南桥连接到系统。
中断控制器I/O APIC是直接连接到处理器的,设备的中断通过I/O APIC路由到处理器。
Intel Q35
Intel不断推出PCIe、AHCI等新芯片组,i440FX已经无法满足需求。Q35是Intel在2007年6月推出的芯片组,最吸引人的就是其支持PCI-e。
可见其北桥为MCH,南桥为ICH9。CPU 通过前端总线(FSB) 连接到北桥(MCH),北桥为内存、PCIE等提供接入,同时连接到南桥(ICH9)。南桥为 USB / PCIE / SATA 等提供接入。
x86启动流程
整个x86硬件启动并引导操作系统启动的流程如下:
整个流程可以分为:
BIOS启动引导、BootLoader启动引导、Kernel初始化
BIOS启动引导阶段
BIOS(Basic Input Output System)是一组固化到计算机内主板上一个ROM芯片上的程序,它保存着计算机最重要的基本输入输出的程序、开机后自检程序和系统自启动程序,它可从CMOS中读写系统设置的具体信息。 其主要功能是为计算机提供最底层的、最直接的硬件设置和控制。在硬件上电后为操作系统的启动做准备工作,然后将操作系统加载到内存启动操作系统。
BIOS调用Bootloader来把操作系统的内核映像加载到系统RAM中,具体流程如下:
-
当PC的电源打开后,80x86架构的CPU将自动进入实模式,并从地址0xFFFF0(CS:0xFFFF,IP:0x0)开始自动执行程序代码,这个地址通常是BIOS的地址。
-
BIOS的首先进行POST(Power On Self Test即加电后自检),检测系统中一些关键设备是否存在和能否正常工作,例如内存和显卡等设备。此时显卡还没有初始化,如果发现了一些致命错误,例如没有找到内存或者内存有问题(此时只会检查640K常规内存),BIOS会直接控制喇叭发声来报告错误,声音的长短和次数代表了错误的类型。(为了兼容之前的CRT显示器,BIOS完成显卡初始化之后使用VGA分辨率640*480 4:3显示,实模式最高支持的就是VGA)。
-
POST自检之后进入ESCD(Extended System Configuration Data)更新阶段,BIOS将对存储在CMOS中和操作系统交换的硬件配置数据进行检测,如果系统硬件发生变动,则会更新该数据,否则不更新保持原状不变,ESCD检测或更新结束后,BIOS将完成最后一项工作,就是启动操作系统。
-
BIOS从物理地址0处开始初始化中断向量(这个BIOS的中断向量很重要,后边的很多和硬盘等的交互都是通过此中断向量完成的),然后根据CMOS中用户指定的硬件启动顺序,读取相应设备的启动或者是引导记录。
-
BIOS将启动设备的第一个扇区(第0磁道第一个扇区被称为MBR即主引导记录,它的大小是512字节,里面存放了用汇编语言编写的预启动信息、分区表信息、魔数0x55AA),读入内存绝对地址0x7C00处,并跳转到这个地址并执行。其实被复制到物理内存0x7C00处的内容就是Boot Loader,对于较早的内核不靠grub启动的,它就是bootsect.S程序,而对于现在PC多数使用grub引导启动的。
加载内核之后的内存布局如下所示:
上面描述的BIOS启动流程是传统BIOS的启动流程,传统BIOS也称为Legacy BIOS。传统BIOS使用Int 13中断读取磁盘,每次只能读64KB,启动过程较慢。
UEFI启动
UEFI全称“统一的可扩展固件接口”(Unified Extensible Firmware Interface) 是一种详细描述类型接口的标准,最初源于2001年Intel推出IA-64架构时提出的EFI,后来在EFI 2.0时吸引了其他公司加入改名为UEFI。UEFI使用模块化、高级语言(主要是C语言)构建一个小型化操作系统,在启动过程中完成硬件初始化,直接利用加载EFI驱动的方式来识别系统硬件并完成硬件初始化,彻底摒弃BIOS读各种中断执行的方案。UEFI定义了一个CPU无关的虚拟机,来加载和执行EFI设备驱动。UEFI虚拟机的指令集叫EFI Byte Code,简称EBC。UEFI启动过程中的DXE(Driver Execution Environment)阶段解释运行EFI启动,这样EFI既可以实现通配,又提供了良好的兼容。
UEFI启动流程如下:
-
系统开机,CPU加载并执行UEFI固件中的预加载环境,完成CPU和内存的初始化(内存没有进入保护模式,但是使用平坦模式突破实模式的1M寻址限制)。这个过程如果出错,因为没有8255的驱动无法发声报警,可以通过长时间无法显示或者是主板上报警灯来判断。
-
CPU和内存初始化成功后,加载并执行UEFI固件中的驱动执行环境DXE(Driver Execution Environment)。DXE会枚举并加载UEFI驱动程序,完成除CPU和内存外的硬件初始化工作。这个过程要比BIOS读中断来加载快很多。
-
启动操作系统的阶段,根据启动记录的启动顺序,转到相应设备(仅限GPT设备,如果启动传统MBR设备,则需要打开CSM支持)的引导记录,引导操作系统并进入。
Legacy启动搭配MBR分区模式,UEFI启动搭配GPT分区(GUID Partition Table)模式。MBR至支持4个主分区,硬盘分区最大仅支持2T容量;GPT支持任意多的分区,每个分区大小原则上是无限制的,突破MBR的2T限制。Legacy BIOS是16位汇编语言程序,只能运行在16位实模式,可访问的内存只有1MB。UEFI是32位或64位高级语言程序(C语言程序),突破实模式限制,可以达到要求的最大寻址。UEFI相比BIOS具有启动速度快(每次可以读取1MB)、安全性高(支持硬件固件验证)和支持大容量硬盘(MBR最大只能识别2T)的特点。目前只有x86支持Legacy启动,新的x86服务器也开始逐步使用UEFI启动,ARM服务器都是UEFI启动。
PXE启动
预启动执行环境PXE(Preboot eXecution Environment)提供了一种使用网络接口启动计算机的机制,这种机制让计算机的启动可以不依赖本地数据存储设备(如硬盘)或本地已安装的操作系统。PXE是1998年Intel引入的,借助TFTP/BOOTP/DHCP协议和NIC上的BIOS扩展实现计算机通过网络启动,现在PXE已经是UEFI标准中的一部分。
PXE有四种启动模式,分别为 IPv4 legacy、IPv4 UEFI、IPv6 legacy、IPv6 UEFI,这里主要介绍IPv4 legacy启动模式需要的文件。
-
pxelinux.0:引导程序(bootstrap)负责系统引导和启动,作用类似于BIOS会调用PXE相关配置文件
-
pxelinux.cfg:PXE配置文件
-
vmlinuz:linux的内核文件,可以被引导程序加载来启动Linux系统
-
initrd.img:Boot Loader Initialized RAM Disk的缩写,作为根文件系统加载各种模块、驱动、服务等,网卡驱动就包含在该文件中
PXE启动流程如下:
-
BIOS从NIC的Expansion ROM中加载PXE预启动执行环境进行执行,一般称为PXE Client。
-
PXE Client向DHCP服务器发送DHCPDISCOVER请求获取IP地址,DHCP服务器返回DHCPOFFER消息,其中包括Client的IP地址以及引导程序pxelinux.0文件在TFTP服务器上的位置信息。
-
PXE Client向TFTP服务器发送GET请求获取pxelinux.0引导程序文件。
-
PXE Client执行pxelinux.0引导程序文件,并向TFTP服务器请求pxelinux.0的配置文件pxelinux.cfg。
-
PXE Client加载pxelinux.cfg引导程序配置文件,并向TFTP服务器请求内核文件vmlinuz和根文件系统initrd.img。
-
PXE Client启动内核映像文件,执行系统操作系统启动流程。
通常PXE启动文件很小,只是一个裁剪的操作系统,它的主要作用是重新下载真正的操作系统镜像(一般通过HTTP下载或者是ISCSI挂载到本地再拷贝),安装到本地磁盘再重启执行。OpenStack社区中的Ironic就是先通过PXE下载一个deploy操作系统,然后deploy操作系统中执行脚本通过iscsi挂载镜像到本地,拷贝再dd到本地磁盘,最终再重启完成装机过程(重启是通过IPMI向BMC发送重启指令实现的)。PXE启动文件功能有限且只能从tftp服务器上获取,服务器厂商设计了新的iPXE,支持从HTTP、iscsi SAN、 Fibre Channel SAN、AoE SAN等多种方式启动,甚至还支持无线网卡。
PCI/PCIe ExpansionOption ROM启动
上面启动的过程中BIOS会从Boot顺序配置的Device中尝试读取第一个扇区来启动,但是需要BIOS中有对应的驱动才能执行。如果BIOS阶段要使用一个PCIe设备,但是原始的BIOS不包含这个设备的驱动程序,就存在设备因为没有驱动导致bios无法读取boot loader的问题。硬件厂商通过PCI/PCIe Expansion ROM来解决这个问题,Expansion rom是pci/pcie设备可选的一个外接的eprom芯片,其中用来存储相应pci设备的初始化代码或者系统启动代码(比如pxe或者pci boot)。BIOS在POST(Power-on Self Test)阶段,会扫描pci设备是否有expansion rom,有的话将其拷贝到ram中执行。在PCI规范中称为expansion rom,在BIOS术语里面称为option rom。
在pci/pcie设备的配置空间中关于expansion rom的定义在30h寄存器,根据PCI文档规定Expansion ROM最大可为16MB。ROM存了设备的驱动程序代码,专业点叫做code image。实际上PCI协议规定ROM里可以存放不只一份code image,不同的code image对应不同的处理器架构、同一供应商的不同产品等等,BIOS或者其他软件需要选择最合适的code image提取到内存中执行。比如CX4网卡的expansion rom上保存了x86和arm的驱动,bios根据arch id选择对应体系结构的驱动,实现同一张网卡在不同体系结构下的适配。
expansion rom中每份image的开始地址都是以512bytes对齐,image由PCI Expansion ROM Header Format和PCI Data Structure Format两部分组成,具体的格式定义如下图:
BIOS的POST阶段,扫描并执行pci设备的expansion的过程大概分以下几步:
-
先判断pci设备是否实现“Expansion rom base address”寄存器,有则进行下一步判断;
-
如果有实现了expansion的基址寄存器,则配置和使能expansion rom,然后查找expansion rom是否有”AA55”的标示字符(AA55是BootLoader的MagicNumber),如果有则说明设备有真实的expansion rom芯片存在;
-
如果expansion rom已经存在,则扫描PCI Expansion ROM Header中的CodeType查看是Legacy(CodeType=0)还是UEFI模式(CodeType=3),如果是UEFI启动会读取EFI头部中的Machine Type,检查是否适合本设备和本CPU架构的image代码存在;
-
如果有适合本环境的image代码存在,则把相应的代码拷贝到ram的合适位置,并跳入header format中指定的初始化入口执行;
-
最后关闭expansion rom的使能。
BlueField1支持云盘启动的时候,服务器上使用AMI BIOS,AMI支持NVMe驱动,但是无法驱动起BlueField1模拟的NVMe盘。当时的解决方法是使用BlueField1卡上Expansion ROM中的驱动。当前的智能网卡都是支持CodeType=1和CodeType=3,实现一个Legacy 16位Code Image和32位 UEFI的Code Image到PCIe Expansion ROM中。
BootLoader启动引导阶段
Bootloader程序是为计算机加载计算机OS内核的。bootloader程序通常位于硬盘上,被BIOS调用用于加载内核。GRUB(GRand Unified Bootloader)是当前linux诸多发行版本默认的引导程序。这样的bootloader一般位于MBR的最前部。Grub运行后,将初始化设置内核运行所需的环境,然后加载内核镜像。grub磁盘引导全过程如下:
-
stage1:grub读取磁盘的第一个512字节的主引导记录MBR。
-
stage1.5:识别各种不同的文件系统格式,目的是为了grub能识别到文件系统。
-
stage2:加载系统引导菜单(/boot/grub/menu.lst或grub.lst),加载内核vmlinuz和RAM磁盘initrd。
当BootLoader工作结束之后的内存布局如下图所示:
Linux内核启动过程
内核映像文件vmlinuz是包含有linux内核的静态链接的可执行文件,通常vmlinux被称为可引导的内核镜像,vmlinuz是vmlinux的压缩文件。其构成包括:
-
第一个512字节的bootsect(第一个扇区,历史遗留代码,主要是考虑到没有BootLoader进行启动的情况)
-
第二个是实模式下的setup代码(若干扇区,物理内存低640K以下)
-
保护模式下的内核代码(物理内存1M以上)
内核的启动实际上从第二部分的实模式入口开始执行的,主要的工作是配置中断描述符表和全局描述符表(实模式下中断向量表在0位置,保护模式中断向量表保存在IDTR寄存器中),然后设置CR0寄存器中的PE位来启用保护模式。
进入保护模式之后第一件事是将内核镜像解压,物理内存0x100000(1M)开始就是保护模式内核入口点,跳转过去之后重建全局描述符表和中断描述符表,并构建页表以支持分页机制。最后开始执行最终的内核启动函数。
内核启动函数中初始化调度系统、内存管理、定时器等,启动一个内核线程执行其他CPU的初始化,同时启动调度器并创建一个cpu_idle任务。最终完成内核的启动过程。
系统虚拟化
虚拟机监控器
系统虚拟化技术要解决的是一台物理机上创建多台虚拟机,并从应用程序的视角虚拟机和真实物理机几乎没有区别。在讨论系统虚拟化技术之前需要考虑从操作系统角度看"Machine",ISA 提供了操作系统和Machine之间的界限。系统虚拟化技术的核心是虚拟机监控器VMM,一般运行在CPU的最高特权级,直接控制硬件,并为上层虚拟机软件提供虚拟的ISA,让这些软件认为运行在真实的物理机上。
VMM为虚拟机提供的ISA分为用户ISA和系统ISA,用户ISA即用户态和内核态都能使用的接口,系统ISA是只有内核态才可以使用的接口。系统ISA主要包括:
-
读写敏感寄存器,例如操作描述符表的SGDT、SIDT、SLDT
-
控制处理器行为,例如ARM下WFI(陷入低功耗状态)
-
控制虚拟/物理内存,例如打开、配置、安装页表
-
控制外设,例如DMA和中断
虚拟机监控器的分类
1974年,Gerald J.Popek和Robert P.Goldberg提出高效的系统虚拟化需要满足一下三个条件:
-
虚拟机监控器为虚拟机内的程序提供与该程序原先执行的硬件完全一样的接口
-
虚拟机只比在无虚拟化的情况下性能略差一点
-
虚拟机监控器控制所有物理资源
Goldberg奖虚拟机监控器分为两种类型,分别为Type-1和Type-2。Type-1型虚拟机监控器直接运行在最高特权级,可以直接控制物理资源,并负责实现调度和资源管理。可以将Type-1虚拟机监控器理解为一种特殊的操作系统,它所管理的进程就是虚拟机,其性能损失较小。Type-1虚拟机监控器的典型代表是Xen。
Type-2型虚拟机监控器需要依托于一个宿主操作系统,复用宿主操作系统中的调度和资源管理功能,专注于提供虚拟化相关的功能,更容易于实现和安装。与Type-1不同的是,Type-2虚拟机监控器更像是宿主操作系统上的一个进程,典型的代表是QEMU,Linux内核负责资源管理和调度,QEMU负责核心的虚拟化功能。
系统虚拟化的实现
根据上面对虚拟机监视器需要面向VM实现虚拟ISA的分析,VMM实现的核心流程应当如下:
-
捕捉所有系统ISA并陷入(Trap)
-
由具体指令实现相应虚拟化
-
控制虚拟处理器行为
-
控制虚拟内存行为
-
控制虚拟设备行为
-
-
回到虚拟机继续执行
通过上面的流程分析,我们可以得出VMM技术主要包括三个方面:
-
CPU虚拟化:为虚拟机提供虚拟处理器vCPU的抽象并执行其指令。VMM直接运行在物理机上,使用物理ISA,并向虚拟机提供虚拟ISA。虚拟ISA和物理ISA可以相同也可以不同。如果两者相同,大部分用户ISA都可以直接在物理机上执行,只有少数系统ISA需要捕捉进行特殊处理。如果两者不同,虚拟机中的每一条指令都需要VMM进行软件模拟,翻译成物理ISA中的指令。
-
内存虚拟化:为虚拟机提供虚拟的物理地址空间。VMM负责管理全部物理内存资源,通过引入客户物理地址空间实现每个虚拟机以为拥有全部的物理内存资源。VMM需要提供一种翻译机制,将客户物理地址翻译成真正的物理地址。
-
设备虚拟化:为虚拟机提供虚拟的I/O设备支持。VMM负责管理全部的物理IO设备,为虚拟机提供所需的虚拟设备访问支持,将虚拟设备的访问映射转换为对物理设备的访问。
-
中断虚拟化:为虚拟机提供虚拟的中断控制支持,包括虚拟IO外设的中断,虚拟定时器中断等。
CPU虚拟化
下陷和模拟
上面系统虚拟化实现中描述了一种最常见的虚拟化实现方法:下陷和模拟。Guest的用户态和内核态都运行在Host的用户态,Guest的用户态执行敏感指令的时候会触发下陷Trap,内核态中的VMM就会代替Guest操作系统以一种安全的方式模拟Emulate,早期的QEMU就是这种实现方式。但是很多体系结构种敏感指令再用户态执行时是无法触发下陷的,因此属于不可虚拟化架构。这里面需要讨论一下敏感指令和特权指令:
-
特权指令:指在用户态执行时会触发下陷的指令,比如写入只读内存
-
敏感指令:指管理系统屋里资源或者是更改CPU状态的指令,比如读写控制寄存器、读写敏感内存、执行IO指令
可虚拟化架构的特征是所有敏感指令都是特权指令,即所有敏感指令在非特权级执行时都会触发下陷。不满足这个定义的架构称之为不可虚拟化架构。早期的CPU并未考虑对虚拟化的支持,导致VMM无法完全捕捉到Guest操作系统的行为,就不能提供相应的虚拟化功能。
x86种就有17条敏感指令不能在非特权级下触发下陷,具体如下:
敏感寄存器指令:读写敏感寄存器或者是内存区域,比如时钟寄存器和中断寄存器
-
SGDT, SIDT, SLDT
-
SMSW
-
PUSHF, POPF
系统保护指令:系统保护区域或者是内存重定向相关的
-
LAR, LSL, VERR, VERW
-
POP
-
PUSH
-
CALL, JMP, INT n, RET
-
STR
-
MOVE
虽然存在部分敏感指令无法触发下陷,但是依然有一些Workaround的方法来解决这些指令的模拟,分别是:解释执行、动态二进制翻译、扫描和翻译、半虚拟化、硬件虚拟化技术。当前主要使用硬件虚拟化技术,但是了解其他几种方案也有一定的帮助。
解释执行
解释执行的方法是依次去除虚拟机内的每一条指令,用软件模拟这条指令的执行,不区分敏感指令还是其他指令。通过内存维护虚拟机状态,通过模拟的指令操作这些内存状态,并不会将真正的虚拟机指令在物理机上执行。该方法不依赖于下陷,全部指令都是被VMM进行模拟执行的。解释执行的具体流程如下:
解释执行的优点是解决了敏感函数不下陷的问题,同时可以模拟不同ISA的虚拟机,易于实现、复杂度低;缺点是执行速度较慢非常慢,任何一条虚拟机指令都会转换成多条模拟指令。
动态二进制翻译
解释执行方法的性能开销主要来自不加区分的依次模拟每一条指令,动态二进制翻译技术通过将多条指令直接翻译成对应的模拟函数,然后直接执行翻译后的代码,提高了性能。动态二进制翻译以基本块为粒度(基本块是编译理论种的概念,指一段顺序执行的代码块,除了最后一条指令中间没有改变控制流的指令。基本块是只有一个入口和一个出口的代码块),将一个基本块种的所有指令都翻译成最终的目标代码,每个基本块翻译之后叫做代码补丁,敏感指令会在翻译过程中被替换。下面是动态二进制翻译的简单过程:
动态二进制翻译相比解释执行提出两个加速技术:在执行前批量翻译虚拟机指令,缓存已翻译完成的指令 。实际使用时可以采用一些方法进一步加速二进制翻译的速度,例如一个基本块之后的基本块也是确定的,而且写一个基本块也被翻译过,则可以直接修改前一个基本块的最后一条指令为跳转指令,直接跳到下一个基本块,这样就可以绕过模拟器。
但是动态二进制翻译无法处理自修改的代码,比如JIT Compiler(Java这种将代码翻译成字节码的JVM虚拟机)。另外中断插入粒度变大,解释执行在任意指令位置插入中断,但是二进制翻译只能在基本块边界插入虚拟中断。
扫描和翻译
实际中如果虚拟机和宿主机的指令集相同,非敏感指令无需模拟就可以直接在CPU中执行。因此还有一种优化方法是只让敏感指令下陷,其他指令直接执行,这就是扫描和翻译的方法。
VMM在执行虚拟机代码时首先扫描代码块,将其中的敏感指令替换成一定会触发异常下陷的指令,其他指令保持不变。在代码执行过程中,VMM就能捕获替换后的特权指令,从而进行相应的模拟操作。
VMM在虚拟机执行前,将其所有内存设置为不可执行。简要流程如下:
-
当物理CPU中的PC第一次执行某个代码页中的指令时,由于该代码页被设置为不可知行,因此会触发一次缺页异常。
-
VMM截获并调用缺页异常处理函数。缺页异常处理函数将流程交给控制器。
-
控制器中首先检查缓存中是否存在已经翻译过的代码页,如果有直接返回用户态;如果不存在就调用扫描翻译模块。
-
扫描翻译模块读取内存中待翻译的代码页内容,如果存在敏感指令将其替换为可以下陷的特权指令,并将翻译后的代码页放入缓存中。
-
VMM将翻译后的代码页权限设置为可执行,然后返回用户态。
-
虚拟机执行饭以后的代码,此时大部分指令可以直接执行,遇到替换后的指令,将下陷通知VMM完成相应的指令模拟。
实际上,由于敏感指令只可能存在于操作系统内核代码中,用户态代码不会包含这样的指令。因此可以只扫描内核代码,忽略所有用户进程代码,从而进一步提升性能。
半虚拟化
前面的三种方法都是不修改Guest虚拟机源代码的,因此必须在机器指令层面进行模拟或者是翻译。这三种无需需要客户虚拟机源码的方式叫做全虚拟化,对应的允许修改客户虚拟机源码的方式被称为半虚拟化。
半虚拟化技术需要对客户操作系统与虚拟机监控器进行协同设计。虚拟机监控器为虚拟机提供超级调用HyperCall,这些超级调用和系统调用类似,涵盖了调度、内存、IO等多方面的功能。同时客户虚拟机指导自己运行在虚拟化环境中,并修改操作系统的代码,将不可虚拟化的指令更改为超级调用。比如可以将那17条超级指令替换为可以下陷的指令。
半虚拟化技术的优点是:解决了敏感函数不下陷的问题,通过协同设计的思想能够提升某些比如IO场景下的系统性能。其缺点是需要修改操作系统代码,难以用于闭源系统,即使是开源系统,也难以同时在不同版本中实现。
硬件虚拟化
上面四种方法都是使用软件技术来解决早期CPU设计中不可虚拟化的敏感指令,除了软件技术之外新的CPU设计也开始考虑虚拟化支持,提供了硬件虚拟化的方案。Intel和AMD在2005和2006年相继推出了虚拟化的硬件扩展,来解决不可虚拟化架构的缺陷。
硬件虚拟化技术在已有的CPU特权级下新增了两个模式,分别是根模式(root mode)和非根模式(non-root mode),两个模式内都有4个特权级别:Ring0~Ring3。VMM作为最高特权级的管理软件管理全部物理资源,并使用硬件虚拟化的功能为上层虚拟机提供服务。VMM运行在根模式Ring0,虚拟机运行在非根模式,虚拟机用户态运行在非根模式Ring3,虚拟机内核态运行在非根模式Ring0。Intel VT-x为每一个虚拟机提供了虚拟机控制结构VMCS,VMM通过VMCS来管理虚拟机的内存映射和其他行为。通过这种方式,过去那些不会引起下陷的敏感指令在运行的时候也会被运行在根模式的VMM捕获,进而实现相应的虚拟化功能。
软件通过VMXON指令进入VMX操作模式,通过VMXOFF指令退出VMX执行模式。每当虚拟机执行敏感指令或者是发生外部中断等事件时,都会触发虚拟机下陷,CPU模式从非根模式切换为根模式,同时硬件会将vCPU所有的系统寄存器保存到VMCS中,这个过程叫做VM Exit。当VMM下陷处理完成之后再恢复虚拟机运行会执行相反的操作,这个过程叫做VM Entry。第一次启动虚拟机使用的时VMLAUNCH指令,后续的VM Entry使用VMRESUME指令。
VMCS是VM Exit和VM Entry切换的核心数据结构,VMCS的访问是通过VMCS指针来操作的。VMCS指针是一个指向VMCS结构的64位地址,使用VMPTRST和VMPTRLD指令进行读写,使用VMREAD、VMWRITE、VMCLEAR等指令对VMCS进行配置。VMCS的内容主要包含6部分:
-
Guest-state area: 发生VM Exit时,CPU的状态会被硬件自动保存至该区域;发生VM Entry时,硬件自动从该区域加载状态至CPU中
-
Host-state area:发生VM Exit时,硬件自动从该区域加载状态至CPU中;发生VM Entry时,CPU的状态会被自动保存至该区域
-
VM-execution control fields: 控制Non-root模式中虚拟机的行为
-
VM-exit control fields:控制VM exit的行为
-
VM-entry control fields:控制VM entry的行为
-
VM-exit information fields:VM Exit的原因和相关信息(只读区域)
发生一次VM Exit的代价是比较高的,可能会消耗成百上千个CPU Cycle,所以对于VM Exit的分析是虚拟化中性能分析和调优的一个关键点。
内存虚拟化
内存虚拟化的目的是给客户操作系统提供一个从0地址开始的连续物理内存空间,同时在多个虚拟机之间实现隔离和调度。内存虚拟化主要涉及4个基础概念:
-
客户机虚拟地址,GVA(Guest Virtual Address)
-
客户机物理地址,GPA(Guest Physical Address)
-
宿主机虚拟地址,HVA(Host Virtual Address)
-
宿主机物理地址,HPA(Host Physical Address)
内存虚拟化就是将客户机虚拟地址GVA转换为最终能够访问的宿主机上的物理地址HPA。
实模式Guest寻址
x86架构的处理器在复位后,将首先进入实模式,在系统软件准备好保护模式的各种数据结构后,系统软件通过设置控制寄存器使处理器从实模式切换到保护模式。因此从内存虚拟化角度,需要分别处理实模式和保护模式下的寻址。
当虚拟机运行在实模式下,物理CPU实际上处于保护模式。为了可以在保护模式下运行实模式代码,x86架构支持virtual-8080模式,该模式在保护模式下模拟实模式的运行环境。当Guest访问最终物理地址的时候,还是需要按照保护模式下的分页寻址方式,这样才能和Host下的其他进程互相隔离并共享系统内存资源。Host为运行在virtual-8080模式下的Guest也准备了一张页表,当切入Guest时CR3寄存器指向这张页表。这张页表完成GPA到HPA的转换。
当Guest处于实模式时,Guest的GVA到HPA的映射需要经过3次转换:
-
Guest使用分段机制将逻辑地址GVA(非虚拟地址)转换为GPA
-
VMM根据虚拟内存条信息完成GPA到HVA的转换
-
Host利用内核的内存管理机制完成HVA到HPA的转换
第一阶段的映射由处于Guest模式的CPU进行处理,此时CPU处于Virtual-8080模式,无需VMM进行干预。当Guest分段得到的GPA送到MMU时,最初VMM为Guest准备的页表时空的,因此MMU会向CPU发送缺页异常,触发CPU从Guest模式退出到Host模式,VMM从CR2寄存器中读取触发缺页异常的GPA,指向GPA到HPA的转换,并更新VMM为Guest准备的页表,完成整个地址映射过程。
为了让处于Guest模式的CR3寄存器指向KVM为Guest准备的页表,在切入Guest前,VMM需要设置Guest对应的VMCS中的CR3字段,将其指向VMM为Guest准备的GPA到HPA专用映射页表root_hpa的基地址。这张专用页表只需要一个根页面,然后在缺页异常时由缺页异常处理函数按需完成GPA到HPA的映射建立。对于运行在实模式的Guest,由于实模式下不会修改CR3,所以除了创建Guest后首次切入Guest运行没有必要每次切入Guest都设置Guest的CR3寄存器,最终只需要一个页表完成GPA到HPA的映射。这一点与保护模式下为每个Guest进程都创建影子页表有所不同,但是数据结构都是一样的,只是数量的差异。
因为上面CR3和页表数量的差异,实模式和保护模式的缺页异常处理函数逻辑也有所不同。KVM中有一个MMU上下文数据结构,除了供MMU使用还有一些页表相关操作,Guest处于不同模式使用不同的上下文。实模式下使用nonpaging context,中断处理函数也为nonpaging_page_fault。x86架构通过设置CR0寄存器开启分页,当Guest设置CR0寄存器时触发CPU从Guest模式退出到Host模式,这时VMM捕获并设置MMU上下文从nonpaging context切换为paging context。
影子页表
通过上面体系结构中内存管理章节的介绍,可以了解到保护模式下操作系统通过CR3寄存器保存页表基地址,通过MMU实现页表查询得到最终的物理地址。由于物理上只有一个MMU单元,这个MMU被Guest的页表占用,Guest的页表只记录GVA到GPA的映射,无法完成GPA到HPA的映射。一种可行的解决方案就是为每个Guest进程分别制作一张页表,这张表记录着GVA到HPA的映射关系。Guest模式下的CR3寄存器不再指向Guest内部那张只能完成GVA到GPA的映射表,而是指向这种新的能够实现GVA到HPA映射的表,这样MMU收到GVA的时候就能够通过查询这张新表得到HPA。这里面核心的两个技术点:
-
KVM构建从GVA到HPA的页表,这个页表需要根据Guest内部页表的更新进行更新,就像Guest页表的影子一样如影随形,并且在Guest的CR3指向的是这张新的映射表,遮挡了之前的页表,这张页表叫做影子页表。
-
保护模式的Guest有自己的页表,而且不只有一张页表,Guest中每个任务都有自己的页表。KVM需要为每一个Guest任务都创建页表,当Guest任务切换时候需要切换到对应影子页表。
影子页表构建好之后,GVA到HPA经过一次映射即可,但是在建立映射时需要经过3次转换:
-
Guest使用自身的页表完成GVA到GPA的转换,在VMM影子页表缺页异常处理函数中观察到GVA还未建立到GPA的映射时,会向Guest注入缺页异常,由Guest的缺页异常处理函数建立的
-
VMM根据虚拟机内存条信息完成GPA到HVA的转换
-
Host利用内核的内存管理机制实现HVA到HPA的转换,这个就是Host内核的缺页异常处理进行处理的
当Guest访问的GVA已经完成跟GPA的映射之后,VMM的影子页表异常处理函数中完成最终到HPA的查询,并填充影子页表中GVA到HPA的映射。整个过程针对每个Guest进程VMM会跟踪其Guest维护的页表,也需要维护与之对应的影子页表。Guest自身的页表不能完成GVA到HPA的多层地址映射,通过在每次Guest设置CR3寄存器的时候,KVM截获这个操作,并将CR3替换为影子页表基地址。影子页表不是在每次CR3切换都重新构建,而是使用Cache机制,使用Guest下陷时的CR3寄存器中的Guest页表基地址作为Key从哈希表中查找到对应的影子页表。
保护模式下Guest发生缺页异常时,控制寄存器CR2中保存的是GVA。因为只有Guest页表中保存了GVA到GPA的映射,因此VMM的缺页异常处理函数中需要先遍历Guest页表找出GVA对应的GPA,再根据Guest页表中是否完成GVA和GPA的映射,决定是继续走GPA到HVA最终到HPA的映射,还是触发Guest操作系统缺页异常完成GVA和GPA映射。影子页表的缺页异常如下图所示:
EPT
影子页表方案中可以看到遍历页表这些原本由MMU做的事情现在由CPU进行处理。每次影子页表切换CPU都会从Guest模式切换到Host模式,缺页中断处理完成之后还要再切换回去,在影子页表还未完整构建出来时候还会有两次切换。另外为了保持Gust页表和影子页表的一致,任何Guest对页表的修改,都需要触发VM Exit,影子页表的维护复杂且低效,并占用较多的内存资源。
为了解决影子页表的问题,Intel CPU在硬件设计上引入了EPT(Extended Page Table,扩展页表),通过硬件实现GVA到GPA的转换。这个转换通过两个阶段,通过MMU将GVA转换为GPA,通过EPT将GPA转换为HPA。MMU和EPT在硬件层面进行互相配合,无需软件干涉。引入EPT之后,EPT可能存在缺页,Intel引入了EPT violation异常,处理EPT异常的原理跟MMU基本相同。
使用EPT后,Guest在读写CR3和执行INVLPG指令时不会导致VM Exit,即Guest发生缺页异常时需从Guest模式切换到Host模式,减少了CPU切换的上下文开销。同时Guest的页表和EPT页表分开维护,影子页表中的同步开销页小事了。一个Guest上很多进程,Guest内需要为每个进程维护页表;Guest相当于Host上的一个进程,只需要维护一个EPT页表即可,这样也减少了内存的占用。Guest内进程切换的时候,也不需要下陷到VMM中切换EPT,所以CPU在Guest模式和Host模式之间的切换页减少了。
VMX在VMCS中有一个字段专门保存EPT地址。在开启EPT后,缺页异常的处理流程如下图所示:
当Guest内部缺页异常时,CPU不再切换到Host模式,而是由Guest自身的缺页异常处理函数进行处理。当Guest完成GVA到GPA的翻译之后,GPA就在硬件内部从MMU流转到EPT了。如果EPT页表中存在GPA到HPA的映射,则EPA最终获得GPA对应的HPA地址,将HPA送到地址总线。如果EPT中还没有建立GPA到HPA的映射,则CPU抛出EPT异常,CPU从Guest模式切换到Host模式,KVM中的EPT异常处理函数负责处理缺页,找到空闲物理页,建立EPT表中的GPA到HPA的映射。
EPT支持下的两阶段地址翻译过程中,MMU查询Guest的页表得到的都是GPA,这些GPA地址都需要通过EPT查询得到最终的HPA,发到最终的地址总线上完成读写。这些GPA地址包括CR3页表基地址、PDE/PTE页表中的页表项地址。EPT支持下的地址翻译过程如下图所示:
除了EPT,Intel在内存虚拟化优化上还引入了VPID(Virtual-Processor Identifier)特性,在硬件上对TLB资源管理进行优化。在没有VPID之前,不同Guest的vCPU在切换时需要刷新TLB,而TLB的刷新会让内存访问的效率下降。VPID技术在硬件上为TLB增加一个标识,可以识别不同vCPU的地址空间,这样系统就可以区分VMM和不同Guest的不同vCPU的TLB,在vCPU切换执行时不再刷新TLB,可以直接使用当前的TLB即可。VPID针对的是虚拟化环境下不同vCPU的层面,而上面TLB章节提到的PCID针对的是在操作系统下不同进程的层面,两者是可以同时起作用的。
中断虚拟化
在讨论中断虚拟化之前,我们先回顾一下物理CPU是如何响应中断的。当操作系统允许CPU响应中断后,每当执行完一条指令,CPU都将检查中断引脚是否有效。如果发现中断引脚有效,CPU将执行IVT/IDT中的中断处理函数,之后再执行下一条指令。中断的类型包括时钟中断、核间中断和外部中断。
图中的Check for Interrupt就是中断评估阶段,由硬件中断控制器进行执行。Process Interrupt就是CPU查询IVT/IDT执行中断处理函数。
PIC虚拟化
物理CPU是通过连接CPU的INTR引脚高低电平来判断是否有中断,对于VMM中的虚拟中断控制器来讲可以使用一个变量来模拟中断引脚。当VMM发现虚拟中断控制器中有中断请求,就向VMCS中VM-entry control部分的VM-entry interruption-information field字段写入中断信息,在切入Guest的时候物理CPU如同检查INTR中断引脚一样检查这个字段,如果有中断CPU就执行Guest的中断处理函数。下图为单核系统使用PIC中断芯片下的虚拟中断过程:
虚拟中断跟物理中断处理流程有一些不一样。中断评估(评估中断是否要被屏蔽、中断请求优先级等)并不是在虚拟中断控制器收到中断时就执行,而是先将中断信息注入到VMCS中,让CPU在VM Entry的时候再执行。所以如果有虚拟中断需要进行注入,处于Guest模式的CPU需要通过VM Exit先退出到Host模式,注入中断信息到VMCS,再通过VM entry触发中断评估和中断处理。
APIC虚拟化
PIC只支持单核处理器,对于SMP多处理器系统,需要APIC的支持。APIC的虚拟化本质上跟PIC虚拟化相同:
多处理器系统中APIC由LAPIC和IO APIC,在虚拟化的时候也需要对这两个组件进行虚拟化支持。同时在多处理器情况下,可能存在需要唤醒的vCPU跑在另外一个物理CPU核的Guest模式下,这个时候需要VMM发送IPI核间中断,使目标CPU从Guest模式退出到Host模式,在下一次VM Entry的时候进行中断注入。
硬件虚拟化支持
每次中断注入都需要Guest模式的CPU先通过VM Exit退出到Host模式,在高速IO设备等触发中断频度较高的场景中,这是一个很大的开销。为了去除这部分VM Exit的开销,Intel在硬件层对中断虚拟化的中断投递(被动)和中断触发(主动)两方面进行了优化:
-
virtual-APIC page:每个物理LAPIC都有一个4KB大小的APIC Page保存其内部寄存器的值,Intel在CPU的Guest模式下实现一个用于保存中断寄存器的virtual-APIC page,将其基地址保存在VMCS中。这样当Guest读取中断寄存器时,就不需要执行VM Exit了。对于写中断寄存器还是需要触发VM Exit,比如写ICR寄存器发送IPI中断。
-
Posted Interrupt:Posted Interrupt将中断评估Guest模式从VM Exit退出Host模式之后在VM Entry时做的工作直接让Guest模式直接执行中断评估逻辑。虚拟中断芯片在收到中断请求后,将中断信息写入posted-interrupt descriptor(其地址保存在VMCS中),Guest模式下的CPU就无需VM Exit就可以执行中断评估,实现向Guest模式的CPU直接递交中断。
Posted-interrupt Processing机制核心是完成两件事:
-
向Posted-Interrupt descriptor中写入中断信息
-
通过发送核间中断POSTED_INTR_VECTOR通知CPU处理Posted-interrupt descriptor中的中断(无需VM Exit)
时钟虚拟化
当前KVM提供的时钟虚拟化有如下几种方案:
-
TSC:Guest中使用rdtsc指令读取TSC时,会因为EXIT_REASON_RDTSC导致VM Exit。VMM读取Host的TSC和VMCS中的TSC_OFFSET,然后把host_tst+tsc_offset返回给Guest。这里要做出OFFSET的原因是考虑到vcpu热插拔和Guest会在不同的Host间迁移。
-
软件模拟时钟:qemu中有对RTC和hpet都模拟出了相应的设备,例如RTC的典型芯片mc146818。这种软件模拟时钟中断存在的问题:由于qemu也是应用程序,收到cpu调度的影响,软件时钟中断不能及时产生,并且软件时钟中断注入则是每次发生VM Exit/Vm Entry的时刻。所以软件模拟时钟就无法精准的出发并注入到Guest,存在延迟较大的问题。
-
kvm-clock:kvm-clock是KVM下Linux Guest默认的半虚拟化时钟源。在Guest上实现一个kvmclock驱动,Guest通过该驱动向VMM查询时间。Guest分配一个内存页,将该内存地址通过写入MSR告诉VMM,VMM把Host系统时间写入这个内存页,然后Guest去读取这个时间来更新。
IO虚拟化
设备管理是操作系统的重要职责之一,但是直接让虚拟机访问物理设别会存在恶意VM直接读写其他VM数据的安全问题,因此虚拟机监控器通常不会允许虚拟机直接管理物理设备,而是使用IO虚拟化技术为虚拟机提供假的设备。IO虚拟化有三个重要的功能:
-
隔离不同虚拟机对外部设备的直接访问,实现IO数据流和控制流的隔离
-
为虚拟机提供虚拟设备接口,让虚拟机操作系统正常使用设备
-
提高物理资源利用率,多个VM同时使用提高物理设备资源利用率
IO虚拟化主要有三种实现方法:软件模拟(全虚拟化)、半虚拟化、设备直通(硬件虚拟化)。这三种方式各有优缺点,下面进行详细的分析。
设备模拟
设备模拟是最早出现的设备虚拟化方案,VMM按照硬件设备的规范,完整的模拟硬件设备的逻辑。完全虚拟化的优势是对Guest完全透明,Guest可以不用修改即可运行。设备模拟的核心技术是通过模拟寄存器来实现中断等逻辑以及捕捉MMIO操作,从而实现操作系统和设备交互的硬件接口。
起初完全虚拟化的逻辑完全实现在用户空间,Guest的IO操作会导致CPU下陷到内核态的VMM,CPU还需要从VMM的内核态切换到用户态进行设备IO操作模拟。
既然IO操作会触发CPU下陷从Guest模式到Host模式,并首先进入内核中的KVM模块,可以在内核空间进行一些设备的模拟。比如,中断芯片的虚拟化模拟就非常适合在内核空间实现。其他的设备模拟由于实现复杂,在内核中实现会导致复杂度增加,也有一定的安全问题。后来,开发人员提出了一种折中的vhost方案,将模拟设备的数据处理部分放在内核空间,控制部分留在用户空间。
设备模拟虚拟化方案的优点是可以模拟多种设备,并允许在中间拦截执行特定逻辑,无需特殊硬件虚拟化支持;相应的缺点就是性能不好。
半虚拟化
在服务器使用场景中,常用的IO设备仅有块存储和网卡,针对这些设备的虚拟化,完全没有必要生搬硬套硬件的逻辑,完全可以定制一个更简洁、高效的Guest驱动和模拟设备之间的交互方式,这就是半虚拟化。半虚拟化在上面的CPU虚拟化中也有提到,但是当前CPU都已经支持硬件虚拟化不再使用,但是在IO设备虚拟化方向依然是一种优秀的解决方案。IO设备半虚拟化采用协同设计,虚拟机感知运行在虚拟化环境中,虚拟机内运行前端驱动,VMM运行后端驱动。前端驱动不再使用会引起大量虚拟机下陷的MMIO接口,而是用VMM提供的HyperCall接口与后端驱动进行交互。前后端驱动不仅通过共享内存机制进行数据传输,还利用批处理Batch优化将多次IO请求整合成一次HyperCall请求。因此相对软件模拟的方法,半虚拟化方法减少了虚拟机下陷的次数,也提升了数据传输速率,最终提升了IO设备虚拟化的性能。
半虚拟化设备虚拟化的优点是:性能较好,多个MMIO/PIO指令可以整合成一次HyperCall;VMM实现简单,不再需要遵循硬件接口规范。其缺点是需要修改操作系统内核,不过好在只是在Guest中增加新的设备驱动,工作量较小。
virtio
virtio协议就是半虚拟化的典型方案。与完全虚拟化相比,使用virtio标准的驱动和模拟设备之间不再使用寄存器等传统IO方式,而是才用了virtqueue的方式来传输数据。这种方式降低了设备模拟实现的复杂度,去掉了很多CPU和IO设备之间不必要的通信,减少了CPU在Guest模式和Host模式之间的切换,IO也不再受数据总线宽度、寄存器宽度等因素的影响,提高了虚拟化的性能。下面是一个virtio协议网络发包流程:
virtio的backend实现也可以走上面提到的vhost模式将数据处理相关部分放在内核中,减少内核空间和用户空间的切换,提高整体虚拟化的性能。
我们以块设备模拟为例,模拟设备的IO处理流程可以用一个线程池在用户态并发执行,模拟设备完成IO处理之后通过中断的方式通知Guest操作系统IO处理完成。
VMM在IO处理函数中会有一次内核空间和用户空间的切换进行唤醒用户空间的IO线程池,KVM针对这块使用eventfd进行优化,Guest发起IO从Guest模式进入Host模式之后,内核态VMM直接唤醒等待在eventfd上的用户态IO线程池,之后立即返回Guest模式。
Virtio-Blk磁盘读取流程:
设备直通
除了软件方式实现设备虚拟化之外,芯片厂商在硬件层面也对设备虚拟化提供了一些支持,比如Intel提出了VT-d方式。VT-d最初支持将设备整个透传给虚拟机,但是这种方式不支持在多个虚拟机之间共享设备,不具备可扩展性,后来演变出SRIOV方案。
SRIOV是Single Root I/O Virtualization and Sharing的简称,引入了两个新的Function类型,一个是Physical Function,简称PF;一个是Virtual Function,简称VF。一个SRIOV可以支持多个VF,每个VF可以分别透传给Guest。这样就从硬件角度实现了多个Guest共享同一个物理设备。每个VF都有独立的用于数据传输的存储空间、队列、中断及命令处理单元等。VMM通过PF管理这些VF,同时Host上的应用依然可以通过PF访问物理设备。SRIOV的设备透传方案,叫做VFIO(Virtual Function I/O),可以安全的把设备I/O、中断、DMA等暴露到用户空间(userspace),从而可以在用户空间完成设备驱动的框架。
设备直通方案的优点是虚拟化性能优越,且能简化VMM的设计与实现;其缺点是需要特定的硬件功能支持(IOMMU、SRIOV等),并且不方便支持热迁移(VFIO也可以深度改进实现热迁移)。
配置空间虚拟化
对于VF,虽然Host在系统启动的时候已经为每个VF划分了内存地址空间来保存寄存器BAR,但是Guest不能直接访问Host物理内存,因此VF配置空间也需要进行虚拟化。当Guest访问VF的配置空间时,将会触发VM Exit陷入VMM,VMM过滤Guest对VF配置空间的访问,并代理模拟完成对VF设备配置空间的操作。这个过程只涉及配置空间操作,不涉及数据传输,因此不影响数据传输的效率。整个配置空间虚拟化主要需要完成两件事情:
-
将VF配置空间的BAR寄存器地址信息修改为GPA,而不是HPA
-
完成GPA到HPA的两阶段映射,使用Host上的mmap实现HVA和HPA的映射,使用KVM的内存条机制实现GPA和HVA的映射
相对硬件虚拟化,软件虚拟方式在安全性方面有明显的优势,大部分设备模拟代码都在用户空间实现,特权操作通过VMM完成。Intel的VT-d在提升硬件虚拟化安全方面做了很多的工作,包括DMA重映射、中断重映射。DMA重映射,是将DMA请求中的Guest的物理地址映射到Host的物理地址;中断重映射,是将能remappable的中断请求根据由VMM设置,位于内存的IRT(Interrupt Remapping Table)发送到指定的vcpu上。
DMA重映射
设备直接透传给Guest之后,需要防范恶意Guest借助透传设备访问其他Guest或Host的内存。为此芯片厂商设计了DMA重映射(DMA Remmapping)机制,在外设和内存之间增加了DMA硬件重映射单元,一个DMA重映射单元可以为所有设备提供地址重映射服务,系统中可以有一个也可以有多个DMA重映射单元。DMA重映射单元是为了对外设进行IO地址翻译的,所以也成为IOMMU。
在设备透传场景中,VMM为每个Guest创建一个页表,并将这个页表设置给DMA重映射单元,这个页表限制了外设只能访问这个页表覆盖的内存,从而限制了外设对其他虚拟机和Host的内存访问。当外设访问内存时,内存地址先到达DMA重映射单元,DMA重映射单元根据外设的信息(总线号、设备号、功能号)确定所对应的页表,通过查表得出物理内存地址,然后将地址送上总线。如果多个设备透传给一个虚拟机,那么它们共享同一个页表。
中断重映射
设备直接透传场景中,除了对恶意外设DMA需要进行重映射加固之外,也需要考虑恶意虚拟机对外设发送恶意的中断。为此,硬件厂商引入了中断重映射(Interrupt Remmapping)机制,在外设和CPU之间加了一个硬件中断重映射单元。当接受到来自于外设的中断时,中断重映射单元会对中断请求的来源进行有效性检查,然后以中断号查询中断重映射表,代替外设向目标发送中断。中断重映射表由VMM而不是虚拟机进行设置,这样就能避免恶意虚拟机篡改实现对其他VM或者是Host的攻击。中断重映射单元根据中断请求来源信息(PCI设备的Bus、Device和Function号)根据不同设备所属的Domain,将该中断请求转发给对应的虚拟机。
之前中断虚拟化中讲到中断注入都需要VM Exit,后来Intel设计了Posted Interrupt机制,让CPU可以在Guest模式直接处理中断。结合设备透传场景的中断重映射,Intel将中断重映射和Posted Interrupt结合起来实现了VT-d Posted-Interrupt。中断重映射硬件单元负责更新目标Guest的Posted-Interrupt Descriptor,将不再导致Guest的VM Exit,实现外部设备的中断可以直达目标CPU。
Medicated Passthrough
VFIO框架解决了设备直通的问题,使得用户态可以直接使用设备的DMA能力。 VFIO使用IOMMU页表来对设备访问进行保护,将用户态对设备的访问限定在一个安全的域内。 利用这个框架,我们可以将GPU、网卡适配器、一些计算加速器(例如:AI芯片,FPGA加速器)直接呈现给虚拟机。对于一些不具备SRIOV能力的设备,或者是需要将一个VF再隔离分配给多个应用程序访问的场景下,VFIO是无法解决这种设备虚拟化场景的。针对这些场景,Nvidia提出了基于VFIO的VFIO Mdev框架来解决这个问题。 在mdev模型的核心在于mdev会对硬件设备的状态进行抽象,将硬件设备的“状态”保存在mdev device数据结构中, 设备驱动层面要求实现一个调度器,将多个mdev设备在硬件设备上进行调度(分时复用), 从而实现把一个物理硬件设备分享给多个虚拟机实例进行使用。
mdev典型的使用场景有几种:
-
vGPU:将一个GPU分配给多个虚拟机共享使用
-
NVMe:将一块NVMe本地盘分配给多个虚拟机共享使用
mdev的本质是使用软件模拟SRIOV,但是mdev设备不具备独立的PCI编号,所有的mdev设备共用parent设备的PCI编号,这时IOMMU是无法起到隔离作用的。由于IOMMU无法起到隔离的作用,vfio-mdev框架将地址翻译交给驱动实现,在驱动中对地址进行检查,防止恶意mdev设备访问其他设备的地址。整体来看,不管是vfio-pci还是mdev-vfio设备都是要过IOMMU的,只不过设备透传场景下IOMMU起到实质的隔离作用,但是mdev设备只是做地址翻译,隔离由驱动来保证。
QEMU-KVM
QEMU-KVM架构
KVM/QEMU架构是典型的机制和策略分离的架构。QEMU运行在用户态,负责实现策略;KVM运行在内核态,负责实现机制。KVM在内核中可以直接使用Linux的内存管理、进程调度等功能,并设置使用硬件虚拟化功能;QEMU在用户态负责实现虚拟设备的支持。KVM/QEMU两者的结合,KVM捕捉所有敏感指令和时间,传递给QEMU实现一些复杂的设备模拟,KVM可以更高效的实现核心的虚拟化功能,把复杂的设备模拟交到用户态的QEMU。
QEMU-KVM架构整体上分为3部分:VMX root模式的应用态(QEMU Device Emulator),VMX root模式的内核态(KVM),VMX non-root模式(VM vCPU包含用户态和内核态)。QEMU是多线程并行和事件驱动组合的架构,执行VM代码有Tiny Code Generator(TCG)和Kernel Based Virtual Machine(KVM)两种方式,TCG使用二进制翻译技术我们这里不讨论。
QEMU-KVM架构中,一个QEMU进程代表一个虚拟机,QEMU会有若干个线程,会为每个vCPU创建一个线程,还有一些其他的线程,如VNC线程、IO线程、RCU线程、热迁移线程等。开始的时候QEMU主线程中监听各类IO事件称之为IO线程,但是当前IO线程指处理块存储IO事件的线程,事件循环主要处理一些fd、timer和bh下半部。线程模型使用BQL(Big QEMU Lock)进行同步。
QEMU运行中提供了一些监控器来跟外部进行进行交互,可以得到虚拟机的统计信息、设备热插拔、动态设置参数、动态开启或关闭功能。QEMU Monitor有多种交互方式,比如QEMU Monitor、TCP Socket、Unix Domain Socket、文件。QEMU Monitor交互协议有两种,一种是字符串形式的HMP(Human Monitor Protocol),另一种是基于json的QMP(QEMU Monitor Protocol)。
QEMU-KVM交互
QEMU通过ioctl操作/dev/kvm的fd实现与内核KVM的通信,支持的ioctl命令如下:
-
KVM_CREATE_VM:创建一个空VM,返回对应VM的文件描述符(输入/dev/kvm的设备文件描述符,输出VM的控制文件描述符)
-
KVM_SET_USER_MEMORY_REGION:设置VM的Guest物理内存条
-
KVM_CREATE_VCPU:给VM添加一个vCPU
-
KVM_RUN:执行vCPU切入Guest模式,每一个vCPU使用一个线程模拟,while循环调用ioctl(KVM_RUN)并检查VM Exist的reason执行相应的模拟逻辑
ioctl(KVM_RUN)时,KVM找到对应的vCPU的VMCS,使用指令加载VMCS,VMLAUNCH/VMRESUME进入Non-Root模式。其中硬件自动同步状态,PC寄存器切换到VMCS中的Guest_RIP并开始执行。
创建一个KVM虚拟机的流程如下:
-
Open the KVM device, kvmfd=open("/dev/kvm", O_RDWR|O_CLOEXEC)
-
Do create a VM, vmfd=ioctl(kvmfd, KVM_CREATE_VM, 0)
-
Set up memory for VM guest, ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, ®ion)
-
Create a virtual CPU for the VM, vcpufd=ioctl(vmfd, KVM_CREATE_VCPU, 0)
-
Set up memory for the vCPU
-
-
vcpu_mmap_size=ioctl(kvmfd, KVM_GET_VCPU_MMAP_SIZE, NULL)
-
run=(struct kvm_run*)mmap(NULL, vcpu_mmap_size, PROT_READ|PROT_WRITE, MAP_SHARED, vcpufd, 0) //每个vCPU有一个kvm_run结构保存vCPU的状态
-
-
Put assembled code on user memory region, set up vCPU's registers such as rip
-
Run and handle exit reason. while(1) { ioctl(vcpufd, KVM_RUN, 0); ... }
Step1-3是创建VM的过程:
/* step 1~3, create VM and set up user memory region */
void kvm(uint8_t code[], size_t code_len) {
// step 1, open /dev/kvm
int kvmfd = open("/dev/kvm", O_RDWR|O_CLOEXEC);
if(kvmfd == -1) errx(1, "failed to open /dev/kvm");
// step 2, create VM
int vmfd = ioctl(kvmfd, KVM_CREATE_VM, 0);
// step 3, set up user memory region
size_t mem_size = 0x40000000; // size of user memory you want to assign
void *mem = mmap(0, mem_size, PROT_READ|PROT_WRITE,
MAP_SHARED|MAP_ANONYMOUS, -1, 0);
int user_entry = 0x0;
memcpy((void*)((size_t)mem + user_entry), code, code_len);
struct kvm_userspace_memory_region region = {
.slot = 0,
.flags = 0,
.guest_phys_addr = 0,
.memory_size = mem_size,
.userspace_addr = (size_t)mem
};
ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, ®ion);
/* end of step 3 */
// not finished ...
}
Step4-6是创建vCPU的过程:
/* step 4~6, create and set up vCPU */
void kvm(uint8_t code[], size_t code_len) {
/* ... step 1~3 omitted */
// step 4, create vCPU
int vcpufd = ioctl(vmfd, KVM_CREATE_VCPU, 0);
// step 5, set up memory for vCPU
size_t vcpu_mmap_size = ioctl(kvmfd, KVM_GET_VCPU_MMAP_SIZE, NULL);
struct kvm_run* run = (struct kvm_run*) mmap(0, vcpu_mmap_size,
PROT_READ | PROT_WRITE, MAP_SHARED,
vcpufd, 0);
// step 6, set up vCPU's registers
/* standard registers include general-purpose registers and flags */
struct kvm_regs regs;
ioctl(vcpufd, KVM_GET_REGS, ®s);
regs.rip = user_entry;
regs.rsp = 0x200000; // stack address
regs.rflags = 0x2; // in x86 the 0x2 bit should always be set
ioctl(vcpufd, KVM_SET_REGS, ®s); // set registers
/* special registers include segment registers */
struct kvm_sregs sregs;
ioctl(vcpufd, KVM_GET_SREGS, &sregs);
sregs.cs.base = sregs.cs.selector = 0; // let base of code segment equal to zero
ioctl(vcpufd, KVM_SET_SREGS, &sregs);
// not finished ...
}
Step7是VM执行的流程:
/* last step, run it! */
void kvm(uint8_t code[], size_t code_len) {
/* ... step 1~6 omitted */
// step 7, execute vm and handle exit reason
while (1) {
ioctl(vcpufd, KVM_RUN, NULL);
switch (run->exit_reason) {
case KVM_EXIT_HLT:
fputs("KVM_EXIT_HLT", stderr);
return 0;
case KVM_EXIT_IO:
/* TODO: check port and direction here */
putchar(*(((char *)run) + run->io.data_offset));
break;
case KVM_EXIT_FAIL_ENTRY:
errx(1, "KVM_EXIT_FAIL_ENTRY: hardware_entry_failure_reason = 0x%llx",
run->fail_entry.hardware_entry_failure_reason);
case KVM_EXIT_INTERNAL_ERROR:
errx(1, "KVM_EXIT_INTERNAL_ERROR: suberror = 0x%x",
run->internal.suberror);
case KVM_EXIT_SHUTDOWN:
errx(1, "KVM_EXIT_SHUTDOWN");
default:
errx(1, "Unhandled reason: %d", run->exit_reason);
}
}
}
int main() {
/*
.code16
mov al, 0x61
mov dx, 0x217
out dx, al
mov al, 10
out dx, al
hlt
*/
uint8_t code[] = "\xB0\x61\xBA\x17\x02\xEE\xB0\n\xEE\xF4";
kvm(code, sizeof(code));
}
//Run Result:
//$ ./kvm
//a
//KVM_EXIT_HLT
默认vCPU启动之后是实模式,只能执行16位汇编指令。如果要运行32位/64位程序,需要设置页表和段寄存器。上面例子中guest中执行in/out和hlt指令会导致虚拟机下陷,进入到用户态程序的while循环中,执行相应的虚拟化逻辑。
热迁移
QEMU热迁移涉及的数据包括:
-
可变状态
-
虚拟机设备状态:Guest内部可见,QEMU进行序列化并传输
-
虚拟机内存:QEMU简单逐轮迭代传输
-
宿主机状态:通过libvirt进行初始化设置
-
热迁移的流程:
-
源端将虚拟机所有RAM设置为脏页
-
持续迭代将虚拟机的脏页从源端发送到目的端,直至剩余数据拷贝时间满足虚拟机停机时间要求(通过带宽和剩余脏页数量进行评估)
-
停止源端上的虚拟机,把剩余脏页发送到目的端,之后再将源端的设备状态发送到目的端
内存脏页同步
热迁移中大部分的工作是将源端的内存发送到目的端,而在热迁移的过程中,源端Guest操作系统也在访问其内存,可能会出现已经迁移走的内存被再次修改。为了解决这个问题,热迁移过程需要对脏页进行跟踪,脏页由KVM进行记录,QEMU会在特定的时间同步KVM的脏页,通过再次迭代将脏页复制到目的端。
脏页同步的流程:
-
QEMU为全部内存块分配脏页位图并全部设置为1,表明初始全部内存都是脏页,都需要进行传输。
-
QEMU通过ioctl(KVM_MEM_LOG_DIRTY_FLAGS)通知KVM记录虚拟机内存写访问
-
热迁移开始后,热迁移线程从脏页位图中选择脏页发送到目的端
-
QEMU记录的脏页数据只剩最后max_size的时候,调用ioctl(KVM_GET_DIRTY_LOG)进行脏页同步,并将其复制到脏页位图中
-
如果脏页数据大于max_size,则进入3)开始下一轮迭代发送的过程
这里面KVM_MEM_LOG_DIRTY_FLAGS记录虚拟机内存写访问记录,可以是KVM将虚拟机内存设置为写保护,也可以使用硬件PML(Page Modification Logging)来记录虚拟机访问过的内存页(超过PML存储上限之后再VM Exit进行转存)。
热迁移参数控制
热迁移会将虚拟机的整个内存和设备状态从源端同步到目的端,迁移的成功率则跟很多因素相关,其中最重要的是:
-
宿主机网络带宽B
-
允许虚拟机宕机的时间T
-
虚拟机内部脏页产生的速度S
在允许虚拟机宕机时间和脏页率固定的情况,迁移成功率与带宽成正比,带宽越大越容易迁移成功;同理在带宽和脏页率固定的情况下,迁移成功率跟允许宕机时间T成正比,允许宕机时间决定了热迁移最后阶段暂停虚拟机需要发送的数据量;在带宽和允许宕机时间固定的情况下,虚拟机脏页率越低迁移成功率越高,能够更快的进行内存拷贝收敛。
QEMU可以通过HMP/QMP设置热迁移的宿主机带宽max_bandwidth和允许虚拟机宕机时间downtime_limit,热迁移过程中会检查是否超出带宽限制,如果超出带宽限制就主动进行usleep将速度降下来;根据downtime_limit和当前传输速率可以得到最后阶段允许发送的max_size,这是判断是否继续脏页拷贝迭代或者是进入最后拷贝阶段的关键值。
max_bandwidth不宜设置过大,会影响宿主机上其他程序(比如云磁盘)或者是虚拟机使用的网络带宽;downtime_limit也不宜设置过大,会导致虚拟机内程序中断时间较长。如果这两个值设置都不大,但是虚拟机内脏页产生速率过快就会导致热迁移过程中脏页拷贝迭代迟迟不能结束,这个时候可以通过降频来提高热迁移成功率。
降频就是通过限制vCPU的运行时间来控制虚拟机的写内存频率,从而降低虚拟机的脏页率。QEMU可以设置cpu_throttle_initial和cpu_throttle_increment来控制降频百分比,这两个参数最终会在每一个vCPU执行usleep将vCPU设置为睡眠状态,从而实现降频和降低脏页率的目的。
KVM性能优化
CPU优化
KVM CPU优化主要是隔离和PV优化两个方面:隔离需要确保vCPU的pCPU运行时间,尽量避免被中断、其他vCPU、其他用户态和内核态线程干扰;PV优化是尽可能的减少VM Exit,让vCPU运行效率尽可能高。
隔离手段包括:
-
vCPU Pinning
-
kWorker Isolate
-
Interrupt Isolate
PV优化:
-
halt-polling
-
PV EOI
-
PV UNHALT
-
PV TLB FLUSH
-
PV SEND IPI
-
PV SCHED YIELD
总结一下KVM虚拟化性能优化的原理在于:
-
降低Guest退出发生频率
-
硬件加速:比如EPT和Posted-Interrupt-Deliver
-
共享内存:比如vCPU状态,PV EOI
-
影子页表:EPT
-
直接分配IO:VT-d
-
批处理HyperCall
-
-
降低Guest退出处理时间
-
并行、同步变异步:PV SEND IPI、PV unhal
-
隔离
vCPU Pinning
将vCPU与特定的pCPU进行绑定,vCPU绑定pCPU可以提升CPU Cache命中率。
除了将vCPU与pCPU进行绑定之外,还需要将QEMU中的IO Thread、vhost内核线程、VNC线程等以及系统中其他的管控面Agent和数据面Agent也绑定到非vCPU售卖的pCPU上,来避免这些线程对vCPU的影响。
kWorker Isolate
kworker是Linux内核实现的per-CPU线程,用来执行系统中的workqueue请求。kworker线程会和vCPU线程争抢物理核资源,导致虚拟化业务性能抖动。为了使虚拟机能够稳定的运行,减少kworker线程对虚拟机的干扰,可以将主机上的kworker线程绑定到特定的CPU上运行。
用户可以通过修改/sys/devices/virtual/workqueue/cpumask文件,将workqueue中的任务绑定到cpumask中指定的CPU上,cpumask中的掩码以十六进制表示。
# echo ff > /sys/devices/virtual/workqueue/cpumask
Interrupt Isolate
将外部设备中断通过smp_affinity绑定到非售卖vCPU,避免外部设备中断影响vCPU的运行。
# echo $mask > /proc/irq/$irq_num/smp_affinity
PV
halt-polling
在计算资源充足的情况下,为使虚拟机获得接近物理机的性能,可以在Host上使用halt-polling特性。没有使用halt-polling特性时,当vCPU空闲退出后,主机会把CPU资源分配给其他进程使用。当主机开启halt-polling特性时,虚拟机vCPU处于空闲时会polling一段时间,polling的时间由具体配置决定。若该vCPU在polling期间被唤醒,可以不从主机侧调度而继续运行,减少了调度流程的开销,从而在一定程度上提高了虚拟机系统的性能。严格意义上,halt-polling不算是PV优化,Guest不需要做任何改变,只需要在Host做了些行为调整。
halt-polling的机制保证虚拟机的vCPU线程的及时响应,但在虚拟机空载的时候,主机侧也会polling,导致主机看到vCPU所在CPU占用率比较高,而实际虚拟机内部CPU占用率并不高。
配置方式如下,polling的时间默认为500000ns:
# echo 400000 > /sys/module/kvm/parameters/halt_poll_ns
Async Page Fault
VM的物理内存是QEMU进程的虚拟内存,QEMU的虚拟内存可以被Swap出去,当Guest的vCPU访问被换出的内存页面时执行会被阻塞直到内存页面被换入。Async Page Fault是一种让Guest vCPU执行更高效的方式,在页面被换入的时间内允许其他Task执行。Async Page Fault中发现页面被换出后,VMM向Guest注入”page not present”异常,并由独立的线程执行Get User Page的换入工作,Guest收到”page not present”异常后调度到其他任务;当内存页面被换入之后VMM向Guest注入“page ready”异常,guest再将之前等待内存页面的任务调度回来。
PV EOI
EOI指End Of Interrupt,PV EOI是避免EOI时的APIC Write。之前一次中断会触发两次VM Exit,一次是VMM截获设备中断后通知Guest退出并注入中断,一次是Guest完成中断处理之后写中断控制器的EOI寄存器。PV EOI也是使用共享内存, VMM在注入中断之前在此共享内存中设置了一个标志位,当Guest处理该中断并写入EOI时,如果发现该标志位则将清除该标志并返回;VMM轮询这个标志位,当检查到清0后会更新模拟中断控制器中的EOI寄存器。原来写EOI寄存器的操作在PV-EOI之后变成了写共享内存的操作,从而避免了写MMIO导致的VM Exit。
EOI在Linux 3.6中开始支持,在QEMU 1.2中开始支持,并在1.3开始默认开启。
PV UNHALT
PV unhalt实际上是关于spinlock优化的,虚拟化环境中spinlock的holder vCPU可以被调度器抢占,当其他vCPU也尝试获取这个spinlock的时候就会一直自旋直到holder vCPU被重新调度。PV unhalt优化是设置pv_lock_ops来重写原生的spinlock函数实现,通常使用ticketlock或者是queued spinlock实现,其底层原理都是让不能获取spinlock进行自旋的vCPU执行halt指令,同时让其他vCPU得到调度。
PV unhalt从Linux 3.12开始支持。
PV TLB Shootdown
当一个CPU修改了virt-to-physical地址映射之后,它需要通知其他CPU失效TLB中的Cache,这个就叫做TLB shootdown。TLB刷新在物理机上是硬件实现的,但是在虚拟化场景中,如果目标CPU被抢占或阻塞,发起TLB刷新的CPU就需要busy-wait等待其重新运行,这是极其低效的。
TLB Shootdown发起的CPU不再等待睡眠的CPU,而是设置一个标志位到Guest-VMM共享的区域,当睡眠的CPU重新执行的时候KVM检查这个标志位来执行TLB刷新。
PV TLB Shootdown从Linux 4.16开始支持。
PV SEND IPI
使用HyperCall将xAPIC/x2APIC物理模式下多个VM Exit优化成一次VM Exit,x2APIC Cluster模式下每个Cluster只需要一次VM Exit。AMD只支持xAPIC,PV SEND IPI更能明显提升性能。
PV TLB Shootdown从Linux 4.19开始支持。
PV SCHED YIELD
如果一个vCPU发送CallFunction的IPI的话,目标vCPU处于被抢占状态的话(中断or其他action抢占了vCPU执行?),就执行sched_yield到目标vCPU触发及时处理(如果多个vCPU被抢占,也只是yield给第一个vCPU)。
更进一步可以Boost Preempted vCPU,在spin lock yield target时优先选择有interrupt delivery和潜在lock holder的vCPU,在spin unlock的时候直接yield到queue Head vCPU,减少主机调度引入的wakeup延迟。
PV TLB Shootdown从Linux 4.19开始支持。
内存优化
NUMA Affinity
跨NUMA Node的内存访问延迟较大,在一些场景中需要将NUMA拓扑透传给Guest。
当前我们的NUMA亲和性设置策略为:如果虚机规格中vCPU小于物理机1个NUMA Node的可售核心时,选择一个Node上剩余较多CPU和Mem资源的物理机Node进行绑定;否则均匀分配跨物理机NUMA Node。
Huge Page
相比传统的4K内存分页,Linux也支持2MB/1GB的大内存分页。内存大页可以有效减少TLB miss,显著提升内存访问密集型业务的性能。具体有两种技术来实现内存大页:
-
静态大页:静态大页要求宿主机操作系统在加载前提前预留一个静态大页池,虚拟机创建时通过修改xml配置文件的方式,指定虚拟机的内存从静态大页池中分配。静态大页能保证虚拟机的所有内存在host上始终以大页形式存在,保证物理连续,但增加了部署的困难,修改静态大页池的页面大小后需要重启host才能生效。静态大页的页面大小支持2M或1G。
-
透明大页:如果开启透明大页模式THP(Transparent Huge Pages),虚拟机分配内存时自动选择可用的2M连续页,同时自动完成大页的拆分合并,当没有可用的2M连续页时,它会选择可用的64K(AArch64架构)或4K(x86_64架构)页面进行分配。透明大页的好处是不需要用户感知,同时能尽量使用2M大页以提升内存访问性能。
在虚拟机完全使用静态大页的场景下,可以通过关闭透明大页的方法,减少宿主机操作系统的开销,以便虚拟机获得更稳定的性能。当前百度云线上采用的是静态大页。
Linux下可以通过sysfs来动态开启或关闭透明大页:
# echo always | never > /sys/kernel/mm/transparent_hugepage/enabled
Timer优化
Exitless Timer
Guest设置定时器和Host上模拟定时器到达都会导致VM Exit,Exitless Timer使用housekeeping CPU使用posted-interrupt来优化Host侧中断注入引起的VM Exit。(posted-interrupt将中断写入VMCS中特定字段,并通过特殊的IPI通知Guest,可以让Guest执行中断评估,从而优化中断注入侧的VM Exit)。
在Redis+BBR的测试场景中,结合nohz_full、mwait/pause/hlt透传、exitless timer等优化手段有20%的性能提升。
Timer虚拟化中的开销会导致定时器精度下降,可以引入时间补偿优化Adaptive tune advance lapic timer,腾讯云的cyclictest测试结果来看调度延迟可以从9us到5us。
VMX Preemption Timer
VMX Preemption Timer相当于Timer Passthrough,Guest和Host都操作HR Timer的红黑树,其中都有锁。VMX Preemption Timer可以缩短Timer路径,优化掉VMM中的HR Timer中的锁,而且省掉了Timer到期时候的中断注入。华为云主要用于NFV场景。
IO优化
IOThread
KVM平台上,对虚拟磁盘的读写在后端默认由QEMU主线程负责处理。这样会造成如下问题:
-
虚拟机的I/O请求都由一个QEMU主线程进行处理,因此单线程的CPU利用率成为虚拟机I/O性能的瓶颈。
-
虚拟机I/O在QEMU主线程处理时会持有QEMU全局锁(qemu_global_mutex),一旦I/O处理耗时较长,QEMU主线程长时间占有全局锁,会导致虚拟机vCPU无法正常调度,影响虚拟机整体性能及用户体验。
可以为virtio-blk磁盘或者virtio-scsi控制器配置IOThread属性,在QEMU后端单独开辟IOThread线程处理虚拟磁盘读写请求,IOThread线程和virtio-blk磁盘或virtio-scsi控制器可配置成一对一的映射关系,尽可能地减少对QEMU主线程的影响,提高虚拟机整体I/O性能,提升用户体验。
除了IOThread之外,Block虚拟化性能提升还有几种方式:
-
Guest设置IO Scheduler为deadline(IO调度算法:none、deadline、cfq)
-
libvirt设置VM Cache模式为none(cache=‘none')
-
libvirt设置IO模式为Native AIO(io='native',而非Posix threads AIO)