ARTICLE DETAIL

资讯详情

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

从gcc到makefile:核心规则、自动化变量与常见报错实战

从gcc到makefile:核心规则、自动化变量与常见报错实战 如果你第一次写 C 语言作业一般流程是gcc main.c -o app完事。等作业变成三个文件、五个文件你开始把编译命令复制粘贴好几遍改一个文件名就要重新找一遍。直到某天你直接在终端敲了个make然后屏幕上蹦出来一行红字——make: *** No targets specified and no makefile found.。恭喜你到了该认真认识 makefile 的时候了。这篇东西不是教科书式的语法大全是我自己从“gcc 一把梭”到“一个 makefile 管整个项目”这段路上踩过的坑、总结出的套路以及真正干活时会用到的写法。不管你是看了“makefile 菜鸟教程”依然一头雾水的新手还是被“生成 makefile”这几个字吸引过来想找省事方案的老哥这篇文章都能让你少走点弯路。我会从 makefile 到底在解决什么问题讲起再拆到具体语法、变量、函数最后聊工具生成、报错排查以及怎么把 makefile 用在编译之外的地方。1. 先搞懂 makefile 到底在做什么1.1 三条核心规则目标、依赖、配方makefile 的本质特别朴素它就是在描述一件事什么文件目标依赖什么文件依赖以及怎么从依赖生成目标配方。这三样东西写成一条规则长这样app: main.o utils.o gcc main.o utils.o -o appapp是目标冒号后面的main.o utils.o是依赖下一行缩进的gcc ...是配方也就是真正执行的动作。目标、依赖、配方三件套齐了make 就能干活。你可能会想这不就是 shell 脚本吗我写个build.sh不也能干这事一句话就能解释区别make 会检查目标和依赖的时间戳。如果依赖比目标新说明源文件改了就重新执行配方如果目标已经是最新的make 就告诉你app is up to date什么都不做。这个“惰性判断”看着不起眼却是整个自动化的地基。1.2 时间戳判断为什么改动一个文件并不会全量编译时间戳机制解决的是重复构建的低效问题。没有 make 的时候你改了一个utils.c要么全量重新编译所有文件要么自己手动找出改动的那个文件单独编。项目小的时候还好等项目到了几十个源文件全量编译一次可能就要几十秒甚至几分钟这时候你才发现 make 的价值。它的判断逻辑是这样的对于规则target: dep1 dep2make 会拿target的修改时间和每个依赖比。只要有一个依赖比 target 新就执行配方。这个“新”是按时间戳算的所以有个经典坑你把系统时间改成昨天再改动文件make 可能就识别不出来需要重新编译。真实开发中没多少人会故意改时间但 CI 环境里文件时间戳错乱导致增量编译失效的事我是真遇到过。依赖传递也是 make 的强项。main.o依赖main.capp依赖main.o utils.o你改了main.cmake 会先重建main.o发现app的依赖main.o变新了再重建app。整个依赖图它会一层层往上推你只需要把每个目标到源文件的关系描述清楚剩下的事它自己干。1.3 为什么不直接写脚本非要用 make写过 build 脚本的人肯定遇到过这种情况脚本里有一堆if [ main.c -nt main.o ]的判断写着写着比业务代码还复杂。make 把这些隐藏起来了你用三行声明依赖关系它自动帮你做时间戳判断而且支持并行、支持增量、支持只构建某一个目标。另外make 的规则是声明式的不是过程式的。写脚本你是在描述“先做 A再做 B再做 C”写 makefile 你是在描述“B 需要 AC 需要 B”至于先做哪个、哪些能并行做make 自己会算。这个区别在大项目里就是天壤之别。比如项目里有三个互不依赖的子模块用 shell 脚本你得乖乖排队编译用 makefile 加一个-j参数三个模块直接并行编时间缩短接近三分之一。2. 手写一个能用的 makefile从零到能跑2.1 变量与自动化变量不再写死路径最原始的 makefile 容易把路径写死比如app: main.o utils.o gcc main.o utils.o -o app main.o: main.c gcc -c main.c -o main.o utils.o: utils.c gcc -c utils.c -o utils.o这个能跑但想换编译器、加编译参数就得全改一遍。所以实际项目中一定会用变量CC gcc CFLAGS -Wall -Wextra -g TARGET app OBJS main.o utils.o $(TARGET): $(OBJS) $(CC) $(CFLAGS) $^ -o $ %.o: %.c $(CC) $(CFLAGS) -c $ -o $这里出现了三个自动化变量新手最容易绕晕但用途非常固定变量含义典型场景$当前规则的目标文件名-o $生成目标$^当前规则的所有依赖去重后链接时把一堆 .o 传给编译器$当前规则的第一个依赖-c $编译源文件$?比目标新的依赖列表增量操作场景%.o: %.c这行叫模式规则表示“任何一个以 .o 结尾的文件都依赖同名的 .c 文件”。配合$和$一条规则就替代了你原本要写几十行的 .o 编译规则。make 内置了.c - .o的隐式规则其实你连模式规则都可以不写直接让 make 用默认的$(CC) -c命令去编但自己写出来更可控也方便加头文件搜索目录这类参数。2.2 伪目标与默认目标clean、all 的约定俗成makefile 里有一类目标不对应任何真实文件叫伪目标最典型的就是clean和all。如果你写了一个叫clean的目标而目录下恰好没有clean这个文件规则能正常执行但如果哪天有人手闲创建了一个名为clean的空文件你再敲make cleanmake 会判断“目标已经有且没有比它新的依赖”然后告诉你没问题不需要执行。这就尴尬了。解决办法是在伪目标上面声明一下.PHONY: all clean.PHONY告诉 make这些名字不代表文件只要执行就老老实实跑配方。凡是clean、install、test这类不对应文件的操作型目标都建议加进.PHONY。这个习惯越早养成越好省得将来莫名奇妙出现“我明明执行了 clean 怎么没反应”的灵异事件。再来说默认目标。make 会把第一个非伪目标当成默认目标。所以习惯上把all写在第一行让它依赖你要构建的最终产物all: $(TARGET)这样敲make等价于敲make all。如果你只有一个最终产物不写 all 直接让第一个目标就是$(TARGET)也行但写上 all 的好处是以后加子目标比如all: app tests不用挪位置。2.3 一个可以直接抄的完整模板把上面的思路串起来我自己的小项目模板长这样CC gcc CFLAGS -Wall -Wextra -g -Iinclude LDFLAGS LDLIBS -lm TARGET app SRCS main.c utils.c OBJS $(SRCS:.c.o) all: $(TARGET) $(TARGET): $(OBJS) $(CC) $(LDFLAGS) $^ -o $ $(LDLIBS) %.o: %.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -f $(TARGET) $(OBJS) .PHONY: all clean$(SRCS:.c.o)是变量替换把源文件列表里的.c换成了.o这样加文件时只需要改SRCS这一行。LDLIBS单独放是为了区分编译参数和链接库因为-lm这种必须放在文件列表后面顺序错了链接器会报 undefined reference。注意配方那一行开头的缩进必须是 Tab 键不能是空格。这是新手最容易踩的坑后面排查部分我会细讲。3. “生成 makefile”的正确打开方式工具辅助与模板体系3.1 cmake 生成 makefile适合跨平台项目很多人在搜“生成 makefile”因为手写 makefile 确实有学习成本。手写维护一套跨平台的构建脚本光是处理 Windows 和 Linux 的编译器差异就够头疼的这时候直接用 CMake 这类工具去生成 makefile 是更务实的路线。CMake 的用法很简单在一个CMakeLists.txt里描述项目结构cmake_minimum_required(VERSION 3.16) project(demo C) add_executable(app main.c utils.c) target_include_directories(app PRIVATE include)然后在项目根目录敲cmake -S . -B build cmake --build build -j$(nproc)第一条命令会在build目录里生成一个 Makefile 和一堆辅助文件第二条命令本质上是调用 make 去构建。好处很明显CMake 自动处理了编译器检测、跨平台差异、头文件依赖生成比手写 makefile 省心得多。而且我们常见的make install、make test这类目标CMake 也帮你生成好了。用 CMake 生成出来的 Makefile 不建议直接改。它只是个中间产物重新跑一次 CMake 就会覆盖掉。要改需求就改CMakeLists.txt这才是稳定的入口。3.2 自动化生成工具的取舍市面上生成 makefile 的方案不止 CMake还有 autotoolsautoconf/automake、qmakeQt 项目、meson以及各类 IDE 自带的生成器。我的建议是分场景选择个人小项目、单目录、纯 C/Python C 扩展手写 makefile 完全够用而且你可以精准控制每一个命令。需要跨平台、给别人用、将来可能要给别人扩展优先 CMake它是目前生态最广的方案文档多、社区大、坑相对少。老牌开源项目用 autotools 的能看懂结构就行自己新起项目别再用学习曲线陡峭生成的文件还一团乱麻。还有一个容易被忽略的“生成”途径从自己的历史项目复制 makefile 改改。我自己电脑里存着一个templates/目录里面有纯 C 的、C 的、带 Python 调用的、带测试目标的需要时直接抄一份改名字。这比搜“makefile 菜鸟教程”然后对着抄效率高多了因为模板里的变量和结构都是经过验证的。3.3 建立自己的 makefile 模板库说句掏心窝的话所谓“会写 makefile”到最后不是背语法而是建立一套自己的模板体系。我会在模板里固定几个约定变量命名统一CC、CFLAGS、LDFLAGS、LDLIBS、SRCS、OBJS、TARGET这套是社区通用命名别人看你的 makefile 也不费劲。目标是all/clean/test/install那套约定俗成不要自创一个buildit。每个模板都带上-Wall -Wextra和-g前者是保证代码质量的基本防线后者是保证能 gdb 调试的基本条件。模板这东西一旦建好收益是长期的。我现在的 C 项目甚至十几分钟就能搭起来makefile 基本不花时间工作量全在设计代码结构上。4. 实用经验与常见报错排查实录4.1 “没有指明目标并且找不到 makefile”到底怎么回事这句报错原文是make: *** No targets specified and no makefile found. Stop.直译就是你没告诉我目标当前目录也没有 makefile 可读make 不知道干啥。它通常在两种情况下出现当前目录确实没有 makefile文件名不对或目录不对。make 默认找名为GNUmakefile、makefile、Makefile的文件注意大小写。很多人从网上复制项目解压后文件叫makefile.txtmake 压根不认识。makefile 存在但文件里面没有任何目标。这种多半是你误操作创建了空文件或者 makefile 内容全被注释了。排查命令就一条ls -l [Mm]akefile*实际看一眼文件在不在。我工作中常见的场景是把 makefile 放在子目录里人站在根目录敲 make自然找不着。解决办法是用make -C subdir让 make 先进子目录make -C build all-C这个参数的作用是先切换到指定目录再执行 make比你在目录之间 cd 来 cd 去强多了。这也是多目录项目组织的基本手法定后面细说。4.2 missing separatorTab 被空格替换成的大坑makefile:2: *** missing separator. Stop.这句报错绝对是新手区第一杀手。它发生在你写了规则、但配方那行的缩进不是 Tab 的时候。有些编辑器默认把 Tab 自动替换成 4 个空格你写完 makefile 一执行就报错。我之前带过一个新人他反复检查语法觉得没问题最后发现他在 VS Code 里启用了“detect indentation 空格缩进”配方行全是空格。你在编辑器里看到的是“对齐了”但 make 不认。这个坑没有任何技术含量但特别隐蔽因为视觉上完全看不出来。我有两个推荐的办法写 makefile 前先在配置里关掉“Tab 转空格”或者把当前文档的缩进检测改为 Tab。拿cat -A Makefile看一眼配方行开头应该是^ITab 的转义表示如果显示成空格立刻就知道问题出在哪了。cat -A是排查 makefile 缩进的终极大杀器用一次就忘不掉。4.3 No rule to make target依赖文件找不到怎么办No rule to make target foo.o, needed by app是另一种高频报错。含义是 make 知道app需要foo.o但没有任何规则能生成foo.o也没有现成的foo.o文件。这个报错的排查思路分三步检查foo.o对应的源文件foo.c是不是真的在。手残删了文件或者路径写错是最常见的原因。检查你有没有提供从.c到.o的规则。如果没写模式规则且foo.o没有规则make 就不知道去哪找。检查源文件的路径是不是在变量里漏掉了。比如SRCS main.c utils.c结果你实际有个foo.c忘了加进去那foo.o自然没有来源。我还遇到过一种更隐蔽的wildcard函数展开出来是空。比如SRCS $(wildcard src/*.c)这个写法本身没问题但如果src目录不存在或者拼写错了wildcard就静默返回空。你看到OBJS也是空的最终产物链接时什么都不依赖make 也不报错构建出来是个空壳。这种静默失效最折磨人需要$(info $(SRCS))打印变量来排查。4.4 链接报错的一般流程undefined reference to xxx属于链接阶段的问题不在 makefile 语法错误范围内但在实际构建中经常碰到。原因通常不是代码没写对而是链接顺序问题gcc main.o utils.o -lm和gcc -lm main.o utils.o结果不一样。GNU 链接器是往一个方向解析符号的库要放在依赖它的目标文件后面。排查套路确认LDLIBS放在命令的最后面而不是前面。如果是自己的.o文件互相引用导致 undefine检查OBJS列表里有没有漏掉某个.o。库文件用绝对路径或-L指定搜索路径避免链接到系统里老版本的库。链接报错往往是 makefile 运行没问题、但程序构建失败所以很多人容易忽略 makefile 本身可能也有锅。我见过有人为了修一个undefined reference把代码翻了个底朝天最后发现是 makefile 里漏加了一个.o文件——编译只执行了部分文件链接自然缺符号。4.5 多目录项目make -C 与递归 make 的取舍项目大了以后src、lib、tests 各放一个目录单 makefile 管所有东西会越来越吃力。常见的做法是每个子目录写一个自己的 makefile然后在根 makefile 里用make -C去调用all: make -C src make -C lib clean: make -C src clean make -C lib clean这个方案叫递归 make理解起来直白但服务大型项目时会遇到两个问题。一个是并行构建容易错乱你在根目录执行make -j8理论上各子目录能并行构建但子目录之间的依赖关系 make 根本不知道可能 src 还没编完 lib 就在用它产出的静态库。另一个是全局的依赖追踪变得困难头文件在 include 目录.o 在 src 目录源文件之间互相 include递归 make 处理起来很别扭。如果你的项目结构是 src/、lib/、include/ 这种我更推荐在一个根 makefile 里用通配符收集所有源文件让 make 自己处理依赖SRCS $(wildcard src/*.c lib/*.c) OBJS $(patsubst %.c,%.o,$(SRCS))$(patsubst)是模式替换函数把.c映射成.o。这样每个子目录的 .c 文件都直接纳入同一个依赖图构建逻辑变成全局的并行也不会出错。只有项目特别大、子目录里各自有独立的构建配置时才值得用递归 make 去隔离复杂性。5. 把 makefile 用到编译之外的场景5.1 用 makefile 管理数据管线makefile 的核心是“目标、依赖、配方”时间戳增量这套逻辑不只是能编代码任何有依赖关系的流程都能用。我自己经常拿它做数据处理。比如我有一份日志数据要清洗然后汇总出报表传统做法是写一个 Python 脚本从上到下跑完。但数据量大了以后每次全量重跑非常浪费时间。用 makefile 把流水线拆成两个目标data/clean.csv: data/raw.csv scripts/clean.py python scripts/clean.py data/raw.csv data/clean.csv report.txt: data/clean.csv scripts/report.py python scripts/report.py data/clean.csv report.txt这样有两个实际收益。其一你改了clean.pymake 会发现data/clean.csv比脚本旧自动重跑清洗但report.txt只有在clean.csv更新后才会重新生成。其二你跑第二遍的时候什么都不改make 会告诉你data/clean.csv is up to date整个流水线零耗时。5.2 用 makefile 做环境部署与项目维护同类思路还能用在部署、构建镜像、同步产物这些运维操作上。给我自己的本地开发环境写的 makefile 里会有一批伪目标.PHONY: start stop restart logs start: docker compose up -d stop: docker compose down restart: stop start logs: docker compose logs -f这一套虽然是 makefile但本质上是个“命令别名管理器”好处是项目的常用操作命令被固化下来了不用担心“那个 deploy 脚本搁哪了”的问题。新同事接手项目一条make help就能看到所有支持的操作。失去任何文本后这个价值会逐渐显现。5.3 我常用的几个提升效率的 make 参数最后分享几个高频使用的 make 参数都属于文档里能找到、但日常很少人注意的make -j$(nproc)并行构建nproc返回 CPU 核数$(nproc)是 shell 命令替换。多核机器上编译速度提升肉眼可见。make -n干跑模式只打印要执行的命令不真正执行。排查“这条规则到底会跑哪些命令”非常好用。make -d打印调试信息会输出一大堆 make 的决策过程适合搞清楚“为什么这个目标没有重建”。make -p打印内置规则和变量查看当前环境下 make 默认的编译命令长什么样。日常用得最多的其实是-n和-j。写 makefile 的时候改完规则先-n跑一遍确认命令符合预期再用-j正式构建基本能避开 90% 的“规则写错导致乱跑”的情况。我自己长期用下来最大的体会是makefile 这门手艺跟写代码一样本质是在给未来的自己留线索。三个月后你回来看项目能通过一个 makefile 快速想起这个项目怎么构建、怎么测、怎么部署比翻 README 里过时的命令行说明靠谱得多。所以越早把 makefile 用起来后面省的时间越多。也别急着一次学完所有语法先拿一个文件练手、搞定自动编译清理遇到新的需求再逐个击破它就会慢慢成为你项目里不可或缺的一部分。
返回列表