扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”

本文目标读者

虽然本文的标题号称是【扫盲】,但俺相信:即使是一些 POSIX 系统的命令行【老手】,对本文中介绍的某些概念,可能也会有【欠缺】。
  因此,这篇教程既适合于命令行的新手,也值得某些【老手】看一看。

由于本文介绍的是 POSIX 系统中【通用的】概念与知识。因此,包括 Linux、BSD 家族、macOS 等各种系统的用户,应该都能从中受益。
  (注:POSIX 是某种操作系统的标准/规范。各种 Linux 发行版以及所有的 UNIX 变种,包括 macOS,都属于“POSIX 系统”)

如果你是这方面的【菜鸟】,并且想要掌握这个领域。【不要】企图只看一遍就完全理解本文的内容(可能需要看好几遍)。俺的建议是:要一边看,一边拿命令行的环境【实践】一下。

一切都从【电传打字机】开始说起

(说完了“引子”与“目标读者”,开始切入正题)
  可能有些读者会纳闷——“聊命令行的基本概念”,为啥要扯到“电传打字机”?是不是扯得太远了?
  俺来解释一下:
  IT 行业的很多基本概念都来自于【历史遗迹】。有时候你觉得某些东西很奇怪(并纳闷“为啥会设计成这样”);而当你搞清楚历史的演变过程之后,自然就明白其中的原因。

在那遥远的【电报时代】

在计算机诞生之前(二战前),【电报】属于高科技的玩意儿——它能够瞬间把信息传送到另一个城市(甚至传送到大洋彼岸)。
  当年的电报线路,是以【字符】为单位发送信息。在线路两端使用【电传打字机】,就可以自动地把对方发过来的字符打印出来。

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第1张图片
(上世纪40年代的电传打字机——用于电报网)

“回车/换行”的来历

稍微懂点 IT 的同学,应该都听说过“回车/换行”,洋文分别称之为“carriage return”&“line feed”。在编程领域,这两个字符简称为 \r\n
  为啥会有这么两个玩意儿捏?
  因为在电传打字机时代,当打印完一行之后,需要用一个控制命令把“打印头”复位(移到打印纸的左边),然后再用另一个控制命令把“打印头”往下移动一行。很自然地,这俩动作就对应了两个控制字符(CR & LF),也就是所谓的“回车 & 换行”。

其它控制字符

如果你去留意一下 ASCII 字符表的开头部分,前面那32个字符都是控制字符,很多都源于遥远的【电报时代】。
  在本文后续的介绍中,还会再聊到这些“控制字符”。

终端(terminal/TTY)

历史演变

“终端”一词,洋文称之为“terminal”。有时候又被称作 TTY,而 TTY 这个简写就来自刚才介绍的【电传打字机】(teletype printer)。
  因为早期的大型机,其“终端”就是【电传打字机】。那时候的终端,也称作【硬件终端】。

为啥会有“终端”这个概念捏?你依然需要了解历史的变迁。
  最早期的计算机(大型机)是【单任务】滴——也就是说,每次只能干一件事情。
  到了60年代,出现了一个【革命性】的飞跃——发明了【多任务】系统,当时叫做“time-sharing”(分时系统)。有了“分时系统”,就可以让多个人同时使用一台大型机。而为了让多个人同时操作这台大型机,就引入了【终端】的概念。每一台大型机安装多个终端,每个操作员都在各自的终端上进行操作,互不干扰。

(跑题)“约翰·麦卡锡”其人

聊到这里,稍微跑题一下:
  最早的“分时系统”由 IT 超级大牛“约翰·麦卡锡”(John McCarthy)设计。此人不仅仅是“分时系统它爹”,还是“Lisp 语言它爹”,另外还参与设计了编程语言“ALGOL 60”。而这个“ALGOL 60”编程语言虽然知道的人不多,但该语言深刻影响了后续的 Ada、BCPL、C、Pascal…
  为了让你体会这只大牛到底有多牛。俺引用另一个牛人保罗·格雷汉姆(《黑客与画家》作者)的观点——他认为在所有编程语言中, Lisp 与 C 是两座无法超越的高峰。而“约翰·麦卡锡”亲自发明了 Lisp 语言,然后又深刻地影响了 C 语言。
  另外,麦卡锡这只大牛还参与创立了“MIT 人工智能实验室”与“斯坦福人工智能实验室”。前者涌现出一大批早期的黑客,其中包括大名鼎鼎的 Richard Stallman(此人开创了:自由软件运动、GNU 社区、GCC、GDB、GNU Emacs …)。

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第2张图片
(超级大牛约翰·麦卡锡)

【远程】终端

跑题结束,言归正传。
  “终端”的好处不光是“多任务”,而且还可以让用户在【远程】进行操作。这种情况下,“终端”通过 modem(调制解调器)与“主机”相连。这种玩法很类似于——互联网普及初期的拨号上网。示意图如下:

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第3张图片
(通过 modem 实现的【远程】终端)

最早的“终端”,本质上就是“电传打字机”——以“打字机”作为输入;以“打印纸”作为输出。这类终端,比较经典的是如下这款:

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第4张图片
(Teletype Model 33 ASR)

到了上世纪70年初,终于有了带【屏幕】的远程终端。DEC 公司的 VT05 是第一款基于 CRT 显示器的远程终端。

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第5张图片
(VT05 终端)

内部结构示意图

下面这张是大型机时代,“终端”与“进程”通讯的示意图。
  图中的 UART 是洋文“Universal Asynchronous Receiver and Transmitter”的缩写(相关维基百科链接在“这里”)。LDISC 是洋文“line discipline”的简写(相关维基百科链接在“这里”)。
  通俗地说,UART 用来处理物理线路的字符传输(比如:“错误校验”、“流控”、等);LDISC 用来撮合底层的“硬件驱动”与上层的“系统调用”,并完成某些“控制字符”的处理与翻译。

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第6张图片
(TTY 示意图1:使用【硬件终端】的大型机内部结构图)

如今的含义

如今,“终端”一词的含义已经扩大了——用来指:基于【文本】的输入输出机制。
  在本文后续的章节中, terminal 与 TTY 这两个术语基本上是同义词。

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

字符模式(character mode)

又要说回到【电传打字机】。
  在本文开头,已经聊过这个玩意儿,并且提到——它是基于【字符】传输滴。也就是说,操作员每次在“电传打字机”上按键,对应的字符会立即通过线路发送给对方。这就是最传统的【字符模式】
  通俗地说,“字符模式”也就是【无缓冲】的模式。

行模式(line mode)

不客气地说,“字符模式”是非常傻逼滴!因为如果你不小心按错键,这个错误也会立即发送出去。
  比如说,你在输入一串很长的命令,结果输到半当中,敲错一个按键,整个命令就废了——要重新再输入一遍。
  所以,当早期的程序员对“字符模式”实在忍无可忍之后,终于发明了【行模式】。
  【行模式】也叫做“行缓冲”。也就是说,终端会把你当前输入的这行先缓冲在本地。只有当你最终按了【回车键】,才会把这一整行发送出去。如果你不小心敲错了一个字符,可以赶紧用“退格键”删掉重输这个字符。
  因此,这种模式称之为【行缓冲】。

顺便说一下:
  早期的标准键盘,【没有】方向键(“上下左右”这4个键)。不信的话,可以去看本文前面贴的那张“Teletype Model 33 ASR”的照片。
  因为无论是“字符模式”还是“行模式”,都没这个需求。

屏模式(screen mode/block mode)

“行模式”进一步的发展就是【屏模式】。这个玩意儿也叫“全屏缓冲”,顾名思义,终端会缓冲当前屏幕的内容。
  在这种模式下,用户可以利用方向键,操纵光标(cursor)在屏幕上四处游走。
  开发这种类型的软件,比较复杂——程序员至少需要做如下工作:
\1. 保存整个屏幕的状态
\2. 根据键盘输入,操纵光标(cursor)移动
\3. 控制屏幕的哪些区域是光标可达,哪些是不可达;
\4. 对于光标可达的部分,控制哪些是“可编辑”,哪些是“只读”;
\5. 根据“光标移动”以及某些“特定的按键”(比如“翻页键”),重新绘制屏幕

  后来,为了简化”屏模式“的编程,专门搞了一个叫做 curses 的编程库。如今的“ncurses 库”就是从 curses 衍生出来滴(前面加了一个 n 表示 new)。

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第7张图片
(“重编译 Linux 内核”的配置界面,基于 ncurses 实现)

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

小结

上述这三种模式,第1种基本淘汰(仅限于极少数场景);第3种用得也不多。与本文关系比较密切的,其实是【第2种】——行模式。
  为了加深你的印象,用 cat 命令来举例(注:这个命令其实与“猫”【无关】,而是 concatenate 的简写)
  大部分情况下,都是用它来显示某个文件的内容,比如说:cat 文件名 。但如果你运行 cat【没】加任何参数,那么它就会尝试读取你在终端的输入,然后把读到的文本再原样输出到终端。

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第8张图片
(动画:演示“行模式”的效果)

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

终端的【回显】

“回显”是啥?

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

◇“回显”的打开与关闭(启用/禁用)

虽然“回显”很人性化,但某些特殊的场合是【不想】“回显”滴,比如当你输入密码/口令的时候。
  因此,终端提供了某种机制,使得程序能够控制“回显”的启用/禁用。
  对于大多数终端,可以用【Ctrl + S】禁用“回显”,然后用【Ctrl + Q】启用“回显”。
  如果你在禁用“回显”的情况下输入一些文本,当你重新启用“回显”的瞬间,这些文本会一起出现在屏幕上。

顺便说一下:
  由于【Ctrl + S】在 Windows 上是很常见的组合键。某些菜鸟刚开始玩 Linux 命令行的时候,会习惯性地按这个组合键,结果就禁用了回显。这时候,任何键盘输入都没有反应。菜鸟就以为终端死掉了。

历史演变

对于 Windows 用户来说,【Ctrl + S】实在太常用了,很容易误按。肯定有大量的用户吐槽过 POSIX 终端的这个快捷键。
  那么,为啥要用这两个快捷键来控制“回显”捏?俺又要第 N 次说到【电传打字机】了。
  由于这玩意儿的输出是【打印纸】,其速率比较【慢】。一旦“对方发送字符的速率”高于“自己这边的打印速率”,就需要向对方发一个控制信号,让对方暂停发送;等到自己这边打印完了,再发送另一个控制字符,通知对方继续。
  (注:上述这种玩法,通信领域行话称之为“流量控制/流控”)
  当年用来表示“暂停发送”的控制字符,对应的就是【Ctrl + S】;用来“恢复发送”的控制字符,也正是【Ctrl + Q】。

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

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

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第9张图片
(上世纪50年代,IBM 公司 704 大型机的控制台)

后来发明了“分时系统”。如刚才所说——“分时系统”使得大型机可以具备多个终端。在这种情况下,你可以把“控制台”通俗地理解为“本地终端”,而【不】是“控制台”的那些终端,称之为“远程终端”。
  在那个年代,计算机属于【非常非常稀缺】的资源。于是拥有大型机的公司,就可以【出租计算资源】,获得一笔相当可观的收入。他们把大型机的某个“远程终端”租给外来人员使用,然后根据“时间/空间”收取费用。由于资源的稀缺性,当年的 CPU 是按【秒】计费,而内存是按【KB】计费。
  由于“远程终端”可能会被【外人】使用,因此对“远程终端”的【权限】要进行一些限制。如果要进行一些高级别的操作(比如“关闭整个系统”),就只能限制在【控制台】(本地终端)进行。有些公司为了安全起见,还会把“控制台”单独锁在某个“secured room”里面。

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第10张图片
(上世纪60年代,DEC 公司 PDP-7 小型机的控制台)

(如今的)虚拟控制台(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 + FnCtrl + Alt + Fn 在这几个 console 之间切换(注:上述所说的 Fn 指的是 F1、F2… 之类的功能键)。

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

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第11张图片
(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)负责与这些驱动打交道,然后再把用户的输入输出转交给“终端模拟器”。

下面这张示意图是 xterm。别看它长得丑,它的出现也算是“里程碑”了。

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第12张图片
(xterm——“图形化终端模拟器”的祖师爷)

内部结构示意图

很多人把“emulator”与“PTY”混为一谈。实际上两者处于【不同】层次。
  在操作系统内部(内核),PTY 分为两部分实现,分别叫做“PTY master” & “PTY slave”。master 负责与“terminal emulator”打交道;而用户通过 emulator 里面的 shell 启动的其它进程,则与 slave 打交道。
  在这个环节中,“PTY slave”又进一步缩写为“PTS”。如果你用 ps 命令查看系统中的所有进程,经常会看到 PTS 之类的字样,指的就是这个玩意儿。对普通用户而言,看到的是“终端模拟器”的界面,至于 PTY 内部的 master & slave,通常是感觉不到滴。

为了让大伙儿更加直观,再放一张 PTY 的结构示意图。

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第13张图片
(TTY 示意图3:【伪终端】的内部结构图)

shell——命令行解释器

费了好多口水,咱们终于聊到 shell 了。
  顺便吐槽一下:
  扫盲命令行的教程,很少会像俺这样,从最基本的概念说起。其导致的后果就是——很多人(甚至包括很多 Linux 程序员)都搞不清“shell、terminal、console、TTY、PTY、PTS”这些概念到底有啥区别。
  在《如何【系统性学习】——从“媒介形态”聊到“DIKW 模型”》一文中,俺特别强调了【基本概念/基础知识】的重要性。这也就是俺为啥前面要费这么多口水的原因。

shell VS terminal

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

常见 shell 举例

常见的 shell 包括如下这些(为避免排名纠纷,按字母序列出):

bash
csh
fish
ksh
zsh

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

shell 的基本功能

显示【命令行提示符】

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

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第14张图片
(截图中的“命令行提示符”包含了:用户名、当前路径、$分隔符)

很多 shell 的“命令行提示符”都会包含【当前路径】。当你用 cd 命令切换目录,提示符也会随之改变。这有助于你搞清楚当前在哪个目录下,可以有效避免误操作
  下面这张图演示了——“命令行提示符”随着当前目录的变化而变化。

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第15张图片

大部分 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

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第16张图片
(“进程树”的效果图。注:为了避免暴露俺的系统信息,特意【不】用自己系统的截图)

初始进程

一般情况下,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 启动了某个进程,它当然可以拿到这个进程的编号(pid),于是 shell 会调用某个系统 API(比如 tcsetpgrp)把“进程编号”与 shell 所属的“终端”关联起来。
  当“终端”需要发送 SIGINT 信号时,再调用另一个系统 API(比如 tcgetpgrp),就可以知道当前进程的编号。

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

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

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

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

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

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

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

为了方便对照上述这4种,俺放一个表格如下:

信号名称编号能否屏蔽默认动作俗称SIGINT2YES进程自己退出自杀SIGTERM15YES进程自己退出自杀SIGQUIT3YES执行 core dump
进程自己退出自杀SIGKILL9NO进程被内核干掉它杀

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

请注意:**【它杀】是一种比较危险的做法,可能导致一些【副作用】。**只有当你用其它各种方式都无法干掉某个进程,才考虑用这招。
  有读者在评论区问到了“它杀的副作用”,俺简单解释一下:
  一方面,当操作系统用这种方式杀掉某个进程,虽然可以把很多内存相关的资源释放掉,但【内存之外】的资源,内核就管不了啦;另一方面,由于进程遭遇“它杀”,无法完成某些善后工作。
  基于上述两点,就【有可能】会产生副作用。另外,“副作用的严重程度”取决于不同类型的软件。无法一概而论。

举例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 对接到终端的【输出】。示意图如下:

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第17张图片
(三个【标准流】的示意图)

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

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

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

扫盲 Linux&UNIX 命令行——从“电传打字机”聊到“shell 脚本编程”_第18张图片
(动画:“标准输入输出”的效果)

请注意,第1行 test 是针对 cat 进程的【输入】,对应于【stdin】(你之所以能看到这行,是因为前面所说的【终端回显】)
  第2行 testcat 进程拿到输入文本之后的原样输出,对应于【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部分都是在讲基础的东西,只有最后一部分才稍微有一点点深度。

★结尾

由于这篇涉及的内容比较杂,跨度也比较大。可能会有一些俺没覆盖到的地方。欢迎在博客留言中补充。
关系。
  也就是说,如果前面的“整体”成功了,那么就【不】执行 echo(【不】打印错误信息);反之,如果前面的“整体”失败了,就会打印错误信息。

你可能感兴趣的:(Linux)