阶段一 · 概念辨析

历史视角与概念辨析

为什么从历史讲起?

也许会好奇--不过是想学命令行操作,为什么要听电传打字机的故事?

因为 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 序列:继续读数字参数(分号分隔) │
└────────────────────────────┘


┌───────────────────────┐
│ 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 串口驱动等
┌─────────────────────────────────────────┐
│ 内核空间 │
├─────────────────────────────────────────┤
│ TTY 核心(设备文件接口) │
│ 例如 /dev/ttyS0 │
│ │
│ 线路规程(Line Discipline) │
│ 缓冲, 回显, 信号 │
│ │
│ TTY 驱动(硬件驱动) │
└─────────────────────────────────────────┘

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

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

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

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

第二件: 信号生成. 某些特殊按键不会被当成字符,而是变成信号(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 等全屏程序就是这么做的--它们需要精确控制每一个按键和光标位置.

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

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

  • 在这个终端上按 Ctrl+C,信号发给这个会话的前台进程组
  • 终端断开(比如关了窗口),内核向这个会话的领头进程发 SIGHUP
  • 一个进程可以通过 /dev/tty 访问自己的控制终端,无论它被重定向到哪里

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.app macOS 自带,够用但功能有限
iTerm2 功能强大,macOS 上最流行的第三方终端
Alacritty GPU 加速,极快,Rust 编写
WezTerm GPU 加速 + Lua 配置,跨平台
Warp 现代化设计,AI 辅助,但闭源
kitty GPU 加速 + 图片显示,适合高级用户

但终端模拟器有一个问题: 它只是一个 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/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:

  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:

┌────────────────────────────────────┐
│ iTerm2 (Terminal Emulator) │
│ 接收键盘事件 │
│ 把字符通过 PTY master 端发送 │
└──────────────┬─────────────────────┘

┌──────────────────────────────────────┐
│ 内核空间 (下行) │
│ PTY Master → Line Discipline → PTY Slave │
│ /dev/ptmx 的 fd 缓冲,回显 /dev/pts/0 │
└──────────────┬───────────────────────┘

┌───────────────────────────────────────────────────────────┐
│ bash (Shell) 用户态进程 │
│ 解析命令, fork 子进程 exec ls │
│ ls 的 stdout 写到 fd 1 = /dev/pts/0 │
└──────────────┬────────────────────────────────────────────┘

┌────────────────────────────────┐
│ 内核空间 (上行) │
│ PTY Slave → PTY Master │
│ /dev/pts/0 iTerm2 的 fd 读到输出 │
└──────────────┬─────────────────┘

┌───────────────────────┐
│ iTerm2 渲染 │
│ 解析 ANSI 转义序列 │
│ 渲染到屏幕 │
└───────────────────────┘

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

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

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

  • Shell 只是一个普通的用户态程序(和 ls, vim, python 一样)
  • 它的 stdin(fd 0), stdout(fd 1), stderr(fd 2)连接到了PTY slave 端
  • 它在 slave 端上读命令, 写结果--完全不关心对面是物理终端还是伪终端
  • 它 fork 子进程时,子进程继承这三个 fd,所以子进程的输出也回到同一个终端

如果把 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. 一张图总结全部概念

┌───────────────────────────────────────────────┐
│ 终端模拟器 (iTerm2 / Terminal.app / xterm) │
│ 接收键盘事件 │
│ 解析 ANSI 转义序列并渲染 │
│ 管理窗口, 字体, 颜色 │
│ 打开 /dev/ptmx 获得 PTY master │
└───────────────────────────────────────────────┘


┌───────────────────────────────────────────────────────────────────┐
│ 内核空间 │
│ 线路规程 (Line Discipline) │
│ 规范模式: 缓冲行, 回显, 信号生成 │
│ 原始模式: 程序自行处理每个字节 │
└───────────────────────────────────────────────────────────────────┘


┌───────────────────────────────────┐
│ 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 = 电传打字机的缩写,现在指 Unix 内核的终端子系统
  • Terminal(终端) = 人机交互的端点,最早是物理机器,现在是软件
  • Terminal Emulator(终端模拟器) = 模拟物理终端的 GUI 程序,如 iTerm2
  • Line Discipline(线路规程) = 内核中的缓冲层,负责回显, 行缓冲, 信号生成
  • PTY(伪终端) = 内核提供的 "虚拟串口线",让终端模拟器和 Shell 能通信
  • PTY Master = 终端模拟器控制端; PTY Slave = Shell 看到的 "终端"
  • Shell = 普通用户态程序,只是恰好连接到了 PTY slave
  • 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 是谁拦截的?