七阶子博客: 杂文 | 游戏 | 戏剧 | 白蛇 | 文艺 | 编程 | 近期
请输入标题关键字或 yyyymmdd 格式的日期

    浅谈 GNU Make 构建项目实践

    摘要

    本文简明地介绍 make 的基础原理,并组合实际项目经验,由浅入深讨论了一种实用的 makefile 通用规则与模板的编写方案。对其中涉及的语法功能技巧择要阐述,希望有助于初学者理解。

    make 原理简介

    make 是 linux/unix 系统下的一款工具,就如同 ls/cp/find/grep 这类程序一样属于基础、通用且经典的设施。其基本原理是根据目标文件与依赖文件的(时间戳)新旧关系,如果依赖文件更新了(目标文件更旧了),就执行指定的一系列操作。因此它需要这两部分关键信息,或按 make 的术语叫“规则”:

    用户需要将这些规则写入配置文件,以指示 make 的运行,习惯上取名为 makefileMakefie ,也可取为其他名字,那就需要额外加上 -f 选项,如:

    make
    make -f filename_other_than_makefile
    

    一个简单的 makefile 规则内容如下:

    oldfile: newfile
    	echo Hello World
        echo 'cp newfile oldfile' >> make.log
        cp newfile oldfile
    

    这里假设 makefile 文件所在目录中另有两个文件名为 oldfilenewfile ,只要在该目录下执行 make 命令,make 程序就会比较这两个文件的时间戳,如果newfile 更新了,就会将其复制到 oldfile (作为备份之用?),如果没有更新就不会有任何操作。

    可以配置多条操作,凡能在 shell 命令行执行的操作都行。每操作配置需要缩进一个TAB 键,目标与依赖文件不能缩进,用一个 : 分隔。这就是 makefile 文件的基本格式要求,当然还有其他许多语法细节,诸如变量引用、变量替换函数、目标依赖链、隐式规则等,那就不是本文所能细说的了,请参考其他入门教程以及官方手册。

    简单 C 程序的构建

    虽然可以花式操作用 make 做些奇怪的事,但 make 的主要用途是构建 C/C++ 程序。一般地,从 C/C++ 源文件到最终可执行程序要经过预处理、编译、链接等多步流程,make 就是为简化这种构建流程而诞生的。

    单文件程序

    先看个最简单的情况,假设只有一个 main.c 文件,那么 makefile 可如下写法:

    main.exe: main.o
    	gcc -o main.exe main.o
    main.o: main.c
    	gcc -c -o main.o main.c
    

    这规则文件表明,main.exe 文件依赖 main.o ,而 main.o 又依赖 main.c 。于是若执行 make 命令,就相当于依次执行如下两条命令:

    gcc -c -o main.o main.c
    gcc -o main.exe main.o
    

    结果会生成 main.exe 可执行文件,当然在 linux 系统下可执行文件不须也不推荐加上 .exe 后缀名,此只为说明方便起见。

    另外,gcc 编译器有很多默认行为,可直接执行 gcc -c main.c 生成默认 main.o的目标文件,不必加 -o 选项。甚至直接 gcc main.c 一步到位,自动预处理、编译、链接成可执行文件,不过若无 -o 指定输出文件名,默认生成的是 a.out

    正因为构建 C/C++ 程序是 make 的拿手好戏,它专门为此默认了一些隐式规则,以简化makefile 的编写,比如 *.o 目标文件的依赖与生成规则就可以省略。简化为一条规则:

    main.exe: main.o
    	gcc -o main.exe main.o
    

    多文件程序

    当然对于单文件程序的编绎,没必要写 makefie ,直接 gcc -o main.exe main.c一条命令解决问题。但假如有多个文件,除 main.c 外,还有两个额外的辅助源文件util1.c util2.c (及相应的 .h 头文件)。这时直接一条命令虽然也能完成编译链接工作:

    gcc -o main.exe *.c
    

    但有个问题,只要改了一个源文件,就要重新编译所有源文件。所以 make 就有用了,可写个 makefile 如下:

    TARGET = main.exe
    ALL_OBJ = main.o util1.o util2.o
    CFLAGS += -Wall 
    $(TARGET) : $(ALL_OBJ)
    	gcc $(CFLAGS) -o $(TARGET) $(ALL_OBJ)
    

    这里用到了变量,语法类似 shell ,不过引用变量要加括号 $(VAR_NAME) 。也省略了从 .c.o 的编译规则,但按 make 的隐式规则,也会自动编译生成三个 .o中间文件。如果后来修改了一个 .c 文件,则 make 只会重新编译一个 .o 文件,另外两个目标不用重新编译,然后将新编译 .o 文件与原来无改动的 .o 文件一起链接生成新的 main.exe 最终可执行文件。

    make 命令可以加参数的,参数就是所用 makefile 内定义的目标(文件)名,默认是文件内定义的第一个目标。假如在开发中改过 util1.c 文件,只是暂时想检查一下语法有没错误,可以明确提供目标参数:

    make util1.o
    

    这里的 util1.o 能由隐式规则生成,故而也是可用的。这比直接在命令行写gcc -c util1.c 的优势是可在 makefile 定义一系列编译选项,避免记不住或每次输入麻烦的问题。

    头文件包含依赖处理

    前述的三文件例程,还漏了重要一点,没加入头文件依赖。最终可执行 main.exe 只依赖几个 .o 文件,而每个 .o 文件只依赖对应的 .c 文件,在上个 makefile 规则中完全没有 .h 文件什么事儿。于是如果改动了某个头文件,再执行 make 是没有作何故事发生的,因为没哪个 .c 文件比它的 .o 更新,make 认为不需要执行任何操作。这不能甩锅给 make ,是我们没把规则需求给 make 讲明白。

    加入头文件 makefile 可修改如下:

    TARGET = main.exe
    ALL_OBJ = main.o util1.o util2.o
    CFLAGS += -Wall 
    .PHONY: all
    all: $(TARGET)
    $(TARGET) : $(ALL_OBJ)
    	gcc $(CFLAGS) -o $(TARGET) $(ALL_OBJ)
    util1.o: util1.c util1.h
    	gcc $(CFLAGS) -o $@ $^
    util2.o: util2.c util2.h util1.h
    	gcc $(CFLAGS) -o $@ $^
    main.o: main.c util1.h util2.h
    	gcc $(CFLAGS) -o $@ $^
    

    将每个 .o 目标文件所依赖的源文件及其头文件明确列出,注意一个源文件经常是会交叉包含其他头文件的。这些规则添加在 makefile 末尾没有问题,因为 make 会先读入整个 makefile 文件,扫描解析后定出依赖链,定出变量的最终值。再顺便解释几个新元素:

    这虽然正确地解决了问题,但很容易想到一旦程序源文件多起来,如此手动地指出每个目标文件所依赖的头文件太繁琐易错了。make 不会这么愚蠢,肯定有更机智的办法。这就需要预处理器的功能了,之前一直忽略了这个流程,也因为平时少用到。

    预处理器 cpp (这是英文缩写,与 C++ 的后缀名正好犯冲,但完全是两个东西)一个重要功能是展开宏,生成真正的源文件供给编译器。如果看不懂复杂宏技巧,可将 cpp处理的中间文件保存下来分析,不过这是另一个话题了。但是 cpp 预处理时肯定要读取每个被包含的头文件,它肯定能掌握被处理源文件所需依赖哪些头文件信息,只要将这部分信息保存下来,就能为 make 所用了。

    通过手册 man cpp 或搜索教程,就能找到使用 -M 选项正是输出目标文件依赖规则的功能。例如 cpp -M main.c 可能是如下输出:

    main.o: main.c util1.h uti2.h \
     /usr/include/stdio.h \
    # ... 以下省略更多
    

    显然,这与我们手写的目标文件依赖规则很像,只是可能更长得多,因为它必然还依赖某些标准库头文件。如果一行显示不全,可能分行显示,但需要在上行开尾加 \ 转义掉换行符,使其逻辑上等效于写在一长行中。

    如果用 -MM 选项代替 -M 选项,则不会输出那些在 /usr/include/ 等标准目录下的依赖头文件,这更适合于 make 使用。毕竟正常开发者不会也无权限去修改标准库的头文件吧。此外,cpp 是在运行在标准输入与标准输出的,如果想保存为文件得重定向。

    现在,我们可以祭出 cpp 来优化 makefile 的编写方式了:

    TARGET = main.exe
    SRC_DIR = .
    ALL_SRC = $(wildcard $(SRC_DIR:%=%/*.c)) # 自动获取所有 .c 源文件
    ALL_OBJ = $(ALL_SRC:%.c=%.o) # 替换 .c 后缀为 .o 得到所有目标文件列表
    ALL_DEP = $(ALL_SRC:%.c=%.d) # 得到一系列 .d 依赖文件列表
    CFLAGS += -Wall 
    .PHONY: all clean
    all: $(TARGET)
    clean:
    	rm -rf $(TARGET) $(ALL_OBJ) $(ALL_DEP)
    $(TARGET) : $(ALL_OBJ)
    	gcc $(CFLAGS) -o $(TARGET) $(ALL_OBJ)
    $(ALL_DEP): %.d : %.c # 用 cpp 处理每个 .c ,保存 .d 依赖文件
    	cpp -MM $< > $@
    ifneq ($(MAKECMDGOALS), clean)
    -include $(ALL_DEP) # 将所有 .d 文件包含进来,实现目标文件的依赖规则
    endif
    

    关键新行已添加注释(makefile 注释语法与 bash 一样使用 #)。我们先用wildcard 函数提取 SRC_DIR 源码目录(这里是当前目录 .)下所有 .c 文件,这就不限于之前的三个源文件的小程序了。得到 ALL_SRC 是一长串以空格分隔的源文件名列表,然后用变量替换功能转换一长串 .o 目标文件名存为 ALL_OBJ ,以及 ALL_DEP 一长串 .d 文件名,不妨称之为依赖文件。

    每个依赖文件用 cpp -MM 命令生成,并且可只用一条通配规则 %.d : %.c 生成,避免手动书写重复的相似规则。最后关键是,生成的 .d 文件可用 include 命令包含进来,因为每个 .d 文件也是符合 makefile 语法规则的。-include 加个 - 前缀是忽略错误之义,写在 ifneqendif 之间只是锦上添花并非必须,这可避免在执行 make clean 时把 .d 文件包含进来,因为那没意义了。

    注意这里还是利用了生成目标文件的隐式规则。包含进来的 .d 文件,声明了类似%.o : %.c {with list of *.h} 的依赖规则,但没有写明操作命令,make 有隐式规则会从相同目录下的 .c 文件生成所需的 .o ,也能自动添加约定的 CFLAGS 选项参数。其实也可显式添加一条通配规则,并未增加太多复杂度:

    $(ALL_OBJ): %.o : %.c
    	gcc -c $(CFLAGS) -o $@ $<
    

    复杂项目工程的构建

    在上节讨论中,我们给出了一个几乎通用的 makefile 文件,内中完全没有写死哪个源文件或目标文件。但它还是有些问题。首先它仍假设所有源文件在一个目录,需要进入那个目录使之成为当前目录执行 make ,而且所有目标文件与依赖文件这些临时的中间文件也都与源文件混在一起,污染视听。另一个小问题是它只用于构建 C 程序,而现在 C++ 程序似乎更主流,当然这改起来也简便。

    典型实际工程项目的目录结构

    在实践中,一个工程项目的目录结构其实是个设计问题,不一定有严格标准。但多少也有些相似的共识习惯,至少肯定是有多个目录的层次结构,不会是单目录一把梭。

    例如一个大项目可能是如下组织:

    /path/to/project/root/macro_service
        doc/
        config/
        src/
            model1/
            model2/
            ......
        include/
        lib/
        obj/
    

    主要就是将源代码放在 src/ 目录下,并且由于源文件数量太多,还应该分模块子目录存放。与 src/ 平级可能有些辅助目录,很可能希望将编译中间文件从 src/ 分离,统一放在平级的 obj/ 目录下。

    现在互联网据说较为流行微服务。那么它一个项目要编译的就不只一个大程序了,可能是一组数量较多的可执行程序。也许其目录结构会是这样:

    /path/to/project/root
        common1/
        util2/
        micro_service/
            serviceA/
            serviceB/
                1.cpp 1.hpp 2.cpp 2.hpp ...
                may_also_subdir/
            serviceC/
        third1/
        third2/
        tools/
            toolA/
            toolB/
        unit-tets/
    

    服务数量众多,但每个服务的代码量不甚巨大,也许几个或几十文件就可实现功能。也因为服务众多,必然会有些公用代码,正常想法会提到外面共用。如此很可能每个服务用自己的单个目录就能存放所有源文件了,不必要再加一层 src/ ,否则太多 src/ 也奇怪。除了生产服务,可能还有命令行工具程序及单元测试,理论上这或许可用脚本完成任务,但既然是熟悉 C/C++ 的团队,又在有较完善的公共库的情况下,直接用 C/C++ 也更和谐。此外可能直接下载使用一些第三方开源库,不想安装在系统中,直接扔在项目中了。如果三方库或自研库数量多起来,或许还要继续分别加层目录管理。

    本文拟基于后一种微服务集的工程目录结构假设,说明以 make 构建项目的流程模式。因为如果一个微服务内的源码也有再分子目录的话,那就与一个大服务的编译过程没有质的区别了,只是量(编译时间)的差异了。

    makefile 模板与 include

    现在需求是在许多子目录中编译出可执行程序。显然在每个这样的源码目录中写一份完整的 makefile 略显笨拙,尽管可以将上节所述的通用 makefile 复制到每个需要编译的目录中。更好的办法是将那个通用的 makefile 放在项目根目录中,比如取名为root.mk ,然后在每个编译目录中的 makefileinclude 它。

    然后再梳理一下,在每个具体的 makefile 中,还需要提供哪些额外信息呢。将这些信息以变量配置的方式写下来。大体框架可能如下:

    TYPE		= exe
    TARGET		= a.out
    INSTALL_DIR	= /path/to/install/
    SRC_DIR		= . sub1 sub2
    OBJ_DIR		=
    INC_DIR		= 
    LIB_DIR		= 
    SYS_LIB		=
    LIB_DEPENDS	= 
    OBJ_DEPENDS	= 
    EXTRA_CFLAGS	=
    EXTRA_CXXFLAGS	=
    include ../../root.mk
    

    主要是用变量定义一些必要信息,且大部分若接受默认值的话可留为空串:

    通用 makefile 规则再优化

    通用 root.mk 大约可分为几部分。首先是处理变量:

    TARGET  := $(strip $(TARGET))
    TYPE    := $(strip $(TYPE))
    INSTALL_DIR := $(strip $(INSTALL_DIR))
    CC       = gcc
    CXX      = g++
    CPP      = cpp
    CFLAGS   += $(C_INC)
    CXXFLAGS += $(C_INC)
    CFLAGS	 += $(EXTRA_CFLAGS)
    CXXFLAGS += $(EXTRA_CXXFLAGS)
    CFLAGS	 += -Wall
    CXXFLAGS += -Wall
    EXTRA_CXXFLAGS += -std=c++11
    SRC_DIR  += .
    OBJ_DIR  ?= obj/
    INC_DIR  += $(COMM_INC_DIR)
    INC_DIR  += /usr/include/mysql/ /usr/include/ ../ ./ ../../
    SYS_LIB  += libpthread libcurl libmysqlclient # and more needed
    LIB_DIR  += $(COMM_LIB_DIR)
    LIB_DIR  += /usr/lib/ /usr/lib64/ /usr/lib64/mysql/
    C_INC    = $(INC_DIR:%=-I%)
    C_LIB    = $(LIB_DIR:%=-L%) $(LIB_DEPENDS:lib%=-l%) $(SYS_LIB:lib%=-l%)
    

    这里将编译器名 gccg++ 也赋给变量,万一以后要改用 clang 编译,也只要改一处。makefile 的变量赋值涉及几种等号,简单解释一下。

    这部分主要是变量值转换处理,准备好编译器命令的选项参数等。接下来是编译源文件为目标文件的规则:

    C_SRC   = $(wildcard $(SRC_DIR:%=%/*.c))
    CXX_SRC = $(wildcard $(SRC_DIR:%=%/*.cpp))
    C_OBJ   = $(C_SRC:%.c=$(OBJ_DIR)%.o)
    CXX_OBJ = $(CXX_SRC:%.cpp=$(OBJ_DIR)%.o)
    C_DEP   = $(C_SRC:%.c=$(OBJ_DIR)%.d)
    CXX_DEP = $(CXX_SRC:%.cpp=$(OBJ_DIR)%.d)
    ALL_OBJ = $(C_OBJ) $(CXX_OBJ)
    ALL_DEP = $(C_DEP) $(CXX_DEP)
    $(C_DEP): $(OBJ_DIR)%.d : %.c
    	@mkdir -p $(dir $@)
    	$(CPP) $(EXTRA_CFLAGS) $(C_INC) -M $< > $@
        @sed -r -i 's|^(\w+)\.o[ :]*|$(@:.d=.o) : |' $@
    $(CXX_DEP): $(OBJ_DIR)%.d : %.cpp
    	@mkdir -p $(dir $@)
    	$(CPP) $(EXTRA_CXXFLAGS) $(C_INC) -MM $< > $@
    	@sed -r -i 's|^(\w+)\.o[ :]*|$(@:.d=.o) : |' $@
    $(C_OBJ): $(OBJ_DIR)%.o : %.c
    	@mkdir -p $(dir $@)
    	$(CC) -c $(CFLAGS) $(EXTRA_CFLAGS) -o $@ $<
    $(CXX_OBJ): $(OBJ_DIR)%.o : %.cpp
    	@mkdir -p $(dir $@)
    	$(CXX) -c $(CXXFLAGS) $(EXTRA_CXXFLAGS) -o $@ $<
    ifneq ($(MAKECMDGOALS), clean)
    -include $(ALL_DEP)
    endif
    

    这里为了同时处理 C 与 C++ 源文件,分别定义了 C_SRCCXX_SRC 保存所有匹配的源文件列表,并分别转换获取 C_OBJCXX_OBJ ,最后拼接成所有目标文件ALL_OBJ ,同理 ALL_DEP 保存所有 .d 文件列表。这只是平行地写了两套变量,增加的一倍代码量属于线性复杂度而已。如果项目中有的 C++ 源文件还会用到 .cc.C (大写C)后缀名,可考虑再平行写一套类似 CCC_SRC CCC_OBJ CCC_DEP 的变量名;如果觉得这太冗余,有失美观,可以的话将 C++ 源文件后缀名批量统一改为.cpp 吧。

    与上节末的 makefile 文件相比,这段代码还将所有 *.o*.d 中间文件放在OBJ_DIR 目录下(个人觉得不必要再将中间文件分门别类放入不同的目录,所以只定义了一个 OBJ_DRI)。但这会遭遇另一个问题,cpp -MM 的输出,不会包含目标文件.o 的目录信息,只取基础文件名。这里的解决办法是用 sed 再处理生成的 .d文件。

    sed-r 选项表示使用扩展的正则表达式,影响后面的正则表达式写法。常规正则替换写法是 s/exp/rep/ ,不过处理文件名时很可能用到 / ,于是改用 s|reg|rep|竖线分隔符更方便些。-i 表示原位修改文件,开始不确定时可以多写一步中间文件尝试下:

    $(CXX_DEP): $(OBJ_DIR)%.d : %.cpp
    	mkdir -p $(dir $@)
    	$(CPP) $(EXTRA_CXXFLAGS) $(C_INC) -MM $< > $@.tmp
    	sed -r 's|^(\w+)\.o[ :]*|$(@:.d=.o) : |' < $@.tmp > $@
    	# rm $@.tmp
    

    makefile 的规则操作行,前导 @ 表示不回显执行命令本身。在调教成功后,可以在 mkdir -psed -ri 命令前加 @ 减少冗余输出。

    然后是编译最终目标的部分,根据目标类型编写不同规则:

    TARGET_MAKE := $(OBJ_DIR)$(TARGET)
    TARGET_INSTALL := $(INSTALL_DIR)$(TARGET)
    ifeq ($(TYPE), exe)
    $(TARGET_MAKE): $(ALL_DEP) $(ALL_OBJ) $(OBJ_DEPENDS)
    	$(CXX) $(C_LIB) $(ALL_OBJ) -Wl,--start-group $(OBJ_DEPENDS) -Wl,--end-group $(LDFLAGS) -o $@
    install: all
    	cp -f $(TARGET_MAKE) $(TARGET_INSTALL)
    endif
    ifeq ($(TYPE), liba)
    $(TARGET_MAKE):$(ALL_DEP) $(ALL_OBJ) $(OBJ_DEPENDS)
    	$(AR) r $(TARGET_MAKE) $(ALL_OBJ)
    install:all
    	cp -f $(TARGET_MAKE) $(TARGET_INSTALL)
    endif
    ifeq ($(TYPE), libso)
    SO_DEPENDS = $(LIB_DEPENDS:%=$(COMM_LIB_DIR)/%.so)
    $(TARGET_MAKE): $(ALL_DEP) $(ALL_OBJ) $(OBJ_DEPENDS) $(SO_DEPENDS) 
    	$(CXX) -shared -fPIC $(C_LIB) $(ALL_OBJ) -Wl,--start-group $(OBJ_DEPENDS) -Wl,--end-group $(LDFLAGS) -o $@
    install: all
    	cp -f $(TARGET_MAKE) $(TARGET_INSTALL)
    endif
    

    这里将 make 出的最终目标文件也暂丢进 OBJ_DIR 目录中,make install 再拷到INSTALL_DIR 目录中。makefileif/endif 也有 else 分支,但没有类似else if 的语法,且嵌套的 if/endif 并不能缩进,所以可读性不佳,故而这里直接将几个 if/edif 平行列出。

    在定义目录前缀如 OBJ_DIR INSTALL_DIR 时,最好规则带上 / 后缀,然后在使用处获取全路径时只有直拼接起来,不必额外多写个 / 。这样的好处是兼容空串目录前缀,否则使用 $(DIR)/$(FILE_NAME) 时,若目录前缀为空就变成了系统的根目录 /了,这经常是不期望的。

    最后一些非关键部分。我们在这个 makefile 中进行了大量的变量处理,开发中有时不确定这些变量定义得对不对,那可以加个伪目标,把其中关键变量名都打印出来,可利于调试:

    .PHONY: all clean install echo
    all:$(TARGET_MAKE)
    clean:
    	rm -rf $(ALL_OBJ) $(ALL_DEP) $(TARGET_MAKE)
    echo:
        @echo $(ALL_SRC)
        @echo mkdir -p $(OBJ_DIR) $(INSTALL_DIR)
    	@echo $(CPP) $(EXTRA_CXXFLAGS) $(C_INC)
    	@echo $(ALL_DEP)
    	@echo $(CXX) -c $(CXXFLAGS) $(EXTRA_CXXFLAGS)
    	@echo $(ALL_OBJ)
    	@echo $(CXX) $(C_LIB) $(ALL_OBJ) -Wl,--start-group $(OBJ_DEPENDS) -Wl,--end-group $(LDFLAGS) -o $(TARGET_MAKE)
    	@echo $(TARGET) $(TARGET_MAKE) $(TARGET_INSTALL)
    

    伪目标 echo 就是把上面搜索到的源文件,转换的目标文件及依赖文件,用到的编译命令选项等复制下来,一键打印出来瞧一瞧,大概瞄一眼。make -n 选项也只打印将要执行的操作而不实际执行,但如果目标已经是新的,不需要操作也不就不会有命令打印出来。

    最后提醒一下,all: 目标定义最好移到 makefile 前面,使之成为第一个目标,这样就可以按默认参数只敲入 make 而无需敲入 make all 了。

    工程多目录自动编译

    如果是开发中修改了项目某个或某几个子目录,只要进入相应的目录执行下 make 就好了。但如果是重新拉一份项目代码,第一次从头编译时,手动进入每个目录进行 make那也是繁琐的。这就引出一个需求,从根目录自动进入各个含 makefile 的子目录进行make ,可以写个脚本做这件事,不过一条 find 命令也能实现基本需求:

    find . -name 'makefile' -execdir make clean \;
    find . -name 'makefile' -execdir make \;
    

    如果不想记这串 find 命令,可以在项目根目录也写个 makefile ,将这个命令放在第一个伪目录下面:

    .PHONY: all
    all:
    	find . -name 'makefile' -execdir make $(MAKECMDGOALS) \;
    

    不过 find 对于大项目递归搜索可能比较耗时,且会搜索许多不必要的目录。如果对此介意或想体验一下 make 的其他技巧,也可以使用纯 make 的方式来定制进入哪些子目录。思路与前面所述的 makefile 配置模板与通用规则 root.mk 类似,不过要写另一套用于中间目录的 makefile 模板与规则。

    先在项目根目录与 root.mk 同级处另写一个 dir.mk 如下:

    .PHONY: all clean install $(TARGETS)
    all clean install : $(TARGETS)
    $(TARGETS):
    	@echo "### make $(MAKECMDGOALS) in $@"
    	@$(MAKE) $(MAKECMDGOALS) -C $@ --no-print-directory
    	@echo
    

    然后在每一个中间目录与根目录,也即不是需要编译源代码的“叶子”目录,写个简单的makefile 如下:

    TARGETS = sub1 sub2 sub3
    include ../dir.mk
    

    只要将该目录下管理的需要编译的子目录明确列出来,赋给变量 TARGET ,然后将根目录的 dir.mk 包含进来,就能实现自动编译往下一层的子目录了。虽然需要在每个中间目录添加这样一个 makefile ,但胜在可精细控制,而且也不算太麻烦吧,毕竟在一个项目中做出增加一层目录的决策本身,比写这个简单 makefile 的工作量复杂得多。

    在 Vim 中利用 makefile 快捷编译

    Vimer 党也许会直接在 linux 下编辑代码,所以也希望能利用 make 从 vim 直接调起编译。这里简单说些思路。

    如果只是编译单文件小程序,可以直接定义如下快捷键映射:

    nnoremap <F9> :!gcc %<CR>
    

    在编辑一个项目时,如果 vim 当前目录有定义 makefile ,也可以直接使用 :make编译整个工程项目,且编译错误会呈现在 quickfix 窗口中。如果只是要编译当前编辑中的源文件,看下有没编译错误之用,则可以输入 :make currnet-filename.o 。不过如果 makefile 规则中将 .o 文件放到其他目录中,也得输入路径信息,利用 vim 命令行参数补全或可节省些输入。如果想定义快捷键自动输入当前源文件关联的目标文件,那可能是有点复杂。例如:

    nnoremap <F9> :make <C-R>='obj/' . expand('%:r') . '.o'<CR><CR>
    nnoermap <S-F9> :make<CR>
    

    这里假设按上节的默认规则,目标文件存在当前目录的 obj/ 子目录下。因为这种路径关系依赖于项目的 makefile 书写,所以这个快捷键映射最好也是作为项目相关的配置,而不要写在全局的 .vimrc 。比如若用 vim 原生的 Session.vim 保存会话,则可将项目配置写在 Sessionx.vim 脚本中。

    还有个问题,如果 vim 未设置 autochir ,则 vim 当前目录很可能与所编辑的源文件不在一起,或者说只要当前目录没有 makefile 文件,那 :make 命令还得加上 -C选项先转到含 makfie 的目录中去。至于如何找到相关联的 makefile 文件所在目录,可能需要了解 VimL 脚本才能较好地解决这问题,这就不展开了。

    总结

    make 原理极简,但 makefile 细节不少。当然我们不一定要掌握 make 的所有细节,需要时查询相关手册。本文讨论打造的 makefile 文件,大致可用于大中型 C/C++项目的构建了,或许还需根据实际情况调整一些编译选项。make 的实际使用基础,还是需要对 C/C++ 程序的编译流程与所用编译器提供的选项功能熟悉。不过在掌握更多makefile 语法后,可以更有效地优化与控制编译过程。

    尽管现在也有许多自动构建工具了,比如 CMake ,能自动生成 makefile 。但由通用工具生成的 makefile 可读性太复杂。若能理解 make 原理并手写 makefile ,那会更有成就感与控制感。而且使用 CMake 不也得学习一下 CMakeList.txt 的写法么。如果没有跨平台编译的需求,只在 linux 下使用的,根据自己的项目定制一份makefile 模板,那后续使用也是相当简单方便的。

    参考链接

    http://ruanyifeng.com/blog/2015/02/make.html 阮一峰写的 Make 教程

    https://blog.codingnow.com/2009/03/gnu_make_vpath.html 云风的博客,其中谈到vpathcpp -MT 选项,只是我具体没试出怎么用好。

    https://blog.csdn.net/yufei_email/article/details/78575637 另一篇 CSDN 的博客,也用的 sed 大法处理 cpp -M 的头文件依赖关系。

    https://www.gnu.org/software/make/manual/html_node/index.html 官方手册,忘记了变量与函数用法可以查查。