Shell编程小结


准备工作

背景

为了系统地学习和总结 Shell 编程,制定了一个 Shell 编程范例的总结计划,不会专门介绍 Shell 的语法, 而是假设对 Shell 编程有了一定的基础。另外,该系列涵盖:数值、逻辑值、字符串、文件、进程、文件系统等所有我们可以操作的“对象”,这个操作对象也将从低级到高级,进而上升到网络层面,整个通过各种方式连接起来的计算机的集合。实际上这也未尝不是在摸索 UNIX 的哲学,那”K.I.S.S”(Keep It Simple, Stupid)蕴藏的巨大能量。

什么是Shell

首先让我们从下图看看 Shell 在整个操作系统中所处的位置吧,该图的外圆描述了整个操作系统(比如 Debian/Ubuntu/Slackware 等),内圆描述了操作系统的核心(比如 Linux Kernel),而 ShellGUI 一样作为用户和操作系统之间的接口。

GUI 提供了一种图形化的用户接口,使用起来非常简便易学;而 Shell 则为用户提供了一种命令行的接口,接收用户的键盘输入,并分析和执行输入字符串中的命令,然后给用户返回执行结果,使用起来可能会复杂一些,但是由于占用的资源少,而且在操作熟练以后可能会提高工作效率,而且具有批处理的功能,因此在某些应用场合还非常流行。

Shell 作为一种用户接口,它实际上是一个能够解释和分析用户键盘输入,执行输入中的命令,然后返回结果的一个解释程序(Interpreter,例如在 linux 下比较常用的 Bash),我们可以通过下面的命令查看当前的 Shell

$ echo $SHELL
/bin/bash
$ ls -l /bin/bash
-rwxr-xr-x 1 root root 702160 2020-06-18 02:33 /bin/bash

该解释程序不仅能够解释简单的命令,而且可以解释一个具有特定语法结构的文件,这种文件被称作脚本(Script)。它具体是如何解释这些命令和脚本文件的,这里不深入分析。

搭建运行环境

为了方便后面的练习,我们先搭建一个基本运行环境:在一个 Linux 操作系统中,有一个运行有 Bash 的命令行在等待我们键入命令,这个命令行可以是图形界面下的 Terminal (例如 Ubuntu 下非常厉害的 Terminator),也可以是字符界面的 Console (可以用 CTRL+ALT+F1~6 切换),如果你发现当前 Shell 不是 Bash,请用下面的方法替换它:

$ chsh $USER -s /bin/bash
$ su $USER

或者是简单地键入Bash:

$ bash
$ echo $SHELL  # 确认一下
/bin/bash

有了基本的运行环境,那么如何来运行用户键入的命令或者是用户编写好的脚本文件呢 ?

假设我们编写好了一个 Shell 脚本,叫 test.sh

第一种方法是确保我们执行的命令具有可执行权限,然后直接键入该命令执行它:

$ chmod +x /path/to/test.sh
$ /path/to/test.sh

第二种方法是直接把脚本作为 Bash 解释器的参数传入:

$ bash /path/to/test.sh

$ source /path/to/test.sh

$ . /path/to/test.sh

基本语法介绍

先来一个 Hello, World 程序。

下面来介绍一个 Shell 程序的基本结构,以 Hello, World 为例:

#!/bin/bash -v
# test.sh
echo "Hello, World"

把上述代码保存为 test.sh,然后通过上面两种不同方式运行,可以看到如下效果。

方法一:

$ chmod +x test.sh
$ ./test.sh
 ./test.sh
 #!/bin/bash -v

 echo "Hello, World"
 Hello, World

方法二:

$ bash test.sh
Hello, World

$ source test.sh
Hello, World

$ . test.sh
Hello, World

我们发现两者运行结果有区别,为什么呢?这里我们需要关注一下 test.sh 文件的内容,它仅仅有两行,第二行打印了 Hello, World,两种方法都达到了目的,但是第一种方法却多打印了脚本文件本身的内容,为什么呢?

原因在该文件的第一行,当我们直接运行该脚本文件时,该行告诉操作系统使用用#! 符号之后面的解释器以及相应的参数来解释该脚本文件,通过分析第一行,我们发现对应的解释器以及参数是 /bin/bash -v,而 -v 刚好就是要打印程序的源代码;但是我们在用第二种方法时没有给 Bash 传递任何额外的参数,因此,它仅仅解释了脚本文件本身。

其他语法细节请直接看每一章小结后的附录。

Shell 程序设计过程

Shell 语言作为解释型语言,它的程序设计过程跟编译型语言有些区别,其基本过程如下:

  • 设计算法
  • 用 Shell 编写脚本程序实现算法
  • 直接运行脚本程序

可见它没有编译型语言的”麻烦的”编译和链接过程,不过正是因为这样,它出错时调试起来不是很方便,因为语法错误和逻辑错误都在运行时出现。下面我们简单介绍一下调试方法。

调试方法介绍

可以直接参考资料:Shell 脚本调试技术 或者 BASH 的调试手段

小结

Shell 语言作为一门解释型语言,可以使用大量的现有工具,包括数值计算、符号处理、文件操作、网络操作等,因此,编写过程可能更加高效,但是因为它是解释型的,需要在执行过程中从磁盘上不断调用外部的程序并进行进程之间的切换,在运行效率方面可能有劣势,所以我们应该根据应用场合选择使用 Shell 或是用其他的语言来编程。

数值运算

前言

这一小节开始,打算结合平时积累和进一步实践,通过一些范例来介绍Shell编程。因为范例往往能够给人以学有所用的感觉,而且给人以动手实践的机会,从而激发人的学习热情。

考虑到易读性,这些范例将非常简单,但是实用,希望它们能够成为我们解决日常问题的参照物或者是“茶余饭后”的小点心,当然这些“点心”肯定还有值得探讨、优化的地方。

概要:

  • 目的:享受用 Shell 解决问题的乐趣;和朋友们一起交流和探讨。
  • 读者:熟悉 Linux 基本知识,如文件系统结构、常用命令行工具、Shell 编程基础等。
  • 补充:看范例时,可参考《Shell基础十二篇》《Shell十三问》
  • 环境:如没特别说明,该系列使用的 Shell 将特指 Bash,版本在 3.1.17 以上。
  • 说明:该系列不是依据 Shell 语法组织,而是面向某些潜在的操作对象和操作本身,它们反应了现实应用。当然,在这个过程中肯定会涉及到 Shell 的语法。

这一小节打算讨论一下 Shell 编程中的基本数值运算,这类运算包括:

  • 数值(包括整数和浮点数)间的加、减、乘、除、求幂、求模等
  • 产生指定范围的随机数
  • 产生指定范围的数列

Shell 本身可以做整数运算,复杂一些的运算要通过外部命令实现,比如 exprbcawk 等。另外,可通过 RANDOM 环境变量产生一个从 0 到 32767 的随机数,一些外部工具,比如 awk 可以通过 rand() 函数产生随机数。而 seq 命令可以用来产生一个数列。下面对它们分别进行介绍。

整数运算

范例:对某个数加 1

$ i=0;
$ ((i++))
$ echo $i
1

$ let i++
$ echo $i
2

$ expr $i + 1
3
$ echo $i
2

$ echo $i 1 | awk '{printf $1+$2}'
3

说明: expr 之后的 $i+,1 之间有空格分开。如果进行乘法运算,需要对运算符进行转义,否则 Shell 会把乘号解释为通配符,导致语法错误; awk 后面的 $1$2 分别指 $i 和 1,即从左往右的第 1 个和第 2 个数。

用 Shell 的内置命令查看各个命令的类型如下:

$ type type
type is a shell builtin
$ type let
let is a shell builtin
$ type expr
expr is hashed (/usr/bin/expr)
$ type bc
bc is hashed (/usr/bin/bc)
$ type awk
awk is /usr/bin/awk

从上述演示可看出: let 是 Shell 内置命令,其他几个是外部命令,都在 /usr/bin 目录下。而 exprbc 因为刚用过,已经加载在内存的 hash 表中。这将有利于我们理解在上一章介绍的脚本多种执行方法背后的原理。

说明:如果要查看不同命令的帮助,对于 lettype 等 Shell 内置命令,可以通过 Shell 的一个内置命令 help 来查看相关帮助,而一些外部命令可以通过 Shell 的一个外部命令 man 来查看帮助,用法诸如 help letman expr 等。

范例:从 1 加到某个数

#!/bin/bash
# calc.sh

i=0;
while [ $i -lt 10000 ]
do
    ((i++))
done
echo $i

说明:这里通过 while [ 条件表达式 ]; do .... done 循环来实现。-lt 是小于号 <,具体见 test 命令的用法:help test

如何执行该脚本?

办法一:直接把脚本文件当成子 Shell (Bash)的一个参数传入

$ bash calc.sh
$ type bash
bash is hashed (/bin/bash)

办法二:是通过 bash 的内置命令 .source 执行

$ . ./calc.sh

$ source ./calc.sh
$ type .
. is a shell builtin
$ type source
source is a shell builtin

办法三:是修改文件为可执行,直接在当前 Shell 下执行

$ chmod ./calc.sh
$ ./calc.sh

下面,逐一演示用其他方法计算变量加一,即把 ((i++)) 行替换成下面的某一个:

let i++;
i=$(expr $i + 1)
i=$(echo $i+1|bc)
i=$(echo "$i 1" | awk '{printf $1+$2;}')

比较计算时间如下:

$ time calc.sh
10000

real    0m1.319s
user    0m1.056s
sys     0m0.036s
$ time calc_let.sh
10000

real    0m1.426s
user    0m1.176s
sys     0m0.032s
$  time calc_expr.sh
1000

real    0m27.425s
user    0m5.060s
sys     0m14.177s
$ time calc_bc.sh
1000

real    0m56.576s
user    0m9.353s
sys     0m24.618s
$ time ./calc_awk.sh
100

real    0m11.672s
user    0m2.604s
sys     0m2.660s

说明: time 命令可以用来统计命令执行时间,这部分时间包括总的运行时间,用户空间执行时间,内核空间执行时间,它通过 ptrace 系统调用实现。

通过上述比较可以发现 (()) 的运算效率最高。而 let 作为 Shell 内置命令,效率也很高,但是 exprbcawk 的计算效率就比较低。所以,在 Shell 本身能够完成相关工作的情况下,建议优先使用 Shell 本身提供的功能。但是 Shell 本身无法完成的功能,比如浮点运算,所以就需要外部命令的帮助。另外,考虑到 Shell 脚本的可移植性,在性能不是很关键的情况下,不要使用某些 Shell 特有的语法。

letexprbc 都可以用来求模,运算符都是 %,而 letbc 可以用来求幂,运算符不一样,前者是 **,后者是 ^ 。例如:

范例:求模

$ expr 5 % 2
1

$ let i=5%2
$ echo $i
1

$ echo 5 % 2 | bc
1

$ ((i=5%2))
$ echo $i
1

范例:求幂

$ let i=5**2
$ echo $i
25

$ ((i=5**2))
$ echo $i

25
$ echo "5^2" | bc
25

范例:进制转换

进制转换也是比较常用的操作,可以用 Bash 的内置支持也可以用 bc 来完成,例如把 8 进制的 11 转换为 10 进制,则可以:

$ echo "obase=10;ibase=8;11" | bc -l
9

$ echo $((8#11))
9

上面都是把某个进制的数转换为 10 进制的,如果要进行任意进制之间的转换还是 bc 比较灵活,因为它可以直接用 ibaseobase 分别指定进制源和进制转换目标。

范例:ascii 字符编码

如果要把某些字符串以特定的进制表示,可以用 od 命令,例如默认的分隔符 IFS 包括空格、 TAB 以及换行,可以用 man ascii 佐证。

$ echo -n "$IFS" | od -c
0000000      t  n
0000003
$ echo -n "$IFS" | od -b
0000000 040 011 012
0000003

浮点运算

letexpr 都无法进行浮点运算,但是 bcawk 可以。

范例:求 1 除以 13,保留 3 位有效数字

$ echo "scale=3; 1/13"  | bc
.076

$ echo "1 13" | awk '{printf("%0.3f\n",$1/$2)}'
0.077

说明: bc 在进行浮点运算时需指定精度,否则默认为 0,即进行浮点运算时,默认结果只保留整数。而 awk 在控制小数位数时非常灵活,仅仅通过 printf 的格式控制就可以实现。

补充:在用 bc 进行运算时,如果不用 scale 指定精度,而在 bc 后加上 -l 选项,也可以进行浮点运算,只不过这时的默认精度是 20 位。例如:

$ echo 1/13100 | bc -l
.00007633587786259541

范例:余弦值转角度

bc -l 计算,可以获得高精度:

$ export cos=0.996293; echo "scale=100; a(sqrt(1-$cos^2)/$cos)*180/(a(1)*4)" | bc -l
4.934954755411383632719834036931840605159706398655243875372764917732
5495504159766011527078286004072131

当然也可以用 awk 来计算:

$ echo 0.996293 | awk '{ printf("%s\n", atan2(sqrt(1-$1^2),$1)*180/3.1415926535);}'
4.93495

范例:有一组数据,求人均月收入最高家庭

在这里随机产生了一组测试数据,文件名为 income.txt

1 3 4490
2 5 3896
3 4 3112
4 4 4716
5 4 4578
6 6 5399
7 3 5089
8 6 3029
9 4 6195
10 5 5145

说明:上面的三列数据分别是家庭编号、家庭人数、家庭月总收入。

分析:为了求月均收入最高家庭,需要对后面两列数进行除法运算,即求出每个家庭的月均收入,然后按照月均收入排序,找出收入最高家庭。

实现:

#!/bin/bash
# gettopfamily.sh

[ $# -lt 1 ] && echo "please input the income file" && exit -1
[ ! -f $1 ] && echo "$1 is not a file" && exit -1

income=$1
awk '{
    printf("%d %0.2f\n", $1, $3/$2);
}' $income | sort -k 2 -n -r

说明:

  • [ $# -lt 1 ]:要求至少输入一个参数,$# 是 Shell 中传入参数的个数
  • [ ! -f $1 ]:要求输入参数是一个文件,-f 的用法见 test 命令,help test
  • income=$1:把输入参数赋给 income 变量,再作为 awk 的参数,即需处理的文件
  • awk:用文件第三列除以第二列,求出月均收入,考虑到精确性,保留了两位精度
  • sort -k 2 -n -r:这里对结果的 awk 结果的第二列 -k 2,即月均收入进行排序,按照数字排序 -n,并按照递减的顺序排序 -r

演示:

$ ./gettopfamily.sh income.txt
7 1696.33
9 1548.75
1 1496.67
4 1179.00
5 1144.50
10 1029.00
6 899.83
2 779.20
3 778.00
8 504.83

补充:之前的 income.txt 数据是随机产生的。在做一些实验时,往往需要随机产生一些数据,在下一小节,我们将详细介绍它。这里是产生 income.txt 数据的脚本:

#!/bin/bash
# genrandomdata.sh

for i in $(seq 1 10)
do
    echo $i $(($RANDOM/8192+3)) $((RANDOM/10+3000))
done

说明:上述脚本中还用到seq命令产生从1到10的一列数,这个命令的详细用法在该篇最后一节也会进一步介绍。

随机数

环境变量 RANDOM 产生从 0 到 32767 的随机数,而 awkrand() 函数可以产生 0 到 1 之间的随机数。

范例:获取一个随机数

$ echo $RANDOM
81

$ echo "" | awk '{srand(); printf("%f", rand());}'
0.237788

说明: srand() 在无参数时,采用当前时间作为 rand() 随机数产生器的一个 seed

范例:随机产生一个从 0 到 255 之间的数字

可以通过 RANDOM 变量的缩放和 awkrand() 的放大来实现。

$ expr $RANDOM / 128

$ echo "" | awk '{srand(); printf("%d\n", rand()*255);}'

思考:如果要随机产生某个 IP 段的 IP 地址,该如何做呢?看例子:友善地获取一个可用的 IP 地址。

#!/bin/bash
# getip.sh -- get an usable ipaddress automatically
# author: falcon &lt;zhangjinw@gmail.com>
# update: Tue Oct 30 23:46:17 CST 2007

# set your own network, default gateway, and the time out of "ping" command
net="192.168.1"
default_gateway="192.168.1.1"
over_time=2

# check the current ipaddress
ping -c 1 $default_gateway -W $over_time
[ $? -eq 0 ] && echo "the current ipaddress is okey!" && exit -1;

while :; do
    # clear the current configuration
    ifconfig eth0 down
    # configure the ip address of the eth0
    ifconfig eth0 \
        $net.$(($RANDOM /130 +2)) \
        up
    # configure the default gateway
    route add default gw $default_gateway
    # check the new configuration
    ping -c 1 $default_gateway -W $over_time
    # if work, finish
    [ $? -eq 0 ] && break
done

说明:如果你的默认网关地址不是 192.168.1.1,请自行配置 default_gateway(可以用 route -n 命令查看),因为用 ifconfig 配置地址时不能配置为网关地址,否则你的IP地址将和网关一样,导致整个网络不能正常工作。

其他运算

其实通过一个循环就可以产生一系列数,但是有相关工具为什么不用呢!seq 就是这么一个小工具,它可以产生一系列数,你可以指定数的递增间隔,也可以指定相邻两个数之间的分割符。

范例:获取一系列数

$ seq 5
1
2
3
4
5
$ seq 1 5
1
2
3
4
5
$ seq 1 2 5
1
3
5
$ seq -s: 1 2 5
1:3:5
$ seq 1 2 14
1
3
5
7
9
11
13
$ seq -w 1 2 14
01
03
05
07
09
11
13
$ seq -s: -w 1 2 14
01:03:05:07:09:11:13
$ seq -f "0x%g" 1 5
0x1
0x2
0x3
0x4
0x5

一个比较典型的使用 seq 的例子,构造一些特定格式的链接,然后用 wget 下载这些内容:

$ for i in `seq -f"http://thns.tsinghua.edu.cn/thnsebooks/ebook73/%02g.pdf" 1 21`;do wget -c $i; done

或者

$ for i in `seq -w 1 21`;do wget -c "http://thns.tsinghua.edu.cn/thnsebooks/ebook73/$i"; done

补充:在 Bash 版本 3 以上,在 for 循环的 in 后面,可以直接通过 {1..5} 更简洁地产生自 1 到 5 的数字(注意,1 和 5 之间只有两个点),例如:

$ for i in {1..5}; do echo -n "$i "; done
1 2 3 4 5

范例:统计字符串中各单词出现次数

我们先给单词一个定义:由字母组成的单个或者多个字符系列。

首先,统计每个单词出现的次数:

$ wget -c http://tinylab.org
$ cat index.html | sed -e "s/[^a-zA-Z]/\n/g" | grep -v ^$ | sort | uniq -c

接着,统计出现频率最高的前10个单词:

$ wget -c http://tinylab.org
$ cat index.html | sed -e "s/[^a-zA-Z]/\n/g" | grep -v ^$ | sort | uniq -c | sort -n -k 1 -r | head -10
    524 a
    238 tag
    205 href
    201 class
    193 http
    189 org
    175 tinylab
    174 www
    146 div
    128 title

说明:

  • cat index.html: 输出 index.html 文件里的内容
  • sed -e "s/[^a-zA-Z]/\n/g": 把非字母字符替换成空格,只保留字母字符
  • grep -v ^$: 去掉空行
  • sort: 排序
  • uniq -c:统计相同行的个数,即每个单词的个数
  • sort -n -k 1 -r:按照第一列 -k 1 的数字 -n 逆序 -r 排序
  • head -10:取出前十行

范例:统计指定单词出现次数

可以考虑采取两种办法:

  • 只统计那些需要统计的单词
  • 用上面的算法把所有单词的个数都统计出来,然后再返回那些需要统计的单词给用户

不过,这两种办法都可以通过下面的结构来实现。先看办法一:

#!/bin/bash
# statistic_words.sh

if [ $# -lt 1 ]; then
    echo "Usage: basename $0 FILE WORDS ...."
    exit -1
fi

FILE=$1
((WORDS_NUM=$#-1))

for n in $(seq $WORDS_NUM)
do
    shift
    cat $FILE | sed -e "s/[^a-zA-Z]/\n/g" \
        | grep -v ^$ | sort | grep ^$1$ | uniq -c
done

说明:

  • if 条件部分:要求至少两个参数,第一个单词文件,之后参数为要统计的单词
  • FILE=$1: 获取文件名,即脚本之后的第一个字符串
  • ((WORDS_NUM=$#-1)):获取单词个数,即总的参数个数 $# 减去文件名参数(1个)
  • for 循环部分:首先通过 seq 产生需要统计的单词个数系列,shift 是 Shell 内置变量(请通过 help shift 获取帮助),它把用户从命令行中传入的参数依次往后移动位置,并把当前参数作为第一个参数即 $1,这样通过 $1就可以遍历用户所有输入的单词(仔细一想,这里貌似有数组下标的味道)。你可以考虑把 shift 之后的那句替换成 echo $1 测试 shift 的用法

演示:

$ chmod +x statistic_words.sh
$ ./statistic_words.sh index.html tinylab linux python
    175 tinylab
     43 linux
      3 python

再看办法二,我们只需要修改 shift 之后的那句即可:

#!/bin/bash
# statistic_words.sh

if [ $# -lt 1 ]; then
    echo "ERROR: you should input 2 words at least";
    echo "Usage: basename $0 FILE WORDS ...."
    exit -1
fi

FILE=$1
((WORDS_NUM=$#-1))

for n in $(seq $WORDS_NUM)
do
    shift
    cat $FILE | sed -e "s/[^a-zA-Z]/\n/g" \
        | grep -v ^$ | sort | uniq -c | grep " $1$"
done

演示:

$ ./statistic_words.sh index.html tinylab linux python
    175 tinylab
     43 linux
      3 python

说明:很明显,办法一的效率要高很多,因为它提前找出了需要统计的单词,然后再统计,而后者则不然。实际上,如果使用 grep-E 选项,我们无须引入循环,而用一条命令就可以搞定:

$ cat index.html | sed -e "s/[^a-zA-Z]/\n/g" | grep -v ^$ | sort | grep -E "^tinylab$|^linux$" | uniq -c
     43 linux
    175 tinylab

或者

$ cat index.html | sed -e "s/[^a-zA-Z]/\n/g" | grep -v ^$ | sort | egrep  "^tinylab$|^linux$" | uniq -c
     43 linux
    175 tinylab

说明:需要注意到 sed 命令可以直接处理文件,而无需通过 cat 命令输出以后再通过管道传递,这样可以减少一个不必要的管道操作,所以上述命令可以简化为:

$ sed -e "s/[^a-zA-Z]/\n/g" index.html | grep -v ^$ | sort | egrep  "^tinylab$|^linux$" | uniq -c
     43 linux
    175 tinylab

所以,可见这些命令 sedgrepuniqsort 是多么有用,它们本身虽然只完成简单的功能,但是通过一定的组合,就可以实现各种五花八门的事情啦。对了,统计单词还有个非常有用的命令 wc -w,需要用到的时候也可以用它。

小结

到这里,Shell 编程范例之数值计算就结束啦。该小节主要介绍了:

  • Shell 编程中的整数运算、浮点运算、随机数的产生、数列的产生
  • Shell 的内置命令、外部命令的区别,以及如何查看他们的类型和帮助
  • Shell 脚本的几种执行办法
  • 几个常用的 Shell 外部命令: sedawkgrepuniqsort
  • 范例:数字递增;求月均收入;自动获取 IP 地址;统计单词个数
  • 其他:相关用法如命令列表,条件测试等在上述范例中都已涉及,请认真阅读之

布尔运算

前言

在 Bash 里有这样的常量(实际上是两个内置命令,在这里我们姑且这么认为,后面将介绍),即 true 和 false,一个表示真,一个表示假。对它们可以进行与、或、非运算等常规的逻辑运算,在这一节,我们除了讨论这些基本逻辑运算外,还将讨论Shell编程中的条件测试命令列表,并介绍它们和布尔运算的关系。

常规的布尔运算

这里主要介绍 Bash 里头常规的逻辑运算,与、或、非。

在 Shell 下如何进行逻辑运算

范例:true or false

单独测试 truefalse,可以看出 true 是真值,false 为假

$ if true;then echo "YES"; else echo "NO"; fi
YES
$ if false;then echo "YES"; else echo "NO"; fi
NO
范例:与运算
$ if true && true;then echo "YES"; else echo "NO"; fi
YES
$ if true && false;then echo "YES"; else echo "NO"; fi
NO
$ if false && false;then echo "YES"; else echo "NO"; fi
NO
$ if false && true;then echo "YES"; else echo "NO"; fi
NO
范例:或运算
$ if true || true;then echo "YES"; else echo "NO"; fi
YES
$ if true || false;then echo "YES"; else echo "NO"; fi
YES
$ if false || true;then echo "YES"; else echo "NO"; fi
YES
$ if false || false;then echo "YES"; else echo "NO"; fi
NO
范例:非运算,即取反
$ if ! false;then echo "YES"; else echo "NO"; fi
YES
$ if ! true;then echo "YES"; else echo "NO"; fi
NO

可以看出 truefalse 按照我们对逻辑运算的理解进行着,但是为了能够更好的理解 Shell 对逻辑运算的实现,我们还得弄清楚,truefalse 是怎么工作的?

Bash 里头的 true 和 false 是我们通常认为的 1 和 0 么?

回答是:否。

范例:返回值 v.s. 逻辑值

truefalse 它们本身并非逻辑值,它们都是 Shell 的内置命令,只是它们的返回值是一个“逻辑值”:

$ true
$ echo $?
0
$ false
$ echo $?
1

可以看到 true 返回了 0,而 false 则返回了 1 。跟我们离散数学里学的真值 1 和 0 并不是对应的,而且相反的。

范例:查看 true 和 false 帮助和类型
$ help true false
true: true
     Return a successful result.
false: false
     Return an unsuccessful result.
$ type true false
true is a shell builtin
false is a shell builtin

说明:$? 是一个特殊变量,存放有上一次进程的结束状态(退出状态码)。

从上面的操作不难联想到在 C 语言程序设计中为什么会强调在 main 函数前面加上 int,并在末尾加上 return 0 。因为在 Shell 里,将把 0 作为程序是否成功结束的标志,这就是 Shell 里头 truefalse 的实质,它们用以反应某个程序是否正确结束,而并非传统的真假值(1 和 0),相反地,它们返回的是 0 和 1 。不过庆幸地是,我们在做逻辑运算时,无须关心这些。

条件测试

从上节中,我们已经清楚地了解了 Shell 下的“逻辑值”是什么:是进程退出时的返回值,如果成功返回,则为真,如果不成功返回,则为假。

而条件测试正好使用了 test 这么一个指令,它用来进行数值测试(各种数值属性测试)、字符串测试(各种字符串属性测试)、文件测试(各种文件属性测试),我们通过判断对应的测试是否成功,从而完成各种常规工作,再加上各种测试的逻辑组合后,将可以完成更复杂的工作。

条件测试基本使用

范例:数值测试
$ if test 5 -eq 5;then echo "YES"; else echo "NO"; fi
YES
$ if test 5 -ne 5;then echo "YES"; else echo "NO"; fi
NO
范例:字符串测试
$ if test -n "not empty";then echo "YES"; else echo "NO"; fi
YES
$ if test -z "not empty";then echo "YES"; else echo "NO"; fi
NO
$ if test -z "";then echo "YES"; else echo "NO"; fi
YES
$ if test -n "";then echo "YES"; else echo "NO"; fi
NO
范例:文件测试
$ if test -f /boot/System.map; then echo "YES"; else echo "NO"; fi
YES
$ if test -d /boot/System.map; then echo "YES"; else echo "NO"; fi
NO

各种逻辑测试的组合

范例:如果 a,b,c 都等于下面对应的值,那么打印 YES,通过 -a 进行”与”测试
$ a=5;b=4;c=6;
$ if test $a -eq 5 -a $b -eq 4 -a $c -eq 6; then echo "YES"; else echo "NO"; fi
YES
范例:测试某个“东西”是文件或者目录,通过 -o 进行“或”运算
$ if test -f /etc/profile -o -d /etc/profile;then echo "YES"; else echo "NO"; fi
YES
范例:测试某个“东西”是否为文件,测试 ! 非运算
$ if test ! -f /etc/profile; then echo "YES"; else echo "NO"; fi
NO

上面仅仅演示了 test 命令一些非常简单的测试,你可以通过 help test 获取 test 的更多用法。需要注意的是,test 命令内部的逻辑运算和 Shell 的逻辑运算符有一些区别,对应的为 -a&&-o||,这两者不能混淆使用。而非运算都是 !,下面对它们进行比较。

比较 -a 与 &&, -o 与 ||, ! test 与 test !

范例:要求某文件可执行且有内容,用 -a 和 && 分别实现
$ cat > test.sh
#!/bin/bash
echo "test"
[CTRL+D]  # 按下组合键CTRL与D结束cat输入,后同,不再注明
$ chmod +x test.sh
$ if test -s test.sh -a -x test.sh; then echo "YES"; else echo "NO"; fi
YES
$ if test -s test.sh && test -x test.sh; then echo "YES"; else echo "NO"; fi
YES
范例:要求某个字符串要么为空,要么和某个字符串相等
$ str1="test"
$ str2="test"
$ if test -z "$str2" -o "$str2" == "$str1"; then echo "YES"; else echo "NO"; fi
YES
$ if test -z "$str2" || test "$str2" == "$str1"; then echo "YES"; else echo "NO"; fi
YES
范例:测试某个数字不满足指定的所有条件
$ i=5
$ if test ! $i -lt 5 -a $i -ne 6; then echo "YES"; else echo "NO"; fi
YES
$ if ! test $i -lt 5 -a $i -eq 6; then echo "YES"; else echo "NO"; fi
YES

很容易找出它们的区别,-a-o 作为测试命令的参数用在测试命令的内部,而 &&|| 则用来运算测试的返回值,! 为两者通用。需要关注的是:

  • 有时可以不用 ! 运算符,比如 -eq-ne 刚好相反,可用于测试两个数值是否相等; -z-n 也是对应的,用来测试某个字符串是否为空
  • Bash 里,test 命令可以用[] 运算符取代,但是需要注意,[之后与] 之前需要加上额外的空格
  • 在测试字符串时,所有变量建议用双引号包含起来,以防止变量内容为空时出现仅有测试参数,没有测试内容的情况

下面我们用实例来演示上面三个注意事项:

  • -ne-eq 对应的,我们有时候可以免去 ! 运算

    $ i=5
    $ if test $i -eq 5; then echo "YES"; else echo "NO"; fi
    YES
    $ if test $i -ne 5; then echo "YES"; else echo "NO"; fi
    NO
    $ if test ! $i -eq 5; then echo "YES"; else echo "NO"; fi
    NO
  • [ ] 可以取代 test,这样看上去会“美观”很多

    $ if [ $i -eq 5 ]; then echo "YES"; else echo "NO"; fi
    YES
    $ if [ $i -gt 4 ] && [ $i -lt 6 ]; then echo "YES"; else echo "NO"; fi
    YES
  • 记得给一些字符串变量加上 "",记得 [ 之后与 ] 之前多加一个空格

    $ str=""
    $ if [ "$str" = "test"]; then echo "YES"; else echo "NO"; fi
    -bash: [: missing `]'
    NO
    $ if [ $str = "test" ]; then echo "YES"; else echo "NO"; fi
    -bash: [: =: unary operator expected
    NO
    $ if [ "$str" = "test" ]; then echo "YES"; else echo "NO"; fi
    NO

到这里,条件测试就介绍完了,下面介绍命令列表,实际上在上面我们已经使用过了,即多个test命令的组合,通过 &&||! 组合起来的命令序列。这种命令序列可以有效替换 if/then 的条件分支结构。这不难想到我们在 C 语言程序设计中经常做的如下的选择题(很无聊的例子,但是有意义):下面是否会打印 j,如果打印,将打印什么?

#include <stdio.h>
int main()
{
    int i, j;

    i=5;j=1;
    if ((i==5) && (j=5))  printf("%d\n", j);

    return 0;
}

很容易知道将打印数字 5,因为 i==5 这个条件成立,而且随后是 &&,要判断整个条件是否成立,我们得进行后面的判断,可是这个判断并非常规的判断,而是先把 j 修改为 5,再转换为真值,所以条件为真,打印出 5 。因此,这句可以解释为:如果 i 等于 5,那么把 j 赋值为 5,如果 j 大于 1 (因为之前已经为真),那么打印出 j 的值。这样用 && 连结起来的判断语句替代了两个 if 条件分支语句。

正是基于逻辑运算特有的性质,我们可以通过 &&|| 来取代 if/then 等条件分支结构,这样就产生了命令列表。

命令列表

命令列表的执行规律

命令列表的执行规律符合逻辑运算的运算规律,用 && 连接起来的命令,如果前者成功返回,将执行后面的命令,反之不然;用 || 连接起来的命令,如果前者成功返回,将不执行后续命令,反之不然。

范例:如果 ping 通 https://www.uestc.edu.cn/ ,那么打印连通信息
$ ping -c 1 https://www.uestc.edu.cn/ -W 1 && echo "=======connected======="

非常有趣的问题出来了,即我们上面已经提到的:为什么要让 C 程序在 main() 函数的最后返回 0 ?如果不这样,把这种程序放入命令列表会有什么样的结果?你自己写个简单的 C 程序,然后放入命令列表看看。

命令列表的作用

有时用命令列表取代 if/then 等条件分支结构可以省掉一些代码,而且使得程序比较美观、易读,例如:

范例:在脚本里判断程序的参数个数,和参数类型
#!/bin/bash

echo $#
echo $1
if [ $# -eq 1 ] && (echo $1 | grep '^[0-9]*$' >/dev/null);then
    echo "YES"
fi

说明:上例要求参数个数为 1 并且类型为数字。

再加上 exit 1,我们将省掉 if/then 结构

#!/bin/bash

echo $#
echo $1
! ([ $# -eq 1 ] && (echo $1 | grep '^[0-9]*$' >/dev/null)) && exit 1

echo "YES"

这样处理后,对程序参数的判断仅仅需要简单的一行代码,而且变得更美观。

小结

这一节介绍了 Shell 编程中的逻辑运算,条件测试和命令列表。

字符串操作

前言

之前已经完成数值运算和布尔运算,这次轮到介绍字符串操作 。咱们先得弄明白两个内容:

  • 什么是字符串?

  • 对字符串有哪些操作?

  • 什么是字符串?

  • 对字符串有哪些操作?

下面是”在线新华字典”的解释:

字符串: 简称“串”。有限字符的序列。数据元素为字符的线性表,是一种数据的逻辑结构。在计算机中可有不同的存储结构。在串上可进行求子串、插入字符、删除字符、置换字符等运算。

而字符呢?

字符: 计算机程序设计及操作时使用的符号。包括字母、数字、空格符、提示符及各种专用字符等。

照这样说,之前介绍的中的数字,布尔运算中的真假值,都是以字符的形式呈现出来的,是一种特别的字符,对它们的运算只不过是字符操作的特例罢了。而这里将研究一般字符的运算,它具有非常重要的意义,因为对我们来说,一般的工作都是处理字符而已。这些运算实际上将围绕上述两个定义来做,它们包括:

  • 找出字符或者字符串的类型,是数字、字母还是其他特定字符,是可打印字符,还是不可打印字符(一些控制字符)。
  • 找出组成字符串的字符个数和字符串的存储结构(比如数组)。
  • 对串的常规操作:求子串、插入字符、删除字符、置换字符、字符串的比较等。
  • 对串的一些比较复杂而有趣的操作,这里将在最后介绍一些有趣的范例。

字符串的属性

字符串的类型

字符有可能是数字、字母、空格、其他特殊字符,而字符串有可能是它们中的一种或者多种的组合,在组合之后还可能形成具有特定意义的字符串,诸如邮件地址,URL地址等。

范例:数字或者数字组合
$ i=5;j=9423483247234;
$ echo $i | grep -q "^[0-9]$"
$ echo $?
0
$ echo $j | grep -q "^[0-9]\+$"
$ echo $?
0
范例:字符组合(小写字母、大写字母、两者的组合)
$ c="A"; d="fwefewjuew"; e="fewfEFWefwefe"
$ echo $c | grep -q "^[A-Z]$"
$ echo $d | grep -q "^[a-z]\+$"
$ echo $e | grep -q "^[a-zA-Z]\+$"
范例:字母和数字的组合
$ ic="432fwfwefeFWEwefwef"
$ echo $ic | grep -q "^[0-9a-zA-Z]\+$"
范例:空格或者 Tab 键等
$ echo " " | grep " "
$ echo -e "\t" | grep "[[:space:]]" #[[:space:]]会同时匹配空格和TAB键
$ echo -e " \t" | grep "[[:space:]]"
$ echo -e "\t" | grep "" #为在键盘上按下TAB键,而不是字符
范例:匹配邮件地址
$ echo "test2007@lzu.cn" | grep "[0-9a-zA-Z\.]*@[0-9a-zA-Z\.]*"
test2007@lzu.cn
范例:匹配 URL 地址(以 http 链接为例)
$ echo "http://news.lzu.edu.cn/article.jsp?newsid=10135" | grep "^http://[0-9a-zA-Z\./=?]\+$"
http://news.lzu.edu.cn/article.jsp?newsid=10135

说明:

  • /dev/null/dev/zero 设备非常有趣,都犹如黑洞,什么东西掉进去都会消失殆尽;后者还是个能源箱,总能从那里取到0,直到退出
  • [[:space:]]grep 用于匹配空格或 TAB 键字符的标记,其他标记请查帮助:man grep
  • 上面都是用 grep 来进行模式匹配,实际上 sedawk 都可用来做模式匹配,关于匹配中用到的正则表达式知识,请参考后面的相关资料
  • 如果想判断字符串是否为空,可判断其长度是否为零,可通过 test 命令的 -z 选项来实现,具体用法见 test 命令,help test
范例:判断字符是否为可打印字符
$ echo "\t\n" | grep "[[:print:]]"
\t\n
$ echo $?
0
$ echo -e "\t\n" | grep "[[:print:]]"
$ echo $?
1

字符串的长度

除了组成字符串的字符类型外,字符串还有哪些属性呢?组成字符串的字符个数。

下面我们来计算字符串的长度,即所有字符的个数,并简单介绍几种求字符串中指定字符个数的方法。

范例:计算某个字符串的长度

即计算所有字符的个数,计算方法五花八门,择其优着而用之:

$ var="get the length of me"
$ echo ${var}     # 这里等同于$var
get the length of me
$ echo ${#var}
20
$ expr length "$var"
20
$ echo $var | awk '{printf("%d\n", length($0));}'
20
$ echo -n $var |  wc -c
20
范例:计算某些指定字符或者字符组合的个数
$ echo $var | tr -cd g | wc -c
2
$ echo -n $var | sed -e 's/[^g]//g' | wc -c
2
$ echo -n $var | sed -e 's/[^gt]//g' | wc -c
5
范例:统计单词个数

更多相关信息见之前单词统计 相关范例。

$ echo $var | wc -w
5
$ echo "$var" | tr " " "\n" | grep get | uniq -c
1
$ echo "$var" | tr " " "\n" | grep get | wc -l
1

说明:

${} 操作符在 Bash 里头是一个“大牛”,能胜任相当多的工作,具体就看网中人的《Shell十三问》之$(())$() 还有${}差在哪?”吧。

字符串的显示

接下来讨论如何控制字符在终端的显示。

范例:在屏幕控制字符显示位置、颜色、背景等

$ echo -e "\033[31;40m" #设置前景色为黑色,背景色为红色
$ echo -e '\033[11;29H Hello, World!' #在屏幕的第11行,29列开始打印字符串Hello,World!

范例:在屏幕的某个位置动态显示当前系统时间

$ while :; do echo -e "\033[11;29H "$(date "+%Y-%m-%d %H:%M:%S"); done

范例:过滤掉某些控制字符串

col 命令过滤某些控制字符,在处理诸如 scriptscreen 等截屏命令的输出结果时,很有用。

$ screen -L
$ cat /bin/cat
$ exit
$ cat screenlog.0 | col -b   # 把一些控制字符过滤后,就可以保留可读的操作日志

字符串的存储

在我们看来,字符串是一连串的字符而已,但是为了操作方便,我们往往可以让字符串呈现出一定的结构。在这里,我们不关心字符串在内存中的实际存储结构,仅仅关系它呈现出来的逻辑结构。比如,这样一个字符串: "get the length of me",我们可以从不同的方面来呈现它。

  • 通过字符在串中的位置来呈现它

这样我们就可以通过指定位置来找到某个子串。这在 C 语言中通常可以利用指针来做。而在 Shell 编程中,有很多可用的工具,诸如 exprawk 都提供了类似方法来实现子串的查询动作。两者都几乎支持模式匹配 match 和完全匹配 index。这在后面的字符串操作中将详细介绍。

  • 根据某个分割符来取得字符串的各个部分

这里最常见的就是行分割符、空格或者 TAB 分割符了,前者用来当行号,我们似乎已经司空见惯了,因为我们的编辑器就这样“莫名”地处理着行分割符(在 UNIX 下为 \n,在其他系统下有一些不同,比如 Windows 下为 \r\n )。而空格或者 TAB 键经常用来分割数据库的各个字段,这似乎也是司空见惯的事情。

正因为这样,所以产生了大量优秀的行编辑工具,诸如 grepawksed 等。在“行内”(姑且这么说吧,就是处理单行,即字符串中不再包含行分割符)的字符串分割方面,cutawk 提供了非常优越的“行内”(处理单行)处理能力。

  • 更方便地处理用分割符分割好的各个部分

同样是用到分割符,但为了更方便的操作分割以后的字符串的各个部分,我们抽象了“数组”这么一个数据结构,从而让我们更加方便地通过下标来获取某个指定的部分。 bash 提供了这么一种数据结构,而优秀的 awk 也同样提供了它,我们这里将简单介绍它们的用法。

范例:把字符串拆分成字符串数组

  • Bash 提供的数组数据结构,以数字为下标的,和 C 语言从 0 开始的下标一样

    $ var="get the length of me"
    $ var_arr=($var)    #把字符串var存放到字符串数组var_arr中,默认以空格作为分割符
    $ echo ${var_arr[0]} ${var_arr[1]} ${var_arr[2]} ${var_arr[3]} ${var_arr[4]}
    get the length of me
    $ echo ${var_arr[@]}    #整个字符串,可以用*代替@,下同
    get the length of me
    $ echo ${#var_arr[@]}   #类似于求字符串长度,`#`操作符也可用来求数组元素个数
    5

    也可以直接给某个数组元素赋值

    $ var_arr[5]="new_element"
    $ echo ${#var_arr[@]}
    6
    $ echo ${var_arr[5]}
    new_element

    Bash 实际上还提供了一种类似于“数组”的功能,即 for i in,它可以很方便地获取某个字符串的各个部分,例如:

    $ for i in $var; do echo -n $i"_"; done
    get_the_length_of_me_
  • awk 里的数组,注意比较它和 Bash 里的数组的异同

    split 把一行按照空格分割,存放到数组 var_arr 中,并返回数组长度。注意:这里第一个元素下标不是 0,而是 1

    $ echo $var | awk '{printf("%d %s\n", split($0, var_arr, " "), var_arr[1]);}'
    5 get

    实际上,上述操作很类似 awk 自身的行处理功能: awk 默认把一行按照空格分割为多个域,并可以通过 $1$2$3 ... 来获取,$0 表示整行。

    这里的 NF 是该行的域的总数,类似于上面数组的长度,它同样提供了一种通过类似“下标”访问某个字符串的功能。

    $ echo $var | awk '{printf("%d | %s %s %s %s %s | %s\n", NF, $1, $2, $3, $4, $5, $0);}'
    5 | get the length of me | get the length of me

    awk 的“数组”功能何止于此呢,看看它的 for 引用吧,注意,这个和 Bash 里头的 for 不太一样,i 不是元素本身,而是下标:

    $ echo $var | awk '{split($0, var_arr, " "); for(i in var_arr) printf("%s ",var_arr[i]);}'
    of me get the length
    4 5 1 2 3

    另外,从上述结果可以看到,经过 for 处理后,整个结果没有按照原理的字符顺序排列,不过如果仅仅是迭代出所有元素这个同样很有意义。

awk 还有更“厉害”的处理能力,它的下标可以不是数字,可以是字符串,从而变成了“关联”数组,这种“关联”在某些方面非常方便。 比如,把某个文件中的某个系统调用名根据另外一个文件中的函数地址映射表替换成地址,可以这么实现:

$ cat symbol
sys_exit
sys_read
sys_close
$ ls /boot/System.map*
/boot/System.map-2.6.20-16-generic
$ awk '{if(FILENAME ~ "System.map") map[$3]=$1; else {printf("%s\n", map[$1])}}' \
    /boot/System.map-2.6.20-16-generic symbol
c0129a80
c0177310
c0175d80

另外,awk还支持用delete函数删除某个数组元素。如果某些场合有需要的话,别忘了awk还支持二维数组。

字符串常规操作

字符串操作包括取子串、查询子串、插入子串、删除子串、子串替换、子串比较、子串排序、子串进制转换、子串编码转换等。

取子串

取子串的方法主要有:

  • 直接到指定位置求子串
  • 字符匹配求子串
范例:按照位置取子串

比如从什么位置开始,取多少个字符

$ var="get the length of me"
$ echo ${var:0:3}
get
$ echo ${var:(-2)}   # 方向相反呢
me

$ echo `expr substr "$var" 5 3` #记得把$var引起来,否则expr会因为空格而解析错误
the

$ echo $var | awk '{printf("%s\n", substr($0, 9, 6))}'
length
awk``$var` 按照空格分开为多个变量,依次为 `$1``$2``$3``$4``$5
$ echo $var | awk '{printf("%s\n", $1);}'
get
$ echo $var | awk '{printf("%s\n", $5);}'
me

差点略掉cut小工具,它用起来和awk类似,-d指定分割符,如同awk-F指定分割符一样;-f指定“域”,如同awk的$数字

$ echo $var | cut -d" " -f 5
范例:匹配字符求子串

用 Bash 内置支持求子串:

$ echo ${var%% *} #从右边开始计算,删除最左边的空格右边的所有字符
get
$ echo ${var% *} #从右边开始计算,删除第一个空格右边的所有字符
get the length of
$ echo ${var##* }  #从左边开始计算,删除最右边的空格左边的所有字符
me
$ echo ${var#* }  #从左边开始计算,删除第一个空格左边的所有字符
the length of me

删除所有 空格+字母组合 的字符串:

$ echo $var | sed 's/ [a-z]*//g'
get
$ echo $var | sed 's/[a-z]* //g'
me

sed 有按地址(行)打印(p)的功能,记得先用 tr 把空格换成行号:

$ echo $var | tr " " "\n" | sed -n 1p
get
$ echo $var | tr " " "\n" | sed -n 5p
me

tr 也可以用来取子串,它可以类似#% 来“拿掉”一些字符串来实现取子串:

$ echo $var | tr -d " "
getthelengthofme
$ echo $var | tr -cd "[a-z]" #把所有的空格都拿掉了,仅仅保留字母字符串,注意-c和-d的用法
getthelengthofme

说明:

  • %#删除字符的方向不一样,前者在右,后者在左,%%%### 的方向是前者是最大匹配,后者是最小匹配。(好的记忆方法见网中人的键盘记忆法:#$% 是键盘依次从左到右的三个键)
  • tr-c 选项是 complement 的缩写,即 invert,而 -d 选项是删除,tr -cd "[a-z]" 这样一来就变成保留所有的字母

对于字符串的截取,实际上还有一些命令,如果 headtail 等可以实现有意思的功能,可以截取某个字符串的前面、后面指定的行数或者字节数。例如:

$ echo "abcdefghijk" | head -c 4
abcd
$ echo -n "abcdefghijk" | tail -c 4
hijk

查询子串

子串查询包括:

  • 返回符合某个模式的子串本身
  • 返回子串在目标串中的位置

准备:在进行下面的操作之前,请准备一个文件 test.txt,里头有内容 “consists of”,用于下面的范例。

范例:查询子串在目标串中的位置

expr index貌似仅仅可以返回某个字符或者多个字符中第一个字符出现的位置

$ var="get the length of me"
$ expr index "$var" t
3

awk却能找出字串,match还可以匹配正则表达式

$ echo $var | awk '{printf("%d\n", match($0,"the"));}'
5
范例:查询子串,返回包含子串的行

awksed 都可以实现这些功能,但是 grep 最擅长

$ grep "consists of" test.txt   # 查询文件包含consists of的行,并打印这些行
$ grep "consists[[:space:]]of" -n -H test.txt # 打印文件名,子串所在行的行号和该行的内容
$ grep "consists[[:space:]]of" -n -o test.txt # 仅仅打印行号和匹配到的子串本身的内容

$ awk '/consists of/{ printf("%s:%d:%s\n",FILENAME, FNR, $0)}' text  #看到没?和grep的结果一样
$ sed -n -e '/consists of/=;/consists of/p' text #同样可以打印行号

说明:

  • awkgrepsed 都能通过模式匹配查找指定字符串,但它们各有所长,将在后续章节中继续使用和比较它们,进而发现各自优点
  • 在这里姑且把文件内容当成了一个大的字符串,在后面章节中将专门介绍文件操作,所以对文件内容中存放字符串的操作将会有更深入的分析和介绍

子串替换

子串替换就是把某个指定的子串替换成其他的字符串,这里蕴含了“插入子串”和“删除子串”的操作。例如,想插入某个字符串到某个子串之前,就可以把原来的子串替换成”子串+新的字符串“,如果想删除某个子串,就把子串替换成空串。不过有些工具提供了一些专门的用法来做插入子串和删除子串的操作,所以呆伙还会专门介绍。另外,要想替换掉某个子串,一般都是先找到子串(查询子串),然后再把它替换掉,实质上很多工具在使用和设计上都体现了这么一点。

范例:把变量 var 中的空格替换成下划线

{} 运算符,还记得么?网中人的教程

$ var="get the length of me"
$ echo ${var/ /_}        #把第一个空格替换成下划线
get_the length of me
$ echo ${var// /_}       #把所有空格都替换成下划线
get_the_length_of_me

awkawk 提供了转换的最小替换函数 sub 和全局替换函数 gsub,类似 ///

$ echo $var | awk '{sub(" ", "_", $0); printf("%s\n", $0);}'
get_the length of me
$ echo $var | awk '{gsub(" ", "_", $0); printf("%s\n", $0);}'
get_the_length_of_me

sed,子串替换可是 sed 的特长:

$ echo $var | sed -e 's/ /_/'    #s <= substitude
get_the length of me
$ echo $var | sed -e 's/ /_/g'   #看到没有,简短两个命令就实现了最小匹配和最大匹配g <= global
get_the_length_of_me

有忘记 tr 命令么?可以用替换单个字符的:

$ echo $var | tr " " "_"
get_the_length_of_me
$ echo $var | tr '[a-z]' '[A-Z]'   #这个可有意思了,把所有小写字母都替换为大写字母
GET THE LENGTH OF ME

说明: sed 还有很有趣的标签用法呢,下面再介绍吧。

有一种比较有意思的字符串替换是:整个文件行的倒置,这个可以通过 tac 命令实现,它会把文件中所有的行全部倒转过来。在某种意义上来说,排序实际上也是一个字符串替换。

插入子串

在指定位置插入子串,这个位置可能是某个子串的位置,也可能是从某个文件开头算起的某个长度。通过上面的练习,我们发现这两者之间实际上是类似的。

公式:插入子串=把”old子串”替换成”old子串+new子串”或者”new子串+old子串”

范例:在 var 字符串的空格之前或之后插入一个下划线

用{}:

$ var="get the length of me"
$ echo ${var/ /_ }        #在指定字符串之前插入一个字符串
get_ the length of me
$ echo ${var// /_ }
get_ the_ length_ of_ me
$ echo ${var/ / _}        #在指定字符串之后插入一个字符串
get _the length of me
$ echo ${var// / _}
get _the _length _of _me

其他的还用演示么?这里主要介绍sed怎么用来插入字符吧,因为它的标签功能很有趣 说明:() 将匹配到的字符串存放为一个标签,按匹配顺序为\1,\2

$ echo $var | sed -e 's/\( \)/_\1/'
get_ the length of me
$ echo $var | sed -e 's/\( \)/_\1/g'
get_ the_ length_ of_ me
$ echo $var | sed -e 's/\( \)/\1_/'
get _the length of me
$ echo $var | sed -e 's/\( \)/\1_/g'
get _the _length _of _me

看看 sed 的标签的顺序是不是 \1,\2…,看到没?\2\1 调换位置后,theget 的位置掉换了:

$ echo $var | sed -e 's/\([a-z]*\) \([a-z]*\) /\2 \1 /g'
the get of length me

sed 还有专门的插入指令,ai,分别表示在匹配的行后和行前插入指定字符

$ echo $var | sed '/get/a test'
get the length of me
test
$ echo $var | sed '/get/i test'
test
get the length of me

删除子串

删除子串:应该很简单了吧,把子串替换成“空”(什么都没有)不就变成了删除么。还是来简单复习一下替换吧。

范例:把 var 字符串中所有的空格给删除掉

鼓励:这样一替换不知道变成什么单词啦,谁认得呢?但是中文却是连在一起的,所以中文有多难,你想到了么?原来你也是个语言天才,而英语并不可怕,你有学会它的天赋,只要有这个打算。

再用 {}

$ echo ${var// /}
getthelengthofme

再用 awk

$ echo $var | awk '{gsub(" ","",$0); printf("%s\n", $0);}'

再用 sed

$ echo $var | sed 's/ //g'
getthelengthofme

还有更简单的 tr 命令,tr 也可以把空格给删除掉,看

$ echo $var | tr -d " "
getthelengthofme

如果要删除第一个空格后面所有的字符串该怎么办呢?还记得 {}#% 用法么?如果不记得,回到这节的开头开始复习吧。(实际上删除子串和取子串未尝不是两种互补的运算呢,删除掉某些不想要的子串,也就同时取得另外那些想要的子串——这个世界就是一个“二元”的世界,非常有趣)

子串比较

这个很简单:还记得 test 命令的用法么? help test 。它可以用来判断两个字符串是否相等。另外,有发现“字符串是否相等”和“字符串能否跟另外一个字符串匹配 " 两个问题之间的关系吗?如果两个字符串完全匹配,那么这两个字符串就相等了。所以呢,上面用到的字符串匹配方法,也同样可以用到这里。

子串排序

差点忘记这个重要内容了,子串排序可是经常用到,常见的有按字母序、数字序等正序或反序排列。 sort 命令可以用来做这个工作,它和其他行处理命令一样,是按行操作的,另外,它类似 cutawk,可以指定分割符,并指定需要排序的列。

$ var="get the length of me"
$ echo $var | tr ' ' '\n' | sort   #正序排
get
length
me
of
the
$ echo $var | tr ' ' '\n' | sort -r #反序排
the
of
me
length
get
$ cat > data.txt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
41 45 44 44 26 44 42 20 20 38 37 25 45 45 45
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
44 20 30 39 35 38 38 28 25 30 36 20 24 32 33
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
41 33 51 39 20 20 44 37 38 39 42 40 37 50 50
46 47 48 49 50 51 52 53 54 55 56
42 43 41 42 45 42 19 39 75 17 17
$ cat data.txt | sort -k 2 -n
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
44 20 30 39 35 38 38 28 25 30 36 20 24 32 33
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
41 33 51 39 20 20 44 37 38 39 42 40 37 50 50
42 43 41 42 45 42 19 39 75 17 17
41 45 44 44 26 44 42 20 20 38 37 25 45 45 45
46 47 48 49 50 51 52 53 54 55 56

子串进制转换

如果字母和数字字符用来计数,那么就存在进制转换的问题。在数值计算一节,已经介绍了 bc 命令,这里再复习一下。

$ echo "ibase=10;obase=16;10" | bc
A

说明: ibase 指定输入进制,obase 指出输出进制,这样通过调整 ibaseobase,你想怎么转就怎么转啦!

子串编码转换

什么是字符编码?这个就不用介绍了吧,看过那些乱七八糟显示的网页么?大多是因为浏览器显示时的”编码“和网页实际采用的”编码“不一致导致的。字符编码通常是指:把一序列”可打印“字符转换成二进制表示,而字符解码呢则是执行相反的过程,如果这两个过程不匹配,则出现了所谓的”乱码“。

为了解决”乱码“问题呢?就需要进行编码转换。在 Linux 下,我们可以使用 iconv 这个工具来进行相关操作。这样的情况经常在不同的操作系统之间移动文件,不同的编辑器之间交换文件的时候遇到,目前在 Windows 下常用的汉字编码是 gb2312,而在 Linux 下则大多采用 utf8

$ nihao_utf8=$(echo "你好")
$ nihao_gb2312=$(echo $nihao_utf8 | iconv -f utf8 -t gb2312)

字符串操作进阶

实际上,在用 Bash 编程时,大部分时间都是在处理字符串,因此把这一节熟练掌握非常重要。

正则表达式

范例:处理 URL 地址

URL 地址(URL(Uniform Resoure Locator:统一资源定位器)是WWW页的地址)几乎是我们日常生活的玩伴,我们已经到了无法离开它的地步啦,对它的操作很多,包括判断 URL 地址的有效性,截取地址的各个部分(服务器类型、服务器地址、端口、路径等)并对各个部分进行进一步的操作。

下面我们来具体处理这个URL地址:ftp://anonymous:ftp@mirror.lzu.edu.cn/software/scim-1.4.7.tar.gz

$ url="ftp://anonymous:ftp@mirror.lzu.edu.cn/software/scim-1.4.7.tar.gz"

匹配URL地址,判断URL地址的有效性

$ echo $url | grep "ftp://[a-z]*:[a-z]*@[a-z\./-]*"

截取服务器类型

$ echo ${url%%:*}
ftp
$ echo $url | cut -d":" -f 1
ftp

截取域名

$ tmp=${url##*@} ; echo ${tmp%%/*}
mirror.lzu.edu.cn

截取路径

$ tmp=${url##*@} ; echo ${tmp%/*}
mirror.lzu.edu.cn/software

截取文件名

$ basename $url
scim-1.4.7.tar.gz
$ echo ${url##*/}
scim-1.4.7.tar.gz

截取文件类型(扩展名)

$ echo $url | sed -e 's/.*[0-9].\(.*\)/\1/g'
tar.gz
范例:匹配某个文件中的特定范围的行

先准备一个测试文件README

Chapter 7 -- Exercises

7.1 please execute the program: mainwithoutreturn, and print the return value
of it with the command "echo $?", and then compare the return of the printf
function, they are the same.

7.2 it will depend on the exection mode, interactive or redirection to a file,
if interactive, the "output" action will accur after the \n char with the line
buffer mode, else, it will be really "printed" after all of the strings have
been stayed in the buffer.

7.3 there is no another effective method in most OS. because argc and argv are
not global variables like environ.

然后开始实验,

打印出答案前指定行范围:第 7 行到第 9 行,刚好找出了第 2 题的答案

$ sed -n 7,9p README
7.2 it will depend on the exection mode, interactive or redirection to a file,
if interactive, the "output" action will accur after the \n char with the line
buffer mode, else, it will be really "printed" after all of the strings have

其实,因为这个文件内容格式很有特色,有更简单的办法

$ awk '/7.2/,/^$/ {printf("%s\n", $0);}' README
7.2 it will depend on the exection mode, interactive or redirection to a file,
if interactive, the "output" action will accur after the \n char with the line
buffer mode, else, it will be really "printed" after all of the strings have
been stayed in the buffer.

有了上面的知识,就可以非常容易地进行这些工作啦:修改某个文件的文件名,比如调整它的编码,下载某个网页里头的所有 pdf 文档等。这些就作为练习自己做吧。

处理格式化的文本

平时做工作,大多数时候处理的都是一些“格式化”的文本,比如类似 /etc/passwd 这样的有固定行和列的文本,也有类似 tree 命令输出的那种具有树形结构的文本,当然还有其他具有特定结构的文本。

实际上,只要把握好特性结构的一些特点,并根据具体的应用场合,处理起来就不会困难。

下面来介绍具体文本的操作,以 /etc/passwd 文件为例。关于这个文件的帮忙和用法,请通过 man 5 passwd 查看。下面对这个文件以及相关的文件进行一些有意义的操作。

范例:选取指定列

选取/etc/passwd文件中的用户名和组ID两列

$ cat /etc/passwd | cut -d":" -f1,4

选取/etc/group文件中的组名和组ID两列

$ cat /etc/group | cut -d":" -f1,3
范例:文件关联操作

如果想找出所有用户所在的组,怎么办?

$ join -o 1.1,2.1 -t":" -1 4 -2 3 /etc/passwd /etc/group
root:root
bin:bin
daemon:daemon
adm:adm
lp:lp
pop:pop
nobody:nogroup
falcon:users

说明: join 命令用来连接两个文件,有点类似于数据库的两个表的连接。 -t 指定分割符,-1 4 -2 3 指定按照第一个文件的第 4 列和第二个文件的第 3 列,即组 ID 进行连接,-o 1.1,2.1 表示仅仅输出第一个文件的第一列和第二个文件的第一列,这样就得到了我们要的结果,不过,可惜的是,这个结果并不准确,再进行下面的操作,你就会发现:

$ cat /etc/passwd | sort -t":" -n -k 4 > /tmp/passwd
$ cat /etc/group | sort -t":" -n -k 3 > /tmp/group
$ join -o 1.1,2.1 -t":" -1 4 -2 3 /tmp/passwd /tmp/group
halt:root
operator:root
root:root
shutdown:root
sync:root
bin:bin
daemon:daemon
adm:adm
lp:lp
pop:pop
nobody:nogroup
falcon:users
games:users

可以看到这个结果才是正确的,所以以后使用 join 千万要注意这个问题,否则采取更保守的做法似乎更能保证正确性,更多关于文件连接的讨论见参考后续资料。

上面涉及到了处理某格式化行中的指定列,包括截取(如 SQLselect 用法),连接(如 SQLjoin 用法),排序(如 SQLorder by 用法),都可以通过指定分割符来拆分某个格式化的行,另外,“截取”的做法还有很多,不光是 cutawk,甚至通过 IFS 指定分割符的 read 命令也可以做到,例如:

$ IFS=":"; cat /etc/group | while read C1 C2 C3 C4; do echo $C1 $C3; done

因此,熟悉这些用法,我们的工作将变得非常灵活有趣。

到这里,需要做一个简单的练习,如何把按照列对应的用户名和用户 ID 转换成按照行对应的,即把类似下面的数据:

$ cat /etc/passwd | cut -d":" -f1,3 --output-delimiter=" "
root 0
bin 1
daemon 2

转换成:

$ cat a
root    bin     daemon
0       1       2

并转换回去,有什么办法呢?记得诸如 trpastesplit 等命令都可以使用。

参考方法:

  • 正转换:先截取用户名一列存入文件 user,再截取用户 ID 存入 id,再把两个文件用 paste -s 命令连在一起,这样就完成了正转换
  • 逆转换:先把正转换得到的结果用 split -1 拆分成两个文件,再把两个拆分后的文件用 tr 把分割符 \t 替换成 \n,只有用 paste 命令把两个文件连在一起,这样就完成了逆转换。

文件操作

前言

在日常学习和工作中,总是在不断地和各种文件打交道,这些文件包括普通文本文件,可以执行的程序,带有控制字符的文档、存放各种文件的目录、网络套接字文件、设备文件等。这些文件又具有诸如属主、大小、创建和修改日期等各种属性。文件对应文件系统的一些数据块,对应磁盘等存储设备的一片连续空间,对应于显示设备却是一些具有不同形状的字符集。

在这一节,为了把关注点定位在文件本身,不会深入探讨文件系统以及存储设备是如何组织文件的(在后续章节再深入探讨),而是探讨对它最熟悉的一面,即把文件当成是一系列的字符(一个 byte)集合看待。因此之前介绍的字符串操作在这里将会得到广泛的应用,关于普通文件的读写操作已经非常熟练,那就是“重定向”,这里会把这部分独立出来介绍。关于文件在 Linux 下的“数字化”(文件描述符)高度抽象,“一切皆为文件”的哲学在 Shell 编程里也得到了深刻的体现。

下面先来介绍文件的各种属性,然后介绍普通文件的一般操作。

文件的各种属性

首先通过文件的结构体来看看文件到底有哪些属性:

struct stat {
    dev_t st_dev; /* 设备   */
    ino_t st_ino; /* 节点   */
    mode_t st_mode; /* 模式   */
    nlink_t st_nlink; /* 硬连接 */
    uid_t st_uid; /* 用户ID */
    gid_t st_gid; /* 组ID   */
    dev_t st_rdev; /* 设备类型 */
    off_t st_off; /* 文件字节数 */
    unsigned long  st_blksize; /* 块大小 */
    unsigned long st_blocks; /* 块数   */
    time_t st_atime; /* 最后一次访问时间 */
    time_t st_mtime; /* 最后一次修改时间 */
    time_t st_ctime; /* 最后一次改变时间(指属性) */
};

下面逐次来了解这些属性,如果需要查看某个文件属性,用 stat 命令就可,它会按照上面的结构体把信息列出来。另外,ls 命令在跟上一定参数后也可以显示文件的相关属性,比如 -l 参数。

文件类型

文件类型对应于上面的 st_mode, 文件类型有很多,比如常规文件、符号链接(硬链接、软链接)、管道文件、设备文件(符号设备、块设备)、socket文件等,不同的文件类型对应不同的功能和作用。

范例:在命令行简单地区分各类文件
$ ls -l
total 12
drwxr-xr-x 2 root root 4096 2007-12-07 20:08 directory_file
prw-r--r-- 1 root root    0 2007-12-07 20:18 fifo_pipe
brw-r--r-- 1 root root 3, 1 2007-12-07 21:44 hda1_block_dev_file
crw-r--r-- 1 root root 1, 3 2007-12-07 21:43 null_char_dev_file
-rw-r--r-- 2 root root  506 2007-12-07 21:55 regular_file
-rw-r--r-- 2 root root  506 2007-12-07 21:55 regular_file_hard_link
lrwxrwxrwx 1 root root   12 2007-12-07 20:15 regular_file_soft_link -> regular_file
$ stat directory_file/
  File: `directory_file/'
  Size: 4096            Blocks: 8          IO Block: 4096   directory
Device: 301h/769d       Inode: 521521      Links: 2
Access: (0755/drwxr-xr-x)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2007-12-07 20:08:18.000000000 +0800
Modify: 2007-12-07 20:08:18.000000000 +0800
Change: 2007-12-07 20:08:18.000000000 +0800
$ stat null_char_dev_file
  File: `null_char_dev_file'
  Size: 0               Blocks: 0          IO Block: 4096   character special file
Device: 301h/769d       Inode: 521240      Links: 1     Device type: 1,3
Access: (0644/crw-r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2007-12-07 21:43:38.000000000 +0800
Modify: 2007-12-07 21:43:38.000000000 +0800
Change: 2007-12-07 21:43:38.000000000 +0800

说明:通过 ls 命令结果每行的第一个字符可以看到,它们之间都不相同,这正好反应了不同文件的类型。 d 表示目录,- 表示普通文件(或者硬链接),l 表示符号链接,p 表示管道文件,bc 分别表示块设备和字符设备(另外 s 表示 socket 文件)。在 stat 命令的结果中,可以在第二行的最后找到说明,从上面的操作可以看出,directory_file 是目录,stat 命令的结果中用 directory 表示,而 null_char_dev_file 它则用 character special file 说明。

范例:简单比较它们的异同

通常只会用到目录、普通文件、以及符号链接,很少碰到其他类型的文件,不过这些文件还是各有用处的,如果要做嵌入式开发或者进程通信等,可能会涉及到设备文件、有名管道(FIFO)。下面通过简单的操作来反应它们之间的区别(具体原理会在下一节介绍,如果感兴趣,也可以提前到网上找找设备文件的作用、块设备和字符设备的区别、以及驱动程序中如何编写相关设备驱动等)。

对于普通文件:就是一系列字符的集合,所以可以读、写等

$ echo "hello, world" > regular_file
$ cat regular_file
hello, world

在目录中可以创建新文件,所以目录还有叫法:文件夹,到后面会分析目录文件的结构体,它实际上存放了它下面的各个文件的文件名。

$ cd directory_file
$ touch file1 file2 file3

对于有名管道,操作起来比较有意思:如果要读它,除非有内容,否则阻塞;如果要写它,除非有人来读,否则阻塞。它常用于进程通信中。可以打开两个终端 terminal1terminal2,试试看:

terminal1$ cat fifo_pipe #刚开始阻塞在这里,直到下面的写动作发生,才打印test字符串
terminal2$ echo "test" > fifo_pipe

关于块设备,字符设备,设备文件对应于 /dev/hda1/dev/null,如果用过 U 盘,或者是写过简单的脚本的话,这样的用法应该用过: :-)

$ mount hda1_block_dev_file /mnt #挂载硬盘的第一个分区到/mnt下(关于挂载的原理,在下一节讨论)
$ echo "fewfewfef" > /dev/null   #/dev/null像个黑洞,什么东西丢进去都消失殆尽

最后两个文件分别是 regular_file 文件的硬链接和软链接,去读写它们,他们的内容是相同的,不过去删除它们,他们却互不相干,硬链接和软链接又有何不同呢?前者可以说就是原文件,后者呢只是有那么一个 inode,但没有实际的存储空间,建议用 stat 命令查看它们之间的区别,包括它们的 Blocksinode 等值,也可以考虑用 diff 比较它们的大小。

$ ls regular_file*
ls regular_file* -l
-rw-r--r-- 2 root root 204800 2007-12-07 22:30 regular_file
-rw-r--r-- 2 root root 204800 2007-12-07 22:30 regular_file_hard_link
lrwxrwxrwx 1 root root     12 2007-12-07 20:15 regular_file_soft_link -> regular_file
$ rm regular_file      # 删除原文件
$ cat regular_file_hard_link   # 硬链接还在,而且里头的内容还有呢
fefe
$ cat regular_file_soft_link
cat: regular_file_soft_link: No such file or directory

虽然软链接文件本身还在,不过因为它本身不存储内容,所以读不到东西,这就是软链接和硬链接的区别。

需要注意的是,硬链接不可以跨文件系统,而软链接则可以。另外,也不允许给目录创建硬链接。

范例:普通文件再分类

文件类型从 Linux 文件系统那么一个级别分了以上那么多类型,不过普通文件还是可以再分的(根据文件内容的”数据结构“分),比如常见的文本文件,可执行的 ELF 文件,odt 文档,jpg 图片格式,swap 分区文件,pdf 文件。除了文本文件外,它们大多是二进制文件,有特定的结构,因此需要有专门的工具来创建和编辑它们。关于各类文件的格式,可以参考相关文档标准。不过非常值得深入了解 Linux 下可执行的 ELF 文件的工作原理,如果有兴趣,建议阅读一下参考资料中和 ELF 文件相关部分,这一部分对于嵌入式 Linux 工程师至关重要。

虽然各类普通文件都有专属的操作工具,但是还是可以直接读、写它们,这里先提到这么几个工具,回头讨论细节。

  • od :以八进制或者其他格式“导出”文件内容。
  • strings :读出文件中的字符(可打印的字符)
  • gccgdbreadelf,objdump等:ELF文件分析、处理工具(gcc编译器、gdb调试器、readelf分析 ELF 文件,objdump` 反编译工具)

再补充一个非常重要的命令,file,这个命令用来查看各类文件的属性。和 stat 命令相比,它可以进一步识别普通文件,即 stat 命令显示的 regular file 。因为 regular file 可以有各种不同的结构,因此在操作系统的支持下得到不同的解释,执行不同的动作。虽然,Linux 下,文件也会加上特定的后缀以便用户能够方便地识别文件的类型,但是 Linux 操作系统根据文件头识别各类文件,而不是文件后缀,这样在解释相应的文件时就更不容易出错。下面简单介绍 file 命令的用法。

$ file ./
./: directory
$ file /etc/profile
/etc/profile: ASCII English text
$ file /lib/libc-2.5.so
/lib/libc-2.5.so: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), not stripped
$ file /bin/test
/bin/test: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked (uses shared libs), stripped
$ file /dev/hda
/dev/hda: block special (3/0)
$ file /dev/console
/dev/console: character special (5/1)
$ cp /etc/profile .
$ tar zcf profile.tar.gz profile
$ file profile.tar.gz
profile.tar.gz: gzip compressed data, from Unix, last modified: Tue Jan  4 18:53:53 2000
$ mkfifo fifo_test
$ file fifo_test
fifo_test: fifo (named pipe)

更多用法见 file 命令的手册,关于 file 命令的实现原理,请参考 magic 的手册(看看 /etc/file/magic 文件,了解什么是文件的 magic number 等)。

文件属主

Linux 作为一个多用户系统,为多用户使用同一个系统提供了极大的方便,比如对于系统上的文件,它通过属主来区分不同的用户,以便分配它们对不同文件的操作权限。为了更方便地管理,文件属主包括该文件所属用户,以及该文件所属的用户组,因为用户可以属于多个组。先来简单介绍 Linux 下用户和组的管理。

Linux 下提供了一组命令用于管理用户和组,比如用于创建用户的 useraddgroupadd,用于删除用户的 userdelgroupdel,另外,passwd 命令用于修改用户密码。当然,Linux 还提供了两个相应的配置,即 /etc/passwd/etc/group,另外,有些系统还把密码单独放到了配置文件 /etc/shadow 中。关于它们的详细用法请参考后面的资料,这里不再介绍,仅介绍文件和用户之间的一些关系。

范例:修改文件的属主
$ chown 用户名:组名 文件名

如果要递归地修改某个目录下所有文件的属主,可以添加 -R 选项。

从本节开头列出的文件结构体中,可以看到仅仅有用户 ID 和组 ID 的信息,但 ls -l 的结果却显示了用户名和组名信息,这个是怎么实现的呢?下面先看看 -n 的结果:

范例:查看文件的属主
$ ls -n regular_file
-rw-r--r-- 1 0 0 115 2007-12-07 23:45 regular_file
$ ls -l regular_file
-rw-r--r-- 1 root root 115 2007-12-07 23:45 regular_file
范例:分析文件属主实现的背后原理

可以看到,ls -n 显示了用户 ID 和组 ID,而 ls -l 显示了它们的名字。还记得上面提到的两个配置文件 /etc/passwd/etc/group 文件么?它们分别存放了用户 ID 和用户名,组 ID 和组名的对应关系,因此很容易想到 ls -l 命令在实现时是如何通过文件结构体的 ID 信息找到它们对应的名字信息的。如果想对 ls -l 命令的实现有更进一步的了解,可以用 strace 跟踪看看它是否读取了这两个配置文件。

$ strace -f -o strace.log ls -l regular_file
$ cat strace.log | egrep "passwd|group|shadow"
2989  open("/etc/passwd", O_RDONLY)     = 3
2989  open("/etc/group", O_RDONLY)      = 3

说明: strace 可以用来跟踪系统调用和信号。如同 gdb 等其他强大的工具一样,它基于系统的 ptrace 系统调用实现。

实际上,把属主和权限分开介绍不太好,因为只有它们两者结合才使得多用户系统成为可能,否则无法隔离不同用户对某个文件的操作,所以下面来介绍文件操作权限。

文件权限

ls -l 命令的结果的第一列的后 9 个字符中,可以看到类似这样的信息 rwxr-xr-x,它们对应于文件结构体的 st_mode 部分(st_mode 包含文件类型信息和文件权限信息两部分)。这类信息可以分成三部分,即 rwxr-xr-x,分别对应该文件所属用户、所属组、其他组对该文件的操作权限,如果有 rwx 中任何一个表示可读、可写、可执行,如果为 - 表示没有这个权限。对应地,可以用八进制来表示它,比如 rwxr-xr-x 就可表示成二进制 111101101,对应的八进制则为 755 。正因为如此,要修改文件的操作权限,也可以有多种方式来实现,它们都可通过 chmod 命令来修改。

范例:给文件添加读、写、可执行权限

比如,把 regular_file 的文件权限修改为所有用户都可读、可写、可执行,即 rwxrwxrwx,也可表示为 111111111,翻译成八进制,则为 777 。这样就可以通过两种方式修改这个权限。

$ chmod a+rwx regular_file

$ chmod 777 regular_file

说明: a 指所有用户,如果只想给用户本身可读可写可执行权限,那么可以把 a 换成 u ;而 + 就是添加权限,相反的,如果想去掉某个权限,用 -,而 rwx 则对应可读、可写、可执行。更多用法见 chmod 命令的帮助。

实际上除了这些权限外,还有两个涉及到安全方面的权限,即 setuid/setgid 和只读控制等。

如果设置了文件(程序或者命令)的 setuid/setgid 权限,那么用户将可用 root 身份去执行该文件,因此,这将可能带来安全隐患;如果设置了文件的只读权限,那么用户将仅仅对该文件将有可读权限,这为避免诸如 rm -rf 的“可恶”操作带来一定的庇佑。

范例:授权普通用户执行root所属命令

默认情况下,系统是不允许普通用户执行 passwd 命令的,通过 setuid/setgid,可以授权普通用户执行它。

$ ls -l /usr/bin/passwd
-rwx--x--x 1 root root 36092 2007-06-19 14:59 /usr/bin/passwd
$ su      #切换到root用户,给程序或者命令添加“粘着位”
$ chmod +s /usr/bin/passwd
$ ls -l /usr/bin/passwd
-rws--s--x 1 root root 36092 2007-06-19 14:59 /usr/bin/passwd
$ exit
$ passwd #普通用户通过执行该命令,修改自己的密码

说明:

setuidsetgid 位是让普通用户可以以 root 用户的角色运行只有 root 帐号才能运行的程序或命令。

虽然这在一定程度上为管理提供了方便,比如上面的操作让普通用户可以修改自己的帐号,而不是要 root 帐号去为每个用户做这些工作。关于 setuid/setgid 的更多详细解释,请参考最后推荐的资料。

范例:给重要文件加锁

只读权限示例:给重要文件加锁(添加不可修改位 [immutable])),以避免各种误操作带来的灾难性后果(例如 : rm -rf

$ chattr +i regular_file
$ lsattr regular_file
----i-------- regular_file
$ rm regular_file    #加immutable位后就无法对文件进行任何“破坏性”的活动啦
rm: remove write-protected regular file `regular_file'? y
rm: cannot remove `regular_file': Operation not permitted
$ chattr -i regular_file #如果想对它进行常规操作,那么可以把这个位去掉
$ rm regular_file

说明: chattr 可以用于设置文件的特殊权限,更多用法请参考 chattr 的帮助。

文件大小

文件大小对于普通文件而言就是文件内容的大小,而目录作为一个特殊的文件,它存放的内容是以目录结构体组织的各类文件信息,所以目录的大小一般都是固定的,它存放的文件个数自然也就有上限,即它的大小除以文件名的长度。设备文件的“文件大小”则对应设备的主、次设备号,而有名管道文件因为特殊的读写性质,所以大小常是 0 。硬链接(目录文件不能创建硬链接)实质上是原文件的一个完整的拷贝,因此,它的大小就是原文件的大小。而软链接只是一个 inode,存放了一个指向原文件的指针,因此它的大小仅仅是原文件名的字节数。下面我们通过演示增加记忆。

范例:查看普通文件和链接文件

原文件,链接文件文件大小的示例:

$ echo -n "abcde" > regular_file   #往regular_file写入5字节
$ ls -l regular_file*
-rw-r--r-- 2 root root  5 2007-12-08 15:28 regular_file
-rw-r--r-- 2 root root  5 2007-12-08 15:28 regular_file_hard_file
lrwxrwxrwx 1 root root 12 2007-12-07 20:15 regular_file_soft_link -> regular_file
lrwxrwxrwx 1 root root 22 2007-12-08 15:21 regular_file_soft_link_link -> regular_file_soft_link
$ i="regular_file"
$ j="regular_file_soft_link"
$ echo ${#i} ${#j}   #软链接存放的刚好是它们指向的原文件的文件名的字节数
12 22
范例:查看设备文件

设备号对应的文件大小:主、次设备号

$ ls -l hda1_block_dev_file
brw-r--r-- 1 root root 3, 1 2007-12-07 21:44 hda1_block_dev_file
$ ls -l null_char_dev_file
crw-r--r-- 1 root root 1, 3 2007-12-07 21:43 null_char_dev_file

补充:主 (major)、次(minor)设备号的作用有不同。当一个设备文件被打开时,内核会根据主设备号(major number)去查找在内核中已经以主设备号注册的驱动(可以 cat /proc/devices 查看已经注册的驱动号和主设备号的对应情况),而次设备号(minor number)则是通过内核传递给了驱动本身(参考《The Linux Primer》第十章)。因此,对于内核而言,通过主设备号就可以找到对应的驱动去识别某个设备,而对于驱动而言,为了能够更复杂地访问设备,比如访问设备的不同部分(如硬件通过分区分成不同部分,而出现 hda1hda2hda3 等),比如产生不同要求的随机数(如 /dev/random/dev/urandom 等)。

范例:查看目录

目录文件的大小,为什么是这样呢?看看下面的目录结构体的大小,目录文件的 Block 中存放了该目录下所有文件名的入口。

$ ls -ld directory_file/
drwxr-xr-x 2 root root 4096 2007-12-07 23:14 directory_file/

目录的结构体如下:

struct dirent {
    long d_ino;
    off_t d_off;
    unsigned short d_reclen;
    char d_name[NAME_MAX+1]; /* 文件名称 */
}

文件访问、更新、修改时间

文件的时间属性可以记录用户对文件的操作信息,在系统管理、判断文件版本信息等情况下将为管理员提供参考。因此,在阅读文件时,建议用 cat 等阅读工具,不要用编辑工具 vim 去阅读,因为即使没有做任何修改操作,一旦执行了保存命令,将修改文件的时间戳信息。

文件名

文件名并没有存放在文件结构体内,而是存放在它所在的目录结构体中。所以,在目录的同一级别中,文件名必须是唯一的。

文件的基本操作

对于文件,常见的操作包括创建、删除、修改、读、写等。关于各种操作对应的“背后动作”将在下一节详细分析。

范例:创建文件

socket 文件是一类特殊的文件,可以通过 C 语言创建,这里不做介绍(暂时不知道是否可以用命令直接创建),其他文件将通过命令创建。

$ touch regular_file      #创建普通文件
$ mkdir directory_file     #创建目录文件,目录文件里头可以包含更多文件
$ ln regular_file regular_file_hard_link  #硬链接,是原文件的一个完整拷比
$ ln -s regular_file regular_file_soft_link  #类似一个文件指针,指向原文件
$ mkfifo fifo_pipe   #或者通过 "mknod fifo_pipe p" 来创建,FIFO满足先进先出的特点
$ mknod hda1_block_dev_file b 3 1  #块设备
$ mknod null_char_dev_file c 1 3   #字符设备

创建一个文件实际上是在文件系统中添加了一个节点(inode),该节点信息将保存到文件系统的节点表中。更形象地说,就是在一颗树上长了一颗新的叶子(文件)或者枝条(目录文件,上面还可以长叶子的那种),这些可以通过tree命令或者ls` 命令形象地呈现出来。文件系统从日常使用的角度,完全可以当成一颗倒立的树来看,因为它们太像了,太容易记忆啦。

$ tree 当前目录

或者

$ ls 当前目录

范例:删除文件

删除文件最直接的印象是这个文件再也不存在了,这同样可以通过 ls 或者 tree 命令呈现出来,就像树木被砍掉一个分支或者摘掉一片叶子一样。实际上,这些文件删除之后,并不是立即消失了,而是仅仅做了删除标记,因此,如果删除之后,没有相关的磁盘写操作把相应的磁盘空间“覆盖”,那么原理上是可以恢复的(虽然如此,但是这样的工作往往很麻烦,所以在删除一些重要数据时,请务必三思而后行,比如做好备份工作),相应的做法可以参考后续资料。

具体删除文件的命令有 rm,如果要删除空目录,可以用 rmdir 命令。例如:

$ rm regular_file
$ rmdir directory_file
$ rm -r directory_file_not_empty

rm 有两个非常重要的参数,一个是 -f,这个命令是非常“野蛮的”,它估计给很多 Linux user 带来了痛苦,另外一个是 -i,这个命令是非常“温柔的”,它估计让很多用户感觉烦躁不已。用哪个还是根据您的“心情”吧,如果做好了充分的备份工作,或者采取了一些有效避免灾难性后果的动作的话,您在做这些工作的时候就可以放心一些啦。

范例:复制文件

文件的复制通常是指文件内容的“临时”复制。通过这一节开头的介绍,我们应该了解到,文件的硬链接和软链接在某种意义上说也是“文件的复制”,前者同步复制文件内容,后者在读写的情况下同步“复制”文件内容。例如:

cp 命令常规地复制文件(复制目录需要 -r 选项)

$ cp regular_file regular_file_copy
$ cp -r diretory_file directory_file_copy

创建硬链接(linkcopy 不同之处是:link 为同步更新,copy 则不然,复制之后两者不再相关)

$ ln regular_file regular_file_hard_link

创建软链接

$ ln -s regular_file regluar_file_soft_link

范例:修改文件名

修改文件名实际上仅仅修改了文件名标识符。可以通过 mv 命令来实现修改文件名操作(即重命名)。

$ mv regular_file regular_file_new_name

范例:编辑文件

编辑文件实际上是操作文件的内容,对应普通文本文件的编辑,这里主要涉及到文件内容的读、写、追加、删除等。这些工作通常会通过专门的编辑器来做,这类编辑器有命令行下的 vimemacs 和图形界面下的 gedit,kedit 等。如果是一些特定的文件,会有专门的编辑和处理工具,比如图像处理软件 gimp,文档编辑软件 OpenOffice 等。这些工具一般都会有专门的教程。

下面主要简单介绍 Linux 下通过重定向来实现文件的这些常规的编辑操作。

创建一个文件并写入 abcde

$ echo "abcde" > new_regular_file

再往上面的文件中追加一行 abcde

$ echo "abcde" >> new_regular_file

按行读一个文件

$ while read LINE; do echo $LINE; done < test.sh

提示:如果要把包含重定向的字符串变量当作命令来执行,请使用 eval 命令,否则无法解释重定向。例如,

$ redirect="echo \"abcde\" >test_redirect_file"
$ $redirect   #这里会把>当作字符 > 打印出来,而不会当作 重定向 解释
"abcde" >test_redirect_file
$ eval $redirect    #这样才会把 > 解释成 重定向
$ cat test_redirect_file
abcde

范例:压缩/解压缩文件

压缩和解压缩文件在一定意义上来说是为了方便文件内容的传输,不过也可能有一些特定的用途,比如内核和文件系统的映像文件等(更多相关的知识请参考后续资料)。

这里仅介绍几种常见的压缩和解压缩方法:

tar

$ tar -cf file.tar file   #压缩
$ tar -xf file.tar    #解压

gz

$ gzip  -9 file
$ gunzip file

tar.gz

$ tar -zcf file.tar.gz file
$ tar -zxf file.tar.gz

bz2

$ bzip2 file
$ bunzip2 file

tar.bz2

$ tar -jcf file.tar.bz2 file
$ tar -jxf file.tar.bz2

通过上面的演示,应该已经非常清楚 tarbzip2,bunzip2,gzip,gunzip命令的角色了吧?如果还不清楚,多操作和比较一些上面的命令,并查看它们的手册:man tar`…

范例:文件搜索(文件定位)

文件搜索是指在某个目录层次中找出具有某些属性的文件在文件系统中的位置,这个位置如果扩展到整个网络,那么可以表示为一个 URL 地址,对于本地的地址,可以表示为 file://+ 本地路径。本地路径在 Linux 系统下是以 / 开头,例如,每个用户的家目录可以表示为: file:///home/ 。下面仅仅介绍本地文件搜索的一些办法。

find 命令提供了一种“及时的”搜索办法,它根据用户的请求,在指定的目录层次中遍历所有文件直到找到需要的文件为止。而 updatedb+locate 提供了一种“快速的”的搜索策略,updatedb 更新并产生一个本地文件数据库,而 locate 通过文件名检索这个数据库以便快速找到相应的文件。前者支持通过各种文件属性进行搜索,并且提供了一个接口(-exec 选项)用于处理搜索后的文件。因此为“单条命令”脚本的爱好者提供了极大的方便,不过对于根据文件名的搜索而言,updatedb+locate 的方式在搜索效率上会有明显提高。下面简单介绍这两种方法:

find 命令基本使用演示

$ find ./ -name "*.c" -o -name "*.h"  #找出所有的C语言文件,-o是或者
$ find ./ \( -name "*.c" -o -name "*.h" \) -exec mv '{}' ./c_files/ \;
# 把找到的文件移到c_files下,这种用法非常有趣

上面的用法可以用 xargs 命令替代

$ find ./ -name "*.c" -o -name "*.h" | xargs -i mv '{}' ./c_files/
# 如果要对文件做更复杂的操作,可以考虑把mv改写为你自己的处理命令,例如,我需要修

改所有的文件名后缀为大写。

$ find ./ -name "*.c" -o -name "*.h" | xargs -i ./toupper.sh '{}' ./c_files/

toupper.sh 就是我们需要实现的转换小写为大写的一个处理文件,具体实现如下:

$ cat toupper.sh
#!/bin/bash

# the {} will be expended to the current line and becomen the first argument of this script
FROM=$1
BASENAME=${FROM##*/}

BASE=${BASENAME%.*}
SUFFIX=${BASENAME##*.}

TOSUFFIX="$(echo $SUFFIX | tr '[a-z]' '[A-Z]')"
TO=$2/$BASE.$TOSUFFIX
COM="mv $FROM $TO"
echo $COM
eval $COM

updatedb+locate 基本使用演示

$ updatedb #更新库
$ locate find*.gz #查找包含find字符串的所有gz压缩包

实际上,除了上面两种命令外,Linux 下还有命令查找工具:whichwhereis,前者用于返回某个命令的全路径,而后者用于返回某个命令、源文件、man 文件的路径。例如,查找find` 命令的绝对路径:

$ which find
/usr/bin/find
$ whereis find
find: /usr/bin/find /usr/X11R6/bin/find /usr/bin/X11/find /usr/X11/bin/find /usr/man/man1/find.1.gz /usr/share/man/man1/find.1.gz /usr/X11/man/man1/find.1.gz

需要提到的是,如果想根据文件的内容搜索文件,那么 findupdatedb+locate 以及 whichwhereis 都无能为力啦,可选的方法是 grepsed 等命令,前者在加上 -r 参数以后可以在指定目录下文件中搜索指定的文件内容,后者再使用 -i 参数后,可以对文件内容进行替换。它们的基本用法在前面的章节中已经详细介绍了,这里就不再赘述。

值得强调的是,这些命令对文件的操作非常有意义。它们在某个程度上把文件系统结构给抽象了,使得对整个文件系统的操作简化为对单个文件的操作,而单个文件如果仅仅考虑文本部分,那么最终却转化成了之前的字符串操作,即上一节讨论过的内容。为了更清楚地了解文件的组织结构,文件之间的关系,在下一节将深入探讨文件系统。

参考资料

后记

  • 考虑到文件和文件系统的重要性,将把它分成三个小节来介绍:文件、文件系统、程序与进程。在“文件”这一部分,主要介绍文件的基本属性和常规操作,在“文件系统”那部分,将深入探讨 Linux 文件系统的各个部分(包括 Linux 文件系统的结构、具体某个文件系统的大体结构分析、底层驱动的工作原理),在“程序与进程”一节将专门讨论可执行文件的相关内容(包括不同的程序类型、加载执行过程、不同进程之间的交互[命令管道和无名管道、信号通信]、对进程的控制等)
  • 有必要讨论清楚 目录大小 的含义,另外,最好把一些常规的文件操作全部考虑到,包括文件的读、写、执行、删除、修改、复制、压缩/解压缩等

文章作者: JoyTsing
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 JoyTsing !
评论
 上一篇
Shell基础学习 Shell基础学习
之前也有一些关于这方面的笔记,但过于零碎,特地整理。
2020-12-18
下一篇 
进程的内存映像 进程的内存映像
前言APUE的第 14 章时,看到一个“打印不同类型的数据所存放的位置”的例子,它非常清晰地从程序内部反应了“进程的内存映像”,通过结合它与Gcc 编译的背后和缓冲区溢出与注入分析的相关内容,可以更好地辅助理解相关的内容。 进程内存映像表首
2020-12-17
  目录