前言

最近常常被一些问题困扰

  1. 如何命名变量
  2. 花括号放在行末还是独立成行
  3. 写Python是否要随身携带游标卡尺
  4. 如何从0构建一个C/C++项目

经过漫长的思考,我发现前三个问题过于困难,那么今天我只好向大家介绍一些第四个问题的内容,也就是nix下常用的构建控制工具make
nix平台上分发的程序源码往往带有makefile文件,make就是根据这一文件的指令完成程序编译的各项任务。
当然,如果客官认为纯手工敲出来的命令构建出的程序才是有灵魂的程序,那我只能为您的耐力点赞。

本文希望能够给出一个较为通用的makefile,并介绍与之相关的编写规则。具体而言,期望达到的目标是

  1. 适应多目录的组织结构
  2. 可以按需编译
  3. 通过简单的修改就可以应用于不同的项目

文中的任何疏漏错误欢迎大家指正。

所有makefile代码在mingw32-make下测试通过,编译器使用g++

C/C++ 程序构建流程

C/C++的编译流程简化来看可分为两个阶段,将源码编译为*.o文件,再链接为可执行文件,即

  1. 源码(*.cpp)-> *.o -> 可执行文件

一个典型的端到端编译命令为

  1. g++ main.cpp -o main.exe

main.cpp为源文件名,-o为目标文件名

多文件编译

多文件编译需要首先将各个源文件编译为.o文件,而后再进行链接成为最终的可执行文件

  1. g++ -c add.cpp -o add.o

-c后文件编译为.o文件,即已编译为二进制代码,但尚未链接

  1. g++ main.o add.o -o t.exe

main.oadd.o以及系统提供的库链接为最终可执行文件

0级

基本格式

  1. target: prerequisites
  2. command

makefile就是由多条类似于上述结构的“规则(Rules)”构成,target目标prerequisites是一系列构建target所需要的前置条件(通常为文件)。
make程序会检查每一个target,如果target不存在,或者target的修改时间早于前置文件(即前置文件有修改)就会执行command中的命令。注意,command前必须为一个tab

  1. add.exe:
  2. g++ main.cpp
  1. add.exe: main.o
  2. g++ -o add.exe main.o
  3. main.o: main.cpp
  4. g++ -c -o main.o main.cpp

上述makefile将main.cpp依次编译为obj文件和最终可执行文件。

伪目标

目标并非实际文件时,它就成为了所谓的“伪目标”。当仅执行make命令时,伪目标会被忽略,只有在显式指定为目标时,伪目标的命令才会被执行。例如:

  1. clean:
  2. rm -f *.o

执行make clean会执行clean中定义的命令,即删除所有obj文件。通常伪目标都是用于清理编译中间文件,完整重编译等工作。
还可以使用.PHONY将某一目标显式指定为伪目标。

  1. .PHONY: clean
  2. clean:
  3. rm -f *.o

在不致混淆的情况下,后文会省略.PHONY

1级

使用变量

在makefile中可以定义变量

  1. cc = g++

定义ccg++

使用变量的语法为$(varname)

  1. add.exe: main.o add.o
  2. $(cc) -o add.exe main.o add.o

这里,我们可以简单理解makefile中的变量就是进行替换(当然实际并不仅限于此)

自动推导

看下面这个例子(注意这里变量的应用)

  1. objs = main.o add.o
  2. add.exe: $(objs)
  3. g++ -o add.exe $(objs)

这里文件夹的结构是

  1. project
  2. |--main.cpp
  3. |--add.h
  4. +--add.cpp

执行make命令后,会发现add.exe被正确生成了,而我们并没有指定main.oadd.o的生成规则,这就是make的自动推导——make程序会根据隐含规则生成构建命令,在这里,make会将main.o包含在构建目标中,同时自动生成其前置文件依赖,即main.cppadd.o同理。这样,我们只需要一条构建规则就可以完成项目的构建,大大减轻编写构建目标的工作量。

然而,这里我们也应当注意到,make会根据.o文件的文件名推断源文件的文件名,如果源文件名与.o文件的文件名不一致,make就无法根据规则找到源文件。
这里同样要注意路径的问题,prerequisite的文件包含路径名,make无法实现自动推导
这些隐含规则能也有自定义的空间,但我们暂时还是关注当下主要的问题,看看多文件的工程该如何管理。

2级

多目录结构

对于复杂的工程而言,不可能把所有源文件和依赖文件都放在项目根目录上,make也也提供了相应的功能,下面是示例文件夹结构:

  1. project
  2. |--include
  3. | +--add.h
  4. |
  5. |--obj
  6. |
  7. +--src
  8. |--main.cpp
  9. +--add.cpp

手工指定结构

targetprerequistes本身可以包含路径名,但prerequistes中若包含路径名,target中相应文件应与其一致

  1. add.exe: ./obj/main.o ./obj/add.o #1
  2. g++ -o add.exe ./obj/main.o ./obj/add.o
  3. ./obj/add.o: ./src/add.cpp ./include/add.h #2
  4. g++ -c ./src/add.cpp -o ./obj/add.o
  5. ./obj/main.o: main.cpp ./include/add.h #3
  6. g++ -c main.cpp -o ./obj/main.o

这里 #1 的./obj/main.o与 #2 的target一致,./obj/add.o与 #3 的target一致。

vpath(小写)

一一指定每个文件的路径非常繁琐,因此,make程序提供了更为强大的语法来实现这一功能。

  1. vpath %.h ./include # 在include中搜索.h文件
  2. vpath %.o ./obj # 在obj中搜素.o文件
  3. vpath %.cpp ./src # 在src中搜索.cpp文件

这里三条命令的含义是对相应后缀的文件,在指定文件夹中搜索相应文件
注意:这一搜索规则只在targetprerequisite中有效,在command中无效,例如

  1. vpath %.h ./include
  2. vpath %.o ./obj
  3. vpath %.cpp ./src
  4. add.exe: main.o add.o
  5. g++ -o add.exe main.o add.o
  6. main.o: main.cpp add.h
  7. g++ -c main.cpp -o ./obj/main.o
  8. add.o: add.cpp add.h
  9. g++ -c add.cpp -o ./obj/add.o

会提示无法找到main.cpp,这表明make正确解析了targetprerequisite目标与依赖关系,但并没有在command中体现出来

那多文件夹结构究竟该如何?

自动化变量

我们发现有些很多情况目标、前置条件和命令中有大量重复的内容,那么有没有一种方法可以简化呢?make提供了自动化变量来实现这一要求。

  1. $< # prerequisites 中的第一个文件
  2. $^ # prerequisites 中的所有文件
  3. $@ # target 的文件名
  4. $* # 不包含后缀的 target 文件名

利用上面这几个变量,让我们重写一遍代码

  1. vpath %.h ./include
  2. vpath %.o ./obj
  3. vpath %.cpp ./src
  4. add.exe: main.o add.o
  5. g++ -o add.exe $^ # 从全部依赖文件生成目标文件
  6. main.o: main.cpp
  7. g++ -c $< -o $@ # 从第一个依赖文件($< 亦即 main.cpp)生成目标文件 ($@ 亦即 main.o)
  8. add.o: add.cpp
  9. g++ -c $< -o $@
  10. clean: # 伪目标,清理中间文件和最终可执行文件
  11. rm -f add.exe *.o

make后输出

  1. g++ -c ./src/main.cpp -o main.o
  2. g++ -c ./src/add.cpp -o add.o
  3. g++ -o add.exe main.o add.o

不错,路径被正确补齐,目标文件顺利生成。回想之前的自动推导,上面的代码还可以改写如下:

  1. vpath %.h ./include
  2. vpath %.o ./obj
  3. vpath %.cpp ./src
  4. add.exe: main.o add.o #0
  5. g++ -o add.exe $^
  6. clean:
  7. rm -f add.exe *.o

一行命令打遍天下。

当然,我们不能满足于此,还有几个问题等待我们去解决。

第一,我们现在还需要手动指定要链接的文件(#0),十分繁琐;

第二,生成的中间文件都直接在project即项目根目录下,非常杂乱;

第三,也是最为致命的问题,在自动推导的情形下,makefile并没有追踪头文件的变化,如果仅头文件有了修改,make仍会认为可执行文件是最新的(在非自动推导的情形下可以通过手动指定解决问题)。

下面,我们就来看如何解决这两个问题。

3级

使用函数

make内设了一些用于完成相关处理工作的函数,借助他们,我们可以更为灵活地描述和实现我们的需求,为了便于观察函数的功能,我们使用下面的makefile

  1. # 这里定义变量,使用函数
  2. VAR = ./src
  3. all: # make 会默认执行第一个目标,通常都使用 all
  4. @echo $(VAR)

执行后会输出./src

基本模式

makefile函数的基本使用模式如下

  1. $(<func_name> <param1>,<param2>,...)

从通配符到文件列表-wildcard

使用通配符匹配文件是非常常用的操作,在makefile中,通配符的使用却需要小心,观察下列示例

  1. VAR1 = ./src/*.cpp
  2. VAR2 = $(wildcard ./src/*.cpp)
  3. OBJS1 = $(patsubst %.cpp, %.o, $(VAR1)) #0 这个函数理解成会进行模式替换,下面会详细介绍
  4. OBJS2 = $(patsubst %.cpp, %.o, $(VAR2))
  5. all:
  6. @echo VAR1 $(VAR1)
  7. @echo VAR2 $(VAR2)
  8. @echo OBJS1 $(OBJS1)
  9. @echo OBJS2 $(OBJS2)

结果输出

  1. VAR1 ./src/add.cpp ./src/main.cpp
  2. VAR2 ./src/add.cpp ./src/main.cpp
  3. OBJS1 ./src/*.o
  4. OBJS2 ./src/add.o ./src/main.o

直接使用通配符的变量在直接使用的情形下没有问题,但在 #0 处可以发现作为函数参数时并没有正确展开,而VAR2则始终给出了期望的结果。
事实上,./src/*.cpp只有在规则中才会展开,变量VAR1中是按照原样保存,而wildcard在变量赋值时已经进行了展开。

模式替换-patsubst

从上面的例子中我们已经可以看出patsubst函数的功能,其语法是

  1. $(patsubst <pattern>, <replacement>, <text>)

将符合模式<pattern>的字符串替换为<replacement>模式的字符串,<text>中空格分隔的字符串都视为独立的字符串,%可以匹配任何字符,例如

  1. $(patsubst %.c,%.o,x.c.c bar.c)

替换结果为

  1. x.c.o bar.o

若字符串中含有%,可使用转义\%

取文件名/剔除路径-notdir

  1. $(notdir <names...>)

$(notdir src/foo.c hacks)返回值是foo.c hacks

更多的函数可以参考这里

自动处理的另一种方式(综合案例)

文件树

  1. project
  2. |--include
  3. | |--add.h
  4. | +--sub.h
  5. |
  6. |--obj
  7. |
  8. +--src
  9. |--main.cpp
  10. +--add.cpp

makefile

  1. DIR_SRC = ./src
  2. DIR_OBJ = ./obj
  3. DIR_INCLUDE = ./include
  4. SRC = $(wildcard $(DIR_SRC)/*.cpp) # 展开所有源文件名,对应输出 #3
  5. OBJS = $(patsubst %.cpp,$(DIR_OBJ)/%.o,$(notdir $(SRC))) # 将源文件名替换成 obj目录中的*.o文件名,对应 #4 #5
  6. INC =$(wildcard $(DIR_INCLUDE)/*.h) # 展开头文件名,对应 #6
  7. all: add.exe
  8. @echo SRC $(SRC)
  9. @echo OBJS $(OBJS)
  10. @echo notdir_SRC $(notdir $(SRC))
  11. @echo INC $(INC)
  12. add.exe: $(OBJS)
  13. g++ -o add.exe $(OBJS) # 对应 #2
  14. $(OBJS): $(SRC) $(INC) # F
  15. @echo $^ # 注意这里虽然对$(OBJS)中每个文件都执行了对应的命令,但他们的依赖前置文件都是一样的
  16. g++ -c $(DIR_SRC)/$(notdir $*).cpp -I $(DIR_INCLUDE) -o $@ # 对应 #0 #1
  17. # 每次用 $* 取得文件名,利用变量拼接成含目录的源文件名($(DIR_SRC)/$(notdir $*).cpp),如 ./src/add.cpp
  18. # $@ 为带有目录的目标文件名,如 obj/add.o
  19. # -I $(DIR_INCLUDE) 指定头文件目录
  20. clean:
  21. rm -f add.exe *.o $(OBJS)

输出

  1. src/add.cpp src/main.cpp include/add.h
  2. g++ -c ./src/add.cpp -I ./include -o obj/add.o #0
  3. src/add.cpp src/main.cpp include/add.h
  4. g++ -c ./src/main.cpp -I ./include -o obj/main.o #1
  5. g++ -o add.exe ./obj/add.o ./obj/main.o #2
  6. SRC ./src/add.cpp ./src/main.cpp #3
  7. OBJS ./obj/add.o ./obj/main.o #4
  8. notdir_SRC add.cpp main.cpp #5
  9. INC ./include/add.h ./include/sub.h #6

嗯,这似乎实现了我们的所有目标,自动识别源文件头文件,自动编译等等,但先不要着急,我们修改一个源文件试试。再次运行,输出为(删去回显部分)

  1. g++ -c ./src/add.cpp -o obj/add.o
  2. g++ -c ./src/main.cpp -o obj/main.o
  3. g++ -o add.exe ./obj/add.o ./obj/main.o

似乎有什么不对…对了,说好的按需编译呢,为什么又从头构建了一遍?
仔细阅读代码和输出就会发现,每一个目标在构建时都引用了全部工程文件作为依赖(#F),这也就不难理解上述行为了。

4级

自定义函数

观察如下示例

  1. v = main.c
  2. subc2o = $(patsubst %.c, %.o, $(1)) #0
  3. v3 = $(call subc, $(v)) #1
  4. all:
  5. @echo "$(v3)"

输出

  1. main.o

0 处定义了一个函数,函数名是subc2o,形式参数为$(1),同时利用$(call fun_name, param1,...)调用自定义函数

这是另一个例子

  1. v1 = 2
  2. v2 = 3
  3. reverse = $(2) $(1)
  4. v3 = $(v1) $(v2)
  5. v4 = $(call reverse,$(v1),$(v2))
  6. all:
  7. @echo '$(v3)'
  8. @echo '$(v4)'

输出

  1. 2 3
  2. 3 2

两阶段工作模式

观察如下示例

  1. v := prev
  2. x = $(v) #0 注意冒号
  3. y := $(v) #1
  4. v := next
  5. v := nextnext
  6. all:
  7. @echo $(x)
  8. @echo $(y)

输出为

  1. nextnext
  2. prev

神奇的结果出现了。

实际上,make工作分为两个阶段,读入(read-in)阶段和目标更新(target-update)阶段。在第一阶段会读取makefile和引用的makfile,引入(internalize)变量和变量值、隐式和显式规则,构建目标和前置条件的依赖图,在第二阶段则会根据第一阶段的解析结果更新目标。上述现象就是这种工作模式的表现之一,#0的赋值在第二阶段才实际发生,#1的赋值在第一阶段即完成。

这种工作方式似乎十分令人费解,不过读者现在只需要知道存在这一现象即可,其详细规则和设计原因我们暂且按下不表。

在前置条件中使用自动化变量

在前文中,自动化变量是一个高效的简化工具,但前文中我们从未在command以外的地方使用过自动化变量,那么…
观察如下示例

  1. all: a
  2. a: $@_ex
  3. @echo $@
  4. a_ex:
  5. @echo $@

输出

  1. mingw32-make: *** No rule to make target '_ex', needed by 'a'. Stop.

很遗憾,自动化变量并未按我们的期望工作,从错误中可以看出,$@被解析为了空值

使用二阶展开

这里我们会遇到二阶段工作模式真正的用武之地。自动化变量只有在解析完目标和前置条件后才能使用,要想提前使用,就要像前面变量赋值的例子一样,设法在第二阶段更新值,这就是所谓的二阶展开(second expension)。
观察如下示例

  1. all: a
  2. .SECONDEXPANSION:
  3. a: $$@_ex
  4. @echo $@
  5. a_ex:
  6. @echo $@

输出

  1. a_ex
  2. a

嗯,完美。

.SECONDEXPANSION:就是指示make后文中会使用二阶展开,$$@则是实际使用二阶展开的变量

综合案例

观察如下示例

  1. DIR_SRC = ./src
  2. DIR_OBJ = ./obj
  3. DIR_INCLUDE = ./include
  4. SRC = $(wildcard $(DIR_SRC)/*.cpp)
  5. OBJS = $(patsubst %.cpp,$(DIR_OBJ)/%.o,$(notdir $(SRC)))
  6. all: add.exe
  7. add.exe: $(OBJS)
  8. g++ -o add.exe $(OBJS)
  9. main_src = main.cpp
  10. main_include =
  11. add_src = add.cpp
  12. add_include = add.h
  13. .SECONDEXPANSION:
  14. $(OBJS): $$(DIR_SRC)/$$($$(notdir $$*)_src) $$(if $$($$(notdir $$*)_include), $$(DIR_INCLUDE)/$$($$(notdir $$*)_include),)
  15. @echo $^
  16. g++ -c $< -I $(DIR_INCLUDE) -o $@
  17. clean:
  18. rm -f add.exe *.o $(OBJS)

输出

  1. src/add.cpp include/add.h
  2. g++ -c src/add.cpp -I ./include -o obj/add.o
  3. src/main.cpp
  4. g++ -c src/main.cpp -I ./include -o obj/main.o
  5. g++ -o add.exe ./obj/add.o ./obj/main.o

下面进行详细解析

  1. $(DIR_SRC)/$($(notdir $*)_src)

首先我们假装$$就是$
$*获取目标名称,$(notdir )删去目录,$(notdir $*)_src拼接为变量名,$($(notdir $*)_src)取变量值,最后进行整体拼接,以obj/add.o为例

  1. $* == obj/add
  2. $(notdir $*) == add
  3. $(notdir $*)_src == add_src
  4. $($(notdir $*)_src) == $(add_src) == add.cpp
  5. $(DIR_SRC)/$($(notdir $*)_src) == src/add.cpp

对第二部分

  1. $$(if $$($$(notdir $$*)_include), $$(DIR_INCLUDE)/$$($$(notdir $$*)_include),) #0

同理我们可以得出(例子同上)

  1. $($(notdir $*)_include) == $(add_include) == add.h
  2. $(DIR_INCLUDE)/$($(notdir $*)_include) == include/add.h

这里有一个新的函数 $(if),其用法是

  1. $(if condition, v1, v2)

condition为真时其值为v1,否则为v2
第二部分的含义综合而言就是检查目标对应的头文件是否存在,若存在,则包含,否则留空(注意#0 v2为空)

至此,我们实现了前两个目标,但是,还能更进一步吗?

(未完待续)