6 分钟阅读

本教程分为两篇:

  1. 上篇《Shell 基础知识》:必看,每一个开发者都必须了解的命令行基础知识。
  2. 下篇《Shell 脚本编程》:建议看,了解 shell 脚本编程基础,能够阅读和编写简单脚本工具。

1. 认识 shell

shell 这个概念开始。

Bash 手册这样介绍:

A Unix shell is both a command interpreter and a programming language.

Unix shell 既是命令解释器,也是编程语言。

下面也将从这两个方面进行介绍。

1.1. 作为命令解析器

Shell 是系统上的一个应用程序,用于解析用户命令并交给操作系统执行。

在早期操作系统中,用户只能通过敲击命令的方式进行系统操作,shell 在结构上成为用户与操作系统进行交互的接口,所以它也视为操作系统的外壳(单词 shell 的本意)。

Shell 程序有两种工作模式:交互式非交互式。简单理解,就是对应终端启动和脚本执行。

/etc/shells 文件记录了系统上可使用的 shell 软件,使用 cat 命令查看该文件:

cat /etc/shells
# /bin/bash
# /bin/csh
# /bin/dash
# /bin/ksh
# /bin/sh
# /bin/tcsh
# /bin/zsh

可以看到,一个操作系统上可能内置了多个 shell 软件,其中最常见的两个是:

  • /bin/sh:一个老牌 shell,一般情况下,也是系统的默认 shell。
  • /bin/bash: 在 sh 的基础上增加了一些实用特性,是使用最广泛的 shell。

💡 本文内容以 bash 为准。

你也能根据个人喜好安装第三方 shell,比较受欢迎的有 zshfish,这些也被认为是更现代化的 shell。MacOS 也使用 zsh 作为默认 shell。

💡 关于 shell 的类别及其发展,可以参考 Linux shell 的演进史

许多人容易混淆 shell 与终端(terminal)程序。当我们需要执行命令时,直接打开的程序是终端,终端再与 shell 建立会话连接。可以理解为,终端是命令行环境的外壳,负责高亮显示、窗口管理等;shell 则是核心,负责命令的解析执行。这种结构是历史原因导致的,早期的终端是一个连接到计算机主机上的物理设备,现在使用的终端软件,全名是终端模拟器(terminal emulator),它以软件形式模拟了早期的终端设备。如此,尽管硬件结构上完全不同,但 shell 也不用做出太大的变化。

💡 更多概念辨析,可以参考 What is the exact difference between a ‘terminal’, a ‘shell’, a ‘tty’ and a ‘console’?

1.2. 作为编程语言

Shell 解析器是支持运行脚本文件的,shell 一词也被用于指代它所支持的语言。

Shell 只能说是脚本语言,严格上不能称为编程语言。与 JS、Python 等高级脚本语言相比,它具有以下特点:

  • 简单,体积小。换个角度看,就是低级,不方便。
  • Unix / Linux 内置,几乎没有环境依赖。
  • 擅长系统操作密集任务,无法胜任计算密集的任务。

Shell 最大的优势体现在无环境依赖,如果无法保证执行环境或者不想增加环境依赖,就必须使用 shell 脚本,比如编写部署脚本时,难以保证服务器上安装了 Node 或 Python 环境,用 shell 更合适。

另外,shell 对系统操作也比较友好,很适合一些涉及操作系统的任务自动化。许多系统工具也是使用 shell 编写。

不过,更多时候,编程语言会是一个更好的选择。因为 shell 没有方便的工具库,意味着需要写更多的代码。而且规模管理糟糕,不适合复杂的程序。

2. 命令

命令(command)是 shell 最重要的单元。

2.1. 什么是命令

一般地,命令可以分为以下五种类型:

  • 可执行文件。
  • 别名。
  • shell 内部命令(built-in)。
  • shell 函数。
  • 保留字,如 if。

type 命令可以查看命令属于什么类型,也可以用于查看本机上是否存在该命令:

type ls
# ls is /bin/ls

type if
# if is a shell keyword

type type
# type is a shell builtin

2.2. 命令的执行过程

以一个简单命令为例:

echo  *.txt

  1. (交互式情况下) 接收用户输入,直到检测到用户输入回车。
  2. 解析收到的命令。
    1. 以空格为分隔符,识别到 echo*.txt 两个单词(word)。
    2. 解析第一个单词。这里,echo 不属于变量赋值和重定向符号,标记为命令。
    3. 命令之后的单词 *.txt 识别为参数。
  3. 对参数 *.txt 进行展开。没有引号包裹,可以执行各种展开(见后面章节)。这里,只有文件展开对 *.txt 生效,假设当前目录下有文件 foot.txt 和 bar.txt,那么展开结果为 foot.txt bar.txt
  4. 查找命令。 echo 不含有斜线/,说明不是文件形式,进一步查找到内置命令echo。查找顺序是由内到外的:运行环境中的函数或别名、shell 中的内置命令、PATH 变量路径集合下的外部命令。
  5. 执行命令。调用命令,传递展开后的参数,执行 echo(foot.txt, bar.txt)
  6. 获取命令执行结果。输出 foot.txt bar.txt

当然,实际解析规则和执行过程比这个要复杂得多,上面忽略了一些边缘情况。

2.3. 命令的参数

语法上,命令之后都会被解析为参数,默认以空格为分隔符。可以简单表示成:

command [arg1 [arg2] ... [argN]]

不过,我们习以为常的以 --- 开头的选项,并不属于 shell 的语法,而是规范上的内容,常见的有 POSIX 规范GNU 规范

根据这些规范,大概可以总结成以下几点:

  • 参数之间用空白隔开。
  • --- 开头的参数称为选项(option),其它称为非选项(non-option)或操作数。
  • 长短上,选项可以分为长选项和短选项。长选项以 --long-opt 的格式,短选项以 -o 的格式。
  • 结构类型上,选项可以分为标志型和键-值对型。键-值对的写法有 --key value--key=value
  • 短选项可以组合,-ab 等同 -a -b。短选项与值直接的空白是可选的,-afoo 等同 -a foo
  • 选项之间顺序无关,一般按字母排序。
  • 选项一般放在非选项之前。-- 用于表示所有选项结束,后面都是非选项,比如 --foo -- --bar,--foo 是选项,--bar 是非选项。

不同的命令行工具采用的规范可能不同,上面的规则并不通用,只是传统规约。

2.4. 命令的退出状态码

命令执行退出时会带有一个状态码(exit status code),范围为 0-255,表示命令执行成功与否,0 表示执行成功,非零表示执行失败。

变量 $? 记录了上一条命令的状态码。

false  # 命令 true 和 false 单纯返回状态码 0 和 1
echo $?
# 1

2.5. 命令的语法文档

参考 git 手册的 git push 语法说明写法:

git push [--all | --mirror | --tags] [--follow-tags] [--atomic] [-n | --dry-run] [--receive-pack=<git-receive-pack>]
	   [--repo=<repository>] [-f | --force] [-d | --delete] [--prune] [-v | --verbose]
	   [-u | --set-upstream] [-o <string> | --push-option=<string>]
	   [--[no-]signed|--signed=(true|false|if-asked)]
	   [--force-with-lease[=<refname>[:<expect>]] [--force-if-includes]]
	   [--no-verify] [<repository> [<refspec>…]]

其中用到了许多具有特定意义的符号:

  • [] 表示可选的部分,可以嵌套。
  • | 表示左右两边互斥。
  • < > 表示需要被实际内容替换的部分。
  • ... 表示可以存在多个值。

了解这些可以帮助我们快速看懂命令的语法表示。自己写文档时也可以使用,提升文档的规范性。

3. 组合命令

命令运算符可以把多个命令拼接成一个命令,形成组合命令。

3.1. ; 顺序执行

命令有两种结束标志: 换行和分号 ; 。 使用 ; ,可以做到在一行内编写多个命令,这也可以实现在终端一次性键入多个命令。

command1; command2; command3

需要注意的是,命令的顺序执行,不会因为出错而终止。也就是说,即使上一条命令执行失败了(退出码非 0),后面的命令也会按序执行。

3.2. && 逻辑与

&& 把几个命令通过与逻辑组合在一起,只有前面的命令成功执行,才执行后面的命令,是最常用的命令组合方式。

# 只有当目录创建成功时,才切换到该目录
mkdir my-folder && cd my-folder

运用 && 运算符能方便地实现条件执行,类似 if ... then ...

3.3. || 逻辑或

&& 相反,|| 表示的是或逻辑,只有当前面的命令执行失败时,才执行后面的命令。

结合 &&|| 可以写出 if...else 结构:

true && echo true || echo false
# true

3.4. | 流水线

流水线是种 I/O 重定向功能,可以把上一个命令的输出作为下一个命令的输入。

多条命令就好像连接成一条流水线一样,数据像流水线上的产品,经过多次加工处理后最终输出。

流水线经常用于数据转换和处理,它的写法非常直观便捷,在许多其它语言上也有流水线的影子。

# history 返回数百条用户历史命令
# grep 匹配出只带有"echo"单词的历史
# less 会将过滤后的历史以滚动查看的方式展示
history | grep "echo" | less

💡 更多流水线的内容,可以参看 Bash pipe tutorial

3.5. & 后台执行

在命令后添加运算符 & 表示启动一个子 shell 进程在后台异步执行这个命令,结果输出到当前 shell。

& 也可以拼接命令。

command1 & command2 & command3  # 命令 1,2 在后台运行,3 在前台运行

这种形式可以用来同时启动多个任务。

3.6. {} 代码块

{ command1; command2; command3 }

代码块可以把几个代码放到一个相同的执行上下文中。不过,这个并不影响变量作用域,也就是没有块作用域。

代码块用得不多,一般见于函数声明处。还有一个场景是实现多条命令的重定向:

{ echo "file content: "; cat source_file } > target_file

如果去掉大括号,重定向的优先级更高,只会影响 cat 命令。

4. 命令行编辑技巧

在使用命令行时,有一些快捷技巧可以提高效率。

💡 这一节的内容只适用于终端环境,不要在脚本中使用。

4.1. 行编辑

所谓行编辑(command line editing),是指命令行支持的一些编辑快捷键。

Bash 的行编辑是借助 Readline 工具库实现的,支持 emacs (默认)和 vi 两种风格,这里只讨论前者。

这里列一些个人认为比较实用的快捷键:

  • Tab: 自动补全,支持文件、命令、参数、用户名、主机名等。两次 Tab 可列出所有可选的自动补全项。
  • Ctrl + A/E: 移动到行首/尾。
  • Ctrl + U/K: 清除光标位置到行首/尾的字符。
  • Ctrl + C: 中止正在执行的命令。
  • Ctrl + L: 清空 shell 打印内容。同命令 clear。
  • Ctrl + D: 关闭 shell 会话。

💡 更多快捷键可以参考 Bash 手册 Command Line Editing (Bash Reference Manual)

4.2. 命令历史

Bash 会记录用户执行过的历史命令,保存在 ~/.bash_history 中,默认保存最近 500 条。

history 命令可以查看历史命令。

历史命令可以方便重复执行。最常用的是上下方向键浏览之前的命令。除此之外,利用 ! 运算符的历史展开(history expansion)功能,可以快速选取特定命令执行。

  • !!: 指代上一条命令。
  • !-n: 指代前 n 条命令,比如 !-1 即表示 !!
  • !n: 指代 history 列出的命令中行号为 n 的命令。

除了命令,还能指代上一条命令的参数:

  • !$: 上一个命令的最后一个参数。
  • !*: 上一个命令的所有参数。
mkdir long-dir-name
cd !* 
# 回车后,展开为 cd long-dir-name

还有一个非常实用的功能:根据关键字查找最近执行的命令,称为 reverse-i-search。按下快捷键 Ctrl + R,出现提示后,输入关键字,会匹配出历史中最近的一个命令。此时,回车可以立即执行,再按 Ctrl + R 会继续向上搜索。

4.3. 命令别名

别名(alias)可以把一个命令(及其一部分参数)定义为一个新命令。利用别名,用户简化一些常用的命令,大大减少常用命令的键击。

alias 命令用于创建别名:

alias ll='ls -al'
# ll 会被替换成 ls -al 执行
ll
# ls 原来的参数也可以正常支持
ll -d my-dir

alias 创建的别名只在当前会话有效,重启终端后,别名就不存在了。如果希望创建一个持久化的别名,可以在 shell 的配置文件中加入别名声明。bash 的配置文件是 ~/.bashrc。

~/.bashrc

# ...
# Aliases
# alias alias_name="command_to_run"

# Long format list
alias ll="ls -al"

每次启动时,shell 都会读取该配置进行初始化,这些别名就可以使用了。

5. 引号

Shell 不存在数据类型(有数组),只有字符串一种值。

有多种方式可以表示字符串:

  • 无引号:简单情况下,字符串内不含有空白时不需要引号,因为空白会被识别成分隔符。
  • 双引号:除了 $(变量展开), ```(命令替换) 和 \(转义)仍然有特殊功能,其它都被解析为普通字符。
  • 单引号:纯字符串,各种字符都会变成普通字符。

6. 变量

6.1. 变量赋值

普通变量无需声明,使用时直接赋值即可。

variable=value # 注意 = 左右没有空格

使用命令替换语法能把命令的输出赋给变量:

# 把 ls 的输出结果赋给 files
files=`ls`

6.2. 使用变量

变量前加美元符号,${variable}表示取对应的变量值,其中大括号在不导致歧义时是可省略的。

echo $files
echo "${files}_end" # 这里大括号是必须的

6.3. 变量的作用域

变量的作用域可以分成三类:

  • 环境变量:能在当前 shell 及其子 shell 中使用,使用 declare -xexport 导出。
  • 全局变量:只能在当前 shell 进程内使用,默认。
  • 局部变量:只能在函数内使用,使用命令 local 声明。

6.4. declare 命令

变量除了保存值以外,还可能绑定某些属性,比如 只读、只能存储数值、作用域。

declare 命令可以赋予变量一些特殊的属性。

declare -r CONST_INT=2 # 设置只读变量,同 readonly 命令声明的变量
declare -i a_int=3 # 数字类型变量
declare -x ENV_VAR=value # 设置为环境变量

尽管这看起来像是变量声明,不过也可以作用于已有变量。

var=val
declare -r var

6.5. setunset 命令

当一个变量被赋值,就称为被 set 的。

set 命令在不接参数会输出所有的变量。使用 unset 命令可以删除变量。

temp_var=temp_val
set|grep temp
# temp_var=temp_val
unset temp_var
set|grep temp
# nothing

6.6. 位置变量和特殊变量

Shell 使用一些位置变量和特殊变量来表示命令及其参数相关的值。

变量 含义及说明
$0 命令行下表示用户当前的 shell;脚本内表示执行的脚本名称。
$N (N>0) 表示执行脚本或函数时的第 N 个参数。N>9 时用${N} 表示。
$# 执行脚本或函数时的参数个数。
$@ 执行脚本时的参数。"$@" 等效于 "$1" "$2" ... "$N"
$* 执行脚本时的参数。"$*" 等效于 "$1 $2 ... $N",是一个整体
$? 上一命令的退出状态码

$@$* 不被双引号包裹时,没有区别。只有在双引号内并且执行 分割 的上下文中才会有差别。

echo-arguments.sh

#!/bin/bash
echo "Use \$*:"
for arg in "$*"
do
        echo "Hello $arg"
done

echo "Use \$@:"
for arg in "$@"
do
        echo "Hello $arg"
done

输出结果为:

Use $*:
Hello JS shell Python
Use $@:
Hello JS
Hello shell
Hello Python

6.7. 变量展开语法

变量语法 ${variable} 其实是变量展开的基本形式,还有一些特殊的展开形式,比如:

  • ${#variable}: 展开为变量的内容长度或数组的长度。
  • ${variable:-default}:为变量设置默认值,当变量内容为空时,展开为默认值。
  • ${variable:offset:length}: 字符串或数组切片。

这部分在脚本写作中使用较多,具体展开规则请查看 bash 手册

7. 环境

Shell 在启动时,会读取系统中的配置文件,设置一系列的环境变量,程序在运行时可以通过环境变量获取一些运行时的配置信息。

💡 这篇文章 阐述了 shell 启动时都会加载哪些配置。

7.1. 查看环境变量

可以通过 printenv 命令查看环境变量:

printenv PATH
# /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/puppetlabs/bin

💡 PATH 变量记录了一组目录,当 shell 解析到一个外部命令时,会到这些目录下查找对应的可执行文件。

也可以直接 echo $name 查看。

7.2. 设置环境变量

有几种方式可以设置环境变量:

a) 修改启动时的配置文件,对系统/用户永久生效(不同的 shell 配置文件有所不同)。

# ~/.bashrc
# ...
USER_ENV="HELLO"

b) 使用 export 命令,仅当前会话有效。

export MODE=production # 同 declare -x MODE=production

c) 在执行命令前设置,仅对该命令有效。

VAR1=V1 VAR2=V2 command arguments

7.3. 子 shell

执行脚本或者 bash 命令时,会创建一个子 shell,子 shell 会继承父 shell 的环境变量(不包括普通变量),子 shell 中设置的环境变量不会影响到父 shell。

💡 参看 bash 中的子 shell 机制

8. 展开

在命令执行前,shell 会先对命令进行展开,即把命令中的特殊模式替换成实际的内容。按顺序依次进行:

  1. 大括号展开ab{c?, d*, ef}g 展开为 abc?g abd*g abefg
  2. 变量展开${var} 展开为对应变量值
  3. 算术展开$(( expression )) 展开为表达式计算后的值。
  4. 命令替换$(command) 或者 command 展开为命令执行后的输出。
  5. 单词分割:把上面的结果根据环境变量 IFS 分割成多个单词,默认使用空白。
  6. 文件名展开:含有字符 * ? [] 的部分会被认为是文件名模式,展开为匹配的文件名(见下)。

在双引号内,只有 $ 和 ``` 还有特殊作用,所以只有变量展开、算术展开和命令替换还有效。

如果想把含特殊符号的参数传递给命令,可以转义或者使用引号。比如,git add 有这样一段说明:

Adds content from all *.txt files under Documentation directory and its subdirectories:

$ git add Documentation/\*.txt

Note that the asterisk * is quoted from the shell in this example; this lets the command include the files from subdirectories of Documentation/ directory.

8.1. Glob 模式

大括号和文件名展开是一种很方便的文件匹配方法,它有一个名称叫 glob。

Glob 在很多语言和工具中都有应用,比如 gitignore 文件,ESLint 配置。

常见的通配符和模式有:

通配符或模式 含义和例子
* 匹配任意字符串(含空串),但是不能跨越目录层级。
** 匹配任意层级目录。
? 匹配一个字符。
[abc] 匹配中括号内的字符集合中的一个。排除法用[^abc][!abc]
a{b,c*}d 先展开成模式abdac*d,再分别匹配,只要能满足一个就算匹配。

glob 和正则表达式容易混淆,二者虽然都是模式匹配的工具,但通配符的含义却是完全不同的。 Glob 是专用于匹配文件名的,而正则是一种更通用的字符串匹配工具。

💡 阮一峰的 命令行通配符教程 进一步说明了这些通配符的使用。

9. I/O 重定向

Shell 的标准输入输出包括 stdin 、stdout、stderr,分别对应文件描述符 0,1,2。

9.1. 重定向输出

使用 > 把命令的输出重定向到文件:

ls > files.txt

如果文件不存在,会创建该文件,所以可以用来很方便地创建一个小文件:

echo "{}" > config.json

如果文件存在,则会先清空再写入。如果希望保留文件原内容,从文件末添加(append),可以使用 >>:

ls >> files.txt

9.2. 重定向错误输出

> 前加上文件描述符 2:

ls 2> ls-err.text

如果希望同时重定向输出和错误输出,使用 &>:

ls &> files.txt # 同 ls > files 2>&1

9.3. 重定向输入

重定向输入用 < 。输入重定向用得比较少,大部分情况都是直接支持用文件做参数。 下面的 read-print.sh 从标准输入读取输入,并打印

#!/bin/bash
read var;
echo $var;

重定向标准输入,把一个文件内容作为输入:

bash read-print.sh < files.txt

9.4. Here 文档和 here 字符串

Here 文档允许我们把一段字符串作为输入源。语法如下:

command << token
# 中间这里是字符串的内容
text ...
token

其中 token 是一段标识,不固定,收尾一致即可,结束标识必须顶格。Here 文档内部支持变量展开 ($ 仍然具有特殊意义)。

适合用于引用一些带格式的长文本。比如,一段 html 字符串:

title="Simple HTML"
content="Hello"

# cat 命令默认从标准输入读取内容
cat << _EOF_
<html>
<head>
    <title>
    The title of page:$title
    </title>
</head>
<body>
    $content
</body>
</html>
_EOF_

如果字符串内容较短,可以使用 here 文档的变体 here 字符串:

alias echo-hello="bash read-print.sh <<< 'Hello'"
echo-hello
# Hello

10. 获取帮助

当你遇到问题时,你不一定需要 google,可以先查看一下命令行上的帮助信息。

10.1. help 命令

Bash 的内置命令 help 能够显示内置命令的用法。不过,只能对内置命令有效,无法查看其它类型的命令用法。

# type 是一个内置命令
help type
# type: type [-afptP] name [name ...]
#    Display information about command type.
# ...

# ls 是一个可执行文件 /bin/ls
help ls
# bash: help: no help topics match `ls'.

10.2. man 命令

大部分命令会带有使用手册(manual page),使用手册比较详细地描述个该命令的语法和参数及其作用。

命令 man 可以查看命令的用户手册。

man ls
# LS(1) General Commands Manual LS (1)
#
# NAME
#     ls – list directory contents
#
# SYNOPSIS
#     ls [-@ABCFGHILOPRSTUWabcdefghiklmnopqrstuvwxy1%,] [--color=when] [-D format] [file ...]
#
# DESCRIPTION
# ...

第一行展示了该词条所在的区块。手册分为 8 个区块:1) 一般命令;2) 系统调用;3)库函数 …… 同一词条在不同区块可能有不同含义。

手册的主要内容包括 NAME(名称)、SYNOPSIS(语法)、DESCRIPTION(描述) 等。

💡 了解更多 man 命令细节,可以参看 Linux 命令 man 全知全会

对内置命令,man 会返回所有的内置命令的说明,不如 help 命令有效。

10.3. info 命令

man page 是一种过时(但仍然使用广泛)的文档格式,Unix 已经采用能支持超链接的 info 格式来提供帮助文档。如果 man 命令失效,你可以试试 info,在大多数时候,它们两个都能生效。

查看 info 手册需要使用一些特殊的子命令:

  • 空格键/PgDn:向下翻页。
  • PgUp: 向上翻页。
  • x:关闭窗口。
  • Tab:跳转到下个超文本链接。
  • q:退出。

10.4. apropos 命令

apropos 能够根据关键字搜索出相关的命令。

apropos rename file
# ...
# mv(1)                    - move files
# ...

不过,经常会匹配到太多内容导致难以找到想要的词条,实际体验不如 google。

10.5. 命令行工具的 help 命令、--help-h 参数

好的软件总是会提供便利完善的帮助信息。主流的命令行工具,几乎都会提供 help 命令,或者 --help/-h 参数,提供使用说明。困惑的时候,不妨试试这种方法。

# 查看全部帮助信息
git help
# 查看子命令帮助信息
git help add
git add --help
git add -h

11. 更多资料

留下评论