浅析 TTY Subsystem
written by SJTU-XHW
References: 参考文献见文章末尾。
本人学识有限,解析难免有错,恳请读者能够批评指正,本人将不胜感激!
为什么讨论 TTY
在 Linux 系统中,TTY 是一个相当有名的子系统。当你输入 ls -l /dev
的时候,你能看到许许多多以 tty
为前缀的字符设备,这就是对 TTY 子系统在文件上的抽象。
虽然我们无论是命令行还是 GUI 使用 Linux 的时候,TTY 似乎对我们无感,但是,当我因为一个小问题在互联网上查了很多关于 TTY 资料,却仍然很难找到一个完整的知识资料的时候,我发现这个 TTY 子系统的重要性被很多人忽视了。在整理了相关资料后,我愿意相信,搞清楚 Linux 的 TTY 子系统,属实是入门 Linux 系统的必由之路。
尤其是,如果你想要写一些更接近 Linux 底层的、I/O interactive 方面的应用程序,那么 TTY
的知识就显得更加重要。
前段时间,我在写 CSAPP 的 shell lab,工作量不大所以很快就完成了。但是文档里有句话让我不解,“这个 tiny shell 不需要支持运行类似 vim
和 tmux
的程序,它们会对你的终端进行一些修改”。出于 “你不让我做,我偏要做” 的心态,我迅速拿着已经完工的 tsh
运行 vim
,结果,vim
被第 22 号信号挂起了。我尝试用内置指令 fg
将任务调向前台,也无济于事:
很自然地,我查看了一下 22 号信号,发现是一个我未曾谋面的信号:SIGTTOU
。很快啊,英文文档 “啪” 的一下就来了,我没有闪,只看见字里行间密密麻麻地挤满一个单词:TTY
。
Google 了很多文章之后我才发现,我们一直使用的命令行(CLI)都建立在一个无形的机制之上,这个机制接收并且传递我们在终端软件窗口的字符指令,建立起用户和操作系统的 “调度 和 沟通” 的桥梁。这,我以前一直是没有关注的。
于是我明白,想要了解这之中究竟发生了什么、想要解决这个问题,就有必要了解一下 Linux TTY 了。
对历史不感兴趣的同学请跳过下面一节。
TTY 的成长之路
实际上,TTY 在 OS 中的迭代和发展史谈不上 “elegant”,甚至可以用 “蜿蜒曲折” 来形容。
在 1869 年,一种机器叫做 “股票行情自动收录器”(stock ticker)在当时金融系统不断发展的资本主义世界诞生了。它的样貌可以描述一下——一个电驱动的机械装置,包含一台机械打字机(typewriter)、一捆的电线,还有一长串用以打孔的纸带组成,它工作时的样貌如下图1:
它的使用和早期的计算机一样(不过更早),都需要有一个操作员在一旁输入指令、处理输出纸带,最终可以达成 “远距离实时广播股票价格” 的任务,是不是有点像影视剧里的电报?
不过这个机器很快就演变为一个更快、基于 ASCII 编码的机械结构。知道它的名字的人可能会多一点:teletype(电传打字机,简称 TTY)。那么 teletype 在当时时如何完成给通信的另一方传输消息的功能的呢?答案是,连接上当时最大的 “网络”——Telex,专门用来在各个资本家之间传输商业电报。
你可能会好奇,这个时候的计算机是什么情况?很遗憾,这个时候计算机还超级大、超级原始,似乎和 teletype 没啥交集。但是没过几年,当计算机的设计和架构发展到,已经可以支持执行多任务(multitask)的时候,一个重要的能力,却也是等待解决的问题就来了:计算机可以与用户进行实时交互了。这意味着,之前 “将打孔写程序的纸带传入机器” 的旧批处理模型(old batch processing model)就可以被人机交互所代替,从而大大减小操作员运行计算机的工作量2。
那么,如何实现与机器的直接交互?(传纸带肯定不够高效的🤡 于是大佬们将视线转向当时市场中一个随处可见的机器——teletype 电传打字机。他们打算用 teletype 连接上计算机,这样只需要打打字向计算机发指令就可以达到交互和执行命令的效果。
有个难题是,当时市面上的 teletype 没有一个 “行业标准”,每个厂家生产的机器都略有不同。在计算机科学的视角来看,这好办,封装嘛,设计一个统一的 从 teletype 输入到 计算机的软件层适配接口(硬件层好说,就串口电信号而已,主要是软件驱动)就行,下次无论 teletype 生产厂家设计什么样的机型,只要计算机的系统支持这个接口规范,那不拿来就能用?
在当时的 Unix 世界中(当时 Unix 已经出现),一种方法是让 OS Kernel 来实现这层接口,处理所有有关于 teletype 输入的底层细节,例如字长(word length)、串口波特率(baud rate)、流控制(flow control)、校验(parity,学过数电的同学会比较清楚这里面的机制)、基础的行 buffer 编辑能力等等。
在 20 世纪 70 年代晚期,基于 teletype 和 OS 处理接口的一类新型输入终端出现了(例如 VT-100),它们在 teletype 的基础上包装了屏幕,支持闪烁的命令行光标、有色彩的输出信息等等,被人们称为 video terminals。
不过,到了今天,我们可以发现所有实体的 teletypes、video terminals 都已经 “灭绝” 了,你只能在一些博物馆,或者硬件发烧友的收藏中寻得它们的踪迹。
由于历史习惯的原因,当今你能看到所有的 Unix 类系统中还有 “TTY”,不过它们都是对以往的 video terminals 在软件上的模拟而已。正如一位外国网友所说:
The legacy from the old cast-iron beasts is still lurking beneath the surface3.
这头钢铁巨兽(teletypes)的意志仍蛰伏在如今的 OS 的外表之下3。
用户视角下的 TTY: old versions
想要了解现代 Linux 系统中的 TTY 机制,我们还需要将时间拨回很早以前——人们使用实体 teletype 输入的时候。我们将 teletype 这个输入计算机指令以实现人机交互的设备称之为 终端(terminal)。当时的软硬件结构是这样的:
teletype 是用一捆捆电线连接到计算机的一个叫 UART(Universal Asynchronous Receiver and Transmitter,通用异步收发器)的硬件设备上的。
对应地,内核里就写了一个驱动 UART driver,用来管理从物理 terminal 设备上发来的电信号 bytes,处理一些细节例如前一节提到的 bytes 校验、流控制。
一开始,UART driver 是直接将初步处理好的 bytes 流直接传给一些正在运行的进程,但是显然这种方法需要弥补几个缺陷:
1. 行编辑能力(Line Editing)
正常人在敲键盘(当时是敲打字机)的时候,无法避免的可能会打错。所以,肯定得有 backspace 退格的功能对吧?这个功能当然可以交给接收信号的进程自己来处理,但是计算机科学讲究不能混淆抽象层级,而且这也不符合 Unix 的设计哲学——运行在其上的程序应该越简单越好(只关心自己的业务逻辑)。
所以为了方便考虑,当时的人们直接把这个功能也放到了操作系统内核里了。这时,操作系统就需要提供一个编辑内容的缓冲区(editing buffer),并且支持一些基本的编辑指令(退格、擦除单词、清空一行等),这些软件功能模块被叫做 line discipline(好家伙,这是在内核里写了一个行编辑器),它的地位如本节开头的图所示。
不过,不同的上层应用程序对于它们接收的输入流肯定有各种各样的自定义的要求,因此 line discipline 除了提供正常的行编辑功能(cooked or canonical mode),还提供 raw mode(对输入流不做处理)。因此这些 advanced application(例如本身就是编辑器软件、邮件客户端、shells,以及自己调用 curses
和 readline
的程序)常常将系统的 line discipline 设置为 raw mode,然后自己执行对流的控制。
正如上面所说,OS Kernel 提供了多种不同的 line disciplines,不过一次只有一个 line discipline attach 到指定的串口设备上(对于旧时设备,如上图,串口设备是 UART driver)。默认的 line discipline 会提供基本的行编辑功能,叫做 N_TTY
(driver/char/n_tty.c
),还有另外种类的 line disciplines 控制例如鼠标串口等。
2. 会话管理(Session Management)
还有一个很重要的一点是,对于一个用户而言可能他(她)需要同时运行多个程序,然后一次与它们之中的一个程序交互。总的来说,用户需要:
如果一个应用程序陷入死循环,那么用户可能会需要向其发信号(kill / suspend……);
一个后台程序如果需要向终端(terminal)写数据,那么它们需要被 suspend 直至前台进程处理结束,或者前台程序已经为其准备好了写终端的环境。
为什么需要 suspend?不然所有后台程序都能随意更改终端,那不乱套了!其实,再想想,你在后台运行一个
echo "Hello"
好像也不会挂起?这个问题与之前的不相悖,我们后面讨论;这个 suspend 的机制就是借助了 OS Kernel 发送的
SIGTTOU
机制,后面详细说。
用户向当前 terminal 输入,肯定只能发送给当前的 “前台”(foreground)进程。
常见的 OS 都会 implement 一个 TTY driver(drivers/char/tty_io.c
)来实现上述需要(具体地位前本章开头的图片)。TTY driver 也是个软件,但和一般的 “进程” 不一样:它是个 “passive object”,这意味着它像一个库,只有一些数据域(data fields)和一些方法(methods),等待其他进程的主动调用 / 内核中断 handler 调用。
于是,人们通常将 UART driver(常用来直接读取串口设备、进行简单 parity 和 flow control)、line discipline 实例(用来作为 editing buffer,处理简单的行编辑命令)、TTY driver(多个进程间读取 terminal 的会话管理)三元组(triplet)称为一个 TTY device(TTY 设备)。
因此,Unix 操作系统对上层的应用程序抽象的 terminal 就是 TTY
设备文件,存放于 /dev
。
顺便提一句,Unix 系统想要对 TTY 设备文件写入的用户需要是这个 TTY 设备文件的拥有者(owner)。我们经常使用
shell
的 login mode 登陆服务器时,内部会执行login
程序,其工作之一便是以root
权限将 shell 接下来需要使用的/dev/ttyN
的拥有者换为登陆用户,以便登录用户能够读写 terminal;
用户视角下的 TTY: modern versions
我们了解了以前计算机是如何将 teletype 连接到计算机上并与之交互的,现在我们再将视野转移到现代计算机(desktop system,一般是图形化的)。
还是讨论现代 Unix 系统,这个时候输入输出模型可以抽象为如下图形:
此时 TTY driver 和 line discipline 的 作用和以往版本的作用几乎相近,但是现在已经不存在 teletype 了,取而代之的是 键盘 和 显示屏,因此就没有 UART 和实体 terminal 什么事了。
取而代之的,是一个在 terminal emulator 中模拟的 video terminal(可以理解为一个包括了字符帧缓存(frame buffer of characters)和图形属性)的复杂状态机)。
它的作用是接收来自键盘驱动的预处理信号,将其传给对应得 line discipline,并将输出数据渲染到 VGA 显示器上。
等等!不太对,我们一直讨论的是 terminal emulator,因此有下面 2 种问题:
- 目前这台计算机还只能简单字符输入和显示,相当于只实现了命令行子系统(console subsystem);
- emulator 位于内核中,不够灵活(rigid),在抽象层级上说,为了可维护性和可扩展性,应该将其挪到用户态空间中(userland);
事实上,为了解决上面的问题,TTY device 的设计更加抽象了:
我们发现,line discipline、TTY driver 的结构都不需要变嘛,因为它们还需要提供基本的行缓冲、会话管理的功能。因此,人们在内核中设计出了 pseudo terminal(pty
,伪终端),用于对接支持 xterm
(Unix 中一种默认的终端模拟器)的桌面程序。
注意,如果你在一个
xterm
桌面进程中调用了screen
、tmux
等程序,那么就是 pseudo terminal 套 pseudo terminal 的复杂结构,这里不讨论。
TTY 与 进程
了解了 TTY
的发展和构成,那么 process 是如何和 PTY 对接的呢?为了讨论这个问题,我们需要复习一些进程、shell job control 的知识。
补充 1:Unix Processes
大部分学过 CSAPP 的同学可能对进程已经有了初步了解,尤其是在考完了 ICS 之后对其理解更为深刻…… 不过 CSAPP 关于进程的知识并不是操作系统的全部,我们下面补充一点。
一个 Linux Process 通常有 5 种状态:
R
:Running / Runnable(On OS run queue),此状态的进程正在 CPU 上运行,或者做好被 CPU 调度的一切准备;D
:Uninterruptible sleep(waiting for some events),此状态的进程因为某些指令正在等待某些事件,大部分原因是 I/O 事件,通常不会被 CPU 调度;例如,程序执行到
read
等系统调用时,等待 I/O 设备触发 CPU 中断引脚;S
:Interruptible sleep(waiting for some events or signals),此状态的进程因为某些指令正在等待某些事件,或者等待某类信号,通常不会被 CPU 调度;例如,程序执行到
pause
、sleep
等系统调用时,挂起等待信号 / 系统计时器中断;T
:Stopped,外部造成的暂停执行,只有 2 种可能:shell 的 job control signal,或者 debugger 断点;Z
:Zombie,已经终止的子进程,但是父进程存在,并且没有回收它;
进程组的知识不再赘述,与大家在 CSAPP 中接触到的一样。
补充 2:Shell Job Control
在 CSAPP 的 shell lab 中,我们进一步深化了对课本中 shell 的理解。通常情况下,一个 shell 承担了一个重要的工作:对用户的程序管理为 前台、后台 两种形式,方便用户指定和操作。这种管理方法就称为 job control。
正如 CSAPP 一书中对 job control 的说明,shell 的进程管理应该呈现如下的模式:
当用户通过 shell 登陆,或者仅启动 shell 时,shell 作为系统中的一个进程,会单独设置自己的进程组(自己进程组号改为与自身进程号一致,防止与父进程联系,导致收到父进程的信号);
此后,通过 shell 创建的所有进程(执行的所有程序)都会在 execve
前设置单独的进程组号,方便 shell 统一管理。前台、后台进程的子进程一般都在自己的进程组里。其中前台进程只有一个,后台进程有多个。
但是!CSAPP 中没有说明的一点是,前台进程是如何与用户输入建立联系的?更准确地说,前台进程如何准确 attach 到当前的 pseudo terminal 上的?
补充 3:Shell Sessions(重要)
实际上,Linux 上除了进程、进程组,还有一个重要的机制 会话(Session,其实 Windows 上也有)。对应的,每个进程也有一个 session ID(sid
),其机制如下:
一个
session
一般包含了多个进程,不过其中有且仅有一个进程是特殊的,被称为 session leader,它一般是创建这个 session 的进程;注:Session Leader 可以在进程信息中看到。
大家不妨运行
ps aux
查看一下当前进程的状况,比如:中间红框的一列描述的就是各个进程的状态。其中可能出现的
D
、R
、S
、T
、Z
称为 标识符,就是之前介绍的含义。还有一类标识符I
表示 Idle Kernel Thread(空闲的内核线程)4;不过除此以外还有一些修饰符,例如:
<
表示该进程的优先级较高(一般是root
用户指定);N
表示该进程优先级较低(一般是普通用户创建,并且主动 Sleep 的时间很长);s
(小写)表示该进程为 session leader;l
表示该进程中的程序是多线程的;+
表示该进程在 shell 指定的前台进程组(foreground process group)中;
每个新创建的进程的
sid
与父进程相同(与父进程共用一个 session);一个 pseudo terminal 只能与一个 session 建立联系;与当前
pty
建立联系的 session 中,只有 session leader 有权控制pty
和 session 的联系;这里的 “联系” 需要开发者(例如 shell 开发者)显式提醒
TTY driver
foreground process group 的 group id 才能完成。使用系统调用tcsetpgrp
:1
2
3
4/* set the specific process group as the foreground process group. */
/* `fd` must be the controlling terminal of the calling process,
* and still be associated with its session. */
int tcsetpgrp(int fd, pid_t pgrp);如果 后台进程组的程序想要读
stdin
或写stdout
(指向当前 session 对应的pty
),那么 OS Kernel 会向该进程发送SIGTTIN / SIGTTOU
信号(default action 是终止进程),要求该后台进程等待;某些情况下,即便是后台进程组也可以直接向当前 session 写,例如在终端上执行命令:
echo Hello &
,则会直接打印内容。这与终端的设定有关。首先 OS 和
TTY device
的设计者们意识到以下的用户需求:- 用户不希望后台进程读取当前 terminal 的输入;
- 用户可能允许后台进程向当前 terminal 输出;
- 用户可能不希望后台进程更改当前 terminal 的设置(包括串口比特率、line discipline、输出策略等等);
因此针对以上需求,设计者们设定:
- OS 不允许后台进程从当前
pty
读入,kernel 一定会对调用读入操作的进程发送SIGTTIN
信号; - OS 默认允许后台进程向当前
pty
输出。允许用户修改这一行为; - OS 默认不允许后台进程修改当前
pty
设置,kernel 一定会对该进程发送SIGTTOU
信号;
另外补充一句,用户可以通过
stty
指令来查看、修改当前终端设置。例如:stty [-a/--all]
:打印当前终端设置;stty tostop/-tostop
:设置不允许 / 允许后台进程向当前pty
输出,默认允许;
到这里,我们终于能够解释文章一开始的问题了——为什么 tsh
中运行 vim
,vim
会收到 SIGTTOU
了。这是因为 CSAPP 的 shell lab 对 “前台进程” 的处理方式有问题,它没有真正将要在前台执行的进程加入前台进程组。
Conclusion: TTY & Process with SIGNALs
现在,所有必须的知识已经集齐了,我们来看看 TTY
与进程交互的总体机制。
之前提到过 Unix 中的一切 I/O 设备都被抽象为 Unix files,这也包括 TTY device。与其他文件一样,一个进程想要进行读写前必须进行一些初始化操作,这个初始化就要求进程与内核间通信,所以信号是不可避免的。
Tips. 在
ioctl
C 库中含有许多与TTY
设备读写相关的操作。
我们以涉及修改 pty
设置的一串动作来举个例子。
step 1. 假设你在一个 login shell 中执行 vim
,那么:
当前 shell 为 vim
创建一个进程(fork & execve
)和进程组(setgid
),通知 TTY device
将该进程标记为前台进程组(tcsetpgrp
);
所以你会看到如下图,启动 vim
后,vim
进程所在组成为 foreground process group(进程状态 S+
);而 shell(这里是 zsh
)进程则由 Ss+
转为 Ss
(不再是前台进程,但仍是 session leader),并且它们都位于同一个 session 中(指向同一个 pts
,即 pts/1
):
step 2. 假设你在 vim
运行时按下 Ctrl + Z
将 vim
转向后台挂起,那么:
触发 Asynchronous Exception(ECF),vim
作为前台进程组直接收到 OS Kernel 发送的 SIGTSTP
信号。在 vim
的 SIGTSTP
signal handler 中,vim
做挂起前的准备工作(例如通过向 TTY device
输出特定序列,将光标移动到上次位置、恢复打开前的命令行内容等等)。最后 vim
向自己进程组发送 SIGSTOP
,正式将自己的进程组挂起到后台;
此时,vim
父进程(之前的 shell)收到 SIGCHLD
信号,shell 进入对应的 handler 进行处理(例如打印 [1]+ Stopped
信息给用户等工作),并且将自己的进程设置为前台进程组(tcsetpgrp
);
这个时候,如果你试图以后台形式运行
vim
(例如bg
),那么vim
会收到 kernel 的SIGTTOU
信号,继续挂起(因为vim
会更改终端设置,例如 line discipline 等等);
step 3. 假设此时你使用 fg
将 vim
转到前台继续执行,那么:
shell 会恢复上一次的 pty
设置、通知 TTY device
当前的前台进程为 vim
所在进程、向 vim
进程发送 SIGCONT
继续。于是 vim
恢复运行、重绘界面;
Summary
当然,TTY device
在与进程交互时,还有更多可用的操作和行为,例如 flow control、blocking I/O,以及文中没有详细介绍的 pty
终端设置,等等。感兴趣的童鞋可以查阅 Linux 的官方文档、本文所引用的参考资料,或者是 man 文档。更多问题或勘误欢迎交流 ~
参考资料
1:Ticker tape - Wikipedia
2: Batch processing - Wikipedia
3: The TTY demystified (linusakesson.net)
4:smp - Why does linux kernel need idle thread? - Stack Overflow