makefile 指定include_如何优雅地使用 makefile

4cc92b12e64e16522d2a06b9b62a95d1.png

前言

最近常常被一些问题困扰

  1. 如何命名变量
  2. 花括号放在行末还是独立成行
  3. 写Python是否要随身携带游标卡尺
  4. 如何从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.oadd.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++

定义ccg++

使用变量的语法为$(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.oadd.o的生成规则,这就是make的自动推导——make程序会根据隐含规则生成构建命令。具体来说,make会将main.o包含在构建目标中,同时自动生成其前置文件依赖,即main.cppadd.o同理。这样,我们只需要一条构建规则就可以完成项目的构建,大大减轻编写构建目标的工作量。

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

lv.2-多目录结构

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

project
|--include
|   +--add.h
|
|--obj
|
+--src
    |--main.cpp
    +--add.cpp

lv.2-手工指定结构

targetprerequistes本身可以包含路径名,但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文件

这里三条命令的含义是对相应后缀的文件,在指定文件夹中搜索相应文件 注意:这一搜索规则只在targetprerequisite中有效,在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正确解析了targetprerequisite目标与依赖关系,但并没有在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为空)

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

(未完待续)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值