
参数可以让 Bash 脚本在每次运行时表现不同。与其把文件路径、用户名、环境名或端口号硬编码进脚本,不如在命令行上把这些值传入,再在脚本内部读取它们。
本指南介绍如何向 Bash 脚本传递参数、读取 $1 和 $2 这样的位置参数、通过 "$@" 处理全部参数、设置默认值、校验必需输入,以及使用 getopts 解析具名选项。
快速参考
如需可打印的快速参考,请参阅 Bash 速查表。
| 语法 | 说明 |
|---|---|
bash script.sh arg1 arg2 | 用两个参数运行脚本 |
$0 | 脚本名或路径 |
$1 , $2 | 第一和第二个位置参数 |
${10} | 第十个位置参数 |
$# | 传入的参数个数 |
"$@" | 所有参数作为独立的带引号单词 |
"$*" | 所有参数合并成一个字符串 |
"${1:-default}" | 第一个参数缺失时使用默认值 |
shift | 移除第一个位置参数,其余参数前移 |
getopts | 解析短格式具名选项,如 -p 3000 -v |
--name value | 用 while 和 case 循环读取的具名参数 |
位置参数
运行脚本时,Bash 会把每个参数赋值给一个带编号的变量:
#!/usr/bin/env bash
printf
'Hello, %s!\n'
"
$1
"
printf
'Argument count: %s\n'
"
$#
"
用一个参数运行该脚本:
bash greet.sh World
Hello, World!
Argument count: 1
这些特殊变量是:
| 变量 | 含义 |
|---|---|
$0 | 脚本名或路径 |
$1 , $2 , … | 第一、第二个……参数 |
${10} , ${11} , … | 第十个及之后的参数 |
$# | 传入的参数个数 |
$@ | 所有参数作为独立单词 |
$* | 所有参数作为一个字符串 |
注意 $10 并不会给出第十个参数。Bash 把它解析为 $1 后接字符 0,这几乎从来不是你想要的结果。一旦位置达到两位数,就把数字用花括号括起来写成 ${10}。
Bash 并没有以此命名的 argv 数组。位置参数起到了相同的作用,当你需要数组下标或切片时,可以用 args=("$@") 把它们复制进一个数组。在该数组中,${args[0]} 是第一个参数,而 $0 仍然保存着脚本名。
关于每个特殊参数的更多细节,请参阅 Bash 位置参数指南。
访问全部参数
使用 $@ 可以遍历每一个参数,而不管传入了多少个:
#!/usr/bin/env bash
for
arg in
"
$@
"
;
do
printf
'Argument: %s\n'
"
$arg
"
done
用三个参数运行它,其中一个包含空格:
bash list-args.sh alpha beta
"hello world"
Argument: alpha
Argument: beta
Argument: hello world
始终给 "$@" 加上引号,这样带空格的参数才会保持为单个值。不加引号的 $@ 会进行单词拆分,把 "hello world" 变成两个独立的单词。
$* 变量看起来和 $@ 可以互换,但两者一旦被加上引号,行为就不同了。"$@" 会展开为每个参数一个单词,而 "$*" 则把所有参数合并成一个由 IFS 的第一个字符(通常是空格)分隔的字符串:
#!/usr/bin/env bash
printf
'With "$@":\n'
for
arg in
"
$@
"
;
do
printf
' [%s]\n'
"
$arg
"
done
printf
'With "$*":\n'
for
arg in
"
$*
"
;
do
printf
' [%s]\n'
"
$arg
"
done
bash expand.sh one
"two three"
With "$@":
[one]
[two three]
With "$*":
[one two three]
注意第一个循环运行了两次,而第二个循环只运行了一次。只要你是把参数转交给别的东西,就用 "$@";只有当你确实想把参数当作一个可打印的字符串时,才保留 "$*"。
将参数传递给其他命令
包装脚本常常需要把收到的全部内容转交给另一个程序。把 "$@" 放在命令的位置上即可做到这一点,每个参数在另一端都会作为独立的单词到达:
#!/usr/bin/env bash
printf
'Running: %s\n'
"
$*
"
"
$@
"
bash run-logged.sh
echo
"hello world"
Running: echo hello world
hello world
该脚本利用两种形式的各自优势。"$*" 为日志消息生成一行可读的摘要,而 "$@" 则把 hello world 作为一个完整参数来运行命令。如果这里去掉 $@ 周围的引号,echo 收到的就会是两个参数而不是一个。
设置默认值
当某个参数可选时,用 ${N:-default} 给它赋一个默认值:
#!/usr/bin/env bash
ENV
=
${
1
:-
production
}
BRANCH
=
${
2
:-
main
}
printf
'Deploying %s to %s\n'
"
$BRANCH
"
"
$ENV
"
如果调用者不传任何参数,ENV 会变成 production,BRANCH 会变成 main。如果只传一个参数,ENV 使用那个值,而 BRANCH 回退到 main。
校验必需参数
当缺少必需参数时,提前退出并打印用法提示:
#!/usr/bin/env bash
if
[
$#
-lt
2
]
;
then
echo
"Usage:
$0
SOURCE DESTINATION"
>
&
2
exit
1
fi
SOURCE
=
$1
DEST
=
$2
printf
'Ready to back up %s to %s\n'
"
$SOURCE
"
"
$DEST
"
$# -lt 2 用于检查是否提供的参数少于两个。错误消息通过 >&2 送往标准错误(stderr),脚本以退出码 1 结束以表示失败。校验通过后,你就可以在真正的备份命令(例如 rsync)中放心地使用 $SOURCE 和 $DEST。
用 shift 遍历参数
shift 会移除第一个参数,并对剩余参数重新编号。当脚本需要接受数量不定的待处理项时,它非常有用:
#!/usr/bin/env bash
printf
'Script: %s\n'
"
$0
"
while
[
$#
-gt
0
]
;
do
printf
'Processing: %s\n'
"
$1
"
shift
done
每次调用 shift 都会把 $2 移入 $1、$3 移入 $2,依此类推,并使 $# 减 1。循环会一直持续到没有剩余参数为止。
使用 getopts 解析具名选项
对于带有可选标志的脚本,getopts 会以标准 Unix 命令的风格解析具名选项:
#!/usr/bin/env bash
PORT
=
8080
VERBOSE
=
0
usage
()
{
echo
"Usage:
$0
[-p PORT] [-v]"
>
&
2
}
while
getopts
":p:v"
opt
;
do
case
$opt
in
p
)
PORT
=
$OPTARG
;;
v
)
VERBOSE
=
1
;;
\?
)
usage
;
exit
1
;;
:
)
usage
;
exit
1
;;
esac
done
shift
$((
OPTIND
-
1
))
printf
'Starting server on port %s (verbose: %s)\n'
"
$PORT
"
"
$VERBOSE
"
选项字符串 ":p:v" 表示 -p 接受一个值,-v 是一个不带值的标志,而开头的冒号让脚本自己处理错误。$OPTARG 保存需要值的选项的取值。shift $((OPTIND - 1)) 会移除所有已解析的标志,使 $1、$2 等指向标志之后剩余的任意位置参数。
运行它:
bash server.sh -p
3000
-v
Starting server on port 3000 (verbose: 1)
关于选项字符串、OPTARG、OPTIND 以及错误处理的更深入讲解,请阅读 Bash getopts 指南。
具名参数(长格式)
getopts 内建命令只支持单字符选项,不支持长选项名。它会把 --user 这样的选项当作无效,而不会把 user 识别为名称。当你想要长格式的具名参数时,可以用 while 循环和 case 语句自己遍历位置参数:
#!/usr/bin/env bash
USER_NAME
=
""
MESSAGE
=
"No message"
while
[
$#
-gt
0
]
;
do
case
$1
in
--user
)
if
[
$#
-lt
2
]
||
[[
$2
==
--*
]]
;
then
echo
"Error: --user requires a value"
>
&
2
exit
1
fi
USER_NAME
=
$2
shift
2
;;
--user
=
*
)
USER_NAME
=
${
1
#*=
}
shift
;;
--message
)
if
[
$#
-lt
2
]
||
[[
$2
==
--*
]]
;
then
echo
"Error: --message requires a value"
>
&
2
exit
1
fi
MESSAGE
=
$2
shift
2
;;
--message
=
*
)
MESSAGE
=
${
1
#*=
}
shift
;;
*
)
echo
"Unknown option:
$1
"
>
&
2
exit
1
;;
esac
done
if
[
-z
"
$USER_NAME
"
]
;
then
echo
"Usage:
$0
--user NAME [--message TEXT]"
>
&
2
exit
1
fi
printf
'Sending to %s: %s\n'
"
$USER_NAME
"
"
$MESSAGE
"
每个选项都有两个分支,这样脚本就能同时接受用户期望的两种写法。--user 和 --message 分支会先检查是否还有另一个参数,且它不是一个新的长选项。随后它们从 $2 读取值,并用 shift 2 跳过这两个参数。等号分支则用 ${1#*=} 剥掉 = 号之前的所有内容,并且只使用一次 shift,因为选项和值占据的是同一个参数。
两种调用方式产生相同的结果:
bash notify.sh --user alice --message
"Build finished"
bash notify.sh --user
=
alice --message
=
"Build finished"
Sending to alice: Build finished
Sending to alice: Build finished
取值检查能防止缺失的值错误地消耗掉下一个选项。循环之后的检查让 --user 成为必需项,而 --message 保持可选,在你省略它时使用默认值。
故障排查
带空格的参数变成了多个独立的值——这几乎总是因为 $@ 或 $* 没有加引号。如果调用者传入 "hello world",脚本会把它当作一个参数接收。之后在循环或转交命令接收到值之前,不加引号地展开 $@ 或 $* 会先做单词拆分,把它变成 hello 和 world。凡是你要遍历或转交参数的地方,都把展开写成 "$@"。
$10 打印的是第一个参数后接一个零。Bash 把 $10 解析为紧跟字面字符 0 的 $1。第十个及之后的参数请使用 ${10}。
在 getopts 循环之后,标志仍然会出现在 $1 中。getopts 内建命令并不会移除它解析过的内容。在循环结束后立即加上 shift $((OPTIND - 1)),让剩余的位置参数从 $1 开始。
像 --verbose 这样的长选项会导致错误。getopts 内建命令不识别长选项名。它会把 --verbose 当作无效输入,而不是一个名为 verbose 的选项。请像上面那样手动解析长选项,或者使用外部的 GNU getopt 命令。
一个具名选项缺失了它的值——在读 $2 之前,先检查是否还有另一个参数,且它不是一个新的长选项。只有在那时才赋值并运行 shift 2。如果没有这些检查,脚本可能会把下一个选项当作值消耗掉,或者在选项位于末尾时无法前进。
结论
Bash 位置参数处理的是有序参数的简单情形,而 getopts 为更复杂的脚本增加了具名标志支持。先从位置参数入手,及早校验参数个数,为可选参数设置默认值,当脚本的选项逐渐增多时再使用 getopts。关于脚本执行与权限的更多内容,请参阅运行 Bash 脚本指南。