写这篇东西的起因也挺直接——有次帮某公司的同事排查一个部署脚本的问题,那脚本本身逻辑没毛病,就是在启动服务那一步时不时报“No such file or directory”。查了半天,最后发现是脚本里用了波浪号去指家目录,而执行脚本的进程环境把 HOME 变量搞丢了。说白了一整天的排查,最后落在了一个特殊符号上。
从那以后我就养成了一个习惯:只要碰 Linux 脚本或者命令行里那些“小符号”,先停下来想清楚它在这句话里的语义。也正是因为这一行干得久了,见过太多因为一个*、一个引号、一个&引发的怪问题,所以今天把在 Linux 里最常用也是最容易被忽视的哪些特殊符号系统性地整理一遍,聊聊它们的语义、用法,以及实际操作中我踩过和看别人踩过的坑。
这篇东西适合刚接触 Linux 的入门者,也适合已经写了几个月或几年脚本、但偶尔会被某些符号“咬一口”的人。你可以把它当成一份查阅笔记,也可以顺着往下读,看看哪些符号组合起来会在行为上发生质变。
1. 通配符家族:*、?、[]的匹配逻辑与误伤陷阱
1.1 通配符的三个基础成员
Linux 命令行的特殊符号里,通配符是学习曲线最平滑、但也是“事故率”最高的一类。最常见的是这三个:*、?和[]。
*在 shell 里表示匹配任意长度的任意字符,包括零个字符。?表示匹配且仅匹配一个字符。[]则是一个字符集合,匹配括号中列出的任意单个字符,比如[abc]匹配 a、b、c 中的任意一个;[0-9]匹配任意数字;[!abc]或者[^abc]则反过来,匹配不是 a、b、c 的任意单个字符。
一个非常经典的场景是清理日志:
rm -f /var/log/app/*.log这句话的本意是删除某个应用目录下所有的.log文件。但你可别小看这个*,它不匹配隐藏文件(也就是以点开头的文件),这一点经常有人忽略。如果目录里有一个.log文件——注意,是文件名开头就是点,比如.logrotate.state,上面的命令并不会把它删掉,这是 shell 通配符的默认规则。
?的应用场景比较窄,通常用在批量匹配固定长度文件名时。例如:
mv file?.txt backup/这会把file1.txt、fileA.txt这种文件移动走,但file10.txt不会命中。
[]在某些场景下比*更精确。比如要打包某个月的数据文件,把 2024 年 1 月到 3 月的日志都打成一个包:
tar czf quarterly_logs.tar.gz app_20240[1-3]*.log这个写法翻译过来就是:匹配以app_202401、app_202402、app_202403开头的所有.log文件。注意,[1-3]只是对紧跟着的那一个位置做限定。
1.2 通配符的展开机制:谁在什么时候解释它
学通配符有一个关键认知必须建立起来:*、?、[]并不是被命令本身解释的,而是被 shell 解释的。也就是说,当你输入:
ls *.txt实际执行顺序是:shell 先扫描命令行,发现*.txt,于是去当前目录找到所有匹配该模式的文件名,再用这些文件名替换掉原来的*.txt,最后把替换后的完整命令行交给ls去执行。
这个机制带来的直接影响有两个。
第一个影响是:如果当前目录下没有任何匹配*.txt的文件,绝大多数 shell 会把*.txt原样保留传给ls,于是ls会报错“无法访问 *.txt: 没有那个文件或目录”。这就是为什么很多人写了批量删除脚本,目录一空就出错的原因。如果你希望这种情况下静默处理,可以在脚本里开启nullglob选项:
shopt -s nullglob files=(*.txt)当没有匹配项时,这个数组会是空的,而不是含有一个名为*.txt的字符串。这个习惯在写安全删除脚本时非常值得养成。
第二个影响是:如果目录下有几十个文件,它们可能全部被展开成参数,导致命令行长度超限。小批量没问题,大批量操作时就要考虑用find配合-exec或xargs了。
1.3 通配符的意外误伤场景
我第一次意识到通配符的危险性,是某次在服务器上执行清理命令时少写了一个字母。原本的意图是清掉临时的.tmp文件:
rm -rf /tmp/app/*.tmp但手误敲成了:
rm -rf /tmp/app/*结果整个目录的内容全被删了。这种事在 Linux 圈子里每天都在发生。个人经验:凡是包含rm -rf的命令,执行前我一定先运行一次等价的展开查看命令,比如:
ls -l /tmp/app/*这样能直观看到通配符展开后到底匹配了什么。更稳的做法是在命令里用echo做一次空跑:
echo rm -rf /tmp/app/*看到输出无误后,再去掉echo执行。这一步在两三秒内就能做完,但能省下大量的恢复时间。
还有一个容易忽略的细节是[]中横线的位置。[a-z]是区间,但如果你想匹配字面上的横线字符,必须把-放在集合的末尾或开头,比如[a-z-]或[-a-z]。否则在某些环境下行为会变得不可预期。
2. 管道与重定向:|、>、>>、<、2>&1的语义划分
2.1 管道的本质是进程间握手
管道符号|应该是 Linux 命令行里最有代表性的符号之一。它把左边命令的标准输出接到右边命令的标准输入,形成一条流水线。很多人习惯性地把它理解为“把结果传给下一个命令”,但实际工作中理解得再深一点会更好用。
管道的本质是为左右两侧各创建一个进程,并把它们连接起来。左侧进程不断往管道里写,右侧进程不断读。这两个进程是同时运行的,不是等左边跑完再执行右边——这一点在处理长耗时命令时特别重要。
举个例子,tail -f app.log | grep ERROR能实时过滤日志,就是因为右侧的grep会持续处理左侧持续产生的内容。如果管道是“先跑完再传结果”的模型,这个实时效果就完全不可能实现。
管道也带来了两个常见问题。第一个是管道中任一命令失败,管道整体退出码默认取最后一个命令的退出码,所以左侧命令出错时你未必能察觉。第二个是过多的管道串联会让排查问题变得困难——生产环境中经常看到一条命令挂着六七个管道,输出经过层层awk、sed、grep的加工,哪个环节吞了数据不好定位。
我的习惯是:管道不超过两到三个,复杂的处理交给脚本文件完成,这样更容易调试和复现。
2.2 重定向:默认是标准输出,但不止于此
重定向符号>和>>是最常用的。>把命令的标准输出写入文件,如果文件已存在则覆盖;>>则追加到文件末尾。
echo "hello" > output.txt echo "world" >> output.txt运行之后output.txt的内容是两行:hello和world。这个大家都懂。
但真正出问题的地方在于:重定向只覆盖标准输出,不覆盖标准错误。比如你执行:
grep "keyword" no_such_file.txt > result.txt由于no_such_file.txt不存在,grep 会向标准错误输出一段报错,这段报错并不会进入result.txt,而是直接打到终端上。很多人会说“我都重定向了为什么还看到报错”,根源就在这里。
想要把标准错误也重定向进去,要写成:
grep "keyword" no_such_file.txt > result.txt 2>&1这里的2>&1含义是:把文件描述符 2(标准错误)重定向到文件描述符 1(标准输出)当前所在的位置。注意这里的顺序有讲究——2>&1必须写在>后面,因为它是“引用”当前标准输出的去向。如果反着写:
grep "keyword" no_such_file.txt 2>&1 > result.txt那么2>&1先执行,此时标准输出还是终端,于是标准错误被指向终端;随后>把标准输出指向了文件。结果是:错误信息依然打印在终端,文件里只有正常的输出内容,和预期完全相反。
2.3 标准输入重定向的语义细节
<用于把文件内容作为命令的标准输入。wc -l < file.txt和wc -l file.txt在表面上结果相同,但实际上是两套交互方式:前者由 shell 打开文件并把内容喂给命令的标准输入;后者是命令自己打开文件读取。
这个区别在区分“标准输入”还是“文件名参数”时有意义。比如很多命令支持-代表标准输入,配合管道能写出很灵活的脚本:
cat file.txt | grep pattern grep pattern < file.txt从性能角度看,后者的开销通常更小,因为省去了cat这个额外进程。这不是玄学,是在大文件、高频执行场景下实实在在的差别。
2.4 实践中常用的重定向组合
处理日志时我会用这几种组合:
> log 2>&1:把正常输出和错误输出都写入同一个文件,适合后台任务。> /dev/null 2>&1:丢弃所有输出,适合只是想执行命令、不关心结果的场景。>> log 2>&1:追加输出,适合持续运行的守护类任务。
/dev/null是 Linux 里的特殊设备文件,可以理解为黑洞,写进去的内容直接丢弃。很多人看到/dev/null会困惑为什么不是普通文件,其实它的价值就在于“永远为空、不占磁盘”。生产环境里定时任务如果不想产生无用的日志文件,这行命令是标配。
3. 命令串联与后台执行:;、&&、||、&的判断逻辑
3.1 分号是“无论成败都继续”
;表示按顺序执行多条命令,前面命令的退出状态不影响后续命令的执行:
pwd; ls; date这种写法适合没有依赖关系的命令组合。但如果在脚本里无节制地使用分号,一旦中间的某条命令失败,后续命令照样执行,往往会把问题掩盖在日志深处。比如:
cd /tmp/app; ./start.sh如果cd /tmp/app失败,脚本依然会尝试执行start.sh,结果大概率是在错误的目录里直接报错。这种问题很隐蔽。
3.2 逻辑运算符号:&& 和 ||
&&表示前一条命令成功(退出码为 0)才执行后一条;||表示前一条命令失败才执行后一条。这俩是 shell 脚本里最实用的控制原语。
mkdir -p /tmp/app && cd /tmp/app && ./run.sh翻译过来是:创建目录成功才进入目录,进入目录成功才执行脚本。任何一步失败,链路立刻中断,不会再往后走。
||通常用来做兜底逻辑:
command || { echo "command failed"; exit 1; }这个写法的好处是:不需要写if语句也能实现失败即退出的效果。在脚本中大量依赖这种语法能减少嵌套层级,让逻辑更平直。
我把&&和||配合起来用的时候有个默认规则:&&只连接“必须成功才能继续”的步骤,||只连接“失败时需要做出反应”的步骤。不要在同一个长命令里把多个&&和||混杂在一起,因为 shell 对它们的解析优先级并不是像编程语言里那样有严格的高低之分,混在一起容易失控。
具体来说,cmd1 && cmd2 || cmd3并不等价于程序语言里的三元表达式。它是从左往右按组解析的:如果cmd1成功,执行cmd2;然后判断整段cmd1 && cmd2的退出码,如果不是 0,再触发cmd3。假如cmd1成功但cmd2失败了,cmd3依然会被执行,这往往不是你想要的行为。
想要精准控制,就用if语句或者单独把逻辑拆开:
if cmd1 && cmd2; then ... else cmd3 fi3.3 后台执行符号 & 的边界问题
&的作用是把命令放到后台执行,命令提示符立即返回。很多人第一次接触时写的是:
command &这样确实会立即拿到提示符,但后台进程的两个问题容易被忽略。
首先是输出问题:后台进程的标准输出仍然会打印到终端。如果命令持续产生输出,会打断你正在做的事情。所以后台执行我一般都会配上输出重定向:
command > log/out.log 2>&1 &其次是终端关闭问题:如果你直接关掉终端或退出登录,这个后台进程大概率会被挂断信号终止。如果需要在退出终端后依然运行,就要用nohup或者setsid,或者把它放到系统服务管理器里管理。
另一个常见的组合是&>,这是 Bash 里把标准输出和标准错误同时重定向到同一个地方的简写:
command &> log/out.log等价于command > log/out.log 2>&1。有些老派的 shell 不支持这个写法,所以在写跨环境脚本时我会保守一点,用完整写法。
4. 引号体系:单引号、双引号、反引号与转义符号的各自职责
4.1 双引号:保留变量,但抑制大部分符号
双引号是“部分引用”,它有一个关键作用:把一堆参数组合成一个整体。最经典的场景是处理带空格的文件名:
touch "my report.txt" ls -l my\ report.txt如果不加引号,shell 会认为my和report.txt是两个独立的参数;而文件实际名字是my report.txt,于是命令就会报错或者说找不到文件。在脚本里处理路径时,凡是路径可能含空格、特殊字符的,我一定用双引号包住整个路径变量:
rm -f "$file"这一习惯可以规避掉大量难以追踪的 bug。很多人写脚本时图省事不写引号,等到路径里出现空格或通配符字符时才开始怀疑人生。
双引号内部依然会做变量展开和命令替换,但对*、?这类通配符不展开。所以:
echo "*.txt"会原样输出*.txt,而echo *.txt会把当前目录下所有.txt文件名列出来。
4.2 单引号:所见即所得
单引号是“全引用”,内部所有字符一律按字面处理,包括变量前缀符号、命令替换符号和反斜杠。比如:
echo '$HOME is /root'输出的是字符串$HOME is /root,而不是先解析$HOME再输出。这里和双引号的区别非常鲜明:
echo "$HOME" echo '$HOME'前者输出/root之类的实际路径,后者直接输出$HOME四个字符。
实际工作中我常用单引号保护包含大量特殊字符的字符串,比如正则需要表达式中的$、*、.等。举个例子,用 grep 查找行尾为.txt的内容:
grep '\.txt$' file如果用双引号,$会被变量解析逻辑盯上,行为就不好预测了。规则记成一句话就是:不希望 shell 做任何解析的时候,用单引号。
4.3 反引号与 $() 命令替换
反引号是最古老的命令替换写法:
today=`date +%F`意思是先执行反引号内的命令,用它的输出替换整段内容,再赋值给变量。反引号到现在还能用,但我不推荐,因为它在嵌套时非常难读:
dir=`dirname \`which python\``内层的反引号必须用反斜杠转义,写出来的代码像迷宫一样。$()写法可读性和嵌套能力都更好:
dir=$(dirname $(which python))更关键的一点是,$()内部自成一体,引号规则更接近常规逻辑;而反引号内部对反斜杠的处理存在历史遗留问题,在不同 shell 上行为还不完全一致。所以现在写脚本一律用$()。
4.4 反斜杠转义:在合适的地方做“取消特殊含义”的动作
反斜杠的作用是去掉紧随其后的特殊字符的特殊意义。它和引号的不同在于它是“点级”的,只影响紧跟其后的那一个字符。比如:
echo \*输出一个星号,而不是展开当前目录的文件列表。
需要小心的是反斜杠在双引号内部的规则。在双引号内部,反斜杠只对$、反引号、双引号、反斜杠本身和换行符有效,对其他字符就原样保留了:
echo "a\*b"在 Bash 中输出是a\*b,星号的反斜杠转义等于没有转义。这个容易记混,但遇到的时候多试一次输出就清楚了。
5. 变量引用与展开边界:$、${}、$()与${}的多种用法
5.1 变量前缀符号的识别边界
$是所有变量引用的起点。最基础的形式是$var,但 shell 对变量名的识别是“尽可能长地匹配”。如果变量名叫file,命令里写$filepath,shell 会认为你引用的是变量filepath而不是变量file后面拼接字符串path。
这就是为什么一定用花括号界定变量名的边界:
echo "${file}_path"花括号让变量名“完整隔离”,后面接任何字符都不会干扰解析。这个习惯在拼接路径或字符串时尤其重要。某次我写备份脚本时拼接带时间戳的文件名,忘了写花括号,结果深夜定时任务跑出来的文件名全是乱码,从那以后只要变量后面紧跟其他字符,花括号必加。
5.2 变量替换的高级姿势
花括号不只是用来界定边界,还能配合不同的修饰符做默认值替换、空值处理、子串操作等。常用且稳定的几个列举如下:
${var:-default}如果变量未定义或为空,则返回default字符串,但变量本身并不被修改。这个适合给命令参数提供默认值。
${var:=default}这个稍微激进一点:如果变量未定义或为空,不仅返回值,还会把default赋值给这个变量。
${#var}返回变量值的长度。
${var:offset:length}提取子串。
${var/old/new}把第一个匹配到的old替换成new;${var//old/new}则替换所有匹配项。在写批量文件改名或字符串清洗逻辑时非常有用。
这些能力在脚本里能省去大量调用awk、sed外部命令的环节。我用 shell 处理纯文本时向来优先考虑内部展开能力,因为外部命令的进程创建有开销,在循环里跑几百次就能明显感受到差别。
5.3 命令替换与算术展开各自的门派
$()已经在前面讲过了。另一个相关符号是$(()),它表示算术展开:
echo $((5 + 3))输出8。注意,shell 的算术能力比较基础,整数运算为主,浮点一概不支持。如果需要浮点计算,就得靠bc或awk来帮忙。
还有一个大家容易混淆的地方:$(command)和${variable},前者是命令替换,后者是变量引用,可千万别写串了。我见过有人把命令替换写成${date},结果 shell 会尝试去取名为date的变量,取不到就是空字符串,整个脚本静默出错。
6. 正则符号在命令行工具中的配合:用户常犯的“通配符思维”误区
6.1 通配符和正则不是一回事
很多从入门到进阶的 Linux 用户会在这一步卡住:为什么grep *.txt file不好使?因为他们在用通配符思维理解正则。
通配符是在 shell 层面对文件名展开的工具,而正则是在文件内容中对文本模式做匹配的语法。grep、sed、awk这些工具用的是正则表达式,它们的元字符体系完全不同。
在正则里,*表示“前一个字符重复零次或多次”,不是“匹配任意字符串”。所以:
grep 'a*' file匹配的不是包含“任意字符串 a”的行,而是任意位置存在零个或多个连续字母 a 的行。由于“零个 a”在任何行都存在,这个命令基本上会输出所有行。
对应关系是:正则里匹配任意字符串要用.*;通配符里的*相当于正则里的.*组合。有一个很经典的回答是“通配符的*等于正则里的.*”,维度一旦厘清,很多命令迷雾就散开了。
6.2 grep 中常被踩的元字符坑
在 grep 中使用正则,默认的是“基础正则”,?、+、{、|、(、)这些字符都需要转义才能表达其特殊含义。如果直接写:
grep 'colou?r' file在基础正则下,这匹配的是字面字符串colou?r,而不是color或colour。
想要让这些字符直接作为元字符使用,用grep -E开启扩展正则:
grep -E 'colou?r' file我在写排查脚本时通常会显式声明用-E,这样正则可读性好很多,不用满屏反斜杠。
6.3 字符类在不同工具中的差异
[a-z]在正则和通配符中都存在,但匹配范围在不同 locale(语言环境)下可能有差异。某些语言环境下,[a-z]的排序规则和纯 ASCII 并不完全一致,可能会包含A到Z之间的一些字符。为了避免这种环境依赖,我一般在脚本开头统一设置 locale:
export LC_ALL=C这条命令把排序规则切到最原始简单的 C locale,保证字符类的匹配行为一致。在服务器环境中处理中文、多字节文本、日志排查时,这个设置能避免一批令人困惑的“为什么匹配多了/少了”的问题。
7. 历史扩展与注释类符号:!、#、:、~、.的特殊语义
7.1 感叹号:历史命令扩展的意外动作
!在交互式 Bash 中默认是有特殊含义的,它用于历史命令扩展。!!表示上一条命令,!$表示上一条命令的最后一个参数:
grep ERROR app.log tail -f !$第二条命令会被展开成tail -f app.log。
但历史扩展在脚本里非常危险,尤其是当你把带!的字符串放在双引号中时。例如:
echo "hello world!"在交互式 Bash 中,历史扩展可能会尝试把!后面的内容当作历史事件来解析,结果可能报错或者出现莫名其妙的行为。避免方法有两个:一是用单引号,二是显式关闭历史扩展:
set +H我写脚本时从不依赖历史扩展这种交互特性,所以会在脚本开头把set +H写进去,防止!引发意外。不过要注意,在非交互式 shell 中,历史扩展默认是关闭的,所以脚本里一般不踩这个坑;只有你在终端里手动粘贴包含!的命令时才会触发。
7.2 井号:注释与 shebang 的双重作用
#在命令行里通常是注释符号,但从#开始到行尾的内容不会被解释执行。这一点在写脚本时用来记录命令意图、维护说明、临时禁用某行逻辑。
# 备份数据库 mysqldump -u root --all-databases > backup.sql#!比较特殊,它出现在脚本第一行时叫 shebang,用于指定解释器。比如:
#!/bin/bash这行并不是注释,而是告诉内核用哪个程序来解释这个脚本。需要注意的是 shebang 必须出现在文件第一行,且#!之后要紧跟解释器的绝对路径。我之前遇到过有人写#! /bin/bash,在#和!之间加了空格,这在某些环境下会导致脚本无法正确执行。这个空格在不同系统上行为不一致,有的能容忍,有的不能,所以统一不加空格最稳妥。
7.3 冒号:空命令的奇妙用途
:是一个特殊的内建命令,字面意思是“什么都不做”,但它的退出码永远是 0。这个特性在几种场景下很有用。
第一个是作为无限循环的占位符。如果某个循环需要手动退出(比如等待用户按键),可以写:
while :; do sleep 1 done第二个场景是变量默认值赋值的兜底:
: ${var:=default}如果变量为空,就把它设为default,但不会在终端打印任何内容。用echo赋值也能达到目的,但会有输出干扰。
第三个场景是清理脚本里带一个什么都不做的分支,避免语法空块。在某些 shell 里,if分支如果完全没有命令会有语法错误,加上一个:就解决了。
7.4 波浪号:家目录展开的隐藏前提
~在 shell 中表示当前用户的家目录,也就是环境变量$HOME所指向的位置:
cd ~/project看起来简单,但有一个陷阱:~的展开是登录时基于$HOME或用户数据库信息确定的,如果当前进程的环境有问题,~可能无法正确展开。我在前面提到的那次事故就是这样——某个自动部署脚本通过 SSH 远程执行时,用户环境没加载完整,$HOME没设置,导致cd ~/app直接失败。后来我把这种依赖改成显式路径:
cd /home/某用户/app或者先判断$HOME是否为空再决定如何拼接路径,这样脚本的健壮性大幅提升。
另外,~后面可以直接跟用户名展开成对应用户的家目录,比如~postgres会展开为 postgres 用户的家目录。但注意只有在~后面紧跟的是用户名的起始位置,这个展开才有效。~后面跟路径分隔符也可以,比如~/logs。但如果~出现了变量或字符串拼接的场景,请务必用${HOME}替换。
7.5 点号的隐藏与当前目录语义
.在 Linux 中有两重身份。第一重是隐藏文件前缀——以.开头的文件默认不被ls显示,也不被通配符匹配。第二重是当前目录的路径表示,./run.sh表示当前目录下的run.sh。
隐藏文件这个设计不是安全机制,只是“默认不展示”的约定。很多应用把配置文件以隐藏文件形式放在家目录里,比如.bashrc、.profile,这算是历史习惯的延续。
.在 ls 输出中还有另一个含义:单个.代表当前目录,..代表上一级目录。删除时如果有人写了rm -rf .*,那会尝试删除当前目录和上一级目录,这属于最危险的一类误操作。警告归警告,更稳的办法是:删除任何匹配隐藏文件的通配符之前,先把展开结果打出来看一眼,确认没有.和..再执行。
8. 花括号展开与算术符号:{}、$(())、|的高阶组合技巧
8.1 花括号展开的批量生成能力
花括号展开是 shell 内置的一项文本生成能力,能按枚举或区间生成一系列字符串。最简单的用法:
echo {a,b,c}输出a b c。它和通配符不同,即便文件不存在,花括号展开也会生成完整的字符串列表,这个特性在批量创建目录时非常实用:
mkdir -p project/{src,docs,tests} mkdir -p logs/{2024,2023}/{01..12}第二行会一次性生成logs/2024和logs/2023下各 12 个月的目录层级。如果用通配符或者for循环也能实现,但这种写法就是天然地平、直接、少犯错。
区间展开还能配合步长,在某些新版本 Bash 中支持{1..10..2}生成1 3 5 7 9。更老的环境不支持步长,所以跨环境操作时要留意 Bash 版本。
花括号展开的时机也值得注意:它在通配符展开之前发生。所以:
echo {01..10}.{txt,log}会先生成 20 个字符串,再对这些字符串做路径名展开。如果有文件存在,匹配到的文件名会替换进去;没有匹配的原样保留。
8.2 圆括号与子进程的边界
(command)会开启一个子 shell 来执行命令。变量赋值和cd等操作在这个子 shell 内部不会影响到外部环境:
(cd /tmp && pwd) pwd第一条pwd输出/tmp,第二条输出你原来的目录。这个特性可以让脚本在一个隔离环境中做临时性操作,不用手动“进入再退出”。
子 shell 和后台进程是两个层面的概念:(command) &是把子 shell 作为后台进程执行,两者可以叠加。我自己常用的是:
(cd /tmp/app && tar czf /backup/app.tgz .)这里把cd和打包放在同一个子 shell 里,不会污染当前工作目录。
8.3 算术、逻辑与管道的组合思路
$(())可以做整数运算,但和管道结合时需要注意,管道两端连接的是标准输入输出,而算术展开是纯内存操作,二者不在一个通道上:
echo $(( $(wc -l < file.txt) + 1 ))这个命令先算出文件的行数,再加 1,最终输出结果。这种嵌套在生成序号、分页逻辑中很有用。
如果计算稍微复杂一点,比如需要浮点、字符串格式化,那别硬扛 shell 的算术,直接用awk:
awk 'END {printf "%.2f\n", NR/100}' file.txt我选择工具的习惯是:整数运算和拼接逻辑用 shell 内建能力完成;一旦涉及浮点、统计、格式化,就交给awk这类专门工具。各司其职,脚本的稳定性和可读性都会上一个台阶。
9. 文件描述符与特殊设备:/dev/null、守护进程等场景中容易被忽略的细节
9.1 文件描述符的 0、1、2
在 Linux 中,每个进程启动时默认带上 3 个文件描述符:
- 0:标准输入
- 1:标准输出
- 2:标准错误
2>&1的本质是把标准错误重定向到标准输出当前指向的地方。理解这个“当前指向”很重要,因为重定向是按从左到右的顺序执行的。
如果我想把标准输出和标准错误分别送到不同的地方,可以写:
command > output.log 2> error.log这在日志排查中很有用:正常内容和错误信息分开存,排查时直接看错误文件。
还有一种中间状态:只丢弃错误,保留正常输出:
command 2>/dev/null这在执行一些预期可能有告警但无关紧要的命令时很好用。
9.2 /dev/null 与 /dev/zero 的差异
/dev/null是数据黑洞,写入即丢弃,读取立即返回 EOF。/dev/zero则是一个无限读取零字节的设备,通常用于创建指定大小的文件或清零磁盘区域:
dd if=/dev/zero of=/tmp/test.img bs=1M count=100这会创建一个 100 MB 的零填充文件,常用于测试磁盘速度或生成临时文件。注意dd本身是个低层级的数据拷贝工具,操作时如果参数写错,有覆盖磁盘的风险,不要在生产环境随意拿它试手。
9.3 文件描述符的复制与移动
除了2>&1这种复制方式,Bash 还支持用&-关闭文件描述符:
command 2>&-意思是不给命令提供标准错误。这个用的场景不多,但某些交互程序为了保持输出纯净会这么做。
还有一些更高级的用法,比如显式打开文件并绑定到指定 fd:
exec 3< input.txt之后可以从文件描述符 3 读取数据。这类技巧在处理多个文件、实现简易协作逻辑时有效,但它对 shell 脚本的正确性要求很高,少写一行exec 3>&-就可能泄漏文件描述符。日常脚本中我用得不多,除非有明确理由,否则更倾向直接用普通命令组合。
9.4 后台守护进程的符号组合
把&、重定向和nohup组合起来时,很多人会犯“符号顺序”的错。规范写法是:
nohup command > log 2>&1 &其中nohup让进程忽略挂断信号,>把输出写入文件,2>&1把错误也写入同一文件,最后的&把整个任务放到后台。如果你习惯用systemd管理服务,那这套命令更多是用来做临时启动、现场调试。
个人建议是:凡是需要长期运行的服务,不要依赖nohup和&这套临时方案,尽早迁入系统服务管理器中统一调度。因为后者有自动重启、日志轮换、依赖就绪检查能力,而这些是 bare shell 后台进程完全没有的。用符号快速启动可以做实验、临时跑任务,但作为生产兜底还是太脆。
10. 从命令到脚本:特殊符号在实战文件中的整体配合与调试方法
10.1 脚本调试的三种利器
写了几个月的命令行后,一定会遇到脚本行为“和想象不一样”的时刻。这时候我习惯依次做三件事。
第一,在脚本开头加来调试参数。bash -x script.sh会把每条命令展开后的实际内容打印到终端,注意是展开后的内容,所以变量值、通配符展开结果都会暴露出来,排查非常直观。如果不想全局开调试,可以只在怀疑的代码块前后加set -x和set +x。
第二,利用bash -n做语法检查,只解析脚本、不执行。它能发现括号不匹配、引号未闭合等语法问题,能在运行前抓出部分低级错误。
第三,在可能出错的关键点加打印。我通常在脚本里定义一个log函数,统一输出到带时间戳的日志文件,方便回溯每一步的执行状态。
10.2 常见六类特殊符号错误的排查顺序
排查特殊符号引发的脚本问题时,我大致按这个顺序追查:
- 是不是引号没配对?单双引号混用?
- 是不是变量引用缺少花括号导致边界错误?
- 是不是通配符被展开成了参数导致命令接收了意外参数?
- 是不是重定向顺序反了?
- 是不是命令替换
$()中带了未转义的特殊字符? - 是不是当前环境的 locale、
$HOME等变量状态不符合预期?
有一次我就是按照这个思路定位了问题的:脚本里有一句:
if [ "$status" = "ok" ]; then某次运行一直不进入 if 分支,用bash -x一跑,原来$status变量因为前面某步的命令替换失败,变成了空字符串,空字符串和ok当然不相等,自然就跳过分支了。
10.3 写脚本时的符号规范约定
经过多次踩坑,我给自己定了一套写作规范,执行起来成本不高,收益却很实在。
第一,变量引用一律加双引号,除非明确需要分词展开。这一点能屏蔽大量“路径含空格”和“变量为空”带来的问题。
第二,变量与字符串拼接时,花括号必写。比如"${prefix}_${date}.log",不会有歧义。
第三,单引号和双引号的使用界限我尽量隐藏到“这个位置是否需要变量展开”来决定:需要展开用双引号,绝不展开用单引号。
第四,重定向规则统一用完整写法> file 2>&1,不贪图&>的简洁,保证可移植性。
第五,任何涉及递归删除、批量删除的命令,先打印展开结果再执行,至少在自己写脚本时强制自己走这一步。
10.4 一个综合性示例
假设我们要写一个脚本,把某个目录下的所有.log文件按日期归档,删除超过 7 天的归档,并输出执行日志。把前面几章的内容串起来,大致会长这样:
#!/bin/bash set -u LOG_DIR="/var/log/app" ARCHIVE_DIR="/backup/logs" DATE=$(date +%Y%m%d) KEEP_DAYS=7 mkdir -p "${ARCHIVE_DIR}/${DATE}" for file in "${LOG_DIR}"/*.log; do [ -e "$file" ] || continue base=$(basename "$file") gzip -c "$file" > "${ARCHIVE_DIR}/${DATE}/${base}.gz" : > "$file" done find "${ARCHIVE_DIR}" -name "*.gz" -mtime +${KEEP_DAYS} -delete echo "$(date '+%F %T') archive done" >> "${ARCHIVE_DIR}/archive.log"这里set -u让脚本遇到未定义变量就报错退出,防止静默出错。[ -e "$file" ] || continue利用了||的逻辑:文件不存在就跳到下一个。: > "$file"则是用“空命令+重定向”把日志文件清空而不是删除,保留文件句柄,让还在写该文件的进程不受影响。
这样的脚本放在定时任务里,既能完成归档清理,也避免了日志文件被进程持续写导致空间不释放的问题。这里的每一个符号、每一行命令,都不是随便写出来的,背后都有对应的语义和边界条件。
好了,这基本就是我这些年和 Linux 特殊符号打交道沉淀下来的完整笔记。写到这里我真心建议你把里面这些示例自己在终端里敲一遍,尤其是引号和花括号那几段,不实际跑一次很难建立起“符号会变成什么”的直觉。等哪天真在服务器上遇到一条命令不按你想的运行了,再回过来翻一翻这篇,多半能少熬几个小时的夜。