Makefile中的基本概念及语法

本文假设你使用过makefile, 了解最基本的一些概念, 比如makefile是干什么用的, makefile中注释是怎么写的...

变量

推荐在makefile内部使用小写字母作为变量名, 以区分make中的内建变量和环境变量.
要注意赋值等号后面的, 空格是有效的字符. 也不要将注释写在和变量定义的同一行, 注释最好独占一行.

变量定义
makefile中变量的定义有以下几种方式:
var:=value: 简单扩展型变量
var=value: 递归扩展型变量
var?=value: 变量为空则赋值
var+=value: 变量追加内容

要了解这四种变量复制的方式有何不同, 则需要了解make处理makefile文件的方式, make串行处理makefile文件文本, 对其做两编解析, 其中:
第一遍读取makefile和include的文件内容, 变量和规则被加载到make内部数据库, 生成依赖图
第二遍通过依赖图判断哪些目标需要更新, 执行相应的脚本

所以对于上述四种赋值方式:
:= 赋值: 变量在第一遍解析时直接赋值
= 赋值: 变量在第二遍解析时才进行赋值, 若value本身是其他该类型变量, 则变量递归展开, 此之谓递归扩展性变量
?= 赋值: 变量在第二遍解析时才进行赋值, 因为它需要第一遍解析来判断该变量是否存在
+= 赋值: 变量何时赋值, 取决于变量最初定义时是何种变量, 默认是递归扩展性变量

# ------ file:makefile ------
simple_value:=10
simple_var:=$(simple_value) # simple_var立即赋值为 10
recursion_var=$(simple_value) # recursion_var为何值待决议
simple_value=20 # simple_value发生改变, recursion_var延迟决议值为 20
$(warning $(simple_var)) # 输出10
$(warning $(recursion_var)) # 输出20

# ------ file:makefile ------
simple_value:=10
simple_var:=$(simple_value) # simple_var立即赋值为 10
recursion_var=$(simple_value) # recursion_var为何值待决议
simple_var+=$(simple_value) # simple_var立即追加值为 10 10
recursion_var+=$(simple_value) # recursion_var延迟追加
null_var?=$(simple_value) # null_var为何值待决议
simple_value=20 # simple_value发生改变, recursion_var延迟决议值为 20 20, null_var延迟决议值为 20
$(warning $(simple_var)) # 输出10 10
$(warning $(recursion_var)) # 输出20 20
$(warning $(null_var)) # 输出20

变量取值
通过上方例子可观察到, makefile中变量的取值使用过$(...)获取的.

$(simple_value)
获取simple_value变量的值, 取值时便会根据变量的类型判断此时是否展开(取值).
需要注意, 当变量取值发生在赋值操作左边时, 变量总是立即展开. 在makefile中这样的操作时允许的, 在变量定义的时候可能遇到的少, 作为目标时则很常见.

simple_value=10
recursion_var=$(simple_value) # recursion_var为何值待决议
$(warning $(recursion_var)) # 但此处要求立即获取recursion_var的值为 10
var=recursion_var # var为何值待决议
$(var)=$(simple_value) # $(var)出现在等号左边, 则立即展开, 为 recursion_var=$(simple_value), recursion_var为何值待决议
simple_value=20 # simple_value发生改变, recursion_var延迟决议值为 20
$(warning $(recursion_var)) # 输出20

环境变量
make使用的变量可以来自make的运行环境, 任何make能够看见的环境变量, 在make开始运行时都转变为同名同值(变量名全大写)的make变量(通过make -p能够看到这些环境变量), 此处列举一些环境变量, 它们可以直接使用, 也可以被修改, 约定俗成的使用这些名称的环境变量能够降低在阅读makefile时的理解

AR=ar
ARFLAGS=rv
CXX=g++
CXXFLAGS
LDFLAGS
LFLAGS

自动变量
自动变量在规则每次执行时都基于当前的目标和依赖自动取值.

$@: 规则的目标文件名
$<: 第一个依赖的文件名
$^: 所有依赖的名字, 名字之间用空格隔开
(还有一些其他的自动变量: $% $? $+ $*, 相对来说用的较少, 需要的自行查阅资料了解.)

对于自动变量都有两种变体: 分别用于获取 目标文件名中的路径部分目标文件名中的文件名部分.
例如: 对于$@的值是dir/foo.o, 则有:
$(@D): 值为dir(不含最后的斜杠). 若dir不存在则为.
$(@F): 值为foo.o

# 此处 $< 为 %.cpp, $@ 为 $(obj_output_dir)/release/%.o
$(obj_output_dir)/release/%.o : %.cpp
    $(CXX) -c $(RELEASE_FLAGS) $(CXXFLAGS) $(INCLUDE_FLAGS) $(src_dir)/$< -o $@

目标和规则

显示规则
下面是makefile中规则的定义方式, 一条规则由目标,依赖,命令组成, 此类规则为显式规则.

# 一般情况下target只有一个, 但也可以是有多目标的
target: prerequiries; command # 如果只有一个目标则也可以和规则放在同一行, 用;分开
<tab>command # 单行命令前面是一个<tab>

目标(target): 一般会生成文件, 如果不是实际的文件, 则最好将其设置成假想目标(假想目标是一类特殊目标)
依赖(prerequiries): 生成目标的前置条件, 如果该前置条件作为目标需要被生成, 则转而先生成该依赖目标
命令(command); 生成目标所使用的命令, 命令的行首总应该是一个<tab>占位, 每行命令在单独的shell环境中执行

  • make在执行命令之前首先打印命令行, 以 @ 起始的命令则不会回显
  • 如果命令出现错误(非零退出状态), make将放弃当前的规则, 以 - 起始的命令会忽略一个命令执行产生的错误

隐含规则
隐含规则虽然很方便, 但了解这些隐含规则实在让人头疼, 并且大多数时候这些隐含规则也不完全适用. 即使在不了解隐含规则的情况下也能写好makefile(并且可读性更高), 所以关于隐含规则此处不做多介绍, 仅了解隐含规则的存在即可.
(通过make -p能够看到这些隐含规则)

# 这是make数据库中内建的一条隐含规则, 默认的一种情况.o文件可以从.cpp文件编译得到
%.o: %.cpp
#  recipe to execute (built-in):
        $(COMPILE.cpp) $(OUTPUT_OPTION) $<

特殊目标
.PHONY
用于指定假想目标, 假想目标用于解决这样一个问题, 对于实际的目标, make会根据目标的状态判断是否需要执行相关操作来更新目标, 若目标(及依赖)并无改动则不需要生成对应的目标. 例如makefile中一般都有clean目标, 若恰好目录中有名为clean的文件, 则根据clean文件的状态, make clean可能不会按预期执行. 将其设定为假想目标, 则能够保证, 无论clean文件是否存在, clean目标下的命令都会执行.

# 将all, debug, release, clean目标设定为假想目标
.PHONY: all debug release clean

.LIBPATTERNS
用于指定库的展开方式, 缺省值是lib%.so lib%.a, 这是很多系统库,开源库库名的默认格式. 有了这条指示, 在链接库时就不用写库全名了, make会自动按格式展开库名.

# 此处在链接过程中会展开为 libpthread.so 和 libdl.so, 他们是
LIBRARY_FLAGS+= -lpthread -ldl

(还有其他特殊目标, 有需要的自行了解, 因不常用则此处不提)

特定目标变量/特定格式变量
这些变量只能在一个目标的命令脚本的上下文起作用(实际环境中用到的不多)

# 此CFLAGS=-g仅对生成prog有效
prog : CFLAGS=-g  
# 此CFLAGS=-O仅对生成.o有效
%.o : CFLAGS=-O  

目录搜寻过程
如果目标没有具体的规则, make则使用隐含规则, 如目标隐含规则存在, make使用内置的规则编译它
如果依赖的目标不在当前目录下, 就搜寻适当的目录

make特别地在当前目录下,与vpath匹配的目录下,VPATH指定的目录下,/lib,/usr/libprefix/lib目录下搜寻依赖的文件, 这里面需要说明的是vpath执行和VPATH变量.

VPATH
值指定了make搜寻的目录
vpath pattern directories
vpath指令, 对一定格式类型的文件名指定一个搜寻路径
pattern是一个包含一个'%'的字符串, 字符%可和任何字符串匹配
directories可以指定多项, 目录的名字由冒号或空格分开

# 对于所需要的文件, 若没有找到就去这些目录找. 目录的名字由冒号或空格分开
VPATH = src ../headers
# 在当前目录没有找到.h文件就去../headers目录中找 
vpath %.h ../headers

需要注意的是, vpath在编译阶段为依赖的目标指定相关目录, 以便能够找到这些依赖的文件, 这和链接器执行链接操作时, 需要指定包含头文件性质是不一样的, 链接还是需要通过-I指定包含头文件.

项目管理

一般来说一个makefile文件管理当前目录下的源码, 当项目源码较多后, 可能期望能有一些方式对项目源码进行管理, 这时就涉及到源码的拆分,编译链接上的问题(实际就是组织makefile的问题), 这涉及到以下知识点.

include包含
include file
用于包含其它的makefile文件, 当一个项目中包含了很多makefile时, 并且它们的内容大同小异, 则可以考虑创建一个公用的makefile, 以减少实际项目中makefile中的内容. 并且, 通过约定公用的makefile文件, 可以达到一些规范的目录, 例如文件生成目录之类.

变量重载
override var=...
当决定使用include包含其他makefile文件后, 则需要考虑如何使用公用makefile文件中的一些变量, 其中最常见的一种需要是重载变量, 即公用的makefile中存在的一些变量, 需要在项目中的的makefile文件中覆盖其内容, 以达到某种处理, makefile中的override指令可以完成这个任务.

override指令可以理解为提供了这样一种机制, 用override修饰后的变量不会在后续的赋值操作中被覆盖!(或者说对于后续出现赋值操作, 都会被由override修饰的变量所覆盖)

override target_file=program

# 此时, 即使../../share/makefile存在target_file变量, 其值仍为program
include ../../share/makefile

递归调用make
递归调用make的命令总是使用变量MAKE, 而不是明确的命令名make, 在文章最后也提及了, 对于要使用的命令最好为其创建一个大写的同名变量(MAKE作为环境变量则已经存在).

这里使用了--directory(或-C)选项, 指示make将先进入指定目录后再执行.

# 递归子目录执行make
# MAKECMDGOALS变量为在执行make命令时附带的参数(目标)列表, 例如make debug, 参数时debug
makesubdirs:
    @for subdir in $(src_subdirs); do \
        $(MAKE) --directory $$subdir $(MAKECMDGOALS); \
    done

clean :
    @echo  "make clean"
    @for subdir in $(src_subdirs); do \
        $(MAKE) --directory $$subdir clean; \
    done

链接
递归编译后, obj文件放在哪的, 如何方便的链接? 如果使用公共的makefile指定了obj目录在当前目录下, 则在每个子目录都创建自己的obj文件夹, 链接收集这些obj文件会比较麻烦. 如果将所有的obj都输出到同一目录, 则链接就方便了.
可以在公共的makefile中做花招, 让obj的输出目录总是在src/obj这个目录(假设src就是源码根目录), 对于src下的子目录, 则对其中的makefile, 通过公共的makefile文件, 能够自行计算到src的相对路径, 以便找到最终的obj文件夹.

# 默认为src目录下的obj目录, 关于subdir_relative_src的值可以在本文搜索一下
obj_output_dir:=$(subdir_relative_src)/obj

# 进行链接
obj_debug_files:=$(addprefix $(obj_output_dir)/debug/, $(notdir $(obj_files)))
# 拿到所有的.o文件: $(obj_output_dir)/debug/*.o
$(target_debug_file) : $(obj_debug_files)
    $(CXX) $(DEBUG_FLAGS) $(CXXFLAGS) $(INCLUDE_FLAGS) $(obj_output_dir)/debug/*.o -o $@ $(DEBUG_LIBRARY_FLAGS)

条件语句

ifeq/ifneq/-else-endif
判断条件是否成立
ifdef/ifndef-else-endif
判断变量是否定义

ifeq (arg1, arg2)
ifeq 'arg1' 'arg2'
ifeq "arg1" "arg2"
参数的写法上面三种均可

# 若没有指定目标, 则不生成目标输出目录
ifneq "$(target_file)" ""
    @mkdir -p $(target_output_dir)/debug
    @mkdir -p $(target_output_dir)/release
endif

内建函数

以下对于常用的函数有示例, 其他仅供了解.

字符串处理
$(patsubst pattern,replacement,text)
寻找text中符合格式pattern的字, 用replacement替换它们. pattern和replacement中可包含通配符%
($(foo:%.o=%.c)patsubst的简写形式, 等价于$(patsubst .o, .c, $(foo)))
$(findstring find,in)
在字符串in中搜寻find, 如果找到, 则返回值是find, 否则返回值为空
$(subst from,to,text)
在文本text中使用to替换每一处from
$(strip string)
去掉前导和结尾空格, 并将中间的多个空格压缩为单个空格
$(filter pattern...,text)
返回在text中由空格隔开且匹配格式pattern...的字, 对于不符合格式pattern...的则移出
$(sort list)
将list中的字按字母顺序排序, 并取掉重复的字, 输出由单个空格隔开

文件名处理
$(wildcard pattern)
获取匹配的文件名, pattern是一个可以含通配符的文件名格式
$(dir names...)
抽取names中每一个文件名中的路径部分, 含最后的斜杠
$(notdir names...)
抽取names中每一个文件名中的文件名
$(suffix names...)
抽取names中每一个文件名的后缀
$(basename names...)
抽取names中每一个文件名中除后缀外一切字符
$(addsuffix suffix,names...)
为names中的每一个文件名添加后缀
$(addprefix prefix,names...)
为names中的每一个文件名添加前缀

其他
$(foreach item,list,$(item))
对于list中的每一个元素item, 对item执行操作
$(if condition,then-part[,else-part])
if函数
$(origin variable)
变量的来源(它是内建变量的还是makefile中定义的之类)
$(shell cat foo)
shell函数, 在shell环境执行命令
$(warning text...)
错误信息但不退出
$(error text...)
错误信息并退出

# 获取目标文件后缀
target_suffix:=$(suffix $(target_file))

# 生成一份与源码文件名同名的.o文件名
obj_files:=$(patsubst %.cpp, %.o, $(src_files))

# 为obj文件名添加执行前缀
obj_debug_files:=$(addprefix $(obj_output_dir)/debug/, $(notdir $(obj_files)))
obj_release_files:=$(addprefix $(obj_output_dir)/release/, $(notdir $(obj_files)))

# 从当前路径找是否存在src/名的目录
is_subdir=$(findstring src/,$(CURDIR))

# 获取所有的cpp文件名, 剔除目录部分
src_files+=$(notdir $(wildcard $(src_dir)/ *.cpp))

# 当前目录下svn版本
svn_version:=$(shell svnversion ../)

宏? 自定义函数?

在makefile中除了内建函数似乎并不支持直接创建函数, 能够实现的是定义一个宏, 它封装了一些操作, 以便在其它位置使用, 在调用宏的时候也完全是做的宏替换.
宏本身不会返回任何结果, 所以不能将它看做是一个函数调用, 但可以通过$(shell ...)内建函数可以做到想要的任何事情(shell脚本能做的都能在makefile中做到), $(shell ...)是可以有返回值的.
宏调用通过$(call ...)函数, 它支持向宏传递参数.
通过$(shell ...)来获取返回值, 通过$(call ...)来调用宏及传参, 也就可以看作是自定义了一个函数.

# 定义宏, 通过shell脚本当前目录到父目录的相对路径
# 可指定一个参数($1), 指定父目录名
# 例如当前路径为: /home/user/trunk/project, 指定父目录为trunk, 则得到 ..
define get_relative_path
$(shell dir_flag="$1"; \
current_path=`pwd`; \
offset=`echo $$current_path | grep -bo $$dir_flag | cut -d: -f1`; \
offset=$$[$$offset + $${#dir_flag}]; \
dir=$${current_path:0:$$offset}; \
echo `realpath --relative-to="." "$$dir"`)
endef

# 下面获取当前目录$(CURDIR)到src目录的相对路径
is_subdir=$(findstring src/,$(CURDIR))
ifneq "$(is_subdir)" ""
# 调用宏, 传递了一个参数"src/"
subdir_relative_src:=$(call get_relative_path,src/)
endif

make选项

-C dir, --directory=dir
在将makefile读入之前, 把路径切换到dir下. 此选项在递归make时常用
-j [N], --jobs[=N]
同时运行N个任务. 有可能多线程编译会因为依赖顺序出现问题, 暂无比较好的解决办法, 只好单线程编译
-k, --keep-going
当目标无法创建时继续执行
-n, --just-print
仅输出执行命令, 而不执行实际动作
-p, --print-data-base
make内部数据库

(关于make参数, 实际中用的貌似并不多, 若想了解更多, 参考make --help)

makefile文件的通用惯例

makefile中为特定命令,选项等提供变量
例如使用程序ssh_scp, 则最好定义一个SSH_SCP变量, 在使用$(SSH_SCP)在命令中调用

makefile中一些约定俗成的变量名
这些变量在开源软件中常见, 它们安规范编译安装到相关目录.
在实际项目中多半不会用到这些目录, 所以仅供了解吧.

prefix
设置安装文件的根目录(如果使用过./configure --prefix=...则对此不会陌生). 缺省值是/usr/local
srcdir
安装要编译的原文件. 该变量正常的值由configure命令设定.
bindir
安装用户可以运行的可执行程序. 缺省值是/usr/local/bin
sbindir
安装从shell中调用执行的可执行程序, 它仅仅对系统管理员有作用. 缺省值是/usr/local/sbin
libexecdir
安装其它程序调用的可执行程序. 缺省值是/usr/local/libexec
datadir
安装只读型体系无关数据文件. 缺省值是/usr/local/share
includedir
安装头文件. 缺省值是/usr/local/include
libdir
安装库文件. 不要在这里安装可执行文件, 它们可能应属于$(libexecdir). 缺省值是/usr/local/lib
infodir
安装软件包的Info文件. 缺省值是/usr/local/info
mandir
安装该程序的手册. 其正常的值是/usr/local/man

makefile中一些约定俗成的目标
这些目标在开源软件中常见, 它们安规范编译安装到相关目录.
对实际项目中如何命名这些目标具有一定的借鉴意义.

all
编译整个程序
check
执行自我检查. 编写一个自我测试程序, 在程序已建立但没有安装时执行
clean
删除生成的文件
distclean
删除生成的文件,包括可能存在的配置文件
installdirs
创建文件要安装的目录
install
编译程序并将可执行程序,库文件等拷贝到为实际使用保留的文件名下
uninstall
删除所有由install目标拷贝安装的文件
TAGS
更新该程序的标志表
info
使用makeinfo程序产生的info文件
dist
为程序创建一个tar文件. 创建tar文件可以将其中的文件名以子目录名开始, 这些子目录名可以是用于发布的软件包名

参考

[1] 官方文档: https://www.gnu.org/software/make/manual/make.html
[2] 《Managing Projects with GNU Make, Third Edition》 by Robert Mecklenburg

发表评论

电子邮件地址不会被公开。必填项已用 * 标注