实战:虚拟机(字节码+栈机+指令执行)

说实话,很多C++程序员写了好几年代码,都没亲手实现过一个虚拟机。我当年也是,总觉得这东西离自己很远——直到有一次需要为一个嵌入式设备设计一套脚本引擎,才硬着头皮啃了一遍。

嗯,今天咱们就来补上这一课。用C++手写一个简单的栈式虚拟机,跑通字节码的加载、解码和执行。你想想看,搞懂了这个,以后看JVM、Lua虚拟机、Python字节码,都会通透很多。

什么是栈式虚拟机?

说白了,就是一台用栈来运算的假想机器。它没有寄存器,所有操作都在栈上完成。

举个例子:计算 1 + 2,在栈机里怎么做?

  1. 把 1 压入栈顶
  2. 把 2 压入栈顶
  3. 执行 ADD 指令:弹出两个数,相加,结果再压回栈

就这么简单。我刚开始学的时候觉得这效率太低了,后来才明白——栈机最大的好处是指令短、实现简单、容易移植。很多IoT设备上的脚本引擎,用的就是这种模型。

字节码设计

我们先定义一套极简的指令集。每条指令固定 4 字节:

字节偏移含义
0操作码(Opcode)
1-3操作数(立即数或地址)

指令集如下:

指令操作码说明
PUSH0x01将立即数压栈
POP0x02弹出栈顶
ADD0x03栈顶两数相加
SUB0x04栈顶两数相减
MUL0x05栈顶两数相乘
DIV0x06栈顶两数相除
HALT0xFF停机
我的习惯:操作码从 0x01 开始,0x00 留作非法指令。这样调试时看到 0x00 就知道出问题了。

核心数据结构

我们需要三个核心组件:

  • 字节码数组:存放编译后的指令序列
  • :运行时数据栈,用 std::vector<int> 实现
  • 程序计数器(PC):指向当前正在执行的指令

代码骨架长这样:

class StackVM {
public:
    void load(const std::vector<uint8_t>& bytecode) {
        code = bytecode;
        pc = 0;
        stack.clear();
    }

    void run() {
        while (pc < code.size()) {
            uint8_t op = code[pc];
            uint32_t operand = 0;
            if (op != 0xFF) { // HALT 没有操作数
                operand = (code[pc+1] << 16) |
                          (code[pc+2] << 8)  |
                          (code[pc+3]);
            }
            execute(op, operand);
        }
    }

private:
    std::vector<uint8_t> code;
    std::vector<int> stack;
    size_t pc = 0;
};
我曾经踩过的坑:字节序问题。如果你在 x86 上写字节码,然后放到 ARM 设备上跑,大小端不一致会导致操作数解析错误。我建议统一用大端序(网络字节序)存储操作数。

指令执行引擎

这是虚拟机的核心。每条指令的执行逻辑都很短:

void execute(uint8_t op, uint32_t operand) {
    switch (op) {
        case 0x01: // PUSH
            stack.push_back(static_cast<int>(operand));
            pc += 4;
            break;

        case 0x02: // POP
            if (stack.empty()) {
                throw std::runtime_error("栈已空,无法POP");
            }
            stack.pop_back();
            pc += 4;
            break;

        case 0x03: // ADD
            if (stack.size() < 2) {
                throw std::runtime_error("栈元素不足,无法ADD");
            }
            {
                int b = stack.back(); stack.pop_back();
                int a = stack.back(); stack.pop_back();
                stack.push_back(a + b);
            }
            pc += 4;
            break;

        // SUB、MUL、DIV 类似,略

        case 0xFF: // HALT
            pc = code.size(); // 跳出循环
            break;

        default:
            throw std::runtime_error("未知操作码");
    }
}

你可能会问:为什么 ADD 要弹出两个数再压回一个?嗯,这就是栈机的核心哲学——操作数隐式传递。指令本身不指定操作数来自哪里,反正都在栈顶。

编译一段测试程序

我们来手动编译一个计算 (10 + 20) * 3 的字节码:

// 伪汇编
PUSH 10
PUSH 20
ADD      // 栈顶现在是 30
PUSH 3
MUL      // 栈顶现在是 90
HALT

// 对应的字节码(十六进制)
01 00 00 0A   // PUSH 10
01 00 00 14   // PUSH 20
03 00 00 00   // ADD
01 00 00 03   // PUSH 3
05 00 00 00   // MUL
FF 00 00 00   // HALT

加载并运行:

StackVM vm;
std::vector<uint8_t> bytecode = {
    0x01, 0x00, 0x00, 0x0A,
    0x01, 0x00, 0x00, 0x14,
    0x03, 0x00, 0x00, 0x00,
    0x01, 0x00, 0x00, 0x03,
    0x05, 0x00, 0x00, 0x00,
    0xFF, 0x00, 0x00, 0x00
};
vm.load(bytecode);
vm.run();
// 此时栈顶应为 90
核心要点:栈机没有寄存器,所有数据流动都通过栈。指令短小(4字节),解码简单。缺点是同样的运算需要更多指令(因为要反复压栈弹栈)。

执行流程可视化

下面这张图展示了 PUSH 10 → PUSH 20 → ADD 过程中栈的变化:

步骤1: PUSH 10 栈底 10 ↑ 栈顶 步骤2: PUSH 20 栈底 10 20 ↑ 栈顶 步骤3: ADD 栈底 30 ↑ 栈顶 ADD 弹出 20 和 10,相加得 30,再压回栈 完整执行流程 PUSH 10 PUSH 20 ADD 结果: 30

扩展思路

这个基础版本只有 7 条指令,但你可以轻松扩展:

  • 跳转指令:JMP、JZ(栈顶为零则跳转),实现条件分支和循环
  • 局部变量:增加局部变量表,支持函数调用
  • 字符串支持:栈元素从 int 改为 Value 联合体
  • GC:如果支持对象和字符串,就需要垃圾回收
我个人的建议:别一上来就想搞个完整的 JVM。先把这个 100 行的栈机跑通,然后逐步加功能。我当初就是一口气加了 20 条指令,结果调试了整整三天。

嗯,今天就到这里。这个栈机虽然简单,但五脏俱全。你把它跑通了,再去看 Lua 的官方实现或者 Python 的 dis 模块,会发现很多似曾相识的东西。


公众号:蓝海资料掘金营,微信deep3321