写脚本十来年,我见过太多人在这几个符号上翻车。有人写了个自认为很健壮的部署脚本,结果参数里带个空格就整个崩掉;有人排查了半天故障,最后发现是没检查$?导致上一步失败了还继续往下跑。$#、$*、$@、$?、$0到$n这一组东西,是 Shell 脚本里最基础也最容易被糊弄过去的知识点——面试能背,真写起来就含糊。这篇就把它们从“背定义”拉到“能上手”的层面,把每个符号的取值逻辑、引号包裹后的行为差异、常见误用场景和排查手法一次讲透。不管你是刚写第一个脚本的新手,还是写了几年但一直靠感觉用的人,看完都能对这几个符号建立起清晰的边界认知,知道什么时候该用哪个、为什么这么用、用错了会出什么事。
1. 先把这组符号的整体地图画出来
1.1 它们到底属于哪一类东西
Shell 脚本里的变量大致分两类:一类是你自己定义的普通变量,比如name="abc";另一类是 Shell 运行时自动维护的特殊变量,也叫位置参数和状态变量。$#、$*、$@、$?、$0、$1到$n全部属于后者,它们的值不由你赋值,而是由脚本被调用的方式、上一条命令的执行结果自动决定。
理解这一点很关键。很多人把它们当成普通变量去理解,就会产生“我能不能给它赋值”的困惑。答案是:位置参数类($0、$1~$n、$#、$*、$@)的值来自脚本启动时传入的参数,你在脚本内部改不了它们的原始含义(虽然可以用set --重置位置参数,这是后话);$?则完全由上一条命令的退出状态决定,你也没法直接赋值,只能通过执行命令去改变它。
我习惯把这组符号按“职责”分成三块来记,这样不容易混:
| 分类 | 符号 | 职责 |
|---|---|---|
| 参数计数 | $# | 告诉你传进来多少个参数 |
| 参数内容 | $0、$1~$n、$*、$@ | 告诉你参数具体是什么 |
| 执行状态 | $? | 告诉你上一条命令成功还是失败 |
这张表建议直接记在脑子里。后面所有的细节,都是围绕这三块展开的。分清楚职责,你就不会在需要判断“参数够不够”的时候去用$*,也不会在需要遍历参数的时候错用$#。
1.2 为什么这几个符号值得单独拎出来讲
表面上看,它们就是几个取值符号,能有多难?难点不在“是什么”,而在“引号包裹之后变成什么”。Shell 的参数展开规则里,双引号对$*和$@的处理是完全不同的,这个差异是无数脚本 bug 的源头。再加上$?的取值时机、$0在不同调用方式下的变化、$n超过 9 之后的写法问题,每一个都能单独挖出一堆坑。
我个人的经验是:能把这几个符号的引号行为讲清楚的人,对 Shell 的参数展开机制基本就通了;讲不清楚的,写出来的脚本大概率在“参数带空格”这种场景下会出问题。所以这篇不打算只给你定义,而是把每个符号放到真实脚本场景里,让你看到它“活”起来是什么样。
2. 参数计数与参数内容:$#、$0、$1~$n 逐个拆
2.1 $# —— 参数个数的守门员
$#返回的是位置参数的个数,注意它不包含$0(脚本名本身)。比如你执行./deploy.sh prod us-east 3,脚本里$#的值就是 3,因为传了prod、us-east、3三个参数。
这个符号最典型的用途就是参数校验。写脚本时第一件事往往不是干活,而是确认“用户有没有按约定传参”。我见过太多脚本直接上来就用$1,结果用户没传参,$1是空字符串,脚本带着空值一路跑下去,最后在某个莫名其妙的地方报错,排查成本极高。
一个稳妥的开头长这样:
#!/bin/bash if [ $# -lt 2 ]; then echo "用法: $0 <环境> <区域>" echo "当前收到 $# 个参数,至少需要 2 个" exit 1 fi这里有个细节值得说:[ $# -lt 2 ]里的$#不需要加引号,因为它永远是一个数字,不会为空也不会带空格。但如果你写成[ "$#" -lt 2 ]也完全没问题,加引号是更保守的习惯。我倾向于对数字类变量不加引号、对字符串类变量一律加引号,这样一眼能看出变量的类型预期。
注意:
$#统计的是位置参数个数,如果你在脚本里用shift移动过参数,$#会随之减少。shift每执行一次,$1被丢弃,后面的参数前移,$#减一。这个特性在解析可选参数时非常有用,后面会讲到。
2.2 $0 —— 脚本自己的名字
$0表示当前脚本的调用名。这里有个容易忽略的点:它到底是“脚本文件名”还是“调用时写的路径”,取决于你怎么调用它。用./deploy.sh调用,$0就是./deploy.sh;用绝对路径/opt/scripts/deploy.sh调用,$0就是完整路径;如果脚本被source执行,$0会变成当前 Shell 的名字(比如bash或-bash),而不是脚本名。
这个差异直接影响“用法提示”的写法。很多人喜欢在 usage 里写echo "用法: $0 <参数>",这在直接执行时没问题,但如果脚本被 source,提示里就会显示成用法: bash <参数>,看起来很奇怪。所以更严谨的做法是用basename提取文件名:
SCRIPT_NAME=$(basename "$0") echo "用法: $SCRIPT_NAME <环境> <区域>"basename "$0"会把路径部分去掉,只留文件名。这样无论怎么调用,提示都干净。至于“脚本所在目录”,那要用dirname "$0"配合cd和pwd去求绝对路径,这是另一个话题,但思路是一样的:$0给的是调用路径,你需要自己加工成想要的形态。
2.3 $1 到 $9 与 ${10} 之后的写法
$1、$2……$9分别对应第 1 到第 9 个位置参数,这个大家都知道。但第 10 个参数怎么写,是个高频翻车点。$10在 Shell 里不会被解释成“第 10 个参数”,而是被解释成“$1后面跟一个字符0”。也就是说,如果你传了 10 个参数,$10拿到的是第 1 个参数加上字面量0。
正确写法是${10},用花括号把变量名边界明确出来:
echo "第1个参数: $1" echo "第10个参数: ${10}" echo "第11个参数: ${11}"这个规则对所有“变量名后面紧跟可能被误认为变量名一部分的字符”的场景都适用。比如$1abc会被理解成变量1abc(虽然位置参数不会这么解析,但普通变量会),而${1}abc才是“第 1 个参数 + abc”。我个人的习惯是:只要变量后面还要接字符,一律用花括号包起来,省得记哪些情况会出问题。
还有一个边界:如果访问了不存在的位置参数,比如只传了 2 个参数却用$3,结果是空字符串,不会报错。这个“静默为空”的特性很危险,因为它不会中断脚本,只会让后续逻辑带着空值继续跑。所以配合$#做校验几乎是必须的。
2.4 用 shift 消费参数:解析可选参数的经典手法
shift不带参数时,把$1丢弃,$2变成$1,以此类推,$#减一。shift n则一次丢弃 n 个。这个机制配合while循环,是解析“带选项的参数”的标准套路:
while [ $# -gt 0 ]; do case "$1" in -e|--env) ENV="$2" shift 2 ;; -r|--region) REGION="$2" shift 2 ;; -v|--verbose) VERBOSE=1 shift ;; *) echo "未知参数: $1" exit 1 ;; esac done这段代码的逻辑是:只要还有参数($# -gt 0),就看$1是什么选项;如果这个选项需要值,就取$2然后shift 2一次消费两个;如果是开关型选项,shift一个即可。循环结束时所有参数都被消费干净。
提示:
shift 2之前一定要确认$#至少是 2,否则shift会失败(返回非零),在set -e的脚本里会直接导致退出。稳妥写法是先判断[ $# -ge 2 ]。
3. $* 和 $@:长得像,行为差得远
3.1 不加引号时,两者几乎一样
先说结论:不加引号时,$*和$@的行为基本一致,都是把所有位置参数展开成一个个独立的词,然后 Shell 会对每个词再做一次分词(word splitting)和通配符展开(globbing)。这意味着如果某个参数本身带空格,不加引号展开后会被拆成多个词。
举个例子,执行./test.sh "hello world" foo,脚本里:
for arg in $*; do echo "[$*] -> $arg"; done for arg in $@; do echo "[$@] -> $arg"; done两个循环的输出是一样的,都会把hello world拆成hello和world两个词。因为不加引号时,Shell 先展开变量,再对结果做分词,空格就是分隔符。
这就是为什么“遍历参数”时永远要加引号。不加引号的$*和$@在参数含空格、含通配符(如*、?)时都会出问题,后者甚至可能把当前目录的文件名给展开进来,造成完全意想不到的结果。
3.2 加双引号后,差异才真正显现
这是整篇文章最核心的知识点,我建议你亲手跑一遍验证:
"$*":把所有参数合并成一个字符串,参数之间用IFS的第一个字符(默认是空格)连接。"$@":把每个参数保持为独立的词,等价于"$1" "$2" ... "$n"。
还是./test.sh "hello world" foo这个例子:
echo "=== 用 \"\$*\" 遍历 ===" for arg in "$*"; do echo "[$arg]" done echo "=== 用 \"\$@\" 遍历 ===" for arg in "$@"; do echo "[$arg]" done输出会是这样:
=== 用 "$*" 遍历 === [hello world foo] === 用 "$@" 遍历 === [hello world] [foo]看到区别了吗?"$*"把两个参数粘成了一个hello world foo,循环只跑一次;"$@"保持了参数的独立性,循环跑两次,且hello world作为一个整体没被拆开。
这个差异决定了它们的适用场景:需要逐个处理参数时用"$@",需要把参数拼成一个整体字符串时用"$*"。绝大多数情况下你要的是前者,所以“遍历参数用"$@"”应该成为肌肉记忆。
3.3 一个必须知道的转发场景
"$@"最经典的用途是参数透传——把当前脚本收到的参数原封不动传给另一个命令。比如你写了个包装脚本,内部要调用真正的执行程序:
#!/bin/bash # wrapper.sh exec /opt/real-tool "$@"这里必须用"$@",不能用"$*",也不能用不带引号的$@。原因很直接:"$@"能保证每个参数(包括带空格的)原样传递,参数边界不丢失;"$*"会把所有参数拼成一个字符串传过去,接收方只会看到一个参数;不带引号的$@会在转发前做一次分词,带空格的参数被拆散。
我踩过这个坑:早期写的一个构建包装脚本用了$*,本地测试参数都不带空格,一切正常;上线后某个参数是带空格的路径,直接被拆成两半,构建失败。改成"$@"之后问题消失。这个教训让我后来所有转发场景一律用"$@",不再犹豫。
3.4 用表格把四种写法钉死
| 写法 | 参数带空格时 | 参数个数保持 | 典型用途 |
|---|---|---|---|
$* | 被拆分 | 否 | 几乎不用,除非明确要分词 |
$@ | 被拆分 | 否 | 几乎不用,风险高 |
"$*" | 合并为一个字符串 | 否(变 1 个) | 拼接展示、日志输出 |
"$@" | 保持完整 | 是 | 遍历、转发、传参 |
这张表我建议贴在显示器边上。判断标准就一条:只要涉及参数传递和遍历,闭眼选"$@"。只有在“我要把参数拼成一句话打印出来”这种展示场景,才考虑"$*"。
4. $? —— 退出状态码,脚本健壮性的命门
4.1 $? 到底记录了什么
$?保存的是上一条已执行命令的退出状态码,取值范围 0 到 255。约定俗成的规则是:0 表示成功,非 0 表示失败。这个“0 是成功”的设定和很多编程语言里“0 是假”的直觉相反,是新手最容易搞反的地方。
需要特别强调的是“上一条”这三个字。$?是易失的,任何一条命令执行完都会覆盖它。这意味着你不能在两条命令之间插入任何东西还指望$?保持原值。看这个错误示范:
some_command echo "命令执行完了" # 这条 echo 成功执行,$? 变成 0 if [ $? -ne 0 ]; then echo "失败了" fi这段代码里的if永远不成立,因为echo把$?覆盖成了 0。正确做法是紧接着判断,或者先把$?存起来:
some_command status=$? echo "命令执行完了" if [ $status -ne 0 ]; then echo "失败了,状态码 $status" fi把$?赋值给一个普通变量,是保住它的标准手法。这个习惯在需要“先做点别的再判断”的场景里必不可少。
4.2 常见退出码的含义与自定义
虽然只有 0 到 255,但实际常用的就那么几个。系统层面有一些约定:
| 退出码 | 常见含义 |
|---|---|
| 0 | 成功 |
| 1 | 通用错误 |
| 2 | 用法错误(参数不对) |
| 126 | 命令找到了但无法执行(权限问题) |
| 127 | 命令未找到 |
| 128+n | 被信号 n 终止(如 130 是 Ctrl+C) |
你自己写脚本时,exit 1表示通用失败、exit 2表示用法错误,是比较通行的做法。关键是失败时要给出非 0 的退出码,否则调用方(可能是另一个脚本、CI 系统、监控)无法感知失败。我见过不少脚本在出错分支里只echo一句错误信息就结束了,退出码还是 0,导致上层以为一切正常,问题被掩盖很久。
if [ ! -f "$CONFIG_FILE" ]; then echo "配置文件不存在: $CONFIG_FILE" >&2 exit 1 fi注意这里把错误信息重定向到>&2(标准错误),这也是个好习惯:正常输出走 stdout,错误信息走 stderr,调用方可以分别处理。
4.3 set -e 与 $? 的微妙关系
很多人用set -e让脚本“遇错即停”,觉得这样就不用管$?了。但set -e有不少例外情况,比如命令出现在if条件里、&&/||链中、或者前面加了!,都不会触发退出。这意味着set -e不能完全替代显式的$?判断。
我的实践是:关键步骤显式判断$?,辅助步骤靠set -e兜底。比如部署脚本里“构建”和“上传”这两步必须显式检查,因为失败后要清理临时文件、回滚状态;而一些无关紧要的日志写入,靠set -e就够了。
set -e build_project if [ $? -ne 0 ]; then echo "构建失败,开始清理" >&2 cleanup exit 1 fi不过要注意,在set -e生效时,build_project失败会直接让脚本退出,根本走不到if。所以上面这段其实有矛盾——要么不用set -e而显式判断,要么用set -e但把清理逻辑放到trap里。这是个容易忽略的细节,我早期就写过这种“看起来在兜底、实际兜不住”的代码。
5. 实操:写一个把这组符号用全的参数解析脚本
5.1 需求与设计
光看定义不够,得有个能跑的完整例子。我设计一个小脚本,需求是:接收环境名、区域、可选的详细模式开关,支持-h帮助,参数不足时给出用法提示,任何一步失败都要有明确退出码。这个脚本会把$#、$0、$1~$n、$*、$@、$?全部用上。
设计思路:用while+case+shift解析选项,用$#做数量校验,用$0生成用法提示,用"$@"做参数透传演示,用$?检查关键命令。
5.2 完整脚本与逐段说明
#!/bin/bash set -u SCRIPT_NAME=$(basename "$0") usage() { echo "用法: $SCRIPT_NAME -e <环境> -r <区域> [-v]" echo " -e, --env 目标环境(必填)" echo " -r, --region 目标区域(必填)" echo " -v, --verbose 详细输出" echo " -h, --help 显示帮助" } ENV="" REGION="" VERBOSE=0 while [ $# -gt 0 ]; do case "$1" in -e|--env) if [ $# -lt 2 ]; then echo "错误: $1 需要一个值" >&2 exit 2 fi ENV="$2" shift 2 ;; -r|--region) if [ $# -lt 2 ]; then echo "错误: $1 需要一个值" >&2 exit 2 fi REGION="$2" shift 2 ;; -v|--verbose) VERBOSE=1 shift ;; -h|--help) usage exit 0 ;; *) echo "错误: 未知参数 $1" >&2 usage exit 2 ;; esac done if [ -z "$ENV" ] || [ -z "$REGION" ]; then echo "错误: 环境和区域都是必填项" >&2 usage exit 2 fi echo "环境: $ENV, 区域: $REGION, 详细模式: $VERBOSE" if [ "$VERBOSE" -eq 1 ]; then echo "原始参数列表: $*" echo "参数个数: $#" fi # 演示参数透传 run_task() { echo "执行任务,收到参数: $@" return 0 } run_task "$@" if [ $? -ne 0 ]; then echo "任务执行失败" >&2 exit 1 fi echo "全部完成" exit 0逐段拆一下关键点。开头set -u让引用未定义变量时报错,配合$#校验能挡住大部分“参数没传”的问题。SCRIPT_NAME=$(basename "$0")保证用法提示干净。while [ $# -gt 0 ]循环里,每个需要值的选项都先检查$# -lt 2,避免shift 2越界。"$@"在run_task调用处保持参数边界。$?在run_task之后立即判断,中间没有插入其他命令。
5.3 跑起来看效果
执行./demo.sh -e prod -r "us east" -v,输出大致是:
环境: prod, 区域: us east, 详细模式: 1 原始参数列表: -e prod -r us east -v 参数个数: 0 执行任务,收到参数: 全部完成注意两个细节:$*打印出来时us east被空格连接,看起来和原始输入一样,但已经无法区分“一个带空格的参数”和“两个参数”了,这正是"$*"的局限;$#在循环结束后是 0,因为所有参数都被shift消费掉了,所以“参数个数”那行显示 0——如果你想打印原始个数,得在循环前存一份。
这个“循环后$#变 0”的现象,是很多人第一次写参数解析时的困惑点。记住:shift会真实地减少$#,循环结束意味着参数被吃完了。
6. 常见问题与排查速查
6.1 高频翻车场景对照表
| 现象 | 可能原因 | 排查与修复 |
|---|---|---|
| 参数带空格被拆成多个 | 用了$@或$*没加引号 | 改用"$@" |
| 遍历参数只循环一次 | 用了"$*" | 改用"$@" |
$10取不到第 10 个参数 | 写成了$10 | 改成${10} |
$?判断永远成功 | 中间插入了其他命令 | 立即判断或先存变量 |
| 脚本失败但上层无感知 | 出错分支没exit非 0 | 补exit 1 |
$0显示成 bash | 脚本被 source 执行 | 用basename "$0"并注意 source 场景 |
shift 2报错退出 | 参数不足 2 个 | 先判断[ $# -ge 2 ] |
参数里的*被展开成文件名 | 变量没加引号触发 globbing | 加双引号 |
6.2 几个我踩过的坑
第一个坑是$?被echo覆盖。早期写脚本喜欢在命令后先echo一句“执行完毕”,再判断$?,结果判断永远通过。后来养成“命令后第一件事就是取$?”的习惯,再没犯过。
第二个坑是"$@"和$@混用。有次写转发脚本,本地测试参数都不带空格,用$@也没事;线上某个参数是带空格的路径,直接被拆散。从那以后转发场景一律"$@",不再图省事。
第三个坑是set -e和$?判断打架。写了set -e又在后面判断$?,结果命令失败时脚本已经退出了,判断逻辑根本没执行。后来改成关键步骤不用set -e、显式判断,或者用trap统一处理清理。
提示:调试参数问题时,在脚本开头加一行
printf '参数个数=%d\n' $#和printf '参数=[%s]\n' "$@",用方括号包住每个参数,能一眼看出参数边界和空格情况。这个技巧比echo好用得多,因为echo会吃掉多余空格,printf的%s配合方括号能精确保留。
6.3 一个容易被忽略的细节:IFS 对 "$*" 的影响
"$*"用IFS的第一个字符连接参数,默认是空格。如果你临时改了IFS,"$*"的连接符也会跟着变。比如IFS=,时,"$*"会变成a,b,c。这个特性偶尔有用(比如拼 CSV),但更多时候是隐患——如果你在脚本某处改了IFS忘了改回来,后面所有"$*"的输出都会变样。我的建议是:改IFS只在局部作用域内改,或者用完立刻恢复,别让它泄漏到全局。
7. 把这组符号用成条件反射
写到这里,这几个符号的边界应该比较清楚了。$#管数量、$0管自己、$1~$n管具体参数、"$@"管安全遍历和转发、$?管成败判断。真正拉开水平差距的不是背定义,而是引号习惯和判断时机——"$@"加引号、$?立即取,这两条做到了,脚本的健壮性会上一个台阶。
我个人的习惯是:新写一个脚本,先把参数校验和 usage 骨架搭好,$#判断、basename "$0"、"$@"转发这三样几乎是模板级的存在,闭着眼都会写。至于$*,除了打日志时拼一句话,基本不用。$?则是在每个“不能失败”的步骤后立刻接上判断,绝不拖到下一行之后。这些习惯不是一天养成的,但每踩一次坑就固化一点,慢慢就成了条件反射。