天天和 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 里盲走。三个符号按优先级从高到低是:单引号 > 双引号 > 反斜杠?不对,正确的记忆方式是:单引号里面所有东西都原样保留,双引号里面大部分东西保留但变量和命令替换仍然生效,反斜杠则是逐字符转义。优先级其实看作用范围,一层一层剥。
name="world" 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'就能输出It's。这个写法第一次看很丑,但看多了就习惯了。更清晰的做法是改用双引号:"It's"。
另一个坑和变量值本身有关。假设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" <<< "$variable"heredoc 里有个小细节:定界符EOF如果写<<-可以忽略行首的 tab,但不能忽略空格;如果定界符加引号<<'EOF',里面的变量不会展开,适合写模板文件。
4.2 标准错误流与 2>&1 的顺序问题
Unix 的文件描述符 0/1/2 分别对应标准输入、标准输出、标准错误。2>&1是把标准错误合并到标准输出,1>&2反过来。顺序很关键:
cmd > file 2>&1 # 正确:先让 stdout 指向 file,再让 stderr 指向 stdout,两者都进 file cmd 2>&1 > file # 错误:先让 stderr 指向当前 stdout(终端),再改 stdout 为 file,stderr 仍去终端这个顺序我屡次在面试题里见到,自己当年也栽过。记法就是:重定向从右往左看,谁在前谁先执行。2>&1必须放在>后面才生效。bash 4.x 之后也有&>或>&这种“合并全部输出”的简写,底层就是> file 2>&1,但为了可移植性,脚本里我坚持写完整版。
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" # 输出 count=0解决办法有几种:用进程替换<(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 2>&1一起用。后台进程的退出码通过wait $!拿,$!是刚才最后一个后台进程的 PID。
5. 变量、命令替换与特殊参数:脚本的骨架符号
5.1 $VAR 与 ${VAR}:差一层括号差很多
$var是最基础的取值方式,但一旦变量名后面紧跟着其他字符就麻烦了。$var_name会被解析成变量var_name,这不是你想要的。所以推荐任何有歧义的位置都写成${var},尤其做字符串拼接时:
prefix="backup" 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 )) # 自增自减,注意位置 i=0 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 判断里:
i=0 if (( i < 10 )); then echo "less than 10"; fi更重要的是它撑起了 C 风格 for 循环:
for ((i=1; i<=10; 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 常见现象速查表
把实际工作里高频遇到的符号错误整理成速查表,出了问题照着对号入座:
| 现象 | 常见原因 | 正确做法 |
|---|---|---|
| 脚本里变量值“消失”了 | 变量含空格,未加引号被拆词 | "$var" |
rm *.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 脚本不再像看天书,自己写脚本的思路也会清晰很多,因为你终于知道每个字符在命令解析的那一刻到底做了什么,而不是靠一知半解碰运气。