Linux(1)terminal

★终端(terminal/TTY)

最早期的计算机(大型机)是【单任务】滴——也就是说,每次只能干一件事情。

到了60年代,出现了一个【革命性】的飞跃——发明了【多任务】系统,当时叫做“time-sharing”(分时系统)。

有了“分时系统”,就可以让多个人同时使用一台大型机。而为了让多个人同时操作这台大型机,就引入了【终端】的概念。

每一台大型机安装多个终端,每个操作员都在各自的终端上进行操作,互不干扰。

“终端”的好处不光是“多任务”,而且还可以让用户在【远程】进行操作。

这种情况下,“终端”通过 modem(调制解调器)与“主机”相连。

这种玩法很类似于——互联网普及初期的拨号上网。示意图如下:

 

 

 最早的“终端”,本质上就是“电传打字机”——以“打字机”作为输入;以“打印纸”作为输出。

◇内部结构示意图

下面这张是大型机时代,“终端”与“进程”通讯的示意图。

图中的 UART 是洋文“Universal Asynchronous Receiver and Transmitter”的缩写(相关维基百科链接在“这里”)。

LDISC 是洋文“line discipline”的简写(相关维基百科链接在“这里”)。

通俗地说,UART 用来处理物理线路的字符传输(比如:“错误校验”、“流控”、等);

LDISC 用来撮合底层的“硬件驱动”与上层的“系统调用”,并完成某些“控制字符”的处理与翻译。

The terminal subsystem consists of three layers:

the upper layer to provide the character device interface,

the lower hardware driver to communicate with the hardware or pseudo terminal,

and the middle line discipline to implement behavior common to terminal devices.

 

 

(TTY 示意图1:使用【硬件终端】的大型机内部结构图)

◇如今的含义

如今,“终端”一词的含义已经扩大了——用来指:基于【文本】的输入输出机制。

 

★终端的3种【缓冲模式】——字符模式、行模式、屏模式

◇字符模式(character mode)

【电传打字机】是基于【字符】传输滴。也就是说,操作员每次在“电传打字机”上按键,对应的字符会立即通过线路发送给对方。

这就是最传统的【字符模式】通俗地说,“字符模式”也就是【无缓冲】的模式。

◇行模式(line mode)

不客气地说,“字符模式”是非常傻逼滴!因为如果你不小心按错键,这个错误也会立即发送出去。

比如说,你在输入一串很长的命令,结果输到半当中,敲错一个按键,整个命令就废了——要重新再输入一遍。

所以,当早期的程序员对“字符模式”实在忍无可忍之后,终于发明了【行模式】。

【行模式】也叫做“行缓冲”。也就是说,终端会把你当前输入的这行先缓冲在本地。

只有当你最终按了【回车键】,才会把这一整行发送出去。

如果你不小心敲错了一个字符,可以赶紧用“退格键”删掉重输这个字符。

因此,这种模式称之为【行缓冲】。

◇屏模式(screen mode/block mode)

“行模式”进一步的发展就是【屏模式】。这个玩意儿也叫“全屏缓冲”,顾名思义,终端会缓冲当前屏幕的内容。

在这种模式下,用户可以利用方向键,操纵光标(cursor)在屏幕上四处游走。

早期的键盘【没】方向键。有了这个【屏模式】之后,键盘上才开始增加了“方向键”(所以“方向键”位于键盘的扩展区)

 

上述这三种模式,第1种基本淘汰(仅限于极少数场景);第3种用得也不多。与本文关系比较密切的,其实是【第2种】——行模式。
为了加深你的印象,用 cat 命令来举例(注:这个命令其实与“猫”【无关】,而是 concatenate 的简写)

大部分情况下,都是用它来显示某个文件的内容,比如说:cat 文件名 。

但如果你运行 cat【没】加任何参数,那么它就会尝试读取你在终端的输入,然后把读到的文本再原样输出到终端。

 

 

 在上述动画中,你的输入并【没有】直接传递给 cat 进程。要一直等到你按下【回车键】,cat 进程才收到你的输入,并立即打印了输出。

★终端的【回显】

◇“回显”是啥?

在刚才那个 gif 动画中,当俺逐个输入 test 的每个字母,这些字母也会逐个显示在屏幕上。这种做法叫做【回显】。

★(早期的)系统控制台/物理控制台(system console)

在【没】发明“分时系统”之前,当时的计算机只能执行【单任务】。因此,那时候的大型机只有【一个】操作界面,称之为【控制台】。那时的“控制台”,真的是一个台子。

后来发明了“分时系统”。“分时系统”使得大型机可以具备多个终端。

在这种情况下,你可以把“控制台”通俗地理解为“本地终端”,而【不】是“控制台”的那些终端,称之为“远程终端”。

由于“远程终端”可能会被【外人】使用,因此对“远程终端”的【权限】要进行一些限制。

如果要进行一些高级别的操作(比如“关闭整个系统”),就只能限制在【控制台】(本地终端)进行。

这里要注意终端和控制台的区别,控制台是本地终端。从终端讲到控制台,范围在缩小,但还是在终端这个大范围说事情

★(如今的)虚拟控制台(virtual console)

到了 PC 时代,传统意义上的【控制台】已经看不到了。但 console 这个术语保留了下来。

◇从“物理 console”到“虚拟 console”

早期大型机的 console 是【独占】硬件滴——“键盘/显示器”固定用于某个 console 滴。
【现代】的 POSIX 系统,衍生出“virtual console”的概念——可以让几个不同的 console【共用】一套硬件(键盘/显示器)。“virtual”一词就是这么来滴。
 不论是早期的“物理控制台”还是后来的“虚拟控制台”,都属于广义上的“终端”。

◇举例:Linux 的 virtual console

假设你的 Linux 系统没安装图形界面(或者默认不启用图形界面),当系统启动完成之后,你会在屏幕上看到一个文本模式的登录提示。这个界面就是 virtual console 的界面。
在默认情况下,Linux 内置了【6个】virtual console 用于命令行操作,然后把第7个 virtual console 预留给图形系统。

你可以使用 Alt + Fn 或 Ctrl + Alt + Fn 在这几个 console 之间切换(注:上述所说的 Fn 指的是 F1、F2... 之类的功能键)。

◇虚拟控制台的【内部结构】

 

 

 

(TTY 示意图2:【虚拟控制台】的内部结构图)

★终端模拟器(terminal emulator)

如果你对比前面的【TTY 示意图1】与【TTY 示意图2】的变化,会发现——“UART & UART 驱动”没了,然后多了这个【终端模拟器】。
多出来的这个玩意儿相当于加了一个【抽象层】,模拟出早期硬件终端的效果,因此就【无需改动】系统内核中的其它部分,比如:LDISC(line discipline
请注意,这个场景下的“终端模拟器”位于操作系统【内核】。换句话说,它属于【内核态】的模拟器。正是因为它处于这个地位,所以能够在“驱动”&“LDISC”之间进行协调。

★伪终端(PTY/pseudotty/pseudoterminal)

 

◇从“文本模式”到“图形模式”

前面讲的那些,都是【文本模式】(文本界面)。
话说到了上世纪80年代,随着【图形界面】的兴起,就出现某种需求——想在图形界面下使用“【文本】终端”。于是就出现了“伪终端”的概念。
通俗地说,“伪终端”就是用某个图形界面的软件来模拟传统的“文本终端”的各种行为。前面说了,TTY 这个缩写相当于“终端”的同义词;因此“pseudotty” 就衍生出 PTY 这个缩写。

◇从“【内核态】终端模拟器”到“【用户态】终端模拟器”

在上一个章节中,emulator 运行在系统内核中,因此是“内核态模拟器”;

等到后来搞“伪终端”的时候,就直接把这个玩意儿从【内核态】转到【用户态】——让它直接运行在【桌面环境】。

 

如此一来,用户就可以直接在桌面环境中使用“终端模拟器”。
当“终端模拟器”变为【用户态】,它就【无法】直接与“键盘驱动 or 显卡驱动”打交道。

 

在这种情况下,由“GUI 系统”(比如:X11)负责与这些驱动打交道,然后再把用户的输入输出转交给“终端模拟器”。

◇内部结构示意图

很多人把“emulator”与“PTY”混为一谈。实际上两者处于【不同】层次。
在操作系统内部(内核),PTY 分为两部分实现,分别叫做“PTY master” & “PTY slave”。

master 负责与“terminal emulator”打交道;而用户通过 emulator 里面的 shell 启动的其它进程,则与 slave 打交道。
在这个环节中,“PTY slave”又进一步缩写为“PTS”。

如果你用 ps 命令查看系统中的所有进程,经常会看到 PTS 之类的字样,指的就是这个玩意儿。

对普通用户而言,看到的是“终端模拟器”的界面,至于 PTY 内部的 master & slave,通常是感觉不到滴。
为了让大伙儿更加直观,再放一张 PTY 的结构示意图。

 

(TTY 示意图3:【伪终端】的内部结构图)

★shell——命令行解释器

◇shell VS terminal

前面所说的“终端”(terminal),本质上是:基于【文本】的输入输出机制。它并【不】理解具体的命令及其语法。
于是就需要引入 shell 这个玩意儿——shell 负责解释你输入的命令,并根据你输入的命令,执行某些动作(包括:启动其它进程)。

 

◇常见 shell 举例

bash
csh
fish
ksh
zsh

在维基百科的“这个页面”,列出了各种各样的 shell 及其功能特性的对照表。
如今影响力最大的 shell 是 bash(没有之一)。其名称源自“Bourne-again shell”,是 GNU 社区对 Bourne shell 的重写,使之符合自由软件(GPL 协议)。
本文后续章节对 shell 的举例,如果没有做特殊说明,均指 bash 这个 shell。

(git那里用的就是这个东西)

★shell 的基本功能

◇显示【命令行提示符】

当你打开一个 shell,会看闪烁的光标左侧显示一个东东,那个玩意儿就是【命令行提示符】(参见下图)

 

 

 (截图中的“命令行提示符”包含了:用户名、当前路径、$分隔符)

很多 shell 的“命令行提示符”都会包含【当前路径】。

当你用 cd 命令切换目录,提示符也会随之改变。

这有助于你搞清楚当前在哪个目录下,可以有效避免误操作
下面这张图演示了——“命令行提示符”随着当前目录的变化而变化。

 

 

 大部分 shell 都可以让你自定义这个【命令行提示符】,使之显示更多的信息量。
比如说,可以让它显示:当前的时间、主机名、上一个命令的退出码......
(注:如果你需要开多个【远程】终端,去操作多个【不同】的系统,“主机名”就蛮有用)

 

◇解析用户输入的【命令行】

假设你想看一下 /home 这个目录下有哪些子目录,可以在 shell 中运行了如下命令:

ls /home

当你输入这串命令并敲回车键,shell 会拿到这一行,然后它会分析出,空格前面的 ls 是一个外部命令,空格后面的 /home 是该命令的参数。
然后 shell 会启动这个外部命令对应的进程,并把上述参数作为该进程的启动参数。

◇内部命令 VS 外部命令

通俗地说,“内部命令”就是内置在 shell 中的命令;而“外部命令”则对应了某个具体的【可执行文件】。
当你在 shell 中执行“外部命令”,shell 会启动对应的可执行文件,从而创建出一个“子进程”;而如果是“内部命令”,就【不】产生子进程。
那么,如何判断某个命令是否为“外部命令”捏?比较简单的方法是——用如下方式来帮你查找。

如果某个命令能找到对应的可执行文件,就是“外部命令”;反之则是“内部命令”。

 

whereis 命令名称

 

◇翻译【通配符】

 

玩过命令行的同学,应该都知道:“星号”(*)与“问号”(?)可以作为通配符,用来模糊匹配文件名。
当你在 shell 中执行的命令包含了上述两个通配符,实际上是 shell 先把”通配符“翻译成具体的文件名,然后再传给相应命令。

◇翻译某些【特殊符号】

比如说:在 POSIX 系统中,通常用 ~ 来表示当前用户的【主目录】(home 目录)。
如果你在 shell 中用到了 ~ 这个符号,shell 会先把该符号翻译成“home 目录的【全路径】”,然后再传给相应命令。

◇翻译【别名】

很多 POSIX 的 shell 都支持用 alias 命令设置别名(把一个较长的命令串,用一个较短的别名来表示)。
设置了别名之后,当你在 shell 中使用“别名”,由 shell 帮你翻译成原先的命令串。
举例:
在《扫盲 netcat(网猫)的 N 种用法——从“网络诊断”到“系统入侵”》一文中,俺使用如下命令创建了 nc-tor 这个别名。

alias  nc-tor='nc -X 5 -x 127.0.0.1:9050' 

设置完之后,当你在 shell 中执行了这个 nc-tor 命令,shell 会把它自动翻译成 nc -X 5 -x 127.0.0.1:9050

 

◇历史命令

大部分 shell 都会记录历史命令。你可以使用某些设定的快捷键(通常是【向上】的方向键),重新运行之前执行过的命令。

 

◇自动补全

很多 shell 都具备自动补全的功能。该功能不仅指“命令”本身的自动补全,还包括对“命令的参数”进行自动补全。

 

◇操作“环境变量”

◇“管道”与“重定向”

 

◇“进程控制”与“作业控制”

★进程的启动与退出

 

◇进程的【启动】及其【父子关系】

 

一般来说,每个“进程”都是由另一个进程启动滴。如果“进程A”创建了“进程B”,则 A 是【父进程】,B 是【子进程】(这个“父子关系”很好理解——因为完全符合直觉)
有些同学会问,那最早的【第一个】进程是谁启动滴?一般来说,第一个进程由【操作系统内核】(kernel)亲自操刀运行起来;而 kernel 又是由“引导扇区”中的“boot loader”加载。

 

◇进程树

在 POSIX 系统(Linux & UNIX),所有的进程构成一个【单根树】的层次关系。进程之间的“父子关系”,体现在“进程树”就是树上的【父子节点】。
你可以使用如下命令,查看当前系统的“进程树”。

pstree

 

 

 (“进程树”的效果图。注:为了避免暴露俺的系统信息,特意【不】用自己系统的截图)

 

◇初始进程

一般情况下,POSIX 系统的“进程树”的【根节点】就是系统开机之后【第一个】创建的进程,并且其进程编号(PID)通常是 1。这个进程称之为“初始进程”。
(注:上述这句话并【不够】严密——因为某些 UNIX 衍生系统的“进程树”,位于根节点的进程【不是】“初始化进程”。这种情况与本文的主题没太大关系,俺不打算展开讨论)
对于“大部分 UNIX 衍生系统”以及“2010年之前的 Linux 发行版”,系统中的“初始进程”名叫 init
如今越来越多的 Linux 发行版采用 systemd 来完成系统引导之后的初始化工作。在这些发行版中,“初始进程”名叫 systemd
你可以用如下命令显示“进程树”中每个节点的“进程编号”(PID),然后就能看到编号为 1 的“初始进程”。

 

pstree -p

 

◇进程的三种死法

关于进程如何死亡,大致有如下三种情况:

  自然死亡
  如果某个进程把它该干的事情都干完了,自然就会退出。
  这种是最常见的情况,也是最优雅的死法。俺习惯称作【自然死亡】。

  自杀
  如果某个进程的工作干到半当中,突然收到某个通知,让它立即退出。
  这时候,进程会赶紧处理一些善后工作,然后自行了断——这就是【自杀】。

  它杀
  比“自杀”更粗暴的方式称之为【它杀】。也就是让“操作系统内核”直接把进程干掉。
  在这种情况下,进程【不会】收到任何通知,因此也【不】可能进行任何善后事宜。

  (注:上述三种死法纯属比喻,以加深大伙儿的印象;不必太较真。十年前俺刚开博客,写过几篇帖子谈“C++ 对象之死”,也用过类似比喻)
  关于“自杀&它杀”的方式,会涉及到【信号】。在下一个章节,俺会单独讨论【进程控制】,并会详细介绍“信号”的机制。

 

◇“孤儿进程”及其“领养”

如果某个进程死了(退出了),而它的子进程还【没】死,那么这些子进程就被形象地称之为“孤儿”,然后会被上述提到的【初始进程】“领养”——“初始进程”作为“孤儿进程”的父进程。
对应到“进程树

”——“孤儿进程”会被重新调整到“进程树根节点”的【直接下级】。 

★“进程控制”与“信号”

◇用【Ctrl + C】杀进程

为了演示这个效果,你可以执行如下命令:

ping 127.0.0.1

如果是 Windows 系统里的 ping 命令,它只会进行4次“乒操作”,然后就自己退出了;
但对于 POSIX 系统里面的 ping 命令,它会永远运行下去(直到被杀掉)。
当 ping 在运行的时候,只要你按下 Ctrl + C 这个组合键,就可以立即终止这个 ping 进程。

 

◇“Ctrl + C”背后的原理——【信号】(signal)

当你按下了 Ctrl + C 这个组合键,当前正在执行的进程会收到一个叫做【SIGINT】的信号。
如果进程内部定义了针对该信号的处理函数,那么就会去执行这个函数,完成该函数定义的一些动作。一般而言,该函数会进行一些善后工作,然后进程退出。
如果进程【没有】定义相应的处理函数,则会执行一个【默认动作】。对于 SIGINT 这个信号而言,默认动作就是“进程退出”。
上述这2种情况,都属于前面所说的自杀。这2种属于【常规情况】。

下面再来说【特殊情况】——有时候 Ctrl + C【无法】让进程退出。为啥会这样捏?
假如说,编写某个进程的程序员,定义了该信号的处理函数,但在这个函数内部,并【没有】执行“进程退出”这个动作。那么当该进程收到 SIGINT 信号之后,自然就【不会】退出。这种情况称之为——信号被该进程【屏蔽】了

◇【谁】发出“Ctrl + C”对应的信号?

很多人(包括很多玩命令行的老手)都有一个【误解】——他们误以为是 shell 发送了 SIGINT 信号给当前进程。其实不然!
在上述 ping 的例子中,当 ping 进程在持续运行之时,你的键盘输入是关联到 ping 进程的“标准输入”(stdin)。在这种情况下,shell 根本【无法】获取你的按键信息。
实际上,是【终端】获取了你的 Ctrl + C 组合键信息,并发送了 SIGINT 信号。因为【终端】处于更底层,它负责承载你所有的输入输出。因此,它当然可以截获用户的某个特殊的组合键(比如:Ctrl + C),并执行某些特定的动作。
如果没有正确理解“终端”与“shell”这两者的关系,就会犯很多错误(造成很多误解)。[终端和shell到底是什么关系?上面不是说终端模拟器中包含shell?]
有的读者可能会问:“终端”如何知道【当前进程】是哪一个?(能想到这点,通常是比较爱思考滴)
当 shell 启动了某个进程,它当然可以拿到这个进程的编号(pid),于是 shell 会调用某个系统 API(比如 tcsetpgrp)把“进程编号”与 shell 所属的“终端”关联起来。
当“终端”需要发送 SIGINT 信号时,再调用另一个系统 API(比如 tcgetpgrp),就可以知道当前进程的编号。

◇对比杀进程的几个信号:SIGINT、SIGTERM、SIGQUIT、SIGKILL

       SIGINT
  在大部分 POSIX 系统的各种终端上,Ctrl + C 组合键触发的就是这个信号。
  通常情况下,进程收到这个信号后,做完相关的善后工作,就自行了断(自杀)。

  SIGTERM
  这个信号基本类似于 SIGINT。
  它是 kill & killall 这两个命令【默认】使用的信号。
  也就是说,当你用这俩命令杀进程,并且【没有】指定信号类型,那么 kill 或 killall 用的就是这个 SIGTERM 信号。

  SIGQUIT
  这个信号类似于前两个(SIGINT & SIGINT),差别在于——进程在退出前会执行“core dump”操作。
  一般而言,只有程序员才会去关心“core dump”这个玩意儿,所以这里就不细聊了。

  SIGKILL
  在杀进程的几个信号中,这个信号是是最牛逼的(也是最粗暴的)。
  前面三个信号都是【可屏蔽】滴,而这个信号是【不可屏蔽】滴。
  当某个进程收到了【SIGKILL】信号,该进程自己【完全没有】处理信号的机会,而是由操作系统内核直接把这个进程干掉。
  此种行为可以形象地称之为“它杀”。
  当你用下列这些命令杀进程,本质上就是在发送这个信号进行【它杀】。【SIGKILL】这个信号的编号是 9,下列这些命令中的 -9 参数就是这么来滴。

kill -9 进程号
kill -KILL 进程号

killall -9 进程名称
killall -KILL 进程名称
killall -SIGKILL 进程名称

 

 

◇【它杀】的危险性与副作用

请注意:【它杀】是一种比较危险的做法,可能导致一些【副作用】。只有当你用其它各种方式都无法干掉某个进程,才考虑用这招。
一方面,当操作系统用这种方式杀掉某个进程,虽然可以把很多内存相关的资源释放掉,但【内存之外】的资源,内核就管不了啦;另一方面,由于进程遭遇“它杀”,无法完成某些善后工作。
基于上述两点,就【有可能】会产生副作用。另外,“副作用的严重程度”取决于不同类型的软件。无法一概而论。
举例1:
某个进程正在保存文件。这时候遭遇“它杀”可能会导致文件损坏。
(注:虽然某些操作系统能做到“写操作的原子性”,但数据存储可能会涉及多个写操作。当进程在作【多个】关键性写操作时,遭遇它杀。可能导致数据文件【逻辑上】的损坏)
举例2:
还有更复杂的情况,比如涉及跨主机的网络通讯。某个进程可能向【远程】的某个网络服务分配了某个远程的资源,当进程“自然死亡 or 自杀”,它会在“善后工作”释放这个资源;而如果死于内核的“它杀”,这个远程的资源就【没】释放。

◇kill VS killall

这两个的差别在于——前者用“进程号”,后者用”进程名“(也就是可执行文件名)。
对于新手而言,如果用 kill 命令,你需要先用 ps 命令打印出当前进程清单,然后找到你要杀的进程的编号;而如果要用 killall 命令,就比较省事(比较傻瓜化)。但万一碰到有多个【同名】进程在运行,而你只想干掉其中一个,那么就得老老实实用 kill 了。

◇进程退出码

任何一个进程退出的时候,都对应某个【整数类型】的“退出码”。
按照 POSIX 系统(UNIX & Linux)的传统惯例——
当“退出码”为【零】,表示“成功 or 正常状态”;
当“退出码”【非零】,表示“失败 or 异常状态”。

◇暂停进程

刚才聊“杀进程”的时候提到了“自杀 VS 它杀”。前者比较“温柔”;而后者比较“粗暴”。
对于暂停进程,也有“温柔 & 野蛮”两种玩法。而且也是用 kill 命令发信号。

【温柔】式暂停(SIGTSTP)

kill -TSTP 进程编号

这个【SIGTSTP】信号类似前面提及的【SIGINT】——
1. 两者默认都绑定到组合键(【SIGINT】默认绑定到组合键【Ctrl + C】;【SIGTSTP】默认绑定到组合键【Ctrl + Z】)
2. 这两个快捷键都是由【终端】截获,并发出相应的信号(具体原理参见本章节的某个小节)
3. 两者都是【可】屏蔽的信号。也就是说,如果某个进程屏蔽了【SIGTSTP】信号,你就【无法】用该方式暂停它。这时候你就得改用【粗暴】的方式(如下)。

【粗暴】式暂停(SIGSTOP)

kill -STOP 进程编号

这个【SIGSTOP】信号与前面提及的【SIGKILL】有某种相同之处——这两个信号都属于【不可屏蔽】的信号。也就是说,收到【SIGSTOP】信号的进程【无法】抗拒被暂停(suspend)的命运。
与“杀进程”的风格类似——当你想要暂停某进程,应该先尝试“温柔”的方法,搞不定再用“粗暴”的方法(套用咱们天朝的老话叫“先礼后兵”)。

◇恢复进程

当你想要重新恢复(resume)被暂停的进程,就用如下命令(该命令发送信号【SIGCONT】)

kill -CONT 进程编号

◇引申阅读

除了前面几个小节提到的信号,POSIX 系统还支持其它一些信号,具体参见维基百科的“这个页面”。

★作业控制(job)

聊完了“进程控制”,再来聊“作业控制”。(注:这里所说的“作业”是从洋文 job 翻译过来滴)

◇啥是“作业”?

“作业”是 shell 相关的术语,用来表示【进程组】的概念(每个作业就是一组进程)。
比如说,当你用“管道符”把若干命令串起来执行,这几个命令对应的进程就被视作【一组】。
(注:“管道符”的用法,后面某个章节会介绍)

◇同步执行(前台执行) VS 异步执行(后台执行)

大部分情况下,你在 shell 中执行的命令都是“同步执行”(或者叫“前台执行”)。对于这种方式,只有当命令运行完毕,你才会重新看到 shell 的“命令行提示符”。
如果你以“异步执行”的方式启动某个外部命令,在这个命令还没有执行完的时候,你就可以重新看到“命令行提示符”。
请注意:
对于【短】寿命的外部命令(耗时很短的外部命令),“同步/异步”两种方式其实【没】啥区别。比如 ls 命令通常很快就执行完毕,你就感觉不到上述两种方式的差异。
只有当你执行了某个【长】寿命的外部命令(其执行时间至少达到若干秒),上述这两种方式才会体现出差别。
到目前为止,本文之前聊的命令执行方式,都属于“同步执行”;如果想用【异步】,需要在整个命令的最末尾追加一个半角的 & 符号。
【同步】方式举例
下列命令以【同步】的方式启动火狐浏览器,只有当你关闭了火狐,才会重新看到 shell 的命令行提示符。

firefox

【异步】方式举例
下列命令以【异步】的方式启动火狐浏览器。你刚敲完回车,就会重新看到 shell 的“命令行提示符”(此时火狐依然在运行)

firefox &

以“同步”方式启动的进程,称作“【前台】进程”;反之,以“异步”方式启动的进程,称作“【后台】进程”。

◇“前台”切换到“后台”

假设当前的 shell 正在执行某个长寿命的【前台】进程,你可以按【Ctrl + Z】,就可以让该进程变为【后台】进程——此时你立即可以看到“命令提示符”。
只要你不是太健忘,应该记得前一个章节有提到过【Ctrl + Z】这个组合键——它用来实现”【温柔】式暂停“,其原理是:向目标进程发送【SIGTSTP】信号。

◇“后台”切换到“前台”

假设当前 shell 正在执行某个后台进程。由于该进程在【后台】执行,此时有“命令提示符”,然后你在 shell 中执行 fg 命令,就可以把该后台进程切换到【前台】。
某些爱思考的同学会问了——如果同时启动了【多个】“后台进程”,fg 命令会切换哪一个捏?
在这种情况下,fg 命令切换的是【最后启动】的那个。
如果你有 N 个“后台进程”,你想把其中的某个切换为“前台进程”,这时候就需要用到 jobs 命令。该命令与乔布斯同名 :)
举例:
假设俺同时启动了 vim 与 emacs 作为后台进程,先用 jobs 命令列出所有的后台进程。假设该命令的输出是如下这个样子。

$ jobs
[1]  running    vim
[2]  running    emacs

在上述的终端窗口,中括号里面的数字称作“job id”。你可以用 fg 命令搭配“job id”,把某个后台进程切换到前台。
(在本例中)如果你想切换 emacs 到前台,就运行 fg %2,如果想切换 vim 就运行 fg %1(以此类推)

◇引申阅读

想进一步了解“作业控制”,可以参考维基百科(这个链接)。

★环境变量(environment variable)

◇“环境变量”是啥?

所谓的“环境变量”,你可以通俗理解为某种【名值对】——每个“环境变量”都有自己的【名称】和【值】。并且名称必须是【唯一】滴。

◇如何添加并修改“环境变量”?

在 bash(或兼容 bash 的其它 shell),你可以用 export 设置环境变量。比如下面这个命令行设置了一个“环境变量”,其名称是 abc,其值是 xyz

export abc=xyz

假如你要设置的【值】包含空格,记得用双引号引用该值(示例如下)。

export abc="program think"

由于“环境变量”的名称具有【唯一性】,当你设置【同名】的“环境变量”就等同于对它的【修改】。

◇如何查看“环境变量”?

设置完之后,你可以用 env 命令查看。该命令会列出【当前 shell】中的【全部】“环境变量”。

◇“环境变量”的【可见性】和【可继承性】

某个进程设置的“环境变量”,其【可见性】仅限于该进程及其子进程(也就是“进程树”中,该进程所在的那个枝节)。
基于上述的【可见性】原则,你在某个 shell 中设置的“环境变量”,只在“该 shell 进程本身”,以及通过该 shell 进程启动的“其它子进程”,才能看到。
另外,如果系统关机,所有进程都会退出,那么你采用上一个小节(export 方式)设置的“环境变量”也就随之消失了。
为了让某个“环境变量”永久生效,需要把相应的 export 命令添加到该 shell 的初始化配置文件中。对于 bash 而言,也就是 ~/.bashrc 或者 ~/.profile
估计有些同学会问:上述这两个初始化配置文件,有啥差别捏?
俺如果有空,会单独写一篇关于 bash 的定制教程,到时候再聊这个话题。

◇“环境变量”有啥用?

通俗地说,“环境变量”是某种比较简单的“IPC 机制”(进程通讯机制),可以让两个进程共享某个简单的文本信息。
举例:
很多知名的软件(比如:curl、emacs)都支持“以环境变量设置代理”。
如果你按照它的约定,在 shell 中设置了约定名称和格式的“环境变量”,然后在【同一个】shell 中启动这个软件,(由于环境变量的【可继承性】)该软件就会看到这个“环境变量”,并根据“环境变量”包含的信息,设置代理。

★“标准流”(standard stream)与“重定向”(redirection)

◇进程的3个“标准流”

在 POSIX 系统(Linux & UNIX)中,每个进程都内置了三个“标准流”(standard stream),分别称作:“标准输入流”(stdin),“标准输出流”(stdout),“标准错误输出流”(stderr)。
当进程启动后,在默认情况下,stdin 对接到终端的【输入】;stdout & stderr 对接到终端的【输出】。示意图如下:

 

 

 (三个【标准流】的示意图)

如果你是程序员,俺补充一下:
当你在程序中打开某个文件,会得到一个“文件描述符”(洋文叫“file descriptor”,简称 fd)。fd 本身是个整数,程序员可以通过 fd 对该文件进行读写。
而进程的三个【标准流】,就相当于是三个特殊的 fd。当进程启动时,操作系统就已经把这三个 fd 准备好了。
由于这三个玩意儿是预先备好滴,所以它们的数值分别是:0、1、2(参见上图中 # 后面的数字)。

◇演示“标准流”的实际效果

在本文前面的某个章节,俺已经用 gif 动画演示了终端的“行模式”。
动画中的 cat 命令同样可以用来演示“标准输入输出”。俺把那个动画再贴一次。

 

 

 (动画:“标准输入输出”的效果)

请注意,第1行 test 是针对 cat 进程的【输入】,对应于【stdin】(你之所以能看到这行,是因为前面所说的【终端回显】)
第2行 test 是 cat 进程拿到输入文本之后的原样输出,对应于【stdout】。

◇“标准流”的【重定向

所谓的【重定向】大体上分两种:

       1. 【输入流】重定向
  把某个文件重定向为 stdin;此时进程通过 stdin 读取的是该文件的内容。
  这种玩法使用小于号(<
  2. 【输出流】重定向
  把 stdout 重定向到某个文件;此时进程写入 stdout 的内容会【覆盖 or 追加】到这个文件。
  这种玩法使用【单个】大于号(>)或【两个】大于号(>>)。前者用于【覆盖】文件内容,后者用于【追加】文件内容。
  另外,有时候你会看到 2>&1 这种写法。它表示:把 stderr 合并到 stdout。
  (注:前面俺提到过——stdout 是“数值为 1 的文件描述符”;stderr 是“数值为 2 的文件描述符”)

◇【重定向】举例

cat 的例子
下面这个命令把某个文件重定向到 cat 的 stdin。

cat < 文件名

很多菜鸟容易把上面的命令与下面的命令搞混淆。请注意:上面的命令用的是【输入重定向】,而下面的命令用的是【命令行参数】。

cat 文件名

cat 命令还可以起到类似“文件复制”的效果。比如你已经有个 文件1,用下面这种玩法,会创建出一个内容完全相同的 文件2

cat < 文件1 > 文件2

某些同学可能会问了:既然能这么玩,为啥还需要用 cp 命令进行文件复制捏?
原因在于:cat 的玩法,只保证内容一样,其它的不管;而 cp 除了复制文件内容,还会确保“目标文件”与“源文件”具有相同的属性(比如 mode)。
更多的例子
在之前那篇《扫盲 netcat(网猫)的 N 种用法——从“网络诊断”到“系统入侵”》,里面介绍了十多种 nc 的玩法。很多都用到了【重定向】。

★匿名管道(anonymous pipe)

◇“匿名管道”的【原理】

在大部分 shell 中,使用竖线符号(|)来表示【管道符】。用它来创建一个【匿名管道】,使得前一个命令(进程)的“标准输出”关联到后一个命令(进程)的“标准输入”。

◇举例

俺曾经在“这篇博文”中介绍过——如何用 netstat 查看当前系统的监听端口。
对于 Windows 系统,可以用如下命令:

netstat -an | find "LISTEN"

对于 POSIX 系统,可以用如下命令:

netstat -an | grep "LISTEN"

在上述两个例子中,都用到了【管道符】。因为 netstat -an 这个命令的输出可能会很多,先把它的输出通过【匿名管道】丢给某个专门负责过滤的命令(比如:POSIX 的 grep 或 Windows 的find)。当这个过滤命令拿到 netstat 的输出内容,再根据你在命令行参数中指定的【关键字】(也就是上述例子中的 LISTEN),过滤出包含【关键字】的那些【行】。最终,你看到的是“过滤命令”(grep 或 find)的输出。

◇【串联的】匿名管道(chained pipeline)

前面的例子,可以用来列出当前系统中所有的监听端口。
现在,假设你运行了 Tor Browser,然后想看看它到底有没有开启 9150 这个监听端口,那么你就可以在上述命令中进行【二次过滤】(具体命令大致如下)。这就是所谓的【串联】。

netstat -an | grep "LISTEN" | grep "9150"

◇“匿名管道”与“作业”(进程组)

用“匿名管道”串起来的多个进程,构成一个“作业”(这点前面提到了)。
你可以尝试执行某个长寿命的,带管道符的命令行,然后用 Ctrl + Z 切到后台,再执行 jobs 看一下,就能看出——该命令行对应的【多个】进程属于同一个 job。

★批处理(batch)

◇啥是“批处理”?


  通俗地说就是:同时执行多个命令。
  为了支持“批处理”,shell 需要提供若干语法规则。而且不同类型的 shell,用来搞“批处理”的语法规则也存在差异。
  在本章节中,俺以 bash 来举例。

◇【无】条件的“批处理”


  如果你把多个命令写在同一行,并且命令之间用半角分号隔开,这种玩法就属于【无条件】的批处理执行。
  举例:
  假设当前目录下有一个 abc.txt 文件,然后要在当前目录下创建一个名为 xxx 的子目录,并把 abc.txt 移动到这个新创建的子目录中。你可以用如下方式搞定(只用【一行】命令)

mkdir ./xxx/; mv abc.txt ./xxx/

为啥这种方式叫做“【无条件】批处理”捏?因为不管前一个“子命令”是否成功,都会继续执行下一个“子命令”。

  请注意:
  虽然俺上述举例只使用了两个“子命令”,但实际上这种玩法可以把 N 个“子命令”串起来。

◇【有】条件的“批处理”


  与“无条件”相对应的,当然是“有条件”啦。
  这种玩法的意思是——后一个“子命令”是否执行,取决于【前一个】“子命令”的结果(成功 or 失败)。
  (注:如何界定“成功/失败”,请参见前面某个章节聊到的【进程退出码】)
  【有】条件的批处理,常见的方式有两种,分别是【逻辑与】、【逻辑或】。

  逻辑与(语法:&&
  只要前面的某个“子命令”【失败】了,就【不再】执行后续的“子命令”。
  举例:
  还是拿前一个小节的例子。如下方式使用了“逻辑与”。如果创建子目录失败,就【不再】执行“移动文件”的操作

mkdir ./xxx/ && mv abc.txt ./xxx/

逻辑或(语法:||
  只要前面的某个“子命令”【成功】了,就【不再】执行后续的“子命令”。
  举例:
  把上述例子进一步扩充,变为如下:

mkdir ./xxx/ && mv abc.txt ./xxx/ || echo "FAILED!!!"

这个有点复杂,俺稍微解释一下:
  你把前面两句看作一个【整体】。其执行的逻辑参见前面所说的“逻辑与”。然后这个“整体”与后面的那句 echo 再组合成【逻辑或】的关系。
  也就是说,如果前面的“整体”成功了,那么就【不】执行 echo(【不】打印错误信息);反之,如果前面的“整体”失败了,就会打印错误信息。


★shell 脚本


  虽然前一个章节拿 bash 来举例。但其实有很多其它类型的 shell 都支持类似的“批处理”机制。
  只要某个 shell 支持刚才所说的【有条件批处理】的机制,它就已经很接近【编程语言】了。
  于是很自然地,那些 shell 的作者就会把 shell 逐步发展成某种【脚本语言】的解释器。然后就有了如今的“shell script”(shell 脚本)和“shell 编程”。
  由于“shell 编程”这个话题比较大。哪怕俺只聊 bash 这一类 shell 的编程,也足够写上几万字的博文。考虑到本文已经很长了,这个话题就不再展开。
  对此感兴趣的同学,可以参考俺分享的电子书。具体参见电子书清单的如下几本(这几本都位于【IT类 / 操作系统 / 使用教程】分类目录下)
Shell 脚本学习指南》(Classic Shell Scripting)
Linux 与 UNIX Shell 编程指南》(Linux and UNIX Shell Programming)
高级 Bash 脚本编程指南》(Advanced Bash-Scripting Guide)
  上述这几本,都属于俺在《如何【系统性学习】——从“媒介形态”聊到“DIKW 模型”》中提到的【入门性读物】。最后一本书的名称中虽然有“高级”字样,不过别怕——其内容的5个部分,有4部分都是在讲基础的东西,只有最后一部分才稍微有一点点深度。

posted on 2020-03-08 13:46  KD7923  阅读(418)  评论(0编辑  收藏  举报

导航