LoginSignup
4

More than 1 year has passed since last update.

Makefile で C 言語の依存関係の更新を自動検知する

Last updated at Posted at 2020-11-21

ネットワークの基幹部分を一度くらい実装してみたい気持ちになったのですが、大体の技術書が C 言語での実装を紹介しているので、良い機会と思い、前々から興味のあった Makefile を書いてみることにしました。

とりあえず半日ほどかけて C 言語の依存関係の更新を自動検知するための仕組みを入れてみたのですが、かなり複雑なことをやっていることに気づいたので、まとめたものをメモしておきます。

TL; DR

Makefile は gcc の使用を前提に作成しています。

Makefile
CC := @gcc
TARGET := a.out

SRCDIR := src
INCDIR := include

OUTDIR := out
BINDIR := $(OUTDIR)/.bin
OBJDIR := $(OUTDIR)/.obj
DEPDIR := $(OUTDIR)/.dep

CFLAGS := -I$(INCDIR)
DEPFLAGS = -MT $@ -MMD -MP -MF $(DEPDIR)/$*.d

SRCS := $(wildcard $(SRCDIR)/*.c)
OBJS := $(addprefix $(OBJDIR)/, $(notdir $(SRCS:.c=.o)))
DEPS := $(addprefix $(DEPDIR)/, $(notdir $(SRCS:.c=.d)))

$(BINDIR)/$(TARGET): $(OBJS) | $(BINDIR)
    $(CC) $(CFLAGS) -o $@ $^

$(OBJDIR)/%.o: $(SRCDIR)/%.c $(DEPDIR)/%.d | $(OBJDIR) $(DEPDIR)
    $(CC) $(DEPFLAGS) $(CFLAGS) -c -o $@ $<

# The empty rule is required to handle the case where the dependency file is deleted.
$(DEPS):

include $(wildcard $(DEPS))

$(BINDIR):
    @mkdir -p $@

$(OBJDIR):
    @mkdir -p $@

$(DEPDIR):
    @mkdir -p $@

.PHONY: all
all: clean $(TARGET)

.PHONY: clean
clean:
    @-rm -rf $(OUTDIR)

ディレクトリ構成:

(project-root)
  ├ Makefile
  ├ include
  ├ src
  └ out
    ├ .bin # 実行ファイル(a.out)の出力先
    ├ .obj # オブジェクトファイルの出力先
    └ .dep # 依存関係ファイルの出力先

srcinclude に配置したファイルは自動で認識されます。
ただし、サブディレクトリを再帰的に認識するようにはしていないのでご注意ください。

背景

そもそも Makefile で依存関係ファイルを更新する方法については、GNU のドキュメントにも記載があります。ドキュメントには「 make depend という依存関係ファイルを更新するためのコマンドを用意するのが伝統的なプラクティスだよ」という旨が書いてあります。

しかし、make depend の提供による依存関係ファイル更新には、ファイルを更新する度にプログラマが明示的にコマンドを実行しないといけないという問題があります。実行し忘れた場合、当然ながら正しくビルドが行われる保証はありません。

また、上記問題を回避したとしても、実装方法をちゃんと検討しておかないと、make で行われる処理が非効率的なものになってしまいかねません。make はかなりのコンテキストを持つコマンドなので、これは意外とバカにできません。不用意なコードを書くと、全ての依存関係ファイルが、実際の変更の有無に関わらず、毎回ビルドされてしまったりします。

ということで、そのあたりもちゃんと考慮した Makefile を書きましょう、というのが大まかな流れになります。

ちなみに、この記事で書かれている内容は、概ねこちらのサイトでやっていることの二番煎じです。

こちらのサイトには、歴史的背景なども含めて、引くくらい詳細に書いてあります。かっこいいですね。
英語が読める方は是非こちらを読みましょう。

事前知識

いくつか事前知識をまとめておきます。

依存関係ファイル .d

依存関係ファイルは gcc などのコンパイラにオプションを指定することで生成できるファイルです。
その名の通り、そのファイルのコンパイルに必要な依存ファイル(ヘッダなど)についての情報が書かれています。

例えば以下の main.c をオプション -MMD 付きの gcc でコンパイルしてみます。

main.c
#include "sub.h"

int main (int argc, char** argv) {
    print();
    return 0;
}

すると以下の依存関係ファイル main.d が生成されます。

main.d
out/.obj/main.o: src/main.c include/sub.h

include/sub.h:

上記のように、依存関係ファイルの中身は Makefile と同じ形式での依存関係の記述になっています。
この例の場合は、main.o を作るには main.csub.h が必要、sub.h は何にも依存してないよ、という内容になっています。

include 構文

Makefile で include を使うと、他のファイルを読み込んで、あたかもその Makefile 自体に読み込んだファイルの中身を書き足したかのように振る舞わせることができます。読み込む対象のファイル名に特に決まりはなく、.d.mkMakefile など、何でも読み込めます。

生成した依存関係ファイルを読み込むことで、オブジェクトファイルおよびヘッダーがどのファイルに依存しているかを Makefile に取り込むことができます。

include の振る舞いについては、少し調べると闇の深さがわかる1 のですが、この記事を読むにはこのレベルの理解で問題ないです。

実現方法

コード上の重要な部分は以下になります。
ここさえ理解してしまえば、あとは素直に読み解けます。

DEPFLAGS = -MT $@ -MMD -MP -MF $(DEPDIR)/$*.d
$(OBJDIR)/%.o: $(SRCDIR)/%.c $(DEPDIR)/%.d | $(OBJDIR) $(DEPDIR)
    $(CC) $(DEPFLAGS) $(CFLAGS) -c -o $@ $<

# The empty rule is required to handle the case where the dependency file is deleted.
$(DEPS):

include $(wildcard $(DEPS))

以下、上記コードについて、順を追って説明します。

依存関係ファイルとオブジェクトファイルの同時生成

まずは DEPFLAGS からです。こちらは依存関係ファイルの生成に関わるフラグです。

DEPFLAGS = -MT $@ -MMD -MP -MF $(DEPDIR)/$*.d

オプション -MMD を指定しています。このオプションは、オブジェクトファイルを生成するのと同時に依存関係ファイルを出力することをコンパイラに指示します。

この「同時に」というのがポイントです。make depend のようにオブジェクトファイルと依存関係ファイルを独立に生成する方式の場合、ファイル間の対応関係を何らかの形で管理する必要が生じてしまいます。その結果の一例が「プログラマが忘れずに make depend した上で make する」というルールでした。同時に生成してしまうことで、依存関係ファイルとオブジェクトファイルの対応関係が一意に定まり、このような管理が不要になります。

このオプションはオブジェクトファイルを生成するルールの中で適用されます。

$(OBJDIR)/%.o: $(SRCDIR)/%.c $(DEPDIR)/%.d | $(OBJDIR) $(DEPDIR)
    $(CC) $(DEPFLAGS) $(CFLAGS) -c -o $@ $<

依存関係ファイルの読み込み

1つ前の節で触れた通り、依存関係ファイルは、オブジェクトファイル生成と同時に生成されます。

$(OBJDIR)/%.o: $(SRCDIR)/%.c $(DEPDIR)/%.d | $(OBJDIR) $(DEPDIR)
    $(CC) $(DEPFLAGS) $(CFLAGS) -c -o $@ $<

生成された依存関係ファイルは Makefile に読み込む必要があります。

単純に include してしまうと、ファイルが存在しない場合に warning が出力されるので、一工夫必要です。-include として黙らせる手もありますが、今回は wildcard を使って、あらかじめディレクトリ上に存在しないファイルを取り除いておくようにしました。

include $(wildcard $(DEPS))

ここで DEPS は依存関係ファイルの一覧です。参考までに定義は以下の通りです。

DEPS := $(addprefix $(DEPDIR)/, $(notdir $(SRCS:.c=.d)))

少しわかりづらいですが、ソースファイル .c の拡張子を .d に変換して、依存関係ファイルの格納先 DEPDIR のパスを追加しています。結果として依存関係ファイルの一覧になります。

依存関係ファイルの削除への対応

この時点で概ね対応完了なのですが、もう1点、コーナーケースへの対応を入れる必要があります。

オブジェクトファイルの生成ルールの依存ファイルとして、依存関係ファイル .d が含まれていることに注目します。

$(OBJDIR)/%.o: $(SRCDIR)/%.c $(DEPDIR)/%.d | $(OBJDIR) $(DEPDIR)
    $(CC) $(DEPFLAGS) $(CFLAGS) -c -o $@ $<

ここに依存関係ファイルを追加している理由は、誰かが依存関係ファイルを誤って削除してしまった場合に、それを検知できるようにするためです。もしも $(DEPDIR)/%.d の記述がない場合、make は依存関係ファイルがないことに気付かず、そのままスルーしてしまいます。

ここまではごく自然な流れに見えますが、ここで問題になるのが、依存関係ファイルの生成ルールをどう記述すべきかです。そもそも依存関係ファイルはオブジェクトファイルの生成と同時に生成するようにしているので、どうにも書きようがないように見えます。

ではどうするかというと、次のようにします。

$(DEPS):

なんと空のルールを書いてしまいます。

一見、意味不明ですが、このように書いておくことで、「ルールがないよ」というエラーを回避し、依存ファイルが削除された場合にうまく対応できるようになります。

具体的には make は以下のように振る舞います。

  1. オブジェクトファイルの生成ルールを確認する
  2. オブジェクトファイルが依存関係ファイルに依存していることを認識する
  3. ディレクトリ上に該当の .d が存在するかどうかを確認する
  4. 存在しないので .d ファイルの生成ルールを確認する
  5. 生成ルールは空なので何もしない
  6. 依存関係ファイルの生成ルール(空)を実行したので依存ファイルはそろったはずだと認識する
  7. オブジェクトファイルの生成ルールを実行する

これでめでたくコア部分の対応は全て完了です。
あとは好みに合わせてディレクトリツリーのカスタマイズ等に精を出しましょう。

その他の注意点

コード上にいくつか注意点があるので、こちらにまとめておきます。
私が Makefile を再読する時のための個人的なメモなので、無視していただいて構いません。

= vs :=

Makefile の中で一箇所だけ不自然に = が使用されている箇所があります。タイポと勘違いして =:= に変えてしまうと、依存関係ファイルが出力されない状態になります。おまけにコンパイラから warning が飛んでくるようになります。

DEPFLAGS = -MT $@ -MMD -MP -MF $(DEPDIR)/$*.d

=:= では、定義中で使用されている変数を展開するタイミングが異なるため、使い分ける必要があります。= では DEPFLAGS が呼び出された先で変数が展開されますが、:= では DEPFLAGS の定義の中で変数が展開されます。

文面だとわかりづらいですが、以下のようなサンプルを作成するとわかりやすいです。

Makefile
VAR1 = $@
rule1:
    @echo $(VAR1)

VAR2 := $@
rule2:
    @echo $(VAR2)

上の Makefile を使って make rule1 を実行するとコンソール上に rule1 と出力されます。これは VAR1 の定義で使用されている $@ が rule1 の中で展開されるためです。そのため $@ はルール名に置換され、コンソール上に rule1 という文字列が表示されます。

一方で make rule2 を実行しても何も出力されません。:= の場合は $@ が VAR2 の中で展開されるためです。VAR2 の定義はルール名を持たないので、$@ は空文字列に置換され、コンソール上には何も表示されません。

この振る舞いの違いは、特に $@$* などを使用する場合に問題になります。

(私のように)ハマらないためにも、ちゃんと GNU のドキュメントを読みましょう。

.PHONY:

省略されがちな .PHONY: ですが、ここでは記述するようにしています。

.PHONY: xxx と書いておくと、Makefile に「 xxx はコマンドであって生成されるファイルではないよ」と伝えることができます。これにより、xxx というファイルが存在した場合でも make xxx が正しく実行できるようになります。

逆に .PHONY: xxx を省略した状態では、xxx というファイルが存在すると make xxx は実行に失敗します。大まかには以下のような流れになります。

  1. makexxx がターゲットだと理解する
  2. .PHONY: がないので xxx はファイルかコマンドのどちらかであると解釈する
  3. ディレクトリ上に xxx があるかを探す
  4. めでたく xxx があるのを発見(故に xxx はファイルであろうと判断する)
  5. 「もうあるからビルドしなくていいよね」とコンソールに出力する

簡単に騙されてしまう姿は愛くるしいですが、有害なので .PHONY: を記述するようにしています。


  1. include は読み込み対象の Makefile が存在しない場合に Makefile 自体の再ビルドを試行したりと、色々と独特な振る舞いをするのですが、今回のテーマからは外れるので割愛します。興味のある方は GNU のドキュメントを参照ください。https://www.gnu.org/software/make/manual/html_node/Remaking-Makefiles.html 

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4