Shell 脚本
参考书籍:《Linux C一站式编程》
注:由于书比较老,部分内容可能已过时。
1. Shell 的历史
Shell 的作用是解释执行用户的命令,用户输入一条命令,Shell就解释执行一条,这种方式称为交互式(Interactive),Shell还有一种执行命令的方式称为批处理(Batch),用户事先写一个Shell脚本(Script),其中有很多条命令,让Shell一次把这些命令执行完,而不必一条一条地敲命令。Shell脚本和编程语言很相似,也有变量和流程控制语句,但Shell脚本是解释执行的,不需要编译,Shell程序从脚本中一行一行读取并执行这些命令,相当于一个用户把脚本中的命令一行一行敲到Shell提示符下执行。
由于历史原因,UNIX系统上有很多种Shell:
sh
(Bourne Shell):由Steve Bourne开发,各种UNIX系统都配有sh
。csh
(C Shell):由Bill Joy开发,随BSD UNIX发布,它的流程控制语句很像C语言,支持很多Bourne Shell所不支持的功能:作业控制,命令历史,命令行编辑。ksh
(Korn Shell):由David Korn开发,向后兼容sh
的功能,并且添加了csh
引入的新功能,是目前很多UNIX系统标准配置的Shell,在这些系统上/bin/sh
往往是指向/bin/ksh
的符号链接(现在应该已经不是了)。tcsh
(TENEX C Shell):是csh
的增强版本,引入了命令补全等功能,在FreeBSD、Mac OS X等系统上替代了csh
。bash
(Bourne Again Shell):由GNU开发的Shell,主要目标是与POSIX标准保持一致,同时兼顾对sh
的兼容,bash
从csh
和ksh
借鉴了很多功能,是各种Linux发行版标准配置的Shell,在Linux系统上/bin/sh
往往是指向/bin/bash
的符号链接[1]。虽然如此,bash
和sh
还是有很多不同的,一方面,bash
扩展了一些命令和参数,另一方面,bash
并不完全和sh
兼容,有些行为并不一致,所以bash
需要模拟sh
的行为:当我们通过sh
这个程序名启动bash
时,bash
可以假装自己是sh
,不认扩展的命令,并且行为与sh
保持一致。
补充:现在有zsh
,zsh
(或 ZShell )是Unix shell的一种,是Bourne shell(sh
)的扩展版本。它包括bash
(另一种流行的shell)的许多功能,但也具有许多增强和改进。zsh
具有强大的命令行完成系统、拼写纠正和许多其他高级功能。
文件/etc/shells
给出了系统中所有已知(不一定已安装)的Shell,除了上面提到的Shell之外还有很多变种。
1 | cat /etc/shells |
用户的默认Shell设置在/etc/passwd
文件中,以我的账户为例:
1 | cat /etc/passwd | grep guoxb |
用户从字符终端登录或者打开图形终端窗口时就会自动执行/bin/bash
。如果要切换到其他Shell,可以在命令行输入程序名,例如:
1 | sh(在bash提示符下输入sh命令) |
本文只介绍bash
和sh
的用法和相关语法,不介绍其他Shell(基本用法和语法是相似的)。所以下文提到的Shell都是指bash
或sh
。
[1]:最新的发行版有一些变化,例如我现在使用的 Ubuntu 20.04 的/bin/sh
是指向/bin/dash
的符号链接,dash
也是一种类似bash
的shell。
1 | ls -al /bin/sh /bin/dash |
2. Shell 如何执行命令
2.1 执行交互式命令
用户在命令行输入命令后,一般情况下Shell会fork
并exec
该命令,但是Shell的内建命令例外,执行内建命令相当于调用Shell进程中的一个函数,并不创建新的进程。像cd
、alias
、umask
、exit
等命令即是内建命令,凡是用which
命令查不到程序文件所在位置的命令都是内建命令,内建命令没有单独的man手册,要在man手册中查看内建命令,应该使用以下命令:
1 | man bash-builtins |
本节会介绍很多内建命令,如export
、shift
、if
、eval
、[
、for
、while
等等。内建命令虽然不创建新的进程,但也会有Exit Status,通常也用 0 表示成功非零表示失败,虽然内建命令不创建新的进程,但执行结束后也会有一个状态码,也可以用特殊变量$?
读出。
2.2 执行脚本
首先编写一个简单的脚本,保存为script.sh
:
1 | ! /bin/sh |
Shell 脚本中用#
表示注释,相当于C语言的//
注释。但如果#
位于第一行开头,并且是#!
(称为Shebang[2])则例外,他表示该脚本使用后面指定的解释器/bin/sh
解释执行。如果把这个脚本文件加上可执行权限然后执行:
1 | chmod +x script.sh |
Shell 会fork
一个子进程并调用exec
执行./script.sh
这个程序,exec
系统调用应该把子进程的代码段替换成./script.sh
程序的代码段,并从它的_start
开始执行。然后script.sh
是个文本文件,根本没有代码段和_start
函数,怎么办呢?其实exec
还有另外一种机制,如果要执行的是一个文本文件,并且第一行用Shebang指定了解释器,则用解释器程序的代码段替换当前进程,并且从解释器的_start
开始执行,而这个文本文件被当作命令参数传给解释器,因此,执行上述脚本相当于执行程序:
1 | /bin/sh ./script.sh |
以这种方式执行不需要script.sh
文件具有可执行权限。再举个例子,比如某个sed
脚本的文件名是script
,它的开头是:
1 | ! /bin/sed -f |
执行./script
相当于执行程序
1 | /bin/sed -f ./script.sh |
以上介绍了两种执行Shell脚本的方法:
1 | ./script.sh |
这两种方法本质上是一样的,执行上述脚本的步骤为:
- 交互式Shell(bash)
fork/exec
一个子Shell(sh)用于执行脚本,父进程bash
等待子进程sh
终止。 sh
读取脚本中的cd ..
命令,调用相应的函数执行内建命令,改变当前工作目录为上一级目录。sh
读取脚本中的ls
命令,fork/exec
这个程序,列出当前工作目录下的文件,sh
等待ls
终止。ls
终止后,sh
继续执行,读到脚本文件末尾,sh
终止。sh
终止后,bash
继续执行,打印提示符等待用户输入。
如果将命令行输入的命令用()括号括起来,那么也会fork
出一个子Shell执行小括号中的命令,一行中可以输入由分号;
隔开的多个命令,比如:
1 | (cd ..;ls) |
和上面两种方法执行Shell脚本的效果是相同的,cd ..
命令改变的是子Shell的PWD
,而不会影响到交互式Shell。
如果将上述命令去掉括号后执行,则有不同的效果,cd ..
命令是直接在交互式Shell下执行的,改变交互式Shell的PWD
。
1 | cd ..;ls |
然而这种方式相当于这样执行Shell脚本:
1 | source ./script.sh |
或者
1 | . ./script.sh |
source
或者.
命令是Shell的内建命令,这种方式也不会创建子Shell,而是直接在交互式Shell下逐行执行脚本中的命令。
[2]:”Shebang”这个术语的起源并不十分明确,但据说它可能来自于Unix历史上的一个命令行工具,叫做”shell bang”(或者”sh-bang”)。在早期的Unix系统中,”!”被称为”bang”,而”shell”则是指命令解释器。因此,”shell bang”可以解释为”命令解释器的bang”,也就是”shebang”。
另外一种说法是,”shebang”这个术语可能来自于”sharp”(井号)和”bang”(感叹号)两个符号的组合。在Unix系统中,”#”是注释的起始符号,而”!”是”bang”的符号。因此,”#!”可以理解为”sharp-bang”,也就是”shebang”。
无论是哪种说法,”shebang”都成为了Unix系统中指定脚本解释器的标准方式,并且在现代的Unix和类Unix系统中被广泛使用。
3. Shell 的基本语法
3.1 变量
按照惯例,Shell 变量由全大写字母加下划线组成,有两种类型的Shell变量:环境变量和本地变量。
环境变量
详见《进程基础知识》中的环境变量部分,环境变量可以从父进程传给子进程,因此Shell进程的环境变量可以从当前Shell传递给
fork
出来的子进程。使用printenv
命令可以显示当前Shell进程的环境变量。
1 | printenv |
本地变量
只存在于当前的Shell进程,用
set
命令可以显示当前Shell进程中定义的所有变量(包括本地变量和环境变量)和函数。
1 | set |
环境变量是任何进程都有的概念,而本地变量是Shell独有的概念。在Shell中,环境变量和本地变量的定义和用法相似。在Shell中定义或赋值一个变量:
1 | VARNAME=value |
注意:等号 = 两边都不能有空格,否则会被Shell解释成命令和命令行参数。
一个变量定义后仅存在于当前的Shell进程,它是本地变量。用export
命令可以把本地变量导出为环境变量,定义和导出环境变量通常可以一步完成:
1 | export VARNAME=value |
也可以分两步:
1 | VARNAME=value |
使用unset
命令可以删除已定义的环境变量或本地变量。
1 | unset VARNAME |
需要注意的是:在定义变量时,在变量名前不用加$
,取变量值时要在变量名前加$
。与C语言不同,Shell 变量不需要明确声明变量类型,实际上,Shell 变量的值都是字符串,比如我们定义VAR=123
,其实VAR
的值是字符串123
而非整数。同时,Shell 变量不需要先定义后使用,如果对一个没有定义的变量取值,则值为空字符串。
$VARNAME与${VARNAME}的区别:
如果一个变量名为VARNAME
,用${VARNAME}
可以表示它的值,在不引起歧义的情况下,也可以用$VARNAME
表示它的值。在shell中,$VARNAME
和${VARNAME}
都是用来引用变量的方式,但在一些情况下,他们的方式会有些不同。
$VARNAME
是一种简单直接的变量引用方式,它用于直接替换变量的值。例如:1
2echo $SHELL
/bin/bash${VARNAME}
是一种更加复杂的变量引用方式,它可以用于对变量进行一些处理。例如,可以在变量名后加上一些字符串来得到一个新的字符串。{}
可以明确变量名的范围,避免变量名与其他字符相连时产生歧义。1
2
3
4echo ${SHELL}
/bin/bash
echo ${SHELL}abc
/bin/bashabc
以下例子展示了这两种表示法的不同:
1 | echo $SHELL |
3.2 文件名代换(Globbing)
Globbing 是一种通配符扩展机制,它是 Unix/Linux 系统中的一种特性,用于匹配文件名或路径名中的通配符。通配符是一种模式匹配语法,用于匹配一个或多个字符,以便查找符合指定模式的文件或路径名。
常见的通配符包括以下三种:
通配符 | 功能 |
---|---|
* | 匹配 0 个或多个任意字符 |
? | 匹配 1 个任意字符 |
[···] | 匹配方括号中任意一个字符的一次出现 |
举个栗子:假设当前目录下有三个文件,文件目录树如下:
1 | tree . |
使用
*
匹配任意字符或字符组合1
2ls *.txt
file1.txt file2.txt使用
?
匹配任意单个字符1
2ls file?.txt
file1.txt file2.txt使用
[]
匹配方括号中的任意一个字符或字符范围:1
2ls file[23].*
file2.txt file3.log
注意:Globbing所匹配的文件名是由Shell展开的,也就是说在参数还没传给程序之前就已经展开了,比如上述例子中的ls file?.txt
命令,实际上传给ls
命令的参数是 file1.txt 和 file2.txt 这两个文件名,而不是一个匹配字符串。
3.3 命令代换
在Shell脚本中,命令代换是一种将命令的输出作为参数传递给其他命令或者变量的方法。
命令代换的语法有两种:$()
或者(`)
使用反引号(`)的语法举例:
1
2
3DATE=`date`
echo $DATE
Wed 19 Apr 2023 03:37:49 PM CST使用
$()
的语法举例:1
2
3DATE=$(date)
echo $DATE
Wed 19 Apr 2023 03:39:09 PM CST
需要注意的是:命令代换会将命令的输出作为一个字符串处理,因此对于一些需求需要进行适当的转换和处理。此外,$()
和反引号(`)的使用是等价的,但是为了可读性和已于维护,推荐使用$()
进行命令代换。
3.4 算术代换
在Shell 脚本中,算术代换是一种将算术表达式的结果作为参数传递给其他命令或者变量的方法。
算术代换的语法使用$(())
,$(())
中的Shell变量取值将转换成整数。
1 | result=$((expression)) |
其中,”expression” 是要计算的算数表达式,计算结果会被赋值给 “result” 变量。
举个栗子:
1 | VAR=45 |
除了加法运算外,算数代换还支持减法、乘法、除法、求余等基本的算术运算,以及逻辑运算,位运算等高级运算。
1 | 求商和余数 |
需要注意的是:算数代换只能用于整数运算,不能用于浮点数运算。如果要进行浮点数运算,需要使用其他工具或者编程语言实现。
3.5 转移字符 \
和C语言类似,在Shell 脚本中,\
被用作转义字符,用于将特殊字符转义为普通字符(回车除外),换句话说,紧跟其后的字符取字面值。
举个栗子:
1 | echo $SHELL |
注意:转义字符只对其后面的一个字符生效,如果要转义多个字符,需要使用多个转义字符。
比如创建一个文件名为 “$ $” 的文件可以这样:
1 | touch \$\ \$ |
还有一个字符虽然不具备特殊含义,但是要用它做文件名也很麻烦,这个字符就是-
号。如果要创建一个以-
开头的文件,这样是不行的:
1 | touch -file |
我们即使加上\
转义也还是会报错:
1 | touch \-file |
这是因为各种UNIX命令都会把-
号开头的命令行参数当作命令的选项,而不会当作文件名。
如果我们非要处理以-号开头的文件名,有如下两种办法:
1 | touch ./-file |
1 | touch -- -file |
\
还有一种用法,在\
后边敲回车表示续行,Shell并不会立即执行命令,而是把光标移到下一行,给出一个续行提示符>
,等待用户的继续输入,最后把所有的续行接到一起当作一个命令执行。
1 | ls \ |
3.6 单引号
和C语言不同,Shell 脚本中的单引号和双引号一样都是字符串的界定符。
单引号('
)用于定义一个单引号字符串,即一个不支持变量替换和命令替换的字符串。
单引号字符串中的所有特殊字符都会被视为普通字符,换句话说就是,单引号用于保持引号内所有字符的字面值,即使引号内的\
和回车也不例外,但是字符串中不能出现单引号。如果引号没有配对就输入回车,Shell 会给出续行提示符>
,要求用户把引号配上对。
1 | echo '$SHELL' |
需要注意的是:单引号字符串只适用于纯文本字符串,并且在单引号字符串里无法插入单引号本身,如果需要插入单引号或者需要进行变量替换或者命令替换,则需要使用双引号字符串。
3.7 双引号
在Shell 脚本中,双引号("
)用于定义一个双引号字符串,即一个支持变量替换和命令替换的字符串。
双引号用于保持引号内所有字符的字面值(包括回车),但是以下情况除外:
$
加变量名可以取变量的值$()
和(`)可以表示命令替换\$
表示$
的字面值\
+反引号表示`` `的字面值\"
表示"
的字面值\\
表示\
的字面值
除了上述情况之外,在其他字符前面的\
无特殊含义,只表示字面值
1 | echo "$SHELL" |
4. bash 启动脚本
启动脚本(startup script)是bash
启动时自动执行的脚本文件。这些脚本文件通常包含了一些用户自定义的配置、别名、环境变量等信息,以便在每次打开终端时都能够自动加载这些信息,从而减少了手动设置的工作量。
用户可以把一些环境变量的设置和alias
[3]、umask
[4]设置放在启动脚本中,这样每次启动Shell时这些设置都会自动生效。思考一下,bash
在执行启动脚本时是以fork
子Shell方式执行还是以source
方式执行的?(答:source
方式)
Bash启动脚本分为全局和局部两种。全局启动脚本的文件名通常是以/etc
目录下的bash.bashrc
或bashrc
文件为名,而局部启动脚本的文件名通常是以用户主目录下的.bashrc
文件为名。具体来说:
/etc/bash.bashrc
:全局启动脚本,适用于所有用户。在Bash shell启动时,会自动执行该文件中的所有命令。/etc/bashrc
:全局启动脚本,适用于所有用户。在Bash shell启动时,会自动执行该文件中的所有命令。/etc/profile
:全局启动脚本,适用于所有用户。在用户登录Shell会读取并执行/etc/profile
文件中的命令和设置,以便在每次登录时自动加载一些系统级别的配置、别名、环境变量等信息。~/.bashrc
:局部启动脚本,适用于当前用户。在Bash shell启动时,会自动执行该文件中的所有命令。~/.bash_profile
:局部启动脚本,适用于当前用户。在用户登录时,会自动执行该文件中的所有命令。通常在该文件中设置一些用户自定义的环境变量和别名等信息。~/.bash_login
:局部启动脚本,适用于当前用户。在用户登录时,如果不存在~/.bash_profile
文件,则会自动执行该文件中的所有命令。~/.profile
:局部启动脚本,适用于当前用户。在用户登录时,如果不存在~/.bash_profile
和~/.bash_login
文件,则会自动执行该文件中的所有命令。
需要注意的是,不同的Linux/Unix发行版可能有不同的Bash启动脚本文件命名和路径,具体的命名和路径可以查看相应的文档或手册。
启动bash
的方法不同,执行启动脚本的步骤也不相同,具体可分为以下几个情况。
4.1 作为交互登录 Shell 启动,或者使用 –login 参数启动
交互Shell 是指用户在提示符下输命令的 Shel l而非执行脚本的 Shell ,登录Shell 就是在输入用户名和密码登录后得到的 Shell ,比如从字符终端登录或者用telnet/ssh
从远程登录,但是从图形界面的窗口管理器登录之后会显示桌面而不会产生登录Shell(也就不会执行启动脚本),在图像界面下打开终端窗口得到的 Shell 也不是登录Shell。
这样启动bash
会自动执行以下脚本:
- 首先执行
/etc/profile
,系统中每个用户登录时都要执行这个脚本。如果系统管理员希望某个设置对所有用户都生效,可以写在这个脚本里。 - 然后依次查找当前用户主目录的
/.bash_profile
、/.bash_login
、/.profile
三个文件,找到第一个存在并且可读的文件来执行,如果希望某个设置只对当前用户生效,可以写到这个脚本里。由于这个脚本在/etc/profile
之后执行,/etc/profile
设置的一些环境变量的值在这个脚本中可以修改,也就是说,当前用户的设置可以覆盖(Override)系统中全局的设置。/.profile
这个启动脚本是sh
规定的,bash
规定首先查找以/.bash_
开头的启动脚本,如果没有则执行/.profile
,是为了和sh
保持一致。 - 顺便一提,在退出登录时,会执行
/.bash_logout
脚本(如果它存在的话)
4.2 以交互非登录Shell启动
比如在图形界面下开一个终端窗口,或者在登录Shell提示符下再输入bash
命令,就可以得到一个交互非登录的Shell ,这种 Shell 在启动时自动执行/.bashrc
脚本。
为了方便使登录Shell 也能自动执行/.bashrc
,通常在/.bash_profile
中调用/.bashrc
:
1 | if [ -f ~/.bashrc ]; then |
这几行的意思是,如果/.bashrc
文件存在则source
它。多数Linux发行版在创建账户时会自动创建/.bash_profile
和/.bashrc
脚本,/.bash_profile
中通常都有上面几行。所以,如果要在启动脚本中做某些设置,使它在图形终端窗口和字符终端的Shell中都起作用,最好就是在/.bashrc
中设置。
为什么登录Shell 和非登录Shell 的启动脚本要区分开呢?最初的设计是这样考虑的,如果从字符终端或者远程登录,那么登录Shell 是该用户的所有其它进程的父进程,也是其它子Shell 的父进程,所以环境变量在登录Shell 的启动脚本里设置一次就可以自动带到其它非登录Shell 里,而 Shell 的本地变量、函数、alias
等设置没有办法带到 子Shell 里,需要每次启动非登录Shell 时设置一遍,所以就需要有非登录Shell 的启动脚本,所以一般来说在~/.bash_profile
里设置环境变量,在~/.bashrc
里设置本地变量、函数、alias
等。如果你的Linux带有图形系统则不能这样设置,由于从图形界面的窗口管理器登录并不会产生登录Shell,所以环境变量也应该在~/.bashrc
里设置。
4.3 非交互启动
为执行脚本而fork
出来的子Shell 是非交互式Shell,启动时执行的脚本文件由环境变量BASH_ENV
定义,相当于自动执行以下命令:
1 | if [ -n "$BASH_ENV" ]; then . "$BASH_ENV"; fi |
如果环境变量BASH_ENV
不是空字符串,则把它的值当作启动脚本的文件名,source
这个脚本。
需要注意的是,在非交互式Shell中,启动脚本的执行仅限于BASH_ENV
变量指定的脚本文件。如果没有设置BASH_ENV
变量,或者指定的脚本文件不存在或无法访问,Shell将不会执行任何启动脚本或配置文件。
4.4 以 sh 命令启动
如果以sh
命令启动bash
,bash
将模拟sh
的行为,以/.bash_
开头的那些启动脚本就不认了。所以,如果作为交互登录Shell启动,或者通过--login
参数启动,则依次执行以下脚本:
/etc/profile
/.profile
如果作为交互Shell启动,相当于自动执行以下命令:
1 | if [ -n "$ENV" ]; then . "$ENV"; fi |
如果作为交互式Shell启动,则不需要执行任何启动脚本。通常我们写的Shell脚本都以#! /bin/sh
开头,都属于这种方式。
[3]: alias
是用来给命令设置别名的。通过alias
我们可以将一个命令用一个简短的别名来代替。举个例子:我们可以将ls -l
命令设置成ll
,这样每次输入ll
就相当于执行了命令ls -l
。
1 | alias ll='ls -l' |
[4]: umask
在Linux或Unix操作系统中,是一个用于限定默认权限的特殊权限掩码。当新建一个文件或者目录时,系统会根据 umask 的值来限制文件或目录的默认权限(即通过 umask 值来计算初始权限)。umask 的值是一个四位八进制数,表示需要屏蔽掉的权限位,其中第 1 个数代表的是文件所具有的特殊权限(SetUID、SetGID、Sticky BIT)。二进制形式的 umask 值的每一位代表一种权限,1 表示该权限被屏蔽,0 表示该权限不被屏蔽。可以通过系统的umask
命令来查看当前用户的 umask 值(root用户默认为 0022 ,普通用户默认为0002):
1 | echo "$(whoami) 的umask值是 $(umask)" |
在Linux系统中,文件和目录的最大默认权限是不一样的:
- 文件:可拥有的最大默认权限是 666 ,即
rw-rw-rw-
,x
是文件的最大权限,新建文件的时候是不会赋予的,只能通过用户手工赋予。 - 目录:可拥有的最大默认权限是 777 ,即
rwxrwxrwx
。
需要注意,这的最大默认权限 ≠ 新建文件时的初始权限。文件和目录的初始权限是计算得出的,具体计算方法如下:
文件(目录)的初始权限 = 文件(目录)的最大默认权限 (bitwise)AND umask值
举个栗子:
文件:假如 umask 值为 0022,要创建一个新文件,其默认权限是 666(即owner、group和others都有读、写权限)。那么计算新文件的实际权限的过程如下:
- 将 umask 值转换为二进制:0022 = 000 010 010
- 将默认权限转换为二进制: 666 = 110 110 110
- 将 umask 值与默认权限进行按位与操作,得到实际权限 644 = 110 100 100 。即owner拥有读写权限,group和others只有读权限。
1
2
3
40022: 000 010 010
& 666: 110 110 110
--------------------
110 100 100 = 6441
2
3root@ubuntu:~/gxb# umask; touch newfile; ll newfile
0022
-rw-r--r-- 1 root root 0 Apr 19 19:52 newfile目录:同文件,假如 umask 值为 0022,要创建一个新目录,其默认权限是 777(即owner、group和others都有读、写、执行权限)。那么计算新目录的实际权限的过程如下:
- 将 umask 值转换为二进制:0022 = 000 010 010
- 将默认权限转换为二进制: 777 = 111 111 111
- 将 umask 值与默认权限进行按位与操作,得到实际权限:755 = 111 101 101 。即owner拥有读写执行权限,group和others只有读和执行权限。
1
2
3
40022: 000 010 010
& 777: 111 111 111
--------------------
111 101 101 = 7551
2
3root@ubuntu:~/gxb# umask; mkdir newdir; ll | grep newdir
0022
drwxr-xr-x 2 root root 4096 Apr 19 19:53 newdir/
5. Shell 脚本语法
5.1 条件测试:test | [
命令test
或[
可以测试一个条件是否成立,如果测试结果为真,则该命令的 Exit Status 为0,如果测试结果为假,则命令 Exit Status为1(注意与C语言的逻辑表示正好相反)。例如测试两个数的大小关系:
1 | VAR=2 |
这里需要注意,[]
内首位处记得加空格,不然会出错,就像下边这样:
1 | [$VAR -gt 3] |
原因就是因为,左方括号[
其实是一个命令(虽然这看起来很奇怪),传给命令的个参数之间应该用空格隔开。比如上边的命令[ $VAR -gt 3 ]
中,[
是命令,而$VAR
、-gt
、3
、]
则是[
命令的四个参数,他们之间必须用空格隔开。
命令test
和[
的参数形式是相同的,只不过test
命令不需要]
参数。以[
为例,常见的测试命令如下表所示:
测试命令 | 含义 |
---|---|
[ -d DIR ] |
如果DIR存在并且是一个目录则为真 |
[ -f FILE ] |
如果FILE存在并且是一个文件则为真 |
[ -z STRING ] |
如果STRING的长度为零则为真 |
[ -n STRING ] |
如果STRING的长度非零则为真 |
[ STRING1 = STRING2 ] |
如果两个字符串相同则为真 |
[ STRING1 != STRING2 ] |
如果两个字符串不相同则为真 |
[ ARG1 OP ARG2 ] |
ARG1和ARG2应该是整数或者取值为整数的变量,OP是-eq (等于)、-ne (不等于)、lt (小于)、le (小于等于)、gt (大于)、ge (大于等于) 之中的一个 |
和C语言相似,测试条件之间还可以做与、或、非逻辑运算:
测试命令 | 含义 |
---|---|
[ ! EXPR ] |
EXPR即expression(表达式),可以是上表中的任意一种测试条件,!表示逻辑反 |
[ EXPR1 -a EXPR2 ] |
EXPR1和EXPR2可以是上表中的任意一种测试条件,-a 表示逻辑与 |
[ EXPR1 -o EXPR2 ] |
EXPR1和EXPR2可以是上表种的任意一种测试条件,-o 表示逻辑或 |
举个例子:
1 | VAR=abc |
注意:这里EXPR指的测试条件是 -d Desktop
这种不带方括号的,因为这里的左方括号[
是命令,右方括号]
是参数,所以不能进行简单的嵌套,比如:
1 | [ -d Desktop -a [ $VAR = 'abc' ] ] |
这样的错误用法原因就是,错误的把[]
当作一个整体,类似于()
,而没有理解[
和]
其实是分开的命令和参数。
此外还需注意:如果上例中的$VAR
变量没有事先定义,则会被Shell展开为空字符串,会造成测试条件的语法错误(展开后为[ -d Desktop -a = 'abc' ]
),作为一种好的Shell编程习惯,应该总是把变量取值放在双引号之中(展开后为[ -d Desktop -a "" = 'abc' ]
):
1 | unset VAR |
5.2 if/then/elif/else/fi
和C语言类似,在Shell中使用if
、then
、elif
、else
、fi
这几条命令实现分支控制。这种流程控制语句本质上也是由若干条 Shell命令组成的,例如先前讲过的
1 | if [ -f ~/.bashrc ]; then |
这其实是三条命令,if [ -f ~/.bashrc ]
是第一条,then . ~/.bashrc
是第二条,fi
是第三条。如果两条命令写在同一行则需要用;
隔开,一行只写一条命令就不需要写;
号了,另外,then
后边有换行,但这条命令没写完,Shell会自动续行,把下一行接在then
后面当作一条命令处理。和[
命令一样,要注意命令和各参数之间必须用空格隔开。if
命令的参数组成一条子命令,如果该子命令的 Exit Status 为 0 (表示真),则执行then
后面的子命令,如果 Exit Status 非 0 (表示假),则执行elif
、else
或者fi
后面的子命令。if
后面的子命令通常是测试命令,但也可以是其他命令。Shell脚本没有{}
括号,所以用fi
表示if
语句块的结束。比如下面的例子:
1 | ! /bin/sh |
最后一行中的:
是一个特殊的命令,称为空命令,该命令不做任何事,但 Exit Status 总是真。此外,也可以执行/bin/true
或/bin/false
得到真或假的 Exit Status。
再来看一个例子:
1 | ! /bin/sh |
上面的例子中,read
命令的作用就是等待用户输入一行字符串,将该字符串存在一个Shell变量中。
此外,Shell 还提供了&&
和||
语法,和C语言类似,具有 Short-circuit特性,很多脚本喜欢写成这样:
1 | test "$(whoami)" != 'root' && (echo you are using a non-privileged account; exit 1) |
这条Shell命令由两个命令组成,分别是测试命令test "$(whoami)" != 'root'
和一个包含两个命令的命令组(echo you using a non-privileged account; exit 1)
。这条命令的作用是:检查当前用户是否为 root 用户,如果不是,则输出一条提示信息,并退出脚本。
&&
相当于if ... then ...
,而||
相当于if not ... then ...
。&&
和||
用于连接两个命令,而上面讲的-a
和-o
仅用于测试表达式中连接两个测试条件,要注意他们的区别,例如:
1 | test "$VAR" -gt 1 -a "$VAR" -lt 3 |
和以下写法是等价的
1 | test "$VAR" -gt 1 && test "$VAR" -lt 3 |
5.3 case/esac
case
命令可类比C语言的switch/case
语句,esac
表示case
语句块的结束。C语言的case
只能匹配整形和字符型常量表达式,而Shell脚本的case
可以匹配字符串和 Wildcard(通配符)[5],每个匹配分支可以有若干条命令,末尾必须以;;
结束,执行时找到第一个匹配的分支并执行相应的命令,然后直接跳到esac
之后,不需要像C语言一样用break
跳出。
1 | ! /bin/sh |
使用case
语句的例子可以在系统服务的脚本目录/etc/init.d
中找到。这个目录下的脚本大多具有这种形式(以/etc/apache2
):
1 | case $1 in |
启动 apache2 服务的命令是
1 | sudo /etc/init.d/apache2 start |
其中,$1
是一个特殊变量,在执行脚本时自动取值为第一个命令行参数,也就是 start ,所以进入start)
分支执行相关的命令。同理,命令行参数指定为stop
、reload
或restart
可以进入其他分支执行停止服务、重新加载配置文件或重新启动服务的相关命令。
5.4 for/do/done
Shell 脚本的for
循环结构和C语言很不一样,它类似于某些编程语言的foreach
循环。其实如果学过python,会比较好理解,跟python中的for循环比较相似。
举个例子:
1 | ! /bin/sh |
FRUIT
是一个循环变量,第一次循环$FRUIT
的取值是apple
,第二次取值是banana
,第三次取值是pear
。再比如,要将当前目录下的chap0
、chap1
、chap2
等文件名改为chap0~
、chap1~
、chap2~
等(按照惯例,末尾有~
字符的文件名表示临时文件),这个命令可以这样写:
1 | for FILENAME in chap?; do mv $FILENAME $FILENAME~; done |
注意:这里的chap?
是匹配当前目录文件下的文件,如果前边加上了路径,比如下面的例子:
1 | tree . |
1 | for FILENAME in ./file?.txt; do echo $FILENAME ; done |
如果加上了路径的话,比如./
,那FILENAME
变量的值内就会包含路径,从输出我们可以看到,这里执行完这条命令后,$FILENAME
的值为./file2.txt
。
5.5 while/do/done
while
的用法和C语言类似。比如下面这个例子,这是脚本的功能是验证密码:
1 | ! /bin/sh |
下面的例子通过算术运算控制循环的次数:
1 | ! /bin/sh |
Shell还有until
循环,类似C语言的do...while
循环。
5.6 位置参数和特殊变量
有很多特殊变量是被Shell自动赋值的,我们已经遇到了$?
和$1
,现在总结一下常见的位置参数和特殊变量:
位置参数和特殊变量 | 含义 |
---|---|
$0 |
相当于C语言main 的argv[0] |
$1 、$2 … |
这些称为位置参数(Positional Parameter),相当于C语言main 函数的argv[1] 、argv[2] … |
$# |
相当于C语言main 函数的argc - 1 ,即传递给脚本的参数个数,注意这里的#后边不表示注释 |
$@ |
表示参数列表$1 、$2 …,例如可以用在for 循环中的in 后面。 |
$? |
上一条命令的 Exit Status |
$$ |
当前Shell的进程号 |
位置参数可以用shift
命令左移。比如shift 3
表示原来的$4
现在变成$1
,原来的$5
现在变成$2
等等,原来的$1
、$2
、$3
丢弃,$0
不移动。不带参数的shift
命令相当于shift 1
。例如:
1 | ! /bin/sh |
下面的例子演示了如何使用 shift
命令结合 $#
变量来处理命令行参数
1 | !/bin/bash |
1 | ./process_args.sh arg1 arg2 arg3 |
5.7 函数
和C语言类似,Shell中也有函数的概念,但是函数定义中没有返回值也没有参数列表。例如:
1 | ! /bin/sh |
注意,函数体的左花括号{
和后面的命令之间必须有空格或换行,如果将最后一条命令和右花括号}
写在同一行,命令末尾必须有;
号。
在定义foo()
函数时并不执行函数体中的命令,就像定义变量一样,只是给foo
这个名字一个定义,到后面调用foo
函数的时候(注意Shell中的函数调用不写括号)才执行函数体中的命令。Shell脚本中的函数必须先定义后调用,一般把函数的定义写在脚本的前面,把函数调用和其他命令写在脚本的最后。
Shell函数没有参数列表并不表示不能传参数,事实上,函数就像是迷你脚本,调用函数时可以传任意个参数,在函数内同样是用$0
、$1
、$2
等变量来提取参数,函数中的位置参数相当于函数的局部变量,改变这些变量并不会影响函数外边的$0
、$1
、$2
等变量。函数中可以用return
命令返回,如果return
后边跟一个数字则表示函数的 Exit Status。
下面这个脚本可以一次创建多个目录,各目录名通过命令行参数传入,脚本逐个测试各目录是否存在,如果目录不存在,首先打印信息,然后试着创建该目录。
1 | ! /bin/sh |
注意:在shell脚本中,/dev/null
是一个特殊的设备文件,它会将所有写入它的数据都丢弃掉,相当于一个黑洞。2>&1
是一个重定向语法,它会将标准错误输出(文件描述符2)重定向到标准输出(文件描述符1)。
因此,/dev/null 2>&1
的意思是将输出和错误都重定向到/dev/null
,也就是将所有输出和错误都丢弃掉,不显示或记录任何输出和错误信息。这通常用于在shell脚本中禁止输出和错误信息。
[5]:Wildcard,通配符。Wildcard和Globbing的区别:在shell脚本中,Wildcard和Globbing都是用来匹配文件名的通配符,但它们的实现机制和使用方式略有不同,具体如下:
Wildcard是指在shell命令中使用的通配符,例如
*
、?
、[ ]
等符号,它们可以匹配文件名中的任意字符。Wildcard是在shell命令执行前由shell解释器进行展开的,展开后的结果是一组符合条件的文件名列表。例如,ls *.txt
命令会展开为所有以.txt
结尾的文件名列表。Wildcard只能用于匹配文件名,不能用于匹配目录名。Globbing是指在shell中使用的一种类似正则表达式的模式匹配技术。Globbing使用的通配符包括
*
、?
、[ ]
、{ }
等符号,它们可以匹配任意字符串、单个字符、字符集合、多个模式等。Globbing也是在shell命令执行前由shell解释器进行展开的,展开后的结果是一组符合条件的字符串列表,可以用于匹配文件名、目录名、环境变量名等各种字符串。例如,echo *.txt
命令会展开为所有以.txt
结尾的文件名列表。
因此,Wildcard和Globbing都是用于匹配文件名的通配符,但Wildcard只能用于匹配文件名,而Globbing可以用于匹配各种字符串。另外,Wildcard是一种简单的通配符,只能匹配固定的字符集合,而Globbing是一种更复杂的模式匹配技术,可以匹配更灵活的模式。
6. Shell 脚本的调试方法
Shell 提供了一些用于调试脚本的选项,如下所示:
-n
:读一边脚本中的命令,但不执行,用于检查脚本中的语法错误。
-v
:一边执行脚本,一边将执行过的脚本命令打印到标准错误输出。
-x
:提供跟踪执行信息,将执行的每一条命令和结果依次打印出来。
使用这些选项由三种方法:
在命令行中提供参数
1
sh -x(/n/v) ./script.sh
在脚本开头提供参数
1
! /bin/sh -x(/n/v)
在脚本中用
set
命令启动或禁用参数1
2
3
4
5
6
7! /bin/sh
if [ -z "$1" ]; then
set -x(/n/v)
echo "ERROR: Insufficient Args."
exit 1
set +x(/n/v)
fiset -x
和set +x
分别表示启动和禁用-x
参数,这样可以只对脚本中的某一段进行跟踪调试。