makefile快速入門

冷豪發表於2022-02-05

前言

  在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

makefile快速入門
// ./foo/foo.h
#ifndef FOO_H_
#define FOO_H_

class Foo
{
public:
    explicit Foo();
};

#endif
foo.h
makefile快速入門
#include "foo.h"
#include <iostream>

using namespace std;

Foo::Foo()
{
    cout << "Create Foo" << endl;
}
foo.cpp

./xthread/xthread.h和./xthread/xthread.cpp

makefile快速入門
// ./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
xthread.h
makefile快速入門
#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;
}
xthread.cpp

./main.cpp

makefile快速入門
// ./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.cpp

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目錄下。

其它的細節,建議讀者跟著做一遍應該可以掌握。