前言
在linux上開發c/c++代碼,基本都會使用make和makefile作為編譯工具。我們也可以選擇cmake或qmake來代替,不過它們只負責生成makefile,最終用來進行編譯的依然是makefile。如果你也是c/c++開發人員,無論你使用什么工具,makefile都是必須掌握的。特別是當你打算編寫開源項目的時候,手動編寫一個makefile非常重要。本文的目的就是讓大家快速了解makefile。
了解makefile
makefile的官方文檔[1] 學習makefile的最佳方式就是直接查閱官方說明
一般的makefile文件會包含幾個部分:定義變量、目標、依賴、方法段。下面就是一個基礎的makefile大概的樣子:
1 TARGET=test 2 OBJS=main.o foo.o bar.o 3 CC=gcc 4 5 $(TARGET):$(OBJS) 6 $(CC) $^ -o $@
1-3行定義了變量,第5行冒號前的部分代表目標,表示這部分編譯工作的最終目的。冒號后面的部分是目標的依賴,表示要生成這個目標需要哪些預先准備工作。第6行是方法段,代表具體的方法。第5-6行組成了一個編譯片段。一個makefile可以包含多個編譯片段,方法段也可以有多行。一個編譯片段的依賴可以是其他片段的目標,這樣當執行make的時候,它就會根據依賴關系處理執行次序。一個makefile文件不能出現重名的目標名,且當你執行make的時候,它會默認執行第一條編譯片段,如果第一條編譯片段並沒有其他依賴,make不會繼續向下執行(這一點很重要,后面會有說明)。
除此以外,makefile還可以通過include的方式包含其它makefile文件,因此我們也可以將公共的部分寫到一起。在makefile里,我們也可以編寫或調用shell腳本。
常見變量和函數介紹
作為學習前的准備,我們先介紹幾個常見的概念:
1. 關於makefile的命名
你可以使用全小寫或首字母大寫的方式來命名,或者你也可以起任何你喜歡的名字,通過make -f的方式來運行。不過我強烈建議你使用makefile或Makefile,並且在所有的項目中保持統一。
2. 聲明變量和使用變量
makefile中聲明變量的方式是=或:=,使用:=的方式主要是為了處理循環依賴,這個規則可以參考shell腳本。使用變量的方式是$()。除了我們自定義的變量以外,makefile也有預定義的變量。常見的有:
(1) CC: C編譯器的名稱,默認是cc。通常如果我們是c++程序會改寫它
(2) CXX: c++編譯器的名稱,默認是g++
(3) RM: 刪除程序,默認值為rm -f
(4) CFLAGS: c編譯器的選項,無默認值
(5) CXXFLAGS: c++編譯器的選項,無默認值
(6) $*: 不包含擴展名的目標文件名稱
(7) $+: 所有的依賴文件,以空格分開,並以出現的先后順序,可能包含重復的依賴文件
(8) $<: 第一個依賴文件的名稱
(9) $@: 目標文件的完整名稱
(10) $^: 所有不重復的依賴文件,以空格分開
(11) MAKE: 就是make命令本身
(12) CURDIR: makefile的當前路徑
3. 常見函數方法介紹
函數調用是makefile的一大特點,調用的共同方式是將函數名以及入參放在$()中,函數名和參數之間以[空格]分開,參數之間用[逗號]分開。除了makefile預定義的函數以外,我們還可以編寫自己的函數,函數內部使用$(數字)的方式使用參數。
1 define <Funcname> 2 echo $(1) 3 echo $(2) 4 endef
(1) call: 自定函數的調用方式,第一個入參是函數名,后面是函數入參
(2) wildcard: 通配符函數,表示通配某路徑下的所有文件,通常我們是將所有*.cpp或*.h文件選擇出來單獨處理
(3) patsubst: 替換函數,經常和wildcard聯合使用,例如將*.cpp全部替換成*.o,后文有詳細的使用方法
(4) foreach: 循環函數,會根據空格將字符串分片處理,我們可以用來處理多個目標的編譯或多個文件路徑的掃描
(5) notdir: 獲取到路徑的最后一段文件名
(6) strip: 去掉字符串前后的空格
(7) shell: 用於在makefile中執行shell腳本
4. 條件分支
makefile也可以根據條件,選擇不同的處理分支。方式如下:
ifeq () else endif 或者 ifndef else endif
條件分支在我的日常開發中不建議使用,因為很容易讓makefile變得晦澀難讀。畢竟是做編譯用的工具,為了方便維護還是不要弄的太復雜。
5. 關於偽目標
A phony target is one that is not really the name of a file; rather it is just a name for a recipe to be executed when you make an explicit request. There are two reasons to use a phony target: to avoid a conflict with a file of the same name, and to improve performance.
對於偽目標官方提供的解釋是這樣的: 偽目標不是一個真實存在的文件名,它只表示了一個編譯的目標。使用偽目標的意義在於:1,避免makefile中的命名重復;2,提高性能。最常用的偽目標就是clean,為了確保我們聲明的目標在makefile路徑下不會重現同名的文件。偽目標的編寫如下:
clean:
$(RM) $(OBJS) $(TARGET)
.PHONY:clean
多目錄編譯和動態庫
通常只要我們開發的不是一個demo程序,一個項目都會包含自己的目錄結構,某些項目還包含自己的動態庫需要在編譯時導出。對於多目錄的編譯,網上的方法很多,這里我只介紹一個我個人比較推薦的方式。所有目錄下的源碼都在主makefile中編譯,如果是動態庫目錄則單獨在動態庫所在的目錄下編寫一個makefile,然后讓主目錄中的makefile來調用。和編譯可執行程序不同,編譯動態庫有以下三個注意點:
1. LDLIBS=-shard: 告訴編譯器,需要生成共享庫
2. CXXFLAGS=-fPIC: 這個是C++的編譯選項,在將.cpp生成.o文件的時候,由於通常我們使用自動推導,因此我們需要用這個變量指明編譯要生成與為位置無關的代碼,否則在連接環節會報錯
3. 編譯目標需要以lib開頭.so結尾
一個完整的例子
下面以一個相對完整的例子作為總結,在這個例子中有對源碼的編譯,也有對動態庫的編譯和導出,還包含了安裝環節。為了方便項目管理,我使用的項目結構如下:
項目
|
-- bin # 可執行程序的所在目錄 | -- include # 內部和外部頭文件的所在目錄。開發初期,這里只會保存外部依賴的頭文件,項目內部的頭文件是在編譯后自動復制進去的,目的是方便在安裝換環節統一處理 | -- lib # 動態庫所在目錄。和include一樣,開發初期只包含依賴的動態庫,項目內部的動態庫是在編譯后復制進去的 | -- src # 源碼目錄
項目源碼如下,你可以直接復制並根據文件頭部注釋中的路徑來生成
./foo/foo.h 和 ./foo/foo.cpp

// ./foo/foo.h #ifndef FOO_H_ #define FOO_H_ class Foo { public: explicit Foo(); }; #endif

#include "foo.h" #include <iostream> using namespace std; Foo::Foo() { cout << "Create Foo" << endl; }
./xthread/xthread.h和./xthread/xthread.cpp

// ./xthread/xthread.h #ifndef XTHREAD_H #define XTHREAD_H #include <thread> class XThread { public: virtual void Start(); virtual void Wait(); private: virtual void Main() = 0; std::thread th_; }; #endif

#include "xthread.h" #include <iostream> using namespace std; void XThread::Start() { cout << "Start XThread" << endl; th_ = std::thread(&XThread::Main, this); } void XThread::Wait() { cout << "Wait XThread Start..." << endl; th_.join(); cout << "Wait XThread End..." << endl; }
./main.cpp

// ./main.cpp #include <iostream> #include "foo/foo.h" #include "xthread.h" using namespace std; class XTask : public XThread { public: void Main() override { cout << "XTask main start..." << endl; this_thread::sleep_for(chrono::seconds(3)); cout << "XTask main end..." << endl; } }; int main(int argc, char *argv[]) { cout << "hello" << endl; Foo foo; XTask task; task.Start(); task.Wait(); return 0; }
main和foo只進行源碼編譯,xthread是動態庫。在編譯順序上,需要先編譯xthread並將頭文件和動態庫文件分別導出到include和lib下,再編譯源碼。最后執行make install,將所有動態庫拷貝至/usr/lib目錄,可執行文件拷貝至/usr/bin目錄。如果你的動態庫還需要給其它項目使用,你還需要將它的頭文件拷貝到/usr/include目錄下。
根據上面介紹的方法,我們首先編寫xthread所在的makefile:
# ./xthread/makefile
TARGET=libxthread.so LDLIBS:=-shared CXXFLAGS:=-std=c++11 -fPIC SRCS:=$(wildcard *.cpp) HEADS:=$(wildcard *.h) OBJS:=$(patsubst %.cpp,%.o,$(SRCS)) $(TARGET):$(OBJS) $(CXX) $(LDFLAGS) $^ -o $@ $(LDLIBS) install:$(TARGET) cp $(TARGET) ../../lib cp $(HEADS) ../../include clean: $(RM) $(OBJS) $(TARGET) .PHONY:clean install
這一步完成以后,makefile可以單獨執行。執行make install會先執行$(TARGET)所在的編譯片段。
編寫主目錄下的makefile,並可以通過主目錄下的makefile控制xthread的編譯執行:
# ./makefile TARGET=hello SRC_PATH=$(CURDIR) $(CURDIR)/foo SRCS=$(foreach dir,$(SRC_PATH),$(wildcard $(dir)/*.cpp)) OBJS=$(patsubst %.cpp,%.o,$(SRCS)) CXXFLAGS=-std=c++11 -I../include LDFLAGS=-L../lib LDLIBS=-lpthread -lxthread CC=$(CXX) INSTALL_DIR=/usr $(TARGET):$(OBJS) depends $(CC) $(LDFLAGS) $(OBJS) -o $@ $(LDLIBS) @cp $(TARGET) ../bin depends: $(MAKE) install -C $(CURDIR)/xthread -f makefile install:$(TARGET) cp ../bin/$(TARGET) $(INSTALL_DIR)/bin cp ../lib/*.so $(INSTALL_DIR)/lib clean: $(RM) $(OBJS) $(TARGET) $(MAKE) clean -C $(CURDIR)/xthread .PHONY: clean install depends
主目錄的$(TARGET)有一個depends,屬於偽目標,會被預先執行。CXXFLAGS表明了編譯需要的外部頭文件的搜索目錄,LDFLAGS表明了外部依賴庫的搜索目錄,LDLIBS說明編譯過程具體需要哪些動態庫。並且會將編譯的可執行文件復制到../bin目錄下。
其它的細節,建議讀者跟着做一遍應該可以掌握。