LPI 102 考试准备,主题 109: Shell、脚本、编程和编译

初级管理(LPIC-1)主题 109

在这个教程中,Ian Shields 将继续帮助您准备参加 Linux Professional Institute® 的初级管理(LPIC-1)考试 102。这是 9 篇系列教程 中的第 5 篇。在本教程中,Ian 将向您介绍 Bash shell、脚本和 Bash shell 编程方面的内容。在学完本教程之后,您将掌握如何对自己的 shell 环境进行定制,如何使用 shell 编程结构来创建函数和脚本,如何设置和取消环境变量,以及如何使用各种登录脚本。

Ian Shields, 高级程序员, IBM

Ian ShieldsIan Shields 为 developerWorks Linux 专区的许多 Linux 项目工作。他是 IBM 北卡罗莱那州 Research Triangle Park 的一名高级程序员。他于 1973 年作为一名系统工程师加入 IBM 位于澳大利亚堪培拉的子公司。之后,在加拿大蒙特利尔和北卡罗莱那州 RTP 从事通信系统和普及运算。他拥有多项专利。他毕业于 Australian National University,本科学位是纯数学和哲学。他拥有北卡罗来纳州立大学的计算机学士和博士学位。



2007 年 3 月 23 日

开始之前

本节解释这些教程讲授什么内容,以及如何从这些教程获得最大的收益。

关于本系列教程

Linux Professional Institute(LPI)对 Linux 系统管理员的认证分为两级:初级(也称为 "认证级别 1")和中级(也称为 “认证级别 2”)。要获得认证级别 1,您必须通过 101 和 102 的考试;要获得认证级别 2,您必须通过 201 和 202 的考试。

developerWorks 提供教程来帮助您准备这 4 门考试。每门考试包含几个主题,每个主题在 developerWorks 上都有一个对应的自学教程。对于 LPI 102 考试,有以下 9 个主题和对应的 developerWorks 教程:

表 1. LPI 102 考试 :教程和主题
LPI 102 考试主题developerWorks 教程教程摘要
主题 105LPI 102 考试准备:
内核
学习如何安装和维护 Linux 内核和内核模块。
主题 106LPI 102 考试准备:
引导、初始化、关机和运行级别
学习如何引导系统、设置内核参数以及关闭或重新引导系统。
主题 107LPI 102 考试准备:打印学习如何在 Linux 系统上管理打印机、打印队列和用户的打印作业。
主题 108LPI 102 考试准备:文档学习如何使用并管理本地文档、查找 Internet 上的文档以及使用自动化登录消息来通知用户系统事件的发生。
主题 109LPI 102 考试准备:shell、脚本、编程和编译(本教程)。学习如何对自己的 shell 环境进行定制以满足用户需求、如何为经常使用的命令序列编写 Bash 函数、如何编写简单的新脚本、使用 shell 语法进行循环和测试,以及如何对现有脚本进行定制。参见下面详细的 目标
主题 111LPI 102 考试准备:
管理作业
敬请期待!
主题 112LPI 102 考试准备:
网络基础
敬请期待!
主题 113LPI 102 考试准备:
网络服务
敬请期待!
主题 114LPI 102 考试准备:
安全性
敬请期待!

要想通过考试 101 和 102(并获得 1 级认证),您应该能够:

  • 在 Linux 命令行上进行操作。
  • 执行简单的维护作业:帮助用户、向更大的系统中添加用户、备份和恢复、关机和重新引导。
  • 安装和配置工作站(包括 X)并将它连接到 LAN,或者通过调制解调器将单独的 PC 连接到 Internet。

要想继续准备 1 级认证,请参考 针对 LPI 101 和 102 考试的 developerWorks 教程 以及 全套 developerWorks LPI 教程

Linux Professional Institute 不为任何第三方考试准备资料或技术做担保。详情请联系 info@lpi.org

关于本教程

欢迎阅读 “Shell、脚本、编程和编译”,这是针对 LPI 102 考试而设计的 9 篇教程中的第 5 篇。在本教程中,您将学习如何使用 Bash shell,如何使用 shell 编程结构来创建函数和脚本,如何设置并取消环境变量,以及如何使用各种登录脚本。

本教程的标题与 LPI 102 考试的主题是对应的,也包括了“编程和编译”;不过 LPI 的目标将“编程”限定于编写 shell 函数和脚本。有关编译程序的其他目标并没有包含在这个主题内。

本教程是按照这个主题的 LPI 目标组织的。大致来说,权值越高的学习目标,在考试中出的题就越多。

表 2. Shell、脚本、编程和编译:本教程中涉及的考试目标
LPI 考试目标目标权值目标摘要
1.109.1
定制并使用 shell 环境
权值 5定制 shell 环境以满足用户需求。设置环境变量(在登录时或在派生新 shell 时)。对经常使用的命令序列编写 Bash 函数。
1.109.2
定制或编写简单脚本
权值 3编写简单 Bash 脚本并对现有脚本进行定制。

前提条件

要想从本教程获得最大的收益,您应该具备 Linux 的基础知识并且拥有一个可以用来实践的 Linux 系统。

本教程依赖于本 LPI 考试系列前面的教程介绍的内容,所以您可能需要先参考 针对考试 101 的教程 。您尤其应该熟悉 “LPI 101 考试准备(主题 103):GNU 和 UNIX 命令” 教程,因为其中有许多对本教程很有帮助的内容,尤其是 “使用命令行” 一节。

不同的程序版本可能会导致不同格式的输出,所以您在进行实践时获得的结果可能会与本教程中的清单和图不完全一样。


Shell 定制

本节介绍了初级管理(LPIC-1)考试 102 的 1.109.1 主题的内容。这个主题的权值为 5。

在本节中,我们将学习如何:

  • 设置并取消环境变量
  • 使用配置文件在登录或派生新 shell 时设置环境变量
  • 对经常使用的命令序列编写 shell 函数
  • 使用命令列表

Shell 和环境

在出现图形界面之前,程序员都是使用打字机终端或 ASCII 显示终端连接到 UNIX® 系统的。用户可以使用打字机终端输入命令,输出结果通常会被打印到连续的纸张上。大部分 ASCII 显示终端都是每行 80 个字符,每屏 25 行,不过也有比这更大或更小的终端。程序员输入一条命令并按下回车键之后,系统就会解释并执行这条命令。

尽管在当今这个使用拖拽式图形界面的时代,这一切看起来似乎太过原始,但是与原来编写程序、打卡、对卡迭(card deck)进行汇编并运行程序的方式相比,这已经是非常大的一个进步了。随着编辑器的出现,程序员甚至可以作为卡像来创建程序,并在终端会话中编译程序。

在终端中输入的字节流向 shell 提供了一个标准输入流,shell 返回的字符流可以打印到纸上,也可以显示到标准输出 上。

接受并执行命令的程序称为 shell。它位于您和操作系统之间。UNIX shell 和 Linux shell 的功能都非常强大,可以通过组合一些基本的函数来构造非常复杂的操作。通过使用编程结构则可以构建一些函数在 shell 中直接执行,或者将这些函数保存成 shell 脚本 的形式,这样就可以一次次重用这些函数了。

有时需要在系统引导之前就执行一些命令,以便能够进行终端连接;有时又需要周期性地执行命令,而不管您登录与否。shell 可以为您完成这些功能。标准输入和输出并不需要来自于(或定向到)终端处的真实用户。

在本节中,将学习更多有关 shell 的内容。具体来说,您将学习有关 bash(又称为 Bourne again)shell 的内容,它是对原来 Bourne shell 的一个增强,另外还提供了其他 shell 所具有的一些特性,以及对 Bourne shell 所做的一些更改以使其更加兼容 POSIX。

POSIX 是 Portable Operating System Interface for uniX 的简称,它是一系列 IEEE 标准,总称为 IEEE 1003。这些标准中的第一个标准是 IEEE Standard 1003.1-1988,它是在 1988 年发布的。其他知名的 shell 包括 Korn shell(ksh)、C shell(csh)及其派生产品 tcsh、Almquist shell(ash)及其 Debian 派生产品(dash)。一些脚本常常需要用到上述某个 shell 的特性,所以要对这些 shell 有一些了解。

您与计算机的很多交互特性在这些会话中都是相同的。回想一下在教程 “LPI 101 考试准备(主题 103):GNU 和 UNIX 命令” 中,当使用 Bash shell 时,就拥有了一个 shell 环境,它定义了很多内容,例如提示符格式、主目录、工作目录、shell 名、已经打开的文件、已经定义的函数等。每个 shell 进程都可以使用这个环境。shell(包括 bash)让您可以创建并修改 shell 变量,并可以将其导出 到环境中由在 shell 中运行的其他进程或从当前 shell 中派生的其他 shell 使用。

环境变量和 shell 变量都有名称。您可以通过在变量名前加上一个 '$' 符号来引用变量的值。一些常用的 bash 变量如表 3 所示。

表 3. 常用 bash 环境变量
变量名功能
USER已登录用户的用户名
UID已登录用户的数字用户 id
HOME用户的主目录
PWD当前工作目录
SHELLshell 名
$进程 id(或正在运行的 Bash shell 进程或其他进程的 PID
PPID启动这个进程的进程的进程 id (即父进程的 id)
?上一个命令的退出码

设置变量

在 Bash shell 中,可以通过在一个名字后面紧跟上一个等号(=)来创建或设置 shell 变量。变量名(或标识符)是由字符、数字和下划线构成的单词,它只能由字符或下划线开头。变量是大小写敏感的,例如 var1 和 VAR1 是不同的两个变量。按照惯例,变量 —— 尤其是导出后的变量 —— 都采用大写,不过这并不是硬性要求。通常,$$ 和 $? 是 shell 参数,而不是变量。它们只能被引用;无法对它们进行赋值。

在创建 shell 变量时,通常都会希望将该变量导出 到环境中,这样从这个 shell 中启动的其他进程也都可以使用该变量了。但所导出的变量对父 shell 不可用。可以使用 export 命令导出一个变量名。在 bash 中,可以在一个步骤中完成赋值和导出。

为了展示赋值和导出操作,让我们在 Bash shell 中运行 bash 命令,然后在这个新 Bash shell 中在运行 Korn shell(ksh)。我们会使用 ps 命令来显示有关正在运行的命令的信息。

清单 1. 设置并导出 shell 变量
[ian@echidna ian]$ ps -p $$ -o "pid ppid cmd"
  PID  PPID CMD
30576 30575 -bash
[ian@echidna ian]$ bash
[ian@echidna ian]$ ps -p $$ -o "pid ppid cmd"
  PID  PPID CMD
16353 30576 bash
[ian@echidna ian]$ VAR1=var1
[ian@echidna ian]$ VAR2=var2
[ian@echidna ian]$ export VAR2
[ian@echidna ian]$ export VAR3=var3
[ian@echidna ian]$ echo $VAR1 $VAR2 $VAR3
var1 var2 var3
[ian@echidna ian]$ echo $VAR1 $VAR2 $VAR3 $SHELL
var1 var2 var3 /bin/bash
[ian@echidna ian]$ ksh
$ ps -p $$ -o "pid ppid cmd"
  PID  PPID CMD
16448 16353 ksh
$ export VAR4=var4
$ echo $VAR1 $VAR2 $VAR3 $VAR4 $SHELL
var2 var3 var4 /bin/bash
$ exit
$ [ian@echidna ian]$ echo $VAR1 $VAR2 $VAR3 $VAR4 $SHELL
var1 var2 var3 /bin/bash
[ian@echidna ian]$ ps -p $$ -o "pid ppid cmd"
  PID  PPID CMD
16353 30576 bash
[ian@echidna ian]$ exit
[ian@echidna ian]$ ps -p $$ -o "pid ppid cmd"
  PID  PPID CMD
30576 30575 -bash
[ian@echidna ian]$ echo $VAR1 $VAR2 $VAR3 $VAR4 $SHELL
/bin/bash

注意:

  1. 在这些操作开始时,Bash shell 的 PID 是 30576。
  2. 第二个 Bash shell 的 PID 是 16353,其父 shell 的 PID 是 30576,也就是原来的 Bash shell。
  3. 我们在第二个 Bash shell 中创建了 VAR1、VAR2 和 VAR3 三个变量,但是只导出了 VAR2 和 VAR3。
  4. 在 Korn shell 中,我们创建了 VAR4。echo 命令只显示了 VAR2、VAR3 和 VAR4 的值,这就证实了 VAR1 的确没有导出。看到提示符改变之后,SHELL 变量的值却还未改变,您会非常奇怪么?通常不能总依赖 SHELL 来告诉您正在哪个 shell 下运行,不过 ps 命令的确可以告诉您实际的命令。注意 ps 会在第一个 Bash shell 前面放上一个连字符(-)来说明这是一个登录 shell
  5. 现在回到第二个 Bash shell 中,我们可以看到 VAR1、VAR2 和 VAR3。
  6. 最后,当我们返回到原始的 shell 中时,新变量都不存在了。

清单 2 显示了在这些常用的 bash 变量中可以看到什么。

清单 2. 环境和 shell 变量
[ian@echidna ian]$ echo $USER $UID
ian 500
[ian@echidna ian]$ echo $SHELL $HOME $PWD
/bin/bash /home/ian /home/ian
[ian@echidna ian]$ (exit 0);echo $?;(exit 4);echo $?
0
4
[ian@echidna ian]$ echo $$ $PPID
30576 30575

环境和 C shell

在诸如 C 和 tcsh shell 之类的 shell 中,可以使用 set 命令在 shell 中设置变量,使用 setenv 命令来设置并导出变量。清单 3 中给出的语法与 export 命令的语法稍有不同。请注意在使用 set 命令时使用的等号(=)。

清单 3. 在 C shell 中设置环境变量
ian@attic4:~$ echo $VAR1 $VAR2

ian@attic4:~$ csh
% set VAR1=var1
% setenv VAR2 var2
% echo $VAR1 $VAR2
var1 var2
% bash
ian@attic4:~$ echo $VAR1 $VAR2
var2

取消变量

可以使用 unset 命令从 Bash shell 中清除变量。可以使用 -v 选项来确保删除变量定义。函数可以使用与变量相同的名字,因此如果希望清除函数定义,就请使用 -f 选项。在没有使用 -f-v 的情况下,如果存在这样一个变量,那么 bash 的 unset 命令就会清除变量定义;否则,如果存在这样一个函数,这个命令就清除函数定义(函数将在后面的 Shell 函数 一节中更详细地加以介绍)。

清单 4. bash unset 命令
ian@attic4:~$ VAR1=var1
ian@attic4:~$ VAR2=var2
ian@attic4:~$ echo $VAR1 $VAR2
var1 var2
ian@attic4:~$ unset VAR1
ian@attic4:~$ echo $VAR1 $VAR2
var2
ian@attic4:~$ unset -v VAR2
ian@attic4:~$ echo $VAR1 $VAR2

默认情况下,bash 会将取消的变量视为该变量的值为空,因此您可能会纳闷为什么一定要取消变量,为什么不仅仅为其赋一个空值呢。如果引用了未定义的变量,Bash 和很多其他 shell 都会允许您生成一个错误。使用命令 set -u 可以针对引用未定义的变量的情况生成一个错误,使用 set +u 可以禁用这种警告,如清单 5 所示。

清单 5. 针对取消的变量生成错误
ian@attic4:~$ set -u
ian@attic4:~$ VAR1=var1
ian@attic4:~$ echo $VAR1
var1
ian@attic4:~$ unset VAR1
ian@attic4:~$ echo $VAR1
-bash: VAR1: unbound variable
ian@attic4:~$ VAR1=
ian@attic4:~$ echo $VAR1

ian@attic4:~$ unset VAR1
ian@attic4:~$ echo $VAR1
-bash: VAR1: unbound variable
ian@attic4:~$ unset -v VAR1
ian@attic4:~$ set +u
ian@attic4:~$ echo $VAR1

ian@attic4:~$

注意取消一个不存在的变量并不会产生错误,即使在指定 set -u 时也是如此。

配置文件

在登录 Linux 系统时,您的 id 就有了一个默认 shell,它就是您的登录 shell。如果这个 shell 是 bash,那么它就会在您控制系统之前先执行几个配置脚本。如果存在 /etc/profile 文件,就首先执行这个文件。根据发行版的不同,/etc 中的其他脚本也可能会执行,例如 /etc/bash.bashrc 或 /etc/bashrc。这些脚本运行之后,如果主目录中存在脚本,该脚本也会被执行。Bash 会按照 ~/.bash_profile、~/.bash_login 和 ~/.profile 的顺序来查找文件。最先找到的文件会首先执行。

当您登出系统时,如果主目录中存在 ~/.bash_logout 脚本,bash 就会执行它。

一旦登录进系统并使用 bash,您还可以启动另外一个 shell(称为交互式 shell)来运行命令,例如在后台运行命令。在这种情况中,bash 只会执行 ~/.bashrc 脚本(假设这个脚本存在)。通常可以使用如清单 6 所示的命令在 ~/.bash_profile 检查这个脚本,以便可以在登录时或在启动交互式 shell 时执行它。

清单 6. 检查 ~/.bashrc
# include .bashrc if it exists
if [ -f ~/.bashrc ]; then
    . ~/.bashrc
fi

可以使用 --login 选项强制 bash 像登录 shell 一样读取配置文件。如果不希望执行登录 shell 的配置文件,可以指定 --noprofile 选项。类似地,如果希望对某个交互式 shell 不执行 ~/.bashrc 文件,可以使用 --norc 选项来启动 bash。也可以通过指定 --rcfile 选项加上希望使用的文件名来强制 bash 使用 ~/.bashrc 之外的文件。清单 7 展示了创建一个名为 testrc 的简单文件并使用 --rcfile 选项来使用这个文件的例子。注意 VAR1 变量并不是 在外部 shell 中设置的,而是通过 testrc 文件针对内部 shell 设置的。

清单 7. 使用 --rcfile 选项
ian@attic4:~$ echo VAR1=var1>testrc
ian@attic4:~$ echo $VAR1

ian@attic4:~$ bash --rcfile testrc
ian@attic4:~$  echo $VAR1
var1

以其他方式启动 bash

除了前面介绍的这种在终端中运行 bash 的标准方法之外,bash 也可以通过其他方法加以使用。

除非您引用(source) 脚本在当前 shell 中运行,否则它就会在自己的非交互式 shell 中运行,上面的配置文件都不会被读取。然而,如果设置了 BASH_ENV 变量,那么 bash 就会对这个值进行扩展,并假设它是一个文件名。如果这个文件存在,那么 bash 就会在非交互式 shell 中执行任何脚本或命令之前先执行这个文件。清单 8 通过两个简单的文件展示了这一点。

清单 8. 使用 BASH_ENV
ian@attic4:~$ cat testenv.sh
#!/bin/bash
echo "Testing the environment"
ian@attic4:~$ cat somescript.sh
#!/bin/bash
echo "Doing nothing"
ian@attic4:~$ export BASH_ENV="~/testenv.sh"
ian@attic4:~$ ./somescript.sh
Testing the environment
Doing nothing

非交互式 shell 也可以使用 --login 选项启动,从而强制配置文件的执行。

Bash 也可以使用 --posix 选项以 POSIX 模式启动。这种模式与非交互式 shell 非常类似,只不过在这种模式下,要执行的文件是在 ENV 环境变量中设定的。

在 Linux 系统中常常会使用一个符号链接来以 /bin/sh 运行 bash。当 bash 检测到它正在以 sh 的名义运行时,它就会试图遵循老式 Bourne shell 的启动行为,而同时又可以兼容 POSIX 标准。当作为登录 shell 运行时,bash 会试图读取并执行 /etc/profile 和 ~/.profile 文件。当使用 sh 命令作为一个交互式 shell 运行时,bash 会试图执行由 ENV 变量指定的文件,与在 POSIX 模式下被调用时一样。当作为 sh 交互运行时,它 会使用由 ENC 变量指定的文件;--rcfile 选项会一直被忽略。

如果 bash 是由远程 shell 守护进程调用的,那么它的行为就与交互式 shell 非常类似,如果存在 ~/.bashrc 文件就会使用该文件。

Shell 别名

Bash shell 允许为命令定义一些 别名。使用别名的最常见原因是为了给命令提供其他名字,或者为命令提供一些默认参数。很多年以来,vi 编辑器一直都是 UNIX 和 Linux 系统上的一个主要工具。vim(Vi IMproved)编辑器与 vi 非常类似,不过有很多改进。因此如果您在使用编辑器时习惯于输入 “vi”,但是实际上却更喜欢使用 vim,那么您就可以借助于别名。清单 9 显示了如何使用 alias 命令来实现这种功能。

清单 9. 使用 vi 作为 vim 的别名
[ian@pinguino ~]$ alias vi='vim'
[ian@pinguino ~]$ which vi
alias vi='vim'
   /usr/bin/vim
[ian@pinguino ~]$ /usr/bin/which vi
/bin/vi

注意在这个例子中,如果使用 which 命令来查看 vi 程序的位置,那就会看到两行输出:第一个是别名,第二个是 vim 的位置(/usr/bin/vim)。然而,如果使用完整路径来执行 which 命令(/usr/bin/which),就可以获得 vi 命令的位置。如果您猜测这可能意味着 which 命令本身在这个系统上就是一个别名,那么您就猜对了。

可以使用 alias 命令来显示所有的别名(如果没使用任何选项,或者只使用了 -p 选项),还可以通过给出别名作为参数但不进行赋值来显示一个或多个别名。清单 10 显示了 whichvi 的别名。

清单 10. which 和 vi 的别名
[ian@pinguino ~]$ alias which vi
alias which='alias | /usr/bin/which --tty-only --read-alias --show-dot --show-tilde'
alias vi='vim'

which 命令的别名有些奇怪。为什么会将 alias 命令(没有参数)的输出定向到 /usr/bin/which 上呢?如果查看一下 which 命令的手册页,就会发现 --read-alias 选项通知 which 从标准输入读取一个别名列表,并将匹配项输出到标准输出设备上。这允许 which 命令报告别名和 PATH 中的命令,这种用法非常常见,因此您的发行版可能已将其作为默认设置了。这是很好的一个做法,因为如果别名和命令名相同,那么 shell 就首先执行别名。知道了这一点以后,就可以使用 alias which 来加以检查。还可以通过运行 which which 命令来了解是否为 which 命令设置了这种别名。 .

别名的另外一种常见用法是自动为命令添加参数,正如在上面看到的 which 命令的 --read-alias 和其他几个参数一样。这种方法也可用在 root 用户使用 cpmvrm 命令的时候,这样在删除或覆盖文件之前能够显示一个提示。具体用法如清单 11 所示。

清单 11. 为了安全起见添加参数
[root@pinguino ~]# alias cp mv rm
alias cp='cp -i'
alias mv='mv -i'
alias rm='rm -i'

命令列表

在之前的教程 “LPI 101 考试准备(主题 103):GNU 和 UNIX 命令” 中,您已经学习了命令序列列表。您刚刚又看到了别名中使用的管道(|)操作符,您也可以使用命令列表。举个简单的例子来说,假设您希望使用一个命令来显示当前目录中的内容,以及当前目录及其子目录所使用的空间。让我们就将其称为 lsdu 命令。因此您可以简单地将 lsdu 命令序列赋值给别名 lsdu。清单 12 给出了实现这种功能的正确方法和错误方法。在阅读之前请仔细查看一下,并考虑为什么第一次尝试会失败。

清单 12. 命令序列的别名
[ian@pinguino developerworks]$ alias lsdu=ls;du -sh # Wrong way
2.9M    .
[ian@pinguino developerworks]$ lsdu
a tutorial  new-article.sh   new-tutorial.sh   readme  tools  xsl
my-article  new-article.vbs  new-tutorial.vbs  schema  web
[ian@pinguino developerworks]$ alias 'lsdu=ls;du -sh' # Right way way
[ian@pinguino developerworks]$ lsdu
a tutorial  new-article.sh   new-tutorial.sh   readme  tools  xsl
my-article  new-article.vbs  new-tutorial.vbs  schema  web
2.9M    .

在引用构成别名的完整序列时需要非常仔细。如果使用 shell 变量作为别名的一部分,还需要注意是使用双引号还是使用单引号。您希望在定义或执行别名时让 shell 对变量进行扩展吗?清单 13 显示了创建名为 mywd 定制命令来打印当前工作目录名的错误方法。

清单 13. 定制 pwd —— 尝试 1
[ian@pinguino developerworks]$ alias mywd="echo \"My working directory is $PWD\""
[ian@pinguino developerworks]$ mywd
My working directory is /home/ian/developerworks
[ian@pinguino developerworks]$ cd ..
[ian@pinguino ~]$ mywd
My working directory is /home/ian/developerworks

注意双引号会导致 bash 在执行命令之前就对变量进行扩展。清单 14 使用了 alias 命令来显示所生成的别名实际上是什么样子,从中可以看出我们的错误是很明显的。清单 14 还给出了定义这个别名的正确方法。

清单 14. 定制 pwd —— 尝试 2
[ian@pinguino developerworks]$ alias mywd
alias mywd='echo \"My working directory is $PWD\"'
[ian@pinguino developerworks]$ mywd
"My working directory is /home/ian/developerworks"
[ian@pinguino developerworks]$ cd ..
[ian@pinguino ~]$ mywd
"My working directory is /home/ian"

终于成功了。

Shell 函数

别名让您可以对某个命令或命令列表选用一种简写或其他名字。此外,还可以添加其他一些内容,例如在 which 命令中加上希望查找的程序名。当 shell 执行用户的输入时,就会对别名进行扩展;之后输入的其他内容都会在最后一个命令或命令列表执行之前添加到该扩展。这意味着只能在命令或命令列表之后添加参数,也只能在最后一个命令中使用这些参数。函数提供了更多功能,包括对参数进行处理的能力。函数是 POSIX shell 定义的一部分,在诸如 bash、dash 和 ksh 之类的 shell 中可以使用,但在 csh 或 tcsh 中不能使用。

在接下来的几节中,将逐步构建一个复杂的命令:从很小的构建块开始,逐渐在每个步骤加以完善,并将其转换成一个函数,以供以后使用。

假想问题

可以使用 ls 命令显示有关文件系统中目录和文件的各种信息。假设您喜欢使用一个命令,假定就是 ldirs, 来显示目录名,所显示的内容如清单 15 所示。

清单 15. ldirs 命令输出结果
[ian@pinguino developerworks]$ ldirs *[st]* tools/*a*
my dw article
schema
tools
tools/java
xsl

为了保持简单性起见,本节中的例子使用了 developerWorks author package 中的目录和文件(请参看 参考资料),如果您想为 developerWorks 编写文章和教程,也可以使用它们。在这些例子中,我们使用了这个包中提供的 new-article.sh 脚本来为一篇我们称之为 “my dw article” 的文章创建一个模板。

在撰写本文时,developerWorks author package 的版本是 5.6,因此如果您使用更新的版本,可能会发现一些不同之处。或者您也可以只使用自己的文件和目录。ldirs 命令也可以处理这些内容。在 developerWorks author package 提供的工具中,可以找到其他 bash 函数的例子。

查找目录项

如果在 ls 命令中使用了上述别名例子所示的颜色选项,请暂时忽略 *[st]* tools/*a*,这样就可以看到类似于图 1 所示的输出结果。

图 1. 使用 ls 命令区分文件和目录
使用 ls 命令区分文件和目录

在本例中,目录都是使用深蓝色显示的,不过使用在本系列教程中所学到的知识还不足以解释这个问题。不过,使用-l 选项会对如何继续处理给出一点线索:目录列表在第一个位置处有一个 “d” 字符。因此第一个步骤应该是使用 grep 对这个长列表中的内容进行一些简单的过滤,如清单 16 所示。

清单 16. 使用 grep 过滤目录项
[ian@pinguino developerworks]$ ls -l | grep "^d"
drwxrwxr-x 2 ian ian 4096 Jan 24 17:06 my dw article
drwxrwxr-x 2 ian ian 4096 Jan 18 16:23 readme
drwxrwxr-x 3 ian ian 4096 Jan 19 07:41 schema
drwxrwxr-x 3 ian ian 4096 Jan 19 15:08 tools
drwxrwxr-x 3 ian ian 4096 Jan 17 16:03 web
drwxrwxr-x 3 ian ian 4096 Jan 19 10:59 xsl

截取目录项

可以考虑使用 awk 而不是 grep,来在一个步骤中既对列表进行过滤,又截取每行的最后一部分内容,也就是目录名,如清单 17 所示。

清单 17. 使用 awk 代替 grep 进行处理
[ian@pinguino developerworks]$ ls -l  | awk '/^d/ { print $NF } '
article
readme
schema
tools
web
xsl

清单 17 中的方法有一个问题:它无法正确处理名字中有空格的那些目录名,例如 “my dw article”。就像是 Linux 和我们生活中的大部分事情一样,解决一个问题通常有很多方法,不过此处的目标是学习函数的知识,因此让我们回到使用 grep 方法上来。在本系列文章中我们学过的另外一个工具是 cut,它可以从一个文件(包括 stdin)中截取出很多域。现在让我们在回过头来看一下清单 16,在文件名之前,可以看到 8 个由空格分隔的域。在之前的命令后面加上 cut 就可以得到如清单 18 所示的输出结果。注意 -f9- 选项告诉 cut 打印第 9 个域以及之后的域的内容。

清单 18. 使用 cut 截取名称
[ian@pinguino developerworks]$ ls -l | grep "^d" | cut -d" " -f9-
my dw article
readme
schema
tools
web
xsl

如果我们在 tools 目录而不是当前目录上执行这个命令,使用这种方法存在的一个小问题就会变得十分明显,如清单 19 所示。

清单 19. 使用 cut 存在的问题
[ian@pinguino developerworks]$ ls -l tools | grep "^d" | cut -d" " -f9-
11:25 java
[ian@pinguino developerworks]$ ls -ld tools/[fjt]*
-rw-rw-r-- 1 ian ian  4798 Jan  8 14:38 tools/figure1.gif
drwxrwxr-x 2 ian ian  4096 Oct 31 11:25 tools/java
-rw-rw-r-- 1 ian ian 39431 Jan 18 23:31 tools/template-dw-article-5.6.xml
-rw-rw-r-- 1 ian ian 39407 Jan 18 23:32 tools/template-dw-tutorial-5.6.xml

时间戳为什么会出现呢?两个模板文件都有 5 个数字的大小,而 java 目录的大小则只有 4 个数字,因此 cut 会将多出来的空格当作另外一个域分隔符来解释。

使用 seq 来查找分割点

cut 命令也可以使用字符位置而不是域来进行分割。除了计算字符个数之外,bash shell 还有很多工具可以使用,因此可以尝试使用 seqprintf 命令来在长目录列表上面打印一个标尺,这样就可以方便地确定在什么地方对输出行的内容进行分割了。seq 命令最多可以使用 3 个参数,这就允许您可以打印出给定值之前的所有数字,或者打印出一个值到另一个值之间的所有数字,又或者打印出从某个值开始按给定的步值到第三个数值结束的所有数字。使用 seq 可以实现的其他有趣功能(包括打印 8 进制和 16 进制数字)请参看手册页。现在,让我们使用 seqprintf 命令来打印一个标尺,每 10 个字符处的位置就标记一下,如清单 20 所示。

清单 20. 使用 seq 和 printf 打印标尺
[ian@pinguino developerworks]$ printf "....+...%2.d" `seq 10 10 60`;printf "\n";ls -l
....+...10....+...20....+...30....+...40....+...50....+...60
total 88
drwxrwxr-x 2 ian ian 4096 Jan 24 17:06 my dw article
-rwxr--r-- 1 ian ian  215 Sep 27 16:34 new-article.sh
-rwxr--r-- 1 ian ian 1078 Sep 27 16:34 new-article.vbs
-rwxr--r-- 1 ian ian  216 Sep 27 16:34 new-tutorial.sh
-rwxr--r-- 1 ian ian 1079 Sep 27 16:34 new-tutorial.vbs
drwxrwxr-x 2 ian ian 4096 Jan 18 16:23 readme
drwxrwxr-x 3 ian ian 4096 Jan 19 07:41 schema
drwxrwxr-x 3 ian ian 4096 Jan 19 15:08 tools
drwxrwxr-x 3 ian ian 4096 Jan 17 16:03 web
drwxrwxr-x 3 ian ian 4096 Jan 19 10:59 xsl

啊哈!现在可以使用 ls -l | grep "^d" | cut -c40- 命令来截取从位置 40 处开始的内容了。我们的第一反应是这也没有真正解决问题,因为更大的文件依然会将正确的分割位置向右移。您可以自己试验一下。

救援的 sed

sed 是 UNIX 和 Linux 工具包中的一个功能非常强大的编辑过滤器,它使用了正则表达式。您知道我们的任务是从以 “d” 开头的每一个输出行去掉它前面的 8 个单词和之后的空格。可以使用 sed 来实现这种功能:使用模式匹配表达式 /^d/ 选择感兴趣的行,并使用替换命令 s/^d\([^ ]* *\)\(8\}// 将前 8 个单词替换为空字符串。使用 -n 选项可以只打印那些通过 p 命令指定的行,如清单 21 所示。

清单 21. 使用 sed 截取目录名
[ian@pinguino developerworks]$ ls -l | sed -ne 's/^d\([^ ]* *\)\{8\}//p' 
my dw article
readme
schema
tools
web
xsl
[ian@pinguino developerworks]$ ls -l tools | sed -ne 's/^d\([^ ]* *\)\{8\}//p'
java

要学习更多有关 sed 的内容,请参看 参考资料 一节的内容。

最终的函数

现在我们已经得到满足 ldirs 函数功能的复杂命令了,接下来应该学习如何将其编写成一个函数。函数由函数名加上后面的 () 构成,然后是一系列复合命令。对于现在来说,复合命令可以是任何命令或命令列表,使用一个分号结束,并使用一对花括号包括起来(且必须使用空格与其他符号分隔开来)。在后面 Shell 脚本 一节中您将学到其他的复合命令。

注意:在 Bash shell 中,函数名前面可以加上单词 “function”,但这并不是 POSIX 规范的一部分,诸如 dash 之类的更简单的 shell 并不支持这种用法。在 Shell 脚本 一节中,您将学习在使用了不同的 shell 时,如何确保脚本会被适当的 shell 解释。

在函数内部,可以使用表 4 中给出的 bash 特殊变量来引用参数。可以像其他 shell 变量一样在这些变量前面加上一个 $ 符号来引用这些变量。

表 4. 函数的 Shell 参数
参数用途
0, 1, 2, ...从参数 0 开始的位置参数。参数 0 指的是启动 bash 的程序名;如果函数是在一个 shell 脚本中运行的,就是这个 shell 脚本的名字。有关其他可能的信息,请参看 bash 的手册页,例如使用 -c 参数启动 bash 时的情况。以单引号或双引号括起来的字符串都会当作一个参数传递,引号会被剥离掉。在双引号的情况中,诸如 $HOME 之类的 shell 变量会在调用函数之前被展开。您可能需要使用单引号或双引号来传递参数,这些参数可以包含对 shell 具有特殊意义的嵌入空格或其他字符。
*从参数 1 开始的位置参数。如果已经把双引号中的内容展开了,那么展开后就是一个单词,使用域间分隔符(IFS)特殊变量的第一个字符来分隔参数;如果 IFS 为空,就不会插入任何分隔。默认的 IFS 值可以是空白、制表符和换行符。如果 IFS 没有设置,那么所使用的分隔符就是空白,就像默认的 IFS 一样。
@从参数 1 开始的位置参数。如果已经把双引号中的内容展开了,那么每个参数都变成一个单词,因此 “$@” 就等于 “$1”“$2”...。如果参数中可能会包含嵌入空白,就可以使用这种格式。
#参数个数,不包括参数 0。

注意: 如果参数多于 9 个,就不能使用 $10 来引用第 10 个参数。而必须首先处理或保存第一个参数($1),然后使用 shift 命令来删除第 1 个参数,并将其他参数下移 1 位,这样 $10 就变成了 $9,依此类推。$# 的值也同时会被更新,从而反应剩余参数的个数。

现在可以定义一个简单函数,其功能仅仅是说明有多少个参数,并显示这些参数;如清单 12 所示。

清单 22. 函数参数
[ian@pinguino developerworks]$ testfunc () { echo "$# parameters"; echo "$@"; }
[ian@pinguino developerworks]$ testfunc
0 parameters

[ian@pinguino developerworks]$ testfunc a b c
3 parameters
a b c
[ian@pinguino developerworks]$ testfunc a "b c"
2 parameters
a b c

不管使用的是 $*、"$*"、$@ 还是 "$@",在上面这个函数的输出结果中并没有太大区别,不过当问题变得复杂时,可以肯定区别将会变得非常大。

现在,用这个到目前为止最为复杂的命令来创建一个 ldirs 函数,使用 “$@” 表示参数。可以像前面的例子一样将全部函数都输入到一行中;当然 bash 也允许在多行中输入命令,在这种情况中会自动添加分号,如清单 23 所示。清单 23 还显示了使用 type 命令来显示函数定义。注意在 type 的输出结果中, ls 命令已经被它别名的展开值替换掉了。如果需要避免这个问题,可以使用 /bin/ls 而不是单单的 ls

清单 23. 第一个 ldirs 函数
[ian@pinguino developerworks]$ # Enter the function on a single line            
[ian@pinguino developerworks]$ ldirs () { ls -l "$@"|sed -ne 's/^d\([^ ]* *\)\{8\}//p'; }
[ian@pinguino developerworks]$ # Enter the function on multiple lines           
[ian@pinguino developerworks]$ ldirs ()
> {
> ls -l "$@"|sed -ne 's/^d\([^ ]* *\)\{8\}//p'
> }
[ian@pinguino developerworks]$ type ldirs
ldirs is a function
ldirs ()
{
    ls --color=tty -l "$@" | sed -ne 's/^d\([^ ]* *\)\{8\}//p'
}
[ian@pinguino developerworks]$ ldirs
my dw article
readme
schema
tools
web
xsl
[ian@pinguino developerworks]$  ldirs tools
java

现在您的函数似乎已经可以正常工作了。但是如果像清单 24 那样运行 ldirs * 会如何呢?

清单 24. 运行 ldirs *
[ian@pinguino developerworks]$ ldirs *
5.6
java
www.ibm.com
5.6

感到惊奇吗?实际上,您并没有找到当前目录中的目录,而是找到了第 2 级子目录的内容。查看一下 ls 命令的手册页或本系列前面的教程就可以理解这是为什么了。或者像清单 25 那样运行 find 命令来查找第 2 级子目录名。

清单 25. 查找第 2 级子目录
[ian@pinguino developerworks]$ find . -mindepth 2 -maxdepth 2 -type d
./tools/java
./web/www.ibm.com
./xsl/5.6
./schema/5.6

添加测试

使用通配符暴露了这种方法在逻辑上存在的一个问题。我们忽略了这样的一个事实,即不使用任何参数时 ldirs 显示的是当前目录的子目录,而 ldirs tools 显示的是 tools 目录中的 java 子目录,而不是 tools 目录本身,这与将 ls 命令用于文件而非目录的情形是一样的。理想情况下,如果没有给定参数,就应该使用 ls -l;如果给定了一些参数,就应该使用 ls -ld 命令。可以使用 test 命令来测试参数个数,然后使用 &&|| 来构建一个命令列表,并执行适当的命令。使用 testtest expression ] 格式,您的表达式可能会是这样: { [ $# -gt 0 ] &&/bin/ls -ld "$@" || /bin/ls -l } | sed -ne ...

不过这段代码还有一个小问题,如果 ls -ld 命令不能找到任何匹配文件或目录,就会产生一条错误消息,并返回一个非 0 的退出代码,这会导致 ls -l 命令也会被执行。这可能并不是我们所期望的。一个解决的方案是为第一个 ls 命令构造一个复合命令,这样如果命令失败,就可以对参数个数再次进行测试。可以对原来的函数进行扩充来包含这种功能,现在这个函数应该如清单 26 所示。可以利用清单 26 中的参数来尝试使用该函数,也可以利用您自己的参数来体验一下,看这个函数是怎样工作的。

清单 26. 使用 ldirs 处理通配符
[ian@pinguino ~]$ type ldirs
ldirs is a function
ldirs ()
{
    {
        [ $# -gt 0 ] && {
            /bin/ls -ld "$@" || [ $# -gt 0 ]
        } || /bin/ls -l
    } | sed -ne 's/^d\([^ ]* *\)\{8\}//p'
}
[ian@pinguino developerworks]$ ldirs *
my dw article
readme
schema
tools
web
xsl
[ian@pinguino developerworks]$ ldirs tools/*
tools/java
[ian@pinguino developerworks]$ ldirs *xxx*
/bin/ls: *xxx*: No such file or directory
[ian@pinguino developerworks]$ ldirs *a* *s*
my dw article
readme
schema
schema
tools
xsl

最终版本

现在,在清单 26 中给出的这个例子中,可以看到一个目录被列出了两次。如果希望,可以通过 sort | uniqsed 的输出结果进行过滤,从而扩充原来的函数来解决这个问题。

从一些基本的构造块开始,现在您已经构建了一个非常复杂的 shell 函数了。

定制击键组合

您在终端会话中输入的击键组合,以及在诸如 FTP 之类的程序中使用的击键组合,都是由 readline 库进行处理的,并且可以进行配置。默认情况下,定制文件是主目录中的 .inputrc 文件;如果系统中存在这个文件,就会在 bash 启动过程中读取这个文件。可以通过设置 INPUTRC 变量来配置不同的文件。如果没有设置这个变量,就会使用主目录中的 .inputrc 文件。很多系统在 /etc/inputrc 中都有一个默认的键映射,因此您通常会希望使用 $include 指令来包含它。

清单 27 展示了如何将 ldirs 函数绑定到 Ctrl-t 的键盘组合上(按下并一直按着 Ctrl 键,然后按下 t)。如果希望此命令执行时不使用任何参数,可以在配置行末尾添加 \n。

清单 27. 样例 .inputrc 文件
# My custom key mappings
$include /etc/inputrc

可以通过先按 Ctrl-x 再按 Ctrl-r 来强制再次读取 INPUTRC 文件。注意如果没有自己的 .inputrc 文件,有些发行版会设置 INPUTRC=/etc/inputrc,因此如果您在这种系统上创建了 .inputrc 文件,就需要先登出系统,然后再登录一次,这样才能使用新的定义。只将 INPUTRC 设置为空或将其指向新文件只会重新读取原来的文件,而不是新的规范。

INPUTRC 文件可以包括一些条件规范。例如,您的键盘行为可能会根据您使用的是 emacs 编辑模式(bash 默认值)还是 vi 模式而有所不同。有关如何定制键盘的更多细节,请参看 bash 的手册页。

保存别名和函数

您可以将自己的别名和函数添加到自己的 ~/.bashrc 文件中,不过也可以将它们保存到任何您喜欢的文件中。不管怎样做,都请记住使用 source. 命令来引用这些文件,这样就会读取文件的内容,并在当前环境中执行这个文件。如果创建了一个脚本并简单执行它,那么这个脚本就是在一个子 shell 中执行的,当这个子 shell 退出并将控制权返回给您时,所有有价值的定制就全部丢失了。

在下一节中,将学习如何超越这些简单的函数,如何添加一些编程结构,例如条件测试和循环结构,并将它们与多个函数结合起来来创建或修改 bash shell 脚本。


Shell 脚本

本节将介绍初级管理(LPIC-1)考试 102 主题 1.109.2 的内容。这个主题的权值为 3。

在本节中,您将学习如何:

  • 使用标准的 shell 语法,例如循环和测试
  • 使用命令替换
  • 测试命令的成功、失败或其他返回值
  • 向超级用户条件性地发送邮件
  • 通过 #! 行选择正确的脚本解释器
  • 管理脚本的位置、所有者、执行和 suid 权限

本节是在上一节中所学习的有关简单函数的基础知识上构建的,将展示增加 shell 编程能力的一些技术和工具。您已经看到使用 && 和 || 操作符的一些简单逻辑,它们让您可以根据前一个命令是正常退出还是错误退出来执行某个命令。在 ldirs 函数中,可以使用这种方法来根据是否向 ldirs 函数传递了参数来修改对 ls 的调用。现在您将学习如何扩展这些基本技术来进行更加复杂的 shell 编程。

测试

在学习如何为变量赋值和传递参数之后,在任何编程语言中接下来要做的第一件事情都是对这些值和参数进行测试。在 shell 中所做的测试会设置返回状态,这与其他命令的做法类似。实际上, test 是一个内嵌的命令

test 和 [

test 内嵌命令会根据对表达式 expr 的计算结果来确定返回 0(True)或 1(False) 。也可以使用方括号,test expr 和 [ expr ] 是等效的。可以通过显示 $? 来检查返回值;或者使用本节后面介绍的各种条件结构来对它进行测试。

清单 28. 几个简单的测试例子
[ian@pinguino ~]$ test 3 -gt 4 && echo True || echo false
false
[ian@pinguino ~]$ [ "abc" != "def" ];echo $?
0
[ian@pinguino ~]$ test -d "$HOME" ;echo $?
0

在第一个例子中, -gt 操作符用来在两个数值之间进行数学比较运算。在第二个例子中, 使用了另外一种 [ ] 形式来比较两个字符串是否相等。在最后一个例子中,使用 -d 一元操作符对 HOME 变量的值进行测试,看它是否是一个目录。

数值可以使用 -eq、-ne、-lt、-le、-gt 或 -ge 进行比较,分别表示等于、不等于、小于、小于或等于、大于、大于或等于。

字符串可以使用操作符 =、!=、< 和 > 分别进行等于、不等于或第一个字符串是在第二个字符串之前还是之后的比较操作。一元操作符 -z 测试字符串是否为空;如果字符串不为空,那么 -n 或不使用任何操作符就返回 True。

注意: < 和 > 操作符也可以由 shell 用来进行重定向,因此必须使用 \< 或 \> 对它们进行转义。清单 29 给出了几个字符串测试的例子。请检查它们是否如您所期望的那样。

清单 29. 几个字符串测试的例子
[ian@pinguino ~]$ test "abc" = "def" ;echo $?
1
[ian@pinguino ~]$ [ "abc" != "def" ];echo $?
0
[ian@pinguino ~]$ [ "abc" \< "def" ];echo $?
0
[ian@pinguino ~]$ [ "abc" \> "def" ];echo $?
1
[ian@pinguino ~]$ [ "abc" \<"abc" ];echo $?
1
[ian@pinguino ~]$ [ "abc" \> "abc" ];echo $?
1

表 5 给出了几个常见的文件测试的例子。如果所测试的文件是一个系统中存在的文件,并且具有指定的特性,测试结果就是 True。

表 5. 几个文件测试的例子
操作符特性
-d目录
-e存在(也可以使用 -a)
-f普通文件
-h符号链接(也可以使用 -L)
-p命名的管道
-r可以读取
-s非空
-SSocket
-w可以写入
-N上次读取之后已经被修改过了

除了上面的一元测试之外,还可以使用表 6 中给出的二元操作符对两个文件进行比较。

表 6. 文件对测试
操作符如果符合该条件就为 True
-nt测试文件 1 是否比文件 2 更新。修改日期会在这个比较和下个比较中使用。
-ot测试文件 1 是否比文件 2 更旧。
-ef测试文件 1 是否是到文件 2 的硬链接。

其他几个测试允许检查诸如文件权限之类的事情。有关详细信息请参看手册页,也可以通过 help test 来查看有关内置测试的简单介绍。还可以使用 help 命令获得关于其他内置功能的信息。

-o 操作符允许测试不同的 shell 选项,这些选项可通过 set -o option 进行设置,如果选项已设置就返回 True (0),否则返回 False (1),如清单 30 所示。

清单 30. 测试 shell 选项
[ian@pinguino ~]$ set +o nounset
[ian@pinguino ~]$ [ -o nounset ];echo $?
1
[ian@pinguino ~]$ set -u
[ian@pinguino ~]$ test  -o nounset; echo $?
0

最后,-a-o 操作允许分别使用逻辑 AND 和 OR 来合并表达式,而一元操作符 ! 则是对测试含义取反。可以使用圆括号来对表达式进行分组,并覆盖默认的优先级顺序。记住 shell 通常会在一个子 shell 中运行表达式,因此需要使用 \( 和 \) 对圆括号进行转义,或者使用单引号或双引号将这些操作符围起来。清单 31 展示了 de Morgan 定律在表达式中的应用。

清单 31. 对测试进行合并和分组
[ian@pinguino ~]$ test "a" != "$HOME" -a 3 -ge 4 ; echo $?
1
[ian@pinguino ~]$ [ ! \( "a" = "$HOME" -o 3 -lt 4 \) ]; echo $?
1
[ian@pinguino ~]$ [ ! \( "a" = "$HOME" -o '(' 3 -lt 4 ')' ")" ]; echo $?
1

(( 和 [[

test 命令的功能非常强大,但是在对转义和字符串与数值比较之间的区别的处理上有些吃力。幸运的是,bash 有两种方法可以按照那些熟悉 C、C++ 或 Java 语法的人更加习惯的方式进行测试。 (( ))复合命令 可以计算一个算术表达式的值,如果这个表达式的值为 0 就将退出状态设置为 1;如果表达式的值不为 0,就将退出状态设置为 0。不需要对 (()) 之间的操作符进行转义。数值计算是按照整型进行的。被除数为 0 会产生错误,但溢出不会产生错误。也可以执行在 C 语言中很常见的数值、逻辑和位操作。let 命令也可以执行一个或多个算术表达式。它通常用来对数值变量进行赋值。

清单 32. 对算术表达式进行赋值和测试
[ian@pinguino ~]$ let x=2 y=2**3 z=y*3;echo $? $x $y $z
0 2 8 24
[ian@pinguino ~]$ (( w=(y/x) + ( (~ ++x) & 0x0f ) )); echo $? $x $y $w
0 3 8 16
[ian@pinguino ~]$ (( w=(y/x) + ( (~ ++x) & 0x0f ) )); echo $? $x $y $w
0 4 8 13

(( )) 类似, [[ ]] 复合命令让您可以使用更加自然的语法进行文件名和字符串测试。可以使用圆括号和逻辑操作符组合 test 命令所允许的测试。

清单 33. 使用 [[ 进行组合
[ian@pinguino ~]$ [[ ( -d "$HOME" ) && ( -w "$HOME" ) ]] &&  
>  echo "home is a writable directory"
home is a writable directory

在使用了 =!= 操作符时,[[ 也可以对字符串进行模式匹配,如清单 34 所示。

清单 34. 使用 [[ 进行通配符测试
[ian@pinguino ~]$ [[ "abc def .d,x--" == a[abc]*\ ?d* ]]; echo $?
0
[ian@pinguino ~]$ [[ "abc def c" == a[abc]*\ ?d* ]]; echo $?
1
[ian@pinguino ~]$ [[ "abc def d,x" == a[abc]*\ ?d* ]]; echo $?
1

甚至可以在 [[ 复合命令中进行数学测试,不过这要非常谨慎。除非是在 (( 复合命令内部,否则 <> 操作符会将操作数当作字符串进行比较,并按照当前的比较序列的顺序测试其顺序。清单 35 给出了几个例子。

清单 35. 使用 [[ 进行数学测试
[ian@pinguino ~]$ [[ "abc def d,x" == a[abc]*\ ?d* || (( 3 > 2 )) ]]; echo $?
0
[ian@pinguino ~]$ [[ "abc def d,x" == a[abc]*\ ?d* || 3 -gt 2 ]]; echo $?
0
[ian@pinguino ~]$ [[ "abc def d,x" == a[abc]*\ ?d* || 3 > 2 ]]; echo $?
0
[ian@pinguino ~]$ [[ "abc def d,x" == a[abc]*\ ?d* || a > 2 ]]; echo $?
0
[ian@pinguino ~]$ [[ "abc def d,x" == a[abc]*\ ?d* || a -gt 2 ]]; echo $?
-bash: a: unbound variable

条件测试

可以使用上面的测试以及 &&|| 控制操作符来完成很多编程,但 bash 还包括了大家更加熟悉的 “if, then, else” 和 case 结构。在学习这些内容之后,您将学习有关循环结构的内容,到那时您的工具箱就更丰富了。

If, then, else 语句

bash 的 if 命令是一个复合命令,它对测试或命令的返回值($?)进行测试,并根据该值是 True(0)还是 False(非 0)来进行分支跳转。尽管上面的测试只会返回 0 或 1,但是这些命令也可以返回其他值。在本教程稍后您将学习更多有关这种测试的内容。bash 中的 if 命令有一个 then 子句,其中包含了如果测试或命令返回 0 时要执行的命令列表;还可以包含一个或多个可选的 elif 子句,每个 elif 子句中都可以有另外一个测试和一个 then 子句,后者中列有相关的命令列表;最后,还可以包括一个可选的 else 子句以及一个命令列表,如果最初测试或 elif 子句中使用的测试都不为 true,并且后面有一个终止 fi 标记着结构的末尾,这些命令就会执行。

使用到现在为止所学习到的内容,就可以构建一个简单的计算器来计算数学表达式的值,如清单 36 所示。

清单 36. 使用 if, then, else 语句计算表达式
[ian@pinguino ~]$ function mycalc ()
> {
>   local x
>   if [ $# -lt 1 ]; then
>     echo "This function evaluates arithmetic for you if you give it some"
>   elif (( $* )); then
>     let x="$*"
>     echo "$* = $x"
>   else
>     echo "$* = 0 or is not an arithmetic expression"
>   fi
> }
[ian@pinguino ~]$ mycalc 3 + 4
3 + 4 = 7
[ian@pinguino ~]$ mycalc 3 + 4**3
3 + 4**3 = 67
[ian@pinguino ~]$ mycalc 3 + (4**3 /2)
-bash: syntax error near unexpected token `('
[ian@pinguino ~]$ mycalc 3 + "(4**3 /2)"
3 + (4**3 /2) = 35
[ian@pinguino ~]$ mycalc xyz
xyz = 0 or is not an arithmetic expression
[ian@pinguino ~]$ mycalc xyz + 3 + "(4**3 /2)" + abc
xyz + 3 + (4**3 /2) + abc = 35

计算器使用 local 语句将 x 声明为本地变量,它只能在 mycalc 函数内部使用。 let 函数有几个可能的选项,与之密切相关的 declare 函数也是一样。请查看 bash 的手册页或使用 help let 来获得更多信息。

正如在清单 36 中所看到的一样,如果使用了 shell 元字符,例如 (、)、*、> 和 <,就需要仔细确保表达式进行了正确的转义。不管怎样,现在您有一个非常方便的计算器可以用来像 shell 一样计算数学表达式的值了。

您可能已经注意到 else 子句和最后两个例子了。正如您看到的一样,将 xyz 传递给 mycalc 并不是什么错误,但是这样得出的结果会是 0。这个函数现在还不够智能,因此还不能识别最后一个例子中的字符值并警告用户。可以使用字符串模式的匹配测试,例如
[[ ! ("$*" == *[a-zA-Z]* ]]
(或适合您自己的语言环境的形式)来剔除包含字母字符的表达式,不过这也同时会使输入中不能再出现某些十六进制符号,因为您可能会使用十六进制的形式用 0x0f 来表示 15。实际上,shell 允许使用 64 进制(使用 base#value),因此尽可以在输入中使用任何字母字符,外加 _ 和 @ 。八进制和十六进制使用常见的符号,对于八进制来说以 0 开头,对于十六进制来说以 0x 或 0X 开头。清单 37 给出了几个例子。

清单 37. 使用不同的进制进行计算
[ian@pinguino ~]$ mycalc 015
015 = 13
[ian@pinguino ~]$ mycalc 0xff
0xff = 255
[ian@pinguino ~]$ mycalc 29#37
29#37 = 94
[ian@pinguino ~]$ mycalc 64#1az
64#1az = 4771
[ian@pinguino ~]$ mycalc 64#1azA
64#1azA = 305380
[ian@pinguino ~]$ mycalc 64#1azA_@
64#1azA_@ = 1250840574
[ian@pinguino ~]$ mycalc 64#1az*64**3 + 64#A_@
64#1az*64**3 + 64#A_@ = 1250840574

关于输入的更详细的讨论已超出了本教程的范围,因此使用您的计算器时必须要相当仔细。

elif 语句非常方便,它通过简化缩进可以帮助您编写脚本。对mycalc 函数执行 type 命令的输出结果(如清单 38 所示)可能会出乎您的意料吧。

清单 38. Type mycalc
[ian@pinguino ~]$ type mycalc
mycalc is a function
mycalc ()
{
    local x;
    if [ $# -lt 1 ]; then
        echo "This function evaluates arithmetic for you if you give it some";
    else
        if (( $* )); then
            let x="$*";
            echo "$* = $x";
        else
            echo "$* = 0 or is not an arithmetic expression";
        fi;
    fi
}

Case 语句

case 复合命令非常适合用在当具有很多可能性并且希望根据匹配某种特定可能性的值来采取相应动作的情况。case 复合命令是以 case WORD in 开始并以 esac ("case" 这个单词倒着拼)结束。每个 case 语句可以包含单个模式或者多个模式(使用 | 分隔开),后面是一个 ) 符号,然后是一个语句列表,最后是两个分号(;;)。

为了阐述这种用法,假设有一个商店供应咖啡、无咖啡因咖啡(decaf)、茶和冷饮。清单 39 中的函数可以用来针对顾客的不同要求做不同的响应。

清单 39. 使用 case 命令
[ian@pinguino ~]$ type myorder
myorder is a function
myorder ()
{
    case "$*" in
        "coffee" | "decaf")
            echo "Hot coffee coming right up"
        ;;
        "tea")
            echo "Hot tea on its way"
        ;;
        "soda")
            echo "Your ice-cold soda will be ready in a moment"
        ;;
        *)
            echo "Sorry, we don't serve that here"
        ;;
    esac
}
[ian@pinguino ~]$ myorder decaf
Hot coffee coming right up
[ian@pinguino ~]$ myorder tea
Hot tea on its way
[ian@pinguino ~]$ myorder milk
Sorry, we don't serve that here

注意使用 '*' 可以匹配之前尚未匹配的任何内容。

Bash 有另外一个类似于 case 的结构,它也可以用来将输出结果显示在终端上并让用户选择适当的项。它就是 select 语句,不过在这里不会对它进行过多的介绍。请参看 bash 的手册页,或者输入 help select 来学习更多内容。

当然,使用这种简单方法也有很多问题;比如无法一次购买两份饮料,这个函数也不能处理以小写形式输入的内容。那么能否采用大小写不敏感的匹配形式呢?答案是肯定的,下面就让我们看一下如何实现这种功能。

返回值

Bash shell 有一个内嵌的 shopt,可以用来设置或取消很多 shell 选项。其中一个选项是 nocasematch,如果这个选项设置了,就会通知 shell 忽略字符串匹配中的大小写。您的第一想法可能是使用在 test 命令中学习到的 -o 操作数。不幸的是, nocasematch 并不能应用 -o 选项,因此只能考虑其他方法。

shopt 命令与大部分 UNIX 和 Linux 命令一样,都会设置一个返回值,可以使用 $? 来查看这个返回值的内容。返回值不仅仅存在于您之前学习过的测试中,如果您仔细分析一下在 if 语句中进行的测试,就会发现它们实际上是在测试底层 test 命令的返回值是 True(0)还是 False(1 或其他非 0 值)。即使您不使用测试,而是使用其他命令,也是如此。返回值为 0 表示命令执行成功,返回值不为 0 表示命令执行失败。

了解了这些知识,您现在就可以测试 nocasematch 选项了,如果还没有设置这个选项,现在就先设置它,然后当您的函数结束时,将它返回到用户的首选项中。 shopt 命令有 4 个方便的选项: -pqsu,分别用来打印当前值、不打印任何东西、设置选项或去除选项的设置。-p-q 选项将返回值设置为 0,表示这个 shell 选项已经设置了;为 1 表示该选项没有设置。 -p 选项会打印将这个选项设置为当前值所需要的命令,而 -q 选项则简单地将返回值设置为 0 或 1。

修改后的函数使用 shopt 中的返回值来设置代表 nocasematch 选项的当前状态的本地变量、设置这个选项、运行 case 命令,然后再将 nocasematch 选项重置成原来的值。实现这种功能的一种方法如清单 40 所示。

清单 40. 测试命令的返回值
[ian@pinguino ~]$ type myorder
myorder is a function
myorder ()
{
    local restorecase;
    if shopt -q nocasematch; then
        restorecase="-s";
    else
        restorecase="-u";
        shopt -s nocasematch;
    fi;
    case "$*" in
        "coffee" | "decaf")
            echo "Hot coffee coming right up"
        ;;
        "tea")
            echo "Hot tea on its way"
        ;;
        "soda")
            echo "Your ice-cold soda will be ready in a moment"
        ;;
        *)
            echo "Sorry, we don't serve that here"
        ;;
    esac;
    shopt $restorecase nocasematch
}
[ian@pinguino ~]$ shopt -p nocasematch
shopt -u nocasematch
[ian@pinguino ~]$ # nocasematch is currently unset
[ian@pinguino ~]$ myorder DECAF
Hot coffee coming right up
[ian@pinguino ~]$ myorder Soda
Your ice-cold soda will be ready in a moment
[ian@pinguino ~]$ shopt -p nocasematch
shopt -u nocasematch
[ian@pinguino ~]$ # nocasematch is unset again after running the myorder function

如果您希望自己的函数(或脚本)返回一个其他函数或命令可以测试的值,就请在自己的函数中使用 return 语句。清单 41 显示了如何实现当顾客购买所能提供的饮料时返回 0,当顾客请求购买其他东西就返回 1。

清单 41. 设置函数的返回值
[ian@pinguino ~]$ type myorder
myorder is a function
myorder ()
{
    local restorecase=$(shopt -p nocasematch) rc=0;
    shopt -s nocasematch;
    case "$*" in
        "coffee" | "decaf")
            echo "Hot coffee coming right up"
        ;;
        "tea")
            echo "Hot tea on its way"
        ;;
        "soda")
            echo "Your ice-cold soda will be ready in a moment"
        ;;
        *)
            echo "Sorry, we don't serve that here";
            rc=1
        ;;
    esac;
    $restorecase;
    return $rc
}
[ian@pinguino ~]$ myorder coffee;echo $?
Hot coffee coming right up
0
[ian@pinguino ~]$ myorder milk;echo $?
Sorry, we don't serve that here
1

如果没有指定自己的返回值,那么返回值就是最后一个命令执行的结果。函数总是习惯在您意想不到的情况下被重用,因此最好设置自己的返回值。

命令也可以返回 0 和 1 之外的值,有时需要对此进行区分。例如,如果找到可匹配模式,grep 命令就返回 0;否则就返回 1;但是如果模式无效或该文件规范并不能匹配任何文件,就会返回 2。如果需要区分除成功(0)或失败(非 0)之外的更多的返回值,可能就需要使用 case 命令,也可以使用带有多个 elifif 命令。

命令替换

在 “LPI 101 考试准备(主题 103):GNU 和 UNIX 命令” 教程中您已经见到过命令替换的用法了,不过下面让我们快速回顾一下相关内容。

命令替换让您可以通过简单地在命令两边加上一个 $( 和 ) 或使用一对反单引号 ` 来将一个命令的输出结果作为另外一个命令的输入使用。如果希望嵌套地使用一个命令的输出结果作为生成最终结果的另外一个命令的一部分,就会发现 $() 格式的优点;它也使得确定要执行什么操作变得更加简单,因为圆括号区分左、右,而两边的反单引号是完全相同的。当然,选择权在您手里,反单引号也很常见。

您通常都会在循环(在下面的 循环 一节中进行介绍)中使用命令替换功能。也可以使用它来简化刚才创建的 myorder 函数。由于 shopt -p nocasematch 会打印出用来将 nocasematch 选项设置为其当前值的命令,因此只需要保存输出结果并在 case 语句的结尾执行它即可。不管您对它进行了修改与否,这都会恢复 nocasematch 选项。修订后的函数如清单 42 所示。您可以自己尝试一下。

清单 42. 使用命令替换而不是返回值测试
[ian@pinguino ~]$ type myorder
myorder is a function
myorder ()
{
    local restorecase=$(shopt -p nocasematch) rc=0;
    shopt -s nocasematch;
    case "$*" in
        "coffee" | "decaf")
            echo "Hot coffee coming right up"
        ;;
        "tea")
            echo "Hot tea on its way"
        ;;
        "soda")
            echo "Your ice-cold soda will be ready in a moment"
        ;;
        *)
            echo "Sorry, we don't serve that here"
            rc=1
        ;;
    esac;
    $restorecase
    return $rc
}
[ian@pinguino ~]$ shopt -p nocasematch
shopt -u nocasematch
[ian@pinguino ~]$ myorder DECAF
Hot coffee coming right up
[ian@pinguino ~]$ myorder TeA
Hot tea on its way
[ian@pinguino ~]$ shopt -p nocasematch
shopt -u nocasematch

调试

如果您自己输入了一些函数,并在输入时出现一些错误,您可能会纳闷究竟是什么地方出现了问题,您也可能会非常想弄清楚该如何对函数进行调试。幸运的是,shell 允许您设置 -x 选项来在 shell 执行函数的同时对命令及其参数进行跟踪。清单 43 显示了这对于清单 42 给出的 myorder 函数来说是如何工作的。

清单 43. 跟踪函数执行
[ian@pinguino ~]$ set -x
++ echo -ne '\033]0;ian@pinguino:~'

[ian@pinguino ~]$ myorder tea
+ myorder tea
++ shopt -p nocasematch
+ local 'restorecase=shopt -u nocasematch' rc=0
+ shopt -s nocasematch
+ case "$*" in
+ echo 'Hot tea on its way'
Hot tea on its way
+ shopt -u nocasematch
+ return 0
++ echo -ne '\033]0;ian@pinguino:~'

[ian@pinguino ~]$ set +x
+ set +x

对于别名、函数或脚本都可以使用这种技术。如果需要更多信息,可以添加 -v 选项进行更详细的输出。

循环

Bash 和其他 shell 都有一些循环结构,这与 C 语言使用的循环结构非常类似。每个循环都会执行一个命令列表 零次到多次。命令列表使用单词 dodone 包含起来,其中每条语句前面都有一个分号。

for
循环有两种形式。shell 脚本编程中最常见的形式是对一组值进行迭代,对每个值都执行命令列表一次。这组值可能为空,在这种情况下命令列表就不会被执行。另外一种形式更加类似于传统的 C for 循环,使用 3 个数学表达式来控制循环的起始条件、步进函数和结束条件。
while
循环每次都在循环开始时计算一个条件的值,如果这个条件为 true,就执行命令列表。如果这个条件最初不为 true,那么这些命令就永远都不会执行。
until
循环执行一个命令列表,并在每个循环结束时计算某个条件的值。如果这个条件为 true,就再次执行这个循环。即使条件最初不为 true,这些命令也会至少被执行一次。

如果所测试的条件是一系列命令,那么所使用的就是最后执行的命令的返回值。清单 44 给出了循环命令的例子。

清单 44. For、while 和 until 循环
[ian@pinguino ~]$ for x in abd 2 "my stuff"; do echo $x; done
abd
2
my stuff
[ian@pinguino ~]$ for (( x=2; x<5; x++ )); do echo $x; done
2
3
4
[ian@pinguino ~]$ let x=3; while [ $x -ge 0 ] ; do echo $x ;let x--;done
3
2
1
0
[ian@pinguino ~]$ let x=3; until echo -e "x=\c"; (( x-- == 0 )) ; do echo $x ; done
x=2
x=1
x=0

尽管这些例子都是假想的,但它们的确揭示了一些重要的概念。通常您希望能迭代传递到函数或 shell 脚本中的参数,或命令替换所创建的列表。之前您已经发现,shell 可以引用以 $* 或 $@ 传递的参数列表,还发现是否使用引号包括这些表达式会对如何解释它们造成影响。清单 45 给出了一个函数,它首先打印参数个数,然后根据 4 种选择来打印参数的内容。清单 46 给出了这个函数执行时的情况,其中为了函数执行而在 IFS 变量前面多加了一个字符。

清单 45. 打印参数信息的函数
[ian@pinguino ~]$ type testfunc
testfunc is a function
testfunc ()
{
    echo "$# parameters";
    echo Using '$*';
    for p in $*;
    do
        echo "[$p]";
    done;
    echo Using '"$*"';
    for p in "$*";
    do
        echo "[$p]";
    done;
    echo Using '$@';
    for p in $@;
    do
        echo "[$p]";
    done;
    echo Using '"$@"';
    for p in "$@";
    do
        echo "[$p]";
    done
}
清单 46. 使用 testfunc 打印参数信息
[ian@pinguino ~]$ IFS="|${IFS}" testfunc abc "a bc" "1 2
> 3"
3 parameters
Using $*
[abc]
[a]
[bc]
[1]
[2]
[3]
Using "$*"
[abc|a bc|1 2
3]
Using $@
[abc]
[a]
[bc]
[1]
[2]
[3]
Using "$@"
[abc]
[a bc]
[1 2
3]

我们需要仔细学习它们之间的差异,尤其是对引号中的格式和包括诸如空格和换行符之类的参数的用法。

Break 和 continue

break 命令让您可以从一个循环中立即退出。如果使用了嵌套循环,也可以指定退出的层次数。因此如果在 for 循环中有一个 until 循环,而这个 for 循环在另外一个 for 循环之中,所有这些循环又全部在一个 while 循环中,那么 break 3 就会立即结束 until 循环和 2 个 for 循环,并将控制权返回给 while 循环中的代码。

continue 语句可以跳过命令列表中的剩下的语句,直接跳转到下一次循环的开头。

清单 47. 使用 break 和 continue
[ian@pinguino ~]$ for word in red blue green yellow violet; do
> if [ "$word" = blue ]; then continue; fi
>  if [ "$word" = yellow ]; then break; fi
>  echo "$word"
> done
red
green

再访 ldirs

还记得为了让 ldirs 函数能够从一个长列表中提取出文件名并确定它是否是一个目录,我们做了多少工作吗?您开发的那个最终函数还算不错,不过现在您掌握了本教程中的所有信息之后,还会创建相同的函数吗?也许就不会了。现在您知道如何使用 [ -d $name ] 来测试一个名字是否是目录了,并且了解了 for 循环的用法。清单 48 给出了可以编写 ldirs 函数的另外一种方法。

清单 48. 实现 ldirs 的另外一种方法
[ian@pinguino developerworks]$ type ldirs
ldirs is a function
ldirs ()
{
    if [ $# -gt 0 ]; then
        for file in "$@";
        do
            [ -d "$file" ] && echo "$file";
        done;
    else
        for file in *;
        do
            [ -d "$file" ] && echo "$file";
        done;
    fi;
    return 0
}
[ian@pinguino developerworks]$ ldirs
my dw article
my-tutorial
readme
schema
tools
web
xsl
[ian@pinguino developerworks]$ ldirs *s* tools/*
schema
tools
xsl
tools/java
[ian@pinguino developerworks]$ ldirs *www*
[ian@pinguino developerworks]$

您会注意到如何没有目录可以匹配您给出的条件,这个函数就会安静地返回。这也许符合您的预期,也许并不符合,不过这个函数可能比使用 sed 解析 ls 命令输出的那个版本更容易理解。至少现在您的工具箱中又多了一个工具。

创建脚本

您可能还记得 myorder 一次只能处理一份饮料。现在可以使用一个 for 循环对这个单一饮料的函数进行组合,从而对参数进行迭代来处理多份饮料。这非常简单,就像是将您的函数放到一个文件中,并添加一些 for 指令一样。清单 49 给出了新 myorder.sh 脚本的内容。

清单 49. 购买多份饮料
[ian@pinguino ~]$ cat myorder.sh
function myorder ()
{
    local restorecase=$(shopt -p nocasematch) rc=0;
    shopt -s nocasematch;
    case "$*" in
        "coffee" | "decaf")
            echo "Hot coffee coming right up"
        ;;
        "tea")
            echo "Hot tea on its way"
        ;;
        "soda")
            echo "Your ice-cold soda will be ready in a moment"
        ;;
        *)
            echo "Sorry, we don't serve that here";
            rc=1
        ;;
    esac;
    $restorecase;
    return $rc
}

for file in "$@"; do myorder "$file"; done

[ian@pinguino ~]$ . myorder.sh coffee tea "milk shake"
Hot coffee coming right up
Hot tea on its way
Sorry, we don't serve that here

注意这个脚本使用了 . 命令来将其引用 到当前 shell 环境中运行,而不是在它自己的 shell 中运行。为了能够执行脚本,可以引用它,也可以使用 chmod -x 命令将这个脚本标记成是可执行的,如清单 50 所示。

清单 50. 将脚本标记成可执行的
[ian@pinguino ~]$ chmod +x myorder.sh
[ian@pinguino ~]$ ./myorder.sh coffee tea "milk shake"
Hot coffee coming right up
Hot tea on its way
Sorry, we don't serve that here

指定 shell

拥有了一个全新的 shell 脚本之后,您可能会问这个脚本是否在所有的 shell 中都能很好地工作。清单 51 给出了相同的 shell 脚本在 Ubuntu 系统上首先使用 bash shell 然后再使用 dash shell 执行时的情况。

清单 51. Shell 的区别
ian@attic4:~$ ./myorder tea soda
-bash: ./myorder: No such file or directory
ian@attic4:~$ ./myorder.sh tea soda
Hot tea on its way
Your ice-cold soda will be ready in a moment
ian@attic4:~$ dash
$ ./myorder.sh tea soda
./myorder.sh: 1: Syntax error: "(" unexpected

这可不太好。

记得我们在前面曾经说过单词 “function” 在 bash 函数定义中是可选的,但它并不是 POSIX shell 规范的一部分吗?与 bash 相比,dash 更小更轻,它并不支持这种可选特性。由于无法确保用户可能会喜欢使用哪种 shell,因此应该总要确保脚本可以移植到所有 shell 环境中,这可能会非常困难;也可以使用所谓的 shebang(#!)方法来指定自己的脚本就在某个特定的 shell 中运行。shebang 行必须位于脚本的第 1 行,在该行中的其他部分指定程序运行必须使用的 shell,因此在 myorder.sh 脚本中就应该是 #!/bin/bash。

清单 52. 使用 shebang
$ head -n3 myorder.sh
#!/bin/bash
function myorder ()
{
$ ./myorder.sh Tea Coffee
Hot tea on its way
Hot coffee coming right up

可以使用 cat 命令来显示 /etc/shells 的内容,即系统中支持的 shell 列表。有些系统也会列出没有安装的一些 shell,有些列出的 shell(可能是 /dev/null)可能是为了确保 FTP 用户不会意外地脱离开自己受限的环境。如果需要修改默认的 shell,可以使用 chsh 命令,这会更新 /etc/passwd 中 userid 对应的项。

Suid 权限和脚本位置

在前面的教程 LPI 101 考试准备:设备、Linux 文件系统与文件系统层次标准 中,您已经学习了如何修改文件的所有者和组,以及如何设置文件的 suid 和 sgid 权限。具有这些权限的可执行文件会在一个具有文件所有者(用于 suid)或组(用于 sgid)的有效 权限的 shell 中运行。因此,程序可以根据权限位的设置来执行文件所有者或组可以执行的任何操作。一些程序需要这样做是有原因的。例如, passwd 程序需要更新 /etc/shadow, chsh 命令(用来修改默认 shell 的命令)需要更新 /etc/passwd。如果为 ls 使用了别名,再试图列出这些程序可能就会导致红色高亮显示的列表出现,以示警告,如图 2 所示。注意这两个程序都设置了 suid 位,因此运转起来就像是 root 用户在运行它们一样。

图 2. 具有 suid 权限的程序
具有 suid 权限的程序

清单 53 显示了一个普通用户可以运行 suid 程序并更新 root 所拥有的文件。

清单 53. 使用 suid 的程序
ian@attic4:~$ passwd
Changing password for ian
(current) UNIX password:
Enter new UNIX password:
Retype new UNIX password:
passwd: password updated successfully
ian@attic4:~$ chsh
Password:
Changing the login shell for ian
Enter the new value, or press ENTER for the default
        Login Shell [/bin/bash]: /bin/dash
ian@attic4:~$ find /etc -mmin -2 -ls
308865    4 drwxr-xr-x 108 root     root         4096 Jan 29 22:52 /etc
find: /etc/cups/ssl: Permission denied
find: /etc/lvm/archive: Permission denied
find: /etc/lvm/backup: Permission denied
find: /etc/ssl/private: Permission denied
311170    4 -rw-r--r--   1 root     root         1215 Jan 29 22:52 /etc/passwd
309744    4 -rw-r-----   1 root     shadow        782 Jan 29 22:52 /etc/shadow
ian@attic4:~$ grep ian /etc/passwd
ian:x:1000:1000:Ian Shields,,,:/home/ian:/bin/dash

尽管可以对 shell 脚本设置 suid 和 sgid 权限,但是大部分现代 shell 都会对脚本忽略这些位的设置。正如您已经看到的一样,shell 是一种功能非常强大的脚本语言,还有很多在本教程中没有介绍的特性,例如解释和执行任意表达式的能力。这些特性允许的限制太过宽泛,这就使环境变得非常不安全。因此,如果您对一个 shell 脚本设置了 suid 或 sgid 权限,就不要期望脚本在执行时能够遵守这些设置。

之前您修改了 myorder.sh 的权限将其标记成可执行的(x)。不管怎样,仍然需要通过在名字前面加上 ./ 才能运行脚本,除非是在当前 shell 中引用它。要只通过名字来执行 shell 脚本,就需要将它放到路径中(以 PATH 变量表示)。通常,您不会 希望将当前目录添加到路径中,因为这可能会引起安全问题。在完成对脚本的测试并确定它非常理想之后,如果这是一个个人脚本,就应该将它放到 ~/nom 中;如果它也可以由系统中的其他人使用,就应该将它放到 /usr/local/bin 中。如果简单地使用 chmod -x 来将它标记成可执行的,那么每个用户都可以执行它(所有者、同组用户以及全体用户)。这通常是我们希望的情况,不过如果需要对这个脚本进行一些限制,从而只有特定组的用户可以执行它,就请回过头来重新阅读一下前面的教程 LPI 101 考试准备:设备、Linux 文件系统与文件系统层次标准

您可能已经注意到 shell 通常都位于 /bin 目录中,而不是在 /usr/bin 目录中。根据文件系统层次标准,/usr/bin 可以位于一个各系统共享的文件系统中,因此在初始化时这个文件系统可能尚不能使用。因此,特定的功能,例如 shell,都应该在 /bin 目录中,这样即使 /usr/bin 没有加载,它们也依然可以使用。用户创建的脚本通常都不需要保存到 /bin(或 /sbin)目录中,因为这些目录中的程序应该给您提供了足够的工具来启动和运行系统,并让您可以挂载 /usr 文件系统。

向 root 用户发送邮件

如果您的脚本在午夜您睡觉时仍需要在系统上运行一些管理任务,而此时出现了问题该怎么办呢?幸运的是,错误信息或日志文件可以很容易地通过邮件发送给您自己或另外一个管理员或 root 用户,方法是将消息通过管道发送给 mail 命令,并使用 -s 选项添加一个主题行,如清单 54 所示。

清单 54. 将错误消息发送给某个用户
ian@attic4:~$ echo "Midnight error message" | mail -s "Admin error" ian
ian@attic4:~$ mail
Mail version 8.1.2 01/15/2001.  Type ? for help.
"/var/mail/ian": 1 message 1 new
>N  1 ian@localhost      Mon Jan 29 23:58   14/420   Admin error
&
Message 1:
From ian@localhost  Mon Jan 29 23:58:27 2007
X-Original-To: ian
To: ian@localhost
Subject: Admin error
Date: Mon, 29 Jan 2007 23:58:27 -0500 (EST)
From: ian@localhost (Ian Shields)

Midnight error message

& d
& q

如果需要发送日志文件,可以使用 < 重定向函数来将它作为 mail 命令的输入进行重定向。如果需要发送多个文件,可以使用 cat 命令对它们进行合并,并将输出通过管道发送给 mail 命令。在清单 54 中,邮件被发送给了用户 ian,他碰巧也是运行该命令的那个人,不过管理脚本更喜欢将邮件发送给 root 用户或其他管理员。像往常一样,您可以参考 mail 的手册页来了解可以指定的其他选项。

本教程到这里就结束了。我们已经介绍了大量有关 shell 和脚本编程的内容。不要忘记 对本教程进行评价 并给出您的反馈。

参考资料

学习

获得产品和技术

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Linux, Open source
ArticleID=203882
ArticleTitle=LPI 102 考试准备,主题 109: Shell、脚本、编程和编译
publish-date=03232007