李守中

Bash 6 脚本技巧

Table of Contents

1. 文件的第一行

众所周知 shell 脚本的第一行以 shebang #! 开始,但 shebang 后面必须跟绝对路径:

  • #!/bin/bash
  • #!/usr/bin/bash
  • #!/bin/sh

多数情况下,以上写法等价,但这里面有坑:

  • 系统的默认 shell 可能不是 bash 或 sh
  • 系统中 bash 的路径可能不是 /usr/bin/bash
  • 系统中 sh 的路径可能不是 /usr/bin/sh

所以合适的写法是:

  • #!/usr/bin/env bash
  • #!/usr/bin/env sh

其中,env 命令会以当前环境变量为基础,调用用户指定的命令,并且 env 命令的所在路径总是 /usr/bin/env。

如果当前环境变量中包含了 $PATH 变量,而用户指定的命令是一个相对路径,那么操作系统就会在 $PATH 变量中查找用户指定的路径 (程序),而 $PATH 变量通常会包含可执行程序的路径。

这就意味着,env 命令会让操作系统从当前环境的 $PATH 变量中查找用户指定的程序,从而可以避免硬编码解释器路径的问题。

但使用 /usr/bin/env 来启动程序也有一些问题: 如果错误的解释器路径被添加到 $PATH 变量值的开头,就会覆盖 $PATH 变量值后面的,解释器真正所在的路径。而为 $PATH 变量值的开头添加一些错误的路径是攻击者的常用手段。

如果操作系统中同时存在解释器的两个版本,比如,bash3 和 bash4,那么就不推荐使用 /usr/bin/env 来作为 shebang 后的内容。给一个例子:

  • /bin/bash 是 bash3 的路径
  • /usr/loca/bin/bash 是 bash4 的路径

如果在脚本中使用 /usr/bin/env bash 那么哪个版本的 bash 被调用取决于哪个版本的 bash 的路径在 $PATH 变量中更靠左。此时,最好硬编码 bash 的绝对路径,从而避免调用错误版本的 bash 而出现问题。

2. 获取当前路径

直接给一个例子 (/opt/t/tt/script.sh):

#!/usr/bin/env bash
echo $BASH_SOURCE
script_path=${BASH_SOURCE[0]}
script_folder=$(dirname $BASH_SOURCE[0])
script_absolute_folder=$(cd "$script_path" && pwd)
echo "script path           : $script_path"
echo "script folder         : $script_folder"
echo "script absolute folder: $script_absolute_folder"
root@debian11:/opt# t/tt/script.sh
t/tt/script.sh
script path           : t/tt/script.sh
script folder         : t/tt
script absolute folder: /opt/t/tt

root@debian11:/opt# /opt/t/tt/script.sh
/opt/t/tt/script.sh
script path           : /opt/t/tt/script.sh
script folder         : /opt/t/tt
script absolute folder: /opt/t/tt

3. 一定要检查文件夹是否存在

给一个例子:

#! /bin/bash

dir_name=/path/not/exist

cd $dir_name
rm *

目录 $dir_name 不存在, cd $dir_name 命令就会执行失败。脚本就会在当前目录下继续执行,然后 rm * 命令就会删除当前目录的文件。

cd $dir_name && rm * 也不行,如果 $dir_name 为空,命令就变成了 cd && rm * ,这次是删除用户主目录的文件。

[[ -d $dir_name ]] && cd $dir_name && rm * 这样才对。

[[ -d $dir_name ]] && cd $dir_name && echo rm * 如果不放心删除什么文件,可以先打印出来看一下。 echo rm * 不会删除文件,只会打印出来要删除的文件。

4. 用 bash 的 -x 参数输出要执行的每行命令

bash 的 -x 参数可以在执行每一行命令之前,打印该命令。一旦出错,这样就比较容易追查。

它在脚本中的写法通常是:

#!/usr/bin/env sh

set -x
#...

当然它也可以通过命令传递到脚本中:

$ cat>test.sh<<'EOF'
#!/bin/bash
echo "hello world1"
echo "hello world2"
echo "hello world3"
EOF

$ bash -x test.sh
+ echo 'hello world1'
hello world1
+ echo 'hello world2'
hello world2
+ echo 'hello world3'
hello world3

5. 用环境变量查 bug

5.1. $LINENO 保存行号

环境变量 $LINENO 保存了这个变量出现的行号。

$ cat>test.sh<<'EOF'
#!/bin/bash

echo "This is line $LINENO"
echo "This is line $LINENO"
EOF

$ bash test.sh
This is line 3
This is line 4

5.2. $FUNCNAME 保存函数调用栈

环境变量 $FUNCNAME 返回一个数组,内容是当前的函数调用堆栈。数组的 0 号成员是当前被调用的函数,1 号成员是调用了当前函数的函数,以此类推。

$ cat>test.sh<<'EOF'
#!/bin/bash

function func1()
{
    echo "func1: FUNCNAME0 is ${FUNCNAME[0]}"
    echo "func1: FUNCNAME1 is ${FUNCNAME[1]}"
    echo "func1: FUNCNAME2 is ${FUNCNAME[2]}"
    func2
}

function func2()
{
    echo "func2: FUNCNAME0 is ${FUNCNAME[0]}"
    echo "func2: FUNCNAME1 is ${FUNCNAME[1]}"
    echo "func2: FUNCNAME2 is ${FUNCNAME[2]}"
}

func1
EOF

$ bash test.sh
func1: FUNCNAME0 is func1
func1: FUNCNAME1 is main
func1: FUNCNAME2 is
func2: FUNCNAME0 is func2
func2: FUNCNAME1 is func1
func2: FUNCNAME2 is main

5.3. $BASH_SOURCE 保存脚本调用栈

环境变量 $BASH_SOURCE 返回一个数组,内容是当前的脚本调用堆栈。数组的 0 号成员是当前执行的脚本,1 号成员是调用了当前脚本的脚本,以此类推。

$ cat>lib1.sh<<'EOF'
# lib1.sh
function func1()
{
    echo "func1: BASH_SOURCE0 is ${BASH_SOURCE[0]}"
    echo "func1: BASH_SOURCE1 is ${BASH_SOURCE[1]}"
    echo "func1: BASH_SOURCE2 is ${BASH_SOURCE[2]}"
    func2
}
EOF

$ cat>lib2.sh<<'EOF'
# lib2.sh
function func2()
{
    echo "func2: BASH_SOURCE0 is ${BASH_SOURCE[0]}"
    echo "func2: BASH_SOURCE1 is ${BASH_SOURCE[1]}"
    echo "func2: BASH_SOURCE2 is ${BASH_SOURCE[2]}"
}
EOF

$ cat>main.sh<<'EOF'
#!/bin/bash
# main.sh
source lib1.sh
source lib2.sh
func1
EOF

$ bash main.sh
func1: BASH_SOURCE0 is lib1.sh
func1: BASH_SOURCE1 is ./main.sh
func1: BASH_SOURCE2 is
func2: BASH_SOURCE0 is lib2.sh
func2: BASH_SOURCE1 is lib1.sh
func2: BASH_SOURCE2 is ./main.sh

5.4. $BASH_LINENO 保存调用命令所在的行号

环境变量 $BASH_LINENO 返回一个数组,内容是每一轮调用对应的行号。 ${BASH_LINENO[$i]}${FUNCNAME[$i]} 是对应关系,表示 ${FUNCNAME[$i]} 在调用它的脚本文件 ${BASH_SOURCE[$i+1]} 里面的行号。

$ cat>lib1.sh<<'EOF'
# lib1.sh
function func1()
{
    echo "func1: BASH_LINENO is ${BASH_LINENO[0]}"
    echo "func1: FUNCNAME is ${FUNCNAME[0]}"
    echo "func1: BASH_SOURCE is ${BASH_SOURCE[1]}"
    func2
}
EOF

$ cat>lib2.sh<<'EOF'
# lib2.sh
function func2()
{
    echo "func2: BASH_LINENO is ${BASH_LINENO[0]}"
    echo "func2: FUNCNAME is ${FUNCNAME[0]}"
    echo "func2: BASH_SOURCE is ${BASH_SOURCE[1]}"
}
EOF

$ cat>main.sh<<'EOF'
#!/bin/bash
# main.sh
source lib1.sh
source lib2.sh
func1
EOF

$ bash main.sh
func1: BASH_LINENO is 7
func1: FUNCNAME is func1
func1: BASH_SOURCE is main.sh
func2: BASH_LINENO is 8
func2: FUNCNAME is func2
func2: BASH_SOURCE is lib1.sh

6. 修改 shell 运行参数查 bug

6.1. POSIX 规范中的 set 命令

set -u (等同于 set -o nounset) 脚本执行过程中遇到不存在的变量,报错。默认情况下,不存在的变量按空处理。

set -e 只要执行过程中发生错误,终止执行。但它不适用于管道命令。由于 Bash 总会把通过管道运算符 | 组合起来的,多个子命令的,最后一个子命令的返回值作为整个命令的返回值,这导致了只要最后一个子命令不失败,管道命令总是会执行成功。那么管道命令后面的命令依然会被执行, set -e 就失效了。

$ cat>test.sh<<'EOF'
#!/bin/bash
set -e
foo | echo a
echo bar
EOF

$ bash test.sh
a
script.sh:行4: foo: 未找到命令
bar

set -e -o pipefail 可以用来解决这种情况。只要管道命令的某个子命令失败,整个管道命令就失败,脚本就会终止执行。

set -E 纠正设置了 set -e 之后 trap 命令无法捕捉信号的问题。

set -xbash -x 效果相同。但 set -x 可以做到只对某段代码打开执行过程输出:

#!/bin/bash

number=1

set -x
if [ $number = "1" ]; then
    echo "Number equals 1"
else
    echo "Number does not equal 1"
fi
set +x

echo "set -x closed"

set 命令还有一些其他参数。

  • set -n: 等同于 set -o noexec ,不运行命令,只检查语法是否正确。
  • set -f: 等同于 set -o noglob ,不对通配符进行文件名扩展。使用 set +f 关闭。
  • set -v: 等同于 set -o verbose ,表示打印 Shell 接收到的每一行输入。使用 set +v 关闭。
  • set -o noclobber: 防止使用重定向运算符 > 覆盖已经存在的文件。

6.2. Bash 特有的 shopt 命令

shoptset 的区别在于,set 是从 Ksh 继承的,属于 POSIX 规范的一部分,而 shopt是 Bash 特有的。

直接输入 shopt 可以查看所有参数,以及它们各自打开和关闭的状态。

shopt 命令后面跟着参数名,可以查询该参数是否打开。

$ shopt globstar
globstar  off

shopt -s 用来打开某个参数。

shopt -u 用来关闭某个参数。

shopt -q 也可以查询某个参数是否打开,但它不直接输出查询结果,而是通过命令的执行状态 $? 表示查询结果。如果状态为 0,表示该参数打开;如果为 1,表示该参数关闭。

$ shopt -q globstar
$ echo $?
1

这个用法主要用于脚本,供 if 条件结构使用:

# 如果打开了这个参数,就执行 if 结构内部的语句
if !(shopt -q globstar); then
    ...
fi


Last Update: 2024-01-02 Tue 14:49

Generated by: Emacs 28.2 (Org mode 9.5.5)   Contact: [email protected]

若正文中无特殊说明,本站内容遵循: 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议