从入门到精通makefile,手写实现make命令-上篇

1,196 阅读9分钟

大家好,我是春哥,一名拥有10多年Linux后端研发经验的BAT互联网老兵。

今天给大家分享一下makefile相关的知识点,通过阅读本篇文章,大家可以自行编写通用的makefile,并手写实现make命令。

1.概述

C/C++项目最常用的编译方式就是通过编写makefile来实现。记得春哥刚毕业的时候就手写makefile,每在项目中新增一个源文件就要修改一遍makefile文件,也是从那个时候开始接触到makefile的语法。

在项目开发中,我们经常需要修改源代码文件,如果每次修改都需要手动执行编译链接,那么显然是非常耗时和低效的。那么有没有什么快捷的方式,能根据代码文件的变更来完成重新编译和链接呢?

在Linux系统中,make命令是一个非常常用的自动化编译工具,它可以解决上述问题,即自动完成程序的编译和链接。我们只需要编写一个名为"Makefile"或者"makefile"的文件,在这个文件中包含了整个程序的编译链接规则,然后执行make命令,make命令就会在当前目录下搜索makefile文件并解析执行其中的编译链接命令,从而完成整个程序的编译链接。

makefile文件本质上就是一个编译链接脚本,它包含了所有的编译链接规则,定义了程序中各个代码文件之间的依赖关系和编译链接选项。make命令就是makefile这个脚本的解析器和执行器,make和makefile的关系,同shell和shell脚本的关系类似。我们可以在makefile文件中定义多个规则,每个规则对应一个目标文件或可执行文件。每个规则包含了编译链接命令和依赖关系,当某个代码文件被修改时,make命令会自动检测该代码文件的依赖关系,并重新编译链接所有受影响的目标文件,从而保证整个程序的正确性和一致性。

make命令并不是每次都简单的重新执行makefile中所有的编译和链接命令,如果是这样的话就和shell脚本没有区别了。相反,make命令会根据源代码文件的变更情况,只重新编译必要的目标文件并重新链接生成可执行文件,从而提高了编译链接的效率。如果你修改了某个.c文件,那么对应的目标文件会被重新编译生成,如果源代码文件没做任何变更,那么make命令不会执行makefile中的任何编译和链接命令,它只会在终端打印出,当前的可执行文件是最新的提示。

1.1 makefile基本语法

makefile的基本语法如下。

target : prerequisites
    command
  • target可以是一个可执行文件,也可以是目标文件,甚至可以是一个伪目标(只是一个标签用于标记一个命令)。

  • prerequisites是生成target所依赖的文件列表。具体来说,当target为可执行程序时,prerequisites为目标文件列表;当target为目标文件时,prerequisites为源代码文件列表;当target为伪目标时,prerequisites为空或者command为空,也可以使用.PHONY显示声明target为伪目标。

  • command是target关联的要执行的命令,需要特别注意的是,command命令必须以一个Tab键开头。当target为可执行文件或者目标文件时,command为生成target需要执行的编译链接命令;当target为伪目标时,command仅仅为target这个标签关联的要执行的命令而已。

从makefile的基本语法我们可以看出,makefile中描述的就是一种依赖关系和生成规则。在makefile中,每个规则都包含了一个target、prerequisites和command,用于指定生成目标文件所依赖的文件列表和生成规则。

1.2make工作方式

  • make会在当前目录下查找名为"Makefile"或者"makefile"的文件,当然我们也可以使用-f选项指定特定的文件为makefile文件。
  • 如果没有找到makefile文件,make命令会报错,提示找不到makefile文件。如果找到了makefile文件,默认情况下make会把makefile文件中出现的第一个target作为最终生成的目标。当然,我们也可以在执行make命令时指定生成特定的target。
  • make会根据makefile文件中描述的依赖关系和最终要生成的target去执行相应的命令,最后生成目标文件。具体来说,如果target是最新生成的,那么make不会执行makefile文件中的任何命令;如果target不存在或者target不是最新的,那么make会执行makefile文件中生成target所关联的命令,并根据需要递归地执行生成其他依赖文件的命令;如果target关联的某些源代码文件被修改,或者target的某些依赖文件缺失,那么make会执行命令生成最新的依赖文件,并执行makefile文件中生成target所关联的命令。
  • make在执行编译链接过程中,不会理会关联命令的错误,它只处理依赖关系。如果依赖的文件无法生成,make会直接报错退出;否则,make会根据makefile文件中描述的依赖关系,递归地执行关联的命令,直到生成最终的target。

2.实例

在实际的C/C++工程项目中,一个程序通常由多个代码文件构成,那么我们该如何编译工程项目的程序呢?假设我们一个项目中有3个源文件和2个头文件,它们分别为sort.c,print.c,main.c,sort.h,print.h,它们的内容如下。

  • sort.h
#pragma once
void mySort(int* data, int len);
  • sort.c
#include "sort.h"

#include <stdlib.h>

int myCmp(const void *lvalue, const void *rvalue) {
  int lIntValue = *(int *)lvalue;
  int rIntValue = *(int *)rvalue;
  return lIntValue - rIntValue;
}

void mySort(int *data, int len) { qsort((void *)data, len, sizeof(int), myCmp); }
  • print.h
#pragma once
void myPrint(int* data, int len);
  • print.c
#include "print.h"

#include <stdio.h>

void myPrint(int* data, int len) {
  int i = 0;
  for (i = 0; i < len; ++i) {
    printf("%d ", data[i]);
  }
  printf("\n");
}
  • main.cpp
#include "print.h"
#include "sort.h"

int main() {
  int data[8] = {10, 20, 25, 2, 234, 13, 3, 1};
  mySort(data, sizeof(data) / sizeof(int));
  myPrint(data, sizeof(data) / sizeof(int));
  return 0;
}

我们可以手动完成整个编译链接过程。

[root@VM-114-245-centos make_learn]# gcc -c sort.c  
[root@VM-114-245-centos make_learn]# gcc -c print.c  
[root@VM-114-245-centos make_learn]# gcc -c main.c  
[root@VM-114-245-centos make_learn]# gcc -o main main.o print.o sort.o  
[root@VM-114-245-centos make_learn]# ./main   
1 2 3 10 13 20 25 234   
[root@VM-114-245-centos make_learn]#   

以这个项目为例,下面我们通过不同版本的makefile文件来实现项目的编译链接,从而让大家逐步了解makefile相关的实用语法。

2.1第一版makefile

mySort : main.o sort.o print.o  
        gcc -g -o mySort main.o sort.o print.o  
main.o : main.c  
        gcc -g -c main.c -o main.o  
sort.o : sort.c  
        gcc -g -c sort.c -o sort.o  
print.o : print.c  
        gcc -g -c print.c -o print.o  
clean :  
        rm -rf mySort main.o sort.o print.o  
.PHONY : clean

我们的第一版makefile非常简单明了。它的最终目标是生成名为mySort的目标文件,该目标文件依赖于main.o、sort.o、print.o这三个目标文件。而这三个目标文件也各自有自己的生成命令。此外,我们还显示声明了clean为一个伪目标,用于清除编译链接产生的目标文件和最终的mySort目标文件。

然而,按照这种方式编写makefile,每次往项目中添加新的源代码时,都需要手动修改mySort的依赖列表,并为新的源代码新增依赖关系描述。这种方式难以维护和扩展,因此我们需要更加智能和灵活的makefile编写方式。

2.2第二版makefile

OBJS = main.o sort.o print.o  
CC = gcc  
CFLAGS = -g  
TARGET = mySort  

$(TARGET) : $(OBJS)   
        $(CC) $(CFLAGS) -o $@ $^  
$(OBJS) : %.o : %.c  
        $(CC) $(CFLAGS) -c $< -o $@  
clean :  
        rm -rf $(TARGET) $(OBJS)   

.PHONY : clean

在第二版的makefile中,我们使用了三种常用的makefile语法,包括用户自定义变量、自动化变量以及静态模式。下面我们将分别介绍这三种不同的语法。

  • 自定义变量

在定义用户自定义变量时需要注意,变量名称是大小写敏感的。例如,OBJS和OBJs是两个不同的变量。变量名称可以包含数字、字符、下划线,也可以以数字开头,但不能包含":"、"="、"#"和空白符(Tab、回车、换行等)。

在第二版makefile中,我们定义了四个自定义变量,分别是OBJS、CC、CFLAGS和TARGET。它们分别表示目标文件列表、编译链接器、编译链接标记和最终生成的目标文件名。和shell变量类似,变量在引用时需要使用小括号"()"包含起来,并在前面加上"$"符号。

  • 自动变量

在第二版makefile中,我们使用了一些奇怪的字符串,例如"$^"、"$<"、"$@"等。这些字符串都是makefile自动化变量,它们代表了makefile中的一些常用信息。

具体来说,"$^"表示依赖文件列表,"$<"表示依赖文件列表中的第一个文件,"$@"表示最终要生成的文件。因此,在第七行中,"$@"表示的是$(TARGET),也就是mySort;"$^"表示的是$(OBJS),也就是"main.o sort.o print.o"。而在第九行中,"$<"表示的是%.c,在静态模式下会扩展成对应的源文件。

通过使用这些自动化变量,我们可以自动化地生成目标文件的命令,从而简化了makefile的编写。同时,这些自动化变量还可以帮助我们自动化地处理依赖关系,从而避免了手动修改makefile的繁琐工作。

  • 静态模式

在第八行和第九行中,我们看到了一种奇怪的依赖关系表达式,这种表达方式被称为静态模式。静态模式是一种能够更灵活地定义多个目标的依赖关系和生成规则的语法。它的语法格式如下。

<targets>:<target-pattern>:<prerequisites-patterns>

targets表示目标集合,上面makefile中targets为"main.o sort.o print.o"。

target-pattern表示目标的匹配模式,以上面为例,%.o表示以.o结尾的目标,也就是说经过匹配过滤后的目标集合为"main.o sort.o print.o"。

prerequisites-patterns表示目标的依赖模式,上面makefile中,目标的依赖模式为%.c,匹配后的目标使用%.c这个模式来生成依赖文件列表,即目标中的.o使用.c替换,然后生成对应依赖文件列表。  

第八行和第九行按照静态模式的语法展开之后的内容如下。

main.o : main.c  
        gcc -g -c main.c -o main.o  
sort.o : sort.c  
        gcc -g -c sort.c -o sort.o  
print.o : print.c  
        gcc -g -c print.c -o print.o

相较于第一版makefile,第二版makefile的依赖关系描述更加简洁,同时自定义变量的使用也让makefile的修改更加直接明了。

2.3第三版makefile

SOURCES = $(wildcard *.c)  
OBJS = $(patsubst %.c,%.o,$(SOURCES))  
TARGET = mySort  
CC = gcc  
CFLAGS = -g  

$(TARGET) : $(OBJS)   
        $(CC) $(CFLAGS) -o $@ $^  

$(OBJS) : %.o : %.c  
        $(CC) $(CFLAGS) -c $< -o $@  

clean :  
        rm -rf $(OBJS) $(TARGET)   

.PHONY : clean

我们的第三版Makefile中使用了makefile中的wildcard函数和patsubst函数。让我们先来看一下makefile函数的语法。

${<function> <arguments>} 或者 $(<function> <arguments>)

其中为函数名,为参数列表。函数名和参数列表之间使用空格分隔,参数列表中各个参数使用逗号分隔。函数调用以$开头,用括号或者大括号包含着函数名和参数列表。

  • wildcard函数

makefile中也存在和Shell中一样的通配符,比如*,?,但是makefile的通配符只能在目标和依赖文件列表中展开,在定义变量时是不会展开的。这时wildcard函数就派上用场了。wildcard函数是通配符扩展函数,$(wildcard *.c)表示当前目录下所有以.c结尾的文件。

  • patsubst函数

patsubst是模式字符串替换函数,它的函数调用格式为:$(patsubst pattern,replacement,text)。它会查找text中匹配pattern的单词,然后使用replacement替换,函数返回被替换后的字符串。pattern可以包含通配符%,%代表任意长度的字符串。如果replacement中也包含通配符%,那么replacement中的%是pattern中%所代表的字符串。

上面makefie中的$(patsubst %.c,%.o,$(SOURCES))函数调用,先对$(SOURCES)展开,得到$(patsubst %.c,%.o,main.c print.c sort.c),最后patsubst函数返回"main.o print.o sort.o"字符串。

第三版makefile已经比较通用了,在项目目录下添加或删除任何文件都不需要修改makefile。

2.4执行命令

上面我们讲解了makefile的语法,现在我们使用第三版本的makefile来实际操作一下,相关命令的执行如下所示。

例子1:执行make时指定clean为目标,则执行clean关联的清理命令。 [root@VM-114-245-centos make_learn]# make clean  
rm -rf print.o sort.o main.o   mySort   [root@VM-114-245-centos make_learn]#  
例子2:执行make时不携带任何参数,则mySort被当作最终的target。 [root@VM-114-245-centos make_learn]# make  
gcc   -g -c print.c -o print.o
gcc   -g -c sort.c -o sort.o
gcc   -g -c main.c -o main.o
gcc   -g -o mySort print.o sort.o main.o [root@VM-114-245-centos make_learn]#  
例子3:再次执行make,因为mySort刚刚生成,故make提示mySort为最新的 [root@VM-114-245-centos make_learn]# make  
make: `mySort' is up to date.
[root@VM-114-245-centos make_learn]#

3.通用版makefile

我们已经迭代了三版makefile,但并不是非常通用。比如,源文件只能匹配.c后缀的源文件,只能匹配当前目录下的文件,只能支持C程序的编译。在本小节中,我们将介绍通用版的makefile,它的内容如下。

#======================== 编译目标 开始 ========================#
TARGET = mySort
#======================== 编译目标 结束 ========================#

#======================= 自定义设置部分 开始 ====================#
# c编译选项
CFLAGS = -g -O2 -Wall -Werror -pipe -m64
# c++编译选项
CXXFLAGS = -g -O2 -Wall -Werror -pipe -m64 -std=c++11
# 连接选项
LDFLAGS =
# 头文件目录
INCFLAGS =
# 源文件目录
SRCDIRS = .
# 单独的源文件
ALONE_SOURCES =
#======================= 自定义设置部分 结束 =====================#

#======================= 固定设置部分 开始 =======================#
# c编译器
CC = gcc
# c++编译器
CXX = g++
# 源文件类型扩展:c后缀的为c源文件,其他的为c++源文件
SRCEXTS = .c .C .cc .cpp .CPP .c++ .cxx .cp
# 头文件类型扩展
HDREXTS = .h .H .hh .hpp .HPP .h++ .hxx .hp

# 如果TARGET为空,则取当前目录的basename作为目标名词
ifeq ($(TARGET),)
	# 取当前路径名列中最后一个名词,CURDIR是make的内置变量,自动会被设置为当前目录
	TARGET = $(shell basename $(CURDIR))
	ifeq ($(TARGET),)
		TARGET = a.out
	endif
endif

# 如果源文件目录为空,则默认当前目录为源文件目录
ifeq ($(SRCDIRS),)
	SRCDIRS = .
endif

# foreach函数用于遍历源文件目录,针对每个目录再调用addprefix函数添加目录前缀,生成各种指定源文件后缀类型的通用匹配模式(类似正则表达式)
# 使用wildcard函数对每个目录下文件,进行通配符扩展,最后得到所有的TARGET依赖的源文件列表,保存到SOURCES中
SOURCES = $(foreach d,$(SRCDIRS),$(wildcard $(addprefix $(d)/*,$(SRCEXTS))))
SOURCES += $(ALONE_SOURCES)
# 和上面的SOURCES类似
HEADERS = $(foreach d,$(SRCDIRS),$(wildcard $(addprefix $(d)/*,$(HDREXTS))))

# 过滤掉c语言相关的源文件,这个后续用于判断时采用c编译还是c++编译
SRC_CXX = $(filter-out %.c,$(SOURCES))

# 目标文件列表,先调用basename函数取源文件的前缀,然后再调用addsuffix函数添加.o的后缀
OBJS = $(addsuffix .o, $(basename $(SOURCES)))

# 定义编译和链接使用的变量
COMPILE.c   = $(CC)  $(CFLAGS)   $(INCFLAGS) -c
COMPILE.cxx = $(CXX) $(CXXFLAGS) $(INCFLAGS) -c
LINK.c      = $(CC)  $(CFLAGS)
LINK.cxx    = $(CXX) $(CXXFLAGS)

.PHONY: all objs clean help debug

# all生成的依赖规则,就是用于生成TARGET
all: $(TARGET)

# objs生成的依赖规则,就是用于生成各个链接使用的目标文件
objs: $(OBJS)

# 下面的是生成目标文件的通用规则
%.o:%.c
	$(COMPILE.c) $< -o $@

%.o:%.C
	$(COMPILE.cxx) $< -o $@

%.o:%.cc
	$(COMPILE.cxx) $< -o $@

%.o:%.cpp
	$(COMPILE.cxx) $< -o $@

%.o:%.CPP
	$(COMPILE.cxx) $< -o $@

%.o:%.c++
	$(COMPILE.cxx) $< -o $@

%.o:%.cp
	$(COMPILE.cxx) $< -o $@

%.o:%.cxx
	$(COMPILE.cxx) $< -o $@

# 最终目标文件的依赖规则
$(TARGET): $(OBJS)
ifeq ($(SRC_CXX),)              # c程序
	$(LINK.c)   $(OBJS) -o $@ $(LDFLAGS)
	@echo Type $@ to execute the program.
else                            # c++程序
	$(LINK.cxx) $(OBJS) -o $@ $(LDFLAGS)
	@echo Type $@ to execute the program.
endif

clean:
	rm $(OBJS) $(TARGET)

help:
	@echo '通用makefile用于编译c/c++程序 版本号1.0'
	@echo
	@echo 'Usage: make [TARGET]'
	@echo 'TARGETS:'
	@echo '  all       (等于直接执行make) 编译并连接'
	@echo '  objs      只编译不连接'
	@echo '  clean     清除目标文件和可执行文件'
	@echo '  debug     显示变量,用于调试'
	@echo '  help      显示帮助信息'
	@echo

debug:
	@echo 'TARGET       :' $(TARGET)
	@echo 'SRCDIRS      :'	$(SRCDIRS)
	@echo 'SOURCES      :'	$(SOURCES)
	@echo 'HEADERS      :'	$(HEADERS)
	@echo 'SRC_CXX      :'	$(SRC_CXX)
	@echo 'OBJS         :' $(OBJS)
	@echo 'COMPILE.c    :' $(COMPILE.c)
	@echo 'COMPILE.cxx  :' $(COMPILE.cxx)
	@echo 'LINK.c       :' $(LINK.c)
	@echo 'LINK.cxx     :' $(LINK.cxx)

#======================= 固定设置部分 结束 =======================#

通用版的makefile由三部分组成。第一部分是编译目标的定义,在这一部分中,我们定义了编译目标变量TARGET,它的值暂时为空。

第二部分是自定义设置的部分,一般我们只需要修改这部分的内容。在这一部分中,我们需要设置C和C++的编译选项、链接选项、头文件搜索路径、源文件目录、单独的源文件。

第三部分为固定设置部分,我们不需要修改这一部分的内容。在这里引入了几个makefile内置函数的调用,它们分别是foreach函数、addprefix函数、addsuffix函数、filter-out函数、basename函数。下面我们分别介绍这几个函数的功能。

  • foreach函数

foreach函数用于完成遍历操作,它的函数调用格式为:$(foreach ,,)。foreach函数会从list变量逐个获取变量保存到var变量中,然后执行text包含的表达式,text表达式中可以使用var变量。

  • addprefix函数

addprefix函数用于添加前缀,它的函数调用格式为:$(addprefix ,<name1 name2 name3 ...>)。addprefix函数会在name序列中每个元素前添加前缀。

  • addsuffix函数

addsuffix函数用于添加后缀,它的函数调用格式为:$(addsuffix ,<name1 name2 name3 ...>)。addsuffix函数会在name序列中每个元素后添加后缀。

  • filter-out函数

filter-out函数用于从一个字符串中剔除掉指定模式的数据,它的函数调用格式为:$(filter-out <pattern1 pattern2 ...>,)。filter-out函数会把text中,匹配pattern序列中任意pattern的数据剔除。

  • basename函数

这里的basename函数不是shell的basename命令,在makefile语法中它用于取前缀,它的函数调用格式为:$(basename <name1 name2 ...>),basename函数会取出name序列中每个数据的前缀。

4.下篇传送门

从入门到精通makefile,手写实现make命令-下篇

5.写在最后

今天分享的内容就到这里,『如果通过本文你有新的认知和收获,记得关注我,下期分享不迷路,我将持续在掘金上分享技术干货』。

硬核爆肝码字,跪求一赞!!!