同一份 C++ 原始碼,同一台機器,連續編兩次,吐出來的 bytecode 卻差了大約 29 個 byte。xeiaso 想把 wasm2js 這支工具 vendor 進 Anubis,讓 proof-of-work 的驗證邏輯在 client 與 server 兩端 byte 完全一致——結果一頭撞進了編譯器預設值織成的不確定性陷阱。
把 wasm2js 變成可重現的 vendor binary——一場跟編譯器預設值的纏鬥
這是一篇 debug 日誌:目標只有一句話——讓 wasm2js(出自 Binaryen 專案)的編譯輸出可重現,這樣 Anubis 才能在 client 與 server 兩端跑「byte 完全相同」的 proof-of-work 驗證邏輯。xeiaso 把這個目標寫成「check logic 只在一個地方定義」(defined in one place on both client and server),靈感來自那場著名的 talk《The Birth and Death of JavaScript》,做法是把 WebAssembly 重新編回 JavaScript。要讓這條路成立,前提是 wasm2js 自己的輸出 deterministic——而現實是,同一份輸入並不會吐出同一份 bytecode。
這個故事的張力不在「演算法多難」,而在「有多少地方會偷偷把不確定性塞進 build」。整趟 debug 不是抓一個 bug,而是一層一層剝:每剝掉一層,輸出的差異就縮小一截,但永遠還剩一點,逼你往 toolchain 更深處看。下面這張表把 build 流程攤成可操作的開關——你可以逐一打開每個會破壞 determinism 的因子,按 rebuild 看兩次輸出的差異。重點不是動畫,而是:每一個 flag 對應的是 toolchain 哪一層、它把什麼東西洩進了輸出 byte。
toggle a build flag, press rebuild, watch the two digests diverge · 3 flags
一個目標:把驗證邏輯壓進同一份 byte
先講清楚為什麼非得 byte-identical 不可。Anubis 是一套靠 proof-of-work 擋爬蟲與機器人的反濫用工具,client 要算出一個滿足條件的答案、server 要驗這個答案。如果驗證邏輯在兩端有任何語意差異,攻擊者就有縫可鑽。xeiaso 給這次工作定的實作目標是 check logic「defined in one place on both client and server」——只寫一次、兩端共用。
把同一段邏輯同時跑在瀏覽器與 server 上,最乾淨的做法是讓兩端執行同一份產物。他的選擇是把 WebAssembly 重新編成 JavaScript,這個點子直接致敬了那場 talk《The Birth and Death of JavaScript》。負責這個轉換的工具是 wasm2js,來自 Binaryen 專案。他把這件事說得很白:「In order to really make sure that the output of this is deterministic(essential for reproducible builds),I need to bundle a copy of wasm2js。」要 bundle 一份可信任的 wasm2js,前提是這份 wasm2js 本身要能重現編譯——而 wasm2js 是 C++ 寫的,要從原始碼編出 byte 一致的 binary,這就是整篇故事的起點。
這裡的工程哲學值得拆開講:vendor 一支 binary 不等於「下載一個檔案放進 repo」。如果你不能從原始碼可重現地編出同一份 binary,那這份 vendored binary 就是一塊無法稽核的黑盒——你沒辦法向別人證明「我 repo 裡這份 wasm2js 真的是這份原始碼編出來的」。可重現編譯把這份信任從「相信我」變成「自己編一次對 checksum」。所以這不是潔癖,是供應鏈安全的硬需求。
放回 Anubis 的脈絡,這個需求更尖銳。proof-of-work 的安全性建立在「client 與 server 對同一個挑戰算出的東西可比對」之上;如果兩端跑的驗證邏輯有任何 byte 級的差異,理論上就可能出現 client 通過、server 卻判定不同的縫隙。把 WebAssembly 編回 JavaScript、再讓兩端共用同一份產物,是把「邏輯一致」這件事從紙面約定變成 byte 約定。而要 byte 約定成立,wasm2js 這支工具的輸出就必須先 byte 一致——不確定性不是美觀問題,它直接落在信任鏈的根上。
工程師看待這類問題的角度其實很固定:先看症狀,再立假設,最後驗修法。症狀是「同源連編兩次 sha256 不同」;可能的假設有一整排——時間戳、亂數種子、檔案系統 inode 順序、平行編譯的 race、外部工具版本、位址相關的 codegen。debug 的手藝在於把這排假設一個一個切掉,每切掉一個就看差異有沒有縮小。xeiaso 走的正是這條路:他沒有一次想通全部,而是讓 build 在每一步都「比上一步更接近 byte-identical」,直到剩下一條自己修不動的邊界為止。
這裡值得停下來想一個容易被跳過的問題:為什麼「重現一份 binary」會這麼難?直覺上,輸入一樣、程式一樣,輸出就該一樣——這正是函數該有的樣子。問題在於,真正餵進編譯器的「輸入」遠不只你看得到的那份 source。當下的 wall clock、$PATH 上裝了哪個工具、process 啟動時系統給的記憶體位址、環境變數、甚至檔案在磁碟上被列舉的順序,全都可能偷偷成為「隱藏輸入」。可重現編譯的工作,本質上就是把這些隱藏輸入一個一個找出來、釘死成常數,讓編譯重新變回一個純函數。這趟 debug 的三層,剛好就是三個典型的隱藏輸入:時間、外部工具、runtime 位址。
三個把不確定性塞進 build 的地方
xeiaso 的原話是:「There are a shocking number of ways to accidentally create nondeterministic output when doing C/C++ development。」這趟 debug 撞到的不是一個 bug,而是三個來自 toolchain 不同層的不確定性來源,每一個的症狀、假設、修法都不同。它們的共同點是:沒有一個寫在你自己的程式碼裡。時間戳是編譯器內建的巨集、wasm-opt 是 clang 自己去找的工具、try_table 漂移藏在 codegen 深處——三層都在你的 source 之外,這正是它們難抓的原因。下面這張卡片把三者並排——每一張對應一個層級:原始碼層、連結工具層、code generation 層。
三層 toolchain,三種不確定性
點任一層看它的症狀、假設與修法
原始碼層 · 時間戳
最容易踩到的一種:原始碼用 __DATE__ 與 __TIME__ 把編譯當下的時間戳印進 binary。xeiaso 給的例子是同一份 source 連編兩次,一次標成 Jun 18 2026 00:00:59、另一次 00:01:11——只差十幾秒,binary 就不一樣。修法是把時間戳從輸出裡拔掉。
連結工具層 · wasm-opt 版本漂移
clang 在 build 時會 shell out 到 wasm-opt。他的原話:「clang shells out to wasm-opt when doing builds。」這在一般情況下合理,但問題是它抓的是 $PATH 上的版本——workstation 上是 v130、DGX Spark 上是 v108,行為與輸出都不同。修法是連結時傳 --no-wasm-opt。
codegen 層 · exception handling 的 pointer leak
最深的一層在 clang 自己的 codegen。他描述這是「address-sensitive code generation hiding in its exception handling path」——raw pointer 值洩進了幾個 try_table block 出現的順序,導致每個 build 跟下一個差大約 29 byte。修法是 setarch --addr-no-randomize 關掉 ASLR,再為 x86_64 與 arm64 各自建一份 known-good checksum。
最先要排掉的,是最容易解釋的那一個。xeiaso 點名「One of the easiest is to use the builtin __DATE__ and __TIME__ macros to stamp a build with the time the compiler was executed at。」這兩個是 C/C++ 預處理器內建的巨集:__DATE__ 展開成編譯當下的日期、__TIME__ 展開成當下的時間,很多程式拿它們印 build banner 或版本字串。問題是這個值在編譯那一刻才定,所以「同一份 source」這個前提根本沒被違反——違反的是「同一個編譯時刻」。
這個症狀有個很好認的特徵:差異是固定位置、固定長度的一小段,而且內容看起來像人類可讀的日期字串。xeiaso 的例子很具體:同源連編兩次,一次標成 Jun 18 2026 00:00:59,另一次標成 00:01:11——只差十幾秒,那一小段 byte 就不同。如果你 hexdump 兩個 binary 做 diff,會看到差異集中在 binary 前段一塊像 ASCII 的區域,而不是整片散開。固定位置加可讀內容,幾乎是時間戳的指紋。
修法在概念上很簡單:別把編譯時刻印進輸出。可重現編譯社群的標準做法是用 SOURCE_DATE_EPOCH 之類的機制把「build 時間」鎖成一個固定值,或乾脆把時間戳從輸出裡拿掉。重點不在哪一種寫法,而在認知:只要輸出裡還留著一個「跟著 wall clock 走」的欄位,這份 build 就永遠不可能 byte-identical。把這一層解掉,差異會明顯縮小——但不會歸零,因為後面還有兩層在等著。
時間戳之所以被列為「最容易」的一種,不只是因為修法簡單,更因為它是這三層裡最看得見的:巨集就寫在原始碼裡,__DATE__ 五個字一搜就到,hexdump 出來的差異又像人類可讀的日期。它是教科書級的範例——幾乎每一份談 reproducible build 的檔案都會先拿時間戳開刀。但也正因為它太典型,反而是個陷阱:解掉它很有成就感,容易讓人以為「啊原來就是這個」而提早收手。xeiaso 把它放在開頭、緊接著就說「還有 shocking number of 種方式」,正是在提醒:時間戳只是冰山露出水面那一角,水面下的兩層才是真的會讓人卡很久的地方。
clang 在你背後 shell out 到 wasm-opt
第二個真正讓 build 整個 fail 掉的是 wasm-opt。xeiaso 的描述很直接:「clang shells out to wasm-opt when doing builds。」這個行為平常沒人會注意——clang 連結階段自動呼叫 wasm-opt 做最佳化,結果存在哪、用哪個版本,都被藏在 toolchain 內部。它從 $PATH 抓 wasm-opt,而 $PATH 上有什麼,完全看你這台機器裝了什麼。
問題在他的 DGX Spark 上爆出來:那台機器的 wasm-opt 版本太舊,build 直接掛掉。他的原話是這個版本「too old」,導致 build fail。對照之下,workstation 上是 wasm-opt version 130,DGX Spark 上是 version 108——v108 vs v130,跨了二十多個版本。這正是 reproducible build 最棘手的一類問題:它不是程式碼錯,而是 build 環境裡一個你沒宣告、卻被默默依賴的外部工具。從工程師的角度看,這個症狀比時間戳更陰險:時間戳是「同機兩次不同」,而這個是「換一台機器就直接掛」——錯誤訊息指向 wasm-opt,但真正的病根是 clang 對 $PATH 的隱形依賴。下面這張圖把這個隱形依賴畫出來。
為什麼舊版 wasm-opt 會在 arm64 上爆掉?xeiaso 指出 wasi-sdk 與 binaryen 都依賴 WebAssembly Exceptions extension——而「C++ is also one of the main places where exceptions are used」。換句話說,編 C++(wasm2js 本身就是 C++)必然會走到 exception handling 這條路,而舊版 wasm-opt 處理不了這套 EH extension。新版(v130)跟得上、舊版(v108)跟不上,於是 build 在 DGX Spark 這台 arm64 機器上 fail。下面這個小工具讓你切換 wasm-opt 版本,看同一份含 exception 的 C++ 在不同版本下會通過還是掛掉。
switch the wasm-opt version, watch the WebAssembly Exceptions gate pass or fail · 2 versions
修法乾淨俐落:連結階段傳 --no-wasm-opt,直接告訴 clang「別自己去呼叫 wasm-opt」。他的描述是「The solution was to pass --no-wasm-opt at the linking step。This removed one angle of irreproducibility。」這句話的措辭很克制——「移除了不可重現的一個角度」,言下之意是還有別的角度沒解決。剝掉外部 wasm-opt 這層,build 至少不再依賴某台機器 $PATH 上的版本,但 byte 漂移還沒消失。值得注意的是這一步同時解了兩件事:DGX Spark 上的 build fail 沒了(不再呼叫太舊的 wasm-opt),輸出對 $PATH 的依賴也沒了——一個 flag,兩個不確定性來源一起切掉。
最後 29 個 byte:pointer 洩進了 try_table 的順序
切掉 wasm-opt、拔掉時間戳之後,build 仍然每次不一樣。差異縮小到一個很小但很頑固的數字:大約 29 byte。這次的源頭不在外部工具,而在 clang 自己的 code generation。他的描述是:clang「has some address-sensitive code generation hiding in its exception handling path。Raw pointer values leak into the order a handful of try_table blocks come out in。」
拆開來看:exception handling 的 codegen 在某處用了 raw pointer 的值來決定一批 try_table block 的排列順序。pointer 值本身是 runtime 才確定的位址,而現代系統有 ASLR(位址空間隨機化),每次 process 啟動位址都不同——於是同一份 source 編出來的 try_table 順序就跟著漂。他把症狀講得很具體:「This surfaces as every build differing from the next by about 29 bytes。」每個 build 跟下一個差大約 29 byte,數字穩定地小,但永遠不為零。
這個 bug 的麻煩之處在於它不像前兩層那樣有明確的元兇可拔。時間戳是原始碼裡看得見的巨集,wasm-opt 是 build script 裡可以加 flag 的步驟;但 try_table 的順序漂移藏在 clang 的 codegen 深處,原始碼一行沒動、flag 也沒得加。你能觀察到的只有結果——每個 build 跟上一個差大約 29 byte,而且這個差值穩定地小,不會隨機放大。穩定地小本身就是一條線索:如果是亂數種子之類的全面隨機,差異會是整片;差值固定在二十幾個 byte,說明漂移被局限在某個小範圍的結構裡,也就是那一把 try_table block 的排列。
從工程師的推理鏈看,這一步特別漂亮:症狀是「差值穩定地小」,這個觀察直接排除了「亂數種子」「平行編譯 race」這類會造成大片漂移的假設,把嫌疑收斂到「某個跟位址有關、但結構固定的東西」。一旦假設指向位址,下一個自然問句就是「哪個位址每次都不同」——答案是 ASLR。把這條鏈走完,修法的方向就浮出來了:要嘛讓位址固定,要嘛接受位址會變、改在更上層對 checksum。這條 timeline 把整趟 debug 攤平:每一步的症狀長什麼樣、當下的假設是什麼、修法是什麼。拖動把手,看 build 在每個階段的狀態如何收斂。
drag the handle along the debug timeline · 5 stages
目標:vendor 一份可重現編譯的 wasm2js,讓 Anubis 驗證邏輯兩端 byte-identical。拖動把手看每一步。
修法分兩步。第一步是把 ASLR 關掉:用 setarch --addr-no-randomize 跑這次 build,讓位址不再隨機,pointer 值穩定下來,try_table 的順序也就跟著穩定。第二步承認一個現實——同一份 source 在不同 CPU 架構上編出來本來就不會一樣,所以他為 x86_64 與 arm64 各自建一份 known-good 的 sha256 checksum。他的原話是這兩步:「(1) Disable address-space randomization for this build using setarch --addr-no-randomize。(2) Create known good sha256 checksums for both x86_64 and arm64 via building this program on machines I trust。」
這裡有一個觀念要分清楚:關 ASLR 不是讓 build「更安全」,而是讓 build 環境「更固定」。ASLR 是 runtime 的安全防護,跟最終 binary 的內容本來不該有關係——它之所以洩進輸出,是因為 codegen 不小心把 runtime 位址當成了排序的依據。setarch --addr-no-randomize 等於是把這個本不該存在的輸入鎖成常數,讓那一把 try_table 每次都以同樣順序排出來。這是治標:病根在 codegen 把位址當輸入,治本要動 LLVM。但治標已經足夠讓同一台機器、同一個架構穩定地產出同一份 byte。
值得多想一層的是:為什麼是 try_table、為什麼是 exception handling?WebAssembly 的 exception handling 不像一般控制流那樣線性,它需要一張表把「哪段程式碼的例外,由哪個 handler 接」對應起來,try_table 就是承載這層對應的結構。當 codegen 要決定這張表裡那一把 block 用什麼順序排出來時,如果它拿了某個 pointer 的數值去當排序鍵——而那個 pointer 又是位址、位址又被 ASLR 隨機化——順序就跟著位址漂。差值穩定在二十幾個 byte,正好對應「就那幾個 block 換了位置」這個解釋:不是內容變了,是同樣一批內容換了排列。這也解釋了為什麼前兩層的修法都不管用:時間戳和 wasm-opt 都動不到 codegen 排 try_table 的那段邏輯,那是 clang 自己更裡面的事。
「on machines I trust」這個措辭點出 reproducible build 的本質:可重現不是憑空產生信任,而是把信任錨定在「我信任的機器上編出來的這份 checksum」上。之後任何人在同架構上自己編,只要對得上這份 checksum,就確認 binary 沒被動過手腳。換句話說,checksum 是這趟 debug 真正的產物——前面三層全都解掉,最後沉澱成兩串可以公開核對的 sha256(x86_64 一串、arm64 一串),這兩串才是別人稽核 vendored binary 時握得住的東西。
同架構內可重現,跨架構還是上游的事
這趟 debug 的終點不是完美的勝利,而是一個誠實的邊界。關 ASLR + 各架構各自 checksum 之後,build 在同一個架構內變得 deterministic——x86_64 上連編多次都對得上、arm64 上也是。但 x86_64 編出來的跟 arm64 編出來的仍然不同,這個跨架構的不一致沒被解決。下面這張圖把「可重現的範圍」畫成一道有邊界的梯子:同架構內是綠的、跨架構是赭的,而那條赭線的另一頭寫著上游 LLVM。
xeiaso 對這個邊界的態度很節制。他說「At the very least builds are deterministic within architectures。This may have to be good enough for now。」——「至少同架構內可重現,現階段大概只能這樣了。」對於跨 host 的可重現,他把球明確踢給上游:「I'd really love to have this be reproducible across hosts, but that's an upstream LLVM bug that I am not powerful enough to tackle。」這不是放棄,是把問題定位清楚——跨架構的 byte 漂移根源在 LLVM 的 code generation,不是 Anubis 這層能單獨修掉的。誠實標出「我修得動哪裡、修不動哪裡」,本身就是 reproducible build 工程的一部分:你交出去的承諾範圍,必須跟你實際控制得了的範圍對齊。
對要 vendor binary 的人來說,這趟 debug 留下一份具體的檢查清單:原始碼裡別用 __DATE__/__TIME__(或用 SOURCE_DATE_EPOCH 之類的機制鎖住);留意 clang 這類 toolchain 會 shell out 到 $PATH 上沒宣告的工具,必要時用 --no-wasm-opt 把它切掉;C++ exception handling 的 codegen 可能對 pointer 敏感,build 時關 ASLR、且把可重現的承諾限定在「同架構內」。三個層級,三種修法,沒有一種是「重編一次就好」。
The lesson:可重現編譯不是一個開關,而是一條要逐層堵漏的鏈——原始碼層的時間戳、連結工具層的隱形 wasm-opt、codegen 層的 pointer leak,每一層都得單獨對付;堵到最後你會撞上一條自己修不動的邊界(上游 LLVM),這時誠實地把可重現範圍限定在「同架構內」,比假裝全綠更有用。