Bash Shell 脚本编程实践
Shell
既是一套命令行工具(交互式地解释和执行用户输入的命令)也是一种脚本设计语言(定义有变量与参数,并提供了控制、循环、分支结构)。Bash
Shell 是由 GUN 官方项目提供的 Shell 解释器,名称源自于
Bourne Again SHell
的英文缩写,整合了传统 Korn Shell 以及 C
Shell
的有效特性,并且尽量遵循IEEE POSIX P1003.2/ISO 9945.2
规范,同时在编程与交互使用方面提供了大量的功能改进,因而在提供丰富功能的基础之上,展现出了良好的兼容特性,大多数.sh
脚本可以无需移植修改即可交由
Bash Shell 来执行。
当用户登入任意一款 Linux
操作系统时,初始化程序init
都将会为用户启动一个Bash
Shell命令解析器,其即可以用于解析命令行输入并与内核进行交互,也可以作为高效的脚本编程语言,运用其提供的变量、参数、循环、分支等编程语法特性,完成一些批量的自动化的任务处理工作,本文将会围绕
Bash Shell 的脚本编程特性,加以进行详细的分析、说明与示例。
基础概念
Shell 脚本的解释过程就是从文件读入字符流,然后进行处理,最后将结果传送至某个文件,所以交互式 Shell 命令与 Shell 脚本在本质上并没有区别,只是 Shell 命令的输入是标准输入,输出是标准输出。
Shell 脚本的注释以#
符号开始,一直到行末结束,例如可以在
Shell
命令行中输入以#
开头的命令,则该命令将会被作为注释而忽略处理。
按下【Ctrl + D】组合键将会在标准输入上产生一个文件结尾,因此在 Shell 命令当中可以使用该组合键直接退出 Shell 命令行。
Sha-Bang
Sha-Bang 是 Shell
脚本开头字符#!
连在一起的读音(Sharp Bang),当 Shell 文件被
Linux
系统读取时,内核会通过#!
表示的值(0x23, 0x21
)识别出随后的解释器路径并调用,最后再将整个脚本内容传递给解释器。由于
Shell 当中#
字符同时表示注释,因此 Shell
解释脚本文件时会自动忽略该行。本文讨论的 Shell
脚本通常以#!/bin/sh
或者#!/bin/bash
开头,表示当前使用的解释器为
Dash Shell 或者 Bash
Shell。本文所涉及的代码都基于#!/bin/bash
路径下的Bash
Shell。
1 | pi@raspberrypi:~ $ echo $SHELL |
接下来,编写一个 Bash Shell 版本的 Hello World 程序:
执行方式
Shell 脚本的执行主要存在如下 5 种方式:
- 将拥有执行权限的脚本作为命令调用,例如:
./hello.sh
; - 显式使用 Shell
程序,将脚本文件作为参数来执行,例如:
sh hello.sh
; - 将脚本文件重定向至 Shell
程序的标准输入,例如:
sh < hello.sh
; - 通过管道符将脚本内容输出至 Shell
程序的标准输入,例如:
cat hello.sh | sh
; - 使用
source
命令执行,例如:source hello.sh
;
字符串与引号
Shell 解释器采用了字符流过滤器模型,简而言之,就是一条命令将结果送到标准输出,这个标准输出被连接到下一条命令的标准输入,每条命令的输出结果都是自己处理之后的字符流,接受的输入都是需要进行处理的字符流,所以字符串是 Shell 当中非常重要的组成部分。
Shell
当中存在'
、"
、`
三种引号类型,其具体使用区别分别如下所示:
- 单引号
'
当中的字符串 Shell 不会进行处理,仅在需要保持字符串原样不变的时候使用; - 双引号
"
当中的字符串 Shell 会进行处理,如果其中包含有可以求值的部分(变量、表达式、命令),则会被 Shell 替换为相应的求值结果; - 反引号
`
用于引用一条 Linux 命令,其作用是将该命令的执行结果输出,效果类似于"$()"
;
特殊字符
*
和?
都是通配符,前者匹配任意个字符,后者仅匹配一个字符;:
表示空命令,其返回值恒为0
,循环语句当中,可以与true
命令等价;;
是分行符,标识一行命令结束,可以通过它将多条命令编写在一行;$
可以用于获取变量或者表达式的值,结合大括号${}
使用,可以在变量出现在字符串当中时,不与字符串内容相混淆;结合小括号1
2
3pi@raspberrypi:~ $ writer=Hank
pi@raspberrypi:~ $ echo ${writer} is the author of this blog
Hank is the author of this blog$()
可以取一个命令的值作为字符串内容,其效果与反引号`
相同; 通过双小括号$(())
可以取得一个数学表达式的计算结果,例如在使用*
运算符计算一个乘积;.
句点符号,等效于source
命令,可用于在 Shell 进程上调用脚本;\
反斜线表示转义符,是一种引用单个字符的方法,也可以用于 Shell 命令的换行;空格作为参数命令的做分隔符,例如:
touch a b
会创建a
和b
两个文件,而touch c\ d
则只会创建一个名为'c d'
文件;
内/外部命令
外部命令:Shell
的绝大多数命令如同/bin/ls
一样,是一个独立的外部可执行程序。当外部命令被调用时,本质就是调用了另外一个程序,首先
Shell 会创建子进程,然后在子进程当中运行该程序;
内部命令:内建在 Shell 程序当中,由 Shell
软件内部进行实现的命令,例如:cd
、source
、export
、time
等,它们都运行在
Shell 进程当中。
注意:如果希望脚本能够改变当前 Shell
自身的一些属性,则必须在 Shell
进程内执行调用。例如修改/etc/profile
、~/.profile
、~/.bashrc
环境变量之后,必须使用source
命令执行它们,以使其生效。
1 | pi@raspberrypi:~ $ source /etc/profile |
重定向
Shell 的设计哲学是字符流 + 过滤器,即将一个程序的输出,作为另一个程序的输入,这样就能将各种用途简单的小工具组合起来,完成一些看起来不可思议的功能。
默认情况下,Linux 当中的每一个进程都拥有 3 个特殊的文件描述指针:
- 标准输入:Standard
Input,文件描述指针为
0
; - 标准输出:Standard
Output,文件描述指针为
1
; - 标准错误输出:Standard
Eror,文件描述指针为
2
;
IO 重定向就是捕捉命令、程序、脚本甚至代码块的输出,然后将其作为输入传递给另外的文件、命令、程序、脚本。
输出重定向
输出重定向符号>
和>>
,可以将标准输出重定向至一个文件当中,如果该文件不存在则创建文件。其中,前者>
会覆盖原文件内容:
1 | pi@raspberrypi:~ $ echo "this is line 1">output.txt |
后者>>
则会在原文件尾部追加新的内容:
1 | pi@raspberrypi:~ $ echo "this is line 1">>output.txt |
输入重定向
输出重定向符号<
和<<
,用于将标准输入重定向至一个文件。如果<
后跟着一个
Shell 脚本文件,则相当于将.sh
脚本中的命令逐条输入至 Shell
程序当中执行:
1 | pi@raspberrypi:~ $ cat<hello.sh |
<<
可以用于 Here Document,
即将文本直接写在 Shell 脚本之中,并以添加终止符EOF
(即
Linux
系统读取至文件结尾时所返回的信号值-1
),该文本相当于一份独立的文件内容,例如:执行下面的hello.sh
脚本以后:
1 | #!/bin/bash |
将会动态生成一个hello.c
源文件,然后编译产生二进制文件hello
,最后执行并且展示结果,同时删除新生成的
2 个文件。
1 | pi@raspberrypi:~ $ ./hello.sh |
注意:Here Document 通常用于进行复杂的多行文本输入时,从而代替
echo
命令繁琐的硬编码操作。
管道
管道符|
用于连接 Linux
命令,前一条命令的标准输出会成为下一条命令的标准输入。管道的最大特点在于是管道符|
两边分别属于不同的进程。例如:从dmesg
输出的内核日志信息中,通过grep
查找
USB 相关的内容。
1 | pi@raspberrypi:~ $ dmesg | grep USB |
常量与变量
Shell
支持多种进制的整型常量,例如以0
开头的八进制,以0x
开头的十六进制。对于非八进制、十进制、十六进制的整数,可以表示为进制#数字
格式,例如:三进制数(120)₃
可以表示为3#120
,转换为十进制值为15
。
Shell
中的变量在使用前不需要声明,赋值时可以直接使用变量名,且赋值的等号=
两边不能有空格。变量定义之后,引用变量时一定需要使用$
符号。
1 | pi@raspberrypi:~ $ writer=Hank |
Shell
变量没有类型,例如annum=2020
,既可以作为十进制整数2020
直接参与算术运算,也可以作为字符串来进行处理。
1 | # 使用 let 计算一个算术表达式并且赋值给变量 |
Shell 变量有作用域,默认为对整个 Shell
文件有效的全局变量。局部变量则需要使用local
关键字进行声明,其只在声明所在的块或者函数当中可见。
1 | #!/bin/bash |
1 | pi@raspberrypi:~ $ ./test.sh |
?
问号也是一个变量,通过$?
可以引用上一条命令的返回值,但是该值只能使用一次,使用完以后就会被目前命令的返回值所替换。
1 | pi@raspberrypi:~ $ false |
环境变量
环境变量是可以改变 Shell
行为的变量,每个进程都拥有各自的环境变量,以用于保存进程相关的各种信息。环境变量的定义通常都是约定俗成的,例如:PATH
定义了
Shell 进程查找命令程序的路径。
1 | pi@raspberrypi:~ $ echo $PATH |
Shell
当中的任何变量都可以通过export
导出为环境变量,环境变量可以被子进程继承,因此也可以被视为父子进程信息传递的一种方式。
1 | pi@raspberrypi:~ $ export PATH="$PATH:/workspace" |
位置参数
位置参数是指调用 Shell
脚本时,按照命令行位置进行引用的参数。脚本当中按照$0
、$1
、$2
的顺序逐个进行引用,依此类推。其中$0
就代表命令本身。
命令行参数相关的特殊变量还有$#
、$*
、$@
,其使用方法如下所示:
$#
:代表命令行参数的个数;$*
:代表全部命令行参数,全部参数作为一个字符串;$@
:代表所有命令行参数,每个参数都是一个独立的字符串;
1 | #!/bin/bash |
1 | pi@raspberrypi:~ $ ./parameter.sh 1 2 3 |
操作符
Shell
当中的每一条命令同时也是一个逻辑表达式,其返回值为0
表示真,返回值为非0
表示假,该值本质上就是当前命令所对应main()
函数的返回值,可以通过$?
来进行获取。Shell
支持基本的数学运算符号以及各种逻辑操作符。
数学运算符包括+
、-
、*
、/
、%
以及幂运算**
,Bash
Shell
本身只支持整数运算,如果需要使用到浮点运算,则可以调用bc
和dc
等外部命令。
逻辑操作符包括&&
和||
,分别代表逻辑与和逻辑或。对于逻辑与&&
而言,如果左侧表达式为false
,则右侧表达式无需执行即可确定整个表达式的结果为false
;
对于逻辑或||
而言,如果左侧表达式为true
,则右侧表达式无需执行即可确定整个表达式的结果为true
;
脚本返回值
通常情况下,Shell
脚本在最后都应该拥有一个返回值,如果未显式的通过exit
指定返回值,则默认使用脚本最后一条命令的返回值;
函数
Shell 脚本当中的函数有 2
种定义方法,其中一种是通过function
关键字进行定义:
另外一种与 C 语言当中函数的定义方式相类似,这种方式可移植性更好,更加推荐使用:
Shell
当中的函数必须在其被调用之前完整的进行定义,调用函数时直接通过函数名称function_name
直接调用即可;
1 | #!/bin/bash |
条件测试
Shell 提供了一系列条件测试运算符,用于判断某种条件是否成立,条件测试运算符主要包含如下 3 种:
文件测试
文件测试通常用于判断文件属性,常用的文件测试条件如下所示:
条件 | 含义 | 示例 |
---|---|---|
-e 或-a |
文件存在(-a 已弃用) |
[ -e ~/.bashrc ] |
-f |
普通文件 | [ -f ~/.profile ] |
-s |
文件长度不为0 |
[ -s /etc/mtab ] |
-d |
文件是目录 | [ -d /etc ] |
-b |
文件是块设备文件 | [ -b /dev/sda ] |
-c |
文件是字符设备 | [ -c /dev/ttyS0 ] |
-p |
文件是管道 | [ -p /tmp/fifo ] |
-h/-L |
文件是符号链接 | [ -L /etc/mtab ] |
-S |
文件是 Socket | [ -S /tmp/socket ] |
-t |
是否为关联到终端的文件描述符 | [ -t /dev/stdout ] |
-r |
文件可读 | [ -r ~/.bashrc ] |
-w |
文件可写 | [ -w ~/.profile ] |
-x |
文件可执行 | [ -x /bin/ls ] |
-g |
文件有 SGID 标识 | [ -g /bin/su ] |
-u |
文件有 SUID 标识 | [ -u /usr/bin/sudo ] |
-k |
具有粘滞位 | [ -k /tmp ] |
-O |
测试者是文件拥有者 | [ -O ~/.bashrc ] |
-G |
文件的组 ID 与测试者相同 | [ -G ~/.profile ] |
-N |
文件从最后一次查看到现在,是否有被修改过 | [ -N ~/.profile ] |
file1 -nt file2 |
文件file1 比文件file2 更新 |
[ ~/.bashrc –nt ~/.profile ] |
file1 -ot file2 |
文件file1 比文件file2 更旧 |
[ ~/.bashrc –ot ~/.profile ] |
file1 -ef file2 |
file1 和file2 都是同一个文件的硬链接 |
[ /usr/bin/test -ef /usr/bin/\[ ] |
! |
取反测试结果,如果没有条件则返回true |
[ ! -d ~/.profile ] |
整数比较
条件 | 含义 | 示例 |
---|---|---|
-eq |
等于 | [ "$m" -eq "$n" ] |
-ne |
不等于 | [ "$m" -ne "$n" ] |
-gt |
大于 | [ "$m" -gt "$n" ] |
-ge |
大于等于 | [ "$m" -ge "$n" ] |
-lt |
小于 | [ "$m" -lt "$n" ] |
-le |
小于等于 | [ "$m" -le "$n" ] |
< |
小于,需要以(()) 方式测试 |
(( "$m" < "$n")) |
<= |
小于等于,需要以(()) 方式测试 |
(( "$m" <= "$n")) |
> |
大于,需要以(()) 方式测试 |
(( "$m" > "$n")) |
>= |
大于等于,需要以(()) 方式测试 |
(( "$m" >= "$n")) |
字符串比较
条件 | 含义 | 示例 |
---|---|---|
= 或== |
相等,== 在[] 和[[]] 里的行为可能会表现不同 |
[ "$str1" = "$str2" ] |
!= |
不相等 | [ "$str1" != "$str2" ] |
> |
大于,按照 ASCII
顺序进行比较,在[] 中使用时需要转义为\> |
[ "$str1" \> "$str2" ] |
< |
小于,按照 ASCII
顺序进行比较,在[] 中使用时需要转义为\< |
[ "$str1" \< "$str2" ] |
-z |
长度为 0 |
[ -z "$str" ] |
-n |
长度不为
0 ,在[] 当中使用时,需要将字符串放入"" 里面 |
[ -n "$str" ] |
混合比较
条件测试还支持在多个表达式之间进行逻辑运算,其中-a
表示与运算,-o
表示或运算。下面的示例用于测试命令行参数提供的整数是否介于0 ~ 100
之间,若位于该区间范围输出yes
,不在则向控制台输出no
。
1 | #!/bin/bash |
1 | pi@raspberrypi:~ $ ./compare.sh 0 |
条件判断
if then
根据if
表达式的逻辑值,决定是否执行then
里的内容。通常if
会与条件测试表达式一同使用,但也可以结合其它命令或者函数。最后,if
需要通过fi
结束条件流程。
如果if
与then
编写在相同的行,则需要额外再添加一个;
分号:
下面代码当中,仅当if
后面的表达式为true
时,then
里的echo
命令才会得到执行。
if then else
条件流程控制语句还可以拥有一个else
分支,用于条件不成立的情况。
这样then
和else
后面各有一个代码块,根据if
后面表达式的逻辑值来决定具体执行哪个,下面是一个具体的示例:
1 | #!/bin/bash |
if then elif else
如果存在多个并列并且互斥的条件,则可用采用elif
来依次判断条件:
1 | if 条件1; then |
程序会依次测试每一个条件,如果条件n
符合,则执行代码块n
,如果所有条件均不符合,则执行最后的else
分支(非必须)。
1 | #!/bin/bash |
1 | pi@raspberrypi:~ $ ./test.sh 1 |
循环结构
Bash Shell
支持for
、while
、until
三种不同类型的循环,其循环体当中的内容必须包含在do
和done
语句之间。
for 循环
for
循环的列表
是一个由空格分隔的字符串列表,支持通配符。如果缺省,则会自动使用当前的命令行参数列表$@
。
下面的示例会根据输入的参数,分别循环打印工作日和非工作日:
1 | #!/bin/bash |
1 | pi@raspberrypi:~ $ ./week.sh |
列表
当中的通配符会被 Shell
展开,下面示例脚本当中,*.c
会被展开为当前目录下所有.c
后缀的非隐藏文件:
Bash Shell 同时也通过双小括号(( ))
支持 C
风格的for
循环。
其中,表达式1
是循环执行之前的初始化,表达式2
是一个代表循环逻辑测试的表达式,表达式3
是每次循环体执行完成之后的处理
while 循环
while 循环根据测试条件,反复执行循环体直至条件为假,同样拥有 Shell 和 C 两种风格。
接下来的示例代码,同时使用 Shell 和 C
两种风格的while
循环,该示例会根据命令行参数的个数来打印它们:
1 | #!/bin/bash |
until 循环
until
循环与while
类似,但是util
循环是在条件为假时执行循环体,直至条件为真时才结束循环。
跳出循环
Shell
循环结构当中,可以使用break
或者continue
跳出循环,它们都可以携带一个用于标识所要跳出循环层数的数值,该数值缺省情况下为1
,表示仅跳出当前所在循环。
break
Bash Shell
当中的break
关键字用于中断整个循环,其具体用法如下:
n
表示跳出循环的层数,如果省略n
,则表示仅中断当前循环。break
关键字通常与if
语句联用,即满足条件时中断循环。例如下面代码用于输出一个4*4
的矩阵:
1 | #!/bin/bash |
continue
Bash Shell
当中的continue
关键字用于跳出本次循环,其具体用法如下:
其中,n
表示循环层数,缺省值为1
。即如果省略,则continue
仅跳出其所在的循环语句,忽略本次循环当中剩余代码的执行,直接进入下一次循环。如果将n
的值设置为2
,那么continue
会对内外两层的循环语句都有效,不但会跳出内层循环,还会跳出外层循环。continue
通常与if
配合使用,在满足条件时跳出本次循环。
1 | #!/bin/bash |
1 | pi@raspberrypi:~ $ ./continue.sh |
分支结构 case in esac
Shell 通过case in esac
语句实现分支结构,该结构与 C
语言中的switch case
语句非常类似。
每个条件行都使用)
结尾,每个条件块都以;;
结尾(),关键字esac
用于终止整个分支结构。下面示例脚本当中,会根据第
1
个命令行参数的值,分别打印对应的提示信息,当所有条件都不匹配时,最后会通过通配符*
拦截执行流程,打印一条提示信息:
1 | #!/bin/bash |
Bash Shell 脚本编程实践