2017年2月4日

汎用 Makefile の書き方

概要
  C/C++ 言語において,コンパイラに MSVC++ ではなく GCC を使用する場合, コマンドラインから $ g++ main.cpp   のようにしてコンパイルを行うことは常である. ファイルが少ない場合や,依存関係の簡単な場合は, コマンドも短く,コンパイルも直ちに終わるため, 大きな問題とはならない. しかしながら, 少し複雑なプログラムを書けば, すぐに多くのファイルを引数に与える必要が生じる. これを解決するために,ご存じのとおり Makefile が使用される. しかしながら, 数十を超えるファイルの依存関係を, ベタ打ちで Makefile に記述することは, とても骨の折れる作業である. また, 誤った記述によるコンパイルエラーや, ファイルの追加や削除による書き換えなど, 非常に手間がかかる. そこで, ファイルとその依存関係を自動で認識し, Makefile の変更を最小限に抑えた, 汎用的に使用できる Makefile が求められる. 現状存在する汎用 Makefile のネットサンプルを元に, 簡潔に理解でき,少ないコストで維持・管理可能かつ, 完動する汎用 Makefile を新たに作成する.

要求仕様
ファイルはディレクトリまで指定し,あとはワイルドカード ( *.c , *.cpp ) で自動検索する.
    (ヘッダは, *.cpp よりコンパイラが検索するので,指定不要)
中間ファイル ( *.o , *.d ) は,別のディレクトリに保存する.
依存関係は,自動で解決する.
コンパイルは,分割コンパイルを行う.
プログラムのステップ数カウント機能を備える.

想定する構成例
exampledir
 |
 ├ Makefile (this file)
 |
 ├ make_temp/ (temporary directory for make)
 |
 ├ exe (executable file)
 |
 ├  *.cpp
 |
 ├ source/
 | |
 │ ├ *.cpp
 | |
 | └ SubDir1
 │   │
 │   └ *.cpp
 |
 └ include/
   |
   └ *.hpp

ソースコード
./Makefile
#============================================================

# 各項目を設定してください

# ソースファイルの場所 ($make steps コマンドで,ヘッダファイルの行数もカウントする場合は,
# ヘッダファイルのディレクトリも追加する必要があります.また,拡張子を記述してはいけません)
# 例: SRCDIR = *. source/*. source/SubDir1/*.
DIR = *. source/*. include/*.

# 生成ファイル名
TARGET = exe
# 親ディレクトリ名にする場合
# TARGET = $(shell basename `readlink -f .`).exe

# コンパイルオプション
# 例: CFLAGS += -L/home/usr/lib -I/home/usr/include
# 例: CFLAGS += -lssl -lcrypto # OpenSSL
# 例: CFLAGS += -pthread       # thread
# 例: CFLAGS += -std=gnu++0x   # C++11
CFLAGS  = -Wall
CFLAGS += -O3

#============================================================

SRCDIR   = $(patsubst %., %.cpp, $(DIR))
SRCS     = $(wildcard $(SRCDIR))

HDIR     = $(patsubst %., %.h, $(DIR))
HEADS    = $(wildcard $(HDIR))

HPPDIR   = $(patsubst %., %.hpp, $(DIR))
HEADppS  = $(wildcard $(HPPDIR))

TEMP_DIR = make_temp
OBJS     = $(addprefix $(TEMP_DIR)/, $(patsubst %.cpp, %.o, $(SRCS)))
#OBJS     = $(addprefix $(TEMP_DIR)/, $(SRCS:%.cpp=%.o))#別表記

DEPS  = $(addprefix $(TEMP_DIR)/, $(patsubst %.cpp, %.d, $(SRCS)))
#DEPS  = $(addprefix $(TEMP_DIR)/, $(SRCS:%.cpp=%.d))#別表記


# exe ファイルの生成
$(TARGET): $(OBJS)
    @echo ""
    @echo "============================================================"
    @echo ""
    
    @echo "SRCS: "
    @echo "$(SRCS)"
    @echo ""
    
    @echo "OBJS: "
    @echo "$(OBJS)"
    @echo ""
    
    @echo "CFLAGS: "
    @echo "$(CFLAGS)"
    @echo ""
    @echo "============================================================"
    @echo ""
    $(CXX) -o $(TARGET) $(OBJS) $(CFLAGS)
    @echo ""


# 各ファイルの分割コンパイル
$(TEMP_DIR)/%.o: %.cpp
    @echo ""
    mkdir -p $(dir $@);\
    $(CXX) $< -c -o $@ $(CFLAGS)


# 「-include $(DEPS)」により,必要な部分のみ分割で再コンパイルを行うため,依存関係を記した *.d ファイルに生成する
$(TEMP_DIR)/%.d: %.cpp
    @echo ""
    mkdir -p $(dir $@);\
    $(CXX) $< -MM $(CFLAGS)\
    | sed 's/$(notdir $*)\.o/$(subst /,\/,$(patsubst %.d,%.o,$@) $@)/' > $@;\
    [ -s $@ ] || rm -f $@
#   @echo $*    # for debug
#   @echo $@    # for debug


.PHONY: all
all:
    @(make clean)
    @(make)
#   make clean ; \  #別表記
#   make            #別表記


.PHONY: clean
clean:
    -rm -rf $(TEMP_DIR) $(TARGET)
#   -rm -f $(OBJS) $(DEPS) $(TARGET)


.PHONY: onece
onece:
    $(CXX) -o $(TARGET) $(SRCS) $(CFLAGS)


.PHONY: steps
steps: $(SRCS) $(HEADS) $(HEADppS)
    @echo "$^" | xargs wc -l


#動作未確認[12]
.PHONY: steps2
steps2: $(SRCS) $(HEADS) $(HEADppS)
    @echo "$^" | xargs grep -Ev '^[[:space:]]*((/?\*.*/?)|(//.*))$' | wc -l


-include $(DEPS)

コンパイルするサンプルコード
./main.cpp ./source/myprint.cpp ./include/myprint.hpp
GitHub に上げておきます.また,少なくとも私の環境では問題なく動作します.

動作説明
はじめに
まず,サンプルコードの,通常のコンパイル手順を確認する.
$ g++ -o exe main.cpp source/myprint.cpp -Wall -O3
次に,これから行いたい分割コンパイルについて確認する.(中間ファイルの一時ディレクトリへの保存等は,煩雑なため,省略した)
$ g++ main.cpp -MM -o main.d ; \
g++ source/myprint.cpp -MM -o myprint.d ; \
 \
g++ main.cpp -c -o main.o ; \
g++ source/myprint.cpp -c -o myprint.o ; \
 \
g++ -o exe main.o myprint.o -Wall -O3
なお,生成した, *.d ファイルは,手動コンパイルでは使用しない.

make の動作
1. コンパイルが始まると,まず, SRCDIR に,ワイルドカードが読み込まれる.
SRCDIR = *.cpp source/*.cpp
2. SRCDIR に指定されたワイルドカードに対応するファイルが, SRCS に読み込まれる.
SRCS   = $(wildcard $(SRCDIR))
3. SRCS へ格納されたファイル名の拡張子を, *.cpp から *.o および, *.d へ書き換えた値が OBJS および, DEPS に格納される.
OBJS     = $(addprefix $(TEMP_DIR)/, $(patsubst %.cpp, %.o, $(SRCS)))
#OBJS     = $(addprefix $(TEMP_DIR)/, $(SRCS:%.cpp=%.o))#別表記

DEPS  = $(addprefix $(TEMP_DIR)/, $(patsubst %.cpp, %.d, $(SRCS)))
#DEPS  = $(addprefix $(TEMP_DIR)/, $(SRCS:%.cpp=%.d))#別表記
動作の詳細:
$(patsubst [置換前], [置換後], $([置換対象])) 関数によって, SRCS 変数内の値 (この場合,拡張子) を置換する. そのあと, $(addprefix [結合する文字列 1], [結合する文字列 2], ...) 関数によって,ディレクトリを,一時ディレクトリ下に合わせる.

4. 依存関係 $(DEPS) が読み込まれる.このとき, $(DEPS) に指定されている *.d ファイルが,実際にはまだ生成されていないため,そのままだと include はエラーを返す.そのため, - で修飾し,エラーを無視させる[4].(この命令は,分割コンパイルを行う際に,変更された *.cpp*.hpp ファイルを,ファイルの更新日時より検出し,依存関係 *.d を読み込むことで,関係するファイルのみ,次の 5. へ再コンパイル命令を発行する)
-include $(DEPS)
5. まだコンパイルされていない *.cpp ファイル,および,変更された *.cpp ファイルの依存関係を記した, *.d ファイルのみを生成する.この際,「中間ファイル ( *.o , *.d ) は,別のディレクトリに保存する」という仕様を達成するために,コンパイラの -MM オプションにより取得した依存関係の示すディレクトリ構造を, 一時ディレクトリ下を示すように変更してから, *.d に書き込む[1].
$(TEMP_DIR)/%.d : %.cpp
    @echo ""
    mkdir -p $(dir $@); \
    $(CXX) $< -MM $(CFLAGS) \
    | sed 's/$(notdir $*)\.o/$(subst /,\/,$(patsubst %.d,%.o,$@) $@)/' > $@ ; \
    [ -s $@ ] || rm -f $@
動作の詳細:
□ 1 行目は,[生成対象] と,[生成に必要な材料] を示す.%.d%.cpp の対応関係は,文字列の (内包を許容した) 完全一致で解決される.このため,例えば $(TEMP_DIR)/ のように別ディレクトリを先頭で指示することはできるが,材料側 (%.cpp) のパスを編集することはできない. (仮に [生成に必要な材料] が完成していない場合は,[生成に必要な材料] を [生成対象] としている処理から読み込まれます)
[生成対象: $@]: [生成に必要な材料: $<]
□ 2 行目は,改行を出力する. echo は,そのままだと, echo を含む行そのものを表示するため, で修飾することにより,これを回避している.
 @echo ""
□ 3 行目は,中間ファイルを保存するための一時ディレクトリを生成する. $(dir "dir_name") 関数は, dir_name に指定された名前にディレクトリを, -p オプションにより,再帰的に生成する. $@ は, $(@) と同義で,[生成対象] を表す Built-In Variables (組み込み変数) である. ; は逐次実行を表し, \ は,コードを改行して記述することを許可する.
 mkdir -p $(dir $@); \
□ 4 行目は, CXX に指定したコンパイラ (g++) の -MM オプションにより,[生成に必要な材料] の依存関係を調べ,出力する. $< は, $(<) と同義で,[生成に必要な材料] を表す Built-In Variables (組み込み変数) である.この出力を,パイプ ( | ) により,5 行目に受け渡す.
 $(CXX) $< -MM $(CFLAGS) \
出力例 (コマンドラインより実行):
$ g++ main.cpp -MM -Wall -O3
main.o: main.cpp include/myprint.hpp
$ g++ source/myprint.cpp -MM -Wall -O3
myprint.o: source/myprint.cpp source/../include/myprint.hpp
□ 5 行目は,sed 関数による文字列置換を行い *.d ファイルとして保存する.
 | sed 's/$(notdir $*)\.o/$(subst /,\/,$(patsubst %.d,%.o,$@) $@)/' > $@ ; \
上記 (4 行目の項目) で得た出力例は,最終的に,下記のように書き換えられる.
make_temp/main.o make_temp/main.d: main.cpp include/myprint.hpp
make_temp/source/myprint.o make_temp/source/myprint.d: source/myprint.cpp source/../include/myprint.hpp

まず,前半の
 | sed 's/$(notdir $*)\.o/$(subst /,\/,$(patsubst %.d,%.o,$@) $@)/'
について,入力に,
myprint.o: source/myprint.cpp source/../include/myprint.hpp
を与えたとして説明する.
$(notdir $*)\.o
について, $* は,[生成対象: $@] (source/myprint.d) の拡張子を除いた名前 (myprint) を示す. したがって, myprint\.o を示す.(\.o の \ は,. のエスケープ表示.詳細は後述)
$(subst /,\/,$(patsubst %.d,%.o,$@) $@)
について, $(patsubst [置換前], [置換後], $([置換対象])) は,[生成対象: $@] (source/myprint.d) を *.d (source/myprint.d) から *.o (source/myprint.o) へ置換する. 結局,
$(subst /,\/,source/myprint.o source/myprint.d)
と記述され, $(subst [置換前], [置換後], [置換対象]) により,source/myprint.o source/myprint.d を,source\/myprint.o source\/myprint.d とエスケープ処理している. この例では,結局 sed 関数は,
 | sed 's/myprint\.o/source\/myprint.o source\/myprint.d/'
のようになる. このとき, [置換対象 (入力)] | $(subst /[置換前]/[置換後]) であるため,
入力 (myprint.o: source/myprint.cpp source/../include/myprint.hpp) の myprint.o は,
make_temp/source/myprint.o make_temp/source/myprint.d に置換され, make_temp/source/myprint.o make_temp/source/myprint.d: source/myprint.cpp source/../include/myprint.hpp となる.


ここで,\.o の . は,任意の一文字を表す特殊文字[7]のため,エスケープ表示する必要がある[1].また,区切りに使用する / も,\/ にエスケープしないと, sed 関数が正しく動作しない.

誤動作としては,次のような状況が考えられる.
エスケープあり
置換される (example.o から example\.o を検索して example2.txt へ置換)
$ echo "example.o" | sed "s/example\.o/example2.txt/"
example2.txt
置換されない (exampleXo から example\.o を検索して example2.txt へ置換)
$ echo "exampleXo" | sed "s/example\.o/example2.txt/"
exampleXo
エスケープなし
置換される (example.o から example.o を検索して example2.txt へ置換)
$ echo "example.o" | sed "s/example.o/example2.txt/"
example2.txt
置換される (exampleXo から example.o を検索して example2.txt へ置換)
$ echo "exampleXo" | sed "s/example.o/example2.txt/"
example2.txt
次に,後半の
 > $@ ; \
について,説明する. > は,ファイルへの出力を意味する[6]. このとき, $@ は,保存先のファイル名となる. したがって, 前半で求めた make_temp/source/myprint.o make_temp/source/myprint.d: source/myprint.cpp source/../include/myprint.hpp は, make_temp/source/myprint.d へ保存される.

□ 6 行目では,5 行目で生成した *.d ファイルが空であった場合 (ファイルの生成に失敗した場合) に,ファイルを破棄する. rm は,ファイルを削除するコマンド -f オプションは,ファイルであることを示す.
 [ -s $@ ] || rm -f $@
例: ./example.txt が空かどうかの判定

./example.txt のサイズが 0 の時 empty を表示する.(0 以外のとき,なにも表示しない) || は,前のコマンドが失敗した場合,後のコマンドを実行する命令.
$ [ -s ./example.txt ]||echo "empty"
6. 各ファイルの分割コンパイルを行う.
$(TEMP_DIR)/%.o : %.cpp
    @echo ""
    mkdir -p $(dir $@); \
    $(CXX) $< -c -o $@ $(CFLAGS)
7. exe ファイルを生成する.
$(TARGET): $(OBJS)
    @echo ""
    @echo "============================================================"
    @echo ""
    
    @echo "SRCS: "
    @echo "$(SRCS)"
    @echo ""
    
    @echo "OBJS: "
    @echo "$(OBJS)"
    @echo ""
    
    @echo "CFLAGS: "
    @echo "$(CFLAGS)"
    @echo ""
    @echo "============================================================"
    @echo ""
    $(CXX) -o $(TARGET) $(OBJS) $(CFLAGS)
    @echo ""

make clean の動作
  生成した一時ファイルと exe ファイルの削除を行う.

  .PHONY: clean.PHONY は,Makefile と同一ディレクトリに,clean という名前のファイルが存在した場合に, make clean が実行できなくなることを防いでいる.

  既知の問題として, make clean を 2 以上連続して行うと, 2 回目以降必ず,一度 *.d ファイルを生成し,コンパイルが走ってから, ファイルの削除が行われることが上げられる. これは,「-include $(DEPS)」によって, *.d の生成が優先されるため, clean で削除されたファイルを生成してしまうからである. この問題は,make が実行された時点で, clean の項目が優先して実行される訳ではないことに起因している. これは,make にとって, clean がコマンドではなく, 単なる [生成対象] の一つとして実装されているためである. *.d の生成より先に clean を呼び出すことは不可能なため, この問題は, 少なくとも Makefile の枠組みの中では, 解決することはできない.(同様の実装をする他の Makefile においても,再現されるばずである)

make all の動作
  make clean を呼び出した後に, make を呼び出す.

  実装方法について, make all について,
.PHONY: all
all:
    @(make clean)
    @(make)
    # make clean ; \ #別表記
    # make  #別表記
を,
.PHONY: all
all: clean $(TARGET)
とする実装を見かけるが, このように実装した場合, clean -> $(TARGET) の順に実行されるとは限らない. 最悪の場合は,ファイルを生成した後に, clean が走り,生成したファイルが削除され, make all を実行しただけでは,コンパイルが完了しなくなる. これは, make all 後に, make を実行すると,削除されたファイルのコンパイルが始まることから確認できる.(単純に $(TARGET) -> clean の順に実行されるのではなく, $(TARGET) を実行するファイルすべての内のどこかで突然 clean が実行される )

make onece の動作
  一度にまとめてコンパイルを行う. 同じソースコードであっても, 分割コンパイルより,まとめてコンパイルした方が, 生成されるバイナリサイズが小さくなることがある.(より最適化されている)

make steps の動作
  プログラムのステップ数 (コメント行を含む) をカウントする.[10][11] を参照のこと.(どうやら,改行コードの数を数えているようで,実際の行数 -1 行として計算される.この点や,コメント行・空行を,含む・含まない,を選択できるようにする改善の余地があり)

メモ:
検索「コメント カウント プログラム ステップ数 コマンド」
Javaのステップ数を数える http://qiita.com/kumai@github/items/3b9e6f73d71323a1bc1d
あたりが参考になりそう.

echo : 一度 echo で標準出力として出力したのち,パイプ |xargs へ受け渡す.
xargs : 値の一覧を一つずつ,任意のコマンドの引数として渡すコマンド.値 (ファイル名) ごとに, wc へ処理を渡す.
wc : -l オプションで,ファイルの行数を数える.

実行例:
$ make steps
  7 main.cpp
  3 source/myprint.cpp
  4 include/myprint.hpp
 14 total


使用方法
1. Makefile をダウンロードする.(下記リンクを [右クリック] -> [名前を付けてリンク先を保存]) (保存名は,"Makefile" とする)
2. ダウンロードした Makefile をルートディレクトリに配置する.(詳細は,「想定する構成」を参照)
3. SRCDIR,および,CFLAGS を設定する.(このとき,拡張子を記述しないことに注意)
例:
SRCDIR   = *.
SRCDIR += source/*.
CFLAGS   = -L/home/usr/lib -I/home/usr/include
CFLAGS += -Wall -O3
4. make する.

最後に
  表示の都合で,tab とすべきところを,スペースにしている場合がある.Makefile では,タブとスペースを厳密に区別するため,このブログのコードをそのままコピー&ペーストしても動作しない.手作業で修正するか,GitHub からダウンロードしてほしい.

  また,Makefile を UTF-8 で保存する場合,ファイルの先頭にバイトオーダーマーク (EF BB BF) を付加すると,正常に make できないことがある.バイトオーダーマークは,ファイルの先頭に記載されるため,正常に動作しない場合は,次のように,ファイルの先頭で停止する.
$ make
Makefile:1: *** missing separator.  Stop.

  ディレクトリの指定においては,[9] で示されているように,
# カレントディレクトリ以下に存在する全ての.cファイルを検索
CURRENT_SOURCE=$(shell find $(PWD) -type f -name "*.c" 2> /dev/null )
のような手法で,すべて自動化することもできる. しかしながら,フォルダごとインストールするタイプのライブラリを使用する場合などに, 余計なファイルまで指定されては困るため,今回は実装を見送った.

参考文献

0 件のコメント:

コメントを投稿