终端、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 的设计哲学在这里体现得淋漓尽致:每个组件做好一件事,通过标准化的接口(文件描述符、设备文件)组合在一起,形成一个灵活而强大的整体。 下次当你打开终端窗口,看到那个闪烁的光标时,你知道自己看到的不只是一个"终端"——而是三个精心设计的软件层,在内核与用户空间之间默默协作的成果。

<p><a href="https://lobste.rs/s/eriaol/terminal_tty_shell">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/05 02:16

# 终端、TTY 与 Shell 来源:https://lfg.popovicu.com/series/the-shell-as-a-language/terminal-tty-and-shell/ 人们常说"我在终端里工作"或者"进入控制台"之类的话。这些说法是很方便的简称,几乎所有使用 Linux 的人在实践中都知道它们的意思。但如果你停下来问:究竟是哪个软件?答案并不简单。你所感知的"终端"不是一个程序。"控制台"在不同语境下可能指三四种不同的东西。随意的说法没什么问题,但它背后精确的图景值得了解,因为本章后续的所有内容都假设你能指出正确的层次。 本章第一篇文章几乎不涉及 shell 脚本。我们不会在这里介绍管道、重定向、`PATH`、变量或作业控制。重点是给各个部分命名。一旦厘清这些,本章后续内容便有了立足的基础。 先说清楚一点:尽管本系列讲的是 Linux,但下面的图景并非 Linux 专属。macOS、FreeBSD 以及任何其他类 Unix 系统上都存在相同的三层结构。我们会在结尾回到这个话题。 ## 目录 打开目录 - [当你"打开终端"时实际运行的是什么](https://lfg.popovicu.com/series/the-shell-as-a-language/terminal-tty-and-shell/#what-is-actually-running-when-you-open-a-terminal) - [终端模拟器](https://lfg.popovicu.com/series/the-shell-as-a-language/terminal-tty-and-shell/#the-terminal-emulator) - [TTY](https://lfg.popovicu.com/series/the-shell-as-a-language/terminal-tty-and-shell/#the-tty) - [Shell](https://lfg.popovicu.com/series/the-shell-as-a-language/terminal-tty-and-shell/#the-shell) - [谁负责什么](https://lfg.popovicu.com/series/the-shell-as-a-language/terminal-tty-and-shell/#who-owns-what) - [从进程内部观察边界](https://lfg.popovicu.com/series/the-shell-as-a-language/terminal-tty-and-shell/#seeing-the-boundary-from-inside-a-process) - [跨越边界进行协议通信](https://lfg.popovicu.com/series/the-shell-as-a-language/terminal-tty-and-shell/#speaking-the-protocol-across-the-boundary) - [本文小结](https://lfg.popovicu.com/series/the-shell-as-a-language/terminal-tty-and-shell/#where-this-leaves-us) ## 当你"打开终端"时实际运行的是什么 当你点击终端图标时,会弹出一个窗口,显示类似 `uros@laptop:~$` 的提示符。你输入 `ls`,输出就打印出来。接着你输入 `cd projects`,提示符随之改变。感觉像是一个统一的程序。然而,它并不是一个程序。 至少有三个独立的软件在协同工作,共同产生这种体验,内核就夹在其中两个之间。这三个部分是: 1. 一个绘制窗口并渲染字符的图形应用程序。这就是**终端模拟器**。 2. 一个充当模拟器与其内部运行程序之间管道的内核对象。这就是 **TTY**,在现代桌面环境中称为**伪终端**(pty)。 3. 一个读取你输入的命令、解析命令并运行其他程序的用户空间程序。这就是 **shell**。 这三者各自独立,由不同的主体负责,各有其契约。它们被无缝拼接在一起,以至于大多数人从未注意到这些接缝。本文的其余部分将逐一介绍每个部分,并以一个小程序作为结尾,让你能从运行中的进程内部看到这些边界。 ## 终端模拟器 GNOME Terminal、Konsole、Alacritty、kitty、xterm、foot……不胜枚举。在 macOS 上有 Terminal.app 和 iTerm2,在 Windows 上有 Windows Terminal。这些都属于同一类程序:绘制窗口、渲染字体、接受键盘和鼠标输入、在屏幕上绘制字符的图形应用程序。 终端模拟器不知道 `ls` 是什么意思。它实际上甚至不知道什么是文件。此外,它不解析命令,不知道连接到它所写入管道另一端的是什么程序。从模拟器的角度来看,它只是从一个文件描述符读取字节并将其渲染为字符,同时将你的键盘输入作为字节写入同一个文件描述符,方向相反。这就是它的全部工作。 名称中"模拟器"这个词是名副其实的。这些程序模拟的是一种 1970 年代的硬件——视频终端:像 DEC VT100 和 VT220 这样的物理机器,有屏幕、键盘,以及一条通往别处的串行线路。当另一端的程序想要移动光标或改变颜色时,它会发送特殊的字节序列(转义序列),硬件终端会对其进行解释。今天的终端模拟器仍然使用这些相同的转义序列,这也是为什么你的 `$TERM` 环境变量显示的是类似 `xterm-256color` 的值。硬件已经消失,但协议保留了下来。 还有一个重要的变体值得一提:在 Linux 上,你可以切换到**文本模式虚拟控制台**(通常通过 Ctrl+Alt+F2 到 F6)。那里没有图形应用程序,内核本身直接将字形绘制到帧缓冲区。在这种情况下,"终端模拟器"的角色由内核来扮演。本文的其余内容依然适用。 ## TTY 这一层将整个系统粘合在一起,也往往是令人困惑的部分。 终端模拟器(一个用户空间程序)和 shell(另一个用户空间程序)需要相互通信。它们不能共享内存:它们是独立的进程,拥有各自独立的地址空间。原则上可以使用普通管道或套接字,但历史上内核已经有了一个专为这类通信设计的、定义完善的抽象,而且它比 Linux 早了几十年。 `TTY` 是"电传打字机"(teletypewriter)的缩写。在 Unix 早期,终端是一种物理设备:一个键盘连接着打印机(后来是屏幕),通过串行线路与计算机相连。内核有一个该串行线路的驱动程序,设备以文件的形式出现在 `/dev` 下,比如 `/dev/tty0`。程序从中读取以获取用户输入,向其写入以显示输出。 当图形工作站出现时,物理电传打字机消失了,但这个抽象太有用了,不能丢弃。于是内核发展出了一种在软件中创建"虚拟"电传打字机的方式:**伪终端**,即 **pty**。 pty 是一个内核对象,暴露出两个端点。一端称为**主端**(PTY master),另一端称为**从端**(PTY slave)。写入主端的内容可以从从端读取,写入从端的内容可以从主端读取。两端都是文件描述符,因此使用与任何其他文件相同的 `read` 和 `write` 系统调用来操作。 当你打开一个图形终端模拟器时,按顺序会发生以下事情: 1. 模拟器向内核请求一个新的 pty。内核返回两个文件描述符:主端和从端。 2. 模拟器 fork 一个子进程。在该子进程中,从端被设置为 stdin、stdout 和 stderr。 3. 子进程随后执行一个 shell 程序(bash、zsh 或用户配置的任何 shell)。 从那时起,shell 从"stdin"读取命令,完全不知道 stdin 实际上是 pty 的从端。模拟器从主端读取并将得到的字节作为字符绘制到屏幕上。当你按下一个键时,模拟器将一个字节写入主端,shell 从从端读取它。两个用户空间程序,通过一个对双方来说看起来完全像文件的内核对象进行通信。 你可以从 shell 内部看到这一点。运行: ``` $ tty ``` 你会得到类似 `/dev/pts/3` 的结果。这就是你的 pty 的从端,以文件系统中的文件形式呈现。`pts` 代表"伪终端从端"(pseudo-terminal slave),数字只是一个计数器。你的 shell 将这个文件作为其 stdin、stdout 和 stderr 打开着。"一切皆文件"的原则在这里发挥了实质作用:从内核的角度来看,你正在盯着的终端是 `/dev` 下的一个特殊文件。 pty 不只是一个哑管道。内核在中间运行一个叫做**线路规程**的东西,它添加了普通管道所没有的行为:将字符回显,让你看到自己输入的内容;以行为单位缓冲输入,让 shell 在你按下 Enter 时收到完整的一行而非一次一个字符;以及从特殊按键生成信号(Ctrl+C 变成 `SIGINT`,Ctrl+Z 变成 `SIGTSTP`)。这一切都发生在内核内部,在两个端点之间。shell 和模拟器都不实现这些功能。我们将在本章后续内容中详细介绍线路规程,因为它值得单独讲述。 ## Shell 现在轮到 bash、zsh、fish、dash、ksh、ash、BusyBox 内置 shell 等等了。这些都是用户空间程序,在操作系统层面并没有什么特殊之处:从内核的角度来看,shell 只是一个普通进程,其 stdin 和 stdout 连接到它继承的文件描述符(在我们的例子中是 pty 从端)。 shell 的工作是从 stdin 读取字节,将这些字节视为一种编程语言,并据此执行操作。它解析命令,进行分词和通配符展开,在 `PATH` 中查找程序,等等。它调用 `fork` 和 `exec` 以子进程的形式运行这些程序。它在命令之间连接管道。它处理重定向。它解释控制流(`if`、`while`、`for`)。它管理后台作业。它维护变量和过去命令的历史记录。 这些都不在内核中,也不在终端模拟器中。全都只是 shell 进程中的代码。 shell 是一个家族中的一员,而非单一的权威实现。你最常遇到的名字有: - `bash` — GNU Bourne Again Shell。长期以来大多数 Linux 发行版事实上的交互式 shell,仍然是 Debian 衍生系统上 root 用户和许多其他用户的默认 shell。 - `zsh` — Z Shell。非常流行的交互式 shell,尤其是搭配 Oh My Zsh 等框架使用。自 Catalina 起成为 macOS 默认登录 shell。 - `fish` — 一个更友好的 shell,开箱即用支持自动建议和语法高亮。刻意设计为不兼容 POSIX。 - `dash` — 一个小巧、快速、严格遵循 POSIX 的 shell。在 Debian 和 Ubuntu 上,`/bin/sh` 实际上是 `dash`,而非 bash。用于运行启动时间要紧的 shell 脚本。 - `ash` 和 `busybox sh` — 用于嵌入式系统和精简容器(包括 Alpine Linux)的微型 shell。 所有这些都在做相同的内核级工作:从 pty 读取,解析一种语言,fork 子进程,向 pty 写入。它们的区别在于接受什么语言,以及为交互式使用提供什么功能。 不同的 shell 能够共存,甚至在脚本中相互替换,原因与不同的 C 库能在 Linux 上共存一样:存在一个标准。POSIX 定义了一种 shell 语言,通常称为"POSIX shell"或简称"sh"。大多数 shell 将该语言实现为一个子集,并在其上添加自己的扩展。这与我们在 C 系列中看到的 glibc 和 musl 的情况直接类比:一个标准化接口,多种实现,各有不同的特性集和权衡取舍。 由此产生的碎片化有一个小而真实的例子:以 `#!/bin/sh` 开头的 shell 脚本将在运行它的系统上 `/bin/sh` 指向的任何 shell 下运行。在 Ubuntu 上,那是 `dash`;在 Alpine 上,那是 BusyBox 的 `ash`;在某些其他系统上,那是 POSIX 模式下的 bash。在其中一个上能运行的脚本,如果不小心使用了 bash 特有的功能,可能在另一个上悄无声息地失败。这正是 Linus Torvalds 曾抱怨过的那种摩擦,也与其他系列中提到的更广泛的碎片化主题相关联。 ## 谁负责什么 退一步看看这些契约是有意义的。 **内核**负责 TTY 和 pty 机制。它定义了在这些文件描述符上 `read` 和 `write` 的行为、线路规程的工作方式、哪些按键产生哪些信号,以及 `/dev/pts` 下哪个文件对应你的终端。这个契约的稳定性与 Linux 的系统调用契约相同:十五年前针对 pty 接口编写的代码今天仍然有效。 **终端模拟器**负责渲染侧。它决定使用哪种字体、如何绘制字形、应用哪种颜色调色板,以及如何解释用于光标移动和样式设置的转义序列。不同的模拟器支持不同的转义序列集和不同的终端功能(真彩色、连字、图像协议、超链接)。`$TERM` 变量是 shell 及其下运行的程序知道应该使用哪种转义序列方言的方式。 **shell** 负责命令语言。它定义了 `ls -la | grep foo > out.txt` 的实际含义,`*.c` 展开为什么,`$HOME` 求值为什么,以及如何调用 `fork` 和 `exec` 来实现这一切。 在 shell 内部运行的程序,比如 `ls` 或 `grep`,不知道也不关心你使用的是哪个终端模拟器。它们对 pty 主端或线路规程一无所知。它们只向内核问一个问题:"我的 stdout 是终端吗?"这个问题由 `isatty` 函数来回答,工具用它来决定是否给输出着色、是否分页,或者在输出被管道传入另一个程序时是否以不同方式运行。 这些都不是 Linux 独有的。同样的图景适用于 macOS、FreeBSD、OpenBSD 以及任何其他类 Unix 系统。macOS 上的 Terminal.app 与 GNOME Terminal 是完全相同意义上的终端模拟器。macOS 有带主从两端的 pty。macOS 有线路规程。macOS 的默认 shell 是 zsh,之前的默认是 bash,也有 POSIX `sh` 可用。这个三层模型处于 POSIX 层面,而非 Linux 层面。如果你在 Mac 上阅读本文,上面的每一段话描述你的系统与描述 Linux 机器一样准确。 ## 从进程内部观察边界 让我们用一个小 C 程序来让这一切变得具体。它不打开任何东西,不 fork 任何东西,只向内核询问关于自己标准输入的两个问题:"这是终端吗?如果是,它叫什么名字?" ```c #include <stdio.h> #include <unistd.h> #include <errno.h> #include <string.h> int main(void) { if (isatty(STDIN_FILENO)) { char *name = ttyname(STDIN_FILENO); if (name == NULL) { perror("ttyname"); return 1; } printf("stdin is a terminal: %s\n", name); } else { printf("stdin is NOT a terminal (errno=%d: %s)\n", errno, strerror(errno)); } return 0; } ``` 编译并以交互方式运行: ``` $ gcc -o tty-probe tty-probe.c $ ./tty-probe stdin is a terminal: /dev/pts/3 ``` 现在用来自终端以外的地方的 stdin 运行同一个程序: ``` $ echo hello | ./tty-probe stdin is NOT a terminal (errno=25: Inappropriate ioctl for device) $ ./tty-probe < /etc/hostname stdin is NOT a terminal (errno=25: Inappropriate ioctl for device) ``` 同一个程序,同一个二进制文件,看到的世界因连接到其文件描述符 0 的内容而完全不同。在第一种情况下,stdin 是你的 shell 正在使用的 pty 的从端,`ttyname` 报告路径。在第二和第三种情况下,stdin 是管道或普通文件,`isatty` 返回 0。 这正是 `ls` 决定是否打印颜色所使用的机制:它询问"我的 stdout 是终端吗?"如果是,则显示颜色。如果不是(因为你将其输出管道传给了 `grep`),则不显示颜色。现在你知道它是如何工作的了。 这个程序无需修改即可在 macOS、FreeBSD 以及任何其他 POSIX 系统上编译和运行。该接口是 POSIX 标准的一部分,而非 Linux 扩展。 ## 跨越边界进行协议通信 `tty-probe` 程序向内核询问了一个关于文件描述符的问题。下一个程序写入字节,这些字节通过 pty 原封不动地传输,并被终端模拟器解释……

相似文章

终端全栈详解

Hacker News Top

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

TTY 解密 (2008)

Hacker News Top

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

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

Lobsters Hottest

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

tterm

Product Hunt

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

Herdr:一个终端,统御一切

Hacker News Top

Herdr 是一个终端复用器,能让开发者在单独的终端中运行多个编程代理,无论是在任何机器上还是通过SSH,并且一目了然地查看持久会话。