如果你平时在Linux下做C/C++开发,大概率撞过这样一条错误:
make: *** No targets specified and no makefile found. Stop.我第一次碰到它时还挺懵,以为是make没装好,结果make -v一看,工具好好的。后来才明白,这句话的真实意思是:我当前目录下根本没有名为makefile、Makefile或GNUmakefile的文件,让make想干活都找不到菜谱。
这种“翻车现场”几乎每个接触Makefile的人都会经历一次。而围绕Makefile的问题远不止这一个:cmake和makefile区别是什么、怎么生成makefile、makefile 头文件路径到底怎么写、在rv1106这种嵌入式板子上交叉编译又该怎么配……这些都是新手高频搜索的问题。这篇文章就顺着这几个方向,把Makefile从原理到实战完整捋一遍,重点放在“为什么”和“怎么排查”,争取让你看完能直接照抄、能改出自己工程用的版本。
1. 从“make没有指明目标并且找不到makefile”说起
1.1 一个典型的翻车现场
很多教程会直接让你跑make,但不会告诉你“make不是万能的,它需要一份菜谱”。看这条报错:
make: *** No targets specified and no makefile found. Stop.注意后半句,“No targets specified”和“No makefile found”其实是两件事叠加在一起的结果:
- make默认会在当前目录按顺序寻找
GNUmakefile、makefile、Makefile三个名字的文件,一个都没有,自然没法继续; - 你也没有在命令行用
make 目标名这种方式直接指定一个目标,所以make连“硬着头皮执行某个已知目标”的机会都没有。
这两件事只要有一件成立,报错就不会出现。比如你哪怕只写了一个makefile但里面是空的,make也会给你另一条错误,而不是这句“找不到菜谱”的话。
注意:如果当前目录有名为
makefile的文件,系统默认会优先找GNUmakefile,然后才是makefile,最后是Makefile。不过现实中绝大多数项目都用Makefile这种大写开头,纯属约定俗成。
1.2 make的真实职责:目标、依赖、命令
Makefile和make的关系,可以类比成菜谱和厨师。菜谱决定做什么菜、需要哪些原料、先放什么后放什么;make负责按照菜谱一步步执行,并且只做“必要的事”。
一份最基础的Makefile规则长这样:
目标: 依赖 <TAB>命令目标表示“我要产出什么东西”,依赖表示“做这个东西需要哪些原料”,命令表示“具体怎么把原料变成目标”。make真正聪明的地方在于:它会比较目标和依赖的时间戳,如果目标比所有依赖都新,就认为目标已经是最新状态,直接跳过,不去重复执行命令。
这个设计非常符合编译场景。一个C工程里有几百个源文件,你只改了一个foo.c,make通过时间戳判断出只有foo.o需要重新编译,剩下的bar.o、baz.o都没变,最后链接时也用旧的bar.o和baz.o,省下大量编译时间。这种“增量构建”能力,是Makefile至今仍没有被淘汰的根本原因。
1.3 没有Makefile时make会发生什么
理解了make的默认行为,就能解释很多奇怪的报错。
我见过有人把makefile放在了src子目录,却在工程根目录执行make,结果就是这条“找不到makefile”的报错。解决办法很简单:
cd src make或者在根目录手动指定:
make -f src/Makefile-f就是用来指定makefile文件路径的,后面可以跟任意文件名和路径,不一定非要叫Makefile。比如你下载了一个老项目,它的构建文件叫build.mk,可以直接make -f build.mk跑起来。
还有种情况:你确实在目录里看到了Makefile,但报错还是“找不到makefile”。这时候大概率不是文件名没对上,而是提示信息被截断了。可以执行make -d或make --debug看到详细的搜索过程,make会依次打印它尝试过的每个文件名。
2. Makefile核心语法,先学会“读”再看“写”
2.1 规则的本体是“配方”
Makefile的基本单元是规则,一句话概括就是“什么文件依赖什么文件,依赖有了就执行什么命令”。
举个例子,假设目录下只有一个hello.c:
hello: hello.c gcc hello.c -o hello这里的hello是目标,hello.c是依赖,第二行以TAB开头的gcc hello.c -o hello是命令。当你执行make hello或直接make(因为没有指定目标,make会采用第一个目标作为默认目标),make发现hello.c比hello新,于是执行命令生成hello。
命令行的TAB缩进是Makefile最容易踩的坑。很多人从编辑器里复制代码,缩进变成了四个空格,然后make会报:
Makefile:2: *** missing separator. Stop.这个“missing separator”里的separator指的就是TAB。make要求规则下面的每一条命令都必须以一个真实的TAB字符开头,空格不算。这个坑几乎人人踩过,解决方法是把编辑器配置成“对Makefile使用TAB缩进”,或者在vim里:set noexpandtab再重新敲一遍。
2.2 变量、自动变量和隐式规则
直接写命令是最原始的做法,但工程一复杂,重复内容就会变多。比如所有源文件都要用同一组编译参数:
CC = gcc CFLAGS = -Wall -O2 -Iinclude hello: hello.c $(CC) $(CFLAGS) hello.c -o helloMakefile里的变量本质上是文本替换。定义处写CC = gcc,使用处写$(CC),make在执行前会把$(CC)展开成gcc。这么做的好处非常明显:如果换编译器,或者加一条编译选项,只需要改变量定义那一行,不用满文件找。
但别高兴太早,=和:=在Makefile里是有区别的。简单说:
=是递归展开变量,它会在使用时才最终展开,所以可能出现变量引用自身的情况;:=是立即展开变量,定义时就会把右侧引用的其他变量展开成当前值。
实际写工程Makefile时,我建议大部分变量用:=,作用更直观,不容易出现“展开结果和预期不符”的诡异问题。
再看自动变量,这是Makefile里最“香”但新手最陌生的部分。自动变量是make在执行每条规则时自动设置的特殊变量,常见的有:
| 自动变量 | 含义 |
|---|---|
$@ | 当前规则的目标名 |
$^ | 当前规则的所有依赖名,空格分隔 |
$< | 当前规则的第一个依赖名 |
$? | 比目标新的依赖名列表 |
$* | 当前规则中%匹配到的部分 |
比如同样编译hello,可以写成:
hello: hello.c $(CC) $(CFLAGS) $^ -o $@这样哪怕将来给hello增加一个util.c依赖,命令一行都不用改,因为$^会自动展开成全部依赖,$@始终代表目标hello。
新手可能觉得自动变量“语法看不懂”,但一旦明白了“它们只是替你省去重复写目标名和依赖名”,就会发现这是最省力的写法。老手写的Makefile里几乎每个规则都在用自动变量,原因就在这里。
2.3 隐式规则、伪目标和常用内置变量
不需要显式写命令,make也能自动完成某些编译动作,这依靠的就是隐式规则。
比如你写:
hello: hello.o然后目录里有个hello.c,就算你没有写“怎么从hello.c生成hello.o”的规则,make也会自动知道用$(CC) -c hello.c -o hello.o来完成这一步骤。因为make内置了一条针对.c到.o的隐式规则,编译命令大致长这样:
$(CC) $(CPPFLAGS) $(CFLAGS) -c hello.c这就是为什么很多精简Makefile看起来“什么都没写”,却依然能跑通的原因。
隐式规则好用,但也带来了不少困惑。比如你写了个目标叫clean,但又恰好有个clean.c文件在目录里,make就可能把clean当成“一个要从clean.c生成的目标”,行为完全不符合预期。解决办法是明确声明目标与文件无关,即伪目标:
.PHONY: clean clean: rm -f *.o hello.PHONY告诉make:clean只是一个动作名称,不要把它当成文件。凡是你觉得“这个名字不该对应真实文件”的目标,都应该放进.PHONY里。常见的还有all、install、test。
另外,Makefile里还内置了很多有用的变量,例如CC默认是cc,CFLAGS默认是空,RM默认是rm -f。你在规则里使用$(RM)、$(CC)会自动补齐默认值,这也是为什么许多Makefile明明没给CC赋值,编译时却用的是cc而不是gcc。
3. cmake和makefile区别:到底该学哪个、用哪个
3.1 两者的分工完全不同
“cmake和makefile区别”是搜索频率很高的话题,很多新手误以为它们是对立的两套东西,其实完全不是。
Makefile是给make程序看的输入文件,它是构建规则的具体描述;CMake是一个“构建系统生成器”,它读取CMakeLists.txt,分析项目结构和依赖关系,然后生成适合当前平台的构建文件,最简单常见的产物就是Makefile。
换句话说,cmake不是替代makefile的,而是替你写makefile的。
用生活化的类比:Makefile像一份食材清单和烹饪步骤;CMake像是一个会读菜谱、再根据你家厨房设备自动调整步骤的智能助手。你用CMake描述“我要这个可执行文件,它由哪些源文件组成,需要链接哪些库”,CMake根据你的描述去生成Makefile,生成完之后,真正执行编译的还是make。
所以流程一般是:
- 编写
CMakeLists.txt; - 执行
cmake -S . -B build生成构建文件; - 执行
cmake --build build,或者直接进入build目录执行make。
3.2 cmake生成makefile的实际例子
为一个简单的C工程写CMakeLists.txt非常简单:
cmake_minimum_required(VERSION 3.16) project(demo C) add_executable(demo main.c util.c) target_include_directories(demo PRIVATE include)然后在工程根目录执行:
mkdir build && cd build cmake .. make你会发现build目录下多了一个Makefile,这个Makefile就是CMake自动生成的。篇幅比手写的长很多,封装了大量用户不用关心的细节,你直接make就能得到可执行文件。
从这里就能看出cmake的优势:它把你“想构建一个什么程序”的意图表达得更高层,跨平台性也更强。同一个CMakeLists.txt,在Linux下生成Makefile,在Windows下可以生成Visual Studio工程,在macOS下可以生成Xcode工程。手写Makefile要想达到这种跨平台程度,得加一整套条件判断,维护成本非常高。
再比如你新增了一个源文件,手写Makefile得手动改依赖列表,但用CMake只需在add_executable里加一个源文件名,生成Makefile后重新构建即可。CMake还原生支持库依赖、查找系统库、安装规则、测试注册这些功能,这些在纯手写Makefile里都要自己一点点实现。
3.3 什么时候不该用cmake
CMake虽然强大,但也不是所有场景都适合,更不能因此觉得Makefile过时了。
以下情况我仍然会直接手写Makefile:
- 小型项目,源文件不超过10个,依赖关系简单,手写Makefile 20行搞定,引入CMake反而增加学习成本;
- 嵌入式裸机工程,不需要复杂的跨平台逻辑,只需要精确控制每一条编译命令,手写的可读性更好;
- 给别人提供“开箱即用”的构建脚本,一个Makefile丢进项目根目录就能跑,比让用户先安装cmake再配置构建更友好;
- 已经维护了很久的老项目,构建流程全部基于Makefile,迁移到CMake需要验证很多东西,收益又不大。
所以正确态度不是“二选一”,而是“按场景选”。如果你的项目将来要发布到多个平台,或者有复杂的库依赖,直接用CMake从第一天开始写会省心很多;如果你只是自己写个小工具,或者在做嵌入式板卡调试,写完Makefile反而比CMake更直观。
4. 实战:可复用的Makefile模板,包括rv1106这种交叉编译场景
4.1 一个够用的C工程模板
下面这个模板是我在实际项目中经常作为起点的版本,目录结构常见于这样的工程:
project/ ├── include/ │ └── util.h ├── src/ │ ├── main.c │ └── util.c └── MakefileCC := gcc TARGET := demo OBJDIR := build SRCS := src/main.c src/util.c OBJS := $(SRCS:.c=.o) OBJS := $(addprefix $(OBJDIR)/,$(OBJS)) CFLAGS := -Wall -Wextra -O2 -Iinclude LDFLAGS := .PHONY: all clean all: $(TARGET) $(TARGET): $(OBJS) $(CC) $(CFLAGS) $(OBJS) -o $@ $(LDFLAGS) $(OBJDIR)/%.o: %.c @mkdir -p $(@D) $(CC) $(CFLAGS) -c $< -o $@ clean: rm -rf $(OBJDIR) $(TARGET)这里有几个关键点:
SRCS := src/main.c src/util.c把所有源文件放在一个变量里,方便增删;OBJS := $(SRCS:.c=.o)利用Makefile的替换引用,把.c后缀批量改成.o;addprefix用于在目标文件前统一加上build/前缀;- 模式规则
%.o: %.c表示“任何一个build/xxx.o都依赖同名的xxx.c”,这是Makefile处理多源文件编译的核心写法; - 命令里的
@mkdir -p $(@D)会在首次编译时自动创建目标目录,$( @D)是目标所在目录的自动变量,写成$( @D)注意中间没有空格; @开头表示执行这条命令前不打印命令本身,只打印输出,让构建日志干净一些。
有人会问,为什么要先把.o文件放到build目录,而不是直接在源码目录编译?因为把编译中间产物集中放一起,clean时只需要删一个目录,也不会把源码目录搞得乱七八糟。这在工程稍微大一点之后,体验差异非常明显。
养成好习惯:所有目标文件都往build目录放,而不是放在源码旁边。
4.2 指定头文件路径的正确姿态
makefile 头文件路径这个热搜词说明大家经常在这里卡壳。
.c文件里的#include "util.h",编译器默认会先从源文件所在目录找,再从-I指定的路径里找。如果头文件放在include目录,编译时就得告诉编译器这个位置:
CFLAGS := -Wall -Iinclude但要注意,这个-Iinclude相对于make执行时所在的目录,不是相对于源文件所在目录。如果你在工程根目录执行make,那include也得在根目录下,这个写法才成立。如果你在src子目录里执行make,就要写成-I../include。
这种相对路径的坑,在子目录结构和交叉编译时特别常见。我的习惯是:Makefile固定在工程根目录,所有路径都基于根目录写,命令里的目录切换尽量少用。如果你必须在子目录执行make,可以这样保证路径始终相对工程根目录:
ROOT_DIR := $(realpath .) CFLAGS := -I$(ROOT_DIR)/includerealpath函数会把相对路径转成绝对路径,这样不管你在哪里执行make,头文件路径都能正确找到。
另一个和头文件相关的坑是依赖关系。看上面模板里的规则:
$(OBJDIR)/%.o: %.c这条规则只声明了.o依赖.c,没有声明它依赖对应的.h。假如你修改了util.h,make可能不会重新编译util.c,导致最终链接的程序用的还是旧逻辑。这是新手最容易遇到的“改了头文件但不生效”问题。
解决办法是自动生成头文件依赖。常见做法是让gcc生成.d文件:
.DELETE_ON_ERROR: $(OBJDIR)/%.o: %.c @mkdir -p $(@D) $(CC) $(CFLAGS) -MMD -MP -c $< -o $@ -include $(OBJS:.o=.d)这里的-MMD让gcc在编译时额外生成一个.d文件,记录这个.o真正依赖哪些头文件;-MP会为每个头文件生成一个空目标,防止“头文件被删除后报错”。最后用-include把所有.d文件包含进Makefile,make就能读取到真实依赖关系。
加上这几行之后,修改任何头文件都会触发依赖该头文件的源文件重新编译。这个技巧几乎每个实际项目都在用,却很少出现在最基础的教程里。
4.3 rv1106加交叉编译链时的Makefile调整
如果你在做瑞芯微rv1106这类嵌入式Linux平台开发,那一定绕不开交叉编译。
交叉编译的意思是:你的开发机是x86架构,目标板是arm架构,两个平台的CPU指令集不同,必须用一个专门的交叉编译器在开发机上生成目标板能运行的二进制。这个交叉编译器的名字往往带平台前缀,比如arm-rockchip831-linux-gnueabihf-。
配合Makefile,只需把工具链相关变量抽出来:
CROSS_COMPILE ?= arm-rockchip831-linux-gnueabihf- CC := $(CROSS_COMPILE)gcc这里的?=表示:如果用户没有通过命令行指定CROSS_COMPILE,就使用默认值。这样既可以在Makefile里写默认工具链,又允许开发者在本地构建时覆盖:
make CROSS_COMPILE=等于把变量设置成空,就会用本机的gcc来编译。
除了CC,还有几个工具链配套变量也需要注意:
AR:链接静态库的工具,通常是$(CROSS_COMPILE)ar;STRIP:裁剪调试信息的工具,通常是$(CROSS_COMPILE)strip;LIBS:需要链接的库,比如-lrt -lpthread。
rv1106这类板子通常跑的是精简Linux系统,动态库路径、文件系统布局都和开发机不同。编译时如果遇到找不到头文件或库,往往不是代码问题,而是没有加对路径。比如板子提供的SDK里有自己的库和头文件,Makefile里就要这样指定:
CFLAGS += -I$(SDK_PATH)/include LDFLAGS += -L$(SDK_PATH)/lib -Wl,-rpath-link,$(SDK_PATH)/lib LIBS := -lpthread -lm-L告诉链接器去哪里找库,-Wl,-rpath-link用于在交叉编译时提前解析库之间的相互依赖。嵌入式平台的动态库经常会互相引用,少了这个选项就可能报“未定义引用”之类的链接错误。
实际编译时,我都习惯加一句打印,确认自己用的确实是交叉编译器而不是本机gcc:
all: @echo "Building with $(CC)"别小看这行提示,我踩过因为环境变量被覆盖、结果用错编译器导致二进制在板子上跑不起来的坑,看到这行打印就能立刻发现问题。
5. 高频报错排查:找不到文件、头文件路径、隐式规则不匹配
5.1 排查“make没有指明目标并且找不到makefile”的完整思路
前面已经说过,这条报错的前提是“当前目录没有默认构建文件”和“没有指定目标”同时成立。实际排查可以按下面几步来:
- 先看当前目录有没有Makefile:执行
ls -la Makefile makefile GNUmakefile; - 没有的话,看看构建文件是否在其他目录,比如
src/Makefile,有就执行make -f src/Makefile; - 如果构建文件在远端仓库里没被拉下来,检查是否忘了执行初始化和子模块更新;
- 如果目录里
Makefile.in存在但Makefile不存在,说明项目需要先执行./configure生成Makefile,这在autotools项目里很常见; - 如果确实有Makefile但make不识别,检查文件名开头是不是大写M,Linux系统区分大小写。
我见过的最离谱的一次是这个文件叫MAKEFILE,全大写。make只认GNUmakefile、makefile、Makefile三种命名,MAKEFILE不在其中。这种情况把文件名改成Makefile即可。
5.2 头文件路径错误和依赖关系缺失
编译时最常见的报错之一:
src/main.c:3:10: fatal error: util.h: No such file or directory这是头文件路径没配好。排查思路:
- 先确认头文件实际位置;
- 如果头文件在
include下,给CFLAGS加上-Iinclude; - 如果头文件还在更深的子目录,比如
include/rv1106/,要么加-Iinclude/rv1106,要么在源码里用相对路径#include "rv1106/util.h",推荐前者; - 验证是否生效,用
make CFLAGS="-Wall -Iinclude -v"加上-v看编译器的搜索路径打印。
还有种情况,头文件路径没问题,编译器也能找到,但链接时报“未定义引用”。这多半是头文件里声明了函数,而实现它的源文件没有被编译进链接列表。比如util.c是后来新建的,你可能忘了在SRCS变量里加src/util.c。检查Makefile里有没有包含所有需要的源文件,这是链接错误最常被忽略的根因。
依赖关系缺失的问题前面提过,表现特征是:改了.h文件,重新执行make,终端显示“make: Nothing to be done”,程序行为却还是旧的。这是时间戳机制的死区,解决方式就是用-MMD -MP生成.d依赖文件并-include进Makefile。
5.3 隐式规则和模式规则不匹配的坑
另一个常见的报错:
make: *** No rule to make target 'foo.o', needed by 'demo'. Stop.意思是make找不到“如何生成foo.o”的规则。虽然make内置了%.o: %.c的隐式规则,但这条规则要求目录里存在对应的foo.c。如果源代码是C++的foo.cpp,而你的Makefile里仍然写着SRCS := src/foo.cpp并试图生成foo.o,内置规则找不到匹配的foo.c,就会报这个错。
解决办法有两种:
- 明确声明C++的编译规则,比如
$(OBJDIR)/%.o: %.cc; - 或者统一用模式规则自己覆盖,让
.o依赖.c还是.cpp一目了然。
还有一种情况是目标文件的后缀名对不上。比如某个源文件是.cxx后缀,内置规则不认,也会报同样的错误。不要和make的隐式规则较劲,最好让源文件后缀保持.c或.cpp这些常见形式,或者干脆明写规则覆盖。
对付这种问题,可以用make -p打印内置规则全量数据,然后搜索%.o:看一下make默认认为的优先级。make -p输出非常长,建议配合grep过滤。
5.4 排查小技巧速查表
| 现象 | 可能原因 | 快速确认方式 |
|---|---|---|
| No targets specified and no makefile found | 当前目录没有Makefile或MAKEFILE命名错误 | ls -la Makefile |
| missing separator | 命令不是TAB开头,被替换成空格 | 用cat -A Makefile看命令行是不是^I开头 |
| No rule to make target xxx.o | 缺少对应源文件或隐式规则不匹配 | ls src/*.c确认源文件存在 |
| No such file or directory(头文件) | -I路径没写或相对路径不对 | make -n后手动执行编译命令验证 |
| undefined reference | 源文件没加入SRCS或缺少库 | 检查SRCS和LDFLAGS |
| Nothing to be done | 时间戳没变化或目标被当成文件 | 执行make -B强制重建验证 |
用make -n是排查Makefile问题的最好习惯之一。它不会真正执行命令,只会把要执行的命令打印出来,相当于“预演一遍”。你可以用它检查命令是否写错、路径是否展开正确,而不需要担心中间产物被搞坏。
再分享一个我自己的习惯:每次拿到一个陌生项目的Makefile,先跑make -n,再跑make -d,前者看命令,后者看make的决策过程。-d输出会明确告诉你“哪个目标为什么被跳过”“哪个依赖比目标新”,排查那些“明明改了却重新编译”的诡异问题,一抓一个准。
我在实际项目中踩过最尴尬的坑就是忘记把.PHONY加上,结果目录里真的生成了一个名为clean的文件,之后执行make clean永远显示“Nothing to be done”,文件删不掉,构建产物也清理不了。后来凡是“名字有可能会和文件冲突”的目标,我都一律声明为.PHONY。这个小习惯帮我省了太多时间。
Makefile这东西,看上去只是“几条编译命令的集合”,但真正上手后你会发现,它本质上是工程构建流程的“可执行文档”。读懂它、写顺它、会排它的错,在Linux开发特别是嵌入式方向里,是一项绕不开的基本功。希望这篇文章里写到的那些模板和排错路径,能帮你少走一些弯路。