注册 登陆

Shell 和 Shell Script基础

 或许,许多人都已经听过 shell 或 bash 这些名字,但不知道您是否知道它们究竟是什么东东呢?

先回到电脑基础常识上吧:所有的电脑都是由硬体和软体构成的,硬体就是大家能摸得着看得见的部份,例如:键盘、荧幕、CPU、记忆体、硬碟、等等。离开了硬体,所谓的电脑是不存在的,因为整个系统的输入和输出以及运算都离不开硬体。请问:如果没有键盘和荧幕您是怎样使用电脑的?但是,您透过键盘进行的输入,以及从荧幕看到的输出,真正发挥功能的,是软体的功劳。而直接负责和这些硬体进行沟通的软体,就是所谓的核心(kernel),kernel 必须能够接管键盘的输入,然后交由 CPU 进行处理,最后将执行结果输出到荧幕上。当然,除了键盘和荧幕外,所有的硬体都必须获得 kernel 的支援才能使用。

那么,kernel 又如何知道我们键盘输入的东西是什么呢?那就是我们这里介绍的 shell 所负责的事情了。因为电脑本身所处理的数据,都是二进位的机器码,和我们人类习惯使用的语言很不一样。比方说,输入 pwd 命令,我们知道这是 print working directory 的意思(非常简单的人类语音),但作为 kernel 来说,它并不知道 pwd 是什么,kernel 只会看机器码,这时候,shell 就会帮我们将 pwd 翻译为 kernel 能理解的程式码。所以,我们在使用电脑的时候,基本上就是和 shell 打交道,而不是直接和 kernel 沟通,更不是直接控制硬体。

简单来看,我们就这样来看待它们的关系:光从字面来解析的话,shell 就是“壳”,kernel 就是“核”。好比一个果实一样,您第一眼看到的就是壳,把壳扒开才看的到里面的核。shell 就是使用者和 kernel 之间的界面,将使用者下的命令翻译给 kernel 处理,关系如下图:

我们在 shell 输入一个命令,shell 会尝试搜索整个命令行,并对其中的一些特殊字符做出处理,如果遇到 CR 字符( Enter ) 的时候,就尝试重组整行命令,并解释给 kernel 执行。而一般的命令格式(syntax)大致如下:
# command parameter1 patrameter2 …

各命令都有自己的选项(options, 通常用“ – ”符号带领),可输入也可以不输入,如果没有额外指定,命令通常都有自己的预设选项;而参数(argument)则视各程式要求而定,有些很严格,有些也有预设的参数。例如 "ls -l" 这个命令,选项是 -l (long list),而预设的参数则是当前目录。在命令行中,选项和参数都被称为参项(parameter)。

我们经常谈到的 Linux,其实是指 kernel 这部份,而在 kernel 之外,则是各种各样的程式和工具,整合起来才成为一个完整的 Linux 发行套件。无论如何,Linux 的 kernel 只有一个(尽管有许多不同的版本,都由 Linus Tovalds 负责维护),但 kernel 之外的 shell 却有许多种,例如 bourne Shell、C Shell、Korn Shell、Zsh Shell、等等,但我们最常接触到的名叫 BASH (Bourne Again SHell),为 GNU 所加强的一个 burne shell 版本, 也是大多数 Linux 套件的预设 shell 。不同的 shell 都各自有其不同的优缺点,有兴趣您可以自行找这方面的资料来看,我这里就不一一介绍了。

BASH 这个优秀的 shell,之所以会被各大 Linux 套件采用为预设的 shell,除了它本身是 open source 程式之外,它的强大功能应该是吸引大家目光的重要因素之一。BASH 的功能很多,实在很难全部介绍,下面只列举其中一少部份而已:

命令补全功能:
当您输入命令的时候,您可以输入目录或档案的开首字面,然后按‘tab’键将您的命令路径补全。比方说,您要 ls 一下 /etc/sysconfig 这个目录的内容(假设您已经在 /etc 目录下了),您可以只输入 ls sy 然后接连按两下 tab 键,然后就会将 /etc/ 目录下所有以 sy 开头的档案和目录显示出来,您或许可以看到 sysconfig、sysctl.conf 、syslog.conf 这三个结果;如果您只输入 ls sys 再按两下 tab 的话,结果是是一样的,因为在 /etc/ 目录下面,所有以 sy 开头的档案,第 3 个字面都是 s 而没有其它字面了;如果您输入 ls sysc 再重复这个动作,那么显示结果就剩下 sysconfig 和 sysctl.conf 而已,因为以 sysc 开头的只有这两个档,如果您再按 ls sysco 接一个 tab,那就会帮您将 sysconfig 这个唯一以 sysco 开头的档案补全。

如果您所输入的路径,是唯一的,那么只要按一下 tab 就能补全,否则,会听到一下 beat 声,这时您再补一下 tab ,就会将所有以此路径开头的档案列出来;假如符号条件的档案太多,那系统会先将符号条件的档案数目告诉您,例如 242 possibilities,然后您按 y 才显示,如果按 n 则让您增加命令的输入,然后您可以重复这些动作,直到您所输入的路径只剩唯一的对应,才可以用一个 tab 补全。

同样的,这个功能也可以用在输入命令的时候,比方说,您要输入 Xconfigurator 命令,那您只需输入 Xc 然后按一下 tab 就可以了!是否很方便呢? ^_^

Tip:用 tab 来补全命令,不但方便迅速,而且也比较保险。因为,如果您前面的路径输入不正确,用 tab 是不能完成补全的,这可以避免您输入错误的路径而执行错误的程式。我强烈建议您执行每一个命令都常试用 tab 补全功能,以确保其正确性。(多敲这个 tab 键没什么坏处啦)

命令记录表:
每次您输入一个命令,并按 Enter 执行之后,那您这个命令就被存放在命令记录表(command history)中,而每个命令都有一个记录号码,您可以用 history命令来看看当前的命令历史表。这样,您只要用向上方向键,就可以依次呼叫出您最近所输入的命令,按下方向键则退回最新的命令,找到您想要重新输入的命令,然后再按 Enter 即可。

不过,也有一下更便利的办法:您可以输入 !nnn (其中的 nnn 是 history 命令找到的命令记录号码),就能执行指定的旧命令了;如果您输入 !! 再 Enter 的话,那就是重复上一个命令(和按向上方向键再 Enter 一样);如果您输入 !ls 的话,则是最后一次的 ls 开头的命令,如果是 !cd 那就是上一个 cd 开头的命令,如此类推;如果您按着 Ctrl 和 R 两个键之后,然后输入您以前曾经输入过的命令,那它会和上面介绍的补全功能一样,将您以前输入过的命令补全起来。呵~~ 太厉害啦!

Bash 会将您登录之后的所有命记录在记 cache 里面,然后,只要您成功退出这个 shell 之后,那这些记录就会存放到家目录的 ~/.bash_history 这个档里面(小心看,它是以 . 开头的档案哦,也就是隐藏档是也,您要用 ls -a 才看得到。) 不过,这个档只保持一定数量的命令记录而已,您可以透过 $HISTFILESIZE 这个变数(我们马上会介绍变数),来获得或改变档案的记录数量。

alias 功能:
在 Linux 里面,您可以透过 alias (别名) 的功能,来定义出一个命令的预设参数,甚至用另外一个名称来简化一个命令(及参数)。如果您输入 alias 这个命令,您就会看到目前的 alias 有哪些。您或许会看到其中有一个: alias rm=’rm -i’ 这行,它的意思是:如果您执行 rm 这个命令,那么系统实际执行的命令会带上 -i 的参数,也就是以 interactive 模式进行,结果是在您进行删除档案的时候,会经过您的确认才真正删除。在某些没有这个 alias 的系统中,那您执行 rm 而不另行指定 -i 的话,那就无声无息的将您能砍的档案给砍掉。小心哦,在 Linux 上面,档案一旦删除就没办法救回了!所以,用心的系统,会帮您做这个 alias。

在另外一种情形之下,当您发现某些长命令会经常使用到,但打字起来挺麻烦的,那您就可以用 alias 来解决。比方说,您每次关机要输入的命令是 shutdown -h now 这么一串,那您先输入 which shd (目的是确定现有的命令名称),如果您并没有发现这个命令出现在您的命令路径之中的话,那您可以输入 alias shd=’shutdown -h now’,然后再输入 shd 就可以关机了!不过,现在不要执行它!!因为您这样真的会把机器关掉哦~~ 请您用 alias 替换其它的长命令看看?

如果您要取消一个 alias,可以使用 unalias 命令,如:unalias shd 。

一旦您满意您的新 alias ,那您可以修改您的 ~/.bashrc 这个档,将它加在其它 alias 命令之后;假如您想系统上所有使用者都能使获得这个 alias ,那就将它放到 /etc/bashrc 里面吧。(如果您目前还不会编辑档案,那就回到上一章补习 vi 吧:-)

强大的 script 能力
玩过 DOS 的朋友,一定会知道 batch 档案的功能,在 BASH 本身可以帮您执行一系列根据条件判断的命令,其功能比 DOS 的 batch 强大多了。在本章的后面部份,会详细讨论 shell script 的基本技巧。

事实上,bash 还有许多厉害的功能,恐怕很难全部介绍了,还是留给您自己去找寻了。

环境变数

还记得上一章里面,我曾经提到过:当我们登入系统的时候,首先就获得一 shell,而且它也占据一个行程,然后再输入的命令都属于这个 shell 的子程式。如果您学习够细心,不难发现我们的 shell 都在 /etc/passwd 这个档里面设定的,也就是帐号设定的最后一栏,预设是 /bin/bash 。

事实上,当我们获得一个 shell 之后,我们才真正能和系统沟通,例如输入您的命令、执行您的程式、等等。您也可以在获得一个 shell 之后,再进入另外一个 shell (也就是启动一个子程式),然后还可以再进入更深一层的 shell (再进入子程式的子程式),直到您输入 exit 才退回到上一个 shell 里面(退回上一级的父程式)。假如您已经阅读过上一章所说过的子程式概念,应该不难理解。不过,您的行为也不是无限制的,而且,有许多设定都必须事先得到定义。所以,当您获得 shell 的时候,同时也获得一些环境设定,或称为“环境变数( Environment variables)”。

所谓的 变数( variable ),就是用特定的名称(或标签)保存一定的设定值,然后供程式将来使用。例如,姓=chen ;名=kenny ,那么‘姓’和‘名’就是变数名称,而 chen 和 kenny 就是变数所保存的值。由 shell 所定义和管理的变数,我们称为环境变数,因为这些变数可以供 shell 所产生的所有子程式使用。环境变数名称一般都用大写字母表示,例如,我们常用的环境变数有这些:

变数名称 代表意思
HISTCMD 当前命令的记录号码。
HISTFILE 命令记录表之存放档案。
HISTSIZE 命令记录表体积。
HOME 预设登录家目录。
IFS 预设分隔符号。
LINENO 当前命令在 shell script 中的行数。
MAIL 邮件信箱的路径。
MAILCHECK 检查邮件的秒数。
OLDPWD 上次进入的目录路径。
OSTYPE 作业系统类型。
PATH 预设命令搜索路径。
PPID 父程式之 PID。
PWD 当前工作目录路径。
SECONDS 当前 shell 之持续启动时间。
SHELL 当前 shell 之执行路径。
TMOUT 自动登出之最高闲置时间。
UID 使用者之 UID。
$ 当前 shell 之 PID。
? 最后一个命令之返回状态。

假如您想看看这些变数值是什么,只要在变数名称前面加上一个“$”符号,然后用 echo 命令来查看就可以了:
# echo $PWD
/root
# echo $$
1206
# echo $?
0

第一个命令就是将当前目录的路径显示出来,和您执行 pwd 命令的结果是一样的;第二个命令将当前这个 shell 的 PID 显示出来,也就是 1206。如果您这时候输入 kill -9 1206 的话,会将当前的 shell 砍掉,那您就要重新登录才能获得另外一个 shell,而它的 PID 也是新的;第三行命令是上一个命令的返回状态:如果命令顺利执行,并没有错误,那通常是 0;如果命令遇到错误,那返回状态则是非 0 ,其值视程式设计者而定(我们在后面的 shell script 的时候会介绍)。关于最后一个命令,不妨比较一下如下结果:
# ls mbox
mbox
# echo $?
0
# ls no_mbox
ls: no_mbox: No such file or directory
# echo $?
1

您会发现:第一命令成功执行,所以其返回状态是 0 ;而第二个命令执行失败,其返回状态是 1 。假如程式设计者为不同的错误设定不同的返回状态等级,那您可以根据返回值推算出问题是哪种错误引起的。

Tips:如果您日后写程式或 script,要养成一个习惯,为每一种命令结果设定返回状态。这非常重要,尤其在进行 debug 的时候。这个我们在后面学习 script 的时候再谈。

我们随时都可以用一个 = (等号) 来定义一个新的变数或改变一个原有变数。例如:
# MYNAME=kenny
# echo $MYNAME
kenny

假如您要取消一个定义好的变数,那么,您可以使用 unset 命令:
# unset MYNAME

不过,环境变数的特性之一,是单向输出的。也就是说:一个 shell 的特定变数,只能在这个 shell 里面使用。如果您要分享给同一个 shell 里面的其它程式、script、命令使用,或它们的子程式使用,那您必须用 export 命令将这个变数进行输出。但无论如何,如果您在一个子程式中定义了一个变数,那么这个变数的值,只影响这个子程式本身以及它自己的子程式,而永远不会影像到父程式或父程式产生的其它子程式。

比方说,您在一个程式中定义一个新的变数,或改变一个原有变数值,在程式结束的时候,那它所设定的变数均被取消;如果您想将变数值分享给该程式所产生的子程式,您必须用 export 命令才能保留这个变数值,除非子程式另外重新定义。但无论如何,当前程式所定义的变数值,是无法传回父程式那边的。不妨做做如下的实验:
# MYNAME=kenny
# echo $MYNAME
kenny
# export MYNAME # 设定一个变数。
#
# 当前的设定值。
# 用 export 输出变数值。
# /bin/bash # 再开一个 shell,也就是进入子程式中。
# echo $MYNAME
kenny

#

# 保留原有设定值。
# export MYNAME=netman
# echo $MYNAME
netman

# 重新定义设定值,同时也用 export 输出。

#
# 变数值被新值取代。
# exit

# 退出子程式,返回父程式。
# echo $MYNAME
kenny

#

# 父程式的变数值并没有改变。

关于变数的另一个特性,是的变数值是可以继承的。也就是说,您可以将一个变数值来设定另外一个变数名称。比方说:

# FIRST_NAME="Kenny"
# MYNAME=$FIRST_NAME

# echo $MYNAME
Kenny

# 定义一个变数。

# 再定义另一个变数,但它的值是第一个变数。

#
# 第二个变数继承了第一个变数的值。

另外,在定义变数的时候您还要注意一些规则:

* 定义变数时,“=”号两边没有空白键;
* 作为变数的名称,只能是字母和数字,但不能以数字开头;如果名称太长,可以用“_”分隔;
* 预先定义的变数均为大写,自定义变数可以混合大小写,以更好区别;
* 只有 Shell 本身定义的变数才能称为环境变数;
* 如果变数中带有特殊字符,必须先行用“\”符号跳脱;
* 如果变数中带有空白,必须使用引号,或进行跳脱。

关于后两项,或许我们再找些例子来体会一下:

# TOPIC=’Q & A’

# 用单引号保留特殊符号和空白

 

# Q1=What\’s\ your\ \"topic\"\?

# echo $Q1
What’s your "topic"?

 

# 用 \ 将特殊符号(含引号)和空白跳脱出来

#

# 跳脱后,特殊符号和空白都保留下来。

 

# ANS="It is $TOPIC."

# echo $ANS
It is Q & A.

 

# 用双引号保留变数值($)

#

# 用双引号,显示出变数值。

 

# WRONG_ANS=’It is "$TOPIC".’

# echo $WRONG_ANS
It is "$TOPIC".

 

 

# 用单引号保留特殊符号和空白(同第一行)

#
# 用单引号,全部保留;同时:

# $ 也当成一般符号保留,而非变数值。

 

# ALT_ANS=’the $TOPIC’\ is\ "’$TOPIC’"\.

# echo $ALT_ANS
The $TOPIC is ‘Q & A’.

 

# 同时混合单引号、双引号、和跳脱字符 \

#

# 单引号保留全部;双引号保留变数值;
# \ 将特殊符号跳脱出来。

我这里解释一下最后面的例子好了:’the $TOPIC is ‘"$TOPIC"\.。首先用单引号将 ‘the $TOPIC is ‘ 这段文字括好,其中用 3 个空白键和一个 $ 符号;然后用双引号保留 $TOPIC 的变数值;最后用 \ 跳脱小数点。

在引用 " " 和 ‘ ‘ 符号的时候,基本上, ‘ ‘ 所包括的内容,会变成单一的字串,任何特殊字符都失去其特殊的功能,而变成一般字符而已,但其中不能再使用 ‘ 符号,而在 " " 中间,则没有 ‘ ‘ 那么严格,某些特殊字符,例如 $ 号,仍然保留着它特殊的功能。您不妨实作一下,比较看看 echo ‘ "$HOME" ‘ 和 echo " ‘$HOME’ " 的差别。

Tips:在 shell 命令行的跳脱字符“ \ ”其实我们会经常用到的。例如,您的一个命令太长,一直打下去可能超过一行,或是想要整洁的输入命令行,您或许想按 Enter 键敲到下一行继续输入。但是,当您敲 Enter 键的时候,事实上是输入一个 CR (Carriage-Return) 字符,一但 shell 读到 CR 字符,就会尝试执行这个命令。这时,您就可以在输入 Enter 之前先输入 \ 符号,就能将 CR 字符也跳脱出来,这样 shell 就不会马上执行命令了。这样的命令行,我们在 script 中经常看到,但您必须知道那代表什么意思。

如果,您想对一些变数值进行过滤,例如:MY_FILE=’ ~/tmp/test.sh’ ,而您想将变数值换成 test.sh (也就是将前面的路径去掉),那您可以将 $MY_FILE 换成 ${MY_FILE##*/}。这是一个变数值字串过滤:## 是用来比对变数前端部份,然后 */ 是比对的色样 (也就是任何字母到 / 之间),然后将最长的部份删除掉。您可以参考如下范例:

当 FNAME="/home/kenny/tmp/test.1.sh" 的时候:
变数名称 代表意思 结果
${FNAME} 显示变数值的全部。

/home/kenny/tmp/test.1.sh

${FNAME##/*/} 比对变数值开端,如果以 /*/ 开头的话,砍掉最长的部份。

test.1.sh

${FNAME#/*/} 比对变数值开端,如果以 /*/ 开头的话,砍掉最短的部份。

kenny/tmp/test.1.sh

${FNAME%.*} 比对变数值末端,如果以 .* 结尾的话,砍掉最短的部份。

/home/kenny/tmp/test.1

${FNAME%%.*} 比对变数值末端,如果以 .* 结尾的话,砍掉最长的部份。

/home/kenny/tmp/test

${FNAME/sh/bash} 如果在变数值中找到 sh 的话,将第一个 sh 换成 bash。

/home/kenny/tmp/test.1.bash

${FNAME//sh/bash} 如果在变数值中找到 sh 的话,将全部 sh 换成 bash。

/home/kenny/tmp/test.1.bash

您除了能够对变数进行过滤之外,您也能对变数做出限制、和改变其变数值:

  字串没设定 空字串 非空字串
使用预设值
var=${str-expr} var=expr var= var=$str
var=${str:-expr} var=expr var=expr var=$str
使用其它值
var=${str+expr} var=expr var=expr var=expr
var=${str:+expr} var=expr var= var=expr
设定预设值
var=${str=expr} str=expr

var=expr
str 不变

var=
str 不变

var=$str
var=${str:=expr} str=expr

var=expr
str=expr

var=expr
str 不变

var=$str
输出错误
var=${str?expr} expr 输出至 stderr var= var=str
var=${str:?expr} expr 输出至 stderr expr 输出至 stderr var=str

一开始或许比较难理解上面的两个表格说明的意思,真的很混乱~~ 但只要多做一些练习,那您就知道怎么使用了。比方说:
# expr=EXPR
# unset str
# var=${str=expr}; echo var=$var str=$str expr=$expr
var=expr str=expr expr=EXPR
# var=${str:=expr}; echo var=$var str=$str expr=$expr
var=expr str=expr expr=EXPR
# str=
# var=${str=expr}; echo var=$var str=$str expr=$expr
var= str= expr=EXPR
# var=${str:=expr}; echo var=$var str=$str expr=$expr
var=expr str=expr expr=EXPR
# str=STR
# var=${str=expr}; echo var=$var str=$str expr=$expr
var=STR str=STR expr=EXPR
# var=${str:=expr}; echo var=$var str=$str expr=$expr
var=STR str=STR expr=EXPR

# MYSTRING=test
# echo ${MYSTRING?string not set\!}
test
# MYSTRING=
# echo ${MYSTRING?string not set\!}

# unset MYSTRING
# echo ${MYSTRING?string not set\!}
bash: MYSTRING: string not set!

请记住这些变数的习性,日后您要写 shell script 的时候就不会将变数搞混乱了。假如您想看看当前 shell 的环境变数有哪些,您可以输入 set 命令;如果只想检查 export 出来的变数,可以输入 export 或 env (前者是 shell 预设的输出变数)。

Bash 设定

到这里,您或许会问:shell 的环境变数在哪里定义呢?可以调整吗?

嗯,第一个问题我不大了解,我猜那是 shell 设计者预设定义好的,我们一登录获得 shell 之后就有了。不过,第二个问题,我却可以肯定答复您:您可以随时调整您的环境变数。您可以在进入 shell 之后用在命令行里面重新定义,也可以透过一些 shell 设定档来设定。

先让我们看看,当您在进行登录的时候,系统会检查哪些档案吧:

1. /etc/profile:首先,系统会检查这个档,以定义如下这些变数:PATH、USER、 LOGNAME、MAIL、HOSTNAME、HISTSIZE、INPUTRC。如果您会 shell script (我们后面再讨论),那您应该看得出这些变数是如何定义的。另外,还指定了 umask 和 ulimit 的设定:umask 大家应该知道了,而 ulmimit 呢?它是用来限制一个 shell 做能建立的行程数目,以避免系统资源被无限制的消耗。最后,它还会检查并执行 /etc/profile.d/*.sh 那些 script,有兴趣您可以追踪看看。

2. ~/.bash_profile:这里会定义好 USERNAME、BASH_ENV、PATH。其中的 PATH 除了现有的 $PATH 之外,还会再加入使用者相关的路径,您会发现 root 和普通帐号的路径是不一样的;而 BASH_ENV 呢,仔细点看,是下一个要检查的档案:

3. ~/.bashrc:在这个档里面,您可以发现一些 alias 设定(哦~~ 原来在这里!)。然后,您会发现有一行:. /etc/bashrc 。在 shell script 中,用一个小数点然后然后一个空白键再指向另外一个 script,意思是同时执行那个 script 并采用那里的变数设定。

4. /etc/bashrc:基本上,这里的设定,是所有使用者在获得 shell 的时候都会采用的。这里指定了一些 terminal 设定,以及 shell 提示字符等等。

5. ~/.bash_login:如果 ~/.bash_profile 不存在,则使用这个档。

6. ~/.profile:如果 ~/.bash_profile 和 ~/.bash_login 都不存在,则使用这个档。

7. ~/.bash_logout:这个档通常只有一个命令:clear,也就是把荧幕显示的内容清掉。如果您想要在登出 shell 的时候,会执行一些动作,例如:清空临时档(假如您有使用到临时档)、还原某些设定、或是执行某些备份之类的。

您可以透过修改上面提到的档案,来调整您进入 shell 之后的变数值。一般使用者可以修改其家目录( ~/ )中的档案,以进行个人化的设定;而作为 root,您可以修改 /etc/下面的档案,设定大家共用的变数值。至于 bash 的变数值如何设定?有哪些变数?各变数的功能如何?您打可以执行 man bash 参考手册资料。

Tips:一旦您修改了 /etc/profile 或 ~/.bash_profile 档案,其新设定要在下次登录的时候才生效。如果您不想退出,又想使用新设定,那可以用 source 命令来抓取:
source ~/.bash_profile

命令重导向

好了,相信您已经对您的 shell 有一定的了解了。然后,让我们看看 shell 上面的一些命令功能吧,这些技巧都是作为一个系统管理员基本要素。其中之一就是:命令重导向 (command redirection) 和 命令管线 (command pipe) 。

在深入讲解这两个技巧之前,先让我们了解一下 shell 命令的基本概念:

名称 代号 代表意思 设备
STDIN 0 标准输入 键盘
STDOUT 1 标准输出 荧幕
STDERR 2 标准错误 荧幕

表格中分别是我们在 shell 中一个命令的标准 I/O (输出与输入)。当我们执行一个命令的时候,先读入输入 (STDIN),然后进行处理,最后将结果进行输出 (STDOUT);如果处理过程中遇到错误,那么命令也会显示错误 (STDERR)。我们可以很容易发现:一般的标准输入,都是从我们的键盘读取;而标准输出和标准错误,都从我们的银幕显示。

同时,在系统上,我们通常用号码来代表各不同的 I/O:STDIN 是 0、STDOUT 是 1、STDERR 是 2。

当您了解各个 I/O 的意思和所代表号码之后,让我们看比较如下命令的结果:
# ls mbox
mbox
# ls mbox 1> file.stdout
#

请小心看第二个命令:在命令的后面多了一个 1 ,而紧接着(没有空白!)是一个大于符号 (>),然后是另外一个档案名称。但是,荧幕上却没有显示命令的执行结果,也就是说: STDOUT 不见了!那到底发生什么事情了呢?

呵,相信您不会这么快忘记了 STDOUT 的代号是 1 吧!没错了,因为我们这里将 1 用一个 > 符号重导到一个档案中了。结果过是:我们将标准输出从荧幕改变到档案中,所以我们在银幕就看不到 STDOUT,而原先的 STDOUT 结果则保存在大于符号右边的档中了。不信,您看看这个档案的内容就知道了:
# cat file.stdout
mbox

当我们用一个 > 将命令的 STDOUT 导向到一个档案的时候,如果档案不存在,则会建立一个新档;如果档案已经存在,那么,这个档案的内容就换成 STDOUT 的结果。有时候,您或许想保留原有档案的内容,而将结果增加在档案末端而已。那您可以多加一个 >,也就是使用 >> 就是了。您可以自己玩玩看哦~~,通常,我们要将一些命令或错误记录下来,都用这个方法。

Tips:如果您不希望 > 意外的盖掉一个原有档,那您可以执行这个命令:
set -o noclobber

不过,仍可以用 >| 来强迫写入。

上前面的例子中,我们指定了 I/O 1 (STDOUT) 进行重导向,这也是预设值,如果您没有指定代号,那么就是进行 STDOUT 的重导向,所以 1> 和 > 是一样的;1>> 和 >> 也是一样的。但如果您使用了数字,那么数字和 > 之间一定不能有空白存在。

好了,下面再比较两个命令:
# ls no_mbox
ls: no_mbox: No such file or directory
# ls no_mbox 2>> file.stderr

嗯,相信不用我多解释了吧?(如果档案不存在,>> 和 > 都会建立新的。)

事实上,在我们的日常管理中,重导向的应用是非常普遍的。我只举下面这个例子就好了:

当我们进行核心编译的时候(我们下一章再介绍),荧幕会飞快的显示出成千上万行信息;其中有大部份是 STDOUT,但也有些是 STDERR。除非您的眼睛真的那么厉害,否则您很难分辩出哪些是正常信息,哪些是错误信息。当您要编译失败,尝试找错误的时候,如果已经将 STDERR 重导出来,就非常方便了:
# make dep clean bzImage modules 1>/dev/null 2>/tmp/kernel.err &

这里,我一共有三个打算:(1) 将标准输出送到一个叫 null 的设备上,如果您记性够好,我在前面的文章中曾比喻它为黑洞:所有东西进去之后都会消失掉。凭我个人的习惯,我会觉得编译核心时跑出来的信息,如果您不感兴趣的话,那都是垃圾,所以我将 STDOUT 给重导到 null 去,眼不见为干净; (2) 然后,我将 STDERR 重导到 /tmp/kernel.err 这个档去,等命令结束后,我就可以到那里看看究竟有部份有问题。有些问题可能不是很重要,有些则可能需要重新再编核心,看您经验啦。(3) 最后,我将命令送到 background 中执行 (呵~~ 相信您还没忘记吧!)。因为,编译核心都比较花时间,所以我将之送到背景去,这样我可以继续做其它事情。

Tips:这时,因为系统太忙了,可能反应速度上会比较慢些,如果您真的很在意,不妨考虑把 make 的 nice level 提高。(忘记怎么做了?那翻看前一章吧)

前面的例子,我们是分开将 STDOUT 和 STDERR 重导到不同的档案去,那么,我们能否把两者都重导到同一个档呢?当然是可以的,请比较下面三行:
# make dep clean bzImage modules >/tmp/kernel.result 2>/tmp/kernel.result
# make dep clean bzImage modules >/tmp/kernel.result 2>&1
# make dep clean bzImage modules &>/tmp/kernel.resultt

我这里告诉您:第一行的命令不怎么正确,因为这样会造成这两个输出同时在‘抢’一个档案,写入的顺序很难控制。而第 2 行和第 3 行的结果都是一样的,看您喜欢用哪个格式了。不过,要小心的是:& 符号后面不能有空白键,否则会当成将命令送到背景执行,而不是将 STDOUT 和 STDERR 整合。

好了,前面我们都在谈 STDOUT 和 STDERR 的重导向,那么,我们是否能重导 STDIN 呢?

当然可以啦~~~

有些命令,当我们执行之后,它会停在那里等待键盘的 STDIN 输入,直到遇到 EOF (Ctrl+D) 标签才会真正结束命令。比方说,在同一个系统上,如果有多位使用者同时登入的话,您可以用 write 命令向特的使用者送出短讯。而短讯的内容就是键盘敲入的文字,这时候命令会进入输入模式,您每输入一行并按 Enter 之后,那么讯息就会在另外一端,直到您按 Ctrl+D 键才离开并结束命令。
# write user1
Hello!
It is me… ^_^
How r u!
(Ctrl+D)

这样通常都需要花一些时间输入,假如对方在写什么东西和查看某些资料的时候,就很混乱。这时候,您或许可以先将短讯的内容写在一个档案里面,例如 greeting.msg,然后这样输入就可以了:
write user1 < greeting.msg

就这样,这里我们用小于符号 (<) 来重导 STDIN 。简单吧?^_^

不过,我们用 cat 命令建立简单的档案的时候,却是使用 > 符号的:
cat > file.tmp

等您按 Ctrl+D 之后,从键盘输入的 STDIN,就保存在 file.tmp 中了。请想想看为什么会如此?(我在 LPI 的考试中碰到过这道题目哦~~~)

pipe

查字典,pipe 这个英文是水管、管道、管线的意思。那么,它和命令又有什么牵连呢?简单的说,一个命令管线,就是将一个命令的 STDOUT 作为另一个命令的 STDIN 。

其实,这样的例子我们前面已经碰到多次了,例如上一章介绍 tr 命令的时候:
# cat /path/to/old_file | tr -d ‘\r’ > /path/to/new_file

上面这个命令行,事实上有两个命令:cat 和 tr ,在这两个命令之间,我们用一个 “ | ”符号作为这两个命令的管线,也就是将 cat 命令的 STDOUT 作为 tr 命令的 STDIN ;然后,tr 命令的 STDOUT 用 > 重导到另外一个档案去。

上面只是一个非常简单的例子而已,事实上,我们可以用多个管线连接多个程式,最终获得我们确切想要的结果。比方说:我想知道目前有多少人登录在系统上面:
# w | tail +3 | wc -l

我们不妨解读一下这个命令行:(1) w 命令会显示出当前登录者的资源使用情况,并且每一个登录者占一行;(2) 再用 tail 命令抓取第 3 行开始的字行;(3) 然后用 wc -l 计算出行数。这样,就可以知道当前的登录人数了。

许多朋友目前都采用拨接 ADSL 上网,每次连线的 IP 都未必一样,只要透过简单的命令管线,您就可以将当前的 IP 抓出来了:

1. 我们不妨观察 ifconfig ppp0 这个命令的输出结果:

# ifconfig ppp0
ppp0 Link encap:Point-to-Point Protocol
inet addr:211.74.48.254 P-t-P:211.74.48.1 Mask:255.255.255.255
UP POINTOPOINT RUNNING NOARP MULTICAST MTU:1492 Metric:1
RX packets:5 errors:0 dropped:0 overruns:0 frame:0
TX packets:3 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:3

2. 不难发现 IP 位址所在的句子中有着其它句子所没有的字眼:inet addr 。然后,我们就可用 grep 把这行抓出来:

# ifconfig ppp0 | grep "inet addr"
inet addr:211.74.48.254 P-t-P:211.74.48.1 Mask:255.255.255.255

3. 再来,我们先用相同的分隔符号将句子分成数列,然后抓出 IP 位址所在的那列。

嗯,这里,我们可以用“ : ”来分出 4 列;也可以用空白键来分出 5 列(空因为句子开首就是一个空白键)。如果用空白键来分的话,由于有些间隔有多个空白键的原因,那么,我们可以用 tr 命令,将多个空白键集合成一个空白键:

# ifconfig ppp0 | grep "inet addr" | tr -s ‘ ‘ ‘ ‘
inet addr:211.74.48.254 P-t-P:211.74.48.1 Mask:255.255.255.255

(注意:在 ‘ ‘ 之间是一个空白键!)

4. 然后用 cut 命令抓出 IP 所在的列,细心数一数,应该是第 3 列:

# ifconfig ppp0 | grep "inet addr" | tr -s ‘ ‘ ‘ ‘ | cut -d ‘ ‘ -f3
addr:211.74.48.254

5. 然后我们用“ : ”再分两列,抓第 2 列就是 IP 了:

# ifconfig ppp0 | grep "inet addr" | tr -s ‘ ‘ ‘ ‘ \
| cut -d ‘ ‘ -f3 | cut -d ‘:’ -f2
211.74.48.254

这里,我们一共用 5 个 pipe 将 4 个命令连接起来,就抓出机器当前的 IP 位址了。是否很好用呢?

在同一个命令行里面出现多个命令的情形,除了 “ | ”之外,或许您会看到 " ` ` " 符号,也就是和 ~ 键同一个键的符号(不用按 Shift )。它必须是一对使用的,其中可以包括单一命令,或命令管线。那它的效果和命令管线又有什么分别呢?

我们使用 pipe 将一个命令的 STDOUT 传给下一个命令的 STDIN,但使用 “ 的时候,它所产生的 STDOUT 或 STDERR 仅作为命令行中的一个参数而已。嗯,不如看看下面命令好了:
# TODAY=`date +%D`
# echo Today is $TODAY.
Today is 08/17/01.

从结果我们可以看出,我们用 “ 将 date 这个命令括起来(可含参数),那么它的执行结果可以作为 TODAY 的变数值。我们甚至还可以将一串命令管线直接用在命令行上面:
# echo My IP is `ifconfig ppp0 | grep "inet addr" \
| tr -s ‘ ‘ ‘ ‘ | cut -d ‘ ‘ -f3 | cut -d ‘:’ -f2`
My IP is 211.74.48.254.

注意:第一行的 CR 被 \ 跳脱了,所以这个命令行‘看起来’有两行。我之所以弄这么复杂,是告诉您这对 “ 符号可以适用的范围。

Tips:在变数中使用 “ 可以将命令的执行结果当成变数值的部份。事实上,除了用 “ 之外,您也可以用这样的格式:VAR_NAME=$(command) ,那是和 VAR_NAME=`command` 的结果是一样的。

除了这对 “ 和 | 之外,还有另外一个符号 “ ; ”来分隔命令的。不过,这个比较简单:就是当第一命令结束之后,再执行第二个命令,如此类推:
# ./configure; make; make install

呵~~ 如果您对您的安装程式有绝对信心,用上面一行命令就够了!

Shell Script

当我们对 shell 变数和命令行有一定认识之后,那么,我们就可以尝试写自己的 shell script 啰~~ 这可是非常好玩而又有成就感的事情呢!^_^

在 linux 里面的 shell script 可真是无处不在:我们开机执行的 run level 基本上都是一些 script ;登录之后的环境设定,也是些 script ;甚至工作排程和记录维护也都是 script 。您不妨随便到 /etc/rc.d/init.d 里面抓两个程式回来看看,不难发现它们都有一个共同的之处:第一行一定是如下这样的:

#!/bin/sh
或:
#!/bin/bash

其实,这里的 #! 后面要定义的就是命令的解释器(command interpreter),如果是 /bin/bash 的话,那下面的句子就都用 bash 来解释;如果是 /usr/bin/perl 的话,那就用 perl 来解释。不同的解释器所使用的句子语法都不一样,非常严格,就算同是用 shell 来解释,不同的 shell 之间的格式也不仅相同。所以,如果您看到 script 的解释器是 /bin/sh 的话,那就要小心了:如果您仔细看这个档案,事实上它仅是一个 link 而已,有些系统或许会将它 link 到其它 shell 去。假如您的 script 句子使用的语法是 bash 的话,而这个 sh 却 link 到 csh ,那执行起来可能会有问题。所以,最好还是直接指定 shell 的路径比较安全一些:在这里的范例都使用 /bin/bash 来作为 script 的解释器。

在真正开始写 script 之前,先让我们认识 script 的一些基本概念:

简单来说,shell script 里面就是一连串命令行而已,再加上条件判断、流程控制、回圈、等技巧,聪明地执行正确的命令和使用正确的参数选项。和我们在 shell 里面输入命令一样,shell script 也有这样的特性:

* 当读到一个 CR 字符的时候,就尝试执行该行命令;
* 它会忽略空白行;句子前面的空白和 tab 也不理会;
* CR 字符也同样可以用“ \ ”符号跳脱;
* 另外,“ # ”符号是注解符号,从这个符号至句子末端的内容全被忽略,程式本身不会读入这部份,但我们经常用来给使用者阅读,因而名为注解;
* 等等。

一个良好的 script 作者,在程式开头的时候,都会用注解说明 script 的名称、用途、作者、日期、版本、等信息。如果您有这个机会写自己的 script,也应该有这个良好习惯。

shell script 档的命名没一定规则,可以使用任何档案名称(参考档案系统),但如果您喜欢的话,可以用 .sh 来做它的副档名,不过这不是硬性规定的。不过,要执行一个 shell script,使用者必须对它有执行权限( x ),用文件编辑器新建立的档案都是没有 x permission 的,请用 chmod 命令加上。执行的时候,除非该 script 已经至于 PATH 环境变数之内的路径内,否则您必须指定路径。例如,您写了一个叫 test.sh 的 shell script,放在家目录内,假设这也是您的当前工作目录,您必须加上路径才能执行:./test.sh 或 ~/test.sh 。所以,建议您在 script 测试无误之后,放在 ~/bin 目录里面,那就可以在任何地方执行自己的 script 了,当然,您要确定 ~/bin 已经出现在您的 PATH 变数里面。

script 之所以聪明,在于它能够对一些条件进行测试( test )。您可以直接用 test 命令,也可以用 if 叙述,例如:test -f ~/test.sh 。它的意思是测试一下 ~/test.sh 这个档案是否存在,这个 -f 通常用在档案上面的测试,除了它,还有很多:

标签 代表意思
-G 存在,并且由 GID 所执行的行程所拥有。
-L 存在,并且是 symbolic link 。
-O 存在,并且由 UID 所执行的行程所拥有。
-S 存在,并且是一个 socke 。
-b 存在,并且是 block 档案,例如磁碟等。
-c 存在,并且是 character 档案,例如终端或磁带机。
-d 存在,并且是一个目录。
-e 存在。
-f 存在,并且是一个档案。
-g 存在,并且有 SGID 属性。
-k 存在,并且有 sticky bit 属性。
-p 存在,并且是用于行程间传送资讯的 name pipe 或是 FIFO。
-r 存在,并且是可读的。
-s 存在,并且体积大于 0 (非空档)。
-u 存在,并且有 SUID 属性。
-w 存在,并且可写入。
-x 存在,并且可执行。

事实上,关于这些测试项目还有很多很多,您可以 man bash 然后参考 CONDITIONAL EXPRESSIONS 那部份。另外,我们还可以同时对两个档案进行测试,例如:test file1 -nt file2 就是测试 file1 是否比 file2 要新。这种测试使用的标签是:

标签 代表意思
-nt Newer Than:第一个档案比第二个档案要新。
-ot Older Than:第一个档案比第二个档案要旧。
-ef Equal File:第一个档案和第二个档案其实都是同一个档案 (如 link)。

我们这里所说的这些测试,不单只用来测试档案,而且还常会用来比对‘字串 (string)’或数字(整数)。那什么是字串呢?字面来介绍就是一串文字嘛。在一个测试中,~/test.sh 本身是一个档案;但 ‘~/test.sh’ ,则是在引号里面(单引号或双引号),那就是字串了。

在数字和字串上面的比对(或测试),所使用的标签大约有:

标签 代表意思
= 等于
!= 不等于
< 小于
> 大于
-eq 等于
-ne 不等于
-lt 小于
-gt 大于
-le 小于或等于
-ge 大于或等于
-a 双方都成立
-o 单方成立
-z 空字串
-n 非空字串

在上面提到的比对中,虽然有些意思一样,但使用场合却不尽相同。例如 = 和 -eq 都是‘等于’的意思,但 = 只能比对字串,而 -eq 则可以用来比对字串,也能用来比对表示色样(我们在 regular expression 会碰到)。

我们之所以要进行测试或比对,主要是用来做判断的:假如测试或比对成立,那就返回一个‘真实 (true)’否则返回‘虚假 (false)’。也就是说:如果条件成立那么就会如何如何;如果条件不成立又会如何如何,从而让 script 有所‘智慧’。基本上,我们的程式之所以那么聪明,都是从这些简单到复杂的判断开始的。

比方说,上面的 -a (AND) 和 -o (OR) 是用来测试两个条件:A 和 B 。如果使用 test A -a B ,那么 A 和 B 都必须成立那条件才成立;如果使用 test A -o B ,那么只要 A 或 B 成立那条件就成立。至于其它的比对和测试,应该更好理解吧?

另外,还有一个特殊符号:“ !”您可不能不会运用。它是‘否’的意思,例如:"! -f"是非档案; "-ne" 和 "! -eq" 都是‘不等于’的意思。

我们在命令行上面已经知道如何定义和改变一个变数,那在 shell script 里面就更是司空见惯了。而且,越会利用变数,您的 script 能力就越高。在 shell script 中所定义的变数有更严格的定义:

标签 代表意思
-a 定义为阵列 (array) 变数
-f 仅定义功 (function) 能名称。
-i 定义为整数。
-r 定义为唯独变数。
-x 透过环境输出变数。

我们除了用 “ = ”来定义变数之外,还可以用 declare 命令来明确定义变数。例如:
# A=3 B="-2"
# RESULT=$A*$B
# echo $RESULT
3*-2
# declare -i A=3 B="-2"
# declare -i RESULT=$A*$B
# echo $RESULT
-6

您这里会发现:如果没有使用 declare 命令将变数定义为整数的话,那么 A 和 B 的变数值都只是字串而已。

您现在已经知道什么是变数、如何定义变数、什么是字串、如何比对和测试字串和档案,这些都是 script 的基本技巧。写一些简单的 script 应该不成问题了,例如在家目录写一个 test.sh ,其内容如下:

1 #!/bin/bash
2 # Purpose: a simple test shell script.
3 # Author: netman <netman@study-area.net>
4 # Date: 2001/08/17
5 # Version: 0.01
6
7 CHK_FILE=~/tmp/test.sh
8
9 if [ ! -e $CHK_FILE ]
10 then
11 echo "$0: Error: ‘$CHK_FILE’ is not found." ; exit 1
12
13 elif [ -d $CHK_FILE ];then
14 echo -n "$CHK_FILE is a directory, and you can "
15 test -x $CHK_FILE || echo -n "NOT "
16 echo "search it."
17 exit 2
18
19 elif [ -f $CHK_FILE ]; then
20 echo "$CHK_FILE is a regular file."
21 test -r $CHK_FILE && echo "You can read it."
22 test -x $CHK_FILE && echo "You can execute it."
23 test -w $CHK_FILE && echo "You can write to it."
24 test -s $CHK_FILE || echo "However, it is empty."
25 exit 0
26
27 else
28 echo "$CHK_FILE is a special file."
29 exit 3
30
31 fi

(注意:我目前用 vi 编辑,并用 :set nu 将行数显示出来,实际的命令行是没有行数的。)

先让我们看第一行:#!/bin/bash,就是定义出 bash 是这个 script 的 command interpreter 。

然后是一些注解,说明了这个 script 的用途、作者、日期、版本,等资讯。

在注解之后,第 7 行才是 script 的真正开始:首先定义出一个变数 CHK_FILE ,目前内容是家目录中 tmp 子目录的 test.sh 档案。

Tips:事实上,这个定义比较有局限,如果您想改良这个设计,可以将这行(第 7 行)换成下面数行:

if [ -z $1 ]
then echo "Syntax Erro! Usage: $0 <file_path>" ; exit 5
else CHK_FILE=$1
fi

第一行是开始一个 if 的判断命令,它一定要用一个 fi 命令来结束(您可以在最后一行找到它);然后在 if 和 fi 之间必须有一个 then 命令。这是典型的 if-then-fi 逻辑判断:如果某某条件成立,然后如何如何;还有 if-then-else-fi 判断:如果某某条件成立,然后如何如何,否则如何如何;另外,也有 if-then-elif-then-else-fi 判断:如果某某成立,然后如何如何;否则,再如果某某成立,然后如何如何;如果还是不成立,那就如何如何。

上面那几行,主要目的是将 CHK_FILE 这个变数值定义为 $1。嗯?您或许会问 $1 是什么啊?那是当您执行这个 script 的时候所输入的第一个参数;而 $0 则是 script 命令行本身。所以,这里是先判断一下 $1 是否为空的 ( -z ),然则(then),告诉您语法错误,并告诉您正确的格式,同时退出,并返回一个状态值(后面再谈);否则(else),就将 CHK_FILE 定义为 $1。

接下来第 9 行,您可以将 "if [ ! -e $CHK_FILE ]" 换成 "if test ! -e $CHK_FILE " ,意思都是一个测试。但如果用 [ ] 的话有一个地方要注意:"[ " 的右边必须保留一个空白;" ]" 的左边必须保留一个空白。

在目前这个 script 中,判断逻辑如下:

1. 先检查 $CHK_FILE (也就是 ~/tmp/test.sh 这个档) 是否不存在( ! -e ),如果( if )条件成立,那就参考 then 里面的命令;否则参考下面 elif 或 else。

2. 如果上一步骤成立,也就是 ~/tmp/test.sh 不存在,然则用 echo 命令告诉您不能读取这个档,并同时返回父程式一个返回状态(还记得我们在前面提到过的 $? 变数吗?),这里为 1。在 script 中,任何时候执行 exit 的话,就会离开 script,不管后面是否还有其它命令行或判断。因为我将这里 echo 和 exit 写在同一行,所以用一个 " ; " 符号分隔开来,否则,您可以将 exit 写在下一行。

3. 接下来( 13 行)是一个 elif ,就是 else if 的意思,也就是说:如果上一个 if 不成立,然后在这里再做另外一个 if 测试。这里是继续检查这个档是否为一个目录( -d ),然则,告您它是一个目录,并同时尝试告诉您是否能对这个目录进行搜索。

然后看看下一行( 15 行)动内容,请留意上一个 echo 和这个 echo,都带有一个 -n 的参数,意思是在显示信息的时候不进行断行( newline )的动作,所以,和下面那行合在一起(共 3 行 script )才是真实显示的内容。这里再进行一个测试:看看您对这个目录是否具有 -x 权限,否则会在 "and you can" 和 "search it." 之间加上一个 "NOT",如果有权限就不出现这个 NOT 。

这里,我们没有用 if-then 来判断,而是直接用 “ || ” ( OR ) 来做判断:非此即彼。这在一些简单的判断中非常好用,尤其对懒人来说,因为不用打太多的字;但功能就比较有限:判断之后只能执行一个命令而已。除了 || 之外,您也可以用 “ && ”( AND ) 做判断,套句 Jack 的名言:You jump I jump。所以,这句也可以换成:
test ! -x $CHK_FILE && echo -n "NOT " (粗体字是修改部份)。

最后,根据目前这个 elif 条件所进行的所有命令都执行完毕,并退出这个 script,同时设定返回状态为 2 。

4. 再下来( 19 行)是另一个 elif ,也就是说:如果连上一个 elif 也不成立的话,那这里继续检查这个档是否是一个常规档案( -f ),然则,告诉您是一个常规档案,然后,接连进行三个测试,分别测试您是否具有 -r、-x、-w 的权限,有的话,分别告诉您相关的可行性。最后还检查这个档案的长度是否超过 0 ( -s ),否则告诉您它是一个空档。完成这些判断之后,就退出 script,并返回一个为 0 的状态。

5. 然后( 27 行)是一个 else,意思是如果上面的所有 if 和 elif 都不成立,那就看这里的。也就是说:这个档案是存在的,但不是目录,也不是常规档案,那它就是一个特殊档。然后退出 script,并设定返回状态为 3。

在这个范例中,script 一共有 0、1、2、3 这四个返回状态,根据这个返回值( $? ),我们就可以得知检查的档案究竟是一个常规档、还是不存在、还是目录、还是特殊档。

6. 最后,再没有其它动作了,就结束这个 if 判断。

目前这个 script 仅提供一些 script 概念给您而已,例如:定义和使用变数、if-then-else-fi 判断式、条件测试、逻辑关系、退出状态、等等。同时,这个范例也提供了一些基本的 script 书写惯例:用不同的缩排来书写不同的判断或回圈。例如这里一共有两个 if-then-else-fi 判断,第一个 if、then、else、fi 都没有缩排,然后,紧接这些命令后面的叙述就进行缩排;当碰到第二层的 if-then-else-fi 的时候,也如此类推。事实上,并非一定如此写法,但日后如果您的程式越写越长,您自然会这样安排的啦~~

刚才我们认识了一个 if-then-else-fi 的判断,事实上,在 script 的应用上,还有其它的许多判断技巧,在我们开发更强大和复杂的 script 之前,不妨先认识一下:

case
格式:

case string in
pattern)
commands
;;
esac

它能根据不同的字串来做相应的动作,不如用例子来说好了:

1 #!/bin/bash
2 # Purpose: a simple test shell script.
3 # Author: netman <netman@study-area.net>
4 # Date: 2001/08/20
5 # Version: 0.02
6

8 echo Please pick a number:
9 echo " "a, To show the local time.
10 echo " "b, To list current directory.
11 echo " "c, To see who is on the machine.
12 echo -n "Your choice: "
13
14 read choice
15
16 case $choice in
17 a | A) echo -n "The local time is "
18 date ;;
19 b | B) echo "The current directory is $PWD ";;
20 c | C) echo "There are following users on the machine:"
21 who ;;
22 *) echo "Your choice is an invalid option." ;;
23 esac

这个 script 是先请您选择 a、b、c 字母,再用 read 命令从键盘读入 choice 的变数值,然后将这个变数应用在 case 判断中:

* 如果是 a 或 A:执行 date 命令;
* 如果是 b 或 B:用 $PWD 这个环境变数显示当前目录;
* 如果是 c 或 C:则执行 who 命令;
* 如果是其它 ( * ) :则告诉您 invalid 。

不知道您是否有注意到:每一个 case 的选项,都用一个 " ) " 作指引,然后,在这个 case 最后一个命令完成以后,一定要用 " ;; " 来结束。最后,还必须用 case 的倒写 esac 来关闭这个判断。

for
格式:

for item in list 
do
commands
done

当您需要重复处理一些事物的时候,for 回圈就非常好用了。它通常用来重复处理一些列表( list ) 中的事物,比方说您有一个变数,里面包含着一串列表,那么回圈会一个接一个的进行处理,直到最后一个处理完毕之后才退出。不如又用一个范例来说明好了:

1 #!/bin/bash
2 # Purpose: a simple test shell script.
3 # Author: netman <netman@study-area.net>
4 # Date: 2001/08/21
5 # Version: 0.03
6
7
8 if [ -z "$1" ] || [ -z "$2" ] ; then
9 echo "Syntax Error: Usage ${0##*/} <word to search> <target dir>"
10 exit 1
11 fi
12 if [ ! -d $2 ]; then
13 echo "${0##*/} : Error: $2 is not a directory."
14 exit 2
15 fi
16 TWORD="$1"
17 TDIR="$2"
18 TFILE=`grep -r "$TWORD" "$TDIR" | cut -d ‘:’ -f1 | uniq`
19
20 if [ ! -z "$TFILE" ]; then
21 echo "You can find $TWORD in following file(s):"
22 for i in $TFILE ;do
23 echo $i
24 done
25 exit 0
26 else
27 echo "Could not find $TWORD in any file under $TDIR."
28 exit 3
29 fi

这个 script 是在一个目录下面搜索档案,如果档案里面发现有指定的文字,就将档案的名称列出来。它必须要抓两个变数:TWORD 和 TDIR ,这两个变数分别为 script 的第 1 个和第 2 个参数。

一开始要检查命令行是否有两个变数,用 -z $1 和 -z $1 来测试,如果它们其一没有指定,就告诉您语法错误,同时退出(返回值为 1 ) 。然后再检查 $2 是否为目录,如果不是目录,就也提出警告,并退出(返回值为 2 )。如果通过上面两道检查,然后用命令 grep、cut、uniq,将档案抓出来。注意:这就是 for 回圈需要检查的列表。

然后会检查列表是否有内容,如果有的话,那就用 for 回圈来重复显示列表里面的所有项目;一次一个,直到列表最后一个项目也处理完毕。这就是一个 for 回圈的基本运作方式了。如果列表没有被建立起来,那就告诉您找不到您指定的文字,并退出(返回值为 3 )。

while
格式:

while condition
do
commands
done

这个回圈应该蛮容易理解的:当条件成立的时候,就一直重复,直到条件消失为止。我们不妨改良前面的 case 那个 script 看看:

1 #!/bin/bash
2 # Purpose: a simple test shell script.
3 # Author: netman <netman@study-area.net>
4 # Date: 2001/08/21
5 # Version: 0.02.1
6

8 while [ "$choice" != "x" ]; do
9 echo
10 echo Please pick a number:
11 echo " "a, To show the local time.
12 echo " "b, To list current directory.
13 echo " "c, To see who is on the machine.
14 echo " "x, To exit.
15 echo -n "Your choice: "
16
17 read choice
18 echo
19
20 case $choice in
21 a) echo -n "The local time is "
22 date ;;
23 b) echo "The current directory is $PWD ";;
24 c) echo "There are following users on the machine:"
25 who ;;
26 x) echo "Bye bye…"; exit 0 ;;
27 *) echo "Your choice is an invalid option." ;;
28 esac
29 done

首先,我们用 while 进行应该条件判断:如果 $choice 的变数值不等于 x 的话,那就重复回圈,直到遇到 x (条件消失)为止。那么这个 script 会一直提示您键入选项,然后进行处理,直到您按 x 键才会结束。

until
格式:

until condition
do
commands
done

这个 until 刚好和 while 相反:如果条件不成立就一直重复回圈,直到条件成立为止。如果继续引用上例,只需将 while 的条件设为相反就可以了:

修改前:
8 while [ "$choice" != "x" ]; do

修改后:
8 until [ "$choice" = "x" ]; do

没错:就是这么简单!

sub function
格式:

function function_name
{
commands
}

或:
function_name ()
{
commands
}

当您在一个 script 中,写好了段可以用来处理特定条件的程式之后,或许后面会重复用到。当然,您可以重复写这些句子,但更便利的办法是:将这些重复性的句子做成 sub function。如果您有模组的概念,那就是将一些能够共享的程式做成模组,然后提供给需要用到此功能的其它程式使用。说实在,看一个程式撰写人的模组化程度,也就能看得出这个人的程式功力。

我们不妨写一个 script 来显示机器目前所使用的网路卡界面资讯,看看里面的 sub function 是怎么运用的 :

1 #!/bin/bash
2 # Purpose: a simple test shell script.
3 # Author: netman <netman@study-area.net>
4 # Date: 2001/08/21
5 # Version: 0.04
6

8 # function 1: to get interface.
9 getif () {
10 until [ "$CHKIFOK" = "1" ] || [ "$GETNONE" = "1" ]; do
11 echo -n "The interface (ethX) for $CHKNET network [Enter for none]: "
12 read CHKIF
13 if [ -z "$CHKIF" ]; then
14 echo
15 echo "There is no interface for $CHKNET network."
16 echo
17 GETNONE=1
18 else
19 chkif # invoke the second function
20 fi
21 done
22 }
23
24 # function 2: to check interface.
25 chkif () {
26 TESTIF=`/sbin/ifconfig $CHKIF | grep "inet add"`
27 if [ -z "$TESTIF" ]; then
28 echo ""
29 echo "ERROR: Could not find interface ‘$CHKIF’ on your machine!"
30 echo " Please make sure $CHKIF has been set up properly."
31 echo ""
32 return 1
33 else
34 CHKIFOK=1
35 getip # invoke the third function
36 return 0
37 fi
38 }
39
40 # function 3: to get ip.
41 getip () {
42 CHKIP=`ifconfig $CHKIF | grep "inet addr" | tr -s ‘ ‘ ‘ ‘ \
43 | cut -d ‘ ‘ -f3 | cut -d ‘:’ -f2`
44 CHKMASK=`ifconfig $CHKIF | grep "inet addr" | tr -s ‘ ‘ ‘ ‘ \
45 | cut -d ‘ ‘ -f5 | cut -d ‘:’ -f2`
46 echo
47 echo "The interface of $CHKNET network is $CHKIF using $CHKIP/$CHKMASK."
48 echo
49 return 0
50 }
51
52 # start of main body
53 for CHKNET in EXTERNAL INTERNAL DMZ ; do
54 getif # invoke the first function
55 unset GETNONE
56 unset CHKIFOK
57 done

在这个 script 中,目前有三个 sub function:

* getif ():这里用 until 回圈从键盘那里读入指定的网路卡。如果直接按 Enter 表示没有界面,然则,回报一个信息,并将 GETNONE 变数设定为 1 ,同时退出这个 function;否则,执行下一个 function 。

* chkif ():当上一个 function 顺利读入网路卡名称之后,会检查这个界面是否存在。这里是用 /sbin/ifconfig 和 grep 来检查,如果命令结果抓不到 IP 位址,表示这张卡还没设定好,然则,回报一个错误信息,并退出 function,返回状态为 1 ;否则,执行下一个 function,然后退出 function,返回状态为 0。

(注意:这里的 function 有使用 return 退出以及设定返回状态;但上一个 function 没有使用 retuen,是因为 getif () 有使用 until 回圈,如果那里用 return 的话,就会打断 until 回圈。)

* getip ():当上一个 function 通过界面检测之后,就将界面的 IP 和 netmask 抓出来,同时告诉您相关的网路资讯,最后退出 function,返回状态为 0。

当所有 sub function 都定义完毕之后,接下来就是 main body 的开始。这里用一个 for 回圈,分别对 EXTERNAL、INTERNAL、和 DMZ 网路进行检查,执行第一 function 就开始一连串的动作了。因为 sub function 里面的变数会重复使用,所以,在每次使用过其中的功能之后,要将某些影响下一个判断的变数清空,用 unset 命令即可。

事实上,用在 script 上面的回圈有非常多的变化,恐怕我也没此功力为大家一一介绍。还是留待您自己去慢慢摸索了。

Regular Expression

常规表示式 (RE — Regular Expression) 应该是所有学习程式的人员必须具备的基本功夫。虽然,我的程式能力很差,而且这里的文章也不是以程式为主,不过,在日后的管理生涯当中,如果会运用 RE 的话,将令许多事情都时半功倍,同时也让您在管理过程中如虎添翼。下面,我们只接触最基本的 RE 常识,至于进阶的技巧,将留给有兴趣的朋友自己发挥。

首先,不妨让我们认识最基本的 RE 符号:

符号 代表意思 范例
^ 句子前端 "^dear" :句子必须以 dear 开头。
$ 句子末端 "dear$":句子必须以 dear 结尾;"^$" :空白行。
\ 跳脱字符 "\\" :\ 符号本身;"\." :小数点;"\ " :空白键。 
. 任何单元字符 ".ear" : 可以是 dear, bear, tear,但不能是 ear 。
? 前一个 RE 出现 0 次或 1 次

"^[0-9]?$" : 空白行或只含 1 个数字的字行。
* 前一个 RE 可出现 0 次或多次

"^.*" :所有字行;

"^[0-9][0-9]*$" : 含一或多个数字的字行。
+ 前一个 RE 可出现 1 次或多次

"^[0-9][0-9]+$" : 含两个或多个数字的字行。
\{n\} 接在前一字符的 n 个相同范围字符 "^[0-9]\{3\}[^0-9]" :句子开头连续 3 个数字,然后是一个非数字。
\{n,\} 接在前一字符的最少 n 个相同范围的字符 "^[0-9]\{3,\}" :句子开头最少要有连续 3 个数字。
\{n,m\} 接在前一字符的 n 到 m 个相同范围的字符 "^[0-9]\{3,5\}" :句子开头连续 3 或 5 个数字。
[list] 列表中任何单元字符 "t[ear]." :可以是 tea, tar, try ,但不能是 tim 。
[range] 范围中任何单元字符 "t[e-r]." :可以是 tea, tim, try ,但不能是 tar 。
[^range] 任何不在范围中的单一字符 "t[^e-r]." :可以是 tar,但不能是 tea, tim, try。

通常,我们用来处理 RE 的程式有 grep、egrep、sed、awk、vi、等等,各程式的语法和功能都相差很多,需要详细研究过才能摸熟。在某些程式中,例如 egrep 和 awk,还可以处理某些延伸字符,例如:" | " 是两个 RE 的或一关系;" ( )" 可用来组合多个 RE ;等等。有兴趣的话,网路上都有许多资料可以找得到,例如网站 龙门少尉的窝 的 “正规表示式的入门与应用”等系列文章。

sed & awk

许多人提到 RE 的时候,都少不了介绍一下 sed 和 awk 这对宝贝,它们都可以用来处理字串,但处理手法上却有所不同。有人说用 sed 对‘字行’为单位的处理比较方便;而 awk 则在列表处理上面有独到的神通。是否如此,大家不妨自己玩玩看啰。

让我们先看看 sed 这个程式,它的命令语法有点类型 vi 里面的编辑功能:

* 以单一字母来做命令名称;
* 命令所需的参数置于命令之后;
* 您可以将行数或 RE 置于命令之前,以特指命令要处理的对象。

关于 sed 的常用命令,请参考下表:

命令 语法 说明
a a\ string 在字行后面增加特定字串(新行)。
c c\ string 将字行换成特定字串。
d d 删除字行。
i i\ string 在字行前面插入特定字串(新行)。
p p 显示字行。除非用 -n 指明,预设会在处理完毕之后显示子行。
s s/oldstring/newstring/flag

用新的字串替换旧的字串。其中可用的旗标有:

g:替换行中的所有旧字串(预设只换第一个);

p:显示;

wfile:写入特定档案。

例如,您要输入:
sed 1,3d src.file

所显示的结果,就会将 src.file 的前面三行砍掉。如果您输入:
sed ‘3,$d’ src.file

这样,所显示的结果,就会从第 3 行到最后一行都砍掉,只剩下第 1 和第 2 行而已。 上面的命令别忘了加引号,否则要 \$ 来跳脱。不过,我强烈建议您用单引号将 sed 的命令括起来。如果您要将空白行拿掉,用 RE 来做非常简单:
sed ‘/^$/d’ src.file

在 sed 里面引用 RE 的时候, 通常都会用两个 / / 符号将 RE 括起来,然后才是命令。 如果您想要更换字串,那就要用 s 命令了:
sed ’s/red/blue/g’ src.file

这样,所有的 red 字串都会被换成 blue ;如果没有加上 g 旗标,那么只有每一行的第一个 red 被替换而已。

除了 d 和 s 命令之外,我们还可以用 a 命令在句子后面新增一行,内容为字串部份;或用 i 命令在句子前面插入一行,内容为字串部份;也可以用 c 命令将整行换成字串部份。不过,您在执行这几个命令的时候,必须要用 ‘ ‘ 将命令和参数括起来,然后用 \ 符号在命令后面跳脱 Enter 键,然后才能完成。嗯,说起来蛮难理解的,不如实作一下吧:
sed ‘$a \
New line appened at the end.’ src.file

这样,就会在档案最后面增加一行句子了。再比方说,您要将第 3 行换成另外的文字,可以这样玩:
sed ‘3c \
The third line is replace with this line.’ src.file

再比方说,您想将您存储邮件的档案 ~/mbox 用虚线分开每一封邮件,可以这样试试:
sed ‘/^From /i \
\
————————-\
\
‘ ~/mbox

我想,您应该不会忘记我们在前面的文章中,用 ifconfig | grep | tr | cut 这些命令管线来抓出网路卡的界面吧。事实上,我们用 sed 命令也一样可以得到同样的结果:
ifconfig eth0 | grep "inet addr" | sed -e ’s/^.*addr://’ | sed ’s/ *Bcast.*$//’

第一个 sed 是将 addr: 到句子前面的字串用 s 命令替换为无字串(也就是在最后的 // 中间没任何字符);然后第二个 sed 将 Bcast 连同前面的空白,到句子末端也用 s 替换为无字串(注意:/ *Bcast 的 / 和 * 之间是空白键);这样,剩下来的就是 IP 位址了。

目前,我们所进行的命令输出,都是在荧幕上,既然您已经学会命令的重导向了,要将结果保存到其它档案去,应是易如反掌了吧。 ^_^

至于 sed 的应用技巧,您可以到如下网站好好研究一下:

http://www.ptug.org/sed/sedfaq.htm

学习过 sed 之后,让我们再看看 awk 这个命令究竟有什么神通。就拿刚才所举的抓 IP 的例子来说好了,换成 awk 也行哦:
ifconfig eth0 | grep "inet addr" | awk -F ‘ ‘ ‘{print $2}’ | awk -F ‘:’ ‘{print $2}’

这里的 awk 和 cut 命令很相似:首先,用 -F 定义出分隔符号(注意:第一个命令用空白做分隔符,所以 -F 后面的两个 ‘ ‘ 之间是空白键),然后再用 print 命令将相应的列抓出来。对 awk 而言,变数 $0 代表每一行被处理的句子 ,然后第一个栏位是 $1、第二个栏位是 $2、…. ,如此类推。

如果您以为 awk 只能做这些事情,就实在是太小看它了!例如您有这样一个文字档(dog.txt),里面只有这么一行文字:

The brown fox jumped on the lazy dog quickly.

然后我们用 awk 来进行处理:
# awk ‘{ $2="black"; $3="dog"; $8="fox"; print}’ dog.txt
The black dog jumped on the lazy fox quickly.

从上面的例子中,我们发现 awk 具有处理变数的能力,事实上,它也有自己内建的变数:

变数名称 代表意思
FS 栏位分隔符号(预设是空白键)。
NF 当前句子中的栏位数目。
NR 当前句子的行数。
FILENAME 当前处理的档案名称。

甚至,awk 还能进行数值上的比对:

变数名称 代表意思
> 大于。
< 小于。
>= 大于或等于。
<= 小于或等于。
== 等于。
!= 不等于。

另外,如果严格来执行的话,awk 命令一共分成三个部份:BEGIN、main、和 END。在 awk 命令中,BEGIN 的部份,是让程式开始时执行一些一次性的命令;而 END 部份则在程式退出的时候执行一些一次性的命令;而 main 呢,则以回圈的形式逐行处理输入。一般来说,我们无须定义 BEGIN 和 END,直接定义 main 的部份就可以执行 awk 命令了。例如:
# echo a b c d | awk ‘BEGIN { x=1;y=2;z=x+y } {print $x $y $z}’
abc

这个例子有点多余,仅作示范而已。因为,我们在 BEGIN 定义了 x、y、z 的值:( 1、2、3 ),然后我们再将 $x、$y、$z (也就是 $1、$2、$3 ) 的栏位列引出来。所以,执行结果是第四栏的 d 就没有显示了。

再例如,您有一个档案 (result.txt),其内容如下:

FName LName English Chinese Math
Kenny Chen 80 80 50
Eward Lee 70 90 90
Amigo Chu 50 80 80
John Smith 90 50 75

您可以用下面的命令,找出 Chinese 及格的名单,而只显示其名(忽略其姓):

# awk ‘{ if ($4 >= 60) print $1" : "$4}’ result.txt | tail +2
Kenny : 80
Eward : 90
Amigo : 80

如果您不想显示作为标头的第一行句子,可以 pipe 到 tail 命令进行过滤。不如,让我们再玩些更复杂的,比方说计算所有名单的平均成绩算,并且以最后一列显示出来,可以这样设计:

# awk ‘{ 
total = $3 + $4 + $5
number = NF – 2
average = total/number
if (NR < 2) printf("%s\t%s\n", $0, "AVERAGE");
if (NR >= 2) printf("%s\t%3.2f\n", $0, average)
}’ result.txt
FName LName English Chinese Math AVERAGE
Kenny Chen 80 80 50 70.00
Eward Lee 70 90 90 83.33
Amigo Chu 50 80 80 70.00
John Smith 90 50 75 71.67

这个命令看起来有点复杂,需要说明一下:

1. 首先,我们用一对 { } 将 awk 的命令括起来,然后在其外面再加一对 ‘ ‘ ,这样您可以在单引号之间敲 Enter 将长命令分成多行输入。

2. 然后定义了 total 的变数为第 3、4、5 栏的总和 (也就是 English + Chinese + Math),以及变数 number 为栏位数目减掉 2 (也就是 NF – FName – LName )。

3. 然后,平均值就是 total 除以 number 。

4. 因为档案中的第一行是不能用来运算的,而且还必须再加上一个叫 AVERAGE 的栏位标头,所以这里首先用一个 if 来判断行号是否少于 2 (不过,我在测试的时候,发现不能用 = 1 来设定,我也不知道为什么?),然则,用 printf 命令(注意:在 print 后面有一个 f 字母) ,以指定格式进行显示。这里的格式是:首先是一个字串 ( %s ),也就是后面所对应的 $0 (整行句子) 以字串格式显示;然后是一个 tab 键 ( \t );再下来又是一个字串,也就后面的 "AVERAGE" (非变数值必须用 " " 括起来);最后输入一个断行符号 ( \n ,newline 的意思)。这里,您会发现,凡是用 % 表示的格式,必须依顺序对应到后面的显示栏位;而用 \ 开头的,则是可以从键盘输入的符号。(或许,刚开始可能比较难看出个所以然,多比较一下,就不难发现它的规则啦。后面还有一个范例。)

5. 接下来的,会先用 if 判断行号是否大于或等于 2 (您也可以用 > 1 ,也就是从第二行开始),然则,再用 printf 命令,按 %s\t%3.2f\n 的格式来显示。其中的 %s、\t、\n 相信您都知道了,只有 %3.2f 没见过而已。它定义出浮点数字( floating point )的显示格式是:小数点左边 3 位数和小数点右边两位数。所以这行的格式是:先用字串显示整行、然后一个 tab 键、然后以 3.2 小数点格式显示前面定义好的 average 变数、最后是一个断行符号:

%s \t %3.2f \n
| | | |
Kenny Chen 80 80 50 70.00
| | | |
$0 average

6. 然后是 ‘{ }’ 这些括号及引号的关闭,最后是要处理的档案名称。

而每一行的输出结果,就会在字行后面按指定的格式加上 tab 键和平均值了。是否很神奇呢?!呵呵,这只是 awk 的牛刀少试而已,若要完全发挥 awk 的强大火力,恐怕已经不是我所能介绍的了。

 

« 上一篇 | 下一篇 »