通过示例入门 Bash 编程

15 Jan, 2014

此前认为有了 Python,也就不太需要使用 Bash,不过到现在,觉得 Bash 用武之地仍然广泛。虽相比于其它设计精良的脚本语言,它表现的实在过于随意,不适合用于太大的工程,不过当其作为称职的“胶水”,将操作系统提供的各种命令粘在一起时,才会觉得它是必不可少的。那么,当大概了解各种命令后,对”胶水“自然就有了需求,恰好看到这篇通过示例学习 Bash 的文章,于是大概将原文翻译一下,因为初学,可能多少会有点错误,还请各位指正。

这里主要是介绍 Bash 的基本语法,前提是你得了解类 Unix 系统和 shell,当然这里不会介绍各种 shell 命令的用法,如果你想让 Bash 成为一把利剑,掌握这些命令的用法是不可或缺的。Bash 是一种脚本语言,所以得有个解释器,而这个解释器的名称也叫 Bash,当然还有 Zsh 之类的,这个是可以设置的。

执行

Bash 是交互式的,你可以打开 shell,然后输入命令,然后得到结果,好吧,这其实就是使用命令行时做的事情,只不过没有用到太多 Bash 的语法而已。对于 Bash 源文件,需要将 #!/bin/bash 放在文件开头单独一行,然后让文件拥有可执行属性:chmod u+x scriptname,如果是在文件的当前目录,运行 ./scriptname 后面加上参数(如果需要的话)就可以了。当 shell 执行这个脚本时,它就会寻找 #!/path/to/interpreter 这个解释器来运行。下面的代码将打印传入的第一个参数。因为这个 #! 的原因,Bash 使用 # 作为注释,以 # 号开始,一直到这行末尾都被视为注释。

#!/bin/bash

# 使用 $1 获得第一个命令行参数
echo $1

基本类型

类似于 Python,Bash 的变量不用显示声明类型,所以一个变量既可以是个数字,也可以将其赋值成数组。设置一个变量使用 foo=3,不过 = 两边不能有空格,如果写成 foo = 3,则 Bash 会认为是调用 foo 命令,=3 则作为参数,如果确实想使用空格,需要使用 (()) 将表达式括起来。引用变量的值,使用美元符号 $foo

删除变量的定义使用 unset:

foo=42
echo $foo    # prints 42
unset foo
echo $foo    # prints nothing

当然可以将一个变量赋值给其它变量:

foo=$bar   # 将 bar 的值赋给 foo

如果值包含空格,需要使用引号括起来:

# 错误:
foo=x y z   # 将 foo 设置为 x,然后执行 y 和 z

# 正确:
foo="x y z" # 设置 foo 为 "x y z"

必要的时候可以使用大括号包裹变量来进行引用(实际上这是标准的做法,直接使用 $foo 只是简写而已)

echo ${foo} # prints $foo

这个对输出数组元素时非常有用,Bash 里不用显示的声明一个数组,任何变量都可以是数组,只要按照数组的方式使用就行了,你可以用任何变量对数组进行赋值:

foo[0]="first"  # 设置第一个元素为 "first"
foo[1]="second" # 设置第二个元素为 "second"

使用索引来引用数组元素值的时候,需要使用大括号:

foo[0]="one"
foo[1]="two"
echo ${foo[1]}  # prints "two"

当你直接引用一个变量时,其实隐式引用的是其第一个元素:

foo[0]="one"
foo[1]="two"
echo $foo       # prints "one"

bar="hello"
echo ${bar[0]}  # prints "hello"

您也可以使用括号来创建一个数组:

foo=("a a a" "b b b" "c c c")
echo ${foo[2]}  # prints "c c c"
echo $foo       # prints "a a a"

可以使用 @ 或者 * 这两个特殊下标来访问数组的所有元素:

array=(a b c)
echo $array       # prints a
echo ${array[@]}  # prints a b c
echo ${array[*]}  # prints a b c

可以这样拷贝一个数组:

foo=(a b c)
bar=("${foo[@]}")
echo ${bar[1]}    # prints b

不要试图直接赋值变量来进行拷贝,因为这样只是表示数组第一个元素而已:

foo=(a b c)
bar=$foo
echo ${bar[1]}    # prints nothing

当然,不要忘了引号,否则含有空格的元素会出现错误:

foo=("a 1" "b 2" "c 3")
bar=(${foo[@]})
baz=("${foo[@]}")
echo ${bar[1]}            # oops, print "1"
echo ${baz[1]}            # prints "b 2"

特殊变量

下面这些特殊变量用来得到传给脚本或者函数的参数:

echo $0      # 执行脚本的名称

echo $1      # 打印第一个参数
echo $2      # 打印第二个参数
echo $10     # 打印第一个参数,后面再跟着个 0 
echo ${10}   # 正确的打印第十个参数的写法

echo $#      # 打印参数个数

变量 $? 用来访问前一个执行进程的退出状态(就是使用 exit 的值)

退出状态是 0 表示进程成功执行没有出现错误,其它状态表示出现了一些错误,在 shell 编程中,使用 true 命令来表示程序总是成功的, false 表示程序总是失败的:

true
echo $?   # prints 0

false
echo $?   # 永远不会输出 0,通常是输出 1

当前 shell 的进程 ID 通过 $$ 访问。通过 $! 来得到最近后台进程的 ID:

# sort two files in parallel:
sort words > sorted-words &        # 启动后台进程
p1=$!
sort -n numbers > sorted-numbers & # 启动后台进程
p2=$!
wait $p1
wait $p2
echo Both files have been sorted.

变量操作

bash 可以引用一个对已有变量进行改变而得到的变量,如下面的字符串替换:

foo="I'm a cat."
echo ${foo/cat/dog}  # prints "I'm a dog."

# 使用双斜线替换所有匹配的字符串
foo="I'm a cat, and she's cat."
echo ${foo/cat/dog}   # prints "I'm a dog, and she's a cat."
echo ${foo//cat/dog}  # prints "I'm a dog, and she's a dog."

# 这些操作一般不会修改原变量
foo="hello" 
echo ${foo/hello/goodbye}  # prints "goodbye"
echo $foo                  # still prints "hello"

# 如果没有替换的,将直接删除被替换的
foo="I like meatballs."
echo ${foo/balls}       # prints I like meat.

使用 ${name#pattern} 来移除 ${name} 匹配模式的最短前缀,如果是 ## 则移除最长前缀:

minipath="/usr/bin:/bin:/sbin"
echo ${minipath#/usr}           # prints /bin:/bin:/sbin
echo ${minipath#*/bin}          # prints :/bin:/sbin
echo ${minipath##*/bin}         # prints :/sbin

使用 % 代替 # 则可以用来匹配后缀:

minipath="/usr/bin:/bin:/sbin"
echo ${minipath%/usr*}           # prints nothing
echo ${minipath%/bin*}           # prints /usr/bin:
echo ${minipath%%/bin*}          # prints /usr

字符串/数组操作

Bash 处理字符串和数组时往往使用的是相同的运算符,例如,前缀运算符 用来计算字符串的字符数或者数组的成员数,一个常见的错误是,数组的第一个元素恰好是字符串,比如很多初学者教程都用下面这个例子来说明这种错误用法:

ARRAY=(one two three)
echo ${#ARRAY}          # prints 3 -- 好像是对的

ARRAY=(a b c)
echo ${#ARRAY}          # prints 1 -- 咦?

这是因为 ${#ARRAY}${#ARRAY[0]} 是一样的,这样只是得到数组第一个元素的字符数而已。正确的方法是:

ARRAY=(a b c)
echo ${#ARRAY[@]}      # prints 3

对字符串或者数组进行切片:

string="I'm a fan of dogs."
echo ${string:6:3}           # prints fan

array=(a b c d e f g h i j)
echo ${array[@]:3:2}         # prints d e

存在性测试

有些操作测试变量是否被设置:

unset username
echo ${username-default}        # prints default

username=admin
echo ${username-default}        # prints admin

如果需要强制测试变量是否为空,则可以使用 :-:

unset foo
unset bar

echo ${foo-abc}   # prints abc
echo ${bar:-xyz}  # prints xyz

foo=""
bar=""

echo ${foo-123}   # prints nothing
echo ${bar:-456}  # prints 456

操作符 = (or :=)- 类似, 但它可以在变量没有值时对变量赋值:

unset cache
echo ${cache:=1024}   # prints 1024
echo $cache           # prints 1024

echo ${cache:=2048}   # prints 1024
echo $cache           # prints 1024

操作符 + 改变已经设置的变量,如果变量未设置,则什么都不做:

unset foo
unset bar

foo=30

echo ${foo+42}    # prints 42
echo ${bar+1701}  # prints nothing

操作符 ? 测试变量是否设置,如果没有则显示指定的信息并终止程序执行:

: {1?failure: no arguments} # 如果没有第一个参数将终止程序

(:是一个特殊的命令,称为空命令,该命令不做任何事,并且忽略给它的所有参数,但 Exit Status 总是真)

间接查找

Bash 允许使用 ! 前缀来间接的实现变量/数组的查找。其实,${!expr}${${expr}} 效果类似:

foo=bar
bar=42
echo ${!foo}  # 打印 $bar, 即 42

alpha=(a b c d e f g h i j k l m n o p q r s t u v w x y z)
char=alpha[12]

echo ${!char} # 打印 ${alpha[12]}, 即 m

数组中的 * 和 @

有两个额外的特殊变量 $*$@,大多数时候通过 ${array[*] 或者 ${array[@]} 来访问数组都可以。但这两者表示传递给当前脚本/函数的参数,并被引号括起来时,他们会有些不同,为了说明这一点的区别,这里创建几个辅助脚本。

首先是 print12:

#!/bin/bash

# 打印前两个参数
echo "first:  $1"
echo "second: $2"

然后创建 showargs:

#!/bin/bash

echo $*
echo $@

echo "$*"
echo "$@"

bash print12 "$*"
bash print12 "$@"

现在执行 showargs:

$ bash showargs 0  " 1    2  3"

运行结果是:

0 1 2 3
0 1 2 3
0  1    2  3
0  1    2  3
first:  0  1    2  3
second: 
first:  0
second:  1    2  3

出现这样的结果是因为 $* 将所有参数组合成一个单一的字符串, $@ 将重新对各个参数添加引用。这两者之间的另一个微妙的差异是如果变量 IFS(内部字段分隔符)被设置,那么这个变量将作为 $* 用来拼接各参数的分隔符。创建一个脚本 atvstar:

#!/bin/bash

IFS=","

echo $*
echo $@

echo "$*"
echo "$@"

执行它:

$ bash atvstar 1 2 3 

# to print:
# 1 2 3
# 1 2 3
# 1,2,3
# 1 2 3

IFS 必须包含一个字符。使用数组作为参数传递给函数时,也会出现上面的现象:

arr=("a b"  " c d    e")

echo ${arr[*]}            # prints a b c d e
echo ${arr[@]}            # prints a b c d e

echo "${arr[*]}"          # prints a b  c d    e
echo "${arr[@]}"          # prints a b  c d    e

bash print12 "${arr[*]}"  
# prints:
# first:  a b  c d    e
# second:

bash print12 "${arr[@]}"
# prints:
# first:  a b
# second:  c d    e

字符串

字符串是字符序列,创建字符串使用单引号,创建插值字符串使用双引号:

world=Earth
foo='Hello, $world!'
bar="Hello, $world!"
echo $foo            # prints Hello, $world!
echo $bar            # prints Hello, Earth!

在插值字符串中,变量会被替换为它的值。

作用域

在 Bash 中,变量的作用域是进程范围的:所有进程都拥有自有变量的副本。此外,变量只有显式的导入到子进程,才能被子进程所访问:

foo=42
bash somescript          # somescript 里无法访问 foo

export foo
bash somescript          # somescript 可以访问 foo
echo "foo = " $foo       # 总是打印 foo = 42

这里假设 somescript 如下所示:

#!/bin/bash
echo "old foo = $foo"
foo=300
echo "new foo = $foo"

运行上面程序得到的结果是:

old foo = 
new foo = 300
old foo = 42
new foo = 300
foo = 42

表达式和运算

在 Bash 中书写算术表达式必须的小心谨慎,expr 命令用来打印算术表达式的结果,当然,请谨慎对待:

expr 3 + 12      # prints 15
expr 3 * 12      # (可能) 出错: * 会扩展到所有文件
expr 3 \* 12     # prints 36

你得小心对待空格或者 Bash 的展开,使用 (( assignable = expression )) 来求值表达式会让你轻松一点:

(( x = 3 + 12 )); echo $x    # prints 15
(( x = 3 * 12 )); echo $x    # prints 36

如果你不想申明一个临时变量来保存求值结果,可以使用 $((expression)) 来完成:

echo $(( 3 + 12 ))   # prints 15
echo $(( 3 * 12 ))   # prints 36

Bash 中声明变量通常是隐式的(在定义时就默认已声明了变量),而且大部分时候是这样做的,不过也可以显式的声明变量,这样可以明确变量类型。使用 declare -i variable 来显式的创建一个整型变量:

declare -i number
number=2+4*10
echo $number        # prints 42

another=2+4*10
echo $another       # prints 2+4*10

number="foobar"
echo $number        # prints 0

赋值给整型变量将强制进行表达式的求值。

文件和重定向

任何 Unix 进程可以访问默认的三个输入/输出通道:STDIN(标准输入), STDOUT(标准输出), STDERR(标准出错)。

  • 写入 STDOUT,输出默认显示在控制台
  • 从 STDIN 读时,默认直接读取用户从控制台输入的内容
  • 写入 STDERR,输出默认显示在控制台

上面的这三个通道都可以被重定向。例如,要将一个文件的内容作为标准输入(而不是用户输入),使用 < 操作符即可:

# 打印出文件中包含单词 foo 的行
grep foo < myfile

将命令的结果输出到文件(而不是控制台),使用 > 操作符:

# 将 file2 与 file1 合并,输出到 combined
cat file1 file2 > combined

如果只是添加新内容到输出文件结尾,使用 >> 操作符:

# 将当前日期和时间添加到 log 文件末尾
date >> log

如果要从 STDIN 读取内容,可以使用 <<endmarker 来规定何时结束读取,例如使用:

cat <<UNTILHERE

这将提示进行输入,并显示输入内容,当输入 UNTILHERE 时,将结束读取。通常这种格式被称为 Here 文档,在 shell 中它通常用于给命令提供输入内容。重定向出错输出(STDERR),使用 2> 操作符:

# 从 httpd 进程启动时将错误写入 error.log
httpd 2> error.log

事实上,所有 I/O 通道都可以使用一个数字来描述,所以 > 其实就是 1>。STDIN 是通道0,STDOUT 是1,STDERR 是2。M>&N 则表示将通道 M 输出重定向到 N。所以下面是将错误输出显示到 STDOUT:

grep foo nofile 2>&1 # 错误会出现在 STDOUT

可以捕获使用反引号包裹的命令的标准输出:

# 将 date 和 whoami 命令的标准输出添加到 log
echo `date` `whoami` >> log

使用 $(command) 符号可以达到相同的目的:

# 将 date 和 whoami 命令的标准输出添加到 log
echo $(date) $(whoami) >> log

从文件中读入内容经常使用 cat path-to-file, 不过Bash 拥有一个相同功能的内建方法 <path-to-file

echo user: `<config/USER` # 打印 config/USER 的内容

Bash 中一个特殊的命令 exec 可以在命令范围内操作通道。

exec < file # 标准输入现在是 file
exec > file # 标准输出现在是 file

你可能需要备份一下 STDIN 和 STDOUT,这样后来需要的时候可以恢复回来:

exec 7<&0 # 将 STDIN 保存在通道 7
exec 6>&1 # 将 STDOUT 保存在通道 6

比如你想将脚本中一段代码的输出输出到文件,你可以这样做:

exec 6>&1       # 将 STDOUT 保存在通道 6
exec > LOGFILE  # 好了,现在标准输出变成 LOGFILE 了

# 其它命令

exec 1>&6       # 重新将标准输出变为 STDOUT

管道

如果想将一个进程的 STDOUT 作为另一进程的 STDIN,则可以使用 | 管道操作符:

# 从 passwd 文件中输出 root 的条目
cat /etc/passwd | grep root

管道的使用一般形式是:outputing-command | inputing-command,并且它可以将多个命令连接起来,就像流水线一样:

# 一行命令找出当前目录占用空间大小的前10个文件(包括目录)

# du -cks *  # 打印当前目录文件占用空间大小

# sort -rn   # 依据 STDIN 输入的第一列,以数字作为标准进行排序,并倒序输出

# head       # 打印 STDIN 输入的前十行

du -cks * | sort -rn | head

有些程序接受一个文件名,并从对应文件中读取,而不是从 STDIN 读。对于这些程序,或接受多个文件名的程序,有一种方法可以用来创建一个临时文件,其中包含一个命令的输出,即 <(command) 形式。

# 将 uptime 与 date 的输出, event.log 最后一行添加到 main.log
cat <(uptime) <(date) <(tail -1 event.log) >> main.log

进程

Bash 的过人之处正是在于协调进程,管道将多个进程连接在一起,进行流水线似的作业,也可以并行的运行进程。在后台执行命令,可以使用 & 后缀符号。

time-consuming-command &

通过 $! 这个特殊变量来获取刚派生的进程 ID:

time-consuming-command &
pid=$!

使用 wait 命令来等待一个进程的结束:

time-consuming-command &
pid=$!
wait $pid
echo Process $pid finished.

如果 wait 后面没有进程 ID,将等待所有子进程结束。下面将一个文件中的所有 JPEG 文件转换为 PNG 文件:

for f in *.jpg
do 
  convert $f ${f%.jpg}.png &
done 
wait
echo All images have been converted.

Glob 模式

Bash 可以使用 glob 符号来匹配字符串和文件名(glob 类似于通配符,只是扩展到可以匹配到多个文件或路径)。在大多数情况下,一个 glob 模式会自动扩展到包含所有匹配文件名的数组:

echo *.txt        # 打印所有 txt 文件名
echo *.{jpg,jpeg} # 打印所有 JPEG 文件名

Glob 模式有以下几种特殊形式:

    • 匹配任意字符串
  1. ? 匹配单个字符
  2. [chars] 匹配在 chars 中的任意字符
  3. [a-b] 匹配在 a 和 b 间的所有字符(包括 a 和 b)

使用这些模式,可以非常轻松的删除所有文件名类似于 fileNNN 的文件,其中 NNN 是三个数字:

rm file[0-9][0-9][0-9]

还有一种大括号形式,它类似于进行模式的组合,比如 `{str1,str2,...,strN} 将扩展 str1 或者 str2 等等,下面这个例子可以清晰的表现这种形式的作用:

echo {0,1}              # prints 0 1
echo {0,1}{0,1}         # prints 00 01 10 11
echo {0,1}{0,1}{0,1}    # prints 000 001 010 011 100 101 110 111

控制结构

像很多程序语言一样,Bash 支持条件,迭代,子程序等控制结构。类似于 If-then-else-style 的条件结构在 Bash 中也存在,但是在 Bash 中,条件是一个命令,这个命令成功退出的状态是 0,表示 "true",否则表示失败,退出状态为非 0,表示 "false":

# this will print:
if true
then  
  echo printed
fi

# this will not print:
if false
then  
  echo not printed
fi

Bash 中可以通过程序的状态来采取不同的操作:

if httpd -k start
then
  echo "httpd started OK"
else
  echo "httpd failed to start"
fi

在 Bash 中,很多条件都是来自于特殊命令进行的测试,这些测试需要使用标志来确定采用何种测试,一些比较常用的标志包括:

  • -e file: 当指定的文件或者目录存在时为真
  • -z string: 指定的字符串为空时为真
  • string1 = string2: 两个字符串相同时为真

可以使用 [ args ] 来进行测试:

if [ "$1" = "-v" ]
then
  echo "switching to verbose output"
  VERBOSE=1
fi

在使用迭代时,whiledo 会在测试命令返回非0退出状态时结束:

# 如果 httpd 程序崩溃,将自动重启它
while true
do
   httpd
done

在 For-In 循环中使用 do...done 可以遍历完所有元素:

# 编译当前目录下的所有 C 源程序
for f in *.c
do
  gcc -o ${f%.c} $f
done

Bash 中的函数有点像独立的脚本,有两种形式来定义一个函数:

function name {
  commands
}

# and

name () {
  commands
}

一旦声明,函数的行为几乎像一个单独的脚本:函数的自变量也采用 $n 这种形式来获得。一个主要的不同之处在于函数可以查看和修改在其外部脚本中定义的变量:

count=20

function showcount {
  echo $count
  count=30
}

showcount    # prints 20
echo $count  # prints 30

一些例子

终于,学完上面的一些内容,可以使用 Bash 完成一些简单的程序了。下面是一个求阶乘的函数:

function fact {
  result=1
  n=$1
  while [ "$n" -ge 1 ]
  do
    result=$(expr $n \* $result)
    n=$(expr $n - 1)
  done
  echo $result
}

或者采用另一种形式的算术运算:

function facter {
  result=1
  n=$1
  while (( n >= 1 ))
  do
    (( result = n * result ))
    (( n = n - 1 ))
  done
  echo $result
}

或者直接声明整型变量:

factered () {
  declare -i result
  declare -i n

  n=$1
  result=1

  while (( n >= 1 ))
  do
    result=n*result
    n=n-1
  done

  echo $result
}

后话

总体说来,这篇文章介绍的只是比较浅显的内容,后面介绍控制结构时比较粗略,这里推荐看以下这篇文章:Shell脚本语法。总的说来,Bash 真印证了其是”未经设计“的,太多的陷阱和奇怪的命令,让人不知所措,这只能靠多用和多看来解决了。当然,Bash 的精髓在于发挥胶水作用,将 Unix 下的各种强大命令组合在一起,要达到 Bash 达人的境界,学习和掌握常见或者和你工作领域相关的命令就是必不可少的,如果真的达到那一步,那么限制你的只能是想象力了。