阶段一 · 概念辨析

历史视角与概念辨析

从电传打字机到终端模拟器——一段必须了解的历史,让你真正理解 terminal / tty / pty

Terminal 学习笔记 · 第 01 篇 · 大纲

为什么从历史讲起?

你可能好奇——我不过是想学命令行操作,为什么要听你讲电传打字机?

因为 terminal、tty、pty 这些词,不经历史讲不清楚。它们不是某个委员会精心命名的——它们是历史一层一层叠加上去的。每次技术演进留下了新的名字,旧名字也还在用。要理解"terminal 和 tty 到底什么关系?pty 又是什么?",你需要的不是定义,而是一个故事。

就像学一门语言的词源(etymology)——知道 tele(远程)+ type(打字)+ writer(机器),你就不需要死记"tty 是什么的缩写"。历史本身就是最好的解释。
1830s–1960s
电传打字机(Teletypewriter / TTY)
机电设备,通过电报线收发文字。后来直接用作计算机的输入输出设备——这就是"终端"的起源。
1970s
Unix TTY 子系统
操作系统内核统一抽象各种终端设备,TTY 从"物理机器"变成一个内核架构概念。
1978
DEC VT100
屏幕取代纸张,转义序列(escape sequences)控制光标和颜色。成为"终端"事实上的标准。
1980s–1990s
终端模拟器(Terminal Emulator)
GUI 时代到来,xterm 等程序用软件模拟 VT100 的行为。物理终端逐渐消失。
1980s–至今
伪终端(Pseudo Terminal / PTY)
内核提供虚拟的"电话线",让终端模拟器和 Shell 之间能像物理连接一样通信。

1. 电报时代的遗产:TTY 这个词从哪来

19 世纪 30 年代,电报(telegraph)已经普及,但需要训练有素的操作员敲莫尔斯电码。人们想要一种更直观的方式——像打字机一样输入,另一端自动打印出来。

这就是电传打字机(Teletypewriter,简称 TTY):你在键盘上敲 "HELLO",远方的另一台机器就自动在纸上打出 "HELLO"。它的本质是通过电线连接的远程打字机

想象两台打字机用电线连在一起。你在北京敲键盘,上海那台打字机就自动打出同样的字。这就是 TTY 的原始形态——一个字:"传"。

为什么这跟计算机有关?

1950–60 年代,计算机还是房屋大小的庞然大物。操作员需要一种方式输入命令查看输出。当时已经有了成熟的电传打字机技术——为什么不直接把计算机接在电线的另一端?

于是:

这就叫"计算机终端(Computer Terminal)"——Terminal 的本意是"端点",即人与计算机之间的交互端点

现代语言的遗迹

为什么几乎所有的编程语言都用 print 输出信息?因为当时的输出真的是印在纸上。C 语言的 printf、Python 的 print()、Java 的 System.out.println()——都是那个纸带滚滚的时代的遗存。

从此,"终端"在计算机世界里就有了明确的含义:一种输入输出设备,让你跟计算机对话。而 TTY 就是这个设备的简称——因为早期终端就是 teletypewriter。

2. 计算机终端的诞生:把计算机"接上"电传打字机

早期计算机使用批处理(Batch Processing)——你把一叠打孔卡交给操作员,几小时后取结果。这就像寄信:等回信的时间远长于写信的时间。

1960 年代,分时系统(Time-Sharing)出现了。多个用户同时连接同一台计算机,每个人独占一个"终端"。你在终端上打字,计算机会在几毫秒内响应——交互式计算诞生了。

Unix 操作系统(诞生于 1969 年)从一开始就为这种多用户、多终端的使用场景而设计。在 Unix 眼里,终端就是一个字符设备(Character Device)——一个接一个地发送和接收字符的设备文件。

Unix 内核把与终端交互的这部分代码称为 TTY 子系统。注意这个微妙的变化:TTY 从一个"物理机器"的名字,变成了一个"内核架构概念"的名字

3. 视频终端时代:VT100 的传奇

电传打字机有一个明显的缺点:慢,而且费纸。你敲一下键盘,机器要等打印头落到纸上。而且所有历史输出都在那一卷纸上——你没法"清屏",只能翻纸。

1978 年,DEC 公司推出了 VT100 视频终端:一个 CRT 显示器加一个键盘。屏幕取代了纸张。这是革命性的——你现在可以:

怎么做到的?——转义序列(Escape Sequences)

视频终端仍然通过串行线路接收字符流。但它约定:如果收到一个特殊的 ESC 字符(ASCII 码 27,八进制 \033),后面跟着的几个字符就不是文本而是命令。比如 ESC [ 2 J 表示"清屏",ESC [ 1 ; 1 H 表示"光标移到左上角"。这种命令序列就叫 ANSI 转义序列(ANSI Escape Codes)

VT100 成为了行业标准。几乎所有的终端模拟器(包括你今天用的 iTerm2)都声称自己"兼容 VT100"。你可以在终端里试试:

# 打印红色文字(ANSI 转义序列)
printf '\033[31mHello Red\033[0m\n'

终端如何区分哪部分是命令、哪部分是文本?

终端的解析状态机

  平时处于"文本模式":收到什么字符就显示什么
       │
       │ 读到 ESC (\033)
       ↓
  进入"指令模式":后续字符不显示,只做解析
       │
       ├── 读到 '['  →  这是 CSI 序列,继续读数字参数(分号分隔)
       │                      │
       │                 读到字母(A-Z, a-z)→ 终结符,执行命令,回到文本模式
       │
       ├── 读到 ']'  →  这是 OSC 序列(设置窗口标题等)
       │
       └── 读到其他 →  可能是单字符命令(如 ESC c = 重置终端),执行后回到文本模式

\033[31m 为例:ESC 触发切换 → [ 声明 CSI 序列 → 31 是参数(红色)→ m 是 SGR(Select Graphic Rendition)终结符,执行并回到文本模式。此后每个字符又正常显示了——所以 Hello Red 被终端用红色渲染,而不是被"吃掉"。

同样的规则:\033[0m 中参数 0 表示"重置所有属性",终结符还是 m。列出所有常用的 SGR 参数:

序列效果
\033[0m重置全部属性
\033[1m粗体 / 亮色
\033[4m下划线
\033[30m ~ \033[37m前景色:黑 · 红 · 绿 · 黄 · 蓝 · 紫 · 青 · 白
\033[40m ~ \033[47m背景色:黑 · 红 · 绿 · 黄 · 蓝 · 紫 · 青 · 白
\033[1;31m多个参数用 ; 组合:粗体 + 红色

你会看到红色的 "Hello Red"。这就是 VT100 时代的遗产——你每天都在用它,只是不知道而已。

4. Unix 的 TTY 子系统 —— 核心

到这里,我们知道:物理终端通过串口线连到计算机,逐个收发字符。但 Unix 内核怎么处理这些字符?TTY 子系统就是干这个的——它是内核中负责终端 I/O 处理的一整套代码。

4.1 TTY 驱动的三层结构

Unix 的 TTY 子系统可以理解为三层:

层级名称职责
上层TTY 核心(TTY Core)与用户态程序交互的系统调用接口(read/write/ioctl)
中层线路规程(Line Discipline)缓冲、回显、编辑、信号生成——TTY 子系统最核心的智能
下层TTY 驱动(TTY Driver)与具体硬件通信——串口驱动、USB 串口驱动等

用户态与内核态的数据流

  用户态程序(Shell、vim 等)
    ↕  read() / write()
  ┌─────────────────────────┐
  │  TTY 核心(设备文件接口)  │
  │  例如 /dev/ttyS0         │
  ├─────────────────────────┤
  │  线路规程(Line Discipline)│  ← 缓冲、回显、信号
  ├─────────────────────────┤
  │  TTY 驱动(硬件驱动)      │
  └─────────────────────────┘
    ↕  串口 / USB
  物理终端设备(或调制解调器)

4.2 Line Discipline:为什么你敲了键却看不到字?

线路规程(Line Discipline)是 TTY 子系统最关键的中间层。它做两件反直觉的事:

第一件:缓冲与回显。默认情况下,你在键盘上敲的字符并不会立即发给应用程序——它们被缓存在内核中。只有当你按下 Enter,整行才一次性发给应用程序。这叫规范模式(Canonical Mode)。同时,你敲的每个字符会被回显(echo)到屏幕上——这是内核帮你做的,不是 Shell!

想象一个秘书在帮你写信。你说一句,她记一句,但不寄出。直到你说"发吧",她才把整封信放进信封寄出去。同时她把你说的每个字念出来给你确认(回显)。Line Discipline 就是这个秘书——她不理解信的内容,只负责缓冲、回显、和投递。

第二件:信号生成。某些特殊按键不会被当成字符,而是变成信号(Signal)

按键生成的信号作用
Ctrl+CSIGINT中断(终止)当前前台进程
Ctrl+ZSIGTSTP挂起(暂停)当前前台进程
Ctrl+\SIGQUIT强制退出并生成 core dump
Ctrl+DEOF发送文件结束符(不是信号,但同样被 Line Discipline 拦截处理)

这就是为什么 Ctrl+C 能终止一个正在运行的程序——不是 Shell 在帮你杀进程,而是内核的 TTY 子系统直接把 SIGINT 信号发给了前台进程

Raw Mode(原始模式):程序可以选择关闭 Line Discipline 的缓冲和信号处理,让每个按键直接送达。Vim、tmux 等全屏程序就是这么做的——它们需要精确控制每一个按键和光标位置。

4.3 控制终端(Controlling Terminal)与作业

每个"会话(Session)"可以关联一个控制终端(Controlling Terminal)。这个关联意味着:

5. 终端模拟器(Terminal Emulator)

1980-90 年代,图形用户界面(GUI)普及了。物理终端逐渐消失——你不再需要一台专门的电传打字机或 VT100 显示器来接计算机。

但人仍然需要命令行。于是诞生了终端模拟器(Terminal Emulator):一个GUI 程序,用软件模拟 VT100(等物理终端)的行为

关键认知:终端模拟器只是一款普通的 GUI 应用,跟浏览器、文本编辑器一样。它做三件事:

  1. 接收键盘输入 → 发给 Shell
  2. 接收 Shell 的输出(含 ANSI 转义序列) → 解析后渲染到屏幕上
  3. 管理窗口(大小、滚动、复制粘贴等 GUI 功能)
如果 Shell 是发动机,终端模拟器就是仪表盘——它不驱动汽车,但让你能跟发动机交互。换个仪表盘(iTerm2 换 WezTerm),不影响你的 Shell(bash/zsh)继续工作。

你用的终端模拟器(macOS 上的选择):

名称特点
Terminal.appmacOS 自带,够用但功能有限
iTerm2功能强大,macOS 上最流行的第三方终端
AlacrittyGPU 加速,极快,Rust 编写
WezTermGPU 加速 + Lua 配置,跨平台
Warp现代化设计,AI 辅助,但闭源
kittyGPU 加速 + 图片显示,适合高级用户

但终端模拟器有一个问题:它只是一个 GUI 程序,不是硬件。物理终端通过 RS-232 串口线连到计算机,内核有一个 TTY 驱动来管理那条线。终端模拟器没有物理线缆——它怎么跟 Shell 通信?

这就是伪终端(PTY)要解决的问题。

6. 伪终端(Pseudo Terminal / PTY)—— 关键的一环

物理终端的通信链路是:

  物理终端 ←→ 串口线 ←→ 串口驱动 ←→ Line Discipline ←→ /dev/ttyS0 ←→ 用户程序

终端模拟器没有串口线。内核需要提供一条虚拟的线来替代它——这就是伪终端(Pseudo Terminal,简称 PTY)

物理终端用 RS-232 串口线通信。PTY 就是内核提供的"虚拟串口线"——两端都是软件,没有物理器件。但通信的方式和物理线路一模一样。

6.1 PTY 对(PTY Pair)

PTY 不是一个单独的设备,而是一对设备——就像一根管道的两端:

设备名谁在用作用
Master(主端)/dev/ptmx/dev/pts/0 的 master fd终端模拟器控制端:向 slave 写入输入,从 slave 读取输出
Slave(从端)/dev/pts/0, /dev/pts/1Shell 等用户程序被控端:对 Shell 来说,这就是它的"终端"

命名含义pts = Pseudo Terminal Slave,ptmx = Pseudo Terminal Master X(X 表示 multiplexer,多路复用器——一个 ptmx 可以产出多对 PTY)。

Master 不是设备文件,而是一个文件描述符(File Descriptor,fd)。你在 /dev/ 下看不到 master——它只存在于打开它的进程里,是一个整数编号。

什么是文件描述符?简而言之:进程用整数编号来指代自己打开的文件、设备、管道或 socket。打开一次,拿到编号,之后所有 I/O 都用这个编号——内核直接通过编号找到对应的内核对象,无需再查文件系统。fd 的三个经典编号是每个进程出生自带的:

fd名称默认指向
0stdin键盘输入
1stdout屏幕输出
2stderr屏幕输出(错误)

Master 和 Slave 的对应关系由内核管理。关键在于:/dev/ptmx 不是一个普通设备——它是一个工厂(factory)。每次 open("/dev/ptmx") 都不复用已有连接,而是触发内核创建一对全新的 master-slave:

  1. 分配一个新的 slave 设备(取下一个可用编号,比如 3)
  2. /dev/pts/ 下创建 /dev/pts/3
  3. 在内核 PTY 对表中记录:这个 master fd ↔ /dev/pts/3
  4. 返回一个整数 fd 给终端模拟器——这就是 master 端
/dev/ptmx 是餐厅前台。每个终端模拟器走进来说"开一桌",前台就分配一张新桌子(新 /dev/pts/N),并给顾客一个取餐牌(master fd)。顾客拿着取餐牌去座位上等着,厨师(Shell)在这张桌子上做菜。取餐牌和桌号一对一——3 号取餐牌的菜只送 3 号桌,不会送错。

终端模拟器如何让 Shell 用上 slave?—— fork + exec + dup2

这是整个流程中最精妙的一步。终端模拟器拿到 master fd 后,需要让 Shell 把对应的 slave 当作自己的 stdin/stdout/stderr。具体做法:

  1. fd_master = open("/dev/ptmx") — 创建 PTY 对,slave = /dev/pts/3
  2. fork() — 分出子进程(子进程继承父进程所有 fd,包括 master fd)
  3. 在子进程中:
    • fd_slave = open("/dev/pts/3") — 打开 slave 设备
    • dup2(fd_slave, 0) — 把 slave 映射为 stdin
    • dup2(fd_slave, 1) — 映射为 stdout
    • dup2(fd_slave, 2) — 映射为 stderr
    • 关闭不需要的 master fd
    • exec("/bin/bash") — 替换当前进程为 Shell
  4. 父进程(终端模拟器)保留 master fd,通过它和 Shell 通信

💡 dup2 是什么?

dup2(old_fd, new_fd) 是 Unix 系统调用,作用:让 new_fd 指向 old_fd 指向的同一个内核对象。比如 dup2(fd_slave, 1) 执行后,fd 1(stdout)不再指向原来的屏幕,而是指向 /dev/pts/3——此后 Shell 的 write(1, ...) 全部进入 slave,最终到达终端模拟器的 master 端。你可以把它理解为 "把编号 A 重定向到编号 B 的背后"

此后:终端模拟器 write(master_fd, "l", 1) → 内核查 PTY 对表 → slave 输入缓冲区 → Shell 的 read(0, ...) 读到 "l"。Shell write(1, "hi", 2) → slave → 内核查表 → master 输出缓冲区 → 终端模拟器 read(master_fd, ...) 读到 "hi"两个进程,各自持有自己的 fd,内核在中间按一对一的表传递数据,不会送错。

关键洞察:Shell 不知道对面是谁

Shell 只看到 /dev/pts/0 这个设备文件——对它来说,这就是一个"终端"。Shell 根本不知道(也不需要知道)对面连的是 iTerm2 还是 tmux 还是一个 SSH 会话。这种透明性是 Unix 设计的精髓。

6.2 完整数据流:从你的手指到屏幕

当你打开 iTerm2,在提示符后敲下 ls 然后按 Enter:

一次完整的命令执行,数据经过了这些路径

  ┌─────────┐
  │ 你的手指  │  敲下 l → s → Enter
  └────┬────┘
       ↓  (macOS 键盘事件)
  ┌─────────────┐
  │  iTerm2     │  终端模拟器接收键盘事件,
  │ (Terminal   │  把字符通过 PTY master 端发送出去
  │  Emulator)  │
  └────┬────────┘
       ↓  write('l'), write('s'), write('\n')
  ┌─────────────────┐
  │  PTY Master     │  /dev/ptmx 的文件描述符
  │  (内核空间)       │
  └────────┬────────┘
           ↓  内核内部传递
  ┌─────────────────┐
  │  线路规程        │  缓冲、回显(终端模拟器可设 raw mode 关掉)
  │  (Line Disc.)   │
  └────────┬────────┘
           ↓
  ┌─────────────────┐
  │  PTY Slave      │  /dev/pts/0
  │  (内核空间)       │
  └────────┬────────┘
           ↓  bash 的 stdin (fd 0) 读到了 "ls\n"
  ┌─────────────────┐
  │  bash (Shell)   │  解析命令,fork 子进程 exec ls
  │  用户态进程       │  ls 的 stdout 写到 fd 1 = /dev/pts/0
  └────────┬────────┘
           ↓  write(文件列表文本)
  ┌─────────────────┐
  │  PTY Slave      │  /dev/pts/0
  └────────┬────────┘
           ↓
  ┌─────────────────┐
  │  PTY Master     │  iTerm2 的 fd 读到了输出
  └────────┬────────┘
           ↓
  ┌─────────────┐
  │  iTerm2     │  解析 ANSI 转义序列(颜色等)
  │             │  渲染到屏幕上
  └──────┬──────┘
         ↓
  ┌──────────┐
  │ 你的眼睛  │  看到文件列表
  └──────────┘

从手指到眼睛,数据走了 8 段路。但这一切发生在几十毫秒内——以至于你感觉就是"打了个 ls,屏幕显示了结果"。

7. Shell 在这一整套体系中的位置

有了上面的全景图,Shell 的位置就一目了然了:

如果把 PTY 比作一根电话线:
- 终端模拟器是你手里的电话机(你对着它说话,听着它传出声音)
- Shell是电话那端的接线员(听你的指令,帮你连接不同的服务)
- PTY就是电话线本身(只负责传信号,不理解内容)
你换一部电话机(换终端模拟器),接线员完全不受影响。接线员换班了(bash 换 zsh),你的电话机也不需要换。

8. 亲手看看你的系统

理论知识够多了。打开你的终端,亲手验证一下:

看看你当前用的是哪个终端设备

$ tty
/dev/pts/2          ← 你在 PTY slave 2 号上

看看系统上所有登录的终端

$ who
yutong           console      Jun 10 10:14
yutong           ttys001      Jun 10 10:14
yutong           ttys002      Jun 10 10:15
# macOS 上用的是 ttys*,Linux 上是 pts/*

看看 iTerm2 进程是怎么连接 PTY 的

$ ps aux | grep -E 'iTerm|login|bash|zsh' | grep -v grep
# 观察 bash/zsh 进程 —— 它们的 controlling terminal 是哪个?

向另一个终端发消息(试试这个有趣的实验)

打开两个终端窗口。在第一个窗口里:

$ tty
/dev/ttys001

第二个窗口里,向第一个窗口的设备文件直接写入:

$ echo "Hello from another terminal!" > /dev/ttys001

你会看到消息直接出现在第一个窗口里!这说明:终端设备文件就是一个可以读写的字符设备——谁都可以往里写

看看 /dev 目录下的终端设备

$ ls -la /dev/ttys* /dev/pts/* 2>/dev/null
crw--w----  1 yutong  tty    16,0 Jun 10 10:14 /dev/ttys000
crw--w----  1 yutong  tty    16,1 Jun 10 10:14 /dev/ttys001
# c = 字符设备(character device)
# 注意权限:只有 owner 可读写

macOS vs Linux 的 /dev 命名差异

macOS 使用 /dev/ttysNNN 作为 PTY slave 的名字(历史原因,macOS 的 BSD 血统)。Linux 使用 /dev/pts/N。概念完全相同,只是命名不同。

9. 一张图总结全部概念

概念全景图 —— 你现在应该能看懂这张图了

                        ┌──────────────┐
                        │   用户(你)   │
                        └──────┬───────┘
                          击键  │  ↑  视觉
                               ↓  │
  ┌─────────────────────────────────────────────────┐
  │          终端模拟器(Terminal Emulator)           │
  │          iTerm2 / Terminal.app / xterm           │
  │          · 接收键盘事件                           │
  │          · 解析 ANSI 转义序列并渲染               │
  │          · 管理窗口、字体、颜色                    │
  │          · 打开 /dev/ptmx 获得 PTY master        │
  └──────────────────────┬──────────────────────────┘
                         │  PTY Master (控制端)
                         │  /dev/ptmx → fd
  ╔══════════════════════╪══════════════════════════╗
  ║              内核空间  │                          ║
  ║  ┌────────────────────┴───────────────────────┐  ║
  ║  │        线路规程(Line Discipline)           │  ║
  ║  │        · 规范模式:缓冲行、回显、信号生成     │  ║
  ║  │        · 原始模式:程序自行处理每个字节        │  ║
  ║  └────────────────────┬───────────────────────┘  ║
  ║                       │  PTY Slave (被控端)       ║
  ║                       │  /dev/pts/0               ║
  ╚═══════════════════════╪══════════════════════════╝
                          │  stdin/stdout/stderr
  ┌───────────────────────┴──────────────────────────┐
  │              Shell(bash / zsh / fish)           │
  │              · 打印提示符(PS1)                   │
  │              · 读取命令、解析、执行                 │
  │              · fork 子进程,继承终端 fd             │
  │              · 作业控制(fg/bg/jobs)              │
  └──────────────────────────────────────────────────┘

一句话总结各概念的关系

概念是什么在哪
Terminal(终端)人与计算机之间的交互端点。现在基本等同于"终端模拟器"GUI 程序
TTY原本指电传打字机(物理设备)。现在是 Unix 内核终端子系统的统称内核概念 + 设备文件
PTY(伪终端)内核提供的虚拟终端对,替代物理串口线内核
Terminal Emulator模拟物理终端行为的 GUI 程序,iTerm2 等用户态 GUI 程序
Shell命令行解释器,读取命令并执行用户态 CLI 程序
Console通常指直接接在计算机上的物理终端/显示器。现在常与 Terminal 混用物理设备(历史)

📌 快速回顾

  1. TTY = 电传打字机的缩写,现在指 Unix 内核的终端子系统
  2. Terminal(终端)= 人机交互的端点,最早是物理机器,现在是软件
  3. Terminal Emulator(终端模拟器)= 模拟物理终端的 GUI 程序,如 iTerm2
  4. Line Discipline(线路规程)= 内核中的缓冲层,负责回显、行缓冲、信号生成
  5. PTY(伪终端)= 内核提供的"虚拟串口线",让终端模拟器和 Shell 能通信
  6. PTY Master = 终端模拟器控制端 | PTY Slave = Shell 看到的"终端"
  7. Shell = 普通用户态程序,只是恰好连接到了 PTY slave
  8. Ctrl+C 能杀进程,是因为内核 TTY 子系统的 Line Discipline 拦截了这个按键并发了 SIGINT

🛠️ 动手练习

  1. 打开终端,运行 tty,记下你的终端设备名
  2. 运行 whow,观察系统上所有的登录终端
  3. 打开两个终端窗口,用 echo "hello" > /dev/ttysXXX 从一个窗口向另一个发消息
  4. printf '\033[31mRed Text\033[0m\n' 打印彩色文字——这是 VT100 时代的遗产
  5. 运行 ps -o pid,comm,tty,观察不同进程关联的终端
  6. 在终端里运行 cat,然后敲几个字(不按 Enter),观察回显行为。然后按 Ctrl+C 退出——思考:回显是谁做的?Ctrl+C 是谁拦截的?