写在前面(最重要)

本文部分资料和示例援引自以下书籍。在此,感谢原作者的创作,以及所有译者的付出,向他们致敬。

  1. Advanced Bash-Scripting Guide

  2. 《高级Bash脚本编程指南》Revision 10中文版

  3. Linux脚本编程执导

其中 《高级Bash脚本编程指南》Revision 10中文版 是 《Advanced Bash-Scripting Guide》 的中文翻译版,文档翻译正在进行中,再次感谢译者付出。

前言

在Linux 系统管理的过程中,了解和掌握Shell脚本的相关知识,是非常重要的。详细了解shell的运行机制,将会对分析系统行为大有裨益。而且编写shell脚本并不困难,Shell脚本是由众多的小的部分组成,我们只要熟悉了shell基本特性,就能够写出能够执行复杂任务的高效脚本,在运维工作中,很多问题将会变得事半功倍。本文将重点介绍shell编程基础,关于shell进阶请查看本系列后续文章。

本文将从以下两个方面介绍shell基础。

第一部分 初见shell 
1. 为什么使用shell编程

2. Sha-Bang(#!)机制

第二部分 Shell基础 
3. 特殊字符

4. 变量与参数

5. 引用

6. 退出与退出状态

7. 测试

第一部分 初见shell

为什么使用shell编程

在Linux运维工作中,为什么使用shell编程,可能是很多像我一样的初学者都会存在的一个疑惑。大家并不明白为什么要使用shell ,只是从他人的口中了解到,shell是一个强大的工具,可以帮助我们解决很多复杂的的问题,可是shell到底怎样强大,到底如何高效,在我们并没有真正深入的了解shell之前,恐怕我们并不能很好的理解。
可是无论你是出于什么样的目的,也无论你是否想要真正的编写shell脚本,只要你想要在一定程度上熟悉Linux系统管理,了解掌握Shell脚本的相关知识就是非常有必要的。当我们将这个工具用的熟练了之后,或许,我们就会明白,为什么Linux系统管理和运维离不开shell了。
下面我们将通过一个小的例子来理解,什么是Sha-Bang(#!)机制,以及如何开始编写脚本。

Sha-Bang(#!)机制

我们先来看一个经过优化的Shell脚本

本示例援引自 《高级Bash脚本编程指南》Revision 10中文版 第一部分第2节

#!/bin/bash
# Cleanup, version 3

# 注意:
# --------
# 此脚本涉及到许多后边才会解释的特性。
# 当你阅读完整本书的一半以后,理解它们就没有任何困难了。

LOG_DIR=/var/logROOT_UID=0     # UID为0的用户才拥有root权限。
LINES=50       # 默认保存messages日志文件行数。
E_XCD=86       # 无法切换工作目录的错误码。
E_NOTROOT=87   # 非root权限用户执行的错误码。


# 请使用root权限运行。
if [ "$UID" -ne "$ROOT_UID" ]
then  
    echo "Must be root to run this script."
    exit $E_NOTROOT
fi

if [ -n "$1" ]
# 测试命令行参数(保存行数)是否为空
then  
    lines=$1
else  
    lines=$LINES # 如果为空则使用默认设置
fi


cd $LOG_DIR


if [ `pwd` != "$LOG_DIR" ]  # 也可以这样写 if [ "$PWD" != "$LOG_DIR" ]
                            # 检查工作目录是否为 /var/log ?
                            
then  
    echo "Can't change to $LOG_DIR"
    exit $E_XCD
    
fi  


# 在清理日志前,二次确认是否在正确的工作目录下。
# 更高效的写法:
#
# cd /var/log || {
#   echo "Cannot change to necessary directory." >&2
#   exit $E_XCD;
# }


tail -n $lines messages > mesg.temp # 保存messages日志文件最后一部分
mv mesg.temp messages              # 替换系统日志文件以达到清理目的


#  cat /dev/null > messages
#* 我们不需要使用这个方法了,上面的方法更安全


cat /dev/null > wtmp  #  ': > wtmp' 与 '> wtmp' 有同样的效果
echo "Log files cleaned up."
#  注意在/var/log目录下的其他日志文件不会被这个脚本清除


exit 0
#  返回0表示脚本运行成功

脚本中涉及到的相关语法,我们会在后面的内容中进行详细的介绍,此处不做详述。

我们看到在脚本第一行有这样一段代码 #!/bin/bash 而开头的 #! 就是告诉系统,这个脚本文件需要使用指定的命令解释器来执行。紧随 #!的是一个路径名。此路径指向用来解释此脚本的程序,它可以是shell,可以是程序设计语言,也可以是实用程序。这个解释器从头( #!的下一行)开始执行整个脚本的命令,同时忽略注释。 
正如下面的示例所表现的,既可以是shell,也可以是其他编程语言。

#!/bin/bash
#!/usr/bin/python
#!/usr/bin/perl

Shell 基础

特殊字符

如果一个字符不仅具有字面本身的意思,同时也具有 元意(meta-meaning) 我们就将其称之为特殊字符,也可以理解为元字符。特殊字符同命令和关键字一样,是bash脚本的组成部分。
就像编程语言里面也有自己的特殊字符一样,例如Java 中 // 表示 注释。

由于特殊字符众多,而我们篇幅有限,所以这里只是以几个字符为例进行,介绍,其余的字符将以表格的形式展示出来,用于查阅。

# #

注释符。如果一行脚本的开头是 # 除了(#!),那么就代表这一行是注释,不会被执行。# 作为注释符的位置可以很灵活,行首,行尾,代码之间,都是可以的。

#这是一行注释

echo "Hello World!" # 这里可以写注释

;

分号,命令分隔符。允许在同一行内放置一条或者多条命令。

echo hello;echo hi

if [ -x "$filename" ]; then    #  注意在分号以后有一个空格
#+                   ^^
  echo "File $filename exists."; cp $filename $filename.bak
else   #                       ^^
  echo "File $filename not found."; touch $filename
fi; echo "File test complete."

注意,有些时候,可能需要被转义才能够正常工作。

;;

双分号,case条件语句终止符。

case "$variable" in  
    abc)  echo "\$variable = abc" ;;
    xyz)  echo "\$variable = xyz" ;;
esac

特殊字符列表

特殊字符 字符含义
# 注释符,如果一行脚本的开头是#(除了#!),那么代表这一行是注释,不会被执行
; 命令分隔符[分号]。允许在同一行内放置两条或更多的命令。
;; case条件语句终止符[双分号]。
;;&, ;& case条件语句终止符(Bash4+ 版本)。
. 1.等价与source命令。2.可以作为隐藏文件。3. 当前工作目录。4.正则表达式中,表示单个字符
双引号,部分引用(弱引用)。
单引号,全引用(强引用)。
, 逗号运算符。逗号运算符1将一系列的算术表达式串联在一起。算术表达式依次被执行,但只返回最后一个表达式的值。
\ 转义符[反斜杠]。转义某字符的标志
/ 文件路径分隔符[正斜杠]。起分割路径的作用。
` 命令引用。也被称作反引号
: 空命令[冒号]。它在shell中等价于”NOP”(即no op,空操作)与shell内建命令true有同样的效果。它本身也是Bash的内建命令之一,返回值是true(0)。
! 取反(或否定)操作符[感叹号]。
* 通配符[星号]。
? 测试操作符[问号]。在一些特定的语句中,? 表示一个条件测试。 也可以作为 通配符。
$ 取值符号[钱字符],用来进行变量替换(即取出变量的内容)。行结束符[EOF]。 在正则表达式中,$ 匹配行尾字符串
${} 参数替换。
$’…’ 引用字符串扩展。这个结构将转义八进制或十六进制的值转换成ASCII3或Unicode字符。
$*, $@ 位置参数。
$? 返回状态变量。此变量保存一个命令、一个函数或该脚本自身的返回状态。
$$ 进程ID变量。此变量保存该运行脚本的进程ID。
() 通过括号执行一系列命令会产生一个子shell(subshell)。 括号中的变量,即在子shell中的变量,在脚本的其他部分是不可见的。父进程脚本不能访问子进程(子shell)所创建的变量。
{xxx,yyy,zzz,...} 花括号扩展结构。
{a..z} 扩展的花括号扩展结构。
{} 代码块[花括号],又被称作内联组(inline group)。它实际上创建了一个匿名函数(anonymous function),即没有名字的函数。但是,不同于那些“标准”函数,代码块内的变量在脚本的其他部分仍旧是可见的。
[ ] 花括号扩展结构。
{xxx,yyy,zzz,...} 测试。在 [ ] 之间填写测试表达式。值得注意的是,[ 是shell内建命令 test 的一个组成部分,而不是外部命令 /usr/bin/test 的链接。
` ` 测试。在 ` ` 之间填写测试表达式。相比起单括号测试 ([ ]),它更加的灵活。它是一个shell的关键字。并且可以支持正则表达式
$[ ... ] 整数扩展符。在 $[ ] 中可以计算整数的算术表达式。
(( )) 整数扩展符。在 (( )) 中可以计算整数的算术表达式。
> &> >& >> < <> 重定向。
~+ 当前工作目录。它等同于内部变量 $PWD。
~- 先前的工作目录。它等同于内部变量 $OLDPWD。
=~ 正则表达式匹配。 在[[]] 测试符的使用过程中就需要用到

变量与参数

变量替换

变量名是指其所指向的一个占位符。引用变量值的过程,我们称之为变量替换。

variable=123

echo $variable
123

在双引号""字符串中可以使用变量替换。我们称之为部分引用,有时候也称弱引用。而使用单引号''引用时,变量只会作为字符串显示,变量替换不会发生。我们称之为全引用,有时也称强引用。

实际上, $variable 这种写法是 ${variable} 的简化形式。在不引起歧义的情况下,省略大括号是没有问题的,但是在某些特殊情况下,有时必须加上大括号。

variable=123echo ${variable}Hello
123Hello

变量赋值

变量赋值,并不是我们想象中的那样简单,虽然只用到了 赋值操作符(=)。
因为变量的赋值方式可以有很多种,所以在给变量进行赋值的过程中要注意各种赋值方式的区别。

#普通赋值
a=123

echo $a

#使用let表达式进行赋值  
let a=16+5


echo $a


#将命令的运行结果赋给a
a=`echo Hello`  

echo $a   

a=`ls -l`         # 将 'ls -l' 命令的结果赋值给 'a'
echo $a           # 不带引号引用,将会移除所有的制表符与分行符


#使用 $(...) 形式进行赋值(与反引号不同的新形式),与命令替换形式相似
arch=$(uname -m)

Bash变量是弱类型的

不同于许多其他编程语言,Bash 并不区分变量的类型。本质上说,Bash 变量是字符串,但在某些情况下,Bash 允许对变量进行算术运算和比较。决定因素则是变量值是否只含有数字。

#!/bin/bash
# int-or-string.sh

a=2334                   # 整数。

let "a += 1"echo "a = $a "           # a = 2335echo                     # 依旧是整数。

b=${a/23/BB}             # 将 "23" 替换为 "BB"。
                         # $b 变成了字符串。
                         

echo "b = $b"            # b = BB35
declare -i b             # 将其声明为整数并没有什么卵用。
echo "b = $b"            # b = BB35


let "b += 1"             # BB35 + 1
echo "b = $b"            # b = 1
echo                     # Bash 认为字符串的"整数值"为0。


c=BB34
echo "c = $c"            # c = BB34
d=${c/BB/23}             # 将 "BB" 替换为 "23"。
                         # $d 变为了一个整数。
                         
echo "d = $d"            # d = 2334
let "d += 1"             # 2334 + 1
echo "d = $d"            # d = 2335
echo


# 如果是空值会怎样呢?
e=''                     # ...也可以是 e="" 或 e=
echo "e = $e"            # e =
let "e += 1"             # 空值是否允许进行算术运算?
echo "e = $e"            # e = 1
echo                     # 空值变为了一个整数。



# 如果时未声明的变量呢?
echo "f = $f"            # f =
let "f += 1"             # 是否允许进行算术运算?
echo "f = $f"            # f = 1
echo                     # 未声明变量变为了一个整数。

#
# 然而……
let "f /= $undecl_var"   # 可以除以0么?
#   let: f /= : syntax error: operand expected (error token is " ")
# 语法错误!在这里 $undecl_var 并没有被设置为0!
#
# 但是,仍旧……
let "f /= 0"
#   let: f /= 0: division by 0 (error token is "0")
# 预期之中。



# 在执行算术运算时,Bash 通常将其空值的整数值设为0。
# 但是不要做这种事情!# 因为这可能会导致一些意外的后果。
# 结论:上面的结果都表明 Bash 中的变量是弱类型的。
exit $?

特殊变量类型

局部变量

仅在代码块或函数中才可见的变量,生效范围为当前shell进程中某代码片断(通常 指函数)。

环境变量

生效范围为当前shell进程及其子进程
一般来说,每一个进程都有自己的环境,也就是一组该进程可以访问到的变量。从这个意义上说,shell表现出与其他进程一样的行为。
每当shell启动时,都会创建出与其环境对应的shell环境变量。改变或增加shell环境变量会使shell更新其自身的环境。子进程(由父进程执行产生)会继承父进程的环境变量。

如果在脚本中设置了环境变量,那么这些环境变量需要被“导出”,也就是通知脚本所在的环境做出相应的更新。这个“导出”操作就是 export 命令。

位置参数

从命令行中传递给脚本的参数 ,就是命令行参数。

  • $1, $2, …:对应第1、第2等参数,shift [n]换位置

  • $0: 命令本身

  • $*: 传递给脚本的所有参数,全部参数合为一个字符串

  • $@: 传递给脚本的所有参数,每个参数为独立字符串

  • $#: 传递给脚本的参数的个数,$@ $* 只在被双引号包起来的时候才会有差异

  • set – 清空所有位置变量

#!/bin/bash


# 调用脚本时使用至少10个参数,例如
# ./scriptname 1 2 3 4 5 6 7 8 9 10

MINPARAMS=10


echo

echo "The name of this script is \"$0\"."
# 附带 ./ 代表当前目录
# $0 表示命令本身  


echo "The name of this script is \"`basename $0`\"."
# 除去路径信息(查看 'basename')
# 查看基目录的路径信息  

echo

if [ -n "$1" ]              # 测试变量是否存在
then 
    echo "Parameter #1 is $1"  # 使用引号转义#
fi


if [ -n "$2" ]
then 
    echo "Parameter #2 is $2"
fi



if [ -n "$3" ]
then
   echo "Parameter #3 is $3"
fi


# ...


if [ -n "${10}" ]  # 大于 $9 的参数必须被放在大括号中
then 
    echo "Parameter #10 is ${10}"
fi


echo "-----------------------------------"
echo "All the command-line parameters are: "$*""


if [ $# -lt "$MINPARAMS" ]
then  
    echo
    echo "This script needs at least $MINPARAMS command-line arguments!"
fi


echo

exit 0

在位置参数中使用大括号助记符提供了一种非常简单的方式来访问传入脚本的最后一个参数。在其中会使用到间接引用。

args=$#           # 传入参数的个数

lastarg=${!args}
# 这是 $args 的一种间接引用方式

# 也可以使用:       lastarg=${!#}          
# 这是 $# 的一种间接引用方式。
# 注意 lastarg=${!$#} 是无效的。

引用

引用就是将一个字符串用引号括起来。这样做是为了保护Shell/Shell脚本中被重新解释过或带扩展功能的特殊字符

引用变量

引用变量时,通常建议将变量包含在双引号中。因为这样可以防止除 $` (反引号)和\ (转义符)之外的其他特殊字符被重新解释。在双引号中仍然可以使用 $ 引用变量(”$variable”),也就是将变量名替换为变量值。
使用双引号可以防止字符串被分割。即使参数中拥有很多空白分隔符,被包在双引号中后依旧是算作单一字符。

variable2=""    # 空值。


COMMAND  $variable2 $variable2 $variable2
                # 不带参数执行COMMAND命令。    
COMMAND "$variable2" "$variable2" "$variable2"
                # 带上3个参数执行COMMAND命令。
COMMAND "$variable2 $variable2 $variable2"
                # 带上1个参数执行COMMAND命令(2空格)。

单引号(’‘)与双引号类似,但是在单引号中不能引用变量,因为$不再具有特殊含义。在单引号中,除'之外的所有特殊字符都将会被直接按照字面意思解释。可以认为单引号(“全引用”)是双引号(“部分引用”)的一种更严格的形式,也就是我们平常所说的强引用。

echo 'Hello $1'Hello $1 
# 单引号中的变量引用将会被当作字符原模原样的输出

退出和退出状态

跟C程序类似,exit 命令被用来结束脚本。同时,它也会返回一个值,返回值可以被交给父进程。  每个命令都会返回一个退出状态(exit status),有时也叫做返回状态(return status)或退出码(exit code)。命令执行成功返回0,如果返回一个非0值,通常情况下会被认为是一个错误代码。一个运行状态良好的UNIX命令、程序和工具在正常执行退出后都会返回一个0的退出码,当然也有例外。  同样地,脚本中的函数和脚本本身也会返回一个退出状态。在脚本或者脚本函数中执行的最后的命令会决定它们的退出状态。在脚本中,exit nnn 命令将会把nnn退出状态码传递给shell(nnn 必须是 0-255 之间的整型数)。

当一个脚本以不带参数的 exit 来结束时,脚本的退出状态由脚本最后执行命令决定(exit 命令之前)

#!/bin/bash

COMMAND_1

...

COMMAND_LAST

# 将以最后的命令来决定退出状态

exit
exit $?
# 这两种方式是一致的,即便什么都不写,也是以最后的命令来决定退出状态。

$? 读取上一个命令的退出状态,在一个函数返回后,$?给出函数最后执行的那条命令的退出状态,这既是Bash的返回值。

测试

Bash 可以对一个条件进行判断,然后根据判断的结果执行相应的命令。Bash中可以用来测试的有 test 命令(与[]等价),双方括号[[]],双圆括号(())测试符,以及 if/then 结构

测试结构

  • if/then 结构是用来检测一系列命令的 退出状态 是否为0,如果为0,则执行接下来的一个或多个命令。

  • [ 是一个buildin 命令,等同于 test 命令,但是写法更加的简洁和高效。该命令将其参数视为比较表达式或文件测试,以比较结果作为其退出状态码返回(0 为真,1 为假)。

  • [[]] 是一种比较新的测试结构,它能够支持扩展的正则表达式来进行比较。而且,要注意 [[ 是一个buildin 命令。 Bash 将 [[ $a -lt $b ]] 视为一整条语句,执行并返回退出状态

  • 结构 (( ... )) 和 let ... 根据其执行的算术表达式的结果决定退出状态码。这样的 算术扩展 结构可以用来进行 数值比较。

注意,双括号算术扩展表达式的退出状态码不是一个错误的值。算术表达式为0,返回1;算术表达式不为0,返回0。

var=-2 && (( var+=2 )) && echo $var
# 并不会输出 $var, 因为((var+=2))的状态码为1

文件测试操作

下面列表中的每一个选项 在进行test 测试时,满足条件 返回0

选项 作用
-e 检测文件是否存在
-f 文件是常规文件(regular file),而非目录或 设备文件
-s 文件大小不为0
-d 文件是一个目录
-b 文件是一个 块设备
-c 文件是一个 字符设备
-p 文件是一个 管道设备
-h 文件是一个 符号链接
-L 文件是一个符号链接
-S 文件是一个 套接字
-t 文件(文件描述符)与终端设备关联,该选项通常被用于 测试 脚本中的 stdin [ -t 0 ] 或 stdout [ -t 1 ] 是否为终端设备。
-r 该文件对执行测试的用户可读
-w 该文件对执行测试的用户可写
-x 该文件可被执行测试的用户所执行
-g 文件或目录是否拥有````sgid权限 如果一个目录设置了sgid``` 标志,那么在该目录中所有的新建文件的权限组都归属于该目录的权限组,而非文件创建者的权限组。该标志对共享文件夹很有用。
-u 是否存在且拥有suid权限
-k 设置了粘滞位(sticky bit)。
-O 执行用户是文件的拥有者
-G 文件的组与执行用户的组相同
-N 文件在在上次访问后被修改过了
f1 -nt f2 文件 f1 比文件 f2 新
f1 -ot f2 文件 f1 比文件 f2 旧
f1 -ef f2 文件 f1 和文件 f2 硬链接到同一个文件
! 取反——对测试结果取反(如果条件缺失则返回真)。
device0="/dev/sda2"    # /   (根目录)
if [ -b "$device0" ]
then  
    echo "$device0 is a block device."
fi
# /dev/sda2 是一个块设备。

其他的比较操作

整数比较

字符 含义
-eq 等于if [ "$a" -eq "$b" ]
-ne 不等于if [ "$a" -ne "$b" ]
-gt 大于if [ "$a" -gt "$b" ]
-ge 大于等于if [ "$a" -ge "$b" ]
-lt 小于if [ "$a" -lt "$b" ]
-le 小于if [ "$a" -le "$b" ]
< 小于(使用 双圆括号)(("$a" < "$b"))
<= 小于等于(使用双圆括号)(("$a" <= "$b"))
> 大于(使用双圆括号) (("$a" > "$b"))
>= 大于等于(使用双圆括号) (("$a" >= "$b"))

字符串比较

字符 含义
= 等于if [ "$a" = "$b" ] 注意 在 = 左右要加上空格
== 等于if [ "$a" == "$b" ] 和 = 同义
!= 不等于 if [ "$a" != "$b" ] 在[[]] 结构中会进行模式匹配
< 小于,按照 ASCII码 排序 if [[ "$a" < "$b" ]] if [ "$a" \< "$b" ] 注意在[]结构中, < 需要被转义
> 大于 同样需要给转义
-z 字符串为空,即字符串长度为0。
-n 字符串非空(null)
# == 在 [] 和 [[]] 中的功能是不同的。

[[ $a == z* ]]   # $a 以 "z" 开头时为真(模式匹配)
[[ $a == "z*" ]] # $a 等于 z* 时为真(字符匹配)

[ $a == z* ]     # 发生文件匹配和字符分割。
[ "$a" == "z*" ] # $a 等于 z* 时为真(字符匹配)

** 使用 -n 来判断字符串是否为null 的时候,一定要对变量进行引用 **

#!/bin/bash
# str-test.sh: 测试是否为空字符串或是未引用的字符串。


# 使用 if [ ... ] 结构

# 如果字符串未被初始化,则其值是未定义的。
# 这种状态就是空 "null"(并不是 0)。

if [ -n $string1 ]    # 并未声明或是初始化 string1。
then  
    echo "String \"string1\" is not null."
else  
    echo "String \"string1\" is null."
fi
# 尽管没有初始化 string1,但是结果显示其非空。


echo

# 再试一次。
if [ -n "$string1" ]   # 这次引用了 $string1。
then  
    echo "String \"string1\" is not null."
else  
    echo "String \"string1\" is null."
fi                    # 在测试括号内引用字符串得到了正确的结果。

echo

通篇来说,我们介绍了shell编程基础中的一些基本内容,在掌握了这些内容的前提下,我们就可以进行简单的shell 脚本编写了。




个人博客地址:http://www.pojun.tech/ 欢迎访问