> 文章列表 > 【C++进阶】Makefile基础(一)

【C++进阶】Makefile基础(一)

【C++进阶】Makefile基础(一)

文章目录

    • 1. 环境
    • 2. 规则
    • 3. 原理
    • 4. 伪目标

Makefile 其实只是一个指示 make 程序(后面简称 make 或有时称之为 make 命令)如何为我们工作的命令文件,我们说 Makefile 其实是在说 make,这一点要有很清晰的认识。而对于我们的项目来说,Makefile 是指软件项目的编译环境。软件产品开发在编码阶段最常见的工作内容大致是:

  • 开发人员根据概要设计进行编码
  • 开发人员编译所设计的源代码以生成可执行文件
  • 开发人员对软件产品进行测试来验证其功能的正确性

上面的三个步骤是一个迭代过程,如果最终验证设计的正确性完全达到要求,那么就完成了编码阶段的开发,如果没有那还得重复这三个步骤,直到达到设计要求为止。

在上面的几步中,与 Makefile 关系最大的是第二步,那 Makefile 的好坏对于项目开发有些什么影响呢?设计得好的 Makefile,当我们重新编译时,只需编译那些上次编译成功后修改过的文件,也就是说编译的是一个 delta,而不是整个项目。反之,如果一个不好的 Makefile 环境,可能对于每一次的编译先要 clean,然后再重新编译整个项目。两种情况的差异是显然的,后者将耗费开发人员大量的时间用于编译,也就意味着低效率。对于小型项目,低效问题可能表现得并不明显,但对于规模相对大的项目,那就非常的明显了。开发人员可能一天做个十次编译(甚至更少)就没有时间用于编码和测试(调试)了。这就是为什么通常大型项目都会有一个专门维护 Makefile 的一个小团
队,来支撑产品的开发。

最为重要的是掌握二个概念,一个是目标(target),另一个就是依赖(dependency)。目标就是指要干什么,或说运行 make 后生成什么,而依赖是告诉 make 如何去做以实现目标。在 Makefile 中,目标和依赖是通过规则(rule)来表达的。我们最为熟悉的是采用 make 来进行软件产品的代码编译,但它可以被用来做很多很多的事情,后面我们会给出一些不是用 make 来进行代码编译的例子。驾驭 Makefile,最为重要的是要学会采用目标和依赖关系来思考所需解决的问题。


在这里插入图片描述

  • 目标(Targets): Makefile中的目标是指需要生成的文件或者是需要执行的操作。目标可以是一个文件、一个命令或者是一个操作序列。
  • 依赖(Dependencies): Makefile中的依赖是指目标所依赖的文件或者是命令。如果依赖文件发生了变化,那么目标也需要重新生成。
  • 命令(Commands): Makefile中的命令是指生成目标所需要执行的操作序列。这些操作可以是编译、链接、复制、打包等等。命令必须以一个制表符或者是多个空格开头,否则会被当成注释。

在这里插入图片描述
Makefile 是一个文本文件,其中包含一些规则和指令,用于描述如何编译和链接一个或多个源代码文件,生成可执行程序或库文件。

Makefile 的工作原理如下:

  1. Makefile 中定义了目标文件、依赖文件和命令。目标文件通常是可执行程序或库文件,依赖文件是源代码文件、头文件或其他依赖项,命令是编译、链接和生成目标文件的操作。

  2. 当执行 make 命令时,Makefile 中的规则会被解析,根据依赖关系生成一个依赖图,确定哪些文件需要重新编译。

  3. Make 程序根据依赖图和规则,递归地执行编译、链接和生成目标文件的操作,确保所有依赖项都被编译和链接,生成最终的目标文件。

  4. 如果某些依赖项没有改变,则不需要重新编译和链接,从而提高了编译效率。

  5. Makefile 还支持变量、条件语句、循环语句等高级特性,可以根据不同的条件进行编译和链接,生成不同的目标文件。

1. 环境

使用makefile的环境要求如下:

  1. 操作系统:makefile可以在大多数操作系统上使用,包括Linux、Unix、Mac OS X、Windows等。
  2. 编译器:makefile需要一个支持GNU make语法的编译器,例如GNU make、BSD make等。
  3. 目标文件:makefile需要可编译的源代码文件,例如C、C++、Java等。
  4. 环境变量:makefile需要一些环境变量来指定编译器、编译选项等,例如CC、CFLAGS、LDFLAGS等。
  5. 编辑器:makefile需要一个编辑器来编写和编辑makefile文件,例如Vim、Emacs等。
  6. make工具:makefile需要一个make工具来执行makefile文件,例如GNU make。

使用步骤:

  1. 安装GNU Make工具:Make是一个命令行工具,用于自动化构建软件的过程。可以从GNU官网下载并安装Make工具。
  2. 创建Makefile文件:Makefile是一个文本文件,其中包含了一系列规则和指令,用于描述如何构建软件。可以在项目根目录下创建一个名为Makefile的文件。
  3. 编写Makefile规则:Makefile规则由目标、依赖和命令组成。目标是指要生成的文件或者执行的操作;依赖是指生成目标的前提条件;命令是指生成目标的具体操作。
  4. 运行Make命令:在命令行中进入项目根目录下,输入make命令即可执行Makefile中定义的规则,生成目标文件或者执行操作。

命令行输入make -v,如果出现类似于下图的版本信息,那么说明make在你的环境中已经可用:
在这里插入图片描述

注意事项:

  1. 在编写Makefile时,应该尽可能使用变量和函数,以便提高代码的可读性和可维护性。
  2. Makefile中的命令必须以Tab键开始,而不是空格键。
  3. Makefile中的依赖关系应该尽可能明确,以便正确地判断哪些规则需要执行。
  4. 在执行Make命令之前,应该确保所有依赖文件都已经存在,否则会导致构建失败。

2. 规则

我们使用Hello World来开始Makefile规则的学习,编写一个如下的 Makefile 文件,文件的存放目录可以是任意的:

all:echo "Hello World"

在这里插入图片描述

需要注意的是 echo 前面必须只有 TAB,且至少有一个 TAB,而不能用空格代替。

Makefile 中第一个很重要的概念就是目标(target),上面代码中的 all 就是我们的目标,目标放在 : 的前面,其名字可以是由字母和下划线组成。echo “Hello World”就是生成目标的命令,这些命令可以是任何在你的环境中运行的命令以及 make 所定义的函数等等,这里的 echo 是 BASH Shell 中的一个命令,其功能是打印字符串到终端上。在这里的 all 目标是在终端上打印出“Hello World”,有时目标会是一个比较抽象的概念。all 目标的定义,其实是定义了如何生成 all 目标,这称之为规则,即上面的 Makefile 定义了一个生成 all 目标的规则。

下面的示例展示了三种不同的运行方式以及每种方式的运行结果:

  • 第一种方式:只要在 Makefile 所在的目录下运行make命令,终端上就会输出两行,第一行实际上是我们在 Makefile 中所写的命令,而第二行则是运行命令的结果
  • 第二种方式:运行make all命令,这告诉 make 工具,我要生成目标 all,其结果跟第一种方式一样
  • 第三种方式:运行make test,指示 make 为我们生成 test 目标。由于我们根本没有定义 test 目标,所以运行结果是可想而知的,make 的确报告了不能找到 test 目标

在这里插入图片描述
现在对上面的 Makefile 做一点小小的改动,如下面所示,增加了 test 规则用于构建 test 目标,实现在终端上打印出“Just for test!”:

all:echo "Hello World"
test:echo "Just for test!"

在这里插入图片描述
从如上输出我们可以发现:

  • 一个 Makefile 中可以定义多个目标
  • 调用make命令时,我们得告诉它我们的目标是什么,即要它干什么。当没有指明具体的目标是什么时,那么 make 以 Makefile 文件中定义的第一个目标作为这次运行的目标。这第一个目标也称之为默认目标(和是不是all没有关系)
  • 当 make 得到目标后,先找到定义目标的规则,然后运行规则中的命令来达到构建目标的目的。现在所示例的 Makefile 中,每一个规则中都只有一条命令,而实际的 Makefile,每一个规则可以包含很多条命令

对于前面的示例,当运行make时,在终端上还打印出了 Makefile 文件中的命令。有时并不希望它这样,因为这样可能使得输出的信息看起来有些混乱。要使make不打印出命令,只要做一点小小的修改,改过的 Makefile 如下所示,就是在命令前加了一个@。 这一符号告诉make,在运行时不要将这一行命令显示出来:

all:@echo "Hello World"
test:@echo "Just for test!

在这里插入图片描述
对上述代码再做一点点小改动,在 all 目标的:后加上 test 目标,如下所示

all: test@echo "Hello World"
test:@echo "Just for test!"

在这里插入图片描述


下面讲解一下 Makefile 中的依赖关系

上面的代码中,all 目标后的 test 告诉 make,all 目标依赖 test 目标,这一依赖目标在 Makefile 中又被称之为先决条件。出现这种目标依赖关系时,make 工具会按从左到右的先后顺序先构建规则中所依赖的每一个目标。如果希望构建 all 目标,那么 make 会在构建它之前得先构建 test 目标,这就是为什么称之为先决条件。下面用类图表达了 all 目标的依赖关系:

在这里插入图片描述

至此,我们了解 Makefile 中规则,下面是规则的文字和 UML。一个规则是由目标(targets)、先决条件(prerequisites)以及命令(commands)所组成的。需要指出的是,目标和先决条件之间表达的就是依赖关系(dependency),这种依赖关系指明在构建目标之前,必须保证先决条件先满足(或构建);而先决条件可以是其它的目标,当先决条件是目标时,其必须先被构建出来。

targets : prerequisitescommand

在这里插入图片描述
规则中目标可以有多个,当存在多个目标,且这一规则是 Makefile 中的第一个规则时,如果我们运行 make 命令不带任何目标,那么规则中的第一个目标将被视为是缺省目标,如下所示:

all test:@echo "Hello World"

在这里插入图片描述
make 处理一个规则的活动图如下图所示,当中的构建依赖目标(build dependent target(s))这一活动(注意是活动,而不是动作)就是重复图下图所示的同样的活动,你可以看作是对下面活动图的递归调用。而运行命令构建目标(run command to build target)则是一个动作,是由命令所组成的动作。活动与动作的区别是,动作是只做一件事(但是可以有多个命令),而活动可以包括多个动作。
在这里插入图片描述

3. 原理

接下来我们试着将规则运用到程序编译当中去,下面我们假设有用于创建 simple 可执行文件的两个源程序文件,我们需要写一个用于创建simple 可执行程序的 Makefile,这个 Makefile 需要如何去写?
foo.c

#include <stdio.h>
void foo ()
{printf ("This is foo()\\n");
}

main.c

extern void foo();
int main ()
{foo();return 0;
}

写一个 Makefile 文件的第一步不是一个猛子扎进去试着写一个规则,而是先用面向依赖关系的方法想清楚,所要写的 Makefile 需要表达什么样的依赖关系,这一点非常的重要。通过不断的练习,我们最终能达到很自然的运用依赖关系去思考问题。到那时,你再写 Makefile 时,头脑会非常的清楚自己在写什么,以及后面要写什么。现在抛开 Makefile,我们先看一看 simple 程序的依赖关系是什么。

第一个跃入我们脑海中的依赖关系图,其中 simple 可执行文件显然是通过 main.c 和 foo.c 最后编译并连接生成的。通过这个依赖图,其实就可以写出一个 Makefile 来了。这样的依赖关系所写出来的 Makefile,在现实中不是很可行,就是你得将所有的源程序都放在一行中让 GCC 为我们编译:
在这里插入图片描述
下图是 simple 程序的依赖关系更为精确的表达,其中加入了目标文件。对于 simple 可执行程序来说,下图表示的就是它的“依赖树”。接下来需要做的是将其中的每一个依赖关系,即其中的每一个带箭头的虚线,用 Makefile 中的规则来表示:
在这里插入图片描述

all: main.o foo.ogcc main.o foo.o -o simple
main.o: main.cgcc main.c -c
foo.o: foo.cgcc foo.c -c.PHONY:clean
clean:rm -f main.o foo.o simple

在这个 Makefile 中,我还增加了一个伪目标用于删除生成的文件,包括目标文件和 simple 可执行程序,这在现实的项目中很常见。
在这里插入图片描述
如果我们在不改变代码的清况下再编译会出现什么现象呢?下图给出了结果,注意到第二次编译并没有构建目标文件的动作,但为什么有构建simple可执行程序的动作呢?
在这里插入图片描述
Makefile会根据文件的时间戳(即最后修改时间)来判断文件是否需要重新构建。如果某个文件的时间戳比依赖它的文件要旧,那么该文件就需要重新构建。因此,如果你多次执行make命令,即使源文件和头文件没有变化,可执行文件的时间戳也会更新,从而导致重新构建。如果想避免这种情况,可以使用make的增量构建功能,这样只会重新构建必要的文件。

下面验证一下如果对 foo.c 进行改动,是否会重新构建。对于 make 工具,一个文件是否改动不是看文件大小,而是其时间戳。Linux下只需用 touch 命令来改变文件的时间戳,这相当于模拟了对文件进行了一次编辑,而不需真正对其进行编辑,如图所示,make 发现了 foo.c 的改变,并对其进行了重新编译:
在这里插入图片描述

4. 伪目标

在 Makefile 中,伪目标是一种特殊的目标,它并不代表一个实际的文件,而是用于完成特定的任务或者组织其他目标的执行顺序。

假设我们有一个C语言项目,包含以下几个文件:main.c, foo.c, bar.c, foo.h, bar.h。我们需要编译这个项目生成一个可执行文件my_program。一个简单的 Makefile 可能如下所示:

my_program: main.o foo.o bar.ogcc -Wall -g -o my_program main.o foo.o bar.omain.o: main.c foo.h bar.hgcc -Wall -g -c main.cfoo.o: foo.c foo.hgcc -Wall -g -c foo.cbar.o: bar.c bar.hgcc -Wall -g -c bar.cclean:rm -f *.o my_program

在这个 Makefile 中,我们有一个名为clean的目标。它不依赖于其他目标,也不代表一个实际的文件。它的作用是删除所有的中间文件(.o文件)和生成的可执行文件(my_program),这就是一个典型的伪目标。

伪目标的主要特点和用途:

  • 不代表实际的文件:伪目标并不对应任何实际存在的文件,它只是为了完成特定任务而存在
  • 避免名称冲突:由于伪目标不代表实际的文件,我们可以避免因文件和目标名称相同而导致的错误
  • 更好地组织Makefile:通过伪目标,我们可以把不同的任务和操作分开,使Makefile更加清晰易读
  • 强制执行:使用伪目标,我们可以强制执行某个任务,而不受文件是否存在或已经更新的影响

在 Makefile 中,我们可以使用`.PHONY``声明一个伪目标,以明确地告诉 make 这个目标不是一个实际的文件。例如,我们可以在上面的例子中添加如下声明:

.PHONY: clean

这样做的好处是,即使当前目录下存在一个名为clean的文件,make 也会知道clean是一个伪目标,而不是一个实际的文件。

当然,除了上述clean伪目标之外,还有其他常见的伪目标。以下是一些在Makefile中经常使用的伪目标:

  1. all:这个伪目标通常用于编译整个项目。当用户执行make或make all时,它将自动编译并生成所有需要的目标。
    .PHONY: all
    all: my_program
    
  2. install:这个伪目标用于安装编译好的程序到系统指定的目录。通常,这需要管理员权限,因为它涉及到在系统目录中创建或修改文件。
    .PHONY: install
    install: my_programcp my_program /usr/local/bin
    
  3. uninstall:这个伪目标用于从系统中删除已安装的程序。和install一样,它通常也需要管理员权限。
    .PHONY: uninstall
    uninstall:rm -f /usr/local/bin/my_program
    
  4. test:这个伪目标用于运行项目的测试用例,确保项目的各个部分正常工作。
    .PHONY: test
    test: my_program./test_script.sh
    
  5. help:这个伪目标用于显示Makefile的使用说明,帮助用户了解如何使用Makefile。
    .PHONY: help
    help:@echo "Usage:"@echo "  make all      - Compile the project"@echo "  make clean    - Remove compiled files and binaries"@echo "  make install  - Install the program"@echo "  make test     - Run tests"