本文翻译自Fabrice Bellard 发表在 USENIX ‘05 Technical ProgramQEMU, a Fast and Portable Dynamic Translator . QEMU 项目站点: http://qemu.org QEMU 现在已演化到2.1版本,GitHub仓库

摘要

本文展示了一种基于动态翻译的高效可移植仿真器QEMU的内部结构。QEMU可以在多种主机(x86, PowerPC, ARM, Sparc Alpha and MIPS)上仿真多种客户机(x86, PowerPC, ARM and Sparc)。通过完备的系统仿真,QEMU支持在虚拟机中运行未经修改的操作系统;通过Linux用户模式仿真,还支持在不同CPU上运行Linux软件。

一、引论

QEMU是一种可以在虚拟机中运行未经修改的“客户”操作系统(比如Windows和Linux)及其程序的仿真器。它可以在Linux、Windows以及Mac OS X等多种主机操作系统上运行,并且,宿主机和客户机的指令集可以不同。

QEMU主要用于在一种操作系统上运行另一种操作系统。比如在Linux上运行Windows,或相反。它也可用作调试,因为虚拟机可以非常方便地暂停、监视状态、保存或恢复。你甚至可以通过增加新的机器描述和仿真器件来模拟专用的嵌入式设备。

同时,QEMU还集成了符合Linux用户模式规范的仿真器。可以将它看作是在不同CPU上运行Linux进程的机器仿真器的一种。主要用于在不完整启动一个虚拟机的情况下测试交叉编译器结果或CPU仿真器。

QEMU由以下几个子系统构成:

  • CPU模拟器(目前支持x86、PowerPC、ARM以及Sparc)
  • 设备模拟(比如VGA显示器、16450串口、PS/2鼠标键盘、IDE硬盘、NE2000网卡等等)
  • 在宿主机设备和模拟设备间协作的常规设备(比如块设备、字符设备、网络设备)
  • 实例化了模拟设备的机器描述(比如PC、PowerMac、Sun4m)
  • 调试器
  • 用户界面

本文主要介绍QEMU所使用的动态翻译器的实现。动态翻译器运行时将客户标CPU指令向宿主机指令集转换。二进制结果代码被存储在一块翻译后缓存中,这样就可以重复使用。跟解释器相比,动态翻译器的优势在于客户机的指令只要执行一次取指、译码。

通常,动态翻译很难在不同主机间移植,因为整个代码生成器都要重写。这个工作量大得和给C编译器增加新客户机指令一样。然而,QEMU仅仅把GCC“离线”生成的机器代码连接起来,这使得它显得很简单。[5]

CPU模拟器还面临一些传统难题:

  • 管理翻译后的代码缓存
  • 寄存器分配
  • 条件代码优化
  • 存储器管理
  • 自修改代码支持
  • 例外支持
  • 硬件中断
  • 用户模式仿真

二、可移植动态翻译

2.1 概述

首先,要将客户机CPU指令分割成一系列较为简单的“微操作”指令。每个微操作都是由一小段经GCC编译成客户机文件的C代码实现。被选取的微操作要比客户机CPU的指令、操作都要少,通常只有数百个。从客户机CPU指令向微操作翻译完全由手工编写的代码完成。因为这阶段的性能要求没有解释器那么严格,所以源代码的编写更注重可读性和密集性。

一个以包含微操作的客户机文件为输入的编译时工具 dyngen 用来生成动态代码翻译器。生成后的动态代码生成器在运行时被调用,生成由一系列微操作组成的主机函数。

这个过程和文献[1]很像,但在编译时为了提高性能做了很多工作。准确地讲,关键思想就是QEMU常量参数可以传递给微操作。为了达到这一目的,每个常量参数都用GCC生产了假代码重定位。这使得 dyngen 可以在建立动态代码时定位重定位的常量参数,并生成能正确解析它们的C代码。微操作中的静态数据和其他函数的引用也可以使用重定位。

2.2 举例

考虑这样一种情况,在将PowerPC指令翻译为x86代码时: addi r1,r1,-16 # r1 = r1 - 16

下面是PowerPC代码翻译器生成的微操作:

movl_T0_r1          # T0 = r1
addl_T0_im -16      # T0 = T0 - 16
movl_r1_T0          # r1 = T0

微操作的数量在不过多影响生成代码质量的前提下被压缩到了最小。比如,我们没有生成在每个PowerPC寄存器间搬移数据的代码,而仅仅使用一些临时的寄存器。T0、T1、T2这些寄存器通常使用GCC静态寄存器变量拓展存储在主机寄存器中。

微操作 movl_T0_r1 通常被写成这样:

void op_movl_T0_r1(void)
{
    T0 = env->regs[1];
}

env 是一个包含客户机CPU状态的数据结构。PowerPC的32个寄存器被存储在 env->regs[32] 数组中。

addl_T0_im 就更有趣了,因为它使用了一个在运行时才能决定其值的 常量参数

extern int __op_param1;
void op_addl_T0_im(void)
{
    T0 = T0 + ((long)(&__op_param1));
}

dyngen 生成的代码生成器以 opc_ptr 指向的微操作流为输入,输出的主机代码存在 gen_code_ptr 指向的位置。 oppraram_ptr 指向微操作变量:

[...]
for(;;) {
  switch(*opc_ptr++) {
  [...]
  case INDEX_op_movl_T0_r1: 
  {
    extern void op_movl_T0_r1();
    memcpy(gen_code_ptr, 
      (char *)&op_movl_T0_r1+0, 
      3);
    gen_code_ptr += 3;
    break;
  }
  case INDEX_op_addl_T0_im: 
  {
    long param1;
    extern void op_addl_T0_im();
    memcpy(gen_code_ptr, 
      (char *)&op_addl_T0_im+0, 
      6);
    param1 = *opparam_ptr++;
    *(uint32_t *)(gen_code_ptr + 2) = 
      param1;
    gen_code_ptr += 6;
    break;
  }
  [...]
  }
}
[...]
}

对于多数像 movl_T0_r1 这样的微操作指令,可以直接复制由GCC生成的主机代码。由于 __o_param1 的重定位由GCC产生,当使用常量参数时, dyngen 用运行时参数 param1 来修正生成的代码。

在代码生成器运行时,主机输出代码如下:

# movl_T0_r1
# ebx = env->regs[1]
mov    0x4(%ebp),%ebx   

# addl_T0_im -16
# ebx = ebx - 16
add    $0xfffffff0,%ebx
# movl_r1_T0
# env->regs[1] = ebx
mov    %ebx,0x4(%ebp)

在x86机器上, T0 映射到了 ebx 寄存器, ebp 寄存器指向CPU上下文状态。

2.3 Dyngen的实现

dyngen 工具是QEMU翻译过程的核心,用它来处理包含微操作客户机文件时包含以下几个步骤:

  • 分析客户机文件得到符号表、重定位信息以及代码段。这个过程依赖于主机的目标文件格式。( dyngen 支持ELF、PE-COFF、MACH-O)
  • 使用符号表定位代码段的微操作。在复制代码开始和结束的时候,执行特定主机的方法。通常会跳过函数头和函数尾。
  • 检查每个微操作的重定位,得到常量参数。可以通过特殊的符号名 __op_paramN 来检测常量函数重定位信息。
  • 使用一个C语言的内存拷贝来复制微操作代码。微操作代码的重定位信息用于修正被复制的代码,以保证重定位的正确性。重定位修正依赖于主机特性。
  • 有些主机使用PC附近的一小段偏移访问常量,比如说ARM,这样一来,常量应该存储在生成代码附近。在生成代码中重定位常量要使用主机相关的方法。

为了使得微操作代码容易分析,在编译的时候使用一系列GCC标识将函数头尾代码处理成一个表格。一个虚编译宏强制GCC通过一条返回指令终止每个微操作对应的函数。如果一个微操作生成了多个返回指令,代码连接就不会工作。

三、实现细节

3.1 翻译块和翻译缓存

第一次开始时,QEMU持续地将客户机代码翻译成主机代码,直到下一个跳转或静态CPU状态被修改。我们将这些基本快称为翻译块。

近期刚被使用过的翻译块保存在一个16MB的缓存中。出于简化设计的目的,缓存会在被填满时完全清空。

静态CPU状态是指在翻译时进入翻译块已知的CPU状态。比如,对所有客户机来讲,在翻译时PC值是已知的。为了在x86机器上获得更好的代码,静态CPU状态包含更多的数据。因为我们需要知道诸如CPU在运行在保护模式还是实模式,用户模式还是内核模式,默认操作大小是16位还是32位这样的重要信息。

3.2 寄存器分配

QEMU使用固定的寄存器分配。这意味着每个客户机CPU寄存器都被映射到了一个主机寄存器或一个内存地址。对于大多数主机,我们仅仅将有限的几个临时变量存储在主机寄存器中,而将大多数寄存器映射到某块内存地址。每个CPU描述中的临时变量分配都经过仔细编码。这种带来的好处是简洁性和可移植性。

为了消除在客户机寄存器直接存储在主机寄存器这种情况下不必要的数据移动操作,QEMU的后续版本引入动态临时寄存器分配器。

3.3 条件代码优化

对CPU条件代码的仿真(x86上的 eflags 寄存器)是提高性能的难点。QEMU使用懒惰条件代码解法:它只存储操作码 CC_SRC 、结果 CC_DST 和操作类型 CC_OP ,而不是计算每个x86指令的条件代码。对于一个像 R=A+B 的32位加法,我们有:

CC_SRC=A
CC_DST=R
CC_OP=CC_OP_ADDL

CC_OP 中读取到的常量得知这是一个32位加法,我们就可以从 CC_SRC CC_DST 中恢复A、B和R。这样一来,在需要的时候,我们就可以计算出所有的ZF、SF、CF、OF这些标志位。

因为一个完整的翻译块是一次生成的,在翻译时条件代码决策可以得到很大程度的优化。一个用于检测后续代码是否使用了CC_OP、CC_SRC、CC_DST的后续过程会被执行。此外,在翻译块结束的地方,我们将这些变量视作会被使用的。之后,我们删除后续代码中没有被使用到的赋值。

3.4 直接块相连

在一个翻译块执行完毕后,QEMU使用模拟PC、其他静态CPU状态来在一个散列表中查找下一个翻译块。如果下一个翻译块没被翻译,那么启动新的翻译;否则,跳转到下个翻译块。

为了加快新模拟PC已知的多数情况,QEMU会修正翻译块使其直接跳到下一个。

大多数情况可移植代码使用间接跳转。在一些像x86、PowerPC这样的主机上,由于分支指令会被直接修改,块链接不会有内部自检。

3.5 存储器管理

对系统仿真来说,QEMU用 mmap 系统调用来仿真客户机MMU。在被仿真的操作系统没有使用主机的保留空间时它可以一直工作。[2]

为了能启动所有的操作系统,QEMU使用了软件MMU。在这种模式下,每次存储器访问都会使用MMU做虚拟地址向物理地址的转换。QEMU用一个地址翻译缓存来加速翻译。

为了避免每次MMU映射改变时都清除已翻译的代码,QEMU使用物理索引翻译缓存。这意味着每个翻译块都由它的物理地址索引。

当MMU映射改变时,跳转客户机的物理地址可能会发生改变,我们要重置翻译块的连接(即一个翻译块永远不能直接跳转到另一个)。

3.6 自修改代码以及翻译后代码无效

在大多数CPU上,由于可以执行通过特殊的Cache无效指令来标识某块代码被修改过,自修改代码的处理是较为简单的。只要无效掉相应的翻译后代码就行。

然而,在类似x86这样没有Cache无效指令的CPU上,自修改代码的处理就很有挑战了。[3]

当某个翻译块的翻译代码正在生成时,如果相应主机页不是只读的,需要将其写保护。如果有向该页的写操作,QEMU会无效掉业内所有的已翻译代码,将其置为可写。

通过维护给定页内包含的所有已翻译块链表,已翻译块的无效操作可以高效地完成。我们还维护了其他链表用于取消直接块相连。

在使用软件MMU时,代码无效操作更加高效:如果一个特定的代码页因为写访问无效掉了很多次,会建立一个代表所有页内代码的位图。每次对该页的存储操作都会检查位图以确认是否要无效掉相应的代码。这避免了在只有数据被修改是也无效掉业内的代码。

3.7 例外

当像除零这样的例外出现时, longjmp 用来跳转到例外处理代码。如果没有使用软件MMU,主机信号管理用来捕获无效的内存访问。

QEMU支持精确例外,在某这意义上说在例外发生时能恢复确切的客户机CPU状态。[4]因为大多数客户机CPU状态都是由翻译后代码明确地存储修改的,并不需要额外的工作。客户机CPU状态(比如当时的PC)并不会被精确地记录。不过,在每条被翻译客户机指令都有记录的模式下,可以在发生例外时通过重新翻译翻译块来回复客户机CPU状态。当例外发生时,主机PC将会被置起以用于查找相关的客户机指令及状态。

3.8 硬件中断

为了运行得更快,QEMU不会在检查每个翻译块的硬件中断,用户应当通过异步调用一些特殊的函数来告知QEMU有中断等待。这样的函数会重置正在执行的翻译快的连接。这就保证了CPU仿真器的主循环的执行能快速返回。主循环会检测是否有中断等待,如果有则进行相关处理。

3.9 用户模式仿真

QEMU支持在一种CPU上运行为另一中客户机CPU编译的Linux进程,这也叫做用户模式仿真。

在CPU层面,用户模式仿真仅仅是全系统仿真的一个子集。QEMU假设用户内存映射由主机操作系统控制,所以用户模式仿真没有MMU模拟。QEMU还包含了通用的Linux系统调用转换器,以便处理在32/64位转换和大小尾端的问题。因为QEMU支持例外,它可以准确地仿真客户机信号。每个客户机线程都在一个主机线程上运行。[5]

四、移植工作

将QEMU移植到一个新的CPU主机上需要完成下列工作:

  • 必须移植 dyngen (参见2.2节)。
  • 可以将微操作使用的临时变量映射到主机上特定的寄存器以优化性能。
  • 许多CPU主机要通过特殊的指令来维护指令Cache与内存之间的一致性。
  • 如果用修正过的分支指令实现直接块连接,需要提供专用的编译宏。

总体上,QEMU的移植难度与一个动态链接器相当。

五、性能

为了评估仿真带来的损耗,我们对比了x86主机上本地模式和x86客户机用户模式仿真的Linux的BYTEmark测试性能。[7]

用户模式的QEMU(0.4.2版本)整数指令约比本地执行慢4倍,浮点性能约慢10倍。因为静态CPU状态没有x86浮点单元栈指针,这种结果也是可以理解的额。在全系统仿真中,软件MMU约减慢了两倍。

QEMU的全系统仿真模式约比Bochs快30倍。[4]

QEMU的用户模式约比 valgring -skin=none (用于调试软件的手工编写的x86-x86动态翻译器,选项禁止Valgrind生成调试代码)快1.2倍。[6]

六、结论以及后续工作

QEMU已经达到了满足日常工作的客户机,特别是用于像Windows这样的x86商业操作系统的仿真。PowerPC客户机很快就能启动Mac OS X,Sparc也即将能启动Linux。由于移植的复杂性被低估,目前还没有其他的动态翻译器能在这么多的主机上支持这么多的客户机机器。QEMU可视作是性能和复杂性的一种优美权衡。

后续的工作将围绕以下几点展开:

  • 移植:QEMU已经能完美支持PowerPC和x86主机,向Sparc、Alpha、ARM以及MIPS的移植需要推进。同时,QEMU也高度依赖用来编译微操作的确切GCC版本的定义。
  • 全系统仿真:增加ARM和MIPS为仿真客户机。
  • 性能:软件MMU的性能仍能提高。在不过多修改现有翻译框架的前提下,一些关键的微操作还可以用汇编手工编写。CPU主循环也可以用汇编手工编写。
  • 虚拟化:当主机和客户机机器一样时,其实可以直接运行大多数代码。最简单的实现是仿真客户机内核代码,而客户机用户代码直接运行。
  • 调试:可以通过添加缓存模拟和循环计数器来开发一个像SIMICS那样的调试器。[3]

参考文献

[1] Ian Piumarta, Fabio Riccardi, Optimizing direct threaded code by selective inlining, Proceedings of the 1998 ACM SIGPLAN Conference on Programming Language Design and Implementation (PLDI).

[2] Mark Probst, Fast Machine-Adaptable Dynamic binary Translation, Workshop on Binary Translation 2001.

[3] Peter S. Magnusson et al., SimICS/sun4m: A Virtual Workstation, Usenix Annual Technical Conference, June 15-18, 1998.

[4] Kevin Lawton et al., the Bochs IA-32 Emulator Project, http://bochs.sourceforge.net.

[5] The Free Software Foundation, the GNU Compiler Collection, http://gcc.gnu.org.

[6] Julian Seward et al., Valgrind, an open-source memory debugger for x86-GNU/Linux, http://valgrind.kde.org/.

[7] The BYTEmark benchmark program, BYTE Magazine, Linux version available at
http://www.tux.org/~mayer/linux/bmark.html.