☰
Makefile 执行细节:默认 Shell、回显机制与并发调试实战
2026/10/17 15:32:36 网站建设 项目流程

1. make 默认 shell 这个坑,比想象中更隐蔽

很多人写了几年 Makefile,从来没想过一个问题:make 执行规则里的命令时,用的是哪个 shell?

答案写在这篇文章的标题里——默认是/bin/sh。但绝大多数开发者的日常环境里,默认 shell 其实是 bash,于是大量 Makefile 在本地跑着没问题,一换到精简容器、CI 镜像或者别人机器上就直接报错。我以前接过一个案例,某项目的部署脚本在 A 同学的 Mac 上一切正常,换到某 Linux 服务器上就出现奇怪的command not found、bad substitution之类错误,排查了半天才发现是 shell 差异。

2. 默认 shell 从哪来,为什么不是 bash

Make 程序本身并不执行命令,它只是把"配方"(recipe)交给 shell 去执行。至于交给哪个 shell,make 有一个内置变量叫SHELL,默认值是/bin/sh。

在绝大多数 Linux 系统上,/bin/sh是指向dash或bash的软链接。但注意,即使指向 bash,bash 在作为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 -n:bash 里可以用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 -s,make 会静默执行所有规则,不打印任何命令行。这适合"我只关心最终结果"的场景,比如发布版本。

也可以在 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 -n(dry-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的信息还不够,可以加--debug=basic或者直接--debug=b:

make -n --debug=b 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 all

cd 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 FLAG=1 echo $$FLAG

第二次echo $$FLAG里读到的 FLAG 是空的。如果你需要环境变量持续有效,要么同一行里用export FLAG=1; 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 user@remote:/srv/app/ # 示意 @ssh user@remote "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 -j2,a和b同时启动,b可能在config.h还没写完就开始编译,直接失败。

这个案例告诉我们一个原则:**想用并发,先保证依赖关系"到位且不冗余"。**到位是指所有真实依赖都写清楚;不冗余是指别把不相干的目标强行串起来,否则并行的收益被抵消大半。依赖关系如果写得准确,make 自己会在合适的时机调度任务,你不需要手工做太多控制。

7.2 并发执行期间输出交错问题

并发执行的第二个实际痛点:多个目标同时运行时,它们的标准输出会交错在一起。make 默认不提供统一的日志加锁机制,不同 target 的输出混在一起,排查问题时会非常痛苦。

GNU make 从 4.0 版本开始,在并行模式下会给每个"命令行"输出加一个前缀(比如[ 1]、[ 2]),但这只是粗略指示,并不能保证完整聚合每个任务的所有输出。如果你希望每个任务的日志完整且独立,常见做法是让每个任务把日志输出到单独文件,最后统一收集:

a: ./run_a.sh > a.log 2>&1 b: ./run_b.sh > b.log 2>&1

这就是为什么很多构建系统在并行模式下会自动做日志分流的原因——纯靠终端拼接,跨 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压掉回显只保留结果,或者用--debug=b做更精细的分析。

其次,.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-database(make -p)了解完整依赖关系。

9.3 某条命令失败,但 make 还继续往后跑

默认情况下,如果一条规则里有多行命令,某行失败不会立刻中断后面的行,除非显式指定目标依赖失败。比如一行编译失败,后面一行还是会被执行。旧版本的 make 和一些实现里,错误处理确实更宽松。如果你希望"出错即停",可以加.SHELLFLAGS = -e,或者让命令之间用&&连接。

9.4@和-s与日志排查

@能悄悄关闭单行回显,对日常体验很棒,但对调试不友好。遇到问题时,把@临时去掉或者用make -n看效果,是定位问题最快的方式。我曾经调试过一个诡异的部署问题:某条命令始终不执行,原本以为是 make 在静默模式下忽略了它,后来去掉@才发现命令在每行都被前面的一条exit提前终止了。回显一打开,问题立马清楚。

10. 最后的体会

Makefile 入门阶段,最容易忽略的就是这些"看似简单实则影响面很大"的执行细节。默认 shell、回显、-n/-s、单行多行、并发——单独看都很简单,但在真实项目里,它们往往交织在一起,共同决定构建是否稳定、是否高效。

我个人的习惯是:一旦项目里的 Makefile 开始变得复杂,就把"调试优先"打好:随时用make -n观察命令、用$(info ...)打印变量、再用--debug=b查看依赖判定过程。这些手段组合起来,几乎能回答所有"为什么 make 做了这件事"或者"为什么 make 没做这件事"的疑问。

如果你刚接触 Makefile 不久,建议先把默认 shell 和回显机制这两个点吃透,再慢慢尝试-n/-s,最后才碰.ONESHELL和并发。顺序对了,你对 Makefile 的控制力会明显上一个台阶,项目里的构建脚本也会越来越顺手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询