
下午三点我把某个共享头文件里一个枚举的注释格式调整了一下顺手加了一个字段。按理说这只是“改了个头文件”的小操作但项目里大约有两百个 cpp 依赖这个头文件Ninja 很快就规划出一条长长的重建链。我盯着终端里的进度条等了六分多钟期间又回复了工作群里七八条消息。当天晚上我把 ccache 装到机器上重新构建这条链路热缓存状态下只花了四十秒左右。这篇文章就是把那天摸索 ccache 的完整过程、以及后来在不同项目里反复调优的经验整理出来它解决的是“重复编译同一份代码”的纯浪费问题和增量编译不冲突和 distcc 也不冲突适合单个 cpp 文件很大、公共头文件很重、或者经常切换分支和构建配置的 C 工程。1. 编译慢的根源与 ccache 的提速逻辑1.1 从预处理到目标文件编译器的“重复劳动”先把 C 编译过程打散看。一条g -c foo.cpp -o foo.o命令内部至少干了四件事预处理、词法语法分析、生成中间表示并做优化、最后生成汇编并汇编成目标文件。预处理阶段会把#include的头文件内容原封不动拉进来。我维护的那个渲染引擎主模块里的公共头文件经过层层展开常能膨胀到二十万到三十万行代码。模板还没算进去C 的模板在编译期实例化每多一种参数组合就相当于多编译一部分新代码。这也是 C 项目相比 C 项目更慢的原因之一C 的头文件展开后就是普通声明而 C 的模板头文件展开后是一整片待实例化的逻辑。Problem的关键在于当你只改了某个公共头文件里一个枚举定义时所有 include 了它的 cpp 文件哪怕实际用到的只有其中三行也都会重新走完上述全流程。增量构建工具Make、Ninja、CMake 生成的目标只负责判断“哪些文件需要重建”并不负责“让重建本身变快”。于是你每次都眼睁睁看着几十上百个 cpp 排着队重新预处理、重新解析、重新优化而其中绝大多数代码在几分钟前刚编译过一模一样的内容。1.2 ccache 的缓存本质哈希后跳过整段编译ccache 的思路非常直接既然同一份预处理源码、同一套编译选项、同一个编译器理论上会生成相同的目标文件那我第一次编译之后把目标文件存起来下次再碰到完全相同的输入直接拷贝结果、跳过编译不就行了它实现这套逻辑的方式是哈希键。每次编译前ccache 会计算一个键这个键由几部分组成预处理后的源码内容、出现在源码中的头文件内容、编译选项、编译器二进制本身的身份信息。只要键命中它就把缓存目录里对应的.o文件拷贝出来交给你编译器本体根本不需要真正执行。这里有个关键细节ccache 不是按“文件修改时间”来判断的。你 touch 了一个头文件但内容没变ccache 依然能命中反过来你改了头文件内容哪怕把 mtime 伪造成旧时间它也会察觉内容变化并触发重编译。它维护的是“内容哈希 依赖清单”manifest而不是纯粹的文件系统时间戳。理解这一点很重要因为它决定了很多调优选项的走向。ccache 实际存在两种命中模式direct 模式先通过源码内容和 manifest 做快速匹配不经过预处理器当 manifest 缺失或不确定时退回 preprocessed 模式真正执行一次预处理后再哈希比对。direct 命中更快preprocessed 命中稍慢但依然比完整编译快很多。默认配置下两个模式都开着日常使用感受不到区别只有在ccache --show-stats里能看到两类命中计数。1.3 与增量编译、distcc 的区别它们不冲突不少人以为有了 ccache 就能替代增量构建或者觉得它和 distcc 是竞争关系其实完全不是一回事。增量构建判断“文件是否变了”变了才重新编译。它解决的是“避免编译没改的文件”这个问题。ccache就算文件被重新纳入了编译列表只要内容层面没有任何有效变化也能直接复用之前的编译结果。它解决的是“同一个文件被反复编译”的浪费。distcc把预处理后的源码分发到局域网内其他机器上并行编译。它解决的是“单机 CPU 核数有限”的问题。三者可以叠加。比如 Ninja 先排除没变化的文件剩下要重建的 cpp 交给 ccache 查缓存命中不了再由 distcc 派发给远端编译器。很多大型项目就是这么干的。我后面第 4 节会展开组合用法这里先建立概念坐标系。2. 从安装到首跑环境准备与一套顺手配置2.1 三种平台的安装方式与选择ccache 的安装几乎没有什么门槛Linux 发行版仓库里基本都有# Debian / Ubuntu sudo apt-get install ccache # RHEL / CentOS / Fedora sudo yum install ccache # 或 sudo dnf install ccachemacOS 上用 Homebrewbrew install ccacheWindows 稍微特殊一点。如果你用 MinGW-w64、Clang、MSYS2 这套工具链ccache 可以直接从官方 GitHub Releases 页下载预编译的 exe放到 PATH 里就行用 scoop 的也可以一行装好scoop install ccache如果你主力是 Visual Studio 的 MSVCcl.execcache 从 3.7 开始就声明支持 MSVC但实际限制不少比如对某些编译选项兼容性一般碰到/analyze、/await这类不常见开关时需要先跑小实验验证。我的建议是Windows 下用 MinGW 或嵌入式交叉工具链比如 ESP32 的 xtensa-esp32-elf-gcc时放心上 ccache纯 MSVC 的组项目优先考虑微软自带的一些加速方案和/MP。装完认一下版本ccache --version看到版本号输出后建议顺手验证一下缓存目录是否可写。Linux/macOS 默认目录是~/.cache/ccacheWindows 是%USERPROFILE%\AppData\Local\ccache。如果后面发现根本不缓存第一反应就查这个路径的权限和磁盘剩余空间。2.2 接入现有工程的三条路径接入方式有三条主流路径侵入性从低到高排列。路径一CMake 官方支持的编译器启动器机制最推荐。在配置项目时指定cmake -B build -G Ninja -DCMAKE_CXX_COMPILER_LAUNCHERccache .. cmake --build build也可以在 CMakeLists.txt 里直接写set(CMAKE_CXX_COMPILER_LAUNCHER ccache)这个启动器机制不会把 ccache 写进编译器的实际路径只是让 CMake 生成构建规则时在编译器前面套一层。它和 Makefile、Ninja 生成器都能配合对源码零侵入。大型项目的 CI 脚本里经常用前一种命令行传入方式方便统一开关。路径二直接包在编译器变量里。Makefile 工程和 autotools 工程经常这么做CXX ccache g或者在配置 autotools 项目时./configure CCccache gcc CXXccache g这个方式简单粗暴但要注意configure 阶段本身也会执行编译器探测有些构建脚本会解析“编译器名”来做判断倘若包成ccache gcc后脚本拿不到预期输出就得调整写法。路径三符号链接接管编译器老派做法。以前很多教程让你把系统里的 g 软链到 ccacheln -s /usr/bin/ccache /usr/local/bin/gccache 会通过argv[0]识别自己“以什么名字被调用”代替真正的编译器。但现在我不推荐在新项目里这么搞原因我会在第 5 节展开现代构建系统对编译器身份的判断很敏感软链接容易引出各种奇怪问题。第一次接入后先用命令行手动验证一遍缓存是否生效ccache g -stdc17 -c main.cpp -o main.o ccache --show-stats第二次执行同样的命令后如果 stats 里的 cache hit 增长、编译时间明显缩短就说明已经接管成功。2.3 第一次缓存报告怎么看ccache --show-stats输出的几个关键字段值得熟记cache hit (direct) 921 cache hit (preprocessed) 47 cache miss 124 files in cache 21034 cache size 892.3 MB max cache size 15.0 GBcache hit (direct)最高效的命中不需要预处理直接命中。cache hit (preprocessed)经过预处理后命中比 direct 慢一些但已经越过真正的编译阶段。cache miss缓存未命中需要完整编译。files in cache和cache size缓存目录里的文件数和占用空间。max cache size缓存上限默认是 5GB不同版本略有差异。我一般只看命中率(direct preprocessed) / (三者的总和)。命中率低于 50% 说明项目里动态变量太多配置需要调整达到 90% 以上编译体感会非常爽。之后的调优基本就是围绕这个数字做文章。3. 提升缓存命中率的调优实践3.1 CCACHE_BASEDIR解决绝对路径带来的缓存抖动很多人装了 ccache 之后发现命中率不如预期最普遍的原因就是路径参与了哈希计算。默认情况下编译命令行里如果带了绝对路径或者源文件通过绝对路径被引用ccache 会把路径写进哈希键。这意味着同一份代码你在/home/user/project下编一次clone 到/home/user/temp/project再编一次缓存大概率不命中。甚至 git worktree、CI 的随机工作目录都会让缓存形同虚设。解决方式是用 base_dir 告诉 ccache“把项目根目录下的绝对路径当相对路径处理”# 修改 ~/.cache/ccache/ccache.conf base_dir /home/user/project这样一来只要编译时的源文件路径都在这个根目录里它们参与哈希计算的相对部分就是稳定的。多台机器、多个工作目录的开发组这个配置几乎能直接解决路径导致的缓存失效问题。需要说明的是旧版本 ccache 里的hash_dir false选项职责和 base_dir 不同新版已不建议再用直接配 base_dir 更准确。3.2 编译器校验策略mtime、内容还是版本号ccache 需要确认“这个编译器是不是上次产生缓存的那个编译器”。默认策略是检查编译器可执行文件的 mtime 和大小也就是compiler_check mtime。mtime 校验在大多数场景下没问题但有一个隐患如果你通过包管理器升级了编译器而新版本恰好保持了近似的文件大小这种情况不常见但确实发生在我同事的机器上ccache 可能误判为同一个编译器然后复用旧的缓存结果造成误命中。所以我一直建议把这个选项改成 contentcompiler_check contentcontent 模式会对编译器二进制做内容哈希。代价是每次首次遇到该编译器时多花几十毫秒做校验但换来的是“编译器内容变了缓存一定全部失效”的强保证。分布式团队里不同机器上编译器路径不同、但内容一致时content 模式也能实现跨机器缓存复用。3.3 sloppiness 的取舍时间宏与头文件时间戳C/C 的__DATE__和__TIME__宏是缓存的天然敌人。项目构建脚本里假如有-DBUILD_TIME$(date)这类注入每次编译输入的宏值都不一样缓存必然失手。ccache 里有一个sloppiness选项字面意思是“放宽校验标准”可以接纳这类无害差异sloppiness time_macros设置后包含__DATE__、__TIME__差异的输入不会破坏缓存命中。代价是如果代码逻辑真的依赖这几个宏的精确值比如用于展示构建时间戳你会拿到旧值。我一般在发布版本里接受这个副作用——构建时间戳本来也会被版本号覆盖。另外很多项目启用预编译头文件PCH后ccache 对#define的处理会变得保守需要追加pch_definessloppiness time_macros,pch_defines挖坑提示sloppiness 是“妥协”不是“特调”开的选项越多缓存命中越宽松但同时越可能掩盖真实变更。建议只加确实必要的项。3.4 与 PCH、Unity Build 等加速手段的适配PCH 和 ccache 不是天然兼容。ccache 老版本对 PCH 的缓存支持不完整升级到 4.x 之后配合上述pch_defines才能稳定工作。如果你在 CMake 里用了target_precompile_headers又同时启用 ccache我建议先在 CI 里跑一轮全量验证确认命中结果和基线产物完全一致再放心铺开。Unity Build把多个 cpp 合并成一个大 cpp 编译同样可以和 ccache 共存。合并后单个编译单元变大首次编译会更慢但之后的缓存命中粒度也变大——只要合并后的文件中任何一部分源码变动整个大编译单元都会失效。所以 Unity Build 适合变化少、稳定模块多的项目和 ccache 配合时收益会比较明显。4. 真实项目接入案例与构建系统组合用法4.1 嵌入式与开源软件源码编译场景先说嵌入式开发很多用 ESP-IDF 的朋友反馈 Windows 下编译 ESP32 工程速度慢。ESP-IDF 底层用的都是 xtensa 和 riscv 的 GCC 交叉编译器这些工具链和 ccache 的适配度很高。我在 ESP-IDF 环境变量里加上export CCACHE_ENABLE1之后反复修改组件内头文件做验证重建时间从两三分钟降到几十秒。注意 ESP-IDF 的构建系统升级过多个版本较新版本直接用环境变量即可老版本则要在 menuconfig 里勾选相关的 “Use ccache” 选项。再说开源软件的源码编译。比如 Ubuntu 下源码编译 PostgreSQL很多人都踩过“改一行代码重编整个项目”的坑。autotools 工程可以在 configure 时包上 ccache./configure CCccache gcc CXXccache g之后 make 阶段就会自动经过 ccache。对于依赖繁多的 C 库比如 cpprestsdk、MessagePack 等在本地编源码做二次开发时这个技巧同样适用避免每次 clean build 都等十几分钟。4.2 中型渲染引擎的 Before/After 对比我拿手里一个约 300 个 cpp 的中型渲染引擎做了一次比较完整的对照。这个项目的特征是公共头文件很重模板和 STL 使用密集任何头文件改动都会波及其中的大部分 cpp。先做冷缓存全量构建记录真实耗时清空缓存后再构建并采集统计数据。整理出的对照如下场景处理方式耗时cache miss冷缓存全量构建不启用 ccache约 6 分 20 秒/冷缓存全量构建启用 ccache约 6 分 35 秒少了约 3%全部改公共头文件后重编不启用 ccache约 5 分 10 秒/改公共头文件后重编启用 ccache约 55 秒少量磁盘缓存体积15GB 上限下约 900MB/我的实测结论是不影响任何源文件的全量构建ccache 几乎不产生额外负担真正让收益起飞的是“反复改头文件 频繁切换分支”的工作流。这个场景下缓存把重复劳动裁掉了绝大部分重建耗时从五分钟级别压到一分钟以内而且数字稳定。4.3 ccache distcc Ninja 大型工程的组合策略大型工程的通用组合是 Ninja 做并发任务调度ccache 做本地缓存distcc 做远端并行。三者的分工在前面已经说过。实际配置时要注意一点不要让 distcc 成为缓存查询的前置环节。ccache 优先查本地缓存命中不了再调 distcc 分发这样才能避免网络传输那些完全可以跳过的编译任务。一种常见配置是用CCACHE_PREFIX让 ccache 在未命中时自动调用 distccexport CCACHE_PREFIXdistcc export DISTCC_HOSTShost1 host2 host3这样 ccache 成了编译命令的唯一入口命中时本地直接返回未命中时自动把预处理后的任务抛弃给远端。加机器后不需要改编译规则缓存和分布式并行同时生效整体吞吐提升最明显。Ninja 在这里的作用是任务编排和并行度控制。-j参数要结合 distcc 的远端核数来定不能只盯着本机 CPU。我见过有人把-j设成本机核数的两倍本地 cache miss 并发瞬间打满反而拖垮了 distcc 通道。4.4 CI/CD 流水线的缓存持久化CI 是 ccache 收益最大的场景之一前提是缓存能跨 job 持久化。GitHub Actions 里可以直接用官方缓存动作- uses: actions/cachev4 with: path: ~/.cache/ccache key: ccache-${{ runner.os }}-${{ matrix.cxx }}-${{ github.ref }}GitLab CI 里有 cache 字段cache: key: $CI_JOB_NAME paths: - .ccache/使用时记得在构建脚本里显式指定CCACHE_DIR到项目工作区下的路径比如.ccache/否则默认路径很容易被 CI 清理机制扫掉。缓存恢复后跑一次编译看命中率和耗时变化通常能省掉 60% 到 90% 的构建时间。第一次跑时没有热缓存需要忍耐一次全量构建。5. 那些年 ccache 踩过的坑与维护手册5.1 动态宏与编译器升级导致的“误命中”风险误命中是最危险的坑因为编译结果看起来正常但语义是过期的。先说动态宏。我遇到过一个构建脚本每次编译前执行-DSNAPSHOT_VERSION\$(date %Y%m%d%H%M)\结果缓存命中率几乎为零因为每一秒的宏值都不同。这类“随时间变化但与代码逻辑无关”的宏应该放到sloppiness time_macros或干脆从编译参数里移除。如果你的编译参数里塞了 git commit 短哈希、时长变量、随机数先想清楚它们是否真的会被代码使用再决定是否保留在哈希键里。再说编译器升级。ccache 默认的compiler_check mtime理论上存在旧缓存误复用的场景。我把compiler_check设成content之后编译器内容一变缓存立即全量失效。这个“全量失效”看起来浪费其实是安全兜底——宁可重新编译所有文件也不能在编译器语义发生变化的关口继续用旧产物。5.2 分支切换与缓存膨胀控制上限与清理策略频繁切换 git 分支是缓存体积失控的温床。每个分支、每个构建类型都有不同的哈希键集切回来的时候命中了老缓存但缓存目录里也堆了大量不再使用的中间结果。ccache 自己会做 LRU 清理但默认 5GB 上限对大型 C 工程来说可能根本不够很快进入“边编边清”的低效循环。更好的办法是手动给上限设大一点ccache --set-config max_size20G或者直接在 ccache.conf 里写max_size 20G。磁盘空间足够时我把这个值设到 20GB 以上实际占用大概在 1GB 到 3GB完全可控。清理命令要区分ccache --clear清空全部缓存适用于缓存状态混乱时的暴力重置。ccache --cleanup只清理过期和多余条目保留有效缓存日常维护用它就够了。5.3 符号链接方案的现代局限开头说的软链接接管编译器方式在单机小型项目里依然有效但在现代构建系统里埋着雷。原因很简单ccache 替代编译器后构建系统通过g --version拿到的输出是 ccache 的模拟结果有些脚本并不能正确识别。我遇到过 VSCode 的 C/C 扩展根据编译器版本匹配 IntelliSense 模式因为输出格式差异导致代码提示直接失效也见过 CMake 的编译器检查在链接阶段绕过了启动器造成“缓存了一个目标文件、但另一处却调用了真实编译器”的混乱局面。所以我的建议很明确新项目一律使用 CMake 的CMAKE_CXX_COMPILER_LAUNCHER老项目至少也用CXX ccache g这类显式方式。符号链接方案留给“实在改不动构建脚本”的历史项目。5.4 缓存目录放网络盘并发与同步的取舍最后提醒一个很容易忽略的环境问题不要直接把CCACHE_DIR指到 NFS、SMB 这类网络磁盘上。ccache 内部依赖文件锁来保证并发一致网络文件系统的锁语义有时并不可靠。多个 job 并发写入时轻则缓存命中率下降重则出现文件损坏。真正的多机共享方案有两种一是各自维护本地缓存定期用 sync 工具同步缓存目录二是依赖 CI 平台的缓存插件做“打包上传 恢复下载”比如 GitHub Actions 的 cache action。这两种我都实践过稳定性远胜直接把共享目录当缓存盘。我在实际使用中的一个体会是ccache 的默认配置已经能给不少项目带来两三倍以上的编译提速但要把命中率稳定在 90% 以上还是得针对项目特点调 base_dir、compiler_check 和 sloppiness 这三个关键项。如果你只在一个纯本地的单基线项目上用 ccache默认配置就够了一旦涉及跨分支、跨编译器、多机器同时构建花二十分钟调一调这些参数回报会非常直接。