写Shell脚本的人早晚都会碰到这么一串看起来像乱码的东西$0、$#、$?、$$、$*、$。我第一次在脚本里看到它们的时候真以为是不是键盘出了问题怎么全是符号。后来搞明白了这些是Linux Shell内置的特殊变量是脚本与外部环境之间传递信息的约定接口——不用声明、不用初始化、直接用。这篇文章就把10个最核心的特殊变量讲透包括含义、底层逻辑、实操用法再附上我这些年踩过的坑和排查技巧。适合刚入门Shell的新手也适合写了几年脚本、但对某些细节一直模模糊糊的老手查漏补缺。先还原一个真实场景。你写了一个备份脚本跑了十分钟发现备份目录是空的抓不到任何线索。如果脚本里提前写了echo failed at $0, exit code $?你就能立刻知道是哪个脚本、哪一步失败。这就是特殊变量的核心价值它们是人向系统发问的窗口也是系统向人反馈的通道。把这些变量用熟你的脚本就不再是能跑就行的玩具而是可以扛事的工具。1. 先弄清楚特殊变量到底是什么1.1 特殊变量存在的意义Shell脚本本质上是一串命令的有序执行但脚本不可能闭门造车。它需要知道我叫什么、用户给我传了什么参数、上一条命令到底成功没有、当前运行环境是啥。普通变量可以通过x1这种方式自己造但上面这些信息掌握在Shell解释器手里我们需要一套现成的只读接口去读取它们这就是特殊变量。特殊变量也叫预留变量或自动变量。它们由Shell在启动和运行过程中自动维护具有固定的名字数字开头的、符号开头的都有。你不需要也不应该给它们赋值只需要去读。理解它们等于理解了Shell这个中间人到底帮你记住了哪些现场信息。和普通变量最大的区别在于普通变量是你的草稿纸特殊变量是系统的工作日志。工作日志上记录了什么你就只能读到什么而且每条记录的更新时机都是固定的。比如$?只反映上一条前台命令的退出状态你一旦执行了别的命令它就被覆盖了。这些更新时机恰恰是很多坑的根源。后面第4节里你会看到几乎所有猜不透的脚本Bug最后都能追溯到我在错误的时间读了特殊变量。1.2 10个核心变量快速总览在逐个细讲之前先给一张总览表把这些变量的名字、含义、典型用途先挂个钩后面看细节的时候就不会迷路。变量含义典型用途$0当前脚本或Shell的名字打印用法、写日志、定位脚本路径$1~$9第1到第9个位置参数接收命令行传参$#位置参数的个数检查参数个数、循环控制$?上一条命令的退出码判断命令成败、错误处理$*所有位置参数作为单个字符串把参数整体拼成一行日志$所有位置参数作为多个独立单词逐个处理参数、安全转发参数$$当前Shell的进程ID生成唯一临时文件、命名日志$!最近一个后台进程的PID配合wait等待后台任务$-当前Shell的开启选项标志检查调试模式、Shell状态$_上一条命令的最后一个参数快速复用路径、目录切换这10个变量说多不多说少不少。关键不是背定义而是理解它们的更新时机和使用场景。下面每一个我都配上实测例子你完全可以复制到自己的终端里跑一遍感受会比光看文章深得多。2. 十个变量逐个拆解每一个都配实测2.1 $0脚本名报警日志里最该有它$0保存的是当前脚本被调用时的名字。有几个细节要注意如果你用bash script.sh执行$0就是script.sh如果你用./script.sh执行$0就是./script.sh如果你用绝对路径/opt/scripts/script.sh执行$0就是完整路径。这带来一个最常见的问题脚本里要显示我的脚本叫什么直接用$0会把路径也带出来。通常我们配合basename使用#!/bin/bash SELF$(basename $0) echo 当前脚本: $SELF这一步看起来简单用处却极大。我在脚本里写usage帮助函数时几乎固定用这种方式usage() { echo 用法: $(basename $0) [选项] 目标目录 echo -f 指定配置文件 echo -h 显示帮助 exit 0 }这样哪怕脚本被改了名、被挪了位置帮助信息都会自动跟着变不会出现脚本叫backup.sh帮助里却写着deploy.sh这种尴尬维护成本直接降下来。$0还有个容易被忽略的应用场景日志定位。当你用cron定时跑脚本或者脚本被多层调用时报错信息里如果没有$0日志文件就是一堆丢了主语的句子。我在所有关键日志里都会带上$0和$LINENOlog() { echo [$(date %F %T)] $0:$LINENO $* /var/log/myscript.log }这样每一条日志都能追溯到具体的脚本和行号排查问题的时间能省一半。你可能注意到了这个函数里也用到了$*后面我会详细讲它怎么用才不会踩坑。2.2 $1~$9 和 $#位置参数是脚本的命令行接口位置参数就是执行脚本时跟在脚本名后面的那些值。$1是第一个$2是第二个以此类推到$9。注意第十个及以后不能写$10必须写成${10}否则Shell会把$1后面那个0当成普通字符拼接。这一点非常容易踩尤其是参数一多就容易翻车。#!/bin/bash echo 第1个参数: $1 echo 第2个参数: $2 echo 第3个参数: ${3} echo 第10个参数: ${10}$#是位置参数的个数。它最常见的用途一是校验参数是否齐全二是配合循环遍历参数。比如一个脚本强制要求至少两个参数#!/bin/bash if [ $# -lt 2 ]; then echo 错误: 至少需要2个参数 2 exit 1 fi还有一类高频用法是while循环加shift逐个拨参。这里提醒一句位置参数处理完一个就少一个$#也会跟着减少所以循环条件里判断$# -gt 0是安全的。这个模式在3.1节会给出完整例子。你现在只需要记住$#是动态的不是恒定的。还有一个细节$0严格来说不算位置参数$#只统计$1开始的那些。所以一个脚本哪怕你运行时不带任何参数$#也是0而$0永远有值。区分清楚这一点写校验逻辑时才不会把脚本名误当成参数。2.3 $?上一条命令的退出码判断成败的关键Linux里每个命令执行完都会返回一个退出码exit code。0表示成功非0表示失败。$?保存的就是上一条前台命令的退出码。这个变量的更新时机极其冷酷只要你又执行了任何一条命令它就被新命令的退出码覆盖了。所以想保留某个命令的结果第一件事是立刻把它存到普通变量里#!/bin/bash tar czf /backup/data.tar.gz /home/user rc$? if [ $rc -ne 0 ]; then echo 备份失败退出码: $rc 2 exit $rc fi这里如果把tar的退出码直接拿到if里判断中间只要夹着echo之类的命令$?就已经不是tar的了。很多人写脚本时在这个地方翻过车明明tar失败了脚本却判断成功最后把空文件当成备份存了下来。这种Bug的隐蔽性极强因为它不报错只是结果错。退出码还有一个特殊的区间值得了解128n。当一个命令被信号终止时Shell返回的退出码是128加上信号编号。比如进程被kill -9杀掉退出码是1371289被kill -15终止退出码是14312815。看到这类退出码基本可以直接判断进程是被信号干掉的而不是自己报错退出的排查方向完全不同。在使用set -e的脚本里$?的行为也会影响你的调试方式。一旦开启set -e任何命令失败脚本都会立即退出此时如果你想捕获某个可能失败的命令正确写法是if command_may_fail; then echo 成功了 else echo 失败了 fi而不是command_may_fail; if [ $? -ne 0 ]; then ...。因为前者让失败不会触发set -e退出后者在set -e下连判断的机会都没有。这个区别很微妙却是生产脚本能不能稳住的关键。建议你开个终端分别试一下记忆会非常深刻。2.4 $* 与 $收集参数但引号用法天差地别这是两个长得几乎一样的变量它们都表示所有位置参数。真正骗人的细节在于加不加引号。先用一个脚本实测#!/bin/bash echo 不加引号的\$* for arg in $*; do echo 参数: $arg done echo 不加引号的\$ for arg in $; do echo 参数: $arg done echo 加引号的\\$*\ for arg in $*; do echo 参数: $arg done echo 加引号的\\$\ for arg in $; do echo 参数: $arg done比如你执行./test.sh a b c d结果会非常有戏剧性不加引号的$*和$行为基本一致都会把b c拆成b和c两个加引号的$*会把所有参数拼成一个字符串a b c d循环只执行一次而$会完整保留每个参数的边界得到a、b c、d三个值。这个差异直接决定了脚本对含空格参数的处理是否安全。结论很明确绝大多数场景下应该使用$。它最忠实地保留了调用者传参时的原意。当你的脚本需要把自己的参数原封不动地转发给另一个脚本或命令时$是唯一不会丢信息的选择#!/bin/bash # 包装脚本把参数全部转给实际执行脚本 exec /opt/scripts/real_work.sh $如果不加引号或者用了$*遇到带空格的路径参数转发过去的参数就碎了目标脚本收到的和你收到的完全不是一回事。$*也不是没有用。当你想把所有参数拼接成一个字符串比如拼成一行日志、拼成一条消息时它很方便#!/bin/bash echo [$(date)] 收到参数: $* run.log再补充一个冷门但好用的点$*拼接时用的分隔符是IFS的第一个字符。默认IFS是空格、制表符、换行所以默认拼接用空格。如果你把IFS改成逗号$*就会用逗号连接参数。这个技巧在做CSV输出时很巧妙但要注意改完IFS记得还原否则后面脚本的解析全乱套。IFS这个环境变量本身就是个坑轻易别动。2.5 $$ 与 $!进程ID临时文件和后台任务都靠它们$$是当前Shell的进程ID。每次执行脚本Shell都会fork出一个新进程这个PID是唯一的。利用这个唯一性我们可以生成不会冲突的临时文件名#!/bin/bash TMPFILE/tmp/build_$$.tmp trap rm -f $TMPFILE EXIT echo 临时文件: $TMPFILE如果两个用户同时跑同一个部署脚本用固定名字做临时文件必然打架。带上$$之后每个进程的文件都不一样这是最朴素也最可靠的唯一性方案。当然追求更强的唯一性可以再拼上时间戳/tmp/build_$$_$(date %s).tmp。多机环境或者极端并发场景下时间和PID组合基本不会冲突。$$还有一个容易被误用的点它不等于你在ps aux | grep script.sh时看到的那个主进程PID吗其实一个脚本就是一个进程$$就是它自己。但如果脚本里用bash sub.sh调子脚本那子脚本又有自己的$$。追踪整条进程链靠的是PPID父进程ID而$$只认当前Shell自己。所以在多层脚本嵌套时别指望各层的$$一样它们各是各的。$!是最近一个放入后台的进程PID。这里必须强调最近一个和后台两个条件。只有你执行了以结尾的命令$!才有意义。典型用法是配合wait#!/bin/bash rsync -av /data/ /backup/data/ backup_pid$! echo 备份任务已启动PID: $backup_pid # 做别的事情 echo 继续执行其他任务... # 等待后台任务完成 wait $backup_pid rc$? if [ $rc -eq 0 ]; then echo 备份完成 else echo 备份失败退出码: $rc 2 exit $rc fi$!保存的是后台进程的PID不是任务号job number。用jobs -l看到的PID应该和$!对得上。如果脚本里连续起了多个后台任务$!只会记得最后一个前面的PID需要你自己在每次启动后立刻存下来否则丢得干干净净。这个启动后立刻保存的习惯比什么都重要。2.6 $- 与 $_两个容易被忽略的状态位$-保存的是当前Shell的启动选项标志。比如执行了set -x之后$-里会多出x执行了set -e会多出e。用echo $-看一下常见的会有himBHs之类的字母组合。这个变量最实用的场景是程序化地检查Shell状态#!/bin/bash case $- in *x*) echo 当前开启了xtrace调试模式 ;; *) echo 没有开启xtrace ;; esac比如你的脚本可能被外层脚本用set -x调用而你希望在脚本里根据是否处于调试模式决定要不要输出更多日志。直接判断$-比靠环境变量传递更可靠因为它是Shell实时维护的状态不会被意外赋值覆盖。$_则是上一条命令的最后一个参数。这是个非常取巧的变量最经典的用法就是刚建完目录就进去mkdir /tmp/new_project cd $_cd $_会直接切到/tmp/new_project省去了重复输入的麻烦。在交互式终端里这个技巧很爽在脚本里它也能帮我们拿到上一条命令的关键参数。比如你执行了cp /data/file /backup/file.bak$_就是/backup/file.bak后续想对这个目标文件做操作时可以直接复用。但要注意$_在不同Shell里的更新细节不完全一致在脚本里依赖它时最好先实测一遍别想当然。我用过几种Shell$_的更新时机有些微妙差异。3. 实战组合三个高频场景直接抄3.1 参数解析写一个带-h/-v的部署脚本单个变量学完了接下来把它们串起来。写得最多的场景就是参数解析。这里给一个可以直接抄的部署脚本框架#!/bin/bash SELF$(basename $0) CONFIG TARGET VERBOSE0 usage() { echo 用法: $SELF -c 配置 -t 目标 [-v] echo -c 指定配置文件路径 echo -t 指定部署目标目录 echo -v 输出详细日志 echo -h 显示帮助 exit 0 } if [ $# -eq 0 ]; then usage fi while [ $# -gt 0 ]; do case $1 in -c|--config) CONFIG$2 shift 2 ;; -t|--target) TARGET$2 shift 2 ;; -v|--verbose) VERBOSE1 shift ;; -h|--help) usage ;; *) echo 未知参数: $1 2 exit 1 ;; esac done if [ -z $CONFIG ] || [ -z $TARGET ]; then echo 错误: 必须指定 -c 和 -t 参数 2 exit 1 fi echo 配置: $CONFIG echo 目标: $TARGET这个框架里用到了$#来控制循环、用$1来逐个判断、用shift来消费参数最后用exit配合控制错误路径。这里有一个细节shift 2表示一次消费两个参数因为-c后面还跟着一个值而-v这种开关参数只需要shift 1。如果参数结构设计得复杂稍微不注意shift的对应关系参数就会整体错位调试起来很痛苦。我在实战中还发现一个问题CONFIG$2这一步如果用户写的是-c但后面没有值$2就会取到下一个参数甚至取不到而报错。更稳的写法是先检查$#是否足够或者干脆用${2:?缺少参数}这种写法让Shell直接报错。具体用哪种取决于你对脚本友好度的要求。3.2 错误处理模板让失败变得可见我看到太多脚本失败的时候什么都不说或者只说一句出错了然后退出。真正的生产脚本应该做到失败时告诉你哪个命令、什么退出码、在哪个脚本的哪一行。我习惯封装一个错误处理函数#!/bin/bash SELF$(basename $0) fail() { local lineno$1 local msg$2 local rc$3 echo [$SELF] 行 $lineno: $msg退出码 $rc 2 exit $rc } # 用法示例 grep -q pattern /etc/config rc$? if [ $rc -ne 0 ]; then fail $LINENO 配置文件里没有找到pattern $rc fi这个模板把$0和$LINENO和$?三个信息汇总到一条错误信息里。实际跑起来的效果是[deploy.sh] 行 18: 配置文件里没有找到pattern退出码 1。凭这一条log就能定位问题不用满屏找。你可能注意到我把$?立刻存进了rc这就是2.3节说的保鲜期问题的实际应用。另一个常见的错误处理场景是检查某个命令是否存在。直接这样写if ! command -v rsync /dev/null; then echo 错误: 未找到rsync命令 2 exit 127 fi退出码127是命令未找到的惯例值和Shell报command not found时一致。把失败的语义通过退出码准确表达出来上层调用你的脚本时才能做出正确的分支判断。比如CI流程里脚本返回127和返回1处理方式可能完全不同。3.3 临时目录与后台任务的进程管理结合$$和$!我写过一个比较完整的并发任务处理片段这里简化一下结构#!/bin/bash WORKDIR/tmp/worker_$$ mkdir -p $WORKDIR # 启动两个后台任务分别记录PID ping -c 3 127.0.0.1 $WORKDIR/ping.log pid1$! sleep 2 $WORKDIR/sleep.log pid2$! echo 任务1 PID: $pid1 echo 任务2 PID: $pid2 # 分别等待并检查结果 wait $pid1 rc1$? wait $pid2 rc2$? if [ $rc1 -eq 0 ] [ $rc2 -eq 0 ]; then echo 全部任务完成 else echo 有任务失败: $rc1 $rc2 2 exit 1 fi rm -rf $WORKDIR这里有几个值得学的点。第一每个后台任务启动后立刻把$!存到自己的变量里因为下一个后台任务一启动$!就变成新PID了。第二wait可以带PID参数也可以不带不带就是等所有后台任务但你就无法分别拿到每个任务的退出码。第三每个任务单独等待、单独记退出码才能精确判断是哪个任务挂了。这些都是把$$和$!组合起来才能实现的。临时目录的清理也要养成习惯。上面的例子在结束时手动rm -rf $WORKDIR但万一脚本中途出错退出这个目录就会残留。更稳的做法是配合trap在退出时自动清理trap rm -rf $WORKDIR EXIT$$生成的目录本身已经避免了并发冲突再把清理动作交给trap就几乎不会留下垃圾文件了。这也是我在生产脚本里的标准写法。4. 高频坑位与排查技巧实录4.1 引号陷阱$*和$的经典翻车现场我见过最典型的翻车是脚本接收了一个带空格的路径参数比如/opt/my project/data想要原样转发给tar结果用了$*不加引号于是这个路径被拆成了/opt/my和project/data两个参数tar直接报错。正确的做法是$它可以保证带空格的参数作为一个整体传下去。这类问题在for循环里同样会出现。for file in $*会把每个空白分隔的片段当成一个元素而for file in $会尊重参数边界。如果你的参数里可能出现空格放弃$*、拥抱$是唯一正确的路。我发现很多新手在这个问题上反复踩坑本质原因是没有理解引号的作用是保留参数边界。管住引号就管住了参数解析的命门。4.2 退出码的保鲜期问题$?只对上一条已执行的前台命令有效而且保质期极短。变量赋值、echo、if条件里的[判断都会刷新它。我见过一个脚本判断命令失败与否是在一行命令后隔了好几条echo才去读$?结果读到的是echo自己的退出码通常是0错误被完全吞掉。正确的姿势就是立刻保存或者用if直接判断命令本身。想让脚本可靠这个习惯必须刻进肌肉记忆。如果你想拿管道命令的退出码还要小心管道返回的是最后一个命令的退出码。cmd1 | cmd2的$?是cmd2的结果。如果你关心第一个命令是否失败需要开启set -o pipefail这样管道中任何命令失败整个管道的退出码就是非0。在处理grep xxx | head这类组合时这个设置能救你一命没有pipefail时哪怕grep什么都没匹配到只要head成功整个管道的退出码就是0你的条件判断会全面失明。4.3 source、exec和basename给$0带来的迷惑行为同样是执行脚本方式不同$0就不同。最常见的是source。当你用source script.sh或. script.sh执行时脚本是在当前Shell里直接跑的$0不是脚本名而是当前Shell的名字比如bash或-bash。如果你在脚本里用$(basename $0)做日志前缀source出来的脚本日志前缀就会变成bash毫无辨识度。同样exec script.sh会用新脚本替换当前进程$0会变成被exec的脚本名。所以如果你的脚本有可能被source千万别依赖$0来定位自己更稳妥的方式是显式传入脚本名或者用${BASH_SOURCE[0]}这种专门记录脚本来源的数组变量。我第一次遇到这个问题时脚本被. ~/tools/env.sh加载日志里全是bash开头的行排查了很久才反应过来。关于$0的这些分身多测几次就记住了。4.4 常见问题速查表症状原因解决方案参数带空格却被拆开用了不加引号的$*或$改用$命令失败但脚本继续往下走$?被中间命令覆盖执行后立刻存rc$?$0显示的是bash而不是脚本名脚本是被source执行的用${BASH_SOURCE[0]}第10个参数取不到写了$10而不是${10}使用${10}管道命令失败没察觉管道退出码只看最后一个命令set -o pipefail临时文件被其他用户覆盖文件名固定、无唯一标识加$$和时间戳后台任务无法精确等待忘记单独保存每个$!启动后立刻存进pid变量按理该失败却一直返回0echo等命令刷新了$?把退出码存下来再输出两个脚本并发写同一个日志没有区分进程日志名里加$$这张表是我日常帮人review脚本时最容易看到的几类问题每一个都是真实踩过的。把这张表存下来下次脚本行为诡异的时候可以对着排查。你会发现大多数问题不是逻辑多难而是特殊变量的更新时机没吃透。5. 个人习惯怎么把这些变量用出章法最后分享一点我自己的实操习惯。写任何脚本的第一行我基本固定写#!/bin/bash和set -o pipefail然后在必读参数校验处加上$#检查。特殊变量的使用要克制、有规律不要一个脚本里一会儿$*一会儿$前后混用最容易出bug。我自己的准则是转发参数一律用$拼日志消息才用$*检查成败永远先存退出码。这几个约定一旦定下来写脚本的速度和正确率都会明显提升。还有一个习惯是写脚本时集中打印一次运行环境摘要。在脚本开头用一组echo把所有关键特殊变量打出来echo 脚本名: $0 参数个数: $# PID: $$ echo 参数列表: $*平时看着没什么用但一旦出问题这段摘要就是第一手的定位线索。尤其是别人跑你的脚本报错时把这段输出发给你你马上就能判断是不是参数传错、是不是进程被信号杀了。这套东西用久了你会发现特殊变量不是冷知识而是Shell脚本最核心的骨架。理解了它们看别人的脚本、写自己的脚本都会顺手太多。