shell 语法

Table of Contents

1 帮助

任何 shell 书籍或者文档都不可能覆盖命令的所有参数使用,在 Linux 中,当需要更多命令帮助时,请使用 man 和 info。

man 是简短的帮助文档,GNU 觉得 man 比较落后,开发了 info,info 具备超链接,以及一些开发文档,内容更加丰富,多数时候通过 man 只能获得大致信息,更详细的可以用 info 查看,所以 info 可和 man 起互补作用。pinfo 是具备 lynx 浏览器风格的 info。

1.1 man

通常会看到一些文档上提示使用man参考帮助信息,格式如:ls(1)。

括号里的数字叫“章节编号”,例如 stat(1) 和 stat(2) 虽然名字一样,但代表了不同的帮助,具体的章节号信息,通过 man man 可以找到。

1.1.1 搜索

-k:按关键字搜索帮助,如:

$ man -k search

whatis 命令也搜索手册的概述,可以用通配符、正则。

1.1.2 更新 man 库

man 中的文档是由 mandb 命令更新。在 crontab 下有一个自动更新的脚本,默认系统会每天自动更新:

/etc/cron.daily/man-db.cron

如果库更新失败,在用 man -k 搜索时就会出错:

$ man -k passwd
passwd: nothing appropriate.

这种情况直接手动运行 mandb 命令更新即可。

1.2 其他命令

help:Bash 内置的命令,语法帮助,注意 zsh 中没有该命令。

2 快捷键

我们会有很大一部分时间是在 shell 中生活,掌握了快捷键可以大大提高效率,下面我总结了常用的快捷键,掌握全部则大大提高你的工作效率。

移动:

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+.(或 _ )  
[email protected] 补全主机名
Esc+~ 补全用户名
Esc+$ 补全变量名

其他:

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

3 shell 脚本

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

4 通配符过滤

*:匹配全部 ?:代表一个字符

5 子 shell

coproc shell 提供的协程功能,让命令在新生成的子 shell 中执行。

“&” jobs fg

6 环境变量和 shell 变量

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

定义变量的语法如下:

var=value

定义字符串变量:

msg=hello

如果要用空格:

msg=hello\ world

更好的方式是用引号包围:

msg='hello world'

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

$ echo 'I'\''m lu4nx'
I'm lu4nx

shell 会把空格当作命令参数,所以定义变量的“=”之间是不能有空格的,如果你写成:

var = value

shell 会把 var 当作一个命令,“=”和“value”是它的参数。在 shell 中需要警惕空格。

引用变量:

${var}或$var

例,分割字符串:

分割字符串:
$  s="hello world"
$  echo ${s:0:5}
hello

通过 export 关键字,可以定义一个环境变量。

打印环境变量:

printenv env

6.1 IFS

可以使用 IFS 变量设定分割符,例如:

IFS=':'

然后调用:

read $r1 $r2 $r3 $r4 $r5 $r6 $r7 < /etc/passwd

因为 passwd 用了“:”将内容分割成了 7 段,所以这样就可以将 7 段的值分别存到了 7 个变量中了。

6.2 EDITOR,默认编辑器设置

export $EDITOR=vim

使用 Ctrl+x+e 组合快捷键可以打开默认编辑器(对 zsh 无效)。

6.3 重要的环境变量

$PS1    命令提示符,通常是$字符,但在 bash 中,你可以使用一些更复杂的值。例如,字符串 [\[email protected]\h \W]$ 就是一个流行的默认值,它给出用户名、机器名和当前目录名,当然也包括一个$提示符
$PS2    二级提示符,用来提示后续的输入,通常是>字符
$IFS        输入域分隔符。当 shell 读取输入时,它给出用来分隔单词的一组字符,它们通常是空格、制表符和换行符
$0      shell 脚本的名字,等同与 SHELL 变量,名字取决于运行方式:./xx.sh、/path/xx.sh 和 bash xx.sh,一般配合 basename 使用
$#      传递给脚本的参数个数,字符串中引用:${!#}
$$      shell 脚本的进程号,脚本程序通常会用它来生成一个唯一的临时文件,如 /tmp/tmpfile_$$
$!      上一个被执行的命令的 PID(后台运行的进程)
$?      上一个命令的退出状态(管道命令使用 ${PIPESTATUS})
$UID 当前用户的 UID
$_ 上一条命令的最后一个参数,放在脚本顶端,可以取到当前脚本的路径(因为这时最后一条命令就是shell执行脚本的命令,最后一个参数自然是文件路径

7 shell 初始化

几个相关文件:

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

几个术语:

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

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

8 标准输入/输出/错误

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

0:标准输入,stdin 1:标准输出,stdout 2:标准错误,stderr

标准输出和标准错误都用于输出消息,之所以要分开区分,是为了在重定向时过滤掉无用的消息,比如一些警告消息可以输出到标准错误,而不影响到标准输出。

<:重定向输入 >:重定向输出,新建或覆盖 command 2>test:重定向标准错误 >>:重定向追加 &>:标准输出、标准错误一同输出 >&1、>&2:输出内容统一到标准输出和标准错误 command 2>&1 或 command &>,标准错误重定向到标准输出 exec 3>&-:&-表示关闭文件句柄

将多条命令的输出重定向到一个文件中:

cat<<EOF > output.txt
$(
cat /etc/passwd
echo ================================
cat /etc/hosts
)
EOF

8.1 tee

可以将内容同时输出到两个地方,从标准输入读入数据并输出到标准输出以及文件中。

例,读入数据并写入到其他文件中:

cat /etc/hosts | tee /tmp/hosts.bak

例,把输出的内容用sudo追加到文件中:

echo -n "127.0.0.1\twww.baidu.com" | sudo tee -a /etc/hosts

要用echo xxx > /etc/xxx 这种需要 root 权限的来说,得这样:

echo xxx | sudo tee --append /etc/xx

8.2 read

交互式输入:

read name
echo $name

9 一切为字符串

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

$ 1+1
bash: 1+1: 未找到命令
$ 1 + 1
bash: 1: 未找到命令

第一条语句,shell 把它当作一个命令来执行;第二条语句有了空格分割,shell 把“1”当作了命令,“+”和“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

双括号还支持很多丰富的运算:

val++
val--
++val
--val
!
~
**
<<
>>
&
|
&&
||

9.1 Here Document

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

如果要输出到文件:

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

10 数组

数组在日常的 shell 编程中用得不太多。

array=(item1 item2 item3)
# 索引数组
echo ${array[0]}
# 更新数组元素
array[0]=xx
# 遍历
for item in ${array[@]}
do
    echo $item
done

取数组长度:\({变量名[*]}或\){变量名[@]}

${test[*]}:获取整个数组内容

array=(item1 item2 item3)
echo ${#array}

# 也可以取字符串长度
msg='hello world'
echo ${#msg}

用 unset 可以删除某些值。

11 子程序

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

hello1() {
    echo hello world
}

11.1 全局变量和局部变量

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

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

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

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

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

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

11.2 子程序参数

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

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

$* 和 [email protected]:将参数当作单个参数。区别:

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

count1=0
for arg in "[email protected]"
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

11.3 传递数组参数

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

11.4 返回数组

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

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

11.5 参数变量

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

12 条件判断

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

if 只能根据命令返回状态码做判断。

表达式组合:

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

12.1 [ 和 test 的区别

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

$ which [
/usr/bin/[

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

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

12.2 条件表达式

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

算术比较 expression1 -eq expression2 如果两个表达式相等则结果为真 expression1 -ne expression2 如果两个表达式不等则结果为真 expression1 -gt expression2 如果expression1大于expression2则结果为真 expression1 -ge expression2 如果expression1大于等于expression2则结果为真 expression1 -lt expression2 如果expression1小于expression2则结果为真 expression1 -le expression2 如果expression1小于等于expression2则结果为真 ! expression 如果表达式为假则结果为真,反之亦然

文件条件测试 -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 如果文件可执行则结果为真

12.3 case-in-esac

case $1 in
    msg)
        echo $2;;
    *)
        echo default
esac

12.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

13 循环

13.1 for循环

语法:

for var in list
do
    commands
done

示例:

for i in 1 2 3; do echo $i; done

for结合ls命令的应用:

for file in $(ls);do echo $file; done

但是,有些环境中,ls命令默认给别名化时带了-F参数,如果这样列文件的话,会有问题:

$ for file in $(ls -F);do echo $file; done
deploy/
html/
Makefile
Makefile.el
src/
static/
templates/
tool/

输出的内容包含了表示文件类型的字符。列文件最好的方式:

for file in *; do echo $file; done

for循环的C语言风格版:

for (( i=1; i<=10; i++))
do
    echo $i
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

有时候想控制分割符,直接修改IFS即可:

str='a,b,c'
IFS=$','
for i in $str
do
    echo $i
done

13.2 while和until

语法:

while test command
do
    other commands
done

until和while类似,但相反。

13.3 循环控制

break和continue。

14 模块化

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

15 脚本化

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

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

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 "[email protected]") # -- 表示参数结束

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 "[email protected]"
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

16 高级技巧

16.1 trap

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

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

命令格式:

trap command 信号

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

例:

trap 'echo bye' INT

while (true); do
    echo '1'
done

当 Ctrl+c 中断脚本时,会执行 echo bye。注意 trap 的位置,要在写在被“保护”的代码之前。

16.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 会产生一定的安全隐患,如果输入源不可控就有可能产生任意命令执行漏洞。

16.3 set

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

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

16.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)

16.5 script

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

例:

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

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

16.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

16.7 调试

bash -x

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

16.8 shell 安全

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

16.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 权限,但要注意这个环境变量可能导致其他程序逻辑判断。

16.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