☰
ccache 实战手册:原理、配置与调优,彻底告别 C++ 编译慢
2026/10/9 15:48:36 网站建设 项目流程

如果你常年在 C++ 项目里摸爬滚打,大概率经历过这种场景:拉完最新分支,改了三行代码,点下构建,然后盯着"正在编译 xxxx.cpp"发呆,等它把依赖链上的几百个翻译单元从头过一遍。更崩溃的是,明明上周刚全量编译过,今天一 clean 又要十分钟起步。这时候 ccache 几乎是性价比最高的解药,没有之一。

ccache 是一款面向 C/C++ 编译过程的缓存工具,核心思路很简单:把一次编译的结果(目标文件)按特征存起来,下次遇到一模一样的编译请求时,直接跳过编译器,从缓存里把 .o 拖出来。它解决的问题非常具体:在不改代码、不改编译参数的前提下,重复编译是纯浪费。适合所有被 C++ 编译时长困扰的人,本地开发、CI 流水线、Docker 镜像构建,哪个场景用上都立刻有感觉。

这篇分享我打算按痛点本质、缓存原理、接入实操、配置调优、问题排查、进阶玩法六个部分来写。坑都标出来了,照着做基本一次上手。

1. 先搞清楚痛点:C++ 编译到底慢在哪

1.1 编译耗时的真正分布

一个 C++ 源文件从磁盘到 .o,编译器内部要过预处理、词法语法分析、语义分析、优化、代码生成这几大关。预处理要展开成千上万个宏和头文件,模板实例化、重载决议又是编译器的重体力活,最后优化器还得在 IR 上做变换,再翻译成目标平台的机器码。单独拿一个文件看,这些工作在几十到几百毫秒内完成,可项目一旦上千个翻译单元,叠加起来就是十分钟量级的等待。

如果你用 gcc 或 clang 分别跑一下 -E、-S、-c,把时间拆开看就会发现,预处理阶段相对没那么夸张,大头基本耗在 -O2/-O3 的优化和代码生成上。这也是为什么同一个 .cpp,Debug 版和 Release 版的编译时间能差出好几倍。理解了这一点,就明白为什么"少编译一点"比"编译得快一点"更值得去争取,ccache 走的正是这条路。

1.2 传统增量构建的盲区

很多人的第一反应是:我有 make/CMake 的增量构建啊,只重编改动过的文件不就行了?增量构建确实有用,但它本质上依赖文件时间戳,遇到三类场景就会失灵。第一,你改的是一个被大量包含的头文件,比如项目的 config.h,所有包含它的 .cpp 全要重编。第二,改的是公共头文件里的模板或内联函数定义,这种基本等于触发全量重建。第三,git 切换分支、cmake 重新 configure、make clean 之后,时间戳对比彻底失效,只能从头编。

更隐蔽的问题是,增量构建只能帮你"少编译改动的部分",管不了"同样的编译重复发生多次"。比如 CI 上每次从零拉代码构建,或者本地在两个分支之间反复横跳,同样的文件被编译了一遍又一遍,增量构建完全帮不上忙。这就是缓存工具的用武之地。

1.3 ccache 与增量构建的本质差异

ccache 不关心文件系统时间戳,它自己维护一个内容哈希驱动的缓存池。只要预处理后的源码、编译器版本、编译参数这些关键信息没变,哪怕你 clean 一百次,它都能直接从缓存里恢复目标文件。换句话说,ccache 把"编译请求"当作查询键,而不是拿"文件是否比上次新"当判断依据。这也是它能在 clean build 下依然大幅提速的根本原因。

这种设计带来一个额外好处:ccache 对构建系统是透明的。它不要求你改变代码组织方式,不要求你换构建系统,甚至不用你维护任何缓存清单,装好、配好、正常编译就行。实际项目里,第一次全量构建只是稍微慢一点,后面每次 clean build 基本都能吃到缓存红利。

2. ccache 工作原理解读:凭什么敢给你缓存结果

2.1 缓存键的构成

ccache 会对每次编译算出一个哈希作为缓存键。构成这个键的信息包括:编译器可执行文件的信息(默认取修改时间和文件大小,可以通过 compiler_check 调整)、编译器版本标识、命令行参数、当前工作目录(是否参与哈希由 hash_dir 设置控制),以及最关键的一项——预处理后的完整源码。

这里有个容易误解的点:ccache 的哈希对象不是原始 .cpp,而是预处理完、宏全部展开以后的文本。预处理相当于给源码做了一次"标准化",只要最终进入编译器的内容等价,缓存就能命中。这保证了精确性,代价是每次缓存 miss 时,ccache 要多跑一遍预处理器来算出这个键。也正因为如此,源码里出现了TIME、DATE这类随时间变化的宏,会导致缓存键天天漂移,这一点后面会专门讲。

2.2 direct mode 与 preprocessor mode

ccache 有两套查缓存流程。direct mode 是更快的那条路:第一次编译某个文件时,ccache 会记录这个编译单元的依赖清单(manifest),包含它引用过哪些头文件以及这些文件的哈希。之后再来编译同一文件,先按清单检查头文件有没有变化,没变就直接取缓存里的 .o,整个过程中连预处理器都不用跑,单次开销极小。

如果 direct mode 用不了(比如某些特殊编译参数、依赖信息不完整),ccache 会自动退到 preprocessor mode:先跑一遍 cpp,哈希预处理结果,再查缓存。这种方式命中后仍然省掉了优化和代码生成的耗时,但省不掉预处理的耗时,属于"打了折的命中"。在 ccache -s 的统计里,这两种命中有明确区分,cache hit (direct) 是大家最希望看到的数据,cache hit (preprocessed) 也算赚,只是赚得没那么彻底。

2.3 让缓存失效的因素

最直接的失效因子是编译参数变化:-O2 改 -O3、新增一个 -D 宏、调整 include 路径顺序,全部会造成 miss。其次,编译器版本或编译器二进制本身变了,也会 miss。再就是源代码文件或它依赖的头文件内容真实变化。这些属于"应该失效",ccache 不背锅。

真正让人头疼的是隐性失效。比如源码里有DATE、TIME,每次预处理结果的哈希都不同;比如某些 CMake 自动生成的 version.h 每次构建内容都变;再比如 CI 每次检出代码后文件 mtime 全部刷新。这些场景不会体现在代码 diff 上,却会让命中率肉眼可见地往下掉,应对方法我在配置调优和问题排查部分展开说明。

3. 快速上手:三种接入方式与验证方法

3.1 命令行前缀:一条命令接入

最小接入方式是把编译器换成 ccache 包装后的命令。在终端里设置环境变量,后续 make 自动就会使用:

export CC="ccache gcc" export CXX="ccache g++"

手动写脚本编译时也可以直接写成:

ccache g++ -c foo.cpp -o foo.o

这种方式适合临时验证、手工编译和小型脚本项目。有一个必须强调的点:接入要统一。如果这次用 ccache g++,下次直接用 g++,两边算出来的缓存键里编译器信息不同,缓存不会互相命中,等于白配。团队协作时,最好把 CC/CXX 的设定写进统一的构建脚本或 shell profile,不要各配各的。

3.2 CMake 项目的标准接入姿势

CMake 项目是体验最好的场景,官方提供了专门的编译器启动器字段,配置期指定即可:

cmake -B build -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_C_COMPILER_LAUNCHER=ccache

也可以在 CMakeLists.txt 里自动探测,团队共享同一套配置,新人不至于漏开:

find_program(CCACHE_PROGRAM ccache) if(CCACHE_PROGRAM AND NOT CMAKE_CXX_COMPILER_LAUNCHER) set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") endif()

有个细节必须提醒:CMAKE_CXX_COMPILER_LAUNCHER 是配置期生成的,写进 build 目录后就固化了。改了 CMakeLists.txt 或新增了这个参数,都得重新执行一次 cmake 配置,否则不会生效。我排障时见过不止一例"配置了但没重新 configure",结果 ccache 的计数一动不动,白白浪费时间排查。

3.3 Make、Autotools 与裸脚本项目

Autotools 和裸 Makefile 项目,思路和命令行前缀一样,直接在 make 命令里指定:

make CC="ccache gcc" CXX="ccache g++" -j$(nproc)

如果 Makefile 里硬编码了 CC 和 CXX,环境变量就会被覆盖,这时候需要去改 Makefile,或者在 configure 阶段把 CC/CXX 传进去。qmake 项目则可以在 qmake.conf 里把 QMAKE_CC/QMAKE_CXX 改成带 ccache 前缀的命令。总之原则只有一个:保证最终执行的编译命令以 ccache 开头,别的构建系统同理。

3.4 用 ccache -s 验证是否真的生效

接入完成后,先看一眼统计信息:

ccache -s

输出会显示缓存目录、命中/未命中次数、命中率等关键数据:

cache directory /home/dev/.ccache cache hit (direct) 10234 cache hit (preprocessed) 567 cache miss 3210 called for link 108 ... cache hit rate 77.1 %

最理想的验证流程是:先正常构建一遍(这轮会充满 miss),记下 stats,然后 make clean,再构建第二遍。如果命中率有明显提升,说明 ccache 已经在正常工作了。真实项目的二次 clean build 通常能把原来 12 分钟的编译压到 1 分半左右,具体取决于项目规模和配置。看到一个陌生环境里 ccache 是否生效,别只看有没有装,数据比直觉可靠。

4. 配置调优:把命中率从 80% 拉到 95%

4.1 三个最先该动的参数:cache_dir、max_size、compression

ccache 默认把缓存放在 ~/.ccache,上限是 5GB,超过上限后会按 LRU 清理旧条目。小项目 5GB 够用,大型项目往往不够。判断依据很简单:先让一个全量构建把缓存装满,然后看 ccache -s 里的 cache size 和 miss 次数占比。如果出现"缓存被反复清理,命中率始终上不去",就该调大上限了。

ccache --set-config=cache_dir=/data/ccache ccache --set-config=max_size=20G

我常用的经验值是:项目全量 .o 总量乘常见构建变体数量(Debug、Release、不同编译宏组合),再留 1.5 到 2 倍余量。比如一个项目全量生成 2GB 的 .o,本地要跑 Debug 和 Release 两种配置,那 6G 到 8G 是比较稳妥的起点。cache_dir 可以指向独立磁盘或更快的存储,想更激进甚至可以放到内存盘上,不过那是另一个话题了。

compression 参数控制缓存条目是否压缩。ccache 4.x 默认开启,老版本可能需要手动配置。压缩能省不少磁盘,代价是写入和读取有少量 CPU 开销,在现代机器上几乎无感,磁盘紧张的 CI 环境更要开。

4.2 sloppiness:在精确与激进之间踩平衡

sloppiness 直译是"宽松度",告诉 ccache 在哪些场景可以放宽命中标准。默认情况下 ccache 相当保守,宁可 miss 也不想给错误结果,这没错,但有些放宽是安全且收益很大的。

我最常用的是 time_macros。很多项目会把构建时间写进代码里:

const char* build_time = __DATE__ " " __TIME__;

这会直接导致预处理结果的哈希永远在变,命中率直接归零。设置 sloppiness=time_macros 之后,ccache 在计算哈希时会忽略这几个时间宏,命中率立刻回升。安全前提是你不真的依赖编译时刻作为功能逻辑,绝大多数项目都是如此。

剩下还有一些可选项,比如 file_stat_matches、include_file_mtime、pch_defines,各自对应不同的"睁一只眼闭一只眼"策略,具体语义要看官方文档。我的建议是:先用默认配置跑一段时间,观察 stats 里的 miss 原因,只有明确知道是哪种因素在捣乱,再去开对应的 sloppiness,不要一股脑全开,否则缓存命中了也心里没底。

4.3 base_dir:让缓存不挑路径

如果同一个项目在不同路径下构建,比如不同的开发机、CI 里不同的 checkout 目录、Docker 里每次目录都不同,编译命令行里往往带着绝对路径。这些路径进了缓存键,两份内容完全相同的编译就会互相命中不了。

base_dir 解决的就是这个问题:设置一个公共根目录,ccache 会把该目录下的绝对路径改写成相对路径参与哈希,让缓存可以跨目录共享。

ccache --set-config=base_dir=/home/runner/work

CI 场景尤其推荐在项目根目录的上一级设置 base_dir。配合 CMake 的 COMPILER_LAUNCHER,GitHub Actions、Jenkins 上的缓存命中率能明显提升。注意 base_dir 别设太大,只覆盖会真实出现在编译路径里的公共前缀就够了。

4.4 compiler_check:要不要信编译器二进制

默认情况下,ccache 对编译器的校验方式是看二进制文件的修改时间和大小,正常环境够用。但有一种情况会出问题:编译器在同一个路径下被反复升级,比如 dev 容器里重装 gcc,mtime 变了内容也变了,缓存大量 miss;反过来如果 mtime 没变但内容变了,极端情况下可能拿到过期的编译结果。

对安全敏感、且编译器体积不算离谱的项目,可以改成按内容校验:

ccache --set-config=compiler_check=content

代价是每次编译前要读取一遍编译器二进制并计算哈希,增加一点固定开销。本地开发机我用默认的 mtime,CI 流水线用 content 心里更踏实。编译器很老不肯换、或者交叉工具链特别大的团队,谨慎评估这个开销。

5. 常见问题与排查技巧实录

5.1 命中率为何突然归零

命中率骤降八成以上是"编译请求本身变了"。最常见的三个来源:编译参数变了、编译器变了、目录变了。排查顺序先看 ccache -s 里 miss 前后的统计变化,再检查最近有没有调整 CMake 配置、升级编译器、改动 include 路径。这类问题通常不是 ccache 坏了,而是构建系统换了烹饪方式。

还有一个容易被忽略的:多个构建系统共享同一个缓存目录,但编译参数风格不一样。比如一个项目用 -O2,另一个用 -O2 -g,哈希天然不同,混在一个目录里统计时命中率会被互相稀释,数字很难看。解决办法是给不同项目指定独立缓存目录,或者接受这种"共享存储但不共享命中"的正常状态。

5.2 时间宏与生成文件的连环坑

时间宏前面已经讲过,这里强调排查手法。如果你怀疑某个文件永远 miss,先用 CCACHE_LOGFILE 定位 miss reason。很多时候你会发现不是时间宏,而是某个生成文件每次构建都在变。典型嫌疑犯包括 version.h、git_commit.h、CMake 生成的配置头。这些文件内容一变,所有包含它的 .cpp 跟着失效,杀伤力极大。

对这类文件,最干净的解法是把变化的内容从源码可见范围里摘出去。比如把 commit id 写进一个单独的 .txt,运行时读取,而不是编进头文件。如果实在没法改工程结构,再考虑针对性地用 sloppiness 放宽校验。千万不要用禁用 ccache 的方式来绕过,那是因噎废食。

5.3 多编译器多架构混用如何隔离

本机装了 GCC、Clang,又时不时交叉编译,三种编译器的产物混在同一个缓存目录里不会串,因为缓存键包含编译器信息。但统计数字会互相干扰,缓存体积也会膨胀得快。建议用 CCACHE_DIR 隔离:

export CCACHE_DIR=~/.ccache-gcc # GCC 专用 export CCACHE_DIR=~/.ccache-clang # Clang 专用

交叉编译尤其如此,不同 target 的编译产物互不相通,隔离之后每个缓存的命中率才有参考价值。类似的,如果团队里有人用 ccache 3,有人用 ccache 4,两者的缓存格式不同,最好也分开目录,避免互相洗掉对方的缓存。

5.4 Windows 与 MSVC 下的特殊注意点

ccache 在 Linux 生态里最常见,但新版本已经可以支持 MSVC 和 clang-cl。Windows 上接入思路类似,关键点在于 PATH 顺序:确保 ccache 包装后的编译器路径排在真正的 cl.exe 之前,或者显式在编译命令里写 ccache cl。另外 Windows 对文件锁和分区的处理方式不同,缓存目录尽量放到 NTFS 本地盘,别放在某些网络映射盘上,否则并发访问的锁开销可能冲掉收益。

Visual Studio 的 MSBuild 项目接入 ccache 相对麻烦,社区里通常先转用 CMake + Ninja,再走 COMPILER_LAUNCHER 这条路。我实测下来这是 Windows 上最顺的接法,既能让 ccache 生效,又能吃到 Ninja 的并行构建。

5.5 终极排查手段:CCACHE_LOGFILE

当查不出原因时,打开详细日志是最直接的。设置环境变量 CCACHE_LOGFILE 指向一个文件,ccache 会把每次编译请求的 key、命中结果、miss reason 全部写进去:

export CCACHE_LOGFILE=/tmp/ccache.log

构建完翻日志,会看到类似 unsupported compiler option、hash mismatch 这类具体原因。实际排障时,光靠这招就能解决大半疑难杂症。日志文件记得及时清理或按天轮转,它体积增长得很快。

这里把最常踩的坑整理成一张速查表,方便以后直接对着找:

症状最常见原因处理建议
命中率突然归零编译参数、编译器、工作目录变化核对 CMake 配置、编译器升级、base_dir 设置
某个 .cpp 永远 miss源码含DATE/TIME,或生成头文件每次变化开 time_macros,或把变化内容移出头文件
日志提示 unsupported ...编译器或参数不被 ccache 支持查官方支持列表,改用更规范的编译参数
多项目共享目录命中率低各项目编译参数差异大用 CCACHE_DIR 隔离独立缓存
Docker 里缓存总失效每层文件拷贝导致 mtime 变化用 BuildKit 的 --mount=type=cache

6. 进阶玩法:CI、Docker 与团队级缓存

6.1 CI 里如何正确持久化 ccache

CI 最大的问题是环境每次都是全新的,本地缓存带不进去。GitHub Actions 里可以用官方缓存 action,把 ~/.ccache 整个目录存起来:

- uses: actions/cache@v4 with: path: ~/.ccache key: ccache-${{ runner.os }}-${{ hashFiles('**/CMakeLists.txt') }}

缓存 key 的策略决定命中效率。如果只按分支来,代码改动后大量缓存失效;如果完全按 commit 来,每次提交都存一份新缓存,目录会失控。我习惯的做法是:key 用配置文件和编译器版本相关的哈希,restore-keys 再回退到最近成功的一次缓存。这样既能吃到历史缓存,又不会在同一 key 下堆积过期数据。任务收尾时建议显式跑一次 ccache -s 确认统计落盘,别让构建进程被强制杀掉。

6.2 Docker 构建中如何让缓存生效

Docker 构建最怕的是每层文件拷贝后 mtime 变化,导致 ccache 大面积失效。解决办法是构建时把 ccache 缓存目录挂为 BuildKit 的缓存挂载,而不是 COPY 进镜像层:

RUN --mount=type=cache,target=/root/.ccache \ cmake --build build -j8

--mount=type=cache 的目录不会进入最终镜像,但会在宿主机的构建缓存里持久保留,多阶段构建之间可以复用,也不受 Docker layer 的 COPY 语义影响。注意不同构建机上这种缓存默认不共享,需要配合远程缓存方案。

6.3 团队共享缓存与 sccache 怎么选

如果团队想跨机器共享编译缓存,本地单一 ccache 目录并不擅长。老办法是挂 NFS 共享 ~/.ccache,能用但锁开销和网络延迟会拉低体验。新版本 ccache 提供了 remote_storage 配置,实测用 Redis 后端挺稳,一行配置就能把缓存中心化:

ccache --set-config=remote_storage="redis://192.168.1.10:6379"

本地命中本地,没命中时再和远程存储交换数据,效果接近分布式缓存。但远程存储引入了网络 I/O,小文件的读写不一定划算,配置前最好先用 CI 统计一次 miss 率和对象大小分布。

如果团队对远程缓存的需求更重,比如多语言、原生支持 S3/GCS/Redis,可以看看 Mozilla 主导的 sccache。两者定位不完全重合:

维度ccachesccache
主打场景单机高性能编译缓存跨机器分布式缓存
语言支持C/C++/Objective-C 为主C/C++、Rust 等多语言
远程存储新版本支持 Redis 等原生支持 S3/GCS/Redis
本地命中率优化成熟度高、选项丰富相对一般
上手难度低中

我的倾向是:小型团队从 ccache 起步,先把本地和 CI 单机的速度提上来,成本最低;等确实需要多机共享、且构建产物管线复杂了,再评估 sccache 也不迟。

最后聊一点个人体会。我在一个几百万行规模的项目里引入 ccache 之后,最直观的变化不是某个单次构建快了多少,而是整个开发节奏变了:以前 full build 之后不敢轻易 clean,现在想 clean 就 clean,编译时间从 12 分钟掉到 1 分半左右。养成的好习惯是每次换机器、换目录、升级编译器后,先跑一次 ccache -s 确认命中率基线,再做下一步。ccache 这东西没什么玄学,原理清楚、配置得当,它就是你本地和 CI 上最靠谱的编译加速器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询