从电传打字机到终端模拟器——一段必须了解的历史,让你真正理解 terminal / tty / pty
你可能好奇——我不过是想学命令行操作,为什么要听你讲电传打字机?
因为 terminal、tty、pty 这些词,不经历史讲不清楚。它们不是某个委员会精心命名的——它们是历史一层一层叠加上去的。每次技术演进留下了新的名字,旧名字也还在用。要理解"terminal 和 tty 到底什么关系?pty 又是什么?",你需要的不是定义,而是一个故事。
tele(远程)+ type(打字)+ writer(机器),你就不需要死记"tty 是什么的缩写"。历史本身就是最好的解释。
19 世纪 30 年代,电报(telegraph)已经普及,但需要训练有素的操作员敲莫尔斯电码。人们想要一种更直观的方式——像打字机一样输入,另一端自动打印出来。
这就是电传打字机(Teletypewriter,简称 TTY):你在键盘上敲 "HELLO",远方的另一台机器就自动在纸上打出 "HELLO"。它的本质是通过电线连接的远程打字机。
1950–60 年代,计算机还是房屋大小的庞然大物。操作员需要一种方式输入命令和查看输出。当时已经有了成熟的电传打字机技术——为什么不直接把计算机接在电线的另一端?
于是:
这就叫"计算机终端(Computer Terminal)"——Terminal 的本意是"端点",即人与计算机之间的交互端点。
现代语言的遗迹
为什么几乎所有的编程语言都用 print 输出信息?因为当时的输出真的是印在纸上。C 语言的 printf、Python 的 print()、Java 的 System.out.println()——都是那个纸带滚滚的时代的遗存。
从此,"终端"在计算机世界里就有了明确的含义:一种输入输出设备,让你跟计算机对话。而 TTY 就是这个设备的简称——因为早期终端就是 teletypewriter。
早期计算机使用批处理(Batch Processing)——你把一叠打孔卡交给操作员,几小时后取结果。这就像寄信:等回信的时间远长于写信的时间。
1960 年代,分时系统(Time-Sharing)出现了。多个用户同时连接同一台计算机,每个人独占一个"终端"。你在终端上打字,计算机会在几毫秒内响应——交互式计算诞生了。
Unix 操作系统(诞生于 1969 年)从一开始就为这种多用户、多终端的使用场景而设计。在 Unix 眼里,终端就是一个字符设备(Character Device)——一个接一个地发送和接收字符的设备文件。
Unix 内核把与终端交互的这部分代码称为 TTY 子系统。注意这个微妙的变化:TTY 从一个"物理机器"的名字,变成了一个"内核架构概念"的名字。
电传打字机有一个明显的缺点:慢,而且费纸。你敲一下键盘,机器要等打印头落到纸上。而且所有历史输出都在那一卷纸上——你没法"清屏",只能翻纸。
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 时代的遗产——你每天都在用它,只是不知道而已。
到这里,我们知道:物理终端通过串口线连到计算机,逐个收发字符。但 Unix 内核怎么处理这些字符?TTY 子系统就是干这个的——它是内核中负责终端 I/O 处理的一整套代码。
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
物理终端设备(或调制解调器)
线路规程(Line Discipline)是 TTY 子系统最关键的中间层。它做两件反直觉的事:
第一件:缓冲与回显。默认情况下,你在键盘上敲的字符并不会立即发给应用程序——它们被缓存在内核中。只有当你按下 Enter,整行才一次性发给应用程序。这叫规范模式(Canonical Mode)。同时,你敲的每个字符会被回显(echo)到屏幕上——这是内核帮你做的,不是 Shell!
第二件:信号生成。某些特殊按键不会被当成字符,而是变成信号(Signal):
| 按键 | 生成的信号 | 作用 |
|---|---|---|
Ctrl+C | SIGINT | 中断(终止)当前前台进程 |
Ctrl+Z | SIGTSTP | 挂起(暂停)当前前台进程 |
Ctrl+\ | SIGQUIT | 强制退出并生成 core dump |
Ctrl+D | EOF | 发送文件结束符(不是信号,但同样被 Line Discipline 拦截处理) |
这就是为什么 Ctrl+C 能终止一个正在运行的程序——不是 Shell 在帮你杀进程,而是内核的 TTY 子系统直接把 SIGINT 信号发给了前台进程。
Raw Mode(原始模式):程序可以选择关闭 Line Discipline 的缓冲和信号处理,让每个按键直接送达。Vim、tmux 等全屏程序就是这么做的——它们需要精确控制每一个按键和光标位置。
每个"会话(Session)"可以关联一个控制终端(Controlling Terminal)。这个关联意味着:
Ctrl+C,信号发给这个会话的前台进程组/dev/tty 访问自己的控制终端,无论它被重定向到哪里1980-90 年代,图形用户界面(GUI)普及了。物理终端逐渐消失——你不再需要一台专门的电传打字机或 VT100 显示器来接计算机。
但人仍然需要命令行。于是诞生了终端模拟器(Terminal Emulator):一个GUI 程序,用软件模拟 VT100(等物理终端)的行为。
关键认知:终端模拟器只是一款普通的 GUI 应用,跟浏览器、文本编辑器一样。它做三件事:
你用的终端模拟器(macOS 上的选择):
| 名称 | 特点 |
|---|---|
| Terminal.app | macOS 自带,够用但功能有限 |
| iTerm2 | 功能强大,macOS 上最流行的第三方终端 |
| Alacritty | GPU 加速,极快,Rust 编写 |
| WezTerm | GPU 加速 + Lua 配置,跨平台 |
| Warp | 现代化设计,AI 辅助,但闭源 |
| kitty | GPU 加速 + 图片显示,适合高级用户 |
但终端模拟器有一个问题:它只是一个 GUI 程序,不是硬件。物理终端通过 RS-232 串口线连到计算机,内核有一个 TTY 驱动来管理那条线。终端模拟器没有物理线缆——它怎么跟 Shell 通信?
这就是伪终端(PTY)要解决的问题。
物理终端的通信链路是:
物理终端 ←→ 串口线 ←→ 串口驱动 ←→ Line Discipline ←→ /dev/ttyS0 ←→ 用户程序
终端模拟器没有串口线。内核需要提供一条虚拟的线来替代它——这就是伪终端(Pseudo Terminal,简称 PTY)。
PTY 不是一个单独的设备,而是一对设备——就像一根管道的两端:
| 端 | 设备名 | 谁在用 | 作用 |
|---|---|---|---|
| Master(主端) | /dev/ptmx → /dev/pts/0 的 master fd | 终端模拟器 | 控制端:向 slave 写入输入,从 slave 读取输出 |
| Slave(从端) | /dev/pts/0, /dev/pts/1 … | Shell 等用户程序 | 被控端:对 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 | 名称 | 默认指向 |
|---|---|---|
| 0 | stdin | 键盘输入 |
| 1 | stdout | 屏幕输出 |
| 2 | stderr | 屏幕输出(错误) |
Master 和 Slave 的对应关系由内核管理。关键在于:/dev/ptmx 不是一个普通设备——它是一个工厂(factory)。每次 open("/dev/ptmx") 都不复用已有连接,而是触发内核创建一对全新的 master-slave:
/dev/pts/ 下创建 /dev/pts/3/dev/pts/3/dev/ptmx 是餐厅前台。每个终端模拟器走进来说"开一桌",前台就分配一张新桌子(新 /dev/pts/N),并给顾客一个取餐牌(master fd)。顾客拿着取餐牌去座位上等着,厨师(Shell)在这张桌子上做菜。取餐牌和桌号一对一——3 号取餐牌的菜只送 3 号桌,不会送错。
终端模拟器如何让 Shell 用上 slave?—— fork + exec + dup2
这是整个流程中最精妙的一步。终端模拟器拿到 master fd 后,需要让 Shell 把对应的 slave 当作自己的 stdin/stdout/stderr。具体做法:
fd_master = open("/dev/ptmx") — 创建 PTY 对,slave = /dev/pts/3fork() — 分出子进程(子进程继承父进程所有 fd,包括 master fd)fd_slave = open("/dev/pts/3") — 打开 slave 设备dup2(fd_slave, 0) — 把 slave 映射为 stdindup2(fd_slave, 1) — 映射为 stdoutdup2(fd_slave, 2) — 映射为 stderrexec("/bin/bash") — 替换当前进程为 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 设计的精髓。
当你打开 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,屏幕显示了结果"。
有了上面的全景图,Shell 的位置就一目了然了:
理论知识够多了。打开你的终端,亲手验证一下:
$ 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/*
$ 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
你会看到消息直接出现在第一个窗口里!这说明:终端设备文件就是一个可以读写的字符设备——谁都可以往里写。
$ 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。概念完全相同,只是命名不同。
概念全景图 —— 你现在应该能看懂这张图了
┌──────────────┐
│ 用户(你) │
└──────┬───────┘
击键 │ ↑ 视觉
↓ │
┌─────────────────────────────────────────────────┐
│ 终端模拟器(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 混用 | 物理设备(历史) |
📌 快速回顾
🛠️ 动手练习
tty,记下你的终端设备名who 和 w,观察系统上所有的登录终端echo "hello" > /dev/ttysXXX 从一个窗口向另一个发消息printf '\033[31mRed Text\033[0m\n' 打印彩色文字——这是 VT100 时代的遗产ps -o pid,comm,tty,观察不同进程关联的终端cat,然后敲几个字(不按 Enter),观察回显行为。然后按 Ctrl+C 退出——思考:回显是谁做的?Ctrl+C 是谁拦截的?