首页 / 资讯中心 / 文章详情

Makefile执行细节:默认Shell、命令回显与并行编译的避坑指南

Makefile执行细节:默认Shell、命令回显与并行编译的避坑指南 ★ FEATURED ARTICLE
写Makefile的人大概都有过这种经历一条命令在终端里手动执行好好的放进Makefile里就报错或者同一段逻辑在别人电脑上能跑通自己电脑上就翻车又或者用make -j8并行编译时日志忽前忽后偶尔还冒出诡异的“文件不存在”。这些问题的根源往往不在命令本身而在 make 执行命令的方式上——默认 shell 是什么、命令如何回显、单行与多行命令有什么本质差异、以及并行时到底什么能并行什么不能。这篇内容围绕这几个 Makefile 入门阶段最容易忽略的点展开适合刚接触 Makefile 的读者也适合写了一段时间但老被“随机性”问题困扰的人。1. 默认 shellMakefile 里那个隐形的执行者1.1 每条 Recipe 命令都由 /bin/sh 来跑Makefile 里规则下面的命令在 make 术语里叫Recipe。很多人以为是 make 自己执行这些命令其实不是。make 干的事是把 Recipe 里的每一行命令交给一个 shell 去解释执行。默认情况下这个 shell 是/bin/sh执行方式大致等价于/bin/sh -c 你的命令想亲眼确认这一点的最快办法是在 Makefile 里写一个规则打印$0check-shell: echo 当前 shell 是$$0注意这里必须写$$0如果写成$0make 会先尝试把$0当 make 变量展开多半展开成空最后 echo 什么也打不出来。跑一下make check-shell输出会是/bin/sh或者sh绝不会是你登录终端里的 bash 或 zsh。这个细节看起来不起眼却解释了无数个“为什么我在终端里能跑放进 Makefile 就挂了”的疑难杂症。1.2 默认 shell 不是 bash很多语法会翻车在常见的 Linux 发行版上/bin/sh往往是一个指向更轻量级 shell 的符号链接这导致 Makefile 的默认 shell 实际是一个只支持 POSIX 语法、功能精简的解释器。它和 bash 的差距比很多人想象中大得多。最容易踩中的坑包括这几种[[ ]]条件表达式不识别会报[[ not found数组不识别arr(1 2 3)直接语法错误source命令不识别得用.点命令echo -e不会转义\n而是把-e当一个普通字符打出来花括号扩展{1..5}无效会原样输出进程替换(...)、(...)不识别举个例子下面这个规则在默认 shell 下必挂demo: if [[ -f Makefile ]]; then echo 找到 Makefile; fi执行时会看到/bin/sh: 1: [[: not found make: *** [Makefile:2: demo] Error 127原因是 make 把这一整行if...fi交给/bin/sh -c去解析而/bin/sh只认 POSIX 的[或test。解决办法有两个要么改成if [ -f Makefile ]; then ...要么让 make 换一个 shell 来跑。1.3 用 SHELL 变量切换 Makefile 的 shellMakefile 里有一个专门的SHELL变量用来决定 Recipe 命令交给谁去执行。注意它跟你在终端里执行echo $SHELL看到的环境变量不是一回事——make 不会读你环境变量里的 SHELL 来作为执行 shell它有自己的一套默认逻辑没写就是/bin/sh。所以哪怕你登录 shell 是 zshMakefile 默认依然用/bin/sh。显式指定用 bash可以写在 Makefile 靠前的位置SHELL : /bin/bash这样后面所有 Recipe 命令都由 bash 执行[[ ]]、数组这些就能用了。这里有两个建议SHELL变量的值必须是绝对路径写成bash这种不带路径的名字make 可能找不到。尽量把SHELL的赋值放在 Makefile 开头或者至少放在使用这些特性的规则之前避免后面规则解析和实际执行环境不一致。如果你用的是较新版本的 GNU make还可以通过.SHELLFLAGS控制 shell 的启动参数默认是-c。比如想额外加上-e命令出错即退出和-x打印执行过程可以写.SHELLFLAGS : -exc不过这是后话新手阶段先记住三点默认 shell 是/bin/sh/bin/sh不等于 bash想省心就用SHELL : /bin/bash显式切换。2. 命令回显看到的不一定是真的在执行2.1 默认回显与 符号make 在默认情况下会把每一条准备执行的命令先打印出来然后再真正执行它。看下面的规则show: echo 我会先被打印出来 echo 我不会被打印出来运行make show输出大概是echo 我会先被打印出来 我会先被打印出来 我不会被打印出来第一行命令先回显了命令本身再显示它的输出第二行命令前面加了所以只有输出没有回显。的作用就是在 make 执行这一行命令时抑制命令本身的打印。2.2 符号的三个使用细节第一必须放在命令行的最开头。像echo hello这种写法只是 echo 的参数不起任何抑制回显的作用。echo hello才是把抑制回显和输出文本组合在一起的标准写法。第二只对所在行有效。一个规则里有三行命令只在第一行加后面两行照样回显。想让整个规则都不回显要么每行都加要么用后面要讲的make -s。第三只是 make 层面的输出控制它不会进入传给 shell 的命令文本。也就是说 make 打印命令时不把它算进去shell 执行命令时也看不到它它纯粹是 make 自己的“静音开关”。2.3 回显的另一个价值看变量展开结果回显并不只是制造噪音它还有个很实用的功能make 在回显时打印的是变量展开之后的最终命令而不是 Makefile 里的原始写法。这一点对排查问题非常有用。FILE : demo.txt clean: rm -f $(FILE)make -n clean下节会细说时会显示rm -f demo.txt你要是在 Makefile 里写的是rm -f $(FILE)通过回显就能确认FILE到底展开成了什么。所以写规则时如果怀疑某个变量没传对不用急着加一堆调试输出先看看 make 回显的完整命令往往一眼就能定位问题。3. make 参数-n 只说不做-s 做了不说3.1 make -n预演模式的正确用法make -n是排查 Makefile 问题时最高频使用的参数之一。它的作用是make 把准备执行的每一条命令完整打印出来但绝不真正执行。类似彩排所有动作都展示一遍但台上的人不会真的跑。典型使用场景有这三种刚接手一个旧项目的 Makefile想先看看make install或make clean到底会碰哪些文件、跑哪些命令。怀疑某个目标没有按预期重新生成用make -n看它到底有没有把那条编译命令纳入计划。自己新写了一个规则想确认变量展开、命令拼接是否正确先用make -n验一遍再实跑。比如有一个清理规则clean: rm -f *.o *.bin rm -rf build/执行make -n clean只会打印这两行rm命令文件实际不会有任何变化。对新手来说这是最安全、最友好的“试运行”方式。3.2 -n 不是百分百无副作用这里必须提醒一个误区make -n确实不会执行 Recipe 里的命令但它管不住 make 在读取 Makefile 阶段执行的东西尤其是$(shell ...)函数和$(eval ...)这类解析期操作。举个例子VERSION : $(shell echo write /tmp/make-version.txt) demo: echo version ok就算执行make -n demomake 在解析 Makefile 时就已经把$(shell echo ...)跑完了/tmp/make-version.txt会被真实地创建出来。所以在make -n下不要因为“没执行 Recipe”就判定整个命令绝对安全还要留意 Makefile 顶层有没有$(shell ...)的副作用。3.3 make -s静默执行但绝不偷懒与-n不同make -s会实打实地执行每条命令只是不打印命令本身。可以把它理解为给 Makefile 里所有 Recipe 命令统一加了。这两者的区别用一个表来对照最清楚参数是否执行命令是否回显命令主要用途无是是默认行为适合日常构建观察过程-n否是预演、排查、确认即将执行的命令-s是否只关心输出结果不想看命令流水账make -s最适合的场景是项目构建时日志量巨大一堆gcc、cc、cp命令占满屏幕真正想看的只有编译错误和最终提示。加上-s之后日志瞬间清爽。3.4 其他值得记住的常用参数make -B无视时间戳比较强制把涉及的目标全部重新构建。适合改了 Makefile 规则、但发现 make 总说“Nothing to be done”时使用。make -C dir先进入dir目录再执行 make。写脚本时经常用它替代cd dir make。make -d调试模式输出海量决策信息适合研究 make 为什么认为某个目标过期新手看了容易头晕按需使用。-n和-s也可以组合使用比如make -ns clean就是只打印命令但不执行同时因为-s的存在make 自身的额外提示也不打印。组合使用一般不如单独用那么直观不过在自动化脚本里偶尔能派上用场。4. 单行命令与多行命令换个行世界就变了4.1 每行 Recipe 命令都是独立的 shell 进程这是 Makefile 与普通 shell 脚本相差最大的地方。在 shell 脚本里cd /tmp之后下一行pwd打印的就是/tmp因为两行在同一个 shell 进程里顺序执行。但在 Makefile 里不是这样demo: cd /tmp pwd执行make demopwd打印的还是你执行 make 时的当前目录而不是/tmp。原因就是 make 把每一行 Recipe 命令分别交给一个新的/bin/sh进程去执行前一行里的cd只影响那一个子 shell下一行启动时又是一个全新的 shell 进程工作目录回到了 make 的当前目录。理解这个模型后很多 Makefile 里的“诡异现象”就都通了在一行里export VARxxx下一行读不到这个变量。在一行里cd build下一行还在原目录。在一行里source env.sh下一行环境变量没变化。4.2 用反斜线把多行连成“一条逻辑命令”既然多行之间状态不共享想在 Makefile 里实现类似 shell 脚本的连贯操作标准做法是用反斜线\把多行物理行拼接成一条逻辑行。make 在解析 Recipe 时会删掉行尾的反斜线并把本行和下一行合并最终交给 shell 的是拼接后的一条完整命令。build: cd /tmp \ echo now in /tmp \ pwd这个规则实际交给 shell 的命令是cd /tmp echo now in /tmp pwd。因为反斜线连接后的内容在同一条 shell 命令行里执行所以cd的效果能被后面的echo和pwd共用。需要注意两个高频错误。一是反斜线后面不能有空格。Makefile 里写cd /tmp \反斜线和下一行之间是同一个逻辑行但反斜线后如果跟了一个空格make 会理解为“命令加上一个空格”续行失效结果执行的是残缺文法大概率报错。二是拼接后 shell 不会自动帮你补逻辑连接符。cd /tmp \然后换行直接写pwd最终命令变成cd /tmp pwdshell 会尝试把pwd当作cd的参数报错。要么用连接前后有依赖关系的命令要么用;连接不关心前一条成功与否的命令。4.3 分号连接与反斜线的取舍如果只是简单地把两三条命令放一行直接用分号也行不用反斜线demo: cd /tmp; pwd这种写法省事但命令一长可读性就很差。Makefile 的习惯是逻辑上属于同一阶段的多步操作用反斜线折行每行写完用收尾看起来像 shell 脚本折行实际是一条命令。这样既保留了可读性又保证了执行连贯性。4.4 $$ 转义make 变量和 shell 变量别打架单行与多行之外还有一个新手最容易卡住的地方Makefile 里写$到底是在说 make 变量还是 shell 变量Makefile 的规则是make 先扫描整条命令把$开头的结构当作 make 变量来处理。想给 shell 传一个$必须在 Makefile 里写成$$。make 会把$$转义成一个$字面量传给 shell然后 shell 再展开成它自己的变量。demo: echo 我当前的家目录是$$HOME如果不写$$而是直接写$HOMEmake 会把HOME当作一个 make 变量去查找通常结果是空最后命令变成echo 我当前的家目录是输出里什么都没有。这也是很多从 shell 脚本搬过来的命令进 Makefile 后变量全变成空字符串的直接原因。4.5 .ONESHELL把整个规则放进同一个 shellGNU make 从 3.82 版本开始支持.ONESHELL特殊目标。声明.ONESHELL:之后它的所有后续规则里的 Recipe 命令会在同一个 shell 进程中执行cd、临时变量、source这些状态可以跨行保留。.ONESHELL: demo: cd /tmp pwd这个例子在普通模式下第二行pwd会打印 make 的执行目录加了.ONESHELL后cd /tmp的效果保留pwd打印的是/tmp。但.ONESHELL并不是银弹。切换到它以后make 不再像默认模式那样逐条检查每条 command 的退出码出错行为会变得和普通 shell 脚本类似需要自己用set -e或来控制“出错即停”。加上这个行为在不同 make 版本里有细微差异我的建议是新手阶段尽量用反斜线显式拼接多行命令理解清楚执行模型之后再根据需要引入.ONESHELL。5. 并发执行make -j 的提速与翻车实录5.1 -j 带来的直观变化默认情况下make 是串行执行的先构建完一个目标再构建下一个。项目一大这种模式就很浪费时间尤其现在很多机器动辄八个十六个核心。make -j N的意义就是允许 make 同时运行最多 N 个 Recipe 任务。用一个直观例子体验一下all: a b c a: sleep 2 echo a done b: sleep 1 echo b done c: sleep 1 echo c donemake串行执行需要约 4 秒make -j3的话三个目标同时跑总耗时大约 2 秒。省下来的就是三个任务重叠执行的时间。5.2 依赖关系决定了并行边界make 并不是一股脑把能跑的命令全部同时丢出去。它的调度逻辑是先看目标之间的依赖关系只有当一个目标的所有依赖都构建完成它才会启动。所以在 Makefile 里把依赖关系写清楚是并行安全的第一个前提。all: app app: lib echo link app lib: echo build libmake -j4时lib必须先完成app才开始不会出现 app 抢在 lib 前面执行的情况。make 的并行能力恰恰建立在依赖图上那些没有依赖关系的兄弟目标比如前面示例里的a、b、c才会被并行调度。反过来如果依赖关系漏写了串行模式下可能靠运气跑通一旦并行顺序错乱就会导致随机性的失败。所以写 Makefile 时任何“先有 A 才能做 B”的关系都必须显式写成依赖。5.3 同一个规则内部的多行命令也可能并行这一节的内容是最容易被忽视、也最容易引发线上事故的点。很多人以为一个规则里的多行命令make 会严格按顺序执行。在非并行模式下确实如此但在-j模式下不保证。原理还是前面说的独立 shell 模型Recipe 的每一行都会生成一个 job而 GNU make 的 jobserver 调度器并不认为同一规则的各行之间有依赖顺序。只要目标过期需要执行它可能把两行并发跑。看这个例子demo: echo hello /tmp/demo.txt cat /tmp/demo.txtmake -j2 demo时cat完全有可能先于echo执行。结果要么是cat: /tmp/demo.txt: No such file or directory要么读到的是上一轮遗留的旧内容。这种问题在串行构建下永远不会出现一旦开启并行就随机复现排查起来极其耗神。解决办法有三种用反斜线把有先后关系的命令拼到一行也就是前面演示过的连接法。用.ONESHELL让整个规则在同一个 shell 里跑保持行间顺序。把“前一步的结果要交给下一步”的逻辑拆成两个目标用依赖关系表达先后。我自己在项目里最常用的还是第一种因为它最直白make 版本兼容性也最好。5.4 并发实操值得养成的四个习惯第一-j的取值不用盲目追求大。nproc能查到 CPU 核数工程上常用“核数 1”作为初始值。但编译高峰期如果内存吃紧-j32可能比-j8慢得多丢失的时间都花在换进换出上。大型 C/C 项目可以先从-j4开始往上试找到一个时间和内存都合适的档位。第二并行任务输出会交错。多个进程同时往 stdout 打印日志会混在一起。若日志需要接后续分析让每个目标把输出写到各自的重定向文件最后再汇总比在屏幕上拼运气清晰得多。第三临时文件名尽量包含 shell 的 PID。并行时多个 job 同时写同一个固定路径的临时文件必然有竞争。在命令里用$$$$表示当前 shell 进程号能有效避免这种冲突tmp : /tmp/build.$$$$ demo: echo data $(tmp) cat $(tmp) rm -f $(tmp)第四不要在 Makefile 里写需要交互的命令比如read、等待键盘输入、交互式选择器。并行模式下多个任务同时要输入终端会乱成一团且容易导致任务挂死。make -l还可以限制系统负载超过阈值时不再启动新任务适合生产环境多任务混跑的情况。不过对大多数人来说先把-j的这几个常见坑避开收益远大于折腾高级参数。6. 常见问题与排查技巧实录6.1 高频报错速查表现象可能原因解决方案[[ not found默认 shell 是 /bin/sh不识别 bash 的[[ ]]改成[ ]或设置SHELL : /bin/bashmissing separator或recipe commences before first targetRecipe 行开头用了空格没用 Tab把缩进改成 Tab注意不是 4 个空格Nothing to be done for allall目标已存在或没有依赖且没有声明为伪目标该目标不是文件时声明.PHONY: all命令里的变量全是空Makefile 中写了$HOME而不是$$HOME普通 shell 变量写成$$VARmake -n竟然产生了文件$(shell ...)在 make 解析阶段就执行了理解-n不拦截解析期命令排查时额外注意顶层的$(shell ...)并行构建时偶发文件不存在同一规则内多行命令被并行执行用连接有先后关系的行为或拆分子目标加了还是有回显没放在命令行开头让成为行首第一个字符6.2 拿到一个未知 Makefile先做这两步排查第一步确认 make 用的是哪个 shell。写一条临时规则打印$$0看输出是不是/bin/sh。发现奇奇怪怪的语法报错时这个动作能省大半时间去猜环境差异。第二步检查 Makefile 里有没有-j相关的调用习惯。如果项目有人习惯make -j16直接上并行类问题就会高频出现。看到“偶发报错”“换台机器就崩”的描述我第一个怀疑的就是这两个方向。6.3 我个人习惯的几个小技巧写新的 Makefile 时我会先用make -n把整条流水线预演一遍重点看变量展开和命令拼接是否符合预期确认无误再实际make -j4。这个习惯帮我避免过很多次“规则写错了但已经把文件删了”的悲剧。对于有用户提示价值的输出比如“构建完成”“安装成功”一定要用echo而不是裸echo否则命令回显和输出混在一起日志非常难看。反过来记录关键操作步骤的命令比如gcc编译命令我一般故意保留回显方便日后核对到底用了什么参数。多行命令只要逻辑上属于同一阶段我就用反斜线折行并在行尾补。这一条约定让我在一个二十多条命令的复杂规则里也能一眼看出哪些步骤是联动的、哪些之间可能被打断。关于并发我会把“每条 Recipe 行都是独立 job”这句话贴在工位上。Makefile 里出现任何“先写文件再读文件”“先创建目录再进入目录”“先设置环境再调用命令”的连续操作第一反应是把它们绑进同一行而不是依赖 make 的行间顺序。毕竟 make 的设计里行与行之间就没有顺序保证这回事尤其在-j模式下。我在实际项目里体会最深的一点是Makefile 看起来只是把命令一条条列出来但它的执行模型和普通 shell 脚本差别很大。理解了“每行一个独立 shell”和“依赖关系决定并行边界”这两条主线再回头看那些莫名其妙的报错和随机性失败基本都能找到明确的原因。遇到问题先怀疑 shell再怀疑并行通常错不了。
阅读完成 · 觉得有帮助?
咨询建站