前言

C语言程序从代码到可执行文件(*.exe)需要经过预处理、编译、汇编和链接这几个步骤。每当修改源文件(*.c)或源文件所包含的头文件(*.h)后,我们都需要重新执行上述几个步骤,以得到修改后的程序。

通常将预处理、编译和汇编这三个步骤统称为编译。

一个项目通常有多个源文件,如果只修改其中一个,就对所有源文件重新执行编译、链接步骤,就太浪费时间了。因此十分有必要引入 Makefile 工具:Makefile 工具可以根据文件依赖,自动找出那些需要重新编译和链接的源文件,并对它们执行相应的动作。


编译链接过程

开始前的准备

本文章目的在于帮助你理解和掌握Makefile的编写方法和编写技巧,在开始阅读和动手编写Makefile前,你可能需要准备一下环境。

本篇文章的示例运行在wsl2上(Windows Subsystem for Linux 2),我的系统信息如下:

1
2
3
4
5
6
gee@JiPing_Desktop:~/workspace/test$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 22.04.1 LTS
Release: 22.04
Codename: jammy

可以看到,我使用Ubuntu系统,且系统发行版本是22.04。如果你是Windows系统,则可以在启用或关闭 Windows 功能中点击开启适用于 Linux 的 Windows 子系统,并在微软商店中下载和安装Ubuntu系统,以获得与我一致的代码编写环境。具体步骤可以参考:安装 WSL

相比 vim 如果你更熟悉 VSCode 的操作,则可以参考:开始通过 WSL 使用 VS Code 来搭建自己熟悉的代码编写环境。

如果你在阅读或实践过程中遇到任何问题,欢迎在评论区中留下你的疑问,我们会尽力尝试解答。

从代码编译开始

在开始编写 Makefile 前,我们先写一段简单的代码,并尝试使用编译工具链将代码变为可执行文件。

编写简单的代码

1
2
3
4
5
6
7
8
/* main.c */
#include <stdio.h>

int main(void)
{
printf("Hello from main!\n");
return 0;
}

编译得到可执行文件

编辑完文件后,回到终端,使用编译工具链将代码变为可执行文件:

如果你在执行 gcc main.c -o main 时遇到问题,很有可能是没有安装 gcc 导致的,在终端中输入 sudo apt-get install build-essential 以安装所需的编译工具。

1
2
3
4
5
6
gee@JiPing_Desktop:~/workspace/example$ vim main.c
gee@JiPing_Desktop:~/workspace/example$ gcc main.c -o main
gee@JiPing_Desktop:~/workspace/example$ ls
main main.c
gee@JiPing_Desktop:~/workspace/example$ ./main
Hello from main!

可以看到,我们顺利得到了可执行文件,并且执行结果也符合预期。

上面所执行的几条命令中,gcc main.c -o main 这条命令负责调用编译工具链,将源文件 main.c 编译、链接为可执行文件 main。这里的GCC(GNU Compiler Collection)就是上文中提及的编译工具链,它是预处理、编译、汇编、链接所使用到的各种工具的集合,它们彼此搭配协作,才最终得到我们所需的可执行文件。


编写简单的代码并得到可执行文件

你可能会好奇gcc 命令中的 -o 选项的作用,它是用来指定输出文件的命名的,随后紧跟的参数就是所要指定的命名,在上面的示例中,我们将输出文件的命名指定为了 main

动手写简单的Makefile

现在我们已经掌握了将代码编译、链接为可执行文件的方法,是时候开始写最简单的Makefile文件了:

编写Makefile并执行make

1
2
3
# Makefile
main : main.c
gcc main.c -o main

编写好后回到终端,使用 make 来执行Makefile:

1
2
3
gee@JiPing_Desktop:~/workspace/example$ vim Makefile
gee@JiPing_Desktop:~/workspace/example$ make
make: 'main' is up to date.

可以看到 Makefile 给出了它的处理结果 make: 'main' is up to date.,意思是 main 已经是最新的了,无需执行任何操作。此时我们的 main.c 没有做任何修改,也就是说即使重新编译、链接得到一个新的 main,它与旧的 main 也不会存在任何的不同,所以Makefile没有执行任何的步骤。

尝试修改 main.c 再执行 make,看看这次的结果会怎样:

1
2
3
4
5
6
7
8
/* main.c */
#include <stdio.h>

int main(void)
{
printf("Hello from new main!\n"); // <- 多加了一个new
return 0;
}

回到终端执行 make

1
2
3
4
5
gee@JiPing_Desktop:~/workspace/example$ vim main.c
gee@JiPing_Desktop:~/workspace/example$ make
gcc main.c -o main
gee@JiPing_Desktop:~/workspace/example$ ./main
Hello from new main!

可以看到,在修改了 main.c 后重新执行 make,Makefile会自动地执行 gcc main.c -o main,以得到新的可执行文件 main。从结果来看,代码中的修改确实反应到了可执行文件上。

Makefile三要素

那么问题就来了,Makefile中的两行语句分别是什么意思呢?拆解来看,两行语句可以分为三部分,分别是目标(target)、依赖(prerequisite)和执行语句(recipe):

延伸思考:目标、依赖和执行语句,三者在Makefile中是否缺一不可?在不修改源文件的前提下尝试修改目标,再执行make时会得到怎样的结果?


简单Makefile语句解析

上面的例子中,可执行文件 main 就是我们想要得到的目标,而 main 的生成依赖于 main.c,所以将 main.c 填写在依赖的位置。在发现目标文件不存在,或依赖的文件有所修改后,Makefile 就会执行下方的执行语句,其任务通常是生成目标文件。

延伸阅读
http://gnu.org上关于三要素的描述如下:
A target is usually the name of a file that is generated by a program; examples of targets are executable or object files. A target can also be the name of an action to carry out, such as ‘clean’ (see Phony Targets).
A prerequisite is a file that is used as input to create the target. A target often depends on several files.
A recipe is an action that make carries out. A recipe may have more than one command, either on the same line or each on its own line. Please note: you need to put a tab character at the beginning of every recipe line!

当增加源文件和修改源文件名称

回看已经写好的Makefile,会发现其中的内容都是有具体指向的:mainmain.c。试想这样一个场景:我们在文件夹中添加新的源文件 bar.c,并将 main.c 重命名为 entry.c,这时再执行 make 会得到怎样的结果呢?

思考题:在函数 Print_Progress_Bar 中,数组 bar 的定义和赋值能否由 char bar[] = PROGRESS_BAR 改为 char *bar = PROGRESS_BAR。为什么?两者有什么不同?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* bar.c */
#include <stdio.h>

#define PROGRESS_BAR "*************************"

// 输入参数
// comp: 完成比例(0% ~ 100%)
void Print_Progress_Bar(float comp)
{
char bar[] = PROGRESS_BAR;
int len_bar = sizeof(PROGRESS_BAR) - 1;
comp = (comp > 1.0f) ? 1.0f : comp;
comp = (comp < 0.0f) ? 0.0f : comp;

// 绘制进度条
int end = comp * len_bar;
for (int i = end; i < len_bar; i++)
{
bar[i] = ' ';
}
printf("|%s|\n", bar);
}
#include <stdio.h>

void Print_Progress_Bar(float comp);

int main(void)
{
printf("Hello from new main!\n");
Print_Progress_Bar(33.0f/100.0f);
return 0;
}

修改完成后在终端执行 make,结果如下:

1
2
3
4
5
gee@JiPing_Desktop:~/workspace/example$ vim bar.c
gee@JiPing_Desktop:~/workspace/example$ mv main.c entry.c
gee@JiPing_Desktop:~/workspace/example$ vim entry.c
gee@JiPing_Desktop:~/workspace/example$ make
make: *** No rule to make target 'main.c', needed by 'main'. Stop.

可以看到,make 提示“No rule to make target ‘main.c’, needed by ‘main’.”,并停止了执行。从提示中我们大致可以猜到,由于找不到依赖文件 main.cmake 停止了执行。解决问题的方法有两种,简单粗暴的做法是:直接根据新的文件命名修改 Makefile 文件:

1
2
3
# Makefile
main : entry.c bar.c
gcc entry.c bar.c -o main

由于主函数调用了 bar.c 中定义的函数,所以在编译时我们需要将 bar.c 一起编译、链接到可执行文件里,同时别忘了把它加进依赖中。修改好后回到终端重新执行 make

1
2
3
4
5
6
gee@JiPing_Desktop:~/workspace/example$ vim Makefile
gee@JiPing_Desktop:~/workspace/example$ make
gcc entry.c bar.c -o main
gee@JiPing_Desktop:~/workspace/example$ ./main
Hello from new main!
|******** |

这一次 make 命令没有再报错。

想象一下,如果我们保持当前的 Makefile 写法,那么之后每次添加源文件,或者修改源文件名称时,都需要我们重新修改 Makefile 文件。当文件数量爆炸多的时候,这样的手动调整显然是十分麻烦的。所以我们迫切需要一种更为通用的写法,来免除这些“痛苦”。

变量和通配符和wildcard函数

仔细观察源文件的命名 main.cbar.c,我们会发现它们有着共同的模式(或称为规律):都以 .c 结尾,这意味着可以用这种模式匹配所有源文件。在 Makefile 中我们可以使用 wildcard 函数(wildcard function)来达到这一目的。

使用wildcard函数

在 Makefile 中,$(function arguments) 的写法用于函数调用, wildcard 函数的使用方法如下:

1
$(wildcard pattern…)

如果我们想匹配当前目录下的所有源文件,就可以这样写:$(wildcard *.c),其中通配符 * 用于匹配任意长度的任何字符,可以是 mainbar,也可以是其他任何你能想得到的字符组合,后面加上 .c 则是要求匹配的字符组合必须以 .c 结尾。

当前示例下,$(wildcard *.c) 展开后得到的结果就是: bar.c entry.c,所以我们的 Makefile 文件可以修改为:

1
2
3
# Makefile
main : $(wildcard *.c)
gcc $(wildcard *.c) -o main

修改后保存,再重新执行 make,得到的结果与之前一致:(这里我将进度条从进度33%改为了52%,以确保 make 执行编译命令)

1
2
3
4
5
6
7
gee@JiPing_Desktop:~/workspace/example$ vim Makefile
gee@JiPing_Desktop:~/workspace/example$ vim entry.c
gee@JiPing_Desktop:~/workspace/example$ make
gcc bar.c entry.c -o main
gee@JiPing_Desktop:~/workspace/example$ ./main
Hello from new main!
|************* |

利用变量

上面的 Makefile 还可以再优化一下可读性和效率,我们可以利用变量保存 wildcard 函数展开后的结果。Makefile 中变量定义的形式与C语言类似:var := value,调用则和函数调用类似:$(var),所以 Makefile 可以进一步修改为:

1
2
3
4
5
# Makefile
SRCS := $(wildcard *.c)

main : $(SRCS)
gcc $(SRCS) -o main

相比上面的 Makefile,进一步修改后的 Makefile 减少了一次函数调用,并且增加了可读性。

变量的赋值和修改

我们在刚才的示例中使用到了赋值符号 := ,该符号与C语言中的赋值符号 = 作用效果相同。以下是几个常用符号的简介:

1
2
3
4
5
6
foo = $(bar)
bar = $(ugh)
ugh = Huh?

all:;echo $(foo)
# 打印的结果为 Huh?,$(foo)展开得到$(bar),$(bar)展开得到$(ugh),$(ugh)展开得到Huh?最终$(foo)展开得到Huh?
1
2
3
4
5
6
x := foo
y := $(x) bar
x := later
# 等效于:
# y := foo bar
# x := later
  • += :文本增添(Appending),用于向已经定义的变量添加文本:
1
2
3
objects = main.o foo.o bar.o utils.o
objects += another.o
# objects最终为main.o foo.o bar.o utils.o another.o
1
2
3
4
5
FOO ?= bar
# FOO最终为bar
foo := ugh
foo ?= Huh?
# foo最终为ugh

动手写进阶的Makefile

到目前为止,我们已经写出一个简单能用的Makefile了,它能应对不太复杂的场景,在没有多级目录的情况下已经足够使用。但我们实际面对的场景往往要复杂得多:源文件和头文件按照功能或层级区分,散落在一个个子文件夹下,这样做更容易管理工程文件,但也带来了两点小麻烦。

先让我们先改造一下当前的目录结构,使其更贴合实际应用场景:

tree 命令的作用是以树的形式展现目录结构,你可能无法直接使用该命令,尝试 sudo apt install tree 以安装和使用 tree 命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
gee@JiPing_Desktop:~/workspace/example$ tree
.
├── Makefile
├── bar.c
├── entry.c
└── main

gee@JiPing_Desktop:~/workspace/example$ mkdir ./func
gee@JiPing_Desktop:~/workspace/example$ mv ./bar.c ./func/
gee@JiPing_Desktop:~/workspace/example$ vim ./func/bar.h
gee@JiPing_Desktop:~/workspace/example$ vim ./entry.c
gee@JiPing_Desktop:~/workspace/example$ tree
.
├── Makefile
├── entry.c
├── func
│ ├── bar.c
│ └── bar.h
└── main

这里我新建了目录 func,并将 bar.c 转移到了 func 目录下,同时在 func 目录下创建了头文件 bar.h。然后在 entry.c 中将手动声明函数改为了头文件包含:

1
2
3
4
5
6
7
8
9
10
11
12
13
// bar.h
// 函数声明
void Print_Progress_Bar(float comp);
// entry.c
#include <stdio.h>
#include <bar.h>

int main(void)
{
printf("Hello from new main!\n");
Print_Progress_Bar(52.0f/100.0f);
return 0;
}

现在再让我们尝试执行 make,看看会发生什么:

1
2
3
4
5
6
7
gee@JiPing_Desktop:~/workspace/example$ make
gcc entry.c -o main
entry.c:2:10: fatal error: bar.h: No such file or directory
2 | #include <bar.h>
| ^~~~~~~
compilation terminated.
make: *** [Makefile:8: main] Error 1

首先出现的问题是编译 entry.c 时提示找不到 bar.h 的头文件,这是编译时没有指定到哪些路径下寻找头文件导致的,解决办法是执行 gcc 命令时通过 -I 选项指定头文件所在路径:

1
2
3
4
5
6
# Makefile
INCS := -I./func
SRCS := $(wildcard *.c)

main : $(SRCS)
gcc $(INCS) $(SRCS) -o main

再来执行make:

1
2
3
4
5
6
7
gee@JiPing_Desktop:~/workspace/example$ vim Makefile
gee@JiPing_Desktop:~/workspace/example$ make
gcc -I./func entry.c -o main <- 缺少bar.c
/usr/bin/ld: /tmp/ccaX7UmM.o: in function `main':
entry.c:(.text+0x22): undefined reference to `Print_Progress_Bar'
collect2: error: ld returned 1 exit status
make: *** [Makefile:9: main] Error 1

我们察觉到执行 make 时又发生了错误,提示主函数中调用了未定义的函数 Print_Progress_Bar,这个函数定义在 bar.c 中。仔细观察可以发现 gcc 的调用中缺少 bar.c,这就引发了我们遇到的问题。显然在 bar.c 装进 ./func 目录后,Makefile 就找不到 bar.c 文件了,这就是我们在刚才提到的小麻烦。

应对复杂的目录结构

首先还是让我们来看一下 make 的报错问题如何解决。思路和方法很简单,使用 wildcard 函数在 ./func 目录下也匹配一遍源文件,再把这些源文件一同添加到 SRCS 变量中就可以了:

1
2
3
4
5
6
7
# Makefile
INCS := -I./func
SRCS := $(wildcard *.c)
SRCS += $(wildcard ./func/*.c)

main : $(SRCS)
gcc $(INCS) $(SRCS) -o main

尝试执行下:

1
2
3
gee@JiPing_Desktop:~/workspace/example$ vim Makefile
gee@JiPing_Desktop:~/workspace/example$ make
gcc -I./func entry.c ./func/bar.c -o main

可以看到问题得到了解决。但这样的方案还是存在缺点的,它不够通用和直观,从中我们很难看出哪些路径得到了使用。或许还有什么办法能将 Makefile 写得更清晰一些。

如果你曾使用过一些 IDE,那你可能会对配置路径感到熟悉,这要求你将一些文件目录添加到工程文件配置中去。我们也可以效仿这样的做法,手动将目录添加到 Makefile 中去。

1
2
3
# Makefile
SUBDIR := .
SUBDIR += ./func

这里定义了变量 SUBDIR,我们将使用它来指定那些存放着源文件和头文件的目录。接下来我们将请出另一个功能强大的函数 foreach 来帮助我们完成一项复杂的功能。

1
$(foreach var,list,text)

foreach(for each)函数的功能与 Python 和C语言中的 for 循环类似,但会更接近 Python 的 for 循环。它的功能描述起来就是:从 list 中逐个取出元素,赋值给 var,然后再展开 text。下面是一个使用示例。

1
2
3
4
5
SUBDIR := .
SUBDIR += ./func

EXPANDED := $(foreach dir,$(SUBDIR),$(dir)/*.c)
# 等效于EXPANDED := ./*.c ./func/*.c

foreach函数

有了 foreach 函数,我们就能配合 wildcard 函数,通过指定路径来获取源文件,并指定头文件所在路径:

1
2
3
4
5
6
7
8
9
# Makefile
SUBDIR := .
SUBDIR += ./func

INCS := $(foreach dir,$(SUBDIR),-I$(dir))
SRCS := $(foreach dir,$(SUBDIR),$(wildcard $(dir)/*.c))

main : $(SRCS)
gcc $(INCS) $(SRCS) -o main

在终端里试试效果(可以使用 rm ./main 移除可执行文件,来确保 make 会执行编译命令):

1
2
3
4
gee@JiPing_Desktop:~/workspace/example$ vim ./Makefile
gee@JiPing_Desktop:~/workspace/example$ rm ./main
gee@JiPing_Desktop:~/workspace/example$ make
gcc -I. -I./func ./entry.c ./func/bar.c -o main

它可以正常工作,且效果与之前是一致的。现在来看,指定路径的做法较之前并没有太大的优势,我们要做的仍是手动指定目录,只是将获取源文件的任务交给了 foreach 函数来完成。在后面,我们会继续深入了解 Makefile,到时指定路径的优势会逐渐显现。

分析编译过程

到目前为止,我们的示例程序还保持着较短的编译、链接时间。但当源文件逐渐增多后,只改动其中一个源文件,我们还能在短时间内获得可执行文件吗?为了解答这个问题,我们先来回顾一下编译、链接的过程。

源文件和头文件需要经过四个步骤才能得到可执行文件,分别是预处理、编译、汇编和链接。

  • 预处理:预处理器将以字符 # 开头的命令展开、插入到原始的C程序中。比如我们在源文件中能经常看到的、用于头文件包含的 #include 命令,它的功能就是告诉预编译器,将指定头文件的内容插入的程序文本中。

预处理演示
  • 编译阶段:编译器将文本文件 *.i 翻译成文本文件 *.s,它包含一个汇编语言程序。
  • 汇编阶段:汇编器将 *.s 翻译成机器语言指令,把这些指令打包成可重定位目标程序(relocatable object program)的格式,并保存在 *.o 文件中。
  • 链接阶段:在 bar.c 中我们定义了 Print_Progress_Bar 函数,该函数会保存在目标文件 bar.o 中。直到链接阶段,链接器才以某种方式将 Print_Progress_Bar 函数合并到 main 函数中去。在链接时如果没有指定 bar.o,链接器就无法找到 Print_Progress_Bar 函数,也就会提示找不到相关函数的定义。

保存 *.o 文件

从编译过程的分析中,我们能找到当前 Makefile 存在的两点问题:

  1. 没有保存 .o 文件,这导致我们每次文件变动都要重新执行预处理、编译和汇编来得到目标文件,即使新得到的文件与旧文件完全没有差别(即编译用到的源文件没有任何变化,就跟bar.c 一样)。
  2. 有保存 .o 文件,则会遇到第二个问题,即依赖中没有指定头文件,这意味着只修改头文件的情况下,源文件不会重新编译得到新的可执行文件!

一开始的编译过程

为了证明以上两个问题,我们对 Makefile 做一些改动:

1
2
3
4
5
6
7
8
9
10
INCS := -I. -I./func

main : ./entry.o ./func/bar.o
gcc ./entry.o ./func/bar.o -o main

./entry.o : ./entry.c
gcc -c $(INCS) ./entry.c -o ./entry.o

./func/bar.o : ./func/bar.c
gcc -c $(INCS) ./func/bar.c -o ./func/bar.o

gcc 命令指定 -c 选项后,会只执行编译步骤,而不执行链接步骤,最后得到 *.o 文件。这里我们添加新的目标和依赖,目的是编译得到 main.o bar.o,最后再手动将它们链接为可执行文件 main。值得一提的是 Makefile 文件会自动匹配依赖和目标,如果依赖的依赖有更新,则目标文件也会得到更新。

现在让我们看看 make 执行的效果:

1
2
3
4
5
6
gee@JiPing_Desktop:~/workspace/example$ vim Makefile
gee@JiPing_Desktop:~/workspace/example$ rm main
gee@JiPing_Desktop:~/workspace/example$ make
gcc -c -I. -I./func ./entry.c -o ./entry.o
gcc -c -I. -I./func ./func/bar.c -o ./func/bar.o
gcc ./entry.o ./func/bar.o -o main

make 执行了我们指定的每一个步骤。现在让我们修改 entry.c,手动删除 bar.o 后再执行 make。(模拟不保存 *.o 文件的情况)

1
2
3
4
5
6
7
8
9
10
// main.c
#include <stdio.h>
#include <bar.h>

int main(void)
{
printf("Happy Birth Day!\n");
Print_Progress_Bar(33.0f/100.0f);
return 0;
}

试验下执行 make 的效果:

1
2
3
4
5
6
7
8
9
gee@JiPing_Desktop:~/workspace/example$ vim entry.c
gee@JiPing_Desktop:~/workspace/example$ rm ./func/bar.o <- 删除bar.o
gee@JiPing_Desktop:~/workspace/example$ make
gcc -c -I. -I./func ./entry.c -o ./entry.o <- 重新编译entry.o
gcc -c -I. -I./func ./func/bar.c -o ./func/bar.o <- 重新编译bar.o
gcc ./entry.o ./func/bar.o -o main
gee@JiPing_Desktop:~/workspace/example$ ./main
Happy Birth Day!
|******** |

我们不仅重新编译了 entry.o,还重新编译了 bar.o,现在再试试保存 bar.o 的情况下执行 make

1
2
3
4
5
6
7
gee@JiPing_Desktop:~/workspace/example$ vim entry.c
gee@JiPing_Desktop:~/workspace/example$ make
gcc -c -I. -I./func ./entry.c -o ./entry.o <- 仅重新编译entry.o
gcc ./entry.o ./func/bar.o -o main
gee@JiPing_Desktop:~/workspace/example$ ./main
保持开心!
|******** |

可以发现,相较于不保存 bar.o 的情况,我们少执行了 bar.o 的编译步骤,这对于工程文件编译速度的提升,可能是巨大的!

现在再让我们尝试修改 bar.h

1
2
3
4
5
6
7
// bar.h
// 注:#ifndef配合#define用于避免源文件重复包含同一头文件的内容
#ifndef _BAR_H
#define _BAR_H
// 函数声明
void Print_Progress_Bar(float comp);
#endif

执行 make

1
2
3
gee@JiPing_Desktop:~/workspace/example$ vim ./func/bar.h
gee@JiPing_Desktop:~/workspace/example$ make
make: 'main' is up to date.

不出所料,源文件果然没有重新编译。

模式规则和自动变量

我们还是先来解决问题,首先是 *.o 文件的保存问题,这个问题其实在上面已经解决了,我们再来看一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SUBDIR := .
SUBDIR += ./func

INCS := $(foreach dir,$(SUBDIR),-I$(dir))
SRCS := $(foreach dir,$(SUBDIR),$(wildcard $(dir)/*.c))

main : ./entry.o ./func/bar.o
gcc ./entry.o ./func/bar.o -o main

./entry.o : ./entry.c
gcc -c $(INCS) ./entry.c -o ./entry.o

./func/bar.o : ./func/bar.c
gcc -c $(INCS) ./func/bar.c -o ./func/bar.o

通过手动添加目标和依赖,我们实现了 *.o 文件的保存,同时还确保了源文件在更新后,只会在最小限度内重新编译 *.o 文件。现在我们可以利用符号 % 和自动变量,来让 Makefile 变得更加通用。首先聚焦于编译过程:

1
2
3
4
5
./entry.o : ./entry.c
gcc -c $(INCS) ./entry.c -o ./entry.o

./func/bar.o : ./func/bar.c
gcc -c $(INCS) ./func/bar.c -o ./func/bar.o

上下比较 ./entry.o./func/bar.o 的目标依赖及执行,可以发现新添加的、用于生成 *.o 文件的目标和依赖,有着相同的书写模式,这意味着存在通用的写法:

1
2
%.o : %.c
gcc -c $(INCS) $< -o $@

Makefile编译的通用写法

这里我们用上了 % ,它的作用有些难以用语言概括,上述例子中, %.o 的作用是匹配所有以 .o 结尾的目标;而后面的 %.c% 的作用,则是将 %.o% 的内容原封不动的挪过来用。

更具体地例子是,%.o 可能匹配到目标 ./entry.o./func/bar.o,这样 % 的内容就会是 ./entry./func/bar,最后交给 %.c 时就变成了 ./entry.c./func/bar.c

另外我们还使用到了自动变量 $< $@,其中 $< 指代依赖列表中的第一个依赖;而 $@ 指代目标。注意自动变量与普通变量不同,它不使用小括号。

结合起来使用,我们就得到了通用的生成 *.o 文件的写法:

1
2
3
4
5
6
7
8
9
10
11
12
# Makefile
SUBDIR := .
SUBDIR += ./func

INCS := $(foreach dir,$(SUBDIR),-I$(dir))
SRCS := $(foreach dir,$(SUBDIR),$(wildcard $(dir)/*.c))

main : ./entry.o ./func/bar.o
gcc ./entry.o ./func/bar.o -o main

%.o : %.c
gcc -c $(INCS) $< -o $@

patsubst 函数

接下来再让我们关注链接步骤,它需要指定 *.o 文件:

1
2
main : ./entry.o ./func/bar.o
gcc ./entry.o ./func/bar.o -o main

这看起来十分眼熟,我们最初解决多文件编译问题时也是采用类似的写法,只有文件后缀不一样:

1
2
main : ./entry.c ./func/bar.c
gcc ./entry.c ./func/bar.c -o main

这给了我们一点提示,是不是能够通过 wildcard 函数来实现通用的写法?可惜的是,在最开始我们是无法匹配到 *.o 文件的,因为起初我们只有 *.c 文件, *.o 文件是后来生成的。但转换一下思路,我们在获取所有源文件后,直接将 .c 后缀替换为 .o,不就能得到所有的 .o 文件了吗?正巧 patsubst 函数可以用于模式文本替换。

1
$(patsubst pattern,replacement,text)

patsubst 函数的作用是匹配 text 文本中与 pattern 模式相同的部分,并将匹配内容替换为 replacement。于是链接步骤可以改写为:

1
2
3
4
5
SRCS := $(foreach dir,$(SUBDIR),$(wildcard $(dir)/*.c))
OBJS := $(patsubst %.c,%.o,$(SRCS))

main : $(OBJS)
gcc $(OBJS) -o main

Makefile中的patsubst函数

这里我们先用 wildcard 函数获取所有的 .c 文件,并将结果保存在 SRCS 中,接着利用 patsubst 函数替换 SRCS 的内容,最后将所有的 .c 替换为 .o 以获得执行编译所得到的目标文件。

于是我们的 Makefile 就可以改写为:

1
2
3
4
5
6
7
8
9
10
11
12
SUBDIR := .
SUBDIR += ./func

INCS := $(foreach dir,$(SUBDIR),-I$(dir))
SRCS := $(foreach dir,$(SUBDIR),$(wildcard $(dir)/*.c))
OBJS := $(patsubst %.c,%.o,$(SRCS))

main : $(OBJS)
gcc $(OBJS) -o main

%.o : %.c
gcc -c $(INCS) $< -o $@

试试效果:

1
2
3
4
5
6
7
8
9
10
gee@JiPing_Desktop:~/workspace/example$ rm main
gee@JiPing_Desktop:~/workspace/example$ rm ./func/bar.o
gee@JiPing_Desktop:~/workspace/example$ rm ./entry.o
gee@JiPing_Desktop:~/workspace/example$ make
gcc -c -I. -I./func entry.c -o entry.o
gcc -c -I. -I./func func/bar.c -o func/bar.o
gcc ./entry.o ./func/bar.o -o main
gee@JiPing_Desktop:~/workspace/example$ ./main
保持开心!
|************************ |

看起来没有太大问题(但仔细看还是会发现,编译时 ./ 被吃了)!至此我们解决了第一个问题,而第二个问题,我们留到后面再解决。现在先让我们看看 Makefile 还有哪些可以丰富和完善的功能。

丰富完善Makefile的功能

到目前为止,我们已经写出足够使用的 Makefile 文件了,接下来我们可以继续完善它的功能。

指定*.o文件的输出路径

细心的你可能会发现,目前编译得到的 .o 文件,都是放在与 .c 文件同一级目录下的,从代码编辑习惯考虑,这可能会导致我们无法方便地寻找源文件或头文件。

1
2
3
4
5
6
7
8
9
10
gee@JiPing_Desktop:~/workspace/example$ tree
.
├── Makefile
├── entry.c
├── entry.o
├── func
│ ├── bar.c
│ ├── bar.h
│ └── bar.o
└── main

理想的做法是将 *.o 文件保存至指定目录,与源文件和头文件区分开:

1
2
3
4
5
6
7
8
9
10
11
12
gee@JiPing_Desktop:~/workspace/example$ tree
.
├── Makefile
├── entry.c
├── func
│ ├── bar.c
│ └── bar.h
├── main
└── output
├── entry.o
└── func
└── bar.o

但应该如何实现呢?先来看一下单个 .o 文件怎么输出到指定文件夹:

1
2
3
./output/func/bar.o : ./func/bar.c
mkdir -p ./output/func
gcc -c $(INCS) ./func/bar.c -o ./output/func/bar.o

我们解决问题的思路是:把输出目录下的 .o 文件作为编译目标,原始目录下的 .c 文件作为依赖,来编译得到目标文件。这里我们需要解决两个问题:1. 如何得到 ./output/func/bar.o 这个路径;2. 如何保证 ./output/func 目录存在。

问题1我们可以在执行 patsubst 函数时解决,就像这样:

1
2
3
OUTPUT := ./output
SRCS := $(foreach dir,$(SUBDIR),$(wildcard $(dir)/*.c))
OBJS := $(patsubst %.c,$(OUTPUT)/%.o,$(SRCS))

在替换 .c 的同时,在内容头部添加输出路径 ./output/,这样 ./func/bar.c 就会替换成 ./output/func/bar.o

接着问题2我们可以使用 mkdir 命令配合 dir 函数解决,dir 函数可以从文本中获得路径,配合 mkdir -p 命令创建目录,可以确保输出路径存在:

1
mkdir -p $(dir ./output/func/bar.o)

dir 函数会把 ./output/func/bar.o 修改成 ./output/func,于是上面的命令就变为了:

1
mkdir -p ./func/func

通过修改 Makefile 来一起解决两个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SUBDIR := ./
SUBDIR += ./func

OUTPUT := ./output

INCS := $(foreach dir,$(SUBDIR),-I$(dir))
SRCS := $(foreach dir,$(SUBDIR),$(wildcard $(dir)/*.c))
OBJS := $(patsubst %.c,$(OUTPUT)/%.o,$(SRCS))

main : $(OBJS)
gcc $(OBJS) -o main

$(OUTPUT)/%.o : %.c
mkdir -p $(dir $@)
gcc -c $(INCS) $< -o $@

这里我们还在 %.o 前面加了 $(OUTPUT)/,确保匹配到的目标是要生成在输出目录的目标。

测试一下,是可以使用的:

1
2
3
4
5
6
7
8
gee@JiPing_Desktop:~/workspace/example$ rm entry.o
gee@JiPing_Desktop:~/workspace/example$ rm ./func/bar.o
gee@JiPing_Desktop:~/workspace/example$ make
mkdir -p output/.//
gcc -c -I./ -I./func entry.c -o output/.//entry.o
mkdir -p output/./func/
gcc -c -I./ -I./func func/bar.c -o output/./func/bar.o
gcc ./output/.//entry.o ./output/./func/bar.o -o main

伪目标

在 Makefile 中我们可以利用目标执行某些动作。比如定义一个 clean 目标,用于清理编译生成的过程文件:

1
2
3
OUTPUT := ./output
clean:
rm -r $(OUTPUT)

修改后执行 make 命令时传入参数 clean 就会执行 clean 目标下的语句:

1
2
3
gee@JiPing_Desktop:~/workspace/example$ vim ./Makefile
gee@JiPing_Desktop:~/workspace/example$ make clean
rm -r ./output

在没有解决头文件依赖问题时,clean 后重新编译,也是一种临时解决方案。

但这样做存在隐患:当前目录下有与目标同名的文件时,在没有依赖的情况下,Makefile 会认为目标文件已经是最新的状态了,目标下的语句也就不再执行。

1
2
3
4
5
6
7
gee@JiPing_Desktop:~/workspace/example$ tree
.
├── Makefile
├── output
└── clean
gee@JiPing_Desktop:~/workspace/example$ make clean
make: 'clean' is up to date.

为解决这一问题,我们可以将 clean 声明为伪目标,表明其并非是文件的命名。向特殊内置目标 .PHONY 添加 clean 依赖以达成这一目的:

1
2
3
4
5
.PHONY : clean

OUTPUT := ./output
clean:
rm -r $(OUTPUT)

添加上 .PHONY : clean 后再执行 make clean

1
2
3
4
5
6
gee@JiPing_Desktop:~/workspace/example$ make clean
rm -r ./output
gee@JiPing_Desktop:~/workspace/example$ tree
.
├── Makefile
└── clean

可以看到 clean 目标下的 rm -r $(OUTPUT) 得到了执行。

简化终端输出

现在我们的 Makefile 所输出的内容会有些杂乱无章,我们很难直观看出哪条命令在编译哪个文件。所以我们常通过 @ 符号,来禁止 Makefile 将执行的命令输出至终端上:

1
2
3
$(OUTPUT)/%.o : %.c
mkdir -p $(dir $@)
@gcc -c $(INCS) $< -o $@

添加 @ 符号后,编译命令 gcc -c $(INCS) $< -o $@ 就不会输出在终端上了:

1
2
3
4
gee@JiPing_Desktop:~/workspace/example$ make
mkdir -p output/.//
mkdir -p output/./func/
gcc ./output/.//entry.o ./output/./func/bar.o -o main

同时你可能注意到 Makefile 中是可以使用终端命令的,所以我们可以用 echo 命令来拟定自己的输出信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
SUBDIR := ./
SUBDIR += ./func

OUTPUT := ./output

INCS := $(foreach dir,$(SUBDIR),-I$(dir))
SRCS := $(foreach dir,$(SUBDIR),$(wildcard $(dir)/*.c))
OBJS := $(patsubst %.c,$(OUTPUT)/%.o,$(SRCS))

main : $(OBJS)
@echo linking...
@gcc $(OBJS) -o main
@echo Complete!

$(OUTPUT)/%.o : %.c
@echo compile $<...
@mkdir -p $(dir $@)
@gcc -c $(INCS) $< -o $@

.PHONY : clean

clean:
@echo try to clean...
@rm -r $(OUTPUT)
@echo Complete!

这是修改 Makefile 后再次执行 make 时,终端的输出:

1
2
3
4
5
6
7
8
gee@JiPing_Desktop:~/workspace/example$ make clean
try to clean...
Complete!
gee@JiPing_Desktop:~/workspace/example$ make
compile entry.c...
compile func/bar.c...
linking...
Complete!

相比之前简洁了许多!

自动生成依赖

还记得修改头文件后,包含该头文件的源文件不会重新编译的问题吗?现在让我们试试看解决这个问题。

问题的解决思路也很简单,就是将头文件一同加入到 *.o 文件的依赖中:

1
2
./entry.o : ./entry.c ./func/bar.h
gcc -c $(INCS) ./entry.c -o ./entry.o

但这实现起来并不容易,我们需要在 Makefile 中为每个源文件单独添加头文件依赖,手动维护这些依赖关系会是一件极其痛苦的事情。幸运的是,gcc 提供了强大的自动生成依赖功能,仅需在编译时指定 -MMD 选项,就能得到记录有依赖关系的 *.d 文件。

-MMD 选项包含两个动作,一是生成依赖关系,二是保存依赖关系到 *.d 文件。与其类似的选项还有 -MD,其作用与 -MMD 相同,差别在于 -MD 选项会将系统头文件一同添加到依赖关系中。

另外我们还可以指定 -MP 选项,这会为每个依赖添加一个没有任何依赖的伪目标。-MP 选项生成的伪目标,可以有效避免删除头文件时,Makefile 因找不到目标来更新依赖所报的错误:``make: *** No rule to make target ‘dummy.h’, needed by ‘dummy.o’. Stop.`。

1
2
3
4
5
6
gee@JiPing_Desktop:~/workspace/example_makefile$ gcc -MMD -MP -c -I. -I./func entry.c -o entry.o
gee@JiPing_Desktop:~/workspace/example_makefile$ ls
Makefile entry.c entry.d entry.o func main output
gee@JiPing_Desktop:~/workspace/example_makefile$ cat entry.d
entry.o: entry.c func/bar.h <- 自动生成的依赖关系
func/bar.h: <- 没有任何依赖的伪目标

接着我们还需要将 *.d 文件记录的依赖关系,手动包含到 Makefile 中,这样才能使其真正发挥作用。所以 Makefile 又可以修改为:

-MMD 选项生成的 .d 文件保存在与 .o 文件相同的路径下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
SUBDIR := ./
SUBDIR += ./func

OUTPUT := ./output

INCS := $(foreach dir,$(SUBDIR),-I$(dir))
SRCS := $(foreach dir,$(SUBDIR),$(wildcard $(dir)/*.c))
OBJS := $(patsubst %.c,$(OUTPUT)/%.o,$(SRCS))
DEPS := $(patsubst %.o,%.d,$(OBJS))

main : $(OBJS)
@echo linking...
@gcc $(OBJS) -o main
@echo Complete!

$(OUTPUT)/%.o : %.c
@echo compile $<...
@mkdir -p $(dir $@)
@gcc -MMD -MP -c $(INCS) $< -o $@

.PHONY : clean

clean:
@echo try to clean...
@rm -r $(OUTPUT)
@echo Complete!

-include $(DEPS)

最后一行的 include 用于将指定文件的内容插入到当前文本中。初次编译,或者 make clean 后再次编译时,*.d 文件是不存在的,这通常会导致 include 操作报错。所以我们在 include 前加了 - 符号,其作用是指示 make 在 include 操作出错时忽略这个错误,不输出任何错误信息并继续执行接下来的操作。

通用模板

文章的末尾,放一个通用的 Makefile 模板吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
ROOT := $(shell pwd)

SUBDIR := $(ROOT)
SUBDIR += $(ROOT)/func

TARGET := main
OUTPUT := ./output

INCS := $(foreach dir,$(SUBDIR),-I$(dir))
SRCS := $(foreach dir,$(SUBDIR),$(wildcard $(dir)/*.c))
OBJS := $(patsubst $(ROOT)/%.c,$(OUTPUT)/%.o,$(SRCS))
DEPS := $(patsubst %.o,%.d,$(OBJS))

$(TARGET) : $(OBJS)
@echo linking...
@gcc $(OBJS) -o $@
@echo complete!

$(OUTPUT)/%.o : %.c
@echo compile $<...
@mkdir -p $(dir $@)
@gcc -MMD -MP -c $(INCS) $< -o $@

.PHONY : clean

clean:
@echo try to clean...
@rm -r $(OUTPUT)
@echo complete!

-include $(DEPS)

参考链接

文章名: 《写给初学者的Makefile入门指南》
作者: 吉平.集
写作日期: 2023.3.6 ~ 2023.3.30
发布日期: 2023.3.30