26、构建系统优化:增量编译原理、并行编译(make -j)、ccache加速

说实话,很多嵌入式工程师对构建系统的理解,还停留在「能编译就行」的阶段。项目小的时候还好,一旦代码量上了几十万行,每次改一行代码都要等五分钟编译,那种感觉……嗯,我懂。

这一章,我们就来聊聊怎么让构建系统跑得更快。核心就三件事:增量编译并行编译ccache 缓存。这三板斧用好了,编译时间能缩短 80% 以上。

核心观点:构建优化的本质,是「只做必要的事,并且同时做」。增量编译解决「做什么」,并行编译解决「怎么做」,ccache 解决「能不能不做」。

一、增量编译原理:为什么改一行代码要重编整个工程?

先问一个问题:你改了一个 .c 文件里的一个函数,make 是怎么知道哪些文件需要重新编译的?

答案很简单——比较时间戳

Makefile 的核心逻辑是:如果目标文件(.o)比依赖文件(.c/.h)旧,就重新生成目标。这就是增量编译的根基。

举个例子:

main.o: main.c config.h
    gcc -c main.c -o main.o

如果 config.h 被修改了,它的时间戳比 main.o 新,make 就会重新编译 main.c。但如果 main.c 没变,config.h 也没变,main.o 就直接拿来用。

听起来很简单对吧?但我在项目中遇到过一个大坑——头文件依赖没写全

我曾经踩过的坑:有个项目,所有 .c 文件都依赖一个全局的 global_config.h。但 Makefile 里只写了 main.c 依赖它,其他文件没写。结果改了一次配置,编译出来的固件里有的模块是新配置,有的还是旧的。排查了整整两天……从那以后,我强制团队用 gcc -MM 自动生成依赖。

自动生成依赖的方法很简单:

%.d: %.c
    @gcc -MM $< > $@

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

这样每个 .c 文件都会生成一个 .d 文件,里面记录了它真正依赖的所有头文件。make 会自动读取这些 .d 文件,增量编译就准确了。

二、并行编译:make -j 到底开多少个线程?

增量编译解决了「少干活」的问题。并行编译解决的是「干活快」的问题。

make 的 -j 参数就是干这个的:

make -j4   # 同时跑4个编译任务

但问题来了——-j 后面跟多少合适?

我个人习惯用这个公式:

-jN,其中 N = CPU核心数 × 2

比如 4 核 CPU,我就用 -j8。为什么是 2 倍?因为编译过程是 CPU 密集型和 I/O 密集型混合的。当某个任务在等磁盘读写时,另一个任务可以占用 CPU。

CPU 核心数 推荐 -j 值 适用场景
2 4 低端开发板、虚拟机
4 8 主流笔记本
8 16 高性能工作站
16 32 服务器级编译集群

小技巧:在 CMake 中,可以用 cmake --build . -j $(nproc) 自动获取 CPU 核心数。nproc 是 Linux 命令,直接返回核心数量。

但要注意——并行编译不是越多越好。我曾经试过在 4 核机器上开 -j32,结果系统直接卡死,鼠标都动不了。因为编译任务把内存吃光了,系统开始疯狂 swap。

所以我的建议是:先试 -j4,如果 CPU 利用率不到 80%,再往上加。别一上来就开满。

三、ccache:让重复编译变成「秒过」

增量编译有个局限——它只认文件时间戳。如果你切换了分支,或者改了编译选项,所有文件的时间戳都变了,make 会认为所有文件都需要重新编译。

但仔细想想:很多文件的内容根本没变啊!

这就是 ccache 的用武之地。它像一个智能缓存:对每个编译任务,计算输入文件 + 编译选项的哈希值,如果哈希值没变,直接返回之前编译好的 .o 文件

安装和使用非常简单:

# 安装
sudo apt install ccache

# 使用(两种方式)
# 方式一:直接调用
ccache gcc -c main.c -o main.o

# 方式二:软链接(推荐)
sudo ln -s /usr/bin/ccache /usr/local/bin/gcc
sudo ln -s /usr/bin/ccache /usr/local/bin/g++

# 查看统计
ccache -s

在 CMake 中集成更简单:

# 在 CMakeLists.txt 开头加上
find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
    set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}")
endif()

或者直接用环境变量:

export CCACHE_DIR=/path/to/cache
cmake -DCMAKE_C_COMPILER_LAUNCHER=ccache ..

实际效果:我在一个 50 万行代码的嵌入式项目中启用 ccache 后,第二次编译(全量)从 12 分钟降到了 40 秒。第一次编译因为要填充缓存,速度反而会慢一点,但第二次开始就起飞了。

不过 ccache 也有几个坑:

  • 调试信息影响缓存命中:-g 选项会生成不同的调试信息,导致哈希值变化。建议调试和发布用不同的缓存目录。
  • 预处理器宏:如果代码里大量使用 #ifdef,不同配置下的编译结果差异很大,缓存命中率会下降。
  • 缓存大小:默认缓存只有 5GB,对于大型项目可能不够。用 ccache -M 20G 设置大一点。

四、三者协同:一个完整的优化方案

这三者不是孤立的。我一般这样组合使用:

  1. 增量编译作为基础,确保每次只编译必要的文件。
  2. 并行编译加速每次编译过程。
  3. ccache作为兜底,处理分支切换、配置变更等场景。

一个典型的 Makefile 优化版:

CC = ccache gcc
CFLAGS = -Wall -O2 -MMD

SRCS = $(wildcard *.c)
OBJS = $(SRCS:.c=.o)
DEPS = $(OBJS:.o=.d)

.PHONY: all clean

all: firmware.elf

firmware.elf: $(OBJS)
    $(CC) $^ -o $@

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

-include $(DEPS)

clean:
    rm -f *.o *.d firmware.elf

编译时直接:

make -j$(nproc)

这样一套组合拳下来,大部分项目的编译时间都能控制在 1 分钟以内。

我的个人习惯:在 CI/CD 流水线里,我会把 ccache 的缓存目录挂载为持久化卷。这样每次构建都能复用之前的缓存,效果特别好。另外,我还会定期用 ccache --cleanup 清理过期缓存,避免磁盘空间被撑爆。

五、知识体系总览

下面这张图总结了本章的核心逻辑:

构建系统优化三大核心 构建优化 增量编译 并行编译 ccache 缓存 时间戳比较 自动依赖生成 -jN 参数调优 CPU/内存平衡 哈希值缓存 缓存命中率 三者协同:编译时间缩短 80%+

你看,其实优化思路并不复杂。核心就是:能不编译就不编译,必须编译就并行编译,编译结果能复用就复用

嗯,这一章的内容就到这里。记住,构建系统优化不是一次性的工作,而是需要持续关注和调整的。每次项目规模增长,都值得重新审视你的构建策略。


公众号:蓝海资料掘金营,微信deep3321