Skip to content

这是一个非常经典的虚拟机设计问题。

答案是:完全可行,而且是基于栈的虚拟机(Stack-based VM)的强项。

要实现“流体化”执行(即多次输入的代码像水流一样连续作用于同一个堆栈),你不需要修改 AutoVM 的核心执行指令集,只需要改变 VM 的宿主交互模式 和 代码加载策略。

核心思想是:状态持久化,指令碎片化。

以下是实现 AutoVM REPL 的具体架构方案:

1. 核心概念:Session State vs. Code Chunk ​

在传统的 a2c 或 a2b 编译运行模式下,生命周期是: 启动 VM -> 加载整个程序 -> 运行 -> 销毁 VM

而在 REPL 模式下,生命周期变为: 启动 VM -> (循环:编译片段 -> 加载片段 -> 运行片段 -> 暂停) -> 销毁 VM

你需要将 VM 拆解为两部分数据:

  1. 持久化状态 (Session State):
  • Data Stack (操作数栈): 必须在多次输入之间保留。
  • Global Variables (全局变量表): 存储 let x = 1 定义的变量。
  • Symbol Table (符号表): 编译器需要知道 x 对应全局变量索引 0。
  • Heap (堆内存): 所有的对象、字符串必须保留。
  1. 临时指令流 (Transient Code Chunk):
  • 这是用户当前这一行代码生成的 Bytecode。
  • 执行完即扔(或者归档),Instruction Pointer (IP) 会重置指向下一段新的代码。

2. 具体的实现步骤 ​

第一步:编译器改造 (Incremental Compiler) ​

编译器不能假设每次都是从零开始。它需要支持“增量编译上下文”。

rust
struct CompilerSession {
    // 符号表必须持久化,否则第二行代码不知道第一行定义的变量
    global_symbols: HashMap<String, u16>,
    // 常量池也需要累积吗?视实现而定,通常每个 Chunk 可以有自己的常量池,
    // 或者共用一个全局常量池
}

impl CompilerSession {
    // 编译一行代码,生成一个独立的 Chunk
    fn compile_fragment(&mut self, source: &str) -> BytecodeChunk {
        // ... 解析 AST ...
        // ... 生成指令 ...
        // 注意:生成的指令如果不包含 return/halt,VM 可能会跑飞,
        // 所以通常会在 Fragment 末尾强制加一个 Yield 或 End 指令
    }
}

第二步:VM 执行循环改造 (Fluid Execution) ​

你需要为 VM 增加一个 eval 或 run_fragment 的接口,而不是原来的 run_program。

假设 AutoVM 的核心是个大循环:

rust
impl VM {
    // 传统入口
    fn run(&mut self) {
        while self.ip < self.code.len() {
            self.step();
        }
    }

    // REPL 专用入口
    fn run_fragment(&mut self, chunk: BytecodeChunk) {
        // 1. 保存旧的指令指针 (IP) 和代码引用
        // (如果你的架构是单一大代码段,这里就是追加代码)
        
        // 方案 A:代码追加模式 (Append Mode)
        // 把新指令追加到 VM 的主代码段末尾
        let start_ip = self.code.len();
        self.code.extend(chunk.instructions);
        self.ip = start_ip; // IP 指向新增代码的开头

        // 2. 执行直到片段结束
        loop {
            let instr = self.fetch();
            match instr {
                OpCode::EndFragment => break, // 专门的指令,表示当前行结束
                _ => self.execute(instr),
            }
        }
        
        // 3. 关键:不要清空栈!
        // 此时栈顶可能留着计算结果,例如用户输入 "1+2",栈顶就是 3
    }
}

第三步:处理栈残留 (Stack Hygiene) ​

在 REPL 中,用户输入的代码分为两种:语句 (Statement) 和 表达式 (Expression)。

  • 表达式: 1 + 2。期望行为:计算结果留在栈顶,REPL 打印出来,然后 弹出。
  • 语句: let x = 10;。期望行为:副作用(修改全局变量),栈不变(或推入 void)。

REPL 循环逻辑:

rust
fn repl_loop() {
    let mut vm = VM::new();
    let mut compiler = CompilerSession::new();

    loop {
        let input = read_line();
        
        // 1. 编译
        let chunk = compiler.compile_fragment(&input);
        
        // 2. 运行
        vm.run_fragment(chunk);

        // 3. 处理栈顶 (Print & Pop)
        if !vm.stack_is_empty() {
            let result = vm.peek(); // 偷看栈顶
            println!("> {:?}", result);
            
            // 这里的策略很重要:
            // 如果你想让下一次输入能复用这个结果(比如输入 "+ 5"),就保留。
            // 但通常 REPL 都是每次清空上一行的临时结果,除非显式赋值。
            // 建议:打印后 POP,保持栈平衡。
            vm.pop(); 
        }
    }
}

3. 遇到的特殊挑战与解法 ​

挑战 A:控制流跨行 (Multi-line Control Flow) ​

如果用户输入 if (x &gt; 0) { 然后回车,怎么办?

  • 解法:这是 Parser 层面的事。REPL 前端需要检测 AST 是否闭合。如果没有闭合(比如缺少右花括号),不要触发 compile_fragment,而是提示用户继续输入,直到攒够一个完整的 Block。

挑战 B:局部变量 vs 全局变量 ​

在 main.auto 里,let a = 1 可能是局部变量(在栈上)。 但在 REPL 里,顶层定义的 let a = 1 必须是 全局变量(在 Globals 数组里)。

  • 解法:REPL 模式下,编译器应强制将顶层变量视为 Global,分配 Global Index 而不是 Stack Slot。这样它们才能跨 Fragment 存活。

挑战 C:跳转指令 (Jumps) ​

如果代码片段里有 jmp(比如循环),Offset 怎么算?

  • 解法:Fragment 内部的跳转是相对的,没问题。但不能跨 Fragment 跳转(也不应该允许)。每次编译 Fragment 时,Offset 是相对于该 Fragment 起始位置的,VM 执行时需要加上 Base IP。

4. 进阶:“流体化”的极致——代码对象化 ​

为了让架构更优雅,不要把代码看作一个无限增长的 Vec&lt;u8&gt;。

建议将 BytecodeChunk 设计为一个对象(类似 Python 的 Code Object)。

rust
struct VM {
    frames: Vec<CallFrame>, // 调用栈
    stack: Vec<Value>,      // 数据栈
    globals: Vec<Value>,    // 全局变量
}

struct CallFrame {
    code: Rc<BytecodeChunk>, // 当前正在执行的代码块
    ip: usize,               // 在该代码块中的偏移量
}

REPL 执行流程:

  1. 用户输入 1 + 1。
  2. 生成一个新的 BytecodeChunk 对象(包含常量池、指令)。
  3. VM 创建一个新的 CallFrame,指向这个 Chunk,压入调用栈。
  4. VM 执行。
  5. 执行完毕,CallFrame 弹出,Chunk 被销毁(如果没有闭包引用它)。
  6. 数据栈(Stack)和全局变量(Globals)保留在 VM 结构体中,毫发无损。

总结 ​

要在 AutoVM 实现 REPL:

  1. VM 实例长驻:不要每次执行完销毁 VM。
  2. 增量编译器:符号表需持久化,能识别上一行定义的变量。
  3. Fragment Execution:支持加载一段代码,执行完后保留数据栈。
  4. 打印即丢弃:REPL 负责打印栈顶结果并清理,保证下一行代码开始时栈是干净的(或者按需保留)。

这就是你说的“流体化”——代码是流过的水(Fragment),而栈是河床(VM State),水流过之后,河床的形状(全局变量/状态)被改变了。

追加 ​

这是一个非常硬核的底层问题。你触碰到了 “增量执行” 与 “作用域闭包” 的边界。

如果是在函数内部(Local Scope),情况确实比全局作用域(Global Scope)复杂得多。全局变量位置是固定的(Global Index),而局部变量的位置是相对于 当前栈帧基址 (Frame Pointer, FP) 的偏移量。

如果前一个 Chunk 执行完了,通常意味着这个“代码块”结束了,栈帧可能就被销毁了。如果栈帧销毁了,局部变量也就没了。

要在函数内的不同 Chunk 间共享局部变量,你必须实现 “开放栈帧 (Open Frame)” 或 “可挂起/恢复的执行上下文 (Resumable Execution Context)”。

以下是具体的解决方案:

核心思想:不要销毁栈帧,而是“挂起”它 ​

你需要将这个函数视为一个 Generator(生成器) 或者 Coroutine(协程),它的执行是可以被“打断”和“续传”的。


1. 编译器层面的配合:持久化局部符号表 ​

首先,编译器(a2b) 必须知道前一个 Chunk 定义了什么变量,以及它们分配在栈的哪个位置。

假设网络传来了两段代码(在同一个函数 foo 内):

  • Chunk A: let x = 10;
  • Chunk B: return x + 5;

编译 Chunk A 时:

  1. 编译器解析 let x。
  2. 编译器分配栈槽(Stack Slot) 0 给 x。
  3. 关键点:编译结束后,不能丢弃符号表!必须将 SymbolTable { x: Slot(0) } 保存下来,作为 CompilerContext 传给下一个 Chunk。

编译 Chunk B 时:

  1. 编译器解析 x + 5。
  2. 查找 x。它去查询传入的 CompilerContext。
  3. 发现 x 在 Slot(0)。
  4. 生成指令 GET_LOCAL 0。

2. VM 层面的配合:开放栈帧 (The Open Frame) ​

VM 执行 Chunk A 后,绝对不能执行 RET (Return) 或销毁 CallFrame。

方案:流式代码注入 (Streaming Code Injection) ​

这种模式下,VM 认为自己正在执行一个无限长的函数。

内存布局状态图:

text
[ Stack ]       [ CallFrame ]           [ CodeObject (Bytecode) ]
| ...   |       | ip: 2       | ------> | 0: CONST 10 
| 10    | <---- | fp: 100     |         | 1: SET_LOCAL 0
| ...   |       | code_ptr    |         | 2: SUSPEND (挂起)  <-- Chunk A 结束
                                        | ... (等待 Chunk B)

执行流程:

  1. 收到 Chunk A (CONST 10; SET_LOCAL 0):
  • 将指令追加到当前的 CodeObject 中。
  • VM 从当前的 IP 开始执行。
  • 执行完 SET_LOCAL 0 后,栈上 Slot 0 存入了 10。
  • 关键:Chunk A 的末尾不生成 RETURN,而是生成一个特殊的虚指令(或者直接让 VM 循环停止),我们称之为 YIELD 或 SUSPEND。
  • VM 停止执行,但 Stack 和 CallFrame 保持原样不动。
  1. 网络空闲期:
  • VM 处于 "Suspended" 状态。
  • 栈上的 10 依然存在。
  • IP 指向代码末尾。
  1. 收到 Chunk B (GET_LOCAL 0; ADD 5):
  • 链接:将 Chunk B 的指令追加到 CodeObject 的末尾(紧接着 Chunk A)。

  • 唤醒:VM 从 IP 处继续执行。

  • 执行 GET_LOCAL 0:

  • VM 读取当前 Frame 的 FP。

  • 访问 Stack[FP + 0]。

  • 成功取到了 10!

  • 执行 ADD 5。


3. 技术难点:指令跳转与修补 ​

如果 Chunk A 包含不完整的控制流,比如:

  • Chunk A: if (x &gt; 5) { (只有前半截)
  • Chunk B: print(x); } (后半截)

这是最棘手的情况。

  • 编译层:Chunk A 无法单独通过编译,因为 if 需要生成 JUMP_IF_FALSE,但跳转目标的偏移量在 Chunk B 里,此时还不知道。
  • 解决策略:
  1. 基于行的 REPL:强制要求用户输入完整的 Block 才能执行。输入 { 后回车,编译器不工作,只缓存文本,直到检测到 } 才开始编译整个 Block。这是 Node.js/Python REPL 的做法。
  2. 基于 AST 的流:如果你的网络协议传输的是 AST,那么必须凑齐一个完整的 AST Node 才能生成 Bytecode。

对于 AutoVM 来说,推荐策略是:“以 Statement(语句)为最小原子单位”。

你不能传输半条指令。网络传输的最小单位必须是能够生成合法 Bytecode 的完整语句。

  • 合法:let x = 10;
  • 合法:while (x &lt; 100) { x = x + 1; }
  • 不合法:while (x &lt; 100) {

4. 总结方案 ​

要在函数内跨 Chunk 访问局部变量:

  1. 编译器状态化: 编译完 Chunk A 后,保留 LocalScope(变量名 -> 栈偏移的映射),供编译 Chunk B 使用。
  2. VM 栈帧持久化:
  • 创建一个专门的 ExecutionSession。
  • Chunk A 执行完后,不要 Pop Frame。
  • Chunk B 的指令 Append 到当前正在执行的 CodeBlock 后面。
  • 更新 CallFrame 的指令边界,让 IP 继续往下跑。

这样,Chunk B 里的 GET_LOCAL 0 就能准确地找到 Chunk A 留下的“遗产”。

这实际上就是把整个网络会话当成了一个 “正在编写且同时正在运行的超长函数”。

追加:如何在Streaming模式下实现局部变量? ​

这是一个非常敏锐的 VM 实现细节问题。

你担心的点是:临时变量(Operands/Temps)会“污染”栈的索引空间,导致后续定义的局部变量 offset 不确定或不连续。

在流式/REPL 环境中,解决这个问题的核心原则是:语句级栈平衡(Statement-Level Stack Balance)。

简单来说:“每一行代码执行结束时,栈上必须只剩下局部变量,所有的临时计算值必须被消费掉或丢弃。”

我们通过一个具体的生命周期图解来解释它是如何工作的:

1. 核心机制:SP (Stack Pointer) vs locals_count ​

编译器(增量编译器)在内存中维护一个计数器 locals_count。

  • BP (Base Pointer): 当前栈帧的基址。
  • SP (Stack Pointer): 当前栈顶。
  • Invariant (不变式): 在每条语句(Statement)执行的间隙,必须保证 SP == BP + locals_count。

2. 执行流程推演 ​

假设 REPL 依次输入三行代码:

  1. let x = 1
  2. x + 2 (这是一个表达式语句,产生临时值)
  3. let y = x + 3

阶段 1: let x = 1 (变量定义) ​

  • 编译期:编译器看到 let,分配 x 的 offset 为 0 (相对于 BP)。更新 locals_count = 1。
  • 运行期:
  1. CONST 1 -> 压栈 1。
  2. 此时栈:[1]。
  3. 语句结束。let 语句的特性是保留栈顶值作为变量。
  4. 状态:SP 在 offset 0 处。SP == BP + 1。平衡。

阶段 2: x + 2 (产生临时值的干扰项) ​

这里是你担心的核心。

  • 编译期:这是个表达式。编译器生成计算指令。
  • 运行期:
  1. GET_LOCAL 0 (x) -> 压栈 1。栈:[1, 1] (x, temp_x)。
  2. CONST 2 -> 压栈 2。栈:[1, 1, 2]。
  3. ADD -> 弹出 1, 2,压入 3。栈:[1, 3] (x, result)。
  • REPL 的特殊处理 (The Cleanup):
  • 在 REPL 循环中,当执行完一行表达式后,通常会做两件事:
  1. Print: 打印栈顶的 3 给用户看。
  2. Pop (关键步骤): 强制弹出栈顶的临时结果。
  • 如果不是 REPL,而是普通函数中的 x+2; 语句,编译器会自动在语句末尾生成一个 POP 指令。

  • 结果:执行完清理后,栈变回 [1]。

  • 状态:SP 回到 offset 0。SP == BP + 1。恢复平衡。

阶段 3: let y = x + 3 (再次定义变量) ​

由于阶段 2 结束后栈被清理了,所以 y 的位置是确定的。

  • 编译期:编译器查看 locals_count,当前是 1。所以 y 的 offset 分配为 1。更新 locals_count = 2。
  • 运行期:
  1. GET_LOCAL 0 (x) -> 压栈 1。栈:[1, 1] (x, temp_x)。注意:此时 temp 确实压在了 x 上面。
  2. CONST 3 -> 压栈 3。栈:[1, 1, 3]。
  3. ADD -> 弹出 1, 3,压入 4。栈:[1, 4] (x, result_y)。
  4. 语句结束。这是一个 let 语句,栈顶的 4 被“正式编制”为局部变量 y。
  • 状态:SP 在 offset 1 处。SP == BP + 2。平衡。

3. 如果我不 POP 呢?(The "Implicit Result" Variable) ​

在某些 REPL (如 Python, Swift) 中,上一行的结果可以通过 _ 或 $0 访问。这是否意味着 3 必须留在栈上?

不能留在操作数栈上。

如果允许 3 留在栈上,栈就会变成 [x, 3]。 下一句 let y = ... 执行时,y 就会变成 stack[BP+2]。 如果用户下一句不是 let 而是又一个表达式 5+5,栈变成 [x, 3, 10]。 这样 offset 就会随着用户输入的历史无限漂移,编译器将无法确定 stack[BP+1] 到底是谁。

解决方案: REPL 的 VM 应该有一个专门的寄存器或全局变量槽位叫 LAST_RESULT。 流程变为:

  1. 计算 x + 2 得到 3。
  2. MOV LAST_RESULT, POP()。
  3. 栈清理干净。

这样,如果用户下一句用了 _,编译器生成 GET_GLOBAL LAST_RESULT,而不是去读栈上的脏数据。

4. 复杂情况:块表达式 (Block Expression) ​

如果流式输入稍微复杂点:

rust
let z = {
    let temp = 1;
    temp + x // 此时栈上有: [x, temp, result]
};

在这个 Block 结束时,编译器生成的代码必须包含 清理局部作用域 的指令。

  1. 计算出 result。
  2. 将 result 暂存(或 Swap 到 temp 下面)。
  3. POP temp。
  4. 将 z 压入。

结论:

在基于栈的 VM 中,局部变量的紧凑排列(Contiguous Layout)是绝对的铁律。

在 REPL/流式模式下,你必须严格遵守 "Statement Boundary Cleanup"(语句边界清理) 原则。只要每处理完一个完整的语句(Chunk),就强制把栈顶回退到 locals_count 的位置,你的局部变量 offset 永远就是 BP + locals_count,不会乱。

基于 MIT 许可发布。