Shell从入门到精通 (Linux典藏大系)
Author: 张春晓
Date: 2014-02-01
Publisher: 清华大学出版社

Linux典藏大系

Shell从入门到精通

张春晓　等编著

清 华 大 学 出 版 社
北　京

内容简介

本书是获得了大量读者好评的“Linux典藏大系”中的Shell分册。Shell脚本程序具有简洁、高效的特点，多年以来，Bash Shell成为系统管理员和Linux程序员解决实际问题的得力工具。本书结合大量的实例，向读者详细介绍了这一工具的使用方法。本书附带1张光盘，内容为本书配套多媒体教学视频及实例源程序及其他学习资料。

本书共15章，分为3篇。涵盖的主要内容包括Shell编程基础、Shell编程环境的搭建、Shell变量、条件测试和判断语句、循环结构、函数、数组、正则表达式、基本文本处理、流编辑、awk工具、文件操作、子Shell与进程处理、Shell脚本调试技术等，并在本书的第15章中给出了2个具体的综合实例。

本书结构清晰，易教易学，实例丰富，可操作性强，对易混淆和实用性强的内容进行了重点提示和讲解。因此，本书非常适合Linux系统管理和开发的相关人员阅读，也可以作为高等学校教师和相关专业学生的教材，又可以作为各类培训班的培训教材。

本书封面贴有清华大学出版社防伪标签，无标签者不得销售。

版权所有，侵权必究。侵权举报电话：010-62782989 13701121933

图书在版编目（CIP）数据

Shell从入门到精通/张春晓等编著. −−北京：清华大学出版社，2014

（Linux典藏大系）

ISBN 978-7-302-33807-9

Ⅰ. ①S… Ⅱ. ①张… Ⅲ. ①UNIX操作系统−程序设计 Ⅳ. ①TP316.81

中国版本图书馆CIP数据核字（2013）第212256号

责任编辑：夏兆彦

封面设计：欧振旭

责任校对：胡伟民

出版发行：清华大学出版社

网　　址：http://www.tup.com.cn, http://www.wqbook.com

地　　址：北京清华大学学研大厦A座

邮　　编：100084

社总机：010-62770175

邮　　购：010-62786544

投稿与读者服务：010-62776969，c-service@tup.tsinghua.edu.cn

质　量　反　馈：010-62772015，zhiliang@tup.tsinghua.edu.cn

经　　销：全国新华书店

开　　本：185mm×260mm

印　　张：24.5

字　　数：612千字

版　　次：2014年2月第1版

印　　次：2014年2月第1次印刷

印　　数：1～0000

定　　价：0.00元

产品编号：054559-01

前　言

随着互联网技术的发展，Linux已经逐步成为主流的服务器操作系统。在Linux系统中，Shell是用户与系统内核之间进行交互的接口，是整个Linux系统中非常重要的一个组成部分。Shell脚本编程成为一个优秀的系统管理员和Linux开发者必须掌握的技术之一。Bash Shell已经成为绝大部分Linux系统默认的Shell程序，本书旨在系统介绍Shell编程的相关知识和技巧。

在Shell发展的几十年中，出现了许多介绍Shell编程的书籍。但是，大多数相关书籍的内容比较陈旧，其内容并没有随着技术的发展而得到补充。另外，许多书籍要么偏重理论，缺乏实践性；要么泛泛而论，缺乏深入的阐述。本书由有着十几年实践经验的一线技术员编写，以实用性为主旨。从最基本的Shell编程环境的搭建和变量开始，一直到Shell脚本的调试技术，由浅入深，系统地介绍了Shell编程各个方面的知识点。最后通过2个具体的综合实例，向读者介绍了如何使用Shell脚本来解决实际问题。

关于“Linux典藏大系”

“Linux典藏大系”是清华大学出版社自2010年1月以来陆续推出的一个图书系列，截止2013年，已经出版了10余个品种。该系列图书涵盖了Linux技术的方方面面，可以满足各个层次和各个领域的读者学习Linux技术的需求。该系列图书自出版以来获得了广大读者的好评，已经成为Linux图书市场上最耀眼的明星品牌之一。其销量在同类图书中也名列前茅，其中一些图书还获得了“51CTO读书频道”颁发的“最受读者喜爱的原创IT技术图书奖”。该系列图书在出版过程中也得到了国内Linux领域最知名的技术社区ChinaUnix（简称CU）的大力支持和帮助，读者在CU社区中就图书的内容与活跃在CU社区中的Linux技术爱好者进行广泛交流，取得了良好的学习效果。

本书有何特色

1．视频讲解，高效学习

为了帮助读者更加高效、直观地学习，作者为本书每章的重点内容专门录制了对应的配套多媒体教学视频。这些视频和本书实例源文件一起收录于配书光盘中。

2．内容全面，系统性强

本书非常全面地讨论了Shell编程各个方面，基本上涵盖了与Shell编程有关的所有重要的知识点。

3．深入浅出，循序渐进

对于绝大部分的初学者来说，Shell编程是一件非常困难的事情。为了能够适应初学者的学习习惯，本书从最基本的基础知识开始讲起，一直到最后的Shell脚本调试技术。在介绍某个知识点的时候，本书也尽量从最简单的内容开始，逐步深入，避免使初学者产生畏惧的心理。

4．由一线技术人员编写，重实践，实用性强

本书以当前最为流行的Bash Shell为基础，针对Shell编程中最容易遇到的问题，依次展开论述。无论是初学者，还是具有一定经验的Linux开发和维护人员，都可以从中获得有用的知识。

5．重点突出，脉络清晰

对于比较重要的知识点，本书都进行了非常深入的探讨和代码演示，使读者不仅知其然，更要知其所以然，只有这样，才能达到融会贯通的境界。

6．项目案例典型，实战行强，有较高的应用价值

本书最后一篇提供了2个综合案例。这些案例来源于作者所开发的实际项目，具有很高的应用价值和参考性。而且这些案例分别使用不同的框架组合实现，便于读者融会贯通地理解本书中所介绍的技术。这些案例稍加修改，便可用于实际项目开发中。

本书内容及知识体系

第1篇　认识Shell编程（第1～2章）

本篇主要介绍了Shell入门的基础和Shell编程环境的搭建。主要包括什么是Shell、为什么要学习Shell编程、最简单的Shell程序的组成、如何在不同的操作系统上面搭建Shell编程环境，以及编辑器的选择等。

第2篇Shell编程基础（第3～13章）

本篇主要介绍了Shell编程所涉及的所有重要的知识点。主要包括Shell变量、条件测试和判断语句、循环结构、函数、数组、正则表达式、基本文本处理、流编辑、awk工具、文件操作，以及子Shell与进程处理等。

第3篇Shell编程实战（第14～15章）

本篇主要介绍了Shell脚本的调试技术及2个综合实例。主要包括Shell编程常见问题、4种常用的Shell脚本调试技术。综合实例包括系统服务脚本，以及Apache日志管理脚本的编写方法。

适合阅读本书的读者

- 需要全面学习Shell编程技术的人员；
- 广大Linux程序员；
- Linux系统管理员；
- 网站管理工程师；
- 希望提高Linux系统管理和开发水平的人员；
- 专业培训机构的学员；
- Linux下的所有编程人员；
- 需要一本案头必备查询手册的人员。

阅读本书的建议

- 没有Linux编程基础的读者，建议从第1章顺次阅读并演练每一个实例。

- 拥有一定Linux编程基础的读者，可以根据实际情况有重点地选择阅读各个章节及实例。

- 对于每一个知识点和实例，先自己思考一下实现的思路，然后再阅读，学习效果会更好。

- Shell编程非常需要大量的实际操作，并且每项功能都会有多种实现方法。所以读者可以在阅读本书知识点以及实例的基础上，对本书的实例进行改编，以其他的方式实现实例的功能。这样理解起来就更加容易，也会更加深刻。

关于作者

本书由张春晓主笔编写。其他参与编写的人员有魏星、吴宝生、伍远明、谢平、顼宇峰、徐楚辉、闫常友、阳麟、杨纪梅、杨松梅、余月、张广龙、张亮、张晓辉、张雪华、赵海波、赵伟、周成、朱森。

您在阅读本书的过程中若有疑问，请发E-mail和我们联系。E-mail地址：bookservice2008@163.com。

编著者

目录
内容简介
前言

第1篇　认识Shell编程
第1章　Shell入门基础（教学视频：66分钟）

1.1　为什么学习和使用Shell编程

1.2　什么是Shell

1.2.1　Shell的起源
1.2.2　Shell的功能
1.2.3　Shell的分类

1.3　作为程序设计语言的Shell

1.3.1　交互式程序
1.3.2　创建脚本
1.3.3　把脚本设置为可执行

1.4　向脚本传递参数

1.4.1　Shell脚本的参数

1.4.2　参数扩展

1.5　第一个Shell程序：Hello，Bash Shell！

1.5.1　Shell脚本的基本元素
1.5.2　指定命令解读器
1.5.3　Shell脚本中的注释和风格
1.5.4　如何执行Shell程序
1.5.5　Shell程序的退出状态

1.6　小结

第2章　Shell编程环境的搭建（教学视频：49分钟）

2.1　在不同的操作系统上搭建Shell编程环境

2.1.1　在Windows上搭建Shell编程环境
2.1.2　在Linux上搭建Shell编程环境
2.1.3　在FreeBSD上搭建Shell编程环境

2.2　编辑器的选择

2.2.1　图形化编辑器

2.2.2　vi（vim）编辑器

2.3　系统环境的搭建

2.3.1　Shell配置文件

2.3.2　命令别名

2.4　小结

第2篇　Shell编程基础
第3章　变量和引用（教学视频：62分钟）

3.1　深入认识变量

3.1.1　什么是变量
3.1.2　变量的命名
3.1.3　变量的类型
3.1.4　变量的定义
3.1.5　变量和引号
3.1.6　变量的作用域
3.1.7　系统变量
3.1.8　环境变量

3.2　变量赋值和清空

3.2.1　变量赋值
3.2.2　引用变量的值
3.2.3　清除变量

3.3　引用和替换

3.3.1　引用
3.3.2　全引用
3.3.3　部分引用
3.3.4　命令替换
3.3.5　转义

3.4　小结

第4章　条件测试和判断语句（教学视频：77分钟）

4.1　条件测试

4.1.1　条件测试的基本语法
4.1.2　字符串测试
4.1.3　整数测试
4.1.4　文件测试
4.1.5　逻辑操作符

4.2　条件判断语句

4.2.1　使用简单的if语句进行条件判断
4.2.2　使用if else语句进行流程控制
4.2.3　使用if elif语句进行多条件判断
4.2.4　使用exit语句退出程序

4.3　多条件判断语句case

4.3.1　多条件判断语句case的基本语法
4.3.2　利用case语句处理选项参数
4.3.3　利用case语句处理用户输入

4.4　运算符

4.4.1　算术运算符
4.4.2　位运算符
4.4.3　自增/自减运算符
4.4.4　数字常量的进制

4.5　小结

第5章　循环结构（教学视频：61分钟）

5.1　步进循环语句for

5.1.1　带列表的for循环语句
5.1.2　不带列表的for循环语句
5.1.3　类C风格的for循环语句
5.1.4　使用for循环语句处理数组

5.2　until循环语句

5.2.1　until语句的基本语法

5.2.2　利用until语句批量增加用户

5.3　while循环语句

5.3.1　while语句的基本语法
5.3.2　通过计数器控制while循环结构
5.3.3　通过结束标记控制while循环结构
5.3.4　理解while语句与until语句的区别

5.4　嵌套循环

5.5　利用break和continue语句控制循环

5.5.1　利用break语句控制循环
5.5.2　利用continue语句控制循环
5.5.3　分析break语句和continue语句的区别

5.6　小结

第6章　函数（教学视频：60分钟）

6.1　函数

6.1.1　什么是函数
6.1.2　函数的定义
6.1.3　函数的调用
6.1.4　函数链接
6.1.5　函数的返回值
6.1.6　函数和别名
6.1.7　再议全局变量和局部变量

6.2　函数参数

6.2.1　含有参数的函数的调用方法
6.2.2　获取函数参数的个数
6.2.3　通过位置变量接收参数值
6.2.4　移动位置参数
6.2.5　通过getopts接收函数参数
6.2.6　间接参数传递
6.2.7　通过全局变量传递数据
6.2.8　传递数组参数

6.3　函数库文件

6.3.1　函数库文件的定义

6.3.2　函数库文件的调用

6.4　递归函数

6.5　小结

第7章　数组（教学视频：52分钟）

7.1　定义数组

7.1.1　通过指定元素值来定义数组
7.1.2　通过declare语句定义数组
7.1.3　通过元素值集合定义数组
7.1.4　通过键值对定义数组
7.1.5　数组和普通变量

7.2　数组的赋值

7.2.1　按索引为元素赋值
7.2.2　通过集合为数组赋值
7.2.3　在数组末尾追加新元素
7.2.4　通过循环为数组元素赋值

7.3　访问数组

7.3.1　访问第1个数组元素
7.3.2　通过下标访问数组元素
7.3.3　计算数组的长度
7.3.4　通过循环遍历数组元素
7.3.5　引用所有的数组元素
7.3.6　以切片方式获取部分数组元素
7.3.7　数组元素的替换

7.4　删除数组

7.4.1　删除指定数组元素

7.4.2　删除整个数组

7.5　数组的其他操作

7.5.1　复制数组
7.5.2　连接数组
7.5.3　加载文件内容到数组

7.6　小结

第8章　正则表达式（教学视频：57分钟）

8.1　什么是正则表达式

8.1.1　为什么使用正则表达式
8.1.2　如何学习正则表达式
8.1.3　如何实践正则表达式

8.2　正则表达式基础

8.2.1　正则表达式的原理
8.2.2　基本正则表达式
8.2.3　扩展正则表达式
8.2.4　Perl正则表达式
8.2.5　正则表达式字符集

8.3　正则表达式应用

8.3.1　匹配单个字符
8.3.2　匹配多个字符
8.3.3　匹配字符串的开头或者结尾
8.3.4　运算符优先级
8.3.5　子表达式
8.3.6　通配符

8.4　grep命令

8.4.1　grep命令的基本语法

8.4.2　grep命令族简介

8.5　小结

第9章　基本文本处理（教学视频：100分钟）

9.1　使用echo命令输出文本

9.1.1　显示普通字符串
9.1.2　显示转义字符
9.1.3　显示变量
9.1.4　换行和不换行
9.1.5　显示命令执行结果
9.1.6　echo命令执行结果的重定向

9.2　文本的格式化输出

9.2.1　使用UNIX制表符
9.2.2　使用fold命令格式化行
9.2.3　使用fmt命令格式化段落
9.2.4　使用rev命令反转字符顺序
9.2.5　使用pr命令格式化文本页

9.3　使用sort命令对文本排序

9.3.1　sort命令的基本用法
9.3.2　使用单个关键字排序
9.3.3　根据指定的列排序
9.3.4　根据关键字降序排序
9.3.5　数值列的排序
9.3.6　自定义列分隔符
9.3.7　删除重复的行
9.3.8　根据多个关键字排序
9.3.9　使用sort命令合并文件

9.4　文本的统计

9.4.1　输出含有行号的文本行
9.4.2　统计行数
9.4.3　统计单词数和字符数

9.5　使用cut命令选取文本列

9.5.1　cut命令及其语法
9.5.2　选择指定的文本列
9.5.3　选择指定数量的字符
9.5.4　排除不包含列分隔符的行

9.6　使用paste命令拼接文本列

9.6.1　paste命令及其语法
9.6.2　自定义列分隔符
9.6.3　拼接指定的文本列

9.7　使用join命令联接文本列

9.7.1　join命令及其语法
9.7.2　指定联接关键字列
9.7.3　内联接文本文件
9.7.4　左联接文本文件
9.7.5　右联接文本文件
9.7.6　全联接文本文件
9.7.7　自定义输出列

9.8　使用tr命令替换文件内容

9.8.1　tr命令及其语法
9.8.2　去除重复出现的字符
9.8.3　删除空行
9.8.4　大小写转换
9.8.5　删除指定字符

9.9　小结

第10章　流编辑（教学视频：52分钟）

10.1　sed命令及其语法

10.1.1　sed命令以及语法
10.1.2　sed命令的工作方式
10.1.3　使用行号定位文本行
10.1.4　使用正则表达式定位文本行

10.2　sed命令的常用操作

10.2.1　sed编辑命令基本语法
10.2.2　选择文本
10.2.3　替换文本
10.2.4　删除文本
10.2.5　追加文本
10.2.6　插入文本

10.3　组合命令

10.3.1　使用-e选项执行多个子命令
10.3.2　使用分号执行多个子命令
10.3.3　对一个地址使用多个子命令
10.3.4　sed脚本文件

10.4　小结

第11章　文本处理利器awk命令（教学视频：78分钟）

11.1　awk入门

11.1.1　awk的功能
11.1.2　awk命令的基本语法
11.1.3　awk的工作流程
11.1.4　执行awk程序的几种方式

11.2　awk的模式匹配

11.2.1　关系表达式
11.2.2　正则表达式
11.2.3　混合模式
11.2.4　区间模式
11.2.5　BEGIN模式
11.2.6　END模式

11.3　变量

11.3.1　变量的定义和引用
11.3.2　系统内置变量
11.3.3　记录分隔符和字段分隔符
11.3.4　记录和字段的引用

11.4　运算符和表达式

11.4.1　算术运算符
11.4.2　赋值运算符
11.4.3　条件运算符
11.4.4　逻辑运算符
11.4.5　关系运算符
11.4.6　其他运算符

11.5　函数

11.5.1　字符串函数

11.5.2　算术函数

11.6　数组

11.6.1　数组的定义和赋值

11.6.2　遍历数组

11.7　流程控制

11.7.1　if语句
11.7.2　while语句
11.7.3　do…while语句
11.7.4　for语句
11.7.5　break语句
11.7.6　continue语句
11.7.7　next语句
11.7.8　exit语句

11.8　awk程序的格式化输出

11.8.1　基本print语句
11.8.2　格式化输出printf语句
11.8.3　使用sprintf()函数生成格式化字符串

11.9　awk的程序与Shell的交互

11.9.1　通过管道实现与Shell的交换

11.9.2　通过system函数实现与Shell的交互

11.10　小结

第12章　文件的操作（教学视频：74分钟）

12.1　文件

12.1.1　列出文件
12.1.2　文件类型
12.1.3　文件的权限

12.2　查找文件

12.2.1　find命令以及语法
12.2.2　find命令：路径
12.2.3　find命令：测试
12.2.4　find命令：使用!运算符对测试求反
12.2.5　find命令：处理文件权限错误信息
12.2.6　find命令：动作

12.3　比较文件

12.3.1　使用comm比较文件

12.3.2　使用diff比较文件

12.4　文件描述符

12.4.1　什么是文件描述符

12.4.2　标准输入、标准输出和标准错误

12.5　重定向

12.5.1　输出重定向（覆盖）
12.5.2　输出重定向（追加）
12.5.3　输入重定向
12.5.4　当前文档
12.5.5　重定向两个文件描述符
12.5.6　使用exec命令分配文件描述符

12.6　小结

第13章　子Shell与进程处理（教学视频：49分钟）

13.1　子Shell

13.1.1　什么是子Shell
13.1.2　内部命令、保留字和外部命令
13.1.3　在子Shell中执行命令
13.1.4　把子Shell中的变量值传回父Shell

13.2　进程处理

13.2.1　什么是进程
13.2.2　通过脚本监控进程
13.2.3　作业控制
13.2.4　信号与trap命令

13.3　小结

第3篇　Shell编程实战
第14章　Shell脚本调试技术（教学视频：33分钟）

14.1　Shell脚本中的常见错误

14.1.1　常见语法错误

14.1.2　常见逻辑错误

14.2　Shell脚本调试技术

14.2.1　使用echo命令调试脚本
14.2.2　使用trap命令调试Shell脚本
14.2.3　使用tee命令调试Shell脚本
14.2.4　使用调试钩子调试Shell脚本

14.3　小结

第15章　利用Shell脚本解决实际问题（教学视频：33分钟）

15.1　编写系统服务脚本

15.1.1　系统启动过程
15.1.2　运行级别
15.1.3　服务脚本的基本语法
15.1.4　编写MySQL服务脚本

15.2　通过脚本管理Apache服务器日志

15.2.1　Apache以及日志文件简介
15.2.2　备份归档文件名生成函数
15.2.3　过期日志备份归档函数
15.2.4　过期日志删除函数
15.2.5　日志归档主程序
15.2.6　定时运行日志归档脚本

15.3　小结

附录CD

第1篇
认识Shell编程

第1章
Shell入门基础

随着Linux和UNIX的广泛应用，Shell日益成为系统管理员的一个非常重要的工具。作为一个优秀的系统管理员或者Linux/UNIX开发者来说，熟练掌握Shell程序设计可以使得工作达到事半功倍的效果。

本章从最基本的Shell概念入手，依次介绍了作为程序设计语言的Shell、向Shell脚本传递参数，以及通过一个最简单的例子来说明如何进行Shell程序设计。

本章主要涉及的知识点如下所述。

- 为什么学习和使用Shell编程：主要介绍Shell在日常管理工作中的重要作用。

- 什么是Shell：主要介绍Shell的基本概念、起源、功能和分类等。

- 作为程序设计语言的Shell：主要介绍什么是交互式程序、如何创建脚本，以及如何将脚本设置为可执行。

- 向脚本传递参数：主要介绍什么是脚本参数，以及脚本参数的用途等。

- 第一个Shell程序：通过一个简单的例子来向读者介绍Shell脚本的基本元素、注释和风格、如何执行Shell程序，以及Shell程序的退出状态。

1.1　为什么学习和使用Shell编程

对于一个合格的系统管理员来说，学习和掌握Shell编程是非常重要的。通过编程，可以在很大程度上简化日常的维护工作，使得管理员从简单的重复劳动中解脱出来。作为本书的第一节，将介绍学习和使用Shell编程的重要性。

作为程序设计语言来说，Shell是一种脚本语言。脚本语言是相对于编译型语言而言的，前者毋需编译，而是由解释器读取程序代码并且执行其中的语句；后者则是预先编译成可执行代码，在使用的时候可以直接执行。

脚本语言的优点在于简单易学，因此，任何人在了解了基本的知识之后都可以毫不费力地编写出一个简单的脚本。关于这一点，在本章的最后一节中将通过一个简单的例子来说明。然而，Shell尽管非常容易上手，但是如果想真正精通Shell编程却不是一件容易的事情。这是因为Shell的语法非常灵活，又涉及Shell中的许多命令。想要真正透彻地了解Shell程序设计，必须下一番功夫才可以。

1.2　什么是Shell

在学习Shell编程之前，必须弄清楚什么是Shell。为了能够使读者在学习具体的Shell编程之前对Shell有个基本的了解，本节将对Shell进行概括性的介绍，包括Shell的起源、功能和分类。

1.2.1　Shell的起源

Shell的起源与计算机世界里面最古老的操作系统UNIX有着密不可分的关系。在1964年，美国AT&T公司的贝尔实验室、麻省理工学院及美国通用电气公司共同参与开始研发一套可以安装在大型主机上的多用户、多任务的操作系统，该操作系统的名称为Multics （MULTiplexed Information and Computing System），运行在美国通用电气公司的大型机GE-645上面。但是由于整个目标过于庞大，糅合了太多的特性，Multics虽然发布了一些产品，但是性能都很低，最终以失败告终。1969年，AT&T最终退出了Multics的开发。但是，该公司其中一位开发者，肯·汤普逊（Kenneth Lane Thompson）则继续为GE-645开发软件。

大约在1970年，另外一位开发者丹尼斯·里奇（Dennis MacAlistair Ritchie）也加入了汤普逊的开发队伍，如图1-1所示。在汤普逊和里奇的组织和领导下，他们启动了另外一个新的多用户、多任务的操作系统的项目，他们把这个项目称之为UNICS（UNiplexed Information and Computing System）。后来，人们取这个单词的谐音，把这个项目称为UNIX。

最初的UNIX完全采用汇编语言编写，因此可移植性非常差。为了提高系统的可移植性和开发效率，汤普逊和里奇于1973年使用C语言重新编写了UNIX。通过这次编写，使得UNIX得以移植到其他小型机上面。

与此同时，第一个重要的标准UNIX Shell于1979年末在UNIX的第7版中推出，并以作者史蒂夫•伯恩（Stephen Bourne）的名字命名，叫做Bourne Shell，简称为sh。Bourne Shell当时主要用于系统管理任务的自动化。此后，Bourne Shell凭借其简单和高效而广受欢迎，很快就成为流行的Shell。虽然Bourne Shell广受欢迎，却缺少一些交互的功能，如命令作业控制、历史和别名等。

而在这时，UNIX的另外一个著名分支BSD UNIX也悄然兴起，随着风头正劲的BSD，另一个老牌Shell也登场了，它就是比尔•乔伊（Bill Joy）在加州大学伯克利分校读书期间开发的C Shell。C Shell开发于二十世纪七十年代末，作为BSD UNIX系统的一部分发布，简称csh。乔伊是美国SUN公司的创始人之一，当年在伯克利分校时主持开发了最早版本的BSD，如图1-2所示。

图1-1　汤普逊和里奇

图1-2　比尔·乔伊

C Shell基于C语言，作为编程语言使用时，语法也类似于C，所以程序员可能会很喜欢它。此外，C Shell还提供了增强交互使用的功能，如作业控制、命令行历史和别名等。当然，C Shell的缺点和其优点一样明显，由于它是为大型机设计并增加了很多新功能，所以，C Shell在小型机上运行比较慢。更为麻烦的是，即使在大型机上，C Shell的速度也不如Bourne Shell，而这个问题，在当时的硬件条件下可以说是致命弱点了。

C Shell之后又出现了许多其他的Shell程序，主要包括Tenex C Shell（tcsh）、Korn Shell （ksh），以及GNU Bourne-Again shell（bash），关于这些Shell的特点，不再详细介绍。

说明：目前，无论是在UNIX系统中，还是在Linux系统中，比较流行的Shell程序都是bash。

1.2.2　Shell的功能

Shell这个单词的意思是“外壳”，它形象地表达出了Shell的作用。在UNIX以及Linux中，Shell就是套在内核外面的一层外壳，如图1-3所示。正因为有Shell的存在，才向普通的用户隐藏了许多关于系统内核的细节。

图1-3　UNIX/Linux Shell

Shell又称命令解释器，它能识别用户输入的各种命令，并传递给操作系统。它的作用类似于Windows操作系统中的命令行，但是，Shell的功能远比命令行强大得多。在UNIX或者Linux中， Shell既是用户交互的界面，也是控制系统的脚本语言。

1.2.3　Shell的分类

关于Shell的分类，在介绍Shell的历史的时候已经简单地介绍过一些了，下面对各种Shell程序做一个简单的概括。常见的几种Shell程序如下所述。

- Bourne Shell：标识为sh，该Shell由Steve Bourne在贝尔实验室时编写。在许多UNIX系统中，该Shell是root用户的默认的Shell。

- Bourne-Again Shell：标识为bash，该Shell由Brian Fox在1987年编写，是绝大多数Linux发行版的默认的Shell。

- Korn Shell：标识为ksh，该Shell由贝尔实验室的David Korn在二十世纪八十年代早期编写。它完全向上兼容Bourne Shell并包含了C Shell的很多特性。

- C Shell：标识为csh，该Shell由Bill Joy在BSD系统上开发。由于其语法类似于C语言，因此称为C Shell。

对于这些Shell程序，其语法或多或少都有所区别。目前大部分人仍然建议使用标准的Bourne-Again Shell。

1.3　作为程序设计语言的Shell

Shell不仅仅是充当用户与UNIX或者Linux交互界面的角色，还可以作为一种程序设计语言来使用。通过Shell编程，可以实现许多非常实用的功能，提高系统管理的自动化水平。本节将介绍作为程序设计语言的Shell的一些特性。

1.3.1　交互式程序

现在读者已经对Shell有了初步的了解，接下来的内容将要逐步接触到真正的Shell脚本程序了。通常情况下，Shell脚本程序有以下两种执行方式：

（1）用户可以依次输入一系列的命令，交互式地执行它们；

（2）用户也可以把所有的这些命令按照顺序保存在一个文件中，然后将该文件作为一个程序来执行。

下面我们首先介绍交互式执行Shell程序。

在命令行上直接输入命令来交互式地执行Shell脚本是一种非常简单的方式。尤其是在测试Shell程序的时候，通过使用交互式方式，可以非常方便地得到程序执行的结果。

【例1-1】该例子的作用是在当前目录中查找文件名包含“xml”这3个字符的文件。如果找到的话，则在当前屏幕上打印出来。我们可以在Shell提示符后面依次输入下面的代码：

01 #-----------------------------/chapter1/ex1-1.sh------------------
02 [root@linux ~]# for filename in 'ls .'
03 > do
04 > if echo "$filename" | grep "xml"
05 > then
06 > echo "$filename"
07 > fi
08 > done
09 package.xml
10 package.xml
11 wbxml-1.0.3
12 wbxml-1.0.3
13 wbxml-1.0.3.tgz
14 wbxml-1.0.3.tgz

每当输入完一行后，都要按回车键换行。当我们输入完第8行之后，Shell开始执行输入的代码，第9行～第14行是Shell程序的输出结果。从结果中可以得知，当前目录中有6个文件的文件名包含“xml”这3个字符。

注意：当Shell期待用户下一步的输入时，正常的Shell提示符“#”将会改变为“>”。用户可以一直输入下去，由Shell来判断何时输入完毕并立即执行程序。

关于上面的程序所涉及的语法，我们将在后面的内容中依次介绍。在此读者只要掌握交互式执行程序的方法即可。

尽管上面的执行方法非常方便快捷，但是，如果每次在执行同一个程序的时候都要重新输入一次将会非常麻烦。此外，如果对程序不是很清楚的情况下，则容易发生输入错误，导致程序不能执行。因此，在实际开发中，这种交互式执行程序的方式并不常见。而是采用将这些语句写入一个脚本文件作为一个程序来执行。

1.3.2　创建脚本

对于一组需要经常重复执行的Shell语句来说，将它们保存在一个文件中来执行是一种非常明智的做法。我们通常称这种包含多个Shell语句的文件为Shell脚本，或者Shell脚本文件。脚本文件都是普通的文本文件，可以使用任何的文本编辑器查看或者修改Shell脚本文件。

【例1-2】使用vi命令创建Shell脚本文件。在Shell命令行中输入vi命令，然后输入以下代码：

01 #-----------------------------/chapter1/ex1-2.sh------------------
02 #! /bin/sh
03
04 #for 循环开始
05 for filename in 'ls .'
06 do
07 # 如果文件名包含 xml
08 if echo "$filename" | grep "xml"
09 then
10 # 输出文件名
11 echo "$filename"
12 fi
13 done

从上面的代码可以得知，Shell程序中的注释以“#”符号开始，一直持续到该行的结束。请注意第一行#!/bin/sh，它是一种特殊形式的注释，其中，“#!”字符告诉系统同一行中紧跟在它后面的那个参数是用来执行本文件的程序。在这个例子中，/bin/sh是默认的Shell程序。

1.3.3　把脚本设置为可执行

当将脚本编辑完成之后，这个脚本还不能马上执行。在Linux中，当用户执行某个程序时，必须拥有该文件的执行权限。用户可以通过ls-l或者ll命令来查看文件的访问权限，其中ll命令只可以在Linux中使用。下面是ll命令的执行结果：

[root@linux chapter1]# ll
total 4
-rw-r--r-- 1 root root 116 Dec 18 23:11 ex1-2.sh

在上面的输出结果中，每一行都是一个文件的描述信息。一共包括6列，其中第一列就是文件的访问权限。通常情况下，每个文件的访问权限都由9位组成，其中最前面的3位表示文件的所有者对于该文件的访问权限，中间的3位表示与所有者同组的其他用户对于该文件的访问权限，最后3位表示其他组的用户对于该文件的访问权限。

在每一组权限中，都用3个字母来表示3种不同的权限，r表示读取权限，w表示写入权限，x表示执行权限。

我们可以发现，在上面的ex1-2.sh文件的权限描述中，任何用户都没有该文件的执行权限。因此，该文件无法直接执行。

为了使得用户拥有某个文件的执行权限，我们可以使用chmod命令。该命令的基本语法如下：

chmod [options] filename

其中，options表示各种权限选项。用户可以使用r、w及x这3个字母分别表示读取、写入和执行的权限，也可以使用数字来表示权限。在数字模式下，4表示读取权限，2表示写入权限，1表示执行权限。另外，用户还可以指定执行权限授予的对象，其中u表示文件的所有者，g表示所有者所属的组，o表示其他组的用户。在授予权限时，操作符加号“+”表示授予权限，减号“-”表示收回权限。

例如，下面的操作授予文件ex1-2.sh的所有者执行权限：

[root@linux chapter1]# chmod u+x ex1-2.sh
[root@linux chapter1]# ll
total 4
-rwxr--r-- 1 root root 116 Dec 18 23:11 ex1-2.sh

对于上面的权限，用户也可以使用数字来表示，如下：

[root@linux chapter1]# chmod 744 ex1-2.sh
[root@linux chapter1]# ll
total 4
-rwxr--r-- 1 root root 116 Dec 18 23:11 ex1-2.sh

在上面的命令中，作为选项的3个数字“744”分别表示文件所有者、所有者所属的用户组以及其他组的权限。其中7是由4、2和1这3个数字相加而得，4表示读取权限。

当授予用户执行权限之后，就可以执行该脚本了，如下：

[root@linux ~]# chapter1/ex1-1.sh
package.xml
package.xml
wbxml-1.0.3
wbxml-1.0.3
wbxml-1.0.3.tgz
wbxml-1.0.3.tgz

注意：777是一个特殊的权限，表示所有的用户都可以读、写和执行该文件。许多用户为了操作上的方便，会直接将该权限授予某些文件。通常情况下，这样的操作会带来安全隐患，因此，在将该权限授予用户时，一定要谨慎。

1.4　向脚本传递参数

许多情况下，Shell脚本都需要接收用户的输入，根据用户输入的参数来执行不同的操作。本节将介绍Shell脚本的参数，以及如何在脚本中接收参数。

1.4.1　Shell脚本的参数

从命令行传递给Shell脚本的参数又称为位置参数，这主要是因为Shell脚本会根据参数的位置来接收它们的值。在Shell脚本内部，用户可以通过一系列的系统变量来获取参数。这些变量的名称都是固定的，并且非常简单，只用1个字符表示，例如$0表示当前执行的脚本名称，$1表示传递给脚本的第1个参数等。表1-1列出了常用的与参数传递有关的系统变量。

表1-1　常用的与参数传递有关的系统变量

变量名 | 说　明
$n | 表示传递给脚本的第n个参数，例如$1表示第1个参数，$2表示第2个参数……
$# | 命令行参数的个数
$0 | 当前脚本的名称
$* | 以“参数1参数2参数3……”的形式返回所有参数的值
$@ | 以“参数1”“参数2”“参数3”……的形式返回所有参数的值
$_ | 保存之前执行的命令的最后一个参数

通过表1-1，可以得知，Shell的位置参数按照0，1，2……的顺序从0开始编号。其中，0表示当前执行的脚本的名称，而1表示第1个参数。由单引号或者双引号引起来的字符串作为一个参数进行传递，传递时会去掉引号。

注意：对于包含空白字符或者其他的特殊字符的参数，需要使用单引号或者双引号进行传递。

变量$@可以以“参数1”“参数2”“参数3”……的形式返回所有的参数的值，因此，$@与“$1”“$2”“$3”……是等价的。如果用户传递的参数中包含空格或者其他的特殊字符，需要使用$@来获取所有的参数的值，不能使用$*。

变量$*以“参数1参数2参数3……”的形式将所有的参数作为一个字符串返回。通常情况下，参数值之间通过空格、制表符或者换行符来隔开，在默认情况下使用空格。

变量$#返回传递给脚本的参数的数量，不包括$0，即排除脚本的名称。

另外，如果用户传递的参数多于9个，则不能使用$10来引用第10个参数。为了能够获取第10个参数的值，用户必须处理或保存第1个参数，即$1，然后使用shift命令删除参数1并将所有剩余的参数下移1位，此时$10就变成了$9，依此类推。$#的值将被更新以反映参数的剩余数量。

【例1-3】传递脚本参数。代码如下：

01 #-----------------------------/chapter1/ex1-3.sh------------------
02 #! /bin/bash
03
04 echo "$# parameters"
05 echo "$@"

然后通过以下方式来执行：

[root@linux chapter1]# ./ex1-3.sh a "b c"
2 parameters
a b c

在上面的代码中，向ex1-3.sh脚本传递了2个参数，其中第2个参数含有空格，所以需要使用双引号引起来。

1.4.2　参数扩展

在上面的小节中，已经介绍了通过系统变量来获取脚本参数的值。对于简单的脚本而言，这已经足够了。因为用户完全可以通过变量$1、$2……来依次获得全部参数，还可以通过$#获得参数的个数。但是，在实践中，用户遇到的并不总是这种简单的情况。例如，用户需要编写一个脚本程序，并且这个脚本程序需要一个拥有许多值的参数，在程序中，用户希望根据这个参数的值来执行不同的操作。在这种情况下，单纯地依靠$1以及$2等变量已经不能满足需求了。此时，用户可以考虑使用参数扩展。

如果接触过UNIX或者Linux，那么用户已经对于ls命令不会陌生。ls命令可能是UNIX或者Linux系统中选项最多的命令了。例如，用户可以使用-l选项来以长格式的方式显示当前目录的内容。实际上，这个-l也是ls命令的一个参数。这个参数与前面介绍的参数的不同之处在于它拥有一个前导的连字符“-”。

用户可以在Shell脚本中使用同样的技术，这称为参数扩展。为了获取到这些参数的值，用户需要在Shell程序中使用getopts命令。

【例1-4】介绍如何使用参数扩展，代码如下。

01 #-----------------------------/chapter1/ex1-4.sh------------------
02 #!/bin/bash
03
04 # 输出参数索引
05 echo "OPTIND starts at $OPTIND"
06 # 接收参数
07 while getopts ":pq:" optname
08 do
09 case "$optname" in
10 "p")
11 echo "Option $optname is specified"
12 ;;
13 "q")
14 echo "Option $optname has value $OPTARG"
15 ;;
16 "?")
17 echo "Unknown option $OPTARG"
18 ;;
19 ":")
20 echo "No argument value for option $OPTARG"
21 ;;
22 *)
23 # Should not occur
24 echo "Unknown error while processing options"
25 ;;
26 esac
27 echo "OPTIND is now $OPTIND"
28 done

对于上面的代码，我们不做过多的介绍，此处只是了解如何使用参数扩展。在代码的第7行中，getopts命令后面的双引号中的第一个冒号告诉getopts命令忽略一般的错误消息，因为此脚本将提供它自己的错误处理。p和q则是两个选项名称。选项后面的冒号表示该选项需要一个值。例如，在绝大部分的命令中，-f选项都可能需要一个文件名。

当找到某个选项时，getopts命令返回true。第二个参数是变量名optname，该变量将接收找到选项的名称。以上程序的执行结果如下：

[root@linux chapter1]# ./ex1-4.sh -p
OPTIND starts at 1
Option p is specified
OPTIND is now 2
[root@linux chapter1]# ./ex1-4.sh -q
OPTIND starts at 1
No argument value for option q
OPTIND is now 2
[root@linux chapter1]# ./ex1-4.sh -f
OPTIND starts at 1
Unknown option f
OPTIND is now 2

1.5　第一个Shell程序：Hello，Bash Shell！

通过前面几节的学习，读者已经接触到一些Shell程序了。在本节中，将介绍一个完整的Shell程序，使得读者能够掌握Shell程序的组成元素，并写出简单的程序。

1.5.1　Shell脚本的基本元素

在学习任何程序设计语言的时候，似乎那个经典的Hello world!都是一个必不可少的例子。接下来，我们介绍如何在Shell语言中实现这个例子。

【例1-5】输出Hello world!，代码如下。

01 #-----------------------------/chapter1/ex1-5.sh------------------
02 #! /bin/bash
03
04 # 输出字符串
05 echo "Hello, Bash Shell!"

这是一个完整的Shell程序，对于拥有执行权限的用户来说，这也是一个可执行的Shell程序。上面的代码非常简单，实际上最主要的只有一行，即第5行，这一行的作用只是在控制台上面输出一行消息。

接下来执行一下这个程序，看看到底会出现什么结果，命令如下：

[root@linux chapter1]# chmod +x ex1-5.sh
[root@linux chapter1]# ./ex1-5.sh
Hello, Bash Shell!

从上面的执行结果可以得知，这个程序已经得到了预期的结果。但是，读者可能会有疑问，作为一个Shell程序，应该具备哪些元素呢？分析上面的例子，可以得知，一个最基本的Shell程序，应该拥有第2行的：

#! /bin/bash

关于这一行的作用，将在下面的内容中详细介绍。另外，第4行是一行注释，用来说明下面的代码的功能。第5行是echo语句，这一行是实现整个程序功能的代码。

所以，对于一个基本的Shell程序来说，应该拥有以下基本元素。

- 第2行的“#!/bin/bash”。

- 注释：说明某些代码的功能。

- 可执行语句：实现程序的功能。

在接下来的内容中，将依次介绍这些基本的元素。

1.5.2　指定命令解读器

当用户在命令行中执行一个脚本程序的时候，Shell会首先判断用户是否拥有该程序的执行权限。如果没有执行权限的话，Shell给出“Permission denied”的提示；否则，Shell会创建一个新的进程，解释并执行Shell程序中的语句。

但是，无论是在UNIX，还是在Linux中，通常会同时安装多个Shell程序，例如sh、bash或者csh等。而这些不同的Shell程序的语法会有些区别，那么到底使用哪个Shell来执行代码呢？

实际上，【例1-5】中的第2行的作用正是告诉当前的Shell，应该调用哪个Shell来执行当前的程序。我们再来回顾一下【例1-5】的第2行代码：

#! /bin/bash

当用户在命令行中执行该程序时，当前的Shell会载入该程序的代码，并且读取其中的第2行，如果发现有“#!”标识，则表示当前的程序指定了解释并执行它的Shell。然后会尝试读取“#!”标识后面的内容，搜寻解释器的绝对路径。如果发现了指定的解释器，则会创建一个关于该解释器的进程，解释并执行当前脚本的语句。在【例1-5】中，当前的Shell会创建/bin/bash的进程，执行ex1-5.sh脚本文件中的语句。

注意：用户应该在“#!”标识后面指定解释器的绝对路径。

Shell脚本的这个规定使得用户可以非常灵活地调用任何解释器，而不仅仅限于Shell程序。下面介绍如何在脚本文件中指定其他解释器程序。

【例1-6】在PHP脚本文件中指定PHP语言的解释器，然后执行文件中的PHP代码，代码如下：

01 #-----------------------------/chapter1/ex1-6.php------------------
02 # 指定解释器
03 #! /usr/local/php5/bin/php
04
05 <?php
06 // 输出 Hello world! 字符串
07 print "Hello world!";
08 ?>

在上面的代码中，第3行指定解释当前文件的解释器的绝对路径，第5行是PHP代码的开始标识符，第7行使用print语句输出字符串“Hello world!”，第8行是PHP代码的结束标识符。

该程序的执行结果如下：

[root@linux chapter1]# ./ex1-6.php
Hello world!

用户还可以指定其他一些命令，例如more或者cat来显示当前程序的代码，请参见下面的例子。

【例1-7】指定more命令作为脚本文件的解释器，代码如下：

01 #-----------------------------/chapter1/ex1-7.sh------------------
02 # 指定解释器
03 #! /bin/more
04
05 # 输出语句
06 echo "Hello world!"

读者应该想象得到，该程序的执行结果会是什么样子。没错，该程序的执行结果是调用more命令来显示当前程序的代码，而不是执行程序中的代码本身。如下：

[root@linux chapter1]# ./ex1-7.sh
#! /bin/more
echo "Hello world!"

到此为止，读者对于“#!”标识的作用有了比较深入的理解。从本质上讲，该标识的作用就是指定解释当前脚本文件的程序，至于最后的结果会是什么样子，还要看指定的程序。如果指定的是Shell或者某些程序语言的解释器，例如/usr/local/php5/bin/php，则会执行其中的代码；如果是其他一些程序，例如/bin/more，则会显示当前脚本文件的内容。

1.5.3　Shell脚本中的注释和风格

通过在代码中增加注释可以提高程序的可读性。传统的Shell只支持单行注释，其表示方法是一个井号“#”，从该符号开始一直到行尾都属于注释的内容。例如【例1-5】中的第4行：

04 #输出字符串

如果需要多行注释内容的话，则在每行注释的开头都要加上“#”，例如：

#注释1
#注释2
#注释3
...

但是这并不意味着用户只能使用单行注释。实际上，用户还可以通过其他一些变通的方法来实现多行注释，其中，最简单的方法就是使用冒号“:”配合here document，其语法如下：

:<<BLOCK
....注释内容
BLOCK

【例1-8】通过here document实现了多行注释，代码如下：

01 #-----------------------------/chapter1/ex1-8.sh------------------
02 #! /bin/sh
03
04 :<<BLOCK
05 本脚本的作用是输出一行字符串
06 作者：chunxiao
07 BLOCK
08 echo "Hello world!"

注意：一个here document就是一段带有特殊目的的代码段，它使用I/O重定向的形式将一个命令序列传递到一个交互程序或者命令中，比如ftp、cat或者ex文本编辑器。在【例1-8】中，我们是将BLOCK之间的代码重定向到一个不存在的命令，从而间接地实现了多行注释。

1.5.4　如何执行Shell程序

在1.3.3节中，我们介绍了如何使得程序变得可执行。那就是修改脚本文件的访问权限。实际上，在Linux中，如果要执行某个Shell程序，用户可以通过3种方式来实现。这3种方式分别为：

- 授予用户执行该脚本文件的权限，使得该程序能够直接执行。

- 通过调用Shell脚本解释器来执行。

- 通过source命令来执行。

关于第一种方法，前面已经详细介绍过了，不再重复说明。第二种方式就是将脚本文件作为参数传递给解释器，在通过这种方式执行脚本的时候，不需要用户拥有执行该脚本文件的权限，只要拥有读取该文件的权限即可。

对于【例1-5】，用户可以使用以下方式来执行：

[root@linux chapter1]# /bin/bash ex1-5.sh
Hello, Bash Shell!

在上面的命令中，/bin/bash是bash Shell的绝对路径。用户首先调用bash，然后bash会载入ex1-5.sh，并且解释其中的语句，最后给出程序的执行结果。

因此，对于第二种方式，用户首先调用的是解释器，然后由解释器解释脚本文件。而第一种方式则是直接在脚本文件中指定解释器，当前的Shell会自动调用指定的解释器，然后创建进程再执行脚本文件。所以，第一种方式和第二种方式在本质上是一样的。

source命令是一个Shell内部命令，其功能是读取指定的Shell程序文件，并且依次执行其中所有的语句。该命令与前面两种方式的区别在于只是简单地读取脚本里面的语句，并且依次在当前的Shell里面执行，并没有创建新的子Shell进程。脚本里面所创建的变量都会保存到当前的Shell里面。

注意：由于source命令是在当前的Shell中执行脚本文件，因此其执行结果可能会与前面两种方式不同。

例如，【例1-5】也可以使用以下方式执行：

[root@linux chapter1]# source ex1-5.sh
Hello, Bash Shell!

1.5.5　Shell程序的退出状态

在UNIX或者Linux中，每个命令都会返回一个退出状态码。退出状态码是一个整数，其有效范围为0～255。通常情况下，成功的命令返回0，而不成功的命令返回非0值。非0值通常都被解释成一个错误码。运行良好的UNIX命令、程序和工具都会返回0作为退出码来表示成功，偶尔也会有例外。

同样，Shell脚本中的函数和脚本本身也会返回退出状态码。在脚本或者是脚本函数中执行的最后的命令会决定退出状态码。另外，用户也可以在脚本中使用exit语句将指定的退出状态码传递给Shell。

在前面的所有的例子中，我们都没有通过exit语句退出程序。在这种情况下，整个程序的退出状态码由最后执行的那一条语句来决定。例如，在下面的脚本中，整个脚本的退出状态将由statement_last这条语句的退出状态来决定。

01 #!/bin/bash
02
03 statement1
04
05 ...
06
07 # 将由最后的命令来决定退出状态码
08 statement_last

另外，在Shell中，系统变量$?保存了最后一条命令的退出状态，因此，上面的程序与下面的程序的效果是完全相同的：

01 #!/bin/bash
02
03 statement1
04
05 ...
06
07 # 将由最后的命令来决定退出状态码
08 statement_last
09 exit $?

其中，第9行的$?保存了最后一条语句statement_last的退出状态。当然，exit语句也可以不带任何参数，此时，脚本的退出状态也由最后一条语句的退出状态决定，所以，上面的程序与下面的程序的效果也是完全相同的：

01 #!/bin/bash
02
03 statement1
04
05 ...
06
07 # 将由最后的命令来决定退出状态码
08 statement_last
09 exit

程序的退出状态非常重要，它反映了脚本的执行是否成功。用户可以根据脚本的执行状态来决定下一步的操作。

【例1-9】演示在不同的情况下程序的退出状态，代码如下：

01 #-----------------------------/chapter1/ex1-9.sh------------------
02 #!/bin/sh
03
04 echo "hello world"
05 # 退出状态为 0, 因为命令执行成功
06 echo $?
07 # 无效命令
08 abc
09 # 非零的退出状态 , 因为命令执行失败
10 echo $?
11 echo
12 # 返回 120 退出状态给 shell
13 exit 120

在上面的代码中，第4行是一个正常的echo语句，因此第6行的输出结果应该是0。第8行是一个无效的命令，因此第10行会输出一个非0值，具体是什么值要看当前Shell的设置。第11行是一个正常的echo语句，同样该语句的退出状态也是0。第13行通过exit语句将退出状态码120返回给当前的Shell。

例1-9的执行结果如下：

01 [root@linux chapter1]# ./ex1-9.sh
02 hello world
03 0
04 ./ex1-9.sh: line 8: abc: command not found
05 127
06
07 [root@linux chapter1]# echo $?
08 120

在上面的执行结果中，第2行是第4行的echo语句的执行结果。第3行的0是【例1-9】中第6行的echo语句的退出状态码。第4行的错误信息是【例1-9】中第8行的无效命令给出的。第5行的127是上面的无效命令的退出状态码。由于程序已经退出，所以需要用户手动输入执行结果中的第7行命令，获取整个脚本的退出状态码，从执行结果可以得知，该脚本的退出状态码为120，这正是【例1-9】中的第13行的exit语句返回的数值。

1.6　小结

作为本书的第1章，本章主要介绍了与Shell程序设计有关的基础知识。包括为什么要学习和使用Shell程序设计、什么是Shell、作为程序设计语言的Shell有哪些特点，以及Shell脚本的参数传递问题。最后介绍了一个非常简单的例子，用来说明Shell程序的基本组成元素和退出状态。本章的重点在于掌握好Shell程序的基本组成部分，以及如何执行Shell程序。在下面一章中，将介绍Shell编程环境的搭建。

第2章
Shell编程环境的搭建

与其他的程序设计语言相比，Shell的编程环境极其简单。通常情况下，用户只需要一个文本编辑器就可以开始Shell程序设计了。当然，如果有其他一些辅助性的工具，则会使得Shell编程更加简单。本章将介绍在不同的操作系统平台上面，如何搭建Shell编程的环境，以及Linux上面文本编辑器的选择。

本章主要涉及的知识点如下所述。

- 在不同的操作系统上搭建Shell编程环境：主要介绍在Windows、Linux及BSD等常见的操作系统上面如何搭建Shell编程环境。

- 编辑器的选择：主要介绍Linux上面的图形化的文本编辑器、终端模拟器，以及非图形化的文本编辑器的使用方法。

- 系统环境的搭建：主要介绍Shell配置文件和命令别名的使用方法。

2.1　在不同的操作系统上搭建Shell编程环境

尽管Shell程序一般都是在UNIX或者Linux等操作系统上面运行的，但是作为开发者来说，他所使用的操作系统却不一定是UNIX或者Linux，完全有可能是Windows等其他的操作系统。本节将介绍在不同的操作系统上面，如何搭建Shell编程环境。

2.1.1　在Windows上搭建Shell编程环境

对于开发者来说，Windows可能是最常用的操作系统了。由于Windows有着非常人性化的图形界面，可以大大地提高开发者的开发效率。

如果想要在Windows上面进行Shell编程，则必须安装一个UNIX模拟器。通过UNIX模拟器，在Windows上面模拟出一个类似于UNIX或者Linux的Shell环境。通过上面的介绍，可以发现模拟器与虚拟机非常相似，但是这两者有着本质的区别。这是因为大部分的模拟器仅仅是在Win32系统下实现了POSIX系统调用的API，而不是一个完整的操作系统；虚拟机则是虚拟出了一台完整的机器，包括硬件。在虚拟机里面安装的是一个完整的操作系统。

尽管与真正的UNIX或者Linux相比，模拟器实现的功能极其有限，但是对于简单的Shell开发来说，使用模拟器已经可以完成大部分的功能。在众多的模拟器中，最常用的是Cygwin。

Cygwin是一个非常优秀的UNIX模拟器，最初由Cygnus Solutions公司开发，目前由Red Hat公司维护。Cygwin是许多自由软件的集合，用于在各种版本的Microsoft Windows上，创建出一个UNIX或者Linux的运行环境。Cygwin的主要目的是通过重新编译，将POSIX系统（如Linux、BSD，以及UNIX系统）上面的软件移植到Windows上。对于学习Shell程序设计的人来说，Cygwin无疑是一个非常强大的工具。

用户可以从以下网站下载Cygwin，目前最新版本是1.7.17-1：

http://www.cygwin.com/

下载完成之后，用户可以按照以下步骤进行安装。

（1）双击安装程序setup.exe，出现安装向导，如图2-1所示。

图2-1　Cygwin安装向导

（2）单击“下一步”按钮，开始选择安装类型，如图2-2所示。如果是第一次安装，用户应该选择第1项Install from Internet。选择第1项后，安装程序会从网络上自动下载Cygwin的程序，并且执行安装操作。

图2-2　选择安装类型

（3）单击“下一步”按钮，选择安装位置，如图2-3所示。如果用户使用默认的安装位置，则可以直接单击“下一步”按钮；否则，用户可以单击Browse按钮，选择想要安装到的目标位置。

图2-3　选择安装位置

（4）单击“下一步”按钮，选择安装包的存储位置，如图2-4所示。该位置的作用是用来保存安装程序从网络上下载的安装包文件。如果使用默认位置，则可以直接单击“下一步”按钮；否则，单击Browse按钮，选择其他的位置。

图2-4　选择安装包存储位置

（5）选择网络连接的类型。由于安装程序需要从网络上下载安装包文件，所以需要指定网络连接的类型，如图2-5所示。

图2-5　选择网络连接类型

提示：通常情况下，用户可以选择第一项，即直接连接。如果需要使用代理服务器，则可以选择第二项或者第三项。

（6）选择下载软件包的网站，如图2-6所示。用户可以根据自己的实际情况，来选择从哪个网站下载安装包。通常情况下，国内网站的下载速度相对较快。因此，用户可以选择第一项，即网易的镜像站点。

图2-6　选择镜像站点

（7）选择软件包。Cygwin本身是一些自由软件的集合，用户可以根据自己的需要选择安装哪些软件包，如图2-7所示。

图2-7　选择软件包

（8）开始安装。选择好软件包之后，便开始从网络上下载软件，如图2-8所示。

图2-8　安装过程

（9）当所有的软件包安装完成之后，会出现图2-9所示的对话框，单击“完成”按钮，退出安装向导。

图2-9　安装完成

当所有的安装操作都完成之后，用户可以单击桌面上的Cygwin Terminal图标，启动Cygwin模拟终端窗口，如图2-10所示。从图中可以看出，Cygwin的模拟终端窗口与真正的UNIX的终端窗口非常相似。此时，用户可以在提示符后面输入一些Shell命令。

图2-10　Cygwin模拟终端窗口

为了验证能否在Cygwin的模拟环境中执行Shell程序，接下来尝试将第1章中的“Hello, Bash Shell！”程序在刚刚安装完成的环境中执行。执行结果如下：

$ ./ex1-5.sh
Hello, Bash Shell!

从上面的执行结果可以得知，在Cygwin的模拟环境中，【例1-5】的执行结果与在Linux中的执行结果基本相同。

注意：在第（7）步中，用户选择的软件包会影响到用户在Cygwin模拟环境中可以使用的命令。所以，在选择软件包的时候，要根据自己的实际需要来选取，例如vi编辑器。如果在安装完成之后，需要添加或者删除软件包，可以重新运行安装程序。

2.1.2　在Linux上搭建Shell编程环境

由于Linux本身都会默认安装Shell脚本的运行环境，所以通常情况下，并不需要用户额外安装什么软件。但是，前面已经介绍过，在同一台Linux上面会同时安装多个Shell，并且，这些Shell的语法会有所不同，所以，用户在编写和执行Shell脚本的时候一定要弄清楚当前使用的是哪种Shell。用户可以使用系统变量$SHELL来获取当前系统默认的Shell，如下：

[root@linux ~]# echo $SHELL
/bin/bash

从上面的输出结果可以得知，当前系统默认的Shell为bash。

在前面的许多例子中，我们都是指定使用的Shell为/bin/sh。实际上，在Linux中，这是一个指向/bin/bash的符号链接，如下：

[root@linux ~]# ll /bin/sh
lrwxrwxrwx. 1 root root 4 Feb 21 2012
/bin/sh -> bash

这意味着，尽管我们在程序中指定的解释器为/bin/sh，但是实际上解释Shell脚本的是/bin/bash。

Shell作为一个软件包，当然也有版本，用户可以使用如下命令来查看bash的版本：

[root@linux ~]# echo $BASH_VERSION
4.1.2(1)-release

从上面的执行结果可以得知，当前bash的版本为4.1.2。这个版本并不是bash的最新版本，目前bash的最新版本为4.2.0。为了能够使用最新版本的bash，用户可以自己编译bash，步骤如下所述。

（1）下载bash源代码，命令如下：

[root@linux ~]# wget http://ftp.gnu.org/gnu/bash/bash-4.2.tar.gz

在上面的命令中，wget命令使用远程服务器下载文件，其参数是bash最新版本的网址。

（2）解压源代码，命令如下：

[root@linux ~]# tar zxvf bash-4.2.tar.gz

（3）配置编译环境，命令如下：

[root@linux ~]# cd bash-4.2
[root@linux bash-4.2]# ./configure

（4）测试编译。为了判断源代码是否能够编译成功，用户可以使用以下命令来测试：

[root@linux bash-4.2]# ./make test

如果以上命令没有任何错误消息，则用户可以进行源代码编译操作。

（5）编译bash，命令如下：

[root@linux bash-4.2]# make install

在默认情况下，bash将被安装到/usr/local/bin下面。

（6）查看是否安装成功。首先切换到新版本的bash的安装目录，如下：

[root@linux bash-4.2]# cd /usr/local/bin/
[root@linux bin]# ll
total 2904
-rwxr-xr-x 1 root root 2964076 Dec 30 23:19 bash
-r-xr-xr-x 1 root root 6828 Dec 30 23:19 bashbug

接下来，切换到新版本的bash，然后查看当前bash的版本，命令如下：

[root@linux bin]# ./bash
[root@linux bin]# echo $BASH_VERSION
4.2.0(1)-release

从上面的命令可以得知，当前的bash的版本已经是4.2.0了。这表示，新版本的bash已经编译成功。但是，目前用户还不能使用这个新的Shell。因为出于安全考虑，用户只能使用/etc/shells文件中列出的Shell。下面的命令列出了该文件的内容：

[root@linux etc]# more shells
/bin/sh
/bin/bash
/sbin/nologin
/bin/tcsh
/bin/csh

从上面的输出结果可以得知，用户可以使用的Shell有5个，而前两个实际上都是bash。

为了能够使用户使用这个新的Shell，我们需要将其添加到这个配置文件中。具体添加的方法有很多种，用户可以直接使用vi编辑器修改/etc/shells文件，追加一行关于新的Shell的路径信息即可，如下：

/bin/sh
/bin/bash
/sbin/nologin
/bin/tcsh
/bin/csh
/usr/local/bin/bash

2.1.3　在FreeBSD上搭建Shell编程环境

FreeBSD是UNIX两大流派中BSD流派的比较典型的一个代表，也是目前应用比较广泛的一个UNIX系统。在默认情况下，FreeBSD使用的Shell为csh，这一点，可以通过系统变量$SHELL来获得，如下：

freebsd# echo $SHELL
/bin/csh

因此，如果用户在其他的Shell环境进行程序设计的话，必须自己安装所需要的Shell。下面以bash为例来说明如何在FreeBSD上面安装其他的Shell。

用户可以通过两种方式来安装bash，其中第一种是通过软件包，这种方式是安装已经编译好的二进制文件，因此安装起来相对较快；另外一种是通过Ports，这种方式是从远程服务器下载软件包的源代码，然后在本地进行编译和安装，因此，需要花费额外的编译时间。下面将分别介绍这两种方式。

1．使用软件包的方式安装bash

如果用户想要直接安装二进制软件包，则需要使用pkg_add命令，如下：

freebsd# pkg_add -r bash

在上面的命令中，选项-r表示需要从远程服务器下载软件包，后面的bash是软件包的名称。当输入以上命令，并且按回车键之后，pkg_add命令便开始搜索远程的服务器，找到bash软件包及其所依赖的其他软件包并下载到本地。当命令执行完成之后，bash就已经可以使用了。

2．使用Ports方式安装bash

通过Ports的方式来安装bash同样非常简单。在使用Ports之前，必须确保当前的FreeBSD系统已经安装了Ports树，然后执行以下命令安装bash：

freebsd# cd /usr/ports/shell/bash
freebsd# make && make install

此时，FreeBSD的Ports管理工具会自动从远程服务器下载bash的源代码，然后编译并且安装。

前面讲过，在Linux上面安装新的Shell后，需要编辑/etc/shells文件，追加新的Shell路径。在FreeBSD中，当用户使用pkg_add命令安装新的Shell之后，则无需再编辑该文件，因为pkg_add命令会自动将新的Shell添加到该文件中。

用户唯一要做的事情就是更改登录后的默认的Shell。这个操作可以使用chsh命令来完成。chsh命令可以用来修改一些用户的信息，包括默认的Shell。在命令行中执行chsh，会自动调用vi或者vim，打开一个编辑窗口，如图2-11所示。

图2-11　chsh命令编辑窗口

然后，用户可以使用vi或者vim的相关命令来修改用户的相关选项。如果想要修改默认的Shell，则可以直接编辑其中的Shell项，将其中的/bin/csh修改为所需要的Shell，例如/usr/local/bin/bash。这样的话，当用户下次登录后就会使用新的Shell。

2.2　编辑器的选择

对于学习Shell编程的用户来说，选择一款好的编辑器是非常重要的。好的编辑器会拥有许多辅助功能，例如语法的提示和自动完成等，从而可以提高开发的效率。尽管与其他程序设计语言相比，Shell并没有专门的编辑器，但是仍然存在一些相对较好的辅助工具。本节将介绍Shell编程中经常使用的一些编辑器。

2.2.1　图形化编辑器

通常情况下，用户都是使用文本编辑器来编写Shell脚本。实际上，也有一些图形化的集成开发工具。其中，最常用的开发工具是ShellEd。ShellEd并不是一个单独的应用程序，它是作为Eclipse的插件使用的。用户可以从Eclipse的官方网站下载Eclipse的最新版本，网址如下：

http://www.eclipse.org/

目前，Eclipse的最新版本为4.2。Eclipse没有安装程序，所有的文件都放在一个压缩包中。用户下载完成之后，只要将其解压，然后执行其中的eclipse.exe文件即可。Eclipse的主界面如图2-12所示。

图2-12　Eclipse主界面

当下载完Eclipse之后，用户可以通过以下步骤来安装ShellEd。

（1）下载ShellEd。用户可以从以下网址下载最新版本的ShellEd：

http://sourceforge.net/projects/shelled/files/

目前ShellEd的最新版本为2.0.2。下载完成之后的文件名为net.sourceforge.shelled-site-2.0.2.zip。

（2）打开Eclipse，选择Help|Install New Software…命令，打开安装软件对话框，如图2-13所示。

（3）单击Add按钮，弹出添加软件源对话框，如图2-14所示。

在Name文本框中输入软件源的名称，例如shelled，单击Archive按钮，在弹出的对话框中选择刚才下载的ShellEd压缩文件。最后单击OK按钮，关闭对话框。

（4）Eclipse会自动查找压缩文件中的软件包，并且在对话框中间的列表框中列出来，如图2-15所示。

单击要安装的软件包名称前面的复选框，选中要安装的软件包，然后单击Next按钮，开始安装。

使用ShellEd开发Shell脚本的操作比较简单，步骤如下所述。

图2-13　安装软件对话框

图2-14　添加软件源对话框

图2-15　列出软件源中的软件包

（1）打开Eclipse，选择File|New|Other命令，打开新建项目向导，如图2-16所示。在向导列表中选择Shell Script Project，然后单击Next按钮，进行下一步操作。

（2）在新建项目对话框中，输入项目名称，例如demo，如图2-17所示。然后单击Finish按钮，完成项目的创建。

图2-16　新建项目对话框

图2-17　新建项目对话框

（3）在左边的项目浏览器中，右击刚刚创建的项目名称，选择New|Other命令，打开新建项目向导，如图2-18所示。选择Shell Script中的Shell Script选项，然后单击Next按钮。

（4）在Name文本框中输入脚本文件的名称，例如helloworld.sh，如图2-19所示。然后单击Finish按钮，关闭对话框。

图2-18　新建项目向导

图2-19　指定脚本文件名称

（5）在源代码编辑窗口中输入代码，如图2-20所示。

图2-20　代码编辑窗口

（6）当代码输入完成之后，右击代码窗口，选择Run As|run shell script命令。如果是第一次执行该命令，通常会出现如图2-21所示的对话框。这表示Eclipse找不到Shell脚本的解释器。单击Yes按钮，进行解释器设置。

图2-21　设置Shell脚本解释器提示对话框

（7）在出现的首选项对话框中，单击Add按钮，如图2-22所示。

图2-22　首选项对话框

（8）在出现的解释器添加窗口中，单击Browse按钮，选择Cygwin安装目录中的bin目录中的bash.exe文件，然后单击OK按钮，关闭对话框，如图2-23所示。

图2-23　选择Shell脚本解释器

此时，在解释器列表中会出现刚才选择的Bash Shell解释器，如图2-24所示。

图2-24　解释器列表

（9）再次执行Shell脚本，在Eclipse底部的Console窗口中会显示程序的执行结果，如图2-25所示。

注意：如果在执行Shell脚本的时候出现以下警告提示：

cygwin warning:
MS-DOS style path detected: D:\workspace\demo\helloworld.sh
Preferred POSIX equivalent is: /cygdrive/d/workspace/demo/helloworld.sh
CYGWIN environment variable option "nodosfilewarning" turns off this
warning.
Consult the user's guide for more details about POSIX paths:
http://cygwin.com/cygwin-ug-net/using.html#using-pathnames
D:\workspace\demo\helloworld.sh: line 2: $'\r': command not found

则需要在Windows中设置一个名称为CYGWIN的系统环境变量，其值为nodosfilewarning。

图2-25　在Eclipse中执行Shell程序

从上面的介绍中可以看到，ShellEd是一个非常好用的Eclipse插件。但是该插件本身并不提供Shell脚本的解释功能，所以需要使用前面安装的Cygwin的Bash Shell解释器来解释Shell脚本。

除了ShellEd之外，还有一些其他图形化的文本编辑器，例如Uedit32或者Notepad++等。这些编辑器都在不同的程度上支持Shell的语法提示。如图2-26所示是Uedit32的编辑界面，从图中可以得知，Uedit会自动识别Shell语句的关键字，并且以不同的颜色来显示。

图2-26　使用Uedit32编辑Shell脚本

使用Notepad++可以达到类似的效果，如图2-27所示。

注意：普通的文本编辑器，例如Uedit或者Notepad++等并没有调试功能。

2.2.2　vi（vim）编辑器

尽管图形化的编辑器可以提高Shell编程的效率，但是，用户只能在图形界面的环境中使用。如果在没有图形界面的场合要编写Shell脚本，这些编辑器便无用武之地。实际上，对于熟悉Linux或者UNIX的用户来说，很少使用图形化的编辑器。在绝大多数情况下，这些用户往往更喜欢选择非图形化的编辑器，最常用的是vi或者vim，其中vim是vi的增强版。在许多情况下，vi已经足够用了，所以下面详细介绍vi编辑器的使用方法。

图2-27　使用Notepad++编辑Shell脚本

vi编辑器是Linux上最常用的编辑器，很多Linux发行版都默认安装了vi。其中，vi这个名称是visual interface的缩写。vi拥有非常多的命令，但是正因为有非常多的命令，才使得vi的功能非常灵活和强大。在一般的Shell编程和系统维护中，vi已经完全够用了。下面详细介绍vi编辑器的使用方法，主要包括vi的使用模式、文件的打开、关闭和保存、插入文本或者新建行、移动光标、删除、恢复字符或者行，以及搜索等。

通常认为，vi有3种使用模式，分别为一般模式、编辑模式和命令模式。在每种模式下面，用户都可以分别执行不同的操作。例如，在一般模式下，用户可以进行光标位置的移动、删除字符，以及复制等；在编辑模式下，用户可以插入字符或者删除字符等；在命令模式下，用户可以保存文件或者退出编辑器等。下面将分别介绍这3种模式的使用方法。

1．一般模式

当用户刚刚进入vi编辑器的时候，当前的模式就是一般模式。一般模式是3个模式中功能最为负责的模式，一般的操作都在该模式下完成。由于vi并没有提供图形界面，所以所有的操作都是通过键盘来完成的。由于在字符界面下，没有鼠标的辅助，光标位置的移动是一个非常麻烦的问题。因此，vi提供了许多关于光标移动的功能键，表2-1列出了这些功能键及其功能描述。

表2-1　光标移动快捷键

操　作 | 快　捷　键 | 说　明
向下移动光标 | 向下方向键、j键或者空格键 | 每按1次键，光标向正下方移动1行
向上移动光标 | 向上方向键、k键或者backspace键 | 每按1次键，光标向正上方移动1行
向左移动光标 | 向左方向键或者h键 | 每按1次键，光标向左移动过1个字符
向右移动光标 | 向右方向键或者l键 | 每按1次键，光标向右移动1个字符
移至下1行行首 | 回车键 | 每按1次键，光标会移动到下1行的行首
移至上1行行首 | -键 | 每按1次键，光标会移动到上1行的行首
移至文件最后1行 | G键 | 将光标移动到文件最后1行的行首

注意：除了表2-1中列出的光标移动的快捷键之外，还有部分快捷键是在命令模式使用，例如行定位或者移动指定行数等，这些操作放在命令模式中介绍。

由于光标的移动相对比较简单，所以此处不再举例说明，用户可以使用vi打开一个文件，使用表2-1列出的快捷键来尝试移动光标。

插入文本也是编辑器的一项基本功能。为了能够快速地在指定的位置插入文本，vi编辑器提供了许多相关的命令，如表2-2所示。

表2-2　文本操作快捷键

操　作 | 快　捷　键 | 说　明
右插入 | a | 在当前光标所处位置的右边插入文本
左插入 | i | 在当前光标所处位置的左边插入文本
行尾追加 | A | 在当前行的末尾追加文本
行首插入 | I | 在当前行的开始处插入文本
插入行 | O或者o | O键在当前行的上面插入一个新行，o键将在当前行的下面插入一个新行
覆盖文本 | R | 覆盖当前光标所在的位置以及后面的若干文本
合并行 | J | 将当前光标所在行与下面的一行合并为一行

在表2-2中列出的快捷键中，除了J键之外，其他的所有的快捷键都会导致vi编辑器从一般模式切换到编辑模式。I键的功能是在当前行的开始位置插入文本，不包括空白字符。

注意：使用文本操作快捷键进入编辑模式后，可以通过Esc键返回到一般模式。

文本的复制和粘贴等功能也是在一般模式下完成的。表2-3列出了常用的与文本复制和粘贴有关的快捷键。

表2-3　文本复制和粘贴快捷键

操　作 | 快捷键 | 说　明
复制行 | yy | 将当前行复制到缓冲区。如果想要定义多个缓冲区，可以使用ayy、byy以及cyy语法。其中yy前面的字符表示缓冲区的名称，可以是任意单个字母。这样的话，可以将多个单独的行复制到多个缓冲区中，各个缓冲区相互之间不受影响
复制多行 | nyy | 将当前行以及下面的n行复制到缓冲区，其中n表示一个整数。与yy命令相似，用户也可以使用anyy或者bnyy等语法来命名缓冲区
复制单词 | yw | 复制从光标当前位置到当前单词词尾的字符
复制多个单词 | nyw | 其中n是一个整数，表示从光标当前位置开始，复制后面的n个单词
复制光标到行首 | y^ | 从当前光标所处的位置开始，复制到当前行的行首
复制光标到行尾 | y$ | 从当前光标所处的位置开始，复制到当前行的行尾
粘贴到光标后面的位置 | p | 将缓冲区中的字符串插入点当前光标所处位置的后面。如果定义了多个缓冲区，则使用ap方式来粘贴，其中字母a表示缓冲区的名称
粘贴到光标前面的位置 | P | 将将缓冲区中的字符串插入到当前光标所处位置的前面。如果定义了多个缓冲区，则使用aP的方式来粘贴，其中字母a表示缓冲区的名称

在表2-3列出的快捷键中，yw快捷键复制的并不一定是整个单词，它是从光标当前的位置开始复制，如果当前光标处于某个单词的中间，则yw命令将会复制从当前光标位置开始该单词后面的所有的字符。例如，在图2-28中光标所处的位置在单词“filename”的字母“i”上面，所以如果使用yw快捷键，则复制到缓冲区的字符为“ilename”。

图2-28　复制单词

与之相类似，如果使用nyw快捷键，则会复制从当前单词的当前字符开始，一直到指定单词数的结尾。例如，在图2-28中，如果使用2yw快捷键，则会复制字符串“ilename in”到缓冲区中。文本的粘贴比较简单，此处不再举例说明。

最后，文本的删除也是在一般模式下完成的。表2-4列出了常用的文本删除的快捷键及其功能。

表2-4　删除文本快捷键

操　作 | 快捷键 | 说　明
删除当前字符 | x | 删除光标所在的位置的字符
删除多个字符 | nx | 删除从光标所在位置开始，后面的n个字符
删除当前行 | dd | 删除光标所处的整个行
删除多个行 | ndd | 删除包括当前行在内的n行
撤销上一步操作 | u | 撤销刚刚执行的操作
撤销多个操作 | U | 撤销针对当前行的所有操作

在表2-4中，其中最常用的操作就是删除当前的字符和删除当前行，这两个操作分别由快捷键x和dd完成。

注意：vi的许多快捷键都是由多个字符组成，在使用的时候一定要注意快速地依次按下指定的键。

2．编辑模式

前面已经介绍过，当用户执行了表2-2中除J键之外的任何快捷键，都会使得vi从一般模式切换到编辑模式。vi的编辑模式与其他编辑器的编辑模式没有太大的区别。在编辑模式下，用户可以使用上、下、左和右4个方向键移动光标，使用backspace键和del来删除光标前面的字符，还可以在光标所在的位置插入字符。

注意：在编辑模式下，用户不能使用h、j、k和l这4个键移动光标，也不能使用x键删除字符。因为在编辑模式下，这些字母都被当做是正常的字母。

3．命令模式

命令模式也是使用比较多的一种模式，在命令模式下，用户主要完成文件的打开、保存、将光标跳转到某行，以及显示行号等操作。下面将分别详细介绍这些功能。vi的命令模式需要从一般模式进入，当用户在一般模式下，按冒号键“:”之后，会在vi编辑界面的底部出现命令提示符，如图2-29所示。

图2-29　命令模式提示符

当出现图2-29中的命令提示符之后，用户就可以输入vi命令，例如保存文件或者退出vi编辑器。表2-5列出了常用的vi命令。

表2-5　常用的vi命令

操　作 | 命令 | 说　明
打开文件 | :e | 打开另外一个文件，将文件名作为参数
保存文件 | :w | 保存文件，即将文件的改动写入磁盘。如果将文件另存为其他文件名，则可以将新的文件名作为参数
退出编辑器 | :q | 退出vi编辑器
直接退出编辑器 | :q! | 不保存修改，直接退出vi编辑器
退出并保存文件 | :wq | 将文件保存后退出vi编辑器

在使用vi编辑文件的时候，如果想要直接打开某个文件，可以将文件名作为参数传递给vi命令，例如，以下命令将调用vi编辑器，并且打开demo.sh文件：

[root@linux chapter2]# vi demo.sh

如果已经启动了vi编辑器，用户还想编辑另外一个文件，则可以按下冒号键，进入命令模式，使用:e命令打开另外一个文件。由于vi只能同时编辑一个文件，所以在打开另外一个文件的同时，当前打开的文件将被关闭。

在编辑文件的过程中，如果想要将当前的改动写入磁盘，则可以进入命令模式，使用:w命令将文件内容重新写入磁盘。

如果想要退出vi编辑器，则可以使用:q命令，其中字母q表示退出（quit）。在当前文件已经做了改动的情况下，如果用户使用:q命令退出vi，编辑器会给出保存文件的提示，如图2-30所示。该提示告诉用户，文件内容修改后并没有将改动写入磁盘。如果用户已经确定丢弃当前所做的修改，则可以使用:q!命令，该命令将直接退出vi编辑器，不给出任何提示。

图2-30　保存文件提示信息

另外，:w和:q这两个命令可以组合使用，变成一个命令，即:wq。当这两个命令组合起来时，表示将文件内容写入磁盘后，退出vi编辑器。在组合使用时，这两个命令的顺序不能颠倒，一定是:w在前，:q在后。

注意：感叹号“!”在vi编辑器中表示跳过某些检查，强制执行某些操作。例如，丢弃当前的修改，直接退出vi编辑器，则可以使用:q!命令；如果丢弃当前的修改，直接打开另外一个文件，则可以使用:e!命令；如果系统管理员修改了某些只读文件，则可以使用:w!命令强制将改动写入磁盘，如图2-31所示。

图2-31　修改只读文件

在命令模式下，除了表2-5列出的文件操作的命令之外，还有其他一些常用命令，如表2-6所示。

表2-6　常用的其他命令

操　作 | 命令 | 说　明
跳至指定行 | :n、:n+或者:n- | :n表示跳到行号为n的行，:n+表示向下跳n行，:n-表示向上跳n行
显示或者隐藏行号 | :set nu或者:set nonu | :set nu表示在每行的前面显示行号；:set nonu表示隐藏行号

替换字符串

:s/old/new、:s/old/new/g、:n,m s/old/new/g或者:%s/old/new/g

:s/old/new表示用字符串new替换当前行中首次出现的字符串old；:s/old/new/g表示用字符new替换当前行中所有的字符串old；:n,m s/old/new/g表示用字符串new替换从n行到m行所有的字符串old；:%s/old/new/g表示用字符串new替换当前文件中所有的字符串old

设置文件格式 | :set fileformat=unix | 将文件修改为unix格式，如win下面的文本文件在linux下会出现^M。其中fileformat可以取unix或者dos等值

对于编写和调试程序的用户来说，在vi编辑器中显示行号是一项非常有用的辅助功能，它可以帮助用户快速地定位出现错误的行。用户可以在命令模式下使用:set nu命令显示行号，如图2-32所示。

最后，再介绍一下文本的搜索。当文件内容比较长时，使用文本搜索可以快速地查找某些字符串。尽管vi的文本搜索是在一般模式下进行的，但是它的使用方法与其他一般模式下的功能有所区别，甚至可以将该项功能单独称为搜索模式，所以，将文本搜索单独放在此处介绍。

用户可以在一般模式下通过反斜线“/”快捷键进入文本搜索模式，如图2-33所示。

图2-32　显示行号

图2-33　搜索模式

当进入搜索模式后，用户可以在提示符后面输入要搜索的字符串，然后按回车键。此时，光标会停留在当前文件中指定的字符串第一次出现的位置，如图2-34所示。

如果要搜索的文本出现了多次，可以使用n键继续向下搜索下一个出现的位置；使用N键向上搜索前一个出现的位置。如果要搜索的文本在当前文件中没有出现，则会给出以下提示：

E486: Pattern not found: print

注意：vi虽然有比较多的命令，但是只要勤加练习，相信用户会很快地熟练掌握，同时也会给工作或学习带来更高的效率。如果不知道自己处在什么模式时，可以按两次Esc键即可回到命令模式，最后提醒一点，注意英文字母的大小写。

图2-34　搜索字符串

2.3　系统环境的搭建

在运行Shell程序的时候，除了脚本本身之外，还有许多因素会影响到Shell的执行结果。这里面主要有Shell本身的环境以及命令的别名等。本节将介绍这两方面的相关知识。

2.3.1　Shell配置文件

前面已经介绍过，到目前为止，已经出现了许多种类型的Shell，其中最常用的有sh和bash等。这些不同的Shell都有各自的系统环境变量的设置方法，分别保存在不同的配置文件中。下面分别介绍这两种Shell的配置文件的使用方法。

注意：在Linux中，sh被设计成bash的符号链接。

1．sh

Bourne Shell（sh）的配置文件主要有两个，分别为每个用户主目录中的.profile文件以及/etc/profile文件。在前面的两个文件中，后者是所有用户共同使用的文件。每个用户在登录Shell之后，会首先读取和执行/etc/profile文件中的脚本，然后再读取和执行各自主目录中的.profile文件。因此，用户可以将所有用户都需要执行的脚本放在/etc/profile文件中。下面的代码是某个/etc/profile文件的部分内容：

root@solaris # more /etc/profile
#ident "@(#)profile 1.19 01/03/13 SMI" /* SVr4.0 1.3 */
# The profile that all logins get before using their own .profile.
if [ -d /opt/SUNWspro/bin ]
then
PATH=/opt/SUNWspro/bin:/opt/csw/bin:${PATH}:/opt/csw/gcc3/bin
MANPATH=${MANPATH}:/opt/SUNWspro/man
fi
trap "" 2 3
export LOGNAME PATH MANPATH
if [ "$TERM" = "" ]
then
if /bin/i386
then
TERM=sun-color
else
TERM=sun
fi
export TERM
fi
…
--More--(45%)

从上面的代码可以得知，/etc/profile文件的内容与普通的Shell脚本并没有太大的区别。

用户主目录中的.profile文件是一个隐藏文件，该文件的内容与/etc/profile几乎是一样的。该文件是每个用户私有的文件，每个用户在登录Shell的时候会自动执行各自的.profile文件，用户之间不会相互影响。.profile文件会在/etc/profile文件之后读取和执行，因此，如果这两个文件中有相同的环境变量，.profile文件中的变量的值会覆盖/etc/profile文件中的相同变量的值。

注意：在UNIX或者Linux中，以圆点开头的文件为隐藏文件。用户可以使用ls命令的-a选项来显示隐藏文件。

2．bash

Bourne-Again Shell（bash）的配置文件主要有5个，其中有4个位于用户主目录中，分别为.bash_profile、.bashrc、.bash_logout和.bash_history，有一个位于/etc/目录中，名称为bashrc。

.bash_profile位于每个用户的主目录中，在该文件中用来保存每个用户自己使用的Shell信息。当用户登录时，该文件将被读取并执行，并且该文件仅被执行一次。默认情况下，.bash_profile文件常常用来设置环境变量，执行用户的.bashrc文件。下面的代码是某个系统中root用户的.bash_profile文件的内容：

01 # .bash_profile
02
03 # Get the aliases and functions
04 if [ -f ~/.bashrc ]; then
05 . ~/.bashrc
06 fi
07
08 # User specific environment and startup programs
09
10 PATH=$PATH:$HOME/bin:/usr/pgsql-9.2/bin
11
12 export PATH

从上面的代码可以看出，.bash_profile文件在第5行调用了用户主目录中的.bashrc文件。第10行设置了PATH系统变量，第12行将系统变量导出。

.bashrc文件包含专属于某个用户的bash的相关信息，当用户登录以及每次打开新的bash时，该文件将被读取并执行。下面的代码是某个系统中root用户的.bashrc的内容：

01 # .bashrc
02
03 # User specific aliases and functions
04
05 alias rm='rm -i'
06 alias cp='cp -i'
07 alias mv='mv -i'
08
09 # Source global definitions
10 if [ -f /etc/bashrc ]; then
11 . /etc/bashrc
12 fi

从上面的内容可以得知，该文件主要用来定义别名和函数，例如第5行定义了命令“rm-i”的别名为rm，第6行定义了命令“cp -i”的别名为cp。另外，该文件还会调用/etc/bashrc文件，例如上面代码中的第11行。

.bash_logout文件在当前用户每次退出Shell时执行。如果没有特别的要求，该文件的内容通常为空。

etc/bashrc与sh中的/etc/profile文件非常相似，它是所有使用bash的用户共同使用的文件。当任何用户在登录bash后，都会执行该文件中的代码。下面的代码是某个Linux系统中的/etc/bashrc的部分内容：

01 # /etc/bashrc
02
03 # System wide functions and aliases
04 # Environment stuff goes in /etc/profile
05
06 # It's NOT a good idea to change this file unless you know what you
07 # are doing. It's much better to create a custom.sh shell script in
08 # /etc/profile.d/ to make custom changes to your environment, as this
09 # will prevent the need for merging in future updates.
10
11 # are we an interactive shell?
12 if [ "$PS1" ]; then
13 if [ -z "$PROMPT_COMMAND" ]; then
14 case $TERM in
15 xterm*)
16 if [ -e /etc/sysconfig/bash-prompt-xterm ]; then
17 PROMPT_COMMAND=/etc/sysconfig/bash-prompt-xterm
18 else
19 PROMPT_COMMAND='printf "\033]0;%s@%s:%s\007" "${USER}"
"${HOSTNAME%%.*}" "${PWD/#$HOME/~}"'
20 fi
21 ;;
22 screen)
23 if [ -e /etc/sysconfig/bash-prompt-screen ]; then
24 PROMPT_COMMAND=/etc/sysconfig/bash-prompt-screen
25 else
26 PROMPT_COMMAND='printf "\033]0;%s@%s:%s\033\\" "${USER}"
"${HOSTNAME%%.*}" "${PWD/#$HOME/~}"'
27 fi
28 ;;
29 *)
30 [ -e /etc/sysconfig/bash-prompt-default ] && PROMPT_COMMAND=
/etc/sysconfig/bash-prompt-default
31 ;;
32 esac
33 fi
34 # Turn on checkwinsize
35 shopt -s checkwinsize
…

注意：Linux不建议用户直接修改/etc/profile或者/etc/bashrc文件，应该尽量将用户的配置信息放在用户主目录中的对应文件中。

2.3.2　命令别名

顾名思义，命令别名是命令的另外一个名称。在Linux中，设置命令别名的作用主要是为了简化命令的输入。对于一个包含许多选项和参数的命令，用户可以为其设置一个别名，这样的话，在调用该命令的时候只要使用别名就可以了。

例如，在Linux中，为了提高安全性，通常为以下两个命令设置别名：

rm -i

和

cp -i

在这两个命令中，-i选项的作用是相同的，都是使得前面的命令进入交互式模式。如果不使用交互模式，rm命令可能会直接删除文件，而不给用户任何提示；cp命令也会直接覆盖已经存在的文件，而不给用户任何提示。这样的操作无疑会给用户带来非常大的风险。因此，在绝大多数的Linux系统，这两个命令的别名设置方法为：

alias rm='rm -i'
alias cp='cp -i'

在上面的代码中，alias命令用来设置命令别名，其基本语法如下：

alias command_alias=command

其中，参数command_alias表示命令的别名，command表示某个Shell命令。当设置了命令别名之后，用户就可以像使用普通的命令一样使用别名。例如，在上面的代码中，通过alias命令为“rm -i”命令设置了别名为rm，这样的话，用户在使用rm命令时，实际上使用的是“rm-i”命令。

注意：在Linux Shell中，别名拥有最高的执行优先级，所以尽管系统中有rm命令，但是Shell仍然优先使用rm别名。另外，其他对象的优先级从高到低分别为关键字（如if、functions等）、函数、内置命令可执行文件和脚本。

2.4　小结

本章详细介绍了Shell编程环境的搭建，主要内容包括在不同的操作系统上面搭建Shell执行环境、编辑器的选择、Shell环境变量的设置方法和命令别名。重点在于掌握在Cygwin中的安装和配置方法、ShellEd的使用方法，以及vi中常用的命令。在下一章中，将介绍Shell编程中的变量和引用。

第2篇
Shell编程基础

第3章
变量和引用

在任何程序设计语言里面，变量都是一个非常重要的话题。变量是程序设计语言中不可缺少的元素。正确、恰当地使用变量可以增加程序的可读性，提高程序的健壮性和灵活性。本章将从变量的基础知识开始，依次介绍什么是变量、变量的赋值和替换，以及变量的引用。

本章主要涉及的知识点如下所述。

- 深入认识变量：主要介绍什么是变量、变量的命名、变量的类型、变量的有效范围，以及系统变量和用户自定义变量等。

- 变量的赋值和替换：主要介绍如何为变量赋值、如何取得变量的值、如何清空变量的值，以及如何进行变量替换。

- 引用：主要介绍什么是全引用、部分引用、命令替换和转义等。

3.1　深入认识变量

在程序设计语言中，变量是一个非常重要的概念。也是初学者在进行Shell程序设计之前必须掌握的一个非常基础的概念。只有理解变量的使用方法，才能设计出良好的程序。本节将介绍Shell中变量的相关知识。

3.1.1　什么是变量

顾名思义，变量就是程序设计语言中的一个可以变化的量，当然，可以变化的是变量的值。变量在几乎所有的程序设计语言中都有定义，并且其涵义也大同小异。从本质上讲，变量就是在程序中保存用户数据的一块内存空间，而变量名就是这块内存空间的地址。

在程序的执行过程中，保存数据的内存空间的内容可能会不断地发生变化，但是，代表内存地址的变量名却保持不变。

由于变量的值是在计算机的内存中，所以当计算机被重新启动后，变量的值将会丢失。因此，对于需要长久保存的数据，应该写入到磁盘中，避免存储在变量中。

3.1.2　变量的命名

对初学者来说，可以简单地认为，变量就是保存在计算机内存中的一系列的键值对。例如：

str="hello"

在上面的语句中，等号前面的部分就是键，等号后面的就是值。用户使用变量的目的就是通过键来存取不同的值。

在程序设计语言中，一般将上述语句中的键称为变量名。因此，用户是通过变量名来对变量所代表的值进行存取。

在不同的程序设计语言中，对于变量名的要求有所不同。在Shell中，变量名可以由字母、数字或者下划线组成，并且只能以字母或者下划线开头。对于变量名的程序，Shell并没有做出明确的规定。因此，用户可以使用任意长度的字符串作为变量名。但是，为了提高程序的可读性，建议用户使用相对较短的字符串作为变量名。

在一个设计良好的程序中，变量的命名有着非常大的学问。通常情况下，用户应该尽可能选择有明确意义的英文单词作为变量名，尽量避免使用拼音或者毫无意义的字符串作为变量名。这样的话，用户通过变量名就可以了解该变量的作用。

例如，下面的变量名都是非常好的选择：

PATH=/sbin
UID=100
JAVA_HOME="/usr/lib/jvm/jre-1.6.0-openjdk.x86_64/bin/../.."
SSHD=/usr/sbin/sshd

而下面的变量名则相对可读性较差：

a="123"
str1="hello"

因为程序的阅读者没有从变量名上面获取到任何有用的信息。当变量较多的时候，程序设计者本身也会搞不清楚这些变量的作用是什么，从而导致程序发生错误。

注意：在Shell语言中，变量名的大小写是敏感的，因此，大小写不同的两个变量名并不代表同一个变量。

3.1.3　变量的类型

与变量密切相关的有两个概念，其中一个是变量的类型，另外一个是变量的作用域。此处先介绍变量的类型，有效范围将在随后介绍。

根据变量类型确定的时间，可以将程序设计语言分为两类，分别是静态类型语言和动态类型语言。其中，静态类型语言是在程序的编译期间就确定变量类型的语言，例如Java、C++和PASCAL，在这些语言中使用变量时，必须首先声明其类型。动态设计语言是在程序执行过程中才确定变量的数据类型的语言。常见的动态语言有VBScript、PHP及Python等。在这些语言中，变量的数据类型根据第一次赋给该变量的值的数据类型来确定。

同样，根据是否强制要求类型定义，可以将程序设计语言分为强类型语言和弱类型语言。强类型语言要求用户在定义变量时必须明确指定其数据类型，例如Java和C++。在强类型语言中，数据类型之间的转换非常重要。与之相反，弱类型语言则不要求用户明确指定变量的数据类型，例如VBScript。用户可以将任意类型的数值赋给该变量。并且，变量的数据类型之间的转换也无需明确进行。

Shell是一种动态类型语言和弱类型语言，即在Shell中，变量的数据类型无需显示地声明，变量的数据类型会根据不同的操作有所变化。准确地讲，Shell中的变量是不分数据类型的，统一地按照字符串存储。但是根据变量的上下文环境，允许程序执行一些不同的操作，例如字符串的比较和整数的加减等。

【例3-1】演示Shell变量的数据类型，代码如下：

01 #-----------------------------/chapter3/ex3-1.sh------------------
02 #! /bin/bash
03
04 # 定义变量 x ，并且赋值为 123
05 x=123
06 # 变量 x 加 1
07 let "x += 1"
08 # 输出变量 x 的值
09 echo "x = $x"
10 # 显示空行
11 echo
12 # 替换 x 中的 1 为 abc ，并且将值赋给变量 y
13 y=${x/1/abc}
14 # 输出变量 y 的值
15 echo "y = $y"
16 # 声明变量 y
17 declare -i y
18 # 输出变量 y 的值
19 echo "y = $y"
20 # 变量 y 的值加 1
21 let "y += 1"
22 # 输出变量 y 的值
23 echo "y = $y"
24 # 显示空行
25 echo
26 # 将字符串赋给变量 z
27 z=abc22
28 # 输出变量 z 的值
29 echo "z = $z"
30 # 替换变量 z 中的 abc 为数字 11 ，并且将值赋给变量 m
31 m=${z/abc/11}
32 # 输出变量 m 的值
33 echo "m = $m"
34 # 变量 m 加 1
35 let "m += 1"
36 # 输出变量 m 的值
37 echo "m = $m"
38
39 echo
40 # 将空串赋给变量 n
41 n=""
42 # 输出变量 n 的值
43 echo "n = $n"
44 # 变量 n 加 1
45 let "n += 1"
46 echo "n = $n"
47 echo
48 # 输出空变量 p 的值
49 echo "p = $p"
50 # 变量 p 加 1
51 let "p += 1"
52 echo "p = $p"

为了便于介绍，首先看一下以上程序的执行结果，如下：

01 [root@linux chapter3]# ./ex3-1.sh
02 x = 124
03
04 y = abc24
05 y = abc24
06 y = 1
07
08 z = abc22
09 m = 1122
10 m = 1123
11
12 n =
13 n = 1
14
15 p =
16 p = 1

在【例3-1】中，第5行定义了变量x，并且将一个整数值赋给该变量，所以变量x的数据类型为整数。第7行使用let语句将变量x的值加1，从而变成了124，输出结果的第2行正是程序的第9行的echo语句的执行结果。

代码的第13行将变量x的值中的数字1替换成了字符串abc，并且将替换后的结果赋给变量y，所以变量y实际上是一个字符串。执行结果的第4行是代码的第15行的echo语句的执行结果。

代码的第17行是使用declare语句声明整数变量y，但是这个语句并不影响当前变量y的值，因此，执行结果的第5行是代码第19行的echo语句的执行结果。

代码的第21行是将变量y加1，用户可能已经注意到，此时变量y的值为abc24，这个值是一个含有字母和数字的字符串。为了能够执行加法运算，Shell会自动进行数据类型的转换，如果遇到含有非数字的字符串，则该字符串将被转换成整数0，所以，当执行加1运算之后，变量y的值就变成1。执行结果的第6行是代码第23行的echo语句的输出。

代码的第27行将一个含有字母的字符串赋给变量z，然后在第29行输出该变量的值，得到了结果的第8行。

代码的第31行将变量z的值中的字母abc替换为整数11，并且将替换后的结果赋给变量m，在第33行输出变量m的值，即执行结果的第9行。

代码的第35行将变量m的值加1，由于此时y的值为1122，这是一个完全由数字组成的字符串，所以Shell可以将其转换为整数，然后执行加法运算，得到结果为1123。

代码的第41行～第52行分别测试了在空串以及没有定义的变量的情况下，执行加法运算的执行结果。从上面的执行结果可以得知，在这两种情况下，变量的值都会被转换为整数0。

从上面的执行结果可以看到，Shell中的变量非常灵活，可以参与任何运算。实际上，在Shell中，一切变量都是字符串类型的。

3.1.4　变量的定义

在Shell中，通常情况下用户可以直接使用变量，无需先进行定义，当用户第一次使用某个变量名时，实际上就同时定义了这个变量，在变量的作用域内，用户都可以使用该变量。

【例3-2】演示通过直接使用变量来定义变量，代码如下：

01 #-----------------------------/chapter3/ex3-2.sh------------------
02 #! /bin/bash
03
04 # 定义变量 a
05 a=1
06 # 定义变量 b
07 b="hello"
08 # 定义变量 c
09 c="hello world"

在上面的代码中，第5行定义了一个名称为a的变量，同时将一个数字赋给该变量。第7行定义了一个名称为b的变量，同时将一个字符串赋给该变量。第9行定义了一个变量c，同时将一个包含空格的字符串赋给该变量。在Shell语言中，如果变量的值包含空格，则一定要使用引号引用起来。

尽管通过以上方式可以非常方便地定义变量，但是，对于变量的某些属性却不容易控制，例如，变量的类型和读写属性等。为了更好地控制变量的相关属性，bash提供了一个名称为declare的命令来声明变量，该命令的基本语法如下：

declare attribute variable

其中，attribute表示变量的属性，常用的属性有如下所述。

- -p：显示所有变量的值。

- -i：将变量定义为整数。在之后就可以直接对表达式求值，结果只能是整数。如果求值失败或者不是整数，就设置为0。

- -r：将变量声明为只读变量。只读变量不允许修改，也不允许删除。

- -a：变量声明为数组变量。但这没有必要。所有变量都不必显式定义就可以用做数组。事实上，在某种意义上，所有变量都是数组，而且赋值给没有下标的变量与赋值给下标为0的数组元素相同。

- -f：显示所有自定义函数，包括名称和函数体。

- -x：将变量设置成环境变量，这样在随后的脚本和程序中可以使用。参数variable表示变量名称。

注意：declare命令又写做typeset。

【例3-3】演示使用不同的方法声明变量，导致变量在不同的环境下表现出不同的行为，代码如下：

01 #-----------------------------/chapter3/ex3-3.sh------------------
02 #! /bin/bash
03
04 定义变量 x ，并将一个算术式赋给它
05 x=6/3
06 echo "$x"
07 # 定义变量 x 为整数
08 declare -i x
09 echo "$x"
10 # 将算术式赋给变量 x
11 x=6/3
12 echo "$x"
13 # 将字符串赋给变量 x
14 x=hello
15 echo "$x"
16 # 将浮点数赋给变量 x
17 x=3.14
18 echo "$x"
19 # 取消变量 x 的整数属性
20 declare +i x
21 # 重新将算术式赋给变量 x
22 x=6/3
23 echo "$x"
24 # 求表达式的值
25 x=$[6/3]
26 echo "$x"
27 # 求表达式的值
28 x=$((6/3))
29 echo "$x"
30 # 声明只读变量 x
31 declare -r x
32 echo "$x"
33 # 尝试为只读变量赋值
34 x=5
35 echo "$x"

以上程序的执行结果如下：

01 [root@linux chapter3]# ./ex3-3.sh
02 6/3
03 6/3
04 2
05 0
06 ./ex3-3.sh: line 15: 3.14: syntax error: invalid arithmetic operator
(error token is ".14")
07 0
08 6/3
09 2
10 2
11 2
12 ./ex3-3.sh: line 32: x: readonly variable
13 2

下面对比执行结果分析一下【例3-3】中的代码。第5行使用通常的方法定义了一个变量x，并且将一个算术式作为初始值赋给该变量。第6行输出变量x的值。前面已经讲过，Shell中将所有的数据都看做是字符串来存储的，所以在程序执行的时候，Shell并不将6/3当成一个将被求值的算术式，而是作为一个普通的字符串，所以第6行直接输出了这个算术式本身，得到了结果的第2行。

代码的第8行使用declare语句声明了变量x为整数，但是程序并没对变量x重新赋值，所以第9行的echo语句的执行结果仍然得到算术式本身，即结果的第3行。

代码的第11行对变量x重新赋值，将前面的算术表达式赋给它。因为当变量被声明为整数之后，可以直接参与算术运算，所以第12行的echo语句中输出了算术式的值，即结果的第4行。

第14行尝试将一个字符串值赋给整数变量x，并且在第15行使用echo语句输出x的值。在Shell中，如果变量被声明成整数，把一个结果不是整数的表达式赋值给它时，就会变成0。因此，结果第5行中的0是代码第15行的echo语句的输出。

第17行将一个浮点数赋给变量x，因为bash并不内置对浮点数的支持，所以得到了执行结果中的第6行的错误消息，此时，变量x的值变为0，即结果的第7行中的0。

第20行取消变量x的整数类型属性，第22行重新将算术式赋给变量x，并且在第23行使用echo语句输出变量x的值。由于此时变量x已经不是整数变量，所以不能直接参数算术运算。因此，变量x的值仍然得到了算术式本身，即结果的第8行。

在Shell中，为了得到算术式的值，可以有两种方法，其中一种就是使用方括号，即第25行中的方式。结果的第9行正是此时变量x的值。另外一种是使用圆括号，即第28行中的方式。从执行结果可以得知，这两种方式都可以得到用户所期望的结果。

第31行使用-r选项声明了一个只读变量，第43行尝试为该变量重新赋值，从而得到了结果中第12行的错误消息。此时变量x的值仍然是2，所以才有结果中的第13行。

3.1.5　变量和引号

在Shell编程中，正确理解引号的作用非常重要。Shell语言中一共有3种引号，分别为单引号（' '）、双引号（" "）和反引号（` `）。这3种引号的作用是不同的，其中单引号括起来的字符都作为普通字符出现；由双引号括起来的字符，除“$”、“\”、“'”和“"”这几个字符仍是特殊字符并保留其特殊功能外，其余字符仍作为普通字符对待；由反引号括起来的字串被Shell解释为命令，在执行时，Shell首先执行该命令，并以它的标准输出结果取代整个反引号（包括两个反引号）部分。关于单引号和双引号的作用，将在后面的引用部分介绍，下面举例说明一下反引号的使用方法。

【例3-4】演示反引号使用方法，代码如下：

01 #-----------------------------/chapter3/ex3-4.sh------------------
02 #! /bin/bash
03
04 # 输出当前目录
05 echo "current directory is `pwd`"

在上面的代码中，第5行的语句中包含一个由反引号引用起来的Shell命令pwd。以上命令的执行结果如下：

[root@linux chapter3]# ./ex3-4.sh
current directory is /root/chapter3

从上面的执行结果可以得知，代码的第5行在执行的过程中会首先执行pwd命令，用该命令的执行结果取代命令所在的位置，然后执行echo语句。

注意：反引号是键盘左上角的波浪号“～”下面的那个符号。

3.1.6　变量的作用域

接下来讨论Shell语言中的与变量密切相关的另外一个概念，即变量的作用域。与其他程序设计语言一样，Shell中的变量也分为全局变量和局部变量两种。下面分别介绍这两种变量的作用域。

1．全局变量

通常认为，全局变量是使用范围较大的变量，它不仅限于某个局部使用。在Shell语言中，全局变量可以在脚本中定义，也可以在某个函数中定义。在脚本中定义的变量都是全局变量，其作用域为从被定义的地方开始，一直到Shell脚本结束或者被显式地删除。

【例3-5】演示全局变量的使用方法，代码如下：

01 #-----------------------------/chapter3/ex3-5.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 func()
06 {
07 # 输出变量 x 的值
08 echo "$v1"
09 # 修改变量 x 的值
10 v1=200
11 }
12 # 在脚本中定义变量 x
13 v1=100
14 # 调用函数
15 func
16 # 输出变量 x 的值
17 echo "$v1"

在上面的代码中，第5行～第11定义了名称为func()的函数，第8行在函数内部输出全局变量v1的值，第10行修改全局变量v1的值为200，第13行在脚本中，即函数外面定义了变量v1，该变量是全局变量。第15行调用函数func()，第17行重新输出修改后的变量v1的值。

该程序的执行结果如下：

[root@linux chapter3]# ./ex3-5.sh
100
200

在上面的执行结果中，100是第8行的echo语句的输出，从执行结果可以得知，在函数func()内部可以访问全局变量v1。200是第17行的echo语句的输出，这是因为程序的第10行在函数func()内部修改了变量v1的值。从【例3-5】的执行结果可以得知，在脚本中定义的变量为全局变量，不仅可以在脚本中直接使用，还可以在函数内部直接使用。

除了在脚本中定义全局变量之外，在函数内部定义的变量默认情况下也是全局变量，其作用域为从函数被调用时执行变量定义的地方开始，一直到Shell脚本结束或者被显式地删除为止。

【例3-6】演示在函数内部定义全局变量的方法，代码如下：

01 #-----------------------------/chapter3/ex3-6.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 func()
06 {
07 # 在函数内部定义变量
08 v2=200
09 }
10 # 调用函数
11 func
12 # 输出变量的值
13 echo "$v2"

在上面的代码中，第5行～第9行定义了名称为func()的函数，其中在第8行定义了一个名称为v2的变量。第11行调用func()函数，第13行输出变量v2的值。

该程序的执行结果如下：

[root@linux chapter3]# ./ex3-6.sh
200

之所以会得到200，是因为在Shell中，默认情况下，函数内部定义的变量也属于全局变量。因此，在代码的第8行定义的变量v2在函数外部仍然可以使用。

注意：函数的参数是局部变量。

2．局部变量

与全局变量相比，局部变量的使用范围较小，通常仅限于某个程序段访问，例如函数内部。在Shell语言中，可以在函数内部通过local关键字定义局部变量，另外，函数的参数也是局部变量。

【例3-7】演示使用local关键字定义局部变量。本例对【例3-6】的代码稍作改动，在定义变量v2的时候使用local关键字，代码如下：

01 #-----------------------------/chapter3/ex3-7.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 func()
06 {
07 # 使用 local 关键字定义局部变量
08 local v2=200
09 }
10 # 调用函数
11 func
12 # 输出变量的值
13 echo "$v2"

该程序的执行结果如下：

[root@linux chapter3]# ./ex3-7.sh

从上面的执行结果可以得知，由于在函数内部使用local关键字显式地定义了局部变量，所以在函数外面不能获得该变量的值。第13行的echo语句仅仅输出了空值。

注意：关于函数的详细介绍请参见本书第6章。

如果用户在函数外面定义了一个全局变量，同时在某个函数内部又存在相同名称的局部变量，则在调用该函数时，函数内部的局部变量会屏蔽函数外部定义的全局变量。也就是说，在出现同名的情况下，函数内部的局部变量会优先被使用。

【例3-8】演示全局变量和局部变量的区别。在本例中，定义两个名称都为v1的变量，其中一个为全局变量，另外一个为局部变量，然后分别比较这两个变量的不同的行为，代码如下：

01 #-----------------------------/chapter3/ex3-8.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 func()
06 {
07 # 输出全局变量 v1 的值
08 echo "global variable v1 is $v1"
09 # 定义局部变量 v1
10 local v1=2
11 # 输出局部变量 v1 的值
12 echo "local variable v1 is $v1"
13 }
14 # 定义全局变量 v1
15 v1=1
16 # 调用函数
17 func
18 # 输出全局变量 v1 的值
19 echo "global variable v1 is $v1"

在上面的代码中，第5行～第13行定义了函数func()。由于函数内部的局部变量是在第10行定义的，所以第8行的echo语句输出的是全局变量v1的值。第10行使用local关键字定义相同名称的局部变量v1，并且赋值为2。第12行输出了局部变量v1的值。第15行定义了全局变量v1，并且赋值为1。第17行调用函数func()。第19行输出全局变量v1的值。

该程序的执行结果如下：

[root@linux chapter3]# ./ex3-8.sh
global variable v1 is 1
local variable is 2
global variable is 1

从上面的执行结果可以得知，在函数func()中，第一次输出的是全局变量v1的值，第二次输出的是局部变量v1的值。尽管在函数内部修改了变量v1的值，但是这只影响到局部变量v1，所以在函数外部输出的仍然是全局变量的值。

注意：Shell变量中的符号“$”表示取变量的值。只有在取值的时候才使用，定义和赋值时无需使用符号“$”。另外，实际上Shell中变量的原型为${var}，而常用的书写形式$var只是一个简写。在某些情况下，简写形式会导致程序执行错误。

3.1.7　系统变量

Shell语言的系统变量主要在对参数判断和命令返回值判断时使用，包括脚本和函数的参数，以及脚本和函数的返回值。Shell语言中的系统变量并不多，但是十分有用，特别是在做一些参数检测的时候。表3-1列出了常用的系统变量。

表3-1　Shell中常用的系统变量

变　量 | 说　明
$n | n是一个整数，从1开始，表示参数的位置，例如$1表示第1个参数，$2表示第2个参数等
$# | 命令行参数的个数
$0 | 当前Shell脚本的名称
$? | 前一个命令或者函数的返回状态码
$* | 以“参数1参数2……”的形式将所有的参数通过一个字符串返回
$@ | 以“参数1”“参数2”……的形式返回每个参数
$$ | 返回本程序的进程ID（PID）

【例3-9】演示常用系统变量的使用方法。在本例中通过Shell系统变量来获取不同的信息，代码如下：

01 #-----------------------------/chapter3/ex3-9.sh------------------
02 #! /bin/bash
03
04 # 输出脚本的参数个数
05 echo "the number of parameters is $#"
06 # 输出上一个命令的退出状态码
07 echo "the return code of last command is $?"
08 # 输出当前脚本名称
09 echo "the script name is $0"
10 # 输出所有的参数
11 echo "the parameters are $*"
12 # 输出其中的几个参数
13 echo "\$1=$1;\$2=$2;\$11=$11"

在上面的代码中，第5行使用变量$#获取当前脚本的参数个数，第7行使用变量$?获取上一个脚本或者命令的返回状态码，第9行通过变量$0获取当前脚本的名称，第11行通过变量$*以一个字符串的形式返回所有的参数值，第13行通过变量$n输出其中几个参数的值。

该程序的执行结果如下：

[root@linux chapter3]# ./ex3-9.sh a b c d e f g h i j k l m n
the number of parameters is 14
the return code of last command is 0
the script name is ./ex3-9.sh
the parameters are a b c d e f g h i j k l m n
$1=a;$2=b;$11=a1

在执行ex3-9.sh脚本文件时，为该程序提供了14个参数，所以代码第5行的输出结果为14。同时由于代码第5行的echo语句执行成功，所以代码第7行的输出结果为0。代码第9行的echo语句输出了当前脚本文件的名称为ex3-9.sh。第11行通过变量$*返回所有的参数的值。第13行分别通过变量$1、$2和$11获取第1个、第2个和第11个参数的值。从上面的执行结果可以得知，第1个和第2个参数的值都已经正确获取，但是第11个参数的值却输出了“a1”，而非希望得到的k。为什么会得到这种错误的结果呢？

原来在Shell语言中，使用$n的形式获取位置参数时，Shell通常的变量名只是一位数字，即1～9。因此，在上面的程序中，使用变量$11获取第11个参数的值时，Shell会将“$1”作为变量名，导致了获取结果实际上是第1个参数的值。而最后的“1”则是变量名$11中的第2个“1”。这个数字会直接与前面的字符串连接在一起。

为了能够使Shell正确地知道哪些部分是变量名，用户可以使用大括号来界定变量名。下面将变量名$11中的数字11用大括号括起来，如下：

echo "\$1=$1;\$2=$2;\$11=${11}"

此时再次执行【例3-9】的脚本文件，就可以得到正确的结果，如下所示。

[root@linux chapter3]# ./ex3-9.sh a b c d e f g h i j k l m n
the number of parameters is 14
the return code of last command is 0
the script name is ./ex3-9.sh
the parameters are a b c d e f g h i j k l m n
$1=a;$2=b;$11=k

注意：【例3-9】中的反斜线“\”称为转义字符，用于将一些Shell中的特殊字符转换为普通字符，例如“$”或者“’”等。

3.1.8　环境变量

Shell的环境变量是所有Shell程序都可以使用的变量。Shell程序在运行时，都会接收一组变量，这组变量就是环境变量。环境变量会影响到所有脚本的执行结果。表3-2列出了常用的Shell变量。

表3-2　常用的Shell环境变量

变量 | 说　明
PATH | 命令搜索路径，以冒号为分隔符。注意与Windows下不同的是，当前目录不在系统路径里
HOME | 用户主目录的路径名，是cd命令的默认参数
COLUMNS | 定义了命令编辑模式下可使用命令行的长度
HISTFILE | 命令历史文件
HISTSIZE | 命令历史文件中最多可包含的命令条数
HISTFILESIZE | 命令历史文件中包含的最大行数
IFS | 定义Shell使用的分隔符
LOGNAME | 当前的登录名
SHELL | Shell的全路径名
TERM | 终端类型
TMOUT | Shell自动退出的时间，单位为秒，若设为0则禁止Shell自动退出
PWD | 当前工作目录

除了表3-2列出的一些环境变量之外，用户还可以使用set命令列出所有的环境变量，如下：

[root@linux chapter3]# set | more
BASH=/bin/bash
BASHOPTS=checkwinsize:cmdhist:expand_aliases:extquote:force_fignore:hos
tcomplete:interactive_comments:login_shell:progcomp:promptvars:sourcepa
th
BASH_ALIASES=()
BASH_ARGC=()
BASH_ARGV=()
BASH_CMDS=()
BASH_LINENO=()
BASH_SOURCE=()
BASH_VERSINFO=([0]="4" [1]="1" [2]="2" [3]="1" [4]="release" [5]=
"x86_64-redhat-linux-gnu")
BASH_VERSION='4.1.2(1)-release'
COLORS=/etc/DIR_COLORS
COLUMNS=235
CVS_RSH=ssh
DIRSTACK=()
EUID=0
GROUPS=()
G_BROKEN_FILENAMES=1
HISTCONTROL=ignoredups
HISTFILE=/root/.bash_history
HISTFILESIZE=1000
HISTSIZE=1000
HOME=/root
HOSTNAME=linux
…

用户如果想要使用环境变量，则可以通过相应的变量名来获取。

【例3-10】演示通过环境变量来获取与当前Shell有关的一些环境变量的值，代码如下：

01 #-----------------------------/chapter3/ex3-10.sh------------------
02 #! /bin/bash
03
04 # 输出命令搜索路径
05 echo "commands path is $PATH"
06 # 输出当前的登录名
07 echo "current login name is $LOGNAME"
08 # 输出当前用户的主目录
09 echo "current user's home is $HOME"
10 # 输出当前的 Shell
11 echo "current shell is $SHELL"
12 # 输出当前工作目录
13 echo "current path is $PWD"

在上面的代码中，第5行输出当前用户所设置的命令搜索路径，第7行输出当前用户的登录名，第9行输出当前用户的主目录，第11行输出当前Shell的全路径，第13行输出当前的工作路径。

该程序的执行结果如下：

[root@linux chapter3]# ./ex3-10.sh
commands path is /usr/lib64/qt-3.3/bin:/usr/local/sbin:/usr/local/
bin:/sbin:/bin:/usr/sbin:/usr/bin:/root/bin:/usr/pgsql-9.2/bin
current login name is root
current user's home is /root
current shell is /bin/bash
current path is /root/chapter3

注意：按照惯例，Shell中的环境变量全部使用大写字母表示。

3.2　变量赋值和清空

在了解了变量的基础知识之后，接下来再介绍一下Shell语言中变量的赋值和变量的销毁。

3.2.1　变量赋值

在Shell语言中，通常情况下变量并不需要专门的定义和初始化语言。一个没有初始化的Shell变量被认为是一个空字符串。用户可以通过变量的赋值操作来完成变量的声明并赋予一个特定的值。并且可以通过赋值语句为一个变量多次赋值，以改变其值。

在Shell中，变量的赋值使用以下语法：

variable_name=value

其中，varibale_name表示变量名，关于变量名的命名规则，前面已经介绍过了，不再重复说明。value表示将要赋给变量的值。一般情况下，Shell中将所有普通变量的值都看做字符串。如果value中包含空格、制表符和换行符，则必须用单引号或者双引号将其引起来。双引号内允许变量替换，而单引号则不可以。

中间的等于号“=”称为赋值符号，赋值符号的左右两边不能直接跟空格，否则Shell会将其视为命令。

例如，下面都是一些正确的赋值语句：

v1=Linux
v2='RedHat Linux'
v3="RedHat Linux $HOSTTYPE"
v4=12345

此外，Shell允许只包含数字的变量值参与数值运算，例如上面的v4。另外，在上面的v3变量值中，包含一个特殊符号“$”，这个符号的作用是取某个变量的值，将在后面的变量替换中介绍。

3.2.2　引用变量的值

当变量赋值完成之后，就需要使用变量的值。在Shell中，用户可以通过在变量名前面加上“$”来获取该变量的值。实际上，在前面的许多例子中，我们已经多次使用了这个符号来获取变量的值，为了使用户更加清楚Shell中变量值的引用方法，在此进行详细介绍。

【例3-11】演示Shell变量的引用方法，代码如下：

01 #-----------------------------/chapter3/ex3-11.sh------------------
02 #!/bin/bash
03
04 v1=Linux
05 v2='RedHat Linux'
06 v3="RedHat Linux $HOSTTYPE"
07 v4=12345
08
09 # 输出变量 v1 的值
10 echo "$v1"
11 # 输出变量
12 echo "$v2"
13 # 输出变量 v3 的值
14 echo "$v3"
15 # 输出变量 v4 的值
16 echo "$v4"

在上面的代码中，第4行～第7行分别定义了4个变量，并且分别各自赋予了不同的初始值。第10行～第16分别输出变量v1～v4的值。该程序的执行结果如下：

[root@linux chapter3]# ./ex3-11.sh
Linux
RedHat Linux
RedHat Linux x86_64
12345

可以得知，上面的程序分别输出了4个变量的值。其中，v3中的环境变量$HOSTTYPE被具体的值取代。

另外，在Shell中，字符串是可以直接连接在一起的，如果对【例3-11】进行修改，将最后的变量v4的输出语句，即代码的第13行改成以下代码：

echo "$v4abc"

则程序在执行时会出现错误，如下：

[root@linux chapter3]# ./ex3-11.sh
Linux
RedHat Linux
RedHat Linux x86_64

从上面的执行结果可以得知，ex3-11.sh脚本文件只正确输出了前3个变量的值，而最后一个变量的值变成了空字符串。

注意：在上面的执行结果中，变量v4的值也输出了，只是变成了空字符串，并非没有输出。

出现这种情况的原因在于，Shell在进行解释代码时，遇到“$v4abc”这个字符串之后，并不知道具体的变量名到底是什么，因此，它会将整个字符串作为一个变量名来使用。在本程序中，变量v4abc当然是没有定义的了。所以导致ex3-11.sh在最后只输出了一个空行。

为了能够使Shell正确地界定变量名，避免混淆，用户在引用变量时可以使用大括号将变量名括起来，如下：

echo "${v4}abc"

那么以上程序就会得到正确的结果，如下：

[root@linux chapter3]# ./ex3-11.sh
Linux
RedHat Linux
RedHat Linux x86_64
12345abc

实际上，本书非常建议用户使用大括号来将变量名进行明确地界定，因为这是一种非常正式的、完整的书写方法，而用户通常使用省略大括号的形式只是一种简写。

3.2.3　清除变量

当某个Shell变量不再需要时，可以将其清除。当变量被清除后，其所代表的值也会消失。清除变量使用unset语句，其基本语法如下：

unset variable_name

其中，参数varibale_name表示要清除的变量的名称。

【例3-12】演示Shell变量清除方法，并且观察在清除前后变量值的变化，代码如下：

01 #-----------------------------/chapter3/ex3-12.sh------------------
02 #! /bin/bash
03
04 # 定义变量 v1
05 v1="Hello world"
06 # 输出 v1 的值
07 echo "$v1"
08 # 清除变量
09 unset v1
10 echo "the value of v1 has been reset"
11 # 再次输出变量的值
12 echo "$v1"

在上面的代码中，第5行定义了一个名称为v1的变量，第7行输出该变量的值。第9行使用unset语句将该变量清除，然后在第12行再次输出该变量的值。

该程序的执行结果如下：

[root@linux chapter3]# ./ex3-12.sh
Hello world
the value of v1 has been reset

从上面的执行结果可以得知，在执行第9行的unset语句之后，变量v1已经被清除掉了，因此，第12行的echo语句仅仅输出了空值。

3.3　引用和替换

通常对于弱类型的程序设计语言来说，变量的功能都相对比较单薄。但是对于Shell来说，变量的功能却非常强大。为了加强变量的功能，Shell对变量的使用方法进行了极大地扩展。本节将介绍其中的引用和替换。

3.3.1　引用

所谓引用，是指将字符串用引用符号包括起来，以防止其中的特殊字符被Shell解释为其他涵义。特殊字符是指除了字面意思之外还可以解释为其他意思的字符。例如，在Shell中“$”符号的本身涵义是美元符号，其ASCII码值为十进制36。除了这个涵义之外，前面已经讲过，“$”符号还可以用了获取某个变量的值，即变量替换。星号“*”也是一个特殊的字符，星号可以用来作为通配符使用。

【例3-13】演示星号通配符的使用方法，命令如下：

[root@linux chapter3]# ll ex*
-rwxr-xr-x 1 root root 179 Jan 7 11:51 ex3-10.sh
-rwxr-xr-x 1 root root 114 Jan 7 15:49 ex3-11.sh
-rwxr-xr-x 1 root root 100 Jan 7 16:15 ex3-12.sh
…

在上面的ll命令中，参数ex*表示列出以ex开头的所有的文件。但是，如果使用双引号将其引用起来，则其涵义会发生变化，如下：

[root@linux chapter3]# ll "ex*"
ls: cannot access ex*: No such file or directory

从上面的执行结果可以得知，当参数ex*被双引号引用起来之后，ll命令会将其作为一个普通的文件名来对待。但是当前目录中并不存在名称为ex*的文件，所以，会给出没有该文件或者目录的提示信息。

对比这两次的执行结果，可以发现，其中起作用的是双引号，正是它改变了星号的意义。而这正是引用的目的。

在Shell中，一共有4种引用符号，如表3-3所示。

表3-3　常用引用符号

引用符号 | 说　明
双引号 | 除美元符号、单引号、反引号和反斜线之外，其他所有的字符都将保持字面意义
单引号 | 所有的字符都将保持字面意义
反引号 | 反引号中的字符串将被解释为Shell命令
反斜线 | 转义字符，屏蔽后的字符的特殊意义

注意：在Linux中，ll命令是ls-l命令的别名。

3.3.2　全引用

在Shell语句中，当一个字符串被单引号引用起来之后，其中所有的字符，除单引号本身之外，都将被解释为字面意义，即字符本身的涵义。这意味着被单引号引用起来的所有字符都将被解释为普通的字符，因此，这种引用方式称为全引用。

【例3-14】演示全引用的使用方法，代码如下：

01 #-----------------------------/chapter3/ex3-14.sh------------------
02 #! /bin/bash
03
04 # 定义变量 v1
05 v1="chunxiao"
06 # 输出含有变量名的字符串
07 echo 'Hello, $v1'

在上面的代码中，第5行定义了一个字符串变量v1，第7行使用echo语句输出一个含有变量名v1的用单引号引用起来的字符串。前面已经讲过，单引号表示全引用，因此第7行会原封不动地输出单引号中的字符串。该程序的执行结果如下：

[root@linux chapter3]# ./ex3-14.sh
Hello, $v1

3.3.3　部分引用

对于单引号来说，被其引用起来的所有的字符都将被解释为字面意义。而对于双引号来说，情况会有所不同。如果用户使用双引号将字符串引用起来，则其中所包含的字符除美元符号（$）、反引号（`），以及反斜线（\）之外的所有其他的字符，都将被解释为字面意义，这称为部分引用。也就是说，在部分引用中，“$”、“`”和“\”仍然拥有特殊的涵义。例如，“$”符号仍然可以用来引用变量的值。

【例3-15】演示部分引用的使用方法，本例将【例3-14】中的单引号改为双引号，代码如下：

01 #-----------------------------/chapter3/ex3-15.sh------------------
02 #! /bin/bash
03
04 # 定义变量
05 v1="chunxiao"
06 # 输出变量的值
07 echo "Hello, $v1"

以上代码的执行结果如下：

[root@linux chapter3]# ./ex3-15.sh
Hello, chunxiao

从上面的执行结果可以得知，echo语句中的变量名已经被变量的值取代。

3.3.4　命令替换

所谓命令替换，是指在Shell程序中，将某个Shell命令的执行结果赋给某个变量。在bash中，有两种语法可以进行命令替换，分别使用反引号和圆括号，如下：

'shell_command'
$(shell_command)

以上两种语法是等价的，用户可以根据自己的习惯来选择使用。

【例3-16】演示反引号的使用方法，代码如下：

01 #-----------------------------/chapter3/ex3-16.sh------------------
02 #! /bin/bash
03
04 # 变量替换
05 v1='pwd'
06 # 输出变量的值
07 echo "current working directory is $v1"

在上面的代码中，第5行将Shell命令pwd的执行结果赋给变量v1，然后在第7行输出该变量的值。

该程序的执行结果如下：

[root@linux chapter3]# ./ex3-16.sh
current working directory is /root/chapter3

从上面的执行结果可以得知，命令pwd的执行结果已经成功地替换了变量名v1。

注意：Shell会将反引号中的字符串当做Shell命令，如果输入了错误的命令，则会出现“command not found”的错误提示。

在上面的代码中，使用了反引号，如果使用圆括号，则对代码进行相应的修改，如下：

01 #! /bin/bash
02
03 v1=$(pwd)
04 echo "current working directory is $v1"

以上代码的执行结果与例【3-15】完全相同。

3.3.5　转义

顾名思义，转义的作用是转换某些特殊字符的意义。转义使用反斜线表示，当反斜线后面的一个字符具有特殊的意义时，反斜线将屏蔽该字符的特殊意义，使得Shell按照该字符的字面意义来解释。

例如，我们已经知道，$符号是一个特殊的符号，通常加在变量名的前面，用来获取变量的值。在下面的两个命令中，第一个命令可以获得变量的值，而第二个命令则直接输出了变量名，如下：

[root@linux chapter3]# echo $SHELL
/bin/bash
[root@linux chapter3]# echo \$SHELL
$SHELL

为什么会得到这样的结果呢？这是因为在第二个命令的$符号前面使用了转义字符“\”，从而使得紧跟在后面的$符号失去了其特殊的作用，变成了一个普通的字符，即仅仅表示$符号本身而已。

3.4　小结

本章详细介绍了Shell语言中的变量和引用的相关知识，主要内容包括变量涵义、变量的命名规则、变量的定义、变量的作用域、系统变量和环境变量、变量赋值和清除、全引用和部分引用、命令替换，以及转义等。重点在于掌握好变量的定义方法、变量的作用域、常用的系统变量和环境变量的使用方法，以及全引用和部分引用。在下一章中，将介绍条件测试和判断语句。

第4章
条件测试和判断语句

作为一个实用的、能够解决实际问题的Shell程序，必须能够根据执行过程中的各种实际情况来做出正确的选择。实际上，这也是各种程序设计语言都必须解决的一个问题。

Shell提供了一系列的条件测试来处理程序执行过程中的各种情况，并做出进一步的操作。本章将介绍各种条件测试的基本语法，以及Shell程序的基本流程控制语句判断语句的使用方法。

本章主要涉及的知识点有如下所述。

- 条件测试：主要介绍Shell程序中的文件、变量、字符串数值，以及逻辑等条件测试。

- 条件判断语句：介绍基本的if、if else，以及if elif语句的使用方法。

- 多条件判断语句case：主要介绍case语句的基本语法，以及使用case语句来解决一些实际问题。

- 运算符：主要介绍Shell中常用的运算符的使用方法，例如算术运算符、位运算符，以及自增、自减运算符等。

4.1　条件测试

为了能够正确处理Shell程序运行过程中遇到的各种情况，Shell提供了一组测试运算符。通过这些运算符，Shell程序能够判断某种或者几个条件是否成立。条件测试在各种流程控制语句，例如判断语句和循环语句中发挥了重要的作用，所以，了解和掌握这些条件测试是非常重要的。本节将介绍常见的条件测试。

4.1.1　条件测试的基本语法

在Shell程序中，用户可以使用测试语句来测试指定的条件表达式的条件的真或者假。当指定的条件为真时，整个条件测试的返回值为0；反之，如果指定的条件为假，则条件测试语句的返回值为非0值。对于熟悉其他的程序设计语言，例如Java或者C的用户来说，这一点非常重要，因为在这些语言中，通常情况下，条件表达式的值为真时，整个表达式的值为非0值；而当条件表达式的值为假时，这个表达式的值为0。

注意：在Shell程序中，条件测试中的指定条件为真时，条件测试的返回值为0。这主要是为了保持与Shell程序的退出状态一致。当某个Shell程序成功执行后，该进程会返回一个0值；而如果该程序执行错误，则会返回一个非0值。

条件测试的语法有两种，分别是test命令和[命令，下面对这两种语法进行介绍。

在绝大部分的Shell中，test都是作为一个内部命令出现的。当然，在某些Shell中，同时也提供了一个相同名称的外部命令。但是，在使用test命令进行条件测试的时候，如果没有指定绝对路径，则使用的都是内部命令。test命令的语法如下：

test expression

其中，参数expression表示需要进行测试的条件表达式，可以由字符串、整数、文件名，以及各种运算符组成。例如，下面的表达式都是有效的条件表达式：

- 1 -eq 2
- ‘string’
- -z ‘string’
- -e file

在上面的表达式中，第1个用来测试两个整数是否相等；第2个和第3个都是用来测试字符串是否为空；第4个用来测试指定的文件是否存在。关于这些表达式的语法，将在后面的内容中详细介绍。

除了使用test命令之外，还有一个内部命令同样可以进行条件测试，该命令的名称为[，这是一个左方括号。与test命令一样，[命令的作用也是对后面的条件表达式进行测试，但是为了增加程序的可读性，Shell要求在条件表达式后面追加一个右方括号]来与前面的[命令配对。因此，[命令的语法如下：

[ expression ]

在上面的语法中，[是条件测试命令，参数expression是一个条件表达式。其中expression的语法与上面的test命令中的语法完全相同。条件表达式和左右方括号之间都必须有一个空格。

注意：作为初学者，必须弄清楚一个事实，那就是左方括号[是一个Shell命令，而非条件表达式的一部分。而命令与参数之间必须保留一个空格。正因为如此，在上面的语法中，expression与左右方括号之间也必须都保留一个空格。

4.1.2　字符串测试

在任何程序设计语言中，字符串都是最常见的数据类型之一。通常情况下，对于字符串的操作主要包括判断字符串变量是否为空，以及两个字符串是否相等。在Shell中，用户可以通过5种运算符来对字符串进行操作，如表4-1所示。

表4-1　字符串运算符

运　算　符 | 说　明
string | 判断指定的字符串是否为空
string1 = string2 | 判断两个字符串string1和string2是否相等
string1 != string2 | 判断两个字符串string1和string2是否不相等
-n string | 判断string是否是非空串
-z string | 判断string是否是空串

对于表4-1中的第一种运算符，也就是单独给定一个字符串的形式，只能使用test命令来测试是否为空串，而不能使用方括号的方式来测试。对于其他4种运算符，都可以使用test命令或者方括号来进行测试。另外，在进行字符串比较的时候，用引号将字符串界定起来是一个非常好的习惯，即使参与测试的字符串为空串。

【例4-1】演示字符串测试的使用方法，代码如下：

01 # 定义字符串变量
02 [root@linux chapter4]# a="abc"
03 # 使用 test 命令测试变量 $a 是否为空串
04 [root@linux chapter4]# test $a
05 # 通过 echo 命令和 $? 环境变量输出测试结果
06 [root@linux chapter4]# echo $?
07 # 输出结果为 0 ，表示变量 $a 的值不为空
08 0
09 # 使用 -n 运算符测试变量 $a 是否不为空
10 [root@linux chapter4]# test -n "$a"
11 # 输出测试结果
12 [root@linux chapter4]# echo $?
13 # 测试结果为 0 表示变量为非空字符串
14 0
15 # 使用 -z 运算符测试变量 $a 是否为空串
16 [root@linux chapter4]# test -z "$a"
17 # 测试结果为 1 ，表示变量 $a 不是空串
18 [root@linux chapter4]# echo $?
19 1

在上面的例子中，我们都是通过运算符来对一个字符串进行测试。这在后面介绍的Shell程序流程控制中，使用非常普遍。

注意：在给变量赋值的时候，等号“=”左右两边一定不要有空格；否则，Shell会把空格前面的字符串当做Shell命令。

另外，对于两个字符串的测试也是很常见的，下面举例说明。

【例4-2】演示Shell中比较两个字符串值的方法，命令如下：

01 # 定义变量 $a
02 [root@linux chapter4]# a="hello"
03 # 定义变量 $b
04 [root@linux chapter4]# b="world"
05 # 比较 2 个字符串是否相等
06 [root@linux chapter4]# [ "$a" = "$b" ]
07 # 输出测试结果为 1 ，表示 $a 和 $b 不相等
08 [root@linux chapter4]# echo $?
09 1
10 # 测试 $a 和 $b 是否不相等
11 [root@linux chapter4]# test "$a" != "$b"
12 # 输出测试结果为 0 ，表示 $a 和 $b 不相等
13 [root@linux chapter4]# echo $?
14 0

从上面的例子可以得知，包含在双引号中的变量名在引用的时候会被变量的值取代，这称为变量的部分引用。关于变量的引用，已经在3.3节中介绍。

注意：$?是一个系统变量，用来获取Shell命令的执行状态。如果执行成功，则返回值为0；否则，返回1。在没有学习流程控制语句之前，本书先使用$?来获取条件测试的结果。

接下来，再看一些比较特殊的例子。在实际的操作系统环境中，经常会遇到字符串中含有空格的情况。在Shell中，空格也是字符串本身的一部分。

【例4-3】说明空格对于字符串比较结果的影响，代码如下：

01 # 定义字符串变量 $a
02 [root@linux chapter4]# a="Hello world. "
03 # 定义字符串变量 $b
04 [root@linux chapter4]# b="Hello world."
05 # 测试 $a 和 $b 是否相等
06 [root@linux chapter4]# [ "$a" = "$b" ]
07 [root@linux chapter4]# echo $?
08 1

在上面的例子中，字符串变量$a的末尾比$b多了一个空格。从而导致这两个字符串变量的值并不相等。在实际的编程过程中，一定要注意这种情况，避免由于空格问题导致程序错误。

另外，在Shell中，字符串中的字母也是区分大小写的，请参见下面的例子。

【例4-4】演示字母大小写对于字符串比较结果的影响。在下面的命令中，参与比较的两个字符串的区别仅仅在于字母h的大小写方面，如下所示。

01 # 变量 $a 的第 1 个字母为大写的 H
02 [root@linux chapter4]# a="Hello world."
03 # 变量 $b 的第 1 个字母为小写的 h
04 [root@linux chapter4]# b="hello world."
05 [root@linux chapter4]# [ "$a" = "$b" ]
06 # 变量 $a 和 $b 的值不相等
07 [root@linux chapter4]# echo $?
08 1

最后，再介绍一个初学者经常遇到的问题。我们执行以下代码：

01 [root@linux chapter4]# a="Apple"
02 [root@linux chapter4]# b="Orange"
03 [root@linux chapter4]# test "$a"="$b"
04 [root@linux chapter4]# echo $?
05 0

从表面上看，这个例子与前面介绍的几个例子并没有太大的区别。其中变量$a的值是“Apple”，变量$b的值是“Orange”。但是，第5行的比较结果的值为0。我们知道，0意味着这两个字符串的值相等。而在第1行和第2行中，这两个变量的值明显是不同的。那么究竟是哪个地方出现了问题呢？

实际上，问题就出在第3行中。根据Shell的要求，运算符的左右两边必须保留空格。而在上面的例子中，表达式被写成了以下形式：

test "$a"="$b"

在这种情况下，Shell并不认为其中的等号“=”是一个字符串运算符，而看做是一个普通的字符。所以，上面的例子实际上相当于执行以下字符串测试：

[root@linux ~]# test "Apple=Orange"
[root@linux ~]# echo $?
0

也就是说，实际上是测试某个字符串是否为空。上面的字符串“Apple=Orange”当然不是空串，所以，无论变量$a和$的值是什么，上面的例子的测试结果永远是0。

注意：在测试运算符“=”、“!=”，以及“-z”等符号的左右两边，一定含有一个空格。

4.1.3　整数测试

在程序设计中，两个整数值的比较是经常遇到的情况，也是算术运算中比较简单的运算。例如，当某个Shell程序执行结束后，会返回一个整数值，用户可以根据这个返回值是否大于0来判断程序是否执行成功。

与字符串测试类似，整数测试也有两种形式的语法：

test number1 op number2

或者

[ number1 op number2 ]

其中，number1和number2分别表示参与比较的两个整数，可以是常量或者变量。op表示运算符。

通常情况下，用户会比较两个整数值是否相等或者哪个数值比较大。对于这些运算， Shell都分别提供了相应的运算符。例如，用户可以使用-eq运算符来比较两个整数值是否相等，使用-ne运算符来比较两个整数值是否不相等，使用-gt运算符来测试某个整数是否大于另外一个整数。表4-2列出了常见的整数运算符及其使用方法。

表4-2　常见整数运算符

运算符 | 说　明
number1 -eq number2 | 比较number1是否等于number2。如果相等，测试结果为0
number1 -ne number2 | 比较number1和number2是否不相等。如果number1和number2不相等，测试结果为0
number1 -gt number2 | 比较number1是否大于number2。如果number1大于number2，测试结果为0
number1 -lt number2 | 测试number1是否小于number2。如果number1小于number2，测试结果为0
number1 -ge number2 | 测试number1是否大于等于number2。如果number1大于等于number2，测试结果为0
number1 -le number2 | 测试number1是否小于等于number2。如果number1小于等于number2，测试结果为0

在整数测试中，最简单的方法就是直接比较两个常数的大小，如下面的例子所示。

【例4-5】比较两个整数是否相等，命令如下：

01 # 比较整数 12 和 14 是否相等
02 [root@linux chapter4]# [ 12 -eq 14 ]
03 [root@linux chapter4]# echo $?
04 1

从上面的执行结果可以得知，12并不等于14。

【例4-6】比较两个整数的大小，命令如下：

01 # 比较 12 是否大于 14
02 [root@linux chapter4]# test 12 -gt 14
03 [root@linux chapter4]# echo $?
04 1
05 # 比较 12 是否小于 14
06 [root@linux chapter4]# test 12 -lt 14
07 [root@linux chapter4]# echo $?
08 0

从上面第4行的输出结果中可以得知，12并不大于14，因此-gt运算的结果为1；从第8行的输出结果可以得知，12小于14，因此-lt运算的结果为0。

下面再介绍一下参与比较的一方是整数变量，而非常数的情况，请参见下面的例子。

【例4-7】比较变量与常数的大小，命令如下：

01 # 定义整数变量 $x
02 [root@linux chapter4]# x=365
03 # 比较变量 $x 的值是否等于 365
04 [root@linux chapter4]# test "$x" -eq 365
05 [root@linux chapter4]# echo $?
06 0
07 # 比较变量 $x 的值是否大于 364
08 [root@linux chapter4]# test "$x" -gt 364
09 [root@linux chapter4]# echo $?
10 0

从第6行可以得知，变量$x的值等于365；而第10行的结果表示变量$x的值大于364。

在许多情况下，参与比较的两个操作数往往都是变量，对于这种情况，其操作方法与前面基本相同，下面的例子就演示了这种情况。

【例4-8】比较两个变量值的大小。在本例中，首先定义两个整数变量$x和$y，然后判断变量$x是否小于或者等于变量$y，代码如下：

01 # 定义变量 $x
02 [root@linux chapter4]# x=123
03 # 定义变量 $y
04 [root@linux chapter4]# y=36
05 # 判断 $x 是否小于或者等于 $y
06 [root@linux chapter4]# [ "$x" -le "$y" ]
07 [root@linux chapter4]# echo $?
08 1

从上面第8行的执行结果可以得知，第6行的表达式所表示的条件为假。

对于初学者来说，经常犯的一个错误就是错误地使用运算符。在进行整数比较的时候，一定要是表4.2列出的运算符。但是，由于受到其他程序设计语言的影响，初学者可能会使用字符串运算符中的“=”和“!=”来进行整数比较，下面的例子就说明了这种情况。

【例4-9】使用“=”比较两个整数会导致错误结果，命令如下：

01 # 使用“ = ”运算符比较 2 个整数
02 [root@linux chapter4]# [ 12 = 13 ]
03 [root@linux chapter4]# echo $?
04 1
05 # 使用 -eq 运算符比较 2 个整数
06 [root@linux chapter4]# [ 12 -eq 13 ]
07 [root@linux chapter4]# echo $?
08 1

在上面的例子中，尽管两次比较的结果都是1，表示这两个值不相等。但是，这两次的比较的过程却有本质的区别。其中，第2行的比较实际上是将这两个整数作为字符串来比较的，因此，相当于以下运算：

[root@linux chapter4]# [ "12" = "13" ]

而第6行则是真正地将12和13作为整数值来比较。

另外，表4-2列出的运算符仅仅是针对整数运算的。如果使用了非整数，则会出错，如【例4-10】所示，代码如下：

01 # 为变量 $x 赋一个小数值
02 [root@linux chapter4]# x=12.3
03 [root@linux chapter4]# y=12
04 [root@linux chapter4]# [ "$x" -gt "$y" ]
05 # 给出错误提示
06 -bash: [: 12.3: integer expression expected

上面例子的第6行给出了错误信息，提示用户需要整数表达式。

注意：字符串运算符和整数运算符不可以混用，初学者一定要注意。

4.1.4　文件测试

在任何程序设计语言中，文件的操作都是必不可少的一部分。同样，Shell也提供了许多与文件有关的操作符。通过这些操作符，用户可以对文件的状态进行检测。例如，判断文件是否存在，以及文件是否可读写等。

文件测试的语法如下：

test op file

或者

[ op file ]

在上面的语法中，op表示操作符，常用的操作符参见表4-3，其中file表示要测试的文件名。

表4-3　常用文件操作符

操作符 | 说　明
-a file | 文件是否存在。如果文件file存在，则结果为0
-b file | 文件是否存在，且为块文件。如果文件file是一个已经存在的块文件，则结果为0
-c file | 文件是否存在，且为字符文件。如果file是一个已经存在的字符文件，则结果为0
-d file | 文件是否存在，且为目录。如果文件file是一个已经存在的目录，则结果为0
-e file | 同-a操作符
-s file | 文件的长度是否大于0或者文件为非空文件。如果file是一个非空文件，则结果为0
-f file | 文件存在，并且为常规文件。如果file是一个已经存在的常规文件，则结果为0
-w file | 文件是否存在且可写。如果文件file是一个可写文件则结果为0
-L file | 文件是否存在，并且为符号链接。如果文件file是一个已经存在的符号链接，则结果为0
-u file | 文件是否设置suid位。如果文件file已经设置了suid位，则结果为0
-r file | 文件是否存在，并且可读。如果文件file是一个已经存在的可读文件，则结果为0
-x file | 文件是否存在，并且可执行。如果文件file是一个已经存在的可执行文件，则结果为0

从表4-3中可以看出，文件操作符的功能主要有3个方面，分别是检测文件是否存在、文件的类型，以及文件的访问权限。下面分别对这3个方面的功能进行介绍。

在Shell程序中，用户可以通过操作符检测某个特定的文件是否存在。为了便于介绍，我们首先看一下当前目录的内容，如下所示。

[root@linux chapter4]# ll
total 12
drwxr-xr-x 2 root root 4096 Nov 9 14:50 dir1
-rw-r--r-- 1 root root 12 Nov 9 14:49 file1
-rw-r--r-- 1 root root 0 Nov 9 14:49 file2
-rwxr-xr-x 1 root root 30 Nov 9 15:06 hello.sh

从上面的执行结果可以得知，当前目录中包含一个目录、两个常规文件，以及一个可执行文件。

【例4-11】通过文件操作符来判断文件是否存在，代码如下：

01 [root@linux chapter4]# test -a file1
02 [root@linux chapter4]# echo $?
03 0

在上面的代码中，第1行使用test命令配合-a操作符来判断名称为file1的文件是否存在。从第3行的结果中可以得知，这个文件是存在的。这与我们上面列出的目录内容是一致的。

【例4-12】判断文件是否存在。在本例中，我们指定一个不存在的文件，以验证操作符能否返回正确的值，代码如下：

01 [root@linux chapter4]# [ -a file3 ]
02 [root@linux chapter4]# echo $?
03 1

从上面的执行结果可以得知，由于当前目录中并不存在名称为file3的文件，所以第3行的输出结果为1。

在某些情况下，用户可能需要判断指定的文件的类型，例如，该文件是否为目录，是否为常规文件，是否为块文件，以及是否为空文件等。这些信息都可以通过操作符来获取，下面分别举例说明。

【例4-13】通过操作符判断各种文件类型，代码如下：

01 # 判断文件 dir1 是否为目录
02 [root@linux chapter4]# test -d dir1
03 [root@linux chapter4]# echo $?
04 0
05 # 判断文件 file1 是否为常规文件
06 [root@linux chapter4]# test -f file1
07 [root@linux chapter4]# echo $?
08 0
09 # 判断 file2 是否为非空文件
10 [root@linux chapter4]# test -s file2
11 [root@linux chapter4]# echo $?
12 1
13 # 判断 file1 是否为块文件
14 [root@linux chapter4]# test -b file1
15 [root@linux chapter4]# echo $?
16 1
17 # 判断 /dev/sda 是否为块文件
18 [root@linux chapter4]# test -b /dev/sda
19 [root@linux chapter4]# echo $?
20 0
21 # 判断 /dev/tty 是否为字符文件
22 [root@linux chapter4]# test -c /dev/tty
23 [root@linux chapter4]# echo $?
24 0

在上面的代码中，第2行使用-d操作符判断指定的文件是否为目录，由于dir1是一个已经存在的目录，所以该操作符的执行结果为0。第6行使用-f操作符判断指定的文件是否为常规文件，由于file1是一个普通的文件，所以该操作符的执行结果为0。第10行使用-s操作符判断指定的文件是否为非空文件，由于file2是一个0字节的文件，所以该操作符的执行结果为1。第14行使用-b操作符判断指定的文件是否为块文件，由于file1是一个常规文件，所以该操作符的执行结果为1；而第18行中的/dev/sda是一个块文件，所以该操作符的执行结果为0。第22行使用-c操作符判断指定的文件是否为字符文件，由于控制终端/dev/tty是一个字符文件，所以该操作符的执行结果为0。

通过操作符还可以判断文件的访问权限。在Linux中，文件的访问主要包括读、写、执行，以及是否设置用户ID（setuid）和组ID（setgid）标志等。

【例4-14】通过文件测试判断用户对文件的访问权限，代码如下：

01 # 判断文件 file1 是否可写
02 [root@linux chapter4]# test -w file1
03 [root@linux chapter4]# echo $?
04 0
05 # 判断文件 file1 是否可读
06 [root@linux chapter4]# test -r file1
07 [root@linux chapter4]# echo $?
08 0
09 # 判断文件 file1 是否可执行
10 [root@linux chapter4]# test -x file1
11 [root@linux chapter4]# echo $?
12 1
13 # 判断文件 hello.sh 是否可执行
14 [root@linux chapter4]# test -x hello.sh
15 [root@linux chapter4]# echo $?
16 0

在上面的命令中，第2行使用-w操作符判断当前用户对于指定的文件file1是否拥有写入的权限。由于文件file1的权限为-rw-r--r--，所有者拥有写入的权限，所以该操作符的执行结果为0。第6行是用-r操作符来判断当前用户是否拥有对于文件file1的读取权限，从上面的文件权限中可以得知，所有的用户都拥有读取权限，所以该操作符的执行结果为0。第10行使用-x操作符判断当前用户是否拥有文件file1的执行权限，从上面的权限中可以得知，任何用户都没有执行该文件的权限，所以该操作符的执行结果为1；而文件hello.sh的权限为-rwxr-xr-x，这意味着所有的用户都拥有执行该文件的权限，所以第14行中的-x操作符的执行结果为0。

在Linux中，文件的访问权限可以使用chmod命令来设置。关于该命令的详细使用方法，请参考有关书籍。下面举一个简单的例子来说明该命令的使用方法。

【例4-15】使用chmod命令为hello.sh文件设置setuid权限，这样的话，执行该文件的用户就会临时拥有该文件所有者所拥有的权限。命令如下：

01 [root@linux chapter4]# chmod u+s hello.sh
02 [root@linux chapter4]# ll
03 total 12
04 drwxr-xr-x 2 root root 4096 Nov 9 14:50 dir1
05 -rw-r--r-- 1 root root 12 Nov 9 14:49 file1
06 -rw-r--r-- 1 root root 0 Nov 9 14:49 file2
07 -rwsr-xr-x 1 root root 30 Nov 9 15:06 hello.sh
08 [root@linux chapter4]# test -u hello.sh
09 [root@linux chapter4]# echo $?
10 0

第1行使用chmod命令的u+s选项为hello.sh文件设置了setuid权限。我们可以从第7行的文件访问权限属性中的得知，该文件的权限属性为-rwsr-xr-x。第8行使用-u操作符判断hello.sh文件是否设置了用户ID权限，从第10行的输出可以得知，-u操作符返回了正确的结果。

注意：除了表4-3列出的文件操作符之外，还有其他一些文件操作符，例如-nt表示某个指定的文件修改时间是否比另外一个文件晚，-ot表示某个指定的文件的修改时间是否比另外一个文件早。当执行成功时，这些操作符的执行结果都是0。

在Shell编程中，文件测试应用非常广泛。例如，当在程序中创建了一个新的文件之后，我们可以使用-a或者-e操作符来判断是否创建成功。另外，在写入文件的时候，我们也可以先通过-w操作符判断当前用户是否拥有写入该文件的权限，从而保证程序的正常执行。

4.1.5　逻辑操作符

前面介绍的字符串测试、整数测试，以及文件测试都是提供了一个测试条件。而在Shell编程中，经常遇到同时判断多个条件的情况。Shell中的逻辑操作符可以将多个不同的条件组合起来，从而构成一个复杂的条件表达式。常用的逻辑操作符如表4-4所示。

表4-4　常用的逻辑操作符

操　作　符 | 说　明
! expression | 逻辑非，条件表达式expression的值为假，则该操作符的运算结果为真
expression1 -a expresion2 | 逻辑与，条件表达式expression1和expression2的值都是真时，整个表达式的值才为真
expression1 -o expression2 | 逻辑或，条件表达式expression1或者expression2的值有一个为真时，整个表达式的值就为真

在表4-4中，一共列出了3个逻辑操作符，分别为逻辑非、逻辑与和逻辑或。其中，逻辑非表示取反，使用感叹号“!”表示。逻辑非只连接一个条件表达式，其执行结果是将该条件表达式的值变成相反的值。因此，如果条件表达式expression的值为真的话，则应用“!”操作符之后，整个表达式的值就为假，反之亦然。

逻辑与使用“-a”表示，连接两个条件表达式，只有expression1和expression2的值都为真时，整个表达式的值才为真。

逻辑或使用“-o”表示，同样连接两个条件表达式，但是与逻辑与不同之处在于，只要expression1和expression2中有一个条件为真，整个表达式的值就为真。

正因为逻辑与和逻辑或有上述特点，所以Shell在处理逻辑操作符时，不一定会将整个表达式中的各个条件都计算完成才会得到整个表达式的值。在逻辑与中，如果第一个表达式expression1的值为假，则无论expression2的值是否为假，整个表达式的值必定为假，因此，就无需再去计算expression2的值；在逻辑或中，如果第一个表达式expression1的值为真，则整个表达式的值必定为真，此时也无需计算expression2的值。

【例4-16】判断整数变量$a的值是否大于20，并且小于60，代码如下：

01 # 定义变量 $a
02 [root@linux chapter4]# a=35
03 [root@linux chapter4]# test "$a" -gt 20 -a "$a" -lt 60
04 [root@linux chapter4]# echo $?
05 0

从第5行的输出结果中，可以得知整个表达式的值为真。

【例4-17】通过条件测试来判断当前用户是否拥有某个文件的写入权限，代码如下：

01 [root@linux chapter4]# [ -e file1 -a -w file1 ]
02 [root@linux chapter4]# echo $?
03 0

在上面的代码中，第1行使用-e操作符和-w操作符，分别判断文件file1是否存在和可写，然后使用-a操作符将这两个条件连接起来。第3行的输出结果表示文件file1同时满足这两个条件。

4.2　条件判断语句

在程序执行的过程中，总会遇到各种各样的情况。而作为一个功能相对完善的程序来说，应该可以根据不同的情况来选择执行不同的代码。此时，我们需要用到条件判断语句。条件判断语句是一种最简单的流程控制语句，该语句使得程序根据不同的条件来执行不同的程序分支。本节将介绍Shell程序设计中的简单的条件判断语句。

4.2.1　使用简单的if语句进行条件判断

条件判断语句使用if语句来实现。最简单的if语句的语法如下：

if expression
then
statement1
statement2
...
fi

在上面的语法中，expression通常代表一个条件表达式，但是也可以是Shell命令。因为在Shell中，每个命令都会有一个退出状态码，如果某个命令正常退出了，则其退出状态为0；如果执行错误，则其退出状态为通常为非0。这种规定与其他程序设计语言有所不同，因为在绝大部分的程序设计语言里面，0通常表示假，而非0则表示真。所以，读者在学习Shell程序设计的时候，一定要注意这点。

在if语句中，只有当expression的值为真时，才执行then子句后面的语句。then子句后面可以跟随多条语句，例如statement1、statement2……。当expression的值为真时，会依次执行这些语句。由于Shell没有提供大括号{}来表示代码块，所以需要使用fi关键字来表示if结构的结束。

为了使得代码更加紧凑，在某些情况下，我们可以将if子句和then子句写在同一行中。此时，需要在expression表达式后面加上一个分号，如下：

if expression; then
statement1
statement2
fi

分号的作用是表示if子句已经结束，后面的代码是then子句。

注意：在Shell程序中，如果想要将多条命令放在同一行中，则需要使用分号将其隔开。

【例4-18】通过条件测试判断文件类型，代码如下：

01 #-----------------------------/chapter4/ex4-18.sh------------------
02 #! /bin/sh
03 # 使用条件测试判断 /bin/bash 是否是一个常规文件
04 if [ -f /bin/bash ]
05 then echo "/bin/bash is a file"
06 fi

上面的例子非常简单，其中，第4行通过if语句判断/bin/bash是否是一个常规文件。如果是的话，就通过第5行的echo语句输出一行提示信息。

该程序的执行结果如下：

[root@linux chapter4]# ./ex4-18.sh
/bin/bash is a file

从上面的执行结果可以得知，/bin/bash是一个已经存在的常规文件。通常情况下，当我们在Shell程序创建一个文件的时候，需要判断该目标文件是否创建成功，然后再采取下一步的行动。

【例4-19】通过条件测试判断文件是否创建成功，代码如下：

01 #-----------------------------/chapter4/ex4-19.sh------------------
02 #! /bin/sh
03 # 通过 echo 命令和重定向创建一个文件
04 echo "hello world!" > ./msg.log
05 if [ -f ./msg.log ]; then echo "file has been created."; fi

第4行通过echo命令结合重定向操作符在当前目录中创建了一个名称为msg.log的文本文件。第5行通过条件测试来判断msg.log文件是否存在。如果文件已经存在，则表示文件创建成功；否则，表示文件创建失败。出于测试的目的，在上面的例子中，如果文件创建成功，只是简单地输出一行提示信息。

以上程序的执行结果如下：

[root@linux chapter4]# ./ex4-19.sh
file has been crreated.

注意：在if语句中，如果使用条件测试，可以使用test命令，也可以使用方括号[]。

当我们在Shell程序中使用代码创建或者修改某个文件的时候，首先判断一下文件是否创建成功，或者判断文件是否存在是一个非常好的习惯。它有效地避免了许多Shell程序中意想不到的问题。当然，在实际的应用场合，当检测到文件没有创建成功或者不存在的时候，会执行重新创建或者输出错误信息的有关代码，而在上面的例子中，我们只是简单地输出提示信息。

在Shell中，还有一个特殊的命令，称为空命令，其表示方法是一个冒号“:”，该命令不做任何事情，但是它的退出状态永远是0。因此，如果我们将该命令作为if语句中的条件，则会永远执行then子句，如下面的例子所示。

【例4-20】使用空命令作为判断条件，代码如下：

01 #-----------------------------/chapter4/ex4-20.sh------------------
02 #! /bin/sh
03 # 使用空命令作为条件
04 if :; then echo "always true"; fi

由于空命令“:”的退出状态永远是0，所以以上程序的输出结果永远如下：

[root@linux chapter4]# ./ex4-20.sh
alway true.

在实际的编程中，有人喜欢使用&&操作符来代替if语句，如下面的例子所示。

【例4-21】使用&&操作符代替if语句，代码如下：

01 #-----------------------------/chapter4/ex4-21.sh------------------
02 #! /bin/sh
03 # 使用 && 操作符代替 if 语句
04 test "$(whoami)" != "root" && (echo you are using a non-privileged
account; exit 1)

上面代码的第4行是一个比较复杂的语句。在该语句中，首先是一个test条件测试，其测试的内容是whoami命令的执行结果是否不等于root。如果条件为真，则执行&&后面括号中的语句，即输出一行提示信息，然后通过exit语句退出程序。

如果以root用户的身份执行【例4-21】，则没任何输出，如下：

[root@linux chapter4]# ./ex4-21.sh

而当我们切换到其他用户再执行该程序时，则会输出有关提示信息，如下：

[root@linux chapter4]# su - chunxiao
[chunxiao@linux ~]$ ./ex4-21.sh
you are using a non-privileged account

注意：在使用if语句的时候，一定不要漏掉最后的结束标志fi。否则会出现以下错误：

[root@linux chapter4]# ./ex4-21.sh
./ex4-21.sh: line 7: syntax error: unexpected end of file

4.2.2　使用if else语句进行流程控制

尽管简单的if语句的功能已经非常强大，但是在绝大部分的情况下，我们需要面对的不止一种情况。例如，在4.2.1节的最后一个例子中，我们只是判断了文件创建成功的情况，并且当文件成功之后，会输出一行提示信息。那么当文件创建不成功的时候，则没有任何输出信息，这显然对用户是不友好的。因此，我们需要在文件没有创建成功的时候，为用户给出适当的提示信息。

if else语句的基本语法如下：

if expression
then
statement1
statement2
…
else
statement3
statement4
…
fi

在上面的语法中，expression表示if语句的执行条件，可以是条件表达式或者一个Shell命令。如果expression的值为真，则执行then子句中的语句statement1、statement2……。如果expression的值为假，则执行else子句中的语句，包括statement3、statement4……，最后通过fi关键字结束整个if代码块。

【例4-22】说明if else语句的使用方法，代码如下：

01 #-----------------------------/chapter4/ex4-22.sh------------------
02 #! /bin/sh
03
04 # 输出提示信息
05 echo "Please enter a number:"
06 # 从键盘读取用户输入的数字
07 read num
08 # 如果用户输入的数字大于 10
09 if [ "$num" -gt 10 ]; then
10 # 输出大于 10 的提示信息
11 echo "The number is greater than 10."
12 # 否则
13 else
14 # 输出小于或者等于 10 的提示信息
15 echo "The number is equal to or less than 10."
16 fi

在上面的代码中，第5行输出要求用户输入数字的提示信息。第7行通过read语句从键盘读取用户输入的数字。第9行则通过整数条件测试来判断用户输入的数字是否大于10，如果大于10，则通过第11行输出大于1的信息；否则，通过第15行的echo语句输出用户输入的数字小于或者等于10的提示信息。

以上代码的执行结果如下：

01 [root@linux chapter4]# ./ex4-22.sh
02 Please enter a number:
03 6
04 The number is equal to or less than 10.
05 [root@linux chapter4]# ./ex4-22.sh
06 Please enter a number:
07 12
08 The number is greater than 10.

在上面的命令执行的过程中，第3行输入数字6，第4行则输出用户输入的数字小于或者等于10的提示信息。当我们在第7行中输入数字12之后，程序则在第8行输出数字大于10的提示信息。

注意：在【例4-22】中，我们接收了用户输入的数据之后，就直接与给定的数值进行比较了。但是在实际的生产环境中，用户可能没有输入任何东西，而是直接按回车键，或者输入其他非数字的字符。对于这些情况，我们通常可以先用一个外层的if else语句进行判断，使得只有符合要求的数据才能进入内部的if else语句进行比较。这样的话，可以提高程序的健壮性。

实际上，if else语句不仅可以处理比较简单的两个分支的情况，还可以通过嵌套来处理多个分支的情况，下面的例子就说明了这种情况。

【例4-23】演示如何通过if else语句根据学生的百分制成绩来输出五分制成绩，代码如下：

01 #-----------------------------/chapter4/ex4-23.sh------------------
02 #! /bin/sh.
03
04 # 输出提示信息
05 echo "Please enter a score:"
06 # 读取用户输入数据
07 read score
08 # 如果用户没有输入数据，则提示用户重新输入
09 if [ -z "$score" ]; then
10 echo "You enter nothing.Please enter a score:"
11 read score
12 else
13 # 如果用户输入的数据不对，则重新输入
14 if [ "$score" -lt 0 -o "$score" -gt 100 ]; then
15 echo "The score should be between 0 and 100.Please enter again:"
16 read score
17 else
18 # 输出级别 A
19 if [ "$score" -ge 90 ]; then
20 echo "The grade is A."
21 else
22 # 输出级别 B
23 if [ "$score" -ge 80 ]; then
24 echo "The grade is B."
25 else
26 # 输出级别 C
27 if [ "$score" -ge 70 ]; then
28 echo "The grade is C."
29 else
30 # 输出级别 D
31 if [ "$score" -ge 60 ]; then
32 echo "The grade is D."
33 else
34 # 输出级别 E
35 echo "The grade is E."
36 fi
37 fi
38 fi
39 fi
40 fi

在上面的例子中，程序将用户输入的成绩分成A～E共5个等级。以上程序的执行结果如下：

[root@linux chapter4]# ./ex4-23.sh
Please enter a score:
12
The grade is E.
[root@linux chapter4]# ./ex4-23.sh
Please enter a score:
98
The grade is A.
[root@linux chapter4]# ./ex4-23.sh
Please enter a score:
77
The grade is C.

在前面的例子中，我们分别列举了简单的if else和两层嵌套的if else结构的使用方法。实际上，Shell中的if else结构并没有限制嵌套的层数，因此，用户可以根据自己的需要来灵活地使用这种流程控制结构。

4.2.3　使用if elif语句进行多条件判断

在【例4-23】中，我们介绍了嵌套的if else语句的使用方法。尽管这种方法可以很好地处理多个分支的情况，但是读者可能会发现一个非常严重的问题，那就是整个程序的条理看起来很不清楚，经常会出现漏掉fi的情况。为此，我们应该寻找更好地能够处理多分支情况的方法。

除了if else语句之外，Shell还提供了一个if elif语句，通过该语句，用户可以以比较优雅的方式来处理多分支情况。if elif语句的基本语法如下：

if expression1
then
statement1
statement2
…
elif expression2
then
statement3
statement4
…
elif expression3
then
statement5
statement6
…
else
statementn
..
fi

在上面的语法中，expression1表示整个if elfi语句结构中的第1个条件表达式，如果该条件表达式的值为真，则执行第1个then子句中的语句statement1及statement2等；否则，继续下面的判断。如果表达式expression2的值为真，则执行第2个then子句中的语句，以此类推。如果所有的条件表达式的值都为假，则执行最后的else子句中的语句。最后是if elif结构的结束标志fi。

【例4-24】本例对【例4-23】进行改进，使得该程序的可读性更强，代码如下：

01 #-----------------------------/chapter4/ex4-24.sh------------------
02 #! /bin/sh
03
04 echo "Please enter a score:"
05
06 read score
07
08 if [ -z "$score" ]; then
09 echo "You enter nothing.Please enter a score:"
10 read score
11 else
12 if [ "$score" -lt 0 -o "$score" -gt 100 ]; then
13 echo "The score should be between 0 and 100.Please enter again:"
14 read score
15 else
16 # 如果成绩大于 90
17 if [ "$score" -ge 90 ]; then
18 echo "The grade is A."
19 # 如果成绩大于 80 且小于 90
20 elif [ "$score" -ge 80 ]; then
21 echo "The grade is B."
22 # 如果成绩大于 70 且小于 80
23 elif [ "$score" -ge 70 ]; then
24 echo "The grade is C."
25 # 如果成绩大于 60 且小于 70
26 elif [ "$score" -ge 60 ]; then
27 echo "The grade is D."
28 # 如果成绩小于 60
29 else
30 echo "The grade is E."
31 fi
32 fi
33 fi

在上面的程序中，我们用一个if elif语句来代替多层嵌套的if else语句，从而使得整个程序的可读性大大增强。关于该程序的执行方法与【例4-23】完全相同。

4.2.4　使用exit语句退出程序

在Shell程序执行的过程中，有时用户可能需要在满足某个条件的时候退出程序的执行。在这种情况下，我们可以通过if语句配合exit语句来实现。exit语句的基本作用是终止Shell程序的执行。除此之外，exit语句还可以带一个可选的参数，用来指定程序退出时的状态码。exit语句的基本语法如下：

exit status

其中，status参数表示退出状态，该参数是一个整数值，其取值范围为0～255。与其他的Shell命令的一样，Shell程序的退出状态也储存在系统变量$?中，因此，用户可以通过该变量取得Shell程序返回给父进程的退出状态码。

按照惯例，退出状态0意味着脚本成功运行完毕；而非0通常意味着程序执行过程出现某些错误，具体的错误可以根据具体的状态码来判断。正因为如此，用户可以用其他的非0值来给父进程传递不同的消息，根据子进程的成功或者失败，父进程采取不同的动作。如果没有参数给exit语句，则脚本的退出状态码就由脚本中最后执行的语句来决定，也就是由exit语句之前的那条语句的执行状态来决定。

【例4-25】演示在不同的情况下，程序返回不同的状态码，代码如下：

01 #-----------------------------/chapter4/ex4-25.sh------------------
02 #! /bin/sh
03
04 # 使用 echo 语句输出字符串
05 echo hello world!
06
07 # 使用 $? 变量获取 echo 语句的执行状态
08 echo $?
09
10 # 执行一个无效的命令
11 aaa
12 # 输出执行状态
13 echo $?
14
15 # 退出
16 exit 120

在上面的代码中，第5行使用echo语句输出一个字符串。第8行输出上一条语句的退出状态。第11行是一个无效的命令，正因为无效，所以该语句会执行失败，第13行输出了这条无效命令的退出状态码。第16行使用exit语句退出程序，并且指定返回状态为120。

该程序的执行结果如下：

01 [root@linux chapter4]# ./ex4-25.sh
02 hello world!
03 0
04 ./ex4-25.sh: line 11: aaa: command not found
05 127

其中，执行结果第3行输出的是代码中第5行的echo语句的退出状态。执行结果第5行的127是代码中第11行的无效命令的执行状态，我们可以发现，成功执行的语句的退出状态为0，而执行错误的语句的退出状态为非0。因为代码中的最后一行的exit语句执行后整个程序就退出了，所以没有输出状态码，我们可以在Shell中通过以下命令获取：

[root@linux chapter4]# echo $?
120

从上面的执行结果可以发现，ex4-25.sh这个脚本的退出状态码为120。

注意：尽管用户可以在程序中设置自己的退出状态码，但是通常情况下每个状态码都有特定的涵义，因此在返回这些状态码的时候一定要注意，避免执行脚本时产生误解。

通常情况下，exit语句与if语句相互配合可以更灵活地控制程序的流程，请参考下面的例子。

【例4-26】使用if和exit语句，使得程序在适当的时候退出。代码如下：

01 #-----------------------------/chapter4/ex4-26.sh------------------
02 #! /bin/sh
03
04 # 如果文件已经存在，则直接退出
05 if [ -e "$1" ]
06 then
07 echo "file $1 exists."
08 exit 1
09 # 如果文件不存在，则创建文件
10 else
11 touch "$1"
12 echo "file $1 has been created."
13 exit 0
14 fi

在上面的代码中，第5行判断用户输入的文件名是否存在，如果已经存在，则通过第7行输出提示信息，第8行通过exit语句退出程序，并且设置退出状态码为1；如果文件不存在，则通过第11行创建指定的文件，第12行输出提示信息，第13行通过exit语句退出程序，并且设置退出状态码为0。

该程序的执行结果如下：

[root@linux chapter4]# ./ex4-26.sh /bin/ls
file /bin/ls exists.
[root@linux chapter4]# echo $?
1
[root@linux chapter4]# ./ex4-26.sh ./demo.txt
file ./demo.txt has been created.
[root@linux chapter4]# echo $?
0

从上面的执行结果可以得知，当指定的文件存在时，程序的退出状态码为1；当指定的文件不存在的时候，程序的退出状态码为0。这些退出状态码可以提供给其他程序，使得其他程序能够根据【例4-26】的执行结果而采取相应的动作。

4.3　多条件判断语句case

4.2节介绍了通过if elif语句来处理多分支的情况。实际上，与大多数程序设计语言一样，Shell也提供了一个专门处理多分支情况的语句，即case语句。通过使用case语句，可以使得程序更加有条理性，本节将介绍case语句的使用方法。

4.3.1　多条件判断语句case的基本语法
case语句的基本语法如下：

case variable in
value_1)
statement_1
statement_2
...
statementn;;
value_2)
statement_1
statement_2
...
statementn;;
value_3)
statement_1
statement_2
...
statementn;;
...
valuen)
statement_1
statement_2
...
statementn;;
*)
statement_1
statement_2
...
statementn;;
esac

在上面的语法中，variable是一个变量，case语句会将该变量的值与value1～valuen中的每个值相比较，如果与某个value的值相等，则执行该value所对应的一组语句。当遇到“;;”符号时，就跳出case语句，执行esac语句后面的语句。如果没有任何一个值与variable的值相匹配，则执行*后面的一组语句。

对于上面的case语句，用户应该注意以下几点：

- 对于变量名variable，可以使用双引号，也可以不使用。

- 每个case子句中的条件测试部分，都以右括号“)”结束。

- 每个case子句都以一对分号“;;”作为结束符。在脚本执行的过程中，当遇到一对分号时，会跳过当前case子句后面的所有的case子句，包括*所对应的子句，执行esac子句后面的其他的语句。

- case语句结构以esac结尾。这与if语句以fi结尾是一样的，都是以前面一个单词的所有字母的逆序排列作为结束标记。

4.3.2　利用case语句处理选项参数

使用case语句来处理选项参数在Shell中非常普遍，尤其是/etc/init.d目录中服务脚本，几乎都含有一个或者多个case语句。例如，下面给出的是Java应用服务器Resin的一个服务脚本的部分代码：

…
01 case "$1" in
02 # 启动服务
03 start)
04 log_daemon_msg "Starting resin"
05 # 如果变量 USER 不为空，则切换到 USER 身份，启动 Resin 服务
06 if test -n "$USER"; then
07 su $USER -c """$RESIN_EXE $ARGS $START_ARGS $START_CMD""" 1>>
$CONSOLE 2>> $CONSOLE
08 else
09 # 输出错误
10 errors='$RESIN_EXE $ARGS $START_CMD 2>&1'
11 if [ $? != 0 ]; then
12 log_daemon_msg $errors
13 fi
14 fi
15
16 log_end_msg $?
17 ;;
18 # 停止服务
19 stop)
20 log_daemon_msg "Stopping resin"
21 if test -n "$USER"; then
22 su $USER -c """$RESIN_EXE $ARGS shutdown""" 1>> $CONSOLE 2>>
$CONSOLE
23 else
24 errors='$RESIN_EXE $ARGS shutdown 2>&1'
25 if [ $? != 0 ]; then
26 log_daemon_msg $errors
27 fi
28 fi
29
30 log_end_msg $?
31 ;;
32 # 查看服务状态
33 status)
34 $RESIN_EXE $ARGS status || exit 3
35 ;;
36 # 重新启动服务
37 restart)
38 $0 stop
39 $0 start
40 ;;
41 # 其他情况
42 *)
43 echo "Usage: $0 {start|stop|status|restart}"
44 exit 1
45 esac
…

在上面的代码中，第1行通过系统变量$1接收用户执行命令时的参数，这些参数可以取4个值，分别是start、stop、status及restart，其涵义分别表示启动Resin服务、停止Resin服务、查看Resin服务状态，以及重新启动Resin服务。在每个子句中，都有一组执行相应操作的语句。如果用户输入了这4个值以外的参数，则执行*对应的子句，提示用户使用方法。

我们可以通过以下方法来对Resin服务执行相应的操作。例如，下面的命令启动Resin服务：

[root@linux init.d]# /etc/init.d/resin start
Starting resin: .

而以下命令查看Resin服务的状态：

[root@linux init.d]# /etc/init.d/resin status
Resin/4.0.30 status for watchdog at 127.0.0.1:6600
watchdog:
watchdog-pid: 22583
server 'app-0' : ACTIVE
password: missing
watchdog-user: root
user: resin(resin)
root: /var/resin
conf: /etc/resin/resin.xml
uptime: 0 days 00h00

以下命令停止Resin服务：

[root@linux init.d]# /etc/init.d/resin stop
Stopping resin: .

注意：在适当的时候使用case语句可以提高程序的可读性。

4.3.3　利用case语句处理用户输入

除了处理参数之外，使用case语句还可以处理用户多种不同的输入，从而根据不同的数据执行不同的代码，请参见下面的例子。

【例4-27】说明case语句的使用方法，代码如下：

01 #-----------------------------/chapter4/ex4-27.sh------------------
02 #! /bin/sh
03
04 # 输出提示信息
05 echo "Hit a key,then hit return."
06 # 读取用户按下的键
07 read keypress
08 #case 语句开始
09 case "$keypress" in
10 # 小写字母
11 [[:lower:]])
12 echo "Lowercase letter.";;
13 # 大写字母
14 [[:upper:]])
15 echo "Uppercase letter.";;
16 # 单个数字
17 [0-9])
18 echo "Digit.";;
19 # 其他字符
20 *)
21 echo "other letter.";;
22 esac

在上面的代码中，第7行读取用户按下的键；第9行是case语句的开始，将变量keypress作为条件变量；第11行测试变量keypress对应的键值是否是小写字母，如果是小写字母，则输出相应的提示；第14行测试变量keypress的对应的键值是否是大写字母；第17行测试变量keypress对应的键值是否是0～9之间的数字；第20行使用星号匹配其他的情况。

该程序的执行结果如下：

01 [root@linux chapter4]# ./ex4-27.sh
02 Hit a key,then hit return.
03 a
04 Lowercase letter.
05 [root@linux chapter4]# ./ex4-27.sh
06 Hit a key,then hit return.
07 B
08 Uppercase letter.
09 [root@linux chapter4]# ./ex4-27.sh
10 Hit a key,then hit return.
11 8
12 Digit.
13 [root@linux chapter4]# ./ex4-27.sh
14 Hit a key,then hit return.
15 ?
16 other letter.

在第3行中，输入了一个小写字母a，第7行输入了一个大写字母B，第11行输入一个数字8，第15行输入一个问号。程序分别根据用户输入的字符进行相应的输出。

4.4　运算符

关于运算符，在4.1节中已经介绍了一些，例如字符串运算符、数值运算符，以及文件运算符等。关于这些运算符的详细使用方法，请参考表4-1。除了这些运算符之外，还有其他的与数值有关的运算符，例如算术运算符、位运算符，以及自增/自减运算符等，本节将介绍这些运算符的使用方法。

4.4.1　算术运算符

与其他的程序设计语言一样，Shell中的算术运算符也主要包括加（+）、减（-）、乘（*）、除（/）、求余（%），以及幂运算（**）等。表4-5列出了常用的算术运算符及其使用方法。

另外，在Linux Shell中，用户可以通过4种方式来执行算术运算，这4种方式分别如下。

表4-5　常用算术运算符及其用法

运算符 | 说　明 | 举　例
+ | 求2个数的和 | 例如1+5是求1和5的和
- | 求2个数的差 | 例如9-3是求9和3的差
* | 求2个数的乘积 | 例如2*4是求2和4的乘积
/ | 求2个数的商 | 例如28/4是求28除以4的商
% | 求余 | 例如23%4是求23除以4后的余数，即3
** | 幂运算 | 例如3**3是求33的值，即27

1．使用expr外部程序
expr是一个Shell命令，可以计算某个表达式的值，其基本语法如下：

expr expression

其中，expression是要计算的表达式。

【例4-28】演示使用expr命令来计算不同的算术运算，代码如下：

01 #-----------------------------/chapter4/ex4-28.sh------------------
02 #! /bin/sh
03
04 # 计算 2 和 100 的差，即 -98
05 result='expr 2 - 100'
06 echo "$result"
07 # 计算 2 和 100 的和，即 102
08 result='expr 2 + 100'
09 echo "$result"
10 # 计算 2 和 5 的乘积，即 10
11 result='expr 2 \* 5'
12 echo "$result"
13 # 计算 24 和 8 的商，即 3
14 result='expr 24 / 8'
15 echo "$result"
16 # 先计算 2 和 6 的差，然后再乘以 12 ，即 -48
17 result='expr \( 2 - 6 \) \* 12'
18 echo "$result"
19 # 错误的语法
20 result='expr 2+5'
21 echo "$result"
22 # 错误的语法
23 result='expr 2-4*9'
24 echo "$result"
25 # 错误的语法
26 result='expr 1-(4-7)'
27 echo "$result"

在上面的代码中，我们使用单反引号“'”将expr和算术表达式引起来。其中第5行计算2和100的差；第8行计算2和100的和；第11行计算2和5的乘积，其中的星号表示乘法运算，而反斜线“\”是转义字符；第14行计算24和8的商；第17行是一个复合运算，先计算括号里面的2和6的差，然后再计算括号外面的乘法；第20行、第23行和第26行是采用紧凑格式来书写算术表达式，即运算符的左右两边没有空格。

该程序的执行结果如下：

01 [root@linux chapter4]# ./ex4-28.sh
02 -98
03 102
04 10
05 3
06 -48
07 2+5
08 2-4*9
09 ./ex4-28.sh: command substitution: line 26: syntax error near unexpected
token '('
10 ./ex4-28.sh: command substitution: line 26: 'expr 1-(4-7)'

在上面的执行结果中，第2行是第5行代码的计算结果，第3行是代码第8行的计算结果，第4行代码是第11行的计算结果，第5行是代码第14行的计算结果，第6行是第17行的计算结果。上述语句都得到了正确的结果。

接下来我们再看一下输出结果中的第7行，在这一行中，程序并没有计算2和5的和，而是直接输出了这个表达式。同样，输出结果中的第8行也是直接输出了算术表达式，而非计算结果。第9行和第10行的错误消息是ex4-28.sh文件中第26行和第27行的执行结果。由于在使用expr进行算术运算的时候需要将括号转义，所以上面的语句执行错误。

注意：在使用运算符时，一定要注意运算符左右两边的空格，否则会得出错误的结果。另外，expr命令不能计算幂运算。另外，在【例4-28】中，使用的是反单引号。

2．使用$((…))

使用这种形式来进行算术运算写法比较自由，无需对运算符和括号做转义处理，可以采用松散或者紧凑的格式来书写表达式。

【例4-29】演示如何使用$((..))符号进行算术运算，代码如下：

01 #-----------------------------/chapter4/ex4-29.sh------------------
02 #! /bin/sh
03
04 # 紧凑格式，计算 3 和 6 的和
05 result=$((3+6))
06 echo "$result"
07 # 松散格式，计算 3 和 9 的和
08 result=$(( 3 + 9 ))
09 echo "$result"
10 # 计算 3 和 6 的乘积
11 reuslt=$(( 3 * 6 ))
12 echo "$result"
13 # 计算 7 和 5 的商
14 result=$(( 7 / 5 ))
15 echo "$result"
16 # 计算 8 和 3 的余数
17 result=$(( 8 % 3 ))
18 echo "$result"
19 # 复合运算
20 result=$(( ( 1-4 ) * 5 ))
21 echo "$result"

在上面的代码中，第5行采用紧凑格式来书写算术表达式，即运算符左右没有空格；第8行采用松散格式来书写算术表达式；第11行执行的是乘法运算，没有将乘法运算符星号进行转义处理；第14行执行除法运算；第17行执行求余数运算；第20行是一个复合运算，其中的括号也没有进行转义处理。

该程序的执行结果如下：

[root@linux chapter4]# ./ex4-29.sh
9
12
12
1
2
-15

从上面的执行结果可以得知，使用$((…))来执行算术运算非常灵活。

3．使用$[…]

使用一个方括号同样可以执行算术运算，这种语法的特点与使用两个圆括号相同，无论是采用紧凑格式还是松散格式，都可以得到正确的结果。另外，算术表达式中的星号和圆括号也无需转义处理。

【例4-30】演示如何使用方括号来进行算术运算，代码如下：

01 #-----------------------------/chapter4/ex4-30.sh------------------
02 #! /bin/sh
03
04 # 加法运算
05 result=$[4+5]
06 echo "$result"
07 # 复合运算
08 result=$[(1+2)*3]
09 echo "$result"
10 # 幂运算
11 result=$[ 2 ** 4 ]
12 echo "$result"

以上程序的执行结果如下：

[root@linux chapter4]# ./ex4-30.sh
9
9
16

4．使用let命令

使用let命令可以执行一个或者多个算术表达式，其中的变量名无需使用$符号。如果表达式中含有空格或者其他特殊字符，则必须将其引用起来。

【例4-31】演示使用let命令来执行算术运算，代码如下：

01 #-----------------------------/chapter4/ex4-31.sh------------------
02 #! /bin/sh
03
04 # 定义变量
05 n=10
06 # 加法运算
07 let n=n+1
08 echo "$n"
09 # 乘法运算
10 let n=n*10
11 echo "$n"
12 # 幂运算
13 let n=n**2
14 echo "$n"

该程序的执行结果如下：

[root@linux chapter4]# ./ex4-31.sh
11
110
12100

除了表4-5列出的运算符之外，还有一些复合算术运算符，如表4-6所示。

表4-6　复合算术运算符

运算符 | 说　明 | 举　例
+= | 将左边的数加上右边的数，然后再将和赋给左边的变量 | 例如，3 += 5等于8
-= | 将左边的数减去右边的数，然后再将差赋给左边的变量 | 例如，5 -= 2等于3
*= | 将左边的数乘以右边的数，然后将积赋给左边的变量 | 例如，2 *= 8等于16
/= | 将左边的数除以右边的数，然后再将商赋给左边的变量 | 例如，6 /= 3等于2
%= | 将左边的数对右边的数求模之后，再赋给左边的变量 | 例如，22 /= 3等于1

关于这些运算符的使用方法，不再详细说明。

4.4.2　位运算符

对于Shell编程来说，位运算可能使用的比较少，在此只是进行简单地介绍。位运算通常出现在整数间，它针对的不是整个整数，而是其二进制表示形式中的某个或者某些位（bit）。例如，2>>1是将二进制形式的2，即10，左移1位，从而变成100，即4。表4-7列出了常用的位运算符。

表4-7　常用位运算符

运算符 | 说　明 | 举　例
<< | 左移 | 4 << 2，将4左移2位，结果为16
>> | 右移 | 8 >> 2，将8右移2位，结果为2
& | 按位与 | 8 && 4，将8和4进行按位与运算，结果为0
| | 按位或 | 8 | 4，将8和4进行按位或运算，结果为12
～ | 按位非 | ～8，将8进行按位非运算，结果为-9
^ | 按位异或 | 10 ^ 6，将10和6进行按二进制位异或运算，结果为12

在表4-7中列出的运算符中，左移运算符“<<”是将整个数字的所有二进制位左移相应的数量，每左移1位，相当于将当前的数字乘以2。右移运算符“>>”是将整个数字的所有二进制位右移相应的数量，每右移1位，相当于将当前的数字除以2。按位与“&”将两个数字的每个二进制位进行与运算。如果这两个位都是1，则运算结果就是1；如果两个位中有任何一个为0，则运算结果就是0。

按位或“|”的运算过程与按位与基本相同，不同之处在于参与按位或运算的两个位中只要有一个位为1，则运算结果就为1；只有两个位都是0的情况下，运算结果才为0。按位非“～”的运算过程是将参与运算的数字的每个二进制位进行非运算，即将原来的1变成0，而将0变成1。按位异或运算的过程与按位与或者按位非基本相同，但是在按位异或的过程中，只要参与运算的两个位的值相同，即同时为0或者同时为1，其运算结果就是1；否则，运算结果为0。

如图4-1和图4-2所示，分别描述了左移和右移的过程。在图4-1中，上面的数字为4，将二进制形式的4左移2位，右边空出的位补0，最后变成了16。

同理，在图4-2中，上面的数字为8，将二进制形式的8向右移动2位，右边的0去掉，最后变成了2。关于其他位运算，其过程大致相同，不再详细说明。下面举例说明移位运算的方法。

图4-1　左移运算

图4-2　右移运算

【例4-32】演示位运算符的使用方法，代码如下：

01 #-----------------------------/chapter4/ex4-32.sh------------------
02 #! /bin/sh
03
04 # 左移运算
05 result=$[ 2 << 3 ]
06 echo "$result"
07 # 右移运算
08 result=$[ 8 >> 2 ]
09 echo "$result"
10 # 按位与运算
11 result=$[ 8 & 4 ]
12 echo "$result"
13 # 按位非运算
14 result=$[ ~8 ]
15 echo "$result"
16 # 按位异或运算
17 result=$[ 10 ^ 6 ]
18 echo "$result"

在上面的代码中，第5行执行左移运算，第8行执行右移运算，第11行执行按位与运算，第14行执行按位非运算，第17行执行按位异或运算。在本例中，我们采用的是$[]的语法来执行算术运算。

该程序的执行结果如下：

[root@linux chapter4]# ./ex4-32.sh
16
2
0
-9
12

除了简单的按位运算符之外，还有一些复合位运算符，表4-8列出了常用的复合位运算符。

表4-8　复合位运算符

运算符 | 说　明 | 举　例
<<= | 将变量的值左移指定位数之后重新赋给该变量 | x<<=3，将x的值左移3位，重新赋给变量x
>>= | 将变量的值右移指定位数之后重新赋给该变量 | x>>=4，将变量x的值右移4位后重新赋给变量x
&= | 将变量的值与指定的数值按位与之后重新赋给该变量 | x&=8，将变量x的值与8按位与运算之后重新赋给变量x
|= | 将变量的值与指定的数值按位或之后重新赋给该变量 | x|=7，将变量x的值与7执行按位或运算之后重新赋给变量x
^= | 将变量的值与指定的数值按位异或之后重新赋给该变量 | x^=9，将变量x的值与9执行按位异或运算之后重新赋给变量x

【例4-33】演示表4-8中列出的复合位运算符的使用方法，代码如下：

01 #-----------------------------/chapter4/ex4-33.sh------------------
02 #! /bin/sh
03
04 # 定义变量 x
05 x=5
06 # 执行左移赋值复合运算
07 let "x<<=4"
08 echo "$x"
09 # 执行右移赋值复合运算
10 let "x>>=2"
11 echo "$x"
12 # 执行按位或赋值运算
13 let "x|=2"
14 echo "$x"

该程序的执行结果如下：

[root@linux chapter4]# ./ex4-33.sh
80
20
22

至于如何得到上面的执行结果，请读者参照图4-2中的位运算方法，就可以得出结论。

注意：复合运算符针对的都是变量。这是因为只有变量才有赋值的操作。

4.4.3　自增/自减运算符

在Shell中，还有一类称为自增或者自减的运算符，这类运算符的作用是将某个变量自动加1或者减1。这类运算符一共有4种，分别是前置自增、前置自减、后置自增和后置自减，如表4-9所示。

表4-9　自增/自减运算符

运算符 | 说　明
++variable | 先将变量variable的值加1，然后再赋给variable
--variable | 先将变量variable的值减1，然后再赋给variable
variable++ | 先使用variable的值，然后再将该变量的值加1
variable-- | 先使用variable的值，然后再将该变量的值减1

在表4-9中列出的运算符中，前置运算符都是先执行运算，然后再使用表达式的值；而后置运算符则是先使用表达式的值，然后再执行自增或者自减运算。

【例4-34】演示自增或者自减运算符的使用方法，代码如下：

01 #-----------------------------/chapter4/ex4-34.sh------------------
02 #! /bin/sh
03
04 # 定义变量 x
05 x=5
06 # 将变量 x 先自增，然后再计算表达式的值
07 x=$[ x + (++x) ]
08 echo "$x"
09 # 将变量先自减，然后再计算表达式的值
10 x=$[ --x ]
11 echo "$x"
12 # 先计算表达式的值，然后再自增
13 x=$((x++))
14 echo "$x"
15 # 先计算表达式的值，然后再自减
16 x=$(( x-- ))
17 echo "$x"

在上面的代码中，第5行定义变量x，并且赋值为5。第7行是一个复合表达式，程序会先执行括号中的++x，将x的值加1，从而变成6，然后再与括号外面的变量x相加，而此时括号外面的变量x的值仍然是5，因此整个表达式的值为11。第10行执行的是前置自减运算，程序会先将x的值减1，从而变成10，然后再将该值赋给变量x。第13行执行的是后置自增运算，因此，整个表达式的值应该是x自增前的值，即10，执行完第13行之后，x的值变为11。第16行执行后置自减运算，先将变量x的值自减，变成10，然后再将10赋给变量x。

该程序的执行结果如下：

[root@linux chapter4]# ./ex4-34.sh
11
10
10
10

对于上面的结果，读者可以对比上面的分析来理解是如何得来的。

注意：无论是自增还是自减运算，都是针对变量而言的。不要对常量或者表达式执行自增或者自减运算，例如5++或者++(4+x)都是错误的。并且自增或者自减运算只能针对整数。

4.4.4　数字常量的进制

默认情况下，Shell总是以十进制来表示数字。但是，用户也可以在Shell中使用其他进制来表示数字，例如二进制、八进制和十六进制。在Shell中，用户可以使用两种语法来表示不同的进制，首先是增加前缀，例如以0开头的数字表示八进制，以0x开头的数字表示十六进制。第二种语法是使用井号“#”，例如2#1000表示二进制，8#42表示八进制。

【例4-35】演示不同的进制的表示方法，代码如下：

01 #-----------------------------/chapter4/ex4-35.sh------------------
02 #! /bin/sh
03
04 # 十进制 20
05 ((x=20))
06 echo "$x"
07 # 八进制 20
08 ((x=020))
09 echo "$x"
10 # 十六进制 20
11 ((x=0x20))
12 echo "$x"

在上面的代码中，第5行的数字没有任何前缀，则表示这个数字是十进制数字；第8行的数字使用了前缀0，表示这是用八进制表示的数字；第11行的数字使用了前缀0x，表示这是一个用十六进制表示的数字。

该程序的执行结果如下：

[root@linux chapter4]# ./ex4-35.sh
20
16
32

在上面的输出结果中，最后输出的全部是以十进制表示的数字。

【例4-36】演示使用井号“#”来表示不同进制的方法，代码如下：

01 #-----------------------------/chapter4/ex4-36.sh------------------
02 #! /bin/sh
03
04 # 二进制
05 ((x="2#100000"))
06 echo "$x"
07 # 八进制
08 ((x=8#123))
09 echo "$x"
10 # 十六进制
11 ((x=16#32))
12 echo "$x"

其执行结果如下：

[root@linux chapter4]# ./ex4-36.sh
32
83
50

4.5　小结

本章深入探讨了条件测试、简单条件判断语句、多条件判断语句，以及运算符的使用方法等。主要内容包括条件测试的基本语法、字符串测试、数值测试、文件测试、逻辑操作符、简单的if else语句、if elif语句、case语句，以及算术运算符、位运算符和自增、自减运算符等。重点在于掌握基本的条件测试的语法，以及条件判断的使用方法。在下一章中，我们将介绍另外一种流程控制语句，即循环结构。

第5章
循环结构

循环结构是Shell程序设计语言中的另外一种常用的流程控制结构。当用户需要重复执行相同的操作时，使用循环结构就可以很好地解决问题。在Shell中，循环结构可以使用for、while，以及until等语句来实现。还可以使用continue和break等语句来在适当的时机跳过或者退出循环体中的某些语句。

本章主要涉及的知识点如下所述。

- 步进循环语句for：主要介绍带列表的for语句、不带列表的for语句、类C风格的for语句，以及使用for循环语句来处理数组。

- until循环语句：主要介绍until语句的基本语法，以及如何使用until语句来批量添加用户。

- while循环语句：主要介绍while循环语句的基本语法，分别使用计数器、结束标记、标志及命令行等方法来控制while循环结构，以及while语句与until语句的区别。

- 嵌套循环：介绍各种嵌套循环结构的使用方法。

- 利用break和continue语句控制循环：分别介绍如何使用break和continue语句来退出循环和跳过循环体中的某些语句，以及这两种语句的区别。

5.1　步进循环语句for
for循环是最简单，也是最常用的循环语句。与其他的程序设计语言一样，for循环都是初学者在学习循环结构时的入门课程。for循环通常用于遍历整个对象或者数字列表。按照循环条件的不同，for循环语句可以分为带列表的for循环、不带列表的for循环，以及类C风格的for循环。本节将介绍这3种不同的for循环结构。

5.1.1　带列表的for循环语句

带列表的for循环通常用于将一组语句执行已知的次数，其基本语法如下：

for variable in {list}
do
statement1
statement2
...
done

在上面的语法中，variable称为循环变量，list是一个列表，可以是一系列的数字或者字符串，元素之间使用空格隔开。do和done之间的所有的语句称为循环体，即循环结构中重复执行的语句。for循环体的执行次数与list中元素的个数有关。在带列表的for语句的执行时，Shell会将in关键字后面的list列表的第1个元素的值赋给变量variable，然后执行循环体；当循环体中的语句执行完毕之后，Shell会将列表中的第1个元素的值赋给变量variable，然后再次执行循环体。当list列表中的所有的元素都被访问后，for循环结构终止，程序将继续执行done语句后面的其他语句。

【例5-1】演示将数字列表作为循环条件列表的for语句的使用方法，代码如下：

01 #-----------------------------/chapter5/ex5-1.sh------------------
02 #! /bin/bash
03
04 #for 循环开始
05 for var in 1 2 3 4 5 6 7 8
06 do
07 # 依次输出列表中的数字
08 echo "the number is $var"
09 done

在上面的代码中，第5行使用从1～8的8个整数作为循环列表，第8行通过echo语句依次输出各个数字。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-1.sh
the number is 1
the number is 2
the number is 3
the number is 4
the number is 5
the number is 6
the number is 7
the number is 8

从上面的执行结果可以得知，通过带列表的for语句，可以非常方便地对列表中的各个值进行处理。

除了将各个数字全部列出之外，用户还可以使用另外一种比较简单的书写方法，即用一个范围来代替列出所有的元素。例如，上面的1～8所有的数字可以使用{1...8}来代替。

【例5-2】本例将【例5-1】进行改造，使用了简单的书写方法，代码如下：

01 #-----------------------------/chapter5/ex5-2.sh------------------
02 #! /bin/bash
03
04 # 使用省略的写法表示某个范围
05 for var in {1..8}
06 do
07 echo "the number is $var"
08 done

该程序的执行结果与【例5-1】完全相同，如下所示。

[root@linux chapter5]# ./ex5-2.sh
the number is 1
the number is 2
the number is 3
the number is 4
the number is 5
the number is 6
the number is 7
the number is 8

在上面的【例5-1】和【例5-2】中，for语句的步长，即循环变量每次增加的值都是1。实际上，Shell允许用户指定for语句的步长。当用户需要另外指定步长时，其基本语法如下：

for varibale in {start..end..step}
do
statement1
statement2
...
done

在上面的语句中，循环列表被大括号包括起来，其中start表示起始的数值，end表示终止的数值，step表示步长。

【例5-3】演示通过for循环，并配合步长来计算100以内奇数的和，代码如下：

01 #-----------------------------/chapter5/ex5-3.sh------------------
02 #! /bin/bash
03
04 # 定义变量，并赋初值为 0
05 sum=0;
06 #for 循环开始，设置起始数值为 1 ，结束数值为 100 ，步长为 2
07 for i in {1..100..2}
08 do
09 # 将数累加
10 let "sum+=i"
11 done
12 echo "the sum is $sum"

在上面的代码中，第5行定义了一个变量sum，并且为其赋初值为0。在后面的代码中，利用这个变量实现数的累加。第7行是for循环的开始，其中i为循环变量，大括号里面是循环条件，指定该循环从1开始，到100结束，并且按照2的步长来递增。由于从1开始，所以递增2之后，第2个数为3，第3个数为5，……以此类推。第10行是使用let语句计算表达式sum+=i的值。也就是说，每循环一次，都将当前的数与sum相加。第12行输出变量sum的值。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-3.sh
the sum is 2500

在for循环的列表条件中，除了使用数字作为元素之外，还可以使用字符串。

【例5-4】本例将1周中7天的名字作为列表条件，依次输出每天的名称。代码如下：

01 #-----------------------------/chapter5/ex5-4.sh------------------
02 #! /bin/bash
03
04 #for 循环开始
05 for day in {Mon Tue Wed Thu Fri Sat Sun}
06 do
07 # 输出循环变量的值
08 echo "$day"
09 done

在上面代码的第5行是for循环的开始，将周一～周日的英文名称作为列表元素，放在大括号内，它们之间用空格隔开。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-4.sh
Mon
Tue
Wed
Thu
Fri
Sat
Sun

如果使用字符串作为列表元素，实际上可以省略外面的大括号。因此，【例5-4】可以修改为以下形式：

01 #! /bin/bash
02
03 #for 循环开始
04 for day in Mon Tue Wed Thu Fri Sat Sun
05 do
06 # 输出循环变量的值
07 echo "$day"
08 done

在前面的例子中的for循环里面，都是直接指定循环的条件列表。实际上，Shell中的for循环非常灵活，除了直接指定条件之外，还可以通过其他的方式来获得条件列表。例如，某些Shell命令会输出一个列表。其中，最常见的命令就是ls命令，该命令可以列出某个目录下面的文件清单。

在下面的【例5-5】中，就是通过ls命令的输出结果作为for循环的执行条件，代码如下：

01 #---------------------------/chapter5/ex5-5.sh------------------
02 #! /bin/bash
03
04 # 使用 ls 命令的执行结果作为列表
05 for file in $(ls)
06 do
07 # 输出每个文件名
08 echo "$file"
09 done

在上面的代码中，值得关注的是第5行，其中$(ls)作为循环条件。前面已经介绍过，$()的作用就是将其中的字符串作为Shell命令来执行。因此，第5行的循环条件就是当前目录的文件列表。第8行通过echo语句依次输出文件名称。

注意：【例5-5】中的$(ls)可以使用'ls'来代替。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-5.sh
ex5-1.sh
ex5-2.sh
ex5-3.sh
ex5-4.sh
ex5-5.sh

在上面的例子中，我们只是简单地输出文件名，实际上在实践中，用户可以针对这些文件进行其他一些处理操作，例如删除某些文件等。

另外，通配符也经常出现在for循环的条件列表中。下面的例子就介绍了使用通配符作为循环列表的方法。

【例5-6】使用通配符“*”作为条件列表，代码如下：

01 #-----------------------------/chapter5/ex5-6.sh------------------
02 #! /bin/bash
03
04 # 使用通配符作为列表条件
05 for file in *
06 do
07 echo "$file"
08 done

在上面的代码中，for循环的条件列表非常简单，只是一个星号“*”，这个星号的作用是列出当前目录中所有的文件，与ls*命令等价。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-6.sh
ex5-1.sh
ex5-2.sh
ex5-3.sh
ex5-4.sh
ex5-5.sh
ex5-6.sh

可以发现，【例5-6】的执行结果与【例5-5】完全相同。这是因为不带任何参数的ls命令也是列出当前目录下的所有的文件。

注意：由于通配符只是一个符号，而非Shell命令，因此，不能单独放在$()或者反引号内部。

列表for循环还有一个比较重要的用途就是处理脚本的参数。前面讲过，用户可以通过系统变量“$*”一次获取所有的参数，并且，这些参数值之间是通过空格隔开的，所以，用户同样可以将该系统变量获取的参数值作为for循环的条件列表，从而可以依次处理各个参数。

【例5-7】使用for循环逐个处理脚本的参数值，代码如下：

01 #-----------------------------/chapter5/ex5-7.sh------------------
02 #! /bin/bash
03
04 # 输出所有的参数
05 echo "$*"
06 # 将参数列表作为条件
07 for arg in $*
08 do
09 # 依次输出各个参数值
10 echo "${arg}"
11 done

在上面的代码中，第7行将系统变量$*作为条件列表。该程序的执行结果如下：

[root@linux chapter5]# ./ex5-7.sh a b c d e f g
a b c d e f g
a
b
c
d
e
f
g

5.1.2　不带列表的for循环语句

在某些特殊情况下，for循环的条件列表可以完全省略，称为不带列表的for循环语句。如果没有为for循环提供条件列表，Shell将从命令行获取条件列表。不带列表的for循环语句的一般语法如下：

for variable
do
statement1
statement2
...
done

由于系统变量$@同样可以获取所有的参数，所以以上的语法等价于以下语法：

for variable in $@
do
statement1
statement2
...
done

也同样等价于以下语法：

for variable in $*
do
statement1
statement2
...
done

【例5-8】演示不带列表的for循环语句的使用方法，代码如下：

01 #-----------------------------/chapter5/ex5-8.sh------------------
02 #! /bin/bash
03
04 # 不带条件列表
05 for arg
06 do
07 # 输出每个参数
08 echo "$arg"
09 done

以上代码的执行结果如下：

[root@linux chapter5]# ./ex5-8.sh a b c d
a
b
c
d

由于不带列表的for语句只能从命令行获取条件列表，所以在使用上受到了比较大的限制。

5.1.3　类C风格的for循环语句

在Linux或者UNIX上面，C或者C++是最主流的开发语言。因此，从事Linux系统管理的系统管理员也或多或少地接触过C或者C++语言。通常来说，这些人对于C语言的语法比较熟悉。为了适应这部分用户的习惯，bash也提供了类C风格的for循环语句。类C风格的for循环语句的基本语法如下：

for ((expression1;expression2;expression3))
do
statement1;
statement2;
...
done

在上面的语法中，for循环语句的执行条件被两个圆括号包括起来。执行条件分为3个部分，由两个分号隔开，第1部分expression1通常是条件变量初始化的语句；第2部分expression2是决定是否执行for循环的条件。当expression2的值为0时，执行整个循环体；当expression2的值为非0时，退出for循环体。第3部分，即表达式expression3通常用来改变条件变量的值，例如递增或者递减等。

【例5-9】演示类C风格的for循环语句的使用方法，代码如下：

01 #-----------------------------/chapter5/ex5-9.sh------------------
02 #! /bin/bash
03
04 #for 循环开始
05 for (( i=1;i<5;i++))
06 do
07 # 输出循环变量 i 的值
08 echo "$i"
09 done

该程序的功能比较简单，仅仅是输出1～4这4个整数的值。在程序执行的时候，Shell会首先执行for循环条件中的第1部分，即表达式i=1，该表达式的作用是为条件变量i赋初值为1。

接着会判断条件的第2部分，即计算表达式i<5的值，如果该表达式的值等于0，则继续执行for循环。由于当前的i的值为1，所以i<5的值为0，for循环将继续执行。然后执行循环条件的第3部分，即表达式i++，该表达式的作用是将变量i的值加1。最后，Shell将执行for循环体中的语句。

在【例5-9】中，实际上有用的代码只有一行，即第8行，通过echo语句输出变量i的值。当本次循环体执行完毕之后，for循环将从头开始，即跳转到第5行。

从第2次循环开始，循环条件中的第1部分将不再执行，而是直接判断第2部分中表达式的值是否为0，如果为0，则继续执行条件的第3部分中的表达式，然后再次执行循环体。如果第2部分中的表达式的值为非0值，则退出for循环，继续执行done语句后面的语句。

【例5-9】的执行结果如下：

[root@linux chapter5]# ./ex5-9.sh
1
2
3
4

与C语言中一样，在Shell中，用户也可以将for循环条件中的3个部分中的任何一部分省略掉，也可以全部省略掉。例如，下面的代码是一个完全合法的for循环结构：

#! /bin/bash
for((;;))
do
echo "Hello world."
done

尽管合法，但是上面的for循环并没有指定终止循环的条件，所以会导致该程序无限地执行下去，成为死循环。

在for循环中，条件的第2部分可以是表达式或者命令。当表达式的值为0或者命令的退出状态码为0时，for循环体将被执行；否则，将退出for循环。

注意：在使用类C风格的for循环语句时，一定要注意退出循环时的条件，稍不注意，会导致死循环的发生。

5.1.4　使用for循环语句处理数组

在学习完for循环语句之后，最后再介绍一个具体的应用，即通过for循环语句来处理数组。希望通过这个例子的学习，可以加深对for语句的理解，并熟练掌握该语句的使用方法。

使用for循环遍历数组非常方便。针对数组，Shell专门提供了一种特殊语法的for循环语句，其基本语法如下：

for variable in ${array[*]}
do
statement1
statement2
...
done

其中，变量variable是循环变量，in关键字后面的部分表示要遍历的数组，其中array表示数组的名称。在遍历数组的过程中，for循环语句会将每个数组元素的值赋给循环变量variable。因此，用户可以在循环体中对每个数组元素进行相应的操作。

【例5-10】演示通过for循环来遍历数组，代码如下：

01 #-----------------------------/chapter5/ex5-10.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 array=(Monday Tuesday Wednesday Thursday Friday Saturday Sunday)
06 # 通过 for 循环遍历数组元素
07 for day in ${array[*]}
08 do
09 # 输出每个数组元素的值
10 echo $day
11 done

在上面的代码中，第5行定义了一个数组，该数组一共有7个元素。第7行通过for循环来遍历该数组。第10行输出每个数组元素的值。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-10.sh
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
Sunday

从上面的执行结果可以得知，已经成功地遍历了数组array中的每个元素。

注意：数组的定义与普通变量基本相同，只是在赋值时不同。关于数组将在第7章中详细介绍。

5.2　until循环语句
until循环语句同样存在于多种程序设计语言中。顾名思义，until语句的作用是将循环体重复执行，直到某个条件成立为止。恰当地使用until语句，可以收到事半功倍地效果。本节将详细介绍until语句的使用方法。

5.2.1　until语句的基本语法
until循环语句的功能是不断地重复执行循环体中的语句，直至某个条件成立。until语句的基本语法如下：

until expression
do
statement1
statement2
...
done

在上面的语法中，expression是一个条件表达式。当该表达式的值不为0时，将执行do和done之间的语句；当expression的值为0时，将退出until循环结构，继续执行done语句后面的其他语句。

在每次执行循环体之间，until语句都会先判断expression的值，如果第一次执行时expression的值为0，则后面的循环体将一次也不会执行；如果expression的值为非0，则until语句将执行do和done之间的所有的语句。当执行完毕之后，until语句会再次判断expression的值，如果为0，则退出循环；否则，继续重复执行循环体。

【例5-11】演示until语句的使用方法，代码如下：

01 #-----------------------------/chapter5/ex5-11.sh------------------
02 #! /bin/bash
03
04 # 定义循环变量 i
05 i=1
06 # 当 i 的值小于 9 时执行循环
07 until [[ "$i" -gt 9 ]]
08 do
09 # 计算 i 的平方
10 let "square=i*i"
11 # 输出 i 的平方
12 echo "$i*$i=$square"
13 # 循环变量加 1
14 let "i=i+1"
15 done

该程序的作用是计算10以内的整数的平方。其中，第5行定义了循环变量i，并且赋初值为1，表示将从1开始递增。第7行中until的条件测试为变量i的值是否大于9，当i的值大于9时，条件测试的值为0；否则，条件测试的值为非0。第10行使用let语句计算表达式的值。第12行输出所求得的平方。第14行比较重要，它的作用是使得循环变量的值加1，从而使得until循环不至于成为死循环。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-11.sh
1*1=1
2*2=4
3*3=9
4*4=16
5*5=25
6*6=36
7*7=49
8*8=64
9*9=81

在上面的执行结果中，脚本依次输出了数字1～9中每个整数的平方。

注意：在使用until循环语句时，一定要注意不要漏掉循环变量的改变，否则会导致死循环。另外，until语句的循环体在条件表达式的值为非0时重复执行；在条件表达式的值为0时，终止循环体的执行。

5.2.2　利用until语句批量增加用户

灵活运用until循环语句，可以提高系统管理员的工作效率。例如，在某个特殊的情况下，需要在当前系统中增加20个临时用户，这些用户的用户名分别为user1、user2、user3……user20。另外，这些用户名默认的密码都是password。如果手动一个一个地增加，则会浪费大量的时间。使用循环语句，则可以批量地将这些用户添加到系统中。

【例5-12】演示如何通过循环语句来批量地增加用户，并设置用户的初始密码，代码如下：

01 #-----------------------------/chapter5/ex5-12.sh------------------
02 #! /bin/bash
03
04 # 定义变量 i
05 i=1
06 # 一直循环到变量 i 的值为 21
07 until [ "$i" -eq 21 ]
08 do
09 # 执行 useradd 命令添加用户
10 useradd user$i
11 # 修改用户密码
12 echo "password" | passwd --stdin user$i > /dev/null
13 # 循环变量自增
14 let "i++"
15 done

在上面的代码中，第7行是until语句的开始位置，当循环变量i的值为21时退出循环。第10行执行useradd命令添加用户。第12行使用命名管道连接了echo和passwd命令，用于将新用户的初始密码设置为password。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-12.sh
[root@linux chapter5]# more /etc/passwd
…
user1:x:503:503::/home/user1:/bin/bash
user2:x:504:504::/home/user2:/bin/bash
user3:x:505:505::/home/user3:/bin/bash
…
user20:x:522:522::/home/user20:/bin/bash

从上面的more命令的执行结果可以得知，user1～user20已经成功地添加到了系统中。

注意：useradd是一个Shell命令，用来向系统中添加新的用户。passwd也是一个Shell命令，用来设置用户的密码。新的用户只有设置密码之后，才可以登录系统。

当这些用户不再需要时，如果要逐个地删除也是一件非常麻烦的事情。同样，用户也可以使用循环语句来批量删除用户。

【例5-13】介绍如何通过循环删除系统用户，代码如下：

01 #-----------------------------/chapter5/ex5-13.sh------------------
02 #! /bin/bash
03
04 i=1
05 until [ "$i" -eq 21 ]
06 do
07 # 依次删除用户
08 userdel -r user$i
09 let "i++"
10 done

在上面的代码中，第8行的userdel是Shell中用来删除用户的命令，-r选项表示删除用户的同时也删除用户主目录。当上述程序执行完成之后，user1～user20这20个用户便会批量被删除。

注意：userdel命令的详细使用方法请参见Linux命令手册。

5.3　while循环语句
while循环是另外一种常见的循环结构。使用while循环结构，可以使得用户重复执行一系列的操作，直到某个条件的发生。这听起来好像跟until循环非常相似，但是与until语句相比，while语句有着较大的区别。本节将详细介绍while语句的使用方法。

5.3.1　while语句的基本语法

首先介绍一下while循环语句的语法，使读者对该语句有个初步的了解。while循环语句的基本语法如下：

while expression
do
statement1
statement2
...
done

在上面的语法中，expression表示while循环体执行时需要满足的条件。虽然可以使用任意合法的Shell命令，但是，通常情况下，expression代表一个测试表达式。与其他的循环结构一样，do和done这两个关键字之间的语句构成了循环体。

当while循环结构在执行时，会首先计算expression表达式的值，如果表达式的值为0，则执行循环体中的语句；否则，退出while循环，执行done关键字后面的语句。当循环体中的语句执行完成之后，会重新计算expression的值，如果仍然是0，则继续执行下一次的循环，直至expression的值为非0。

5.3.2　通过计数器控制while循环结构

所谓计数器，实际上就是指一个循环变量，当该变量的值在某个范围内时，执行循环体；当超过该范围时，终止循环。

【例5-14】使用while循环来输出数字1～9的平方，代码如下：

01 #-----------------------------/chapter5/ex5-14.sh------------------
02 #! /bin/bash
03
04 # 定义循环变量
05 i=1
06 #while 循环开始
07 while [[ "$i" -lt 10 ]]
08 do
09 # 计算平方
10 let "square=i*i"
11 # 输出平方
12 echo "$i*$i=$square"
13 # 循环 变量自增
14 let "i=i+1"
15 done

本例实际上是对【例5-11】的改编，只是用while语句取代了until语句。在上面的代码中，第7行是while循环语句的开始，可以看出，其中的循环条件恰好与until语句的循环条件相反，前者使用了小于运算符，而后者则使用大于运算符。但是，这两个例子的执行结果是完全相同的。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-14.sh
1*1=1
2*2=4
3*3=9
4*4=16
5*5=25
6*6=36
7*7=49
8*8=64
9*9=81

5.3.3　通过结束标记控制while循环结构

在某些情况下，用户可能不知道while循环会执行多少次。此时，用户就无法使用计数器来控制while循环。为了处理这种情况，用户可以在程序中设置一个特殊的标记值，当该标记值出现时，终止while循环。这种特殊的标识值称为结束标记。

【例5-15】演示如何通过结束标记来控制while循环，代码如下：

01 #-----------------------------/chapter5/ex5-15.sh------------------
02 #! /bin/bash
03
04 # 提示用户输入数字
05 echo "Please enter a number between 1 and 10.Enter 0 to exit."
06 # 读取用户输入的数字
07 read var
08 #while 循环开始
09 while [[ "$var" != 0 ]]
10 do
11 # 提示用户输入数字太小
12 if [ "$var" -lt 5 ]
13 then
14 echo "Too small. Try again."
15 read var
16 # 提示用户输入数字太大
17 elif [ "$var" -gt 5 ]
18 then
19 echo "Too big. Try again."
20 read var;
21 else
22 echo "Congratulation! You are right."
23 exit 0;
24 fi
25 done

本例是一个猜数字的游戏。这个游戏几乎是用户在学习许多程序设计语言时必做的题目。在上面的代码中，第5行提示用户输入一个1和10之间的数字，如果直接输入0，则退出程序。第7行使用read语句读取用户输入的数字，并且保存到变量var中。第9行的while语句使用变量var的值是否等于0作为循环条件。第12行～第15行是用户输入数字小于5的情况。第17行～第20行是用户输入数字大于5的情况。第21行～第23行是用户恰好输入5的情况，此时程序会提示用户猜到了正确的数字，并且退出循环。

在上面的代码中，变量var是结束标记，当该变量的值为0或者5时，退出while循环。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-15.sh
Please enter a number between 1 and 10.Enter 0 to exit
3
Too small. Try again.
8
Too big. Try again.
5
Congratulation! You are right.
[root@linux chapter5]# ./ex5-13.sh
Please enter a number between 1 and 10.Enter 0 to exit
0

注意：与for循环相比，while循环条件的变化通常在循环体内部，例如某个循环变量的自增或者结束标记的赋值等。而for循环的条件变化通常放在for语句中括号里面的第3个部分。另外，until语句的条件的变化也通常放在循环体内部。

5.3.4　理解while语句与until语句的区别

为了能使读者加深对while语句和until语句的理解。在此对这两个循环语句的区别进行详细地探讨。

首先，这两个语句的语法结构非常相似，都是将循环条件放在语句的后面，例如while语句的基本语法结构如下：

while expression
do
...
done

until语句的语法结构如下：

unitl expression
do
...
done

但是，在while语句中，当expression的值为0时才执行循环体中的语句，当expression的值为非0值时，将退出循环体；在until语句中，当expression的值为非0时，执行循环体中的语句，当expression的值为0时，将退出循环结构。因此，用户在使用这两种语句时，一定要注意区分这个关键的地方。

在执行机制方面，这两个语句是相同的，即首先会判断expression的值，当该表达式的值符合要求时，才执行循环体中的语句；否则，都不会执行循环体。

另外，在其他程序设计语言，例如C++中也有until语句，但是，该语句的条件表达式是放在循环体后面的。因此在until语句执行时，会首先执行循环体，再判断执行条件。这样的话，until语句的循环体至少会执行一次。而在Shell中，这种情况并不存在。

5.4　嵌套循环

在程序设计语言中，嵌套的循环也是一种非常常见的结构，Shell同样也支持嵌套循环。通过嵌套循环，可以完成更复杂的功能。本节将介绍Shell中嵌套循环的使用方法。

在【例5-11】中，我们使用了until循环计算从1～9这9个整数的平方。下面以九九乘法表为例来介绍嵌套循环的使用方法。

【例5-16】使用两层循环打印出乘法表，代码如下：

01 #-----------------------------/chapter5/ex5-16.sh------------------
02 #! /bin/bash
03
04 # 外层循环
05 for ((i=1;i<=9;i++))
06 do
07 # 内层循环
08 for ((j=1;j<=i;j++))
09 do
10 # 计算 2 个数的乘积
11 let "product=i*j"
12 # 输出乘积
13 printf "$i*$j=$product"
14 # 输出空格分隔符
15 if [[ "$product" -gt 9 ]]
16 then
17 printf " "
18 else
19 printf " "
20 fi
21 done
22 echo
23 done

在上面的代码中，内外两层循环都是通过计数器来控制，外层循环的计数器是从1～9，而内层循环的计数器是从1～i，即外层循环的计数器。

当外层循环的变量i等于1时，内层循环的变量j会从1开始，依次与外层循环变量i即1相乘，然后通过第13行的printf语句输出，直至j增加到1为止，退出内层循环。也就是说，当外层循环变量i的值为1时，这两层循环都只执行一次。

接下来外层循环的变量i自增，变成2，内层循环重新从1开始执行，依次与2相乘，直至值为2为止。依此类推，外层循环的变量从1增加到9就可以得到整个乘法表。

为了使输出结果美观，第17行和第19行的作用是输出分隔符。如果得到的乘积是两位数，则输出3个空格；如果得到的乘积是一位数，则输出4个空格。这样的话，可以使得算式对齐。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-16.sh
1*1=1
2*1=2 2*2=4
3*1=3 3*2=6 3*3=9
4*1=4 4*2=8 4*3=12 4*4=16
5*1=5 5*2=10 5*3=15 5*4=20 5*5=25
6*1=6 6*2=12 6*3=18 6*4=24 6*5=30 6*6=36
7*1=7 7*2=14 7*3=21 7*4=28 7*5=35 7*6=42 7*7=49
8*1=8 8*2=16 8*3=24 8*4=32 8*5=40 8*6=48 8*7=56 8*8=64
9*1=9 9*2=18 9*3=27 9*4=36 9*5=45 9*6=54 9*7=63 9*8=72 9*9=81

嵌套循环不仅仅可以使用相同的循环语句，还可以将不同的循环语句相互嵌套使用。

注意：【例5-16】中使用printf语句而非echo语句输出结果，是因为默认情况下，printf语句不会自动换行。当然，用户也可也使用echo-n语句来输出。

5.5　利用break和continue语句控制循环

在Shell中的循环结构中，还有两个语句非常有用，即break和continue语句。前者用于立即从循环中退出；而后者则用来跳过循环体中的某些语句，继续执行下一次循环。本节将详细介绍这两个语句的使用方法。

5.5.1　利用break语句控制循环
break语句的作用是立即跳出某个循环结构。break语句可以用在for、while或者until等循环语句的循环体中。

在【例5-16】中，嵌套循环中的外层循环的计数器是从1～9，如果用户只想输出5以内的乘法表，则可以对【例5-16】进行改造，使用break语句在i等于5的时候退出外层循环。下面的例子就演示了break语句的使用方法。

【例5-17】输出5以内的乘法表，代码如下：

01 #-----------------------------/chapter5/ex5-17.sh------------------
02 #! /bin/bash
03
04 for ((i=1;i<=9;i++))
05 do
06 for ((j=1;j<=i;j++))
07 do
08 let "product=i*j"
09 printf "$i*$j=$product"
10 if [[ "$product" -gt 9 ]]
11 then
12 printf " "
13 else
14 printf " "
15 fi
16 done
17 echo
18 # 当变量 i 的值为 5 时，退出循环
19 if [[ "$i" -eq 5 ]]
20 then
21 break;
22 fi
23 done

在上面的代码中，第19行～第22行是退出循环的语句。当变量i的值由1变为5时，第19行的条件测试的值为0，所以第21行break语句被执行，立即退出当前循环。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-17.sh
1*1=1
2*1=2 2*2=4
3*1=3 3*2=6 3*3=9
4*1=4 4*2=8 4*3=12 4*4=16
5*1=5 5*2=10 5*3=15 5*4=20 5*5=25

从上面的执行结果可以得知，程序输出了从1～5的乘法表。但是，如果我们将break语句放到内层循环中，情况可能会有所不同，请参见下面的例子。

【例5-18】本例将【例5-17】中的break语句移到内层循环中，代码如下：

01 #-----------------------------/chapter5/ex5-18.sh------------------
02 #! /bin/bash
03
04 for ((i=1;i<=9;i++))
05 do
06 for ((j=1;j<=i;j++))
07 do
08 let "product=i*j"
09 printf "$i*$j=$product"
10 if [[ "$product" -gt 9 ]]
11 then
12 printf " "
13 else
14 printf " "
15 fi
16 # 退出循环
17 if [[ "$j" -eq 5 ]]
18 then
19 break
20 fi
21 done
22 echo
23 done

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-18.sh
1*1=1
2*1=2 2*2=4
3*1=3 3*2=6 3*3=9
4*1=4 4*2=8 4*3=12 4*4=16
5*1=5 5*2=10 5*3=15 5*4=20 5*5=25
6*1=6 6*2=12 6*3=18 6*4=24 6*5=30
7*1=7 7*2=14 7*3=21 7*4=28 7*5=35
8*1=8 8*2=16 8*3=24 8*4=32 8*5=40
9*1=9 9*2=18 9*3=27 9*4=36 9*5=45

可以看出，【例5-18】并没有得到与【例5-17】相同的执行结果。尽管内层循环的计时器执行到5就停止了，但是外层循环却没有受到break语句的影响，仍然从1增加到9。

为什么会得到这种结果呢？这是因为，在默认情况下，break语句仅仅退出一层循环。在【例5-18】中，当内层循环的计数器增加到5时，便执行了第19行的break语句，退出了内层循环，但是外层循环不受影响，仍然继续向下执行。所以在上面输出的乘法表中，前面的一个数从1一直增加到9，而后面的数却一直小于或者等于5。

幸运的是，用户可以在break语句的后面增加一个数字作为参数，用来指定要退出的循环的层数。例如，“break 2”这条语句将会退出两层循环。

【例5-19】通过break语句跳出指定的层数，代码如下：

01 #-----------------------------/chapter5/ex5-19.sh------------------
02 #! /bin/bash
03
04 for ((i=1;i<=9;i++))
05 do
06 for ((j=1;j<=i;j++))
07 do
08 let "product=i*j"
09 printf "$i*$j=$product"
10 if [[ "$product" -gt 9 ]]
11 then
12 printf " "
13 else
14 printf " "
15 fi
16 if [[ "$j" -eq 5 ]]
17 then
18 # 增加参数 2
19 break 2
20 fi
21 done
22 echo
23 done

在上面的代码中，第19行使用了2作为break语句的参数，从而使得该语句能够退出两层循环结构。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-19.sh
1*1=1
2*1=2 2*2=4
3*1=3 3*2=6 3*3=9
4*1=4 4*2=8 4*3=12 4*4=16
5*1=5 5*2=10 5*3=15 5*4=20 5*5=25 [root@linux chapter5]#

可以看到，这次的执行结果与【例5-17】完全相同。值得注意的是，乘法表的最后一行与命令提示符位于同一行中。这是因为代码的第19行执行break语句后就直接退出程序了，所以第22行的echo语句并没有得到执行。

5.5.2　利用continue语句控制循环

前面介绍的break语句是退出循环体。而continue语句则比较有趣，它的作用不是退出循环体，而是跳过当前循环体中该语句后面的语句，重新从循环语句开始的位置执行。

【例5-20】演示continue语句的使用方法，代码如下：

01 #-----------------------------/chapter5/ex5-20.sh------------------
02 #! /bin/bash
03
04 for var in {1..10}
05 do
06 # 如果当前数字为奇数
07 if [[ "$var%2" -eq 1 ]]
08 then
09 # 跳过后面的语句
10 continue
11 fi
12 echo "$var"
13 done

上面的代码的作用是输出10以内的偶数。第7行判断当前数字能否被2整除，如果求模结果为0，则表示当前数为偶数；如果为1，则当前数为奇数。在当前数为奇数的情况下，执行continue语句，跳过后面的输出语句，从第4行开始重新执行循环。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-20.sh
2
4
6
8
10

注意：与break语句一样，continue语句也可以附加一个整数作为参数，表示要跳过的循环层数，但是很少使用。

5.5.3　分析break语句和continue语句的区别

关于break语句和continue语句的区别，实际上在前面介绍这两个语句的时候已经分别做了说明。但是并没有进行细致的对比，为了能够使读者更加深入理解这两个语句的用法，在此对这两个语句的区别进行进一步介绍。

正如前面所讲，break语句和continue语句都可以位于各种循环体内，用于控制当前的循环流程。但是，break语句是直接退出当前的循环结构，转向执行循环体后面的语句；而continue语句则只是跳过当前循环体中continue语句后面的语句，转向当前循环体的起始位置，重新执行下一次循环，并没有退出当前的循环结构。这是这两者的最本质的区别。

另外，没有参数的break语句和continue语句都只是影响到本层的循环流程，如果想要影响多层循环，则可以附加数字参数。

【例5-21】演示在双层嵌套循环中，break语句对于流程的影响，代码如下：

01 #-----------------------------/chapter5/ex5-21.sh------------------
02 #!/bin/sh
03
04 # 外层循环
05 for i in a b c d
06 do
07 echo -n "$i "
08 # 内层循环
09 for j in 'seq 10'
10 do
11 if [ $j -eq 5 ];then
12 break
13 fi
14 echo -n "$j "
15 done
16 echo
17 done

在上面的代码中，第5行是外层循环的开始，该层循环使用a、b、c和d这4个字母作为条件列表。第9行是内层循环的开始，使用seq命令生成1～10的整数列表。第11行判断内层循环变量是否等于5，如果等于5则退出内层循环。

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-21.sh
a 1 2 3 4
b 1 2 3 4
c 1 2 3 4
d 1 2 3 4

在上面的结果中，最左边的1列字母是由外层循环输出的。当外层循环第1次执行时，代码第7行输出了字母a，接着就进入了内层循环，从1开始递增，当执行到代码第14行时，输出了数字1。紧接着，内层循环便开始执行第2次，此时变量j的值变成了2，仍然小于5，于是代码第14行的echo语句便输出了2。依此类推，在内层循环的第3次和第4次循环过程中，分别输出了数字3和4。当第5次循环时，此时变量j的值已经等于5，第11行的if语句的条件测试的值为0，于是，代码第12行的break语句得到了执行，退出了内层循环。因此，内层循环输出到4就终止了。另外含有-n选项的echo语句不会自动换行，所以前面几次的输出都位于同一行中。

当内层循环通过break语句退出之后，执行代码第16行的echo语句，导致输出换行。然后继续执行外层循环，输出了结果第2行中的字母b。再接着又重新进入了内层循环，输出了结果第2行中的后面4个数字，这个过程与结果第1行中的4个数字的输出完全相同，不再重复介绍。

依此类推，当外层循环执行4次之后，整个程序就退出了。从上面的执行结果可以得知，break语句会直接退出内层循环，但是不影响外层循环的执行。

【例5-22】演示通过break语句跳出指定的层数，代码如下：

01 #-----------------------------/chapter5/ex5-22.sh------------------
02 #!/bin/sh
03
04 for i in a b c d
05 do
06 echo -n "$i "
07 for j in 'seq 10'
08 do
09 if [ $j -eq 5 ];then
10 # 指定跳出层数 2
11 break 2
12 fi
13 echo -n "$j "
14 done
15 echo
16 done

该程序的执行结果如下：

[root@linux chapter5]# ./ex5-22.sh
a 1 2 3 4 [root@linux chapter5]#

从上面的执行结果可以得知，外层循环只执行1次，整个程序便退出了。而内层循环却输出了4次，执行了5次。当内层循环第5次执行时，变量j的值便成了5，满足代码第9行的条件测试，于是代码第11行的break语句得到了执行，该语句的执行导致了程序直接退出了内外两层循环，即使是代码第15行的echo语句也没有机会执行，所以，在上面的执行结果中，程序的输出与Shell命令提示符位于同一行中。

分析完了break和带参数的break语句的区别。接下来继续对【例5-21】进行修改，将代码第12行的break语句替换为continue语句，从而对比continue语句break语句的区别。

【例5-23】通过continue语句跳过当前循环后面的语句，代码如下：

01 #-----------------------------/chapter5/ex5-23.sh------------------
02 #!/bin/sh
03
04 for i in a b c d
05 do
06 echo -n "$i "
07 for j in 'seq 10'
08 do
09 if [ $j -eq 5 ];then
10 # 跳过后面的语句
11 continue
12 fi
13 echo -n "$j "
14 done
15 echo
16 done

【例5-23】与【例5-21】的唯一区别就是代码第12行。为了便于比较分析，下面首先看一下【例5-23】的执行结果，如下：

[root@linux chapter5]# ./ex5-23.sh
a 1 2 3 4 6 7 8 9 10
b 1 2 3 4 6 7 8 9 10
c 1 2 3 4 6 7 8 9 10
d 1 2 3 4 6 7 8 9 10

从上面的执行结果可以看到，换成continue语句之后，外层循环仍然执行了4次，但是内层循环却从1一直增加到10，只是少了5。

为什么会出现这种结果呢？我们分析一下【例5-23】的执行过程可以弄清楚了。【例5-23】的外层循环的执行过程与【例5-21】的外层循环的执行过程完全相同，不再重复分析。在此重点分析一下内层循环的执行过程。当刚刚进入内层循环之后，循环变量j的值从1开始增加，1～4这4个数字都是小于5的，所以，代码第13行的echo语句便输出了这4个数字。当内层循环在执行第5次循环时，变量j的值变成了5，代码第9行的条件测试的值为0，代码第11行的continue语句得到了执行。该语句的执行导致了代码第13行的echo语句被跳过，所以数字5便没有被输出。

但是continue语句并没有跳出内层循环，而是从代码第7行开始继续执行内层循环，此时变量j的值变成了6，代码第9行的条件测试不再等于0，所以第13行的echo语句便得以执行，输出了数字6。同理，7～10这几个数字也得到了输出。

用户可以对比【例5-21】和【例5-23】的执行结果，思考一下这两者的区别到底在哪里。

与break语句一样，continue语句也可以接收一个整数作为参数，从而使得当前的程序

能够跳过指定的层数。但是，含有参数的continue语句和含有参数的break语句同样存在着很大的区别。

【例5-24】演示含有参数的continue语句的使用方法，代码如下：

01 #-----------------------------/chapter5/ex5-24.sh------------------
02 #!/bin/sh
03
04 for i in a b c d
05 do
06 echo -n "$i "
07 for j in 'seq 10'
08 do
09 if [ $j -eq 5 ];then
10 # 使用含有数字参数的 continue 语句
11 continue 2
12 fi
13 echo -n "$j "
14 done
15 echo
16 done

同样，我们先看一下该程序的执行结果，如下：

[root@linux chapter5]# ./ex5-24.sh
a 1 2 3 4 b 1 2 3 4 c 1 2 3 4 d 1 2 3 4 [root@linux chapter5]#

可以看出，【例5-24】所有的输出信息都位于同一行中，包括Shell命令提示符。但是，除了没有换行之外，【例5-24】的结果与【例5-21】是完全相同的。下面我们分析一下【例5-24】的执行过程，以了解为什么会得到上面的输出结果。

同样，当外层循环第1次执行时，代码第6行的echo语句便输出了字母a。接着进入内层循环，内层循环执行了4次，依次输出了数字1、2、3和4。当内层循环在第5次执行时，变量j的值为5，这使得代码第11行的continue 2语句得到执行。该语句执行的后果是直接跳过了外层循环剩下的语句，即第15行的echo语句。所以，当外层循环执行完第1次之后，并没有换行。

依此类推，当外层循环执行了4次之后，整个程序便终止了。但是自始至终，代码第15行的echo语句始终没有机会得以执行，所以程序输出的所有信息都在同一行里面。

5.6　小结

本章详细介绍了Shell语言中的循环语句的使用方法。主要内容包括步进循环语句for、until循环语句、while循环语句、嵌套循环结构，以及break和continue这两种循环控制语句的使用方法。重点在于掌握3种循环语句的基本用法，理解break和continue的区别，对于简单的嵌套循环结构，也应该有所了解。在下一章中，将介绍函数的用法。

第6章
函数

和其他程序设计语言一样，在Shell语言中也存在着函数。尽管在Shell中，函数并非必需的编程元素，但是通过使用函数，可以对程序进行更好地组织。将一些相对独立的代码变成函数，可以提高程序的可读性和重用性，避免重复编写大量相同的代码。本章将从函数的基础知识开始，依次介绍函数的定义、调用方法，以及参数传递等。

本章主要涉及的知识点如下所述。

- 函数：主要介绍函数的基础知识，包括什么是函数、函数的定义方法、函数的调用方法、函数的返回值、函数和别名的区别，以及函数中的全局变量和局部变量。

- 函数参数：主要介绍函数的参数传递，包括带参数的函数的调用方法、如何获取函数的参数的个数、通过位置变量获取参数值、移动位置变量、通过getopts获取参数值、间接参数传递、通过全局变量传递参数；以及传递数组参数等。

- 函数库文件：主要介绍如何共享函数，主要包括函数库文件的定义以及函数库文件的调用方法。

- 递归函数：介绍如何在Shell中实现递归函数及其调用方法。

6.1　函数

函数几乎是学习所有程序设计语言时都必须过的一关。对于学习过其他程序语言的用户来说，函数可能并不陌生。但是Shell中的函数与其他程序设计语言的函数有许多不同之处。为了使用户了解Shell中的函数，本节将介绍函数的相关基础知识。

6.1.1　什么是函数

通俗地讲，所谓函数就是将一组功能相对独立的代码集中起来，形成一个代码块，这个代码可以完成某个具体的功能。从上面的定义可以看出，Shell中的函数的概念与其他语言的函数的概念并没有太大的区别。从本质上讲，函数是一个函数名到某个代码块的映射。也就是说，用户在定义了函数之后，就可以通过函数名来调用其所对应的一组代码。

Shell函数可以完成某些相对独立的功能。因此，为了提高代码的可读性和代码的重用性，程序员应该将某些功能通过定义函数的方式来实现。这样的话，如果程序的其他地方需要相同的功能，只要调用该函数就可以了，无需重复书写相同的代码。

从形式上看，函数与Shell脚本并没有明显的区别，Shell脚本中可以使用的命令和语句，都可以在函数中使用。但是，在执行的时候，函数和脚本还是有着本质上的区别。实际上，对于这一点，许多初学者，甚至包括许多已经接触Shell程序多年的用户来说，都没有完全弄清楚。对于一个单独的Shell脚本来说，在执行的时候，会为其创建一个新的Shell进程，来解释并执行脚本中的代码，当脚本执行完成，该Shell进程就会自动结束；而对于一个函数来说，在调用的时候，系统并不会为其单独创建一个Shell进程，而是在调用者的Shell进程中直接解释并执行函数中的代码。

6.1.2　函数的定义

在了解了函数的基本概念之后，接下来介绍Shell中的函数的定义方法。由于Shell脚本是从头开始执行的，所以Shell规定，函数必须在调用前定义。

在Shell语言中，用户可以通过两种语法来定义函数，分别如下：

function_name ()
{
statement1
statement2
...
statement n
}

或者

function function_name ()
{
statement1
statement2
...
statement n
}

上面两种语法的区别在于后者在函数名称前面使用了function关键字。其中function_name表示函数名称。Shell中的函数的命名规则与变量的命名规则基本相同，可以使用数字、字母或者下划线，但是只能以字母或者下划线开头。另外，用户应该尽量使用有意义的英文单词来命名函数，以利于提高代码的可读性。

Shell函数的函数体由一对大括号包括起来。其中包含一系列的Shell语句。通常情况下，能够在Shell脚本中使用的Shell命令都可以在函数中使用。

【例6-1】演示函数定义的方法。在本例中，定义了一个名称为sayhello()的函数，代码如下：

01 #-----------------------------/chapter5/ex6-1.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 function sayhello()
06 {
07 echo "Hello, World!"
08 }
09 # 调用函数
10 sayhello

在上面的代码中，第5行使用function关键字定义sayhello()函数，第5行～第8行是函数体，该函数只有1行代码，即使用echo语句输出1个字符串。第10行调用上面定义的函数。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-1.sh
Hello, World!

如果省略function关键字，则【例6-1】可以修改为以下代码：

01 #! /bin/bash
02
03 # 定义函数
04 sayhello()
05 {
06 echo "Hello, World!"
07 }
08 # 调用函数
09 sayhello

前面已经讲过，在Shell语言中，函数必须在调用前定义。因此，如果将【例6-1】中第10行的函数调用语句移到前面，如下：

01 #! /bin/bash
02
03 # 调用函数
04 sayhello
05 # 定义函数
06 sayhello()
07 {
08 echo "Hello, World!"
09 }

则会出现问题，其结果如下：

[root@linux chapter6]# ./ex6-1.sh
./ex6-1.sh: line 3: sayhello: command not found

上面的错误提示给出，未发现sayhello命令。之所以会出现以上错误，是因为Shell在执行脚本的时候，会优先将sayhello作为一个Shell命令来执行。

6.1.3　函数的调用

当某个函数定义之后，用户就可以通过函数名来调用该函数。在Shell中，函数调用的基本语法如下：

function_name param1 param2 …

在上面的语法中，function_name表示函数名称，其后面跟的param1、param2…表示函数的参数。

在上面的例子中，实际上已经调用过函数了。为了使读者更加清楚函数的调用方法，下面进一步举例说明。

【例6-2】介绍函数的定义和调用方法，代码如下：

01 #-----------------------------/chapter6/ex6-2.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 getCurrentTime()
06 {
07 current_time=`date`
08 echo "$current_time"
09 }
10
11 # 调用函数
12 getCurrentTime

在上面的代码中，第5行～第9行定义了名称为getCurrentTime()的函数，该函数的功能是输出当前的系统时间。其中第7行通过date命令来获取当前系统时间，并且将其值赋给变量current_time。第12行通过函数名来调用函数。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-2.sh
Tue Jan 22 23:30:34 HKT 2013

注意：当用户定义了一个函数之后，实际上该函数就成为一个合法的Shell命令，可以在后继的脚本中使用。定义函数时需要使用圆括号，而调用函数时无需圆括号。

6.1.4　函数链接

所谓函数链接，是指在某个Shell函数中调用另外一个函数的过程。与其他程序设计语言一样，Shell允许用户函数的嵌套调用。

【例6-3】演示在某个函数中调用另外一个函数的函数链接，代码如下：

01 #-----------------------------/chapter6/ex6-3.sh------------------
02 #! /bin/bash
03
04 # 定义函数 john()
05 john()
06 {
07 echo "Hello, this is John."
08 }
09 # 定义函数 alice
10 alice()
11 {
12 # 调用函数 john
13 john
14 echo "Hello, this is Alice."
15 }
16 # 调用函数 alice
17 alice

在上面的代码中，第5行～第8行定义了一个名称为john()的函数，第10行～第15行定义了名称为alice()的函数，其中在第13行调用了前面定义的函数john()。第17行调用函数alice()。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-3.sh
Hello, this is John.
Hello, this is Alice.

从上面的执行结果可以得知，程序首先输出了关于john的信息，这条信息是函数john()输出的。然后输出了关于alice的信息，而这条信息是程序的第14行输出的。

函数链接并不仅仅局限于在某个函数中调用另外一个函数，还可以进行多层嵌套调用，或者在某个函数中调用多个函数，下面的例子就说明了这种情况。

【例6-4】演示在某个函数中同时调用多个其他函数的方法，代码如下：

01 #-----------------------------/chapter6/ex6-4.sh------------------
02 #! /bin/bash
03
04 # 定义函数 john()
05 john()
06 {
07 echo "Hello, this is John."
08 }
09
10 # 定义函数 alice()
11 alice()
12 {
13 echo "Hello, this is Alice."
14 }
15 # 定义函数 sayhello()
16 sayhello()
17 {
18 john
19 alice
20 }
21 # 调用函数 sayhello()
22 sayhello

在上面的代码中，第5行～第8行定义了函数john()，第11行～第14行定义函数alice()，第16行～第20行定义函数sayhello()。其中，在函数sayhello()内部同时调用了函数john()和函数alice()。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-4.sh
Hello, this is John.
Hello, this is Alice.

从上面的执行结果可以得知，函数sayhello()通过调用前面两个函数，依次输出相关的信息。

注意：在函数嵌套调用时，一定要注意函数定义的顺序和调用的顺序。必须按照先定义，后调用的原则。

6.1.5　函数的返回值

尽管在其他程序设计语言中，函数的返回值似乎是一个习以为常的问题，也就是说，函数就应该根据用户输入的参数，进行相关的运算，然后将运算结果返回给调用者。然而，在Shell语言中，函数的返回值并不显得理所当然。这意味着Shell中的函数并没有准备要把函数中的某个运算结果作为函数的返回值来返回。因此，在Shell中，处理函数的返回值可以分为几种情况来讨论。

首先，用户可以使用return语句来返回某个数值，这与绝大部分的程序设计语言是相同的。但是，在Shell中，return语句只能返回某个0～255之间的整数值。还记得前面介绍过的脚本的退出状态码吗？当一个Shell脚本执行结束的时候，可以通过exit命令返回其退出状态码。与之相类似，函数中的return语句实际上是用来返回函数的退出状态码的。

【例6-5】演示如何通过return语句返回某个数值，代码如下：

01 #-----------------------------/chapter6/ex6-5.sh------------------
02 #! /bin/bash
03
04 # 定义求和函数
05 sum()
06 {
07 let "z = $1 + $2"
08 # 将和作为退出状态码返回
09 return "$z"
10 }
11 # 调用求和函数
12 sum 22 4
13 # 输出和
14 echo "$?"

在上面的代码中，第5行～第10行定义了一个求和函数，其中第7行计算两个参数的和。关于函数参数的传递将在6.2节中介绍。第9行通过return语句将变量$z返回。第12行调用函数sum()，并且传递两个参数。与执行脚本相同，函数的退出状态码也可以通过系统变量$?获取，所以第14行通过变量$?获取了函数sum()的返回值（在此作为函数退出状态码返回）。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-5.sh
26

从上面的执行结果可以得知，所传递的两个数的和被成功地返回。但是通过return语句只能返回整数值，并且是0～255之间的整数值，如果超出这个范围，则会返回错误的结果。例如，如果将第12行的两个参数改为以下两个数：

sum 253 4

则执行结果如下：

[root@linux chapter6]# ./ex6-5.sh
1

可以发现，正确的结果应该是257，但是函数的返回值却是1。

尽管在函数中使用return语句返回用户所需要的数据存在着许多限制，但是在适当的时候使用仍然不失是一种非常便捷的方法。在Shell中，还有一种更加优雅的方法可以帮助用户获得函数执行后的某个结果，那就是使用echo语句。

在函数中，用户将需要返回的数据写入到标准输出（stdout），通常这个操作是使用echo语句来完成的。然后在调用程序中将函数的执行结果赋给一个变量。这种做法实际上就是命令替换的一个变种。

【例6-6】演示如何通过echo语句来传递函数返回值，代码如下：

01 #-----------------------------/chapter6/ex6-6.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 length()
06 {
07 # 接收参数
08 str=$1
09 result=0
10 if [ "$str" != "" ]; then
11 # 计算字符串长度
12 result=${#str}
13 fi
14 # 将长度值写入标准输出
15 echo "$result"
16 }
17 # 调用函数
18 len=$(length "abc123")
19 # 输出执行结果
20 echo "the string's length is $len"

在上面的代码中，第5行～第16行定义了一个名称为length()的函数，用来计算字符串的长度。其中，第8行通过位置变量$1接收参数，第12行计算用户输入的字符串的长度，第15行通过echo语句将字符串的长度值写入标准输出。第18行调用行数，并将执行结果赋给变量len。第20行输出变量len的值。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-6.sh
the string's length is 6

注意：Shell程序实际上是由许多Shell命令组成的，但是为了与其他程序设计语言保持一致，在本书中仍然将其称为语句，例如echo。

由于用户可以将各种数据写入到标准输出，所以通过echo语句可以将各种数据作为返回值返回给函数调用者，而不仅仅局限于整数。因此，使用echo语句获得函数的返回值更加灵活和强大。

6.1.6　函数和别名

在Shell中，与函数比较相似的一个概念就是别名。一个别名是一个Shell命令的缩写或者其他容易记忆的名称。用户可以使用alias命令来设置别名，其基本语法如下：

alias name="command"

其中，name是要指定的别名，command则是原有的Shell命令，也就是真正要执行的命令。

函数和别名的相似之处在于，它们都是通过一个名称映射到一个或者一组命令。例如函数是函数名到一组Shell命令的映射，而别名则是别名到一个Shell命令的映射。无论是函数还是别名，在调用时都是执行其所对应的相关命令。

与函数相比，别名的功能相对比较简单，其主要区别有以下两点：

- 用户不能为一组命令指定别名。

- 别名中不能通过系统变量操作参数列表。

为了使读者更加清楚函数和别名的区别，下面举一个简单的例子。首先使用alias命令定义一个别名，如下：

[root@linux chapter6]# alias ls="ls -l"

上面的命令表示将ls-l这个命令的别名命名为ls，这样的话用户在命令行中输入ls命令之后，实际上执行的就是含有-l选项的ls命令，如下：

[root@linux chapter6]# ls .
total 24
-rwxr-xr-x 1 root root 63 Jan 22 17:33 ex6-1.sh
-rwxr-xr-x 1 root root 98 Jan 22 23:20 ex6-2.sh
…

当用户不再需要某个别名时，可以使用unalias命令将其删除，如下：

[root@linux chapter6]# unalias ls
[root@linux chapter6]# ls .
ex6-1.sh ex6-2.sh ex6-3.sh ex6-4.sh ex6-5.sh ex6-6.sh

从上面的执行结果可以得知，当别名被删除之后，用户再执行ls命令时，会执行原始的ls命令，因此，上面的命令只是简单地输出了文件名。

实际上，上述功能完全可以使用函数来实现。前面介绍的函数都是在脚本中定义的，实际上，用户可以直接在Shell环境中定义函数，其方法就是直接在Shell命令提示符后面依次输入函数名和函数体。例如，为了定义一个名称为lsl()的函数，用户可以在命令行中依次输入以下语句：

01 [root@linux chapter6]# lsl()
02 > {
03 > ls -l "$@"
04 > }

其中，第1行输入函数名称，当按回车键之后，Shell会继续等待用户输入，因此后面的几行中在命令行的左侧出现一个大于号提示符。用户可以继续输入函数的函数体，当用户输入最后的右侧大括号之后，函数定义完成，又重新出现Shell提示符。

函数定义完成之后，就可以直接在当前的Shell中使用，如下：

[root@linux chapter6]# lsl .
total 24
-rwxr-xr-x 1 root root 63 Jan 22 17:33 ex6-1.sh
-rwxr-xr-x 1 root root 98 Jan 22 23:20 ex6-2.sh
…

当然，上面例子中的函数只是简单地执行了ls命令，实际上在函数内部可以对文件进行更加复杂地操作，例如删除文件或者复制文件等。关于这些功能，用户可以在上面的例子的基础上尝试实现。

用户定义的Shell函数可以通过unset命令来删除，删除以后的函数名将不再有效，如下：

[root@linux chapter6]# unset lsl
[root@linux chapter6]# lsl
-bash: lsl: command not found

注意：在前面的例子中，我们定义了一个名称为ls的别名，而Shell中已经存在一个名称为ls的命令。但是在这种别名和同名命令同时存在的情况下，Shell会优先使用用户定义的别名，所以在上面的例子中，用户尽管没有指定要执行别名还是真实的命令，但是Shell会优先执行别名。

6.1.7　再议全局变量和局部变量

本书在第3章中已经详细介绍过了变量的使用方法。默认情况下，除了与函数参数关联的特殊变量之外，其他所有的变量都有全局的有效范围。另外，在函数内部，如果没有使用local关键字进行修饰，那么函数中的变量也是全局变量。

【例6-7】演示在函数内外所定义的全局变量的使用方法，代码如下：

01 #-----------------------------/chapter6/ex6-7.sh------------------
02 #! /bin/bash
03
04 # 在函数外定义全局变量
05 var="Hello world"
06
07 func()
08 {
09 # 在函数内改变变量的值
10 var="Orange Apple Banana"
11 echo "$var"
12 # 在函数内定义全局变量
13 var2="Hello John"
14 }
15 # 输出变量值
16 echo "$var"
17 # 调用函数
18 func
19 # 重新输出变量的值
20 echo "$var"
21 # 输出函数内定义的变量的值
22 echo "$var2"

在上面的代码中，第5行定义了一个名称为var的变量，该变量在函数外面定义，所以该变量属于全局变量，其有效范围是整个脚本，无论是在函数内还是在函数外，都可以正常引用。第7行～第14行定义了一个名称为func()的函数，其中，第10行为变量var重新赋值，第11行输出变量var的值。第13行在函数内部定义了一个名称为var2的函数。第16行在函数外面输出变量var的值。第18行调用了函数func()。由于函数内部修改了变量var的值，所以在第20行又重新输出了该变量的值。第22行输出变量var2的值，该变量是在函数内部定义的。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-7.sh
Hello world
Orange Apple Banana
Orange Apple Banana
Hello John

在上面的执行结果中，第1行的字符串是第16行代码的echo语句的输出，此时函数还没有被调用，所以变量var的值并没有改变。第2行的字符串是第11行代码的函数内的echo语句的输出结果，此时变量var的值已经被修改。而第3行的字符串是第20行代码的echo语句的执行结果，此时函数被调用，所以变量var的值已经改变。最后一行的字符串是第22行代码的echo语句的执行结果。

从上面的执行结果可以得知，无论在何处，赋值语句都会影响全局变量的值。并且，全局变量的值被改变之后，在整个脚本内都有效。另外，默认情况下，在函数内部定义的变量也是全局变量，在脚本的任何位置都可以引用。

【例6-8】演示在函数内部定义局部变量的方法，以及在全局变量和局部变量重名的情况下，会发生什么事情。代码如下：

01 #-----------------------------/chapter6/ex6-8.sh------------------
02 #! /bin/bash
03
04 # 全局变量
05 var="Hello world"
06
07 func()
08 {
09 # 局部变量
10 local var="Orange Apple Banana"
11 echo "$var"
12 # 局部变量
13 local var2="Hello John"
14 }
15 echo "$var"
16 func
17 echo "$var"
18 echo "$var2"

其实本例和【例6-7】基本相同，区别在于前者的第10行和第13行的变量名称前面都使用了关键字local。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-8.sh
Hello world
Orange Apple Banana
Hello world

在上面的执行结果中，一共有4行输出。其中第1行是第15行代码的echo语句的执行结果，该语句输出了全局变量var的值。第2行是第11行代码的输出，可以发现，该语句输出了局部变量var的值。第3行是第17行代码的输出，尽管我们在第16行代码调用了函数func()，但是这并没有影响到全局变量var的值，该语句仍然输出了变量var的初始值。第4行是一个空行，是第18行代码的输出结果。之所以会输出空行，是因为第13行代码中的变量var2的作用域仅仅局限于函数内部。

从【例6-8】可以得知，当某个程序中同时存在相同名称的全局变量和局部变量时，在函数内部，局部变量会屏蔽全局变量。也就是说，在函数内部，使用的是局部变量，而函数外面，使用的是全局变量。

6.2　函数参数

在6.1节中，我们介绍的绝大部分函数都没有参数。实际上，对于函数来说，参数非常重要。通过参数，用户可以将想要函数处理的数据传递给函数。Shell语言中，同样支持含有参数的函数。本节将介绍函数参数的使用方法。

6.2.1　含有参数的函数的调用方法

在某些程序设计语言中，函数的参数分为形参和实参，其中，形参是在函数定义的时候指定的参数；而实参则是在函数调用的时候指定的参数。通常情况下，函数的实参都是放在圆括号里面，如果有多个参数，则这些参数之间使用逗号隔开。当然，也有一些语言在调用函数的时候并不是将参数放在圆括号里面，例如Visual Basic等。但是，这些语言的参数仍然需要使用逗号隔开。

与这些程序设计语言相比，Shell的函数参数的语法比较特殊。实际上，Shell将脚本参数和函数参数做了统一地处理。也就是说，Shell采用了相同的方法来处理脚本的参数和函数参数。因此，对于含有参数的函数，用户可以使用以下语法来调用。

function_name arg1 arg2 ...

其中，function_name表示函数名称，arg1、arg2及arg3等则表示函数参数，这些参数之间通过空格隔开。用户可以发现，实际上这种语法与执行Shell脚本的语法完全相同。

6.2.2　获取函数参数的个数

在Shell中，不仅含有参数的函数的调用方法和执行脚本的语法相同，而且，在函数内部，用户也是通过位置变量来接收参数的值。这一点，与Shell脚本也是完全相同的。前面已经介绍过，用户可以通过系统变量$#来获取脚本的参数的个数。

【例6-9】演示如何通过$#来获取函数参数的个数，代码如下：

01 #-----------------------------/chapter6/ex6-9.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 func()
06 {
07 # 输出参数个数
08 echo "the function has $# parameters."
09 }
10 # 调用函数
11 func a b c d e f g hello
12 func 12 3 "hello world"
13 func

在上面的代码中，第5行～第9行定义了名称为func()的函数，其中第8行输出了参数的个数。第11行调用func()函数，提供了8个参数，第12行同样是调用func()函数，但是只提供了3个参数。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-9.sh
the function has 8 parameters.
the function has 3 parameters.
the function has 0 parameters.

从上面的执行结果可以得知，当用户在Shell脚本中定义函数时，实际上并没有指定该函数到底拥有多少个参数。函数最终会有多少个参数，取决于用户在调用该函数时为该函数提供了多少个参数。在上面的例子中，当用户为函数func()提供8个参数时，系统变量$#输出了参数个数为8；当用户为函数func()提供3个参数时，系统变量$#的值为3；而当用户没有为函数提供参数时，系统变量$#的值为0。与其他程序设计语言相比，在这一点上面，Shell函数拥有非常明显的特点。

另外，在第12行代码中，第3个参数为“hello world”，中间含有一个空格，在这种情况下，用户应该使用单引号或者双引号将其引用起来，免得Shell误认为是两个参数。

注意：Shell函数的参数以空格隔开，如果某个参数中含有空格，则应该使用引号将其引用起来。

6.2.3　通过位置变量接收参数值

与Shell脚本一样，用户可以在Shell函数中使用位置变量来获取参数值。例如，$0表示脚本名称，$1表示第1个参数，$2表示第2个参数等，依此类推。另外，用户还可以通过系统变量$@和$*获取所有参数的值，至于这两者的区别，与3.1节中介绍的完全相同，不再重复说明。

【例6-10】演示如何在函数中使用位置变量获取参数的值，代码如下：

01 #-----------------------------/chapter6/ex6-10.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 func()
06 {
07 # 输出所有的参数
08 echo "all parameters are $*"
09 # 输出所有的参数
10 echo "all parameters are $@"
11 # 输出脚本名称
12 echo "the script's name is $0"
13 # 输出第 1 个参数
14 echo "the first parameter is $1"
15 # 输出第 2 个参数
16 echo "the second paramter is $2"
17 }
18 # 调用函数
19 func hello world

在上面的代码中，第8行使用系统变量$*获取所有参数值，第10行使用系统变量$@获取所有的参数值，第12行使用位置变量$0获取当前脚本的名称，第14行和第16行分别使用位置变量$1和$2获取第1个和第2个参数的值。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-10.sh
all parameters are hello world
all parameters are hello world
the script's name is ./ex6-10.sh
the first parameter is hello
the second paramter is world

从上面的执行结果可以得知，系统变量$*和$@都获取了函数的所有的参数，在本例中，第8行和第10行代码的输出从表面上看不出有什么区别。位置变量$0也成功地获取当前脚本的名称，$1和$2分别获取了第1个和第2个参数值。

注意：无论是在脚本中，还是在函数中，位置变量$0获取的都是当前执行的脚本的名称。

6.2.4　移动位置参数

在前面介绍中，在Shell脚本中，用户可以使用shift命令来使得脚本的所有的位置参数向左移动一个位置，从而使得用户可以通过9以内的位置变量来获取超过9个的参数。在函数中，这种方法仍然适用。

【例6-11】通过一种非常巧妙的方法依次获取传递给函数的各个参数，代码如下：

01 #-----------------------------/chapter6/ex6-11.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 func()
06 {
07 # 通过 while 循环和 shift 命令依次获取参数值
08 while (($# > 0))
09 do
10 echo "$1"
11 shift
12 done
13 }

在上面的代码中，通过while循环语句配合shift命令获取各个参数的值。第8行是循环语句的开始，其中将$#变量的值大于0作为循环执行的条件。我们已经知道系统变量$#获取的传递给函数或者脚本的参数的个数。当传递给函数func()的参数个数为0时，终止循环。第10行输出第1个参数的值。第11行是执行shift命令。

上面的程序之所以会正确地执行，关键之处就在于shift命令。该命令的功能是将传递给函数func()的位置参数向左移动一个位置，同时删除原来的第1个参数。因此，每执行1次shift命令，所有的位置参数都会向左移动1个位置，原来处于第2个位置的参数会移动到第1个位置上面。同时，函数的参数个数也会随之减1。所以，当传递给函数func()的参数的个数变成0时，就表示所有的参数都已经移动完成。另外，由于所有的参数都会移动到第1个位置上面，所以用户只要使用一个位置变量$1就可以了。

注意：shift命令会影响到系统变量$#的值。

6.2.5　通过getopts接收函数参数

在绝大多数情况下，用户可以通过位置变量$1、$2…$n等手动获取每个参数。但是，如果用户需要处理情况比较多，或者是多个分支的情况，这个方法就不够用了。这个时候，用户可以考虑使用getopts来接收参数。

getopts是bash内置的一个命令，通过该命令，用户可以获取函数的选项以及参数值，或者是脚本的命令行选项以及参数值。getopts命令的基本语法如下：

getopts optstring [args]

在上面的语法中，参数optstring包含一个可以为getopts命令识别的选项名称列表。如果某个选项名称的后面跟随着一个冒号，则表示用户可以为该选项提供参数值。同时，参数值将被保存到一个名称为$OPTARG的系统变量中。getopts命令会依次遍历每个选项，选项名称将被保存到args变量中。

【例6-12】演示如何通过getopts命令获取各个选项及其参数值，代码如下：

01 #-----------------------------/chapter6/ex6-12.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 func()
06 {
07 # 逐个接收选项及其参数
08 while getopts "a:b:c" arg
09 do
10 # 当指定了 -a 选项时
11 case "$arg" in
12 a)
13 # 输出 -a 选项的参数值
14 echo "a's argument is $OPTARG"
15 ;;
16 b)
17 echo "b's argument is $OPTARG."
18 ;;
19 c)
20 echo "c"
21 ;;
22 ?)
23 # 未知选项
24 echo "unkown argument."
25 exit 1
26 ;;
27 esac
28 done
29 }
30 # 调用函数
31 func -a hello -b world

在上面的代码中，第8行通过getopts命令获取用户提供给函数func()的各个选项，其中函数func()可以接收的选项有a、b和c，其中选项a和b可以含有参数，而选项c不含有参数。在while循环的过程中，当前选项名称将被保存到变量arg中，而选项的参数值将被保存到系统变量$OPTARG中。

当用户提供了-a和-b选项时，输出参数值。当用户提供了-c选项时，只是简单地输出一个字符“c”。如果用户提供了未知选项（不在选项列表中的选项）时，输出相应的提示信息。

第31行调用函数func()，并且提供了-a和-b这两个选项及其参数。该函数的执行结果如下：

[root@linux chapter6]# ./ex6-12.sh
a's argument is hello.
b's argument is world.

从上面的执行结果可以得知，程序已经成功地获取了-a和-b这两个选项的参数值。

注意：系统变量$OPTARG是一个内置的系统变量。

6.2.6　间接参数传递

在Shell中，函数还支持间接参数传递。所谓间接参数传递，是指通过间接变量引用来实现函数参数的传递。如果某个变量的值又是另外一个变量的变量名，则该变量称为间接变量。

例如，在某个脚本中，存在以下两个变量：

var=name
name=John

可以发现，在上面的代码中，变量var的值恰好是后面一个变量的变量名。此时，对于第2个变量，用户可以通过以下两种方式来引用：

${name}
${!var}

其中第1种引用方法是直接通过变量名来引用，而第2种引用方法则是通过第1个变量名来间接地引用。在Shell中，变量的间接引用通常使用以下语法来实现：

${!var_name}

其中，变量var_name的值是另外一个变量的名称。当用户使用以上语法间接引用变量时，实际上取得的是var_name的值所代表的变量的值，而非变量var_name的值。

【例6-13】演示如何在函数中使用间接参数传递，代码如下：

01 #-----------------------------/chapter6/ex6-13.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 func()
06 {
07 echo "$1"
08 }
09
10 # 定义变量
11 var=name
12 name=John
13 # 调用函数
14 func "$var"
15 func ${!var}
16 # 修改变量的值
17 name=Alice
18 # 再次调用函数
19 func "$var"
20 func ${!var}

在上面的代码中，函数func()比较简单，只有一行代码，输出第1个参数的值。第11～第12行定义了两个变量，其中变量var的值是下面一个变量的名称。第14行将变量var作为参数传递给函数，第15行通过间接引用的方法将第2个变量作为参数传递给函数。第17行修改变量name的值。第19行～第20行分别调用函数func()。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-13.sh
name
John
name
Alice

在上面的输出结果中，第1个字符串name是第14行代码的函数调用输出的。第2个字符串John是第15行代码的间接参数传递调用func()时输出的。可以发现，当使用间接引用变量时，程序正确地输出了第2个变量的值。第3个字符串name是第19行代码的函数调用输出的。第4个字符串是第20行代码的间接参数传递输出的。从执行结果可以得知，当修改了第2个变量的值之后，通过间接参数传递，用户仍然可以得到正确的结果。

6.2.7　通过全局变量传递数据

参数的作用是在主程序和函数之间传递数据。但是，用户除了可以使用参数传递数据之外，还可以通过全局变量来传递。前面已经介绍过，全局变量的作用域是整个程序，包括函数内部。尽管这种方式是有效的，但是在许多程序设计语言中，这种做法却饱受诟病，其原因在于会导致程序结构非常不清晰，代码的可读性较差。

【例6-14】演示如何通过全局变量向函数传递数据，代码如下：

01 #-----------------------------/chapter6/ex6-14.sh------------------
02 #! /bin/bash
03
04 # 定义全局变量
05 file="/bin/ls"
06 # 定义函数
07 func()
08 {
09 if [ -e "$file" ]
10 then
11 echo "the file exists."
12 else
13 echo "the file does not exist."
14 fi
15 }
16 # 调用函数
17 func
18 # 修改全局变量的值
19 file="/bin/a"
20 # 调用函数
21 func

在上面的代码中，第5行定义了一个全局变量。第9行判断全局变量所表示的文件是否存在。第17行调用前面定义的函数。第19行修改全局变量的值，第21行重新调用函数。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-14.sh
the file exists.
the file does not exist.

注意：无论在何种程序设计语言中，都不建议使用全局变量来传递数据。因此，用户应该尽量避免使用这种方式。

6.2.8　传递数组参数

在某些情况下，用户需要将一个数组作为参数传递给某个函数，然后在函数中对数组内容进行相应的处理。严格地讲，Shell并不支持将数组作为参数传递给函数，但是用户仍然可以通过一些变通的方法实现数组参数的传递。

首先，用户可以将数组的元素展开，然后作为多个由空格隔开的参数传递给函数。下面举例说明这种传递参数的方法。

【例6-15】演示将数组元素作为多个参数传递给函数的方法，代码如下：

01 #-----------------------------/chapter6/ex6-15.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 func()
06 {
07 echo "number of elements is $#."
08 while [ $# -gt 0 ]
09 do
10 echo "$1"
11 shift
12 done
13 }
14 # 定义数组
15 a=(a b "c d" e)
16 # 调用函数
17 func "${a[@]}"

在上面的代码中，函数内部通过while循环语句配合shift命令将数组元素逐个接收过来。第15行定义了一个数组，关于数组的详细使用方法，将在第7章中介绍。第17行调用函数func()，并且将数组传递给函数，其中变量${a[@]}可以获取数组所有元素的值。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-15.sh
number of elements is 4.
a
b
c d
e

从上面的执行结果可以得知，程序可以成功地获取传递过去的数组元素的个数，以及各个元素的值。实际上，在【例6-15】中，数组元素的个数与函数func()的实际参数的个数是相同的，因此，用户可以使用系统变量$#来获取数组元素个数。

在【例6-15】中，有一点非常值得注意，那就是第17行代码在调用函数时，变量${a[@]}外面是使用双引号引用起来的。其主要原因就是数组a的第3个元素中含有空格。在这种情况下，如果将变量${a[@]}外面的双引号去掉，则会将第3个元素误认为是2个元素。代码如下：

01 #! /bin/bash
02
03 # 定义函数
04 func()
05 {
06 while [ $# -gt 0 ]
07 do
08 echo "$1"
09 shift
10 done
11 }
12 # 定义数组
13 a=(a b "c d" e)
14 # 调用函数
15 func ${a[@]}

执行结果如下：

[root@linux chapter6]# ./ex6-15.sh
number of elements is 5.
a
b
c
d
e

从上面的执行结果可以得知，程序已经错误地将数组元素判断为5个。

6.3　函数库文件

在前面的几节中，我们写过一些脚本，用来完成一些特定的任务。许多脚本都要执行一些常用的操作，例如显示出错信息和警告信息，以及提示用户输入等。为了方便地重用这些功能，可以创建一些可重用的函数。这些函数可以单独地放在函数库文件中。本节将介绍如何在Shell程序中创建和调用函数库文件。

6.3.1　函数库文件的定义

创建一个函数库文件的过程类似于编写一个Shell脚本。脚本与库文件之间的唯一区别在于函数库文件通常只包括函数，而脚本中则可以既包括函数和变量的定义，又包括可执行的代码。此处所说的可执行代码，是指位于函数外部的代码，当脚本被载入后，这些代码会立即被执行，无需另外调用。例如，在下面的代码中：

01 #! /bin/bash
02
03 msg="Hello world."
04 echo "$msg"
05 func()
06 {
07 echo "$1"
08 }
09 func "$msg"

第1行～第4行都是可执行代码，第5行～第8行定义了一个名称为func()的函数，虽然其中的第7行是可执行的代码，但是只有当用户调用函数时，这些代码才会执行。所以，第5行～第8行的函数定义属于不可执行代码。第9行是函数调用，也属于可执行代码。

下面定义一个函数库文件，代码如下：

01 #-----------------------------/chapter6/lib.sh------------------
02 #! /bin/bash
03
04 # 定义函数
05 error()
06 {
07 echo "ERROR:" $@ 1>&2
08 }
09 warning()
10 {
11 echo "WARNING:" $@ 1>&2
12 }

在上面的代码中，只定义了两个函数，其名称分别为error()和warning()。其中前者用来将错误消息显示到标准输出；而后者用来显示警告信息。1和2都是文件描述符，其中1表示标准输出，2表示标准错误输出。符号>&可以复制一个输出描述符。

从上面的代码可以看出，函数库文件与普通的脚本的结构完全相同。通常情况下，用户应该为函数库文件提供有意义的名称，例如errors.sh或者math.sh等。同时，为了便于管理，应该将所有的库文件单独放到一个目录中，例如lib等。

注意：由于函数库文件是由主程序载入并执行的，所以用户无需拥有库文件的执行权限，只要拥有读取权限即可。

6.3.2　函数库文件的调用

当库文件定义好之后，用户就可以在程序中载入库文件，并且调用其中的函数。在Shell中，载入库文件的命令为.，即一个圆点，其语法如下：

. filename

其中，参数filename表示库文件的名称，必须是一个合法的文件名。库文件可以使用相对路径，也可以使用绝对路径。另外，圆点命令和库文件名之间有一个空格。

【例6-16】演示如何在Shell脚本中加载库文件，并且调用其中的函数，代码如下：

01 #-----------------------------/chapter6/ex6-16.sh------------------
02 #! /bin/bash
03
04 # 载入函数库
05 . ex6-22.sh
06
07 # 定义变量
08 msg="the file is not found."
09 # 调用函数库中的函数
10 error $msg

在上面的代码中，第5行使用圆点命令载入前面定义的lib.sh文件，第10行调用其中的error()函数显示错误消息。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-16.sh
ERROR: the file is not found.

当用户使用函数库文件时，必须在调用函数前将库文件载入，否则会出现以下错误：

[root@linux chapter6]# ./ex6-16.sh
./ex6-16.sh: line 10: error: command not found

上面的错误是将第5行移到第10行后面出现的。之所以会出现以上错误，是因为Shell在解释脚本的第10行时，并没有发现用户定义了名称为error()的函数，所以会将其作为一个Shell名称来对待，但是系统中又没有名称为error的名称，所以就出现了命令未发现的错误。

另外，载入库文件命令.后面的库文件名称一定要准确，必须是一个已经存在的磁盘文件。无论是使用相对路径，还是绝对路径，都必须是准确的。如果指定的文件不存在，则会出现以下错误：

[root@linux chapter6]# ./ex6-16.sh
./ex6-16.sh: line 3: ../ex6-16.sh: No such file or directory
./ex6-16.sh: line 6: error: command not found

上面的错误信息是将【例6-16】中的第5行代码改为一个错误的文件名之后出现的。其中，第1行错误信息是Shell在解释第5行时，并没有发现指定的文件。第2行错误信息是由于前面没有正确地载入库文件，所以导致Shell没有找到名称为error()的函数。

注意：载入文件命令.与文件名之间一定要保留一个空格，许多初学者往往忽略了这个空格而导致程序执行错误。

6.4　递归函数

Linux的Shell也支持函数的递归调用。也就是说，函数可以直接或者间接地调用自身。在函数的递归调用中，函数既是调用者，又是被调用者。本节将介绍如何在Shell中实现递归函数。

递归函数的调用过程就是反复地调用其自身，每调用一次就进入新的一层。首先看一个具体的例子。

【例6-17】演示递归函数的定义方法，代码如下：

01 #-----------------------------/chapter6/ex6-17.sh------------------
02 #! /bin/bash
03
04 # 定义递归函数
05 func()
06 {
07 read y
08 # 递归调用
09 func "$y"
10 echo "$y"
11 }
12 # 调用函数
13 func

在上面的代码中，第5行～第11行定义了一个递归函数，其中，第7行是从标准输入读取数据。第9行是调用自身，第10行输出变量$y的值。第13行从主程序中调用递归函数。

可以发现，【例6-17】中确实定义了一个递归函数。但是，上面的函数却存在着一个非常大的问题，即该程序会无限地重复调用下去，不会主动退出。这是因为在【例6-17】中并没有提供递归调用终止的条件，这当然是不正确的。为了能够使得递归调用在适当的时机终止，用户需要在递归函数中指定某个条件。当该条件满足时，程序不再进行递归调用，而是逐层返回，直至最外面的一层调用。

在介绍递归函数时，经常使用的一个例子就是计算某个数的阶乘。在数学中，整数n的阶乘可以使用以下公式来计算：

n! = 1 （当 n=0 时）
n! = n * ( n-1)! （当 n>0 时）

也就是说，当n等于0时，n的阶乘就是1；当n大于0时，n的阶乘等于n与n-1的阶乘的乘积。通常情况下，在计算阶乘时，用户应该指定当n等于0时终止递归调用。

【例6-18】实现了根据用户输入的数值计算其阶乘的方法，代码如下：

01 #-----------------------------/chapter6/ex6-18.sh------------------
02 #! /bin/bash
03
04 # 定义递归函数
05 fact()
06 {
07 # 定义局部变量
08 local n="$1"
09 # 当 n 等于 0 时终止递归调用
10 if [ "$n" -eq 0 ]
11 then
12 result=1
13 else
14 # 当 n 大于 0 时，递归计算 n-1 的阶乘
15 let "m=n-1"
16 fact "$m"
17 let "result=$n * $?"
18 fi
19 # 将计算结果以退出状态码的形式返回
20 return $result
21 }
22
23 # 调用递归函数
24 fact "$1"
25
26 echo "Factorial of $1 is $?"

在上面的代码中，第5行～第21行定义了一个计算阶乘的递归函数。其中将递归变量的值为0作为终止递归调用的条件。第10行～第12行是当递归变量的值为0时，直接返回其阶乘值1。第13行～第18行是当递归变量的值大于0时，递归计算n-1的阶乘的过程。第15行将递归变量n的值减1，赋给另外一个变量m，第16行是递归调用的语句，通过该语句调用fact()函数自身，并将m作为新的递归变量传递给它。第17行代码是在退出递归的过程中才调用，每退出一层，便得到一个fact()函数的退出状态码，这个退出状态码代表当前递归变量的阶乘值，通过第20行的return语句返回，并通过系统变量$?获得。然后将当前退出的递归变量的阶乘值与上层递归调用中的局部变量$n相乘，得到当前的$n的阶乘值。最后，最顶层的递归调用的结果也是通过return语句返回。

第24行是调用递归函数，第26行输出计算的结果，其中计算结果通过系统变量$?获取。

该程序的执行结果如下：

[root@linux chapter6]# ./ex6-18.sh 4
Factorial of 6 is 24

为了使读者更加清楚【例6-18】的调用过程，下面对其进行简单地分析：

（1）用户传入数值为4。

（2）此时，n的值为4，大于0，所以执行程序的第15行，将4减1后得到3。

（3）继续执行第16行，将3作为新的递归变量传递给函数fact()。为了便于说明，将该层递归调用命名为第1层。

（4）重新返回到第8行开始执行。此时递归变量仍然大于0，所以重复执行第15行，将3减1后得到2。

（5）继续执行第16行，将2作为新的递归变量传递给函数fact()。为了便于说明，将该层递归调用命名为第2层。

（6）重新返回到第8行开始执行。此时递归变量为2，仍然大于0，所以继续执行第15行，将2减1后得到1。

（7）继续执行第16行，将1作为新的递归变量传递给函数fact()。为了便于说明，将该层递归调用命名为第3层。

（8）重新返回到第8行开始执行。此时递归变量为1，仍然大于0，所以继续执行第15行，将1减1后得到0。

（9）继续执行第16行，将0作为新的递归变量传递给函数fact()。为了便于说明，将该层递归调用命名为第4层。

（10）重新返回到第8行开始执行。此时递归变量为1，仍然大于0，所以继续执行第15行，将1减1后得到0。

（11）继续执行第16行，将0作为新的递归变量传递给函数fact()。为了便于说明，将该层递归调用命名为第5层。

（12）此时递归变量的值为0，所以满足第10行的递归变量值等于0的条件，程序执行第12行的赋值语句。然后执行第20行的return语句，将1以退出状态码的形式返回。并且退出了第5层递归调用。

（13）当第5层递归调用退出后，程序便执行第17行的算术运算。其中变量n的值为1，而系统变量$?获取的是第5层递归调用的退出状态码，即1。此时，变量result的值就是1的阶乘值，即1。

（14）继续执行第20行的return语句，退出第4层递归调用，将1的阶乘值以退出状态码的形式返回。

（15）重复执行第（14）步，依次退出第3层、第2层和第1层递归调用，便依次得到2、3和4的阶乘值。

（16）等程序退出了第1层递归调用后，执行第20行代码，将最后的4的阶乘值以退出状态码的形式返回。

（17）最后执行第26行，输出最终结果。

尽管【例6-18】已经实现了阶乘的计算，但是前面已经介绍过，return返回的只能是255以内的数值。因此，当阶乘值超出这个范围时，便得到错误的结果。但是用户可以使用6.1节中介绍的方法来对其改进。

6.5　小结

本章详细介绍了Shell语言中的函数的使用方法，主要内容包括函数的基础知识、函数的参数传递、函数库文件的定义和调用，以及递归函数的实现等。重点在于掌握Shell函数的定义方法、函数的返回值、通过位置变量和getopts函数传递参数，以及如何将通用的函数写成库文件以提高代码的重用性。在下一章中，将介绍数组的相关知识。

第7章
数组

Shell是一种非常强大的脚本语言。在前面几章中，我们已经学习了Shell的相关基础知识，包括变量的使用。实际上，Shell的功能远不止这些。Shell语言还支持数组，提供了包括数组的定义、访问，以及删除等在内的一系列的操作。本章将介绍如何在Shell中使用数组类型。

本章主要涉及的知识点如下所述。

- 定义数组：主要介绍在Shell中定义数组变量的各种方法，包括通过指定元素值来定义数组、通过declare语句定义数组、通过元素值集合定义数组、通过键值对定义数组，以及通过字符串定义数组等。

- 数组的赋值：主要介绍如何为数组元素赋值，包括通过循环为数组元素赋值、通过索引为数组元素赋值、通过集合为数组元素赋值，以及在数组末尾追加新元素等。

- 访问数组：主要介绍数组元素的访问方法，包括如何访问数组的第1个元素、通过索引访问数组元素、计算数组长度、通过循环遍历数组元素、以字符串的形式输出所有的数组元素、以切片方式获取部分数组元素，以及数组元素的替换等。

- 删除数组：介绍如何销毁数组变量，包括删除某个指定的数组元素，以及删除整个数组等。

- 数组的其他操作：主要包括数组的复制、数组的连接，以及如何从文本文件中加载数据到数组等。

7.1　定义数组

所谓数组，是指将具有相同类型的若干变量按照一定的顺序组织起来的一种数据类型。Shell语言对于数组的支持非常强大。在Shell中，用户可以通过多种方式来创建一个数组。为了能够使读者充分了解数组的创建方法，本节将介绍最常用的几种数组定义方法。

7.1.1　通过指定元素值来定义数组

在Shell中，用户可以通过直接指定数组中的元素值来定义一个新的数组变量，其基本语法如下：

array[key]=value

在上面的语法中，array表示数组变量的名称；key参数表示数组元素的索引，通常是一个整数值；value表示key所对应的数组元素的值。通过以上语句，用户可以定义名称为array的新数组变量。

【例7-1】演示如何通过直接指定元素值来定义数组，代码如下：

01 #-----------------------------/chapter7/ex7-1.sh------------------
02 #! /bin/bash
03
04 # 指定数组元素值
05 array[1]=one
06 array[3]=three
07
08 # 输出数组元素
09 echo "${array[@]}"

在上面的代码中，第5行和第6行分别指定了索引为1和3的两个元素的值，其中数组名称为array。尽管没有明确定义这个变量名，但是通过第5行和第6行代码，Shell会自动创建一个名称为array的数组，并且将第2个元素和第3个元素的值分别赋为one和two这两个字符串。第9行以字符串的形式输出整个数组的元素的值，其中${array[@]}表示获取所有数组元素的值，关于该语句的使用方法，将在随后介绍。

注意：Shell中的数组的下标（索引）从0开始。这一点与C、C++或者Java等语言是相同的。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-1.sh
one three

从上面的执行结果可以得知，第9行代码的echo语句输出了第5行和第6行定义的两个元素的值，这两个值之间用空格隔开。

7.1.2　通过declare语句定义数组

在第3章中，我们已经介绍过，用户可以通过declare语句来声明变量。除此之外，用户还可以使用该语句来定义数组。在使用该语句定义数组时，其基本语法如下：

declare -a array

在上面的语法中，-a选项表示后面定义的是一个数组，其名称为array。

【例7-2】演示如何通过declare语句来声明数组，代码如下：

01 #-----------------------------/chapter7/ex7-2.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 declare -a array
06 # 为元素赋值
07 array[0]=1
08 array[1]=2
09 # 输出元素值
10 echo "${array[@]}"

在上面的代码中，第5行通过declare语句声明了一个名称为array的数组，接下来的两行代码是为数组元素赋值，第10行输出所有元素的值。

尽管上面的代码看起来并没有什么问题，但是实际上在Shell中，这样的声明不是非常必要的。因为在Shell中，所有的变量都不必显式定义就可以用做数组。事实上，在某种意义上讲，所有的变量都是数组，而且赋值给没有下标的变量与赋值给下标为0的元素的效果是相同的。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-2.sh
1 2

7.1.3　通过元素值集合定义数组

在某些情况下，用户可能需要一次性为数组的所有元素提供一个值。此时，用户可以以元素值集合的形式来定义数组，其基本语法如下：

array=( v_0 v_1 v_2 ... vn)

在上面的语法中，array表示数组名称，等号后面的是所有的元素值的集合，这些值按照一定的顺序来排列，之间用空格隔开，最后所有的值用圆括号括起来。Shell会将这些值从下标为0的第1个元素开始，依次将这些值赋给数组的所有元素。

另外，在上面的语法中，用户并没有显示指定数组元素的具体个数。在这种情况下，数组元素的个数与值的个数相同。

注意：Shell中的数组的下标从0开始，在本书中，第1个数组元素是指下标为0的元素。

【例7-3】演示如何通过元素值集合来创建数组变量，代码如下：

01 #-----------------------------/chapter7/ex7-3.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 array=(1 2 3 4 5 6 7 8)
06 # 输出第 1 个数组元素的值
07 echo "the first element is ${#array[0]}"
08 # 输出所有元素的值
09 echo "the elements of this array are ${array[@]}"
10 # 输出数组长度
11 echo "the size of the array is ${#array[@]}"

在上面的代码中，第5行通过8个值来创建一个名称为array的数组。第7行输出第1个元素的值，第9行输出所有元素的值，第11行输出数组的长度，其中变量${@array[@]}表示数组中元素的个数。关于该变量的使用方法，将在随后介绍。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-3.sh
the first element is 1
the elements of this array are 1 2 3 4 5 6 7 8
the size of the array is 8

从上面的执行结果可以得知，第1个值1赋给了下标为0的第1个元素。同时，所有元素的值按照用户指定的顺序排列。所创建的数组的长度与用户提供的值的个数相同。

7.1.4　通过键值对定义数组

在前面的例子中，我们已经介绍了许多创建数组的方法。但是在前面的例子中，当为数组元素提供值时，一般都是连续赋值的。实际上，在Shell中，数组元素的索引并不一定是连续的。

当用户需要定义索引不连续的数组时，需要显示指定要赋值的数组元素，也就是要为哪个数组元素指定值。此时，用户可以使用键值对的方式来定义数组，其基本语法如下：

array=([0]=value_0 [1]=vlaue_1 [2]=value_2 ... [n]=valuen)

在上面的语法中，等号左边的array表示数组名，等号右边的圆括号表示数组元素及其值。其中方括号里面的数字表示数组元素的索引（下标），value0、value1、value2…valuen表示所对应的元素的值。每个索引和值组成一个键值对，键和值之间用等号隔开。

当通过键值对定义数组时，用户所提供的键值对中的元素索引不一定是连续的，可以任意指定要赋值的元素的索引。之所以可以这样操作，是因为用户已经显式指定了索引， Shell就可以知道值和索引的对应关系。

【例7-4】演示通过键值对定义数组的方法，代码如下：

01 #-----------------------------/chapter7/ex7-5.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 array=([1]=one [4]=four)
06 # 输出数组长度
07 echo "the size of the array is ${#array[@]}"
08 # 输出索引为 4 的元素的值
09 echo "the fourth element is ${array[4]}"

在上面的代码中，第5行定义了一个名称为array的数组变量，同时为该数组指定了两个元素的值，其索引分别为1和4。第7行输出数组元素的个数，第9行输出索引为4的元素的值。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-4.sh
the size of the array is 2
the fourth element is four

从上面的执行结果可以得知，【例7-4】所定义的数组的元素的个数为2，其中索引为4的元素的值为four。

在【例7-4】中，数组元素的索引为整数。实际上，Shell中的数组的索引并不仅仅是数字，还可以是字符串，这称为关联数组。在使用关联数组时，需要首先使用declare语句来声明数组，然后再通过键值对的形式为数组元素提供值。

【例7-5】演示关联数组的使用方法，代码如下：

01 #-----------------------------/chapter7/ex7-5.sh------------------
02 #! /bin/bash
03
04 # 声明数组
05 declare -A array
06
07 # 为数组赋值
08 array=([flower]=rose [fruit]=apple)
09 # 输出第 1 个元素的值
10 echo "the flower is ${array[flower]}"
11 # 输出第 2 个元素的值
12 echo "the fruit is ${array[fruit]}"
13 # 输出数组长度
14 echo "the size of the array is ${#array[@]}"

在上面的代码中，第8行通过含有-A选项的declare语句声明了一个名称为array的数组，为该数组提供了两个键值对，其中第1个键值对的索引为字符串flower，第2个键值对的索引为fruit。第10行输出索引为flower的元素的值，第12行输出索引为fruit的元素的值，第14行输出数组的元素的个数。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-5.sh
the flower is rose
the fruit is apple
the size of the array is 2

从上面的执行结果可以得知，所有元素的值都被正确地输出。

注意：关联数组在bash 4.0及以上版本获得支持。

7.1.5　数组和普通变量

在Shell中，所有的普通变量实际上都可以当做数组变量来使用。对普通变量操作与对相同名称的下标为0的元素的操作是等效的。

【例7-6】演示如何将普通变量作为数组来处理，代码如下：

01 #-----------------------------/chapter7/ex7-6.sh------------------
02 #! /bin/bash
03
04 # 定义字符串变量
05 array="hello, world."
06 # 输出下标为 0 的元素的值
07 echo "${array[0]}"
08 # 输出所有元素的值
09 echo "${array[@]}"
10 # 输出所有元素的值
11 echo "${array[*]}"

在上面的代码中，第5行定义了了一个名称为array的普通变量，并且赋予一个字符串作为初始值。第7行输出下标为0的第1个元素的值，第9和第11行都是输出所有元素的值，其中的符号@和*都是通配符，表示匹配所有的元素。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-6.sh
hello, world.
hello, world.
hello, world.

从上面的执行结果可以得知，3条echo语句都输出了相同的内容，都是第5行代码赋予普通变量的字符串。所以，数组的第1个元素的值与同名普通变量的值是相同的。另外，当定义一个普通变量时，实际上可以以数组的形式来操作该变量，但是这个数组只有一个元素。

7.2　数组的赋值

当用户定义好数组之后，就可以用它来存储数据了。这就需要为数组赋值。数组的赋值与普通变量的赋值有许多不同之处，本节将介绍如何在Shell中为数组赋值。

7.2.1　按索引为元素赋值

在Shell中，为数组元素赋值有两种基本的方法，分别是按索引赋值和以集合的形式赋值。其中，按索引赋值是最基本的赋值方法，其基本语法如下：

array[n]=valuen

其中，array是数组名称，方括号里面的n表示元素的索引，等号右边的valuen表示元素n的值。以上语法表示将索引为n的元素的值赋为valuen。

【例7-7】演示通过索引来为数组赋值的方法，代码如下：

01 #-----------------------------/chapter7/ex7-7.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 students=(John Rose Tom Tim)
06
07 # 输出元素值
08 echo "the old students are: ${students[*]}"
09
10 # 改变第 1 个元素的值
11 students[0]=Susan
12 # 改变第 4 个元素的值
13 students[3]=Jack
14 # 输出数组新的值
15 echo "the new students are: ${students[*]}"
16
17 # 声明关联数组
18 declare -A grades
19 # 初始化新的数组
20 grades=([john]=90 [rose]=87 [tim]=78 [tom]=85 [jack]=76)
21 # 输出数组元素值
22 echo "the old grades are: ${grades[@]}"
23 # 改变 Tim 的分数
24 grades[tim]=84
25 # 重新输出数组元素值
26 echo "the new grades are: ${grades[@]}"

在上面的代码中，一共定义了两个数组。其中第4～15行是关于第1个数组的操作。第5行定义了名称为students的数组，并且赋予一组初始值。第8行输出所有元素的值。第11行和第13行分别修改第1个和第4个元素的值。第15行重新输出数组的值。

第17～26行是关于第2个数组的操作，该数组是一个关联数组。其索引是一组姓名，而值则是一组分数。第18行是通过declare语句声明的数组变量，第20行以键值对的形式定义了数组grades。第22行输出数组grades所有元素的值。第24行修改索引为tim的元素的值。第26行重新输出数组grades的值。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-7.sh
the old students are: John Rose Tom Tim
the new students are: Susan Rose Tom Jack
the old grades are: 78 87 76 85 90
the new grades are: 84 87 76 85 90

从上面的执行结果可以得知，通过索引可以明确指定要修改数组中的那个元素的值。当执行赋值语句之后，目标元素的值发生改变。

7.2.2　通过集合为数组赋值

通过集合为数组赋值与通过集合定义数组的语法完全相同。当为某个数组提供一组值时，Shell会从第1个元素开始，依次将这些值赋给每个元素。当新的值的个数超过原来的数组长度时，Shell会在数组末尾追加新的元素；当新的值的个数少于原来的数组长度时， Shell会将新的值从第1个元素开始赋值，然后删除超出的元素。

【例7-8】演示通过集合为数组元素赋值的方法，代码如下：

01 #-----------------------------/chapter7/ex7-8.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 a=(a b c def)
06 # 输出所有元素的值
07 echo "${a[@]}"
08
09 # 为数组元素重新赋值
10 a=(h i j k l)
11 # 输出所有元素的值
12 echo "${a[@]}"
13 # 为数组元素重新赋值
14
15 a=(m n)
16 echo "${a[@]}"

在上面的代码中，第5行定义了一个名称为a的数组，并且赋予了4个初始值。第10行以集合的形式为该数组重新赋值，提供了5个值。第15行再次为该数组重新赋值，这次只提供了2个值。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-8.sh
a b c def
h i j k l
m n

从上面的执行结果可以得知，第1次输出了最初的4个值，第2次输出了新的5个值，而第3次只输出了2个值。

注意：在使用值集合时，如果某个值中含有空格，则需要使用单引号或者双引号将其引用起来。否则，Shell会将其认为是两个元素值。

7.2.3　在数组末尾追加新元素

在Shell中，向已有的数组末尾追加新的元素非常方便。在通过索引为数组元素赋值时，如果指定的索引不存在，则Shell会自动添加一个新的元素，并且将指定的值赋给该元素。

【例7-9】演示如何在数组末尾追加新的数组元素，代码如下：

01 #-----------------------------/chapter7/ex7-9.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 array=(1 2)
06 # 输出数组
07 echo "${array[@]}"
08 # 向数组末尾追加元素
09 array[2]=3
10 array[3]=4
11 echo "${array[@]}"

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-9.sh
1 2
1 2 3 4

从上面的执行结果可以得知，当执行第9行和第10行代码之后，数组元素由原来的2个增加为4个，同时，原来的数组元素仍然保持。

对于关联数组来说，向数组末尾追加元素同样非常方便。下面以具体的例子说明如何在关联数组后面追加新元素。

【例7-10】演示向关联数组追加新元素的方法，代码如下：

01 #-----------------------------/chapter7/ex7-10.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 declare -A array
06 # 初始化数组
07 array=([a]=a [b]=b)
08
09 echo "the old elements are ${array[@]}"
10 # 向数组追加元素
11 array[c]=c
12
13 echo "the new elements are ${array[@]}"

在上面的代码中，第5行使用declare语句定义了一个关联数组。第7行以键值对的形式赋予数组初始值。第11行向数组末尾追加了一个新的元素。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-10.sh
the old elements are a b
the new elements are a b c

从上面的执行结果可以得知，新的数组元素已经被追加。

注意：无论以何种方式为关联数组赋值，都需要显示指定元素的下标。否则会出现以下错误：

./ex7-9.sh: line 7: array: 1: must use subscript when assigning associative
array

7.2.4　通过循环为数组元素赋值

在实践中，最常用的数组赋值方法是通过一个循环语句逐个为每个元素提供值。这个循环语句可以是前面介绍的任意一种循环语句，例如for、while或者until等。

【例7-11】演示为数组元素逐个赋值的方法。在本例中，通过for循环语句将1～10这10个数字赋给一个数组，代码如下：

01 #-----------------------------/chapter7/ex7-11.sh------------------
02 #! /bin/bash
03
04 # 通过循环为数组赋值
05 for i in {1..10}
06 do
07 array[$i]=$i
08 done
09 # 输出元素的值
10 echo "${array[@]}"

在上面的代码中，第5行是for循环语句的开始，其中变量i为循环变量。for循环语句将依次把1～10这10个整数赋给该变量。第7行是赋值语句，其中等号左边方括号里面的变量i为数组元素的索引，等号右边为将要赋给数组元素的值。第10行输出所有元素的值。在循环执行的过程中，Shell会逐个向数组array末尾追加元素，并且赋予新的值。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-11.sh
1 2 3 4 5 6 7 8 9 10

7.3　访问数组

当用户将数据存储到数组之后，下一步就是如何读取这些数组元素的值。对于数组的读取操作，Shell也提供了许多非常灵活的手段。本节将介绍如何访问数组元素。

7.3.1　访问第1个数组元素

此处所指的第1个数组元素，是指下标为0的元素。前面已经介绍过，所有的普通变量都可以当做数组变量来处理。当用户对普通变量进行赋值操作时，与对同名数组中下标为0的第1个数组元素的操作效果是相同的。反过来，如果用户把数组变量看做普通的变量，直接使用数组名称来访问数组，会出现什么结果呢？

【例7-12】演示通过数组名访问第1个元素的值，代码如下：

01 #-----------------------------/chapter7/ex7-12.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 array=(1 2 3 4 5)
06 # 通过数组名访问数组
07 echo "${array}"

在上面的代码中，第7行的大括号里面不再是array[@]，而直接是数组名。下面看一下程序的执行结果，如下：

[root@linux chapter7]# ./ex7-12.sh
1

可以发现，【例7-12】输出了数组的第1个元素的值。事实上确实如此，在Shell中，当直接使用数组名来访问数组时，得到的是下标为0的元素的值。

7.3.2　通过下标访问数组元素

与其他程序设计语言一样，通常情况下，访问数组中某个具体的元素都是通过下标来指定的，其基本语法如下：

array[n]

其中array表示数组名称，n表示下标。在Shell中，下标从0开始，因此第1个数组元素是array[0]。如果数组的长度为n，则最后一个元素的下标为n-1。

【例7-13】演示如何通过下标来访问数组的元素，代码如下：

01 #-----------------------------/chapter7/ex7-13.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 array=(Mon Tue Wed Thu Fri Sat Sun)
06 # 输出下标为 0 的元素
07 echo "the first element is ${array[0]}"
08 # 输出下标为 3 的元素
09 echo "the fourth element is ${array[3]}"

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-13.sh
the first element is Mon
the fourth element is Thu

在使用下标访问数组元素时，一定要注意Shell中的数组的下标是从0开始，与Java或者C++等语言保持一致。

7.3.3　计算数组的长度

在Shell中，用户可以通过特殊操作符$#来获得数组长度。该操作符的基本语法如下：

${#array[@]}

或者

${#array[*]}

在上面的语法中，array表示数组名称，方括号中的@或者*是通配符，表示匹配所有的元素。

【例7-14】演示如何使用$#操作符来获取数组长度，代码如下：

01 #-----------------------------/chapter7/ex7-14.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 array=(Mon Tue Wed Thu Fri Sat Sun)
06 # 输出数组长度
07 echo "the length of the array is ${#array[@]}"

第5行定义了一个含有7个元素的数组。第7行输出数组的长度。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-14.sh
the length of the array is 7

通过$#操作符，除了可以获取整个数组的长度之外，还可以获取某个数组元素的长度。当使用该操作符获取具体元素的长度时，其基本语法如下：

${#array[n]}

在上面的语法中，array表示数组名称，n表示元素的下标。

【例7-15】演示如何通过$#操作符获取某个数组元素的长度，代码如下：

01 #-----------------------------/chapter7/ex7-15.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 linux[0]="Debian"
06 linux[1]="RedHat"
07 linux[2]="Ubuntu"
08 linux[3]="Suse"
09
10 # 输出第 4 个元素
11 echo "the fourth element is ${linux[3]}"
12 # 输出第 4 个元素的值
13 echo "the length of the fourth element is ${#linux[3]}"
14 # 输出第 1 个元素的值
15 echo "the first element is ${linux}"
16 # 输出第 1 个元素的长度
17 echo "the length of the first element is ${#linux}"

在上面的代码中，第5～8行定义了含有4个元素的数组，第11行输出第4个元素的值，第13行输出第4个元素的长度。第15行输出了第1个元素的值。第17行输出第1个元素的长度。前面已经讲过，在Shell中，通过数组名可以访问第1个元素。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-15.sh
the fourth element is Suse
the length of the fourth element is 4
the first element is Debian
the length of the first element is 6

从上面的执行结果可以得知，第4个元素为Suse，其字符串长度为4。第1个元素为Debian，其长度为6。

7.3.4　通过循环遍历数组元素

在程序设计时，数组的遍历是一个极其常见的操作。通常情况下，用户需要使用循环结构来实现数组元素的遍历。

【例7-16】演示如何通过for循环结构来遍历数组，代码如下：

01 #-----------------------------/chapter7/ex7-16.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 array=(Mon Tue Wed Thu Fri Sat Sun)
06 # 通过下标访问数组
07 for i in {0..6}
08 do
09 echo "${array[$i]}"
10 done

在上面的代码中，第6～9行通过循环依次输出数组的各个元素。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-16.sh
Mon
Tue
Wed
Thu
Fri
Sat
Sun

在【例7-16】中，for循环结构需要预先知道数组元素的个数。如果用户不知道数组的长度，则无法使用【例7-16】中的方法。因此，我们需要对其进行改进，使其能够自动判断数组的长度。

在7.3.3节中，我们已经介绍了获取数组长度的方法。如果将获取数组长度和循环结构结合起来，则会使得遍历数组元素的算法更加灵活。

【例7-17】本例对【例7-16】中的算法进行改进，代码如下：

01 #-----------------------------/chapter7/ex7-17.sh------------------
02 #! /bin/bash
03
04 array=(Mon Tue Wed Thu Fri Sat Sun)
05 # 获取数组长度
06 len="${#array[@]}"
07 # 通过循环结构遍历数组
08 for ((i=0;i<$len;i++))
09 do
10 echo "${array[$i]}"
11 done

在上面的代码中，第6行通过操作符$#获取当前数组array的长度，并且将其赋给变量len。第8行中将数组长度len作为循环终止条件。通过以上改进，使得遍历数组的操作更加灵活，用户无需预先知道数组的长度。

7.3.5　引用所有的数组元素

在Shell脚本中，用户可以通过多种不同的方法来输出整个数组。前面介绍过，用户可以通过下标来访问某个具体的数组元素。但是，如果用户指定下标为@或者*，则表示引用当前数组中的所有的元素。其中，符号@或者*称为通配符，表示匹配所有的元素。

实际上，这种引用所有数组元素的方法在前面的许多例子中已经使用过，在此做一个系统的介绍。当用户使用@或者*引用所有的数组元素时，Shell会将所有的数组元素的值列举出来，这些值之间用空格隔开。在某些情况下，用户可能会需要这种方式来处理数组元素。

【例7-18】演示通过通配符引用数组元素的方法。在本例中用户通过通配符来获取所有的数组元素，并且将其作为for循环的条件，然后在循环体中依次对各个数组元素进行处理。代码如下：

01 #-----------------------------/chapter7/ex7-18.sh------------------
02 #! /bin/bash
03
04 array=(Mon Tue Wed Thu Fri Sat Sun)
05 # 通过循环输出所有的数组元素
06 for e in "${array[@]}"
07 do
08 echo "$e"
09 done

用户已经知道，for循环结构的一种就是使用列表作为循环条件。而使用通配符@或者*获取的数组元素的值恰好是以列表的形式呈现的，所以用户可以很方便地将其作为for循环语句的列表条件。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-18.sh
Mon
Tue
Wed
Thu
Fri
Sat
Sun

7.3.6　以切片方式获取部分数组元素

所谓切片，是指截取数组的部分元素或者某个元素的部分内容。例如，指定一个具体的数组，截取从第2个元素开始的5个元素。或者，截取某个数组中指定元素的前几个字符。

当然，对于上面所说的切片，用户完全可以使用循环结构来实现。但是对于切片，Shell提供了更加快捷的方式。用户可以像获取数组元素的值一样来获取数组的某个切片。获取切片的基本语法如下：

${array [@|*]:start:length}

在上面的语法中，array表示数组名称，方括号中的符号@和*的涵义与前面介绍的完全相同，都是通配符，表示匹配所有的数组元素，两者只能选择一个。start参数表示起始元素的下标，下标永远从0开始。length表示要截取的数组元素的个数。通过以上切片，用户得到的是一个空格隔开的多个元素值组成的字符串。

【例7-19】演示如何进行数组切片，代码如下：

01 #-----------------------------/chapter7/ex7-19.sh------------------
02 #! /bin/bash
03
04 linux=("Debian" "RedHat" "Ubuntu" "Suse" "Fedora" "UTS" "CentOS")
05 # 数组切片
06 echo ${linux[@]:2:4}

在上面的代码中，第4行定义了一个含有7个元素的数组。第6行从下标为2的数组元素开始，截取其中的4个元素。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-19.sh
Ubuntu Suse Fedora UTS

从上面的执行结果可以得知，程序从Ubuntu开始，一直输出到UTS。

通过上面的方式，用户得到的切片是一个字符串，并非数组。当然用户可以将这个切片赋给其他的变量，以供其他地方使用。下面的例子就验证了这一点。

【例7-20】演示将切片结果赋给变量的方法，代码如下：

01 #-----------------------------/chapter7/ex7-20.sh------------------
02 #! /bin/bash
03
04 linux=("Debian" "RedHat" "Ubuntu" "Suse" "Fedora" "UTS" "CentOS")
05 # 将切片结果赋给一个变量
06 var=${linux[@]:2:4}
07
08 echo "$var"

在上面的代码中，第6行将切片结果赋给了一个名称为var的变量，第8行输出该变量的值。

该程序的执行结果与【例7-19】完全相同，如下：

[root@linux chapter7]# ./ex7-20.sh
Ubuntu Suse Fedora UTS

但是，在某些情况下，用户可能希望切片得到的结果仍然是一个数组。要得到这个结果，用户可以使用圆括号操作符，其基本语法如下：

(${array [@|*]:start:length})

上面的语法只是在原来的基础上增加了一对圆括号。

【例7-21】演示将切片结果以数组形式保存的方法。本例将切片的结果赋给了一个新的数组变量，然后输出新的数组的长度，最后通过循环输出所有的数组元素。代码如下：

01 #-----------------------------/chapter7/ex7-21.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 linux=("Debian" "RedHat" "Ubuntu" "Suse" "Fedora" "UTS" "CentOS")
06 # 切片
07 array=(${linux[@]:2:4})
08 # 获取新的数组的长度
09 length="${#array[@]}"
10 # 输出数组长度
11 echo "the length of new array is $length"
12 # 通过循环输出各个元素
13 for ((i=0;i<$length;i++))
14 do
15 echo "${array[$i]}"
16 done

在上面的代码中，第7行将切片结果赋给了新的数组变量array，第13行通过for循环结果输出所有的数组元素。由于新的数组的下标仍然是从0开始的，所以循环变量i的值也要从0开始。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-21.sh
the length of new array is 4
Ubuntu
Suse
Fedora
UTS

从上面的执行结果可以得知，新的数组的长度为4。其数组元素为切片得到的4个数组元素。

除了可以对数组进行切片之外，用户还可以对数组元素进行切片，截取某个数组元素的一部分，得到一个子字符串。对数组元素进行切片的语法与对数组进行切片的语法基本相同。只是将其中的通配符换成某个具体的下标，如下：

${array [n]:start:length}

在上面的语法中，n表示要切片的数组元素的下标。start参数表示开始的位置，与前面介绍的一样，这个开始位置也从0开始计算。length表示要截取的长度。

【例7-22】演示如何对数组元素进行切片，代码如下：

01 #-----------------------------/chapter7/ex7-22.sh------------------
02 #! /bin/bash
03
04 linux=("Debian" "RedHat" "Ubuntu" "Suse" "Fedora" "UTS" "CentOS")
05 # 对数组元素切片
06 str=(${linux[4]:2:4})
07 # 输出切片结果
08 echo "$str"

在上面的代码中，第6行对下标为4的第5个元素进行切片，从第3个字符开始，截取其中的4个字符。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-22.sh
dora

从上面的执行结果可以得知，下标为4的元素为Fedora，从0开始计算，位于位置2的字符为d，从d开始数起，第4个字符为a，所以最终得到的结果为dora。

注意：无论是对数组进行切片还是对数组元素进行切片，如果其中的长度值超过数组或者数组元素的长度，都会截取到数组或者数组元素的末尾为止。

7.3.7　数组元素的替换

在Shell中，用户还可以对数组进行另外一种特殊的操作，称为数组元素的替换。所谓替换，是指将某个数组元素的部分内容用其他的字符串来代替，但是并不影响原来的数组的值。

数组元素替换的基本语法如下：

${array[@|*]/pattern/replacement}

在上面的语法中，array表示要操作的数组名称，pattern参数表示要搜索的字符串， replacement参数表示用来替换的字符串。

【例7-23】演示如何对数组进行替换，以及如何将替换的结果赋给一个新的数组变量。代码如下：

01 #-----------------------------/chapter7/ex7-23.sh------------------
02 #! /bin/bash
03
04 # 定义数组
05 a=(1 2 3 4 5)
06 # 输出替换结果
07 echo "the result is ${a[@]/3/100}"
08 # 输出原始数组
09 echo "the old array is ${a[@]}"
10 # 将替换结果赋给一个数组变量
11 a=(${a[@]/3/100})
12 # 输出新的数组变量的值
13 echo "the new array is ${a[@]}"

在上面的代码中，第5行定义了一个由数字组成的数组。第7行将其中的3替换成100，然后输出替换后的结果。第9行输出原始数组的值。第11行将替换后的结果赋给一个新的数组变量，由于此处使用了圆括号，所以得到的仍然是一个数组。第13行输出新的数组的所有元素的值。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-23.sh
the result is 1 2 100 4 5
the old array is 1 2 3 4 5
the new array is 1 2 100 4 5

其中，第1行的字符串是代码第7行的输出结果，可以发现其中的3已经被替换成了100。第2行的字符串是第9行代码的输出结果，之所以输出原始数组的值，是为了验证替换操作是否改变了原始数组的值。从上面的输出结果可以得知，替换操作并不影响数组的原始值。第3行的字符串是第13行代码的输出，得到的是新的数组变量的值。

注意：在【例7-23】中，为了得到一个新的数组变量，用户需要使用圆括号操作符。否则，得到的将是一个字符串变量。

7.4　删除数组

当某个数组变量不再需要时，用户可以将其删除，从而释放相应的内存。用户可以删除整个数组，也可以删除其中的数组元素。下面将介绍如何对数组或者元素进行删除。

7.4.1　删除指定数组元素

与删除其他的Shell变量一样，用户可以使用unset命令来删除某个数组元素，其基本语法如下：

unset array[n]

在上面的语法中，array表示数组名称，n表示要删除的数组元素的下标，从0开始计算。

【例7-24】演示如何删除数组元素，代码如下：

01 #-----------------------------/chapter7/ex7-24.sh------------------
02 #! /bin/bash
03
04 linux=("Debian" "RedHat" "Ubuntu" "Suse" "Fedora" "UTS" "CentOS")
05 输出原始数组长度
06 echo "the length of original array is ${#linux[@]}"
07 # 输出数组的原始值
08 echo "the old array is ${linux[@]}"
09 # 删除下标为 3 的元素
10 unset linux[3]
11 输出新的数组的长度
12 echo "the length of new array is ${#linux[@]}"
13 # 输出新的数组的值
14 echo "the new array is ${linux[@]}"

在上面的代码中，第6行输出原始数组的值，第10行删除下标为3的数组元素，第13行重新输出新的数组的值。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-24.sh
the length of original array is 7
the old array is Debian RedHat Ubuntu Suse Fedora UTS CentOS
the length of new array is 6
the new array is Debian RedHat Ubuntu Fedora UTS CentOS

从上面的执行结果可以得知，下标为3的数组元素Suse已经被删除。同时，数组的长度也由原来的7变成了6。

7.4.2　删除整个数组

如果某个数组不再需要了，用户同样可以使用unset命令来将其删除，基本语法如下：

unset array

其中，array表示要删除的数组的名称。

【例7-25】演示如何删除整个数组，代码如下：

01 #-----------------------------/chapter7/ex7-1.sh------------------
02 #! /bin/bash
03
04 linux=("Debian" "RedHat" "Ubuntu" "Suse" "Fedora" "UTS" "CentOS")
05 # 删除整个数组
06 unset linux
07 echo "${linux[@]}"

在上面的代码中，第6行使用unset删除了名称为linux的数组，第7行输出数组的内容。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-25.sh

从上面的执行结果可以得知，当输出被删除之后，程序只输出了一个空行。

7.5　数组的其他操作

Shell的数组还提供了许多其他的操作，例如数组的复制、数组的连接，以及从文本文件中加载数据到数组，对于用户来说，这些操作也非常重要，本节将详细介绍这操作的实现方法。

7.5.1　复制数组

所谓复制数组，是指创建一个已经存在的数组的副本。也就是将一个数组的内容全部存储到另外一个新的数组中。

在Shell中，用户可以通过以下语法来实现数组的复制：

newarray=("${array[@]}")

其中，newarray表示新的数组，array表示已有的数组。同样，其中的@表示匹配所有的数组元素。右边最外层是一对圆括号。

【例7-26】演示数组复制的方法，代码如下：

01 #-----------------------------/chapter7/ex7-26.sh------------------
02 #! /bin/bash
03
04 linux=("Debian" "RedHat" "Ubuntu" "Suse" "Fedora" "UTS" "CentOS")
05 # 复制数组
06 linux2=("${linux[@]}")
07 echo "${linux2[@]}"

在上面的代码中，第6行创建了一个名称为linux2的数组副本，第7行输出新的数组的内容。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-26.sh
Debian RedHat Ubuntu Suse Fedora UTS CentOS

可以发现，新的数组的内容与原来的数组的内容完全相同。

注意：其中的@符号也可以使用*来代替。

7.5.2　连接数组

连接数组是指将2个数组的数组元素连接在一起，变成一个大的数组。新的数组依次包含2个数组的所有的元素。数组连接的语法如下：

("$array1[@]}" "${array2[@]}")

其中，array1表示第1个数组，array2表示第2个数组。这2个数组按照先后顺序用空格隔开，最外边用一对圆括号包围起来。

【例7-27】演示连接数组的方法，代码如下：

01 #-----------------------------/chapter7/ex7-27.sh------------------
02 #! /bin/bash
03
04 # 定义 2 个数组
05 linux=("Debian" "RedHat" "Ubuntu" "Suse" "Fedora" "UTS" "CentOS")
06 shell=("bash" "csh" "ksh" "rsh" "sh" "rc" "tcsh")
07
08 # 连接数组
09 linuxshell=("${linux[@]}" "${shell[@]}")
10
11 # 输出合并后的数组
12 echo "the new array is ${linuxshell[@]}"
13 # 输出新的数组的长度
14 echo "the length of new array is ${#linuxshell[@]}"

在上面的代码中，第5～6行分别定义了2个数组。第9行将这2个数组连接在一起。第12行输出合并后的数组的所有的元素，第14行输出合并后的数组的长度。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-27.sh
the new array is Debian RedHat Ubuntu Suse Fedora UTS CentOS bash csh ksh
rsh sh rc tcsh
the length of new array is 14

从上面的执行结果可以得知，第1个数组的元素排在新数组的前面，第2个数组的元素排在新数组的后面。合并后的数组的长度等于原来的2个数组的长度的和，即14。

注意：在执行数组连接时，参与连接的数组之间要保留1个空格。

7.5.3　加载文件内容到数组

在Shell中，用户可以将普通的文本文件的内容直接加载到数组中，文件的每一行构成数组一个元素的内容。这在处理一些日志文件的时候非常有用。

首先准备一个文本文件，该文件比较简单，只包含4行文本，内容如下：

[root@linux chapter7]# cat demo.txt
Ubuntu
Suse
Fedora
UTS

【例7-28】演示如何将demo.txt文件的内容加载到一个数组中，然后将数组的内容依次打印出来。代码如下：

01 #-----------------------------/chapter7/ex7-28.sh------------------
02 #! /bin/bash
03
04 # 加载文件内容
05 content=(`cat "demo.txt"`)
06 # 通过循环输出数组内容
07 for s in "${content[@]}"
08 do
09 echo "$s"
10 done

在上面的代码中，第5行将cat命令的执行结果赋给数组变量content。第7～10行依次输出数组content的各个元素的值。

该程序的执行结果如下：

[root@linux chapter7]# ./ex7-28.sh
Ubuntu
Suse
Fedora
UTS

7.6　小结

本章详细介绍了Shell数组的使用方法。主要内容包括数组的定义、数组的赋值、数组元素的访问方法、删除数组，以及数组的其他的操作方法等。重点在于掌握Shell数组的定义方法、引用数组元素的基本语法、数组元素的赋值方法，以及如何删除某个数组元素或者整个数组变量等。在下一章中，将介绍正则表达式的用法。

第8章
正则表达式

正则表达式是一种非常有用的工具。它通过一个字符串来描述、匹配一系列符合某个语法规则的字符串，从而可以对这些符合语法规则的字符串进行相应的处理，例如替换或者修改。Shell中的许多工具和命令都使用正则表达式来增强其功能，例如grep、sed和awk等。因此不理解正则表达式就无法发挥这些工具的强大功能。本章将介绍正则表达式的基础知识，以及如何在Shell编程环境中使用正则表达式。

本章主要涉及的知识点如下所述。

- 什么是正则表达式：主要介绍什么是正则表达式、为什么要学习正则表达式，以及如何学习和实践正则表达式。

- 正则表达式基础：主要介绍正则表达式的元字符和扩展元字符、如何匹配单个字符、如何匹配多个字符、如何匹配字符串的开头或者结尾，以及运算符的优先级。

- grep命令：主要介绍grep命令的基本语法，正则表达式在grep命令中的应用，以及grep命令族中的其他命令简介。

8.1　什么是正则表达式

所谓正则表达式，实际上就是用来描述某些字符串匹配规则的工具。由于正则表达式语法简练，功能强大，得到了许多程序设计语言的支持，包括Java、C++、Perl以及Shell等。对于初学者来说，首次接触正则表达式非常难以接受，本节将介绍正则表达式的入门知识，以利于后面几节内容的学习。

8.1.1　为什么使用正则表达式

在进行程序设计的过程中，用户会不可避免地遇到处理某些文本的情况。有的时候，用户还需要查找符合某些比较复杂规则的字符串。对于这些情况，如果单纯依靠程序设计语言本身，则往往会使得用户通过复杂的代码来实现。但是，如果使用正则表达式，则会以非常简短的代码来完成。

对于接触过Linux或者UNIX的用户来说，下面的命令并不陌生：

[root@linux ~]# ls -l *.txt
-rw-r--r-- 1 root root 598 Nov 11 2009 install.txt
-rw-r--r-- 1 root root 58 Dec 15 2008 robots.txt
-rw-r--r-- 1 root root 23 Oct 11 2010 version.txt

上面的命令非常简单，其作用是列出以.txt结尾的文件名，其中的星号“*”表示匹配任意的字符串。通常情况下，用户称上面命令中的“*”为通配符。当Shell遇到该符号时，会将其解释为任意的字符串。与通配符类似，正则表达式也是用来匹配文本的。通配符只是实现了一些非常简单的正则表达式的功能，与之相比，正则表达式能够更加准确地描述用户的需求。

正则表达式的历史非常悠久，并且与UNIX有着不可分割的联系。1940年，美国新泽西州的Warren McCulloch和出生在美国底特律的Walter Pitts这两位神经生理学家，研究出了一种使用数学方式来描述神经网络的方法。他们将神经系统中神经元描述成小而简单的自动控制元，这是正则表达式的雏形。

1956年，美国数学家斯蒂芬•科尔•克莱尼使用称为正则集合的数学符号来描述Warren McCulloch和Walter Pitts建立的模型，并由此引入了正则表达式的概念。

后来，美国的另外一位著名的计算机科学家肯•汤普逊（Ken Thompson），也就是大名鼎鼎的UNIX之父，将正则表达式引入了UNIX中的一个名称为QED的编辑器中。再后来，又将正则表达式引入了另外一个目前非常流行的编辑器ed中。最后，某些UNIX的命令例如grep也提供了对于正则表达式的支持。

目前，正则表达式在UNIX或者Linux中得到了广泛地应用。常见的支持正则表达式的UNIX工具如下所列。

- grep命令族：用于匹配文本行。

- sed流编辑器：用于改变输入流。

- awk：用于处理字符串的语言。

- more或者less等：文件查看程序。

- ed、vi或者vim等：文本编辑器。

从上面的列表可以发现，非常多的UNIX工具都提供了正则表达式的支持。因此，掌握正则表达式，可以更好地利用这些工具。

8.1.2　如何学习正则表达式

学习正则表达式的最好的方法就从学习和理解简单的例子开始。当读者对本书中的简单的例子完全理解之后，可以在例子的基础上不断地进行修改，然后自己进行测试。如此不断反复地实践，最终总会达到融会贯通的境界。

通常来说，读者在学习正则表达式时，需要注意以下几点。

1．重点在于理解元字符

在正则表达式里面，处于核心地位的就是元字符。正则表达式所描述的规则最终都是由元字符表达出来。因此，对于常见的正则表达式元字符，用户必须完全理解和掌握其涵义。只有这样，才有可能达到灵活运用的地步。

2．掌握好正则表达式的语法

正则表达式之所以简练，是因为它有着严格的语法规则。例如，元字符“*”表示匹配该符号前面的一个普通字符0次或者多次。也就是说，“*”的作用范围只是紧靠该字符前面的一个字符，而非多个字符。只有掌握好这些语法，才会读懂本书中的例子，也才能写出好的正则表达式。

3．开拓思路，寻找最佳的表达方法

对于同一个需求，可能会有许多种编写正则表达式的方法。但是，在这些方法中，总会有一种或者较少的几种方法是最简练的，并且是最完备的。所以，初学者在学习正则表达式的时候，一定不要满足于把问题解决了，而要不断思考有没有更好的解决方法。

8.1.3　如何实践正则表达式

当一个正则表达式完成之后，要保证这个表达式一定是准确的，就需要不断地测试，这样才可以确定其正确与否。在不同的环境下，用户需要不同的工具来帮助完成测试的过程。如果是在Shell命令行中，用户可以使用grep命令来测试。本书中的大部分例子，也是通过该命令讲解的。关于该命令的详细使用方法，将在8.4节中介绍。

【例8-1】演示如何通过正则表达式来查找字符串，代码如下：

01 #-----------------------------/chapter8/ex8-1.sh------------------
02 #! /bin/bash
03
04 str='cat version.txt|grep rev'
05 echo "$str"

在上面的代码中，第4行将Shell命令cat和grep的执行结果赋给变量str，第5行输出该变量的值。其中文本文件version.txt的内容请参见本书配套光盘的chapter7目录中的version.txt，如下：

app= 4.8.5
rev= 55324

上面的文件的内容比较简单，只有两行。

【例8-1】的执行结果如下：

[root@linux chapter8]# ./ex8-1.sh
rev= 55324

在【例8-1】中，cat命令用来显示文件的内容，其后的竖线|是管道，表示将cat命令的输出结果作为后面的那个命令的输入。grep命令用来查找文本，后面的“rev”是要匹配的字符串。上面的命令的执行结果表示version.txt文件中的某一行含有“rev”这个字符串。

实际上，grep命令后面的参数可以换成任意的正则表达式，而不仅仅是一个普通的字符串。这些将在随后的例子中逐步介绍。

8.2　正则表达式基础

通过前面一节的学习，用户对于正则表达式应该有了初步的了解。本节将在前面一节的基础上，逐步介绍正则表达式的基础知识，主要是各种元字符及其使用方法。

8.2.1　正则表达式的原理

简单地讲，正则表达式是对文本进行过滤的工具。而正则表达式之所以拥有过滤文本的功能，是因为它定义了一系列的元字符，通过元字符配合其他的字符来表达出一种规则，只有符合该规则的文本才能保留下来，而不符合该规则的文本则被过滤掉。如图8-1所示，描述了正则表达式在处理文本时的过程。

所谓元字符，是指用来描述字符的字符。元字符的作用在于对字符表达式的内容、转换以及各种操作信息进行描述。而正则表达式（Regular Expression，RE）就是由各种元字符和一般字符构成的字符串。

图8-1　正则表达式处理文本的流程

8.2.2　基本正则表达式

基本正则表达式（Basic Regular Expression，BRE），又称为标准正则表达式，是最早制订的正则表达式规范，仅支持最基本的元字符集。基本正则表达式是POSIX规范制订的两种正则表达式语法标准之一，另外一种语法标准称为扩展正则表达式，将在随后介绍。

基本正则表达式所定义的元字符主要有以下几种。

1．行首定位符“^”

“^”称为行首定位符，是正则表达式中的定位符之一，用来匹配行首的字符，表示行首的字符是“^”后面的那个字符。正则表达式中的定位符的作用与其他的元字符不同，它们不是用来匹配具体的文本，而是匹配某个具体的位置，例如行首定位符“^”就是用来匹配文本行的开头的字符的。

【例8-2】演示行首定位符的用法，代码如下：

01 #-----------------------------/chapter8/ex8-2.sh------------------
02 #! /bin/bash
03
04 # 列出 /etc 目录中的以字母 po 开头的文件
05 str='ls /etc | grep "^po"'
06 echo "$str"

在上面的代码中，第5行通过ls命令列出/etc目录中的文件，然后将执行结果传递给grep命令，通过grep命令筛选出以字符串“po”开头的文件名。

注意：尽管本书在解释行首定位符的时候使用了以某个字符串开头的说法，但是希望大家养成按照字符来理解正则表达式的习惯，即不要将“^po”理解为以字符串“po”开头的文本行，而是理解为第一个字符为字母“p”，紧跟着一个字母“o”的文本行。这一点非常重要，在学习正则表达式时必须注意。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-2.sh
polkit-1
popt.d
portreserve
postfix

2．行尾定位符“$”

与行首定位符的作用恰恰相反，行尾定位符的作用是用来定位文本行的末尾的。从语法上讲，行尾定位符的位置也与行首定位符相反，行首定位符位于所作用的字符之前，而行尾定位符位于所作用的字符之后。

【例8-3】演示行尾定位符的使用方法，代码如下：

01 #-----------------------------/chapter8/ex8-3.sh------------------
02 #! /bin/bash
03
04 # 列出 /etc 目录中以 conf 结尾的文件名
05 str='ls /etc | grep "conf$"'
06
07 echo "$str"

与【例8-2】相反，该例将/etc目录中的以“conf”结尾的文件名罗列出来。该例的执行结果如下：

[root@linux chapter8]# ./ex8-3.sh
anthy-conf
asound.conf
autofs_ldap_auth.conf
cas.conf
cgconfig.conf
…

同样，用户在理解行尾定位符的时候，也应该从字符的角度去理解，也就是说【例8-3】匹配的是以字母“f”结尾，同时倒数第2～4个字符分别为“n”、“o”和“c”的文本行。

当学习完行首定位符和行尾定位符之后，细心的读者可能会提出一个疑问，即如何精确地匹配一个文本行？实际上，配合使用行首定位符和行尾定位符，可以很方便地解决这个问题，那就是将需要匹配的字符全部放在行首定位符和行尾定位符之间就可以了。例如，下面的表达式就筛选出完全匹配“cat”的文本行：

^cat$

上面的表达式表示匹配以字符“c”开头，紧接着一个字母“a”，最后以字符“t”结尾的文本行，除了这3个字符之外，文本行中不含有其他任何字符。

如果用户将行首定位符和行尾定位符直接连起来，变成以下表达式，会发生什么情况：

^$

上面的表达式所描述的规则就是同时匹配文本行的开头和结尾，所匹配的字符为空字符。另外，上述表达式也可以理解为先是行首，然后紧接着就是行尾，中间没有任何字符。所以，表达式“^$”的涵义是匹配所有的空行，行中没有任何字符，包括空白字符。

而下面的两种情况：

^

和

$

则没有任何应用意义，因为任何一个文本行都有开头和结尾，所以上面两个表达式匹配所有的行，包括表达式“^$”所表示的空行。

3．单个字符匹配“.”

圆点“.”用来匹配任意单个字符，包括空格，但是不包括换行符“\n”。当用户使用“.”符号后，意味着该位置一定有一个字符，无论它是什么字符。

【例8-4】演示圆点符号的用法，代码如下：

01 #-----------------------------/chapter8/ex8-4.sh------------------
02 #! /bin/bash
03
04 # 列出所有的包含字符串“ samba ”的文件名
05 str='ls /etc | grep "samba"'
06
07 echo "$str"
08
09 echo "==============================="
10
11 # 列出包含字符串 samba 以及另外一个字符的文件名
12 str='ls /etc | grep "samba."'
13
14 echo "$str"

在上面的代码中，一共输出了两次文件名。其中第5行直接使用字符串“samba”去匹配文件名，这样的话，只要包含这个字符串的文件名都会被筛选出来，无论这个字符串的后面有没有其他字符。第12行使用了圆点符号“.”，表示字符串“samba”后面至少有一个字符。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-4.sh
samba
samba4
===============================
samba4

在上面的输出结果中，横线上面的是第一次的筛选结果，可以发现在/etc目录下面有两个文件名都包含字符串“samba”。横线下面的是第二次的筛选结果，可以发现只有末尾为“4”的那个文件名被筛选出来。

注意：可以连续使用圆点符号来匹配多个字符，例如“l..p”匹配含有1个字母“l”，然后是任意两个字符，再接着是一个字母“p”的字符串。

4．限定符“*”

星号“*”是正则表达式中的限定符之一。限定符本身不代表任何字符，它是用来指定其前面的一个字符必须重复出现多少次才能满足匹配。星号“*”表示匹配其前导字符的任意次数，包括0次。

【例8-5】演示星号通配符的使用方法，代码如下：

01 #-----------------------------/chapter8/ex8-5.sh------------------
02 #! /bin/bash
03
04 # 筛选出以字符 s 开头，紧跟着 1 个字符 s ，任意个字符 s 的文件名
05 str='ls /etc | grep "^sss*"'
06
07 echo "$str"

在上面的代码中，第5行的正则表达式为“^sss*”，这表示匹配以字符“s”开头，紧跟着1个字符“s”，接着是0个或者多个字符“s”的文本行。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-5.sh
ssh
ssl
sssd

在上面的执行结果中，第1行和第2行都是以字符“s”开头，紧接着是一个字符“s”，后面是0个字符“s”，因此符合正则表达式所描述的规则。第3行的前面同样是两个字符“ss”，但是紧跟着一个字符“s”，这个文本行同样符合正则表达式所描述的规则。

5．字符集匹配“[]”

方括号“[]”的功能比较特殊，它是用来指定一个字符集合的，其基本语法为：

[abc]

其中a、b和c表示任意的单个字符。只要某个字符串在方括号所在的位置上出现了方括号中的任意一个字符，就都满足匹配规则。另外，对于连续的数字或者字母，可以使用连字符“-”来表示一个范围，例如“[a-f]”表示匹配字母表中a到f中的任意一个字母，而“[0-9]”表示匹配任意单个数字。

【例8-6】演示方括号的使用方法，代码如下：

01 #-----------------------------/chapter8/ex8-6.sh------------------
02 #! /bin/bash
03
04 # 筛选所有以字符 r 开头，并且紧跟着 1 个字符 c 的文本行
05 str='ls /etc |grep "^rc"'
06
07 echo "$str"
08
09 echo "=============================="
10
11 # 筛选所有以字符 r 开头，紧跟着 1 个字符为 c ，下面 1 个字符为单个数字的文本行
12 str='ls /etc | grep "^rc[0-9]"'
13
14 echo "$str"

在上面的代码中，第5行的正则表达式表示匹配所有以字符“r”开头，紧跟着一个字符为“c”的文本行。第12行的正则表达式表示匹配以字符“r”开头，下面一个字符为“c”，再下面一个字符为单个数字的文本行。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-6.sh
rc
rc0.d
rc1.d
rc2.d
rc3.d
rc4.d
rc5.d
rc6.d
rc.d
rc.local
rc.sysinit
==============================
rc0.d
rc1.d
rc2.d
rc3.d
rc4.d
rc5.d
rc6.d

在上面的结果中，横线上面的是所有以字符串“rc”开头的文本行，而横线下面的是第3个字符为数字的文本行。

注意：当元字符“*”或者“.”等位于字符集匹配符“[]”后，便变成了一个普通的字符，只有字面意义，没有特殊意义。

6．字符集不匹配“[^]”

前面已经介绍过行首定位符“^”和字符集匹配符“[]”。但是如果将这两个符号结合起来，则其意义会发生变化。符号“[^]”表示不匹配其中列出的任意字符，其语法如下：

[^abc]

其中a、b和c表示任意的单个字符。“[^]”符号的用法与符号“[]”的用法相同，不再举例说明。

除了前面介绍的6个元字符之外，在基本正则表达式中还定义了其他一些元字符。这些元字符使用较少，语法较繁琐，且在扩展正则表达式和PERL正则表达式中都有替代的元字符，所以不再详细说明。表8-1列出了基本正则表达式的其他元字符。

表8-1　其他的基本正则表达式元字符

元字符 | 说　明 | 举　例
\(\) | 定义子表达式的开始和结束位置。在后续正则表达式中可以通过转义序列来引用子正则表达式。最多可以定义9个子表达式，通过\1～\9来引用 | 例如，正则表达式“\(love\).*\1”表示匹配两个“love”中间包含任意个字符的文本行，其中“\1”表示引用前面的“love”
x\{m, n\} | 区间表达式，匹配字符x重复的次数区间。其中x\{n\}表示最多重复n次，x\{m,\}表示最少重复m次，x\{m, n\}表示重复m～n次 | 例如，正则表达式“a\{2, 3\}”表示字符“a”重复2～3次
\< | 词首定位符 | “\<hello”匹配含有以字符串“hello”开头的单词的文本行
\> | 词尾定位符 | “hello\>”匹配含有以字符串“hello”结尾的单词的文本行

8.2.3　扩展正则表达式

扩展正则表达式（Extended Regular Expression，ERE）支持比基本正则表达式更多的元字符，但是扩展正则表达式对有些基本正则表达式所支持的元字符并不支持。8.2.2节中介绍的元字符“^”、“$”、“.”、“*”、“[]”，以及“[^]”这6个元字符在扩展正则表达式中都得到了支持，并且其意义和用法都完全相同，不再重复介绍。接下来重点介绍一下在扩展正则表达式中新增加的一些元字符。

1．限定符“+”

加号“+”的意义与限定符“*”基本相同，但是星号“*”限定前面的字符可以出现任意次，而加号“+”却限定前面的字符至少出现一次。

【例8-7】演示加号“+”的使用方法，代码如下：

01 #-----------------------------/chapter8/ex8-7.sh------------------
02 #! /bin/bash
03
04 # 筛选以字符串“ ss ”开头，后面至少紧跟着 1 个字符“ s ”的文本行
05 str='ls /etc | egrep "^sss+"'
06
07 echo "$str"

从上面的代码可以看出，该例只是对【例8-5】进行了一点改动，将其中的星号“*”换成了加号“+”。

该例的执行结果如下：

[root@linux chapter8]# ./ex8-7.sh
sssd

对比【例8-5】和【例8-7】，可以发现在【例8-7】中，少了两个文本行“ssh”和“ssl”，这是因为这两个文本行只包含了两个字符“s”。

注意：在介绍扩展正则表达式时，本书使用了egrep命令，而不是grep命令，这是因为grep命令使用的是基本正则表达式，而egrep命令默认使用扩展正则表达式。

2．限定符“?”

问号“?”是另外一个限定符，它用来限定前面的字符最多只出现1次，即前面的字符可以重复0次或者1次。

【例8-8】演示问号通配符的使用方法，代码如下：

01 #-----------------------------/chapter8/ex8-8.sh------------------
02 #! /bin/bash
03
04 # 筛选以字符串“ ss ”开头，后面跟着 0 或者 1 个 s 的文本行
05 str='ls /etc | egrep "^sss?"'
06
07 echo "$str"

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-8.sh
ssh
ssl
sssd

可以发现，【例8-8】的执行结果与【例8-5】完全相同，这是因为在目录/etc中只包含不超过3个字符“s”的文件名，如果有超过3个字符“s”的文件名，则会在【例8-5】中出现，但是不会在【例8-8】中出现。

3．竖线“|”和圆括号“()”

竖线“|”表示多个正则表达式之间“或”的关系，其语法为：

expression1|expression2|expression3|…|expressionn

圆括号“()”用来表示一组可选值的集合。竖线和圆括号经常在一起使用，表示一组可选值。

【例8-9】演示圆括号和竖线的使用方法，代码如下：

01 #-----------------------------/chapter8/ex8-9.sh------------------
02 #! /bin/bash
03
04 # 筛选含有字符串“ ssh ”、“ ssl ”或者以字符串“ yum ”开头的文本行
05 str='ls /etc | egrep "(ssh|ssl|^yum)"'
06
07 echo "$str"

在上面的代码中，第5行的正则表达式表示匹配正则表达式“ssh”、“ssl”或者“^yum”的文本行。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-9.sh
ssh
ssl
yum
yum.conf
yum.repos.d

在上面的输出结果中，第1行文本符合“ssh”，第2行文本符合“ssl”，第3～6行文本符合“^yum”。

在前面的内容中，介绍过方括号“[]”也可以用来表示一组可选字符集。例如下面的表达式：

[abc]

表示可以匹配字符a、b或者c中的任意一个。这个表达式也可以使用竖线和圆括号来表达，如下：

(a|b|c)

上面2个表达式是等价的。

另外下面3个表达式也是等价的：

(Jeffrey|Jeffery)
Jeff(rey|ery)
Jeff(re|er)y

看了上面的这些例子，读者可能会觉得方括号“[]”和竖线结合圆括号这两种元字符之间没有太大的区别。但是一定不要混淆它们的概念。使用方括号只能匹配目标文本中的单个字符，而竖线的两边则可能是完整的正则表达式，也可能是任意长度的文本。

注意：扩展正则表达式取消了子表达式“()”和次数匹配“{m,n}”语法符号的转义符引用语法，在使用这两种语法符号时，不再需要添加转义符。

8.2.4　Perl正则表达式

正则表达式是Perl语言的一大特色。Shell中的grep和egrep命令都支持Perl正则表达式。Perl正则表达式的元字符与扩展正则表达式的元字符大致相同，扩展正则表达式中的元字符在Perl正则表达式中都得到了支持。另外，Perl正则表达式还增加了一些元字符。下面对常用的增加的元字符进行介绍。

1．数字匹配\d

符号“\d”匹配0～9中的任意一个数字字符，等价于表达式“[0-9]”。

【例8-10】演示符号“\d”的使用方法，代码如下：

01 #-----------------------------/chapter8/ex8-10.sh------------------
02 #! /bin/bash
03
04 # 筛选以字符串 rc 开头，紧跟着一个数字的问本行
05 str='ls /etc | grep -P "^rc\d"'
06
07 echo "$str"

在上面的代码中，第5行使用正则表达式“^rc\d”来筛选以字符“r”开头，紧跟着一个字符“c”，然后是一个0～9之间的任意数字的文本行。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-10.sh
rc0.d
rc1.d
rc2.d
rc3.d
rc4.d
rc5.d
rc6.d

2．非数字匹配\D

符号“\D”和符号“\d”的作用恰好相反，后者是匹配一个0～9之间的单个数字字符，而前者则匹配一个非数字字符。“\D”等价于表达式“[^0-9]”。

3．空白字符匹配\s

符号“\s”匹配任何空白字符，包括空格、制表符以及换页符等，等价于表达式“[\f\n\r\t\v]”。

4．非空白字符匹配\S

符号“\S”匹配任何非空白字符，等价于表达式“[^\f\n\r\t\v]”。

8.2.5　正则表达式字符集

前面已经讲过，一个正则表达式就是由一系列字符组成的字符串，其中包括元字符和普通字符。由于元字符和普通字符都有许多个，所以形成了元字符集和普通字符集这两个集合。关于元字符集我们已经在前面分成基本正则表达式、扩展正则表达式和Perl正则表达式3个小节介绍过了，扩展正则表达式和Perl正则表达式都是在基本正则表达式的基础上加以扩展而形成的。由于基本正则表达式产生的时间比较早，所以有些元字符比较繁琐，这些元字符在扩展正则表达式和Perl正则表达式中得到了改进。

接下来，再详细介绍一下普通字符集。在正则表达式中，普通字符集中的字符只表示它们的字面涵义，不对其他字符产生影响。正则表达式的最简单的形式就是只由普通字符集中的字符组成，不包含元字符。

正则表达式的字符集通常使用方括号表达式表示，例如：

[cC]hina
[^hello]
[a-zA-Z]
[0123456789]
[0-9]

第1个表达式表示匹配“china”或者“China”这2个字符串，第2个表达式表示不匹配“hello”这个字符串，第3个表达式表示匹配所有的大小写字母，第4个表达式表示匹配所有的个位数字，第5个表达式所表达的与第4个表达式等价，只是使用了连字符“-”来表示一段连续的数字。上面这些例子都是使用方括号表达式来表示某个字符集的具体应用。

在方括号表达式中，除了可以使用字面意义上的字符，例如a-z、A-Z或者0-9，以及其他一些字符之外，还可以使用POSIX字符集。POSIX字符集是为了在不同国家的字符编码中保持一致，而定义的一些特殊的字符类。表8-2列出了常用的POSIX字符类。

表8-2　常用的POSIX字符类

字　符　类 | 说　明
[:alnum:] | 匹配任意一个字母或者数字，等价于A-Za-z0-9
[:alpha:] | 匹配任意一个字母，等价于A-Za-z
[:digit:] | 匹配任意一个数字，等价于0-9
[:lower:] | 匹配任意一个小写字母，等价于a-z
[:upper:] | 匹配任意一个大写字母，等价于A-Z
[:space:] | 匹配任意一个空白字符，包括空格、制表符、换行符，以及分页符等
[:blank:] | 匹配空格和制表符
[:graph:] | 匹配任意一个看得见的可打印字符，不包括空白字符
[:print:] | 匹配任何一个可以打印的字符，包括空白字符，但是不包括控制字符、字符串结束符'\0'、EOF文件结束符（-1）
[:cntrl:] | 匹配任何一个控制字符，即ASCII字符集中的前32个字符，例如换行符、制表符等
[:punct:] | 匹配任何一个标点符号，例如“[]”、“{}”或者“,”等
[:xdigit:] | 匹配十六进制数字，即0-9、a-f，以及A-F

与其他普通字符一样，表8-2中列出的字符类也需要放在方括号中。例如：

[[:alnum:]]

等价于以下表达式：

[a-zA-Z0-9]

8.3　正则表达式应用

本章前面的小节中详细介绍了正则表达式的基础知识。为了使读者更加清楚如何使用正则表达式，本节将详细介绍正则表达式的相关应用。

8.3.1　匹配单个字符

在所有的正则表达式中，单个字符的匹配是最基本、最简单的一种。对于初学者来说，只有掌握了最基本的正则表达式，才有可能去学习和理解复杂的正则表达式。因此，我们首先介绍最简单的单个字符的匹配。

在正则表达式中，可以用来匹配单个字符的表达式大致有4种，分别是单个一般字符、转义后的元字符、圆点“.”表达式，以及方括号表达式。下面将分别进行介绍。

1．单个一般字符

所谓一般字符，是指除了正则表达式中已经定义的元字符之外的所有字符，例如英文字符、数字、空白字符以及标点符号等。这些一般字符在正则表达式中都只是表达它们自身的字面意义，没有其他的意义。当需要匹配某个一般字符时，可以直接将该字符作为表达式或者是表达式的一部分。

【例8-11】演示如何使用普通字符作为表达式来匹配单个字符，代码如下：

01 #-----------------------------/chapter8/ex8-11.sh------------------
02 #! /bin/bash
03
04 # 搜索含有字符“ a ”的文本行
05 str='grep "a" demo2.txt'
06
07 echo "$str"

在上面的代码中，正则表达式比较简单，只有一个普通字符“a”，这个表达式表示匹配含有字符“a”的文本行。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-11.sh
the school report of the class is
name score
alice 89

从上面的执行结果可以得知，一共有3行含有字符“a”。

2．转义后的元字符

在前面介绍基本正则表达式、扩展正则表达式以及Perl正则表达式的时候，都介绍了一些元字符。如果想要匹配这些元字符本身，则需要在这些字符的前面加上转义字符“\”。通过这个操作，就可以关闭这些元字符的特殊意义，而只保留其字面意义。

例如，想要匹配圆点“.”就可以使用表达式“\.”。经过转义之后，这个表达式就表示一个圆点符号，而不是任意单个字符。如果想要匹配其他的元字符，例如问号“?”，同样可以使用表达式“\?”。

假设有一个名称为demo3.txt的文本文件，其内容如下：

01 No matter what you're looking for, our motto is "keep it simple"
02 Start by entering a basic name or word.
03 If you're looking for a place or product in a specific location,
04 enter the name along with the town or zip code.

上面的demo3.txt只有4行，其中，第2行和第4行都含有句号。

【例8-12】演示直接使用元字符圆点作为表达式，则会导致匹配结果出错，代码如下：

01 #-----------------------------/chapter8/ex8-12.sh------------------
02 #! /bin/bash
03
04 str='grep "." demo3.txt'
05
06 echo "$str"

在上面的代码中，第4行直接使用了圆点“.”，没有经过转义。在这种情况下，Shell会认为这是一个元字符，其意义为表示任意的单个字符，而非普通字符圆点。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-12.sh
No matter what you're looking for, our motto is "keep it simple"
Start by entering a basic name or word.
If you're looking for a place or product in a specific location,
enter the name along with the town or zip code.

正因为Shell把其中的圆点看做一个元字符，所以【例8-12】把所有的文本行都输出了。为了能够得到正确的结果，我们可以对【例8-12】进行修改，在圆点的前面加上转义字符，修改后的代码如下：

01 #! /bin/bash
02
03 str='grep "\." demo3.txt'
04
05 echo "$str"

该代码的执行结果如下：

[root@linux chapter8]# ./ex8-12.sh
Start by entering a basic name or word.
enter the name along with the town or zip code.

可以发现，含有圆点的第2行和第4行都被输出了，而其他两行则没有被输出。

注意：反斜线“\”也是一个元字符，如果想要匹配反斜线，也需要在其前面加上转义字符，即表达式“\\”表示匹配一个反斜线。

3．圆点表达式

圆点“.”表示匹配任意单个字符，除了换行符之外。关于圆点表达式的使用方法，请参见【例8-4】，此处不再重复介绍。

4．方括号表达式

前面已经介绍过，方括号表达式用来表示一个可选字符的集合。尽管通常情况下，在方括号中含有多个字符，但是一次只能从这些字符中选择一个，因此，方括号表达式仍然表示的是匹配单个字符。例如，表达式“[abc]”就表示匹配字符“a”、“b”或者“c”中的任意一个。同时，这种表示方法也是最简单的一种形式，也就是直接将所要匹配的字符都在方括号中罗列出来。

如果在方括号中的字符列表前面加上符号“^”，则表示取反的意思。也就是说，不匹配方括号中列出来的任何一个字符。例如，表达式“[^abc]”表示不匹配“a”、“b”和“c”这3个字符中的任何一个。

对于一段或者几段连续的字符，可以使用连字符来简化书写形式。例如，想要匹配所有的英文字母，可以使用表达式“[a-zA-Z]”。如果想要匹配所有的单个数字，可以使用表达式“[0-9]”。

另外，前面还介绍了POSIX字符集，这些字符集都可以用在方括号表达式中，用来表示某些字符。例如，所有的字母和数字，可以使用表达式“[[:alnum:]]”表示，等价于“[a-zA-Z0-9]”。所有的数字可以使用表达式“[[:digit:]]”表示，等价于“[0-9]”。所有的字母可以使用表达式“[[:alpha:]]”表示，等价于“[a-zA-Z]”。

注意：当元字符位于方括号中时，除了极少的几个元字符，例如连字符“-”或者“^”之外，其他元字符都将失去其特殊意义，而只保留字面意义。例如，表达式“[\.]”表示的是反斜线“\”和圆点“.”这两个字符。如果想要匹配圆点，则只要使用“[.]”就可以了。

8.3.2　匹配多个字符

接下来再介绍如何匹配多个字符。正则表达式可以使用多种方法来匹配多个字符，其中最简单的一种就是将多个字符按照指定的顺序拼接起来。

【例8-13】演示如何匹配一个由多个字符组成的字符串，代码如下：

01 #-----------------------------/chapter8/ex8-13.sh------------------
02 #! /bin/bash
03
04 # 搜索字符串“ matter ”
05 str='grep "matter" demo3.txt'
06
07 echo "$str"

在上面的代码中，第5行的正则表达式为“matter”，这个表达式是由6个普通字符组成的一个单词，因此，这个表达式将匹配含有这个字符串的文本行。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-13.sh
No matter what you're looking for, our motto is "keep it simple"

可以发现，在上面输出的一行文本中，含有字符串“matter”。

除了使用普通字符之外，还可以使用其他的字符集，例如方括号表达式。下面的例子就演示了方括号和普通字符一起使用的方法。

【例8-14】演示如何将普通字符和方括号表达式混合起来使用，代码如下：

01 #-----------------------------/chapter8/ex8-15.sh------------------
02 #! /bin/bash
03
04 # 匹配含有字符“ o ”，后面紧跟着字符“ r ”或者“ u ”的文本行
05 str='grep "o[ru]" demo3.txt'
06
07 echo "$str"

第5行的表达式表示匹配首先是一个字符“o”，紧接着是一个字符“r”或者“u”的文本行。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-14.sh
No matter what you're looking for, our motto is "keep it simple"
Start by entering a basic name or word.
If you're looking for a place or product in a specific location,
enter the name along with the town or zip code.

从结果中可以得知，上面的每一行都含有字符串“or”或者“ou”。

在某些情况下，直接将字符拼接起来不失为一种非常直观的方法。但是，如果字符比较多的话，会导致表达式非常长。因此，用户可以使用其他的方式来表达。

在前面的内容中，介绍了两个限定符，分别是星号“*”和加号“+”。其中星号可以在前面所讲的3种正则表达式中使用，而加号则不能在基本正则表达式中使用。这两个元字符的区别在于星号表示匹配0个或者多个任意的字符，而加号表示匹配1个或者多个任意的字符。

【例8-15】使用星号来匹配多个字符“o”，代码如下：

01 #-----------------------------/chapter8/ex8-15.sh------------------
02 #! /bin/bash
03
04 # 匹配任意多个字符“ o ”
05 str='grep "lo*king" demo3.txt'
06
07 echo "$str"

在上面的代码中，第5行的表达式将匹配字符串“lking”、“loking”、“looking”以及“loooking”等。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-15.sh
No matter what you're looking for, our motto is "keep it simple"
If you're looking for a place or product in a specific location,

在本例中，由于没有字符“l”后面不跟字符“o”的字符串，所以将其中的星号换成加号也会得到同样的结果。

尽管星号或者加号非常好用，但是用户不能精确地控制字符重复的次数。为了能够准确地表达前置字符重复出现的字符，用户还需要使用其他的元字符。在基本正则表达式中，用户可以使用元字符“\{m, n\}”表示，而在扩展正则表达式和Perl正则表达式中，用户在使用前面的元字符时无需将花括号转义，直接使用“{m, n}”即可。其中的参数m表示前置字符最少出现的次数，而参数n表示前置字符最多出现的次数。

由元字符“{m, n}”可以衍生出另外两种表示方法。其中，元字符“{m}”表示前置字符出现m次，元字符“{m,}”表示前置字符最少出现m次。

在中国，免费电话号码的前缀是800，其格式通常如下：

800-xxx-xxxx

其中，前面3位是固定的，后面的6位都是数字，通常使用连字符将其分割开来。为了能够匹配这种格式的电话号码，用户可以使用正则表达式。

假设存在一个名称为demo4.txt的文本文件，其内容如下：

Intel:800-820-1100
Abit:800-820-0323
Asus:800-820-6655
Sony:800-810-2228
HP:8008100716
IBM:800-810-1818#

可以得知，每一行都是一个800免费电话号码，其中，有些电话号码的格式并不符合上面的格式。

【例8-16】演示如何通过正则表达式来筛选符合指定格式的电话号码，代码如下：

01 #-----------------------------/chapter8/ex8-16.sh------------------
02 #! /bin/bash
03
04 # 筛选符合格式的电话号码
05 str='egrep "800-[[:digit:]]{3}-[[:digit:]]{4}$" demo4.txt'
06
07 echo "$str"

在上面的代码中，第5行的正则表达式的前面4个字符都是固定的普通字符，所以可以用普通字符来表达；紧接着是3个数字，在此使用的是POSIX字符类，当然其中的“[:digit:]”也可以直接写做“[0-9]”；再接下来是一个连字符，最后是4个数字，并且以数字结尾。由于grep命令默认使用的是基本正则表达式，而在基本正则表达式中花括号表达式需要转义，所以本例中使用egrep命令。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-16.sh
Intel:800-820-1100
Abit:800-820-0323
Asus:800-820-6655
Sony:800-810-2228

从上面的执行结果可以发现，demo4.txt文件中不符合格式要求的最后两个电话号码被过滤掉了。

注意：【例8-16】中的正则表达式一定要在最后加上“$”符号，否则最后一个电话号码不能被过滤掉。

8.3.3　匹配字符串的开头或者结尾

匹配字符串的开头或者结尾非常有用，在某些情况下，用户也必须对字符串的开头和结尾进行限制。在【例8-16】中，我们已经使用美元符号“$”来对电话号码的结尾进行了匹配。

在正则表达式中，用户可以通过定位符来对字符串开头或者结尾进行匹配，定位符又称为锚点。一共有两个定位符，分别为行首定位符“^”和行尾定位符“$”。前者用来匹配文本行的开头字符，后者用来匹配文本行的结尾字符。

如果行首定位符和行尾定位符一起使用，则这两个符号之间的表达式就匹配整个字符串或者文本行。如果“^”符号后面紧跟着“$”符号，即“^$”，则表示一个空行。因为行首后面紧跟着行尾，行中没有任何字符。

在理解和使用定位符时，一定要注意定位符仅仅影响到其后置字符或者前置字符，而不是一个字符串。

假设存在一个名称为demo5.txt的文本文件，其内容如下：

020-85222213
86754234
800-820-1100
8008100716
abc123
98-3876

在上面的内容中，有些行是以3个数字开头，有些行是以其他字符开头，有些行是以两个数字开头。下面的工作是要将这些行中以3个数字开头的筛选出来。

【例8-17】演示如何使用行首定位符筛选数据行，代码如下：

01 #-----------------------------/chapter8/ex8-17.sh------------------
02 #! /bin/bash
03
04 # 筛选以 3 个数字开头的文本行
05 str='egrep "^[[:digit:]]{3}" demo5.txt'
06
07 echo "$str"

在上面的代码中，第5行的表达式使用了egrep命令，其中^[[:digit:]]{3}表示以任意3个数字开头，当然也可以写做^[0-9]{3}。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-17.sh
020-85222213
86754234
800-820-1100
8008100716

从上面的结果可以得知，demo5.txt文件中以两个数字，以及以字母开头的行已经被过滤掉。

如果想要匹配以4个数字结尾的文本行，则可以使用行尾定位符，如下面的例子所示。

【例8-18】演示行尾定位符的使用方法，代码如下：

01 #-----------------------------/chapter8/ex8-18.sh------------------
02 #! /bin/bash
03
04 # 筛选以 4 个数字结尾的文本行
05 str='egrep "[[:digit:]]{4}$" demo5.txt'
06
07 echo "$str"

上面代码中第5行的表达式[[:digit:]]{4}$表示以4个数字结尾的文本行。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-18.sh
020-85222213
86754234
800-820-1100
8008100716
98-3876

8.3.4　运算符优先级

正则表达式按照从左到右的顺序进行计算，并且遵循一定的优先级，这一点与算术表达式非常相似。所谓优先级，是指在正则表达式中，当多个运算符同时出现时，高优先级的运算符将被先处理，低优先级的运算符将被后处理。表8-3按照从高到低的顺序依次列出了运算符的优先级。

表8-3　正则表达式运算符优先级

运　算　符 | 说　明
\ | 转义符
[] | 方括号表达式
() | 分组
*、+、?、{m}、{m,}、{m, n} | 限定符
普通字符 | 按照从左到右的顺序
^、$ | 定位符
| | 或运算

在表8-3列出的运算符中，有些在基本正则表达式没有或者有不同的表示方法。例如在基本正则表达式中没有加号元字符+和分组元字符()；{m}、{m,}以及{m, n}这3种元字符需要将花括号转义，分别变成\{m\}、\{m,\}以及\{m, n\}。另外，也没有或运算符|，但是这不影响总体的优先级顺序。

当用户了解了运算符的优先级之后，就可以进一步地对【例8-17】和【例8-18】中的两个正则表达式加深理解。在表达式^[[:digit:]]{3}中，由于方括号的优先级最高，所以先计算方括号表达式，得到匹配数字的结果，接下来是计算限定符，得到了3个数字的结果，最后计算定位符，得到以3个数字开头的结果。用户也可以按照这个思路去对表达式[[:digit:]]{4}$加深理解。

8.3.5　子表达式

在8.2节刚开始的时候已经讲过，在学习正则表达式的时候应该按照字符的角度去理解和掌握。因此，限定符*或者+都作用于其前面的字符或者元字符上面。尽管这样已经保证了正则表达式的基本能力，但是还会出现一些无法正常表达的情况。

例如，在进行网页设计的时候，开发者经常会使用空格来实现页面中文本的对齐，而在HTML语言中，空格是使用&nbsp;表示的，这6个字符连在一起表示一个HTML文档中的空格符。在HTML文档的不同位置，空格符重复出现的次数也不一致。

下面的代码是一个HTML文档的一个片段：

01 <div class=" kd-appbar" ><div id='notify-box'>&nbsp;;&nbsp;;
<span class=" notify" >
02 <span id=" notify-text" >&nbsp;&nbsp;</span></span></div>
03 <div class=" kd-appname-wrapper" >&nbsp;&nbsp;
04 <div class=" goog-inline-block kd-appname" id="app-name">News
05 <div class=" goog-inline-block goog-flat-menu-button-dropdown" >
&nbsp;</div></div></div>

在上面的代码中，第1行含有2个不连续的空格符，这2个空格符之间有1个分号。第2行中含有2个连续的&nbsp;表示的空格符，第3行含有2个连续的空格符，第5行含有1个空格符。

【例8-19】通过正则表达式定位所有的2个连续的HTML空格符，代码如下：

01 #-----------------------------/chapter8/ex8-19.sh------------------
02 #! /bin/bash
03
04 str='egrep " {2}" html.txt'
05 echo "$str"

在上面的代码中，第4行表达式为&nbsp;{2}，用户试图使用该表达式来匹配两个连续的&nbsp;。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-19.sh
<div class="kd-appbar"><div id='notify-box'> ; ;<span
class="notify">

从上面的执行结果可以发现，程序并没有输出用户希望得到的结果，而是输出了含有两个不连续的空格符的文本行。为什么会出现以上结果？根据前面的介绍，限定符仅仅作用于其前置字符，在表达式&nbsp;{2}中，限定符{2}仅仅作用于字符;，因此，这个表达式的意思是匹配字符串&nbsp;后面跟着两个分号的文本行，这个意思与用户的真实意图有着明显的区别。

为了能够正确表达需求，用户可以使用子表达式来表示。所谓子表达式，是指由多个普通字符或者元字符组成的一个小的正则表达式。与正则表达式一样，子表达式本身也是一个完整的表达式，但是在使用时，子表达式是作为一个大的正则表达式的一部分来使用的，而不是单独使用。在正则表达式中，子表达式作为一个整体来看待。子表达式使用圆括号()括起来。

对于【例8-19】，用户可以使用子表达式来解决匹配的问题，修改后的代码如下：

01 #! /bin/bash
02
03 str='egrep "( ){2}" html.txt'
04 echo "$str"

在上面的代码中，第3行使用圆括号将空格符&nbsp;括起来，形成一个子表达式，这样的话，就被看做一个整体，后面的限定符{2}的作用对象就是这个子表达式。

修改后的程序的执行结果如下：

[root@linux chapter8]# ./ex8-19.sh
<span id="notify-text">  </span></span></div>
<div class="kd-appname-wrapper">

可以发现，引入子表达式后，程序输出了正确的匹配结果。

下面再看另外一个例子，用正则表达式来匹配IP地址。IP地址是由圆点隔开的四组数字，例如202.116.0.2就是一个合法的IP地址。在IP地址的四组数字中，每一组的最小值为十进制0，最大值为十进制的255。

假设存在一个存储IP地址的文本文件，名称为ip.txt，其内容如下：

01 202.116.3.2
02 3.4.2
03 10.0.0.1
04 255.255.255.255.0
05 256.45.2.1

在上面的代码中每一行描述了一个IP地址，其中第1行和第3行的IP地址是正确的，而其他的IP地址则是错误的。

【例8-20】演示通过正则表达式匹配IP地址的方法，代码如下：

01 #-----------------------------/chapter8/ex8-20.sh------------------
02 #! /bin/bash
03
04 # 匹配 IP 地址
05 str='egrep "^([[:digit:]]{1,3}\.){3}[[:digit:]]{1,3}$" ip.txt'
06
07 echo "$str"

第5行的正则表达式为^([[:digit;]]{1,3}\.){3}([[:digit:]]{1,3}$)，其中[[:digit:]]{1,3}\.是一个子表达式，表示匹配1～3个数字，然后是一个圆点。整个表达式描述的字符串以3组重复的1～3个数字后跟一个圆点为开头，然后以1～3个数字结尾。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-20.sh
202.116.3.2
10.0.0.1
256.45.2.1

从上面的执行结果可以发现，前面两行的代码是正确的IP地址，而第3行的代码却是一个错误的IP地址。之所以会输出这个错误的IP地址，是因为【例8-20】中的正则表达式并没有严格规定每一组数组的范围，从而导致了每一组中的数字的范围超出了255。

为了改进IP地址的匹配规则，用户需要对【例8-20】的表达式进行改进，使其对每一组数字进行更加精确地限制。

【例8-21】通过正则表达式分别对IP地址的3组数字给出明确的匹配规则，代码如下：

01 #-----------------------------/chapter8/ex8-21.sh------------------
02 #! /bin/bash
03
04 # 匹配正确 IP 地址
05 str='egrep "^([0-9]{1,2}|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.([0-9]
{1,2}|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.([0-9]{1,2}|1[0-9]{2}|2[0-4]
[0-9]|25[0-5])\.([0-9]{1,2}|1[0-9]{2}|2[0-4][0-9]|25[0-5])$" ip.txt'
06
07 echo "$str"

第5行的表达式比较复杂，下面对其进行简单地分析。为了能够准确地表达IP地址的规则，用户可以进一步分析IP地址的每一组的数字的特征，将每一组数字都分为4段，分别为0～99、100～199、200～249，以及250～255，可用子表达式[0-9]{1,2}|1[0-9]{2}|2 [0-4][0-9]|25[0-5]来表达。其中[0-9]{1,2}表示0～99，1[0-9]{2}表示100～199，2[0-4][0-9]表示200～249，25[0-5]表示250～255。然后，各个组之间使用圆点隔开。因为IP地址都是由四组数字组成，任何多于或者少于四组都是错误的，所以在表达式的开头加上行首定位符，在表达式的结尾加上行尾定位符。

该程序的执行结果如下：

[root@linux chapter8]# ./ex8-21.sh
202.116.3.2
10.0.0.1

注意：出于便于阅读的目的，有些用户习惯使用圆括号来将表达式进行分组，不管是否需要。尽管对执行结果并没有什么影响，但是会影响表达式的解释效率。

8.3.6　通配符

有一点必须明确，那就是Shell本身并不支持正则表达式，使用正则表达式的是一些Shell命令和工具，例如grep、awk以及sed等。但是，Shell使用了正则表达式中的某些元字符作为其通配符，常用的有*、?、[]、{}以及^等。这些字符在Shell中的意义与在正则表达式中的意义有些区别，例如*表示匹配任意的字符，而非正则表达式中的限制其前导字符的0次或者多次重复。?表示一个字符，而非其前导字符的0次或者1次重复。

为了使读者了解通配符的使用方法，下面以ls命令为例来依次介绍常用的通配符的使用方法。

如果用户想要列出以ex开头的当前目录中所有的文件，可以使用以下命令：

[root@linux chapter8]# ls -l ex*
-rwxr-xr-x 1 root root 60 Feb 15 01:15 ex8-10.sh
-rwxr-xr-x 1 root root 52 Feb 17 23:53 ex8-11.sh
-rwxr-xr-x 1 root root 53 Feb 18 00:32 ex8-12.sh
-rwxr-xr-x 1 root root 57 Feb 18 17:56 ex8-13.sh
…

在上面的命令中，ex*表示匹配以字符串ex开头的所有的文件名。

如果想要列出以字符d或者e开头的文件名，则可以使用方括号将这些字符列出来，如下：

[root@linux chapter8]# ls -l [de]*
-rw-r--r-- 1 root root 63 Feb 13 19:21 demo2.txt
-rw-r--r-- 1 root root 221 Feb 18 00:17 demo3.txt
-rw-r--r-- 1 root root 105 Feb 19 01:22 demo4.txt
…
-rwxr-xr-x 1 root root 60 Feb 15 01:15 ex8-10.sh
-rwxr-xr-x 1 root root 52 Feb 17 23:53 ex8-11.sh
…

在某些情况下，文件名是按照字母或者数字顺序来编号的，在这种情况下，用户可以使用连字符来表示一个范围，这与正则表达式中的表达方法是一致的，如下：

[root@linux chapter8]# ls -l ex8-[1-9].sh
-rwxr-xr-x 1 root root 57 Feb 13 19:05 ex8-1.sh
-rwxr-xr-x 1 root root 53 Feb 14 11:31 ex8-2.sh
-rwxr-xr-x 1 root root 59 Feb 14 14:43 ex8-3.sh
-rwxr-xr-x 1 root root 142 Feb 14 15:28 ex8-4.sh
-rwxr-xr-x 1 root root 56 Feb 14 15:54 ex8-5.sh
-rwxr-xr-x 1 root root 138 Feb 14 17:00 ex8-6.sh
-rwxr-xr-x 1 root root 57 Feb 14 17:47 ex8-7.sh
-rwxr-xr-x 1 root root 57 Feb 14 18:11 ex8-8.sh
-rwxr-xr-x 1 root root 67 Feb 14 18:51 ex8-9.sh

上面的命令表示列出以ex8-开头，然后是编号1～9，接着是.sh的文件名。

8.4　grep命令

在Shell的命令中，grep命令是一个与正则表达式关系非常密切，并且使用也非常频繁的命令。在本章的绝大部分例子中，都使用了grep命令来讲解。为了使读者掌握这个命令的使用方法，本节将对其进行详细介绍。

8.4.1　grep命令的基本语法
grep命令的名称来自于全局搜索正则表达式并打印文本行（Global Search Regular Expression and Print out the line）的缩写。它是一个非常古老的UNIX命令，也是一种强大的文本搜索工具。grep命令使用正则表达式来搜索文本，并且把匹配的文本行打印出来。

grep命令的基本语法如下：

grep [options] pattern [file…]

在上面的语法中，options表示选项，如表8-4所示。pattern表示要匹配的模式，file表示一系列的文件名。grep命令会从一个或者多个文件中搜索满足指定模式的文本行，并且打印出来。模式后面的所有的字符串参数都被看做文件名。

表8-4　常用的grep命令选项

选项 | 说　明
-c | 只打印匹配的文本行的行数，不显示匹配的内容
-i | 匹配时忽略字母的大小写
-h | 当搜索多个文件时，不显示匹配文件名前缀
-l | 只列出含有匹配的文本行的文件的文件名，而不显示具体的匹配的内容
-n | 列出所有的匹配的文本行，并显示行号
-s | 不显示关于不存在或者无法读取文件的错误信息
-v | 只显示不匹配的文本行
-w | 匹配整个单词
-x | 匹配整个文本行
-r | 递归搜索，不仅搜索当前目录，还要搜索其各级子目录
-q | 禁止输出任何匹配结果，而是以退出状态码的形式表示搜索是否成功，其中0表示找到了匹配的文本行
-b | 打印匹配的文本行到文件头的偏移量，以字节为单位
-E | 支持扩展正则表达式
-P | 支持Perl正则表达式
-F | 不支持正则表达式，将模式按字面意义匹配

grep命令的模式非常灵活，可以是字符串，也可以是变量，还可以是正则表达式。但是如果模式中含有空格，则需要将模式使用双引号引起来，以避免grep命令将空格后面的模式误认为文件名。当然，在绝大部分情况下，使用单引号将模式引用起来也可以。

例如，下面的命令将搜索名称为demo3.txt文件中是否含有字符串simple的文本行，命令如下：

[root@linux chapter8]# grep simple demo3.txt
No matter what you're looking for, our motto is "keep it simple"

从上面的输出结果可以得知demo3.txt文件有一行含有指定的模式simple。

如果只想统计含有某个模式的行数，则可以使用-c选项，如下：

[root@linux chapter8]# grep -c simple demo3.txt
1

从上面的输出结果可以得知，含有模式simple的文本行只有一行。

8.4.2　grep命令族简介

随着UNIX的发展，grep命令也在不断地完善。到目前为止，grep命令族已经包括grep、egrep以及fgrep等3个命令。egrep和fgrep命令跟grep命令只有很少的区别。其中，egrep命令是grep命令的扩展，它使用扩展正则表达式作为默认的正则表达式引擎，因此，egrep命令支持更多的元字符。fgrep命令是fixed grep或者fast grep的缩写。在fgrep命令中，所有的字母都看做单词，也就是说，在fgrep命令中，所有的正则表达式中的元字符都将作为一般字符，仅仅拥有其字面意义，不再拥有特殊意义。

8.5　小结

本章详细介绍了Shell正则表达式的相关知识，包括什么是正则表达式、正则表达式的基础知识、正则表达式的具体应用，以及grep命令族。重点在于掌握基本正则表达式、扩展正则表达式及Perl正则表达式的元字符及使用方法、正则表达式的字符集、如何匹配单个字符、如何匹配多个字符、定位符的使用方法，以及子表达式等。在下一章中，将介绍Shell的基本文本处理方法。

第9章
基本文本处理

无论是在UNIX中，还是在Linux中，文本的处理都非常重要。在Linux中，所有的一切都是文件，而与普通用户关系最密切的，就是文本文件。同时，文本处理也成为Shell程序设计中很重要的一部分，Linux系统中许多程序的相互协作都是通过文本文件来实现的。本章将介绍如何在Shell程序中对文本进行处理。

本章主要涉及的知识点如下所述。

- 使用echo命令输出文本：主要介绍echo命令的基本语法、显示普通字符串、转义字符、变量、命令执行结果等各种数据的方法。

- 文本的格式化输出：主要介绍对文本格式化输出的各种方法，包括制表符、fold、fmt，以及pr等命令的使用方法。

- 使用sort命令对文本排序：主要介绍sort命令的基本语法，以及如何根据不同的标准对文本进行排序。

- 文本的统计：主要介绍如何创建行号、统计行数、单词数，以及字符数等。

- 使用cut命令选取特定的文本：主要介绍cut命令的基本语法，以及如何根据不同的要求对文本列进行选择。

- 使用paste命令拼接文本行：主要介绍paste命令的基本语法，以及如何根据用户需求来并行拼接多个文件的列。

- 使用join命令联接文本行：主要介绍joint命令的基本语法，以及使用不同的方法来联接两个文本文件。

- 使用tr命令替换文件内容：主要介绍tr命令的基本语法、如何去除重复出现的字符、删除空行、大小写转换，以及删除指定字符等。

9.1　使用echo命令输出文本

在进行Shell程序设计的过程中，文本的输出非常重要。例如程序为用户提供的提示信息及程序的执行结果等，这些信息都是作为文本输出的。本节将介绍最简单的文本输出命令echo。

9.1.1　显示普通字符串

在本书前面几章的例子中，我们已经接触过echo命令了。echo命令的功能就是输出一行文本。在Shell程序中，多用于显示提示信息或者程序产生的数据。

echo命令的基本语法如下：

echo [options] string…

在上面的语法中，options表示命令选项。echo命令常用选项比较少，只有一个-n，该选项表示不输出行尾的换行符。在默认情况下，每执行一次echo命令，都会在输出信息的行尾加上一个换行符。参数string表示要输出的文本，用户可以同时指定多个文本，这些文本之间用空格隔开。

【例9-1】演示如何使用echo命令输出用户提示信息，代码如下：

01 #-----------------------------/chapter9/ex-1.sh------------------
02 echo -n "What is your first name? "
03 read first
04 echo -n "What is your last name? "
05 read last
06 echo -n "What is your middle name? "
07 read middle
08 echo -n "What is your birthday? "
09 read birthday

在上面的代码中，每个echo语句都提示用户输入一条信息，然后使用read语句读取用户输入的数据。之所以使用了-n选项，主要是禁止echo语句执行完成之后附加换行符，从而使得光标可以停留在提示信息后面，如图9-1所示。

图9-1　使用-n选项禁止附加换行符

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-1.sh
What is your first name? Chunxiao
What is your last name? Zhang
What is your middle name?
What is your birthday? 1973-07-19

在使用echo命令输出文本时，如果要输出的文本是由多个单词组成的，用户可以使用双引号或者单引号将其引起来。当然，如果不使用引号引起来的话，在绝大部分情况下，也不会影响输出的结果，只是此时echo命令将一个字符串根据空格分割成多个字符串输出了。例如下面两条命令的执行结果并没有不同之处：

[root@linux chapter9]# echo "Hello world."
Hello world.
[root@linux chapter9]# echo Hello world
Hello world

9.1.2　显示转义字符

除了支持普通文本的输出之外，echo命令还支持简单的转义字符的输出。通过转义字符，echo命令可以控制输出的格式，或者是输出某些特殊的字符，例如退格符、换页符，以及制表符等。表9-1列出了echo命令支持的转义字符。如果要使echo命令支持转义字符，还必须使用-e选项。

表9-1　echo命令的转义字符

字　符 | 说　明
\a | 报警符，相当于ASCII码的BEL字符
\b | 退格符
\c | 禁止继续输出文本
\f | 换页符
\n | 换行符
\r | 回车符
\t | 水平制表符
\v | 垂直制表符
\\ | 反斜线

在表9-1列出的转义字符中，\c字符使echo停止继续输出该字符后面的文本，包括最后的换行符。

【例9-2】演示各种转义字符的使用方法，代码如下：

01 #-----------------------------/chapter9/ex9-2.sh------------------
02 #! /bin/bash
03
04 # 退格符
05 echo -e "this is a\b string."
06 # 禁止继续输出后面的文本
07 echo -e "hello \c world."
08 # 换行符
09 echo -e "hello \n world."
10 # 使用制表符输出表格
11 echo -e "Alice\t99"
12 echo -e "John\t82"
13 echo -e "Tom\t91"

在上面的代码中，第5行使用了退格符，该字符会使光标从当前位置向左后退一个字符，然后继续输出文本。因此，该字符会导致前面的一个字符被覆盖。第7行使用了\c字符，该字符会导致echo语句忽略hello后面的文本。第9行使用了换行符\n，该字符会使光标跳到下一行的开始位置。第11～13行使用制表符输出了一个简单的表格，该字符会使后面的内容在垂直方向上对齐。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-2.sh
01 this is string.
02 hello hello
03 world.
04 Alice 99
05 John 82
06 Tom 91

在上面的输出结果中，第1行代码中的字符a消失了，这是因为使用了退格符，从而导致字符a被后面的空格覆盖。第2行有2个hello，其中第1个hello是代码第7行的echo语句的输出结果。由于字符\c会使echo语句忽略后面的内容，包括换行符，所以代码第9行的echo语句并没有转到下一行输出。同时，由于代码第9行使用了换行符，所以单词world转到了下一行输出。输出结果的第4～6行是使用制表符输出的简单的表格。

9.1.3　显示变量

用户可以使用echo语句将程序中的变量的值打印出来，在前面的许多例子中，都使用echo语句来输出某些信息。

【例9-3】演示使用echo语句显示变量的方法，代码如下：

01 #-----------------------------/chapter9/ex9-3.sh------------------
02 #! /bin/bash
03
04 echo -n "Please input a name:"
05
06 read name
07 #输出变量的值
08 echo "Hello, $name"
09
10 v1="sing"
11 v2="danc"
12 #错误的输出变量值的方法
13 echo "We are $v1ing, we are $v2ing."

在上面的代码中，第4行输出一行提示信息，第6行使用read语句读取用户输入信息，第8行将变量name的值输出。第10～11行分别定义了两个变量，第13行同样输出这两个变量的值。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-3.sh
Please input a name:Chunxiao
Hello, Chunxiao
We are , we are .

在上面的执行结果中，可以发现用户输入的文本已经被正常输出。但是，第11～12行代码定义的两个变量却没有正确输出。之所以会得到这样的结果，是因为第13行代码中的变量名与其他字符连接在一起，从而导致Shell搞不清楚变量名到底是由哪些字符组成。而第8行代码中，变量名name与其他的字符并没有连接在一起。为了使Shell能够正确地解析变量名，用户需要使用花括号将变量名括起来。

【例9-4】使用花括号界定变量名，代码如下：

01 #-----------------------------/chapter9/ex9-4.sh------------------
02 #! /bin/bash
03
04 echo -n "Please input a name:"
05
06 read name
07
08 echo "Hello, $name"
09
10 v1="sing"
11 v2="danc"
12
13 echo "We are ${v1}ing, we are ${v2}ing."

在上面的代码中，第13行使用花括号将变量名v1和v2分别括起来，括号里面的就是变量名。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-4.sh
Please input a name:Chunxiao
Hello, Chunxiao
We are singing, we are dancing.

可以发现，这次得到了正确的结果。

注意：如果变量名与不能作为变量名的字符，例如-、’及/等连接在一起时，可以不使用花括号。

9.1.4　换行和不换行

在默认情况下，echo命令在输出文本的末尾会自动追加一个换行符，下面的例子就说明了这种情况。

【例9-5】演示echo命令会自动追加换行符，代码如下：

01 #-----------------------------/chapter9/ex9-5.sh------------------
02 #! /bin/bash
03
04 echo "You are always in control of your search settings."
05 echo " Here's a quick review of the options that you can set."

在上面的代码中，第4～5行分别输出一行文本。尽管没有使用换行符，但是，echo语句会自动追加一个换行符。也就是说，第5行的文本会在下面一行输出。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-5.sh
You are always in control of your search settings.
Here's a quick review of the options that you can set.

为了能够避免echo语句自动换行，用户可以使用两种方法来解决：首先，用户可以使用echo命令的-n选项，其次，用户还可以使用转义字符\c。-n选项可以使echo命令不输出结尾的换行符；转义字符\c可以使echo命令忽略其后的字符。

【例9-6】演示使用-n选项来使多条echo语句输出到同一行中，代码如下：

01 #-----------------------------/chapter9/ex9-6.sh------------------
02 #! /bin/bash
03
04 # 使用 -n 选项输出文本
05 echo -n "You are always in control of your search settings."
06 echo " Here's a quick review of the options that you can set."

该程序的执行结果如下所示。

[root@linux chapter9]# ./ex9-6.sh
You are always in control of your search settings. Here's a quick review
of the options that you can set.

从上面的输出结果可以得知，使用-n选项之后，第5行的echo语句在结尾并没有输出换行符，所以第6行的echo语句会紧接着在同一行继续输出。

如果使用转义字符\c，可以达到同样的效果。但是，由于\c会忽略该字符后面的所有的文本，所以如果仅仅解决换行问题，应该将转义字符\c放在文本的最后。

【例9-7】演示如何使用\c来避免换行，代码如下：

01 #-----------------------------/chapter9/ex9-7.sh------------------
02 #! /bin/bash
03
04 # 使用转义字符避免换行
05 echo -e "You are always in control of your search settings.\c"
06 echo " Here's a quick review of the options that you can set."

在第5行中，为了使得echo命令支持转义字符，需要使用-e选项。该程序的执行结果与【例9-6】完全相同，如下所示。

[root@linux chapter9]# ./ex9-7.sh
You are always in control of your search settings. Here's a quick review
of the options that you can set.

9.1.5　显示命令执行结果

除了显示文本之外，echo命令还可以将Shell命令执行结果显示出来。不过，在显示命令执行结果的时候，需要使用反引号将命令引用起来，其语法如下：

echo 'command'

【例9-8】演示如何使用echo命令显示命令的执行结果，代码如下：

01 #-----------------------------/chapter9/ex9-8.sh------------------
02 #! /bin/bash
03
04 # 显示 date 命令的执行结果
05 echo 'date'
06 # 显示 ls 命令的执行结果
07 echo 'ls'

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-8.sh
Fri Feb 22 23:31:58 HKT 2013
ex9-1.sh ex9-2.sh ex9-3.sh ex9-5.sh ex9-6.sh ex9-7.sh ex9-8.sh

9.1.6　echo命令执行结果的重定向

关于重定向的详细介绍，请参考本书的第12章。简单地讲，重定向就是将Shell命令的标准输出重新定向到一个文件。在默认情况下，echo命令的标准输出设备为显示器。但是，在某些情况下，用户可能不需要将echo命令显示的信息输出到屏幕，而是需要将其保存到一个磁盘文件中，此时，用户就需要使用重定向。

重定向的操作符为>或者>>，在目标文件已经存在的情况下，前者会覆盖目标文件原有的内容，而后者则会将数据追加到原来文件的末尾。

将echo命令的执行结果重定向的语法比较简单，直接将重定向操作符放在echo语句的结尾，然后再指定一个文件名即可。

【例9-9】演示将echo命令执行结果重定向的方法，代码如下：

01 #-----------------------------/chapter9/ex9-9.sh------------------
02 #! /bin/bash
03
04 # 将要输出的信息写入文件
05 echo "Hello, world." > hello.txt
06 # 将输出的信息追加到文件的结尾
07 echo "Hello, Chunxiao." >> hello.txt

在上面的代码中，第5行语句是将字符串Hello, world.写入hello.txt文件，并且会覆盖原有文件的内容，如果hello.txt文件已经存在的话。第7行是将字符串Hello, Chunxiao追加到hello.txt文件的末尾。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-9.sh
[root@linux chapter9]# more hello.txt
Hello, world.
Hello, Chunxiao.

从上面的执行结果可以得知，第5行和第7行语句中的字符串已经被写入hello.txt文件中，并且第7行的echo语句并没有覆盖原来的内容。

9.2　文本的格式化输出

尽管echo命令语句提供了最基本的输出功能，但是在某些场合，用户可能需要对文本的输出格式进行更加细致地控制。在Shell程序中，文本的格式化输出主要有制表符、pr命令，以及fmt命令等。本节将介绍这几个命令的使用方法。

9.2.1　使用UNIX制表符

制表符的功能是在不使用表格的情况下，在垂直方向上按列对齐文本。对于输出某些简单的表格，例如名单及简单列表使用制表符是非常方便快捷的。尤其是在字符界面下输出表格，使用制表符可以达到事半功倍的效果。

在Shell中，制表符通常使用转义字符\t表示，其中字母t表示Tab，即表格的英文单词Table的前3个字母。前面已经讲过，echo命令支持制表符的输出。但是为了使用转义字符，需要使用-n选项。

【例9-10】演示使用echo命令结合制表符来输出九九乘法表，代码如下：

01 #-----------------------------/chapter9/ex9-10.sh------------------
02 #! /bin/bash
03
04 #双层嵌套循环输出乘法表
05 for((i=1;i<10;i++))
06 do
07 for((j=1;j<=$i;j++))
08 do
09 #使用制表符对齐列
10 echo -n -e "$i*$j\t"
11 done
12 #换行
13 echo ""
14 done

关于for循环语句的使用方法，前面已经详细介绍过了。在本例中，使用双层for循环结构，依次以表格的形式输出两个循环中的循环变量的乘积。在第10行中，echo语句用来输出数据，最后附加了一个制表符，是为了纵向对齐每列。第13行的echo语句的作用是当内层循环每次结束之后，打印一个换行符。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-10.sh
1*1
2*1 2*2
3*1 3*2 3*3
4*1 4*2 4*3 4*4
5*1 5*2 5*3 5*4 5*5
6*1 6*2 6*3 6*4 6*5 6*6
7*1 7*2 7*3 7*4 7*5 7*6 7*7
8*1 8*2 8*3 8*4 8*5 8*6 8*7 8*8
9*1 9*2 9*3 9*4 9*5 9*6 9*7 9*8 9*9

注意：在不同的场合，制表符的大小可能会有所不同。但是，在通常情况下，制表符默认为4个或者5个空格。

9.2.2　使用fold命令格式化行

顾名思义，fold命令的功能是将超过指定宽度的文本行进行折叠处理，使得超过指定宽度的字符转到下一行输出。fold命令的基本语法如下：

fold [options] [file…]

在上面的语法中，options表示选项，fold命令常用的选项有如下所列。

- -b：按字节计算宽度。默认情况下，fold命令按列来计算宽度。

- -s：在空格处折断行。

- -w：指定宽度，默认值是80列。

file参数用来指定要输出的文件名，可以是多个文件，文件名之间用空格隔开。

【例9-11】演示使用fold命令格式化输出文本的方法。首先准备一个文本文件，其内容如下所示。

[root@linux chapter9]# cat demo1.txt
You are always in control of your search settings. Here's a quick review
of the options that you can set (and change whenever you like) on the search
settings page. You can reach that page by clicking the gear icon in the top
right corner of the search results page, then clicking Search settings. You
can also visit the page directly at google.com/preferences. The options are
grouped into three categories: Search results, Languages, and Location. Click
the three links on the left side of the page to change categories.

然后使用fold命令来格式化输出该文件的内容，命令如下：

[root@linux chapter9]# fold -w 90 demo1.txt

以上命令的执行结果如图9-2所示。

图9-2　指定输出宽度

从图9-2可以看出，指定了宽度之后，当文本行的宽度达到指定列数之后，便立即换行，即使是一个单词，也被截断。因此，从外观上看，整个文本非常整齐。但是，由于换行处的单词被截断了，所以这并不符合用户的习惯。

对于以空格作为单词分隔符的语言来说，用户可以使用-s选项，使fold命令在空格处将行折断，从而保持了单词的完整。也就是说，当fold命令输出到指定列数之后，发现输出的一个单词还没有输出完，此时，fold命令会在当前行继续输出完还没有输出完的单词，然后到下一行继续输出。

【例9-12】通过-s选项使文本行在换行时保留单词的完整，同样指定文本行的宽度为90列，命令如下：

[root@linux chapter9]# fold -s -w 90 demo1.txt

以上命令的执行结果如图9-3所示。

图9-3　指定宽度和空格处换行

比较图9-2和图9-3，可以发现在图9-3中，所有的行尾的单词都是完整的。尽管在图9-3中，所输出的文本并没有图9-2的整齐，但是它更符合用户的习惯。

注意：fold命令的-w选项会使文本行被生硬地截断，并不判断单词的完整性，因此在使用时一定要注意。

通常情况下，用户使用fold命令的目的仅仅是输出到屏幕或者打印机等设备上面。如果想要将格式化的结果保存下来，可以使用重定向操作符将输出重定向到磁盘文件。

【例9-13】通过重定向将fold命令的输出结果保存到文件，命令如下：

[root@linux chapter9]# fold -w 90 demo1.txt > formtedtext.txt

上面的命令将格式化的结果保存到formtedtext.txt文件中。当命令执行完成之后，用户可以使用cat或者more等命令来查看文件的内容，命令如下：

[root@linux chapter9]# cat formtedtext.txt

以上命令的执行结果如图9-4所示。

图9-4　使用cat命令查看格式化输出生成的文本文件

从图9-4中可以看到，formatedtext.txt文件完整地保存了fold命令的格式化结果。

9.2.3　使用fmt命令格式化段落

与fold命令相比，fmt命令提供了更多的功能。fmt命令是Shell中的一个简单的文本格式化工具，其名称来自于格式化（format）的缩写。fmt命令的基本语法如下：

fmt [-width] [option]... [file]...

其中，-width选项用来指定文本行的列数，默认为75列，即每行显示75个字符。option表示各种命令选项，常用的选项如下所列。

- -c：保留每个段落的前两行的缩进，该段落剩余的行的左边距与第2行相同。

- -t：该选的功能与-c选项基本相同，但是在使用-t选项时，每个段落的第1行和第2行的缩进必须是不相同的，否则第1行将被看做一个单独的段落。

- -s：只折断超出指定宽度的行，不合并少于指定宽度的行。

- -u：统一空格的个数，单词之间保留1个空格，句子之间保留2个空格。

- -w：指定每个行的最大宽度，默认值为75列。

file参数为要格式化其内容的文件名，可以同时指定多个文件，文件名之间用空格隔开。如果指定文件名为-，则fmt命令会从标准输入，即键盘读取文本。

为了演示fmt命令的使用方法，用户需要首先创建一个文本文件，其内容如下：

[root@linux chapter9]# cat demo2.txt
Linux is a UNIX-like computer operating system assembled under the model
of free and open source software development and distribution.
The defining component of Linux is the Linux kernel, an operating system
kernel first released 5 October 1991 by Linus Torvalds.[11][12]
Linux was originally developed as a free operating system for Intel
x86-based personal computers.
It has since been ported to more computer hardware platforms than any other
operating system. It is a leading operating system on servers and other big
iron systems such as mainframe computers and supercomputers:[13][14][15][16]
more than 90% of today's 500 fastest supercomputers run some variant of
Linux,[17] including the 10 fastest.[18] Linux also runs on embedded systems
(devices where the operating system is typically built into the firmware
and highly tailored to the system) such as mobile phones, tablet computers,
network routers, televisions[19][20] and video game consoles; the Android
system in wide use on mobile devices is built on the Linux kernel.
The development of Linux is one of the most prominent examples of free and
open source software collaboration: the underlying source code may be used,
modified, and distributed commercially or non-commercially by anyone under
licenses such as the GNU General Public License.
Typically Linux is packaged in a format known as a Linux distribution for
desktop and server use. Some popular mainstream Linux distributions include
Debian (and its derivatives such as Ubuntu and Linux Mint), Fedora (and its
derivatives such as Red Hat Enterprise Linux and CentOS), Mandriva/Mageia,
OpenSUSE, and Arch Linux. Linux distributions include the Linux kernel,
supporting utilities and libraries and usually a large amount of application
software to fulfill the distribution's intended use.

【例9-14】演示使用-w选项来指定行的最大长度为80列，代码如下：

01 #-----------------------------/chapter9/ex9-13.sh------------------
02 #! /bin/bash
03
04 # 指定行长度
05 str='fmt -c -w 80 demo2.txt'
06 echo "$str"

在上面的代码中，第5行使用-c选项来指定文本行的最大长度为80列。

以上代码的执行结果如图9-5所示。

从图9-5可以得知，使用-w选项指定了文本行的最大长度为80列之后，fmt命令输出文本超过80个字符便会自动换行。如果达到80个字符时fmt命令发现没有显示完的单词，便将该单词移至下一行显示。

使用-c选项之后，每个文本的每个段落前面两行的缩进格式被保留下来，同时，该段落从第3行开始，其缩进格式与第2行相同。

如果不想fmt命令将不足指定行长度的行合并，则可以使用-s选项。

【例9-15】本例对【例9-14】进行改进，加上-s选项，以避免合并行，代码如下：

01 #-----------------------------/chapter9/ex9-14.sh------------------
02 #! /bin/bash
03
04 # 不合并不足指定行宽的行
05 str='fmt -s -c -w 80 demo2.txt'
06 echo "$str"

以上代码的执行结果如图9-6所示。

图9-5　指定行宽和缩进

图9-6　不合并不足指定行宽的行

对比图9-5和图9-6，可以发现，在图9-6中，The defining component…、It has since…，以及Typically Linux is…这3行并没有被合并到上面的一行中。

关于fmt命令的其他选项不再举例说明，用户可以自己去尝试操作。与fold命令一样，用户也可以通过输出重定向将fmt命令格式化结果输出到文件中。这个操作与fold基本相同，请读者参考fold命令的使用方法进行练习，不再详细说明。

注意：fmt命令的着重点在于文本的段落。另外，fmt命令的-w选项和fold命令的-w选项的功能并不相同。前者会考虑单词的完整性，而后者则是直接将单词截断。

9.2.4　使用rev命令反转字符顺序
rev命令的功能是用来反转文件中的文本行的字符顺序，其名称来自于reverse（反转）的前3个字母。rev命令的基本语法如下：

rev [file ...]

其中，file表示要处理的文件的文件名列表，如果是多个文件，则文件名之间用空格隔开。

假设存在一个名称为demo3.txt的文本文件，其内容如下：

[root@linux chapter9]# cat demo3.txt
1 2 3 4 5 6 7 8 9
Hello, world.

【例9-16】演示rev命令的使用方法，代码如下：

01 #-----------------------------/chapter9/ex9-15.sh------------------
02 #! /bin/bash
03
04 # 反转文本行
05 str='rev demo3.txt'
06 echo "$str"

该例的执行结果如下：

[root@linux chapter9]# ./ex9-15.sh
9 8 7 6 5 4 3 2 1
.dlrow ,olleH

从上面的执行结果可以得知，demo3.txt文件中的2行文本的字符都被逐个反转顺序。

9.2.5　使用pr命令格式化文本页
pr命令是一个非常有用的工具，其功能主要是将文本文件的内容转换成适合打印的格式。pr命令的名称来自于print（打印）的前两个字母，其基本语法如下：

pr [option]... [file]...

其中，option表示命令选项，常用的选项如下所列。

- -column：指定输出的栏数，默认值为1。

- -a：修改-column的显示效果，水平创建栏，与-column选项配合使用。

- -d：产生两个空格的输出。

- -F或者-f：使用换页符代替换行符实现分页。

- -h：指定页眉，如果没有指定，则默认使用文件名作为页眉。

- -l：指定每页的行数，默认为66行。

- -o：指定每行的缩进的字符数量。

- -w：指定页面宽度，默认为72个字符。

在使用-column等选项时，一定要注意指定的不能超过页面的宽度。默认情况下，pr命令的分栏是垂直划分的，也就是说，文件的前面几行会作为第1栏的内容，接下来的几行会作为第2栏的内容，依此类推。但是用户可以使用-a选项来改变这种显示的效果，使得水平划分栏。也就是说，文件的第1行作为第1栏的第1行，第2行作为第2栏的第1行，依此类推。当所有栏的第1行都输出之后，再重新回到第1栏输出第2行。

为了演示pr命令的使用方法，首先准备一个文件，其内容如下：

[root@linux chapter9]# cat demo4.txt
Niger
Mali
Afghanistan
Burkina Faso
Malawi
Uganda
Zambia
Chad
DR Congo
Somalia
Rwanda
Mozambique
Angola
Tanzania
Guinea-Bissau
East Timor
Sierra Leone
Equatorial Guinea
Benin
Guinea
Nigeria
South Sudan
Senegal
Liberia
Kenya

在上面的文件中，包含一个国家名称的列表。为了打印输出，用户需要使用pr命令来对该文件进行格式化处理。

【例9-17】演示使用pr命令将该文件的内容进行格式化处理，代码如下：

01 #-----------------------------/chapter9/ex9-16.sh------------------
02 #! /bin/bash
03
04 # 格式化文本页
05 str='pr -4 demo4.txt'
06 echo "$str"

其中，第5行将demo4.txt的内容分成4栏输出。页面宽度为默认值，即72个字符。

该例的执行结果如图9-7所示。

在图9-7中可以看到，pr命令生成了一个可以输出到打印机的基本的页面，包括打印日期、页眉及页码。同时，将内容分成指定的栏来输出。如果文本的行数不够一个完整的页面的高度，则使用换行符来填充。

【例9-18】演示如何自定义页眉、文本水平分栏，以及使用换页符来代替换行符，代码如下：

01 #-----------------------------/chapter9/ex9-17.sh------------------
02 #! /bin/bash
03
04 # 自定义页眉
05 str='pr -h "List of Countries" -a -f -4 demo4.txt'
06 echo "$str"

该例的执行结果如图9-8所示。

图9-7　格式化文本页

图9-8　自定义页眉，水平分栏

除了以上常用选项之外，如果用户不想显示标题，则可以使用-t选项，命令如下：

[root@linux chapter9]# pr -t -4 demo4.txt

以上命令的执行结果如图9-9所示。

图9-9　省略标题栏

如果想要将格式化的结果保存到文件中，同样可以使用输出重定向操作符，如下所示。

[root@linux chapter9]# pr -h "List of Countries" -a -f -4 demo4.txt >
countries.txt
[root@linux chapter9]# more countries.txt
2013-02-24 10:45 List of Countries Page 1
Niger Mali Afghanistan Burkina Faso
Malawi Uganda Zambia Chad
DR Congo Somalia Rwanda Mozambique
Angola Tanzania Guinea-Bissau East Timor
Sierra Leone Equatorial Guinea Benin Guinea
Nigeria South Sudan Senegal Liberia
Kenya
^L

注意：与fold和fmt命令不同，pr命令主要是为打印输出设置页面。

9.3　使用sort命令对文本排序

在Linux或者UNIX中，许多数据都是以文本文件的形式存在。因此，用户经常需要对这些文本进行排序，为此，Shell提供了sort命令来完成这个任务。本节将介绍sort命令的使用方法。

9.3.1　sort命令的基本用法

Shell中的sort命令有3种执行模式，分别为排序文本、检查文件是否已经排序以及合并文件。在本节当中，对这3种执行模式将依次详细介绍。在本节中，首先介绍最基本的排序功能。

sort命令的基本语法如下：

sort [option]... [file]...

在上面的语法中，option表示sort命令的选项，常用的选项如下所列。

- -b：忽略前导空格或者制表符，找出第一个非空格字符。

- -c：测试文件是否已经排序。

- -d：根据字典顺序排序。该选项仅比较数字、字母和空格等字符。

- -f：忽略大小写。将小写字母转成大写字母后参与比较。

- -i：仅仅比较可打印字符。

- -n：根据算术值进行比较，参与比较的字符有空格、十进制数字，以及减号等。如果对非数值字符进行算术值比较，将会产生无法预知的结果。

- -R：根据哈希值随机排序。

- -r：颠倒排序结果。

- -k：定义排序关键字。

- -m：仅仅合并已经排好序的文件，不执行排序操作。

- -o：将排序结果写入文件，而不是标准输出设备。

- -t：指定字段分隔符。默认值为空格。

- -u：删除重复的行，只保留第一个。

file参数是要排序的文件列表，多个文件名之间用空格隔开。如果使用符号-作为文件名，则从标准输入读取数据。

尽管sort命令的语法相对比较简单，没有非常多的令人眼花缭乱的选项参数，但是， sort命令的功能非常强大，完全可以满足用户对于简单数据的管理。

为了学习sort命令的使用方法，用户需要首先准备一个文本文件，其名称为demo5.txt，内容如下：

[root@linux chapter9]# cat demo5.txt
Toy_Story HK 239 3972
The_Hill KL 63 2972
Star_Wars HK 301 4102
Boys_in_Company_C HK 192 2192
Aliens HK 532 4892
Alien HK 119 1982
A_Few_Good_Men KL 445 5851

上面文件的内容共分4列，第1列为商场名称，第2列为供货区代码，第3列为本季度租金金额，第4列为本年度租金金额。这些列之间使用制表符隔开。

最简单的sort命令的用法就是不使用任何选项。如果不指定任何选项，则sort命令会将整个文本行作为一个关键字来排序。

【例9-19】演示使用最简单的sort命令对demo5.txt文件进行排序，代码如下：

01 #-----------------------------/chapter9/ex9-18.sh------------------
02 #! /bin/bash
03
04 # 使用默认选项对文件排序
05 result='sort demo5.txt'
06 # 将输出结果保存到文件
07 echo "$result" > sorted_default.txt
08 # 显示排序结果
09 cat sorted_default.txt

该例的执行结果如下：

[root@linux chapter9]# ./ex9-18.sh
A_Few_Good_Men KL 445 5851
Alien HK 119 1982
Aliens HK 532 4892
Boys_in_Company_C HK 192 2192
Star_Wars HK 301 4102
The_Hill KL 63 2972
Toy_Story HK 239 3972

对比demo5.txt文件的内容，可以发现使用sort命令之后，文件的内容已经处于有序状态了。如果不提供任何关键字，则sort会将整个文本行作为关键字进行排序，其排序规则是首先比较第1列，如果第1列的值相同，则比较第2列，依此类推，直至比较完所有的列。如果所有的列都相同，则判定这些行是重复的行。

注意：sort命令处理数据是以行为单位进行的，与关系型数据库中的表非常相似。

9.3.2　使用单个关键字排序

前面讲过，如果不指定任何选项，则sort命令会根据整个文本行进行排序，文本行中的所有的列都会参与排序。但是，在某些情况下，用户可能不需要所有的列都进行比较，而只是根据其中的某几个列进行排序。对于这种情况，用户可以使用-k选项来定义排序关键字，其基本语法如下：

-k pos1[,pos2]

其中，pos1表示排序关键字的起始位置，pos2表示排序关键字的结束位置，这两者之间用逗号隔开。通常情况下，组成排序关键字都是以列为单位的。此时，post1和pos2就是关键字的起始列和结束列的列号。列号从1开始，即第1列为1，第2列为2，依此类推。

注意：sort命令中，一个文本行最多只能包括10列。

在通过关键字排序的时候，sort命令首先会根据关键字中的第1个列排序，如果关键字的第1个列相同，则会根据关键字中的第2个列排序，依此类推。

【例9-20】演示使用2个列作为一个关键字来进行排序的方法，代码如下：

01 #-----------------------------/chapter9/ex9-19.sh------------------
02 #! /bin/bash
03
04 # 通过第 2 列和第 3 列排序
05 result='sort -k 2,3 demo5.txt'
06
07 echo "$result"

在上面的代码中，第5行使用-k选项定义了一个关键字，包括第2列和第3列，程序会首先根据第2列进行排序，在第2列相同的情况下，再根据第3列排序。

该例的执行结果如下：

[root@linux chapter9]# ./ex9-19.sh
Alien HK 119 1982
Boys_in_Company_C HK 192 2192
Toy_Story HK 239 3972
Star_Wars HK 301 4102
Aliens HK 532 4892
A_Few_Good_Men KL 445 5851
The_Hill KL 63 2972

观察上面的排序结果，可以发现第3列中的63比同列中其他各个值都小，但是却排到了最后，这是因为在默认情况下，sort命令会将列的值作为字符串来排序。而“63”中的第1个字符“6”的ASCII码比其余的值的第1个字符的ASCII码值都大，所以排到了最后。通常情况下，这种排序方法并不符合用户的要求，关于如何解决这个问题，将在随后的内容中介绍。

在理解了排序关键字的定义方法之后，接下来再介绍几种特殊的情况。一般情况下，排序关键字是由起始列和终止列定义的，但是在某些情况下，用户可能会省略终止列，只保留起始列，那么其语法就变成以下形式：

-k pos

以上语法也是一种合法的语法，它表示从pos参数指定的列开始，一直到文本行的结尾都是排序关键字。这意味着，sort在排序的时候，会从pos指定的列开始，一直比较到行尾。

注意：关键字-k pos并不表示根据第pos个列排序，而是根据从第pos列开始一直到行尾的所有列排序，初学者往往在这个问题上犯错误。

为了能够观察到sort命令排序的过程，新创建一个文本文件，其名称为demo6.txt，内容与demo5.txt基本相同，除了第3列中有两个相同的值之外，如下所示：

[root@linux chapter9]# cat demo6.txt
Toy_Story HK 239 3972
The_Hill KL 63 2972
Star_Wars HK 301 4102
Boys_in_Company_C HK 239 2192
Aliens HK 532 4892
Alien HK 119 1982
A_Few_Good_Men KL 445 5851

【例9-21】演示只指定sort命令排序关键字的起始列的情况，代码如下：

01 #-----------------------------/chapter9/ex9-20.sh------------------
02 #! /bin/bash
03
04 if [ $1 -gt 4 ]
05 then
06 echo "column no. could not be greater than 4."
07 exit
08 fi
09
10 # 仅指定起始列
11 result='sort -r -k $1 demo6.txt'
12
13 echo "$result"

在上面的代码中，第4行判断用户输入的列号是否超过文件中的列的数量。如果超过了文本文件中列的实际列数，则给用户相关的提示。第11行使用位置变量获取用户指定的列号，并且将demo6.txt文件排序。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-20.sh 2
Alien HK 119 1982
Boys_in_Company_C HK 239 2192
Toy_Story HK 239 3972
Star_Wars HK 301 4102
Aliens HK 532 4892
A_Few_Good_Men KL 445 5851
The_Hill KL 63 2972

分析上面的排序结果，可以发现，【例9-21】首先会根据第2列来排序，当第2列相同时，再根据第3列排序，当第3列相同时，再根据第4列排序。由于第4列中，“2192”的第1个字符“2”的ASCII码值小于“3972”的第1个字符“3”的ASCII码值，所以“2192”排在“3972”前面。如图9-10所示，描述了sort命令的排序过程。

图9-10　sort命令的排序过程

除了只指定起始列之外，还有可能存在其他两种特殊的情况，即起始列的列号大于终止列的列号和起始列的列号等于终止列的列号。当用户指定的起始列的列号大于终止列的列号时，该关键字将不发挥任何作用。但是，如果起始列的列号等于终止列的列号，却是一种比较常见的情况，关于这种情况，将在接下来的内容中介绍。

注意：用户不能只提供终止列号，不提供起始列号，这是语法上面的错误。

在上面的例子中，所有的关键字都是由一个个完整的列组成的。实际上，在某些情况下，用户还需要仅仅将列中的一个子串作为排序关键字的组成部分。例如，Linux系统中的绝大部分软件的日志文件都是由空格分隔的许多列组成的文本文件，如下所示。

[root@linux chapter9]# more demo7.txt
61.152.107.126 - - [24/Feb/2013:05:28:55 +0800] "\x80}\x01\x03\x01" 302 -
61.152.107.126 - - [24/Feb/2013:05:28:55 +0800] "\x80}\x01\x03\x01" 302 -
38.229.1.15 - - [24/Feb/2013:06:40:28 +0800] "\x16\x03\x01" 302 -
180.168.208.28 - - [24/Feb/2013:10:10:16 +0800] "\x80w\x01\x03\x01" 302 -
222.186.24.118 - - [24/Feb/2013:10:45:27 +0800] "GET http://www.baidu.com/
HTTP/1.1" 200 28538
222.186.24.118 - - [24/Feb/2013:10:45:27 +0800] "GET http://www.baidu.com/
HTTP/1.1" 200 28538
222.186.24.118 - - [24/Feb/2013:13:58:38 +0800] "GET http://www.baidu.com/
HTTP/1.1" 200 28537
222.186.24.118 - - [24/Feb/2013:13:58:46 +0800] "GET http://www.baidu.com/
HTTP/1.1" 200 28537

在上面的文件中，第4列是访问日期，其中第14、15个字符为访问时间中的小时，如果用户需要根据这个小时来排序，那么将整个列作为关键字的一部分参与排序就无法实现了。

为了处理这种情况，-k选项中的起始列可以指定开始的字符，而终止列可以指定结束字符，其语法如下：

-k pos1[.start][,pos2.[end]]

在上面的语法中，pos1同样表示起始列的列号，小数点后面的start表示起始列中开始的字符位置。pos2同样表示终止列的列号，小数点后面的end表示终止列中结束的字符位置。也就是说，从pos1列的第start个字符开始，一直到第pos2列的第end个字符结束，都是排序关键字的组成部分。

在上面的语法中，列号和字符位置编号都从1开始。如果省略了start，则表示从第1个字符开始；如果省略了end，则表示到最后一个字符结束。

【例9-22】演示使用某个列的子串作为关键字的组成部分的方法，代码如下：

01 #-----------------------------/chapter9/ex9-21.sh------------------
02 #! /bin/bash
03
04 # 根据第 4 列的第 14~15 个字符排序，并输出到文件
05 sort -t ' ' -n -k 4.14,4.15 demo7.txt > sorted_log
06
07 cat sorted_log

在上面的代码中，第5行中的-t选项用来指定列分隔符为空格，关于该选项的使用方法将在后面介绍。-n选项表示将关键字作为数值来排序，该选项的用法也将随后介绍。-k 4.14, 4.15表示从第4列的第14个字符开始，到第4列的第15个字符结束，中间的子串作为排序关键字。最后，通过输出重定向操作符将排序结果输出到文件。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-21.sh
222.186.24.118 - - [25/Feb/2013:01:27:14 +0800] "GET http://www.baidu.com/
HTTP/1.1" 200 28537
222.186.24.118 - - [25/Feb/2013:01:27:23 +0800] "GET http://www.baidu.com/
HTTP/1.1" 200 28537
61.152.107.126 - - [24/Feb/2013:05:28:55 +0800] "\x80}\x01\x03\x01" 302 -
61.152.107.126 - - [24/Feb/2013:05:28:55 +0800] "\x80}\x01\x03\x01" 302 -
38.229.1.15 - - [24/Feb/2013:06:40:28 +0800] "\x16\x03\x01" 302 -
222.186.24.118 - - [25/Feb/2013:07:40:40 +0800] "GET http://www.baidu.com/
HTTP/1.1" 200 28538
222.186.24.118 - - [25/Feb/2013:07:40:52 +0800] "GET http://www.baidu.com/
HTTP/1.1" 200 28537
180.168.208.28 - - [24/Feb/2013:10:10:16 +0800] "\x80w\x01\x03\x01" 302 -
222.186.24.118 - - [24/Feb/2013:10:45:27 +0800] "GET http://www.baidu.com/
HTTP/1.1" 200 28538
222.186.24.118 - - [24/Feb/2013:10:45:27 +0800] "GET http://www.baidu.com/
HTTP/1.1" 200 28538
…

9.3.3　根据指定的列排序

一般情况下，sort命令都是使用几个列组成一个关键字来排序的。如果用户只想根据某一个列排序的话，同样也可以通过-k选项来实现。实际上这是一种特殊的情况，即起始列的列号和终止列的列号相同。因此，根据某个指定的列排序的语法如下：

-k pos, pos

其中，pos表示要排序的列的列号。同样，列号也是从1开始编号。如果只想根据某个列中的子串来排序，可以指定起始字符和终止字符，语法如下：

-k pos.[start],pos[.end]

【例9-23】演示根据用户输入的列号来对文件进行排序的方法，代码如下：

01 #-----------------------------/chapter9/ex9-22.sh------------------
02 #! /bin/bash
03
04 if [ $1 -gt 4 ]
05 then
06 echo "column no. could not be greater than 4."
07 exit
08 fi
09
10 # 根据指定的列排序
11 result='sort -k $1,$1 demo6.txt'
12
13 echo "$result"

在上面的代码中，第11行使用位置变量$1来获取用户输入的列号，并且将该列号作为起始列号和终止列号。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-22.sh 1
A_Few_Good_Men KL 445 5851
Alien HK 119 1982
Aliens HK 532 4892
Boys_in_Company_C HK 239 2192
Star_Wars HK 301 4102
The_Hill KL 63 2972
Toy_Story HK 239 3972

9.3.4　根据关键字降序排序

在前面所有的例子中，都是根据关键字的升序排列。sort命令还提供了-r选项和r修饰符，用来实现根据关键字降序排列。其中-r选项是作为全局选项使用，其作用对象为sort命令中所有没有附件修饰符的列。修饰符r可以附加在组成关键字的列号后面，其作用域为所附加的列。

【例9-24】使用-r选项对文本进行降序排序，代码如下：

01 #-----------------------------/chapter9/ex9-23.sh------------------
02 #! /bin/bash
03
04 # 使用 -r 选项降序排序
05 result='sort -r -k 2,3 demo5.txt'
06
07 echo "$result"

在上面的代码中，-r选项将作用于第2列和第3列。sort命令会首先根据第2列降序排序，然后再根据第3列降序排序。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-23.sh
The_Hill KL 63 2972
A_Few_Good_Men KL 445 5851
Aliens HK 532 4892
Star_Wars HK 301 4102
Toy_Story HK 239 3972
Boys_in_Company_C HK 192 2192
Alien HK 119 1982

从上面的例子可以得知，使用-r选项会同时作用于所有的关键字。当用户只需要根据某个关键字降序排列，而其他的关键字升序排序时，使用-r选项就很难实现，因此给用户带来了一些不方便。

与全局选项-r相比，修饰符r就显得更加灵活。它附加在关键字的后面，其作用域仅限于所附加的关键字，对其他关键字不产生影响。

【例9-25】演示如何使用修饰符实现【例9-24】的降序操作，代码如下：

01 #-----------------------------/chapter9/ex9-24.sh------------------
02 #! /bin/bash
03
04 # 使用修饰符实现降序
05 result='sort -k 2,3r demo5.txt'
06
07 echo "$result"

上面代码中的第5行的排序语句与下面两条语句是等价的：

result='sort -k 2r,3 demo5.txt'
result='sort -k 2r,3r demo5.txt'

也就是说，修饰符可以附加在起始列后面，也可以附加在终止列后面，或者附加在两者后面都可以。修饰符的位置不影响排序的效果。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-24.sh
The_Hill KL 63 2972
A_Few_Good_Men KL 445 5851
Aliens HK 532 4892
Star_Wars HK 301 4102
Toy_Story HK 239 3972
Boys_in_Company_C HK 192 2192
Alien HK 119 1982

分析上面的排序结果，可以发现与【例9-24】是完全相同的。

注意：修饰符的作用对象是整个关键字，而不是某个列。

9.3.5　数值列的排序

在【例9-20】中，我们曾经提到一个问题，即数值列的排序问题。在默认情况下，sort命令会将所有的列看做字符串，并且按照字符串的排序规则进行排序。为了使sort命令能够正确处理数值字段，用户需要使用-n选项或者修饰符n。

【例9-26】使用-n选项对demo5.txt的第3列进行排序，代码如下：

01 #-----------------------------/chapter9/ex9-25.sh------------------
02 #! /bin/bash
03
04 # 对第 3 列按数值排序
05 result='sort -n -k 3,3 demo5.txt'
06
07 echo "$result"

在上面的代码中，第5行的排序关键字为-k 3,3，表示对第3列排序，同时使用-n选项，该全局选项会对前面的关键字产生影响。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-25.sh
The_Hill KL 63 2972
Alien HK 119 1982
Boys_in_Company_C HK 192 2192
Toy_Story HK 239 3972
Star_Wars HK 301 4102
A_Few_Good_Men KL 445 5851
Aliens HK 532 4892

从上面的执行结果可以得知，通过使用-n选项之后，sort命令已经将第3列作为数值来排序，其中，数值63排到了最前面。

如果使用修饰符n，则可以将【例9-26】的第5行进行以下修改：

result='sort -k 3,3n demo5.txt'

也就是将其中的-n选项去掉，在关键字后面附加修饰符n。以上代码的执行效果与【例9-26】完全相同。

修饰符可以同时使用多个，如果想要根据第3列的数值降序排序，可以将【例9-26】第5行修改为以下代码：

result='sort -k 3,3nr demo5.txt'

在上面的代码中，同时使用了修饰符n和r，前者表示按照数值排序，后面表示根据当前关键字降序排序。

对于初学者来说，非常容易将【例9-26】中的关键字写成以下形式：

result='sort -k 3n demo5.txt'

以为上面的语句就是根据第3列的数值升序排序，而实际上这条语句是错误的。前面已经讲过，如果用户只提供了起始列的列号，那么sort将从起始列开始一直到文本行的结尾作为一个关键字来排序。因此，上面的语句中的关键字实际上包括两列，而非只有一列。

注意：如果对非数值列使用-n选项或者修饰符，会导致不可预料的结果。

9.3.6　自定义列分隔符

在默认情况下，sort命令会将连续的空格或者制表符作为列的分隔符。但是，在实践中，并不总是这样，可能会存在着其他分隔符，例如冒号、逗号或者分号。用户完全有可能使用这些符号来分隔各个列。例如，在UNIX或者Linux中，/etc/passwd文件就是以冒号分隔各个列的，如下所示。

[root@linux chapter9]# cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/spool/mail:/sbin/nologin
…

从上面的内容可以得知，/etc/passwd文件的每个行都是由7列组成，列与列之间通过冒号隔开。其中第1列是用户的登录名，第2列是密码位，第3列是用户ID，第4列是用户的主组ID，第5列是备注，第6列是用户的主目录，第7列是用户默认的Shell程序。

因此，用户在对类似于/etc/passwd文件的文本文件进行排序时，必须指定列的分隔符。sort命令提供了一个-t选项，可以使得用户自己来指定自己需要的列分隔符。

【例9-27】演示自定义列分隔符的方法，代码如下：

01 #-----------------------------/chapter9/ex9-26.sh------------------
02 #! /bin/bash
03
04 # 自定义分隔符
05 result='sort -t : -k3n,3 /etc/passwd'
06
07 echo "$result"

在上面的代码中，第5行使用-t选项指定分隔符为冒号，同时使用-k选项指定排序关键字为3n,3，表示根据第3列排序，并且将第3列作为数值处理。前面已经介绍过，/etc/passwd文件的第3列是用户ID，因此，【例9-27】的功能是根据用户ID将当前系统中的用户升序排序。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-26.sh
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
…

注意：用户可以使用-t""指定空格作为分隔符。

9.3.7　删除重复的行

在处理文本数据时，去掉重复的行通常是一件非常棘手的事情，尤其是在数据量比较大的情况下。但是，sort命令提供了一个-u选项，可以很方便地完成这个任务。

为了演示sort命令删除重复行的方法，在demo5.txt文件中插入一条重复的行，内容如下：

[root@linux chapter9]# cat demo5.txt
Toy_Story HK 239 3972
The_Hill KL 63 2972
Star_Wars HK 301 4102
Boys_in_Company_C HK 192 2192
Aliens HK 532 4892
Alien HK 119 1982
A_Few_Good_Men KL 445 5851
Boys_in_Company_C HK 192 2192

其中，第4行和第8行是重复的。在下面的命令中，分别使用不含-u选项的sort命令和含有-u选项的sort命令对demo5.txt文件进行排序，其结果分别如下：

[root@linux chapter9]# sort demo5.txt
A_Few_Good_Men KL 445 5851
Alien HK 119 1982
Aliens HK 532 4892
Boys_in_Company_C HK 192 2192
Boys_in_Company_C HK 192 2192
Star_Wars HK 301 4102
The_Hill KL 63 2972
Toy_Story HK 239 3972
[root@linux chapter9]# sort -u demo5.txt
A_Few_Good_Men KL 445 5851
Alien HK 119 1982
Aliens HK 532 4892
Boys_in_Company_C HK 192 2192
Star_Wars HK 301 4102
The_Hill KL 63 2972
Toy_Story HK 239 3972

对比两次执行的结果，可以发现，当没有使用-u选项时，sort命令不会去掉重复的行；而使用-u选项之后，对于重复的行，sort命令只保留第1条。

9.3.8　根据多个关键字排序

前面介绍的例子都是根据一个关键字排序，实际上，sort命令的功能远远不止如此。用户可以在sort命令中同时指定多个关键字，sort命令会依次根据各个关键字来排序。也就是说，如果同时指定了3个关键字，则sort命令会首先根据第1个关键字排序，如果遇到第1个关键字相同的情况，则会根据第2个关键字排序。同理，如果前面两个关键字都相同，再根据第3个关键字排序，依此类推。如果用户指定的关键字的值全部相同，sort命令会对整个文本行根据当前系统语言环境的排序规则进行排序。

使用多个关键字排序的语法如下：

sort [option] -k pos1,pos2 -k pos3,pos4,... [file…]

也就是说，用户可以同时使用多个-k选项来定义多个排序关键字，这些关键字会依次作用于要排序的文本。

【例9-28】演示根据多个关键字排序的方法，代码如下：

01 #-----------------------------/chapter9/ex9-27.sh------------------
02 #! /bin/bash
03
04 # 根据第 3 列的数值降序，第 4 列的数值升序排序
05 result='sort -k 3,3nr -k 4,4n demo6.txt'
06
07 echo "$result"

在上面的代码中，第5行定义的两个排序关键字分别为-k 3,3nr和-k 4,4n，前者表示根据第3列的数值降序排序，后者表示根据第4列的数值升序排序。sort命令在处理文本时，会首先使用第1个关键字，在第1个关键字相同的情况下，再使用第2个关键字。

该程序的执行结果如下：

[root@linux chapter9]# cat demo6.txt
Toy_Story HK 239 3972
The_Hill KL 63 2972
Star_Wars HK 301 4102
Boys_in_Company_C HK 239 2192
Aliens HK 532 4892
Alien HK 119 1982
A_Few_Good_Men KL 445 5851
[root@linux chapter9]# ./ex9-27.sh
Aliens HK 532 4892
A_Few_Good_Men KL 445 5851
Star_Wars HK 301 4102
Boys_in_Company_C HK 239 2192
Toy_Story HK 239 3972
Alien HK 119 1982
The_Hill KL 63 2972

在上面的【例9-28】中所定义的两个关键字都使用了修饰符，这些修饰符仅仅作用于所附加的关键字本身，并不影响其他的关键字。

在使用多个关键字的时候，用户可以根据自己的情况来选择使用全局选项或者修饰符。全局选项的作用对象是没有附加修饰符的关键字。

为了能够使读者更加深入理解全局选项、修饰符与多个关键字之间的关系，下面给出了一些多个关键字的排序语句：

sort -t : -k 2,2n -k 5.3,5.4
sort -t : -k 5b,5 -k 3,3n /etc/passwd
sort -t : -n -k 5b,5 -k 3,3 /etc/passwd
sort -t : -b -k 5,5 -k 3,3n /etc/passwd
sort -s -t '.' -k 1,1n -k 2,2n -k 3,3n -k 4,4n

第1条语句只使用了修饰符，其作用是将冒号作为列分隔符，将第2列作为数值升序排序，然后再根据第5列的第3、4个字符升序排序。

第2～4条语句是等价的。其中第2条语句是去除第5列的前导空格，并且首先根据第5列排序，然后再按照数值对第3列升序排序。第3条语句使用了全局选项-n，由于第1个关键字已经有一个修饰符b，所以sort命令会认为第1个关键字不再需要修饰符，只将-n选项作用于第2个关键字-k 3,3。因此第2条语句与第1条是等价的。第4条语句使用了全局选项-b，根据同样的原因，该选项仅仅作用于第1个关键字。

第5条语句使用了-s和-t这两个全局选项，前者表示使sort命令使用稳定的排序算法，而后者指定列分隔符为小数点。接下来定义了4个关键字，分别按照数值根据第1～4列排序。

注意：所谓排序的稳定性是指在待排序的记录序列中，存在多个具有相同的关键字的记录，若经过排序，这些记录的相对次序保持不变。

9.3.9　使用sort命令合并文件

使用sort命令可以很方便地合并多个文件，同时将文本文件的内容进行排序。当使用sort命令合并文件时，其基本语法如下：

sort file1 file2 …

其中file1、file2等表示要合并的文件的文件名，这些文件名之间用空格隔开。sort命令会根据指定的顺序依次将各个文件的内容合并。

【例9-29】演示了使用默认选项合并两个文件，代码如下：

01 #-----------------------------/chapter9/ex9-28.sh------------------
02 #! /bin/bash
03
04 # 合并文件并输出磁盘文件
05 result='sort demo5.txt demo6.txt > result.txt'
06
07 cat result.txt

在上面的代码中，第5行将demo5.txt和demo6.txt这个两个文件有序地合并在一起，并且通过重定向输出到文件中。关于这两个文件的内容，请参见前面的例子。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-28.sh
A_Few_Good_Men KL 445 5851
A_Few_Good_Men KL 445 5851
Alien HK 119 1982
Alien HK 119 1982
Aliens HK 532 4892
Aliens HK 532 4892
Boys_in_Company_C HK 192 2192
Boys_in_Company_C HK 239 2192
Star_Wars HK 301 4102
Star_Wars HK 301 4102
The_Hill KL 63 2972
The_Hill KL 63 2972
Toy_Story HK 239 3972
Toy_Story HK 239 3972

从上面的执行结果可以得知，合并后的文件是有序的，文件的内容是按照sort命令默认的排序规则排序的。另外，这两个文件有许多重复的行，如果想要去掉重复的行，可以使用sort命令的-u选项，将代码中的第4行进行以下修改：

result='sort -u demo5.txt demo6.txt > result.txt'

修改后的【例9-29】的执行结果如下：

[root@linux chapter9]# ./ex9-28.sh
A_Few_Good_Men KL 445 5851
Alien HK 119 1982
Aliens HK 532 4892
Boys_in_Company_C HK 192 2192
Boys_in_Company_C HK 239 2192
Star_Wars HK 301 4102
The_Hill KL 63 2972
Toy_Story HK 239 3972

如果用户只想将两个文件合并，不想要执行排序处理，可以使用sort命令的-m选项。将【例9-29】中的第4行改成以下代码：

result='sort -m demo5.txt demo6.txt > result.txt'

修改后的程序的执行结果如下：

[root@linux chapter9]# ./ex9-28.sh
Toy_Story HK 239 3972
The_Hill KL 63 2972
Star_Wars HK 301 4102
Boys_in_Company_C HK 192 2192
Aliens HK 532 4892
Alien HK 119 1982
A_Few_Good_Men KL 445 5851
Toy_Story HK 239 3972
The_Hill KL 63 2972
Star_Wars HK 301 4102
Boys_in_Company_C HK 239 2192
Aliens HK 532 4892
Alien HK 119 1982
A_Few_Good_Men KL 445 5851

9.4　文本的统计

在Shell编程中，文本的统计也非常重要。为了统计文本，Shell提供了许多有用的工具来完成文本的统计，例如wc、cat及grep等。本节将介绍如何使用这些工具实现文本的统计。

9.4.1　输出含有行号的文本行

对于程序设计者来说，输出含有行号的源代码非常有用。通过行号，用户可以快速地定位出现错误的行。因此，行号的显示和输出已经成为编辑器的必备功能。如图9-11所示的是Eclipse的编辑界面，从图中可以看到在每一行代码的前面都有一个行号对应。

图9-11　Eclipse的编辑界面

在Shell中，有许多命令都可以输出行号，其中包括cat、grep及wc等。下面分别介绍如何使用这些命令输出行号。

cat命令提供了一个-n选项，通过该选项，cat命令会在每一个文本行的前面添加一个行号，如下所示。

[root@linux chapter9]# cat -n ex9-1.sh
1 #! /bin/bash
2
3 echo -n "What is your first name? "
4 read first
5 echo -n "What is your last name? "
6 read last
7 echo -n "What is your middle name? "
8 read middle
9 echo -n "What is your birthday? "
10 read birthday

从上面的输出结果可以得知，文件ex9-1.sh一共有10行。

与cat命令一样，grep命令也有一个名称为-n的选项，该选项使grep命令在输出结果的时候会显示符合指定筛选条件的文本行的行号。为了能够输出所有的文本行，用户可以使用空串作为grep命令的参数，如下所示。

[root@linux chapter9]# grep -n "" ex9-1.sh
1:#! /bin/bash
2:
3:echo -n "What is your first name? "
4:read first
5:echo -n "What is your last name? "
6:read last
7:echo -n "What is your middle name? "
8:read middle
9:echo -n "What is your birthday? "
10:read birthday

在上面的输出结果中，每一行的前面都有行号，行号与文本行之间用冒号隔开。

除了前面介绍的几个命令之外，Shell还提供了一个名称为nl的命令，用来为文本添加行号，其基本语法如下：

nl [option]... [file]...

nl命令的常用选项如下所列。

- -b：显示风格，可以取a、t及n等值，a表示为所有行添加行号，t表示仅仅为非空行添加行号，n表示不添加行号。

- -i：行号的增量，默认值为1。

- -v：行号的起始值，默认值为1。

参数file表示要添加行号的文件的列表。

【例9-30】演示使用nl命令为文本添加行号的方法，代码如下：

01 #-----------------------------/chapter9/ex9-29.sh------------------
02 #! /bin/bash
03
04 # 为文本添加行号，并输出到文件
05 nl -b a ex9-1.sh > textwithlineno.txt
06
07 cat textwithlineno.txt

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-29.sh
1 #! /bin/bash
2
3 echo -n "What is your first name? "
4 read first
5 echo -n "What is your last name? "
6 read last
7 echo -n "What is your middle name? "
8 read middle
9 echo -n "What is your birthday? "
10 read birthday

注意：如果不使用-b a选项，则nl命令只编号非空行。

9.4.2　统计行数

在Linux或者UNIX中，许多用户数据都是存储在文本文件中的。例如系统用户的信息存储在/etc/passwd及/etc/shadow等文件中。通常情况下，这些文件的每一行都是一条完整的记录，用来描述一件事物。因此，用户只要统计文件中文本的行数就可以知道文件中的记录数。

在Shell程序中，用户可以使用grep及wc等命令来统计文本行的数量。下面分别介绍这两种统计文本行数的方法。

grep命令提供了一个名称为-c的选项，用来统计符合筛选条件的文本行的行数，下面的例子就演示了这个选项的使用方法。

【例9-31】演示使用grep命令统计记录数，代码如下：

01 #-----------------------------/chapter9/ex9-30.sh------------------
02 #! /bin/bash
03
04 echo -n "Please input a name:"
05 # 读取用户输入数据
06 read name
07
08 while [ $name != "e" ]
09 do
10 # 统计含有用户输入数据的行数
11 quantity='grep -c "$name" demo5.txt'
12 echo "$quantity records contains $name."
13 echo -n "Please input a name:"
14 read name
15 done

上面的程序类似于关系型数据库的查询操作。第6行通过read语句接收用户输入的数据，第11行通过grep命令查询含有用户输入数据的记录数，第12行输出统计结果。当用户输入字符e之后，程序终止运行。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-30.sh
Please input a name:a
2 records contains a.
Please input a name:A
3 records contains A.
Please input a name:C
1 records contains C.
Please input a name:e
[root@linux chapter9]#

wc命令是Shell中用来对文本进行各种统计的命令，其基本语法如下：

wc [option]... [file]...

其中，wc命令常用的选项如下所列。

- -c：统计文本的字节数。

- -m：统计字符数。

- -l：统计行数。

- -L：统计最长行的长度。

- -w：统计单词数。

与其他命令一样，wc命令可以接受多个文件名作为参数，这些文件名之间用空格隔开。

如果想要统计某个文件的行数，可以使用以下命令：

[root@linux chapter9]# wc -l ex9-1.sh
10 ex9-1.sh

上面的命令使用-l选项统计ex9-1.sh文件的行数，得到的结果为10行。同时，用户可以发现，如果直接使用wc -l命令统计文本的行数，会在输出行数的同时还包括被统计的文件的文件名。而在Shell程序，用户通常需要的仅仅是行数，这为用户带来了许多不便。下面为读者介绍一个小技巧，那就是去掉wc-l命令中的文件名，只得到行数。

【例9-32】演示通过-l选项来统计文本文件的行数，代码如下：

01 #-----------------------------/chapter9/ex9-31.sh------------------
02 #! /bin/bash
03
04 # 统计文本行数
05 lines='cat ex9-1.sh | wc -l'
06
07 echo "the file has $lines lines."

在上面代码的第5行，首先使用cat命令显示文件的内容，然后通过管道将结果输出给wc–l命令，并且将得到结果赋给变量lines。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-31.sh
the file has 10 lines.

除了直接统计文本的行数之外，wc命令还有许多其他的用法，在下面的例子中，我们通过find命令和wc命令，来统计查找到的文件数。

【例9-33】使用find结合wc命令来统计/etc目录下面以.conf为扩展名的文件的数量，代码如下：

01 #-----------------------------/chapter9/ex9-32.sh------------------
02 #! /bin/bash
03
04 # 统计 /etc 目录下面有多少个以 conf 为扩展名的文件
05 count='find /etc -name "*.conf" | wc -l'
06
07 echo "$count files have been found."

在上面的代码中，第5行首先使用find命令查找/etc目录下面以.conf为扩展名的文件列表。我们知道，在find命令的输出结果中，每一行都是一个文件名。然后通过管道将这个列表输出到wc命令中，通常wc命令统计出搜索结果列表的行数。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-32.sh
411 files have been found.

以上结果表明，在/etc目录下面有411个文件以.conf为扩展名。

注意：通过cat等命令、管道，以及wc命令来获得文本行数是一个非常有用的技巧。

9.4.3　统计单词数和字符数

在许多语言中，例如英语，单词之间都是通过空格来隔开的。在wc命令中，也是通过空格来区分单词的。用户可以通过-w选项来统计文本中的单词的数量。另外，通过-m选项可以统计文本的字符数。

【例9-34】使用cat命令配合wc命令来取得文件中的单词的数量和字符数量，代码如下：

01 #-----------------------------/chapter9/ex9-33.sh------------------
02 #! /bin/bash
03
04 # 统计单词数，只取得数量
05 words='cat demo1.txt | wc -w'
06
07 echo "there are $words words in file demo1.txt"
08 # 统计字符数
09 chars='cat demo1.txt | wc -m'
10
11 echo "there are $chars characters in file demo1.txt"

在上面的代码中，第5行使用cat命令显示文件内容，然后再使用wc –w命令统计单词数，这两者之间用管道连接起来。这样操作的目的同样也是为了取消wc命令输出结果中的文件名。第9行使用同样的方法获得文本的字符数。

该程序的输出结果如下：

[root@linux chapter9]# ./ex9-33.sh
there are 89 words in file demo1.txt
there are 528 characters in file demo1.txt

注意：wc命令的执行结果会随着系统的区域设置不同而不同。

9.5　使用cut命令选取文本列

对于关系型数据库而言，用户可以在水平方向上选择行，也可以在垂直方向上选择列。与此相对应，针对文本文件，Shell也提供了相应的操作方法。前面介绍的排序和筛选都是以行为单位进行的，操作的结果也都是行。而cut命令则是从垂直方向上对文本进行操作。本节将介绍如何使用cut命令操作文本列。

9.5.1　cut命令及其语法

在进行文本处理的时候，经常会遇到选择某个文件的某些列的情况。例如，用户使用文本文件存储了一个电话本，包括姓名、性别、电话号码，以及家庭住址等信息。其中的每项信息都是作为一个列存在，所有的列组成一条完整的记录。现在用户需要从这个文件中选择姓名和电话号码这两个列，把其余的列排除掉。在这种情况下，就需要对文本文件从垂直方向上选取部分列。Shell中的cut命令就被设计成这样的功能。通过cut命令，用户可以从文本文件或者文本流中选择某些文本列。如图9-12所示，描述了cut命令的工作原理。

图9-12　cut命令的工作原理

cut命令的基本语法如下：

cut option... [file]...

在上面的语法中，option表示选项，cut命令常用的选项如下所列。

- -b：只选择指定的字节。

- -c：只选择指定的字符。

- -d：自定义列分隔符，默认值为制表符。

- -f：只选择列表中指定的文本列，文本列用列号表示，多个列之间用逗号隔开。

- -n：取消分隔多字节字符。

- -s：不输出不包含列分隔符的行。

file参数表示要处理的文件列表，多个文件名之间用空格分隔。

9.5.2　选择指定的文本列

选取指定的文本列需要使用cut命令的-f选项，该选项接收一个文本列的列表，cut命令会从文本文件中选择列表中列出的列。

前面已经介绍过，在Linux系统中，许多文件都有固定格式的文本文件，例如/etc/passwd、/etc/shadow、/etc/group，以及各种日志文件等。这些文件有的以空格分隔各个列，有的以空格分隔各个列，还有的以冒号分隔各个列。

下面就以/etc/passwd文件为例，说明cut命令的使用方法。某个系统中passwd文件的内容如下：

[root@linux chapter9]# cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/spool/mail:/sbin/nologin
…

在9.3.6节中，已经介绍过passwd文件的各个列的涵义。如果读者对该文件的格式不太清楚的话，可以参考9.3.6节中的内容。

【例9-35】演示如何通过cut命令从passwd文件中选择某些列，代码如下：

01 #-----------------------------/chapter9/ex9-34.sh------------------
02 #! /bin/bash
03
04 # 自定义分隔符为冒号，选择第 1 列和第 6 列
05 result='cut -d ":" -f 1,6 passwd'
06
07 echo "$result"

在上面的代码中，第5行使用-d选项指定列的分隔符为冒号，然后使用-f选项指定要选择的列为第1列和第6列。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-34.sh
root:/root
bin:/bin
daemon:/sbin
adm:/var/adm
…

在passwd文件中，第1列为用户的登录名，第6列为用户的主目录。因此，【例9-35】的功能是从passwd文件中选择当前系统中用户的登录名和主目录这两列数据。在上面的输出结果中，一共只输出了两列，但是这两列仍然以原来的分隔符分隔各个列。因此，cut命令的功能是从文本文件中抽取指定的列，但是并不影响文件的其他属性。

在-f选项中指定要选择的列的列表的时候，除了将所有需要的列罗列出来，并且用逗号隔开之外，还可以使用连字符表示一段连续的列号。例如，要选择第1～3列，可以将【例9-35】中的第5行修改为以下语句：

result='cut -d ":" -f 1-3 passwd'

如果要选择从第3列开始，一直到文本行的结尾，则可以使用以下语句：

result='cut -d ":" -f 3- passwd'

也就是说，省略连字符后面的终止列的列号。除此之外，还可以将逗号与连字符混合使用，如下所示。

result='cut -d ":" -f 1,2,4-5 passwd'

上面的语句表示选择第1列，第2列和第4、5列。

9.5.3　选择指定数量的字符

除了从文本中选择列之外，用户还可以通过cut命令从每一行中选择指定数量的字符。在选择字符时，cut命令的语法如下：

cut -c list

其中，-c选项表示选择字符（character）。list参数表示要选择的范围，可以是以下语法形式：

1-4,6
3,5,8
-4,8
3-

在上面的语法中，第1行表示选择每行的第1～4个字符和第6个字符，第2行表示选择第3、5和8列，第3行表示选择第1～4列和第8列，第4行表示选择从第3列开始一直到行尾。

【例9-36】演示通过cut命令从文本文件中选择指定列的方法，代码如下：

01 #-----------------------------/chapter9/ex9-35.sh------------------
02 #! /bin/bash
03
04 # 选择指定的列
05 result='cut -c1-3,5 passwd'
06
07 echo "$result"

在上面的代码中，第5行的语句表示选择每个文本行的第1～3列和第5列。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-35.sh
roo:
binx
daeo
admx
lp::
syn:
shud
…

注意：由于选择字符是将整个文本行看做一个字符串进行的，所以不需要也不能指定列分隔符。

9.5.4　排除不包含列分隔符的行

在某些情况下，用户的数据文件可能会存在错误的数据行。这些错误的数据行在格式上与正确的数据行有所区别，例如不包含正确的列分隔符。cut命令的-s选项可以帮助用户排除掉这些不包含正确列分隔符的行。

为了演示排除不包含列分隔符的行的方法，我们在passwd文件的末尾追加一条记录，如下所示。

[root@linux chapter9]# cat passwd
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
…
user

在上面的最后一行只包含一个单词user。

【例9-37】分别使用不含-s选项的cut命令和含有-s选项的cut命令来提取passwd文件的第1列，代码如下：

01 #-----------------------------/chapter9/ex9-36.sh------------------
02 #! /bin/bash
03
04 # 提取所有行的第 1 列
05 cut -d ":" -f 1 passwd > allusers.txt
06 echo "all users:"
07 # 显示所有的行
08 cat allusers.txt
09 # 只提取正确的行
10 cut -s -d ":" -f 1 passwd > validusers.txt
11 echo "valid users:"
12 # 显示所有正确的行
13 cat validusers.txt

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-36.sh
all users:
root
bin
…
user
valid users:
root
bin
…
openvpn_as

从上面的执行结果可以得知，当不使用-s选项时，cut命令会输出所有的文本行；在使用-s选项之后，cut命令只输出了被正确的列分隔符分隔的行。

注意：只要文本行中含有一个列分隔符，都会被含有-s选项的cut命令输出。

9.6　使用paste命令拼接文本列

在上一节中，我们介绍了cut命令，这个命令非常强大，可以从文本中选择某些列。有了这个命令之后，用户可能会反过来考虑，如果能够将多个文件的列合并起来，那岂不更好？Linux中的paste命令就是实现这个功能的，本节将介绍如何使用paste命令来处理文本文件。

9.6.1　paste命令及其语法

前面介绍的cut命令可以将文本文件纵向分隔，使得原来文件的某些列重新生成一个新的文件。paste命令的功能恰好与cut命令相反。该命令的主要功能是将某些文件的文本行并行地连接在一起，形成一个新的文件。新文件的列数等于参与组合的所有文件的列的总和，新文件的行数与参与组合的文件的行数相等。paste命令的工作原理如图9-13所示。

图9-13 paste命令的工作原理

在图9-13中，文件1包含3列，文件2包含4列，这两个文件都含有14行。合并以后的文件包含7列，其中前3列来自文件1，后4列来自文件2。合并后的文件同样含有14行。

paste命令的基本语法如下：

paste [option]... [file]..

其中，paste命令常用的选项如下所列。

- -d：指定拼接结果中列分隔符。默认情况下paste命令生成的文件使用制表符分隔列。

- -s：将多个文件串行地拼接在一起，即将后面文件的内容追加到前面一个文件的后面。

其中，-s选项表示将各个文件依次连接在一起。也就是说，将后面一个文件的文本行追加在前面一个文件的内容的后面。因此，如果使用-s选项，则生成的文件的列数与参与组合的文件的列数相同，但是行数等于参与组合的所有文件的行数之和。

file参数表示参与拼接的文件列表，文件名之间用空格隔开。如果省略文件名，则表示从标准输入接收文本数据。

注意：从原理上看，paste命令非常类似于关系型数据库中的联接的概念，但是paste命令仅仅是简单地拼接列，并不涉及关键字的比较。而下一节将介绍的join命令基本等同于关系型数据库中的联接。

为了学习paste命令的使用方法，用户需要首先准备两个文件，其文件名分别为students.txt和phones.txt。其中，前者存储学生的学号和姓名，后者存储学生的学号和电话号码，如下：

[root@linux chapter9]# cat students.txt
200200110 Abdul
200200164 Abram
200200167 Bartley
200200168 Bennett
200200172 Cecil
200200173 John
200200187 Cat
[root@linux chapter9]# cat phones.txt
200200110 13611499594
200200164 13682239867
200200167 13710153203
200200168 13622259071
200200172 13430324699
200200179 13640656767

【例9-38】通过paste命令和重定向将students.txt和phones.txt这两个文件拼接起来，代码如下：

01 #-----------------------------/chapter9/ex9-37.sh------------------
02 #! /bin/bash
03
04 # 拼接 2 个文件，并输出到磁盘文件
05 paste students.txt phones.txt > contactinfo.txt
06
07 # 显示拼接结果
08 cat contactinfo.txt

在上面的代码中，第5行使用paste命令将students.txt和phones.txt这两个文件依次拼接在一起，并且使用制表符作为列分隔符，最后将拼接结果输出到名称为students.txt的文件中。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-37.sh
200200110 Abdul 200200110 13611499594
200200164 Abram 200200164 13682239867
200200167 Bartley 200200167 13710153203
200200168 Bennett 200200168 13622259071
200200172 Cecil 200200172 13430324699
200200173 John 200200179 13640656767
200200187 Cat

从上面的执行结果可以得知，以上两个文件的每一行都并行地拼接在一起，其中第1个文件的所有行成为新文件的第1、2列，第2个文件的所有行成为新文件的第3、4列。另外，paste命令是将两个文件的行按照它们在各自文件中出现的顺序进行拼接。由于students.txt文件的行数多于phones.txt文件的行数，所以在最后一行中，只出现了students.txt文件中的行所产生的前两列，而后面的两列为空白。

注意：paste命令并不要求参与拼接的文件中的文本行是有序的。另外，paste命令只是简单地进行文本行的拼接，并不进行任何关键字的比较。例如，在上面的拼接结果中，尽管students.txt文件中的第6行的学号为200200173，phones.txt文件的第6行的学号为200200179，但是paste命令仍然只是机械地将这两行拼接在一起。

9.6.2　自定义列分隔符

在默认情况下，paste命令会使用制表符作为新生成的文件的列分隔符。但是，用户可以使用-d选项来自定义所需要的分隔符，例如逗号或者分号等。

【例9-39】使用逗号作为paste命令拼接结果的列分隔符，代码如下：

01 #-----------------------------/chapter9/ex9-38.sh------------------
02 #! /bin/bash
03
04 # 自定义列分隔符
05 paste -d "," students.txt phones.txt > contactinfo.txt
06
07 cat contactinfo.txt

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-38.sh
200200110 Abdul,200200110 13611499594
200200164 Abram,200200164 13682239867
200200167 Bartley,200200167 13710153203
200200168 Bennett,200200168 13622259071
200200172 Cecil,200200172 13430324699
200200173 John,200200179 13640656767
200200187 Cat,

9.6.3　拼接指定的文本列
paste命令并不直接支持只选择文件中的某些列来拼接。但是，用户可以通过间接的途径来使最后生成的文件中仅仅含有指定的列。在这里介绍两种方法，第一种就是先使用cut命令将需要的列从各个文件中提取出来，然后再使用paste命令进行拼接；第二种的顺序恰好相反，先使用paste命令进行拼接各个文件，然后再使用cut命令从生成的文件中提取所需要的列。由于第一种方法的执行效率相对较高，所以建议使用第一种方法来拼接指定的文本列。

【例9-40】通过cut命令和重定向将students.txt的第1列和phones.txt的第2列拼接起来，并且去掉其他列，代码如下：

01 #-----------------------------/chapter9/ex9-39.sh------------------
02 #! /bin/bash
03
04 # 选择 students.txt 文件的第 1 列
05 cut -f1 students.txt > students.tmp
06
07 # 选择 phones.txt 文件的第 2 列
08 cut -f2 phones.txt > phones.tmp
09
10 # 将生成的 2 个临时文件拼接
11 paste students.tmp phones.tmp > contactinfo.txt
12
13 # 输出拼接结果
14 cat contactinfo.txt

在上面的代码中，第5行和第8行分别提取students.txt文件的第1列和phones.txt文件的第2列，各自生成一个临时文件。第11行将生成的两个临时文件再进行拼接。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-39.sh
200200110 13611499594
200200164 13682239867
200200167 13710153203
200200168 13622259071
200200172 13430324699
200200173 13640656767
200200187

【例9-41】先通过paste命令连接2个文件，然后再通过cut命令提取指定数据列，代码如下：

01 #-----------------------------/chapter9/ex9-40.sh------------------
02 #! /bin/bash
03
04 # 先将 2 个文件拼接
05 paste students.txt phones.txt > contactinfo.tmp
06 # 再从拼接结果中选择第 1 和 3 列
07 cut -f1,3 contactinfo.tmp > contactinfo.txt
08 # 输出拼接结果
09 cat contactinfo.txt

【例9-41】的执行结果与【例9-40】完全相同，请读者自行验证。

注意：由于paste命令只是简单地拼接文本行，所以如果用户需要将两个文件中某个关键字相同的行进行拼接的话，是不能完成的。在下一节中，我们将介绍如何使用join命令解决这个问题。

9.7　使用join命令联接文本列

在9.6节中我们介绍了paste命令，使用该命令，可以将多个文件的文本行进行拼接。同时，也提出了一个问题，那就是paste命令只能根据文本行的顺序进行拼接，而不能基于某个关键字进行联接。在Shell中，用户可以使用join命令来解决这个问题。本节将介绍join命令的使用方法。

9.7.1　join命令及其语法
join命令是一个非常有趣的Shell命令。对于熟悉关系型数据库的用户来说，一定会了解其中的数据表的联接的概念。所谓联接，就是根据用户指定的条件，将多个数据表的中的数据行联接起来，形成一个新的表，这个表的列由参与联接的多个表的列构成。

在Linux中，许多数据都是通过文本文件来存储的，而不是数据表的形式。与数据表的形式相似，这些文本文件也是由许多列构成，列与列之间由列分隔符隔开，常用的列分隔符有制表符、空格、逗号，以及分号等。

join命令就是用来联接这些文本文件中的数据行的，它是根据参与联接的两个文本文件的公共列来联接数据行。其基本语法如下：

join [option]... file1 file2

在上面的语法中，option表示join命令的相关选项，常用的选项如下所列。

- -1 field：根据第1个文件的指定列进行联接。其中参数field用来指定第1个文件中用来联接的关键字列。

- -2：field：根据第2个文件的指定列进行联接。其中参数field用来指定第2个文件中用来联接的关键字列。

- -a filenum：指定是否输出不匹配的行。其中参数filenum可以取值为1或者2，分别代表参与联接的第1个文件和第2个文件。

- -e string：使用参数string指定的字符串代替空列。

- -i：在比较关键字时忽略大小写。

- -o：自定义输出列。

- -t：自定义列分隔符。

- -v filenum：该选项的功能是输出filenum指定文件的所有的行。

在上面的选项中，其中-a选项用来指定是否需要输出参与联接的两个文件中的不匹配的文本行，该选项可以取1或者2，当取值为1时，表示输出第1个文件中的所有的文本行，包括关键字不匹配的行；当取值为2时，表示输出第2个文件中的所有的文本行。该选项可以重复指定，例如想要同时输出两个文件中的所有的的行，可以指定-a1-a 2。

file1和file2代表参与联接的两个文本文件，与前面介绍的其他命令不同的是，join命令只能联接两个文件。如果需要联接两个以上的文件，则需要多次执行join命令。

最简单的join命令的使用方法就是不带任何选项，下面首先从最简单的join命令开始介绍。

【例9-42】演示最简单的join命令的使用方法，代码如下：

01 #-----------------------------/chapter9/ex9-41.sh------------------
02 #! /bin/bash
03
04 # 使用默认选项联接 2 个文件
05 result='join students.txt phones.txt > contactinfo.txt'
06
07 cat contactinfo.txt

在上面的代码中，第5行使用join命令将students.txt和phones.txt文件联接起来并且输出到磁盘文件contactinfo.txt中。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-41.sh
200200110 Abdul 13611499594
200200164 Abram 13682239867
200200167 Bartley 13710153203
200200168 Bennett 13622259071
200200172 Cecil 13430324699

用户可以对比【例9-38】和【例9-42】的输出结果。首先，paste命令输出两个文件中的所有的列，而默认情况下，join命令对于两个文件中共同的列只输出1次。其次，paste命令只是根据文本行在文件中出现的顺序进行拼接，并不考虑关键字列，因此，students.txt文件中的第6行和phones.txt文件中的第6行会拼接在一起。而join命令则会比较关键字列，只将关键字相同的行进行联接，因此，【例9-42】只输出了5行。除了上面介绍的两点不同之外，paste命令和join命令还有许多不同之处，将在后面的内容中介绍。

注意：在students.txt和phones.txt文件中，列分隔符为制表符，用户可以使用-t选项来指定输入文件，以及输出文件的列分隔符。

9.7.2　指定联接关键字列

在默认情况下，join命令会将两个文件的第1列作为关键字列进行比较。因此，在【例9-40】中，尽管用户并没有明确指定关键字列，但是join命令仍然将students.txt和phones.txt这两个文件的第1列作为关键字列进行比较。

但是，在实际情况中，作为关键字的列并不总是第1列。用户还可以使用-1或者-2选项分别用做关键字的列。其中，-1代表从第1个文件中指定关键字列，-2代表从第2个文件中指定关键字列。这两个选项都使用列号作为参数值。

新创建一个名称为scores.txt的文件文件，其内容如下：

[root@linux chapter9]# cat scores.txt
1 200200110 78
1 200200164 78
2 200200167 92
3 200200168 87
4 200200172 65

其中，第1列为记录号，第2列为学号，第3列为分数。由于在scores.txt文件中，学号并不位于第1列，所以不能使用默认的join命令来联接students.txt和scores.txt这两个文件。下面介绍如何通过选项指定作为联接关键字的数据列。

【例9-43】使用-1和-2选项来指定关键字列，代码如下：

01 #-----------------------------/chapter9/ex9-42.sh------------------
02 #! /bin/bash
03
04 # 指定 students.txt 的第 1 列和 scores.txt 文件的第 2 列作为列关键字
05 result='join -1 1 -2 2 students.txt scores.txt > studentsscores.txt'
06
07 cat studentsscores.txt

其中第5行通过-1选项指定文件students.txt的关键字列为第1列，通过-2指定文件phones.txt的关键字为第2列，然后将联接结果输出到studentsscores.txt文件中。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-42.sh
200200110 Abdul 1 78
200200164 Abram 1 78
200200167 Bartley 2 92
200200168 Bennett 3 87
200200172 Cecil 4 65

在上面的输出结果中，前面两列来自students.txt文件，后面两列来自于scores.txt文件。join命令将students.txt文件的第1列与scores.txt文件的第2列相同的行联接在一起。

注意：在默认情况下，join命令在比较关键字时会区分大小写，但是用户可以通过指定-i选项来忽略大小写的区分。

9.7.3　内联接文本文件

所谓内联接文本文件，实际上就是使用默认选项的join命令对两个文本文件的联接操作。在默认情况下，join命令只输出关键字匹配的文本行，而忽略关键字不匹配的文本行。如图9-14所示，描述了内联接的原理，在图9-14中，左边的圆代表第1个文件的行，右边的圆代表第2个文件的行，中间的阴影代表内联接的结果，表示只有关键字匹配的文本行才会出现在结果中。

例如，在students.txt文件中存在着200200173和200200187这两个关键字，在phones.txt文件中存在着200200179关键字，这3个关键字没有匹配成功，所以这3行全部都被忽略。

关于内联接文本文件的例子请参照【例9-40】，不再举例说明。

注意：参与联接的两个文件必须按照关键字排序，否则会出现错误。但是用户可以通过指定--nocheck-order选项来使join命令不检查文件是否按照关键字排序。

图9-14　内联接文本文件

图9-15　左联接文本文件

9.7.4　左联接文本文件

所谓左联接，是指在联接结果中输出左边文件的所有行，即使在右边的文件中没有匹配的行。在左联接中，对于在右边的文件中没有相应关键字的行，由右边文件所形成的列使用空白符填充。如图9-15所示，描述了左联接文本文件的原理，在图9-15中，左右两个圆分别代表参与联接的两个文件，阴影部分代表左联接的结果，从图中可以看出，左边文件的所有行都包含在输出结果中。

在join命令中，要实现左联接，可以使用以下语法：

join -a 1 filenum file1 file2

其中，选项-a1表示显示第1个文件的所有的行，无论是否匹配成功。

【例9-44】演示通过join命令左联接文件的方法，代码如下：

01 #-----------------------------/chapter9/ex9-43.sh------------------
02 #! /bin/bash
03
04 # 左联接文件
05 result='join -a 1 students.txt phones.txt > contactinfo.txt'
06
07 cat contactinfo.txt

在上面的代码中，第5行的join命令使用了-a 1选项，表示列出student.txt文件的所有的行。

程序的执行结果如下：

[root@linux chapter9]# ./ex9-43.sh
200200110 Abdul 13611499594
200200164 Abram 13682239867
200200167 Bartley 13710153203
200200168 Bennett 13622259071
200200172 Cecil 13430324699
200200173 John
200200187 Cat

从上面的执行结果可以得知，students.txt文件中的所有的7行全部被输出，其中第6、7行在phones.txt文件中没有相应的学号，所以这2行的第3列为空白。

9.7.5　右联接文本文件

右联接的原理与左联接恰好相反，它是将右边文件的所有的行全部显示出来，而在匹配不成功的行中，由左边文件形成的列使用空白填充。如图9-16所示，描述了右联接的原理，图中的阴影部分代表右联接的结果。通过图9-16可以看出，右联接的结果中包含了文件2的所有行，而对于文件1来说，仅仅包含了匹配成功的行。

图9-16　右联接文本文件

右联接文本文件的语法如下：

join -a 2 filenum file1 file2

其中，-a2表示输出第2个文件的所有的行。

【例9-45】演示通过join命令右联接文本文件的方法，代码如下：

01 #-----------------------------/chapter9/ex9-44.sh------------------
02 #! /bin/bash
03
04 # 右联接文件
05 result='join -a 2 students.txt phones.txt > contactinfo.txt'
06
07 cat contactinfo.txt

在上面的代码中，第5行的join命令使用了-a 2选项，表示输出第2个文件的所有的行。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-44.sh
200200110 Abdul 13611499594
200200164 Abram 13682239867
200200167 Bartley 13710153203
200200168 Bennett 13622259071
200200172 Cecil 13430324699
200200179 13640656767

由于最后1行在phones.txt中只有学号和电话号码，而在students.txt文件中没有姓名，所以第2列为空白。

9.7.6　全联接文本文件

所谓全联接，是指除了显示两个文件中关键字匹配成功的行之外，还包括前后两个文件中所有不匹配的行。同样，对于第1个文件中的关键字匹配不成功的行，由第2个文件形成的列通过空白填充；反之，对于第2个文件中的关键字匹配不成功的行，由第1个文件形成的列也通过空白填充。如图9-17所示，描述了全联接的原理，图中阴影代表全联接的结果，可以看出全联接的结果中包含了两个文件中的所有的行。

图9-17　全联接文本文件

全联接文本文件的语法如下：

join -a 1 -a 2 filenum file1 file2

在上面的语法中，同时使用了-a1和-a2这两个选项，表示输出第1个和第2个文件的所有的行。

【例9-46】演示使用join命令通过全联接来联接两个文本文件的方法，代码如下：

01 #-----------------------------/chapter9/ex9-45.sh------------------
02 #! /bin/bash
03
04 # 全联接文本文件
05 result='join -a 1 -a 2 students.txt phones.txt > contactinfo.txt'
06
07 cat contactinfo.txt

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-45.sh
200200110 Abdul 13611499594
200200164 Abram 13682239867
200200167 Bartley 13710153203
200200168 Bennett 13622259071
200200172 Cecil 13430324699
200200173 John
200200179 13640656767
200200187 Cat

注意：在进行全联接时，选项-a1-a2不能写在一个-a选项中，只能重复使用两个-a选项。

9.7.7　自定义输出列

在默认情况下，join命令会输出参与联接的两个文件的所有的列。但是，在某些情况下，用户可能并不需要得到所有的列，就可以使用-o选项来指定要输出的列的清单。-o选项可以接受一个字段的列表，其语法如下：

filenum.filed

其中，filenum表示文件号，可以取值为1或者2，分别代表第1个和第2个文件。field代表要输出的列的列号，为一个十进制数字。文件号和列号之间用圆点连接起来，组成一个整体，代表某个文件的第几列。如果同时指定了多个列，则多个列之间用空格隔开。

另外，用户可以直接为-o选项指定参数值0，表示仅仅输出关键字列。

【例9-47】演示使用join命令的-o选项自定义输出列的方法，代码如下：

01 #-----------------------------/chapter9/ex9-46.sh------------------
02 #! /bin/bash
03
04 # 联接文件，并指定输出列的清单
05 result='join -1 1 -2 2 -o 1.1 1.2 2.3 students.txt scores.txt >
studentsscores.txt'
06
07 cat studentsscores.txt

在上面代码的第5行，指定输出的列包括第1个文件的第1、2列和第2个文件的第3列。

该文件的执行结果如下：

[root@linux chapter9]# ./ex9-46.sh
200200110 Abdul 78
200200164 Abram 78
200200167 Bartley 92
200200168 Bennett 87
200200172 Cecil 65

从上面的执行结果可以得知，scores.txt文件中的记录号已经被省略了。

9.8　使用tr命令替换文件内容

在Shell提供的文本处理工具中，还有一种非常实用的工具，其功能是用来批量替换文本中的字符。其中最主要的命令就是tr，本节将介绍tr命令的使用方法。

9.8.1　tr命令及其语法
tr是单词translate的前面两个字母，其功能是转换或者删除指定的字符。与其他的文本处理命令不同，tr命令不能直接从文件中读取数据，只能从标准输入获取数据，并且将处理结果写到标准输出设备。

tr命令的基本语法如下：

tr [option]... set1 [set2]

在上面的语法中，option表示tr命令的选项，常用的选项如下所列。

- -c：用字符集set2替换字符集set1中没有包含的字符。

- -d：删除字符集set1中的所有字符，不执行替换操作。

- -s：压缩set1中重复的字符。

- -t：将字符集set1用set2转换。

参数set1和set2分别表示参与操作的两个字符集，其中set1用于查询，set2用于处理各种转换操作。也就是说，凡是在字符集set1中出现的字符，都将被替换为字符集set2中相应位置上的字符。

tr命令中的字符集使用类似于正则表达式的形式来表达，常用的语法形式如下所列。

- [a-z]：所有的小写字母。

- [A-Z]：所有的大写字母。

- [0-9]：单个数字。

- /octal：一个三位的八进制数，对应有效的ASCII字符。

- [char*n]：表示字符char重复出现指定次数n。

另外，tr命令还支持字符类，常用的字符类如下所列。

- [:alnum:]：所有字母字符与数字。

- [:alpha:]：所有字母字符。

- [:blank:]：所有水平空格。

- [:cntrl:]：所有控制字符。

- [:digit:]：所有数字。

- [:graph:]：所有可打印的字符，不包含空格符。

- [:lower:]：所有小写字母。

- [:print:]：所有可打印的字符，包含空格。

- [:punct:]：所有标点字符。

- [:space:]：所有水平与垂直空格符。

- [:upper:]：所有大写字母。

- [:xdigit:]：所有十六进位制的数字。

9.8.2　去除重复出现的字符

使用tr命令，可以快速地将文本中连续出现多个相同的字符压缩为一个字符。例如在下面的demo9.txt文件中，就存在着许多由于输入错误导致的重复字符：

[root@linux chapter9]# cat demo9.txt
Windows RT is a new Windows-based operating systemmmmmmmmmmm that's optimized
for thin and ligggggggggggggggggggggggggght PCs that have extended battery
life and are designed for life on the go.

对于上面的语法错误，人工删除非常麻烦，下面介绍如何通过tr命令去掉连续的重复字符。

【例9-48】使用tr命令删除多余的重复字符，代码如下：

01 #-----------------------------/chapter9/ex9-47.sh------------------
02 #! /bin/bash
03
04 # 压缩重复字符
05 result='tr -s "[a-z]" < demo9.txt'
06
07 echo "$result"

在上面的代码中，由于tr命令不直接接受文件作为参数，所以第5行使用了输入重定向将文本文件demo9.txt的内容输入到tr命令。当然，为了解决这个问题，也可以使用其他的文本显示命令配合管道来完成。tr命令中使用了-s选项，表示压缩重复字符。由于在demo9.txt文件中出现重复的字符都是小写字母，所以使用[a-z]表示要处理的字符集。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-47.sh
Windows RT is a new Windows-based operating system that's optimized for thin
and light PCs that have extended batery life and are designed for life on
the go.

从上面的输出结果可以得知，多余的字符已经被全部删除。

9.8.3　删除空行

有的文本中，会存在许多空行。所谓空行，是指只含有换行符，除此之外不含有任何其他字符的行。

在下面的demo9.txt文件中，除了两行文本之外，其他所有的行都是空行，如下所示。

[root@linux chapter9]# cat demo10.txt
Hello, world.
I love Linux.

【例9-49】使用tr命令快速删除demo9.txt文件中多余的空行，代码如下：

01 #-----------------------------/chapter9/ex9-48.sh------------------
02 #! /bin/bash
03
04 # 删除空白行
05 result='cat demo10.txt | tr -s ["\n"]'
06
07 echo "$result"

其中第5行使用cat命令来显示文件内容，然后通过管道将结果输出到tr命令。在tr命令中，由于空行只含有换行符，所以可以使用\n表示空行。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-48.sh
Hello, world.
I love Linux.

注意：换行符也可以使用八进制数值\012表示，因此，【例9-49】中的第5行可以修改为：

result='cat demo10.txt | tr -s ["\012"]'

9.8.4　大小写转换

在前面的例子中，实际上只为tr命令提供了一个字符集。当使用tr进行大小写转换时，需要同时提供两个字符集。前者是目标字符集，后者是用来替换的字符集，其语法如下：

tr [a-z] [A-Z]

在使用以上命令时，出现在输入文本中的字符集[a-z]中的小写字母将被字符集[A-Z]中相应位置的大写字母代替。

【例9-50】使用tr命令将当前目录中的所有文件的文件名转为大写，代码如下：

01 #-----------------------------/chapter9/ex9-49.sh------------------
02 #! /bin/bash
03
04 # 将当前目录在的所有文件的文件名转为大写
05 for file in 'ls';do
06 echo "$file" | tr 'a-z' 'A-Z'
07 done

在上面的代码中，第5～7行使用for循环对当前目录中的每个文件名进行转换。从第5行可以看出，字符集也可以使用'a-z'及'A-Z'等语法表示。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-49.sh
ALLUSERS.TXT
COUNTRIES.TXT
DEMO10.TXT
DEMO1.TXT
DEMO2.TXT
DEMO3.TXT
…

注意：如果想要将大写转为小写，只要将两个字符集颠倒就可以了。

9.8.5　删除指定字符

使用tr命令可以快速地删除文本中出现的某些字符，这在某些情况下非常有效。当用户使用tr命令删除指定字符时，需要使用-d选项。

例如，用户有一个内容如下的文件demo11.txt：

[root@linux chapter9]# cat demo11.txt
Monday 09:00
Tuesday 09:10
Wednesday 10:11
Thursday 11:30
Friday 08:00
Saturday 07:40
Sunday 10:00

现在用户想要把其中的时间字符去掉。通过观察发现，实际上时间字符是由数字和冒号组成的。所以，用户可以通过tr命令来删除这些字符。

【例9-51】使用tr命令删除demo11.txt文件中的时间列，代码如下：

01 #-----------------------------/chapter9/ex9-50.sh------------------
02 #! /bin/bash
03
04 # 删除数字和冒号
05 result='tr -d "[0-9][:]" < demo11.txt'
06
07 echo "$result"

在上面的代码中，第5行使用含有-d选项的tr命令来删除字符集中指定的字符。其中字符集为0-9和冒号。

该程序的执行结果如下：

[root@linux chapter9]# ./ex9-50.sh
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
Sunday

注意：在使用tr命令时，注意字符集表示一个个单独的字符，而非字符串。因此，tr-d"[Hello]表示的是文本中出现的5个字符，而非Helloh这个字符串。

除了上面使用-d选项之外，用户还可以使用其他间接的方法来删除文本中的字符，如下面的例子所示。

【例9-52】通过补集的方式来删除指定的字符，代码如下：

01 #-----------------------------/chapter9/ex9-51.sh------------------
02 #! /bin/bash
03
04 # 使用补集删除某些字符
05 result='tr -cs "[a-z][A-Z]" "[\n*]" < demo11.txt'
06
07 echo "$result"

在上面的代码中，第5行通过-c选项，使得tr命令用空行来代替文本中不在[a-z][A-Z]中出现的字符。由于在demo11.txt中，所有用户需要的字符都是大写字母或者小写字母，而需要删除的字符都是非字母字符，所以第5行的语句首先将demo11.txt中的非字母字符替换为换行符，然后再通过-s选项压缩文本中的重复字符，从而最终达到删除时间字符的目的。

【例9-52】的执行结果与【例9-51】完全相同，请读者自行执行并验证。

9.9　小结

本章详细介绍了Shell程序中处理文本的各种命令及其使用方法，主要包括使用echo命令输出文本、文本的格式化输出、使用sort命令对文本进行排序、文本的统计、使用cut命令选取文本列、使用paste命令拼接文本列、使用join命令联接文本列，以及使用tr命令替换文本内容等。重点在于掌握文本处理命令的基本用法。在下一章中，将介绍流编辑命令sed的使用方法。

第10章
流编辑

在第9章中，我们已经介绍了许多处理文本的命令，但是，Shell所提供的文本处理工具还有许多，可以说是应有尽有。在这众多的文本处理工具中，其中有一类称为流编辑器的工具却不得不提。流编辑器在处理文本中发挥着重要的作用，许多其他工具无法完成的工作，使用流编辑器可以非常方便地完成。本章将对Shell中的流编辑工具进行介绍。

本章主要涉及到知识点如下所述。

- 什么是sed：主要介绍什么是sed工具、sed工具的主要操作、工作方式，以及地址表示方法、在sed命令中使用正则表达式等。

- sed命令的常用操作：主要介绍sed命令的注释方法、打印文本、替换文本、删除文本、追加文本、插入文本、移到下一行、读写文件，以及多行模式等。

- 组合命令：主要介绍如何在sed中组合多条命令，以及如何将多条命令应用到一个地址范围等。

10.1　sed命令及其语法
sed命令是将一系列的编辑命令应用于一批文本的理想工具。sed命令拥有非交互式和高效的特点，可以为用户节约大量的时间。本节将介绍sed命令的基础知识。

10.1.1　sed命令以及语法
sed命令是一个非交互式的文本编辑器，它可以对来自文本文件，以及标准输入的文本进行编辑。其中，标准输入可以是来自键盘、文件重定向、字符串、变量或者是管道的文本。

sed命令会从文件或者标准输入中一次读取一行数据，将其复制到缓冲区，然后读取命令行或者脚本的编辑子命令，对缓冲区中的文本行进行编辑。重复此过程，一直到所有的文本行都处理完毕。如图10-1所示，描述了sed命令工作流程。

通常情况下，sed命令会适用于以下几种场合：

- 编辑一个非常大的文本文件，以至于使用普通的交互式编辑器非常慢。

- 编辑命令比较复杂，在普通的文本编辑器中难以完成。

- 扫描一个比较大的文本文件，并且需要经过一系列的操作。

sed命令编辑的文件是原始文件在缓冲区中的副本，因此编辑操作并不影响原来的文件，并且输出到屏幕。如果用户想要将处理结果保存下来，可以将输出重定向到一个磁盘文件中。

图10-1　sed命令工作流程

sed命令的基本语法如下：

sed [options] [script] [inputfile...]

在上面的语法中，options代表sed命令的选项，常用的选项如下所列。

- -n：取消默认输出。

- -e：允许执行多个脚本。

- -f：从脚本文件中读取命令。

- -i：直接修改原始文件。

- -l：指定行的长度。

- -r：在脚本中使用扩展正则表达式。

- -s：默认情况下，sed将把命令行指定的多个文件名作为一个长的连续的输入流。而GNU sed则允许把它们当做单独的文件，这样的话，正则表达式不进行跨文件匹配。

- -u：最低限度的缓存输入与输出。

script参数表示sed脚本文件，用户可以将一系列的sed命令写在一个脚本文件中，sed会从该文件中读取并依次执行各个命令。inputfile表示输入文本文件，如果没有指定输入文件，则从标准输入中读取。

在上面的选项中，-n选项需要特别说明一下。前面已经讲过sed的工作流程。在开始处理文本之前，sed命令的缓冲区是空的。sed读入一行内容，删除尾部的换行符，存入到缓冲区中，然后执行相应的编辑命令。如果用户没有使用-n选项，在缓冲区中的内容会被输出到标准输出；如果用户使用了-n选项，则sed命令不会输出任何内容，而是直接读取下一行数据，重复上面的步骤。

在这里就会出现一个问题，如果用户想要在屏幕上输出sed命令的处理结果，应该怎么办？这就需要用到下面介绍的sed的编辑命令，sed的子命令p可以强制输出缓冲区的内容。

10.1.2　sed命令的工作方式

通常情况下，用户可以通过3种方式来使用sed命令，下面将分别进行介绍。

首先，用户可以在命令行直接执行sed命令，其基本语法如下：

sed [options] commands inputfile

在上面的语法中，commands表示sed的相关操作命令，例如打印文本、删除文件，以及插入文本等。

其次，用户可以将sed操作命令写入脚本文件，然后通过sed命令读取该文件并且执行其中的命令，其基本语法如下：

sed [options] -f script inputfile

在上面的语法中，参数scirpt代表一系列的sed操作命令。

最后，用户通过将sed的操作命令写入文件，然后将该脚本文件授予用户执行权限。这样的话用户就可以直接执行文件，其基本语法如下：

./scrpt inputfile

其中，script代表保存sed命令的脚本文件。在前面已经讲过，在脚本文件中，用户应该在#!符号后面执行该脚本的解释器，因此在这种方式下，sed脚本文件的第一行应该如下：

#! /bin/sed

注意：在第2种方式中，用户不需要指定脚本的解释器，因为在调用的时候，已经指定使用sed命令载入并解释该文件。但是，在第3种方式中，用户必须指定解释器程序。

10.1.3　使用行号定位文本行

通常情况下，一个完整的sed命令由定位参数和编辑命令两部分组成。通过定位参数， sed命令可以对特定的文本行进行操作。sed命令提供了两种方式来实现文本行的定位，其一为行号定位，其二为正则表达式定位。下面首先介绍使用行号定位文本行。

1．定位某个特定的行

在sed命令中，指定某个特定的文本行的语法如下：

x

其中，x是一个整数，代表特定的文本行的行号。例如，现在想要定位到第10行，则可以使用以下语句：

10

2．定位某段连续的行
sed使用以下语法来指定某一段连续的文本行：

x,y

在上面的语法中，x表示起始行的行号，y表示终止行的行号。以上语法表示指定从x开始一直到y行的所有的文本行。

3．指定起始行和步长

在某些情况下，用户需要的不是一段连续的文本行，而是一段有固定间隔的行，例如偶数行、奇数行或者每隔3行等。在这种情况下，用户可以使用以下语法来指定文本行的步长：

first~step

在上面的语法中，first和step都是整数，其中first表示起始行，step表示行号增长的步长。

例如，如果要指定文本文件的偶数行，用户可以使用以下语句：

0~2

如果用户想要指定文本文件的奇数行，则可以使用以下语句：

1~2

4．指定文件的第一行和最后一行

指定文件的第一行比较简单，直接使用数字1即可，如下：

1

对于文件的最后一行，sed命令专门提供了一个操作符$。因此，指定文件最后一行的语句如下：

$

5．指定某行后面的几行

在sed命令中，用户可以采用相对定位的方法来指定行，如下：

x,+n

以上语法表示指定从x行以及后面的n行文本。例如：

2,+5

表示从第2行以及后面的5行，即第2～7行。

10.1.4　使用正则表达式定位文本行

除了使用行号定位文本行之外，sed命令还支持正则表达式定位。sed命令中，正则表达式的语法如下：

/regexp/

在上面的语法中，两个斜线之间的regexp表示正则表达式，其中，常用的正则表达式元字符如下所列。

- 一般字符：字符本身就匹配字符本身，例如/abc/就是定位包含abc的行。

- *：表示前置表达式重复了0次或者多次，例如，/a*/表示匹配字符a出现0次或者多次的情况。

- \+：与星号类似，但是该元字符匹配其前置表达式出现1次以上，属于扩展正则表达式。

- \?：匹配其前置表达式重复出现0次或者1次。

- \{i\}：类似于星号，但匹配前置表达式出现i次，如a\{3\}匹配aaa。

- \{i,j\}：匹配前置表达式最少出现i次，最多出现j次。例如，a\{1,2\}可以匹配a或者aa这两种情况。

- \{i,\}：匹配其前置表达式至少出现i次。

- .：匹配任意字符。

- ^：匹配行首字符。

- $：匹配行尾字符。

- []：匹配方括号中的任意单个字符。

- [^]：匹配不出现在方括号中的任意单个字符。

- \n：匹配换行符。

从上面的介绍可以得知，sed命令中的元字符的含义与前面介绍的正则表达式中的元字符基本相同。

注意：与正则表达式一样，如果要匹配元字符本身，需要使用转义字符。例如想要匹配字符*，则需要使用\*。

10.2　sed命令的常用操作

除了定位参数之外，sed命令的另外一个组成部分就是编辑命令。常用的编辑命令有打印、插入、删除，以及替换等，本节将介绍如何在sed命令使用这些编辑命令。

10.2.1　sed编辑命令基本语法
sed命令的基本语法如下：

[address1[,address2]] command [argument]

在上面的语法中，address1和address2都称为位置参数，在10.1节中已经讲过，sed命令的位置参数可以使用两种方式来表示，分别为行号和正则表达式。位置参数的作用是用来限制sed编辑命令所影响的文本行的范围。如果某个sed命令没有提供位置参数，则表示对指定文本文件中的所有的文本行执行编辑操作；如果只有一个位置参数时，表示只对符合指定位置的文本行进行操作；如果有两个位置参数，则表示对从address1所表示的文本行开始，一直到address2所表示的文本行结束所包含的所有的文本行进行操作。

command表示sed所提供的子命令，用来实现编辑操作。argument则表示子命令的参数，例如替换命令s需要两个参数，分别为要查找的模式和用来替换的字符串。

注意：sed命令的两个位置参数可以同时使用行号或者正则表达式，也可以混合使用，即其中一个位置参数使用行号，另外一个位置参数使用正则表达式。

10.2.2　选择文本

在前面介绍正则表达式的时候，使用了grep及egrep等命令来对文本进行筛选。实际上sed命令也可以实现这个功能。在sed命令中，选择文本行主要通过位置参数来完成，基本语法如下：

[address1[,address2]] p

在上面的语法中，address1和address2都是位置参数。如果省略位置参数，则表示选择整个文件。子命令p表示将缓冲区中的文本行执行输出操作，即打印（print）缓冲区中的文本。

注意：sed命令的缓冲区又称为模式空间（pattern space）。

【例10-1】演示使用行定位方法来选择输出某些文本行，其中使用的文本文件仍然是第9章中的students.txt。代码如下：

01 #----------------------------/chapter10/ex10-1.sh------------------
02 #! /bin/bash
03
04 # 输出 1~3 行，不使用 -n 选项
05 sed '1,3p' students.txt
06
07 echo "===================================="
08
09 # 输出 1~3 行，使用 -n 选项
10 sed -n '1,3p' students.txt

在上面的代码中，第5行没有使用-n选项来输出students.txt文件的第1～3行，第10行使用了-选项来打印同样的文本行。

该程序的输出结果如下：

[root@linux chapter10]# ./ex10-1.sh
200200110 Abdul
200200110 Abdul
200200164 Abram
200200164 Abram
200200167 Bartley
200200167 Bartley
200200168 Bennett
200200172 Cecil
200200173 John
200200187 Cat
====================================
200200110 Abdul
200200164 Abram
200200167 Bartley

在上面的执行结果中，横线上面的是不含有-n选项的sed命令的输出结果，横线下面是含有-n选项的sed命令的输出结果。分析输出结果中的文本行，可以发现在不使用-n选项的情况下，sed命令将文本文件的前3行输出了两次。之所以会出现这个结果，是因为sed命令首先会读取一行文本，然后自动将缓冲区中的该行文本输出到标准输出。接着执行sed的子命令，遇到p命令之后，该命令又会强制sed命令输出一次缓冲区中的文本。这样的话，导致了前面3行输出两次。而对于后面的其他的行，是sed命令的默认输出，没有受到位置参数的影响，所以只输出了1次。

当使用了-n选项之后，sed命令的默认输出被取消，所以只有受p子命令影响的前面3行被输出。

【例10-2】演示使用正则表达式来作为定位参数，代码如下：

01 #----------------------------/chapter10/ex10-2.sh------------------
02 #! /bin/bash
03
04 # 使用正则表达式定位
05 result='sed -n '/^20020017/ p' students.txt'
06
07 echo "$result"

在上面的代码中，第5行使用正则表达式/^20020017/来定位文本，表示以字符2开头，然后紧跟着字符串0020017的文本行，对于这些文本行执行定位参数后面的子命令p。

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-2.sh
200200172 Cecil
200200173 John

另外，还有一些特殊的情况，例如用户需要输出第一行，则可以使用以下命令：

[root@linux chapter10]# sed -n '1 p' students.txt
200200110 Abdul

其中，位置参数1表示第一行文本。

如果想要打印最后一行，则可以使用以下命令：

[root@linux chapter10]# sed -n '$ p' students.txt
200200187 Cat

其中的位置参数$表示定位文本文件的最后一行。

如果想要输出奇数行，则可以使用以下命令：

[root@linux chapter10]# sed -n '1~2 p' students.txt
200200110 Abdul
200200167 Bartley
200200172 Cecil
200200187 Cat

其中的位置参数1～2表示从第一行开始，每隔两行。

同理，如果想要输出偶数行，则只要将上面的位置参数的1改为0即可，如下：

[root@linux chapter10]# sed -n '0~2 p' students.txt
200200164 Abram
200200168 Bennett
200200173 John

下面的命令的位置参数使用正则表达式和行号来表达：

[root@linux chapter10]# sed -n '/Abdul/,5 p' students.txt
200200110 Abdul
200200164 Abram
200200167 Bartley
200200168 Bennett

在上面的命令中，第一个位置参数使用正则表达式/Abtul/表达，表示从含有字符串Abtul的文本行开始，第二个位置参数使用行号5表达，表示到第5行结束。

10.2.3　替换文本

使用sed命令，可以很方便地对文本文件中指定的文本进行替换操作。文本的替换需要使用s子命令，其语法如下：

[address1[ ,address2]] s/pattern/replacemen/[flag]

在上面的语法中，address1与address2都是位置参数，关于它们的用法请参见前面的几个小节的介绍。在s命令中，位置参数通常会被省略，表示在所有的文本行中进行替换操作，其语法如下：

s/pattern/replacemen/[flag]

s子命令表示执行替换（substitute）操作。pattern为使用正则表达式表示的匹配模式， replacement为用来替换的由一般字符组成的字符串。

flag为替换标志，它的值会影响到s命令的行为，常用的选项有以下值。

- g：全局（global）匹配，会替换文本行中所有符合规则的字符串。

- 十进制数字：如果flag为一个十进制数字n，则表示替换文本行中第n个符合规则的字符串。

- p：替换第1个符合规则的字符串，并且将缓冲区输出到标准输出。

- w：替换第1个符合规则的字符串，并且将受影响的行输出到磁盘文件中。

- 空：如果没有指定flag，则表示替换文本行中的第1个符合规则的字符串。

【例10-3】演示使用s子命令替换文本的方法，代码如下：

01 #----------------------------/chapter10/ex10-3.sh------------------
02 #! /bin/bash
03
04 echo "substitute the first pattern."
05 # 只将每行中第 1 次出现的小写字母 e 替换为大写字母 E
06 result='sed 's/e/E/' students.txt'
07
08 echo "$result"
09
10 echo "substitute all the patterns."
11 # 将每一处的小写字母 e 都替换为大写字母 E
12 result='sed 's/e/E/g' students.txt'
13
14 echo "$result"

在上面的代码中，第6行中的正则表达式为/e/表示匹配小写字母e，后面的E为用来替换的字符串。第12行使用了g选项来执行相同的替换。

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-3.sh
substitute the first pattern.
200200110 Abdul
200200164 Abram
200200167 BartlEy
200200168 BEnnett
200200172 CEcil
200200173 John
200200187 Cat
substitute all the patterns.
200200110 Abdul
200200164 Abram
200200167 BartlEy
200200168 BEnnEtt
200200172 CEcil
200200173 John
200200187 Cat

通过分析上面的输出结果，可以发现，代码中第6行的sed语句只替换了每行中第一次出现的字母e，其他的字母e却没有被替换，例如Bennett中被替换成了BEnnett。但是代码中第12行的sed语句却替换了所有的小写字母e。

上面的两个例子都省略了位置参数，表示在所有的文本行中搜索匹配的模式。除此之外，用户可以指定sed命令搜索的文本行的范围，如下面的例子所示。

【例10-4】使用行号定位方法来定位文本行，代码如下：

01 #----------------------------/chapter10/ex10-4.sh------------------
02 #! /bin/bash
03
04 # 替换第 1~3 行中的所有的小写字母 e 为大写字母 E
05 result='sed '1,3 s/e/E/g' students.txt'
06
07 echo "$result"

在上面的代码中，第5行表示替换指定文本文件的1～3行中的所有的小写字母e为大写字母E，其余的文本行不受影响。

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-4.sh
200200110 Abdul
200200164 Abram
200200167 BartlEy
200200168 Bennett
200200172 Cecil
200200173 John
200200187 Cat

分析上面的输出结果，可以发现只有第1～3文本行中的小写字母e被替换，而其余的小写字母并没有被替换。这个例子说明了在上面的语句中，位置参数已经发挥作用。

前面已经介绍过，在sed命令中，位置参数的表示非常灵活，可以使用行号，也可以使用正则表达式，甚至还可以将这两种方式混合起来使用。

【例10-5】演示通过两种方式来定位文本行。第一个位置参数使用行号，而第二个位置参数则使用正则表达式来表示。代码如下：

01 #----------------------------/chapter10/ex10-5.sh------------------
02 #! /bin/bash
03
04 # 使用混合位置参数
05 result='sed '1,/^200200167/ s/e/E/g' students.txt'
06
07 echo "$result"

在上面的代码中，第5行的位置参数为1,/^200200167/，其中，第1个位置参数为1，表示从第1行开始；第2个位置参数为正则表达式/^200200167/，表示到以字符2开头，并且后继为字符串00200167的文本行为止。

由于在students.txt文件中，符合上面的正则表达式的行恰好为第3行，所以【例10-5】的执行结果与【例10-4】完全相同。

对于用户来说，要使用好sed命令，必须学会灵活地运用正则表达式。下面再举一个更加实用的例子。作为开发人员，经常会遇到以下情况：对于一段HTML代码，如何将其中的HTML标记全部过滤掉。下面的例子就给出了一种解决方法。

假设有下面一段HTML代码需要用户处理：

01 <div class="xspace-itemdata">
02 <a href="#xspace-tracks">view(1881)</a>
03 <a href="#xspace-itemreply">review(5)</a>
04 <a href="#xspace-itemform">score</a>
05 </div>

【例10-6】使用sed命令结合正则表达式将上面HTML代码中的文本提取出来，代码如下：

01 #----------------------------/chapter10/ex10-6.sh------------------
02 #! /bin/bash
03
04 # 将文件中的 HTML 标记替换为空
05 result='sed 's/<[^>]*>//g' html.txt'
06
07 echo "$result"

用户需要特别注意代码第5行中正则表达式/<[^>]*>/，这个正则表达式所描述的匹配规则为：首先是一个左尖括号，在随后的文本中不会出现右尖括号，接下来是匹配任意多个任意字符，最后是一个右尖括号。因此，这个正则表达式会匹配<…>这样的字符串，并且左右两个尖括号之间不会出现另外的右尖括号。

另外，代码第5行的sed语句也需要进行说明，在这个语句中，并没有为sed命令提供第2个参数值，这意味着用空串来替换匹配的模式。

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-6.sh
view(1881)
review(5)
score

从上面的执行结果可以得知，所有的HTML标记全部被过滤掉了。

注意：在s命令中，各个参数之间并不一定要使用斜线/分隔，还可以使用除空格以及换行符之外的其他任意的字符，例如冒号或者分号等。总之，s命令会将紧跟在后面的那个字符作为参数分隔符。因此，下面的两条语句与【例10-6】中的第5行是等价的：

result='sed 's:<[^>]*>::g' html.txt'
result='sed 's;<[^>]*>;;g' html.txt'

其中，第1条使用了冒号作为参数分隔符，第2条使用分号作为参数分隔符。

前面讲过，在s子命令中，作为替换的字符串中通常只包含一般字符。但是，如果替换字符串中含有字符&或者反斜线加上数字\n，其中n是一个1～9之间的数字，则表示引用子字符串。其中&符号表示引用匹配的模式，即在某个文本行中与模式相匹配的那部分子串；而\n表示第n个由圆括号括起来的子串。

【例10-7】演示&符号的使用方法，其中demo1.txt文件的内容如下：

[root@linux chapter10]# cat demo1.txt
This is a string.

本例代码如下：

01 #----------------------------/chapter10/ex10-7.sh------------------
02 #! /bin/bash
03
04 # 引用与模式相匹配的子串
05 result='sed 's/string/long &/' demo1.txt'
06
07 echo "$result"

在上面的代码中，第5行中的&表示引用模式string，因此每个文本行中第1个与模式string相匹配的子串都将被替换为long string这个字符串。

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-7.sh
This is a long string.

注意：由于【例10-7】中没有使用全局替换标志g，所以只会替换每个文本行中第1次匹配的子串。

【例10-8】演示使用\n的形式来引用模式中子串，代码如下：

01 #----------------------------/chapter10/ex10-8.sh------------------
02 #! /bin/bash
03
04 # 通过数字来引用模式中的子串
05 result='sed 's/\(This\) \(is\) \(a\) \(string\)/\2 \1 \3 \4/' demo1.txt'
06
07 echo "$result"

在上面的代码中，第5行的匹配模式被圆括号分隔成4个子串，在后面的替换字符串中，\2表示引用第2个子串，即is，\1表示引用第1个子串，即This，\3和\4分别引用第3个和第4个子串，分别为a和string。

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-8.sh
is This a string.

从上面的执行结果可以得知，【例10-8】重新排列了模式中的单词的次序。

注意：在使用\n的形式引用模式的子串时，不能定义9个以上的子串，因此n的取值为1～9。

10.2.4　删除文本

接下来介绍如何使用sed命令删除某些文本行。sed提供了d子命令来实现文本行的删除（delete），其语法如下：

[address1[ ,address2]] d

在上面的语法中，两个位置参数的涵义以及用法请参见前面的内容。命令d表示删除位置参数指定的行。如果省略位置参数，则表示删除文本文件中的所有的行。在执行删除操作时，sed命令会首先读取一行文本到缓冲区，然后将符合位置参数的文本行删除，接着读取并处理下一行。下面的例子演示了如何删除指定的文本行。

【例10-9】通过sed命令删除指定的文本行，代码如下：

01 #----------------------------/chapter10/ex10-9.sh------------------
02 #! /bin/bash
03
04 # 删除第 1 行
05 result='sed -e '1 d' students.txt'
06
07 echo "$result"

在上面的代码中，第5行的sed语句中多个一个选项-e，这个选项的功能是告诉sed，将后面的字符串作为子命令来处理。当然，如果只有一个子命令，这个选项可以省略，因此，前面的所有例子中，包括【例10-9】，都可以省略-e选项。子命令1 d表示将第1行删除，其中数字1为定位参数。

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-9.sh
200200164 Abram
200200167 Bartley
200200168 Bennett
200200172 Cecil
200200173 John
200200187 Cat

对比前面列出的students.txt文件的内容，可以发现第一行，即学号为200200110的一行数据已经被删除。

如果想要删除文本文件的最后一行，可以使用符号$作为定位参数，如下面的例子所示。

【例10-10】演示删除文本文件最后一行的方法，代码如下：

01 #---------------------------/chapter10/ex10-10.sh------------------
02 #! /bin/bash
03
04 # 删除最后一行
05 result='sed -e '$ d' students.txt'
06
07 echo "$result"

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-10.sh
200200110 Abdul
200200164 Abram
200200167 Bartley
200200168 Bennett
200200172 Cecil
200200173 John

从上面的执行结果中可以得知，学号为200200187的最后一行已经被删除。

d子命令最多支持两个定位符，在上面的例子中，我们都使用了一个定位符。实际上， sed命令的定位符非常灵活，可以指定某一段文本行，间隔指定数量的文本行以及从某个行开始一直到文件结尾。下面的例子就实现了这些更为复杂的定位。

【例10-11】演示sed命令中文本行的不同定位方法，代码如下：

01 #---------------------------/chapter10/ex10-11.sh------------------
02 #! /bin/bash
03
04 # 删除 1~4 行
05 result='sed -e '1,4 d' students.txt'
06
07 echo "$result"
08
09 echo "==============================="
10
11 # 删除奇数行
12 result='sed -e '1~2 d' students.txt'
13
14 echo "$result"
15
16 echo "=============================="
17
18 # 删除偶数行
19 result='sed -e '0~2 d' students.txt'
20
21 echo "$result"
22
23 echo "============================="
24
25 # 删除从第 1 行开始，一直到以 200200172 开头的行
26 result='sed -e '1,/^200200172/ d' students.txt'
27
28 echo "$result"
29
30 echo "============================"
31
32 # 删除从第 4 行开始，一直到最后一行的所有行
33 result='sed '4,$ d' students.txt'
34
35 echo "$result"

在上面的代码中，第5行代码的子命令为1,4 d，表示从第1个文本行开始，一直删除到第4个文本行为止。第12行代码的子命令为1～2 d，表示从第1个文本行开始，每隔2行删除1行文本，即删除行号为奇数的文本行。第19行代码的子命令为0～2 d，表示从第0个文本行开始，每隔2行删除1行文本，即删除行号为偶数的文本行。第26行代码的子命令为1,/^200200172/ d，表示从第1个文本行开始，到以字符串00200172开头的文本行为止，删除这一段文本行。第33行的子命令为4,$ d，表示删除从第4个文本行开始，到文件末尾的所有文本行。

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-11.sh
200200172 Cecil
200200173 John
200200187 Cat
===============================
200200164 Abram
200200168 Bennett
200200173 John
==============================
200200110 Abdul
200200167 Bartley
200200172 Cecil
200200187 Cat
=============================
200200173 John
200200187 Cat
============================
200200110 Abdul
200200164 Abram
200200167 Bartley

注意：还记得在介绍正则表达式的时候讲过的空白行的表示方法吗？在正则表达式中，用户可以使用表达式^$来表示空白行。因此，用户可以使用以下命令来删除文件中的所有的空白行：

sed '^$ d' students.txt

10.2.5　追加文本

所谓追加文本，是指将某些文本插入到某个位置的后面。sed命令提供了a子命令来实现文本的追加（append），其基本语法如下：

[address1] a string

从上面的语法可以得知，子命令a最多只能使用一个位置参数。参数string表示将要追加的文本。a子命令会将string代表的文本插入到address1所表示的位置的后面。这一点也是追加文本与后面将要介绍的插入文本的区别。

【例10-12】演示通过sed命令追加文本的方法，代码如下：

01 #---------------------------/chapter10/ex10-12.sh------------------
02 #! /bin/bash
03
04 # 在第 2 行后面追加文本
05 result='sed '2 a 200200109 Tom' students.txt'
06
07 echo "$result"

在上面的代码中，使用行号2作为位置参数，表示在第2行后面追加一行文本。

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-12.sh
200200110 Abdul
200200164 Abram
200200109 Tom
200200167 Bartley
200200168 Bennett
…

从上面的执行结果可以得知，学号为200200109的文本行被插入到第2行后面。

除了使用行号来定位之外，同样，用户还可以使用正则表达式作为位置参数，如下面的例子所示。

【例10-13】通过正则表达式指定追加文本的位置，代码如下：

01 #---------------------------/chapter10/ex10-15.sh------------------
02 #! /bin/bash
03
04 # 在以 200200110 开头的文本行后面追加文本
05 result='sed '/^200200110/ a 200200109 Tom' students.txt'
06
07 echo "$result"

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-13.sh
200200110 Abdul
200200109 Tom
200200164 Abram
200200167 Bartley
…

10.2.6　插入文本

插入文本的操作与追加文本非常相似，只是插入的位置不同。追加文本是在位置参数指定的位置的后面插入文本，而插入文本则是在位置参数指定的位置的前面插入文本。在sed命令中，子命令i用来实现文本的插入，其语法如下：

[address1] i string

与子命令a一样，子命令i也是最多只能使用一个位置参数。位置参数address1用来指定要插入文本的位置，子命令i表示当前的操作是插入文本，参数string表示将要被插入的文本。

从上面的语法可以看出，i子命令和a子命令的使用方法并没有太大的区别。下面以具体的例子来说明插入文本的方法。

【例10-14】演示通过sed命令插入文本的方法。为了便于比较i子命令和a子命令的区别，本例对【例10-12】进行修改，将其中的a子命令换成i子命令。代码如下：

01 #---------------------------/chapter10/ex10-14.sh------------------
02 #! /bin/bash
03
04 # 在以 200200110 开头的文本行前面插入文本
05 result='sed '2 i 200200109 Tom' students.txt'
06
07 echo "$result"

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-14.sh
200200110 Abdul
200200109 Tom
200200164 Abram
…

对比【例10-14】和【例10-12】的执行结果，可以发现文本行的插入位置不同。

同样，用户可以使用其他的位置参数来指定插入的位置，用户可以对【例10-13】进行修改，以观察两者的不同。

10.3　组合命令
sed命令支持将多个子命令组合在一起使用，这一点非常重要，因为在通常情况下，用户需要对文本进行多个不同的操作。如果不支持组合命令的话，则需要使用多个sed命令来完成；如果使用组合命令，则可以在一条sed命令中将这些操作完成。本节将介绍sed中的组合命令的使用方法。

10.3.1　使用-e选项执行多个子命令
sed命令的-e选项可以使sed将跟在其后面的字符串作为子命令执行。在前面的大部分例子中，由于都是一个子命令，所以将该选项省略。但是，如果想要为sed命令同时指定多个子命令，则必须使用多个-e选项。

【例10-15】演示sed命令-e选项的使用方法，代码如下：

01 #---------------------------/chapter10/ex10-15.sh------------------
02 #! /bin/bash
03
04 # 将所有的小写字母 e 替换为大写字母，然后打印 2~3 行
05 result='sed -n -e 's/e/E/g' -e '2,3 p' students.txt'
06
07 echo "$result"

在上面的代码中，第5行的语句执行了两个sed子命令，首先是子命令s，将所有的小写字母e替换为大写字母E。然后是p子命令，将第2～3行文本输出到屏幕。

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-15.sh
200200164 Abram
200200167 BartlEy

从上面的执行结果可以得知，第2个子命令输出的是第1个子命令处理后的结果。这是因为sed是一个行编辑工具，也就是说，sed命令会逐行读取文本到缓冲区中，然后依次应用各个子命令。所有的子命令都是对缓冲区中的数据进行处理的，而p子命令将缓冲区中的数据输出到标准输出。

注意：如果sed命令中只有1个子命令，则-e选项可以省略。

10.3.2　使用分号执行多个子命令

用户还可以使用分号来将各个子命令隔开，其语法如下：

sed -e 'command1;command2...' filename

其中，command1以及command2等都表示多个子命令，这些子命令之间用分号隔开。filename参数表示要处理的文本文件。

【例10-16】演示通过分号执行多个子命令的方法，代码如下：

01 #---------------------------/chapter10/ex10-16.sh------------------
02 #! /bin/bash
03
04 # 使用分号隔开多个子命令
05 result='sed -e 's/e/E/g;2 i 200100001 Ellen' students.txt'
06
07 echo "$result"

在上面的代码中，第5行包含两个子命令，第1个是s子命令，第2个是i子命令。

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-16.sh
200200110 Abdul
200100001 Ellen
200200164 Abram
200200167 BartlEy
200200168 BEnnEtt
200200172 CEcil
200200173 John
200200187 Cat

在上面的执行结果中，除了新插入的第2行文本之外，其他的所有文本行中的小写字母e都被替换成了大写字母E，这个正是s子命令的处理结果。第2行中的小写字母e并没有被替换，是因为sed命令的子命令是按照从左到右的顺序依次执行的，s子命令在前， i子命令在后，所以i子命令插入的行并没有被s子命令处理。

10.3.3　对一个地址使用多个子命令

在某些情况下，用户需要对同一个地址使用多个子命令，例如，对某个文件的前20行进行多次替换操作等。sed命令提供了对同一个地址使用多个子命令的语法，如下：

address {
command1
command2
command3
…
}

在上面的语法中，address为位置参数，表示后面一组命令的应用对象。command1及command2等为一组sed子命令，这些子命令使用大括号括起来。在大括号中的每个子命令，都允许有自己的位置参数。

除了上面将多个子命令分行书写的语法之外，还可以将所有的子命令写在一行里面，如下：

[address] {command1;command2;command3;…}

注意：在不同的sed版本中，对于组合命令的语法要求可能会不同。例如，有的版本中要求右大括号必须单独一行，子命令后面不能有空格等，用户在使用组合命令时需要注意。

【例10-17】演示对同一个地址使用多个子命令的方法，代码如下：

01 #---------------------------/chapter10/ex10-17.sh------------------
02 #! /bin/bash
03
04 # 组合命令
05 result='sed -n '1,5 {
06 s/e/E/g
07 s/a/A/g
08 2 i 201303009 Tom
09 p
10 }' students.txt'
11
12 echo "$result"

在上面的代码中，第5行的sed命令中的位置参数为1,5，表示对第1～5行文本使用后面的一组子命令，接下来是左大括号。第6行是第1个子命令s，将小写字母e替换为大写字母E，第7行是第2个子命令s，将小写字母a替换为大写字母A，第8行是第3个子命令i，在第2行文本的前面插入一行，代码的第9行是子命令p，用来输出缓冲区中的数据。

该程序的执行结果如下：

[root@linux chapter10]# ./ex10-17.sh
200200110 Abdul
201303009 Tom
200200164 AbrAm
200200167 BArtlEy
200200168 BEnnEtt
200200172 CEcil

从上面的执行结果可以得知，sed会依次执行各个子命令。另外，在大括号里面，还可以指定位置参数，例如代码的第8行的i子命令。

注意：大括号中的子命令可以写在一行里面，需要使用分号隔开。但是不鼓励这样做，因为会导致程序的可读性较差。

10.3.4　sed脚本文件

尽管直接将子命令写在sed命令中会非常方便，但是当用户的需求比较复杂，子命令较多的情况下，直接在sed命令中输入子命令会导致程序的可读性差，会让用户分不清哪些是子命令，以及这些子命令的功能是什么。

为了避免这个问题，sed提供了-f选项，通过这个选项，sed命令可以从指定的脚本文件中读取子命令，然后对每个文本行依次执行各个子命令，其语法如下：

sed -f script

其中，script表示sed脚本文件。

sed脚本的语法比较简单，就是将各个子命令依次列出来，不必使用引号。如果将多条子命令写在同一行中，需要使用分号将其隔开。另外，sed脚本文件支持代码注释，如果某一行以#开头，则表示该行为注释。sed脚本的注释仅限于行注释，不能跨行。

下面以一个具体的例子来说明sed脚本文件的使用方法。在进行程序设计时，经常会遇到格式编排的问题，良好的格式会有利于用户阅读和理解程序。通过sed命令，可以对一些格式错乱的代码进行编排。例如，下面显示的是一段Java代码：

01 import java.rmi.RemoteException;
02
03 public class HelloClient
04 {
05 /**
06 * @param args
07 */
08
09 public static void main(String[] args)
10 {
11 // TODO Auto-generated method stub
12 HelloProxy ws = new HelloProxy();
13 try
14 {
15 System.out.println(ws.sayHello("dfasdfasdfsd"));
16 }
17 catch (RemoteException e)
18 {
19 // TODO Auto-generated catch block
20 e.printStackTrace();
21 }
22 }
23
24 }

可以发现，上面的代码中，存在着许多空行，另外代码缩进也不整齐。为了整理上面的代码的格式，用户需要执行一些操作，例如删除空行、调整代码缩进字符数，以及删除不必要的注释等。

为了处理以上问题，编写一个sed脚本文件，其名称为java.sed，具体代码如下：

01 #-----------------------------/chapter10/java.sed------------------
02 s/^[[:blank:]][[:blank:]]*/\t/g
03 /^$/d
04 /^[[:blank:]]\/\//d
05 /^[[:blank:]]\/\*/d
06 /^[[:blank:]]\*/d

在上面的代码中，第2行使用s子命令将超过1个空格或者制表符替换为1个制表符；第3行使用d子命令删除所有的空行；第3行删除以1个空格开头，后跟2个斜线/的行；第5行删除以2个空格开头，后跟字符串/*的行；第6行删除以2个空格开头，后跟1个星号的行。

然后用户可以使用以下命令来执行sed脚本文件中的子命令，如下：

[root@linux chapter10]# sed -f java.sed code.java
import java.rmi.RemoteException;
public class HelloClient
{
public static void main(String[] args)
{
HelloProxy ws = new HelloProxy();
try
{
System.out.println(ws.sayHello("dfasdfasdfsd"));
}
catch (RemoteException e)
{
e.printStackTrace();
}
}
}

通过上面的输出结果，可以发现代码中的空行和注释都已经被删除，另外，代码的缩进也已经非常整齐。

在本章最开始的时候，我们已经讲过sed命令有3种工作方式，分别是命令行、sed脚本文件，以及可执行脚本。前面两种方式都已经介绍过了，下面再介绍一下第3种方式。

第3种方式就是将所有的命令都写在一个脚本文件中，然后赋予该脚本文件的可执行权限，这样的话，用户就不需要再在命令行中调用sed命令，而是直接执行脚本文件。

从大体上讲，sed可执行脚本文件的语法与其他的Shell脚本文件非常相似，首先在第1行指定解释器，然后是一系列的代码。但是，在sed可执行脚本中，解释器不再是/bin/bash，而是/bin/sed。然后，脚本文件中的语句不再是Shell命令，而是sed子命令。

下面将上面的java.sed文件进行修改，变成一个可执行的脚本文件，内容如下：

01 #! /bin/sed -f
02
03 s/^[[:blank:]][[:blank:]]*/\t/g
04 /^$/d
05 /^[[:blank:]]\//d
06 /^[[:blank:]]\/\*/d
07 /^[[:blank:]]\*/d

在上面的代码中，第1行指定解释器程序，此处需要使用-f选项，因为下面的子命令都来自脚本文件。第3～7行是一系列子命令。

修改完成之后，使用chmod命令修改文件的权限，如下：

[root@linux chapter10]# chmod +x java.sed

接下来，用户就可以使用以下方式直接执行该脚本文件：

[root@linux chapter10]# ./java.sed code.java
import java.rmi.RemoteException;
public class HelloClient
{
public static void main(String[] args)
{
HelloProxy ws = new HelloProxy();
try
{
System.out.println(ws.sayHello("dfasdfasdfsd"));
}
catch (RemoteException e)
{
e.printStackTrace();
}
}
}

10.4　小结

本节详细介绍了流编辑器sed的使用方法，主要包括sed命令及其语法，sed常用的操作命令，例如查找、替换、删除、插入，以及追加文本等。另外，还介绍了sed的组合命令的使用方法。本章重点在于掌握sed命令的工作原理，以及基本的编辑操作命令。除了以上内容之外，sed还包括许多其他的内容，例如读写文本及缓冲区的管理等。关于这些内容，请读者参考相关技术手册。在下一章中，将介绍另外一个文本处理工具awk的使用方法。

第11章
文本处理利器awk命令

上一章详细介绍了流编辑器sed命令。通过这个命令，用户可以非常方便地处理格式化的文本数据。与sed相比，awk的功能更为强大，甚至称它为一种程序设计语言也为不过。awk特别适合处理文本化的数据，它不仅仅是简单的字符串匹配，而是包含了变量、函数、表达式，以及流程控制等一系列的功能。因此，对于系统管理员来说，掌握好awk命令，可以更加方便地处理文本数据。本章将对awk进行详细介绍。

本章主要涉及的知识点如下所述。

- awk入门：主要介绍awk的功能、工作方式、基本语法，以及工作流程等。

- awk的模式匹配：主要介绍awk命令中的匹配模式，包括BEGIN模式、END模式、关系表达式、正则表达式，以及混合模式等。

- 变量：主要介绍awk程序中的变量的定义方法、常用的系统变量，以及使用系统变量获取记录和字段的值。

- 运算符和表达式：主要介绍各种运算符，包括算术运算符、赋值运算符、条件运算符、逻辑运算符，以及关系运算符等。

- 函数：主要介绍awk内置的常用字符串函数和算术函数。

- 数组：主要介绍数组的定义和引用方法，以及数组的遍历等。

- 流程控制：主要介绍awk中的if、while、do…while、break、continue、next，以及exit等流程控制语句的使用方法。

- awk程序的格式化输出：主要介绍awk程序中的输出语句，包括print及printf等。

- awk的程序与Shell的交互：主要介绍通过管道和system函数实现awk程序与Shell的交互。

11.1　awk入门
awk是一种非常强大的数据处理工具，其本身可以称为一种程序设计语言，因而具有其他程序设计语言所共同拥有的一些特征，例如变量、函数，以及表达式等。通过awk，用户可以编写一些非常实用的文本处理工具。本节将介绍awk的基础知识。

11.1.1　awk的功能
awk的名称来自于其三位开发者的名字的缩写，分别为阿尔佛雷德·艾侯（Alfred Aho）、彼得·杰·温伯格（Peter Jay Weinberger）和布莱恩·威尔森·柯林汉（Brian Wilson Kernighan）。awk大约在1977年开发完成，然后在1979年作为UNIX第7版的一部分发布。后来，awk衍生出许多版本，其中主要的有兼容POSIX标准的mawk，以及同时兼容POSIX和GNU标准的gawk。目前，在绝大部分的Linux发行版中，默认安装的是gawk，即GNU awk。

awk是Linux以及UNIX环境中现有的功能最强大的数据处理工具。简单地讲，awk是一种处理文本数据的编程语言。awk的设计使得它非常适合于处理由行和列组成的文本数据。而在Linux或者UNIX环境中，这种类型的数据是非常普遍的。

除此之外，awk还是一种编程语言环境，它提供了正则表达式的匹配、流程控制、运算符、表达式、变量，以及函数等一系列的程序设计语言所具备的特性。它从C语言等中获取了一些优秀的思想。awk程序可以读取文本文件，对数据进行排序，对其中的数值执行计算已经生成报表等。

注意：在许多Linux发行版中，/bin/awk命令是/bin/gawk命令的符号链接。

11.1.2　awk命令的基本语法
awk命令的基本语法如下：

awk pattern { actions }

在上面的语法中，pattern表示匹配模式，actions表示要执行的操作。以上语法表示当某个文本行符合pattern指定的匹配规则时，执行actions所执行的操作。在上面的语法中， pattern和actions都是可选的，但是两者必须保证至少有一个。如果省略匹配模式pattern，则表示对所有的文本行执行actions所表示的操作；如果省略actions，则表示将匹配成功的行输出到屏幕。

注意：actions前面的左大括号需与pattern位于同一行中。

awk命令的匹配模式非常灵活，可以是以下任意一种。

- 正则表达式：需要使用斜线将正则表达式包围起来。

- 关系表达式：例如x>34，判断变量x与34是否存在大于的关系。

- 模式1，模式2：指定一个行的范围。该语法不能包括BEGIN和END模式。

- BEGIN：让用户指定在第1行文本被处理之前所发生的操作，通常可在这里设置全局变量。

- END：让用户在最后1行文本被读取之后发生的操作。

关于awk的匹配模式，将在11.2节中详细介绍。

awk命令的操作由一个或者多个命令、函数或者表达式组成，它们之间由换行符或者分号隔开，并且位于大括号内。通常情况下，有以下4种操作：

- 变量或者数组赋值。

- 输出命令，例如printf或者print。

- 内置函数。

- 流程控制语句，例如if、while或者for等。

注意：awk命令的语法隐含一个条件结构，即如果符合匹配规则，则执行后面的操作。

11.1.3　awk的工作流程

对于初学者来说，搞清楚awk的工作流程非常重要。只有在掌握了awk的工作流程之后，才有可能用awk来处理数据。在awk处理数据时，它会反复执行以下4个步骤：

（1）自动从指定的数据文件中读取行文本。

（2）自动更新awk的内置系统变量的值，例如列数变量NF、行数变量NR、行变量$0，以及各个列变量$1、$2等。

（3）依次执行程序中所有的匹配模式及其操作。

（4）当执行完程序中所有的匹配模式及其操作之后，如果数据文件中仍然有末读取的数据行，则返回到第（1）步，重复执行（1）～（4）的操作。

awk命令的工作流程如图11-1所示。

图11-1　awk工作流程

注意：awk会自动逐行读取数据文件的所有文本行，无需用户自己来处理这个循环操作。

11.1.4　执行awk程序的几种方式

与上一章介绍的sed工具相似，用户也可以通过3种方式来执行awk程序，分别是命令行、awk脚本，以及可执行文件。当然，单独在命令行中使用awk命令的机会比较少，更多的是在Shell脚本中使用awk命令。

为了学习awk的使用方法，用户需要首先准备一个数据文件，其名称为scores.txt，内容如下：

[root@linux chapter11]# cat scores.txt
John 85 92 79 87
Nancy 89 90 73 82
Tom 81 88 92 81
Kity 79 65 83 90
Han 92 89 80 83
Kon 88 76 85 97

在上面的内容中，第1列为学生姓名，后面4列为学生成绩。接下来，我们将以该文件为例，来介绍执行awk程序的几种方式。

1．通过命令行执行awk程序

在这种情况下，用户可以像执行其他Shell命令一样来执行awk程序，其语法如下：

awk 'program-text' datafile

在上面的语法中，program-text表示要执行的awk程序语句，必须使用单引号将其引用起来，以防止Shell解释该语句。datafile表示要处理的数据文件。

【例11-1】演示通过命令行执行awk程序的方法，具体命令如下：

[root@linux chapter11]# awk '{ print }' scores.txt
John 85 92 79 87
Nancy 89 90 73 82
Tom 81 88 92 81
Kity 79 65 83 90
Han 92 89 80 83
Kon 88 76 85 97

在上面的命令中，awk的程序语句非常简单，在大括号中只有一条print语句，表示输出scores.txt文件中所有的文本行。

尽管这样非常方便，但是如果程序语句比较多的话，在命令行中直接输入是一件非常头疼的事情。因此，如果需要执行较长的awk语句，可以使用下面介绍的awk脚本。

2．执行awk脚本

在awk程序语句比较多的情况下，用户可以将所有的语句写在一个脚本文件中，然后通过awk命令来解释并执行其中的语句。awk调用脚本的语法如下：

awk -f program-file file ..

在上面的语法中，-f选项表示从脚本文件中读取awk程序语句，program-file表示awk脚本文件名称，file表示要处理的数据文件。

【例11-2】演示编写并执行awk程序的方法，其中awk脚本代码如下：

01 #---------------------------/chapter11/ex11-2.awk------------------
02 # 输出所有的行
03 { print }

上面的代码实际上是将【例11-1】中放在命令行中的程序写入到一个专门的脚本文件中。然后，用户就可以通过以下命令执行该脚本：

[root@linux chapter11]# awk -f ex11-2.awk scores.txt
John 85 92 79 87
Nancy 89 90 73 82
Tom 81 88 92 81
Kity 79 65 83 90
Han 92 89 80 83
Kon 88 76 85 97

可以发现，【例11-2】的执行结果与【例11-1】完全相同。

注意：awk脚本中不能含有除awk语句之外的其他命令或者语句，例如Shell命令等。

3．可执行脚本文件

在上面介绍的两种方式中，用户都需要输入awk命令才能执行程序。除此之外，用户还可以通过类似于Shell脚本的方式来执行awk程序。在这种方式中，需要在awk程序中指定命令解释器，并且赋予脚本文件的可执行权限。其中指定命令解释器的语法如下：

#!/bin/awk -f

以上语句必须位于脚本文件的第一行。然后用户就可以通过以下命令执行awk程序：

awk-script file

其中，awk-script为awk脚本文件名称，file为要处理的文本数据文件。

【例11-3】演示以可执行脚本文件的方式执行awk程序的方法，代码如下：

01 #----------------------------/chapter11/ex11-3.sh------------------
02 #! /bin/awk -f
03
04 # 输出所有的行
05 { print }

在上面的代码中，第2行指定解释器为/bin/awk，第5行为awk程序语句。

以上代码的执行结果如下；

[root@linux chapter11]# chmod +x ex11-3.sh
[root@linux chapter11]# ./ex11-3.sh scores.txt
John 85 92 79 87
Nancy 89 90 73 82
Tom 81 88 92 81
Kity 79 65 83 90
Han 92 89 80 83
Kon 88 76 85 97

在上面的命令中，第1个命令将执行权限赋予ex11-3.sh，第2个命令执行ex11-3.sh脚本文件。可以发现，【例11-3】的执行结果与前面两个例子完全相同。

11.2　awk的模式匹配

在awk中，匹配模式处于非常重要的地位，它决定着匹配模式后面的操作会影响到哪些文本行。awk中的匹配模式主要包括关系表达式、正则表达式、混合模式，BEGIN模式，以及END模式等，本节将对这些模式匹配进行详细地介绍。

11.2.1　关系表达式
awk提供了许多关系运算符，例如大于＞、小于＜或者等于==等，关于关系运算符的详细使用方法，将在11.4节中详细介绍。awk允许用户使用关系表达式作为匹配模式，当某个文本行满足关系表达式时，将会执行相应的操作。

【例11-4】演示如何使用关系表达式作为awk命令的匹配模式，代码如下：

01 #---------------------------/chapter11/ex11-4.sh------------------
02 #! /bin/bash
03
04 # 打印第 2 列的成绩超过 80 的行
05 result='awk '$2 > 80 { print }' scores.txt'
06
07 echo "$result"

在上面的代码中，第5行的awk语句中$2>80为关系表达式，表示第2列的成绩超过80分，其中变量$2为列变量，表示第2列的值。大括号中的print语句表示打印匹配成功的行。

该例的执行结果如下：

[root@linux chapter11]# ./ex11-4.sh
John 85 92 79 87
Nancy 89 90 73 82
Tom 81 88 92 81
Han 92 89 80 83
Kon 88 76 85 97

从上面的执行结果可以得知，第2列的值为79的文本行已经被过滤掉。

注意：变量$2为列变量，表示引用第2列的值。关于列变量的使用方法，将在11.3节中详细介绍。print为awk语句中的操作，表示将符合条件的行输出到屏幕。

11.2.2　正则表达式
awk支持以正则表达式作为匹配模式，与sed一样，用户需要将正则表达式放在两条斜线之间，其基本语法如下：

/regular_expression/

【例11-5】演示使用正则表达式作为匹配模式的方法，代码如下：

01 #----------------------------/chapter11/ex11-5.sh------------------
02 #! /bin/bash
03
04 # 输出以字符 T 开头的行
05 result='awk '/^T/ { print }' scores.txt'
06
07 echo "$result"

在上面的代码中，第5行的awk语句中的正则表达式为/^T/，表示筛选以字符T开头的文本行。

以上代码的执行结果如下：

[root@linux chapter11]# ./ex11-5.sh
Tom 81 88 92 81

【例11-6】使用更为复杂的正则表达式来充当匹配模式，代码如下：

01 #----------------------------/chapter11/ex11-6.sh------------------
02 #! /bin/bash
03
04 # 输出以 Tom 或者 Kon 开头的行
05 result='awk '/^(Tom|Kon)/ { print }' scores.txt'
06
07 echo "$result"

其中，第5行的awk语句中的正则表达式为/^(Tom|Kon)/，表示匹配所有以Tom或者Kon开头的文本行。

以上代码的执行结果如下：

[root@linux chapter11]# ./ex11-6.sh
Tom 81 88 92 81
Kon 88 76 85 97

11.2.3　混合模式
awk不仅支持单个的关系表达式或者正则表达式作为模式，还支持使用逻辑运算符&&、||或者!将多个表达式组合起来作为一个模式。其中，&&表示逻辑与，||表示逻辑或，!表示逻辑非，关于这3个运算符的详细用法，将在11.4节中介绍。

【例11-7】演示awk命令中混合模式的使用方法，代码如下：

01 #----------------------------/chapter11/ex11-7.sh------------------
02 #! /bin/bash
03
04 #混合模式
05 result='awk '/^K/ && $2 > 80 { print }' scores.txt'
06
07 echo "$result"

在上面的代码中，第5行的模式由一个正则表达式和一个关系表达式通过&&运算符连接起来，表示同时匹配以字符K开头以及第2列的值大于80的行。

以上代码的执行结果如下：

[root@linux chapter11]# ./ex11-7.sh
Kon 88 76 85 97

在scores.txt文件中，以字符K开头的一共有两行，其中一个文本行的第2列的值为79，而另外一行的第2列的值为88。通过上面的执行结果，可以发现，【例11-7】只输出了第2列的值为88的一行，这意味着代码第5行中的混合模式已经生效。

注意：awk的混合模式可以将正则表达式和关系表达式混合在一起使用，而不仅仅是单纯使用正则表达式或者关系表达式。

11.2.4　区间模式
awk还支持一种区间模式，也就是说通过模式可以匹配一段连续的文本行。区间模式的语法如下：

pattern1, pattern2

其中，pattern1和pattern2都是前面所讲的匹配模式，可以是关系表达式，也可以是正则表达式等。当然，也可以是这些模式的混合形式。

【例11-8】在awk命令中使用区间模式匹配一段连续的文本行，代码如下：

01 #----------------------------/chapter11/ex11-8.sh------------------
02 #! /bin/bash
03
04 # 区间模式
05 result='awk '/^Nancy/, $2==92 { print }' scores.txt'
06
07 echo "$result"

在上面的代码中，第5行的awk语句中使用了区间模式来匹配文本行，其中第1个模式为正则表达式，表示匹配以Nancy开头的文本行。第2个模式为关系表达式，表示第2列的值为92的文本行。因此，上面的代码输出包含匹配前后两个模式在内的文本行之间的所有的行。

以上代码的执行结果如下：

[root@linux chapter11]# ./ex11-8.sh
Nancy 89 90 73 82
Tom 81 88 92 81
Kity 79 65 83 90
Han 92 89 80 83

注意：在使用区间模式时，一定要注意前后的边界。如果有多个行符合匹配模式，则awk会匹配第一次符合要求的行。

11.2.5　BEGIN模式

BEGIN模式是一种特殊的内置模式，其成立的时机为awk程序刚开始执行，但是又尚未读取任何数据之前。因此，该模式所对应的操作仅仅被执行一次，当awk读取数据之后， BEGIN模式便不再成立。所以，用户可以将与数据文件无关，而且在整个程序的生命周期中，只需执行一次的代码放在BEGIN模式对应的操作中。

【例11-9】演示awk命令中BEGIN模式的使用方法，代码如下：

01 #----------------------------/chapter11/ex11-9.sh------------------
02 #! /bin/awk -f
03
04 # 通过 BEGIN 模式输出字符串
05 BEGIN { print "Hello! World." }

在上面的代码中，第2行指定命令解释器为awk，第5行指定模式为BEGIN，其所对应的操作为print语句。

以上代码的执行结果如下：

[root@linux chapter11]# ./ex11-9.sh
Hello! World.

对于【例11-9】，有几点需要特别指出。首先，【例11-9】是通过可执行脚本文件执行awk程序的；其次，awk程序中只包含BEGIN模式，在这种情况下，awk不需要读取任何数据行，因此，我们并没有指定数据文件。

通常情况下，用户可以将一些初始化的操作放在BEGIN模式的操作中，例如自定义列分隔符、行分隔符，以及初始化变量等，如下面的例子所示。

【例11-10】在BEGIN模式中进行变量的初始化操作。代码如下：

01 #---------------------------/chapter11/ex11-10.sh------------------
02 #! /bin/awk -f
03
04 # 通过 BEGIN 模式初始化变量
05 BEGIN {
06 FS="[\t:]"
07 RS="\n"
08 count=30
09 print "The report is about students's scores."
10 }

在上面的代码中，第5行是BEGIN模式的开始，在大括号里面是一系列的操作，其中第6行指定列分隔符为制表符，第7行指定行分隔符为换行符，第8行定义了一个名称为count的变量，第9行输出一条信息。

以上代码的执行结果如下：

[root@linux chapter11]# ./ex11-10.sh
The report is about students's scores.

由于【例11-10】只有一条输出语句，所以只输出一行信息。

注意：对于只包含BEGIN模式的awk程序，awk不会打开任何数据文件。

11.2.6　END模式

END模式是awk的另外一种特殊模式，该模式成立的时机与BEGIN模式恰好相反，它是在awk命令处理完所有的数据，即将退出程序时成立，在此之前，END模式并不成立。无论数据文件中包含多少行数据，在整个程序的生命周期中，该模式所对应的操作只被执行一次。因此，一般情况下，用户可以将许多善后工作放在END模式对应的操作中。

【例11-11】演示awk程序中END模式的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-11.sh------------------
02 #! /bin/awk -f
03
04 # 输出报表头
05 BEGIN {
06 print "scores report"
07 print "================================="
08 }
09
10 # 输出数据
11 { print }
12
13 # 报表完成
14 END {
15 print "================================"
16 print "printing is over"
17 }

在上面的代码中，第5～8行是BEGIN模式以及操作，用于数报表的头部。第11行输出报表数据。第14～17行是END模式及其操作，用于输出提示信息。

以上代码的执行结果如下：

[root@linux chapter11]# ./ex11-11.sh scores.txt
scores report
=================================
John 85 92 79 87
Nancy 89 90 73 82
Tom 81 88 92 81
Kity 79 65 83 90
Han 92 89 80 83
Kon 88 76 85 97
================================
printing is over

11.3　变量

与其他程序设计语言一样，awk本身支持变量的相关操作，包括变量的定义和引用，以及参与相关的运算等。此外，还包含了许多内置的系统变量。本节将介绍awk中的变量的相关知识。

11.3.1　变量的定义和引用

变量是用来存储数据。变量由变量名和值两部分组成，其中变量名是用来实现变量值的引用的途径，而变量值则是内存空间中存储的用户数据。

awk的变量名只能包括字母、数字和下划线，并且不能以数字开头。例如abc、a_、_z及a123都是合法的变量名，而123abc则是非法的变量名。另外，awk的变量名是区分大小写的，因此，X和x分别表示不同的变量。

在awk中定义变量的方法非常简单，只要给出一个变量名并且赋予适当的值即可。awk中的变量类型分为两种，分别为字符串和数值。但是在定义awk变量时，无需指定变量类型，awk会根据变量所处的环境自动判断。如果没有指定值，数值类型的变量的默认值为0，字符串类型的变量的默认值为空串。

【例11-12】演示awk变量的定义、赋值，以及引用的方法。为了避免指定数据文件，本例的变量定义和赋值都在BEGIN模式中。代码如下：

01 #---------------------------/chapter11/ex11-12.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 定义变量 x
06 x=3
07 # 定义变量 message
08 message="Hello " "world"
09 # 输出变量
10 print x
11 print message
12 }

在上面的代码中，第6行定义了变量x，并将数值3赋给变量x。第8行定义变量message，并将两个字符串连接后的结果赋给变量message。第10和第11行分别输出这两个变量的值。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-12.sh
3
Hello world

11.3.2　系统内置变量
akw提供了许多非常实用的系统变量，例如字段变量、字段数变量，以及记录数变量等。表11-1列出了常用的系统变量。

表11-1　常用的awk系统变量

变　量 | 说　明
$0 | 记录变量，表示当前正在处理的记录
$n | 字段变量，其中n为整数，且n大于1。表示第n个字段的值
NF | 整数值，表示当前记录（变量$0所代表的记录）的字段数
NR | 整数值，表示awk已经读入的记录数
FILENAM | 表示正在处理的数据文件的名称
FS | 字段分隔字符，默认值是空格或者制表符
RS | 记录分隔符，默认值是换行符

与关系型数据库相对应，awk所要处理的文件也是由许多记录（record）组成。所谓记录，是用来描述某个具体事物的各个方面的。而记录是由多个字段（field）组成，每个字段描述某个事物的一个具体的方面。在本章中，我们引入了记录和字段的概念，其主要原因在于尽管awk是逐行读取数据的，但是在处理数据时却是以记录为单位的，而且在某些情况下，记录可以跨越多行。因此，读者应该搞清楚文本行、记录，以及字段等概念的区别。

在表11-1中，变量$0表示awk正在处理的记录，该变量将整个记录作为一个字符串来处理。变量$n所表示的是当前记录的第n个字段的值，例如$1表示第1个字段的值，$2表示第2个字段的值，依此类推。

NF代表当前记录的字段数（number of field），在某些特殊情况下，数据文件的各条记录的字段数并不一定完全相同，所以对于所有的记录来说，该变量的值也不一定相同。NR代表awk已经读取的记录的数量。

FS是一个比较常用的变量，用来表示字段分隔符（field seperator）。其默认值为空格或者制表符，这也是最常见的分隔字段的方式。但是在某些情况下，例如在/etc/passwd文件中，字段的分隔符是冒号。为了处理这种情况，用户可以使用FS变量自定义当前数据文件的字段分隔符，awk会在处理数据文件之前参考该变量的值来分隔记录中的各个字段。

记录分隔符（record seperator）变量RS用来自定义数据文件中记录的分隔符。在默认情况下，awk会将换行符\n作为记录分隔符，因此，绝大部分的数据文件都是一行包含一条记录。但是，如果某些文件中的一条记录跨越多行，则不能使用默认的分隔符。此时，用户可以使用RS变量自定义记录分隔符。例如，可以将记录分隔符指定为空行。

注意：awk会在读取数据行之前通过FS及RS确定记录和字段的分隔符，然后进行记录和字段的分隔。每读取一条记录之后，变量$0以及$1、$2等变量都会自动更新。

11.3.3　记录分隔符和字段分隔符

对于文本形式的数据来说，记录和字段的分隔非常重要。因此，在本节中对于这两个分隔符专门进行介绍。

记录分隔符使用系统变量RS来指定，如果没有指定，则默认为换行符\n。在大多数的情况下，这种方式都是有效的，因为awk每次都是从数据文件中只读取一行数据进行处理。所以正常情况下，一行数据就代表一条记录。但是，这并不意味着总是这样，有些数据文件中用多行文本来描述一条记录，如下：

Xiong, Yi
Can live xPlane imaging of the in-plane view...?
Pentacyclic hemiacetal sterol with antifouling...

Song Lin
Incidence of brain injuries in premature infants...
Identification of Ichthyotoxic Microalgae Species...
Rifampicin and Parkinson's disease

Li, Zhaohui
Frequency-locked multicarrier generator based...
Behavioral Stress Fails to Accelerate the Onset...
…

在上面的文件中，每条记录由作者及其发表的论文组成，首先是作者姓名，然后依次是所发表的论文的标题，每个字段都是独占一行。记录之间用空行隔开。

对于上面文件，如果使用默认的记录分隔符，则会将一条完整的记录拆分开来，如下面的例子所示。

【例11-13】使用默认的记录分隔符处理数据，代码如下：

01 #---------------------------/chapter11/ex11-13.sh------------------
02 #! /bin/awk -f
03
04 {
05 # 输出每行数据
06 print
07 print "=========================================="
08 }

在上面的代码中，第6行的作用是输出awk读取的数据行，第7行打印一行横线作为屏幕上记录之间的分隔线。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-13.sh articles.txt
Xiong, Yi
==========================================
Can live xPlane imaging of the in-plane view...?
==========================================
Pentacyclic hemiacetal sterol with antifouling...
==========================================
==========================================
Song Lin
==========================================
Incidence of brain injuries in premature infants...
==========================================
Identification of Ichthyotoxic Microalgae Species...
==========================================
Rifampicin and Parkinson's disease
==========================================
…

从上面的执行结果可以得知，【例11-13】将一条完整的记录拆分成了多条记录，即原本是字段的数据现在也作为记录来显示了，显然，这种结果并不是用户所希望看到的。

为了解决这个问题，用户可以根据数据文件的实际情况指定记录分隔符。在articles.txt文件中，记录分隔符为空行。下面的例子就通过指定记录分隔符，避免了记录分隔错误的问题。

【例11-14】本例通过自定义记录分隔符解决了【例11-13】中出现的问题，代码如下：

01 #---------------------------/chapter11/ex11-14.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 定义记录分隔符
06 RS=""
07 }
08
09 {
10 print
11 print "=========================================="
12 }

在上面的代码中，第6行定义记录分隔符为空串，此时，awk会将空白行作为记录分隔符。前面已经讲过，awk会在读取数据之前执行BEGIN模式所对应的操作，因此，把RS变量的定义放在BEGIN模式的操作中是最为恰当的。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-14.sh articles.txt
Xiong, Yi
Can live xPlane imaging of the in-plane view...?
Pentacyclic hemiacetal sterol with antifouling...
==========================================
Song Lin
Incidence of brain injuries in premature infants...
Identification of Ichthyotoxic Microalgae Species...
Rifampicin and Parkinson's disease
==========================================
Li, Zhaohui
Frequency-locked multicarrier generator based...
Behavioral Stress Fails to Accelerate the Onset...
==========================================

从上面的执行结果可以得知，在指定列分隔符之后，程序已经正确处理文件中的记录了。

注意：当将记录分隔符指定为空字符串时，awk会将多个连续的空白行看做一个单一的记录分隔符，也就是说，awk不会再将空白行作为一条空白记录。另外，awk也会忽略文件开头和末尾处的空白行。

接下来再讨论一下字段分隔符。在默认情况下，awk会将空格或者制表符作为字段分隔符。但是，在某些情况下，例如在上面的articles.txt文件中，每一行为一个字段。因此，在处理这种类型的数据时，必须自己来指定字段分隔符。

【例11-15】演示如何通过自定义记录分隔符和字段分隔符来处理articles.txt文件，代码如下：

01 #---------------------------/chapter11/ex11-15.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 定义记录分隔符
06 RS=""
07 # 定义字段分隔符
08 FS="\n"
09 }
10 # 输出第 1 个字段
11 { print $1 }

在上面的代码中，第6行指定记录分隔符为空字符串，第8行指定字段分隔符为换行符。第11行输出每条举例的第1个字段的值。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-15.sh articles.txt
Xiong, Yi
Song Lin
Li, Zhaohui

从上面的执行结果可以得知，程序已经正确输出了第1列的值。

11.3.4　记录和字段的引用

在awk中，用户可以使用系统变量来引用数据文件中的记录和字段。与关系型数据库不同，awk每次只是读取一行文本，因此，在awk程序中记录和字段的引用都是针对当前记录来说的。

变量$0表示正在读取的当前的记录，该变量将整个记录作为一个字符串来处理。因此，如果在程序中输出该变量的值。用户可以通过下面的例子来得到验证。

【例11-16】在awk脚本中引用当前记录，代码如下：

01 #---------------------------/chapter11/ex11-16.sh------------------
02 #! /bin/awk -f
03
04 # 通过 $0 引用整个记录
05 { print $0 }

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-16.sh scores.txt
John 85 92 79 87
Nancy 89 90 73 82
Tom 81 88 92 81
Kity 79 65 83 90
Han 92 89 80 83
Kon 88 76 85 97

从上面的执行结果可以得知，变量$0的值正是当前的记录。

如果想单独引用某个具体的字段，可以使用系统变量$n来实现，如下面的例子所示。

【例11-17】演示如何通过系统变量来输出具体的字段，代码如下：

01 #---------------------------/chapter11/ex11-17.sh------------------
02 #! /bin/awk -f
03
04 {
05 # 输出第 1 个字段以及第 2~5 个字段的和
06 print $1, $2+$3+$4+$5
07 }

在上面的代码中，第6行输出第1个字段，以及第2～5个字段的和。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-17.sh scores.txt
John 343
Nancy 334
Tom 342
Kity 317
Han 344
Kon 346

注意：对于用户来说，变量$0并不是只读的，用户可以自己把数据存储到$0变量中， awk仍然会自动分隔成多个字段。

11.4　运算符和表达式
awk是一种编程语言环境，因此，它也支持常用的运算符及表达式，例如算术运算、逻辑运算，以及关系运算等。本节将对awk所支持的常用的运算符及表达式进行介绍。

11.4.1　算术运算符
awk支持常用的算术运算，这一点与其他程序设计语言基本相同。如表11-2所示，列出了awk所支持的算术运算符。

表11-2　awk支持的算术运算符

运　算　符 | 说　明 | 举　例
+ | 加法运算 | 1+2表示计算1和2的和
- | 减法运算 | 82-2表示计算82和2的差
* | 乘法运算 | 2*5表示计算2和5的积
/ | 除法运算 | 6/3表示计算6和2的商
% | 求模运算 | 5/2表示计算5除以2的余数
^ | 指数运算 | 2^3表示计算2的3次方

【例11-18】演示awk各种算术运算的操作，代码如下：

01 #---------------------------/chapter11/ex11-18.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 除法运算
06 x=5/2
07 print x
08 # 求模运算
09 x=5%2
10 print x
11 # 指数运算
12 x=2^3
13 print x
14 }

在上面的代码中，第6行计算表达式5除以2的商，第9行计算表达式5除以2的余数，第12行计算2的3次方。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-18.sh
2.5
1
8

在上面的执行结果中，第1行是5除以2的商，可以得知，awk支持浮点数。第2行是5除以2的余数，其值为1。第3行是2的3次方，其值为8。

11.4.2　赋值运算符

如表11-3所示，列出了awk中常用的赋值运算符。

表11-3　awk支持的赋值运算符

运　算　符 | 说　明 | 举　例
= | 赋值运算 | x=5表示将数值5赋给变量x
+= | 复合赋值运算，表示将前后两个数值相加后的和赋给前面的变量 | x+=5表示先将x的值与5相加，然后将和赋给变量x，等价于表达式x=x+5
-= | 复合赋值运算，表示将前后两个数值相减后的查赋给前面的变量 | x-=5表示先将变量x的值减去5，然后将得到的差赋给变量x，等价于表达式x=x-5
*= | 复合赋值运算，表示前后两个数值的乘积赋给前面的变量 | x*=5表示先将变量x的值乘以5，然后将得到的乘积赋给变量x
/= | 复合赋值运算，表示将前后两个数值的商赋给前面的变量 | x/=5表示先将变量x除以5，然后将得到的商赋给变量x
%= | 复合赋值运算，表示将前面的数值除以后面的数值所得的余数赋给前面的变量 | x%=5表示将变量x与5相除后的余数赋给变量x
^= | 复合运算符，表示将前面的数值的后面数值次方赋给前面的变量 | x^=3表示将变量x的3次方赋给变量x

在表11-3中，除了赋值运算符=之外，其他的都是复合赋值运算符，也就是先执行某个运算，然后将运算结果赋给前面的变量。

【例11-19】演示赋值运算符的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-19.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 简单赋值
06 x=4
07 print x
08 # 求和赋值
09 x+=10
10 print x
11 # 乘积赋值
12 x*=2
13 print x
14 # 幂运算赋值
15 x^=2
16 print x
17 }

在上面的代码中，第6行是简单赋值语句，将整数值4赋给变量x。第9行是加法复合赋值运算，将变量x的值先加上10，然后赋给变量x，此时变量x的值为14。第12行是乘法复合赋值运算，先将变量x的值乘以2，然后赋给变量x，此时变量x的值为28。第15行是幂赋值复合运算，先计算变量x的2次方，然后将计算结果赋给变量x，最后变量x的值为784。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-19.sh
4
14
28
784

可以发现，上面程序的执行结果与前面分析的完全一致。

11.4.3　条件运算符
awk中的条件运算符只有一个，其语法如下：

expression?value1:value2

这是一个三目运算符，当表达式expression的值为真时，返回值为value1；否则，返回值为value2。

【例11-20】演示条件运算符的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-15.sh------------------
02 #! /bin/awk -f
03
04 {
05 # 如果大于 90 ，输出 A ，否则输出 B
06 grade=($2>90?"A":"B")
07 print grade
08 }

在上面的代码中，第6行中的条件运算符表示当第2个字段的值大于90时，变量grade的值为A；否则，变量grade的值为B。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-20.sh scores.txt
B
B
B
B
A
B

11.4.4　逻辑运算符
awk支持3种逻辑运算，分别为逻辑与、逻辑或和逻辑非，如表11-4所示，列出了这3种运算的运算符。

表11-4　awk支持的逻辑运算符

运　算　符 | 说　明 | 举　例
&& | 逻辑与，当前后两个表达式的值全部为真时，其运算结果才为真，反之则为假 | 1>2&&3>2的值为假
|| | 逻辑或，前后两个表达式只要有一个为真，则其运算结果为真。当两个表达式的值都为假时，其运算结果才为假 | •1>2||3>2的值为真
! | 逻辑非，当表达式的值为真时，其运算结果为假；当表达式的值为假时，其运算结果为真 | !(1>2)的值为真

【例11-21】演示&&运算符的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-21.sh------------------
02 #! /bin/awk -f
03
04 # 输出所有的字段的值都大于 80 的记录
05 $2 > 80 && $3 > 80 && $4 > 80 && $5 >80 {
06 print
07 }

在上面的代码中，第5行的匹配模式为第2～5个字段的值都大于80。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-21.sh scores.txt
Tom 81 88 92 81

11.4.5　关系运算符
awk支持常用的关系运算符，例如大于、小于，以及等于等，如表11-5所示，列出了awk中常用的关系运算符。

表11-5　awk中的关系运算符

运算符 | 说　明 | 举　例
> | 大于 | 5>2的值为真
>= | 大于或者等于 | 8>=8的值为真
< | 小于 | 8<12的值为假
<= | 小于或者等于 | 4<=7的值为真
== | 等于 | 8==8的值为真
!= | 不等于 | 1!=3的值为真
～ | 匹配运算符 | $1～/^T/表示匹配第1个字段以字符T开头的记录
!～ | 不匹配运算符 | $1 !～/a/表示匹配第1个字段不含有字符a的记录

在表11-5中，匹配运算符～和不匹配运算符!～是awk所特有的关系运算符，其语法如下：

A ~ B

和

A !~ B

在上面的语法中，A表示一个字符串，B表示一个正则表达式。匹配运算符表示判断字符串A中是否含有符合正则表达式B所表达的子字符串；不匹配运算符表示字符串A中是否不含有符合正则表达式B所表达的子字符串。

【例11-22】演示匹配运算符的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-22.sh------------------
02 #! /bin/awk -f
03
04 # 匹配第 1 个字段以字符 K 开头的记录
05 $1 ~ /^K/ { print }

在上面的代码中，第5行的匹配模式为$1～/^K/，表示第1个字段是以字符K开头的记录。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-22.sh scores.txt
Kity 79 65 83 90
Kon 88 76 85 97

11.4.6　其他运算符

除了前面介绍的运算符之外，awk还支持其他的一些运算符，例如正号+、负号-、自增++，以及自减--等。这些运算符的使用方法与其他语言的使用方法完全相同，不再举例说明。

注意：在awk中，各运算符的优先级与其他的程序设计语言相同，不再详细说明。

11.5　函数
awk提供函数的支持。awk本身提供了许多系统函数，例如字符串函数以及算术函数。另外，用户还可以自定义函数。由于自定义函数使用非常少，所以本节主要介绍awk提供的系统函数。

11.5.1　字符串函数

字符串是awk中的两大类型之一，awk提供了一些常见的字符串处理函数，例如index()、length()，以及match()等，如表11-6所示，列出了常用的字符串函数。

表11-6　awk常用的字符串函数

函数 | 说　明
index(string1, string2) | 返回string2在string1中的位置。如果string2在string1中出现多次，则返回第1次出现的位置；如果string1不包含string2，则该函数返回0
length(string) | 返回字符串string的长度
match(string, regexp) | 在字符串string中搜索符合正则表达式regexp的子字符串。如果有多个匹配的字符串，则以第1个匹配的字符串为准。该函数的返回值体现在系统变量RSTART和RLENGTH中
split(string,array,seperator) | 根据指定的分隔符seperator，将字符串string分隔成多个字段，并存储到数组array中
sub(regexp,replacement,string) | 将字符串string中的第1个符合正则表达式regexp的子字符串替换为replacement
gsub(regexp,replacement,string) | 将字符串string中所有符合正则表达式regexp的子字符串全部替换为replacement
substr(string,start,[length]) | 从字符串string中截取指定的子串，起始位置为start，长度为length。如果省略length，则表示从start开始一直截取到字符串结束

下面分别对表11-6列出的函数进行详细介绍。

1. index(string1, string2)

该函数用来定位字符串string2在字符串string1中出现的位置。如果出现多次，则返回第1次出现的位置；如果string1不包含string2，则该函数返回0。该函数区分字母的大小写，用户在使用时一定要注意。

【例11-23】演示index()函数的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-23.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 输出子串在父串中出现的位置
06 print index("Hello,world.","world")
07 }

在上面的代码中，第6行的index()函数搜索字符串world在字符串Hello,world中出现的位置。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-23.sh
7

2. length(string)

该函数的返回值为整数，表示字符串string的长度。

【例11-24】演示length()函数的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-24.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 输出字符串的长度
06 print length("Hello, world.")
07 }

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-24.sh
13

3. match(string, regexp)

该函数的第1个参数为字符串，第2个参数为正则表达式，其功能是在字符串string中搜索匹配正则表达式的子串。用户可以通过系统变量RSTART和RLENGTH来获取相关的返回值。其中，RSTART用来返回正则表达式匹配的子串在父串中出现的位置，如果匹配不成功，则返回0。RLENGTH用来返回正则表达式匹配的子串的长度，如果匹配不成功，则返回-1。

【例11-25】演示match()函数的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-25.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 通过正则表达式搜索子串
06 match("Hello, world.",/o/)
07 print RSTART, RLENGTH
08 }

在上面的代码中，第6行的match()函数中的正则表达式为/o/，表示匹配字符o。第7行分别输出变量RSTART和RLENGTH的值。

该程序的执行结果如下；

[root@linux chapter11]# ./ex11-25.sh
5 1

其中，第1个数字5表示正则表达式/o/匹配的子串在字符串Hello, world中第1次出现的位置为5。第2个数字1表示正则表达式所匹配的子串的长度为1。

4. split(string, array, seperator)

该函数的功能是将一个字符串根据指定的分隔符拆分成一个数组。其中第1个参数为要拆分的字符串，第2个参数是用来存储拆分结果的数组，第3个参数用来指定分隔符，分隔符可以使用正则表达式来表达。

【例11-26】演示split()函数的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-26.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 string="5P12p89"
06 # 使用分隔符 P 或者 p 分隔字符串
07 split(string,arr,/[Pp]/)
08 # 输出第 1~3 个数组元素
09 print arr[1]
10 print arr[2]
11 print arr[3]
12 }

在上面的代码中，第7行的split()函数中的分隔符为正则表达式/[Pp]/，表示使用大写字母P或者小写字母p来分隔字符串。同时，将分隔后的各个子串存储到数组arr中。第9～11行依次输出数组arr的各个元素的值。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-26.sh
5
12
89

从上面的执行结果可以得知，作为分隔符的字符并不出现在拆分结果中。

5. sub(regexp, replacement, string)和gsub(regexp, replacement, string)

这2个函数的作用都是替换字符串中的子串，其区别在于前者只替换第1次出现的子串，而后者则替换所有出现的子串。这两个函数的参数完全相同，第1个参数为正则表达式，表示匹配规则。第2个参数为用来替换的字符串，第3个参数是将要被处理的字符串。

【例11-27】演示sub()函数以及gsub()函数的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-27.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 定义字符串
06 string="abcd6b12abcabc212@123465"
07 # 将第 1 个符合正则表达式 /(abc)+[0-9]*/ 的子串用括号括起来
08 sub(/(abc)+[0-9]*/,"(&)",string)
09 print string
10
11 # 将所有符合正则表达式 /(abc)+[0-9]*/ 的子串用括号括起来
12 gsub(/(abc)+[0-9]*/,"(&)",string)
13 print string
14 }

在上面的代码中，第6行定义了一个由各种字符组成的字符串。第8行的sub()函数的第1个参数为正则表达式/(abc)+[0-9]*/，表示匹配1个或者多个字符串abc，后面紧跟着0个或者多个数字的字符串；第2个参数为(&)，其中的圆括号是普通字符，&符号表示引用前面匹配的子串，这个符号的作用在介绍正则表达式时已经详细介绍过了。

第12行的作用与第8行大致相同，不过使用的是gsub()函数，因此第8行替换的是第1次出现的子串，而第12行替换的所有出现的子串。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-27.sh
(abc)d6b12abcabc212@123465
((abc))d6b12(abcabc212)@123465

在上面的执行结果中，第1行字符串是sub()函数的替换结果，可以发现，sub()函数只替换了最开始的abc这3个字符。而第2行字符串是gsub()函数的替换结果，可以发现， gsub()函数进行了两处替换。

注意：【例11-27】执行结果中的第2行字符串的最开始3个字符由两层圆括号包围，这是因为前面已经使用sub()函数替换过1次。另外，如果sub()函数和gsub()函数的第2个参数为空串，则表示将符合匹配规则的子串删除。

6．substr(string,start,[length])

该函数的功能是截取指定长度的子串。第1个参数为父串，第2个参数表示子串开始截取的位置，第3个参数表示要截取的长度。其中，第3个参数可以省略，如果省略了该参数，则表示从start参数指定的位置开始，一直截取到父串的末尾。

通过match()函数和substr()函数，用户可以将父串中所有符合规则的子串提取出来。

【例11-28】演示使用substr()函数截取子串的方法，代码如下：

01 #---------------------------/chapter11/ex11-28.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 定义字符串变量
06 pages="p12-P34 P56-p78"
07 # 通过循环依次匹配字符串中的数字
08 while(match(pages,/[0-9]+/)>0) {
09 # 截取并输出匹配的子串
10 print substr(pages,RSTART,RLENGTH)
11 # 删除匹配的子串
12 sub(/[0-9]+/,"",pages)
13 }
14 }

在上面的代码中，第6行定义了一个包含字母、数字，以及连字符的字符串。第8～13行是while循环结构，关于while循环结构，将在11.7节中介绍。第8行中while循环语句的执行条件为match(pages,/[0-9]+/)>0，表示当字符串变量pages中含有一个或者多个连续的数字时执行循环结构。第10行使用substr()函数截取匹配的子串，第1个参数就是pages变量，第2个参数为系统变量RSTART，表示匹配的子串在父串中出现的位置。第3个参数为系统变量RLENGTH，表示匹配的子串的长度。后面的这2个参数都来自match()函数的匹配结果。第12行使用sub()函数将已经匹配过的子串删除。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-28.sh
12
34
56
78

从上面的执行结果可以得知，字符串中的所有的连续的数字都被提取出来了。

11.5.2　算术函数
awk提供了基本的执行算术运算的函数，如表11-7所示。

表11-7　awk中的算术函数

函数 | 说　明
int(x) | 返回数值x的整数部分
sqrt(x) | 返回数值x的平方根
exp(x) | 返回e的x次方
log(x) | 返回以e为底的对数值
sin(x) | 返回x的正弦值，其中x为弧度值
cos(x) | 返回x的余弦值，x为弧度值
rand() | 返回介于0与1之间的随机数
srand([x]) | 以x为种子返回一个随机数

关于算术函数的使用方法与其他程序设计语言基本相同，不再详细说明。

11.6　数组

用户可以在awk编程环境中使用数组。这种特性使用户在存储结构化的数据时变得非常方便。与其他程序设计语言相比，awk中的数组有许多特殊的使用方法，本节将对这些使用方法进行详细介绍。

11.6.1　数组的定义和赋值

数组是用来存储一组相互关联的数据的结构体。在awk中，用户可以自定义数组，并且在定义数组时，无需指定其类型和大小。实际上，awk并没有提供数组定义的语句，当用户为数组第1个元素赋值时，awk便会自动创建该数组。此时，数组中只有1个元素。当为数组中的第2个元素赋值时，数组中便会包含2个元素，依此类推。

awk数组的命名规则与变量名基本相同，也是由字母、数字和下划线组成。另外，awk的数组名也是区分大小写的，例如array和Array是不同的数组名，初学者务必注意这一点。awk数组的下标从1开始计算，即数组的第1个元素的下标为1，第2个元素的下标为2，依此类推。另外，awk也支持关联数组，这与Shell是相同的。所谓关联数组，是指以字符串作为下标的数组。

在定义数组时，用户并不需要指定数组的数据类型。实际上，awk数组可以存储任何简单类型的数据，例如字符串、整数，以及浮点类型的数据。甚至在同一个数组，各个元素的类型也可以是不同的。

数组元素赋值的语法如下：

array[n]=value

其中，array表示数组名称，n表示数组元素的下标，等号为赋值运算符，value为要赋给数组元素的数值。

用户可以使用以下语法引用数组元素的值：

array[n]

其中，array表示数组名称，n为数组元素的下标。

【例11-29】演示awk脚本中数组的定义、数组元素的赋值，以及引用数组元素的方法，代码如下：

01 #---------------------------/chapter11/ex11-29.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 为数组元素赋值
06 arr[1]="Tim"
07 arr["a"]=12
08 arr[3]=3.1415
09 arr[4]=5
10 # 输出数组元素的值
11 print arr[1],arr[2],arr["a"]*arr[3],arr[4]
12 }

在上面的代码中，第6行创建了一个名称为arr的数组，并且为下标为1的元素赋值为一个字符串Tim；第7行为下标为a的数组元素赋值为一个整数12；第8行为下标为3的数组元素赋值为浮点数3.1415；第10行为下标为4的数组元素赋值为5。第11行通过print语句依次输出下标为1和2的元素的值、下标为a和3的元素的乘积，以及下标为4的元素的值。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-29.sh
Tim John 37.698 5

从上面的执行结果可以得知，在同一个数组中，可以混合使用整数下标和字符串下标。另外，也可以将字符串、整数，以及浮点数的数据赋给同一个数组的不同元素。数组的元素可以参与相关的运算。

11.6.2　遍历数组

所谓数组的遍历，是指将数组中的每个元素的值依次输出。当然，用户可以通过循环结构来实现数组的遍历。在使用正常的循环结构来遍历数组时，必须首先知道数组的长度，这样的话才能确定循环终止的条件。在awk中，数组的长度可以使用length()函数获得，该函数以数组名作为参数，返回数组的长度。

【例11-30】演示通过正常的for循环结构来遍历数组，代码如下：

01 #---------------------------/chapter11/ex11-30.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 定义数组
06 stu[1]="200200110"
07 stu[2]="200200164"
08 stu[3]="200200167"
09 stu[4]="200200168"
10 stu[5]="200200172"
11
12 # 计算数组的长度
13 len=length(stu)
14
15 # 通过循环遍历数组
16 for(i=1;i<=len;i++)
17 {
18 print i,stu[i]
19 }
20 }

在上面的代码中，第6～10行定义了一个名称为stu的数组，包含5个元素。第13行通过length()函数获取数组的长度。第16～19行通过for循环结构遍历数组。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-30.sh
1 200200110
2 200200164
3 200200167
4 200200168
5 200200172

使用上面的方法遍历数组有一个前提条件，即数组元素的下标必须是连续的，并且是整数值作为下标。如果数组的下标是非连续的或者在字符串，那么使用上面的方法不能实现数组的遍历。接下来介绍awk提供的另外一种遍历数组的方法。

awk提供一种更为便捷的机制来遍历数组元素，其语法如下：

for (n in array) {
print array[n]
}

在上面的语法中，n表示数组元素的下标，array表示数组的名称。当使用以上语法遍历数组时，awk会将数组array中的每个现有的下标依次赋给变量n，每次赋值之后都执行一次循环体中的代码。可以发现，这是一种非常方便的方式，但是在这种方式中，数组元素的下标默认是无序的。所以导致通过以上语句得到的数组元素也是无序的。

【例11-31】演示使用for结构来遍历数组的方法，代码如下：

01 #---------------------------/chapter11/ex11-31.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 定义数组
06 arr[1]="Tim"
07 arr[2]="John"
08 arr["a"]=12
09 arr[3]=3.1415
10 arr[4]=5
11 arr[99]=23
12
13 # 遍历数组
14 for(n in arr)
15 {
16 print arr[n]
17 }
18 }

在上面的代码中，第6～11行定义了一个名称为arr的数组，其下标包括整数和字符串，另外数组的下标是不连续的。第14～17行通过for循环来遍历数组。

该程序的执行结果如下；

[root@linux chapter11]# ./ex11-31.sh
5
12
23
Tim
John
3.1415

从上面的执行结果可以得知，for循环并没有按照定义的顺序来输出数组元素。为了更加清楚地显示for循环遍历的顺序，下面将代码第16行的print语句改为以下形式：

print n

即直接输出下标。修改以后的【例11-31】的执行结果如下：

[root@linux chapter11]# ./ex11-31.sh
4
a
99
1
2
3

在上面的输出结果中，下标并没有表现出任何的顺序。另外，在for循环中，所输出的下标是用户定义时使用的下标，并没有转换为整数。

注意：要按照顺序遍历数组，必须使用下标来引用数组元素。

除了上面所介绍的内容之外，awk还提供了关于数组的其他的一些操作，例如可以使用delete语句来删除数组的某个元素，使用in运算符来判断数组中是否包含某个元素等。关于这些内容，用户可以参考相关的书籍，不再详细介绍。

11.7　流程控制

作为一种程序设计语言，awk支持程序的流程控制，例如条件判断、循环，以及其他的一些流程控制语句，例如continue、break，以及exit等。掌握这些基本的流程控制语句，对于编写出结构良好的程序非常重要。本节将介绍awk流程控制语句的使用方法。

11.7.1　if语句
if语句的功能是根据用户指定的条件来决定执行程序的哪个分支，其语法如下：

if (expression)
{
statement1
statement2
}
else
{
statement3
statement4
}

在上面的语法中，当表达式expression的值为真时，执行语句statement1和statement2；否则，执行语句statement3和statement4。如果if以及else后面的语句只有1条，则可以省略大括号，变成以下形式：

if (expression)
statement1
else
statement2

但是，为了提高程序的可读性，某些开发者可能更喜欢无论是否是多条语句，都使用大括号将其括起来。

如果用户只想处理表达式expression的值为真的情况，而忽略为假的情况，则可以省略else语句，只保留if语句，如下：

if (expression)
{
statement1
statement2
}

除此之外，if和else语句还可以嵌套，从而实现更为复杂的条件分支结构。

【例11-32】演示多层嵌套if语句的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-32.sh------------------
02 #! /bin/awk -f
03
04 {
05 #90 分以上为 A
06 if ($2 >= 90) {
07 print $1,"A"
08 }
09 else {
10 #80 分以上为 B
11 if($2 >= 80 && $2 < 90)
12 {
13 print $1,"B"
14 }
15 # 其余为 C
16 else
17 {
18 print $1,"C"
19 }
20 }
21 }

该例的功能是根据输入文件中的第2个字段的值来将其分级，90以上输出A，80分以上、90以下输出B，其余分数输出C。其中，本例所使用的数据文件就是前面所创建的scores.txt文件。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-32.sh scores.txt
John B
Nancy B
Tom B
Kity C
Han A
Kon B

11.7.2　while语句
while语句是另外一种常用的循环结构，其语法如下：

while (expression)
{
statement1
statement2
...
}

当表达式expression的值为真时，执行循环体中的statement1以及statement2等语句。如果循环体中只包含一条语句，则可以省略大括号。

【例11-33】演示while循环语句的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-33.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 定义循环变量
06 i=0
07 #while 循环开始
08 while (++i <= 9)
09 {
10 # 输出循环变量 i 的平方
11 print i^2
12 }
13 }

在上面的代码中，第6行定义循环变量i，并且赋初值为0。第8行是while循环结构的开始，其中i<=9作为循环条件，++i是一个前置自增表达式。第11行输出循环变量的平方。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-33.sh
1
4
9
16
25
36
49
64
81

从上面的执行结果可以得知，该例实际上是输出了1～9这9个数字的平方。

11.7.3　do…while语句
awk还支持另外一种while循环语句，其语法如下：

do {
statement1
statement2
...
}while (expression)

同样，当表达式expression的值为真时，执行循环体中的语句。

【例11-34】使用do…while循环结构实现了输出1～9的平方，代码如下：

01 #---------------------------/chapter11/ex11-34.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 定义循环变量
06 i=1
07 do
08 {
09 # 输出循环变量的平方
10 print i^2
11 }while (++i<=9)
12 }

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-34.sh
1
4
9
16
25
36
49
64
81

注意：while语句与do…while语句的区别在于后者会首先执行循环体中的语句，然后判断是否继续执行，因此do…while循环体中的语句至少执行一次。而while则首先判断表达式的值是否为真，然后才决定是否执行循环体。

11.7.4　for语句
for循环语句通常用在循环次数已知的场合中，其语法如下：

for(expression1; expression2; expression3)
{
statement1
statement2
...
}

在上面的语法中，表达式expression1通常用来初始化循环变量，表达式expression2通常用来指定循环执行的条件，表达式expression3通常用来改变循环变量的值。当表达式expression2的值为真时，执行循环体中的语句。

【例11-35】通过两层for循环实现了九九乘法表的打印输出，代码如下：

01 #---------------------------/chapter11/ex11-35.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 外层循环
06 for(i=1;i<=9;i++)
07 {
08 # 内层循环
09 for(j=1;j<=i;j++)
10 {
11 # 将每一行的数值连接成一个字符串
12 if(i*j<10)
13 {
14 row=row" "i*j
15 }
16 else
17 {
18 row=row" "i*j
19 }
20 }
21 # 输出每一行数值
22 print row
23 row=""
24 }
25 }

在上面的代码中，第6～23行是外层for循环，该循环从1开始，当循环变量i的值增加到9时终止。第9～20行是内层for循环，该循环从1开始，当循环变量j的值增加到外层循环的循环变量i时终止。为了能够使得外侧循环一次中所得到的乘积在同一行中输出，所以使用了一个名称为row的变量将同一次外层循环中的所有的乘积连接成一个字符串。

为了的使格式更加整齐，当乘积小于10时，乘积之间用3个空格隔开；当乘积大于等于10时，乘积之间用两个空格隔开。第22行通过print语句将变量row的值输出。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-35.sh
1
2 4
3 6 9
4 8 12 16
5 10 15 20 25
6 12 18 24 30 36
7 14 21 28 35 42 49
8 16 24 32 40 48 56 64
9 18 27 36 45 54 63 72 81

注意：除了上面介绍的for循环之外，还有一种专门用来遍历数组的for循环结构，在介绍数组的时候已经讲过，不再重复说明。

11.7.5　break语句

用户可以通过使用break语句在适当的时机退出for，以及while等循环结构，而不必等到循环结构自己退出。

【例11-36】演示在awk程序中通过while循环读取数据文件，当第1列的值为Kity时，退出while循环，代码如下：

01 #---------------------------/chapter11/ex11-36.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 循环读取数据
06 while( getline < "scores.txt" > 0)
07 {
08 # 当第 1 个字段的额值为 Kity 时退出
09 if($1=="Kity")
10 break
11 else
12 print $1,$2,$3,$4,$5
13 }
14 }

在上面的代码中，第6行的getline读取数据文件，该数据来自文件的输入重定向。getline是awk中用于输入重定向的一个函数，它可以从标准输入、管道或者文件读取输入数据，而不只是从当前被处理的文件来读取。getline函数读取一行数据之后，会自动更新NF、NR，以及FNR等系统变量。如果成功读取一条记录，则getline函数返回1；如果到了文件末尾，则返回0。因此，在上面的代码中，使用getline函数的返回值大于0作为while循环执行的条件。

第9行判断第1个字段的值是否等于Kity。如果等于Kity，则退出循环；否则输出第1～5个字段的值。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-36.sh
John 85 92 79 87
Nancy 89 90 73 82
Tom 81 88 92 81

注意：break语句不仅可以用在while循环结构中，还可以用在for及do…while循环结构中，包括嵌套循环结构，用户可以自己去练习。

11.7.6　continue语句
continue语句的主要功能是跳过循环结构中该语句后面的尚未执行的语句。break语句与continue语句的功能有着明显的区别，前者是直接退出循环结构，而后者是跳过循环体中尚未执行的语句，重新执行下一次循环。

【例11-37】演示continue语句的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-37.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 通过循环读取数据
06 while( getline < "scores.txt" > 0)
07 {
08 # 当第 1 个字段含有字符串 Nancy 时跳过后面的语句
09 if($1 == "Kity")
10 continue
11 print $1,$2,$3,$4,$5
12 }
13 }

实际上，该例的代码只是将【例11-36】的代码中的第10行换成了continue语句，其他代码完全相同。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-37.sh
John 85 92 79 87
Nancy 89 90 73 82
Tom 81 88 92 81
Han 92 89 80 83
Kon 88 76 85 97

对比【例11-36】和【例11-37】的输出结果，可以发现【例11-36】只输出了3行，其中包含第1个字段为Kity的行在内的后面3行并没有被输出。这说明当第1列的值为Kity时，break语句导致while循环直接退出。而【例11-37】的输出结果只缺少第1列为Kity的那一行，该行后面的其他行仍然被输出了，这说明continue语句仅仅导致当第1列的值为Kity时，该语句后面的print语句被跳过，但是并没有退出循环结构。

11.7.7　next语句
next语句的功能与continue语句非常相似，但是next语句并不是用在循环结构中，而是用在整个akw程序中。当awk执行程序时，如果遇到next语句，则该语句后面的所有的程序语句都被忽略，包括其他的模式，以及对应的操作。awk会继续读取下一行数据，并且从第一个模式及其操作开始执行。

假设存在一个包含许多空行的文件，如下：

[root@linux chapter11]# cat scores3.txt
John 85 92 79 87
Nancy 89 90 73 82
Tom 81 88 92 81
Kity 79 65 83 90
Han 92 89 80 83
Kon 88 76 85 97

现在用户需要输出这些数据，并且忽略其中的空行。

【例11-38】使用next语句可以解决这个问题，条件代码如下：

01 #---------------------------/chapter11/ex11-35.sh------------------
02 #! /bin/awk -f
03
04 # 当读取的行为空行时跳过后面的语句
05 /^[\t]*$/ {
06 next
07 }
08
09 # 输出各个字段
10 {
11 print $1,$2,$3,$4,$5
12 }

在上面的代码中，第5行的模式为正则表达式/^[\t]*$/，表示匹配只含有0个或者多个制表符的行。当匹配成功时，执行第6行的next语句。第10～12行的print语句只有非空行时才会被执行到。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-38.sh scores3.txt
John 85 92 79 87
Nancy 89 90 73 82
Tom 81 88 92 81
Kity 79 65 83 90
Han 92 89 80 83
Kon 88 76 85 97

从上面的执行结果可以得知，所有的空白行都被忽略，没有出现在输出结果中。next语句还可以用在其他场合中，读者可以深入理解next语句的功能，在此基础上达到灵活运用的目的。

注意：从执行效果上看，next语句和continue语句非常相似，都是跳过后面的部分语句，但是这两者的应用场合有所不同。

11.7.8　exit语句
exit语句的功能是终止awk程序的执行。该语句的使用方法与其他语句基本相同，在此不再举例说明。

11.8　awk程序的格式化输出

对于程序来说，输出是一项非常重要的功能，而绝大部分用户对于数据输出都有一定的格式要求，awk提供了基本的格式化输出功能。本节将介绍awk中与输出有关的函数。

11.8.1　基本print语句
print语句提供了基本的输出功能。我们在前面许多例子中，已经使用该语句来输出各种数据。print语句的基本语法如下：

print string1,string2,...

在上面的语法中，参数string1及string2等都是要输出的数据，各个参数之间使用逗号隔开。在输出的时候，print语句会自动使用空格将各个参数值隔开。

关于print语句，在此不再详细举例说明，读者可以参考前面的例子以了解其使用方法。

11.8.2　格式化输出printf语句

虽然大多数情况下awk的print语句可以完成任务，但有时我们还需要对格式做更多的控制。awk提供了printf()函数来实现字符串的格式化。这个函数的功能和语法与C语言中的printf()函数基本相同，如下：

printf(format, [arguments])

其中圆括号是可选的，第1个参数format是一个用来描述输出格式的字符串，通常以引号括起来的字符串常量的形式提供。arguments为一个参数列表，表示用来显示的数据，可以是变量名等，多个参数之间用逗号隔开。参数列表的项是有顺序的，与前面的格式化字符串中的格式说明相对应。

格式化字符串的语法如下：

%format

在每个格式描述符前面都要使用一个百分号%，awk中常用的格式化描述符如表11-8所示。

表11-8　常用的格式化描述符

描　述　符 | 说　明 | 描　述　符 | 说　明
c | ASCII字符 | s | 字符串
d | 十进制整数 | % | 百分号本身
e | 浮点格式 | |

在表11-8中，最常用的格式描述符为d和s，其中d表示以十进制整数的形式显示相应的参数，s表示以字符串的形式显示相应的参数。

【例11-39】演示printf函数的使用方法，代码如下：

01 #---------------------------/chapter11/ex11-39.sh------------------
02 #! /bin/awk -f
03
04 # 格式化输出各个字段
05 {
06 printf ("%s\t%d\t%d\t%d\t%d\t%d\n",$1,$2,$3,$4,$5,($2 + $3 + $4 +
$5))
07 }

在上面的代码中，第6行使用printf语句输出一个成绩报表，各个列分别为姓名、各项成绩，以及总成绩。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-39.sh scores.txt
John 85 92 79 87 343
Nancy 89 90 73 82 334
…

注意：与print函数不同，printf函数不会自动在输出的末尾追加一个换行符，因此，用户需要在格式化字符串的最后追加一个\n字符。

11.8.3　使用sprintf()函数生成格式化字符串

上面介绍的printf()函数会将格式化结果输出到标准输出设备。sprintf()函数的功能与printf()函数的功能大致相同，但是该函数只是以字符串的形式返回格式化结果，并不输出到标准输出设备。用户可以将格式化的结果进行其他处理或者使用print()和printf()函数输出到标准输出。

【例11-40】演示awk工具的简单报表功能，代码如下：

01 #---------------------------/chapter11/ex11-40.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 输出报表头
06 print "Scores list"
07 }
08
09 {
10 # 逐行输出学生成绩
11 printf ("%s\t%d\t%d\t%d\t%d\t%d\n",$1,$2,$3,$4,$5,($2 + $3 + $4 +
$5))
12 # 计算总成绩
13 total+=$2 + $3 + $4 + $5
14 }
15
16 END {
17 # 计算平均分
18 average=total/NR
19 # 格式化统计结果
20 sum=sprintf("Total: %d students, average: %.2f",NR,average)
21 print sum
22 }

在上面的代码中，第4～7行是BEGIN模式，用来输出报表头部。第9～14行匹配所有的记录，依次输出各条记录，其中第13行计算总成绩。第16～22行是END模式，其功能是输出统计信息。其中第20行使用sprintf函数格式化统计结果。格式化字符%.2f表示以浮点形式显示对应的参数，并且保留2位小数。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-40.sh scores.txt
Scores list
John 85 92 79 87 343
Nancy 89 90 73 82 334
Tom 81 88 92 81 342
Kity 79 65 83 90 317
Han 92 89 80 83 344
Kon 88 76 85 97 346
Total: 6 students, average: 337.67

11.9　awk的程序与Shell的交互
awk提供了与Shell命令交互的能力，从而可以使用户在awk程序中使用系统资源。awk主要通过两种机制来实现这种交互功能，分别为管道和sytem函数。本节将对这两种交互机制进行详细介绍。

11.9.1　通过管道实现与Shell的交换

用户可以很容易地在awk程序中使用操作系统资源，包括在程序中调用Shell命令处理程序中的数据；或者在awk程序中获取Shell命令的执行结果。awk提供了管道来实现这种数据的双向交互。awk的管道与UNIX或者Linux中的管道非常相似，但是特性有些不同。

【例11-41】使用管道调用Shell命令来获取当前在线的用户数，代码如下：

01 #---------------------------/chapter11/ex11-41.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 通过管道获取 who 命令的执行结果
06 while("who" | getline) n++
07 printf("There %d online users.\n",n)
08 }

在上面的代码中，第6行是一个while循环结果，其循环条件为"who" | getline。在执行的时候，awk会首先调用Shell命令who，该命令会列出当前系统中已经登录的用户，每一行描述一个用户的信息。然后将who命令的执行结果通过管道传递给getline函数，该函数依次读取每一行数据，在逐行读取数据的过程中，实现变量n的自增。其中变量n的最后值就代表当前在线的人数。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-41.sh
There 1 online users.

注意：awk的管道与UNIX或者Linux的管道虽然名称相同，但不是同一个概念。

11.9.2　通过system函数实现与Shell的交互
awk提供另一个调用Shell命令的方法，即使用awk函数，其语法如下：

system(command)

其中参数command表示要执行的Shell命令。与管道相比，system函数有许多据局限，例如不能在awk程序中直接获取Shell命令的执行结果，另外，也不能直接将awk程序中的数据传递给Shell命令来处理。要实现这种数据传递，必须借助其他的手段。

【例11-42】通过awk工具的system函数执行Shell中的ls命令，并且将得到的文件列表在程序中打印出来，代码如下：

01 #---------------------------/chapter11/ex11-42.sh------------------
02 #! /bin/awk -f
03
04 BEGIN {
05 # 使用 system 函数调用 Shell 命令
06 system("ls > filelist")
07 # 通过 getline 函数获取数据
08 while(getline < "filelist" > 0)
09 {
10 print $1
11 }
12 }

在上面的代码中，第6行使用system函数执行ls命令，由于system不支持数据的传递，所以需要借助一个临时文件作为数据传递的桥梁。即将ls命令的执行结果重定向到名称为filelist的文件中。第8行使用getline函数从数据文件中读取数据，然后在第10行输出到屏幕。

该程序的执行结果如下：

[root@linux chapter11]# ./ex11-42.sh
articles.txt
8 ex11-10.sh
9 ex11-11.sh
10 ex11-12.sh
11 ex11-13.sh
…

11.10　小结

本章系统地介绍了awk程序的设计方法，主要内容包括awk的基础知识、awk的模式匹配、变量、运算符和表达式、函数、数组、流程控制、格式化输出，以及与Shell的交互等。重点在于掌握awk命令的基本语法，理解awk的匹配模式、流程控制，以及常用的函数等。在下一章中，将介绍Shell程序中的文件操作。

第12章
文件的操作

Linux及UNIX始终秉持“一切即文件”的理念。因此，在Shell编程的过程中，文件的操作是一种非常频繁的事情。本章将对Shell程序设计中的与文件操作相关的知识进行介绍。

本章主要涉及的知识点如下所述。

- 文件：主要介绍文件的基础知识，包括列出文件、文件类型，以及文件权限。

- 查找文件：主要介绍使用find命令来查找文件，包括find命令及语法、路径、测试、使用!运算符对测试求反、处理文件权限错误信息，以及find命令的相关操作等。

- 比较文件：主要介绍使用comm及diff等命令来比较文件。

- 文件描述符：主要介绍什么是文件描述符、标准输入、标准输出，以及标准错误。

- 重定向：主要介绍输入重定向，以及输出重定向及其使用方法。

12.1　文件

对于大多数的Linux及UNIX系统管理员来说，他们每天面对的都是各种类型的文件，包括系统配置的更改、软件的安装及调试。因此，了解Linux中的文件的基础知识非常重要。本节将介绍文件的类型以及文件的权限等。

12.1.1　列出文件

相对于Windows等操作系统，Linux的系统管理员最常使用的不是图形界面，而是字符界面。因此，所有的操作都依赖Linux的命令来完成。一般情况下，当进入某个目录之后，用户总是需要查看当前目录中的文件的列表，以确定需要操作的文件是否存在。

Linux提供了ls命令来列出某个目录的内容，其基本语法如下：

ls [option]... [file]...

在上面的语法中，option表示ls命令的选项。file参数表示文件名。

最简单的ls命令是不带任何选项和参数的形式，如下：

[root@linux chapter12]# ls
ex12-1.sh iex12-1.sh iex12-2.sh

当单独使用ls时，该命令会列出当前目录中的文件的名称。这些文件名会以多列形式显示。尽管在某些情况下，用户获取到文件名就可以判断文件是否存在，但是，在绝大部分情况下，使用不带任何选项和参数的ls命令提供的信息非常少。因为在Linux中，除了文件名之外，文本还拥有许多其他的属性，例如文件的大小、文件的所有者，以及文件创建日期等。

用户可以使用ls命令的-l选项来显示更为详细的信息，如下：

[root@linux chapter12]# ls -l
total 12
-rwxr-xr-x 1 root root 43 Mar 27 23:04 ex12-1.sh
-rwxr-xr-x 1 root root 158 Mar 27 11:38 iex12-1.sh
-rwxr-xr-x 1 root root 109 Mar 27 11:02 iex12-2.sh
…

在上面的输出结果中，第1列为文件的文件类型和访问权限，其中，第1个字符为文件的类型，如果为常规文件，则使用一个连字符-表示。关于其他的文件类型的表示方法，将在下一小节中介绍。后面的9个字符为文件的访问权限，分为3组，每3个字符为一组，分别表示文件所有者、同组用户，以及其他组用户对于该文件的访问权限。在每一组权限中，使用3个字符r、w和x分别表示读、写和执行的权限。如果当前用户没有该权限，则使用连字符-表示。

第2列为文件的硬链接数，在上面的例子中，所有的文件都只有1个硬链接，即文件本身。第3列为文件的所有者，在上面的例子中，文件的所有者为root。第4列为所有者的主用户组，第5列为文件的大小，第6列为文件的最后访问时间，第7列为文件名。

如果没有指定路径，则ls命令会列出当前的工作目录的内容。用户可以在ls命令的后面加上具体的路径，以显示指定路径的内容。例如下面的命令列出/etc目录中的文件：

[root@linux chapter12]# ls -l /etc
total 2228
drwxr-xr-x. 3 root root 4096 Feb 21 2012 abrt
drwxr-xr-x. 4 root root 4096 Feb 21 2012 acpi
-rw-r--r--. 1 root root 45 Oct 12 07:53 adjtime
-rw-r--r--. 1 root root 1512 Jan 12 2010 aliases
…

在Linux或者UNIX中，隐藏文件的文件名以圆点.开头，在默认情况下，ls命令并不列出隐藏文件。如果想要列出所有的文件，包括隐藏文件，可以使用-a选项。例如，下面的命令将用户root的主目录中的所有的文件列出来：

[root@linux ~]# cd
[root@linux ~]# ls -la
total 2129964
dr-xr-x---+ 76 root root 4096 Mar 26 15:25 .
dr-xr-xr-x. 28 root root 4096 Dec 20 12:51 ..
-rw-------. 1 root root 14402 Mar 28 04:46 .bash_history
-rw-r--r--. 1 root root 18 May 20 2009 .bash_logout
-rw-r--r-- 1 root root 195 Nov 1 08:34 .bash_profile
-rw-r--r--. 1 root root 176 Sep 23 2004 .bashrc
drwxr-xr-x. 4 root root 4096 Nov 19 22:23 .cache
…

在上面的执行结果中，第1行的文件名为一个圆点，表示当前的目录。第2行的文件名为两个圆点，表示当前目录的父目录。在所有的目录中，都包含.和..这两个特殊的目录。第3行开始都是以圆点开头的普通的隐藏文件。

注意：关于ls命令的其他选项，读者可以参考Shell手册。

12.1.2　文件类型

具体地讲，UNIX的文件类型非常多。但是这些类型大致可以分为3类，分别为普通文件、目录和伪文件。在伪文件中，有3种类型最常见，分别为特殊文件、命名管道，以及proc文件。下面分别对这些文件类型进行介绍。

1．普通文件

普通文件，又称为常规文件或者一般文件，是与人直接打交道最多的文件类型。普通文件包含数据，通常位于某种类型的存储设备上面，例如硬盘、U盘、CD或者DVD等。当我们使用Microsoft Word编辑.doc文件时，Word程序本身以及所编辑的.doc文件都是普通文件。

普通文件可以分为文本文件和二进制文件这两种类型。文本文件包含的数据由可显示字符，例如字母、数字、标点符号、空格及制表符等组成。一个文本文件由许多行组成，每个行的结尾都有一个表示换行的符号，称为换行符（LF）。当我们编辑文本文件时，按回车键后，就在光标所在的位置插入了一个换行符。

文本文件通常用来存储文本数据，例如纯文本、Shell脚本程序、源代码、配置文件，以及HTML文档等。总地来说，只要是我们能够读懂和理解的文件，一般都是文本文件。如图12-1所示，显示了使用cat命令输出一个Shell程序的代码。

图12-1　使用cat命令显示文本文件内容

二进制文件既包含可显示字符，也包括不可显示字符，也就是说二进制文件包含了全部的256个ASCII字符。这种类型的文件只有在执行或者由其他的应用程序解释时才有意义。对于绝大部分人来说，这种文件不可直接阅读和理解。常见的二进制文件包括可执行文件、图像文件、数据库文件、视频文件、音频文件、Office文档等。在UNIX系统中，最常用的ls命令就是一个二进制文件，它通常位于/usr/bin目录中。如果用户使用cat命令来查看二进制文件的内容，则会出现许多不可读的乱码，如图12-2所示。

在ls-l命令中，普通文件的第1列的第1个字符为连字符-，如下：

[root@linux chapter12]# ls -l /bin/ls
-rwxr-xr-x 1 root root 111744 Dec 8 2011 /bin/ls

图12-2　查看二进制文件时的乱码

2．目录

在UNIX及Linux系统中，目录也是一种文件。和普通的文件类似，目录也存储在某些存储设备上面，但是目录本身不存储具体的用户数据。目录的作用是组织和访问其他文件。从这一点上讲，目录与我们现实生活中的文件夹的作用相同，在现实生活中，我们可以通过文件夹快速地找到所需要的文件，而在计算机中，我们通过目录也可以快速地找到所需要的文件。

目录中包含各种各样的文件，当然也包括目录。当一个目录中包含目录时，这个目录就被称为父目录，被包含的目录就被称为子目录。通常情况下，某些功能相同或者相关的文件会被放在同一个目录中，例如，UNIX或者Linux系统中的常规命令通常放在/usr/bin目录中，而与系统有关的命令通常放在/usr/sbin目录中。

由于目录可以嵌套，因此就可以按照层次来组织文件。在后面的内容中，我们可以看到，整个UNIX或者Linux文件系统就是一个大的树型的层次结构体。位于最上面的是根目录，根目录中包含文件以及子目录，子目录中又包含其他的子目录。用户可以将自己的文件放在这个树型结构的某个子树中，并可以创建或者删除自己的目录和文件。

在实践的过程中，一定要注意文件和目录的关系。文件本身不包含文件名、文件的大小，以及文件的存储权限等属性，它只存储文件的内容。文件名只有在目录文件中才能找到。每个子目录和文件都在其父目录中拥有一条记录，该记录包含以下两部分：

- 文件名。

- 文件或者目录的唯一标识符（即inode节点）。

用户不能直接读写目录文件本身的内容，但是在执行某些操作时，会通过内核间接地操作目录文件。例如，当我们删除一个文件时，该文件的父目录中就会删除该文件对应的记录；当我们创建一个文件时，会在该文件的父目录文件中增加一条关于该文件的记录。

有的时候，用户可能会听到另外一个名词，叫做“文件夹”。特别是在桌面环境中，文件管理器中的目录的表现形式就是一个个文件夹图标，如图12-3所示。实际上这个名词来自于Apple Macintosh和Microsoft Windows。Windows操作系统使用文件夹来组织文件，其功能与UNIX系统的目录基本相同，但是其功能却没有那么强大；Macintosh的文件夹实际上就是目录，只是在文件管理器中以文件夹的形式表现出来。

3．伪文件

与普通文件和目录不同，伪文件不是用来存储数据的。正因为如此，这些文件并不占用磁盘空间，尽管这些文件确实存在于目录树中，是目录树的一部分，并且也是按照目录的方式来组织。伪文件的目的是提供一种服务，通过这种服务，系统可以像操作普通文件一样来访问这些伪文件所代表的对象。

图12-3　文件夹

最重要的伪文件是设备文件。设备文件是物理设备在系统中的内部表示，例如键盘、鼠标、显示器、打印机、硬盘等。对于这些设备，都有对应的伪文件来表示它们。除了设备文件之外，还有命名管道，通过命名管道，用户可以将一个命令的输出连接到另外一个命令的输入上面。最后一种伪文件是proc文件，它允许访问内核中的信息。

注意：除了上面介绍的3种文件之外，还有其他文件类型，例如链接等。

12.1.3　文件的权限

UNIX使用一套简单有效的方式来为文件设置权限。我们再次浏览一下上面的ls -l命令的输出结果，该结果一共有7列，其中第1列就是文件的权限，如下：

-rwxr-xr-x

第1个字符是“-”，这个字符表示当前的文件是一个普通文件，不属于权限的范围。从第2个字符开始一直到结尾的9个字符代表了当前文件的存取权限。为了更加清楚地分析这个文件的权限，可以将这9个字符分成3组，如下：

rwx r-x r--

上面的3组分别代表不同类别的权限，每一类权限都用3位来表示，依次表示文件的读取权限、写入权限和执行权限。r表示读取（read）权限，拥有该权限的用户将可以使用cat及more等命令查看文件内容。w表示写入（write）权限，拥有该权限的用户将可以对该文件进行编辑修改。x表示执行（execute）权限，拥有该权限的用户将可以执行该文件，当然，前提是该文件是可执行文件。如果某个权限位是“-”符号，则表示不拥有该权限。

注意：对于非可执行文件来说，执行权限没有意义。

在上面的例子中，第1组权限是rwx，它表示该文件的所有者拥有该文件的读、写和执行这3个权限。那么如何判断哪个用户是该文件的所有者呢，在上面ls-l命令的输出结果中第3列就是文件的所有者，在本例中，db.dat文件的所有者是root用户。当root用户登录进系统之后，便拥有了对于该文件的3种权限。

第2组是权限是r-x，它表示该文件的组所有者对该文件拥有读和执行的权限，由于第2个权限位是“-”符号，因此，组所有者没有写入该文件的权限。ls -l命令的第4列显示的就是当前文件的用户组。在上面的例子中，db.dat文件的用户组是root。在当前系统中，属于root用户组的所有用户都拥有对于db.dat文件的读取和执行权限。

第3组权限是r-x，它表示当前系统的其他用户对该文件拥有读取的权限，由于第2个和第3个权限位都是“-”符号，因此其他用户对db.dat文件不拥有写入和执行的权限。所谓其他用户，是指当前系统中除文件所有者和文件的用户组成员之外的其他所有用户。在上面的例子中，是指除root用户和root组成员之外的其他的用户。

从上面的分析中，可以得知，在UNIX系统中，我们可以分别给所有者、组成员，以及其他用户赋予不同的权限。作为初学者，必须弄清楚这3类权限的设置方法，如果权限设置不当，会导致灾难性的后果。

注意：在上面的例子中尽管root用户也属于root用户组的成员，但是文件的用户组权限并不适用于所有者。文件的所有者有单独的权限设置。如果root用户放弃了对于db.dat文件的所有权，那么root用户将拥有对于db.dat文件的组权限。

12.2　查找文件

文件的搜索是UNIX及Linux中经常用到的操作。在Shell程序设计的时候，也经常用到文件搜索。Linux提供了几个关于文件搜索的命令，例如locate、whereis以及find等。其中，find命令的功能最为强大，所以本节详细介绍find命令在Shell程序中的使用方法。

12.2.1　find命令以及语法
find命令是UNIX系统中最古老、最复杂的程序之一。find命令的功能非常强大，可以根据不同的标准搜索任何文件，并且可以在任何位置搜索。另外，当find搜索完成之后，可以以多种不同的方式来处理搜索结果。因此，find命令是进行文件搜索的最常用的工具。

严格来说，find命令的语法非常复杂。实际上find命令的完整语法已经超出本书所讨论的范围，所以下面将逐步介绍最常用的功能。

简单地讲，find命令的基本思想就是搜索一个或者多个目录树，根据用户指定的测试条件，查找符合特定标准的文件。当查找完成之后，find命令将对查找到的文件执行指定的操作。这些操作可以非常简单，例如仅仅将文件名打印出来；也可以非常复杂，例如删除文件、显示文件的详细信息或者将搜索结果传递给另外一个命令。

在使用find命令的，用户通常需要提供3种信息，分别为要搜索的路径、测试条件以及操作。find命令的基本语法如下：

find path test action

其中，path参数表示要搜索的路径，用户可以同时指定多个路径，路径之间用空格隔开。test参数表示测试条件，同样，用户也可以同时指定多个测试条件，它们之间用空格隔开。action参数则表示对于搜索结果要执行的操作，如果有多个操作，则这些操作之间用空格隔开。

find命令的工作流程如下：

（1）搜索所有用户指定的路径，包括所有的子目录。

（2）对于遇到的每个文件，检查是否符合用户指定的条件。在该步骤中，所有符合条件的文件形成一个列表。

（3）对于结果列表中的每个文件，执行用户指定的操作。

例如，下面是一个简单的find命令：

[root@linux ~]# find / -name ls -print

在上面的命令中，路径为根目录/，表示在当前系统的所有的目录及其子目录中进行搜索。测试条件为-name ls，表示搜索文件名为ls的文件。操作为-print，表示将搜索到的文件名打印出来。

12.2.2　find命令：路径
find命令的路径参数表示在何处搜索指定的文件。通常情况下，路径都是一个目录名。用户可以使用相对路径、绝对路径，以及简写形式来指定路径。例如，下面的命令中都是一些合法的路径：

find /usr/bin
find /
find .
find ~root

在上面的命令中，第1个命令表示搜索/usr/ bin目录及其子目录。第2个命令表示搜索根目录及其子目录，实际上是搜索整个文件系统。第3个命令是搜索当前的目录，第4个命令是搜索root用户的主目录。

注意：在上面的命令中，并没有指定测试条件和操作，表示将所有的的文件打印出来。

【例12-1】通过find命令在当前的目录中搜索扩展名为.sh的文件，代码如下：

01 #---------------------------/chapter12/ex12-1.sh------------------
02 #! /bin/bash
03
04 # 在当前目录中查找扩展名为 sh 的文件
05 files='find . -name "*.sh"'
06
07 echo "$files"

在上面的代码中，第5行的find命令的路径参数为.，表示当前目录。测试条件为-name"*.sh"，表示搜索扩展名为.sh的文件。操作参数被省略，表示将搜索结果输出到标准输出设备。

该程序的执行结果如下：

[root@linux chapter12]# ./ex12-1.sh
./iex12-2.sh
./ex12-1.sh
./iex12-1.sh

如果用户想要在多个路径下面查找，则可以将这些路径全部列出来，中间用空格隔开，如下面的例子所示。

【例12-2】演示通过find命令同时搜索多个路径的方法，代码如下：

01 #---------------------------/chapter12/ex12-2.sh------------------
02 #! /bin/bash
03
04 # 同时指定多个路径
05 files='find /etc /usr/local -name httpd.conf'
06
07 echo "$files"

该程序的执行结果如下：

[root@linux chapter12]# ./ex12-2.sh
/etc/httpd/conf/httpd.conf

从上面的执行结果可以得知，指定的httpd.conf文件位于/etc/httpd/conf目录下面。

12.2.3　find命令：测试
find命令的测试条件用来对搜索结果进行筛选。只有符合指定条件的文件才会出现在最终的搜索结果中。前面已经讲过，当用户省略测试条件时，则表示指定目录中的所有的文件都将出现在搜索结果中。如表12-1所示，列出了find命令中常用的测试条件。

表12-1　常用的find命令测试条件

条件 | 说　明
-name pattern | 表示包含指定匹配模式的文件名
-iname pattern | 表示包含指定匹配模式的文件名，该条件不区分字母大小写
-type | 指定文件类型，可以取f和d这两个值，分别表示普通文件和目录
-perm mode | 匹配其权限被设置为指定mode的文件
-user userid | 匹配其所有者为指定用户ID的文件
-group groupid | 匹配其所有者的主组为指定组ID的文件
-size size | 匹配其大小为size的文件
-empty | 匹配空文件
-amin [-+]n | 文件最后一次访问时间，其中-n表示访问时间为距今n分钟以内，+n表示访问时间距今n分钟以前，n表示恰好为n分钟
-atime [-+]n | 文件的最后一次访问时间，其中-n表示访问时间为n天以内，+n表示访问时间为n天以前，n表示最后一次访问时间恰好为n天
-cmin [-+]n | 文件最后一次状态改变的时间，其中-n表示访问时间为距今n分钟以内，+n表示访问时间距今n分钟以前，n表示恰好为n分钟
-ctime [-+]n | 文件的最后一次状态改变的时间，其中-n表示访问时间为n天以内，+n表示访问时间为n天以前，n表示最后一次访问时间恰好为n天
-mmin [-+]n | 文件内容最后一次被修改的时间，其中-n表示访问时间为距今n分钟以内，+n表示访问时间距今n分钟以前，n表示恰好为n分钟
-mtime [-+]n | 文件内容最后一次被修改的时间，其中-n表示访问时间为n天以内，+n表示访问时间为n天以前，n表示最后一次访问时间恰好为n天

表12-1列出的测试条件可以分为3组，分别为文件名、文件的特征，以及文件的访问或者修改时间。其中，-name和-iname是从文件名进行测试；-type、-perm、-user、-group、-size及-empty是从文件的特征方面进行测试；-amin、-atime、-cmin、-ctime、-mmin及-mtime等是从文件的最后访问时间及修改时间方面进行测试。

在find命令的测试条件中，最常用的是-name和-type，下面将分别进行介绍。

【例12-3】分别统计/etc目录中全部的文件数量及子目录数量，代码如下：

01 #----------------------------/chapter12/ex12-3.sh------------------
02 #! /bin/bash
03
04 # 统计文件数量
05 files='find /etc -type f -print | wc -l'
06 # 统计子目录数量
07 directories='find /etc -type d -print | wc -l'
08 # 输出统计结果
09 echo "There are $files regular files in /etc directory."
10 echo "There are $directories directories in /etc directory."

在上面的代码中，第5行的find命令中使用-type f指定文件类型为普通文件，然后将搜索结果通过管道传递给wc命令。第7行的find命令使用-type d选项指定文件类型为目录，同样将搜索结果传递给wc命令。

该程序的执行结果如下：

[root@linux chapter12]# ./ex12-3.sh
There are 1539 regular files in /etc directory.
There are 269 directories in /etc directory.

注意：在-type选项中，除了f表示普通文件，d表示目录之外，还有c表示字符设备， b表示块设备，p表示管道，以及l表示链接等。

如果想通过文件名搜索文件，可以使用-name和-iname这两个选项，这两个选项的区别在于前者区分字母大小写，而后者则不区分大小写。

【例12-4】演示通过文件名来搜索文件的方法，代码如下：

01 #----------------------------/chapter12/ex12-4.sh------------------
02 #! /bin/bash
03
04 # 搜索名称为 httpd.conf 的文件
05 file='find /etc -name httpd.conf -print'
06
07 # 输出前 5 行
08 head -5 $file

在上面的代码中，第5行的find命令中使用-name选项指定要搜索的文件名为httpd.conf，第8行将搜索到的文件的前5行输出到屏幕。

该程序的执行结果如下：

[root@linux chapter12]# ./ex12-4.sh
#
# This is the main Apache server configuration file. It contains the
# configuration directives that give the server its instructions.
# See <URL:http://httpd.apache.org/docs/2.2/> for detailed information.
# In particular, see

通常情况下，-name选项会精确匹配要搜索的文件名，在【例12-4】中，类似httpd.conf.bak等的文件名不会出现在搜索结果中。为了能够使搜索结果更加完整，用户可以使用通配符?、*或者[]。其中?表示匹配任意一个单个字符，*表示匹配0或者多个任意字符，[]表示匹配其中的任意一个字符。在使用通配符的时候，注意要使用引号将其引用起来，这样的话可以避免在传递给find命令之前Shell解释这些通配符，从而导致搜索出现错误。

【例12-5】本例对【例12-4】进行修改，在-name选项中使用了通配符*，代码如下：

01 #----------------------------/chapter12/ex12-5.sh------------------
02 #! /bin/bash
03
04 # 使用通配符扩大搜索范围
05 file='find /etc -name "httpd.conf*" -print'
06
07 head -5 $file

在上面的代码中，第5行的-name "httpd.conf*"表示匹配以字符串httpd.conf开头的文件名，后面可以跟随任意多个字符。

该程序的执行结果如下：

[root@linux chapter12]# ./ex12-5.sh
==> /etc/httpd/conf/httpd.conf <==
#
# This is the main Apache server configuration file. It contains the
# configuration directives that give the server its instructions.
# See <URL:http://httpd.apache.org/docs/2.2/> for detailed information.
# In particular, see
==> /etc/httpd/conf/httpd.conf.bak <==
#
# This is the main Apache server configuration file. It contains the
# configuration directives that give the server its instructions.
# See <URL:http://httpd.apache.org/docs/2.2/> for detailed information.
# In particular, see
==> /etc/httpd/conf/httpd.conf.bak.20120320 <==
#
# This is the main Apache server configuration file. It contains the
# configuration directives that give the server its instructions.
# See <URL:http://httpd.apache.org/docs/2.2/> for detailed information.
# In particular, see

从上面的执行结果可以得知，使用通配符之后，程序输出了3个文件的内容，其文件名分别为httpd.conf、httpd.conf.bak，以及httpd.conf.bak.20120320。

方括号[]也是一种非常有用的通配符，用户可以将一系列的字符放在方括号中，find命令会匹配方括号中的任意一个字符，例如：

find /etc -name "a[abc]" -print
find /etc -name "data[123abc]" -print

上面第1条命令表示搜索文件名为aa、ab，以及ac的文件；第2条命令表示搜索文件名为data1、data2、data3、dataa、datab，以及datac的文件。如果方括号中是一段连续的字母或者数字，则可以使用连字符-来简化书写方法。例如上面的两条命令与下面的两条命令等价：

find /etc -name "a[a-c]" -print
find /etc -name "data[1-3a-c]" -print

【例12-6】演示方括号通配符的使用方法，代码如下：

01 #----------------------------/chapter12/ex12-6.sh------------------
02 #! /bin/bash
03
04 # 搜索文件名为 rc 后面跟随 1 个数字的文件名
05 files='find /etc -name "rc[1-9].d" -print'
06
07 echo "$files"

在上面的代码中，第5行的find命令的测试条件为-name"rc[1-9].d"，表示搜索文件名以rc开头，然后跟随1～9中的任意一个数字，然后以.d结尾的文件。

该程序的执行结果如下：

[root@linux chapter12]# ./ex12-6.sh
/etc/rc1.d
/etc/rc3.d
/etc/rc5.d
/etc/rc2.d
/etc/rc4.d
…

注意：尽管在使用通配符的时候不使用引号不一定总是出现错误，但是使用单引号或者双引号将通配符引用起来是一个非常好的习惯。可以有效地避免在传递给find命令之前，Shell解释并应用其中的通配符。

在find命令中，用户可以同时使用多种测试条件，例如：

[root@linux chapter12]# find /bin -type f -name ls
/bin/ls

表示搜索类型为普通文件，名称为ls的文件。

对于表12-1中列出的其他测试条件，用户可以自己去练习使用，此处不再举例说明。

12.2.4　find命令：使用!运算符对测试求反
find命令提供了一个感叹号运算符!，用来对测试条件求反。!符号可以加在任何测试条件的前面，表示其后测试的相反条件。在使用!运算符的时候，一定要注意语法问题，即必须在!符号的左右各留一个空格，这样find命令才会正确解释!运算符。另外，为了避免Shell解释!符号，用户需要使用单引号或者反斜线将其引用起来。

例如，下面的命令搜索/data1/wwwroot目录中，除jpg文件之外的普通文件：

[root@linux chapter12]# find /data1/wwwroot -type f \! -name "*.jpg" -print
/data1/wwwroot/demo.360zippo.com/gallery.php
/data1/wwwroot/demo.360zippo.com/goods.php
/data1/wwwroot/demo.360zippo.com/ECShop_V2.7.3_UTF8_release1106.rar
/data1/wwwroot/demo.360zippo.com/vote.php
/data1/wwwroot/demo.360zippo.com/languages/zh_cn/calendar.php
...

在上面的命令中，-type f表示搜索普通文件，\!-name"*.jpg"表示不匹配以.jpg为扩展名的文件。

如果使用单引号，则上面的命令可以书写为：

[root@linux chapter12]# find /data1/wwwroot -type f '!' -name "*.jpg" -print

12.2.5　find命令：处理文件权限错误信息
find命令可以在任意指定的位置中搜索指定的文件。但是，Linux系统的权限非常严格。如果当前用户对于指定的目录没有访问权限，则find命令会给出一些错误信息，如下：

[root@linux chapter12]# su - chunxiao
[chunxiao@linux ~]$ find / -name httpd.conf
find: '/proc/tty/driver': Permission denied
find: '/proc/1/task/1/fd': Permission denied
find: '/proc/1/task/1/fdinfo': Permission denied
find: '/proc/1/fd': Permission denied
…

在上面的命令中，首先使用su命令从root用户切换到另外一个普通用户chunxiao，然后执行find命令。在find命令执行的过程中，给出一系列Permission denied的错误消息，这意味着当前用户对于当前目录没有访问权限。尽管在命令行中执行find命令的时候，这些错误消息并不会引起什么问题。但是，如果在Shell程序中执行find命令，出现这些错误消息，则会导致意想不到的结果。因此，在Shell程序中，用户应该尽量避免这类错误消息的出现。

实际上，find命令的错误消息是写入到标准错误的，通常情况下是输出到屏幕上。因此，用户可以通过标准错误重定向到/dev/null来达到将错误消息直接丢弃的目的。其中/dev/null是一个空设备，任何写入到该设备的数据都将被直接丢弃。

在下面的命令中，使用重定向将错误消息丢弃：

[chunxiao@linux ~]$ find / -name httpd.conf 2> /dev/null
/etc/httpd/conf/httpd.conf

在上面的命令中，2是一个文件描述符，表示标准错误，大于号>表示输出重定向。关于文件描述符和重定向，将在本章的后面几节中详细介绍。从上面的执行结果可以得知， find命令的错误消息已经不再出现。

12.2.6　find命令：动作
find命令最为强大的功能在于不仅仅可以根据复杂的条件来搜索文件，而且还可以非常方便地对搜索结果进行处理。前面已经讲过find命令的语法中的第3部分的action参数就是find命令对于搜索结果的动作。如表12-2所示，列出了find命令中常用的动作。

表12-2　find命令中常用的动作

动作 | 说　明
-print | 默认动作，将搜索结果写入到标准输出
-fprint file | 与-print相同，但是将搜索结果写入到文件file
-ls | 以详细格式（长格式）显示搜索结果
-fls file | 同-ls，但是将搜索结果写入到文件file
-delete | 将搜索到的文件删除
-exec command {} \; | 查找并执行命令，{}表示搜索到的文件名
-ok command {} \; | 查找并执行命令，但是需要用户确认

在find命令的动作中，-print和-fprint的作用都是输出完整的文件名，但是前者是写入标准输出，后者是写入用户指定的文件。-ls和-fls的作用都是以长格式列出搜索结果，前者是写入标准输出，后者写入用户指定的文件。-delete动作的作用是将搜索到的文件直接删除。用户在使用-delete的时候一定要非常谨慎，因为该动作不给出任何确认提示。

-exec使find命令对搜索结果中的文件执行指定的Shell命令，相应的Shell命令的语法为：

'command' {} \;

其中command表示Shell命令，大括号{}表示搜索结果中的文件名，最后的分号表示命令的结束。注意，最后的分号需要反斜线来转义，不能单独使用分号。另外，分号和前面的大括号之间有一个空格。

-ok和-exec的作用相同，只不过以一种更为安全的方式来执行该参数所给出的Shell命令，在执行每一个命令之前，都会给出提示，让用户来确定是否执行。

大多数情况下，用户需要搜索某些文件，然后将其删除，这是作为系统管理员最常用的命令之一。

【例12-7】演示使用find命令搜索并删除文件的方法，代码如下：

01 #----------------------------/chapter12/ex12-7.sh------------------
02 #! /bin/bash
03
04 # 查找并删除扩展名为 .php 的文件
05 find ./tmp -name "*.php" -exec rm -f {} \;
06
07 # 如果命令成功退出，则输出成功提示，否则给出错误提示
08 if [ $? -eq 0 ]
09 then
10 echo "the files have been deleted successfully.";
11 else
12 echo "Failed to delete files.";
13 fi

在上面的代码中，第5行是find命令，其搜索路径为./tmp，即当前目录中的tmp子目录。测试条件为-name"*.php"，使用通配符*来表示所有扩展名为.php的文件。动作为-exec rm-f{}\;，其中的Shell命令为rm-f，即将指定的文件强制删除，不给出任何提示。后面的{}作为rm命令的参数，表示文件名，即find命令搜索出来的文件。命令的最后以\;符号结束。

第8行使用系统变量$?来获取find命令的退出状态，如果等于0，则表示执行成功，因此输出成功提示；否则，给出错误提示。

该程序的执行结果如下：

[root@linux chapter12]# ./ex12-7.sh
the files have been deleted successfully.

注意：在使用-exec和-ok时，一定要注意语法。另外，-exec在执行Shell命令的时候不会给出任何提示，因此，在使用时一定要慎重。

12.3　比较文件

通常情况下，Linux中的数据都是以文本文件的形式存储的，并且每一行为一条记录。因此，在Shell程序中处理数据的话，经常会遇到将多个数据文件中的数据进行对比分析的情况。本节将介绍Linux中最常用的两个比较文件的命令comm和diff。

12.3.1　使用comm比较文件
comm命令的作用是逐行比较两个有序的文件，其语法如下：

comm [option]... file1 file2

其中，option表示comm命令的有关选项，比较常用的选项如下所列。

- -1：不显示第1个文件中独有的文本行。

- -2：不显示第2个文件中独有的文本行。

- -3：不显示两个文件中共同的文本行。

- --check-order：检查参与比较的两个文件是否已经排序。

- --nocheck-order：不检查参与比较的两个文件是否已经排序。

file1和file2分别表示参与比较的两个文件。

默认情况下，comm命令会输出3列，分别为第1个文件独有的文本行、第2个文件独有的文本行，以及两个文件公共的文本行。用户可以使用-1、-2以及-3选项分别隐藏这3个列。

在学习comm命令之前，用户需要准备两个文本文件，其名称分别为students.txt和students1.txt，其内容分别如下：

[root@linux chapter12]# cat students.txt
01 Abdel
02 Abdiel
03 Abdieso
04 Abdul
05 Abejundio
06 Abel
07 Abelard
08 Abenzio
09 Abercio
10 Abhay
11 Abhi
12 Accursius
13 Achilles
14 Babul
15 Bae
16 Bahar
17 Bailey
18 Baingana
19 Baird
20 Calixto
21 Callis
22 Calvert
23 Camlin
24 Campbell
[root@linux chapter12]# cat students1.txt
01 Abdel
02 Abdiel
03 Abdieso
04 Abdul
05 Abejundio
06 Abel
07 Abelard
08 Abenzio
09 Abercio
10 Abhay
11 Abhi
12 Ace
13 Babul
14 Bae
15 Bahar
16 Bailey
17 Baingana
18 Baird
19 Bane
20 Bansi
21 Calixto
22 Callis
23 Calvert
24 Camlin
25 Campbell
26 Carden

从上面的输出信息可以得知，students.txt和students1.txt存储的是一些学生的姓名。用户可以在本书所配光盘中的chapter12目录中找到这两个文件。这两个文件都是已经按照字母顺序排好的，因此，可以直接使用comm命令来比较其内容的差异。

作为初学者，应该首先了解一下直接使用comm命令对比这两个文件的结果，代码如下：

[root@linux chapter12]# comm students.txt students1.txt
Abdel
Abdiel
Abdieso
Abdul
Abejundio
Abel
Abelard
Abenzio
Abercio
Abhay
Abhi
Accursius
Ace
Achilles
Babul
Bae
Bahar
Bailey
Baingana
Baird
Bane
Bansi
Calixto
Callis
Calvert
Camlin
Campbell
Carden

从上面的输出结果可以得知，comm命令会将比较结果分为3列输出，这3列之间用制表符隔开。另外，即使在输出结果中，每一行都是一条记录。

【例12-8】通过comm命令对students.txt和students1.txt这两个文件的内容进行对比，并给出对比结果，代码如下：

01 #----------------------------/chapter12/ex12-5.sh------------------
02 #! /bin/bash
03
04 # 找出第 1 个文件独有的文本行，并去掉空行
05 p1='comm -2 -3 students.txt students1.txt'
06
07 echo "These students only appear in students.txt:"
08 echo "$p1"
09
10 # 找出第 2 个文件独有的文本行，并去掉空行
11 p2='comm -1 -3 students.txt students1.txt'
12
13 echo "These students only appear in students1.txt:"
14 echo "$p2"
15
16 # 找出 2 个文件共有的文本行，并去掉空行
17 p3='comm -1 -2 students.txt students1.txt'
18
19 echo "These students appear in both students.txt and students1.txt:"
20 echo "$p3"

在上面的代码中，第5行找出只在students.txt文件出现的学生姓名，第11行找出只在students1.txt文件中出现的学生姓名，第17行找出同时在students.txt和students1.txt出现的学生姓名。

该程序的执行结果如下：

[root@linux chapter12]# ./ex12-8.sh
These students only appear in students.txt:
Accursius
Achilles
These students only appear in students1.txt:
Ace
Bane
Bansi
Carden
These students appear in both students.txt and students1.txt:
Abdel
Abdiel
Abdieso
Abdul
Abejundio
Abel
Abelard
Abenzio
Abercio
Abhay
Abhi
Babul
Bae
Bahar
Bailey
Baingana
Baird
Calixto
Callis
Calvert
Camlin
Campbell

有了上面的输出结果之后，用户可以进一步对各项数据进行统计，如下面的例子所示。

【例12-9】通过comm命令结合管道和wc命令对3类学生数据分别进行统计，代码如下：

01 #----------------------------/chapter12/ex12-9.sh------------------
02 #! /bin/bash
03
04 # 统计只出现在 students.txt 文件中的学生数
05 q1='comm -2 -3 students.txt students1.txt | wc -l'
06
07 echo "These are $q1 students only appear in students.txt."
08
09 # 统计只出现在 students1.txt 文件中的学生数
10 q2='comm -1 -3 students.txt students1.txt | wc -l'
11
12 echo "These are $q2 students only appear in students1.txt."
13
14 # 统计同时出现在 students.txt 和 students1.txt 文件中的学生数
15 q3='comm -1 -2 students.txt students1.txt | wc -l'
16
17 echo "These are $q3 students appear in both students.txt and
students1.txt."

在上面的代码中，第5、10和15行分别统计出只在students.txt、students1.txt，以及同时在两个文件中出现的学生人数。

该程序的执行结果如下：

[root@linux chapter12]# ./ex12-9.sh
These are 2 students only appear in students.txt.
These are 4 students only appear in students1.txt.
These are 22 students appear in both students.txt and students1.txt.

12.3.2　使用diff比较文件
diff命令功能也是逐行比较多个文件的内容。与Comm所不同的是，diff命令并不要求参与比较的文件是有序的。另外，diff不仅可以比较普通文件，而且还可以比较多个目录的内容的差异。diff命令的基本语法如下：

diff [option]... files

其中，option表示命令选项。diff命令的选项主要是用来控制输出的格式，常用的选项如下所列。

- -c：输出包含上下文环境的格式。

- -u：以统一格式显示文件的不同。

- -y：以并列的方式显示文件的异同之处。

files参数表示参与比较的文件列表。

【例12-10】演示默认情况下，diff命令的输出格式，命令如下：

[root@linux chapter12]# diff students.txt students1.txt
01 12,13c12
02 < Accursius
03 < Achilles
04 ---
05 > Ace
06 19a19,20
07 > Bane
08 > Bansi
09 24a26
10 > Carden

在上面的输出信息中，diff命令使用非常简洁的语法来描述两个文件的差异。通常情况下，文件内容的差异包括增加、删除和修改这3种操作，diff分别使用a、d和c这3个字母来表示这3种操作。

第1行中的12,13c12表示第1个文件的12、13行在第2个文件中已经变成了第12行。在students.txt文件中，第11行为Abhi，第12行为Accursius，第13行为Achilles，第14行为Babul。在students1.txt文件中，第11行为Abhi，第12行为Ace，第13行为Babul。通过对比可以发现，students.txt文件的第11行、第14行分别与students1.txt文件的第11行、第13行相同，所以diff认为students.txt文件中的第11行和第14行之间的第12、13行在students1.txt文件中被替换为第11行和第13行之间的第12行。

输出结果的第2～5行是两个文件中的具体的内容差异。其中第2、3行列出的是第1个文件中与第2个文件不同的内容，在每行的左边都使用左尖括号<表示。第5行列出的是第2个文件中与第1个文件不同的内容，在每行的左边使用右尖括号>表示。这两部分内容之间用横线隔开。

第6～8行描述的是另外一处的不同。19a19,20表示在第2个文件在第1个文件的19行后面增加了2行，即第2个文件的第19～20行，具体内容在随后的第7、8行列出。同理，第9、10行描述了两个文件的第3个不同的地方。

包含上下文环境的输出格式是另外一种非常有用的格式。用户可以使用-c选项使得diff命令以上下文格式来输出比较的结果，代码如下：

[root@linux chapter12]# diff -c students.txt students1.txt
01 *** students.txt 2013-03-30 21:30:05.195223029 +0800
02 --- students1.txt 2013-03-30 21:29:07.096311797 +0800
03 ***************
04 *** 9,24 ****
05 Abercio
06 Abhay
07 Abhi
08 ! Accursius
09 ! Achilles
10 Babul
11 Bae
12 Bahar
13 Bailey
14 Baingana
15 Baird
16 Calixto
17 Callis
18 Calvert
19 Camlin
20 Campbell
21 --- 9,26 ----
22 Abercio
23 Abhay
24 Abhi
25 ! Ace
26 Babul
27 Bae
28 Bahar
29 Bailey
30 Baingana
31 Baird
32 + Bane
33 + Bansi
34 Calixto
35 Callis
36 Calvert
37 Camlin
38 Campbell
39 + Carden

在上面的输出结果中，第1行和第2行是一些文件的信息，包括文件名及一个时间戳。从第4行开始，是比较的结果。其中，星号标注的是原始文件的内容的范围，例如第4行的星号之间的行数为9,24，表示下面列出的行数为原始文件的第9～24行。连字符标注的范围是新文件中的内容的范围，例如第21行的行数为9,26，表示下面列出的是新文件的第9～26行。从上面的输出可以发现，上下文格式不仅列出了两个文件中不同的行，而且还列出了一些相同的行，这些相同的行正是所谓的上下文环境，其作用是为了更加准确地定位不同之处。

同样，上下文格式也使用了一些特殊的字符来表示不同的差异，行首的感叹号!表示两个文件对应行的内容被修改，行首的加号+表示新增加的行，行首的空格表示没有发生改变的行。从上面的输出结果可以得知，两个文件的内容发生了3处修改，3处增加。

统一格式（unified format）保留了上下文环境格式的优点，但是它以更为简洁的方式来来显示文件的差异。用户可以使用-u选项启用统一格式，代码如下：

[root@linux chapter12]# diff -u students.txt students1.txt
01 --- students.txt 2013-03-30 21:30:05.195223029 +0800
02 +++ students1.txt 2013-03-30 21:29:07.096311797 +0800
03 @@ -9,16 +9,18 @@
04 Abercio
05 Abhay
06 Abhi
07 -Accursius
08 -Achilles
09 +Ace
10 Babul
11 Bae
12 Bahar
13 Bailey
14 Baingana
15 Baird
16 +Bane
17 +Bansi
18 Calixto
19 Callis
20 Calvert
21 Camlin
22 Campbell
23 +Carden

在上面的输出结果中，第1、2行与上下文环境格式基本相同，显示文件的相关信息，但是原始文件的信息以连字符-标注，而新文件的信息以加号+标注。

第3行描述的是一段内容范围，以两个@开始，两个@结束。-9,16表示原始文件的第9～16行，+9,18表示新文件的第9～18行。在下面的内容中，行首的加号表示增加的行，减号表示删除的行。

除了上面几种格式之外，diff命令还有一种更为直观的格式，将参与比较的两个文件的内容分为两列显示出来，用户可以使用-y选项来输出这种格式，代码如下：

[root@linux chapter12]# diff -y students.txt students1.txt
Abdel Abdel
Abdiel Abdiel
Abdieso Abdieso
Abdul Abdul
Abejundio Abejundio
Abel Abel
Abelard Abelard
Abenzio Abenzio
Abercio Abercio
Abhay Abhay
Abhi Abhi
Accursius | Ace
Achilles <
Babul Babul
Bae Bae
Bahar Bahar
Bailey Bailey
Baingana Baingana
Baird Baird
> Bane
> Bansi
Calixto Calixto
Callis Callis
Calvert Calvert
Camlin Camlin
Campbell Campbell
> Carden

12.4　文件描述符

在Shell程序中操作文件的时候，经常会遇到使用文件描述符的情况。使用文件描述符来操作文件，可以使程序符合POSIX标准。本节将介绍文件描述符的基础知识、常用的文件描述符，以及自定义文件描述符的方法。

12.4.1　什么是文件描述符

从形式上讲，文件描述符是一个非负整数。实际上，它是一个索引值，指向内核为每个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时，内核向进程返回一个文件描述符。在程序设计中，一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX及Linux等操作系统。

在Linux中，所有的设备都是文件。习惯上，标准输入的文件描述符是0，标准输出的文件描述符是1，标准错误的文件描述符是2。尽管这种习惯并非严格的标准，但是因为绝大部分的Shell和应用程序都遵循这种习惯，所以用户在编写Shell程序的时候，也要注意遵循这种习惯，避免其他问题的出现。

文件描述符从0开始，其最大值与当前系统中可以打开的最大文件数有关。每个系统对于文件描述符的上限都有自己的规定，例如FreeBSD及Solaris中，每个进程最多可以打开的文件数取决于系统内存的大小，整数类型的最大值及系统管理员的设定的限制。Linux的内核规定每个进程最多打开的文件数不能超过1,048,576个。

文件描述符是由无符号整数表示的句柄，进程使用它来标识打开的文件。文件描述符与包括文件的相关信息，例如文件的打开模式、文件的位置类型、文件的初始类型等相关联，这些信息被称做文件的上下文。

12.4.2　标准输入、标准输出和标准错误

当用户在Shell中执行命令的时候，每个进程都和3个文件描述符相关联，并且使用这3个文件描述符来引用相关的文件。这3个文件描述符分别为标准输入、标准输出和标准错误。在默认情况下，这3个文件描述符会自动创建，用户直接使用即可。

标准输入的文件描述符为0，表示默认的输入文件，即Shell需要执行命令的来源。通常情况下，标准输入为键盘，因为绝大部分的命令来自于键盘的输入。但是，标准输入也可以是文件或者其他的命令的输出，这需要使用输入重定向。

标准输出的文件描述符为1，表示默认的输出文件，即显示Shell命令的执行结果的地方。通常情况下，标准输出的设备为显示器，但是也可以是其他的文件或者设备。

标准错误的文件描述符为2，标准Shell命令的错误消息输出的地方。在默认情况下，标准错误与标准输出的设备相同，也是显示器，但是也可以是其他文件或者设备。

在Shell命令执行的时候，都会继承父进程的文件描述符。因此所有运行的Shell命令，都会有默认3个文件描述符。

例如，用户在Shell命令行中执行以下命令：

[root@linux chapter12]# ls -l /etc
total 2228
drwxr-xr-x. 3 root root 4096 Feb 21 2012 abrt
drwxr-xr-x. 4 root root 4096 Feb 21 2012 acpi
-rw-r--r--. 1 root root 45 Oct 12 07:53 adjtime
..

当用户输入以上命令，并按回车键之后，Shell会创建一个进程来执行ls命令，该进程会继承其父进程，即当前的Shell的3个文件描述符。因此，子进程也可以从标准输入读取数据，并且向标准输出和标准错误写入数据。

上面的命令的功能是列出/etc目录中的文件列表。其中参数/etc是用户通过键盘输入， ls命令从标准输入（即键盘）读取的。而后面列出的一系列文件则是ls命令输出到标准输出（即显示器）的数据。

从上面的例子可以得知，在默认情况下，ls命令从标准输入读取参数，然后在单独的进程中执行，并且将执行结果写入到标准输出。

如果在命令执行的过程中发生错误，Shell命令会将错误消息写入到标准错误，在默认情况下，标准错误也是显示器。例如，下面的ls命令在执行的时候发生了错误：

[root@linux chapter12]# ls -lz /etc
ls: invalid option -- 'z'
Try 'ls --help' for more information.

很明显，ls命令并没有提供-z选项，所以在ls命令执行的时候，出现了无效选项的错误。ls命令将该错误消息输出到了显示器。

注意：0、1和2都是约定俗成的文件描述符，用户自定义的文件描述符不能使用这3个数字。

12.5　重定向

在Linux的输入输出中，重定向占有非常重要的地位。在编写Shell程序以及进行系统管理的时候，经常与重定向打交道。因此对于初学者来说，搞清楚重定向的使用技巧非常有用。

12.5.1　输出重定向（覆盖）

通常情况下，所谓输出重定向是指将原本输出到标准输出的数据输出到其他文件或者设备中。输出重定向的操作符为大于号>，其基本语法如下：

cmd > file

在上面的语法中，cmd表示Shell命令，大于号>为重定向操作符，file表示重定向的目标文件。

如图12-4所示，描述了输出重定向的过程。在执行重定向操作的时候，如果目标文件file不存在，则重定向操作符>会先创建一个名称为file空文件，然后向该文件中写入数据；如果目标文件已经存在，则重定向操作符>会清空原始文件的内容，然后再向该文件写入数。

图12-4　输出重定向

在前面一节中，我们举了一个ls命令的例子。下面的例子中，使用输出重定向操作将ls命令的执行结果输出到一个磁盘文件中：

[root@linux chapter12]# ls -l /etc > filelist
[root@linux chapter12]# cat filelist
total 2228
drwxr-xr-x. 3 root root 4096 Feb 21 2012 abrt
drwxr-xr-x. 4 root root 4096 Feb 21 2012 acpi
-rw-r--r--. 1 root root 45 Oct 12 07:53 adjtime
…

在上面的例子中，ls命令的执行结果不再显示到屏幕上面，而是写入到名称为filelist的文件中。从cat命令的执行结果可以得知，文件filelist中包含了ls命令的输出结果。

实际上，输出重定向操作符还有一种完整的语法，如下：

cmd [n]> file

在上面的语法中，cmd同样表示Shell命令，n为一个整数，表示文件描述符，大于号为重定向操作符，file为重定向的目标文件。当Shell在执行以上命令时，会将文件file打开，并且与文件描述符n相关联。

在上一节中，我们已经介绍过3个常用的文件描述符为0、1和2。其中，1和2都是用来输出数据的，分别表示标准输出和标准错误。在执行输出重定向的时候，如果文件描述符被省略的话，则表示重定向的是标准输出。这意味着输出重定向的默认文件描述符为1。所以上面的例子中的命令的结果与下面的命令是相同的：

[root@linux chapter12]# ls -l /etc 1> filelist

注意：文件描述符与大于号之间没有任何空格，大于号与后面的文件名之间的空格可有可无。

除了将标准输出重定向到文件之外，用户还可以将标准错误重定向文件。下面的例子将标准输出和标准错误都重定向到某个文件，如下：

[root@linux chapter12]# ls -lz 1> filelist 2> errmsg
[root@linux chapter12]# cat errmsg
ls: invalid option -- 'z'
Try 'ls --help' for more information.

在上面的命令中，使用2>errmsg将标准错误重定向到名称为errormsg的文件。因此，尽管有一个错误的选项，但是ls命令的错误消息并没有输出到屏幕，而是保存到了errormsg文件中。

对于标准输出和标准错误的同时重定向，还有一种更为简洁的语法，命令如下：

[root@linux chapter12]# ls -lz &> filelist

上面的命令中，&符号代表标准输出和标准错误。

在Linux中，/dev/null是一个空设备，任何写入到该设备的数据都将被直接丢弃。因此，如果用户想要把某个命令的输出信息和错误信息完全丢弃，可以将其重定向到/dev/null文件，命令如下：

[root@linux chapter12]# ls -l &> /dev/null

以上命令表示将ls命令的标准输出和标准错误同时重定向到空设备。

由于重定向操作符>会覆盖原始文件的内容，所以该操作符为用户提供了一个非常实用的技巧，即快速地清空一个文件的内容或者创建一个空文件，命令如下：

[root@linux chapter12]# > errmsg

上面的命令表示将文件errormsg的内容清空，如果该文件不存在，则创建一个空白文件。

然而，上述命令不一定在所有的Shell中都能够正常工作，还有一种更为通用的语法，命令如下：

[root@linux chapter12]# :> errmsg

上面的命令中的冒号表示一个空输出。

绝大部分的Shell还支持将一组命令的输出重定向，其语法如下：

{ cmd1;cmd2;...; } [n]> file

在上面的语法中，使用大括号将所有的命令包围起来，多个命令之间用分号隔开。命令与左右大括号之间各保留一个空格，最后一条命令的结尾也要使用分号。例如，下面的命令将date和who这两个命令的标准输出和标准错误都重定向的名称为message的文件：

[root@linux chapter12]# { date;who; } &> message

12.5.2　输出重定向（追加）

除了覆盖模式之外，输出重定向还有一种追加模式，其操作符为连续的两个大于号>>。>>操作符的功能与>基本相同，只是前者不会覆盖目标文件的内容，而是追加在已有文件的后面。>>操作符的基本语法如下：

cmd [n]>> file

其中cmd表示Shell命令，n为文件描述符，如果省略n，则默认值为1，即标准输出。>>操作符同样支持一组命令，语法如下：

{ cmd1;cmd2;...; } [n]>> file

下面的命令演示了这种追加模式的输出重定向的使用方法：

[root@linux chapter12]# date >> date
[root@linux chapter12]# date >> date
[root@linux chapter12]# cat date
Sun Mar 31 15:53:21 HKT 2013
Sun Mar 31 15:53:22 HKT 2013

在上面的例子中，连续执行了两次重定向命令，其目标文件都为date，从cat命令的输出结果可以得知，第2次的重定向并没有覆盖第1次重定向的内容。

12.5.3　输入重定向

通常情况下，Shell命令会从标准输入，即键盘读取用户输入的数据。但是Shell提供了另外一种读取用户输入的机制，即从文件中获取输入，这种机制称为输入重定向，如图12-5所示。输入重定向与输出重定向非常相似，其操作符为小于号<，基本语法如下：

cmd < file

在上面的语法中，cmd表示Shell命令，file文件的内容将作为cmd命令的输入。

图12-5　输入重定向

例如，在没有提供文件名作为参数的情况下，grep命令会从标准输入读取数据。下面的命令通过重定向使grep命令间接地从students.txt文件中读取数据：

[root@linux chapter12]# grep Bae < students.txt
Bae

与输出重定向相对应，输入重定向默认的文件描述符为0，所以上面的命令等价于下面的命令：

[root@linux chapter12]# grep Bae 0< students.txt
Bae

注意：文件描述符0与小于号之间没有任何空格。

输入重定向和输出重定向可以同时使用，表示从文件中读取输入数据，然后将执行结果输入到文件中。例如，在下面的命令中，grep命令将从students.txt文件中读取数据，其执行结果将被写入到demo.txt文件：

[root@linux chapter12]# grep Bae > demo.txt < students.txt
[root@linux chapter12]# cat demo.txt
Bae

12.5.4　当前文档

输入重定向的另外一个用途是生成当前文档（here documents）。当前文档主要用在命令行中需要多行输入的情况，其基本语法如下：

cmd << delimiter
document
delimiter

其中，cmd表示Shell命令，<<为输入重定向操作符，delimiter为分隔符。当Shell遇到重定向操作符<<时，会一直读取用户的输入，直到遇到某一行，其中包含指定的分隔符delimiter。两个分隔符delimiter之间的行都属于命令cmd的标准输入。最后的delimiter告诉Shell当前文档已经结束。如果没有后面的分隔符，Shell将会继续读取输入，并会永远进行下去。

注意：分隔符delimiter不能含有空格或者制表符。

在下面的例子中，使用当前文档作为cat命令的标准输入，命令如下：

[root@linux ~]# cat << eof
> This is a test file.
> There are two lines.
> eof
This is a test file.
There are two lines.

在上面的命令中，分隔符为字符串eof，当Shell遇到<<符号时，便会出现一个>提示符，用户可以在后面输入文本。输入完毕之后，再输入分隔符eof，告诉Shell已经输入完毕。接着Shell会执行前面的cat命令来输出用户输入的文本。

用户可以将当前文档操作符和输出重定向结合起来，从而将当前文档的内容输出到磁盘文件，代码如下：

[root@linux ~]# cat > test.txt << eof
> This is a test file.
> There are two lines.
> eof
[root@linux ~]# cat test.txt
This is a test file.
There are two lines.

12.5.5　重定向两个文件描述符

在前面的例子中，介绍了输出重定向，即把标准输出重定向到文件。除此之外，用户还可以通过重定向操作将到一个文件描述符的输出重定向到另外一个文件描述符，即复制一个文件描述符，其语法如下：

n>&m

在上面的语法中，n和m都是文件描述符。当n=1，且m=2时，文件描述符1成为文件描述符2的副本，所以所有的标准输出都被重定向到标准错误；而当n=2，m=1时，文件描述符2成为文件描述符1的副本，所以所有的标准错误都被重定向到标准输出。当n=1时，可以省略。

例如，下面的命令将标准错误复制到标准输出：

[root@linux chapter12]# echo "hello,world" 1>&2

由于标准输出文件描述符1可以省略，所以上面的命令与下面的命令的功能相同：

[root@linux chapter12]# echo "hello,world" >&2

用户可以将输出重定向和文件描述符的重定向结合起来，代码如下：

[root@linux chapter12]# echo "Hello, world." > message 2>&1
[root@linux chapter12]# cat message
Hello, world.

在上面的例子中，echo命令的输出结果会被重定向到文件message中，由于标准错误也被重定向到标准输出，所以如果在命令的执行过程中出现错误，错误消息也被重定向到文件message中。

注意：除了使用操作符>&重定向文件描述符之外，用户还可以使用操作符<&来重定向文件描述符。

12.5.6　使用exec命令分配文件描述符

用户在命令后面使用重定向操作符的时候，重定向只对当前的命令有效。用户可以使用exec命令创建新的文件描述符，并且将文件描述符绑定到文件或者另外一个文件描述符或者文件。如表12-3所示，列出了常用的重定向操作。

表12-3　常用的重定向操作

重定向 | 说　明
exec 2> file | 将所有命令的标准错误重定向到文件file
exec n< file | 只读的方式打开名称为file的文件，并且使用文件描述符n，n是大于3的整数
exec n> file | 以写入的方式打开名称为file的文件，并且使用文件描述符n，n是大于3的整数
exec n<> file | 以读写方式打开文件file，并且使用文件描述符n，n是大于3的整数
exec n>&- | 关闭文件描述符n
exec n>&m | 使得文件描述符n成为文件描述符m的副本，即将文件描述符m复制到n
exec n>&- | 关闭文件描述符n

【例12-11】演示exec命令分配文件描述符的方法，代码如下：

01 #---------------------------/chapter12/ex12-11.sh------------------
02 #! /bin/bash
03
04 # 创建标准错误副本
05 exec 99>&2
06
07 # 将标准错误重定向到文件 errlog
08 exec 2> errlog
09 # 执行命令
10 ls -lw
11 # 恢复标准错误
12 exec 2>&99
13 # 关闭文件描述符
14 exec 99>&-

在上面的代码中，第5行使用exec命令为标准错误创建一个副本，便于在程序的后面恢复标准错误。第8行将标准错误重定向到名称为errlog的文件。第10行执行一条含有错误选项的ls命令。第12行恢复标准错误的文件描述符。第14行使用exec命令关闭自定义的文件描述符99。

该程序的执行结果如下：

[root@linux chapter12]# ./ex12-11.sh
[root@linux chapter12]# cat errlog
ls: option requires an argument -- 'w'
Try 'ls --help' for more information.

当执行完成之后，查看errlog文件，可以发现其中已经包含ls命令的错误消息。下面再举一个更为复杂的例子。

【例12-12】列出当前目录中的文件，并且在文件名前面加上行号，代码如下：

01 #---------------------------/chapter12/ex12-12.sh------------------
02 #!/bin/bash
03 echo 'START'
04
05 # 将 ls -l 命令的执行结果重定向到文件描述符 100
06 exec 100< <(ls -l)
07
08 # 定义变量
09 num=1
10 # 通过循环从文件描述符 100 中读取数据行
11 while read line; do
12 # 输出数据
13 echo "LINE $num: $line"
14 num=$(($num + 1))
15 done <&100
16 # 关闭文件描述符
17 exec 100>&-
18 echo 'END'

在上面的代码中，第6行是一种特殊的重定向，其功能为将ls-l命令的标准输出重定向到一个匿名管道，然后再将匿名管道与文件描述符100相关联。

注意：第6行是两个用空格隔开的小于号，不是重定向操作符<<。

第11～15行通过while循环从文件描述符100中读取数据，每次读取一行，当读取结束时，read命令的返回值为-1，循环终止。第13行输出含有行号的文件名，第14行是变量num的自增。第17行关闭文件描述符100。

该程序的执行结果如下：

[root@linux chapter12]# ./ex12-12.sh
START
LINE 1: total 108
LINE 2: -rw-r--r-- 1 root root 834 Mar 31 14:52 d
LINE 3: -rw-r--r-- 1 root root 58 Mar 31 15:53 date
LINE 4: -rw-r--r-- 1 root root 4 Mar 31 16:39 demo.txt
LINE 5: -rw-r--r-- 1 root root 12 Mar 31 20:24 echo.txt
…
LINE 29: drwxr-xr-x 17 root root 4096 Mar 30 20:52 tmp.bak
END

12.6　小结

本章详细介绍了Shell程序中的文件的操作方法。主要内容包括文件的类型及其权限、文件的查找方法、文件的比较方法、文件描述符，以及输入输出重定向等。重点在于掌握文件的基本类型、访问权限的表示方法、find命令的基本用法、文件描述符，以及基本的重定向操作。在下一章中，将介绍子Shell与进程处理。

第13章
子Shell与进程处理

当用户在命令行中执行一个Shell脚本的时候，会感觉到Shell脚本就是在当前的命令行提示符下被解释的。实际上，其过程并不是用户看到的那样，在脚本被执行的时候，当前的Shell会启动另外一个Shell实例，这样的话，每个Shell脚本都有效地运行在父Shell的一个子进程中。在进行Shell编程的时候，不可避免地会遇到处理子Shell和父Shell的关系，以及进程和作业的控制，本章将对子Shell和进程处理进行介绍。

本章主要涉及的知识点如下所述。

- 子Shell：主要介绍子Shell的相关知识，包括Shell内部命令、外部命令、圆括号命令，以及子Shell中的变量的作用域等。

- 进程处理：主要介绍进程和作业的控制，包括什么是进程、通过Shell脚本管理进程、作业控制、信号，以及trap命令等。

13.1　子Shell

Shell本身也是一个程序，也可以启动自己的子进程，这些子进程称为子Shell。作为初学者，必须搞清楚子Shell与其父Shell的区别与联系，以及Shell命令与子Shell的关系。本节将对子Shell的基础知识进行介绍。

13.1.1　什么是子Shell

当用户登录Linux或者UNIX之后，操作系统会根据用户/etcpasswd文件中的配置启动一个Shell进程，该Shell进程为当前用户所执行的Shell命令的父进程。每个用户都可以指定自己的默认Shell程序，下面显示的是/etc/passwd文件的部分内容：

[root@linux chapter13]# cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
…

从上面的内容可以得知，/etc/passwd每一行描述的是一个用户的信息，每一行由7个字段组成，这些字段之间用分号隔开。7个字段的涵义如下：

登录名 :x: 用户 ID: 用户组 ID: 备注信息 : 用户主目录 : 默认 Shell 程序

其中，默认Shell程序在第7个字段定义，并且使用绝对路径表示。如果这个字段的Shell程序不存在、不合法或者执行失败，则无法登录主机。如果某个用户不需要登录，则可以将该用户的默认的Shell程序设置为/sbin/nologin。

当用户在执行一个Shell脚本的时候，父Shell会根据脚本程序的第1行#!符号后面指定的解释器程序开启一个子Shell进程，然后在子Shell的环境中执行该Shell脚本。一旦子Shell中的脚本执行完毕，该子Shell进程随即结束，并且返回到父Shell中。这个过程不会影响父Shell中的环境。

所谓子Shell，实际上是父Shell的一个子进程。子Shell本身也可以创建自己的子进程，从而成为其子进程的父Shell。从定义可以看出，父Shell和子Shell都是相对的。某个Shell可以成为一个Shell的父Shell，也可以同时成为另外一个Shell的子Shell，反之亦然。在环境变量，以及标准输入、标准输出和标准错误等方面，父Shell和子Shell是相同的。

【例13-1】演示子Shell与父Shell的关系，代码如下：

01 #----------------------------/chapter13/ex13-1.sh------------------
02 #! /bin/bash
03
04 # 改变工作目录
05 cd /var/log
06 # 打印当前工作目录
07 pwd

在上面的代码中，第5行通过cd命令改变当前的工作目录，第7行通过pwd命令打印当前的工作目录。

该程序的执行结果如下：

[root@linux chapter13]# ./ex13-1.sh
/var/log
[root@linux chapter13]#

从上面的执行结果可以得知，当执行ex13-1.sh时，Shell会创建一个子Shell，子Shell和父Shell是相对独立的。用户在脚本中执行的Shell命令并不会影响到父Shell，例如第5行代码的cd命令在子Shell中改变当前的工作目录，当子Shell退出之后，父Shell当前的工作目录不受影响。

如果用户想要在当前的Shell中执行脚本，则可以使用圆点命令，如下：

[root@linux chapter13]# . ./ex13-1.sh
/var/log
[root@linux log]# pwd
/var/log

从上面的执行结果可以得知，使用圆点命令在当前的Shell中执行脚本，会影响到当前Shell环境。在上面的例子中，当前Shell的工作目录已经被切换到/var/log。

注意：圆点命令与脚本文件名之间有一个空格。

13.1.2　内部命令、保留字和外部命令

Shell命令分为内部命令和外部命令。所谓内部命令，是指包含在Shell工具包中的命令，内部命令是Shell本身的重要组成部分。内部命令嵌入在Shell程序中，并不单独以磁盘文件的形式存在于磁盘上。例如cd、bg，以及fg等命令都是bash Shell的内部命令。如表13-1所示，列出了bash Shell中常用的内部命令。

表13-1　bash Shell常用的内部命令

内部命令 | 说　明
. | 读取Shell脚本，并在当前Shell中执行脚本
alias | 设置命令别名
bg | 将作业置于后台运行
cd | 改变当前工作目录
echo | 打印指定的文本
eval | 将参数作为Shell命令执行
exec | 以特定的程序取代Shell或者改变当前Shell的输出输入
exit | 退出Shell
export | 将变量声明为环境变量
fc | 与命令历史一起运行
fg | 将作业置于前台运行
getopts | 处理命令行选项
history | 显示命令历史
jobs | 显示在后台运行的作业
kill | 向进程发送信号
logout | 从Shell中注销
pwd | 显示当前的工作目录
set | 设置Shell环境变量
shift | 变换命令行参数

内部命令实际上是Shell程序的一部分，其中包含的是一些比较简练的Linux系统命令，这些命令由Shell程序识别并在Shell程序内部完成运行，通常在加载用户的默认Shell时就被加载并驻留在系统内存中。

除了内部命令之外，还有一部分保留字也是Shell的重要组成部分，例如if、for、then，以及while等都是bash Shell的内置关键字。保留字不是Shell命令，通常用在Shell脚本中，组成Shell脚本的基本语法结构。如表13-2所示，列出了bash Shell中常用的保留字。

表13-2　bash Shell中常用的保留字

保留字 | 说　明
! | 逻辑非
: | 空命令
break | 跳出for、while，以及until循环结构
case | 多重条件判断
continue | 跳过for、while、until，以及select等结构中后面的语句，从头开始执行下一次循环
declare | 声明并定义变量属性
do | 语句块的定义，常用于for、while、until，以及select等循环结构中
done | 语句块的定义，常用于for、while、until，以及select等循环结构中
elif | if条件判断结构中的分支语句
else | if条件判断结构中的分支语句
esac | case多条件分支结构的结束语句
for | 循环语句
let | 执行算术运算
local | 定义局部变量
read | 从标准输入读取一行数据
return | 从函数或者脚本返回
test | 条件测试
then | if条件判断结构中的关键字
until | 循环语句
wait | 等待后台作业完成
while | 循环语句

外部命令是Linux系统中的实用程序部分，这些实用程序以磁盘文件的形式存在于磁盘中。在用户登录时，外部命令并不随着默认Shell的载入被加载到内存中，而是在需要的时候才被调进内存。

尽管外部命令的代码不包含在Shell程序中，但是其命令执行过程则是由Shell程序控制的。Shell程序管理外部命令执行时的路径查找和代码加载，并控制命令的执行。

绝大部分的Shell命令都是外部命令，例如ls、at、du、host，以及id等。外部命令通常位于/usr/bin及/usr/sbin等目录中，其中/usr/sbin中的命令通常与系统管理有关。

由于内部命令的代码嵌入到Shell程序中，所以当用户执行内部命令，Shell并不需要创建子Shell，而是由当前的Shell程序直接解释并执行。而外部命令则是由当前的Shell程序创建一个子Shell，然后在子Shell环境中执行的。当命令执行完成之后，子Shell退出，并返回到父Shell中。

【例13-2】说明Shell在执行外部命令时的过程，代码如下：

01 #----------------------------/chapter13/ex13-2.sh------------------
02 #! /bin/bash
03
04 # 查找包含 ps 字符串的进程
05 ps -ef|grep ps
06 # 显示当前 Shell 的层次
07 echo $SHLVL
08 # 查找执行 ex13-2.sh 脚本的 Shell 进程的 ID
09 pidof -x ex13-2.sh
10
11 exit 0

在上面的代码中，第5行通过ps命令、管道，以及grep命令来查找含有字符串ps的进程列表。第7行使用系统变量$SHLVL来输出当前Shell的层次，该层次以用户的登录Shell为第1层。第9行使用pidof命令获取执行脚本ex13-2.sh的子Shell的进程ID。

该程序的执行结果如下：

[root@linux chapter13]# ./ex13-2.sh
…
root 4958 4957 1 23:50 pts/0 00:00:00 ps -ef
root 4959 4957 0 23:50 pts/0 00:00:00 grep ps
…
2
4957

在上面的输出结果中，最开始的表格是第4行代码的ps命令输出的，一共有8列，分别是进程的所有者、进程ID、父进程ID、CPU使用率、进程启动时间、终端号、进程使用CPU累计时间，以及所执行的命令。在此，我们比较关注进程的ID和父进程ID。在上面的输出结果中，执行命令ps-ef的进程ID为4958，其父进程ID为4957。在接下来第7行代码的echo语句输出当前Shell的层次为2，这意味着脚本是在登录Shell的子Shell中执行。最后，pidof命令的输出结果为4957，即执行脚本的子Shell的进程ID为4957。

有了上面的结果之后，我们再进一步分析一下【例13-2】的脚本执行的整个过程。首先，无论是ps还是grep命令，都是外部命令，所以Shell会为这两个命令创建子Shell来执行。而这两个子Shell的进程ID分别为4958和4959。另外，脚本ex13-2.sh本身也是在用户登录Shell的子Shell中执行的，这个子Shell的进程ID即pidof命令的输出结果，即4957。而这个进程ID恰好是登录Shell的子Shell的进程ID。因此，【例13-2】中的Shell之间的关系如图13-1所示。

图13-1　【例13-2】中的子Shell之间的关系

13.1.3　在子Shell中执行命令

接下来讨论一下在哪些情况下Shell会在子Shell中执行命令。实际上，不同的Shell可能会以不同的方式来执行命令。但是，在bash Shell中，以下几种情况通常会使Shell在其子Shell中执行命令。

1．圆括号结构

当一组命令放在圆括号中时，该组命令会在一个子Shell环境中执行，其语法如下：

(command1;command2;command3;...)

在上面的语法中，command1、command2，以及command3等都是Shell命令，这些命令写在一行中，它们之间用分号隔开。

如果每一行只有一条命令，则可以省略分号，变成以下语法形式：

(
command1
command2
command3
...
)

【例13-3】演示圆括号结构的使用方法，代码如下：

01 #----------------------------/chapter13/ex13-3.sh------------------
02 #!/bin/bash
03
04 echo
05 # 输出子 Shell 的层次
06 echo "Subshell level OUTSIDE subshell = $BASH_SUBSHELL"
07
08 echo
09 # 定义子 Shell 外面的变量
10 outer_variable=Outer
11 # 圆括号开始
12 (
13 # 输出子 Shell 的层次
14 echo "Subshell level INSIDE subshell = $BASH_SUBSHELL"
15 # 定义子 Shell 内的变量
16 inner_variable=Inner
17 # 在子 Shell 内输出圆括号里面定义的变量
18 echo "From subshell, \"inner_variable\" = $inner_variable"
19 # 在子 Shell 内输出圆括号外面定义的变量
20 echo "From subshell, \"outer\" = $outer_variable"
21 )
22
23 echo
24 # 输出子 Shell 级别
25 echo "Subshell level OUTSIDE subshell = $BASH_SUBSHELL"
26 echo
27 # 判断 inner_variable 变量是否已经定义
28 if [ -z "$inner_variable" ]
29 then
30 echo "inner_variable undefined in main body of shell"
31 else
32 echo "inner_variable defined in main body of shell"
33 fi
34 # 输出圆括号内定义的变量
35 echo "From main body of shell, \"inner_variable\" = $inner_variable"

在上面的代码中，第6行使用系统变量BASH_SUBSHELL获取当前子Shell的层次。如果当前环境为用户登录时默认的Shell，则该变量的值为0。第10行在圆括号结构外面定义了一个变量outer_variable，并且赋予了初始值。第12～21行是圆括号结构，其中包含4条语句。第14行在圆括号内部输出子Shell的层次。第16行定义了另外一个变量inner_variable。第18行输出变量inner_variable的值，第20行输出变量outer_variable的值。

第25行重新在圆括号结构外面输出当前子Shell的层次。第28～33行判断变量$inner_variable是否已经被定义，并且根据不同的情况输出不同的消息。第35行输出圆括号结构内定义的变量inner_variable的值。

该程序的执行结果如下：

[root@linux chapter13]# ./ex13-3.sh
Subshell level OUTSIDE subshell = 0
Subshell level INSIDE subshell = 1
From subshell, "inner_variable" = Inner
From subshell, "outer" = Outer
Subshell level OUTSIDE subshell = 0
inner_variable undefined in main body of shell
From main body of shell, "inner_variable" =

在上面的输出结果中，可以发现代码第6行的echo语句的输出结果为0，表示当前处于最顶层的子Shell中。而代码第14行的echo语句的输出结果为1，表示当前子Shell的层次为1。代码第18行和第20行的echo语句分别输出了圆括号外面和内部定义的两个变量值。从上面的执行结果可以得知，子Shell中可以访问父Shell中定义的变量的值。代码第25行的echo语句的输出结果为0，表示程序已经退出子Shell。最后，在子Shell中定义的变量不能在父Shell中访问。

由于圆括号结构中的命令都是在一个子Shell中执行的，与调用另外一个脚本中的代码非常相似，所以通过圆括号结构，用户可以将Shell程序中的某一段代码放在后台执行，其实现方法就是在圆括号结构的后面使用&操作符。

【例13-4】演示将脚本中的部分代码放在后台执行的方法，代码如下：

01 #----------------------------/chapter13/ex13-4.sh------------------
02 #!/bin/bash
03
04 # 输出开始提示信息
05 echo "Before starting subshell"
06 # 圆括号结构开始
07 (
08 count=1
09 while [ $count -le 10 ]
10 do
11 echo "$count"
12 sleep 1
13 # 在 Shell 中修改循环变量的值
14 (( count++ ))
15 done
16 ) &
17 echo "Finished"

在上面的代码中，第7～16行是整个圆括号结构。第8行定义了循环变量count，并且赋予初始值1。第9行是while循环结构的开始，其循环条件为变量count的值小于等于10。第11行输出循环变量的值，第12行使得当前的进程睡眠1秒钟。第14行又是另外一个嵌套两层的圆括号结构，在该结构中执行循环变量的自增。第16行使用&操作符将整个圆括号结构置于后台执行。

该程序的执行结果如下：

[root@linux chapter13]# ./ex13-4.sh
Before starting subshell
Finished
1
[root@linux chapter13]# 2
3
4
5
6
7
8
9
10

当用户输入以上命令执行ex13-4.sh的时候，当前的Shell会创建一个子进程来执行脚本文件。代码第5行的echo语句输出了一条信息。当遇到圆括号及&操作符的时候，Shell会将其中的命令放在另外一个子Shell中并且作为后台作业执行。此时，执行脚本文件的子进程和圆括号中的代码是并行地执行的。所以执行脚本ex13-4.sh的子进程继续执行圆括号后面的代码，即代码第17行的echo语句输出另外一条信息。此时，正在后台执行的圆括号中的代码输出了循环变量的值1。

执行脚本的子进程在执行完代码第17行的echo语句后退出，返回到命令提示符。而圆括号中的代码却仍然在执行，依次输出了2～10的值。

通过上面的例子可以得知，子Shell中的代码可以访问父Shell中的变量的值。并且，当变量的值在子Shell中修改之后，在父Shell中可以获得变化之后的值。

注意：在【例13-4】中第14行的语句使用了两层圆括号，所以会导致产生两层子Shell。

2．后台执行或异步执行

在某些情况下，Shell命令需要较长的时间来执行，尤其是在处理大量的数据的时候。在这种情况下，用户可以将命令置于后台执行，而不必等待命令执行结束。

将命令置于后台执行的语法如下：

command&

其中，command表示要执行的命令，&操作符表示将前面的命令置于后台执行。在命令末尾追加&操作符之后，当前命令会由一个子Shell在后台执行。当前的Shell会立即获得控制权并且返回到命令行提示符。后台命令和当前的Shell是并行的，相互之间没有依赖及等待关系。这意味着，后台命令和当前Shell是异步的并行。

用户可以在任何一个Shell命令后面使用&操作符，将该命令置于后台运行。关于&操作符的例子，在上面的【例13-4】中已经介绍过了，此处不再详细说明。

3．命令替换

命令替换的语法如下：

'command'

或者

$(command)

其中，command表示要执行的命令。command会在一个子Shell中执行，不会影响当前的Shell环境。

【例13-5】演示使用命令替换在子Shell中执行命令，代码如下：

01 #----------------------------/chapter13/ex13-5.sh------------------
02 #! /bin/bash
03
04 # 在子 Shell 中执行命令，并且返回结果
05 $(cd /;ls;echo "current working directory is ";pwd)
06
07 echo "current working directory is"
08
09 # 在父 Shell 中输出当前工作目录
10 pwd

在上面的代码中，第5行使用$()符号执行一组Shell命令，这些命令之间用分号隔开，第1个命令为cd，将子Shell的当前工作目录切换到根目录；第2个命令为echo，用来输出一行提示信息；第3个命令为pwd，输出当前工作目录。第10行在父Shell中输出当前的工作目录。

该程序的执行结果如下：

[root@linux chapter13]# ./ex13-5.sh
bin data2 etc lib64 mnt root sys
boot data_.base.tar home lost+found net sbin tmp
cgroup demo.sh ImagedTabSet.css media opt selinux usr
data1 dev lib misc proc srv var
current working directory is
/
current working directory is
/root/chapter13

从上面的执行结果可以得知，子Shell中的cd命令只会改变子Shell的工作目录，并不影响父Shell的环境，因此，在上面的例子中，父Shell的工作目录仍然为/root/chapter13，并没有切换到根目录。

除了以上两种在子Shell中执行命令的方法之外，还有其他一些方法，例如管道以及进程替换等。在不同的Shell中，对于管道和进程替换的处理方法会有所不同，读者可以参考相关的书籍，不再详细介绍。

13.1.4　把子Shell中的变量值传回父Shell

在子Shell中，代码可以访问父Shell的变量；反之，在父Shell中，无法访问到子Shell中的变量的值。但是，用户可以通过一些变通的技巧来取得子Shell中的变量的值。下面将分别进行介绍。

1．通过临时文件

在Linux中，通过临时文件传递数据是一个非常重要的技巧，许多地方都能使用到。对于磁盘文件来说，只要拥有足够的权限，任何进程都可以访问到。另外，通过临时文件，用户可以在进程之间传递大量的数据，不会受到内存空间的限制。

【例13-6】演示通过临时文件在父子Shell之间传递数据，代码如下：

01 #----------------------------/chapter13/ex13-6.sh------------------
02 #! /bin/bash
03
04 (
05 # 在子 Shell 中定义变量 x
06 x=500
07 # 将变量 x 的值输出到临时文件 tmp
08 echo "$x" >tmp
09 )
10
11 # 在父 Shell 中直接引用变量 x 的值
12 echo "$x"
13 # 从临时文件中读取变量 x 的值
14 read b <tmp
15
16 echo "$b"

在上面的代码中，第4～9行是圆括号结构，将在子Shell中执行。第6行定义了一个名称为x的变量，第8行将变量x的值通过重定向输出到临时文件tmp中。第12行在父Shell中直接引用变量x的值。第14行通过read语句从临时文件中读取变量x的值。

该程序的执行结果如下：

[root@linux chapter13]# ./ex13-6.sh
500

从上面的执行结果可以得知，第12行的echo语句的输出为空串。而第16行的echo语句正确输出了变量x的值。

2．使用命名管道

命名管道是Linux及UNIX系统中的最古老的进程间通信的方式，同时也是一个相对比较简单的通信机制。

【例13-7】演示使用命名管道实现向父shell传递数据的方法，代码如下：

01 #----------------------------/chapter13/ex13-7.sh------------------
02 #! /bin/bash
03
04 # 创建名称为 fifo 的命名管道
05 if [ ! -e fifo ];then
06 mkfifo fifo
07 fi
08
09 # 子 Shell
10 (
11 x=500
12 # 将变量 x 的值输出到管道
13 echo "$x" > fifo
14 )&
15 # 从管道中读取数据
16 read y <fifo
17
18 echo "$y"

在上面的代码中，第5～7行判断命名管道fifo是否存在，如果不存在，则创建该管道。第10～14行是圆括号结构，并且在圆括号后面使用了&操作符将该结构放在后台运行。第16行从命名管道fifo中读取数据。

该程序的执行结果如下：

[root@linux chapter13]# ./ex13-7.sh
500

3．不使用子Shell

之所以出现以上变量传递问题，是因为使用了子Shell。如果不使用子Shell，则以上问题就不存在。当用户在某个Shell脚本中调用另外一个脚本时，被调用的脚本会在子Shell中执行。但是，用户可以通过圆点命令和source命令来执行脚本，使得被调用的脚本在当前Shell进程中执行。

【例13-8】演示两个脚本之间传递数据的方法，其中output.sh的代码如下：

01 #----------------------------/chapter13/output.sh------------------
02 #! /bin/bash
03
04 # 输出变量 message 的值
05 echo "$message"

从上面的代码可以得知，output.sh的功能非常简单，只是输出变量message的值。但是变量message并没有在当前脚本中定义。

脚本ex13-8.sh的代码如下：

01 #----------------------------/chapter13/ex13-8.sh------------------
02 #! /bin/bash
03
04 # 定义变量 message ，并赋予初始值
05 message="Hello world."
06
07 # 使用 source 命令调用脚本
08 source ./output.sh

在上面的代码中，第5行定义了名称为message的变量，第8行使用source命令调用output.sh脚本。

上述程序的执行结果如下；

[root@linux chapter13]# ./ex13-8.sh
Hello world.

从上面的执行结果可以得知，尽管变量在ex13-8.sh脚本中定义，但是仍然可以在output.sh脚本中访问。之所以会这样，是因为在第8行中使用source命令调用output.sh脚本，这使得output.sh脚本与ex13-8.sh脚本在同一个Shell进程中执行，所以两者的变量可以相互访问。

除了上面介绍的临时文件和命名管道之外，还有许多方法可以实现子Shell向父Shell传递数据，例如命令替换和使用eval命令等，读者可以根据自己的实际情况来选择适当的通信机制。

13.2　进程处理

在进行系统维护的过程中，经常会遇到进程和作业的处理问题。通过Shell编程，用户可以对进程和作业进行有效地管理。本节介绍如何通过Shell进行进程的相关操作。

13.2.1　什么是进程

在讲到进程的时候，不得不提到另外一个概念，即程序。通常情况下，我们所讲的程序是计算机指令的集合，是一个静态的概念。

进程是指在自身的虚拟地址空间运行的一个单独的程序，是程序执行的基本单元。进程会利用处理器资源、内存资源，并且进行各种I/O操作，从而完成某项指定的任务。因此，进程是一个动态的概念。

进程与程序是有区别的，进程不是程序，虽然它由程序产生。程序只是一个静态的指令集合，不占系统的运行资源；而进程是一个随时都可能发生变化的、动态的、使用系统运行资源的程序。而且一个程序可以启动多个进程。

大致来说，Linux中的进程可以分为以下3类。

- 交互进程：由Shell启动的进程。交互进程既可以在前台运行，也可以在后台运行。

- 批处理进程：进程序列。

- 监控进程：又称为守护进程，Linux的服务进程，在后台运行。

13.2.2　通过脚本监控进程

通常情况下，通过脚本来监控进程可以收到事半功倍的效果。例如，在绝大部分的Web服务器都是运行在Linux上面。但是在某些情况下，Web服务器的进程httpd会由于某些错误而退出，导致用户的网站不能访问。而系统管理员则不可能会24小时都在监控Web服务器的运行状态。

在这种情况下，系统管理员可以编写一个脚本来监控Web服务器进程是否存在，如果不存在，则重新启动该服务进程。

【例13-9】演示Web服务器监控脚本的编写方法，代码如下：

01 #----------------------------/chapter13/ex13-9.sh------------------
02 #!/bin/bash
03
04 #Apache httpd 进程监控 shell
05
06 # 启动服务命令
07 RESTART="/sbin/service httpd start"
08
09 #pgrep 命令路径
10 PGREP="/usr/bin/pgrep"
11
12 #Apache Web 服务器的进程名称
13 HTTPD="httpd"
14
15 # 查找 httpd 进程
16 $PGREP ${HTTPD} &>/dev/null
17 # 如果没有找到，则重新启动服务
18 if [ $? -ne 0 ]
19 then
20 $RESTART
21 fi

在上面的代码中，第7行定义了重新启动Apache Web访问进程的命令；第10行定义了pgrep命令的路径；第13行定义了Apache Web服务的进程名称；第16行使用pgrep命令判断Apache Web服务进程是否存在，其中的重定向是为了避免pgrep命令输出信息。第18行通过系统变量$?判断第16行的pgrep命令是否执行成功，如果不等于0，则表示不存在Apache Web服务进程，从而在第20行启动该服务进程。

为了能够周期性地检查Apache Web服务的进程是否存在，用户可以使用crontab任务计划来定期执行上面的脚本，代码如下：

*/30 * * * * /root/chapter13/ex13-9.sh >/dev/null 2>&1

以上代码表示每30秒钟执行一次/root/chapter13/ex13-9.sh脚本。

13.2.3　作业控制

通常情况下，我们将一个正在执行的进程称为一个作业。尽管进程和作业密不可分，但是进程和作业是有区别的。一般来说，作业是针对用户而言的，是用户为了完成某项任务而启动的进程，一个作业可以包含一个进程，也可以包含多个进程，这些进程之间相互协作，共同完成任务。而进程则是针对操作系统而言的，是操作系统中程序执行的基本单位。

例如，下面的一组名称可以称为一个作业，其功能是在ls命令的手册中查找包含long字符串的文本行，然后将匹配结果传递给more命令，代码如下：

[root@linux chapter13]# man ls | grep long | more
Mandatory arguments to long options are mandatory for short options
across -x, commas -m, horizontal -x, long -l, single-column -1,
in a long listing, don’t print group names
-l use a long listing format
with -l, show times using style STYLE: full-iso, long-iso, iso,

尽管上面的一组命令构成了一个作业，但是实际上Shell会启动3个进程，分别执行man、grep及more命令。

作业控制指的是用户控制正在运行的组成作业的进程的行为。在前面已经介绍过，用户可以在命令的后面附加&操作符，使该命令在后台执行。另外，用户还可以将作业中的某个进程挂起，暂停其执行，然后在某个时刻继续执行该进程。

【例13-10】演示将进程进行前后台切换的方法，代码如下：

01 #---------------------------/chapter13/ex13-10.sh------------------
02 #! /bin/bash
03
04 # 使得当前进程休眠 10 秒钟
05 sleep 10

上面的代码比较简单，其中第5行使用sleep命令使当前进程休眠10秒钟。sleep命令的功能是使进程休眠指定的时间，其基本语法如下：

sleep number[suffix]

其中，参数number表示要休眠的时间长度，参数suffix表示时间单位，默认为秒，可以是秒、分钟、小时及天等，这些时间单位分别使用s、m、h及d等字母表示。

在命令行中输入以下命令执行ex13-10.sh：

[root@linux chapter13]# ./ex13-10.sh &
[1] 28426

当用户输入以上命令，并且按回车键之后，Shell会将脚本放在后台执行，并且立即返回两个数字，其中方括号中的数字为作业号，后面的数字为执行脚本的进程ID。由于脚本在后台执行，所以用户可以同时执行其他的操作。当作业执行完成之后，会在命令行给出以下提示：

[1]+ Done ./ex13-10.sh

以上提示包括作业号、状态，以及所执行的命令。

在执行前台作业的时候，用户可以通过组合键Ctrl+Z使当前的作业挂起，例如，用户在命令行中输入以下命令：

[root@linux chapter13]# vi demo.sh

当按回车键之后，Shell会调用vi编辑器，出现一个全屏编辑窗口。在编辑文本的过程中，用户需要暂时退出vi编辑器，执行其他的命令。则可以先切换到vi的命令状态，然后按下Ctrl+Z组合键。此时，Shell会给出以下提示信息：

[1]+ Stopped vi demo.sh
[root@linux chapter13]#

以上信息告诉用户，作业号为1的作业已经被挂起，其命令为vi demo.sh。接下来就是Shell的命令提示符。

当用户处理完其他任务之后，可以使用fg命令将后台执行的作业切换到前台执行，如下：

[root@linux chapter13]# fg

fg是一个内部命令，其作用是将后台的作业移至前台执行，其基本语法如下：

fg [jobspec]

在上面的语法中，参数jobspec用来指定要切换的作业，可以是作业号或者是作业的命令名称等。如果省略该参数，则表示将作业号为1的作业移至前台。因此，如果当前系统中只有一个作业在后台运行，则可以直接使用fg命令，省略其他参数。如表13-3所示，列出了fg命令中用来指定作业的方法。

表13-3　fg命令中指定作业的方法

方　法 | 说　明
%n | n为整数，表示作业号
%string | 以字符串string开头的命令所对应的作业
%?string | 包含字符串string的命令所对应的作业
%+或者%% | 最近提交的一个作业
%- | 倒数第2个提交的作业

例如，以下命令将以字符串vi开头的命令所对应的作业移至前台：

[root@linux chapter13]# fg %vi

前台作业和后台作业在功能上并没有什么不同。只是前台作业会占用终端窗口，用户不能同时执行其他命令，必须等待前台作业完成才能执行其他操作。而后台作业则不占用终端窗口，用户可以同时执行其他操作。

Shell提供了另外一个内部命令jobs，用来查看正在后台执行的作业的列表，其基本语法如下：

jobs [options]

其中，options表示jobs命令的选项，常用的选项有-l和-p，前者显示作业的详细信息，后者只显示作业的进程ID。例如，下面的jobs命令列出当前用户的所有后台作业：

[root@linux chapter13]# jobs
[1] Stopped vi demo.sh
[2]- Stopped vi demo1.sh
[3]+ Stopped vi demo2.sh

通过上面的结果，可以得知当前用户有3个后台作业在执行。默认情况下，jobs命令的输出结果包含3列，第1列为作业号，如果在某个作业号后面有一个加号+，则表示当前作业为默认作业。也就是说，在使用fg命令管理作业的时候，如果没有指定作业，则会将作业号后面附加+符号的作业移至前台。

另外，在上面的输出结果中，编号为2的作业号后面有一个减号-，该符号表示当前作业为即将成为默认作业的作业。也就是说，当含有+符号的作业退出之后，含有-符号的做作业将成为默认作业。

对于其他的作业，作业号后面是一个空格。对于同一个用户来说，只能有一个作业使用+符号标识，也只能有一个作业使用-符号标识。

在jobs命令的输出结果中，第2列为作业的执行状态。如表13-4所示，列出了几种常见的作业状态。

表13-4　作业状态

状　态 | 说　明
Running | 该作业并没有被挂起，而是正在运行
Done | 该作业已经完成并返回退出状态码0
Done (code) | 该作业已经正常完成和退出，并返回指定的非0退出状态码code。状态码使用十进制表示
Stopped | 该作业被挂起

用户可以使用disown命令来删除作业列表中的作业，该命令的基本语法如下：

disown [jobspec ...]

在上面的语法中，参数jobspec表示要从列表中删除的作业。与fd命令一样，用户可以使用作业号、进程ID，以及命令名称等方法来指定作业。

下面的例子演示了disown命令的使用方法。首先执行3个vi命令，并且使其在后台执行，代码如下：

[root@linux chapter13]# vi demo.sh &
[1] 30880
[root@linux chapter13]# vi demo1.sh &
[2] 30881
[1]+ Stopped vi demo.sh
[root@linux chapter13]# vi demo2.sh &
[3] 30890
[2]+ Stopped vi demo1.sh
[root@linux chapter13]# jobs
[1] Stopped vi demo.sh
[2]- Stopped vi demo1.sh
[3]+ Stopped vi demo2.sh

通过最后的jobs命令，可以得知当前有3个作业被挂起。其中3号作业为默认作业， 2号作业为即将成为默认作业的作业。接下来的命令将1号作业删除，代码如下：

[root@linux chapter13]# disown %1
-bash: warning: deleting stopped job 1 with process group 30880
[root@linux chapter13]# jobs
[2]- Stopped vi demo1.sh
[3]+ Stopped vi demo2.sh

在上面的命令中，disown使用%1作为参数。执行完该命令之后，Shell给出一条警告信息，告诉用户1号作业已经被删除。然后通过jobs命令再次输出作业列表，发现只剩下2号和3号作业。

注意：删除作业还可以使用kill命令，该命令将在信号中介绍。

13.2.4　信号与trap命令

信号在Linux系统中是非常重要的一种通信机制。信号在软件层次上模拟了硬件中断机制。因此，简单地讲，信号即软件中断。在Linux系统中，用户可以通过kill命令给某个进程发送一个特定的信号，也可以通过键盘发送一些信号，比如组合键CTRL+C可能触发SIGINT信号，而组合键CTRL+\可能触发SIGQUIT信号等。

绝大部分的系统都预定义了一些信号，用户可以通过kill命令的-l选项来列出当前系统支持的信号，代码如下：

[root@linux chapter13]# kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
…

注意：用户还可以通过man 7 signal命令查看有关信号的帮助手册。

如表13-5所示，列出了常用的信号及其涵义。

表13-5　常用信号及其涵义

信　号 | 值 | 涵义
SIGHUP | 1 | 终端挂起或者控制进程终止
SIGINT | 2 | 键盘中断
SIGQUIT | 3 | 键盘的退出键被按下
SIGABRT | 6 | 由abort(3)发出的退出指令
SIGKILL | 9 | 立即结束进程

表13-5只列出了最常用的几个信号，关于其他的信号，读者可以参考Linux的帮助手册。对于管理员来说，最常用的信号是SIGKILL，该信号用来立即结束进程的运行。SIGKILL信号不能被阻塞、处理和忽略。如果管理员发现某个进程终止不了，可尝试发送这个信号。

前面讲过，删除作业的方法除了使用disown命令之外，还可以使用kill命令。kill的基本语法如下：

kill [-s signal|-p] [--] pid...

在上面的语法中，-s选项用来指定要发送的信号，-p选项表示只是打印指定名称的进程的进程ID，不发送信号，参数pid表示某个特定的进程ID。

下面的例子演示了如何使用kill命令来删除作业。首先使用jobs命令列出当前的作业列表，如下：

[root@linux chapter13]# jobs
[2]- Stopped vi demo1.sh
[3]+ Stopped vi demo2.sh
[root@linux chapter13]# jobs -p
30881
30890

由于kill需要指定进程ID，所以还要使用jobs命令的-p选项输出作业的进程ID。现在想要删除进程ID为30881的作业，可以使用以下命令：

[root@linux chapter13]# kill -s 9 30881

以上命令表示向进程ID为30881的进程发送值为9的信号，即SIGKILL信号。执行完以上命令之后，再次使用jobs命令查看作业列表，代码如下：

[root@linux chapter13]# jobs
[2]- Killed vi demo1.sh
[3]+ Stopped vi demo2.sh
[root@linux chapter13]# jobs
[3]+ Stopped vi demo2.sh

从上面的执行结果可以得知，2号作业的状态为Killed，表示该作业已经退出。在接下来的作业列表中，该项作业已经消失。

注意：kill命令中的-s可以省略，直接使用数字，例如上面的命令可以直接写成kill -9 30881。

对于有些信号，进程会有默认的响应动作，而有些信号，进程可能直接会忽略，当然，用户还可以对某些信号设定专门的处理函数。在Shell程序中，用户可以通过trap命令来设定响应某个信号的动作。trap是一个内部命令，其基本语法如下：

trap [[arg] sigspec ...]

在上面的语法中，参数arg表示信号响应操作的函数，sigspec表示特定的信号。

【例13-11】演示trap命令的使用方法，代码如下：

01 #---------------------------/chapter13/ex13-11.sh------------------
02 #! /bin/bash
03
04 # 定义响应函数
05 function signal_handler {
06 echo "Good bye."
07 }
08
09 # 绑定响应函数
10 trap signal_handler 0

在上面的代码中，第5～7行定义了响应信号的函数，该函数比较简单，只是输出一行消息。第10行通过trap命令将函数与信号0绑定。信号0是一个特殊的信号，在POSIX标准中，把0定义为空信号，当进程退出时会触发该信号。因此用户经常使用该信号来判断一个特定的进程是否存在。

该程序的执行结果如下：

[root@linux chapter13]# ./ex13-11.sh
Good bye.

13.3　小结

本章详细介绍了子Shell以及进程和作业管理，主要包括什么是子Shell、内部命令、外部命令、圆括号结构、子Shell中的变量作用域、把子Shell中的变量值传回父Shell、什么是进程、创建单进程脚本、作业控制，以及信号与trap命令。重点在于掌握子Shell的基本概念、什么情况下会使用子Shell、子Shell的变量及其作用域、父子Shell的数据传递，以及作业的控制等相关知识。在下一章中将介绍Shell脚本调试技术。

第3篇
Shell编程实战

第14章
Shell脚本调试技术

对于程序设计者来说，调试技术是一项最基本的技能。而在Shell程序设计的时候，这项技能就显得更加重要。这是因为与其他高级程序设计语言，例如C++、Java以及C#等相比，Shell程序设计并没有功能非常完善的集成开发工具。但是，Shell本身却提供了非常强大的程序调试方法。本章将对Shell程序设计中常用的调试技术进行介绍。

本章主要涉及的知识点如下所述。

- Shell脚本中的常见错误：主要介绍Shell程序设计中经常遇到的语法错误和逻辑错误。

- Shell脚本调试技术：主要介绍Shell脚本的调试方法，包括简单的echo命令、trap命令、tee命令，以及钩子程序调试方法等。

14.1　Shell脚本中的常见错误

实际上，编写Shell脚本的过程就是不断排除错误的过程。尤其对于初学者来说，由于不熟悉Shell脚本的语法，会经常出现一些意想不到的错误。本节列出了Shell程序设计中经常出现的错误，从而使读者在编程时可以避免这些错误的发生。

14.1.1　常见语法错误

对于学习程序设计的读者来说，语法错误可能是最常见的错误。所谓语法错误，是指用户所编写的程序中的语句或者关键字等存在错误，使得程序无法执行。

无论是学习Shell编程，还是其他程序设计语言，例如C++或者Java，了解并掌握其语法都是一项基本功。每种程序设计语言都有自己的语法特点，这些特点都是需要深入了解，并严格遵循的。

对于Shell程序设计来说，语法问题是一个非常突出的问题。因为Shell的种类已经是比较多的了，常见有Bourne shell（sh）、Bourne-Again shell（bash）、C shell（csh），以及Korn shell （ksh）等。这些Shell都有自己的语法。然而，对于用户来说，完全掌握这些Shell的语法却并非是必需的。因为尽管这些是不同的Shell，但是其功能却是大同小异，因此，只要掌握好一种Shell的语法就可以完成自己的任务。而且，作为系统管理员，在进行系统维护的时候，熟悉一种Shell就可以了。因此，在本书中，我们以最为流行的Bourne-Again shell，即bash为例来介绍Shell编程。

在进行Shell程序设计的时候，语法错误经常表现为关键字书写错误、引号错误、漏掉空格符，以及变量的大小写问题等。

【例14-1】给出了一种常见的语法错误，代码如下：

01 #----------------------------/chapter14/ex14-1.sh------------------
02 #! /bin/bash
03
04 # 定义循环变量
05 n=1
06 #while 循环开始
07 while [[ $n -le 5 ]];
08 echo "the numberis $n."
09 let "n+=1"
10 done

在上面的代码中，第5行定义并初始化一个循环变量n，第7～10行是while循环结构，其功能是输出循环变量的值。

在该程序执行的时候，会出现以下错误：

[root@linux chapter14]# ./ex14-1.sh
./ex14-1.sh: line 10: syntax error near unexpected token 'done'
./ex14-1.sh: line 10: 'done'

上面的错误信息告诉用户，在程序的第10行意外地出现了一个关键字done。在while循环结构中，循环体的语句需要放在do和done这两个关键字之间。但是在上面的代码中，第7行和第8行之间却没有do关键字。因此，Shell在解释14-1.sh脚本的时候发现这个问题，并且给出相应的提示。

从上面的例子可以得知，尽管Shell给出的错误消息中出现问题的是第10行，但是实际上第10行并没有错误，出现错误的是第7行和第8行之间漏掉了do关键字。因此，用户在分析错误提示的时候，不应该仅仅局限于给出的代码行，而应该对错误所涉及的代码段整体检查。

在上面代码中的第7行和第8行之间补充了do关键字之后，程序代码如下：

01 #---------------------------/chapter14/ex14-1a.sh------------------
02 #! /bin/bash
03
04 # 定义循环变量
05 n=1
06 #while 循环结构
07 while [[ $n -le 5 ]];
08 do
09 echo "the numberis $n."
10 let "n+=1"
11 done

用户可以在所配光盘中的chapter14目录中的ex14-1a.sh文件中查看具体代码。修改完成以后，ex14-1a.sh的执行结果如下：

[root@linux chapter14]# ./ex14-1.sh
the numberis 1.
the numberis 2.
the numberis 3.
the numberis 4.
the numberis 5.

从上面的执行结果可以得知，程序已经能够正确运行，并且输出了所希望的结果。

由于历史的原因，Shell脚本的某些语法非常严格，因此，用户经常会因为空格问题导致程序执行错误。

【例14-2】演示在if语句中由于缺少空格而导致错误发生，代码如下：

01 #----------------------------/chapter14/ex14-2.sh------------------
02 #! /bin/bash
03
04 while :
05 do
06 # 接收用户输入数据
07 read x
08 # 如果用户输入的字符串为 exit ，则退出程序
09 if [ $x == "exit"]; then
10 exit 0
11 else
12 echo "$x"
13 fi
14 done

上面的程序功能是不断接收用户输入的数据，然后直接输出到屏幕。如果用户输入的字符串为exit，则退出程序。

该程序的执行结果如下：

[root@linux chapter14]# ./ex14-2.sh
abc
./ex14-2.sh: line 9: [: missing ']'
abc
…

从上面的执行结果可以得知，代码的第9行存在着语法错误。在4.1节中介绍条件测试的时候已经讲过，左方括号是一个Shell命令，命令与参数之间至少有一个空格，因此，第9行的条件测试中，表达式与左右方括号之间必须保留一个空格。而在上面的代码中，条件表达式与右方括号之间没有空格。正是由于缺少了这个空格，才导致错误的发生。

在第9行的右方括号前面插入一个空格之后，代码如下：

01 #---------------------------/chapter14/ex14-2a.sh------------------
02 #! /bin/bash
03
04 while :
05 do
06 # 接收用户输入数据
07 read x
08 # 如果用户输入的字符串为 exit ，则退出程序
09 if [ $x == "exit" ]; then
10 exit 0
11 else
12 echo "$x"
13 fi
14 done

以上代码请参见所配光盘中的chapter14目录中的ex14-2a.sh，其执行结果如下：

[root@linux chapter14]# ./ex14-2a.sh
abc
abc
exit
[root@linux chapter14]#

从上面的输出结果可以得知，程序已经可以正常运行。

注意：尽管在大部分的程序设计语言中，函数与参数之间是否存在空格并不影响程序的执行，但是在Shell脚本中，一定要注意空格问题。

14.1.2　常见逻辑错误

通常情况下，Shell脚本中的语法错误是非常明显的，并且语法错误一般会导致程序不可执行。但是逻辑错误却比较隐蔽，因为这些错误通常不会引起程序执行失败，但是逻辑错误却会导致程序得到错误的结果。因此，相比语法错误，逻辑错误调试起来会更加困难。

【例14-3】演示了Shell编程中经常遇到的一种逻辑错误，代码如下：

01 #----------------------------/chapter14/ex14-3.sh------------------
02 #! /bin/bash
03
04 # 定义变量 x
05 x=1
06 # 当变量 x 的值为 1 时
07 if [ x == 1 ]; then
08 echo "x=1"
09 # 当变量 x 的值为 0 时
10 elif [ x == 0 ]; then
11 echo "x=0"
12 # 其他情况
13 else
14 echo "other"
15 fi

在上面的代码中，第5行定义变量x，并赋初值为1。第7行是if语句，判断当x的值为1时输出x=1。第10行判断当x的值为0时，输出x=0。第13行是变量x为其他值时的情况，输出other。

尽管上面的代码比较简单，但是却存在着逻辑错误。下面首先看一下程序的执行结果，如下：

[root@linux chapter14]# ./ex14-3.sh
other

从上面的执行结果可以得知，Shell并没有给出任何错误提示信息，这意味着程序没有语法错误。但是程序的执行结果却并非所预期的x=1，而是other。

对于有过编程经验的读者来说，当遇到这种情况的时候，首先想到的应该是if条件语句中的条件测试存在问题，导致程序没有执行正确的分支。因此，用户应该将重点放在第7行和第10行的条件测试上面。仔细分析这两条语句之后，就会发现其中的条件表达式x==1和x==0并没有使用$符号来引用变量的值，其结果就是第7行和第10行的条件测试都不成立，程序跳到第13行的else语句中执行。

找到原因之后，分别对第7行和第10行进行修改，代码如下：

01 #---------------------------/chapter14/ex14-3.sh------------------
02 #! /bin/bash
03
04 # 定义变量 x
05 x=1
06 # 当变量 x 的值为 1 时
07 if [ $x == 1 ]; then
08 echo "x=1"
09 # 当变量 x 的值为 0 时
10 elif [ $x == 0 ]; then
11 echo "x=0"
12 # 其他情况
13 else
14 echo "other"
15 fi

修改完成之后，程序的执行结果如下：

[root@linux chapter14]# ./ex14-3.sh
x=1

注意：逻辑错误非常常见，并且不易被察觉，因此，当编写完脚本之后，一定要反复检查，确保没有逻辑错误。

14.2　Shell脚本调试技术

任何一个Shell开发者，都必须掌握所需要的调试技术，对于初学者来说，这显得尤为重要。因为在编写Shell脚本的时候，经常会遇到各种各样的错误，通过调试技术，可以快速地排除错误。本节将介绍Shell编程中经常使用的几种调试技术。

14.2.1　使用echo命令调试脚本
echo命令是Shell编程中最简单的调试技术。当用户需要验证程序中某个变量的值时，就可以直接使用echo命令将该变量的值输出到屏幕。

【例14-4】演示通过echo命令来调试程序的方法，代码如下：

01 #----------------------------/chapter14/ex14-4.sh------------------
02 #! /bin/bash
03
04 # 定义变量 a
05 a=1
06 # 当 a 的值等于 1 时
07 if [ "$a" -eq 1 ]
08 then
09 b=2
10 else
11 b=1
12 fi
13 c=3

上面的代码比较简单，第5行定义变量a，第7～12行根据变量a的值为变量b赋值。第13行定义变量c。由于整个程序没有输出语句，所以用户并不清楚变量a、b以及c的值是否正确。为了验证这3个变量的值是否正确，用户可以在程序的最后加入3行echo语句，如下：

01 #! /bin/bash
02
03 # 定义变量 a
04 a=1
05 # 当 a 的值等于 1 时
06 if [ "$a" -eq 1 ]
07 then
08 b=2
09 else
10 b=1
11 fi
12 c=3
13
14 echo "a=$a"
15 echo "b=$b"
16 echo "c=$c"

修改以后的【例14-4】的执行结果如下：

[root@linux chapter14]# ./ex14-4.sh
a=1
b=2
c=3

从上面的执行结果可以得知，变量a、b以及c都获得了正确的值。

尽管使用echo命令输出变量的值非常方便，但是，如果在程序中加入了大量的echo语句，当程序正式发布的时候，还需要将这些多余的echo语句删掉，这样还是非常麻烦。因此，用户需要更好的调试手段。

14.2.2　使用trap命令调试Shell脚本

前面已经讲过，trap命令可以捕获指定的信号，并且执行预定的命令，其基本语法如下：

trap 'command' signal

其中，参数command表示捕获指定的信号后要执行的命令，而参数signal表示指定的信号。

在Shell脚本执行的时候，会产生3个所谓的伪信号，分别为EXIT、ERR以及DEBUG。其中，EXIT信号在退出某个函数或者某个脚本执行完成时触发，ERR信号在某条命令返回非0状态时触发，DEBUG信号在脚本的每一条命令执行之前触发。

注意：之所以称为伪信号，是因为这三个信号是由Shell产生的，而其他的信号是由操作系统产生的。

【例14-5】演示使用trap命令输出发生错误的行号以及退出状态码，代码如下：

01 #----------------------------/chapter14/ex14-5.sh------------------
02 #! /bin/bash
03
04 # 定义信号处理函数
05 ERRTRAP()
06 {
07 echo "[LINE:$1] Error:Command or function exited with status code $?"
08 }
09 # 定义函数
10 func()
11 {
12 # 返回值为 1
13 return 1
14 }
15
16 # 使用 trap 命令捕获 ERR 信号
17 trap 'ERRTRAP $LINENO' ERR
18 # 调用错误的命令
19 abc
20 # 调用函数
21 func

在上面的代码中，第5～18行定义了信号处理函数，在该函数中，使用位置变量$1获取用户传递给函数的参数，使用系统变量$?获取上一个命令的退出状态码。第10～14行定义了一个名称为func()的函数，该函数比较简单，只有一行return语句，用来返回一个非0的退出状态码。第17行使用trap命令捕获ERR信号，并且将前面定义的ERRTRAP()函数作为ERR信号被触发时的响应函数，传递给ERRTRAP()函数的参数为Shell内置变量$LINENO，该变量代表当前执行的行号。

第19行执行了一个错误的命令，该命令会触发ERR信号。第21行调用func()函数，由于该函数返回非0值，所以也会触发ERR信号。

该程序的执行结果如下：

[root@linux chapter14]# ./ex14-5.sh
./ex14-5.sh: line 18: abc: command not found
[LINE:18] Error:Command or function exited with status code 127
[LINE:20] Error:Command or function exited with status code 1

从上面的执行结果可以得知，trap命令一共捕获了两次错误信号，第1次错误出现在第19行，其退出状态码为127，第2次出现在第21行，其退出状态码为1。

除了捕获错误信号之外，用户还可以使用trap命令捕获其他的信号，例如DEBUG信号。Shell在执行每条命令之前，都会触发该信号。这在跟踪变量的值的变化的时候，显得更加重要。

【例14-6】演示通过捕获DEBUG信号来进行程序调试的方法，代码如下：

01 #----------------------------/chapter14/ex14-6.sh------------------
02 #! /bin/bash
03
04 # 捕获 DEBUG 信号
05 trap 'echo "before execute line:$LINENO,a=$a,b=$b,c=$c"' DEBUG
06
07 # 定义变量 a
08 a=1
09 # 根据变量 a 的值初始化变量 b
10 if [ "$a" -eq 1 ]
11 then
12 b=2
13 else
14 b=1
15 fi
16 # 定义变量 c
17 c=3
18
19 echo "end"

在上面的代码中，第5行使用trap命令捕获了DEBUG信号，其中，信号的响应命令为一条echo命令，用来输出行号，以及a、b和c这3个变量的值。

该程序的执行结果如下：

[root@linux chapter14]# ./ex14-6.sh
before execute line:7,a=,b=,c=
before execute line:9,a=1,b=,c=
before execute line:11,a=1,b=,c=
before execute line:16,a=1,b=2,c=
before execute line:18,a=1,b=2,c=3
end

从上面的执行结果，可以清晰地看到每条命令执行后，3个变量的值的变化。通过值的变化，用户可以得知程序执行了哪条分支。

注意：并不是每条语句都会触发DEBUG信号，【例14-6】中的注释、空行、trap命令本身、then语句、else语句，以及fi等语句都不会触发DEBUG信号。

14.2.3　使用tee命令调试Shell脚本

在普通的语句中，用户使用echo和trap命令就可以非常轻松地完成调试，但是对于管道或者重定向来说，使用上面两种方法就显得心有余而力不足，因为在管道的作用下，一些命令的输出结果将会直接成为下一个命令的输入，中间结果并不会显示在屏幕上，因此给程序调试带来了困难。

由于在实际开发过程中，管道和重定向在Shell脚本中使用得非常多。所以必须找到能够输出中间结果的方法。在这种情况下，tee命令就可以轻松地完成任务。tee命令会从标准输入读取数据，将其内容输出到标准输出设备，同时又可将内容保存成文件。

【例14-7】本实例的功能是将当前目录中的文件名全部转换为大写字母，代码如下：

01 #----------------------------/chapter14/ex14-7.sh------------------
02 #! /bin/bash
03
04 # 将文件名转换为大写
05 list='ls -l | awk '{print toupper($7)}''
06
07 echo "$list"

在上面的代码中，第5行首先使用ls –l命令列出当前目录中的文件，然后通过awk命令的toupper()函数将第7个字段转换为大写。

该程序的执行结果如下：

[root@linux chapter14]# ./ex14-7.sh
14
14
14
14
14
…

从上面的执行结果可以得知，【例14-7】并没有正确输出文件名。由于awk命令所处理的数据来自于ls命令，所以用户应该弄清楚ls命令的输出结果到底是什么格式的。由于ls命令和awk命令通过管道连接，所以使用echo或者trap命令不可能得到ls命令的处理结果。

此时tee命令就可以派上用场了，将【例14-7】的代码修改如下：

01 #! /bin/bash
02
03 # 将文件名转换为大写
04 list='ls -l | tee list.txt | awk '{print toupper($7)}'''
05
06 echo "$list"

实际上，上述代码只是在【例14-7】的第5行的ls–l命令与awk命令之间插入了一个tee命令，该命令将ls命令的执行结果输出到list.txt文件。修改完成之后，再次执行ex14-7.sh，其结果如下：

[root@linux chapter14]# ./ex14-7.sh
14
14
14
14
14
16
16
17
19
19
[root@linux chapter14]# cat list.txt
total 36
-rwxr-xr-x 1 root root 83 Apr 14 16:31 ex14-1a.sh
-rwxr-xr-x 1 root root 87 Apr 14 18:50 ex14-1b.sh
-rwxr-xr-x 1 root root 90 Apr 14 18:58 ex14-1.sh
-rwxr-xr-x 1 root root 112 Apr 14 19:28 ex14-2.sh
…

从上面的执行结果可以得知，插入tee命令之后，程序的执行结果仍然会输出到屏幕上。当程序执行完成之后，ls命令的执行结果会输出到list.txt文件。通过观察list.txt文件，可以发现，该文件以空格分隔各个字段，其中文件名为第9个字段。所以将【例14-7】中的第5行的$7修改为$9，如下：

list='ls -l | tee list.txt | awk '{print toupper($9)}'''

然后再次执行ex14-7.sh，其结果如下：

[root@linux chapter14]# ./ex14-7.sh
EX14-1A.SH
EX14-1B.SH
EX14-1.SH
EX14-2.SH
EX14-3.SH
…

经过修改之后，【例14-7】已经能够输出正确的结果了。

14.2.4　使用调试钩子调试Shell脚本

在许多程序设计语言中，用户在调试程序的时候都可以设定一个开关变量，当该变量的值为真时，才输出调试信息；否则，不输出调试信息。例如用户可以设计以下代码：

if [ "$DEBUG" = "true" ]; then
输出调试信息
fi

只有当变量DEBUG的值为true时，才输出调试信息。这样的代码块称为调试钩子。在调试钩子中，用户可以输出任何调试信息。使用调试钩子，用户可以通过开关变量控制是否输出调试信息。这样的话，在开发过程中，可以将开关变量的值设置为真，便于程序的调试。当调试完成，需要发布脚本的时候，将开关变量的值设置为flase即可，无需再一条条地删除程序中的调试代码。

【例14-8】演示使用调试钩子调试程序的方法，代码如下：

01 #----------------------------/chapter14/ex14-8.sh------------------
02 #! /bin/bash
03
04 # 定义调试开关
05 export DEBUG=true
06
07 # 调试函数
08 DEBUG()
09 {
10 if [ "$DEBUG" == "true" ];then
11 $@
12 fi
13 }
14
15 a=1
16 # 调用调试函数
17 DEBUG echo "a=$a"
18
19 if [ "$a" -eq 1 ]
20 then
21 b=2
22 else
23 b=1
24 fi
25 # 调用调试函数
26 DEBUG echo "b=$b"
27 c=3
28 # 调用调试函数
29 DEBUG echo "c=$c"

在上面的代码中，第5行定义变量DEBUG，并且将其值设置为true。第8～13行定义了一个名称为DEBUG()的函数。在该函数中，根据变量DEBUG的值来决定是否执行传递给它的参数。实际上，传递给DEBUG()函数的参数为echo语句。第17行调用DEBUG()函数，并且将一条echo语句传递给它。第26行和第29行执行了同样的操作。

该程序的执行结果如下：

[root@linux chapter14]# ./ex14-8.sh
a=1
b=2
c=3

从上面的执行结果可以得知，因为开关变量DEBUG的值为true，所以输出了相应的调试信息。如果将DEBUG变量的值设置为false，则上面的程序没有任何输出，用户可以自行验证。

14.3　小结

本章介绍了Shell脚本的调试技术，主要包括Shell脚本中常见的错误，以及通过echo、trap、tee这3个命令和调试钩子来调试程序的方法。重点在于熟悉常见错误及其解决方法，并且能够熟练使用echo、trap及tee等命令来调试程序。在下一章中，将进行Shell编程实战，介绍Shell脚本在系统管理中的具体应用。

第15章
利用Shell脚本解决实际问题

在前面的章节中，我们详细介绍了Shell编程的各个方面的知识。虽然并非面面俱到，但是应该为读者打下了一个坚实的基础。在本章中，将介绍几个具体的实例，以加深和巩固前面讲过的知识。

本章主要涉及的知识点如下所述。

- 编写系统服务脚本：主要介绍系统的启动、运行级别、服务脚本的基本语法，以及如何编写MySQL服务脚本。

- 通过脚本管理Apache服务器日志：主要介绍Apache及日志文件、如何编写备份文件名生成函数、旧日志搜索函数、日志备份函数、删除旧日志，以及定时运行脚本等。

15.1　编写系统服务脚本

在进行系统管理的时候，一般都需要用到系统服务脚本。通过编写服务脚本，可以使得系统服务的管理更加便利，更加规范。在本节中，将介绍Linux的系统启动过程和初始化过程，并且以MySQL服务脚本为例，来说明系统服务脚本的编写方法。

15.1.1　系统启动过程

从大的方面来讲，Linux系统的启动过程如图15-1所示。当一个Linux系统启动的时候，首先是用户按下电源，接着电脑加电，然后执行BIOS中的固件程序，完成系统硬件的自检。当硬件自检没有发现问题之后，便调用内核装载器来载入系统内核。内核装载器又称为内核装载程序，目前比较流行的是GNU GRUB。GRUB是GRand Unified Bootloader的缩写，它是一个多重操作系统启动管理器，可以用来引导不同系统，例如Windows或者Linux等。

注意：在不同的硬件系统中，这个过程可能会有所不同，图15-1所描述的是x86硬件平台的过程。

图15-1　Linux启动过程

当Linux内核加载完成之后，便执行/sbin/init程序，创建系统中的第一个进程。init进程的进程ID为1，是Linux系统中所有进程的父进程。init进程会读取/etc/inittab、/etc/init/rc.conf以及/etc/init/rcS.conf等文件，执行一系列的初始化操作。然后执行/etc/rc.d目录中相应的子目录中的服务脚本，创建启动或者停止某项服务。

在Linux的启动过程中，有个文件发挥了非常重要的作用，该文件为一个Shell脚本文件，其名称为/etc/rc.d/rc。/etc/rc.d/rc的作用是根据不同的运行级别来启动或者停止服务。无论是对于初学者，还是有一定经验的读者来说，这个文件都是一个极好的范例，有兴趣的读者可以仔细地阅读这个文件的代码。

注意：在Linux中，/etc/rc是/etc/rc.d/rc文件的符号链接。/etc/rc文件是BSD流派的UNIX系统一直沿用的系统初始化文件。

当init进程完成所有的初始化任务之后，便出现登录窗口，此时用户可以登录进系统进行操作了。

15.1.2　运行级别

所谓运行级别，是指UNIX或者Linux等类UNIX操作系统的不同运行模式。在不同的运行级别下，用户可以执行相应的操作，例如运行级别1表示单用户模式，在这种运行级别下，只允许root用户登录，不启动网络服务。运行级别的概念只用于System V流派的UNIX系统以及Linux系统，对于BSD流派的UNIX系统则不使用运行级别的概念。

在通常情况下，运行级别分为7级，分别使用0～6这7个数字来表示。典型的运行级别如下所述。

- 0：停机。

- 1：单用户模式，不启用网络，不启动各种服务，只允许root用户登录进行维护。

- 2：多用户模式，不启用网络，不启动各种服务。

- 3：多用户模式，除图形界面之外，各种网络服务都可以使用。

- 4：用户自定义。

- 5：带图形界面的多用户模式。

- 6：重新启动系统。

在Linux系统中，各个运行级别下面需要启动或者停止的服务的脚本都位于特定的目录中，这些目录都位于/etc目录中，其名称为/etc/rc0.d～/etc/rc6.d，分别对应上面7个运行级别。下面列出的是运行级别3所对应的目录下面的部分脚本文件列表：

[root@linux rc0.d]# ll /etc/rc3.d/
total 0
lrwxrwxrwx 1 root root 20 Apr 6 13:48 K01certmonger
-> ../init.d/certmonger
lrwxrwxrwx 1 root root 15 Apr 6 13:48 K01numad
-> ../init.d/numad
lrwxrwxrwx 1 root root 16 Apr 6 13:59 K01smartd
-> ../init.d/smartd
…
lrwxrwxrwx 1 root root 17 Mar 9 15:31 S01sysstat
-> ../init.d/sysstat
lrwxrwxrwx 1 root root 22 Apr 6 13:48 S02lvm2-monitor
-> ../init.d/lvm2-monitor
lrwxrwxrwx 1 root root 18 Apr 6 13:48 S05cgconfig
-> ../init.d/cgconfig
…

从上面的输出可以得知，/etc/rc3.d中的脚本文件实际上是/etc/init.d目录中的相应脚本文件的符号链接。这些符号链接的命名有着特定的规则，如果文件名的首字符为大写字母K，表示将要传递stop参数给脚本，以停止该服务。如果文件名的首字符为S，则表示将要传递start参数给脚本，以启动该服务。首字符后面的数字表示该脚本执行的顺序。例如，在上面的结果中，K01numad表示在运行级别3中，将传递stop参数给/etc/init.d/numad脚本文件，以停止numad服务。S01sysstat表示在运行级别3中，将传递start参数给/etc/init.d/sysstat脚本，以启动sysstat服务。

注意：用户可以通过init命令来切换运行级别。

15.1.3　服务脚本的基本语法

在Linux系统中，服务脚本有固定的语法，通常情况下，服务脚本应该包括处理服务启动、服务停止、服务重新启动，以及查看服务状态的函数。另外，服务脚本还可以接受某些特定的参数，例如start、stop，以及restart等，并且根据这些参数调用不同的函数。

下面给出的是某个Linux系统中的Apache Web服务器的服务脚本，为了节省篇幅，省略了部分无关紧要的代码。

01 # 在当前 Shell 中执行定义公共函数的脚本
02 . /etc/rc.d/init.d/functions
03
04
05 #Apache 各组件的路径
06 apachectl=/usr/sbin/apachectl
07 httpd=${HTTPD-/usr/sbin/httpd}
08 prog=httpd
09 pidfile=${PIDFILE-/var/run/httpd/httpd.pid}
10 lockfile=${LOCKFILE-/var/lock/subsys/httpd}
11
12 # 脚本执行结果
13 RETVAL=0
14 # 停止服务的超时时间
15 STOP_TIMEOUT=${STOP_TIMEOUT-10}
16
17 # 定义启动服务的函数
18 start() {
19 echo -n $"Starting $prog: "
20 LANG=$HTTPD_LANG daemon --pidfile=${pidfile} $httpd $OPTIONS
21 RETVAL=$?
22 echo
23 [ $RETVAL = 0 ] && touch ${lockfile}
24 return $RETVAL
25 }
26
27 # 定义停止服务的函数
28 stop() {
29 echo -n $"Stopping $prog: "
30 killproc -p ${pidfile} -d ${STOP_TIMEOUT} $httpd
31 RETVAL=$?
32 echo
33 [ $RETVAL = 0 ] && rm -f ${lockfile} ${pidfile}
34 }
35 # 定义重新加载配置文件的函数
36 reload() {
37 echo -n $"Reloading $prog: "
38 if ! LANG=$HTTPD_LANG $httpd $OPTIONS -t >&/dev/null; then
39 RETVAL=6
40 echo $"not reloading due to configuration syntax error"
41 failure $"not reloading $httpd due to configuration syntax error"
42 else
43 # Force LSB behaviour from killproc
44 LSB=1 killproc -p ${pidfile} $httpd -HUP
45 RETVAL=$?
46 if [ $RETVAL -eq 7 ]; then
47 failure $"httpd shutdown"
48 fi
49 fi
50 echo
51 }
52
53 # 根据用户传递的参数执行不同的操作
54 case "$1" in
55 # 启动服务
56 start)
57 start
58 ;;
59 # 停止服务
60 stop)
61 stop
62 ;;
63 # 查看服务状态
64 status)
65 status -p ${pidfile} $httpd
66 RETVAL=$?
67 ;;
68 # 重新启动服务
69 restart)
70 stop
71 start
72 ;;
73 # 强制重新启动
74 force-reload|reload)
75 reload
76 ;;
77 # 处理其他情况
78 *)
79 echo $"Usage: $prog {start|stop|restart|condrestart|
try-restart|force-reload|reload|status|fullstatus|graceful|help|configt
est}"
80 RETVAL=2
81 esac
82
83 exit $RETVAL

上面给出的代码是一个比较规范的服务脚本。读者可以仔细揣摩上面的代码的整体结构以及每行代码的作用，从而掌握服务脚本的编写方法。

在上面的代码中，第2行使用圆点操作符执行/etc/rc.d/init.d/functions文件，该文件定义了服务脚本所需要的大部分的公共函数。之所以使用圆点操作符，而不是直接执行，是因为这些函数需要在当前服务脚本后面的代码中使用到，故将/etc/rc.d/init.d/functions文件与当前服务脚本在同一个子Shell中执行。

第6～10行将Apache各组件的路径用变量表示出来，然后在后面的代码中通过变量来调用这些组件。这是一个非常好的习惯，因为后面可能有多处代码都用到了这些路径，如果路径发生改变，则只要修改变量的值就可以了，无需修改每处用到路径的代码。

第13行是一个服务脚本退出状态的代码，如果脚本执行成功，则返回0；否则，返回其他值。

第18～51行定义了处理各个操作的函数，当用户指定不同的参数时，程序需要调用这些函数来改变服务的状态。第18～25行定义了启动服务的函数start()，其中第19行输出一行提示信息，第20行启动Apache服务进程，第21行通过系统变量$?获取第19行的命令的执行结果，并且将结果赋给变量RETVAL。第23行在第19行执行成功的情况下，创建/var/lock/subsys/httpd文件，该文件的作用是表示Apache服务器进程已经成功启动。第24行将服务启动的结果返回。

第28～34行定义了停止服务的函数stop()。其中第30行通过killproc命令终止Apache服务进程。如果终止成功，则第30行会删除第23行创建的文件以及进程ID文件。

第36～51定义了重新加载配置文件的函数，其中的语句与前面两个函数大致相同，不再重复介绍。

第54～81行根据用户传递的参数，调用前面定义的函数，从而使得服务进程的状态得以改变。由于分支较多，为了使整个结构清晰，通常情况下，服务脚本都采用case语句，并且通过位置变量$1获取传递的参数值。

第56～58行是用户传递start参数的时候需要执行的代码。此时需要启动服务，所以直接调用start()函数即可。同理，对于下面的stop、status、restart，以及force-reload和reload等参数，也调用相应的函数。

第78～80行处理了其他参数值的情况。也就是说，如果用户传递了除start、stop、restart、condrestart等参数值以外的其他值，则输出一行提示信息，以告诉用户该服务脚本接受哪些参数值。

通过上面的例子，可以大致得出服务脚本的基本语法。即先定义处理各种操作的函数，然后通过case结构来根据用户传递的不同的参数值调用前面定义的函数。其中参数值都是固定的，包括start、stop、restart、status以及reload等。

注意：在15.1.2节中已经讲过，如果/etc/rc0.d～/etc/rc6.d等目录中的脚本的文件名以S开头，则系统在初始化的时候会自动传递start参数该脚本；如果文件名以K开头，则系统将自动传递stop参数给该脚本。

15.1.4　编写MySQL服务脚本

为了使读者更加深入理解服务脚本的编写方法，下面以MySQL服务器为例，来介绍如何从头编写一个服务脚本。通常情况下，服务脚本的行数都比较多，所以下面我们分成几个部分来介绍MySQL服务脚本的编写。

1．定义常量和函数

为了使程序保持一个良好的结构，需要将某些命令的路径使用变量表示出来，同时，将某些相对独立的功能编写成函数。这部分的代码如下：

01 #MySQL 服务主程序的路径
02 mysql="/usr/bin/mysqld_safe"
03
04 #MySQL 管理工具路径
05 mysqladmin="/usr/bin/mysqladmin"
06
07 # 定义获取 MySQL 选项的函数
08 get_mysql_option()
09 {
10 # 使用 my_print_defaults 命令输出各个选项
11 result='/usr/bin/my_print_defaults "$1" | sed -n "s/^--$2=//p" | tail
-n 1'
12 # 如果文件不存在，则使用默认值
13 if [ -z "$result" ]; then
14 result="$3"
15 fi
16 }
17 # 数据库文件路径
18 get_mysql_option mysqld datadir "/var/lib/mysql"
19
20 datadir="$result"
21
22 #Socket 文件路径
23 get_mysql_option mysqld socket "$datadir/mysql.sock"
24 socketfile="$result"
25
26 # 日志文件路径
27 get_mysql_option mysqld_safe log-error "/var/log/mysqld.log"
28 errlogfile="$result"
29
30 # 进程 ID 文件路径
31 get_mysql_option mysqld_safe pid-file "/var/run/mysqld/mysqld.pid"
32 mypidfile="$result"

在上面的代码中，第2行定义了MySQL主程序的路径，如果用户的路径与此不同，可以根据自己的实际情况进行修改。第5行定义了MySQL管理工具mysqladmin的路径，同样，用户可以自行修改以满足自己的需要。第8～16行定义了一个名称为get_mysql_option()的函数，该函数的功能是获取MySQL的各个选项。

第11行是一条比较复杂的语句，首先使用my_print_defaults命令输出MySQL的选项值。my_print_defaults是MySQL自带的一个工具，该工具可以输出MySQL配置文件中的各个选项的值。例如，下面的命令输出my.cnf文件中的mysqld组的选项的值：

[root@linux init.d]# my_print_defaults mysqld
--datadir=/var/lib/mysql
--socket=/var/lib/mysql/mysql.sock
--user=mysql
--symbolic-links=0

在第11行中，my_print_defaults命令的参数通过位置变量$1获取。然后通过管道将输出结果传递给sed命令，sed命令的匹配规则为^--$2=，其中$2为位置变量，表示用户传递的第2个参数值。如果想要获取数据库文件的选项值，则实际匹配规则为^--datadir=。读者可以分析一下上面的my_print_defaults命令的输出结果，就可以理解为什么要使用这样的规则。由于MySQL允许用户在my.cnf配置文件中重复指定某个选项，但是只有最后指定的选项才会生效，所以还需要将sed命令的输出结果传递给tail命令，其中-n 1表示只输出最后一行文本。

第13～15行是处理某个选项在my.cnf配置文件中不存在的情况，此时，get_mysql_option()函数将返回用户指定的第3个参数值。

第18行通过get_mysql_option()函数获取数据库文件的路径，传递给get_mysql_option()函数的参数值分别为mysqld、datadir和/var/lib/mysql。其中mysqld表示在my.cnf文件中，数据库文件的选项位于mysqld这一组中。datadir表示在my.cnf文件中，数据库文件的选项名称为datadir。/var/lib/mysql表示如果在my.cnf文件中没有关于数据库文件的选项，则使用/var/lib/mysql作为数据库文件的路径。

第23、27和31行分别获取Socket文件、日志文件和进程ID文件的路径，其原理与获取数据库文件路径相同，不再重复说明。

2．定义状态处理函数

接下来再介绍服务脚本中的关键部分，即处理各种操作的函数。实际上，服务脚本的状态操作有许多种，但是其实现方法大同小异，所以下面只给出了3个最常用的函数的定义方法，即start()函数、stop()函数和restart()函数。函数定义的具体代码如下：

01 # 服务启动函数
02 start(){
03 # 如果程序不可执行，则直接退出
04 [ -x $mysql ] || exit 5
05 # 判断服务进程是否存在
06 /usr/bin/mysqladmin --socket="$socketfile" --user=mysql ping 2>&1
07 if [ $? = 0 ]; then
08 echo "mysql has been already running."
09 ret=0
10 else
11 if [ ! -d "$datadir/mysql" ] ; then
12 echo "mysql database does not exists."
13 exit 1
14 fi
15
16 $mysql --datadir="$datadir" --socket="$socketfile"
--pid-file="$mypidfile" --basedir=/usr --user=mysql >/dev/null 2>&1 &
17
18 ret=$?
19
20 if [ $ret -eq 0 ]; then
21 touch $lockfile
22 else
23 echo "starting mysql failed."
24 fi
25 fi
26 return $ret
27 }
28
29 stop(){
30 # 如果进程文件不存在，则直接退出
31 if [ ! -f "$mypidfile" ]; then
32 echo "mysql is not running."
33 return 0
34 fi
35 # 从进程文件中获取进程 ID
36 mysqlpid='cat "$mypidfile"'
37 # 如果进程 ID 为整数，则调用 mysqladmin 停止 mysql 服务
38 if [ -n "$mysqlpid" ]; then
39 $mysqladmin --socket="$socketfile" --user=root shutdown
40
41 ret=$?
42 # 如果停止成功，则删除锁定文件和 Socket 文件
43 if [ $ret -eq 0 ]; then
44 rm -f $lockfile
45 rm -f "$socketfile"
46 echo "mysql stopped."
47 ret=0
48 else
49 echo "stopping mysql failed."
50 ret=1
51 fi
52
53 return $ret
54 }
55
56 restart(){
57 stop
58 start
59 }

在上面的代码中，第2～27行定义了start()函数，该函数的功能是启动MySQL服务。第4行判断变量mysql表示的MySQL主程序是否存在并且可以执行，如果不存在或者不可执行，则直接退出。第6行使用mysqladmin命令判断MySQL服务进程是否已经存在。如果服务进程已经存在，返回0；否则，返回非0值。第7行使用系统变量$?获取第6行的返回状态码，如果等于0，则输出一行提示信息；否则，继续执行下面的代码。

第11～14行判断MySQL的系统数据库mysql是否存在，如果不存在，则直接退出程序。

第16行执行MySQL主程序，第20～23行根据第16行的返回状态码执行相应的操作。如果启动成功，则创建状态锁定文件；否则输出启动失败的提示信息。

第29～54行定义了stop()函数，该函数的功能是停止MySQL服务。在本函数中，通过进程ID文件是否存在来判断MySQL进程是否存在。如果存在的话，则在第39行使用mysqladmin命令停止MySQL服务。停止成功之后，则需要删除锁定文件和Socket文件。

第56～59行定义了restart()函数，该函数的功能是重新启动MySQL服务。其代码比较简单，首先调用前面定义的stop()函数停止MySQL服务，然后再调用start()函数启动MySQL服务。

3．接收参数值

最后一部分是整个脚本的流程控制部分，也是脚本的主程序。与上面定义的函数相对应，此处只处理了4个参数的情况，具体代码如下：

01 # 根据参数值执行相应的操作
02 case "$1" in
03 # 启动服务
04 start)
05 start
06 ;;
07 # 停止服务
08 stop)
09 stop
10 ;;
11 # 查看状态
12 status)
13 re='pidof "$procname"'
14
15 if [ "$re" -gt 0 ]; then
16 echo "mysql is running."
17 else
18 echo "mysql is not running."
19 fi
20 ;;
21 # 重新启动
22 restart)
23 restart
24 ;;
25 *)
26 echo $"Usage: $0 {start|stop|status|restart}"
27 exit 2
28 esac
29
30 exit $?

在上面的代码中，第4行是用户传入start参数值的情况，此时需要调用start()函数。第8行是用户传入stop参数值的情况，此时需要调用stop()函数。第12行是用户传入status参数值的情况，此时通过pidof函数来判断指定的进程是否存在，如果pidof命令的返回值大于0，表示指定的进程存在；否则，表示指定的进程不存在。第22～24行是处理用户传入restart参数值的情况，此时需要调用restart()函数。

以上整个程序的代码请参见本书所配光盘中的chapter15目录中的mysql文件，具体代码如下：

001 #-----------------------------/chapter15/mysql------------------
002 #!/bin/sh
003 #
004 # MySQL 服务脚本
005 # 指定运行级别以及优先级
006 # chkconfig: - 64 36
007 # description: MySQL database server.
008
009 #MySQL 服务主程序的路径
010 mysql="/usr/bin/mysqld_safe"
011
012 #MySQL 管理工具路径
013 mysqladmin="/usr/bin/mysqladmin"
014
015 # 定义获取 MySQL 选项的函数
016 get_mysql_option()
017 {
018 # 使用 my_print_defaults 命令输出各个选项
019 result='/usr/bin/my_print_defaults "$1" | sed -n "s/^--$2=//p" |
tail -n 1'
020 # 如果文件不存在，则使用默认值
021 if [ -z "$result" ]; then
022 result="$3"
023 fi
024 }
025 # 数据库文件路径
026 get_mysql_option mysqld datadir "/var/lib/mysql"
027
028 datadir="$result"
029
030 #Socket 文件路径
031 get_mysql_option mysqld socket "$datadir/mysql.sock"
032 socketfile="$result"
033
034 # 日志文件路径
035 get_mysql_option mysqld_safe log-error "/var/log/mysqld.log"
036 errlogfile="$result"
037
038 # 进程 ID 文件路径
039 get_mysql_option mysqld_safe pid-file "/var/run/mysqld/mysqld.pid"
040 mypidfile="$result"
041
042 # 服务启动函数
043 start(){
044 # 如果程序不可执行，则直接退出
045 [ -x $mysql ] || exit 5
046 # 判断服务进程是否存在
047 /usr/bin/mysqladmin --socket="$socketfile" --user=mysql ping 2>&1
048 if [ $? = 0 ]; then
049 echo "mysql has been already running."
050 ret=0
051 else
052 if [ ! -d "$datadir/mysql" ] ; then
053 echo "mysql database does not exists."
054 exit 1
055 fi
056
057 $mysql --datadir="$datadir" --socket="$socketfile" --pid-file=
"$mypidfile" --basedir=/usr --user=mysql >/dev/null 2>&1 &
058
059 ret=$?
060
061 if [ $ret -eq 0 ]; then
062 touch $lockfile
063 else
064 echo "starting mysql failed."
065 fi
066 fi
067 return $ret
068 }
069
070 stop(){
071 # 如果进程文件不存在，则直接退出
072 if [ ! -f "$mypidfile" ]; then
073 echo "mysql is not running."
074 return 0
075 fi
076 # 从进程文件中获取进程 ID
077 mysqlpid='cat "$mypidfile"'
078 # 如果进程 ID 为整数，则调用 mysqladmin 停止 mysql 服务
079 if [ -n "$mysqlpid" ]; then
080 $mysqladmin --socket="$socketfile" --user=root shutdown
081
082 ret=$?
083 # 如果停止成功，则删除锁定文件和 Socket 文件
084 if [ $ret -eq 0 ]; then
085 rm -f $lockfile
086 rm -f "$socketfile"
087 echo "mysql stopped."
088 ret=0
089 else
090 echo "stopping mysql failed."
091 ret=1
092 fi
093
094 return $ret
095 }
096
097 restart(){
098 stop
099 start
100 }
101
102 # 根据参数值执行相应的操作
103 case "$1" in
104 # 启动服务
105 start)
106 start
107 ;;
108 # 停止服务
109 stop)
110 stop
111 ;;
112 # 查看状态
113 status)
114 if [ -n pidof "$procname" ]; then
115 echo "mysql is running."
116 fi
117 ;;
118 # 重新启动
119 restart)
120 restart
121 ;;
122 *)
123 echo $"Usage: $0 {start|stop|status|restart}"
124 exit 2
125 esac
126
127 exit $?

在上面的代码中，第6行需要特别说明一下。该行的作用是告诉chkconfig命令，当前的服务脚本可以在哪些运行级别下面执行。其中连字符-表示当前脚本适用于所有的运行级别。如果只想在某些运行级别下面执行，则可以直接用数字指定，例如：

# chkconfig: 345 64 36

表示当前脚本可以在3、4和5这3个运行级别下面运行。后面的2个数字分别表示当前脚本在启动和停止时的优先级。数值越小，优先级越高；反之，优先级越低。

当整个mysql脚本都编写完成之后，将其复制到/etc/init.d目录中，并且赋予可执行权限，命令如下：

[root@linux chapter15]# cp mysql /etc/init.d/
[root@linux chapter15]# chmod +x /etc/init.d/mysqld

接下来使用chkconfig命令更新系统服务，如下所示：

[root@linux chapter15]# chkconfig mysql on

最后，用户就可以使用service命令来启动、停止或者查看运行状态了，代码如下：

[root@linux chapter15]# service mysql start
[root@linux chapter15]# service mysql status
mysql is running.
[root@linux chapter15]# service mysql stop
mysql stopped.

注意：为了使系统初始化的时候可以启动MySQL服务，需要使用chkconfig命令将新编写的服务脚本添加到系统服务中。其中chkconfig命令中的服务名与服务脚本文件名称相同。

15.2　通过脚本管理Apache服务器日志

在系统维护的过程中，Apache Web服务器的日志管理也是一件比较麻烦的工作。如果能够通过脚本来自动将旧日志文件归档，然后将其删除，则可以大大减轻系统管理员的工作。本节将介绍如何通过脚本以及任务计划来自动管理Apache日志。

15.2.1　Apache以及日志文件简介

Apache是目前世界上最为流行的Web服务器之一，特别是最热门和访问量大的网站，都几乎无一例外地采用了Apache作为网站的服务器软件。Apache是由美国Apache软件基金会管理的一个开放源码的应用软件，可以在绝大部分的操作系统中运行，包括UNIX、Linux以及Windows等。

对于系统管理员来说，日志是一个非常有用的工具。日志记录了服务器的活动、性能，以及出现的问题，在出现故障的时候，借助日志可以快速地发现原因所在，从而在最短的时间内解决问题。

Apache服务器提供了非常全面而灵活的日志记录功能，主要包括两种日志类型，分别为错误日志和访问日志。

错误日志是最重要的日志文件，其文件名和位置取决于Apache的配置文件httpd.conf中的ErrorLog指令，绝大部分情况下，错误日志的名称为error_log或者error.log位于/var/log/httpd/目录中。Apache服务器将有关诊断和请求处理中出现的错误信息都存到这个错误日志中，所以如果服务器启动或者运行中出现问题，则用户首先应该查看错误日志，以了解具体的原因。

注意：在UNIX或者Linux中，错误日志的名称通常为error_log，而在Windows中，错误日志的名称通常为error.log。

为了便于帮助用户了解故障原因，错误日志中对于每条日志，都有一些描述信息，如下：

[root@linux ~]# more /var/log/httpd/360zippo.com-error_log-20130414
[Sun Apr 07 05:11:04 2013] [error] [client 183.62.115.227] File does not
exist: /data1/www/js/js, referer: http://www.360zippo.com/
[Sun Apr 07 05:11:04 2013] [error] [client 183.62.115.227] File does not
exist: /data1/www/js/js, referer: http://www.360zippo.com/
…

从上面的内容可以得知，每一条错误日志通常包括错误发生的日期和时间、严重性、导致错误的IP地址，以及具体的原因。例如，在上面的例子中，错误的原因为文件不存在。在错误日志中，文件使用文件系统绝对路径表示，而非Web服务器中的路径。

访问日志记录了Apache服务器处理的所有的请求，其文件名和位置取决于httpd.conf文件中的CustomLog指令。通常情况下，访问日志位于/var/log/httpd/目录中，其文件名为access_log或者access.og。

访问日志的格式非常灵活，通常情况下包括客户端的IP地址、请求时间和日期、请求的URL，以及状态等，如下：

[root@linux httpd]# more 360zippo.com-access_log
123.125.68.154 - - [28/Apr/2013:03:42:55 +0800] "GET /category-26-b0.html
HTTP/1.1" 200 5457
42.62.37.107 - - [28/Apr/2013:03:43:02 +0800] "GET /brand-12-c0-
3-shop_price-ASC.html HTTP/1.0" 200 6268
42.62.37.107 - - [28/Apr/2013:03:46:10 +0800] "GET /gallery.php?id=
115&img=297 HTTP/1.0" 200 867
42.62.37.107 - - [28/Apr/2013:03:46:38 +0800] "GET /user.php?act=
affiliate&goodsid=98 HTTP/1.0" 200 3846
42.62.37.107 - - [28/Apr/2013:03:47:10 +0800] "GET /gallery.php?id=
75&img=216 HTTP/1.0" 200 881
42.62.37.107 - - [28/Apr/2013:03:50:36 +0800] "GET /gallery.php?id=
116&img=305 HTTP/1.0" 200 879

通过上面的输出结果，可以得知每条访问日志通常包括客户端的IP地址、客户端的身份、客户端的标识、服务器完成请求处理时的时间、客户端请求资源的方法、资源URL、返回状态码，以及资源的大小等。

尽管Apache服务器的日志都由文本组成，但是如果应用的场合是一个访问量非常大的网站的话，则这些日志文件会很快塞满整个文件系统。所以，必须采用适当的手段定期清理这些日志。但是这些日志又是系统管理员进行故障处理的重要依据，所以不可以将其简单地删除，而应定期地将其压缩归档，并且转移到其他存储设备上。下面的内容就是逐步介绍各个步骤的实现方法。

15.2.2　备份归档文件名生成函数

为日志归档文件指定一个有意义的名称非常重要。按照惯例，备份文件的名称一般是以备份的时间命名的。这样做有两个好处，其一是通过文件名就可以知道当前备份创建的时间，其二是文件名不会出现重名的情况。正因为以备份日期命名有着非常大的便利性，所以在本例中专门编写一个函数来生成文件名称。

生成备份归档文件名的函数如下：

01 # 该函数生成文件名
02 function filename()
03 {
04 # 生成时间戳字符串
05 timestamp=$(date +%Y%m%d%H%M%S)
06 # 输出完整的文件名
07 echo "$1".$timestamp."tar"
08 }

在上面的代码中，第2行是函数定义的开头，其中函数名为filename()。第5行通过date命令生成一个时间戳字符串。在该行语句中，使用$()符号执行Shell命令。其中date命令的语法需要特别说明一下：

date +%Y%m%d%H%M%S

字符串+%Y%m%d%H%M%S为date命令输出结果的格式说明，其中%Y表示4位数字的年份，%m表示两位数字的月份，%d表示两位数字的日，%H表示24小时制的小时，%M表示两位数字的分钟，%S表示两位数字的秒数。格式字符串前面的加号+是固定的。date与格式字符串之间有一个空格。

注意：在date命令的格式字符串中，字母的大小写有不同的涵义。例如，字符%Y表示完整的4位数字的年份，而%y表示两位数字的年份。通过格式字符串，date命令可以输出各种格式的日期字符串，读者可以参考date命令的手册。

第7行使用echo语句将文件名输出，其中位置变量$1表示用户传入的参数值，该参数值组成文件名的第1部分。文件名的第2部分即第5行生成的字符串。这两部分之间使用圆点操作符连接起来。由于本例的日志归档需要使用tar命令，所以文件名的后缀为.tar。

用户可在Shell程序中使用以下方法获取函数生成的文件名：

file='filename httpd_log'

生成的文件名的格式如下：

httpd_log.20130430012016.tar

15.2.3　过期日志备份归档函数

在编写完成归档文件名生成函数之后，接下来就要编写一个函数来将旧的日志文件进行归档。归档函数的功能是查找日志目录中前一天生成的日志文件，然后通过tar命令将其归档。为了节省磁盘空间，还可以将归档后的备份档案压缩。归档函数的代码如下：

01 # 日志归档函数
02 function archivelog()
03 {
04 # 生成归档文件名
05 archivefile='filename httpd_log'
06
07 # 获取存储归档日志的目录
08 archivedest=$1
09
10 # 如果目标目录不存在，则创建该目录
11 if [ ! -d archivedest ];then
12 mkdir -p $archivedest
13 fi
14
15 # 进入日志文件所在的目录
16 cd /var/log/httpd
17
18 # 查找 1 天前的日志文件，并且进行归档
19 find . -mtime +1 -exec tar -rf $archivedest$archivefile {} \;
20
21 # 将归档日志压缩
22 zip $archivedest$archivefile".zip" $archivedest$archivefile
23
24 # 压缩成功后删除压缩前的归档日志
25 if [ "$?" -eq 0 ]
26 then
27 rm -f $archivedest$archivefile
28 fi
29
30 return $?
31 }

在上面的代码中，第5行通过前面定义的归档文件名生成函数生成一个文件名。第8行获取用户指定的存放归档日志的路径。如果目标路径不存在，则在第11～13行使用mkdir命令创建。在第12行中，mkdir命令使用了-p选项，其原因为目标路径通常会由多层目录构成，如果路径中的某层目录不存在，则使用-p选项之后，mkdir命令会依次创建各层目录。

第16行通过cd命令将当前工作目录切换到Apache的日志文件所在的目录，便于后面使用tar命令时用相对路径表示要归档的文件。

注意：在默认情况下，tar命令在归档的时候会去掉文件名最前面的根目录符号，从而使档案中的文件使用相对路径表示。这样做的好处非常明显，即在提取文件的时候，被提取的文件会释放到当前的工作目录中，而不会直接释放到绝对路径表示的目录中。这样可以避免无意中覆盖其他的文件。

第19行使用find命令搜索修改时间为1天前的文件，其中选项-mtime表示根据修改时间来搜索文件，+1表示修改时间距当前时间超过1天。-exec表示对搜索到的文件执行后面指定的操作，即使用tar命令归档。

在tar命令中，我们使用了-rf选项，其中-r选项表示将文件追加到已有档案中，-f选项表示后面紧跟的是档案的文件名。在第19行中，档案的文件名由archivedest和archivefile这两个变量连接而成。关于find命令的使用方法，请参考本书第12章。

第22行将归档生成的档案文件进行压缩，其目的是为了节省磁盘空间。

第27行在压缩归档文件成功的前提下，将未压缩的归档文件删除。

15.2.4　过期日志删除函数

当所有的过期日志都已经成功备份之后，就可以将其从磁盘中删除，以释放被占用的磁盘空间。过期日志删除函数比较简单，其代码如下：

01 # 删除已经归档的过期日志文件
02 function removearchivedlog()
03 {
04 cd /var/log/httpd
05 # 查找 1 天前生成的日志文件，并且将其删除
06 find . -mtime +1 -exec rm -f $archivedest$archivefile {} \;
07 }

其中，起主要作用的是第6行的find语句，将搜索到的文件传递给rm命令。

15.2.5　日志归档主程序

到目前为止，本例中所有使用到的函数都已经编写完成。接下来是编写主程序，调用前面所定义的各个函数。主程序的代码比较简单，如下：

01 # 将过期日志归档
02 archivelog "/root/chapter15/"
03
04 # 删除已归档日志
05 if [ "$?" -eq 0 ]
06 then
07 removearchivedlog
08 fi
09
10 exit 0

第2行调用archivelog()函数将过期日志备份到/root/chapter15/路径下面，第7行将归档后的过期日志删除。

本例完整的代码如下：

01 #------------------------/chapter15/archivelog.sh------------------
02 #! /bin/bash
03
04 # 归档文件名生成函数
05 function filename()
06 {
07 timestamp=$(date +%Y%m%d%H%M%S)
08 echo "$1".$timestamp."tar"
09 }
10 # 过期日志归档函数
11 function archivelog()
12 {
13 archivefile='filename httpd_log'
14
15 archivedest=$1
16
17 if [ ! -d archivedest ];then
18 mkdir -p $archivedest
19 fi
20
21 cd /var/log/httpd
22
23 find . -mtime +1 -exec tar -rf $archivedest$archivefile {} \;
24
25 zip $archivedest$archivefile".zip" $archivedest$archivefile
26
27 if [ "$?" -eq 0 ]
28 then
29 rm -f $archivedest$archivefile
30 fi
31
32 return $?
33 }
34
35 # 已归档日志删除函数
36 function removearchivedlog()
37 {
38 cd /var/log/httpd
39
40 find . -mtime +1 -exec rm -f $archivedest$archivefile {} \;
41 }
42
43 # 将过期日志归档
44 archivelog "/root/chapter15/"
45
46 # 删除已归档日志
47 if [ "$?" -eq 0 ]
48 then
49 removearchivedlog
50 fi
51
52 exit 0

15.2.6　定时运行日志归档脚本

要实现Apache日志的完全自动化管理，必须定时运行上面创建的归档脚本。通常情况下，用户可以使用两种方法来实现脚本的定时运行。一种方法是使用sleep命令，另外一种方法是使用cron工具。下面将分别对这两种方法进行介绍。

sleep命令可以使脚本进程暂时休眠指定的时间间隔，到期之后进程会重新开始执行。要使用sleep命令实现脚本的定义执行，需要将前面的主程序进行修改，将第43～50行放在一个循环结构中，如下：

01 # 无限循环
02 while true
03 do
04 # 归档过期日志
05 archivelog "/root/chapter15/"
06
07 if [ "$?" -eq 0 ]
08 then
09 # 删除过期日志
10 removearchivedlog
11 fi
12 # 休眠 1 天
13 sleep 86400
14 done

为了使得归档操作能够不断地重复执行，将archivelog()和removearchivedlog()这两个函数的调用都放在了一个无限循环结构中。第13行使用sleep命令使进程每天执行一次，其中sleep命令以秒为单位。

修改完成之后，用户可以使用以下命令执行归档脚本：

[root@linux chapter15]# ./archivelog.sh &

下面重点介绍如何使用cron工具来重复执行某项任务。

1．cron简介

对于系统任务的调度是每个操作系统都必须提供的功能。Windows的任务计划程序如图15-2所示。

图15-2　Windows任务计划程序

通过任务计划，用户可以实现在每天、每周或者每月重复执行某任务，例如系统备份或者发送电子邮件等。

Linux的任务计划管理是通过cron工具实现的。cron这个名字来自古希腊语的chronos，即时间的意思。cron工具主要包括3个组成部分，分别为crontab文件、crontab命令，以及crond守护进程。crontab文件记录了用户的任务计划列表，用户可以通过crontab命令来管理crontab文件中的任务计划列表，crond进程是cron工具的服务进程，该进程会读取crontab文件中的任务计划并且定期执行。

2．crontab文件及其基本语法
crontab文件包含需要提交给crond守护进程的一系列任务计划。每个用户可以拥有自己的crontab文件；整个操作系统也可以拥有一个全局的crontab文件。全局crontab文件只能由系统管理员来管理，其文件名通常为/etc/crontab；而每个用户的crontab文件则可以由每个用户管理，通常位于/var/spool/cron目录中，其文件名为每个用户的登录名。

在crontab文件中，每一行都描述一项任务计划。每一行均遵循特定的语法格式，一般包含6个字段，其中前5个字段描述任务计划执行时间，第6个字段描述任务计划中需要执行的命令，字段之间通过空格或者制表符隔开。

下面给出的是PostgresSQL数据库的服务账户postgres的crontab文件的内容：

[root@linux cron]# more /var/spool/cron/postgres
*/5 * * * *
/var/lib/pgsql/scripts/auto_archive_log.sh

图15-3描述了crontab文件中任务计划的各个字段的涵义及其取值范围。

图15-3　crontab文件中任务计划的字段

- 分钟：描述任务计划被执行时的分钟数，其取值范围为0～59，例如取值为0时表示整点执行。

- 小时：描述任务计划被执行时的小时数，其取值范围为0～23，例如取值为0时表示0点执行作业。

- 日：描述任务计划在每个月的哪天执行，其取值范围为1～31，例如取值为1时表示每个月的第1天执行作业。

- 月：描述任务计划在哪个月执行，其取值范围为1～12，例如取值为12时表示每年的12月份执行作业。

- 周：描述任务计划在每周的第几天执行，其取值范围为0～7，其中0和7都表示星期天，其余1～6分别表示星期一至星期六。例如取值为0或者7表示将在每个星期日执行作业。

- 需要执行的命令：通常使用绝对路径表示，当前用户需要拥有执行权限。对于每个时间字段，都遵循以下语法规则：

- 如果需要指定多个值，则使用逗号隔开各个值。例如，用户可以在小时字段中使用0,2,4,6,8,10,12,14,16,18,20,22表示每隔2个小时执行一次任务。还可以在日期字段中使用1,15,31表示每月的第1、15和31天执行作业。

- 如果用户需要为某个字段指定一段连续的值，则可以使用连字符。例如，用户可以在日期字段使用1-7表示每月的第1～7天执行作业。

- 用户可以使用通配符*来匹配所有可能的值。例如，用户在分钟字段使用星号*表示每分钟都执行作业，在小时字段中使用星号*表示每个小时都执行作业。

- 用户可以使用斜线操作符/来跳过某些给定的数值。例如，用户在小时字段中使用*/2表示每隔2个小时执行作业，等价于直接使用0,2,4,6,8,10,12,14,16,18,20,22表示。

注意：与Linux系统中其他的配置文件相同，crontab文件也支持行注释。在每个文本行的开头使用#符号，表示从行首一直到行尾都是注释的内容。

3．显示用户的任务计划列表

用户可以通过crontab -l命令来列出每个用户的任务列表。在默认情况下，crontab命令只列出当前用户的任务清单，如下：

[root@linux chapter15]# crontab -l
1 1 * * * /backup.sh
*/4 1 * * * /sendemail.sh

作为系统管理员，root用户可以查看其他所有用户的任务计划列表。此时，需要使用-u选项来指定用户，如下：

[root@linux ~]# crontab -l -u postgres
*/5 * * * *
/var/lib/pgsql/scripts/auto_archive_log.sh

crontab-l命令会读取/var/spool/cron目录中的以用户登录名命名的文件的内容，然后将其输出。因此，crontab-l命令的输出结果与直接查看/var/spool/cron目录中的相应文件的内容是一致的。

4．编辑用户的任务计划列表

用户可以使用含有-e选项的crontab命令来编辑某个用户的任务计划列表，其中，用户名需要使用-u选项指定。在默认情况下，crontab命令修改的是当前用户的任务计划列表。

注意：如果无意中输入了无选项的crontab命令，请使用Ctrl+C组合键或者q命令退出编辑器，此时编辑器不会保存修改。如果使用w命令保存了修改，则原有的crontab文件的所有内容都将被清除，变成空文件。这一点在编辑crontab文件时一定要注意。

例如，下面的命令修改root用户的任务计划列表，将前面创建的Apache日志归档脚本添加进去：

[root@linux chapter15]# crontab -e

当输入以上命令并且按回车键之后，会出现一个全屏的编辑器窗口。通常情况下，该编辑器为vi或者vim，具体需要使用哪个编辑器，用户可以根据自己的需要设定。进入编辑模式后，在文件的末尾追加一行：

0 0 * * * /root/chapter15/archivelog.sh

以上语句表示每天的0时0分执行一次/root/chapter15/archivelog.sh脚本。输入完成之后，切换到命令模式，输入以下命令保存并退出编辑器：

:wq

如果保存成功，Shell会给出以下提示信息：

"/tmp/crontab.WFJCBU" 1L, 40C written
crontab: installing new crontab

以上信息告诉用户，已经成功地安装了新的任务计划。

注意：如果对于vi编辑器不熟悉，可以参考本书的2.2节。

添加完成之后，用户可以使用crontab-l命令查看计划列表，如下：

[root@linux log]# crontab -l
0 * * * * /root/chapter15/archivelog.sh

通过上面的输出结果，可以确认Apache日志归档脚本已经成功地添加到了crontab文件中。

15.3　小结

本章以两个具体的例子说明了如何通过Shell编程来解决实际问题。这两个例子非常具有代表性，且非常实用。本章的重点在于使读者掌握根据自己的实际需要，来编写复杂脚本文件的方法。尽管本书介绍的是Shell编程，但是读者不应该仅仅局限于脚本本身。除了脚本之外，还应该了解Linux系统的其他知识，例如系统服务的原理，以及任务计划的管理等。只有全面掌握这些知识，才可以编写出功能完善的Shell程序。

附　录

CD-link：http://pan.baidu.com/share/link?shareid=3504944541&uk=1027030125

密码：en6v