用 JavaScript 写一个 X86 模拟器

1,343 阅读3分钟
原文链接: blog.jobbole.com

来龙去脉

像一些没有计算机背景的人一样,我总是想要正确地了解底层是如何工作的,并在上面花费了大量的精力。

在学习过程中,我得到《从头开始学习编程》这本书,但一直都没有读,直到在一次 飞往巴西的 11 个小时航班上,我才去阅读它,看上去它是一个不错的开始。

我很喜欢这本书,但是其中的例子都是用 Linux x86 GNU 汇编写的,而我却在一台 64 位 Max OS X上……我挣扎了一小下想要搞清楚在 i386 和 x86_64上,汇编器和连接器、标记和语法都有什么不同,但没有网络还是没有搞定……

「原来在 i386 上系统调用 exit 函数的返回值是被压栈了,而不是放在 %ebx 寄存器里;x86_64 上,系统调用的编号以 0×2000000 作为偏移;寄存器不同和你必须使用 syscall ,而不是 int $0×80…… 如果你刚开始接触汇编,所有这些都相当不容易。」

我尝试把它放到一边,却忍不住一直在想它,所以我开始写了一个很假的 x86 解析器,在电池耗尽前,我有足够时间做这些事情(飞行中不能上网是在太惨了)。

数个月后在切尔西球场,Facebook 举办了一场黑客马拉松。我想实现一个 x86 模拟器一定很酷,而且可以搞懂一个二进制是如何运行的。

说服人们参与这个项目并不容易,我花了一些时间证明我并没有疯掉,我很清楚编写一个正确的模拟器有多难,我只想要编写一个非常简单的玩一玩。但最后 Uri Baghin 答应入伙。

我们的目标

我们最初的目标是运行最简单的 x86 程序:退出且代码是 0:

# program.s
.section __TEXT,__text
.globl start
start:
mov $0x1, %eax
push $0x0
call _syscall
_syscall:
int $0x80

在 Mac OS X上,上面代码上是这样编译的:

$ as -static -arch i386 -o program.o program.s
$ ld -static -arch i386 -o program program.o

为了验证它,你可以用如下命令执行它并且检查退出代码:

$ ./program
$ echo $?

所以我们决定把这个问题一分为二:找到二进制文件中实际的汇编指令和执行它们。

Mach-O 二进制格式

二进制文件中不仅包含汇编代码,还包含布局信息、支持的体系结构、它们应该如何被加载到内存和二进制中存在的符号信息。

查看图片

注:我推荐使用 MachOView ,可以把二进制的布局更好地可视化,在研究二进制文件的时候相当有用。

为了找到二进制文件里面的汇编代码,我们需要阅读一些加载的命令,特别是 LC_SEGMENT 命令,它们有如何把二进制的段映射到虚拟内存的信息。

LC_SEGMENT 加载命令有这些字段:

  • 命令(这个例子就是 LC_SEGMENT)
  • 命令的大小
  • 段的名称 (比如:__TEXT)
  • 虚拟内存地址 (段应该被复制到虚拟内存的位置)
  • 虚拟内存大小(段使用的虚拟内存大小)
  • 文件偏移 (段在二进制文件的位置)
  • 文件大小(段在二进制文件中的大小)
  • 最大虚拟内存保护(虚拟内存允许的最大保护级别)
  • 最初的虚拟内存保护(虚拟内存的初始保护级别)
  • 节的数目(段里节的数目)
  • 标记(可以在 mach-o/loader.h 中 segment_command 结构后面找到可能的标记)

我们现在可以忽略虚拟内存保护和标记,只要把从文件偏移到文件偏移加上文件大小这部分内容复制到虚拟内存的相应区域,即从虚拟内存地址到虚拟内存地址加上虚拟内存大小的这段区域。

接下来我们需要 LC_UNIXTHREAD 加载命令。它告诉我们这个程序中主线程的最初执行状态。

这里重要的一点是 %eip 的初始值(指令指针),它告诉第一个程序指令在虚拟内存中的位置,也就是真实的代码从哪里开始(注意:在同样的示例代码中,我们使用一个叫做 PC 的全局变量而不是模拟一个寄存器)。

二进制的执行

好了,现在我们已经把程序代码映射到虚拟内存中,有一个指针指向代码开始的位置,我们只需要执行它。有件事让这更加有趣,就是 x86 指令的长度是不同的,我们必须要先阅读操作码(opcode)搞清楚这条指令会占用多少字节。

查看图片

为了保持简单,我用 objdump 反编译了这个生成的二进制文件,  objdump 命令行界面(CLI,Command Line Interface)通过安装 binutils 包就可以得到。它看上去像这样:

$ brew install binutils # if you don't have it installed yet
$ gobjdump -d program

Disassembly of section .text:

00001ff2 :
    1ff2:       b8 01 00 00 00          mov    $0x1,%eax
    1ff7:       6a 00                   push   $0x0
    1ff9:       e8 00 00 00 00          call   1ffe <_syscall>

00001ffe <_syscall>:
    1ffe:       cd 80                   int    $0x80

因为我不准备实现完整功能的模拟器,我只是查看了一些必要操作码的语法,有一个不错的 x86 参考资料可以在 ref.x86asm.net/coder.html 上找到。

我创建了一个操作码到函数的映射表(用简单的 JavaScript 对象实现的)。如果操作码有需要,函数会负责读取更多的数据,例如:

var Functions = {};
Functions[0x6a] = function () {
  // Push one byte
  push(read(1));
};

如果操作码实际上是操作码的前缀,那我们必须要读取下一个字节,这才是真正的操作码:

var Fn0x0f = {
  // jge
  0x8d: function () {
    var dist = read(4);
    if (!NG) {
      PC += dist;
    }
  },
  // ...
};

Functions[0x0f] = function () {
  // Call the actual function inside the prefix
  var fn = read(1);
  Fn0x0f[fn]();
};

我们最后要处理的就是系统调用,我们必须要真实地模拟它,因为我们有另外一个映射表把系统调用的编号映射到函数上:

var Syscalls = {};
Syscalls[0x01] = function () {
  // Fake exit, since there's no OS
  console.log('Program returned %s', Stack[Registers[ESP + 1]]);
  PC = -1; // Mark the program as ended by setting the program counter to -1
};

为了加载二进制,我们需要使用 File API。有一个 html 页面作为入口点,把二进制文件作为一个单独的输入,输出会显示在控制台上。

示例代码

这次黑客马拉松的最终代码可以在这个 gist 上找到,包含 3 个文件:载入程序(mach-o.js)、伪代码逻辑(x86.js)和 作为入口点的 index.html。代码可以执行基本的汇编和 C 程序。

注意:不能运行 libc,所以要使用 -static -nostdlib 来编译 C 程序并提供一个客制化的装载引导。

请记住这些代码相当简单,它只是在一场黑客活动中编写的,所以在可读性上没有花费很多时间(关注重要的事情)而且我们后边也不会在上面迭代了。

运行用 clang -O3 参数编译 C 编写的 fibonacci(40) 程序,执行时间只花费了不到 9 分 47 秒。

编辑:阅读了 HackerNews 上的这个评论后,我用 Unit8Array 替换了 DataView,现在 fibonacci(40)耗时已经降到 1 分53 秒,比 Perl 和 ruby 1.8 都要快,比较结果看这里 :)