从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个问题需要注意
-
刚才我们讲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 $@
是不是多余的? 这个不是多余的,可以理解为,一个规则声明,和隐式推导的规则不冲突,这个理论上是可以省略的,但是实际上一般不会省略,而且还有用处,比如,编译参数可以加在这里,因为隐式推导的规则无法添加编译参数。 -
为什么main.d也要作为目标项? 因为main.d应该是可以根据依赖项变化的,比如让main.d像main.o一样依赖main.c和func.h。假如我们不让main.d作为目标项,那么就变成了main.o: main.c 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)
|