早期的终端

preview

早期的终端一般是一种叫做 电传打字机 (Teletype) 的设备。为啥呢?因为 Unix 的创始人 Ken Thompson 和 Dennis Ritchie 想让 Unix 成为一个多用户系统。多用户系统就意味着要给每个用户配置一个终端,每个用户都要有一个显示器、一个键盘。但当时所有的计算机设备都非常昂贵(包括显示器),而且键盘和主机是集成在一起的,根本没有独立的键盘。

后来他们机智地找到了一样东西,那就是 ASR-33 电传打字机。虽然电传打字机原本的用途是在电报线路上收发电报,类似如下:

+----------+     Physical Line     +----------+
| teletype |<--------------------->| teletype |
+----------+                       +----------+

但是它既有可以发送信号的键盘,又能把接收到的信号打印在纸带上,完全可以作为人机交互设备使用。

而且最重要的是,价格低廉。

于是,他们把很多台 ASR-33 连接到计算机上,让每个用户都可以在终端登录并操作主机。就这样,他们创造了计算机历史上第一个真正的多用户操作系统 Unix,而电传打字机就成为了第一个 Unix 终端。

终端模拟器

随着计算机的进化,我们已经见不到专门的终端硬件了,取而代之的则是键盘与显示器。

但是没有了终端,我们要怎么与那些传统的、不兼容图形接口的命令行程序(比如说 GNU 工具集里的大部分命令)交互呢?这些程序并不能直接读取我们的键盘输入,也没办法把计算结果显示在我们的显示器上。

这时候我们就需要一个程序来模拟传统终端的行为,即 终端模拟器 (Terminal Emulator)。

 +----------+        +-------------------+
 | Keyboard | -----> |                   |        +-------+
 +----------+        | Terninal Emulator | <----> | Shell |
 |  Monitor | <----- |                   |        +-------+
 +----------+        +-------------------+

对于 Shell,终端模拟器会「假装」成一个传统终端设备;而对于现代的图形接口,终端模拟器会「假装」成一个 GUI 程序。一个终端模拟器的标准工作流程是这样的:

  1. 捕获你的键盘输入;
  2. 将输入发送给 Shell(Shell 会认为这是从一个真正的终端设备输入的);
  3. 拿到命 Shell 的输出结果;
  4. 调用图形接口(比如 X11),将输出结果渲染至显示器。

终端模拟器有很多,这里就举几个经典的例子:

  • GNU/Linux:gnome-terminal、Konsole;
  • macOS:Terminal.app、iTerm2;
  • Windows:Win32 控制台 、ConEmu 等。

my-terminals.png

▲终端模拟器:Hyperwsl-terminal

现在,专门的终端硬件已经基本上仅存于计算机博物馆,人们通常图省事儿,直接称呼终端模拟器为「终端」。

终端窗口 vs 虚拟控制台

大部分终端模拟器都是在图形用户界面 (GUI) 中运行的,但是也有例外。

比如在 GNU/Linux 操作系统中,按下 Ctrl + Alt + F1,F2…F6 等组合键可以切换出一个黑不溜秋的全屏终端界面,不过不要被它们唬着了,虽然它们并不运行在图形界面中,但其实它们也是终端模拟器的一种。

preview

▲ 一个正在显示系统启动信息的虚拟控制台

这些全屏的终端界面与那些运行在 GUI 下的终端模拟器的唯一区别就是它们是 由操作系统内核直接提供的。这些由内核直接提供的终端界面被叫做 虚拟控制台 (Virtual Console),而上面提到的那些运行在图形界面上的终端模拟器则被叫做 终端窗口 (Terminal Window)。除此之外并没有什么差别。

当然了,因为终端窗口是跑在图形界面上的,所以如果图形界面宕掉了那它们也就跟着完蛋了。这时候你至少还可以切换到 Virtual Console 去救火,因为它们由内核直接提供,只要系统本身不出问题一般都可用。

什么是 shell

shell 是一个程序,它接受从键盘输入的命令,然后把命令传递给操作系统去执行。

 +----------+        +-------------------+
 | Keyboard | -----> |                   |        +-------+        +----+
 +----------+        | Terninal Emulator | <----> | Shell | -----> | OS |
 |  Monitor | <----- |                   |        +-------+        +----+
 +----------+        +-------------------+

什么是 TTY

最早的 Unix 终端是 ASR-33 电传打字机。而电传打字机 (Teletype/Teletypewriter) 的英文缩写就是 tty,即 tty 这个名称的来源。

由于 Unix 被设计为一个多用户操作系统,所以人们会在计算机上连接多个终端(在当时,这些终端全都是电传打字机)。Unix 系统为了支持这些电传打字机,就设计了名为 tty 的子系统(没错,因为当时的终端全都是 tty,所以这个系统也被命名为了 tty,就是这么简单粗暴),将具体的硬件设备抽象为操作系统内部位于 /dev/tty* 的设备文件。

tty▲ 还记得上面我们说过的特殊的终端,也就是通过 Ctrl + Alt + F1-6 呼出的那些虚拟控制台 (Virtual Console) 吗?其对应的就是上图中的 tty1tty6

随着计算机的发展,终端设备已经不再限制于电传打字机,但是 tty 这个名称还是就这么留了下来。久而久之,它们的概念就混淆在了一起。所以在现代,tty 设备就是终端设备,终端设备就是 tty 设备,无需区分。

                      +----------------+
                      |   TTY Driver   |
                      |                |
                      |   +-------+    |       +----------------+
 +------------+       |   |       |<---------->| User process A |
 | Terminal A |<--------->| ttyS0 |    |       +----------------+
 +------------+       |   |       |<---------->| User process B |
                      |   +-------+    |       +----------------+
                      |                |
                      |   +-------+    |       +----------------+
 +------------+       |   |       |<---------->| User process C |
 | Terminal B |<--------->| ttyS1 |    |       +----------------+
 +------------+       |   |       |<---------->| User process D |
                      |   +-------+    |       +----------------+
                      |                |
                      +----------------+

由于早期计算机上的 串行端口 (Serial Port) 最大的用途就是连接终端设备,所以当时的 Unix 会把串口上的设备也同样抽象为 tty 设备(位于 /dev/ttyS*)。因此,现在人们也经常将串口设备称呼为 tty 设备。

内核 TTY 子系统

    +-----------------------------------------------+
    |                    Kernel                     |
    |                                 +--------+    |
    |   +--------+   +------------+   |        |    |       +----------------+
    |   |  UART  |   |    Line    |   |  TTY   |<---------->| User process A |
<------>|        |<->|            |<->|        |    |       +----------------+
    |   | driver |   | discipline |   | driver |<---------->| User process B |
    |   +--------+   +------------+   |        |    |       +----------------+
    |                                 +--------+    |
    |                                               |
    +-----------------------------------------------+
  • UART driver对接外面的UART设备
  • Line discipline主要是对输入和输出做一些处理,可以理解它是TTY driver的一部分
  • TTY driver用来处理各种终端设备
  • 用户空间的进程通过TTY driver来和终端打交道

什么是 pts

liyongjun@Box:~$ tty
/dev/pts/0
liyongjun@Box:~$ lsof /dev/pts/0
COMMAND   PID      USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
bash     2206 liyongjun    0u   CHR  136,0      0t0    3 /dev/pts/0
bash     2206 liyongjun    1u   CHR  136,0      0t0    3 /dev/pts/0
bash     2206 liyongjun    2u   CHR  136,0      0t0    3 /dev/pts/0
bash     2206 liyongjun  255u   CHR  136,0      0t0    3 /dev/pts/0
lsof    40676 liyongjun    0u   CHR  136,0      0t0    3 /dev/pts/0
lsof    40676 liyongjun    1u   CHR  136,0      0t0    3 /dev/pts/0
lsof    40676 liyongjun    2u   CHR  136,0      0t0    3 /dev/pts/0
liyongjun@Box:~$ echo 666 > /dev/pts/0
666

pts 也是 tty 设备。

通过上面的 lsof 可以看出,当前运行的 bash 和 lsof 进程的 stdin(0u)、stdout(1u)、stderr(2u) 都绑定到了这个TTY 上。

下面是 tty 和进程以及 I/O 设备交互的结构图:

   Input    +--------------------------+    R/W     +------+
----------->|                          |<---------->| bash |
            |          pts/0           |            +------+
<-----------|                          |<---------->| lsof |
   Output   | Foreground process group |    R/W     +------+
            +--------------------------+                               
  • 可以把 tty 理解成一个管道(pipe),在一端写的内容可以从另一端读取出来,反之亦然。
  • 这里input和output可以简单的理解为键盘和显示器,后面会介绍在各种情况下 input/ouput 都连接的什么东西。
  • tty 里面有一个很重要的属性,叫 Foreground process group,记录了当前前端的进程组是哪一个。process group 的概念会在下一篇文章中介绍,这里可以简单的认为 process group 里面只有一个进程。
  • 当 pts/0 收到 input 的输入后,会检查当前前端进程组是哪一个,然后将输入放到进程组的 leader 的输入缓存中,这样相应的 leader 进程就可以通过 read 函数得到用户的输入
  • 当前端进程组里面的进程往 tty 设备上写数据时,tty 就会将数据输出到 output 设备上
  • 当在 shell 中执行不同的命令时,前端进程组在不断的变化,而这种变化会由 shell 负责更新到 tty 设备中

从上面可以看出,进程和 tty 打交道很简单,只要保证后台进程不要读写 tty 就可以了,即写后台程序时,要将stdin/stdout/stderr 重定向到其它地方

TTY是如何被创建的

下面介绍几种常见的情况下 tty 设备是如何创建的,以及 input 和 output 设备都是啥。

键盘显示器直连 终端

先看图再说话:

                   +-----------------------------------------+
                   |          Kernel                         |
                   |                           +--------+    |       +----------------+ 
 +----------+      |   +-------------------+   |  tty1  |<---------->| User processes |
 | Keyboard |--------->|                   |   +--------+    |       +----------------+
 +----------+      |   | Terminal Emulator |<->|  tty2  |<---------->| User processes |
 | Monitor  |<---------|                   |   +--------+    |       +----------------+
 +----------+      |   +-------------------+   |  tty3  |<---------->| User processes |
                   |                           +--------+    |       +----------------+
                   |                                         |
                   +-----------------------------------------+

键盘、显示器都和内核中的终端模拟器相连,由模拟器决定创建多少tty,比如你在键盘上输入 ctrl+alt+F1 时,模拟器首先捕获到该输入,然后激活 tty1,这样键盘的输入会转发到 tty1,而 tty1 的输出会转发到显示器,同理用输入 ctrl+alt+F2,就会切换到 tty2。

当模拟器激活 tty 时如果发现没有进程与之关联,意味着这是第一次打开该 tty,于是会启动配置好的进程并和该tty 绑定,一般该进程就是负责 login 的进程。

当切换到 tty2 后,tty1 里面的输出会输出到哪里呢?tty1 的输出还是会输出给模拟器,模拟器里会有每个 tty 的缓存,不过由于模拟器的缓存空间有限,所以下次切回 tty1 的时候,只能看到最新的输出,以前的输出已经不在了。

不确定这里的终端模拟器对应内核中具体的哪个模块,但肯定有这么个东西存在

SSH远程访问

 +----------+       +------------+
 | Keyboard |------>|            |
 +----------+       |  Terminal  |
 | Monitor  |<------|            |
 +----------+       +------------+
                          |
                          |  ssh protocol
                          |
                          ↓
                    +------------+
                    |            |
                    | ssh server |--------------------------+
                    |            |           fork           |
                    +------------+                          |
                        ||
                        |   |                               |
                  write |   | read                          |
                        |   |                               |
                  +-----|---|-------------------+           |
                  |     |   |                   |||      +-------+    |       +-------+
                  |   +--------+   | pts/0 |<---------->| shell |
                  |   |        |   +-------+    |       +-------+
                  |   |  ptmx  |<->| pts/1 |<---------->| shell |
                  |   |        |   +-------+    |       +-------+
                  |   +--------+   | pts/2 |<---------->| shell |
                  |                +-------+    |       +-------+
                  |    Kernel                   |
                  +-----------------------------+

这里的 Terminal 可以是任何地方的程序,比如 windows 上的 putty,所以不讨论客户端的 Terminal 程序是怎么和键盘、显示器交互的。由于 Terminal 要和 ssh 服务器打交道,所以肯定要实现 ssh 的客户端功能。

这里将建立连接和收发数据分两条线路解释,为了描述简洁,这里以sshd代替ssh服务器程序:

建立连接

  • 1.Terminal请求和sshd建立连接
  • 2.如果验证通过,sshd将创建一个新的session
  • 3.调用API(posix_openpt())请求ptmx创建一个pts,创建成功后,sshd将得到和ptmx关联的fd,并将该fd和session关联起来。
#pty(pseudo terminal device)由两部分构成,ptmx是master端,pts是slave端,
#进程可以通过调用API请求ptmx创建一个pts,然后将会得到连接到ptmx的读写fd和一个新创建的pts,
#ptmx在内部会维护该fd和pts的对应关系,随后往这个fd的读写会被ptmx转发到对应的pts。

#这里可以看到sshd已经打开了/dev/ptmx
liyongjun@Box:~$ sudo lsof /dev/ptmx 
COMMAND     PID      USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
sshd      41185 liyongjun   10u   CHR    5,2      0t0   88 /dev/ptmx
sshd      41185 liyongjun   12u   CHR    5,2      0t0   88 /dev/ptmx
sshd      41185 liyongjun   13u   CHR    5,2      0t0   88 /dev/ptmx
  • 4.同时sshd创建shell进程,将新创建的pts和shell绑定

收发消息

  • 1.Terminal 收到键盘的输入,Terminal 通过 ssh 协议将数据发往 sshd
  • 2.sshd 收到客户端的数据后,根据它自己管理的 session,找到该客户端对应的关联到 ptmx 上的 fd
  • 3.往找到的 fd 上写入客户端发过来的数据
  • 4.ptmx 收到数据后,根据 fd 找到对应的 pts(该对应关系由 ptmx 自动维护),将数据包转发给对应的 pts
  • 5.pts 收到数据包后,检查绑定到自己上面的当前前端进程组,将数据包发给该进程组的 leader
  • 6.由于 pts 上只有 shell,所以 shell 的 read 函数就收到了该数据包
  • 7.shell 对收到的数据包进行处理,然后输出处理结果(也可能没有输出)
  • 8.shell 通过write函数将结果写入 pts
  • 9.pts 将结果转发给 ptmx
  • 10.ptmx 根据 pts 找到对应的 fd,往该 fd 写入结果
  • 11.sshd 收到该 fd 的结果后,找到对应的 session,然后将结果发给对应的客户端

键盘显示器直连 图形界面

 +----------+       +------------+
 | Keyboard |------>|            |
 +----------+       |  Terminal  |--------------------------+
 | Monitor  |<------|            |           fork           |
 +----------+       +------------+                          |
                        ||
                        |   |                               |
                  write |   | read                          |
                        |   |                               |
                  +-----|---|-------------------+           |
                  |     |   |                   |||      +-------+    |       +-------+
                  |   +--------+   | pts/0 |<---------->| shell |
                  |   |        |   +-------+    |       +-------+
                  |   |  ptmx  |<->| pts/1 |<---------->| shell |
                  |   |        |   +-------+    |       +-------+
                  |   +--------+   | pts/2 |<---------->| shell |
                  |                +-------+    |       +-------+
                  |    Kernel                   |
                  +-----------------------------+

为了简化起见,本篇不讨论Linux下图形界面里Terminal程序是怎么和键盘、显示器交互的。

这里和上面的不同点就是,这里的 Terminal 不需要实现 ssh 客户端,但需要把 ssh 服务器要干的活也干了(当然ssh 通信相关的除外)。

TTY和PTS的区别

从上面的流程中应该可以看出来了,对用户空间的程序来说,他们没有区别,都是一样的;从内核里面来看,pts 的另一端连接的是 ptmx,而 tty 的另一端连接的是内核的终端模拟器,ptmx 和终端模拟器都只是负责维护会话和转发数据包;再看看 ptmx 和内核终端模拟器的另一端,ptmx 的另一端连接的是用户空间的应用程序,如 sshd、tmux 等,而内核终端模拟器的另一端连接的是具体的硬件,如键盘和显示器。

TTY相关信号

除了上面介绍配置时提到的 SIGINT,SIGTTOU,SIGWINCHU 外,还有这么几个跟 TTY 相关的信号

SIGTTIN

当后台进程读 tty 时,tty 将发送该信号给相应的进程组,默认行为是暂停进程组中进程的执行。暂停的进程如何继续执行呢?请参考下一篇文章中的 SIGCONT。

SIGHUP

当 tty 的另一端挂掉的时候,比如 ssh 的 session 断开了,于是 sshd 关闭了和 ptmx 关联的 fd,内核将会给和该tty 相关的所有进程发送 SIGHUP 信号,进程收到该信号后的默认行为是退出进程。

SIGTSTP

终端输入 CTRL+Z 时,tty 收到后就会发送 SIGTSTP 给前端进程组,其默认行为是将前端进程组放到后端,并且暂停进程组里所有进程的执行。

跟 tty 相关的信号都是可以捕获的,可以修改它的默认行为

参考

命令行界面 (CLI)、终端 (Terminal)、Shell、TTY,傻傻分不清楚?

Linux TTY/PTS概述 - SegmentFault 思否

《快乐的Linux命令行》—— 什么是 shell

流程图工具

一个好用的在线纯文本流程图绘制工具