实战:虚拟机(字节码+栈机+指令执行)
说实话,很多C++程序员写了好几年代码,都没亲手实现过一个虚拟机。我当年也是,总觉得这东西离自己很远——直到有一次需要为一个嵌入式设备设计一套脚本引擎,才硬着头皮啃了一遍。
嗯,今天咱们就来补上这一课。用C++手写一个简单的栈式虚拟机,跑通字节码的加载、解码和执行。你想想看,搞懂了这个,以后看JVM、Lua虚拟机、Python字节码,都会通透很多。
什么是栈式虚拟机?
说白了,就是一台用栈来运算的假想机器。它没有寄存器,所有操作都在栈上完成。
举个例子:计算 1 + 2,在栈机里怎么做?
- 把 1 压入栈顶
- 把 2 压入栈顶
- 执行 ADD 指令:弹出两个数,相加,结果再压回栈
就这么简单。我刚开始学的时候觉得这效率太低了,后来才明白——栈机最大的好处是指令短、实现简单、容易移植。很多IoT设备上的脚本引擎,用的就是这种模型。
字节码设计
我们先定义一套极简的指令集。每条指令固定 4 字节:
| 字节偏移 | 含义 |
|---|---|
| 0 | 操作码(Opcode) |
| 1-3 | 操作数(立即数或地址) |
指令集如下:
| 指令 | 操作码 | 说明 |
|---|---|---|
| PUSH | 0x01 | 将立即数压栈 |
| POP | 0x02 | 弹出栈顶 |
| ADD | 0x03 | 栈顶两数相加 |
| SUB | 0x04 | 栈顶两数相减 |
| MUL | 0x05 | 栈顶两数相乘 |
| DIV | 0x06 | 栈顶两数相除 |
| HALT | 0xFF | 停机 |
我的习惯:操作码从 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 过程中栈的变化:
扩展思路
这个基础版本只有 7 条指令,但你可以轻松扩展:
- 跳转指令:JMP、JZ(栈顶为零则跳转),实现条件分支和循环
- 局部变量:增加局部变量表,支持函数调用
- 字符串支持:栈元素从
int改为Value联合体 - GC:如果支持对象和字符串,就需要垃圾回收
我个人的建议:别一上来就想搞个完整的 JVM。先把这个 100 行的栈机跑通,然后逐步加功能。我当初就是一口气加了 20 条指令,结果调试了整整三天。
嗯,今天就到这里。这个栈机虽然简单,但五脏俱全。你把它跑通了,再去看 Lua 的官方实现或者 Python 的 dis 模块,会发现很多似曾相识的东西。
公众号:蓝海资料掘金营,微信deep3321