QEMU.js:现在很认真并且使用 WASM

曾几何时,我决定为了好玩 证明过程的可逆性 并学习如何从机器代码生成 JavaScript(更准确地说,Asm.js)。 选择 QEMU 进行实验,一段时间后写了一篇关于 Habr 的文章。 在评论中有人建议我在 WebAssembly 中重新制作该项目,甚至我自己退出了 就快结束了 不知怎的,我不想要这个项目......工作正在进行,但非常缓慢,现在,最近在那篇文章中出现 一条评论 主题“那么这一切是如何结束的?” 针对我的详细回答,我听到“这听起来像一篇文章”。 嗯,如果可以的话,会有一篇文章。 也许有人会发现它很有用。 从中,读者将了解有关 QEMU 代码生成后端设计的一些事实,以及如何为 Web 应用程序编写即时编译器。

任务

由于我已经学会了如何“以某种方式”将 QEMU 移植到 JavaScript,所以这次决定明智地去做,而不是重复老错误。

错误一:从点发布分支

我的第一个错误是从上游版本 2.4.1 分叉出我的版本。 然后在我看来这是一个好主意:如果点发布存在,那么它可能比简单的 2.4 更稳定,分支更是如此 master。 由于我计划添加大量自己的错误,因此我根本不需要其他人的错误。 结果大概就是这样。 但事情是这样的:QEMU 并没有停滞不前,在某些时候他们甚至宣布将生成的代码优化 10%。“是的,现在我要冻结了,”我想,然后崩溃了。 这里我们需要说一句题外话:由于QEMU.js的单线程特性,并且原始QEMU并不意味着没有多线程(即能够同时操作几个不相关的代码路径,并且不仅仅是“使用所有内核”)对此至关重要,我必须“将其转出”线程的主要功能才能从外部调用。 这在合并过程中自然产生了一些问题。 然而,事实上,分支机构的一些变化 master我试图与它合并我的代码,也在点发布中(因此在我的分支中)被精心挑选,也可能不会增加便利性。

总的来说,我认为扔掉原型,将其拆解为零件并基于更新鲜的东西从头开始构建新版本仍然是有意义的 master.

错误二:TLP 方法

从本质上讲,这不是一个错误,一般来说,这只是在完全误解“去哪里以及如何移动?”和一般“我们会到达那里吗?”的情况下创建项目的一个特征。 在这些条件下 笨拙的编程 是一个合理的选择,但是,自然地,我不想不必要地重复它。 这次我想明智地做到这一点:原子提交、有意识的代码更改(而不是“将随机字符串在一起直到编译(带有警告)”,正如 Linus Torvalds 曾经对某人所说的那样,根据维基语录)等等。

错误三:不知道浅滩就下水

我还没有完全摆脱这个,但现在我决定根本不走阻力最小的路,而是“作为一个成年人”去做,即从头开始编写我的 TCG 后端,以免后来不得不说,“是的,这当然是慢慢的,但我无法控制一切——TCI就是这样写的……” 此外,这最初似乎是一个显而易见的解决方案,因为 我生成二进制代码。 正如他们所说,“根特聚集у,但不是那个”:代码当然是二进制的,但控制权不能简单地转移给它 - 它必须显式地推入浏览器进行编译,从而产生来自 JS 世界的某个对象,该对象仍然需要被保存在某个地方。 然而,在普通的 RISC 架构上,据我了解,一种典型的情况是需要显式重置指令缓存以重新生成代码 - 如果这不是我们需要的,那么无论如何,它已经接近了。 另外,从我上次的尝试中,我了解到控制似乎没有转移到翻译块的中间,因此我们实际上不需要从任何偏移量解释字节码,我们可以简单地从 TB 上的函数生成它。

他们过来踢

尽管我早在 XNUMX 月份就开始重写代码,但一种神奇的力量却在不知不觉中悄悄出现:来自 GitHub 的信件通常作为有关问题和 Pull 请求响应的通知到达,但在这里, 突然 在线程中提及 Binaryen 作为 qemu 后端 在上下文中,“他做了类似的事情,也许他会说些什么。” 我们正在讨论使用 Emscripten 的相关库 二进制 创建 WASM JIT。 好吧,我说你那里有 Apache 2.0 许可证,而 QEMU 整体上是在 GPLv2 下分发的,它们不太兼容。 突然发现,执照可以 以某种方式修复它 (我不知道:也许改变它,也许双重许可,也许其他什么......)。 这当然让我很高兴,因为那时我已经仔细观察过 二进制格式 WebAssembly,我有点悲伤和难以理解。 还有一个库可以通过转换图吞噬基本块,生成字节码,甚至在必要时在解释器本身中运行它。

然后还有更多 在 QEMU 邮件列表上,但这更多的是关于“谁需要它?”的问题。 它是 突然,事实证明这是必要的。 至少,如果它的工作速度或多或少快的话,您可以收集以下使用可能性:

  • 无需任何安装即可启动一些教育性内容
  • iOS 上的虚拟化,据传言,唯一有权动态生成代码的应用程序是 JS 引擎(这是真的吗?)
  • 迷你操作系统演示——单软盘、内置、各种固件等...

浏览器运行时功能

正如我已经说过的,QEMU 与多线程相关,但浏览器没有它。 嗯,就是不……一开始根本不存在,然后WebWorkers出现了——据我了解,这是基于消息传递的多线程 没有共享变量。 当然,当基于共享内存模型移植现有代码时,这会产生严重的问题。 随后迫于舆论压力,也以名义实施 SharedArrayBuffers。 它被逐渐引入,他们庆祝了它在不同浏览器中的推出,然后他们庆祝了新年,然后是 Meltdown...之后他们得出的结论是粗略或粗略的时间测量,但借助共享内存和线程递增计数器,都是一样的 它会非常准确地计算出来。 因此我们禁用了共享内存的多线程。 似乎他们后来又重新打开了它,但是,正如从第一个实验中清楚地看到的那样,没有它仍然存在,如果是这样,我们将尝试在不依赖多线程的情况下做到这一点。

第二个特性是不可能对堆栈进行低级操作:您不能简单地获取、保存当前上下文并使用新堆栈切换到新上下文。 调用栈由JS虚拟机管理。 看来,问题是什么,因为我们仍然决定完全手动管理以前的流程? 事实上,QEMU 中的块 I/O 是通过协程实现的,这就是低级堆栈操作派上用场的地方。 幸运的是,Emscipten 已经包含了异步操作的机制,甚至是两个: 异步化 и 解释器。 第一个方法会导致生成的 JavaScript 代码显着膨胀,因此不再受支持。 第二种是当前的“正确方法”,通过本地解释器生成字节码来工作。 当然,它的工作速度很慢,但它不会使代码变得臃肿。 确实,对这种机制的协程的支持必须独立贡献(已经有为 Asyncify 编写的协程,并且有一个用于 Emterpreter 的大致相同 API 的实现,您只需要连接它们)。

目前,我还没有设法将代码拆分为在 WASM 中编译并使用 Emterpreter 解释的代码,因此块设备还无法工作(请参阅下一个系列,正如他们所说......)。 也就是说,最后你应该得到像这样有趣的分层的东西:

  • 解释块 I/O。 那么,您真的期望模拟 NVMe 具有本机性能吗? 🙂
  • 静态编译的主要 QEMU 代码(转换器、其他模拟设备等)
  • 动态编译访客代码到 WASM

QEMU 源的特点

正如您可能已经猜到的,用于模拟客户架构的代码和用于生成主机机器指令的代码在 QEMU 中是分开的。 事实上,这甚至有点棘手:

  • 有访客架构
  • 加速器,即用于Linux上硬件虚拟化的KVM(用于相互兼容的来宾和主机系统),用于在任何地方生成JIT代码的TCG。 从 QEMU 2.9 开始,出现了对 Windows 上 HAXM 硬件虚拟化标准的支持(详细信息)
  • 如果使用 TCG 而不是硬件虚拟化,那么它为每个主机架构以及通用解释器提供单独的代码生成支持
  • ...以及围绕所有这些 - 模拟外围设备、用户界面、迁移、记录重放等。

顺便说一句,你知道吗: QEMU 不仅可以模拟整个计算机,还可以模拟主机内核中单独用户进程的处理器,例如,AFL 模糊器将其用于二进制检测。 也许有人想将 QEMU 的这种操作模式移植到 JS 上? 😉

与大多数历史悠久的自由软件一样,QEMU 是通过调用构建的 configure и make。 假设您决定添加一些内容:TCG 后端、线程实现或其他内容。 对于与 Autoconf 进行通信的前景,不要急于感到高兴/害怕(酌情下划线)——事实上, configure QEMU 显然是自己编写的,并不是由任何东西生成的。

WebAssembly

那么这个叫做 WebAssembly(又名 WASM)的东西是什么? 这是 Asm.js 的替代品,不再伪装成有效的 JavaScript 代码。 相反,它是纯粹的二进制且经过优化的,甚至简单地向其中写入整数也不是很简单:为了紧凑性,它以以下格式存储 LEB128.

您可能听说过 Asm.js 的重新循环算法 - 这是“高级”流程控制指令(即 if-then-else、循环等)的恢复,JS 引擎是为此设计的,来自低级 LLVM IR,更接近处理器执行的机器代码。 自然,QEMU的中间表示更接近第二种。 看起来这就是字节码,折磨的结束......然后还有块,if-then-else 和循环!......

这是 Binaryen 有用的另一个原因:它自然可以接受与 WASM 中存储的内容接近的高级块。 但它也可以从基本块图和它们之间的转换生成代码。 嗯,我已经说过,它将 WebAssembly 存储格式隐藏在方便的 C/C++ API 后面。

TCG(微型代码生成器)

TCG的 原来是 显然,它无法承受与 GCC 的竞争,但最终它在 QEMU 中作为主机平台的代码生成机制找到了自己的位置。 还有一个 TCG 后端会生成一些抽象字节码,这些字节码会立即由解释器执行,但我决定这次避免使用它。 然而,事实上,在 QEMU 中,已经可以通过函数启用到生成的 TB 的转换 tcg_qemu_tb_exec,事实证明它对我非常有用。

要将新的 TCG 后端添加到 QEMU,您需要创建一个子目录 tcg/<имя архитектуры> (在这种情况下, tcg/binaryen),它包含两个文件: tcg-target.h и tcg-target.inc.c и 规定 这全都是关于 configure。 您可以将其他文件放在那里,但是,正如您可以从这两个文件的名称中猜测到的那样,它们都将包含在某处:一个作为常规头文件(它包含在 tcg/tcg.h,并且该文件已经存在于目录中的其他文件中 tcg, accel 不仅如此),另一个 - 仅作为代码片段 tcg/tcg.c,但它可以访问其静态函数。

考虑到我会花太多时间详细研究它的工作原理,我只是从另一个后端实现中复制了这两个文件的“骨架”,并在许可证标头中诚实地指出了这一点。

文件 tcg-target.h 主要包含表单中的设置 #define-s:

  • 目标架构上有多少个寄存器以及宽度是多少(我们想要多少就有多少,想要多少就有多少 - 问题更多的是浏览器在“完全目标”架构上将生成更高效的代码) ...)
  • 主机指令的对齐:在 x86 上,甚至在 TCI 中,指令根本没有对齐,但我将在代码缓冲区中放入根本不是指令,而是指向 Binaryen 库结构的指针,所以我会说:4字节
  • 后端可以生成哪些可选指令 - 我们包含在 Binaryen 中找到的所有内容,让加速器将其余指令分解为更简单的指令
  • 后端请求的TLB缓存的大小大约是多少? 事实上,在 QEMU 中,一切都很严肃:尽管有辅助函数执行加载/存储时考虑到客户 MMU(如果没有它,我们现在会在哪里?),它们以结构的形式保存翻译缓存,其处理方便直接嵌入到广播块中。 问题是,这个结构中的哪个偏移量可以通过小而快速的命令序列最有效地处理?
  • 在这里,您可以调整一两个保留寄存器的用途,启用通过函数调用 TB,并可选择描述一些小的 inline- 功能如 flush_icache_range (但这不是我们的情况)

文件 tcg-target.inc.c当然,通常尺寸要大得多,并且包含几个强制功能:

  • 初始化,包括限制哪些指令可以对哪些操作数进行操作。 我从另一个后端公然复制
  • 需要一条内部字节码指令的函数
  • 您还可以将辅助函数放在这里,也可以使用静态函数 tcg/tcg.c

对于我自己,我选择了以下策略:在下一个翻译块的第一个单词中,我写下了四个指针:一个起始标记(附近的某个值) 0xFFFFFFFF,它确定 TB 的当前状态)、上下文、生成的模块和用于调试的幻数。 最初,标记被放置在 0xFFFFFFFF - n哪里 n - 一个小的正数,每次通过解释器执行都会增加1。当达到 0xFFFFFFFE,编译发生,模块被保存在函数表中,导入到一个小的“启动器”中,执行从 tcg_qemu_tb_exec,并且该模块已从 QEMU 内存中删除。

套用经典的话就是“拐杖,这声音里交织着多少进步者的心……”。 然而,内存在某个地方泄漏了。 而且,它是由 QEMU 管理的内存! 我有一段代码,在编写下一条指令(好吧,即指针)时,删除了之前链接位于此位置的指令,但这没有帮助。 实际上,在最简单的情况下,QEMU 在启动时分配内存并将生成的代码写入其中。 当缓冲区用完时,代码将被丢弃,并开始在其位置写入下一个代码。

在研究了代码之后,我意识到使用幻数的技巧可以让我在第一次传递时释放未初始化缓冲区上的错误内容,从而避免堆破坏失败。 但是谁会重写缓冲区来绕过我的函数呢? 正如 Emscripten 开发人员建议的那样,当我遇到问题时,我将生成的代码移植回本机应用程序,并在其上设置 Mozilla Record-Replay...总的来说,最终我意识到了一个简单的事情:对于每个块, A struct TranslationBlock 及其描述。 猜猜在哪里...没错,就在缓冲区中的块之前。 意识到这一点,我决定不再使用拐杖(至少是一些),只是扔掉神奇的数字,并将剩余的单词转移到 struct TranslationBlock,创建一个单链表,当翻译缓存重置时可以快速遍历,并释放内存。

一些拐杖仍然存在:例如,代码缓冲区中的标记指针 - 其中一些只是简单的 BinaryenExpressionRef,即他们查看需要线性放入生成的基本块中的表达式,部分是BB之间转换的条件,部分是去哪里。 嗯,已经有为Relooper准备好的块了,需要根据情况进行连接。 为了区分它们,假设它们都至少对齐四个字节,因此您可以安全地使用最低有效的两位作为标签,只需记住在必要时将其删除。 顺便说一下,QEMU 中已经使用了此类标签来指示退出 TCG 循环的原因。

使用二进制

WebAssembly 中的模块包含函数,每个函数都包含一个主体,即一个表达式。 表达式是一元和二元运算、由其他表达式列表、控制流等组成的块。 正如我已经说过的,这里的控制流被精确地组织为高级分支、循环、函数调用等。 函数的参数不是在堆栈上传递的,而是显式传递的,就像在 JS 中一样。 还有全局变量,不过我没用过,就不告诉大家了。

函数也有局部变量,从零开始编号,类型为:int32 / int64 / float / double。 在这种情况下,前 n 个局部变量是传递给函数的参数。 请注意,虽然这里的所有内容在控制流方面并不完全是低级的,但整数仍然不带有“有符号/无符号”属性:数字的行为方式取决于操作代码。

一般来说,Binaryen 提供 简单的C-API:您创建一个模块, 在他那边 创建表达式 - 一元、二元、其他表达式的块、控制流等。 然后创建一个以表达式作为函数体的函数。 如果您像我一样有一个低级转换图,那么 relooper 组件将为您提供帮助。 据我了解,可以在块中使用执行流的高级控制,只要不超出块的边界即可 - 也就是说,可以使内部快速路径/慢速路径路径分支在内置TLB缓存处理代码内部,但不会干扰“外部”控制流。 当您释放 relooper 时,它的块也会被释放;当您释放模块时,分配给它的表达式、函数等会消失 竞技场.

但是,如果您想动态解释代码,而不需要不必要地创建和删除解释器实例,则将此逻辑放入 C++ 文件中可能是有意义的,并从那里直接管理库的整个 C++ API,绕过现成的 -做了包装纸。

所以要生成你需要的代码

// настроить глобальные параметры (можно поменять потом)
BinaryenSetAPITracing(0);

BinaryenSetOptimizeLevel(3);
BinaryenSetShrinkLevel(2);

// создать модуль
BinaryenModuleRef MODULE = BinaryenModuleCreate();

// описать типы функций (как создаваемых, так и вызываемых)
helper_type  BinaryenAddFunctionType(MODULE, "helper-func", BinaryenTypeInt32(), int32_helper_args, ARRAY_SIZE(int32_helper_args));
// (int23_helper_args приоб^Wсоздаются отдельно)

// сконструировать супер-мега выражение
// ... ну тут уж вы как-нибудь сами :)

// потом создать функцию
BinaryenAddFunction(MODULE, "tb_fun", tb_func_type, func_locals, FUNC_LOCALS_COUNT, expr);
BinaryenAddFunctionExport(MODULE, "tb_fun", "tb_fun");
...
BinaryenSetMemory(MODULE, (1 << 15) - 1, -1, NULL, NULL, NULL, NULL, NULL, 0, 0);
BinaryenAddMemoryImport(MODULE, NULL, "env", "memory", 0);
BinaryenAddTableImport(MODULE, NULL, "env", "tb_funcs");

// запросить валидацию и оптимизацию при желании
assert (BinaryenModuleValidate(MODULE));
BinaryenModuleOptimize(MODULE);

...如果我忘记了什么,抱歉,这只是代表比例,详细信息在文档中。

现在,crack-fex-pex 开始了,如下所示:

static char buf[1 << 20];
BinaryenModuleOptimize(MODULE);
BinaryenSetMemory(MODULE, 0, -1, NULL, NULL, NULL, NULL, NULL, 0, 0);
int sz = BinaryenModuleWrite(MODULE, buf, sizeof(buf));
BinaryenModuleDispose(MODULE);
EM_ASM({
  var module = new WebAssembly.Module(new Uint8Array(wasmMemory.buffer, $0, $1));
  var fptr = $2;
  var instance = new WebAssembly.Instance(module, {
      'env': {
          'memory': wasmMemory,
          // ...
      }
  );
  // и вот уже у вас есть instance!
}, buf, sz);

为了以某种方式连接 QEMU 和 JS 的世界,同时快速访问编译后的函数,创建了一个数组(用于导入启动器的函数表),并将生成的函数放置在那里。 为了快速计算索引,最初使用的是零字翻译块的索引,但后来使用这个公式计算出的索引开始简单地拟合到 struct TranslationBlock.

顺便说一下, 演示 (目前许可证不明确) 仅在 Firefox 中工作正常。 Chrome 开发者是 不知何故还没有准备好 事实上,有人想要创建超过一千个 WebAssembly 模块实例,因此他们只需为每个实例分配一千兆字节的虚拟地址空间......

目前为止就这样了。 如果有人感兴趣的话也许会有另一篇文章。 也就是说,至少还剩下 只是 使块设备工作。 按照 JS 世界的惯例,异步编译 WebAssembly 模块也可能有意义,因为在本机模块准备就绪之前仍然有一个解释器可以完成所有这些工作。

最后出个谜语: 您已经在 32 位体系结构上编译了一个二进制文件,但代码通过内存操作从 Binaryen 爬升,位于堆栈上的某个位置,或者位于 2 位地址空间上 32 GB 的其他位置。 问题在于,从 Binaryen 的角度来看,这是访问太大的结果地址。 如何解决这个问题?

以管理员的方式

我最终没有对此进行测试,但我的第一个想法是“如果我安装 32 位 Linux 会怎样?” 那么地址空间的上部将被内核占用。 唯一的问题是占用多少空间:1 或 2 Gb。

以程序员的方式(从业者的选项)

让我们在地址空间的顶部吹一个泡泡。 我自己也不明白为什么它会起作用 - 在那里 已经 必须有一个堆栈。 但“我们是实践者:一切都对我们有用,但没人知道为什么……”

// 2gbubble.c
// Usage: LD_PRELOAD=2gbubble.so <program>

#include <sys/mman.h>
#include <assert.h>

void __attribute__((constructor)) constr(void)
{
  assert(MAP_FAILED != mmap(1u >> 31, (1u >> 31) - (1u >> 20), PROT_NONE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0));
}

...确实它与 Valgrind 不兼容,但幸运的是,Valgrind 本身非常有效地将每个人都赶出了那里:)

也许有人会更好地解释我的这段代码是如何工作的......

来源: habr.com

添加评论