做Linux环境下的C/C开发make这个命令迟早要碰上。我第一次接触Makefile的时候觉得这不就是个自动化编译工具嘛后来项目越做越大、文件越堆越多才真正体会到它存在的意义把重复的编译命令变成一套规则让机器替你做增量构建你只负责改代码和敲make。这篇文章就围绕make/Makefile这套项目自动化构建工具把核心思路、语法细节、实操步骤还有我在真实工程里踩过的坑都摆出来希望对正在学Linux、或者在项目里被编译问题折磨的同学有点用。1. 为什么需要make从手动编译到自动化构建1.1 手动编译的痛点假设你正在写一个稍微像样点的项目比如一个简单的网络聊天室文件结构大概是这样的server.c client.c common.h utils.c utils.h没有make之前每次改完代码重新编译你要敲的命令大概是这样gcc -Wall -g -c utils.c -o utils.o gcc -Wall -g -c server.c -o server.o gcc -Wall -g -c client.c -o client.o gcc -Wall -g utils.o server.o server.o -o server gcc -Wall -g utils.o client.o client.o -o client这还只是个五个文件的玩具项目如果其中有文件依赖关系没理清楚编译顺序错了就是一堆未定义引用错误看得人头皮发麻。再往后文件到几十个、上百个的时候手动敲命令这件事本身就变成了效率黑洞。你每次编译都在重复机械劳动还要小心别敲错文件名、别漏掉某个依赖库。1.2 make解决的核心问题make的出现就是为了解决上面这一整摊子事。它通过解析Makefile中定义的规则帮你完成三个最关键的工作第一是自动化构建。你只需要敲一个make命令它会按照规则自动找到目标文件、自动执行对应的编译命令不需要你记住整个项目的编译流程。第二是增量编译。make会检查目标文件和依赖文件的时间戳只有当一个文件的依赖比目标文件更新的时候它才会重新编译这个目标。也就是说你改了utils.c它就只重新编utils.o再重新链接其余没改动的文件直接跳过。这在大型项目中能省掉大量编译时间。第三是依赖关系管理。每个源文件包含哪些头文件、哪个目标文件需要哪些依赖这些关系写进Makefile里make就能按正确的顺序编译避免出现“用旧头文件编译出来的目标文件”这种隐性问题。我记得第一次用make的时候最大的感受就是原来编译这件事也能“声明式”地做。你告诉make“我要什么结果、依赖什么、怎么做”剩下的事情它帮你按部就班完成。这种声明式的构建思路其实和后来接触的CMake、Ninja、CI流水线都是一脉相承的。1.3 什么样的项目适合引入make如果你只是写一个单文件的C程序测试那直接gcc编译其实更方便引入make反而是杀鸡用牛刀。但一旦项目满足以下任何一个条件就值得引入make源文件超过三个且存在明确的依赖关系需要区分Debug和Release两种编译配置编译命令比较复杂涉及多个库、头文件路径、宏定义有其他开发者要接手这个项目需要一个标准化的构建入口我自己习惯的做法是哪怕项目再小只要超过两个源文件就顺手写个简单Makefile。成本极低但后续每次编译都能省下大量重复劳动。2. Makefile核心语法与设计思路2.1 规则的三要素目标、依赖、命令Makefile的基本结构其实特别简单核心就是一条规则rule长这样目标文件: 依赖文件列表 命令1 命令2这一块包含三个要素目标target、依赖prerequisites和命令recipe。目标通常是你要生成的文件名比如hello、server.o也可以是一个伪目标比如clean、install伪目标不产生实际文件后面会单独讲。依赖是构建这个目标之前必须存在的文件或者必须执行的目标。命令则是真正干活的部分通常是shell命令。这里有个容易让新手栽跟头的点命令前面必须是一个Tab键不能是四个空格也不能是八个空格。make对缩进非常敏感凡是命令行的缩进不对就会报missing separator错误。我第一次写Makefile的时候被这个坑卡了半小时怎么都找不到原因后来才发现是编辑器默认把Tab转换成了空格。一个最简单的Makefile长这样hello: main.c gcc -o hello main.c执行make的时候make会检查当前目录下有没有hello这个文件以及main.c是不是比hello更新。如果hello不存在或者main.c比hello新它就会执行gcc命令重新生成hello。2.2 变量让Makefile“活”起来写死文件名和命令是最初级的用法实际项目里几乎不会这么写。你迟早要用变量来抽象编译参数和文件列表。Makefile里面的变量其实类似C语言里的宏替换定义和引用的方式很简单CC gcc CFLAGS -Wall -g -O2 TARGET myapp SRCS main.c utils.c OBJS $(SRCS:.c.o) $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $(TARGET) $(OBJS)这里面CC是编译器CFLAGS是编译选项TARGET是最终的可执行文件名SRCS是源文件列表而OBJS则是把SRCS中所有.c替换成.o之后得到的目标文件列表。这样一写以后想换编译器或者加编译选项只需要改一行不用动规则本身。2.3 自动变量写规则时的偷懒神器在规则命令中写依赖文件名时最烦人的是名字一变命令里的文件名也要跟着变。为了彻底解决这个问题make内置了一套自动变量$表示当前规则的目标文件名$^表示当前规则的所有依赖文件列表以空格分隔$表示当前规则的第一个依赖文件之前那条规则可以改写成$(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^如果目录里还有一堆.c文件需要单独编译成.o通常会配合模式规则来写。模式规则用%做通配符一次搞定一类文件%.o: %.c $(CC) $(CFLAGS) -c $ -o $这条规则的意思是任何一个.o文件都由对应的.c文件编译出来。加上自动变量之后就算项目新增了100个源文件你也不需要为每个文件写一条编译规则了。2.4 伪目标那些不产生文件的目标有些目标并不是真的要生成一个同名文件而是执行一些辅助操作比如清理编译产物、安装程序。如果你直接写clean: rm -f $(OBJS) $(TARGET)然后在当前目录下恰好存在一个叫clean的文件make会认为clean这个目标已经是最新的直接告诉你“无事可做”。为了避免这种歧义要把clean声明为伪目标.PHONY: clean clean: rm -f $(OBJS) $(TARGET)用.PHONY声明过的目标make不会去检查是否存在同名文件而是无条件执行命令。我习惯把所有辅助目标全部加上.PHONY包括clean、install、test、format宁可多写几行也不让它被同名文件绊住。2.5 Makefile的搜索路径与终极简化如果不想在每个规则里写全路径make还有个VPATH机制用它指定依赖文件的搜索目录VPATH src include这样make在找不到文件时会自动去src和include目录下找。和VPATH配合常用的还有$(wildcard)函数它能自动把当前目录下所有匹配的文件展开成列表SRCS : $(wildcard src/*.c) OBJS : $(patsubst %.c,%.o,$(SRCS))$(wildcard src/*.c)会返回src目录下所有.c文件的完整列表$(patsubst)则是把列表里每个.c替换成.o。这两招组合使用能让Makefile在文件增删时几乎不需要改动。我真正确认make值得认真学是在写完上面这几行之后——一个上百文件的C项目Makefile核心部分只有十几行整个编译、清理流程清晰可见这种掌控感是手动敲编译命令完全给不了的。3. 手写一个完整Makefile从入门到实操3.1 准备一个测试项目纸上谈兵不如动手跑一遍我们从一个最简单的项目开始。假设目录结构如下demo/ ├── Makefile ├── include/ │ └── utils.h ├── src/ │ ├── main.c │ └── utils.c三个文件内容也很简单main.c调用utils.c里提供的函数utils.h声明这个函数。3.2 第一版菜鸟级Makefile我们先不管目录结构先用最笨的写法把它编出来确认功能正常main: src/main.o src/utils.o gcc -Iinclude -o main src/main.o src/utils.o src/main.o: src/main.c include/utils.h gcc -Iinclude -c src/main.c -o src/main.o src/utils.o: src/utils.c include/utils.h gcc -Iinclude -c src/utils.c -o src/utils.o .PHONY: clean clean: rm -f main src/*.o这个版本能跑但很明显有重复劳动编译命令写了两遍文件路径也写了无数遍要是再加两个源文件就得再手写两条规则。不过对新手来说能跑通比写得好更重要。3.3 第二版用变量和模式规则优化接下来用上面讲的变量和模式规则优化CC gcc CFLAGS -Wall -g -Iinclude TARGET main SRCS src/main.c src/utils.c OBJS $(SRCS:.c.o) $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^ %.o: %.c $(CC) $(CFLAGS) -c $ -o $ .PHONY: clean clean: rm -f $(TARGET) $(OBJS)试一下执行流程makemake会自动找到src/main.c和src/utils.c为它们生成.o文件然后链接出main。整个过程的中间产物都在src目录下看起来也整洁。加上-Wall -g调试信息和警告一目了然。这里有一点值得注意$(SRCS:.c.o)这种写法是make的替换引用只对末尾匹配的字符串替换如果路径是src/main.c替换结果就是src/main.o路径自然保留不会出错。3.4 第三版自动收集源文件现在的Makefile仍然需要手动列出SRCS。新加一个文件就要改一次Makefile这很烦。用wildcard自动收集CC gcc CFLAGS -Wall -g -Iinclude TARGET main SRCS $(wildcard src/*.c) OBJS $(SRCS:.c.o) $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^ %.o: %.c $(CC) $(CFLAGS) -c $ -o $ .PHONY: clean clean: rm -f $(TARGET) $(OBJS)现在src目录下新增一个.c文件不需要改Makefilemake会自动把它编译进可执行文件。但使用wildcard也有个隐藏陷阱如果src目录是空的$(wildcard src/*.c)会返回空字符串OBJS也会是空的make会报“没有明确的目标”。这种情况在空仓库clone下来第一次编译时很容易遇到解决办法通常是确保src目录下至少有一个.c文件或者用别的手段给SRCS一个保底值。3.5 多目录项目的Makefile结构当项目越来越大模块拆分成多个目录比如src/main、src/net、src/common上面的写法就不够用了。我比较常用的一种多目录结构是每层目录放一个Makefile或者用递归make的方式。递归make的思路是顶层Makefile调用每个子目录的Makefile每个子目录负责编译自己目录下的目标文件。假设demo下有app和lib两个子目录all: $(MAKE) -C app $(MAKE) -C lib .PHONY: all clean clean: $(MAKE) -C app clean $(MAKE) -C lib clean这里的关键是用$(MAKE)而不是make这样会继承顶层Makefile的变量和参数比如你执行make -j8递归下去的子make也会带-j8参数并行编译的效率才能发挥出来。递归make的优点是每个子目录独立构建隔离性好清理也方便。缺点是全局依赖管理偏弱如果跨目录的头文件变了可能不会触发下游模块重新编译。对中小规模项目来说递归make完全够用C项目如果用到复杂依赖再考虑CMake也不迟。4. make命令的高级用法与工程化技巧4.1 并行编译make -j大型项目编译一次动辄几分钟起步单核编译会让人等到怀疑人生。make天然支持并行编译只要在执行时加个参数make -j4-j后面跟的是同时执行的命令数量一般取CPU核心数的1.5到2倍比较合适。我自己的经验是8核机器用-j1616核的机器用-j24实测下来编译速度能快好几倍。不过并行编译也有它的坑如果你的Makefile里命令之间隐式存在先后依赖关系但规则里没有正确声明依赖并行执行时就会出问题。比如一条规则生成文件A另一条规则依赖A如果第一条规则的命令还没跑完第二条就已经开始执行那结果就是找不到文件或者用了半个生成的文件。解决办法只有一条把所有真实的依赖关系都写清楚别指望make自动知道。4.2 调试技巧make -n和make -B写Makefile和写普通代码一样也会犯错。排查的时候有几个好用的参数make -n只打印要执行的命令不真正执行相当于“空跑看剧本”。在改动规则后想看执行顺序是否正确用这个再合适不过。make -B强制重新构建忽略时间戳判断所有目标都重新编译。改了头文件但某个依赖忘了写明的时候可以用它验证是不是依赖写漏了。make -d输出make的调试信息包含它在找哪个文件、比较什么时间戳、为什么决定执行或跳过。调试复杂依赖时很管用但输出会非常长。4.3 include把公共配置拆出去如果你在一台机器上同时维护好几个项目它们的编译选项可能高度重合比如都用了同样的-Wall、-O2、同一个第三方库路径。这种情况下可以把公共配置抽到一个公共文件里比如common.mk# common.mk CC gcc CFLAGS -Wall -O2 -I/path/to/common/include LDFLAGS -L/path/to/common/lib -lmylib然后在项目Makefile里引入它include ../common/common.mk这样公共配置一改所有项目生效不用每个项目单独改。还有一类文件也适合用include引入就是自动生成的依赖文件。很多项目会用gcc -MM生成每个.c文件对应的.d依赖文件再include进来这样头文件更新时所有依赖它的源文件都会准确重编。这个技巧在“头文件修改后不重编”的问题上特别好用。4.4 条件判断与多配置构建同一个项目Debug版本和Release版本的编译选项往往不同。可以用变量覆盖实现ifeq ($(DEBUG), 1) CFLAGS -g -O0 -DDEBUG else CFLAGS -O2 -DNDEBUG endif编译Debug版就执行make DEBUG1编译Release就正常make默认走else分支。这里用追加选项是因为:赋值如果重复执行会有覆盖问题。实际工程里我还会把目标名区分开比如生成bin_debug和bin_release两个可执行文件避免切换配置时光清理上一版产物就很烦。4.5 命令前的小符号和-Makefile命令默认会回显一行命令内容再执行它的输出。如果你不想让命令本身被打印出来在前面加clean: echo Cleaning... rm -f $(TARGET) $(OBJS)试一下执行make clean终端只会输出Cleaning...而不会把rm那条命令本身显示出来。这个在写“给使用者看的提示信息”时非常有仪式感。命令前加-则不同它表示即使这条命令失败也继续执行后续命令不认为构建失败。比如clean: -rm -f $(OBJS) -rm -f $(TARGET)rm删除不存在的文件会返回非零退出码如果没加-make会认为clean失败。加上-之后就算文件本来就不存在也不会中断。不过现在更推荐的做法是把rm改成rm -f因为-f本身就会忽略“文件不存在”的情况也不需要再额外加-。4.6 环境变量、命令行变量与覆盖优先级make里变量的来源有好几个环境变量、Makefile内部定义、命令行输入。它们之间的优先级很容易把人绕晕我直接告诉你结论命令行变量优先级最高其次是Makefile里显式定义的变量最后才是环境变量。这就是为什么很多项目的默认配置写在Makefile里但你可以执行make CFLAGS-O3来临时覆盖它。除非Makefile里用override修饰了变量否则命令行一定赢override CFLAGS -DSPECIALoverride不常用但如果你写的是一个对外发布的库Makefile不希望用户通过命令行误改掉某个关键变量override就是你的保护锁。5. 常见问题与排查技巧实录5.1 “make: *** No targets specified and no makefile found”这是新手最容易撞上的错误之一。字面意思是make没找到Makefile文件也没在命令行指定目标。排查思路很简单当前目录下有没有Makefile或makefile文件名得一字不差make默认找的是GNUmakefile、makefile、Makefile这三个名字。文件是不是真的在当前目录如果Makefile放在src目录下而你当前在根目录执行make自然找不到。是否误拼成了MakeFile或MAKEFILELinux文件名大小写敏感make只认那几个默认名。是否执行make时指定了错误的-f参数make -f mybuild.mk能指定任意文件名但路径要写对。顺便说一句如果你确实不知道怎么下手先确认自己在哪pwd看一眼当前目录ls -la看一眼有没有Makefile文件。80%的情况是路径不对或者文件名拼错。5.2 “missing separator. Stop.”Tab和空格的恩怨make报这个错九成是缩进问题。Makefile里命令行的缩进必须是Tab不能用空格。你在编辑器里把Tab显示出来或者干脆用cat -A查看Makefilecat -A Makefile如果命令行开头看到的是^I说明是Tab没问题如果看到的是空格或点那就是关键行没对齐。大多数现代编辑器默认把Tab转成4个空格写Makefile时必须关掉这个设置。还有的文本编辑器能自动识别Makefile类型并且正确插入Tab用起来会省心很多。5.3 “make[2]: *** [makefile:18: libs] Error 1”这类错误怎么读很多人一看Error 1就慌了其实这类错误信息有固定结构make[2]表示这次make调用处在嵌套第2层makefile:18是出错的Makefile文件和第18行libs是目标名Error 1表示该目标的命令返回了退出码1。这行的价值是告诉你“链路上的哪一环断了”真正的原因得往上面看日志。比如libs: math.o $(CC) -o libs math.o -lm math.o: math.c $(CC) $(CFLAGS) -c math.c如果math.c编译时报语法错误make的输出会先有一段gcc的报错然后才是make[2]: *** [makefile:18: libs] Error 1。很多人只看到最后一行的Error 1却忽略了在它之前编译器已经给了真正的原因。下次再遇到这种嵌套错误先往上翻完整日志定位到真正的编译命令和编译错误再去改代码。5.4 “Nothing to be done for all”的原因与对策这句话的意思是make认为目标all已经是最新状态不需要做任何事。可能的原因有三种第一种你想要的目标确实是all而且它被当成文件名目录里恰好存在一个叫all的文件且它比所有依赖都新。解决办法把all声明为.PHONY。第二种all依赖的文件都存在的且时间戳都满足依赖关系make认为无需重建。不想让它“自作聪明”用make -B强制重建。第三种实际项目中比较隐蔽你把all的规则写成了:all: gcc -o main src/main.c但all后面没有依赖。make会认为all永远是最新的因为它没有任何依赖永远不需要重建。正确写法是让all依赖真实的可执行文件all: main main: src/main.c gcc -o main src/main.c5.5 改了头文件却没有触发重编这个问题在依赖关系没写全时非常典型。假设main.c里包含了utils.h但你只写了main.c依赖关系没写utils.h。第一次编译时main.o被生成之后你改了utils.hmake不会察觉main.o已经过期于是让你看到一个“莫名其妙的效果没生效”。解法是把头文件写进依赖列表main.o: main.c utils.h $(CC) $(CFLAGS) -c main.c -o main.o如果要自动化生成这些依赖可以用gcc -MM生成.d文件然后include进来。这个技巧非常大程度减轻手动写的负担而且准确率比手写高得多。5.6 make和CMake到底什么关系把make和CMake放在一起比较的帖子很多我直接给你结论make是构建工具CMake是构建系统生成器。CMake读取CMakeLists.txt生成Makefile或者Ninja、Visual Studio的工程文件然后再由make/Ninja完成真正的编译。所以它们不是替代关系而是上下游关系。直接写Makefile的核心优势是轻量、透明、没有额外学习成本适合中小型C项目CMake的强项是跨平台、支持复杂依赖、能方便地生成IDE工程在大型C项目里几乎是标配。但从学习顺序来说我强烈建议先把make和Makefile搞明白再去学CMake。理解了Makefile里目标、依赖、命令的概念CMake的target、add_library、target_link_libraries这些概念基本就是换了个表达方式上手会快很多。6. 我对make的实际体会说到底make和Makefile这套东西看着不起眼但它背后是一种“把重复劳动交给工具”的思路。写Makefile的过程其实就是逼自己把项目的构建流程梳理清楚这个过程中你对项目结构、文件依赖、编译选项的理解都会上一个台阶。我现在遇到任何一个新的Linux项目第一件事永远是先读Makefile十个项目里有九个看完Makefile就基本知道代码怎么组织、依赖什么库、有哪些坑了。最后给刚入手的同学一个建议别急着去背Makefile的语法手册。从一个小项目开始用最笨的写法跑通编译再逐步引入变量、模式规则、wildcard、include然后把每个步骤的报错都读一遍。踩过几个坑之后这套构建工具就算真正长在你身上了。
