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