图片描述

Read Me

  • 本文是以英文版<bash cookbook> 为基础整理的笔记,力求脱水
  • 2017.11.23 更新完【基础】,内容涵盖bash语法等知识点。
  • 本系列其他两篇,与之互为参考

    • 【中级】内容包括工具、函数、中断及时间处理等进阶主题。传送门
    • 【高级】内容涉及脚本安全、bash定制、参数设定等高阶内容。传送门
  • 所有代码在本机测试通过

    • Debian GNU/Linux 9.2 (stretch)
    • GNU bash, version 4.4.12(1)-release (x86_64-pc-linux-gnu)

约定格式

# 注释:前导的$表示命令提示符
# 注释:无前导的第二+行表示输出

# 例如:
$ 命令 参数1 参数2 参数3 # 行内注释
输出_行一
输出_行二
    
$ cmd par1 par1 par2 # in-line comments
output_line1
output_line2

获取帮助

天助自助者

命令查询 man help

# cmd表示任意命令
$ man cmd
    
# 手册第7章(这一章是内容总览)
$ man 7 cmd
    
$ cmd -h
    
$ cmd --help
    
# 查看bash内置命令的帮助文档
$ help builtin-cmd 

删除 rm

# 文件删除前询问确认(误删文件会很麻烦的)
$ rm -i abc.file
rm: remove regular file 'abc.file'?

命令(精确)查找 type which locate

# 从$PATH的路径列表中查找:可执行的别名、关键字、函数、内建对象、文件等。
$ type ls
ls is aliased to `ls -F -h`
    
$ type -a ls # 查找全部(All)匹配的命令
ls is aliased to `ls -F -h`
ls is /bin/ls
    
$ which which
/usr/bin/which
    
# 也用于判断命令是bash内置(built-in)或是外部的(external)
$ type cd
cd is a shell builtin
# 从cron job维护的系统索引库中查找。
$ locate apropos
/usr/bin/apropos
/usr/share/man/de/man1/apropos.1.gz
/usr/share/man/es/man1/apropos.1.gz
/usr/share/man/it/man1/apropos.1.gz
/usr/share/man/ja/man1/apropos.1.gz
/usr/share/man/man1/apropos.1.gz
    
# slocate (略)

命令(模糊)查找 apropos

# 从man手册中查找匹配的命令关键字。
$ apropos music
cms (4) - Creative Music System device driver
    
$ man -k music # 效果同上
cms (4) - Creative Music System device driver

一、基本定义和I/O

在linux眼里,一切皆文件

输入/输出

文件描述符【简表】

类型 标识 描述符编号
标准输入 STDIN 0
标准输出 STDOUT 1
标准错误 STDERR 2
用户自定义 3...

I/O重定向【简表】

命令 备注
命令 <输入.in 读入
命令 >输出.out 覆盖写
命令 >l输出.out 在noclobber作用域内强制覆盖写
命令 >>输出.out 追加写
命令 <<EOF 输入 EOF 将"输入"内嵌到脚本内
命令a l 命令b l 命令c 单向管道流
命令a l tee 输出a l 命令b T型管道流 (三通接头)
2 >&1 &的意思是,将1解释为描述符而不是文件名
2 >&3- -的意思是 : 自定义描述符3用完后释放

I/O的流向

$ 命令 1>输出文件.out 2>错误日志.err
    
# 单向管道流
$ cat my* | tr 'a-z' 'A-Z' | uniq | awk -f transform.awk | wc
    
# 通过tee实现管道分流,将uniq的输出写入x.x,同时也传给awk处理
$ ... uniq | tee /tmp/x.x | awk -f transform.awk ...
    
# 对于不接受标准输入作为参数的命令,比如rm
# 此时无法像这样写管道流
$ find . -name '*.c' | rm
# 解决办法是,将输入通过$(...)打包为子进程
$ rm $(find . -name '*.class')
    
# 通过引入一个自定义的临时描述符3,可以实现STDOUT和STDERR的对调
$ ./myscript 3>&1 1>stdout.logfile 2>&3- | tee -a stderr.logfile
# 简化的结构
$ ./myscript 3>&1 1>&2 2>&3

单行多命令 sub-shell

# 一是用{},因为花括号是保留字,所以前后括号与命令间都要留一个空格
$ { pwd; ls; cd ../elsewhere; pwd; ls; } > /tmp/all.out
    
# 二是用(),bash会把圆括号内的序列打包为一个子进程(sub-shell)
# 子进程是个很重要的概念,这里暂不展开
# 如果说bash是个壳,sub-shell就是壳中壳了
# 类比python的闭包
$ (pwd; ls; cd ../elsewhere; pwd; ls) > /tmp/all.out

here document

here document是个linux脚本语言所特有的东西
对这个专有名词,我在网上也没找到现成的翻译
这里的here可以理解为"here it is"
即把原本需要从外部引用的输入文件
用一对EOF标识符直接内嵌进脚本
这样就免去了从命令行再多引入一个外部文件的麻烦
如果把输入文件比作脚本需要的电池
就相当于让脚本“自带电池”(借用了python的词)
# bash会对内容块内一些特殊标识进行不必要的解析和转义,进而可能导致一些异常行为
# 所以作为一个良好的习惯,建议改用<<\EOF,或<<'EOF',甚至可以是<<E\OF
    
$ cat ext
# here is a "here" document ## 巧妙的双关语
grep $1 <<EOF
mike x.123
sue x.555
EOF
$
$ ext 555
sue x.555
$
# tab缩进:<<-'EOF'
# -(减号)会告知bash忽略EOF块内的前导tab标识
# 最后一个EOF前内务必不要留多余的空格,否则bash将无法定位内容块结束的位置
    
$ cat myscript.sh
...
    grep $1 <<-'EOF'
        lots of data
        can go here
        it's indented with tabs
        to match the script's indenting
        but the leading tabs are
        discarded when read
        EOF # 尾巴的EOF前不要有多余的空格
    ls
...
$

获取用户输入 read

# 直接使用
$ read
    
# 通过-p参数设置提示符串,并用ANSWER变量接收用户的输入
$ read -p "给个答复 " ANSWER
    
# 输入与接收变量的对应原则:
# 类比python中元组的解包(平行赋值)
# 参数:       PRE MID POST
# 输入比参数少:one
# 参数:       PRE(one), MID(空), POST(空)
# 输入比参数多:one two three four five
# 参数:       PRE(one), MID(two), POST(three four five)
$ read PRE MID POST
    
# 密码的接收
# -s关闭明文输入的同时,也屏蔽了回车键,所以通过第二句显式输出一个换行
# 
# $PASSWD以纯文本格式存放在内存中,通过内核转储或查看/proc/core等方式可以提取到
$ read -s -p "密码: " PASSWD ; printf "%b" "\n"
  • 【应用】接收用户输入
# 文件名: func_choose
# 根据用户的输入选项执行不同命令
# 调用格式: choose <默认(y或n)> <提示符> <选yes时执行> <选no时执行>
# 例如:
# choose "y" \
# "你想玩个游戏吗?" \
# /usr/games/spider \
# 'printf "%b" "再见"' >&2
# 返回: 无
function choose {
    
    local default="$1"
    local prompt="$2"
    local choice_yes="$3"
    local choice_no="$4"
    local answer
    
    read -p "$prompt" answer
    [ -z "$answer" ] && answer="$default"
    
    case "$answer" in
        [yY1] ) exec "$choice_yes"
            # 错误检查
            ;;
        [nN0] ) exec "$choice_no"
            # 错误检查
            ;;
         *    ) printf "%b" "非法输入 '$answer'!"
    esac
} # 结束
# 文件名: func_choice.1
# 把处理用户输入的逻辑单元从主脚本中剥离,做成一个有标准返回值的函数
# 调用格式: choice <提示符>
# 例如: choice "你想玩个游戏吗?"
# 返回: 全局变量 CHOICE
function choice {
    
    CHOICE=''
    local prompt="$*"
    local answer
    
    read -p "$prompt" answer
    case "$answer" in
        [yY1] ) CHOICE='y';;
        [nN0] ) CHOICE='n';;
        *     ) CHOICE="$answer";;
    esac
} # 结束
    
# 主脚本只负责业务单元:
# 不断返回一个包的时间值给用户确认或修改,直到新值满足要求
until [ "$CHOICE" = "y" ]; do
    
    printf "%b" "这个包的时间是 $THISPACKAGE\n" >&2
    choice "确认? [Y/,<新的时间>]: "
    
    if [ -z "$CHOICE" ]; then
        CHOICE='y'
    elif [ "$CHOICE" != "y" ]; then
        # 用新的时间覆写THISPACKAGE相关的事件
        printf "%b" "Overriding $THISPACKAGE with ${CHOICE}\n"
        THISPACKAGE=$CHOICE
    fi
done
    
# 这里写THISPACKAGE相关的事件代码   
# 以下总结三种常用的预定义行为:

# 1. 当接收到'n'之外的任何字符输入时,向用户显示错误日志
choice "需要查看错误日志吗? [Y/n]: "
if [ "$choice" != "n" ]; then
    less error.log
fi
    
# 2. 只有接收到小写'y',才向用户显示消息日志
choice "需要查看消息日志吗? [y/N]: "
if [ "$choice" = "y" ]; then
    less message.log
fi
    
# 3. 不论有没有接收到输入,都向用户做出反馈   
choice "挑个你喜欢的颜色,如果有的话: "
if [ -n "$CHOICE" ]; then
    printf "%b" "你选了: $CHOICE"
else
    printf "%b" "没有喜欢的颜色."
fi

二、命令/变量/算术

命令

抛开窗口和鼠标的束缚

运行的机制 $PATH

# 当输入任意一条命令时
$ cmd
    
# bash会遍历在环境变量$PATH定义的路径,进行命令匹配
# 路径串用冒号分隔。注意最后的点号,表示当前路径
$ echo $PATH
/bin:/usr/bin:/usr/local/bin:.
    
# 做个小实验:
$
$ bash # 首先,开一个bash子进程
$ cd # 进到用户的home路径
$ touch ls # 创建一个与ls命令同名的空文件
$ chmod 755 ls # 赋予它可执行权限
$ PATH=".:$PATH" # 然后把当前(home)路径加入PATH的头部
$
    
# 这时,在home路径下执行ls命令时,会显示一片空白
# 因为你所期望的ls已经被自创的ls文件替换掉了
# 如果去到其他路径再执行ls,一切正常
    
# 实验做完后清理现场
$ cd
$ rm ls
$ exit # 退出这个bash子进程
$
    
# 所以,安全的做法是,只把当前路径附在PATH的尾部,或者干脆就不要附进去
# 一个实用的建议:
# 可以把自己写的所有常用脚本归档在一个自建的~/bin目录里
PATH=~/bin:$PATH
# 通过自定义的变量操作命令:
# 比如定义一个叫PROG的通用变量
$ FN=/tmp/x.x
$ PROG=echo
$ PROG $FN
$ PROG=cat
$ PROG $FN
变量的取名是很有讲究的。有些程序,比如InfoZip,会通过$ZIP和$UNZIP等环境变量传参给程序。如果你在脚本中擅自去定义了一个类似ZIP='/usr/bin/zip'的变量,会怎么想也想不明白:为什么在命令行工作得好好的,到了脚本就用不了? 所以,一定要先去读这个命令的使用手册(RTFM: Read The Fxxking Manual)。

运行的顺序 串行 并行

三种让命令串行的办法
# 1. 不停的手工输入命令,哪怕前一条还没执行完,Linux也会持续接收你的输入的
    
# 2. 将命令串写入一个脚本再批处理
$ cat > simple.script
long
medium
short
^D # 按Ctrl-D完成输入
$ bash ./simple.script
    
# 3. 更好的做法是集中写在一行:
# 顺序执行,不管前一条是否执行成功
$ long ; medium ; short
# 顺序执行,前一条执行成功才会执行下一条
$ long && medium && short
命令的并行
# 1. 用后缀&把命令一条条手工推到后台
$ long &
[1] 4592 
$ medium &
[2] 4593
$ short
$
    
# 2. 写在一行也可以
$ long & medium & short
[1] 4592
[2] 4593  # [工作号] 进程号
$
$ kill %2 # 关闭medium进程,或者kill 4593
$ fg %1   # 把long进程拉回前台
$ Ctrl-Z  # 暂停long进程
$ bg      # 恢复long进程,并推到后台
linux其实并没有我们所谓“后台”的概念。当说“在后台执行一条命令”时,实际上发生的是,命令与键盘输入脱开。然后,控制台也不会阻塞在该命令,而是会显示下一条命令提示符。一旦命令“在后台”执行完,该显示的结果还是会显示回屏幕,除非事先做了重定向。
# 不挂断地运行一条后台命令
$ nohup long &
nohup: appending output to 'nohup.out'
$
用&运行一条后台命令时,它只是作为bash的一个子进程存在。当你关闭当前控制台时,bash会广播一个挂断(hup)信号给它的所有子进程。这时,你放在后台的long命令也就被“意外”终止了。通过nohup命名可以避免意外的发生。如果决意要终止该进程,可以用kill,因为kill发送的是一个SIGTERM终止信号。控制台被关闭后,long的输出就无处可去了。这时,nohup会被输出追加写到当前路径下的nohup.out文件。当然,你也可以任意指定这个重定向的行为。
脚本的批量执行
# 如果有一批脚本需要运行,可以这样:
for SCRIPT in /path/to/scripts/dir/*
do
    if [ -f $SCRIPT -a -x $SCRIPT ]
    then
        $SCRIPT
    fi
done
# 这个框架的一个好处是,省去了你手工维护一个脚本主清单的麻烦
# 先简单搭个架子,很多涉及robust的细节还待完善

返回状态 $?

用$?接收命令返回
# $?变量动态地存放“最近一条”命令的返回状态
# 惯例:【零值】正常返回;【非零值】命令异常
# 取值范围: 0~255,超过255会取模
    
$ badcommand
it fails...
$ echo $?
1           # badcommand异常
$ echo $?
0           # echo正常
$
    
$ badcommand
it fails...
$ STAT=$?   # 用静态变量捕获异常值
$ echo $STAT
1
$ echo $STAT
1
$
$?结合逻辑判断
# 例如:
# 如果cd正常返回,则执行rm
cd mytmp
if [ $? -eq 0 ]; 
    then rm * ; 
fi
    
# 更简洁的表达:
# A && B:逻辑与
# 如果cd正常返回,则执行rm
$ cd mytmp && rm *
    
# A || B:逻辑或
# 如果cd异常返回,则打印错误信息并退出
cd mytmp || { printf "%b" "目录不存在.\n" ; exit 1 ; }
    
# 如果不想写太多的逻辑判断,在脚本中一劳永逸的做法是:
set -e      # 遇到任何异常则退出
cd mytmp    # 如果cd异常,退出
rm *        # rm也就不会执行了

变量

一些常识 $

  • 变量是:

    • 存放字符串和数字的容器
    • 可以比较、改变、传递
    • 不需要事先声明
# 主流的用法是,全用大写表示变量,MYVAR
# 以上只是建议,写成My_Var也可以
    
# 赋值不能有空格 变量=值
# 因为bash按空格来解析命令和参数
$ MYVAR = more stuff here    # 错误
$ MYVAR="more stuff here"    # 正确
    
# 变量通过$来引用
# 抽象来看,赋值语句的结构是:左值=右值
# 通过$,告诉编译器取右值
# 而且,$将变量和同名的字面MYVAR做了区分
$ echo MYVAR is now $MYVAR
MYVAR is now more stuff here
    
for FN in 1 2 3 4 5
do
    somescript /tmp/rep$FNport.txt      # 错误 $FNport被识别为变量
    somescript /tmp/rep${FN}port.txt    # 正确 {}界定了变量名称的范围
done

导出和引用 export

# 查看当前环境定义的所有变量
$ env
$ export -p
  • 导出变量的正确方式
# 可以把导出声明和赋值写在一起
export FNAME=/tmp/scratch
    
# 或者,先声明导出,再赋值
export FNAME
FNAME=/tmp/scratch
    
# 再或者,先赋值,再声明导出
FNAME=/tmp/scratch
export FNAME
    
# 赋过的值也可以修改
export FNAME=/tmp/scratch
FNAME=/tmp/scratch2
  • 正确的理解变量引用
# 通过上边的声明,我们有了一个FNAME的环境变量
$ export -p | grep FNAME
declare -x FNAME="/tmp/scratch2"
# 我们暂称它是父脚本
    
# 现在如果父脚本内开了(调用)一个子脚本去访问和修改这个变量,是可以的
# 但是,这个修改行为,对于父脚本是透明的
# 因为子脚本访问和修改的,只是从父脚本copy过来的环境变量复本
# 这是单向的继承关系,也是linux的一种设计理念(或称为安全机制)
    
# 父脚本有没有什么办法去接收到这个改动呢?
# 唯一的取巧办法是:
# 让子脚本将修改echo到标准输出
# 然后,父脚本再通过shell read的方式去读这个值
# 但是,从维护的角度来讲,并不建议这样做
# 如果真的需要这么做,那原来的设计就有问题
所谓环境,指的是当前环境,也即当前控制台
如果你新开一个bash控制台,是根本看不到这个FNAME变量的
因为两个控制台是相互隔离的运行环境

参数的访问计数 ${} * @ {#}

  • 基本用法
# 访问脚本的第{1}个参数
$ cat simplest.sh
echo ${1}
$ ./simplest.sh you see what I mean
you
$ ./simplest.sh one more time
one
$
    
# 索引是单数的时候,花括号可以省略
# $10其实是$1+0
$ cat tricky.sh
echo $1 $10 ${10}
$ ./tricky.sh I II III IV V VI VII VIII IX X XI
I I0 X
$
#!/bin/bash
# actall.sh 批量更改文件权限
for FN in $*
do
    echo changing $FN
    chmod 0750 $FN
done
$ ./actall.sh abc.tmp another.tmp allmynotes.tmp
    
# $*相当于把参数序列按原样放入循环
for FN in abc.tmp another.tmp allmynotes.tmp
do
    echo changing $FN
    chmod 0750 $FN
done
  • 处理意外的空格 "" $@
首先要感谢苹果公司。是苹果,普及了文件名带空格的写法。
当你给文件命名时,可以优雅地写成My Report或是Our Dept Data
而不是丑陋的MyReport或是Our_Dept_Data了。
这给脚本的处理带来不便。因为空格对于脚本而言,是个最基础的分隔符
# 用引号包裹参数
$ touch "Oh the Waste"          # 先建一个名称含空格的演示文件
$ cat quoted.sh
ls -l "${1}"                    # 这个引号告诉ls,将脚本传进来的参数视为整体
$
$ ./quoted.sh "Oh the Waste"    # 这个引号告诉脚本,将用户给的参数视为整体
-rw-r--r-- 1 jimhs jimhs 0 Nov 11 22:57 Oh the Waste
$
# 处理循环的时候,前边例子中的$*识别不了带空格的参数,这时要使用加引号的"$@"
    
# 先建三个名称含空格的演示文件 
$ touch abc.tmp "yet another".tmp "all my notes".tmp
    
#!/bin/bash
# actall.sh 批量更改文件权限
for FN in "$@"
do
    echo changing $FN
    chmod 0750 $FN
done
$ ./actall.sh *.tmp
  • 计数 $#
#!/bin/bash
# check_arg_count 计算参数个数
#
if [ $# -lt 3 ]
then
    # 参数少于三个
elif [ $# -gt 3 ]
    # 参数多于三个
else
    # 参数等于三个
fi
    
# 注意三者的不同含义
${#}        # 参数的个数
${#VAR}     # VAR值的长度
${VAR#alt}  # 替换
  • 访问下一个参数 shift
# 专业的脚本往往会涉及两种参数:
# 一种作为选项开关,用于控制脚本的行为;另一种才是待处理处理
# 选项开关一旦被解析完成,就可以丢弃
# 这时,shift就派上了用场
    
#!/bin/bash
# actall.sh 批量更改文件权限
# 通过-v开关控制echo行为
VERBOSE=0;
if [[ $1 = -v ]]
then
    VERBOSE=1;
    shift;  # 移位: -v参数处理完可以丢弃了
fi
    
for FN in "$@"
do
    if (( VERBOSE == 1 ))
    then
        echo changing $FN
    fi
    chmod 0750 "$FN"
done
$ ./actall.sh -v *.tmp
    
# 这个例子比较简单,只能处理单个的选项
# 实际应用中,会涉及更复杂的情况
# 例如多参数,myscript -a -p,这时参数顺序应该不影响行为
# 以及重复的参数,是该忽略还是提示错误。等等
# 后续谈到getopts时,会给出解决方案
  • 数组变量 [ ]
# bash可以处理一维数组变量
    
$ MYRA=(first second third home)
$ echo runners on ${MYRA[0]} and ${MYRA[2]}
runners on first and third
    
# 如果只写$MYRA,就表示${MYRA[0]}

默认值 ${:- := =}

  • 基本用法
# ${:-}用于设置命令行参数的默认值
# 位置1的参数如果为空,则使用/tmp
FILEDIR=${1:-"/tmp"}
    
# 变量的默认值
# 如果HOME变量没有设置,则设置为/tmp
cd ${HOME:=/tmp}
    
# 做个小实验
$ echo ${HOME:=/tmp}
/home/jimhs
$ unset HOME            # 删除HOME
$ echo ${HOME:=/tmp}
/tmp
$ echo $HOME
/tmp
$ cd ; pwd
/tmp
$
    
# 为了方便记忆和区分,可以这样理解:
# ${HOME:=/tmp}用等号(=),表示赋值和返回这个值
# ${1:-"/tmp"}只有半个等号(-),只是返回,没有赋值。因为位置参数本身也没法被赋值
  • 空值 null
# ${=}用于允许空值的情况
# 空值null,也即""这样的空字符串
# 空值,和不存在是两个概念
    
# 继续上边的实验
$ echo ${HOME=/tmp} # 不会替换,因为HOME值存在
/home/jimhs
$ HOME=""
$ echo ${HOME=/tmp} # 也不会替换,因为HOME值为""
$ unset HOME
$ echo ${HOME=/tmp} # 会替换,因为HOME变量不存在了
/tmp
$ echo $HOME
/tmp
$
  • 不只是常量
关于${:=}等号的右边,除了常量,还可以放什么? 引用bash手册的原话是 “is subject to tilde expansion, parameter expansion, command substitution, and arithmetic expansion.”
# tilde expansion 波浪符展开 (不能带引号"")
${BASE:=~uid17}
    
# parameter expansion 参数展开
${BASE:=${HOME}}
    
# command substitution 命令替代
cd ${BASE:="$(pwd)"}
    
# arithmetic expansion 算术运算
echo ${BASE:=/home/uid$((ID+1))}

参数缺失 ${:?}

# ${:?}可以检查参数是否提供,该如何处理
    
#!/bin/bash
# check_unset_parms 检查参数是否设置
USAGE="使用格式:myscript 路径 文件 方式“
FILEDIR=${1:?"错误。未提供路径。"}
FILESRC=${2:?"错误。未提供文件。"}
CVTTYPE=${3:?"错误。${USAGE}"}
    
# 运行的效果是这样的:
$ ./myscript /tmp /dev/null
./myscript: line 5: 3: 错误。使用格式:myscript 路径 文件 方式
$
    
# 对于第三个分支,可以通过嵌入子进程$()实现更复杂的功能    
CVTTYPE=${3:?"错误。$USAGE. $(rm $SCRATCHFILE)"}
    
# 也可以牺牲点紧凑,写成下边这样更易读的结构
if [ -z "$3" ]
then
    echo "错误。$USAGE"
    rm $SCRATCHFILE
fi
刚才运行时的提示,显示了行号和错误内容,像个脚本错误。虽然其实是用户自己使用不当造成的。所以,出于用户友好的考虑,商业级脚本并不会这样使用。对脚本编写和调试人员来说,倒是蛮实用的。
./myscript: line 5: 3: 错误。使用格式: myscript 路径 文件 方式

部分替代 ${% # //}

# ${%}可以对变量内容做部分的修改
    
#!bin/bash
# suffixer 把后缀.bad改成.bash
for FN in *.bad
do
    mv "${FN}" "${FN%bad}bash"
done
    
#                           分解来看各个步骤:
#                           # FN=subaddon.bad
NOBAD="${FN%bad}"           # NOBAD="subaddon."
NEWNAME="${NOBAD}bash"      # NEWNAME="subaddon.bash"
mv "${FN}" "${NEWNAME}"     # mv "subaddon.bad" "subaddon.bash"
# 用vi和sed等编辑器的替代格式可以吗?比如这样:
mv "${FN}" "${FN/.bad/.bash}"
# 编辑器中,末尾还要加个/来封闭这条语句
# 但这里不需要了,因为右半花括号}已经做了句法封闭
    
# ${FN/.bad/.bash}会把我们的subaddon.bad替换为subashdon.bad
# 如果写成${FN//.bad/.bash},就会替换为subashdon.bash
# 因为这两种写法,都无法像%那样实现替换位置的锚定。

字符串操作【简表】

${...} 动作
name:number:number 提取子串,起始位置:长度
#name 返回字串长度
name#pattern 删除最短匹配,左向右
name##pattern 删除最长匹配,左向右
name%pattern 删除最短匹配,右向左
name%%pattern 删除最长匹配,右向左
name/pattern/string 替代第一处匹配
name//pattern/string 替代所有匹配

算术

bash支持整数运算

语法 ((...)) let

# 双括号内的表达式各项间对空格不敏感
# 各变量不需要再用$前缀,位置参数除外
COUNT=$(( COUNT + 5+MAX * 2 - $2))

# 除法取整数部分。不支持浮点运算
$ echo $((- 7/- 3))
2
     
# 多条表达式可以通过逗号级联
# 逗号运算符返回的是它右边的值,这里是3
$ echo $(( X+=5 , Y*=3 ))
3
     
# 双括号内的括号和星号已经是算术运算符的本义了
# 所以不需要再用\进行转义
Y=$(( ( X + 2 ) * 10 ))
    
# 双括号与let等效
# let语句的整条表达式内不能有空格
let Y=(X+2)*10

运算符【全表】

算术运算符 描述 用法 等价于
= 赋值 a=b a=b
*= a*=b a=(a*b)
/= a/=b a=(a/b)
%= 余数 a%=b a=(a%b)
+= a+=b a=(a+b)
-= a-=b a=(a-b)
<<= 左位移 a<<=b a=(a<<b)
>>= 右位移 a>>=b a=(a>>b)
&= 按位与 a&=b a=(a&b)
^= 按位异或 a^=b a=(a^b)
l= 按位或 al=b a=(alb)

三、测试/流程控制

测试 [...] [[...]]

  • 测试体有三类

    • file 文件
    • string 字符串
    • expr 算术表达式
  • 使用说明

    • 运算符分为单目,和双目两种
    • 各运算符,用于条件测试,或在结构体[ ... ]和[[ ... ]]内使用
    • 多个运算符之间可以通过-a(逻辑与)和-o(逻辑或)进行级联
    • 也可以成组地用于转义过的( \( ... \) )内部
    • 用于字符串比较的<和>,以及结构体[[ ... ]],在bash 2.0版之前不可用
    • 正则表达式=~,只适用于bash 3.0及后续版本的结构体[[ ... ]]内部

测试运算符【全表】

测试运算符 真值
-a file 文件存在, 弃用, 同 -e
-b file 文件存在,且为 块设备
-c file 文件存在,且为 字符设备
-d file 文件存在,且为 目录
-e file 文件存在; 同 -a
-f file 文件存在,且为 常规文件
-g file 文件存在 且已 设置setgid位
-G file 文件存在,且由 有效组id拥有
-h file 文件存在,且为 符号链接, 同 -L
-k file 文件存在 且已 设置粘滞位
-L file 文件存在,且为 符号链接, 同 -h
-N file 文件自从上次读后做过修改
-O file 文件存在,且由 有效用户id拥有
-p file 文件存在,且为 管道或命名管道(FIFO文件)
-r file 文件存在,且为 可读
-s file 文件存在,且为 非空
-S file 文件存在,且为 套接字
-t N 文件描述符N指向终端
-u file 文件存在 且已 设置setuid位
-w file 文件存在,且为 可写
-x file 文件存在,且为 可执行, 或是可搜索的目录
fileA -nt fileB fileA 文件修改时间 晚于 fileA
fileA -ot fileB fileA 文件修改时间 早于 fileA
fileA -ef fileB fileA 和 fileB 指向同一文件
-n string 字符串非空
-z string 字符串长度为零
stringA = stringB stringA 匹配 stringB (POSIX版)
stringA == stringB stringA 匹配 stringB
stringA != stringB stringA 不匹配 stringB
stringA =~ regexp stringA 匹配 扩展正则表达式regexp
stringA < stringB stringA 小于 stringB 字典序
stringA > stringB stringA 大于 stringB 字典序
exprA -eq exprB exprA 等于 exprB
exprA -ne exprB exprA 不等于 exprB
exprA -lt exprB exprA 小于 exprB
exprA -gt exprB exprA 大于 exprB
exprA -le exprB exprA 小于等于 exprB
exprA -ge exprB exprA 大于等于 exprB
exprA -a exprB exprA 真 且 exprB 真
exprA -o exprB exprA 真 或 exprB 真

条件

单分支 if...then...else

  • 基本结构
#紧凑的写法:
if list; then list; [ elif list; then list; ] ... [ else list; ] fi
    
#更具可读性的写法:
if (( $# < 3 ))
then
    # 分支1
    exit 1
elif (( $# > 3 ))
then
    # 分支2
    exit 2
else
    # 参数个数等于3时。。
fi
  • 测试体
# 1. 方括号: 
# 主要用于文件测试
if [ -d file ]
    
# 也可用于简单的算术测试
# 如果算术表达式本身含括号,则还需要对括号进行转义或加引号区别
# 这时就不如用双圆括号方便
if [ $# -lt 3 ]
    
# 2. 双圆括号: 
# 只限于算术表达式
if (( $# < 3 ))
    
# 3. 命令:
# 各种命令的exit返回值可用于测试:
if ls; pwd; cd $1;
then
    echo success;   # cd成功时
else
    echo failed;    # cd失败时
fi
  • 【应用】逆波兰表达式
【摘自百度百科】逆波兰表达式(Reverse Polish Notation),简称为RPN,由J. Lukasiewicz (12/21/1878 – 02/13/1956)发展而来,在避免使用括号的情况下,完成表达式的有优先级的运算。RPN表达式由操作数(operand)和运算符(operator)构成,不使用括号,即可表示带优先级的运算关系,但是须使用元字符,如空格。一般在计算机中,使用栈操作进行RPN表达式的计算。遇到操作数就入栈,遇到运算符,就对当前栈顶元素进行相应的一元或者二元运算。
#!/bin/bash
# rpncalc.sh
#
# 实现简单的RPN命令行整数计算
# 普通写法:(5+4)*2
# 逆波兰写法:5 4 + 2 *
#
    
# 先检查参数个数是否正确(不能小于3,或者为偶数)
if [ \( $# -lt 3 \) -o \( $(($# % 2)) -eq 0 \) ]
then
    echo "用法: ./rpncalc 操作数 操作数 运算符 [ 操作数 运算符 ] ..."
    echo "乘法用x或*表示"
    exit 1
fi
    
ANS=$(($1 ${3//x/*} $2))    # 用x或*通配乘法
shift 3
    
# 循环直至参数耗尽,返回最终结果$ANS
while [ $# -gt 0 ]
do
    ANS=$((ANS ${2//x/*} $1))
    shift 2
done
echo $ANS
如果不喜欢逆波兰表达式,可以有另一种方案:
function calc
{
    awk "BEGIN {print \"结果: \" $* }";
}
这个函数通过awk内置的计算功能实现,且支持浮点数。比较使用,可以存到/etc/bashrc或~/.bashrc
$ calc 2 + 3 + 4.5
结果: 9.5
    
# 括号和乘号因为是bash的元字符,所以需要用\取消转义
$ calc \(2+2-3\)\*4
结果: 4
    
# 或者直接将整个表达式包裹进单引号内
$ calc '(2+2-3)*4.5'
结果: 4.5

多分支 case...in

case语句的强大,归功于右半括号):不光可以进行简单的字符串比较,还可以实现复杂的模式匹配
  • 基本结构
case $FN in
    *.gif) gif2png $FN
        ;;  # 双分号告诉bash,该分支到此结束(break)
    *.png) pngOK $FN
        ;;
    *.jpg) jpg2gif $FN
        ;;
    *.tif | *.TIFF) tif2jpg $FN
        ;;
    *) printf "格式无法识别: %s" $FN # 都不匹配时执行这里(default)
        ;;
esac 
    
# esac比end-case省字符
# 等价于:
    
if [[ $FN == *.gif ]]
then
    gif2png $FN
elif [[ $FN == *.png ]]
then
    pngOK $FN
elif [[ $FN == *.jpg ]]
then
    jpg2gif $FN
elif [[ $FN == *.tif || $FN == *.TIFF ]]
then
    tif2jpg $FN
else
    printf "格式无法识别: %s" $FN
fi
    
# elif比elseif省字符

循环

while

bash不同于其他编程语言,比如c或java,的地方是:当循环测试体返回零值时,判断为真。因为在bash的语境中,零代表一切正常,非零才代表异常。这点与其他语言正好相反。
  • 基本结构
# 1.无限循环
# 注意:因为expr非零,所以((expr))为零,所以while判断为真
while (( 1 ))
{
    ...
}
    
# 2.算术测试
# 各变量前无需再加$前缀
while (( COUNT < MAX ))
do
    some stuff
    let COUNT++
done
    
# 3.文件测试
# 方括号用法同if语句
while [ -z "$LOCKFILE" ]
do
    循环体
done
    
# 4.读取文件输入
# 文件末尾的值是-1,此时while非真,退出循环
while read lineoftext
do
    process $lineoftext
done
  • 如何将文件传给while-read循环
    
# 1.文件重定向给脚本
$ 脚本 <文件
    
# 2.对于固定的文件,可以直接在脚本内做重定向
while read lineoftext
do
    循环体
done < 文件
    
# 3.或者通过管道的方式
# 注意:管道左边的cat和右边的整个循环体都将是独立的子进程
# 循环内定义的任何变量,出循环体后都无法继续使用了
# 反斜线\进行分行,使结构更具可读性
cat 文件 | \
while read lineoftext
do
    循环体
done
  • 【应用】清理垃圾文件
当使用版本控制命令svn查看一个工作目录内的文件变动时,常见的输出格式如下。
每个文件的前缀标识:M有修改、A新添加、?无法识别。
标识?的文件通常为运行后留下的临时或其他碎片文件。
$ svn status bcb
M      bcb/amin.c
?      bcb/dmin.c
?      bcb/mdiv.tmp
A      bcb/optrn.c
M      bcb/optson.c
?      bcb/prtbout.4161
?      bcb/rideaslist.odt
?      bcb/x.maxc
$
有两种方法清理这些垃圾文件:
  • 方法一
# svn输出|grep取前缀为?的行|cut取出文件名|rm删除
    
svn status mysrc | grep '^?' | cut -c8- | \
    while read FN; do echo "$FN"; rm -rf "$FN"; done
  • 方法二
# 因为bash根据空格作为参数分隔符
# 所以,分别用两个参数TAG和FN接收前缀标识和文件名
    
svn status mysrc | \
while read TAG FN
do
    if [[ $TAG == \? ]]
    then
        echo $FN
        rm -rf "$FN"
    fi
done

for

for常用于需要计数的循环
  • 基本结构
# 采用类c的风格
for (( expr1 ; expr2 ; expr3 )) ; do list ; done
    
# 支持多变量
for (( i=0, j=0 ; i+j < 10 ; i++, j++ ))
do
    echo $((i*j))
done
  • 浮点数循环
# 可以结合seq命令实现浮点数循环
# 要注意seq的参数顺序,递增量是写在中间的
    
$ seq 1.0 .03 1.1
1.00
1.03
1.06
1.09
# for结构
    
for fp in $(seq 1.0 .01 1.1)
do
    echo $fp; 其他语句
done
    
# 先通过一个$()子进程展开seq列表,全部展开完了再传给for处理
# 原seq列表内的"换行"都被子进程替换为了空格
# 这样,每一个浮点值,传给for的时候都变成了字符串 "1.01" "1.02"
# while结构
    
seq 1.0 .01 1.1 | \
while read fp
do
    echo $fp; 其他语句
done
    
# seq与while通过管道|连接,各自为独立的子进程,是并行的关系
# 这样,对于特别大而耗时的序列,seq的处理不会阻塞while循环
# 而前一个的for结构,是串行的关系,存在阻塞的隐患
  • 早期的循环风格
# 早期的bash, for循环只能写成展开的形式
# 类c的风格是从版本2.04往后才有的
    
for i in 1 2 3 4 5 6 7 8 9 10
do
    echo $i
done

select...in

select可以对一个列表进行循环选择,实现简单的目录功能
$ cat dblist
testDB
simpleInventory
masterInventory
otherDB
#!/bin/bash
# dbinit.sh
DBLIST=$(cat dblist)
select DB in $DBLIST
do
    echo 初始化数据库: $DB
    # 其他操作
done
$ ./dbinit.sh
1) testDB
2) simpleInventory
3) masterInventory
4) otherDB
#? 2
初始化数据库: simpleInventory
#?
$
select会对列表各项添加数字编号。以#?作为提示输入符,无限循环列表,直到用户按Ctrl+D结束输入并退出。对于系统默认的#?提示符,可以通过修改PS3变量进行个性化。(注:PS1就是标准控制台的输入提示符,PS2是跨行输入时显示的提示符。)
#!/bin/bash
# dbinit.sh
PS3="0 inits >"
select DB in $DBLIST
do
    if [ $DB ]
    then
        echo 初始化数据库: $DB
        PS3="$((i++)) inits >"
        # 其他操作
    fi
done

jimhs
17 声望1 粉丝