JIT 编译器原理简述/实现 Brainfuck JIT 编译器

在生活中, 如果有两种合理但不同的方法时, 你应该总是研究两者的结合, 看看能否找到两全其美的方法. 我们称这种组合为杂合(hybrid). 例如, 为什么只吃巧克力或简单的坚果, 而不是将两者结合起来, 成为一块可爱的坚果巧克力呢?

在 1960 年约翰·麦卡锡偶然发现了此方法. 在他的重要论文《符号表达式的递归函数及其在机器上的计算》(Recursive functions of symbolic expressions and their computation by machine, Part I)第一部分中, 他提到了在运行时被转换的函数, 因此不需要编译并执行系统. JIT 编译是两种传统的机器代码翻译方法: 提前编译(AOT)和解释(Interpreter)的结合, 它结合了两者的优点和缺点.

让我们回忆一下第一篇文章的内容, 是关于编写 JIT 所需要的 4 个步骤:

  1. 申请一段可写和可执行的内存
  2. 将源码翻译为机器码(通常经过汇编)
  3. 将机器码写入第一步申请的内存
  4. 执行这部分内存

为了在 Rust 中编写汇编代码, 我们将使用一个名为 dynasm-rs 的扩展库. dynasm-rs 是对著名 C/C++ 库 dynasm 的模仿, 后者则是 LuaJIT 项目的重要组成部分. dynasm-rs 是一个汇编语言编译器, 它可以将汇编代码编译为机器码.

首先申请一段内存作为 Brainfuck 解释器的纸带, 并取得该段纸带在内存中的起始地址和终止地址:

let mut tape: Box<[u8]> = vec![0; 65536].into_boxed_slice();
let tape_addr_from = tape.as_mut_ptr();
let tape_addr_to = unsafe { tape_addr_from.add(tape.len()) };

我们将整个 Brainfuck 程序看作一个函数, 它接收两个参数: 纸带起始地址和终止地址. 根据 nasm 规范, 函数的第一个参数被存在 rdi 寄存器中, 第二个参数被存在 rsi 寄存器中. 我们将它们复制到 r12 和 r13 这两个寄存器内持久化存储. 同时 rcx 寄存器被用作为纸带的指针 SP, 赋予其初始值为纸带起始地址.

let mut ops = dynasmrt::x64::Assembler::new()?;
let entry_point = ops.offset();

dynasm!(ops
    ; .arch x64
    ; mov   r12, rdi // arg tape_addr_from
    ; mov   r13, rsi // arg tape_addr_to
    ; mov   rcx, rdi // stack pointer
);

编写 sysv64 格式的 getchar/putchar 函数, 使之后的汇编代码中可以调用这两个函数.

unsafe extern "sysv64" fn putchar(char: *mut u8) {
    std::io::stdout()
        .write_all(std::slice::from_raw_parts(char, 1))
        .unwrap();
}

unsafe extern "sysv64" fn getchar(char: *mut u8) {
    std::io::stdout().flush().unwrap();
    std::io::stdin()
        .read_exact(std::slice::from_raw_parts_mut(char, 1))
        .unwrap();
}

之后, 将每个 IR 翻译为语义等价的汇编代码如下. 首先 SHL(x), SHR(x), ADD(x)SUB(x) 4 个操作符最为简单, 它们均只使用一行汇编代码即可完成翻译. 之后是 PUTCHARGETCHAR, 它们遵循汇编中函数调用的逻辑, 函数的参数与地址按照规则写入指定寄存器, 然后使用 call 指令调用该函数. 最后是 JIZ(x)JNZ(x), 它们负责流程控制, 其对应的汇编代码通过组合使用标签, 比较运算和 jump 指令完成.

for ir in code.instrs {
    match ir {
        ir::IR::SHL(x) => dynasm!(ops
            ; sub rcx, x as i32 // sp -= x
        ),
        ir::IR::SHR(x) => dynasm!(ops
            ; add rcx, x as i32 // sp += x
        ),
        ir::IR::ADD(x) => dynasm!(ops
            ; add BYTE [rcx], x as i8 // *sp += x
        ),
        ir::IR::SUB(x) => dynasm!(ops
            ; sub BYTE [rcx], x as i8 // *sp -= x
        ),
        ir::IR::PUTCHAR => dynasm!(ops
            ; mov  r15, rcx
            ; mov  rdi, rcx
            ; mov  rax, QWORD putchar as _
            ; sub  rsp, BYTE 0x28
            ; call rax
            ; add  rsp, BYTE 0x28
            ; mov  rcx, r15
        ),
        ir::IR::GETCHAR => dynasm!(ops
            ; mov  r15, rcx
            ; mov  rdi, rcx
            ; mov  rax, QWORD getchar as _
            ; sub  rsp, BYTE 0x28
            ; call rax
            ; add  rsp, BYTE 0x28
            ; mov  rcx, r15
        ),
        ir::IR::JIZ(_) => {
            let l = ops.new_dynamic_label();
            let r = ops.new_dynamic_label();
            loops.push((l, r));
            dynasm!(ops
                ; cmp BYTE [rcx], 0
                ; jz => r
                ; => l
            )
        }
        ir::IR::JNZ(_) => {
            let (l, r) = loops.pop().unwrap();
            dynasm!(ops
                ; cmp BYTE [rcx], 0
                ; jnz => l
                ; => r
            )
        }
    }
}

同时不要忘记为该函数写上 return:

dynasm!(ops
    ; ret
);

最后, 通过强制类型转换将这段内存标记为一个合法的 rust 函数的函数体, 这可以通过 std::mem::transmute 函数实现.

let exec_buffer = ops.finalize().unwrap(); // compile the asm
let fun: extern "sysv64" fn(tape_addr_from: *mut u8, tape_addr_to: *mut u8) =
    unsafe { std::mem::transmute(exec_buffer.ptr(entry_point)) };
fun(tape_addr_from, tape_addr_to);

至此, 我们成功将一个 Brainfuck 程序动态编译为一个函数. 上面的汇编代码中没有进行包括 I/O, 溢出等方面的错误处理, 这是一项复杂的工程, 并且特意不被加入到代码中以便读者只关心其核心逻辑. 您可以尝试自己去实现.

本系列所有代码均放置在 http://github.com/mohanson/brainfuck 仓库中.