手把手教你寫一個 Makefile 文件

如果我們是在 Linux 下開發,那 Makefile 肯定要知道,不懂 Makefile,面對較大的工程項目的時候就會比較麻煩,懂得利用開發工具將會大大提高我們的開發效率,也可以說 Makefile 是必須掌握的一項技能。

一、瞭解什麼是 Makefile

一個大型工程中的源文件不計其數,各個功能或者模塊分別放在不同的目錄下,手動敲命令去編譯就帶來很大的麻煩,那麼 Makefile 可以定義一系列的編譯規則,哪些文件需要先編譯,哪些文件需要後編譯,哪些文件需要重新編譯,甚至進行更復雜的功能操作,Makefile 帶來的好處就是——“自動化編譯”,一旦寫好,只需要一個 make 命令,整個工程完全自動編譯,極大的提高軟件開發的效率。

make 是一個命令工具,是一個解釋 Makefile 中指令的命令工具,一般來說,大多數的 IDE 都有這個命令,比如:Linux 下 GNU 的 make、Visual C++ 的 nmake、Delphi 的 make。可見,Makefile 都成爲了一種在工程方面的編譯方法。當然,不同產商的 make 各不相同,也有不同的語法,但其本質都是在 “文件依賴性” 上做文章

二、明白編譯鏈接過程

在編寫 Makefile 之前,還是要先了解清楚程序編譯鏈接過程,無論是 c、c++,首先要把源文件編譯成中間代碼文件,在 Windows 下也就是 .obj 文件,Unix/Linux 下是 .o 文件,即 Object File,這個動作叫做編譯(compile)。然後再把大量的 Object File 合成執行文件,這個動作叫作鏈接(link)。

編譯時,編譯器需要的是語法的正確,函數與變量的聲明的正確。對於後者,通常是你需要告訴編譯器頭文件的所在位置(頭文件中應該只是聲明,而定義應該放在 C/C++ 文件中),只要所有的語法正確,編譯器就可以編譯出中間目標文件。一般來說,每個源文件都應該對應於一箇中間目標文件(O 文件或是 OBJ 文件)。

鏈接時,主要是鏈接函數和全局變量,所以,我們可以使用這些中間目標文件(O 文件或是 OBJ 文件)來鏈接我們的應用程序。鏈接器並不管函數所在的源文件,只管函數的中間目標文件(Object File),在大多數時候,由於源文件太多,編譯生成的中間目標文件太多,而在鏈接時需要明顯地指出中間目標文件名,這對於編譯很不方便,所以,我們要給中間目標文件打個包,在 Windows 下這種包叫 “庫文件”(Library File),也就是 .lib 文件,在 Unix/Linux 下是 Archive File,也就是 .a 文件,也叫靜態庫文件。

總結一下,編譯鏈接的過程如下:

  1. 源文件首先會生成中間目標文件,再由中間目標文件生成執行文件。

  2. 在編譯時,編譯器只檢測程序語法,和函數、變量是否被聲明。如果函數未被聲明,編譯器會給出一個警告,但可以生成 Object File。

  3. 在鏈接程序時,鏈接器會在所有的 Object File 中找尋函數的實現,如果找不到,那就會報鏈接錯誤碼(Linker Error),在 VC 下,這種錯誤一般是:Link 2001 錯誤,意思是說,鏈接器未能找到函數的實現。你需要指定函數的 Object File。

三、編寫一個簡單的 Makefile

1. Makefile 的基本語法規則:

目標 ... : 依賴 ...
        實現目標的具體表達式(命令)
        ...
        ...

總結】:通過依賴 (prerequisites) 中的一些文件生成目標 (target) 文件,目標文件要按照命令 (command) 中定義的規則來生成。

2. 來看一個簡單的示例代碼

簡單寫三個方法文件 (openFile.c、readFile.c、writeFile.c)、一個頭文件(operateFile.h) 和一個主函數文件(main.c),代碼如下:

// openFile.c
#include "operateFile.h"

void openFile()
{
    printf("open file...........\n");
}
// readFile.c
#include "operateFile.h"

void readFile()
{
    printf("read file...........\n");
}
// writeFile.c
#include "operateFile.h"

void writeFile()
{
    printf("write file...........\n");
}
// operateFile.h
#ifndef __OPERATEFILE_H__
#define __OPERATEFILE_H__

#include <stdio.h>

void openFile(void);
void readFile(void);
void writeFile(void);

#endif
// main.c
#include <stdio.h>
#include "operateFile.h"

int main()
{
    openFile();
    readFile();
    writeFile();
    
    return 0;
}

3. 根據上面的語法規則及編譯鏈接過程編寫一個 Makefile 文件

main:main.o openFile.o readFile.o writeFile.o  # main生成所需要的.o文件
    gcc -o main main.o openFile.o readFile.o writeFile.o  # 生成main的規則

main.o:main.c  # mian.o文件生成所需要的mian.c文件
    gcc -c main.c
openFile.o:openFile.c
    gcc -c openFile.c
readFile.o:readFile.c
    gcc -c readFile.c
writeFile.o:writeFile.c
    gcc -c writeFile.c

clean:    # 需要手動調用
    rm *.o main

注意:Makefile 的註釋符號是 ‘#’。

4. 編寫完成後,執行 make 命令,make 會在當前目錄下找到名字爲 Makefile 或 makefile 的文件,程序就會自動運行,產生相應的中間文件和可執行文件

a. 如果執行 make 出現如下信息,那就是命令行 (makefile 中的 gcc 或者 rm) 前面沒有用 tab 鍵縮進,不能用空格:

b. 如果執行 make 出現如下信息,那就是你的代碼沒有修改過,Makefile 拒絕你的請求:

這裏還會有一種情況就是如果只修改過其中一個文件,那麼重新編譯就可以看到只編譯修改的那個文件,沒有編譯其他未修改的文件,避免了重複編譯。這裏可以想象在一個大型源碼的工程或者一個內核源碼,裏面的源文件上千或上萬個,如果只修改了一個小問題,就要全部重新編譯,就會花費大量編譯的過程,Makefile 就可以避免這個問題,而且支持多線程併發操作,可以減少很多編譯的時間,提高工作效率。

那麼 Makefile 是如何判斷文件是否有修改過呢?

Makefile 是通過對比時間戳,當我們生成中間文件或可執行文件之後,他們的創建時間肯定要比 .c 文件最後修改的時間晚,如果某個 .c 文件有新修改過,它的時間戳肯定會比原來生成中間文件或可執行文件的時間戳晚,這樣就判斷這個 .c 文件有被更新過,就會重新編譯它。

5. 正常運行後,執行可執行文件輸入 ./main 即可,就能看到代碼執行的結果

6. 在 makefile 文件的最後可以看到有個 clean,這個 clean 就是前面所說的標籤,它不是一個文件,所以 make 無法生成它的依賴關係和決定它是否要執行,只能通過顯示指定這個目標纔可以 ,通過 make clean 的指令就可以執行 clean 下面的命令。

到這裏,一個基礎版的 Makefile 就完成了。

四、Makefile 的優化

學會了編寫基礎版的 Makefile 後,就可以對剛剛寫的 Makefile 進行優化。

優化 1:省略命令

我們將上面寫的基礎版 Makefile 改成下面這樣的省略版:

main:main.o openFile.o readFile.o writeFile.o   
    gcc -o main main.o openFile.o readFile.o writeFile.o  

clean:     
    rm *.o main

執行 make 後的結果:

可以看到,這些文件都在同一目錄下的時候,省略版和基礎版的結果是一樣的,省略版的 makefile 中去掉了生成 main.o、openFile.o、readFile.o 和 writeFile.o 這些目標的依賴和生成命令,這就是 make 的隱含規則,make 會試圖去自動推導產生這些目標的依賴和生成命令,這個行爲就是隱含規則的自動推導。

優化 2:引入變量

這裏引入變量的意思有點像使用宏替換,改成 $(變量名),$ 是格式:

TARGET = main
OBJS = main.o openFile.o readFile.o writeFile.o
CC = gcc

$(TARGET):$(OBJS)
    $(CC) -o $(TARGET) $(OBJS)

clean:     
    rm $(OBJS) $(TARGET)

優化 3:引入函數

格式:$(函數名  實參列表)

# 函數1
$(wildcard  *.c)    # 表示當前路徑下的所有的 .c
# 函數2
$(patsubst %.c, %.o, 所有的.c文件)    # 生成中間文件 .o
# 函數3
$(notdir xxx)   # 去除xxx文件的絕對路徑,只保留文件名

引入函數後的 Makefile 版本可以改寫成:

TARGET = main 
SOURCE = $(wildcard *.c)
OBJS = $(patsubst %.c, %.o, $(SOURCE))
CC = gcc

$(TARGET):$(OBJS)
    $(CC) -o $(TARGET) $(OBJS)
    
clean:     
    rm $(OBJS) $(TARGET)

優化 4:對文件進行分類管理

在一個實際工程項目中程序文件比較多,我們就會對文件按照文件類型進行分類,分爲頭文件、源文件、目標文件和可執行文件,分別放在不同的目錄中,由 Makefile 統一管理這些文件,將生產的目標文件放在目標目錄下,可執行文件放到可執行目錄下,分類目錄如下圖:

可見原來那些文件都不在同一目錄下了,那麼這時候如果還用之前的 Makefile,make 就沒法處理了,自動推導也會無法進行,就需要改成如下:

INC_DIR = ./include
BIN_DIR = ./bin
SRC_DIR = ./src
OBJ_DIR = ./obj
 
SRC = $(wildcard $(SRC_DIR)/*.c)      # /*/
OBJ = $(patsubst %.c, $(OBJ_DIR)/%.o, $(notdir $(SRC)))
 
TARGET = main
BIN_TARGET = $(BIN_DIR)/$(TARGET)
 
CC = gcc
 
$(BIN_TARGET):$(OBJ)
    $(CC) $(OBJ) -o $@
 
$(OBJ_DIR)/%.o:$(SRC_DIR)/%.c
    $(CC) -I$(INC_DIR) -c $< -o $@
 
clean:
    find $(OBJ_DIR) -name *.o -exec rm -rf {} \;  # 刪除 .o 文件
    rm $(BIN_TARGET)   # 刪除可執行文件main

在 Makefile 中,最終要生成可執行文件 main 我們把它叫做終極目標,其它所有的 .o 文件本身也是一個目標,也需要編譯生成,工程裏面許多的 .c 就會生成許多的 .o,每一個 .c 都寫一遍目標依賴命令顯然是不可行的,於是就有了類似 for 循環的東西,把所有目標變成一個集合,但不是真正用 for 循環,而是使用一些抽象的符號表示,解釋如下:

當然,不止只有這些符號只是列舉了上面出現的或者常見的。

執行 make 後的結果:

make 執行後 bin 目錄裏面已經生成了可執行文件 main,obj 目錄裏面已經生成了中間目標文件 main.o、openFile.o、readFile.o、writeFile.o,最後執行 main 後的結果也是和前面基礎版的 Makefile 的結果是一樣的。

ok,看到這裏應該對 Makefile 有了一定的瞭解,可以動手敲一敲用起來!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/cYvWmM6MgWElmhOpnxSA9g