SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

Bash 通配展开:通配符与 extglob 用法

了解 Bash 如何在命令运行前把通配符展开为文件名,并掌握 nullglob、dotglob、globstar 与 extglob 否定模式的版本差异和实际行为。

命令运行前 Bash 通配展开的工作方式

Bash 通配展开会在命令启动前,将类似 *.log 的模式转换为按排序排列的实际文件名列表。rm *.log 不会将模式直接传递给 rm。Bash 会读取目录,保留匹配的名称,进行排序,然后运行 rm,并将每个名称作为单独的参数传入。rm 程序无法判断之前是否使用过模式。

通配展开中几乎所有令人意外的行为都源于这一事实。匹配由 shell 根据当时存在的文件执行,命令只能看到最终生成的列表。它的正式名称是路径名展开。日常使用中通常称为通配展开,两者含义相同。

构建一个小型目录树用于练习

下面的每个示例都针对同一组文件运行。请在临时目录中创建这些文件,避免影响附近的重要内容,并且只需运行一条命令即可清理。

lab=$(mktemp -d)
cd "$lab"
mkdir -p logs archive/2025 archive/2026
touch app.log app.log.1 app.log.2 app.log.10 error.log debug.LOG
touch notes.txt notes.md README 'weekly report.log'
touch .env .apprc
touch logs/nginx.log logs/nginx.log.1
touch archive/2025/dec.log archive/2026/jan.log

mktemp -d 创建一个新的目录并返回其路径,$(...) 则将该路径保存到变量 lab 中。如果您不熟悉这种语法,请参阅命令替换如何捕获命令输出,其中有完整说明。

查看刚刚创建的内容,并检查您的 shell,因为末尾的一条说明取决于版本:

ls -a
bash --version

这里的所有内容都适用于 bash 4.0 及更高版本。末尾附近的 globskipdots 说明适用于 bash 5.2 及更高版本。截至 August 2026,当前的 Ubuntu 和 Debian releases 提供的就是这些版本。

全文使用的预览命令是 printf '%s\n' PATTERN。它每行输出一个匹配项,不会修改磁盘上的内容,因此可以安全地用于检查您不确定的任何模式。

三个通配符:星号、问号和方括号

  • * 匹配任意长度的字符序列,也可以匹配空序列。
  • ? 恰好匹配一个字符。
  • [...] 恰好匹配方括号内集合中的一个字符。
printf '%s\n' *.log
printf '%s\n' app.log.?
printf '%s\n' app.log.[0-9]
printf '%s\n' notes.*
printf '%s\n' *

将第二行与您创建的文件列表进行比较。? 只代表一个字符,不能匹配更多字符,因此两位数字后缀不符合该模式。将其扩展为 app.log.[0-9]*,然后再次运行以查看差异。

这三个通配符都适用两条规则。通配符不会匹配 /,因此模式始终只在一个目录层级内匹配。开头的 . 必须按字面输入,因此以 * 开头的模式会跳过所有隐藏名称。第二条规则正是 rm * 不会匹配点文件的原因。

Glob 结果会按排序顺序返回,使用当前 locale 的整理顺序,也就是逐字符进行文本排序。后缀为 10 的名称因此会排在后缀为 2 的名称之前,因为比较过程不会将任何一个后缀当作数字处理。运行 printf '%s\n' app.log.*,然后运行 ls -v app.log.*,并比较两者:ls -v 会按数值对嵌入的数字进行排序,sort -V 在管道中执行相同的工作。

字符类和范围

在方括号内可以写列表、范围或否定条件。

  • [ch] 匹配一个字符,可以是 ch
  • [a-f] 匹配范围 a 到 f 内的一个字符。
  • [!0-9] 匹配一个非数字字符。[^0-9] 表示相同的含义。
printf '%s\n' [dn]*
printf '%s\n' *.[A-Z]*
printf '%s\n' [A-Z]*

范围是导致不同机器出现差异的部分。范围按照当前 locale 的排序规则解析,而不是按照普通 ASCII 顺序解析。因此在某些系统上,[a-z] 也会匹配大写字母。Bash 提供了 globasciiranges 选项来强制使用普通 ASCII 顺序。较新的构建版本默认启用该选项,因此应使用 shopt globasciiranges 检查当前设置,不要直接假设其状态。脚本如果必须在所有环境中以相同方式匹配,可以在靠近文件顶部的位置单独一行设置 LC_ALL=C,将所有范围恢复为普通字节顺序。

该赋值必须作为独立命令执行。作为前缀书写时,LC_ALL=C ls [a-z]* 只会为 ls 设置 locale,不会影响其他内容;而且 shell 已经先按照自身的 locale 展开了模式,之后 ls 才开始执行。这再次体现了第一节介绍的规则。

花括号展开不是通配

花括号看起来像通配符,但行为完全不同。花括号展开发生在路径名展开之前,而且从不检查磁盘。

echo file{1,2,3}.txt
echo {01..10}
echo {a..e}
echo {0..20..5}

这些文件都不存在,但展开仍会执行。这正是两者的区别:通配模式会向文件系统查询,而花括号只生成文本。这也是创建对象时应使用花括号、查找对象时不应使用花括号的原因。

mkdir -p site/{css,js,img}
cp app.log{,.bak}
touch report-{2025,2026}-{01,02}.csv

cp app.log{,.bak}会展开为两个单词:app.logapp.log.bak,因为逗号前的空项会保留原始文本。{01..10}会保留前导零,因为任一端点带有前导零时,bash 都会将生成的每个数字补齐到相同宽度。

展开顺序也解释了一个常见问题。Bash 会先展开花括号,然后展开变量,最后执行路径名展开。

n=5
echo {1..$n}

$n变为数字时,花括号展开早已完成,因此不会生成任何范围。请使用 seq 1 "$n",或使用写成 for ((i=1; i<=n; i++)) 的 C 风格循环。

没有匹配到任何内容时会发生什么

这是第一种失败模式,每个人都会至少遇到一次。默认情况下,bash 会完全保留未匹配的模式。该词会按您输入的原样传递给命令,因此命令会收到字符 *.tmp,并尝试将它们当作文件名处理。

printf '%s\n' *.tmp

printf 而言,这没有问题。但对于会创建内容的命令,这并不安全。在不包含任何 .tmp 文件的目录中,touch *.tmp 会创建一个文件,文件名就是字面上的 *.tmp。之后删除该文件时必须使用引号,避免 shell 再次展开它:rm -- '*.tmp'

两个 shell 选项会改变这一规则。请分别使用一个无法匹配任何内容的模式测试这两个选项,然后取消设置。

shopt -s nullglob
printf '%s\n' *.tmp
shopt -u nullglob

shopt -s failglob
printf '%s\n' *.tmp
shopt -u failglob

nullglob 会让未匹配的模式展开为空,因此命令实际运行时的参数少于您写入的参数。这正是循环中需要的行为。关闭 nullglob 时,for f in *.tmp 会使用保存字面模式的 f 将循环体执行一次。几乎所有出现这种情况的脚本都有错误。开启 nullglob 后,循环体会执行零次。

nullglob 也有自身的风险,因为即使命令的所有参数都被删除,命令仍会执行。ls *.nope 会变成单独的 ls,从而列出整个目录。grep -l needle *.nope 会变成 grep -l needle,由于没有文件可读取,它会等待标准输入,看起来像是卡住了。请在需要该行为的循环周围开启 nullglob,并在循环结束后再次关闭它。

failglob 采用另一种方式:未匹配的模式会被视为错误,bash 会报告该错误,并且命令根本不会执行。对于交互式 shell,这是一个合适的设置,因为它可以阻止输入错误的模式被作为字面名称传递给 rm

隐藏文件:dotglob

shopt -s dotglob
printf '%s\n' *
shopt -u dotglob

运行该代码块,并将输出的列表与前文的普通 printf '%s\n' * 进行比较。dotglob 会移除开头的点号规则,因此 * 也会匹配隐藏名称。在 dotglob 下,条目 ... 始终会被排除。

不使用 dotglob 时,.* 是访问隐藏文件的常用方式,但过去存在风险。在 5.2 之前的 bash 中,.* 还会匹配 ...,因此类似 chmod -R 755 .* 的递归命令会直接进入父目录。Bash 5.2 新增了 globskipdots 选项,该选项默认启用,可在每次展开时排除 ...。依赖此行为前先运行 shopt globskipdots,因为较旧的系统可能不支持该选项。

globstar:向下遍历整个目录树进行匹配

shopt -s globstar
printf '%s\n' **/*.log
printf '%s\n' **/
shopt -u globstar

设置 globstar 后,作为完整路径组件的 ** 可以匹配任意深度的文件和目录,包括向下 0 层。因此,**/*.log 不仅匹配当前目录,还匹配 logs/ 以及 archive/ 下的所有内容。带结尾斜杠的 **/ 只匹配目录,因此可以快速查看目录树的结构。

未设置 globstar 时,** 的行为与普通 * 相同,只在当前目录中匹配。关闭该选项后运行相同的模式并进行比较:从其他位置复制的递归模式如果匹配结果远少于预期,通常就是因为这个选项未启用。所有 bash 默认都关闭 globstar

需要注意的一项限制是:** 不会跟随指向目录的符号链接。如果目录树通过符号链接连接起来,则需要改用 find -L

nocaseglob,用于处理大小写不一致的名称

shopt -s nocaseglob
printf '%s\n' *.log
shopt -u nocaseglob

nocaseglob 使文件名匹配忽略大小写,因此大写的 .LOG 后缀可以匹配小写的 .log 模式。将此列表与第一节中的列表进行比较。随后立即关闭该选项,因为如果保持启用状态,它会改变此 shell 中后续每条命令的匹配行为,过一小时再排查这种问题会很困难。

nocaseglob 仅适用于文件名展开。对于 case 语句或双方括号测试中的模式匹配,相关选项是 nocasematch;两者需要分别设置。

extglob:用于表示“不是此项”的模式

扩展模式默认处于关闭状态。请单独占一行启用它:

shopt -s extglob

共有五种形式,每种形式都接受由 | 分隔的模式列表:

  • ?(list) 匹配出现 0 次或 1 次。
  • *(list) 匹配出现 0 次或多次。
  • +(list) 匹配出现 1 次或多次。
  • @(list) 恰好匹配其中一个备选项。
  • !(list) 匹配不符合任何备选项的内容。

扩展模式必须由已启用该选项的 shell 读取,因此下面的批处理内容应放在一个通过 bash -O extglob 启动的小脚本中。-O 标志会在新 shell 读取文件中的第一行之前设置 shopt 选项,因此其中的所有模式都能正确解析。本节最后一段会说明顺序为何重要。

cat > patterns.sh <<'EOF'
printf '%s\n' @(app|error).log
printf '%s\n' app.log.+([0-9])
printf '%s\n' *.@(md|txt)
printf '%s\n' !(*.log)
printf '%s\n' !(*.log|*.md|README)
EOF
bash -O extglob patterns.sh

+([0-9]) 表示一个或多个数字,因此可以匹配任意长度的编号后缀。这解决了前文 ? 的长度限制。建议连续运行这两个模式,观察差异。

否定形式用于表示“除某项之外的全部内容”,也是启用 extglob 的主要原因。它有两点容易让人意外。

它不仅匹配文件,也匹配目录。因此,ls !(*.log) 会在结果中列出目录内容,而不是列出目录名。使用 ls -dprintf 预览,可以查看目录本身的名称。

它也和其他 glob 一样遵循点号规则。因此,!(*.log) 会匹配所有不以 .log 结尾的可见名称。要匹配隐藏名称,还必须设置 dotglob

!(...) 中的 ! 表示 glob 否定。它与 历史展开和感叹号字符 无关;后者是独立功能,并在不同阶段执行。

还有一个陷阱,这次发生在解析阶段。Bash 会先解析完整命令,再执行其中的任何部分。因此,在同一行启用 extglob 并使用扩展模式会失败:解析该模式时,选项仍处于关闭状态。请让 shopt -s extglob 单独占一行,放在模式之前,并尽量靠近脚本顶部。函数体会在定义函数时解析,因此必须在函数定义之前启用该选项,而不是在调用函数之前启用。从 bash -O extglob 启动 shell(如上面的批处理内容所示),可以在开始解析之前确定正确的顺序。

Shell 会展开模式,命令不会

这是第二种失败模式,可以解释一整类错误。

grep -l needle *.log
find . -name '*.log'
find . -name *.log

第一行让 bash 执行匹配,并将文件名列表传给 grep。第二行将模式加引号,因此 find 接收到的是 5 个字符 *.log,然后在起始路径下的每一层目录中自行匹配。第三行存在错误:bash 会先在当前目录中展开模式,因此 find 接收到的是一个具体名称。当前目录中只有一个匹配项时,命令会静默地搜索错误的目标。如果有两个或更多匹配项,find 会报告用法错误,因为多出的名称会被放在它期望接收表达式的位置。

规则很简单。如果模式是供命令处理的,就将它加引号。如果模式是供 shell 处理的,就不要加引号。--exclude 中的模式以及 tarrsync 中的模式也遵循同样的规则,grep 中的 --include 过滤条件同样如此。

Glob 模式也不是正则表达式,尽管两者使用了一些相同的字符。在 glob 中,* 表示任意长度的字符序列。在正则表达式中,* 表示前一个元素重复 0 次或多次,因此 grep '*.log' 要求的内容与表面看起来的含义完全不同。

变量也遵循相同的操作顺序。变量展开之后才会执行 glob 匹配,因此包含模式的未加引号变量会根据目录内容继续展开。

pat='*.log'
printf '%s\n' $pat
printf '%s\n' "$pat"

不加引号时,bash 会对变量的结果执行 glob 匹配。加引号时,模式会保持为字面值。还有一个相关结果值得依赖:glob 匹配生成的单词不会再次按空格拆分。因此,for f in *.log 会将包含空格的文件名作为一个名称处理,而 for f in $(ls *.log) 会将该名称拆成两个单词。直接遍历 glob 结果。

运行前预览有破坏性的匹配模式

不要让 rm 成为你尝试新模式时执行的第一个命令。先使用无害的命令运行该模式,查看列表,然后只修改行首的命令。

target='!(*.log)'
printf '%s\n' $target
ls -ld -- $target
rm -- $target

将模式写入 target 可以避免三行内容彼此不一致。$target 会被特意保留为未加引号的形式,因为这样 bash 才能对其进行展开。printf '%s\n' 每行打印一个名称,不会执行任何修改。ls -ld 显示每个条目本身,而不是列出目录内容;模式列会标出哪些条目是目录,这是执行递归删除前需要检查的内容。读取 drwxr-xr-x 权限位介绍了该列。

按向上箭头调出上一行,只编辑命令部分,这样模式可以保持不变。凭记忆重新输入模式时最容易引入错误。

-- 放在模式前面。它表示选项结束,因此以 - 开头的文件名会被当作名称,而不是选项。

如果希望在脚本的一段时间内关闭 globbing,set -f 会禁用它,set +f 会重新启用它。

这个习惯很重要,因为 rm 没有撤销功能。恢复使用 rm -rf 删除的文件过程缓慢,而且通常无法完整恢复,因此预览所需的成本远低于误操作后的代价。

完成后清理练习目录。先打印路径并阅读确认,因为将 rm -rf 指向错误的变量,正是本节要避免的事故。

cd ~
echo "$lab"
rm -rf -- "$lab"

FAQ

为什么 find . -name *.log 会找到错误的文件?

因为 bash 会在 find 启动前展开 *.log。Shell 会在当前目录中匹配该模式,并将得到的文件名传递给 find;随后,find 会在每一层目录中搜索这个名称。如果匹配到两个或更多文件,find 会在本应接收表达式的位置收到额外参数,并报告用法错误。将模式引用为 find . -name '*.log',这样由 find 按自身规则执行匹配和递归。

模式显示为字面文本是什么意思?

这表示没有匹配项。默认情况下,bash 会保留未匹配的模式不变,并将原始字符传递给命令;命令随后会将它们当作文件名处理。设置 shopt -s failglob,可将未匹配的模式转换为错误并停止命令;设置 shopt -s nullglob,则可让它展开为空。在 nullglob 模式下,请确认命令在完全没有参数时仍然有效,因为 ls *.nope 会变成一个单独的 ls,从而列出整个目录。

如何在 bash 中匹配除某个模式之外的所有文件?

在单独一行上使用 shopt -s extglob 启用扩展模式,然后使用否定形式。!(*.log) 会匹配不以 .log 结尾的名称,!(*.log|*.md) 则会排除两者。除非同时设置 dotglob,否则隐藏文件不会出现在结果中;目录也会被包含。因此,在将模式传递给 rm 前,先使用 printf '%s\n' !(*.log) 预览结果。

为什么 * 会跳过隐藏文件?

开头的点号必须按字面匹配,因此 * 永远不会匹配以点号开头的名称。使用 .* 访问隐藏名称,或设置 shopt -s dotglob,将隐藏名称纳入所有普通模式。启用 dotglob 时,... 始终会被排除;在 bash 5.2 及更高版本中,.* 也会通过默认启用的 globskipdots 选项排除它们。

** 要正常工作,是否需要 globstar?

需要。没有 shopt -s globstar 时,bash 会将 ** 当作普通的 * 处理;它只会匹配单个目录中的内容,然后停止搜索。启用 globstar 后,作为完整路径组件的 ** 可以匹配任意深度,因此 **/*.log 会访问当前目录及其下的所有子目录,而单独使用 **/ 时只匹配目录。请注意,** 不会跟随指向目录的符号链接,因此对于由符号链接组成的目录树,应使用 find -L

#bash#globbing#shell#linux#scripting