理解Make增量编译的实现原理
Make基本概念
Make是一个GNU工具,它通过读取Makefile文件,根据其中的规则执行对应的命令,最终完成代码的编译和构建,一般和GCC等编译工具结合使用。如果仅仅是简单编写一个程序,比如foo.c,程序代码如下:
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("foo bar\n");
return 0;
}
直接用gcc -Wall foo.c -o foo
生成可执行文件就可以,看似也用不到Makefile。但是如果使用Makefile, 我们可以实现更加复杂的编译控制,比如增量编译功能,具体请往下看。
编译过程
让我们用一个Makefile来完成上述编译工作,同时修改文件foo.c,并增加文件bar.c,另外我们还需要增加一个Makefile文件。具体代码和文件如下:
foo.c源码
#include <stdio.h>
#include <stdlib.h>
int foo()
{
printf("foo\n");
return 0;
}
bar.c源码
#include <stdio.h>
#include <stdlib.h>
int bar()
{
printf("bar\n");
return 0;
}
main.c源码
#include <stdio.h>
#include <stdlib.h>
int main()
{
foo();
bar();
return 0;
}
Makefile文件
# 定义变量
SRCS = $(wildcard *.c) # 查找当前目录下所有 .c 文件
OBJS = $(SRCS:.c=.o) # 将 .c 文件转换为 .o 文件
CC = gcc # 指定编译器
CFLAGS = -g -Wall -O2 # 编译选项
TARGET = app # 目标可执行文件名
# 目标规则
$(TARGET): $(OBJS)
$(CC) $^ -o $@ $(CFLAGS)
# 编译规则:将 .c 文件编译为 .o 文件
%.o: %.c
$(CC) -c $< -o $@ $(CFLAGS)
# 清理规则
clean:
rm -f $(OBJS) $(TARGET)
PHONY: clean
完成后在shell下执行make, 就可以完成上述编译工作了。
gcc -c bar.c -o bar.o -g -Wall -O2
gcc -c foo.c -o foo.o -g -Wall -O2
gcc -c main.c -o main.o -g -Wall -O2
main.c: In function ‘main’:
main.c:6:9: warning: implicit declaration of function ‘foo’ [-Wimplicit-function-declaration]
6 | foo();
| ^~~
main.c:7:9: warning: implicit declaration of function ‘bar’ [-Wimplicit-function-declaration]
7 | bar();
| ^~~
gcc bar.o foo.o main.o -o app -g -Wall -O2
其中上面的输出包含两个warning告警,原因是我们隐式声明了函数。一种解决方法是使用extern显示引用一下,另一种是定义一个头文件包含这些函数原型,这样源文件直接引用头文件即可。第一种方法修改简单,但是当函数多的时候会有大量的extern引用,且每个用到的源文件都需要引用一次,而且不利于保持引用和原型的一致,因此我们采用第二种方法修改,增加头文件api.h,源码如下:
#ifndef _API_H_
#define _API_H_
extern int foo();
extern int bar();
#endif
完成后修改所有源文件包含api.h,执行make clean
清除上一次的编译结果,然后再make
编译,输出就没有warning了,具体输出如下:
gcc -c bar.c -o bar.o -g -Wall -O2
gcc -c foo.c -o foo.o -g -Wall -O2
gcc -c main.c -o main.o -g -Wall -O2
gcc bar.o foo.o main.o -o app -g -Wall -O2
增量编译
在上面编译完成的基础上,如果我们再重新make编译一遍,会发现make检测到没有文件更新,所以不重新编译了。
make: 'app' is up to date.
具体来说,就是Makefile中的target的时间比dependencies要更晚,所以没有必要再重新编译了,这也就是我们说的增量编译了,没有文件更新,不需要编译。
那么如果我修改了其中一个源文件,比如foo.c,那么make编译是什么结果呢,让我们试试。
新的foo.c文件
#include <stdio.h>
#include <stdlib.h>
#include "api.h"
int foo()
{
printf("foo\n");
printf("foo #2\n");
return 0;
}
其中高亮部分的是新增加的一行代码,重新make编译一下,输出如下:
gcc -c foo.c -o foo.o -g -Wall -O2
gcc bar.o foo.o main.o -o app -g -Wall -O2
可以看到foo.c被重新编译了,同样的道理,如果我们修改了bar.c或者main.c,都会触发修改的文件重新编译,不修改的文件不会编译。
那么如果我们修改了api.h文件,结果又是如何呢?比如增加一个结构体和宏定义,具体修改后的代码如下:
#ifndef _API_H_
#define _API_H_
#define MAX_LINE_NUM 256
struct api_cmd_t
{
char *cmd;
int len;
};
extern int foo();
extern int bar();
#endif
其中高亮部分的是新增加的代码,重新执行make
编译一下:
make: 'app' is up to date.
咦,我们发现,程序并没有重新编译,这可不行,因为如果我定义或者修改的变量或者结构体,正好是源文件代码中已经使用的,那编译出来的程序并不是我预期的,这就麻烦了。而且,不论是源文件还是头文件的修改,程序都应该重新编译,具体对于头文件来说,因为头文件不需要单独编译,所以所有依赖于他的源文件都需要编译。
编译优化
GCC支持通过-M 生成文件的依赖关系,包括标准库的头文件,-MM 生成文件的依赖关系,不包括标准库的头文件。比如我们可以用下面的命令为main.c生成依赖文件:
gcc -M -MF main.d main.c
这将生成 main.d 文件,内容如下:
main.o: main.c /usr/include/stdc-predef.h /usr/include/stdio.h \
/usr/include/x86_64-linux-gnu/bits/libc-header-start.h \
/usr/include/features.h /usr/include/features-time64.h \
/usr/include/x86_64-linux-gnu/bits/wordsize.h \
/usr/include/x86_64-linux-gnu/bits/timesize.h \
/usr/include/x86_64-linux-gnu/sys/cdefs.h \
/usr/include/x86_64-linux-gnu/bits/long-double.h \
/usr/include/x86_64-linux-gnu/gnu/stubs.h \
/usr/include/x86_64-linux-gnu/gnu/stubs-64.h \
/usr/lib/gcc/x86_64-linux-gnu/13/include/stddef.h \
/usr/lib/gcc/x86_64-linux-gnu/13/include/stdarg.h
...此处省略大量标准库文件列表
这样,我们在Makefile中包含main.d文件的时候,如果main.c依赖的文件有更新,则会重新编译main.o,这样就实现了修改头文件的增量编译。对应我们上面的例子,我们可以在Makefile中增加如下代码,修改后的Makefile如下,其中高亮部分是新增加部分:
# 定义变量
SRCS = $(wildcard *.c) # 查找当前目录下所有 .c 文件
OBJS = $(SRCS:.c=.o) # 将 .c 文件转换为 .o 文件
DEPS = $(SRCS:.c=.d) # 将 .c 文件转换为 .d 文件
CC = gcc # 指定编译器
CFLAGS = -g -Wall -O2 # 编译选项
MFLAGS = -M -MF # 编译依赖选项
TARGET = app # 目标可执行文件名
# 目标规则
$(TARGET): $(OBJS)
$(CC) $^ -o $@ $(CFLAGS)
-include $(DEPS) # 包含依赖文件,前面的"-"表示即使没有找到包含文件也不会报错退出
# 编译规则:将 .c 文件编译为 .o 文件
%.o: %.c
$(CC) -c $< -o $@ $(CFLAGS)
# 编译规则:将 .c 文件编译为 .d 文件
%.d: %.c
$(CC) -c $< $(MFLAGS) $@
# 清理规则
clean:
rm -f $(OBJS) $(TARGET)
PHONY: clean
完成上述修改后,我们再次修改api.h文件,新增如下一个宏定义,如下高亮部分所示:
#ifndef _API_H_
#define _API_H_
#define MAX_LINE_NUM 256
#define MAX_CMD_NUM 1024
struct api_cmd_t
{
char *cmd;
int len;
};
extern int foo();
extern int bar();
#endif
再次执行make
,会发现所有.c都重新编译了,这也就是实现了增量编译功能,输出如下所示:
gcc -c bar.c -o bar.o -g -Wall -O2
gcc -c foo.c -o foo.o -g -Wall -O2
gcc -c main.c -o main.o -g -Wall -O2
gcc bar.o foo.o main.o -o app -g -Wall -O2
其他编译工具
CMake,Meson,ninja
问题思考
如果我们仅仅是修改了Makefile,比如修改了链接选项,如果让项目重新编译呢?因为编译选项影响的是所有文件,所以这里应该是完全重新编译所有文件。
参考资料