vatt'ghern jaskier's ballads
本文 1 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

一個從頭到尾沒人呼叫的函式 explode() 被執行了;把未定義行為換成寫入 null,GCC 存進去的是 0 而不是 99,Clang 卻又回到執行 explode()。「最壞不過是 segfault」要怎麼解釋這幾個結果?

沒人呼叫的 explode() 為何執行

有一種很常見的安全感:C 或 C++ 的程式碼就算踩到未定義行為(UB),最壞也不過是 segfault,程式當掉,找到那一行改掉就好。這篇部落格文章把這個說法當成靶子,原文引述的立場是「at worst we’ll get a segfault」,回應只有一句:「This is false.」作者不靠語言條文來反駁,靠的是三段很短的程式,加上編譯器吐出來的組合語言。

這篇把它當成一個謎題來讀。三段輸出各自違反了哪一種直覺?幾個常見的解釋裡,哪些撐得住,哪些在證據面前站不住?範圍只限於這篇文章給的材料。文章用的是 GCC 16.2 搭配 -O1,B 版另外附了 Clang 的 godbolt 列表。至於編譯器內部怎麼推理的一般理論,除非文章自己說了,我不補;補了就等於把我的知識包裝成作者的證據。

一個沒人呼叫的 explode()

第一段程式是整個謎題的起點。文章裡有一個全域變數,宣告成 volatile int device_command = 0;,名字暗示它代表對某個裝置下的指令。函式 serve_hot_chocolate() 先做 device_command = 42;,接著呼叫 std::unreachable();,後面標了 UB。另一個函式 explode() 只做一件事:device_command = 3;。整份程式沒有任何一處呼叫 explode()。

#include <utility>
volatile int device_command = 0;
void serve_hot_chocolate() {
        device_command = 42;
        std::unreachable();   // UB
}
void explode() {
        device_command = 3;
}

變數叫 device_command、又宣告成 volatile,合理的解讀是作者拿它模擬對硬體暫存器發指令:寫進去的每一個值,都是外界看得到的動作。把它當成「寫 42 是端熱可可,寫 3 是引爆」的玩具,故事比較好記,但技術上的重點只有一個:這兩次寫入都是會被觀察到的。

在讀作者給的結果之前,先請你猜一下。三個版本都用 GCC 16.2、-O1 編譯:原版是呼叫 std::unreachable(),第二版把它換成對 null 指標的寫入,第三版在寫入前先把指標加 10。下面的小工具讓你對每一版先選一個預測,再對答案。選完之前,答案不會出現。

先為三個版本各選一個預測,再按對答案 · 3 題

A 版:寫入 42 之後呼叫 unreachable,GCC 16.2 -O1 會怎樣?

文章的結果 序列是寫入 42,接著落入 explode() 的寫入 3。作者寫道:「The function `explode()` will be executed, even though it was never called!」

B 版:把 unreachable 換成對 null 指標寫入 99,GCC 16.2 -O1 會怎樣?

文章的結果 在寫入 42 之後,GCC 對位址 0 存入的是 0 而不是 99,接著是 ud2 指令。Clang 的列表則完全沒有對 null 的寫入,也沒有 ud2:serve_hot_chocolate() 只寫入 42,接著 explode() 寫入 3 後 ret。作者的話是:「Again we get exploding chocolate.」

C 版:指標先加 10 再寫入 99,GCC 16.2 -O1 會怎樣?

文章的結果 寫入 42,再對位址 40 存入 99,然後 ret。作者的評語是:「taking the code at face value. Lucky.」

尚未對答案

互動圖表

三段 UB 範例在 GCC 16.2 的 -O1 下結局各不相同,一個執行了沒人呼叫的 explode(),一個寫入 0 之後遇到 ud2,一個碰巧照字面執行。

三個正確答案落在三個不同的選項:三段程式只差一兩行,輸出卻落在三種截然不同的類別。你可以看看自己的預測是否一致地偏向某一種,例如三題都選「當掉」。文章的證據顯示,結局的種類本身就不固定:同樣的作者、同樣的編譯器和選項,三版各是一種。

五個假設各自要先說出預測,才有資格接受檢驗。如果有人主張「編譯器會偵測 UB 並擋下」,它預測的是第一個例子根本編譯不出列表,或者至少會有一份診斷。如果主張「最壞只是 segfault」,它預測的是程式在 UB 那一行當掉,之前的寫入照常發生、之後的程式碼完全不會執行。如果主張「換編譯器結果一樣」,它預測的是 GCC 與 Clang 在同一段程式上歸到同一類。五個假設分三節處理:假設一那節收「會擋下」與「最壞只是 segfault」,假設二收「換編譯器結果一樣」,假設三收「假設 UB 不會發生」與「結果合理就沒問題」。

三個程式版本乘上兩個編譯器,文章只給了四格結果。「最壞只是 segfault」是一句全稱的話,一個反例就夠它倒下;同樣的樣本要證實任何全稱說法則遠遠不足。後面紅色格子與綠色格子的分量因此不同。

假設一:編譯器會擋下來,不然最壞也只是當掉

最直接的想法是,既然程式員都知道 std::unreachable() 之後的路徑不該走到,編譯器也應該知道,要嘛報錯,要嘛至少在執行時讓程式立刻出事。文章對這一點的回答分兩層。第一層是原則:「No compiler is obligated to “detect” UB at all.」沒有任何編譯器有義務偵測 UB。第二層是能力:「Because of the halting problem, it cannot in the general case know」。

第二句的引文在我取得的內容裡到「know」為止,後面接什麼,我只能從上下文推測是「某條路徑會不會被執行」,所以這裡只採用能確認的部分:作者主張編譯器在一般情況下做不到知道。這個主張與停機問題掛鉤,是作者自己的論述,不是我補的理論。

回到證據。第一個例子裡,作者展示的是編譯器產生的組合語言,也就是說程式成功編譯了;文章給我的內容裡沒有提到任何拒絕編譯的訊息。如果假設一成立,這份列表根本不該存在。所以就文章呈現的結果而言,假設一站不住:編譯器沒有擋,也不需要擋。要留意這個結論的強度:它說的是「這個編譯器在這個選項下沒有擋」,並不是「所有編譯器永遠不會警告」,後者文章沒有談。

那它至少會讓程式當掉嗎?這是假設一的弱化版,也是「最壞不過是 segfault」的原型。第一個例子同樣不支持。作者描述的結果是:執行了 explode(),沒有人呼叫過它。這個結局沒有崩潰、沒有錯誤訊息,程式繼續往下執行別的東西。

讓人困惑的地方在於:explode() 是怎麼被「走到」的?看組合語言列表的形狀,答案相當具體。列表裡 serve_hot_chocolate() 先有一條把 42 存進 device_command 的指令,下一個標籤就是 explode(),裡面是把 3 存進同一個變數的指令,最後才是 ret。兩個標籤之間沒有 ret,也沒有 jump。換句話說,執行完第一個函式最後一條指令之後,處理器下一條看到的就是 explode() 的第一條指令。

同一份程式的兩種讀法,GCC 16.2 -O1 原始碼裡的關係 serve_hot_chocolate() device_command = 42; std::unreachable(); // UB explode() device_command = 3; 沒有任何一行呼叫 explode() 兩個函式之間沒有呼叫箭頭 輸出列表裡的順序(依文章列表摘錄) serve_hot_chocolate(): mov device_command, 42 此處沒有 ret,也沒有 jump explode(): mov device_command, 3 ret 執行流程順著位址往下走,直接進入 explode() 這是從列表讀出的推論,文章沒有逐步講解

這個圖要小心的地方是推論的來源。「落入下一個標籤」是我從列表形狀讀出來的,文章的逐字結論只有 explode() 會被執行這一句。不過兩者吻合:一個沒有 ret 的函式尾端,除了接著執行相鄰的指令之外,沒有別的路。這也解釋了為什麼是 explode() 被走到,而不是別的函式:它剛好排在後面。換個排列順序,被牽連的就是另一個函式,文章沒有測這個變體,所以這句只能當作對列表的讀法。

順帶一提,這個例子裡編譯器並沒有做什麼「聰明」的事。它做的反而是少做事:std::unreachable() 那一行沒有產生任何指令。文章的說法,大意是編譯器對那條路徑不產生程式碼;我取得的逐字引文不含這句,所以這裡只用列表本身當證據。一個沒有指令的位置,就是沒有東西擋住前面的流程往下掉。

假設二:寫 null 會在那一行崩潰,換個編譯器也一樣

第一個例子的 UB 是明示的,unreachable 這個名字就在告訴編譯器「這裡不會到」。也許問題出在它太特殊?對 null 指標寫入才是人們心目中的經典 UB,也是 segfault 的來源。文章把第一個例子裡的 std::unreachable() 換成這種寫法:先拿一個 null 指標,再對它寫入 99。

void serve_hot_chocolate() {
        device_command = 42;
        int* ptr = nullptr;
        *ptr = 99;
}

這段是依文章描述整理的形狀,不是逐字轉貼。GCC 16.2、-O1 給出的結果有三步:先把 42 存進 device_command,然後對位址 0 存入一個值,最後是 ud2。列表裡的第二步寫的是 DWORD PTR ds:0, 0,也就是存進去的是 0。原始碼寫的是 99。

這裡有兩個值得停下來看的事實。第一,確實有一條對位址 0 的寫入,所以「寫 null 會寫到位址 0」這件事字面上發生了;第二,寫進去的不是程式員寫的那個值。如果程式沒有 UB,99 會被存進去;有了 UB,變成 0。文章沒有解釋為什麼是 0,我也不猜。這個 0 是一條線索,也是文章留給讀者的一個沒收尾的洞,後面的整理會把它列進「文章沒回答的事」。

最後的 ud2 是一條故意讓處理器丟出無效指令例外的指令。所以這個程式確實會當掉。但它當掉的方式,和你在腦中演練的「在 *ptr = 99 那一行存取到不能存取的位址,然後被作業系統送 segfault」是不同的故事:先有一次沒人預期的存入 0,再由一條編譯器放進去的指令收尾。就算你把「最壞不過是 segfault」放寬成「最壞不過是當掉」,它發生時,現場和你預期的也已經不一樣了。

B 版:同一行 null 寫入,三種讀法 原始碼的意圖 存 42 到 device_command 存 99 到位址 0 (之後的程式碼) GCC 16.2 -O1 的列表 存 42 到 device_command 存 0 到位址 0 ud2(故意當掉) Clang 在同一版本上 列表只存 42,接著 explode() 存 3、ret;無 null 寫入、無 ud2

Clang 那一行是整個例子裡最有資訊量的證據。同一段原始碼、同樣是對 null 的寫入,GCC 給了「存 0 然後 ud2」,Clang 給了「又是爆炸巧克力」,也就是第一個例子裡那種執行了沒人呼叫的 explode() 的行為。作者在「Now let’s check clang with godbolt:」之後附了 Clang 的列表:serve_hot_chocolate() 只有一條把 42 存進 device_command 的指令,緊接著是 explode() 的存 3 與 ret。這份列表裡找不到對位址 0 的寫入,也沒有 ud2。

這一行直接殺掉另一個常見的假設:同一段 UB 在不同編譯器上,結果是一樣的。證據是兩個編譯器、同一段程式、兩種不同的歸類。反過來看,它也提醒一件事:你手上的編譯器給了你一個「看起來合理」的結果,並不能預測換一個編譯器、換一個版本、換一個優化等級之後會不會變。文章只測了這兩個編譯器,更多的組合我沒有證據。

假設三:編譯器假設 UB 不會發生,結果合理就代表沒事

把兩個例子並排放,有一個解釋同時說得通。假設編譯器處理程式的時候,預設「程式沒有 UB」,那麼 std::unreachable() 那一行就不可能被走到,不需要為它產生任何指令,列表裡自然什麼都沒有,前面的流程掉進相鄰的 explode()。對 null 的寫入同樣如此:既然這個寫入是 UB,這條路徑上的後續程式碼就不必當真,GCC 在存入 0 之後直接放了 ud2,沒有 ret;Clang 則回到了和第一個例子相同的歸類。

要注意這個假設在本文中的地位。作者的結論方向與它一致,但我取得的逐字引文裡沒有這個假設的原句,所以我把它當成「與證據相容的解釋」,而不是「作者逐字寫下的主張」。更重要的是,兩個例子只能說明這個假設相容,不能證明它是唯一的解釋,也不能證明編譯器內部是這樣運作。文章給的是輸出,不是編譯器的決策記錄。

這個假設還留下一個明顯的缺口:存入 0 而不是 99。如果編譯器只是「假設那條路徑不存在」,為什麼還會產生一條對位址 0 的寫入,而且值還不對?在文章給的材料裡,這個問題沒有答案。我能做的只是把它標成未解,而不是塞一個聽起來合理的機制進去。

第三個例子的作用,是堵住最後一條退路:「如果它跑出了我預期的結果,就代表程式沒問題。」文章把指標先加 10,再寫入 99。列表的結果是:先存 42,再對位址 40 存入 99,最後 ret。這一節我讀到的內容沒有標明編譯器,下文與表格暫時放在 GCC 欄。這正是用字面讀這段程式會得到的結果:先發指令 42,再寫一個值,然後正常返回。作者的評語只有一句:「taking the code at face value. Lucky.」

位址 40 這個數字是列表上的,值得多看一眼。若一個 int 是 4 個位元組,指標加 10 就是位址加 40,與列表吻合。這裡的「4 個位元組」是我補的前提,文章沒有寫;但即使不補,它也不影響結論:這份輸出看起來是「天真的程式員期待的答案」,作者仍然說它只是運氣。

用下面的探測器可以把三個例子與兩個編譯器放在同一張表裡。每一格要嘛有文章給的結果,要嘛標明我讀到的版本沒有列出。選一格,右邊會顯示該格的輸出,以及五個假設在這格證據下的狀態:推翻、相容,或是文章沒涉及。

選程式版本與編譯器,看該格證據與五個假設的狀態 · 3 版本 × 2 編譯器

版本 編譯器
GCC 16.2 -O1 Clang A unreachable B 寫入 null C null 加 10 explode() 被執行 我讀到的版本未列出 存 0(不是 99),然後 ud2 explode() 又被執行 存 99 到位址 40,ret 我讀到的版本未列出 六格之中我讀到的版本只有四格有列表,空白格不能拿來推論。

A unreachable,GCC 16.2 -O1

serve_hot_chocolate():
    mov  device_command, 42
explode():
    mov  device_command, 3
    ret

42 之後直接接上 explode() 的標籤與寫入 3。沒有任何程式呼叫 explode(),它的寫入仍然被執行。

  • 推翻編譯器會偵測 UB 並擋下
  • 推翻最壞只是 segfault,其餘照寫
  • 相容編譯器假設 UB 不會發生
  • 未涉及換編譯器結果一樣
  • 未涉及結果合理就代表沒問題

探測器的 C 版 GCC 那一格,是整個謎題裡最需要老實對待的證據。在這一格,「最壞不過是 segfault,其餘照寫」沒有被推翻:程式沒有當掉,也確實照寫的執行。但是「結果合理就代表沒問題」被作者自己推翻了,理由就是那個「Lucky」。同一個假設在同一組證據下一半留下、一半倒下,因為它其實包含兩個說法:行為上的描述,以及對程式正確性的推論,作者要打的是後者。

同樣地,A 版和 B 版 GCC 那兩格,都標出「相容」而不是「證實」。一個解釋與三筆觀察相容,只代表目前沒有觀察與它衝突;要證實,得有能排除其他解釋的對照實驗,例如把 explode() 搬到前面、換不同的 -O 等級,文章沒有做這些,我也不能替它做。

剩下的解釋,以及文章沒回答的事

把前面的證據合起來看,五個假設的狀態可以一次排出來。「編譯器會偵測 UB 並擋下」在每一筆證據下都沒有出現,加上作者明說編譯器沒有義務偵測,所以這個假設站不住。前兩個例子讓「最壞只是 segfault,其餘照寫」也站不住:A 版沒有當掉卻執行了別的函式,B 版存進去的值不是原意。B 版的 GCC 與 Clang 結局不同,「換編譯器結果一樣」跟著失效;C 版則否定了「結果合理就代表沒問題」。只有「編譯器假設 UB 不會發生」在所有能判斷的格子裡都相容。

五個假設,四筆有結果的證據 A GCC B GCC B Clang C GCC 編譯器會偵測 UB 並擋下 推翻 推翻 推翻 推翻 最壞只是 segfault,其餘照寫 推翻 推翻 推翻 相容 編譯器假設 UB 不會發生 相容 相容 相容 未涉及 換編譯器結果一樣 未涉及 推翻 推翻 未涉及 結果合理就代表沒問題 未涉及 未涉及 未涉及 推翻 相容只代表目前沒有觀察與它衝突,不代表已經證實。

表格底下的結論有一個明顯的限度:能存活的那個假設,是唯一一個沒有被反駁、也沒有被證實的。它的支持來自四筆觀察,全部出自同一篇文章、同一位作者挑選的例子。作者挑選的例子,本來就是為了說明「UB 不只是 segfault」這個論點,所以它們很可能是最容易展示這件事的案例,不代表 UB 的結果分佈。

這種不對稱也決定了該怎麼讀這張表。推翻很便宜:一格紅色,就足以讓一句全稱的話失效,而且四格資料裡紅色佔了大多數。相容很貴:綠色那一列看起來整齊,卻是唯一一列沒有任何一格能說「它已經被證實」。如果你只記得一件事,記得紅色的格子比綠色的格子可靠。

文章沒有回答的事情,我列在這裡。存入 0 而不是 99 的原因就是其中一個。Clang 在 A 版與 C 版的結果,我讀到的版本沒有給;換版本或換優化等級會不會改變結局,文章也沒有測。explode() 為什麼剛好排在 serve_hot_chocolate() 後面,同樣沒有討論。再加上整件事只有一位作者的一篇部落格,沒有第二個獨立來源,我也沒有自己重新編譯過,這張表該讀成「目前的觀察」,不是定論。

文章用一句帶刺的話收尾:「And to think that some people don’t believe in nasal demons.」「nasal demons」是文章用來指稱 UB 後果的說法,這句話的語氣暗示作者不把它當成純粹的玩笑。換成這篇的證據來說:三個版本、兩個編譯器、四格有列表的資料,沒有一格是「程式在寫 null 的那一行,照原意當掉」。

對讀者的實際影響,我只敢往下推一小步,而且標明這是推論。合理的推測是:如果你正在用「編譯、跑一下、看到結果」的方式確認某段可疑的程式碼沒事,C 版的 GCC 結果就是反例,它跑出了你預期的值,卻被作者形容成運氣。反過來,如果你看到某個編譯器對 UB 給了奇怪的輸出,A 版和 B 版告訴你,奇怪的輸出本身不是 bug 報告的充分理由,因為文章的出發點是:編譯器沒有義務在這裡做任何特定的事。

具體做法是我的建議,不是文章的內容,但每一步都能在文章的證據裡找到對應。拿可疑的片段,用同一組選項編譯兩個不同的編譯器,看輸出的歸類是否相同:B 版的 GCC 與 Clang 在這一步就分了岔。再改變優化等級重編一次,因為文章只用了 -O1,它沒有告訴我們別的等級下會發生什麼,你得自己確認。最後把輸出和你心裡預期的行為逐項對照,不要只看最後的數值;A 版的 explode() 被執行時,程式並沒有任何看得見的錯誤,只有裝置指令多了一個 3。

這個流程只能找出歧異,找不出安全。C 版的 GCC 輸出恰好等於字面讀法,作者把它叫做運氣,所以「看起來符合預期」同樣可能是巧合。檢查的目的是讓你在歧異出現時及早警覺,一致的輸出不構成保證。

Take-away:下次看到「在我這裡跑起來沒事」,先把它記成這個編譯器、這個選項下的一筆觀察。我的建議是把同一段程式再用第二個編譯器、另一個 -O 等級各編一次,並排比較輸出;B 版的 GCC 與 Clang 就是這樣分出歧異的,C 版位址 40 的結果則提醒你,一致也不代表沒問題。