
做了几年 Linux 下的 C/C 开发我越来越觉得make 和 makefile 是每个在这条路上走的人都绕不过去的一课。哪怕你平时用的是 CMake、Ninja或者 IDE 里的一键构建底层原理翻来覆去还是那几个老家伙在撑着。make 是一个自动化构建工具makefile 就是它的规则文件你把“怎么编译、哪些文件依赖哪些文件”写进去执行一个 make 命令工具会自动判断哪些需要重建、哪些可以跳过然后按照依赖关系把命令一条条跑完。这篇文章我打算从手动编译的痛点讲起把 make 的原理、语法、实战写法、常见报错以及 make 和 CMake 的关系都梳理一遍适合刚接触 Linux 编程的同学也适合被老项目里复杂 makefile 折磨的运维和开发。1. 为什么要用 make从手动编译到自动化构建1.1 手动编译到底有多痛先还原一个特别常见的场景。假设你手头有个小项目就三个源文件main.c、utils.c、parser.c还有一个 include 目录放着公共头文件。编译命令大概长这样gcc -Wall -g -o app main.c utils.c parser.c -Iinclude -lm一个文件的时候还好敲一遍就完了。但文件一多问题立刻冒出来。我记得读书时做过一个操作系统课程设计三十多个 .c 文件分了好几个模块每次编译得在终端里翻历史记录把那条一长串的 gcc 命令再调出来。敲错一个字母编译器直接报文件不存在漏掉一个 .o 文件链接阶段一堆 undefined reference查半天才发现是少编译了某个模块。这还不是最让人崩溃的。最痛的是增量编译这件事。三十个源文件你只改了一个文件里的一个函数按理说只需要重新编译那一个文件再把所有 .o 链接起来就行。但手动操作时因为懒得维护依赖关系绝大多数人会选择全部重新编译。十几秒能搞定的事硬是拖到几分钟。等哪天项目大到几百个文件每次全量编译都要好几分钟的时候你真的会想砸键盘。1.2 make 的解题思路时间戳与依赖make 解决这个问题的思路其实非常朴素它通过比较文件时间戳来判断“谁该重新生成”。目标文件不存在需要构建。目标文件存在但某个依赖文件比它新说明依赖有更新需要重新构建。目标文件比所有依赖文件都新说明什么都没变跳过。这个机制依赖的是文件系统里的 mtime也就是最后修改时间。这也解释了为什么系统时间混乱会导致 make 出现诡异问题。如果你把文件从一台时间不准的机器拷贝过来或者有人手动把系统时间改到未来make 会检测到文件时间戳异常然后报一个经典错误clock skew detected。意思是你这个构建很可能不完整。1.3 make 与 makefile 的关系make 是程序makefile 是它的配置脚本。默认情况下make 会在当前目录按顺序查找 GNUmakefile、makefile、Makefile 这三个文件找到哪个用哪个。实际项目里大家最常用的是 Makefile 这个名字因为很多工具和文档默认优先识别它。如果你在当前目录没有放任何 makefile直接执行 make就会看到那个经典报错make: *** 没有指明目标并且找不到 makefile。停止。这句话翻译过来就是我既没有拿到目标名称也没有找到规则文件我不知道该干什么。这个报错非常典型很多新手第一次看到就懵了其实原因往往特别简单要么目录不对要么文件名不叫 makefile 或者 Makefile。2. makefile 基础语法从最简单的规则开始2.1 规则的基本结构makefile 最核心的东西就是规则格式如下目标: 依赖 命令目标是你要生成的东西可以是文件也可以是个动作的名字。依赖是这个目标依赖的源文件或前置目标。命令是你想让 shell 真正执行的操作。举一个最简单的例子hello: main.c gcc -o hello main.c .PHONY: clean clean: rm -f hello执行 make hellomake 会检查 hello 这个文件是否存在或者是否比 main.c 新。如果 hello 不存在或者 main.c 更新了就执行 gcc 那行命令。如果直接执行 make默认会处理第一个目标也就是 hello。2.2 命令行的 Tab 坑注意细节命令那一行前面必须是一个 Tab 字符不能是空格。这是 makefile 新手踩得最多、也最容易疑惑的坑。我当时第一次写 makefile用记事本敲了一个保存后执行 make直接报错Makefile:2: *** missing separator. Stop.查了半天才发现缩进用的全是空格。make 对缩进字符特别敏感规则里的命令必须用 Tab 开头。如果你用的是 vim直接按 Tab 键就行如果你习惯在编辑器里把 Tab 自动替换成空格那就要关掉这个设置否则分分钟被这个报错折磨。2.3 默认目标与多目标组织makefile 里可以写很多条规则但默认只会执行第一个目标。所以实际项目里大家通常会先安排一个总目标比如 allall: app app: main.o utils.o gcc -o app main.o utils.o main.o: main.c gcc -c main.c -o main.o utils.o: utils.c gcc -c utils.c -o utils.o执行 make 等价于 make all。make 会先看 all 依赖什么发现依赖 app再看到 app 依赖 main.o 和 utils.o于是先去构建这两个 .o 文件最后执行链接命令。这里依赖关系就像一棵倒挂的树make 会从根目标出发一层层往下解析。2.4 伪目标 .PHONY 到底干什么用再看 clean 这个目标。clean 不是一个真实存在的文件它只是一个动作的名字。如果不加处理当当前目录刚好出现一个文件叫 clean 时make 会认为这个目标已经“最新”直接跳过rm 命令永远不会执行。解决办法就是把 clean 声明为伪目标.PHONY: clean clean: rm -f *.o app用 .PHONY 声明后make 就不会拿它跟真实文件做时间戳比较无论什么情况都会执行后面的命令。除了 cleaninstall、test、all 这类只执行动作、不生成文件的目标都建议大家加上 .PHONY避免意外。3. 让 makefile 更聪明变量、自动变量与模式规则3.1 变量的定义与引用如果你把 gcc 命令在每条规则里重复写改一次编译选项就要全文替换非常痛苦。makefile 支持变量最常用的赋值方式有三种递归展开式赋值变量在引用时才会展开可能出现递归引用。:立即展开式赋值变量在定义时立即展开结果固定。?如果变量未被定义过才赋值。实际写 makefile我推荐优先用:逻辑更直观。CC : gcc CFLAGS : -Wall -g -O2 -Iinclude OBJS : main.o utils.o parser.o app: $(OBJS) $(CC) $(CFLAGS) -o app $(OBJS)变量引用用$(变量名)。这里的 OBJS 是源文件列表后面配合 wildcard 和 patsubst 函数还能做到源文件列表自动生成这个我们放到实战部分讲。3.2 自动变量$、$^、$在一条规则里目标名在左边写了一次依赖名在右边写了一次命令里还要再写实在冗余。GNU make 为此提供了自动变量$表示当前目标名。$^表示当前规则的所有依赖文件列表已经去重。$表示当前规则的第一个依赖。比如规则 main.o: main.c命令里写 $ 代表 main.o写 $ 代表 main.c写 $^ 在这里也是 main.c。main.o: main.c $(CC) $(CFLAGS) -c $ -o $单独看没觉得省多少事但配合模式规则效果就很明显了。3.3 模式规则%.o 与隐式规则如果有几十个 .c 文件难道要写几十条编译规则当然不行。模式规则就是用来干这个的它用通配符 % 匹配文件名%.o: %.c $(CC) $(CFLAGS) -c $ -o $这条规则的意思是任何一个 .o 文件都可以由同名的 .c 文件编译生成。这样你只需要在链接目标里列出 .o 文件列表make 就会自动套用模式规则去生成每个 .o完全不用你逐个写编译命令。反过来make 还有一套内置的隐式规则。它其实默认知道 %.o: %.c 应该怎么编译甚至自带默认的 CC 变量。但默认参数往往不符合实际需求编译选项、头文件路径都跟你项目对不上所以大家通常还是自己写模式规则并明确设置编译参数。3.4 常用函数wildcard、patsubst、notdirmakefile 里也有函数。我最常用的是下面这四个$(wildcard 模式)按模式匹配文件列表。$(patsubst 模式, 替换为, 列表)把列表中符合模式的部分替换掉。$(notdir 列表)去掉文件路径只保留文件名。$(foreach 变量, 列表, 表达式)遍历列表生成新的内容。举个例子SRCS : $(wildcard src/*.c) OBJS : $(patsubst src/%.c, obj/%.o, $(SRCS))第一行会把 src 目录下所有 .c 文件展开成列表第二行把每个 src/xxx.c 映射成 obj/xxx.o。这样以后新增源文件不需要改 makefile非常方便。4. 完整实战从零写一个多文件项目的 Makefile4.1 项目结构初始化光讲语法不动手学不会。这里我准备一个极简又完整的 C 项目project/ ├── include/ │ └── utils.h ├── src/ │ ├── main.c │ ├── utils.c │ └── parser.c └── Makefile头文件放在 include 目录源文件放在 src 目录这样方便演示 makefile 里的头文件路径怎么配。utils.h 里声明了几个工具函数utils.c 实现main.c 调用parser.c 也引入了 utils.h。目标是把它们编译链接成一个可执行文件 app。4.2 编写 Makefile 并逐段解释完整的 Makefile 如下CC : gcc CFLAGS : -Wall -g -Iinclude -MMD SRC_DIR : src OBJ_DIR : obj TARGET : app SRCS : $(wildcard $(SRC_DIR)/*.c) OBJS : $(patsubst $(SRC_DIR)/%.c, $(OBJ_DIR)/%.o, $(SRCS)) DEPS : $(OBJS:.o.d) all: $(TARGET) $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^ $(OBJ_DIR)/%.o: $(SRC_DIR)/%.c mkdir -p $(OBJ_DIR) $(CC) $(CFLAGS) -c $ -o $ clean: rm -rf $(OBJ_DIR) $(TARGET) -include $(DEPS) .PHONY: all clean一段一段看。CFLAGS 里的 -Iinclude 就是告诉编译器去 include 目录找头文件这样源文件里写#include utils.h才能被找到。如果你在做嵌入式交叉编译通常这里还会加上工具链相关的参数比如-I/opt/rv1106_sdk/include这种具体路径原理是一样的。SRCS 用 wildcard 自动收集 src 目录下的 .c 文件OBJS 用 patsubst 把每个 src/xxx.c 映射成 obj/xxx.o。DEPS 是依赖文件列表后面专门讲。all 是总目标依赖 TARGET也就是 app。$(TARGET): $(OBJS)这条规则靠$^把所有 .o 文件一次链接起来。接下来那条模式规则定义 obj 目录下的 .o 怎么从 src 目录下的 .c 生成。注意命令里我加了 mkdir -p确保 obj 目录存在否则首次编译会报错目录不存在。4.3 执行构建与验证增量效果首次执行 make输出大概是这样mkdir -p obj gcc -Wall -g -Iinclude -MMD -c src/main.c -o obj/main.o mkdir -p obj gcc -Wall -g -Iinclude -MMD -c src/utils.c -o obj/utils.o mkdir -p obj gcc -Wall -g -Iinclude -MMD -c src/parser.c -o obj/parser.o gcc -Wall -g -Iinclude -MMD -o app obj/main.o obj/utils.o obj/parser.o再执行一次 make输出变成make: app is up to date.现在手工修改 main.c 里的某个函数随便加个空格再保存重新执行 makemkdir -p obj gcc -Wall -g -Iinclude -MMD -c src/main.c -o obj/main.o gcc -Wall -g -Iinclude -MMD -o app obj/main.o obj/utils.o obj/parser.o注意看只有 main.o 被重新编译utils.o 和 parser.o 直接复用原来的。这就是增量编译的实际效果。你改多少个文件make 只会重新编受影响的文件别的统统跳过。4.4 头文件依赖自动生成前面的 makefile 还缺一个关键能力如果 utils.h 被修改了而 utils.c 和 parser.c 都包含了它make 怎么知道要重新编译它们只看当前规则的话obj/%.o 只依赖 src/%.c根本不知道 .h 的存在。结果就是头文件改了make 无动于衷最后链接出一个还是旧代码的程序这种 bug 特别隐蔽。解决办法是让编译器生成依赖文件。GCC 的 -MMD 参数会在编译时自动生成一个 .d 文件文件里记录了该 .c 文件实际包含的头文件依赖关系。比如编译 utils.c 会生成 obj/utils.d内容大概是这样obj/utils.o: src/utils.c include/utils.h然后在 makefile 末尾用 -include 把这些 .d 文件引入make 就能感知到头文件的依赖了。-include前面的减号表示文件不存在也不要报错。首次编译时这些 .d 文件还没生成如果没有减号make 会直接报错退出这个细节非常关键。做完这一步再修改 utils.h重新执行 makeutils.o 和 parser.o 都会被重新编译因为通过 .d 文件make 已经知道它们依赖 utils.h。4.5 头文件路径与库路径的配置头文件路径是很多新手纠结的地方。你可以在 CFLAGS 里用 -I 加路径如果有多个目录就写多个 -I。链接阶段需要加第三方库的话用 -L 指定库路径用 -l 指定库名。比如CFLAGS : -Iinclude -I/opt/third_party/include LDFLAGS : -L/opt/third_party/lib -lm -lpthread注意 -l 后面直接跟库名不要写全名。比如链接 libpthread.so就写 -lpthread。这个细节很基础但很多人第一次总是写成全路径然后跟编译器较劲半天。5. 常见报错与排查技巧5.1 高频报错速查表我在实际答疑中遇到最多的 make 报错整理成了一张表报错信息常见原因解决办法make: *** 没有指明目标并且找不到 makefile。停止。当前目录没有 makefile或执行目录不对检查目录和文件名用make -f 路径指定文件Makefile:2: *** missing separator. Stop.命令行的缩进用了空格不是 Tab把命令行缩进改成 Tabmake: Nothing to be done for all目标已是最新或目标没有依赖和命令如果期望执行命令检查是否有依赖用make -B强制重建No rule to make target xxx.h, needed by main.omake 找不到某个依赖文件检查文件名和路径确认头文件是否存在以及依赖规则是否完整clock skew detected. Your build may be incomplete.文件时间戳晚于当前系统时间检查系统时间用find -newer找出异常文件用touch修正gcc: command not found编译器没装或不在 PATH 里安装 gcc或者检查交叉编译工具链路径是否配置这里重点说一下 clock skew。遇到这个报错先怀疑系统时间再看文件时间戳。最简单的方法是用 find 找出 mtime 在未来时间的文件find . -newer $(date %Y%m%d)找到后 touch 一下把时间戳修正到当前时间问题基本就解决了。5.2 在 Windows 上使用 make 的姿势很多初学者在 PowerShell 里敲 make会看到下面这种报错make : 无法将“make”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。意思是系统里根本没有 make 这个程序或者没把可执行文件所在目录加进 PATH。想在 Windows 上用 make常见方案有三个装 MSYS2 或 MinGW-w64在它提供的 shell 里使用 make。用 WSL在 Linux 子系统里跑 make这是我最推荐的学习方案因为它就是真正的 Linux 环境。用 Chocolatey 等包管理器执行 choco install make会装一个 Windows 下的 GNU make。需要提醒的是Windows 原生环境下的 make 很挑剔makefile 里的命令默认要通过 cmd 执行跟 Linux 上的 shell 差异很大。所以我给新人的建议是学习阶段直接用虚拟机或 WSL别在原生 Windows 环境里折腾 make免得把时间花在环境适配而不是学习本身。5.3 调试 makefile 的实用命令make 自带的调试参数非常有用我日常排查问题主要用这几个make -n试运行只打印将要执行的命令不真正执行适合验证 makefile 逻辑。make -B强制重建所有目标忽略时间戳适合怀疑增量判断出错时使用。make -p打印 make 数据库包括所有变量、规则和隐式规则信息量很大可以配合 grep。make --debugb输出“为什么重建这个目标”的基础原因排查时间戳问题时很实用。make -r禁用内置隐式规则排查是不是隐式规则干扰了自定义规则。我自己排查问题的标准流程是先用 ls -l 看文件时间戳再用 make -n 看 make 想干什么配合 make --debugb 确认它到底因为哪个文件才去重建。这套组合拳能解决绝大多数“make 没有按预期执行”的问题。5.4 并行编译加速make 一次只编译一个文件在多核机器上其实浪费资源。给 make 加一个 -j 参数可以并行执行任务make -j4 make -j$(nproc)第二个命令会根据 CPU 核数自动设置并行数很实用。但注意如果项目里同一个输出文件被多规则同时生成或者目录创建步骤有问题并行编译可能会偶发报错。稳妥起见小项目可以先不开 -j项目大到编译时间难以忍受时再上并行遇到问题再排查。6. 从 makefile 到 CMake什么时候该升级6.1 make 和 CMake 到底什么关系经常有人问 cmake 和 makefile 区别是什么。这里明确一下make 是实际执行构建的构建系统而 CMake 是一个元构建工具。你写 CMakeLists.txt运行 cmake 命令它在 Linux 下默认会生成一套 Makefile然后再由 make 去编译。CMake 也可以生成 Ninja 构建文件但 Ninja 是另一个构建系统。所以两者是上下游关系不是替代关系。CMake 让你用一种高级描述语言组织工程自动处理跨平台差异、库依赖查找等脏活再转换成底层构建系统的规则。makefile 则是那个底层规则本身需要你手工管理很多细节。6.2 什么时候继续用 makefile下面的场景我建议直接用 makefile不需要引入 CMake小项目几个源文件自己一个人维护。不需要跨平台构建只针对 Linux 或某个嵌入式平台。嵌入式交叉编译项目很多老工具链的工程文件还是以 makefile 为中心的。我也见过一个性能取向的项目几百个源文件仍然坚持纯 makefile因为它能把构建流程抠到非常细中间插脚本、调整链接参数都很直接。6.3 什么时候转向 CMake项目一旦规模上来或者要跨平台我建议尽早切 CMake需要支持 Linux、Windows、macOS 多个平台。需要方便引入第三方库find_package 确实好用。团队协作时CMakeLists 的语法比 makefile 更容易让新人接手。另外如果在 Windows 上用 visual studio 那套工具链CMake 也能生成相应的工程文件这也是 makefile 给不了的跨工具链能力。不过我的个人观点是即使最终全面用 CMakemakefile 的基础依然值得扎实学一遍。因为 CMake 生成出来的构建文件出了问题时你还是得回到“理解依赖规则、理解时间戳、理解目标构建顺序”这个层面去排查这些核心概念在 makefile 里最直接。7. 最后聊几句实在的经验make 这个东西说难其实不难核心就那么几个概念目标、依赖、命令、变量、模式规则。难的是你真正去维护一个复杂项目时各种边界问题会轮番出现头文件依赖没生成、并行编译偶发冲突、时间戳异常、目录不存在、windows 环境差异等等。我在实际使用中最大的体会是写 makefile 时一定要把“可维护性”放在第一位。变量命名清晰目录结构统一依赖文件自动生成这些一开始多花十分钟后面能省一整天排查的时间。另一个小技巧是把默认目标设计好让执行 make 就能构建出最终产物不要让人再去记什么 make build、make release 的花式目标名越简单越好用。