Contents

Bash特殊变量

1. 概述

Bash 带有一些特殊的内置变量。当我们想要控制 Bash 脚本的执行流程时,它们会派上用场。 我们只能读取它们的值,而不能分配给它们。

在本教程中,我们将了解如何以及何时使用它们。

2. 特殊位置变量

首先,让我们解释一下什么是位置变量。我们使用它们来引用传递给 shell 脚本函数 的参数。

基本语法是*${N}*, 其中N是一个数字。如果N是单个数字,我们可以省略花括号

但是,出于可读性和一致性的原因,我们将在示例中使用花括号表示法。

让我们通过创建一个名为positional_variables的函数来看看它在实践中的样子 :

function positional_variables(){
    echo "Positional variable 1: ${1}"
    echo "Positional variable 2: ${2}"
}

然后,我们可以调用positional_variables,并传递两个参数:

$ positional_variables "one" "two"
Positional variable 1: one
Positional variable 2: two

到目前为止,这种处理没有什么特别之处。

让我们尝试引用一个未传递的参数

function positional_variables(){
    echo "Positional variable 1: ${1}"
    echo "Positional variable 2: ${2}"
    echo "Positional variable 3: ${3}"
}

Bash 认为它未分配

$ positional_variables "one" "two"
Positional variable 1: one
Positional variable 2: two
Positional variable 3:

2.1. 所有参数

让我们看看如果我们用特殊的*@*字符更改数字会发生什么:

function positional_variables(){
    echo "Positional variables with @: ${@}"
}

** ${@}构造扩展到传递给我们函数的所有参数:

$ positional_variables "one" "two"
Positional variables with @: one two

此外,我们可以迭代它们,类似于数组

function positional_variables(){
    echo "Positional variables with @: ${@}"
    for element in ${@} 
    do
        echo ${element}
    done
}

让我们运行它,看看我们得到了什么:

$ positional_variables "one" "two"
Positional variables with @: one two
one
two

我们也可以使用${∗}构造来实现相同的结果

function positional_variables(){
    echo "Positional variables with @: ${@}"
    echo "Positional variables with *: ${*}"
}

让我们看一下输出:

$ positional_variables "one" "two"
Positional variables with @: one two
Positional variables with *: one two

乍一看,输出是一样的,对吧?不完全是。 让我们更改*IFS * 变量并重新运行它:

function positional_variables(){
    IFS=";"
    echo "Positional variables with @: ${@}"
    echo "Positional variables with *: ${*}"
}

现在,输出是:

$ positional_variables "one" "two"
Positional variables with @: one two
Positional variables with *: one;two

这里的区别是输入参数之间的连接。

使用${∗}时,参数扩展为${1}c${2}等等,其中cIFS*中的第一个字符集。*

一次获取所有输入对于输入验证非常有用。

让我们考虑一个简单的实用程序函数,它检查是否通过了特定选项:

function login(){
    if [[ ${@} =~ "-user" && ${@} =~ "-pass" ]]; then
        echo "Yehaww"
    else
        echo "Bad Syntax. Usage: -user [username] -pass [password] required"
    fi
}

在这个片段中,我们检查了所有函数参数的两个特定标志。

让我们关注每次调用会发生什么不同:

$ login -user "one" -pass "two" 
Yehaww
$ login -pass "two" -user "one" 
Yehaww
$ login "one" "two"
Bad Syntax. Usage: -user [username] -pass [password] required

请注意,无论我们输入的顺序如何,前两个调用都会成功。

这允许用户以任何顺序传递参数,但这是一个非常基本的检查:

$ login -user -pass
Yehaww

注意我们的条件通过了,尽管我们只发送了两个字符串。接下来我们将看到如何处理这个问题。

2.2. 参数数量

大多数输入验证序列首先检查参数的数量。

让我们*使用*${#}构造来获取输入的数量

function login(){
    if [[ ${#} < 4 ]]; then
        echo "Bad Syntax. Usage: -user [username] -pass [password] required"
        return
    fi
    echo "Passed argument number check" 
    # previous checks omitted
}

在这个例子中,我们验证我们的函数是用最少数量的参数调用的。

这样,我们可以快速失败,并且我们不会浪费时间处理其他验证:

$ login
Bad syntax. Usage: -user [username] -pass [password] required

但是,我们仍然可以绕过这个检查:

$ login -user -pass "one" "two"
Passed argument check
Yehaww

让我们让它更健壮:

function login(){
    # previous checks omitted 
    while [[ ${#} > 0 ]]; do
        case $1 in
            -user) 
            user=${2};
            shift;;
            -pass)
            pass=${2};
            shift;;
            *)
            echo "Bad Syntax. Usage: -user [username] -pass [password] required"; 
            return;;
        esac
        shift
    done
    echo "User=${user}"
    echo "Pass=${pass}"
}

这看起来有点复杂。让我们分解一下。

我们使用while循环并检查第一个位置变量是否与我们的任何选项匹配。

如果是,那么我们从第二个位置变量中提取值。

之后,我们使用内置的shift 来移动我们的 while 循环。

让我们看看它的实际效果:

$ login -pass "one" -user "two"
Passed argument check
Yehaww
User=two
Pass=one

现在让我们尝试更改输入顺序:

$ login -pass -user "two" "one"
Passed argument check
Yehaww
Bad Syntax. Usage: -user [username] -pass [password] required

2.3. 脚本名称

让我们通过在函数中添加脚本名称来使我们的错误处理更具表现力:

function login(){
    if [[ ${#} < 4 ]]; then 
        echo "Bad Syntax. Usage: ${0} -user [username] -pass [password] required" 
        return 
    fi
    # previous checks omitted
}

我们运行它并看到一条错误消息:

$ login
Bad syntax. Usage: ./shell.sh -user [username] -pass [password] required

让我们看看我们做了什么。我们使用${0}位置变量提取了我们的脚本名称。

请注意,它计算为脚本名称(包含我们的函数)而不是函数名称。

3. 流程和工作变量

有时,在复杂的脚本中,我们需要其他命令的执行状态才能进一步进行。此外,我们可能需要在后台执行长时间运行的任务并检查它们是否完成。

幸运的是,Bash 提供了一些巧妙的技巧来处理这种情况。让我们来看看它们。

3.1. 上一条命令的执行状态

还记得我们之前的登录示例吗?让我们稍微改进一下:

function login(){
    if [[ ${#} < 4 ]]; then
        echo "Bad syntax. Usage: ${0} -user [user] -pass [pass] required"
        return 15
    fi 
    if [[ ${@} =~ "-user" && ${@} =~ "-pass" ]]; then
        echo "Yehaww"
    else
        echo "Bad Syntax. Usage: -user [username] -pass [password] required"
        return 16
    fi
}

我们返回两个错误代码:1615

让我们用它们来区分调用代码中的错误:

function check_login(){
    login
    login_rc=${?}
    if [[ $login_rc == 15 ]];then
        echo "Insufficient parameters to login function"
    elif [[ $login_rc == 16 ]];then
        echo "Parameters -user and -pass not sent to login function"
    elif [[ $login_rc == 0 ]];then
        echo "Everthing is awesome ... proceeding"
    fi
}

我们*使用*${?}构造来检索最后一个命令的执行状态

在这种情况下,登录功能将在第一次检查时失败:

$ check_login
Bad syntax. Usage: ./shell.sh -user [username] -pass [password] required
Insufficient parameters to login function

最重要的是,我们使用中间变量来检查返回码

让我们看看如果我们不这样做会发生什么

function check_login(){
    login "one" "two"
    if [[ ${?} == 15 ]];then
        echo "Insufficient parameters to login function"
    elif [[ ${?} == 16 ]];then
        echo "Parameters -user and -pass not sent to login function"
    elif [[ ${?} == 0 ]];then
        echo "Everthing is awesome ... proceeding"
    fi
}

让我们重新运行该函数并查看输出:

$ check_login
Bad Syntax. Usage: -user [username] -pass [password] required

那么这里发生了什么?

让我们使用set -x添加一些详细信息并重新运行它:

function check_login(){
    set -x
    login one two
    # previous checks
}

在这种情况下,每次比较都会覆盖最后一个命令退出状态

$ check_login
+ login one two
+ [[ 2 == 0 ]]
+ [[ 2 == 1 ]]
+ [[ one two =~ -user ]]
+ echo 'Bad Syntax. Usage: -user [username] -pass [password] required'
Bad Syntax. Usage: -user [username] -pass [password] required
+ return 16
+ [[ 16 == 15 ]]
+ [[ 1 == 16 ]]
+ [[ 1 == 0 ]]

起初,退出状态是我们所期望的16。第一次比较(失败)后,退出状态为1

这是正确的,因为它表示执行的比较退出代码并返回一个非零值。

3.2. 后台作业进程 ID

让我们回到login函数并假设执行需要一段时间:

function login() {
    echo "Sleeping for 3 seconds"
    sleep 3s
    # previous checks omitted
}

现在让我们*将其称为异步命令,使用*&控制运算符

function check_login() {
    login &
    login_rc=${?}
    # previous checks omitted
}

让我们运行它,看看会发生什么:

$ check_login
Everthing is awesome ... proceeding
Sleeping for 3 seconds
$ [original script finished]
...[after 3 seconds]
Bad syntax. Usage: ./shell.sh -user [username] -pass [password] required

我们的调用函数在异步login函数返回之前完成。此外,我们的退出代码检查变得毫无用处。

幸运的是,我们可以使用*${!}*构造来获取我们的后台进程 id并在继续之前wait

function check_login(){
    login &
    wait ${!}
    # previous checks omitted
}

让我们看一下输出:

$ check_login
Sleeping for 3 seconds
Bad syntax. Usage: ./shell.sh -user [username] -pass [password] required
Insufficient parameters to login function

这是一个幼稚的实现。异步启动一个长时间运行的进程并立即等待它完成是没有意义的。 让我们更好地使用并实现三个 Linux 内核版本的简单并行下载:

function download_linux(){
    declare -a linux_versions=("5.7" "5.6.16" "5.4.44")
    declare -a commands
    mkdir linux
    for version in ${linux_versions[@]}
        do 
            curl -so ./linux/kernel-${version}.tar.xz -L \
            "https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-${version}.tar.xz" &
            echo "Running with pid ${!}"
            commands+=(${!})
        done   
    for pid in ${commands[@]}
        do
            echo "Waiting for pid ${pid}"
            wait $pid
        done
}

首先,我们为每个内核版本异步启动curl实用程序。然后,我们保留后台进程id。 最后,我们在退出函数之前等待每个后台进程完成。

让我们看看它打印了什么:

$ download_linux
Running with pid 5699
Running with pid 5700
Running with pid 5701
Waiting for pid 5699
Waiting for pid 5700
Waiting for pid 5701

这需要一段时间才能完成,因为每个版本的大小约为 100MB。 在下载过程中,我们可以在不同的终端中检查linux目标文件夹的内容:

$ ls linux/
kernel-5.4.44.tar.xz  kernel-5.6.16.tar.xz  kernel-5.7.tar.xz

3.3. 当前 Shell 进程 ID

到目前为止,我们已经看到了后台进程 id。

我们还可以使用${$}构造来识别当前的 shell 进程 ID :

function login(){
    echo "Current shell process id ${$}"
    echo "Sleeping for 3 seconds"
    sleep 3s
    # previous checks omitted
}

我们再次使用我们的login功能:

$ login
Current shell process id 6575
Sleeping for 3 seconds
Bad syntax. Usage: ./shell.sh -user [username] -pass [password] required

但是如果我们异步调用我们的函数会发生什么?让我们来看看:

function check_login() {
    echo "Calling shell process id ${$}"
    login &
    wait ${!}
    # previous checks omitted
}

我们得到输出:

$ check_login
Calling shell process id 6772
Current shell process id 6772
Sleeping for 3 seconds
# same output as before

** ${$}扩展为调用 shell 进程 id。结果,我们后台作业返回的shell进程id与主脚本中的相同。 我们可以将其写入PID文件 。让我们将logincheck_login函数放在一个脚本中:

#!/bin/bash
function login() { 
    # same body as before
}
function check_login() { 
    echo "${$}" > shell.pid
    login & 
    wait ${!} 
    # previous checks omitted 
}
check_login

然后,让我们实现一个基本的看门狗脚本来检查我们的主脚本是否正在运行:

#!/bin/bash
function watchdog(){
    pid=`cat ./shell.pid`
    if [[ -e /proc/$pid ]];then
        echo "Login still running"
    else 
        echo "Login is finished"
    fi
}
watchdog

首先,我们读取进程 id,然后检查它是否存在于虚拟proc 文件系统中。

让我们运行我们的登录脚本,并在一个单独的终端中运行我们的看门狗脚本:

$ ./shell.sh
Current shell process id 7549
Sleeping for 3 seconds
$ ./watchdog.sh 
Login still running

这些机制在daemons 上很常见,用于关闭或重新启动处理。

4. 其他特殊变量

我们最后保存了最奇特的内置变量。让我们看看他们是关于什么的。

4.1. 当前外壳选项

在前面的示例中,我们使用带有*–x选项的内置set*来启用详细程度。

我们可以使用${-}构造打印当前的 shell 选项

让我们重新运行我们的check_login函数:

function check_login() {
    echo "Before shell options ${-}"
    set -x
    echo "After shell options ${-}"
    # same body as before
}

让我们看看设置详细程度之前和之后的选项:

$ check_login
Before shell options hB
+ echo 'After shell options hxB'
After shell options hxB
# same debug output as before

默认情况下,我们的 shell 设置了hB标志。h选项可以创建最近执行的命令的哈希表以加快查找速度,而 B 选项可以启用大括号扩展 机制。

当我们设置-x*标志时,我们指示 Bash 执行命令跟踪*。

当然,我们可以在手册中进一步探索许多其他选项。

4.2. 下划线变量

现在让我们看一下最有趣的内置特殊变量:${_}

根据上下文,它有不同的含义,这就是为什么它有点难以理解。

让我们启动一个新终端并查看它的当前值:

$ echo ${_}
true

我们启动一个新的 shell 时,它会扩展为 last command executed 的最后一个参数

那么,真正的价值从何而来?它背后的魔力就在我们的*~/.bashrc*文件的最后一行:

$ cat ~/.bashrc
# For some news readers it makes sense to specify the NEWSSERVER variable here
#export NEWSSERVER=your.news.server
# some other comments
test -s ~/.alias && . ~/.alias || true

这可能因一个 Linux 发行版而异。

让我们在其中添加一个新命令并生成一个新终端:

test -s ~/.alias && . ~/.alias || true
echo lorem ipsum

现在让我们看一下提示,看看*${_}*扩展为:

lorem ipsum
$ echo ${_}
ipsum

由于我们修改了*.bashrc*,我们生成的每个 shell 都会首先提示我们的虚拟文本。

当 Bash 扩展特殊的下划线变量时,它将使用我们的echo的第二个参数填充它。

一般来说,这种结构在使用单参数命令时很有用

$ mkdir test && cd ${_}
test $

但是如果我们改为启动脚本会发生什么?让我们看一个简单的单行:

#!/bin/bash
echo "This is underscore in our script before: ${_}"

让我们从终端运行它并向它传递一个参数:

$ ./shell.sh lorem
This is underscore in our script before: ./shell.sh

现在特殊变量被扩展为脚本文件的绝对路径。 在脚本内部,它的行为类似于我们的第一个场景:

#!/bin/bash
echo "This is underscore in our script before: ${_}"
echo "demo1234"
echo "This is underscore in our script after: ${_}"

让我们看看它输出了什么:

$ ./shell.sh
This is underscore in our script before: ./shell.sh
demo1234
This is underscore in our script after: demo1234

另一方面,与 Bash 混淆的一个普遍原因是存在具有有效名称<_> **的环境变量。 让我们在一个新的终端中尝试这个 hack:

$ env -i _=lorem/ipsum bash -c 'echo "First time [${_}]" && echo "Second time [${_}]"'
First time [lorem/ipsum]
Second time [First time [lorem/ipsum]]

嗯,这有点误导。让我们解释一下这里发生的事情。我们首先使用env命令启动了一个新环境。

然后,我们为环境变量*<_>*设置一个新值,该值是在我们的第一次 echo调用中选取的。

之后,在第二次echo调用中,Bash 用我们第一次echo调用的最后一个参数替换了它的值。

最后,下划线变量在 Bash MAILPATH 的上下文中也有特殊含义