第 B 章 Linux Shell 脚本

作者: Brinnatt 分类: ARM64 Linux 补充知识 发布时间: 2022-01-21 20:44

脚本都以 #!/bin/bash 开头,# 称为 sharp,! 在 unix 行话里称为 bang,合起来简称就是常见的 shabang。/bin/bash 表示在执行脚本时内部会使用该路径的 bash 去执行。

脚本被执行有两种方式:一种是将脚本作为 bash 命令的参数,一种是作为独立的可执行文件执行。

作为 bash 命令的命令行参数时,此时 #!/bin/bash 行就无所谓存在与否。运行方式如:

[root@arm64v8 ~]# cat cat.sh 
wc -l /etc/passwd
[root@arm64v8 ~]# bash cat.sh 
40 /etc/passwd
[root@arm64v8 ~]#

作为独立的可执行文件执行时要求对脚本文件具有可执行权限,运行的方式是直接使用脚本名,如:

[root@arm64v8 ~]# cat cat.sh 
#!/bin/bash
wc -l /etc/passwd
[root@arm64v8 ~]# chmod u+x cat.sh 

# 相对路径执行
[root@arm64v8 ~]# ./cat.sh
40 /etc/passwd
[root@arm64v8 ~]#

# 绝对路径执行
[root@arm64v8 ~]# /root/cat.sh 
40 /etc/passwd
[root@arm64v8 ~]#

B1、echo 和 printf 打印

无论是在命令提示符下,还是使用脚本运行命令,都少不了打印命令,特别是在调试时经常用到;常用的命令是 echo;偶尔为了使打印结果更美观,会用到 printf,该命令可以对结果进行格式化输出。

echo 引号和感叹号问题

[root@arm64v8 ~]# echo "Hello World!"
-bash: !": event not found
[root@arm64v8 ~]# 
[root@arm64v8 ~]# echo 'Hello World!'
Hello World!
[root@arm64v8 ~]#
[root@arm64v8 ~]# cat cat.sh 
#!/bin/bash
echo "Hello World!"
[root@arm64v8 ~]# 
[root@arm64v8 ~]# ./cat.sh 
Hello World!
[root@arm64v8 ~]#
  • 在 bash 环境下,感叹号只能通过单引号包围来输出,因为此时感叹号表示引用历史命令,除非设置 "set +H" 关闭历史命令的引用。
  • 在 shell 脚本中不会出现这类问题。

echo 中的转义

echo -e 识别转义和特殊意义的符号,如换行符、n、制表符 \t、转义符 \ 等。

[root@arm64v8 ~]# echo -e 'Hello World!\n';echo 'Hello World!'
Hello World!

Hello World!
[root@arm64v8 ~]# echo 'Hello World!\n';echo 'Hello World!'
Hello World!\n
Hello World!
[root@arm64v8 ~]#

echo 默认的分行处理

不加 -n 的默认情况下 echo 会在每行行尾加上换行符号,使用 echo -n 取消分行输出。

[root@arm64v8 ~]# echo 'Hello World!' > echo.sh
[root@arm64v8 ~]# echo 'Hello World!' >> echo.sh 
[root@arm64v8 ~]# cat echo.sh 
Hello World!
Hello World!
[root@arm64v8 ~]# 
[root@arm64v8 ~]# echo -n 'Hello World!' > echo1.sh
[root@arm64v8 ~]# echo 'Hello World!' >> echo1.sh
[root@arm64v8 ~]# cat echo1.sh 
Hello World!Hello World!
[root@arm64v8 ~]#

echo 颜色输出

echo 可以控制字体颜色和背景颜色输出。

格式:echo -e "\033[ ; m …… \033[0m"

常见的字体颜色:重置=0,黑色=30,红色=31,绿色=32,黄色=33,蓝色=34,紫色=35,天蓝色=36,白色=37。

常见的背景颜色:重置=0,黑色=40,红色=41,绿色=42,黄色=43,蓝色=44,紫色=45,天蓝色=46,白色=47。

ANSI 控制码的说明

  • \033[0m 关闭所有属性 \033[1m 设置高亮度 \033[4m 下划线 \033[5m 闪烁 \033[7m 反显 \033[8m 消隐
  • \033[nA 光标上移n行 \033[nB 光标下移n行 \033[nC 光标右移n行 \033[nD 光标左移n行 \033[y;xH设置光标位置
  • \033[2J 清屏 \033[K 清除从光标到行尾的内容 \033[s 保存光标位置 \033[u 恢复光标位置 \033[?25l 隐藏光标
  • \033[?25h 显示光标

因为需要使用特殊符号,所以需要配合 -e 选项来识别特殊符号。

[root@arm64v8 ~]# echo -e "\e[1;44;33m yellow words on blue background \e[0m"
 yellow words on blue background 
[root@arm64v8 ~]#
  • 颜色控制和字体控制选项的定义顺序无所谓,只要被定义出来,shell 都能识别。建议定义了颜色后同时定义关闭颜色,否则颜色会继续影响 bash 环境的颜色。

  • 另外,任意一个 \e 可以使用 \033 替换。

printf

使用 printf 可以输出更规则更格式化的结果。它引用于 C 语言的 printf 命令,但是有些许区别。

使用 printf 可以指定字符串的宽度、实现左对齐(使用减符号 -)、右对齐(默认的)、格式化小数输出等。

使用 printf 最需要注意的两点是:

  1. printf 默认不在结尾加换行符,它不像 echo 一样,所以要手动加 "\n" 换号;
  2. printf 只是格式化输出,不会改变任何结果,所以在格式化浮点数的输出时,浮点数结果是不变的,仅仅只是改变了显示的结果。
[root@arm64v8 ~]# cat > printf.sh << EOF       # 将下面的内容覆盖到printf.sh脚本中
> #!/bin/bash
> # filename: printf.sh
> printf "%-5s %-10s %-4s\n" No Name Mark     # 三个%分别对应后面的三个参数
> printf "%-5s %-10s %-4.2f\n" 1 Sarath 80.34 # 减号“-”表示左对齐
> printf "%-5s %-10s %-4.2f\n" 2 James 90.998 # 5s表示第一个参数占用5个字符
> printf "%-5s %-10s %-4.2f\n" 3 Jeff 77.564
> EOF
[root@arm64v8 ~]# bash printf.sh                # 执行结果:左对齐,取小数点后两位
No    Name       Mark
1     Sarath     80.34
2     James      91.00
3     Jeff       77.56
[root@arm64v8 ~]#
[root@arm64v8 ~]# sed -i s#'-'##g printf.sh       # 将减号“-”去掉,将右对齐
[root@arm64v8 ~]# bash printf.sh
   No       Name Mark
    1     Sarath 80.34
    2      James 91.00
    3       Jeff 77.56
[root@arm64v8 ~]#

printf 中还可以加入分行符、制表符等符号。

#修改printf.sh将其改为如下格式
[root@arm64v8 ~]# cat printf.sh 
#!/bin/bash
# filename: printf.sh
printf "%-s\t %-s\t %s\n" No Name Mark
printf "%-s\t %-s\t %4.2f\n" 1 Sarath 80.34
printf "%-s\t %-s\t %4.2f\n" 2 James 90.998
printf "%-s\t %-s\t %4.2f\n" 3 Jeff 77.564
[root@arm64v8 ~]# 

# 出现制表符
[root@arm64v8 ~]# bash printf.sh 
No   Name    Mark
1    Sarath  80.34
2    James   91.00
3    Jeff    77.56
[root@arm64v8 ~]#

printf 还有一个常见的 i 格式,表示对整型格式化占用几个整数,前面示例中的 s 表示对字符格式化。

B2、Bash 支持的运算操作

id++ id-- ++id --id                     # 自增、自减
- +                                     # 一元运算符,即正负号
! ~                                     # 逻辑取反、位取反
**                                      # 幂运算
* / %                                   # 乘法、除法、取模
+ -                                     # 加法、减法
<< >>                                   # 位左移、位右移
&                                       # 按位与运算
^                                       # 按位异或运算
|                                       # 按位或运算
<= >= < >                               # 大小比较
== !=                                   # 等值比较
&&                                      # 逻辑与
||                                      # 逻辑或
expr ? expr : expr                      # 三目条件运算
= *= /= %= += -= <<= >>= &= ^= |=       # 各种赋值语句
expr1 , expr2                           # 多个表达式,例如$((x+=1,y+=3,z+=4))

几个注意事项:

  1. 空变量或未定义变量或字符串变量参与数值运算时,都当作数值 0
  2. 变量替换先于算术运算,所以既可以使用变量名 var,也可使用变量引用 $var,例如 $[a+1]$[$a+1] 在结果上是等价的
  3. 算术表达式可以进行嵌套,先计算最内层。如 $(( (6+4)/$[2+3] ))
  4. 0 开头的表示八进制,0x 或 0X 开头的表示十六进制,可使用 base#N 的方式指定 N 是多少进制的数。例如 echo $[ 010 + 10 ] 得到 18、echo $[ 0x10 + 10 ] 得到 26,echo $[ 2#100 + 10 ] 得到 14。参见下面一个实际案例
  5. 由上面的运算符可看出,$[]、$(()) 以及 let 命令,既支持算术运算,还支持赋值、大小比较、等值比较。如 a=3;echo $[a==3]

B3、变量

变量存在于内存中。假设变量 str,设置或修改变量属性时,不带 $ 号,只有引用变量的值时才使用 $ 号。也就是说在内存中,标记变量的变量名称是 str,而不是 \$str。

B3.1、环境变量

环境变量就是运行在 "环境" 上下文的,在这个上下文都可以引用。

  • 例如,常见的 cd、ls 等命令严格来说应该使用绝对路径如 /bin/ls 来执行,由于 /bin 目录加入到了 PATH 环境变量中,系统自己会去寻找 PATH 下的路径是否有该命令。

环境变量常用大写字母表示。

  • 常见的环境变量有 HOSTNAME、SHELL、HISTSIZE、USER、PATH、PWD、LANG、HOME、LOGNAME。
    • 分别表示当前主机名、SHELL 的路径即 bash 的类型、history 保存多少记录、当前用户名、自动搜索路径、当前目录、使用的语系(临时修改语系时就改这个变量)、当前用户的家目录、当前登录的用户。

使用 env 或者 export 可以查看当前用户的环境变量。

[root@arm64v8 ~]# env
XDG_SESSION_ID=3
HOSTNAME=arm64v8
TERM=xterm
SHELL=/bin/bash
HISTSIZE=1000
SSH_CLIENT=10.51.111.52 13872 22
SSH_TTY=/dev/pts/1
QT_GRAPHICSSYSTEM_CHECKED=1
USER=root
LS_COLORS=rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=01;05;37;41:su=37;41:sg=30;43:ca=30;41:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.Z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.jpg=01;35:*.jpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.axv=01;35:*.anx=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=01;36:*.au=01;36:*.flac=01;36:*.mid=01;36:*.midi=01;36:*.mka=01;36:*.mp3=01;36:*.mpc=01;36:*.ogg=01;36:*.ra=01;36:*.wav=01;36:*.axa=01;36:*.oga=01;36:*.spx=01;36:*.xspf=01;36:
MAIL=/var/spool/mail/root
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/root/bin
PWD=/root
LANG=en_US.UTF-8
HISTCONTROL=ignoredups
SHLVL=1
HOME=/root
LOGNAME=root
XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share
SSH_CONNECTION=10.51.111.52 13872 10.51.111.142 22
LESSOPEN=||/usr/bin/lesspipe.sh %s
XDG_RUNTIME_DIR=/run/user/0
DISPLAY=localhost:10.0
_=/usr/bin/env
[root@arm64v8 ~]#

使用 echo 可以输出变量的值。

[root@arm64v8 ~]# echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/root/bin
[root@arm64v8 ~]#

例如,在 PATH 环境变量中新加入一个目录 /usr/local/mysql/bin。

[root@arm64v8 ~]# PATH=/usr/local/mysql/bin:$PATH

这里也能看到两个 PATH,第一个没使用 $,第二个使用了。当对变量本身进行操作,则不使用 $,当对变量值进行操作,则使用 $

B3.2、普通变量

脚本语言是弱类型的语言,变量通常不需要特地声明甚至不需要初始化,在脚本运行时由解释器进行解释运算,解释器知道变量在什么时候是什么类型,所以直接赋值使用即可。

bash 中,变量默认都是字符串类型,不论是否使用引号赋值,都以字符串方式存储。

  1. 变量赋值方式:str=value 。注意等号左右没有空格。如果有空格就是进行比较运算符的比较运算了。

  2. 变量引用方式:$str 或者 ${str},例如 echo "the var is ${str}"

    [root@arm64v8 ~]# str="King of the world"
    [root@arm64v8 ~]# echo "I am the $str"
    I am the King of the world
    [root@arm64v8 ~]#
  3. 释放变量: unset str,注意变量名前不加前缀 $

    [root@arm64v8 ~]# unset str 
    [root@arm64v8 ~]# echo "I am the $str"
    I am the 
    [root@arm64v8 ~]#
  4. 查看所有的变量:不接任何参数的 set 或者 declare 命令,输出结果中包含了普通变量和环境变量。

  5. 定义只读变量:readonly str。这时将无法修改变量值也无法 unset 变量,只有重新登录 shell 才能继续使用只读变量。

  6. 临时将普通变量升级为环境变量: export str 或者赋值时 export str="value" ,这样 $str 就可以在当前 shell 和子 shell 中使用,但是退出脚本或者重新登录 shell 都会取消 export 效果。

    [root@arm64v8 ~]# str="Hello World";echo $str    # 当前shell定义变量str
    Hello World
    [root@arm64v8 ~]# bash                           # 开启子shell
    [root@arm64v8 ~]# echo $str                      # 子shell中查看变量结果发现没有该变量。
    
    [root@arm64v8 ~]# exit                           # 退出子shell
    exit
    [root@arm64v8 ~]# echo $str                      # 当前shell可以使用str变量
    Hello World
    [root@arm64v8 ~]#
    • 在子 shell 中查看变量,结果竟然没有该变量。这是因为 $str 的作用域只在当前 shell,要想在子 shell 中也能引用普通变量,则需要使用 export 升级为环境变量。

      [root@arm64v8 ~]# echo $str                # 这是刚才定义的str
      Hello World
      [root@arm64v8 ~]# export str           # 使用export升级为环境变量
      [root@arm64v8 ~]# export | grep str        # 查看环境变量果然有str
      declare -x str="Hello World"
      [root@arm64v8 ~]#

B3.3、修改变量的生命周期和作用域

普通的变量在脚本结束或退出登录后就失效,并且只对当前 shell 有效,其他用户和当前用户的子 shell 都无法使用。

使用 export 可以升级为临时局部的环境变量,只对当前用户的当前 shell 和子 shell 有效,退出脚本和退出登录后也失效。

如果想要设置永久的且全局的变量,一种方法是将变量的设置语句放入到 /etc/profile 文件中,因为每个用户登录时,都会调用该文件并执行其中的语句。

如果想立即加载此文件中的配置使得临时添加的设置立即生效,只需 source 该文件即可。

[root@arm64v8 ~]# source /etc/profile

/etc/profile 文件是 bash 的全局配置文件,还有每个用户的配置文件 ~/.bash_profile,此文件中的变量将只对对应的用户生效。

B3.4、获取变量的长度

在使用 "${}" 方式引用变量时,变量名前加上#就可以查看该变量的字符长度。空格也算入长度。例如:

[root@arm64v8 ~]# echo ${str}
Hello World
[root@arm64v8 ~]# echo ${#str}
11
[root@arm64v8 ~]# echo ${#PATH}
59
[root@arm64v8 ~]# 

B3.5、declare 声明变量

declare [+/-][选项] 变量名

选项说明:
-/+     给变量设定类型属性,取消给变量设定的类型属性
-i      声明为整型
-x      声明为环境变量
-p      显示变量名和值

例如,声明一个环境变量 declare -x str,取消该变量 declare +x str

[root@arm64v8 ~]# export | grep str     # 这是当时使用export str将普通变量升级为环境变量
declare -x str="Hello World"          # 你会发现竟然显示的是 declare,说明跟使用declare声明一样
[root@arm64v8 ~]# 
[root@arm64v8 ~]# declare +x str        # 取消该变量
[root@arm64v8 ~]# 
[root@arm64v8 ~]# export | grep str     # 查看已取消
[root@arm64v8 ~]#

B3.6、位置变量和特殊变量

$?:         上一条代码执行的回传指令,回传0表示标准输出,即正确执行,否则为标准错误输出。

$$:         当前shell的PID。除了执行bash命令和shell脚本时,$$不会继承父shell的值,其他类型的子shell都继承。

$BASHPID:   当前shell的PID,这和"$$"是不同的,因为每个shell的$BASHPID是独立的。而"$$"有时候会继承父shell的值。

$!:         最近一次执行的后台进程PID。

$#:         统计参数的个数。

$@:         所有单个参数,如"a""b""c""d"。

$*:         所有参数的整体,如“abcd”。

$0:         脚本名。

$1……$n:     参数位置。

使用下面的脚本来验证位置变量和特殊变量。

[root@arm64v8 ~]# cat var.sh
#!/bin/bash
# try to understand these special variables

echo '$?:'$?
echo '$$:'$$
echo '$!:'$!
echo '$#:'$#
echo '$@:'$@
echo '$*:'$*
echo '$0:'$0
echo '$1:'$1
echo '$2:'$2
echo '$3:'$3
echo '$4:'$4
[root@arm64v8 ~]# chmod u+x var.sh 
[root@arm64v8 ~]# ./var.sh a b c d e        # 使用5个参数来运行该脚本。
$?:0
$$:9894
$!:
$#:5
$@:a b c d e
$*:a b c d e
$0:./var.sh
$1:a
$2:b
$3:c
$4:d
[root@arm64v8 ~]#

B3.7、shift 轮替变量

使用 shift [N] 可以指定参数轮替,每执行一次 shift N 就踢掉 N 个参数,默认 N 为 1。

先看例子再解释:

[root@arm64v8 ~]# cat shift.sh 
#!/bin/bash

while [ $# -ne 0 ]
do
    echo -e "\e[31m 第一个参数为: $1 \e[0m"
    echo -e "\e[32m 参数个数为: $#   \e[0m"
    echo
    shift
done
[root@arm64v8 ~]# chmod u+x shift.sh 
[root@arm64v8 ~]# 
[root@arm64v8 ~]# ./shift.sh 小明 小刚 小亮 小雄 小贝
 第一个参数为: 小明 
 参数个数为: 5   

 第一个参数为: 小刚 
 参数个数为: 4   

 第一个参数为: 小亮 
 参数个数为: 3   

 第一个参数为: 小雄 
 参数个数为: 2   

 第一个参数为: 小贝 
 参数个数为: 1   

[root@arm64v8 ~]#
  • 小明 小刚 小亮 小雄 小贝 分别对应 $1, $2, $3, $4, $5,shift 参数操作,是将参数从左到右逐个移动。
  • 脚本处理 $1 之后,遇到 shift(默认 shift 1),原 $1 消失,​$2 变为$1$3 变为$2$4 变为$3$5 变为 $4;就这样依次变动直到 shift 完所有参数。
    • 脚本最先处理 小明,处理完后,遇到 shift, 小明 消失,小刚 顶到原来 小明 的位置,后面参数依次向前挪动。

B3.8、变量引用

  1. 正常情况,变量引用的方式:${VARNAME},可以省略 ,即 $VARNAME

  2. 变量中字符的长度: ${#VARNAME}

  3. 变量赋值

    ${parameter:-word}:              如果parameter为空或未定义,则变量展开为“word”;否则展开为parameter的值;
    
    ${parameter-word}:               几乎等价${parameter:-word},除了parameter设置了但为空时,变量的结果将是null,而非word。
    
    ${parameter:+word}:              如果parameter为空或未定义,不做任何操作,即仍然为空;否则展开为“word”值;
    
    ${parameter:=word}:              如果parameter为空或未定义,则变量赋值(注意是赋值,不是展开)为“word”,否则为parameter自身;
    
    ${parameter=word}:               在man bash里没有该定义,但是经测试,等价于${para:=word}。
    
    ${parameter:offset}:         取子串,从offset处的后一个字符开始取到最后一个字符;
    
    ${parameter:offset:length}:      取子串,从offset处的后一个字符开始,取lenth长的子串;
  4. 如果定义了一个配置文件,该文件中有很多变量、函数、命令等,想要加载它们到当前环境下,可使用 source 或者 . 命令。

    [root@arm64v8 ~]# source /etc/profile.d/bash_completion.sh 
    [root@arm64v8 ~]# . /etc/profile.d/bash_completion.sh
  5. 局部变量,在函数中定义局部变量使其不影响函数外的同名变量

    local VAR_NAME=

B3.9、变量的切分、提取和替换

其实是对变量实现的功能,只是使用文件名的说法比较典型,且容易理解它的用途。

例如,将文件名 "Linux.docx.jpg" 存放到变量 file_name 中,然后执行从左向右或从右向左的删除或贪婪删除。

[root@arm64v8 ~]# file_name="Linux.docx.jpg"
[root@arm64v8 ~]# 
[root@arm64v8 ~]# file_name_greedy=${file_name%%.*} 
[root@arm64v8 ~]# echo ${file_name_greedy}
Linux
[root@arm64v8 ~]# 
[root@arm64v8 ~]# file_name_nongreedy=${file_name%.*}
[root@arm64v8 ~]# echo $file_name_nongreedy
Linux.docx
[root@arm64v8 ~]# 
[root@arm64v8 ~]# extention_name_greedy=${file_name##*.}
[root@arm64v8 ~]# echo ${extention_name_greedy} 
jpg
[root@arm64v8 ~]# extension_name_nongreedy=${file_name#*.}
[root@arm64v8 ~]# echo ${extension_name_nongreedy} 
docx.jpg
[root@arm64v8 ~]#
  • ${var%%.*}${var%.*} 中的 %.* 表示从右向左匹配 .* 并删除,由于 Linux.docx.jpg 有两种符合条件的匹配:".jpg"".docx.jpg",所以使用两个 "%%" 表示贪婪删除,即删除最长匹配 ".docx.jpg"。可以使用一个 % 表示非贪婪删除,表示删除最短的匹配即 ".jpg"
  • ${var##*.}${var#*.} 中的 #*. 表示从左向右匹配 *. ##*. 执行贪婪删除,即删除 "Linux.docx.",同理 #*. 表示非贪婪删除,即删除 "Linux."

除了删除,还可以实现提取和替换的功能。

[root@arm64v8 ~]# echo $file_name
Linux.docx.jpg
[root@arm64v8 ~]# 
[root@arm64v8 ~]# echo "${file_name:0:5}"         # 提取第0个字节后的5个字节,即1-5字节
Linux
[root@arm64v8 ~]# echo "${file_name:6:4}"         # 提取第6个字节后的4个字节,即第7、8、9、10字节
docx
[root@arm64v8 ~]# echo "${file_name/jpg/pdf}"     # 非贪婪替换jpg为pdf,即只替换从左向右的第一个
Linux.docx.pdf
[root@arm64v8 ~]# echo "${file_name//jpg/pdf}"        # 贪婪替换jpg为pdf,即所有的jpg都替换为pdf
Linux.docx.pdf
[root@arm64v8 ~]#

B4、Bash 环境配置流程

当用户登录系统时,会加载各种 bash 配置文件,还会设置或清空一系列变量,有时还会执行一些自定义的命令。这些行为都算是启动 bash 时的过程。

另外,有些时候登录系统是可以交互的(如正常登录系统),有些时候是无交互的(如执行一个脚本)。

  • 总的来说 bash 启动类型可分为交互式 shell 和非交互式 shell。
    • 交互式 shell 还分为交互式的登录 shell 和交互式非登录 shell。
    • 非交互的 shell 在某些时候可以在 bash 命令后带上 "--login" 或短选项 "-l",这时也算是登录式,即非交互的登录式 shell。

判断是否交互式、是否登陆式

判断是否为交互式 shell 有两种简单的方法:

  1. 判断变量 "-",如果值中含有字母 "i",表示交互式。

    [root@arm64v8 ~]# echo $-
    himBH
    [root@arm64v8 ~]# cat login.sh 
    #!/bin/bash
    
    echo $-
    [root@arm64v8 ~]# bash login.sh 
    hB
    [root@arm64v8 ~]#
  2. 判断变量 PS1,如果值非空,则为交互式,否则为非交互式,因为非交互式会清空该变量。

    [root@arm64v8 ~]# echo $PS1
    [\u@\h \W]\$
    [root@arm64v8 ~]#

判断是否为登录式的方法也很简单,只需执行 "shopt login_shell" 即可。值为 "on" 表示为登录式,否则为非登录式。

[root@arm64v8 ~]# shopt login_shell 
login_shell     on
[root@arm64v8 ~]# 
[root@arm64v8 ~]# bash
[root@arm64v8 ~]# shopt login_shell 
login_shell     off
[root@arm64v8 ~]#

所以,要判断是交互式以及登录式的情况,可简单使用如下命令:

[root@arm64v8 ~]# echo $PS1;shopt login_shell
[\u@\h \W]\$
login_shell     on
[root@arm64v8 ~]# 
[root@arm64v8 ~]# echo $-;shopt login_shell
himBH
login_shell     on
[root@arm64v8 ~]#

几种常见的 bash 启动方式

  1. 正常登录(伪终端登录如 ssh 登录,或虚拟终端登录)时,为交互式登录 shell。

    [root@arm64v8 ~]# echo $PS1;shopt login_shell 
    [\u@\h \W]\$
    login_shell      on
    [root@arm64v8 ~]#
  2. su 命令,不带 "--login" 时为交互式、非登录式 shell,带有 "--login" 时,为交互式、登录式 shell。

    [root@arm64v8 ~]# su root
    [root@arm64v8 ~]# echo $PS1;shopt login_shell 
    [\u@\h \W]\$
    login_shell      off
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# su -
    Last login: Fri Sep 17 06:52:17 CST 2021 on pts/1
    [root@arm64v8 ~]# echo $PS1;shopt login_shell
    [\u@\h \W]\$
    login_shell      on
    [root@arm64v8 ~]#
  3. 执行不带 "--login" 选项的 bash 命令时为交互式、非登录式 shell。但指定 "--login" 时,为交互式、登录式 shell。

    [root@arm64v8 ~]# bash
    [root@arm64v8 ~]# echo $PS1;shopt login_shell
    [\u@\h \W]\$
    login_shell      off
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# bash -l
    [root@arm64v8 ~]# echo $PS1;shopt login_shell
    [\u@\h \W]\$
    login_shell      on
    [root@arm64v8 ~]#
  4. 使用命令组合(使用括号包围命令列表)以及命令替换进入子 shell 时,继承父 shell 的交互和登录属性。

    [root@arm64v8 ~]# (echo $BASH_SUBSHELL;echo $PS1;shopt login_shell)
    1
    [\u@\h \W]\$
    login_shell      on
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# su
    [root@arm64v8 ~]# (echo $BASH_SUBSHELL;echo $PS1;shopt login_shell)
    1
    [\u@\h \W]\$
    login_shell      off
    [root@arm64v8 ~]#
  5. ssh 执行远程命令,但不登录时,为非交互、非登录式。

    [root@arm64v8 ~]# ssh localhost 'echo $PS1;shopt login_shell'
    root@localhost's password: 
    
    login_shell      off
    [root@arm64v8 ~]#
  6. 执行 shell 脚本时,为非交互、非登录式 shell。但指定了 "--login" 时,将为非交互、登录式 shell。

    [root@arm64v8 ~]# cat login.sh 
    #!/bin/bash
    
    echo $PS1
    shopt login_shell
    
    # 不带"--login"选项时,为非交互、非登录式shell。
    [root@arm64v8 ~]# bash login.sh 
    
    login_shell      off
    [root@arm64v8 ~]# 
    
    # 带"--login"选项时,为非交互、登录式shell。
    [root@arm64v8 ~]# bash -l login.sh 
    
    login_shell      on
    [root@arm64v8 ~]#
  7. 在图形界面下打开终端时,为交互式、非登录式 shell。但可以设置为使用交互式、登录式 shell。

加载 bash 环境配置文件

无论是否交互、是否登录,bash 总要配置其运行环境。bash 环境配置主要通过加载 bash 环境配置文件来完成。但是否交互、是否登录将会影响加载哪些配置文件,除了交互、登录属性,有些特殊的属性也会影响读取配置文件的方法。

bash 环境配置文件主要有 /etc/profile~/.bash_profile~/.bashrc/etc/bashrc/etc/profile.d/*.sh,为了测试各种情形读取哪些配置文件,先分别向这几个配置文件中写入几个 echo 语句,用以判断该配置文件是否在启动 bash 时被读取加载了。

[root@arm64v8 ~]# echo "echo '/etc/profile goes'" >>/etc/profile
[root@arm64v8 ~]# echo "echo '~/.bash_profile goes'" >>~/.bash_profile
[root@arm64v8 ~]# echo "echo '~/.bashrc goes'" >>~/.bashrc
[root@arm64v8 ~]# echo "echo '/etc/bashrc goes'" >>/etc/bashrc
[root@arm64v8 ~]# echo "echo '/etc/profile.d/test.sh goes'" >>/etc/profile.d/test.sh
[root@arm64v8 ~]# chmod +x /etc/profile.d/test.sh
[root@arm64v8 ~]#
  1. 交互式登录 shell 或非交互式但带有 "--login"(或短选项 "-l",例如在 shell 脚本中指定 "#!/bin/bash -l" 时)的 bash 启动时,将先读取 /etc/profile,再依次搜索 ~/.bash_profile~/.bash_login~/.profile,并仅加载第一个搜索到且可读的文件。当退出时,将执行 ~/.bash_logout 中的命令。

    但要注意,在 /etc/profile 中有一条加载 /etc/profile.d/*.sh 的语句,它会使用 source 加载 /etc/profile.d/ 下所有可执行的 sh 后缀的脚本。

    [root@arm64v8 ~]# grep -A 8 \*\.sh /etc/profile  
    for i in /etc/profile.d/*.sh /etc/profile.d/sh.local ; do
       if [ -r "$i" ]; then
           if [ "${-#*i}" != "$-" ]; then 
               . "$i"
           else
               . "$i" >/dev/null
           fi
       fi
    done
    [root@arm64v8 ~]#
    • 内层 if 语句中的 "${-#*i}" != "$-" 表示将 "$-" 从左向右模式匹配 "*i" 并将匹配到的内容删除(即进行变量切分),如果"$-" 切分后的值不等于 "$-",则意味着是交互式 shell,于是怎样怎样,否则怎样怎样。

    同样的,在 ~/.bash_profile 中也一样有加载 ~/.bashrc 的命令。

    [root@arm64v8 ~]# grep -A 1 \~/\.bashrc ~/.bash_profile
    if [ -f ~/.bashrc ]; then
    . ~/.bashrc
    fi
    [root@arm64v8 ~]#

    ~/.bashrc 中又有加载 /etc/bashrc 的命令。

    [root@arm64v8 ~]# grep -A 1 /etc/bashrc ~/.bashrc
    if [ -f /etc/bashrc ]; then
    . /etc/bashrc
    fi
    [root@arm64v8 ~]#

    其实 /etc/bashrc 中还有加载 /etc/profile.d/*.sh 的语句,但前提是非登录式 shell 时才会执行。以下是部分语句:

    if ! shopt -q login_shell ; then   # We're not a login shell
    ...
       for i in /etc/profile.d/*.sh; do
           if [ -r "$i" ]; then
               if [ "$PS1" ]; then
                   . "$i"
               else
                   . "$i" >/dev/null 2>&1
               fi
           fi
       done
    ...
    fi
    • 从内层 if 语句和 /etc/profile 中对应的判断语句的作用是一致的,只不过判断方式不同,写法不同。

    因此,交互式的登录 shell 加载 bash 环境配置文件的实际过程如下

    1. /etc/profile --> /etc/profile.d/*.sh
    2. ~/.bash_profile --> ~/.bashrc --> /etc/bashrc --> /etc/profile.d/*.sh

    以下结果验证了结论:

    # 新开终端登录时
    Last login: Fri Sep 17 07:25:17 2021 from 10.51.111.52
    /etc/profile.d/test.sh goes
    /etc/profile goes
    /etc/bashrc goes
    ~/.bashrc goes
    ~/.bash_profile goes
    [root@arm64v8 ~]#
    # ssh远程登录时
    [root@arm64v8 ~]# ssh localhost
    root@localhost's password: 
    Last login: Fri Sep 17 08:08:46 2021 from 10.51.111.52
    /etc/profile.d/test.sh goes
    /etc/profile goes
    /etc/bashrc goes
    ~/.bashrc goes
    ~/.bash_profile goes
    [root@arm64v8 ~]#
    # 执行带有"--login"选项的login时
    [root@arm64v8 ~]# bash -l
    /etc/profile.d/test.sh goes
    /etc/profile goes
    /etc/bashrc goes
    ~/.bashrc goes
    ~/.bash_profile goes
    [root@arm64v8 ~]#
    # su带上"--login"时
    [root@arm64v8 ~]# su -
    Last login: Fri Sep 17 08:12:24 CST 2021 from 10.51.111.52 on pts/1
    /etc/profile.d/test.sh goes
    /etc/profile goes
    /etc/bashrc goes
    ~/.bashrc goes
    ~/.bash_profile goes
    [root@arm64v8 ~]#
    # 执行shell脚本时带有"--login"时
    [root@arm64v8 ~]# cat shell.sh 
    #!/bin/bash -l
    echo Hello
    [root@arm64v8 ~]# chmod u+x shell.sh 
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# ./shell.sh 
    /etc/profile goes
    /etc/bashrc goes
    ~/.bashrc goes
    ~/.bash_profile goes
    Hello
    [root@arm64v8 ~]#
    • 之所以执行 shell 脚本时没有显示执行 /etc/profile.d/*.sh ,是因为它是非交互式的,根据 /etc/profile 中的 if [ "${-#*i}" != "$-" ] 判断,它将会把 /etc/profile.d/*.sh 的执行结果重定向到 /dev/null 中。也就是说,即使是 shell 脚本(带 "--login " 选项),它也加载了所有 bash 环境配置文件。
  2. 交互式非登录 shell 的 bash 启动时,将读取 ~/.bashrc,不会读取 /etc/profile~/.bash_profile~/.bash_login~/.profile

    因此,交互式非登录 shell 加载 bash 环境配置文件的实际过程为:

    • ~/.bashrc --> /etc/bashrc --> /etc/profile.d/*.sh

    • 例如,执行不带 "--login" 的 bash 命令或 su 命令时。

      [root@arm64v8 ~]# bash
      /etc/profile.d/test.sh goes
      /etc/bashrc goes
      ~/.bashrc goes
      [root@arm64v8 ~]#
      
      [root@arm64v8 ~]# su
      /etc/profile.d/test.sh goes
      /etc/bashrc goes
      ~/.bashrc goes
      [root@arm64v8 ~]#
  3. 非交互式、非登录式 shell 启动 bash 时,不会加载前面所说的任何 bash 环境配置文件,但会搜索变量 BASH_ENV,如果搜索到了,则加载其所指定的文件。但并非所有非交互式、非登录式 shell 启动时都会如此,见情况 4。

    它就像是这样的语句:

    if [ -n "$BASH_ENV" ];then
       . "$BASH_ENV"
    fi

    几乎执行所有的 shell 脚本都不会特意带上 "--login" 选项,因此 shell 脚本不会加载任何 bash 环境配置文件,除非手动配置了变量 BASH_ENV。

  4. 远程 shell 方式启动的 bash,它虽然属于非交互、非登录式,但会加载 ~/.bashrc,所以还会加载 /etc/bashrc,由于是非登录式,所以最终还会加载 /etc/profile.d/*.sh,只不过因为是非交互式而使得执行的结果全部重定向到了 /dev/null 中。

    使用 ssh 连接但不登录远程主机时(例如只为了执行远程命令),就是远程 shell 的方式,但它却是非交互、非登录式的 shell。

    [root@arm64v8 ~]# ssh localhost echo "Hello"
    root@localhost's password: 
    /etc/bashrc goes
    ~/.bashrc goes
    Hello
    [root@arm64v8 ~]#
    • 正如上文所说,它同样加载了 /etc/profile.d/*.sh,只不过 /etc/bashrc 中的 if 判断语句 if [ "$PS1" ]; then 使得非交互式的 shell 要将执行结果重定向到 /dev/null 中。

B5、数学运算

使用 let、(())、$(())$[] 进行基本的整数运算,使用 bc 进行高级的运算,包括小数运算。

其中 let(()) 几乎完全等价,除了做数学运算,还支持数学表达式判断,例如数值变量 a 是否等于 3let a==3((a==3)),但一般不会使用它们来判断,而是使用 test 命令结合条件表达式:test "$a" -eq 3。因此,本文只介绍 let 的赋值运算功能。

B5.1、基本整数运算

[root@arm64v8 ~]# str=17
[root@arm64v8 ~]# let str=str+4     # 等价 let str+=4
[root@arm64v8 ~]# let str-=3        # 等价 let str=str-3
[root@arm64v8 ~]# echo ${str}
18
[root@arm64v8 ~]#

let 也可以使用 (( )) 进行替换,它们几乎完全等价。且额外的功能是:如果最后一个算术表达式结果为 0,则返回状态码 1,其余时候返回状态码 0

如果想在命令行中做计算,则可以使用 $(())$[]

[root@arm64v8 ~]# str=10
[root@arm64v8 ~]# echo $((str+=8))
18
[root@arm64v8 ~]# echo $[str=str-6]
12
[root@arm64v8 ~]# 

当然,在为变量赋算术值的时候也可以使用 $(())$[]

[root@arm64v8 ~]# str=11
[root@arm64v8 ~]# str=$((str+=7));echo $str
18
[root@arm64v8 ~]# str=$[str-=6];echo $str
12
[root@arm64v8 ~]#

其实,在算数计算过程中,等号右边的变量是可以带上 $ 符号的,但等号左边的变量不允许带上 $ 符号,因为它是要操作的变量,不是引用变量。例如:

[root@arm64v8 ~]# let str=$str-1            # 等价于let str=str-1
[root@arm64v8 ~]# str=$((str-1))            # 等价于str=$((str-1))
[root@arm64v8 ~]# str=$[$str-1]             # 等价于str=$[str-1]
[root@arm64v8 ~]# echo $((str=$str-1))      # 等价于echo $((str=str-1)),但不能写成echo $(($str=str-1))
8
[root@arm64v8 ~]# echo $[str=$str-1]        # 等价于echo $[str=str-1],但不能写成echo $[$str=str-1]
7
[root@arm64v8 ~]#

还可以自增、自减运算。"++""--" 表示变量自动加 1 和减 1。但是位置不同,返回的结果是不同的。

x++     先返回结果,再加1

++x     先加1再返回结果

x--     先返回结果,再减1

--x     先减1再返回结果

假如 x 的初始值为 10,则 echo $[x++] 将显示10,但在显示完后(即返回结果之后),x 的值已经变成了11,再执行 echo $x 将返回 11。

[root@arm64v8 ~]# x=10; echo $((x++)); echo $x
10
11
[root@arm64v8 ~]#

如果此时再 echo $[x++] 仍将返回 11,但此时 x 已经是 12 了。

[root@arm64v8 ~]# echo $((x++)); echo $x
11
12
[root@arm64v8 ~]#

再将 x 变量的初始值初始化为 10,则 echo $[++x] 将显示 11,因为先加 1 后再赋值给 x,echo 再显示 x 的值。++x 完全等价于 x=x+1,它们都是先加 1 后赋值。同理自减也是一样的。

[root@arm64v8 ~]# x=10; echo $((++x)); echo $x
11
11
[root@arm64v8 ~]#

因此,在使用自增或自减进行变量赋值时,需要注意所赋的值是否加减立即生效的。例如:

[root@arm64v8 ~]# x=10; y=$((x++)); echo $y; echo $y
10
10
[root@arm64v8 ~]#
  • 因为 y=$((x++)) 赋给 y 的值是加 1 前的值,虽然赋值结束后,​$((x++)) 已经变成 11,但这和 y 无关。

  • 所以,对于自增自减类的变量赋值应该使用先计算再显示的 "++x""--x" 的方式。

    [root@arm64v8 ~]# x=10; y=$((++x)); echo $y; echo $y
    11
    11
    [root@arm64v8 ~]#

总结下数值变量的赋值运算的方法:

let i=i-1
let i=$i-1
let i-=1
i=$((i-1))
i=$(($i-1))
i=$[i - 1]
i=$[$i - 1]
echo $((i=i-1))
echo $((i=$i-1))

let arr_test[a]=${arr_test[0]} - 1
let arr_test[a]-=1
echo $((arr_test[a]++))
echo $[ arr_test[a]++]

B5.2、bc 命令高级算术运算

bc 可用于浮点数的计算,是 linux 中的计算器。该命令功能丰富,支持自定义变量、自定义函数表达式逻辑、支持科学计算等等。虽然功能丰富,但多数时候仅用它的基本计算功能。

以下是一个基本的功能示例:

[root@arm64v8 ~]# bc
b 1.06.95                       # 首先输出bc的版本信息,可以使用-q选项不输出头部信息
Copyright 1991-1994, 197, 1998, 2000, 2004, 2006 Free Software Foundation, Inc.
This is free software with ABSOLUTELY NO WARRANTY.
For details type `warranty'.
pie=3.1415                      # 可以变量赋值
pie*3*3                         # 运算时不需要空格
28.2735
r=3
pie*r*r
28.2735
pie*r^2                         # 可以使用幂次方
28.2735
r=3 /* 将半径设置为3 */           # 还可以使用C语言风格的注释

输入 quit 命令可以退出 bc 计算器。

还支持自增和自减的功能。

[root@arm64v8 ~]# bc -q
r=2
r++
2
r++
3
++r
5
++r
6
--r
5

bc 运算器有一个内建的变量 scale,用于表示计算的精度(其实是刻度),即小数位保留几位。默认刻度为 0,所以除法运算的默认结果是整数。

[root@arm64v8 ~]# bc -q
13/(1+3)
3
scale=3
13/(1+3)
3.250
quit
[root@arm64v8 ~]#

更人性化的功能是可以通过命令替换来实现批处理模式的计算。

格式:var=`echo "option1;option2;...;expression"|bc`

其中 options 部分一般设置精度 scale,和变量赋值,expression 部分是计算表达式,最后将它们放在反引号中赋值给变量。如:

[root@arm64v8 ~]# area=`echo "scale=2;r=3;3.1415*r*r"|bc`
[root@arm64v8 ~]# echo $area 
28.2735
[root@arm64v8 ~]#

由于是在命令行中指定,所以这样的使用方式限制较多。最常做法是将它们放置于脚本中。

[root@arm64v8 ~]# cat bc.sh 
#!/bin/bash
# script for calculating

var1="hello"
var2="world"

value=`bc<<EOF
scale=3
r=3
3.1415*r*r
EOF`
echo $value
[root@arm64v8 ~]# bash bc.sh 
28.2735
[root@arm64v8 ~]#

当 bc 计算的结果小于 1 的时候,例如 "0.1+0.1=0.2",它会显示 ".2" 而不是显示 "0.2",要想显示出前面的 0,bc自身似乎没有做出相关选项。不过可以通过外部程序,如 printf 将其显示出来。也可以结合 echo 命令显示。

[root@arm64v8 ~]# printf "%.2f\n" `echo "0.1 + 0.1" | bc`
0.20
[root@arm64v8 ~]# echo 0`echo "0.1 + 0.1" | bc`
0.2
[root@arm64v8 ~]#

以下是计算 1+2+...+10 的几种不同方式,要求输出在屏幕上的结果为 "1+2+3+4+5+6+7+8+9+10=计算结果",这是非常不错的例子。

[root@arm64v8 ~]# echo $(seq -s "+" 10)=`seq -s "+" 10|bc`
1+2+3+4+5+6+7+8+9+10=55
[root@arm64v8 ~]# 
[root@arm64v8 ~]# echo $(seq -s "+" 10)=$((`seq -s "+" 10`))
1+2+3+4+5+6+7+8+9+10=55
[root@arm64v8 ~]# 
[root@arm64v8 ~]# echo $(seq -s "+" 10)=$(seq -s " + " 10|xargs expr)   # 注意"+"和" + "
1+2+3+4+5+6+7+8+9+10=55
[root@arm64v8 ~]#

B5.3、expr 命令

expr 命令可以实现数值运算、数值或字符串比较、字符串匹配、字符串提取、字符串长度计算等功能。它还具有几个特殊功能,判断变量或参数是否为整数、是否为空、是否为 0 等。

下面将使用示例来介绍 expr 的用法,在介绍之前,需要注意三点:

(1).数值表达式("+ - * / %")和比较表达式("< <= = == != >= >")会先将两端的参数转换为数值,转换失败将报错。所以可借此来判断参数或变量是否为整数。

(2).expr中的很多符号需要转义或使用引号包围。

(3).所有操作符的两边,都需要有空格。
  1. "string : REGEX" 字符串匹配示例。要输出匹配到的字符串结果,需要使用 "\("和"\)",否则返回的将是匹配到的字符串数量。

    [root@arm64v8 ~]# expr abcde : 'ab\(.*\)'
    cde
    [root@arm64v8 ~]# expr abcde : 'ab\(.\)'
    c
    [root@arm64v8 ~]# expr abcde : 'ab.*'  
    5
    [root@arm64v8 ~]# expr abcde : 'ab.'
    3
    [root@arm64v8 ~]# expr abcde : '.*cd*'
    4
    [root@arm64v8 ~]# 
    • 注意,由于 REGEX 中隐含了 "^",所以使得匹配时都是从 string 首字符开始的。

      [root@arm64v8 ~]# expr abcde : 'cd.*'
      0
      [root@arm64v8 ~]#
      • 之所以为 0,是因为真正的正则表达式是 "^cd.*",而 abcde 不是 c 开头而是 a 开头的,所以无法匹配到任何结果。因此,任何字符串匹配时,都应该从首字符开始。
    • 字符串匹配时,会先将两端参数转换为字符格式。

  2. "substr string pos len" 用法示例。

    该表达式是从 string 中取出从 pos 位置开始长度为 len 的子字符串。如果 pos 或 len 为非正整数时,将返回空字符串。

    [root@arm64v8 ~]# expr substr abcde 2 3
    bcd
    [root@arm64v8 ~]# expr substr abcde 2 4
    bcde
    [root@arm64v8 ~]# expr substr abcde 2 5
    bcde
    [root@arm64v8 ~]# expr substr abcde 2 0
    
    [root@arm64v8 ~]# expr substr abcde 2 -1
    
    [root@arm64v8 ~]#
  3. "length string" 用法示例。该表达式是返回 string 的长度,其中 string 不允许为空,否则将报错,所以可以用来判断变量是否为空。

    # 注意引号,对比分析
    [root@arm64v8 ~]# var="hello world"
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# expr length $var
    expr: syntax error
    [root@arm64v8 ~]# expr length "$var"
    11
    [root@arm64v8 ~]# expr length '$var'
    4
    [root@arm64v8 ~]#
  4. 算术运算用法示例。

    [root@arm64v8 ~]# expr 1 + 2
    3
    [root@arm64v8 ~]# a=3
    [root@arm64v8 ~]# b=4
    [root@arm64v8 ~]# expr $a + $b
    7
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# expr 4 + $a
    7
    [root@arm64v8 ~]# expr $a - $b
    -1
    [root@arm64v8 ~]#

    算术乘法符号 "*" 因为是 shell 的元字符,所以要转义,可以使用引号包围,或者使用反斜线。

    [root@arm64v8 ~]# expr $a * $b
    expr: syntax error
    [root@arm64v8 ~]# expr $a '*' $b
    12
    [root@arm64v8 ~]# expr $a \* $b
    12
    [root@arm64v8 ~]# expr $b / $a           # 除法只能取整数
    1
    [root@arm64v8 ~]# expr $b % $a
    1
    [root@arm64v8 ~]#
    • 任意操作符两端都需要有空格,否则:

      [root@arm64v8 ~]# expr 4+$a 
      4+3
      [root@arm64v8 ~]# expr 4 +$a
      expr: syntax error
      [root@arm64v8 ~]#
  5. 比较操作符 < <= = == != >= > 用法示例。其中 "<"和">" 是正则表达式锚定元字符,且 "<" 会被 shell 解析为重定向符号,所以需要转义或用引号包围。

    这些操作符会首先会将两端的参数转换为数值,如果转换成功,则采用数值比较,如果转换失败,则按照字符集的排序规则进行字符大小比较。比较的结果若为 true,则 expr 返回 1,否则返回 0。

    [root@arm64v8 ~]# a=3
    [root@arm64v8 ~]# expr $a = 1
    0
    [root@arm64v8 ~]# expr $a = 3
    1
    [root@arm64v8 ~]# expr $a \* 3 = 9
    1
    [root@arm64v8 ~]# expr abc \> ab
    1
    [root@arm64v8 ~]# expr akc \> ackd
    1
    [root@arm64v8 ~]# 
  6. 逻辑连接符号 "&" 和 "|" 用法示例。这两个符号都需要转义,或使用引号包围。

    [root@arm64v8 ~]# expr $abc '|' 1
    expr: syntax error
    [root@arm64v8 ~]# expr "$abc" '|' 1
    1
    [root@arm64v8 ~]# expr "$abc" '&' 1 
    0
    [root@arm64v8 ~]# expr $abc '&' 1 
    expr: syntax error
    [root@arm64v8 ~]# expr 0 '&' abc
    0
    [root@arm64v8 ~]# expr abc '&' 0
    0
    [root@arm64v8 ~]# expr abc '|' 0
    abc
    [root@arm64v8 ~]# expr 0 '|' abc  
    abc
    [root@arm64v8 ~]# expr abc '&' cde
    abc
    [root@arm64v8 ~]# expr abc '|' cde
    abc
    [root@arm64v8 ~]#
    • 注意,expr 在使用变量时必须要加 "",否则报语法错误。而且得是弱引用的双引号,如果用单引号,则是强引用,$ 会被解析成字符而不是变量符。
    • "&" 表示如果两个参数同时满足非空且非 0,则返回第一个参数的值,否则返回 0。且如果发现第一个参数为空或 0,则直接跳过第二个参数不做任何计算。
    • "|" 表示如果第一个参数非空且非 0,则返回第一个参数值,否则返回第二个参数值,但如果第二个参数为空或为 0,则返回 0。且如果发现第一个参数非空或非 0,也将直接跳过第二个参数不做任何计算。

B6、管道和重定向

B6.1、管道 "|"

管道顾名思义,类似管道一样将管道入口的数据通过管道传递给管道出口。

管道是为了解决进程间通信问题而存在,它可以让两个进程之间的数据进行传递,将一个进程的输出数据传递给另一个进程作为其输入数据。管道左边是数据给予方,管道右边是数据接收方。

  • 例如 echo "abcd" | passwd --stdin username,表示将进程 echo 的输出结果 "abcd" 作为进程 passwd 的输入数据。

    [root@arm64v8 ~]# useradd yidam
    [root@arm64v8 ~]# echo "abcd" | passwd --stdin yidam 
    Changing password for user yidam.
    passwd: all authentication tokens updated successfully.
    [root@arm64v8 ~]#

B6.2、重定向

最常见的标准输入(stdin)、标准输出(stdout)和标准错误输出(stderr)的文件描述符分别是 0、1 和 2,其中 0、1、2 也可以认为是它们的数字代号。

标准输入 = /dev/stdin = 代号0 = < 符号(注意,没有包含 << 符号)。

标准输出 = /dev/stdout = 代号1 = > 或 >> 符号。

标准错误输出  = /dev/stderr = 代号2 = 使用 2> 或 2>> 符号。
  • 注意,/dev/std{in,out,err} 分别是 0、1、2 默认的输出目标,当重定向后,就不再使用这些目标

  • <、>、2> 实现的是覆盖功能,>>、2>> 实现的是追加的功能,但是注意 "<<" 不是追加功能,而是表示此处生成文档(here document),在后面 cat 和重定向配合的内容里有说明。

  • 此外,还有 <<<,它表示此处字符串(here string),也见下文。

  • 有时候,使用 "-" 也表示 /dev/stdin。如:

    [root@arm64v8 ~]# cat /etc/fstab | cat -

脚本中常见 2>&1&> 以及 &>> 的符号,它们都表示将 stdout 和 stderr 都重定向到同一个地方去,即重定向所有输出内容。如最常见的 "&> /dev/null"

cat 和重定向配合

配合 cat 使用可以分行输入内容到文件中。

# 覆盖的方式输入到log.txt
[root@arm64v8 ~]# cat << EOF > log.txt
> this is stdin characters
> EOF

# 两种方式等价
[root@arm64v8 ~]# cat > log.txt << EOF
> this is stdin characters
> EOF
[root@arm64v8 ~]#
  • 一方面,EOF 部分都必须使用 "<<EOF",它表示 here document,此后输入的内容都作为一个 document 输入给 cat。
  • 既然是 document,那就肯定有 document 结束符标记 document 到此结束,结束符使用的是 here document 后的字符,例如此处为 EOF。
  • 实际上 EOF 字符可以换成任意字符,但必须前后一致。
  • > log1.txt 表示将 document 的内容覆盖到 log.txt 文件中,如果是要追加,则使用 >> log.txt

tee 双重定向

可以使用 tee 双重定向。一般情况下,重定向要么将信息输入到文件中,要么输出到屏幕上,但是既想输出到屏幕又想输出到文件就比较麻烦。使用 tee 的双重定向功能可以实现该想法。

tee [-a] file

选项说明:
-a:     默认是将输出覆盖到文件中,使用该选项将变为追加行为。
file:   除了输出到标准输出中,还将输出到file中。如果file为"-",则表示再输入一次到标准输出中。
[root@arm64v8 ~]# cat tee.txt       # 准备一个文件
I am here
[root@arm64v8 ~]# cat tee.txt | tee tee1.log | cat - > tee2.log 
[root@arm64v8 ~]# 
[root@arm64v8 ~]# cat tee1.log 
I am here
[root@arm64v8 ~]# cat tee2.log 
I am here
[root@arm64v8 ~]#
  • 将 tee.txt 文件内容全部保存到 tee1.log,同时把副本交给后面的的 cat,使用这个 cat 又将内容保存到了 tee2.log。其中 "-" 代表前面的 stdin。

  • tee 默认会使用覆盖的方式保存到文件,可以使用 -a 选项来追加到文件。

  • tee 常用方式是将内容保存到某个文件,同时打印到屏幕。

    [root@arm64v8 ~]# cat tee.txt | tee save.txt
    I am here
    [root@arm64v8 ~]# cat save.txt 
    I am here
    [root@arm64v8 ~]#

<< 和 <<<

在 bash 中,<< 和<<< 是特殊重定向符号。<< 表示的是 here document,<<< 表示的是 here string。

here document 在上文已经解释过了,对于 here string,表示将 <<< 后的字符串作为输入数据。

# 两种方式是等价的
[root@arm64v8 ~]# passwd --stdin yidam <<< hello
Changing password for user yidam.
passwd: all authentication tokens updated successfully.
[root@arm64v8 ~]# 
[root@arm64v8 ~]# echo "hello" | passwd --stdin yidam 
Changing password for user yidam.
passwd: all authentication tokens updated successfully.
[root@arm64v8 ~]#

B6.2.1、文件描述符(file description,fd)

文件描述符是 IO 重定向中的重要概念。文件描述符使用数字表示,它指明了数据的流向特征。

软件设计认为,程序应该有一个数据来源、数据出口和报告错误的地方。在 Linux 系统中,它们分别使用描述符 0、1、2 来表示,这 3 个描述符默认的目标文件(设备)分别是 /dev/stdin、/dev/stdout、/dev/stderr,它们分别是各个终端字符设备的软链接。

[root@arm64v8 ~]# ll /dev/std*
lrwxrwxrwx 1 root root 15 Sep 18 00:08 /dev/stderr -> /proc/self/fd/2
lrwxrwxrwx 1 root root 15 Sep 18 00:08 /dev/stdin -> /proc/self/fd/0
lrwxrwxrwx 1 root root 15 Sep 18 00:08 /dev/stdout -> /proc/self/fd/1
[root@arm64v8 ~]# 
[root@arm64v8 ~]# ll /proc/self/fd/
total 0
lrwx------ 1 root root 64 Sep 18 07:20 0 -> /dev/pts/1
lrwx------ 1 root root 64 Sep 18 07:20 1 -> /dev/pts/1
lrwx------ 1 root root 64 Sep 18 07:20 2 -> /dev/pts/1
lr-x------ 1 root root 64 Sep 18 07:20 3 -> /proc/29727/fd
[root@arm64v8 ~]#

在 Linux 中,每一个进程打开时都会自动获取 3 个文件描述符 0、1 和 2,分别表示标准输入、标准输出、和标准错误,如果要打开其他文件,则文件描述符必须从 3 开始标识。对于我们人为要打开的描述符,建议使用 9 以内的描述符,超过 9 的描述符可能已经被系统内部分配给其他进程。

文件描述符说白了就是系统为了跟踪这个打开的文件而分配给它的一个数字,这个数字和文件绑定在一起,数据流入描述符的时候也表示流入文件。而 Linux 中一切皆文件,这些文件都可以分配描述符,包括套接字。

程序在打开文件描述符的时候,有三种可能的行为:从描述符中读、向描述符中写、可读也可写。从 lsof 的 FD 列可以看出程序打开这个文件是为了从中读数据,还是向其中写数据,亦或是既读又写(3r的r是read,w是write,u是read and write)。

[root@arm64v8 ~]# lsof -i :22
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
sshd      945 root    3u  IPv4  20725      0t0  TCP *:ssh (LISTEN)
sshd      945 root    4u  IPv6  20727      0t0  TCP *:ssh (LISTEN)
sshd     2901 root    3u  IPv4  32383      0t0  TCP arm64v8:ssh->10.51.111.52:hp-status (ESTABLISHED)
sshd    30060 root    3u  IPv4  84587      0t0  TCP arm64v8:ssh->10.51.111.52:11928 (ESTABLISHED)
[root@arm64v8 ~]#

B6.2.2、文件描述符的复制(duplicate)

文件描述符的复制表示复制文件描述符到另一个文件描述符中以作其副本。使用 ”&” 进行复制。

  • [n]<&word:将文件描述符 n 复制于 word 代表的文件或描述符。可以理解为文件描述符 n 重用 word 代表的文件或描述符,即 word 原来对应哪个文件,现在 n 作为它的副本也对应这个文件。n 不指定则默认为 0(标准输入就是 0),表示标准输入也将输入到 word 所代表的文件或描述符中。

  • [n]>&word:将文件描述符 n 复制于 word 代表的文件或描述符。可以理解为文件描述符 n 重用 word 代表的文件或描述符,即 word 原来对应哪个文件,现在 n 作为它的副本也对应这个文件。n 不指定则默认为 1(标准输出就是 1),表示标准输出也将输出到 word 所代表的文件或描述符中。

  • 例如,3>&1 表示 fd=3 复制于 fd=1,而 fd=1 目前的重定向目标文件是 /dev/stdout(fd=1 指向与输出设备是默认的),因此 fd=3 也重定向到 /dev/stdout,以后进程将数据写入 fd=3 的时候,将直接输出到屏幕。

    • 这里的 3>&1 等价于 3>&/dev/stdout
    • 如果用 ”复制” 来理解,就是 fd=3 是当前 fd=1 的一个副本,即指向 /dev/stdout 设备。如果后面改变了 fd=1 的输出目标(如 file1),由于 fd=3 的目标仍然是 /dev/stdout,所以可以拿 fd=3 来还原 fd=1 使其目标变回 /dev/stdout。
  • 再例如,cat <&1 表示 fd=0 复制于 fd=1 上,而此时 fd=1 的重定向文件是 /dev/stdout,所以 fd=0 也指向这个 /dev/stdout 文件,而 cat 从 fd=0 中读取标准输入,于是 /dev/stdout 既是标准输入设备,也是标准输出设备,也就是说进程从 /dev/stdout(屏幕) 接受输入,输入后再直接输出到 /dev/stdout。

    [root@arm64v8 ~]# cat <&1
    hello world
    hello world
    you will know
    you will know
    you have known already    
    you have known already

B6.2.3、重定向顺序很重要

想必很多人都知道 >file 2>&1 的作用,它等价于 &>file,表示标准输出和标准错误都重定向到 file 中。那它和 2>&1 >file 有什么区别呢?

  • >file 2>&1 分两个过程
    1. 先打开 file,再将 fd=1 重定向到 file 文件上,这样 file 文件就成了标准输出的输出目标;
    2. 之后再将 fd=2 复制于 fd=1,而 fd=1 此时已经重定向到 file 文件上,因此 fd=2 也重定向到 file 上。所以,最终的结果是标准输出重定向到 file 上,标准错误也重定向到 file 上。
  • 2>&1 >file 也分两个过程
    1. 先将 fd=2 复制于 fd=1,而此时 fd=1 重定向的文件是默认的 /dev/stdout,所以 fd=2 也重定向到 /dev/stdout;
    2. 之后再将 fd=1 重定向到 file 文件上。也就是说,这里的标准错误和标准输出仍然是分开输出的,只不过是使用 /dev/stdout 替代了 /dev/stderr,使用 file 替代了 /dev/stdout。所以,最终的结果是标准错误输出到 /dev/stdout,即屏幕上,而标准输出将输出到 file 文件中。

可以使用下面的命令来测试 2>&1 >file。第一个 ls 命令是正确的,结果输出到 /tmp/right.log 中,第二个 ls 命令是错误的,结果将直接输出到屏幕上。

[root@arm64v8 ~]# ls /boot 2>&1 >/tmp/right.log
[root@arm64v8 ~]# cat /tmp/right.log
config-4.19.113-300.axs7.10.aarch64
dtb-4.19.113-300.axs7.10.aarch64
efi
grub
grub2
initramfs-0-rescue-c2b8491a721242ebbe560d4a3cbccf33.img
initramfs-4.19.113-300.axs7.10.aarch64.img
lost+found
System.map-4.19.113-300.axs7.10.aarch64
vmlinuz-0-rescue-c2b8491a721242ebbe560d4a3cbccf33
vmlinuz-4.19.113-300.axs7.10.aarch64
[root@arm64v8 ~]# 
[root@arm64v8 ~]# ls wrong 2>&1 > /tmp/wrong.log
ls: cannot access wrong: No such file or directory
[root@arm64v8 ~]#

最后需要说明的是一种特殊情况,如果是 >&word,且 word 不是一个数值,比如 echo haha >&/tmp/a.log,那么 >&word&>word 是等价的,都表示 >word 2>&1,即标准错误和标准输出都重定向同一个目标。

B6.2.4、改变当前 shell 环境的重定向目标

如果在命令中直接改变重定向的位置,那么命令执行结束的时候描述符会自动还原。ls /boot 2>&1 >/tmp/a.log 命令,在 ls 执行结束后,fd=2 还原回默认的 /dev/stderr,fd=1 还原回默认的 /dev/stdout。

但是我们可以通过 exec 程序直接在当前的 shell 环境下改变重定向目标,只有在当前 shell 退出的时候才会释放描述符的绑定。

例如:下面的命令将标准错误 fd=2 指向 fd=3 对应的文件上。

exec 2>&3

因此,我们可能在一段程序执行结束后,需要将描述符还原到原来的位置,并关闭不再需要的描述符。毕竟描述符也是资源,是有限的(ulimit -n)。

B6.2.5、关闭文件描述符

[n]>&-
[n]<&-

关闭文件描述符的方式是将 [n]>&word[n]<&word 中的 word 使用符号 -,这表示释放 fd=n 描述符,且关闭其指向的文件。

B6.2.6、打开文件

[n]<> filename:打开 filename,并指定其文件描述符为 n,该描述符是可读、可写的描述符。若不指定 n 则默认为 0,若 filename 文件不存在,则先创建 filename 文件。

[root@arm64v8 ~]# exec 3<> /tmp/fd.test
[root@arm64v8 ~]# lsof -n | grep "/fd.test" | column -t
bash    1283  root  3u  REG  8,4  0  16252990  /tmp/fd.test
grep    1352  root  3u  REG  8,4  0  16252990  /tmp/fd.test
column  1353  root  3u  REG  8,4  0  16252990  /tmp/fd.test
[root@arm64v8 ~]#
  • 如果再 exec 1>&3 将 fd=1 复制于 fd=3,那么 /tmp/fd.test 就成了标准输出的目标。

B6.2.7、文件描述符的移动

文件描述符的移动表示将文件描述符 1 移动到描述符 2 上,同时关闭文件描述符 1。

  • [n]>&digit-:将文件描述符 digit 代表的输出文件移动到 n 上,并关闭 digit 值的描述符
  • [n]<&digit-:将文件描述符 digit 代表的输入文件移动到 n 上,并关闭 digit 值的描述符
[root@arm64v8 ~]# exec 3<> /tmp/a.log 
[root@arm64v8 ~]# lsof -n | grep "/a.log" | column -t
bash    2440  root  3u  REG  8,4  701  16252936  /tmp/a.log
grep    2695  root  3u  REG  8,4  701  16252936  /tmp/a.log
column  2696  root  3u  REG  8,4  701  16252936  /tmp/a.log
[root@arm64v8 ~]# 
[root@arm64v8 ~]# exec 1>&3- # 将3移动到1上,关闭3
[root@arm64v8 ~]#
[root@arm64v8 ~]# lsof -n | grep "/a.log" | column -t # 在另一个bash窗口查看
bash  2440  root  1u  REG  8,4  701  16252936  /tmp/a.log
[root@arm64v8 ~]#
  • 可见,fd=3 移动到 fd=1 后,原本与 fd=3 关联的 /tmp/a.log 已经关联到 fd=1 上。

B7、Bash 数组

变量和数组的区别

  • 变量在内存中占用的空间是离散的;
  • 数组在内存中是先开辟一段连续的大内存空间,随后数组中的每个元素都放入数组内存中。数组元素使用数组 index 标识。

bash 里有两种数组:普通数组和关联数组。

  • 普通数组只能使用整型数值作为数组索引;
  • 关联数组可以使用字符串作为索引。
    • 所谓的关联数组,它的另外三种称呼:字典(dict)、hash 结构和映射(map),是一种 key 和 value 一 一对应的关系。

普通数组

  1. 定义数组的方式一

    [root@arm64v8 ~]# array_test=(1 2 3 4)

    它们分别存储在索引位 0-3 的位置上,是 array_test[0] 到 array_test[3] 对应的值。此时 array_test[0] 表示的是一个变量,所以使用 $ 来引用。数组的引用方式:${array_name[index]}

    [root@arm64v8 ~]# echo ${array_test[2]} 
    3

    注意数组中定义是使用空格作为分隔符定义在括号内,而不是逗号。如果使用逗号,则它们将作为一个整体,也就是数组索引 0 的值。如果使用逗号,则:

    [root@arm64v8 ~]# array_test=(1,2,3,4)
    [root@arm64v8 ~]# echo ${array_test[0]} 
    1,2,3,4
    [root@arm64v8 ~]#
  2. 定义数组的方式二:可以自定义索引位

    [root@arm64v8 ~]# array_test1[1]=1
    [root@arm64v8 ~]# array_test1[2]=2
    [root@arm64v8 ~]# array_test1[3]=3
    [root@arm64v8 ~]# array_test1[4]=4
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# echo ${array_test1[*]} 
    1 2 3 4
    [root@arm64v8 ~]#

    但是在索引位 4 之后定义 array_test1[7]=7 则表示 5 和 6 的数组变量未定义,即不存在,这可以通过统计变量的元素个数来验证。但在 shell 中是可以直接引用未定义变量的,只不过它们的初始值是空或是 0。

  3. 打印数组所有值

    [root@arm64v8 ~]# echo ${array_test1[*]} 
    1 2 3 4
    [root@arm64v8 ~]#
    [root@arm64v8 ~]# echo ${array_test1[@]}
    1 2 3 4
    [root@arm64v8 ~]#
  4. 查看数组索引号

    [root@arm64v8 ~]# echo ${!array_test1[*]}
    1 2 3 4
    [root@arm64v8 ~]# echo ${!array_test1[@]}
    1 2 3 4
    [root@arm64v8 ~]#
  5. 数组中变量长度和数组长度

    [root@arm64v8 ~]# echo ${#array_test1[1]}    # 显示下标为1的数组变量的字符长度
    1
    [root@arm64v8 ~]# echo ${#array_test1[*]}    # 显示数组中的元素个数(只统计值不为空的元素)
    4
    [root@arm64v8 ~]# echo ${#array_test1[@]}    # 显示数组中的元素个数(只统计值不为空的元素)
    4
    [root@arm64v8 ~]#

关联数组

关联数组支持字符串作为数组索引。使用关联数组必须先使用 declare -A 声明它。

[root@arm64v8 ~]# declare -A array_dep
[root@arm64v8 ~]# 
[root@arm64v8 ~]# array_dep=([name1]=小刚 [name2]=小亮)

其中 name1 和 name2 就是关联数组的 index。引用数组变量时需要使用 index 来引用对应的值。

[root@arm64v8 ~]# echo ${array_dep[name1]} 
小刚
[root@arm64v8 ~]#

也可以分开赋值。

[root@arm64v8 ~]# array_dep[name6]=小雄
[root@arm64v8 ~]# array_dep[name7]=小贝
[root@arm64v8 ~]# 
[root@arm64v8 ~]# echo ${array_dep[*]}
小亮 小刚 小贝 小雄
[root@arm64v8 ~]# 
[root@arm64v8 ~]# echo ${array_dep[name5]}  # 没有定义为空

[root@arm64v8 ~]#
  1. 查看数组所有值

    [root@arm64v8 ~]# echo ${array_dep[*]}
    小亮 小刚 小贝 小雄
    [root@arm64v8 ~]# echo ${array_dep[@]}   # 顺序不一定是按定义时的顺序
    小亮 小刚 小贝 小雄
    [root@arm64v8 ~]#
  2. 查看数组索引号

    [root@arm64v8 ~]# echo ${!array_dep[@]}
    name2 name1 name7 name6
    [root@arm64v8 ~]# echo ${!array_dep[*]}
    name2 name1 name7 name6
    [root@arm64v8 ~]#
  3. 统计数组长度

    [root@arm64v8 ~]# echo ${#array_dep[*]}
    4
    [root@arm64v8 ~]# echo ${#array_dep[@]}
    4
    [root@arm64v8 ~]#

数组元素截取、替换

和变量的截取和替换是类似的。

[root@arm64v8 ~]# array=(1 2 3 4 5 6)
[root@arm64v8 ~]# array0=${array[*]:2:2}    # 从数组全部元素中第2个元素向后截取2个元素出来(即3 4)
[root@arm64v8 ~]# array1=${array[*]/5/6}    # 将数组中的5替换成6
[root@arm64v8 ~]# 
[root@arm64v8 ~]# echo ${array0[*]}
3 4
[root@arm64v8 ~]# echo ${array1[*]}
1 2 3 4 6 6
[root@arm64v8 ~]#

还有从左匹配删除从右匹配删除,和变量是一样的。

[root@arm64v8 ~]# array=(one two three foue five)
[root@arm64v8 ~]# array1=${array[*]#*o}     # 从左非贪婪匹配并删除所有数组变量中匹配内容
[root@arm64v8 ~]# array2=${array[*]##*o}    # 从左贪婪匹配并删除所有数组变量中匹配的内容
[root@arm64v8 ~]# array3=${array[*]%o}      # 从右非贪婪匹配并删除所有数组变量中匹配内容
[root@arm64v8 ~]# array4=${array[*]%%o}     # 从右贪婪匹配并删除所有数组变量中匹配内容
[root@arm64v8 ~]# 
[root@arm64v8 ~]# echo ${array1[*]}
ne three ue five
[root@arm64v8 ~]# echo ${array2[*]}
ne three ue five
[root@arm64v8 ~]# echo ${array3[*]}
one tw three foue five
[root@arm64v8 ~]# echo ${array4[*]}
one tw three foue five
[root@arm64v8 ~]#

for 循环遍历数组

在 shell 中的循环结构中,可以使用数组名来表示整个数组变量。

for i in ${array[*]};do  
    echo $i
done

或者让 i 变成数组 index 的方法:

for i in ${!array[*]};do
    echo ${array[$i]}
done

以下是遍历数组的三个常见用法总结:

array=($(ls /boot))

for i in ${array[*]};do  # 以数组值的方式直接遍历数组
    echo $i
done

for ((i=0;i<${#array[*]};i++));do    # 以数组变量个数的方式遍历数组
    echo ${array[$i]}
done

for i in ${!array[*]};do      # 以数组index的方式遍历数组
    echo ${array[$i]}
done

以下是一个数组遍历的示例:统计文件中重复行的次数。假设 nfs.log 文件中内容如下。

[root@arm64v8 ~]# cat nfs.log 
portmapper
portmapper
portmapper
portmapper
portmapper
portmapper
status
status
mountd
mountd
mountd
mountd
mountd
mountd
nfs
nfs
nfs_acl
nfs
nfs
nfs_acl
nlockmgr
nlockmgr
nlockmgr
nlockmgr
nlockmgr
nlockmgr
[root@arm64v8 ~]#

以下是数组遍历的脚本。

[root@arm64v8 ~]# cat ergodic.sh 
#!/bin/bash

declare -A array_test  # 定义关联数组

for i in `cat ~/nfs.log`
do
    let ++array_test[$i] 
done

for j in ${!array_test[*]}
do
    printf "%-15s %3s\n" $j :${array_test[$j]} 
done
[root@arm64v8 ~]# chmod u+x ergodic.sh
[root@arm64v8 ~]# ./ergodic.sh 
status           :2
nfs              :4
portmapper       :6
nlockmgr         :6
nfs_acl          :2
mountd           :6
[root@arm64v8 ~]#

B8、别名 alias

  1. 默认 rm 是 "rm -i" 的别名,ll 就是 "ls -l" 的别名。可以自定义别名来代替某些命令配合某些选项,也可以定义别名组合多个命令。例如:

    [root@arm64v8 ~]# alias ls='ls -lA --color'
    [root@arm64v8 ~]# ls
    total 88
    -rw-------. 1 root root 14652 Sep 18 08:41 .bash_history
    -rw-r--r--. 1 root root    18 Dec 29  2013 .bash_logout
    -rw-r--r--. 1 root root   204 Sep 17 07:30 .bash_profile
    -rw-r--r--. 1 root root   198 Sep 17 07:30 .bashrc
    drwx------. 3 root root  4096 Sep 15 06:51 .cache
    -rw-r--r--. 1 root root   100 Dec 29  2013 .cshrc
    -rwxr--r--  1 root root   201 Sep 19 02:13 ergodic.sh
    -rw-r--r--  1 root root   208 Sep 19 02:09 nfs.log
    drwxr-----. 3 root root  4096 Sep 15 06:09 .pki
    -rw-r--r--  1 root root    10 Sep 18 06:50 save.txt
    drwx------  2 root root  4096 Sep 17 07:07 .ssh
    -rw-r--r--. 1 root root   129 Dec 29  2013 .tcshrc
    -rw-r--r--  1 root root    10 Sep 18 06:46 tee1.log
    -rw-r--r--  1 root root    10 Sep 18 06:37 tee2.log
    -rw-r--r--  1 root root    10 Sep 18 06:46 tee3.log
    -rw-r--r--  1 root root    10 Sep 18 06:32 tee.txt
    -rw-------  1 root root  4848 Sep 19 02:13 .viminfo
    -rw-------  1 root root   173 Sep 19 00:28 .Xauthority
    [root@arm64v8 ~]#
    • 这样在列出目录时将同时列出隐藏文件。
  2. 使用不带参数的 alias 将列出当前 shell 环境下所有的已定义的别名。

    [root@arm64v8 ~]# alias 
    alias cp='cp -i'
    alias egrep='egrep --color=auto'
    alias fgrep='fgrep --color=auto'
    alias grep='grep --color=auto'
    alias l.='ls -d .* --color=auto'
    alias ll='ls -l --color=auto'
    alias ls='ls -lA --color'
    alias mv='mv -i'
    alias rm='rm -i'
    alias which='alias | /usr/bin/which --tty-only --read-alias --show-dot --show-tilde'
    [root@arm64v8 ~]#
  3. 另外需要说明的是,当别名和命令同名时,将优先执行别名(否则别名就没有意义了),这可以从 which 的结果中看出:

    [root@arm64v8 ~]# which mv
    alias mv='mv -i'
    /usr/bin/mv
    [root@arm64v8 ~]#

    如果定义的命名名称和原始命令同名(例如定义的别名 ls='ls -l' ),此时如果想要明确使用原始命令,可以删除别名或者使用绝对路径或者使用转义符来还原命令。

  4. alias 命令是临时定义别名,要定义长久生效的别名就将别名定义语句写入 /etc/profile~/.bash_profile~/.bashrc,第一个对所有用户有效,后面两个对对应用户有效。修改后记得使用 source 来重新调取这些配置文件。

  5. 使用 unalias 可以临时取消别名。

B9、Bash 命令替换和组合

Linux 中使用反引号 ""(在波浪线的按键上)或者 $() 来执行命令替换。使用括号 () 来组合一系列命令。

[root@arm64v8 ~]# echo what date it is? $(date +%F)
what date it is? 2021-09-19

[root@arm64v8 ~]# echo what date it is? `date +%F`        # 或者使用反引号
what date it is? 2021-09-19
[root@arm64v8 ~]#
  • 反引号和 $() 几乎等价,但尽量使用 $()。反引号有两点不方便之处
    • 命令替换嵌套或者是包含引号的时候,反引号很麻烦,不如 $() 易读。
    • 反引号处理反斜线的转义规则比较不明确,但是 $() 中的反斜线会按正常的方式转义。

使用 $() 可以让括号里的命令提前于整个命令运行,然后将执行结果插入在命令替换符号处。

由于命令替换的结果经常交给外部命令,不应该让结果有换行的行为,所以默认将所有的换行符替换为了空格(实际上所有的空白符都被压缩成了单个空格)。

[root@arm64v8 ~]# echo -e "a\nb"
a
b
[root@arm64v8 ~]# echo `echo -e "a\nb\t   \tc"`
a b c
[root@arm64v8 ~]#

使用双引号引用可以保留空白符。

[root@arm64v8 ~]# echo "`echo -e "a\nb\t   \tc"`"
a
b       c
[root@arm64v8 ~]#

从上面大概可以知道,命令替换分为两个过程:

  1. 开启子 shell 执行其中的命令
  2. 将子 shell 中的输出结果打包插入在命令行中。但打包输出结果的过程是可以控制的(例如上面使用双引号)。

所以,如果想要将命令替换得到的多行结果保存在变量中(变量保存多行数据)。可以如下操作:

[root@arm64v8 ~]# var="`echo -e "a b\n1 2"`"      # 命令替换加双引号保护
[root@arm64v8 ~]# echo "$var"                     # 变量引用也加双引号保护
a b
1 2
[root@arm64v8 ~]#

很多时候,在命令行中需要使用 "cat a.txt|command" 或者执行 $(cat a.txt) 来传递文件 a.txt 中的内容,但这不是最好的方法。它们等价的效率更高的方法分别是 "< a.txt""$(< a.txt)"

如果使用括号将一系列命令包围,可以使得这些命令独立于当前 bash 环境运行。这其实是一个命令组。

[root@arm64v8 ~]# (umask 077;touch new.txt;ls -l new.txt)
-rw------- 1 root root 0 Sep 19 03:04 new.txt
[root@arm64v8 ~]#

B10、trap 信号捕捉

通常 trap 都在脚本中使用,主要有 2 种功能:

  1. 忽略信号。当运行中的脚本进程接收到某信号时( 例如误按了 CTRL+C ),可以将其忽略,免得脚本执行到一半就被终止
  2. 捕捉到信号后做相应处理。主要是清理一些脚本创建的临时文件,然后退出

常见信号

常见的信号以及它们的数值代号、说明如下:

Signal     Value   Comment

─────────────────────────────

SIGHUP      1      终止进程,特别是终端退出时,此终端内的进程都将被终止
SIGINT      2      中断进程,几乎等同于sigterm,会尽可能的释放执行clean-up,
                   释放资源,保存状态等(CTRL+C)
SIGQUIT     3      从键盘发出杀死(终止)进程的信号
SIGKILL     9      强制杀死进程,该信号不可被捕捉和忽略,进程收到该信号后不会
                   执行任何clean-up行为,所以资源不会释放,状态不会保存
SIGTERM    15      杀死(终止)进程,几乎等同于sigint信号,会尽可能的释放执行
                   clean-up,释放资源,保存状态等
SIGSTOP    19      该信号是不可被捕捉和忽略的进程停止信息,收到信号后会进入stopped状态
SIGTSTP    20      该信号是可被忽略的进程停止信号(CTRL+Z)

每个信号其真实名称并非是 SIGXXX,而是去除 SIG 后的单词,每个信号还有其对应的数值代号,在使用信号时,可以使用这 3 种方式中的任一一种。例如 SIGHUP,它的信号名称为 HUP,数值代号为 1,发送 HUP 信号时,以下 3 种方式均可。

kill -1 PID
kill -HUP PID
kill -SIGHUP PID

在上面所列的信号列表中,KILL 和 STOP 这两个信号无法被捕捉。一般来说,在设置信号陷阱时,只会考虑 HUP、INT、QUIT、TERM 这 4 个会终止、中断进程的信号。

trap 布置陷阱

trap 的语法格式为:

(1) trap [-lp]
(2) trap cmd-body signal_list
(3) trap '' signal_list
(4) trap    signal_list
(5) trap -  signale_list
  • 语法1:-l 选项用于列出当前系统支持的信号列表,和 kill -l 一样的作用
    • -p 选项用于列出当前 shell 环境下已经布置好的陷阱
  • 语法2:当捕捉到给定的信号列表中的某个信号时,就执行此处给定 cmd-body 中的命令
  • 语法3:命令参数为空字符串,这时 shell 进程和 shell 进程内的子进程都会忽略信号列表中的信号
  • 语法4:省略命令参数,重置陷阱为启动 shell 时的陷阱。不建议此语法,因为给定多个信号时结果将出人意料
  • 语法5:等价于语法 4
  1. 查看当前 shell 已布置的陷阱。

    [root@arm64v8 ~]# trap
    trap -- '' SIGTSTP
    trap -- '' SIGTTIN
    trap -- '' SIGTTOU
    [root@arm64v8 ~]#
    • trap 不接任何参数和选项时,默认为 -p。
    • 这 3 个陷阱都是信号忽略陷阱,当捕获到 TSTP、TTIN 或 TTOU 信号时,将不做任何处理。
  2. 设置一个陷阱,当这个陷阱捕捉到 15 信号时,就打印一条消息。

    [root@arm64v8 ~]# trap 'echo "caught the TERM signal"' TERM
    [root@arm64v8 ~]# trap
    trap -- 'echo "caught the TERM signal"' SIGTERM
    trap -- '' SIGTSTP
    trap -- '' SIGTTIN
    trap -- '' SIGTTOU
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# kill $BASHPID 
    caught the TERM signal
    [root@arm64v8 ~]#
  3. 重置 TERM 信号的陷阱为初始状态。

    [root@arm64v8 ~]# trap -p
    trap -- 'echo "caught the TERM signal"' SIGTERM
    trap -- '' SIGTSTP
    trap -- '' SIGTTIN
    trap -- '' SIGTTOU
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# trap - SIGTERM
    [root@arm64v8 ~]# trap -p
    trap -- '' SIGTSTP
    trap -- '' SIGTTIN
    trap -- '' SIGTTOU
    [root@arm64v8 ~]#
  4. 在脚本中设置一个能忽略 CTRL+C 和 SIGTERM 信号的陷阱。

    [root@arm64v8 ~]# cat trap.sh 
    #!/bin/bash
    # script_name: trap.sh
    #
    trap '' SIGINT SIGTERM
    sleep 10
    echo sleep success
    [root@arm64v8 ~]#

    当执行该脚本后,将首先陷入睡眠状态,按下 CTRL+C 将无效。仍会执行完所有的命令。

    [root@arm64v8 ~]# chmod u+x trap.sh
    [root@arm64v8 ~]# ./trap.sh 
    ^C^C^Csleep success
    [root@arm64v8 ~]#
  5. 布置一个当脚本中断时能清理垃圾并立即退出脚本的陷阱。

    [root@arm64v8 ~]# cat trap.sh 
    #!/bin/bash
    # script_name: trap.sh
    #
    trap 'echo trap handling...;rm -rf /tmp/$BASHPID$BASHPID;echo TEMP file cleaned;exit' SIGINT SIGTERM SIGQUIT SIGHUP
    mkdir -p /tmp/$BASHPID$BASHPID/
    touch /tmp/$BASHPID$BASHPID/{a.txt,a.log}
    sleep 10
    echo first sleep success
    sleep 10
    echo second sleep success
    [root@arm64v8 ~]#
    • 这样,无论是什么情况中断( 除非是 SIGKILL ),脚本总能清理掉临时垃圾。

B11、Bash 常用命令

shell 脚本虽然也可以实现很复杂的编程,但是很少有人会这么做,因为有很多优秀的语言可以实现高效编程,像 java、python 等;所以 shell 脚本的意义更多在于 linux 系统层实现命令的堆砌,一次性执行完预定义的所有命令,提高运维效率。

因此,了解一些常用的命令,可以很好的找到写 shell 脚本的感觉。

B11.1、read 命令

要与 Linux 交互,脚本获取键盘输入的结果是必不可少的,read 可以读取键盘输入的字符。

语法:read [-rs] [-a ARRAY] [-d delim] [-n nchars] [-N nchars] [-p prompt] [-t timeout] [-u fd] [var_name1 var_name2 ...]

选项说明:
-a:将分裂后的字段依次存储到指定的数组中,存储的起始位置从数组的index=0开始。
-d:指定读取行的结束符号。默认结束符号为换行符。
-n:限制读取N个字符就自动结束读取,如果没有读满N个字符就按下回车或遇到换行符,则也会结束读取。
-N:严格要求读满N个字符才自动结束读取,即使中途按下了回车或遇到了换行符也不结束。其中换行符或回车算一个字符。
-p:给出提示符。默认不支持"\n"换行,要换行需要特殊处理,见下文示例。例如,"-p 请输入密码:"
-r:禁止反斜线的转义功能。这意味着"\"会变成文本的一部分。
-s:静默模式。输入的内容不会回显在屏幕上。
-t:给出超时时间,在达到超时时间时,read退出并返回错误。也就是说不会读取任何内容,即使已经输入了一部分。
-u:从给定文件描述符(fd=N)中读取数据。

基础用法示例

  1. 将读取的内容分配给数组变量,从索引号 0 开始分配。

    [root@arm64v8 ~]# read -a array_test
    This is your commander!   
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# echo ${array_test[@]} 
    This is your commander!
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# echo ${array_test[2]} 
    your
    [root@arm64v8 ~]#
  2. 指定读取行的结束符号,而不再使用换行符。

    [root@arm64v8 ~]# read -d '/'
    what is your name \//[root@arm64v8 ~]#       # 输入完尾部的"/",自动结束read

    由于没有指定 var_name,所以通过 $REPLY 变量查看 read 读取的行。

    [root@arm64v8 ~]# echo $REPLY 
    what is your name /
    [root@arm64v8 ~]#
  3. 限制输入字符。

    # 输入了5个字符后立即结束
    [root@arm64v8 ~]# read -n 5
    12345[root@arm64v8 ~]# 
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# echo $REPLY 
    12345
    [root@arm64v8 ~]#
    • 如果输入的字符数小于 5,按下回车会立即结束读取。

    • 但如果使用的是 "-N 5" 而不是 "-n 5",则严格限制读满 5 个字符才结束读取。

      [root@arm64v8 ~]# read -N 5
      12 # 换行符也算字符
      3  # 换行符也算字符
      [root@arm64v8 ~]#
  4. 使用 -p 选项给出输入提示。

    [root@arm64v8 ~]# read -p "please enter your name: "
    please enter your name: Brinnatt
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# echo $REPLY 
    Brinnatt
    [root@arm64v8 ~]#
    • "-p" 选项默认不带换行功能,且也不支持 "\n" 换行。但通过 $'string' 的方式特殊处理,就可以实现换行的功能。

      [root@arm64v8 ~]# read -p $'please enter your name: \n'
      please enter your name: 
      Brinnatt
      [root@arm64v8 ~]# echo $REPLY 
      Brinnatt
      [root@arm64v8 ~]# 
  5. 不回显输入的字符。比如输入密码的时候,不回显输入密码。

    [root@arm64v8 ~]# read -s -p "please enter your password: "
    please enter your password: 
    [root@arm64v8 ~]# 
    [root@arm64v8 ~]# echo $REPLY 
    Brinnatt
    [root@arm64v8 ~]#
  6. 将读取的行分割后赋值给变量。

    [root@arm64v8 ~]# read var1 var2 var3
    abc def ghi jklm dismiss
    [root@arm64v8 ~]#
    [root@arm64v8 ~]# echo ${var1} -- $var2 -- $var3 
    abc -- def -- ghi jklm dismiss
    [root@arm64v8 ~]#
  7. 给出输入时间限制。没完成的输入将被丢弃,所以变量将赋值为空(如果在执行 read 前,变量已被赋值,则此变量在 read 超时后将被覆盖为空)。

    [root@arm64v8 ~]# var=5
    [root@arm64v8 ~]# read -t 3 var
    1
    [root@arm64v8 ~]# echo $var
    
    [root@arm64v8 ~]#

while read line

如果 read 不明确指定按字符数读取文件(或标准输入),那么默认是按行读取的,而且每读一行都会在那一行处打上标记(即文件指针。当然,按字符数读取也一样会打上标记),表示这一次已经读取到了这个地方,使得下次仍然能够从这里开始继续向下读取。这使得 read 结合 while 使用的时候,是按行读数据非常好的方式。

[root@arm64v8 ~]# cat test 
line 1
line 2
line 3
line 4

# 第一种
[root@arm64v8 ~]# cat test | while read line;do echo $line;done
line 1
line 2
line 3
line 4

# 第二种
[root@arm64v8 ~]# while read line;do echo $line;done < test 
line 1
line 2
line 3
line 4
[root@arm64v8 ~]#

# 第三种
[root@arm64v8 ~]# while read line < test; do echo $line;done
  • 第一种不建议:因为管道会开启子 shell,使得 while 中的命令都在子 shell 中执行,而且,cat test 会一次性将 test 文件所有数据装入内存,如果 test 文件足够大,会直接占用巨量内存。
  • 第二种建议:使用输入重定向的方式则每次只占用一行数据的内存,而且是在当前 shell 环境下执行的,while 内的变量赋值、数组赋值在退出 while 后仍然有效。
  • 第三种不建议:因为测试了就知道为什么不用,它会在每次循环的时候都重新打开 test 文件,使得每次都从头开始读数据,而不是每次从上一次标记的地方继续读数据。

精确输出每行数据

while read line 在读取数据时,默认是按照 IFS 变量来将所读取的行数据进行切割保存到各对应的变量当中。这会使得所读取的数据在保存到 line 中时会丢失前缀空白。

所以,要想原原本本地输出每行数据,需要修改 IFS。不仅如此,在保存到 line 中之后,在使用 line 时还需要加上双引号包围,否则前缀空格仍然会在变量替换后被忽略。

所以,最佳方式如下:

[root@arm64v8 ~]# while IFS= read -r line;do echo "$line";done < test 
line 1
line 2
line 3
line 4
[root@arm64v8 ~]#
  • 上面还加上了 -r 选项,表示忽略原始数据中的反斜线转义功能,而是当作原本的反斜线字符输出。

B11.2、date 命令

date 用于获取和设置操作系统的时间,还有 hwclock 是获取硬件时间。

date 有个选项 "-d",可以用来描述获取什么时候的时间,描述的方式非常开放,但不能使用 "now" 关键字,其他的如 3 天前 "3 days ago",3 天后 "3 days",昨天 "yesterday",下周一 "next Monday",epoch 时间 "@EPOCH" 等等。

Linux 中设置 date 命令的显示格式:date [+format],其中 "+" 表示从前面的时间中获取其中的格式部分,如 date -d "yesterday" +"%Y" 获取的是昨天的年份部分。

format 格式如下所示:

单位 符号 意义 描述
%Y year
%m month (01..12)
%d day of month (如01)
%U week number of year with Sunday as first day
%W week number of year with Monday as first day
%H 时(24时制) hour (00..23)
%M minute (00..59)
%S second (00..60)
%N 纳秒 ns of current minute
%s 从 1970-01-01 到目前时间的秒数总数
完整格式 %T 完整时间 time; same as %H:%M:%S
完整格式 %D 日期完整格式 date; same as %m/%d/%y
完整格式 %F 日期完整格式 date; same as %Y-%m-%d
[root@arm64v8_backup ~]# date +%F
2021-09-19
[root@arm64v8_backup ~]# 
[root@arm64v8_backup ~]# date +"%F %T"                # 有空格需要使用双引号或引号来分隔
2021-09-19 06:00:11
[root@arm64v8_backup ~]# date +"%Y-%m-%d %H:%M:%S"    # 有空格需要使用双引号或引号来分隔
2021-09-19 06:00:43
[root@arm64v8_backup ~]#

使用 date 命令可以计算时间差。例如:

[root@arm64v8_backup ~]# date -d "3 days ago" +%F
2021-09-16
[root@arm64v8_backup ~]# date -d "-3 days" +%F
2021-09-16
[root@arm64v8_backup ~]# date -d "now-3 days" +%F
2021-09-16
[root@arm64v8_backup ~]#

B11.3、[u]sleep 命令

在 shell 中常使用 sleep 命令指定休眠时间,休眠的意思表示让当前进程进入睡眠状态。例如:

[root@arm64v8_backup ~]# sleep 5

sleep 默认的休眠单位为秒,因此上面表示休眠 5 秒钟。如果要休眠毫秒级、微秒级,则可以使用小数。例如:

[root@arm64v8_backup ~]# sleep 0.5      # 表示休眠半秒钟。

此外,还有专门的微秒级的休眠命令 usleep。例如:

[root@arm64v8_backup ~]# usleep 1000    # 表示休眠1000微秒,即1毫秒。

B11.4、tr 命令

tr 主要用于将从标准输入读取的数据进行结果集映射、字符压缩和字符删除。

tr [options] [SET1] [SET2]

-c: 使用SET1的补集
-d: 删除字符
-s: 压缩字符
-t: 截断SET1,使得SET1的长度和SET2的长度相同
  1. 删除文件中的空行

    [root@arm64v8_backup ~]# echo -e "1\n\n2\n\n\n3" | tr -s '\n'
    1
    2
    3
    [root@arm64v8_backup ~]#
    • 如果参数 -s 替换为 -d,就是删除所有换行符,输出结果为 123。
  2. 删除重复字符

    [root@arm64v8_backup ~]# echo "Hellooo    Javaaa" | tr -s "[ ao]"
    Hello Java
    [root@arm64v8_backup ~]# echo "Heoolloooo oo Pythonnnnn" | tr -s 'on'
    Heollo o Python
    [root@arm64v8_backup ~]#
    [root@arm64v8_backup ~]# echo "abc123" | tr -d "[:digit:]"
    abc
    [root@arm64v8_backup ~]# echo "abc123" | tr -d -c "[:digit:]"
    123
    • -s 是删除所有重复出现字符序列,只保留第一个。
    • [] 中括号表示的是一个字符类,可以自定义,也可以使用内置字符类,比如:
      • [:alnum:]所有的数字和字母。
      • [:alpha:]所有的字母。
      • [:digit:]所有的数字。
      • [:blank:]所有水平空白=空格+tab。
      • [:space:]所有水平或垂直空白=空格+tab+分行符+垂直tab+分页符+回车键。
      • 使用 man tr 查看更多
  3. 删除空格

    [root@arm64v8_backup ~]# echo "   Hello World  " | tr -d '[ \t]'
    HelloWorld
    [root@arm64v8_backup ~]#
    • 删除空格,包括 tab 键。
    • 这里 tr 命令会删除包括中间的空格,如果只需要删除行首或者尾部的空格,可以使用 sed 命令。
  4. 大小写替换,shell 编程中可用于忽略大小写的字符串判断场景。

    [root@arm64v8_backup ~]# echo "Hello World" | tr '[a-z]' '[A-Z]'
    HELLO WORLD
    [root@arm64v8_backup ~]# echo "Hello World" | tr '[A-Z]' '[a-z]'
    hello world
    [root@arm64v8_backup ~]# echo "Hello World" | tr '[A-Za-z]' '[a-zA-Z]'
    hELLO wORLD
    [root@arm64v8_backup ~]#
  5. 使用 -t 选项截断替换

    [root@arm64v8_backup ~]# echo "acdbe" | tr -t "ab" "xyz"
    xcdye
    [root@arm64v8_backup ~]# echo "acdbe" | tr -t "abcde" "xy"
    xcdye
    [root@arm64v8_backup ~]#
    • 类似木桶原理,SET1 和 SET2 谁最短以谁为准截断成相同长度,然后一一对应替换。
  6. 删除数字或字母,在 shell 编程中可用于判断输入是否为纯数字或字母。

    [root@arm64v8_backup ~]# echo "hello 123World456" | tr -d '[0-9]'
    hello World
    [root@arm64v8_backup ~]# echo "hello123World456" | tr -d '[a-zA-Z]'
    123456
    [root@arm64v8_backup ~]#
  7. 将多行内容合并为一行。

    [root@arm64v8_backup ~]# echo -e "1\n2\n3\n4" | tr -d '\n'
    1234
  8. 将多个连续空格合并为一个空格,并将空格替换为破折号 -

    [root@arm64v8_backup ~]# echo "2018       06  01" |tr -s ' ' '-'
    2018-06-01
    [root@arm64v8_backup ~]#
  9. 删除非数字字符,主要用于了解下 -c 参数的作用。

    [root@arm64v8_backup ~]# echo "2018abcdefdf06zzz01" |tr -d -c '[0-9]'
    20180601

B11.5、cut 命令

cut 命令将行按指定的分隔符分割成多列,它的弱点在于不好处理多个分隔符重复的情况,因此经常结合 tr 的压缩功能。

cut OPTION... [FILE]...

-b:                     按字节筛选;
-n:                     与"-b"选项连用,表示禁止将字节分割开来操作;
-c:                     按字符筛选;
-f:                     按字段筛选;
-d:                     指定字段分隔符,不写-d时的默认字段分隔符为"TAB";因此只能和"-f"选项一起使用。
-s:                     避免打印不包含分隔符的行;
--complement:           补足被选择的字节、字符或字段(反向选择的意思或者说是补集);
--output-delimiter:     指定输出分割符;默认为输入分隔符。

准备一个示例文件:每一行都有 6 个字段,没有对齐。

[root@arm64v8_backup ~]# cat cut.txt 
ID  name    gender  age  email          phone
1 Bob     male    28   abc@qq.com     18023394012
5     Alex    male    18   ccc@xyz.com    18185904230
3  Tony    male    21   aaa@163.com    17048792503
2   Alice  female 24  def@gmail.com  18084925203
4      Kevin      male    21   bbb@189.com    17023929033
[root@arm64v8_backup ~]#
  1. 取出示例文件的第 2 和第 4 个字段

    [root@arm64v8_backup ~]# cat cut.txt | cut -d " " -f2,4
    
    Bob 
    
    Alice
    
    [root@arm64v8_backup ~]# cat cut.txt | tr -s " " | cut -d " " -f2,4
    name age
    Bob 28
    Alex 18
    Tony 21
    Alice 24
    Kevin 21
    [root@arm64v8_backup ~]#
    • 先用 tr 压缩一下每行各字段之间的空格,然后使用 cut 就很方便处理,否则很难定义分隔符。
    • 2,4 表示第 2 和第 4 字段,如果要取连续字段,可使用 - 连接,比如 2-4
  2. 输出除了第 2 字段和第 4 字段其余的所有字段。

    [root@arm64v8_backup ~]# cat cut.txt | tr -s " " | cut -d " " -f2,4 --complement
    ID gender email phone
    1 male abc@qq.com 18023394012
    5 male ccc@xyz.com 18185904230
    3 male aaa@163.com 17048792503
    2 female def@gmail.com 18084925203
    4 male bbb@189.com 17023929033
    [root@arm64v8_backup ~]#
  3. 按字符或字节范围进行截取

    [root@arm64v8_backup ~]# cat cut.txt | tr -s " " | cut -c1-3
    ID 
    1 B
    5 A
    3 T
    2 A
    4 K
    [root@arm64v8_backup ~]# cat cut.txt | tr -s " " | cut -b1-3
    ID 
    1 B
    5 A
    3 T
    2 A
    4 K
    [root@arm64v8_backup ~]#
  4. 指定输出分隔符

    [root@arm64v8_backup ~]# cat cut.txt | tr -s " " | cut -d " " -f2,4 --output-delimiter $'\t'
    name age
    Bob      28
    Alex 18
    Tony 21
    Alice    24
    Kevin    21
    [root@arm64v8_backup ~]#
    • 指定制表符这种无法直接输入的特殊字符的方式是 $'\t'

B11.6、sort 命令

sort 读取每一行输入,并按照指定的分隔符将每一行划分成多个字段,这些字段就是 sort 排序的对象。

sort 可以指定按照何种排序规则进行排序,如按照当前字符集排序规则(这是默认排序规则)、按照字典排序规则、按照数值排序规则、按照月份排序规则、按照文件大小格式( k < M < G )。

还可以去除重复行,指定降序或升序(默认)的排序方式。

默认的排序规则为字符集排序规则,通常几种常见字符的顺序为:"空字符串 < 空白字符 < 数值 < a < A < b < B < ... < z <Z",这也是字典排序的规则。

sort [OPTION]... [FILE]...

选项说明:
-c:         检测给定的文件是否已经排序。如未排序,则会输出诊断信息,提示从哪一行开始乱序。
-C:         类似于"-c",只不过不输出任何诊断信息。可以通过退出状态码1判断出文件未排序。
-m:         对给定的多个已排序文件进行合并。在合并过程中不做任何排序动作。
-b:         忽略字段的前导空白字符。空格数量不固定时,该选项几乎是必须要使用的。"-n"选项隐含该选项。
-d:         按照字典顺序排序,只支持字母、数值、空白。除了特殊字符,一般情况下基本等同于默认排序规则。
--debug:    将显示排序的过程以及每次排序所使用的字段、字符。同时还会在最前几行显示额外的信息。
-f:         将所有小写字母当成大写字母。例如,"b"和"B"是相同的。
  :         在和"-u"选项一起使用时,如果排序字段的比较结果相等,则丢弃小写字母行。

-k:         指定要排序的key,key由字段组成。key格式为"POS1[,POS2]",POS1为key起始位置,POS2为key结束位置。
-n:         按数值排序。空字符串""或"\0"被当作空。该选项除了能识别负号"-",其他所有非数字字符都不识别。
  :         当按数值排序时,遇到不识别的字符时将立即结束该key的排序。

-M:         按字符串格式的月份排序。会自动转换成大写,并取缩写值。规则:unknown<JAN<FEB<...<NOV<DEC。
-o:         将结果输出到指定文件中。
-r:         默认是升序排序,使用该选项将得到降序排序的结果。
  :         注意:"-r"不参与排序动作,只是操作排序完成后的结果。

-s:         禁止sort做"最后的排序"。
-t:         指定字段分隔符。
  :         对于特殊符号(如制表符),可使用类似于-t$'\t'或-t'ctrl+v,tab'(先按ctrl+v,然后按tab键)的方法实现。

-u:         只输出重复行的第一行。结合"-f"使用时,重复的小写行被丢弃。

准备一个示例文件:

[root@arm64v8_backup ~]# cat system.txt 
6       Debian  600     200
3       bsd     1000    600
1       mac     2000    500
5       SUSE    4000    300
2       winxp   4000    300
4       linux   1000    200
[root@arm64v8_backup ~]#
  1. 不加任何选项时,将对整行从第一个字符开始依次向后直到行尾按照默认的字符集排序规则做升序排序。

    [root@arm64v8_backup ~]# sort system.txt 
    1       mac     2000    500
    2       winxp   4000    300
    3       bsd     1000    600
    4       linux   1000    200
    5       SUSE    4000    300
    6       Debian  600     200
    [root@arm64v8_backup ~]#
    • 由于每行的第一个字符 1 < 2 < 3 < 4 < 5 < 6,所以结果如上。
  2. 以第三列为排序列进行排序。由于要划分字段,所以指定字段分隔符。指定制表符这种无法直接输入的特殊字符的方式是 $'\t'

    [root@arm64v8_backup ~]# sort -t $'\t' -k3 system.txt 
    4    linux   1000    200
    3    bsd     1000    600
    1    mac     2000    500
    2    winxp   4000    300
    5    SUSE    4000    300
    6    Debian  600     200
    [root@arm64v8_backup ~]#
    • 结果中虽然 1000 < 2000 < 4000 的顺序是对了,但 600 却排在最后面,因为这是按照默认字符集排序规则进行排序的,字符 6 大于 4,所以排最后一行。
  3. 对第三列按数值排序规则进行排序。

    [root@arm64v8_backup ~]# sort -t $'\t' -k3 -n system.txt 
    6    Debian  600     200
    3    bsd     1000    600
    4    linux   1000    200
    1    mac     2000    500
    2    winxp   4000    300
    5    SUSE    4000    300
    [root@arm64v8_backup ~]#
    • 结果中 600 已经排在第一行。结果中第 2 行、第 3 行的第三列值均为 1000,如何决定这两行的顺序?
  4. 在对第 3 列按数值排序规则排序的基础上,使用第 4 列作为决胜属性,且是以数值排序规则对第 4 列排序。

    [root@arm64v8_backup ~]# sort -t $'\t' -k3 -k4 -n system.txt 
    6    Debian  600     200
    4    linux   1000    200
    3    bsd     1000    600
    1    mac     2000    500
    2    winxp   4000    300
    5    SUSE    4000    300
    [root@arm64v8_backup ~]#
  5. 如果想在第 3 列按数值排序后,以第 2 列作为决胜列呢?由于第 2 列为字母而非数值,所以下面的语句是错误的,虽然得到了期望的结果。

    [root@arm64v8_backup ~]# sort -t $'\t' -k3 -k2 -n system.txt
    6    Debian  600     200
    3    bsd     1000    600
    4    linux   1000    200
    1    mac     2000    500
    2    winxp   4000    300
    5    SUSE    4000    300
    [root@arm64v8_backup ~]#
    • 之所以最终得到了正确的结果,是因为默认情况下,在命令行中指定的排序行为结束后,sort 还会做最后一次排序,这最后一次排序是对整行按照完全默认规则进行排序的,也就是按字符集、升序排序。由于 1000 所在的两行中的第一个字符 3 小于 4,所以 3 排在前面。

    • 之所以说上面的语句是错误的,是因为第 2 列第一个字符是字母而不是数值,在按数值排序时,字母是不可识别字符,一遇到不可识别字符就会立即结束该字段的排序行为。可以使用 "--debug" 选项来查看排序的过程和排序时所使用的列。注意,该选项只有 CentOS 7+ 上的 sort 才有。

      [root@arm64v8_backup ~]# sort --debug -t $'\t' -k3 -k2 -n system.txt
      sort: using ‘en_US.UTF-8’ sorting rules
      sort: key 1 is numeric and spans multiple fields
      sort: key 2 is numeric and spans multiple fields
      6>Debian>600>200
            ___           # 第1次排序行为,即对"-k3"排序,此次用于排序的字段为第3列
      ^ no match for key   # 第2次排序行为,即对"-k2"排序,但显示无法匹配排序key
      ________________       # 默认sort总会进行最后一次排序,排序对象为整行
      3>bsd>1000>600
         ____
      ^ no match for key
      ______________
      4>linux>1000>200
           ____
      ^ no match for key
      ________________
      1>mac>2000>500
         ____
      ^ no match for key
      ______________
      2>winxp>4000>300
           ____
      ^ no match for key
      ________________
      5>SUSE>4000>300
          ____
      ^ no match for key
      _______________
      [root@arm64v8_backup ~]#
    • 由此可见,排序规则也讲究一个先后顺序,先按照人为指定的字段排序,可以指定多个字段,比如 -k3 -k2,先按第 3 个字段排序,如果第 3 字段有相同的值,再按第 2 个字段排序,如果还有相同的值,最后按照默认字符集排序;如果 -k3 没有相同的值,那一次性就排序完了,后面的 -k2 和默认字符排序无意义。

  6. 在对第 3 列按数值排序规则排序的基础上,使用第 2 列作为决胜属性,且以默认排序规则对此列降序排序。

    [root@arm64v8_backup ~]# sort -t $'\t' -k3n -k2r system.txt
    6    Debian  600     200
    4    linux   1000    200
    3    bsd     1000    600
    1    mac     2000    500
    2    winxp   4000    300
    5    SUSE    4000    300
    [root@arm64v8_backup ~]#
    • 紧跟在字段后的选项(如 "-k3n" 的 "n" 和 "-k2r" 的 "r")称为私有选项,使用短横线写在字段外的选项(如 "-n"、"-r")为全局选项。
    • 当没有为字段分配私有选项时,该排序字段将继承全局选项。当然,只有像 "-n"、"-r" 这样的排序性的选项才能继承和分配给字段,"-t" 这样的选项则无法分配。
    • 因此,"-n -k3 -k4"、"-n -k3n -k4" 和 "-k3n -k4n" 是等价的,"-r -k3n -k4" 和 "-k3nr -k4r" 是等价的。
  7. 精确指定排序 key

    [root@arm64v8_backup ~]# sort -t $'\t' -n -k3,4 system.txt
    6    Debian  600 200
    3    bsd     1000    600
    4    linux   1000    200
    1    mac     2000    500
    2    winxp   4000    300
    5    SUSE    4000    300
    [root@arm64v8_backup ~]#
    • key 由字段组成,格式为 "POS1,[POS2]",表示每行排序的起始和终止位置。也就是说,key 才是排序的对象。如果省略 POS2,则 key 将自动扩展到行尾,即等价于 "POS1,line_end"。
    • 由于 "n" 选项只能识别数字和负号 "-",当排序时遇到无法识别字符时,将导致该 key 的排序立即结束。
      • 也就是说,对于 "abc 123 456 abc" 这样的输入,分隔符为空格,当指定 "-k 2,3n" 时,虽然排序 key 包括 "123 456",但由于中间的空白字符无法被 n 识别,使得在排完第 2 字段 "123" 时就立即结束该 key 的排序。
      • 但是 key 为 "123 456" 对于默认的字符集排序规则来说是有意义的。
    • 总结:使用 -n 数值排序时,不要指定 POS2,因为毫无意义;如果使用默认字符集排序规则可以使用 POS2。
  8. 使用 "-u" 去除重复字段所在的行。例如第 3 列有两行 1000,两行 4000,去除字段重复的行时,将只保留排在前面的第一行。

    [root@arm64v8_backup ~]# sort -t $'\t' -k3n -u system.txt
    6    Debian  600     200
    3    bsd     1000    600
    1    mac     2000    500
    5    SUSE    4000    300
    [root@arm64v8_backup ~]#

B11.7、uniq 命令

uniq 是去重,不相邻的行不算重复值。

uniq [OPTION]... [INPUT [OUTPUT]]

选项说明:
-c:统计出现的次数(count)。
-d:只显示被计算为重复的行。
-D:显示所有被计算为重复的行。
-u:显示唯一值,即没有重复值的行。
-i:忽略大小写。
-z:在末尾使用\0,而不是换行符。
-f:跳过多少个字段(field)开始比较重复值。
-s:跳过多少个字符开始比较重复值。
-w:比较重复值时每行比较的最大长度。即对每行多长的字符进行比较。
# 去重只能删除相邻行相同的值
[root@arm64v8 ~]# cat uniq.txt 
abct
abc
123
123
456
789
456
[root@arm64v8 ~]# uniq uniq.txt 
abct
abc
123
456
789
456
[root@arm64v8 ~]#
# 排序后去重
[root@arm64v8 ~]# sort uniq.txt |uniq 
123
456
789
abc
abct
[root@arm64v8 ~]#
# 使用-d显示重复的行
[root@arm64v8 ~]# sort uniq.txt | uniq -d
123
456
[root@arm64v8 ~]#
# 使用-D显示所有重复过的行
[root@arm64v8 ~]# sort uniq.txt | uniq -D
123
123
456
456

# 使用-u显示唯一行
[root@arm64v8 ~]# sort uniq.txt | uniq -u
789
abc
abct

# 使用-c统计哪些记录出现的次数
[root@arm64v8 ~]# sort uniq.txt | uniq -c
      2 123
      2 456
      1 789
      1 abc
      1 abct

# 使用-d -c统计重复行出现的次数。
[root@arm64v8 ~]# sort uniq.txt | uniq -c -d
      2 123
      2 456

# -c不能和-D一起使用。结果说显示所有重复行再统计重复次数是毫无意义的行为。
[root@arm64v8 ~]# sort uniq.txt | uniq -c -D
uniq: printing all duplicated lines and repeat counts is meaningless
Try 'uniq --help' for more information.
[root@arm64v8 ~]#

B11.8、seq 命令

seq 命令用于输出数字序列。支持正数序列、负数序列、小数序列。

seq [OPTION]... LAST                  # 指定输出的结尾数字,初始值和步长默认都为1
seq [OPTION]... FIRST LAST            # 指定开始和结尾数字,步长默认为1
seq [OPTION]... FIRST INCREMENT LAST  # 指定开始值、步长和结尾值

OPTION:
-s:指定分隔符,默认是\n。
-w:使用0填充左边达到数字的最大宽度。
[root@arm64v8 ~]# seq 5
1
2
3
4
5
[root@arm64v8 ~]# seq -s "-" 5                # 指定使用减号作为分隔符
1-2-3-4-5
[root@arm64v8 ~]# seq -s "-" 5 10         # 指定开始和结尾
5-6-7-8-9-10
[root@arm64v8 ~]# seq -s "-" 5  2 10      # 指定开始、步长和结尾
5-7-9
[root@arm64v8 ~]# seq -s "-" 3.1 2 10     # 小数序列
3.1-5.1-7.1-9.1
[root@arm64v8 ~]# seq -s "-" 3.1 2.3 10
3.1-5.4-7.7-10.0
[root@arm64v8 ~]# seq -w 99 100 1200        # 左边使用0填充
0099
0199
0299
0399
0499
0599
0699
0799
0899
0999
1099
1199
[root@arm64v8 ~]#

B11.9、expect 命令

借助 expect 处理交互的命令,可以将交互过程如 ssh 登录,ftp 登录等写在一个脚本上,使之自动化完成。尤其适用于需要对多台服务器执行相同操作的环境中,可以大大提高系统管理员的工作效率。

expect 命令使用起来也可以很复杂,但往往我们的需求很简单,所以我们只学习常用的语法,想要更精深,这里有一遍不错的文章 expect - 自动交互脚本

expect [选项] [ -c cmds ] [ [ -[f|b] ] cmdfile ] [ args ]

-c: 执行脚本前先执行的命令,可多次使用。
-d: debug 模式,可以在运行时输出一些诊断信息,与在脚本开始处使用 exp_internal 1 相似。
-f: 从文件读取命令,仅用于使用 #! 时。如果文件名为 "-",则从 stdin 读取(使用 "./-" 从文件名为 - 的文件读取)。
-i: 交互式输入命令,使用 "exit" 或 "EOF" 退出输入状态。
--: 标示选项结束(如果你需要传递与 expect 选项相似的参数给脚本时),可放到 #! 行: #!/usr/bin/expect --。
-v: 显示 expect 版本信息。
expect 中的相关命令

spawn:          启动新的进程 
send:           向进程发送字符串 
expect:         从进程接收字符串 
interact:       允许用户交互,比如 ssh 到某服务器后保持登陆不退出状态
exp_continue:   匹配多个字符串时在执行动作后加此命令,否则一个expect代码块中第一个匹配后执行完动作就 return 了
expect eof:     结束 expect
expect 最常用的语法(tcl 语言:模式-动作) 

单一分支模式的语法: 
    expect "hi" { send "You said hi\n" }    匹配到 hi 后,会输出"you said hi",并换行
    [root@armv8_1 ~]# expect 
    expect1.1> expect "hi" { send "You said hi\n" }
    hight
    You said hi
    expect1.2>

多分支模式的语法: 
    expect "hi" { send "You said hi\n" }  "hehe" { send "Hehe yourself\n" }  "bye" { send "Good bye\n" }
    [root@armv8_1 ~]# expect 
    expect1.1> expect "hi" { send "You said hi\n" }  "hehe" { send "Hehe yourself\n" }  "bye" { send "Good bye\n" }
    byeybe
    Good bye
    expect1.2>
    等价于:
    expect { "hi" { send "You said hi\n" } "hehe" { send "Hehe yourself\n" } "bye" { send "Goodbye\n" } } 

B11.9.1、示例一:密钥拷贝

  1. 安装 expect,系统默认没有此命令
[root@armv8_1 ~]# yum install expect
  1. 创建配置文件
[root@armv8_1 ~]# vim hosts 
192.168.122.12 root root
192.168.122.13 root root
192.168.122.14 root root
  1. 编写脚本
[root@armv8_1 ~]# vim copykey.sh 
#!/bin/bash

# create keypair
if [ ! -f ~/.ssh/id_rsa ];then
 ssh-keygen -t rsa -P "" -f ~/.ssh/id_rsa
else
 echo "id_rsa has been created ..."
fi

# distribute publickey to hosts in hosts file
while read line
  do
    user=`echo $line | cut -d " " -f 2`
    ip=`echo $line | cut -d " " -f 1`
    passwd=`echo $line | cut -d " " -f 3`
    expect <<EOF
      set timeout 10
      spawn ssh-copy-id $user@$ip
      expect {
        "yes/no" { send "yes\n";exp_continue }
        "password" { send "$passwd\n" }
      }
     expect "password" { send "$passwd\n" }
EOF
  done <  hosts
[root@armv8_1 ~]# ls
copykey.sh  hosts
  1. 给脚本执行权限
[root@armv8_1 ~]# chmod +x copykey.sh
  1. 执行脚本
[root@armv8_1 ~]# ./copykey.sh

B11.9.2、示例二:文件拷贝

[root@armv8_1 ~]# vim scp.sh
#!/usr/bin/expect
spawn scp /etc/fstab root@192.168.122.12:/root
expect {
     "yes/no" { send "yes\n";exp_continue }
     "password" { send "root\n" }
}
expect eof
[root@armv8_1 ~]#
[root@armv8_1 ~]# chmod +x scp.sh 
[root@armv8_1 ~]# ./scp.sh 
spawn scp /etc/fstab root@192.168.122.12:/root
fstab                                                      100%  682   657.8KB/s   00:00    
expect: spawn id exp6 not open
    while executing
"expect eof"
    (file "./scp.sh" line 7)
[root@armv8_1 ~]#
  • 从结果来看,倒是已经实现了文件传输,为什么还有报错 expect: spawn id exp6 not open
    • 因为上一个实验实现了免密登陆,所以这里的 expect 都没有排上用场,not open。

B11.9.3、示例三:远程执行命令

[root@armv8_1 ~]# vim executecmd.sh
#!/usr/bin/expect 
# usage: ./executecmd.sh <IP> <USER> <PASSWORD>

  set ip [lindex $argv 0] 
  set user [lindex $argv 1] 
  set password [lindex $argv 2] 
  set timeout 10 
  spawn ssh $user@$ip 
  expect {   
       "yes/no" { send "yes\n";exp_continue }         
       "password" { send "$password\n" } 
  } 
  expect "]#" { send "useradd yidam\n" }
  expect "]#" { send "echo welcome|passwd --stdin yidam\n" }
  send "exit\n"
  expect eof
[root@armv8_1 ~]# chmod +x executecmd.sh 
[root@armv8_1 ~]# ./executecmd.sh "192.168.122.12" "root" "root"
spawn ssh root@192.168.122.12
The authenticity of host '192.168.122.12 (192.168.122.12)' can't be established.
ECDSA key fingerprint is SHA256:CazUKsUPgYGfZkFikEyZczk7ekVoHoXkoTrBuyIR6FQ.
ECDSA key fingerprint is MD5:1e:b6:d9:9a:03:7c:9a:11:99:f8:c7:52:f9:0a:4d:be.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '192.168.122.12' (ECDSA) to the list of known hosts.
root@192.168.122.12's password: 
Last login: Sat Nov 27 17:27:29 2021 from 192.168.122.11
[root@armv8_2 ~]# useradd yidam
[root@armv8_2 ~]# echo welcome|passwd --stdin yidam
Changing password for user yidam.
passwd: all authentication tokens updated successfully.
[root@armv8_2 ~]# exit
logout
Connection to 192.168.122.12 closed.
[root@armv8_1 ~]#

B11.9.4、示例四:shell 调用 expect

[root@armv8_1 ~]# vim shllcallexpt.sh
#!/bin/bash
# description: this script plans to use shell to call expect
# usage: ./shllcallexpt.sh <IP> <USER> <PASSWORD>

ip=$1  
user=$2 
password=$3 
expect << EOF  
    set timeout 10 
    spawn ssh $user@$ip 
    expect { 
        "yes/no" { send "yes\n";exp_continue } 
        "password" { send "$password\n" }
    } 
    expect "]#" { send "useradd Brinnatt\n" } 
    expect "]#" { send "echo welcome|passwd --stdin Brinnatt\n" } 
    expect "]#" { send "exit\n" }
    expect eof 
EOF
[root@armv8_1 ~]# chmod +x shllcallexpt.sh 
[root@armv8_1 ~]# ./shllcallexpt.sh 192.168.122.12 "root" "root"
spawn ssh root@192.168.122.12
The authenticity of host '192.168.122.12 (192.168.122.12)' can't be established.
ECDSA key fingerprint is SHA256:CazUKsUPgYGfZkFikEyZczk7ekVoHoXkoTrBuyIR6FQ.
ECDSA key fingerprint is MD5:1e:b6:d9:9a:03:7c:9a:11:99:f8:c7:52:f9:0a:4d:be.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '192.168.122.12' (ECDSA) to the list of known hosts.
root@192.168.122.12's password: 
Last login: Sat Nov 27 17:32:25 2021 from 192.168.122.11
[root@armv8_2 ~]# useradd Brinnatt
[root@armv8_2 ~]# echo welcome|passwd --stdin Brinnatt
Changing password for user Brinnatt.
passwd: all authentication tokens updated successfully.
[root@armv8_2 ~]# exit
logout
Connection to 192.168.122.12 closed.
[root@armv8_1 ~]#

B12、测试表达式

test 可用于测试表达式,支持测试的范围包括:字符串比较,算术比较,文件存在性、属性、类型等判断。

[] 完全等价于 test,只是写法不同。双中括号 [[]] 基本等价于 [],它支持更多的条件表达式,还允许在双中括号内使用逻辑运算符 "&&""||""!""()",但这些使用单中括号都能实现,只不过多写几个单中括号而已。单中括号 [] 无法实现的功能是正则表达式匹配,而 [[]] 可以实现。

test[ ] 以及 [[ ]] 都使用条件表达式来完成测试。test[] 用法虽简单,但语法比较复杂,反而是 [[]] 的语法较为简单。但不管如何,先解释条件表达式。

  1. 文件类条件表达式

    表达式 含义
    -e file 文件是否存在(exist)
    -f file 文件是否存在且为普通文件(file)
    -d file 文件是否存在且为目录(directory)
    -b file 文件是否存在且为块设备block device
    -c file 文件是否存在且为字符设备character device
    -S file 文件是否存在且为套接字文件Socket
    -p file 文件是否存在且为命名管道文件FIFO(pipe)
    -L file 文件是否存在且是一个链接文件(Link)
  2. 文件属性条件表达式

    表达式 含义
    -r file 文件是否存在且当前用户可读
    -w file 文件是否存在且当前用户可写
    -x file 文件是否存在且当前用户可执行
    -u file 文件是否存在且设置了SUID
    -g file 文件是否存在且设置了SGID
    -k file 文件是否存在且设置了sbit(sticky bit)
    -s file 文件是否存在且大小大于0字节,即用于检测文件是否为非空白文件
    -N file 文件是否存在,且自上次read后是否被modify
  3. 两个文件比较

    表达式 含义
    file1 -nt file2 (newer than)判断file1是否比file2新
    file1 -ot file2 (older than)判断file1是否比file2旧
    file1 -ef file2 (equal file)判断file2与file2是否为同一文件,可用在判断hard link的判定上。
    主要意义在判定,两个文件是否均指向同一个分区上的同一个inode
  4. 两个整数比较

    表达式 含义
    int1 -eq int2 两数值相等(equal)
    int1 -ne int2 两数值不等(not equal)
    int1 -gt int2 n1大于n2(greater than)
    int1 -lt int2 n1小于n2(less than)
    int1 -ge int2 n1大于等于n2(greater than or equal)
    int1 -le int2 n1小于等于n2(less than or equal)
  5. 判定字符串

    表达式 含义
    -z string (zero)判定字符串是否为空?若string为空字符串,则为true
    -n string 判定字符串是否非空?若string为空字符串,则false。注:-n可省略
    string1 = string2
    string1 == string2
    string1和string2是否相同。相同则返回true。"=="和"="等价,但"="可移植性更好
    str1 != str2 str1是否不等于str2,若不等,则返回true
    str1 > str2 str1字母顺序是否大于str2,若大于,则返回true
    str1 < str2 str1字母顺序是否小于str2,若小于,则返回true
  6. 逻辑运算符

    表达式 含义
    -a或&& (and)两表达式同时为true时才为true。"-a"只能在test或[]中使用,&&只能在[[]]中使用
    -o或|| (or)两表达式任何一个true则为true。"-o"只能在test或[]中使用,||只能在[[]]中使用
    ! 对表达式取反
    ( ) 用于改变表达式的优先级,为了防止被shell解析,应该加上反斜线转义( )

test 和 [ ] 用法

  1. 不带任何参数时,直接返回 false。

    [root@arm64v8_backup ~]# [ ];echo $?
    1
  2. 只有一个参数时,测试表达式采取的是 [ arg ],根据条件表达式的说明,仅当 arg 为非空时返回 true。

    [root@arm64v8_backup ~]# test nick;echo $?
    0
    [root@arm64v8_backup ~]# test $nothing; echo $?
    1
    [root@arm64v8_backup ~]# test '';echo $?
    1
    [root@arm64v8_backup ~]#
  3. 两个参数时

    # 单目条件运算,文件类测试
    [root@arm64v8_backup ~]# [ -e /etc/passwd ];echo $?
    0
    [root@arm64v8_backup ~]# [ -n "abcd" ];echo $?
    0
    [root@arm64v8_backup ~]# [ -z "abcd" ];echo $?
    1
    [root@arm64v8_backup ~]#
    
    # 取反
    [root@arm64v8_backup ~]# [ ! -e /etc/passwd ];echo $?
    1
    [root@arm64v8_backup ~]# [ ! -n "abcd" ];echo $?
    1
    [root@arm64v8_backup ~]# [ ! -z "abcd" ];echo $?
    0
    [root@arm64v8_backup ~]#
  4. 三个参数时

    # 双目运算符
    [root@arm64v8_backup ~]# [ 12 -eq 12 ];echo $?
    0
    [root@arm64v8_backup ~]# [ 12 -eq 13 ];echo $?
    1
    [root@arm64v8_backup ~]# [ "someone" != "somebody"]; echo $? # 注意格式
    -bash: [: missing `]'
    2
    [root@arm64v8_backup ~]# [ "someone" != "somebody" ]; echo $?
    0
    [root@arm64v8_backup ~]#
  5. 四个参数以上

    [root@arm64v8_backup ~]# [ "abc" == "abc" -a "bcd" == "xyz" ];echo $?
    1
    [root@arm64v8_backup ~]# [ "abc" == "abc" -o "bcd" == "xyz" ];echo $?
    0
    [root@arm64v8_backup ~]#

[[ ]] 用法

  1. 当条件表达式中使用的运算符是 "==""!=" 时,该运算符的右边会被当作 pattern 被匹配,"==" 表示能匹配成功则返回 0,"!=" 则相反。但此时只是通配符匹配,不支持正则表达式匹配。通配符包括:"*""?""[...]"

    [root@arm64v8_backup ~]# [[ abc == a* ]];echo $?
    0
    [root@arm64v8_backup ~]# 
    [root@arm64v8_backup ~]# [[ abc == a*d ]];echo $?
    1
    [root@arm64v8_backup ~]#
  2. 当条件表达式中使用的运算符是 "=~" 时,该运算符的右边会被当作正则表达式的 pattern 被匹配。

    [root@arm64v8_backup ~]# [[ abc =~ aa* ]];echo $?
    0
    [root@arm64v8_backup ~]# [[ abc =~ aa.* ]];echo $?
    1
    [root@arm64v8_backup ~]#
  3. 除了可以使用逻辑运算符 ! 和 (),还可以使用 &&、||,分别表示逻辑与和逻辑或,等价于 [] 的 "-a" 和 "-o"。但是 [[]] 不再支持 "-a" 和 "-o"。

    [root@arm64v8_backup ~]# [[ 3 -eq 3 && 5 -eq 5 ]];echo $?
    0
    [root@arm64v8_backup ~]#

    总之,除了模式匹配和正则表达式匹配时需要使用 [[]],其余时候建议使用 [ ]。

B13、shell 编程控制语句

任何编程语言都有对应的控制语句,像函数结构、循环结构及判断结构等;shell 编程对于大多数人来说,只是为了提高运维效率,很少有人使用 shell 完成很复杂的需求;所以我们只需要了解一下 shell 常用的语法即可。

B13.1、shell 函数

在 shell 中,函数可以被当作命令一样执行,它是命令的组合结构体。可以将函数看成是一个普通命令或者一个小型脚本。

首先给出几个关于函数的结论:

  1. 当在 bash 中直接调用函数时,如果函数名和命令名相同,则优先执行函数,除非使用 command 命令。例如:定义了一个名为 rm 的函数,在 bash 中输入 rm 执行时,执行的是 rm 函数,而非 /bin/rm 命令,除非使用 "command rm ARGS"。
  2. 如果函数名和命令别名同名,则优先执行别名。也就是说,在优先级方面:别名 > 函数 > 命令自身。
  3. 当前 shell 定义的函数只能在当前 shell 使用,子 shell 无法继承父 shell 的函数定义。除非使用 "export -f" 将函数导出为全局函数。
  4. 定义了函数后,可以使用 unset -f 移除当前 shell 中已定义的函数。
  5. 除非出现语法错误,或者已经存在一个同名只读函数,否则函数的退出状态码是函数内部结构中最后执行的一个命令的退出状态码。
  6. 可以使用 typeset -f [func_name] 或 declare -f [func_name] 查看当前 shell 已定义的函数名和对应的定义语句。使用 typeset -F 或 declare -F 则只显示当前 shell 中已定义的函数名。
  7. 函数可以递归,递归层次可以无限。
  8. shell 函数也接受位置变量 $0、$1、$2...,但函数的位置参数是调用函数时传递给函数的,而非传递给脚本的参数。所以脚本的位置变量和函数的位置变量是不同的,但是 $0 和脚本的位置变量 $0 是一致的。另外,函数也接受特殊变量 "$#",和脚本的 "$#" 一样,它也表示位置变量的个数。
  9. 函数体内部可以使用 return 命令,当函数结构体中执行到return命令时将退出整个函数。return 后可以带一个状态码整数,即 return n,表示函数的退出状态码,不给定状态码时默认状态码为 0。
  10. 函数结构体中可以使用 local 命令定义本地变量,例如:local i=3。本地变量只在函数内部(包括子函数)可见,函数外不可见。
  11. 只有先定义了函数,才可以调用函数。不允许函数调用语句在函数定义语句之前。

函数的语法结构:

[ function ] name () compound-cmd [redirection]
  • 上面的语法结构中定义了一个名为 name 的函数
    • 关键字 function 是可选的,如果使用了 function 关键字,则 name 后的括号可以省略。
    • compound-cmd 是函数体,通常使用大括号 {} 包围。由于历史原因,大括号本身也是关键字,所以为了不产生歧义,函数体和大括号之间必须使用空格、制表符、换行符分隔开来。
    • 同理,大括号中的每一个命令都必须使用分号 ";"、"&" 结束或换行书写。如果使用 "&" 结束某条命令,这表示该命令放入后台执行。
    • 还可以指定可选的函数重定向功能,这样当函数被调用的时候,指定的重定向也会被执行。

定义一个名为 rm 的函数,该函数会将传递的所有文件移动到 "~/backup" 目录下,目的是替代 rm 命令,避免误删除的危险操作。

[root@arm64v8_backup ~]# function rm () { [ -d ~/rmbackup ] || mkdir ~/rmbackup;/bin/mv -f $@ ~/rmbackup; } &>/dev/null
[root@arm64v8_backup ~]#

在调用 rm 函数时,只需是给 rm 函数传递参数即可。例如,要删除 /tmp/a.log。

[root@arm64v8_backup ~]# rm /tmp/a.log
[root@arm64v8_backup ~]# ls rmbackup/
a.log
[root@arm64v8_backup ~]#

在执行函数时,会将执行可能输出的信息重定向到 /dev/null 中。

为了让函数在子 shell(例如脚本)中也可以使用,使用 export 的 "-f" 选项将其导出为全局函数。取消函数的导出则使用 export 的 "-n" 选项。

export -f rm
export -n rm

另外需要注意的是,函数支持无限递归。这可能在不经意间出错,导致崩溃。例如,写一个名为 "ls" 的函数。

function ls() { ls -l; }
  • 这时执行 ls 命令会卡住,和想象中的 "ls -l" 效果完全不同,因为函数体中的 ls 也递归成了函数,这将无限递归下去。

B13.2、if 结构

if test-commands1; then
    commands1;
elif test-commands2; then
    commands2;
...
else
    commands3;
fi

if 的判断很简单,一切都以返回状态码是否为 0 为判决条件。如果 test-commands1 执行后的退出状态码为 0(不是其执行结果为0),则执行 commands1 部分的结构体,否则如果 test-commands2 返回 0 则执行commands2部分的结构体,如果都不满足,则执行 commands3 的结构体。

常见的 test-commands 有几种类型:

  1. 一条普通的命令。只要该命令退出状态码为 0,则执行 then 后的语句体。例如:

    [root@arm64v8_backup ~]# if echo ifok &> /dev/null; then echo bingo;fi
    bingo
    [root@arm64v8_backup ~]#
  2. 测试语句。例如 test、[ ]、[[ ]]。

    [root@arm64v8_backup ~]# if [ $((1+2)) -eq 3 ];then echo bingo;fi
    bingo
    [root@arm64v8_backup ~]# name=Brinnatt
    [root@arm64v8_backup ~]# if [[ "$name" =~ B.*t$ ]];then echo bingo;fi
    bingo
    [root@arm64v8_backup ~]#
  3. 使用逻辑运算符,包括 !、&& 和 ||。该特性主要是为普通命令而提供,因为测试语句自身就支持逻辑运算。所以,对于测试语句就提供了两种写法,一种是将逻辑运算符作为测试语句的一部分,一种是将逻辑运算符作为 if 语句的一部分。例如:

    [root@arm64v8_backup ~]# if ! id "$name" &>/dev/null;then echo "$name" miss;fi
    Brinnatt miss
    [root@arm64v8_backup ~]# if ! [ 3 -eq 3 ];then echo go;fi
    [root@arm64v8_backup ~]# if [ ! 3 -eq 3 ];then echo go;fi
    [root@arm64v8_backup ~]# if [ 3 -eq 3 ] && [ 4 -eq 4 ] ;then echo go;fi
    go
    [root@arm64v8_backup ~]# if [ 3 -eq 3 -a 4 -eq 4 ];then echo go;fi
    go
    [root@arm64v8_backup ~]# if [[ 3 -eq 3 && 4 -eq 4 ]];then echo go;fi
    go
    [root@arm64v8_backup ~]#

    注意,在 if 语句中使用 () 不能改变优先级,而是让括号内的语句成为命令列表并进入子 shell 运行。因此,要改变优先级时,需要在测试语句中完成。

B13.3、case 结构

case expression in
    pattern1)
        statement1
        ;;
    pattern2)
        statement2
        ;;
    pattern3)
        statement3
        ;;
    ……
    *)
        statementn
esac

sysV 风格的服务启动脚本是 shell 脚本中使用 case 语句最典型案例。例如:

case "$1" in
    start)
        start;;
    stop)
        stop;;
    restart)
        restart;;
    reload | force-reload)
        reload;;
    status)
        status;;
    *)
        echo $"Usage: $0 {start|stop|status|restart|reload|force-reload}"
        exit 2
esac

从上面的示例中,可以看出一些结论:

  1. case 中的每个小分句都以双分号 ";;" 结尾,但最后一个小分句的双分号可以省略。实际上,小分句除了使用 ";;" 结尾,还可以使用 ";&" 和 ";;&" 结尾,只不过意义不同,它们用的不多,不过为了文章完整性,稍后还是给出说明。

  2. 每个小分句中的 pattern 部分都使用括号 "()" 包围,只不过左括号 "(" 不是必须的。

  3. 每个小分句的 pattern 支持通配符模式匹配(不是正则匹配模式,因此只有 3 种通配元字符:"*""?"[...]),其中使用 "|" 分隔多个通配符 pattern,表示满足其中一个 pattern 即可。例如 "([yY] | [yY][eE][sS]])" 表示即可以输入单个字母的 y 或 Y,还可以输入 yes 三个字母的任意大小写格式。

    set -- y;case "$1" in ([yY]|[yY][eE][sS]) echo right;;(*) echo wrong;;esac
    • 其中 "set -- string_list" 的作用是将输入的 string_list 按照 IFS 分隔后分别赋值给位置变量 $1、$2、$3...,因此此处是为 $1 赋值字符 "y"。
  4. 最后一个小分句使用的 pattern 是 "*",表示无法匹配前面所有小分句时,将匹配该小分句。一般最后一个小分句都会使用 "*" 避免 case 语句无法匹配的情况,在 shell 脚本中,此小分句一般用于提示用户脚本的使用方法,即给出脚本的 Usage。

如果小分句不是使用双分号 ";;" 结尾,而是使用 ";&" 或 ";;&" 结尾,则 case 语句的行为将改变。

  • ";;" 结尾符号表示小分句执行完成后立即退出 case 语句。
  • ";&" 表示继续执行下一个小分句中的 command 部分,而无需进行匹配动作,并由此小分句的结尾符号来决定是否继续操作下一个小分句。
  • ";;&" 表示继续向后(不止是下一个,而是一直向后)匹配小分句,如果匹配成功,则执行对应小分句中的 command 部分,并由此小分句的结尾符号来决定是否继续向后匹配。
set -- y
case "$1" in
    ([yY]|[yY][eE][sS])
        echo yes;&
    ([nN]|[nN][oO])
        echo no;;
    (*)
        echo wrong;;
esac
yes
no
  • 在此示例中,$1 能匹配第一个小分句,但第一个小分句的结尾符号为 ";&",所以无需判断直接执行第二个小分句的 "echo no",但第二个小分句的结尾符号为 ";;",于是直接退出 case 语句。因此,即使 $1 无法匹配第二个小分句,case 语句的结果中也输出了 "yes" 和 "no"。
set -- y
case "$1" in
    ([yY]|[yY][eE][sS])
        echo yes;;&
    ([nN]|[nN][oO])
        echo no;;
    (*)
        echo wrong;;
esac
yes
wrong
  • 在此示例中,$1 能匹配第一个小分句,但第一个小分句的结尾符号为 ";;&",所以继续向下匹配,第二个小分句未匹配成功,直到第三个小分句才被匹配上,于是执行第三个小分句中的 "echo wrong",但第三个小分句的结尾符号为 ";;",于是直接退出 case 语句。所以,结果中输出了 "yes" 和 "wrong"。

B13.4、for 循环

for 循环在 shell 脚本中应用极其广泛,它有两种语法结构:

结构一:for name [ [ in [ word ... ] ] ; ] do cmd_list ; done
结构二:for (( expr1 ; expr2 ; expr3 )) ; do cmd_list ; done
  1. 结构一:将扩展 in word,然后按照 IFS 变量对 word 进行分割,并依次将分割的单词赋值给变量 name,每赋值一次,执行一次循环体 cmd_list,然后再继续将下一个单词赋值给变量 name,直到所有变量赋值结束。

    如果省略 in word,则等价于 "in $@",即展开位置变量并依次赋值给变量 name。注意,如果 word 中使用引号包围了某些单词,这引号包围的内容被分割为一个单词。

    [root@arm64v8_backup ~]# for i in 1 2 3 4;do echo $i;done
    1
    2
    3
    4
    [root@arm64v8_backup ~]# for i in 1 2 "3 4";do echo $i;done
    1
    2
    3 4
    [root@arm64v8_backup ~]#
  2. 结构二:该结构的 expr 部分只支持数学计算和比较。首先计算 expr1,再判断 expr2 的返回状态码,如果为 0,则执行 cmd_list,并将计算 expr3 的值,并再次判断 expr2 的状态码。直到 expr2 的返回状态码不为 0,循环结束。

    [root@arm64v8_backup ~]# for ((i=1;i<=3;++i));do echo $i;done
    1
    2
    3
    [root@arm64v8_backup ~]# for ((i=1,j=3;i<=3 && j>=2;++i,--j));do echo $i $j;done
    1 3
    2 2
    [root@arm64v8_backup ~]#

B13.5、while 循环

使用 while 循环尽量要让条件运行到可以退出循环,否则无限循环。

语法结构:

while test_cmd_list; do cmd_list; done

首先执行 test_cmd_list 中的命令,当 test_cmd_list 的最后一个命令的状态码为 0 时,将执行一次 cmd_list,然后回到循环的开头继续执行 test_cmd_list。只有 test_cmd_list 中最后一个测试命令的状态码非 0 时,循环才会退出。

例如:计算 1 到 10 的算术和。

[root@arm64v8_backup ~]# let i=1,sum=0;while [ $i -le 10 ];do let sum=sum+i;let ++i;done;echo $sum
55
[root@arm64v8_backup ~]#
  • 在此例中,test_cmd_list 中只有一个命令 [ $i -le 10 ],所以它的状态直接决定整个循环何时退出。
  • test_cmd_list 中可以是多个命令。

while 下面两种用法稍微特殊点:

  1. 无限循环
while :;do         # 或者"while true;do"

    ...

done
  1. 读文件
while read line

do

    ...

done </path/filename

B13.6、until 循环

until 和 while循环基本一致,所不同的仅仅只是 test_cmd_list 的意义。

语法结构:

until test_cmd_list; do cmd_list; done

首先判断 test_cmd_list 中的最后一个命令,如果状态码为非 0,则执行一次 cmd_list,然后再返回循环的开头再次执行 test_cmd_list,直到 test_cmd_list 的最后一个命令状态码为 0 时,才退出循环。

当判断 test_cmd_list 最后一个命令的状态满足退出条件时直接退出循环,也就是说循环是在 test_cmd_list 最后一个命令处退出的。

[root@arm64v8_backup ~]# i=5;until echo tryme;[ "$i" -eq 0 ];do let --i;echo $i;done
tryme
4
tryme
3
tryme
2
tryme
1
tryme
0
tryme
[root@arm64v8_backup ~]#

B13.7、exit、break、continue 和 return

exit [n]:       退出当前shell,在脚本中应用则表示退出整个脚本(子shell)。其中数值n表示退出状态码。
break [n]:      退出整个循环,包括for、while、until和select语句。其中数值n表示退出的循环层次。
continue [n]:   退出当前循环进入下一次循环。n表示继续执行向外退出n层的循环。默认n=1,表示继续当前层的下一循环,n=2表示继续上一层的下一循环。
return [n]:     退出整个函数。n表示函数的退出状态码。

唯一需要注意的是,return 并非只能用于 function 内部,绝大多数人都有这样的误解。如果 return 用在 function 之外,但在 . 或者 source 命令的执行过程中,则直接停止该执行操作,并返回给定状态码 n(如果未给定,则为 0)。如果 return 在 function 之外,且不在 source 或 . 的执行过程中,则这是一个错误用法。

[root@arm64v8_backup ~]# return 
-bash: return: can only `return' from a function or sourced script
[root@arm64v8_backup ~]# 

可能有些人不理解为什么不直接使用 exit 来替代这时候的 return。下面给个例子就能清楚地区分它们。

[root@arm64v8_backup ~]# cat return.sh 
#!/bin/bash

if [ "$1" = "exit" ];then 
        echo "exit current shell..."
        exit 0
else 
        echo "return 0"
        return 0
fi
[root@arm64v8_backup ~]# source return.sh
return 0
[root@arm64v8_backup ~]# source return.sh exit
exit current shell...
Connection closing...Socket close.

Connection closed by foreign host.

Disconnected from remote host(192.168.0.181:22) at 23:30:33.

Type `help' to learn how to use Xshell prompt.
[C:\~]$
  • 当执行 source return.sh 的时候,直接 return,而当给定 exit 参数,即 source return.sh exit 的时候,将直接退出当前 shell。

B14、综合案例

Linux 操作系统下面有一个很好的学习案例,就是 SysV 风格的服务脚本,都是使用 Shell 编写的。我们分析完这些服务框架,将来使用 Shell 就可以得心应手。

B14.1、functions 文件分析

/etc/rc.d/init.d/functions 几乎被 /etc/rc.d/init.d/ 下所有的 Sysv 服务启动脚本加载,在该文件中提供了几个有用的函数。

  • daemon:启动一个服务程序。启动前还检查进程是否已在运行。
  • killproc:杀掉给定的服务进程。
  • status:检查给定进程的运行状态。
  • success:显示绿色的 "OK",表示成功。
  • failure:显示红色的 "FAILED",表示失败。
  • passed:显示绿色的 "PASSED",表示 pass 该任务。
  • warning:显示绿色的 "warning",表示警告。
  • action:根据进程退出状态码自行判断是执行 success 还是 failure。
  • confirm:提示 "(Y)es/(N)o/(C)ontinue? [Y]" 并判断、传递输入的值。
  • is_true"$1" 的布尔值代表为真时,返回状态码 0,否则返回 1。包括 t、y、yes 和 true,不区分大小写。
  • is_false"$1" 的布尔值代表为假时,返回状态码 0。否则返回 1。包括 f、n、no 和 false,不区分大小写。
  • checkpid:检查 /proc 下是否有给定 pid 对应的目录。给定多个 pid 时,只要存在一个目录都返回状态码 0。
  • __pids_var_run:检查 pid 是否存在,并保存到变量 pid 中,同时返回几种进程状态码。是 functions 中重要函数之一。
  • __pids_pidof:获取进程 pid。
  • pidfileofproc:获取进程的 pid。但只能获取 /var/run 下的 pid 文件中的值。
  • pidofproc:获取进程的 pid。可获取任意给定 pidfile 或默认 /var/run 下 pidfile 中的值。

前三个是 functions 文件最重要的 3 个函数,还用到了一些额外的辅助函数,稍稍有点复杂。所以由简至繁,先介绍并展示后面几个函数,再回头解释前 3 个函数。

以下是 /etc/init.d/functions 文件的开头定义的语句。设置 umask 值,使得加载该文件的脚本所在 shell 的 umask 为 22。导出路径变量。

但说实话,这个导出的路径变量并不理想,因为要为非 rpm 包安装的程序设计服务启动脚本时,必须写全路径命令,例如 /usr/local/mysql/bin/mysql。因此,可以考虑将 /etc/init.d/functions 中的语句注释掉。

umask 022

# Set up a default search path.
PATH="/sbin:/usr/sbin:/bin:/usr/bin"
export PATH

注意:本文分析的 /etc/init.d/functions 文件是 CentOS 7+ 上的,和 CentOS 6 有些许区别,但该有的目的和动作都有。

B14.1.1、显示函数

包括 echo_success、success、echo_failure、failure、echo_passed、passed、echo_warning 和 warning 函数。这几个函数的定义方式和使用方法完全一样。

以下是 echo_success 和 success 函数的定义语句。

echo_success() {
  [ "$BOOTUP" = "color" ] && $MOVE_TO_COL
  echo -n "["
  [ "$BOOTUP" = "color" ] && $SETCOLOR_SUCCESS
  echo -n $"  OK  "
  [ "$BOOTUP" = "color" ] && $SETCOLOR_NORMAL
  echo -n "]"
  echo -ne "\r"
  return 0
}

success() {
  [ "$BOOTUP" != "verbose" -a -z "${LSB:-}" ] && echo_success
  return 0
}

很简单,就是不换行带颜色输出 "[ OK ]" 字样。

[root@arm64v8 ~]# . /etc/init.d/functions 
[root@arm64v8 ~]# success
[root@arm64v8 ~]#                                          [  OK  ]
[root@arm64v8 ~]# echo_success 
[root@arm64v8 ~]#                                          [  OK  ]
[root@arm64v8 ~]#

同理,剩余的几个状态显示函数也一样。

[root@arm64v8 ~]# . /etc/init.d/functions 
[root@arm64v8 ~]# failure
[root@arm64v8 ~]#                                          [FAILED]
[root@arm64v8 ~]# echo_failure
[root@arm64v8 ~]#                                          [FAILED]
[root@arm64v8 ~]#

B14.1.2、action 函数

这个函数在写脚本时还比较有用,可以根据退出状态码自动判断是执行 success 还是执行 failure 函数。

action 函数定义语句如下:

action() {
  local STRING rc

  STRING=$1
  echo -n "$STRING "
  shift
  "$@" && success $"$STRING" || failure $"$STRING"    # $"string"和"string"没有区别。详细内容可参考本文的评论区
  rc=$?
  echo
  return $rc
}

这个函数定义的很有技巧。先将第一个参数保存并踢掉,再执行后面的命令( "$@" 表示执行后面的命令)。所以,当 action 函数只有一个参数时,action 直接返回 OK,状态码为 0,当超过一个参数时,第一个参数先被打印,再执行从第二个参数开始的命令。

[root@arm64v8 ~]# . /etc/init.d/functions 
[root@arm64v8 ~]# action
                                                           [  OK  ]
[root@arm64v8 ~]# action 8
8                                                          [  OK  ]
[root@arm64v8 ~]# action sleeping sleep 3
sleeping                                                   [  OK  ]
[root@arm64v8 ~]#
[root@arm64v8 ~]# action "try to fail" bash noshell.sh
try to fail bash: noshell.sh: No such file or directory
                                                           [FAILED]
[root@arm64v8 ~]#

所以,在脚本中使用 action 函数时,可以让命令执行成功与否的判断显得更 "专业"。算是一个比较有趣的函数。

通常,该函数会结合 /bin/true 和 /bin/false 命令使用,它们无条件返回 0 和 1 状态码。

[root@arm64v8 ~]# . /etc/init.d/functions 
[root@arm64v8 ~]# 
[root@arm64v8 ~]# action $"MESSAGES: " /bin/true
MESSAGES:                                                  [  OK  ]
[root@arm64v8 ~]# action $"MESSAGES: " /bin/false
MESSAGES:                                                  [FAILED]
[root@arm64v8 ~]#

例如,mysqld 启动脚本中,判断 mysqld 已在运行时,直接输出启动 ok 的消息。(但实际上根本没做任何事)

if [ $MYSQLDRUNNING = 1 ] && [ $? = 0 ]; then
    # already running, do nothing
    action $"Starting $prog: " /bin/true
    ret=0

B14.1.3、is_true 和 is_false函数

这两个函数的作用是转换输入的布尔值为状态码。

is_true() {
    case "$1" in
    [tT] | [yY] | [yY][eE][sS] | [tT][rR][uU][eE])
    return 0
    ;;
    esac
    return 1
}

is_false() {
    case "$1" in
    [fF] | [nN] | [nN][oO] | [fF][aA][lL][sS][eE])
    return 0
    ;;
    esac
    return 1
}

当 is_true 函数的第一个参数(后面的参数会忽略掉)为忽略大小写的 t、y、yes 或 true 时,返回状态码 0,否则返回 1。

当 is_false 函数的第一个参数(后面的参数会忽略掉)为忽略大小写的 f、n、no 或 false 时,返回状态码 0,否则返回 1。

B14.1.4、confirm 函数

这个函数一般用不上,因为脚本本来就是为了避免交互式的。在 arm64 CentOS 7+ 的 functions 中已经删除了该函数定义语句。不过,借鉴下它的处理方法还是不错的。

以下摘自 x86 CentOS 6.4 的 /etc/init.d/functions 文件。

# returns OK if $1 contains $2
strstr() {
  [ "${1#*$2*}" = "$1" ] && return 1   # 参数$1中不包含$2时,返回1,否则返回0
  return 0
}

# Confirm whether we really want to run this service
confirm() {
  [ -x /bin/plymouth ] && /bin/plymouth --hide-splash
  while : ; do 
      echo -n $"Start service $1 (Y)es/(N)o/(C)ontinue? [Y] "
      read answer
      if strstr $"yY" "$answer" || [ "$answer" = "" ] ; then
         return 0
      elif strstr $"cC" "$answer" ; then
     rm -f /var/run/confirm
     [ -x /bin/plymouth ] && /bin/plymouth --show-splash
         return 2
      elif strstr $"nN" "$answer" ; then
         return 1
      fi
  done
}
  • 第一个函数 strstr 的作用是判断第一个参数 "$1" 中是否包含了 "$2",如果包含了则返回状态码 0。这函数也是一个不错的技巧。
  • 第二个函数 confirm 的作用是根据交互式输入的值返回不同的状态码,如果输入的是 y 或 Y 或不输入时,返回 0。输入的是 c 或 C 时,返回状态码 2,输入的是 n 或 N 时返回状态码 1。
  • 于是可以根据 confirm 的状态值决定是否要继续执行某个程序。
[root@x86c64 ~]# . /etc/init.d/functions 
[root@x86c64 ~]# 
[root@x86c64 ~]# confirm 
Start service  (Y)es/(N)o/(C)ontinue? [Y] y
[root@x86c64 ~]# echo $?
0
[root@x86c64 ~]# confirm 
Start service  (Y)es/(N)o/(C)ontinue? [Y] 
[root@x86c64 ~]# echo $?
0
[root@x86c64 ~]# confirm 
Start service  (Y)es/(N)o/(C)ontinue? [Y] n
[root@x86c64 ~]# echo $?
1
[root@x86c64 ~]# confirm 
Start service  (Y)es/(N)o/(C)ontinue? [Y] c
[root@x86c64 ~]# echo $?
2
[root@x86c64 ~]#

B14.1.5、pid 检测相关函数

启动进程时,pid 文件非常重要。不仅可以通过它判断进程是否在运行,还可以从中读取 pid 号用来杀进程。

checkpid、__pids_var_run 和 __pids_pidof 函数

  1. pid 文件的路径可能为 /var/run/$base.pid 文件(​ $base 表示进程名的 basename),也可能是自定义的路径,例如 mysql 的 pid 可以自定义为 /mysql/data/mysql01.pid。但无论哪种情况,functions 中的 __pids_var_run 函数都可以处理。
  2. pid 文件中可能有多行,表示多实例。
  3. 每个进程都必有一个 pid,但并不一定都记录在 pid 文件中,例如线程的 pid。但无论如何,在 /proc/ 目录下,一定会有 pid 号命名的目录,只要有对应 pid 号的目录,就表示该进程已经在运行。函数 checkpid 专门检测给定的 pid 值在 /proc 下是否有对应的目录存在。
  4. 为了获取进程名的 pid 值,此处函数 __pids_pidof 使用的是 pidof 命令。该命令专门设计用来在脚本中取给定进程的 pid。它的 "-o" 选项用于忽略某些进程号,在脚本中应用时常被忽略的是调用 pidof 的 shell 的 PID,当前 shell 的 PID 以及父 shell 的 pid。总之,该函数的目的就是为了获取合理无误的进程 pid。

以下是函数 checkpid__pids_var_run__pids_pidof 的定义语句。

# Check if any of $pid (could be plural) are running
checkpid() {
    local i

    for i in $* ; do                                                    # 检测/proc目录下是否存在给定的进程目录
        [ -d "/proc/$i" ] && return 0
    done
    return 1
}

# __proc_pids {program} [pidfile]
# Set $pid to pids from /var/run* for {program}.  $pid should be declared
# local in the caller.
# Returns LSB exit code for the 'status' action.
__pids_var_run() {                                                      # 通过检测pid判断程序是否已在运行
    local base=${1##*/}                                                 # 获取进程名的basename
    local pid_file=${2:-/var/run/$base.pid}                             # 定义pid文件路径

    pid=
    if [ -f "$pid_file" ] ; then                                      # 给定的pid文件是否存在
            local line p

        [ ! -r "$pid_file" ] && return 4                                  # "user had insufficient privilege"
        while : ; do                                                    # 将pid文件中的pid值(可能有多行)赋值给pid变量
            read line
            [ -z "$line" ] && break
            for p in $line ; do
                [ -z "${p//[0-9]/}" ] && [ -d "/proc/$p" ] && pid="$pid $p"
            done
        done < "$pid_file"

            if [ -n "$pid" ]; then                                    # pid存在,则返回0。否则表示pid文件存在,但/proc下没有对应命令
                    return 0                                            # 即进程已死,但pid文件却存在,返回状态码1。
            fi
        return 1 # "Program is dead and /var/run pid file exists"
    fi
    return 3 # "Program is not running"                                   # pid文件不存在时,表示进程未运行,返回状态码3
}

# Output PIDs of matching processes, found using pidof
__pids_pidof() {                                                        # 下面的pidof命令的意义见稍后解释
    pidof -c -m -o $$ -o $PPID -o %PPID -x "$1" || \                      # 忽略当前shell的PID,父shell的pid和
                                                                        # 调用pidof程序的shell的pid
        pidof -c -m -o $$ -o $PPID -o %PPID -x "${1##*/}"             # 总之就是找出合理的pid
}

__pidsvar_run 函数的定义语句中可以了解到,只有当 pid 文件存在,且 /proc 下有 pid 对应的目录时,才表示进程在运行(当然,线程没有pid文件)。__pids_var_run 函数调用方法:

__pids_var_run program [pidfile]

如果不给定 pidfile,则默认为 /var/run/$base.pid 文件。函数的执行结果为 4 种状态码:

  • 0:program 正在运行。
  • 1:program 进程已死。pid 文件存在,但 /proc 目录下没有对应的文件。
  • 3:pid 文件不存在。
  • 4:pid 文件的权限错误,不可读。

除了返回状态码,__pids_var_run 函数还会保存变量 pid 的结果,以供其他程序引用。

__pids_pidof 中使用了 pidof 命令,其中使用了几个 "-o" 选项,它用于忽略指定的 pid。但看上去 "$$""$PPID""%PPID" 不是很好理解。"-o $$" 是忽略的 shell 进程,大多数时候它会继承父 shell 的 pid,但在脚本中时它代表的是脚本所在 shell 的 pid。"-o $PPID" 忽略的是父 shell。"-o %PPID" 忽略的是调用 pidof 命令的 shell。不是很好理解,可以参考下面的测试语句。

测试脚本:

#!/bin/bash

echo 'pidof bash: '`pidof bash`
echo 'script shell pid: '`echo $$`
echo 'script parent shell pid: '`echo $PPID`
echo 'pidof -o $$ bash: '`pidof -o $$ bash`
echo 'pidof -o $PPID bash: '`pidof -o $PPID bash`
echo 'pidof -o %PPID bash: '`pidof -o %PPID bash`
echo 'pidof -o $$ -o $PPID -o %PPID bash: '`pidof -o $$ -o $PPID -o %PPID bash`

测试语句:

[root@arm64v8 ~]# pidof bash
1533
[root@arm64v8 ~]# (echo 'parent shell: '$$;echo "current bash pid: `pidof bash`";./pid.sh)|cat -n
     1  parent shell: 1533
     2  current bash pid: 2165 1533
     3  pidof bash: 2168 2165 1533
     4  script shell pid: 2168
     5  script parent shell pid: 2165
     6  pidof -o $$ bash: 2165 1533
     7  pidof -o $PPID bash: 2168 1533
     8  pidof -o %PPID bash: 2165 1533
     9  pidof -o $$ -o $PPID -o %PPID bash: 1533
[root@arm64v8 ~]#
  • 第一个 pidof 命令:说明当前已有 1 个 bash,pid 为:1533
  • 第二个命令:
    • 行 1 说明括号的父 shell 为 1533。
    • 行 5 说明脚本的父 shell 为 2165。即括号的父 shell 为当前 bash 环境,脚本的父 shell 为括号所在 shell。
    • 行 2 说明括号所在子 shell 的 pid 为 2165。
    • 行 3 说明 shell 脚本所在子 shell 的 pid 为 2168。
    • "-o $$" 忽略的是当前 shell,即脚本所在 shell 的 pid,因为在 shell 脚本中时,$$ 不继承父 shell 的 pid。
    • "-o $PPID" 忽略的是 pidof 所在父 shell,即括号所在 shell。
    • "-o %PPID" 忽略的是调用调用 pidof 程序所在的 shell,即脚本所在 shell。

pidfileofproc 和 pidofproc函数

除了以上 3 个 pid 相关函数,functions 文件中,还提供了两个函数 pidfileofprocpidofproc,均用于获取给定程序的 pid 值。

以下是 pidfileofproc 函数的定义语句。注意,该函数不是获取 pidfile,而是获取 pid 值。

# A function to find the pid of a program. Looks *only* at the pidfile
pidfileofproc() {
    local pid

    # Test syntax.
    if [ "$#" = 0 ] ; then
        echo $"Usage: pidfileofproc {program}"
        return 1
    fi

    __pids_var_run "$1"          # 不提供pidfile,因此认为是/var/run/$base.pid
    [ -n "$pid" ] && echo $pid
    return 0
}

因此,pidfileofproc 函数只能获取 /var/run 下的 pid。

以下是 pidofproc 函数的定义语句:

# A function to find the pid of a program.
pidofproc() {
    local RC pid pid_file=

    # Test syntax.
    if [ "$#" = 0 ]; then
        echo $"Usage: pidofproc [-p pidfile] {program}"
        return 1
    fi
    if [ "$1" = "-p" ]; then            # 既可以获取/var/run/$base.pid中的pid,
        pid_file=$2                     # 也可以获取自给定pid文件中的pid
        shift 2
    fi
    fail_code=3 # "Program is not running"

    # First try "/var/run/*.pid" files
    __pids_var_run "$1" "$pid_file"
    RC=$?
    if [ -n "$pid" ]; then                # $pid不为空时,输出program的pid值
        echo $pid
        return 0
    fi

    [ -n "$pid_file" ] && return $RC      # $pid为空,但使用了"-p"指定pidfile时,返回$RC。
    __pids_pidof "$1" || return $RC       # $pid为空,且$pidfile为空时,获取进程号pid并输出
}

这两个函数的区别在于 pidfileofproc 只能搜索 /var/run 下的 pid,而 pidofproc 可以搜索自给定的 pidfile 或 /var/run/ 下的 pid。而前面的 __pids_pidof 函数,只有在获取 bash 进程时更精确(因为它会忽略父 shell 进程)。

这两个函数用的比较少,但确实有使用它的脚本。如 crond 启动脚本中借助 pidfileofproc 来杀进程:

echo -n $"Stopping $prog: "
if [ -n "`pidfileofproc $exec`" ]; then
        killproc $exec
        RETVAL=3
else
        failure $"Stopping $prog"
fi

dnsbind 的 named 服务启动脚本中借助 pidofproc 来判断进程是否已在运行。

pidofnamed() {
        pidofproc -p "$ROOTDIR$PIDFILE" "$named";
}

if [ -n "`pidofnamed`" ]; then
  echo -n $"named: already running"
  success
  echo
  exit 0;
fi;

daemon 函数

daemon 函数用于启动一个程序,并根据结果输出 success 或 failure。

定义语句如下:

# A function to start a program.
daemon() {
    # Test syntax.
    local gotbase= force= nicelevel corelimit       # 定义一大堆变量
    local pid base= user= nice= bg= pid_file=
    local cgroup=
    nicelevel=0
    while [ "$1" != "${1##[-+]}" ]; do              # 当参数$1以"-"或"+"开头时进入循环,但$1为空时也满足
      case $1 in
        '')    echo $"$0: Usage: daemon [+/-nicelevel] {program}" "[arg1]..."
               return 1;;
        --check)                                    # daemon接受"--arg value"和"--arg=value"两种格式的参数
           base=$2
           gotbase="yes"
           shift 2
           ;;
        --check=?*)
               base=${1#--check=}
           gotbase="yes"
           shift
           ;;
        --user)
           user=$2
           shift 2
           ;;
        --user=?*)
               user=${1#--user=}
           shift
           ;;
        --pidfile)
           pid_file=$2
           shift 2
           ;;
        --pidfile=?*)
           pid_file=${1#--pidfile=}
           shift
           ;;
        --force)
               force="force"
           shift
           ;;
        [-+][0-9]*)
               nice="nice -n $1"
               shift
           ;;
        *)     echo $"$0: Usage: daemon [+/-nicelevel] {program}" "[arg1]..."
               return 1;;
      esac
    done

        # Save basename.
        [ -z "$gotbase" ] && base=${1##*/}                # 若未传递"--check",则此处获取bashname

        # See if it's already running. Look *only* at the pid file.
    __pids_var_run "$base" "$pid_file"

    [ -n "$pid" -a -z "$force" ] && return                  # 如进程已在运行(已检测出pid),且没有使用force
                                                            # 强制启动,则退出daemon函数

    # make sure it doesn't core dump anywhere unless requested   
    corelimit="ulimit -S -c ${DAEMON_COREFILE_LIMIT:-0}"      # corelimit、cgroup和资源控制有关,忽略它

    # if they set NICELEVEL in /etc/sysconfig/foo, honor it
    [ -n "${NICELEVEL:-}" ] && nice="nice -n $NICELEVEL"

    # if they set CGROUP_DAEMON in /etc/sysconfig/foo, honor it
    if [ -n "${CGROUP_DAEMON}" ]; then
        if [ ! -x /bin/cgexec ]; then
            echo -n "Cgroups not installed"; warning
            echo
        else
            cgroup="/bin/cgexec";
            for i in $CGROUP_DAEMON; do
                cgroup="$cgroup -g $i";
            done
        fi
    fi

    # Echo daemon
        [ "${BOOTUP:-}" = "verbose" -a -z "${LSB:-}" ] && echo -n " $base"

    # And start it up.                                      # 启动程序。runuser的"-s"指定执行程序的shell,$user指定运行的身份
                                                            # "$*"是剔除掉daemon选项后程序的启动指令。
    if [ -z "$user" ]; then
       $cgroup $nice /bin/bash -c "$corelimit >/dev/null 2>&1 ; $*"
    else
       $cgroup $nice runuser -s /bin/bash $user -c "$corelimit >/dev/null 2>&1 ; $*"
    fi

    [ "$?" -eq 0 ] && success $"$base startup" || failure $"$base startup"
}

daemon 函数调用方法:

daemon [--check=servicename] [--user=USER] [--pidfile=PIDFILE] [--force] program [prog_args]

需要注意的是:

  1. 只有 "--user" 可以用来控制 program 启动的环境。
  2. "--check" 和 "--pidfile" 都是用来检查是否已运行的,不是用来启动的,如果提供了 "--check",则检查的是名为 servicename 的进程,否则检查的是 program 名称的进程。
  3. "--force" 则表示进程已存在时仍启动。
  4. prog_args 是向 program 传递它的运行参数,一般会从 /etc/sysconfig/$base 文件中获取。

例如 httpd 的启动脚本中:

echo -n $"Starting $prog: "
daemon --pidfile=${pidfile} $httpd $OPTIONS

还需注意,通常 program 的运行参数可能也是 "--" 开头的,要和 program 前面的选项区分。例如:

daemon --pidfile $pidfile --check $servicename $processname --pid-file=$pidfile
  • 第二个 "--pid-file""$processname" 的运行参数,第一个 "--pidfile" 是 daemon 检测 "$processname" 是否已运行的选项。
  • 由于提供了 "--check $servicename",所以函数调用语句 __pids_var_run $base [pidfile] 中的 $base 等于 $servicename,即表示检查 $servicename 进程是否允许。
  • 如果没有提供该选项,则检查的是 $processname
  • 至此,daemon 函数已经分析完成。实际上很简单,就是为 daemon 提供几个选项,再提供要执行的命令,并为该命令提供启动参数。

killproc 函数

killproc 函数的作用是根据给定程序名杀进程。中间它会获取程序名对应的 pid 号,且保证 /proc 目录下没有 pid 对应的目录才表示进程关闭成功。

# A function to stop a program.
killproc() {
    local RC killlevel= base pid pid_file= delay try

    RC=0; delay=3; try=0
    # Test syntax.
    if [ "$#" -eq 0 ]; then
        echo $"Usage: killproc [-p pidfile] [ -d delay] {program} [-signal]"
        return 1
    fi
    if [ "$1" = "-p" ]; then  # 指定pid_file。不给"-p"时,"__pids_var_run"将检查/var/run下的文件
        pid_file=$2
        shift 2
    fi
    if [ "$1" = "-d" ]; then  # awk的多目运算符。delay的有效值单位为d(天)、时(h)、分(m)、秒(s)。
                              # 不写单位时默认为秒。该语句将所给时间转换成秒,接受小数,做四舍五入计算
        delay=$(echo $2 | awk -v RS=' ' -v IGNORECASE=1 '{if($1!~/^[0-9.]+[smhd]?$/) exit 1;d=$1~/s$|^[0-9.]*$/?1:$1~/m$/?60:$1~/h$/?60*60:$1~/d$/?24*60*60:-1;if(d==-1) exit 1;delay+=d*$1} END {printf("%d",delay+0.5)}')
        if [ "$?" -eq 1 ]; then
            echo $"Usage: killproc [-p pidfile] [ -d delay] {program} [-signal]
            return 1
        fi
        shift 2
    fi

    # check for second arg to be kill level
    [ -n "${2:-}" ] && killlevel=$2               # 获取稍后的kill程序将要发送的信号

        # Save basename.
        base=${1##*/}

    # Find pid.                                     # 获取program的pid号,以让kill程序杀掉
    __pids_var_run "$1" "$pid_file"                 # 检查program是否已有对应pid文件,并返回pidfile中所有pid值
    RC=$?
    if [ -z "$pid" ]; then
        if [ -z "$pid_file" ]; then
            pid="$(__pids_pidof "$1")"              # pid为空,且没有pidfile时,获取program的pid
        else
            [ "$RC" = "4" ] && { failure $"$base shutdown" ; return $RC ;}
        fi
    fi

    # Kill it.                                      # 根据pid,杀掉已存在的进程
    if [ -n "$pid" ] ; then                           # 如果进程pid存在,则杀死它
        [ "$BOOTUP" = "verbose" -a -z "${LSB:-}" ] && echo -n "$base "
        if [ -z "$killlevel" ] ; then             # 没有指定要传递的信号时
            if checkpid $pid 2>&1; then              # 给定pid在/proc目录中是否有对应目录
                # TERM first, then KILL if not dead
                kill -TERM $pid >/dev/null 2>&1       # 先发送TERM信号
                usleep 50000
                if checkpid $pid ; then             # 0.5秒后还没死透,则
                    try=0
                    while [ $try -lt $delay ] ; do  # 在给定delay时间内不断检测是否已死
                        checkpid $pid || break
                        sleep 1
                        let try+=1
                    done
                    if checkpid $pid ; then         # 超出delay后,发送KILL信号强制杀死
                        kill -KILL $pid >/dev/null 2>&1
                        usleep 50000
                    fi
                fi
            fi
            checkpid $pid                           # 若/proc下还有pid对应的目录,则进程关闭失败
            RC=$?
            [ "$RC" -eq 0 ] && failure $"$base shutdown" || success $"$base shutdown"
            RC=$((! $RC))
        # use specified level only
        else                                        # 使用指定的信号杀进程
            if checkpid $pid; then
                kill $killlevel $pid >/dev/null 2>&1
                RC=$?
                [ "$RC" -eq 0 ] && success $"$base $killlevel" || failure $"$base $killlevel"
            elif [ -n "${LSB:-}" ]; then
                RC=7 # Program is not running
            fi
        fi
    else                                            # 如果进程pid不存在,表示未运行
        if [ -n "${LSB:-}" -a -n "$killlevel" ]; then
            RC=7 # Program is not running
        else
            failure $"$base shutdown"
            RC=0
        fi
    fi

    # Remove pid file if any.
    if [ -z "$killlevel" ]; then                  # 未给定信号时,可能KILL信号强杀时使得pid文件还存在,手动移除它
            rm -f "${pid_file:-/var/run/$base.pid}"
    fi
    return $RC
}

根据此脚本,可以知道关闭进程时,需要再三确定 pid 文件是否存在,/proc 下是否有和 pid 对应的目录。直到 /proc 下已经没有了和 pid 对应的目录时,才表示进程真正杀死了。但此时 pid 文件仍可能存在,因此还要保证 pid 文件已被移除。

该函数的调用方法:

killproc [-p pidfile] [ -d delay] {program} [-signal]

status 函数

status 函数用于获取进程的运行状态,有以下几种状态:

  • ${base} (pid $pid) is running...
  • ${base} dead but pid file exists
  • ${base} status unknown due to insufficient privileges.
  • ${base} dead but subsys locked
  • ${base} is stopped

以下的 status 函数定义语句。注意,此为 CentOS 7+ 上语句,比 CentOS 6 多了一段 systemctl 的处理,用于 Sysv 的 status 状态向 systemd 的 status 状态转换。

status() {
    local base pid lock_file= pid_file=

    # Test syntax.
    if [ "$#" = 0 ] ; then
        echo $"Usage: status [-p pidfile] {program}"
        return 1
    fi
    if [ "$1" = "-p" ]; then
        pid_file=$2           # 指定pidfile
        shift 2
    fi
    if [ "$1" = "-l" ]; then
        lock_file=$2          # 指定lockfile
        shift 2
    fi
    base=${1##*/}

    if [ "$_use_systemctl" = "1" ]; then
        systemctl status ${0##*/}.service
        ret=$?
        # LSB daemons that dies abnormally in systemd looks alive in 
        # systemd's eyes due to RemainAfterExit=yes
        # lets adjust the reality a little bit
        if systemctl show -p ActiveState ${0##*/}.service | grep -q '=active$' && \
        systemctl show -p SubState ${0##*/}.service | grep -q '=exited$' ; then
            ret=3
        fi
        return $ret
    fi

    # First try "pidof"
    __pids_var_run "$1" "$pid_file"   # 根据给定的pidfile获取program的pid,并返回pid值
    RC=$?
    if [ -z "$pid_file" -a -z "$pid" ]; then   # pid为空,且没有pidfile时,获取program的pid
        pid="$(__pids_pidof "$1")"
    fi
    if [ -n "$pid" ]; then             # pid存在,则返回程序正在运行
        echo $"${base} (pid $pid) is running..."
        return 0
    fi

    case "$RC" in
        0)
            echo $"${base} (pid $pid) is running..."
            return 0
            ;;
        1)               # program进程已死。pid文件存在,但/proc目录下没有对应的文件。
            echo $"${base} dead but pid file exists"
            return 1
            ;;
        4)               # pid文件不可读,错误
            echo $"${base} status unknown due to insufficient privileges."
            return 4
            ;;
    esac
    if [ -z "${lock_file}" ]; then
        lock_file=${base}
    fi
    # See if /var/lock/subsys/${lock_file} exists  
    if [ -f /var/lock/subsys/${lock_file} ]; then   # 检查/var/lock/subsys下是否有lockfile
        echo $"${base} dead but subsys locked"      # pid不存在,但锁文件存在时
        return 2
    fi
    echo $"${base} is stopped"   # 以上都不满足时,表示程序未运行
    return 3
}

函数调用方法:

status [-p pidfile] [-l lockfile] program

由于函数定义原因,如果同时提供 "-p" 和 "-l" 选项,"-l" 选项必须放在 "-p" 的后面。

B14.2、编写 SysV 服务脚本

SysV 服务管理脚本和 /etc/rc.d/init.d/functions 文件中的几个重要函数(包括 daemon,killproc,status 以及几个和 pid 有关的函数)关系密切。

B14.2.1、SysV 脚本的特性

SysV 风格的服务启动脚本有以下几个特性:

  1. 一般都放在 /etc/rc.d/init.d 目录下。
  2. 这类脚本要求能接受 start、stop、restart、status 等参数来管理服务进程。
  3. 基本上都会加载 /etc/rc.d/init.d/functions 文件,因为该文件中定义了几个对进程管理非常有用的函数。
  4. 基本上都会加载 /etc/sysconfig 目录下的同名文件。此目录下的服务同名文件一般都是为服务管理脚本提供选项参数的。例如 /etc/sysconfig/httpd。
  5. 在脚本的顶端,需要加上# chkconfig# description 两行。chkconfig 行定义的是该脚本被 chkconfig 工具管理时的主要依据,包括开机和关机时的启动、关闭顺序,以及运行在哪些运行级别。description 是该脚本的描述性语句。虽然这两行以 "#" 开头,但必不可少。

例如,/etc/init.d/httpd 脚本的前面几行内容如下:

#!/bin/bash
#
# httpd        Startup script for the Apache HTTP Server
#
# chkconfig: - 85 15
# description: The Apache HTTP Server is an efficient and extensible  \
#              server implementing the current HTTP standards.
# processname: httpd
# config: /etc/httpd/conf/httpd.conf
# config: /etc/sysconfig/httpd
# pidfile: /var/run/httpd/httpd.pid
#

# Source function library.
. /etc/rc.d/init.d/functions

if [ -f /etc/sysconfig/httpd ]; then     # 判断后再加载
    . /etc/sysconfig/httpd
fi

B14.2.2、SysV 脚本要具备的能力

要使用脚本管理服务进程,该脚本还要求具备以下能力,且处理逻辑越完善,脚本就越完美。

  1. 启动进程时:
    • 要求能够检测进程是否已在运行。这包括检测 pid 文件是否存在、/proc 目录下是否有进程 pid 值对应的目录。
    • 应该为程序创建锁文件,路径一般在 /var/lock/subsys 目录下。
    • 如果使用 daemon 函数启动进程,允许 "--user" 指定程序的运行身份。
    • 有些进程启动时需要依赖于其他进程,如 NFS 启动时依赖于 rpcbind 服务、mountd 服务等,所以在 NFS 脚本中必须能够检测并启动这些依赖服务。
  2. 关闭进程时:
    • 要求能够检测进程是否已在运行。同样是检测 pid 文件是否存在,/proc 目录下是否有 pid 对应的目录。要注意,只有 /proc 目录下没有了对应目录,才表示进程已死,但 pid 文件仍可能存在,例如 kill -9 就会出现这种问题。
    • 可以使用 functions 文件中的 killproc 函数杀进程,也可以直接使用 kill 或 killall。
    • 为了让脚本更完善,杀进程时应该多次检测进程是否真的已经杀死。
    • 杀死进程的最后,必须要删除 pid 文件和锁文件。
    • 对于有依赖性的服务,考虑是否也应该杀死它们。
  3. 服务重读配置文件时(reload):
    • 对于非终端进程,发送 HUP 信号的作用是重读配置文件,而不会中断进程。
    • 为了标准,应该找出 "master" 进程的 pid,并向其发送 HUP 信号。一般来说,服务的子进程或线程不会也没必要读取配置文件。为了方便,可以直接向所有进程发送 HUP 信号。
    • 最好在发送 HUP 信号前,也检查进程是否已在运行。当然,对于 reload 来说,这无所谓。
    • 如果待管理程序支持配置文件的语法检查,在发送 HUP 信号前,应该检查语法是否错误。
    • 实在无法实现重读配置文件的功能,应该让其和 restart 的功能一致,一般这也是 "force-reload" 的功能。
  4. 重启服务时:
    • 一般来说,就是先 stop,再 start。
  5. 查看 status 时:
    • 除非有额外的状态显示需求,否则 /etc/init.d/functions 中的 status 函数已经足够完美了。

B14.2.3、start 函数分析

以 httpd 的服务管理脚本 /etc/init.d/httpd 为例。

start() {
    echo -n $"Starting $prog: "
    LANG=$HTTPD_LANG daemon --pidfile=${pidfile} $httpd $OPTIONS
    RETVAL=$?
    echo
    [ $RETVAL = 0 ] && touch ${lockfile}
    return $RETVAL
}

函数首先输出 "Starting $prog" 信息,再使用 daemon 启动 "$httpd" 程序。

在 daemon 语句中,"--pidfile" 是 daemon 的参数,该参数为 daemon 检测 pid 文件是否存在,"$httpd" 进程是否已在运行。注意,这个 "--pidfile" 是写在 "$httpd" 前面的,表示这是 daemon 的参数,而非 "$httpd" 的启动参数。

检测完成后,启动程序。程序的启动命令从 "$httpd" 参数开始,"$OPTIONS""$httpd" 的启动选项。一般出现 "$OPTIONS" 这个字眼,很可能加载了 /etc/sysconfig 目录下的同名文件,目的是提供程序启动参数。

如果启动成功,则会 daemon 函数会调用 functions 中的 success 函数显示 "[ OK ]",否则会显示 "[ FAILED ]"。

最后,如果启动成功,则会创建该进程的锁文件 "$lockfile"。锁文件一般都在 /var/lock/subsys 目录下。

很多时候,管理的进程也有 "--pidfile" 类似的选项。例如下面的启动语句:

daemon --pidfile $pidfile $processname --pidfile=$pidfile

两个 "--pidfile" 选项,但他们的作用是不一样的。第一个 "--pidfile" 是 daemon 函数的参数,以便 daemon 能够检测该文件中的 pid 进程是否已在运行。第二个 "--pidfile" 是 "$processname" 的启动参数,启动时会创建此文件作为 pid 文件。

再看一个不使用 daemon 函数管理进程启动动作的示例。以下是 /etc/init.d/sshd 中的 start 函数内容。

start()
{
        [ -x $SSHD ] || exit 5
        [ -f /etc/ssh/sshd_config ] || exit 6
        # Create keys if necessary
        if [ "x${AUTOCREATE_SERVER_KEYS}" != xNO ]; then
                do_rsa_keygen
                if [ "x${AUTOCREATE_SERVER_KEYS}" != xRSAONLY ]; then
                        do_rsa1_keygen
                        do_dsa_keygen
                fi
        fi

        echo -n $"Starting $prog: "
        $SSHD $OPTIONS && success || failure
        RETVAL=$?
        [ $RETVAL -eq 0 ] && touch $lockfile
        echo
        return $RETVAL
}

前面多了一大段,这和服务启动脚本的框架无关,是程序自身要求的,但作用很简单。无非就是判断下程序是否可执行,配置文件是否存在,是否要创建服务端主机验证阶段的密钥对,也就是 /etc/ssh/ssh_host_{rsa,dsa}_key 等几个文件。

再下面才是服务启动脚本中的通用逻辑部分。输出一段信息,然后启动程序,创建锁文件。但这里没有使用 daemon 函数管理,所以这里配合了 success 和 failure 函数以便人性化显示 "[ OK ]" 或 "[ FAILED ]"。

B14.2.4、stop 函数分析

仍然以 /etc/init.d/httpd 中的 stop 函数为例。

# When stopping httpd, a delay (of default 10 second) is required
# before SIGKILLing the httpd parent; this gives enough time for the
# httpd parent to SIGKILL any errant children.
stop() {
    status -p ${pidfile} $httpd > /dev/null
    if [[ $? = 0 ]]; then
        echo -n $"Stopping $prog: "
        killproc -p ${pidfile} -d ${STOP_TIMEOUT} $httpd
    else
        echo -n $"Stopping $prog: "
        success
    fi
    RETVAL=$?
    echo
    [ $RETVAL = 0 ] && rm -f ${lockfile} ${pidfile}
}

首先使用 "status" 函数检查进程的状态,如果进程已在运行,则使用 killproc 函数杀掉它,否则表示进程未运行或进程已死,但 pid 文件还存在。所以,在最后删掉 pidfile 和 lockfile。

需要注意的是,killproc 杀进程时,能保证 pidfile 同时被删除。但它不负责 lockfile,而且执行 stop 之前曾手动执行了 "kill -9" 杀进程,那么进程虽然已死,但 pid 文件却存在。因此也仍需手动 rm 删除 pidfile。

killproc 的调用方法为:

killproc [-p $pidfile] -[d $delay] $processname [-signal]

它的逻辑和执行过程是这样的:

  1. 根据 pidfile 找出要杀的 pid,如果没有指定 pidfile,则默认从 /var/run/$base.pid 读取;
  2. 如果指定了要发送的信号,则 killproc 通过 kill 命令发送给定信号。0.5 秒后检查 /proc 目录下是否还有对应目录存在,有则说明进程杀死失败,返回 "[ FAILED ]" 信息,否则表示成功,于是删除 pid 文件。
  3. 如果没有指定要发送的信号,则 killproc 先发送 TERM 信号( 即 kill -15 ),然后在给定的延迟时间 delay 内,每隔一秒检查一次 /proc 下是否有对应目录,如果发现没有,则表示进程杀死成功,于是删除 pid 文件(其实这种情况不用删,因为 TERM 信号会自动做收尾动作)。但如果 delay 都超时了,还发现进程存在,则发送 KILL 信号强制杀死进程,最后删除 pid 文件。

现在再理解 killproc -p ${pidfile} -d ${STOP_TIMEOUT} $httpd 就很简单了。

再看 /etc/init.d/sshd 脚本中的 stop。

stop()
{
    echo -n $"Stopping $prog: "
    killproc -p $PID_FILE $SSHD
    RETVAL=$?
    # if we are in halt or reboot runlevel kill all running sessions
    # so the TCP connections are closed cleanly
    if [ "x$runlevel" = x0 -o "x$runlevel" = x6 ] ; then
        trap '' TERM
        killall $prog 2>/dev/null
        trap TERM
    fi
    [ $RETVAL -eq 0 ] && rm -f $lockfile
    echo
}

更直接,直接就 killproc。但是后面还设置了 runlevel 的判断情况,这就属于程序自身属性了,和服务管理脚本的逻辑框架无关。

最后再看 mysqld 中的 stop 函数。

stop(){
    if [ ! -f "$mypidfile" ]; then
        # not running; per LSB standards this is "ok"
        action $"Stopping $prog: " /bin/true      # pid文件都不存在,直接显示成功
        return 0
    fi
    MYSQLPID=`cat "$mypidfile" 2>/dev/null`       # 读取pidfile中的pid号
    if [ -n "$MYSQLPID" ]; then                   # 如果pid不为空,则
        /bin/kill "$MYSQLPID" >/dev/null 2>&1     # 先发送默认的TERM信号杀一次
        ret=$?
        if [ $ret -eq 0 ]; then             # 如果杀成功了,则执行下面一段。
                                            # 否则直接失败,但这不可能。为了逻辑完整,后面仍写了else
            TIMEOUT="$STOPTIMEOUT"
            while [ $TIMEOUT -gt 0 ]; do    # 在延迟时间内,每隔1秒杀一次
                /bin/kill -0 "$MYSQLPID" >/dev/null 2>&1 || break
                sleep 1
                let TIMEOUT=${TIMEOUT}-1
            done
            if [ $TIMEOUT -eq 0 ]; then     # 如果达到延迟时间边界,则返回杀死进程超时信息
                echo "Timeout error occurred trying to stop MySQL Daemon."
                ret=1
                action $"Stopping $prog: " /bin/false
            else                            # 否则进程杀死成功,删除pidfile和lockfile
                rm -f $lockfile
                rm -f "$socketfile"
                action $"Stopping $prog: " /bin/true
            fi
        else
            action $"Stopping $prog: " /bin/false
        fi
    else                                    # 如果pid为空,则表示未成功读取pidfile。
        # failed to read pidfile, probably insufficient permissions
        action $"Stopping $prog: " /bin/false
        ret=4
    fi
    return $ret
}

B14.2.5、reload 函数分析

关于 reload 函数,主要有两点:

  1. 语法检查;
  2. 发送 HUP 信号给 "master" 进程。其中语法检查要程序自身能支持,例如 httpd -tnginx -t

以下是 /etc/init.d/{httpd,nginx} 两个脚本中的 reload 函数。

## reload() in /etc/rc.d/init.d/httpd
reload() {
    echo -n $"Reloading $prog: "
    if ! LANG=$HTTPD_LANG $httpd $OPTIONS -t >&/dev/null; then  # 语法检查
        RETVAL=6
        echo $"not reloading due to configuration syntax error"
        failure $"not reloading $httpd due to configuration syntax error"
    else
        # Force LSB behaviour from killproc        # 语法检查通过,发送HUP信号
        LSB=1 killproc -p ${pidfile} $httpd -HUP
        RETVAL=$?
        if [ $RETVAL -eq 7 ]; then             # 注意reload失败时退出状态码为7
            failure $"httpd shutdown"
        fi
    fi
    echo
}

## reload() in /etc/rc.d/init.d/nginx
reload() {
    configtest_q || return 6           # 语法检查
    echo -n $"Reloading $prog: "
    killproc -p $pidfile $prog -HUP    # 发送HUP信号
    echo
}

configtest_q() {
    $nginx -t -q -c $NGINX_CONF_FILE
}

case "$1" in
    reload)
        rh_status_q || exit 7        # reload失败时,退出状态码7
        $1
        ;;

唯一需要注意的是,reload 失败时,退出状态码为 7。这大概已经约定俗成了吧。

再看 /etc/init.d/sshd 中的 reload。

reload()
{
    echo -n $"Reloading $prog: "
    killproc -p $PID_FILE $SSHD -HUP
    RETVAL=$?
    echo
}

case "$1" in
    reload)
        rh_status_q || exit 7
        reload
        ;;

有意思的是 mysqld 的 reload。它直接退出不做任何动作。

case "$1" in
  reload)
    exit 3
    ;;

如果不使用 killproc 函数,而是使用 kill 命令,那么应该找出 "master" pid。可以使用 functions 中的 pidofproc 函数。例如:

pid=$(pidofprco -p pidfile $processname)
action "Reloading $prog: " kill -HUP $pid

B14.2.6、status、restart、force-reload 等

  • status:就是为了获取进程状态的,一般直接调用 functions 中的 status 函数 status -p "$pidfile" $prog
  • restart:一般直接 stop 再 start 即可。
  • force-reload:其实就是 restart。
  • condrestart:称为条件式重启。所谓的条件一般是判断锁文件是否存在,存在则重启,否则忽略该动作。"try-restart" 也是一样的行为。
标签云