下一篇文章:make选项和makefile.build
使用gcc选项可以提升编译的效率并降低编译的复杂度,然而这还仍然伴随着重复且复杂的指令输入。那么能不能像写程序一样将要编译的内容也写到一块,然后要编译只需要一条简单的指令就可以完成呢?这就是本文章要学习的内容
1. Makefile规则
Makefile的核心为规则,其基本格式如下:
target : prerequisites
command #注意前面的Tab缩进一定要有
其中target是目标文件或伪文件(例如clean、all);prerequisites是生成目标文件所依赖的文件,可以是源代码、库文件等;command通常是要执行的命令。
在执行的过程中,会先比较依赖文件是否比目标文件更新或者判断目标文件是否不存在,如果是则执行命令。这样就可以实现避免未改动文件被重复编译的情况。我们可以使用make target
来指定要执行的目标,如果缺省target,则默认执行第一个目标。
假设我们目前文件目录下存在test.c、func.c、head.c、func.h四个文件,我们需要编译链接这四个文件生成可执行结果。按照上述规则,写成的makefile内容如下:
res : test.c func.c head.c
gcc -o res test.c func.c head.c #注意command的命令是给终端执行的
clean : #清除编译过程中生成的.o文件和结果文件,不过这个makefile并没有生成.o文件
rm *.o res -f #-f表示force强制删除
然而makefile比较简单粗暴,并没有利用到避免重复编译的优点,效率较低。下面我们对makefile进行改进
res : test.o func.o head.o #res会依赖于这三个.o文件,而这三个.o文件会由下面的指令生成
gcc -o res test.o func.o head.o
test.o : test.c #生成test.o
gcc -c -o test.o test.c
func.o : func.c
gcc -c -o func.o func.c
head.o : head.c
gcc -c -o head.o head.c
clean :
rm *.o res -f #-f表示force强制删除
这样改进保证了未改动的文件不会被重复的编译,这个可以通过执行make指令来查看,如下:
#第一次执行make,终端输出信息如下
gcc -c -o test.o test.c
gcc -c -o func.o func.c
gcc -c -o head.o head.c
gcc -o res test.o func.o head.o #对所有的文件进行了编译
#不对任意文件做修改,再次执行make,终端输出信息如下
make: “res”已是最新。 #相当于不做任何处理
#此时修改test.c文件,再次执行make,终端输出信息如下
gcc -c -o test.o test.c
gcc -o res test.o func.o head.o #可以看到只对修改的文件进行了重新编译
虽然这样提升了执行的效率,然而我们在makefile中给每一个.o都用了两行语句,如果文件数很大,那么整个makefile就会显得特别臃肿和啰嗦。为了优化makefile的内容结构,下面开始讲解makefile中的变量规则
2. 变量
(1)全局变量
我们可以在makefile文件中定义一些全局变量,在多个地方引用优化程序结构,例如
CC = gcc
CFLAGS = -c -o
定义好后,在使用这些全局变量时,通过$()的形式引用就可以:
gcc -c -o test.o test.c
#上面可以简化为下面的格式
$(CC) $(CFLAGS) test.o test.c
(2)自动变量
Makefile 提供了一些自动变量,用于在规则中引用目标和依赖文件:
- $@:表示当前规则的目标文件名
- $<:表示当前规则的第一个依赖文件名
- $^:表示所有依赖文件名列表
什么意思呢?下面给出一个例子来说明:
#$@就是test,$<就是test.c,$^就是test.c hello.c
test : test.c hello.c
gcc -o test test.c hello.c
#这样上面这个规则就可以简化为
test : test.c hello.c
gcc -o $@ $^
(3)通配符
通过上面的学习,我们可以实现对单个规则的简化。然而一个makefile往往具有很多规则,单个规则的简化仍然不能显著降低内容的繁琐度。前面的学习我们知道了规则之间是有依赖的,有没有办法利用这种依赖关系来简化整个makefile结构呢?答案是肯定的,这就是本节要介绍的通配符。
在makefile中,通配符是%
,作为模式规则使用,它可以匹配多个不同的文件,类似于终端中的 *
号。当触发某些依赖时,若满足通配符的匹配要求,则自动展开并执行相应的规则。这段描述比较抽象,下面我们通过一个详细的例子来讲解:
res : test.o func.o head.o
gcc -o res test.o func.o head.o
%.o : %.c #前面的%用于通配符匹配,后面的%是直接引用前面匹配的结果
gcc -c -o $@ $^
make res
发现 res
依赖 test.o
、func.o
和 head.o
,于是尝试寻找它们的规则。test.o
需要由 test.c
编译生成,但 Makefile 中并没有直接定义 test.o : test.c
规则,而是使用了模式规则 %.o : %.c
。%.o
可以匹配 test.o
,同时 %.c
也会对应 test.c
,于是该规则展开为:
test.o : test.c
gcc -c -o $@ $^
对于func.o 和 head.o也是同理,这样我们利用一个通用规则 %.o : %.c
,避免了为每个 .c
文件单独编写 .o
生成规则,使 Makefile 更加简洁和易维护!
(4)基于变量的 makefile内容改进
上面我们学习了makefile的变量知识,就可以基于该内容进一步对第1节中的结构进行优化,优化结果如下:
CC = gcc
CFLAGS = -c -o
res : test.o func.o head.o
$(CC) -o $@ $^
%.o : %.c
$(CC) $(CFLAGS) $@ $^
clean :
rm *.o res -f #-f表示force强制删除
3. 函数和指令(头文件检测)
上面的学习我们完成了对makefile的优化,但优化后程序还是缺少了一个关键部分,那就是——头文件检测。简答来说,就是上述程序无法识别.h文件修改,或者说一个.c的要引用的头文件被修改后,这个.c并不会被重新编译。
聪明的你可能会想到,我直接按照上面通配符的方式添加依赖不就行了,如下:
#对第二个规则修改如下
%.o : %.c %.h
$(CC) $(CFLAGS) $@ $^
这在部分情况下确实是可行的,即所有的.c文件只包含自己的.h文件(C标准库除外),但这在实际的工程往往是罕见的。实际上,一个.h文件往往会被多个.c文件引用,上述规则于是便失效了。
一个简单的方法是手动添加依赖,如下。这在同样文件数量较小的时候是没问题,然而当工程文件数量特别多的时候,这么做就会非常麻烦!
test.o : func.h
func.o : func.h
PS:为什么不是func.c : func.h
?
答:前面我们学习makefile规则时,介绍了makefile规则冒号前面的是目标文件,也就是我们想要编译生成的目标文件。而test.c是源文件,是已经存在的文件。或者说func.o : func.h
的意思是只要func.h发生了变化,就要重新编译生成func.o(对于上面,也同样要重新编译生成main.o)。然而func.c并不是要生成的文件,它只是源代码。
那么能不能让程序自动检测头文件,从而避免我们的手动添加呢?答案是肯定的!不过在开始优化之前,我们需要先再学习一个makefile的知识点:函数和指令。
在 Makefile 中,函数用于处理字符串、文件路径、条件判断等,提高Makefile的灵活性和自动化程度。Makefile 提供了内置函数,可以像 $(func arg1, arg2, ...)
这样调用。下面开始介绍一下makefile的常用函数
(1)foreach:$(foreach var , list, text)
该函数用于将list下每一个元素取出来赋值给var,然后把var改成text描述的形式,例如:
FILES = test func head
OBJS = $(foreach var, $(FILES),$(var).o) #相当于一个循环,var = FILES[i],然后再var = $(var).o,最后OBJS[i] = var
#执行结果:OBJS = test.o func.o head.o
(2)wildcard:$(wildcard pattern)
该函数用于判断pattern所列出的文件是否存在,并把存在的文件都列出来。或者说获取所有符合pattern格式的文件列表(检索都是在当前目录)。例如:
#假设文件目录下有test.c func.c head.c
#(1)判断所列文件是否存在
FILES = test.c func.c head.c other.c
SRC = $(wildcard $(FILES))
#执行结果:SRC = test.c func.c head.c
#(2)获取所有符合pattern格式的文件列表
SRC = $(wildcard *.c)
#执行结果:SRC = test.c func.c head.c
(3)filter:$(filter pattern…, text)
该函数用于从text中筛选符合pattern格式的部分,将筛选出来的内容保留下来,例如:
FILES = test.o func.c head.c func.h test.c
RES = $(filter %.c, $(FILES))
#执行结果:RES = func.c head.c test.c
PS:为什么这里要用%.c
而不是*.c
?*
和%
到底什么时候用的呢?
答:*
是终端使用的通配符,而%
是makefile使用的通配符。由于filter进行的是内部的格式匹配(因为这里FILES是内部定义的内容),故这里应用使用。而上面wildcard
由于要使用外部的文件信息,即当前文件目录的文件内容,故需要使用*
用于匹配。当我们要引用外部的文件信息时,通配符就需要使用*
;如果只是内部定义的信息,在使用通配符时就使用%
。或者再简单来说,函数中只有wildcard在匹配使用使用*
,其余的函数的匹配使用的都是%
。_
(4)filter-out:$(filter-out pattern…, text)
该函数用于从text中筛选符合pattern格式的部分,将筛选出来的内容剔除出去,和上面的函数是反着来的。例如:
FILES = test.o func.c head.c func.h test.c
RES = $(filter %.o %.h, $(FILES)) #这里设置了两个模式
#执行结果:RES = func.c head.c test.c
(5)patsubst:$(patsubst pattern, replacement, text)
该函数用于将text中匹配pattern的部分替换为replacement的格式,这么说有些抽象,下面用一个例子介绍:
FILES = test.o func.c head.c func.h test.c
RES = $(patsubst %.c, %.o, $(FILES)) #将符合%.c的内容变成%.o的形式,例如head.c -> head.o
#执行结果:RES = test.o func.o head.o func.h test.o
(6)subst:$(subst from, to, text)
该函数用于将text中的from替换为to,和上面patsubst的区别subst是局部的判断,而patsubst是全局的判断。例如给定一个.c,subst会发现head.c中存在.c的子串,并将该.c替换为.o从而变成test.o。而patsubst会发现没有任何一个字符串是.c,不做任何处理。下面是一个例子:
FILES = test.o func.c head.c func.h test.c
RES = $(patsubst .c,.o,$(FILES)) #将字符串中的.c的内容变成.o,例如head.c -> head.o
#执行结果:RES = test.o func.o head.o func.h test.o
需要注意的是subst函数无法使用通配符进行匹配。
(7)info:$(info text)
该函数用于在makefile执行时输出信息,相当于printf
,但不会影响目标的构建过程,常用于调试、日志记录、或者显示一些关键信息。例如:
FILES = test.o func.c head.c func.h test.c
all :
$(info FILES = $(FILES)) #info第一个空格后面的全部都是要输出信息
#执行make后,终端输出:FILES = test.o func.c head.c func.h test.c
(8)shell:$(shell command)
该函数用于执行shell端命令,并返回其输出,例如:
A = $(shell date) #此时date就为要执行的shell命令,并返回其结果,即电脑时间
#A = Mon Feb 10 08:30:08 EST 2025
A = $(shell echo leyun) #此时echo leyun就为要执行的shell命令,其结果显然易见是leyun,返回值leyun赋值给A
#A = leyun
在 Makefile 中,指令用于定义构建过程、指定依赖关系、设置编译选项等。了解一些常用指令可以帮助我们更高效使用makefile。下面开始介绍一些makefile中常用的指令。
(1)ifneq:ifneq (var1,var2)
该指令用于判断两个字符串是否不相等,如果不相等,则执行相应的代码块,类似如C语言的if(a != b)
。下面是一个例子:
FILES = test.o func.c head.c func.h test.c
#注意ifneq和后面的括号之间一定要有一个空格!
ifneq ($(FILES),) #这里第二个参数为空,相当于判断FILES是否不为空,若不为空,则执行下面的命令
$(info $(FILES)) #使用info将FILES输出出来
endif
#运行结果(终端输出):test.o func.c head.c func.h test.c
还有一个ifeq指令,用于var1和var2是否相当,类似如C语言的if(a == b)
,这里不再赘述。
(2).PHONY
.PHONY用于声明伪目标(不对应实际文件的目标,常用于清理、构建等操作),可以避免与文件名称冲突时make指令失效的问题。
什么意思呢?例如我们想执行make clean
来清除编译生成的文件,然而如果此时当前目录中存在一个文件名为clean,则会出现下面的情况:
make: “clean”已是最新。
此时,make并没有去执行clean目标,而是去判断clean的更新情况,而由于该clean文件没有任何依赖项,make会一直认为它是最新的。这并不是我们所期望的,故需要添加如下声明,这样即使目录存在clean的文件,也不会影响make clean的正常执行!
.PHONY : clean
(3)变量赋值指令=
在makefile中,变量的赋值方式一共有四种:=
,:=
,?=
,+=
=
:普通赋值
使用 =
赋值的变量,在引用时会在每次使用时重新求值。
A = $(B)
B = 3
all :
echo A = $(A) #执行结果:A = 3
上面的程序中令A等于B的值,但它并没有立刻进行计算,而是储存了$(B)
。然后紧接着B才被赋值为3。后面echo输出A时,$(A)
被展开成 $(B)
,而$B
等于3,故执行输出的结果为:A = 3
:=
:立即赋值
使用:=
赋值时,变量的值会在赋值时立即计算并固定,不会在后续使用时重新计算。
A := $(B)
B = 3
all :
echo A = $(A) #执行结果:A =
在上面的程序中,A立即对B进行了解析,由于此时B还没有被赋值,故A为空,且由于不会后续使用时重新计算,故输出结果为:A =
?=
:条件赋值
如果变量没有被定义,则使用 ?=
赋值;如果已经定义了该变量,则不会改变其值。
A ?= 2 #此时A还没有被定义,故A被赋值为2
A ?= 3 #此时A已经被定义了,故A的值不变
all :
echo A = $(A) #执行结果:A = 2
+=
:追加赋值
将新的值追加到已有变量的末尾。
A := 2
A += 3 #将3添加在A的后面,变成2 3
all :
echo A = $(A) #执行结果:A = 2 3
(4)include
该指令其用法有两种:
include 文件名1 文件名2 ... #用法1:如果指定的文件不存在,会报错并终止make运行。
-include 文件名1 文件名2 ... #用法2:如果文件不存在,不会报错,make仍然继续执行。
include可以用于包含其他的makefile文件,一般用于拆分大型的Makefile。需要注意的include在包含其他mk文件时,相当于直接把对应的mk文件内容插入到当前makefile中,在包含时需要注意目标被覆盖的问题。例如目前存在两个待包含的mk文件:
#config.mk的文件内容如下
CFLAGS = -o
#rules.mk的文件内容如下
clean :
rm -f *.o main
如果写成如下的格式,则整合后可以发现clean变成第一个目标了,直接make就会执行clean的目标,这是我们所不期望的。
include config.mk rules.mk
main : main.c
gcc $(CFLAGS) $@ $^
#则整合后,makefile内容如下:
CFLAGS = -o
clean :
rm -f *.o main
main : main.c
gcc $(CFLAGS) $@ $^
正确的使用方式如下:
include config.mk
main : main.c
gcc $(CFLAGS) $@ $^
include rules.mk #rules.mk需要放到main目标的下面, config.mk也可以放到这个下面
此外,include还可以自动生成依赖,这个可以用于我们本节的目标,即自动头文件检测。但生成依赖的前提是我们需要有依赖(xxx.d)文件,或者说我们需要知道一个.c文件都引用了那些头文件(这里不考虑C库)。很幸运的是我们可以在gcc编译时添加一些选项实现这一点,即:
gcc -Wall -g -MMD -c -o test.o test.c #最关键的选项是-MMD,前面两个选项是调试相关,可以省略
执行上述命令后,可以在本地生成test.d文件,里面即保存了test.o依赖的文件信息,这也是之前我们手动添加的信息。下面只需要利用include指令将.d文件的内容插入到makefile中就可以实现头文件的自动检测!
#test.d内容信息,假设test.c引用了"func.h"的头文件
test.o: test.c func.h
在上面学习函数与指令时,我们基本已经知道如何实现头文件的自动检测了。根据据上面的学习,我们可以将前面的makefile程序进一步优化:
CC := gcc
CFLAGS := -Wall -g -MMD -c -o
#objs := test.o func.o head.o #如果不是所有的.c文件需要编译,则还是需要手动标注,但一般都是所有.c都要编译的
objs := $(wildcard *.c) #由于objs在这两行中不断的计算改变,故使用:=(立即赋值)更好一些
objs := $(patsubst %.c,%.o,$(objs)); #将所有的.c改成.o
test : $(objs)
$(CC) -o $@ $^
#判断是否存在依赖文件
dep_files := $(wildcard *.d)
ifneq ($(dep_files),)
include $(dep_files) #将依赖的关系添加到makefile中
endif
%.o : %.c
$(CC) $(CFLAGS) $@ $<
clean:
rm -f *.o test
distclean:
rm -f *.o *.d test
至此,我们对上述makefile程序的优化就基本结束。本文我们以对一个makefile文件优化为线索,串讲了一下makefile的基本知识点,并给出了易错知识的提醒,相信大家看到这里一定对makefile有了基本的认知,能够写出具有自动化的makefile程序!
但这就完了吗?不不不,makefile还有一个很关键且易错的内容还没讲,那就是缩进!不要小看这个索引,很多时候makefile报错很有可能就是缩进问题!
4. 小小缩进,大大不同
在makefile中,加缩进的语句和不加缩进的语句在执行时,有明细的区别!下面这段程序在执行时会报错,存在多个错误,你知道都有哪些吗?为了搞懂整个问题,下面我们开始讲解缩进在makefile中的作用。内容不多,但很重要!
FILES = main.c
ifneq ($(FILES),)
echo leyun
$(info debug)
include $(dep_files)
endif
(1)不加缩进的语句
在makefile中,变量定义、规则定义、指令以及注释都是不需要缩进的,如下:
# 变量定义
FILE = main.c func.c
# 规则定义
main.o : main.c
# 指令
include $(dep_files)
ifneq (var1,var2)
endif
# 注释,也就是我自己~~~不过写到某一个语句的后面是没问题的!
(2)加缩进的语句
加缩进的语句执行时会被当做shell(也就是终端)的命令指向,例如我们规则下的命令都需要使用Tab缩进。若不是用则make时会报错如下。
*** 遗漏分隔符 (null)。 停止。
shell命令都必须放在规则下,这就意味着在makefile中,只有规则下的shell命令才需要缩进,其他地方都不需要缩进。
下面我们回过头来看本节开始提出的问题,我们就可以很容易的发现上述程序的问题:
FILES = main.c
#由于ifneq并不是规则,make解析时并不会将echo试做shell命令,而make本身并没有echo的关键字,从而导致报错!
ifneq ($(FILES),)
echo leyun #shell命令不应该位于ifneq下,删除!
$(info debug) #本行和下行都是makefile的指令,前面不需要缩进,去除缩进
include $(dep_files)
endif
修改后的程序如下,此时程序在执行时就没有问题了!
FILES = main.c
ifneq ($(FILES),)
$(info debug)
include $(dep_files)
endif
上述缩进有时候不符合要求,makefile也能执行,但还是建议严格按照缩进的要求去写,给自己少留麻烦捏~~~
到这里makefile的入门内容算是基本上讲的差不多了,希望能对看到这里的大家带来学习上的帮助!