makefile的使用技巧

本文探讨了从Visual Studio的IDE编译方式,转向CMake管理和跨平台需求,再到面对特定环境下Makefile的编写与复杂项目优化。作者详细解析了Makefile的工作原理,分享了源文件多时的高效编写策略,以及处理.h依赖和优化编译效率的方法。

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

从vc6.0到visual studio,编译代码的工作都是由IDE来完成,我们只需要点击编译按钮,即可将源文件编译成库文件或可执行文件,非常方便。到后来,由于跨平台开发和自动构建等需求,转而开始使用CMake作为代码构建的工具,CMake能灵活的组织代码和构建项目,而且跨平台。再后来,工作中发现有些场景下不得不使用makefile进行代码编译。比如,有些系统上未安装CMake工具,而且也不具备安装条件,此时,我们不得不编写makefile来构建项目。

makefile的编写逻辑其实很简单,目标:依赖。最终目标文件依赖.o文件,.o文件依赖.c文件。一个简单的makefile如下:

test:test.o
	gcc test.o -o test
test.o:test.c
	gcc -c test.c -o test.o
.PHONY:clean
clean:
	rm -f *.o test

以上makefile可以编译只有一个test.c的工程,最终生成test可执行文件。

以上的逻辑搞懂了,基本上就可以为任何工程编写makefile了。然而我们上面的例子太简单了,如果复杂一点的项目,源文件成百上千,这样编写要累死人。

下面版本将解决源文件多的问题:

SRC = $(wildcard /src/*.c)
OBJ = $(patsubst %.c, %.o, $(SRC))
test:$(OBJ)
	gcc $(OBJ) -o test
%.o:%.c
	gcc -c $< -o $@
.PHONY:clean
clean:
	rm -f *.o test

其中SRC是makefile中用户自定义的变量,wildcard是makefile中内置的函数,此函数后面的参数指定源文件的路径。第一条语句意思是将src/路径下所有.c文件名赋值给SRC变量,SRC类似于存放了所有源文件名的数组。第二条语句通过patsubst函数,将SRC中的.c改为.o赋值给OBJ,OBJ中保存了所有.c文件对应的.o文件名。后面生成目标文件时,可以使用前面定义好的变量,避免了写一大堆.o文件。通配符%表示任意一个,%.o:%.c表示任何一个.o文件依赖其对应的.c文件,比如a.o:a.c, b.o:b.c。$<代表依赖项,$@代表目标,在这里,$<代表某个.c文件,$@代表这个.c文件对应的.o文件。上面通过使用makefile的变量和通配符大大减少了makefile编写内容。

以上makefile其实是不完整的,或者说是有问题的。因为.o目标文件除了依赖.c文件外,还需要依赖.h文件。.o文件依赖哪些.h文件要看.c文件依赖哪些.h文件。上面第一个例子中,假设test.c文件中包含了test.h文件,那么test.o的依赖中还应包含test.h。

test.o:test.c test.h

第二个例子中忽略了.h依赖,而且对于一个大型项目,每一个.c文件依赖的.h可能都不一样。给每个.c文件找出它所依赖的.h文件,这个工作量也是有点大。有一种粗暴的方式就是把所有.h文件作为.c文件的依赖,这样,.c文件总能找到它的依赖。这样makefile的改动也很小,如下:

SRC = $(wildcard /src/*.c)
OBJ = $(patsubst %.c, %.o, $(SRC))
HEAD = $(wildcard /src/*.h)
HEAD += $(wildcard /include/*.h)
test:$(OBJ)
	gcc $(OBJ) -o test
%.o:%.c $(HEAD)
	gcc -c $< -o $@ 
.PHONY:clean
clean:
	rm -f *.o test

增加一个HEAD变量,HEAD变量里面包含了这个工程所依赖的所有头文件。通过+=可以对HEAD变量进行追加内容。最后将HEAD变量加到%.o:%.c语句后面。以上解决方案可以保证编译没问题。但是由于每个.c文件都依赖所有.h文件,那么当某一个头文件修改时,再次make进行编译,就会导致所有.o文件被重新编译,最终导致整个项目的编译。而我们期望修改一个文件,只编译依赖它的目标文件,而不是全部编译。如何找到每一个.c文件确切的头文件依赖?gcc提供的-MM参数可以得到一个.c文件所依赖的所有头文件,通过这个功能貌似可以解决上面的问题。

首先我们看看通过gcc -MM会得到什么。

$ gcc -MM main.c
$ main.o: main.c func.h

我们得到了main.o: main.c func.h这一句,这个正是makefile中.o目标文件的依赖规则语句。makefile具有隐式推导能力,比如在makefile中只有main.o: main.c func.h这一句,那么在makefile进行执行之前,会先推导出gcc -c main.c -o main.o,也就是说我们可以在makefile中只写一句 main.o: main.c func.h。因此,我们只需要将每个.c文件通过gcc -MM生成的结果放到makefile中就可以了。但是makefile又不可能动态更改,好在makefile有include功能。类似与C语言中的#include,可以包含文件内容进来,makefile的include也具有这个能力,所以,我们可以在makefile中通过命令生成.o文件的依赖规则内容,并写入文件,再通过include包含在makefile中。

SRC = $(wildcard /src/*.c)
OBJ = $(patsubst %.c, %.o, $(SRC))
INCLUDEFLAGS = -I./include
test:$(OBJ)
	gcc $(OBJ) -o test
%.o:%.c
	gcc -c $< -o $@ 
%.d:%.c
	@set -e; rm -f $@;	\
	gcc -MM $(INCLUDEFLAGS) $< > $@.$$$$;	\
	sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' < $@.$$$$ > $@; \
	rm -f $@.$$$$
-include $(OBJ:.o=.d)
.PHONY:clean
clean:
	rm -f *.o test *.d

增加了$.d:%c规则,目的是执行下面的的shell命令。

@set -e;语句表示不输出shell命令执行过程;

rm -f $@;删除.d文件,$@代表目标文件,在%.d:%.c规则中,目标文件就是.d文件;

gcc -MM $< > $@.$$$$;这条语句就是生成.c的依赖规则,$<代表.c文件,将依赖规则写入$@.$$$$文件中,$@代表目标文件.d,后面的$$$$实际上是$$$符号转义需要一个$,即两个$$代表一个$,而$$最终在shell中会被替换为进程ID,最终$@.$$$$文件名可能是 test.d.6732,它只是一个临时文件,后面会删除。

sed 's,\($*\)\.0[ :]*,\1.o $@ : ,g' < $@.$$$$ > $@;这条语句分两部分看;

首先看sed命令 sed commond file,将生成的包含依赖规则的文件内容使用sed处理,处理命令s,\($*\)\.0[ :]*,\1.o $@ : ,g,这条命令是一个正则表达式替换命令,结构:s,要替换的内容,替换之后的内容,g

要替换的内容:\($*\)\.0[ :]*$*代表目标文件不包含后缀名,比如 test.d为目标,而 $*表示test,即:test.o;

替换之后的内容:\1.o $@ :,1代表查找到的第一个结果,即test。最终替换成test.o test.d;

最后将替换好的内容写入到$@,即test.d中;

rm -r $@.$$$$,删除临时文件;

上面的命令总结起来就是:通过gcc -MM命令生成.o文件的依赖规则,同时修改依赖规则,写入到.d文件中,最后在目标项中加入.d。

其结果就是在makefile中增加了如下语句:

main.o main.d: main.c func.h

最后-include,将.d文件包含到makefile中,$(OBJ:.o=.d)意思是所有.o对应的那个.d文件,即这条语句包含了所有.d文件。include 前面的"-"表示如果有错误忽略,因为,当第一次make时,不存在.d文件,include一定是找不到文件的。

至此,我的makefile基本形态已经确定了。

这里还有2个问题需要注意

  1. 刚才我们讲makefile会对main.o: main.c func.h进行隐式推导,会变成如下:

    main.o: main.c func.h
    	gcc -c main.c -o mian.o
    

    既然可以推导得到,那么我们makefile前面的

    %.o:%.c
    	gcc -c $< -o $@ 
    

    是不是多余的?

    这个不是多余的,可以理解为,一个规则声明,和隐式推导的规则不冲突,这个理论上是可以省略的,但是实际上一般不会省略,而且还有用处,比如,编译参数可以加在这里,因为隐式推导的规则无法添加编译参数。

  2. 为什么main.d也要作为目标项?

    因为main.d应该是可以根据依赖项变化的,比如让main.d像main.o一样依赖main.c和func.h。假如我们不让main.d作为目标项,那么就变成了main.o: main.c func.h,此时,我们修改func.h,在里面增加一个头文件包含如下:

    // func.h
    #include "in.h"	// 新增
    

    此时,我们make时,由于func.h发生了变化,所以main.o会被重新编译,这一次没有问题。接着,修改in.h文件,因为main.o只依赖main.c和func.h,再次make时,main.o没有重新编译!这当然不是我们所期望的。

    反过来想,当main.d作为目标项,即:main.o main.d: main.c func.h,这种情况下,当func.h进行修改时,main.d会像main.o一样重新生成,编译之后的main.d变成main.o main.d: main.c func.h in.h, 此时,我们再次修改in.h时,就能触发main.o被重新编译,这正达到了我们的要求。这也就是为什么会有sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' < $@.$$$$ > $@;这句脚本去修改目标依赖项的原因。

上面对于.d文件的生成脚本是大家普遍使用的,而且网上都是这种写法,但是在我自己使用中发现这个脚本其实是有问题的。

.
├── func.c
├── func.h
├── include
│   ├── in2.h
│   ├── in.h
│   └── test.h
├── main.c
├── makefile
└── src
    └── test.c

上图所示的目录结构,运行gcc -MM命令:

$ gcc -MM -I./include src/test.c
test.o: src/test.c include/test.h include/in2.h

通过上面的脚本生成的.d文件内容为:

test.o: src/test.c include/test.h include/in2.h

test.d文件并没有被添加到目标项中。这是因为替换脚本并没有识别找到test.o,因为目录结构的存在,查找时,匹配字符串是src/test.o,带了路径。因此,没能正确匹配和替换。最终导致生成的.d文件中没有将.d添加为目标项,不将.d作为目标项导致的问题就是上面第2个问题。

正确的脚本:

%.d:%.c
     rm -f $@; \
     $(CC) -MM $(INCLUDEFLAGS) $< > $@.$$$$; \
     sed 's,\($(notdir $*)\)\.o[ :]*,\$*.o $@ : ,g' < $@.$$$$ > $@; \
     rm -f $@.$$$$ 

通过makefile内置函数notdir将查找项的路径去掉,在将带路径的.o和.d添加上去,最终由脚本生成的.d文件为:

src/test.o src/test.d : src/test.c include/test.h include/in2.h

完整版的makefile模板

INCLUDEFLAGS = -I. -I./include
CFLAGS = -g
LDFLAGS =
CC = gcc

SRC += $(wildcard ./*.c)
SRC += $(wildcard ./src/*.c)

OBJ = $(patsubst %.c, %.o, $(SRC))
DEP = $(patsubst %.c, %.d, $(SRC))
BIN = a.out

.PHONY:all
all:$(BIN)

$(BIN):$(OBJ)
	$(CC) $(OBJ) -o $@ $(LDFLAGS)

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

%.d:%.c
	@set -e;rm -f $@; \
	$(CC) -MM $(INCLUDEFLAGS) $< > $@.$$$$; \
	sed 's,\($(notdir $*)\)\.o[ :]*,\$*.o $@ : ,g' < $@.$$$$ > $@; \
	rm -f $@.$$$$ 

-include $(OBJ:.o=.d)

.PHONY:clean
clean:
	rm -f $(OBJ) $(BIN) $(DEP)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值