bash 教程-4 shell 脚本 调试 环境 [MD]


我的GitHub 我的博客 我的微信 我的邮箱
baiqiantao baiqiantao bqt20094 baiqiantao@sina.com

目录

目录
  • 目录
  • Bash 教程
    • 脚本基础
      • 如何运行一个脚本
      • Shebang 行:#! env
      • 权限和路径:chmod PATH
      • 脚本参数:$n $# $@
      • 读取命令执行结果:$?
      • 配置项参数终止符:--
    • 脚本基础命令
      • 移除脚本参数:shift
      • 解析脚本参数:getopts
      • 终止当前脚本:exit
      • 加载外部脚本:source
      • 指定命令别名:alias
      • 创建临时文件:mktemp
      • 响应系统信号:trap
    • 脚本调试
      • 命令错误处理
      • 几个调试参数
      • 调试参数 -x
      • 调试用的环境变量
    • 脚本执行环境
      • 定制环境参数:set
      • 调整环境参数:shopt
    • 对话 Session
      • 登录 Session
      • 非登录 Session
      • 退出时执行的脚本

Bash 教程

  • bash-tutorial
  • Bash 教程

本文改编自 网道的 Bash 教程,主要为了精简大量本人不感兴趣的内容。

脚本基础

脚本 script 就是包含一系列命令的一个文本文件,所有能够在命令行完成的任务,都能够用脚本完成。

如何运行一个脚本

运行 shell 脚本一般有两种方法:

  • 作为可执行程序运行:./test.sh
    • 此格式可让 shell 在当前目录寻找并执行 test.sh 文件
    • 注意一般不能写成 test.sh,否则 shell 会去 PATH 里找文件
    • 如果当前目录不在 PATH 里,写成 test.sh 会提示找不到文件
  • 作为解释器参数运行:bash test.sh
    • 直接运行解释器,其参数就是脚本文件路径
    • 以这种方式运行脚本时,脚本中指定的解释器将不起作用,而会以运行时指定的解释器为准

Shebang 行:#! env

脚本的第一行通常是指定解释器,这一行以 #! 字符开头,这个字符称为 Shebang,所以这一行就叫做 Shebang 行。注意:不能在 Shebang 行的行末添加注释。

#!/bin/bash          # 【#!】后面是脚本解释器存放的位置,与脚本解释器之间可以有空格
#!/bin/sh            # 必须确保 Bash 解释器存放在指定目录,否则脚本就无法执行

./script.sh          # 脚本中有 Shebang 行时,可以直接调用执行脚本
/bin/sh ./script.sh  # 如果没有 Shebang 行,需要将脚本传给解释器才行执行

一般情况下,我们并不需要区分 Bourne ShellBourne Again Shell,所以用 #!/bin/sh#!/bin/bash 都可以

env 命令总是指向 /usr/bin/env 文件,如果不知道某个命令的具体路径,或者希望兼容其他用户的机器,可以使用 env 命令返回 Bash 可执行文件的位置。

#!/usr/bin/env bash  # 查找【$PATH】环境变量里面第一个匹配的【bash】的路径
#!/usr/bin/env node  # 其他脚本文件也可以使用,比如 Node.js 脚本的 Shebang 行

权限和路径:chmod PATH

脚本执行的前提条件是,脚本需要有执行权限。可以使用下面的命令,赋予脚本执行权限。

chmod +x script.sh   # 给所有用户执行权限
chmod +rx script.sh  # 给所有用户读权限和执行权限(755)
chmod u+rx script.sh # 只给脚本拥有者读权限和执行权限(700)

chmod 755 script.sh  # 另一种语法,给所有用户读权限和执行权限

脚本调用时需要指定脚本的路径,如果脚本存放在环境变量 $PATH 中,就不需要指定路径。

# 可在主目录新建一个【~/bin】子目录,专门存放可执行脚本,然后把【~/bin】加入【$PATH】
export PATH=$PATH:~/bin  # 将目录【~/bin】添加到环境变量【$PATH】的末尾
source ~/.bashrc         # 将命令加到【~/.bashrc】中,然后重新加载一次,配置即可生效

脚本参数:$n $# $@

调用脚本的时候,脚本文件名后面可以带有参数。脚本文件内部,可以使用特殊变量,引用这些参数。

  • $0:脚本文件名
  • $n:传递到脚本的第 n 个参数的值,建议使用 ${n} 来获取
  • $#:传递到脚本的参数数量
  • $@:全部的参数,参数之间使用空格分隔,可以利用 for 循环读取每一个参数
  • $*:全部的参数,参数之间使用 $IFS 的第一个字符(默认为空格)分隔,也可以自定义
# 调用格式【./test.sh 29 男】
chmod 777 test.sh
echo $0   #【./test.sh】脚本文件名
echo $1   #【29】注意,对于 command -o xxx yyy,$1 是 -o,$2 是 xxx
echo $2   #【男】
echo $#   #【2】传递到脚本的参数数量
echo $*   #【29 男】以一个字符串显示所有向脚本或函数传递的参数
echo $@   #【29 男】

$*$@ 的区别:在双引号中,$* 的作用是将所有参数当做一个参数

# 【./test.sh 29 男 白乾涛】
for i in $@;   do echo -n $i-; done;  # 【29-男-白乾涛】
for i in $*;   do echo -n $i-; done;  # 【29-男-白乾涛】

for i in "$@"; do echo -n $i-; done;  # 【29-男-白乾涛】
for i in "$*"; do echo -n $i-; done;  # 【29 男 白乾涛】注意,这里是将所有参数当做一个参数

for i in '$@'; do echo -n $i-; done;  # 【$@】
for i in '$*'; do echo -n $i-; done;  # 【$*】

读取命令执行结果:$?

环境变量 $? 可以读取前一个命令的返回值(即命令执行结果)。

cd $some_directory
if [ "$?" = "0" ]; then  # 可以直接写成【if cd $some_directory; then】
  rm *    # 如果执行成功,就删除该目录里面的文件
else
  echo "无法切换目录!" 1>&2
  exit 1  # 否则退出脚本
fi

cd $some_directory && rm *   # 第一步执行成功,才会执行第二步
cd $some_directory || exit 1 # 第一步执行失败,才会执行第二步

配置项参数终止符:--

--- 开头的参数,会被 Bash 当作配置项解释。如果它们不是配置项,而是实体参数的一部分,就需要使用 配置项参数终止符 -- 告诉 Bash,在它后面以 --- 开头的参数不是配置项,而是实体参数。

cat -- -f       # 可以正确展示文件 -f 的内容
cat -- --file   # 可以正确展示文件 --file 的内容

ls -- $myPath   # 强制将变量 $myPath 当作实体参数(即路径名)解释
grep -- "--xxx" test.txt # 在文件里面搜索字符串【--xxx】

脚本基础命令

移除脚本参数:shift

使用 shift 命令可以移除脚本参数。

shift    # 移除脚本当前的第一个参数,即原来的 $2 变成 $1,原来的 $3 变成 $2
shift 3  # 移除脚本当前的前三个参数,即原来的 $4 变成 $1,原来的 $5 变成 $2

解析脚本参数:getopts

在脚本内部,可以使用 getopts 命令解析复杂的脚本参数,比如取出所有的带有前置连词线 - 的参数。

它带有两个参数。

  • 第一个参数 optstring 是由脚本所有的连词线参数组成的字符串,顺序不重要
    • 带有参数值的配置项参数,后面必须带有一个冒号 :
    • 比如,某个脚本可以有三个配置项参数 -l-h-a,其中只有 -a 可以带有参数值,另外两个都是开关参数,那么第一个参数就可以写成 lha:
  • 第二个参数 name 是一个变量名,用来保存当前取到的配置项参数
while getopts 'lha:' OPTION; do # 每次循环就会读取一个连词线参数以及对应的参数值
  count=$(($OPTIND - 1)) # 代表已经处理的参数个数,变量 OPTIND 在开始执行前是 1
  case "$OPTION" in      # 变量 OPTION 保存的是当前处理的那一个连词线参数
    l)
      echo "参数 l,count=$count"  # 不带参数值时,每次执行 OPTIND 就会加 1
      ;;
    h)
      echo "参数 h,count=$count"
      ;;
    a)  # 如果连词线参数带有参数值,处理参数的时候可以获取参数值
      echo "参数 a,count=$count"  # 带参数值时,每次执行 OPTIND 就会加 2
      echo "参数值是 $OPTARG"  # 环境变量 $OPTARG 会保存当前连词线参数的参数值
      ;;
    ?)  # 如果输入了 optstring 中未指定的参数,比如 -x,那么 OPTION 等于【?】
      echo "存在非法参数,脚本使用格式: $(basename $0) [-l] [-h] [-a xxx]" >&2
      exit 1  # 终止当前脚本的执行,并向 Shell 返回一个退出值,1 代表执行失败
      ;;
  esac
done  # 正常退出 while 循环,就意味着连词线参数全部处理完毕

echo "已处理的参数个数:$count"  # 不仅指连词线参数,还包括连词线参数携带的参数值
shift $count   # 将这些连词线参数移除后,就可以使用 $1 $2 等处理命令的主参数
echo "主参数:$@"
  • 只要遇到不带连词线的参数,getopts 就会执行失败,从而退出 while 循环
    • 可以解析 command -l -a xxx -h yyy 中的连词线参数 h
    • 不可以解析 command -l zzz -a xxx -h yyy 中的连词线参数 ah
  • getopts 也可以正确处理多个连词线参数写在一起的形式,比如command -lh
  • 变量 $OPTIND
    • getopts 开始执行前是 1,然后每次执行就会加 12(带参数值)
    • 连词线参数全部处理完毕后,$OPTIND - 1 就是已经处理的参数个数(包含参数值)
    • 使用 shift 命令将这些参数移除后,就可以使用 $1 $@ 等处理命令的主参数

终止当前脚本:exit

exit 命令用于终止当前脚本的执行,并向 Shell 返回一个退出值。

exit   # 终止当前脚本,并将最后一条命令的退出状态,作为整个脚本的退出状态
exit 0 # 退出值 0 代表执行成功(正常结束),只要退出值非 0,就可认为执行失败
exit 1 # 退出值 1 代表执行失败(发生错误),2 用法不对,126 不可执行,127 命令未发现

exitreturn 命令的区别:

  • return 命令是函数的退出,并返回一个值给调用者,脚本依然执行
  • exit 是整个脚本的退出,如果在函数之中调用 exit,则退出函数,并终止脚本执行

加载外部脚本:source

  • source 命令可以在当前 Shell 执行脚本,而不会新建一个子 Shell
  • source 命令执行脚本时,不需要 export 变量就可以在脚本中读取当前 Shell 的变量
  • source 命令通常用于重新加载一个配置文件,或在脚本内部加载外部库
  • source xxx 可简写为 . xxx
#!/bin/bash
source ./lib.sh      # 在脚本内部加载外部库
function_from_lib    # 在脚本里面使用外部库定义的函数
echo $var_from_shell # 在脚本里面读取当前 Shell 的变量

指定命令别名:alias

alias 命令用来为一个命令指定别名。一般来说,都会把常用的别名写在 ~/.bashrc 的末尾。

alias NAME=DEFINITION # 等号两侧不能有空格
alias search=grep     # 为 grep 命令起一个 search 的别名

alias today='date +"%A, %B %-d, %Y"' # 为长命令指定一个更短的别名
today    # 【星期六, 一月 22, 2022】

alias rm='rm -i'     # 通过指定别名,可以修改已有命名的默认行为
alias echo='echo →'  # 别名也可以接受参数,参数会直接传入原始命令

alias       # 显示所有别名
unalias lt  # 解除别名

注意,只能为命令定义别名,为其他部分(比如很长的路径)定义别名是无效的。

创建临时文件:mktemp

mktemp 命令是为安全创建临时文件而设计的:

  • 生成的临时文件名是随机的,权限是只有用户本人可读写
  • 支持唯一文件名和清除机制
  • 可以使用 trap 命令指定退出时的清除操作
  • 注意:此命令创建临时文件前,不会 检查临时文件是否存在,需要自行判断是否创建成功
  • 注意:/tmp 目录是所有人可读写的,在此目录下创建的文件默认是所有人可读

参数:

  • -d:使用默认的文件名模板创建一个临时目录,默认的文件名模板是 tmp. 后接十个随机字符
  • -p:指定临时文件所在的目录,默认是使用环境变量 $TMPDIR,未设置值时使用 /tmp 目录
  • -t:指定临时文件的文件名模板,模板的末尾必须至少包含三个连续的 X 字符,表示随机字符
mktemp                 # 【/tmp/tmp.4GcsWSG4vj】使用默认的文件名模板创建一个临时文件
mktemp -d              # 【/tmp/tmp.Wcau5UjmN6】使用默认的文件名模板创建一个临时目录
mktemp -p /home/bqt/   # 【/home/bqt/tmp.FOKEtvs2H3】指定临时文件所在的目录
mktemp -t tmp.XXXXXXX  # 【/tmp/tmp.yZ1HgZV】指定临时文件的文件名模板

echo "创建了临时文件 $(mktemp)" # 获取创建的临时文件
TMPFILE=$(mktemp) || exit 1    # 创建失败时退出脚本
trap 'rm -f "$TMPFILE"' EXIT   # 使用 trap 命令指定,脚本退出时清除临时文件

响应系统信号:trap

trap 命令用来在 Bash 脚本中响应系统信号,例如按 Ctrl + C 所产生的中断信号 SIGINT

trap -l                       # 列出所有 64 个系统信号
trap [动作] [信号1 信号2 ...]  # 命令格式,动作指的是一个 Bash 命令
trap 'rm -f "$TMPFILE"' EXIT  # 遇到 EXIT 信号时(脚本退出时),执行指定的清理命令

常用的信号有以下几个:

  • SIGHUP:编号 1,脚本与所在的终端脱离联系
  • SIGINT:编号 2,用户按下 Ctrl + C,意图让脚本终止运行
  • SIGQUIT:编号 3,用户按下 Ctrl + 斜杠,意图退出脚本
  • SIGKILL:编号 9,该信号用于杀死进程
  • SIGTERM:编号 15,这是 kill 命令发出的默认信号
  • EXIT:编号 0,这不是系统信号,而是 Bash 脚本特有的信号,只要退出脚本就一定会产生
#!/bin/bash

trap 'rm -f "$TMPFILE"' EXIT       # 脚本退出时清除临时文件
TMPFILE=$(mktemp) || exit 1
ls /etc > $TMPFILE
if grep -qi "kernel" $TMPFILE; then echo 'find'; fi

上面代码中,不管是脚本正常执行结束,还是用户按 Ctrl + C 终止,都会产生 EXIT 信号,从而触发删除临时文件。

注意,trap 命令必须放在脚本的开头。否则,它上方的任何命令导致脚本退出,都不会被它捕获。

脚本调试

编写 Shell 脚本的时候,一定要考虑到命令失败的情况,否则很容易出错。

#! /bin/bash
dir=/path/not/exist
cd $dir   # 如果目录不存在,cd 命令就会执行失败,就不会改变当前目录

rm *             # 脚本会继续执行,从而删光当前目录的文件
cd $dir && rm *  # 如果变量为空,cd 会进入用户主目录,从而删光用户主目录的文件
[[-d $dir]] && cd $dir && rm *       # 比较安全的写法,先判断目录是否存在再执行
[[-d $dir]] && cd $dir && echo rm *  # 不删除文件,而是先打印要删除的文件看一下

命令错误处理

如果脚本里面有运行失败的命令(返回值非 0),Bash 只是显示有错误,并且继续执行后面的命令,而不会终止执行。

实际开发中,如果某个命令失败,往往需要脚本停止执行,防止错误累积。

command || exit 1       # 如果命令 command 执行失败了,脚本停止执行
command1 && command2    # 如果 command1 执行成功了,才继续执行 command2

command || {echo "失败"; exit 1;}             # 停止执行之前完成多个操作
if ! command; then echo "失败"; exit 1; fi    # 写法二
if ["$?" -ne 0]; then echo "失败"; exit 1; fi # 写法三

几个调试参数

为了方便 Debug,有时在启动 Bash 的时候,可以加上启动参数。

bash -n scriptname  # 不运行脚本,只检查是否有语法错误
bash -v scriptname  # 每一行语句输出运行结果前,先输出该行语句
bash -x scriptname  # 每一个命令处理之前,先输出该命令,再执行该命令

调试参数 -x

  • bash-x 参数可以在执行每一行命令之前,打印该命令
  • 输出的命令之前的 + 号,是由环境变量 PS4 决定的,可以修改这个变量的值
  • set 命令也可以设置 Shell 的行为参数,有利于脚本除错
bash -x          # 加上 -x 参数启动一个 bash,后续执行每条命令前,都会先显示该命令
bash -x test.sh  # 加上 -x 参数执行脚本,执行脚本中的每条命令前,都会先显示该命令
#! /bin/bash -x  # 参数 -x 也可以写在脚本的 Shebang 行(演示代码,这一行不能有注释)
export PS4='- '  # 可以修改环境变量 PS4 的值,默认为 + 号
$ echo `pwd`     # 执行此命令前会先执行 pwd 命令,有 n 层调用就会有 n 个连续的 +
++ pwd           # 执行每条命令(pwd)前,都会先显示该命令,注意这里打印的是 ++
+ echo /home/bqt # 执行每条命令(echo)前,都会先显示该命令
/home/bqt        # 这一行是 echo 命令自身的打印

调试用的环境变量

  • LINENO:返回变量在脚本文件中的行号
  • FUNCNAME:返回当前的函数调用堆栈。数组的 0 号成员是当前调用的函数,1 号成员是调用当前函数的函数,记作 m(i)
  • BASH_SOURCE:返回当前的脚本调用堆栈。数组的 0 号成员是当前执行的脚本,1 号成员是调用当前脚本的脚本,记作 n(i)
  • BASH_LINENO:返回每一轮调用对应的行号。f(i)m(i) 是一一对应关系,表示 ${FUNCNAME[$i]} 在调用它的脚本文件 ${BASH_SOURCE[$i+1]} 里面的行号
#!/bin/bash

echo "当前行号 $LINENO"                        # 当前行号 3
echo "${FUNCNAME[0]} - ${FUNCNAME[1]}"        # main -
echo "${BASH_SOURCE[0]} - ${BASH_SOURCE[1]}"  # ./test.sh -
echo "${BASH_LINENO[0]} - ${BASH_LINENO[1]}"  # 0 -

脚本执行环境

Bash 执行脚本时,会创建一个子 Shell。这个子 Shell 就是脚本的执行环境,Bash 默认给定了这个环境的各种参数。

定制环境参数:set

set 命令用来修改子 Shell 环境的运行参数,即定制环境。

  • set -u:遇到不存在的变量时,报错并停止执行,而不是继续向下执行
  • set -n:不运行命令,只检查语法是否正确
  • set -x:在运行结果之前,先输出执行的那一行命令。可使用 set +x 关闭
  • set -e:只要返回值非 0 就终止执行。可使用 set +e 关闭,等价于 command || true
  • set -f:不对通配符进行文件名扩展。可使用 set +f 关闭
  • set -v:打印 Shell 接收到的每一行输入。可使用 set +v 关闭
  • set -E函数内的命令在执行错误时,即使设置了 set -e,仍然可以被 trap 命令捕获
  • set -o pipefail:管道命令中只要一个子命令失败,整个管道命令就失败
  • set -o noclobber:防止使用重定向运算符 > 覆盖已经存在的文件
set      # 显示所有的环境变量和 Shell 函数
set -u   # 等同于【set -o nounset】遇到不存在的变量时,报错并停止执行
set -n   # 等同于【set -o noexec】不运行命令,只检查语法是否正确

set -x   # 等同于【set -o xtrace】在运行结果之前,先输出执行的那一行命令
set -e   # 等同于【set -o errexit】只要返回值非 0 就终止执行
set -f   # 等同于【set -o noglob】不对通配符进行文件名扩展
set -v   # 等同于【set -o verbose】打印 Shell 接收到的每一行输入

set -E            # 使函数内的命令在执行错误时,始终可以被 trap 命令捕获
set -o pipefail   # 管道命令中只要一个子命令失败,整个管道命令就失败
set -o noclobber  # 防止使用重定向运算符 > 覆盖已经存在的文件

set -Eeuxo pipefail         # 参数可以采用混合写法,建议放在 Bash 脚本的头部
bash -euxo pipefail test.sh # 也可以在执行 Bash 脚本的时候,从命令行传入参数

关于 set -o pipefail 的解释:

  • 参数 set -e 不适用于管道命令(即多个子命令通过管道运算符 | 组合成的命令)
  • Bash 默认会把管道命令中,最后一个子命令的返回值,作为整个命令的返回值
  • 所以,只要最后一个子命令返回值非 0,管道命令就代表执行成功
  • 通过 set -eo pipefail 可实现,管道命令中只要一个子命令失败,整个管道命令就失败

调整环境参数:shopt

shopt 命令跟 set 命令的作用很类似,也可以用来调整 Shell 的参数。区别是:set 是 POSIX 规范的一部分,而 shopt 是 Bash 特有的命令。

shopt           # 查看所有参数,以及它们的开关状态
shopt xxx       # 查询某个参数的开关状态
shopt -s xxx    # 打开某个参数
shopt -u xxx    # 关闭某个参数
shopt -q xxx    # 通过命令的执行结果 $? 查询某个参数的开关状态,0 表示打开,1 表示关闭

对话 Session

用户每次使用 Shell,都会开启一个与 Shell 的 Session。

Session 有两种类型:登录 Session 和非登录 Session,也可以叫做 login shell 和 non-login shell。

登录 Session

登录 Session 是用户登录系统以后,系统为用户开启的原始 Session,通常需要用户输入用户名和密码进行登录。

登录 Session 一般会进行 整个系统环境 的初始化,启动的初始化脚本依次如下:

  • 针对所有用户的初始化脚本
    • /etc/profile:全局配置脚本。Linux 更新时候会更新此文件,因此建议不要直接修改这个文件
    • /etc/profile.d/:此目录所有 .sh 文件。如果想修改所有用户的登陆环境,建议在此目录中新建 .sh 脚本
  • 针对当前用户的初始化脚本
    • ~/.bash_profile:个人配置脚本。该脚本若存在则不再往下执行。一般建议在此文件中修改个人的登录环境
    • ~/.bash_login:C shell 的初始化脚本。该脚本若存在则不再往下执行
    • ~/.profile:Bourne shell 和 Korn shell 的初始化脚本
bash --login      # 强制执行登录 Session 时会执行的脚本
bash --noprofile  # 跳过上面这些 profile 初始化脚本

非登录 Session

非登录 Session 是用户进入系统以后,手动创建的 Session,这时不会进行环境初始化。比如,在命令行执行 bash 命令,就会新建一个非登录 Session。

非登录 Session 的初始化脚本依次如下"

  • /etc/bash.bashrc:针对所有用户的初始化脚本
  • ~/.bashrc:仅针对当前用户的初始化脚本

对用户来说,~/.bashrc 通常是最重要的脚本。非登录 Session 默认会执行它,而登录 Session 一般也会通过调用执行它。每次新建一个 Bash 窗口,就相当于新建一个非登录 Session,所以 ~/.bashrc 每次都会执行。

注意,执行脚本相当于新建一个非互动的 Bash 环境,这种情况不会调用 ~/.bashrc

bash --norc           # 禁止在非登录 Session 执行 ~/.bashrc 脚本
bash --rcfile testrc  # 指定用另一个脚本代替 ~/.bashrc 脚本

退出时执行的脚本

脚本在每次退出 Session 时都会执行脚本 ~/.bash_logout,通常用来做一些清理工作和记录工作,比如删除临时文件,记录用户在本次 Session 花费的时间。

如果没有退出时要执行的命令,这个文件也可以不存在。

2022-01-15