JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

標頭檔、引入路徑,與連結函式庫

第 3 篇把靜態與動態函式庫分了開來;現在我們要把一個函式庫真正接進一次建置裡。本篇追著每個真實專案都得回答的兩個問題走——編譯器在哪裡找到標頭,連結器又在哪裡找到函式庫——並交給你確切的旗標、那兩種錯誤、以及讓這一步成為初學者最常絆倒之處的那些誠實的眉角。

兩個各自獨立、卻容易搞混的問題

到這裡,你已經能從你自己的檔案建出一支程式,也從第 3 篇知道了把一個靜態函式庫摺進你執行檔、跟向一個動態函式庫在執行時借用之間的差別。我們還沒做的,是那段管線工程:真正告訴建置「用這個外部函式庫」。這聽起來像一件事,其實是兩件——而把它們分清楚,正是讓這個主題從惱人變成豁然開朗的最關鍵一招。那兩個問題是:編譯器在哪裡找到這個函式庫的標頭,以及連結器在哪裡找到這個函式庫已編譯好的程式碼?

這兩者住在建置的兩個不同階段裡,這正是它們讓人困惑的原因。回想分離編譯那張圖:先由編譯器把每個 `.c` 變成一個目的檔,再由連結器把這些目的檔接在一起。標頭在「第一」個階段才重要,函式庫檔在「第二」個階段才重要。標頭是一個承諾;函式庫是兌現那個承諾的程式碼。其中一個對、另一個錯,你就會得到一次在意外之處失敗的建置——所以我們一次處理一個。

標頭:一個編譯器去讀的承諾

當你在一個檔案頂端寫 `#include <zlib.h>`,你貼進來的不是這個函式庫真正的機器碼——你貼進來的是它的標頭檔,那大致上是一串函式原型:像 `int compress(unsigned char *dest, unsigned long *destLen, const unsigned char *source, unsigned long sourceLen);` 這樣的宣告。每個原型告訴編譯器一個函式的名稱、它收哪些引數、回傳什麼——但「不含函式本體」、沒有實作。這就是不帶實作的介面:足夠讓編譯器檢查你的呼叫型別正確,僅此而已。

所以標頭在編譯期的全部工作,就是讓編譯器替你的呼叫做型別檢查。手上有了原型,它就能確認你傳的引數數量與種類都對,也知道結果是什麼型別。關鍵在於,它「不需要」函式本體就能做這件事——前置處理器就是把標頭的文字貼進來,編譯器讀那些宣告,而真正的程式碼留在別處、直到連結期才登場。這就是為什麼少了標頭會給你一個編譯期的錯誤,像是 `implicit declaration of function 'compress'`(隱式宣告函式)或 `unknown type name`(不認識的型別名稱):編譯器根本沒見過那個承諾,所以它沒辦法判斷你的呼叫合不合理。

但編譯器只能貼進它「找得到」的標頭。標頭住在磁碟上的檔案裡,而編譯器會去搜尋一份固定的目錄清單——它的引入路徑——尋找角括號裡的那個名字。系統目錄 `/usr/include` 預設就在那份清單上,這就是為什麼 `#include <stdio.h>` 直接就能用。一個你裝在某個不尋常之處的函式庫就不在清單上,而這正是下一個想法登場的地方。

引入路徑與函式庫路徑:那兩個 -I 與 -L 旗標

編譯器找標頭的搜尋清單,與連結器找函式庫檔的搜尋清單,是引入路徑與函式庫路徑的兩個半邊,而每一邊都有自己的旗標。要把一個目錄加進「標頭」的搜尋裡,你傳 `-I` 再接那個目錄:`gcc -I/opt/zlib/include -c main.c`。那告訴編譯器:「解析一個 `#include` 時,也去 /opt/zlib/include 裡找。」要把一個目錄加進連結器找「函式庫」的搜尋裡,你傳 `-L`:`gcc main.o -L/opt/zlib/lib -lz -o app`。同樣的想法、不同的階段——`-I` 給編譯器,`-L` 給連結器。

注意那條連結指令裡的第三個旗標:`-lz`。這是你用來指名要連結「哪一個」函式庫的方式,它有一個值得學一次的小小命名慣例。`-lz` 的意思是「連結名為 z 的函式庫」,而工具鏈會把它展開成一個檔名:它會在函式庫搜尋路徑裡找 `libz.a`(一個靜態函式庫)或 `libz.so`(一個共享函式庫)。所以 `-lz` 找到 `libz.so`,`-lm` 找到數學函式庫 `libm.so`,`-lpthread` 找到 `libpthread.so`。`lib` 前綴與 `.a`/`.so` 後綴是替你加上的;你只供應中間那段。完全漏掉 `-l`,連結器就根本不會去找那個函式庫,不管你給了它多少條 `-L` 路徑。

# compile phase: -I tells the COMPILER where headers live
gcc -I/opt/zlib/include  -c main.c   ->  main.o

# link phase: -L tells the LINKER where library files live,
#             -lz picks the library  libz.so  (or libz.a)
gcc main.o  -L/opt/zlib/lib  -lz  -o app

#  -I<dir>   add <dir> to the header search   (compiler)
#  -L<dir>   add <dir> to the library search  (linker)
#  -lNAME    link libNAME.so / libNAME.a       (linker)
把一個外部函式庫接進來的三個旗標:編譯期用 -I 找標頭,接著連結期用 -L 與 -l 找函式庫檔。

連結器:未定義參照,與順序的陷阱

現在進到第二個階段。在編譯器憑著標頭的承諾接受了你的呼叫之後,它在目的檔裡留下了一張字條,上面寫著:「我呼叫一個叫 compress 的函式,但我沒有它的程式碼——請誰來提供。」那張沒填上的字條,就是一個「未定義符號」。連結器的工作,是把每一張這樣的字條,去跟真正定義它的程式碼配對,從你其他的目的檔、以及你用 `-l` 指名的那些函式庫裡,把那段程式碼拉進來。當一個符號始終配不上對,連結就失敗,並丟出本整階最有名的那句訊息:`undefined reference to 'compress'`(對 compress 的未定義參照)。

這就是為什麼一個連結器錯誤感覺起來和編譯錯誤這麼不一樣,也是為什麼當它頭一次襲擊一支乾乾淨淨編譯過的程式時,初學者會一頭霧水。編譯這步過了,是因為標頭滿足了它——原型在場、呼叫的型別正確。連結這步失敗了,是因為「程式碼」不見了——你忘了 `-lz`、或 `-L` 指錯了目錄、或你把函式庫名字拼錯了。同一個根本原因,產生了那句口訣的兩個半邊:少了標頭,編譯垮掉;少了函式庫,連結垮掉。

一條你只在執行時才遇上的第三條路徑

這裡有一個轉折,會逮住那些以為程式一旦連結成功就大功告成的人。如果你連結的是一個「動態」函式庫,連結器並沒有把 `libz.so` 複製進你的執行檔——它只記了一張字條,寫著「執行時,去找 libz.so」。所以你連結期用的那條 `-L` 路徑還不夠;當你真正執行這支程式時,另一個搜尋者——動態載入器——得從頭再找一次那個函式庫,而它用的是它「自己」的一份目錄清單,共享函式庫搜尋路徑。那條路徑是第三樣東西,跟 `-I` 和 `-L` 都各自獨立。

這正是那個經典謎團的來源:程式建得完美無瑕,接著卻拒絕啟動、丟出 `error while loading shared libraries: libz.so.1: cannot open shared object file`(載入共享函式庫時發生錯誤:無法開啟共享物件檔)。你的程式碼一點問題都沒有——只是載入器在啟動時找不到那個 `.so`,因為它被裝在某個系統載入器不會去搜尋的地方。在 Linux 上,載入器會檢查像 `/usr/lib` 這類標準目錄,加上環境變數 `LD_LIBRARY_PATH` 裡那份以冒號分隔的清單,再加上 `ldconfig` 建好的快取。所以建置期的 `-L` 與執行期的搜尋路徑,是在兩個不同的時刻回答兩個不同的問題:「去哪裡找到函式庫好記下這條相依」對上「去哪裡找到函式庫好載入它」。

一個靜態函式庫繞開了整條這第三條路徑,而這正是它安靜的魅力:因為它的程式碼在連結期就被複製進了你的執行檔,啟動時沒有 `.so` 要去獵捕,所以程式到哪裡都能跑、不必為函式庫路徑操心。代價,就如第 3 篇所權衡的,是一個更大的執行檔、以及無法共享更新。不論你選哪一個,心智模型都是同一條三步鏈:編譯器找到標頭,連結器找到函式庫檔,而——只有動態函式庫才有的——載入器在你執行時「再一次」找到那個函式庫檔。

親手做一次,然後交給工具去做

親手為一個函式庫一字一句寫出 `-I`、`-L`、`-l`,很有教育意義,但真實的函式庫往往各自需要好幾個旗標,而那些旗標還隨機器不同而不同。標準的解方是一個叫 pkg-config 的小幫手:函式庫安裝時附上一個小小的元資料檔,而 `pkg-config --cflags zlib` 會印出編譯器所需的那些 `-I` 旗標,`pkg-config --libs zlib` 會印出連結器所需的那些 `-L` 與 `-l` 旗標。你把它們嵌進你的建置,讓正確的路徑是被「探查出來」而非寫死的——這正是同一種直覺,放大之後,便驅動了最後一篇裡的套件管理員與 CMake

當你真的撞上其中一個錯誤時,按順序走這條鏈,而不是亂猜。每一步都對應到那三種搜尋之一,所以訊息會告訴你該轉哪一顆旋鈕——而轉對那一顆,遠比四處灑旗標、然後祈禱要快得多。

  1. 編譯器找不到標頭(#include 上出現 'no such file',或 'implicit declaration'):標頭的目錄不在引入路徑裡——用 -I<目錄> 加上它,或安裝這個函式庫的開發套件。
  2. 能編譯卻連結不起來('undefined reference to ...'):你沒指名函式庫、指錯了、或 -L 目錄不對——加上 -l名稱 與正確的 -L<目錄>,並把 -l 放在你的目的檔之後。
  3. 連結得起來卻啟動不了('error while loading shared libraries'):動態載入器在執行時找不到那個 .so——把它裝進標準目錄、執行 ldconfig,或把 LD_LIBRARY_PATH 設成存放它的那個目錄。
  4. 不想親手猜這些旗標:讓 pkg-config 替你吐出它們——`gcc (pkg-config --cflags zlib) -c main.c` 接著 `gcc main.o (pkg-config --libs zlib) -o app`——並讓 CMake 在專案規模上做同一件事。

這就是使用一個外部函式庫的整個樣貌。標頭是一個介面,編譯器讀它來替你的呼叫做型別檢查,沿著引入路徑被找到;函式庫檔是實作,連結器把它接進來,沿著函式庫路徑被找到;而一個動態函式庫在程式執行時,由載入器多加一次搜尋。把這三種搜尋與三句錯誤訊息彼此對應好,一類感覺像黑魔法的 bug,就變成一份簡短、可修的檢查清單——而這正是最後一篇的套件管理員與 CMake,在全規模上交到你手上的那份槓桿。