shell 编程

Table of Contents

1. 终端快捷键

下面我总结了常用的快捷键:

移动:

Alt+b 前移一个单词
Alt+f 后移一个单词
Ctrl+e(End) 移动到行尾
Ctrl+b 往左移动一个字符
Ctrl+f 往右移动一个字符
Esc+b 往左移动一个单词
Esc+f 往右移动一个单词

删除字符:

Del 或 Ctrl+d 删除光标所在位置的字符
Back Space(Ctrl+h) 删除光标所在字符的前一个字符
Esc+d 删除一个单词
Ctrl + 退格、Ctrl+w 删除前一个单词
Alt+d 删除后面单词,删除单词后,命令保存到内存中的,使用 Ctrl+y 粘贴
Alt+y 循环粘贴

删除行:

Ctrl+k 由光标开始,删除右边至行尾的所有字符
Ctrl+u 由光标开始,删除左边至行首的所有字符
Ctrl+a、Ctrl+k 删除整行

复原操作:

Ctrl+y 把之前删除的字符或字符串复制到光标所在位置(Alt+y 可以循环粘贴)

重复执行:

Esc+N(重复次数) 重复操作 n 次
Alt+. 上一条命令的最后参数
Alt+<n> 上一条命令第 n 个参数,这里的“参数”和命令接受的“参数”不一样,这里的参数是上条命令按空格分割开的。比如要使用上条命令第二个参数:Alt+4、Alt+.

搜索历史:

Ctrl+r 搜索执行过的命令
Ctrl+p 调出前一个命令
Ctrl+n 调出下一条命令
Esc+< 调出第一个历史命令
Esc+> 移到最后个历史命令后面,即等待键入指令的现行命令行

补全:

Tab 或 Esc+.(或 _ )  
Esc+@ 补全主机名
Esc+~ 补全用户名
Esc+$ 补全变量名

其他:

Ctrl+l 清屏
Ctrl+s 冻结当前输入,Ctrl+q 解冻

2. 配置文件

/etc/profile,不要去修改,因为可能升级系统时被覆盖掉;如果需要所有用户共享,把脚本放在 /etc/profile.d 下最好。

几个概念:

  • 登录 shell:登录shell时输入帐号和密码,这是登录 shell
  • 非登录 shell:用 bash 命令运行 shell 脚本
  • 交互式 shell:就是交互式输入命令

区别在于,“登录shell”会尝试读取:/etc/profile、~/.bashprofile、~/.bashlogin 和 ~/.profile。

3. 通配符过滤

# *:匹配零个或多个
# 和同事开玩笑的正确写法:
# Linux 中 必须写为 rm -rf --no-preserve-root / 才可以,所以改为 /* 才能避免该参数
rm -rf /*

# 以前公司服务器上有位同事的脚本中有段以下逻辑,但 SITE 变量和 rm -rf 之间还隔着几十行代码,出于某种原因我把 SITE 变量写成了空字符串,结果从根目录开始,所有我有权限的文件都被删干净了:
SITE="xx"
DIR="$SITE"
mkdir ${DIR}
rm -rf ./$DIR

# ========================================
# ?:匹配单个字符,示例,显示 202x 的文件:
ls 202?

# ========================================
# 花括号扩展:{a,b,c}
# 逗号分割,但中间不能加空格
# 比如以下命令,首先会扩展 a,然后依次是 b、c、d:
echo a{b,c,d}                   # => ab ac ad

# 示例1,假设有这些文件夹:
# 2012  2013  2014  2015  2016  2017  2018  2019  2020  2021  2022
# 列出 2015 ~ 2018 的内容:
ls 201{5,6,7,8}

# 示例2,生成备份文件的小技巧:
# 因为第一个元素为空,会被展开成:cp file file.bak
cp file{,.bak}

4. 变量

# 定义变量语法如下:
var=value
# shell 会把空格当作命令参数,所以“=”之间是不能有空格的,如果你写成:
var = value                     # !错误示范
# shell 会把 var 当作一个命令,“=”和“value”是它的参数。在 shell 中需要警惕空格。

# 在 shell 中赋值的变量叫作 shell 变量,变量只对当前 shell 有效,所以另起一个终端是无法访问的,要全局访问的变量叫“环境变量”

# 变量不区分类型,所以字符串也不需要加引号
# 但是,变量值如果要用空格,请用反斜杠转义:
msg=hello\ world
# 不过一般都用引号包围,方便、美观:
msg='hello world'

# 如果要在单引号字符串中引用单引号字符,不能用“\'”这样的转义,正确写法是“'\''”:
echo 'I'\''m lu4nx'             # => I'm lu4nx

# 以下两种方式都可以引用变量:
${var}
$var

# 作为一个复杂的示例,分割字符串:
s="hello world"
echo ${s:0:5}                   # => hello

5. 一切为字符串

shell 对所有输入都当作字符串,也就是 shell 是没有整数、浮点数这些数据类型的,更无法直接做运算,若是直接像其他编程语言一样做四则表达式:

$ 1+1 # shell 把它当作一个命令来执行
bash: 1+1: 未找到命令
$ 1 + 1 # shell 把“1”当作了命令,“+”和“1”都当作了参数
bash: 1: 未找到命令

如果要做四则运算,则需要借助外力:

# 方法1,expr 命令,但是不能用浮点数
expr 1 + 1 # => 2

# 方法2,Bash 内置的 let 命令:
let a=1+1
echo $a                         # => 2

# 方法3,bc 命令,比较推荐,因为它支持复杂的运算和浮点数
echo 5.1 + 1 | bc               # => 6.1

# 方法4,$[…]
echo $[ 1 + 3 ]                 # => 4

# 方法5,$((…))
echo $((1+1))                   # => 2

5.1. Here Document

多行字符串:

COMMAND <<EOF
  ...
  ...
  ...
EOF

# 如果要输出到文件:

COMMAND <<EOF > outfile
  ….
  ….
  ….
EOF

6. 管道

shell 最多允许用 9 个句柄,其中 0~2 已分配给预留的 3 个文件描述符:

句柄号 含义
0 标准输入,stdin
1 标准输出,stdout
2 标准错误,stderr
# |,一个进程的标准输出作为另外一个进程的标准输入,例:
ps ux | fgrep chrome

# >,重定向输出到文件,新建或覆盖已有文件,例:
ps ux > processes

# >>,重定向输出文件,新建文件或追加到文件,例:
ps ux >> processes

# 2>file,重定向标准错误到文件,例:
find / -name 'data' 2>err.log

# &>,标准输出、标准错误一同输出,例:
find / -name 'data' &>out.log

# >&,统一输出,几个示例:
find / -name 'data' >&1         # 输出内容统一到标准输出
find / -name 'data' >&2         # 输出内容统一到标准错误
find / -name 'data' 2>&1        # 标准错误重定向到标准输出
find / -name 'data' 2>&-        # “>&-”表示关闭句柄,这个示例中表示关闭标准错误句柄

# 重定向输入,例,文件 data 作为 cat 的输入:
cat < data

# 丢弃输出结果:
ps aux > /dev/null

# 标准输出和标准错误都用于输出消息,之所以要分开区分,是为了在重定向时过滤掉无用的消息,比如一些警告消息可以输出到标准错误,而不影响到标准输出。
# 例,将错误信息重定向到 err.log、输出内容重定向到 out.log:
find / -name 'data' 2>err.log 1>out.log

# 将多条命令的输出重定向到一个文件中:
cat<<EOF > output.txt
$(
cat /etc/passwd
echo ================================
cat /etc/hosts
)
EOF

7. 数组

# 构造一个数组
array=(item1 item2 item3)
# 元素索引
echo ${array[0]}
# 更新数组元素
array[0]=xx

# 元素遍历
for item in ${array[@]}
do
    echo $item
done

# 获取整个数组内容:
echo ${array[*]}

# 取数组长度:${变量名[*]}或${变量名[@]}
echo ${#array}                  # => 3
# 这个方法也可以取字符串长度
msg='hello world'
echo ${#msg}                    # => 11

8. 条件判断

if [ 表达式 ]; then
    ...
elif [ 表达式 ]; then
    ...
else
    ...
fi

if 只能根据命令返回状态码做判断。表达式组合:

[ expr ] # 表达式为真
[ ! expr ] # 表达式不为真
! [ expr ] # 同上
[ expr1 -a expr2 ] # 类似:if(expr1 && expr2)
[ expr1 -o expr2 ] # 类似:if(expr1 || expr2)
# 复合测试
[ ... ] && [ ... ]
[ ... ] || [ ... ]

8.1. [ 和 test 的区别

“[ .. ]”之间是有空格分开的,不能紧凑地写在一起,因为“[”实际上是一个系统命令,后面提供的都是参数:

$ which [
/usr/bin/[

“[”之后的都是命令的参数,所以需要空格分割。为了统一,需要用“]”作为最后一个参数。

“[”其实和 test 命令是等价的,记不住表达式的话,可以:man test。

8.2. 条件表达式

字符串比较:

string1 = string2	如果两个字符串相同则结果为真
string1 != string2	如果两个字符串不同则结果为真
-n string	如果字符串不为空则结果为真
-z string	如果字符串为null(一个空串)则结果为真

算术比较:

expr1 -eq expr2	如果两个表达式相等则结果为真
expr1 -ne expr2	如果两个表达式不等则结果为真
expr1 -gt expr2	如果expr1大于expr2则结果为真
expr1 -ge expr2	如果expr1大于等于expr2则结果为真
expr1 -lt expr2	如果expr1小于expr2则结果为真
expr1 -le expr2	如果expr1小于等于expr2则结果为真
! expr	如果表达式为假则结果为真,反之亦然

文件条件测试:

-d file	如果文件是一个目录则结果为真
-e file	如果文件存在则结果为真。要注意的是,历史上-e选项不可移植,所以通常使用的是-f选项
-f file	如果文件是一个普通文件则结果为真
-g file	如果文件的set-group-id位被设置则结果为真
-r file	如果文件可读则结果为真
-s file	如果文件的大小不为0则结果为真
-u file	如果文件的set-user-id位被设置则结果为真
-w file	如果文件可写则结果为真
-x file	如果文件可执行则结果为真

8.3. case-in-esac

# case-in-esac 常常出现在写脚本,处理参数时
case $1 in
    msg)
        echo $2;;
    *)
        echo default
esac

8.4. 双方括号——模式匹配

reg='systemd-private-[0-9a-zA-Z]+-([a-zA-Z]+)\.service-[0-9a-zA-Z]+'
for f in /tmp/*
do
    # [[ ... ]] 提供模式匹配,示例如下:
    if [[ $f =~ $reg ]]; then
        # 显示后台服务的进程名
        echo ${BASH_REMATCH[1]}
    fi
done

9. 循环

9.1. for 循环

# 标准结构:
for var in list
do
    commands
done

# 其实“list”参数只是特定分割符的分开的元素,默认的分割符号有空格、Tab 和换行符号,所以下面两条命令是等价的:
for i in $(echo -n "1\t2\t3"); do echo $i; done
for i in $(echo -n "1\n2\n3"); do echo $i; done

# 示例:
for i in 1 2 3; do echo $i; done

# 有时候想控制分割符,直接修改IFS即可:
str='a,b,c'
IFS=$','
for i in $str
do
    echo $i
done

# 比如要处理当前目录下所有 .txt 文件,结合通配符即可:
for i in *.txt; do do_something; done
# 或者有时需要根据其他命令的输出来处理,例:
for pid in $(pgrep vim); do kill $pid; done

# for 循环的 C 语言风格版,很少使用:
for (( i=1; i<=10; i++))
do
    echo $i
done

9.2. while 和 until

while test command
do
    other commands
done

# until 和 while 类似,但相反。
# break 和 continue 可以控制循环,与 C 语言中类似

10. 子程序

# 两种定义函数的方式
function hello {
    echo hello world
}

hello1() {
    echo hello world
}

10.1. 全局变量和局部变量

函数中定义的变量,默认是全区作用域:

function test() {
    a=1024
    echo $a
}

test # => 1024
echo $a # => 1024

如果不小心和全局变量重名了,会导致变量被莫名其妙修改。加 local 关键字可以定义局部变量:

function test1() {
    n=2048
    echo $n
}

test # => 2048
echo $n # 无法输出

10.2. 子程序参数

$n:n 为第几个参数,参数从 $1、$2 … 中取,超过 9 的参数,需要通过 ${…} 引用。

function say {
    echo "you say: $1"
}

$* 和 $@:将参数当作单个参数。区别:

count=0
for arg in "$*"
do
    count=$[ $count + 1 ]
done
echo $count # => 1

count1=0
for arg in "$@"
do
    count1=$[ $count1 + 1 ]
done
echo $count1 # => 3

shift 负责跳过参数:

while [ -n "$1" ]
do
    echo $1
    shift
done

输出如下:

./test.sh 1 2 3 4
1
2
3
4

10.3. 传递数组参数

function hello {
    echo $@
}
myarray=(a b c d)
hello ${myarray[*]}

10.4. 返回数组

function hello {
    local myarray=(a b c d)
    echo ${myarray[*]}
}

newarray=$(hello)
echo ${newarray[*]}

10.5. 参数变量

$N 脚本程序的参数($1, $2, $3 …) $* 在一个变量中列出所有的参数,各个参数之间用环境变量 IFS 中的第一个字符分隔开。如果 IFS 被修改了,那么 $* 将命令行分割为参数的方式就将随之改变 $@ 它是 $* 的一种精巧的变体,它不使用 IFS 环境变量,所以即使 IFS 为空,参数也不会挤在一起

11. shell 脚本

“#!”出现在 Linux/Unix 脚本第一行,称作为“Shebang”。源于发音符号“#”(读作 Sharp)和“!”(读作 Bang),合起来就是 Sharp-bang,“Shebang”是“Sharp-bang”的缩写。

11.1. 接收参数

查找参数的三种常见方式。

第一种,缺点是无法处理多个参数:

if ! [ -n "$1" ]
then
    echo no args
    exit
fi

case $1 in
    start)
        echo starting...;;
    stop)
        echo stoping...;;
    status)
        echo ok;;
    *)
        echo "I don't known"
esac

第二种,使用getopt:

set -- $(getopt -q ab:cd "$@") # -- 表示参数结束

while [ -n "$1" ]
do
    case "$1" in
        -a) echo "Found the -a option" ;;
        -b) param="$2"
            echo "Found the -b option, with parameter value $param"
            shift ;;
        -c) echo "Found the -c option" ;;
        --) shift
            break ;;
        *) echo "$1 is not an option";;
    esac
    shift
done
#
count=1
for param in "$@"
do
    echo "Parameter #$count: $param"
    count=$[ $count + 1 ]
done

第三种,使用比getopt更高级的getopts:

while getopts :ab:c opt
do
    case "$opt" in
        a) echo "Found the -a option" ;;
        b) echo "Found the -b option, with value $OPTARG";;
        c) echo "Found the -c option" ;;
        *) echo "Unknown option: $opt";;
    esac
done

11.2. 模块化

把代码拆分到不同的文件,用 source 或者“.”加载其他文件:

source 文件
. 文件

12. 环境变量

所有通过当前 shell 运行的进程都可以访问的变量就是环境变量,环境变量一般用于对某些程序做一些“个性设置”,把信息存在内存中,方便程序和脚本读取。

# 通过 export 关键字,可以定义一个环境变量,例:
export PATH=${PATH}:~/bin

# 打印环境变量的两个命令:
printenv
env

一些典型环境变量:

# EDITOR,设置默认编辑器设置
# 设置后,使用 Ctrl+x+e 组合快捷键可以打开对应的编辑器。
export EDITOR=emacs

# IFS,设置默认分割符,该变量直接影响了 for 循环、read 等,如:
IFS=':'
read $r1 $r2 $r3 $r4 $r5 $r6 $r7 < /etc/passwd
# 因为 passwd 用了“:”将内容分割成了 7 段,所以这样就可以将 7 段的值分别存到了 7 个变量中了。

# PS1,命令提示符,通常是“$”,你可以使用一些更复杂的值,例:
# 给出用户名、机器名和当前目录名和“$”
PS1='[\u@\h \W]$'

# 还有一些常见的:
# $PS2,二级提示符,用来提示后续的输入,通常是>字符
# $#,传递给脚本的参数个数,字符串中引用:${!#}
# $$,shell 脚本的进程号,脚本程序通常会用它来生成一个唯一的临时文件,如 /tmp/tmpfile_$$
# $!,上一个被执行的命令的 PID(后台运行的进程)
# $?,上一个命令的退出状态(管道命令使用 ${PIPESTATUS})
# $UID,当前用户的 UID
# $_,上一条命令的最后一个参数,放在脚本顶端,可以取到当前脚本的路径(因为这时最后一条命令就是shell执行脚本的命令,最后一个参数自然是文件路径

13. 高级技巧

13.1. trap

假设有一个 shell 脚本,它在中途会产生一些临时文件,但在工作作期间突然被中断执行了,那这些临时文件是不能自动清理的。有了 trap,就可以捕获中断信号,然后做相应的文件清理。

trap 可以想象为 shell 的异常处理,trap 根据捕获进程信号来执行指定的命令。

# 用法:
# trap command 信号
# Linux 具体的信号可以通过 man 获得: man 7 signal。只是 trap 命令写信号时不需要“SIG”开头。

# 示例,如果 Ctrl+c 中断程序运行后,打印一个“bye”
# 比如有时我们的脚本会产生中间文件,想在中途被中断时自动删除,就可以用这个逻辑
# 注意 trap 的位置,要在写在被“保护”的代码之前。

trap 'echo bye' INT

while (true); do
    echo '1'
done

13.2. eval

将字符串交给 shell 再次扫描并执行,比如x文件的内容:

$ cat x
ls /etc

然后用 eval 可以让 x 里的内容再次执行:

eval $(cat x)

例,远程另外一台机器登录我的笔记本,无法 ssh-add:

$ ssh-add
Could not open a connection to your authentication agent.

这个时候需要执行 ssh-agent 的内容:

$ ssh-agent
SSH_AUTH_SOCK=/tmp/ssh-mVzghWGagC5W/agent.11859; export SSH_AUTH_SOCK;
SSH_AGENT_PID=11860; export SSH_AGENT_PID;
echo Agent pid 11860;

便可以用 eval:

$ eval $(ssh-agent)
Agent pid 11864

谨慎使用 eval,eval 会产生一定的安全隐患,如果输入源不可控就有可能产生任意命令执行漏洞。

13.3. set

-e:脚本执行时一出错就中断执行 -u:引用了不存在的变量时报错

set –:将参数分别设置到$1、$2等变量中

13.4. 过程替换

语法:<(命令)

当调用“<(…)”时,会在产生一个文件,文件内容就是中间命令输出的内容。

场景1,给 spark-shell -i 传递参数

spark-shell -i 可以指定 Scala 脚本文件名,加载并执行。但某些情况下需要传递参数,例如传递日期。

if [ $# -eq 1 ]
then
    year=$(expr substr $1 1 4)
    month=$(expr substr $1 5 2)
    day=$(expr substr $1 7 2)
else
    year=$(date +'%Y' -d '-1days')
    month=$(date +'%m' -d '-1days')
    day=$(date +'%d' -d '-1days')
fi

spark-shell -i <(
    echo "val yyyyMMdd = ${year}${month}${day}";
    echo "val yyyyMM = ${year}${month}";
    cat main.scala
)

场景2,省去中间结果文件

有一个文件 a,我需要对某条命令执行结果和文件 a 做 diff 对比,一般是将命令执行结果写到一个中间文件中再执行 diff,但可以用过程替换给省去中间结果文件:

diff -u a <(command)

13.5. script

script 命令可以将所有 shell 上的显示写入到文件中(默认是 typescript),比如我在 FreeBSD 中编译内核提示出错,就可用 script 命令将内核编译时打印输出内容全部写入到某个文件里(如果不指定文件,默认记录到 typescript 文件中),然后再在这个文件中找出错点即可。

例:

script /tmp/log
echo 'hello world'
exit
cat /tmp/log
hello world

exit 即可退出 script 环境,并且 script 会在记录文件中添加结束时间。

13.6. 命令堆栈

用 pushd 把当前目录保存到命令堆栈中:

$ cd /etc/rc.d
$ pushd
~ /etc/rc.d

dirs 命令可以浏览命令堆栈中的内容:

$ dirs
~ /etc/rc.d

popd 可以弹出最近的堆栈目录,然后切换过去:

$ cd /tmp
$ popd
/etc/rc.d
$ pwd
/etc/rc.d

堆栈内容可以移动:

pushd +1
pushd -1

13.7. 调试

bash -x

Bashdb(bashdb.sourceforge.net)+ RoalGUD(https://github.com/realgud/realgud

13.8. shell 安全

由于 shell 简单灵活,一不小心就掉进坑里,我们要时刻注意一些问题,这些问题有可能会造成安全隐患。

shell 脚本静态检查工具:https://github.com/koalaman/shellcheck

13.9. LDPRELOAD 环境变量

在国外网上流传了这么一段脚本,号称运行后就可获得 root 权限:

#!/bin/sh
echo "1|nux r007 3xp10|7 by 1c4m7uf"
cd /tmp
cat >ex.c <<eof
int getuid() { return 0; }
int geteuid() { return 0; }
int getgid() { return 0; }
int getegid() { return 0; }
eof
gcc -shared ex.c -oex.so
LD_PRELOAD=/tmp/ex.so sh
rm /tmp/ex.so /tmp/ex.c

执行结果如下:

bash test.sh
1|nux r007 3xp10|7 by 1c4m7uf
sh-4.3# id
uid=0(root) gid=0(root) 组=0(root),1000(lu4nx) 环境=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
sh-4.3#

这里要注意脚本用到了 LDPRELOAD 环境变量,它可以指定一个动态链接库在加载其他 .so (比如 glibc)之前优先加载。上面的代码实质上编译了一个动态链接库,那段 C 代码重新实现了 getuid、geteuid、getgid 和 getegid,并且全部返回 0(返回 0 表示 root),等同劫持了 getuid 等函数。shell 权限在判断是 root 时,提示符会变成“#”;而 id 命令也是调用了这几个函数。这里实际上只是个假象,并没有真正获得 root 权限,但要注意这个环境变量可能导致其他程序逻辑判断。

13.10. 通配符问题

shell 在遇到字符“*”时,会把它转变成当前目录下全部文件,比如目录下有 a、b 和 c 三个文件,当执行“ls *”时,shell 会将最终执行的命令转变成“ls a b c”。

假如目录下有一个文件名叫“-l”:

$  ls
-l

当执行:ls *时,-l 会被展开成参数:

$  ls *
总用量 0
-rw-rw-r--. 1 lu4nx lu4nx 0 12月  8 10:37 -l

利用这个特性,我们可以把文件名构造得像参数一样,很容易引起安全隐患。

例 1,当前目录下的两个文件:

$ ls
-c  '__import__('\''os'\'').system('\''id'\'')'

当执行 python * 时就会出问题:

$  python *
uid=1000(lu4nx) gid=1000(lu4nx) 组=1000(lu4nx) 环境=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

例 2,当目录下有这几个文件时:

$ ls
-exec  id

执行 find 命令:

$  find * \;
uid=1000(lu4nx) gid=1000(lu4nx) 组=1000(lu4nx) 环境=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
uid=1000(lu4nx) gid=1000(lu4nx) 组=1000(lu4nx) 环境=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
uid=1000(lu4nx) gid=1000(lu4nx) 组=1000(lu4nx) 环境=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

唯一鸡肋的地方是命令必须加“;”,通过对 Bash 的源码调试,发现如果创建了一个叫“;”的文件,通配符展开时,“;”比较靠前,导致无法正确解析。展开后的命令如下:

find ; -exec id

14. 推荐书籍

  • 《Linux Pocket Guide》,中文名《Linux 命令速查手册》,书很薄,掌握其中 80% 以上内容算合格。
  • 《Linux 命令行与 shell 脚本编程大全》
  • 《Advanced Bash-Scripting Guide》,进阶学习强烈推荐