Shell特殊符号全解析:通配符、引号与重定向的避坑指南

📅 发布时间:2026/10/18 23:20:39
Shell特殊符号全解析:通配符、引号与重定向的避坑指南
搞Linux的人迟早会被一行命令里的特殊符号逼疯一回。我印象最深的一次是处理一批带空格的日志文件脚本里少写了双引号结果一个for循环把原本该当整体的文件名全拆成了碎片后面的grep和mv跟着一起错位排错排了两个小时最后发现罪魁祸首就是一句话里少了两个引号。从那以后我养成了一个习惯看到一行带符号的命令先在本子上把符号拆一遍再回车执行。这篇文章写给两类人一是刚接触Linux命令行、被各种括号和符号搞得一头雾水的初学者二是写Shell脚本时会反复踩同一个坑的从业者。里面的内容没有玄学全是符号在不同位置上的真实行为。把下面这几组符号吃透你再看任何一条命令都能第一时间判断出它在做什么以及你漏了什么引号会出什么问题。1. 通配符与大括号展开Shell处理命令的第一道关卡Shell解析命令不是从左边一个字符一个字符读过去的它会先做很多“展开”动作。文件名通配符*、?、[]和大括号展开{}就是最先发起作用的一批符号理解它们才能理解后续的引号问题。1.1 三个必须刻进脑子的元字符* ? []这三个字符在Shell里不是普通的字符而是“匹配文件名的符号”。*匹配零个或多个任意字符但它默认不匹配以.开头的隐藏文件。你在普通目录里敲ls *看到的全是普通文件.bashrc这种文件不会出来除非你显式写ls .bash*。?匹配恰好一个字符。ls report_202?.txt能匹配report_2021.txt匹配不了report_20210.txt也匹配不了report_202.txt。[]匹配括号内字符集合中的一个字符。[abc]只匹配a、b、c其中一个[0-9]匹配任意数字[!0-9]匹配任意非数字字符。注意这里的!是取反不是感叹号。这三个符号处理的是“磁盘上已经存在的文件”。如果你敲ls *.logShell会自动把*.log替换成当前目录下所有以.log结尾的文件名替换完再把它们作为参数交给ls。如果当前目录一个.log文件都没有默认情况下Shell就把*.log原样留给lsls会报“没有那个文件或目录”。这段行为很多人不知道后面会专门踩坑。范围匹配有一个小坑[a-z]在中文locale环境里匹配的并不一定是纯粹的小写字母不同地区排序规则不一样。如果你要写对文件名大小写敏感的匹配建议显式写[a-zA-Z]不要只写[a-z]。1.2 大括号展开不是通配符先展开再干活大括号展开是最容易和通配符搞混的一个。通配符是“匹配已存在的文件”而大括号展开是“生成一串字符串”它根本不管文件存不存在。echo {a,b,c} # 输出a b c echo {1..5} # 输出1 2 3 4 5 mkdir -p project/{src,docs,tests}/{sub1,sub2} # 一次性创建 project/src/sub1、project/src/sub2、project/docs/sub1……一共6个目录我常用它做的一件事是备份文件cp app.log{,.bak}这条命令会被展开成cp app.log app.log.bak等于复制一份带.bak后缀的备份。还有批量生成测试文件名touch file_{01..10}.txt # 生成 file_01.txt 到 file_10.txt注意前导零这里有个非常重要的顺序问题大括号展开发生在所有其他展开变量、通配符、命令替换之前。所以echo {1..$n}不会把变量n代入后展开成{1..5}它只会原样输出{1..5}。如果要在循环里生成动态序列用for ((i1; in; i))的C风格循环别指望大括号帮你去算变量。1.3 不加引号的通配符有多野两个血泪场景通配符不匹配时保留字面量这个行为能引发很多诡异问题。最典型的一个for f in *.log; do echo 处理 $f done如果当前目录没有.log文件*.log不会匹配到任何东西Shell会直接把字符串*.log当作循环变量的值结果这个循环会执行一次$f的值就是*.log。你后面如果写了mv $f /backup/它会真的去找一个叫*.log的文件找不到就报错。如果文件恰好存在那就更糟会误操作。解决方法是开启nullglobshopt -s nullglob for f in *.log; do echo 处理 $f done shopt -u nullglob开了nullglob之后没有匹配项时模式会被替换成空串循环一次都不执行。脚本里用到批量文件处理时这个选项很值得加。第二个场景更危险。你写folder rm -rf $folder/*$folder没设值命令就变成了rm -rf /*。这不是玩笑真的有运维这么挂过的。Windows下很多人没这个意识但Linux的rm是直接删不经过回收站。遇到所有带路径的变量先问自己一句这个变量有没有可能是空的只要有可能要么加引号要么先判断[ -n $folder ] rm -rf $folder引号不是摆设它就是你的保险丝。2. 引号体系为什么脚本里的字符串老是“变魔术”通配符和分词是Shell里最活跃的两个“魔法”。引号的作用就是控制这两个魔法要不要生效。很多脚本的bug不是逻辑错了而是引号加错了位置。2.1 单引号与双引号唯一区别就是“睁一只眼闭一只眼”双引号像一个半透明的玻璃罩它能把空格、通配符、分号这类字符罩住让Shell不把它们当语法拆开但有一个例外——$、反引号和命令替换$()在双引号里依然是活的。namehello world printf %s\n $name # hello # world printf %s\n $name # hello world第一行没有引号Shell会把hello world按空格拆成两个参数传给printf所以输出了两行。第二行加了双引号整个hello world变成一个参数。单引号则是完全不透光的铁罩里面的所有字符全部原样保留一个例外都没有。$不会展开反引号不会执行反斜杠也是普通字符。echo $HOME # 输出$HOME echo $HOME # 输出/home/someone我见过很多新手在这里困惑“为什么同一个变量换个引号输出就不一样”记住一句话双引号是“变量值照常用但别拆分”单引号是“什么都别动我说了算”。单引号里没法直接再放单引号这是Shell一个老规矩。如果变量里面本身包含单引号要用\这种拼接写法或者干脆用双引号把这段包起来。实际调脚本时遇到包含单引号的字符串我一般优先用双引号处理省事。2.2 反引号和$()之争选后者基本不会错命令替换就是“先执行命令再把输出结果放到当前位置”。老式写法是反引号新式写法是$()。echo date %F echo $(date %F)两条命令都能输出当前日期但我强烈建议用$()。反引号有两个硬伤第一反引号里面想再用反引号需要一堆反斜杠转义可读性跟乱码一样。第二反引号处理反斜杠的规则非常怪容易出“看起来没问题但就是不对”的灵异现象。$()则没有这个问题而且支持嵌套配合得明明白白# 获取脚本所在目录的绝对路径 dir$(dirname $(realpath $0))写这种嵌套命令时左边是$()右边也一眼能找到配对的括号。反引号嵌套会让人的眼睛直接裂开。要注意命令替换和双引号搭配时是一个高频考点printf %s\n $(cat multi_line.txt) # 多行内容会按行输出换行保留 printf %s\n $(cat multi_line.txt) # 所有换行都被当作分隔符内容堆成一行原因就是不加引号时命令替换的输出会经过“分词”这一关换行和空格都会被当作分隔符。所以凡是需要保留命令输出原格式的务必用双引号包住$()。2.3 引号实战盘点这几种场景必须加引号有些场景新手容易漏我按经验列一下路径含空格或特殊字符时cp $src $dst。find命令里匹配文件名find . -name *.log这个引号必须加。不加的话Shell会在当前目录先把*.log展开再传给findfind拿到的就是一堆文件名而不是匹配模式。awk和sed的程序体awk {print $1}。程序体里的$1是要留给awk处理的不用单引号包住的话Shell会先把它当成位置参数展开等awk运行时已经晚了。正则表达式grep 关键字 file虽然很多情况下不加也没事但养成加的习惯防止!、*、$这类字符被Shell抢走。test测试结构里[ $var ]。这个我在后面会专门讲不加引号变量为空时直接语法报错。2.4 引号的顺序与嵌套命令替换里的引号怎么处理很多人会问双引号里套命令替换命令替换内部再带双引号会不会冲突答案是不会。Shell解析时双引号内部的$()会开启一个新的解析环境。你在$(echo hello world)里写内层双引号Shell能正确配对不用担心外面的引号把它吃掉。echo $(echo hello world) # 输出hello world这种写法在拼接复杂字符串时很常用比如result$(grep 关键字 /tmp/a.txt | head -n 1)不要怕嵌套Shell处理引号的优先级比你想象得聪明。真正该怕的是不加引号后输出被无情分词。3. 重定向与管道Shell的血液系统命令执行时有三个默认打开的“管道口”标准输入fd 0、标准输出fd 1、标准错误fd 2。重定向符号干的事情就是把这些口重新接到文件或其他命令上。这一块如果不理解看生产环境里的启动脚本会一头雾水。3.1 文件描述符与重定向家族、、、2、21、先记住三个数字0是标准输入1是标准输出2是标准错误。 file等价于1 file把标准输出写到文件覆盖已有内容。 file追加不会覆盖。2 file只把标准错误写入文件。21把标准错误重定向到标准输出当前指向的位置。 file是bash的简写等价于 file 21把标准输出和标准错误都写进同一个文件。 file把文件内容作为命令的标准输入比如wc -l /etc/passwd。 file以“可读可写”模式打开文件这个平时用得少但处理设备文件时会遇到。生产环境里最常见的启动方式是这样nohup ./server.sh /tmp/server.log 21 这条命令把标准输出写到日志把标准错误也送到同一个日志然后整个进程放到后台。看到21你要知道它读作“二去跟一”意思就是“把2号口接到1号口现在指的那个地方”。3.2 顺序问题21为什么必须放在后面才有意义这是重定向里最经典的一个坑我见过不止一个同事栽在这上面。# 正确写法标准输出先进文件标准错误跟进 command file 21 # 错误写法标准错误先接终端标准输出再进文件 command 21 file第二种写法里Shell执行顺序是先把fd2指向“当前fd1所在的位置”也就是终端然后再把fd1指向file。结果就是stdout进了filestderr还在终端上输出。你本来想把错误也收进日志结果错误信息噼里啪啦打在屏幕上日志里什么都没有。记这句话21是“快照式的复制”复制的是那一刻fd1指向的位置而不是绑定跟随fd1变化。所以它必须放在的后面。同理file 21和file效果一样但21这种写法更通用在纯POSIX sh里也能跑是bash扩展。写跨平台脚本时我一般用file 21。文件描述符不只有这三个你还可以自定义。比如exec 3 /tmp/data.txt echo hello 3 exec 3-这个技巧在处理多路输出时有用这里先不展开。3.3 管道与/dev/null的日常组合管道符|把左边命令的stdout接到右边命令的stdin形成了一个流水线。grep error app.log | sort | uniq -c这种链式写法核心就是把每一步的输出交给下一步处理。管道默认只传递标准输出不传递标准错误。要让错误也流进管道得显式合并grep error * 21 | less另外还有一个东西叫tee它的作用是在管道中间“分叉”一边继续往下游传一边把数据写进文件。常见的组合是grep error app.log | tee filtered.log屏幕上你会看到grep的结果同时这个结果也被写进filtered.log。跑长时间任务时我经常用这个组合保留过程数据又不用中断管道。平时写脚本要“静默执行”某个命令最标准的口径是command /dev/null 21把标准输出和标准错误都丢进黑洞设备/dev/null。简写是command /dev/null但可移植性不如前者看团队习惯用哪个。3.4 |和pipefail让管道更符合预期bash提供了一个简写|它等于21 |也就是把管道左边命令的标准错误合并进标准输出再交给右边command | tee /tmp/log.txt相当于command 21 | tee /tmp/log.txt还有一个极其实用的开关叫pipefail。默认情况下管道的退出码只看最后一个命令。比如false | true按理说左边失败了但整体退出码还是0因为最后执行的是true。这在脚本里会掩盖掉中间命令的失败。set -o pipefail false | true echo $? # 输出1开了pipefail之后只要管道里任何一个命令非零退出整体退出码就是非零取最右边那个非零值。写部署脚本时我习惯在脚本开头加set -euo pipefail其中-o pipefail就是防止管道把错误吞掉。4. 变量与特殊参数$家族一次看全$在Shell里是最忙的符号。变量展开、命令替换、算术运算、位置参数全都跟它有关。这一节把$后面跟各种符号的情况梳理一遍你会发现自己以前很多模糊的地方其实就是差这层窗户纸。4.1 特殊参数对照表$0 $1 $# $ $* $? $$ $!写法含义典型用法$0当前脚本或命令的名字basename $0获取脚本名$1~$9第1到第9个位置参数$1是第一个参数$#位置参数的个数判断传参够不够$所有位置参数每个独立成词for arg in $是标准遍历$*所有位置参数作为一个字符串一般不推荐直接用$?上一条命令的退出码0成功非0失败$$当前Shell的进程PID生成临时文件名$!最近一个后台进程的PIDwait $!等待后台任务$-当前Shell的启动选项调试时查看set状态$_上一条命令的最后一个参数Shell技巧里偶尔用到$?是排查问题时我最先看的东西。每跑完一条关键命令echo $?看一下如果非0说明它已经失败了别让后面继续跑。mkdir /tmp/demo || exit 1 echo 目录创建成功4.2 $与$*的经典陷阱引号内外是两个世界$和$*平时看起来一样但它们在双引号里是完全不同的行为。写一个测试脚本#!/bin/bash echo 带引号的 \$ for arg in $; do echo 参数: $arg done echo 带引号的 \$* for arg in $*; do echo 参数: $arg done调用./test.sh hello world second third结果会是带引号的 $ 参数: hello world 参数: second 参数: third 带引号的 $* 参数: hello world second third也就是说$把每个参数当独立个体$*把所有参数揉成一个字符串。遍历位置参数时永远用$不要用$*。如果忘了加引号$和$*的行为就一样了都会按IFS做分词后果就是带空格的参数被拆得稀碎。很多脚本里处理文件名列表就因为用了这俩中的一个没用对导致带空格的文件名被拦腰截断。一句话遍历用$想拼字符串再用$*。4.3 变量默认值与裁剪${var:-x}这类展开是脚本保命符$var是最基础写法但聪明的脚本会用${var:-default}这类扩展语法。它们在变量未设置或为空时给出默认值不让脚本因为空变量直接炸掉。写法行为例子${var:-default}为空时返回default不改varecho ${port:-8080}${var:default}为空时把var设为default: ${port:8080}${var:?msg}为空时报错退出${dir:?目录未设置}${var:word}非空时返回word为空返回空${flag:已开启}:?这个我强烈推荐。它专门用来防“空变量把命令搞爆炸”rm -rf ${dir:?必须设置dir变量}如果$dir为空这行命令会直接报错退出而不是执行rm -rf。加了这个保险我上面提到的/*事故就不会发生。裁剪类操作也非常实用filenamereport_2026.log echo ${filename%.*} # report_2026去掉最短后缀 echo ${filename##*_} # 2026.log去掉最长前缀 echo ${filename//_/-} # report-2026.log替换所有下划线批量改文件名时用这套操作比临时调sed要快得多也省得引入外部进程。4.4 命令替换与引号合作如何正确保留换行处理多行文本时命令替换和引号的配合决定了换行保不保得住。$(cat file)会去掉文件结尾的所有换行但保留中间的换行。如果你用双引号包住结果中间换行就保留不用双引号换行会被分词替换成空格printf %s\n $( /etc/passwd)$( file)是$(cat file)的高效替代直接用Shell读取文件不派生子进程。但要注意它同样会去掉尾部换行。如果想逐行处理文件内容更稳的方式是用mapfilemapfile -t lines /tmp/data.txt for line in ${lines[]}; do echo 行: $line donemapfile逐行读入数组不管有没有空格都不会碎掉。处理文件名列表时这几乎是唯一靠谱的姿势。命令替换适合拿简短的输出遇上一堆带空格的文件名还是老老实实用数组。5. 逻辑分隔符与括号体系命令之间的“粘合剂”符号不只是给单个命令加修饰还能把多条命令粘起来或者改变它们之间的关系。;、、||、括号、中括号它们决定了命令是一个一个跑还是条件式地跑。5.1 ; ||与!三个分隔符一个取反分号;表示无条件顺序执行不管上一条成功还是失败下一条都照样跑。cd /tmp; ls要求左边成功退出码为0才执行右边。||是左边失败才执行右边。这是脚本里最常见的安全保险cd /data/project || exit 1 mkdir -p back_up cp *.conf back_up/!放在命令前会把命令的退出码取反if ! grep -q health /tmp/app.log; then echo 日志里没有健康检查标记 fi注意!在交互式Shell里还有个身份它是历史扩展的触发符。你敲echo hello!在有些交互Shell里!会被替换成上一条命令是history expansion输出直接变成奇怪的东西。脚本文件里没问题因为非交互Shell默认关闭了历史扩展。但命令行里遇到!要么加单引号要么执行set H关掉历史展开否则它会跟你玩捉迷藏。5.2 小括号()与花括号{}子Shell与当前Shell的分界小括号和花括号都能把多条命令放在一个组里执行但它们的核心区别是天壤之别。(cmd1; cmd2)是在子Shell里执行组内发生的目录切换、变量赋值、环境变量修改都不会影响当前Shell。拿打包场景来说(cd /tmp/project tar -czf /tmp/project.tar.gz .)这条命令在子Shell里可以放心cd结束后你的当前工作目录纹丝不动。{ cmd1; cmd2; }则是在当前Shell执行里面的变量修改会生效。它的语法比较刁钻左花括号后面必须有一个空格右花括号前面必须有一个分号或者换行。count0 { count5; echo $count; } echo 外面的count也是5: $count如果你想让一个函数内部修改变量并往外生效用花括号组或者函数体都行。想知道一个命令组的执行环境记住这个口诀小括号是“隔离实验室”花括号是“现场直播”。补充一个相关知识点进程替换(command)和(command)看着像重定向实际是生成一个临时文件描述符让你“像读文件一样”读命令输出。比如diff (sort a.txt) (sort b.txt)这个处理排序后对比的场景非常有用遇到时别再傻傻地先生成临时文件了。5.3 中括号[]与双方括号[[]]条件测试的正确打开方式[其实是一个命令它是test命令的别名。Shell的规矩是命令和参数之间必须有空格所以[ $var ok ]里每个空格都不能少少了就报错或者逻辑不对。[ ]作为命令有两个烦人之处变量必须加引号。写成[ $var ok ]如果$var为空展开后就变成[ ok ]直接语法错误。逻辑操作符不能直接写和||得用-a和-o后者已经过时且容易出歧义。bash提供的[[ ]]是关键字不是外部命令处理起来舒服得多if [[ -f $file -r $file ]]; then echo 文件存在且可读 fi if [[ $name *.txt ]]; then echo 以txt结尾 fi if [[ $answer ~ ^[Yy](es)?$ ]]; then echo 确认 fi[[ ]]内部不会做单词拆分所以里面变量不写引号也问题不大。它还支持做模式匹配支持~做正则匹配和也不需要像[ ]里那样转义。写bash脚本时我几乎所有条件判断都用[[ ]]只有必须兼容纯POSIX sh的时候才退回[ ]。有一个[[ ]]的坑得提醒你正则模式如果放在双引号里正则语法会失效变成纯字符串匹配。也就是说if [[ $answer ~ ^[Yy] ]]; then这个写法匹配不了“Y开头的字符串”它只会找字面量^[Yy]。正则放变量里也一样要确保模式变量不加引号pat^[Yy] if [[ $answer ~ $pat ]]; then5.4 与缠在一起的坑后台执行与千万别看错在命令行里有好几个身份最容易搞错的是“后台运行”和“重定向合并”。看这两条命令# 这条真正把命令放到后台并且stdout/stderr都写进日志 cmd /tmp/cmd.log 21 # 这条没有把命令放后台它只是把stdout和stderr合并写进文件 cmd /tmp/cmd.log是一个整体表示“两个输出流都进这个文件”。里面的不再承担“后台执行”的职责。很多人想当然地写cmd file以为既重定向又后台了结果命令把整个终端卡住才发现自己少写了末尾那个空格加。正确的后台日志写法是cmd /tmp/cmd.log 21 # 或者 cmd /tmp/cmd.log 另外cmd1 cmd2表示cmd1放后台紧接着前台执行cmd2。如果不想要这个语义就把挪到整条复合命令的最后。写启动脚本时要特别注意的位置它决定了整个任务是“并行”还是“串行”。还有一点后台任务会随终端退出而收到挂断信号用nohup包一层可以避免nohup cmd /tmp/cmd.log 21 记住这个组合部署服务时几乎每次都用到。6. 高频符号速查表与典型踩坑记录前面的章节是按功能模块讲的这里汇总一个速查表方便你贴在工位上或者存到笔记里。下面的表格覆盖了日常脚本里90%会碰到的符号我挑的每个例子都是真实会用到的写法。6.1 常用特殊符号一页速查符号名字/类别主要作用典型示例*?[]文件名通配符匹配已存在的文件名ls *.log{}大括号展开批量生成字符串cp app.log{,.bak}~波浪号展开用户家目录cd ~/work;命令分隔符无条件顺序执行cd /tmp; pwdAND列表左边成功才执行右边make make install||OR列表左边失败才执行右边cd $dir || exit 1!取反/历史展开反转退出码交互下是历史if ! grep -q x f; then...后台运行命令放入后台server.sh |管道stdout接下一个命令的stdinps | grep bash|合并管道stdoutstderr一起传递cmd | less重定向输出覆盖/追加写文件echo hi f重定向输入文件内容作为输入wc -l f2标准错误重定向错误信息写文件cmd 2 err.log21合并错误到输出两条流写同一目标cmd f 21合并重定向stdoutstderr写文件cmd f EOFhere-doc把多行文本作为输入cat EOF f strhere-string把字符串作为输入grep x $var$()命令替换先执行命令再把结果放进来f$(mktemp)引号防分词/保留变量$var[][[]]测试结构条件判断if [[ -f $f ]]()子Shell隔离执行环境的命令组(cd /tmp make){}命令块当前Shell内执行{ echo hi; }#注释符行首内容忽略# 注释\转义符让下一个字符失去魔法echo \*6.2 我踩过最典型的五个坑坑一for循环里通配符没匹配到文件循环体照样跑了一遍处理了一个名叫*.log的“幽灵参数”。解法就是前面说的shopt -s nullglob或者先compgen -G *.log确认有匹配再进循环。坑二find命令里的分号没转义。find . -name *.log -exec rm {} ;分号是Shell的控制符必须写成\;。我刚开始写find时每次忘转义就报奇怪的语法错误后来养成了习惯凡是在find里看到;一律先加反斜杠。坑三管道里的while循环改不到外部变量。比如count0 seq 3 | while read x; do count$((count 1)) done echo $count # 输出0原因是管道两端的命令都是在各自子Shell里运行的循环里的count改了也是白改。解决方法是把循环体写到函数里或者用进程替换while read x; do ...; done (seq 3)或者干脆用mapfile。坑四21放错位置日志里永远缺stderr。这个前面讲过了再强调一次cmd 21 file和cmd file 21结果完全不同后者才是把两股流都写进文件。坑五条件测试里的空变量引发语法错误。[ $name ok ]当$name为空时展开成[ ok ]Shell直接报unary operator expected。这个错误提示很抽象半天找不到原因。后来我凡是用[ ]变量一律加引号再也没遇到过这个问题。6.3 避坑自检清单把习惯练成肌肉记忆我现在的习惯是脚本写完后不着急跑先对照这几个问题过一遍所有变量是否都加了双引号尤其是路径、文件名、命令替换结果。所有[ ]测试里的变量是否加了引号所有find的;是否转义所有和21的顺序是否正确所有可能为空的变量是否在删除、覆盖、移动类操作前做了判断或用了${var:?}所有管道链是否开了set -o pipefail所有引用外部文件列表的循环是否考虑过文件名带空格的情况还有一个最实用的调试工具set -x。在脚本开头加一行set -x或者直接bash -x script.shShell会把每一条实际执行的命令打印出来展开后的参数清清楚楚。我刚被符号坑的时候就是靠这个命令一遍遍看Shell把命令拆成了什么慢慢才建立起对展开规则的直观感觉。结尾符号这种东西拼的不是记忆力而是对Shell解析顺序的理解。每次出问题我都会用set -x看一遍展开结果然后对照“大括号展开→变量展开→命令替换→分词→文件名展开→删除引号”这条流水线基本都能精准定位是哪个符号在作妖。我个人最后再分享一个小习惯凡是带路径的参数一律加双引号凡是可能为空的变量一律先判断再操作凡是多行文本一律用mapfile而不是裸命令替换。这三条看着简单但它们能挡掉的坑比我踩过的任何一个都多。Linux没有魔法特殊符号也不是故意整你等你摸清它们在不同位置的行为这些符号反而能变成你手上最高效的工具。