Makefile:工地上的铁面工头
引子没有工头的工地想象一个巨大的建筑工地。工地上有一万块砖、三千根钢筋、五百袋水泥、两百个工人。图纸已经画好了材料已经到齐了工人已经就位了。然后——没有人知道该先干什么。砌墙的工人问我现在能开始吗不行地基还没打好。打地基的工人问水泥搅拌好了吗还没有搅拌机的工人在等沙子。运沙子的工人问沙子往哪倒不知道因为场地还没平整。平整场地的工人问挖掘机在哪挖掘机司机在等柴油。整个工地陷入了死锁——每个人都在等别人先完成没有人知道正确的顺序。这时候一个人走了进来。他手里拿着一本厚厚的施工手册上面写着每一道工序的先后顺序、每一个步骤的具体操作、每一种材料的使用方法。他站在工地中央用洪亮的声音开始指挥“第一步平整场地挖掘机组出发”“场地平整完毕好第二步打地基混凝土组开工”“地基凝固了第三步砌墙砖瓦组上”“注意三号区域的墙昨天已经砌好了没有改动跳过直接去四号区域”这个人就是工头。而他手里那本施工手册就是Makefile。第一章Makefile是什么最朴素的定义Makefile是一个文本文件它告诉make程序要构建什么目标依赖什么前提条件怎么构建具体命令就这三件事。但这三件事组合起来就能指挥一个拥有几万个源文件的巨型项目有条不紊地完成编译。最简单的Makefile让我们从最简单的例子开始。假设你有一个C程序// hello.c#includestdio.hintmain(){printf(Hello, World!\n);return0;}没有Makefile你手动编译gcc hello.c-ohello简单。但如果你的项目有三个文件呢// main.c —— 主程序#includemath_utils.h#includestring_utils.hintmain(){printf(12%d\n,add(1,2));printf(Hello → %s\n,to_upper(Hello));return0;}// math_utils.c —— 数学工具#includemath_utils.hintadd(inta,intb){returnab;}// string_utils.c —— 字符串工具#includestring_utils.hchar*to_upper(constchar*s){/* ... */}手动编译gcc-cmain.c-omain.o gcc-cmath_utils.c-omath_utils.o gcc-cstring_utils.c-ostring_utils.o gcc main.o math_utils.o string_utils.o-omyprogram四条命令。每次修改任何一个文件你都得重新敲这四条命令。三个文件还好。Mono项目有几千个源文件。你打算手敲几千条编译命令吗这时候Makefile登场了# 我的第一个Makefile myprogram: main.o math_utils.o string_utils.o gcc main.o math_utils.o string_utils.o -o myprogram main.o: main.c math_utils.h string_utils.h gcc -c main.c -o main.o math_utils.o: math_utils.c math_utils.h gcc -c math_utils.c -o math_utils.o string_utils.o: string_utils.c string_utils.h gcc -c string_utils.c -o string_utils.o clean: rm -f *.o myprogram然后你只需要敲一个命令make工头拿起施工手册开始指挥。一切自动完成。第二章Makefile的语法——施工手册的书写规范规则Rule施工手册的每一页Makefile由一条条规则组成。每条规则的格式是目标: 依赖1 依赖2 依赖3 命令1 命令2 命令3注意命令前面必须是一个Tab键不是空格。这是Makefile最臭名昭著的语法要求。用空格代替Tabmake会直接报错毫不留情。无数程序员在这个坑上栽过跟头。这就像工头的规矩——施工手册必须用蓝色圆珠笔填写用黑色的撕了重写。不讲道理但就是这么规定的。让我们解读一条规则main.o: main.c math_utils.h string_utils.h gcc -c main.c -o main.o翻译成人话目标main.o我要生产的东西 依赖main.c, math_utils.h, string_utils.h生产它需要的原材料 命令gcc -c main.c -o main.o具体的生产步骤 含义 要得到main.o这个产品 需要main.c、math_utils.h、string_utils.h这三个原材料。 如果任何一个原材料比产品更新被修改过 就执行gcc命令重新生产。依赖关系一张施工顺序图多条规则组合在一起就形成了一张依赖关系图Dependency Graphmyprogram / | \ / | \ main.o math_utils.o string_utils.o / | \ | \ | \ / | \ | \ | \ main.c | string | math string string | _utils | _utils _utils _utils math .h math .c .c .h _utils _utils .h .hmake读取Makefile后会在内部构建这张图。然后它从最终目标myprogram开始自底向上地检查每个节点make的思考过程 我要构建 myprogram。 它依赖 main.o, math_utils.o, string_utils.o。 让我先检查这三个依赖。 main.o 依赖 main.c, math_utils.h, string_utils.h。 main.c 的修改时间是 10:30。 main.o 的修改时间是 10:25。 main.c 比 main.o 更新说明源码改过了需要重新编译。 执行gcc -c main.c -o main.o math_utils.o 依赖 math_utils.c, math_utils.h。 math_utils.c 的修改时间是 09:00。 math_utils.o 的修改时间是 09:15。 math_utils.o 比 math_utils.c 更新。没有改动跳过 string_utils.o 依赖 string_utils.c, string_utils.h。 同样没有改动跳过 现在回到 myprogram。 main.o 刚刚重新编译了比myprogram更新需要重新链接。 执行gcc main.o math_utils.o string_utils.o -o myprogram 完成。看到了吗make只重新编译了被修改过的文件。你改了main.c它只重新编译main.o然后重新链接。math_utils.o和string_utils.o没有变化完全跳过。这就是make的核心价值——增量编译。在一个有几千个源文件的项目中你修改了一个文件make只重新编译那一个文件和受它影响的文件而不是把整个项目从头编译一遍。这就像工头发现三号区域的墙有一块砖砌歪了他只让工人拆掉那一块砖重砌而不是把整栋楼推倒重建。Mono项目有几千个源文件全量编译可能需要半小时。但如果你只改了一个文件增量编译可能只需要几秒钟。这就是Makefile的威力。第三章Makefile的高级武器变量不要重复自己# 笨办法到处写gcc main.o: main.c gcc -c -O2 -Wall -I./include main.c -o main.o math_utils.o: math_utils.c gcc -c -O2 -Wall -I./include math_utils.c -o math_utils.o string_utils.o: string_utils.c gcc -c -O2 -Wall -I./include string_utils.c -o string_utils.o如果你想把gcc换成clang要改三处。想加一个编译选项也要改三处。文件多了改漏一个就出Bug。用变量# 聪明办法用变量 CC gcc CFLAGS -O2 -Wall -I./include OBJECTS main.o math_utils.o string_utils.o myprogram: $(OBJECTS) $(CC) $(OBJECTS) -o myprogram main.o: main.c math_utils.h string_utils.h $(CC) $(CFLAGS) -c main.c -o main.o math_utils.o: math_utils.c math_utils.h $(CC) $(CFLAGS) -c math_utils.c -o math_utils.o string_utils.o: string_utils.c string_utils.h $(CC) $(CFLAGS) -c string_utils.c -o string_utils.o现在想换编译器改一处。想加编译选项改一处。CC clang # 换编译器只改这一行 CFLAGS -O2 -Wall -g # 加调试信息只改这一行变量就像工头的通讯录——他不会记住每个工人的手机号他只记住电工组长、“木工组长”。需要换人的时候只需要更新通讯录里的一条记录。自动变量Makefile的速记符号# 每条规则都要写文件名太啰嗦 main.o: main.c math_utils.h string_utils.h $(CC) $(CFLAGS) -c main.c -o main.oMakefile提供了一组自动变量让你不用重复写文件名# $ 目标文件名 # $ 第一个依赖文件名 # $^ 所有依赖文件名 main.o: main.c math_utils.h string_utils.h $(CC) $(CFLAGS) -c $ -o $$ → main.o目标 $ → main.c第一个依赖即源文件 $^ → main.c math_utils.h string_utils.h所有依赖这些符号看起来像天书但一旦习惯了写起来飞快。这就像工头的行话——外人听不懂但工地上的人一听就明白。“把那个东西搬到那边去”在工地上那个东西和那边所有人都知道指的是什么。模式规则一条规则管所有# 笨办法每个.c文件写一条规则 main.o: main.c $(CC) $(CFLAGS) -c $ -o $ math_utils.o: math_utils.c $(CC) $(CFLAGS) -c $ -o $ string_utils.o: string_utils.c $(CC) $(CFLAGS) -c $ -o $三个文件写三条规则三千个文件写三千条用模式规则Pattern Rule# 聪明办法一条规则搞定所有 .c → .o 的编译 %.o: %.c $(CC) $(CFLAGS) -c $ -o $%是通配符匹配任意文件名。这条规则的意思是“对于任何.o文件如果存在同名的.c文件就用这条命令编译。”make需要 main.o → 匹配 %.o: %.c → % main → 编译 main.c make需要 math_utils.o → 匹配 %.o: %.c → % math_utils → 编译 math_utils.c make需要 string_utils.o → 匹配 %.o: %.c → % string_utils → 编译 string_utils.c一条规则管所有。这就像工头说所有砖墙都按同一个标准砌——12厘米厚水泥砂浆1:3配比。他不需要对每面墙单独下指令。伪目标不生产东西的命令.PHONY: clean all install clean: rm -f *.o myprogram all: myprogram tests docs install: myprogram cp myprogram /usr/local/bin/clean不是一个文件名它不会生产任何文件。它只是一个动作——清理编译产物。.PHONY告诉make“这些目标不是文件不要去检查它们是否存在。”如果不加.PHONY万一目录下恰好有一个叫clean的文件make会说clean已经存在了不需要重新生成。然后什么都不做。加了.PHONYmake就知道这是一个命令不是文件每次都要执行。常见的伪目标make # 编译项目默认目标 make all # 编译所有东西 make clean # 清理编译产物 make install # 安装到系统目录 make uninstall # 从系统目录卸载 make test # 运行测试 make dist # 打包发布 make check # 检查环境第四章一个真实的Makefile——Mono的编译Mono项目的Makefile是由Automake自动生成的非常复杂。但核心逻辑和我们上面讲的完全一样。让我们看一个简化版# Mono的 mono/mini/Makefile极度简化版 CC gcc CFLAGS -O2 -g -Wall \ -I../../ \ -I../../mono \ $(GLIB_CFLAGS) \ -DHAVE_CONFIG_H # 源文件列表 MINI_SOURCES \ mini.c \ mini-amd64.c \ mini-codegen.c \ method-to-ir.c \ branch-opts.c \ decompose.c \ dominators.c \ cfold.c \ liveness.c \ ssa.c \ local-propagation.c \ driver.c \ debug-mini.c \ linear-scan.c \ trace.c \ jit-icalls.c # 把 .c 替换成 .o得到目标文件列表 MINI_OBJECTS $(MINI_SOURCES:.c.o) # 依赖的其他模块的库 MONO_LIBS \ ../metadata/libmonoruntime.a \ ../sgen/libmonosgen.a \ ../utils/libmonoutils.a # 最终目标mono可执行文件 mono: $(MINI_OBJECTS) $(MONO_LIBS) $(CC) $(MINI_OBJECTS) $(MONO_LIBS) \ $(GLIB_LIBS) -lpthread -ldl -lm \ -o mono # 模式规则所有.c文件的编译方式 %.o: %.c $(CC) $(CFLAGS) -c $ -o $ # 特殊依赖关系 mini.o: mini.c mini.h mini-ops.h cpu-amd64.h config.h method-to-ir.o: method-to-ir.c mini.h ir-emit.h config.h mini-amd64.o: mini-amd64.c mini.h cpu-amd64.h config.h # 清理 .PHONY: clean clean: rm -f $(MINI_OBJECTS) mono当你执行make时工头开始工作$ make gcc -O2 -g -Wall -I../../ -DHAVE_CONFIG_H -c mini.c -o mini.o gcc -O2 -g -Wall -I../../ -DHAVE_CONFIG_H -c mini-amd64.c -o mini-amd64.o gcc -O2 -g -Wall -I../../ -DHAVE_CONFIG_H -c mini-codegen.c -o mini-codegen.o gcc -O2 -g -Wall -I../../ -DHAVE_CONFIG_H -c method-to-ir.c -o method-to-ir.o gcc -O2 -g -Wall -I../../ -DHAVE_CONFIG_H -c branch-opts.c -o branch-opts.o gcc -O2 -g -Wall -I../../ -DHAVE_CONFIG_H -c decompose.c -o decompose.o ...十几个文件逐个编译... gcc -O2 -g -Wall -I../../ -DHAVE_CONFIG_H -c jit-icalls.c -o jit-icalls.o gcc mini.o mini-amd64.o mini-codegen.o method-to-ir.o ... jit-icalls.o \ ../metadata/libmonoruntime.a ../sgen/libmonosgen.a ../utils/libmonoutils.a \ -lglib-2.0 -lpthread -ldl -lm \ -o mono先编译每个.c文件为.o文件最后把所有.o文件和库文件链接成最终的mono可执行文件。并行编译工头的分身术make-j8-j8告诉make最多同时执行8个编译任务。make会分析依赖关系图找出哪些文件之间没有依赖关系可以同时编译mini.c 和 method-to-ir.c 之间没有依赖关系 → 可以同时编译 mini.c 和 mini-amd64.c 之间没有依赖关系 → 可以同时编译 但 mono 依赖所有 .o 文件 → 必须等所有 .o 编译完才能链接时间线-j8并行编译 时刻1: [mini.c] [method-to-ir.c] [mini-amd64.c] [decompose.c] ...8个同时编译 时刻2: [branch-opts.c] [cfold.c] [liveness.c] [ssa.c] ...又8个同时编译 时刻3: [trace.c] [jit-icalls.c] ...剩余的编译 时刻4: [链接所有.o文件 → mono]最后一步必须串行8核CPU编译速度接近8倍提升。这就像工头把工人分成8个小组同时在8个不同的区域施工。只要这些区域之间没有依赖关系不会互相影响就可以并行推进。# 更激进的写法自动使用所有CPU核心make-j$(nproc)# nproc返回CPU核心数# 8核CPU → make -j8# 16核CPU → make -j16第五章make的时间戳魔法make判断需不需要重新编译的方法极其简单——比较文件的修改时间。如果 目标文件 不存在 → 需要编译 如果 目标文件 比 任何一个依赖文件 更旧 → 需要编译 如果 目标文件 比 所有依赖文件 都更新 → 不需要编译跳过$ls-l--timemodify -rw-r--r--1user user1523410:30 main.c ←10:30修改的 -rw-r--r--1user user845610:35 main.o ←10:35编译的main.o10:35比main.c10:30更新 → 不需要重新编译。# 你修改了main.c$vimmain.c# 修改时间变成 11:00$ls-l--timemodify -rw-r--r--1user user1530011:00 main.c ←11:00修改的 -rw-r--r--1user user845610:35 main.o ← 还是10:35main.c11:00比main.o10:35更新 → 需要重新编译这个机制简单到令人发指但它工作得非常好。唯一的陷阱是时钟问题——如果你的系统时钟不准或者你从另一台机器复制了文件时间戳可能不对make可能会做出错误的判断。这时候你需要# 强制重新编译所有文件makecleanmake# 或者更新所有文件的时间戳touch*.c *.hmakemake clean就像工头说“把工地清空从头来过。”尾声沉默的指挥家每天全世界有几百万次make命令被执行。在Google的数据中心里make或它的变体在编译Chrome浏览器的几万个源文件。在Linux内核开发者的电脑上make在编译2800万行代码的操作系统内核。在某个大学生的宿舍里make在编译他的课程作业——一个只有三个文件的小程序。在你的电脑上当你编译Mono的时候make在指挥几千个源文件按照正确的顺序编译、链接最终变成一个可以运行C#代码的虚拟机。它不写一行代码。它不编译任何东西。它只是站在那里拿着施工手册看着时间戳发出指令“这个文件改过了重新编译。”“这个文件没动跳过。”“依赖还没准备好等一下。”“所有依赖就绪开始链接。”“完成。”$make...... $光标安静地闪烁。没有报错。没有警告。工头合上了施工手册转身离开了工地。他的工作完成了。而你的工作才刚刚开始。