手把手教你寫一個 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 文件,也叫靜態庫文件。
總結一下,編譯鏈接的過程如下:
源文件首先會生成中間目標文件,再由中間目標文件生成執行文件。
在編譯時,編譯器只檢測程序語法,和函數、變量是否被聲明。如果函數未被聲明,編譯器會給出一個警告,但可以生成 Object File。
在鏈接程序時,鏈接器會在所有的 Object File 中找尋函數的實現,如果找不到,那就會報鏈接錯誤碼(Linker Error),在 VC 下,這種錯誤一般是:Link 2001 錯誤,意思是說,鏈接器未能找到函數的實現。你需要指定函數的 Object File。
三、編寫一個簡單的 Makefile
1. Makefile 的基本語法規則:
目標 ... : 依賴 ...
實現目標的具體表達式(命令)
...
...
-
目標 (target):就是一個目標文件,可以是 Object 文件,也可以是執行文件,還可以是一個標籤 (Label);
-
依賴 (prerequisites):就是要生成那個 target 所需要的文件或是目標;
-
命令 (command):Shell 命令,也就是 make 工具需要執行的命令。
【總結】:通過依賴 (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 統一管理這些文件,將生產的目標文件放在目標目錄下,可執行文件放到可執行目錄下,分類目錄如下圖:
-
bin 目錄:放可執行文件
-
include 目錄:放頭文件
-
obj 目錄:放中間目標文件
-
src 目錄:放源文件
可見原來那些文件都不在同一目錄下了,那麼這時候如果還用之前的 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 循環,而是使用一些抽象的符號表示,解釋如下:
-
%.o:所有 .o 結尾的文件
-
%.c:所有 .c 結尾的文件
-
$@:表示目標文件
-
$<:表示第一個依賴文件,也叫初級依賴
-
$^:表示所有的依賴文件,也叫終極依賴
當然,不止只有這些符號,只是列舉了上面出現的或者常見的。
執行 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