- 技術 💻
Makefileの書き方|自動変数一覧とタスクランナー用テンプレート
Makefileとは、「何を作るか(ターゲット)」「何に依存するか」「どう作るか(レシピ)」を書いておき、make コマンドで必要な処理だけを実行させるための設定ファイルです。 もともとはC言語のビルド用ですが、現在は make test や make up のように、プロジェクトのコマンドをまとめる「タスクランナー」 としても広く使われています。
この記事では、基本構文から自動変数・変数代入・関数・条件分岐までを実際に make を実行した出力つきで解説し、help を自動生成する Docker Compose / Python(uv) / Node.js 用テンプレートを載せます。
検証環境: GNU Make 4.3(make --version の出力は GNU Make 4.3 / Built for x86_64-pc-linux-gnu)、gcc、uv 0.8.17、Node.js 22。GNU Make の最新版は 4.4.1(2023年2月)です。
Makefileの書き方の早見表
| やりたいこと | 書き方 | ポイント |
|---|---|---|
| 基本のルール | ターゲット: 依存 の次行に タブ + コマンド |
スペースだと missing separator |
| 最初に実行されるターゲット | ファイル内で最初のターゲット | .DEFAULT_GOAL := help で変更 |
| ファイルを作らないコマンド | .PHONY: clean test |
同名ファイルがあっても実行される |
| 作るファイル名 | $@ |
build/app |
| 最初の依存 | $< |
コンパイル元の .c |
| 全部の依存(重複なし) | $^ |
リンク時に使う |
| 更新された依存だけ | $? |
差分処理 |
パターンの % 部分 |
$* |
out/a.log なら a |
| ターゲットのディレクトリ | $(@D) |
mkdir -p $(@D) |
| 使うときに展開する変数 | A = 値 |
後から上書きされた値になる |
| その場で確定する変数 | A := 値 |
通常はこちらを推奨(?= 未定義なら、+= 追記) |
| 拡張子を変換 | $(patsubst src/%.c,build/%.o,$(SRCS)) |
$(SRCS:src/%.c=build/%.o) も同じ |
| ファイル一覧・コマンド結果 | $(wildcard src/*.c) / $(shell git --version) |
:= と組み合わせる |
| 条件分岐 | ifeq ($(OS),Linux) 〜 endif |
行頭はタブにしない |
| 別ファイルを読み込む | include config.mk / -include local.mk |
- 付きはファイルがなくても続行 |
| 実行せずに確認 | make -n |
dry-run |
| 並列実行 | make -j4 |
依存関係が正しいことが前提 |
| 行頭の記号 | @(表示しない) / -(エラーでも続行) |
Error 1 (ignored) |
| レシピを1つのシェルで | .ONESHELL: |
cd が次の行に引き継がれる |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Makefileの書き方|自動変数一覧とタスクランナー用テンプレート」
基本の書き方:ターゲット・依存・レシピ
ターゲット: 依存ファイル1 依存ファイル2
レシピ(実行するコマンド)※行頭はタブ
make は 「ターゲットが存在しない」か「依存ファイルの方が新しい」ときだけレシピを実行します。
C言語のビルドで動きを確認する
次の構成で試します。
src/main.c src/util.c src/util.h Makefile
CC := gcc
CFLAGS := -Wall -O2
SRCS := $(wildcard src/*.c)
OBJS := $(patsubst src/%.c,build/%.o,$(SRCS))
TARGET := build/app
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) -o $@ $^
build/%.o: src/%.c src/util.h
@mkdir -p $(@D)
$(CC) $(CFLAGS) -c -o $@ $<
clean:
rm -rf build
実行結果:
$ make
gcc -Wall -O2 -c -o build/main.o src/main.c
gcc -Wall -O2 -c -o build/util.o src/util.c
gcc -Wall -O2 -o build/app build/main.o build/util.o
$ make
make: Nothing to be done for 'all'.
$ touch src/util.c && make
gcc -Wall -O2 -c -o build/util.o src/util.c
gcc -Wall -O2 -o build/app build/main.o build/util.o
$ ./build/app
total=5
2回目は何も実行されず、util.c を更新すると util.o とリンクだけがやり直されました。 これが依存関係を書く意味です。
タブではなくスペースで書くと「missing separator」
レシピの行頭をスペース4つにしたMakefileを実行すると、次のエラーになりました。
$ make -f Makefile.space
Makefile.space:2: *** missing separator. Stop.
レシピの行頭は必ずタブ文字です。.editorconfig に [Makefile] と indent_style = tab を書いておくと防げます。cat -A Makefile ではタブが ^I と表示されます。
自動変数の一覧($@ $< $^ $? $*)
自動変数は、ルールごとにmakeが自動で値を入れてくれる変数です。依存に a.txt を重複して書いた例で確認しました。
out/result.txt: a.txt b.txt a.txt
@echo '$$@ = $@'
@echo '$$< = $<'
@echo '$$^ = $^'
@echo '$$+ = $+'
@echo '$$? = $?'
@echo '$$(@D) = $(@D)'
@echo '$$(@F) = $(@F)'
@touch $@
out/%.log: %.txt
@echo '$$* = $*'
$ make
$@ = out/result.txt
$< = a.txt
$^ = a.txt b.txt
$+ = a.txt b.txt a.txt
$? = a.txt b.txt
$(@D) = out
$(@F) = result.txt
$ touch b.txt && make | grep '\$?'
$? = b.txt
$ make out/a.log
$* = a
| 変数 | 意味 | 実行結果 |
|---|---|---|
$@ |
ターゲット名 | out/result.txt |
$< |
最初の依存 | a.txt |
$^ |
全依存(重複を除く) | a.txt b.txt |
$+ |
全依存(重複もそのまま) | a.txt b.txt a.txt |
$? |
ターゲットより新しい依存 | 初回は全部、2回目は b.txt |
$* |
パターンの % に一致した部分 |
a |
$(@D) / $(@F) |
ディレクトリ/ファイル名 | out / result.txt |
レシピの中でシェルの $ を使いたいときは $$ と2つ重ねます(上の '$$@' はそのためです)。
変数の代入:= と := と ?= と += の違い
NAME = before
LAZY = $(NAME)
NOW := $(NAME)
NAME = after
ENV ?= dev
FLAGS := -Wall
FLAGS += -O2
TIME_LAZY = $(shell date +%N)
TIME_NOW := $(shell date +%N)
show:
@echo 'LAZY = $(LAZY)'
@echo 'NOW = $(NOW)'
@echo 'ENV = $(ENV)'
@echo 'FLAGS = $(FLAGS)'
@echo 'TIME_LAZY: $(TIME_LAZY) / $(TIME_LAZY)'
@echo 'TIME_NOW : $(TIME_NOW) / $(TIME_NOW)'
$ make
LAZY = after
NOW = before
ENV = dev
FLAGS = -Wall -O2
TIME_LAZY: 193890592 / 196437124
TIME_NOW : 190171156 / 190171156
$ ENV=prod make | grep ENV
ENV = prod
$ make ENV=stg | grep ENV
ENV = stg
| 代入 | 展開のタイミング | 実行結果から読み取れること |
|---|---|---|
= |
使うたびに展開 | 後から NAME = after にすると LAZY も after。$(shell) は参照のたびに実行され、値が毎回変わる |
:= |
その行で確定 | NOW は before のまま。$(shell) は1回だけ実行 |
?= |
未定義のときだけ設定 | 環境変数 ENV=prod やコマンド引数 ENV=stg が優先 |
+= |
追記 | -Wall -O2 |
迷ったら := を使うと、値がいつ決まるかが明確になり、$(shell ...) の実行回数も1回で済みます。
なお A = x の次に A = $(A) y と書くと、*** Recursive variable 'A' references itself (eventually). Stop. で止まりました。追記は A += y か A := $(A) y と書きます。
.PHONY:ファイルを作らないターゲット
clean という名前のファイルがある状態で、.PHONY なしの clean を実行した結果です。
$ touch clean && make clean
make: 'clean' is up to date.
clean を「既にあるファイル」とみなして何もしません。 .PHONY: clean を追加するとレシピが実行されました。test build docs などフォルダ名と重なりやすいターゲットは必ず .PHONY に書きます。
パターンルール %.o: %.c
最初のC言語の例の build/%.o: src/%.c src/util.h がパターンルールです。% は任意の文字列に一致し、左右で同じ文字列が使われます(build/main.o → src/main.c)。ヘッダー更新後に何が実行されるかを make -n(dry-run)で確認しました。
$ touch src/util.h && make -n
mkdir -p build
gcc -Wall -O2 -c -o build/main.o src/main.c
mkdir -p build
gcc -Wall -O2 -c -o build/util.o src/util.c
gcc -Wall -O2 -o build/app build/main.o build/util.o
-n は @ で隠したコマンドも表示し、実際には何も実行しません。
よく使う関数(wildcard・patsubst・shell・foreach ほか)
SRCS := $(wildcard src/*.c)
OBJS := $(patsubst src/%.c,build/%.o,$(SRCS))
NAMES := $(notdir $(basename $(SRCS)))
GIT := $(shell git --version)
DIRS := api web worker
EACH := $(foreach d,$(DIRS),svc-$(d))
.PHONY: show
show:
@echo 'SRCS = $(SRCS)'
@echo 'OBJS = $(OBJS)'
@echo 'NAMES = $(NAMES)'
@echo 'FILTER = $(filter %b.c,$(SRCS))'
@echo 'GIT = $(GIT)'
@echo 'EACH = $(EACH)'
src/a.c、src/b.c、src/sub/c.c がある状態での出力:
SRCS = src/a.c src/b.c
OBJS = build/a.o build/b.o
NAMES = a b
FILTER = src/b.c
GIT = git version 2.43.0
EACH = svc-api svc-web svc-worker
| 関数 | 役割 | 注意点 |
|---|---|---|
wildcard |
ファイル一覧 | サブディレクトリは含まない(src/sub/c.c は出ない)。再帰したいなら $(shell find src -name '*.c') |
patsubst |
パターン置換 | $(VAR:a=b) は短縮形 |
filter / notdir / basename |
抽出・パス操作 | filter-out は除外 |
shell / foreach |
コマンド出力/繰り返し | shell は := と組み合わせる |
条件分岐 ifeq・ifdef と include
OS := $(shell uname -s)
ifeq ($(OS),Linux)
OPEN := xdg-open
else ifeq ($(OS),Darwin)
OPEN := open
else
OPEN := start
endif
ifdef DEBUG
CFLAGS := -g -O0
else
CFLAGS := -O2
endif
Linuxでの実行結果は OPEN = xdg-open、CFLAGS = -O2、make DEBUG=1 では CFLAGS = -g -O0 になりました。ifeq などの行はタブで始めない(タブで始めるとレシピとして扱われる)のがポイントです。
include で設定を分けると、個人ごとの上書きファイルも作れます。
# config.mk の中身は「APP_PORT ?= 8000」と「export DB_NAME := app」
include config.mk
-include local.mk
.PHONY: p
p:
@echo port=$(APP_PORT) db=$$DB_NAME
$ make
port=8000 db=app
$ echo 'APP_PORT := 9000' > local.mk && make
port=9000 db=app
-include はファイルがなくても続行します。 - なしだと No rule to make target 'nothing.mk'. Stop. で止まりました。export した変数はシェルの環境変数になります($$DB_NAME)。
並列実行 -j と .ONESHELL・デフォルトゴール
-j で並列実行
1秒かかる独立したターゲット3つを実行した時間です。
| コマンド | 実行時間 |
|---|---|
make |
3.03秒 |
make -j3 |
1.03秒 |
make -j3 --output-sync=target |
1.01秒 |
依存関係が正しければ -j だけで速くなります。 ログが混ざるなら --output-sync=target でターゲットごとにまとめます。-j でだけ失敗する場合は依存の書き漏れを見直します。
レシピは1行ずつ別のシェルで実行される(.ONESHELL)
normal:
cd /tmp
pwd
X=1
echo "X=$$X"
make normal の結果、pwd は /tmp ではなくMakefileのあるディレクトリを表示し、最後は X= と空になりました。各行は別のシェルなので、cd も変数も引き継がれません。 ファイル先頭に .ONESHELL: を書くと /tmp と X=1 が出力されました。使わない場合は cd /tmp && pwd と1行につなげます。
デフォルトゴールとエラー時の動き
| 書き方 | 動き(実行結果) |
|---|---|
何も指定しない/.DEFAULT_GOAL := second |
最初のターゲット/second が実行された |
| レシピがエラー | make: *** [Makefile:4: fail] Error 1 で停止、終了コード 2 |
レシピ行頭に - |
make: [Makefile:7: ignore] Error 1 (ignored) と表示して続行 |
タスクランナー用テンプレート(help自動生成つき)
ターゲットの行末に ## 説明 を書き、grep と awk で一覧を表示するのが定番の書き方です。
Python(uv)プロジェクト用
# ==== Python (uv) プロジェクト用 Makefile ====
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help
MAKEFLAGS += --no-builtin-rules
.PHONY: help setup lint format test check clean
help: ## コマンド一覧を表示
@grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
setup: ## 依存関係をインストール(uv sync)
uv sync
uv add --dev ruff pytest
lint: ## ruff で静的チェック
uv run ruff check .
format: ## ruff で整形
uv run ruff format .
test: ## pytest を実行
uv run pytest -q
check: lint test ## lint と test をまとめて実行
clean: ## キャッシュを削除
rm -rf .pytest_cache .ruff_cache
find . -name __pycache__ -type d -prune -exec rm -rf {} +
uv init --lib taskapp での実行結果(色コードは省略):
$ make
help コマンド一覧を表示
setup 依存関係をインストール(uv sync)
lint ruff で静的チェック
format ruff で整形
test pytest を実行
check lint と test をまとめて実行
clean キャッシュを削除
$ make check
uv run ruff check .
I001 [*] Import block is un-sorted or un-formatted
(中略)
make: *** [Makefile:18: lint] Error 1
check: lint test は lint が失敗した時点で止まり、test は実行されません。 uv run ruff check --fix . で直した後は次のとおり通りました(ruff 0.16.9 / pytest 9.1.1)。
$ make check
uv run ruff check .
All checks passed!
uv run pytest -q
. [100%]
1 passed in 0.01s
.SHELLFLAGS := -eu -o pipefail -c はパイプ途中の失敗や未定義変数でも止める設定です。
Node.js プロジェクト用(npm install を必要なときだけ)
# ==== Node.js 用 Makefile ====
STAMP := node_modules/.installed
.DEFAULT_GOAL := help
.PHONY: help install test build
help: ## コマンド一覧
@grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-10s %s\n", $$1, $$2}'
# package.json か package-lock.json が変わったときだけ npm install
$(STAMP): package.json package-lock.json
npm install
@mkdir -p $(@D) && touch $@
install: $(STAMP) ## 依存関係をインストール
test: $(STAMP) ## テスト
npm test --silent
build: $(STAMP) ## ビルド
npm run build --silent
目印ファイル(スタンプ)を依存にしたことで、make build の1回目は npm install → ビルド、2回目は npm run build --silent だけが実行されました。 更新日時で判断するmakeならではの使い方です。
Docker Compose 用
# ==== Docker Compose 用 Makefile ====
COMPOSE ?= docker compose
SERVICE ?= app
.DEFAULT_GOAL := help
.PHONY: help up down restart logs sh
help: ## コマンド一覧
@grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-10s %s\n", $$1, $$2}'
up: ## バックグラウンドで起動
$(COMPOSE) up -d
down: ## 停止してコンテナを削除
$(COMPOSE) down
restart: down up ## 再起動
logs: ## ログを追いかける(SERVICE=名前 で絞り込み)
$(COMPOSE) logs -f $(SERVICE)
sh: ## コンテナに入る
$(COMPOSE) exec $(SERVICE) sh
make -n で展開結果を確認しました(docker compose 自体は未実行)。
$ make -n restart
docker compose down
docker compose up -d
$ make -n logs SERVICE=db
docker compose logs -f db
SERVICE ?= app にしておくと、make logs SERVICE=db のように引数で切り替えられます。
GNU make と BSD make の違い
この記事の構文はGNU make向けです。FreeBSD等の標準 make はBSD makeで、条件分岐は .if / .endif、変数は ${.TARGET} ${.ALLSRC}、関数の代わりに修飾子 ${VAR:M*.c} を使うため、gmake として GNU make を入れて使います(BSD make は未実行、マニュアルに基づく)。macOS標準は GNU Make 3.81 なので、.ONESHELL(3.82以降)を使うなら Homebrew の gmake を使います。
just・npm scripts・Taskfile との比較
| ツール | 設定ファイル | 強み | 注意点 |
|---|---|---|---|
| make | Makefile |
標準で入っていることが多い、更新日時で差分実行 | タブ必須、$$ |
| just | justfile |
引数が素直、タブ不要 | 別途インストール |
| npm scripts | package.json |
Nodeなら追加不要 | 差分実行はない |
| Taskfile | Taskfile.yml |
YAML、sources で差分実行 |
別途インストール |
よくある質問
Q. make: *** No rule to make target 'xxx' と出ます。
A. 依存に書いたファイルが存在せず、それを作るルールもないときのエラーです。ファイル名のつづり、パス、include したファイルの有無を確認します。
Q. Makefile と makefile のどちらがよいですか?
A. GNU make の公式マニュアルは Makefile を推奨しています(探す順は GNUmakefile → makefile → Makefile)。
Q. レシピでシェル変数が空になります。
A. 各行が別のシェルで動くためです。&& でつなぐか .ONESHELL: を使い、$$X と書きます。
まとめ
- Makefileは「ターゲット: 依存」+タブで始まるレシピ。スペースだと
missing separator - 自動変数
$@$<$^$?$*$(@D)でファイル名を繰り返し書かずに済む - 変数は基本
:=、上書き可能にしたい値は?=、追記は+= - ファイルを作らないターゲットは
.PHONYに書く make -nで確認、make -jで並列化、.ONESHELLでレシピを1つのシェルに## コメントと grep/awk の help で、タスクランナーとしてチームに共有しやすくなる
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Makefileの書き方|自動変数一覧とタスクランナー用テンプレート」
株式会社RJCでは、チームの誰もが同じコマンドで開発を始められるように、環境の整え方をプロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。