Makefileの基本的な書き方について

最近研究室Wikiに後輩向けの文章書いてたのですが,いくつかはclosedにするのも勿体無いので編集してブログで公開しようかと思います。

概要

  • Makefile は自分の書いたコードのビルドの自動化に便利なため基本的な記法は知っておくとよい
    • いちいちシェルの履歴を確認したりコマンドをミスする悲しいことがなくなる
  • Makefile はビルドだけでなく実験やらなんやらの task runner としても便利
    • ビルド・実験を回す・結果をグラフにする,などをまとめてやったりすることも可能
  • 上記は shell script でも可能だがメリットとして
    • あるタスクに対して依存関係が記述できる
      • 実験タスクの依存にビルドタスクを記述すれば,実験タスクを実行したら自動でビルドしてくれる
    • あるタスクについて,依存先が更新されていなければ実行しないという機能が標準で存在
      • 二行上のような依存を記述していたときに,ソースコードの更新の有無でビルドの実行をする/しないを勝手に判断してくれる
  • 以下の説明は GNU Make に準拠して説明を行う。BSD Make の場合上手く動作しない記法も含まれる

Makefile の基本的な利用法

TARGET := prog
CC := /usr/bin/gcc
CFLAGS := -g -O2 -Wall
RM := /bin/rm

$(TARGET): main.c
    $(CC) $(CFLAGS) $< -o $@

clean:
    $(RM) -rf $(TARGET)

all: $(TARGET)

.PHONY: all clean

上記は最も基本的な Makefile の記述のひとつ。 ひとつひとつ見ていく

Makefile の変数

Makefile はシェル変数とは別に独自の変数を持つ。:= で変数を定義し,$(VAR) のように記述して展開する。 また,変数は = でも定義が可能だが,:== は定義式の評価のタイミングが異なるなど差異が存在する。 具体的には = を用いると値が決まるのが実際に参照されるときになる。 Makefile のタスクの中でシェルの環境変数($PATH など)を展開したい場合は $$PATH のように $ をエスケープする必要がある。

Makefile の変数は := の代わりに += を使うことで,既に存在する変数に追記することができる。

また,Makefile(とくに GNU Make)には多種多様な関数や機能が用意されている。一例として,

SRC  := main.c test.c test2.c
OBJS := $(SRC:.c=.o)

と記述すると,$(OBJS) の中身は main.o test.o test2.o になる。

Makefile のタスク

タスクは ターゲット: 依存先 という風に記述する。最初の節にある Makefile の例をみてみると,

$(TARGET): main.c
    $(CC) $(CFLAGS) $< -o $@

と書いてある。この中に書いてある変数を全部展開すると,

prog: main.c
    gcc -g -O2 -Wall main.c -o prog

となる。つまり,prog という実行ファイルは,main.c というファイルに依存しているという記述である。このタスクの中身は,タスクの依存関係の次の行からインデントしてシェルスクリプトを記述することになる。この例では

$(CC) $(CFLAGS) $< -o $@

となっている部分である。タスクが複数行ある場合は,

target: deps
    mkdir -p test
    cd test && touch testfile.txt

のように全てインデントを行なう。注意しなければならないのは,このインデントはソフトインデント(半角スペース)ではなくハードインデント(タブ文字)でなくてはならない。また,このタスクは実際には

target: deps
    sh -c "mkdir -p test"
    sh -c "cd test && touch testfile.txt"

のように一行ごとにシェルが別々に起動して実行されるため,

target: deps
    mkdir -p test
    cd test
    touch testfile.txt

と記述すると意図通り動作せず失敗してしまう。

Makefile の疑似ターゲット

例示した Makefile では .PHONY: all clean と記述してあり,また,依存が存在しないタスク clean$(TARGET) を依存に指定するタスク all が記述してある。.PHONY: で指定したタスクは擬似的なターゲットである。

これは allclean といった名前のファイルを作るタスクではないことを明示している。というのも,Makefile のタスクは一般的にターゲットと同じファイル名のファイルを生成するタスクを記述するツールであるためだ。

Makefile の挙動として,タスクのターゲットに指定しているファイル名が存在しないとき,または依存先が更新されていた場合,このどちらかが真であればタスクが実行されるということになっている。しかし,プロジェクトのビルドではなく,この clean ターゲットのようにただのタスクを記述したいときにそれでは不便なため,このように .PHONY: を用いて擬似的なターゲットを作ることで解決している。

ちなみに,make コマンドは引数にターゲットを指定するが,引数を渡さずにただ make とだけ打った場合は暗黙的に make all を実行したことと同じと見なされる。

Makefile の特殊な変数

Makefile のタスクの例にて,$<$@ という変数を利用した。これは Makefile が自動的に定義する特殊な変数である。

TARGET: DEP1 DEP2
    なんかのタスク

というタスクを用意したときに,$@TARGET として展開される。また,$^DEP1 として展開され,$<DEP1 DEP2 という風に展開される。他にも自動変数は複数あるが,基本的にこのみっつを覚えておけば良い。

Makefile の発展的な記述方法:サフィックスルール

TARGET := prog
CC := gcc
CXX := g++
CFLAGS := -g -O2 -Wall
CXXFLAGS := $(CFLAGS)
LD := ld
LDFLAGS := -lc -lm -lpthread

SRCS := main.c library1.c library2.c
OBJS := $(SRCS:.c=.o)

.SUFFIXES:
.SUFFIXES: .c .o

.PHONY: all

all: $(TARGET)

.c.o:
    $(CC) $(CFLAGS) -c $^ -o $@

$(TARGET): $(OBJS)
    $(LD) $(LDFLAGS) $< -o $@

Makefile の発展的な記法として サフィックスルール が存在する。 上記の Makefile を見てほしい。

make コマンドを実行したとき,自動で all ターゲットを実行するため,この場合は all: $(TARGET) という疑似ターゲットによる依存関係をみて,$(TARGET): $(OBJS) という依存関係が記述されたターゲットのタスクを実行することになる。

しかし,この時依存関係には $(OBJS) しか書いていない。この $(OBJS) 変数はこの定義だと main.o library1.o library2.o と展開されるが,この .o 拡張子を持つオブジェクトファイルをどのソースコードからどう生成するかという依存関係は一切記述されていないのである。

これは実は,.c.o: と書かれているターゲットで解決される。これをサフィックスルールと呼び,この場合,.o という拡張子であれば自動的に同名で拡張子が .c となっているファイルから生成するよ,ということを言っている。

このサフィックスルールの場合は gcc に -c オプションを追加しているため,結果としてソースコードが 1 ファイル単位でオブジェクトファイルにコンパイルされ,$(TARGET): $(OBJS) のタスクでは生成された全てのオブジェクトファイルを ld コマンドによってリンカーがリンクしているのである。

この方法だと,複数のソースコードのうち一つのファイルだけが変更された時,そのファイルのコンパイルと全体のリンクだけが走り,全てのファイルを全てコンパイルしなおさなくて済む(=差分コンパイルが可能)という点でメリットがある。

Makefile の task runner としての利用方法

基本的には前章で説明した方法を応用し,疑似ターゲットを作りつつそのターゲットに依存関係を追加すれば,プロジェクトのビルド以外の仕事も依存関係を考慮して実行できるということになる。

次に示すのは Markdown で記述されたファイルを LaTeX へ変換した上で PDF へ typeset(コンパイル)するタスクと,make preview によって生成した PDF を PDF ビューワーで閲覧するタスクが定義してある筆者作の Makefile である。

実際には LaTeX には latexmk という Perl 製の便利な typeset スクリプトが標準に存在するため,実際に LaTeX を記述する際には PDF の閲覧にも typeset にもそちらを用いてほしい。あくまで Makefile の応用の一例である。

TEX=platex
PANDOC=pandoc
BIBTEX=pbibtex
DVI2PDF=dvipdfmx
TEX_FLAGS= --shell-escape -kanji=utf8 -kanji-internal=utf8 -interaction=batchmode

UNAME=$(shell uname)
ifeq $($(UNAME), Darwin)
    VIEWER=open
else ifeq $($(UNAME), Linux)
    VIEWER=xdg-open
else ifeq $($(OS), Windows_NT)
    VIEWER=start
endif

TARGET=paper

COUNT=3

SOURCE = manuscript.tex

MDSCRIPTS = src/abstract.md src/contents.md

TEXFILES=$(MDSCRIPTS:.md=.tex)
DVIFILE=$(SOURCE:.tex=.dvi)
BIBFILES=cite/paper.bib
REFSTYLES=sty/crossref_config.yaml

PANDOC_FLAGS= --smart -f markdown+pipe_tables -t latex --filter pandoc-crossref --natbib

PANDOC_FILTER_FLAGS= -M "crossrefYaml=$(REFSTYLES)"

.SUFFIXES: .tex .md .pdf
.PHONY: all semi-clean clean preview

all: $(TARGET).pdf semi-clean

.md.tex:
 @cat $< \
 | $(PANDOC) --verbose $(PANDOC_FLAGS) $(PANDOC_FILTER_FLAGS) \
 | sed 's/.png/.pdf/g' \
 | sed 's/includegraphics/includegraphics[width=1.0\\columnwidth]/g' \
 | sed 's/\[htbp\]/[t]/g' \
 > $@

$(TARGET).pdf: $(TEXFILES)
 @cd tex && for i in `seq 1 $(COUNT)`; \
 do \
  $(TEX) $(TEX_FLAGS) $(SOURCE); \
  if [ ! -e "$(SOURCE:.tex=.blg)" ]; then \
   $(BIBTEX) $(basename $(SOURCE)); \
  fi \
 done
 cd tex && $(DVI2PDF) -o ../$(TARGET).pdf $(DVIFILE) 2> /dev/null

semi-clean:
 @cd tex && rm -f *.aux *.log *.out *.lof *.toc *.bbl *.blg *.xml *.bcf *blx.bib *.spl

clean: semi-clean
 @rm -f $(TARGET).pdf $(DVIFILE) $(TEXFILES)

preview:
 @$(VIEWER) $(TARGET).pdf 2> /dev/null

ちなみに,タスクの先頭に @ がついてる行があるが,これを行うと実行したコマンドが表示されなくなる。