← 返回首页

手写 JIT 编译器:从字节码到机器码

编译原理 2026-10-18 · 阅读约 15 分钟

JIT(Just-In-Time)编译器是现代语言运行时的核心组件。本文将基于 x86-64 架构,从零实现一个微型 JIT 编译器,涵盖寄存器分配、指令选择和机器码生成的全过程。

一、JIT 编译器架构概览

我们的微型 JIT 编译器采用三级流水线:字节码解析 → 中间表示(IR)构建 → x86-64 机器码生成。最终生成的机器码被写入可执行内存页并直接调用。

use std::mem;
use libc::{mmap, mprotect, MAP_ANON, MAP_PRIVATE, PROT_EXEC, PROT_READ, PROT_WRITE};

struct JitCompiler {
    code: Vec,
    registers: [bool; 16],  // x86-64 通用寄存器占用状态
}

impl JitCompiler {
    fn new() -> Self {
        JitCompiler { code: Vec::new(), registers: [false; 16] }
    }

    fn emit(&mut self, bytes: &[u8]) {
        self.code.extend_from_slice(bytes);
    }
}

二、寄存器分配策略

采用线性扫描寄存器分配算法。维护一个活跃区间列表,当寄存器不足时将变量溢出到栈上。x86-64 有 16 个通用寄存器,我们保留 rsp、rbp 用于栈管理,其余 14 个可用于分配。

fn allocate_register(&mut self, var_id: usize) -> Option {
    for i in 0..14 {
        if !self.registers[i] {
            self.registers[i] = true;
            return Some(i as u8);
        }
    }
    None  // 寄存器溢出,需要 spill 到栈
}

fn spill_to_stack(&mut self, var_id: usize) -> i32 {
    let offset = self.stack_slots.entry(var_id)
        .or_insert_with(|| { self.stack_offset -= 8; self.stack_offset });
    *offset
}

三、x86-64 指令编码

x86-64 指令编码使用变长格式,包含可选的前缀字节、REX 前缀、操作码、ModR/M 字节和立即数。以 add rax, rbx 为例,其机器码为 48 01 D8。

fn emit_add_reg_reg(&mut self, dst: u8, src: u8) {
    // REX.W + 01 /r: ADD r/m64, r64
    self.emit(&[0x48]);           // REX.W prefix
    self.emit(&[0x01]);           // ADD opcode
    let modrm = 0xC0 | (src << 3) | dst;  // Mod=11 (register mode)
    self.emit(&[modrm]);
}

fn emit_mov_imm_reg(&mut self, reg: u8, value: i64) {
    // REX.W + B8+rd: MOV r64, imm64
    self.emit(&[0x48]);
    self.emit(&[0xB8 | reg]);
    self.emit(&value.to_le_bytes());
}

四、字节码到机器码转换

定义一套简单的字节码指令集,包含算术运算、内存访问和控制流。每条字节码指令被翻译为对应的 x86-64 指令序列。

enum Bytecode {
    LoadConst(u8, i64),    // reg = constant
    Add(u8, u8, u8),       // dst = src1 + src2
    Mul(u8, u8, u8),       // dst = src1 * src2
    JmpIfZero(u8, i32),    // if reg == 0, jump offset
    Ret(u8),               // return reg
}

fn compile_bytecode(&mut self, bc: &Bytecode) {
    match bc {
        Bytecode::LoadConst(reg, val) => {
            self.emit_mov_imm_reg(*reg, *val);
        }
        Bytecode::Add(dst, src1, src2) => {
            self.emit_mov_reg_reg(*dst, *src1);
            self.emit_add_reg_reg(*dst, *src2);
        }
        Bytecode::Ret(reg) => {
            self.emit_mov_reg_reg(0, *reg);  // RAX = return value
            self.emit(&[0xC3]);              // RET
        }
        _ => unimplemented!(),
    }
}

五、可执行内存与调用

生成的机器码通过 mmap 映射到可执行内存页,然后转换为函数指针直接调用。这是 JIT 编译器的最后一步——将数据变为可执行代码。

fn finalize(&self) -> unsafe extern "C" fn() -> i64 {
    let size = self.code.len();
    let ptr = mmap(
        std::ptr::null_mut(), size,
        PROT_READ | PROT_WRITE,
        MAP_PRIVATE | MAP_ANON, -1, 0
    ) as *mut u8;

    std::ptr::copy_nonoverlapping(self.code.as_ptr(), ptr, size);
    mprotect(ptr as *mut _, size, PROT_READ | PROT_EXEC);

    mem::transmute(ptr)
}

六、性能基准测试

对比 JIT 编译执行与解释执行的性能差异。对于一个计算斐波那契数列的循环,JIT 编译版本比解释执行快 15-30 倍,接近原生编译的性能水平。

JIT 编译器是连接解释执行与静态编译的桥梁,掌握其原理对理解 V8、LuaJIT、Julia 等现代语言运行时至关重要。

← 返回首页