本文概述
- 前言
- 构建工具:make和Makefile的必要性是什么?
- 编译器的选择
- Makefile编写准备
- GCC编译器选项
- GNU Make快速入门
- Makefile基本语法解析
- 多个Target
- Make通配符和模式规则
- 命令和执行
- Makefile完整配置文件
- 总结
前言
C/C++开发一直都不简单,首先掌握其语言就有一些难度,本人是自学C/C++的,磨了好长时间,现在终于能综合C/C++开发了——其实和Java或其它语言开发都差不多:
- 首先要掌握语言,基本SDK。
- 项目开发一般要配备一个构件工具,使用构建工具都要书写构建脚本。
C/C++基本要掌握基本语言特性、STL,高级的例如掌握boost库,接着项目开发需要使用make或cmake进行构建。本文讨论make构建C/C++项目,make来自GNU,使用Makefile作为构建脚本。
我以前刚接触C++开发的时候,看到个Makefile,都不知道是啥,而且这脚本内容让人觉得恐惧——恐惧来自无知,Makefile编写并不难,本文带你一起编写Makefile,只要内心,一天就可以掌握Makefile编写了。
构建工具:make和Makefile的必要性是什么?
之前已经说过很多次了,因为我一直在解决项目开发中的构建问题。任何语言,包括C/C++开发,首先你要编写各种的C/C++源文件,正常来说,你需要将这些源文件逐一地编译成二进制目标代码(一般是.o或.obj文件),然后将这些目标代码链接成可执行文件,或编译成静态库和动态库。
如果你只有几个源文件,这个过程还好。假如是要编译10个源文件,这工作量也不小,而且很可能是你每次编写代码完成都要编译运行一下,非常麻烦!浪费时间,而且有可能哪里出错了,那就更加浪费时间了。如果当前项目不止10个源文件,而是更多,成十个、上百个、上千个……手动编译成了一个麻烦。
构建工具就是为了解决这个问题,它可以帮你一次性完全这些任务,并且每次可以让你即时编写、即时运行项目。省去了很多不必要的工作,让我们可以集中于项目的需求实现。
Make就是这样的构建工具,它使用Makefile作为构建脚本,我们在Makefile中编写项目编译链接指令,使用Make解析Makefile,并执行里面的指令。在Unix/Linux下你可能使用过Make,例如下载第三方程序的源代码进行安装,一般是使用Make进行编译和安装。
流行的C/ C++替代构建系统有SCons、CMake、Bazel和Ninja。一些代码编辑器(如Microsoft Visual Studio)有自己的内置构建工具。对于Java,有Ant、Maven和Gradle。其他语言如Go和Rust也有自己的构建工具。
像Python、Ruby和Javascript这样的解释型语言不需要类似makefile的构建。makefile的目标是编译任何需要编译的文件,基于已经更改的文件。但当解释语言中的文件发生变化时,不需要重新编译任何文件。当程序运行时,使用文件的最新版本。
编译器的选择
Make是GNU编译工具链的一部分,但是Makefile并不一定只能使用GCC编译器,原则上你可以使用所有需要的编译器,只要你当前的平台安装了GNU的Make即可。
目前流行的编译器包括GCC、Clang、LLVM和微软的CL,使用Make构建C/C++项目,都可以使用这些编译器。但是习惯上,只有面向Unix/Linux开发才需要使用Make+Makefile,对于Windows平台的开发则不需要。但这不是一定的,你仍然可以使用Make构建任何平台的项目——支持Make即可,例如windows下的CLion默认使用CMake构建。
Makefile类似shell脚本,所以使用Make构建和平台或编译器无关,它等于将你在命令行输入的命令复制到Makefile中了。
Makefile编写准备
编写Makefile需要使用Make和一个编译器,如果你想在Windows上编写C/C++的Make项目,可以使用MinGW。方便起见,本文直接在Linux下编程,因为对Linux的相关命令比较熟悉。
你需要在命令行中输入gcc/g++ -v查看当前系统中是否安装了GNU编译套件,如果还没安装,那要先安装,安装过程就不说了,下面直接进入Makefile的编写。
GCC编译器选项
常用的GCC编译选项如下:
- -o <file>:指定输出文件名。
- -Wall:打印所有编译警告信息。
- -Werror:在产生警告的地方停止编译,迫使程序员重新修改代码。
- -g:生成gdb调试器使用的附加符号调试信息。
- -E:仅预处理文件(如导入头文件、预处理宏),不编译、汇编或链接。一般生成*.i源代码文件,文件包含源文件的完整代码形式。
- -S:仅编译,不汇编或链接。一般生成*.s文件,文件包含源代码的汇编代码。
- -c:编译和汇编,不链接。一般生产*.o或*.obj文件,文件包含源代码的二进制字节码。
- ar rc lib<name>.a *.o:编译静态库。
- gcc -fPIC -shared -o lib<name>.so:编译动态库。
- gcc -c -I<dir> -o *.o:使用指定头文件编译(库头文件),-I(大写i)指定编译使用的头文件目录(编译的时候需要库的头文件,链接的时候需要库的实现文件.a或.so,windows下是.lib或.dll)。
- gcc -L<dir> -l<name>:链接第三方库,包括动态库和静态库,-L指定库文件目录,-l(大写l)指定库文件的名称,类Nnix下前缀lib和后缀.a省略,windows下需要完整名称,如-lapp.lib。
- -static:强制链接时使用静态库链接。
对于静态库链接,搜索路径的顺序为:
- Ld会去找GCC命令中的-L参数。
- 然后找环境变量LIBRARY_PATH中的值。
- 找默认目录/lib、/usr/lib、/usr/local/lib。
动态库的搜索路径顺序为:
- 找-L参数指定的目录。
- 找环境变量LD_LIBRARY_PATH中的路径。
- 找配置文件/etc/ld.so.conf中指定的路径。
- 找默认目录/lib和/usr/lib。
C/C++开发一般是将编译和链接分开的,也就是说常用的一个命令是-c,而编译.c/.cpp文件需要指定头文件目录,默认.cpp和.h在同一个目录中,但是如果使用库则不是了,需要使用-l<dir>指定头文件目录。
而链接的时候需要指定二进制文件,包括目标文件和库文件-L<dir>指定目录,-l<name>指定库文件。
下面是GNU相关的常用命令:
- file <filename>:查看目标文件或可执行文件的类型。
- nm <filename>:查看二进制文件的符号表。
- ldd <filename>:查看可执行文件需要的共享库列表。
GNU Make快速入门
C/C++项目结构
C/C++项目虽然没有分包,但是最好有一个清晰的项目结构,什么东西都放到一个目录下可是超级乱。
下面是推荐的C/C++项目结构:
- src:放置项目源文件,里面可以再建立更多的目录(类似分包)。
- bin/build:目标文件或可执行文件,例如.o、.obj或.exe文件。
- include:库头文件,用于编译阶段。
- lib:库文件,包括静态库和动态库,用于链接阶段。
- sources:资源文件,例如配置文件、图片文件等。
第一个Makefile示例
首先在src中新建一个main.c源文件,内容如下:
#include <stdio.h>
int main(int count, char **args) {
printf("hello world!\n");
return 0;
}
然后在项目根目录新建一个makefile文件,文件名可以是makefile、Makefile或GNUMakefile,内容如下:
all: app
app: app.o
gcc -o app app.o
app.o: main.c
gcc -c -o app.o main.c
clean:
rm app *o
run:
./app
在项目根目录中运行make(相当于运行make all):
$ make
gcc -c -o app.o main.c
gcc -o app app.o
运行make run或./app可以运行该程序。
不带参数地运行make会启动makefile中的目标“all”。makefile由一组规则组成。规则由3部分组成:目标、先决条件列表和命令,如下所示:
目标: 条件1 条件2 ...
命令
其中命令左边空格是一个Tab,目标和先决条件使用冒号:隔开。
当make被要求执行一个规则时,它首先在先决条件中查找文件(一个条件可能对应一个文件)。如果任何先决条件都有关联的规则,则尝试先更新它们。规则的整体执行逻辑为:
Make执行指定的规则,检查条件对应的文件是否存在,如果不存在,则查找对应的规则创建它。例如上面的例子,make会首先执行all目标,app不存在。检查app文件对应的规则,规则app又依赖于app.o;,app.o不存在,接着找到app.o规则,该规则依赖于main.c,该文件存在。接着对比main.c和app.o的更新时间,如果先决条件不比target更新,则不会运行该命令。换句话说,只有当目标与它的先决条件相比过时时,该命令才会运行。
如果你再次运行make,构建不会再次执行,而是提示:make: Nothing to be done for `all'.
由上面你可以发现,每个target相当于一个任务或函数,make target可以执行指定的任务或函数。执行target的命令需要检查:
- 条件为空,则表示无条件执行命令。
- 条件对应的文件不存在,则去执行对应新的target。
- 条件对应的文件存在,则去对比当前target对应的文件和条件对应的文件的更新时间,只有条件比target新才会执行命令。
如果你的项目需要重新构建,那么需要清理一下上一次生成的目标文件。
Target和条件名称一般对应一个文件,但不是必须的,如果不对应一个文件,表示该文件不存在,条件一般是用于提供当前target命令的输入文件。只要一个target提供了给其它target的命令,它的命名不重要,例如下面的例子:
all: app.exe
app.exe: myanme
gcc -o ./bin/app.exe ./bin/app.o
myanme: main.c
gcc -c -o ./bin/app.o ./src/main.c
myname不对应一个文件,但是myname对应的target命令能生成app.exe target所需要的文件。
Makefile的更多内容
注释和断行
注释以#开头,一直持续到行尾。长行可以通过反斜杠(\)断行并在几行中继续。
虚假目标(或人为目标)
不代表文件的目标称为虚假目标。例如,上面例子中的“clean”,它只是一个命令的标签。如果目标是一个文件,它将根据其先决条件检查是否过时。“假目标”总是过时的,它的命令将被运行。标准的假目标是:all,clean,install。
变量
变量以$开头,用圆括号(…)或大括号{…}括起来。单字符变量不需要圆括号,例如:$(CC), $(CC_FLAGS), $@, $^。
自动变量
自动变量在匹配规则后由make设置。这包括:
- $@:目标文件名。
- $*:没有文件扩展名的目标文件名。
- $<::第一个条件的文件名。
- $^:所有文件的先决条件,以空格分隔,丢弃重复的。
- $+:类似于$^,但包含重复项。
- $?:所有比目标更新的先决条件的名称,用空格分隔。
我们可以使用变量重写以上的Makefile文件:
# 变量定义
app_name = app
clean_cmd = rm app *.o
COPTIONS = -c -o
# 使用变量: ${variable name}
all: ${app_name}
app: app.o
gcc -o $@ $<
app.o: main.c
gcc $(COPTIONS) $@ $^
# 2. 使用变量: $(variable name)
clean:
$(clean_cmd)
run:
./app
逼格是不是高很多了?——达到了让大部分看不懂的程度。
虚拟路径:VPATH & vpath
可以使用VPATH(大写)指定搜索依赖项和目标文件的目录。例如,
VPATH = src include
你还可以使用vpath(小写)来更精确地描述文件类型及其搜索目录。例如,
vpath %.c src
vpath %.h include
模式规则
如果没有显式规则,可以使用模式匹配字符'%'作为文件名的模式规则来创建目标。例如,
%.o: %.c
$(COMPILE.c) $(OUTPUT_OPTION) $<
%: %.o
$(LINK.o) $^ $(LOADLIBES) $(LDLIBS) -o $@
隐式模式规则
Make附带了大量的隐式模式规则,你可以通过make --print-data-base命令列出所有规则。
下面我们继续展开详细讨论。
Makefile基本语法解析
一个Makefile包含一系列的规则(rule),一个规则的语法如下:
targets: prerequisites
command1
command2
command3
......
其中:
- targets是文件名,使用空格分隔,通常一个规则只有一个taregt。
- command以一个Tab空格开始,命令可以有多个,这些命令是用于完成target的。
- prerequisites也是文件名,使用空格分隔,这些文件需要存在命令才能运行,这些又称为依赖。
例如下面的例子,该例子包含3个规则,当我们运行make app的时候,make会从app目标开始执行,其执行的详细步骤如下:
- 我们允许make app,所以make会首先搜索该app target。
- app target依赖于app.o,所以make去搜索app.o target。
- app.o target又依赖于app.c,make接着搜索app.c target。
- app.c没有依赖,所以echo命令无条件运行。
- 以上命令运行完成后返回app.o target中,因为该target的依赖已经完成了,所以开始执行gcc -c命令。
- gcc -c命令执行完成后返回顶部的app target中,因为该app的依赖已经完成了,所以开始执行gcc命令。
- 当app中的命令完成后,即可得到程序app。
app: app.o
gcc app.o -o app
app.o: app.c
gcc -c app.c -o app.o
app.c:
echo "int main() {return 0}" > app.c
多个Target
一个规则中可以有多个target,使用空格分隔,下面是一个例子:
TARGET = app
OBJS = main.o calculator.o
SOURS = main.cpp calculator.cpp
G+ = g++
$(TARGET): $(OBJS)
$(G+) -o $@ $^
$(OBJS): $(SOURS)
$(G+) -c -o $@ $*.cpp
clean:
rm app *.o
Make通配符和模式规则
在make中,*和%都是通配符,其中:
- *匹配任意字符,*搜索你的文件系统以匹配文件名。建议你总是将它封装在wildcard函数中,例如$(wildcard *.c)。*可用于目标、先决条件、命令或通配符函数中,但不能用于变量中(除非使用wildcard函数),用于变量中会将*作为一个文件名。
- 对于%,当使用“匹配”模式时,它匹配字符串中的一个或多个字符;在“替换”模式下使用时,它接受匹配的词干并替换字符串中的词干,%最常用于规则定义和一些特定的函数中。
静态规则
下面是一个静态规则:
targets ...: target-pattern: prereq-patterns ...
commands
这是什么意思?请继续看下面的例子:
objects = foo.o bar.o all.o
all: $(objects)
# 这些文件通过隐式规则编译
foo.o: foo.c
bar.o: bar.c
all.o: all.c
all.c:
echo "int main() { return 0; }" > all.c
%.c:
touch $@
clean:
rm -f *.c *.o all
下面是一个更加神奇的写法(上天了,越来越看不懂了):
objects = foo.o bar.o all.o
all: $(objects)
#和上面的例子的展开写法是类似的
$(objects): %.o: %.c
all.c:
echo "int main() { return 0; }" > all.c
%.c:
touch $@
clean:
rm -f *.c *.o all
这些文件通过隐式规则编译,隐式规则就是GCC帮我们自动做了一些工作,除了最终的那个目标命令不能省略(以上例子是all,引用自别人的代码,虽然还是省略,但是我在CPP中尝试不行,除非all目标的链接命令不省略,展开写法最好换行)。
下面是我的例子:
TARGET = app
objects = main.o calculator.o
CXX = g++
$(TARGET): $(objects)
$(CXX) -o $@ $^
#$(objects): %.o: %.cpp
main.o: main.cpp
calculator.o: calculator.cpp
clean:
rm app *o
至于为什么会这样的,make在背后做了手脚,我也不知道,不深究,知道这样的写法能够省去编写编译命令即可。
静态规则和过滤器函数
继续看看下面逆天的写法:
obj_files = foo.result bar.o lose.o
src_files = foo.raw bar.c lose.c
all: $(obj_files)
$(filter %.o,$(obj_files)): %.o: %.c
echo "target: $@ prereq: $<"
$(filter %.result,$(obj_files)): %.result: %.raw
echo "target: $@ prereq: $<"
%.c %.raw:
touch $@
clean:
rm -f $(src_files)
其中filter是一个函数,函数名和参数使用空格隔开,参数列表使用逗号分隔,你可以猜到,这个函数的意思是:使用第一个参数的匹配模式 从第二个参数的文件集中过滤目标文件,例如第一个filter函数过滤所有.o文件。
隐含规则
也许make中最让人困惑的部分是所创造的魔法规则和变量。以下是一些隐含规则:
- 编译一个C程序:n.o从n.c中自动生成,命令形式为$(CC) -c $(CPPFLAGS) $(CFLAGS)
- 编译c++程序:n.o由n.cc或n.cpp自动生成,命令格式为$(CXX) -c $(CPPFLAGS) $(CXXFLAGS)
- 链接单个对象文件:n自动从n.o生成,通过运行命令$(CC) $(LDFLAGS) n.o $(LOADLIBES) $(LDLIBS)。
因此,隐式规则使用的重要变量是:
- CC:编译C程序的程序;默认cc
- CXX:编译c++程序的程序;默认g++
- CFLAGS:给C编译器的额外标记
- CXXFLAGS:给c++编译器的额外标记
- CPPFLAGS:给C预处理器的额外标记
- LDFLAGS:当编译器应该调用链接器时,提供给编译器的额外标记
下是一个使用示例:
TARGET = app
objects = main.o calculator.o
CXX = g++ # 隐含规则: 指定编译器
CXXFLAGS = -g # 隐含规则: 指定编译选项
$(TARGET): $(objects)
$(CXX) -o $@ $^
$(objects): %.o: %.cpp
clean:
rm app *o
模式规则
定义一个模式规则,将每个.c文件编译成.o文件:
%.o : %.c
$(CC) -c $(CFLAGS) $(CPPFLAGS) $< -o $@
模式规则在目标中包含一个'%'。这个'%'匹配任何非空字符串,其他字符匹配它们自己。模式规则先决条件中的' % '代表与目标中的' % '匹配的同一个词干。
下面是另一个例子:
%.c:
touch $@
双冒号规则
双冒号规则很少使用,但允许为同一个目标定义多个规则。如果这些是单冒号,则会打印警告,并且只运行第二组命令。
all: blah
blah::
echo "hello"
blah::
echo "hello again"
clean:
rm -f $(src_files)
命令和执行
命令响应/静默
在命令前添加@以阻止输出
你还可以使用make -s命令在每一行之前添加一个@
all:
@echo "这行将不会被打印"
echo "但这行会"
Makefile完整配置文件
让我们来看看一个非常有趣的例子,它适用于中型项目。
这个makefile的奇妙之处在于它自动为你确定依赖项。你所要做的就是把你的C/C++文件放到src/文件夹中。
# Thanks to Job Vranish (https://spin.atomicobject.com/2016/08/26/makefile-c-projects/)
TARGET_EXEC := final_program
BUILD_DIR := ./build
SRC_DIRS := ./src
# Find all the C and C++ files we want to compile
SRCS := $(shell find $(SRC_DIRS) -name *.cpp -or -name *.c)
# String substitution for every C/C++ file.
# As an example, hello.cpp turns into ./build/hello.cpp.o
OBJS := $(SRCS:%=$(BUILD_DIR)/%.o)
# String substitution (suffix version without %).
# As an example, ./build/hello.cpp.o turns into ./build/hello.cpp.d
DEPS := $(OBJS:.o=.d)
# Every folder in ./src will need to be passed to GCC so that it can find header files
INC_DIRS := $(shell find $(SRC_DIRS) -type d)
# Add a prefix to INC_DIRS. So moduleA would become -ImoduleA. GCC understands this -I flag
INC_FLAGS := $(addprefix -I,$(INC_DIRS))
# The -MMD and -MP flags together generate Makefiles for us!
# These files will have .d instead of .o as the output.
CPPFLAGS := $(INC_FLAGS) -MMD -MP
# The final build step.
$(BUILD_DIR)/$(TARGET_EXEC): $(OBJS)
$(CC) $(OBJS) -o $@ $(LDFLAGS)
# Build step for C source
$(BUILD_DIR)/%.c.o: %.c
mkdir -p $(dir $@)
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
# Build step for C++ source
$(BUILD_DIR)/%.cpp.o: %.cpp
mkdir -p $(dir $@)
$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@
.PHONY: clean
clean:
rm -r $(BUILD_DIR)
# Include the .d makefiles. The - at the front suppresses the errors of missing
# Makefiles. Initially, all the .d files will be missing, and we don't want those
# errors to show up.
-include $(DEPS)
总结
文章虽然写得很长,但是发现后面的部分写得有点乱。关于模式匹配和函数的解释和例子不足,希望下次写文章能够再详细描述清楚。但是本文对于构建一般的C/C++项目已经足够了,例如上面介绍的隐式规则,利用这些隐式规则,可以使用一个精简的Makefile构建一个复杂的项目。
如果要像编程一样编写Makefile还是,Makefile还是蛮复杂的,这简直就是一个make语言的脚本。Makefile非常强大,看来一篇文章介绍是不足够的,接着再写一篇详细理清楚Makefile吧。