长久以来,我们把终端和 shell 视作开发者理所当然的基本工具,但其底层涉及的组件和机制却并不简单。作为一名后端工程师,每天使用终端的时长甚至会超过代码编辑器,深入了解其背后的实现是有必要的。本文将尝试尽量清晰全面的介绍 Unix 所使用的终端系统。
本文内容主要以 Linux 系统的具体实现为基础,但因为其遵循 SUSv3(Single UNIX Specification Version 3)规范,因此也适用于其他 Unix 系统(如 macOS)。
tty 的由来
所有介绍 tty 的文章都会介绍终端设备的发展史,过去几十年硬件设备的发展日新月异,但直到今天,Unix 终端在软件部分仍在使用从上世纪 70 年代沿袭至今的工作机制。
电传打字机
电传打字机是早期的计算机输入输出设备,其英文 teletype 的缩写也是 tty 这一名称的由来。终端和 tty 实际上是可相互替代的同义术语,下文将结合语境使用。
电传打字机的发明远远早于计算机,一开始计算机的计算能力无法支持实时的交互,只能使用打孔卡片进行批处理计算,然后等待一晚上之后得到结果。随着算力的提升,计算机操作系统从批处理系统迈向分时处理系统,聪明的计算机先驱们发现稍作改造,就能使用现成的电传打字机作为输入输出设备。
电传打字机通过两条线缆连接到计算机的 UART(Universal Asynchronous Receiver and Transmitter)接口,一条线缆传输从电传打字机按下键盘触发的输入信号到计算机,一条线缆传输从计算机回到电传打字机的输出信号。计算机操作系统提供 UART 驱动程序来管理字节的物理传输,包括奇偶校验和流量控制。
整个终端系统的示意图如下:
引用自 http://www.linusakesson.net/programming/tty/
引用自 https://www.yabage.me/2016/07/08/tty-under-the-hood/
特定的 UART 驱动、line descipline
实例和 TTY 驱动三者组成了一个 TTY 设备,有时也被直接称为 TTY。这只是一个抽象表示,如果你对它们之间的交互细节还有疑惑,后面的章节会深入其内部实现。
下面通过一段执行 cat
命令的输入序列和输出,来描述电传打字机终端的工作过程。输入序列如下:
cat^M
hello^M
^Z
^M
表示按下回车键,按惯例下文均使用 ^
表示 ASCII 控制字符,如 ^Z
实际表示按下 Ctrl + Z
键。
电传打字机应当打印以下结果:
|
|
- 在打字机敲下
cat
命令时,电信号经过 UART 驱动转换为 ASCII 字符并传递给line discipline
组件的字符缓冲区,此时可以通过退格键或者^W
键对缓冲区中的内容进行编辑,只有当我们按下回车键后,line discipline
才会将整行字符串发送给tty driver
,之后用户空间的shell
进程可以通过read()
调用读取到cat
命令并执行。 - 默认配置下
line discipline
组件会将字符缓冲区的字符立刻复制到输出缓冲区,随即经过 UART 驱动转换成电信号通过输出线缆传输给电传打字机,因此电传打字机会立刻打印输入的每一个字符,这一机制被称为echo
(回声,这也许是 echo 命令的最初由来)。 cat
命令执行时,其标准输入、标准输出和标准错误都指向当前的tty driver
。再次从电传打字机输入hello
并回车时,电传打字机首先echo
输入的字符,在换行后,cat
命令从标准输入读取到一行hello
并立刻写入到标准输出也就是tty driver
,再经过line discipline
最终输出到电传打字机,也就是我们看到的第二行hello
。- 最后按下
^z
按钮,这两个按键对应 26 这个 ASCII 码点,在到达line discipline
组件时,line discipline
并不会将该字符继续发给tty driver
,而是在echo
给电传打字机后发送一个SIGSTP
信号给当前正在运行的cat
进程,使其进入STOPPED
状态。 - 控制权回到
shell
进程后,由于它是cat
进程的父进程,可以通过wait()
调用获取子进程的运行状态,shell
进程会打印一行[1]+ 已停止 cat
到tty driver
。
半个世纪后的今天,终端的使用方式与上述过程并没有太大差别。
视频终端
DEC VT-100 视频终端
随着显示屏技术的进步,很快出现了基于数字图形的视频终端,1978 年发布的 VT100 是其中最具代表性的终端产品。
除了显示效果大幅提升,视频终端还能根据特殊的转义码(escape code)执行光标移动、换行等操作,其使用效果已经接近我们今天所使用的终端了。虽然视频终端长的像家用计算机,但实际上没有任何计算能力,是纯粹的输入输出设备。
虽然外接设备实现了全面升级,不过这一阶段终端系统的软件架构并没有太大变化,可以认为和电传打字机基本相同。
终端模拟器
现在只有在博物馆才能见到以上两种专用终端设备了,取而代之的,是完全用软件实现的的终端模拟器,以及显示器和键盘等通用外接设备。UART 和物理终端不复存在,操作系统在内核完全通过软件模拟出一个视频终端,并直接渲染到显示器设备:
这一系统已经相当接近我们日常使用的终端,你可以在 Ubuntu 等发行版的图形界面直接通过 Ctrl+Alt+F1
呼出一个模拟终端。这些虚拟设备在操作系统的 /dev
目录下作为字符设备存在:
|
|
内核空间实现的终端模拟器不够灵活,一些场景下,如通过 ssh
连接到远程主机或者使用 iterm2 等自定义终端程序,使用的是基于 tty 系统扩展的伪终端(pseudoterminal, pty),在 shell 中执行 tty
命令可以直接获取当前使用的终端:
|
|
在终端中执行的命令会打开所当前所使用的终端作为其标准输入、标准输出和标准错误:
|
|
由于核心机制相同,伪终端会在稍后一些的章节中介绍,我们先来了解一下 job controll 机制。
job controll
作为计算机早期的主要交互设备,终端从一开始就承载了 job controll(作业控制)的职能,使用终端可以方便地在系统中同时运行多个进程:输入 ^C
或 ^Z
杀死或挂起进程,通过 fg
或者 bg
让进程切换到前台或后台运行,关闭终端时终止当前终端中的后台进程。
job controll 是由 tty 系统配合 shell 共同实现的,支持 job controll 功能的 shell 称为 job controll shell,主要的交互式 shell 都属于 job controll shell。
process group 和 job
在 shell 中执行命令时,shell 会 fork
出一个新的进程去执行命令,在简单地单个命令场景下,会创建一个仅包含单个进程的进程组;在执行多个命令组成的管道时,一个管道中的所有进程都属于一个新的进程组。
进程组中的每个进程都有相同的进程组 ID,这个 ID 与其中一个进程的 PID 相同,该进程被称为进程组 leader。一个进程组也被称为一个 job,下文将使用 job 来称呼进程组。
内核允许对一个进程组也就是一个 job 的所有成员同时进行各种操作,特别是发送信号。这一特性是 job controll 的基础机制:shell 允许用户暂停或恢复一个管道中的所有进程,tty 可以对通过 shell 执行的命令以 job 为单位进行管理。
除了终端输入,我们还可以通过 jobs
和 fg
、bg
、kill
等命令管理 job:
|
|
session 和控制终端
用户通过 login
命令登录主机时,在完成身份凭证的校验后,login
会使用 /etc/password
文件中指定的 shell 程序为用户创建一个初始 session。一个 session 是多个 job 的集合,一个 session 中创建的所有 job 都有相同的 session ID。
session leader 是创建 session 的进程,它的 PID 成为 session ID。使用 job controll shell 创建一个 session 时,session leader 即为该 shell 进程。默认情况下,fork(2)
创建的子进程与父进程属于同一进程组,因此从键盘发出的 ^C
将同时影响父进程和子进程。但作为 session leader 的职责之一,shell 每次执行命令或启动管道时都会创建一个新的进程组。
执行 ps -efj
命令,可以查看进程对应的进程组 ID(PGID)和 session ID(SID)(命令输出经过筛选):
|
|
session 通常会有一个控制终端。控制终端是在 session leader 进程第一次打开 tty 设备时建立的(有些 Unix 系统需要执行特定的调用才能将一个 tty 设置为控制终端)。对于一个由交互式 shell 创建的会话,控制终端就是用户登录所使用的终端。一个终端最多可以成为一个 session 的控制终端。
打开控制终端后,session leader 将成为该终端的控制进程。如果终端断开连接(例如终端窗口被关闭),控制进程会收到一个 SIGHUP
信号,如果控制进程是 shell
,它会在退出前先向当前 session 中已暂停的 job 发送 SIGCONT
信号,并在这些 job 恢复运行后向所有 job 发送 SIGHUP
信号。这就是关闭终端窗口时,终端中的后台执行命令或已暂停命令也会被终止的原因。
在任意时刻一个 session 中有且仅有一个 job 位于前台,没有执行任何命令时,前台 job 就是交互式 shell 本身。一个 session 中只有前台 job 可以从控制终端读取输入并向其发送输出。如果用户通过控制终端输入中断字符(通常是 ^C
)或暂停字符(通常是 ^Z
),终端驱动程序就会发送一个信号,杀死或暂停(即停止)前台 job。
执行 ps l
命令时,如果 STAT 一栏包含小写的 s
,该进程是其所在 session 的 session leader;如果包含 +
号,说明该进程属于其所在 session 的前台 job:
|
|
一个 session 可以有任意数量的后台 job,这些作业是通过用 &
字符来作为命令后缀而创建的。如果一个后台 job 尝试去读取或写入终端,终端驱动会向其发送 SIGTTIN
或 SIGTTOU
信号,上文示例中执行 bg bat
时(bat
是一个现代化的 cat
程序),由于 bat
会尝试从标准输入即当前 tty 中读取,因此 job 在恢复运行后立刻收到了 tty driver
的 SIGTTIN
信号而又被挂起。
SIGTTIN
信号的默认行为是 suspend,但即使程序故意忽略了 SIGTTIN
信号,也无法从终端中成功读取到数据,SIGTTOU
也是同理。
shell 与 tty 的协作
为了实现 job controll,tty 还需要记录并且实时更新当前 session 的前台 job,这一任务实际上是由作为 session leader 的交互式 shell 来完成的,tty 只是被动地读取前台 job 信息。
在创建一个新的 job 并运行于前台后,shell 将 tty 当前的前台 job 设置为该 job;之后 shell 会通过 wait()
系统调用监控子进程也就是所有 job 的运行状态。如果当前的前台 job 结束运行或者被用户通过 ^Z
暂停,终端的控制权将重新归还给 shell,此时 shell 要做的第一件事是将 tty 当前的前台 job 重置为自己。否则,它自己对 tty 的 IO 也会失败(shell 需要打印提示符,循环读取输入以及打印刚刚被停止的进程)。
如果在当前的前台 job 中有多个进程,它们全都可以自由地向 TTY 输出,或者同时试图从 TTY 读取。job controll 只是保护你免受不同 job 的干扰,这些进程通常位于一个管道或者 shell 脚本中,所以一般来说不会出什么问题。
tty 的内部实现
linux 的 tty 实现
上文使用的 tty 示意图中,line discipline
被抽象为单独的组件,并且通过 tty driver
与 session 中的 job 进行交互,而操作系统的具体实现可能不同。接下来我们将以 Linux kernel 2.6 源码为基础,深入 tty 的内部实现。
终端系统的具体实现由三层驱动组成,示意图如下:
-
最上层提供通用的字符设备接口(如
read()
、write()
和ioctl()
)给用户进程访问,图中省略了用户进程访问字符设备所经过的 VFS 等中间层。用户进程通过字符设备接口写入要发送到 tty 设备的数据,然后数据通过
line discipline
传递给 tty 设备驱动的write()
方法。用户进程对字符设备执行
read()
时,会调用line discipline
的read()
方法,最终从输入缓冲区中获取数据。 -
中间的
line discipline
包装了终端设备层的访问行为,对经过的字符进行处理后放入专门的输入和输出缓冲区。此外line discipline
还有保存未回车提交的字符缓冲区,开启 tty 的 echo 配置时,写入到字符缓冲区的字符会自动被复制到输出缓冲区,最后写入到 tty 设备驱动。 -
下层硬件驱动与硬件或虚拟设备通信,并负责将接收到的数据发送给
line discipline
。
有些情况下用户进程可配置 line discipline
不对经过的数据进行处理,即数据将未经处理直接发送给用户进程,下文将详细介绍如何配置 line discipline
的具体行为。
line discipline
line discipline
是 tty 系统三层实现中最复杂的部分,顶层和底层的驱动只负责单一的数据输入输出功能,line discipline
层承担了数据处理和控台职能:
-
line buffer:对输入的单个字符进行缓冲,按下回车键时缓冲的整行字符才会发送到输入缓冲区,能够被
TTY
的前台用户进程所读取。今天我们很难理解 tty 的行缓冲设计,因为绝大部分命令行程序都能够接受单个字符的输入,由用户程序来处理删除换行等逻辑(如
vi
等编辑器)。但追溯到计算机发展的初期,CPU 的计算能力和内存大小都非常有限,如果由用户程序处理单个字符的输入,每一次输入都需要切换 CPU 上下文甚至从磁盘换入换出内存,频次较高的终端输入就会成为计算机的性能瓶颈,因此在内核建立中间层统一处理字符的输入是非常高效的选择。 -
line edit:在输入出错的情况下,用户可以输入特殊字符对尚未通过回车提交的字符进行编辑,
line discipline
负责将特殊字符转换为缓冲字符的编辑操作,如^H
退格、^W
擦除字、^U
清空行、^D
EOF。 -
echo:将输入的内容回传给终端,以便用户可以看到输入的内容,具体实现是由
line discipline
将输入的每个字符复制到输出缓冲区。 -
job controll:用户可以直接从终端输入特殊字符暂停或终止正在运行的进程,
line discipline
负责将特殊字符转换为信号并发送给前台 job,如^C
发送SIGINT
、^Z
发送SIGTSTP
等。
line discipline
使得终端提供的功能更加完整,但我们并不总是需要上述的所有特性:
- 计算机性能大幅提升的现在,line buffer 已经不再必要。
- 有时候我们需要自定义用于 line edit 和 job controll 的自定义按键。
- 打开 vi 编辑器的插入模式时,需要禁用 job controll,切换回命令模式后需要重新开启,且禁用 echo 功能。
- 通过 ssh 连接到远程主机时,需要禁用本地终端的 job controll,否则在本地输入
^C
会直接发送SIGINT
信号给本地的前台 job 即 ssh 客户端进程,即使 ssh 进程忽略了该信号,也无法通过输入将字符发送到远程终端。
因此 line discipline
提供的这些特性都是可以配置或者开关的。
配置 tty 的行为
tty 的各种行为具有极高的可配置性,通过 stty -a
命令可以列出当前 tty 的所有配置(-F
可以指定其他 tty 设备):
|
|
下面按行数对上面输出的配置项进行简要介绍(所有配置项的详细描述可通过 man stty
查阅):
- 输出速率(无终端硬件时无意义);rows 和 columns 代表以字符为单位的终端渲染窗口大小;line = 0 代表
line discipline
使用默认的N_TTY
类型驱动(其他类型基本用不上)。 - 当前 line edit 和 job controll 功能所绑定的特殊按键。
- 输出的第三行起都是控制
line discipline
和tty driver
行为的开关 flag,以-
开头表示该 flag 处于关闭状态。我们只介绍几个关键的 flag:- echo:控制 line discipline 是否 echo 终端的输入。
- icanon:控制 line buffer 和 line edit 行为,关闭后输入字符会立刻发送前台 job,且停止对特殊字符的转义。
- isig:控制是否开启对
INTR
,QUIT
和SUSP
信号对应的特殊符号的检查,如果关闭,输入^C
等特殊符号不会触发这些信号。 - raw:关闭绝大部分开关 flag(包括 icanon),终端基本不会对输入输出字符做任何处理。
这些配置项都可以通过 stty
命令更改,尝试执行如下命令禁用 icanon 和 echo,会发现终端的输入行为变得相当奇怪:
|
|
执行 stty sane
可以将当前 tty 恢复到默认配置。
stty
使用 SUSv3 定义的 termios
api 操作 tty 的配置,在所有 Unix 系统都可以通用,此外进程还可以使用 ioctl
或者 libc
提供的库函数读取或修改已打开的 TTY 设备的配置。
伪终端
为什么需要伪终端
虽然操作系统通过内核的终端模拟器实现了对终端硬件设备的模拟,但这种 tty 系统无法适应一些场景下的需求:
- 用户希望使用由用户实现的终端模拟程序以扩展终端的功能,需要某种机制接入当前的 tty 系统并替代内核的终端模拟器。
- 用户希望实现通过网络访问远程主机上的面向终端程序(比如
vi
),面向终端程序期望通过终端打开,以便执行一些面向终端的操作如通过 termios 接口设置终端icanon
和echo
开关 flag,以及实现终端对前台 job 的控制。这些操作要求远程主机上必须打开一个终端,否则对应的系统调用会执行失败,但我们无法通过 socket 等数据传输机制来连接本地程序到远程主机的终端模拟器。
tty 系统的特点就是在不断扩展的同时,仍然保持对历史设计的兼容性。为了实现以上需求,Unix 在原有 tty 系统的基础上发展出了伪终端,今天我们所使用的终端大部分都是伪终端。
什么是伪终端
伪终端(pseudoterminal)是一组成对的虚拟设备:一个 pty master 和一个 pty slave,有时也被称为 pty pair。一对伪终端设备提供了像双向管道一样的 IPC 通道—两个进程可以分别打开 master 和 slave,然后通过伪终端向任一方向传输数据。熟悉 Linux 网络的话你会发现它们特别像 veth pair。
如下图所示,理解伪终端有以下两个关键点:
-
slave 设备就像一个标准的 tty 设备一样工作,实际上它们甚至在内核中使用同一套驱动代码,用同一种对象表示,只是很小部分属性和方法有差异,所有可以应用于标准终端设备的操作也可以应用于 slave 设备,只有个别配置对伪终端没有意义会被忽略(例如设置终端的 speed)。
Once both the pseudoterminal master and slave are open, the slave provides processes with an interface that is identical to that of a real terminal.
在使用时,slave 设备代替原来的标准 tty 打开面向终端的程序,作为控制终端。
许多介绍 tty 的文章会把 pty master、pty slave 和 tty 视作三个独立个体进行交互,却忽视了 pty slave 本身就是
type=pty
的 tty 设备(type=console
为标准 tty 设备即终端模拟器)。 -
master 设备可以作为文件打开,打开后的文件就像通过输入输出线缆连接到了 slave 设备的 tty 驱动,写入文件的数据会输入到 slave 设备,slave 设备的输出数据能够从这个文件中读取,关闭 master 设备对 slave 设备意味着终端设备断开了连接。
在使用时,master 设备就像一个中继代理,对应的 tty 设备(pty slave)将其视作用户正在使用的终端硬件接收其输入的数据,并向其返回输出数据;用户程序以直接读写 master 设备文件的方式向 tty 设备输入数据并读取其输出,然后自行处理键盘输入和视频输出等逻辑,甚至不处理直接通过网络 socket 收发数据。
伪终端是如何工作的
与标准终端直接打开 /dev/ttyN
相比,伪终端的使用方式有些特别。在伪终端的使用场景里,通常会有一个和用户直接或通过网络交互的用户程序,以及一个用户希望使用的面向终端程序,典型的工作流程如下:
-
用户程序打开
/dev/ptmx
(pseudo terminal master multiplexer)文件获取一个可用的 pty master 设备,该设备将被打开并返回给用户程序一个对应的文件描述符。为了避免竞争条件,进程必须通过
/dev/ptmx
分配一个 pty master,对应的 pty slave 设备文件(/dev/pts/N
)也会同时被创建出来,但必须对 pty master 的文件描述符执行特定调用后才能打开使用。虽然 pty master 实际上是和 pty slave 同一类型的 tty 设备,但它不会出现在/dev
文件系统中,用户程序只能通过这个文件描述符和它交互(SUSv3 是这样定义的,实际上 macOS 会预先创建好许多对伪终端设备,master 命名为/dev/pty[p-za-e][0-9a-f]
)。 -
用户程序调用
fork()
来创建一个子进程。该子进程执行以下步骤。- 调用
setsid()
来启动一个新的 session,子进程是该 session 的 session leader。这个步骤也导致子进程失去其控制终端。 - 打开 pty master 对应的 pty slave 设备,打开前需要依次执行
grantpt
、unlockpt
、ptsname
等调用以获取 slave 设备的文件名和使用权限。因为子进程是一个 session leader 且没有控制终端,所以 pty slave 会自动成为子进程的控制终端。 - 调用
dup()
复制 pty slave 设备的文件描述符并设置为标准输入、输出和错误。 - 调用
exec()
来启动将被连接到 pty slave 设备的面向终端程序。
伪终端支持任意两个进程之间通信,并不要求它们之间有父子关系,只需要另一个进程能够获取到 pty master 对应的 pty slave 设备的名称并打开它。
- 调用
-
这两个程序现在可以通过伪终端进行通信。用户程序写入 pty master 的任何数据都会变成连接到 pty slave 的面向终端程序的输入,而面向终端程序写入 pty slave 的任何数据都可以被打开 pty master 的用户程序读取。
UNIX 98 伪终端的实现
早期的 Unix 衍生系统各自实现了不同的接口来初始化伪终端,Linux 和 macOS 对伪终端的命名规则也完全不同,因为它们分别继承了 system V 和 BSD 的实现,SUSv3 基于 System V 规范化了伪终端的实现,按该规范实现的伪终端被称为 UNIX 98 Pseudoterminals,规范中定义了以下库函数:
posix_openpt()
:打开一个未被使用的 pty master 设备,并返回其文件描述符。在 Linux 的实现中,该函数被实现成直接返回open("/dev/ptmx", flags)
。grantpt()
:传入 pty master 的文件描述符,更改对应 pty slave 的所有权和访问权限。在 Linux 系统的实现中 pty slave 在创建时就已经设置好了权限,因此可省略该调用。unlockpt()
:传入 pty master 的文件描述符,解锁对应 pty slave,之后该设备才可以被打开。上锁的目的是避免 slave 设备在完成初始化(例如调用grantpt()
)之前,就被另一个进程打开。ptsname()
:传入 pty master 的文件描述符,返回对应 pty slave 的设备名称,将名称传入open()
系统调用可以打开该设备。在 Linux 系统中,slave 设备的命名格式是/dev/pts/N
,N 代表一个递增的整数,而 macOS 的命名格式是/dev/ttysN
。
上述库函数之间有依赖关系,需要依次执行才能成功获取一对伪终端。这些基本库函数的调用基本上是不变的,系统编程时可以直接使用 libc 中由 BSD 封装好的 openpty()
和 forkpty()
函数:
|
|
openpty()
函数封装了上述初始化库函数,直接返回一对完成初始化的伪终端设备,返回 pty master 的文件描述符到 amaster
参数中,返回 pty slave 的文件名到 name
参数中。传入的 termp
和 winp
参数如果不为 NULL,将决定 pty slave 的终端配置和窗口大小。
forkpty()
函数封装了 openpty()
、fork(2)
和 login_tty()
等函数来获取一对伪终端设备,并创建一个在伪终端中运行的子进程。子进程被创建后会创建一个新的会话,并打开 pty slave 使其成为子进程的控制终端,复制 pty slave 的文件描述符并设置为子进程的标准输入、输出和错误,最后关闭 pty slave。像 fork
一样该函数会分别在父子进程中返回,通过判断返回的子进程 ID 是否为 0,我们可以在子进程逻辑中通过 exec
执行最终需要在终端中运行的程序。
远程终端示例
在实践中,伪终端被用来实现终端模拟程序如 iterm2,应用程序从 pty master 读取数据并像终端模拟器一样将数据渲染到显示器;还被用来实现远程登录程序如 sshd,从 pty master 读取的数据通过网络发送到另一台主机上连接到终端或终端模拟器的 ssh 客户端程序。
下面我们结合终端模拟程序和 ssh 来看看一个远程终端的示例:
- 假设 iterm2 已经用上文描述过的流程打开了一对伪终端,并创建了一个 bash 进程连接到 pty slave。用户在 iterm2 中输入
ssh
命令并回车,写入字符到 pty master 然后输入到 pty slave。bash
从标准输入 pty slave 中读取字符序列并解释执行,启动ssh
客户端,ssh
客户端启动后首先通过ioctl
为当前终端设置 opost、-isig、-icanon、-echo 等 flag,然后请求和远程主机建立 TCP 连接。 - 远程主机的
sshd
接收客户端的 TCP 连接请求,执行openpty()
获得一对已初始化的伪终端设备。接着sshd
fork
出一个子进程执行login
完成用户认证,打开 pty slave 并设置为标准输入、标准输出和标准错误。最后login
创建一个新的 session 并执行bash
,bash
成为新 session 的 session leader,bash
的标准输入、标准输出和标准错误都设置为了 pty slave,pty slave 成为新 session 的控制终端。 - 当用户在本地主机的 iterm2 中输入命令
ls
和回车键,由于本地主机bash
所连接的终端 pty slave 的大部分line discipline
规则已经被ssh
客户端禁用,输入的每一个字符包括回车都会不经处理直接写入到 pty slave,echo 配置也已经被关闭,此时的输入不会在本地终端回显。ssh
客户端从标准输入读取字符序列,并通过网络将ls^M
发送给远程主机的sshd
。 sshd
将从 TCP 连接上接收到的字符序列写入 pty master,输入到 pty slave,pty slave 中的line discipline
会缓冲收到的单个字符。由于远程主机 pty slave 的line discipline
没有禁用 echo 规则,所以 pty slave 会将收到的字符写入到输出缓冲区,发送给 pty master,sshd
从 pty master 中读取到字符并通过 TCP 连接发回客户端。客户端收到字符后写入标准输出,最终在 iterm2 终端应用中展示。在 ssh 连接中,本地输入最终由远程的 pty slave echo 到本地终端。- 远程主机的 pty slave 接收到
^M
特殊字符后将缓冲的字符发送到输入缓冲区,然后bash
从标准输入读取字符、解释并执行命令ls
。bash
fork
出ls
子进程,该子进程的标准输入、标准输出和标准错误同样设置为了 pty slave。ls
命令的执行结果写入标准输出 pty slave,然后输出到 pty master,再由sshd
读取后通过 TCP 连接发送给本地主机的ssh
客户端,最终在 iterm2 终端应用中展示。
如果想通过实际代码加强理解,可查看该 GitHub 仓库,仓库作者用 golang 实现一个没有数据加密能力的 ssh。
总结
本文的篇幅很长,最后再来回顾一下主要内容:
- tty 得名于电传打字机,和终端是同义词,最早的 tty 系统软件设计基本延续到了今天。
- tty 一开始就承载了 job controll 职能,需要了解 process group、job、session 和控制终端等概念,以及下述规则:
- 内核允许对一个 job 的所有成员同时发送信号。
- 控制终端只会对前台 job 发送输入特殊字符触发的信号,只有前台 job 可以从控制终端读取输入并向其发送输出。
- shell 作为 session leader 会持续监控所有 job 的运行状态,并根据状态的变化及时更新控制终端的前台 job。
- linux 的 tty 实现采取分层设计,每一层都封装了对更下一层的访问,顶层和底层的设备驱动只有单一的数据传输职责,更复杂的功能都在中间的
line discipline
中实现。 line discipline
实现了 line buffer、line edit、echo、job controll 等功能,这些功能可以通过stty
命令和ioctl()
调用进行配置。- 伪终端的 slave 设备就像一个标准的 tty 设备一样工作,连接到面向终端程序作为控制终端;master 设备提供了一个文件描述符给用户程序,用户程序可以读写该文件向 slave 设备输入数据并读取 slave 设备的输出。
- 常见的终端使用场景,如用户终端模拟程序及 ssh 连接远程主机,使用的都是伪终端。
参考链接
- The TTY demystified 全面介绍了 tty、job controll 和信号等概念,本文引用了其中 2 张图片
- TTY: under the hood 作者制作的示意图非常棒,本文引用了其中 3 张图片
- Linux terminals, tty, pty and shell
- The Linux programming interface - 64: Pseudoterminals
- Linux Device Drivers, 3rd Edition
- TTY 到底是什么? | 卡瓦邦噶!
- 理解 Linux 终端、终端模拟器和伪终端_Linux_swordholder_InfoQ 写作社区
- Chris's Wiki :: blog/unix/JobControlAndTTYs
- pty(7) - Linux manual page
- pts(4) - Linux manual page
- termios(3) - Linux manual page
- What are the responsibilities of each Pseudo-Terminal (PTY) component (software, master side, slave side)?