felix86:在 RISC-V Linux 上运行 x86-64 程序

felix86 25.05

四月份取得了很多进展!

GPU 试验

在 RISC-V 主板上使用合适的 GPU 目前……很棘手。

起初,我尝试使用英伟达™(NVIDIA®)GTX 1050 Ti,但很快我就意识到 Bianbu 缺乏nouveau 驱动程序的支持。接下来,我又尝试了 AMD Radeon HD 7790,但在初始化过程中出现了错误。最后,我选择了 AMD HD 7350,因为 Bianbu wiki 声称它受支持,幸好它确实受支持。

图0:felix86:在 RISC-V Linux 上运行 x86-64 程序

BPI-F3 没有 PCIe 插槽,必须使用 mPCIe 适配器。

能够使用 GPU 对我帮助很大。在此之前,所有游戏都是使用 llvmpipe 运行的,这是一种使用 CPU 绘制图形的软件 GPU 后备。这样做的成本极高,对于任何只需将帧缓冲复制到屏幕的游戏来说,成本更是成倍增加。有些游戏还能应付,如《VVVVVV》的帧速率还算 “过得去”,而其他游戏,如《World of Goo》的帧速率则达到了惊人的每秒 0.5 帧。其他游戏,如《SuperTuxKart》,在编译某些着色器时会冻结,这种情况在实际的 x86-64 硬件和 felix86 下都会发生。有了 GPU 的支持,《SuperTuxKart》可以让我们以每秒 10 帧的速度参加比赛,我们希望今后能提高速度!

图1:felix86:在 RISC-V Linux 上运行 x86-64 程序

我们终于可以比赛了!

在大多数游戏中,GPU 的工作都带来了极大的性能提升。不过,有些游戏还是顽固地继续缓慢运行。在下一节中,你将发现其中的原因!

元素周期表

自修改代码

四月份,我们添加了对自修改代码的初步支持。

自修改代码有多种形式:

  • 在一个地址加载一个库,卸载后在同一地址加载另一个库
  • 重新编译器为提高性能而修改自己的代码,如块链接
  • 反反编译器/反调试器的 DRM 或反作弊策略

自修改代码之所以会造成问题,是因为重新编译器会一次编译几大块代码(称为基本代码块),并将它们缓存起来,以防止今后重新编译。这种缓存是根据地址进行的。但是,如果存在自修改代码,底层的客户代码就会发生变化,但缓存的宿主代码仍然相同。在这种情况下,我们需要将这些代码块标记为无效并重新编译。这就是基本思路,还有其他技术可以处理其他类型的自修改代码,以获得更好的性能。

如果没有自修改代码的支持,蔚来也能运行,但速度非常非常慢。加载时间是最糟糕的部分,游戏仅加载图形就需要 100 秒左右,进入菜单可能需要 10 分钟或更长时间。无论有没有 GPU,菜单的渲染速度都不到 2 FPS。

有了自修改代码的支持,这些加载时间大大缩短,游戏可以以大约 20 FPS 的速度运行,只是在加载新区域时会有一些卡顿。这是前所未有的巨大性能提升!

原因在于Celeste 是一款用 C# 编写的游戏。这款游戏在 C# 运行时环境下运行,该环境带有一个 JIT,可将 CIL 转换为主机代码。这个名为 Mono 的运行环境拥有大量自修改代码,原本缓慢的重新编译代码会逐渐被优化,从而加快运行速度。现在,如果没有自修改代码支持,我们的仿真器就永远无法实现这些优化,因为它会一直运行缓存代码,而不会意识到客户机代码已发生变化,从而导致性能大打折扣。

目前还不支持所有类型的自修改代码,但支持库加载/卸载(通过使用 munmap 和 mmap),同样也支持在当前执行块之外的其他块上自修改代码。自修改代码可能存在一种令人讨厌的情况,即区块本身的指令被修改,但这是由反作弊或 DRM 软件恶意完成的,我们还没到那一步。

图2:felix86:在 RISC-V Linux 上运行 x86-64 程序

在修复了另外几个 Bug 后,Celeste 现在可以在游戏中运行了,而且运行速度约为 20 FPS!

实施细节

检测自修改代码的方法是禁止写入包含我们重新编译过的访客代码的页面。然后,在写入这些页面时会触发一个信号处理器,我们可以使用映射找到这些页面中包含的所有线程的所有代码块,从而使这些页面中的代码块失效。

我们有一个 “使调用者无效 ”的 thunk 函数。我们将每个无效代码块的前两条指令替换为跳转到该 thunk 函数的指令。这个跳转是特殊的:

void Recompiler::invalidateBlock(BlockMetadata* block) {
    u64* address = (u64*)block->address;
    const u64 offset = (u64)invalidate_caller_thunk - (u64)address;
    const auto hi20 = static_cast<int32_t>(((static_cast<uint32_t>(offset) + 0x800) >> 12) & 0xFFFFF);
    const auto lo12 = static_cast<int32_t>(offset << 20) >> 20;
    u64 storage;
    Assembler tas((u8*)&storage, 8);
    ASSERT(isScratch(t4));
    ASSERT(isScratch(t6));
    tas.AUIPC(t4, hi20);
    tas.JALR(t6, lo12, t4);
    __atomic_store(address, &storage, __ATOMIC_SEQ_CST);
}

正如你所看到的,跳转也链接到了 t6。这是一个可用的抓取寄存器,我们在 thunk 函数中专门使用它来获取 BlockMetadata 结构。

还需要注意的是,块链接看起来像这样:

as.AUIPC(t4, hi20);
as.JALR(t5, lo12, t4);

因此,寄存器 t5 t6 用于向 invalidate_caller_thunk 传递信息。寄存器 t5 保存的是需要解除链接(随后重新链接到新块)的调用者,而寄存器 t6 保存的是已失效块的地址。实际上,它保存的是失效块的地址 + 8,而 t5 保存的是链接位置的地址 + 8。但我们可以做一个简单的减法。

之后,invalidate_caller_thunk 会保存上下文并跳转到一个 C++ 函数,该函数会找到该代码块,将其标记为无效,并解除调用者的链接,在编译时将该区域标记为链接区域。然后跳转回调度程序。调度程序将编译已失效的代码块,重新链接自然会再次发生。

上下文保存重做

x86-64 寄存器静态分配给 RISC-V 寄存器。进入重新编译的代码时,我们会从内存中加载这些寄存器;退出时,我们会将它们存储在内存中。

以前,这种情况发生在基本程序块级别。当进入一个基本程序块时,该基本程序块中使用的寄存器会在需要时从内存中加载,而在程序块结束时,我们会将所有内容写入内存。然而,这种方法会带来一些问题:

  • 当多个程序块连接在一起时,就会出现多次加载/存储,而这些情况本可以避免。
  • 当发生信号时,我们不知道哪些寄存器被加载了正确的值(代表 x86-64 寄存器),哪些寄存器被加载了垃圾值

第一个问题更像是一种假设。我们可以假设这样做的成本会很高,但在调度器中加载/存储这些寄存器的替代方案,在调度器经常被调用的情况下也会成为性能瓶颈。

第二个问题更严重。过去,为了处理这个问题,我们习惯于从代码块的起始位置直到信号发出时的 PC 位置走一遍指令,并对指令进行解码。任何修改静态分配寄存器的指令都意味着寄存器的更新值尚未反映在内存中,因此我们需要将其从 ucontext_t 结构中提取出来。

但是,如果我们在派发器中加载所有寄存器,并确保每次执行重新编译的代码时,静态分配的寄存器值都是正确的,那么我们就可以随时从 ucontext_t 中提取寄存器值,这些值就不会成为垃圾值!

这就是本拉取请求所要实现的目标

我们以前的方法存在的另一个问题是,在代码块中执行指令是没有问题的,但如果我们想在代码块中加入一些控制流呢?那么一切都可能中断。

8 位和 16 位原子实现

RISC-V 默认只支持 32 位和 64 位的原子操作,如 amoaddamoxoramoswap(等),以匹配 x86-64 的 lock addlock xorlock xchg(等)。名为 Zabha 的扩展增加了对 8 位和 16 位操作的支持,但目前还没有 RISC-V 硬件实现这一功能。要模拟 8 位和 16 位原子,我们需要使用 lr.wsc.w,并进行一些移位和屏蔽操作,以便在 32 位字内对 8 位或 16 位值执行操作。

之所以添加 8 位和 16 位lock xchg支持,是因为一些游戏正在使用它,而如果不能以原子方式模拟该指令,则可能导致随机死锁或数据竞赛。

Wine

仿真 Wine 兼容层在过去曾给我们带来一些问题,但经过多次错误修复后,它已成功运行了第一个程序,即一个简单的 hello_world.exe 文件。

例如,其中一个问题是我们使用主机 epoll_event 结构来模拟 epoll 的系统调用,如 epoll_ctl epoll_pwait。但这是不正确的,因为出于向后兼容的原因,epoll_event 结构在 x86-64 中打包,而在 RISC-V 中没有打包。这将导致 wineserver 通信出现问题,应用程序会卡住。

此外,felix86 现在可以通过 wine 运行一些简单的 Windows 游戏,这是自上个月以来的一大进步:

图3:felix86:在 RISC-V Linux 上运行 x86-64 程序

Windows 7 纸牌游戏现在可在 felix86 下运行

更好的用户体验

新增的一些功能将提升用户体验。

配置文件

模拟器现在有两种配置方式:

  • 使用首次运行后创建的 /home/$USER/.config/felix86/config.toml 文件
  • 使用环境变量

使用环境变量可快速进行短期配置,而编辑配置文件可支持长期配置,且不会污染全局环境变量。你可以通过运行 felix86 --configs 或阅读源代码中的 config.inc 文件来打印所有配置。

binfmt_misc 支持

binfmt_misc 是 Linux 内核中的一个实用工具,它允许执行无法识别的文件格式。在我们的例子中,我们想检测 x86 和 x86-64 应用程序,并尝试在 felix86 下运行它们。为此注册一个模拟器非常容易。这意味着,只要设置了 rootfs 路径并安装了仿真器,就可以执行 x86-64 和 x86 可执行文件,而无需预输入仿真器路径,内核也会将它们传递给我们的仿真器。

要在 binfmt_misc 中注册仿真器,请使用 sudo felix86 -b。也可以使用相同命令取消注册仿真器。

杀死所有实例

有些应用程序(如 wine)会产生大量守护进程,即使在原始应用程序被杀死后也会继续存在。felix86 提供了一个新参数,即 -k 或 --kill-all,用于杀死 felix86 的所有活动实例。

向 32 位程序支持迈进

在最新的更改中,一些 32 位应用程序显示出了生命迹象!虽然目前还没有可运行的 32 位游戏,但我们正一步步接近它。

新指令

随着我们逐步支持 32 位程序,一些以前无关紧要的指令又变得重要起来。

例如,某些 32 位程序需要 cmpxchg8b 指令,该指令在前 x86_64 时代用于执行 64 位原子比较交换。值得庆幸的是,它可以通过 lr.d/sc.d 循环来实现,而不像 cmpxchg16b 那样,除了一些难看的变通方法外,目前还无法以原子方式进行仿真。Zacas 扩展将使适当的 cmpxchg16b 仿真成为可能,但它不在 RVA23 配置文件中,因此可能需要一段时间。无论如何,如果 Zacas 扩展可用,felix86 将加以利用。

新的 32 位系统调用

除了指令,32 位程序还需要支持新的系统调用。与 64 位程序不同,这里的系统调用实现起来更为棘手。由于指针是 32 位的,因此与许多主机系统调用中使用的结构不匹配,需要进行 marshalling。对于某些系统调用(如 sendmsg)来说,这一点更为棘手,因为需要对多个结构体进行编译并调整长度。还有许多系统调用在 64 位模式下不会出现,需要用现代系统调用来模拟。

MMX 支持

在现代程序中,MMX 指令基本上没有被使用,因为 SSE 指令可以在更大的寄存器上做同样的事情。不过,现代程序有时仍会使用它们。

例如,SDL2 的某些版本的路径支持 MMX 和整数,但不支持 SSE,因此在运行时,MMX 路径是首选。由于我在实现 MMX 上拖拖拉拉,所以在仿真 CPUID 中禁用了该功能,只在最初的 ld.so 检查中保持启用状态,该检查断言 ISA 级别足以运行应用程序。

现在已经支持 MMX,剩下的 15 个矢量寄存器中有 8 个被静态分配给 MM0-MM7 寄存器。如果你以为 MMX 寄存器是 XMM 寄存器的 64 位部分(就像我在启动仿真器之前所想的),那就错了,它们实际上是 ST0-ST7 x87 寄存器的一部分。

由于 MMX 和 x87 FPU 指令在相同的寄存器上运行,因此 emms 指令在运行任何 x87 指令之前使用,在运行任何 MMX 指令之后使用。这非常好,因为我们有了一个简单的切换点来刷新分配的 MMX 寄存器。

大多数 MMX 指令都是使用现有的 SSE 处理程序实现的,而像 punpckh 这样的指令则由于寄存器较小而有所不同,因此在处理时需要更加小心。

五月份的文章就写到这里!一两个月后再见!

我们还有很多工作要做–不要犹豫,加入我们吧!

欢迎投稿!

如果你对这个项目感兴趣,请加入我们的软件仓库。

写于 2025 年 5 月 1 日

本文文字及图片出自 Run x86-64 programs on RISC-V Linux

阅读余下内容
 

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注


京ICP备12002735号