“失眠,是明日心事的序章。”
Bash 脚本:浅浅入下 Bash 编程
前言
其实关于 Linux 脚本,不同的人叫法不同,有的叫Bash,也有的叫Shell
具体区别会简单说一下,不过大家本质都一样,都是通过 Linux 命令行执行一连串指令
我也懒得分那么细,所以都归类到 Linux 这个 Tag 里,下面也简称Linux命令
。
这里推荐一本 Bash 编程教程的电子书,来自 Linux 中国,请放心食用:电子书:An introduction to programming with Bash
更新:因为这篇文章对 Bash 语法涉及甚少,涉及的知识都是为了实现实例的脚本而介绍的,因此又重新写了一篇介绍 Bash 的语法:Bash 脚本 DLC:Bash 语法和 URL 检测脚本实例,并且用的脚本例子更简单,算是本篇的 DLC 吧。
推荐大家把两篇 bash 的文章结合起来看捏。
Shell 和 Bash
从广义上来讲,我们可以把操作系统分为Shell(壳)
和Kernel(内核)
Kernel
包含着计算机的许多基本功能,包括但不限于管理系统进程、内存、网络等。这部分内容往往是用户/应用程序不可达、不能直接调用的。这也是考虑到操作系统的高效性与安全性,不然随便来个程序都给自己最多的内存等资源,那不是乱套了。
而Shell
则是操作系统用来沟通外界和Kernel
的桥梁,用于沟通用户和Kernel
。最早的Shell
通过命令行(CLI,Command Line Interface)实现,发展到如今的图形界面(GUI,Graphical User Interface)。因此,事实上Shell
是一种抽象概念,而 Windows 中的命令行和桌面都是一种Shell
Bash
则是Linux GNU中最常用的一种 Shell,全称为Bourne-Again Shell
,是当前大多数 Linux 发行版的默认 Shell。
其他的 shell 还有 sh、bash、ksh、rsh、csh 等。sh
全称是 Bourne Shell,源自其作者玻恩(Bourne ),Bash
则是其改进版。
在命令行界面输入echo $SHELL
可以看到当前系统所使用的Shell
root$ echo $SHELL
/bin/bash
一些 Linux 脚本知识
因为 Linux 脚本的本质就是让很多 Linux 命令一起执行,这里就不从头介绍 Linux 命令了,假设大家多多少少都会一点
这里主要放一些在脚本编写时候用到的知识或容易见到的命令。
不过一些死板的语法就不提了,比如循环啊判断啊啥的,反正例子中会涉及,自己去看看也很快的。
Shebang:注释
在 Linux 脚本开头,我们需要先写上#!/bin/bash
或 #!/usr/bin/env bash
,这就是所谓的Shebang,没怎么找到它的中文名,就这么叫着先吧。
这一段注释是用来告诉 Shell,我这个文件是一个 Linux 脚本,需要将其中的内容当作 Linux 命令解释执行。同时声明自己用的是上面解释器。
虽然不同 Shell 的Shebang不同,但都大同小异。这里我们就简单区别一下#!/bin/bash
和 #!/usr/bin/env bash
的区别。
我们知道/bin
目录下都是放的一些应用程序,而解释器就在其中,包括bash
。因此,当我们声明了#!/bin/bash
,就是为了让系统知道,要去这个地方找bash
程序作为解释器来执行当前脚本。
root:/bin$ ls | grep 'bash'
bash
bashbug
rbash
而env
也在/bin
目录下,其除了能显示环境变量外,还可以执行指令。也就是说,可以在env
后接指令,而系统会在环境变量中找到这个指令并执行。
通常,/bin
目录往往会在环境变量($PATH)
中,因此,#!/usr/bin/env bash
将bash
作为参数传给env
执行,而env
会在PATH
中查找bash
执行,碰巧bash
在/bin
目录下,而/bin
也碰巧在PATH
中,因此就可以成功解释执行脚本。
base作为参数传给env -> env在PATH中找bash -> env进入了PATH中的/bin目录 -> /bin目录中有bash -> env成功找到bash,并用其解释执行脚本
root:/bin$ env | grep PATH
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin......
虽然在大部分情况下,#!/bin/bash
和 #!/usr/bin/env bash
的写法没有差别,但是还是推荐使用 #!/usr/bin/env bash
因为#!/bin/bash
相当于以静态路径的形式规定了解释器的位置,这会导致在不同的 Linux 系统下,同一脚本可能无法正常运行(用的解释器不一样),使得脚本可移植性较差;而#!/usr/bin/env bash
不必在系统的特定位置查找命令解释器,便于在多系统间移植。因此,在不了解主机的环境时,#!/usr/bin/env bash
写法可以使开发工作快速地展开。
不过,由于#!/usr/bin/env bash
会选择使用从 $PATH
中匹配到的第一个解释器,因此,如果有人恶意伪造解释器(自己写一个假的 bash)并将其写入环境变量中位于靠前位置,系统就会选择这个假的 bash 来执行脚本,存在安全隐患。
Shell 中的变量及声明
一些常见的环境变量和特殊变量可见这里:Linux 变量
这里还有一些变量是可以在脚本内部使用的:
变量 | 作用 |
---|---|
$0 |
保存当前脚本的名字 |
$1 ~$9 |
对应脚本的第一个参数到第九个参数。 |
$# |
保存参数的总数 |
$@ |
保存全部的参数,参数之间使用空格分隔 |
$* |
保存全部的参数,参数之间使用变量$IFS 值的第一个字符分隔,默认为空格 |
如果脚本的参数多于 9 个,那么第 10 个参数可以用${10}
的形式引用,以此类推
如果命令是command -o foo bar
,那么-o
是$1
,foo
是$2
,bar
是$3
。但是如果用引号包括,则视为一个参数,比如command -o "foo bar"
的$1
是-o
,$2
则是foo bar
。
可以用循环来读取每一个参数:
for i in "$@";
do
echo "$i"
done
注意,在 Shell 脚本中最好在引用变量的时候给它加上引号,养成良好习惯 QwQ
declare 命令定义变量
如果我们想要自己定义变量,就需要用到declare
命令:declare OPTION VARIABLE=value
其中,常用的参数OPTION
如下:
参数 | 作用 |
---|---|
-a |
声明数组变量 |
-i |
声明整数变量 |
-l |
声明变量为小写字母(lower ) |
-u |
声明变量为大写字母(upper ) |
-r |
声明只读变量(常量) |
-x |
设为环境变量(declare -x 等同于export ) |
-f |
输出所有函数定义 |
-F |
输出所有函数名 |
-p |
查看变量信息 |
set 命令
set
命令在一般的 Linux 命令行操作中比较少见,但是在脚本中却经常看见。其主要是对之后执行的脚本进行一些配置。主要见到的有两种:set -e
与 set -o pipefail
set -e
:在set -e
之后出现的代码,一旦出现了返回值非零,整个脚本就会立即退出。在脚本中一些意料之外的情况,如输入参数为空或不正确之类的情况,我们就可以用exit 1
等代码退出脚本。set -o pipefail
:设置了这个选项以后,包含管道命令(用|
连接多个命令)的语句的返回值,会变成最后一个返回非零的管道命令的返回值。
文件表达式
在对文件进行读写的时候,可以用文件表达式快速判断文件是否存在,是否可读可写可执行等
表达式 | 作用 |
---|---|
-f filename |
如果 filename 存在且为常规文件,则为true |
-e filename |
如果 filename 存在(exist ),则为true |
-d filename |
如果 filename 为目录(directory ),则为true |
-L filename |
如果 filename 为符号链接,则为true |
-r filename |
如果 filename 可读,则为true |
-w filename |
如果 filename 可写,则为true |
-x filename |
如果 filename 可执行,则为true |
-s filename |
如果filename 内容长度不为 0,则为true |
-h filename |
如果filename 是软链接,则为true |
括号
刚开始接触 Shell 的时候被括号搞得头疼,各种括号的用法和用处都不同,有的括号内可以进行算术运算,有的括号两边需要加空格,这里还是总结一下的好
简单来说就是:
- shell 命令及输出用小括号
( )
,左右不留空格 - 算数运算用双小括号
(( ))
- 算数比较用单中括号
[ ]
,左右留空格 - 字符串比较用双中括号
[[ ]]
- 快速替换用花括号
{ }
,左右留空格 - 反单引号可以将其中的内容作为命令执行 ` ```
然后细节讲讲
括号 | 作用 |
---|---|
单括号() |
1. 另开命令:小括号中的内容会开启一个子 shell 独立运行 2. 执行命令并输出: a=$(command) , 等同于a=$`command` ,执行command 后将输出赋给变量a 3. 初始化数组: array=(a b c d) |
双括号(()) |
1. 省去$ 符号的算术运算:内部的字符会自动视为变量而无需$ ,((foo = a + 5)) 2. C 语言规则运算: $((exp)) ,exp 为符合 C 语言规则的运算符 / 表达式 3. 跨进制运算:二、八、十六进制运算时,输出结果转为十进制 echo $((16#5f)) 输出95 |
单中括号[ ] |
1. 字符串比较:== 和!= 2. 整数比较: 不等于 -gt ,大于 -lt ,小于 -eq ,等于 -ne 3. 数组索引:array[0] |
双中括号[[]] |
1. 允许使用模式或正则表达式:[[ "hello" == hell? ]] ,结果为true 2. 逻辑运算符:可直接使用 && ,< ,> 等操作符,单中括号则需要用字符-lt 等表示 |
大括号{} |
1. 创建匿名函数 2. 特殊替换: ${var:-string} - 若变量 var 为空,则使用变量 string, ${var:=string} - 若变量 var 为空,则将 string 赋给 var, ${var:+string} - 若变量 var 不为空,则使用变量 string, ${var:?string} - 若变量 var 为空,则输出 string 并退出脚本 |
运行脚本
当我们写完一个脚本后,需要加上脚本的路径来运行脚本,否则会报错说找不到命令:/path/script para1 para2...
比如是当前路径就很方便,直接./script para1 para2...
如果想像 Linux 命令那样不加路径,则需要把文件放到/bin
目录下,很多程序都在这个目录里,该目录包含在环境变量中,所以系统会到这里寻找我们执行的程序。
因此,比较无脑的方法就是把自己写的脚本复制到/bin
目录下,当然缺点显而易见,每次改完都要复制一遍。
好一点的方法则可以在/bin
目录里创建一个链接指向我们的脚本文件,就好像一个指针。
比如我想在/bin
目录里创建一个链接指向我当前目录的一个脚本:
root$ ln -s $PWD/script /usr/local/bin
root$ ll /usr/local/bin
total 8
drwxr-xr-x 2 root root 4096 Aug 1 16:20 ./
drwxr-xr-x 10 root root 4096 Aug 20 2021 ../
lrwxrwxrwx 1 root root 50 Mar 1 14:58 script -> /mnt/f/test/script*
这之后就可以直接用script
命令来执行我们的脚本了
不过我都懒得创建链接,还是直接./script
方便
实例:命令统计脚本
需求描述
假设我们有一个history.log
文件,里面保存了很多条历史命令(这里假设只有这么 10 条)
root$ cat history.log
ll
git
ll
sudo snap install shell2http
./hotreload
telnet -nltp
telnet localhost 8080
ps aux | grep shell
ll
cat nohup.out
我们想要写一个脚本count
,来统计命令条数、所占百分比:
root$ ./count history.log
3 27.27% ll
2 18.18% telnet
1 9.09% cat
1 9.09% git
1 9.09% grep
1 9.09% hotreload
1 9.09% ps
1 9.09% snap
步步实现
大家要明白一个道理,基本上所有脚本的实现都是一步步来的,在基础上添加修改。
除非你天赋异禀=。=
设置变量
由于我们这个命令肯定要接收一个文件名作为参数,所以我们可以先为这个参数设置一个变量
#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
cat "${FILE}"
变量名为FILE
,declare -r
表示其为只读变量,"${1:?file no found!}"
表示将第一个参数赋值给FILE
。如果没有第一个参数(那就是没有参数),则输出file no found!
并退出程序
root$ ./count
./count: line 2: 1: file no found!
初步统计命令数量
首先,我们考虑到所有命令都是采用命令 参数
的格式,我们想统计的只有命令,所以后面的参数可以扔掉。
于是我们可以用cut
命令帮我们获取空格前面的命令
root$ cat history.log | cut -d' ' -f1
ll
git
ll
sudo
./hotreload
telnet
telnet
ps
ll
cat
虽然好像获取到了命令,但是没能进行一个统计计数,于是我们可以先用sort
把相同的命令放到一起,再用uniq
计数。
这时候结果并没有根据前面的数字大小排序,所以我们要再进行以此sort
root$ cat history.log | cut -d' ' -f1 | sort | uniq -c | sort -n -r
3 ll
2 telnet
1 sudo
1 ps
1 git
1 cat
1 ./hotreload
sort
默认是对文本进行排序,即ASCII
码排序,想让其根据数字排序需要加上-n
。其默认升序,我们用-r
让其变成降序。
最后将命令放入脚本:
#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
cat "${FILE}" | cut -d' ' -f1 | sort | uniq -c | sort -n -r
处理被管道分割的命令
虽然好像还不错的样子,但是回头一看,发现其实一行可能有多个命令,他们通过管道|
来连接,比如ps aux | grep shell
。但是我们上述命令只获取了管道前面的命令。
为了能够获取管道后面的命令,我们用sed
将管道的|
替换成换行符,让其成为新的一行:sed -E -e 's/\|/\n/'
(-E
启用扩展正则表达式后|
需要被转义)
但是可能输入命令的小朋友比较呆,管道前后可能有一个或多个空格,甚至没有空格,因此正则表达式还要改一下:sed -E -e 's/ *\| */\n/'
。
最后,可能一行有很多个管道,连接了很多个命令,所以最后加个g
进行全局匹配:sed -E -e 's/ +\| +/\n/g'
而这个处理过程需要在其他命令执行之前,所以我们修改后的脚本:
#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
sed -E -e 's/ *\| */\n/g' "${FILE}" | cut -d' ' -f1 | sort | uniq -c | sort -n -r
看一下效果:
root$ ./count history.log
3 ll
2 telnet
1 sudo
1 ps
1 grep
1 git
1 cat
1 ./hotreload
在我们这个例子中,多了个grep
,那就是成功了。
处理 sudo 等非程序命令
类似sudo
,nohup
等命令并非执行某程序,比如sudo
是“以管理员身份执行某程序”,因此其后面跟着的才是我们真正要统计的命令。
所以我们还需要sed
来把这些命令给去掉:sed -e 's/^(sudo|nohub)//'
考虑到其后面可能手抖多按了空格,所以修改表达式:sed -e 's/^(sudo|nohub) +//'
然后放到脚本里。为了防止脚本过长,我们让一条语句占一行,在每行后面加个\
连接符:
#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
sed -E -e 's/ *\| */\n/g' \
-e 's/^(sudo|nohup) +//' \
"${FILE}" \
| cut -d' ' -f1 | sort | uniq -c | sort -n -r
然后运行一下:
root$ ./count history.log
3 ll
2 telnet
1 snap
1 ps
1 grep
1 git
1 cat
1 ./hotreload
可以看到sudo
没有了,变成了snap
,那就没问题。
处理含有路径的命令
有些命令含有其路径,比如./hotreload
,我们要把路径去掉,于是又需要我们的sed
了
在写命令之前,我们先写正则表达式。路径可能有很多层,但是不变的格式就是path/command
,我们需要的就是斜杠后面的命令command
,因此在斜杠前面的任何字符我都不要,所以正则表达式就是^.*/
,表示“斜杠以及斜杠前的全部字符”。
我们要把它去掉,就是替换为空,那么sed
命令可以这么写:sed -E 's#^.*/##'
(因为表达式中带有斜杠/
,因此分隔符选用#
,否则需要加上转义符变成sed -E 's/^.*\///'
,可读性也差)
#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
sed -E -e 's/ *\| */\n/g' \
-e 's/^(sudo|nohup) +//' \
"${FILE}" \
| cut -d' ' -f1 \
| sed -E 's#^.*/##' \
| sort | uniq -c | sort -n -r
看下效果:
root$ ./count history.log
3 ll
2 telnet
1 snap
1 ps
1 hotreload
1 grep
1 git
1 cat
可以看到./hotreload
变成hotreload
,也算成功了吧
计算百分比
涉及到了计算,我们可以用awk
命令,它允许我们执行 C 语言的语句,因此我们可以计算每个命令的百分比。
awk
格式为awk BEGIN{} {} END{}
三个语句块分别表示执行前操作
,对所有行操作
,执行后操作
。这里我们不需要BEGIN
。
在{}
中,我们设置两个变量,一个是total
,表示命令总数,每次+1;另一个是类似Map
的数据结构cmds[]
,比如cmds[ll]
表示命令ll
的执行次数。
于是我们给出{}
中的代码:{total++; cmds[$1]++;}
在END{}
中,我们进行循环计算百分比并且格式化输出:
END{
for (cmd in cmds) {
printf "%d %f %s\n", cmds[cmd], cmds[cmd]/total*100, cmd;
}
}
由于我们输出的时候,每个cmd
只输出一次,所以我们的uniq
就不需要了,脚本就可以修改如下:
#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
sed -E -e 's/ *\| */\n/g' \
-e 's/^(sudo|nohup) +//' \
"${FILE}" \
| cut -d' ' -f1 \
| sed -E 's#^.*/##' \
| awk '{total++; cmds[$1]++;} END{for (cmd in cmds) {printf "%d %f %s\n", cmds[cmd], cmds[cmd]/total*100, cmd;}}' \
| sort -n -r
看看效果:
root$ ./count history.log sort
3 27.272727 ll
2 18.181818 telnet
1 9.090909 snap
1 9.090909 ps
1 9.090909 hotreload
1 9.090909 grep
1 9.090909 git
1 9.090909 cat
感觉不错,那么我们进行最后一步的格式化输出
格式化输出
最后我们要让输出变得好看一点,比如规定其宽度,百分数的小数点等
说到格式化我们还是习惯用printf
,所以最后还是用awk
我们只是想处理每一行的输出,所以BEGIN{}
和END{}
都不需要
我们规定命令出现的次数为 3 位的整数:%3d
;百分数保留 2 位小数,共 6 位,并输出%
:%6.2f%%
;最后输出命令名的字符串:%s
别忘了最后还有一个换行符
awk '{printf "%3d %6.2f%% %s\n", $1, $2, $3}'
最后结果就如下啦:
#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
sed -E -e 's/ *\| */\n/g' \
-e 's/^(sudo|nohup) +//' \
"${FILE}" \
| cut -d' ' -f1 \
| sed -E 's#^.*/##' \
| awk '{total++; cmds[$1]++;} END{for (cmd in cmds) {printf "%d %f %s\n", cmds[cmd], cmds[cmd]/total*100, cmd;}}' \
| sort -n -r \
| awk '{printf "%4d %6.2f%% %s\n", $1, $2, $3}'
输出结果:
root$ ./count history.log
3 27.27% ll
2 18.18% telnet
1 9.09% snap
1 9.09% ps
1 9.09% hotreload
1 9.09% grep
1 9.09% git
1 9.09% cat
按字母排序
这是最后一个小问题,虽然我们的输出根据命令的数量排序了,但是当数量相同时,后面并没有根据命令名进行次级排序
于是我们修改倒数第二行的sort
命令为:
sort -t' ' -k1,1nr -k3,3
我们先用-t' '
将每行按空格分割,-k1
~-k3
分别表示三个区域,即对第一行来说,-k1 = 3
,-k2 = 27.27%
,-k1 = ll
-k1,1
表示对第一列排序(-k1,2
表示对第一列和第二列排序,以此类推),n
表示该列为数字而非字符,r
表示降序排序
最后-k3,3
指定第三列为次级排序,默认升序所以不用再多加什么参数。
最最最最后的结果如下
#!/usr/bin/env bash
declare -r FILE="${1:?file no found!}"
sed -E -e 's/ *\| */\n/g' \
-e 's/^(sudo|nohup) +//' \
"${FILE}" \
| cut -d' ' -f1 \
| sed -E 's#^.*/##' \
| awk '{total++; cmds[$1]++;} END{for (cmd in cmds) {printf "%d %f %s\n", cmds[cmd], cmds[cmd]/total*100, cmd;}}' \
| sort -t' ' -k1,1nr -k3,3 \
| awk '{printf "%4d %6.2f%% %s\n", $1, $2, $3}'
输出:
root$ ./count history.log
3 27.27% ll
2 18.18% telnet
1 9.09% cat
1 9.09% git
1 9.09% grep
1 9.09% hotreload
1 9.09% ps
1 9.09% snap
这样我们的这个脚本就完美实现啦!