sh脚本

参考链接:

https://wangdoc.com/bash/index.html

控制多条命令的继发

命令的组合符&&||,多个命令之间的继发关系可以通过三种符号去控制。
Command1 && Command2 

上面命令的意思是,如果Command1命令运行成功,则继续运行Command2命令。

Command1 || Command2

上面命令的意思是,如果Command1命令运行失败,则继续运行Command2命令。

Command1 ; Command2

上面命令的意思是,不管Command1有没有成功 ,继续运行Command2命令。

type命令

利用type可以判断一个命令是内置命令还是外部程序

root@2010104-0289:~# type ls
ls is aliased to `ls --color=auto'
root@2010104-0289:~# type echo
echo is a shell builtin
root@2010104-0289:~# type go
go is /root/go/bin/go

type命令的-t参数,可以返回一个命令的类型:别名(alias),关键词(keyword),函数(function),内置命令(builtin)和文件(file)。

快捷键

  • Ctrl + L:清除屏幕并将当前行移到页面顶部。
  • Ctrl + C:中止当前正在执行的命令。
  • Shift + PageUp:向上滚动。
  • Shift + PageDown:向下滚动。
  • Ctrl + U:从光标位置删除到行首。
  • Ctrl + K:从光标位置删除到行尾。
  • Ctrl + D:关闭 Shell 会话。
  • :浏览已执行命令的历史记录。

模式扩展

Bash 一共提供八种扩展。

  • 波浪线扩展
  • ? 字符扩展
  • * 字符扩展
  • 方括号扩展
  • 大括号扩展
  • 变量扩展
  • 子命令扩展
  • 算术扩展

Bash 允许用户关闭扩展。

$ set -o noglob
# 或者
$ set -f

下面的命令可以重新打开扩展。

$ set +o noglob
# 或者
$ set +f

波浪线~扩展

波浪线~会自动扩展成当前用户的主目录。

$ echo ~
/home/me

~user表示扩展成用户user的主目录。

$ echo ~foo
/home/foo

$ echo ~root
/root

如果用户不存在的话。

$ echo ~nonExistedUser
~nonExistedUser

~+会扩展成当前所在的目录,等同于pwd命令。

$ cd ~/foo
$ echo ~+
/home/me/foo

?扩展

?字符代表文件路径里面的任意单个字符,不包括空字符。比如,Data???匹配所有Data后面跟着三个字符的文件名。

# 存在文件 a.txt 和 b.txt
$ ls ?.txt
a.txt b.txt

? 字符扩展属于文件名扩展,只有文件确实存在的前提下,才会发生扩展。如果文件不存在,扩展就不会发生。

# 当前目录有 a.txt 文件
$ echo ?.txt
a.txt

# 当前目录为空目录
$ echo ?.txt
?.txt

* 字符扩展

*字符代表文件路径里面的任意数量的任意字符,包括零个字符。

# 存在文件 a.txt、b.txt 和 ab.txt
$ ls *.txt
a.txt b.txt ab.txt

注意,*不会匹配隐藏文件(以.开头的文件),即ls *不会输出隐藏文件。

如果要匹配隐藏文件,需要写成.*

# 显示所有隐藏文件
$ echo .*

如果要匹配隐藏文件,同时要排除...这两个特殊的隐藏文件,可以与方括号扩展结合使用,写成.[!.]*

$ echo .[!.]*

注意,*字符扩展属于文件名扩展,只有文件确实存在的前提下才会扩展。如果文件不存在,就会原样输出。

# 当前目录不存在 c 开头的文件
$ echo c*.txt
c*.txt

*只匹配当前目录,不会匹配子目录。

# 子目录有一个 a.txt
# 无效的写法
$ ls *.txt

# 有效的写法
$ ls */*.tx

Bash 4.0 引入了一个参数globstar,当该参数打开时,允许**匹配零个或多个子目录。

[]方括号扩展

方括号扩展的形式是[...],只有文件确实存在的前提下才会扩展。如果文件不存在,就会原样输出。括号之中的任意一个字符。比如,[aeiou]可以匹配五个元音字母中的任意一个。

# 存在文件 a.txt 和 b.txt
$ ls [ab].txt
a.txt b.txt

# 只存在文件 a.txt
$ ls [ab].txt
a.txt

方括号扩展还有两种变体:[^...][!...]。它们表示匹配不在方括号里面的字符,这两种写法是等价的。比如,[^abc][!abc]表示匹配除了abc以外的字符。

# 存在 aaa、bbb、aba 三个文件
$ ls ?[!a]?
aba bbb

上面命令中,[!a]表示文件名第二个字符不是a的文件名,所以返回了ababbb两个文件。

注意,如果需要匹配[字符,可以放在方括号内,比如[[aeiou]。如果需要匹配连字号-,只能放在方括号内部的开头或结尾,比如[-aeiou][aeiou-]

方括号扩展有一个简写形式[start-end],表示匹配一个连续的范围。比如,[a-c]等同于[abc][0-9]匹配[0123456789]

# 存在文件 a.txt、b.txt 和 c.txt
$ ls [a-c].txt
a.txt
b.txt
c.txt

# 存在文件 report1.txt、report2.txt 和 report3.txt
$ ls report[0-9].txt
report1.txt
report2.txt
report3.txt
...

这种简写形式有一个否定形式[!start-end],表示匹配不属于这个范围的字符。比如,[!a-zA-Z]表示匹配非英文字母的字符。

$ echo report[!1–3].txt
report4.txt report5.txt

大括号扩展

大括号扩展{...}表示分别扩展成大括号里面的所有值,各个值之间使用逗号分隔。比如,{1,2,3}扩展成1 2 3

$ echo {1,2,3}
1 2 3

$ echo d{a,e,i,u,o}g
dag deg dig dug dog

$ echo Front-{A,B,C}-Back
Front-A-Back Front-B-Back Front-C-Back

注意,大括号扩展不是文件名扩展。它会扩展成所有给定的值,而不管是否有对应的文件存在。

另一个需要注意的地方是,大括号内部的逗号前后不能有空格。否则,大括号扩展会失效。

$ echo {1 , 2}
{1 , 2}

大括号可以嵌套。

$ echo {j{p,pe}g,png}
jpg jpeg png

$ echo a{A{1,2},B{3,4}}b
aA1b aA2b aB3b aB4b

大括号也可以与其他模式联用,并且总是先于其他模式进行扩展。

$ echo {cat,d*}
cat dawg dg dig dog doug dug

上面例子中,会先进行大括号扩展,然后进行*扩展。

大括号扩展有一个简写形式{start..end},表示扩展成一个连续序列。比如,{a..z}可以扩展成26个小写英文字母。

大括号扩展有一个简写形式{start..end},表示扩展成一个连续序列。比如,{a..z}可以扩展成26个小写英文字母。

$ echo {a..c}
a b c

$ echo d{a..d}g
dag dbg dcg ddg

$ echo {1..4}
1 2 3 4

$ echo Number_{1..5}
Number_1 Number_2 Number_3 Number_4 Number_5

这种简写形式支持逆序。

$ echo {c..a}
c b a

$ echo {5..1}
5 4 3 2 1

这种简写形式可以嵌套使用,形成复杂的扩展。

$ echo .{mp{3..4},m4{a,b,p,v}}
.mp3 .mp4 .m4a .m4b .m4p .m4v

大括号扩展的常见用途为新建一系列目录。

$ mkdir {2007..2009}-{01..12}

上面命令会新建36个子目录,每个子目录的名字都是”年份-月份“。

这个写法的另一个常见用途,是直接用于for循环。

for i in {1..4}
do
  echo $i
done

上面例子会循环4次。

如果整数前面有前导0,扩展输出的每一项都有前导0

$ echo {01..5}
01 02 03 04 05

$ echo {001..5}
001 002 003 004 005

这种简写形式还可以使用第二个双点号(start..end..step),用来指定扩展的步长。

$ echo {0..8..2}
0 2 4 6 8

上面代码将0扩展到8,每次递增的长度为2,所以一共输出5个数字。

多个简写形式连用,会有循环处理的效果。

$ echo {a..c}{1..3}
a1 a2 a3 b1 b2 b3 c1 c2 c3

变量扩展

Bash 将美元符号$开头的词元视为变量,将其扩展成变量值,

$ echo $SHELL
/bin/bash

变量名除了放在美元符号后面,也可以放在${}里面。

$ echo ${SHELL}
/bin/bash

${!string*}${!string@}返回所有匹配给定字符串string的变量名。

$ echo ${!S*}
SECONDS SHELL SHELLOPTS SHLVL SSH_AGENT_PID SSH_AUTH_SOCK

子命令扩展

$(...)可以扩展成另一个命令的运行结果,该命令的所有输出都会作为返回值。

$ echo $(date)
Tue Jan 28 00:01:13 CST 2020

上面例子中,$(date)返回date命令的运行结果。

算术扩展

$((...))可以扩展成整数运算的结果,详见《Bash 的算术运算》一章。

$ echo $((2 + 2))
4

字符类

[[:class:]]表示一个字符类,扩展成某一类特定字符之中的一个。常用的字符类如下。

  • [[:alnum:]]:匹配任意英文字母与数字
  • [[:alpha:]]:匹配任意英文字母
  • [[:blank:]]:空格和 Tab 键。
  • [[:cntrl:]]:ASCII 码 0-31 的不可打印字符。
  • [[:digit:]]:匹配任意数字 0-9。
  • [[:graph:]]:A-Z、a-z、0-9 和标点符号。
  • [[:lower:]]:匹配任意小写字母 a-z。
  • [[:print:]]:ASCII 码 32-127 的可打印字符。
  • [[:punct:]]:标点符号(除了 A-Z、a-z、0-9 的可打印字符)。
  • [[:space:]]:空格、Tab、LF(10)、VT(11)、FF(12)、CR(13)。
  • [[:upper:]]:匹配任意大写字母 A-Z。
  • [[:xdigit:]]:16进制字符(A-F、a-f、0-9)。

字符类的第一个方括号后面,可以加上感叹号!,表示否定。比如,[![:digit:]]匹配所有非数字。

$ echo [![:digit:]]*

字符类也属于文件名扩展,如果没有匹配的文件名,字符类就会原样输出。

注意:文件名可以使用通配符。

Bash 允许文件名使用通配符,即文件名包括特殊字符。这时引用文件名,需要把文件名放在单引号里面。

$ touch 'fo*'
$ ls
fo*

上面代码创建了一个fo*文件,这时*就是文件名的一部分。

转义

某些字符在bash中含有特殊含义:针对这些需要转义。

在echo中要打印不可打印的字符时,要增加-e参数。

$ echo a\tb
atb

$ echo -e "a\tb"
a        b

利用\,可以将一行命令写成多行。

单引号

Bash 允许字符串放在单引号或双引号之中,加以引用。

单引号用于保留字符的字面含义,各种特殊字符在单引号里面,都会变为普通字符,比如星号(*)、美元符号($)、反斜杠(\)等。

$ echo '*'
*

$ echo '$USER'
$USER

$ echo '$((2+2))'
$((2+2))

$ echo '$(echo foo)'
$(echo foo)

由于反斜杠在单引号里面变成了普通字符,所以如果单引号之中,还要使用单引号,不能使用转义,需要在外层的单引号前面加上一个美元符号($),然后再对里层的单引号转义。

# 不正确
$ echo it's

# 不正确
$ echo 'it\'s'

# 正确
$ echo $'it\'s'

不过,更合理的方法是改在双引号之中使用单引号。

$ echo "it's"
it's

双引号

双引号比单引号宽松,可以保留大部分特殊字符的本来含义,但是三个字符除外:美元符号($)、反引号(`)和反斜杠(\)。也就是说,这三个字符在双引号之中,会被 Bash 自动扩展。

$ echo "$SHELL"
/bin/bash

$ echo "`date`"
Mon Jan 27 13:33:18 CST 2020

$ echo "I'd say: \"hello!\""
I'd say: "hello!"

$ echo "\\"
\

双引号的另一个常见的使用场合是,文件名包含空格。这时就必须使用双引号,将文件名放在里面。

$ ls "two words.txt"

上面命令中,two words.txt是一个包含空格的文件名,否则就会被 Bash 当作两个文件。

双引号还有一个作用,就是保存原始命令的输出格式。

# 单行输出
$ echo $(cal)
一月 2020 日 一 二 三 四 五 六 1 2 3 ... 31

# 原始格式输出
$ echo "$(cal)"
      一月 2020
日 一 二 三 四 五 六
          1  2  3  4
 5  6  7  8  9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31

上面例子中,如果$(cal)不放在双引号之中,echo就会将所有结果以单行输出,丢弃了所有原始的格式。

变量

Bash 变量分成环境变量和自定义变量两类。

环境变量

环境变脸是Bash自带的变量,进入shell的时候就已经定义好了的,可以直接使用。通常是系统定义好的,也可以由用户从父shell传入子shell。

env命令或printenv命令,可以显示所有环境变量。

注意,Bash 变量名区分大小写,HOMEhome是两个不同的变量。

set命令可以显示所有变量(包括环境变量和自定义变量),以及所有的 Bash 函数。

创建变量

命名规则

  • 字母、数字和下划线字符组成。
  • 第一个字符必须是一个字母或一个下划线,不能是数字。
  • 不允许出现空格和标点符号。

变量声明的语法如下。

variable=value

Bash 没有数据类型的概念,所有的变量值都是字符串。

读取变量

读取变量的时候,直接在变量名前加上$就可以了。

$ foo=bar
$ echo $foo
bar

读取变量的时候,变量名也可以使用花括号{}包围,比如$a也可以写成${a}。这种写法可以用于变量名与其他字符连用的情况。

$ a=foo
$ echo $a_file

$ echo ${a}_file
foo_file

如果变量的值本身也是变量,可以使用${!varname}的语法,读取最终的值。

删除变量

unset命令用来删除一个变量

这个命令不是很好用,因为不存在的变量相当于空字符串。

所以删除变量可以把这个变量设置为空字符串。

输出变量

利用export可以用来向子Shell输出变量。

NAME=foo
export NAME

上面命令输出了变量NAME。变量的赋值和输出也可以在一个步骤中完成。

export NAME=value

上面命令执行后,当前 Shell 及随后新建的子 Shell,都可以读取变量$NAME

子 Shell 如果修改继承的变量,不会影响父 Shell。

# 输出变量 $foo
$ export foo=bar

# 新建子 Shell
$ bash

# 读取 $foo
$ echo $foo
bar

# 修改继承的变量
$ foo=baz

# 退出子 Shell
$ exit

# 读取 $foo
$ echo $foo
bar

特殊变量

$?

$?为上一个命令的退出码,用来判断上一个命令是否执行成功。返回值是0,表示上一个命令执行成功;如果是非零,上一个命令执行失败。

$ ls doesnotexist
ls: doesnotexist: No such file or directory

$ echo $?
1

上面例子中,ls命令查看一个不存在的文件,导致报错。$?为1,表示上一个命令执行失败。

$$

$$为当前Shell的进程ID。

$ echo $$
10662

这个特殊的变量可以用来命名临时文件。

LOGFILE=/tmp/output_log.$$

$_

$_为上一个命令的最后一个参数.

$ grep dictionary /usr/share/dict/words
dictionary

$ echo $_
/usr/share/dict/words

$!

$!为最近一个后台执行的异步命令的进程 ID。

$ firefox &
[1] 11064

$ echo $!
11064

上面例子中,firefox是后台运行的命令,$!返回该命令的进程 ID。

$0

表示当前shell的名称

$-

表示当前shell的启动参数

@和#

表示脚本的参数数量

变量的默认值

bash提供四个特殊的语法,跟变量的默认值有关,目的是保证变量不为空.

${varname:-word}

上面语法的含义是,如果变量varname存在且不为空,则返回它的值,否则返回word。它的目的是返回一个默认值,比如${count:-0}表示变量count不存在时返回0

${varname:=word}

上面语法的含义是,如果变量varname存在且不为空,则返回它的值,否则将它设为word,并且返回word。它的目的是设置变量的默认值,比如${count:=0}表示变量count不存在时返回0,且将count设为0

${varname:+word}

上面语法的含义是,如果变量名存在且不为空,则返回word,否则返回空值。它的目的是测试变量是否存在,比如${count:+1}表示变量count存在时返回1(表示true),否则返回空值。

${varname:?message}

上面语法的含义是,如果变量varname存在且不为空,则返回它的值,否则打印出varname: message,并中断脚本的执行。如果省略了message,则输出默认的信息“parameter null or not set.”。它的目的是防止变量未定义,比如${count:?"undefined!"}表示变量count未定义时就中断执行,抛出错误,返回给定的报错信息undefined!

上面四种语法如果用在脚本中,变量名的部分可以用到数字19,表示脚本的参数。

filename=${1:?"filename missing."}

上面代码出现在脚本中,1表示脚本的第一个参数。如果该参数不存在,就退出脚本并报错。

declare

declare命令可以声明一些特殊类型的变量,为变量设置一些限制,比如声明只读类型的变量和整数类型的变量。

它的语法形式如下。

declare OPTION VARIABLE=value

declare命令的主要参数(OPTION)如下。

  • -a:声明数组变量。
  • -f:输出所有函数定义。
  • -F:输出所有函数名。
  • -i:声明整数变量。
  • -l:声明变量为小写字母。
  • -p:查看变量信息。
  • -r:声明只读变量。
  • -u:声明变量为大写字母。
  • -x:该变量输出为环境变量。
-i

-i参数声明整数变量以后,可以直接进行数学运算。

# declare -i var1=2 var2=3
# echo $var1*$var2
2*3
# echo $(($var1*$var2))
6
# result=var1*var2
# echo $result
var1*var2
# declare -i result
# echo $result
var1*var2
# result=var1*var2
# echo $result
6

-x

-x参数等同于export命令,可以输出一个变量为子 Shell 的环境变量。

$ declare -x foo
# 等同于
$ export foo

-r

-r参数可以声明只读变量,无法改变变量值,也不能unset变量。

$ declare -r bar=1

$ bar=2
bash: bar:只读变量
$ echo $?
1

$ unset bar
bash: bar:只读变量
$ echo $?
1

上面例子中,后两个赋值语句都会报错,命令执行失败。

-u

-u参数声明变量为大写字母,可以自动把变量值转成大写字母。

$ declare -u foo
$ foo=upper
$ echo $foo
UPPER

-l

-l参数声明变量为小写字母,可以自动把变量值转成小写字母。

$ declare -l bar
$ bar=LOWER
$ echo $bar
lower

-p

-p参数输出变量信息。

$ foo=hello
$ declare -p foo
declare -- foo="hello"
$ declare -p bar
bar:未找到

上面例子中,declare -p可以输出已定义变量的值,对于未定义的变量,会提示找不到。

如果不提供变量名,declare -p输出所有变量的信息。

-f

-f参数输出当前环境的所有函数,包括它的定义。

-F

-F参数输出当前环境的所有函数名,不包含函数定义。

readonly 命令

readonly命令等同于declare -r,用来声明只读变量,不能改变变量值,也不能unset变量。

$ readonly foo=1
$ foo=2
bash: foo:只读变量
$ echo $?
1

let命令

let命令声明变量时,可以直接执行算术表达式。

$ let foo=1+2
$ echo $foo
3

上面例子中,let命令可以直接计算1 + 2

let命令的参数表达式如果包含空格,就需要使用引号。

$ let "foo = 1 + 2"

let可以同时对多个变量赋值,赋值表达式之间使用空格分隔。

$ let "v1 = 1" "v2 = v1++"
$ echo $v1,$v2
2,1

上面例子中,let声明了两个变量v1v2,其中v2等于v1++,表示先返回v1的值,然后v1自增。

字符串操作

获取字符串长度

${#varname}

例子

$ myPath=/home/cam/book/long.file.name
$ echo ${#myPath}
29

大括号{}是必需的,否则 Bash 会将$#理解成脚本的参数个数,将变量名理解成文本。

$ echo $#myvar
0myvar

上面例子中,Bash 将$#myvar分开解释了。

子字符串

${varname:offset:length}

上面语法的含义是返回变量$varname的子字符串,从位置offset开始(从0开始计算),长度为length

$ count=frogfootman
$ echo ${count:4:4}
foot

这种语法不能直接操作字符串,只能通过变量来读取字符串,并且不会改变原始字符串。变量前面的美元符号可以省略。

# 报错
$ echo ${"hello":2:3}

如果省略length,则从位置offset开始,一直返回到字符串的结尾。

$ count=frogfootman
$ echo ${count:4}
footman

上面例子是返回变量count从4号位置一直到结尾的子字符串。

如果offset为负值,表示从字符串的末尾开始算起。注意,负数前面必须有一个空格, 以防止与${variable:-word}的变量的设置默认值语法混淆。这时,如果还指定length,则length不能小于零。

$ foo="This string is long."
$ echo ${foo: -5}
long.
$ echo ${foo: -5:2}
lo

上面例子中,offset-5,表示从倒数第5个字符开始截取,所以返回long.。如果指定长度为2,则返回lo

搜索和替换

Bash 提供字符串搜索和替换的多种方法。

字符串头部的模式匹配

以下两种语法可以检查字符串开头,是否匹配给定的模式。如果匹配成功,就删除匹配的部分,返回剩下的部分。原始变量不会发生变化。

# 如果 pattern 匹配变量 variable 的开头,
# 删除最短匹配(非贪婪匹配)的部分,返回剩余部分
${variable#pattern}

# 如果 pattern 匹配变量 variable 的开头,
# 删除最长匹配(贪婪匹配)的部分,返回剩余部分
${variable##pattern}

上面两种语法会删除变量字符串开头的匹配部分(将其替换为空),返回剩下的部分。区别是一个是最短匹配(又称非贪婪匹配),另一个是最长匹配(又称贪婪匹配)。

匹配模式pattern可以使用*?[]等通配符。

$ myPath=/home/cam/book/long.file.name

$ echo ${myPath#/*/}
cam/book/long.file.name

$ echo ${myPath##/*/}
long.file.name

下面写法可以删除文件路径的目录部分,只留下文件名。

$ path=/home/cam/book/long.file.name

$ echo ${path##*/}
long.file.name

如果要将头部匹配的部分,替换成其他内容,采用下面的写法。

# 模式必须出现在字符串的开头
${variable/#pattern/string}

# 示例
$ foo=JPG.JPG
$ echo ${foo/#JPG/jpg}
jpg.JPG

字符串尾部的模式匹配

以下两种语法可以检查字符串结尾,是否匹配给定的模式。如果匹配成功,就删除匹配的部分,返回剩下的部分。原始变量不会发生变化。

# 如果 pattern 匹配变量 variable 的结尾,
# 删除最短匹配(非贪婪匹配)的部分,返回剩余部分
${variable%pattern}

# 如果 pattern 匹配变量 variable 的结尾,
# 删除最长匹配(贪婪匹配)的部分,返回剩余部分
${variable%%pattern}

上面两种语法会删除变量字符串结尾的匹配部分(将其替换为空),返回剩下的部分。区别是一个是最短匹配(又称非贪婪匹配),另一个是最长匹配(又称贪婪匹配)。

$ path=/home/cam/book/long.file.name

$ echo ${path%.*}
/home/cam/book/long.file

$ echo ${path%%.*}
/home/cam/book/long

基本和#一致,只是将#换成了$

任意位置的模式匹配

以下两种语法可以检查字符串内部,是否匹配给定的模式。如果匹配成功,就删除匹配的部分,换成其他的字符串返回。原始变量不会发生变化。

# 如果 pattern 匹配变量 variable 的一部分,
# 最长匹配(贪婪匹配)的那部分被 string 替换,但仅替换第一个匹配
${variable/pattern/string}

# 如果 pattern 匹配变量 variable 的一部分,
# 最长匹配(贪婪匹配)的那部分被 string 替换,所有匹配都替换
${variable//pattern/string}

下面的例子将分隔符从:换成换行符。

$ echo -e ${PATH//:/'\n'}
/usr/local/bin
/usr/bin
/bin
...

上面例子中,echo命令的-e参数,表示将替换后的字符串的\n字符,解释为换行符。

前面提到过,这个语法还有两种扩展形式。

# 模式必须出现在字符串的开头
${variable/#pattern/string}

# 模式必须出现在字符串的结尾
${variable/%pattern/string}

改变大小写

下面的语法可以改变变量的大小写。

# 转为大写
${varname^^}

# 转为小写
${varname,,}

下面是一个例子。

$ foo=heLLo
$ echo ${foo^^}
HELLO
$ echo ${foo,,}
hello

算数运算

算数表达式

((...))语法可以进行整数的算术运算。

$ ((foo = 5 + 5))
$ echo $foo
10

((...))会自动忽略内部的空格,所以下面的写法都正确,得到同样的结果。

$ ((2+2))
$ (( 2+2 ))
$ (( 2 + 2 ))

这个语法不返回值,命令执行的结果根据算术运算的结果而定。只要算术结果不是0,命令就算执行成功。

$ (( 3 + 2 ))
$ echo $?
0

如果算术结果为0,命令就算执行失败。

$ (( 3 - 3 ))
$ echo $?
1

这个语法只能计算整数,否则会报错。

~# echo  $((1.2+1))
-bash: 1.2+1: syntax error: invalid arithmetic operator (error token is ".2+1")

如果在$((...))里面使用字符串,Bash 会认为那是一个变量名。如果不存在同名变量,Bash 就会将其作为空值,因此不会报错。

$ foo=hello
$ hello=3
$ echo $(( foo + 2 ))
5

上面例子中,变量foo的值是hello,而hello也会被看作变量名。这使得有可能写出动态替换的代码。

进制

  • number:没有任何特殊表示法的数字是十进制数(以10为底)。
  • 0number:八进制数。
  • 0xnumber:十六进制数。
  • base#numberbase进制的数。
$ echo $((0xff))
255
$ echo $((2#11111111))
255

位运算

$ echo $((16>>2))
4

$ echo $((16<<2))
64

$ echo $((17&3))
1
$ echo $((17|3))
19
$ echo $((17^3))
18

逻辑运算

$ echo $((3 > 2))
1
$ echo $(( (3 > 2) || (4 <= 1) ))
1

$ a=0
$ echo $((a<1 ? 1 : 0))
1
$ echo $((a>1 ? 1 : 0))
0

赋值运算

算术表达式$((...))可以执行赋值运算。

$ foo=5
$ echo $((foo*=2))
10

求值运算

逗号,$((...))内部是求值运算符,执行前后两个表达式,并返回后一个表达式的值。

$ echo $((foo = 1 + 2, 3 * 4))
12
$ echo $foo
3

expr 命令

expr命令支持算术运算,可以不使用((...))语法。

$ expr 3 + 2
5

Bash行操作

光标移动

  • Ctrl + a:移到行首。
  • Ctrl + b:向行首移动一个字符,与左箭头作用相同。
  • Ctrl + e:移到行尾。
  • Ctrl + f:向行尾移动一个字符,与右箭头作用相同。

清除屏幕

Ctrl + l快捷键可以清除屏幕,即将当前行移到屏幕的第一行,与clear命令作用相同。

编辑操作

  • Ctrl + d:删除光标位置的字符(delete)。
  • Ctrl + w:删除光标前面的单词。
  • Ctrl + t:光标位置的字符与它前面一位的字符交换位置(transpose)。

使用Ctrl + d的时候,如果当前行没有任何字符,会导致退出当前 Shell,所以要小心。

  • Ctrl + k:剪切光标位置到行尾的文本。
  • Ctrl + u:剪切光标位置到行首的文本。
  • Ctrl + y:在光标位置粘贴文本。

操作历史

Bash 会保留用户的操作历史,即用户输入的每一条命令都会记录。退出当前 Shell 的时候,Bash 会将用户在当前 Shell 的操作历史写入~/.bash_history文件,该文件默认储存500个操作。

环境变量HISTFILE总是指向这个文件。

$ echo $HISTFILE
/home/me/.bash_history

$ echo Hello World
Hello World

$ echo Goodbye
Goodbye

$ !e
echo Goodbye
Goodbye

上面例子中,!e表示找出操作历史之中,最近的那一条以e开头的命令并执行。Bash 会先输出那一条命令echo Goodbye,然后直接执行。

同理,!echo也会执行最近一条以echo开头的命令。

$ !echo
echo Goodbye
Goodbye

$ !echo H
echo Goodbye H
Goodbye H

$ !echo H G
echo Goodbye H G
Goodbye H G

注意,!string语法只会匹配命令,不会匹配参数。所以!echo H不会执行echo Hello World,而是会执行echo Goodbye,并把参数H附加在这条命令之后。同理,!echo H G也是等同于echo Goodbye命令之后附加H G

感叹号!的快捷键如下。

  • !!:执行上一个命令。
  • !n:执行历史文件里面行号为n的命令。
  • !-n:执行当前命令之前n条的命令。
  • !string:执行最近一个以指定字符串string开头的命令。
  • !?string:执行最近一条包含字符串string的命令。
  • ^string1^string2:执行最近一条包含string1的命令,将其替换成string2

最后,按下Ctrl + r会显示操作历史,可以用方向键上下移动,选择其中要执行的命令。也可以键入命令的首字母,Shell 就会自动在历史文件中,查询并显示匹配的结果。

通过定制环境变量HISTTIMEFORMAT,可以显示每个操作的时间。

$ export HISTTIMEFORMAT='%F %T  '
$ history
1  2013-06-09 10:40:12   cat /etc/issue
2  2013-06-09 10:40:12   clear

上面代码中,%F相当于%Y - %m - %d%T相当于%H : %M : %S

只要设置HISTTIMEFORMAT这个环境变量,就会在.bash_history文件保存命令的执行时间戳。如果不设置,就不会保存时间戳。

如果不希望保存本次操作的历史,可以设置环境变量HISTSIZE等于0。

目录堆栈

cd -

Bash 可以记忆用户进入过的目录。默认情况下,只记忆前一次所在的目录,cd -命令可以返回前一次的目录。

# 当前目录是 /path/to/foo
$ cd bar

# 重新回到 /path/to/foo
$ cd -

上面例子中,用户原来所在的目录是/path/to/foo,进入子目录bar以后,使用cd -可以回到原来的目录。

Bash 脚本入门

Shebang 行

脚本的第一行通常是指定解释器,即这个脚本必须通过什么解释器执行。这一行以#!字符开头,这个字符称为 Shebang,所以这一行就叫做 Shebang 行。

#!后面就是脚本解释器的位置,Bash 脚本的解释器一般是/bin/sh/bin/bash

#!/bin/sh
# 或者
#!/bin/bash

如果 Bash 解释器不放在目录/bin,脚本就无法执行了。为了保险,可以写成下面这样。

#!/usr/bin/env bash

Shebang 行不是必需的,但是建议加上这行。如果缺少该行,就需要手动将脚本传给解释器。举例来说,脚本是script.sh,有 Shebang 行的时候,可以直接调用执行。

$ ./script.sh

如果没有 Shebang 行,就只能手动将脚本传给解释器来执行。

$ /bin/sh ./script.sh
# 或者
$ bash ./script.sh

env命令

env命令总是指向/usr/bin/env文件,或者说,这个二进制文件总是在目录/usr/bin

#!/usr/bin/env NAME这个语法的意思是,让 Shell 查找$PATH环境变量里面第一个匹配的NAME。如果你不知道某个命令的具体路径,或者希望兼容其他用户的机器,这样的写法就很有用。

/usr/bin/env bash的意思就是,返回bash可执行文件的位置,前提是bash的路径是在$PATH里面。其他脚本文件也可以使用这个命令。比如 Node.js 脚本的 Shebang 行,可以写成下面这样。

#!/usr/bin/env node

nv命令的参数如下。

  • -i, --ignore-environment:不带环境变量启动。
  • -u, --unset=NAME:从环境变量中删除一个变量。
  • --help:显示帮助。
  • --version:输出版本信息。

下面是一个例子,新建一个不带任何环境变量的 Shell。

$ env -i /bin/sh

注释

Bash 脚本中,#表示注释,可以放在行首,也可以放在行尾。

# 本行是注释
echo 'Hello World!'

echo 'Hello World!' # 井号后面的部分也是注释

脚本参数

调用脚本的时候,脚本文件名后面可以带有参数。

$ script.sh word1 word2 word3

脚本文件内部,可以使用特殊变量,引用这些参数。

  • $0:脚本文件名,即script.sh
  • $1~$9:对应脚本的第一个参数到第九个参数。
  • $#:参数的总数。
  • $@:全部的参数,参数之间使用空格分隔。
  • $*:全部的参数,参数之间使用变量$IFS值的第一个字符分隔,默认为空格,但是可以自定义。
#!/bin/bash
# script.sh

echo "全部参数:" $@
echo "命令行参数数量:" $#
echo '$0 = ' $0
echo '$1 = ' $1
echo '$2 = ' $2
echo '$3 = ' $3

执行结果如下。

$ ./script.sh a b c
全部参数:a b c
命令行参数数量:3
$0 =  script.sh
$1 =  a
$2 =  b
$3 =  c

用户可以输入任意数量的参数,利用for循环,可以读取每一个参数。

#!/bin/bash

for i in "$@"; do
  echo $i
done

shift 命令

shift命令可以改变脚本参数,每次执行都会移除脚本当前的第一个参数($1),使得后面的参数向前一位,即$2变成$1$3变成$2$4变成$3,以此类推。

while循环结合shift命令,也可以读取每一个参数。

#!/bin/bash

echo "一共输入了 $# 个参数"

while [ "$1" != "" ]; do
  echo "剩下 $# 个参数"
  echo "参数:$1"
  shift
done

上面例子中,shift命令每次移除当前第一个参数,从而通过while循环遍历所有参数。

shift命令可以接受一个整数作为参数,指定所要移除的参数个数,默认为1

shift 3

上面的命令移除前三个参数,原来的$4变成$1

getopts 命令

getopts命令用在脚本内部,可以解析复杂的脚本命令行参数,通常与while循环一起使用,取出脚本所有的带有前置连词线(-)的参数。

getopts optstring name

它带有两个参数。第一个参数optstring是字符串,给出脚本所有的连词线参数。getopts规定带有参数值的配置项参数,后面必须带有一个冒号(:)。

while getopts 'lha:' OPTION; do
  case "$OPTION" in
    l)
      echo "linuxconfig"
      ;;

    h)
      echo "h stands for h"
      ;;

    a)
      avalue="$OPTARG"
      echo "The value provided is $OPTARG"
      ;;
    ?)
      echo "script usage: $(basename $0) [-l] [-h] [-a somevalue]" >&2
      exit 1
      ;;
  esac
done
shift "$(($OPTIND - 1))"

# ./test_param.sh -lh -a aa
linuxconfig
h stands for h
The value provided is aa

配置项参数终止符 --

变量当作命令的参数时,有时希望指定变量只能作为实体参数,不能当作配置项参数,这时可以使用配置项参数终止符--

$ myPath="~/docs"
$ ls -- $myPath

上面例子中,--强制变量$myPath只能当作实体参数(即路径名)解释。

如果变量不是路径名,就会报错。

$ myPath="-l"
$ ls -- $myPath
ls: 无法访问'-l': 没有那个文件或目录

上面例子中,变量myPath的值为-l,不是路径。但是,--强制$myPath只能作为路径解释,导致报错“不存在该路径”。

exit 命令

exit命令后面可以跟参数,该参数就是退出状态。

# 退出值为0(成功)
$ exit 0

# 退出值为1(失败)
$ exit 1

source 命令

source命令用于执行一个脚本,通常用于重新加载一个配置文件。

$ source .bashrc

source命令最大的特点是在当前 Shell 执行脚本,不像直接执行脚本时,会新建一个子 Shell。所以,source命令执行脚本时,不需要export变量。

#!/bin/bash
# test.sh
echo $foo

上面脚本输出$foo变量的值。

# 当前 Shell 新建一个变量 foo
$ foo=1

# 打印输出 1
$ source test.sh
1

# 打印输出空字符串
$ bash test.sh

上面例子中,当前 Shell 的变量foo并没有export,所以直接执行无法读取,但是source执行可以读取。

source命令的另一个用途,是在脚本内部加载外部库。

#!/bin/bash

source ./lib.sh

function_from_lib

source有一个简写形式,可以使用一个点(.)来表示。

$ . .bashrc

别名,alias 命令

alias命令用来为一个命令指定别名,这样更便于记忆。下面是alias的格式。

alias NAME=DEFINITION

上面命令中,NAME是别名的名称,DEFINITION是别名对应的原始命令。注意,等号两侧不能有空格,否则会报错。

一个常见的例子是为grep命令起一个search的别名。

alias search=grep

alias也可以用来为长命令指定一个更短的别名。下面是通过别名定义一个today的命令。

$ alias today='date +"%A, %B %-d, %Y"'
$ today
星期一, 一月 6, 2020

有时为了防止误删除文件,可以指定rm命令的别名。

$ alias rm='rm -i'

上面命令指定rm命令是rm -i,每次删除文件之前,都会让用户确认。

alias定义的别名也可以接受参数,参数会直接传入原始命令。

$ alias echo='echo It says: '
$ echo hello world
It says: hello world

上面例子中,别名定义了echo命令的前两个参数,等同于修改了echo命令的默认行为。

指定别名以后,就可以像使用其他命令一样使用别名。一般来说,都会把常用的别名写在~/.bashrc的末尾。另外,只能为命令定义别名,为其他部分(比如很长的路径)定义别名是无效的。

直接调用alias命令,可以显示所有别名。

$ alias

unalias命令可以解除别名。

$ unalias lt

read命令

read命令的格式如下。

read [-options] [variable...]

上面语法中,options是参数选项,variable是用来保存输入数值的一个或多个变量名。如果没有提供变量名,环境变量REPLY会包含用户输入的一整行数据。

下面是一个例子demo.sh

#!/bin/bash
echo -n "输入一些文本 > "
read text
echo "你的输入:$text"

# ./read_sh.sh
输入一些文本 > huangzle
你的输入:huangzle

read可以接受用户输入的多个值。

#!/bin/bash
echo Please, enter your firstname and lastname
read FN LN
echo "Hi! $LN, $FN !"

上面例子中,read根据用户的输入,同时为两个变量赋值。

如果用户的输入项少于read命令给出的变量数目,那么额外的变量值为空。如果用户的输入项多于定义的变量,那么多余的输入项会包含到最后一个变量中。

如果read命令之后没有定义变量名,那么环境变量REPLY会包含所有的输入。

#!/bin/bash
# read-single: read multiple values into default variable
echo -n "Enter one or more values > "
read
echo "REPLY = '$REPLY'"

上面脚本的运行结果如下。

$ read-single
Enter one or more values > a b c d
REPLY = 'a b c d'

read命令除了读取键盘输入,可以用来读取文件。

while read myline
do
  echo "$myline"
done < $filename

上面的例子通过read命令,读取一个文件的内容。done命令后面的定向符<,将文件导向read命令,每次读取一行,存入变量myline,直到文件读取完毕。

参数

-t

read命令的-t参数,设置了超时的秒数。如果超过了指定时间,用户仍然没有输入,脚本将放弃等待,继续向下执行。

#!/bin/bash

echo -n "输入一些文本 > "
if read -t 3 response; then
  echo "用户已经输入了"
else
  echo "用户没有输入"
fi

上面例子中,输入命令会等待3秒,如果用户超过这个时间没有输入,这个命令就会执行失败。if根据命令的返回值,转入else代码块,继续往下执行。

环境变量TMOUT也可以起到同样作用,指定read命令等待用户输入的时间(单位为秒)。

$ TMOUT=3
$ read response

上面例子也是等待3秒,如果用户还没有输入,就会超时。

-p 参数

-p参数指定用户输入的提示信息。

read -p "Enter one or more values > "
echo "REPLY = '$REPLY'"

上面例子中,先显示Enter one or more values >,再接受用户的输入。

-a参数

-a参数把用户的输入赋值给一个数组,从零号位置开始。

$ read -a people
alice duchess dodo
$ echo ${people[2]}
dodo

上面例子中,用户输入被赋值给一个数组people,这个数组的2号成员就是dodo

-n 参数

-n参数指定只读取若干个字符作为变量值,而不是整行读取。

$ read -n 3 letter
abcdefghij
$ echo $letter
abc

上面例子中,变量letter只包含3个字母。

-e 参数

-e参数允许用户输入的时候,使用readline库提供的快捷键,比如自动补全。

echo Please input the path to the file:

read -e fileName

echo $fileName

上面例子中,read命令接受用户输入的文件名。这时,用户可能想使用 Tab 键的文件名“自动补全”功能,但是read命令的输入默认不支持readline库的功能。-e参数就可以允许用户使用自动补全。

其它参数
  • -d delimiter:定义字符串delimiter的第一个字符作为用户输入的结束,而不是一个换行符。
  • -r:raw 模式,表示不把用户输入的反斜杠字符解释为转义字符。
  • -s:使得用户的输入不显示在屏幕上,这常常用于输入密码或保密信息。
  • -u fd:使用文件描述符fd作为输入。

IFS变量

read命令读取的值,默认是以空格分隔。可以通过自定义环境变量IFS(内部字段分隔符,Internal Field Separator 的缩写),修改分隔标志。

IFS的默认值是空格、Tab 符号、换行符号,通常取第一个(即空格)。

如果把IFS定义成冒号(:)或分号(;),就可以分隔以这两个符号分隔的值,这对读取文件很有用。

#!/bin/bash
# read-ifs: read fields from a file

FILE=/etc/passwd

read -p "Enter a username > " user_name
file_info="$(grep "^$user_name:" $FILE)"

if [ -n "$file_info" ]; then
  IFS=":" read user pw uid gid name home shell <<< "$file_info"
  echo "User = '$user'"
  echo "UID = '$uid'"
  echo "GID = '$gid'"
  echo "Full Name = '$name'"
  echo "Home Dir. = '$home'"
  echo "Shell = '$shell'"
else
  echo "No such user '$user_name'" >&2
  exit 1
fi

上面例子中,IFS设为冒号,然后用来分解/etc/passwd文件的一行。IFS的赋值命令和read命令写在一行,这样的话,IFS的改变仅对后面的命令生效,该命令执行后IFS会自动恢复原来的值。

如果IFS设为空字符串,就等同于将整行读入一个变量。

条件判断

if

if是最常用的条件判断结构,只有符合给定条件时,才会执行指定的命令。它的语法如下。

if commands; then
  commands
[elif commands; then
  commands...]
[else
  commands]
fi

if关键字后面是主要的判断条件,elif用来添加在主条件不成立时的其他判断条件,else则是所有条件都不成立时要执行的部分。

if test $USER = "foo"; then
  echo "Hello foo."
else
  echo "You are not foo."
fi

ifthen写在同一行时,需要分号分隔。分号是 Bash 的命令分隔符。它们也可以写成两行,这时不需要分号。

if true
then
  echo 'hello world'
fi

if false
then
  echo 'it is false' # 本行不会执行
fi

除了多行的写法,if结构也可以写成单行。

$ if true; then echo 'hello world'; fi
hello world

注意,if关键字后面也可以是一条命令,该条命令执行成功(返回值0),就意味着判断条件成立。

$ if echo 'hi'; then echo 'hello world'; fi
hi
hello world

if后面可以跟任意数量的命令。这时,所有命令都会执行,但是判断真伪只看最后一个命令,即使前面所有命令都失败,只要最后一个命令返回0,就会执行then的部分。

$ if false; true; then echo 'hello world'; fi
hello world

test

if结构的判断条件,一般使用test命令,有三种形式。

# 写法一
test expression

# 写法二
[ expression ]

# 写法三
[[ expression ]]

上面三种形式是等价的,但是第三种形式还支持正则判断,前两种不支持。

上面的expression是一个表达式。这个表达式为真,test命令执行成功(返回值为0);表达式为伪,test命令执行失败(返回值为1)。注意,第二种和第三种写法,[]与内部的表达式之间必须有空格。

$ test -f /etc/hosts
$ echo $?
0

$ [ -f /etc/hosts ]
$  echo $?
0

上面的例子中,test命令采用两种写法,判断/etc/hosts文件是否存在,这两种写法是等价的。命令执行后,返回值为0,表示该文件确实存在。

实际上,[这个字符是test命令的一种简写形式,可以看作是一个独立的命令,这解释了为什么它后面必须有空格。

下面把test命令的三种形式,用在if结构中,判断一个文件是否存在。

# 写法一
if test -e /tmp/foo.txt ; then
  echo "Found foo.txt"
fi

# 写法二
if [ -e /tmp/foo.txt ] ; then
  echo "Found foo.txt"
fi

# 写法三
if [[ -e /tmp/foo.txt ]] ; then
  echo "Found foo.txt"
fi

文件判断

以下表达式用来判断文件状态。

  • [ -a file ]:如果 file 存在,则为true
  • [ -b file ]:如果 file 存在并且是一个块(设备)文件,则为true
  • [ -c file ]:如果 file 存在并且是一个字符(设备)文件,则为true
  • [ -d file ]:如果 file 存在并且是一个目录,则为true
  • [ -e file ]:如果 file 存在,则为true
  • [ -f file ]:如果 file 存在并且是一个普通文件,则为true
  • [ -g file ]:如果 file 存在并且设置了组 ID,则为true
  • [ -G file ]:如果 file 存在并且属于有效的组 ID,则为true
  • [ -h file ]:如果 file 存在并且是符号链接,则为true
  • [ -k file ]:如果 file 存在并且设置了它的“sticky bit”,则为true
  • [ -L file ]:如果 file 存在并且是一个符号链接,则为true
  • [ -N file ]:如果 file 存在并且自上次读取后已被修改,则为true
  • [ -O file ]:如果 file 存在并且属于有效的用户 ID,则为true
  • [ -p file ]:如果 file 存在并且是一个命名管道,则为true
  • [ -r file ]:如果 file 存在并且可读(当前用户有可读权限),则为true
  • [ -s file ]:如果 file 存在且其长度大于零,则为true
  • [ -S file ]:如果 file 存在且是一个网络 socket,则为true
  • [ -t fd ]:如果 fd 是一个文件描述符,并且重定向到终端,则为true。 这可以用来判断是否重定向了标准输入/输出错误。
  • [ -u file ]:如果 file 存在并且设置了 setuid 位,则为true
  • [ -w file ]:如果 file 存在并且可写(当前用户拥有可写权限),则为true
  • [ -x file ]:如果 file 存在并且可执行(有效用户有执行/搜索权限),则为true
  • [ file1 -nt file2 ]:如果 FILE1 比 FILE2 的更新时间最近,或者 FILE1 存在而 FILE2 不存在,则为true
  • [ file1 -ot file2 ]:如果 FILE1 比 FILE2 的更新时间更旧,或者 FILE2 存在而 FILE1 不存在,则为true
  • [ FILE1 -ef FILE2 ]:如果 FILE1 和 FILE2 引用相同的设备和 inode 编号,则为true

$FILE要放在双引号之中。这样可以防止$FILE为空,因为这时[ -e ]会判断为真。而放在双引号之中,返回的就总是一个空字符串,[ -e "" ]会判断为伪。

字符串判断

  • [ string ]:如果string不为空(长度大于0),则判断为真。
  • [ -n string ]:如果字符串string的长度大于零,则判断为真。
  • [ -z string ]:如果字符串string的长度为零,则判断为真。
  • [ string1 = string2 ]:如果string1string2相同,则判断为真。
  • [ string1 == string2 ] 等同于[ string1 = string2 ]
  • [ string1 != string2 ]:如果string1string2不相同,则判断为真。
  • [ string1 '>' string2 ]:如果按照字典顺序string1排列在string2之后,则判断为真。
  • [ string1 '<' string2 ]:如果按照字典顺序string1排列在string2之前,则判断为真。

注意,test命令内部的><,必须用引号引起来(或者是用反斜杠转义)。否则,它们会被 shell 解释为重定向操作符。

注意,字符串判断时,变量要放在双引号之中,比如[ -n "$COUNT" ],否则变量替换成字符串以后,test命令可能会报错,提示参数过多。另外,如果不放在双引号之中,变量为空时,命令会变成[ -n ],这时会判断为真。如果放在双引号之中,[ -n "" ]就判断为伪。

整数判断

  • [ integer1 -eq integer2 ]:如果integer1等于integer2,则为true
  • [ integer1 -ne integer2 ]:如果integer1不等于integer2,则为true
  • [ integer1 -le integer2 ]:如果integer1小于或等于integer2,则为true
  • [ integer1 -lt integer2 ]:如果integer1小于integer2,则为true
  • [ integer1 -ge integer2 ]:如果integer1大于或等于integer2,则为true
  • [ integer1 -gt integer2 ]:如果integer1大于integer2,则为true
INT=-5

if [ -z "$INT" ]; then
  echo "INT is empty." >&2
  exit 1
fi
if [ $INT -eq 0 ]; then
  echo "INT is zero."
else
  if [ $INT -lt 0 ]; then
    echo "INT is negative."
  else
    echo "INT is positive."
  fi
  if [ $((INT % 2)) -eq 0 ]; then
    echo "INT is even."
  else
    echo "INT is odd."
  fi
fi

正则表达式

[[ expression ]]这种判断形式,支持正则表达式。

[[ string1 =~ regex ]]

上面的语法中,regex是一个正则表示式,=~是正则比较运算符。

下面是一个例子。

#!/bin/bash

INT=-5

if [[ "$INT" =~ ^-?[0-9]+$ ]]; then
  echo "INT is an integer."
  exit 0
else
  echo "INT is not an integer." >&2
  exit 1
fi

上面代码中,先判断变量INT的字符串形式,是否满足^-?[0-9]+$的正则模式,如果满足就表明它是一个整数。

逻辑判断

通过逻辑运算,可以把多个test判断表达式结合起来,创造更复杂的判断。

  • AND运算:符号&&,也可使用参数-a
  • OR运算:符号||,也可使用参数-o
  • NOT运算:符号!

使用否定操作符!时,最好用圆括号确定转义的范围。

if [ ! \( $INT -ge $MIN_VAL -a $INT -le $MAX_VAL \) ]; then
    echo "$INT is outside $MIN_VAL to $MAX_VAL."
else
    echo "$INT is in range."
fi

上面例子中,test命令内部使用的圆括号,必须使用引号或者转义,否则会被 Bash 解释。

算数判断

Bash 还提供了((...))作为算术条件,进行算术运算的判断。

if ((3 > 2)); then
  echo "true"
fi

注意,算术判断不需要使用test命令,而是直接使用((...))结构。

如果算术计算的结果是非零值,则表示判断成立。这一点跟命令的返回值正好相反,需要小心。

case结构

case结构用于多值判断,可以为每个值指定对应的命令,跟包含多个elifif结构等价,但是语义更好。它的语法如下。

case expression in
  pattern )
    commands ;;
  pattern )
    commands ;;
  ...
esac

OS=$(uname -s)

case "$OS" in
  FreeBSD) echo "This is FreeBSD" ;;
  Darwin) echo "This is Mac OSX" ;;
  AIX) echo "This is AIX" ;;
  Minix) echo "This is Minix" ;;
  Linux) echo "This is Linux" ;;
  *) echo "Failed to identify this OS" ;;
esac

case的匹配模式可以使用各种通配符,下面是一些例子。

  • a):匹配a
  • a|b):匹配ab
  • [[:alpha:]]):匹配单个字母。
  • ???):匹配3个字符的单词。
  • *.txt):匹配.txt结尾。
  • *):匹配任意输入,通过作为case结构的最后一个模式。
#!/bin/bash

echo -n "输入一个字母或数字 > "
read character
case $character in
  [[:lower:]] | [[:upper:]] ) echo "输入了字母 $character"
                              ;;
  [0-9] )                     echo "输入了数字 $character"
                              ;;
  * )                         echo "输入不符合要求"
esac

ash 4.0之前,case结构只能匹配一个条件,然后就会退出case结构。Bash 4.0之后,允许匹配多个条件,这时可以用;;&终止每个条件块。

read -n 1 -p "Type a character > "
echo
case $REPLY in
  [[:upper:]])    echo "'$REPLY' is upper case." ;;&
  [[:lower:]])    echo "'$REPLY' is lower case." ;;&
  [[:alpha:]])    echo "'$REPLY' is alphabetic." ;;&
  [[:digit:]])    echo "'$REPLY' is a digit." ;;&
  [[:graph:]])    echo "'$REPLY' is a visible character." ;;&
  [[:punct:]])    echo "'$REPLY' is a punctuation symbol." ;;&
  [[:space:]])    echo "'$REPLY' is a whitespace character." ;;&
  [[:xdigit:]])   echo "'$REPLY' is a hexadecimal digit." ;;&
esac

循环

while循环

while循环有一个判断条件,只要符合条件,就不断循环执行指定的语句。

while condition; do
  commands
done

上面代码中,只要满足条件condition,就会执行命令commands。然后,再次判断是否满足条件condition,只要满足,就会一直执行下去。只有不满足条件,才会退出循环。

number=0
while [ "$number" -lt 10 ]; do
  echo "Number = $number"
  number=$((number + 1))
done

while的条件部分可以执行任意数量的命令,但是执行结果的真伪只看最后一个命令的执行结果。

$ while true; false; do echo 'Hi, looping ...'; done

上面代码运行后,不会有任何输出,因为while的最后一个命令是false

until 循环

until循环与while循环恰好相反,只要不符合判断条件(判断条件失败),就不断循环执行指定的语句。一旦符合判断条件,就退出循环。

until condition; do
  commands
done

for...in 循环

for...in循环用于遍历列表的每一项。

for variable in list
do
  commands
done

for i in word1 word2 word3; do
  echo $i
done

列表可以由通配符产生。

for i in *.png; do
  ls -l $i
done

上面例子中,*.png会替换成当前目录中所有 PNG 图片文件,变量i会依次等于每一个文件。

列表也可以通过子命令产生。

#!/bin/bash

count=0
for i in $(cat ~/.bash_profile); do
  count=$((count + 1))
  echo "Word $count ($i) contains $(echo -n $i | wc -c) characters"
done

上面例子中,cat ~/.bash_profile命令会输出~/.bash_profile文件的内容,然后通过遍历每一个词,计算该文件一共包含多少个词,以及每个词有多少个字符。

in list的部分可以省略,这时list默认等于脚本的所有参数$@。但是,为了可读性,最好还是不要省略,参考下面的例子。

for filename; do
  echo "$filename"
done

# 等同于

for filename in "$@" ; do
  echo "$filename"
done

for 循环

for (( expression1; expression2; expression3 )); do
  commands
done

for (( i=0; i<5; i=i+1 )); do
  echo $i
done

break,continue

for number in 1 2 3 4 5 6
do
  echo "number is $number"
  if [ "$number" = "3" ]; then
    break
  fi
done

select 结构

select结构主要用来生成简单的菜单。它的语法与for...in循环基本一致。

select name
[in list]
do
  commands
done

Bash 会对select依次进行下面的处理。

  1. select生成一个菜单,内容是列表list的每一项,并且每一项前面还有一个数字编号。
  2. Bash 提示用户选择一项,输入它的编号。
  3. 用户输入以后,Bash 会将该项的内容存在变量name,该项的编号存入环境变量REPLY。如果用户没有输入,就按回车键,Bash 会重新输出菜单,让用户选择。
  4. 执行命令体commands
  5. 执行结束后,回到第一步,重复这个过程。
select brand in Samsung Sony iphone symphony Walton
do
  echo "You have chosen $brand"
done

1) Samsung
2) Sony
3) iphone
4) symphony
5) Walton
#? 1
You have chosen Samsung
#? 1
You have chosen Samsung

Bash函数

函数和别名的区别:别名只适合封装简单的单个命令,函数则可以封装复杂的多行命令。

函数总是在当前 Shell 执行,这是跟脚本的一个重大区别,Bash 会新建一个子 Shell 执行脚本。如果函数与脚本同名,函数会优先执行。但是,函数的优先级不如别名,即如果函数与别名同名,那么别名优先执行。

Bash 函数定义的语法有两种。

# 第一种
fn() {
  # codes
}

# 第二种
function fn() {
  # codes
}

上面代码中,fn是自定义的函数名,函数代码就写在大括号之中。这两种写法是等价的。

# hello() {
>   echo "Hello $1"
> }
# hello wolld
Hello wolld

删除一个函数可以用unset命令

unset -f functionName

查看当前 Shell 已经定义的所有函数,可以使用declare命令。

$ declare -f #查看所有的函数
$ declare -F #查看所有函数名
$ declare -f functionName #查看特定函数名的定义

参数变量

函数体内可以使用参数变量,获取函数参数。函数的参数变量,与脚本参数变量是一致的。

  • $1~$9:函数的第一个到第9个的参数。
  • $0:函数所在的脚本名。
  • $#:函数的参数总数。
  • $@:函数的全部参数,参数之间使用空格分隔。
  • $*:函数的全部参数,参数之间使用变量$IFS值的第一个字符分隔,默认为空格,但是可以自定义。

下面是一个示例脚本test.sh

#!/bin/bash
# test.sh

function alice {
  echo "alice: $@"
  echo "$0: $1 $2 $3 $4"
  echo "$# arguments"

}

alice in wonderland

运行该脚本,结果如下。

$ bash test.sh
alice: in wonderland
test.sh: in wonderland
2 arguments

return 命令

return命令用于从函数返回一个值。函数执行到这条命令,就不再往下执行了,直接返回了。

function func_return_value {
  return 10
}

函数将返回值返回给调用者。如果命令行直接执行函数,下一个命令可以用$?拿到返回值。

$ func_return_value
$ echo "Value returned by function is: $?"
Value returned by function is: 10

只能返回数字

全局变量和局部变量,local命令

Bash 函数体内直接声明的变量,属于全局变量,整个脚本都可以读取。这一点需要特别小心。

fn () {
  foo=1
  echo "fn: foo = $foo"
}

fn
echo "global: foo = $foo"

$ bash test.sh
fn: foo = 1
global: foo = 1

函数体内不仅可以声明全局变量,还可以修改全局变量。

foo=1

fn () {
  foo=2
}

echo $foo

上面代码执行后,输出的变量$foo值为2。

函数里面可以用local命令声明局部变量。

# 脚本 test.sh
fn () {
  local foo
  foo=1
  echo "fn: foo = $foo"
}

fn
echo "global: foo = $foo"

上面脚本的运行结果如下。

$ bash test.sh
fn: foo = 1
global: foo =

上面例子中,local命令声明的$foo变量,只在函数体内有效,函数体外没有定义。

数组

创建数组

数组可以采用逐个赋值的方法创建。

ARRAY[INDEX]=value

上面语法中,ARRAY是数组的名字,可以是任意合法的变量名。INDEX是一个大于或等于零的整数,也可以是算术表达式。注意数组第一个元素的下标是0, 而不是1。

$ array[0]=val
$ array[1]=val
$ array[2]=val

数组也可以采用一次性赋值的方式创建。

ARRAY=(value1 value2 ... valueN)

# 等同于

ARRAY=(
  value1
  value2
  value3
)

采用上面方式创建数组时,可以按照默认顺序赋值,也可以在每个值前面指定位置。

$ array=(a b c)
$ array=([2]=c [0]=a [1]=b)

$ days=(Sun Mon Tue Wed Thu Fri Sat)
$ days=([0]=Sun [1]=Mon [2]=Tue [3]=Wed [4]=Thu [5]=Fri [6]=Sat)

只为某些值指定位置,也是可以的。

names=(hatter [5]=duchess alice)

上面例子中,hatter是数组的0号位置,duchess是5号位置,alice是6号位置。

没有赋值的数组元素的默认值是空字符串。

定义数组的时候,可以使用通配符。

$ mp3s=( *.mp3 )

上面例子中,将当前目录的所有 MP3 文件,放进一个数组。

read -a命令则是将用户的命令行输入,读入一个数组。

$ read -a dice

上面命令将用户的命令行输入,读入数组dice

读取数组

读取数组指定位置的成员,要使用下面的语法。

$ echo ${array[i]}     # i 是索引

读取所有的成员

$ foo=(a b c d e f)
$ echo ${foo[@]}
a b c d e f

这两个特殊索引配合for循环,就可以用来遍历数组。

for i in "${names[@]}"; do
  echo $i
done

@*放不放在双引号之中,是有差别的。

$ activities=( swimming "water skiing" canoeing "white-water rafting" surfing )
$ for act in ${activities[@]}; \
do \
echo "Activity: $act"; \
done

Activity: swimming
Activity: water
Activity: skiing
Activity: canoeing
Activity: white-water
Activity: rafting
Activity: surfing

上面的例子中,数组activities实际包含5个元素,但是for...in循环直接遍历${activities[@]},会导致返回7个结果。为了避免这种情况,一般把${activities[@]}放在双引号之中。

${activities[*]}放在双引号之中,所有元素就会变成单个字符串返回。

$ for act in "${activities[*]}"; \
do \
echo "Activity: $act"; \
done

所以,拷贝一个数组的最方便方法,就是写成下面这样。

$ hobbies=( "${activities[@]}" )

这种写法也可以用来为新数组添加成员。

$ hobbies=( "${activities[@]" diving )

默认位置

如果读取数组成员时,没有读取指定哪一个位置的成员,默认使用0号位置。

$ declare -a foo
$ foo=A
$ echo ${foo[0]}
A

上面例子中,foo是一个数组,赋值的时候不指定位置,实际上是给foo[0]赋值。

引用一个不带下标的数组变量,则引用的是0号位置的数组元素。

$ foo=(a b c d e f)
$ echo ${foo}
a
$ echo $foo
a

上面例子中,引用数组元素的时候,没有指定位置,结果返回的是0号位置。

数组的长度

要想知道数组的长度(即一共包含多少成员),可以使用下面两种语法。

${#array[*]}
${#array[@]}

提取数组的序号

${!array[@]}${!array[*]},可以返回数组的成员序号,即哪些位置是有值的。

$ arr=([5]=a [9]=b [23]=c)
$ echo ${!arr[@]}
5 9 23
$ echo ${!arr[*]}
5 9 23

${array[@]:position:length}的语法可以提取数组成员。

$ food=( apples bananas cucumbers dates eggs fajitas grapes )
$ echo ${food[@]:1:1}
bananas
$ echo ${food[@]:1:3}
bananas cucumbers dates

上面例子中,${food[@]:1:1}返回从数组1号位置开始的1个成员,${food[@]:1:3}返回从1号位置开始的3个成员。

如果省略长度参数length,则返回从指定位置开始的所有成员。

$ echo ${food[@]:4}
eggs fajitas grapes

上面例子返回从4号位置开始到结束的所有成员。

追加数组成员

数组末尾追加成员,可以使用+=赋值运算符。它能够自动地把值追加到数组末尾。否则,就需要知道数组的最大序号,比较麻烦。

$ foo=(a b c)
$ echo ${foo[@]}
a b c

$ foo+=(d e f)
$ echo ${foo[@]}
a b c d e f

删除数组

删除一个数组成员,使用unset命令。

$ foo=(a b c d e f)
$ echo ${foo[@]}
a b c d e f

$ unset foo[2]
$ echo ${foo[@]}
a b d e f

上面例子中,删除了数组中的第三个元素,下标为2。

删除成员也可以将这个成员设为空值。

$ foo=(a b c d e f)
$ foo[1]=''
$ echo ${foo[@]}
a c d e f

上面例子中,将数组的第二个成员设为空字符串,就删除了这个成员。

直接将数组变量赋值为空字符串,相当于删除数组的第一个成员。

unset ArrayName可以清空整个数组。

关联数组

关联数组使用字符串而不是整数作为数组索引。

declare -A可以声明关联数组。

declare -A colors
colors["red"]="#ff0000"
colors["green"]="#00ff00"
colors["blue"]="#0000ff"

整数索引的数组,可以直接使用变量名创建数组,关联数组则必须用带有-A选项的declare命令声明创建。

访问关联数组成员的方式,几乎与整数索引数组相同。

echo ${colors["blue"]}

set命令

set命令用来修改子 Shell 环境的运行参数,即定制环境。一共有十几个参数可以定制,官方手册有完整清单,本章介绍其中最常用的几个。

顺便提一下,如果命令行下不带任何参数,直接运行set,会显示所有的环境变量和 Shell 函数。

$ set

set -u

执行脚本的时候,如果遇到不存在的变量,Bash默认忽略它。

大多数情况下,这不是开发者想要的行为,遇到变量不存在,脚本应该报错,而不是一声不响的往下执行。

set -u就用来改变这种行为。脚本在头部加上它,遇到不存在的变量就会报错,并停止执行。

#!/usr/bin/env bash
set -u

echo $a
echo bar

set -x

默认情况下,脚本执行后,只输出运行结果,没有其他内容。如果多个命令连续执行,它们的运行结果就会连续输出。有时会分不清,某一段内容是什么命令产生的。

set -x用来在运行结果之前,先输出执行的那一行命令。

#!/usr/bin/env bash
set -x

echo bar

执行上面的脚本,结果如下。

$ bash script.sh
+ echo bar
bar

脚本当中如果要关闭命令输出,可以使用set +x

#!/bin/bash

number=1

set -x
if [ $number = "1" ]; then
  echo "Number equals 1"
else
  echo "Number does not equal 1"
fi
set +x

上面的例子中,只对特定的代码段打开命令输出。

bash错误处理

如果脚本里面有运行失败的命令(返回值非0),Bash 默认会继续执行后面的命令。

#!/usr/bin/env bash

foo
echo bar

上面脚本中,foo是一个不存在的命令,执行时会报错。但是,Bash 会忽略这个错误,继续往下执行。

$ bash script.sh
script.sh:行3: foo: 未找到命令
bar

可以看到,Bash 只是显示有错误,并没有终止执行。

这种行为很不利于脚本安全和除错。实际开发中,如果某个命令失败,往往需要脚本停止执行,防止错误累积。这时,一般采用下面的写法。

command || exit 1

上面的写法表示只要command有非零返回值,脚本就会停止执行。

如果停止执行之前需要完成多个操作,就要采用下面三种写法。

# 写法一
command || { echo "command failed"; exit 1; }

# 写法二
if ! command; then echo "command failed"; exit 1; fi

# 写法三
command
if [ "$?" -ne 0 ]; then echo "command failed"; exit 1; fi

另外,除了停止执行,还有一种情况。如果两个命令有继承关系,只有第一个命令成功了,才能继续执行第二个命令,那么就要采用下面的写法。

command1 && command2

set -e

上面这些写法多少有些麻烦,容易疏忽。set -e从根本上解决了这个问题,它使得脚本只要发生错误,就终止执行。

#!/usr/bin/env bash
set -e

foo
echo bar

执行结果如下。

$ bash script.sh
script.sh:行4: foo: 未找到命令

可以看到,第4行执行失败以后,脚本就终止执行了。

set -e根据返回值来判断,一个命令是否运行失败。但是,某些命令的非零返回值可能不表示失败,或者开发者希望在命令失败的情况下,脚本继续执行下去。这时可以暂时关闭set -e,该命令执行结束后,再重新打开set -e

set +e
command1
command2
set -e

上面代码中,set +e表示关闭-e选项,set -e表示重新打开-e选项。

还有一种方法是使用command || true,使得该命令即使执行失败,脚本也不会终止执行。

#!/bin/bash
set -e

foo || true
echo bar

上面代码中,true使得这一行语句总是会执行成功,后面的echo bar会执行。

set -o pipefail

set -e有一个例外情况,就是不适用于管道命令。

set -o pipefail用来解决这种情况,只要一个子命令失败,整个管道命令就失败,脚本就会终止执行。

其它参数

et命令还有一些其他参数。

  • set -n:等同于set -o noexec,不运行命令,只检查语法是否正确。
  • set -f:等同于set -o noglob,表示不对通配符进行文件名扩展。
  • set -v:等同于set -o verbose,表示打印 Shell 接收到的每一行输入。

上面的-f-v参数,可以分别使用set +fset +v关闭。

常见错误

编写 Shell 脚本的时候,一定要考虑到命令失败的情况,否则很容易出错。

#! /bin/bash

dir_name=/path/not/exist

cd $dir_name
rm *

改成这样

[[ -d $dir_name ]] && cd $dir_name && rm *

bash -x 参数

加上-x参数,执行每条命令之前,都会显示该命令。

$ bash -x script.sh
+ echo hello world
hello world

number=1
if [ $number = 1 ]; then
  echo "Number is equal to 1."
else
  echo "Number is not equal to 1."
fi

上面的脚本执行之后,会输出每一行命令。

$ trouble
+ number=1
+ '[' 1 = 1 ']'
+ echo 'Number is equal to 1.'
Number is equal to 1.

LINENO

变量LINENO返回它在脚本里面的行号。

#!/bin/bash

echo "This is line $LINENO"

执行上面的脚本test.sh$LINENO会返回3

$ ./test.sh
This is line 3

FUNCNAME

变量FUNCNAME返回一个数组,内容是当前的函数调用堆栈。该数组的0号成员是当前调用的函数,1号成员是调用当前函数的函数,以此类推。

BASH_SOURCE

变量BASH_SOURCE返回一个数组,内容是当前的脚本调用堆栈。该数组的0号成员是当前执行的脚本,1号成员是调用当前脚本的脚本,以此类推,跟变量FUNCNAME是一一对应关系。

BASH_LINENO

变量BASH_LINENO返回一个数组,内容是每一轮调用对应的行号。${BASH_LINENO[$i]}${FUNCNAME[$i]}是一一对应关系,表示${FUNCNAME[$i]}在调用它的脚本文件${BASH_SOURCE[$i+1]}里面的行号。

mktemp 命令,trap 命令

运行mktemp命令,就能生成一个临时文件。

Bash 脚本使用mktemp命令的用法如下。

#!/bin/bash
trap 'rm -f "$TMPFILE"' EXIT
TMPFILE=$(mktemp) || exit 1
echo "Our temp file is $TMPFILE"

参数

-d参数可以创建一个临时目录。

-p参数可以指定临时文件所在的目录。

-t参数可以指定临时文件的文件名模板,模板的末尾必须至少包含三个连续的X字符,表示随机字符,建议至少使用六个X。默认的文件名模板是tmp.后接十个随机字符。

$ mktemp -t mytemp.XXXXXXX
/tmp/mytemp.yZ1HgZV

trap命令用来在 Bash 脚本中响应系统信号。

trap的命令格式如下。

$ trap [动作] [信号1] [信号2] ...

trap命令响应EXIT信号的写法如下。

$ trap 'rm -f "$TMPFILE"' EXIT

上面命令中,脚本遇到EXIT信号时,就会执行rm -f "$TMPFILE"

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270