尧图精选

一文搞懂Shell特殊符号:通配符、引号、重定向与管道

🕒 发布时间:2026/10/1 18:20:40 📁 来源:尧图网络
天天和 Linux 打交道谁还没被 shell 命令行里的特殊符号坑过反正我是实打实被坑过很多次尤其是刚把 shell 脚本当回事的那段时间一个没加引号的变量、一条写错的重定向就能让备份任务在半夜静悄悄失败。后来才慢慢想明白这些符号不是装饰品也不是故意刁难人的加密字符它们就是 shell 语法本身的一部分。把*、?、、|、$()、这些符号的脾气摸清楚你的命令行操作和脚本编写基本就通了七成。这篇东西就是写给想系统过一遍 shell 特殊符号的人不管你是刚入行的运维、写自动化脚本的开发还是单纯想把 Linux 命令用得顺手一点的爱好者按着我下面的顺序过一遍那些看着眼花的符号组合会变得相当有规律。1. 特殊符号到底在学什么先搭一张全景图1.1 为什么说特殊符号是 shell 的“标点符号”你写中文需要逗号、句号、括号shell 命令行也一样而这些特殊符号就是 shell 的标点符号。它们负责把零散的命令词汇组装成有逻辑的句子哪些文件要被操作、命令的输出送到哪里、上一条命令成功了再执行什么、如何在命令里引用变量。所以学这些符号本质上是在学 shell 的语法而不是背一堆神秘的字符组合。我见过很多新手把特殊符号当成“遇到再说”的知识点结果就是每次写脚本都像在碰运气——命令跑通了不知道为什么跑挂了也不知道去哪排查。反过来如果你能准确说出$()和反引号的区别、[ ]和[[ ]]的差异、和的生效时机那你看任何 shell 脚本都会变得非常轻松因为脚本无非就是这些符号在不同场景下的排列组合。1.2 特殊符号的分组逻辑为了让学习过程不那么吓人建议把这几十个符号按功能分成四大类每类单独突破分类代表性符号核心作用文件匹配与路径* ? [ ] ~ . .. {}扩展文件名、定位路径引用、转义与注释 \ # ; 反引号控制字符串解析方式重定向、管道与流程 ||变量、括号与特殊参数$ ${} $() $(( )) $? $# $ () {} [ ] [[ ]]取值、计算、条件判断这个分类不是严格的语言学分类而是按使用场景分的目的是让你在“想匹配文件”“想拼接命令”“想写条件判断”的时候能快速定位到对应的符号家族。下面每一章我都会挑最常用的符号讲透冷门的带一句了解即可。1.3 一条不算学习路径的学习路径如果你完全不知道从哪里开始我建议按这个顺序来先掌握通配符和重定向因为这两个是日常命令行里最高频的再攻破引号和转义因为这决定了你写的命令会不会被 shell“理解错”然后学习管道和逻辑链接符让多条命令能协作最后再啃变量展开、括号和特殊参数把它们用到脚本里。这也是我写这篇文章的章节顺序跟着走就好。2. 通配符与路径类符号先学会“模糊匹配”2.1 glob 不是正则* ? [] 的本质*、?、[ ]这组符号在 shell 里叫 glob 通配符用于文件名匹配。注意这里的*和正则表达式里的*含义完全不同。在 glob 里*代表任意长度的任意字符而正则里*是“前一个字符出现 0 次或多次”。我见过不少从 Perl/Python 转过来的人把两者弄混在 shell 里写*.txt没问题但在 grep 里写*.txt就抓瞎了因为 grep 默认用的是正则。具体到用法# * 匹配任意多个字符包括0个 ls *.log # ? 匹配恰好一个字符 rm test? # 能匹配 test1、testX但不会碰 test # [ ] 匹配集合中的任意一个字符 cp [abc]*.conf /tmp # 匹配 a开头、b开头、c开头的 .conf 文件 # [a-z] 匹配范围内的一个字符 ls file[0-9].txt # file1.txt、file5.txt但不匹配 filea.txt[ ]里还可以用排除写法[!abc]或者[^abc]表示匹配不是 a/b/c 的一个字符。不过在 glob 里[!abc]更通用[^abc]在部分 shell 里表现不一致写脚本时优先用[!abc]。2.2 波浪号、点号和花括号的路径玩法~是家目录的快捷方式~就是$HOME~user是切换到指定用户的家目录。它在路径最开头才生效写在中间就是普通字符。另外还有两个不常见但很有用的写法~代表当前目录等价于$PWD~-代表上一个所在目录等价于$OLDPWD。cd ~-比cd -更显式在脚本里语义更清晰。.和..就简单了一个当前目录一个上级目录。但这里有个新手最容易犯的严重问题rm -rf .是删除当前目录rm -rf ./*才是删除当前目录下的所有东西。前者在脚本里一旦出现且目录路径解析出错后果很严重所以我在写脚本时几乎不用裸的.或..而是用$(pwd)或显式路径替代。花括号{}在路径场景里叫做“花括号扩展”brace expansion可以做排列组合和序列生成echo {a,b,c}.txt # a.txt b.txt c.txt echo file{1..3}.log # file1.log file2.log file3.log echo {01..10} # 支持补零01 02 ... 10 echo {a..e}{1..2} # 组合展开生成10个结果花括号扩展最大的特点是它在所有其他展开之前执行而且是纯文本层面的展开不要求文件名真实存在。所以你可以用它给 cp、mv 批量生成目标名也可以用它生成参数列表。但注意花括号扩展不是通配符它不在乎文件是否真的存在。2.3 通配符的边界与坑通配符有几个特性必须刻进脑子第一glob 不匹配隐藏文件。*不会匹配以.开头的文件这是有意设计防止你把隐藏配置一锅端。真想匹配.config这类文件得写.*或者.[!.]*匹配一个点开头但第二个字符不是点的文件顺带排除.和..。第二glob 如果没有匹配到任何文件默认保留字面字符。比如当前目录没有 txt 文件时ls *.txt会报“无法访问 *.txt”而不是安静地什么都不做。这在脚本里是隐藏炸弹你以为rm *.tmp会清理临时文件结果一个都没匹配到时rm收到参数*.tmp报错退出后续命令可能被链带崩。所以要么提前判断要么在脚本开头加上shopt -s nullglob没有匹配就展开为空或failglob没有匹配直接报错让行为变得可控。第三通配符的展开是“先展开再执行”的。所以ls *.txt真正执行的是ls a.txt b.txt ...这意味着如果文件名带空格裸通配符会把它拆成两个参数必须用find ... -print0配合xargs -0或者while IFS read -r循环来处理。3. 引号、转义与注释shell 最容易咬人的地方3.1 单引号、双引号、反斜杠的优先级搞不清引号规则等于在 shell 里盲走。三个符号按优先级从高到低是单引号 双引号 反斜杠不对正确的记忆方式是单引号里面所有东西都原样保留双引号里面大部分东西保留但变量和命令替换仍然生效反斜杠则是逐字符转义。优先级其实看作用范围一层一层剥。nameworld echo hello $name # 输出hello $name echo hello $name # 输出hello world echo hello\ $name # 反斜杠只转义空格输出hello world单引号是最强的“保险箱”里面的$、、*、?全部失去特殊含义原样输出。双引号则像“防弹玻璃墙”挡住了空格拆分和通配符展开但放行了$和这两个“使者”。反斜杠是“一对一保镖”只保它后面的那一个字符。在双引号内部反斜杠的特殊之处在于它只对$、、、\和换行这几种字符保持转义能力对其他字符会原样保留反斜杠本身。这就是为什么echo \*输出\*而不是*——因为在双引号里反斜杠不负责转义星号。3.2 命令替换反引号与 $() 的取舍命令替换是把一个命令的输出塞到另一个命令的上下文里最常见的有两种写法老式的反引号cmd和新式的$(cmd)。任何时候我推荐用$()理由很实在反引号里如果再嵌套反引号转义会让人发疯$()可以任意嵌套。$()里的内容被当成独立单元引号处理更符合直觉。反引号在视觉上容易和单引号混淆脚本可读性差。echo 今天是 $(date %F) echo 今天是 date %F # 能用但不推荐 # 嵌套示例反引号写法非常痛苦 echo $(echo $(date %Y)) echo echo \date %Y\ # 这行光是转义就看不清了还有一个容易被忽略的细节命令替换会去掉输出末尾的换行符。所以如果你用$(cat file)读一个文件文件末尾的换行会消失这在拼接字符串或者做哈希比较时可能引发诡异问题。批量场景下更推荐用mapfile或while read逐行读而不是无脑命令替换。3.3 注释、分号与续行#在行首是注释但在变量展开里它就不是注释了比如${var#pattern}是去掉前缀匹配那是参数扩展的语法。所以看到#别急着当注释看位置。分号;的作用是把多条命令放在同一行依次执行不管前一条是否成功cd /tmp; ls; echo done注意它和的区别;是无论成败都继续是只有成功才继续||是只有失败才继续。三者应用场景完全不同。如果你在交互式命令行里只想“连着敲几条命令”用;如果在脚本里要做条件串联用和||。反斜杠还有一个高频用法是续行符。在行尾写一个\shell 会认为下一行还是同一条命令。这个在脚本里写长命令时让可读性提升不少但注意\后面不能跟任何字符哪怕一个空格都会破坏续行。我见过不少人排错半天就因为是\而不是\。3.4 引号上的经典踩坑引号问题在脚本里是重灾区这里列几个最常见的坑。单引号里想再出现单引号shell 没有提供转义机制只能通过“闭合–转义–重开”的方式拼接比如It\s就能输出Its。这个写法第一次看很丑但看多了就习惯了。更清晰的做法是改用双引号Its。另一个坑和变量值本身有关。假设path/tmp/my file如果你在脚本里写rm -rf $path空格会让 rm 收到两个参数轻则删错文件重则整个目录结构被拆散。所以涉及变量引用的场景一律给变量加双引号$path。除非你特意想依赖 shell 的单词拆分做轮询那也至少先搞明白 IFS 是怎么回事。还有一个我早年踩过的坑grep $pattern file如果$pattern里含正则特殊字符你会得到一堆意外匹配如果$pattern是空字符串grep 会认为没给模式直接读标准输入脚本就挂起在那里。加引号能解决前半个问题要彻底解决还得理解 pattern 的构造。4. 重定向、管道与进程控制让命令串起来4.1 重定向家族 重定向改变的是命令的输入输出来源是 shell 里最实用的一类符号。是覆盖写入是追加写入。有个必须记住的危险行为在命令启动时立刻、马上、无条件地清空目标文件哪怕这条命令后面执行失败了文件也已经空了。所以不要写cmd file还期望 cmd 失败时保留原文件内容做不到的。这也是为什么很多脚本用生成临时文件时宁可先生成到同目录的.tmp再 mv 过去保证原子性。把文件内容作为命令的标准输入是 here-document直接在脚本里嵌入多行文本当输入是 here-string把一个字符串当输入# heredoc常见于给交互式命令批量喂指令 cat EOF out.txt 第一行 第二行 EOF # here-string省去 echo 再管道 grep key $variableheredoc 里有个小细节定界符EOF如果写-可以忽略行首的 tab但不能忽略空格如果定界符加引号EOF里面的变量不会展开适合写模板文件。4.2 标准错误流与 21 的顺序问题Unix 的文件描述符 0/1/2 分别对应标准输入、标准输出、标准错误。21是把标准错误合并到标准输出12反过来。顺序很关键cmd file 21 # 正确先让 stdout 指向 file再让 stderr 指向 stdout两者都进 file cmd 21 file # 错误先让 stderr 指向当前 stdout终端再改 stdout 为 filestderr 仍去终端这个顺序我屡次在面试题里见到自己当年也栽过。记法就是重定向从右往左看谁在前谁先执行。21必须放在后面才生效。bash 4.x 之后也有或这种“合并全部输出”的简写底层就是 file 21但为了可移植性脚本里我坚持写完整版。4.3 管道与子 shell 的隐藏代价|管道把前一个命令的标准输出接到后一个命令的标准输入。这是 Unix 哲学的核心但新手容易把它想成“无脑连接”。实际上管道在 bash 里的每个分段都会启动一个子 shell子 shell 里的变量修改不会传回父 shell。这是无数人排错到自闭的根源# 你以为 count 会变成5实际上永远输出0 echo -e 1\n2\n3\n4\n5 | while read line; do ((count)) done echo count$count # 输出 count0解决办法有几种用进程替换(cmd)或者把循环移出管道或者用mapfile先读进数组再循环或者像 bash 4.2 之后启用的shopt -s lastpipe让最后一个管道段留在当前 shell 执行。我自己最常用的是mapfile因为它对空白、特殊字符的处理更干净。管道还有一个影响中间任何一条命令失败整体退出码默认是最后一条命令的退出码而不是中间失败的那条。这意味着你的脚本可能吞掉了错误。想严格对待失败要么打开set -o pipefail要么在管道中间留检查点。4.4 后台运行与逻辑短路 和 和 ||把命令放到后台执行立即返回提示符。符号容易混淆的是、、||。是后台执行符是“前一条成功才执行后一条”||是“前一条失败才执行后一条”三者毫无关系但视觉上很容易写错。# 后台执行适合处理耗时任务 tar czf backup.tgz ./data # 逻辑短路脚本里的条件执行神器 mkdir -p /tmp/build cd /tmp/build make grep -q error log.txt || echo no error # 后台 条件组合 systemctl restart nginx tail -f /var/log/nginx/access.log 用的时候标准输出还是会骚扰终端所以一般配合/dev/null 21一起用。后台进程的退出码通过wait $!拿$!是刚才最后一个后台进程的 PID。5. 变量、命令替换与特殊参数脚本的骨架符号5.1 $VAR 与 ${VAR}差一层括号差很多$var是最基础的取值方式但一旦变量名后面紧跟着其他字符就麻烦了。$var_name会被解析成变量var_name这不是你想要的。所以推荐任何有歧义的位置都写成${var}尤其做字符串拼接时prefixbackup echo $prefix_up # 想取 prefix 再加 _up错了它找的是 prefix_up 这个变量 echo ${prefix}_up # 正确backup_up${}能力远不止包一层壳它支持参数扩展语法在变量可能为空时给默认值这是脚本健壮性的关键# 变量为空时用默认值不改原变量 echo ${url:-https://example.com} # 变量为空时赋值会改变原变量 echo ${url:https://example.com} # 变量非空才用默认值 echo ${url:已设置} # 报错退出适合必须传参的场景 : ${DIR:?DIR 变量未设置}${var#pattern}和${var%pattern}用于删除前缀/后缀在批量处理文件名时是神器。比如把所有.txt后缀去掉${file%.txt}从路径里提取文件名${path##*/}。四个符号# ## % %%的区别是“最短匹配还是最长匹配”这个多试几次就熟了。5.2 特殊参数$? $# $ $* 一个都不能错脚本里有一组由数字和符号组成的预定义变量决定了脚本的行为逻辑变量含义$0脚本名或当前 shell 名$1~$9位置参数第1~9个参数$#参数个数$全部参数每个是独立单词$*全部参数整体是一个单词$?上一条命令的退出码$$当前 shell 的 PID$!最近一个后台进程的 PID这里最容易出错的是$和$*在双引号内的差异。举一个例子# 脚本接收参数 a b c for arg in $; do echo $arg; done # 输出两行a b / c for arg in $*; do echo $arg; done # 输出一行a b c所以当你需要把“用户传入的所有参数原封不动转发给另一个命令”时务必写$这样带空格的参数才能保持原样。5.3 算术展开与嵌套$(( ))做整数算术是 shell 原生的数学运算。注意它只支持整数3/2 结果是 1。常用写法echo $(( 10 20 * 3 )) echo $(( (1 2) * 4 )) # 自增自减注意位置 i0 echo $(( i )) # 输出0然后 i 变成1 echo $(( i )) # 先加再输出输出2$(( ))里面还可以嵌套$()命令替换来动态取值虽然绝大多数场景用不上但知道这个组合会让你对“shell 一切都是字符串、一切都要展开”这个本质有更深的体会。5.4 变量引用的常见坑赋值语句等号两边不能有空格name abc会被解析成执行name命令并带两个参数。这个错误几乎每个写 shell 的人都犯过我也会时不时看到同事在调试这种问题。另一个坑是变量值里含特殊字符时的处理。echo $var和echo $(cmd)都会让输出里的反斜杠加上一些解释老版本 bash 的 echo 默认不解释\n实现还分shopt -s xpg_echo。为了少踩这种坑我的默认习惯是能用printf %s\n $var就不用 echo。printf 的行为跨平台更一致。还有一点在set -u未定义变量视为错误模式下${var:-default}是最安全的取值写法因为$var一旦未定义直接报错退出。我建议脚本开头都加set -u -e -o pipefail虽然前期会被报错烦到但长远看能挡掉大部分低级错误。6. 括号与范围语法if、for 里的符号陷阱6.1 () 子 shell 与 {} 当前 shell 的执行块圆括号(cmd)会在一个子 shell 里执行命令子 shell 里的一切环境变化不会带到当前 shell。最经典的用例是临时切换目录再执行命令不影响当前所在目录(cd /var/log grep error syslog) # 只在括号内生效 pwd # 当前目录没变花括号{ cmd1; cmd2; }是在当前 shell 里执行一组命令命令之间分号不能漏括号前后要有空格。它和()的区别在于{}里的变量修改会保留下来所以它在脚本里可以做“分组但不隔离”的效果。注意这不是花括号扩展的{a,b}而是命令分组语法两者语境不同。6.2 [ ] 是命令[[ ]] 是关键字这是个容易让人懵的领域。[不是 shell 语法符号它是一个独立的命令等同于test只是它以]结尾。既然是命令它的各个部分就必须用空格隔开否则 shell 会把[和后面的内容粘连到一起if [ -f $file ]; then ... fi # 错误示范[] 内缺空格会直接报错 if [-f $file ]; then ... fi[]是普通命令意味着它遵循命令解析规则变量值里的空格会拆词所以$var的双引号在[]里尤其重要。而[[ ]]是 bash 的关键字不遵循普通命令的单词拆分规则里面支持、||、正则~、模式匹配等更丰富的语法推荐在新脚本里优先使用[[ -f $file ]] echo 存在 [[ $name *.txt ]] echo 是txt文件 [[ $str ~ ^[0-9]$ ]] echo 是数字但[[ ]]不是 POSIX 标准sh或dash下不认如果你的脚本开头是#!/bin/sh老老实实用[]。6.3 算术求值 (()) 与 C 风格 for 循环双圆括号(( ))是算术求值运行在 shell 自己的整数计算环境里。它和$(( ))不同$(( ))会产生字符串结果(( ))则是纯计算不输出结果但它的退出码游戏规则是“结果非0就返回真”。因此它可以优雅地用在 if 判断里i0 if (( i 10 )); then echo less than 10; fi更重要的是它撑起了 C 风格 for 循环for ((i1; i10; i)); do echo $i done这种循环写法比for i in {1..10}更灵活因为步长、终止条件都是运行时计算出来的不受花括号扩展“先展开”的限制。6.4 一张表总结五类括号符号类型要点( )子 shell隔离环境变化{ }命令分组当前 shell 执行前后留空格、末尾加分号[ ]test 命令是命令不是语法空格敏感[[ ]]bash 关键字推荐使用支持正则和布尔逻辑(( ))算术求值C 风格计算常用于 if 和 for另外提一个容易混淆的点正则表达式里的{n,m}表示重复次数和 shell 的花括号扩展完全是两码事。在grep a{2,3}里没引号的话bash 可能会尝试花括号扩展所以正则里记得加引号。7. 排错必备这些坑我几乎都踩过7.1 常见现象速查表把实际工作里高频遇到的符号错误整理成速查表出了问题照着对号入座现象常见原因正确做法脚本里变量值“消失”了变量含空格未加引号被拆词$varrm *.txt报错但明明有文件可能是在错误的目录或者*.txt被引号包住了先pwd; ls确认重定向后文件立刻清空的截断发生在命令启动时改用临时文件再 mv管道循环里改的变量外面不变管道分段在子 shell 执行用mapfile或进程替换[ $a -gt 10 ]报参数太多$a为空扩展后命令里缺参数写成[ $a -gt 10 ][[ $a 10 ]]判断错误在[[ ]]里是排序比较不是数比用-gt脚本里有$pattern却查不到内容变量未导出或引号导致字面匹配export或加双引号$(cat file)读文件总少一行命令替换删除了末尾换行用 mapfile 读取7.2 调试三板斧bash -n、set -x、shellcheck排错最实用的三个工具按顺序用第一bash -n script.sh只做语法检查不执行。它能立刻抓出引号没闭合、括号不匹配、for 语法错误这类问题。跑一下就知道了改错成本最低。第二bash -x script.sh或者脚本里临时加set -x会把每条命令展开后的真实样子打印出来。这一步能看到通配符展开成了什么、变量取值是什么、引号怎么处理的多数“诡异”问题到这里就原形毕露。看完了记得set x关掉否则日志根本没法看。第三用 shellcheck 做静态检查。这是 Linux 下检查 shell 脚本的神器会提示你没加引号的变量、不可移植的语法、危险用法等。shellcheck script.sh一条命令比自己肉眼检查可靠得多。我第一次跑它对一个 300 行的脚本扫出 40 多个问题虽然不全是错误但确实让我养成了规范写脚本的习惯。7.3 一个小习惯先看展开再下结论最后说一个我自己的实战经验。遇到任何 shell 命令或脚本行为不符合预期第一反应不是“shell 有 bug”而是问自己这条命令在真正执行前被 shell 展开成了什么样子用set -x看一遍展开结果比任何猜测试错都高效。比如echo $var和echo $var的区别只有看到展开后有没有拆词你才会真正理解为什么引号那么重要。还有一个小技巧写脚本时多用set -u、set -e、set -o pipefail这“三件套”它能让脚本在出错时早点暴露而不是带着异常状态一路跑下去。一开始你会觉得脚本怎么老报错但那些报错恰恰是帮你挡掉更大麻烦的信号。等这些符号玩熟了你会发现读别人的 shell 脚本不再像看天书自己写脚本的思路也会清晰很多因为你终于知道每个字符在命令解析的那一刻到底做了什么而不是靠一知半解碰运气。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →