0%

Shell 脚本编程

“不能逃避,不能逃避,不能逃避。”——碇真嗣

Shell 脚本创建和执行

指定解释器

可以使用任何文本编辑器编辑 shell 脚本,并且可以赋予其任何后缀(在命令行中后缀没有意义)。

命令行中文件后缀没有意义的原因非常简单:在命令行模式中,我们通过命令或指定解释器的方式给定了解释和执行这段脚本的软件;相对的,在图形化界面中,我们常常双击文件打开或执行。这时候才需要后缀帮助系统判断打开或执行所使用的软件

在脚本的第一行一般使用#!标记指定使用的解释器,例如#! /bin/bash

运行 shell 脚本

运行 shell 脚本有两种方式:

  • 作为可执行程序,使用./file的方式执行
    • 需要赋予执行权限
  • 作为解释器参数/bin/bash file

注释

shell 提供了两种注释方法:第一种是#,能够做到单行注释;第二种是:<<,可以多行注释。多行注释的方法是:

1
2
3
4
5
:<<zelda
注释内容...
注释内容...
注释内容...
zelda

其中 zelda 可以用任何自定义的字符串代替。

文本流和重定向

  • 重定向不是命令
  • 任何程序都带有默认 IO
    • 0:标准输入
    • 1:标准输出
    • 2:错误输出
  • IO 流指向虚拟终端,cd /proc/$$/fd可以查看(/dev/pts/0,1,2);通过修改指向的虚拟终端可以做到一个 bash 输入,另一个 bash 输出
  • 输出重定向:修改程序的 1 或 2(左边与文件描述符之间不能有空格)
    • >:覆盖重定向
    • >>:追加重定向
    • &>,&>:同时重定向 1 和 2(两个符号中间不能有空格)
  • 输入重定向:修改程序的 0
    • <:文件
    • <<:输入多行(首先输入一个自定义的字符串作为识别标记,按下回车后开始输入多行。输入完成后再次输入前面的识别标记)
      • 一个简单的例子:在脚本中要用一个 echo 打印多行文本,需要使用一个外部文件,安全性较低;使用多个 echo 不方便调整格式;cat 加多行重定向可以解决这些问题,同时容易调整格式
    • <<<:字符串(双引号)
  • read 命令对换行符敏感,可以读取标准输入
  • exec 命令在现有进程上运行一个可执行文件,替换原有的可执行文件同时不改变进程号
    • exec 可以用来外挂重定向
  • 全重定向的 socket 案例(获取百度主页)
1
2
3
exec 8<> /dev/tcp/www.baidu.com/80
echo -e "GET / HTTP/1.0\n" >& 8
cat <& 8

Shell 变量

变量命名

Shell 定义变量的格式非常简单,例如a="hello, world"。需要注意的是,shell 变量等号左右不能有空格。

这是因为 shell 根据空格切分指令,会将变量定义理解为某个不存在的指令

Shell 变量命名遵循一些熟悉的规则:

  • 可以使用英文字母、数字和下划线
  • 不能使用数字开头
  • 不能包含空格
  • 不能使用标点
  • 不能使用 bash 关键字

使用变量

使用定义过的变量,只需要在变量名前加 $:

1
2
3
line="hello world"
echo $line
echo ${line}

$ 符号后的花括号可以帮助解释器识别变量边界,例如:

1
echo "I am good at ${skill}Script"

变量可以重新定义。重新定义时不需要使用 $ 符号,只有在使用变量时才使用 $ 符号。

与变量使用相关的一些命令:

命令 作用
readonly 将变量定义为只读
unset 删除变量
set 查看当前 shell 变量
export 设置环境变量

写时复制

环境变量在父子进程中是不共享的,即:在父进程中 export 的环境变量,随着子进程的开启,父子进程中对其进行的修改不会影响对方进程下的相应环境变量。这带来了潜在的性能问题:如果父进程有大量的环境变量,子进程开启时需要复制同样数量的环境变量,会占用大量的系统资源。

Linux 引入了写时复制的机制来规避这一问题,具体的实现通过fork()函数:系统拥有两套内存空间,一套为真实的物理内存,一套为虚拟内存。系统为进程开辟连续的虚拟内存空间,但是它们实际指向的物理内存位置可能是不连续的。当需要开辟子进程时,系统首先为其开辟虚拟内存空间,同时将空间上的内容指向父进程对应内容的物理内存地址(类似于引用)。只有当父进程变量发生改变时,才为子进程开辟物理地址复制变量值。这一过程通过开辟新的物理内存地址并修改虚拟内存指针实现。

对于 Java 虚拟机而言,真实的内存地址要经过两次翻译:JVM、系统、内存。

变量类型

Shell 中的变量分为本地、局部、环境变量,另外这里还介绍一些特殊变量:

  • 本地:当前 shell 拥有,生命周期跟随当前 shell
  • 局部:使用 local 关键字,创建一个只在函数局部有效的变量
    • 和我们熟悉的编程语言如 Python 不同,函数外存在某一个变量的情况下,函数内使用相同的变量名不会新建一个局部变量
  • 环境:所有的程序,包括 shell 启动的程序,都能访问环境变量
  • 特殊:
    • $n:第 n 个参数,例如 $1,$2,${11}
    • $@,$*:参数列表
    • $#:参数个数
    • $$:当前 shell 的 PID,优先级高于管道
    • $BASHPID:同上,优先级低于管道
    • $?:上一个命令的退出状态
      • 0:成功
      • 其它:失败

引用

  • shell 中的字符串可以使用单引号、双引号,甚至不用引号
  • 单引号是强引用,里面的任何字符都会原样输出,因此单引号内不能使用变量
  • 双引号是弱引用,支持参数扩展(执行时先将变量扩展为变量的值);
  • 花括号扩展不能被引用
  • 命令替换(反引号)可以将命令的结果作为变量的赋值

数组和字符串

bash 仅支持一维数组,定义数组的方式是使用括号并以空白符(回车也可以!)隔开数组元素:a=(a1 a2 a3)

  • 获取数组元素:${数组名[下标]}(序号从 1 开始)
  • 获取数组所有元素:${数组名[@]}
  • 获取数组长度(即元素个数,这个方法对字符串也有用):${ #数组名[@]}(@ 可以替换为 *)
  • 获取某个元素长度:${ #数组名[下标]}

上面 { 和 # 不应该有空格。这里是为了规避和 nunjucks syntax 的冲突写成了这样

字符串截取

这一部分完全来自runoob。字符串可以使用下标截取,也可以使用 # 和 % 操作符截取:

符号 作用
#*t 从左边开始删除到第一个指定的字符 t
##*t 从左边开始删除到最后一个指定的字符 t
%t* 从右边开始删除到第一个指定的字符 t
%%t* 从右边开始删除到最后一个指定的字符 t

删除包括指定的字符本身

一些例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var=http://www.aaa.com/123.htm
echo ${var#*//} # www.aaa.com/123.htm
echo ${var##*/} # 123.htm
echo ${var%/*} # http://www.aaa.com
echo ${var%%/*} # http:

echo ${var:0:5} # 从第 0 个字符开始截取,截取 5 个字符
# http:
echo ${var:7} # 从左边第 7 个字符开始截取,直到结束
# www.aaa.com/123.htm
echo ${var:0-7:3} # 从右边第 7 个字符开始截取,截取 3 个字符
# 123
echo ${var:0-7} # 从右边第 7 个字符开始截取,直到结束
# 123.htm

上面 0-7 这样的用法可以理解为 -7,类似于 Python 中的用法。最右边的字符是 0-1,而左边是 0

Shell 运算符

算术运算符

原生 bash 不支持简单算术运算,但可以由以下几种方式实现:

  • expr 表达式,数值和运算符之间必须有空格,且表达式要作命令替换(反斜杠)。* 需要用反斜杠转义
    • Mac 中 shell 的 expr 语法是$((expression)),同时 * 不需要转义
  • let 表达式:let C=$A+$B
  • $[expression]
  • $((expression))
    • C=$(($A+$B)),双括号里面的 $ 可以省略
    • $ 用于取值,不需要取值的算术运算可以省略,例如((a++))

关系运算符和布尔运算符

关系运算符和布尔运算符可以被用在条件表达式中(内容在下一节)。它们在 shell 中的形式比较特殊:

运算符 作用 说明
-eq 相等 equal
-ne 不等 not equal
-gt 大于 greater than
-lt 小于 less than
-ge 大于等于 greater / equal
-le 小于等于 less / equal
!
-o or
-a and
&& 逻辑与
|| 逻辑或

文件测试运算符

运算符 作用
-b 检测文件是否为块设备文件
-c 检测文件是否为字符设备文件
-d 检测文件是否为目录
-f 检测文件是否为普通文件(既不是目录,也不是设备)
-g 检测文件是否设置了 SGID 位
-k 检测文件是否设置了粘着位(Sticky Bit)
-p 检测文件是否为有名管道
-u 检测文件是否设置了 SUID 位
-r 检测文件是否可读
-w 检测文件是否可写
-x 检测文件是否可执行
-s 检测文件是否不为空(大小是否大于 0)
-e 检测文件(包括目录)是否存在

字符串运算符

运算符 作用
= 检测两个字符串是否相等
!= 检测两个字符串是否不相等
-z 检测字符串长度是否为0
-n 检测字符串长度是否不为0
str(字符串本身) 检测字符串是否不为空

Shell 流程控制

条件表达式

Shell 条件表达式有三种实现方法,分别是 test 命令,中括号和双中括号。其中 test 命令(test 后面直接加表达式)和中括号需要使用上一节中的关系运算符,而双中括号可以使用常见的大于小于符号等;另外 [ 是一个命令,所以和后面的表达式之间必须有空格。

脚本例题:

  • 允许用户以参数形式提供用户名添加用户
  • 自动添加与用户名相同的密码
  • 静默运行脚本
  • 避免捕获用户接口
  • 程序自定义输出
1
2
3
4
5
6
7
8
9
10
#! /bin/bash

[ ! $# -eq 1 ] && echo "参数错误" && exit 2 # 判断参数是否为 1 个,同时利用 && 左边为真才执行右边的特性,打印错误信息

id $1 >& /dev/null && echo "用户已存在" && exit 3 # /dev/null 是一个数据黑洞,可以当作垃圾桶使用,不会开辟空间存储

! useradd $1 >& /dev/null && echo "用户添加失败" && exit 7 # 执行 useradd,根据退出码决定是否打印失败信息

echo $1 | passwd --stdin $1 >& /dev/null # 添加密码
echo "用户$1添加成功"

流程控制语句

if else

1
2
3
4
5
6
7
8
if condition 1
then
command 1
elif condition 2
command 2
else
command 3
fi

写在一行内:

if [ $(ps -ef | grep -c "ssh") -gt 1 ]; then echo "true"; fi

for

for 语句支持步进和增强 for 循环:

1
2
3
4
5
6
for var in item1 item2 ... itemN
do
command
done

# 增强 for 循环看后面例子

while

1
2
3
4
while condition
do
command
done

until

until 和 while 刚好相反,一直执行直到 condition 为 true:

1
2
3
4
until condition
do
command
done

case

1
2
3
4
5
6
7
8
case var in
var1)
command1
;;
var2)
command2
;;
esac

例子:循环遍历文件每一行,使用 num 变量统计行数并打印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#! /bin/bash

# 增强 for 循环
num=0
oldIFS=$IFS # split 切割扩展的切割规则保存在 IFS 环境变量中,默认包含空白符。这里我们希望只切割换行符
IFS=$'\n'

for k in `cat $1`;
do
echo $k
((num++))
done;
IFS=oldIFS
echo num:$num

# for 循环步进
num=0
lines=`cat $1 | wc -l`
for((i=1;i<=lines;i++))
do
head -$i $1 | tail -1
((num++))
done;
echo num:$num

# while read
# read 每次从标准输入读取一行,并将其读取到 line 变量中
exec 8<&0
exec 0< $1

num=0
while read line
do
echo $line
((num++))
done
echo num:$num
exec 0<&8

# 上一种方式的简写,shell 自动在 while 执行前后重设 0
num=0
while read line
do
echo $line
((num++))
done 0< $1
echo num:$num