由浅入深介绍Makefile,附Linux详细用例&分析

        最近发现网上关于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中包含的内容与之前基本一致,这里我们主要讲几个发生变化的地方:

  1. 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标准来进行编译。
  2. OBJS = $(SRCS:src/%.cpp=build/%.o):这里的语法与之前还是一致的,只是我们希望把.o文件放到Build目录下。与之前一致,我们希望生成的文件名与源文件一致,只是更改后缀为.o。
  3. 在生成目标文件和生成可执行文件的步骤,我们增加了一行:@mkdir -p bin build,这里会尝试新建文件夹用于放置我们生成的文件。
  4. 接下来回到之前的两个符号:$@和$<。在这次的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)。

        我们现在让需求变得更加复杂一些:

  1. 我们编写的Makefile中有一个指令:make clean。如果我本地也有这样一个文件名本身就叫做clean怎么办?
  2. 如果我的项目结构变得更加复杂,全仓编译的耗时变得很长,有没有办法多写几个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,我们有:

  1. OBJ = $(addprefix ../build/, $(SRC:.cpp=.o)):这里相比之前的用法,新增了addprefix。这里的作用是通过$(addprefix)指定.o文件路径,确保它们存储在build/目录下。
  2. .PHONY: all clean:前文介绍过,这里是告诉Makefile,all和clean只是指令,不需要去目录下寻找或创建名为all或clean的文件。
  3. all的指令我们先前并没有接触过,事实上,它是告诉Makefile,当make指令后不接任何额外指令时需要做什么。也就是说,当我们直接执行make命令时,它其实是在尝试执行all下的操作。

        总的来说,src/Makefile执行的功能和Part 2中几乎一致。接下来我们看看顶层的Makefile做了什么操作:

  1. .PHONY: all clean:作用同上。
  2. @$(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的朋友们。

  • 18
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值