Shell编程基础 - 从此成为Shell达人【1】

在刚工作那会负责集成发版、各种车端启动脚本等,完整的看了《Linux Shell核心编程指南》1这本书,算系统学习Shell编程,还自诩为Shell达人。如今换了工作,不再特别需要这项技能,曾经学习的Shell编程基本已经遗忘,此次快速的复习下并且记录成此文。

Shell变量的类型只能是字符串,所以定义变量时不需要指定类型。定义一个变量语法如下:

shell

var=123 # 定义var为123
var="1 2 3" # 双引号将其内字符看成一个整体
unset var # 删除变量

有如下几点需要注意:

  • 变量未被定义时定义,已经被定义则可以被重复赋值。类似python对变量的定义。
  • 定义变量时等号两边不可有空格
  • 双引号将其内字符看成整体,不会被空格、换行符等中断。推荐定义变量时使用双引号

当需要引用变量时可在变量名前加$符号,当变量名与其他非变量名字符混在一起时,需要{}分隔。语法如下:

shell

bash-3.2$ var="123"
bash-3.2$ echo $var
123
bash-3.2$ echo "$var"
123
bash-3.2$ echo "$var4"

bash-3.2$ echo "${var}4"
1234
bash-3.2$ echo "\${var}4"
${var}4
bash-3.2$ echo '${var}4'
${var}4

有如下几点需要注意:

  • line4在引用var变量前后加上双引号,当被引用的变量含有空格/换行符/制表符被看成一个整体。也就是双引号使得变量在作为命令输入时作为一个参数,而不是被空格分隔的几个参数。所以在大部分情况需要使用双引号。

    如下代码,line2不使用双引号,导致传给touch命令的参数被空格分为了三个创建了三个文件,而line3引用变量使用引号使其作为一个参数传给touch命令创建一个文件。:

    shell

    bash-3.2$ var="1 2 3"
    bash-3.2$ touch $var # 创建三个文件,为1 2 3
    bash-3.2$ touch "$var" # 创建一个文件,为"1 2 3"
  • line6打印了不存在的变量var4,结果为空。在Shell中引用未被定义的变量结果为空

  • line8使用{}分隔变量名和字符字面量,可以正确引用到var变量。所以在Shell中变量名和其它字符混在一起时,应当使用{}分隔

  • line10展示了可以使用转义符\,屏蔽变量引用$符号。

  • line12展示了单引号可以屏蔽内部的所有转义符,不会进行变量替换、命令替换等特殊操作。

下面是Shell中常见系统内置变量的表格:

类别变量描述
环境变量$HOME当前用户的主目录路径
$PATH可执行程序的搜索路径列表,用 : 分隔
$PWD当前工作目录的路径
$OLDPWD之前的工作目录路径,可以用 cd - 切换回去
$RANDOM返回0到32767的随机整数
$PPID返回当前Shell进程的父进程,在从音频播放C++类的实现学习Linux系统子进程管理中有应用
进程和脚本相关$?上一个命令的退出状态码,0为正常退出,其它为异常退出
$$当前Shell进程的进程 ID (PID)
$!最后一个后台进程的 PID
$0当前脚本的文件名
$n当前脚本参数$1为第一个参数,以此类推
$#传递给脚本或函数的参数个数
$*传递给脚本或函数的所有参数,作为一个整体的字符串
$@传递给脚本或函数的所有参数,每个参数独立
特殊符号变量$-当前Shell的选项标志(如 -i 表示交互模式)
$_上一个命令的最后一个参数或最后一次使用的参数

反引号``和$()都可以用作命令替换,将命令的输出结果作为字符串返回。如下示例,创建以当前年月日作为文件名的文件。

shell

bash-3.2$ touch "$(date +%Y%m%d).txt"
bash-3.2$ touch "`date +%Y%m%d`.txt"

反引号``和$()二者的区别在于$()支持嵌套功能,而反引号不支持。所以推荐用$()而不是反引号``。

首先需要了解,普通方法执行外部命令或者Shell脚本都会在子进程(SubShell)中执行,而使用source.点命令来执行Shell脚本可以在当前Shell进程中执行脚本内容,就如同在Shell中一行一行输入脚本的命令.命令在一些低版本的Shell中不支持。使用方法如下:

shell

bash-3.2$ . test.sh
bash-3.2$ source test.sh

而默认情况下,当前Shell进程所定义的变量不对其它进程可见。也就是在当前Shell中执行bash test.sh,在test.sh中引用不了当前Shell定义的变量,而source test.sh由于在当前Shell中执行所以可以引用当前Shell定义的变量,当然在脚本中定义的变量可以被当前Shell所引用。如下例子。

shell

bash-3.2$ var="123"
bash-3.2$ cat test.sh
echo "$var"
bash-3.2$ bash test.sh

bash-3.2$ source test.sh
123

使用export修饰定义的变量可以被子进程引用,但是不能被父进程引用。如下,在执行export.sh的子进程可以引用父进程被定义为exportvar。但是子进程定义exportvar_inside却不能被父进程所引用。子进程会继承父进程的一些属性,但是子进程很难“影响”到父进程。

shell

bash-3.2$ cat export.sh
echo "$var"
export var_inside=123
bash-3.2$ export var="123"
bash-3.2$ bash export.sh
123
bash-3.2$ echo "$var_inside"

综上可以解释为何修改~/.bashrc后需要执行source ~/.bashrc,并且在~/.bashrc定义的环境变量需要export修饰。

在Shell中以下执行方法会在子进程(SubShell)中执行,包括如下:

  • 执行外部命令。也就是除去一些Shell内部(built-in)命令,例如cdalias等,除去Shell的变量定义、循环、函数等语句。

  • 普通方式执行Shell脚本。source执行则不会创建子进程。

  • 执行含有管道的命令组,即使全部是内部命令也会在子Shell中执行。由于管道需要将标准输入和输出进行重定向,需要fork出子进程才能实现该功能。

  • 使用&将命令放置后台执行也会被放入子Shell中执行。由于在后台执行,天然不能放在当前Shell中执行,否则会阻塞当前的Shell进程。

  • 使用分组命令()$()执行命令也会在子Shell中执行。

也就意味着上述执行方法需要注意变量作用域的问题,作为被执行的命令内部引用不到父Shell中定义的非export变量,而被在执行命令中定义的变量也不会被引入到父Shell中

Shell中有如下算数运算的语法可以对整数进行运算,注意不可计算小数

  • $(( 表达式 )):

    这种语法是POSIX标准的一部分,兼容性更好,推荐使用。

    shell

    bash-3.2$ x=2
    bash-3.2$ echo $(( x+=2 ))
    4

    有如下几点需要注意:

    1. 表达式中引用变量不需要加$符号。
    2. 表达式两边不需要有空格,但是为了可读性最好加上空格。
  • $[表达式]:

    这种语法的用法和上边类似,虽然语法上更加简单但未被标准所支持,所以不推荐使用

  • let:

    该命令会进行运算但是不导出运算结果,需要后续语句从保存运算结果的变量中取值。

    shell

    bash-3.2$ let x=10
    bash-3.2$ let x++
    bash-3.2$ echo $x
    11

下面列出Shell支持的运算符号。

符号含义描述
++自加1
自减1
+加法
-减法
*乘法
/除法
**幂运算
%取余(求模)
+=自加任意数
-=自减任意数
*=自乘任意数
/=自除任意数
%=对任意数取余
&&逻辑与
||逻辑或
>、>=大于、大于等于
<、<=小于、小于等于
表达式?表达式:表达式三元运算符

小数计算Shell原生不支持,需要用其它命令来支持,例如bc命令。例如:

shell

bash-3.2$ echo "scale=2; 3/4" | bc
.75

其中scale可以指定保留几位小数,默认仅展示整数部分。

在Shell中支持索引数组关联数组,其实严格意义上讲二者都是map: 索引数组是key类型为整数,value类型为字符串的map;关联数组是key类型为字符串,value类型为字符串的map,分别有如下特点。

  • 索引数组

    索引数组可以直接使用数组名[索引号]=val定义,索引号无需连续,故其严格讲是个map而不是array。

    shell

    bash-3.2$ name[1]="hello"
    bash-3.2$ name[2+2]='!'
    bash-3.2$ name[2]="world"
    bash-3.2$ echo "${name[*]}"  # 取数组所有的值,按照索引从小到大排序,必须使用{}
    hello world !
    bash-3.2$ echo "${#name[@]}" # 取数组数量
    3
    bash-3.2$ echo "${#name[*]}"
    3

    可以一次性初始化多个元素,不能指定每个元素的索引,默认以0为起始索引递增。

    shell

    bash-3.2$ name=(hello world '!')
    bash-3.2$ echo ${name[0]}
    hello

    还可以将其它命令输出字符串赋值给数组变量,数组每个元素是将输出用空格、换行符或制表符等分割的字符串。

    shell

    bash-3.2$ files=($(ls))
    bash-3.2$ echo ${files[*]}
    echo.sh exit.sh export.sh heredocument.sh range.txt replace.txt test.sh test.txt
  • 关联数组

    关联数组就可以使用字符串作为key,定义前需要用declare -A 数组名声明变量为关联数组。另外声明的变量自动拥有local属性,作用域只在局部而不是默认的全局。从bash 4.0版本后才支持。

    定义方式如下,和索引数组类似有两种形式:

    shell

    ~/Documents/project/bash » declare -A man
    ~/Documents/project/bash » man[name]=tom
    ~/Documents/project/bash » man[age]=26
    ~/Documents/project/bash » echo ${man[*]}
    26 tom
    ~/Documents/project/bash » declare -A woman
    ~/Documents/project/bash » woman=([name]=lucy [age]=25)
    ~/Documents/project/bash » echo ${woman[*]}
    25 lucy

首先需要区分@*对数组的区别,如下:

  • ${数组名[@]}: 将数组元素视为若干个体,如同不使用双引号括起字符串。
  • ${数组名[*]}: 将数组元素视为一个整体,如同使用双引号括起字符串。

故可以使用${数组名[@]}遍历数组的值。

shell

~/Documents/project/bash » for i in ${woman[@]}; do
\ echo $i
\ done
25
lucy

当然也可以通过索引(key)来遍历数组,如下语法可以取数组索引的列表,这里*@都将结果视为若干个体,没有区别。

shell

echo ${!woman[@]}
echo ${!woman[*]}

在Shell中定义函数的语法有多种,如下都是合法的。个人推荐最后一种方式。使用unset可以取消函数定义。

shell

func1() {
  # 代码序列
}

function func2 {
  # 代码序列
}

function func3() {
  # 代码序列
}

函数的调用方法和命令的执行方法类似,不需要括号(),调用时也可以将参数通过空格间隔传入,在函数内部可以通过$1$2等获取调用方传入的参数。当然也可以使用$@获取所有的位置参数。如下示例。

shell

bash-3.2$ cat test.sh
#!/bin/bash

function func() {
  echo "$*"
}

func 1 2 3 # 函数调用

bash-3.2$ ./test.sh
1 2 3

在Shell中调用函数不会开启子进程,故在函数内部和在外部定义和使用变量的效果相同。函数外部的变量函数内部可以直接调用,函数内部定义的变量在函数执行后也可以被外部引用。在函数内部定义变量使用local修饰,可以使变量作用域只在函数内部,且和全局同名变量也不会导致意外修改全局变量,并且其在函数内部引用优先级高于全局变量。

使用local定义的变量作用域和一般的现代编程语言一致,故在函数内部定义的变量应该使用local修饰,来保证不会影响到全局变量

Shell函数的返回值是一个整数,逻辑上表明函数是否执行成功。默认函数的返回值为函数内部执行最后一个命令的返回值。也可以使用return关键字返回指定整数(有效范围为0~255)作为返回值。调用方可以使用$?来获取该返回值

当然一般我们希望函数能返回更多信息,其实在函数中echo各种字符可以作为传统意义上的“返回值”。可以使用$()引用函数echo出来的“返回值”。

shell

bash-3.2$ function func() {
> echo "$*"
> }
bash-3.2$ func_result=$(func 1 2 3) # 将函数echo的返回值赋值给变量
bash-3.2$ echo $func_result
1 2 3

条件if语句基本的语法格式如下:

shell

if 命令; then
  命令序列
elif 命令; then
  命令序列
else
  命令序列
fi

需要注意的是if的条件是判断命令是否正常结束,即判断命令的退出码是否为0正常结束。即如下示例。

shell

if ls nofile; then
  echo true
else
  echo false
fi

一般的编程语言在if判断条件是逻辑表达式是否为true,Shell中提供test[ ][[ ]]条件测试命令,支持将传入的逻辑表达式是否为true转换成该命令是否正常结束,从而支持逻辑表达式。

在Shell中支持test[ ][[ ]]条件测试命令,可以进行字符串或数字的比较、测试文件属性等。测试的结果通过命令是否正常退出返回,可以在if表达式中使用,也可以通过$?获取测试结果。

test[ ]是POSIX标准的测试语句兼容性强,而[[ ]]功能更强大一些,兼容性稍差,但是也被主流解释器所支持,所以更推荐使用[[ ]]命令。有如下[[ ]]支持的功能,而test[ ]不支持。

  • 直观符号支持

    [[ ]]支持<>符号,一般采用locale语言排序,使用LANG=C设置在排序时使用ASCII码;支持逻辑与&&和逻辑或||符号,而test[ ]需要使用-a-o表达对应符号意思。所以使用[ ]非常蹩脚,不推荐。

  • 模式匹配支持

    [[ ]]==支持模式匹配,允许使用通配符,例如,*?[...]等。如下示例:

    shell

    [[ $name == J* ]] # 判断name变量是否以J开头
    [[ $name == J?cob ]] # 判断name变量是否在J和cob之间有任意单个字符
    [[ $age == [0-9] ]] # 判断age变量是否为数字
  • 正则匹配支持

    [[ ]]中支持使用=~进行正则匹配,如下示例:

    shell

    [[ $age =~ [0-9]]] # 变量是否**含有**数字
  • 分组()测试支持

    [[ ]]支持分组()测试,可以改变优先级,例如:

    shell

    [[ a == a && (b == b || c == d) ]] # 先判断括号中的结果

另外需要特别注意,左右方括号必须要有空格,并且比较符号两边也必须有空格

以下是[[ ]]命令支持的判断与比较。

  • 字符判断

    条件描述
    [[ $str1 == $str2 ]]str1str2 相等时为真,支持通配符匹配
    [[ $str1 != $str2 ]]str1str2 不相等时为真
    [[ -z $str ]]str 为空时为真(zero)
    [[ -n $str ]]str 非空时为真(not zero)
    [[ $str == pattern ]]strpattern 匹配时为真
    [[ $str =~ regex ]]str 匹配正则表达式 regex 时为真
  • 整数判断

    条件描述
    [[ $num1 -eq $num2 ]]num1 等于 num2 时为真
    [[ $num1 -ne $num2 ]]num1 不等于 num2 时为真
    [[ $num1 -lt $num2 ]]num1 小于 num2 时为真
    [[ $num1 -le $num2 ]]num1 小于等于 num2 时为真
    [[ $num1 -gt $num2 ]]num1 大于 num2 时为真
    [[ $num1 -ge $num2 ]]num1 大于等于 num2 时为真
  • 文件属性判断

    条件描述
    [[ -e $file ]]文件存在时为真
    [[ -f $file ]]普通文件存在时为真
    [[ -d $file ]]目录存在时为真
    [[ -L $file ]]符号链接存在时为真
    [[ -b $file ]]块设备文件存在时为真
    [[ -c $file ]]字符设备文件存在时为真
    [[ -p $file ]]命名管道(FIFO)存在时为真
    [[ -r $file ]]文件可读时为真
    [[ -w $file ]]文件可写时为真
    [[ -x $file ]]文件可执行时为真
    [[ -s $file ]]文件存在且大小大于 0 时为真

另外[[ ]]支持逻辑取反符号!,如下示例:

shell

if [[ ! -z $str ]]; then
    echo "字符串非空"
fi

如果需要再一行代码中输入多条语句,可以使用;&&||将多个命令分隔。其中:

  • 分号;: 按照顺序执行,前后语句没有逻辑关系。
  • 逻辑与&&: 仅当前一条执行成功,才会执行后一条语句,并且两个语句都成功才认为整行语句执行成功。例如,A命令&&B命令,只有A命令执行成功才会执行B命令,并且两条命令都成功才会认为整行命令成功。
  • 逻辑或||: 仅当前一条执行失败,才会执行后一条语句,并且两个语句其一成功就认为整行语句执行成功。例如,A命令||B命令,只有A命令执行失败才会执行B命令,并且两条命令只要有一条执行成功就认为整行命令执行成功。

通过使用&&||可以实现一行代码的if\else逻辑,比较推荐。如下示例,判断当前用户是否为root,需要注意&&||先后问题,写反了意义就不同了。

shell

[[ $USER == root ]] && echo true || echo false
# 错误的写法: [[ $USER == root ]] || echo true && echo false

虽然上述写法很优雅,但是本人曾经踩过一次坑。如果需要条件满足就执行某个命令,条件不满足则不执行任何命令逻辑,就会写出如下的代码。

shell

[[ $USER == root ]] && echo true

默认情况下上述代码可以正确运行,但是一般Shell脚本会开启set -e选项,脚本中命令执行失败就会结束不再继续执行,防止错误继续传递。而对于这种写法,在条件为false时整行语句就会被认为执行失败导致脚本意外退出。故即使不需要else也要加上,可以写成如下形式(:命令为空命令)。

shell

[[ $USER == root ]] && echo true || :

Shell的case语句类似于一般语言中的switch/case语句,但是提供了更多方便的用法。基本的语法如下。

shell

case word in
模式1)
  命令序列
  ;;
模式2|模式3)
  命令序列
  ;;
*)
  命令序列
  ;;
esac

case命令会对word关键词匹配下面的每个模式,模式支持多个条件匹配用|隔开。

在命令序列需要使用结束符来表明之后的动作,支持如下三种结束符:

  • ;;: 类似C/C++中的break语句不再继续匹配。
  • ;&: 继续执行下一个模式匹配的命令,在C/C++的switch语句中的默认行为。
  • ;;&: 继续匹配下边的模式匹配,如果匹配就执行对应的命令序列。

模式匹配还支持一些特殊符号。如下表。

特殊字符描述
变量展开$、命令展开$()展开功能
*匹配任意字符,例如*.txt)匹配以txt为结尾的字符串
?匹配单个字符
[...]匹配括号内的任意单个字符,可以使用-表示连续的字符集
扩展通配符后续文章介绍

举个如下的case语句使用例子,可以匹配yyesnno不区分大小写。

shell

read -p "continue? (y|n)" key
case $key in
  [Yy]|[Yy][Ee][Ss]):
    echo "yes"
    ;;
  [Nn]|[Nn][Oo]):
    echo "no"
    ;;
  *)
    echo "wtf"
    ;;
esac

select语句支持设置多个选项,执行到该语句时会进入交互模式,等待用户输入编号。比较鸡肋。

shell

bash-3.2$ cat select.sh
select item in "cpu" "ip"; do
 echo $item
done
bash-3.2$ bash select.sh
1) cpu
2) ip
#? 1
cpu
#? 2
ip
#? 3

#? ^D

在Shell中for语句有下面基本的语法格式。

shell

for var [ in 字符串 ]; do
  命令序列
done

有如下需要注意的地方:

  • 可以忽略in和后边的字符串,此时默认取值为$@,遍历当前作用域的参数。

  • 字符串按照空格、制表符、换行符($IFS指定)分割成子字符串,循环赋值给var。如下几种方式可以产生这样的字符串。

    • 字面量: 直接写类似for var in 1 2 3;do echo $var; done,注意字符串如果被双引号包围,则代表一个整体,只会遍历一次
    • 花括号扩展: 例如{1..5}可以产生1~5以空格分隔的字符串;{1..10..2}产生以1开始最大到10步长为2的字符串。但是其不可以引用变量,即{1..$i}是非法的。后续文章系统介绍这些Shell的扩展。
    • seq命令: 其可以生成序列,并且作为命令可以引用其它变量。例如: for var in ${seq 1 10}; echo $var; done
    • 数组: 可以使用${array[@]}来获取以空格分隔的字符串。

另外Shell还支持C语言风格的for循环。如下,语法和C语言略有不同。其定义的i变量在for语句执行完后还可以引用。

shell

for ((i=0; i<=5; ++i>));do
  echo $i
done

使用while语句也可以遍历,其语法格式如下。

shell

while 命令; do
  语句序列
done

需要注意的是while语句中的命令和if中的类似,推荐使用[[ ]]格式的命令作为条件。如下示例,打印0~10。

shell

i=0
while [[ $i -le 10 ]];do
  echo "$i"
  let ++i
done

until语句比较鸡肋,和while语句的区别在于: until循环语句在条件判断结果为true才结束循环,而while则是判断结果为false时结束。

Shell的循环也支持breakcontinue用于中断循环和中断此次循环。

不同于C语言,这两个命令后边可以跟数字参数,数字要求大于等于1,表示对第几层循环执行breakcontinue操作。

类似exit()系统调用结束进程,Shell中也支持exit命令结束Shell进程,可以指定退出码。