前言
C语言程序从代码到可执行文件(*.exe)需要经过预处理、编译、汇编和链接这几个步骤。每当修改源文件(*.c)或源文件所包含的头文件(*.h)后,我们都需要重新执行上述几个步骤,以得到修改后的程序。
通常将预处理、编译和汇编这三个步骤统称为编译。
一个项目通常有多个源文件,如果只修改其中一个,就对所有源文件重新执行编译、链接步骤,就太浪费时间了。因此十分有必要引入 Makefile 工具:Makefile 工具可以根据文件依赖,自动找出那些需要重新编译和链接的源文件,并对它们执行相应的动作。
开始前的准备
本文章目的在于帮助你理解和掌握Makefile的编写方法和编写技巧,在开始阅读和动手编写Makefile前,你可能需要准备一下环境。
本篇文章的示例运行在wsl2上(Windows Subsystem for Linux 2),我的系统信息如下:
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 前,我们先写一段简单的代码,并尝试使用编译工具链将代码变为可执行文件。
编写简单的代码
/* 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
以安装所需的编译工具。
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
# Makefile
main : main.c
gcc main.c -o main
编写好后回到终端,使用 make
来执行Makefile:
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
,看看这次的结果会怎样:
/* main.c */
#include <stdio.h>
int main(void)
{
printf("Hello from new main!\n"); // <- 多加了一个new
return 0;
}
回到终端执行 make
:
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时会得到怎样的结果?
上面的例子中,可执行文件 main
就是我们想要得到的目标,而 main
的生成依赖于 main.c
,所以将 main.c
填写在依赖的位置。在发现目标文件不存在,或依赖的文件有所修改后,Makefile 就会执行下方的执行语句,其任务通常是生成目标文件。
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,会发现其中的内容都是有具体指向的:main
、main.c
。试想这样一个场景:我们在文件夹中添加新的源文件 bar.c
,并将 main.c
重命名为 entry.c
,这时再执行 make
会得到怎样的结果呢?
思考题:在函数 Print_Progress_Bar 中,数组
bar
的定义和赋值能否由char bar[] = PROGRESS_BAR
改为char *bar = PROGRESS_BAR
。为什么?两者有什么不同?
/* 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
,结果如下:
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.c
, make
停止了执行。解决问题的方法有两种,简单粗暴的做法是:直接根据新的文件命名修改 Makefile 文件:
# Makefile
main : entry.c bar.c
gcc entry.c bar.c -o main
由于主函数调用了 bar.c
中定义的函数,所以在编译时我们需要将 bar.c
一起编译、链接到可执行文件里,同时别忘了把它加进依赖中。修改好后回到终端重新执行 make
:
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.c
、 bar.c
,我们会发现它们有着共同的模式(或称为规律):都以 .c
结尾,这意味着可以用这种模式匹配所有源文件。在 Makefile 中我们可以使用 wildcard 函数(wildcard function)来达到这一目的。
使用wildcard函数
在 Makefile 中,$(function arguments)
的写法用于函数调用, wildcard 函数的使用方法如下:
$(wildcard pattern…)
如果我们想匹配当前目录下的所有源文件,就可以这样写:$(wildcard *.c)
,其中通配符 *
用于匹配任意长度的任何字符,可以是 main
、bar
,也可以是其他任何你能想得到的字符组合,后面加上 .c
则是要求匹配的字符组合必须以 .c
结尾。
当前示例下,$(wildcard *.c)
展开后得到的结果就是: bar.c entry.c
,所以我们的 Makefile
文件可以修改为:
# Makefile
main : $(wildcard *.c)
gcc $(wildcard *.c) -o main
修改后保存,再重新执行 make
,得到的结果与之前一致:(这里我将进度条从进度33%改为了52%,以确保 make
执行编译命令)
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
可以进一步修改为:
# Makefile
SRCS := $(wildcard *.c)
main : $(SRCS)
gcc $(SRCS) -o main
相比上面的 Makefile
,进一步修改后的 Makefile
减少了一次函数调用,并且增加了可读性。
变量的赋值和修改
我们在刚才的示例中使用到了赋值符号 :=
,该符号与C语言中的赋值符号 =
作用效果相同。以下是几个常用符号的简介:
=
:递归赋值(Recursively Expanded Variable Assignment),使用变量进行赋值时,会优先展开引用的变量,示例如下:
foo = $(bar)
bar = $(ugh)
ugh = Huh?
all:;echo $(foo)
# 打印的结果为 Huh?,$(foo)展开得到$(bar),$(bar)展开得到$(ugh),$(ugh)展开得到Huh?最终$(foo)展开得到Huh?
:=
:简单赋值(Simply Expanded Variable Assignment),最常用的赋值符号:
x := foo
y := $(x) bar
x := later
# 等效于:
# y := foo bar
# x := later
+=
:文本增添(Appending),用于向已经定义的变量添加文本:
objects = main.o foo.o bar.o utils.o
objects += another.o
# objects最终为main.o foo.o bar.o utils.o another.o
?=
:条件赋值(Conditional Variable Assignment),仅在变量没有定义时创建变量:
FOO ?= bar
# FOO最终为bar
foo := ugh
foo ?= Huh?
# foo最终为ugh
动手写进阶的Makefile
到目前为止,我们已经写出一个简单能用的Makefile了,它能应对不太复杂的场景,在没有多级目录的情况下已经足够使用。但我们实际面对的场景往往要复杂得多:源文件和头文件按照功能或层级区分,散落在一个个子文件夹下,这样做更容易管理工程文件,但也带来了两点小麻烦。
先让我们先改造一下当前的目录结构,使其更贴合实际应用场景:
tree
命令的作用是以树的形式展现目录结构,你可能无法直接使用该命令,尝试sudo apt install tree
以安装和使用tree
命令。
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
中将手动声明函数改为了头文件包含:
// 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
,看看会发生什么:
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
选项指定头文件所在路径:
# Makefile
INCS := -I./func
SRCS := $(wildcard *.c)
main : $(SRCS)
gcc $(INCS) $(SRCS) -o main
再来执行make:
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
变量中就可以了:
# Makefile
INCS := -I./func
SRCS := $(wildcard *.c)
SRCS += $(wildcard ./func/*.c)
main : $(SRCS)
gcc $(INCS) $(SRCS) -o main
尝试执行下:
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
中去。
# Makefile
SUBDIR := .
SUBDIR += ./func
这里定义了变量 SUBDIR
,我们将使用它来指定那些存放着源文件和头文件的目录。接下来我们将请出另一个功能强大的函数 foreach 来帮助我们完成一项复杂的功能。
$(foreach var,list,text)
foreach(for each)函数的功能与 Python 和C语言中的 for 循环类似,但会更接近 Python 的 for 循环。它的功能描述起来就是:从 list
中逐个取出元素,赋值给 var
,然后再展开 text
。下面是一个使用示例。
SUBDIR := .
SUBDIR += ./func
EXPANDED := $(foreach dir,$(SUBDIR),$(dir)/*.c)
# 等效于EXPANDED := ./*.c ./func/*.c
有了 foreach 函数,我们就能配合 wildcard 函数,通过指定路径来获取源文件,并指定头文件所在路径:
# 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
会执行编译命令):
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
存在的两点问题:
- 没有保存
.o
文件,这导致我们每次文件变动都要重新执行预处理、编译和汇编来得到目标文件,即使新得到的文件与旧文件完全没有差别(即编译用到的源文件没有任何变化,就跟bar.c
一样)。 - 有保存
.o
文件,则会遇到第二个问题,即依赖中没有指定头文件,这意味着只修改头文件的情况下,源文件不会重新编译得到新的可执行文件!
为了证明以上两个问题,我们对 Makefile
做一些改动:
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
执行的效果:
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
文件的情况)
// 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
的效果:
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
。
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
。
// bar.h
// 注:#ifndef配合#define用于避免源文件重复包含同一头文件的内容
#ifndef _BAR_H
#define _BAR_H
// 函数声明
void Print_Progress_Bar(float comp);
#endif
执行 make
:
gee@JiPing_Desktop:~/workspace/example$ vim ./func/bar.h
gee@JiPing_Desktop:~/workspace/example$ make
make: 'main' is up to date.
不出所料,源文件果然没有重新编译。
模式规则和自动变量
我们还是先来解决问题,首先是 *.o
文件的保存问题,这个问题其实在上面已经解决了,我们再来看一遍:
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
变得更加通用。首先聚焦于编译过程:
./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
文件的目标和依赖,有着相同的书写模式,这意味着存在通用的写法:
%.o : %.c
gcc -c $(INCS) $< -o $@
这里我们用上了 %
,它的作用有些难以用语言概括,上述例子中, %.o
的作用是匹配所有以 .o
结尾的目标;而后面的 %.c
中 %
的作用,则是将 %.o
中 %
的内容原封不动的挪过来用。
更具体地例子是,%.o
可能匹配到目标 ./entry.o
或 ./func/bar.o
,这样 %
的内容就会是 ./entry
或 ./func/bar
,最后交给 %.c
时就变成了 ./entry.c
或 ./func/bar.c
。
另外我们还使用到了自动变量 $< $@
,其中 $<
指代依赖列表中的第一个依赖;而 $@
指代目标。注意自动变量与普通变量不同,它不使用小括号。
结合起来使用,我们就得到了通用的生成 *.o
文件的写法:
# 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
文件:
main : ./entry.o ./func/bar.o
gcc ./entry.o ./func/bar.o -o main
这看起来十分眼熟,我们最初解决多文件编译问题时也是采用类似的写法,只有文件后缀不一样:
main : ./entry.c ./func/bar.c
gcc ./entry.c ./func/bar.c -o main
这给了我们一点提示,是不是能够通过 wildcard 函数来实现通用的写法?可惜的是,在最开始我们是无法匹配到 *.o
文件的,因为起初我们只有 *.c
文件, *.o
文件是后来生成的。但转换一下思路,我们在获取所有源文件后,直接将 .c
后缀替换为 .o
,不就能得到所有的 .o
文件了吗?正巧 patsubst 函数可以用于模式文本替换。
$(patsubst pattern,replacement,text)
patsubst 函数的作用是匹配 text
文本中与 pattern
模式相同的部分,并将匹配内容替换为 replacement
。于是链接步骤可以改写为:
SRCS := $(foreach dir,$(SUBDIR),$(wildcard $(dir)/*.c))
OBJS := $(patsubst %.c,%.o,$(SRCS))
main : $(OBJS)
gcc $(OBJS) -o main
这里我们先用 wildcard 函数获取所有的 .c
文件,并将结果保存在 SRCS
中,接着利用 patsubst 函数替换 SRCS
的内容,最后将所有的 .c
替换为 .o
以获得执行编译所得到的目标文件。
于是我们的 Makefile
就可以改写为:
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 $@
试试效果:
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
文件同一级目录下的,从代码编辑习惯考虑,这可能会导致我们无法方便地寻找源文件或头文件。
gee@JiPing_Desktop:~/workspace/example$ tree
.
├── Makefile
├── entry.c
├── entry.o
├── func
│ ├── bar.c
│ ├── bar.h
│ └── bar.o
└── main
理想的做法是将 *.o
文件保存至指定目录,与源文件和头文件区分开:
gee@JiPing_Desktop:~/workspace/example$ tree
.
├── Makefile
├── entry.c
├── func
│ ├── bar.c
│ └── bar.h
├── main
└── output
├── entry.o
└── func
└── bar.o
但应该如何实现呢?先来看一下单个 .o
文件怎么输出到指定文件夹:
./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 函数时解决,就像这样:
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 命令创建目录,可以确保输出路径存在:
mkdir -p $(dir ./output/func/bar.o)
dir 函数会把 ./output/func/bar.o
修改成 ./output/func
,于是上面的命令就变为了:
mkdir -p ./func/func
通过修改 Makefile 来一起解决两个问题:
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)/
,确保匹配到的目标是要生成在输出目录的目标。
测试一下,是可以使用的:
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
目标,用于清理编译生成的过程文件:
OUTPUT := ./output
clean:
rm -r $(OUTPUT)
修改后执行 make
命令时传入参数 clean
就会执行 clean
目标下的语句:
gee@JiPing_Desktop:~/workspace/example$ vim ./Makefile
gee@JiPing_Desktop:~/workspace/example$ make clean
rm -r ./output
在没有解决头文件依赖问题时,clean
后重新编译,也是一种临时解决方案。
但这样做存在隐患:当前目录下有与目标同名的文件时,在没有依赖的情况下,Makefile 会认为目标文件已经是最新的状态了,目标下的语句也就不再执行。
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
依赖以达成这一目的:
.PHONY : clean
OUTPUT := ./output
clean:
rm -r $(OUTPUT)
添加上 .PHONY : clean
后再执行 make clean
:
gee@JiPing_Desktop:~/workspace/example$ make clean
rm -r ./output
gee@JiPing_Desktop:~/workspace/example$ tree
.
├── Makefile
└── clean
可以看到 clean
目标下的 rm -r $(OUTPUT)
得到了执行。
简化终端输出
现在我们的 Makefile 所输出的内容会有些杂乱无章,我们很难直观看出哪条命令在编译哪个文件。所以我们常通过 @
符号,来禁止 Makefile 将执行的命令输出至终端上:
$(OUTPUT)/%.o : %.c
mkdir -p $(dir $@)
@gcc -c $(INCS) $< -o $@
添加 @
符号后,编译命令 gcc -c $(INCS) $< -o $@
就不会输出在终端上了:
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 命令来拟定自己的输出信息:
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
时,终端的输出:
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 文件的依赖中:
./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.`。
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 文件相同的路径下。
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 模板吧!
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)
结语
在一开始写单片机的时候,我更多是依赖 IDE 提供的编译环境,所以对于头文件包含、配置路径这些操作没有很深入的了解。
有时候只修改一个头文件,代码就要编译好久,比修改好几个源文件所花费的时间要长很多,放在以前我是肯定不知道原因的,但是现在知道了:如果有特别多源文件包含了同一个头文件,那修改这个头文件时,那些包含了这个头文件的源文件都要重新编译一次,这就是编译耗时很长的原因。
这个事情给了我一些启发,当我们向深层去挖掘的时候,很可能能找到一套理论来解释我们遇到的问题。这对我们写代码是有用处的,知道哪些东西会引发问题,我们就可以提前避开这些危险的东西。
所以保持思考,坚持学习,不为了其它,只为了解决更多的问题!
最后的最后,今天是3月30日,祝生日快乐!!
参考链接
- 如何在 Ubuntu 20.04 上安装 GCC(build-essential)
- linux - What’s the meaning of gcc ‘-c’ and gcc ‘-o’?
- GNU make
- make - What does % symbol in Makefile mean
- What do the makefile symbols $@ and $< mean?
- What does the ‘-c’ option do in GCC?
- Automatic Prerequisites (GNU make)
- c++ - makefile dependency generation - Code Review Stack Exchange
- Include (GNU make)
文章名: 《写给初学者的Makefile入门指南》
作者: 吉平.集
写作日期: 2023.3.6 ~ 2023.3.30
发布日期: 2023.3.30