Unix 终端系统(TTY)是如何工作的

长久以来,我们把终端和 shell 视作开发者理所当然的基本工具,但其底层涉及的组件和机制却并不简单。作为一名后端工程师,每天使用终端的时长甚至会超过代码编辑器,深入了解其背后的实现是有必要的。本文将尝试尽量清晰全面的介绍 Unix 所使用的终端系统。

本文内容主要以 Linux 系统的具体实现为基础,但因为其遵循 SUSv3(Single UNIX Specification Version 3)规范,因此也适用于其他 Unix 系统(如 macOS)。

tty 的由来

所有介绍 tty 的文章都会介绍终端设备的发展史,过去几十年硬件设备的发展日新月异,但直到今天,Unix 终端在软件部分仍在使用从上世纪 70 年代沿袭至今的工作机制。

电传打字机

teletype

电传打字机是早期的计算机输入输出设备,其英文 teletype 的缩写也是 tty 这一名称的由来。终端和 tty 实际上是可相互替代的同义术语,下文将结合语境使用。

电传打字机的发明远远早于计算机,一开始计算机的计算能力无法支持实时的交互,只能使用打孔卡片进行批处理计算,然后等待一晚上之后得到结果。随着算力的提升,计算机操作系统从批处理系统迈向分时处理系统,聪明的计算机先驱们发现稍作改造,就能使用现成的电传打字机作为输入输出设备。

电传打字机通过两条线缆连接到计算机的 UART(Universal Asynchronous Receiver and Transmitter)接口,一条线缆传输从电传打字机按下键盘触发的输入信号到计算机,一条线缆传输从计算机回到电传打字机的输出信号。计算机操作系统提供 UART 驱动程序来管理字节的物理传输,包括奇偶校验和流量控制。

整个终端系统的示意图如下:

teletype-diagram

引用自 http://www.linusakesson.net/programming/tty/

teletype-diagram-3d

引用自 https://www.yabage.me/2016/07/08/tty-under-the-hood/

特定的 UART 驱动、line descipline 实例和 TTY 驱动三者组成了一个 TTY 设备,有时也被直接称为 TTY。这只是一个抽象表示,如果你对它们之间的交互细节还有疑惑,后面的章节会深入其内部实现。

下面通过一段执行 cat 命令的输入序列和输出,来描述电传打字机终端的工作过程。输入序列如下:

  1. cat^M
  2. hello^M
  3. ^Z

^M 表示按下回车键,按惯例下文均使用 ^ 表示 ASCII 控制字符,如 ^Z 实际表示按下 Ctrl + Z 键。

电传打字机应当打印以下结果:

1
2
3
4
5
# cat
hello
hello
^Z
[1]+  已停止               cat
  1. 在打字机敲下 cat 命令时,电信号经过 UART 驱动转换为 ASCII 字符并传递给 line discipline 组件的字符缓冲区,此时可以通过退格键或者 ^W 键对缓冲区中的内容进行编辑,只有当我们按下回车键后,line discipline 才会将整行字符串发送给 tty driver,之后用户空间的 shell 进程可以通过 read() 调用读取到 cat 命令并执行。
  2. 默认配置下 line discipline 组件会将字符缓冲区的字符立刻复制到输出缓冲区,随即经过 UART 驱动转换成电信号通过输出线缆传输给电传打字机,因此电传打字机会立刻打印输入的每一个字符,这一机制被称为 echo (回声,这也许是 echo 命令的最初由来)。
  3. cat 命令执行时,其标准输入、标准输出和标准错误都指向当前的 tty driver。再次从电传打字机输入 hello 并回车时,电传打字机首先 echo 输入的字符,在换行后,cat 命令从标准输入读取到一行 hello 并立刻写入到标准输出也就是 tty driver,再经过 line discipline 最终输出到电传打字机,也就是我们看到的第二行 hello
  4. 最后按下 ^z 按钮,这两个按键对应 26 这个 ASCII 码点,在到达 line discipline 组件时,line discipline 并不会将该字符继续发给 tty driver,而是在 echo 给电传打字机后发送一个 SIGSTP 信号给当前正在运行的 cat 进程,使其进入 STOPPED 状态。
  5. 控制权回到 shell 进程后,由于它是 cat 进程的父进程,可以通过 wait() 调用获取子进程的运行状态,shell 进程会打印一行 [1]+ 已停止 cattty driver

半个世纪后的今天,终端的使用方式与上述过程并没有太大差别。

视频终端

DEC VT-100 视频终端

DEC VT-100 视频终端

随着显示屏技术的进步,很快出现了基于数字图形的视频终端,1978 年发布的 VT100 是其中最具代表性的终端产品。

除了显示效果大幅提升,视频终端还能根据特殊的转义码(escape code)执行光标移动、换行等操作,其使用效果已经接近我们今天所使用的终端了。虽然视频终端长的像家用计算机,但实际上没有任何计算能力,是纯粹的输入输出设备。

虽然外接设备实现了全面升级,不过这一阶段终端系统的软件架构并没有太大变化,可以认为和电传打字机基本相同。

终端模拟器

现在只有在博物馆才能见到以上两种专用终端设备了,取而代之的,是完全用软件实现的的终端模拟器,以及显示器和键盘等通用外接设备。UART 和物理终端不复存在,操作系统在内核完全通过软件模拟出一个视频终端,并直接渲染到显示器设备:

terminal-emulator-diagram

terminal-emulator-diagram-3d

这一系统已经相当接近我们日常使用的终端,你可以在 Ubuntu 等发行版的图形界面直接通过 Ctrl+Alt+F1 呼出一个模拟终端。这些虚拟设备在操作系统的 /dev 目录下作为字符设备存在:

1
2
3
4
5
$ ll /dev/tty*
crw-rw-rw- 1 root tty     5,  0 3月  31 2022 /dev/tty
crw--w---- 1 root tty     4,  0 3月  31 2022 /dev/tty0
crw--w---- 1 root tty     4,  1 8月  11 22:37 /dev/tty1
crw--w---- 1 root tty     4, 10 3月  31 2022 /dev/tty10

内核空间实现的终端模拟器不够灵活,一些场景下,如通过 ssh 连接到远程主机或者使用 iterm2 等自定义终端程序,使用的是基于 tty 系统扩展的伪终端(pseudoterminal, pty),在 shell 中执行 tty 命令可以直接获取当前使用的终端:

1
2
$ tty
/dev/pts/0

在终端中执行的命令会打开所当前所使用的终端作为其标准输入、标准输出和标准错误:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ cat
^Z
[1]+  已停止               cat
$ jobs -l
[1]+  8775 停止                  cat
$ ll /proc/8775/fd
总用量 0
lrwx------ 1 root root 64 10月  6 15:24 0 -> /dev/pts/0
lrwx------ 1 root root 64 10月  6 15:24 1 -> /dev/pts/0
lrwx------ 1 root root 64 10月  6 15:24 2 -> /dev/pts/0

由于核心机制相同,伪终端会在稍后一些的章节中介绍,我们先来了解一下 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 为单位进行管理。

除了终端输入,我们还可以通过 jobsfgbgkill 等命令管理 job:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ bat
^Z
[1]  + 16849 suspended  bat
$ jobs -l
[1]  + 16849 suspended  bat
$ bg bat
[1]  + 16849 continued  bat
[1]  + 16849 suspended (tty input)  bat
$ fg bat
[1]  + 16849 continued  bat
^C

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)(命令输出经过筛选):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ ps -efj
UID        PID  PPID  PGID   SID  C STIME TTY          TIME CMD
root      6541  1813  6541  6541  0 11:14 ?        00:00:00 sshd: root@pts/2
root      7186  6541  7186  7186  0 11:15 pts/2    00:00:00 -bash
root      8577  1813  8577  8577  0 11:06 ?        00:00:00 sshd: root@pts/1
root      8579  8577  8579  8579  0 11:06 pts/1    00:00:00 -bash
root     17834  8579 17834  8579  0 11:17 pts/1    00:00:00 sleep 100000
root     19131  8579 19131  8579  0 11:18 pts/1    00:00:00 ps -efj
root     19132  8579 19131  8579  0 11:18 pts/1    00:00:00 grep --color=auto pts
root     22526  1813 22526 22526  0 10:28 ?        00:00:00 sshd: root@pts/0
root     22643 22526 22643 22643  0 10:28 pts/0    00:00:00 -bash

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:

1
2
3
4
5
6
7
$ ps l
F   UID   PID  PPID PRI  NI    VSZ   RSS WCHAN  STAT TTY        TIME COMMAND
4     0  8729  8727  20   0 115764  3776 wait_w Ss+  pts/0      0:00 -bash
0     0  8775  8729  20   0 108088   688 do_sig T    pts/0      0:00 cat
4     0  8811  8809  20   0 115560  3552 do_wai Ss   pts/1      0:00 -bash
0     0  8855  8811  20   0 153364  3716 -      R+   pts/1      0:00 ps l
4     0 25232     1  20   0 110220  1700 wait_w Ss+  tty1       0:00 /sbin/agetty --noclear tty1 linux

一个 session 可以有任意数量的后台 job,这些作业是通过用 & 字符来作为命令后缀而创建的。如果一个后台 job 尝试去读取或写入终端,终端驱动会向其发送 SIGTTINSIGTTOU 信号,上文示例中执行 bg bat 时(bat 是一个现代化的 cat 程序),由于 bat 会尝试从标准输入即当前 tty 中读取,因此 job 在恢复运行后立刻收到了 tty driverSIGTTIN 信号而又被挂起。

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 disciplineread() 方法,最终从输入缓冲区中获取数据。

  • 中间的 line discipline 包装了终端设备层的访问行为,对经过的字符进行处理后放入专门的输入和输出缓冲区。此外 line discipline 还有保存未回车提交的字符缓冲区,开启 tty 的 echo 配置时,写入到字符缓冲区的字符会自动被复制到输出缓冲区,最后写入到 tty 设备驱动。

  • 下层硬件驱动与硬件或虚拟设备通信,并负责将接收到的数据发送给 line discipline

Untitled

有些情况下用户进程可配置 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 设备):

1
2
3
4
5
6
7
8
$ stty -a
speed 9600 baud; rows 41; columns 143; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = M-^?; eol2 = M-^?; swtch = <undef>; start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R;
werase = ^W; lnext = ^V; flush = ^O; min = 1; time = 0;
-parenb -parodd -cmspar cs8 -hupcl -cstopb cread -clocal -crtscts
-ignbrk -brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff -iuclc ixany imaxbel iutf8
opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0
isig icanon iexten echo echoe -echok -echonl -noflsh -xcase -tostop -echoprt echoctl echoke

下面按行数对上面输出的配置项进行简要介绍(所有配置项的详细描述可通过 man stty 查阅):

  1. 输出速率(无终端硬件时无意义);rows 和 columns 代表以字符为单位的终端渲染窗口大小;line = 0 代表 line discipline 使用默认的 N_TTY 类型驱动(其他类型基本用不上)。
  2. 当前 line edit 和 job controll 功能所绑定的特殊按键。
  3. 输出的第三行起都是控制 line disciplinetty driver 行为的开关 flag,以 - 开头表示该 flag 处于关闭状态。我们只介绍几个关键的 flag:
    • echo:控制 line discipline 是否 echo 终端的输入。
    • icanon:控制 line buffer 和 line edit 行为,关闭后输入字符会立刻发送前台 job,且停止对特殊字符的转义。
    • isig:控制是否开启对 INTRQUITSUSP 信号对应的特殊符号的检查,如果关闭,输入 ^C 等特殊符号不会触发这些信号。
    • raw:关闭绝大部分开关 flag(包括 icanon),终端基本不会对输入输出字符做任何处理。

这些配置项都可以通过 stty 命令更改,尝试执行如下命令禁用 icanon 和 echo,会发现终端的输入行为变得相当奇怪:

1
stty -echo -icanon; cat

执行 stty sane 可以将当前 tty 恢复到默认配置。

stty 使用 SUSv3 定义的 termios api 操作 tty 的配置,在所有 Unix 系统都可以通用,此外进程还可以使用 ioctl 或者 libc 提供的库函数读取或修改已打开的 TTY 设备的配置。

伪终端

为什么需要伪终端

虽然操作系统通过内核的终端模拟器实现了对终端硬件设备的模拟,但这种 tty 系统无法适应一些场景下的需求:

  • 用户希望使用由用户实现的终端模拟程序以扩展终端的功能,需要某种机制接入当前的 tty 系统并替代内核的终端模拟器。
  • 用户希望实现通过网络访问远程主机上的面向终端程序(比如 vi),面向终端程序期望通过终端打开,以便执行一些面向终端的操作如通过 termios 接口设置终端 icanonecho 开关 flag,以及实现终端对前台 job 的控制。这些操作要求远程主机上必须打开一个终端,否则对应的系统调用会执行失败,但我们无法通过 socket 等数据传输机制来连接本地程序到远程主机的终端模拟器。

tty 系统的特点就是在不断扩展的同时,仍然保持对历史设计的兼容性。为了实现以上需求,Unix 在原有 tty 系统的基础上发展出了伪终端,今天我们所使用的终端大部分都是伪终端。

什么是伪终端

Untitled

伪终端(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 收发数据。

Untitled

伪终端是如何工作的

与标准终端直接打开 /dev/ttyN 相比,伪终端的使用方式有些特别。在伪终端的使用场景里,通常会有一个和用户直接或通过网络交互的用户程序,以及一个用户希望使用的面向终端程序,典型的工作流程如下:

  1. 用户程序打开 /dev/ptmxpseudo 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])。

  2. 用户程序调用 fork() 来创建一个子进程。该子进程执行以下步骤。

    1. 调用 setsid() 来启动一个新的 session,子进程是该 session 的 session leader。这个步骤也导致子进程失去其控制终端。
    2. 打开 pty master 对应的 pty slave 设备,打开前需要依次执行grantptunlockptptsname 等调用以获取 slave 设备的文件名和使用权限。因为子进程是一个 session leader 且没有控制终端,所以 pty slave 会自动成为子进程的控制终端。
    3. 调用 dup() 复制 pty slave 设备的文件描述符并设置为标准输入、输出和错误。
    4. 调用 exec() 来启动将被连接到 pty slave 设备的面向终端程序。

    伪终端支持任意两个进程之间通信,并不要求它们之间有父子关系,只需要另一个进程能够获取到 pty master 对应的 pty slave 设备的名称并打开它。

  3. 这两个程序现在可以通过伪终端进行通信。用户程序写入 pty master 的任何数据都会变成连接到 pty slave 的面向终端程序的输入,而面向终端程序写入 pty slave 的任何数据都可以被打开 pty master 的用户程序读取。

Untitled

UNIX 98 伪终端的实现

早期的 Unix 衍生系统各自实现了不同的接口来初始化伪终端,Linux 和 macOS 对伪终端的命名规则也完全不同,因为它们分别继承了 system V 和 BSD 的实现,SUSv3 基于 System V 规范化了伪终端的实现,按该规范实现的伪终端被称为 UNIX 98 Pseudoterminals,规范中定义了以下库函数:

  1. posix_openpt():打开一个未被使用的 pty master 设备,并返回其文件描述符。在 Linux 的实现中,该函数被实现成直接返回 open("/dev/ptmx", flags)
  2. grantpt():传入 pty master 的文件描述符,更改对应 pty slave 的所有权和访问权限。在 Linux 系统的实现中 pty slave 在创建时就已经设置好了权限,因此可省略该调用。
  3. unlockpt():传入 pty master 的文件描述符,解锁对应 pty slave,之后该设备才可以被打开。上锁的目的是避免 slave 设备在完成初始化(例如调用 grantpt())之前,就被另一个进程打开。
  4. ptsname():传入 pty master 的文件描述符,返回对应 pty slave 的设备名称,将名称传入 open() 系统调用可以打开该设备。在 Linux 系统中,slave 设备的命名格式是 /dev/pts/N,N 代表一个递增的整数,而 macOS 的命名格式是 /dev/ttysN

上述库函数之间有依赖关系,需要依次执行才能成功获取一对伪终端。这些基本库函数的调用基本上是不变的,系统编程时可以直接使用 libc 中由 BSD 封装好的 openpty()forkpty() 函数:

1
2
3
4
5
6
7
#include <pty.h>
int openpty(int *amaster, int *aslave, char *name,
                     const struct termios *termp,
                     const struct winsize *winp);
pid_t forkpty(int *amaster, char *name,
             const struct termios *termp,
             const struct winsize *winp);

openpty() 函数封装了上述初始化库函数,直接返回一对完成初始化的伪终端设备,返回 pty master 的文件描述符到 amaster 参数中,返回 pty slave 的文件名到 name 参数中。传入的 termpwinp 参数如果不为 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 来看看一个远程终端的示例:

Untitled

  1. 假设 iterm2 已经用上文描述过的流程打开了一对伪终端,并创建了一个 bash 进程连接到 pty slave。用户在 iterm2 中输入 ssh 命令并回车,写入字符到 pty master 然后输入到 pty slave。bash 从标准输入 pty slave 中读取字符序列并解释执行,启动 ssh 客户端,ssh 客户端启动后首先通过 ioctl 为当前终端设置 opost、-isig、-icanon、-echo 等 flag,然后请求和远程主机建立 TCP 连接。
  2. 远程主机的 sshd 接收客户端的 TCP 连接请求,执行 openpty() 获得一对已初始化的伪终端设备。接着 sshd fork 出一个子进程执行 login 完成用户认证,打开 pty slave 并设置为标准输入、标准输出和标准错误。最后 login 创建一个新的 session 并执行 bashbash 成为新 session 的 session leader,bash 的标准输入、标准输出和标准错误都设置为了 pty slave,pty slave 成为新 session 的控制终端。
  3. 当用户在本地主机的 iterm2 中输入命令 ls 和回车键,由于本地主机 bash 所连接的终端 pty slave 的大部分 line discipline 规则已经被 ssh 客户端禁用,输入的每一个字符包括回车都会不经处理直接写入到 pty slave,echo 配置也已经被关闭,此时的输入不会在本地终端回显。ssh 客户端从标准输入读取字符序列,并通过网络将 ls^M 发送给远程主机的 sshd
  4. 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 到本地终端。
  5. 远程主机的 pty slave 接收到 ^M 特殊字符后将缓冲的字符发送到输入缓冲区,然后 bash 从标准输入读取字符、解释并执行命令 lsbash forkls 子进程,该子进程的标准输入、标准输出和标准错误同样设置为了 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 连接远程主机,使用的都是伪终端。

参考链接

updatedupdated2023-06-062023-06-06