問題#
動態語言的 stack 上可能放任何東西:
arr = [1, 3.14, "hello", :sym, true, nil, MyClass.new]VM 內部怎麼用一個 C 型別表示這些?這是動態語言實作最基礎的設計決策,影響:
- 每個 value 的記憶體大小(直接乘以 stack 深度、object 欄位數)
- 運算速度(型別判斷的成本)
- GC 必須能識別「這是不是指標」
方案 1:tagged union(最直觀)#
typedef enum { VAL_NIL, VAL_BOOL, VAL_INT, VAL_DOUBLE, VAL_OBJ } ValueType;
typedef struct {
ValueType type;
union {
bool boolean;
int64_t integer;
double number;
Object *obj;
} as;
} Value;特點:
- 結構大小:64-bit 平台上 16 bytes(type tag 4 bytes + padding 4 + union 8)
- 型別檢查直接
value.type == VAL_INT - 直觀、跨平台
#define INT_VAL(i) ((Value){.type = VAL_INT, .as.integer = (i)})
#define BOOL_VAL(b) ((Value){.type = VAL_BOOL, .as.boolean = (b)})
#define OBJ_VAL(p) ((Value){.type = VAL_OBJ, .as.obj = (p)})
#define IS_INT(v) ((v).type == VAL_INT)
#define AS_INT(v) ((v).as.integer)代表:早期 Lua、Crafting Interpreters 第一階段、許多教學 VM。
缺點:
- 空間翻倍:每個值要 16 bytes(payload 只 8 bytes)。對 stack 深的程式吃記憶體
- 每次取值要走兩級 indirection(讀 type、讀 union)
方案 2:tagged pointer#
利用「指標一定對齊到 8 / 16 byte」── 指標的低幾位永遠是 0。把這幾個 bit 用來放 tag:
typedef uintptr_t Value; // 64-bit 機器是 8 byte
#define TAG_BITS 3 // 低 3 bits
#define TAG_MASK 0x7
#define TAG_OBJ 0
#define TAG_INT 1
#define TAG_BOOL 2
#define TAG_NIL 3
#define IS_INT(v) (((v) & TAG_MASK) == TAG_INT)
#define AS_INT(v) ((int64_t)(v) >> TAG_BITS) // 算術右移帶符號
#define INT_VAL(i) ((Value)((i) << TAG_BITS) | TAG_INT)
#define IS_OBJ(v) (((v) & TAG_MASK) == TAG_OBJ)
#define AS_OBJ(v) ((Object*)(v)) // 低 3 bits 已經是 0
#define OBJ_VAL(p) ((Value)(p))特點:
- 結構大小:8 bytes
- INT 失去 3 bits(變 61-bit signed integer)
- 每次存取 INT 要 shift(一條 cycle)
代表:CRuby(YARV)、JavaScriptCore(部分)、CLR、Dalvik。
CRuby 的 VALUE:
低 1 bit = 1 → Fixnum(左移 1 位)
低 2 bit = 10 → Flonum(特殊壓縮 double)
0 / 2 / 4 / 6 → 指標(依 alignment 不同)
specific 常數 → Qfalse=0、Qtrue=20、Qnil=8(神奇魔法數)對 small int、特殊常數(true/false/nil)零分配,效能極好。
方案 3:NaN-boxing(最巧妙)#
利用 IEEE 754 雙精度浮點的特性:NaN 有 2^52 種表示,正常數值用不到那麼多。把不是 NaN 的 64-bit 視為 double,把 NaN 的 payload 拿來放型別 tag + 指標 / 整數。
IEEE 754 雙精度結構#
| sign(1) | exponent(11) | mantissa(52) |NaN:exponent 全 1、mantissa 不全 0。
0xFFF0_0000_0000_0000 ← -infinity
0x7FF0_0000_0000_0000 ← +infinity
0xFFF8_0000_0000_0001 ~ 0xFFFF_FFFF_FFFF_FFFF ← Quiet NaN(含 51-bit payload)我們把這 51 bits 拿來:
0xFFF8_0000_0000_0000 QNaN bits(固定)
0xXXXX_YYYY_YYYY_YYYY 高 16 bits 是 type tag、低 48 bits 是 payload48 bits 剛好能放下一個 user-space 指標(多數 64-bit OS 只用 48-bit 虛擬位址)。
#define QNAN 0x7FFC000000000000ULL
#define SIGN_BIT 0x8000000000000000ULL
#define TAG_NIL 1
#define TAG_FALSE 2
#define TAG_TRUE 3
typedef uint64_t Value;
#define IS_NUMBER(v) (((v) & QNAN) != QNAN) // 不是 NaN,是 double
#define AS_NUMBER(v) (*(double*)&(v))
#define NUMBER_VAL(d) (*(uint64_t*)&(d))
#define NIL_VAL ((Value)(QNAN | TAG_NIL))
#define FALSE_VAL ((Value)(QNAN | TAG_FALSE))
#define TRUE_VAL ((Value)(QNAN | TAG_TRUE))
#define IS_OBJ(v) (((v) & (QNAN | SIGN_BIT)) == (QNAN | SIGN_BIT))
#define AS_OBJ(v) ((Object*)((v) & ~(QNAN | SIGN_BIT)))
#define OBJ_VAL(p) ((Value)(QNAN | SIGN_BIT | (uint64_t)(uintptr_t)(p)))優點:
- 結構 8 bytes
- double 是原生表示,零開銷取值
- 指標是 48-bit 直接拿,零開銷
- 整數可內嵌在 NaN payload,零分配
缺點:
- 編碼複雜
- 在某些 ARM 平台上 48-bit 假設不總成立
- Debug 較痛苦(看不出記憶體 dump 是什麼)
代表:SpiderMonkey(Firefox)、LuaJIT、JavaScriptCore(部分)、Wren、mruby/c。Crafting Interpreters 第二階段優化用這個。
方案 4:boxed everything#
最簡單但最慢:每個值都是堆上物件:
struct Value {
Type type;
union { ... };
};
Value *make_int(int i); // malloc 一個
代表:早期 Smalltalk、教學語言。優勢只有「同質」── 任何地方都是 Value*。GC 壓力極大、cache locality 極差。沒人會這樣設計生產級 VM。
整數的特例:small int 內嵌#
對動態語言,整數使用率極高(迴圈 counter、index)。如果把每個 int 都堆配置 ➡️ 效能災難。所以幾乎所有現代 VM 都把小整數內嵌在 value 裡:
| VM | 整數表示 |
|---|---|
| CRuby Fixnum | tagged:63-bit signed |
| CPython int | 物件,但 -5..256 是預先配置的 immortal |
| V8 Smi | tagged:31-bit signed(32-bit V8)/ 32-bit (64-bit V8) |
| JSC | NaN-boxing 中的 int32 |
| Lua 5.3+ | tagged union 中的 int64 |
bignum(任意大小)超過 small int 範圍時 promote 到堆物件。Ruby 的 Bignum、Python 的任意大整數都是這樣處理。
浮點數:double 的特殊地位#
NaN-boxing 把 double 放第一公民。tagged pointer / tagged union 體系下,double 通常 boxed:
struct Float {
Object obj;
double value;
};為了避免這個分配,CRuby 引入 flonum ── 把 double 的 mantissa 截短,塞進 tagged value。代價是少幾 bit 精度,益處是大量浮點運算不分配。
V8 有 “double field unboxing”:物件如果欄位永遠是 double,存原生 double 而非 boxed。
tag 配置例子#
64-bit value 拿出 3-bit tag 的常見方案:
000 obj pointer
001 fixnum (61-bit signed)
010 symbol
011 immediate constants (true/false/nil/undefined ...)
100 small string (7-byte ASCII inline)
101 ...
110 ...
111 ...看起來簡單,實際 trade-off 一堆:
- 整數要不要符號擴展(讀的時候 shift)?
- nil 要不要單獨 tag(vs immediate)?
- 字串要不要嘗試 inline(4~7 byte 短字串可塞進 tag)?
每個 trade-off 對應一個 benchmark 上幾個百分點。
value 與 GC 的互動#
GC 必須回答:「這個 64-bit 是不是指標?」── 各方案的處理:
| 方案 | GC 怎麼判斷 |
|---|---|
| Tagged union | 看 type 欄位 |
| Tagged pointer | 檢查 tag bits |
| NaN-boxing | 檢查是否是 NaN + sign bit |
| Conservative | 把任何看起來像對齊指標的 64-bit 當作可能指標 |
最後一個叫 conservative GC(如 Boehm GC)── 不需要 VM 配合,就能掃 C stack。代價是會錯把整數當指標 ➡️ 多保留一些其實死的物件。
GC 也決定哪些 value 變動需要 write barrier:
- 指標寫入老物件 ➡️ 需要 barrier 通知 generational GC
- 純整數更新 ➡️ 不需要
效能影響量級#
對動態語言密集計算(數字 + 物件混合):
| 方案 | 相對速度 | 記憶體(每 value) |
|---|---|---|
| Boxed all | 1.0x | 16~24 bytes |
| Tagged union | 2~3x | 16 bytes |
| Tagged pointer | 3~4x | 8 bytes |
| NaN-boxing | 3~5x | 8 bytes |
加上 GC 壓力的差異,實際差距可能更大。
該選哪個#
決策樹:
是否優先寫起來簡單?
├ 是 → tagged union(教學 VM、原型、嵌入式)
└ 否 → 主要型別是浮點?
├ 是 → NaN-boxing
└ 否 → tagged pointer(指標 + 整數為主)實務上:
- 學寫 VM、玩具語言 ➡️ tagged union(之後再升級)
- 想榨效能、有時間調 ➡️ NaN-boxing
- 嵌入式、嚴格控制記憶體 ➡️ tagged pointer 或 NaN-boxing
- 教學 / 除錯 ➡️ tagged union
小結#
- value 表示是動態語言 VM 最基礎的決策,影響整個系統
- 四種主流:boxed / tagged union / tagged pointer / NaN-boxing
- 選擇依:簡單度、效能要求、記憶體預算、目標型別分布
- GC、frame 大小、stack 深度都會反過來受 value 表示影響
下一章看 opcode 怎麼用這些 value 做算術與分支。