兩件事一起講#
對 VM 來說,「算術」與「控制流」是兩類 opcode,但它們共用同一套對 value 的操作模式:型別檢查、運算、push 結果。一起看比分開講更省篇幅,也更能對比。
算術:static vs dynamic#
對靜態語言(Java、C#),bytecode 知道準確型別:
JVM:
iadd ;; integer add,型別寫死在 opcode
ladd ;; long add
fadd ;; float add
dadd ;; double add對動態語言,型別是執行期才知:
通用 ADD opcode:
pop b
pop a
if (IS_INT(a) && IS_INT(b)) push INT_VAL(a+b)
else if (IS_DOUBLE(a) && IS_DOUBLE(b)) push DOUBLE_VAL(a+b)
else if (mixed) ... 強制轉型
else if (IS_STRING(a)) ... 字串連接
else ... 呼叫 obj.+ 方法
push result每條 ADD 都要做這串型別判斷。這就是「動態語言慢」的核心原因 ── opcode 看起來只一條,骨子裡是十幾條 branch。
通用 ADD 的實作#
case OP_ADD: {
Value b = pop();
Value a = pop();
if (IS_INT(a) && IS_INT(b)) {
push(INT_VAL(AS_INT(a) + AS_INT(b)));
} else if (IS_NUMBER(a) && IS_NUMBER(b)) {
push(DOUBLE_VAL(AS_NUMBER(a) + AS_NUMBER(b)));
} else if (IS_STRING(a) && IS_STRING(b)) {
push(OBJ_VAL(string_concat(a, b)));
} else {
runtime_error("invalid operands for +");
}
break;
}幾個取捨:
整數溢位#
C 的 int + int 溢位是 UB(signed)。VM 通常選一邊:
- 包裝進 long / bignum:CRuby、Python 的 int 自動 promote
- 無視溢位(silent wrap):JavaScript 的 number 是 double,不會溢位但會失精度
- 拋例外:少見,但 SmallTalk-72 等老語言用過
對 small int + small int ➡️ 大整數的場景:
int64_t r = (int64_t)a + (int64_t)b;
if (r > INT_MAX || r < INT_MIN) {
push(make_bignum(r)); // promote
} else {
push(INT_VAL((int)r));
}整數 + 浮點#
JavaScript:全部當 double。Lua / Ruby / Python:promote 整數到 double。
double a_d = IS_INT(a) ? (double)AS_INT(a) : AS_DOUBLE(a);
double b_d = IS_INT(b) ? (double)AS_INT(b) : AS_DOUBLE(b);
push(DOUBLE_VAL(a_d + b_d));字串 + 字串#
連接 vs 拒絕。Python 接受 "a" + "b" 拒絕 "a" + 1。Ruby 同。JavaScript 全接受("a" + 1 = "a1")── 著名爭議點。
語言設計取捨,VM 只是執行決定。
物件 + 物件#
對 Ruby、Python,落到 a.+ (b) 的方法呼叫 ── 這要走第八章的 method dispatch 機制。
算術 opcode 集#
最低限度的一組:
ADD, SUB, MUL, DIV, MOD ;; 算術
NEG ;; 一元負
NOT ;; 邏輯非
EQ, NEQ, LT, LE, GT, GE ;; 比較
AND, OR ;; 位元 / 邏輯
SHL, SHR ;; 移位對 register VM,每條會帶 register 編號:
ADD R0, R1, R2 ;; R0 = R1 + R2
LT R0, R1, R2 ;; R0 = R1 < R2 (boolean)對 stack VM,全是 0-arg 形式(從 stack 取):
ADD ;; pop b, a; push a + b
LT比較與相等#
== 在動態語言是個大坑:
1 == 1.0 # ? Ruby: true
"1" == 1 # ? Ruby: false; JS: true (==), false (===)
nil == false # ? Ruby: falseVM 通常分兩種比較:
OP_EQ:值相等(含跨型別轉換規則)OP_IS/OP_SAME:identity(同一物件)
或交給語言層的 method(CRuby 的 == 是 method)。
控制流:分支#
最基本兩條:
JMP offset ;; 無條件跳
JMP_IF_FALSE offset ;; pop 一個,如果 falsy 就跳offset 是相對於當前 pc 的有號偏移(4 byte 通常夠)。
if / else#
if cond
A
else
B
end
C編譯成:
... cond ...
JMP_IF_FALSE L1
... A ...
JMP L2
L1:
... B ...
L2:
... C ...while#
while cond
body
endL_start:
... cond ...
JMP_IF_FALSE L_end
... body ...
JMP L_start
L_end:for / range loop#
JIT 友善的設計把 induction variable 顯式:
;; for i in 1..10 do ... end
PUSH 1 ;; i = 1
STORE_LOCAL i
L_start:
LOAD_LOCAL i
PUSH 10
LE
JMP_IF_FALSE L_end
... body ...
LOAD_LOCAL i
INC
STORE_LOCAL i
JMP L_start
L_end:break / continue#
把 loop 起點與終點壓進編譯期的 loop stack;遇到 break emit JMP <end-of-loop>、continue emit JMP <top-of-loop>。
但如果 break 出多層 loop(break 2、Ruby 用 closure / catch…),實作就變複雜 ── 對 closure 中的 break 需要拋例外形式(throw + catch)。
短路求值(&& / ||)#
不能單純當二元算術:
a && b // 如果 a 是 false,不算 b
a || b // 如果 a 是 truthy,不算 b
bytecode 用 jump:
;; a && b
... a ...
DUP ;; 留一個 a 在 stack 上
JMP_IF_FALSE L_end ;; a falsy → 結果就是 a
POP ;; 否則扔掉 a
... b ... ;; 算 b(結果就是 b)
L_end:或用 JMP_IF_FALSE_KEEP 一條 opcode 把上面合成。
三元 / 條件運算#
cond ? a : b跟 if/else 同構,只是 stack 留一個結果而非執行 statement。
switch / case#
樸素編法:每個 case 一條比較 + JMP_IF。最差 O(n) 比較。
優化:
- 跳躍表(jump table):case 是密集連續整數時
- 二分查找表:case 多但稀疏
- 完美哈希:CRuby 對符號的 case
CPython 3.10+ 引入 MATCH_* opcode 處理結構式 match。Java 7+ 對 String switch 用 hashCode + 線性比對。
異常處理#
例外是「特殊的非局部跳躍」。實作有兩派:
Try-table(每個 method 一張表)#
method start
... ...
L_try_start:
... 可能拋例外的程式碼 ...
L_try_end:
JMP L_after
L_handler_X:
... handler ...
JMP L_after
L_after:
method end
exception_table:
[L_try_start, L_try_end, ExceptionTypeX, L_handler_X]
...raise 時,VM 從當前 pc 反查表 ── 找到 handler 就跳過去;沒有就 unwind 一個 frame 再找。代表:JVM、Java bytecode Code attribute。
顯式 try / catch opcode#
每個 try 區塊在 stack 推一個 handler frame,catch 時 pop。樸素但運行期開銷較大。
效能:分支預測再次出現#
JMP_IF_FALSE 是 indirect branch 嗎?不是 ── target 是編譯期固定的 offset,所以是 direct conditional branch。CPU 可以預測「真/假」的傾向,模式穩定(迴圈條件)時準確率近 100%。
但動態 method dispatch 是 indirect。看到這種:
LOAD self
LOAD method_name
INVOKE ;; 跳到誰?要 lookupdispatch 的 indirect 比一般 branch 難預測得多 ── 第八章物件模型會深入 inline cache 怎麼救。
opcode 設計實例#
不同 VM 的算術 + 控制流 opcode 數:
| VM | 算術 + 比較 opcode | 分支 opcode |
|---|---|---|
| JVM | ~50(型別特化) | 8(按比較型別) |
| CPython | 1 BINARY_OP | 5 |
| Lua | ~10(含特化) | ~6 |
| mruby | ~15 | 5 |
| Wasm | ~80(含 SIMD) | 10 |
JVM 的型別特化 opcode 看似浪費(iadd / ladd / fadd / dadd 四條做加法)── 但這讓直譯器幾乎不需要 runtime 型別檢查,速度遠優於通用 ADD。
CPython 從 3.11 開始用 BINARY_OP 一條開頭,在 runtime 觀察到型別後 specialize 成 BINARY_OP_ADD_INT / BINARY_OP_ADD_FLOAT 等變體 ── 兩派的折衷。
完整例子:實作 n.times { puts i }#
10.times { |i| puts i }bytecode 概念展開(簡化):
PUSH 10
GETSEND :times ;; method name
SEND 0 (1 block) ;; 帶一個 block
;; block 裡:
block_start:
LOAD_BLOCK_ARG 0 ;; i
PUSH_GLOBAL :puts
SEND 1
RETURN
RETURN這已經涉及 block / closure(第七章)+ method dispatch(第八章)。算術與控制流是基礎,真實程式幾乎每幾條 opcode 就會碰到上層概念。
小結#
- 算術 opcode 在動態語言裡是「型別 check + 運算」的縮寫
- 加法的細節:整數溢位、int + double、字串連接、物件方法呼叫
- 控制流靠 conditional / unconditional JMP,offset 編譯期決定
- 短路
&&/||必須用 JMP,不能當二元運算 - 例外有 try-table 與 explicit opcode 兩派
- direct branch 預測友善;method dispatch 的 indirect branch 才是真的痛
接下來看「函式與 frame」── 把這些 opcode 包進 function call。