Makefile简易使用

本文最后更新于:9 个月前

写在前面

因为在linux上编译多个cpp文件时,需要分别g++ -c生成.o文件,然后将所需的.o文件一起链接以生成可执行文件,比较繁琐且冗余.因此需要使用到makefile工具来帮助我们进行多文件编译(甚至文件可以指定不同目录,更有利于工程项目管理),故有此篇文章

linux下编译多个cpp的过程

在开始说makefile的一些基本语法前,先说一下在linux下编译多个cpp的过程

我们通常编译一个.cpp文件都是经过预处理---生成.i文件--->编译---生成.s文件--->汇编---生成.o文件--->链接--->生成可执行文件.exe,然后在执行的过程中,会被加载器将程序放置到内存上,并通过动态链接器链接.dll.so动态库,以实现程序运行

静态链接器(如ld)负责链接的是.a.lib这些静态库,这些静态库是通过ar这个工具将多个.o文件构成的.静态链接是构建可执行文件时的最后一步.主要功能是符号解析(通过符号表找到.o文件的只声明无定义的函数的定义位置)和重定位(定位到虚拟地址(有OS的机子)/物理地址(裸机))

动态链接器(如ld-linux.so.2)负责链接的是.so.dll这些动态库或者叫共享库,要使用到这个工具的时候,我们的程序已经被加载器加载到内存上了,当我们遇到如printf这些动态库定义的函数时,会去检查内存中有没有加载对应的动态库(这里是libc.so),没有的话就加载到内存上,有得话就不用加载,取得该动态库在内存中的起始地址,然后我们编译过printf这个函数时,它的调用方式形如call 0xaaaa 这样,这里的地址是相对于动态库的地址,因此由获得的动态库起始地址加这个偏移地址就可以得到printf在内存中的真正地址了.这一类库的生成是我们在利用g++编译时通过-shared选项实现的.

静态链接后的exe文件会包含别的.o文件(静态库)的代码,比较占地方,而动态链接的则没有算到exe文件里面去,毕竟它(动态库)只是在exe运行时动态加载到内存中去的,但是这个动态库要是没有,那就跑不起来,而静态链接的则没事,因为代码已经拷贝到exe里了

每个.cpp文件都可以单独用如下方式g++ -c xx.c -o xx.o生成.o形式的目标文件,该文件里的内容是机器码,但是这个文件不能被执行,因为存在两个问题:

  1. 调用了别的.cpp文件定义的函数,但是我们只包含了该函数声明的.h文件:即在其中,只有符号的引用,而没有实际的定义,这里所说的符号即是函数,需要进行符号解析;
  2. 这里的地址是不对的,并不是实际运行的地址(虚拟地址/物理地址),而是一个标志,是逻辑地址,需要进行重定位

我们这里着重讲第一点,比如我在a.cpp文件中调用了hello(),而只包含了头文件hello.h,而实际实现是在hello.cpp中,我们各自通过g++ -c ...编译得到a.o hello.o,其中a.o中所有关于hello()的函数调用,会跳到同一个地址,在该地址会call hello,这个call hello的真正地址,需要在hello.o中获得

这些.o文件有自己的符号导入表符号导出表,这些表对应的是符号---地址,因此这需要静态链接器去在hello.o的符号导出表找到hello这一项对应的地址,并作偏移(两个.o要合并,地址难免有冲突,因此需要偏移),然后写到a.o的符号导入表中hello那一项对应的地址中去,由此call hello这里的hello就可以找到对应的实际的地址了.

之后的重定位则是将逻辑地址换成实际运行的地址.

实际上,链接这一过程,解决的就是上面所说的两个问题,因此,对于一个庞大的工程项目来说,我们可以保留需要的.o文件,然后若有部分cpp有修改,则将之重新编译为.o,没改的则不重新编译(因为对于一个大项目来说,编译的时间可能也很长),然后将所有需要的.o一起链接成一个可执行文件即可,这个过程若是手动管理,繁琐且复杂,还容易出错,因此需要一个工具来进行管理,由此便需要makefile

makefile基本语法

makefile三要素

在makefile中有三要素:目标(target),依赖(prerequisite)和执行语句(recipe)

makefile 3 element

目标main的生成依靠于依赖main.c,当目标不存在或依赖被修改,则执行下方执行语句

这三要素的描述原文如下(本文关于makefile的部分主要参考参考文件中的一篇blog,不过行文按自己的理解展开):

A target is usually the name of a file that is generated by a program; examples of targets are executable or object files. A target can also be the name of an action to carry out, such as ‘clean’ (see Phony Targets).
A prerequisite is a file that is used as input to create the target. A target often depends on several files.
A recipe is an action that make carries out. A recipe may have more than one command, either on the same line or each on its own line. Please note: you need to put a tab character at the beginning of every recipe line!

关于变量

我们可以通过一些方式定义变量,变量可以使得我们的操作更加简洁,通过以下方式定义和$(变量)方式引用

变量定义

  • 简单赋值:= 与python中的a=4一样

    SOURCE_DIR := .

  • 追加赋值+= 与python中的s = 'a',s += 'b' #最终s为'ab',但是makefile中的追加赋值会自动用空格隔开两个值,显示为a b

    SOURCE_DIR += ./objects #结合上面的. 这里的SOURCE_DIR是. ./objects

  • 条件复制?= 只在变量未定义时创建变量

    1
    2
    3
    4
    5
    VAR ?= one
    # VAR变量为one
    VAR := two
    VAR ?= one
    # VAR变量为two
  • 递归赋值= 使用变量赋值时,会优先展开引用的变量(没用过,不是很清楚)

变量引用

通过$(TARGET)一类的方式引用变量

一些函数

函数使用的时候和变量的引用差不多,都是通过$()的方式引用,不过函数引用时要在里面加上函数名和对应参数

wildcard函数

通配符匹配函数,使用形如:$(wildcard pattern)

比如可以通过:$(wildcard *.c)匹配当前目录下的所有.c结尾的文件

foreach函数

跟c++里的foreach差不多,使用形如:$(foreach var,list,text)其中list是列表,是某一个变量的引用,var会取得列表的每一个元素,然后交由text进行展开

例子如下:

1
2
3
4
SUBDIR := .
SUBDIR += ./object

INCS := $(foreach f,$(SUBDIR),-I$(f))

上面的例子使得INCS的值为:-I. -I./object,这个**-I选项用于gcc编译过程中指定头文件所在路径**

patsubst函数

起到替换作用的一个函数,使用形如:$(patsubst pattern,replace,list)

从list中取匹配pattern的元素,然后替换为replace

在举例说明前先说下**%自动变量**

**%**的示例如下(例子好说明):%.o : %.c作为目标的%(就是第一个%)会匹配所有结尾为.o的目标,然后这里比如是有a.ob.o则会匹配到这俩,然后作为依赖的%(第二个%)会把前面的ab.c构成a.cb.c

自动变量有(引用无需()):

  • <表示取依赖列表的第一个依赖,通过$<的方式引用;
  • @表示取目标(target),通过$@的方式引用;
  • ^表示取依赖列表的所有依赖,通过$^的方式引用

通过自动变量和%可以简化生成目标文件的语句为:

: %.c
1
2
%.o : %.c
gcc -c $< -o $@

说回patsubst函数,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
SUBDIR := .
SUBDIR += ./object

INCS := $(foreach f,$(SUBDIR),-I$(f)) # 头文件的搜索目录,有的喜欢叫cflags
SRCS := $(foreach f,$(SUBDIR),$(wildcard $(f)/*.cpp)) # 匹配所有cpp
OBJS := $(patsubst %.cpp,%.o,$(SRCS)) # 把上面的.cpp后缀统一换成.o后缀

test : $(OBJS):
g++ $^ -o $@

%.o : %.cpp
g++ -c $(INCS) $< -o $@

这其中把所有的CPP文件的后缀通过SRCS换成了.o(通过%),并放置在OBJS这个变量中

dir函数

获取目标文件路径的一个函数,可以配合mkdir -p这样的shell语句使用(是的,makefile里面可以写shell语句)

这样我们可以通过makefile来更好的管理工程文件,比如头文件放哪,目标文件放哪这样子,而这其中牵扯到的目录创建,也可以一并交由makefile来做

dir函数形如:$(dir basedir/file),它可以从后面的这一坨文本中获取路径,即basedir而去掉file,因此可以这么用:

mkdir -p $(dir $@)

这里我们取的是目标文件的路径

伪目标.PHONY

我们之前在配一些项目的时候,不免会用到如make clean一类的指令来清除项目文件夹下多余的内容,然后重新make来构建内容

在makefile中,我们可以利用target去执行一些动作,如:

1
2
clean :
rm -r $(OBJS) $(TARGET)

则可以通过**make clean**来执行对应的执行语句(默认执行第一条target,因此我们前面的都是直接make就行,没有指明target)

但我们知道!一个目标文件不存在或者依赖文件有修改才会去执行对应的执行语句,若当前目录有个clean文件,那么这个make clean就发挥不了作用,因为它也没依赖,而目标文件又存在

此问题可以通过伪目标来解决,将clean声明为伪目标,表明它不是文件的命名

伪目标的声明是通过将fake target声明为.PHONY这个特殊的内置目标的依赖来实现的,如下:

1
2
3
.PHONY : clean
clean :
rm -rf $(OBJS) $(TARGET)

通过@简化输出

不难发现,我们前面在make的过程中,所有的执行语句都打印在了终端上,看起来乱乱的,如果不希望它被打印,可以在执行语句的前面加一个@来不让它打印,示例如下:

@rm -rf $(OBJS) $(TARGET)

这样执行make clean的时候,就不会有rm -rf ...这么一句话被打印了

自动生成依赖

我们上面通过wildcard函数匹配到了对应目录(这个对应目录是采用了foreach函数实现的)下的源文件,然后通过patsubst函数替换这些源文件为对应的.o文件,然后通过一些规则去生成最终想要的可执行文件,如test,其中它的一些.o文件如果找不到,则会去匹配到对应的规则利用.cpp文件编译生成.o文件,同时这其中任意一环的依赖被修改或是目标文件不存在,则会去update,去把它生成出来.

但这时你如果改动了头文件咋办呢?它不在我们的依赖中,改了咋知道要重新生成呢?

显然我们可以给头文件依次加入我们的依赖中去,但其中若是用了如algorithm(我假设它是静态库啊,这个看具体编译和链接的情况,因为好像有libstdc++.a也有libstdc++.so这样的库),那我岂不是还得去放这个库的比如/usr/lib或什么/usr/local/lib去加入-I选项来扫一下,这样不大方便;若是手动添加,更加痛苦

gcc提供了一种自动生成依赖的选项-MMD,我们一般还搭配-MP选项来使用:

MMD作用:①生成依赖关系;②保存依赖关系到.d文件,其中这个文件是和我们的target即是%.o处在同一目录下

这里生成的依赖关系是包含了目标文件它的源文件和头文件,比如:a.o : a.cpp head/a.h

MP作用是避免头文件删除了(不用了,把它删了),然后因为我们的makefile脚本里又有这么个头文件依赖在,它如果找不到匹配的规则,则会报错,因此,它的作用是生成头文件的没有任何依赖的伪目标

为什么需要给头文件也生成一个伪目标呢?这说来话长:

我的理解是我们make的时候:如针对$(TARGET) : $(OBJS),我们如果缺少某一些.o文件,则会先找到匹配的规则(rule),如%.o : %.c去生成对应的.o文件,如果遍历所有规则都找不到的话就会报错(正是这样,才可以改依赖,比如改头文件,才会影响到我们目标的生成,且是最小程度的影响,只会影响被改动的依赖的目标,然后该目标若是作为某一条规则的依赖,则依次影响,是一个层层递进的过程);

那你的头文件如果不用了,从目录下删掉了,那么找不到头文件,会去遍历可以match它的rule,如果找不到,就报错,但显然,我就是想把头文件给它删了,因为没用,实际程序是能运行的,那在编译时增加了-MP选项,则生成头文件的伪目标,这个伪目标很单纯,没有依赖,此时,我的头文件虽然找不到了,但是可以找到与它match的rule,告知我们怎么处理,则可以规避掉报错的问题.

那我们通过在编译的时候增加-MMD,-MP选项来实现自动生成依赖并存于.d文件中,怎么将文件中的内容引入makefile脚本中呢?

通过include即可引入,但是因为第一次编译的时候.d文件还不存在,这时候include会报错,通过-include来避免报错,使得makefile脚本正常运行,使用示例如下:

1
2
3
4
DEPS := $(patsubst %.o,%.d,$(OBJS))
# ...
# ...
-include $(DEPS)

通用模板

通用模板中的内容上面都介绍过,是我目前用的一个通用模板,要改的话在上面改就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
SUBDIR := .

OUTPUT := ./obj
TARGET := test
INCS := $(foreach dir,$(SUBDIR),-I$(dir))
INCS += `pkg-config --cflags opencv4` # 通过pkg-config指令,将opencv4的头文件路径列出

SRCS := $(foreach dir,$(SUBDIR),$(wildcard $(dir)/*.cpp))

OBJS := $(patsubst %.cpp,$(OUTPUT)/%.o,$(SRCS))
DEPS := $(patsubst %.o,%.d,$(OBJS))

LIBS := `pkg-config --libs opencv4` # 通过pkg-config指令,将opencv4的库文件路径列出

$(TARGET) : $(OBJS)
@echo linking...
@g++ $^ -o $@ $(LIBS)
@echo complete!

$(OUTPUT)/%.o : %.cpp
@echo compling...
@mkdir -p $(dir $@)
@g++ -MMD -MP -c $(INCS) $< -o $@
@echo complete!

.PHONY : clean

clean :
@echo cleaning...
@rm -rf $(OUTPUT) $(TARGET)
@echo complete!

-include $(DEPS)

注意: Please note: you need to put a tab character at the beginning of every recipe line!

在每个执行语句要用tab字符(制表符)开头!

关于GCC编译选项的一些补充

  • -I 指定搜索头文件的路径
  • -L 指定搜索库文件的路径
  • -l 指定链接的库文件如-lm链接数学库
  • -D 类似于c中的宏定义,在-D中出现的变量在C中可使用,如-D DEBUG=1,然后在C中打印出DEBUG这个变量会显示1的值

参考文件


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!