Shell - 脚本编程

Shell - 脚本编程,变量、数组、运算符、echo/printf、流程控制、函数、参数传递、文件包含。

入门

什么是 shell 脚本
shell-script 可以理解为 Windows 下的 bat 批处理文件,它们的作用是类似的。
shell-script 是通过 shell 解释器来运行的,称为 解释型语言,即没有 编译 环节。

shell 解释器有哪些
1) sh(Bourne Shell):UNIX 最初使用的 shell,而且在每种 UNIX 上都可以使用。
Bourne Shell 在 shell 编程方面相当优秀,但在处理与用户的交互方面做得不如其他几种 shell。

2) bash(Bourne Again Shell):Linux 默认 shell,它是 Bourne Shell 的扩展。
Bourne Again Shell 与 Bourne Shell 完全兼容,并且在 Bourne Shell 的基础上增加了很多特性。
bash 可以提供命令补全,命令编辑和命令历史等功能,bash 包含了 csh、ksh 的很多优点,以及友好的交互界面。

3) zsh(Z Shell):是一种 Unix Shell,它可以用作为交互式的登录 shell,也是一种强大的 shell 脚本命令解释器。
Zsh 可以认为是一种 Bourne shell 的扩展,带有数量庞大的改进(因此配置比较复杂),包括一些 bash、ksh、tcsh 的功能。

究竟使用哪个 shell
先给出结论:编写 shell 脚本,建议使用 bash;使用 shell 交互环境,建议使用 bash 或 zsh(推荐)。

在 Linux 中,/bin/sh 通常是一个指向 /bin/bash 的软链接文件,所以在脚本中使用 sh 或 bash 都是调用的同一个程序 /bin/bash。那是不是就可以认为它们完全一样呢?不是,如果使用 /bin/sh,那么 bash 会以 sh 兼容的方式运行(不支持 bash 的特性,如高级重定向)。例子,我想比较本地的 /usr/local/ss-tproxy 与 github 上的 ss-tproxy 的区别,但又不想下载 github 上的文件,就可以使用 bash 的 <(command) 重定向,将 command 的输出作为一个虚拟的文件参数传递给 diff:

但是如果使用 /bin/sh 来执行上面的命令,就会提示语法错误。为了提高 shell 脚本的执行性,建议始终使用 /bin/bash。最后说一下 zsh,zsh 基本与 bash 兼容,且提供更强大、更灵活的功能,但是为什么不建议使用 zsh 作为 shell 脚本的解释器呢?因为 zsh 并不是默认的 shell,很多 Linux 上都没有自带 zsh,必须自己安装。为了可移植性,不建议使用 zsh 作为脚本解释器,不过作为 shell 交互环境还是非常不错的,尤其是 oh-my-zsh,不要太爽。

hello world 程序
编写和运行 shell 脚本非常简单,只需要一个文本编辑器(推荐 vim)和一个 shell 解释器(推荐 bash)。

hello.sh,扩展名无所谓,见名知意即可。

添加可执行权限,然后运行脚本。

其中,#!/bin/bash是告诉系统,启动什么解释器来运行该脚本,这是一个约定标记,必须位于文件首行!
chmod +x hello.sh则是设置文件的可执行权限,否则在运行./hello.sh的时候会提示没有执行权限。
当然,也可以显式的指定解释器来运行脚本,这样的话就不需要执行权限,也不需要首行的约定标记,如:/bin/bash hello.sh

无论是./hello.sh还是/bin/bash hello.sh,其实都是启动了一个全新的 shell 程序来解释运行脚本语句。
除了这种方式,我们也可以直接将让当前 shell 读取脚本内容,并解释执行,使用source命令,如:source hello.sh

那么这两种方式有什么区别呢?
1) 子进程中运行,具体启动流程:当前 shell(父进程)首先读取文件首行,然后执行 fork() 系统调用,创建一个子进程,紧接着执行 exec() 系统调用,载入指定的解释器(/bin/bash),然后开始解释运行。这两个 shell 之间是父子进程关系,子 shell 会继承父 shell 的环境变量(env 可查看),但是它们之间是不会互相影响的。

2) 当前进程中运行,这个就比较简单了,相当于我把一条一条的命令保存到了文件中,然后执行source来载入它,让 shell 来运行,这个和手打来执行命令完全没区别,比较符合”批处理”的概念。正因如此,脚本中的命令可以改变当前 shell 的环境变量、普通变量;任何语句带来的影响都会在当前 shell 中体现出来。

一个很浅显的比方,对于第一种方式:父 shell 就是爸爸、子 shell 就是儿子。
儿子在执行cd /etc/命令时(子 shell),只会让儿子进入 /etc/ 目录,而爸爸(父 shell)还是在原来的目录下;
爸爸在执行cd /proc/命令时(父 shell),只会让爸爸进入 /proc/ 目录,而儿子(子 shell)还是在原来的目录下;

而对于第二种方式,shell 还是那个 shell,但是它被人身控制了,它必须听我的命令。
当执行cd /etc命令时,它就得进去这个目录,由不得它;当执行exit命令时,它就得死,残忍的结束自己的生命。

#!/bin/env bash#!/bin/bash区别
#!/bin/env bash:从$PATH环境变量中查找 bash 命令的位置,并启动它;
#!/bin/bash,直接根据绝对路径启动 bash 解释器(不存在则报错,然后退出)。

理论来说,前者的移植性更好一些,因为有些系统的 bash 可能不在 /bin 目录(但实际上不必太过担心)。

shell 注释
#号开头的行就是注释,它会被解释器忽略。
shell 不支持多行注释,只能在每行前面添加#

如果需要临时注释一大段代码,过一会又要取消注释,怎么办呢?
我们可以利用 function 函数,将这段代码放在函数中,要用的时候调用就行了。

变量

定义变量
和 C/C++、Java 一样,shell 也有变量,并且定义方式大同小异。
如:name=Otokazename='Otokaze'name="Otokaze"
三种方式都可以,并且不需要指明变量类型,因为它们本质都是字符串。
但是,如果变量值存在空白符(如:空格),那么必须使用引号包围起来。
使用单双引号其实是有区别的,具体的细节我会在后面进行详细解释。

从标准输入读取
除了普通的变量定义方式,我们也可以获取用户的输入,并将其存储在变量中;
语法:read var_name,当运行到read所在行时,当前 shell 进程阻塞,等待用户输入;
当用户输入完成,并键入回车后,当前进程继续执行,输入的字符串(不包含换行符)将保存至var_name中。

变量命名规则
只能以字母_开头,后面可跟字母数字_

变量初始化
在定义变量的时候必须进行赋值(称为:初始化),当然也可以赋空值:name=""

引用变量值
在定义变量之后,我们就可以使用这个变量了,比如我要打印 name 变量的值:echo ${name}echo $name
这里特别注意,在引用变量时,必须加上$,并且最好加上{}来标识边界,这是一个很好的习惯!

重新赋值
一个变量被定义后,可以被重新赋值,变量名前不用加$。如:name="Google"
可以这样简单记忆:写入变量值时,不能加上$;读取变量值时,必须加上$,而定义的时候其实也是写入变量值,不能加$

只读变量
有时候我们想定义一个常量,或者叫只读变量,在 C/C++ 中使用关键字const,在 Shell 中使用readonly
readonly有两种用法:1) readonly MAX_SIZE=100,直接定义;2) MAX_SIZE=100; readonly MAX_SIZE,将变量设置为只读。
任何尝试修改只读变量的操作都会产生:MAX_SIZE: readonly variable错误;并且只读变量不能被 unset 删除

删除变量
使用unset命令,可以一次性删除多个变量。如:unset nameunset var_a var_b var_c
但是,对于只读变量,是不可以用 unset 进行删除的,它的生命周期和 shell 进程一样长。

变量类型
注意,这里不是指变量存储的值的类型,它们都是字符串,没什么好说的。变量类型有四种:
1) 局部变量,或者称为函数变量,即在函数中使用local关键字定义的变量都是局部变量。
局部变量只能在函数内部使用;在函数被调用的时候初始化,在函数返回的时候回收释放。
2) 全局变量,即当前 shell 环境中定义的变量,一般我们定义的变量都是全局的,除非在函数中使用local关键字。
注意,在函数中,如果不使用local定义变量,那么它默认就是全局变量!全局变量就相当于 C/C++ 中的静态变量,在 shell 退出的时候才被释放。
3) 环境变量,所有的环境变量都可以使用env命令查看,子进程会继承父进程的环境变量(拷贝一份)。
我们可以使用export命令将全局变量导出为环境变量。只读变量也能导出为环境变量,它依旧只读。
4) shell变量,shell 进程设置的特殊变量,一部分是全局变量,一部分是环境变量,它们的存在都是为了保证 shell 的正常运行。

单双引号区别
单引号的特点:
1) 单引号中的任何字符都会原样输出,单引号中的变量引用(${})、命令替换($())都是无效的。
2) 单引号字符串中不允许出现单引号,就算是使用’\’转义也不行,也就是说单引号必须成对出现!

双引号的特点:
1) 可以进行变量引用、命令替换;
2) 允许转义字符,可以包含单引号(无需转义),可以包含双引号(需要转义)。

获取字符串长度
使用号,如:url="www.zfl9.com"; echo ${#url},输出 12。

提取子串
索引值从 0 开始。如:
echo ${url:3:5},输出".zfl9",3 表示从索引值 3 开始,5 表示提取 5 个字符;
echo ${url:3},输出".zfl9.com",如果省略长度,则默认提取剩下的所有字符;
echo ${url:0-1},输出"m",倒数第一个索引为 -1,倒数第二个为 -2,以此类推;
echo ${url:0-8:4},输出"zfl9",0-8 表示索引 -8,4 表示提取 4 个字符长度。

删除子串
### 表示从字符串左边匹配子串,然后删除;%%% 表示从字符串右边匹配子串,然后删除。
模式中可以使用 glob 通配符,如 ?*[][^]#% 中的 * 为最短匹配,##%% 中的 * 为最长匹配。

echo ${url#w*.},最短匹配,输出"zfl9.com"
echo ${url##w*.},最长匹配,输出"com"

echo ${url%.*},最短匹配,输出"www.zfl9"
echo ${url%%.*},最长匹配,输出"www"

替换子串
echo ${url/./*},仅替换一次,输出"www*zfl9.com"
echo ${url//./*},替换所有匹配的子串,输出"www*zfl9*com"

查找子串
为了查找子串,我们需要借助 expr 命令,具体用法如下:
expr index $url zfl9,注意 expr 是以 1 开始的,因此输出 5;
expr index $url google,因为没有匹配的子串,因此输出 0;
注意,对于查找的子串"zfl9""google",它是一个一个字符去匹配的;
如:expr index $url abcd.,它会将"abcd."五个字符依次去匹配,因此返回 4。

数组

bash 支持一维数组,不支持多维数组。并且没有限定数组的长度。
类似的,数组元素的下标以 0 开始编号,获取元素要利用下标,下标可以是整数或算术表达式,其值应大于等于 0。

定义数组
1) 直接定义,array=(1 2 3 4 5),定义数组需要使用()圆括号,每个元素之间用空格隔开。
2) 依次赋值,array[0]=1; array[3]=4,数组的下标可以不连续,并且下标的范围没有限制。

访问数组
echo ${array[0]},使用给定下标,整数形式;
echo ${array[1 - 1]},使用算数表达式,支持+ - * / % **加减乘除、取余、乘方;
ind=0; echo ${array[ind]},使用变量,变量值须为大于等于 0 的整数,并且不需要加$

所有元素
echo ${array[@]},获取所有元素;或者echo ${array[*]}也可以。
@*是有区别的,@是将每个元素分开传递,*则是一次性传递。
当然,一般情况下是体现不出来的;只有在双引号中,才会表现得不一样:

数组长度
1) echo ${#arr[@]};2) echo ${#arr[*]};这两种方式都可以,没有什么区别;
同时也可以获取单个元素的长度:echo ${#arr[0]},和获取字符串长度的方法相同。

位置参数

在执行 shell 脚本时,我们可以向 shell 脚本传递命令行参数(位置参数)。
获取参数可以通过特殊变量$n,其中 n 为非负整数,$0是当前执行文件名。
而我们传递的参数是从序号 1 开始的,$1就是第一个参数,$2就是第二个参数,以此类推。
当 n 大于等于 10 时,需要使用${10}来进行引用,这个和引用变量是一样的,用来标识边界。

例子:

几个特殊变量

变量名 说明
$0 当前的可执行文件名
$n n 为非负整数,传递的命令行参数
$# 命令行参数个数,不包括$0
$@ 获取所有参数,不包括$0
$* 获取所有参数,不包括$0
$? 最后一个命令的退出值,0 为无错误,其它值为有错误
$$ 当前 shell 进程的 PID
$! 最后一个后台任务的 PID

$@$*区别
其实和前面一节中的数组${arr[@]}${arr[*]}区别是一样的,在双引号中才有区别:

传递参数时加引号和不加引号的区别
加引号:shell 会将引号内的字符串作为一个参数进行传递;
不加引号:shell 会使用空格进行参数分割,然后传递给命令;

运算符

算数运算符
在 bash 中,有三种特殊方式,用来支持算数运算:(())$(())$[]
具体用法请看我之前的博客 - Shell - 四则运算,它们的区别:
(())不会进行命令替换;$(())$[]会进行命令替换。什么意思,请看例子:

那你会想,$(())$[]有什么用呢?最常见的就是将结果保存至变量:result=$[ a + 10 ]

关系运算符
关系运算符只支持数字,不支持字符串。
在 bash 中,需要借助/bin/test/bin/[命令进行关系运算。
test[是一样的,它们都是普通命令,区别是[需要使用]标记结束。
如:test 10 -eq 10[ 10 -eq 10 ],这也就是为什么[]内侧需要空格。

注意,在 shell 中,返回值 0 表示真,其他值表示假,这个和其他语言是相反的。

运算符 说明 例子
-eq equal==等于 [ 10 -eq 10 ],真
-ne not equal!=不等于 [ 10 -ne 11 ],真
-lt less than<小于 [ 10 -lt 20 ],真
-le less than or equal<=小于等于 [ 10 -le 10 ],真
-gt greater than>大于 [ 10 -gt 5 ],真
-ge greater than or equal>=大于等于 [ 10 -ge 10 ],真

逻辑运算符
同样的,逻辑运算也要借助于/bin/test/bin/[命令。

运算符 说明 例子
-a 逻辑与,&& [ 10 -eq 10 -a 20 -gt 10 ],真
-o 逻辑或,|| [ 10 -eq 10 -o 20 -lt 10 ],真
! 逻辑非,! [ 10 -eq 10 -a ! 20 -lt 10 ],真

如果你喜欢使用&&||!,那么你可以尝试使用[[关键字:
如:[[ 10 -eq 10 && 20 -eq 20 ]]真、[[ 1 -lt 0 || 1 -gt 0 ]]真。
但是,[[ ]]中不再支持-a-o,只支持&&||!了。

字符串测试
同样的,字符串测试也要借助于/bin/test/bin/[命令。

运算符 说明 例子
= 两个字符串是否相等 [ "a" = "a" ],真
!= 两个字符串是否不相等 [ "a" != "b" ],真
-n 字符串是否非空 [ -n "www" ],真
STRING 字符串是否非空,同-n [ "www" ],真
-z 字符串是否为空 [ -z "" ],真

文件测试
同样的,文件测试也要借助于/bin/test/bin/[命令。

运算符 说明 例子
-e 文件是否存在 [ -e /etc/resolv.conf ],真
-s 文件是否非空 [ -s /etc/resolv.conf ],真
-d 文件是否为目录 [ -d /etc ],真
-f 文件是否为普通文件 [ -f /etc/resolv.conf ],真
-b 文件是否为块设备 [ -b /dev/sda ],真
-c 文件是否为字符设备 [ -c /dev/tty ],真
-p 文件是否为具名管道 [ -p pipe ],pipe 是我创建的管道文件,真
-S 文件是否为套接字文件 [ -S /run/systemd/coredump ],真
-h 文件是否为软链接文件 [ -h /bin/sh ],真
-L 文件是否为软链接文件,同-h [ -L /bin/sh ],真
-r 文件是否有可读权限 [ -r /etc/resolv.conf ],真
-w 文件是否有可写权限 [ -w /etc/resolv.conf ],真
-x 文件是否有可执行权限 [ -x /bin/sh ],真
-u 文件是否有SUID权限 -
-g 文件是否有SGID权限 -
-k 文件是否有sticky权限 -
-O 文件所属用户是否有效 -
-G 文件所属用户组是否有效 -
-t 文件描述符是否已打开 [ -t 0 ],真
-ef 两个文件是否相同(所在设备相同 && inode 相同) [ f1 -ef f2 ],f2 是 f1 的硬连接文件,真
-nt newer than(修改时间) [ f1 -nt f2 ],假
-ot older than(修改时间) [ f1 -ot f2 ],假

echo

echo 可能是我们接触 Linux 的第一个命令了。大家都比较熟悉,下面是几个简单的用法:
echo "www.zfl9.com www.baidu.com www.google.com",使用双引号扩起来;
echo 'www.zfl9.com www.baidu.com www.google.com',使用单引号扩起来;
echo www.zfl9.com www.baidu.com www.google.com,也可以省略引号;

上面几个都是最常见的用法,谁都知道,但是下面这些命令,你可能就不一定熟悉了:
echo -n "www.zfl9.com"-n选项,不在字符串末尾添加\n换行符;
echo -e "\twww.zfl9.com"-e选项,开启字符串转义;
echo -e "www.zfl9.com\n\c这些字符串不会被输出"\c表示从这以后的字符串将不再输出;
echo -e "\e[32mtrue\e[0m" "\e[35mfalse\e[0m",支持设定字符串颜色,true 为绿色,false 为红色;

echo 默认是关闭转义功能的,使用选项-e来显式开启它,下面是一些常见的转义字符:

转义字符 说明
\a 响铃(BEL),终端会响一声
\b 退格(BS),将当前位置退回上一个字符
\r 回车(CR),将当前位置移至本行开头
\n 换行(LF),将当前位置移至下行开头
\f 换页(FF),将当前位置移至下页开头
\t 水平制表(HT)
\v 垂直制表(VT)
\0 空字符(NULL)
\0NNN 八进制数字(1 ~ 3 位)
\xHH 十六进制数字(1 ~ 2 位)

输出颜色
这个可能是最炫的功能了,我们一起来学习一下,如何让 echo 输出带有颜色的字符!
要想输出颜色,就必须打开转义功能,使用选项-e;具体格式\e[控制码m字符串,或\033[控制码m字符串
\e[\033[开头,控制码可以有多个,它们之间使用分号;隔开,最后以字符m结束。
但是为了不影响后面的输出,我们通常需要使用\e[0m来恢复默认格式,因此,一般形式为:\e[控制码m字符串\e[0m

编码 说明
\e[0m 恢复默认格式
\e[1m 粗体/高亮显示
\e[2m 模糊(部分终端支持)
\e[3m 斜体(部分终端支持)
\e[4m 下划线
\e[5m 闪烁(慢)
\e[6m 闪烁(快)(部分终端支持)
\e[7m 交换背景色与前景色
\e[8m 隐藏(什么也看不见)(部分终端支持)
\e[3xm 前景色,x 为颜色值(可参见下面的颜色表)
\e[4xm 背景色,x 为颜色值(可参见下面的颜色表)
\e[nA 光标上移 n 行
\e[nB 光标下移 n 行
\e[nC 光标右移 n 行
\e[nD 光标左移 n 行
\e[y;xH 调整光标位置,y 为纵向,x 为横向
\e[s 保存光标位置
\e[u 恢复光标位置
\e[?25l 隐藏光标
\e[?25h 显示光标
\e[2J 清屏
\ec 清屏(推荐)

颜色表

编码 说明
0
1
2 绿
3
4
5
6
7

需要注意的是,这些控制码都是由终端支持的,与具体的语言无关,因此你可以使用 C/C++、Java、Python 等语言输出颜色。

printf

除了 echo,还有一个常用的输出命令就是 printf,它支持格式化输出,和 C 语言的 printf() 风格类似。
语法:printf format-string arguments...,和 printf() 一样,它不会自动在字符串末尾添加换行符。

格式参数,以%开头,如果需要输出%本身,需要使用%%进行转义,常用的几个格式:
%c,单个字符,如果传入的参数为多个字符,那么只提取第一个字符;
%s,字符串,使用%ns控制长度(默认右对齐),使用%-ns进行左对齐,下同;
%d,整数,十进制,使用%nd控制长度(默认右对齐),使用%0nd进行高位补零,使用%+d显示正负号;
%f,浮点数,精确到小数点后六位(四舍五入),使用%.nf控制精确位数;
%e,浮点数,以科学计数法表示,指数部分以小写的 e 表示;
%E,浮点数,以科学计数法表示,指数部分以大写的 E 表示;
%g,浮点数,自动选择使用%f%e格式;
%G,浮点数,自动选择使用%f%E格式;

如果格式参数的个数与实际参数的个数不一致,那么format-string将被重用;
如:printf "%s\n" "baidu" "google" "facebook",将输出三行,说明%s\n被重用了。

printf 支持的转义字符和 echo 一样,并且还额外支持以下几个转义字符:
\NNN八进制数字(0 ~ 3 位)、\xHH十六进制数字(1 ~ 2 位)、\uHHHHUnicode码、\uHHHHHHHHUnicode码。

流程控制

注意,每个分支不可为空,如果不需要此分支,那就不要写。

if
if 根据 condition 的返回值判断是否要执行该分支,如果 condition 返回 0,则为真,否则为假。
一般使用/bin/test/bin/[[[进行条件测试,具体的命令用法已在前文给出,不再复述。

if…else
如果 condition 为真,则执行 if 分支,否则执行 else 分支。

if…elif…else
从上至下依次匹配,如果条件为真,则执行该分支,如果所有条件都为假,则执行 else 分支(也可以没有 else 分支)。

foreach
用于遍历(枚举)指定的元素列表。

for
C/C++ 风格的 for() 循环,使用双重圆括号。

while
如果 condition 返回 0,则继续循环,否则退出循环。

until
如果 condition 返回非 0,则继续循环,否则退出循环,和 while 刚好相反。

case
if…elif…elif…else 的简化直观版本,相当于 C 语言的 switch 语句。
globbing 即 shell 通配符,case 还在此基础上添加了对|选择元字符的支持。
因此,在 case 中,支持*?[][^]|五种模式匹配元字符。
解释一下|,它和 regex 中的|是一样的,的意思,用来选择多个模式。

break、continue
break:结束当前循环,执行循环后面的语句;
continue:结束此轮循环,直接开始下轮循环。
这两个关键字用于forwhileuntil中。

函数

在 shell 中,同样有函数的概念,具体语法如下:
其中,function()return都可以省略。
如果没有return语句,那么默认返回最后一条命令的退出值;
如果有return语句,那么返回值类型为 int([0, 255])。

定义好函数之后,我们就可以使用它了;
使用函数很简单,一个函数就和一个普通的命令一样,只需要写函数名,不用加()
如:func_name;还可以传递参数,func_name arg1 arg2 arg3,在函数内部使用$n获取;
其中$0为当前可执行文件名,$1为第一个参数、$2为第二个参数,…,以此类推;
使用$#获取参数个数,$@$*获取所有传递的参数,$?可在函数外部获取函数返回值。
$@$*的区别说了很多遍,这就不再重复了,你会发现函数其实和一个 shell 脚本没多大区别。

递归调用
在 shell 函数中,同样支持递归调用,即自己调用自己。
并且,我发现 bash 中没有深度限制,可以一直递归下去;但是在 zsh 中就有 1000 层限制。

关于 bash 函数,还有几点要说明一下:
1) 必须先定义函数,才能使用,不支持类似 C/C++ 中的函数声明;
2) 在一个函数中,可以调用另一个已定义的函数,也可以调用本身。

函数局部变量
在函数中,我们可以访问全局变量(读取、修改);也可以定义新的变量;
但是要注意,在函数中定义的变量默认是全局变量,即在函数外部依旧可以访问;
那么,如何定义函数局部变量呢?使用关键字local,如local var_name=value
这时,var_name变量就是一个局部变量,在函数外部不可访问,只能在函数内部使用。
如果局部变量和全局变量有命名冲突(变量名一样),则优先使用局部变量(优先级高)。

具名函数、匿名函数
具名函数:通常情况下我们定义的都是具名函数,也就是都有函数名;
匿名函数:使用{ command1; command2; ...; commandN; }来定义和使用匿名函数。

匿名函数中不能有 return 语句,如果花括号与命令在同一行,需要空格隔开(左括号)和分号结束(右括号);
比如:{ echo www.zfl9.com; };如果不在同一行,就不需要使用空格隔开和以分号结束,具体例子如下:

重定向

要彻底理解重定向,我们必须先来了解这些基础知识:

文件描述符
文件描述符是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。
当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。

stdin、stdout、stderr
每个进程(除了守护进程)都会默认打开这三个文件:
stdin:标准输入文件,对应键盘,文件描述符 FD 为 0;
stdout:标准输出文件,对应显示器,文件描述符 FD 为 1;
stderr:标准错误文件,对应显示器,文件描述符 FD 为 2;

我们可以使用lsof -p $$命令查看当前 shell 打开的文件描述符信息;
从命令输出中可以发现 fd0、fd1、fd2 都是以 rw 读写模式打开的。

了解这些知识之后,我们再来学习一下 shell 重定向操作:
command 0<data.file:重定向标准输入,0 表示 fd0(stdin),<表示只读方式,即不再从键盘读取数据,而是从 data.file 读取数据;
command 1>data.file:重定向标准输出,1 表示 fd1(stdout),>表示只写方式(会清空原文件),即不再往显示器写入数据,而是往 data.file 写入数据;
command 1>>data.file;重定向标准输出,1 表示 fd1(stdout),>>表示追加方式(不清空原文件),即不再往显示器写入数据,而是往 data.file 写入数据;
command 2>data.file:重定向标准错误,2 表示 fd2(stderr),>表示只写方式(会清空原文件),即不再往显示器写入数据,而是往 data.file 写入数据;
command 2>>data.file;重定向标准错误,2 表示 fd2(stderr),>>表示追加方式(不清空原文件),即不再往显示器写入数据,而是往 data.file 写入数据;

其实可以和 C 语言中的 fopen() 中的 mode 联系起来,<rb>wb>>ab

其中:<默认与fd0关联,即<0<一样;>>>默认与fd1关联,即>>>1>1>>一样。

将 stdout、stderr 重定向到不同文件:
command >out.log 2>err.log(写入);command >>out.log 2>>err.log(追加)。

将 stdout、stderr 重定向至同一文件:
1) command >log 2>&1(写入);command >>log 2>&1(追加);
2) command 2>log 1>&2(写入);command 2>>log 1>&2(追加);
3) command &>log(写入);command &>>log(追加)。

三种方式产生的效果都是一样的(但里面有些细节不一样),其中第三种是简写方式,在某些时候有局限性。
如:我要将 stdout 和 stderr 合并,用管道传递给下一个命令,就不能使用方式三,只能为cmd1 2>&1 | cmd2

合并 stdout、stderr 流到其中一个流:
command 2>&1:将 stderr 合并到 stdout
command 1>&2:将 stdout 合并到 stderr

heredoc
有时候我不想从 stdin 读取数据,而是想现写,这时候就可以使用<<,打开 heredoc 功能。
具体用法:command <<EOF,其中 EOF 只是一个表示结束的字符串,你可以换成任意字符串;
当你按下回车后,你就可以写入任意数据了,写完后,新起一行,输入EOF,按下回车就可以了。

command <<EOF:不带引号的 EOF,允许变量引用、命令替换;
command <<'EOF':单引号的 EOF,关闭变量引用、命令替换;
command <<"EOF":双引号的 EOF,关闭变量引用、命令替换。
无论带不带引号,heredoc 都使用单独的 EOF 行表示结束(前后不能有任何内容)。

这个功能在 shell 脚本中很常用。比如,我想在脚本中输出一大段内容到一个文件中,heredoc 就派上用场了:

重定向汇总

命令 说明
CMD FD<FILENAME rb方式打开 FILENAME,将 FD 指向的文件改为 FILENAME,FD 默认为 0
CMD FD>FILENAME wb方式打开 FILENAME,将 FD 指向的文件改为 FILENAME,FD 默认为 1
CMD FD>>FILENAME ab方式打开 FILENAME,将 FD 指向的文件改为 FILENAME,FD 默认为 1
CMD FD1<&FD2 将 FD2 合并至 FD1(读取),FD1 默认为 0
CMD FD1>&FD2 将 FD1 合并至 FD2(写入),FD1 默认为 1
exec FD<FILENAME rb方式打开 FILENAME,并分配指定 FD(限制为 0-9),默认 FD 为 0
exec FD>FILENAME wb方式打开 FILENAME,并分配指定 FD(限制为 0-9),默认 FD 为 1
exec FD>>FILENAME ab方式打开 FILENAME,并分配指定 FD(限制为 0-9),默认 FD 为 1
exec FD<>FILENAME rb+方式打开 FILENAME,并分配指定 FD(限制为 0-9),默认 FD 为 0
exec FD<&- 关闭 FD 的输入,实际上 FD 会被释放,默认 FD 为 0
exec FD>&- 关闭 FD 的输出,实际上 FD 会被释放,默认 FD 为 1

关于exec打开自定义描述符的一些说明:
在 bash-4.4.12 中测试发现,FD 没有限制,可以大于 9;
在 zsh-5.4.2 中测试发现,FD 被限制为 0-9,不能是其它值。

管道

管道,即:将上一个命令的标准输出作为下一个命令的标准输入,这两个命令之间使用|管道连接符相连。
比如,查找当前系统中是否有用户 zhang3:cat /etc/passwd | grep 'zhang3'grep 'zhang3' /etc/passwd

特别注意:管道只会将前一个命令的 stdout 作为后一个命令的 stdin,而 stderr 则不会被后一个命令读取!
如果想要让后一个命令获取前一个命令的 stdout 和 stderr,可以这么写:cat /etc/passwd 2>&1 | grep 'zhang3'

文件包含

其实在第一节中我们就提到了source命令,用来载入指定的文件内容,并在当前 shell 中执行;此外,source还有一个别名.
其实原理非常简单,当前 shell 一行一行的读取指定文件(文本文件),并在当前 shell 中解释执行,就和手打一样,完全没区别。

sub-shell
sub-shell即子 shell。我们有 3 种方式启动一个 sub-shell 来执行命令:
1) ./test.sh:test.sh 需要可执行权限,并且首行有约定标记;
2) /bin/bash test.sh:test.sh 不需要可执行权限,并且首行约定标记也不需要;
3) (command1; command2; ...; commandN):直接启动一个 sub-shell 来解释执行给定的命令。

sub-shell 的特点是:
1) 必须启动一个新的 bash 解释器来执行给定的脚本/命令;
2) sub-shell 会继承当前 shell 的环境变量(拷贝一份);
3) 在 sub-shell 中改变环境变量并不会影响当前 shell 的环境变量。

大部分情况下,sub-shell 都能满足我们的需求;但是如果我们需要让脚本中的语句改变当前 shell 的环境变量,就必须使用source命令。

通配符

模式 说明
* 匹配 0 个或多个字符(/除外)
** *的基础上支持匹配/,即支持匹配多级目录
? 匹配单个字符
[abc] 匹配集合中的任意单个字符
[0-9] 同上,可指定范围
[^abc] 不匹配集合中的任意单个字符
[^0-9] 同上,可指定范围
[:alpha:] 字母
[:digit:] 数字
[:alnum:] 字母 + 数字
[:lower:] 小写字母
[:upper:] 大写字母
[:cntrl:] 控制字符
[:space:] 空白字符
[:print:] 可打印字符
[:xdigit:] 十六进制数

特殊符号

符号 说明
() 启动 sub-shell,解释执行括号中的命令
{} 1) 匿名函数;2) 枚举,如{a,b,c}{a..z}
[] test,但是需要使用]来标识边界
[[]] 增强版test,支持 shell 通配符,regex 正则表达式、&& || ! ()逻辑连接符;如[[ "abc" == * ]]通配符、[[ "a" != [0-9] ]]通配符(取反)、[[ "www" =~ w+ ]]正则匹配
${} 变量引用,当然也可以省略花括号,但是强烈建议带上花括号
$() 命令替换,执行括号中的命令,并获取它的标准输出结果,存储到一个变量中
(()) 1) 整数运算,具体用法请查看 - 四则运算;2) 用于 for 循环,如for ((i = 0; i < 10; i++)); do echo $i; done
$(()) 整数运算,并执行命令替换(将计算结果替换出来)
$[] 整数运算,并执行命令替换(将计算结果替换出来)
$'string' 仅 zsh 有效,用于转义字符串,支持 echo 的所有转义字符

逻辑运算符

  • &&:逻辑与,二元操作符。cmd1 && cmd2,只有 cmd1 返回 0 才执行 cmd2。
  • ||:逻辑或,二元操作符。cmd1 || cmd2,只有 cmd1 返回非 0 才执行 cmd2。
  • !:逻辑非,一元操作符。! cmd,如果 cmd 返回 0 则该命令返回 1,否则返回 0。

小技巧:利用 &&|| 来替代 if(命令很短时,这很有用)

  • condition && statement_if_true
  • condition || statement_if_false
  • condition && statement_if_true || statement_if_false

注意,&&|| 后面只能跟一个语句,如果有多个语句(分号隔开),那么只有第一个语句作为 statement 块,后面的语句都被视为正常语句,而非 statement 块。如果实在需要多条语句,可以使用匿名函数的语法({ statement1; statement2; ...; })。

追加运算符

  • string+=newstr:追加 newstr 子串至 string 字符串末尾。
  • array+=(element):追加 element 元素至 array 数组末尾。

变量操作符

  • ${variable:-default value}:如果 variable 不存在或为空,则表达式返回 default value。
  • ${variable:=default value}:如果 variable 不存在或为空,则将 default value 赋值给它。
  • ${variable:+alternate value}:如果 variable 存在且不为空,则表达式返回 alternate value。
  • ${variable:?error message}:如果 variable 不存在或为空,则表达式报告 error message 错误并结束脚本。

文件转换符

  • cmd $(<arg.dat):将 arg.dat 的文件内容作为 cmd 的命令行参数。
  • cmd <(command):将 <(command) 作为一个可读的文件参数,该文件的内容为 command 的标准输出。
  • cmd >(command):将 >(command) 作为一个可写的文件参数,写入的内容作为 command 的标准输入。

EOF 用法

  • cmd <<EOF,以单独的 EOF 行结束:将输入的内容作为 cmd 的 stdin 数据,支持变量引用、命令替换。
  • cmd <<'EOF',以单独的 EOF 行结束:将输入的内容作为 cmd 的 stdin 数据,关闭变量引用、命令替换。
  • cmd <<"EOF",以单独的 EOF 行结束:将输入的内容作为 cmd 的 stdin 数据,关闭变量引用、命令替换。

参数转发
如果你希望为一个命令添加自定义参数,可以考虑使用参数转发功能。比如 tomcat 的启动脚本 catalina.sh,原始脚本只提供 start|stop 等基本参数,我想添加一些自定义的参数,又不影响原有参数,可以吗?当然,在你处理完自定义参数后,只需执行 catalina.sh "$@",表示将参数原模原样的传递给 catalina.sh,特别注意这个双引号,如果缺少,就不能处理参数中带空白符的情况了,也就失去了参数转发的意义。

没有双引号的情况:

加了双引号的情况:

特殊命令

:,内建命令,它总是返回 0,并且不产生任何输出,因此经常用来清空文件;
true,位于/bin/true,它和:一样,总是返回 0,并且不产生任何输出,通常用于 while 无限循环的测试条件;
false,位于/bin/false,和true刚好相反,总是返回 1,并且不产生任何输出,暂时没有发现具体用途(开玩笑的);
test,位于/bin/test,通常用于 if 的条件测试;
[,位于/bin/[,通常用于 if 的条件测试,但是它需要使用]来标识结尾;

eval
语法:eval COMMAND,用于执行 COMMAND 命令。

是不是觉得很奇怪,我难道不能直接运行 COMMAND 命令吗,为何要多此一举,加个 eval 呢?
确实。一般情况下,我们是用不到 eval 的,我们完全可以直接运行 COMMAND;
但是,有一种情况除外,我需要使用脚本动态生成命令并执行它,这时候 eval 就能大显身手了。

当然你可能会想,我不是可以使用/bin/bash -c "COMMAND"来执行命令吗,这不是一样可以做到动态生成命令并执行?
这种方式确实是可行的,但是如果 COMMAND 中有改变环境变量的语句呢?你用这种方式只会改变 sub-shell 的环境变量!

为了深入理解 eval,我们先来了解 shell 中命令的执行流程,在 shell 中,一个命令有 3 种写法:
1) 直接写Normal Command;2) 放在双引号中"Command";3) 放在单引号中'Command'。区别如图:
shell 执行命令的流程

1) 处理管道|
2) alias 替换,即将命令别名替换为真正的命令;
3) brace 替换,如将ls {a,b}.txt解析为ls a.txt b.txt
4) ~ 替换,将~替换为当前登录用户的家目录;
5) 变量替换,即处理${}变量引用;
6) 命令替换,即处理$()命令替换;
7) 算数表达式运算,即处理$(())$[]的内容;
8) glob 扩展,也就是 shell 通配符(***?[][^]);
9) 查找可执行文件,优先级依次降低:function、built-in、$PATH 变量;

放在单引号中的命令执行流程最为简单,直接查找命令,然后执行;而没有引号或双引号中的命令则会进行很多解析步骤。

回到 eval
上面我们了解了 shell 执行一个命令的具体流程和细节;很好,eval 也会进行一样的步骤,来解析一条命令!
因此,为了避免当前 shell 的解析与 eval 的二次解析出现混乱,我建议使用单引号将命令包围起来,即eval 'cmd arg1 arg2 ...'

mktemp
mktemp,用于创建临时文件,并打印出 tmp 文件所在的路径,参数如下。

  • -d,创建临时文件夹而不是临时文件;
  • -u,仅仅打印 tmp 文件的路径,而不创建(不安全);
  • -q,在创建文件失败或其他错误时,不输出错误信息;
  • --suffix=SUFF,指定文件后缀;
  • -p,指定文件夹,默认为$TMPDIR,如果该变量为空则使用/tmp目录;

cat、sed
有时候我们从肉眼上很难区分 tab 和连续空格,不过我们可以借助 cat、sed 来区分:

xxd、od
xxdod都是 Linux 自带的二进制查看器;xxd支持二进制、十六进制;od支持八进制、十进制、十六进制。

xxd命令,常用参数:

  • -b,以二进制格式查看文件内容,默认为十六进制;
  • -e,以小端字节序显示(不建议),默认为大端字节序;
  • -g,设置每个显示单位的长度(字节),默认为 2 字节;
  • -u,以十六进制显示时,使用大写的 ABCDEF;
  • -i,以 C 语言风格显示(十六进制,前缀 0x);
  • -l,只输出前 n 个字符;

xxd命令举例:xxd -g1 /etc/resolv.conf(十六进制)、xxd -b /etc/resolv.conf(二进制)。

od命令,常用参数:

  • -A,指定地址进制,取值[odxn]o八进制(default)、d十进制、x十六进制、n不显示地址栏;
  • -t,指定输出格式,cASCII字符、oN八进制,可指定单位长度 N、xN十六进制,可指定单位长度 N。

od命令举例:od -An -to1 /etc/resolv.conf(八进制)、od -An -tc /etc/resolv.conf(ASCII 字符 + 转义字符)。

dirname、basename
dirname path:获取 path 路径字符串的父目录部分,如dirname /a/b/c输出/a/bdirname /a/b/c/输出/a/b
basename path:获取 path 路径字符串的文件(目录)名,如basename /a/b/c输出cbasename /a/b/c/输出c

进入脚本所在目录
比如,我要在脚本 A 中执行脚本 B,而脚本 B 与脚本 A 在同一个目录,该怎么做?假设 A 脚本为A.sh,B 脚本为B.sh

初学者可能会使用./B.sh,但是,这条语句执行的是执行者所在目录下的 B 脚本,而并非与 A 脚本同一目录下的 B 脚本!除非 A 脚本就在当前目录下,否则执行到该语句时就会失败。

为什么?你还记得之前一直提的 sub-shell 吗?你启动了 A 脚本后,A 会继承当前 shell 的环境变量,而执行到./B.sh时,它会被 shell 解释器转换为${PWD}/B.sh${PWD}是当前目录,而这个环境变量是继承自当前 shell 的,因此就会出错了!

因此,我们需要将${PWD}环境变量修改为脚本 A 所在的目录,而cd命令就会改变${PWD}环境变量。好,我们一步一步来:
1) 既然脚本 A 不在当前目录,那么我是怎么运行的呢?有两种方式:第一种,使用绝对路径来执行/path/to/A.sh;第二种,使用相对路径来执行path/to/A.sh
2) 对于第一种情况,很简单,直接可以从$0变量中获取脚本的绝对路径;对于第二种情况,也简单,先从$0中获取相对路径,再使用 cd 进去就可以了;
3) 因此,我们可以使用这条命令cd $(dirname $0);是不是很简单,它同时支持第一种和第二种情况,无论是绝对路径还是相对路径都能适应!

pgrep
pgrep命令,可以理解为processes grep,也就是使用正则表达式来查找与进程相关的信息;
比如:pgrep 'nginx'查找所有运行的 nginx 进程,并打印出它们的 PID;pgrep -c 'nginx'不打印 PID,而是统计进程数目;

pgrep的常用参数:

  • -i,忽略大小写;
  • -v,反向匹配;
  • -c,统计匹配到的数量;
  • -l,显示 process_name(不带参数);
  • -a,显示 full_command_line(带启动参数);
  • -w,显示 TID(线程 ID);
  • -d,指定分隔符,默认为换行符;
  • -f,使用 full_process_name 去匹配(带启动参数);
  • -P,查找给定 PPID(父进程 ID)下的进程;
  • -x,只查找与 command_name 完全匹配的进程;
  • -o,查找最先(oldest)启动的进程;
  • -n,查找最后(newest)启动的进程;
  • -F,从给定的 pid 文件中查找进程;

lsof
lsoflist open files,因为在 Unix 中一切皆文件,因此 lsof 支持查找普通文件套接字文件等等;

使用详解:

  • lsof,显示系统打开的所有文件;
  • lsof filename,显示打开了指定文件的进程;
  • lsof +d dirname,显示指定目录下所有被打开的文件;
  • lsof +D dirname,显示指定目录下所有被打开的文件(递归);
  • lsof -c command,显示指定进程打开的文件(根据名称);
  • lsof -p pid,显示指定进程打开的文件(根据 PID);
  • lsof -u username/uid,显示指定用户打开的文件;
  • lsof -g group_id,显示指定用户组 ID 打开的文件;
  • lsof -d fd/type,显示打开了指定 fd 的进程;
  • lsof -R,显示父进程 PID(PPID);
  • lsof -n,显示 ip 而不是 hostname;
  • lsof -i [46][tcp|udp][@host|addr][:svc_name|port],按照给定条件查找;
  • lsof cond1 -a cond2,加了 -a 参数说明须同时满足 cond1 和 cond2,默认为 OR 或。

文件描述符:

  • cwd:current work dirctory;
  • txt:program text (code and data);
  • ltx:shared library text (code and data);
  • mem:memory-mapped file;
  • mmap:memory-mapped device;
  • pd:parent directory;
  • rtd:root directory;
  • 0u:标准输入文件
  • 1u:标准输出文件
  • 2u:标准错误文件

文件状态:

  • r:只读模式;
  • w:只写模式;
  • u:读写模式;
  • 空格:文件状态未知且文件未被锁定;
  • -:文件状态未知且文件已被锁定;

文件类型:

  • IPv4:IPv4 套接字文件;
  • IPv6:IPv6 套接字文件;
  • unix:Unix 套接字文件;
  • BLK:块设备;
  • CHR:字符设备;
  • DIR:文件夹;
  • REG:普通文件;
  • LINK:符号链接文件;
  • FIFO:管道文件;

tail 进阶用法

  • tail -n3:显示最后 3 行;
  • tail -n+2:显示第 2 行至最后 1 行(从 1 开始);
  • tail -c3:显示最后 3 字节;
  • tail -c+2:显示第 2 字节至最后 1 字节(从 1 开始)。

参数解析

一般的 shell 脚本都不会有太复杂的命令行参数,这时候使用$n获取位置参数就足够了。
但是,有些时候需要解析一些比较复杂的命令行参数,这时候就需要 shift 和 getopts 了。

shift
shift是 shell 的一个内建命令,常用于位置参数的解析;
shift 将所有位置参数(除$0)往前移动一个单位,即丢弃最前边的参数;
如:shift 3,往前移动三个位置、不带参数的shift则相当于shift 1

最简单的用法,依次获取每个位置参数(当然,这只是演示一下 shift 的用法)

现在我们来搞一个实用点的 - 安全删除,即模拟 Windows 回收站:

虽然很简陋,但是核心功能还是没有问题的,我们来测试一下:

getopts
除了使用 shift,我们还可以使用 getopts 内建命令,它主要是模仿 C 库中的 getopt() 函数。

语法:getopts optstring optname [arguments ...]
变量$OPTIND:选项所在的索引值,初始值为 1,它会自动递增;
变量$OPTARG:选项所附带的参数(如果有的话)。
其中,optstring 是选项字符串,用来定义如何处理选项;optname 是一个变量,用来存储捕获到的选项;如果没有 arguments,则从当前的位置参数解析,如果有 arguments,则从给定的 arguments 解析。

optstring格式:
1) 如果选项后面没有:号,说明该选项后没有参数值;
2) 如果选项后面带有:号,说明该选项后需要提供参数,参数值存储在$OPTARG

当 getopts 遇到未知选项选项缺少参数的情况时:
1) 如果 optstring 以:开头,getopts 将不会输出默认的出错信息,而是交给我们自己来处理。并且,遇到未知选项时将optname设为?,遇到缺少参数时将optname设为:
2) 如果 optstring 不以:开头,getopts 将输出默认的出错信息,并且不区分未知选项和缺少参数的情况,统一将optname设为?

简单例子,演示了如何使用 getopts:

在函数中使用 getopts
因为$OPTIND变量每次都会自增,因此调用一次函数后,$OPTIND的值就不再是 1 了,再调用函数就会无法解析;
为了解决这个问题,我们可以在函数中定义一个与$OPTIND同名的 local 变量,并且将其初始值设为 1,就没有问题了。