作者自己把話講死:「this is not safer for being in Rust,probably slightly unsafer than the C。」整份移植塞進 2015 個 unsafe block,不是因為翻得草率,而是因為 runtime 就是 GC——它的工作本來就是繞過型別系統去搬動活指標。
逐行把 OCaml runtime 從 C 翻成 Rust——一場不重寫的遷移
mbacarella 的 rustcaml 做了一件聽起來矛盾的事:把 OCaml 4.14 的整個 C runtime——71 個 runtime/*.c、約 40,000 行——逐行翻成 Rust,而且刻意「翻」而不是「重寫」。目標不是寫出一份 idiomatic、借用檢查器點頭的 Rust runtime,而是保留 C 的結構與語意到「能持續 track upstream 改動」的程度。每翻完一個檔案就用未改動的官方 compiler test suite 跑一遍,per-file 的 build toggle 讓你能在 C 版與 Rust 版之間逐檔切換,git 每翻一檔 commit 一次以便 bisect。約七天、一個人、配合 Claude Code 完成。這篇拆的是它的內部結構:OCaml 的值怎麼編碼、GC 為何天生跟 borrow checker 為敵、unsafe 落在哪裡、哪些 C 慣用法在 stable Rust 根本沒有對應,以及「逐行」這個約束到底換來什麼。
一個機器字:tagged integer 與 boxed block 的二選一
先把「逐行」的工作流講清楚,因為它是整個專案能成立的支點。作者沒有一次重寫整個 runtime,而是建立了一個 per-file 的 build toggle:每個 runtime/foo.c 都有一個對應的 foo.rs,build system 用一個 flag 決定這一檔用 C 版還是 Rust 版 link 進去。於是流程變成一個機械的循環——挑一個還沒翻的 .c,逐行翻成 .rs,把 toggle 切到 Rust 版,跑未改動的官方 compiler test suite,全綠就 commit,紅了就在這一檔內 debug。71 個檔、約 40,000 行,每一檔都是一次「翻譯 → 切換 → 驗證 → commit」。這個結構的價值在於:任何時刻你手上都是一個 C 與 Rust 混合、但完整可運作的 runtime,而且任何迴歸都能用 git bisect 收斂到單一檔案的單一 commit——這是一份 big-bang 重寫永遠拿不到的安全網。
要理解這場移植為什麼充滿 unsafe,得先理解 OCaml 怎麼在記憶體裡擺一個值。OCaml 的每個值都是一個機器字(64-bit 平台上 8 byte),而這個字同時要能表示「一個小整數」或「一個指向 heap block 的指標」。判別方法是看最低位 bit:bottom bit 是 1,這個字就是 immediate integer,上面 63 個 bit 是有號整數;bottom bit 是 0,這個字就是 pointer,因為所有 heap block 都對齊到 4 或 8 byte、最低位天生是 0。作者在 thread 裡的原話:「if the bottom bit is set to 1, it knows the top 63-bits are an integer」、而 pointer「require no decoding to use」——指標完全不用解碼就能直接 deref,只有 immediate int 需要一次算術右移把 tag bit 抹掉。
下面這個 widget 把同一個 64-bit 字攤開來標註。OCaml 的值表示不是一個 struct,而是「同一塊 bit 在不同情況下代表完全不同的東西」這種 type erasure——這正是 Rust 型別系統最不喜歡的形狀。點任一區塊,看它在「C 怎麼讀」與「Rust 必須怎麼讀」兩種視角下分別是什麼。
click any field to read its role · 4 fields across two value shapes
click a field above
tag bit(bit 0)· 責任
整個值表示系統的軸心。bit 0 = 1 代表這個字本身就是值(immediate);bit 0 = 0 代表它是指標。heap block 對齊到 8 byte,地址最低三位天生是 0,所以「指標的 tag bit 永遠是 0」是免費的——不用額外空間存型別標記。
C 怎麼讀:Is_long(v) 巨集就是 ((v) & 1)。Rust 必須:把 value 定義成 usize(不是 enum),手動 v & 1 判別——型別系統幫不上忙,因為它不是 tagged union。
63-bit integer payload · 責任
承載小整數本身。OCaml 的 int 因此是 63-bit 而非 64-bit——犧牲一個 bit 換來「整數不必 box、不進 GC、deref 一個 int 不會誤把它當指標」。Val_long(n) = (n << 1) | 1 編碼,Long_val(v) = v >> 1 解碼(算術右移保留符號)。
翻譯落點:這層是純算術,Rust 翻起來最乾淨,(n << 1) | 1 直接對應,幾乎不需要 unsafe。痛點不在這裡。
header word · 責任
每個 boxed block 在 field[0] 之前有一個 header word,packing 了三件事:block 的 size(幾個 word)、GC 的 color(白/灰/黑,mark-sweep 用)、以及 tag(這個 block 是 tuple、string、closure、custom 哪一種)。指標指向 field[0],要拿 header 得 Hd_val(v) = ((header_t *)(v))[-1]——往回讀一個 word。
Rust 痛點:「往指標前面一個 word 讀」是 ptr.offset(-1),越界於任何 Rust 認得的 allocation 之外,必然 unsafe。GC 還會就地改寫 color bit——這是 runtime 對「不可變引用底下的記憶體」做 mutation,borrow checker 的反面教材。
block fields · 責任
實際的 payload word。每個 field 本身又是一個 OCaml value——可能是 immediate int、也可能是指向另一個 block 的指標。runtime 對 field 的存取(Field(v, i))本質上就是 ((value *)v)[i] 這種 raw pointer 索引。
Rust 痛點:每一次 Field(v, i) 都是 raw pointer deref,作者的原話是「every field access is ~a raw pointer deref」。type erasure 讓 Rust 沒有任何 struct 型別可以對應——只能是 *mut usize 上的索引,全程 unsafe。
互動圖表
OCaml 的每個值是一個機器字,bit 0 = 1 表示 63-bit 整數,bit 0 = 0 表示堆積指標,Rust 的型別系統無法靜態表達。
這張圖解釋了整場移植的根本張力。OCaml 的值表示是一種故意的 type erasure:所有東西都壓成一個 usize,靠一個 bit 跟一個 header word 在 runtime 動態分辨。Rust 的整個賣點是反過來的——把型別資訊靜態化、讓編譯器在編譯期擋掉非法存取。runtime 要做的恰恰是 Rust 設計來防止的事:把無型別的 word 當成指標 deref、往 allocation 邊界外讀 header、在 immutable view 底下改寫 GC color。所以「翻譯」這個詞要照字面理解——C 的 value typedef 變成 Rust 的 type Value = usize,C 的 Field(v,i) 巨集變成一個 unsafe fn,C 的指標算術變成 (v as *mut usize).offset(i)。語意一一對應,安全性也一一對應——也就是說,都不安全。
這個編碼選擇不是 OCaml 獨有的奇技,但它把後果推到極致。把整數做成 immediate、最低位當 tag,意味著小整數永遠不進 heap:它們不被 allocate、不被 GC 掃描、不需要 deref。代價是 OCaml 的 int 只有 63-bit、而且每次算術都要付出 shift 的成本(a + b 在 boxed 表示下是 a + b,在 tagged 表示下要寫成 a + b - 1 來抵銷兩個 tag bit)。這個 trade 在 functional 語言裡非常划算——大量的小整數、cons cell、option、變體建構子都因此免去 allocation。但對 runtime 的移植者來說,它把「每個 word 都可能是兩種東西之一」這件事釘進了每一行存取程式碼,沒有任何地方能逃進「這裡一定是指標」或「這裡一定是整數」的靜態保證。
header word 本身還藏著一個 Rust 特別難受的細節:它在 field[0] 的前面,而指標指向的是 field[0]。也就是說 runtime 持有的每個 block pointer,其合法可讀範圍其實是 [ptr - 1 word, ptr + size words)——起點在指標之前。Rust 的 *mut T 沒有「往負方向也合法」的概念;ptr.offset(-1) 在 miri 底下會直接判 undefined behavior,除非那塊記憶體是 runtime 自己用裸 allocator 配出來、且明確知道前一個 word 屬於同一個 allocation。逐行翻譯選擇的就是後者:runtime 自管 heap,header 與 fields 是同一塊 raw allocation,Hd_val 的 [-1] 在 unsafe 裡照抄。OCaml 還有一個 Double_array 的特例——float array 為了避免每個 float 都 box,整個 array 用沒有 tag 的 raw double 連續存放,header 的 tag 標成 Double_array_tag,存取時走完全不同的 path。這種「同一個 Field 介面、底下 layout 卻不同」的 special case,正是 type erasure 在 runtime 裡製造的複雜度。
bottom bit 是個 discriminator——拖著它在 int 與 pointer 之間翻轉
判別只看一個 bit,這件事值得自己動手玩一次才有體感。下面的滑桿掃過一個 raw word 的數值;每動一格,readout 告訴你這個 bit pattern 在 runtime 眼裡是 immediate int 還是 pointer,並用對應的解碼規則把它還原。注意最低位 bit 的開關效果是 binary 的——它不是「漸變」,而是把整個字的解讀瞬間切換到另一個世界。
drag the value, toggle bit 0 · watch the same word flip between int and pointer
同一個數值,bit 0 一翻,runtime 對它的處置完全不同——這也是為什麼 Rust 無法用 enum 對應:t…
同一個 word,bit 0 切換使整字瞬間從 63-bit 有號整數解讀切換為直接 deref 的堆積指標,不需任何解碼。
關鍵不在算術——n << 1 | 1 翻成 Rust 毫無困難。關鍵在判別之後:一旦 runtime 認定某個 word 是 pointer,它要做的所有事都越過 Rust 的安全邊界。它把這個 usize cast 成 *mut usize,往前讀 header(在任何 Rust allocation 之外),往後索引 field,而且這些指標還會在 GC 期間被搬走、改寫。作者因此給出那句結論:「It's actually merely as hard as the C version if you use unsafe。」逐行翻譯的意義在這裡浮現——你不是在跟 Rust 的型別系統協商一個安全抽象,你是在用 unsafe 把 C 的記憶體模型原封不動搬過來,讓 diff 能對得上 upstream。
這裡值得停下來區分兩種「用 Rust 寫 runtime」的路線,因為它們的目標完全相反。一條是idiomatic 重寫:重新設計值表示、用 enum 或 NonNull wrapper 把 tag 變成型別系統認得的 discriminant、把 GC 包進一個 safe 的抽象(像 gc-arena 那種 crate 的做法),結果是一份 Rust 程式設計師會點頭的程式碼,但跟 OCaml 的 C runtime 已經沒有逐行對應關係。另一條是 rustcaml 選的transliteration:value 就是 usize、Field 就是 unsafe fn、巨集就是 #[inline] 的 unsafe fn,連 C 的變數命名與註解都盡量保留。重寫路線追求的是「更安全、更 Rust」,transliteration 路線追求的是「能跟 upstream 同步、能 bisect、能用既有 test suite 當 oracle」。兩條路線沒有對錯,只是回答不同的問題——而這篇講的這份移植,從第一行就明確選了後者。
選擇 transliteration 還有一個常被忽略的副作用:bug 的形狀也一起被保留了。如果 upstream C runtime 有一個靠 UB 僥倖跑對的角落(例如 strict-aliasing 邊緣、signed overflow),逐行翻過來的 Rust 會把同一個僥倖也帶過來,而不是「因為 Rust 比較嚴格所以順手修掉」。這既是缺點(你沒有趁機消滅 bug)也是優點(你沒有引入新的行為差異)——對「跟 C trunk byte-identical」這個驗收標準而言,後者才是要的:任何行為偏移都會在 fixpoint test 裡現形。
GC 是 borrow checker 的天敵——拖著一個值穿過 collection
真正讓「safe Rust runtime」這個想法破產的是 GC 本身。作者講得很白:「The runtime is the GC. It mutates and moves live pointers during collection。」OCaml 用的是 generational、moving collector:minor heap 滿了觸發 minor collection,存活的物件被複製到 major heap,原地址作廢,所有指向它的指標都要被改寫成新地址。這是一個會「搬動別人正持有的記憶體、並偷偷改寫指向它的所有指標」的操作——borrow checker 的整個存在意義就是禁止這種事。
下面把一個值放在 minor heap,拖著它穿過一次 collection。看它被複製到 major heap 後,原本指向它的 root(暫存器、stack、其他 block 的 field)如何被 runtime 逐一改寫成新地址。在 Rust 裡這意味著:你手上同時有「對舊位置的 raw pointer」「對新位置的 raw pointer」「以及一堆需要被原地改寫的、別處持有的指標」——沒有任何 borrow 規則能描述這個狀態。
drag the live object from minor heap into major heap · watch every pointer to it get rewritten · 1 moving collection
moving collector 的核心動作:複製存活物件、作廢舊地址、改寫所有指向它的指標
OCaml 的 moving GC 複製物件並改寫所有指向舊地址的 root 指標,這與 borrow checker 的別名規則根本互斥。
這也解釋了為什麼那 2015 個 unsafe block 不是工程缺陷,而是問題的本質。GC 的活動——複製、改寫指標、就地改 header 的 color bit——全都是「在多個別名指標之間移動資料」,這正是 Rust 用 &mut 的獨佔性所禁止的。Rust 的借用模型核心是「同一時間,要嘛多個 &、要嘛恰好一個 &mut」;而 GC 在 collection 期間恰恰握著一個物件的多個別名(root、其他 block 的 field、它自己的 forwarding pointer),還要對它們全部寫入。沒有任何 lifetime 標註能描述「這些別名暫時失效、值被搬到別處、然後所有別名被改寫指向新位置」。這不是「Rust 太囉嗦」,而是 Rust 的安全保證跟 moving GC 的語意在定義上互斥。
多核版的 GC 機制更把這點推到極限。OCaml 的並行 runtime 需要 lock-free 的資料結構來協調多個 domain 的 minor collection 與 major heap 共享;作者特別提到 lock-free 的部分用 skip-list 而非 tree 實作,理由是「The concurrent, lock-free part is actually doable with a skip-list in a way that would be extremely hard if it was a tree」。skip-list 的 lock-free insert 只需要在少數幾個 forward pointer 上做 CAS,不像平衡樹要處理 rotation 跨多節點的原子性;這個選擇在 C 裡就成立,翻成 Rust 時也照抄——但它依賴的 AtomicPtr + 裸指標 traversal 同樣全程 unsafe,因為節點之間的 aliasing 是 lock-free 演算法的前提。Rust 著名的「learning Rust with too many linked lists」教材講的就是這種痛:連單向 linked list 在 safe Rust 裡都要 Rc<RefCell<…>> 繞一大圈,何況是 lock-free skip-list。結論還是那句:「merely as hard as the C version if you use unsafe」。Rust 沒有讓 GC 變安全,它只是讓你能把 C 的 GC 一行一行抄過來,並且——這是逐行翻譯真正的回報——讓抄過來的結果能跟 upstream 的 C 做 diff。
還有一個容易被忽略的約束:runtime 必須講 C ABI。OCaml 編譯器產出的 native code、以及所有用 C stub 寫的 binding,都假設 runtime 的函式遵循 C 的呼叫慣例與 symbol naming。作者點出 Rust 沒有自己的 stable FFI——extern "C" 是唯一能跟外界對接的方式。這意味著 runtime 的對外介面全部要標 #[no_mangle] extern "C",參數與回傳值的記憶體佈局要跟 C 那邊位元對齊,任何 Rust 的 niche optimization(例如把 Option<NonNull> 壓成一個指標寬度)都不能用在跨 ABI 邊界的型別上。這層 ABI 約束加上 GC 的別名需求,等於從兩端把「safe Rust」的可能性夾死。
那些 stable Rust 根本沒有的 C 慣用法
除了 GC 與值表示,runtime 還大量用 C 的非標準慣用法——其中好幾個 stable Rust 沒有對應物,只能用 inline assembly 補。下面這張對照表把主要的幾個列出來:每一個都是「C 有、Rust(stable)沒有」的 gap,以及移植採用的補法。
loop { match … },nightly 才有 become(explicit tail call)重建 threaded dispatch。其餘四個都得跌進 inline assembly。五個 C 慣用法的 Rust 對照
computed goto、TLS、setjmp 在 stable Rust 均無對應,須改用 loop+match 或 inline assembly 補足。
computed goto 值得多講一句,因為它直接決定了那個 bytecode 慢 1.44x 的數字。OCaml 的 bytecode interpreter 用 computed goto 把「執行完一個 opcode 後跳到下一個 opcode 的 handler」變成一個對 branch predictor 友善的 indirect jump。重點在於 branch predictor 怎麼學:在 threaded code 裡,每個 opcode handler 末尾各有自己的 indirect jump,predictor 因此能對「OP_PUSH 之後常接 OP_ADD」這種 opcode pair 建立 per-site 的歷史。一旦退回單一 loop { match },所有 dispatch 收斂到同一個 switch jump,predictor 只看到一個高度多態的跳點,命中率塌下來。stable Rust 沒有 &&label 也沒有 goto *p,只能用 loop { match opcode { … } };Rust nightly 的 become(explicit tail call)讓每個 opcode handler 尾呼叫下一個,把 dispatch 分散回各 handler,重建了 threaded dispatch,這就是為什麼 nightly 的 bytecode 反而比 C 快 5%。
這個現象不是 OCaml 獨有。CPython 最近也踩過同一個坑——它的 tail-call interpreter 一度被宣傳為純贏的優化,後來 nelhage 的分析指出那其實大部分是補回了之前因為某個 LLVM 版本的迴歸而失去的 computed-goto 效益,而非憑空的新增益。把這兩件事擺在一起看,結論很清楚:interpreter 的效能瓶頸常常不在「指令做什麼」,而在「dispatch 怎麼跳」——而 dispatch 形狀又被你用的語言能不能表達 threaded code 決定。rustcaml 的 stable/nightly 落差因此成了一個乾淨的對照實驗:同一份翻譯、唯一變數是 dispatch 能不能 tail-call,慢的那 44% 就精準歸因到 computed goto 的缺席。
// C:computed goto——dispatch 是分散的 indirect jump,predictor 友善
static void *table[] = { &&OP_ADD, &&OP_PUSH, ... };
goto *table[*pc++];
OP_ADD: /* ... */ goto *table[*pc++];
OP_PUSH: /* ... */ goto *table[*pc++];
// Rust stable:loop + match——dispatch 收斂成一個集中 branch
loop {
match bytecode[pc] {
OP_ADD => { /* ... */ }
OP_PUSH => { /* ... */ }
}
pc += 1;
}
// Rust nightly:become(explicit tail call)——重建 threaded dispatch
fn op_add(st: &mut St) { /* ... */ become dispatch(st); }
fn op_push(st: &mut St) { /* ... */ become dispatch(st); }
逐行翻譯換來什麼——以及實測落在哪
把這些拼起來就看得出「逐行」這個約束的回報結構。它不換來安全——作者反覆強調這份 Rust 比 C 略不安全。它換來的是兩件事:第一,可 diff 性。因為每個 .rs 檔對應一個 .c 檔、語意一一對應,upstream OCaml 改了 runtime 的某一行,你能在對應的 Rust 檔找到該改的那一行——這讓「跟上 4.14 之後的 bugfix」成為可能,而一份 idiomatic 重寫做不到(重寫之後 upstream 的 diff 無處安放)。第二,漸進信心。per-file toggle + 每檔一個 commit + 每檔跑完整 test suite,意味著任何時刻你都有一個可運作的混合 build,迴歸能 bisect 到單一檔案。
驗證的嚴謹程度值得記下:未改動的官方 compiler test suite、fixpoint test(compiler 編譯自己兩次、兩份 binary 必須 byte-identical)、Sandmark benchmark 的 A/B、TSan(thread sanitizer)、以及一整個 opam switch build 加社群套件(iter、gen、dune、digestif 等)。下表是實測——點欄位標題排序。native 落在 C trunk 的 0.865x–1.131x 之間(作者形容「eh, just about parity, kinda」),bytecode 在 stable 慢 1.44x,但 nightly 用了 explicit tail call 之後反而快 5%。
click a column header to sort · 4 configs × wall-clock vs C trunk
| 配置 | 模式 | best | worst | 代表值 (×C) |
|---|---|---|---|---|
| Rust stable | native | 0.865 | 1.131 | 1.05× |
| Rust stable | bytecode | 0.791 | 1.810 | 1.44× |
| Rust nightly + ETC | native | 0.95 | 1.05 | 1.00× |
| Rust nightly + ETC | bytecode | 0.91 | 0.91 | 0.91× |
互動圖表
stable bytecode 慢 1.44× 因缺 computed goto;nightly explicit tail call 讓它反而快 5%。
幾個落點值得讀進去。native 的 stable 配置代表值約 1.05x,最壞 1.131x、最好甚至比 C 快(0.865x)——native code 本來就由 OCaml 編譯器產出,runtime 只是被呼叫的 support code,所以翻譯成 Rust 的影響有限。bytecode 是另一回事:interpreter 的 hot loop 直接被 computed-goto 的缺席拖累,stable 慢 1.44x,worst case 到 1.810x。nightly 用 become 重建 threaded dispatch 後,bytecode 反而 0.91x(快 5%)——這個反轉精確印證了「慢的原因是 dispatch 形狀,不是 Rust 本身」。值得提醒讀者:這份移植鎖定 OCaml 4.14,因為 5.x 的 runtime 為了 shared-memory 平行而整個重寫了 GC,逐行對照的基準會完全不同。
fixpoint test 特別值得理解它為什麼是個強 oracle。OCaml 編譯器是 self-hosting 的——它能拿自己編譯自己。fixpoint 的意思是:用 stage-1 編譯器編出 stage-2 編譯器,再用 stage-2 編出 stage-3;如果 runtime 的行為完全正確,stage-2 與 stage-3 的 binary 必須 byte-for-byte 相同。任何 runtime 的細微行為偏移——某個 hash 順序變了、某個 float 的捨入差一個 ulp、GC 觸發時機改變導致 finalizer 順序不同——都可能讓兩份 binary 出現差異,當場暴露。對「忠實翻譯」這個目標來說,這比任何單元測試都嚴格:它驗的不是「功能對不對」,而是「行為跟 C 一模一樣」。再配合 Sandmark 的 A/B 效能比對、TSan 抓 data race、以及一整個 opam switch 加 iter/gen/dune/digestif 等社群套件的實際 build,整個驗證面覆蓋了「正確性、行為一致性、效能、並行安全、生態相容」五個維度。
還有一個 meta 層面的觀察。整份移植是配合 Claude Opus 4.7/4.8 與 Claude Code 在約七天內完成的——這恰好是「逐行翻譯」這種任務的甜蜜點:規則機械、語意對應明確、每一步都有 test suite 當 oracle。它不是創造性的架構設計,而是高度結構化的 transliteration,正適合用 LLM 把人從 40,000 行的機械搬運裡解放出來。值得注意的是 per-file toggle 跟 LLM 工作流是互補的:每翻一檔就有一個明確的、可機器驗證的 stopping point,模型不需要一次抓住整個 runtime 的全局狀態,只要對著一個 .c 檔產出語意等價的 .rs、然後讓 test suite 判生死。OCaml 上游甚至為此類貢獻寫了 AI.md 政策——這場移植在工具與題型的契合度上,本身就是一個值得記下的案例。
What this enables:一份能跟 upstream C 做逐行 diff、用 unsafe 忠實保留 GC 與值表示的 Rust runtime——它不讓 OCaml 變安全,但讓「在 Rust 工具鏈裡持續維護一個 C runtime 的翻本」第一次變得 tractable,並順手量出 bytecode 慢的真正原因是 computed-goto 的缺席,而非 Rust。