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》,进阶学习强烈推荐