函式 = bytecode + metadata#

對 VM 來說,一個函式就是:

struct Function {
    Object   obj;
    char    *name;
    int      arity;            // 期望的引數個數
    int      num_locals;       // 區域變數槽位
    int      num_upvalues;     // closure 捕獲的數量(第七章)
    uint8_t *code;             // bytecode 起點
    int      code_len;
    Constant *constants;       // 該函式自己的常數池
    int      const_count;
    // 例外表、debug info...
};

VM 內部把每個 def 編譯成這個結構。執行 def f(x); x + 1; end 的結果就是建一個 Function 物件、放進當前 scope 的 binding 裡。

Frame:每次呼叫的執行記錄#

呼叫一次函式 = 配一個 frame。frame 紀錄:

struct Frame {
    Function *func;       // 我在執行哪個函式
    uint8_t  *return_pc;  // 返回後該跳哪
    Value    *base;       // 我的 local / register 起點
    int       num_args;   // 收到幾個引數
    Frame    *prev;       // 上一個 frame(呼叫者)
};

frame 鏈構成 call stackbacktracestack overflowreturn 全靠這條鏈。

兩種 frame 配置#

Linked list(每個 frame 獨立配置)#

struct VM {
    Frame *current_frame;
};

void call(Function *f) {
    Frame *new_frame = malloc(sizeof(Frame));
    new_frame->prev = vm.current_frame;
    new_frame->func = f;
    new_frame->base = malloc(sizeof(Value) * f->num_locals);
    vm.current_frame = new_frame;
}

優點:易理解。 缺點:每次 call 都 malloc,慢;多次 indirection 對 cache 不友善。沒人這樣做。

連續 frame stack(主流)#

整個 call stack 是一塊連續記憶體。frame 是其中一段:

+───────────────────+ ← stack base
| frame 0 locals    |
| frame 0 args      |
+───────────────────+
| frame 1 locals    |
| frame 1 args      |
+───────────────────+
| frame 2 locals    |   ← 當前 frame
| frame 2 args      |
| frame 2 operand   |
| stack             |
+───────────────────+ ← sp(當前 stack pointer)

call 只是把 sp 推一段、設好新 frame 的 base/return_pc:

void call(VM *vm, Function *f, int nargs) {
    Frame *frame = &vm->frames[vm->frame_count++];
    frame->func = f;
    frame->return_pc = vm->pc;
    frame->base = vm->sp - nargs;       // 引數已在 stack 頂
    vm->pc = f->code;
}

return 把 sp 收回、pc 復原:

void op_return(VM *vm) {
    Frame *frame = current(vm);
    Value result = pop(vm);
    vm->sp = frame->base;               // 整段釋放
    push(vm, result);
    vm->pc = frame->return_pc;
    vm->frame_count--;
}

連續 stack 的好處:

  • 配置「sp += n」一條指令
  • cache 極友善(frame 之間相鄰)
  • frame 之間可以共享運算元(呼叫時引數已在 stack 上,不用搬)

引數傳遞#

Stack VM 約定#

;; f(1, 2, 3)
PUSH f
PUSH 1
PUSH 2
PUSH 3
CALL 3      ;; 引數個數

CALL 的 handler:

case OP_CALL: {
    int nargs = read_byte();
    Value f = peek(nargs);             // 引數下面是函式本身
    if (!IS_FUNCTION(f)) error();
    call(vm, AS_FUNCTION(f), nargs);
    break;
}

引數已自然在「新 frame 的 base」位置。

Register VM 約定#

;; f(R5, R6, R7) → R0
MOVE R10, fn       ;; R10 = f
MOVE R11, R5
MOVE R12, R6
MOVE R13, R7
CALL R10, 3, 1     ;; 從 R10 開始,3 個引數,1 個結果

新 frame 的 base 就是 R10,引數已就位。Lua / mruby 用這個。

預設引數、可變引數、命名引數#

每個語言都有自己一套規則:

def f(a, b=10, *args, **kwargs):
    ...

對 VM 來說這是 frame 的「引數對齊」工作:

  1. 收到的位置引數數量
  2. 與 function metadata 對比預期形狀
  3. 不足的填預設值(從 constants 取)
  4. 多餘的塞進 args list
  5. keyword args 塞進 kwargs dict

CPython 的 KW_NAMESCALL_FUNCTION_KWCALL_FUNCTION_EX 系列 opcode 處理這個。

回傳值#

最簡單:function body 的最後 expression 就是回傳值(Ruby、Lisp、Rust)。VM 約定:

RETURN       ;; pop top of stack, 放回呼叫者 stack

或 register VM:

RETURN R5    ;; 把 R5 移到呼叫者期望的位置

多返回值(Lua、Go):用一段連續 register / stack 表達。

tail call#

def f(n)
    g(n + 1)    # 這是 tail position
end

f 的最後動作是呼叫 g ── 沒有任何 f 自己要做的事了。理論上可以重用 f 的 frame:把 g 的引數覆寫到 f 原本的 frame,然後 jump 而非 call。這叫 tail call optimization

原本:  f frame → g frame → return → return
TCO:   f frame  ─重用→  g 直接跑在裡面 → return 一次

對遞迴函式極重要:沒 TCO 的 factorial(100000) 會 stack overflow,有 TCO 跑得跟 loop 一樣。

;; 沒 TCO
CALL g
RETURN

;; 有 TCO
TAILCALL g     ;; 重寫 frame、跳走、不留呼叫紀錄

支援 TCO 的:Scheme(規範要求)、Lua、Wasm(明確 opcode)。 不支援的:Python(Guido 拒絕)、JavaScript(規格寫了沒人實作)、CRuby。

stack overflow#

frame stack 是有限的(典型 1 MB)。每次 call 推一個 frame ── 太深就爆。

實務:

if (vm->frame_count >= FRAMES_MAX) {
    runtime_error("stack overflow");
}

或讓 frame stack 動態擴展(malloc 新 chunk、用 segment)── 代價是寫起來複雜,但避免硬限制。

C function call interop#

VM 函式呼叫原生 C 函式

typedef Value (*NativeFn)(VM *vm, int nargs, Value *args);

case OP_CALL: {
    if (IS_NATIVE(f)) {
        NativeFn fn = AS_NATIVE(f);
        Value *args = vm->sp - nargs;
        Value result = fn(vm, nargs, args);
        vm->sp -= nargs + 1;     // 收掉引數 + function
        push(vm, result);
        break;
    }
    // 否則走一般 bytecode call
    ...
}

原生函式不開 frame(沒有 bytecode 要跑),直接呼叫 C 函式。但要小心:

  • 原生函式可能 trigger GC ➡️ 它持有的 Value 必須讓 GC 看到(透過 vm 的 stack)
  • 原生函式可能觸發 callback ➡️ 重新進入 dispatch(reentrancy)

反過來:C 呼叫 VM 函式#

embed VM 時很常見:

Value func = vm_get_global(vm, "my_func");
Value arg  = INT_VAL(42);
Value result = vm_call(vm, func, 1, &arg);

vm_call 在 C 層面 push 引數、呼叫 dispatch loop、等它 return。實作上常用一個 sentinel opcode(OP_HALT)讓 dispatch loop 退出,然後 C 層收回 result。

範例:fact(n) 完整流程#

def fact(n)
    return 1 if n <= 1
    n * fact(n - 1)
end

fact(3)

bytecode(stack VM 概念表示):

fact:
  L0: LOAD_LOCAL n       ;; 0: n
      PUSH 1
      LE
      JMP_IF_FALSE L1
      PUSH 1
      RETURN
  L1: LOAD_LOCAL n
      LOAD_LOCAL n
      PUSH 1
      SUB
      LOAD_GLOBAL fact
      CALL 1
      MUL
      RETURN

main:
      PUSH 3
      LOAD_GLOBAL fact
      CALL 1
      RETURN

執行 fact(3) 時 frame stack:

1. main 開始
2. CALL fact(3) → push fact frame, n=3
3. fact: 3 <= 1? false → JMP L1
4. push 3, 算 3-1=2, CALL fact(2) → push fact frame, n=2
5. fact: 2 <= 1? false → ...
6. push 2, 算 2-1=1, CALL fact(1) → push fact frame, n=1
7. fact: 1 <= 1? true → push 1, RETURN(pop frame, 結果 1 在 stack)
8. 回到 fact(2): mul → 2*1 = 2, RETURN
9. 回到 fact(3): mul → 3*2 = 6, RETURN
10. 回到 main: 結果 6

每個 RETURN 把 frame 砍掉、pc 回復、結果留在呼叫者 stack 頂。

C stack vs VM stack:兩條獨立的 stack#

很重要的概念:

  • VM stack = VM 內部的 frame 鏈,跟 VM 函式呼叫對應
  • C stack = host C 函式的呼叫鏈,dispatch loop 自己只佔一個 C frame

這意味著 VM 可以呼叫幾百萬層深的 VM 函式而不爆 C stack(因為 C 層只有一個 dispatch loop frame)。但如果 dispatch loop 是用遞迴 C 寫成的(tree-walker)── C stack 與 VM stack 一起增長,更容易爆。

例外:如果 VM 函式呼叫 native C 函式、native 又呼叫 VM 函式 ➡️ 這時 C stack 會跟著增加。reentrant 設計需要謹慎處理。

小結#

  • 函式 = bytecode + metadata,是 first-class object
  • frame 紀錄一次呼叫,主流是連續 frame stack
  • 引數傳遞:stack VM 用 stack pos、register VM 用 register window
  • TCO 把 tail position 的呼叫變成 jump,避免 frame 累積
  • VM stack 與 C stack 是兩條獨立的 stack(直譯器層級下)
  • 與原生 C 互通:natives 不開新 frame,但要注意 GC 與 reentrancy

下章看 closure 怎麼讓函式「捕獲外部變數」。