跳转到内容

Bourne Shell 脚本/调试和信号处理

来自维基教科书,开放的书籍,开放的世界

在前面的章节中,我们已经向你介绍了 Bourne Shell 及其如何使用 shell 的语言编写脚本。我们已经尽力包括我们能想到的所有细节,这样你就可以编写出最好的脚本。但是,无论你多么认真地关注,无论你多么小心地编写脚本,总会有那么一天,你写的某些东西就是无法正常工作,无论你多么确信它应该正常工作。那么,你该怎么办呢?

在本模块中,我们将介绍 Bourne Shell 提供的工具,用来处理这些意外情况。包括你脚本的意外行为(你需要调试脚本才能解决)以及脚本周围的意外行为(由操作系统向你的脚本发送信号导致)。

调试标志

[编辑 | 编辑源代码]

所以,现在是深夜,你刚刚完成了一个漫长而复杂的 shell 脚本,你倾注了三天的心血,只吃咖啡、可乐和披萨...... 它就是无法正常工作。在里面某个地方,有一个你无法找到的 bug。有些东西出错了,出现了某种意外行为,或者其他一些事情让你抓狂。那么,你该怎么调试这个脚本呢?当然,你可以用大量的“echo”命令来调试,但有没有更简单的方法呢?

一般来说,调试任何程序最有效的方法是逐语句跟踪程序的执行过程,以查看程序究竟在做什么。最先进的形式(由现代 IDE 提供)允许你通过在特定点停止执行并检查程序的内部状态来跟踪程序。不幸的是,Bourne Shell 没有那么先进。但它提供了次好的选择:命令跟踪。shell 可以打印出执行的每个命令。

跟踪功能(有两个)可以使用“set”命令激活,或者在调用 shell 可执行文件时直接传递参数来激活。在任何一种情况下,你都可以使用“-x”参数、“-v”参数或两者。

-v
启用详细模式;shell 会在读取每个命令时打印它。
-x
这将启用命令跟踪;shell 会在执行每个命令时打印它。

让我们考虑以下脚本

divider.sh: 带有潜在错误的脚本
#!/bin/sh

DIVISOR=${1:-0}
echo $DIVISOR
expr 12 / $DIVISOR


让我们执行这个脚本,并且不传递命令行参数(所以我们使用默认值 0 作为 DIVISOR 变量)

运行脚本

代码:

$ sh divider.sh

输出:

0
expr: 除以零

当然,这并不难找出问题所在,但让我们再仔细看看。让我们看看 shell 到底执行了什么,使用-x参数

以跟踪模式运行脚本

代码:

$ sh -x divider.sh

输出:

+ DIVISOR=0

+ echo 0
0
+ expr 12 / 0

expr: 除以零

所以,很明显,shell 尝试执行除以零的操作。为了防止我们对零的来源感到困惑,让我们看看 shell 实际上读取了哪些命令

以详细模式运行脚本

代码:

$ sh -v divider.sh

输出:

#!/bin/sh


DIVISOR=${1:-0}
echo $DIVISOR
0
expr 12 / $DIVISOR

expr: 除以零

所以很明显,脚本读取了一个带有变量替换的命令,但它并没有正常工作。如果我们将这两个参数结合起来,得到的输出将告诉我们整个悲惨的故事

以最大调试模式运行脚本

代码:

$ sh -xv divider.sh

输出:

#!/bin/sh


DIVISOR=${1:-0}
+ DIVISOR=0
echo $DIVISOR
+ echo 0
0
expr 12 / $DIVISOR
+ expr 12 / 0

expr: 除以零

还有一个参数可以用来调试脚本,即-n参数。这会导致 shell 读取命令但不执行它们。你可以使用此参数对脚本进行语法检查。

放置参数的位置

[编辑 | 编辑源代码]

正如你在上一节中看到的,我们通过将参数作为命令行参数传递给 shell 可执行文件来使用 shell 参数。但我们不能将参数放在脚本本身中吗?毕竟,里面有一个解释器提示... 而且,确实可以这样做。让我们稍微修改一下脚本,然后尝试一下。

相同的脚本,但现在在解释器提示中添加了参数
#!/bin/sh -xv

DIVISOR=${1:-0}
echo $DIVISOR
expr 12 / $DIVISOR


运行脚本

代码:

$ chmod +x divider.sh
$ ./divider.sh

输出:

#!/bin/sh


DIVISOR=${1:-0}
+ DIVISOR=0
echo $DIVISOR
+ echo 0
0
expr 12 / $DIVISOR
+ expr 12 / 0
expr: 除以零

工作正常!

所以,没有问题。但有一个小陷阱。让我们再次尝试运行该脚本

再次运行脚本

代码:

$ sh divider.sh

输出:

0

expr: 除以零

调试去哪里了?

那么,调试去哪里了呢?好吧,你必须记住,当你尝试以可执行文件本身的形式执行脚本时,会使用解释器提示。但在上一个示例中,我们没有这样做。在上一个示例中,我们自己调用了 shell,并将其作为参数传递给它。所以 shell 在没有激活调试的情况下执行。如果我们执行“sh -xv divider.sh”,它会正常工作。

那么,源代码脚本(即使用点符号)呢?

再次运行脚本

代码:

$ . divider.sh

输出:

0

expr: 除以零

那里也没有调试...

这次脚本是由运行我们交互式 shell 的同一个 shell 进程执行的。同样的原理也适用:那里也没有调试。因为交互式 shell 并没有以调试标志启动。但我们也可以解决这个问题;这就是“set”命令的用武之地

再次运行脚本

代码:

$ set -xv
$ . divider.sh

输出:

. divider.sh

+ . divider.sh
#!/bin/sh -vx

DIVISOR=${1:-0}
++ DIVISOR=0
echo $DIVISOR
++ echo 0
0
expr 12 / $DIVISOR
++ expr 12 / 0
expr: 除以零

现在我们有了完整的跟踪。

现在我们在交互式 shell 中启用了调试,并且可以获得脚本的完整跟踪。事实上,我们甚至可以获得交互式 shell 调用脚本的跟踪!但现在,如果我们在交互式 shell 中使用调试启动一个新的 shell 进程,它会继承调试吗?

再次运行脚本

代码:

$ sh divider.sh

输出:

sh divider.sh

+ sh divider.sh
0
expr: 除以零

不完全是...

好吧,我们肯定获得了脚本被调用的跟踪,但没有脚本本身的跟踪。故事的寓意是:调试时,确保你知道你是在哪个 shell 中激活了跟踪。

顺便说一下,要关闭交互式 shell 中的跟踪,你可以执行“set +xv”或简单地执行“set -”。

退出脚本

[编辑 | 编辑源代码]

在编写或调试 shell 脚本时,有时需要在特定点退出(停止脚本执行)。你可以使用“exit”内置命令来执行此操作。该命令看起来像这样

exit [n]
* 其中 n(可选)是脚本的退出状态。

如果你省略了可选的退出状态,脚本的退出状态将是调用“exit”之前执行的最后一个命令的退出状态。

例如

从脚本中退出
#!/bin/sh -x
echo hello
exit 1


如果你运行这个脚本,然后测试输出状态,你会看到(使用“$?”内置变量)

检查退出状态

代码:

echo $?

输出:

1

在使用“exit”时需要注意的一点是:“exit”实际上会终止正在执行的进程。因此,如果你正在执行带有解释器提示的可执行脚本,或者显式调用了 shell 并将脚本作为参数传递给它,那么这没问题。但如果你已经源代码脚本(使用了点符号),那么你的脚本将由运行你的交互式 shell 的进程执行。因此,使用“exit”命令可能会意外终止你的 shell 会话并使你注销!

“exit”有一个变体,专门用于不是自身进程的代码块。这就是“return”命令,它与“exit”命令非常相似

return [n]
* 其中 n(可选)是代码块的退出状态。

return 的语义与 'exit' 完全相同,但主要用于 shell 函数(它使函数返回而不会终止脚本)。以下是一个示例

exit_and_return.sh:一个带有函数和显式返回的脚本
#!/bin/sh

sayHello() {
  echo 'Hi there!!'
  return 2
}

echo 'Hello World!!'
sayHello
echo $?
echo 'Goodbye!!'
exit


如果运行此脚本,将会看到以下内容

运行脚本

代码:

./exit_and_return.sh

输出:

Hello World!!

Hi there!!
2

Goodbye!!

函数以 2 的可测试退出状态返回。但是,由于脚本执行的最后一个命令 ('echo Goodbye!!') 退出时没有错误,因此脚本的总体退出状态为零。

您也可以使用 'return' 语句退出通过源代码执行的 shell 脚本(该脚本将由运行交互式 shell 的进程运行,因此这不是子进程)。但这通常不是一个好主意,因为这会限制脚本的源代码执行方式:如果您尝试以其他方式运行它,仅使用 'return' 语句会导致错误。

信号捕获

[编辑 | 编辑源代码]

语法、命令错误或调用 'exit' 不是唯一可以阻止脚本执行的东西。运行脚本的进程也可能突然从操作系统接收信号。信号是一种简单的事件通知形式:将信号视为房间里突然亮起的一盏小灯,以告知您房间外有人需要您的注意。只是不止一盏灯。Unix 系统通常允许使用许多不同的信号,因此它更像是拥有一堵满是小灯的墙,每盏灯都可能突然开始闪烁。

在像 MS-DOS 这样的单进程操作系统上,生活很简单。环境是单进程的,这意味着您的代码(一旦运行)拥有完整的机器控制权。任何到达的信号始终是硬件中断(例如,计算机发出信号表示软盘已准备好读取),如果您不需要外部硬件,您可以安全地忽略所有这些信号;要么是您不感兴趣的某些设备事件,要么是出了问题——在这种情况下,计算机无论如何都会崩溃,您无能为力。

在 Unix 系统上,生活并不那么容易。在 Unix 上,信号可以来自任何地方(包括其他程序)。您也永远无法完全控制系统。信号可能是硬件中断,也可能是另一个程序发出的信号,或者可能是厌倦了等待的用户登录到第二个 shell 会话,现在正在命令您的进程死亡。从好的方面来说,生活仍然不那么复杂。大多数 Unix 系统(当然还有 Bourne Shell)都为大多数信号提供了默认处理。通常,您仍然可以安全地忽略信号并让 shell 或操作系统处理它们。事实上,如果所讨论的信号是 9 号(松散地翻译:KILL!! KILL!! DIE!! DIE, RIGHT NOW!!),您可能应该忽略它并让操作系统杀死您的进程。

但有时您只需要进行自己的信号处理。可能是因为您一直在处理文件,并且希望在进程死亡之前进行一些清理。或者因为信号是您的多进程程序设计的一部分(例如,侦听信号 16,即“用户定义的信号 1”)。这就是 Bourne Shell 为我们提供 'trap' 命令的原因。

trap 命令实际上非常简单(尤其是如果您曾经做过任何形式的事件驱动编程)。本质上,trap 命令表示“如果此进程接收以下信号之一,请执行此操作”。它看起来像这样

trap [command string] signal0 [signal1] ...
* 其中 command string 是一个包含要执行的命令的字符串,如果捕获到信号
signaln 是要捕获的信号。

例如,要捕获用户定义的信号 1(通常称为 SIGUSR1)并在出现时打印“Hello World”,您需要执行以下操作

捕获 SIGUSR1
$ trap "echo Hello World" 16


大多数 Unix 系统还允许您使用符号名称(我们稍后会回到这些名称)。因此,您也可以执行以下操作

捕获 SIGUSR1(稍微容易一些)
$ trap "echo Hello World" SIGUSR1


如果您能做到这一点,您通常也可以做到以下几点

捕获 SIGUSR1(更简单)
$ trap "echo Hello World" USR1


传递给 'trap' 的命令字符串是一个包含命令列表的字符串。但它不会被视为命令列表;它只是一个字符串,并且只有在捕获到信号后才会被解释。命令字符串可以是以下任何内容

一个字符串
包含命令列表的字符串。允许使用任何和所有命令,并且您还可以使用以分号分隔的多个命令(即命令列表)。
''
空字符串。实际上这与前一种情况相同,因为这是空的命令字符串。这会导致 shell 在捕获到信号时不执行任何操作——换句话说,忽略信号。
空,空字符串。这会将信号处理重置为默认信号操作(通常是“杀死进程”)。

在命令列表之后,您可以列出您希望与该命令列表关联的任意多个信号。您以这种方式设置的陷阱对跟随 'trap' 命令的每个命令都有效。

现在,可能需要查看一个示例来澄清一下。您可以在任何地方(照常)使用 'trap',包括交互式 shell。但大多数情况下,您希望将陷阱引入脚本而不是交互式 shell 进程。让我们创建一个使用 'trap' 命令的简单脚本

一个简单的信号陷阱
#!/bin/sh

trap 'echo Hello World' SIGUSR1

while [ 1 -gt 0 ]
do
   echo Running....
   sleep 5
done


这个脚本本身是一个无限循环,它打印“Running...”然后休眠五秒钟。但我们添加了一个 'trap' 命令(循环之前,否则陷阱永远不会被执行,它不会影响循环),它在进程接收 SIGUSR1 信号时打印“Hello World”。因此,让我们通过运行脚本来启动该进程

无限循环...

代码:

$ ./trap_signal.sh

输出:

Running....
Running....
Running....
Running....
Running....
Running....
...
这可能会让人厌烦......

要启动陷阱,我们必须向正在运行的进程发送信号。为此,登录到一个新的 shell 会话,并使用一个进程工具(如 'ps')来查找正确的进程 ID(PID)

查找进程 ID

代码:

$ ps -ef | grep signal

输出:

bzt 10865 7067 0 15:08 pts/0 00:00:00 /bin/sh ./trap_signal.sh

bzt 10808 10415 0 15:12 pts/1 00:00:00 fgrep signal

我们的 PID 是 10865

现在,要向该进程发送信号,我们使用内置于 Bourne Shell 的 'kill' 命令

kill [-signal] ID [ID] ...
* 其中 -signal 是要发送的信号(可选;默认为 15 或 SIGTERM)
ID 是要发送信号的进程的 PID(至少其中一个)

顾名思义,'kill' 的初衷实际上是杀死进程(这与默认信号为 SIGTERM 以及默认信号处理程序为终止相符)。但实际上它做的只是向进程发送信号。因此,例如,我们可以像这样向我们的进程发送 SIGUSR1

让我们启动陷阱...

代码:

kill -SIGUSR1 10865

输出:

...
Running....
Running....
Running....
Running....
Running....
Hello World
Running....
Running....
...

您可能会注意到,在“Hello World!”出现之前有一个短暂的暂停;它不会在运行的 'sleep' 命令完成之前出现。但之后,它就出现了。但您可能有点惊讶:信号并没有杀死进程。这是因为 'trap'完全用您设置的命令替换了信号处理程序。而单独的 'echo Hello World' 不会杀死进程...这里的教训很简单:如果您希望您的信号陷阱终止进程,请确保您包含一个 'exit' 命令。

在命令列表中包含多个命令以及可能捕获许多信号的情况下,您可能担心 'trap' 语句可能会变得很乱。幸运的是,您还可以使用 shell 函数作为 'trap' 中的命令。以下示例说明了这一点以及退出事件处理程序和非退出事件处理程序之间的区别

带有 shell 函数作为处理程序的陷阱
#!/bin/sh

exit_with_grace() {
  echo Goodbye World
  exit
}

trap "exit_with_grace" USR1 TERM QUIT
trap "echo Hello World" USR2

while [ 1 -gt 0 ]
do
   echo Running....
   sleep 5
done


系统信号

[编辑 | 编辑源代码]

以下是 POSIX-1003 2001 版标准中对信号的正式定义

A mechanism by which a process or thread may be notified of, or affected by, an event occurring in the system.
Examples of such events include hardware exceptions and specific actions by processes.
The term signal is also used to refer to the event itself.

换句话说,信号是从一个进程(可能是系统进程)发送到另一个进程的某种简短消息。但这究竟意味着什么?信号是什么样的?上面给出的定义有点含糊...

如果您对在计算中给出含糊定义时会发生什么有任何了解,您已经知道上面问题的答案:每个开发的 Unix 版本都提出了自己对“信号”的定义。它们几乎都选择了由一个整数组成(因为这很简单)的消息,但并非所有地方都完全相同。然后进行了一些标准化,Unix 系统将自己组织成 System V 和 BSD 版本,最后每个人都同意以下定义

The system signals are the signals listed in /usr/include/sys/signal.h .

天啊,这太有帮助了...

从那时起发生了很多事情,包括 POSIX-1003 标准的定义。该标准标准化了大多数 Unix 接口(包括第 1 部分 (1003.1) 中的 shell),最终提出了一个标准的符号信号名称列表和默认处理程序。因此,通常情况下,现在您可以利用该列表并期望您的脚本在大多数系统上都能正常工作。只是要注意,它并不完全万无一失...

POSIX-1003 定义了以下表格中列出的信号。给出的值是典型数值,但不是强制性的,您不应该依赖它们(但另一方面,您使用符号值是为了不使用实际值)。

POSIX 系统信号
信号 默认操作 描述 典型值
SIGABRT 中止并转储核心文件 中止进程并生成核心转储 6
SIGALRM 终止 闹钟。 14
SIGBUS 中止并转储核心文件 访问内存对象的未定义部分。 7, 10
SIGCHLD 忽略 子进程终止、停止 20, 17, 18
SIGCONT 继续进程(如果已停止) 继续执行,如果已停止。 19,18,25
SIGFPE 中止并转储核心文件 错误的算术运算。 8
SIGHUP 终止 挂起。 1
SIGILL 中止并转储核心文件 非法指令。 4
SIGINT 终止 终端中断信号。 2
SIGKILL 终止 杀死(无法捕获或忽略)。 9
SIGPIPE 终止 写入没有读取者的管道(即断开管道)。 13
SIGQUIT 终止 终端退出信号。 3
SIGSEGV 中止并转储核心文件 无效的内存引用。 11
SIGSTOP 停止进程 停止执行(无法捕获或忽略)。 17,19,23
SIGTERM 终止 终止信号。 15
SIGTSTP 停止进程 终端停止信号。 18,20,24
SIGTTIN 停止进程 后台进程尝试读取。 21,21,26
SIGTTOU 停止进程 后台进程尝试写入。 22,22,27
SIGUSR1 终止 用户定义信号 1。 30,10,16
SIGUSR2 终止 用户定义信号 2。 31,12,17
SIGPOLL 终止 可轮询事件。 -
SIGPROF 终止 性能分析计时器超时。 27,27,29
SIGSYS 中止并转储核心文件 错误的系统调用。 12
SIGTRAP 中止并转储核心文件 跟踪/断点陷阱 5
SIGURG 忽略 套接字上有高带宽数据可用。 16,23,21
SIGVTALRM 终止 虚拟计时器超时。 26,28
SIGXCPU 中止并转储核心文件 CPU 时间限制已超过。 24,30
SIGXFSZ 中止并转储核心文件 文件大小限制已超过。 25,31

之前我们谈到了作业控制以及挂起和恢复作业。作业挂起和恢复实际上完全基于向进程发送信号,因此实际上可以使用 'kill' 和信号列表完全控制作业停止和启动。要挂起进程,请向其发送 SIGSTOP 信号。要恢复,请向其发送 SIGCONT 信号。

Err... ERR?

[编辑 | 编辑源代码]

如果您在线阅读有关 'trap' 的内容,您可能会遇到另一种称为 **ERR** 的“信号”。它与 'trap' 的使用方式与常规信号相同,但实际上它根本不是信号。它用于捕获命令错误(即非零退出状态),例如

错误捕获

代码:

$ trap 'echo HELLO WORLD' ERR
$ expr 1 / 0

输出:

expr: 除以零

HELLO WORLD

非零退出状态被捕获,就像它是一个信号一样。

那么为什么我们在讨论 'trap' 时没有早点介绍这个“信号”呢?嗯,我们把它留到系统信号和非系统信号的讨论中是有原因的:ERR 根本不是标准。它是由 Korn Shell 添加的,以使生活更轻松,但没有被 POSIX 标准采用,它肯定不是原始 Bourne Shell 的一部分。因此,如果您使用它,请记住您的脚本可能不再可移植。


下一页: 附录 A:命令参考 | 上一页: 模块化
首页: Bourne Shell 脚本
华夏公益教科书