第一個問題:是哪個階段在說話?
你現在已經走過整條管線了:前置處理器貼文字、編譯器把一個翻譯單元變成組合語言、組譯器做出一個目的檔、連結器把碎片接起來。出狀況時最有用的單一技能,是在讀訊息的任何一個字之前先問:這是那四個階段裡的哪一個產生的?因為這四個階段知道的事完全不同,它們的錯誤意思也就完全不同——而初學者常常花上好幾個鐘頭去修一個函式的內容,但真正的抱怨卻來自一個根本沒看過那段內容的階段。
有個快速的判別法。編譯器的訊息幾乎一定帶著檔名和行號——`main.c:8:5: error: ...`——因為編譯器正在讀你的文字,能精準指出它是在哪裡卡住的。連結器的訊息則沒有:它指名一個符號,像是 `undefined reference to 'sqrt'`,沒有行號,常常前面冠著連結器自己的名字(`ld:`),或被標成 'collect2' 的錯誤。光是這一個差別——有行號還是只有一個光禿禿的符號名——通常就能在你還沒讀懂訊息的其他任何細節之前,告訴你正在跟工具鏈的哪一半搏鬥。
編譯器錯誤:一個編譯器無法剖析的句子
編譯器錯誤的意思是:在讀某一個翻譯單元時,編譯器撞上了違反 C 規則的文字——壞掉的文法、一個未宣告的名字、一個型別不符。經典的格式是 `檔案:行:欄: error: 訊息`。嚴格地由左讀到右。檔案和行把你定位;欄號指出那一行裡的位置;`error:` 這個字標示嚴重程度;而訊息用白話說出哪條規則被破壞了。真實錯誤裡有極大一部分都很平凡:少了一個分號(編譯器通常會把它報在真正那一行的「下」一行,因為句子是在那裡才終於講不通的)、一個多餘的大括號,或一個打錯字的變數名。
有一個編譯器錯誤值得特別注意,因為它預示了連結器。如果你呼叫一個編譯器從沒聽過的函式,現代 C 會報 `error: implicit declaration of function 'foo'`。這是編譯器在告訴你,它找不到函式原型——沒有任何承諾說 `foo()` 存在、收這些引數。解法幾乎一定是漏了 `#include` 那個宣告它的標頭檔。注意這裡的層次:這是一個關於「宣告」的「編譯器」錯誤。就算你修好了它,之後仍可能撞上一個「連結器」錯誤——如果那個函式雖然有宣告、卻從沒被真正供應的話。兩個不同的階段、兩個不同的問題,很容易搞混。
那句有名的:undefined reference
現在來看另一半。`undefined reference to 'sqrt'` 這條訊息是一個連結器錯誤,它有個精確的意思:每個翻譯單元各自編譯都沒問題,但當連結器試著把目的檔接起來時,它發現了一個洞——某個目的檔「需要」的一個名字——卻沒有任何目的檔或函式庫「提供」它。用上一篇的詞彙來說,這個符號是一個未定義符號,而它一直未被定義。編譯器很滿意,因為一個標頭檔承諾了 `sqrt()` 存在;連結器不滿意,因為在連結時,沒有人真的把那段機器碼交出來。
真正的成因只有少數幾個,而把它們一一命名,就等於治好了一半。(1)你忘了連結某個函式庫:`sqrt()` 住在數學函式庫裡,所以解法是 `gcc main.c -lm`——`-lm` 告訴連結器也去搜尋 libm。(2)你在另一個 `.c` 檔裡寫了一個函式,卻忘了把它一起編譯、連結進來:`gcc main.c helper.c`。(3)一個打錯字、或宣告與定義之間的不符——宣告成 `sqrt`、卻定義成 `Sqrt`——於是名字永遠對不上。(4)順序:對靜態函式庫而言,供應某個符號的函式庫,必須排在需要它的那個目的檔「之後」(在指令列上),這是連結器由左往右掃描所留下的一個老脾氣。
main.c: the verdict: #include <math.h> <math.h> declares sqrt -> compiler: OK double r = sqrt(2.0); sqrt's code is in libm -> linker: ? $ gcc main.c /usr/bin/ld: undefined reference to `sqrt' $ gcc main.c -lm (links libm) -> a.out OK
它的鏡像:multiple definition
如果說 `undefined reference` 是「一個需要的名字卻沒有供應者」,它的鏡像就是 `multiple definition of 'count'`——「一個名字卻有太多供應者」。這同樣是一個連結器錯誤,意思是有兩個目的檔,各自提供了同一個全域符號的一份真正的定義,而連結器拒絕去猜你指的是哪一個。最常見的禍首,是把一個「定義」放進了標頭檔。因為前置處理器會把標頭貼進每一個引入它的原始檔,所以一行像 `int count = 0;` 坐在標頭裡,就會在「每一個」翻譯單元裡各變成一個叫 `count` 的獨立全域——而在連結時,它們就撞在一起了。
解法把整套「標頭對原始碼」的紀律都編碼進去了。標頭只應該「宣告」——做出承諾——用 `extern int count;`,意思是「某處存在一個全域 `count`」。然後恰好一個原始檔把它「定義」一次,`int count = 0;`,這才真正保留了那塊儲存空間。宣告放標頭、定義放在某一個 `.c`:就這一條規則,能讓連結器對每個名字都恰好看見一個供應者。(注意:一個引入防衛並不能修這個——防衛擋的是同一個檔案裡標頭被貼「兩次」,但 `multiple definition` 是跨「不同」檔案的,防衛從不碰它。)
面對任何沒見過的訊息的一套方法
你會遇到本篇從沒列出的訊息。重點從來不是要你背一本目錄——而是給你一套程序,把任何陌生的一面文字,變成一個有位置、有名字的問題。照順序由上往下跑,一旦解法明顯了就停下。
- 捲到輸出裡的「第一條」錯誤,先別理它下面那一連串。
- 判別階段:它帶著 檔案:行(編譯器),還是指名一個沒有行號的光禿符號(連結器)?這決定了接下來的一切。
- 若是「編譯器」:到那個確切的檔案與行,照字面讀訊息,也檢查上面一行——少了的分號或大括號通常會晚報一行。
- 若是「連結器」:讀那個符號名。'undefined reference' 表示缺了供應者(用 -l 連結函式庫、補上漏掉的 .c、或修正打錯的名字);'multiple definition' 表示一個定義漏進了標頭。
- 套用「一個」修正、重新建置、再從頭讀起——許多後續錯誤已經不見了。
- 還是卡在字面上?把那段訊息原原本本地搜尋;既然階段已經辨識出來,你會帶著真正的理解去讀答案,而不是亂猜。
這就是整個本階安靜的回報。一面紅色的建置輸出,過去感覺像是機器毫無理由地拒絕你。現在你知道根本沒有「那台機器」這回事——有的是四支程式,每一支職責狹窄,每一支用自己的方言、為自己關心的事抱怨。為階段命名,訊息就不再是雜訊,而成了一道精確的指示。你已經從「它編不過」走到了「連結器找不到 sqrt,因為我沒有傳 -lm」——而這句話本身,就已經包含了它的解藥。