本文主要是记录学习 Shell 编程时所记录的一些笔记。
Shell 脚本的格式
在 Linux 中有很多命令,每一条命令都可以完成某种特定的任务,这也是 UNIX 的设计哲学:一条命令只做一件事。因此为了组合命令以及便于多次执行,可以使用脚本文件来保存需要执行的命令。
创建一个以 .sh
为后缀的文件,我们可以在其中编写很多指令。如果执行的是刚编写好的脚本文件,那么在执行前还需要为其赋予可执行权限 chmod u+rx filename
。
说到 Shell 脚本执行方式,有下面几种:
bash ./filename.sh
./filename.sh
source ./filename.sh
.filename.sh
在 1、2 中,会创建一个名叫 bash 的子进程,Shell 脚本就会运行在该子进程中,两者的区别就在于 2 需要在脚本开头使用 #!/bin/bash
来指定使用 bash,而方式 1 在执行时就明确需要使用 bash 了。方式 3、4 则是在当前进程中执行 Shell 脚本。
如何理解前两种和后两种执行方式的区别呢?比如在 Shell 脚本中存在切换目录的操作 cd
,在前两种方式中,在脚本执行完成后,当前的路径是不会被改变的,因为是使用另一个进程执行的该脚本,而后两种方式在脚本执行完成后,当前路径就会被改变,因为它就是在当前进程下执行的。
有时候我们会发现 Shell 脚本的第一行往往都是 #!/bin/bash
,写这一行代码有什么作用呢?或者说有什么好处呢?
简而言之,在第一行开头使用 #!
可以在文件中指定脚本的解析方式,在使用 bash filename.sh
来执行 Shell 时,就是直接指定 bash 作为解析方式,文件内以 #
开头的部分都会被当作注释。而在使用 ./filename.sh
来执行脚本文件时,会使用系统自带的 Shell 来解析脚本文件,遇到以 #!
开头的行时就会被当成非注释,因此在开头写上 #!/bin/bash
就表示需要使用 bash 来解析脚本文件。换句话说,如果第一行写的是,#!/usr/bin/python
或者 #!/usr/bin/python3
就表示该文件需要使用 Python 来解析,即使文件后缀为 .sh
。
管道与重定向
- 管道:进程通信的主要工具,在 Shell 中是为了方便两条命令能够进行通信
- 重定向:将标准输出输出到指定的位置
管道
因为在 Shell 编程中,管道是没有名字的,所以也叫匿名管道或者管道符。管道符是 |
,其作用就是将前一个命令执行的结果传递给后面的命令。比如,echo 123 | cat
。
值得注意的是,管道会为两条命令创建一个子进程来进行通信,所以在使用 cd
、pwd
等内部命令时,当前的进程是得不到相应的结果的。
重定向
一个进程默认会打开标准输入、标准输出、错误输出三个文件描述符
- 输入重定向
<
比如read var < /path/filename
- 输出重定向
>
、>>
、2>
、&>
比如echo 123 > /path/filename
>
清空文件内容并输入>>
在文件末尾追加2>
当命令执行错误时,会将错误信息输出到指定文件&>
无论命令执行成功与否,都会将信息输出到指定文件中
变量
定义 变量的命名与大多数编程语言的区别相差不大,都是由字母、数字以及下划线组成且不以数字开头。
变量的赋值
变量名=变量值
(=
两边不允许出现空格,因为出现空格后,变量名会被当作命令,因此会报错)a=123
- 使用 let 为变量赋值
let a=10+20
- 将命令赋值给变量
l=ls
- 将命令结果赋值给变量,使用
$()
或者''
letc=$(ls -l /etc)
- 变量值有空格等特殊字符可以包含在
""
或''
中
变量的引用
定义完变量后就需要使用它,使用方式为 ${变量名}
,当然如果变量在引用时不会引发歧义的话,是可以省略掉 {}
的,什么叫不会引发歧义呢?
1 | s1='hello' |
此外,还有一种使用场景,如果我需要使用的变量为空值,是否可以使用别的值来代替空值,而当变量被被复赋值后,又可以直接输出变量值而非控制?有一种才做叫做变量替换,形式为 变量=${引用变量-替换值}
1 | # 设定 path 为文件名,如果为空则输出下划线 _ |
变量的作用范围
变量的默认作用范围是只在当前的进程当中,也就是说,别的进程想要引用另外一个进程的变量是取不到的。
这就需要用到之前提及过的 Shell 脚本的几种解析方式了,如果我们想在脚本中使用该进程中设置的其他变量,就需要使用到 source filename.sh
或者 . filename.sh
,因为这样脚本就会运行在当前进程下。
- 那如果子进程也想访问父进程中的变量,该如何实现呢?
可以使用 export 关键字,通过
export 变量
的方式,让别的进程也可以放到这一变量。 - 如果想要删除设置过的变量,又该如何实现呢?
可以使用 unset 关键字,通过
unset 变量
来删除变量。
系统环境的变量
系统的环境变量都为大写字母加下划线的组合,要查看有哪些环境变量可以使用命令 env | more
或者 set | more
来查看,使用 set
可以查看更多的命令,这里还是用了管道加 more
指令的方式来分页查看命令
常见的系统变量,比如有 PATH
,当我们要使用非系统自带的指令时,就需要将可执行命令的路劲追加到 PATH
中,也就是使用冒号 :
来分割 PATH=$PATH:新的命令路径
,并记得用 export
来到处 PATH
,这样在使用时,系统就可以找到需要使用的命令。
除了系统变量外,还有一些预定义变量
$?
:用来表示上一条命令是否成功执行,成功为 0, 失败为 1$$
:用来查看当前进程的 PID,在对脚本的运行状态以及监测时会用到$0
:用来查看当前进程的名称
此外,还有用于为脚本传递参数的位置变量,比如 $1
、$2
、$3
、$4
、$5
、$6
、$7
、$8
、$9
、${10}
…… ……
它们分别用来代替第一个脚本参数以及之后的参数,这样我们在编写 Shell 脚本时就可以很方便的指代传进来的参数。
环境变量的配置
环境变量的配置文件有很多个
/etc/profile
保存系统或终端启动时的运行环境,在登录情况下,会被第一个加载~/.bash_profile
/etc/profile.d/
这是一个目录,根据不同的 Shell 版本会执行下面不同的脚本~/.bashrc
/etc/bashrc
简单区别,在 etc
目录下的配置文件,是所有用户通用的;而其他跟在 ~
后的配置文件,表示在家(home)目录下,当前用户特有的配置文件。此外,除了目录的不同,还区分了 profile
和 bashrc
这几种配置文件,这是因为登录当前用户的情况分为 loggin shell
和 no login shell
,也就是说对于 login shell
会使用当前的所有配置文件,而对于 no login shell
则会只使用 bashrc
的文件。
各个配置文件在加载时也分先后,相同的变量,后加载的会覆盖掉先加载的。在登陆状态下,配置文件的加载顺序为
/etc/profile
~/.bash_profile
~/.bashrc
/etc/bashrc
而在非登录的情况下,配置文件是加载不完全的,其加载顺序如下:
~/.bashrc
/etc/bashrc
值得注意的是,这些配置文件都是在每次打开终端或者 Shell 时会去加载,因此如果新添加了环境变量是不会立即生效的,所以要使得环境变量立即生效可以使用 source
来重新加载添加了新环境变量的的配置文件。
数组
定义数组
1
+ **显示数组的所有元素** ```echo ${array[@]}
显示数组元素个数
${#array[@]}``` 1
+ **显示数组的第一个元素** ```echo ${array[0]}
运算符
赋值运算符
=
赋值运算符,用于算数赋值和字符串复制- 使用
unset
取消变量的复制 =
除了作为复制运算符还可以作为测试操作符
算数运算符
使用 expr
来进行计算 expr 4 + 5
,运算符两边要加空格,否则无法计算,当然除了基本运算,expr 还支持其它操作,更多操作可以使用 man expr
来查看。
注意 expr
只支持整数运算,而不支持浮点数运算
如果要将运算结果赋值给变量,可以使用反引号 `, 比如1
num=`expr 4 + 5 `
数字常量
数字常量的使用方法
let "变量名 = 变量值"
- 变量值以
0
开头为八进制 - 变量值以
0x
开头为十六进制
双圆括号
双圆括号是 let 命令的简化
(( a = 10 ))
(( a++ ))
echo $(( 10 + 20 ))
测试和判断
测试命令 test
,可以用于检查文件或者比较值,可以做以下测试:
- 文件测试
- 整数测试
- 字符串测试
test
命令的具体操作可以使用命令行输入 man test
来查看。常用的参数有一下几种
-n STRING
判断字符串长度是否是非零(nonzero)-z STRING
判断字符串长度是否为零(zero)INTEGER -eq INTERGER
等于判断(equal),字符串的等于判断可以用=
INTEGER -ge INTERGER
大于等于判断(greater than or equal)INTEGER -gt INTERGER
大于判断(greater than)INTEGER -le INTERGER
小于等于判断(less than or equal)INTEGER -lt INTERGER
小于判断(less than)INTEGER -ne INTERGER
不等于判断(not equal),字符串的不等于判断可以用!=
-d FILE
判断FILE是否为目录(directory)-e FILE
判断FILE是否存在(exist)-f FILE
判断FILE是否为文件(file)-r FILE
判断FILE是否可读(readable)-s FILE
判断FILE大小(size)是否大于 0-w FILE
判断FILE是否可写(writable)-x FILE
判断FILE是否可执行(executable)
test
测试语句可以简化为 []
符号,[]
符号还有扩展写法 [[]]
支持 &&
、||
、<
、>
根据之前提到的,在命令行中,如果要测试上一条语句是否成功,还可以使用 echo $?
来判断结果(0 为成功,非零为失败)。
分支
if 判断
if-then 语句的基本用法
1 | if [ 测试条件 ] 或 命令返回值是否为 0 |
有时候也可以写成一行,但是要注意使用 ;
分隔
1 | if [ 测试条件 ] 或 命令返回值是否为 0; then 执行相应命令; fi |
此外还有 if-then-else 语句可以在条件不成功的情况下也运行相应的命令
1 | if [ 测试条件 ] 或 命令返回值是否为 0 |
如果还需要多个 if 判断,就可以使用 if-elif-else 语句
1 | if [ 测试条件成立 ]; then |
而对于复杂的判断,可能还需要使用嵌套的 if 语句
1 | if [ 测试条件成立 ] |
case 分支
对于条件判断有很多的情况,可以使用 case 语句和 select 语句来构成分支
1 | case "$变量" in |
*
作为通配符,在 case 分支中的作用类似与其他语言 switch 的 defaul 语句。如果要判断的变量是符合两种情况之一即可执行命令,那么可以在情况判断中使用 |
来分隔多个情况。
循环
for 循环遍历命令的执行结果
for 循环的语法
1 | for 参数 in 列表 |
使用反引号或 $()
方式执行命令,命令的结果当作列表进行处理,列表可以使用 {}
来产生,比如 {1..9}
表示 1 到 9 的列表。
列表中若有多个变量,需用空格分隔,在对文本进行处理时,需要使用文本查看命令去除文本内容(文本处理默认逐行处理,如果文本出现空格会当作多行处理)。
当前目录下,含有三个文件 a.mp3 b.mp3 c.mp3,现需要修改文件后缀为 mp4
1 | for filename in `ls *.mp3` |
C 语言风格的 for 命令
1 | for (( 变量初始化; 循环判断条件; 变量变化)) |
while 循环
1 | while test测试是否成立 |
如果测试条件始终成立就会形成死循环,有时候也可以不写条件而用 :
来代替,表示什么都不做始终为真。
1 | while : |
死循环可以用来构建命令菜单等操作。
循环可以嵌套使用,当想中止循环时,可以使用 break 来提前中止;当想跳过本次循环时,可以使用 cotinue 来跳过本次循环。
until 循环
until 循环与 while 循环相反,循环测试为假时,执行循环,为真时循环终止
1 | until test测试是否成立 |
使用循环对命令参数的处理
之前提到过命令参数可以使用 $1
、$1
…… ${10}
…… $n
进行读取,而 $0
表示的是脚本名称。
此外,$*
和 $@
代表所有的位置参数,可以用于遍历命令参数;$#
代表位置参数的数量,也可以用于使用 C 语言风格 for 来遍历位置参数。
1 | for pos in $* |
在遍历命令参数时,还可以使用 shift 命令来对关键字参数列表进行左移,这样每次左移都会把第一个位置的参数去掉,用后一个参数补上该位置。这在使用 while 循环来遍历位置参数时,比较有用。
1 | while [ $# -ge 1 ] |
函数
1 | function fname() { |
这里也可以省略 function
,直接 函数名() { }
的方式来定义函数。
如果要声明一些函数作用范围内的变量,可以使用 local 变量名
的方式,这样可以避免影响到函数外面的变量的使用。
值得注意的是,()
不写参数类型,要往函数中传递参数可以直接在使用时跟在函数名后面,而要调用函数参数就可以使用位置参数 $1
、$1
…… ${10}
…… $n
此外,函数也可以具有返回值,使用 return
来返回特定值。
值得注意的是,当把函数写进 Shell 脚本文件后,在运行时应该使用 source
或者 .
来运行脚本,否则当前进程
是找不到刚写的函数的。
计划任务
平时我们编写的脚本都是手动去运行,但有时候也需要设定在某一时刻去运行脚本,这时人为的去运行往往不太合适,这时候就需要提到计划任务了。
一次性计划任务
一次性任务需要使用 at
命令。
在命令行中输入 at 时间
就可以进入设定一次性任务的交互界面(如果需要查看当前时间可以提前输入 date
命令来查看),比如输入 at 21:30
就表示要在晚上九点半执行接下来你要输入的指令,在输入完要计划定时执行的任务后,还需要通过 ctrl + D
来进行提交。如果要查看有多少计划任务,可以输入 atq
命令来查看。
值得注意的是,计划任务是不会出现终端的,所以如果包含标准输出的话,需要重定向到文件中,以便到时进行查看。
周期性计划任务
周期性计划任务需要使用 crontab
命令。
要设置周期性计划任务还需要加一个参数,即输入 crontab -e
来设置,输入该命令后即进入配置页面,配置周日任务的格式为 分钟 小时 日期 月份 星期 要执行的命令
,比如
在 4 月 15 日早上 6 点 30 分执行任务(将日期追加到日期文件里)
1 | 30 6 15 4 * /usr/bin/date >> /tmp/date.txt |
每周一到周五的晚上 9 点整执行任务
1 | 0 21 * * 1-5 /usr/bin/date >> /tmp/date.txt |
值得注意的是,因为最小时间单位为 分钟
,所以,如果在 分钟
的位置为 *
就表示每分钟都要执行该任务,如果要表示整点,那么在 分钟
的位置要为 0。此外,使用 数字-数字
表示连续的时刻,数字,数字
表示分隔的两个时刻。
每一个用户都可以设置自己的周期任务,可以在 /var/spool/cron/{用户名}
中查看到。我们也可以使用 crontab -l
来查看现有的计划任务。
在执行周期性计划任务时,有时候会出现以外的情况,比如设定了下午三点半的任务,但是任务开始前服务器意外宕机,而设定时间过了之后才得以重启服务器,那么此时周期任务就无法按时执行了。此时就会延时运行之前的任务,延时任务的配置文件路径为 /etc/anacrontab
,一般情况下不需要更改。
计划任务加锁 flock
有时候,会在某一时刻执行多个任务,比如因为延时上一个备份任务与这一时刻的备份任务同时启动,假如都对同一份数据进行备份,此时为了备份的完整性,会对要备份的文件或者数据上锁,在同一时间只能允许一个程序去访问(排他锁),使用命令
1 | flock -xn "/tmp/f.lock" -c "{要上锁的文件路径}" |
文本搜索
文本内容的过滤(查找) grep
grep 命令的使用方法
1 | grep 匹配模板 文件路径 |
匹配模板就可以理解为正则表达式,关于正则表达式的使用可以另寻查找。
文件的查找命令 find
find 命令的使用方法
1 | find 路径 查找条件 [补充条件] |
比如要查找 /etc 下所有文件名中含有 passwd 的文件
1 | find /etc -name passwd |
或者使用正则来查找是文件类型且含有 pass 的文件
1 | find /etc -type f -regex pass.* |
更多使用方式可以使用 man find
来查看。
行编辑器介绍:sed 和 awk
Vim 我们通常称为全文本编辑器,而 sed 和 awk 都是对行进行编辑,所以称为行编辑器
sed 用法
sed 一般用于对文本内容做替换,其基本工作方式为:将文件以行为单位读取到内存(模式空间),使用 sed 的每个脚本对该行进行操作,处理完成后输出该行。
sed 的替换命令 s
:
sed 's/old/new/ filename
sed -e 's/old/new/' -e 's/old/new/' filename
-e 可以连续进行替换,也可以用分号分隔sed 's/old/new/;s/old/new/' filename
sed -i 's/old/new/' 's/old/new' filename
-i 是将修改内容写回到源文件,默认是输出修改但不改变源文件
带正则表达式的替换命令 s
:
sed 's/正则表达式/new/' filename
sed -r 's/扩展正则表达式/new/' filename
有时候需要匹配 /
,但是 /
太多会出现错误,可以使用其他符号代替分隔符,比如 @
、!
等,即 sed 's!/!new!' filename
。
在扩展正则表达式中还有一种概念叫做分组,即用圆括号 ()
来框住要匹配的内容,如果要取得匹配到的分组内容,可以这么用 sed -r 's/(匹配分组1)|(匹配分组2)/\1\2/'
,\1
、\2
分别表示分组的序号。
替换命令 s 的加强版
标志位
s/old/new/标志位
,通过设定标志位来加强搜索功能
- 全局替换:
sed 's/old/new/g' filename
,其中g
就表示全局替换,用于替换所有出现的次数 - 替换第n次匹配:
sed 's/old/new/数字' filename
,如果数字为 2 则表示只替换第 2 次的匹配 - 打印替换成功的内容
sed 's/old/new/p' filename
,具体的意思就是如果匹配到了某一行,不仅会将改行的内容进行替换,还会在下一行中打印输出(看着就有两行相同的) - 阻止默认输出
sed -n 's/old/new/p' filename
,如果标志位不为p
,则不会有输出内容,如果修改为p
,则只会将匹配并修改成功的行输出 - 将替换成功的内容写入到文件
sed 's/old/new/w output_file' filename
,在标志位w
后空一格在写上输出文件,注意这只输出匹配并替换成功的内容,其他内容是不会被输出的
寻址
sed 命令还有寻址的操作,可以找到特定行后,再对行内内容进行查询与替换
sed '/正则表达式/s/old/new/g' filename
,比如只查询以数字开头的行sed '/^[0-9]/s/old/new' filename
sed '行号s/old/new/g' filename
,行号可以是具体的行,也可以是最后一行$
,或者指定多行'1,3s/old/new'
(只查询 1 到 3 行)
可以混合使用行号和正则地址
分组
在匹配到某行后,对行内的内容进行多种替换
/正则表达式/{s/old/new;s/old/new}
sed 脚本文件
如果匹配规则很长,也可以是将匹配内容保存为文件,使用 -f
加载脚本文件
sed -f sedscript filename
sed 的其它命令
- 删除命令 d:
sed '/regex/d' filename
将匹配到的行删除,在执行删除命令后,后面跟着的其它命令都不会被执行- 相较于替换命令 s 的
sed 's/old//' filename
,这只是去掉了匹配的内容,但是该行还是保留了
- 相较于替换命令 s 的
- 追加命令 a:
sed '/regex/a 追加内容' filename
在匹配到的行之下追加内容- 读取文件内容并追加 r
sed '/regex/r input_file' filename
- 读取文件内容并追加 r
- 插入命令 i:
sed '/regex/i 插入内容' filename
在匹配到的行之上插入内容 - 更改命令 c:
sed '/regex/c 更改内容' filename
将匹配到的行整个更改 - 打印行号 =:
sed '/regex/=' filename
- 对匹配到的行的下一行进行操作 n:
sed '/regex/new/n' filename
(比如只想对奇数行或偶数行进行操作时) - 将匹配到的行进行输出 p:
sed '/regex/p' filename
- 相较于替换命令 s 的
sed 's/old/new/p' filename
,这是打印替换后的内容,而这次是将匹配到的内容输出
- 相较于替换命令 s 的
- 退出命令 q:
sed 行号q filename
,当读到某一行时退出- 另一种写法
sed -n 1,10p filename
,这种方式效率没上面高,因为这要把所有行都处理一遍但只输出前十行,而上一种方法只读到前十行就结束了
- 另一种写法
awk 用法
awk 一般用于对“比较规范”的文本进行处理,用于统计数量并输出指定字段、按需要的格式进行输出,更像是脚本语言。
使用 sed 将不规范的文本,处理为“比较规范”的文本。
awk 脚本的流程控制
- 输入数据前例程 BEGIN{}
- 主输入循环 {}
- 所有文件读取完成例程 END{}
awk 的字段引用和分离
每一行称作 awk 的记录;使用空格、制表符分隔开的单词称作字段(可以自己指定分隔的字段)
字段的引用
- awk 中使用
$1 $2 ... $n
表示每一个字段,而$0
表示一整行:awk '{print $1, $2, $3}' filename
- awk 可以使用 -F 选项改变字段分隔符:
awk -F '分隔符' '{print $1, $2, $3}' filename
(分隔符可以使用正则表达式) - awk 对符合要求的特定行(利用正则表达式)做处理:
awk '/regex/{print $1, $2, $3}' filename
awk 的系统变量
- FS 和 OFS 字段分隔符,OFS 表示输出的字段分隔符
- 指定字段分隔符:
awk 'BEGIN{FS=":"}{print $1} filename
,指定:
为分隔符,与awk -F ':' '{print $1, $2, $3}' filename
的作用一样 - 输出时指定字段的分隔符:
awk 'BEGIN{FS=":";OFS="-"}{print $1 $2} filename
,源文件以:
分隔字段,输出时再以-
连接字段
- 指定字段分隔符:
- RS 记录分隔符
- 输出时以记录分隔符来分行:
awk 'BEGIN{RS=":"}{print $0} filename
- 输出时以记录分隔符来分行:
- NR 和 FNR 行数(区别体现在多文件输出上)
- 在多文件输出上 NR 会把多个文件整合然后从 1 直到末尾:
awk '{print NR, $0}' filename
- 而 FNR 则会对每个文件都从 1 开始计数:
awk '{print FNR, $0}' filename
- 在多文件输出上 NR 会把多个文件整合然后从 1 直到末尾:
- NF 字符按数量,最后一个字段内容可以用 $NF 取出
- 输出记录的字段个数:
awk '{print NF}' filename
- 直接输出每行记录的最后一个字段:
awk '{print $NF}' filename
- 输出记录的字段个数:
awk 判断和循环
正如之前是所说,awk 就像一个脚本语言,它也包含一些类似于其他语言的赋值操作符、算术操作符、关系操作符、布尔操作符等。
条件语句
条件语句使用 if 开头,格式如下
1 | if (表达式) |
类似于 C 语言的 if-else 语句,如果有多个语句需要执行则需要用 {}
将多个语句括起来。
循环语句
while 循环
1 | while(表达式) |
有多个 awk 语句时,需要用 {}
将多个语句括起来。
do-while 循环
1 | do { |
for 循环
1 | for(初始值; 循环判断条件; 累加) |
有多个 awk 语句时,需要用 {}
将多个语句括起来。
在对循环的边界进行判断时,对于记录长度,可以使用之前讲过的系统变量 NF。
类似的,循环语句也与 C 语言的相似,而且同样支持 break 和 continue。
awk 数组
awk 数组的定义:数组名[下标]=值
,下标可以使用数字也可以使用字符串。
在对数组进行遍历的方法如下:
1 | for(变量 in 数组名) |
如果要删除数组的值,可以使用 delete 数组[下标]
的方式。
有时候 awk 语句很长或者说会经常被使用,那么可以将 awk 语句写入以 .awk
结尾的文件中,使用时就可以利用 awk -f backup.awk filename
awk 还有命令行参数数组,这也与 C 语言的参数数组类似:
- ARGC:表示命令行参数长度
- ARGV:表示命令行参数数组
不过值得注意的时,ARGV[0] 为命令名称,从 ARGV[1] 往后才是命令参数;此外,在处理脚本的时候,是在读入文件之前就要进行处理,所以需要在 BEGIN{}
进行使用。
因此,结合文件和命令参数,就可以更方便的使用 awk:awk -f backup.awk 参数1 参数2
。