Makefile的使用
1 概要
软件的分层使软件的逻辑关系更清晰,但是也带来一个副作用,即Makefile也变得复杂了。道理显而易见:对于一个简单项目,如果所有文件都放在同一个文件夹内,Makefile写起来也会十分简单,但是我们不能一直停留在原始时代,当复杂项目的源文件按类型、功能、模块等分散到不同路径时,需要我们掌握复杂的Makefile写法来编译它们。
Makefile其实就是一套规则(相当于脚本),make按照这一套规则(相当于解释器),生成最后的结果;其次如果只改了某些文件,重新编译时可以只编译那些改变的部分(即增量编译),这样加快了再次编译速度。所以掌握了Makefile可以实现自动化编译可以提高版本构建效率。
2 Makefile的框架
很多人会被Makefile吓到,归结下来,可能是如下几个原因:
一是因为Makefile有独自的写法,和平时常用的c、python等语法不同;
二是一些IDE屏蔽了工程的构建信息,由于不常使用所以不熟悉;
三是Makefile中涉及到编译的几个步骤以及gcc命令、shell脚本等,需要多方面的知识,更令人迷惑的则是Makefile中的隐式规则。
但把这些知识点一个个弄明白,分而治之,掌握Makefile也非难事。
Makefile入门网上有许多资料,本文只是梳理我认为的几个Makefile常用知识点。以下分别介绍gcc编译C代码的过程以及复杂Makefile的编写。
2.1 gcc编译C代码过程
对于如下c代码:
#include <stdio.h>
int main()
{
printf("hello, world\n");
return 0;
}
其编译过程如下4步(编译环境为Ubuntu):
step1: 预编译:
gcc -E hello.c -o hello.i
程序中有以#include #define开头的行,称为预处理语句(C语言的编译预处理命令必须用“#”开头),在编译之前必须由编译预处理将它们替换成C编译程序能够接受的正文。分为:
-
宏定义, #define 展开宏定义
-
条件编译, 如:“#if” “#ifdef” “#else” “#elif” “#endif”等
-
文件包含 #include,将被包含的文件插入到该预编译指令的位置
还有一些删除注释、添加行号和文件名标识等也是在预处理这一步完成的。
step2: 编译:
gcc -S hello.i -o hello.s
编译是指:将高级语言(C语言)翻译为汇编语言的过程,其中包括翻译和查错(词法分析、语法分析、语义分析生成和优化目标代码,出错时,停止编译)。
step3: 汇编:
gcc -c hello.s -o hello.o
汇编过程是将汇编代码转换为机器代码的过程,每一条汇编语句几乎都对应着一条机器指令。
step4: 链接:
gcc -o hello hello.o
示例中hello.c 程序调用了 printf 函数(存在在libc.a中),链接器将libc.a中的print.o与hello.o重新组织以下,形成最终的可执行程序,这属于静态链接过程(static linking)。
2.2 复杂Makefile的组织方式
只有单个文件的Makefile写起来与直接用手敲2.1节的几条命令区别并不大,并不能体现出Makefile的优势;当项目变得复杂(目录结构很多),Makefile才能体现其作为脚本的优势。
1. make与Makefile与gcc的关系:
gcc是编译器;而make是一个命令工具,用来解析makefile脚本。
可以这么简单的比方:
makefile是像一首歌的曲谱,曲谱中写了怎么调用gcc、 GNU binutils、shell命令等对整个项目的各个文件进行分别编译和链接;
make工具就像指挥家,指挥家根据曲谱指挥演奏者怎么样演奏(make工具就根据makefile中的命令进行编译和链接的);
而gcc、 GNU binutils、shell等像演奏者,实际干活的是它们。
2. 构建的核心:
简单认为:构建就是将库、可链接二进制文件(linux上的.o文件)链接成可执行文件的过程。涉及到几个问题:
- 库从什么地方找?(-L指令)
- 库的名字是什么(-l指令)
- 怎么编译库或者可链接二进制文件?源文件怎么收集?(wildcard、foreach、call等系列函数的灵活使用)
- 头文件哪里找?(-I指令 汇编的时候才需要头文件,链接时不需要头文件)
在编写Makefile时,解决上述几个问题,就可写出正确的Makefile。当然还要注意不少的细节。
3. 复杂Makefile组织的方式:
例如:一个多目录结构,文件夹如树状结构组织,源文件分散在其中。Makefile同样需要树状结构组织(这并不绝对,只要Makefile能找到源文件即可)。
例子:引用的是李老师的B站视频(见参考3),虽然例子比较简单,但是复杂的文件结构也可类似处理。例子已推送到github:
https://github.com/sz-ok/Makefile_learning
下图中手动为Makefile指定了一个标记,方便后面表述。
$ tree
.
├── head
│ └── head.h
├── main
│ ├── main.c
│ └── Makefile --------- mk-main
├── Makefile ------------- mk-top
└── tst
├── foo
│ ├── foo.c
│ └── Makefile ----- mk-foo
├── Makefile --------- mk-tst
└── tst.c
mk-top:
#作用是制定规则来说明当前目录下生成终极目标文件test
TGT = test
#指定子目录
SUB_DIR = main tst
#指定当前目录
export TOP_PATH = $(shell pwd)
#指定头文件目录
export HEAD_PATH = $(TOP_PATH)/head
#指定子目标
export SUB_TGT = bulit_in.o
#CROSS_COMPILER = arm-linux-
export CC = $(CROSS_COMPILER)gcc
#编译选项,指定编译时的头文件路径
export CFLAGS = -I$(HEAD_PATH) -Wall
#指定链接器
export LD = ld
#指定链接器选项
export LDFLAGS =
#终极目标 (后面表示包括子目录的所有.o)
.PHONY: all clean $(SUB_DIR)
all:$(TGT)
$(TGT): $(SUB_DIR)
$(CC) $(CFLAGS) $(^:=/$(SUB_TGT)) -o $@
#下面规则说明进入到生成test所需要依赖的子目录
#-C选项,可以让make进入到后面指定的目录
$(SUB_DIR):
make -C $@
clean:
-rm -f $(TGT)
for dir in $(SUB_DIR); do \
make -C $$dir clean; \
done
mk-main:
SRCS = main.c
SUB_DIR =
all:$(SUB_TGT)
.PHONY: $(SUB_TGT) $(SUB_DIR) clean
#下面的规则说明,如何生成当前目录下的子目标(是由当前目录下的.c生成的.o和当前下的
子目录下的子目标临时打包生成的)
$(SUB_TGT): $(SRCS:.c=.o) $(SUB_DIR)
$(LD) $(LDFLAGS) $(SRCS:.c=.o) $(SUB_DIR:=/$(SUB_TGT)) \
-r -o $@
%.o: %.c
$(CC) $(CFLAGS) $< -c
%.d: %.c
$(CC) $(CFLAGS) $< -MM > $@
#表明main.o编译会关系到main.d, 而main.d又关联到main.c和common.h,所以只要main.c所引用到的头文件有所修改,都会重新编译main.o和main.d
ifneq ($(MAKECMDGOALS), clean)
sinclude $(SRCS:.c=.d)
endif
$(SUB_DIR):
make -C $@
clean:
-rm -f *.o *.d
for dir in $(SUB_DIR); do \
make -C $$dir clean; \
done
mk-tst:
SRCS = tst.c
SUB_DIR = foo
all:$(SUB_TGT)
.PHONY: $(SUB_TGT) $(SUB_DIR) clean
#下面的规则说明,如何生成当前目录下的子目标(是由当前目录下的.c生成的.o和当前下的
子目录下的子目标临时打包生成的)
$(SUB_TGT): $(SRCS:.c=.o) $(SUB_DIR)
$(LD) $(LDFLAGS) $(SRCS:.c=.o) $(SUB_DIR:=/$(SUB_TGT)) \
-r -o $@
%.o: %.c
$(CC) $(CFLAGS) $< -c
%.d: %.c
$(CC) $(CFLAGS) $< -MM > $@
#表明tst.o编译会关系到tst.d, 而tst.d又关联到tst.c和common.h,所以只要tst.c所引用到的头文件有所修改,都会重新编译tst.o和tst.d
ifneq ($(MAKECMDGOALS), clean)
sinclude $(SRCS:.c=.d)
endif
$(SUB_DIR):
make -C $@
clean:
-rm -f *.o *.d
for dir in $(SUB_DIR); do \
make -C $$dir clean; \
done
foo目录与main目录一样,没有子文件夹,所以mk-foo的Makefile与mk-main一致。
其编译过程大略如下: foo目录编译生成bulit_in.o, 与 tst目录下的tst.o 打包生成 bulit_in.o,与 main目录编译生成的bulit_in.o链接得到最终目标文件test。
- top Makefile:进入各子目录下执行make命令,将各个子目录下的.o文件链接生成可执行文件
- 子Makefile:将当前目录下的.c文件编译生成.o文件
3 Makefile中的一些知识点
3.1 常见gcc命令
gcc是GNU compiler collection 的缩写,注意gcc是一个编译器集合,gcc工作时需要binutils配合。
gcc调用binutils工具集:
命令 | 等价命令 | 用途 |
---|---|---|
-S | cc1 仅编译,不进行汇编、链接 | 编译 |
-c | as (binutils工具集) | 汇编 |
-o | ld (binutils工具集) | 链接 |
gcc常用命令:
选项 | 用途 |
---|---|
-E | 只进行预处理,不进行编译、汇编、链接 |
-D | 使用-D name[=definition]预定义名为name的宏 |
-l(小L) | 使用-l libname或者-llibname,使链接器在链接时搜索名为libname.a/libname.so (静态/动态)的库文件 |
-L | 使用-Ldir添加搜索目录,即链接器在搜索-l选项指定的库文件时,除了系统的库目录还会(优先)在-L指定的目录下搜索 |
-I(大写的i) | 使用-I dir,将目录dir 添加为头文件搜索目录 |
-include | 使用-include file,等效于在被编译的源文件开头添加#include "file" |
-static | 指定静态链接(默认是动态链接) |
-O0~3 | 开启编译器优化,-O0为不优化,-O3为最高级别的优化 |
-Os | 优化生成代码的尺寸,使能所有-O2的优化选项,除了那些让代码体积变大的 |
-Og | 优化调试体验,在保留调试信息的同时保持快速的编译,对于生成可调试代码,比-O0更合适,不会禁用调试信息。 |
-Wall | 使编译器输出所有的警告信息 |
-march | 指定目标平台的体系结构,如-march=rv32imafdc ,常用于交叉编译 |
-mtune | 指定目标平台的CPU以便GCC优化,如-mtune=nuclei-300-series ,常用于交叉编译 |
-M | 生成文件关联的信息。包含目标文件所依赖的所有源代码 |
-MM | 生成文件关联的信息。 |
-MMD | 和-MM相同,但是输出将导入到.d的文件里面 |
make常用命令:
(通过make -h可查看全部指令,这里仅列出2个常用的)
选项 | 用途 |
---|---|
make -f filename | 执行指定的Makefile或其他文件名 |
make -C DIRECTORY | 跳到指定目录执行Makefile |
3.2 Makefile中变量
变量的赋值方式:
赋值方式 | 作用 |
---|---|
= | 延迟赋值 (变量的值是整个makefile中最后被指定的值) |
:= | 立即赋值(赋予当前位置的值,不受后面值的影响 ) |
?= | 条件赋值(如果之前有赋值,则不会赋值; 否则采用此条赋值) |
+= | 追加赋值(拼接,以空格隔开,Makefile中变量类型是字符串类型) |
注意:?= 与 +=也是默认延迟赋值。
可以结合实例进行理解,如下:
- 延迟赋值 =
例如:
# test =
A = 2233
B = ${A}
A = 7788
all:
@echo "test ="
@echo A = $A, B = $B,
结果为:
test =
A = 7788, B = 7788,
“=”是最普通的等号,然而在Makefile中确实最容易搞错的赋值等号,使用”=”进行赋值,变量的值是整个makefile中最后被指定的值。
- 立即赋值 :=
类似上述例子,将=换为:=
# test :=
A = 2233
B := ${A}
A = 7788
all:
@echo "test :="
@echo A = $A, B = $B,
结果为:
test :=
A = 7788, B = 2233,
”:=”就表示直接赋值,赋予当前位置的值,不受后面值的影响。
- 条件赋值 ?=
例如:
# test ?=
A = 2233
A ?= 7788
all:
@echo "test :="
@echo A = $A,
结果为:
test :=
A = 2233,
“?=”表示如果该变量没有被赋值,则赋予等号后的值。
怎么理解?= 也是默认延迟赋值呢?
同样例如:
# test ?=
A ?= 7788_${B}
B = 'BBBB'
all:
@echo "test ?="
@echo A = $A,
结果为:
test ?=
A = 7788_BBBB,
- 追加赋值 += (以空格隔开)
例如:
# test +=
A = 2233
A += 7788_${B}
B = 'BBBB'
all:
@echo "test +="
@echo A = $A,
结果为:
test +=
A = 2233 7788_BBBB,
可见+= 也是默认延迟赋值属性。
但是修改上述例子:
# test +=
A := 2233 # 将A = 2233改为A := 2233, += 好像失去了延迟赋值的属性了
A += 7788_${B}
B = 'BBBB'
all:
@echo "test +="
@echo A = $A,
结果为:
test +=
A = 2233 7788_,
通过对比,可大概了解这些算符的差别。(PS: 我无力吐槽,为啥设计出这些令人迷惑的东西?)
Makefile中的特殊变量:
特殊变量 | 作用 |
---|---|
$@ | 当前规则的目标 |
$^ | 依赖列表(所有依赖) |
$< | 第一个依赖 |
$$ | 当前执行的进程的进程编号 |
$* | 模式规则中所有%匹配的部分 |
$? | 模式规则中所有比所在规则中的目标更新文件组成的列表 |
代表命令的变量:
Makefile书写中,有一些书写约定。比如:与编译器相关的一些命令,可以用变量来表示,其好处是:当换编译工具等,可以仅改变变量(相当于C语言的define的作用),约定并不是强制规则,但是按照约定会给他人阅读你的代码带来方便。
常见约定的变量如下表所示:
变量 | 含义 |
---|---|
CC | C编译程序。默认是"cc" |
CXX | C++编译程序。默认是"g++" |
CPP | C/C++预处理器。默认是"$(CC) -E" |
AR | 函数库打包程序,可创建静态库.a文档。默认是"ar"。 |
AS | 汇编程序。默认是"as“ |
CFLAGS | C编译程序的命令行参数 |
CXXFLAGS | C++编译程序的命令行参数 |
CPPFLAGS | C/C++预处理器的命令行参数 |
ARFLAGS | 函数库打包程序的命令行参数。默认值是"rv" |
ASFLAGS | 汇编程序的命令行参数 |
LDFLAGS | 链接器的命令行参数 |
一些常用shell命令(如cp ls等)可以直接在Makefile中使用。
Makefile环境变量:
只列几个常用的变量
变量 | 含义 |
---|---|
MAKEFILES | make执行时首先将此变量的值作为需要读入的Makefile文件,多个文件之间使用空格分开。 |
MAKEFILES_LIST | make 程序在读取多个 makefile 文件时,读取的文件名将会被自动依次追加到变量MAKEFILE_LIST中。 |
MAKECMDGOALS | 代表了make执行的终极目标 |
CURDIR | 此变量代表 make 的工作目录。当使用“-C”选项进入一个子目录后,此变量将被重新赋值。 |
.SUFFIXES | 可识别的后缀 |
如下是MAKECMDGOALS应用例子(见2.2节)
意味着,如果最终目标是clean,需要执行sinclude语句。
#表明main.o编译会关系到main.d, 而main.d又关联到main.c和common.h,所以只要main.c所引用到的头文件有所修改,都会重新编译main.o和main.d
ifneq ($(MAKECMDGOALS), clean)
sinclude $(SRCS:.c=.d)
endif
clean:
3.2 Makefile中函数列表
文本处理函数
函数名 | 作用 |
---|---|
$(subst FROM,TO,TEXT) | 字符串替换函数:把字串“TEXT”中的“FROM”字符替换为“TO” |
$(patsubst PATTERN,REPLACEMENT,TEXT) | 支持通配符的字符串替换函数 |
$(strip STRINT) | 去掉字串“STRINT”开头和结尾的空字符,并将其中多个连续空字符合并为一个空字符。 |
$(findstring FIND,IN) | 查找字符串函数,如果在“IN”之中存在“FIND”,则返回“FIND”,否则返回空。 |
$(filter PATTERN…,TEXT) | 过滤函数,空格分割的“TEXT”字串中所有符合模式“PATTERN”的字串 |
$(filter-out PATTERN...,TEXT) | 反过滤函数,和“filter”函数实现的功能相反 |
$(sort LIST) | 排序函数,给字串“LIST”中的单词升序排列,并去掉重复的单词 |
$(word N,TEXT) | 取字串“TEXT”中第“N”个单词(“N”的值从 1 开始) |
$(wordlist S,E,TEXT) | 从字串“TEXT”中取出从“S”开始到“E”的单词串。“S”和“E” 表示单词在字串中位置的数字 |
$(words TEXT) | 统计单词数目函数 |
$(firstword NAMES…) | 取首单词函数,等效于$(word 1 , NAMES…) |
文件名处理函数
函数名 | 作用 |
---|---|
$(dir NAMES…) | 取目录函数-从文件名序列“NAMES…”中取出各个文件名的目录部分 |
$(notdir NAMES…) | 取文件名函数-文件名序列“NAMES…”中每一个文件的非目录部分 |
$(suffix NAMES…) | 取后缀函数 |
$(basename NAMES…) | 取前缀函数 |
$(addsuffix SUFFIX,NAMES…) | 加后缀函数 |
$(addprefix PREFIX,NAMES…) | 加前缀函数 |
$(join LIST1 ,LIST2) | 将字串“LIST1”和字串“LIST2”各单词进行对应连接 |
$(wildcard PATTERN) | 获取匹配模式文件名函数,列出当前目录下所有符合模式“PATTERN”格式的文件名。支持通配符 |
流程相关函数
函数名 | 作用 |
---|---|
$(foreach VAR,LIST,TEXT) | 类似于 for VAR in LIST:TEXT |
$(if CONDITION,THEN-PART[,ELSE-PART]) | 类似于 CONDITION?THEN-PART:ELSE-PART |
$(call VARIABLE,PARAM1,PARAM2,...) | call”函数是唯一一个可以创建定制化参数函数的引用函数,VARIABLE表达式中的$(1),$(2),$(3)等,会被参数< PARAM1>;, |
$(value VARIABLE) | 不对变量“VARIBLE”进行任何展开操作,直接返回变量“VARIBALE” 的值。 |
$(eval VARIABLE) | 根据其参数的关系、 结构,对它们进行替换展开。经常搭配call函数使用,见参考2 |
$(origin VARIABLE) | 获取此变量(参数)相关的信息,告诉我们这个变量的定义方式 |
shell函数 | 函数“shell”的参数(一个 shell 命令)在 shell 环境中的执行结果 |
3.3 Makefile中模式规则
Makefile中的隐含规则:
如果一个目标文件在Makefile中没有重建它的明确规则,但make时依旧正确运行,有可能时make用到了隐含规则来重建它。
可以使用make -p打印出make的所有隐含规则,调用顺序:显示规则 > 隐含规则 > 否则报错
规则中的模式替换:
格式为:
< targets …>: < target-pattern>: < prereq-patterns …>
<commands>
<targets …>:指定一个或多个目标文件,可使用通配符。
<target-pattern...>:指定 <targets …>目标文件的模式,如%.o,表示<targets>集合中都是以.o结尾的文件。
<prereq-patterns …>:指定<targets …>目标文件依赖的文件的模式,如%.c ,表示 <targets …>集合中的目标文件的依赖文件都是以.c结尾的文件
例子:
TARGET = main.o hello.o test.o
all:$(TARGET)
$(TARGET):%.o:%.c
gcc -c $< -o $@
这个模式规则指明了如何由%.c 来创建%.o,属于makefile中的隐含规则。
3.4 Makefile中的变量(字符串)替换
-
后缀字符串替换,将字符串中的后缀字符(串)使用指定的字符(串)进行替代。
格式为 $(var:a=b)
意思是:将var表达式中以空格分开所有的子串,以a结尾的字符替换为b。
VAR := acc bcc ccd NEW := $(VAR:cc=aa) test: @echo "new is $(NEW)"
make test后结果为“new is aaa baa ccd”,通过例子可以看出这种方法只能处理后缀字符串替换。
NEW := $(VAR:=aa)
则结果为“new is accaa bccaa ccdaa”,这种方式可以在变量后添加新的字符串。
-
变量中的模式替代
使用%来匹配模式,%匹配的保留字符,其它为替代字符,较第一种方法更为通用。
格式为$(var:a%b=x%y)
VAR := a.c b.c c.c NEW := $(VAR:%.c=%.o) test: @echo "new is $(NEW)"
make test后结果为“new is a.o b.o c.o”
-
模式替代函数
格式为 $(patsubst pattern, replacement, text)
搜索text中以空格分开的单词,将符合pattern模式替换为replacement,pattern和replacement支持%通配符。
VAR := a.c b.c c.c NEW := $(patsubst %.c, %.o, $(VAR)) test: @echo "new is $(NEW)"
make test结果同样为“new is a.o b.o c.o”
这几种方法得到的效果是相同的:
$(patsubst %.c, %.o, $(VAR)) $(VAR:%.c=%.o) $(VAR:.c=.a)
3.5 Makefile中的PHONY关键字
Makefile中.PHONY关键字修饰的目标被称之为伪目标。其作用如下:
-
避免目标名与文件名重名。用.PHONY修饰后告诉make 目的为了执行执行一些列命令,而不需要创建这个目标。
如上节例子中的clean,用.PHONY修饰后,无论在当前目录下是否存在“clean”这个文件。我们输入“make clean”之后。“rm”命令都会被执行。而且当一个目标被声明为伪目标后,make 在执行此规则时不会去试图去查找隐含规则来创建它。这样也提高了 make 的执行效率。
-
伪目标的另外一种使用场合是在 make 的并行和递归执行过程中。
并行:
# 写法1: SUBDIRS = foo bar baz subdirs: for dir in $(SUBDIRS); do \ $(MAKE) -C $$dir; \ done # 写法2:这种写法出错更容易定位,且利用了make的并行处理功能。 SUBDIRS = foo bar baz .PHONY: subdirs $(SUBDIRS) subdirs: $(SUBDIRS) $(SUBDIRS): $(MAKE) -C $@ foo: baz
递归:
.PHONY: cleanall cleanobj cleandiff cleanall : cleanobj cleandiff rm program cleanobj : rm *.o cleandiff : rm *.diff
当一个伪目标作为另外一个伪目标依赖时,就成了必须执行的部分,如同cleanall调用了cleanobj与cleandiff。
3.6 Makefile 中 echo 和@echo的区别
echo: 会在shell中显示echo这条命令和后面要输出的内容
@echo: 不会显示echo这条命令,只会显示后面要输出的内容
例如:
echo “hello world” 输出为:
echo "hello world"
hello world
@echo "hello world" 输出为:
hello world
3.7 在Makefile打印错误或警告信息
# 在makefile中打印警告或者错误消息的方法:
$(warning xxxxx)
# 或者
$(error xxxxx)
# 输出变量方式为:
$(warning $(XXX))
参考: