Contents

Shell编程极简入门实践

Contents
By StevenSLXie (Last updated: 29, Dec, 2014)

0. 写在前面

程序员多多少少都会和命令行打交道,一些常用的命令,比如-l -n -a等等),即使从网上找到了可能符合自己要求的代码,也往往因为看不懂而无法修改化为己用。

这个极简教程,或者说笔记,针对的是正是这部分读者。具体地说,通过学习这篇文档,你将获得以下技能:

  • 熟练掌握Unix/Linux下的最常用命令及其最常见用法;
  • 能够编写脚本,对文件进行批处理,对一些网络任务进行自动化等等;
  • 避免写脚本过程中的最常见错误;
  • (Hopefully)可以借此消除对命令行的恐惧;

这个教程的特点是:

  • 不求全面,只求实用。只覆盖最常用的命令及其用法;
  • 以大量例子为导向;
  • 一边阅读一边动手写例程的话,大约只需要1.5-2.5小时的时间;

这篇文档假定你是在Linux/Unix环境下,比如Ubuntu, 比如Mac OS X。同时假定你至少了解一门其它的编程语言。这个教程的代码均在Mac OS下测试过,由于各种shell的标准差别很小,(有充足的理由相信)在别的平台应该也都能顺利运行。

1. Hello World

首先打开你用得最顺手的文本编辑器,在第一、二行分别打入

1
2
3
#!/bin/bash  
echo "Hello, World!"  

保存文件,文件可保存在你喜欢的文件夹,扩展名选择tutorial.sh。

接着,打开命令行工具Terminal,首先将工作目录改到你保存文件的文件夹,比如如果你将/Users/Steven/code,则在命令行里执行以下操作

1
2
cd /Users/Steven/code  

tutorial.sh这个脚本,所以我们要先将工作目录转到这个脚本对应的文件夹下面。接着,在命令行继续输入

1
2
chmod +x tutorial.sh  

tutorial.sh变为一个可执行的文件。

接下来,我们就可以运行tutorial.sh这个脚本了。在命令行里打入

1
2
./tutorial.sh  

如无意外,你将看到命令行里返回./,系统会只在系统目录里面查找(准确来说是PATH变量定义的路径)。

我们回头来看看tutorial.sh里面的程序,目前它只有两行:

1
2
3
#!/bin/bash  
echo "Hello, World!"  

print是类似的。

就这样,我们完成了一个最简单的bash scripting的程序编写。这里面有几点需要注意:

  • 执行脚本文件前,先要cd到文件所在的目录;
  • 执行脚本文件前,先要chmod +x tutorial.sh将其变为可执行程序;
  • 脚本文件的第一行,记得写上#!/bin/bash。

2. 整数和字符串

变量的定义很简单,按照以下格式就可以了:

1
2
NAME=var  

比如定义一个字符串:

1
2
NAME='Steven'  

比如定义一个整型变量:

1
2
NUM=3  

这里有几点要注意,一是变量的名字,虽然大小写不限,但按照惯例一般采用全大写的方式。第二点特别重要,让我们做一个小实验来说明一下。打开刚才的那个tutorial.sh文件,将之前的内容清空,并打入

1
2
3
4
#!/bin/bash  
NAME = 'Steven'  
echo $NAME  

如果你是直接复制以上的代码段,那么命令行应该会出现以下错误信息:

1
2
./tutorial.sh: line 2: NAME: command not found  

出现这个错误,是因为:定义变量时,=的前面和后面,都是不能有空格的!这一点可能和其它语言不一样,但请务必注意。因为出现这类错误时,报错信息定位的栏数(line 2),是指向你引用变量的那一段代码,而不是定义变量的那一行,因此debug起来可能不是那么直观。

于是,我们把代码改为:

1
2
3
4
#!/bin/bash  
NAME='Steven'  
echo $NAME  

命令行将显示Steven。

从上面的例子我们也可以看到,当你定义了一个变量,要引用它时,要在前面加上[1]:

1
2
3
4
#!/bin/bash  
NAME='Steven'  
echo "My name is ${NAME}SLXie."  

可想而知,如果没有花括号,NAME和后面的SLXie就无法区分了。

对于字符串变量,既可以用单括号,比如"My name is ${NAME}SLXie."。请试着将它的双括号改为单括号,并观察它的输出结果。

单括号会将被引用字符串中的几乎所有特殊字符当作普通字符处理,比如上面的$,单括号时只把它当作一个普通的美元符号输出。

再看一个例子,试着在脚本分别输入这两行,并观察它们的输出。

1
2
3
echo "Here is $50."  
echo 'Here is $50.'  

在这里插播一句,看这个教程的时候,最好是看到那里,就动手写到哪里。写的时候,不要直接复制粘贴,而是试着手打代码到编辑器里面。有时候代码里有一些微小而琐碎的东西,一定要自己打一遍才能记得牢。比如,一开始可能容易将#!bin/bash。

花括号{}也可以用来对字符串进行某些操作。比如下面这个例子:

1
2
3
4
#!/bin/bash  
USERNAME='StevenSLXie'  
echo "My name is ${USERNAME}. People usually call me ${USERNAME:0:6}."  

它会输出:

My name is StevenSLXie. People usually call me Steven.

这时候,${#USERNAME}则是获取字符串的长度。更多的字符串用法,我们将在后面的正则表达式哪一节看到。

3. 数组

数组可以这样简单粗暴地定义:

1
2
3
4
NAMES[0]='Steven'  
NAMES[1]='Peter'  
NAMES[2]='David'  

当数组体量太大时,这样定义未免麻烦,因此我们也可以用一行声明的方式来定义:

1
2
declare -a NAMES=('Steven' 'Peter' 'David')  

用declare -a来声明,后面一次性定义所有数组元素。请注意,在这里,整个数组用小括号括起来,而每个数组元素之间,是用空格来隔开的,而不是逗号或者其它。

访问数组的其中一个元素,和其它语言没什么不同。在你声明好数组之后,就可以访问数组元素了:

1
2
3
4
echo ${NAMES[0]}  
echo ${NAMES[2]}  
echo ${NAMES[*]}  

${#NAMES[0]}。

一个数组声明并定义后,我们仍可以二次定义它,比如下面的代码是在原来的数组基础上再添加一个人名。

1
2
3
4
5
declare -a NAMES=('Steven' 'Peter' 'David')  
echo ${#NAMES[*]}  
NAMES=("${NAMES[*]}" 'Nancy')  
echo ${NAMES[*]}  

命令行将返回:

1
2
3
3  
Steven Peter David Nancy  

4. 运算符

4.1 算术运算符

Shell编程里的算术运算符和大多数编程语言很类似,主要是这些+ - * / %等。如果你试着在命令行里执行运算的话,比如输入以下算式:

1
2
2 + 2  

会得到:

1
2
-bash: 2: command not found  

这条错误信息。这是因为命令行的逻辑是它会把一行命令的第一个词当作是命令,在系统中寻找与之匹配的执行语句,因为在这里它会认为2是一个命令,而显然它不可能找到这个命令。要想执行运算,我们在命令行里打入

1
2
expr 2 + 2  

输出结果是expr是一个常用命令,evaluate an expression的意思。注意,这里数字和运算符之间,必须有一个空格。不然的话,如果你输入,

1
2
expr 2+2  

则会输出

1
2
2+2  

这种情况下,2+2当成一个字符串,而evaluate一个字符串的结果,自然就是它本身了。算术运算当然也可以用变量,比如:

1
2
3
VAR=5  
expr 2 + ${VAR}  

其它的算术运算符大体类似,但有一个要特别注意,如果你进行乘法运算,比如:

1
2
expr 2 * 15  

会输出:

1
2
expr: syntax error  

这是因为。就像这样:

1
2
expr 2 * 15  

4.2 关系判断运算符

Shell提供了丰富的关系判断运算符,先来看一个例子,在tutorial.sh加入以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
A=10  
B=15  
  
if [ $A -eq $B ]  
then  
    echo 'True'  
else  
    echo 'False'  
fi  

这是一个[$A -eq $B]是会报错的。(不如自己写段代码试一试?)

完整的关系判断运算符文档可以看这里:Unix Basic Operator

4.3 逻辑运算符

与主流编程语言用-a来表示的。看看下面这个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
A=10  
B=15  
  
if [ $A -lt 8 -a $B -gt 8 ]  
then  
    echo 'True'  
else  
    echo 'False'  
fi  

这是判断变量A是否小于8且变量B是否大于B。

完整的关系判断运算符文档可以看这里:Unix Basic Operator

5. if条件判断、while循环、for循环

if | while | for语句和其它主流语言很相似,因此用起来应该不是大问题。值得注意的可能是以下几个小点:

  • if语句的格式是if…then…elif…fi。注意,这里用fi来标记一个条件判断的结束。嗯,感觉是一种很调皮的设定。
  • 所有的while和for语句,其执行的语句都都始于do,终于done。
  • for的格式是for VAR in ARRAY,是Python的样式,和经典的C for循环可能稍有不同。

下面我们用一个例子来感受一下这三种语句。

 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
echo 'Shall we begin the demo?(y/n)'  
read ANS  
  
A=0  
declare -a B=(0)  
  
if [ $ANS = 'y' ]  
then  
    echo 'Output the results of the while loop'  
    while [ $A -lt 10 ]  
    do  
        echo $A  
        A=`expr $A + 1`  
        B=(${B[*]} $A)  
    done  
else  
    echo 'Not ready for the demo yet.'  
fi  
  
echo 'Output the results of the for loop.'  
for I in ${B[*]}  
    do  
        echo $I  
    done  

第二行的ANS这个变量。

接着,我们用一个A大于等于10的时候,退出循环。

最后是一个B里面的元素依次打印出来。

这里要补充一点,在对SUM=expr 5 + 25

1的左边。

还有一点要注意,当我们对一个已经定义过的变量进行重新赋值的时候,是不需要加$。

6. 函数

Shell脚本里当然可以定义函数。比如这样的:

1
2
3
4
5
hello(){  
    echo "Hello World!"  
}  
  

函数可以直接调用,比如下面这个脚本:

1
2
3
4
5
6
7
#!/bin/bash  
hello(){  
    echo "Hello World!"  
}  
  
hello  

注意,但函数没有参数时,调用只需要写上函数名,而不是hello()之类的。

Shell函数的特殊之处,在于它参数传递的形式,具体地说,参数并不是像别的语言一样,写在括号里面,而是类似下面这个例子:

1
2
3
4
intro(){  
    echo "There are $1 people here. They are $2, $3."  
}  

Shell用intro函数,则是这个样子:

1
2
intro 2 'Steven' 'David'  

命令行的输出则是:

1
2
There are 2 people here. They are Steven, David.  

参数不是写在括号里,而是在函数名之后依次排列,并以空格隔开。

函数也可以有返回值,比如:

1
2
3
4
5
hello(){  
    A=`expr 5 * 10`  
    return $A  
}  

返回变量A=hello,而是:

1
2
3
hello  
RET=$?  

hello的返回值。

7. sed和正则表达式

正则表达式是一种特殊的字符串,用来描述一串具有某种共同特征的字符串。在进行批处理的时候,正则表达式有着异常强大的应用。

sed则是一个流编辑器(stream editor),它读入一个输出,并通过加工处理,输出经处理后的 文件/字符串 输出。下面我们通过一系列例子,来掌握sed的基本应用。

首先我们要来新建一些txt文件供sed处理。在命令行输入:

1
2
3
4
5
6
7
mkdir files  
cd files  
touch test-1.txt  
touch test-2.txt  
touch test-3.txt  
cd ../  

第一行tutorial.sh所在的目录里。

然后,分别打开那三个txt文件,将以下几行字符串拷贝到文件里。

1
2
3
4
5
6
7
8
This is a file with several lines  
some of which are blank lines  
for example, the line that follows is blank  
  
But this line has several characters.  
  
And this marks the end of the file.  

接着,打开我们的#!/usr/bash。在里面输入:

1
2
3
4
5
cd files  
FILE=test-1.txt  
  
sed -i.tmp "/^$/d" $FILE  

保存后运行test-1.txt,如无意外的话,你会看到文档变成这个样子:

1
2
3
4
5
6
This is a file with several lines  
some of which are blank lines  
for example, the line that follows is blank  
But this line has several characters.  
And this marks the end of the file.  

所有的空行被删除了。我们来看看test-1.txt,逐行扫描,找到空行,删除掉空行。

而txt的。但最好扩展名不要和文件夹的已有文件重复。

接着你可以试试将上面的sed语句改为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
  

  
这时你会发现改动后的结果,在命令行显示出来了,而原文件并不会改动。

  

  
再来看下面的语句:

  

这个语句的意思,就是删除-i '.tmp'。在Mac OS X的Bash Shell里面,似乎提供一个备份文件的扩展名是必须的,而在Linux平台则似乎是可选的。

更为常见的sed的应用,是用它来进行替换。看看下面的例子:

sed -e ’s/But/but/' test-2.txt

1
2
3

命令行的输出:

This is file with several lines
some of which are blank lines
for example, the line that follows is blank

but this line has several characters.

And this marks the end of the file.

1
2
3

可以观察到,首字母大写的test-2.txt文档的内容改为:

1.This is file with several lines
2.some of which are blank lines
for example, the line that follows is blank

7.But this line has several characters.

And this marks the end of the file.

1
2
3

第三行和最后一行前面有一个空格。接着我们在tutorial.sh里面加入以下命令:

sed -i ‘.tmp’ ’s/^[ 1-3]//' test-2.txt

1
2
3

你会看到以下输出:

.This is file with several lines
.some of which are blank lines
for example, the line that follows is blank

7.But this line has several characters.

And this marks the end of the file.

1
2
3
4
5
6
7

在这个例子里,正则表达式sed根据正则表达式的要求去逐行扫描,找出“以空格或者数字1-3的行”。

找到之后干什么呢?这就要看第二个/之间并没有内容。这是说,找到了符合要求的这个字符串,就将其替换为空字符。于是你可以看到以上的输出了。第一行第二行的数字被移除,第三行和最后一行的空格被移除,而第五行的数字7则不受影响。

保留新的test-2.txt,我们继续执行以下命令:

sed -i ‘.tmp’ ’s/[!.]$/;/' test-2.txt

1
2
3
4
5

这个命令则是找出以.结尾的行,并一律改为以分号结尾。

我们继续在原有的文档操作,输入以下命令:

sed -i ‘.tmp’ ’s/^[. 1-9]*//;s/[;.!]$/ ENDING/' test-2.txt

1
2
3
4
5

这是两个替换命令一起来。首先,我们找出以ENDING。

输出结果应该是:

This is file with several lines
some of which are blank lines
for example, the line that follows is blank

But this line has several characters ENDING

And this marks the end of the file ENDING

1
2
3
4
5

回头看这个命令,sed -i '.tmp' 's/^[. 1-9]*//;s/[;.!]$/ ENDING/' test-2.txt。两个小时之前,如果你看到这么复杂的命令行的时候,很有可能因为看起来过于复杂而崩溃掉吧。但现在,你也能读、写这样复杂的命令了。恭喜!

让我们继续来看看sed的一些其它应用。

sed -i ‘.tmp’ ’s/^some.*//' test-2.txt

1
2
3

删除以.可以指代任意字符。

sed -i ‘.tmp’ ’s/….$//' test-2.txt

1
2
3

删除每行末尾的四个字符。

sed -i ‘.tmp’ ’s/But/but/g' test-2.txt

1
2
3
4
5
6
7

这个看起来有点熟悉对不对?如果你比较之前的语句,会发现多了一个sed -i '.tmp' 's/But/but/3' test-2.txt。

除了删除和替换,sed还支持插入(insert/append)新的字符串。

比如下面的例子:

sed -i ‘.tmp’ ‘3 a
just some random text’ test-2.txt

1
2
3
4
5

它的功能是往insert的意思。

另外一个例子:

sed -i ‘.tmp’ ‘$ a
just some random text’ test-2.txt

1
2
3
4
5

在文件末尾处加上新行。

当然我们也可以用正则表达式要判别,比如:

sed -i ‘.tmp’ ‘/text/ i
INSERT THIS BEFORE EVERY LINE CONTAINING TEXT’ test-2.txt

1
2
3
4
5
6
7
8
9

在每一行包含INSERT THIS BEFORE EVERY LINE CONTAINING TEXT。

更多的http://www.grymoire.com/Unix/Sed.html#uh-1 。

#### 9. grep

grep相当于Unix/Linux命令行的Google,可以快速地找出包含某个字符串的文件。让我们先将当前目录移到files子文件夹,并显示该目录下的所有文件名。

cd files
ls

1
2
3

如果你按照这个教程一路走下来,现在这个文件夹里面应该有'file'的那些行。

正常的话会输出:

1
2
3
This is file with several lines  
And this marks the end of the file.  

这两行包含着file。

grep可以同时搜索多个文件,比如这样:

1
2
3
4
5
6
  

  
输出则是:

  

test-1.txt:This is file with several lines
test-1.txt:And this marks the end of the file.
test-2.txt:This is file with several li
test-2.txt:And this marks the end of the file END

1
2
3

格式是文件名:字符串。当然,罗列所有的文件名,有时候很不方便。这时候可以用上模糊搜索。比如这样:

有时候你只想知道哪些文件包含了某个字符串,而对那一行的具体内容是什么并不重要,那么可以这样:

系统会打印出包含file,那么可以:

1
2
3
4
5
6
7
8
9
  

  
而有时候你想知道的非常多,不仅是文件名,出现了几行,而且具体的行数也要知道,那么可以:

  

有的时候你想知道输出相反的结果:那些不包含file的行,那么可以:

global regular expression print。所以“你问我支持不支持,他的名字叫全局正则表达式打印器,怎么能不支持?”(请忽略一个蛤丝的老梗~)。

我们来看几个例子:

输出结尾为END的那些行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  

  
输出含有。

  

  
如果我们要搜索包含characters.的行,正确的命令是这样的:

  

grep ‘characters.’ test-*.txt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21

同时试一试grep 'characters.' test-*.txt,看看结果有什么不同。

#### 10. 写一个脚本吧

前面九节基本上覆盖了Shell编程最基本的内容,这一节我们来动手写一个脚本,一方面是把我们之前学到的东西复习一下,串联起来。另一方面,是将之前没有覆盖到的几个常用命令介绍一下。

我们的任务是在当前目录下,新建一个脚本"This is a test file...!!"。

接着,将十个文件的文件名从Final-test-n.txt。

然后,删除文件内容里面的标点符号。

接着,将文件内容全部变成大写。

将改动后的文件,拷贝一份到任意一个新的文件夹。

请注意,这当中会出现一些我们没学到的知识,如果你发现现有的知识不足以解决问题的话,请Google之。

下面是我写的脚本,为了便于理解,我将各个命令都分开写了。

#!/bin/bash
mkdir final
cd final

declare -a NAME
NAME=(1 2 3 4 5 6 7 8 9 10)

创建新文件。

for I in ${NAME[*]}
do
touch final-test-${I}.txt
done

往文档写入。这里使用的是echo,通过>改变其默认输出。不妨思考一下如果用sed来实现会有什么问题?

for F in final-test-*.txt
do
echo ‘This is a test file…!!’ > $F
done

文件名首字母大写。注意echo和sed的连用,以及我们引用命令的一种新方法$(command)。还有mv这个新命令。

for F in final-test-*.txt
do
NEW=$(echo “$F” | sed -e ’s/^./F/')
mv “$F” “$NEW”
done

删除标点。

for F in *.txt
do
sed -i.tmp ’s/…!!$//' $F
done

大写。注意tr的使用。

for F in *.txt
do
tr ‘[:lower:]’ ‘[:upper:]’ < $F > FILE2
mv FILE2 $F
done

cd ../
mkdir repo

cd final

复制文件。

for F in *.txt
do
cp $F ../repo/$F
done

 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
31
32

当中新出现的东西,就留待读者自己解决啦。如果你也完成了这个任务,不妨将你的代码发给我。相信你一定能写出比我更简洁的代码!

#### 11. 后记

Happy Coding!

#### 参考资源

*   1. Bash Array Tutorial
*   2. Shell脚本编程三十分钟入门
*   3. Unix Tutorial Point
*   4. about.com
*   5. Sed - An Introduction and Tutorial by Bruce Barnett
*   6. The Geek Stuff
*   7. Grep Tutorials
*   8. Drew's Grep Tutorial

#### 勘误和交流

匆忙写下的一个笔记,出错简直是一定的。如果您发现了任何错误或者有关于本文的任何建议,麻烦发邮件给我(stevenslxie at gmail.com)或者在GitHub上直接交流,不胜感激。

#### 转载声明

如果你喜欢这篇文章,可以随意转载。但请

*   标明原作者StevenSLXie;
*   标明原链接(https://github.com/StevenSLXie/Shell-Programming-Tutorial/edit/master/%E6%95%99%E7%A8%8B.md);
*   在可能的情况下请保持文本显示的美观。比如,请不要直接一键复制到博客之类,因为代码的显示效果可能非常糟糕;
*   请将这个转载声明包含进来;