前言:一个源文件比较少的程序,直接使用一条 gcc 命令就能完成编译;但是随着源文件越来越多,每次都手动判断哪些文件需要重新编译、这些文件又应该按照什么顺序处理,就会变得非常麻烦。make 与 Makefile 正是用来解决这类问题的,本篇主要介绍它们的基本使用、依赖关系、增量构建以及多文件项目中的常见写法,最后再简单补充一下回车、换行和行缓冲区。
1.make与Makefile的简单认识
1.1为什么需要make
假设一个程序只有一个 code.c 文件,直接使用下面的命令就能生成可执行文件:
bash
gcc code.c -o code
如果程序由多个源文件组成,我们通常会先把每个 .c 文件编译成对应的 .o 文件,再把这些目标文件链接起来。文件数量少时还可以手动完成,但工程变大以后就会出现几个问题:
- 编译命令越来越多,每次完整输入比较麻烦;
- 文件之间存在依赖关系,处理顺序不能随意打乱;
- 只修改了一个源文件时,没有必要把所有文件全部重新编译;
- 不同开发者手动输入的命令可能不一致,容易出现遗漏。
Makefile 可以把目标、依赖关系和构建命令统一记录下来,make 则负责读取这些规则,判断当前需要执行哪些命令。
这里需要注意:**make本身不是编译器。**真正完成预处理、编译、汇编和链接的仍然是 gcc 或 g++,make 只是按照 Makefile 中写好的依赖关系组织这些工具工作。
简单来说:
Makefile是一个保存构建规则的文件;make是读取规则并组织自动化构建的命令工具。
1.2第一份Makefile
先准备一个简单的 code.c:
c
#include <stdio.h>
int main()
{
printf("hello Makefile!\n");
return 0;
}
接着在同一目录中创建一个名为 Makefile 的文件:
makefile
code: code.c
gcc -o code code.c
.PHONY: clean
clean:
rm -f code
一条最基本的 Makefile 规则由三部分组成:
text
目标文件: 依赖文件
<Tab>生成目标文件所执行的命令

在上面的规则中:
code是目标,也就是最终希望得到的文件;code.c是依赖文件,生成code需要用到它;gcc -o code code.c是构建目标时真正执行的命令,也可以叫做依赖方法。
命令所在的行默认必须以 Tab 键 开头,不能随手使用几个空格代替。编辑 Makefile 时如果出现 missing separator 一类错误,首先就可以检查这里是不是没有使用 Tab。
保存以后,在当前目录直接执行:
bash
make
make 通常会寻找 Makefile 或 makefile,在没有显式指定目标时,把其中第一个普通目标作为默认目标。因此这里会执行 code 对应的命令:
text
gcc -o code code.c
生成以后就可以运行:
bash
./code
输出结果为:
text
hello Makefile!
除了直接执行 make,也可以在后面写出想要构建的目标:
bash
make code
make clean
make code 明确要求生成 code,make clean 则明确要求执行 clean 规则。它们选择的是两个不同的目标。
2.make的工作方式与常用写法
2.1依赖关系与增量构建
前面的规则只用一条 gcc 命令完成了整个翻译过程。为了更直观地观察依赖关系,可以把预处理、编译、汇编和链接分别写成四层规则:
makefile
code: code.o
gcc code.o -o code
code.o: code.s
gcc -c code.s -o code.o
code.s: code.i
gcc -S code.i -o code.s
code.i: code.c
gcc -E code.c -o code.i
.PHONY: clean
clean:
rm -f *.i *.s *.o code
它们形成了一条完整的依赖链:
text
code -> code.o -> code.s -> code.i -> code.c

当我们请求生成最上层的 code 时,make 会先从目标开始逐层查找依赖:
code依赖code.o;code.o依赖code.s;code.s依赖code.i;code.i最终依赖已经存在的code.c。
找到最底层以后,再按照相反的顺序逐层生成目标,所以实际执行顺序是:
text
code.c -> code.i -> code.s -> code.o -> code
这个过程可以简单理解为:自顶向下查找依赖,自底向上执行构建。 make 并不是把 Makefile 从第一行到最后一行依次执行,而是先确定当前目标,再按照依赖关系选择真正需要运行的规则。
第一次执行 make 时,目标文件都不存在,因此四条命令会依次执行:
text
gcc -E code.c -o code.i
gcc -S code.i -o code.s
gcc -c code.s -o code.o
gcc code.o -o code
如果什么都没有修改,再次执行 make,通常会看到类似下面的提示:
text
make: 'code' is up to date.
这是因为 make 会结合文件是否存在以及修改时间判断目标是否需要更新:
- 目标文件不存在,需要执行对应命令;
- 某个依赖文件的修改时间晚于目标文件,需要重新生成目标;
- 目标存在,并且不早于它的所有普通依赖,就认为目标是最新的。
可以使用 stat 查看文件时间:
bash
stat code.c
stat code
其中 Modify 表示文件内容的修改时间,Change 表示文件状态信息的变更时间,并不是创建时间;经典的 make 增量构建主要比较的是文件的 Modify 时间,也就是常说的 mtime。它通常不会逐字比较两个文件的内容。
例如使用 touch 更新源文件的修改时间:
bash
touch code.c
make
这时 code.c 比原来的中间文件更新,受影响的规则就会重新执行。多文件工程中也是同样的思路:只重新编译缺失或者过期的部分,再完成必要的链接,这就是增量构建。
2.2clean与伪目标
构建过程中会生成 .i、.s、.o 和可执行文件,有时我们希望把这些文件清理掉,再从头构建,可以准备一个 clean 规则:
makefile
.PHONY: clean
clean:
rm -f *.i *.s *.o code

clean 只是一个规则名,并不准备生成一个真正叫做 clean 的文件,所以可以使用:
makefile
.PHONY: clean
把它声明成伪目标 。这样即使当前目录中碰巧存在一个名为 clean 的真实文件,make clean 也不会因为时间判断而跳过清理命令。
这里有两个容易混淆的地方:
- 伪目标会忽略同名文件带来的更新时间判断,但并不代表每次执行
make都会自动运行; clean不是默认目标,也没有被默认目标依赖,因此只有执行make clean,或者其他目标显式依赖它时,才会运行对应命令。
清理命令中的通配符会删除当前目录中所有匹配文件,实际使用时应该确认自己位于正确的项目目录。后面的多文件写法会直接删除记录在变量中的目标文件,范围会更明确。
2.3变量、自动变量与模式规则
如果文件名和编译选项在多处重复出现,可以先定义变量,再使用 $(变量名) 进行引用:
makefile
BIN = code
CC = gcc
SRC = code.c
OBJ = code.o
CFLAGS = -Wall -O0
RM = rm -f
$(BIN): $(OBJ)
$(CC) $^ -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
.PHONY: clean
clean:
$(RM) $(OBJ) $(BIN)
这里的 BIN、CC、SRC 等都是普通变量,它们的作用如下:
| 变量 | 保存的内容 |
|---|---|
BIN |
最终可执行文件名 |
CC |
使用的编译器 |
SRC |
源文件 |
OBJ |
目标文件 |
CFLAGS |
编译阶段使用的选项 |
RM |
清理文件时使用的命令 |
规则中的 $@、$^ 和 $< 叫做自动变量,它们会随着当前正在执行的规则自动变化:
| 自动变量 | 含义 |
|---|---|
$@ |
当前规则的目标文件名 |
$^ |
当前规则的所有普通依赖文件名,重复项会被去掉 |
$< |
当前规则中的第一个依赖文件名 |
例如:
makefile
$(BIN): $(OBJ)
$(CC) $^ -o $@
在当前例子中会展开成:
bash
gcc code.o -o code
而下面这一条是模式规则:
makefile
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
它描述的是"怎样由一个同名的 .c 文件生成 .o 文件"。当某个 .o 目标真正被依赖时,make 才会尝试使用这条通用规则,并不会因为写了 %.o: %.c 就主动把目录中的全部文件依次编译。

在当前目录中由同名 .c 生成 .o 时,只写 gcc -c code.c 也会默认得到 code.o;不过把 -o $@ 显式写出来,可以让目标文件名更加清楚,也能让规则更完整。
还可能在命令开头看到单独的 @:
makefile
@$(CC) $(CFLAGS) -c $< -o $@
这个 @ 与自动变量 $@ 不是一回事。命令开头的 @ 表示执行时不回显这条命令本身,但编译器产生的警告、错误以及程序主动输出的内容仍然会显示。
2.4多文件的简单处理
如果当前目录中有多个 .c 文件,继续手动填写 SRC 和 OBJ 也比较麻烦,可以使用 wildcard 自动获取源文件,再通过替换引用得到对应的目标文件:
makefile
BIN = app
CC = gcc
SRC = $(wildcard *.c)
OBJ = $(SRC:.c=.o)
CFLAGS = -Wall -O0
RM = rm -f
$(BIN): $(OBJ)
@$(CC) $^ -o $@
@echo "linking ... $^ to $@"
%.o: %.c
@$(CC) $(CFLAGS) -c $< -o $@
@echo "compiling ... $< to $@"
.PHONY: clean
clean:
$(RM) $(OBJ) $(BIN)

其中:
makefile
SRC = $(wildcard *.c)
会获取当前目录中已经存在的所有 .c 文件;
makefile
OBJ = $(SRC:.c=.o)
会把 SRC 中每个文件名末尾的 .c 替换成 .o。例如:
text
SRC = main.c add.c sub.c
OBJ = main.o add.o sub.o
最终目标 app 依赖这些 .o 文件,模式规则再负责说明每个 .o 应该怎样由对应的 .c 生成。第一次执行 make 时,各源文件会依次编译并完成链接;以后只修改 add.c,通常只需要重新生成 add.o,然后重新链接 app,其他没有变化的源文件不必重复编译。
这里使用的是 Make 自带的 wildcard,比通过 $(shell ls *.c) 调用外部命令更加直接。刚开始能够看懂变量、自动变量、模式规则以及这份多文件 Makefile 就够用了,更复杂的条件判断和函数可以在真正遇到对应需求时再继续补充。
3.回车、换行与行缓冲区
3.1回车与换行
平时经常把"回车"和"换行"放在一起说,但它们最初表示的是两个不同动作:
| 转义字符 | 名称 | 作用 |
|---|---|---|
\r |
回车,Carriage Return | 把光标移动到当前行开头 |
\n |
换行,Line Feed | 移动到下一行 |
在 Linux 文本文件中,一行通常使用 LF 结束,也就是 C 语言字符串中的 \n。Windows 文本文件常见的行结束方式则是 CRLF,也就是 \r\n。大多数时候直接使用 \n 输出换行即可,标准库会按照当前环境处理文本输出。
3.2stdout与行缓冲
程序调用 printf 时,数据不一定会立刻写到终端或文件。标准库通常会先把数据放入缓冲区,等满足一定条件后再集中写出,这样可以减少频繁的输入输出操作。
在 Linux 中,当 stdout 直接连接到终端时通常采用行缓冲。例如:
c
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello Linux!\n");
sleep(3);
return 0;
}
字符串中带有 \n,在终端行缓冲场景下通常会触发刷新,所以 hello Linux! 会先显示,随后程序再等待三秒。
如果去掉换行:
c
printf("hello Linux!");
sleep(3);
这部分内容可能先留在 stdout 的缓冲区中,等程序正常结束时才显示出来。想让它立即可见,可以显式刷新:
c
printf("hello Linux!");
fflush(stdout);
sleep(3);

需要注意,\n 并不等价于任何情况下都执行一次 fflush(stdout)。当标准输出被重定向到普通文件或者管道时:
bash
./test > log.txt
stdout 通常会从行缓冲变成全缓冲,此时即使输出中出现换行,也不一定马上写入文件。缓冲区写满、显式调用 fflush(stdout)、关闭流或者程序正常结束,都可能让数据真正写出。
所以观察输出是否立即出现时,除了看字符串中有没有换行,还要考虑输出连接的是终端、文件还是管道。刚开始先记住:终端上的 stdout 通常行缓冲,需要立即显示时可以主动调用 fflush(stdout)。
把这些内容放在一起以后,make 的作用就比较清楚了:它根据 Makefile 中的依赖关系和文件修改时间,只执行当前真正需要的构建命令;变量、自动变量和模式规则则让同一套规则能够适应更多源文件。理解这些基础写法以后,再面对由多个文件组成的小项目,就不需要每次手动输入一长串编译命令了。
完