
1. make 默认 shell 这个坑比想象中更隐蔽很多人写了几年 Makefile从来没想过一个问题make 执行规则里的命令时用的是哪个 shell答案写在这篇文章的标题里——默认是/bin/sh。但绝大多数开发者的日常环境里默认 shell 其实是 bash于是大量 Makefile 在本地跑着没问题一换到精简容器、CI 镜像或者别人机器上就直接报错。我以前接过一个案例某项目的部署脚本在 A 同学的 Mac 上一切正常换到某 Linux 服务器上就出现奇怪的command not found、bad substitution之类错误排查了半天才发现是 shell 差异。2. 默认 shell 从哪来为什么不是 bashMake 程序本身并不执行命令它只是把配方recipe交给 shell 去执行。至于交给哪个 shellmake 有一个内置变量叫SHELL默认值是/bin/sh。在绝大多数 Linux 系统上/bin/sh是指向dash或bash的软链接。但注意即使指向 bashbash 在作为sh被调用时也会退回到 POSIX 兼容模式很多扩展语法不可用。说白了就是make 默认给你的是一个最小公倍数级别的 shell而不是你终端里那个功能完整的 bash。2.1 sh 和 bash 的几个经典差异逐个踩过才会长记性我总结几个在 Makefile 里最容易踩到 shell 差异的场景[[ ]]条件测试[[ ... ]]是 bash 的扩展语法在 sh 下直接报[[: not found。老老实实写成[ ... ]或者test ...才是 POSIX 兼容的。source命令sh 里没有source只有.点命令例如. /etc/profile。如果 Makefile 里写source venv/bin/activate在 sh 下会直接失败。数组bash 数组arr(1 2 3)在 POSIX 模式下根本不认sh 里只能用set --或字符串拼接来模拟。echo -nbash 里可以用echo -n不换行但 POSIX 标准只要求 echo 基础能力-n不是标准选项。在 dash 里echo -n会原样输出-n。想要跨 shell 输出不换行最稳的是printf。我自己写 Makefile 时有个习惯凡是给项目长期维护用的 Makefile默认语法就按 POSIX 写比如禁用[[ ]]、禁用数组、用$$?而不是$?注意 Makefile 里要取 shell 变量必须用$$转义。这样至少在 sh、bash、zsh 各种环境下都能跑。2.2 能不能手动指定 shell什么时候该这么干可以。在 Makefile 顶部直接覆盖SHELL : /bin/bash注意这里的写法用:立即展开而不是。因为如果写成SHELL /bin/bash遇到递归展开的情形可能在你意想不到的地方生效而:更符合直觉确定下来就不会再变。另外.SHELLFLAGS变量也可以自定义 shell 的参数比如加-e让 shell 在遇到第一个错误时退出SHELL : /bin/bash .SHELLFLAGS : -eu -o pipefail这在很多 CI 场景下特别有用。默认情况下make 执行规则里的每一条命令时shell 返回非零退出码make 就认为这条规则失败了。但你写的命令如果本身不检查中间错误——比如一条命令里有多个子命令用分号串联——出问题时可能不会被立刻发现。加上-e后首个失败的命令会直接中断整条规则早点暴露问题。不过别一上来就加-e我见过一个项目加了-e之后之前勉强能过的构建开始大面积失败因为很多命令的中间步骤其实一直有非零退出码只是之前没被检查。这种问题需要逐个命令修工作量不小。2.3 一个容易被忽略的点Makefile 里的赋值和 shell 变量是两码事新手经常混淆Makefile 里写NAME foo在规则命令里写echo $NAME会输出foo吗答案看具体写法。$NAME在 Makefile 语法里表示的是 make 的变量NAME而不是 shell 环境变量。想要读取 make 变量的值在命令里写$(NAME)想要在命令里传给 shell 变量需要写$$NAME它会展开成一个$NAME传给 shell。很多人在这上面栽过跟头。我之前写过这样一段排查经历一条部署命令里要检查环境变量写成deploy: if [ -z $ENV_ID ]; then echo ENV_ID not set; exit 1; fi结果 ENV_ID 始终为空因为 make 自己把$ENV_ID当成 make 变量展开成了空字符串。改成$$ENV_ID之后就正常了。**这个$$的设计本质上就是把美元符号原样交给 shell的转义手段。**只要记住一点在 Makefile 规则命令里凡是想让 shell 来处理的变量一律用$$。提示在调试这类问题时不要只盯着命令本身先确认这句命令是 make 展开后传给 shell 的还是 shell 自己解析的。展开阶段的问题用make -n能看到结论。3. 命令回显机制为什么 make 总是把命令打印出来如果你跟 make 打过几个照面一定会注意到make 执行你写的规则时会先把命令原文打印在终端然后再执行。这就是默认的回显echo行为。从机制上看这个回显信息是 make 自己在执行每条命令前打印的不是 shell 打印的。也就是说即使命令本身的输出为空——比如运行的是静默命令——make 也照样把命令行打印出来。这在调试时很有用你能看到 make 实际执行了哪条命令、参数是怎么展开的、有没有多出来奇怪的空格。但回显也会带来噪音。当构建日志很长时满屏的命令行混在一起真正的错误输出反而被淹没了。所以很多项目的构建命令都会做一层降噪处理。3.1 单行内关闭回显命令前的符号在规则的命令前面加一个make 执行这条命令前就不会打印它。典型用法是输出温馨提示hello: echo Build started at $(shell date)执行make hello只会看到Build started at ...不会出现echo Build started at ...这行代码本身。.PHONY: hello是另一个建议——如果不声明目录下若存在名为hello的文件make 会认为目标已更新这个规则根本不会执行。建议声明一下.PHONY: hello hello: echo Build started at $(shell date)3.2 全局关闭回显-s参数和.SILENT特殊目标如果整条规则甚至整个 Makefile 都不想看到回显有两种办法直接执行make -smake 会静默执行所有规则不打印任何命令行。这适合我只关心最终结果的场景比如发布版本。也可以在 Makefile 里声明特殊目标.SILENT它能让指定目标配套的所有命令都不回显.SILENT: clean clean: rm -rf build两种方式各有适用场景。我个人在平时开发时不会全局静默因为回显是排查问题最廉价的手段——你一看回显就能立刻发现make 执行的根本不是我以为的那条命令。很多诡异问题的根源就是某个规则里变量展开错误结果命令被展开成了另一个意思。静默模式下这种问题要半天才能定位。3.3 回显在调试时的放大镜作用反过来想回显是理解 make 内部执行逻辑的最佳窗口。我调试 Makefile 时的标准流程是——先跑一次带回显的目标看命令实际展开成了什么。如果发现命令和你期望的不一致通常都是在变量引用、转义、空格处理上出了问题。比如下面这个例子VAR : $(shell echo hello) test: echo $(VAR)执行make test时回显是echo hello输出是hello。看起来没问题。但如果你把变量定义改成VAR : hello world回显会是echo hello world看似也没问题。可是如果你在某个场景里需要把$(VAR)当成一个整体字符串传给 shell 命令比如printf %s $(VAR)回显就可能变成printf %s hello world此时printf收到两个参数行为就完全不同了。这种问题光靠看输出很难发现但回显里能看到完整展开后的命令行瞬间就能明白发生什么。4. 只显示命令但不执行make -n的调试方法论make -ndry-run它的语义是把 make 准备执行的命令全部打印出来但不真正执行它们。这条命令的价值在排查 Makefile 时怎么强调都不过分。它让我在不动手的情况下看清整个构建流程确认规则之间的依赖关系、变量展开结果、命令构造是否符合预期。4.1-n模式下哪些内容会显示哪些不会实测下来-n会打印每个目标下将被执行的每条命令包括带前缀的命令——注意只是抑制了正常执行时的回显在-n模式下它也会被显示出来因为 make 想让你看到完整的命令序列。变量的求值也会照常发生。比如你的命令里有$(shell ...)这种函数调用-n不会阻止它的执行。反正$(shell ...)是在 make 解析阶段完成的跟-n无关。条件判断ifeq、ifdef等也会照常处理错误的目标依赖照样报错。唯一被跳过的部分是规则的配方recipe真正执行这一步。4.2-n的典型实战场景我常用的一个场景是检查一个大型 Makefile 里某个特定 target 最终要跑什么命令。make -n deploy输出会是部署过程中所有要被执行的命令逐条列出。这时我可以逐个检查有没有可疑的命令——比如某个路径写错、某个变量展开成了空字符串、某些不该执行的命令误入依赖链。另一个场景是排查为什么 make 总是执行某些步骤。如果目标依赖写得不合理make -n能清楚地展示出因为这个目标依赖了它所以它也被纳入执行列表。这对优化构建速度、减少多余执行非常有帮助。我曾经给一个模拟项目优化构建流程发现make build每次都会重新执行数据库迁移脚本导致构建就特别慢。通过make -n检查发现迁移脚本被错误地声明成了build目标的前置依赖但它的实际作用只是生成一个初始化文件文件存在与否才是判断是否执行的依据。修复方式是给迁移脚本目标改成先检查文件是否存在存在则跳过依赖关系理顺后构建速度快了一倍。4.3-n和--debug配合使用放大信息量如果-n的信息还不够可以加--debugbasic或者直接--debugbmake -n --debugb build这样额外输出每条规则进入/退出的信息以及 make 认为目标是否需要重建的判定过程。对于复杂项目的 debug这种组合比单看-n强很多。4.4-s与-n的完全相反关系明确区分-n是只看不干-s是只干不说。两者不是同一个问题的正反面适用范围也完全不同参数含义适用场景make -n只打印要执行的命令不真正执行调试、审查、CI 预检make -s执行命令但不打印回显正式构建、发布流程、日志压缩-s常用于 CI 或构建脚本中减少输出量但它在本地开发时用得少因为本地需要靠回显来观察行为。如果既想减少输出又想保留一定可读性可以在规则里给特定命令加两步配合。提示如果你在写自动化脚本需要根据 make 的执行结果来判断后续步骤make -s -n组合也可能用得上——只看计划不执行也不打印多余内容。但要注意静默模式下你连回显都看不到如果你比较依赖回显来理解执行情况慎用。5. 单行命令的真正含义每次规则里的命令默认独立 shell 执行这是 Makefile 新手最容易被坑的一点make 里每条规则下的每行命令默认是在独立的 shell 进程里执行的。比如这个规则build: cd build pwd执行make build后你会看到pwd输出的还是当前目录而不是build目录。原因在于cd build在第一个 shell 里执行并退出第二个 shell 里pwd完全不知道这个目录变化。5.1 为什么 make 要把每条命令分到独立 shell这个设计有它的合理性每条命令独立执行意味着某条命令的失败不会影响其他命令的 shell 状态错误隔离做得比较好。而且这样也让 make 能在每条命令前后把状态控制得更清楚回显、静默、做依赖检查都更简单。但代价就是shell 状态比如环境变量、当前目录无法在行与行之间保留。这是 make 用户最常踩的坑之一——试着在某一行设置环境变量下一行去读发现变量是空的。5.2 单行命令内用;串联以及反斜杠续行如果你确实需要在同一个 shell 里连续做几件事该怎么办第一种办法在同一行里用分号;串联。例如build: cd build make allcd build make all是在同一个 shell 里执行的目录切换生效之后执行make all时确实在build目录里。注意比;更安全因为前者保证只有cd成功时才执行后续命令。第二种办法用反斜杠\换行。Makefile 里反斜杠加换行在解析时会被合并成一个逻辑行所以下面这种写法等价于把两条命令用分号拼成了同一行build: cd build \ make all这里的要点是**反斜杠只是让 make 把多行拼成一行再交给 shell并不改变每条规则只有一行命令的本质。**它和独立 shell 的问题无关本质上是在这个 shell 里执行了一长串命令。5.3 几个实测中常犯的错误案例案例一cd失效。clean: cd build rm -rf *这个规则会进入build然后立刻退回到当前目录最后rm -rf *删除的是当前目录的所有文件——如果你在项目根目录执行后果是灾难性的。正确写法是clean: cd build rm -rf * # 或者 rm -rf build/*案例二环境变量丢失。test: export FLAG1 echo $$FLAG第二次echo $$FLAG里读到的 FLAG 是空的。如果你需要环境变量持续有效要么同一行里用export FLAG1; echo $$FLAG要么直接用.ONESHELL后面会讲。6. .ONESHELL 和多行命令的整合方式针对上一节的痛点GNU make 提供了一个特别目标.ONESHELL它让整条规则的所有行在同一个 shell 进程中执行。这意味着cd、变量定义、export都能在行与行之间保留。6.1 .ONESHELL 的语法和使用场景声明方式极简单.ONESHELL: build: cd build make all加了.ONESHELL:后上面这个规则会正确地在build目录里执行make all。换句话说它把一规则一行一 shell默认模式改成了一规则一个 shell多行脚本式执行。适用场景很明显当你需要写多步骤任务时比如准备环境变量、进入目录、执行构建、跑测试脚本这些步骤之间有状态依赖用.ONESHELL让整个流程变成像写 shell 脚本一样顺畅。6.2 .ONESHELL 的坑错误处理方式和引号变化但.ONESHELL也有自己的麻烦事。第一错误处理方式改变。正常情况下make 只关心最后一条命令的退出码——如果一条规则里有三行命令第一行失败只要后面两行还在执行make 在默认模式下不会立即停下来除非最后一条也失败了。但在.ONESHELL模式下整段脚本是作为一个整体交给 shell 的make 只看这一个 shell 的最终退出码。这意味着如果你希望出错即停得自己加set -e或者依赖 shell 的出错即退策略。本来是隐式的错误中断现在变显式了容易漏。第二引号匹配跨行。普通模式下引号只在本行内匹配你写一行内引号不闭合make 会报错。但在.ONESHELL模式下所有行合并成一个 shell 脚本引号可以跨行有时一个错位的引号会让整段脚本结构错乱调试起来更困难。第三行号追踪丢失。.ONESHELL模式下出错时 shell 报错的行号是相对整段脚本的不再对应 Makefile 的行号。定位问题时要自行换算加大排查成本。6.3 一个用 .ONESHELL 解决实际问题的例子假设你要写一个从代码拉取、构建、部署到远端的三步任务deploy: clean build echo Deploying to remote server... scp build/app.tar.gz userremote:/srv/app/ # 示意 ssh userremote cd /srv/app ./restart.sh # 示意 .PHONY: clean build deploy clean: rm -rf build build: mkdir -p build echo build steps... build/app.tar.gz单靠分号、反斜杠也能实现但可读性远不如.ONESHELL下写成缩进多行来得清晰。如果你在写 CI 脚本或者部署脚本时格外重视可读性.ONESHELL是个好选择。不过需要提醒的是.ONESHELL不是 POSIX make 标准的一部分它是 GNU make 的扩展。如果你的 Makefile 需要在不同 make 实现之间移植比如某些系统自带的不是 GNU make要谨慎使用。绝大多数 Linux 环境都是 GNU make但 BSD 系统的 make 实现就不一定支持。7. 并发执行make -j 和它带来的收益与代价这是 Makefile 进阶里最容易出问题的点。GNU make 支持并行执行规则参数是-j或--jobs后面跟数字表示并发任务数make -j4 build如果依赖关系写得正确多核机器上构建速度会有肉眼可见的提升。但在项目里大规模使用并发之前需要先敲响警钟并发执行是否能安全完全取决于依赖图是否正确。7.1 并发与依赖完整性的紧密关系make 的并行的逻辑是如果一个目标的前置依赖都已经完成且它自身不再被其他任务阻塞就可以开始执行。这意味着你要让 make 正确并行必须在 Makefile 里把所有真实存在的依赖关系都声明清楚。比如有两个目标都依赖common.h的更新结果a.o: a.c common.h cc -c a.c -o a.o b.o: b.c common.h cc -c b.c -o b.o这里a.o和b.o之间没有相互依赖make 会同时编译它们。如果它们都在写同一个输出文件或者一个生成文件另一个要用而你没在 Makefile 里声明这种关系并行执行时就会出现竞态两个任务同时读写同一个文件结果不确定。我曾经在一个模拟项目里见过一个典型的错误目标all依赖a和b其中a会生成config.h而b依赖config.h。但 Makefile 里只写了b依赖源文件没有声明b: config.h。串行执行时all先做a再做b碰巧没问题一旦make -j2a和b同时启动b可能在config.h还没写完就开始编译直接失败。这个案例告诉我们一个原则**想用并发先保证依赖关系到位且不冗余。**到位是指所有真实依赖都写清楚不冗余是指别把不相干的目标强行串起来否则并行的收益被抵消大半。依赖关系如果写得准确make 自己会在合适的时机调度任务你不需要手工做太多控制。7.2 并发执行期间输出交错问题并发执行的第二个实际痛点多个目标同时运行时它们的标准输出会交错在一起。make 默认不提供统一的日志加锁机制不同 target 的输出混在一起排查问题时会非常痛苦。GNU make 从 4.0 版本开始在并行模式下会给每个命令行输出加一个前缀比如[ 1]、[ 2]但这只是粗略指示并不能保证完整聚合每个任务的所有输出。如果你希望每个任务的日志完整且独立常见做法是让每个任务把日志输出到单独文件最后统一收集a: ./run_a.sh a.log 21 b: ./run_b.sh b.log 21这就是为什么很多构建系统在并行模式下会自动做日志分流的原因——纯靠终端拼接跨 target 的信息隔离太难了。7.3-l负载限制和-n只显示不执行在并行的关系-l参数可以限制 make 启动新任务时的系统负载上限比如make -j8 -l 6表示最多并发 8 个任务但当系统负载超过 6 时不再启动新任务。这个参数适合共享开发机或 CI 机器上避免 make 把整台机器的资源吃光。但要注意-l依赖系统负载的读取不同系统上的数值含义有细微差别不要依赖它解决依赖关系缺失的问题。-n在并发模式下也有用你可以用make -n -j4 build只查看将要执行的命令而不实际执行。这能帮你确认并行的任务集合是否正确从而排查为什么某个任务和另一个任务同时启动了这类问题。7.4 并发加速的实测参考以模拟项目为例纯串行构建一个包含 20 个源文件的项目大概需要 15 秒左右用-j4后降到 5~6 秒提升非常明显。但如果你把-j从 4 提到 16收益并不会线性增长瓶颈可能出现在磁盘 IO、链接器或公共资源的争抢上。更糟糕的是并发太高时某些隐藏的依赖问题会被放大出错的概率显著上升。所以我的建议是**先在串行模式下把构建彻底跑通调稳确认依赖关系完全正确后再逐步打开-j参数从 2 开始慢慢往上试探。**不要一上来就-j16除非你已经对项目的依赖图有十足把握。8. 实操中我对这几个特性的组合用法最后分享几个我实测下来觉得好用的组合技巧也都是踩过坑之后总结出来的。首先调试最常用的命令组合是make -n。不管项目多大先用make -n看一遍准备执行的命令能过滤掉大量低级错误。尤其是当你怀疑某条规则被错误触发时-n能让你看到它是否真的在计划内被触发。如果嫌回显太多可以加-s压掉回显只保留结果或者用--debugb做更精细的分析。其次.ONESHELL不一定要全局开启。GNU make 为它提供了另一种细化方式——你可以在特定 target 内部用$(shell ...)特性局部模拟类似效果或者用反斜杠续行把需要的部分串起来。但如果你发现某个 target 的脚本特别复杂且必须在同一个 shell 里执行多个步骤直接用.ONESHELL会让代码干净得多。再一个实用技巧是在 Makefile 的开头把 shell 相关的配置集中声明例如SHELL : /bin/bash .SHELLFLAGS : -euo pipefail .ONESHELL:这样整个文件的执行模式就完全确定了之后的规则写起来更像脚本。不过要特别注意前面说的坑——.SHELLFLAGS加了-e后所有命令的隐藏失败都会被暴露需要你在初期迭代阶段有足够的耐心去逐个修复。等把 Makefile 稳定下来之后这个配置带来的可维护性收益是相当大的。还有个小技巧调试 Makefile 变量时可以用$(info ...)函数直接打印变量值不需要写规则。例如在 Makefile 里加一行$(info SHELL is $(SHELL), CC is $(CC))在 make 解析阶段就会输出变量的值比写规则再执行要快得多。这在排查变量展开问题时比make -n更直接。9. 几个常踩的雷区我帮大家提前排掉这里汇总一下我在长期使用 make 过程中踩过的坑不一定都能归到某一节里但都很常见。9.1 默认 shell 和 CI 环境不一致本机用 macOS默认/bin/sh指向 bash但 CI 容器里/bin/sh可能指向 dash。代码语法一旦用了 bash 特性本地好好的CI 直接崩。建议在项目的 README 里明示 Makefile 需要 GNU make 和 bash 环境或者干脆在 Makefile 顶部设置SHELL : /bin/bash。如果不想依赖 bash那就老老实实把命令都写成 POSIX 兼容。9.2 依赖关系不完整时串行掩盖问题并发放大问题前面已经详细说过这一点。我见过很多项目在串行构建下一直没事一上-j4就各种诡异失败最后定位全是依赖缺失或共享文件竞争。这也是为什么我在引入并发前会先跑几遍make -n确认命令集、再看一遍make --print-databasemake -p了解完整依赖关系。9.3 某条命令失败但 make 还继续往后跑默认情况下如果一条规则里有多行命令某行失败不会立刻中断后面的行除非显式指定目标依赖失败。比如一行编译失败后面一行还是会被执行。旧版本的 make 和一些实现里错误处理确实更宽松。如果你希望出错即停可以加.SHELLFLAGS -e或者让命令之间用连接。9.4和-s与日志排查能悄悄关闭单行回显对日常体验很棒但对调试不友好。遇到问题时把临时去掉或者用make -n看效果是定位问题最快的方式。我曾经调试过一个诡异的部署问题某条命令始终不执行原本以为是 make 在静默模式下忽略了它后来去掉才发现命令在每行都被前面的一条exit提前终止了。回显一打开问题立马清楚。10. 最后的体会Makefile 入门阶段最容易忽略的就是这些看似简单实则影响面很大的执行细节。默认 shell、回显、-n/-s、单行多行、并发——单独看都很简单但在真实项目里它们往往交织在一起共同决定构建是否稳定、是否高效。我个人的习惯是一旦项目里的 Makefile 开始变得复杂就把调试优先打好随时用make -n观察命令、用$(info ...)打印变量、再用--debugb查看依赖判定过程。这些手段组合起来几乎能回答所有为什么 make 做了这件事或者为什么 make 没做这件事的疑问。如果你刚接触 Makefile 不久建议先把默认 shell 和回显机制这两个点吃透再慢慢尝试-n/-s最后才碰.ONESHELL和并发。顺序对了你对 Makefile 的控制力会明显上一个台阶项目里的构建脚本也会越来越顺手。