TTY 解密 (2008)

Hacker News Top 工具

摘要

详细解释Linux和UNIX中的TTY子系统,涵盖从电传打字机到现代模拟终端的历史,以及线路规程的作用。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/05/20 02:23

# TTY 探秘 来源:https://www.linusakesson.net/programming/tty/index.php 20世纪40年代的真实电传打字机。 TTY 子系统是 Linux 乃至整个 UNIX 设计的核心。不幸的是,它的重要性常常被忽视,而且很难找到关于它的优秀入门文章。我认为,对于开发者和高级用户来说,理解 Linux 中的 TTY 是必不可少的。不过要小心:你即将看到的东西并不特别优雅。事实上,TTY 子系统——虽然从用户的角度来看功能相当完备——却是一个充斥着各种特殊情况的复杂小混乱。要理解它为何变成这样,我们必须回到过去。 ## 历史 1869年,*股票行情收录器*被发明出来。它是一种由打字机、一对长线和纸条打印机组成的机电设备,其目的是实时远距离分发股票价格。这个概念逐渐演变成了更快的、基于 ASCII 的*电传打字机*。电传打字机曾经连接成一个遍布全球的大型网络,称为*Telex*,用于传输商业电报,但当时这些电传打字机尚未与任何计算机连接。 然而,与此同时,计算机——虽然仍然相当庞大和原始,但已具备多任务处理能力——变得足够强大,能够与用户实时交互。当命令行最终取代了旧的批处理模式时,电传打字机被用作输入和输出设备,因为它们在市场上很容易获得。当时存在各种各样的电传打字机型号,彼此略有不同,因此需要某种软件兼容层。在 UNIX 世界中,方法是让操作系统内核处理所有底层细节,例如字长、波特率、流量控制、奇偶校验、用于基本行编辑的控制代码等等。而在 20 世纪 70 年代末期,由固态*视频终端*(如 VT-100)实现的炫酷光标移动、彩色输出和其他高级功能,则留给了应用程序。 如今,我们发现物理电传打字机和视频终端几乎已经灭绝。除非你去参观博物馆或拜访硬件爱好者,否则你很可能看到的都是模拟的虚拟终端——对真实设备的软件仿真。但正如我们将看到的,那些旧铁怪兽的遗产仍然潜伏在表面之下。 ## 使用场景 图 用户在一个终端(物理电传打字机)上打字。这个终端通过一对电线连接到计算机上的*UART*(通用异步接收/发送器)。操作系统包含一个*UART 驱动程序*,它管理字节的物理传输,包括奇偶校验和流量控制。在一个简单的系统中,UART 驱动程序会直接将传入的字节传递给某个应用程序进程。但这种方法缺乏以下基本功能: **行编辑**。大多数用户在打字时会出错,所以退格键通常很有用。这当然可以由应用程序自己实现,但根据 UNIX 设计哲学,应用程序应尽可能简单。因此,为了方便,操作系统在*线路规程*内部提供了一个编辑缓冲区和一些基本编辑命令(退格、删除单词、清除行、重新打印),这些命令默认是启用的。高级应用程序可以通过将线路规程设置为*原始*模式(而不是默认的*熟*(或*规范*)模式)来禁用这些功能。大多数交互式应用程序(编辑器、邮件客户端、shell、所有依赖 `curses` 或 `readline` 的程序)都运行在原始模式下,并自己处理所有行编辑命令。线路规程还包含字符回显和回车与换行之间自动转换的选项。你可以把它想象成一个内核级别的原始 `sed(1)`。 顺便提一下,内核提供了几种不同的线路规程。每次只有一个线路规程附加到给定的串行设备上。提供行编辑功能的默认规程称为 `N_TTY`(如果你有兴趣,可以在 `drivers/char/n_tty.c` 中找到)。其他规程用于其他目的,例如管理分组交换数据(ppp、IrDA、串行鼠标),但这超出了本文的范围。 **会话管理**。用户可能希望同时运行多个程序,并一次与其中一个交互。如果一个程序进入无限循环,用户可能想要杀死或挂起它。在后台启动的程序应该能够执行,直到它们试图写入终端,此时它们应该被挂起。同样,用户输入只应定向到前台程序。操作系统在*TTY 驱动程序*(`drivers/char/tty_io.c`)中实现了这些功能。 操作系统进程是“活的”(具有*执行上下文*),这意味着它可以执行操作。TTY 驱动程序不是活的;用面向对象的术语来说,TTY 驱动程序是一个被动对象。它有一些数据字段和一些方法,但它实际能做事的唯一方式是在进程或内核中断处理程序的上下文中调用它的某个方法。线路规程同样是一个被动实体。UART 驱动程序、线路规程实例和 TTY 驱动程序的特定三元组可以统称为*TTY 设备*,或者有时简称为 TTY。 用户进程可以通过操作 `/dev` 下相应的设备文件来影响任何 TTY 设备的行为。这需要对设备文件具有写权限,因此当用户登录到某个 TTY 时,该用户必须成为该设备文件的所有者。传统上,这是由以 root 权限运行的 `login(1)` 程序完成的。 上一张图中的物理线路当然可以是一条长途电话线: 图 这并没有改变太多,只是系统现在还必须处理调制解调器挂断的情况。 让我们继续来看一个典型的桌面系统。这是 Linux 控制台的工作方式: 图 TTY 驱动程序和线路规程的行为与前面的例子完全相同,但不再涉及 UART 或物理终端。相反,一个视频终端(一个复杂的状态机,包括字符和图形字符属性的*帧缓冲区*)在软件中仿真,并渲染到 VGA 显示器上。控制台子系统有些僵化。如果我们将终端仿真移到用户空间,事情会变得灵活(和抽象)得多。这是 `xterm(1)` 及其克隆的工作方式: 图 为了将终端仿真移到用户空间,同时保持 TTY 子系统(会话管理和线路规程)完整,*伪终端*或 *pty* 被发明出来。正如你可能猜到的,当你在伪终端内部运行伪终端时(例如使用 `screen(1)` 或 `ssh(1)`),事情会变得更加复杂。 现在让我们退一步,看看这一切如何融入进程模型。 ## 进程 Linux 进程可以处于以下状态之一: 进程状态 R 运行中或可运行(在运行队列中) D 不可中断睡眠(等待某个事件) S 可中断睡眠(等待某个事件或信号) T 停止,要么因为作业控制信号,要么因为被调试器跟踪。 Z 僵尸进程,已终止但尚未被父进程收尸。 通过运行 `ps l`,你可以看到哪些进程在运行,哪些在睡眠。如果进程在睡眠,`WCHAN` 列(“等待通道”,等待队列的名称)会告诉你进程在等待哪个内核事件。 ``` $ 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)` 系统调用,因此只要其子进程状态发生变化,这些进程就会被移动到运行状态。 有两种睡眠状态:可中断睡眠和不可中断睡眠。可中断睡眠(最常见的情况)意味着当进程在等待队列中时,它实际上也可能在接收到信号时被移动到运行状态。如果你查看内核源码,你会发现任何等待事件的内核代码都必须在 `schedule()` 返回后检查是否有信号挂起,并在这种情况下中止系统调用。 在上面的 `ps` 输出中,`STAT` 列显示每个进程的当前状态。同一个列还可能包含一个或多个属性或标志: s 该进程是会话领导者。 + 该进程是前台进程组的一部分。 这些属性用于作业控制。 ## 作业和会话 当你按下 `^Z` 挂起一个程序,或者使用 `&` 在后台启动一个程序时,发生的就是作业控制。作业与进程组是同一回事。像 `jobs`、`fg` 和 `bg` 这样的内部 shell 命令可以用来操作*会话*内的现有作业。每个会话由一个*会话领导者*(shell)管理,它使用复杂的信号和系统调用协议与内核紧密协作。 下面的例子说明了进程、作业和会话之间的关系: ### 以下 shell 交互... 截图 ### ...对应于这些进程... 表 ### ...以及这些内核结构。 - TTY 驱动程序 (`/dev/pts/0`) ``` 大小: 45x13 控制进程组: (101) 前台进程组: (103) UART 配置 (忽略,因为这是 xterm): 波特率、奇偶校验、字长等。 线路规程配置: 熟/原始模式、换行修正、中断字符含义等。 线路规程状态: 编辑缓冲区 (当前为空)、缓冲区内的光标位置等。 ``` - pipe0 ``` 可读端 (连接到 PID 104 的文件描述符 0) 可写端 (连接到 PID 103 的文件描述符 1) 缓冲区 ``` 基本思想是每个管道是一个作业,因为管道中的每个进程都应该同时被操作(停止、恢复、杀死)。这就是为什么 `kill(2)` 允许你向整个进程组发送信号。默认情况下,`fork(2)` 将新创建的子进程放在与父进程相同的进程组中,这样例如键盘上的 `^C` 会影响父进程和子进程。但是,shell 作为其会话领导者的职责的一部分,每次启动管道时都会创建一个新的进程组。 TTY 驱动程序会跟踪前台进程组的 id,但只是被动地。会话领导者必须在必要时显式更新此信息。类似地,TTY 驱动程序会跟踪连接终端的大小,但此信息必须由终端仿真器甚至用户显式更新。 如上图所示,多个进程将 `/dev/pts/0` 附加到它们的标准输入。但只有前台作业(`ls | sort` 管道)会从 TTY 接收输入。同样,默认配置下,只有前台作业允许向 TTY 设备写入。如果 `cat` 进程试图写入 TTY,内核会使用信号将其挂起。 ## 信号疯狂 现在让我们更仔细地研究内核中的 TTY 驱动程序、线路规程和 UART 驱动程序如何与用户态进程通信。UNIX 文件(包括 TTY 设备文件)当然可以被读写,并且可以通过神奇的 `ioctl(2)` 调用(UNIX 的瑞士军刀)进一步操作,为此已经定义了大量与 TTY 相关的操作。尽管如此,`ioctl` 请求必须由进程发起,因此当内核需要*异步*与应用程序通信时,它们无法使用。 在*银河系漫游指南*中,道格拉斯·亚当斯提到一个极其沉闷的星球,上面住着一群沮丧的人类和一种长着尖牙的动物,这种动物通过狠狠地咬人类的大腿来与他们交流。这与 UNIX 惊人地相似,在 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` 的输出中),最低有效位对应信号 1。本文将重点讨论以下信号:`SIGHUP`、`SIGINT`、`SIGQUIT`、`SIGPIPE`、`SIGCHLD`、`SIGSTOP`、`SIGCONT`、`SIGTSTP`、`SIGTTIN`、`SIGTTOU` 和 `SIGWINCH`。 ### SIGHUP - 默认动作:**终止** - 可能动作:终止、忽略、函数调用 当检测到挂起条件时,UART 驱动程序会向整个会话发送 `SIGHUP`。通常,这会杀死所有进程。某些程序,如 `nohup(1)` 和 `screen(1)`,会与其会话(和 TTY)分离,以便其子进程不会注意到挂起。 ### SIGINT - 默认动作:**终止** - 可能动作:终止、忽略、函数调用 当输入流中出现*交互式注意*字符(通常是 `^C`,ASCII 码 3)时,TTY 驱动程序会向当前前台作业发送 `SIGINT`,除非此行为已被关闭。任何具有 TTY 设备访问权限的人都可以更改交互式注意字符并切换此功能;此外,会话管理器会跟踪每个作业的 TTY 配置,并在作业切换时更新 TTY。 ### SIGQUIT - 默认动作:**核心转储** - 可能动作:核心转储、忽略、函数调用 `SIGQUIT` 的工作方式与 `SIGINT` 类似,但退出字符通常是 `^\`,默认动作不同。 ### SIGPIPE - 默认动作:**终止** - 可能动作:终止、忽略、函数调用 内核会向任何试图向没有读者的管道写入的进程发送 `SIGPIPE`。这很有用,否则像 `yes | head` 这样的作业永远不会终止。 ### SIGCHLD - 默认动作:**忽略** - 可能动作:忽略、函数调用 当进程死亡或改变状态(停止/继续)时,内核会向其父进程发送 `SIGCHLD`。`SIGCHLD` 信号携带额外信息,即进程 id、用户 id、终止进程的退出状态(或终止信号)以及一些执行时间统计信息。会话领导者(shell)使用此信号跟踪其作业。 ### SIGSTOP - 默认动作:**挂起** - 可能动作:挂起 此信号将无条件地挂起接收进程。

相似文章

终端全栈详解

Hacker News Top

全面解析终端、Shell、TTY、控制台及其相关概念(如POSIX和ANSI转义码)之间的区别与联系,并包含动手创建TUI的实践指导。

终端、TTY 与 Shell --- I've been curious about how terminals and shells work for a while now. I know that when I open a terminal emulator like iTerm and run `ls`, somehow the shell and the program `ls` run and print out results. But how does the terminal emulator communicate with the shell? What is a TTY? What even is a pseudoterminal? I've found a lot of existing resources on this kind of confusing or incomplete, so I'm going to try to write the clearest explanation I can. 我对终端和 Shell 的工作原理一直很好奇。我知道当我打开像 iTerm 这样的终端模拟器并运行 `ls` 时,Shell 和 `ls` 程序会运行并打印出结果。但终端模拟器是如何与 Shell 通信的呢?TTY 是什么?伪终端又是什么? 我发现现有的很多资料要么令人困惑,要么不够完整,所以我打算尽力写出最清晰的解释。 --- ## some history: physical terminals Before we talk about terminal emulators, let's discuss their predecessor: physical terminals. The very first computers were programmed not with a terminal but with physical switches. You would flip the switches to enter a binary program and see the output on some LEDs. Then computers got terminals. The earliest terminals were teletypes. These were basically fancy typewriters: they had a keyboard that you could type commands to the computer on, and a printer that would print out the results. Later, CRT screens replaced the printers. But the terminals still worked the same way: you type a character on the keyboard, the character gets sent to the computer, the computer sends the character back (this is called "echoing"), and the character gets displayed on the screen. The terminal itself wasn't doing a lot of computing – it was mainly just a way to communicate with the computer. Here's a picture of a VT100 terminal, which was introduced by DEC in 1978. This terminal was very important historically because it introduced support for ANSI escape codes (more on those later). ## 一些历史:物理终端 在谈论终端模拟器之前,我们先来聊聊它的前身:物理终端。 最早的计算机并不是通过终端来编程的,而是通过物理开关。你需要拨动开关来输入二进制程序,并通过一些 LED 灯查看输出结果。 后来,计算机有了终端。最早的终端是电传打字机(teletype)。这些基本上就是高级打字机:它们有一个键盘,可以向计算机输入命令,还有一台打印机,用于打印输出结果。 再后来,CRT 屏幕取代了打印机。但终端的工作方式仍然相同:你在键盘上输入一个字符,字符被发送到计算机,计算机再将字符发回(这称为"回显"),然后字符显示在屏幕上。终端本身并不承担太多计算工作——它主要只是一种与计算机通信的方式。 这是 VT100 终端的图片,由 DEC 于 1978 年推出。这款终端在历史上非常重要,因为它引入了对 ANSI 转义码的支持(后面会详细介绍)。 --- ## what's a TTY? TTY is short for "teletypewriter" – basically just another name for "terminal". In Linux, if you look at `/dev/`, you'll see a bunch of TTY devices. Historically, these TTY devices corresponded to physical terminals plugged into the computer. Each physical terminal connected to the computer had a corresponding TTY device in `/dev/`, and the operating system would communicate with those physical terminals through those TTY devices. ## 什么是 TTY? TTY 是"teletypewriter"(电传打字机)的缩写——基本上就是"终端"的另一种叫法。在 Linux 中,如果你查看 `/dev/` 目录,会看到一堆 TTY 设备。 从历史上看,这些 TTY 设备对应的是连接到计算机的物理终端。每台连接到计算机的物理终端在 `/dev/` 下都有一个对应的 TTY 设备,操作系统通过这些 TTY 设备与物理终端进行通信。 --- ## terminal emulators These days, most of us aren't using physical terminals. We're using terminal emulators – programs like iTerm2, GNOME Terminal, Terminal.app, etc. A terminal emulator emulates a physical terminal in software. Terminal emulators need to: 1. Emulate some physical terminal (usually VT100 or a variant of it), including ANSI escape codes. For example, if your shell sends the escape code `\x1b[34m`, that means "change the text colour to blue" and your terminal emulator needs to render the following text as blue. 2. Show the terminal on your screen 3. Accept keyboard/mouse input and send it to the shell Now, how does the terminal emulator communicate with the shell? ## 终端模拟器 如今,我们大多数人都不再使用物理终端,而是使用终端模拟器——如 iTerm2、GNOME Terminal、Terminal.app 等程序。 终端模拟器在软件层面模拟物理终端。终端模拟器需要: 1. 模拟某种物理终端(通常是 VT100 或其变体),包括 ANSI 转义码。例如,如果你的 Shell 发送转义码 `\x1b[34m`,这意味着"将文本颜色改为蓝色",你的终端模拟器需要将随后的文本以蓝色渲染。 2. 在屏幕上显示终端界面 3. 接收键盘/鼠标输入并将其发送给 Shell 那么,终端模拟器是如何与 Shell 通信的呢? --- ## pseudoterminals The way terminal emulators communicate with shells is through a **pseudoterminal** (also called a pty). A pseudoterminal is a fake terminal: it's an API that looks to programs like a terminal but is just two file descriptors in a Linux program: a "master" (or "controller") side and a "secondary" (or "replica") side. The way pseudoterminals work is: 1. The terminal emulator opens the master side of a pseudoterminal 2. The shell (and its child processes, like `ls`) gets the secondary side of the pseudoterminal as its stdin/stdout/stderr 3. The terminal emulator reads characters from the master side (which is the shell's output) and displays them on the screen 4. The terminal emulator also writes characters to the master side (which is the keyboard input) and the shell reads them from the secondary side Here's a diagram: ``` keyboard --> terminal emulator --> PTY master --> PTY secondary --> shell screen <-- terminal emulator <-- PTY master <-- PTY secondary <-- shell ``` The PTY (pseudoterminal) is managed by the operating system – the terminal emulator writes to the master side and the OS is responsible for making the data available on the secondary side. ## 伪终端 终端模拟器与 Shell 通信的方式是通过**伪终端**(也称为 pty)。 伪终端是一种虚拟终端:它是一个 API,对程序来说看起来像一个终端,但在 Linux 程序中实际上只是两个文件描述符:一个"主"(master,也称 controller)端和一个"从"(secondary,也称 replica)端。 伪终端的工作方式如下: 1. 终端模拟器打开伪终端的主端 2. Shell(及其子进程,如 `ls`)将伪终端的从端作为其 stdin/stdout/stderr 3. 终端模拟器从主端读取字符(即 Shell 的输出)并显示在屏幕上 4. 终端模拟器也向主端写入字符(即键盘输入),Shell 从从端读取这些字符 下面是一个示意图: ``` 键盘输入 --> 终端模拟器 --> PTY 主端 --> PTY 从端 --> Shell 屏幕显示 <-- 终端模拟器 <-- PTY 主端 <-- PTY 从端 <-- Shell ``` PTY(伪终端)由操作系统管理——终端模拟器向主端写入数据,操作系统负责将数据在从端提供给 Shell。 --- ## what is the TTY driver? But there's something else in the middle: the TTY driver. The TTY driver sits between the PTY master and secondary. ``` keyboard --> terminal emulator --> PTY master --> TTY driver --> PTY secondary --> shell screen <-- terminal emulator <-- PTY master <-- TTY driver <-- PTY secondary <-- shell ``` The TTY driver does a few things: 1. Line editing. The TTY driver keeps a buffer of the current line. When you press backspace, the TTY driver removes the last character from the buffer, and the character gets deleted from the terminal screen. When you press enter, the TTY driver sends the buffer to the shell. This is called "cooked mode" and is the default mode for terminals. 2. Echo. The TTY driver echoes characters back to the terminal emulator, so that the characters you type are displayed on the screen. 3. Handling special characters like Ctrl+C, Ctrl+Z, and Ctrl+D: when you press Ctrl+C, the TTY driver sends a SIGINT signal to the foreground process group, which usually causes the process to exit. The reason for the TTY driver is historical: back in the day, the TTY driver was a kernel module that handled communication with physical terminals. The pseudoterminal was designed to fit into the same framework. So the OS has a TTY driver even when there's no physical terminal. ## 什么是 TTY 驱动? 但中间还有另一个组件:TTY 驱动。TTY 驱动位于 PTY 主端和从端之间。 ``` 键盘输入 --> 终端模拟器 --> PTY 主端 --> TTY 驱动 --> PTY 从端 --> Shell 屏幕显示 <-- 终端模拟器 <-- PTY 主端 <-- TTY 驱动 <-- PTY 从端 <-- Shell ``` TTY 驱动负责以下几件事: 1. **行编辑**。TTY 驱动维护当前行的缓冲区。当你按下退格键时,TTY 驱动从缓冲区中删除最后一个字符,该字符也会从终端屏幕上消失。当你按下回车键时,TTY 驱动将缓冲区内容发送给 Shell。这称为"熟模式"(cooked mode),是终端的默认模式。 2. **回显**。TTY 驱动将字符回显给终端模拟器,使你输入的字符显示在屏幕上。 3. **处理特殊字符**,如 Ctrl+C、Ctrl+Z 和 Ctrl+D:当你按下 Ctrl+C 时,TTY 驱动向前台进程组发送 SIGINT 信号,通常会导致进程退出。 TTY 驱动存在的原因是历史性的:早年间,TTY 驱动是一个内核模块,负责处理与物理终端的通信。伪终端的设计是为了融入同一套框架。因此,即使没有物理终端,操作系统中也有 TTY 驱动。 --- ## raw mode and cooked mode As I mentioned above, in "cooked mode", the TTY driver does line editing. Programs can put the TTY into "raw mode" which disables line editing. Most interactive programs (like `vim`, `python`, `bash`) put the terminal into raw mode and handle all the keyboard input themselves, because they need to do their own line editing (e.g. handling arrow keys for history in bash) and they don't want the TTY driver to intercept special characters like Ctrl+C. For example, when you run vim, vim puts the terminal into raw mode. If you press Ctrl+C in vim, vim will handle the Ctrl+C itself (by cancelling the current operation), instead of the TTY driver sending a SIGINT signal to vim and killing it. ## 原始模式与熟模式 如前所述,在"熟模式"(cooked mode)下,TTY 驱动负责行编辑。程序可以将 TTY 设置为"原始模式"(raw mode),这会禁用行编辑。 大多数交互式程序(如 `vim`、`python`、`bash`)会将终端切换到原始模式,自行处理所有键盘输入,因为它们需要实现自己的行编辑功能(例如 bash 中用方向键浏览历史记录),并且不希望 TTY 驱动拦截 Ctrl+C 等特殊字符。 例如,当你运行 vim 时,vim 会将终端设置为原始模式。如果你在 vim 中按下 Ctrl+C,vim 会自行处理这个 Ctrl+C(取消当前操作),而不是让 TTY 驱动向 vim 发送 SIGINT 信号并将其终止。 --- ## ANSI escape codes Earlier I mentioned ANSI escape codes. These are sequences of characters that terminals interpret as commands. For example: - `\x1b[34m` means "change the text colour to blue" - `\x1b[0m` means "reset the text colour to the default" - `\x1b[1m` means "bold text" - `\x1b[2J` means "clear the screen" The `\x1b` is the escape character (ASCII code 27). The `[` character is the "Control Sequence Introducer". The rest of the sequence is the command. When a shell script uses `echo -e "\x1b[34mhello\x1b[0m"`, it's telling the terminal emulator to display "hello" in blue. ## ANSI 转义码 前面我提到了 ANSI 转义码。这些是终端将其解释为命令的字符序列。例如: - `\x1b[34m` 表示"将文本颜色改为蓝色" - `\x1b[0m` 表示"将文本颜色重置为默认值" - `\x1b[1m` 表示"粗体文本" - `\x1b[2J` 表示"清屏" `\x1b` 是转义字符(ASCII 码 27)。`[` 字符是"控制序列引导符"(Control Sequence Introducer)。序列的其余部分是具体命令。 当 Shell 脚本使用 `echo -e "\x1b[34mhello\x1b[0m"` 时,它是在告诉终端模拟器以蓝色显示"hello"。 --- ## what's a shell? I've been talking about shells throughout, but I haven't defined what a shell is. A shell is a program that: 1. Reads commands from the user (from a TTY or from a script file) 2. Runs those commands 3. Shows the output to the user Examples of shells are bash, zsh, fish, and sh. How does a shell run a command? The shell uses the `fork` and `exec` system calls. `fork` creates a copy of the shell process, and `exec` replaces the copy with the new program. The shell then waits for the program to finish. The shell also sets up the child process's stdin/stdout/stderr, and handles job control (foreground/background processes). ## 什么是 Shell? 我在整篇文章中一直在提 Shell,但还没有给出定义。Shell 是一个程序,它: 1. 从用户处读取命令(来自 TTY 或脚本文件) 2. 执行这些命令 3. 将输出显示给用户 Shell 的例子有 bash、zsh、fish 和 sh。 Shell 是如何运行命令的?Shell 使用 `fork` 和 `exec` 系统调用。`fork` 创建 Shell 进程的一个副本,`exec` 将该副本替换为新程序。然后 Shell 等待程序执行完毕。 Shell 还负责设置子进程的 stdin/stdout/stderr,以及处理任务控制(前台/后台进程)。 --- ## what happens when you run `ls`? Let's walk through what happens when you type `ls` in a terminal emulator. 1. You type `l` on the keyboard 2. The terminal emulator receives the `l` character and sends it to the PTY master 3. The TTY driver receives the `l` character and (in cooked mode) echoes it back to the PTY master 4. The terminal emulator receives the echoed `l` character and displays it on the screen 5. Same for `s` and then Enter 6. When Enter is pressed, the TTY driver sends the buffer `ls\n` to the PTY secondary 7. The shell reads `ls\n` from the PTY secondary 8. The shell forks and execs `ls` 9. `ls` writes its output to its stdout (which is the PTY secondary) 10. The TTY driver passes the output through to the PTY master 11. The terminal emulator reads the output from the PTY master and displays it on the screen ## 运行 `ls` 时发生了什么? 让我们来梳理一下在终端模拟器中输入 `ls` 时发生的事情。 1. 你在键盘上按下 `l` 2. 终端模拟器接收到字符 `l`,并将其发送到 PTY 主端 3. TTY 驱动接收到字符 `l`,并(在熟模式下)将其回显给 PTY 主端 4. 终端模拟器接收到回显的字符 `l`,并将其显示在屏幕上 5. `s` 和回车键同理 6. 当按下回车键时,TTY 驱动将缓冲区内容 `ls\n` 发送到 PTY 从端 7. Shell 从 PTY 从端读取 `ls\n` 8. Shell 执行 fork 和 exec 来运行 `ls` 9. `ls` 将其输出写入 stdout(即 PTY 从端) 10. TTY 驱动将输出传递到 PTY 主端 11. 终端模拟器从 PTY 主端读取输出,并将其显示在屏幕上 --- ## what about SSH? SSH is interesting because it involves two computers. Here's what happens when you run `ssh user@server` and then run `ls`: 1. You type `ls` on the keyboard 2. Your terminal emulator sends `ls` to the PTY master on your local computer 3. The TTY driver echoes the characters back 4. The terminal emulator displays the echoed characters on the screen 5. The **SSH client** reads the characters from the PTY master and sends them over the network to the SSH server 6. The SSH server receives the characters and writes them to the PTY secondary on the remote computer 7. The shell running on the remote computer reads the characters from the PTY secondary 8. The shell runs `ls` and writes the output to the PTY secondary on the remote computer 9. The SSH server reads the output from the PTY master on the remote computer and sends it over the network to the SSH client 10. The SSH client writes the output to the PTY secondary on the local computer 11. The terminal emulator on the local computer reads the output from the PTY master and displays it on the screen Wait, that doesn't make sense. Let me reconsider. The SSH client is running on the local computer and the SSH server is running on the remote computer. The SSH client replaces the shell in the local PTY. ## SSH 呢? SSH 很有趣,因为它涉及两台计算机。下面是当你运行 `ssh user@server` 后再运行 `ls` 时发生的事情: 1. 你在键盘上输入 `ls` 2. 你的终端模拟器将 `ls` 发送到本地计算机的 PTY 主端 3. TTY 驱动将字符回显 4. 终端模拟器将回显的字符显示在屏幕上 5. **SSH 客户端**从 PTY 主端读取字符,并通过网络将其发送到 SSH 服务器 6. SSH 服务器接收字符,并将其写入远程计算机的 PTY 从端 7. 在远程计算机上运行的 Shell 从 PTY 从端读取字符 8. Shell 运行 `ls`,并将输出写入远程计算机的 PTY 从端 9. SSH 服务器从远程计算机的 PTY 主端读取输出,并通过网络发送给 SSH 客户端 10. SSH 客户端将输出写入本地计算机的 PTY 从端 11. 本地计算机上的终端模拟器从 PTY 主端读取输出,并将其显示在屏幕上 等等,这说不通。让我重新理一理。SSH 客户端运行在本地计算机上,SSH 服务器运行在远程计算机上。SSH 客户端在本地 PTY 中取代了 Shell 的角色。 --- Here's a better diagram for what happens with SSH: **Local computer:** ``` keyboard --> terminal emulator --> PTY master --> TTY driver --> PTY secondary --> ssh client screen <-- terminal emulator <-- PTY master <-- TTY driver <-- PTY secondary <-- ssh client ``` **Remote computer:** ``` (network) --> ssh server --> PTY master --> TTY driver --> PTY secondary --> shell (network) <-- ssh server <-- PTY master <-- TTY driver <-- PTY secondary <-- shell ``` So the SSH client is the "shell" from the perspective of the local terminal emulator, and the SSH server is the "terminal emulator" from the perspective of the remote shell. The SSH client and server communicate over the network using the SSH protocol. One interesting thing about SSH is that both the local and remote computers have a PTY. The local PTY handles things like Ctrl+C on the local side (in addition to the remote PTY handling Ctrl+C on the remote side). 下面是 SSH 场景下更清晰的示意图: **本地计算机:** ``` 键盘输入 --> 终端模拟器 --> PTY 主端 --> TTY 驱动 --> PTY 从端 --> ssh 客户端 屏幕显示 <-- 终端模拟器 <-- PTY 主端 <-- TTY 驱动 <-- PTY 从端 <-- ssh 客户端 ``` **远程计算机:** ``` (网络) --> ssh 服务器 --> PTY 主端 --> TTY 驱动 --> PTY 从端 --> Shell (网络) <-- ssh 服务器 <-- PTY 主端 <-- TTY 驱动 <-- PTY 从端 <-- Shell ``` 因此,从本地终端模拟器的角度来看,SSH 客户端扮演的是"Shell"的角色;而从远程 Shell 的角度来看,SSH 服务器扮演的是"终端模拟器"的角色。SSH 客户端和服务器通过 SSH 协议在网络上进行通信。 SSH 有一个有趣的地方:本地和远程计算机各自都有一个 PTY。本地 PTY 在本地端处理 Ctrl+C 等操作(同时远程 PTY 在远程端也处理 Ctrl+C)。 --- ## what about `tmux`? `tmux` is a terminal multiplexer: it lets you have multiple terminal sessions in a single window. It's also interesting from a PTY perspective. When you run `tmux`, `tmux` creates a new PTY for each window/pane and runs a shell in each PTY. `tmux` itself is the "terminal emulator" for those shells, but `tmux` is also running inside a PTY (the one created by your actual terminal emulator). So if you're running `tmux` inside iTerm, the chain is: ``` iTerm --> PTY --> tmux --> PTY --> shell ``` `tmux` acts as both a terminal emulator (for the shells it manages) and as a program (from iTerm's perspective). This is why `tmux` can keep running even if you close iTerm – the `tmux` process and its shells are attached to PTYs that don't depend on iTerm. When you reconnect to `tmux`, it re-attaches to the existing PTYs. ## `tmux` 呢? `tmux` 是一个终端复用器:它允许你在单个窗口中拥有多个终端会话。从 PTY 的角度来看,它也很有趣。 当你运行 `tmux` 时,`tmux` 为每个窗口/面板创建一个新的 PTY,并在每个 PTY 中运行一个 Shell。`tmux` 本身充当这些 Shell 的"终端模拟器",但 `tmux` 自身也运行在一个 PTY 中(由你实际使用的终端模拟器创建的那个)。 因此,如果你在 iTerm 中运行 `tmux`,整个链路是: ``` iTerm --> PTY --> tmux --> PTY --> shell ``` `tmux` 既充当终端模拟器(对其管理的 Shell 而言),又作为一个普通程序(从 iTerm 的角度而言)。 这就是为什么即使你关闭 iTerm,`tmux` 仍然可以继续运行——`tmux` 进程及其 Shell 附加在不依赖 iTerm 的 PTY 上。当你重新连接到 `tmux` 时,它会重新附加到已有的 PTY 上。 --- ## summary Here's a summary of what we've covered: - **Physical terminals**: the historical predecessors to terminal emulators. Teletypes that sent characters to the computer and received characters back. - **TTY**: short for teletypewriter. In Linux, TTY devices are in `/dev/`. Historically they corresponded to physical terminals. - **Terminal emulators**: programs that emulate physical terminals in software (iTerm2, GNOME Terminal, etc.) - **Pseudoterminals (PTYs)**: fake terminals – an API that looks like a terminal to programs. Has a master side (used by the terminal emulator) and a secondary side (used by the shell). - **TTY driver**: sits between PTY master and secondary. Handles line editing (cooked mode), echo, and special characters (Ctrl+C, etc.) - **Raw mode vs cooked mode**: in cooked mode, the TTY driver does line editing. In raw mode, the program handles all input itself. - **ANSI escape codes**: sequences of characters that terminals interpret as commands (e.g. change text colour, clear screen). - **Shell**: a program that reads commands, runs them, and shows output. Uses `fork` and `exec` system calls. Examples: bash, zsh, fish. ## 总结 以下是我们所涵盖内容的总结: - **物理终端**:终端模拟器的历史前身。向计算机发送字符并接收字符的电传打字机。 - **TTY**:teletypewriter 的缩写。在 Linux 中,TTY 设备位于 `/dev/` 下。历史上对应物理终端。 - **终端模拟器**:在软件层面模拟物理终端的程序(iTerm2、GNOME Terminal 等)。 - **伪终端(PTY)**:虚拟终端——一种对程序来说看起来像终端的 API。有主端(由终端模拟器使用)和从端(由 Shell 使用)。 - **TTY 驱动**:位于 PTY 主端和从端之间。处理行编辑(熟模式)、回显和特殊字符(Ctrl+C 等)。 - **原始模式与熟模式**:熟模式下,TTY 驱动负责行编辑;原始模式下,程序自行处理所有输入。 - **ANSI 转义码**:终端将其解释为命令的字符序列(例如更改文本颜色、清屏)。 - **Shell**:读取命令、执行命令并显示输出的程序。使用 `fork` 和 `exec` 系统调用。例如:bash、zsh、fish。

Lobsters Hottest

# 终端背后的秘密:终端模拟器、TTY 与 Shell 作为开发者,我们每天都在使用终端。但你有没有想过,当你打开一个"终端窗口"时,究竟发生了什么?你输入的字符经过怎样的路径,最终变成命令的输出? 大多数人将整个体验笼统地称为"终端",但实际上,这背后存在三个截然不同的层次,它们各司其职、协同工作。理解这三个层次,不仅能帮助你排查奇怪的问题,更能让你对自己每天使用的工具有更深刻的认识。 ## 三个层次概览 在深入细节之前,先来认识这三位主角: 1. **终端模拟器(Terminal Emulator)**:你在屏幕上看到的那个窗口 2. **TTY / 伪终端(Pseudo-terminal)**:操作系统内核中的一个抽象层 3. **Shell**:真正解释并执行你命令的程序 它们的关系可以这样理解: ``` 你的键盘输入 ↓ 终端模拟器(GUI 窗口) ↓ 伪终端(内核层,PTY) ↓ Shell(bash / zsh / fish …) ↓ 命令输出原路返回 ``` --- ## 第一层:终端模拟器 ### 它是什么 终端模拟器是一个**图形界面程序**。常见的有 iTerm2、GNOME Terminal、Alacritty、Windows Terminal、Kitty 等。它的核心职责是: - 渲染文字到屏幕上 - 捕获你的键盘输入 - **模拟**老式硬件终端(如 VT100、xterm)的行为 "模拟器"这个词至关重要。在个人电脑普及之前,终端是一台真实的硬件设备——一台带显示器和键盘的哑终端,通过串口连接到大型主机。现代的终端模拟器用软件重现了那台硬件的行为。 ### 它做什么 当你按下键盘上的一个键,终端模拟器会将这个按键**转换成字节序列**,然后写入伪终端。例如,方向键 `↑` 通常被转换为转义序列 `\x1b[A`。 反过来,当程序向终端写入转义序列时,终端模拟器负责**解释**这些序列,并作出相应动作,例如: ``` \x1b[31m → 将后续文字渲染为红色 \x1b[2J → 清空整个屏幕 \x1b[1A → 光标上移一行 ``` 这就是为什么同一个程序(比如 `vim`)在不同的终端模拟器里,行为可能略有差异——不同的模拟器对转义序列的支持程度不同。 ### 一个常见的误解 很多人以为终端模拟器"运行"了 Shell。实际上,终端模拟器只是**启动**了 Shell,并为它提供一个可以读写的伪终端接口。之后,终端模拟器和 Shell 是两个独立运行的进程,通过内核中的伪终端相互通信。 --- ## 第二层:TTY 与伪终端(PTY) 这是三层中最容易被忽视、也最难理解的一层,但它是整个系统的**核心枢纽**。 ### TTY 的历史渊源 TTY 是 **Teletype**(电传打字机)的缩写。在计算机的早期历史中,人们用电传打字机与计算机交互——输入字符,打印机打印出响应。这套设备通过串口连接到计算机。 Unix 操作系统从一开始就将这些串口设备抽象为文件,放在 `/dev/tty*` 路径下。程序只需要读写这些文件,就能与用户交互,而不需要关心底层是什么硬件。 ### 伪终端(PTY)的出现 当我们转向图形界面,不再有真实的硬件串口时,Unix 需要一种方式来保持这套抽象,同时支持终端模拟器这样的软件。于是**伪终端(Pseudo-Terminal,PTY)**诞生了。 PTY 是内核提供的一对相互连接的虚拟设备: - **主端(master side)**:终端模拟器持有这一端 - **从端(slave side)**:Shell 及其子进程持有这一端,对应 `/dev/pts/0`、`/dev/pts/1` 这样的设备文件 你可以把它想象成一条**双向管道**,但这条管道远比普通管道聪明——它内置了一个叫做**行规程(line discipline)**的模块。 ### 行规程:被遗忘的功能 行规程是 TTY 层中最精妙的设计之一。它在内核中处理大量"低级"的终端行为,让每个 Shell 和应用程序不必自己重新实现这些功能: | 功能 | 说明 | |------|------| | **回显(Echo)** | 将你输入的字符显示在屏幕上 | | **行编辑** | `Backspace` 删除字符,`Ctrl+U` 清除整行 | | **信号生成** | `Ctrl+C` 发送 `SIGINT`,`Ctrl+Z` 发送 `SIGTSTP` | | **规范模式** | 缓冲整行输入,直到你按下回车 | 这解释了一个有趣的现象:即使在 Shell 还没启动、或 Shell 崩溃的情况下,`Backspace` 键依然"有效"——因为删除字符这个操作是由**内核**在 TTY 层处理的,而不是由 Shell 处理的。 ### 用命令亲眼验证 在终端里输入: ```bash tty ``` 你会看到类似这样的输出: ``` /dev/pts/3 ``` 这就是当前 Shell 正在使用的伪终端从端设备文件。你可以用 `ls -la /dev/pts/` 查看系统中所有活跃的伪终端。 更有趣的是,你可以直接向另一个终端窗口写入文字: ```bash # 在终端 A 中运行 tty,假设输出是 /dev/pts/3 # 然后在终端 B 中运行: echo "你好,终端 A" > /dev/pts/3 ``` 终端 A 的屏幕上会直接出现这段文字——这直观地展示了 TTY 设备文件的本质。 --- ## 第三层:Shell Shell 是你**最熟悉**却往往被与终端混为一谈的那一层。 ### Shell 是一个普通进程 Shell(bash、zsh、fish 等)本质上是一个**普通的用户空间进程**。它的特别之处在于: - 它将 `/dev/pts/N` 作为自己的**标准输入(stdin)**、**标准输出(stdout)**和**标准错误(stderr)** - 它读取你输入的文本,解析为命令,然后通过 `fork()` + `exec()` 创建子进程来执行这些命令 - 子进程同样继承了对 TTY 设备的连接 ### 原始模式 vs 规范模式 Shell 在启动后,通常会让 TTY 层工作在**规范模式(canonical mode)**下。此时行规程会帮 Shell 缓冲输入、处理退格键等,Shell 直接读取一整行已处理好的输入。 但当你运行 `vim` 或其他全屏程序时,情况就不同了。`vim` 会将 TTY 切换到**原始模式(raw mode)**: - 行规程的大部分处理被**绕过** - 每个按键立即传递给应用程序 - 应用程序自行决定如何处理每一个字节 这就是为什么在 `vim` 里,`Backspace` 的行为可以被完全自定义,而在普通 Shell 提示符下,`Backspace` 的行为是由内核保证的。 ### Shell 不是终端 这个区别在实践中非常重要。考虑以下场景: ```bash # 这会失败,因为 ssh 命令没有分配 TTY ssh user@host vim /etc/hosts # 这会成功,-t 参数强制分配一个伪终端 ssh -t user@host vim /etc/hosts ``` `vim` 需要一个真实的 TTY 才能工作(它需要将终端切换到原始模式)。当 `ssh` 不分配 TTY 时,远端的 `vim` 无法正常运行——因为它的标准输入只是一个普通管道,而不是一个 TTY 设备。 --- ## 三层如何协同工作:一次完整的按键之旅 让我们追踪一次按键——假设你在 Shell 提示符下输入字母 `l`,准备输入 `ls` 命令: ``` 1. 你按下键盘上的 "l" 键 2. 操作系统检测到按键事件 3. 终端模拟器收到按键事件, 将其编码为字节 0x6C(ASCII 'l'), 写入 PTY 主端 4. 内核 TTY 层(行规程)收到这个字节: - 将字节追加到输入缓冲区 - 因为开启了回显,将 'l' 写回 PTY 主端 5. 终端模拟器从 PTY 主端读取到回显的 'l', 将其渲染到屏幕上 (这就是你"看到"自己输入的原因) 6. Shell 此时还没有收到任何东西—— 它在等待一个完整的行(规范模式) 7. 你继续输入 "s",然后按下回车 8. 行规程收到回车,将完整的行 "ls\n" 送入 Shell 可读取的队列 9. Shell 的 read() 调用返回,得到 "ls\n" 10. Shell 解析命令,fork() 出子进程, exec() 执行 /bin/ls 11. ls 将输出写入其标准输出(同一个 PTY 从端) 12. 内核 TTY 层将输出传递到 PTY 主端 13. 终端模拟器读取输出,渲染到屏幕上 ``` 整个过程在毫秒之内完成,但涉及了用户空间和内核空间之间多次切换,以及三个独立组件的协作。 --- ## 为什么这些知识对开发者有用 理解这三个层次,能帮助你解释和解决很多实际问题: **1. 为什么有些程序检测到自己的输出被重定向后,行为会改变?** ```bash ls --color=auto # 输出彩色 ls --color=auto | cat # 输出变成黑白 ``` `ls` 通过检查标准输出是否是 TTY(使用 `isatty()` 系统调用)来决定是否输出颜色代码。管道不是 TTY,所以颜色被关闭。 **2. 为什么 `sudo` 有时会提示输入密码失败?** `sudo` 需要从 TTY 读取密码。如果你在一个没有 TTY 的环境中运行 `sudo`(例如某些 CI 环境),它就无法工作。 **3. 为什么 `screen` 和 `tmux` 能"保持"会话?** `screen` 和 `tmux` 本身就是终端模拟器(运行在终端里的终端模拟器)。它们创建自己的 PTY,Shell 连接到这个 PTY。当你断开 SSH 连接时,真正的终端模拟器消失了,但 `tmux` 创建的 PTY 和连接到它的 Shell 仍然存在于服务器上。 **4. 理解 `stty` 命令** `stty` 命令直接操作 TTY 层的设置: ```bash stty -echo # 关闭回显(输入密码时脚本里常用) stty echo # 重新开启回显 stty -a # 查看当前 TTY 的所有设置 ``` --- ## 总结 | 层次 | 代表 | 职责 | |------|------|------| | 终端模拟器 | iTerm2, GNOME Terminal, Alacritty | 图形渲染、转义序列解释、按键捕获 | | TTY / PTY | `/dev/pts/N`(内核模块) | 数据路由、行规程、信号生成 | | Shell | bash, zsh, fish | 命令解析、进程管理、脚本执行 | 这三层各自解决了不同的问题,通过清晰的接口相互协作。Unix 的设计哲学在这里体现得淋漓尽致:每个组件做好一件事,通过标准化的接口(文件描述符、设备文件)组合在一起,形成一个灵活而强大的整体。 下次当你打开终端窗口,看到那个闪烁的光标时,你知道自己看到的不只是一个"终端"——而是三个精心设计的软件层,在内核与用户空间之间默默协作的成果。

终端文本渲染的各种缺陷(2024)

Lobsters Hottest

本文探讨了终端模拟器中文本渲染的各种根本性问题,包括字符定义歧义、Unicode处理问题、有缺陷的二维网格假设以及光标不同步,凸显了支持复杂脚本和字体的困难。

花括号:Unix和C语言的演变

Hacker News Top

详细探讨了早期Unix系统在Teletype Model 33上如何输入花括号,涵盖了ASCII 1963、三字符组、双字符组以及终端驱动程序转换。

tterm

Product Hunt

tterm 是一款集终端、真实浏览器和 Claude Code 于单一界面的产品,提供集成开发环境。