4. Hello, JIT

通常来说,Just In Time (JIT) 编译器 是指在某段高阶代码即将运行时将其编译到机器码再执行的程序。下文中,我们把这样的程序叫做 "JIT"。

一个最简单的 JIT 工作方式是这样的:

  1. 将源代码编译为机器码。
  2. 申请一段可写可执行内存,写入机器码。
  3. 跳转到这段内存,执行机器码。
  4. 执行完毕,稍作清理,退出。

以下代码引用自 Hello, JIT World: The Joy of Simple JITs

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>

int main(int argc, char *argv[]) {
  // 机器码:
  //   mov eax, 0
  //   ret
  unsigned char code[] = {0xb8, 0x00, 0x00, 0x00, 0x00, 0xc3};

  if (argc < 2) {
    fprintf(stderr, "Usage: jit1 <integer>\n");
    return 1;
  }

  // 把用户给出的数值写入机器码,覆盖立即数 "0"
  //   mov eax, <user's value>
  //   ret
  int num = atoi(argv[1]);
  memcpy(&code[1], &num, 4);

  // 分配可写可执行内存
  // 注意:真实的程序不应该映射同时可写可执行的内存,
  // 这里有安全风险。
  void *mem = mmap(NULL, sizeof(code), PROT_WRITE | PROT_EXEC,
                   MAP_ANON | MAP_PRIVATE, -1, 0);
  memcpy(mem, code, sizeof(code));

  // 定义一个函数指针指向机器码内存,再执行函数
  int (*func)() = mem;
  return func();
}

编写 JIT 的一大难点是如何生成机器码,这里通常有跨平台问题、可读性消失问题。

最笨的方法:写一段汇编,用汇编器生成机器码,再复制到高级代码里。但这样不具有通用性,开发效率非常低。

dynasm

DynAsm 是 LuaJIT 的一部分,它用预处理器把混合汇编的 C 文件转换成 纯 C 文件,还包含一个微型运行时,用来执行运行时工作。

dynasm-rs 是对应的 Rust 实现,用过程宏在编译期解析汇编语法,也包含微型运行时。

Rust 过程宏作为编译器插件几乎是万能的,不光是汇编,html、shell、cpp 等语言都能嵌入,转换成对应的 Rust 结构,给人一种相当纯粹的感觉。过程宏还有很多用途,感兴趣的可以自行研究。

cargo add dynasm dynasmrt

修改 main.rs,导入 dynasm.

mod bfir;

use dynasm::dynasm;
use dynasmrt::{DynasmApi, DynasmLabelApi};

use std::io::{stdout, Write};

编写 print 函数,使用 "sysv64" ABI。

x86-64 Linux 系统上默认为 System V ABI. 相关文档

unsafe extern "sysv64" fn print(buf: *const u8, len: u64) -> u8 {
    let buf = std::slice::from_raw_parts(buf, len as usize);
    stdout().write_all(buf).is_err() as u8
}

首先初始化汇编器,指定架构为 x64,全局标签 hello 指向字符串。

fn main() {
    let mut ops = dynasmrt::x64::Assembler::new().unwrap();
    let string = b"Hello, JIT!\n";

    dynasm!(ops
        ; .arch x64
        ; ->hello:
        ; .bytes string
    );

dynasm 使用 nasm 的方言,左操作数为目标,右操作数为源。

sysv64 调用约定规定 rdi, rsi, rdx, rcx 存放前四个整数参数,rax 存放返回值。

    let hello = ops.offset();
    dynasm!(ops
        ; lea rdi, [->hello]                // 将 hello 字符串地址放入 rdi
        ; mov rsi, QWORD string.len() as _  // 将 字符串长度放入 rsi
        ; mov rax, QWORD print as _         // 将 print 函数地址放入 rax
        ; call rax                          // 调用函数
        ; ret                               // 返回
    );

完成汇编,取得可执行缓冲区。根据偏移拿到函数地址,强制转换为函数指针。最后调用机器码,得到结果。

    let buf = ops.finalize().unwrap();

    let hello_fn: unsafe extern "sysv64" fn() -> u8 =
        unsafe { std::mem::transmute(buf.ptr(hello)) };

    let ret = unsafe { hello_fn() };

    assert_eq!(ret, 0);
}

运行结果

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 1.30s
     Running `target/debug/bfjit`
Hello, JIT!

完整代码

mod bfir;

use dynasm::dynasm;
use dynasmrt::{DynasmApi, DynasmLabelApi};

use std::io::{stdout, Write};

unsafe extern "sysv64" fn print(buf: *const u8, len: u64) -> u8 {
    let buf = std::slice::from_raw_parts(buf, len as usize);
    stdout().write_all(buf).is_err() as u8
}

fn main() {
    let mut ops = dynasmrt::x64::Assembler::new().unwrap();
    let string = b"Hello, JIT!\n";

    dynasm!(ops
        ; .arch x64
        ; ->hello:
        ; .bytes string
    );

    let hello = ops.offset();
    dynasm!(ops
        ; lea rdi, [->hello]
        ; mov rsi, QWORD string.len() as _
        ; mov rax, QWORD print as _
        ; call rax
        ; ret
    );

    let buf = ops.finalize().unwrap();

    let hello_fn: unsafe extern "sysv64" fn() -> u8 =
        unsafe { std::mem::transmute(buf.ptr(hello)) };

    let ret = unsafe { hello_fn() };

    assert_eq!(ret, 0);
}

如何处理 panic

跨越 Rust 边界的 panic 是未定义行为,我们很难让汇编去匹配 unwind ABI。

暴露给外部调用的 Rust 函数最好捕获 panic,用其他方式去处理。

例如这样

#![allow(unused)]
fn main() {
unsafe extern "sysv64" fn print(buf: *const u8, len: u64) -> u8 {
    let ret = std::panic::catch_unwind(|| {
        let buf = std::slice::from_raw_parts(buf, len as usize);
        stdout().write_all(buf).is_err()
    });
    match ret {
        Ok(false) => 0,
        Ok(true) => 1,
        Err(_) => 2,
    }
}
}