兩件事一起講#

對 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: false

VM 通常分兩種比較:

  • 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
end
L_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          ;; 跳到誰?要 lookup

dispatch 的 indirect 比一般 branch 難預測得多 ── 第八章物件模型會深入 inline cache 怎麼救。

opcode 設計實例#

不同 VM 的算術 + 控制流 opcode 數:

VM算術 + 比較 opcode分支 opcode
JVM~50(型別特化)8(按比較型別)
CPython1 BINARY_OP5
Lua~10(含特化)~6
mruby~155
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。