从零开始写一个通用的Makefile

本文深入解析Makefile的原理及应用,从基本规则到高级特性,包括依赖管理、递归编译、通配符使用等,展示如何构建复杂项目的自动化编译流程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

第一部分

先来看一下我们此时要编译的代码

  • main.h
#ifndef _MAIN_H_
#define _MAIN_H_

#define NUM 3

#endif
  • main.c
#include <stdio.h>
#include "main.h" 

int main(int argc, char *argv[])
{

	printf("%d\n", NUM);

	return 0;
}

这段代码没有什么可以讲的,只是简单的一个打印语句而已,下面我们来讲一下Makefile。

示例1
all : main.o
	gcc -o app main.o

这算是最简单的一个Makefile的,简单讲一下Makefile的规则,在这个示例中“all”是目标,“main.o”是依赖,"gcc -o app main.o"是命令,一个make规则就是由这三部分(目标、依赖、命令)组成的。

命令会执行的条件

  • 1、没有目标这个文件

在这个示例中就是只要当前目录没有“all”这个文件,就会执行“gcc -o app main.o”这条命令。

  • 2、依赖文件比目标文件新

在这个示例中,就是只要“main.o”的比“all”文件新,就执行“gcc -o app main.o”这条命令。

其实Makefile还是比较好理解的,只要记住一个点,在make进入Makefile文件时,会把遇到的第一个目标当作最终目标,然后以后的所有操作都是为了这个目标生成。

接下来说回我们这个示例。

可能有人会好奇,此时没有main.o,只有main.c为什么可以编译成功?

那是因为make的自动推导功能。

只要 make 看到一个[.o]文件,它就会自动的把[.c]文件加在依赖关系中,如果 make 找到一个 xxx.o,那么 xxx.c,就会是 xxx.o 的依赖文件。并且cc –c -o xxx.c xxx.c $(CFLAGS)也会被推导出来。

这里make找到了 main.o ,然后就会自动推导出:

gcc -c -o main.o main.c $(CFLAGS)

从Makefile的打印信息可以看出这一点

cc    -c -o main.o main.c
gcc -o app main.o

当然上面这样写Makefile风格不太好,修改时会比较麻烦,应该使用变量,修改的时候只要修改相应的变量。

Makefile的变量定义的C语言的宏定义差不多,引用的时候向宏一样展开,使用$(变量名)来引用变量。

下面是修改后的代码

示例2
CC = gcc

objs := main.o

target := app

all : $(objs)
        $(CC) -o $(target) $(objs)

.PHONY : clean
clean:
        rm -f $(shell find -name "*.o")
        rm -f $(target)

这个示例定义了“CC ”、“objs”、“target”三个变量,然后下面使用$(变量名)来引用。

.PHONY : clean
clean:
        rm -f $(shell find -name "*.o")
        rm -f $(target)

这一部分的代码是用于清除所有的“.o”文件和生成的目标文件,使用“”make clean“执行。

.PHONY修饰目标为伪目标,向make说明,不管是否有这个文件,这个目标就是“伪目标”,如果没有使用.PHONY来修饰,那么只要当前目录下有clean文件,那么当我们使用“make clean”的时候,是不会执行“clean”这个规则的。


现在我们来做一个实验,修改”main.h“文件,然后编译。

  • main.h
#ifndef _MAIN_H_
#define _MAIN_H_

#define NUM 4

#endif

此时我们会发现,main.c是没有重新被编译的,程序的输出结果还是“4”。

why?

因为我们使用的并没有指明main.o的依赖关系,而是让Makefile让我们自动推导,make帮我们推导的结果如下。

main.o : main.c
gcc -c -o main.o main.c $(CFLAGS)

可见,起始并没有包含main.h,记住make的规则是,只有目标不存在,或者依赖文件比目标新才会编译。

此时的目标是“main.O”已经存在了,而make自动推导的依赖只有“main.c”没有“main.h”,所以我们修改“main.h”时也就不会重新编译生成“main.o”了。

那要怎么办呢?

既然让make自动推导不那么靠谱,那我们就自动指定“main.o”的规则了。

下面来看一看我们是如何修改的

示例3
CC = gcc

objs := main.o

target := app

all : $(objs)
        $(CC) -o $(target) $(objs)

main.o : main.c main.h
        $(CC) -c  -o main.o main.c

.PHONY : clean
clean:
        rm -f $(shell find -name "*.o")
        rm -f $(target)

我们添加了

main.o : main.c main.h
        $(CC) -c  -o main.o main.c

让“main.o”的依赖对象“main.c”和“main.h”,此时修改“main.h”后,就会重新编译执行“main.h”了。

这里强调一下为什么要写这个依赖关系,是因为不写不能编译吗?

不是的,如果不写“.o”文件的依赖关系,make会自动帮我们推导,此时还是能正常编译,但是make推导的依赖关系不全(比如“main.o”只会推导成"main.o"依赖于“main.c”,但是并不能推导出“main.h”)。当目标已经生成,我们再修改了make推导规则中的规则没有被包括的文头件后,不会重新编译。


那么现在问题又来了,我们现在是只有一个源文件,写它的依赖关系还算容易,但是如果有非常多的源文件,每个源文件都有很多依赖关系,那么把这些全写出来就没有那么容易了。

emmmm。。。

那有什么更好的解决办法呢?

有的,来看一看我们下面的示例。

第二部分

在看此示例前,我们先来看一看一些知识。

使用下面命令会发生什么?

gcc -M main.c
  • 输出结果为main.o的依赖
main.o: main.c /usr/include/stdc-predef.h /usr/include/stdio.h \
 /usr/include/features.h /usr/include/i386-linux-gnu/sys/cdefs.h \
 /usr/include/i386-linux-gnu/bits/wordsize.h \
 /usr/include/i386-linux-gnu/gnu/stubs.h \
 /usr/include/i386-linux-gnu/gnu/stubs-32.h \
 /usr/lib/gcc/i686-linux-gnu/4.8/include/stddef.h \
 /usr/include/i386-linux-gnu/bits/types.h \
 /usr/include/i386-linux-gnu/bits/typesizes.h /usr/include/libio.h \
 /usr/include/_G_config.h /usr/include/wchar.h \
 /usr/lib/gcc/i686-linux-gnu/4.8/include/stdarg.h \
 /usr/include/i386-linux-gnu/bits/stdio_lim.h \
 /usr/include/i386-linux-gnu/bits/sys_errlist.h main.h

可以看到,这其中包括了标准库头文件,起始我们并不需要这些,因为我们要得到依赖规则的主要目的是在我们修改了相应的头文件时,目标会重新编译,因为我们是不会修改库文件的,所以这些库文件的信息也就不需要了。

使用下面这个命令可以去除这些信息

gcc -MM main.c
  • 输出结果
main.o: main.c main.h

如果我们要把结果写到一个文件中,我们要怎么做?

使用下面的命令

gcc -Wp,-MMD,main.o.d main.c

这样子也就把结果写入“main.o.d”文件中了。

通配符

“$@”:表示目标的集合.

  • 举例
在“main.o : main.c”中,$@就表示“main.o”

在“%.o : %c”:(%表示任意长度字符),$@就表示当前目录所有“.o”文件的集合

“$<”:表示依赖的集合

  • 举例
在“main.o : main.c”中,$@就表示“main.c”

在“%.o : %c”:(%表示任意长度字符),$@就表示当前目录所有“.c”文件的集合

有了上面的介绍,我们来看一看下面的例子

现在我们添加一个文件“hello.c”

  • hello.c
#include <stdio.h>

void hello(void)
{
        printf("hello world!\n");
}

  • main.c
#include <stdio.h>
#include "main.h"

extern void hello(void);

int main(int argc, char *argv[])
{

        printf("%d\n", NUM);

        hello();

        return 0;
}

看一看我们的Makefile

示例4
CC = gcc

objs := main.o hello.o

target := app

all : $(objs)
        $(CC) -o $(target) $(objs)

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

.PHONY : clean
clean:
        rm -f $(shell find -name "*.o")
        rm -f $(target)

我们在其中增加了“hello.o”

并使用了下面来指定我们的依赖规则

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

上面的意思是,所有的“xxx.o”文件都依赖于“xxx.c”文件,然后编译生成。比如在编译过程中,发现没有“main.o”文件,那么make就会去查找相应的规则,找到了这条规则,make就会认为“main.o”依赖于“main.c”,然后使用“$(CC) -c -o $@ $<”这条命令编译

哈哈哈,上面这个例子只是为了下面的例子做铺垫,下面来看一看的例子。

示例5
CC = gcc

objs := main.o hello.o

target := app

all : $(objs)
        $(CC) -o $(target) $(objs)

dep_file = .$@.d

%.o : %.c
        $(CC) -Wp,-MMD,$(dep_file) -c -o $@ $<

.PHONY : clean
clean:
        rm -f $(shell find -name "*.o")
        rm -f $(target)

这条命令

dep_file = .$@.d
%.o : %.c
        $(CC) -Wp,-MMD,$(dep_file) -c -o $@ $<

在编译生成“.o”文件是会生成依赖文件“.$@.d”。
比如“main.o”就会生成“.main.o.d”。

好,现在有了依赖文件,我们要怎么使用?

在第一次编译的时候,我们并不需要这些依赖文件,因为不管源文件相应的头文件有没有改变,源文件都会编译。依赖文件的作用是给后面再次编译时使用的,所以我们需要在再次编译时需要把第一次编译生成的依赖文件给包含进来。

在介绍如何包含之前,先介绍一些知识。

  • foreach 函数

$(foreach ,,)

这个函数的意思是,把参数中的单词逐一取出放到参数所指定的变量中,
然后再执行所包含的表达式。每一次会返回一个字符串,循环过程中,
的所返回的每个字符串会以空格分隔,最后当整个循环结束时,所返回的每个字符串
所组成的整个字符串(以空格分隔)将会是 foreach 函数的返回值。

举例

names := a b c d 
files := $(foreach n,$(names),$(n).o)

$(files)的值是“a.o b.o c.o d.o”。
  • wildcard函数

这个函数的意思是,展开扩展符

举例

files := $(wildcard *.c)

$(files)的结果为当前目录下所有“.c”文件的集合

还可以这样用

objs := .main.c.d .hello.c.d

files := $(wildcard $(objs))

查看当前目录下是否有“ .main.c.d .hello.c.d”这两个文件,如果有,则返回相应的文件名,没有则返回空

接下来就来看一看这个比较有B格的示例了

这个示例很好的解决了文件的依赖问题

示例6
CC = gcc

objs := main.o hello.o

target := app

dep_files := $(foreach f, $(objs), .$(f).d)
dep_files := $(wildcard $(dep_files))

ifneq ($(dep_files),)
        include $(dep_files)
endif

all : $(objs)
        $(CC) -o $(target) $(objs)

dep_file = .$@.d

%.o : %.c
        $(CC) -Wp,-MMD,$(dep_file) -c -o $@ $<

.PHONY : clean
clean:
        rm -f $(shell find -name "*.o")
        rm -f $(target)

.PHONY : distclean
distclean:
        rm -f $(shell find -name "*.o")
        rm -f $(shell find -name "*.d")
        rm -f $(target)

dep_files := $(foreach f, $(objs), .$(f).d)

此时$(objs)=main.o hello.o
$(dep_files)=.main.o.d .hello.o.d

dep_files := $(wildcard $(dep_files))

这个的作用是,看当前目录是否存在依赖文件,
第一遍编译的时候是没有的(depfiles)为空,之后再编译(dep_files)为空, 之后再编译(depfiles)(dep_files)=.main.o.d .hello.o.d

ifneq ($(dep_files),)
        include $(dep_files)
endif

这个的作用是如果$(dep_files)不为空,则将其包含进来。

一旦包含进来,那么main.o和hello.o的依赖关系就有了

下面是“.main.o.d和.hello.o.d”文件的内容

  • .main.o.d
main.o: main.c main.h
  • hello.o.d
hello.o: hello.c

之后编译的时候,这两个文件都会包含进来,而main.o会依赖main.h,所以修改main.h,根据make的规则,总会重新编译main.c。

至此,文件依赖问题就解决了。

那还有什么问题呢?

我们上面写的Makefile还算比较像样子了,但是我们只是在同一级目录下编译文件,如果我们有很多个目录,没有目录又有它的子目录,此时我们怎么解决编译的问题。

第三部分

我们创建一个目录dir1,在目录下创建一个文件test.c

此时的目录结构

  • Makefile
  • main.c
  • hello.c
  • dir1
    • test.c

在main.c中引用test.c的函数,现在问题是怎么编译test.c

一种笨方法:直接将文件的路径添加进Makefile

示例7
CC = gcc

objs := main.o hello.o dir1/test.o

target := app

all : $(objs)
        $(CC) -o $(target) $(objs)


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

.PHONY : clean
clean:
        rm -f $(shell find -name "*.o")
        rm -f $(target)

.PHONY : distclean
distclean:
        rm -f $(shell find -name "*.o")
        rm -f $(shell find -name "*.d")
        rm -f $(target)

这种办法虽然能解决问题,但是不是很好,一个问题是当目录多了,添加问价很麻烦。另一个问题是,使用这种办法,我们上面介绍的解决依赖的办法就不那么容易使用了。

那有没有什么别的办法呢?

有的,接下来介绍一种使用递归的办法,哈哈,也是我们的最终版本“通用的Makefile”。

该版本的文件格式:

  • Makefile
  • Makefile.build
  • main.c
  • dir1/
    • Makefile
    • test.c

如需包含目录

则在子目录的上一级目录的Makefile中使用"obj-y += dir1/"

然后在子目录中添加Makefile,然后添加“obj-y += test.c”添加要编译的文件

直接上代码吧

通用的Makefile
  • Makfile
# 指定编译器
CROSS_COMPILE =

# 汇编器
AS			= $(CROSS_COMPILE)as

# 链接器
LD			= $(CROSS_COMPILE)ld

# C语言编译器
CC			= $(CROSS_COMPILE)gcc

# C++编译器
CXX			= $(CROSS_COMPILE)g++

# 用于创建库
AR			= $(CROSS_COMPILE)ar

# 用于列出文件中的符号信息
NM			= $(CROSS_COMPILE)nm

# 用于取出符号信息
STRIP		= $(CROSS_COMPILE)strip

# 实现目标文件的格式转换
OBJCOPY		= $(CROSS_COMPILE)objcopy

# 反汇编
OBJDUMP		= $(CROSS_COMPILE)objdump

# 导出所有符号,供子目录Makefile使用
export AS LD CC CXX AR NM STRIP OBJCOPY OBJDUMP

# C语言编译器参数
CFLAGS := -Wall -O2 -g -Werror

# 将当前目录下的include头文件路径包含进来
CFLAGS += -I $(shell pwd)/include

# 链接器的链接参数
LDFLAGS :=

# 将这两个参数导出,供子目录Makefile使用
export CFLAGS LDFLAGS

# 当前的顶层目录
TOPDIR := $(shell pwd)

# 将顶层目录导出,供子目录Makefile使用
export TOPDIR

# 编译生成的文件名
TARGET := app

# 包含要编译的文件
obj-y += main.o

# 包含要编译的子目录
obj-y += dir1/

# 该Makefile的第一个目标,为最终目标
all:
	make -C ./ -f $(TOPDIR)/Makefile.build # 跳转到当前目录,make执行Makefile.build文件
	$(CC) $(LDFLAGS) -o $(TARGET) built-in.o # 将built-in.o链接生成可执行文件TARGET
	
clean:
	rm -f $(shell find -name "*.o") # 删除所有的.o文件
	rm -f $(TARGET) # 删除可执行程序

distclean:
	rm -f $(shell find -name "*.o") # 删除所有的.o文件
	rm -f $(shell find -name "*.d") # 删除所有的依赖文件
	rm -f $(TARGET) # 删除可执行程序
  • Makefile.build
# 假想目标
PHONY := __build
__build:

# 要编译的目标
obj-y :=
# 要编译的目录 
subdir-y :=

# 将当前目录下的Makefile包含进来
include Makefile

# 得到当前目录下的所有目录
# 举例:$(obj-y)=source1.o source2.o dir1/ dir2/;$(_subdir-y)=dir1/ dir2/
_subdir-y	:= $(filter %/, $(obj-y))

# 将目录的“/”去掉
# 举例:$(_subdir-y)=dir1/ dir2/;$(subdir-y)=dir1 dir2
subdir-y 	+= $(patsubst %/,%,$(_subdir-y))

# 给每个子目录名加上“/built-in.o”
# 举例:$(subdir-y)=dir1 dir2;$(subdir_objs)=dir1/built-in.o dir2/built-in.o
subdir_objs := $(foreach f,$(subdir-y),$(f)/built-in.o)

# 得到当前目录下的源文件目标
# 举例:$(obj-y)=source1.o source2.o dir1/ dir2/;$(cur_objs)=source1.o source2.o
cur_objs 	:= $(filter-out %/, $(obj-y))

# 得到当前目录下源文件目标的依赖文件
# 举例:$(cur_objs)=source1.o source2.o;$(dep_files)=.source1.o.d .source2.o.d
dep_files	:= $(foreach f,$(cur_objs),.$(f).d)

# 搜索当前目录下含有的$(dep_files)文件
dep_files	:= $(wildcard $(dep_files))

# 如果$(dep_files)不为空,则将其包含进来
ifneq ($(dep_files),)
	include $(dep_files)
endif

PHONY += $(subdir-y) # 假想目标

# 该目标依赖于$(subdir-y)和built-in.o
__build : $(subdir-y) built-in.o

# 当前目录下所有子目录
# 跳转到子目录下调用顶层目录的Makefile.build
$(subdir-y) :
	make -C $@ -f $(TOPDIR)/Makefile.build

# 该目标依赖于当前目录的所有.o文件,还有所有子目录的built-in.o文件
# 将所有依赖打包为built-in.o
built-in.o : $(cur_objs) $(subdir_objs)
	$(LD) -r -o $@ $^

dep_file = .$@.d

# 所有.o文件的规则
# “-Wp,-MMD,$(dep_file)”表示将每个文件的依赖写入一个文件中
%.o : %.c
	$(CC) $(CFLAGS) -Wp,-MMD,$(dep_file) -c -o $@ $<

# 声明所有的假想目标
.PHONY : $(PHONY)

这两个文件的大部分知识是上面介绍的了,代码里也做了详细的注释,当然看懂还是需要一定的时间的。

这里我简单介绍一下这个文件的执行流程

首先执行make会来到这里

Makefile

all:
	make -C ./ -f $(TOPDIR)/Makefile.build # 跳转到当前目录,make执行Makefile.build文件
	$(CC) $(LDFLAGS) -o $(TARGET) built-in.o # 将built-in.o链接生成可执行文件TARGET

先执行下面这条命令,去执行Makefile.build

make -C ./ -f $(TOPDIR)/Makefile.build

然后执行Makefile.build中的这条规则

Makefile.build

__build : $(subdir-y) built-in.o

这条负责依赖于 $(subdir-y)(当前目录所有的子目录)与built-in.o(当前目录目标文件与子目录所有目标文件打包而成的)

然后到达这个依赖,$(subdir-y)(所有的子目录)

$(subdir-y) :
	make -C $@ -f $(TOPDIR)/Makefile.build

执行这条命令,将跳转到每一个子目录中执行Makefile.build,到了子目录,又是同样的过程,所以这是一个递归过程。

make -C $@ -f $(TOPDIR)/Makefile.build

到达最底层目录后,开始打包,执行下面这条规则,将所有的目标文件打包成built-in.o

built-in.o : $(cur_objs) $(subdir_objs)
	$(LD) -r -o $@ $^

然后递归开始返回,逐级调用

built-in.o : $(cur_objs) $(subdir_objs)
	$(LD) -r -o $@ $^

一级一级当前目录下的目标文件和所有子目录的built-in.o打包成一个built-in.o。

最后来到最顶层,将main.o和所有子目录的built-in.o打包成目标文件,此时编译链接完成。

至此,如何编译一个通用的Makefile也就写完了。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值