理解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就可以生成可执行文件foo,看似也用不到Makefile。但是如果使用Makefile, 我们可以实现更加复杂的编译控制,比如增量编译功能,具体请往下看。

编译过程

让我们用一个Makefile来完成上述编译工作。具体是:修改文件foo.c,增加文件bar.c和main.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, 就可以完成上述编译工作了。

$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告警,原因是我们隐式声明了函数foo和bar。一种解决方法是使用extern显示引用一下,另一种是定义一个头文件包含这些函数原型,这样所有源文件直接引用头文件即可。第一种方法修改简单,但是当函数多的时候会有大量的extern引用,且每个用到的源文件都需要引用一次,不利于保持引用和原型的一致。因此我们采用第二种方法修改,增加头文件api.h,其源码如下:

#ifndef _API_H_
#define _API_H_

extern int foo();
extern int bar();

#endif

完成后修改所有源文件foo.c, bar.c和main.c都包含api.h,执行make clean清除上一次的编译结果,然后再make编译,输出就没有warning了,具体输出如下:

$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
gcc  bar.o foo.o main.o -o app -g -Wall -O2

增量编译

在上面编译完成的基础上,如果我们再重新make编译一遍,会发现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编译一下,输出如下:

$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
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 
...此处省略大量标准库文件列表
 /usr/include/x86_64-linux-gnu/bits/struct_mutex.h \
 /usr/include/x86_64-linux-gnu/bits/struct_rwlock.h /usr/include/alloca.h \
 /usr/include/x86_64-linux-gnu/bits/stdlib-float.h api.h

这样,我们在Makefile中包含main.d文件的时候,如果main.c依赖的文件有更新,比如api.h,则会重新编译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) $(DEPS) $(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都重新编译了,这也就是实现了增量编译功能,输出如下所示:

$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
gcc  bar.o foo.o main.o -o app -g -Wall -O2

注意依赖文件的后缀并不一定是.d,也可能是.dep,或者其他,比如开源软件strongswan生成的依赖文件后缀是.Po。

其他编译工具

CMake,Meson,ninja,这是一些比较新的构建工具。

CMake可以根据 CMakeLists文件生成各种编译需要的文件,比如Makefile, 比如vcxproj文件,然后再调用对应的工具进行编译。Wireshark在Windows下就是使用CMake进行工程文件vcxproj的生成,具体看这里

Meson 是一个由 Python 实现的开源项目,其思想是,开发人员花费在构建调试上的每一秒都是浪费,同样等待构建过程直到真正开始编译都是不值得的。

Ninja 是一个轻量的构建系统,主要关注构建的速度。它与其他构建系统的区别主要在于两个方面:一是 Ninja 被设计成需要一个输入文件的形式,这个输入文件则由高级别的构建系统生成;二是 Ninja 被设计成尽可能快速执行构建的工具。

项目开发中一般将 Meson 和 Ninja 配合使用,Meson 负责构建项目依赖关系,Ninja 负责编译代码。DPDK使用meason和ninja进行编译构建,具体看这里

问题思考

如果我们仅仅是修改了Makefile,比如修改了链接选项,如果让项目重新编译呢?因为编译选项影响的是所有文件,所以这里应该是完全重新编译所有文件。

参考资料

  1. https://blog.csdn.net/weixin_39450742/article/details/119180695
  2. https://blog.csdn.net/m0_49476241/article/details/130892502
  3. https://www.cnblogs.com/RioTian/p/17984286

Comments are closed.