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 中体现出来。
#!/bin/env bash
和#!/bin/bash
区别#!/bin/env bash
:从$PATH
环境变量中查找 bash 命令的位置,并启动它;#!/bin/bash
,直接根据绝对路径启动 bash 解释器(不存在则报错,然后退出)。
理论来说,前者的移植性更好一些,因为有些系统的 bash 可能不在 /bin 目录(但实际上不必太过担心)。
shell 注释
以#
号开头的行就是注释,它会被解释器忽略。
shell 不支持多行注释,只能在每行前面添加#
。
如果需要临时注释一大段代码,过一会又要取消注释,怎么办呢?
我们可以利用 function 函数,将这段代码放在函数中,要用的时候调用就行了。
变量
定义变量
和 C/C++、Java 一样,shell 也有变量,并且定义方式大同小异。
如:name=Otokaze
、name='Otokaze'
、name="Otokaze"
;
三种方式都可以,并且不需要指明变量类型,因为它们本质都是字符串。
但是,如果变量值存在空白符(如:空格),那么必须使用引号包围起来。
使用单双引号其实是有区别的,具体的细节我会在后面进行详细解释。
从标准输入读取
除了普通的变量定义方式,我们也可以获取用户的输入,并将其存储在变量中;
语法:read var_name
,当运行到read
所在行时,当前 shell 进程阻塞,等待用户输入;
当用户输入完成,并键入回车后,当前进程继续执行,输入的字符串(不包含换行符)将保存至var_name
中。
变量命名规则
只能以字母
或_
开头,后面可跟字母
、数字
、_
。
变量初始化
在定义变量的时候必须进行赋值(称为:初始化),如 name='Otokaze'
,也可以赋空值:name=""
;
其实 shell 允许你直接使用未定义/赋值的变量,未定义变量在字符串上下文中为空串,数值上下文中为 0。
引用变量值
定义变量后就可以使用这个变量了,比如我要打印 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 name
、unset 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 支持 4 种语法来进行算数运算(只支持整数运算):let
、(())
、$(())
、(过时,同 $[]
$(())
)。let
、(())
、$(())
这 3 个都是内置命令(废话),它们所支持的运算符是一样的,那么它们有什么区别呢?
let
:支持多个算数表达式的计算(单纯计算)(())
:只支持单个算数表达式的计算(单纯计算)$(())
:只支持单个算数表达式的计算(计算&结果替换)
基本用法:
let
和 (())
是有返回值的,如果最后一个表达式的值不为 0 则为 true(返回 0),如果为 0 则为 false(返回 1),和 C 语言一样。
let 支持的运算符(优先级从高到低):
var++
、var--
:后自增、后自减++var
、--var
:前自增、前自减+expr
、-expr
:一元加(乘以 1)、一元减(乘以 -1)!
、~
:逻辑非、按位非,!
建议放在单引号中(let)**
:幂(乘方)*
、/
、%
:乘、除、取余+
、-
:加、减<<
、>>
:按位左移、按位右移<
、<=
、>
、>=
:小于、小于等于、大于、大于等于==
、!=
:等于、不等于&
:按位与^
:按位异或|
:按位或&&
:逻辑与||
:逻辑非expr1 ? expr2 : expr3
:条件运算符,如果 expr1 为 true 则计算 expr2,如果 expr1 为 false 则计算 expr3=
、+=
、-=
、*=
、/=
、%=
、<<=
、>>=
、&=
、^=
、|=
:赋值、加减乘除取余、左移右移、按位与、按位异或、按位或
注意,let
系列的操作符只支持整数运算(即使计算结果是小数,也会被去除小数部分,注意不是四舍五入)。
如果需要进行小数运算(浮点数运算),请使用 awk、bc 等外部命令来完成,建议使用 awk,bc 有些系统没有。
关系运算符
关系运算符只支持数字,不支持字符串。
在 bash 中,需要借助/bin/test
或/bin/[
命令进行关系运算。test
和[
是一样的,它们都是普通命令,区别是[
需要使用]
标记结束。
如:test 10 -eq 10
、[ 10 -eq 10 ]
,这也就是为什么[]
内侧需要空格。
建议使用 [
替代 test
命令,因为 bash 已经内置了 [
命令,所以效率更高。
注意,在 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 位)、\uHHHH
Unicode码、\uHHHHHHHH
Unicode码。
echo
和 printf
都是 bash 的内置命令,因此从效率上讲没多大区别,根据自己的需要选择使用。
流程控制
注意,每个分支不可为空,如果不需要此分支,那就不要写,如果真的需要,那么就使用
:
内置命令填充。
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、continuebreak
:结束当前循环,执行循环后面的语句;continue
:结束此轮循环,直接开始下轮循环。
这两个关键字可用于for
、while
、until
中。
break
和 continue
后面可以接一个整数(大于 0),即 break N
、continue N
。
这个 N 是什么意思呢?它表示要跳出几层循环,N 默认为 1,即只跳出当前这层循环。例子:
函数
在 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; }
;如果不在同一行,就不需要使用空格隔开和以分号结束,具体例子如下:
函数定义的推荐语法
使用标准的、可移植的 func_name() { ... }
形式。此外,再介绍一个特殊的语法:func_name() ( ... )
,看例子:
#!/bin/bash
func1() {
echo "[func1] change dir to /etc"
cd /etc
echo "[func1] current dir is: $(pwd)"
}
func2() (
echo "[func2] change dir to /bin"
cd /bin
echo "[func2] current dir is: $(pwd)"
)
cd /
echo "[main] before call func1: $(pwd)"
func1
echo "[main] after call func1: $(pwd)"
echo "[main] before call func2: $(pwd)"
func2
echo "[main] after call func2: $(pwd)"
# root @ arch in ~ [20:55:58]
$ chmod +x test.sh
# root @ arch in ~ [20:56:25]
$ ./test.sh
[main] before call func1: /
[func1] change dir to /etc
[func1] current dir is: /etc
[main] after call func1: /etc
[main] before call func2: /etc
[func2] change dir to /bin
[func2] current dir is: /bin
[main] after call func2: /etc
区别就是,func_name() { ... }
定义的是一个具名函数,shell 中的函数都是在当前 shell 进程中执行的,所以使用 cd 改变工作目录时会影响到当前 shell 进程;而 func_name() ( ... )
其实定义的是一个具名子程序,当你调用这个“函数”时,其实相当于调用一个外部的 shell script 脚本,该子程序运行在独立的 shell 进程,该 shell 进程与调用者所在的 shell 进程是互不影响的。在某些场合,使用 func_name() ( ... )
有奇效。
重定向
要彻底理解重定向,我们必须先来了解这些基础知识:
文件描述符
文件描述符是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。
当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。
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 合并到 stdoutcommand 1>&2
:将 stdout 合并到 stderr
heredoc
有时候我不想从 stdin 读取数据,而是想现写,这时候就可以使用<<
,打开 heredoc 功能。
具体用法:command <<EOF
,其中 EOF
只是一个表示结束的字符串,你可以换成任意字符串;
当你按下回车后,你就可以写入任意数据了,写完后,新起一行,输入EOF
,按下回车就可以了。
command <<EOF
:不带引号的 EOF,允许变量引用、命令替换;command <<'EOF'
:单引号的 EOF,关闭变量引用、命令替换;command <<"EOF"
:双引号的 EOF,关闭变量引用、命令替换。
无论带不带引号,heredoc 都使用单独的 EOF 行表示结束(前后不能有任何内容)。
这个功能在 shell 脚本中很常用。比如,我想在脚本中输出一大段内容到一个文件中,heredoc 就派上用场了:
heredoc 变种
除了 <<EOF
外,我们还可以直接使用 <<<'string data'
或 <<<"string data"
或 <<<$'string data'
(如果没有空白符也可以不加引号,第 3 种会进行转义),它们的作用是一样的(重定向 stdin),<<EOF
适用于多行文本,<<<string
适用于单行文本串(当然也可以多行),例子:
重定向汇总
命令 | 说明 |
---|---|
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-shellsub-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' |
转义单引号中的字符串,支持 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 数组末尾。- shell 允许未定义
string
、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'
。区别如图:
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 ...'
。
mktempmktemp
,用于创建临时文件,并打印出 tmp 文件所在的路径,参数如下。
-d
,创建临时文件夹而不是临时文件;-u
,仅仅打印 tmp 文件的路径,而不创建(不安全);-q
,在创建文件失败或其他错误时,不输出错误信息;--suffix=SUFF
,指定文件后缀;-p
,指定文件夹,默认为$TMPDIR
,如果该变量为空则使用/tmp
目录;
cat、sed
有时候我们从肉眼上很难区分 tab 和连续空格,不过我们可以借助 cat、sed 来区分:
xxd、odxxd
和od
都是 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
,指定输出格式,c
ASCII字符、oN
八进制,可指定单位长度 N、xN
十六进制,可指定单位长度 N。
od
命令举例:od -An -to1 /etc/resolv.conf
(八进制)、od -An -tc /etc/resolv.conf
(ASCII 字符 + 转义字符)。
dirname、basenamedirname path
:获取 path 路径字符串的父目录部分,如dirname /a/b/c
输出/a/b
、dirname /a/b/c/
输出/a/b
;basename path
:获取 path 路径字符串的文件(目录)名,如basename /a/b/c
输出c
、basename /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)
;是不是很简单,它同时支持第一种和第二种情况,无论是绝对路径还是相对路径都能适应!
pgreppgrep
命令,可以理解为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 文件中查找进程;
lsoflsof
即list 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 开始)。
守护进程的启动方式
nohup command args... </dev/null &>>/var/log/proc.log &
setsid command args... </dev/null &>>/var/log/proc.log
command args... </dev/null &>>/var/log/proc.log & disown
(command args... </dev/null &>>/var/log/proc.log &)
(推荐)
参数解析
一般的 shell 脚本都不会有太复杂的命令行参数,这时候使用$n
获取位置参数就足够了。
但是,有些时候需要解析一些比较复杂的命令行参数,这时候就需要 shift 和 getopts 了。
shiftshift
是 shell 的一个内建命令,常用于位置参数的解析;
shift 将所有位置参数(除$0
)往前移动一个单位,即丢弃最前边的参数;
如:shift 3
,往前移动三个位置、不带参数的shift
则相当于shift 1
。
最简单的用法,依次获取每个位置参数(当然,这只是演示一下 shift 的用法)
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,就没有问题了。
别名处理
在交互式 shell(bash) 中,我们可以使用 alias mycmd='command args...'
语法来创建一个 alias 别名,当我们执行 mycmd
命令时,实际上执行的是 command args...
命令,通常我们会为一个长命令创建一个短别名,方便使用。那么你有尝试过在 shell script 中使用 alias
指令吗?如果你尝试过,你可能已经知道,在 bash 脚本中,默认是不允许使用 alias 指令的,虽然这条指令不会报错,但是实际上是没有生效的,请看例子:
#!/bin/bash
alias sayhello='echo "hello, world!"'
sayhello
$ ./alias.sh
./alias.sh: line 3: sayhello: command not found
那么如果一定要在 shell script 中使用 alias 指令,该怎么办呢?简单,在脚本开头添加 shopt -s expand_aliases
命令即可:
#!/bin/bash
shopt -s expand_aliases
alias sayhello='echo "hello, world!"'
sayhello
$ ./alias.sh
hello, world!
捕捉信号
在 C/C++ 中我们可以使用 signal()
函数为某个信号注册一个处理函数,这样当我们的程序收到给定信号时,就会自动执行我们指定的处理函数,这在某些时候非常有用。那么我们能否在 shell 脚本中捕捉指定的信号,然后注册对应的处理函数呢?当然是可以的,使用内置命令 trap
即可,用法非常简单,trap cmd_string signals...
,cmd_string 是对应的“处理函数”,可以是任何有效的 shell 语句,建议使用单引号或双引号包含 cmd_string,而 signals 是要捕捉的信号名称,如 INT
(用户按下 Ctrl+C)、TERM
(kill 的默认信号)等等,多个信号可使用空格隔开,也可以有多个 trap 语句,只要它们捕捉的信号不冲突就行。当 shell 进程捕捉到对应的信号后,会查找通过 trap 注册的处理语句(cmd_string),然后使用 eval
解析并执行我们传递给它的 cmd_string
。例子:
#!/bin/bash
trap 'echo +$0+; exit' INT
trap 'echo -$0-; exit' TERM
while true; do
echo 'hello, world'
sleep 1
done
添加可执行权限,然后执行脚本,当我们按下 Ctrl+C 组合键,就会执行 echo +$0+; exit
语句,也就是打印当前脚本名称然后退出脚本;当我们使用 kill
给该 shell 进程发送 TERM 信号时,脚本就会执行 echo -$0-; exit
语句,也是打印当前脚本名称然后退出脚本。
脚本调试
除了使用 shopt
设置 shell options 外,我们还可以使用 set
命令来设置 bash 的一些 options,比如 set -v
表示启用脚本回显功能(即在执行一条命令前都会先打印一下),对应的,set +v
表示禁用脚本回显功能(默认就是禁用),总之你记住,set -XXX
就是启用某选项,而 set +XXX
就是禁用某选项。除了通过 set 命令来设置,我们也可以直接通过 bash 命令行选项来传递,比如 bash -v /path/to/script.sh
,当然也可以直接在 script.sh 的脚本第一行加上 -v
选项,即 #!/bin/bash -v
,不过有些时候我们只希望在脚本的指定位置启用一些选项,然后又关闭这些选项,这种情况下只能使用 set -v
,比如只在某个函数中设置 -v 选项,就可以在函数第一行调用 set -v
来启用该选项,然后在函数最后一行调用 set +v
来关闭该选项,非常灵活和方便。
脚本回显,打印当前执行的命令:set -v
#!/bin/bash -v
echo "www.zfl9.com"
echo "www.baidu.com"
echo "www.google.com"
$ ./test.sh
#!/bin/bash -v
echo "www.zfl9.com"
www.zfl9.com
echo "www.baidu.com"
www.baidu.com
echo "www.google.com"
www.google.com
调试脚本,比 set -v
更详细:set -x
#!/bin/bash -x
echo "www.zfl9.com"
echo "www.baidu.com"
echo "www.google.com"
$ ./test.sh
+ echo www.zfl9.com
www.zfl9.com
+ echo www.baidu.com
www.baidu.com
+ echo www.google.com
www.google.com
严格模式,只要有一个命令返回值非 0 就结束运行:set -e
#!/bin/bash -e
ech0
echo "www.zfl9.com"
echo "www.baidu.com"
echo "www.google.com"
$ ./test.sh
./test.sh: line 2: ech0: command not found
set -u
,当尝试读取未定义的 shell 变量时,将直接结束运行
#!/bin/bash -u
echo "$string"
echo "hello, world"
$ ./test.sh
./test.sh: line 2: string: unbound variable
set -eo pipefail
,如果管道命令的某个子命令返回非 0 则结束运行
注意单独设置
set -o pipefail
是没效果的,要和set -e
一起使用才有效果。
#!/bin/bash
set -eo pipefail
foobar | echo "pipe"
echo "hello, world"
$ ./test.sh
pipe
./test.sh: line 3: foobar: command not found
个人习惯在 shell 脚本开头加上这几行,可以极大的提高脚本的健壮性
#!/bin/bash
set -o nounset
set -o errexit
set -o pipefail
shopt -s expand_aliases