
这个系列写到第8篇前面把规则、变量、函数、条件判断这些“骨架”都过了一遍今天聊点更底层的执行细节Makefile里的命令到底是谁在跑、怎么回显、能不能并行。别小看这些边角料我见过太多人在上面翻车——有人在recipe里写let i直接报 not found有人cd进目录后发现下一行又回到了原目录还有人一加-j输出就乱成一锅粥。这篇文章专治这些问题覆盖默认 shell、命令回显、-n/-s参数、单行多行命令和并发执行适合已经会写基本规则、想把 Makefile 写得更稳更快的开发者。1. 默认 shell 到底是谁为什么总是 /bin/sh 而不是 bash1.1 先搞清楚 Makefile 里命令的“执行者”很多人第一次在 Makefile 里写echo $0看到输出是sh会愣一下我终端用的明明是 bash怎么到了 Makefile 里就变成了 sh因为 GNU make 在执行 recipe 时默认调用的是/bin/sh -c 你的命令行而不是$SHELL也不是 bash。这个设计来自 POSIX 标准make 必须跑在任何一个类 Unix 系统上而/bin/sh是约定俗成一定存在的解释器用它做默认值可移植性最有保障。换句话说Makefile 的 recipe 每一行本质上都是由/bin/sh这个“工人”去执行的。你可以在 Makefile 里这样验证check-shell: echo shell $(SHELL) echo $$0这里有个细节要留意make 里的变量SHELL和你在 shell 环境里echo $SHELL得到的值不是一回事。make 有自己内置的SHELL变量默认就是/bin/sh。你的登录 shell 是 bash/zsh 都不影响它除非你显式去改。1.2 sh 和 bash 到底差在哪三个高频翻车现场默认 sh 带来的第一个问题就是语法差异。很多开发者习惯写 bash 语法结果在 Makefile 里直接爆炸。我总结最常见的三种let i1bash 内置的整数运算关键字dash常见的 /bin/sh 实现里根本没有会报let: not found。[[ ... ]]bash 的条件测试关键字sh 不认要用[ ... ]或case。echo -e a\nbbash 会把-e当作选项处理但某些 sh 实现会把-e原样打印出来。如果你的代码要同时跑在不同系统上老老实实按 POSIX sh 语法写 recipe是成本最低的选择。这里说的“不同系统”不针对具体发行版而是指你无法保证每个用户的 /bin/sh 都链接到 bash。这不是小众问题很多最小化安装的环境默认 sh 都不是 bash。1.3 真的想用 bash 怎么办可以显式改 SHELL如果你确认当前环境一定支持 bash也可以在 Makefile 里改SHELL : /bin/bash注意用:立刻赋值避免递归展开带来的意外。改完之后recipe 每行就变成了/bin/bash -c ...bash 语法就都能用了。但我的建议是除非项目确实需要数组、进程替换这类 bash 特性否则不要轻易改。改了SHELL意味着这个 Makefile 基本告别了“在任何 Unix 上直接跑”的便利性换来的只是一时顺手。另外一个相关但容易忽略的点make 的 recipe 不会加载你的~/.bashrc、~/.zshrc。所以你在交互终端里定义过的 alias、函数、环境变量在 Makefile 里默认一个都没有。我常见有人写deploy: ll /tmp然后报ll: command not found因为ll是 alias不是真正的命令。解决办法就一个recipe 里不要依赖任何交互 shell 的配置需要的环境变量用 export 明确写在 recipe 里或者通过$(shell ...)去取。2. 命令回显为什么 make 每次都把命令打印一遍2.1 回显的本质make 在“替你敲命令”如果你第一次跑 make大概率会看到这样的输出gcc -c main.c -o main.o gcc -c utils.c -o utils.o gcc -o app main.o utils.o每一行命令都被打印出来然后才执行。这不是 shell 的set -x而是 make 自己的默认行为make 会把将要执行的命令行先输出到 stdout然后交给/bin/sh -c去执行。就好比 make 把你手写的命令在终端里先“念”了一遍再真的去执行。这个设计是有历史的早期没有日志系统打印命令就是最好的构建记录。直到今天通过回显你也能清楚看到变量展开后的真实命令比如 Makefile 里写的是$(CC) -o $ $^回显里就会显示gcc -o app main.o utils.o方便排查变量有没有展开错。2.2 符号把这一行命令“静音”如果某一行你不想被回显就在行首加build: echo 开始编译... $(CC) -o app $(OBJ)加了之后echo 开始编译...这行命令本身不会被打印但它的输出也就是“开始编译...”这行文字还是会显示。注意区分关掉的是“命令回显”不是“命令输出”。echo这个组合是 Makefile 里最常用的写法几乎每个项目的clean、install目标里都能看到。因为它能让构建日志看起来更简洁只留下有信息量的输出而不是满屏的echo ...。2.3 make -n只显示命令不真正执行-n也叫 dry-run或者--just-print。字面意思就是“只把命令打印出来但不执行”。这个参数在调试 Makefile 时太好用了make -n输出结果会是把所有需要执行的命令按顺序打印一遍画风类似上面的回显但最后不会真的去编译。 使用场景非常典型你改了一堆规则想确认某目标到底会触发哪些命令又不想真的动文件系统你想知道某个目标是否会被重建但又不敢直接跑你想给别人展示一份“构建计划书”可以直接把make -n的输出贴出来。这里有个细节-n模式下带的命令也会被打印出来而且打印的是去掉之后的原始命令。因为只是 make 层的静音标记平时在真执行时不让打印但在-n模拟执行时make 打印的是“本来要执行的命令”所以能把echo背后的echo也显示出来。这一点经常被刚接触的人忽略弄明白之后用-n排查问题会顺很多。2.4 make -s全盘静音-s是--silent或--quiet的缩写效果等于给 Makefile 里每一行都加了。也就是说make -s跑完整个构建你不会看到任何一行命令只看到命令的输出结果。make -s这个参数适合在 CI 或脚本里使用日志里只需要“有没有报错”和“产物生了没有”不需要完整回显。注意-s和-n同时出现时-n优先因为“模拟执行要打印命令”是调试需求静音反而没意义。为了更清楚地对比这几个开关我整理了一个小表写法作用范围命令是否打印命令是否执行典型用法默认无前缀每一行打印执行平时构建行首加仅当前行不打印执行静默 echomake -n整个 Makefile打印含 命令不执行调试/预览make -s整个 Makefile不打印执行CI/脚本make -n -s整个 Makefile打印不执行强制预览2.5 顺带一提忽略错误的-前缀和回显控制同属于“行首前缀”体系的还有一个-。写在命令前表示“这一行命令执行失败也继续不中止整个目标”clean: -rm -f *.o echo clean donerm删不存在的文件会返回非零状态前面加-后 make 不会把它当错误中断。这是 Makefile 里处理“允许失败”命令的标准姿势和一样是 recipe 前缀可以连写clean: -rm -f *.o我习惯把-合在一起既不要回显失败了也无所谓。顺序无所谓-也可以。3. 单行命令 vs 多行命令Makefile 里最反直觉的一个坑3.1 每一行都是独立的 shell这才是根源如果你以前写过 shell 脚本到了 Makefile 里大概率踩过这个坑你写了两行 recipe第一行cd build第二行pwd结果 pwd 还是原来的目录cd 好像白写了。看一个例子wrong: cd /tmp pwd跑一下$ make wrong cd /tmp pwd /path/to/your/project为什么因为 make 对 recipe 的规则是每一行命令都由一个新的/bin/sh -c ...去执行。也就是说第一行是/bin/sh -c cd /tmp执行完这个 shell 就退出了cd 的作用只存在于那一个 shell 进程里第二行是新起的 shellpwd 当然还在原目录。这就是“单行命令”和“多行命令”的真正分界线不是看你在 Makefile 里写了几行字而是看 make 会把哪些文本放进同一个 shell 进程去跑。3.2 用分号写成一行从根本上解决问题最简单的方案就是把需要共享状态的命令塞到同一行right: cd /tmp pwd用连接cd 和 pwd 在同一个/bin/sh -c里执行pwd 打印的是 /tmp。但一行里写太多逻辑阅读性会很差。所以就有了续行写法right2: cd /tmp \ pwd \ ls这里要注意make 在读 Makefile 时会把\和换行当成续行符把三行拼成一行交给 shell 的还是一整行/bin/sh -c cd /tmp pwd ls。continuation line 是 make 层面的拼接shell 端看到的是同一行。有一个非常隐蔽的坑\后面不能有空格。如果你写成right3: cd /tmp \ pwd反斜杠后面跟了一个空格make 会认为这行没有续行符于是\被原样传给 shellshell 报错说不认识\。这种错误在编辑器中很难发现建议直接开启编辑器的空白字符显示。3.3 更现代的答案.ONESHELL 让整个 recipe 共享一个 shellGNU make 从 3.82 开始提供了.ONESHELL伪目标。只要在 Makefile 里声明它之后所有 recipe 的每一行命令就不各自开新 shell 了而是变成一个 shell 脚本来执行.ONESHELL: test: cd /tmp pwd此时 pwd 输出的就是 /tmp。cd跨行生效整个 recipe 就像一个普通的 shell 脚本。但 .ONESHELL 有它自己的坑最大的坑是它默认不会开启set -e。普通模式下make 发现一行命令返回非零状态就停止执行并报错但在 .ONESHELL 下所有行都在同一个 shell 里如果第一行失败第二行照样会执行。你得在 recipe 里手动加.ONESHELL: test: set -e cd /tmp pwd command_that_fails echo 这行不会执行因为set -e告诉 shell“一旦有命令失败就退出”。这是 .ONESHELL 使用中必须养成的习惯不写的话很容易出现“明明上一步失败了后面还在跑”的错觉而且错误信息还不好查。另外要注意.ONESHELL是 GNU make 专有的如果你的 Makefile 需要被其他 make 实现兼容慎用。如果只是个人项目或团队统一用 GNU make那可以放心上。3.4 多行命令中的变量为什么 shell 变量要写两个 $单行和多行的区别还牵扯到 make 变量和 shell 变量的边界。在 recipe 里$开头的东西会被 make 先展开所以你想用 shell 变量时要写成$$。比如写一个循环loop: for i in 1 2 3; do echo $$i; done这里$$i传给 shell 后变成$ishell 正确展开成 1、2、3。如果写了$imake 会把它当成 make 变量i不存在展开为空shell 端就变成echo循环彻底废掉。这个规则在多行命令里同样适用尤其是你用\把 for 循环拆成多行时每一处 shell 变量都要写$$loop: for i in 1 2 3; do \ echo num is $$i; \ done我见过不少人在这里栽跟头倒不是不懂$$而是for循环拆行时漏掉某一个$$编译半天才发现输出不对。最好的排查方法就是make -n它会打印 make 展开后的命令一眼就能看出$i是不是已经变成空了。3.5 什么时候用单行什么时候用多行说点实操经验。我的原则是简单的两步操作比如cd make直接一行写完别拆。逻辑稍微多一点的用\续行保持可读性但保证所有逻辑在同一个 shell 里。需要大量跨行共享变量、条件判断的复杂脚本不要硬塞进 recipe而是把脚本单独写成一个.sh文件在 recipe 里只留一行bash scripts/do.sh。真的想在 Makefile 里写一大段 shell再考虑.ONESHELL并加set -e。最忌讳的是把 Makefile 当 shell 脚本写指望 cd、export、变量赋值能跨行生效然后被 make 的“每行独立 shell”坑得死去活来。理解了底层机制之后这些行为就都说得通了。4. 并发执行make -j 是一把双刃剑4.1 -j 的原理按依赖图同时开工串行构建太慢是并发需求的原动力。make -j4的意思是“同时最多跑 4 个 recipe / job”。make 会根据依赖关系计算出一个“调度表”只要一个目标的所有依赖都已完成它的 recipe 就可以启动不管其他目标有没有完成。举个例子有 a、b、c 三个目标.PHONY: all all: a b c echo all done a: echo start a sleep 2 echo end a b: echo start b sleep 2 echo end b c: echo start c sleep 2 echo end c串行跑要 6 秒make -j3大概只要 2 秒因为三个 sleep 同时进行。但输出会变成这样start a start b start c end a end b end c看起来还好因为 echo 之间没有太多竞争。但当多个任务往同一个终端写大段日志时字符会互相穿插一片混乱。4.2 输出同步--output-sync 选项GNU make 4.0 起提供了--output-sync它可以缓冲每个 job 的输出等 job 整个结束再一次性打印避免穿插。make -j4 --output-synctargettarget模式表示“每个目标的输出作为一个整体输出”不会和别的目标混在一起还有line模式表示按行同步输出更实时但可能还会穿插recurse模式则适合递归 make 的场景。我实际使用中的建议CI 环境固定加--output-synctarget日志清楚很多。但要注意输出同步会带来一点内存开销需要缓存输出对小项目无所谓对超大项目来说如果每个 job 都打印巨量日志内存峰值会上升。4.3 并发的几个隐蔽问题输出只是小事后果严重的是文件竞争两个 recipe 如果同时写同一个临时文件结果就不可预期了。比如gen-a: echo a tmp.txt gen-b: echo b tmp.txt这两个目标如果被并发调度tmp.txt 的内容可能是a\nb、b\n、a\n里任何一种完全看谁先抢到文件。解决办法是如果两个任务确实存在“写完再看”的关系就把它们放到依赖链里让 make 强制排序如果只是为了避免并发冲突则可以用各自独立的临时文件名或者用flock加锁。递归 make 的 jobserver项目一大很多人喜欢在子目录里各自放 Makefile顶层用循环调子 make。并发时每个子 make 最好不要自己再开-j而是由顶层 make 通过 jobserver 机制统一调度。你只需要在顶层调用时写sub: $(MAKE) -C subdir这里$(MAKE)前面的前缀很讲究它告诉 make即使加了-n也要真的去执行递归 make否则make -n会停在顶层看不到子目录里要跑什么命令。如果递归子 make 时出现warning: jobserver unavailable: using -j1说明 jobserver 传递断了子 make 退化成单 job。常见原因是没有通过$(MAKE)而是直接写了make这样 jobserver 的环境变量传不过去。统一用$(MAKE)是标准解法。-j 后面不跟数字全速模式慎用make -j单独写GNU make 的意思是“不限制并行数量”有多少任务就开多少。看起来很爽但实际会带来巨大的资源竞争和内存压力。我在本机试过一次小型项目都能把系统卡到没响应。除非你明确知道自己在做什么否则永远给-j填一个数字。4.4 并发对 -n 和 -s 的影响并发模式下-n的输出顺序可能会让人困惑因为所有 job 都是“模拟”的make 会按调度顺序打印命令但由于不真正执行整个输出还是基本有序的一般不会出现穿插。-s在并发下依然有效配合--output-sync后整个构建过程只会看到最终输出非常干净。一个有用的组合是make -j8 -n先模拟并发场景下会跑哪些命令确认没有明显的并行冲突再真正执行。这个方法能让很多并发问题在动手前就暴露出来。4.5 并行就一定快吗不一定。有三种情况并行反而更慢任务本身是 I/O 密集的比如大规模拷贝、解压磁盘成了瓶颈开再多 job 也是排队。任务之间有隐藏的共享状态比如都往同一个数据库写锁等待导致实际执行时间拉长。机器本身核心少并发数超过 CPU 核心数太多上下文切换开销盖过了并行收益。我的经验是-j的数字先取 CPU 逻辑核心数然后通过实际构建时间微调。对于编译任务一般-j在核心数到核心数2 之间表现最好。再多内存占用先爆。有条件的话直接在 CI 机器上按核心数自动设置make -j$(nproc)这样可以避免不同机器上写死-j4与机器核心数不符的问题。5. 常见问题与排查技巧实录5.1 把这些高频报错和“灵异现象”整理成速查表日常帮别人排查 Makefile 问题久了我把最容易出现的情况整理成了一张表症状真正原因解决办法/bin/sh: 1: let: not foundrecipe 用了 bash 专属语法默认 shell 是 sh改成 POSIX 写法或SHELL : /bin/bash[[ not found类似问题[[是 bash 关键字sh 不支持用[ ] 正确转义或改 SHELLcd白做了下一行还在原目录make 每行独立 shell用cd /tmp 命令或改 .ONESHELLecho -e打印出-esh 实现不支持 echo 的 -e 选项用printf %b\n ...变量$$i忘了写$$循环只输出一次空串make 先展开$i为空recipe 里 shell 变量必须写双$make -j系统卡死-j 没带数字无限并发改为-j$(nproc)递归 make 报 jobserver unavailable子 make 没走$(MAKE)使用$(MAKE)调用命令行里明明有某命令make 却说 not foundrecipe 环境不加载 rc 文件export PATH 或写明绝对路径输出乱成一团并发 job 输出天然交错加--output-synctarget加 后make -n还是打印对应命令 只是真执行时静音-n 模拟时仍打印这是正常行为用来排查问题反而方便5.2 排查 Makefile 执行问题的三个固定步骤踩了这么多次坑之后我总结出一套固定的排查流程基本能解决 90% 的 Makefile “命令执行”问题第一步先看 make 展开后的命令长什么样。make -n永远是你最忠实的伙伴。举个例子如果发现某个变量没有展开预期值-n会直接暴露。你不需要猜看打印出来的命令就行。第二步确认执行者是谁。在 Makefile 里临时加一行$(info current shell: $(SHELL))跑make看输出。如果项目里有需求要确认 bash 特性到底能不能用这个方法一锤定音。第三步把可疑的 recipe 行复制到终端里手动执行。比如排查cd行为就在终端敲一遍cd /tmp pwd看看结果是否符合预期。虽然终端是 bash和 sh 有差异但至少能排除“命令本身写错”的可能性。如果终端能跑而 make 不能跑那基本就是 shell 差异或环境变量问题。5.3 一条能省一天时间的习惯先想清楚“谁在解析这行字”排查多了之后我发现很多人卡壳不是因为不懂 make 语法或 shell 语法而是没分清“这行内容糟糕是给 make 看的还是给 shell 看的”。Makefile 里一行字经过两道解析make 先看行首是 tab 吗有没有、-前缀里面有没有$(变量)、$(函数)需要展开如果不是 recipe 行它就是规则、变量定义或其他语句。展开完毕后make 把整行文本交给/bin/sh -c由 shell 再解释一遍$i、cd、、||、通配符展开这些都在 shell 层发生。很多初学者把这两层混在一起比如想在 make 层展开变量又想在 shell 层用同一个名字结果冲突或者在 make 层写for循环完全没意识到 for 是 shell 的语法。想明白了“make 负责展开shell 负责执行”这个基本分工之后上面所有问题都不是问题。写在最后的个人体会Makefile 学到最后你会发现它其实是一个“元脚本”它负责规划命令、调度命令而不是代替 shell 去执行复杂的逻辑。所以我写 Makefile 的底线是三条除非必要不改默认 shellrecipe 保持短小、可读、可预见涉及复杂逻辑一律外提脚本文件。还有一个小技巧我在自己项目里一直用把make -n当作 Makefile 的“编译器”每次写完新目标先-n看一遍确认没有语法误区和变量展开错误再真正开跑。这比直接执行然后看一堆报错再回头改效率高太多。