利用硬件虚拟化(KVM)加速QEMU上的IOS

2020-07-20 07:07:04

作者Lev Aronsky(@levaronsky)*虽然QEMU一开始是一个仿真硬件的平台(特别是主机CPU不支持的架构),但后来的版本获得了使用硬件辅助虚拟化执行代码的能力。这可以带来巨大的性能优势,因为大多数执行的操作码都是由CPU直接执行的,而不是被翻译成许多模拟原始操作码行为的本地操作码。

使用虚拟化需要支持执行体系结构的主机CPU。在基于Intel的机器上(比如我们用来在QEMU上开发iOS的机器),虚拟化x86/x64以外的架构是不可能的。因此,在QEMU上运行iOS(一种ARM64操作系统)时,我们会使用常规仿真。起初,表演是绰绰有余的。但是,随着我们在QEMU上执行IOS的努力的发展,以及操作系统的更多部分的成功启动,我们开始注意到性能的下降。

现代ARM芯片支持硬件辅助虚拟化,类似于其x86bretheren。如果我们要在基于ARM的系统上运行我们的QEMU版本,应该可以利用底层CPU的虚拟化功能来实现近乎本机的性能。这篇文章记录了我们必须克服的挑战,以便使用硬件辅助虚拟化在QEMU上成功引导我们的iOS系统。

当从基于英特尔的笔记本电脑转向基于ARM的系统进行开发时,第一个问题是选择哪种平台。我们应该在云中使用ARM服务器吗?基于ARM的开发板?开发人员友好的Android手机?每种选择似乎都有自己的优势。

ARM服务器很容易成为其中功能最强大的--但是基于ARM的专用服务器的选择是有限的,它们并不便宜,而且我们不确定我们是否能获得所需的访问级别(我们的假设是需要重新编译内核)。购买物理服务器是我们短暂考虑的一个选择,但随着价格在数千美元,这个想法很快就被抛弃了。

二手Android手机是另一个很好的选择,但即使是对开发者友好的手机也可能很难使用。使用Android而不是通用Linux可能是一个限制因素,而要找到一款有足够RAM在成熟的Android环境中并行执行iOS的手机,我们将需要使用一款最新的手机,这将是一款不便宜的手机。

基于ARM的开发板看起来是个不错的选择,它可以让我们以替代产品价格的一小部分来开发和开发我们的代码。为了获得对内核开发、64位ARM SOC和不少于4 GB RAM的良好售后支持,我们选择了Pine Rock64作为我们的试验床:

主板配备了一个ARM64CPU(基于Cortex-A53的RK3328),以及4 GB的RAM。这个选择似乎非常适合我们的目的:在模拟iPhone时,我们成功地使用了Cortex-A53作为我们的CPU,虽然4 GB的RAM并不理想(我们通常使用6 GB的专门用于QEMU的RAM来运行我们的模拟),但我们的测试表明,使用较少的RAM(例如,2 GB)在这一点上没有明显的影响。

一旦我们的主板启动并运行最新版本的Armbian,就到了我们第一次尝试运行QEMU的时候了,同时使用硬件辅助的CPU虚拟化来代替仿真。理论上,用户只需将-enable-kvm开关添加到命令行…。

不幸的是,这并不是那么简单。当QEMU启动成功时,iOS无法启动。在引导时附加gdb让我们一开始可以看到正确执行的指令,但是继续之后,我们很快就会发现自己陷入了一个位于0xfffffff0070a0200的无限循环中。基于内核符号,它是中断/异常处理的矢量之一。达到该代码量之前就发生了异常,操作系统仍然没有达到可以更优雅地处理它的程度(例如转储寄存器和内存内容,至少结合了某种类型的问题描述)。在这种情况下,操作系统仍然没有达到可以更优雅地处理它的程度(例如,转储寄存器和内存内容,以及至少某种类型的问题描述)。我们别无选择,只能一步一步地完成内核的早期初始化,一条接一条指令,直到跳转到异常向量。

在这一点上,重要的是要注意,当第一次加载内核时,MMU还没有启用,并且代码被映射到物理地址(执行从0x470a5098开始)。只有在稍后的初始化过程中,才会启用MMU(一旦页表初始化),并且地址会切换到熟悉的内核模式(较高的位设置为1)。但是,在Ghidra等反汇编程序中查看内核映像时,所有代码都映射到内核地址。因此,我们正在检查的初始化代码可以在0xfffffff0070a5098找到。

某些初始化代码包含多次执行的循环。为了使后续执行更有效,我们使用了以较小间隔设置的断点。这让我们可以继续执行,而不是逐个遍历每个指令和循环。使用此技术,我们很快发现地址0x470a72e4出现异常:

0x470a72d4 MSR VBAR_EL1,x00x470a72d8 movz x0,0x593d0x470a72dc movz x1,0x3454,LSL 160x470a72e0 or r x0,x0,x10x470a72e4=>;MSR sctlr_el1,x00x470a72e8 isb0x470a72e8。

如我们所见,在上述地址,值0x3454593d写入SCTLR_EL1寄存器。根据ARM DDI 0487,D13.2.113,这是系统控制寄存器,提供EL0和EL1的系统顶级控制。其第一位用于通过MMU启用地址转换。由于在执行此指令时发生异常(并且在寄存器的new值中设置了位0),因此地址转换配置是立即考虑的。

有几个寄存器用于配置MMU,即TCR_EL1、TTBR1_EL1和MAIR_EL1。我们检查了存储在这些寄存器中的值,以便启用MMU。一个特别突出的字段是TCR_EL1.TG1,它指示TTBR1_EL1的粒度大小。OuriOS内核的初始化代码将TCR_EL1.TG1的值设置为0b01(该字段以位31:30存储,写入0x470a7244处的TCR_EL1的值为0x000000226519a519)。值0b01对应于16KB的颗粒大小。

值得注意的是,在单步执行位于0x470a7244的MSR指令之后,检查TCR_EL1的值发现了一个略有不同的值0x00000022a519a519-颗粒大小读出为0b10(4KB)!这清楚地说明了启用MMU时出现异常的原因:初始化代码设置的页表设计为16KB的粒度INMIND,而启用MMU时存储在TCR_EL1中的实际粒度设置为4KB。MMU错误地处理页面条目,在启用MMU后立即发生页面故障。但是,为什么我们试图设置的16KB颗粒的值不成立呢?

如果值被编程为保留值或尚未实现的大小,则硬件将对待该字段,就好像它已被编程为实现定义的大小选择,该实现定义的大小已针对除从该寄存器读回的值之外的所有目的而实现。由实现定义读回的值是编程的值还是对应于所选大小的值。

在我们的示例中,TCR_EL1.TG在尝试将其设置为0b01后被读回为0b10。这表明在我们的示例中,读回值是与所选的粒大小相对应的值(因为它不是我们编程的值),并且16KB大小尚未在我们的CPU中实现。我们可以借助ID_AA64MMFR0_EL1寄存器(AArch64内存模型功能寄存器0)验证这一假设。其字段TGran16(位23:20)用于指示支持16KB内存转换区块大小:当位全部设置为0时,不支持该区块大小。读取我们的Rock64开发板上的寄存器的值返回0x00001122-因此,位23:20被设置为0,并且我们的CPU没有实现16KB的区块大小。事实上,通过参考ARM DDI 0500(ARM Cortex-A53 MPCore处理器技术参考手册)中的4.2.1节,我们可以看到0x00001122的值是经过设计的-即,Cortex-A53内核没有实现16KB的颗粒大小。有趣的是,QEMU中的Cortex-A53实现忽略了这一点,实现了16KB的粒度(ID_AA64MMFR0_EL1的值为0x00001122,与参考手册相符,但将TCR_EL1.TG设置为0b01可以正常工作)。

要继续这个项目,我们有几个选择。我们曾短暂考虑过为iOS内核打补丁,使其使用4KB或64KB页面,Cortex-A53都支持这两种页面。这个想法很快就被放弃了,因为它需要付出很大的努力,而其成功的可能性也是值得怀疑的。虽然构建具有不同页面大小的初始页面表应该是可行的,但我们必须找到内核代码中操作页面表的所有位置,并相应地更新它们-这不是一件容易完成的壮举。

因此,我们不得不改用支持16KB页面的ARM内核。这让我们又回到了硬件(ARM服务器、开发板、手机)的选择上,还有一个要完成的要求:我们要查阅驱动所选硬件的内核的技术参考手册,并验证iOS内核所使用的粒度大小是否受支持。

不幸的是,大多数预算开发板使用较旧的ARM内核(Cortex-A53或Cortex-A72),不支持所需的粒度大小。我们发现有一块主板的内核具有所需的支持-但它只有1 GB的RAM。大多数Android手机也不符合我们的要求--我们需要一款配备最新芯片组的现代手机,所有以前使用手机所面临的挑战仍然存在。

我们选择切换到基于云的ARM服务器进行初始开发。如果概念验证成功,内核中的所有潜在问题都解决了,我们会考虑购买更昂贵的开发板或扬声器。我们的第一次尝试是在亚马逊AWS上进行的,但我们很快发现他们的Graviton内核不支持16KB页面(即将发布的Graviton 2内核基于ARM Neoverse N1,有必要的支持--但当时还没有上市)。我们最终与Packet合作,它有两种不同的ARM产品,一个基于Cavium ThunderX CN8890,另一个基于Ampere eMAG8180,价格具有竞争力。

我们开始使用基于Cavium的服务器,并在0x470a72e4成功通过了初始故障点(成功启用了MMU,并且执行继续到下一条指令,没有任何异常)。

我们的快乐是短暂的。仅仅几条指令之后,在0x470a72f4处,我们遇到了另一条指令,该指令失败了,并将我们放入异常处理程序(根据状态寄存器的值,我们命中了INVALID指令):

0x470a72ec movz x0,00x470a72f0 MSR tpidr_el1,x00x470a72f4==>;mRS x12,s3_0_c15_c4_00x470a72f8或x12,x12,0x8000x470a72fc或x12,x12,0x100000000000。

这条看起来很神秘的MRS指令试图读取专用寄存器的值。虽然许多特殊寄存器都有定义的名称(并相应地在反汇编程序中显示-所有上述寄存器,如SCTLR_EL1,都是此类特殊寄存器的示例),但有些寄存器不是由ARM定义的,而是特定于实现的。对这些寄存器的访问是用几个字段(op0、op1、CRM、CRN和op2)编码的,这些字段的组合对于每个系统寄存器是唯一的。

在我们的示例中,查找这个字段组合会发现它是一个特定于Apple的特殊寄存器(它在XNUsource中显示为ARM64_REG_HID4,在pExpert/pExpert/arm64/arm64_common.h中定义)。虽然这个寄存器的用途不清楚,但我们在QEMU代码中处理了它(以及其他Apple特定的寄存器),只需存储写入这些寄存器的任何值,然后按原样读回它们。事实上,处理这些寄存器的代码仍然存在-为什么它不会执行,为什么我们会遇到无效指令呢?根本原因在于QEMU的简化。虽然QEMU的大部分代码与客户CPU的基础无关(无论它是在软件中模拟的,还是在硬件中虚拟化的),但某些功能需要不同的实现。特殊寄存器的处理就是这样的特征之一。

当没有使用KVM时(并且模拟了CPU体系结构),实现MSR和MRS指令的代码使用回调来支持一系列特殊寄存器(target/arm/Translate-a64.c):

/*mRS-从系统寄存器移出*msr(寄存器)-移到系统寄存器*SYS*SYSL*这些在';READ';和';WRITE';*版本中基本上是相同的,但op0字段各不相同。*/static void handle_sys(DisasContext*s,uint32_t insn,bool is read,unsign int op0,unsign int op1,unsign int op2,unsign int crn,unsign int CRM,unsign int rt){const ARMCPRegInfo*ri;TCGv_i64 TCG_RT;ri=get_arm_cp_reginfo(s->;cp_regs,ENCODE_AA64_cp_rt){const ARMCPRegInfo*ri;TCGv_i64 TCG_RT;ri=get_arm_cp_reginfo(s->;cp_regs,ENCODE_AA64_CP_。/*...*/if(Isread){if(ri->;type&;arm_CP_const){tcg_gen_movi_i64(tcg_rt,ri->;setvalue);}if(ri->;readfn){TCGv_ptr tmpptr;tmpptr=tcg_const_ptr(Ri);gen_helper_get_cp_reg64(tcg_rt,Tcg_temp_free_ptr(Tmpptr);}Else{tcg_gen_ld_i64(tcg_rt,cpu_env,ri->;fieldoffset);}}Else{if(ri->;type&;arm_CP_const){/*如果访问权限未禁止,视为WI*/Return;}Else if(ri-&>;writefn){TCGv_ptr tmppt。Gen_helper_set_cp_reg64(cpu_env,tmpptr,tcg_rt);tcg_temp_free_ptr(Tmpptr);}否则{tcg_gen_st_i64(tcg_rt,cpu_env,ri->;fieldoffset);}/*...*/}。

DisasContext结构的cp_regs包含仿真CPU的各种协处理器(特殊)寄存器的描述符。可以通过使用唯一标识寄存器的op0、op1、CRM、CRN和op2字段来检索ARMCPRegInfo类型的描述符。描述符结构可以包含用于访问控制(访问fn,未在片段中显示)、写入(Write Efn)和读取(Readfn)寄存器的回调函数。虽然所有标准的特殊寄存器都是由QEMU中的各种核心实现预定义的,但是在仿真期间添加对新的特殊寄存器的支持就像编写几个回调函数并将新的描述符添加到CPU的cp_regs字段一样简单。

对于KVM,其实现有很大的不同-但在深入研究它之前,我们需要对虚拟化和虚拟机管理程序有一个基本的了解。

硬件辅助虚拟化背后的想法非常简单:我们希望能够直接在主机CPU上执行代码,但要阻止它访问不属于它的资源。内核代码就是一个很好的例子。从理论上讲,它是万能的-它可以重新配置敏感的寄存器,并且可以完全访问所有可用内存:我们如何避免并行执行的两个内核之间的冲突?该问题通过限制在CPU上的虚拟环境中执行的代码的权限来解决。基本上,某些本质上被认为是敏感的指令(例如,可以配置重要系统寄存器的指令)会中断虚拟化代码的正常执行,并将控制权返回给主机,在主机中,负责虚拟机的软件(虚拟机管理程序)可以检查指令,并在不影响主机的情况下仿真其效果。这些到主机的跳转通常称为VM出口。并且当代码在此虚拟化模式下执行时

在QEMU/KVM世界中,KVM承担起虚拟机管理程序的低级职责:它配置虚拟CPU,在这些CPU上调度代码执行,并在各种VM退出发生时对其进行初步处理。当发生KVM自己无法处理的出口时(例如与KVM不知道的模拟设备的通信),KVM会将处理转移到QEMU。将对QEMU的这些传输保持在最低限度背后的原因是性能:VM退出已经是非常昂贵的操作(虚拟CPU及其寄存器的状态必须保存并稍后恢复,而导致退出的指令的仿真本身可能是一个漫长的过程)。添加从KVM(内核模式)到QEMU(用户模式)的传输会进一步减慢执行速度。

那么,当启用KVM时,QEMU如何处理特殊寄存器呢?在使用硬件虚拟化的情况下,指令直接在CPU上执行。当访问主机处理器未知的特殊寄存器时(就像在非Apple CPU上运行时Apple专用寄存器的情况一样),就会引发异常,并发生VM退出。虚拟机管理程序必须在恢复来宾执行之前处理访问。KVM应如何处理此问题?

让我们从检查arch/arm64/kvm/handleexit.c中负责处理VM退出的HANDLE_EXIT函数开始:

Int HANDLE_EXIT(struct KVM_vCPU*vCPU,struct KVM_RUN*RUN,INT EXCEPTION_INDEX){/*...*/EXCEPTION_INDEX=ARM_EXCEPTION_CODE(EXCEPTION_INDEX);开关(EXCEPTION_INDEX){/*...*/case ARM_EXCEPTION_TRAP:返回HANDLE_TRAP_EXCEPTIONS(vCPU,RUN);/*...*/}}。

当发生退出时,KVM检查CPU的状态寄存器,以找出导致退出的原因。当它确定异常是由陷阱引起的(这是访问不受支持的特殊寄存器的情况)时,它会调用HANDLE_TRAP_EXCEPTIONS函数:

STATIC INT HANDLE_TRAP_EXCEPTIONS(struct kvm_vcpu*vCPU,struct kvm_run*run){int已处理;/**参见ARM B1.14.1:";Hyp Traps on Instructions*,这些指令未通过其条件代码检查";*/if(!KVM_CONDITION_VALID(VCPU)){KVM_SKIP_INTR(vCPU,KVM_vCPU_TRAP_IL_is32bit(VCPU));HANDLED=1;}ELSE{EXIT_HANDLE_FN EXIT_HANDLER;EXIT_HANDLER=KVM_GET_EXIT_HANDLER(VCPU);HANDLED=EXIT_HANDLER(vCPU,RUN);}返回HANDLED;}。

该函数反过来检索处理程序函数并执行它。处理程序函数通过KVM_GET_EXIT_HANDLER检索:

STATIC EXIT_HANDLE_FN KVM_GET_EXIT_HANDLER(struct KVM_vCPU*vCPU){u32 HSR=KVM_vCPU_GET_HSR(VCPU);u8 HSR_EC=ESR_ELX_EC(HSR);RETURN ARM_EXIT_HANDLES[HSR_EC];}

当由于不支持的特殊寄存器而发生陷阱时,返回的处理程序是kvm_handle_sys_reg,来自arch/arm64/kvm/sys_regs.c:

Int kvm_handle_sys_reg(struct kvm_vcpu*vcpu,struct kvm_run*run){struct sys_reg_params params;unsign long esr=kvm_vcpu_get_hsr(VCPU);int rt=kvm_vcpu_sys_get_rt(VCPU);int ret;trace_kvm_handle_sys_reg(ESR);params。Is_aarch32=false;params。IS_32bit=FALSE;参数。Op0=(ESR&>;>;20)&;3;参数。Op1=(ESR&>;>;14)&;0x7;参数。CRN=(ESR&>;&>10)&;0xf;参数。CRM=(ESR&>;>;1)&;0xf;参数。Op2=(ESR&>;>;17)&;0x7;参数。Regval=vcpu_get_reg(vcpu,rt);params。Is_write=!(ESR&;1);ret=仿真系统_注册(vCPU,&;

.