前言

说起Makefile ,在不久前,我也就是仅限于知道有这个东西,但是自从接触到Msys2后,我首次接触到了Make,最开始,我认为make仅限于进行C/C++的构建,直到后来,我才发现,Make 是 Linux下的默认构建工具,他的适用范围不仅仅局限于C/C++,而理解这一特性的前提就是掌握MakeFile的语法。

本文的目的在于,让你对Makefile的最根本的东西有所认识,但是并没还有介绍过多的语法细节,以及工程应用场景,因为那些场景或者规则,都依赖于本文讲的这一部分,我的总体叙述逻辑也和官网的文档保持相同逻辑,先讲大致框架,然后逐步完善细节。认知由浅入深,而不是一次到位。

正文

为什么有Makefile

Linux系统下,或者说任何系统下,命令行脚本都是万能和通用的,无论是构建任务还是启动关闭系统功能或者定时任务等,命令行脚本都可以完成,但是也正因为这种通用性,让命令行脚本事实丧失了专用性,Linux系统为了更好的进行构建任务,设计了make工具,而make工具使用的Makefile的语法也是为了构建任务专门设计,构建任务显而易见的特点就是多过程,并且生成的某一个文件被下一个需要生成的文件所依赖,命令行工具虽然可以很好的展示多过程性,但是并不能很好的展示文件之间的依赖关系(通过注释的方式标注然后二次处理总是不太方便,使用其他形式标注,比如通过函数标注也显得不是十分的优美)。这是我的个人的一个全局性质的感触,如果你还不能很好的理解,我说一个使用场景你就可以理解了。

假设我们现在拥有三个文件 a.txt,b.txt,c.txt。我们要合并这些文本文档,并且规则是先将 a.txt 插入到 b.txt的第二个自然段,然后整体翻译为英文。之后将这段英文追加到 c.txt 之后,最后合并的结果保存到 d.txt中,我们当然能够使用通用命令记录这个过程。然后我们使用这个脚本就可以很好的执行生成任务。但是你是否想过,如果这个脚本执行多次,那么一些已经完成的任务,我们似乎也必须重复执行,而这样的任务完全是多此一举,因为前一次生成的和新生成的部分并无差异,当然你也可以通过在脚本中加入判断逻辑来跳过这个重复构建的子过程,判断是否可以跳过的方式也十分的简单,如果将要生成的文件的创建时间晚于用于生成这个文件的文件的修改时间,就可以判断我们不能跳过这个子过程,反之可以跳过,你应该也关注到了,这个过程中我们实质上最关键的就是知道生成一个文件需要另外那些文件,然后在此基础上获取所有文件的时间戳,之后进行比较判断。但是判断语句以及获取时间戳并且比对的过程完全是批量重复的部分,应该通过某种方式跳过,我想你已经想到了通过将这一过程封装为脚本函数,我们就可以很好的避免重写这一部分逻辑。比如我们创建一个脚本函数

# 定义一个函数 func,用于根据源文件的修改时间决定是否重新构建目标文件
func() {
    # 检查传入参数数量是否少于 2 个
    # $# 表示传入函数的参数个数
    if [ "$#" -lt 2 ]; then
        # 如果参数不足,输出错误信息到标准错误(stderr)
        echo "错误:至少需要两个参数" >&2
        echo "用法: func <目标文件> '<命令>' [源文件1] [源文件2] ..." >&2
        # 返回非零值表示失败
        return 1
    fi

    # 第一个参数是目标文件路径
    target="$1"
    # 使用 shift 移除第一个参数,使 $1 指向下一个参数
    shift
    # 第二个参数是用于构建目标文件的命令(需用引号包裹,以支持含空格的命令)
    cmd="$1"
    shift
    # 剩余所有参数被视为源文件列表
    sources="$@"

    # 初始化标志变量,用于判断是否需要重新构建
    need_rebuild=false

    # 如果目标文件不存在,则必须重建
    if [ ! -e "$target" ]; then
        need_rebuild=true
    else
        # 遍历所有源文件,检查是否存在以及是否比目标文件更新
        for src in $sources; do
            # 如果某个源文件不存在,发出警告并触发重建(可选策略)
            if [ ! -e "$src" ]; then
                echo "警告:源文件 '$src' 不存在!" >&2
                need_rebuild=true
                break  # 一旦发现缺失源文件,立即跳出循环
            fi
            # -nt 表示 "newer than":如果源文件比目标文件新,则需重建
            if [ "$src" -nt "$target" ]; then
                need_rebuild=true
                break  # 找到一个需要更新的源文件即可,无需继续检查
            fi
        done
    fi

    # 如果需要重建,则执行构建命令
    if [ "$need_rebuild" = true ]; then
        echo ">>> 正在构建: $target"
        # 使用 eval 执行用户提供的命令字符串(注意:eval 有安全风险,此处假设输入可信)
        if eval "$cmd"; then
            echo ">>> 构建成功: $target"
        else
            # 若命令执行失败,输出错误信息并返回非零状态
            echo ">>> 构建失败: $target" >&2
            return 1
        fi
    else
        # 如果不需要重建,说明目标已是最新
        echo ">>> $target 已是最新的"
    fi
}

就上边的任务而言,我们可以如此书写。

# 假设我们已经写好了 insert_second_paragraph 和 translate 工具

# 步骤1:合并 a.txt 到 b.txt 的第二段,生成 merged_ab.txt
func merged_ab.txt \
    'insert_second_paragraph b.txt a.txt > merged_ab.txt' \
    a.txt b.txt

# 步骤2:将 merged_ab.txt 翻译为英文,生成 intermediate_en.txt
func intermediate_en.txt \
    'translate merged_ab.txt > intermediate_en.txt' \
    merged_ab.txt

# 步骤3:将 c.txt 和翻译结果合并,生成最终 d.txt
func d.txt \
    'cat c.txt intermediate_en.txt > d.txt' \
    c.txt intermediate_en.txt

到这里你已经知道了如何很好的使用脚本实现 “执行必要的构建” ,但是你仍然会发现,使用脚本的构建还是有一些不好的地方,首先构建任务大量批量执行,使用脚本的执行效率并不高,并且使用脚本函数,每一行都需要手动书写func,好在我们有同时解决两个问题的方法:首先将不必要的重复部分剔除,比如func 这个部分剔除,比如任务我们可以如此书写:

merged_ab.txt: a.txt b.txt
insert_second_paragraph b.txt a.txt > merged_ab.txt

intermediate_en.txt: merged_ab.txt
translate merged_ab.txt > intermediate_en.txt

d.txt:c.txt intermediate_en.txt
cat c.txt intermediate_en.txt > d.txt

执行任何一个func函数的必要入参我们以这样的方式书写出来:第一行是目标文件和生成这个文件需要的文件,第二行是构建目标文件需要执行的命令。然后我们专门创建一个程序make解析这个文件,并且在内部批量执行func函数的逻辑。这样一来,执行效率问题和构建文件的可读性也大幅增加,恭喜你,你已经实现了一个make 命令雏形并且知道了Makefile文件的基本语法!!

从上边看,Makefile 的语法基本已经不可能变得更加简洁,但是具体细节上仍然存在优化空间,而这就是Makefile真正语法解决的问题,从大概上,Makefile的逻辑就如同我上边所说的那样。

Makefile 语法说明

这部分语法网络上还是有很多人讲的,我放在这里的是一个Java程序员写的教程,写的已经十分好了,但是需要注意的是,他并没有讲清楚Makefile的所有特性,而这些没有覆盖到的特性恰恰是优雅使用Makefile的关键。如果你想要了解这一部分知识,建议你直接看官网的make文档。相信我,官方的文档的质量之高,超乎你的想象!

Makefile 重点标注

GCC 基本用法

# 一步到位(默认),GCC会自动根据扩展名确认当前阶段,并且完成剩余阶段,终点是可执行文件
gcc hello.c -o hello
gcc hello.i -o hello 
gcc hello.s -o hello 
gcc hello.o -o hello 

# 分步执行(由于传入了参数,GCC会调整最终的阶段,依然根据扩展名确定当前阶段,并且完成剩余阶段)
gcc -E hello.c -o hello.i      # 预处理 - 停留在 预处理阶段之后
gcc -S hello.i -o hello.s      # 编译 - 停留在 编译阶段之后
gcc -c hello.s -o hello.o      # 汇编 - 停留在 汇编阶段之后
gcc hello.o -o hello           # 链接 - 停留在 链接阶段之后

因为我们是需要使用Makefile进行C/C++编程,所以有必要对于C/C++的编译流程有所了解,C++的构建通常分为三步,预处理 - 编译 - 链接 ,这里的编译是笼统的说法,实质上包含了两步(将预处理后的代码变转换为汇编代码-编译,将汇编后代码转换为机器码-汇编)。而GCC的代码优化实际是发生在GCC生成汇编代码后。

你可以看到上边我展示的代码块,上边已经详细说明了如何通过传参的方式(传递何种参数)控制gcc的编译流程。这是我们深度使用make的前提基础。至于记忆也很容易记忆吧,只需要记忆几条规则:

  1. gcc 会自动根据源文件的扩展名确认源文件进行到的阶段。

  2. 如果传入了进行流程控制的参数 (-E -S -c),gcc 会调整这次构建的终止阶段,否则终止在生成二进制文件后。

  3. -E -S 并不常用,更多的用于代码分析 ,-c 十分常用,广泛使用以减少重复工作,-o可以定义生成的文件名,如果默认输出流为终端,添加了-o 参数后,将把结果输出到 -o 指定的文件中(比如 -E 参数默认将预处理代码输出到终端,使用 -o 参数后则输出到指定文件)。当然即使没有 -o ,gcc 也有默认行为,如果不进行流程控制,默认走完所有流程,若此时没有-o 则自动生成.out结尾的与源文件同名可执行文件。使用 -E 进行流程控制时,若此时没有 -o 则将处理结果输出到终端。如果使用 -S -c 进行流程控制,则自动生成与源文件同名的扩展名分别为 .s .o的 中间文件。

  4. GCC的 源文件 和 目标参数的先后顺序并无要求,源文件可以位于任何位置,但是目标文件前一个参数必须是 “-o”(当然如果不显式指定,则按照默认行为处理),但是这不包含链接阶段,链接阶段应该确保,被依赖的文件位于依赖那个文件的文件之前。因为GCC默认从左向右处理。如果不这样做,会出现未定义问题。

  5. 另外就是 你有的时候会看到 有人使用 CC 而不是 GCC,不要怀疑,这里的 CC 就是指代 GCC。

上边是最最基本的用法,这里并没有附上静态库,动态库的使用方法,等到需要的时候我再提到,现在先了解这么多即可。

Makefile 基本写法

TARGET:PREREQUISTES
    COMMAND #这里因为Tab字符无法显示,所以我换为了4个空格表示缩进
  1. target:规则的目标。通常是最后需要生成的文件名或者为了实现这个目的而必需的中间过程文件名。可以是.o文件、也可以是最后的可执行程序的文件名等。另外,目标也可以是一个make执行的动作的名称,如目标“clean”,我们称这样的目标是“伪目标”。

  2. prerequisites:规则的依赖。生成规则目标所需要的文件名列表。通常一个目标依赖于一个或者多个文件。makefile规则是否执行的关键就是看依赖的修改日期是否比目标修改日期更迟。

  3. command:规则的命令行。是规则所要执行的动作(任意的 shell 命令或者是可在shell 下执行的程序)。它限定了 make 执行这条规则时所需要的动作。

  4. 总体而言,规则包含了文件之间的依赖关系以及更新此规则目标所需要的命令。而每一个命令必须以Tab 字符开始,Tab字符提示make这一行是一个命令行。当然一个Makefile 文件中还包含了除了规则以外的其他东西,但是最简单的Makefile文件只包含规则。

#sample Makefile
# 在这个Makefile中,我们的目标(target)就是可执行文件“edit”和那些.o文件\
(main.o,kbd.o….);依赖(prerequisites)就是冒号后面的那些 .c 文件和 .h文件。


edit : main.o kbd.o command.o display.o insert.o search.o files.o utils.o
    cc -o edit main.o kbd.o command.o display.o insert.o search.o files.o utils.o

main.o : main.c defs.h
    cc -c main.c

kbd.o : kbd.c defs.h command.h
    cc -c kbd.c

command.o : command.c defs.h command.h
    cc -c command.c

display.o : display.c defs.h buffer.h
    cc -c display.c

insert.o : insert.c defs.h buffer.h
    cc -c insert.c

search.o : search.c defs.h buffer.h
    cc -c search.c

files.o : files.c defs.h buffer.h command.h
    cc -c files.c

utils.o : utils.c defs.h
    cc -c utils.c

clean : #清除目标文件和所有的中间文件
    rm edit main.o kbd.o command.o display.o insert.o search.o files.o utils.o

从这个简单的案例,首先应该学习到:

  1. 通常来说,.o 文件数目和 .c 文件数目保持一致,并且名字可以保证一一对应,这是因为,通常来说一个.c 文件是一个编译单元。如果你很好的学习了C语言的作用域相关知识点,这一点不难接受。

  2. 而 .o 文件的依赖应该包含:其对应的.c 中从外部引用的文件以及 .c 文件自身,并且出现在依赖的文件都应该是自己手写的,而且不包含标准库,这是因为,标准库并不会发生修改,执行构建命令时,链接是自动进行的,即使不加入依赖也不会有任何影响。或者说即使加入依赖也没有意义 - 根据我们之前的分析,目标和依赖的作用就是用来判断是否有必要执行这一条规则,即使没有目标和依赖,只要按一定的顺序执行命令,也可以得到正确的输出文件。而标准库注定不发生变化,因此也不可能对是否执行这一条规则这件事情产生影响,所以写上去并没有意义,而且由于不在同一个目录,如果硬写的话,可能会徒增不小的劳动量。

  3. make程序会把出现在两条规则之间的所有以 Tab 字符开始的行都作为命令行来处理。但是存在一些规则没有命令行。还有一些规则没有依赖,只有 “目标” ,这个 “目标” 不是一个文件,仅仅代表一个动作的标识,而这样的规则需要在命令部分实现这个动作。Makefile将这样的没有任何依赖只有动作的目标称为 “伪目标” 。

  4. 我们代入一个比较常见的视角,假设我们第一次构建完毕,现在有的应该是 edit 时间晚于所有的 .o 文件,晚于所有的 .c .h 文件,加入我在生成之后修改了 def.h 那么 def.h 的时间就要晚于 任何一个 .o 和 我们生成的 edit文件。我们执行了edit规则之后,首先看看依赖,看看这些依赖是否已经是最新的状态,假设当前检查到 依赖 utils.o ,为了确保utils.o 已经处于最新状态,我们找到了目标为 utils.o 的规则,发现 defs.h 要晚于 utils.o ,所以 utils.o 并不是最新的状态,所以我们就需要执行目标为utils.o 的规则下的命令,现在 utils.o 的时间戳也修改了,继续判断下一个依赖是否处于最新状态 ... 当确认所有依赖为最新状态后,由于至少有一个依赖的时间 晚于 edit的时间,所以我们需要重新执行目标为 edit的规则。

Makefile 写法优化

学习完上边的知识之后,我们已经可以完成最简单的项目的多文件编程,但是这种写法虽然可以使用,但是仍然不够好,比如我们注意到命令和依赖是有重复书写的成分的,是否有办法简写。还有就是utils.o 一看就依赖utils.o 这样显而易见的依赖关系是否可以不用标注?好在Makefile语法编撰者已经想到这个问题了。这两个疑问分别对应Makefile 语法中的 ”指定变量“ 和 “自动推导”。

## 原始写法 ##
edit : main.o kbd.o command.o display.o insert.o search.o files.o utils.o
    cc -o edit main.o kbd.o command.o display.o insert.o search.o files.o utils.o

.PHONY : clean
clean :
    rm edit main.o kbd.o command.o display.o insert.o search.o files.o utils.o


## 使用变量 ##
objects = main.o kbd.o command.o display.o insert.o search.o files.o utils.o

edit : $(objects)
    cc -o edit $(objects)

.PHONY : clean
clean :
    rm edit $(objects) #注意这里的 edit 和 $(objects) 直接一个空格相隔,就像是拼接在了一起一样。

makefile 允许你创建变量,这里的变量和编程中的变量有所不同,它并不对应着一块可以读写的内存空间,它更像是一连串字符的标识,说是C语言中的宏或者更加合适。在执行的时候,这里的变量会被自动 “替换” 或者说 “解析为” 这个 “变量的值” 。

除此之外,Makefile 还提供了简写方法,因为对单个.c 文件进行编程的时候,比如编译utils.c ,我们往往并不写 “-o” 指定名字,编译器提供了默认行为,对于 CC -c utils.c 这一条指令,gcc 会自动创建 utils.o,而不需要我们指定名字,这是gcc的默认行为。make 最早就是linux平台下的工具,所以针对 gcc 的默认行为设计了make 的默认行为,当目标为 name.o 的时候,自动将name.c 作为依赖项,并且自动使用正确的命令重建name.o(你不需要手动添加命令了)。这种”自动推导“,是隐含规则的一种,表示并不是由用户创建的规则,而是内置在make并没有显式暴露在makefile中的规则,结合我们之前的经验,我们不难发现,此时单单从字面上看,Makefile 目标为 .o 文件的规则,它的依赖是不包含 .c 文件的。或者说大部分情况下,是这个 .o 文件所引用的有我们手动书写的 .h 文件。

# sample Makefile
objects = main.o kbd.o command.o display.o insert.o search.o files.o utils.o
edit : $(objects)
    cc -o edit $(objects)

main.o : defs.h

kbd.o : defs.h command.h

command.o : defs.h command.h

display.o : defs.h buffer.h

insert.o : defs.h buffer.h

search.o : defs.h buffer.h

files.o : defs.h buffer.h command.h

utils.o : defs.h

.PHONY : clean
clean :
    rm edit $(objects)

而makefile语法撰写者,还要更加深思熟虑一点,它敏锐的观察到了另一种风格的写法,上边我们不是已经指明了 .o 文件的依赖应该全部都是 .h 文件吗,现在单独看一条规则,规则的大致情况就是 “一个目标 多个依赖” 并且这些依赖是可以重复的。我们是否可以将这些重复书写的依赖提取出来,然后让所有具有这个依赖的目标写在一起?我这样说你可能并不明白,所以我将这种写法放在下方:

#sample Makefile
objects = main.o kbd.o command.o display.o insert.o search.o files.o utils.o

edit : $(objects)
    cc -o edit $(objects)

$(objects) : defs.h
kbd.o command.o files.o : command.h
display.o insert.o search.o files.o : buffer.h

.PHONY : clean
clean :
    rm edit $(objects)

我们现在可以从数学上思考这个问题,从道理上说,第一种写法,是一个源文件还有他的多个依赖构成规则的上部,后一种写法是一个依赖项和多个依赖这个依赖项的源文件构成规则的上部。前者我们可以确认,规则的个数应该就是源文件的个数 + 1,后者我们也可以确认,规则的个数恰好就是头文件的个数 + 1.所以那种写法最节省笔墨,自然是看源文件和头文件的多少而有所不同,A依赖B的时候,B同时也被A依赖,无论以谁为叙述主题,这样的 “依赖/被依赖” 关系的数目是相等的,而一条规则就是将拥有相同的 依赖或者被依赖对象的关系合并到一行,试想我们不进行和并,那么总共要书写的恰好就是关系的总数 * 2,因为一段关系有两端。而我们想要尽可能的减少书写量,就要做到尽可能的合并,因为每多合并一次,就可以少写一次端点。而在关系总数确定的情况下,关系整体中,总数较少的那侧端点是需要进行最多的合并的。当然这样的“数学的思考”,开发中遇不到就是了,因为现在的Makefile都是自动生成的。

如果你观察的十分仔细,你会发现,我给出的标准配置都附上了伪任务 “clean”,这是因为一个一个的手动清理十分的麻烦,我们在传递代码的时候,不需要中间文件,但是确认代码可用的时候难免需要创建中间文件,而手动删除中间文件确实太傻太浪费时间。在C/C++中,我们的中间文件是 .o 文件和二进制文件。

.PHONY : clean
clean :
rm edit $(objects)

这一部分还是十分值得说道说道的,你可能会好奇为什么我们需要 .PHONY ?.PHONY 是Makefile中的一个特殊规则,Makefile 中有很多特殊规则可以调整make解析时的行为,当然我大可以直接说清楚,有了 PHONY 会有什么变化,但是我还是感觉此等设计过于精妙,打算分析一下。

从形式上看,一条规则表示的是,当两个条件之一被满足的时候,执行规则中的命令( ”目标文件不存在“ 或者 “目标文件生成之后对依赖文件进行了修改” ,注意在第二个条件中,首先要检查是否所有依赖都是最新的状态)。make如果检查到目标文件不存在就没有必要依次查找目标为依赖文件的规则了,这有利于大幅降低解析的时间。从设计的角度上看,make也必然采取这样的实现:只有当目标文件已经存在的时候,才进行时间戳的比较逻辑。如果目标文件不存在,就直接执行规则中的命令。

Make 利用这样的特性开发了伪规则的用法,当我们尝试构建 “clean” 的时候,因为 clean 不存在,所以make直接就执行 clean 规则下的命令了,而这里的命令恰好就是清除中间文件的命令。因为用不到目标后的依赖部分,并且即使添加依赖也没有任何意义,所以依赖部分是空的也无所谓。但是因为伪规则实际上是依赖普通规则的构建逻辑,只是编写makefile的人约定伪规则的目标不存在,从而按照普通规则的解析逻辑,伪规则的命令部分就必然执行。这样一来看起来天衣无缝了,但是实际上,makefile 在执行完命令后,会自动 touch 目标为一个文件(默认情况下,所有的目标都假设为一个文件,无论是否有扩展名,什么扩展名),touch命令的特点就是·,如果文件存在,那么就是更新时间戳,如果文件不存在,就创建那个文件。只要执行了命令就必然 自动touch 一次被认定为文件的目标。

为什么要有 touch ?很简单,假如目标为文件的规则执行完命令后,按照用户的指令并没有成功生成或者更新文件,会出现什么问题?很显然,由于目标文件时间戳没有修改或者说没有目标文件没有被创建,那么后面即使目标文件的依赖没有发生任何修改,由于目标文件的时间戳太久或者说目标文件没有被创建,这一条规则就需要被重复执行,而这一条规则的命令部分是无效的。现在我们好好思考一下,我们上边设计伪规则的时候,是不是刚好就契合了这一点,所以按照自动touch的性质,这个必然不存在的文件即使执行构建的命令并不能创建,也会自动的touch这个必然不存在的文件。当我们下一次执行命令的时候,由于 clean 文件已经存在,所以开始检查时间戳,但是依赖部分为空,依赖文件为空表示,所有依赖都是最新的状态,并且没有发生修改,所以命令就不会执行了。

而超乎预期的行为的根本原因就是自动 touch 的特性,虽然目标为文件的时候,自动touch 可以很好的避免重复执行无效的命令,但是当我们的目标是一个动作的时候,自动 touch 会引入不必要的文件以及使得动作无法执行。所以makefile 语法就专门提供了特殊的标记,phony 这个单词表示伪造的,通过.PHONY:clean 就表示 目标为 clean 的规则是一个伪规则,标记为伪规则的规则不再执行自动touch,从而杜绝了引入不必要的文件的情况,并且 make 还会在执行伪规则的时候,跳过文件检查的逻辑(目标文件是否存在,时间戳比对等),直接执行 clean 命令。所以如果一些人和你说伪规则无需 .PHONY 直接说不对就可以了,这会引入额外的不必要文件。

总结

看完这一篇文章,你需要对Makefile的基本语法有所了解,认识到,Makefile的核心就是规则,而规则分为普通规则和特殊规则,普通规则用于构建文件,特殊的规则用于调整 make 遇到特殊规则的依赖列表后的行为。普通规则因为需要生成文件,所以往往需要带有command,而特殊规则作为 “配置项” 通常无需command ,“.PHONY : clean” 就是一个最简单的特殊规则,他告诉make: clean 是一个伪规则,遇到 目标为 clean 的规则,应当忽视文件是否存在,并且不进行默认的 touch 命令。

然后你也需要了解,在Makefile中,普通规则执行的流程,或者说任何规则执行的流程,简而言之 “目标 : 依赖”在含义上表示,当依赖做出修改后,目标必须要重新生成,而为了确认依赖是否发生了修改,必须执行一遍目标为依赖的规则。所以,一条规则虽然含以上是制定了依赖关系,但是我们更加需要注意的是规则更关键的是限定了命令执行的顺序。如果一个规则只有目标和依赖,那么你可以理解为,这个规则实质上是按照从左到右的顺序依次执行子规则。也就是实际上实现了一个Makefile 管理多个项目的能力。

.PHONY:all
all: prog1 prog2 prog3

prog1:mian1.o
prog1:mian2.o
prog1:mian3.o