Using `make` to compile C programs (for non-C-programmers)

Julia Evans

用 `make` 編譯 C 程式(給非 C 程式設計師)

原文由 Julia Evans 發布,訂閱此部落格

我從來不是個 C 程式設計師,但每隔一陣子就得從原始碼編譯 C/C++ 程式。這對我來說一直有點辛苦:很長一段時間,我的做法基本上就是「先裝好相依套件,然後跑 make,如果跑不起來,就去找別人編好的執行檔,不然就放棄」。

「指望別人已經編好了」這招在用 Linux 的時候還算管用,但過去幾年改用 Mac 之後,就越來越常遇到得自己動手編譯的情況。

來聊聊編譯 C 程式時可能會需要做的事吧!我會用幾個自己實際編過的 C 程式當例子,談談可能會出錯的地方。我們會提到的三個程式是:

  • paperjam
  • sqlite
  • qf(一個可以搭配 rg -n THING | qf 快速開啟搜尋結果的 pager)

步驟一:安裝 C 編譯器

這一步很簡單:在 Ubuntu 系統上,如果還沒裝 C 編譯器,我會這樣安裝:

sudo apt-get install build-essential

這會裝好 gccg++make。在 Mac 上的情況就比較混亂一點,大概就是「安裝 Xcode command line tools」之類的。

步驟二:安裝程式的相依套件

跟一些比較新的程式語言不同,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

步驟三:執行 ./configure(如果需要的話)

有些 C 程式會附 Makefile,有些則是附一個叫做 ./configure 的腳本。舉例來說,如果你下載 sqlite 的原始碼,裡面就不是 Makefile,而是一個 ./configure 腳本。

我對這個 ./configure 腳本的理解是:

  1. 你執行它,它會印出一大堆有點讓人摸不著頭緒的輸出,然後不是產生出一個 Makefile,就是因為少了某個相依套件而失敗
  2. 這個 ./configure 腳本是一套叫做 autotools 的系統的一部分,我從來不需要學它,除了「執行它來產生 Makefile」這件事以外。

我想應該可以傳一些選項讓 ./configure 腳本產生出不同的 Makefile,不過我從來沒這樣做過。

步驟四:執行 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 程式會分成兩個步驟:

  1. 編譯程式碼成目的檔(用 gccclang
  2. 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

Makefiles 有時會定義自己的環境變數來傳給編譯器/連結器,但 make 也有一堆「隱含的」環境變數,會自動傳給 C 編譯器和連結器。這裡有一份隱含環境變數的完整清單,其中之一就是 CPPFLAGS,它會自動傳給 C 編譯器。

(嚴格來說,這種情況用 CXXFLAGS 會比較正常,但這個 MakefileCXXFLAGS 寫死了,所以設定 CPPFLAGS 是我找到唯一不用修改 Makefile 就能設定編譯器旗標的方法)

題外話:我花了很久才意識到 `make` 跟 C/C++ 的關係有多緊密——我以前以為 `make` 只是個通用的建置系統(當然你確實可以用它來建任何東西!),但它其實為建置 C/C++ 程式提供了許多其他種類程式所沒有的貼心設計。

兩種把環境變數傳給 make 的方法

多虧了 @zwol 我才知道,其實有兩種把環境變數傳給 make 的方式:

  1. CXXFLAGS=xyz make(常見的做法)
  2. make CXXFLAGS=xyz

兩者的差別在於,make CXXFLAGS=xyz 會覆蓋 Makefile 裡設定的 CXXFLAGS 值,但 CXXFLAGS=xyz make 不會。

我不確定哪一種才是常規,不過在這篇文章裡我會用第一種方式。

如何用 CPPFLAGSLDLIBS 來修正這個編譯錯誤

既然已經談過 CPPFLAGSLDLIBS 是怎麼傳給編譯器和連結器的,這裡就是我最後用來成功建置程式的咒語!

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 發行版都會為它們建置的每個套件準備建置檔,所以就算你沒辦法直接安裝那個發行版的套件,也許還是能從那個發行版的建置方式中得到一些提示。意識到這點(感謝我的朋友 Dave)對我來說真是個恍然大悟的時刻。

舉例來說,這一行來自 paperjam 的 nix 套件寫道:

  env.NIX_LDFLAGS = lib.optionalString stdenv.hostPlatform.isDarwin "-liconv";

這基本上就是在說「在 Mac 上建置時要傳連結器旗標 -liconv」,所以這就是我們可以用來建置它的線索。

同一個檔案還寫著 env.NIX_CFLAGS_COMPILE = "-DPOINTERHOLDER_TRANSITION=1";。我不太確定這是什麼意思,不過當我嘗試建置 paperjam 套件時,確實會得到一個關於某個叫 PointerHolder 的錯誤,所以我想這應該跟「PointerHolder transition」有關。

步驟五:安裝執行檔

一旦成功編譯出程式,你大概會想把它安裝到某個地方!有些 Makefileinstall 目標,讓你可以用 make install 把工具安裝到系統上。我對這個總是有點害怕(它到底會把檔案放到哪裡?以後如果想移除要怎麼辦?),所以如果編譯的是比較簡單的程式,我通常會直接手動複製執行檔來安裝,像這樣:

cp qf ~/bin

步驟六:也許可以自己做一個套件!

搞懂這一切之後,我發現自己可以用新學到的 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,我覺得它很重要,但我還沒搞懂

本文章由 muse-spark-1.2-contributor 進行翻譯

留言