首页 / 资讯中心 / 文章详情

Shell脚本特殊变量详解:$#、$@、$?等参数与状态变量用法指南

Shell脚本特殊变量详解:$#、$@、$?等参数与状态变量用法指南 ★ FEATURED ARTICLE
1. 别小看这几个符号它们决定了脚本的“智商”写 shell 脚本的人大概都经历过这种时刻脚本在自己机器上跑得好好的换一台机器、换一组参数立刻翻车。排查半天最后发现是$#没判断、$?没检查、$和$*用混了。这几个看起来像乱码的符号其实是 shell 脚本里最基础也最容易被忽视的“传感器”——它们负责告诉脚本外面传进来几个参数、参数长什么样、上一条命令到底成没成功、当前脚本叫什么名字。这篇内容就是要把$#、$*、$、$?、$0到$n这一组特殊变量一次性讲透。不是那种抄手册式的罗列而是从实际写脚本的角度出发说清楚每个变量在什么场景下用、为什么这么用、用错了会出什么事。适合刚接触 shell 脚本的新手打地基也适合写了几年脚本但一直靠“试出来”的老手做一次系统梳理。看完之后你写参数校验、写错误处理、写批量任务分发的时候心里会有底得多。我见过太多脚本开头直接rm -rf $1/*连参数有没有传都不判断也见过循环里用$*遍历参数结果带空格的参数被拆得七零八落。这些问题根子上都是对这几个特殊变量理解不到位。下面按“先搞懂是什么再搞懂怎么用最后搞懂怎么不踩坑”的顺序展开。2. 先把这组特殊变量摆上台面2.1 它们到底是什么shell 的位置参数与状态变量shell 脚本在运行时会维护一组内置的变量专门用来描述“当前这次执行的上下文”。这组变量不需要你定义脚本一启动它们就存在统称为特殊变量。其中和参数、状态相关的那几个就是本文要拆解的主角。可以把一次脚本执行想象成一次函数调用你敲命令时跟在后面的东西就是实参脚本内部需要一种方式拿到这些实参$0到$n就是干这个的同时脚本还需要知道“一共传了几个”“上一步干成了没有”$#和$?负责这些。它们的分工非常明确变量含义典型用途$0当前脚本的名称含调用路径打印用法、日志标识$1~$n第 n 个位置参数读取具体入参$#位置参数的个数参数数量校验$*所有位置参数作为一个整体字符串需要整体拼接时$所有位置参数保持各自独立循环遍历参数$?上一条命令的退出状态码错误判断、流程控制这张表先记住下面逐个拆。需要强调的是这些变量在函数内部和脚本顶层的行为基本一致但$0在函数里仍然指向脚本名而不是函数名这一点后面会专门说。2.2 为什么非要用特殊变量脚本和外界沟通的唯一通道有人会问我直接写死参数不行吗小脚本可以但只要脚本需要复用、需要被别人调用、需要放进定时任务或者流水线里就必须通过参数和状态来沟通。特殊变量就是脚本的“接口协议”。举个很实际的场景你写了一个备份脚本需要指定源目录、目标目录、保留天数。如果写死路径换一个项目就得改脚本用位置参数接收同一个脚本能服务多个项目。而接收之后你得知道用户到底传了三个还是两个这就轮到$#出场。备份命令执行完你得判断有没有失败失败了要不要告警这就轮到$?。整套逻辑环环相扣缺一个环节脚本就是“裸奔”。所以这几个变量不是语法糖是脚本健壮性的基础设施。理解它们本质上是在理解“脚本如何感知外部世界”。3. 逐个拆解每个符号的脾气和用法3.1$0脚本的身份证但别指望它永远靠谱$0保存的是当前脚本被调用时的名称。直接./backup.sh执行$0就是./backup.sh用绝对路径/opt/scripts/backup.sh执行它就是完整路径通过bash backup.sh执行它可能是backup.sh。这个差异很关键。最常见的用途是打印用法说明。比如脚本参数不对时输出Usage: $0 source target用户一看就知道怎么调。但这里有个坑如果你在脚本里source了另一个脚本或者通过软链接调用$0的值可能和你预期不一样。我一般会在脚本开头做一次规范化SCRIPT_NAME$(basename $0) SCRIPT_DIR$(cd $(dirname $0) pwd)basename取文件名dirname配合cd pwd拿到脚本所在目录的绝对路径。这样后面引用同目录下的配置文件、日志目录时就不会因为调用路径不同而找不到。这个写法几乎是我每个稍复杂脚本的标配开头实测下来非常稳。注意$0在函数内部不会变成函数名它始终是脚本名。想看函数名得用$FUNCNAME这是另一个话题了。3.2$1到$n位置参数超过 9 要加花括号位置参数按顺序对应命令行上传入的实参。$1是第一个$2是第二个依此类推。这里有个新手极易踩的坑当 n 大于等于 10 时必须写成${10}、${11}。因为$10会被 shell 解释成$1后面跟一个字符0结果完全不对。# 错误写法$10 被解析为 $1 加字符 0 echo $10 # 正确写法 echo ${10}这个坑我在早期写批量处理脚本时踩过当时传了十几个参数输出总是莫名其妙多一个 0查了半天才反应过来。从那以后只要参数可能超过 9 个我一律用花括号包起来养成习惯就不容易错。位置参数还有一个特性如果对应的参数没传它的值是空字符串不会报错。这意味着$1在没传参时展开为空脚本继续往下跑。所以永远不要假设参数一定存在用之前先校验这是下一节$#的职责。3.3$#参数计数器脚本的第一道安检门$#返回位置参数的个数注意它不包含$0。这个细节很多人搞混以为$#会把脚本名也算进去实际上不会。./test.sh a b c执行时$#等于 3。它的核心用途是参数数量校验这是脚本健壮性的第一道关卡。我习惯在脚本开头就做判断if [ $# -lt 2 ]; then echo Usage: $0 source_dir target_dir exit 1 fi这段逻辑的意思是参数少于两个就直接打印用法并退出后面的代码根本不会执行。这样做的好处是把错误挡在最前面避免脚本跑到一半因为缺参数而失败留下半成品状态。尤其是涉及文件操作的脚本半途失败可能留下脏数据清理起来很麻烦。$#还能配合shift使用。shift会把位置参数整体左移一位$1被丢弃原来的$2变成$1同时$#减一。处理不定长参数时这个组合很好用while [ $# -gt 0 ]; do echo 处理参数: $1 shift done每轮循环处理当前$1然后shift把已处理的丢掉直到$#归零。这种写法在处理“选项 值”交替出现的参数时特别顺手。3.4$*和$长得像脾气完全不同这两个是最容易混淆的一对必须重点说。它们都代表“所有位置参数”但在加双引号时的行为天差地别。$*把所有参数拼成一个字符串参数之间用IFS的第一个字符默认是空格连接。$把每个参数保持独立展开后相当于$1 $2 $3。不加引号时两者都会做分词和通配符展开行为几乎一样但都不安全。真正的区别在加引号之后。看个例子假设执行./test.sh hello world foo# 用 $* for arg in $*; do echo [$arg] done # 输出: [hello world foo] 只有一个元素 # 用 $ for arg in $; do echo [$arg] done # 输出: # [hello world] # [foo] 两个元素保持原样差别一目了然。$*把带空格的参数和后面的参数糊成一团$则原样保留每个参数的边界。结论很明确需要遍历参数时一律用$只有确实想把所有参数拼成一个整体字符串时才用$*。我见过一个真实案例某脚本用for f in $*遍历待处理文件结果文件名里带空格的那个文件被拆成两个不存在的路径脚本报错但没退出最后处理了一半文件就“成功”结束了。这种 bug 隐蔽性极强因为不带空格的文件一切正常只有特定数据才触发。改成for f in $之后问题消失。提示$*和$不加引号时都会发生分词带空格或通配符的参数会被二次解释这是很多诡异问题的根源。养成“遍历用$、拼接用$*”的肌肉记忆能避开一大半坑。3.5$?上一条命令的“体检报告”$?保存的是最近一条命令的退出状态码。约定俗成的规则是0表示成功非0表示失败不同数值代表不同错误类型但脚本层面通常只关心是不是 0。它的关键特性是“只反映紧邻的上一条命令”。这意味着如果你在命令和判断之间插了一句echo$?就被echo的返回值覆盖了。看这个错误示范cp source.txt target.txt echo 复制完成 if [ $? -ne 0 ]; then echo 复制失败 fi这里的$?反映的是echo的退出码几乎总是 0而不是cp的。正确做法是紧接着判断或者先把状态码存起来cp source.txt target.txt status$? if [ $status -ne 0 ]; then echo 复制失败状态码: $status exit $status fi把$?立刻赋值给一个普通变量是避免被覆盖的标准手法。我在写多步骤脚本时几乎每一步关键操作后都会存一次状态码方便定位到底哪一步出的问题。另一个常见用法是配合set -e。脚本开头加set -e后任何命令返回非 0 都会让脚本立即退出省去大量手动判断。但set -e有它的脾气比如在if条件里、管道中间、左侧的命令失败不会触发退出这些边界情况需要心里有数。我的习惯是简单脚本用set -e图省事复杂脚本手动判断$?更可控。4. 把它们串起来一个完整的参数处理脚本4.1 从需求到代码一个带校验的批量处理脚本光说不练假把式。假设要写一个脚本接收一个操作类型和若干文件路径对每个文件做处理任何一步失败都要明确报错。这个需求把$#、$、$?、$0全用上了。#!/bin/bash SCRIPT_NAME$(basename $0) # 第一道关卡参数数量校验 if [ $# -lt 2 ]; then echo 用法: $SCRIPT_NAME 操作类型 文件1 [文件2 ...] exit 1 fi # 取出第一个参数作为操作类型然后 shift 掉 operation$1 shift # 此时 $# 是剩余文件的数量$ 是剩余文件列表 echo 操作类型: $operation echo 待处理文件数: $# # 遍历剩余参数必须用 $ for file in $; do if [ ! -f $file ]; then echo 跳过不存在的文件: $file continue fi # 模拟处理操作 process_file $file status$? if [ $status -ne 0 ]; then echo 处理失败: $file (状态码 $status) exit $status fi echo 处理成功: $file done echo 全部完成这段代码的逻辑链条很清晰先用$#挡住参数不足的调用再用$1取出操作类型并shift剩下的交给$遍历每个文件处理完用$?检查结果。$0通过basename规范化后用于用法提示。四个变量各司其职缺一个脚本就不完整。4.2 参数校验的层次数量、类型、存在性参数校验不是一句$# -lt 2就完事的实际项目里我一般分三层做数量校验用$#确认参数个数在合理范围太少报用法太多可以警告或忽略。类型/格式校验比如操作类型必须是预设的几个值之一用case判断。存在性校验文件路径是否真实存在、目录是否可写用-f、-d、-w等测试符。case $operation in backup|restore|verify) ;; *) echo 不支持的操作类型: $operation echo 可选值: backup, restore, verify exit 1 ;; esac这三层校验的顺序也有讲究先数量、再类型、最后存在性。因为数量不对时后面的参数可能根本不存在先判断类型会读到空值类型不对时没必要去检查文件存在性纯属浪费。这个顺序是我踩过几次“参数错位导致误判”之后固定下来的。4.3 退出码的设计让$?传递有意义的信息$?只告诉你成功或失败但如果你自己写的脚本要返回更细的信息就需要设计退出码。约定上0成功1通用错误2用法错误126权限问题127命令不存在128以上通常表示被信号终止。自定义脚本可以在这个框架内扩展。我一般这样设计1表示参数或用法错误2表示输入数据有问题3表示外部依赖失败比如调用的命令不存在4表示处理过程中的业务错误。这样上层调用方拿到$?就能大致判断问题出在哪一层不用翻日志。if [ ! -r $source ]; then echo 源文件不可读: $source exit 2 fi退出码设计得好脚本就像有了自己的“错误语言”排查效率提升非常明显。反过来所有错误都exit 1上层只能知道“失败了”具体原因还得去翻日志这在自动化流程里很要命。5. 踩坑实录那些年我们一起写错的特殊变量5.1 高频错误速查表下面这张表是我这些年见过、也亲自踩过的高频错误按出现频率排序建议对照自己的脚本自查一遍。错误写法问题正确写法for f in $*带空格参数被拆分for f in $echo $10被解析为$1加0echo ${10}命令后隔几行才判断$?状态码被中间命令覆盖命令后立即status$?不判断$#直接用$1缺参时展开为空逻辑错乱开头用$#校验$*用于遍历所有参数糊成一个遍历用$用$0直接拼路径调用方式不同导致路径错dirnamecdpwd规范化set -e下依赖$?命令失败已退出判断不到明确哪些场景用set -e这张表里的每一条背后都有真实翻车案例。尤其是第一条和第三条隐蔽性最强因为脚本在“正常数据”下跑得好好的只有边界情况才暴露。5.2 一个关于$的经典翻车现场说个印象最深的。某次写一个日志归档脚本接收一批日志文件路径逐个压缩。测试时用的文件名都是app.log、error.log这种不带空格的一切正常。上线后有个服务的日志目录名带空格脚本直接把一个路径拆成两段压缩命令报“文件不存在”但因为没检查$?脚本继续往下跑最后报告“归档完成”实际上漏了好几个文件。问题出在两处一是for f in $*没加引号二是没检查$?。修复也很直接for f in $; do gzip $f if [ $? -ne 0 ]; then echo 压缩失败: $f 2 failed1 fi done [ $failed -eq 1 ] exit 1改完之后带空格的路径正常处理失败也能被捕获。这个案例让我彻底记住了参数遍历永远加引号用$关键操作永远检查$?。这两条现在是我 review 别人脚本时的必查项。5.3 排查思路特殊变量出问题时怎么定位当脚本行为诡异时我一般按这个顺序排查在脚本开头加一行调试输出echo 参数个数: $#, 参数列表: $先确认脚本收到的到底是什么。在关键命令后打印状态码echo 上条命令状态: $?确认失败发生在哪一步。用bash -x script.sh跟踪执行-x会把每条命令展开后的样子打出来能直接看到变量被替换成了什么分词问题一目了然。检查引号九成的分词问题都出在引号上重点看$*、$、$1有没有正确加引号。bash -x这个技巧值得单独强调。它输出的每一行前面带显示的是变量展开后的实际命令。比如你写for f in $*-x输出里能清楚看到循环体被执行了几次、每次f是什么值分词问题瞬间现形。我调试参数相关问题时第一反应就是加-x跑一遍。提示生产脚本里不要留调试输出但可以在开头加一个DEBUG开关[ $DEBUG 1 ] set -x需要时打开平时关闭兼顾排查和整洁。6. 进阶用法让特殊变量发挥更大价值6.1 用$透传参数包装脚本的标准姿势很多时候我们需要写一个包装脚本在调用真正的命令前做一些准备工作然后把收到的参数原样传下去。这时候$是唯一正确的选择#!/bin/bash # 包装脚本先记录日志再调用真实命令 echo $(date) 调用参数: $* /var/log/wrapper.log exec real_command $exec会用后面的命令替换当前进程$保证参数原样传递包括带空格的。如果用$*或$不加引号参数在传递过程中就被重新分词了真实命令收到的可能和用户输入的完全不一样。这种包装脚本在构建流程、部署脚本里非常常见透传的正确性直接决定整条链路是否可靠。6.2$?与逻辑运算符的配合简洁但不失严谨和||本质上就是在利用退出码做短路判断。cmd1 cmd2表示cmd1成功$?为 0才执行cmd2cmd1 || cmd2表示cmd1失败才执行cmd2。这种写法比一堆if简洁但要注意可读性。# 简洁写法 mkdir -p /tmp/work cd /tmp/work || { echo 准备工作失败; exit 1; } # 等价但更易读的写法 if ! mkdir -p /tmp/work; then echo 创建工作目录失败 exit 1 fi cd /tmp/work || exit 1我的经验是两三个命令的简单串联用/||没问题超过三个或者涉及重要操作老老实实写if。因为/||链一长出错时很难判断到底哪一环断的而if可以精确控制每个分支的提示信息。可读性在脚本维护中比省几行代码重要得多。6.3 参数默认值${1:-default}这类写法的定位严格说${1:-default}属于参数展开语法不算本文主角但它和位置参数配合极多值得提一句。${1:-default}表示“如果$1未设置或为空就用 default”。这让脚本可以支持可选参数retention_days${2:-7} echo 保留天数: $retention_days不传第二个参数时默认保留 7 天传了就按传入的来。这种写法比先判断$#再赋值简洁但要注意它和$#校验是互补关系数量校验保证必需参数存在默认值语法处理可选参数。两者配合脚本的参数接口就既严谨又灵活。7. 写在最后几个让我少走弯路的习惯聊了这么多最后分享几个我在实际写脚本中固化下来的习惯都是踩坑换来的。第一个习惯每个脚本开头必做参数数量校验。哪怕只有一行[ $# -lt 1 ] { echo 用法: $0 参数; exit 1; }也比不写强。这一行能挡掉大量“忘记传参”的低级错误让脚本在错误输入下优雅退出而不是带病运行。第二个习惯遍历参数一律$拼接日志一律$*。这个对应关系我写在便签上贴了好几年现在已经成了条件反射。带空格的路径、带特殊字符的参数在这条规则下都不会出问题。第三个习惯关键命令后立刻存$?。不给自己“等会儿再判断”的机会因为中间随便插一句echo状态码就没了。存下来之后无论是立即判断还是最后统一汇总都游刃有余。第四个习惯调试先上bash -x。参数相关的诡异问题-x的输出比盯着代码看半小时都管用。变量展开成什么样、循环跑了几次、命令实际长什么样一目了然。这几个符号本身不复杂难的是在写脚本时始终保持对它们的敏感度。写的时候多问一句“参数传对了吗”“上一步成功了吗”脚本的可靠性就会上一个台阶。后面如果脚本越写越复杂可以再往函数参数、数组、getopts这些方向扩展但$#、$、$?这套基础打牢了往上走会顺很多。
阅读完成 · 觉得有帮助?
咨询建站