入门基础
Make 是什么
make 是一个自动化构建工具。它最初常用于 C/C++ 项目编译,但现在也广泛用于:
- Go 项目构建
- 前端项目打包
- Python 环境初始化
- Docker 镜像构建
- 代码生成
- 单元测试
- lint 检查
- 本地开发环境启动
- CI/CD 中的任务编排
它的核心能力是:
- 根据规则执行命令
- 根据文件依赖决定是否重新执行
- 把复杂命令封装成简单命令,比如
make build - 为项目提供统一的开发入口
你可以把 Makefile 理解成一个项目级别的“命令说明书”。
最小 Makefile
最简单的 Makefile 文件长这样:
hello: echo "Hello, Makefile"在当前目录执行:make hello
输出:
echo "Hello, Makefile"Hello, Makefile这里:
hello是目标,英文叫targetecho "Hello, Makefile"是命令,英文叫recipe- 命令行前面必须是 Tab,不是空格
结构如下:
目标: 命令或者更完整地写:
target: prerequisites recipe其中:
target:要生成的目标,或者要执行的任务名prerequisites:依赖项recipe:实际执行的 shell 命令
Makefile 的基本语法
目标
build: go build -o app执行:
make build含义是:执行 build 目标下的命令。
多条命令
build: echo "start build" go build -o app echo "build done"执行顺序是从上到下。
默认目标
Makefile 的第一个目标是默认目标。
例如:
build: go build -o apptest: go test ./...直接执行:
make等价于:
make build很多项目默认要做的事不止一件:先构建,再测试,甚至再生成文档。
如果直接把 build 放在第一个,那 make 默认就只构建,不测试。
所以我们想要一个“总指挥”——通常叫 all,让它去调用别的任务:
.PHONY: allall: build testbuild: go build -o apptest: go test ./...all是第一个目标,所以make默认跑它。all后面写了build test,表示它依赖这两个目标,Make 会先去完成它们。- 结果:
make→ 默认跑all→ 依次执行build、test。
这样想加什么默认步骤,只要加到 all 的依赖里就行,非常灵活。
目标与文件的关系
Make 最早是为“文件构建”设计的。比如:
hello: hello.c gcc hello.c -o hello含义是:
hello是目标文件hello.c是依赖文件- 如果
hello不存在,执行命令生成它 - 如果
hello.c比hello更新,重新执行命令 - 如果
hello已经是最新的,就不执行
假设目录中有:hello.c,执行:
make hello会生成:hello 这个可执行文件,再执行一次:
make hello可能输出:make: ‘hello’ is up to date.
这就是 Make 的增量构建能力。
原理:时间戳比较规则,
make会做两步检查:
- 目标文件
hello是否存在?
- 不存在 → 一定“过时”,执行命令生成。
- 存在 → 进入下一步。
- 依赖文件
hello.c的修改时间(mtime),是否比目标文件hello的修改时间更新?
- 是 →
hello.c被改过,hello过时,重新编译。- 否 →
hello已经是最新的,跳过命令。
伪目标 .PHONY
Make 的“本能”:所有目标都优先当作文件
Make 的设计初衷是生成文件,所以当你写:
clean: rm -rf buildMake 的思路是:
- 寻找一个叫
clean的文件。 - 如果这个文件存在,而且它的所有依赖都比它旧,就不执行命令——因为“文件已经是最新的”。
- 如果文件不存在,就运行命令来“生成”它。
所以,如果你当前目录下恰好有一个文件名叫 clean(哪怕是个空文件),执行 make clean 时,Make 会发现:
- 文件
clean存在 - 它没有依赖(自然没有依赖比它新)
- 于是判定:
clean已经是最新,不需要执行命令
于是就输出 make: 'clean' is up to date.,你的清理任务就被跳过了,这显然是错的。所以应该声明伪目标:
.PHONY: cleanclean: rm -rf build.PHONY 的意思是:这些目标不是实际文件,每次都执行。
常见写法:
.PHONY: all build test clean install runall: buildbuild: go build -o apptest: go test ./...clean: rm -rf app当前 Makefile 第一行是:
.PHONY: all build test clean install run意思是这些目标都不是文件,而是任务名。
变量体系
变量
基础变量
APP = myappbuild: go build -o $(APP)执行 make build 时,$(APP) 会展开为 myapp。
Make 里变量引用常见两种形式:
$(APP)${APP}推荐使用 $(APP)。这两种写法在大多数现代 Make 工具(GNU Make、BSD Make)里功能完全一样,但推荐 $(APP) 主要是出于历史、可移植性和防混淆的考虑。
标准与兼容性:
$( )是“嫡系”,${ }是“旁支”Make 最初的设计者 Stuart Feldman 选择了
$(VAR),这是 Makefile 的“正统”语法。
${VAR}是后来一些实现(尤其是 GNU Make)为了兼容 shell / Perl 等语言的习惯而加入的扩展。但严格的 POSIX 标准只定义了$(...),并不是所有 Make 变体都支持${...}(比如一些老旧的 Unix 系统或嵌入式环境里的简化版make)。如果你希望 Makefile 在陌生系统上也可靠运行,
$(APP)是唯一保险的写法。
与 Shell 的冲突风险
Make 里大量工作是通过 Shell 执行的。Shell 本身也使用
${},而且用法更复杂。虽然 Make 在展开变量时会先处理完再交给 Shell,但人的大脑容易混淆。用
$( )可以一眼区分这是 Make 变量,不是 Shell 变量,减少误解。同时,如果 Makefile 中不小心写错了引用,错误信息也更容易定位。
变量覆盖(优先级问题)
Makefile:
APP = myappbuild: go build -o $(APP)命令行覆盖:
make build APP=server实际执行:
go build -o server这在工程里很常用,比如:
make build ENV=prodmake docker IMAGE_TAG=v1.2.3默认优先级:命令行 > Makefile 赋值 > 环境变量
在 不加任何特殊选项 的情况下,Make 按这个顺序决定变量的最终值:
- 命令行传入的变量(如
make APP=server)优先级最高,它会覆盖其他同名变量。- Makefile 里用
=或:=直接赋值的变量次之。- 来自 Shell 环境变量的值优先级最低,除非 Makefile 里完全没有定义这个变量,它才会作为默认值出现。
两个“逆转”优先级的特殊手段
-e选项(让环境变量反超 Makefile 赋值):执行make -e build,会强制环境变量覆盖 Makefile 里的同名赋值(但命令行依然最高)。这很少用,因为容易让构建行为变得不可预期。override指令(锁定 Makefile 的值):如果你在 Makefile 里写:override APP = locked_value。那么无论在命令行怎么传APP=...,Makefile 都会保持locked_value,不会被命令行覆盖。通常用于保护一些关键路径名,比如override SHELL := /bin/bash。
递归展开变量 =
A = $(B)B = helloprint: echo $(A)执行:
make print输出:
hello= 是递归展开,变量在使用时才展开。
简单展开变量 :=
B = helloA := $(B)B = worldprint: echo $(A)输出:
hello:= 是立即展开,定义时就确定值。
追加变量 +=
FLAGS = -WallFLAGS += -O2build: gcc $(FLAGS) main.c -o app最后 FLAGS 是:
-Wall -O2条件赋值 ?=
ENV ?= dev # 只在 ENV 未被定义时,才赋值为 devrun: echo "env=$(ENV)"make run→env=dev
(未通过命令行或环境变量定义 ENV,使用默认值)make run ENV=prod→env=prod
(命令行定义 ENV 会覆盖默认值)- 若 Shell 中已
export ENV=staging,make run→env=staging
(环境变量也视为“已定义”,?=不生效)
?= 表示:只在变量尚未被赋值(无论来自命令行、环境变量还是 Makefile 内部)时,才赋默认值。
自动变量
Make 提供了一组动态变化的特殊变量,它们的值会根据当前规则的目标和依赖自动填充。这些变量能让你写出更通用、复用性更强的规则。
$@
含义:当前规则中目标文件的名字。
app: main.go go build -o $@这里 $@ 是 app。
它让你不需要重复目标名,改动目标名时命令会自动同步。
$<
含义:当前规则的第一个依赖文件。
main.o: main.c gcc -c $< -o $@这里:
$<是main.c$@是main.o
典型场景:编译单个源文件时,
$<常代表“主源文件”,头文件作为后续依赖不会干扰命令。
$^
表示所有依赖。
app: main.o util.o gcc $^ -o $@这里 $^ 是:main.o util.o
典型场景:当目标需要链接多个对象文件时,
$^是最自然的写法。
$?
表示比目标更新的依赖。
backup.zip: a.txt b.txt c.txt zip $@ $?只有发生变化的文件会放进 $?。
典型场景:增量打包、库更新
常用自动变量总结
命令执行
Shell 与 Make 的关系
Makefile 里的命令默认由 /bin/sh 执行。
每一行命令默认是一个 shell
test: cd app pwd很多人以为 pwd 会在 app 目录下执行,但实际上不是。因为每一行都是单独 shell,等价于:
sh -c 'cd app'sh -c 'pwd'所以 pwd 仍然是原目录。
正确写法:
test: cd app && pwd或者:
test: cd app; pwd更推荐 &&,前一步失败则后一步不执行。
使用反斜杠续行
install: for pkg in a b c; do \ echo $$pkg; \ done这里有两个重点:
- 行尾的
\表示 shell 续行 $$pkg才是 shell 变量$pkg
为什么要写两个 $?
因为 Make 会先处理变量,如果写 $pkg,Make 会把 $p 当成 Make 变量。要把 $ 传给 shell,必须写 $$。
当前 Makefile 里有:
install-tools: @for tool in $(TOOLS) ; do \ go install $$tool; \ done这里:
$(TOOLS)是 Make 变量$$tool是 shell 循环变量
命令前缀
Makefile 命令前面可以加特殊符号。
@ 不打印命令本身
hello: @echo "hello"不加 @:
echo "hello"hello加 @:
hello- 忽略错误
clean: -rm app如果 app 不存在,rm app 报错,但 Make 不会停止。常见写法:
clean: rm -f app比 -rm app 更清晰。
+ 强制执行递归 make
sub: +$(MAKE) -C subdir build这个在递归 Make 或并行构建中有用。
内置能力
Make 的内置变量
Make 提供了一些预定义变量,可以直接在 Makefile 中使用,无需自己声明。它们让操作目录、调用编译器、控制 Shell 等变得非常方便。
| 变量 | 含义 | 默认值 |
|---|---|---|
CURDIR |
make 启动时的绝对路径 |
(执行路径) |
MAKE |
当前 make 命令名 | make |
MAKECMDGOALS |
命令行中指定的目标 | 空或 all |
MAKEFILE_LIST |
所有已加载 Makefile 路径列表 | 依 include 而定 |
SHELL |
执行命令的 Shell | /bin/sh |
CC |
C 编译器 | cc |
CXX |
C++ 编译器 | g++ |
CFLAGS |
C 编译参数 | 空 |
CXXFLAGS |
C++ 编译参数 | 空 |
LDFLAGS |
链接器参数 | 空 |
VPATH |
依赖文件的搜索路径 | 无 |
比如:CWD := $(CURDIR)就是获取make命令启动时所在目录的绝对路径。
函数
Makefile 里有函数,格式是:
$(函数名 参数1,参数2,...)# 或者${函数名 参数1,参数2,...}- 函数名与参数之间必须有空格(这是与变量引用
$(VAR)的唯一区别)。 - 参数之间用逗号分隔。
shell
执行 shell 命令并拿到输出。
DATE := $(shell date +%Y%m%d)print: echo $(DATE)当前 Makefile 里有:
$(shell go list -f '{{.Dir}}/' -m)意思是执行 go list,拿到所有 Go module 的目录。
wildcard
通配符匹配文件。
SRCS := $(wildcard *.c)比如目录中有:
main.c util.c则 SRCS 是:
main.c util.cpatsubst
模式替换。将 $(SRCS) 中所有符合 %.c 模式的字符串替换成 %.o,% 表示匹配任意非空字符串:
SRCS := main.c util.cOBJS := $(patsubst %.c,%.o,$(SRCS))OBJS 是:
main.o util.osubst
字符串替换,参数顺序:$(subst 查找字符串,替换字符串,原始文本)
TEXT := a-b-cOUT := $(subst -, ,$(TEXT)) *# 把 '-' 换成空格*OUT 是:
a b cfilter
保留匹配项。
FILES := a.go b.py c.goGOFILES := $(filter %.go,$(FILES))GOFILES 是:
a.go c.gofilter-out
去掉匹配项。
FILES := a.go b_test.go c.goFILES := $(filter-out %_test.go,$(FILES))dir 和 notdir
拆分路径的目录部分和文件名部分
PATHS := src/main.go pkg/util.goDIRS := $(dir $(PATHS))FILES := $(notdir $(PATHS))结果:
DIRS = src/ pkg/FILES = main.go util.go条件判断
Makefile 支持条件语句。
基本语法形式
# 单分支ifeq (参数1, 参数2) 语句块endif# 双分支ifeq (参数1, 参数2) 语句块1else 语句块2endif# 多分支(GNU Make 3.81+)ifeq (参数1, 参数2) 语句块1else ifeq (参数3, 参数4) 语句块2else 语句块3endif关键规则:
- 条件必须顶格写,前面不能有 Tab 缩进(
ifeq/ifneq/ifdef/ifndef在行首)。 - 语句块内的 Make 变量赋值或规则可以正常缩进,但
else/endif也必须顶格。 - 条件判断发生在 Makefile 解析阶段(不是执行阶段),因此不能放在规则命令内部。
ifeq — 相等判断
用途:判断两个参数是否完全相同(展开后进行字符串比较)。
ENV ?= devifeq ($(ENV),prod) FLAGS = -O2else FLAGS = -gendif($(ENV),prod)括号内逗号两边可以有空格(推荐添加以提升可读性),也可以省略成'$(ENV)' 'prod'或用双引号包裹:"$(ENV)" "prod"。- 如果
ENV=prod,则FLAGS = -O2,否则FLAGS = -g。
ifneq — 不等判断
用途:与 ifeq 相反,两个参数不相同时条件为真。
ifneq ($(ENV),prod) FLAGS = -gendif等价于 ifeq + else 的另一个分支。可以用于“非生产环境才加调试信息”等场景。
ifdef — 变量已定义
用途:判断一个变量是否已被定义(即使其值为空,也算已定义)。
ifdef DEBUG FLAGS += -gendif- 执行
make build DEBUG=1,DEBUG被定义为1,条件为真。 - 执行
make build DEBUG=,DEBUG的值是空字符串,但ifdef DEBUG仍然为真。 - 若从未在命令行、环境变量或 Makefile 中声明过
DEBUG,则ifdef DEBUG为假。
易混淆点:检查变量是否非空,更稳妥的方式是用 ifeq ($(VAR),) 测试空值,而不是依赖 ifdef。
# 只在 DEBUG 非空时添加 -gifneq ($(DEBUG),) FLAGS += -gendififndef — 变量未定义
用途:变量未定义时条件为真。常用来设置默认值。
ifndef ENV ENV = devendif- 若
ENV从未被赋值(包括命令行未传入、环境变量不存在、Makefile 中之前没有对其赋值),则执行赋值。 - 这和我们之前学的
?=功能很相似,但?=更简洁;ifndef的优势在于可以在条件块内执行更复杂的操作,比如:
ifndef ENV $(info Setting ENV to default) ENV = devendif模式规则
模式规则是 Makefile 非常重要的能力。
基础模式规则
%.o: %.c gcc -c $< -o $@含义是:
- 任意
.o文件依赖对应的.c文件 - 比如
main.o依赖main.c - 比如
util.o依赖util.c
完整例子:
app: main.o util.o gcc $^ -o $@%.o: %.c gcc -c $< -o $@执行:
make appMake 会自动知道:
main.o <- main.cutil.o <- util.capp <- main.o util.o静态模式规则
OBJS = main.o util.o$(OBJS): %.o: %.c gcc -c $< -o $@它由三部分组成:
目标列表 : 目标模式 : 依赖模式 命令- 目标列表:你需要应用规则的具体文件列表,这里
$(OBJS)就是main.o util.o。 - 目标模式:
%.o—— 从目标列表中提取“匹配部分”的模板。 - 依赖模式:
%.c—— 根据提取出的匹配部分,生成对应的依赖。
它是怎么工作的?
Make 逐一取出
$(OBJS)中的每个文件,用目标模式%.o去匹配,提取出%对应的茎(stem),然后把依赖模式中的%替换成这个茎,得到依赖文件。例如:
- 对于
main.o,%匹配到main,所以依赖是main.c。- 对于
util.o,%匹配到util,所以依赖是util.c。效果上看起来和普通模式规则一样,但范围限制在了
$(OBJS)列表里。
依赖关系
Makefile 最大的价值之一是表达依赖。
build: generate go build -o appgenerate: go generate ./...执行:
make build会先执行:
make generate再执行:
go build -o app多个依赖:
release: clean test build package执行顺序大体是:
clean -> test -> build -> package -> release但注意:如果开了并行构建 make -j,没有依赖关系的目标可能并行执行。
include 拆分 Makefile
大项目中 Makefile 可能拆成多个文件。
include scripts/common.mkinclude scripts/docker.mk如果文件不存在会报错。可以用 -include 忽略不存在:
-include .env.mk常见用法:
-include local.mk让每个人可以有自己的本地配置。
工程化实践
递归 Make
如果项目有多个子目录:
build-common: $(MAKE) -C common buildbuild-server: $(MAKE) -C server build-C 表示进入目录执行 make。例如:
make -C predict build等价于:
cd predict && make build推荐用 $(MAKE) 而不是直接写 make,因为它会继承 make 的参数,比如 -j。
并行构建
执行:
make -j4表示最多同时执行 4 个任务。例如:
all: a b ca: sleep 1; echo ab: sleep 1; echo bc: sleep 1; echo c执行:
make -j3 alla b c 可能同时执行。
如果你希望某些任务必须顺序执行,要明确写依赖:
all: cc: b echo cb: a echo ba: echo a错误处理
默认行为
命令返回非 0,Make 停止。
test: false echo "never run"执行时 echo 不会执行。
忽略错误
test: -false echo "still run"使用 set -e
如果一行中有复杂 shell:
build: set -e; \ cd app; \ go build ./...如果中间失败,会停止。
推荐用 &&
build: cd app && go build ./...Makefile 与环境变量
Make 变量和 shell 环境变量会互相影响。
Makefile 变量传给 shell
ENV = devrun: echo $$ENV这样并不会输出 dev,因为 ENV 是 Make 变量,不一定是 shell 环境变量。
要传给 shell:
export ENV = devrun: echo $$ENV或者:
run: ENV=dev go run main.go命令行传变量
make run ENV=prodMakefile:
run: echo $(ENV)输出:
prodexport 所有变量
exportENV = dev不太推荐大范围使用,容易污染环境。
常见工程目标设计
一个规范的 Makefile 通常会有这些目标:
.PHONY: all init install-tools build test lint fmt clean run tidy generateall: buildinit: @echo "init project"install-tools: @echo "install tools"build: @echo "build project"test: @echo "run tests"lint: @echo "run lint"fmt: @echo "format code"clean: @echo "clean output"run: @echo "run app"tidy: @echo "tidy deps"generate: @echo "generate code"对于 Go 项目,常见写法:
APP ?= appPKGS := ./....PHONY: build test lint fmt tidy clean runbuild: go build -o bin/$(APP) .test: go test $(PKGS)lint: golangci-lint runfmt: gofmt -w .tidy: go mod tidyclean: rm -rf binrun: build ./bin/$(APP)常见坑
Tab 和空格
命令必须用 Tab。
Make 变量和 Shell 变量混淆
Make 变量:$(NAME)
Shell 变量:$$name
每行一个 shell
错误:
run: cd output sh predict_server.sh正确:
run: cd output && sh predict_server.sh忘记 .PHONY
建议所有任务型 target 都加 .PHONY。
变量展开时机
A = $(shell date)B := $(shell date)A每次使用时展开B定义时展开
路径中有空格
Makefile 对空格非常敏感。工程路径尽量不要包含空格。
make 默认用 /bin/sh
如果你写了 Bash 特性,最好指定:
SHELL := /bin/bash编写高质量 Makefile 的建议
提供帮助命令
常见写法:
.PHONY: helphelp: @echo "Available targets:" @echo " make build Build project" @echo " make test Run tests" @echo " make lint Run linter" @echo " make clean Clean output"更高级一点:
.PHONY: helphelp: @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ awk 'BEGIN {FS = ":.*?## "}; {printf "%-20s %s\n", $$1, $$2}'build: ## Build project go build -o apptest: ## Run tests go test ./...执行:
make help输出:
build Build projecttest Run tests可覆盖变量
APP ?= appENV ?= devGOFLAGS ?=build: go build $(GOFLAGS) -o $(APP)这样用户可以:
make build APP=server GOFLAGS="-race"明确失败行为
复杂 shell 目标推荐:
deploy: set -euo pipefail; \ echo "deploy"; \ ./deploy.sh注意 pipefail 是 Bash 特性,如果用它,要设置:
SHELL := /bin/bash不要把太复杂的逻辑塞进 Makefile
Makefile 适合做任务编排,不适合写复杂业务逻辑。
推荐:
build: ./script/build.sh不推荐在 Makefile 里塞几百行 shell。
Go 项目模板
Go 项目 Makefile 模板
下面是一个比较实用的 Go 项目模板:
SHELL := /bin/bashAPP ?= appPKGS := ./...GOFLAGS ?=LDFLAGS ?=.PHONY: help install-tools fmt lint test build run clean tidyhelp: @echo "Available targets:" @echo " install-tools Install development tools" @echo " fmt Format code" @echo " lint Run linter" @echo " test Run tests" @echo " build Build binary" @echo " run Run binary" @echo " clean Remove build outputs" @echo " tidy Tidy go modules"install-tools: go install golang.org/x/tools/cmd/goimports@latest go install github.com/golang/mock/mockgen@latestfmt: gofmt -w . goimports -w .lint: golangci-lint run ./...test: go test -count=1 $(PKGS)build: mkdir -p bin go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o bin/$(APP) .run: build ./bin/$(APP)clean: rm -rf bintidy: go mod tidy使用:
make buildmake testmake runmake build APP=servermake build LDFLAGS="-s -w"多模块 Go Workspace 模板
当项目是多模块 workspace,可以参考这种结构:
MODULE_DIRS := $(shell go list -f '{{.Dir}}' -m).PHONY: tidy test synctidy: @for mod in $(MODULE_DIRS); do \ echo "tidy $$mod"; \ (cd $$mod && go mod tidy); \ done @go work synctest: @for mod in $(MODULE_DIRS); do \ echo "test $$mod/..."; \ go test $$mod/...; \ donesync: go work sync注意:
(cd $$mod && go mod tidy)比:
cd $$mod && go mod tidy更安全一些,因为它在子 shell 里切目录,不影响后续命令。
调试与学习路径
Makefile 调试技巧
打印变量
print: @echo "APP=$(APP)" @echo "CURDIR=$(CURDIR)"或者通用写法:
print-%: @echo '$*=$($*)'使用:
make print-APPmake print-CURDIR只打印不执行
make -n build会打印将要执行的命令,但不执行。
显示详细调试信息
make --debug=b build更详细:
make --debug=v build打印数据库
make -p会输出 Make 解析后的所有变量、规则,非常多。
禁用内置规则
make -r大项目中有时会用:
MAKEFLAGS += --no-builtin-rulesMakefile 与 CI/CD
很多项目会在 CI 里直接使用 Makefile:
steps: - run: make install-tools - run: make lint - run: make test - run: make build好处是:
- 本地和 CI 使用同一套入口
- 新人只要学会
make test、make build - CI 配置更简洁
但当前项目比较特殊:CI 并不完全走 make install-tools,而是直接在 CI 配置里下载工具。这也是你之前遇到本地工具链和 CI 行为不一致的原因。
Comments