理解Make增量编译的实现原理

理解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,比如修改了链接选项,如果让项目重新编译呢?因为编译选项影响的是所有文件,所以这里应该是完全重新编译所有文件。

参考资料

  1. https://blog.csdn.net/weixin_39450742/article/details/119180695
  2. https://blog.csdn.net/m0_49476241/article/details/130892502

Comments are closed.