使用 `make` 編譯 C 程式(給非 C 程式設計者)
我從來不是 C 程式設計師,但每隔一段時間就需要從原始碼編譯 C/C++ 程式。這對我來說一直有點吃力:很長一段時間,我的做法基本上就是「安裝相依套件,執行 make,如果失敗,就試著找別人已經編譯好的執行檔,不然就放棄」。
當我還在使用 Linux 時,「指望別人已經編譯好」這招還算管用,但過去幾年改用 Mac 之後,我越來越常遇到必須自己編譯程式的情況。
所以,來談談編譯 C 程式時你可能需要做些什麼吧!我會用幾個我實際編譯過的 C 程式的例子,聊聊可能會出什麼問題。以下是我們會談到的三個程式:
步驟 1:安裝 C 編譯器
這很簡單:在 Ubuntu 系統上,如果我還沒有 C 編譯器,我會用以下指令安裝:
sudo apt-get install build-essential
這會安裝 gcc、g++ 和 make。在 Mac 上的情況比較混亂,但大概就是「安裝 Xcode Command Line Tools」之類的。
步驟 2:安裝程式的相依套件
不像一些較新的程式語言,C 沒有相依套件管理工具。所以如果一個程式有任何相依套件,你就得自己去一一找出來。幸好也因為這樣,C 程式設計師通常會把相依套件維持在最少,而且相依套件往往都可以透過你正在使用的套件管理員取得。
README 裡幾乎總會有一個章節說明如何取得相依套件,例如在 paperjam 的 README 中,它寫道:
要編譯 PaperJam,你需要 libqpdf 和 libpaper 函式庫的標頭檔(通常以 libqpdf-dev 和 libpaper-dev 套件的形式提供)。
你可能需要
a2x(可在 AsciiDoc 中找到)來建置說明手冊頁面。
所以在 Debian 為基礎的系統上,你可以像這樣安裝相依套件。
sudo apt install -y libqpdf-dev libpaper-dev
如果 README 給了一個套件名稱(像是 libqpdf-dev),我基本上都會假設它指的是「在以 Debian 為基礎的 Linux 發行版上」:如果你在 Mac 上,brew install libqpdf-dev 是不會有用的。我到現在還沒有完全掌握在 Mac 上開發的訣竅,所以在這方面還沒有太多心得。我想在這個例子中,如果你使用 Homebrew,應該是 brew install qpdf。
步驟 3:執行 ./configure(如有需要)
有些 C 程式會附帶一個 Makefile,有些則是附帶一個叫做 ./configure 的腳本。舉例來說,如果你下載 sqlite 的原始碼,裡面會有一個 ./configure 腳本,而不是 Makefile。
我對這個 ./configure 腳本的理解是:
- 你執行它,它會印出一大堆有點難以理解的輸出,然後它要嘛產生一個
Makefile,要嘛因為你缺少某個相依套件而失敗 ./configure腳本是名為 autotools 的系統的一部分,除了「執行它來產生Makefile」之外,我從來不需要去學它的其他東西。
我想也許有一些選項可以傳給 ./configure 腳本,讓它產生不同的 Makefile,但我從來沒有這樣做過。
步驟 4:執行 make
下一步是執行 make 來嘗試建置程式。關於 make 的一些說明:
- 有時候你可以執行
make -j8來平行化建置,讓速度更快 - 它在編譯程式時通常會印出一大堆編譯器警告。我一向直接忽略它們。這個軟體又不是我寫的!編譯器警告才不是我的問題。
編譯器錯誤往往是相依套件的問題
以下是我在 Mac 上編譯 paperjam 時遇到的錯誤:
/opt/homebrew/Cellar/qpdf/12.0.0/include/qpdf/InputSource.hh:85:19: error: function definition does not declare parameters
85 | qpdf_offset_t last_offset{0};
| ^
這些年來我學到,最好不要對這類問題想太多:如果它提到了 qpdf,很有可能只是表示我在引入 qpdf 相依套件的方式上做錯了什麼。
現在來談談一些以正確方式引入 qpdf 相依套件的方法。
給編譯器與連結器的極簡介紹
在談如何修復相依套件問題之前:建置 C 程式分為 2 個步驟:
- 用
gcc或clang將程式碼編譯成目的檔 - 用
ld將那些目的檔連結成最終的執行檔
在建置 C 程式時,了解這一點很重要,因為有時候你需要把正確的旗標傳給編譯器和連結器,告訴它們去哪裡找到你要編譯的程式所需的相依套件。
make 使用環境變數來設定編譯器與連結器
如果我在 Mac 上執行 make 來安裝 paperjam,我會得到這個錯誤:
c++ -o paperjam paperjam.o pdf-tools.o parse.o cmds.o pdf.o -lqpdf -lpaper
ld: library 'qpdf' not found
這並不是因為我的系統上沒有安裝 qpdf(其實已經安裝了!)。而是編譯器和連結器不知道如何找到 qpdf 函式庫。要修正這個問題,我們需要:
- 將
"-I/opt/homebrew/include"傳給編譯器(告訴它去哪裡找標頭檔) - 將
"-L/opt/homebrew/lib -liconv"傳給連結器(告訴它去哪裡找函式庫檔案,並連結iconv)
而我們可以透過環境變數,讓 make 把那些額外的參數傳給編譯器和連結器!要了解這是如何運作的:在 paperjam 的 Makefile 裡面,你可以看到一堆環境變數,像是這裡的 LDLIBS:
paperjam: $(OBJS)
$(LD) -o $@ $^ $(LDLIBS)
你放進 LDLIBS 環境變數的所有內容,都會作為命令列參數傳給連結器(ld)。
隱藏的環境變數:CPPFLAGS
Makefile 有時會定義自己的環境變數來傳給編譯器/連結器,但 make 也有許多「隱含的」環境變數,它會自動傳給 C 編譯器和連結器。這裡有一份隱含環境變數的完整清單,其中之一就是 CPPFLAGS,它會自動傳給 C 編譯器。
(嚴格來說,用 CXXFLAGS 會比較正常,但這個 Makefile 把 CXXFLAGS 寫死了,所以設定 CPPFLAGS 是我找到的唯一一種不用編輯 Makefile 就能設定編譯器旗標的方法)
make 跟 C/C++ 的關聯有多緊密——我以前以為 make 只是個通用的建置系統(當然你也可以拿它來做任何事!),但它有許多針對建置 C/C++ 程式而設計的便利功能,是建置其他種類的程式所沒有的。把環境變數傳給 make 的兩種方法
多虧了 @zwol,我才知道其實有兩種方法可以把環境變數傳給 make:
CXXFLAGS=xyz make(常見的做法)make CXXFLAGS=xyz
兩者的差別在於,make CXXFLAGS=xyz 會覆蓋 Makefile 中設定的 CXXFLAGS 值,但 CXXFLAGS=xyz make 不會。
我不確定哪一種才是常規做法,但在這篇文章中我會使用第一種。
如何使用 CPPFLAGS 和 LDLIBS 修復這個編譯器錯誤
既然我們已經談過 CPPFLAGS 和 LDLIBS 如何被傳給編譯器和連結器,以下就是我最後用來成功建置程式的指令!
CPPFLAGS="-I/opt/homebrew/include" LDLIBS="-L/opt/homebrew/lib -liconv" make paperjam
這會把 -I/opt/homebrew/include 傳給編譯器,並把 -L/opt/homebrew/lib -liconv 傳給連結器。
另外,我不想假裝我「神奇地」就知道那些是正確的參數,找出它們的過程其實包含了一大堆讓人困惑的 Google 搜尋,只是在這篇文章中省略了。我想說的是:
-I編譯器旗標告訴編譯器要在哪個目錄尋找標頭檔,例如/opt/homebrew/include/qpdf/QPDF.hh-L連結器旗標告訴連結器要在哪個目錄尋找函式庫,例如/opt/homebrew/lib/libqpdf.a-l連結器旗標告訴連結器要連結哪些函式庫,例如-liconv的意思是「連結iconv函式庫」,或-lm的意思是「連結math」
小技巧:如何只建置某一個特定檔案:make $FILENAME
昨天我發現了一個很酷的工具叫做 qf,它可以讓你從 ripgrep 的輸出中快速開啟檔案。
qf 位於一個包含各種工具的大型目錄中,但我只想編譯 qf。所以我就只編譯 qf,像這樣:
make qf
基本上,如果你知道(或能猜到)你想建置的檔案的輸出檔名,你就可以透過執行 make $FILENAME 來告訴 make 只建置那個檔案
小技巧:你不需要 Makefile
我有時會寫一些沒有相依套件、只有 5 行的 C 程式,而我最近才學到,如果我有一個叫做 blah.c 的檔案,我甚至不需要建立 Makefile,就能像這樣直接編譯它:
make blah
它會被自動展開為 cc -o blah blah.c,可以省下一些打字。我不知道自己是否會記得這個(我可能還是會繼續打 gcc -o blah blah.c),但這似乎是個有趣的小技巧。
小技巧:參考其他打包系統如何建置同一個 C 程式
如果你在建置某個 C 程式時遇到困難,也許其他人也曾遇到同樣的建置問題!每個 Linux 發行版都會為其建置的每個套件提供建置檔,所以即使你無法直接從那個發行版安裝套件,也許還是可以從那個 Linux 發行版獲得如何建置該套件的線索。意識到這一點(感謝我的朋友 Dave(大衛))對我來說是一個巨大的頓悟時刻。
例如,來自 paperjam 的 nix 套件中的這一行寫道:
env.NIX_LDFLAGS = lib.optionalString stdenv.hostPlatform.isDarwin "-liconv";
這基本上是在說「在 Mac 上建置時要傳遞連結器旗標 -liconv」,所以這就是我們可以用來建置它的線索。
同一個檔案還寫著 env.NIX_CFLAGS_COMPILE = "-DPOINTERHOLDER_TRANSITION=1";。我不太確定這是什麼意思,但當我嘗試建置 paperjam 套件時,確實會得到一個關於某個叫做 PointerHolder 的東西的錯誤,所以我想這應該跟「PointerHolder 轉換」有關。
步驟 5:安裝執行檔
一旦你成功編譯了程式,你大概會想把它安裝到某個地方!有些 Makefile 有 install 目標,讓你可以用 make install 把工具安裝到系統上。我對這個做法總是有點擔心(它會把檔案放到哪裡?如果之後想解除安裝怎麼辦?),所以如果我編譯的是一個相當簡單的程式,我通常會改為手動複製執行檔來安裝,像這樣:
cp qf ~/bin
步驟 6:也許可以製作你自己的套件!
一旦弄懂了這一切,我就意識到我可以運用新學到的 make 知識,為 Homebrew 貢獻一個 paperjam 套件!這樣我以後在其他系統上就可以直接執行 brew install paperjam 了。
好處是,即使所有不同打包系統的細節各不相同,它們在本質上都是使用 C 編譯器和連結器。
即使你不是 C 程式設計師,了解一點 C 也會很有用
我覺得這一切是一個很有趣的例子,說明了即使你這輩子從來不打算寫一個像樣的 C 程式,了解一些 C 程式如何運作的基本知識(像是「它們有標頭檔」)也會很有用。
能夠自己編譯 C/C++ 程式的感覺很好,儘管我對所有的編譯器和連結器旗標還不是完全有把握,而且我仍然打算除了「執行 ./configure 來產生 Makefile」之外,永遠不去學 autotools 是如何運作的。
這篇文章中有兩件事我沒有提到:
LD_LIBRARY_PATH / DYLD_LIBRARY_PATH(用來在執行時告訴動態連結器去哪裡尋找動態連結的檔案),因為我已經不記得上次遇到LD_LIBRARY_PATH問題是什麼時候了,也找不到例子。pkg-config,我認為它很重要,但我還沒搞懂
隨機一篇部落格