阶段一 · 概念辨析

TTY 与 PTY:内核视角

1. 为什么需要内核视角?

前两篇从历史演进和分工协作的角度理解了 terminal / shell / tty / pty. 现在下沉一层,回答一个问题: 当运行 stty -echo 关掉回显,或者 Vim 启动时把终端切到 raw mode--内核到底改了什么?

答案藏在 termios 这个内核数据结构里. 它是一组开关,控制着终端 I/O 的每一个细节. 理解它,就真正拥有了 "操纵终端行为" 的能力.

termios 就像飞机驾驶舱里的控制面板--几百个拨杆和旋钮,每个控制一个具体行为: 回显开/关, 缓冲行/字符, Ctrl+C 转信号/当普通字符...
Shell 和 Vim 的区别,本质上就是它们拨动了不同组合的开关.

2. termios: 终端行为的 "开关面板"

Unix 内核用 struct termios 来描述一个终端的所有行为配置. 它包含四组标志位(flag),外加一些特殊字符的定义:

成员 全称 控制什么
c_iflag Input Flags 输入端如何处理收到的字符
c_oflag Output Flags 输出端如何处理要发送的字符
c_cflag Control Flags 硬件控制(波特率, 数据位, 校验位等)
c_lflag Local Flags 最核心的一组--回显, 规范模式, 信号生成
c_cc[] Control Characters 特殊字符定义(哪些按键触发 EOF, SIGINT, SUSP 等)

ioctl(fd, TCGETS, &termios) 读出当前配置,改几个位,再 ioctl(fd, TCSETS, &termios) 写回去. 这就是 stty 命令和 Vim 的 raw mode 在底层做的事.

2.1 c_lflag - 决定终端 "像不像终端"

c_lflag 是四个标志组里最重要的一组. 它控制的正是我们反复提到的那些 "终端感" 行为:

标志 含义 关掉会怎样
ECHO 回显: 敲的字符显示在屏幕上 敲键盘什么都看不见(密码输入就是这样)
ICANON 规范模式: 内核按行缓冲,Enter 才提交 每个按键立刻送达程序(raw mode 的核心)
ISIG 信号生成: Ctrl+C → SIGINT 等 Ctrl+C 变成普通字符(ASCII 3),不杀进程
ECHOE 按退格键时擦除屏幕上最后一个字符 退格只是发一个字符,屏幕不更新
ECHOK 按 Ctrl+U 杀行后显示视觉反馈(如换行) 行被清了但看不到
IEXTEN 扩展功能: Ctrl+V(逐字输入),Ctrl+W(删词) 这些特殊编辑组合键失效

可以同时关掉 ECHO + ICANON + ISIG,但保留其他位--这就是精细控制. 实际的 raw mode 不是 "全关",而是一组精心选择的默认值.

2.2 c_iflag - 输入端怎么处理收到的字符

标志 含义 关掉会怎样
ICRNL 收到的回车(CR, \r)转为换行(NL, \n) 程序看到的是 \r 而不是 \n
IXON 软件流控: Ctrl+S 暂停输出,Ctrl+Q 恢复 Ctrl+S 失效(不会意外冻结终端了)
INLCR 收到的 \n 转为 \r 程序直接收到 \n
IGNCR 忽略收到的 \r \r 正常传递给程序
BRKINT 收到 BREAK 信号时发送 SIGINT BREAK 被忽略

Ctrl+S 的陷阱

几乎所有终端新用户都踩过这个坑: 不小心按了 Ctrl+S,终端突然 "卡住" 了--敲什么都没反应. 其实不是卡了,是 IXON 标志让内核暂停了输出. 按 Ctrl+Q 恢复. 这就是 c_iflag 在日常中的实际影响.

2.3 c_oflag - 输出端怎么处理要发的字符

标志 含义
OPOST 启用输出后处理(这个关了,下面所有标志都失效)
ONLCR 输出 \n 时自动在前面加 \r(\n → \r\n)
OCRNL 输出 \r 时转为 \n
TAB3 Tab 转为空格输出

ONLCR 是日常影响最大的标志. 想想为什么 Shell 输出 \n 就能让光标到下一行行首--这其实是内核帮忙补了 \r. 如果关掉 ONLCR,终端会变成 "楼梯效果":

# 关掉 ONLCR 后的效果
$ stty -onlcr; printf "line1\nline2\nline3\n"; stty onlcr
line1
line2
line3
# 因为只有 \n(换行)没有 \r(回车),光标只下移不回到行首

3. stty: 不用写代码就能操作 termios

stty(set tty)是查看和修改 termios 的命令行工具. 它直接调用 ioctl 读写 termios 结构.

3.1 查看当前设置

$ stty -a
speed 38400 baud; 45 rows; 180 columns;
lflags: icanon isig iexten echo echoe echok echoke -echonl echoctl
-echoprt -altwerase -noflsh -tostop -flusho pendin
-nokerninfo -extproc
iflags: -istrip icrnl -inlcr -igncr ixon -ixoff ixany imaxbel iutf8
-ignbrk brkint -inpck -ignpar -parmrk
oflags: opost onlcr -oxtabs -onocr -onlret
cflags: cread cs8 -parenb -parodd hupcl -clocal -cstopb -crtscts
-dsrflow -dtrflow -mdmbuf
cchars: discard = ^O; dsusp = ^Y; eof = ^D; eol = ;
eol2 = ; erase = ^?; intr = ^C; kill = ^U; lnext = ^V;
min = 1; quit = ^\; reprint = ^R; start = ^Q;
status = ^T; stop = ^S; susp = ^Z; time = 0; werase = ^W;

这就是当前终端的完整 termios 配置. 每一行对应一组标志--lflags, iflags, oflags, cflags, 和 c_cc[] 特殊字符定义.

stty -a- 前缀表示该标志已关闭,没有前缀表示已开启. 例如: icanon(开) vs -echonl(关).

3.2 修改终端行为

# 关掉回显(输入密码时的效果)
$ stty -echo
$ read -p "password: " pass # 输入时什么都看不到
password:
$ stty echo # 恢复

# 把 Ctrl+C 拦截关掉,Ctrl+C 变成普通字符
$ stty -isig
$ cat # 按 Ctrl+C -- cat 没有被杀,而是收到了 ASCII 3
# 按 Ctrl+D 退出 cat
$ stty isig # 务必恢复!

# 关掉行缓冲--每个字符立即送达,不回显
$ stty -icanon -echo
$ cat # 现在每个按键立刻被 cat 读到并输出(会看到双份:cat 也回显了)

3.3 救命命令: stty sane

当折腾终端设置导致乱套后(比如运行了 cat 二进制文件导致终端状态混乱),可能会看到: 敲什么键都不回显, 换行错乱, Ctrl+C 失效. 此时需要:

# 盲打这两行(因为看不到回显):
$ stty sane
# 或者:
$ reset

stty sane 把终端恢复到 "合理" 的默认状态. reset 做了同样的事,还额外清屏和重置光标.

4. 规范模式 vs 原始模式

上一篇反复提到这两个模式. 现在有了 termios 的知识,可以精确理解它们各自拨动了哪些开关.

4.1 规范模式: 内核替程序做的三件事

规范模式(ICANON=1)下,一个 read(stdin, buf, n) 调用是这样的:

敲 'h' → 内核 buffer: "h"          回显 'h' (ECHO)
敲 'e' → 内核 buffer: "he" 回显 'e' (ECHO)
敲 'l' → 内核 buffer: "hel" 回显 'l' (ECHO)
按退格 → 内核 buffer: "he" 擦掉 'l' (ECHOE)
敲 'l' → 内核 buffer: "hel" 回显 'l' (ECHO)
敲 'o' → 内核 buffer: "hello" 回显 'o' (ECHO)
按 Enter → 内核把 "hello\n" 提交给 read()
程序的 read() 终于返回了

在这个模式里,程序在 read() 上阻塞,对用户的按键毫不知情. 所有编辑(退格, Ctrl+U 删行, Ctrl+W 删词)都是内核代劳的.

4.2 原始模式: 全部交还给程序

原始模式不是某一个标志,而是一组标志的组合关闭. 没有 "raw mode" 这个单一位--只是约定俗成地叫 "raw":

关闭的标志 效果
ICANON 不再行缓冲--每个字符立刻送达程序
ECHO 不再回显--程序自己决定什么时候显示什么
ISIG Ctrl+C / Ctrl+Z 不再生成信号,变成普通字符
IEXTEN Ctrl+V / Ctrl+W 等扩展编辑键失效
ICRNL \r 不再转为 \n,程序自己处理
IXON Ctrl+S / Ctrl+Q 软件流控失效
OPOST 输出后处理失效(包括 ONLCR--\n 不再自动补 \r)

全关之后,终端变成了一根透明的管道: 每个字节原样进出,没有任何加工. Vim 就是这么用的--它需要精确控制每一个按键, 每一个光标位置, 每一帧画面.

raw ≠ 全关

严格来说有两种模式: raw(几乎全关,包括 ICRNL 和 OPOST)和 cbreak(只关 ICANON,保留 ISIG 和信号处理). cbreak 是一个中间状态: 程序逐字符接收输入,但 Ctrl+C 仍然能终止它. 大多数场景下其实用的是 cbreak 而非完全的 raw.

5. PTY 创建: 从应用开发者视角

前两篇反复说 "终端模拟器打开 /dev/ptmx, 内核创建 PTY 对". 现在来看具体的系统调用.

5.1 四步创建 PTY

创建一对 PTY 需要四个步骤,三个系统调用:

// PTY 创建四部曲
int master = posix_openpt(O_RDWR | O_NOCTTY);
// ① 打开 /dev/ptmx,获得 master fd
// O_NOCTTY:不要让 master 成为本进程的控制终端

grantpt(master);
// ② 修改 slave 设备文件的权限和所有者
// 确保子进程有权打开它

unlockpt(master);
// ③ 解锁 slave 设备
// 在此之前 slave 无法被打开

char *slave_name = ptsname(master);
// ④ 获取 slave 设备文件名,如 "/dev/pts/3"

拿到 master fd 和 slave 名字后,传统的 fork + exec 流程:

// fork + dup2 + exec
pid_t pid = fork();
if (pid == 0) { // 子进程
setsid(); // 创建新会话(脱离父终端的控制)
int slave = open(slave_name, O_RDWR);
dup2(slave, STDIN_FILENO); // 0 → slave
dup2(slave, STDOUT_FILENO); // 1 → slave
dup2(slave, STDERR_FILENO); // 2 → slave
close(slave);
close(master); // 子进程不需要 master
execlp("bash", "bash", NULL);
}
// 父进程(终端模拟器)保留 master fd,关闭 slave 名字

setsid() 是关键: 它让子进程脱离原来的会话,创建一个新的会话. 此时该进程没有控制终端--然后打开 slave 设备,slave 自动成为这个新会话的控制终端.

5.2 SSH 如何使用 PTY

ssh user@remote 时,SSH 服务端做的事情和终端模拟器一模一样:

本地终端 → SSH 客户端 → 网络传输 → SSH 服务端

posix_openpt() 创建 PTY 对
fork + exec bash(连到 slave)

Shell 输出 → master 读出 → 转发到网络 → 客户端显示

SSH 服务端在中间充当了 "桥梁": 一端是网络 socket(连着本地),另一端是 PTY master(连着远程 Shell). 它从 socket 读到按键 → 写到 master → Shell 输出 → master 读出 → 发回 socket. 这就是为什么 SSH 登录后感觉 "就是在用本地终端"--因为远程 Shell 确实连着一个 PTY,和本地的体验完全一样.

ssh 的 -t 参数

如果运行 ssh remote "ls"(不带 -t),SSH 不会分配 PTY--命令的 stdin/stdout 直接连到 socket. 如果运行 ssh -t remote "vim",SSH 会强制分配 PTY--因为 vim 需要一个终端才能正常工作. 不带 -t 时运行 vim 会报 Vim: Warning: Output is not to a terminal.

6. 终端信号全家福

以下是与终端相关的完整信号列表. 这些信号全由内核 TTY 层触发,不是 Shell 发的:

信号 触发条件 默认行为 谁在处理
SIGINT Ctrl+C(来自 c_cc[VINTR]) 终止进程 内核 Line Discipline(需 ISIG 开启)
SIGQUIT Ctrl+(来自 c_cc[VQUIT]) 终止 + core dump 内核 Line Discipline(需 ISIG 开启)
SIGTSTP Ctrl+Z(来自 c_cc[VSUSP]) 挂起进程 内核 Line Discipline(需 ISIG 开启)
SIGWINCH 窗口大小改变 忽略 内核 TTY 层(终端模拟器发 ioctl 后触发)
SIGHUP 终端断开(窗口关闭,SSH 断连) 终止进程 内核 TTY 层(master 端 close 时触发)
SIGTTIN 后台进程尝试读终端 挂起进程 内核 TTY 层
SIGTTOU 后台进程尝试写终端 挂起进程 内核 TTY 层
SIGPIPE 向已关闭的管道/socket 写入 终止进程 内核 pipe/socket 层

这个全家福里有一个关键的区分: 前三行仅在 ISIG 开启时生效--关掉 ISIG, Ctrl+C 就变成了普通字符. 但 SIGHUP 和 SIGWINCH 不受 ISIG 影响--它们是 TTY 层的核心机制,关不掉.

SIGHUP 的实际意义

当关闭终端窗口,内核检测到 PTY master 端被 close → 向 slave 端的会话领头进程发送 SIGHUP → Shell 收到 SIGHUP 后终止,并向自己的子进程(所有后台作业)也发 SIGHUP. 这就是为什么 "关窗口 = 杀掉里面所有程序". 可以用 nohup 让进程忽略 SIGHUP 来避免这种情况.

快速回顾

  • termios = 内核维护的终端行为配置,四组标志位(lflag/iflag/oflag/cflag) + 特殊字符表(c_cc)
  • c_lflag 最重要: ECHO(回显), ICANON(规范模式), ISIG(信号生成)--这是 "终端感" 的三大支柱
  • stty 是 termios 的命令行接口,stty -a 查看,stty -echo 修改,stty sane 救命
  • 规范模式: 内核缓冲整行,处理退格/Ctrl+U/Ctrl+W,Enter 后提交给程序
  • 原始模式: ICANON+ECHO+ISIG+ICRNL+OPOST 等全关,终端变成透明管道,每个字节原样进出
  • PTY 创建四步: posix_openpt → grantpt → unlockpt → ptsname,然后 fork + setsid + dup2 + exec
  • SSH 在远程创建 PTY,服务端充当 socket ↔ master 的桥梁
  • 终端信号: SIGINT/SIGQUIT/SIGTSTP 依赖 ISIG; SIGHUP/SIGWINCH 不依赖 ISIG

动手练习

  1. 查看 termios: 运行 stty -a,找到 intr, eof, susp 这三个特殊字符的定义
  2. 关闭回显: 运行 stty -echo; read -p "输入密码:" p; stty echo; echo "密码是 $p",体验关回显的效果
  3. 逐字符模式: 运行 stty -icanon -echo; cat 进入逐字符模式(按 Ctrl+D 退出),和规范模式的 cat 对比行为差异
  4. 关闭信号: 运行 stty -isig; cat,然后按 Ctrl+C--观察发生了什么?(Ctrl+D 退出后立即 stty isig 恢复)
  5. 终端是设备文件: 运行 tty 记下终端名,再运行 echo hello > /dev/ttysXXX 向自己的终端写数据--验证终端就是一个可读写的字符设备
  6. ONLCR 开关: 尝试 stty -onlcr; printf "a\nb\nc\n"; stty onlcr,观察 ONLCR 开关对输出的影响