☰
Linux Shell特殊变量完全指南:$0、$?、$@等10个核心变量详解
2026/9/28 7:23:00 网站建设 项目流程

写Shell脚本的人,早晚都会碰到这么一串看起来像"乱码"的东西:$0、$#、$?、$$、$*、$@。我第一次在脚本里看到它们的时候,真以为是不是键盘出了问题,怎么全是符号。后来搞明白了,这些是Linux Shell内置的特殊变量,是脚本与外部环境之间传递信息的约定接口——不用声明、不用初始化、直接用。这篇文章就把10个最核心的特殊变量讲透,包括含义、底层逻辑、实操用法,再附上我这些年踩过的坑和排查技巧。适合刚入门Shell的新手,也适合写了几年脚本、但对某些细节一直模模糊糊的老手查漏补缺。

先还原一个真实场景。你写了一个备份脚本,跑了十分钟,发现备份目录是空的,抓不到任何线索。如果脚本里提前写了echo "failed at $0, exit code $?",你就能立刻知道是哪个脚本、哪一步失败。这就是特殊变量的核心价值:它们是人向系统发问的窗口,也是系统向人反馈的通道。把这些变量用熟,你的脚本就不再是"能跑就行"的玩具,而是可以扛事的工具。

1. 先弄清楚:特殊变量到底是什么

1.1 特殊变量存在的意义

Shell脚本本质上是一串命令的有序执行,但脚本不可能闭门造车。它需要知道"我叫什么"、"用户给我传了什么参数"、"上一条命令到底成功没有"、"当前运行环境是啥"。普通变量可以通过x=1这种方式自己造,但上面这些信息掌握在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和$LINENO:

log() { 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的隐蔽性极强,因为它不报错,只是结果错。

退出码还有一个特殊的区间值得了解:128+n。当一个命令被信号终止时,Shell返回的退出码是128加上信号编号。比如进程被kill -9杀掉,退出码是137(128+9);被kill -15终止,退出码是143(128+15)。看到这类退出码,基本可以直接判断进程是被信号干掉的,而不是自己报错退出的,排查方向完全不同。

在使用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="" VERBOSE=0 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) VERBOSE=1 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脚本最核心的骨架。理解了它们,看别人的脚本、写自己的脚本,都会顺手太多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询