位元詩人 [GNU Make] Makefile 教學:如何建立多設定檔專案

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

隨著專案變大,Makefile 長度也會逐漸拉長,若再加上跨平台的需求,設定檔會更加冗長。在一個專案中,make 命令稿不僅限於單一檔案,我們可以將 make 命令稿拆成多個檔案,每一份 make 命令稿看起來就不會那麼冗長。一個常見的策略是將共通的部分保留在主要的 Makefile 中,再針對不同平台各自撰寫 make 設定檔。

多設定檔專案的前提在於我們可以從 Makefile 中呼叫其他的 Makefile。在終端機呼叫特定 make 命令稿的指令如下:

$ make -C /path/to/config -f config_file task

在 Makefile 中可參考以下方式:

someTask:
    $(MAKE) -C $(TARGET_DIR) -f $(CONFIG_FILE) task

在此處,我們用 $(MAKE) 變數而非實際的 make 指令,因為有時候 Make 的執行檔名稱是其他的名字,像 Windows 上的 GNU Make 移植品可能是 mingw32-make,而非 make

筆者在 GitHub 上放了一個微型專案,是筆者練習二元搜尋樹時所寫的範例,讀者可自行前往觀看。在這份專案中,讀者可先忽略二元樹的實作,專注在 Makefile 上即可。

本專案的架構如下:

$ tree
.
├── include
│   └── (省略一些訊息)
├── Makefile
├── src
│   ├── (省略一些訊息)
│   ├── Makefile
│   └── Makefile.win
└── test
    ├── Makefile
    ├── Makefile.win
    └── (省略一些訊息)

我們將 tree 輸出的結果中,略去和 Make 設定檔無關的部分。由此輸出可看出,我們共有五個 Make 設定檔。

使用此專案的方式如下:

  • makemake dynamic:編譯動態函式庫
  • make static:編譯靜態函式庫
  • make test:編譯並執行測試程式
  • make memo:編譯測試程式後,檢查其記憶體使用情形
  • make trim:去除檔案尾端的空白
  • make clean:去除由編譯器所産生的檔案

本微專案在 Windows 和 GNU/Linux 上測試過,可使用 Visual C++、GCC、Clang 等 C 編譯器來編譯此專案的程式碼。

在專案根目錄的 Makeifle 是主要的 Make 設定檔,執行 Make 時會先取讀此設定檔。

一開始是偵測專案所在平台的程式碼:

ifeq ($(OS),Windows_NT)
    detected_OS := Windows
else
    detected_OS := $(shell sh -c 'uname -s 2>/dev/null || echo not')
endif

export detected_OS

其實和先前的程式碼相同。但在最後以 export 將變數輸出,這樣子之後的 Make 設定檔就不需重寫相同的程式碼,可沿用現有的變數。其他的變數也比照此法處理,不重覆寫出。

我們動態地設定 Make 設定檔名稱:

ifeq ($(detected_OS),Windows)
    CONFIG=Makefile.win
else
    CONFIG=Makefile
endif

之後就不需要把不同系統所用的設定檔名稱寫死在設定檔中。在此專案中,類 Unix 系統共用一個設定檔,Windows 系統使用一個設定檔。

在我們上述的改寫後,編譯動態函式庫的指令如下:

all: dynamic

dynamic:
    $(MAKE) -C $(SOURCE_DIR) -f $(CONFIG) dynamic

由於我們把指令參數化了,乍看是同一條指令,在不同系統上實際對應的指令會動態改變。我們就是透過這條指令呼叫相對應的 Make 設定檔並執行相關的任務。

本專案執行測試的指令如下:

test: trim
    $(MAKE) -C $(SOURCE_DIR) -f $(CONFIG) compile_debug
    $(MAKE) -C $(TEST_DIR) -f $(CONFIG) test

trim:
    $(MAKE) -C $(SOURCE_DIR) -f $(CONFIG) trim
    $(MAKE) -C $(TEST_DIR) -f $(CONFIG) trim

為什麼要這樣寫呢?我們想在執行 test 任務前,先將程式碼尾端的空白去掉,所以我們在 test 任務前會呼叫 trim 任務。

在這個專案中,程式碼分散在兩處,所以我們要先在 $(SOURCE_DIR) 中編譯目的檔 (objects),再到 $(TEST_DIR) 編譯並執行測試測試程式。

從這些指令可看出,我們的指令基本上和平台脫勾了,實際的指令會根據不同的 Make 設定檔而有所不同。接著,我們以 src/Makefile.win 為例,來看實際的指令如何撰寫。

我們動態地決定 CFLAGS

ifeq ($(CC),cl)
    CFLAGS_DEBUG=/W4 /sdl /Zi
else
    CFLAGS_DEBUG=-Wall -Wextra -g -std=c99
endif

ifeq ($(TARGET),Debug)
    CFLAGS=$(CFLAGS_DEBUG)
else
    ifeq ($(CC),cl)
        CFLAGS=/W4 /sdl /O2
    else
        CFLAGS=-Wall -Wextra -O2 -std=c99
    endif
endif

我們根據 C 編譯器的不同,決定目的檔的名稱:

ifeq ($(CC),cl)
    OBJS=bstree.obj bstiter.obj bstnode.obj
else
    OBJS=bstree.o bstiter.o bstnode.o
endif

同樣地,我們根據 C 編譯器的不同,動態地設定函式庫檔名:

ifeq ($(CC),cl)
    DYNAMIC_LIB=algobstreei.dll
else
    DYNAMIC_LIB=libalgobstreei.dll
endif

ifeq ($(CC),cl)
    STATIC_LIB=algobstreei.lib
else
    STATIC_LIB=libalgobstreei.a
endif

我們根據 C 編譯器的不同,決定編譯動態函式庫的指令:

dynamic:
ifeq ($(CC),cl)
    for %%x in (*.c) do $(CC) $(CFLAGS) /F 8192 /I..\include /c %%x
    link /DLL /out:$(DYNAMIC_LIB) *.obj
else
    for %%x in (*.c) do $(CC) $(CFLAGS) -fPIC -c %%x -I"..\include"
    $(CC) $(CFLAGS) -shared -o $(DYNAMIC_LIB) *.o -I"..\include"
endif

在此段程式碼中,我們假定使用者使用 Visual C++ 或 MinGW,考量目前 C 編譯器的市佔率,這樣的寫法應可滿足大部分的使用者。

在編譯給測試程式的目的檔時,我們刻意指定適合除錯的編譯器參數:

compile_debug: CFLAGS := $(CFLAGS_DEBUG)
compile_debug: $(OBJS)

%.obj: %.c
    $(CC) $(CFLAGS) /F 8192 /I..\include /c $<

%.o: %.c
    $(CC) $(CFLAGS) -c $< -I"..\include"

由本範例專案可看出,適度地將 Make 命令稿分離,我們可以減少單一 Make 命令稿的長度,並以更有邏輯的方式組織 Make 命令稿。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。