【Linux】make/Makefile 自动化工具的介绍

前言:一个源文件比较少的程序,直接使用一条 gcc 命令就能完成编译;但是随着源文件越来越多,每次都手动判断哪些文件需要重新编译、这些文件又应该按照什么顺序处理,就会变得非常麻烦。makeMakefile 正是用来解决这类问题的,本篇主要介绍它们的基本使用、依赖关系、增量构建以及多文件项目中的常见写法,最后再简单补充一下回车、换行和行缓冲区。


1.make与Makefile的简单认识

1.1为什么需要make

假设一个程序只有一个 code.c 文件,直接使用下面的命令就能生成可执行文件:

bash 复制代码
gcc code.c -o code

如果程序由多个源文件组成,我们通常会先把每个 .c 文件编译成对应的 .o 文件,再把这些目标文件链接起来。文件数量少时还可以手动完成,但工程变大以后就会出现几个问题:

  • 编译命令越来越多,每次完整输入比较麻烦;
  • 文件之间存在依赖关系,处理顺序不能随意打乱;
  • 只修改了一个源文件时,没有必要把所有文件全部重新编译;
  • 不同开发者手动输入的命令可能不一致,容易出现遗漏。

Makefile 可以把目标、依赖关系和构建命令统一记录下来,make 则负责读取这些规则,判断当前需要执行哪些命令。

这里需要注意:**make本身不是编译器。**真正完成预处理、编译、汇编和链接的仍然是 gccg++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 通常会寻找 Makefilemakefile,在没有显式指定目标时,把其中第一个普通目标作为默认目标。因此这里会执行 code 对应的命令:

text 复制代码
gcc -o code code.c

生成以后就可以运行:

bash 复制代码
./code

输出结果为:

text 复制代码
hello Makefile!

除了直接执行 make,也可以在后面写出想要构建的目标:

bash 复制代码
make code
make clean

make code 明确要求生成 codemake 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 会先从目标开始逐层查找依赖:

  1. code 依赖 code.o
  2. code.o 依赖 code.s
  3. code.s 依赖 code.i
  4. 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)

这里的 BINCCSRC 等都是普通变量,它们的作用如下:

变量 保存的内容
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 文件,继续手动填写 SRCOBJ 也比较麻烦,可以使用 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 中的依赖关系和文件修改时间,只执行当前真正需要的构建命令;变量、自动变量和模式规则则让同一套规则能够适应更多源文件。理解这些基础写法以后,再面对由多个文件组成的小项目,就不需要每次手动输入一长串编译命令了。


相关推荐
朱容zr3331331 小时前
为什么推荐使用自增主键?使用UUID作为主键的优缺点是什么?
java·运维·数据库·后端·mysql·面试·性能优化
HXDGCL1 小时前
东莞市华创力科技:专业环形导轨工厂,助力自动化产线升级
运维·科技·自动化
funnycoffee1231 小时前
Cisco ip access-list序号重排方法
linux·网络·tcp/ip
殷忆枫2 小时前
Linux 4G模块驱动适配实战:从手动绑定到自动识别
linux·运维·服务器
☆凡尘清心☆2 小时前
CentOS Stream 9 编译安装 Redis 7.2.7 完整版详细步骤
linux·redis·centos
wuminyu2 小时前
JUC组件逐层剥离与深度剖析
java·linux·c语言·jvm·c++·算法
大黄说说2 小时前
EF Core 避坑指南:查询慢、循环查询、并发更新问题如何解决
java·服务器·数据库
ocean'2 小时前
防火墙策略路由
linux·服务器·数据库
三言老师3 小时前
awk条件筛选日志内容实操
linux·运维·服务器·centos