为什么从历史讲起?
也许会好奇--不过是想学命令行操作,为什么要听电传打字机的故事?
因为 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". 在终端里试试:
|
终端如何区分哪部分是命令,哪部分是文本?
|
以 \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 串口驱动等 |
|
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 应用,跟浏览器, 文本编辑器一样. 它做三件事:
- 接收键盘输入 → 发给 Shell
- 接收 Shell 的输出(含 ANSI 转义序列) → 解析后渲染到屏幕上
- 管理窗口(大小, 滚动, 复制粘贴等 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)-- 关键的一环
物理终端的通信链路是:
|
终端模拟器没有串口线. 内核需要提供一条虚拟的线来替代它--这就是伪终端(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:
- 分配一个新的 slave 设备(取下一个可用编号,比如 3)
- 在
/dev/pts/下创建/dev/pts/3 - 在内核 PTY 对表中记录: 这个 master fd ↔
/dev/pts/3 - 返回一个整数 fd 给终端模拟器--这就是 master 端
/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)- 映射为 stderr- 关闭不需要的 master fd
exec("/bin/bash")- 替换当前进程为 Shell
- 父进程(终端模拟器)保留 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:
|
从手指到眼睛,数据走了 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. 亲手看看系统
理论知识够多了. 打开终端,亲手验证一下:
看看当前用的是哪个终端设备
|
看看系统上所有登录的终端
|
看看 iTerm2 进程是怎么连接 PTY 的
|
向另一个终端发消息(试试这个有趣的实验)
打开两个终端窗口. 在第一个窗口里:
|
在第二个窗口里,向第一个窗口的设备文件直接写入:
|
消息直接出现在第一个窗口里! 这说明: 终端设备文件就是一个可以读写的字符设备--谁都可以往里写.
看看 /dev 目录下的终端设备
|
macOS vs Linux 的 /dev 命名差异
macOS 使用 /dev/ttysNNN 作为 PTY slave 的名字(历史原因,macOS 的 BSD 血统). Linux 使用 /dev/pts/N. 概念完全相同,只是命名不同.
9. 一张图总结全部概念
|
一句话总结各概念的关系:
| 概念 | 是什么 | 在哪 |
|---|---|---|
| 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
动手练习
- 查看终端设备名: 打开终端,运行
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是谁拦截的?