ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Makefile核心概念与实战:依赖、交叉编译、报错排查和CMake选择

Makefile核心概念与实战:依赖、交叉编译、报错排查和CMake选择 一提到 Makefile很多人第一反应是“不就是把编译命令写进文件里嘛”。这句话没错但只对了一小半。我最早接触 Makefile 是在一块基于 RV1106 的嵌入式视觉开发板上当时项目里源码文件从两三个涨到十几个手工敲 gcc 命令越来越痛苦于是开始认真翻教程、看例子。结果越深入越发现Makefile 真正厉害的地方不是“替你存命令”而是它把编译过程里的依赖关系讲清楚了——哪些文件变了需要重新编译哪些可以不动。理解了这一点后面看再复杂的构建系统都不慌。这篇文章适合三类人刚入门 Makefile 没多久、只会照着抄示例的初学者在做嵌入式或交叉编译项目、被头文件路径问题折磨的开发者以及在 Makefile 和 CMake 之间反复纠结选型的工程人。我会按自己实际项目的经验从最小规则讲到变量、模式规则再到交叉编译里的头文件路径、高频报错的排查链路最后聊聊 Makefile 和 CMake 的关系。全程会用能直接跑的例子说话也会把我踩过的坑一并说出来。1. 从手工编译到 Makefile这个工具真正解决的问题1.1 不是把命令“存起来”而是描述依赖关系我见过不少刚入行的人把 Makefile 当成一个“命令便签”每个目标下面写一行编译命令然后把所有命令复制进去。这样写出来的文件确实能跑但一旦项目扩大问题就来了——每次改一个 .c 文件你根本说不清哪些目标该重新编译只能全部执行一遍。Makefile 的核心不是命令是依赖关系。它的工作机制可以用一个生活场景类比你要做一顿晚饭菜谱上写着“先煮饭再炒菜最后煲汤”。普通人会想“我所有的菜都得从洗切开始”但 Makefile 会先看一眼——米饭已经熟了那就不煮了鸡肉还没解冻那先处理鸡肉。换句话说Makefile 会对比目标和依赖的时间戳只有依赖比目标新的时候才会重新执行那几条命令。这个机制决定了它和普通脚本的本质区别。脚本是“从上往下无条件执行”Makefile 是“从目标出发检查依赖能省则省”。所以在写 Makefile 之前脑子里第一个要建立的概念就是每个规则 一个目标 一组前置文件 一组生成命令。目标往往是一个文件依赖是这个文件生成前必须满足的文件命令则是“如果依赖有变化我要做什么来完成目标”。1.2 规则的最小模型目标、依赖、命令看一个最小例子app: main.o utils.o gcc -o app main.o utils.o main.o: main.c utils.h gcc -c main.c -o main.o这里第一行“app: main.o utils.o”表示目标 app 依赖两个 .o 文件。如果 main.o 或 utils.o 比 app 新make 就会执行下面这一行 gcc 命令。第二段同理main.o 依赖 main.c 和 utils.h一旦这两个文件有变更main.o 就要重新生成。有一点必须强调我当年在这个坑里至少浪费了一个小时第二行的命令前面必须是 Tab 键不能是四个空格。make 解析的时候只看这个位置是不是 Tab不是的话直接报错“missing separator”。这大概是新手遇到最多的问题之一报错信息很直白但很多人第一反应是去查语法而不是看缩进。Makefile:2: *** missing separator. Stop.看到这个先检查命令前的空白是不是 Tab。1.3 先跑通一个最小的可执行版本工具讲得再多不如先有一份能跑的最小文件。我的建议是建一个空目录放三个源文件main.c、utils.c、utils.h内容随意比如 main.c 里调用 utils.h 声明的一个函数就行然后写下面这个 Makefileapp: main.o utils.o gcc -o app main.o utils.o main.o: main.c utils.h gcc -c main.c -o main.o utils.o: utils.c utils.h gcc -c utils.c -o utils.o clean: rm -f app main.o utils.o在目录里执行 make你会看到 make 依次编译 main.o、utils.o最后链接成 app。再执行 make它会说“make: app is up to date.”——这就是增量编译生效了。这时候你再去 touch 一下 utils.c再 make会发现只有 utils.o 和链接两步重新执行了main.o 完全没动。能跑通这个最小案例Makefile 的地基就算有了。接下来要做的是让它不再靠重复粘贴命令来维护。2. 让 Makefile 像我工程文件一样耐看变量、模式规则和伪目标2.1 变量 和 : 的区别必须在早期搞懂项目一大命令里到处是编译器名、编译参数、头文件路径。如果不引入变量改一个编译器版本就得全局搜索替换这不叫工程化。所以第一步是用变量把这些值抽出来。Makefile 里变量的写法很直接CC gcc用的时候写$(CC)。我需要提醒一个新手很容易忽略的点和:看起来差不多行为完全不同。是递归展开变量在真正使用的时候才展开当前值。:是立即展开赋值时就把右边的值固定下来。A hello B $(A) world A hi all: echo B$(B)用时B 最终会显示 “hi world”因为 make 在 echo 那一刻才去展开 A如果 B 用的是:它会在赋值瞬间把 A 的值当时是 hello固定下来结果变成 “hello world”。这个区别在交叉编译场景特别容易埋雷比如你在后面重新定义了一个变量前面用递归展开的地方全都跟着变。我的原则很简单想在赋值当场确定值的用:需要“用到再取”的再用不要一股脑全用。常用变量一般长这个样子CC : gcc CFLAGS : -Wall -O2 -g TARGET : app SRCS : main.c utils.c OBJS : $(SRCS:.c.o)最后一行的$(SRCS:.c.o)是替换引用把 main.c 变成 main.o。这一行能省掉你手写所有 .o 文件列表的功夫我非常推荐。2.2 模式规则和自动变量不再为每个文件写重复规则如果每个 .c 文件都要写一条“xx.o: xx.c xx.h gcc 命令”的规则还不算真正省事。要给任意 .c 文件生成 .o用模式规则%.o: %.c $(CC) $(CFLAGS) -c $ -o $这里%是通配符表示“任何名字”。配合自动变量$当前目标名。$依赖列表中的第一个文件。$^全部依赖文件。上面这条模式规则展开后就等价于为每个 .c 文件生成对应的 .o而且会自动把文件包含的 .h 当作“准依赖”问题放到第 3 节讲。自动变量是写 Makefile 最舒服的部分——有了它们规则体基本可以复用新增源码文件只需要改 SRCS 变量的列表。我常用的一些自动变量的对比整理如下。变量含义典型用法$规则的目标文件名gcc -o $ ...$第一个依赖gcc -c $ -o $$^所有依赖gcc -o $ $^$?比目标新的依赖列表链接多个 .o 时很好用$*匹配%的部分生成 .d 文件时常用2.3 伪目标没有 .PHONY 的 clean 总有一天会出事目录里有可能出现一个叫 clean 的文件这不是开玩笑。如果哪天有人不小心在项目根目录创建了空文件 clean然后运行 make cleanmake 会认为目标是这个文件而且它已经存在且没有依赖于是什么都不做。为了避免这种“文件和目标同名”的诡异问题必须显式声明伪目标.PHONY: clean all.PHONY告诉 make这些名字不对应任何真实文件命令一定要执行。clean、all、install 这些常用目标我都建议加进去。写伪目标这件事看起来只是个形式实战中能挡掉很多“为什么我没执行命令”的怪问题。到这里一个结构化还算清楚的 Makefile 就成形了。接下来我想专门写写嵌入式交叉编译场景下的头文件路径问题——这是我个人调试时间最长、也最容易被搜索到的地方。3. 嵌入式交叉编译里的头文件路径RV1106 上的实际排错3.1 “fatal error: No such file or directory”到底是谁报的在 RV1106 这类嵌入式平台上做开发经常遇到编译时报fatal error: rk_common.h: No such file or directory 1 | #include rk_common.h先搞清楚这个报错的来源。它不是链接器报的是编译器在预处理阶段找不到头文件。C 语言里#include的解析顺序大致是双引号形式先找当前源文件所在目录找不到再去-I指定的路径里找尖括号形式则直接去标准路径和-I路径找。普通本机编译时/usr/include这类系统路径是自动在搜索列表里的所以很多头文件“莫名就能找到”。交叉编译不一样RV1106 是另一个架构的目标板它的系统头文件并不在你的 Ubuntu 主机的/usr/include里而在交叉工具链自带的 sysroot 目录里。很多第一次做嵌入式的人直接在 CFLAGS 里写-I/usr/include结果要么找不到目标板的头文件要么把主机头文件和目标板头文件混在一起编译出新一批奇奇怪怪的错误。正确的思路是先找到你的交叉编译器同级目录下的 sysroot 或者 include 目录用它作为默认搜索路径。RV1106 用的通常是 32 位 ARM 工具链常见路径类似/opt/arm-rockchip830-linux-uclibcgnueabihf/bin/arm-rockchip830-linux-uclibcgnueabihf-gcc头文件一般在同一级目录下的arm-rockchip830-linux-uclibcgnueabihf/sysroot/usr/include。你不一定非要把这个路径手动写进 CFLAGS——只要 CC 用的是正确的交叉编译器它自己就知道去 sysroot 找。真正需要手写的是第三方库的头文件路径比如 SDK 里面给你的include目录。3.2 -I、-L、-l 是三件事别放一起传“makefile 头文件路径”这个热搜词的背后其实是三个参数总被搞混-I指定头文件搜索路径作用在编译阶段的预处理。-L指定库文件搜索路径作用在链接阶段链接器去找 .a / .so。-l指定链接哪个库比如-lm是链接数学库它和文件名libm.a是对应关系。把它们分开理解之后很多报错就好判断了。编译期报“No such file or directory”查-I链接期报“cannot find -lx”要么是库没编出来要么是-L路径不对找不到符号 undefined reference才轮到去查链接顺序和库本身。在交叉编译里还有一个关键词是 sysroot。工具链本身已经带了一份目标板根文件系统的头文件和库通常不用你再手动-I到系统路径。如果编译时提示找不到的是stdio.h、string.h这类基础头文件先别急着加路径而是检查你的 CC 是不是真的指向了交叉编译器。这个判断在项目里我做过太多次了基础头文件找不到八成是编译器没换对。3.3 一套可直接套用的 RV1106 交叉编译 Makefile 骨架结合前面讲的变量和模式规则给一套我在嵌入式小项目里常用的骨架你可以直接在项目里改路径CC : /opt/arm-rockchip830-linux-uclibcgnueabihf/bin/arm-rockchip830-linux-uclibcgnueabihf-gcc CFLAGS : -Wall -O2 -g -I$(SDK_INCLUDE_DIR) LDFLAGS : -L$(SDK_LIB_DIR) -lrockchip_common SDK_INCLUDE_DIR : /path/to/sdk/include SDK_LIB_DIR : /path/to/sdk/lib TARGET : app SRCS : main.c utils.c sdk_wrapper.c OBJS : $(SRCS:.c.o) .PHONY: all clean all: $(TARGET) $(TARGET): $(OBJS) $(CC) -o $ $^ $(LDFLAGS) %.o: %.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -f $(TARGET) $(OBJS)实际使用时有几个细节我想特别提醒第一-I指定的路径不要带多余空格也不要用引号把整个路径括起来再塞进变量Makefile 对变量值里的空格处理有时候会把路径拆成两段。第二如果头文件改了但 .o 一直不重新编译说明你还缺依赖生成下面给一个现在很多项目都在用的组合CFLAGS -MMD -MP -include $(OBJS:.o.d)-MMD会让 gcc 在编译时顺带生成.d依赖文件里面记录的是“这个 .o 到底依赖哪些头文件”-MP给每个头文件生成一个伪目标避免删除头文件后报错。-include前面这个短横线表示“文件不存在也别报错”。这样嵌入到 Makefile 里就再也不会出现“明明改了头文件却不重编”的问题了。头文件路径问题只是 Makefile 排错的一个面真正让人崩溃的往往是 make 连文件都没找到时的那些报错。4. “make 没有指明目标并且找不到 makefile”我遇到时的完整排查路径4.1 先把这句话拆开默认目标和默认文件如果你在终端里直接敲 make而项目目录里真的没有 Makefile常见报错有两种形态make: *** No targets specified and no makefile found. Stop.这句话其实拆成两半看就懂了。“No targets specified”是因为你没在命令行给它传目标make 只能自己找默认目标“no makefile found”是因为 make 去找默认文件名了结果没找到。make 默认按顺序找这几个文件GNUmakefile、makefile、Makefile。绝大部分项目用的是大写的 Makefile但你完全可以用-f指定其他名字make -f build.mk至于默认目标make 会把读到的第一个非伪目标的规则目标当作默认目标。这也解释了为什么很多项目的 Makefile 第一行会写 all 目标甚至显式声明.DEFAULT_GOAL : all如果你没有 .DEFAULT_GOALmake 也会用第一个规则。所以有时候你明明没写“默认目标”它依然能找到 app 来构建。反过来说如果你的 Makefile 第一个规则是某个“检查环境”之类的辅助目标那执行 make 时可能不会构建你期望的 app。4.2 逐步排查的四个层次遇到“找不到 makefile”的报错我会按下面几个层次来排查每一步都能排除一批原因第一层确认当前目录。最简单也最容易被忽视。我之前在 RV1106 项目里用脚本批量拉取代码脚本里 cd 到了 build 目录但实际 Makefile 在源码根目录结果每次构建报这个错。你用 pwd 看一眼再 ls -a 看目录里有没有隐藏的 makefile90% 的情况能在这一分钟解决。第二层确认文件名。Linux 下文件名大小写敏感Makefile 和 makefile 是两个文件。如果网上拷下来一个脚本把文件另存为 MakeFile 或 MAKEFILEmake 默认是不会找的。检查完字典型命名再考虑你的文件是不是被 git 操作弄丢了。第三层确认你是否需要在其他目录执行 make。有些工程习惯把构建产物放在单独目录Makefile 在项目根目录需要先cd 目录 make。如果你已经在子目录执行 make但 Makefile 写在上一级就会覆盖不了。嵌入式 SDK 里还有一种常见情况——官方给的示例要求先 source 某个环境变量脚本再回到指定目录执行 make。漏了这步Makefile 根本不会被生成出来。第四层检查命令行参数的干扰。如果你用了环境变量 MAKEFLAGS 或 MAKEFILE_LISTmake 读取文件的顺序和集合会发生变化。一般项目用不到这个但如果你的 shell 配置里奇奇怪怪地导出了 MAKEFILES它可能把 make 拉去读另一个文件。4.3 容易和它混淆的另一个报错No rule to make target真正让我头疼的其实是另一个报错make: *** No rule to make target foo.o, needed by app. Stop.这句是在说Makefile 里清清楚楚写了 app 需要 foo.o但 make 找不到任何一条规则能“生成”这个 foo.o。它和“找不到 makefile”的区别在于文件读到了依赖关系也建起来了但某个依赖文件缺失或者没有对应规则。这个问题的一般排查路径是看 Makefile 里 SRCS 是不是写错了文件名看这个 .o 文件是否已经被 gitignore、被删了看有没有“源文件在 src 子目录但规则里直接写顶层路径”的路径错位。还有一个比较隐蔽的点如果你用%.o: %.c这种模式规则make 会根据每个 .o 去找对应名字的 .c。如果一个 .c 被你改了名或者放到别的子目录make 就会告诉你 No rule to make target。你看着明明有规则但规则匹配不上就会出现这种半吊子状态。把两种报错的差异整理成一张表遇到时对着查会快很多。报错关键词含义优先排查方向No targets specified and no makefile found没找到默认 Makefile目录、文件名、-f 参数No rule to make target目标或依赖没有生成规则源码文件名、SRCS 列表、路径Missing separator命令前是空格不是 Tab检查缩进up to datemake 认为目标无需重建时间戳、依赖是否完整5. Makefile 和 CMake 到底选谁我的看法要看项目阶段5.1 它们不是同一个层级的东西“cmake 和 makefile 区别”之所以常年被搜索我觉得是很多人把它们放在了同一个比较维度里。实际上CMake 和 Makefile 不是同一层级的工具。Makefile 是给 make 读的构建描述文件CMake 是一个构建系统的“生成器”——它根据 CMakeLists.txt 先生成一套构建文件再调用底层构建工具来编译。底层构建工具可以是 make也可以是 Ninja、Visual Studio 等。也就是说CMake 能生成 Makefile但 Makefile 存在的意义不完全依赖 CMake。mkdir build cd build cmake .. -G Unix Makefiles make这三行命令里第二行把 CMakeLists.txt 翻译成了 Makefile第三行执行的才是真正的 make 构建。因为很多人第一眼看到的是“用 CMake 确实也调用了 make”才会把它们当成二选一。准确说法是make 是执行者CMake 是编排者。我用一张表说明两者的实际差异。对比维度MakefileCMake学习曲线入门简单精通难概念多起步门槛略高跨平台依赖 make 和 shell 环境可在 Windows/Linux/macOS 生成对应构建文件依赖检查需要自己用 -MMD 等技巧补通过 target 的 include_directories 等自动传递外部库管理手写 -I -L -lfind_package 可以自动化小项目启动速度一个文件直接跑至少写 CMakeLists.txt 再生成5.2 什么时候我会坚持用 Makefile我自己判断的标准不是“哪个更先进”而是“项目生命周期里构建问题会不会拖慢开发”。对于单片小型嵌入式项目源码文件不超过 20 个、有固定 SDK、交叉编译链固定我第一选择仍是 Makefile。理由很现实它简单直接一个文件丢进仓库任何人拉下来指定 CC 就能编译不用先去装 cmake、不用理解 target 和 interface include directory 这些概念。就 RV1106 那种开发板场景官方 SDK 很多示例本身就是 Makefile 工程我在上面加几个变量和规则一天就能搞定编译。但是对于要长期迭代、多人协作、以后可能要移植到不同平台的项目我会主动迁移到 CMake。CMake 的 target 体系能让依赖关系跟着目标走不需要每个子目录都维护一份全局 CFLAGSfind_package 和 pkg-config 模块也能省掉大量手写路径的工作。这个优势在项目成长到几十个目录后特别明显。换句话说Makefile 适合“你明确知道事情不会更复杂”的阶段CMake 适合“你知道以后一定要跨平台、要扩展”的阶段。5.3 用了 CMake 之后Makefile 就消失了吗没有。很多人以为用 CMake 就不需要懂 Makefile实际上你迟早会面对 CMake 生成出来的那一大堆 Makefile 和中间文件。尤其当你怀疑 CMake 生成的构建配置有问题时你还是得打开生成的 Makefile看某个变量被传进了哪一步。我也被问过“cmake 生成的 makefile 在哪”这种问题——它就在你执行 cmake 的 build 目录里而不是源码根目录。所以我的建议是不要试图手工去改 CMake 生成的 Makefile每次构建配置的修改都应该回到 CMakeLists.txt 里做否则下一次 cmake 重新生成就会把你的修改冲掉。说到这里很多人的感受可能是“Makefile 的历史任务已经完成该被 CMake 替代了”。我的看法恰恰相反。当你在一个小项目里只需要定义 3 个变量、 20 行规则就能完成交叉编译时Makefile 依然是性价比最高的选择。6. 我从项目里沉淀下来的 Makefile 使用习惯6.1 让增量编译真正生效的配置组合一个 Makefile 写得“能编译”很容易写得“天天用着顺手”才是功夫。我现在的项目基本都默认带这几样配置CXX : g CFLAGS : -Wall -O2 -g -MMD -MP DEPS : $(OBJS:.o.d) -include $(DEPS) .PHONY: all clean all: $(TARGET) $(TARGET): $(OBJS) $(CC) -o $ $^ %.o: %.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -f $(TARGET) $(OBJS) $(DEPS)这里的核心是-MMD -MP配合-include。没有它你的 Makefile 看起来也能编译但只要改了某个头文件make 不会重新编译任何 .o因为它的依赖模型里根本没有头文件这一项。很多人的项目第一次出现“改了头文件但不生效”的问题原因就在这。6.2 三个我反复踩过的坑第一个坑是命令前的空白。我前面提过一次但还是要再强调。很多人从网页复制 Makefile网页会把 Tab 转成空格然后 make 就报 missing separator。我在自己项目里养成习惯写 Makefile 时把编辑器的“将 Tab 替换为空格”关掉或者干脆用 vim 默认配置避免复制粘贴时把 Tab 弄丢。第二个坑是变量值里的尾部空格。写过这么一行SDK_INCLUDE_DIR : /path/to/sdk/include # 注意最后这个空格看起来没问题但 make 会把这一整串包括空格都当作变量值。后面用的时候实际路径变成“/path/to/sdk/include ”带个尾巴空格碰到某些严格检查就会很恶心。排查这种问题可以用一个技巧在变量赋值后立即用括号显示变量名比如$(info [$(SDK_INCLUDE_DIR)])把值和边界打出来看。我调试 Makefile 时经常这么干。第三个坑是把-include和include混用。如果你的 Makefile 里直接写include $(DEPS)而项目刚拉下来还没有 .d 文件make 会因为“文件不存在”报错导致构建直接失败。用-include告诉 make“这些文件有就当依赖读没有就不读”第一次 clean 构建才能顺利通过。这个细节如果没人提醒很容易卡住新手几分钟。6.3 一个小技巧永远先 make -n 一次最后分享一个我每次改动 Makefile 后都会做的操作make -n-n是 dry-runmake 只打印它想执行的命令不真正执行。这对我来说是最高效的“语法检查 意图检查”。如果 Makefile 里有路径问题make -n 的输出能让你一眼看到命令行拼出来的实际内容如果依赖关系写错了你也能在真正执行前看出来哪里会多编、哪里会漏编。再配合make -p打印所有变量和规则的内部视图绝大多数 Makefile 疑难杂症都能在 5 分钟内定位。我现在的习惯流程是改完 Makefile 先 make clean再 make -n 检查确认无误后 make -j$(nproc) 正式编译。这套流程虽然简单但在 RV1106 那种交叉编译要几十秒的项目里帮我避免过很多次“把整个 SDK 目录重新编一遍”的惨剧。Makefile 这种老工具上手不难但把它用顺了你会发现它其实是整个构建体系里最透明、最容易控制的一层。
返回列表