解密 TTY 设备

2021-05-30 ⏳19.8分钟(7.9千字)

终于找到一篇把 TTY 系统讲透的文章了。联系作者拿到了翻译授权。现在分享给大家。以下是原文。

在 Linux 和 UNIX 的通用设计中,TTY 子系统是非常核心的部分。但很可惜,人们往往对它的重要性视而不见,而且市面上也很少有写得比较好的文章。我认为,作为 Linux 开发者和高级用户,每个人都有必要对 TTY 设备有一个基本的理解。

但有一点要说明,你接下来要看到的内容不见得有多么优雅。实际上,TTY 子系统虽说从用户的使用角度来看完全没有问题,但在一些特殊方面着实很混乱。要想理解其中内情,我们必须回顾 TTY 设备的历史。

历史

人们在 1869 年发明了股票行情自动收录器(stock sticker)。这是一种电子机械装置,它由打字机、一对很长的电线和纸带打印机三部分组成。它的目标是实现远距离发布股票的实时价格行情。这种系统逐渐演变成电传打字机(teletype)。电传打字机使用 ASCII 编码,传输速度更快。Telex 是曾经是世界上最大的电传打字机网络,专门用来发送商业电报。但那时候的电传打字机还没有连接过任何一台计算机。

同时代的计算机虽然体积巨大、功能原始,却可以同时运行多个任务。所以,那时的计算机已经具备跟用户进行实时交互的能力了。当命令行模式最终取代老旧的批处理模式的时候,电传打字机的市场已经非常成熟,所以电传打字机就成了计算机的输入和输出设备。

当时的电传打字机也是五花八门,不同设备之间会有一些差异。所以需要设计统一的软件层来兼容这些差异。UNIX 社区的应对方法就是让操作系统内核来处理底层细节,这包括字长、波特率、流量控制、奇偶校验,以及原始行编辑模式的控制编码等等等等。到了 1970 年代后期,市场上出现了诸如 VT-100 之类的视频终端,它们支持移动光标、彩色显示和一些其他高级的特性。但这些功能在 UNIX 中都交给应用层去实现。

现在电传打字机和视频终端几乎绝迹。除非你是硬件发烧友或者是去参观博物馆,你能看到的 TTY 只有视频终端模拟器——使用软件模拟真实的设备。但我们应该透过界面看到下面过时的钢铁怪兽(形容 TTY 系统历史包袱很重,译者注)。

使用场景

TTY系统架构

用户在终端上输入内容(终端是一种实际存在的物理设备)。终端设备通过一对电线连接到计算机的UART(通用异步收发)设备。操作系统装有 UART 设备驱动,用来管理物理线路的数据传输,包括奇偶校验和流量控制。在原始的操作系统中,UATR 驱动会将收到的数据直接发送给应用进程。但是这种方式无法支持以下关键特性:

整行编辑

很多用户在录入的时候会出错,所以需要退格键(也就是删除键,译者注)。这种功能当然可以交给应用程序来实现,但根据 UNIX 的设计哲学,应用程序应该尽量保持简洁。为了方便使用,操作系统提供了一个编辑缓冲区和一组初级的的编辑指令(退格、清空单词、清空整行、重新打印等)。这些功能默认开启,并由所谓的线路规则(line discipline)控制(这里的 discipline 确实不好翻译,译者注)。有些高级应用可能需要禁用行编辑功能,它们需要将线路规则从 cooked 模式(就是刚才说的默认模式)切换成原始模式。大多数交互型程序(像编辑器、邮件客户端、命令行,以及所有依赖 curses 库或者 readline 的程序)都在原始模式下运行,并且自行处理所有行编辑指令。线路规则还包含选项来控制字符回显(echoing)和自动转换回车符跟换行符。你也可以将线路规则看成是一个基础的、内核级的 sed(1) 命令。

此外,系统内核提供了多种不同的线路规则。一个设备同一时间只能关联一种规则。默认的规则叫 N_TTY,提供行编辑功能。如果你有冒险精神,可以到 drivers/char/n_tty.c 探究其源码。还有很多其他的规则,比如 ppp, IrDA 和串口鼠标都是用来提供数据交换管理功能的,但这些功能已经了超出本文的讨论范围。

会话管理

人们可能需要同时运行多个程序,每次只跟其中之一进行交互。如果程序陷入死循环,用户可能想杀死或者暂停该程序号。后台运行的程序只要没有向终端写入内容,就应该一直运行。如果它想向终端发送内容,就应该被挂起等待。类似地,用户输入的内容只应该发送给前台运行的程序。操作系统通过 TTY 驱动实现这些功能(drivers/char/tty_io.c)。

操作系统中的进程是「活的」(拥有执行上下文),也就是说进程可以主动执行一些操作。TTY 驱动不是「活的」。如果用面向对象的术语来描述的话,TTY 驱动是一个被动对象(passive object)。它有自己的属性、数据和一些方法。但是只能在其他进程的执行文或者内核的中断处理过程中调用它,TTY 驱动才能执行一些操作。线路规则(line discipline)也是一种被动对象。

将 UART 驱动、线路规则跟 TTY 驱动三者看成一个整体,我们称之为一个 TTY 设备(有时直接简称 TTY)。用户进程可以通过操作 /dev 目录下对应的设备文件来改变 TTY 设备的行为。操作之前需要获取对应设备文件的写入权限。所以当用户从特定 TTY 登录的时候,该用户就会成为该设备的所有者(也就获得了写权限,译者注)。这部分工作一直是由 login(1) 程序完成。login(1) 程序以 root 权限运行(所以可以更改 TTY 设备的所有者,译者注)。

上一张流程图中的物理线路也可以是长途电话线:

使用长途电话线连接终端

唯一的区别是系统需要处理调制解调器的连接状态。

我们再看看典型的桌面计算机系统。下图展示了 Linux 终端的工作原理:

Linux终端工作原理

TTY 驱动和线路规则跟之前的例子一模一样,但这里不再需要 UART 设备或者物理终端了。取而代之的是视频终端(一个复杂的状态机,包括字符帧缓冲区和字符图形控制属性)模拟软件,软件的界面会通过 VGA 显示器绘制。

终端子系统可以说有点坚挺(rigid,可能是要表达不管系统如何演变,终端系统没有太大的变化。译者注)。如果我们将终端模拟逻辑挪到用户空间,系统会变得更灵活(也更抽象)。这就是 xterm(1) 及其克隆软件的工作原理:

xterm工作原理

为了在用户态模拟终端,同时还要保留 TTY 子系统(会话管理和线路规则)交互功能,人们发明了伪终端(pty)。你可能会猜能不能在伪终端内再运行伪终端。可以,像 screen(1)ssh(1) 都是这样运行的,但这样会变得更加复杂。

现在我们回过关来看看所有这些内容如何跟进程模型协同工作。

进程

Linux 进程可能处于以下状态:

进程状态
状态 描述
R 正在运行或者可以运行(处于就绪队列)
D 不可中断睡眠(等待特定事件)
S 可中断睡眠(待待特定事件或者信号)
T 暂停,可能收到任务控制信号或被调试器追踪
Z 僵尸进程,已经终止但没有被父进程回收

By running ps l, you can see which processes are running, and which are sleeping. If a process is sleeping, the WCHAN column (“wait channel”, the name of the wait queue) will tell you what kernel event the process is waiting for.

运行 ps l 可以看到当前哪些进程在运行,哪些在休眠。如果进程在休眠,它的 WCHAN 列(等待队列 wait channel 的缩写)会显示进程正在等待的内核事件:

$ ps l
F   UID   PID  PPID PRI  NI    VSZ   RSS WCHAN  STAT TTY        TIME COMMAND
0   500  5942  5928  15   0  12916  1460 wait   Ss   pts/14     0:00 -/bin/bash
0   500 12235  5942  15   0  21004  3572 wait   S+   pts/14     0:01 vim index.php
0   500 12580 12235  15   0   8080  1440 wait   S+   pts/14     0:00 /bin/bash -c (ps l) >/tmp/v727757/1 2>&1
0   500 12581 12580  15   0   4412   824 -      R+   pts/14     0:00 ps l

“wait” 对应 wait(2) 系统调用的等待队列。如果子进程有状态变化,等待 “wait” 事件的进程会被标记为可运行状态。休眠状态分两种:可中断休眠和不可中断休眠。可中断休眠(多数休眠都是此类)表示进程在等待队列的时候,如果收到信号,也会被转移到就绪队列。如果阅读内核源码就会发现,任何等待内核事件的代码都必须在 shedule() 调用返回的时候检查是否有没有处理的信号。如果有信号则会结束系统调用。

在上面 ps 返回结果中,STAT 列显示每一个进程的当前状态。该列可能显示一个或多个附加属性或标记:

标记 含义
s 该进程会话首领
+ 该进程属于前台进程组

这些属性被用于任务控制。

任务与会话

如果你按 ^Z 暂停程序或者使用 & 启动后台运行的进程,系统就会执行任务调度。任务就是一组进程。shell 内置了像 jobs, fgbg 之类的命令来管理会话下面的任务。每个会话由会话的首领进程(leader)控制。这个首领进程是 shell,它会通过复杂的信号和系统调用协议跟内核合作完成任务调度。

下面的例子演示了进程、任务和会话的关系:

下面的 shell 交互:

多任务终端截图

对应这些进程:

会话、任务和进程的关系

以及这些内核结构:

基本思路是每个管道都是一个任务,因为管道两端的进程需要同时管理(暂停、唤醒或者杀死)。这也是 kill(2) 允许你给整个进程组发信号的原因。在默认情况下,fork(1) 创建的子进程的进程组跟父进程一相同。也就是说,如果按 ^C 会同时影响父进程和子进程。但 shell 要履行会话管理职责,会在创建管道的时候创建新的进程组。

TTY 驱动会被动地记录前台进程组的 ID。会话的首领进程需要在必要的时候更新这些信息。TTY 驱动也会跟踪当前连接的终端的尺寸,但这些信息需要由终端模拟器或者由用户主动更新。

As you can see in the diagram above, several processes have /dev/pts/0 attached to their standard input. But only the foreground job (the ls | sort pipeline) will receive input from the TTY. Likewise, only the foreground job will be allowed to write to the TTY device (in the default configuration). If the cat process were to attempt to write to the TTY, the kernel would suspend it using a signal.

我们看上面的图表,好几个进程都把 /dev/pts/0 绑定到自己的标准输入设备。但是只有前台任务(ls|sort 管道)才能从 TTY 设备接收用户输入的内容。同样,也只有前台进程可以向 TTY 设备写入内容(默认配置条件下)。如果 cat 进程试图向 TTY 写入内容,内核会通过信号将其挂起。

混乱的信号

现在我们讨论一下内核态的 TTY 驱动、线路规则以及 UART 驱动是如何跟用户态的进程进行通信的。

ioctl(2) 号称 UNIX 的瑞士军刀,可以对文件执行读取、写入甚至管理等操作。TTY 设备文件也不例外。大多数 TTY 相关的操作都需要通过 ioctl 完成。但是,ioctl 请求必须在用户进程发起,所以操作系统内核没法通过 ioctl 与应用程序进行异步通信。

在《The Hitchhiker’s Guide to the Galaxy》一书中,Douglas Adams 提到了一颗非常暗淡(dull)的星球。在这颗星球上面居住着可怜(depressed)的人类,他们饲养着满口獠牙的动物。这些动物跟人类沟通需要撕咬人类的大腿(所以我译成可怜。译者注)。这和 UNIX 非常像。内核通过发送信号与进程通信,这些信号可能会暂停进程,也可能终止进程。进程可以拦截这些信号并做出适当响应,但多数进程什么也不会做。

所以说信号是一种让内核跟进程完成异步通信的机身,但非常粗暴。UNIX 的信号比较混乱,每个信号都需要单独学习。

你可以通过 kill -l 命令查看系统支持的信号,输出结果可能是这个样子:

$ kill -l
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL
 5) SIGTRAP	 6) SIGABRT	 7) SIGBUS	 8) SIGFPE
 9) SIGKILL	10) SIGUSR1	11) SIGSEGV	12) SIGUSR2
13) SIGPIPE	14) SIGALRM	15) SIGTERM	16) SIGSTKFLT
17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU
25) SIGXFSZ	26) SIGVTALRM	27) SIGPROF	28) SIGWINCH
29) SIGIO	30) SIGPWR	31) SIGSYS	34) SIGRTMIN
35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3	38) SIGRTMIN+4
39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12
47) SIGRTMIN+13	48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14
51) SIGRTMAX-13	52) SIGRTMAX-12	53) SIGRTMAX-11	54) SIGRTMAX-10
55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7	58) SIGRTMAX-6
59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX	

大家可以看到,信号是从 1 开始编号的。当它们使用位掩码表示的的时候(比如 ps s 的输出结果),使用最后一位表示信号。

本文会集中讨论以下信号: SIGHUP, SIGINT, SIGQUIT, SIGPIPE, SIGCHLD, SIGSTOP, SIGCONT, SIGTSTP, SIGTTIN, SIGTTOU and SIGWINCH

SIGHUP

当系统发现满足持起条件的时候,UART 驱动会给整个会话的所有进程发送 SIGHUP 信号。一般来说,这会终止所有进程。但诸如 nohup(1) 或者 screen(1) 之类的程序,它们已经脱离会话(和 TTY 设备),所以它们的子进程不会收到挂起信号。

SIGINT

如果从标准输入流读取到交互通知符(interactive attention character,一般是 ^C,对应的 ASCII 编码是 3),TTY 驱动会给前台任务发送 SIGINT 信号(除非人为关闭这一特性)。只要有 TTY 设备的操作权限,从从都能修改交互通知符、开关交互通知功能。此外,会话的首领进程会记录每一个任务的 TTY 配置。切换任务的时候,首领进程会根据任务的配置调整 TTY 设备。

SIGQUIT

SiGQUITSIGINT 类似,但是针对退出符(一般是 ^\),程序收到信号后的默认行为也有所不同。

SIGPIPE

如果进程向管道写入内容,但没有进程从该管道读取内容,内核就会给进程发送 SIGPIPE 信号。如果没有 SIGPIPE,像 yes | head 这样的任务就没法结束。

SIGCHLD

内核在进程退出或者状态发生变化(暂停、继续)的时候会给其父进程发送 SIGCHILD 信号。SIGCHILD 信号还会附带一些扩展信息,包括:进程ID、用户ID、终止状态或信号,以及其他一些统计信息等。会话首领进程(shell)会根据这些信号跟踪它的任务。

SIGSTOP

收到该信号的进程会被无条件挂起,也就是说不能定制该信号的处理动作。注意,SIGSTOP 并非内核在任务调度时发出的。而是在收到 ^Z 之后触发的。这个动作可以由应用程序拦截。应用程序可以在拦截之后将光标移动到屏幕的底部或者将终端切换到特定的状态然后让自己进入休眠状态。

SIGCONT

SIGCONT 可以唤醒休眠的进程。当用户执行 fg 指令的时候,shell 就会发送 SIGCONT 信号。应用程序无法拦截 SIGSTOP 信号。所以一旦收到该信号,就说明该进程之前被挂起,然后被唤醒。

SIGTSTP

SIGTSTPSIGINTSIGQUIT 类似,但对应的魔法字符一般是 ^Z,而且默认动作是挂起进程。

SIGTTIN

如果后台任务试图从 TTY 设备读取内容,TTY 就会给它发送 SIGTTIN 信号。这通常会挂起该任务。

SIGTTOU

如果后台任务试图向 TTY 设备写入内容,TTY 就会给它发送 SIGTTOUT 信号。这通常会挂起该任务。我们可以给每个 TTY 设备关闭这一特性。

SIGWINCH

我们前面说过,TTY 设备会跟踪终端的尺寸,但应用需要手动更新这些信息。当终端尺寸发生变化,TTY 设备会给前台任务发送 SIGWINCH 信号。诸如编辑器之类良好的交互程序会在收到信号后从 TTY 设备读取终端尺寸信息并相应地重新绘制界面。

举个例子

假设你选用了一款终端文本编辑器编辑文件。光标可能处于屏幕的中央位置。编辑器正在执行诸如在大文件中搜索或者替换这一类 CPU 密集型任务。现在你按 ^Z。因为行线路规则已经配置成拦截 ^Z (ASCII 编码为 26) ,你不需要等待编辑器完成任务之后才能读取 TTY 设备。相反,线路规则子系统会给前台进程组发送 SIGTSTP 信号。进程组包含编辑器以及编辑器创建的所有子进程。

编辑器已经注册了 SIGTSTP 信号的处理函数,所以内核会将执行流切换到信号处理代码。这些代码会将光标移动到屏幕的最后一行(需要向 TTY 设备写入对应的控制序列)。因为编辑器还在前台运行,所以它可以向 TTY 设备写入控制序列。但写完之后编辑器就会向自己的进程组发送 SIGSTOP 信号。

现在编辑器暂停了。会话的首领进程会收到 SIGCHILD 信号,并附带了暂停进程的ID。当有的前台进程都暂停后,会话首领进程会读取当前 TTY 设备的配置并保存,供后续恢复使用。会话首领进程接着会使用 ioctl 调用将自己注册为 TTY 设备的前台进程。然后会输出诸如 “[1]+ Stopped” 之类的内容,来通知用户有任务已经暂停。

这时候,ps(1) 会告诉你编辑器进程处于暂停状态(T)。如果你想唤醒它,可以使用 shell 的内置命令 bg 或者使用 kill(1) 命令给进程发送 SIGCONT 信号。编辑器收到信号后会执行对应的处理函数。该函数可能会重新绘制编辑器的 GUI,这需要向 TTY 设备写入内容。但因为编辑器是后台任务,TTY 设备不允许其写入。相反,TTY 设备会向编辑器进程发送 SIGTTOU 信号,并再次暂停编辑器进程。这个信息会通过 SIGCHILD 信号发送给会话首领进程,shell 会再一次向终端写入 “[1]+ Stopped” 提示。

然而,当我们执行 fg,shell 首先会恢复之前保存的线路规则配置。它会通知 TTY 设备,从现在开始编辑器将作为前台任务执行。最后,它会给编缉器进程组发送 SIGCONT 信号。收到信号后编辑器会尝试重绘 GUI。因为它这个时候已经是前台任务,所以不会再被 SIGTTOU 信号打断了。

流量控制和阻塞I/O

如果在 xterm 中运行 yes,你会看到一行行连续不断的 “y”。xterm 应用需要执行解析每一行的内容、更新帧缓冲区、通知 X 服务器滚动窗口内容等操作。很显然,yes 输出 “y” 的速度跟 xterm 处理的速度是匹配的。但这些进程相互之间是怎么配合的呢?

问题的答案就在阻塞 I/O。伪终端可以只能在自己的内核缓冲区保存一定数量的信息。如果缓冲区满了,而且 yes 还尝试调用 write(2) 写入内容,这个 write(2) 调用就会被阻塞。这时 yes 进程就会被挂起并进进入等待状态。它会一直休眠到 xterm 进程读取一部分缓冲区的内容为止(也就是缓冲区有空间了。译者注)。

如果 TTY 设备连接的是串口,工作过程也是一样的。yes 可以以高于波特率(可能是 9600)的速度写入内容。如果串口的最大速度被限制在那里,内核缓冲区快就会写满,后续的 write(2) 调用就会阻塞写入进程。如果该进程使用的是非阻塞 I/O,就会收到 EAGAIN 错误码。

信不信,就算是内容缓冲区没满,也可以手动将 TTY 设备切换到阻塞模式。除非后续重新设置,否则每个进程尝试通过 write(2) 写入 TTY 设备都会被自动挂起。这种功能有什么用呢?

假设我们以 9600 的波特率跟一台老旧的 VT-100 设备通信。我们发送了一串复杂的控制序列通知终端滚动屏幕。这个时候终端会忙着执行滚动操作,没法继续以 9600 波特率接收数据。在物理链路层面,终端的 UART 还是以 9600 波特率运行,但终端没有足够的空间保存收到的字符。这时候就应该将 TTY 设备切换到阻塞状态。

我们前面已经说过,TTY 设备可以通过修改配置来特殊处理某些字节。比如在默认配置下,终端收到的 ^C 字符并不会被应用的 read(2) 读取,而是会触发 TTY 给前台任务发送 SIGINT 信号。同样地,也可以配置 TTY 设备响应结束流控命令和开始流控命令。一般是通过 ^S(ASCII 编码 19) 和 ^Q(ASCII 编码 17) 完成。老设备会自动发发送这些字符,并且期待操作系统根据这些字符修改成对应的控制流。这就是所谓的流量控制。这也是当你不小心按到了 ^S 后你的 xterm 看起来像锁住一样(不显示输入内容,译者注)的原因。

这里有一处重要的差异。不论是 TTY 被流量控制设置成暂停状态还是缓冲已满,进程向 TTY 设备写入内容都会被挂起。如果是后台任务尝试写入 TTY 会收到 SIGTTOU 信号并挂起整个进程组。我不清楚为什么 UNIX 的设计者没有使用阻塞 I/O,而是又发明了 SIGTTOUSIGTTIN 信号。但我猜 TTY 驱动被设计用来管理整个任务,而不单是处理任务中的具体的进程。

配置 TTY 设备

要想查看 shell 控制的 TTY 设备,你可以查看 ps l 的输出结果。这个我们在前面已经说过了。你也可以简单地运行 tty(1) 命令。

进程可以使用 ioctl(2) 来读取或者修改打开的 TTY 设备。API 文档在 tty_ioctl(4) 手册中。因为它是 Linux 应用跟内核的二进制接口,所以在不同的 Linux 版本中都保持不变。但是,这个接口没法移稙到其他系统。如果想移稙到其他平台,需要使用 POSIX 封装的版本,这在手册 termios(3) 中有描述。

我不会展开讨论 termios(3) 接口。但是如果你在开发 C 程序并希望拥有诸如禁用 ^C 发送 SIGIT 信号、禁用行编辑或字符回显、修改串口波特率、开关流控制等等特性,你会发现想要的内容都在前面提到的手册里。

还有一个命令行工具叫 stty(1),可以管理 TTY 设备。它使用 termios(3) 接口。

让我们试一下吧。

 $ stty -a
speed 38400 baud; rows 73; columns 238; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = <undef>; eol2 = <undef>; swtch = <undef>; start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W; lnext = ^V; flush = ^O; min = 1; time = 0;
-parenb -parodd 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

-a 选项是让 stty 显示所有配置。默认情况一下它会输出 shell 连接的 TTY 设备的配置,但你可以通过 -F 选项指定任意设备。

有一些配置是 UART 参数,有一些是线路规则,还有一些用于任务控制。这些配置协同工作。我们看一下第一行:

配置 模块 功能
speed UART 波特率。伪终端会忽略该配置。
rows, columns TTY 驱动 有人认为这是 TTY 绑定的终端的尺寸(以字符数为单位)。但它们中是内核空间内的一对变量,你可以随便读写。修改这两个参数后 TTY 驱动会给前台任务发送 SIGWINCH 信号。
line 线足规则 TTY 设备上绑定的线路规则。0 表示 N_TTY/proc/tty/ldiscs 里列出了所有合法的取值。没有列出的取值会被当成 N_TTY,但不要依赖这一特性。

自己尝试一下这些操作:打开 xterm。记下它的 TTY 设备名(使用 tty 命令)和尺寸(使用 stty -a 命令)。在 xterm 中启动 vim (或者其他全屏终端应用程序)。编辑器会从 TTY 设备读取当前终端的大小然后将自己的界面填满整个屏幕。现在我们打开另一个窗口,输入:

stty -F X rows Y

这需 X 是 TTY 设备名,Y 设成终端高度的一半。该命令会更新内核内存中的 TTY 数据,并且给编辑器发送 SIGWINCH 信号。编辑器收到信号后只会利用窗口上方一半的区域重绘自己的界面。

stty -a 输出的第二行展示出所有的特殊字符。打开一个新 xterm 窗口,执行:

stty intr o

现在按 “o” 「而非 ^C 」就会给前台任务发送 SIGINT 信号。你可以试一下其他命令。比如 cat,验证一下能不能用 ^C 打死进程。然后试着在终端输入 “hello”。

有时候你可能会遇到退格键不正常的 UNIX 系统。这是因为终端模拟器没有将退格字符(ASCII 字符可能是 8 或者 127)转换成 TTY 设备中 erase 配置指定的字符。解决问题最常用的方法是执行 stty erase ^H(ASCII 编码 8) 或者 stty erase ^? (ASCII 编码 127)。但有很多终端应用程序使用 readline 库,这会将行约束规则设成原始模式。刚才的方法对这类应用程序不会生效。

最后,stty -a 会列出一系列开关。显然,没有使用特别的顺序。有些跟 UART 相关,有些会影响行约束规则,有些会影响控制流,有些用于任务控制。短杠(-)表示开关是关着的,其他表示开着。所有开关都在 stty(1) 的手册中有详细的说明,所以我在这只大概提几个:

icanon 开关行编辑模式。你可以在一个新的 xterm 窗口执行下面的命令:

stty -icanon; cat

注意,诸如 ^U 之类的行编辑命令都不管用了。而且 cat 一次只能收到一个字符(以及后续字符),而不是之前的一次可以收到一行内容。

echo 开启字符回显功能,默认开启。打开行编辑模式(stty icanon),然后输入:

stty -echo; cat

你后面输入的时候,终端模拟器会将内容传输给内核。通常内核会将相同的内容写回终端模拟器,这样你就能看到自己输入了什么字符。如果关闭字符回显功能,你就看不到自己输入的内容。但我们这个时候处于 cooked 模式,行编辑功能依然有效。一旦你按了回车,线路规则就会将编辑缓冲区的内容发送给 catcat 会将你输入的内容写回屏幕。

tostop 控制后台任务能否向终端写入内容。先试一下这行命令:

stty tostop; (sleep 5; echo hello, world) &

& 符号表示在后台运行任务。五秒终后,任务会试着向 TTY 写入内容。TTY 驱动会发送 SIGTTOU 信号并挂起进程。你的 shell 也可能会立即在屏幕上打印这一信息,也可能会先输入命令提示符再输出相关信息。现在我们关掉后台任务,然后执行下面的命令:

stty -tostop; (sleep 5; echo hello, world) &

命令执行后会切换到命令提示符状态。但五秒钟后,后台任务会向终端写入 hello, world ,该内容可能会出现在你正在输入的内容中间。

最后,你可以使用 stty sane 将 TTY 设备配置恢复到初始状态。

结论

我希望本文已经为你提供了足够多的信息来理解 TTY 驱动和线路规则、以及它们跟终端、行编辑和任务控制的关系。更多详情可以阅读我前面提到的 man 手册和 glibc 手册(info libc, “Job Control”)。

最后,欢迎大家在本文(原文)或者本站(原站)留言,但我不一定有时间回答所有收到的问题。谢谢阅读。