上个月我排查一个部署脚本时被通配符结结实实地坑了一回:脚本里写的是for f in logs/*.log,本地 macOS 跑得好好的,一上 CI 的 Linux 容器就一个文件都匹配不到。查了一下午,最后才发现是不同环境对“通配符语义”的理解有细微差别,而根源就藏在 POSIX.2 标准里。
这里说的 POSIX(Portable Operating System Interface),是指一套可移植操作系统接口标准,其中 POSIX.2 定义了 Shell 与各种工具的行为规范。日常写脚本用到的 *、?、[] 这些 glob 通配符,就是在这一册里被正式规定的。很多人觉得通配符无非就是“星号匹配一切”,但真正跨语言、跨平台、跨工具用起来,行为差异能让人怀疑人生。这篇文章我会从 POSIX.2 的“匹配”规则讲起,把通配符的精确语义、边界条件、常见实现差异一次说透,最后给出可移植的写法建议。
1. POSIX.2 管的是哪件事:从“匹配”的三种含义开始
1.1 Shell 里的“匹配”是展开,不是判断
POSIX.2 里所谓的“匹配”,对应的其实是路径名展开(Pathname Expansion)。你在命令行里敲ls /var/log/*.log,Shell 并不是在做“这个路径是否符合模式”的判断,而是先把/var/log/*.log这个模式拆开,扫描目录,把能匹配到的所有路径全找出来,生成一个文件列表,再把这个列表作为参数传给ls。整个过程发生在命令执行之前,属于 Shell 的预处理阶段,机制上和编程语言里的“字符串匹配”完全不是一回事。
这个差异非常关键。比如在 Shell 里执行:
bash复制echo *.txt
实际看到的是a.txt b.txt c.txt这种展开后的结果,而不是*.txt这个字面量。如果当前目录一个 txt 文件都没有,默认情况下 echo 输出的居然还是*.txt,因为匹配失败时 Shell 会把原始模式原样保留。这个反直觉的行为我待会专门展开,但你要先建立一个大前提:Shell 里的通配符是“展开机制”,不是一个布尔判断函数。
1.2 通配符和正则表达式是两套完全不同的语言
另一个常见的混淆点是,很多人把 glob 通配符当成正则表达式用。这两种东西长得有点像,但规则完全不同。在 POSIX 通配符里,*表示“任意长度的任意字符”,而正则表达式里*表示“前一个字符出现零次或多次”。同样写a*,通配符匹配的是所有以a开头的文件名,正则匹配的却是零个或多个a,完全是两个意思。
点号也容易混:正则里.能匹配任意字符,通配符里.就是普通字符,意义就是文件名的点。方括号的取反写法也不同,正则里写[^abc],POSIX 通配符标准里写[!abc]。很多刚接触脚本编程的人在这里踩坑,用正则的思维去写 glob 模式,导致匹配结果和预期差别巨大。
1.3 这一节在标准里的位置
标题里的“3.1 匹配”,像是一份教程或标准文档的章节编号。如果去看 POSIX.2 原文,通配符相关的规则分散在“路径名展开(Pathname Expansion)”和“模式匹配标记(Pattern Matching Notation)”两处。后来很多标准库函数,比如fnmatch(),也以这套规则为蓝本。所以理解了这节内容,等于拿到了整个 Unix 系系统里“文件名模式匹配”的通用底座。各语言各工具里的通配符,都是在这个底座上做了自己的扩展或取舍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三个核心符号的精确语义:*、?、[]的边界在哪里
2.1 *:任意长度字符序列,但不能跨过“/”
POSIX.2 对*的定义是:匹配任意长度的任意字符串,包括空字符串。所以*.txt能匹配a.txt、abc.txt,也能匹配一个就叫.txt的文件(前提是人能看到隐藏文件)。但这里有一条几乎人人都容易忽略的硬限制:*不能匹配路径分隔符/。
假设目录结构是/var/log/nginx/access.log,你写/var/log/*.log,Shell 只会扫描/var/log/这一层目录,绝对匹配不到/var/log/nginx/access.log,因为*无法跨越那个/。这个设计是为了让 glob 模式天然具备“单层路径”的语义,不至于一条*把所有层级全部吞掉。
拿生活场景类比一下:Shell 的*就像搜索框里的精确词匹配,只会搜索当前分类下的内容,不会自动跨到子分类去。真正要跨目录的话,需要显式写多个*,或者在支持**的环境里使用递归通配符,而不是指望单条*通吃。
2.2 ?:恰好匹配一个字符
?的语义是匹配任意单个字符,同样不能匹配/。比如?.txt能匹配a.txt、1.txt,但不能匹配ab.txt,因为那是两个字符。
实际使用中?的出场率比*低,但在文件名有固定位数的场景里非常有用。比如日志文件命名是access.20250101.log这种定长格式,你想匹配 2025 年任意一天的日志,可以写access.20250?.log,这个?正好占一个数字位,不会误匹配到其它长度的文件名。
这里有个隐蔽的坑:在 UTF-8 locale 下,?匹配的是“一个字符”,但底层实现是按字节还是按字符,不同工具的行为并不一致。中文字符在 UTF-8 下占三个字节,某些实现里一个?可能只匹配其中一个字节,结果产生乱码或匹配失败。后面讲兼容性时我会再提这个点。
2.3 []:匹配的是集合中的一个字符,不是一组字符串
[abc]的语义是匹配 a、b、c 这三个字符里的任意一个。注意,匹配的结果是一个字符,不是一个子串。所以[abc].txt匹配的是a.txt、b.txt、c.txt这三个文件,而不是一个叫abc.txt的文件。
很多新手会在这一步产生误解,觉得[abc]匹配的是字符串“abc”。可以简单记:方括号里列的是“候选字符”,匹配时逐个字符去比对,而不是把括号内容当成整体子串去匹配。想匹配连续多个字符中的任意一个,仍然要配合*和?使用,比如[abc]*.txt匹配以 a、b、c 中任一字符开头的 txt 文件。
还有个细节:如果[后面紧跟一个!,表示取反,匹配“集合之外的任意一个字符”,这个我在下一节详细展开。
3. 方括号里的细节规则:范围、字符类、取反与转义
3.1 [a-z]的范围匹配:真实行为和 locale 强相关
方括号里可以用-表示连续范围,比如[a-z]、[0-9]、[a-zA-Z]。看起来很简单,但范围的语义是受 locale 影响的。在 C/POSIX locale 下,字符顺序是 ASCII,[a-z]就是 a 到 z 这 26 个小写字母,行为最可预期。但在 en_US.UTF-8 这种 locale 下,排序规则基于 Unicode collation,范围的实际覆盖可能超出你的直觉。
有多离谱呢?在某些 locale 下,[a-z]甚至可能包含一些在 ASCII 顺序里排在大写字母之后的字符,或者包含带重音符号的字母。如果你的脚本运行环境 locale 不固定,看着一样的模式,在不同机器上的匹配范围可能不一样。我的建议是:在脚本开头显式设置LC_ALL=C,把范围匹配钉死在 ASCII 语义上;如果业务场景需要匹配字母数字,更稳妥的是使用下一小节说的 POSIX 字符类。
还有一个基础坑:范围的写法要求两端是单个字符。写[a-z0-9]没问题,但如果你手滑写了[a-],这个-出现在末尾会被当成普通字符,不是一个范围。
3.2 [!...]取反:POSIX 标准只认感叹号
方括号取反的 POSIX 标准写法是[!a-z],匹配一个不在 a-z 范围内的字符。注意,这里用的是感叹号!,不是正则里常见的脱字符^。虽然 Bash、GNU 的很多工具里[^a-z]也能用,但 POSIX 规范里只保证[!a-z]是合法的。为了可移植,写标准写法是最稳的。
一个容易踩的边界:[!]这种几乎空集合的表达式在不同实现下行为不统一,有的当成“匹配非 ] 的任意字符”,有的直接报错。尽量避免写出这样模棱两可的模式。如果需要匹配除了指定字符之外的任意字符,明确写出范围,比如[!a-z],不要只写一个!。
3.3 POSIX 字符类:[[:alpha:]]才是字母匹配的正解
在方括号内部,POSIX 还规定了预定义的字符类,格式是[:classname:],必须放在[]内层使用。常见的有:
[[:alnum:]]:字母和数字[[:alpha:]]:字母[[:digit:]]:数字[[:lower:]]:小写字母[[:upper:]]:大写字母[[:space:]]:空白字符(空格、制表符、换行等)[[:punct:]]:标点符号
[[:alpha:]]会比[a-zA-Z]更准确地反映 locale 下的字母定义。在 UTF-8 locale 下,[[:alpha:]]能匹配带重音的字母如 é、ü 等,而[a-zA-Z]匹配不了。反过来,如果你只想匹配纯 ASCII 字母,那就用LC_ALL=C加[a-zA-Z],别用[[:alpha:]]。
注意不要少写一层括号。很多人会写成[:alpha:],这在 glob 模式里实际是一个字符集合,包含:、a、l、p、h这些字符,不是你想要的字母类。
3.4 -和]作为普通字符的摆放技巧
方括号里有两个特殊字符要小心处理。一个是-,它默认表示范围。当你想把它当普通字符用时,把它放在集合的开头或结尾,比如[-a]或[a-],这样-就不再是范围分隔符。另一个是],它是集合的结束标记。当你想让]作为普通字符时,把它放在集合的第一个位置。比如[]a]匹配的是]或a,这里的第一个]被当成了普通字符。
这个规则有点像转义,但比转义更隐蔽,因为不仔细看根本发现不了。我见过有人为了匹配包含]和-的文件名,写出了一长串诡异的模式,其实按这个摆放规则来,模式会清晰很多。在代码评审时如果看到这种模式,值得停下来确认一下作者是不是真的理解了这个语义。
4. 匹配之外的行为边界:隐藏文件、结果排序与“零匹配”处理
4.1 隐藏文件的点号规则:为什么 * 看不到 .gitignore
POSIX 通配符有一条很重要的约定:如果一个模式的开头不是.,那么匹配结果不会包含任何以.开头的文件。这就是为什么 ls * 永远看不到 .gitignore、.env 这些文件。Shell 这样设计是为了避免普通操作不小心碰到隐藏文件,属于默认的安全机制。
这条规则在实际脚本里造成过很多困扰。比如你想写个循环处理配置目录下所有文件,包括隐藏的,写for f in /etc/app/*; do ... done,结果发现.env这类文件根本没进循环。解决办法大致有两种:一是显式在模式里包含点前缀,比如写/etc/app/.*;二是 Bash 环境下手动开启shopt -s dotglob,让*也能匹配隐藏文件。不过 dotglob 属于 Bash 扩展,不是 POSIX 标准行为,跨环境脚本要谨慎依赖。
一个更细的坑:当你显式写.*去匹配隐藏文件时,它会匹配到.和..这两个特殊目录项。这在遍历目录时会导致各种诡异行为。一个经典写法是同时写两个模式:.[!.]*匹配所有“以点开头但第二个字符不是点”的文件,再用..?*匹配“以两个点开头但后面还有内容”的文件,避开.和..。虽然丑,但在严格 POSIX 环境里这是最可靠的方式。
4.2 匹配不到任何文件:保留原样,而不是报错
POSIX 标准规定,如果通配符模式匹配不到任何文件,Shell 会把原始模式字符串原样传给命令。这就是为什么你执行ls *.nonexistent时,系统提示的是“ls: 无法访问 '*.nonexistent': 没有那个文件或目录”,而不是安静地返回空列表。
这个设计初看很反直觉,但它有历史原因:如果展开结果为空,命令收到的参数个数会发生变化,可能意外改变命令行为。保留原始字符串至少让命令知道“这里有一个文件名字面量,但它不存在”。实际写脚本时,这个默认行为容易导致两个问题:一是你以为文件存在,结果命令拿到的是字面量模式,一路报错;二是在循环里处理时,模式原样进入循环体,操作了一个不存在的文件。
Bash 提供了两个扩展选项来调整行为:nullglob让匹配不到的展开结果为空字符串,failglob让匹配不到时直接报错中止。写健壮脚本时,我习惯在文件匹配场景显式开启nullglob,这样循环逻辑不会处理任何不存在的条目。但同样需要注意,这是 Bash 扩展,不是所有 shell 都支持。
4.3 展开结果的排序:不是按目录物理顺序
当你匹配到多个文件时,Shell 会对结果列表进行排序,排序规则由当前 locale 决定,而不是目录里的物理存储顺序。在 C locale 下就是简单的字节序,大写字母排在小写字母前面,数字靠前。在 UTF-8 locale 下,排序顺序会和字节序有差异,因为 collation 规则更复杂。
如果脚本对文件名顺序敏感,比如按顺序处理日志文件,最稳妥的做法是展开后显式排序。比如:
bash复制LC_ALL=C sort < <(printf '%s\n' logs/*.log)
或者用 Bash 的shopt -s globsort?其实 Bash 没有这个选项,但你可以通过sort命令重新排。关键是:不要假设展开结果一定按你想要的顺序排列。
4.4 引号和反斜杠:通配符不是永远生效
通配符只在 Shell 的特定阶段生效。如果你给模式加上单引号或双引号,它就不会被展开。比如echo "*.txt"输出的就是字面量*.txt。单引号内所有字符都是字面量,双引号内虽然还有部分特殊字符(比如$和反引号),但通配符在双引号里同样不会展开。
反斜杠是另一个控制方式:echo \*.txt输出*.txt,这里的\把*转义成了普通字符。但要注意,双引号内的反斜杠规则不一样。在双引号里,\只对$、`、"、\、换行符这几个字符转义,对*不生效。所以写"\*.txt"时,输出的反而是\*.txt,反斜杠被保留下来了。这个细节看着小,但一旦脚本里有拼接路径和模式的操作,很容易让人陷入“为什么转义没生效”的困惑。
5. 标准在不同实现里的“翻译”:Shell、find、Python、Redis、Spring
5.1 Bash 和 POSIX Shell:最接近标准,但也夹杂大量扩展
Bash 默认的 glob 行为基本遵循 POSIX.2 的规则,但它加了许多扩展。最常用的是**(globstar 选项开启后),可以递归匹配零个或多个目录层级。比如**/*.txt能匹配当前目录以及所有子目录下的 txt 文件,这在默认的 POSIX 模式里做不到。
另一个容易踩的是 extglob,它扩展出了?(pattern)、*(pattern)、+(pattern)等类似正则的语法。这些在 POSIX 标准里都不存在。写脚本时如果用了这些特性,换到sh或dash环境立刻报错。我现在写需要跨 shell 的脚本时,会刻意避开**和 extglob,只使用标准三个通配符。如果确实需要递归匹配,用find或手动循环更靠谱。
Bash 也有一个和标准不一致的小点:当开启nullglob后,匹配不到时展开为空,此时命令参数数量会变化,某些命令行为可能和默认保留原样时完全不同。这不是 Bug,是你必须了解的模式切换。
5.2 find -name 的匹配:fnmatch 语义,只针对文件名
find . -name "*.txt"是日常高频命令,但它和 Shell 展开有本质区别:-name做的是对“文件名”本身的模式匹配,不是扫描目录然后展开列表。匹配的依据是每个条目的 basename,所以*不会跨到目录层级,行为比 Shell 更严格:你想匹配subdir/a.txt,写find . -name "*.txt"是匹配不到的,因为-name看的是a.txt这个值,路径里的目录结构完全不在匹配范围内。
要匹配完整路径,得用-path,比如find . -path "*subdir/*.txt"。注意这里的*依然不跨/?实际上-path的匹配规则和-name不完全一样,GNU find 的-path对整个路径串做匹配,*是可以匹配/的。不同 find 实现之间这部分的差异也很大,如果做跨平台工具,最好先用最小命令验证再写进脚本。
和 Shell 展开相比,find -name还有一个差异:它没有隐藏文件保护规则。find . -name "*"会列出包括隐藏文件在内的所有条目,因为 find 直接遍历目录条目,不做“点开头文件是否被默认排除”的判断。这个差异在日常使用中经常被人忽视。
5.3 Python 的 glob、pathlib 与 fnmatch:同源但各有性格
Python 的 glob.glob 和 pathlib.Path.glob 底层遵循的是 fnmatch 风格,但它们对路径分隔符的处理和 Shell 一致:*不跨目录。与 Shell 不同的是,老版本 Python 的 glob 默认不会跳过隐藏文件,glob.glob('*')会把.gitignore也列出来。从 Python 3.11 开始,glob 增加了 include_hidden 参数,默认值是 False,这意味着新版默认行为向 Shell 靠拢了,不返回以点开头的文件。
pathlib.Path.glob('**/*.txt') 的递归行为也有一些版本差异,尤其是“是否匹配隐藏目录里的文件”,在不同 Python 版本和不同操作系统上表现不完全一致。我的经验是:用 pathlib 处理路径时,不要依赖默认行为,需要隐藏文件就显式过滤或加参数,否则测试环境和生产环境容易产生差异。
另外一个高频率误区是用 fnmatch.fnmatch 做路径匹配。fnmatch 模块本身不感知路径分隔符,它的*可以匹配任意字符,包括/。这意味着 fnmatch('a/b/c.txt', '*.txt') 返回 True,完全不能用它来判断“某路径是否匹配某层目录模式”。要做路径匹配,应该用 glob、pathlib 或专门的路径匹配库,而不是直接 fnmatch。
5.4 Redis KEYS 命令的通配符模式:长得像 glob,但不是一套东西
Redis 的 KEYS pattern 和 SCAN MATCH 支持一种 pattern 语法,支持*、?、[]和\转义。Redis 的*匹配整个 key 的任意长度内容,不区分目录层级,冒号、点号全都照吞不误。因为 Redis 的 key 本身没有“隐藏文件”的概念,也没有路径分隔符的特殊语义,所以这条规则和 POSIX 通配符只是“形似”。
生产环境里最忌讳的就是用 KEYS user:* 去遍历大量 key,KEYS 会阻塞 Redis 服务,key 一多就是事故。如果要按模式遍历,一定要用 SCAN 命令配合 MATCH 参数,比如:
bash复制SCAN 0 MATCH user:* COUNT 100
SCAN 的 MATCH 用的是和 KEYS 相同的 pattern 语法,好处是分批返回,不会阻塞。就算匹配模式写错了,也能及时止损。
5.5 Spring 的 PathPattern 与 AntPathMatcher:路径感知的 glob 方言
Java 后端常见的 /api/**/*.json 这种路径模式,和 POSIX 通配符的关系又隔了一层。Spring 的 PathPattern 里,*匹配单层路径(不包含/),**匹配多层路径,?匹配单个字符,{var}是路径模板变量。AntPathMatcher 是早期的实现,PathPattern 是后来推出、解析更严格、性能更好的替代品。
这套规则的好处是显式区分了“单层匹配”和“多层匹配”,比原始 POSIX 通配符更适配 Web 路由场景。坏处是,如果你习惯了 Shell 的**递归语义,再写 Ant 风格的模式,会因为*和**的层数语义不同而出错。在新项目里建议优先用 PathPattern,它处理**和模板变量的语义更明确,能少踩不少坑。可以把各种路径匹配实现简单地比作同一套标准在不同应用场景下的“方言”。
| 环境 | *是否跨/ |
是否跳过隐藏文件 | 是否有**递归 |
备注 |
|---|---|---|---|---|
| POSIX Shell | 否 | 是 | 无标准实现 | 默认匹配不到时保留原样 |
| Bash + globstar | 否 | 是(默认) | 有 | 默认关闭 globstar |
| find -name | 否(只看 basename) | 否 | 无 | 匹配目标是文件名而非路径 |
| Python glob | 否 | 3.11 后默认是 | 有(recursive=True) | 老版本默认不跳过隐藏文件 |
| Python fnmatch | 是 | 否 | 无 | 不感知路径分隔符 |
| Redis KEYS/SCAN MATCH | 是 | 不适用 | 无 | key 没有路径层级概念 |
| Spring PathPattern | 否 | 不适用 | 是 | *单层,**多层 |
6. 跨环境写通配符时的实操建议与验证思路
6.1 先搞清楚执行匹配的到底是谁
在写任何包含通配符的代码或脚本之前,先问自己一个问题:这个模式最终会被哪个组件执行?是 Shell 展开、find 命令、Python 库、Redis、还是 Web 框架的路由匹配器?这一步没想清楚,后面全是坑。
举个例子,你在 Bash 脚本里写:
bash复制find . -name "*.log" -exec cp {} /backup/ \;
这里的*.log是被 find 命令自己解释的,不是 Shell 展开。如果你文件名里有特殊字符,Shell 不会先处理这个模式,而是原样传给 find。反过来,如果你写的是:
bash复制cp /var/log/*.log /backup/
这个*.log就是 Shell 展开的,而且如果匹配不到,Shell 会把/var/log/*.log原样传给 cp,cp 就会报“没有那个文件或目录”。两段逻辑看起来差不多,执行机制完全不同,排查问题的方向也完全不同。
6.2 可移植写法的一些准则
基于我这些年被各种通配符行为折磨的经验,如果你要写跨环境、跨工具执行的脚本或代码,以下几个建议值得直接抄作业:
- 尽量只用标准通配符
*、?、[],不要依赖**和 extglob,除非你明确知道目标环境支持。 - 需要字母范围时显式设置
LC_ALL=C,避免 locale 导致范围意外变宽。 - 范围匹配优先用 POSIX 字符类
[[:alpha:]]等,语义更清晰。 - 处理隐藏文件时,显式写
.*,或者用工具的隐藏文件开关,不要赌默认行为。 - 匹配不到文件时,根据场景选择 Shell 的
nullglob、failglob选项,并放入脚本头部的“环境准备”环节。 - 代码里做路径过滤,优先使用成熟的路径匹配功能,比如 Python 的
pathlib或 Spring 的PathPattern,不要自己拼字符串正则去模拟 glob。 - 跨平台时注意路径分隔符差异,POSIX 语义基于
/,Windows 上很多匹配逻辑会自动转换路径分隔符,模式里统一用/往往更安全。
这些准则不是从标准原文里直接抄的,是我在真实项目里一条条试出来的。很多问题在单机开发环境不存在,一旦上生产、换环境、换语言版本,立刻暴露。
6.3 用最小用例验证你的假设
我以前也经常凭记忆写通配符,直到被坑过几次之后,养成了一个习惯:遇到不确定的行为,先写最小用例验证,再写正式逻辑。这个方法比翻文档都快。
比如验证 Shell 的 nullglob 行为:
bash复制mkdir -p /tmp/globtest && cd /tmp/globtest
touch a.txt b.TXT .hidden
shopt -s nullglob
printf '<%s>\n' *.txt
验证 Python 的 glob 是否包含隐藏文件:
python复制from pathlib import Path
print(list(Path('.').glob('*')))
print(list(Path('.').glob('*', include_hidden=True)))
验证 Perl 或 Ruby 的行为也类似,都是先跑最小样例,把模式在不同环境下打印出来。做跨环境工具链时,我甚至会写一个简单的测试脚本,在 CI 的每个目标环境上跑一遍,确认 glob 行为一致,然后才敢把逻辑写进正式代码。
踩的坑多了之后,我的体会是:通配符看起来简单,但它本质上是“跨标准、跨实现、跨 locale 的复合问题”。不要在脑子里把通配符当成一个固定不变的知识点,它更像一套在不同环境下有具体语义的 API。先把 POSIX.2 的基准规则吃透,再去逐个了解各工具的方言,遇到诡异问题基本都能一眼定位到“是哪个环节对标准做了不同的解释”。这个底层逻辑搞清楚了,比背一百条具体命令都管用。
