一個從頭到尾沒人呼叫的函式 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() 的第一條指令。
這個圖要小心的地方是推論的來源。「落入下一個標籤」是我從列表形狀讀出來的,文章的逐字結論只有 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」放寬成「最壞不過是當掉」,它發生時,現場和你預期的也已經不一樣了。
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 編譯器
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 不會發生」在所有能判斷的格子裡都相容。
表格底下的結論有一個明顯的限度:能存活的那個假設,是唯一一個沒有被反駁、也沒有被證實的。它的支持來自四筆觀察,全部出自同一篇文章、同一位作者挑選的例子。作者挑選的例子,本來就是為了說明「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 的結果則提醒你,一致也不代表沒問題。