歡迎來到 Level-Up 系列的「當機驗屍課」三部曲。

幾個月前,OpenAI 內部的 Rockset 服務——負責 ChatGPT 搜尋對話紀錄的那個——開始出現詭異的當機。一個正常的 C++ function 跑完、準備回家,卻跳到一個鬼位址然後暴斃。更離譜的是,有些案件的「回程地址」是空的。就像照著 GPS 回家,結果 GPS 導到懸崖底下,而且懸崖底下連路都沒有。

這種死法「不該存在」。

要搞懂這樁命案,調查員得先學解剖學——搞清楚程式的身體構造是什麼、撞牆暴斃是怎麼發生的。這是第一課 (⁠◕⁠‿⁠◕⁠)


🏰 第 0 層:城裡出現「不可能的命案」

⚔️ Level 0 / 7 當機的驗屍課(上)
0% 完成

OpenAI 的工程師發現這些當機有個詭異的共通點:死者都是在「回家」的瞬間暴斃。

程式的世界裡,「回家」是這樣運作的:function A 呼叫 function B,B 做完事情之後會「回到 A」繼續執行。這是最基本的程式運作方式,每天發生幾十億次,穩到不能再穩。

但這批命案不一樣。

  • 有些死者的「回程地址」被改成了 NULL——像是 GPS 被抹掉,導航到 0 號地址
  • 有些死者的堆疊指標(%rsp)莫名其妙偏移了 8 個位元組,像是有人把路牌悄悄轉了一個角度
  • 兩種情況都是在 ret(回家)指令執行的瞬間暴斃

這些不是普通的 bug。普通 bug 會寫到錯的地方、算出錯的結果。但這種「回程地址剛好被改成 NULL」的機率,低到工程師們一度懷疑是 kernel 出問題、是編譯器出 bug、是硬體壞掉。

Mogu 內心戲:

先不要急著搞懂「為什麼會這樣」——那是 Post B、Post C 的事。Post A 的任務是:學會看懂一具程式的屍體。你要破案,得先懂解剖學。

這堂課會建立四個核心概念:

  1. %rip:死者此刻人在哪
  2. stack:一疊拜訪便條
  3. call / ret:怎麼拜訪、怎麼回家
  4. segfault:撞牆暴斃

學完這四件事,調查員才有辦法看懂命案現場。

小測驗

這批命案的共通點是什麼?


🏰 第 1 層:%rip — 死者此刻人在哪

⚔️ Level 1 / 7 當機的驗屍課(上)
14% 完成

CPU 一次只執行一條指令。就像一個人一次只能在一個地方。

那 CPU 怎麼知道「現在要執行哪一條指令」?答案是一個叫做 %rip 的暫存器。

Mogu 插嘴:

%rip 的全名是 instruction pointer(指令指標)。開頭那個 r 是 x86-64 架構的命名慣例,代表 64 位元的暫存器。rip 唸起來剛好很像 R.I.P.rest in peace,安息),巧合嗎?在調查命案的時候,這個雙關還真的挺應景的 (⁠¬⁠‿⁠¬⁠)

CPU 的工作流程大致是:

  1. %rip 指向哪裡
  2. 去那個位址讀一條指令
  3. 執行指令
  4. %rip 移到下一條指令
  5. 重複

當程式當機的時候,%rip 的值就是「死者最後在哪」。調查員檢視命案現場的第一件事,通常就是看 %rip 停在什麼位址。

如果 %rip 指向一個「不是指令的地方」——比如說指向一塊資料、或者指向一個根本不存在的位址——程式就會暴斃。因為 CPU 試圖在那個位址讀取指令,結果發現那根本不是指令,kernel 就會把程式斃掉。

小測驗

%rip 的功能是什麼?


🏰 第 2 層:stack — 一疊拜訪便條

⚔️ Level 2 / 7 當機的驗屍課(上)
29% 完成

程式不會只有一個 function。function A 會呼叫 B,B 會呼叫 C,C 可能再呼叫 D。這種「拜訪」的層層關係,需要一個機制來記錄「等一下要回哪裡」。

這個機制就是 stack(堆疊)。

這疊便條有幾個重要特性:

  • 後進先出(LIFO):最後壓上去的便條,最先被撕掉
  • 每張便條都有一個「回程地址」:告訴 CPU「這個 function 跑完要回哪裡」
  • %rsp:一根手指,永遠指著最上面那張便條
Mogu 歪樓一下:

%rsp 的全名是 stack pointer(堆疊指標),那個 sp 就是這兩個字的縮寫。這根「手指」非常重要——CPU 要找最上面的便條時,就看 %rsp 指向哪裡。如果這根手指被亂動,整個「回家」的機制就會崩潰。這也是為什麼開頭那句「堆疊指標偏移了 8 個位元組」聽起來這麼恐怖。

每張便條在 stack 上佔一塊空間,這塊空間叫做一個 stack frame(堆疊框)。一個 stack frame 通常包含:

  • 回程地址:這個 function 跑完要回哪裡
  • 區域變數:這個 function 自己用的資料
  • 上一層的 %rbpframe pointer,框架指標):像書籤,方便定位
小測驗

當 function A 呼叫 function B 時,stack 會發生什麼事?


🏰 第 3 層:call / ret — 拜訪與回家

⚔️ Level 3 / 7 當機的驗屍課(上)
43% 完成

現在把便條的比喻對應到真實的 CPU 指令。

當 function A 要呼叫 function B 時,會執行 call 指令。call 做兩件事:

  1. 壓便條:把「A 裡面的下一條指令的位址」壓到 stack 上(這就是回程地址)
  2. 跳過去:把 %rip 設成 B 的第一條指令的位址

當 function B 跑完,要回到 A 的時候,會執行 ret 指令。ret 也做兩件事:

  1. 撕便條:從 stack 頂端拿出回程地址
  2. 跳回去:把 %rip 設成那個回程地址

還有一個暫存器叫 %rbpframe pointer),功能像是夾在某張便條上的書籤。編譯器會用它來定位「當前這一層 function 的 stack frame 在哪」。偵錯的時候,調查員可以從 %rbp 開始,一層一層往上追,重建整個呼叫鏈。

Mogu murmur:

%rbpbpbase pointer(基底指標)的縮寫。有些編譯器會為了效能把 %rbp 省略掉(開 -fomit-frame-pointer 這個選項),但 Rockset 反過來開了 -fno-omit-frame-pointer,所以調查員可以從 %rbp 一路追蹤整個呼叫鏈。這個看似不起眼的編譯選項,後來救了他們一命——這是伏筆,記著。

整個機制穩定到不行——只要便條上的回程地址是正確的,ret 就會乖乖跳回正確的地方。

但如果便條被改了呢?

小測驗

ret 指令執行的時候,會做什麼?


🏰 第 4 層:segfault — 撞牆暴斃

⚔️ Level 4 / 7 當機的驗屍課(上)
57% 完成

現在調查員已經知道 call / ret 的機制了。接下來要搞懂:程式怎麼死的?

假設 stack 上的回程地址被改成了一個「不是指令」的位址——可能是一塊資料、可能是一個沒有對應到實體記憶體的虛擬位址、可能是 NULL(0 號位址)。

ret 指令不會檢查回程地址是不是合理的。它只會:

  1. 從 stack 拿出那個數字
  2. %rip 設成那個數字
  3. 嘗試執行那個位址的「指令」

如果那個位址根本不是可執行的指令,CPU 會產生一個錯誤(fault)。kernel 收到之後,會把程式斃掉。這就是 segfault

Mogu OS:

segfault 的全名是 segmentation fault(區段錯誤),來自早期作業系統把記憶體切成不同「segment(區段)」的概念。當程式試圖存取沒有權限的區段(或根本不存在的區段),就會觸發錯誤。現代作業系統的 segfault 通常是因為存取了無效的虛擬位址,不一定真的跟 segmentation 有關,但這個名字就這麼留下來了。工程師之間講「它 segfault 了」,基本上就是「它撞牆暴斃了」的意思。

在 OpenAI 那批命案裡,死者的回程地址被改成 NULL(0 號位址)。ret 指令把 %rip 設成 0,CPU 嘗試在位址 0 讀取指令,但位址 0 通常故意不對應到任何記憶體(用來抓「存取空指標」這種 bug),kernel 就把程式斃掉。

核心問題來了:誰改了便條?

小測驗

segfault(撞牆暴斃)是怎麼發生的?


🏰 第 5 層:C++ 沒有 memory safety — 沒護欄的城市

⚔️ Level 5 / 7 當機的驗屍課(上)
71% 完成

既然 call / ret 的機制這麼穩定,便條怎麼會被改掉?

答案跟 C++ 這個語言的特性有關。

C++ 給程式設計師「低階控制」的能力——可以直接操作記憶體、直接控制指標、直接決定資料放在哪裡。這種控制力帶來極高的效能,但也帶來風險:沒有什麼東西會阻止程式寫到不該寫的地方

這種「可以寫到任何地方」的特性,在程式語言的術語裡叫做「缺乏 memory safety(記憶體安全)」。

相比之下,有些語言有「護欄」:

  • Rust:有 borrow checker(借用檢查器),編譯器會在編譯時期就檢查記憶體存取是否合法
  • 有垃圾回收(GC)的語言(Java、Go、Python):執行期會幫忙管理記憶體,不讓程式直接亂寫

這些語言的城市有護欄。想寫到別人的便條?編譯器或執行期會擋住。

C++ 的選擇是:不設護欄,讓程式設計師自己負責。這樣的好處是效能——省下護欄的額外負擔。壞處是:一旦寫錯地方,便條就被改了,然後撞牆暴斃,而且調查員很難追兇手。

Mogu 認真說:

OpenAI 的 Rockset 服務選用 C++ 是為了效能和記憶體效率——處理大量即時查詢,需要榨乾每一點效能。但代價就是:一旦出 bug,可能就是這種「便條被改、撞牆暴斃」的詭異命案。這是 C++ 的經典取捨:效能換來的代價,是「沒護欄」。

小測驗

為什麼 C++ 程式的 stack 上的回程地址可能被改掉?


🏰 最終關:重演命案

⚔️ Level 6 / 7 當機的驗屍課(上)
86% 完成

現在調查員具備了足夠的解剖學知識,可以重演那樁命案了。

把線索串起來:

  1. function A 呼叫 function Bcall 指令在 stack 上壓一張便條,寫著「B 跑完回到 A 的某個位址」
  2. function B 正常執行:做該做的事,沒有任何問題
  3. 某個時刻,便條被改了:回程地址被改成 NULL(0 號位址)
  4. function B 執行 ret:從 stack 拿出回程地址(現在是 NULL),把 %rip 設成 NULL
  5. CPU 嘗試執行位址 0 的指令:位址 0 沒有對應到任何記憶體
  6. 撞牆暴斃kernel 斃掉程式,留下一具屍體(命案現場)

調查員現在知道「怎麼死的」了:

  • %rip 最後指向 NULL
  • stack 上的回程便條被改成 NULL
  • ret 照著跳,然後撞牆

但關鍵問題還沒解決:誰改了便條?

盯著這一具屍體,看不出兇手是誰。

工程師做了很多嘗試:讀程式碼、猜假設、一條一條排除。但每個假設都有反例。有些命案的堆疊指標偏移 8 個位元組,有些命案的回程地址是 NULL——看起來像同一種死法,但細節又不太一樣。

這就像面對一連串命案,每具屍體的傷口都有點不同。調查員陷入僵局。

要破案,得換個方法。

不是盯著一具屍體看,而是把全城一年的屍體都撈出來、建檔、分類。這是流行病學的思路:找 pattern,而不是解個案。

Mogu murmur:

劇透一點點:OpenAI 的工程師最後發現,看起來像一種 bug,其實是兩種完全不相關的 bug。一種是硬體壞掉(某台 Azure 主機的 CPU 算數會算錯),另一種是 libunwind 這個函式庫裡藏了 18 年的競態條件(race condition)。正因為他們一直當成同一個 bug 在抓,所以怎麼抓都抓不到。分開來看,答案瞬間就清楚了。這招怎麼辦到的,就是 Post B 的主菜。

調查員學會了看屍體。下一課,要進命案現場。

小測驗

調查員目前已知和未知的分別是什麼?


🎓 通關小結

調查員在這堂課學會了四件事:

概念一句話解釋
%rip死者「此刻人在哪」——CPU 現在要執行哪條指令
stack一疊拜訪便條,記錄每層 function「辦完回哪」
call / ret出門壓便條 / 撕便條照著回家
segfault便條被改成無效地址 → ret 照著跳 → 撞牆暴斃

調查員也知道了 C++ 這座城市的特性:沒有護欄,誰都能改別人的便條。效能極高,但出事的時候會很難抓。

現在調查員理解「怎麼死的」了,但還不知道「誰幹的」。

下一課,調查員要進命案現場——那份封存的屍體檢驗報告(core dump)。會學到什麼是「封鎖線」(red zone)、什麼是「第一個趕到現場的人」(signal handler),以及為什麼單看個案永遠抓不到兇手。

從法醫升級成流行病學家,才有辦法破案 (⁠⌐⁠■⁠_⁠■⁠)

🔗 延伸閱讀

Post B 會大量用到 signal(訊號)這個機制——那個每幾毫秒就送一次、最後變成兇器的 SIGUSR2,就是這一族的成員。想先暖身,或喜歡這種「把底層拆給新手看」的爬塔風,這兩篇同系列可以續攤:

(Post B 待續)