最近发现网上关于Makefile的详解很多,不少大佬们都从语法到实现详细地讲解了Makefile的原理和编写规范;但结合自身学习的体验来说,我更希望能结合简单的示例,逐步学习Makefile中的每一步是在实现什么功能;另一方面来说,Makefile文件本身用到大量符号语言(如$, @, <, ...),对于新手来说可读性较差,在我刚刚入门学习时给我造成了不小的困扰。本文在此尝试从简单的例子起步,解释Makefile中常用语句的用法和功能,并逐步增加实例复杂度,演示如何用Makefile实现愈发复杂的功能,希望给能学习使用Makefile的朋友提供一些帮助。
Makefile 是一个定义了如何编译和链接代码的文件,常用在 C 和 C++ 项目中,因为它可以帮助管理多个源文件之间的依赖关系,减少重复编译的时间。简单来说,Makefile文件可以帮助我们自动生成可执行文件。接下来我们配合几个由浅入深的例子来看看Makefile如何帮助我们自动化地生成可执行文件。
1. 单文件的简单示例
我们先看一个最简单的例子:假设我们只有一个文件main.cpp:
#include <iostream>
int main() {
std::cout<<"Hello! \n";
return 0;
}
假设我们的文件结构是这样的:
|--test
- main.cpp
对于单文件的编译,我们通常可以在Linux的Shell中直接完成:
> g++ -o program main.cpp
> ls # 查看当前路径下有哪些文件
> main.cpp program
该命令的目的是编译main.cpp文件,生成名为program的可执行文件。其中 "g++" 是声明我们使用g++作为编译器,"-o program" 是指明生成的可执行文件的名字为program。
接下来我们要为这个项目编写一个Makefile文件。Makefile在面对单文件时往往不能很好地展示出它的优势,但通过这样的简单示例,可以较为轻松地了解Makefile的基本使用方法。我们在test文件夹下新建一个文件Makefile,内容如下:
# 编译器选择
CXX = g++
# 可执行文件名称
TARGET = my_program
# 源文件
SRCS = main.cpp
# 目标文件
OBJS = $(SRCS:.cpp=.o)
# 编译每个源文件生成目标文件
%.o: %.cpp
$(CXX) -c $< -o $@
# 从目标文件生成可执行文件
$(TARGET): $(OBJS)
$(CXX) -o $(TARGET) $(OBJS)
# 清理编译生成的文件
clean:
rm -f $(OBJS) $(TARGET)
在介绍每一步的功能之前,我们先在Shell中运行一下make指令来看看这个Makefile到底实现了什么功能:
> cd .../test # 移动到test路径下
> ls # 查看当前路径下有哪些文件
Makefile main.cpp
> make # 运行make指令会查找当前路径下的Makefile文件,并根据里面的内容执行自动化编译操作。
g++ -c main.cpp -o main.o
g++ -o program main.o
> ls # 执行完成,查看当前路径下现在有哪些文件
main.cpp main.o Makefile program
可以看到,其实Makefile主要代替我们执行了两步操作:
1. 编译源文件(SRCS)生成目标文件(OBJS):
g++ -c main.cpp -o main.o
2. 链接目标文件(OBJS)生成可执行文件(TARGET):
g++ -o program main.o
接下来回到Makefile中,我们仔细分析一下文件中到底需要写些什么:
在前4行,我们其实是在声明编译器(g++),并告诉g++,我们的可执行文件叫做program, 源文件只有一个,叫做main.cpp, 所有的目标文件都和源文件同名,只是把后缀从.cpp更改为.o:
CXX = g++
TARGET = my_program # 可执行文件
SRCS = main.cpp # 源文件
OBJS = $(SRCS:.cpp=.o) # 目标文件
接下来,我们告诉编译器如何生成目标文件(main.o):
%.o: %.cpp
$(CXX) -c $< -o $@
从这里开始,Makefile中的语言就稍微显得有些抽象了。不过我们结合上面的声明和Shell中的实际效果来看,不难看出这里的作用:当我们需要生成main.o时,通过这里的匹配规则,我们需要用到main.cpp。接下来,我们用第二行的语句来生成main.o。 -c 告诉编译器只进行编译,不进行链接; $< 是自动变量,表示第一个依赖文件,即对应的.cpp文件。$@是自动变量,表示规则中的目标文件,即.o文件。这里的解释会有些生硬晦涩,稍后我们结合更多示例再做更详细的解释,这里我们对比一下Makefile中的命令和Shell中的指令:
### Makefile中 ###
%.o: %.cpp
$(CXX) -c $< -o $@
### Shell中 ###
g++ -c main.cpp -o main.o
Makeflie中的第四步是告诉编译器如何链接目标文件(main.o)生成可执行文件(program),这里也直接放上Makefile和Shell的对比:
### Makefile中 ###
$(TARGET): $(OBJS)
$(CXX) -o $(TARGET) $(OBJS)
### Shell中 ###
g++ -o program main.o
以$(TARGET)为例,不难看出,$符号承担了“取值”功能,如果说先前的 TARGET = program 给变量TARGET赋了值,那么$(TARGET)就是一个取值的过程。通过对比也可以看出,$(TARGET) = program, $(OBJ) = main.o。
Makefile的最后实现了clean的功能,在Shell中执行 make clean 会删除Makefile生成的所有目标文件和可执行文件。
> ls
main.cpp main.o Makefile program
> make clean
> ls
main.cpp Makefile # 此时,main.o和program被删除
到目前为止,我们的Makefile实现都比较简单,但是有一点需要注意:Makefile是按照规则进行匹配的文件,里面的命令的执行顺序与编写的顺序无关。我们前文中先编写了生成main.o的命令,再编写了生成program的命令,但是最后的执行顺序与文件中命令的前后位置无关。
2. 增加文件垂直结构,使用C++11特性的例子
接下来我们在上面例子的基础上引入一些新的变化,让我们的文件结构更复杂一些。这些变化比较符合普遍的项目的文件结构:
- 我们希望把函数功能放在一个额外的文件中,再用main来调用,所以我们增加了util.cpp和util.hpp文件;
- 在func内部,我们希望使用C++11的新特性进行一些操作,所以在编译func时需要-std=C++11关键字;
- 为了把头文件和源文件分开,让项目结构更加清晰,我们希望把util.hpp放进include文件夹,把main.cpp和util.cpp放进src文件夹;
- make命令过程中生成的.o文件和可执行文件可能会让文件夹变得混乱,我们希望Makefile能够新建两个文件夹,把make指令生成的.o文件放进build文件夹,把最终生成的可执行文件放进bin文件夹。
基于以上的这些需求,我们得到了一个如下的项目结构:
|--test
|--src
- main.cpp
- util.cpp
|--include
- util.hpp
-Makefile
相应的,3个文件中的代码如下:
/// util.hpp ///
#ifndef UTIL_HPP
#define UTIL_HPP
void func();
#endif
/// util.cpp ///
#include "util.hpp"
#include <memory>
void func() {
std::unique_ptr<int> ptr(new int(10)); // usage from C++11
}
/// main.cpp ///
#include <iostream>
#include "util.hpp"
int main() {
std::cout<<"Hello! \n";
func();
return 0;
}
接下来是Makefile, 与先前的文件相比有些许不同,我们稍后逐一分析:
# 编译器和编译选项
CXX = g++
CXXFLAGS = -Iinclude -std=c++11 # 指定 include 目录
# 目标可执行文件
TARGET = bin/my_program
# 源文件和目标文件
SRCS = src/main.cpp src/util.cpp
OBJS = $(SRCS:src/%.cpp=build/%.o)
# 默认规则:生成可执行文件
$(TARGET): $(OBJS)
@mkdir -p bin build
$(CXX) $(CXXFLAGS) -o $@ $(OBJS)
# 编译源文件生成目标文件
build/%.o: src/%.cpp
@mkdir -p build
$(CXX) $(CXXFLAGS) -c $< -o $@
# 清理生成的文件
clean:
rm -rf build bin
按照惯例,附上终端中运行make指令后的结果:
> cd .../test # 移动到test路径下
> ls # 查看当前路径下有哪些文件和文件夹
Makefile src/ include/
> make # 运行make指令会查找当前路径下的Makefile文件,并根据里面的内容执行自动化编译操作。
g++ -Iinclude -std=c++11 -c src/main.cpp -o build/main.o
g++ -Iinclude -std=c++11 -c src/util.cpp -o build/util.o
g++ -Iinclude -std=c++11 -o bin/program build/main.o build/util.o
> ls # 执行完成,查看当前路径下现在有哪些文件
bin/ build/ include/ src/ Makefile
> ./bin/program # 运行生成的可执行文件,被我们放在bin文件夹下
Hello!
Makefile中包含的内容与之前基本一致,这里我们主要讲几个发生变化的地方:
- CXXFLAGS = -Iinclude -std=c++11:这里的-Iinclude告诉编译器该去哪里找.hpp头文件。例如,main.cpp中有:#include "util.hpp", 编译器首先会在同目录下尝试寻找util.hpp, 没找到的话会去include/下再寻找hpp文件。类似的,假设我们现在把util.hpp放到src/include文件夹下,那么这里的语句应该改为 -Isrc/include。-std=c++11告诉编译器,编译时需要使用C++11标准来进行编译。
- OBJS = $(SRCS:src/%.cpp=build/%.o):这里的语法与之前还是一致的,只是我们希望把.o文件放到Build目录下。与之前一致,我们希望生成的文件名与源文件一致,只是更改后缀为.o。
- 在生成目标文件和生成可执行文件的步骤,我们增加了一行:@mkdir -p bin build,这里会尝试新建文件夹用于放置我们生成的文件。
- 接下来回到之前的两个符号:$@和$<。在这次的Makefile中出现了两次,我们把它们放到一起来看:
$(TARGET): $(OBJS)
$(CXX) $(CXXFLAGS) -o $@ $(OBJS)
build/%.o: src/%.cpp
$(CXX) $(CXXFLAGS) -c $< -o $@
其实从这个例子中我们可以更好地发现,如果我们的规则是"$(A) : $(B)",也就是说,我们依赖B文件来生成A文件,那么此时$@就代表着左侧的,我们希望获得的文件,也就是$(A);而这里$<其实代表着“第一个依赖文件”,也就是$(B)中的第一个……我们再举另一个例子来帮助理解:
假设我有这样的需求,用main.o和util.o来生成program文件:
program: main.o util.o
g++ -o $@ $<
此时,$@就代表冒号左侧的program, 但是$<代表冒号右侧的第一个文件,也就是main.o, 此时util.o被遗漏了。显然是不正确的。这里我们要把 "$<" 替换为 "$^",它指向所有依赖文件(main.o, util.o)。
我们现在让需求变得更加复杂一些:
- 我们编写的Makefile中有一个指令:make clean。如果我本地也有这样一个文件名本身就叫做clean怎么办?
- 如果我的项目结构变得更加复杂,全仓编译的耗时变得很长,有没有办法多写几个Makefile, 让它们联合编译?
基于这些问题,引出我们的最后一个示例,用来介绍嵌套实现Makefile和PHONY语句。
3. 嵌套Makefile, .PHONY伪目标示例
先解释一下.PHONY的作用。Makefile中一些指令是不需要依赖的(例如: clean : ),也就是冒号后方不跟任何其他参数。如果项目内有与指令同名的文件,会让Makefile感到困惑。为了避免这种情况发生,我们会使用.PHONY指令来明确告诉Makefile: clean只是一个命令的名字,不是让你去生成这个文件!
继续以 Part 2 中的文件格式为例,但我们这次分别在主目录下和src目录下分别编写一个Makefile。
顶层的Makefile内容如下:
# 顶层 Makefile
# Variables
SRC_DIR = src
BUILD_DIR = build
BIN_DIR = bin
# Phony targets
.PHONY: all clean
# Default target
all:
@$(MAKE) -C $(SRC_DIR) all
# Clean target
clean:
@$(MAKE) -C $(SRC_DIR) clean
src/Makefile内容如下:
# src/Makefile
# Variables
CXX = g++
CXXFLAGS = -std=c++11 -I../include -Wall
SRC = main.cpp util.cpp
OBJ = $(addprefix ../build/, $(SRC:.cpp=.o))
BUILD_DIR = ../build
BIN_DIR = ../bin
# Phony targets
.PHONY: all clean
# Default target to build the program
all: $(BIN_DIR)/program
# Create the executable in /bin
$(BIN_DIR)/program: $(OBJ)
@mkdir -p $(BIN_DIR)
$(CXX) $(CXXFLAGS) -o $@ $^
# Compile .cpp files into .o files in /build
../build/%.o: %.cpp
@mkdir -p $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -c $< -o $@
# Clean target
clean:
rm -rf $(OBJ_FILES) $(BIN_DIR)/program
在前文介绍过Makefile中的内容后,相信大家已经基本可以理解里面写的指令是在做什么了。这里我们还是重点看一些新增的指令:
对于src/Makefile,我们有:
- OBJ = $(addprefix ../build/, $(SRC:.cpp=.o)):这里相比之前的用法,新增了addprefix。这里的作用是通过$(addprefix)指定.o文件路径,确保它们存储在build/目录下。
- .PHONY: all clean:前文介绍过,这里是告诉Makefile,all和clean只是指令,不需要去目录下寻找或创建名为all或clean的文件。
- all的指令我们先前并没有接触过,事实上,它是告诉Makefile,当make指令后不接任何额外指令时需要做什么。也就是说,当我们直接执行make命令时,它其实是在尝试执行all下的操作。
总的来说,src/Makefile执行的功能和Part 2中几乎一致。接下来我们看看顶层的Makefile做了什么操作:
- .PHONY: all clean:作用同上。
- @$(MAKE) -C $(SRC_DIR) all:这一行包含多个我们没见过的参数。$(MAKE)是一个自动变量,表示make命令本身。这样写能够把环境变量和make选项传递到src/Makefile。-C $(SRC_DIR) all 的意思是,去SRC_DIR目录下执行make all的指令。这一行连接了我们顶层的Makefile和src下的Makefile。
4. 结语
到这里为止,通过逐渐复杂化的三个例子,相信大家已经对于Makefile的基本指令和结构有了一些了解。事实上,在实际项目应用中,还有许多更加复杂的指令和需求,但在此就不做过多展开了。
写这篇博客的初心是因为我刚开始学习Makefile的时候,发现网上许多教程开篇就在详细介绍Makefile中的指令和语法,让我感到很困惑:我只是想写一个帮我自动化实现g++ -o program main.cpp的文件,怎么一上来就那么复杂?笔者本人更喜欢从实际示例中对比学习,自认为直接上例子,先分析最简单的结构,学习起来会比较高效。关于Makefile的进阶介绍,网上有许多大佬讲的比较详细了,本文权当以示例的形式给大家介绍一下Makefile的结构和简单用法。希望能够帮到刚刚上手Makefile的朋友们。