资讯详情

Linux下make与Makefile实战:从入门到增量构建排错

📅 2026/10/4 2:59:10 | 华诺云谱 👁 阅读
Linux下make与Makefile实战:从入门到增量构建排错
只要你在 Linux 下写过几行 C 代码或者从 GitHub 拉过源码准备自己编译就一定绕不开 make 和 Makefile。它不是什么炫酷的新技术反而是那种越用越顺手的自动化构建工具你只需要维护一个文本文件几十个项目文件也能在一条 make 命令里完成编译、清理和安装。很多人初学时会被那堆“目标、依赖、变量、隐式规则”的名词吓住等真正上手之后才会发现把 Makefile 聊明白了Linux 下的构建体系也就通了一大半。这篇文章我打算用尽量口语化的方式把自动化构建里最常用的 make/Makefile 用法拆开讲清楚从最基础的规则语法到多文件工程怎么写再到常见报错怎么排查。文章不要求你之前有编译原理基础只要会基本操作 Linux 命令行、写过最简单的 C 程序就可以跟着往下看。我会把原理、实操、踩坑都混在一起说这样更像实际工作里遇到问题的节奏。1. 为什么还需要 Makefile从手动敲命令说起1.1 手动编译时的痛点假设你手里有一个很小的项目只有两个源文件 main.c 和 utils.c编译它需要按照顺序执行三条命令gcc -Wall -Wextra -c main.c -o main.o gcc -Wall -Wextra -c utils.c -o utils.o gcc main.o utils.o -o myapp命令本身不难但如果你把这个过程放大到一个真实项目问题马上就会冒出来。第一文件一多命令行会变得又臭又长很难不敲错第二你改了一个文件往往忍不住把整个项目重新编译一遍浪费大量时间第三别人拿到你的源码还得自己琢磨“先编译哪个、后编译哪个、需要链接哪些库”。我见过不少新手在 Windows 上按“全选复制、粘贴到命令行”的方式编译源码那完全不是工程化的做法。手动编译只适合验证一两行代码一旦项目开始分模块、带头文件、链接外部库人的记忆力就不太够用了。这时候就需要一个工具把“如何从源代码得到最终程序”这条链路沉淀下来让机器自动完成。1.2 自动化构建到底解决什么问题make 做的就是这件事。它本身是一个自动化构建工具核心思想用一句话概括根据文件的修改时间判断哪些东西需要重新生成只做最小必要的工作。你写一个 Makefile把“目标文件依赖哪些源文件、用什么命令生成”告诉 make它就会自己维护这棵依赖树帮你增量构建。为什么强调“增量”因为编译 C/C 工程最耗时的环节往往是预处理、词法语法分析、代码生成和优化一个几万行的源文件可能要编译好几秒。如果只改了一个小文件却要把整个项目从头编一遍那效率低得让人抓狂。Makefile 会对比目标和依赖的时间戳只有当依赖文件比目标文件新或者目标文件不存在的时候它才会重新执行生成命令。这就是增量构建的基础也是 make 在几十年后依然没有被完全取代的原因。这也能解释为什么很多开源项目直到现在还在用 Makefile它简单、可移植、不依赖特定的集成开发环境一条make命令就能在几乎所有 Unix-like 系统上完成构建。另外不少人的印象里make 只用于 C/C其实它的 recipe 可以是任何 shell 命令所以也能用来完成文档生成、数据处理、打包发布等自动化任务只是最常见的场景仍然是编译程序。2. Makefile 的核心语法目标、依赖、配方2.1 三条最基本的规则Makefile 的规则长这样目标: 依赖列表 配方命令“目标”通常是要生成的文件名“依赖列表”是生成目标所需要的东西“配方命令”是真正要执行的 shell 命令。一个最简单的 Makefile 可以是myapp: main.c gcc -o myapp main.c意思是如果myapp文件不存在或者main.c比myapp新就执行gcc -o myapp main.c。这个规则其实已经具备了一个构建脚本的核心能力但工程上我们很少写得这么简陋因为一旦项目出现多个源文件你必须把每个中间产物.o 文件也纳入依赖关系才能避免每次全量编译。稍微工程化一点的版本是这样CC gcc CFLAGS -Wall -Wextra -g myapp: main.o utils.o $(CC) $(CFLAGS) -o myapp main.o utils.o main.o: main.c utils.h $(CC) $(CFLAGS) -c main.c -o main.o utils.o: utils.c utils.h $(CC) $(CFLAGS) -c utils.c -o utils.o这里出现了三个规则最后目标是myapp它依赖两个 .o 文件每个 .o 文件又依赖对应的 .c 文件和公共头文件。make 在执行的时候会从顶层目标开始递归检查所有依赖。如果main.o不存在它会先执行main.o那条规则然后再链接生成myapp。这种“由顶向下找依赖、由底向上执行”的机制就是 make 构建流程的核心。2.2 变量与自动变量的用法上面我写了CC和CFLAGS两个变量Makefile 里可以用变量名 值来定义使用时写成$(变量名)。变量最大的好处是避免重复以后想换编译器、加编译选项只需要改最前面那几行。常见的做法还包括CC gcc CFLAGS -Wall -Wextra -O2 -g LDFLAGS -lm TARGET myapp不过如果每个规则里都手动把main.o、utils.o写一遍代码还是很啰嗦。Makefile 提供了一组自动变量专门用来在规则内部指代“目标”和“依赖”最常用的是这几个自动变量含义$当前规则的目标文件名$依赖列表中的第一个文件$^所有依赖文件列表用空格分隔已去重$?比目标新的依赖文件列表所以上面的规则可以写成myapp: main.o utils.o $(CC) $(CFLAGS) $^ -o $ main.o: main.c utils.h $(CC) $(CFLAGS) -c $ -o $这样即使后面给myapp增加新的对象文件链接规则里的配方不用改只要更新依赖列表就行。我刚开始用自动变量的时候总觉得它们像某种“黑魔法”但看多了就习惯了现在手写 Makefile 基本离不开这几个符号。2.3 隐含规则与 .PHONYMakefile 还有一套隐含规则比如它默认知道“从 .c 文件编译出 .o 文件”该用什么命令。你可以只写依赖关系不写配方make 会自动补全myapp: main.o utils.o $(CC) $^ -o $ main.o: main.c utils.h utils.o: utils.c utils.h这里我没有写编译 .c 到 .o 的命令make 会调用默认的$(CC) $(CFLAGS) -c $ -o $。隐含规则看着省事但有个小问题它不会自动把你项目里的头文件加入依赖。比如上例中main.o依赖了utils.h你明明写在规则里了才能正确触发重编如果没有写改了utils.h后 make 不会认为main.o过期于是链接出来的程序还用着旧目标文件。这一步是新手最容易忽略的“坑”后面我会单独讲怎么用-MMD自动生成头文件依赖。还要提一下.PHONY它专门用来声明“不是真实文件”的目标。最常见的场景是clean.PHONY: clean clean: rm -f *.o myapp如果你不把它声明为伪目标而工作目录里恰好有一个叫clean的文件make 会发现“这个文件的依赖都不比它新”于是什么都不做。加了.PHONY之后make 不检查同名文件是否存在每次都执行配方。这个坑我在真实项目里踩过一次当时目录里有个临时文件叫 clean所有人都发现make clean突然失效了排查了半天才想起来是伪目标的问题。3. 写一个能用的 Makefile从单文件到多目录工程3.1 先让单文件项目跑起来很多人对“写 Makefile”有畏惧心理其实从单文件起步压力很小。比如你有一个hello.c最省事的 Makefile 只需要hello: hello.c gcc -o hello hello.c甚至你都可以不写 Makefile直接执行make hellomake 会利用隐含规则自动帮你编译。但实际开发中我还是建议写一个最小的 Makefile因为可以顺手把常用参数固定下来CC gcc CFLAGS -Wall -Wextra -stdc11 hello: hello.c $(CC) $(CFLAGS) -o $ $ .PHONY: clean clean: rm -f hello这段代码很直白目标hello依赖源文件hello.c配方里用$指代hello用$指代hello.c。跑make之后生成可执行文件跑make clean清理产物。对一个小工具来说这样的构建体验已经比手动敲命令舒服太多了。3.2 多文件工程和头文件路径当项目里有了 include 目录比如这种结构project/ ├── include/ │ └── utils.h ├── src/ │ ├── main.c │ └── utils.c └── Makefile最简单的写法是把头文件路径用-I告诉编译器CC gcc CFLAGS -Wall -Wextra -g -Iinclude TARGET myapp SRCS src/main.c src/utils.c OBJS $(SRCS:.c.o) $(TARGET): $(OBJS) $(CC) $(CFLAGS) $^ -o $ src/%.o: src/%.c include/utils.h $(CC) $(CFLAGS) -c $ -o $ .PHONY: clean clean: rm -f $(OBJS) $(TARGET)这里最关键的是-Iinclude。编译器在处理#include utils.h时会先到包含这条语句的源文件所在目录找如果找不到就遍历-I指定的目录。很多新手遇到fatal error: utils.h: No such file or directory第一个反应是“头文件明明在啊”其实就是-I路径没配对编译器根本不知道去哪儿找。检查头文件路径是排查编译错误时的基本功。另一种常见的多文件组织方式是不用子目录所有 .c 文件平铺在项目根目录下那样规则会更简单SRCS main.c utils.c OBJS $(SRCS:.c.o) %.o: %.c utils.h $(CC) $(CFLAGS) -c $ -o $用%.o: %.c这种模式规则意思是“所有 .o 文件都由同名 .c 文件生成”。不过要注意如果每个源文件依赖的头文件不一样把所有头文件都写进模式规则会让依赖关系过于粗糙改一个头文件就可能触发无关文件重编。所以到了工程后期我们更推荐用编译器自动生成依赖文件见下文。3.3 无 root 权限时怎么编译安装很多刚从 GitHub 拉下源码的人会问“我连sudo都没有怎么编译安装”其实编译本身通常不需要 root只有“安装到系统目录”这一步才需要写 /usr/local。这时候可以用--prefix把安装路径指到自己的家目录./configure --prefix$HOME/.local make -j$(nproc) make install如果项目没有 configure 脚本而 Makefile 支持PREFIX变量也可以这样make PREFIX$HOME/.local make PREFIX$HOME/.local install装完记得把路径加进环境变量export PATH$HOME/.local/bin:$PATH还要提醒一个细节编译大型项目时强烈建议用make -j$(nproc)开并行nproc会输出当前机器 CPU 核心数。并行构建能省非常多时间但第一次编译建议先不加-j或者用-j2因为一旦 Makefile 里依赖关系没写全并行时会出现“某个头文件还没生成就被另一个任务拿去用”的诡异报错排查起来比较费劲。等确认能稳定构建再开高并行度。如果是给交叉编译平台做构建比如手里是 rv1106 这类嵌入式板子的 SDKMakefile 里通常要指定交叉编译器并把 SDK 的头文件和库目录都指对。道理和-Iinclude一模一样只是把CC换成类似arm-rockchip-linux-gnueabihf-gcc再通过CFLAGS和LDFLAGS把路径告诉编译器。别被“交叉编译”这个词吓到它只是“在电脑上编辑用另一套工具链编出目标板上能跑的二进制”而已Makefile 的骨架完全没变。4. make 命令行参数与排错技巧4.1 常用参数速查很多人以为 make 就是简简单单一个make其实它有一批很实用的命令行参数掌握后能大幅提升调试效率。下面是我日常用最多的几个参数作用典型使用场景make构建默认目标通常是第一个目标最常见的用法make target只构建指定目标如make clean、make main.omake -f file指定 makefile 文件文件名不叫 Makefile 时make -C dir先进入目录再执行在 Makefile 里递归调用子项目make -j N并行执行 N 个任务make -j$(nproc)加速构建make -n只打印命令不真执行预览这次构建会做什么make -B无条件重新构建强制重新编译所有目标make -d输出调试信息排查依赖判断问题make -n是我用得最多的“安全预览”尤其是刚拿到一个陌生项目的 Makefile不敢直接跑先看看它到底会执行哪些命令心里有底再动手。make -B则适合在怀疑“某个依赖没触发重编”时用强制全量构建一次能很快区分是代码问题还是 Makefile 依赖写漏了。4.2 Makefile 内部调试三板斧很多时候问题不在命令行参数而在 Makefile 里的变量值不对。我常用的调试手段有三种。第一在 Makefile 里插入$(info ...)输出变量值$(info CC$(CC)) $(info OBJS$(OBJS))执行make时这些信息会直接打印出来方便你确认变量展开结果是否符合预期。注意它不会出现在最终的命令里而是作为 make 的解析信息输出适合快速定位变量写错的场景。第二用make -d看 make 的完整决策过程。这个输出非常啰嗦但里面能看到目标时间戳比较的细节比如哪个文件被判断为过期、哪条规则被选择等。如果项目复杂建议重定向到文件再慢慢翻make -d /tmp/make.log 21第三在 recipe 行首加可以不要回显命令本身只输出命令结果。比如echo build done适合让终端输出更清爽。这个属于细节技巧但在项目里很常用。5. 常见错误与排查实录5.1 “没有指明目标并且找不到 makefile”这是新手最常见的第一条报错完整信息是make: *** No targets specified and no makefile found. Stop.这句报错已经说得很直白当前目录下既没有找到名为 GNUmakefile、makefile 或 Makefile 的文件命令行里也没有指定目标。解决办法通常有三个方向先确认当前目录是哪个用ls -a看有没有 Makefile如果文件不叫 Makefile要用make -f 文件名指定如果 Makefile 确实存在但你没进到对应目录那就先cd。这句话里有个小知识点make 查找文件名的顺序是 GNUmakefile、makefile、Makefile实际项目里大家几乎都用 Makefile。5.2 “make 不是内部命令”或“无法将 make 项识别为 cmdlet”如果你看到的是英文make: command not found说明系统里根本没装 make。Debian/Ubuntu 系执行sudo apt update sudo apt install build-essentialbuild-essential不止包含 make还包含 gcc 和一整套构建基础工具是装一次能解决后续很多问题的包。CentOS/RHEL 系则用yum install make gcc或dnf install make gcc。如果你用的是 Windows在 PowerShell 里敲make出现“无法将‘make’项识别为 cmdlet、函数、脚本文件或可运行程序的名称”本质是同一个问题make 不在环境变量的可执行路径里。解决办法有两个主流方向。第一个是启用 WSL在 Linux 环境里跑 make这也是现在很多人推荐的方案第二个是安装 MinGW-w64 或 MSYS2然后把其 bin 目录加进系统的 PATH。我个人更推荐 WSL因为 Makefile 本身就是为 Unix-like 环境设计的很多第三方库的构建脚本也只考虑了 Linux 路径在原生 Windows 下折腾往往问题更多。5.3 missing separatorTab 和空格的恩怨Makefile 的配方命令必须以 Tab 键开头这是它语法最“反人类”的地方。如果你用了空格缩进会看到Makefile:3: *** missing separator. Stop.解法就是把行首的空格改成真正的 Tab 字符。某些编辑器默认会把 Tab 替换成多个空格需要在编辑器设置里关掉“expand tab”或者在文件里单独关闭这个选项。我早年在这个报错上翻车多次现在已经养成习惯写 Makefile 第一件事就是在编辑器状态栏确认“缩进模式是 Tab 而不是 Spaces”。5.4 头文件路径找不到报错长这样main.c:1:10: fatal error: utils.h: No such file or directory原因一般是头文件路径不在编译器的搜索范围里。处理顺序是先确认头文件真实存在再检查源文件里的#include路径是否正确最后给CFLAGS加上对应的-I选项。如果是第三方库可以用pkg-config自动生成编译参数pkg-config --cflags --libs libcurl然后在 Makefile 里写成CFLAGS $(shell pkg-config --cflags libcurl) LDLIBS $(shell pkg-config --libs libcurl)这样就不用手动去猜头文件和库放在哪了。我当时在嵌入式项目里遇到头文件目录嵌套了好几层用 pkg-config 未必能覆盖最后还是老老实实打开编译日志照着错误路径一层一层加-I修改 CFLAGS 才解决。只要是和头文件路径相关的报错先把你实际用的编译命令完整打印出来看基本都能找到原因。5.5 error writing temporary file 和磁盘空间问题看到这个报错先不用慌它不是因为代码写得不对而是构建过程中创建临时文件失败了。常见原因是/tmp空间不足或者没有写权限。先看磁盘df -h /tmp如果确实满了清理/tmp下的旧文件或者把临时目录改到当前用户有空间的地方export TMPDIR$HOME/tmp mkdir -p $TMPDIR大型编译工程经常产生大量临时文件我遇到过在嵌入式 SDK 全量编译时/tmp被塞满的情况改完 TMPDIR 之后就正常了。这类问题看起来吓人但解决思路很简单顺着报错里的路径去查空间和权限。5.6 链接阶段的 undefined reference前面的都还算编译问题链接阶段的undefined reference to xxx则是另一个高频坑。原因通常是链接时没有把对应库传进去或者库的链接顺序不对。静态库依赖遵循“被依赖者要放在依赖者后面”的规则比如gcc main.o -lfoo -o app如果main.o依赖libfoo.a这个顺序就没问题如果你写成-lfoo放在main.o前面某些链接器会把libfoo.a里的符号视为未引用而丢弃最后还是报 undefined reference。碰到这类问题我建议先把 Makefile 里的LDLIBS和LDFLAGS分开看LDLIBS放库LDFLAGS放库路径这样别人接手时更容易看懂。6. 面试与进阶把 make 聊明白的加分项6.1 面试官常问的几个问题make 和 Makefile 是 Linux 面试题里的常客虽然不是最核心的大题却非常能体现候选人的工程基础。我整理过几个高频问题供准备面试的朋友参考。第一个是“make 是怎么判断一个文件是否需要重新编译的”。标准答案就是比较目标文件和依赖文件的修改时间依赖比目标新或者目标不存在就执行配方。面试官听完通常还会追问“如果你改了头文件但 Makefile 没写依赖关系会怎样”这时候就要引出头文件依赖自动生成的知识点。第二个是“.PHONY是干什么的”。它能声明伪目标让 make 不检查同名文件是否存在始终执行配方。可以顺便举clean的例子。第三个是“$、$^、$分别代表什么”。这个属于最基础的自动变量必须脱口而出$是目标名$^是所有依赖$是第一个依赖。第四个稍微难点“Makefile 里、:、?、有什么区别”。这里核心是区分递归展开和简单展开。定义的变量在使用时才完成展开:定义时立刻展开?只在变量未定义时赋值是追加。我实际写项目时对路径这类不希望被二次重新计算的变量习惯用:能少踩一些隐蔽的坑。第五个问题关于依赖关系“你的 Makefile 如何自动跟踪头文件变化”。答案是用 GCC 的-MMD -MP选项生成 .d 依赖文件再在 Makefile 里-include它们。简单示例CFLAGS -MMD -MP -include $(OBJS:.o.d)这样编译器每次编译都会生成对应的.d文件里面记录了这个源文件实际引用的头文件列表make 下次构建时自动把它们当成依赖头文件改了也能触发重编。这套方案几乎零维护成本是我在多文件项目里最推荐的写法。6.2 继续前进的方向CMake、Ninja 和构建体系把 Makefile 聊明白之后下一步自然就会接触到 CMake。CMake 的作用是生成构建系统比如它可以生成 Makefile也可以生成 Ninja 的构建文件。很多大型开源项目选择 CMake是因为它的跨平台能力更强还能处理查找依赖库、生成安装包等复杂问题。但无论用 CMake、Ninja 还是 Meson底层的“目标、依赖、增量构建”思路和 make 是共通的所以你不要觉得自己学 make 是在学一个“老古董”它反而是理解现代构建体系最轻松的入口。如果你所在团队喜欢更快的构建Ninja 值得一试。Ninja 的设计目标就是快适合大项目增量编译。很多项目会先写 CMakeLists.txt然后让 CMake 生成 Ninja 文件再执行ninja。它和 make 并没有本质冲突更像是“用了同样思想但更偏执行效率”的替代品。至于 SCons、Meson 这些工具思想也都大同小异无非是语法不同、生态不同。我建议学习顺序是先手写 Makefile把一个中型项目完整构建出来再尝试用 CMake 重写一次最后再按需接触 Ninja。这样你对每层工具的价值会有更直接的体感。我自己在实际项目里的习惯是小工具、内部模块直接手写 Makefile三五个文件时它比任何构建系统都直观出了问题你也知道去哪改工程一旦开始要跨平台、要打包发布、有复杂的第三方依赖就切到 CMake。不要一上来就追求什么“通用模板”一个能跑、能增量编译、能 clean 的 Makefile 才是好 Makefile。构建系统没有银弹把最常用的这个搞清楚其他都只是换汤不换药。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑