
先交代个背景。前阵子我想给一个纯 C 的小工具项目补个构建脚本项目本身只有六个源文件、两个头文件结果我盯着 Makefile 的 Tab 缩进折腾了十几分钟又打开 CMakeLists.txt 想用 CMake发现为了这点东西写这么一大坨语法实在提不起劲。那天晚上我心一横决定自己写一个构建工具名字就叫caveman——因为它的思路真的像原始人一样只负责把.c文件变成可执行文件其他一概不管。这个项目说到底是个命令行小工具你给它一个非常简单的构建描述文件它就能自动完成编译和链接并且支持增量构建、头文件依赖追踪、并行编译这些日常够用的功能。适合谁看适合那些被 Make 语法坑过、又嫌 CMake 太重的 C 语言开发者也适合想从零搞懂构建工具到底在干嘛的朋友。我把它在真实项目里用了两个月陆续修掉了一批边角问题下面把设计思路、实现细节、踩坑过程全部摊开讲。1. 为什么我会写一个叫 caveman 的构建工具1.1 从被 Make 折腾到决定自研先说说我到底烦在哪。Make 本身不复杂复杂的是它那些历史遗产规则用 Tab 缩进空格就报错变量展开的时机分递归展开和简单展开写错就是各种诡异现象隐式规则和内置变量太多新手根本不知道CC、CFLAGS是从哪冒出来的。我那个项目里有个需求部分源文件要用不同的编译参数。在 Make 里我得写src/foo.o: CFLAGS -DSPECIAL_MODE这条规则放在什么位置、会不会被后面的赋值覆盖全得靠脑子记。而 CMake 倒是不用记这些但代价是要背它那一整套 cache、generator、target 的概念最后生成的构建目录比源码目录还大就为了编译六个 C 文件。所以我当时的诉求特别朴素给我一个文本文件写上源文件列表、头文件搜索路径、链接库然后一条命令全部搞定。这就是caveman的起点。1.2 caveman 的定位服务 90% 的小项目我需要说清楚caveman从来没打算替代 Make 和 CMake。大项目有复杂的依赖图、条件编译、交叉编译、安装打包需求这些必须交给重型工具。caveman盯的是另一个场景个人项目、课程作业、研究原型、内部小工具——这些项目的共同点是构建逻辑一眼能看完。这九十行逻辑我把它固化成三条设计约束约束具体含义零依赖工具本身只有一个二进制不依赖 Python、不依赖 Make、不依赖任何共享库构建文件可读描述文件就是几行键值对任何人打开都能立刻看懂快增量构建的判断要快启动时间要短小项目干等一两秒是不可接受的这三条约束几乎决定了后面所有的实现取舍。1.3 先定下不做的事情写工具最容易失控的就是顺手再加个功能。我给caveman定了一个明确的不做清单不做变量展开和条件分支语法想要条件编译请自己去改构建文件不做自动发现源文件目录里突然多了个.c文件并不会自动进构建不做交叉编译支持cc指向谁就调用谁不做 Windows 下的动态库导出符号这种平台魔法只管静态编译和简单链接。砍掉这些之后核心逻辑就只剩三件事读配置、生成编译命令、按依赖决定执行哪些命令。2. caveman 的核心设计把构建文件压缩到三行2.1 比 Makefile 还简单的构建文件格式caveman的构建描述文件默认叫Cavemanfile也支持命令行参数-f指定别的名字。完整内容长这样C src/main.c src/util.c src/parser.c I src include L -lm O build解释一下这四行的含义C是源文件列表用空格分隔支持相对路径I是头文件搜索目录列表对应编译器的-I参数L是额外链接参数原样拼到链接命令尾部-lm、-lpthread都写在这O是输出目录所有中间文件和产物都放这里不写默认是build。有人可能会问库文件列表呢如果我要链接一个静态库直接写L -L/usr/local/lib -lfoo就行没必要单独造一个语法。这就是可读的代价和收益有些东西交给编译器原生的参数表达反而省去一层转译。输出文件名的规则也很直白取输出目录名所以上面的配置最终会生成build/caveman。想改名加一行T myapp就行。2.2 解析器实现两遍扫描加状态机解析器我没有引入任何第三方库整个实现大概一百来行。逻辑分两道工序第一遍逐行读文件跳过空行和#开头的注释把每行按切分成键和值键统一转成大写。遇到未知键直接报错避免拼写错误被静默忽略。第二遍才是真正的语义处理按优先级取默认值C缺失直接退出因为无源可编I缺失不影响O缺失用buildT缺失从O推导。这里我特意做了个决定支持同一个键出现多次。比如你想把不同目录的源文件分开写清楚C src/main.c C src/db.c src/net.c解析器遇到重复键时采用追加逻辑最后合并成一个列表。这个小特性在实际使用中出乎意料地有用——生成 Cavemanfile 的脚本可以按目录分块输出可读性比一条长到三行的C 好得多。2.3 编译与链接直接调用 cc而不是自己做编译器caveman不维护自己的编译器依赖图——什么头文件包含什么、哪个符号定义在哪全都交给真正的编译器和链接器。它只做一件事构造命令行并执行。编译命令的构造逻辑是这样的for (src in sources) { obj obj_path_for(src); cmd cc flags -I include_dirs -c src -o obj; run(cmd); } ld_cmd cc objs link_flags -o out_path; run(ld_cmd);这里有个很重要的理念不要用system()拼字符串去执行。第一版我用system()跑得欢后来遇到路径里有空格的文件就彻底失灵。正确做法是先用shlex风格的分词把配置里的每段拆成参数数组再用execvp直接传参数数组完全绕开 shell 的解析层。还有一个细节编译参数的顺序。-I必须放在-c之前吗不是必须但放在前面能避免某些老编译器在特殊情况下出现诡异的头文件解析顺序问题。既然拼字符串的成本为零我就一律按cc [flags] -Idir -c src -o obj来组织固定顺序也方便在-v调试模式下打印出人类熟悉的命令格式。3. 增量构建时间戳、依赖文件与一个真正的坑3.1 朴素时间戳方案为什么够用构建工具的核心价值有一半在增量构建——改一个文件不应该把整个项目重编一遍。caveman的实现方式很简单比较源文件和对应.o文件的修改时间源文件更新就重编否则跳过。这一步朴素到几乎不需要设计但我在这个基础上加了一个保险目标文件不存在时强制编译哪怕它的时间戳逻辑看起来不需要编译——这是为了防止你手动删了build目录后还在用旧时间戳做判断的脏状态。串行扫描的耗时几乎可以忽略不计六个文件的项目在毫秒级就能完成判断。如果有人项目里有几千个文件那就该用 Ninja 而不是我这工具我把话说在前面。3.2 头文件依赖第一个真正的坑时间戳方案掩盖了一个严重问题我一开始只比较.c文件的 mtime完全没管头文件。后果特别典型你改了src/util.h然后跑caveman它发现所有.c文件都没变直接跳过编译最后链接用的还是旧对象文件。这种 bug 是最恶心的类型——不报错就是行为不对而且你会在排查上浪费一晚上。解决方案我选了编译器自带的依赖生成能力。GCC 和 Clang 都支持-MMD -MP参数会在编译时顺带输出一个.d文件里面记录了该源文件 include 的所有头文件路径。改动后的流程变成编译时加-MMD -MP每个.c对应生成.d文件第一次构建时只有.d还没有.o增量判断时先读.d文件拿到头文件列表把源文件 mtime 和所有头文件 mtime 一起取最大值再跟.o的 mtime 比较某个头文件比.o新就标记对应源文件需要重编。这个方案的巧妙之处在于完全借助编译器自身对#include的理解不自己实现一套 C 预处理器逻辑可靠性高得多。但要注意第一次编译时没有.d文件必须无条件编译哪怕.o已经存在——因为旧的.d可能没记录你头文件新增的依赖。3.3 并行编译的取舍make -j那么自然caveman一开始却是串行的。原因是我当时觉得小项目不值得做并行。直到我在一个十六个源文件的项目上跑每次全量编译要三十多秒实在忍不了。我采用的实现方案不是复杂的工作线程池而是按依赖分层并行第一层是所有没有依赖的源文件第二层是第一层编完才能编的实际上 C 文件之间很少互相依赖几乎所有源文件都在同一层所以真正有效的方式就是nproc个 worker 并发跑编译命令等全部结束再做一次链接。坑也在这里并发输出会打架。多个cc同时往终端刷输出日志就乱成一锅粥。解决办法是每个 worker 把输出先存到内存等该文件编译结束后一次性打印并且打印前加一行cc src/xxx.c的标记行。这样日志既不乱又保留了哪个文件在编译的可读性。4. 实测从源码到可执行文件的完整流程4.1 一个可以直接抄的 demo我假设你现在手里有个项目叫hello_caveman目录结构长这样hello_caveman/ ├── Cavemanfile ├── src/ │ ├── main.c │ ├── greet.c │ └── greet.h └── build/Cavemanfile内容C src/main.c src/greet.cgreet.h里声明一个函数greet.c实现它main.c调用它。然后执行$ ./caveman [CC] src/main.c - build/main.o [CC] src/greet.c - build/greet.o [LD] build/hello_caveman第一次构建就这三行输出。caveman自动推导出输出文件名是build/hello_caveman因为输出目录名是build。如果你不想要这种推导在配置文件里加T run_me就改成build/run_me。跑二进制确认没问题后改一下greet.c里的实现再执行$ ./caveman [CC] src/greet.c - build/greet.o [LD] build/hello_caveman注意到没main.c没有被重编。整个增量过程只重编了受影响的文件然后重新链接用时基本可以忽略。4.2 日志与调试参数构建工具的输出直接决定使用者能不能快速定位问题。caveman提供了三档输出默认档每个文件一行格式[CC] 源文件 - 目标文件或[LD] 产物路径-v详细档完整打印实际执行的命令行包括所有编译参数和-I路径编译报错时你会需要它-n空跑档只打印将要执行的命令不真正执行。这个在检查构建文件写没写对时特别好用相当于试运行。我实际排查编译问题时的流程永远是先-n看命令再用-v确认参数顺序最后才去看编译器报错本身。顺序反了就是在乱猜。4.3 增量判断失败的现场增量构建偶尔也会失灵表现是改了文件却不重编。我遇到过一种真实场景你用touch src/greet.c手动改了时间戳但系统时钟同步导致 mtime 比.o还要老触发不了重编。解决办法是caveman -f full强制全量编译一次或者删掉对应的.o文件。这个命令也解释了为什么我在解析器里把目标不存在强制编译单独列成一个逻辑分支——它救过我太多次了。5. 真实项目中的边界情况与补救5.1 路径里有空格Windows 用户的噩梦前面提过我一开始用system()拼字符串执行编译命令这在小项目 Linux 上跑得很欢直到有同事在 Windows 上用他的用户名目录是C:\Users\Zhang Wei名字里带空格。整个工具瞬间崩溃错误信息看着像是编译器找不到文件其实是 shell 把Zhang和Wei拆成了两个参数。我后来把整条执行链路改成分词 - 参数数组 -execvp彻底绕过 shell 的空白分隔。这条路对 Linux 和 Windows 都适用因为只要不进 shell空格就是普通字符。还有一个配套修改构建文件本身也按空格分词如果某人死活用src/my file.c这种带空格的文件名配置文件里就不支持需要在文档里明确写死这个限制。宁可清晰报错不要在奇怪的引号转义里打转。5.2 多目标产物一个超纲需求默认行为是全量编译所有 C 源文件链接一个目标。但我的一个数据结构项目里确实需要同时产出两个可执行文件一个命令行工具一个自动化测试跑批量用例的入口。我后来给caveman加了目标段语法C src/main.c src/core.c O build [test] C src/test_main.c src/core.c T test_runner中括号[test]表示一个可选目标块块内的C会覆盖全局的C块内的T指定输出名。这种语法实现起来并不难解析器遇到[开头就切到目标上下文以后读到的键都优先存在目标块的哈希表里。但这算是我对克制原则的一次妥协。我的经验是如果你能坚持只做一个目标caveman就是最爽的形态一旦开始要多目标make的优势会迅速体现出来。所以我虽然实现了也在 README 里建议用户不到万不得已不要用这个功能。5.3 静态库与安装步骤除可执行文件以外我第二个实际需求是把core.c编成静态库给别的项目用。这又逼着我加了lib:前缀语法C src/core.c O build/lib TOOL static当TOOL是static时编译产物不是链接成可执行文件而是先编出所有.o再调用ar rcs build/lib/libcore.a ...。实测下来这个分支代码量反而最少难点只在命名规则上libT的值 .a。安装部署我就没做进工具里我的建议是构建文件之外的事都交给用户自己解决。复制一个二进制去~/bin或者做成 symlink一条 shell 命令的事没必要在工具里增加一个install子命令。6. 后续还能怎么扩展6.1 输出 compile_commands.json这个功能是我在给项目配 clangd 做代码补全时才意识到必不可少的。clangd 需要一个compile_commands.json文件才能知道每个文件的编译参数是什么手写是不可能的CMake 会自动生成bear工具可以录制但用caveman也一样能生成。实现便宜到发指既然编译命令本来就要构造那在每条编译命令生成后顺手往一个 JSON 数组里写一条记录就行[ { directory: /path/to/project, command: cc -Isrc -c src/main.c -o build/main.o, file: src/main.c } ]我加了个-g compile_commands.json参数命令执行前写出所有编译命令整个增量构建的流程都不用变。这算是我觉得性价比最高的一个扩展。6.2 当 CI 里的最小可用构建器还有一个使用场景是 CI。有些最小的 Docker 镜像里没有make也没有cmake但一定有cc。caveman是编译出来的单个静态二进制扔进容器里就能跑不需要额外装依赖。再加上-n空跑参数你甚至可以在 CI 里先-n检查构建文件是否正确再实际构建。我实际在 GitLab CI 里这么用过一轮status 状态码返回正常失败退出码非零跟 shell 脚本衔接没有任何意外。唯一要注意的是 CI 的并发实例之间不要把build目录共享否则时间戳判断会互相污染。6.3 生成依赖图与清理最后一个是顺手加的-clean子命令删除整个输出目录。这个实现甚至不用设计就是把O指定的目录递归删掉但我在执行前加了一个安全校验——输出目录不允许是/、.或者..这种危险名字防止用户配置写错导致删库级别的灾难。依赖图可视化我也想过去做用 Graphviz 画每个.c和.h之间的 include 关系后来发现.d文件本身就是机器可读的依赖列表真要画图让别人写个脚本转换就行没必要塞进caveman里。这个决定我至今觉得正确。工具本身不大全部的源码加起来也就一千行上下但它在真实项目里经受了两个月的折腾从只能编单目标的小玩具变成了一个能处理静态库、多目标、跨平台参数传递的实用家伙。回头再看工具越小越难做这句话我是真信了因为没有一个功能是多余的也没有一个坑是可以跳过的。如果你也被构建系统折磨到心烦不妨试着写一个只属于自己的小工具——不需要有什么野心解决好自己的问题就已经值回票价了。