1 背景为什么需要 make 和 Makefile刚开始学 Linux 下 C 语言编程时编译一个文件很简单$ gcc test.c-otest敲一行命令生成可执行文件test运行./test就完事了。但真实的项目不会只有一个文件。假设我们的项目长这样project/ ├── main.c ├── func.c └── func.h其中main.c调用了func.c里实现的函数两者都#include func.h。这时候编译命令变成$ gcc main.c func.c-oapp到这一步还算能接受。可问题在于——每改一次代码就要重新敲一遍这串命令。文件越多命令越长如果项目里有几十个.c文件这条命令会长到令人崩溃而且极容易漏掉某个文件导致链接报错。更麻烦的是手动敲 gcc 无法判断“哪些文件真的需要重新编译”。改一个func.cmain.c明明没动也被迫一起重新编译。小项目无所谓大项目里全量编译一次可能要几分钟甚至几十分钟开发效率被严重拖累。1.1 矛盾的本质把上面的痛点抽象一下其实是两个问题问题手动 gcc 的表现命令太长、易漏文件每次都要完整敲一遍所有源文件不知道该重编谁要么全量重编慢要么漏编错这两个问题的根源是我们脑子里清楚“谁依赖谁、改了什么该重编什么”但编译器不知道它只会老老实实执行你敲的命令。那能不能把这份“依赖关系 编译方法”写进一个文件交给工具去判断和执行1.2 make 与 Makefile 的分工答案就是make和MakefileMakefile一个文本文件用来描述项目里“谁依赖谁、每个目标怎么生成”。它是“说明书”。makeLinux 下的一条命令用来读取Makefile按里面写的规则自动判断该编译什么、该跳过什么然后执行对应的命令。它是“执行者”。二者配合就实现了自动化构建你只需要敲一个make剩下的“判断哪些文件过期、按什么顺序编译、执行哪些命令”全部交给工具完成。用一句话概括 Makefile 的本质Makefile 依赖关系 依赖方法。依赖关系目标文件依赖哪些源文件谁需要谁。依赖方法用什么命令从依赖生成目标怎么生成。后面所有内容——基本语法、推导栈、伪目标、时间戳——都是围绕这两个概念展开的。1.3 本篇要解决的问题读完本篇你应当能够看懂并手写一个多文件 C 项目的 Makefile理解 make 是如何沿着依赖链“推导”出编译顺序的说清楚 make 为什么能“只编译改过的文件”时间戳机制会用.PHONY、变量、模式规则把 Makefile 写得更专业。2 依赖关系与依赖方法上一节我们知道了 Makefile 的本质是“依赖关系 依赖方法”。这一节就把这两个概念拆开讲透——它们是整个 Makefile 的地基后面所有语法、推导、伪目标都建立在它们之上。2.1 Makefile 的基本结构一个 Makefile 由若干条规则rule组成每条规则的基本格式固定为target: prerequisitesTabcommand拆开看只有三部分target目标要生成的文件比如可执行文件app、目标文件main.o。prerequisites依赖生成 target 所需要的文件比如main.c、func.c。command命令从依赖生成目标的具体命令比如gcc main.c -o main.o。2.2 依赖关系谁需要谁依赖关系写在冒号左边和右边回答的是“target 依赖 prerequisites”这个问题。以多文件项目为例project/ ├── main.c ├── func.c └── func.h最终的可执行文件app是由main.o和func.o链接出来的所以app: main.o func.o这一行只表达了一件事要得到app必须先有main.o和func.o。 它没有说怎么生成只说了“谁需要谁”。依赖关系可以有多层。main.o又依赖main.cmain.o: main.cfunc.o依赖func.cfunc.o: func.c把这些依赖关系串起来就形成了一条依赖链app → main.o → main.c → func.o → func.c这条链就是 make 判断“先编译谁、后链接谁”的依据。2.3 依赖方法怎么生成只有依赖关系make 只知道“谁需要谁”但不知道“怎么把依赖变成目标”。这就需要依赖方法——写在规则下面、以Tab开头的那一行命令。例如生成app的方法app: main.o func.o gcc main.o func.o-oapp生成main.o的方法main.o: main.c gcc-cmain.c-omain.o生成func.o的方法func.o: func.c gcc-cfunc.c-ofunc.o把三条规则拼在一起就是一个能用的 Makefileapp: main.o func.o gcc main.o func.o-oapp main.o: main.c gcc-cmain.c-omain.o func.o: func.c gcc-cfunc.c-ofunc.o在项目目录下执行makemake 会读取这份文件按依赖链依次执行命令最终生成app。2.4 二者缺一不可依赖关系和依赖方法是 Makefile 的两条腿少一条都跑不起来情况结果只有依赖关系没有依赖方法make 知道要生成app但不知道用什么命令生成报错No rule to make target只有依赖方法没有依赖关系make 不知道该在什么条件下执行这条命令规则无法被正确触发用一个通俗的比喻依赖关系是“我是你儿子”身份依赖方法是“打钱”动作。光有身份不动作钱不会到账光有动作没有身份对方根本不知道你是谁。所以写 Makefile 时每一条规则都要同时问自己两个问题这个目标依赖谁依赖关系用什么命令生成它依赖方法两个答案都写清楚这条规则才完整。2.5 第一个高频坑命令前必须是 Tab这是初学者写 Makefile 时最常见、也最让人抓狂的报错必须提前说清楚依赖方法那一行开头必须是 Tab 键不能是空格。如果用了空格make 会报Makefile:2: *** missing separator. Stop.missing separator的意思就是“缺少分隔符”——make 没认出这一行是命令因为它期待的是 Tab。3 基本语法第 2 节把“依赖关系 依赖方法”这条公式讲清楚了但那是单条规则的视角。一个真实的 Makefile 由若干条规则组成本节就来讲规则怎么组织、make 默认执行哪条、以及写好一个完整 Makefile 需要知道的几个基础语法点。3.1 规则是 Makefile 的基本单元一个 Makefile 由一条或多条规则rule组成每条规则的结构就是第 2 节讲过的target: prerequisitesTabcommand多条规则并列写在一起就构成一个完整的 Makefile。比如一个三文件项目可以写成三条规则app: main.o func.o gcc main.o func.o-oapp main.o: main.c gcc-cmain.c-omain.o func.o: func.c gcc-cfunc.c-ofunc.o这三条规则分别负责链接出app、编译出main.o、编译出func.o。规则之间通过依赖关系app依赖main.o和func.o串起来形成一个整体。3.2 默认目标make 不带参数时执行谁Makefile 里往往有多条规则但直接敲make不带任何参数时make 并不会把所有规则都执行一遍而是只执行第一条规则的目标以及它依赖链上需要的规则。以上面的 Makefile 为例第一条规则是app: main.o func.o所以make等价于make app。make 会以app为起点沿着依赖链找到main.o、func.o的生成规则依次执行最终链接出app。这个默认规则带来一个写 Makefile 的约定把最终要生成的可执行文件对应的规则写在最上面。如果顺序写反把main.o的规则放在第一行那make就只会编译main.o不会链接出app。怎么执行其他目标显式指定目标名即可$makeapp# 构建 app默认目标等价于直接 make$makemain.o# 只构建 main.o$makeclean# 执行 clean 规则后面讲伪目标时会用到3.3 注释Makefile 里用#表示注释从#开始到行尾的内容都会被 make 忽略# 这是注释make 不会执行app: main.o func.o# 行尾也可以写注释gcc main.o func.o-oapp注释适合用来标注规则用途、说明变量含义或者临时屏蔽某条规则。写工程化的 Makefile 时养成给关键规则和变量加注释的习惯别人和几个月后的自己读起来会轻松很多。3.4 换行与续行当一条命令或一行依赖太长时可以用反斜杠\续行make 会把下一行拼接上来app: main.o func.o\utils.o log.o gcc main.o func.o utils.o log.o-oapp注意\后面不能有空格否则反斜杠会被当成普通字符续行失效。依赖列表太长时这种写法很常见能让 Makefile 保持可读的宽度。3.5 多规则的组织顺序其实不重要回到 3.1 的三条规则它们的书写顺序是app→main.o→func.o。但如果你把顺序打乱比如写成main.o: main.c gcc-cmain.c-omain.o app: main.o func.o gcc main.o func.o-oapp func.o: func.c gcc-cfunc.c-ofunc.o第一行规则变成了main.o按 3.2 的说法make的默认目标就变成了main.o只会编译 main.o。默认目标确实由第一条规则决定。示例$lsfunc.c func.h main.c Makefile $makegcc-cmain.c-omain.o $lsfunc.c func.h main.c main.o Makefile但如果你显式执行make app或者把app规则放回第一行、只打乱后面两条的顺序app: main.o func.o gcc main.o func.o-oapp func.o: func.c gcc-cfunc.c-ofunc.o main.o: main.c gcc-cmain.c-omain.omake 依然能正确执行。它不会按“从上到下”的顺序傻执行而是从app出发发现依赖main.o和func.o再回过头去 Makefile 里找生成它们的规则——找到哪条就执行哪条和规则写在第几行无关。这就引出了一个关键问题make 到底是怎么沿着依赖链找到该执行的规则的这就是下一节“推导栈”要讲的内容。3.6 一个完整的多文件示例讲完基本语法先搭一个能直接跑通的多文件项目后面推导栈、伪目标、时间戳都基于它。项目结构project/ ├── main.c ├── func.c ├── func.h └── Makefilefunc.h#includestdio.hintadd(inta,intb);func.c#includefunc.hintadd(inta,intb){returnab;}main.c#includefunc.hintmain(){printf(add(1, 2) %d\n,add(1,2));return0;}Makefileapp: main.o func.o gcc main.o func.o-oapp main.o: main.c gcc-cmain.c-omain.o func.o: func.c gcc-cfunc.c-ofunc.o运行$makegcc-cmain.c-omain.o gcc-cfunc.c-ofunc.o gcc main.o func.o-oapp $ ./app add(1,2)3到这里一个最基础、能跑通的多文件 Makefile 就完成了。它没有用任何变量、没有模式规则、没有伪目标但依赖关系和依赖方法都是完整的——这正是下一节推导栈的最佳素材。4 推导栈make 如何运行第 3 节末尾留了一个问题把 Makefile 里的规则顺序打乱make 为什么还能正确执行它到底是怎么沿着依赖链找到该执行的规则的这一节就用 3.6 的例子走一遍 make 的完整解析过程。理解了这个过程Makefile 里很多“看起来反直觉”的行为都会变得理所当然。4.1 从默认目标出发先回到 3.6 的 Makefileapp: main.o func.o gcc main.o func.o-oapp main.o: main.c gcc-cmain.c-omain.o func.o: func.c gcc-cfunc.c-ofunc.o执行make时make 做的第一件事是找到默认目标。按第 3 节讲的规则默认目标是第一条规则的目标也就是app。默认目标appmake 以app为起点开始检查它的依赖main.o和func.o。4.2 递归检查依赖make 检查一个依赖时会问两个问题这个依赖文件存在吗如果存在它比目标新吗时间戳比较下一节会详细讲对main.o如果main.o不存在或者比app新说明需要重新生成它make 会去 Makefile 里找一条能生成main.o的规则找到main.o: main.c于是 make 暂时“挂起”app的处理转去执行main.o的规则。对func.o同理找到func.o: func.c转去执行。这个“挂起当前目标、转去处理依赖”的过程就是递归。用调用栈的视角看执行顺序深度优先后序遍历依赖树 1. app 入栈发现依赖 main.o、func.o先不执行 2. main.o 入栈依赖 main.cmain.c 是源文件到达叶子 3. main.o 回溯执行 gcc -c main.c -o main.o出栈 4. func.o 入栈依赖 func.c到达叶子 5. func.o 回溯执行 gcc -c func.c -o func.o出栈 6. app 回溯执行 gcc main.o func.o -o app出栈实际执行时是深度优先的先把main.o这一支处理完编译出main.o再处理func.o这一支编译出func.o最后回到app执行链接。所以执行顺序是gcc-cmain.c-omain.o gcc-cfunc.c-ofunc.o gcc main.o func.o-oapp4.3 为什么规则顺序不重要理解了推导栈3.5 节留下的问题就有答案了。make 不是“从上到下逐条执行规则”而是从默认目标出发按依赖链去 Makefile 里搜索对应的规则。搜索是按目标名匹配不是按行号顺序。所以把main.o和func.o的规则顺序对调make 依然能找到它们app: main.o func.o gcc main.o func.o-oapp func.o: func.c gcc-cfunc.c-ofunc.o main.o: main.c gcc-cmain.c-omain.o执行make输出依然是gcc-cmain.c-omain.o gcc-cfunc.c-ofunc.o gcc main.o func.o-oapp唯一受顺序影响的是默认目标——它永远是第一条规则的目标。所以写 Makefile 的惯例是把最终可执行文件的规则放在最上面其他的随便排。这里容易引出一个误解既然规则顺序不重要那是不是可以随便乱写不是。顺序不影响“能不能执行”但影响“先看哪条、好不好读”。依赖链本身是有方向的app依赖.o.o依赖.c按依赖链从上层到下层写符合人的阅读习惯反过来写虽然能跑但读起来别扭。所以顺序自由是 make 给的按依赖链写是给人看的。4.4 依赖不存在时会怎样推导栈还有一个重要行为如果某个依赖既不存在也找不到生成它的规则make 会直接报错。比如把main.c删掉再执行makemake: *** No rule to make target main.c, needed by main.o. Stop.意思是要生成main.o需要main.c但main.c不存在Makefile 里也没有生成main.c的规则推导链断了只能停止。这个报错在排查问题时很有用——它精确指出了依赖链在哪一环断掉顺着报错里的文件名去检查通常能很快定位问题。5 伪目标前面讲的规则目标都是实实在在的文件app、main.o、func.o。但实际开发中有一类目标它并不生成文件只代表“执行某个动作”——最典型的就是clean。5.1 先写一个 clean 规则清理编译产物的规则通常长这样clean:rm-fapp main.o func.o执行make clean就会删掉所有产物。看起来没问题但它藏着一个坑。5.2 坑目录下恰好有个叫 clean 的文件make 判断一条规则要不要执行依据是目标文件和依赖文件的时间戳。clean规则没有依赖make 就只看目标clean是否存在如果当前目录下没有clean这个文件规则正常执行如果当前目录下恰好有一个叫clean的文件make 会认为“目标已存在且没有更新的依赖”直接提示make: clean is up to date.$lsclean func.c func.h main.c Makefile $makeclean make:cleanis up to date.清理命令根本不执行。这就是伪目标要解决的问题。5.3 用 .PHONY 声明伪目标在规则前加一行.PHONY.PHONY: clean clean:rm-fapp main.o func.o.PHONY: clean的意思是clean是一个伪目标不对它做文件存在性和时间戳判断每次调用都执行命令。加上之后再执行make clean不管目录里有没有叫clean的文件删除命令都会执行。5.4 小结伪目标 不生成文件、只执行动作的目标用.PHONY: 目标名声明声明后总是执行不受同名文件干扰常见伪目标clean、all、install、rebuild。6 三个时间第 4 节提到make 检查依赖时会问“它比目标新吗”。这个“新”是怎么判断的答案在文件的时间戳里。6.1 三个时间分别是什么Linux 下每个文件都有三个时间用stat命令查看$statmain.c File: ‘main.c’ Size:110Blocks:8IO Block:4096regularfileDevice: fd01h/64769d Inode:655833Links:1Access:(0664/-rw-rw-r--)Uid:(1000/ xc)Gid:(1000/ xc)Access:2026-10-2421:56:22.956606929 0800 Modify:2026-10-2421:56:22.953606929 0800 Change:2026-10-2421:56:22.953606929 0800 Birth: -时间含义英文atime最后一次访问文件内容的时间Access Timemtime最后一次修改文件内容的时间Modify Timectime最后一次修改文件属性权限、inode 等的时间Change Time6.2 make 只看 mtimemake 判断“依赖是否比目标新”比较的是依赖的 mtime和目标的 mtime依赖的 mtime晚于目标的 mtime → 源文件改过了需要重新构建依赖的 mtime早于或等于目标的 mtime → 目标已是最新跳过提示up to date。make 不看 atime也不看 ctime。 比如chmod x main.c会改变 ctime但 mtime 不变此时 make 不会重新编译——这能直观证明 make 的判据是 mtime。$lsfunc.c func.h main.c Makefile $makegcc-cmain.c-omain.o gcc-cfunc.c-ofunc.o gcc main.o func.o-oapp $lsapp func.c func.h func.o main.c main.o Makefile $makemake:appis up to date. $chmodx main.c $lsapp func.c func.h func.o main.c main.o Makefile $makemake:appis up to date.6.3 验证实验$lsapp func.c func.h func.o main.c main.o Makefile $makemake:appis up to date. $touchmain.c# 更新 main.c 的 mtime$make# 重新编译 main.o 并重新链接gcc-cmain.c-omain.o gcc main.o func.o-oapptouch不修改文件内容只更新 mtime就能触发 make 重新构建——这正好说明 make 看的是时间戳不是内容差异。7 语法细节语法拓展到这儿一个能跑、能正确判断重编的 Makefile 已经写出来了。但它还有几个明显的问题源文件列表是手写的、每个.c都要单独写一条编译规则、链接和编译的命令有大量重复。这一节讲几个让 Makefile 更专业、更好维护的语法点最后用一个完整的工程化例子把这些点串起来。7.1 变量把重复的东西抽出来先看 3.6 的基础版 Makefile里面gcc、main.o、func.o这些字眼反复出现app: main.o func.o gcc main.o func.o-oapp main.o: main.c gcc-cmain.c-omain.o func.o: func.c gcc-cfunc.c-ofunc.o如果想把编译器从gcc换成g或者加一个-Wall编译选项就要改多处容易漏。用变量可以解决CCgcc CFLAGS-Wall-gTARGETapp OBJmain.o func.o用$(变量名)引用$(TARGET):$(OBJ)$(CC)$^-o$这样换编译器只改CC一行加编译选项只改CFLAGS一行。7.2 自动变量让命令不用写死文件名7.1 用变量解决了“编译器、目标名反复出现”的问题但规则里的命令本身还是写死的$(TARGET):$(OBJ)gcc main.o func.o-oappmain.o func.o和app还是硬编码。如果OBJ变了比如新增了utils.o这条命令也得跟着改。自动变量就是用来消除这种硬编码的——它由 make 在规则执行时自动填入当前规则的目标和依赖变量含义示例规则app: main.o func.o$目标名冒号左边app$^所有依赖文件去重main.o func.o$第一个依赖文件main.o用它们改写规则$(TARGET):$(OBJ)$(CC)$^-o$这条命令展开后是gcc main.o func.o -o app但写的是“把所有依赖编译成目标”。OBJ变了命令不用改——这是后面模式规则能成立的前提。7.3 模式规则一条规则搞定所有 .c → .o基础版里每个.c文件都要手写一条编译规则main.o: main.c gcc-cmain.c-omain.o func.o: func.c gcc-cfunc.c-ofunc.o文件一多这些规则就是纯复制粘贴。模式规则用%通配一条顶所有%.o: %.c$(CC)$(CFLAGS)-c$-o$%.o: %.c的意思是“任何一个.o都可以由同名的.c生成”。make 需要main.o时自动匹配到main.c展开命令为gcc -Wall -g -c main.c -o main.o需要func.o时同理。新增utils.c这条规则自动适用不用改 Makefile。7.4 Makefile 函数wildcard 与 shell7.1 里OBJ main.o func.o还是手写的新增源文件就要手动加。Makefile 内置了函数可以在“读 Makefile 的阶段”动态生成变量值。最常用的两个wildcard获取匹配的文件列表。SRC$(wildcard *.c)这一行等价于“把当前目录下所有.c文件名列出来赋给SRC”。SRC main.c func.c以及以后新增的任何.c自动就位不用手写。shell执行 shell 命令并取回输出。SRC$(shellls*.c)效果和wildcard类似但wildcard是 Makefile 内置函数不依赖 shell更推荐。shell适合执行pwd、date这类需要外部命令的场景。注意区分$(wildcard *.c)是 Makefile 函数$(shell ls *.c)是调用 shell。前者更轻、更可移植收集源文件时优先用wildcard。7.5 变量替换从 SRC 推出 OBJ有了SRCOBJ也不用手写可以用变量替换直接推出来OBJ$(SRC:.c.o)语法是$(变量:旧后缀新后缀)意思是“把SRC里每个以.c结尾的文件名替换成.o”。比如SRCmain.c func.c utils.c OBJmain.o func.o utils.o这样新增一个utils.cwildcard自动把它收进SRC变量替换自动把它变成utils.o放进OBJ规则完全不用动。这是整份 Makefile 能做到“新增文件零修改”的关键一步。7.6 命令前的 不回显命令本身默认情况下make 执行每条命令前会先把命令打印出来gcc-cmain.c-omain.o gcc-cfunc.c-ofunc.o gcc main.o func.o-oapp命令多了会刷屏。在命令前加make 就只执行、不打印命令本身$(CC)-o$$^配合echo打印自定义进度提示$(CC)-o$$^ echolinking $^ to$... done输出变成linking main.o func.o to app ... done比一屏命令清爽得多。注意echo那一行本身也要加否则echo命令自身又会被打印出来。7.7 完整工程化模板把上面所有语法点合起来就是一份可以直接用于多文件 C 项目的 MakefileTARGETapp SRC$(wildcard *.c)OBJ$(SRC:.c.o)CCgcc EchoechoRmrm-rf$(TARGET):$(OBJ)$(CC)-o$$^ $(Echo)linking $^ to$... done%.o: %.c $(CC)-c$$(Echo)compiling $ to$... done.PHONY: clean clean:$(Rm)$(OBJ)$(TARGET).PHONY:testtest: echoDebug-------echo$(SRC)echoDebug-------echo$(OBJ)echoDebug-------逐行说明TARGET app最终可执行文件名SRC $(wildcard *.c)自动收集当前目录所有.c文件OBJ $(SRC:.c.o)由 SRC 推出所有.o文件名CC、Echo、Rm把常用命令抽成变量方便统一替换$(TARGET): $(OBJ)链接规则$展开为app$^展开为所有.o%.o: %.c模式规则一条搞定所有.c → .o命令前不回显命令本身只显示echo的进度提示.PHONY: clean/.PHONY: test声明伪目标总是执行test目标打印SRC和OBJ的内容用来验证wildcard和变量替换是否推导正确。写复杂 Makefile 时这种“调试目标”能帮你快速确认变量值是否符合预期。验证实验$lsfunc.c func.h main.c Makefile $makecompling main.c to main.o...donecompling func.c to func.o...donelinking main.o func.o to app...done$maketestDebug----------- main.c func.c Debug----------- main.o func.o Debug----------- $lsapp func.c func.h func.o main.c main.o Makefile $makecleanrm-rfmain.o func.o app $lsfunc.c func.h main.c Makefile这份 Makefile 的核心优势新增一个utils.cSRC自动收录OBJ自动推导出utils.o模式规则自动编译链接规则自动把它链进去——一行都不用改。这就是从“能跑”到“好维护”的跨越。8 总结到这里make 和 Makefile 的核心内容就讲完了。回顾一下全文的脉络8.1 核心结论Makefile 依赖关系 依赖方法。依赖关系回答“谁需要谁”依赖方法回答“怎么生成”二者缺一不可。make 从默认目标第一条规则的目标出发按目标名匹配规则后序遍历依赖树执行命令。规则书写顺序不影响执行结果只影响默认目标。伪目标用.PHONY声明绕过时间判断总是执行典型应用是clean。make 只比较mtime实现“只编译改过的文件”。依赖链决定“谁该先编译”mtime 决定“谁需要重新编译”。变量、自动变量、模式规则、wildcard和变量替换让 Makefile 从“能跑”升级到“新增文件零修改”的通用模板。8.2 一句话收尾Makefile 是 Linux 下工程管理的必修课。初期手写几遍理解依赖链和时间戳后期项目变大可以直接用 CMake 等工具自动生成 Makefile——但底层逻辑仍然是这一篇讲的“依赖关系 依赖方法”。希望这篇文章对你有帮助。本文代码环境LinuxCentOS 7/ gcc make完整 Makefile 模板见 7.7 节可直接复制使用如果本文对你有帮助欢迎点赞、收藏、关注——你的支持是我持续更新的最大动力如有疑问欢迎在评论区留言讨论~
阅读完成 · 觉得有帮助?