QEMU转换器的演变

2021-01-29 13:36:47

Linaro的QEMU团队隶属于工具链工作组(TCWG)。团队的其余成员将时间花在与编译器和其他代码生成器(如GCC和LLVM)上。在处理仿真时,QEMU有其自己的模块,称为微型代码生成器(TCG)。它与编译器具有许多相似之处,尽管它与典型的编译器相比具有不同的约束条件。由于代码生成器是基于即时(JIT)的,因此它无法像典型的编译器在优化其输出时那样花费大量时间(或内存!)。对于仅在刷新到缓存中之前仅执行一次或两次的代码而言尤其如此。

TCG实际上是QEMU使用的第二个代码生成器。最初,QEMU充当“模板”翻译器,其中每个单独的指令都有与之关联的C代码片段。翻译是将这些模板拼接成更大的代码块的情况。这意味着将QEMU移植到新系统相对容易,因为如果GCC支持,您可以生成在其下运行的代码。但是,最终,这种方法的局限性使得必须转向新的代码生成器,TCG诞生了。

TCG的根源是C编译器的通用后端。主要区别在于不是将抽象语法树从高级语言转换为微操作,而是将输入分解为单个指令的操作。

static void disas_add_imm(DisasContext * s,uint32_t insn){/ *解码指令* / int rd = extract32(insn,0,5); int rn = extract32(insn,5,5); uint64_t imm = extract32(insn,10,12); / *分配临时人员* / TCGv_i64 tcg_rn = cpu_reg_sp(s,rn); TCGv_i64 tcg_rd = cpu_reg_sp(s,rd); TCGv_i64 tcg_result = tcg_temp_new_i64(); / *执行操作* / tcg_gen_addi_i64(tcg_result,tcg_rn,imm); tcg_gen_mov_i64(tcg_rd,tcg_result); / *清理* / tcg_temp_free_i64(tcg_result); }

解码步骤涉及解剖指令的各个字段,以计算出需要哪些寄存器和立即值。该操作是从作为代码生成器基本单元的TCG ops合成的。经过简单的优化之后,这些操作将转换为主机指令并执行。

如果在QEMU中打开调试选项,则可以自己查看该过程,尽管要警告它会产生很多输出:

自2008年以来,TCG一直是QEMU的一部分,但随着时间的推移,它发生了一些变化。自2015年以来,我一直在从事这项工作,并且认为过去五年来发生的一些变化将是一个有趣的练习。

最初,每个来宾体系结构都只提供了“ gen_intermediate_code”功能,该功能处理将来宾代码块转换为TCG操作的过程。尽管他们看上去都非常相似,但他们也倾向于积累自己的轻微特质。转换为通用翻译器循环的工作不涉及任何特定的前沿技术,而主要涉及重构一组“ TranslatorOps”背后的特定于体系结构的部分,任何从事过Linux之类工作的人都会熟悉设备驱动。我提到这项工作的主要原因是因为它为实现翻译器的结构独立增强功能开辟了道路。其中包括改进的跟踪和TCG插件检测等功能。

最近的另一项创新是解码树。这是从QEMU的另一种测试工具(称为“随机指令序列”(生成器)用户空间RISU)进行的实验开始的,该工具用于测试指令解码器。

理想情况下,一个指令集适合一个很好的规则和树状解码模式。但是,现实常常会遇到麻烦,尤其是当ISA设计人员试图将其他功能压缩到越来越拥挤的操作码空间中时。最终,您将获得类似这样的功能,这些功能以非常特殊的顺序执行一系列蒙版模式测试,以准确弄清正在解码的指令。不用说,此过程容易出错,并且由于对操作码进行解码时的错误而发生了许多错误。

解码树通过允许对操作码字段进行简单的文本描述来解决此问题,然后让脚本自动生成可以对其进行的最有效的操作码解码。另外,它还可以自动从指令中提取字段并将其传递给简化的实现,而该实现只需专注于操作的语义即可。

static void trans_add_imm(DisasContext * s,arg_rri * a){TCGv_i64 tcg_rn = cpu_reg_sp(s,a-> rn); TCGv_i64 tcg_rd = cpu_reg_sp(s,a-> rd); TCGv_i64 tcg_result = tcg_temp_new_i64(); / *执行操作* / tcg_gen_addi_i64(tcg_result,tcg_rn,a-> imm); tcg_gen_mov_i64(tcg_rd,tcg_result); / *清理* / tcg_temp_free_i64(tcg_result); }

解码树最初是为了支持QEMU中SVE的引入而编写的,但是从那时起,新的来宾就使用了它,并且几种现有的来宾体系结构已被转换为使用由解码树支持的指令解码。

系统仿真的原始实现是单线程的,尽管用户模式仿真遵循其翻译的程序的线程模型,但这在行为上显然很不稳定。将QEMU转换为完全多线程的应用程序的过程始于引入KVM支持,但很长一段时间以来,一直认为TCG具有太多的全局状态,无法使多线程可行。

最后,这是一项多年努力,涉及社区许多不同部门的捐款。您可以在我进行合并时写的LWN文章中了解一些详细信息。幕后发生的变化如称为QEMU哈希表(QHT)的无锁哈希表(已针对读取案例进行了优化)以及前端更改(如正确地对原子和内存屏障操作进行建模)。

现在,MTTCG是大多数主线架构的默认设置,任何新架构从一开始就倾向于支持MTTCG。

当我们开始为QEMU实施ARM的可扩展矢量扩展时,我们意识到我们正在对TCG的标量定向API费劲。到目前为止,大多数单指令多数据(SIMD)指令都是通过手动展开一系列标量操作来实现的。尽管这样做有效,但效率还是有些低下的,特别是如果实际的实现最终还是以助手调用的形式结束时(就像大多数浮点操作一样)。引入SIMD TCG ops的先前提议已被拒绝,因为矢量大小范围很大,这会导致TCG ops激增-每个矢量大小一个。

最终,SVE的向量大小不可知方法将是一种新API的灵感,该API可以在任意大小的向量上对向量op进行编码。该界面足够丰富,后端仍然可以选择使用主机自己的向量指令来生成代码,同时还为我们无法提供的情况提供基于帮助程序的后备。目标特定助手仍然存在,但现在他们可以使用TCGv_vec接口以一致的方式将指针传递到寄存器文件。虽然最初是为了支持SVE工作而编写的,但其他目标已经开始将界面用于其矢量实现。

转换器通过一次翻译指令块来工作。在该块的末尾,它可以跳转到两个块之一。当这些是静态地址时,一旦下一个块被翻译,该跳转将被修补。如果翻译器不知道下一步要执行什么,它将从已翻译的代码返回到外部循环,该外部循环将翻译一个新块或处理某种异步操作。但是,在某些情况下,我们不需要进行如此昂贵的退出,即计算出的跳转。译员在翻译时无法知道跳转的位置,但可以肯定地进行内联查找并避免昂贵的退出。

仍有很多改进的余地,因此,一些需要改进的方面包括:

尽管JIT足够快,即使在交互使用中您也不会注意到它,但在许多用例中它仍然效率很低。 linux-user模式的常见用例是使用来宾编译器作为伪交叉编译器-在模拟目标硬件上有效地运行本机编译器。对于典型的编译,最终每次调用都会重新生成很多代码,这有点浪费。我们可以在执行完成后保存翻译缓存,以备将来之用。

在运行系统仿真时,我们禁用页面之间生成的块的链接。这是因为系统在任何时候都可能换出页面以换取不同的内容,这时我们将需要找到跳入页面的所有块并使它们无效。但是,对于许多代码而言,页面粒度过大。例如,内核通常驻留在一系列固定的物理页面中,并且从不进行交换。

目前,JIT并未考虑多个块的任何热序列。例如,大多数JavaScript引擎将检测特定的块序列何时处于紧密循环中,然后将热路径组合为单个高度优化的序列。通过考虑更大的块,您将有更多机会进行传统优化,例如消除死代码和寄存器传播。

当前的优化过程相对简单,因为大多数块都非常小,您始终需要确保在块结束之前,将主机寄存器中计算出的值正确地存储回代表来宾寄存器的内存中。但是,目前我们仍然需要重新加载更多的值。两个示例是用于多个操作和存储负载传播的常量,其中,值存储在寄存器中,然后立即用于后续操作,并且仍然存在于主机寄存器中。

单一静态分配(SSA)表单是编译器用来表示特定操作集的数据流的相当标准的方式。它受到编译器的青睐,因为它使分析更容易,并且优化成为转换操作树的问题。 QEMU当前使用一种更简单的虚拟寄存器方法,该方法有利于更快的代码生成。在快速代码生成和最佳代码生成之间需要权衡取舍,而对于编译器,我们通常不必担心(例如,比较-O0和-O3编译)。这可能是距离太远的一步,也可能是更快代码的门户。我们将不得不试验;-)

可以公平地假设团队中完成的许多工作都是关于改进QEMU的ARM特定仿真的-例如,参见即将发布的5.1版本中的最新变更日志和ARMv8.5-MemTag。但是,我们也受益于QEMU是一个健康的项目,它支持各种各样的主机和客户机体系结构。我们的目标仍然是使QEMU成为免费软件开发人员的仿真平台,以试用最新的ARM ISA功能以及适用于任何体系结构的最佳免费软件仿真平台。我希望本文能使您了解过去几年对核心翻译人员所做的各种更改。随着我们每天继续致力于改善QEMU,肯定还会有更多的事情发生。