shell学习笔记

[TOC]

0. 前言

鉴于实习中经常使用 shell 语言,因此趁此机会好好学习一下“强大”的 shell 语言。

参考:

1. 简介

shell 直译为壳。在操作系统(Linux)中,被称为外壳,通常与 kernel 内核相对立。在没有图形界面的时代,shell 是用户与操作系统交互的接口

shell 本身是一个程序。在操作系统实验课程中,老师曾让我们实现一个微型 shell ,代码见:https://github.com/99MyCql/OS_pratice。它包括 shell 的许多基本功能:命令提示符运行命令/程序(如:cat、echo、ls等)、重定向输入输出管道。完成实验时,成就感满满,几乎与真实的 shell 无疑。

但,当时的我可能忽略了 shell 的另一个“身份”——解释器。shell 解释器可运行 shell 脚本语言,它支持变量、条件判断、循环等等语法。这让 shell 具备了可编程性,而失去这一大功能的 shell 只能称为 mini shell 。

关于解释型语言的定义,以及与编译型语言的区别,就不在此赘述了。

shell 分为很多种,包括:Bourne Shell(sh)、Bourne Again shell(bash)、Z Shell(zsh)等等,它们的本质基本相同,本文将主要基于 Bash Shell

接触过 python 的hxd应该知道,python 既可以在解释器中一行一行地敲,也可以写在一个文件中再运行文件。

shell 也是同理,既可以运行在命令行,也可以写入脚本文件再执行。shell 脚本语言的代码文件以 .sh 结尾:

  • 可以通过执行解释器执行:bash test.sh
  • 或者直接使用当前命令行的解释器执行:相对路径执行 ./test.sh 或绝对路径 /home/test/test.sh(需为脚本添加可执行权限 chmod +x test.sh

在 shell 脚本文件中,需要在第一行添加如下内容,指定解释器,如下指定 bash shell 为解释器:

1
2
3
#!/bin/bash
# 或者, env 命令(这个命令总是在 /usr/bin 目录),返回 Bash 可执行文件的位置
#!/usr/bin/env bash

shell 中注释为 #

2. 使用命令

在 shell 命令行中,可以输入cat、echo、grep等命令(这些命令本质是一个可执行文件),去执行对应的程序。

同样,在 shell 脚本中,也可以使用命令,包括内部命令外部命令(在我看来 shell 语言中的命令就相当于其他语言中的库函数)。比如:

1
2
#!/bin/bash
echo "hello world"

同时还包括:

  • 管道 |
  • 重定向 < >
  • 命令结束符 ; 。使用:Command1 ; Command2 允许单行多个命令,第二个命令总是接着第一个命令执行,不管第一个命令执行成功或失败。
  • 命令组合符 && || 。使用:Command1 && Command2 第一个命令运行成功,才继续运行第二个命令;Command1 || Command2 第一个命令运行失败,才继续运行第二个命令。

更多 Linux 命令可以参见我的另一篇笔记:linux命令学习笔记

3. 变量

定义变量

1
2
3
var=value
var='value'
var="value"

变量名:由字母、数字和下划线字符组成;首字符必须是字母下划线,不能是数字。

变量值:没有数据类型的概念,都是字符串,如果值中包含空格,需使用引号包围。

注意:赋值符号附近不能有空格。

同时,可以将命令执行的结果赋给变量

1
2
var=`command`
var=$(command) # 更推荐这种表示方式

也可以将运算结果赋给变量

1
var=$((5 * 7))

使用变量

两种方式:

1
2
$var
${var} # 更推荐这种表示方式

花括号 {} 用于区分变量边界。比如:在如下代码中,不使用花括号,会把 varScript 当成变量。

1
2
var="Java"
echo "I am good at $varScript" # 错误

如果变量值包含连续空格或制表符,使用变量时应用双引号 "" 包围起来,因为 Shell 会将多个空格合为一个:

1
2
3
var="1      2  3"
echo $var # 1 2 3
echo "$var" # 1 2 3

注意:当使用单引号 '' 将包围变量时,变量将不会解析,而是会被当成普通字符串

1
2
3
4
var="1      2  3"
echo $var # 1 2 3
echo "$var" # 1 2 3
echo '$var' # $var

修改变量值

重新赋值即可:

1
2
3
4
var="hello world" # hello world
echo $var
var="hello world!!!" # hello world!!!
echo $var

删除变量

1
2
3
4
var="hello world"
echo $var # hello world
unset var
echo $var #

shell 中不存在的变量一律等于空字符串,所以即使unset命令删除了变量,还是可以读取这个变量(值为空字符串)。而且,被删除的变量可再次使用。

环境变量

用户创建的变量仅用于当前 Shell,子 Shell (在当前shell中运行的shell)默认读取不到父 Shell 定义的变量。

使用 export 命令可以设置变量为环境变量,使子 shell 可以读取该变量。

测试脚本 test.sh 如下:

1
2
3
4
#!/bin/bash
echo $test_export
echo $test_noexport
export test_export2="export"

运行:

1
2
3
4
5
6
7
8
9
$ export test_export="export" # 设为环境变量,子 shell 可读
$ test_noexport="no export"
$ ./test.sh
export


$ echo $test_export2 # 显然,父 shell 也读不到子 shell export 的变量


注意:子 Shell 如果修改环境变量,不会影响父 Shell 。

常用的环境变量有:

  • HOME:用户的主目录。
  • HOST:当前主机的名称。
  • PATH:由冒号分开的目录列表,当输入可执行程序名后,会搜索这个目录列表。
  • PWD:当前工作目录。
  • USER:当前用户的用户名。
  • LINENO:返回它在脚本中的行号。
  • FUNCNAME:返回一个数组,内容是当前的函数调用堆栈。该数组的0号成员是当前调用的函数,1号成员是调用当前函数的函数。
  • BASH_SOURCE:返回一个数组,内容是当前的脚本调用堆栈。该数组的0号成员是当前执行的脚本,1号成员是调用当前脚本的脚本

只读变量

readonly 命令指示变量只读,不可修改。

1
2
3
readonly var="hello"
var="hello world" # var: readonly variable
echo $var # hello

变量默认值

  • ${var:-word} 如果变量 var 为空或已被删除(unset),那么返回 word,但不改变 var 的值。
  • ${var:=word} 如果变量 var 为空或已被删除(unset),那么返回 word,并将 var 的值设置为 word。
  • ${var:?message} 如果变量 var 为空或已被删除(unset),那么将消息 message 送到标准错误输出,并将脚本停止运行,可以用来检测变量 var 是否可以被正常赋值。
  • ${var:+word} 如果变量 var 被定义,那么返回 word,但不改变 var 的值。

特殊变量

  • $0 当前脚本的文件名。
  • $n 传递给脚本或函数的参数,$1 表示第一个参数,$2 表示第二个参数。
  • $# 传递给脚本或函数的参数个数。
  • $* 传递给脚本或函数的所有参数。
  • $@ 传递给脚本或函数的所有参数。被双引号 "" 包含时,$* 会将所有参数作为一个整体,而 $@ 会分开。
  • $? 上个命令的退出状态,或函数的返回值。
  • $$ 当前Shell进程ID。对于 Shell 脚本,就是这些脚本所在的进程ID。

4. 字符串

字符串是 shell 最基本的数据类型。

拼接字符串(推荐使用 {} ):

1
2
3
str="world"
echo "hello $str!" # hello world!
echo "hello ${str}!" # hello world!

获取字符串长度(变量使用必须要加 {} ):

1
2
str="hello"
echo ${#str} # 5

提取字符串(offset 默认为0,length 默认到结尾):

1
${str:offset:length}

字符转义:

  • 经典的转义字符 \n \t 转义;
  • 对于 shell 中的特殊字符,如 $ * & 等,需要转义;
  • 使用单引号 '' 时,转义字符都会被当成普通字符串

字符串匹配并删除:

  • ${str#pattern} 从字符串首字符开始,删除最短匹配的部分,返回剩余字符串。pattern 支持 *?[] 等通配符。

  • ${str##pattern} 从字符串首字符开始,删除最长匹配(贪婪匹配)的部分,返回剩余字符串。

    1
    2
    3
    4
    5
    6
    str=/home/root/shell/study
    str=${str#/*/} # root/shell/study
    echo $str
    echo ${str##/*/} # root/shell/study
    str=/home/root/shell/study
    echo ${str##/*/} # study
  • ${str%pattern} 从字符串尾字符开始,删除最短匹配的部分,返回剩余字符串。

  • ${str%%pattern} 从字符串尾字符开始,删除最长匹配的部分,返回剩余字符串。

更高级的匹配建议使用:grepawk

5. 数组

定义数组

1
arr=(value0 value1 value2 value3)

1
2
3
4
5
6
arr=(
value0
value1
value2
value3
)

或单独定义(可以不使用连续的下标,而且下标的范围没有限制):

1
2
3
arr[0]=value0
arr[1]=value1
arr[3]=value3

追加

使用 += 可追加元素:

1
2
3
4
arr=(a b c d)
echo $arr # a b c d
arr+=(e f)
echo ${arr[@]} # a b c d e f

使用数组

单个元素:

1
value=${arr[i]}

全部元素:

1
2
${arr[*]}
${arr[@]}

注意

  • 默认 ${arr} = ${arr[0]} 而非全部元素。
  • ${arr[@]} "${arr[@]}" ${arr[*]} "${arr[*]}" 有不同效果,详情见:读取所有成员推荐使用 "${arr[@]}"

多个元素:

1
${arr[@]:offset:length}

获取数组长度:

1
2
${#arr[*]}
${#arr[@]}

获知数组哪个位置上有值,即获取数组中存在值的元素的索引(提取数组索引):

1
2
3
4
unset arr
arr[1]=a
arr[3]=b
echo ${!arr[@]} # 1 3

6. 运算表达式

语法:使用 (( )) 包裹,或者使用 expr 命令。更推荐前一种。

获取表达式的结果:$(( ))

在表达式中可以使用变量,且**不需要加$**。若变量为空,则当作 0 。

在表达式中,可以使用进制:默认十进制、0num 八进制、0xnum 十六进制、base#num base进制

算术运算

  • + 加法
  • - 减法
  • * 乘法
  • / 除法(整除)
  • % 余数
  • ** 指数
  • ++ 自增运算(前缀或后缀)
  • -- 自减运算(前缀或后缀)
1
2
3
i=0
echo $((++i)) # 1
echo $(((1+2) * 3)) # 9

位运算

与 C 语言一致:

  • << 左移
  • >> 右移
  • &
  • |
  • ~ 按位取反
  • ^ 异或
1
echo $((16>>2)) # 4

逻辑运算

与 C 语言一致:

  • < 小于
  • > 大于
  • <= 小于或相等
  • >= 大于或相等
  • == 相等
  • != 不相等
  • && 逻辑与
  • || 逻辑或
  • ! 逻辑否
  • expr1 ? expr2 : expr3 三元条件运算

如果逻辑表达式为真,返回1,否则返回0:

1
echo $((3 > 2)) # 1

赋值运算

支持直接赋值 = ,也支持 += *= |= 等等。

1
2
i=1
echo $((i+=1)) # 2

7. 条件判断 if

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if commands
then
commands
[elif commands
then
commands...]
[else
commands]
fi

# 或

if commands; then
commands
[elif commands; then
commands...]
[else
commands]
fi

if 后面所接的判断条件是一个命令,命令返回成功(0)则为真,返回失败(非0)则为假。

test

if 判断条件通常使用 test 命令,它是一个用于判断的命令,它有三种形式:

1
2
3
4
5
6
7
8
# 写法一
test expr

# 写法二
[ expr ]

# 写法三
[[ expr ]]

注意

  • 中括号 [ ] 与表达式之间必须包含空格
  • 第二种形式与第三种形式,在某些场景(比如逻辑判断)有所不同,详情参考:https://www.zsythink.net/archives/2252

由于 test 是一个命令,它支持很多选项:

1) 文件判断

  • [ -a $file ]:如果 file 存在,则为true。
  • [ -b $file ]:如果 file 存在并且是一个块(设备)文件,则为true。
  • [ -c $file ]:如果 file 存在并且是一个字符(设备)文件,则为true。
  • [ -d $file ]:如果 file 存在并且是一个目录,则为true。
  • [ -e $file ]:如果 file 存在,则为true。
  • [ -f $file ]:如果 file 存在并且是一个普通文件,则为true。

更多见:条件判断

2) 字符串判断

  • [ $str ]:如果str不为空(长度大于0),则判断为真。
  • [ -n $str ]:如果字符串str的长度大于零,则判断为真。
  • [ -z $str ]:如果字符串str的长度为零,则判断为真。
  • [ $str1 = $str2 ]:如果str1和str2相同,则判断为真。
  • [ $str1 == $str2 ]:等同于[ $str1 = $str2 ]。
  • [ $str1 != $str2 ]:如果str1和str2不相同,则判断为真。
  • [ $str1 '>' $str2 ]:如果按照字典顺序str1排列在str2之后,则判断为真。
  • [ $str1 '<' $str2 ]:如果按照字典顺序str1排列在str2之前,则判断为真。

注意:test命令内部的><,必须用引号括起来(或者是用反斜杠转义),否则它们会被 shell 解释为重定向操作符。

3) 整数判断

由于 > < 会被误解为重定向操作法,所以有专门的整数判断指令。

  • [ $int1 -eq $int2 ]:如果int1等于int2,则为true。
  • [ $int1 -ne $int2 ]:如果int1不等于int2,则为true。
  • [ $int1 -le $int2 ]:如果int1小于或等于int2,则为true。
  • [ $int1 -lt $int2 ]:如果int1小于int2,则为true。
  • [ $int1 -ge $int2 ]:如果int1大于或等于int2,则为true。
  • [ $int1 -gt $int2 ]:如果int1大于int2,则为true。

4) 逻辑判断

  • [[ $expr1 && $expr1 ]] / [ $expr1 ] && [ $expr1 ]
  • [[ $expr1 || $expr1 ]] / [ $expr1 ] || [ $expr1 ]
  • [ ! $expr1 ] / [ ! \( $expr1 && $expr2 \) ]

注意:test命令内部使用()必须使用引号或转义。

运算表达式

if 判断条件也可以使用运算表达式 (( ))

但注意:运算表达式返回非0 ((1)) 表示真,返回0 ((0)) 表示假

1
2
3
4
5
6
echo $((2 > 1)) # 1
if ((2>1)); then
echo true # true
else
echo false
fi

普通命令

if 判断条件可以直接使用命令,命令返回成功(0)则为真,返回失败(非0)则为假。

当然,也可以使用管道、重定向、命令结束符;、命令组合符&& ||等。

比如:

1
2
3
if mkdir temp && cd temp; then
echo "enter in temp/"
fi

8. case

1
2
3
4
5
6
7
case expression in
pattern )
commands ;;
pattern )
commands ;;
...
esac

pattern 支持基本的模式匹配,比如:

1
2
3
4
5
6
7
8
9
echo -n "输入一个字母或数字 > "
read character
case $character in
[[:lower:]] | [[:upper:]] ) echo "输入了字母 $character"
;;
[0-9] ) echo "输入了数字 $character"
;;
* ) echo "输入不符合要求"
esac

9. 循环

while

1
2
3
while commands; do
commands
done

判断条件与 if 一样。

until

1
2
3
until commands; do
commands
done

for

遍历列表每一项:

1
2
3
for variable in ${arr[@]}; do
commands
done

或:

1
2
3
for (( expr1; expr2; expr3 )); do
commands
done

比如:

1
2
3
4
5
6
7
for ((i=0; i<10; i++)); do
echo $i
done

for i in $(seq 0 9); do
echo $i
done

continue

提前终止本轮循环,进行下一轮循环。

10. 函数

定义:

1
2
3
4
5
6
7
# 第一种
func() {
}

# 第二种
function func() {
}

调用:

1
2
func # 直接调用无参数
func param1 param2 # 传入参数

参数

$1~$9:函数的第1个到第9个的参数。
$0:函数所在的脚本名。
$#:函数的参数总数。
$@:函数的全部参数,参数之间使用空格分隔。
$*:函数的全部参数,参数之间使用变量$IFS值的第一个字符分隔,默认为空格,但是可以自定义。

return

函数返回,可指定返回值,调用者通过 $? 获取。

local 局部变量

shell 中定义变量属于全局变量,在函数中声明局部变量需使用 local ,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func1() {
foo1=1
echo $foo1 # 1
}
func1
echo $foo1 # 1

func2() {
local foo2
foo2=2
echo $foo2 # 2
}
func2
echo $foo2 # 空

其它

主要介绍在脚本中使用较多,而在命令行中使用较少的命令。

set

命令行下不带任何参数,直接运行 set ,会显示所有的环境变量和 Shell 函数

常用选项:

  • set -u 遇到不存在的变量则报错(默认会跳过)
  • set -x 在运行命令前,先输出该命令,常用于调试。set -x 开启,set +x 关闭。
  • set -e 遇到错误则终止执行(默认命令执行出错会忽略)。set -e 有一个例外情况,就是不适用于管道命令(多个子命令通过管道符组合,Bash 会把最后一个子命令的返回值,作为整个命令的返回值)。
  • set -o pipefail 用来解决这种情况,只要一个子命令失败,整个管道命令就失败,脚本就会终止执行。

使用示例:

1
2
# set -x # 调试时再开启
set -euo pipefail

注意

  • 使用 set -e 后,如果调用函数,函数返回了非零值,程序也会退出

read

1
read [-options] [var...]

输入由回车结束,用户输入将被保存到变量 var ,多个输入项通过空格区分。

若未提供变量名,环境变量 REPLY 会包含用户输入的一整行数据。

若提供的输入项少于变量数目,则剩余变量为空。

常用选项:

  • p 指定提示信息。
1
2
read -p "Enter your input:"
echo $REPLY
  • a 把用户的输入赋值给一个数组,从零号位置开始。
1
2
read -a arr
echo ${arr[@]}
  • n 指定只读取若干个字符作为变量值,而不是整行读取。
1
2
read -n 3 var
echo $var

read 还可用于读文件:

1
2
3
4
filename='xxx'
while read line; do
echo "$line"
done < $filename

exit

用于退出当前执行的 Shell ,并返回一个值,返回 0 代表成功,返回 非0 代表失败。

source

用于执行一个脚本文件,但不同于直接执行(会新建子 shell ),source 会在当前 shell 执行。

类似于加载外部库。