
如果你和我一样日常工作离不开Linux Shell那一定和这10个特殊变量打过交道$0、$?、$*、$、$#、$$、$!、$-、$_、$1~$9。说句实在话这些符号单独看每个都很简单但真正写脚本时很容易在引号、退出码、子shell、位置参数上踩坑。尤其是$*和$的那点区别我见过不少三五年经验的人都会迷糊。这篇文章不打算写成一本文档手册而是以我日常写脚本的真实视角把这10个核心参数掰开揉碎讲清楚。每个变量都会给语义、给示例、给坑点最后还有一个可以直接复制改用的综合脚本模板。适合刚接触Shell的新手也适合写过不少脚本但总想查资料的老手。你读完能收获的不是一句“原来如此”而是以后写脚本时针对特殊变量能形成肌肉记忆。1. 先把这10个特殊变量认全用途与分类1.1 特殊变量解决什么问题Shell脚本本质是给解释器一串命令但脚本运行过程中它需要知道自己叫什么、被传入了什么参数、上一条命令成没成功、自身进程号是多少。这些信息从哪来就是我们常说的“特殊变量”。它们由Shell在运行前帮你填好不需要你手动赋值也不能随便覆盖覆盖了也能但会破坏语义劝你别干。可以把它类比成你进入会议室时主持人塞给你的一张纸条纸条上写着你的名字、今天要讲的议题数量、上一轮投票结果还有你自己作为第几号代表参会。这张纸条就是Shell通过环境注入到脚本里的“身份信息”。没有这些信息脚本就是个瞎子既不知道自己在哪也不知道该跟谁配合。这10个变量按功能可以分成四类位置参数$0、$1~$9、$#、状态与退出码$?、参数整体处理$*、$、进程与调试相关$$、$!、$-、$_。理清这个分类后面再看任何脚本都会轻松很多。1.2 10个变量速查表先放一张速查表每条信息都值得记住。后面每个变量都会详细展开我建议你先把这张表截图存起来写脚本时对照着用变量含义典型用途$0当前脚本的名称或路径打印帮助信息、获取脚本所在目录$1~$9第1到第9个位置参数接收命令行传入参数${10}及以上第10个及之后的位置参数接收更多参数$#位置参数个数参数合法性校验$?上一条命令的退出状态码判断命令是否成功$*所有位置参数整体视为一个字符串一次性传递所有参数$所有位置参数各自独立循环遍历、保留原始分隔$$当前Shell进程PID生成临时文件名、记录日志标识$!最近一个后台进程PID配合wait管理并行任务$-当前Shell的选项标志判断是否开启x、e等调试选项$_上一条命令的最后一个参数快速复用上一个参数、临时路径传递严格来说位置参数不止$1到$9超过9个需要用${10}这种花括号形式。这一点太多新手栽过跟头以为$10就是第10个参数其实Shell会把它解析成$1后面跟字符串0结果永远取不到第10个。先记住这个结论后面专门讲。1.3 不同Shell的兼容性说明上面这些变量大部分来自POSIX标准所以在sh、bash、zsh里基本通用。但细节上有差异。最典型的是$_在bash里是“上一条命令的最后一个参数”在zsh里可能表现不一样$!和$$的逻辑差不多但子shell里$$的含义需要格外小心。我个人的建议是新脚本一律用#!/bin/bash不要用sh。很多Linux发行版的sh现在指向dash行为跟bash有不少差异。你写${array[]}、[[ ]]这些语法时sh底下很容易翻车。把这篇文章里所有示例用bash跑基本不会遇到兼容性问题。如果必须写POSIX兼容脚本再考虑把代码收敛到老实的if [ ]和${var:-default}这类写法。2. 核心变量逐个拆解从语义到实战陷阱2.1$0与位置参数脚本的自我认知$0是脚本自己的“名字”但具体是什么取决于你如何调用它。假设有个脚本叫test.sh内容是echo $0bash test.sh→ 输出test.sh/tmp/test.sh→ 输出/tmp/test.sh./test.sh→ 输出./test.sh通过PATH直接调用 → 可能输出完整路径由于这个特性脚本里想获取自身所在目录不能直接对$0做dirname就完事还要考虑是相对路径还是绝对路径。我常用的一行写法SCRIPT_DIR$(cd $(dirname ${BASH_SOURCE[0]}) /dev/null pwd)注意这里用的是${BASH_SOURCE[0]}而不是$0。因为脚本被source进来的时候$0还是调用者的名字而${BASH_SOURCE[0]}才是脚本文件自身。这个细节藏着无数坑尤其在写库文件、公共函数的时候。普通直接执行的情况下$0够用但为了健壮建议统一用${BASH_SOURCE[0]}。$1到$9则对应传入的第1到第9个参数和C语言argv[1]到argv[9]一个意思只不过Shell没有“argv[0]”这个概念因为$0已经给了脚本名。举个例子#!/bin/bash echo 脚本名: $0 echo 第1个参数: $1 echo 第2个参数: $2 echo 第3个参数: $3执行./test.sh a b c会依次输出a、b、c。如果参数不足$3就为空字符串不会报错。这是Shell比较宽容的地方但也是很多逻辑隐患的来源——你不校验就使用很可能会把空字符串当参数传进命令里。超过9个参数时必须用${10}、${11}这种写法。为什么会这样因为$10会被解释成$1后面接一个字符0相当于两个东西拼在一起。如果把$1的值设为foo$10就是foo0。这不是瞎说你可以在终端里执行set -- a b c d e f g h i j然后分别打印$10和${10}结果立刻见分晓。shift命令和位置参数是绑定在一起的。执行shift会把$2变成$1、$3变成$2以此类推并且$#减1。这在解析命令行的-a、-b这类选项参数时非常常用。但要注意写循环里反复shift很容易在边界条件上出错网上也有专门的getopts工具复杂选项解析还是建议用它。2.2$#与参数个数校验$#就是位置参数的个数。它和$*、$是伴生关系$#告诉你有多少个参数后面两个负责把参数取出来。校验参数是脚本健壮性的第一步。比如一个脚本必须至少接受两个参数才继续执行最简单的写法#!/bin/bash if [ $# -lt 2 ]; then echo 用法: $0 输入文件 输出目录 2 exit 1 fi这里把用法信息写到2标准错误是脚本输出规范里很值得养成的习惯。因为真正希望程序处理的数据走标准输出辅助信息不该污染管道。还有exit 1表示非零退出码调用方通过$?可以感知到脚本失败了。$#在函数里同样适用但含义变成“函数的参数个数”。这点很容易搞混你在函数里写echo $#它不会输出脚本的参数个数而是函数的参数个数。如果你希望在函数里拿到脚本总参数个数需要在进入函数前就保存下来比如SCRIPT_ARGC$#。另外shift会改变$#所以如果你先做了一次shift再检查$#得到的值是剩余参数个数不是最初的个数。解析选项参数时这个是符合预期的但如果是校验整体个数得放在shift之前。2.3$?退出码的判断陷阱$?保存的是上一条已执行命令的退出状态码。0表示成功非0表示失败。失败的码值有约定1表示一般错误2表示用法错误126命令不可执行127命令不存在128以上通常和信号有关。但更细致的规定不同命令不完全一样所以只判断“等于0”还是“不等于0”最稳妥。常见写法grep error app.log if [ $? -eq 0 ]; then echo 找到了error行 else echo 没有匹配到或grep本身失败 fi这种写法能跑但不推荐。因为$?极其“脆弱”——它是上一条命令的返回值只要你在判断前执行了任何命令它的值就被覆盖了。比如下面的代码就是经典翻车现场grep error app.log echo grep执行完毕 if [ $? -eq 0 ]; then echo 成功 fi这个脚本不管grep成不成功只要echo执行成功$?就会被覆盖成0。所以正确姿势是要么立刻把$?存进普通变量要么直接用if去判断命令本身if grep -q error app.log; then echo 找到了 fi第二种方式更符合Shell哲学也让代码看起来干净很多。另一个大坑是管道。命令a | b执行完后$?取的是管道中最后一条命令b的退出码而不是a的。比如set -o pipefail开启pipefail后管道返回失败的条件是“任一命令失败”只要前面命令失败整体退出码就是非0。这个选项配合set -e能避免很多“明明前面失败了整个管道还返回成功”的诡异问题。但要注意pipefail不是POSIX标准bash和zsh支持sh里可能没有这个选项。函数也有退出码机制函数体里最后一条命令的退出码就是函数返回值。你可以用return 10显式返回一个数字但它同样受0~255范围限制超过255会被取模。所以别试图用$?传一个很大的整数它不是通信工具只适合传“成功与否”或“错误码”。2.4$*与$引号引发的世纪难题这两个变量每天都有人问总结起来就一句话**不加引号时两者一模一样加引号后$*把所有参数拼成一个字符串$保持每个参数独立。**听起来简单真正写起来没几个人敢拍胸脯。先看无引号的情况。假设执行./test.sh a b c参数个数是2其中第一个参数包含空格。无引号的$*或$都会把a b拆成a和b再和c混合最终变成三个词。这基本不是你想要的。再看带引号for arg in $*; do echo 参数: $arg done因为$*把两个参数拼接成了单个字符串a b c循环只会执行一次。而for arg in $; do echo 参数: $arg done$会保留原始的边界循环执行两次第一次arg是a b第二次是c。这才是遍历参数的“正确打开方式”。为什么*和会有这种差异我的记忆方法是像是快递员把每个包裹单独交到你手上*像是把所有包裹塞进一个大麻袋递给你。你如果后续要逐个处理包裹肯定希望一个个拿如果你只是想把它们打包传给别人那麻袋可能更方便。实操中最常见的场景是把脚本参数继续传递给另一个命令./child_script.sh $这里的$能把父脚本收到的所有参数原封不动、按原始分隔传给子脚本。如果你写成$*子脚本只会收到一个被空格拼接起来的字符串参数个数变成1很多参数解析逻辑就废了。所以凡是要“透传参数”请坚定地使用$。2.5$$与$!进程身份与后台任务$$是当前Shell的进程PID。听上去简单但有个非常隐蔽的坑在子shell里$$并不会变成子shell的PID而是保持父Shell的PID。所谓子shell指的是( ... )、管道中的各段命令、命令替换$(...)等环境。例如echo 父进程PID: $$ ( echo 子shell PID: $$ ) echo BASHPID: $BASHPID如果不了解这点你会以为括号里打印出了子shell的PID其实它和外面一样。想获取真正的当前bash进程PID需要用$BASHPID。这个变量是bash扩展出来的不是POSIX标准。为什么$$还这么流行因为它最常见的用途是生成临时文件的后缀。比如TMP_FILE/tmp/backup_$$.log这样多个脚本实例同时跑起来时每个实例的PID不同文件名也就不会冲突。虽然$$在子shell里不好使但在单层脚本里足够用。更好的方式是mktemp它能创建安全且唯一的临时文件还能避免PID被预测带来的安全风险。如果你只是写个日常辅助脚本用$$没问题如果是需要暴露给外部用户的场景建议上mktemp。$!则是“最近一个被放到后台执行的进程的PID”。什么算后台就是命令结尾加。比如sleep 100 echo 后台进程PID: $! wait $! echo 后台进程已结束退出码: $?这个变量在并行任务里非常有用。我经常这样写把三四个耗时的压缩、传输命令一股脑丢到后台记录$!然后用wait等它们全部结束。wait如果不带参数会等待所有子进程如果带上$!就只等那一个。你还能在等待期间轮询检查进程是否还活着if kill -0 $PID 2/dev/null; then echo 进程 $PID 仍在运行 else echo 进程 $PID 已结束 fi注意kill -0不会真的发送信号只做存在性检查这是判断一个进程是否还活着的常用技巧。2.6$-与$_调试标记和最后参数$-看名字很怪它是一个“字符串”里面保存当前Shell激活了哪些选项标志。你直接在终端里执行echo $-常见的输出可能长这样himBHs或者hBc。不同发行版、不同shell版本会有差异。其中eerrexit一有命令失败就退出unounset使用未定义变量时直接报错xxtrace执行前打印每条命令vverbose打印输入行fnoglob禁用通配符展开hhashall启用命令哈希默认开B花括号展开默认开i交互模式m监控模式作业控制在脚本里你可以通过set -x打开调试模式此时$-里会多出x用set x关闭是关闭的意思。实用场景是你可以在脚本里判断当前是否处于调试模式从而决定是否输出额外日志case $- in *x*) echo 当前处于 xtrace 模式 ;; *) echo 普通模式 ;; esac$_则是一个“记忆型”变量它会记住上一条命令的最后一个参数。看例子echo hello world echo 上一个参数是: $_第一次echo hello world执行完后$_的值是world所以第二次输出“上一个参数是: world”。这个特性在一些批量文件操作里可以简化重复输入但可读性比较差我不建议在脚本里大量使用。还有个更隐蔽的用法在脚本开头$_的值是被执行脚本的完整路径由bash传入所以有些老脚本会用$_来定位脚本目录。但这种行为依赖bash实现不同版本不一致还是老老实实用${BASH_SOURCE[0]}吧。2.7 补充位置参数超过9个怎么办前面已经提到过${10}这里再展开说。很多人写成$10得到的不是第10个参数而是$1后面加字符0。只有当参数超过9个时花括号才是必需语法。那为什么$9不用加花括号因为Shell解析变量名时会尽可能多地吞掉后续字母数字和下划线。$10的字符“1”和“0”都能作为变量名的一部分所以Shell把$10整体当作一个变量名而不是$1和0的组合。加上花括号就把边界明确了。另外如果你需要把参数打包然后重置可以这样set -- a b c这会把$1a、$2b、$3c同时$#变成3。这个技巧在临时模拟参数、或者在函数内部创建一套局部参数列表时很常用。实现在解析完参数后想把剩余参数重新赋值给位置参数也是一样shift 2 set -- $前者去掉前两个参数后者把剩下的参数保序放回位置参数中。注意这里$写在set --后面也是透传的经典用法。3. 综合实战一个带参数解析和日志记录的脚本模板3.1 需求分析单独讲变量总是零散的我把它们放到一个真实场景里串起来。需求很简单写一个备份脚本支持输入文件路径和目标目录如果参数不够就打印用法执行核心命令时判断退出码所有输出带时间戳如果指定了-d开启调试模式最后把后台任务PID记录到日志里。这个脚本会用到$0、$#、$1、$2、$?、$、$$、$!、$-、$_几乎把今天所有主角都拉练了一遍。3.2 完整脚本#!/bin/bash # 文件名: backup_util.sh # 用途: 将源文件备份到目标目录支持调试模式 SCRIPT_NAME$(basename $0) LOG_DIR/tmp TIMEOUT30 # 判断是否传入 -d 调试选项 DEBUG_MODE0 while getopts d opt; do case $opt in d) DEBUG_MODE1 ;; *) echo 未知选项 2; exit 1 ;; esac done shift $((OPTIND - 1)) # 校验剩余参数数量 if [ $# -lt 2 ]; then echo 用法: $SCRIPT_NAME [-d] 源文件 目标目录 2 exit 1 fi SRC_FILE$1 DEST_DIR$2 # 开启调试模式 if [ $DEBUG_MODE -eq 1 ]; then set -x fi # 记录日志结合 $$ 生成唯一前缀 LOG_FILE${LOG_DIR}/backup_$$.log echo [$$] 开始备份 $SRC_FILE 到 $DEST_DIR $LOG_FILE # 准备目录 mkdir -p $DEST_DIR if [ $? -ne 0 ]; then echo 创建目录失败退出 2 exit 1 fi # 后台执行拷贝 cp -v $SRC_FILE $DEST_DIR $LOG_FILE 21 CP_PID$! echo [$$] 后台cp任务PID: $CP_PID $LOG_FILE # 等待任务结束 wait $CP_PID CP_STATUS$? if [ $CP_STATUS -eq 0 ]; then echo [$$] 备份成功最后一个参数: $_ $LOG_FILE else echo [$$] 备份失败退出码: $CP_STATUS 2 exit $CP_STATUS fi # 如果开启了调试模式打印当前选项状态 if [ $DEBUG_MODE -eq 1 ]; then echo 当前选项标志: $- $LOG_FILE set x fi echo 完成。日志见 $LOG_FILE这个脚本不是完美的生产级脚本但足够演示特殊变量的使用方式。你可能会问getopts里的$opt和$#会不会和被解析参数混淆不会getopts把当前选项放到$opt变量shift $((OPTIND - 1))之后位置参数才真正变成剩余的普通参数。这是一个非常经典的结合用法。3.3 逐段解读脚本开头用$(basename $0)拿到脚本文件名。为什么不用绝对路径因为帮助信息里不需要展示目录名字短一点更友好。这里的$0如果被soure调用仍然可能不准但备份脚本通常都是直接执行问题不大。while getopts d opt这段本质上是循环调用getopts命令每次解析出一个选项依次处理。d后面没有冒号表示这个选项不带参数。解析完后OPTIND指向第一个非选项参数的位置所以用shift $((OPTIND - 1))把所有选项参数全部甩掉。if [ $# -lt 2 ]这里$#已经是去掉选项参数后的剩余参数个数了。这个顺序很重要先解析选项再校验参数否则-d会被当成普通位置参数。后台执行cp时我把标准输出和错误都重定向到了日志文件然后用$!拿到后台进程PID接着wait $CP_PID等它结束。这里wait的返回值就是cp的退出码所以下一行用$?取出来保存进CP_STATUS。注意我没有在wait和if之间插入其他命令否则$?早就变了。日志里用了$$作为文件前缀。如果两个备份脚本同时跑各自的PID不同日志文件就不会互相覆盖。虽然简单但在单机场景下够用。3.4 运行测试假设有一个文件/data/app.tar.gz目标目录/backupbash backup_util.sh -d /data/app.tar.gz /backup执行后$#是2$1是/data/app.tar.gz$2是/backup。调试模式下会打印每一步命令日志内容大概是[12345] 开始备份 /data/app.tar.gz 到 /backup [12345] 后台cp任务PID: 23456 [12345] 备份成功最后一个参数: /backup最后一行那个“最后一个参数”就是利用了cp命令的最后一个参数/backup传递到$_的特性。如果你不告诉读者这个细节光看日志会有点懵。平时写脚本我其实不太用$_但用来做日志里“最近操作目标”的记录很方便。4. 常见问题与排查技巧实录4.1$*和$的分工什么场景必须用$很多人在知乎、博客里看了一堆理论真到写代码时还是混淆。我建议你直接把下面这个实验跑一遍胜过读十遍解释#!/bin/bash test_params() { echo 参数个数: $# echo 使用 \\$*\: for arg in $*; do echo [$arg] done echo 使用 \\$\: for arg in $; do echo [$arg] done } test_params a b c输出结果非常直观$*的循环只打印一次[a b c]$的循环打印两次分别是[a b]和[c]。记住这个实验以后看到别人代码里写了$你就知道他需要保留参数边界写了$*他多半只是想拼接字符串。具体到场景把参数透传给另一个脚本或函数用$把所有参数打包成一个字符串作为日志记录用$*用for遍历参数用$只是想打印出来两者都可以但$*更符合人类阅读习惯注意还有一个“空参数”的问题。如果某个参数是空字符串$能保留这个空参数表现为参数个数包含它$*会把空参数直接丢掉。这在处理位置敏感的接口调用时差异很大。4.2$?为什么老是被“偷走”真实排查中最常见的原因是“中间插入了一条无关命令”。举一个我帮同事看过的例子if ! command1; then status$? echo command1返回: $status exit $status fi这个写法本身没问题但如果写成command1 status$? echo command1执行完成 if [ $status -ne 0 ]; then ...问题不大因为你已经保存了status。但如果写成command1 echo command1执行完成 status$?status保存的是echo的退出码基本永远是0。排查这种问题第一眼先看“保存$?的那一行和命令执行之间有没有夹杂其他命令”。更保险的做法是直接用if command; then或者用、||把这些命令串起来让退出码在被使用之前不被干扰。还有函数返回值的范围问题。如果你在函数里return 300外层$?实际是44300-25644。这是POSIX规定的退出码范围。写代码时别为了省事把业务错误码设成300、500这种大数字要么用普通变量传状态要么在0~255内做映射。很多时候我发现脚本返回值太大导致$?判断出错排查半天最后发现是设计层面没有规划好错误码表。4.3$$在子shell里“失效”的问题我在日志场景里踩过这样一个坑脚本里用了命令替换$( ... )命令替换中打印$$原以为是子shell自己的PID结果和主脚本一模一样。当时在排查两个临时文件是否冲突差点被误导。如果想要子shell的真实PID用$BASHPID。比如echo 父PID: $$ bash -c echo bash -c里的PID: $$ echo 子shell BASHPID: $BASHPID注意bash -c会新起一个进程所以它的$$是新进程的PID。而( ... )这种子shell不会重新初始化$$因此保持父值。这个区别记住就好。如果你的目的是生成唯一文件名建议别依赖PID的“唯一性”直接mktemp最稳它保证不会重复还能设置正确的文件权限。4.4 常用set选项与$-的关联$-在排错时很有用。当你开启set -eux后脚本出错就退出、未定义变量即报错、每条命令执行前都打印。如果你怀疑某个脚本行为诡异第一步可以先看脚本头部有没有这行“保命组合”set -Eeuo pipefail这行东西我几乎每个工程脚本都会写。作用拆开看-E让ERR陷阱在函数和子shell中也能触发-e任何命令返回非0立即退出-u使用未定义变量时报错退出而不是当成空字符串-o pipefail管道中任意命令失败则整体失败开启后$?仍然能拿到退出码但脚本会立刻退出所以不会继续执行带病逻辑。调试时再加-x就能看到每一步执行了啥。想确认当前状态用echo $-看输出里含有eux。不过要说句公道话set -e也不是银弹。它有坑管道、命令替换、条件判断、/||右侧命令等场景下行为可能和你期待的不一致。比如set -e false || echo 这里会执行 echo 继续执行false || echo整体返回值是0所以脚本不会退出。这种事多到一篇文章说不完。我的经验是set -e能防大部分低级错误但别指望它替代你自己的错误处理。核心关键命令后仍然手动检查$?双重保险。4.5 特殊变量取默认值组合拳打法特殊变量经常和Shell的“参数扩展”语法一起出现。虽然${var:-default}不是“特殊变量”本身但它是围绕$1、$2这些位置参数做兜底的最常用工具。比如某个脚本允许第二个参数省略缺省时用/tmpTARGET_DIR${2:-/tmp}如果$2为空或未定义就返回/tmp。同理脚本名取默认真名SCRIPT_NAME${0##*/}其中##*/是参数扩展的模式删除相当于basename $0。如果在处理位置参数时不想频繁shift也可以用${:2}这种数组切片语法批量取子集。Bash 3.0以上都支持${:起始位置:长度}非常灵活。这种“特殊变量扩展语法”结合起来才真正算Shell脚本的老手玩法。最后分享一点实际体会写Shell这十年我最大的体会是特殊变量本身不复杂复杂的是它们被夹在真实命令流中间时的“时序问题”。$?要立刻保存$要记得加引号$0要想着可能被source$$要警惕子shell$!要配合wait使用。这些经验不踩几次坑很难内化。我建议你把自己的~/.bashrc或者脚本模板区放一个固定片段头部写好set -Eeuo pipefail变量全部用大写命名参数校验统一放前面日志统一带上$$。这样至少能让特殊变量的使用错误减少七成。如果你还想再进一步把今天讲的十来个变量做成一个shell函数dump_special_vars()在你怀疑任何脚本行为异常时调用它一次性打印所有特殊变量的值。相信我排错效率会高很多。