
ls *.log这条命令我每天要敲几十遍但真正能说清 Linux 通配符到底是谁在展开、什么时候会不展开、展开成什么样的人其实没那么多。面试的时候我常问一句rm *和rm *有什么区别能答到点子上的不到一半——很多人以为通配符是命令自己处理的其实它是 Shell 在处理命令从头到尾只见到一份已经展开好的文件名列表。这个认知差一旦建立起来后面所有为什么没匹配上为什么删错文件为什么脚本在 A 机器好好的到 B 机器就炸了的问题基本都能顺着同一条线索找到答案。这篇东西我想按实际用的顺序来讲先弄清楚展开的时机和顺序再把每个元字符的边界一条条摆出来然后进真实场景——批量改名、清理日志、处理脏文件名最后把网络设备里那个也叫通配符的 ACL 反掩码单独拉出来划清界限因为这两个概念混在一起是排障时最常见的误判来源。内容偏实操命令都是能直接抄的也把踩过的坑一起说了。1. 展开这一步发生在 Shell 里不在你敲的命令里1.1 一行命令的展开顺序决定了很多玄学Bash 处理一行命令的展开顺序是固定的花括号展开、波浪号展开、参数与变量展开、命令替换、算术展开、词分割、路径名展开也就是 glob、最后去引号。这个顺序不是学术细节它直接解释了为什么同样一个*放在不同位置表现完全不一样。举个最典型的例子echo {a,b}*.txt先做花括号展开变成两个词a*.txt和b*.txt然后这两个词各自去做路径名展开。所以如果目录里没有a.txt你看到的是字面的a*.txt而不是报错说文件不存在——因为花括号本身不检查文件是否存在它是纯粹的文本替换。再比如我见过不少人写脚本时这么干files*.log; rm $files。这里$files是变量展开展开之后得到的字符串还会经历词分割和路径名展开所以*会被当成通配符匹配一堆文件然后删掉。而写成rm $files就没有这个问题因为双引号阻止了词分割和路径名展开rm收到的是一个名字叫*.log的普通参数。两个写法看起来只差一对引号实际行为差着一条命。理解展开顺序的意义就在这儿你能精确预测每一步之后*还剩多少通配符身份。1.2 没有匹配时 Bash 的默认行为以及 nullglob 藏着的雷默认情况下一个通配符如果匹配不到任何文件Bash 会把它原样保留也就是把*.log这五个字符当成一个普通参数传给命令。所以ls *.log在没有 log 文件时会显示类似无法访问 *.log的报错这不是 ls 的事是 Shell 把字面模式交出去了。很多排障场景下这个报错本身就是最好的信号它告诉你当前目录的上下文和你以为的不一样。问题出在nullglob这个开关上。有人为了让循环空目录时不跑在脚本开头加了shopt -s nullglob确实解决了for f in *.txt在无匹配时循环一次拿到字面*.txt的尴尬但同时也会让rm -f $dir/*这类命令在匹配为空时静默地什么都不做——本来是好事可如果路径拼错了你根本发现不了。我更倾向在脚本里用failglob匹配不到直接让脚本报错退出把问题暴露在第一时间而不是让它悄悄跑完一个空循环。下面这张表是我自己在不同场景下的选择倾向开关无匹配时的行为适合场景默认保留字面模式字符串交互式命令报错即提示nullglob展开为空参数消失明确知道要遍历可能为空的目录failglob直接报错终止命令脚本中做严格校验宁可失败注意GLOBIGNORE这个变量在被设置之后会顺带把dotglob打开也就是*突然开始匹配以点开头的隐藏文件。这个副作用经常让人在设置完忽略规则后发现脚本莫名开始处理.git、.bashrc之类的东西排查时很难往这上面想。2. 六种元字符各自的匹配边界2.1*和?能做什么、明确不做什么*匹配任意长度的字符串包括空串这是基础。但它的边界比大多数人以为的窄它不匹配路径分隔符/也不匹配文件名开头的那个点。所以*不会跨目录echo */*.conf和echo *.conf完全是两回事前者要求多一层目录。至于隐藏文件*是故意绕开的这是 Unix 从早期就定下的约定避免用户一个*就把.和..裹进去。真要用.*匹配隐藏文件的时候必须格外小心因为.*会实打实地匹配到.和..这两个特殊目录项。老版本的递归删除事故基本都出在这里rm -rf .*试图删当前目录失败和父目录灾难。稳妥的写法是rm -rf ./.[!.]*或者干脆交给find去做比如find . -maxdepth 1 -type f -name .* -delete语义清晰也不会误伤。?用于匹配单个字符。这里有个在中文环境里特别容易翻车的点?匹配的是一个字符而不是一个字节。在 UTF-8 的 locale 下ls ?.txt可以匹配到文.txt因为在字符层面它就一个汉字但如果你把LC_ALLC一设locale 退化到按字节处理同一个文件名就需要三个字节得写ls ???.txt。这就是为什么有些备份脚本在服务器上跑得好好的换到另一台 locale 不同的机器上就匹配不到带中文的文件名了。2.2[]与 POSIX 字符类范围写法受 locale 影响方括号提供的是字符集合匹配。[abc]匹配其中一个字符[a-z]匹配范围[!abc]是取反写成[^abc]也可以两种否定写法在 Bash 里都认。这一层看着简单但[a-z]这种范围写法的隐含风险在于它依赖于当前的排序规则。在不少 locale 下排序顺序并不是纯 ASCII范围可能会把带音标的字母一起吞进来比如某些西文 locale 下[a-z]能匹配到é。这种 bug 极难复现因为它在开发机上可能永远不出现。我的做法是只要范围匹配的结果需要严格可预测就在命令前面显式加LC_ALLC或者在脚本里把它设成变量的形式局部生效。另一个更省心的办法是直接用 POSIX 字符类把意图写清楚[[:digit:]]数字[[:alpha:]]字母[[:alnum:]]字母与数字[[:space:]]空白字符[[:punct:]]标点这些名字听起来啰嗦但胜在语义明确不受排序规则摆布。清理批量生成的文件时rm -f report_[[:digit:]][[:digit:]].txt这样的写法一眼就知道在删什么比[0-9][0-9]更容易在 code review 时通过。2.3{}根本不是通配符但它总跟通配符混着用花括号展开和路径名展开是两套完全不同的机制最明显的区别是它不检查文件是否存在。echo {a,b,c}不管这三个文件在不在都会输出a b c。它支持序列{1..10}、带步长{1..10..2}、带前导零{01..10}、字母序列{a..e}还能嵌套{a,b{1,2}}展开成a b1 b2。搞清楚这个区别有实际价值ls {a,b}.log和ls (a|b).log需要 extglob在结果上可能一样但在没有匹配文件时表现完全不同——前者会把a.log b.log两个字面量交给 ls 并报错后者则因为走了路径名展开无匹配时保留模式串或按开关处理。做生成类操作比如批量建目录mkdir -p project/{src,test,docs}花括号是首选因为它压根不需要文件存在。2.4 引号、转义与变量展开的三方博弈引号对通配符的影响本质上是控制路径名展开这一步要不要执行。单引号全部阻止双引号阻止通配符但允许变量和命令替换反斜杠逐个转义。所以$dir/*.log是很典型的正确写法目录变量被引号保护住防止路径里有空格时被词分割拆开而*留在引号外面继续完成通配任务。这个位置关系我最初也写错过写成$dir/*.log结果整个模式被当成字面路径rm 报文件不存在还以为目录权限有问题绕了半小时。反斜杠的用法在临时单次操作里很方便比如你先想确认一下展开结果echo \*.log会把模式原样打出来。但要转义一长串可读性就崩了这时候我宁可用引号。3. 把通配符用进真实运维场景3.1 批量改名和批量清理先看清单再动手批量改扩展名是通配符最经典的用法。假设目录里有一堆.JPG想改成.jpg写法是for f in *.JPG; do mv -- $f ${f%.JPG}.jpg; done。这里有两个细节值得展开。第一--参数告诉 mv后面全是文件名不是选项防止碰到以短横线开头的文件名被当成选项解析。第二${f%.JPG}用%从右侧做最短匹配去掉扩展名这是参数展开而不是通配符但它和通配符配合起来极其顺手一个负责挑文件一个负责改字符串。清理日志我从来不用一行命令一次到底而是分两步。第一步printf %s\n /var/log/app/*.log.2023*把将要操作的文件完整列出来看一眼确认路径拼对了、范围没写宽。第二步再执行rm -f。这个习惯是被坑出来的有一次我把年份写错一位直接把当年的日志删了虽然日志可以重放但代价是半天时间。后来我就改成find /var/log/app -maxdepth 1 -name *.log.2023* -mtime 30 -delete把时间条件和名称条件交给 find干跑一次加-print看结果确认后再换成-delete稳得多。3.2find -name和 Shell 通配符不是同一套规则这一点值得单独强调因为它是最容易写错的地方。find . -name *.log这条命令Shell 会先展开*.log如果当前目录恰好有几个 log 文件命令就变成find . -name a.log -name b.log之类的东西行为完全不是你想要的。所以-name后面的模式必须加引号find . -name *.log。加引号之后模式字符串交给 find 自己去匹配走的是 fnmatch 那一套。更关键的差异在于隐藏文件。GNU find 的-name *是会匹配.hidden的因为 find 匹配的是文件名的完整字符串没有 Shell 那套开头点就跳过的约定。同理find 匹配的字符串不跨越路径分隔符所以-name *.log不会匹配到子目录里的文件除非配合遍历。另外-path匹配的是包含路径的整体字符串这时候*在 find 里就能跨过斜杠了比如find . -path */logs/*.log是常用的写法。这些行为差异我整理成了一张对照表特性Shell 路径名展开GNU find 的-name匹配开头为点的隐藏文件默认不匹配匹配*是否跨/不跨不跨-path可跨无匹配时的行为保留字面量或按开关处理仅是不输出结果模式是否需要引号由使用意图决定必须加否则被 Shell 抢占3.3 参数列表过长撞上 ARG_MAX 之后怎么办通配符展开是在把文件名列表塞进命令的参数里。参数数量和总长度有内核上限可以用getconf ARG_MAX查一下通常是两兆左右ARG_MAX 是总字节数另外还有单个参数的栈限制影响实际可用量。目录里有几十万个小文件时rm dir/*会直接报参数列表过长这不是权限问题也不是磁盘问题就是参数装不下。解决思路有两条。一是让命令自己去遍历目录不再依赖 Shell 展开比如find dir -maxdepth 1 -type f -delete。二是分批传递find dir -maxdepth 1 -type f -print0 | xargs -0 rm -f。这里-print0和-0的配对很重要因为默认的 xargs 是按空白字符拆分输入的文件名里只要有一个空格就会被拆成两个不存在的路径删不掉还刷屏报错碰到换行符更是灾难。用 NUL 做分隔符是唯一能覆盖所有合法文件名的做法。如果坚持用 Shell 循环也可以find ... -print0 | while IFS read -r -d f; do rm -f -- $f; doneIFS防止前后空白被吃掉-r防止反斜杠被解释-d 把分隔符改成 NUL。这套参数组合我基本是背下来的写文件遍历时无脑套用。4. 和网络设备里那个通配符划清界限4.1 ACL 通配符掩码的运算规则网络设备配置里的访问控制列表有一项 wildcard mask中文也翻成通配符掩码或者反掩码。它跟 Shell 通配符只是中文译名撞车机制上毫无关系Shell 通配符是做字符串模式匹配ACL 反掩码是做一个按位的匹配判断。规则很简单也很反直觉——二进制位为 0 表示这一位必须匹配为 1 表示这一位忽略不关心。也就是说0 管得严1 放得松跟子网掩码的直觉正好相反。换算方法是拿 255.255.255.255 减去子网掩码逐段计算即可。前缀长度子网掩码通配符掩码反掩码含义/24255.255.255.00.0.0.255匹配一整个 C 类网段/26255.255.255.1920.0.0.63匹配 64 个地址/30255.255.255.2520.0.0.3匹配 4 个地址精确主机255.255.255.2550.0.0.0只匹配这一个地址任意地址0.0.0.0255.255.255.255匹配所有地址这张表我建议贴在工位上尤其是前两行的对比因为写反了不会报错只会静默地放行过多或者过少。反掩码还有一个很有用的特性可以用它来做奇偶匹配。最后一段写0是偶数地址写1是奇数地址比如192.168.1.0 0.0.0.1匹配的是这一段里所有奇数结尾的地址。这个技巧在设计策略时偶尔能省掉一堆枚举条目。4.2 为什么按掩码的思路写 ACL 会匹配不上ACL 里带了通配符但匹配不上这类问题绝大多数成因是把掩码和反掩码的位置写反了。假设你想放行192.168.1.0/24正确写法是192.168.1.0 0.0.0.255。如果写成192.168.1.0 255.255.255.0设备不会报语法错误它会按字面理解前三段随便是什么都行第四段必须等于 0。于是变成放行任意网段的 .0 地址范围比你想的大得多看起来像是策略生效了但效果不对。反过来想精确放行某一台主机192.168.1.10正确写法是192.168.1.10 0.0.0.0。有人照着掩码的思路写成192.168.1.10 255.255.255.255那意思就变成任意地址全放行这是一条很容易被忽略的安全配置疏漏。我的排查顺序是固定的三步先把这条规则的地址和反掩码在纸上换算一遍确认覆盖范围再看规则的编写顺序因为这类设备是按从上到下的顺序匹配一条更宽的规则排在前面会把后面的精确规则彻底屏蔽最后才怀疑接口方向和应用位置的问题。顺序颠倒着查往往要绕很远才能回到第一层。5. 那些能把你坑到怀疑人生的文件名5.1 空格、换行、前导横线与编码乱码文件名里可以有空格可以有换行甚至可以以一个短横线开头。通配符展开会把它们原样放进参数列表问题出在你处理这些参数的方式上。for f in $(ls)这种写法是最经典的错误示范命令替换的结果会经历词分割一个叫my file.txt的文件会被拆成my和file.txt两个参数。正确姿势永远是让 Shell 自己去枚举for f in *; do ...; done并且循环体里所有引用都加引号$f。前导横线的坑在于它会被选项解析器吃掉。目录里有个文件名就叫-rf你执行rm *Shell 展开后参数列表里有-rfrm 把它当成选项剩下的一堆文件名变成操作对象——后果不用我细说。防御方式有两个命令里加--表示选项结束或者把通配符写成./*展开后每个名字都带./前缀就不再是以横线开头了。第二种还有个附带好处输出里能看到完整路径日志更清楚。编码乱码的问题通常出现在解压环节。压缩包里的文件名用了某种编码解压工具按另一种编码解释出来的名字就是一串看不懂的字符。这时候麻烦在于你还想用通配符去批量处理它们——*.txt可能匹配不到因为扩展名后面还粘着乱码字符或者扩展名本身也被改写了。先别急着批量操作用ls -b看看实际转义后的形式再用LC_ALLC ls按字节视角看一遍把真实名字确定下来再决定用什么模式匹配。5.2 一条可复现的排查链路碰到通配符明明应该匹配到却没有的时候我基本是顺着下面这条链走一遍通常三五分钟能定位。第一步用echo加同样的模式看展开结果echo /path/*.log。这一步能立刻区分是模式没匹配到还是命令行为不对。第二步把结果换成分行输出printf %s\n /path/*.log因为echo会把所有参数用空格连起来看着像是一个文件名其实可能是两个。第三步如果怀疑有不可见字符用printf %q\n /path/*它会用转义形式把名字打出来空格、换行、特殊字符全都现形。第四步验证权限和目录上下文ls -ld /path确认你确实在看你想看的那个目录尤其是脚本里有cd的时候。第五如果以上都正常还是不对脚本里加set -x或者用bash -x script.sh把每一步展开后的实际命令打出来Shell 到底把什么交给了程序一看便知。提示脚本里养成在开头写set -euo pipefail的习惯配合failglob使用能让大部分静默错误变成明确的失败。代价是脚本变严格了需要处理更多边界情况但排障成本会显著低于让错误一路传播下去。6. 打开 globstar 和 extglob 之后6.1globstar**的递归边界shopt -s globstar打开后**可以递归匹配任意层级的目录。ls **/*.log会列出当前目录及所有子目录下的 log 文件这在临时找文件时非常爽。但有几个边界得知道。第一**单独使用时比如ls **会把中间层级的目录本身也列出来输出往往比你想的多通常都配合后缀使用**/*.conf这种更可控。第二符号链接的处理在不同 Bash 版本间有差异旧版本不会跟着链接往里走新版本行为有调整跨机器跑脚本时这会是个变量。第三性能上**在大目录树里明显比 find 慢因为它是 Shell 自己在遍历和拼字符串涉及几十万个文件时不要用它做批量操作老老实实用 find。我在做代码仓库内的批量操作时会先shopt -s globstar nullglob然后for f in src/**/*.js; do ...; done比 find 写起来短但只在目录规模可控时用。仓库大到一定程度或者需要按修改时间、大小过滤就切回 find。6.2extglob把正则的思维搬进来一点shopt -s extglob提供了五个扩展操作符?(pat)匹配零次或一次*(pat)零次或多次(pat)一次或多次(pat)恰好一次!(pat)表示除了 pat 之外的任何东西。前三个的实用性一般真正好用的是()和!()。(jpg|png|gif)一次性挑出三种图片格式比写三次或用花括号更紧凑而且它走的是路径名展开不存在匹配时能配合failglob一起报错。!()的杀伤力极大。rm -rf !(*.txt)意思是删除所有不是 txt 的东西敲下去之前真的要盯三秒。我的做法是先把它写成ls -d !(*.txt)看输出确认范围没错再把ls -d换成rm -rf。这一步换算花不了三秒钟但能避免一次不可逆的事故。顺便说一句!()里的模式如果不小心写得太宽比如!(/tmp/*)在某些情况下会连带删除目录结构本身这种极端情况我不建议在交互式命令行里冒险。6.3 几种匹配机制该怎么选一张对照表用久了会发现同一个需求往往有好几种写法都能实现差别在于可预测性、性能和可读性。下面这张表是我自己的取舍标准需求推荐方式理由交互式临时挑几个文件Shell 通配符*.log敲得快展开结果肉眼可见固定的几种后缀花括号{jpg,png,gif}不依赖文件存在适配生成场景按时间、大小、类型过滤find加条件条件表达式丰富不占参数列表跨目录递归find或globstar小目录树用 globstar大目录树用 find排除某类文件extglob的!()表达式紧凑但需要先 dry-run传给批处理工具用-print0加xargs -0兼容任意文件名包括空格换行网络设备策略ACL 反掩码位匹配与 Shell 通配符无关这张表里最后一行是我特意加上的因为工作中真的会有同事拿着0.0.0.255来问这不是通配符吗为什么我这边匹配不到。一旦明确了两者是不同体系沟通成本会下降很多。最后聊一个我自己的习惯。凡是包含通配符的删除或覆盖命令我一律先把它当成ls跑一遍。rm -rf ./build/*敲下去之前先敲ls -d ./build/*看看展开的是哪些东西尤其是路径里有变量拼接的时候。这个习惯已经帮我拦下过好几次事故有一次变量没赋值成功路径变成了./*ls -d一跑出来满屏的上级目录内容立刻就知道不对劲了。三秒钟的确认换一次心安这笔账怎么算都划算。