在 Bash 脚本中传递与使用命令行参数

参数可以让 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 脚本指南。