
1. 先弄清通配符到底指哪一样东西1.1 Shell 里的通配符和正则表达式不是一回事很多人第一次学 Linux 命令看到ls *.txt能列出所有文本文件转头学正则时又看到*表示前面那个字符重复零次或多次当场就懵了同一个符号怎么两副面孔答案很简单这两套东西根本不归一个部门管。Shell 里的通配符准确叫法是globbing或者叫pathname expansion是命令行解释器在启动程序之前做的预加工它的工作对象是磁盘上真实存在的文件名正则表达式是grep、sed、awk这些工具内部自己解析的匹配语法工作对象是文本流里的字符。一个面向文件名一个面向文本内容引擎不同、规则不同、生效时机也不同。这个区别不是学究式的抠字眼它直接决定你踩不踩坑。*在 Shell 通配符里代表任意长度的任意字符包括零个字符所以*.log能匹配a.log也能匹配.log这种极端情况而在grep里*单独写是没有意义的它必须跟在某个字符后面ab*表示的是一个a后面跟零个或多个b。你要是把grep * file当成匹配所有行来用实际得到的是匹配任意行因为单独一个*在正则里退化成普通字符——这就是典型的拿错地图走错路。我在带新人的时候有个固定动作让他们分别执行echo *.txt和grep .*\.txt filename然后问一句这两条命令的展开分别发生在什么阶段。能答上来的人后面写脚本基本不会在路径匹配上翻车答不上来的人十有八九会在find -name和rm上栽跟头。所以这一节我要先把边界划清楚后面讲的所有内容没有特别说明的话都是指 Shell 的 globbing。1.2 网络里的反掩码通配符是另一套体系搜通配符这个词会大量撞见反掩码ACL 通配符无法匹配掩码这类内容这块得单独拎出来说清楚免得概念串味。网络设备配置里的通配符掩码wildcard mask和 Shell 通配符除了中文译名撞车技术上一点关系都没有。它本质是一个 32 位的二进制串用在访问控制列表或者路由协议的网段声明里规则是比特位为 0 表示这一位必须严格匹配比特位为 1 表示这一位随便。它跟子网掩码正好是逐位取反的关系所以被叫成反掩码。举个例子192.168.1.0 0.0.0.255表示匹配整个192.168.1.0/24网段因为最后 8 位全是 1主机位随便写成192.168.1.0 0.0.0.0就只精确匹配这一台写成0.0.0.0 255.255.255.255表示匹配全部地址。这套东西的坑在于0 和 1 的含义和掩码相反很多人算惯了掩码转手写反掩码时容易把0.0.0.255写成255.255.255.0结果规则范围完全跑偏排查半天以为是设备 bug。记住一句话就行反掩码里 0 是必须对得上1 是不关心跟通配符里*代表随便什么都可以的思路反而更接近。那为什么会搜到ACL 有通配符无法匹配掩码这种说法多数情况是网段和反掩码没对齐比如想匹配192.168.1.0/25却写了/24的反掩码或者地址本身不在声明的网段内。这属于网络设备配置范畴跟 Linux 命令行没有任何交集。之所以在这篇里花半节篇幅讲它是因为这两个概念在搜索时高频混在一起很多人拿着 AC 的疑问去查 Shell 的文档越看越糊涂。把边界划清后面的内容才能安心往下走。2. 通配符为什么能自己变成文件名Bash 展开顺序拆解2.1 一行命令从敲下回车到程序启动经历了什么理解通配符最关键的一步是意识到它不是程序的功能而是 Shell 的功能。你敲下ls *.txt回车ls这个程序从头到尾都没见过*.txt这个字符串。真正发生的事情是Bash 在启动ls之前先扫描命令行发现*.txt是个通配模式于是去当前目录把匹配的文件名一个个抓出来替换成实际路径最后交给内核去执行的是ls a.txt b.txt c.txt这样的形式。ls收到的从来都是已经展开好的列表。Bash 的展开链条是有严格顺序的官方手册里列得清楚我按实际执行的先后排一遍花括号展开→波浪号展开~展开成家目录→参数、命令、算术展开$VAR、$(cmd)、$((11))→单词拆分按 IFS 切分→路径名展开也就是 globbing→引号移除。这个顺序不是随便定的它决定了为什么echo {a,b}$HOME和echo $HOME{a,b}结果不同也决定了为什么变量里带的*默认不会被当通配符处理。注意变量展开在 globbing 之前但变量里的内容默认不会再次触发 globbing。files*.txt; echo $files输出的是「多个空格分隔的文件名」这是因为发生了单词拆分加通配符展开而files*.txt; echo $files输出的是字面量*.txt。加不加引号走的是两条完全不同的路。2.2 通配符展开发生在程序启动之前意味着什么这个提前量带来两个后果一个是好事一个是麻烦。好事是任何程序都自动支持通配符ls、rm、cp、tar、chmod、你自己写的脚本全都能直接吃通配符因为它们拿到的就是展开后的文件列表压根不需要自己实现匹配逻辑。这也解释了一个新手常见的困惑为什么ls支持通配符但python不支持——其实python也支持你在命令行里写python *.py照样能跑因为展开是 Bash 干的活。麻烦在于程序拿不到原始的匹配模式。find . -name *.txt是新手最容易写错的命令之一当前目录如果有a.txtShell 会先把它展开成find . -name a.txt看着结果对了纯属运气要是目录里有两个.txt文件命令会变成find . -name a.txt b.txtfind会直接报语法错误。正确写法是find . -name *.txt或者find . -name *.txt用引号把模式保护起来让它原样传给find由find自己的匹配引擎去处理。这个坑我见过太多次了尤其是目录里恰好只有一个匹配文件时命令碰巧能跑通换台机器就崩排查起来非常费劲。另一类麻烦是参数长度上限。通配符展开是在把文件名塞进命令行参数里而系统对单个进程的参数总长度是有限制的getconf ARG_MAX可以查通常是 2MB 上下。一个目录里要是有几十万个文件rm *.log会直接报Argument list too long因为展开出来的参数列表超长了。这时候就不能指望通配符得换成find . -name *.log -delete或者find . -name *.log -print0 | xargs -0 rm这类流式处理方式。记住这个判断标准通配符是一次性把所有匹配项塞进命令行匹配项特别多的时候必然撑爆参数上限。3. 四类基础通配符逐个过关3.1 星号覆盖面最广也最容易误伤*匹配任意长度的任意字符长度可以是零。这是使用频率最高、也是杀伤力最大的一个符号。它能匹配a、abc、a.tar.gz也能匹配空字符串所以*.txt会匹配a.txt也会匹配.txt这种没名字只有后缀的文件。这个特性在批量操作里很好用反过来也很危险rm *这种命令我就不多说了每个老运维都有一段不想回忆的经历。一个必须记住的规则*默认不匹配以点开头的隐藏文件。这是 Bash 有意为之的保护机制因为隐藏文件里往往躺着.bashrc、.ssh、.git这类要命的东西如果rm *顺手把.bashrc一起删了那是真的会让人眼前一黑。所以*和.*是两套独立的匹配规则别指望前者覆盖后者。至于.*这里有个经典坑多数 Bash 版本下.*会把.和..一并匹配进来rm -rf .*虽然会被rm拒绝删除这两个特殊目录但它会递归删掉所有隐藏子目录的内容后果非常严重。要么用rm -rf .[!.]*这种排除写法要么设置GLOBIGNORE.:..要么干脆别用.*做删除操作。*还有一个容易被忽略的性质它不跨越目录分隔符。/home/*/logs只匹配/home下一级目录里的logs不会递归到更深层级。想递归匹配要么用**需要开globstar要么老老实实用find。这一点在写脚本时特别重要很多人以为*是什么都匹配实际上它只是当前层级内什么都匹配。3.2 问号与方括号把匹配范围收窄?匹配恰好一个任意字符注意是恰好一个不能多也不能少。file?.txt能匹配file1.txt、fileA.txt但匹配不了file.txt或file12.txt。批量处理有规律的文件名时这个符号特别顺手比如日志文件按天滚动log_2024??.txt这种模式就能精确圈定范围。同样地?也不会匹配文件名开头的那个点?bashrc匹配不了.bashrc要写成.bashrc或者.??这类显式带点的形式。方括号[...]是字符集匹配括号里列出的任意一个字符。file[123].txt匹配file1.txt、file2.txt、file3.txt其他的一概不碰。范围写法更实用file[0-9].txt一口气覆盖十个数字[a-z]、[A-Z]、[a-zA-Z]、[0-9a-fA-F]都是常见组合。多个字符集还能连着写[a-z][0-9].log匹配一个字母加一个数字结尾的日志文件规则表达能力比很多人以为的强得多。这里有个真实场景值得说处理解压出来的文件乱码时文件名里常混着奇怪的字符用[!a-zA-Z0-9._-]这种取反集合能快速筛出文件名里含有非正常字符的那些文件比肉眼一个个翻要高效多了。字符集加上?和*的组合几乎能覆盖日常文件操作的绝大部分需求。3.3 取反与字符类容易被忽略的细节[!...]和[^...]都表示取反匹配不在集合里的任意一个字符。file[!0-9].txt匹配后缀不是数字的[^a-z]*匹配不以小写字母开头的。两种写法在 Bash 里都认但 POSIX 标准只规定了[!...]所以写脚本如果考虑可移植性用!更稳。这个符号在清理临时文件时挺管用比如排除掉指定前缀的文件再打包命令写起来干净利落。取反有个细节坑[!abc]匹配的是一个不是 a、b、c 的字符不是一个不包含 abc 的字符串。想表达后者得用扩展通配符!(abc)这个后面讲extglob的时候再说。另外如果!出现在方括号里但不是第一位它就是个普通字符[a!b]匹配的是 a、!、b 三个字符中的任意一个这个区分几乎没人在文档里强调但脚本里写错就是逻辑错误。还有个东西叫字符类写法是[[:class:]]双冒号是必须的。常用的有[[:digit:]]数字、[[:alpha:]]字母、[[:alnum:]]字母数字、[[:space:]]空白、[[:upper:]]、[[:lower:]]、[[:punct:]]。它的好处是跟随当前 locale 定义处理非 ASCII 字符时比硬写[0-9]、[a-z]更靠谱。注意别写成[:digit:]少一层方括号它就退化成普通字符集了这个错误我见过不止一次而且因为不报错只是匹配结果静默地不对特别难查。4. 花括号扩展长得像通配符其实不是4.1 花括号在展开链条上排在更前面{a,b,c}这个写法看起来跟[abc]挺像但它们在 Bash 的处理链条上处于完全不同的位置。花括号展开排在第一位比变量展开还早更别说 globbing 了。这意味着echo {a,b}{1,2}会先展开成a1 a2 b1 b2这是笛卡尔积式的组合。而[ab][12]是通配符它要求磁盘上真实存在a1、b2这样的文件才会展开没有匹配项时原样输出。这个先后顺序的实际影响很微妙。花括号展开完全不关心文件是否存在它纯粹是字符串层面的拼接所以echo {1..5}永远输出五个数字哪怕目录里什么都没有。而通配符展开必须依赖真实文件没有匹配就保持原样。写脚本时这个区别决定了你该用哪个要生成一批固定字符串比如批量创建目录、批量构造 URL、批量拼测试数据用花括号要根据磁盘上的实际情况筛选文件用通配符。还有一个关键差异花括号展开不区分文件名是否以点开头它跟隐藏文件保护机制没关系因为它压根不碰文件系统。.、..这些东西在花括号里就是普通字符串echo {.,..}老老实实输出. ..。4.2 常用写法与踩坑点花括号的写法比很多人想象中丰富。逗号列表{jpg,png,gif}是最常见的序列写法{1..10}生成 1 到 10{a..z}生成小写字母{01..10}会自动补零对齐这个在生成带序号的文件名时特别好用能保证ls排序正常还能指定步长{0..100..5}生成 0、5、10 一直到 100。组合使用能少写很多重复命令cp config.yml config.yml.{bak,old}这种写法可以直接生成两份备份名。踩坑点集中在几个地方。逗号后面不能有空格{a, b}会因为空格被拆成两个单词b}会变成一个独立的参数结果莫名其妙。花括号里不能直接用变量做范围{$a..$b}不会按你预期展开得先用seq或者eval绕一下。嵌套花括号可以工作{a,{b,c}}展开成a b c但层级一多可读性直线下降超过两层建议拆成两行写。单个元素的花括号不展开{a}就是{a}本身Bash 不会傻乎乎地给你去掉括号这一点跟通配符无匹配则原样保留的行为倒是挺像。提示花括号展开和通配符同时出现时花括号先跑完剩下的部分再走 globbing。ls *.{txt,md}的流程是先把*.{txt,md}变成*.txt *.md再分别做路径名展开。理解这个顺序很多为什么这条命令的行为跟我想的不一样的问题就自动有答案了。5. 让通配符更听话的十来个 shopt 开关5.1 查看与开关shopt 的基本用法Bash 通过shopt内置命令控制一大批可选行为跟 globbing 相关的有好几个。查当前状态的命令是shopt不带参数会列出所有选项和开关状态查单个用shopt extglob。开启用shopt -s 选项名关闭用shopt -u 选项名。这些设置作用在当前 Shell 会话脚本里设了只影响脚本自己不会污染父 Shell反过来你在终端里设的脚本执行时不会自动继承除非写在.bashrc里或者脚本自己显式设置。我建议把这些开关当成按需开启的增强功能默认状态下 Bash 的行为是最保守也最可预测的一旦动了某个开关最好在脚本开头集中声明并写注释说明为什么。团队协作时见过太多这个脚本在我机器上跑得好好的换个人就出错的情况最后查出来是某个人在自己的.bashrc里偷偷开了nullglob导致脚本的空匹配行为完全变了样。5.2 globstar 与 extglob递归和扩展模式globstar开启了**语法让通配符第一次具备递归能力。**单独出现表示任意层级目录**/*.log能匹配当前目录及所有子目录下的.log文件dir/**匹配dir下的所有内容。这个功能在批量处理项目文件时非常好用比如统计代码行数、批量替换配置、清理构建产物一条命令就能覆盖整个目录树。代价是递归遍历本身就慢目录层级深、文件多的时候会明显卡顿而且**的展开结果里包含目录本身写删除命令时得格外小心。extglob开的是扩展模式匹配提供五个新语法?(pattern)匹配零次或一次*(pattern)匹配零次或多次(pattern)匹配一次或多次(pattern)匹配恰好一次!(pattern)匹配不符合这个模式的任何东西。最后那个!(...)是重点它终于实现了排除某个模式的能力。比如要删除目录下所有非.txt的文件写成rm !(*.txt)意思很直白。语法上有个硬性要求竖线分隔的多个模式不能有空格(jpg|png|gif)合法(jpg | png)就是错的。注意extglob有个历史坑——在有些 Bash 版本里开启这个选项会影响解析器的行为如果是在函数体或者case语句中间开启可能导致后面的语法解析异常。稳妥做法是把它写在脚本最前面或者在需要的地方用子 Shell 包起来(shopt -s extglob; ...)。5.3 dotglob、nullglob、failglob、nocaseglob 怎么选dotglob让*也能匹配隐藏文件。开了之后rm *就真的会删掉.bashrc所以这个选项我一般只在特定场景临时开比如同步.gitignore描述的规则、批量处理.env文件。用完立刻shopt -u关掉绝不留到下一次。nullglob和failglob处理的是同一个问题通配符没有匹配到任何文件时怎么办。默认行为是把模式原样保留所以ls *.nothing会报文件不存在的错误rm *.nothing也会报错。nullglob让无匹配时展开成空命令就变成什么参数都没有failglob则直接让命令失败并且不执行。两个选项都有用选哪个看场景写清理脚本希望没有匹配就安静跳过用nullglob写关键操作希望没有匹配就立刻停下来别乱来用failglob。我个人倾向在破坏性操作的脚本里用failglob宁可报错也不要静默地什么都不做因为后者可能掩盖掉真正的问题。nocaseglob让匹配忽略大小写处理 Windows 传过来的文件或者用户上传的杂乱文件时有用。nocasematch是它的兄弟但作用于case语句和[[ ]]里的模式匹配不是文件路径。这两个名字太像经常被搞混记住glob结尾的是管文件路径的就行。选项作用典型场景风险globstar启用**递归匹配遍历目录树批量处理展开结果多删除类操作危险extglob启用?() *() () () !()排除特定模式的文件语法不能带空格版本兼容性差异dotglob*匹配隐藏文件处理.env、.gitignore误删关键配置文件nullglob无匹配时展开为空清理脚本安静跳过静默失败掩盖问题failglob无匹配时命令失败关键破坏性操作批处理中断nocaseglob路径匹配忽略大小写处理混合大小写文件名可能匹配到意料之外的文件6. 通配符在真实场景里的用法与保命姿势6.1 批量重命名与批量改后缀批量改后缀是通配符最经典的应用场景但 Bash 里没有rename这种内置命令得靠循环。常见的写法是用for遍历配合参数扩展做字符串替换for f in *.jpeg; do mv -- $f ${f%.jpeg}.jpg done这段代码有几个细节值得拆。--是防止文件名以短横线开头被mv当成选项虽然少见但真会遇到加了绝对不亏。$f加引号是因为扩展出来的文件名可能带空格不加引号会被拆成多个参数mv就会报参数数量不对。${f%.jpeg}是参数扩展不是通配符它从变量值末尾删掉.jpeg%是短匹配最短后缀%%是长匹配最长后缀。这几个符号跟通配符里的*、?长得像但完全是另一套机制属于 Bash 字符串处理分清这个能省不少事。如果要处理的是多层级的文件就得请出find配合。比如把所有子目录里的.tmp改成.bakfind . -type f -name *.tmp -exec bash -c for f; do mv -- $f ${f%.tmp}.bak; done _ {} 注意-name *.tmp用了单引号把模式保护起来交给find自己解析而不是让当前 Shell 先展开。这也是前面强调过的那个坑写find的时候引号必须到位。6.2 删除、打包、同步时的引号与参数上限删除操作是通配符最容易出人命的地方我这里列几条自己一直在用的保命习惯。第一先看再删把rm换成ls跑一遍确认展开结果跟预期一致再动手。第二加--分隔符rm -- *.log防止以短横线开头的文件名被解析成选项。第三对隐藏文件格外警惕.*那个坑已经说过能不碰就不碰。第四重要操作前先备份tar czf backup.tar.gz .花不了几秒钟。参数长度上限的问题在打包和同步场景里特别突出。tar czf logs.tar.gz *.log在日志目录里跑文件一多就直接爆Argument list too long。规范做法是用find配合-print0和xargs -0用空字符分隔避免文件名里的空格和特殊字符把参数切错find . -maxdepth 1 -name *.log -print0 | tar czf logs.tar.gz --null -T -rsync也一样直接写rsync -av *.tar /backup/在文件多的时候会失败改用--include和--exclude规则或者用--files-from从一个清单文件读。find那套是按需流式处理的思路通配符是一次性全塞进去的思路规模一大就得换前者。6.3 脚本里要不要指望通配符写脚本的时候通配符的可靠性其实不如很多人想象的高。原因有三个不同用户的 Shell 设置不同有人开了nullglob有人没开不同 Bash 版本行为有差异extglob的解析时机、globstar的边界处理都改过locale 影响字符集匹配范围。所以我在写需要跨环境运行的脚本时倾向于不用通配符做关键筛选而是用find加显式条件或者用ls的结果配合while read循环处理。如果非要用我的做法是在脚本开头做三件事显式设置需要的 shopt 选项并写注释用set -u和set -e让变量和错误更严格所有变量引用一律加引号。这三条能挡掉八成的意外。还有一招是临时关闭 globbingset -f会让通配符退化成普通字符处理完再set f恢复。这个技巧在拼接包含*的字符串时特别有用比如构造传递给远程命令的参数列表。提示想知道某个脚本运行时的实际展开结果可以在关键位置临时插入echo或者printf %s\n $把参数一个个打出来看。这比读代码猜要靠谱得多尤其是在排查为什么这条命令在生产机器上表现得不一样的时候。7. 通配符相关的常见问题与排查速查7.1 为什么我的通配符没展开这是最高频的问题排查路径其实很固定按顺序过一遍基本都能定位。第一检查是不是被引号包住了。ls *.txt里的*就是普通字符因为双引号阻止了 globbing。单引号、双引号、反斜杠三种方式都能阻止展开区别在于单引号阻止一切展开包括变量双引号阻止通配符但允许变量反斜杠只阻止它后面那一个字符。第二检查当前目录有没有匹配项。默认行为是无匹配则原样保留看着像没展开实际是没东西可展开。第三检查是不是在错误的目录下。**/*.log只在globstar开启且当前工作目录正确时才有意义。第四检查隐藏文件。*不匹配点开头的文件需要dotglob或者显式写.。还有一种情况是命令本身不支持比如在sudo后面接通配符。sudo rm *.log的展开是当前用户的 Shell 做的不是 root 的 Shell所以匹配的是当前用户能看到的文件跟权限没关系。这种误会经常出现在为什么我明明有权限却删不掉的排查里。现象可能原因验证方法处理方式通配符原样输出无匹配文件ls看目录内容确认目录、开启nullglob通配符原样输出被引号保护检查命令里的引号去掉引号或用find -name只匹配到部分文件*不匹配隐藏文件ls -a对比用.*或开dotglobArgument list too long参数超过系统上限getconf ARG_MAX改用find xargs子目录文件没匹配到*不跨目录检查模式里的斜杠开启globstar用**大小写不匹配区分大小写对比文件名实际大小写开nocaseglobfind -name报语法错误模式被 Shell 提前展开echo看实际参数给模式加引号7.2 为什么它展开出了我不想要的文件反过来匹配到多余文件同样是常见问题而且更危险因为它往往发生在删除或者批量修改的场景里。最常见的原因是模式写得过于宽松*.txt在某些目录下会匹配到几万个文件包括那些名字里带.txt但其实是备份的、以及名字里带.txt.bak的。解决思路是把模式收紧用更精确的字符集和位置约束比如[0-9]*.txt限定前缀或者用extglob写!(*.bak)排除掉备份文件。第二个原因是工作目录不对。很多人以为rm /var/log/*.log会删掉所有日志但如果globstar没开这个模式其实只匹配/var/log/这一层子目录里的完全没动。反过来如果误开了globstar某些写法可能扩展到意想不到的深度。操作前pwd和ls确认一下成本极低收益极高。第三个原因是文件名里有特殊字符。文件名带换行符、带空格、带*号本身这些极端情况会让基于通配符的脚本彻底失控。find -print0加xargs -0的组合就是专门对付这个的用空字符做分隔任何文件名里的特殊字符都不会造成歧义。如果脚本要长期维护、处理来源不可控的文件这套写法应该成为默认选择而不是等到出事以后再改。我自己踩过最疼的一次坑是在一个共享目录里执行rm -rf *里面有个软链接指向了另一个目录结果递归删除顺着链接删掉了一大批不该动的东西。从那以后我给自己定了条规矩任何rm -rf前面必须先用ls -la看清楚目录里有什么再执行。这个习惯看起来笨但它救过我至少三次。通配符本身没有善恶它只是老老实实按规则办事真正决定结果的是你按下回车之前有没有多看一眼。