最近接手了一个在仓库里躺了两年多的旧嵌入式项目,代码文件倒是齐全,就是文档约等于零,唯一的构建入口就是一个孤零零的Makefile。设备那边催得急,我打开终端敲了一声make,屏幕回了一行冷冰冰的提示:make: *** No targets specified and no makefile found. Stop.。
那一瞬间心里其实挺不是滋味的。不是因为这条报错有多吓人,而是它意味着你连项目的门都还没摸到。排查到最后发现,只是文件名被人手滑改成了makefile.bak,但这个过程里我把Makefile的语法、目标推导、变量展开逻辑又重新过了一遍。这次经历之后我意识到,不管外面工具链怎么推陈出新,Makefile依旧是Linux和嵌入式开发的底层语言,是绕不过去的基本功。这篇文章就把我这些年和Makefile打交道的实战经验拆开讲讲,从语法到排错,从和CMake的选型对比到交叉编译现场配置,一次说清楚。
1. 为什么还在折腾Makefile:它解决的从来不只是"编译"这件事
很多人觉得Makefile不就是把编译命令写进文件里么,gcc a.c -o a一行事,非得搞这么复杂?这种理解不能说错,但格局小了。Makefile的核心价值不是记录命令,而是处理**"哪些东西需要重新生成,哪些可以跳过"**这个增量构建的问题。一个稍微有点规模的项目,几百个源文件很正常,如果每次改一个.c文件都全量编译,时间成本不可接受。Makefile通过文件时间戳对比来判定依赖关系,只重编发生变更的部分及其下游目标,这才是它存在的根本意义。
它本质上是GNU Make这个程序解析的一种描述语言,你自己定义目标(target)、依赖(prerequisite)和命令(recipe),Make根据依赖关系构建一张图,然后按照拓扑顺序执行规则。这套机制看起来简单,但恰恰是这份简单让它具备了极强的通用性和可移植性——只要有make和编译器的地方就能跑,不需要安装额外的构建环境。
我自己实际用下来的感受是,Makefile还有一个容易被忽略的作用:它是项目的可执行文档。一个写得好的Makefile,读完之后你基本能知道这个项目有哪些模块、用什么方式编译、链接了什么库、目标产物是什么。这比看一堆写在某篇wiki里的零散命令可靠得多,毕竟代码仓库里不会撒谎的东西,除了源码,就是构建脚本。
再说说现阶段的必要性。现在很多新项目直接上CMake,甚至有同学从实习到毕业都没手写过Makefile。但你要是去搞Linux内核、驱动模块、嵌入式BSP,或者那些没有适配CMake的老牌开源库,Makefile就是唯一入口。RV1106这类带异构算力的IPC芯片SDK,官方给的demo和库工程几乎全是Makefile体系的。所以Makefile不是要不要学的问题,是在特定场景里没得选。
这一章先摆清Makefile的定位。下面进入正题,看看一个可维护、可扩展的Makefile到底该怎么搭。
2. 把Makefile拆开揉碎:从一条规则到一套能长期维护的构建脚本
2.1 规则、变量、自动变量:这三板斧先练熟
先说不带花活的骨架。Makefile里最基本的单元是规则:
target ... : prerequisites ... recipetarget通常是文件名,也可以是一个标签(伪目标)。prerequisites是生成target所依赖的文件或其他目标。recipe则是具体执行的shell命令,必须以Tab键开头,用空格对齐会直接报missing separator,这个坑新手踩得最频繁。
光会用gcc hello.c -o hello这种直白规则还不够,变量系统才是Makefile真正灵活的地方。变量定义有四种写法,区别很容易被忽略:
=:递归展开变量。使用时才展开,如果变量定义里引用自身或互相引用,容易变成死循环。:=:立即展开。定义时就求值,后续变化不影响已有值,推荐优先使用。?=:条件赋值。只有变量未定义时才赋值,适合给用户留覆盖入口。+=:追加赋值。对=变量会保留递归展开特性,对:=变量则立即展开后追加。
自动变量是最省手的工具,它们不需要你显式定义,在规则执行时自动填充:
| 自动变量 | 含义 |
|---|---|
$@ | 当前规则的目标文件名 |
$< | 第一个依赖文件名 |
$^ | 所有依赖文件的列表,去重 |
$? | 所有比目标新的依赖文件列表 |
$* | 模式规则中匹配的茎部分,即去掉后缀的中间部分 |
比如典型的一条编译规则:
%.o: %.c $(CC) $(CPPFLAGS) $(CFLAGS) -c -o $@ $<意思是:任何.o文件依赖于同名.c文件,命令里$@是目标.o文件,$<是源.c文件。这种模式规则让你不用为每个源文件单独写规则,几百个文件几行搞定。
2.2 一份可直接抄的通用Makefile模板
下面这个模板是我反复打磨过的,适合中大型C项目,拿来改改就能用:
CC ?= gcc AR ?= ar CFLAGS ?= -Wall -Wextra -O2 -g CPPFLAGS ?= -Iinclude -Isrc LDFLAGS ?= LDLIBS ?= TARGET := myapp SRCS := $(wildcard src/*.c) OBJS := $(SRCS:.c=.o) DEPS := $(OBJS:.o=.d) .PHONY: all clean distclean all: $(TARGET) $(TARGET): $(OBJS) $(CC) $(LDFLAGS) -o $@ $^ $(LDLIBS) %.o: %.c $(CC) $(CPPFLAGS) $(CFLAGS) -MMD -MP -c -o $@ $< -include $(DEPS) clean: rm -f $(OBJS) $(DEPS) distclean: clean rm -f $(TARGET)这里有个细节值得多说两句:-MMD -MP这两个编译选项会在编译的同时生成*.d依赖文件,把当前源文件include的头文件列表自动写进去,然后-include $(DEPS)把这些依赖文件再拉回Makefile里。这样一旦某个头文件发生变化,所有间接包含它的源文件都会被正确触发重编译。这是解决头文件依赖遗漏的正统方案,比手动在规则里列出头文件靠谱得多。
和.PHONY一起声明的那些目标(all、clean、distclean),它们不代表真实文件,而是动作标签。如果不加.PHONY,当目录下恰好存在一个叫clean的文件时,Make会认为这个目标已经是最新的,直接跳过命令,那也是坑。
2.3 函数与文件包含:当项目规模再上台阶
项目模块一多,单个Makefile就会膨胀到千行,这时候要会用include指令拆文件。比如把编译选项丢到config.mk,把各个子目录的构建规则拆到各自的Makefile,然后在顶层统一include。GNU Make还提供了丰富的文本函数,像是:
# 筛选出指定目录下的所有.c文件 SRCS := $(wildcard src/*.c) # 把.c后缀批量替换成.o OBJS := $(patsubst %.c, %.o, $(SRCS)) # 提取所有源文件的目录列表 DIRS := $(dir $(SRCS)) # 给所有目标批量添加前缀路径 OUT := $(addprefix build/, $(TARGET))wildcard、patsubst、addprefix这几个是我使用频率最高的,掌握了它们,写起Makefile来才能从"一个一个列文件名"升级到"用规则描述文件集合"。函数调用还有一种常见用途是foreach遍历子目录,在聚合多个静态库的时候特别顺手。
顺带提一个容易犯迷糊的点:Makefile里的变量和Shell变量不是一回事。规则内的recipe是由Shell执行的,如果你想在命令行里用Shell变量,得写$$VAR而不是$(VAR),两个美元符号先转义成一个,再交给Shell解释。比如:
print-%: @echo "Variable $* = $($*)"这条规则配合make print-CFLAGS之类的命令,可以快速查看变量的实际取值,调试时相当好用。这也是我在排查变量展开问题时的第一个工具。
3. 一场真实排错:从"No targets specified"到找出Makefile真身
3.1 报错出现的场景与第一反应
回到开头那个报错。make: *** No targets specified and no makefile found. Stop.这句英文其实已经把情况说得很直白了:make在启动后没有找到名为makefile或Makefile的文件(GNU Make默认按顺序查找GNUmakefile、makefile、Makefile),也没有通过-f参数指定文件。有人总说它"没指明目标",准确的解读是"因为没有Makefile,所以没有任何可执行的目标,只能停止"。
出现这个报错最常见的原因是当前目录压根没有Makefile,但有几个变种情况我都在实际工作中遇到过:
- 文件名写错:
makefile.bak、makefile_old、MFILE,五花八门。 - 文件已经提交到
.gitignore但没push,新机器上clone下来就不存在。 - 在子目录执行了make,但这个子目录没有自己的Makefile,顶层那个又在上一级。
- 单个Makefile的名字是
Makefile.in(autoconf模板),忘了执行configure生成最终的Makefile。
3.2 完整的排查链路与工具手段
我的建议是遇到这个报错不要瞎猜,按下面的顺序排查,每一步都能看到实际输出:
第一步,确认当前目录情况:
pwd ls -la重点看有没有Makefile或makefile,以及它们的权限位是否是-rw-r--r--,如果文件存在但不可读,make同样会报找不到makefile。
第二步,指定文件再试一次,排除文件名校验问题:
make -f Makefile如果能正常执行,说明是默认搜索顺序和实际文件名不匹配;如果继续报错,说明文件内容可能有更严重的问题。
第三步,用make -d查看调试输出。这个参数会打印make的内部解析过程,包括在哪些路径尝试打开哪个文件。输出很啰嗦,但配合grep就能锁定问题:
make -d 2>&1 | grep -E "Reading makefile|No such file"第四步,如果Makefile存在但内容有问题,通常会报missing separator或commands commence before first target,那就不是不存在的错,而是语法层的错,定位到对应行看看是不是用了空格当Tab。
这类错误的本质,都是构建入口文件缺失或不可用。把搜索顺序和查找逻辑刻在脑子里,不管报错怎么变,都能一眼判断到底是"文件不存在"还是"找不到匹配项"。
3.3 其他高频make报错的快速对照
排查完入口文件的错误,再多说几个日常编译中高频出现的报错,我整理成了一张表:
| 报错信息 | 根因 | 快速解法 |
|---|---|---|
missing separator | 命令前用了空格而非Tab | 把recipe行首替换为Tab |
No rule to make target 'utils.h' | 依赖列表里的头文件路径找不到 | 检查头文件是否在-I指定路径内,或依赖里路径写错 |
undefined reference to 'xxx' | 链接阶段缺库或链接顺序错误 | 把库放在引用它的目标文件之后,加上-lxxx |
warning: overriding recipe for target | 同一个目标被定义了多条规则且都有命令 | 只保留一个recipe定义 |
*** missing separator. Stop. | include文件里混入非make格式内容 | 检查include的文件是否被当作makefile解析 |
这些报错看起来复杂,根因往往是两个:路径没配对、顺序没弄对。路径问题集中在-I参数和依赖列表;顺序问题集中在链接库的排列上。理解了这两条主线,排错能力会有一个质的提升。
4. CMake与Makefile:构建工具选型背后的真实逻辑
4.1 CMake到底做了什么事
现在新项目用CMake几乎是主流选择,尤其是涉及跨平台或第三方库依赖的时候。CMake本身不是一个编译器,也不是构建器,它是一套元构建系统——读取CMakeLists.txt,根据当前平台和编译器,生成对应的构建工程文件,在Linux上默认生成Makefile,在Windows上可以生成Visual Studio工程,也可以生成Ninja的构建文件。
这里有个很重要的认知:你用了CMake,底层往往还是make在工作。当你执行cmake .. && make的时候,make执行的是CMake生成的那个Makefile。这也是为什么很多从Makefile转过来的同学,在CMake的构建目录里翻到那份巨大的Makefile会觉得困惑——它不是给人看的,是给机器跑的。
从逻辑层面对比一下两者:
| 对比维度 | Makefile | CMake |
|---|---|---|
| 本质 | 直接的构建规则描述 | 跨平台工程生成器 |
| 语法 | 规则+变量+函数,灵活但细节多 | 命令式声明,模块化好 |
| 跨平台 | 依赖Unix shell,Windows较弱 | 原生支持多平台多编译器 |
| 依赖管理 | 需自己维护第三方库路径 | 支持find_package等机制 |
| 增量构建 | 基于时间戳,成熟稳定 | 底层仍依赖make或ninja |
| 学习曲线 | 入门快,深入难 | 概念多,但有官方文档支撑 |
4.2 什么场景应该留在Makefile,什么场景应该上CMake
我自己的选型标准很现实:如果目标平台固定是Linux或嵌入式环境,没有跨平台需求,且项目规模在几万行以内,手写Makefile反而更直接。理由有三点。第一,Makefile反馈链路短,改动一处变量立刻能跑,没有CMake那层配置生成的额外开销。第二,嵌入式工具链和SDK经常是Makefile体系先行,官方示例、交叉编译工具链路径都是围绕Makefile组织的,你硬套CMake反而要自己补齐一堆toolchain.cmake的配置。第三,Makefile的执行逻辑更透明,出错了直接在执行的shell命令上看,不需要理解CMake那一层抽象。
反过来,如果是以下情况,我会毫不犹豫选择CMake:需要同时产出Windows和Linux版本;有大量第三方依赖需要find_package;项目有多个可执行文件和库目标,之间依赖关系复杂;团队里有多个平台背景的开发者,希望统一工程描述语言。CMake的target_include_directories、target_link_libraries这种以目标为中心的描述方式,在大型工程里的可维护性确实比手写Makefile强很多。
4.3 从Makefile思维迁移到CMake的对照
如果你习惯了Makefile里的变量,迁移到CMake时最容易踩的坑是"把CMake当Makefile写"。比如在CMake里也大量使用set(CFLAGS "-Wall")然后直接拼到add_compile_options,这种写法能用,但属于用Makefile思维写CMake。CMake的护城河在于目标属性,比如:
add_library(utils STATIC src/utils.c) target_include_directories(utils PUBLIC include) target_link_libraries(app PRIVATE utils)PUBLIC和PRIVATE关键字对应关系可以这么记:PUBLIC就相当于Makefile里全局定义的CPPFLAGS和LDLIBS,所有链接这个目标的地方都会自动带上;PRIVATE则只对目标自身生效。从Makefile迁移过来的人,需要在大脑里把"编译参数是全局变量"换成"每个目标独立管理自己属性"这个心智模型,很多问题就会迎刃而解。
再补充一个实际操作里的对照:Makefile里你可以定义一个debug目标去编带调试信息的版本,CMake里通常用CMAKE_BUILD_TYPE=Debug这个变量控制,数据驱动的方式比逻辑分支更优雅。本质上,CMake把Makefile里靠变量和分支手动实现的东西,提炼成了规范化的配置项。
从选型再往下深入,嵌入式交叉编译场景会让Makefile的优势尤其明显。下面就来专门聊聊以RV1106平台为例的实战配置。
5. 交叉编译现场的Makefile实战:以RV1106平台的头文件与链接配置为例
5.1 交叉编译的本质和它在Makefile里的映射
交叉编译就是在x86主机上生成ARM目标码。它带来的核心问题是:编译器、头文件、库文件都不能用系统默认的那一套,必须全部指向目标芯片的SDK工具链。反映到Makefile里,就是需要覆盖默认的CC、CPPFLAGS、CFLAGS、LDFLAGS、LDLIBS这些变量。
RV1106是瑞芯微面向IPC类产品的一颗视觉处理芯片,带MCU和NPU,SDK里提供的是arm交叉编译器,常见前缀类似arm-rockchip-linux-gnueabihf-。你在Makefile里最需要改的地方,集中在下面这些变量:
CROSS_COMPILE ?= arm-rockchip-linux-gnueabihf- CC := $(CROSS_COMPILE)gcc AR := $(CROSS_COMPILE)ar STRIP := $(CROSS_COMPILE)strip在Makefile里用:=而不是=定义这些变量,能让编译器路径在使用前就固定下来,避免递归展开时发生意外的路径拼接。这是我在实际项目中吃过亏之后养成的习惯。
5.2 头文件路径:-I到底该怎么给才不出错
makefile 头文件路径 rv1106这个搜索词组指向的问题,我深有体会。交叉编译时头文件找不到是高频问题,报错通常是xxx.h: No such file or directory。解决路径问题的核心其实就是CPPFLAGS变量。
交叉编译环境下的头文件有三层结构:
第一层是编译器自带的内部头文件,一般在交叉编译器的include目录。第二层是芯片SDK的公共头文件,比如RV1106的SDK通常会提供sdk/include之类的基础目录,里面放了平台相关的寄存器定义、外设驱动接口等。第三层是项目自身的头文件,按模块拆在include/或各子目录下。
对应到Makefile里:
SDK_PATH ?= /opt/rockchip/rv1106_sdk CPPFLAGS += -I$(SDK_PATH)/include CPPFLAGS += -I$(SDK_PATH)/include/uapi CPPFLAGS += -I./include一个小技巧是使用-H选项看头文件展开路径:
make CFLAGS+="-H" 2>&1 | grep -E "^\.|^ |No such file"-H会让编译器打印实际打开的每个头文件对应的磁盘路径,一眼就能看出是SDK路径没配对,还是头文件名打错。我之前排查一个协议栈编译失败,就是用这个参数发现SDK版本和编译器的include结构不匹配。
关于头文件路径还有一点容易被忽略——-isystem和-I的区别。-I指定的路径里的头文件,编译器会照常输出warning;-isystem指定路径里的头文件,编译器会抑制大部分warning。如果你的SDK头文件本身对warning不友好,又想保证自己的代码strict编译,可以用-isystem $(SDK_PATH)/include来隔离第三方头文件的噪音。
5.3 库的链接顺序与库路径配置
链接阶段的问题比头文件路径更难定位,因为它报错不直接告诉你缺哪个文件,而是抛出一堆undefined reference。RV1106平台涉及库文件时,Makefile里一般这样配置:
LDFLAGS += -L$(SDK_PATH)/lib -L./lib LDLIBS += -lrockchip_mpp -lpthread -lm -lrt链接顺序的原则必须刻进DNA:库文件要放在引用它们的对象文件后面。GNU ld是从左到右扫描,遇到未解析符号会记录到一个待解析列表,后续扫描到能解析这些符号的库才处理。如果你把-lrockchip_mpp放在了对象文件前面,链接器扫描到库时还不知道后面需要它,于是跳过,最后对象文件里的符号没人管,报undefined reference。
当你发现加了-lxxxx还是报未定义引用时,先别急着怀疑库本身坏了,把命令改成make LDLIBS="-Wl,--start-group -lrockchip_mpp -Wl,--end-group"试试。--start-group会让链接器在库组内反复扫描直到符号全部解析,这是应对循环依赖的临时救急手段。当然正常工程的解法是调整库顺序而不是无脑套group。
再补充一个和动态库相关的坑:如果你交叉编译产出的可执行文件运行时提示cannot open shared object file,通常是因为目标板上没有这个动态库,或者路径不在系统搜索范围内。编译阶段可以用-Wl,-rpath,$(SDK_PATH)/lib把运行时的库搜索路径固化到可执行文件里,但更推荐的方式是确认目标板的库路径规划,别把运行期的问题伪装成编译期的难题。
5.4 一次完整的RV1106样例工程Makefile
最后给一份我在RV1106平台上跑通过的简化版Makefile,包含上面讲到的交叉编译、头文件路径和库链接配置:
CROSS_COMPILE ?= arm-rockchip-linux-gnueabihf- CC := $(CROSS_COMPILE)gcc STRIP := $(CROSS_COMPILE)strip SDK_PATH := /opt/rockchip/rv1106_sdk TARGET := isp_demo CFLAGS := -O2 -Wall -march=armv7-a -mfpu=neon CPPFLAGS := -I$(SDK_PATH)/include -I./include -isystem $(SDK_PATH)/include/uapi LDFLAGS := -L$(SDK_PATH)/lib LDLIBS := -lrockchip_mpp -lpthread -lm -lrt SRCS := $(wildcard src/*.c) OBJS := $(SRCS:.c=.o) all: $(TARGET) $(TARGET): $(OBJS) $(CC) $(LDFLAGS) -o $@ $^ $(LDLIBS) $(STRIP) $@ %.o: %.c $(CC) $(CPPFLAGS) $(CFLAGS) -c -o $@ $< clean: rm -f $(OBJS) $(TARGET) .PHONY: all clean有几个点做一下说明。-march=armv7-a -mfpu=neon要严格匹配芯片核心的架构特性,RV1106的Cortex-A7核心支持NEON,这两个选项能明显优化编解码类程序的性能,但如果在不该用的芯片上开了NEON,反而会出现非法指令错误。STRIP在最终产物完成后执行能缩减体积,对嵌入式存储空间紧张的场景是刚需。加-isystem来处理uapi里的头文件,是因为SDK的这份头文件存在不少非严格的C写法,挂到自己项目里会导致warning刷屏。
在实际调试过程中,我还发现交叉编译时一个很容易误导人的现象:主机上编译运行正常,换成交叉工具链后代码逻辑变了。这多半和结构体对齐、数据类型字节长度差异有关。碰到这种问题,可以在CFLAGS里临时加-Wall -Wconversion -Wpacked观察警告,一般能提前暴露问题点。
说到底,交叉编译的Makefile配置就是三件事:找到正确的编译器、告诉编译器头文件在哪、告诉链接器库文件和顺序是什么。把这三个环节死死咬住,RV1106也好,其他任何嵌入式芯片也好,configure起来都只是换汤不换药。
我自己在实际项目里养成的习惯是,每到一个新平台先写一个最小化的Makefile把编译路径全跑通,再去叠加业务代码。这样做的好处是,一旦出现构建错误,你能清晰地判断是SDK环境问题还是代码本身问题,而不是让两者混在一起互相干扰。这个思路在任何工具链上都适用,也是Makefile这种底层构建工具教给我最值钱的经验。