GNU Make 相容的 Makefile 教學:多設定檔專案

PUBLISHED ON JUN 24, 2018 — BUILD AUTOMATION

    隨著專案變大,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 的執行檔名稱是其他的名字,像 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=/Wall /sdl /Zi
    else
    	CFLAGS_DEBUG=-Wall -Wextra -g -std=c99
    endif
    
    ifeq ($(TARGET),Debug)
    	CFLAGS=$(CFLAGS_DEBUG)
    else
    	ifeq ($(CC),cl)
    		CFLAGS=/Wall /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 命令稿。