你的 C 程式究竟「是什麼意思」?
你在這整座梯子上,學的都是底下究竟發生了什麼:原始碼變成機器碼、載入器把它放進記憶體、CPU 一次擷取並照辦一個細小的指令,而堆疊與堆積隨著程式執行而挪動著位元組。此刻你會很想把 C 想成那些指令的一層薄薄、誠實的標籤——以為 `x + 1` 不過「就是」一個 add 指令。但這並不是這個語言所承諾的,而本階段正建立在理解這道落差之上。C 標準根本沒在描述你的處理器。它描述的是一台想像中的電腦。
那台想像中的電腦有個名字:C 抽象機。當語言標準——也就是那份在 1989 年首度通過、稱為 C89、定義了 C 是什麼意思的文件——告訴你某段程式碼做什麼時,它描述的是這台抽象機的行為,而不是任何矽晶片的行為。抽象機有帶著值的變數、有副作用發生的次序、有算術會產生什麼的規則,諸如此類。你真正的程式之所以「正確」,當且僅當它在這台理想化機器上會以某種方式運作。如此一來,編譯器的工作就比你想的窄:產出一個真實的可執行檔,它跑起來時,給出的可觀察結果,要和抽象機本來會給出的一樣。
彷彿規則:解放最佳化器的那份合約
這個原則有個值得認識的名字:彷彿規則(as-if rule)。標準大致是說,一個實作可以隨它高興地變換一個程式,「彷彿」它忠實地執行了抽象機的每一步——前提是程式的可觀察行為要被保住。「可觀察」是一份精確的清單:你的程式所進行的輸入/輸出(每一次 read() 和 write()、每一次 printf()),以及某些 volatile 物件的最終狀態。其餘的一切——確切用了哪些暫存器、各個彼此獨立之計算的順序、某個值是住在記憶體裡還是自始至終只待在暫存器裡——都是編譯器的私事,對一個合規的程式而言是看不見的。
一個小例子能把這份自由感變得鮮明。假設你寫 `int x = 2 + 3; int y = x * 10; printf("%d\n", y);`。抽象機會算出 x = 5、再算 y = 50、再印出 50。但彷彿規則允許編譯器把這一切全跳過:它可以在編譯時就算出 50,產出的程式碼只是印出那個常數。最終的二進位檔裡沒有 x、沒有 y、沒有乘法——然而唯一可觀察的東西,那個印出來的 50,是一模一樣的。這支程式的行為「彷彿」它真的跑了抽象機,而那正是標準唯一要求過的。
you wrote (the abstract machine's steps):
int x = 2 + 3; // x <- 5
int y = x * 10; // y <- 50
printf("%d\n", y); // observable: prints 50
the compiler may emit (as-if, same observable result):
mov edi, 50 ; the 50 was folded at compile time
call printf ; x and y never exist at run time
the contract: only the printed 50 (an output) is observable;
everything else is the compiler's to rearrange or delete.這就是每一個最佳化等級背後的引擎。當你用 `gcc -O2 -Wall main.c` 編譯時,你是在授權編譯器積極地利用彷彿規則——摺疊常數、刪除沒用到的變數、重排彼此獨立的工作、把值留在暫存器裡、把整個函式內聯進來。這一切都不准改變你程式「可觀察地」做了什麼。到目前為止這聽起來純然良善,而且在 -O0 對上 -O2 時,多數程式碼確實表現得一模一樣。麻煩,是從你的程式踏出抽象機的規則那一刻才開始的——因為編譯器的承諾是有條件的,而你剛剛違反了那個條件。
抽象機可以含糊的三種方式
抽象機並沒有把每個細節都釘死。標準刻意留了一些東西鬆動,而把這些鬆動的「種類」分開來,至關重要——因為它們帶來的後果天差地遠。一共有三種,而 C 整個安全性的故事,就活在它們彼此的差別裡。本階下一篇會深入最危險的那一種;這裡,我們把三種並排攤開,好讓你從此不再把它們搞混。
第一種,由實作定義的行為(implementation-defined):標準要求必須做出一個選擇、並且要「載明」,但讓每個實作自己挑。一個 `int` 有幾個位元、一個樸素的 `char` 是有號還是無號、某些轉換的結果——這些都是真實、穩定的答案,你的編譯器會在它的手冊裡告訴你,只是各家不見得相同。第二種,未指定行為(unspecified):標準給出一份允許結果的菜單,實作可以任挑其一,不必載明、甚至不必前後一致。經典例子是 `f(a(), b())` 裡函式引數的求值次序——可能 a() 先跑,也可能 b() 先跑,而你絕不可以倚賴任一邊。這兩種屬於未指定與由實作定義的行為的領域,兩者都活得下去:一個可移植的程式,只要避免去依賴它們就好。
第三種,而且完完全全在另一顆星球上:未定義行為(undefined behavior),通常寫作 UB。在這裡,標準並不提供一份結果菜單——它撤回「所有」要求。如果你的程式執行了一個被標準標為未定義的動作,那麼從那一點起,抽象機就再也沒有任何已定義的行為,而實作獲准做「字面上的任何事」。不是「數種之一」:是任何事。標準用的措辭是它「不施加任何要求」。這正是本階段存在的目的所要教的主題,而本篇接下來會說明,為什麼這一條規則,遠比它乍聽之下更鋒利、也更危險。
為什麼 UB 讓編譯器刪掉並重排你的程式碼
接下來這一步,是讓每個人都吃一驚的,而它從彷彿規則出發,帶著一種冷酷的邏輯。最佳化器獲准「假設未定義行為永遠不會發生」。它不會替它插入檢查;它不會優雅地處理它;它就只是理所當然地認定:你這位程式設計師,從來不會寫出觸發 UB 的程式——因為標準說,一個正確的程式不會這麼做。於是編譯器從這個假設往前推理,而那串推理,會回過頭來改寫看起來毫不相干的程式碼。
看一個有名的形狀。假設你在某處寫了 `int *p = ...; int v = *p; if (p == NULL) handle_null();`。在 `int v = *p;` 這一行解參考 p 的同時,你已經向編譯器承諾了 p 不是空的——因為解參考一個空指標是未定義的,而編譯器假設 UB 永不發生。於是它推斷出 p「在下一行也」不可能是空的,並且完全有權把 `if (p == NULL)` 這個檢查當成死碼刪掉,因為在它的假設底下,那條分支永遠走不到。你的空指標檢查就此消失。那支本該自我防衛的程式,如今直直駛進它本來在提防的那場崩潰——而依照標準,編譯器一點錯也沒犯。
現在你終於能解釋一個讓每個初學者都困惑的現象:一個臭蟲在 -O0 下乖乖的、在 -O2 下卻搞毀一切。在 -O0 下,編譯器幾乎什麼都不最佳化,於是你那段有問題的程式碼常常「碰巧」做了某件無害的事——被刪的檢查還在、那個過時的值還在記憶體裡、程式一跛一跛地走著。把最佳化器調高,同樣的程式碼,如今在彷彿規則下被推理一番,便以你的 UB 讓它變得「合法」的種種方式被變換,於是潛伏的臭蟲引爆了。程式碼沒變;變的是編譯器「利用你那個破裂之承諾」的許可。臭蟲一直都在,-O2 只是不再替它遮掩罷了。
讀懂抽象機:序列點與次序
抽象機還有最後一塊,值得湊近細看,因為它生出一整族你會在後續幾篇遇到的 UB:副作用發生的「次序」。在抽象機上,那些一切都保證已塵埃落定的時刻,叫做序列點(sequence point)——例如一條完整敘述在其分號處的結束、或逗號運算子裡的那個逗號。在兩個序列點之間,依彷彿規則,實作可以隨它高興地把各個子計算交錯進行。這份自由本來無害——直到其中兩個子計算碰上了「同一個」物件。
最典型的陷阱是 `i = i++ + 1;` 或 `a[i] = i++;`。這裡你修改了 i,又在沒有序列點隔開這兩次存取的情況下再次讀取或修改它,而標準宣告整件事是未定義的——不是未指定、不是「i 最後會是兩個值之一」,而是徹底的 UB。人們以為會得到一個「確定但因平台而異」的結果;他們實際拿到的,是一支根本沒有已定義意義的程式,最佳化器大可把它當成「想做什麼都行」的許可證。誠實的規則很直白:在兩個序列點之間,絕不要對同一個物件又讀又寫兩次;也絕不要寫一條其結果取決於引數求值次序的單一運算式。
留意整幅圖變得多麼乾淨。抽象機告訴你意義;彷彿規則告訴你編譯器可以用任何手段去交付那個意義;由實作定義與未指定行為,是你能繞著寫程式的、有界限的含糊;而 UB 是意義就此停止的那道懸崖邊——過了它,彷彿規則就不再保護你,因為已經沒有任何東西可供它去忠實了。本階段接下來的每一篇,都是一趟具體懸崖邊的巡禮——溢位、越界存取、讀取未初始化的變數、緩衝區溢位——以及那些把你拉回安全距離的安全編碼習慣。你現在握住了它們全都掛在上面的那副框架。