函式 = 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 stack。backtrace、stack overflow、return 全靠這條鏈。
兩種 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 的「引數對齊」工作:
- 收到的位置引數數量
- 與 function metadata 對比預期形狀
- 不足的填預設值(從 constants 取)
- 多餘的塞進 args list
- keyword args 塞進 kwargs dict
CPython 的 KW_NAMES、CALL_FUNCTION_KW、CALL_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
endf 的最後動作是呼叫 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 怎麼讓函式「捕獲外部變數」。