38、C++项目实战:简易虚拟机

说实话,很多C++初学者学到后面都会有个困惑:学了那么多语法、数据结构,到底能做什么?我个人觉得,写一个简易虚拟机就是个特别好的练手项目。它能把内存管理、指令解析、寄存器操作这些底层概念串起来,而且写出来特别有成就感。

我记得我第一次做这个项目时,是在一个周末的下午。本来只是想试试水,结果一写就停不下来。看着自己定义的指令一条条被执行,那种感觉,嗯,真的很奇妙。

什么是虚拟机?

说白了,虚拟机就是一个软件模拟的计算机。它有自己的CPU(虚拟的)、内存(一块数组)、寄存器(几个变量),还有一套指令集。你写一段“汇编代码”,虚拟机就能逐条执行。

我们这次要做的,是一个基于栈的简易虚拟机。为什么选栈式?因为实现简单,而且很多真实语言(比如Java的JVM)底层也是栈式的。

核心概念:栈式虚拟机 vs 寄存器式虚拟机

  • 栈式:操作数都在栈上,指令短小,实现简单
  • 寄存器式:操作数在寄存器中,性能更高,但实现复杂

我们选栈式,因为——你想想看,用C++实现一个寄存器式虚拟机,光寄存器分配就够你喝一壶的。

整体架构设计

先画个图,让你对整体有个感觉。这个图我当年画在草稿纸上,现在用SVG重新画一遍。

简易虚拟机架构图 指令读取器 从内存读取指令 指令解码器 解析操作码和操作数 执行引擎 执行算术/逻辑操作 数据栈 存储操作数和结果 内存 存储变量和程序 寄存器 PC、SP、标志位 程序计数器(PC)指向当前指令,栈指针(SP)指向栈顶 指令周期:取指 → 解码 → 执行 → 更新PC

指令集设计

指令集是虚拟机的灵魂。我建议你一开始不要搞太多指令,先定几个核心的:

操作码 名称 格式 说明
0x01 PUSH PUSH imm 将立即数压入栈
0x02 POP POP 弹出栈顶元素
0x03 ADD ADD 弹出两个数相加,结果压栈
0x04 SUB SUB 弹出两个数相减,结果压栈
0x05 MUL MUL 弹出两个数相乘,结果压栈
0x06 DIV DIV 弹出两个数相除,结果压栈
0x07 HALT HALT 停止执行

我的经验:一开始别贪多。我最初设计了20多条指令,结果调试时把自己绕晕了。后来砍到8条,反而更清晰。记住,少即是多

核心代码实现

好,现在开始写代码。我们用一个类来封装整个虚拟机:

#include <iostream>
#include <vector>
#include <cstdint>

class SimpleVM {
private:
    std::vector<int> stack;        // 数据栈
    std::vector<uint8_t> memory;   // 内存(存放程序)
    size_t pc;                     // 程序计数器
    bool running;                  // 运行标志

public:
    SimpleVM() : pc(0), running(false) {}
    
    // 加载程序到内存
    void loadProgram(const std::vector<uint8_t>& program) {
        memory = program;
        pc = 0;
        running = true;
    }
    
    // 执行一条指令
    void step() {
        if (pc >= memory.size()) {
            running = false;
            return;
        }
        
        uint8_t opcode = memory[pc++];
        
        switch (opcode) {
            case 0x01: { // PUSH
                if (pc + 4 > memory.size()) {
                    std::cerr << "错误:指令不完整\n";
                    running = false;
                    break;
                }
                // 读取4字节立即数(小端序)
                int value = 0;
                value |= memory[pc++];
                value |= memory[pc++] << 8;
                value |= memory[pc++] << 16;
                value |= memory[pc++] << 24;
                stack.push_back(value);
                break;
            }
            case 0x02: { // POP
                if (stack.empty()) {
                    std::cerr << "错误:栈为空\n";
                    running = false;
                    break;
                }
                stack.pop_back();
                break;
            }
            case 0x03: { // ADD
                if (stack.size() < 2) {
                    std::cerr << "错误:栈中元素不足\n";
                    running = false;
                    break;
                }
                int b = stack.back(); stack.pop_back();
                int a = stack.back(); stack.pop_back();
                stack.push_back(a + b);
                break;
            }
            case 0x04: { // SUB
                if (stack.size() < 2) {
                    std::cerr << "错误:栈中元素不足\n";
                    running = false;
                    break;
                }
                int b = stack.back(); stack.pop_back();
                int a = stack.back(); stack.pop_back();
                stack.push_back(a - b);
                break;
            }
            case 0x05: { // MUL
                if (stack.size() < 2) {
                    std::cerr << "错误:栈中元素不足\n";
                    running = false;
                    break;
                }
                int b = stack.back(); stack.pop_back();
                int a = stack.back(); stack.pop_back();
                stack.push_back(a * b);
                break;
            }
            case 0x06: { // DIV
                if (stack.size() < 2) {
                    std::cerr << "错误:栈中元素不足\n";
                    running = false;
                    break;
                }
                int b = stack.back(); stack.pop_back();
                if (b == 0) {
                    std::cerr << "错误:除零\n";
                    running = false;
                    break;
                }
                int a = stack.back(); stack.pop_back();
                stack.push_back(a / b);
                break;
            }
            case 0x07: { // HALT
                running = false;
                break;
            }
            default:
                std::cerr << "错误:未知操作码 0x" 
                          << std::hex << (int)opcode << "\n";
                running = false;
        }
    }
    
    // 运行整个程序
    void run() {
        while (running) {
            step();
        }
    }
    
    // 获取栈顶元素
    int getTop() const {
        if (stack.empty()) return 0;
        return stack.back();
    }
};

注意:上面的代码为了清晰,省略了一些边界检查。在实际项目中,你还需要考虑:

  • 内存越界访问
  • 栈溢出(可以设置最大深度)
  • 指令对齐问题

我曾经在一个项目中忘了检查除零,结果虚拟机跑着跑着就崩了,查了半天才发现是用户输入了0作为除数。

编写测试程序

虚拟机写好了,总得跑点东西吧?我们来写一个计算 (10 + 20) * 3 的程序:

// 计算 (10 + 20) * 3
// 对应的指令序列:
// PUSH 10
// PUSH 20
// ADD
// PUSH 3
// MUL
// HALT

std::vector<uint8_t> program = {
    0x01, 0x0A, 0x00, 0x00, 0x00,  // PUSH 10
    0x01, 0x14, 0x00, 0x00, 0x00,  // PUSH 20
    0x03,                           // ADD
    0x01, 0x03, 0x00, 0x00, 0x00,  // PUSH 3
    0x05,                           // MUL
    0x07                            // HALT
};

SimpleVM vm;
vm.loadProgram(program);
vm.run();
std::cout << "结果: " << vm.getTop() << std::endl;  // 输出 90

你看,代码虽然简单,但背后的逻辑是完整的。每条指令都对应着CPU中的一个微操作。我个人觉得,当你亲手写出这个循环时,你对计算机的理解会上一个台阶。

扩展思路

基础版本完成后,你可以往这几个方向扩展:

  • 添加比较指令:CMP、JMP、JE、JNE 等,让虚拟机支持条件分支
  • 支持函数调用:增加 CALL 和 RET 指令,需要维护调用栈
  • 增加 I/O 指令:比如 PRINT 指令,直接输出栈顶值
  • 实现汇编器:写一个简单的汇编器,把文本指令翻译成字节码

我的建议:先别急着加功能。把基础版本跑稳了,再逐步迭代。我见过太多人一上来就想搞个完整的JVM,结果写到一半就放弃了。从小做起,慢慢来。

调试技巧

写虚拟机最头疼的就是调试。指令执行错了,你都不知道是哪里出的问题。这里分享几个我常用的方法:

  1. 单步执行:在 step() 函数里加个断点,或者打印每条指令执行前后的栈状态
  2. 栈快照:每次操作后打印栈的内容,看看是不是符合预期
  3. 指令计数器:记录执行了多少条指令,防止死循环
// 调试用的打印函数
void debugPrint() {
    std::cout << "PC: " << pc << " | 栈: [";
    for (size_t i = 0; i < stack.size(); ++i) {
        if (i > 0) std::cout << ", ";
        std::cout << stack[i];
    }
    std::cout << "]\n";
}

嗯,说到调试,我记得有一次我写了个死循环,虚拟机跑了一整夜。第二天发现CPU风扇呼呼转,程序还在跑。从那以后,我每次都会加一个最大指令数限制。

总结

简易虚拟机这个项目,说白了就是把计算机组成原理的知识用C++实现一遍。你不需要把它做得多么复杂,关键是理解指令-执行-状态更新这个核心循环。

写完之后你会发现,很多高级概念——比如解释器、编译器后端、甚至操作系统的进程调度——底层都是类似的思路。这个项目就像一把钥匙,能帮你打开很多扇门。

最后,如果你在实现过程中遇到问题,别急着搜答案。先自己想想,栈的状态应该是什么样的?PC应该怎么变化?想清楚了再动手。这个过程,比代码本身更有价值。


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