前言
最近常常被一些问题困扰
- 如何命名变量
- 花括号放在行末还是独立成行
- 写Python是否要随身携带游标卡尺
- 如何从0构建一个C/C++项目
经过漫长的思考,我发现前三个问题过于困难,那么今天我只好向大家介绍一些第四个问题的内容,也就是nix下常用的构建控制工具make。
在nix平台上分发的程序源码往往带有makefile文件,make就是根据这一文件的指令完成程序编译的各项任务。
当然,如果客官认为纯手工敲出来的命令构建出的程序才是有灵魂的程序,那我只能为您的耐力点赞。
本文希望能够给出一个较为通用的makefile,并介绍与之相关的编写规则。具体而言,期望达到的目标是
- 适应多目录的组织结构
- 可以按需编译
- 通过简单的修改就可以应用于不同的项目
文中的任何疏漏错误欢迎大家指正。
所有makefile代码在mingw32-make下测试通过,编译器使用g++
C/C++ 程序构建流程
C/C++的编译流程简化来看可分为两个阶段,将源码编译为*.o文件,再链接为可执行文件,即
源码(*.cpp)-> *.o -> 可执行文件
一个典型的端到端编译命令为
g++ main.cpp -o main.exe
main.cpp为源文件名,-o为目标文件名
多文件编译
多文件编译需要首先将各个源文件编译为.o文件,而后再进行链接成为最终的可执行文件
g++ -c add.cpp -o add.o
将-c后文件编译为.o文件,即已编译为二进制代码,但尚未链接
g++ main.o add.o -o t.exe
将main.o和add.o以及系统提供的库链接为最终可执行文件
0级
基本格式
target: prerequisitescommand
makefile就是由多条类似于上述结构的“规则(Rules)”构成,target是目标,prerequisites是一系列构建target所需要的前置条件(通常为文件)。make程序会检查每一个target,如果target不存在,或者target的修改时间早于前置文件(即前置文件有修改)就会执行command中的命令。注意,command前必须为一个tab。
add.exe:g++ main.cpp
add.exe: main.og++ -o add.exe main.omain.o: main.cppg++ -c -o main.o main.cpp
上述makefile将main.cpp依次编译为obj文件和最终可执行文件。
伪目标
目标并非实际文件时,它就成为了所谓的“伪目标”。当仅执行make命令时,伪目标会被忽略,只有在显式指定为目标时,伪目标的命令才会被执行。例如:
clean:rm -f *.o
执行make clean会执行clean中定义的命令,即删除所有obj文件。通常伪目标都是用于清理编译中间文件,完整重编译等工作。
还可以使用.PHONY将某一目标显式指定为伪目标。
.PHONY: cleanclean:rm -f *.o
在不致混淆的情况下,后文会省略.PHONY
1级
使用变量
在makefile中可以定义变量
cc = g++
定义cc为g++
使用变量的语法为$(varname)
add.exe: main.o add.o$(cc) -o add.exe main.o add.o
这里,我们可以简单理解makefile中的变量就是进行替换(当然实际并不仅限于此)
自动推导
看下面这个例子(注意这里变量的应用)
objs = main.o add.oadd.exe: $(objs)g++ -o add.exe $(objs)
这里文件夹的结构是
project|--main.cpp|--add.h+--add.cpp
执行make命令后,会发现add.exe被正确生成了,而我们并没有指定main.o和add.o的生成规则,这就是make的自动推导——make程序会根据隐含规则生成构建命令,在这里,make会将main.o包含在构建目标中,同时自动生成其前置文件依赖,即main.cpp,add.o同理。这样,我们只需要一条构建规则就可以完成项目的构建,大大减轻编写构建目标的工作量。
然而,这里我们也应当注意到,make会根据.o文件的文件名推断源文件的文件名,如果源文件名与.o文件的文件名不一致,make就无法根据规则找到源文件。
这里同样要注意路径的问题,若prerequisite的文件包含路径名,make无法实现自动推导。
这些隐含规则能也有自定义的空间,但我们暂时还是关注当下主要的问题,看看多文件的工程该如何管理。
2级
多目录结构
对于复杂的工程而言,不可能把所有源文件和依赖文件都放在项目根目录上,make也也提供了相应的功能,下面是示例文件夹结构:
project|--include| +--add.h||--obj|+--src|--main.cpp+--add.cpp
手工指定结构
target和prerequistes本身可以包含路径名,但prerequistes中若包含路径名,target中相应文件应与其一致
add.exe: ./obj/main.o ./obj/add.o #1g++ -o add.exe ./obj/main.o ./obj/add.o./obj/add.o: ./src/add.cpp ./include/add.h #2g++ -c ./src/add.cpp -o ./obj/add.o./obj/main.o: main.cpp ./include/add.h #3g++ -c main.cpp -o ./obj/main.o
这里 #1 的./obj/main.o与 #2 的target一致,./obj/add.o与 #3 的target一致。
vpath(小写)
一一指定每个文件的路径非常繁琐,因此,make程序提供了更为强大的语法来实现这一功能。
vpath %.h ./include # 在include中搜索.h文件vpath %.o ./obj # 在obj中搜素.o文件vpath %.cpp ./src # 在src中搜索.cpp文件
这里三条命令的含义是对相应后缀的文件,在指定文件夹中搜索相应文件
注意:这一搜索规则只在target,prerequisite中有效,在command中无效,例如
vpath %.h ./includevpath %.o ./objvpath %.cpp ./srcadd.exe: main.o add.og++ -o add.exe main.o add.omain.o: main.cpp add.hg++ -c main.cpp -o ./obj/main.oadd.o: add.cpp add.hg++ -c add.cpp -o ./obj/add.o
会提示无法找到main.cpp,这表明make正确解析了target,prerequisite目标与依赖关系,但并没有在command中体现出来
那多文件夹结构究竟该如何?
自动化变量
我们发现有些很多情况目标、前置条件和命令中有大量重复的内容,那么有没有一种方法可以简化呢?make提供了自动化变量来实现这一要求。
$< # prerequisites 中的第一个文件$^ # prerequisites 中的所有文件$@ # target 的文件名$* # 不包含后缀的 target 文件名
利用上面这几个变量,让我们重写一遍代码
vpath %.h ./includevpath %.o ./objvpath %.cpp ./srcadd.exe: main.o add.og++ -o add.exe $^ # 从全部依赖文件生成目标文件main.o: main.cppg++ -c $< -o $@ # 从第一个依赖文件($< 亦即 main.cpp)生成目标文件 ($@ 亦即 main.o)add.o: add.cppg++ -c $< -o $@clean: # 伪目标,清理中间文件和最终可执行文件rm -f add.exe *.o
make后输出
g++ -c ./src/main.cpp -o main.og++ -c ./src/add.cpp -o add.og++ -o add.exe main.o add.o
不错,路径被正确补齐,目标文件顺利生成。回想之前的自动推导,上面的代码还可以改写如下:
vpath %.h ./includevpath %.o ./objvpath %.cpp ./srcadd.exe: main.o add.o #0g++ -o add.exe $^clean:rm -f add.exe *.o
一行命令打遍天下。
当然,我们不能满足于此,还有几个问题等待我们去解决。
第一,我们现在还需要手动指定要链接的文件(#0),十分繁琐;
第二,生成的中间文件都直接在project即项目根目录下,非常杂乱;
第三,也是最为致命的问题,在自动推导的情形下,makefile并没有追踪头文件的变化,如果仅头文件有了修改,make仍会认为可执行文件是最新的(在非自动推导的情形下可以通过手动指定解决问题)。
下面,我们就来看如何解决这两个问题。
3级
使用函数
make内设了一些用于完成相关处理工作的函数,借助他们,我们可以更为灵活地描述和实现我们的需求,为了便于观察函数的功能,我们使用下面的makefile
# 这里定义变量,使用函数VAR = ./srcall: # make 会默认执行第一个目标,通常都使用 all@echo $(VAR)
执行后会输出./src
基本模式
makefile函数的基本使用模式如下
$(<func_name> <param1>,<param2>,...)
从通配符到文件列表-wildcard
使用通配符匹配文件是非常常用的操作,在makefile中,通配符的使用却需要小心,观察下列示例
VAR1 = ./src/*.cppVAR2 = $(wildcard ./src/*.cpp)OBJS1 = $(patsubst %.cpp, %.o, $(VAR1)) #0 这个函数理解成会进行模式替换,下面会详细介绍OBJS2 = $(patsubst %.cpp, %.o, $(VAR2))all:@echo VAR1 $(VAR1)@echo VAR2 $(VAR2)@echo OBJS1 $(OBJS1)@echo OBJS2 $(OBJS2)
结果输出
VAR1 ./src/add.cpp ./src/main.cppVAR2 ./src/add.cpp ./src/main.cppOBJS1 ./src/*.oOBJS2 ./src/add.o ./src/main.o
直接使用通配符的变量在直接使用的情形下没有问题,但在 #0 处可以发现作为函数参数时并没有正确展开,而VAR2则始终给出了期望的结果。
事实上,./src/*.cpp只有在规则中才会展开,变量VAR1中是按照原样保存,而wildcard在变量赋值时已经进行了展开。
模式替换-patsubst
从上面的例子中我们已经可以看出patsubst函数的功能,其语法是
$(patsubst <pattern>, <replacement>, <text>)
将符合模式<pattern>的字符串替换为<replacement>模式的字符串,<text>中空格分隔的字符串都视为独立的字符串,%可以匹配任何字符,例如
$(patsubst %.c,%.o,x.c.c bar.c)
替换结果为
x.c.o bar.o
若字符串中含有%,可使用转义\%。
取文件名/剔除路径-notdir
$(notdir <names...>)
例$(notdir src/foo.c hacks)返回值是foo.c hacks。
更多的函数可以参考这里
自动处理的另一种方式(综合案例)
文件树
project|--include| |--add.h| +--sub.h||--obj|+--src|--main.cpp+--add.cpp
makefile
DIR_SRC = ./srcDIR_OBJ = ./objDIR_INCLUDE = ./includeSRC = $(wildcard $(DIR_SRC)/*.cpp) # 展开所有源文件名,对应输出 #3OBJS = $(patsubst %.cpp,$(DIR_OBJ)/%.o,$(notdir $(SRC))) # 将源文件名替换成 obj目录中的*.o文件名,对应 #4 #5INC =$(wildcard $(DIR_INCLUDE)/*.h) # 展开头文件名,对应 #6all: add.exe@echo SRC $(SRC)@echo OBJS $(OBJS)@echo notdir_SRC $(notdir $(SRC))@echo INC $(INC)add.exe: $(OBJS)g++ -o add.exe $(OBJS) # 对应 #2$(OBJS): $(SRC) $(INC) # F@echo $^ # 注意这里虽然对$(OBJS)中每个文件都执行了对应的命令,但他们的依赖前置文件都是一样的g++ -c $(DIR_SRC)/$(notdir $*).cpp -I $(DIR_INCLUDE) -o $@ # 对应 #0 #1# 每次用 $* 取得文件名,利用变量拼接成含目录的源文件名($(DIR_SRC)/$(notdir $*).cpp),如 ./src/add.cpp# $@ 为带有目录的目标文件名,如 obj/add.o# -I $(DIR_INCLUDE) 指定头文件目录clean:rm -f add.exe *.o $(OBJS)
输出
src/add.cpp src/main.cpp include/add.hg++ -c ./src/add.cpp -I ./include -o obj/add.o #0src/add.cpp src/main.cpp include/add.hg++ -c ./src/main.cpp -I ./include -o obj/main.o #1g++ -o add.exe ./obj/add.o ./obj/main.o #2SRC ./src/add.cpp ./src/main.cpp #3OBJS ./obj/add.o ./obj/main.o #4notdir_SRC add.cpp main.cpp #5INC ./include/add.h ./include/sub.h #6
嗯,这似乎实现了我们的所有目标,自动识别源文件头文件,自动编译等等,但先不要着急,我们修改一个源文件试试。再次运行,输出为(删去回显部分)
g++ -c ./src/add.cpp -o obj/add.og++ -c ./src/main.cpp -o obj/main.og++ -o add.exe ./obj/add.o ./obj/main.o
似乎有什么不对…对了,说好的按需编译呢,为什么又从头构建了一遍?
仔细阅读代码和输出就会发现,每一个目标在构建时都引用了全部工程文件作为依赖(#F),这也就不难理解上述行为了。
4级
自定义函数
观察如下示例
v = main.csubc2o = $(patsubst %.c, %.o, $(1)) #0v3 = $(call subc, $(v)) #1all:@echo "$(v3)"
输出
main.o
0 处定义了一个函数,函数名是subc2o,形式参数为$(1),同时利用$(call fun_name, param1,...)调用自定义函数
这是另一个例子
v1 = 2v2 = 3reverse = $(2) $(1)v3 = $(v1) $(v2)v4 = $(call reverse,$(v1),$(v2))all:@echo '$(v3)'@echo '$(v4)'
输出
2 33 2
两阶段工作模式
观察如下示例
v := prevx = $(v) #0 注意冒号y := $(v) #1v := nextv := nextnextall:@echo $(x)@echo $(y)
输出为
nextnextprev
神奇的结果出现了。
实际上,make工作分为两个阶段,读入(read-in)阶段和目标更新(target-update)阶段。在第一阶段会读取makefile和引用的makfile,引入(internalize)变量和变量值、隐式和显式规则,构建目标和前置条件的依赖图,在第二阶段则会根据第一阶段的解析结果更新目标。上述现象就是这种工作模式的表现之一,#0的赋值在第二阶段才实际发生,#1的赋值在第一阶段即完成。
这种工作方式似乎十分令人费解,不过读者现在只需要知道存在这一现象即可,其详细规则和设计原因我们暂且按下不表。
在前置条件中使用自动化变量
在前文中,自动化变量是一个高效的简化工具,但前文中我们从未在command以外的地方使用过自动化变量,那么…
观察如下示例
all: aa: $@_ex@echo $@a_ex:@echo $@
输出
mingw32-make: *** No rule to make target '_ex', needed by 'a'. Stop.
很遗憾,自动化变量并未按我们的期望工作,从错误中可以看出,$@被解析为了空值
使用二阶展开
这里我们会遇到二阶段工作模式真正的用武之地。自动化变量只有在解析完目标和前置条件后才能使用,要想提前使用,就要像前面变量赋值的例子一样,设法在第二阶段更新值,这就是所谓的二阶展开(second expension)。
观察如下示例
all: a.SECONDEXPANSION:a: $$@_ex@echo $@a_ex:@echo $@
输出
a_exa
嗯,完美。
.SECONDEXPANSION:就是指示make后文中会使用二阶展开,$$@则是实际使用二阶展开的变量
综合案例
观察如下示例
DIR_SRC = ./srcDIR_OBJ = ./objDIR_INCLUDE = ./includeSRC = $(wildcard $(DIR_SRC)/*.cpp)OBJS = $(patsubst %.cpp,$(DIR_OBJ)/%.o,$(notdir $(SRC)))all: add.exeadd.exe: $(OBJS)g++ -o add.exe $(OBJS)main_src = main.cppmain_include =add_src = add.cppadd_include = add.h.SECONDEXPANSION:$(OBJS): $$(DIR_SRC)/$$($$(notdir $$*)_src) $$(if $$($$(notdir $$*)_include), $$(DIR_INCLUDE)/$$($$(notdir $$*)_include),)@echo $^g++ -c $< -I $(DIR_INCLUDE) -o $@clean:rm -f add.exe *.o $(OBJS)
输出
src/add.cpp include/add.hg++ -c src/add.cpp -I ./include -o obj/add.osrc/main.cppg++ -c src/main.cpp -I ./include -o obj/main.og++ -o add.exe ./obj/add.o ./obj/main.o
下面进行详细解析
$(DIR_SRC)/$($(notdir $*)_src)
首先我们假装$$就是$$*获取目标名称,$(notdir )删去目录,$(notdir $*)_src拼接为变量名,$($(notdir $*)_src)取变量值,最后进行整体拼接,以obj/add.o为例
$* == obj/add$(notdir $*) == add$(notdir $*)_src == add_src$($(notdir $*)_src) == $(add_src) == add.cpp$(DIR_SRC)/$($(notdir $*)_src) == src/add.cpp
对第二部分
$$(if $$($$(notdir $$*)_include), $$(DIR_INCLUDE)/$$($$(notdir $$*)_include),) #0
同理我们可以得出(例子同上)
$($(notdir $*)_include) == $(add_include) == add.h$(DIR_INCLUDE)/$($(notdir $*)_include) == include/add.h
这里有一个新的函数 $(if),其用法是
$(if condition, v1, v2)
condition为真时其值为v1,否则为v2
第二部分的含义综合而言就是检查目标对应的头文件是否存在,若存在,则包含,否则留空(注意#0 v2为空)
至此,我们实现了前两个目标,但是,还能更进一步吗?
(未完待续)
