【笔记】TTY / PTY / Line Discipline 与 SSH 终端链路排查
1. 背景
讨论从一个具体问题出发:docker exec -i 和 -t 分别做什么、二者的关系。展开后依次讨论了 TTY/PTY 的分层模型、line discipline 是什么、这套机制在真实的 macOS iTerm2 → SSH → Linux 服务器链路里如何工作(信号转发、窗口 resize),最后用一次 kill -9 杀死远程 vim 后本地终端花屏的真实案例,验证并纠正了对该机制的理解。
2. 核心问题
docker exec -i和-t各自的作用是什么,为什么交互式操作通常要-it一起用?- TTY/PTY 是什么模型,”line discipline” 在其中扮演什么角色?
- 在一次真实的
iTerm2 → ssh → 远程 shell链路里,有几个 PTY、几个关键进程,数据(普通输入、信号、窗口变化)是怎么流转的? - TTY 的模式(raw/cooked)能不能在进程运行期间动态切换?切换范围是进程私有的还是设备全局的?
kill -9杀掉一个 TUI 程序后终端花屏,根因是什么,和前面讨论的 TTY 模式切换是不是同一层问题?
3. 当前理解
3.1 docker exec -i / -t
-i(--interactive):保持容器进程的 stdin 打开,不加的话 stdin 会被关闭/重定向到空设备,本地敲的内容传不进容器进程。-t(--tty):给容器进程分配一个伪终端(PTY),让它的 stdin/stdout/stderr 表现为连在一个终端设备上(isatty()为真、有行缓冲/回显、能收窗口尺寸变化信号等)。- 二者正交、可任意组合,但语义上互补:
-i决定”有没有数据能写进 stdin”,-t决定”这个 stdin/stdout/stderr 是不是一个终端”。交互式 shell 几乎总要-it同时用。 - 关键的一点容易被忽视:
-t一旦启用,stdout 和 stderr 会共享同一个 PTY,不再是两条独立通道。Docker attach 协议(mobyAPI 文档)写明:TTY 模式下传输的是”PTY 的原始数据流”,只有 TTY 关闭时才会按 stream type 头部区分 stdout/stderr 做多路复用。这也是为什么脚本化调用容器命令时通常故意不加-t——需要保留 stdout/stderr 分离。
3.2 TTY/PTY 分层模型
不是简单的”数据中继”,而是三层结构:
1
2
3
4
5
终端模拟器(真实终端 / docker client)
↕ PTY master
[ 内核 line discipline,默认 N_TTY ]
↕ PTY slave
进程(shell / 容器内进程)
line discipline 是内核 TTY 子系统里插在”设备驱动”和”用户态 read()/write()”之间的可插拔模块(可用 ioctl(TIOCSETD) 切换,交互终端场景基本只会遇到默认的 N_TTY)。它不是被动转发,而是主动解释字节流:
- 模式切换:由
termios配置驱动 cooked(canonical)/raw 两种行为。cooked 模式下,输入先进行缓冲,支持 backspace/Ctrl-U 等行内编辑,遇到\n才整行提交给上层read();raw 模式不缓冲,字节到即可读。 - 回显(echo):cooked 模式下,敲的字符会被 line discipline 自动写回输出端——这是内核做的,不是应用程序自己 echo 的。
- 信号翻译:识别
termios里配置的控制字符(INTR=Ctrl-C、QUIT=Ctrl-\、SUSP=Ctrl-Z、EOF=Ctrl-D),命中时不把字节交给上层,而是直接给这个终端会话的前台进程组发对应信号(SIGINT/SIGQUIT/SIGTSTP),或让read()返回 0。 - 窗口尺寸:终端模拟器 resize 时通过
ioctl(TIOCSWINSZ)写入新尺寸,内核更新winsize结构并给前台进程组发SIGWINCH。
3.3 “line discipline” 词源
discipline 在这里取的是它较少见但仍规范的义项——”一套约束行为的规则体系”(a set of rules that governs behavior),跟”纪律训练”的常见义无关。历史上贝尔实验室 Unix(1970s)把 TTY 驱动栈分为”底层驱动”+”上层协议处理”两段,上层这段规定字节流该被如何解释(是否缓冲成行、是否回显、是否把特定字符转成信号),就叫 line discipline,字面即”(对一行输入的)处理规范”。现代 Linux 里对应 tty_ldisc 子系统,且这套 discipline 可在运行时替换(默认 N_TTY,也有 N_PPP 等)。
3.4 真实链路分析:macOS iTerm2 → SSH → Linux 服务器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
macOS(iTerm2) Linux 服务器(sshd)
┌─────────────────────┐ ┌──────────────────────┐
│ iTerm2(GUI 进程) │ │ sshd 主进程(监听) │
│ 持有 PTY-A master │ │ └─fork→ sshd 会话子进程│
│ │ │ │ 持有 PTY-B master│
│ PTY-A │ │ │ │
│ │ │ │ PTY-B │
│ slave = 本地 zsh 的 │ │ │ │
│ 控制终端 │ │ slave = 远程 bash │
│ zsh --fork/exec--> │ 加密 SSH 连接 │ 的控制终端 │
│ ssh 客户端进程 │ ────网络─────→ │ bash(及其子进程, │
│ (继承同一个 slave fd │ ←──────────── │ 如 vim) │
│ 做 stdin/out) │ │ │
└─────────────────────┘ └──────────────────────┘
- PTY 共 2 个:PTY-A(本地,master 在 iTerm2 手里,slave 是本地 zsh 的控制终端)、PTY-B(远程,master 在 sshd 会话子进程手里,slave 是远程 bash 的控制终端)。
- 进程链:本地
iTerm2 → zsh → ssh 客户端;远程sshd 会话子进程 → bash → 后续命令(vim 等)。 - 关键点:
ssh客户端自己不持有任何 PTY 的 master 端,它只是继承了 PTY-A 的 slave fd 做自己的 stdin/stdout/stderr。 - 两层 line discipline 分工不同:
ssh客户端一旦要在远端分配 pty,会tcsetattr把本地 PTY-A slave 切成 raw 模式,本地这层基本不做编辑/回显/信号翻译,只是把字节原样传给远端;真正的行编辑、回显、Ctrl-C→SIGINT 翻译发生在远程 PTY-B 的 line discipline 上(除非远程正在跑 vim 之类把它也切成 raw 的程序)。 - 回显路径:本地敲的字节 → 原样传到远程 → 远程 line discipline 回显 → 经 sshd → 网络传回 → ssh 客户端写回本地 PTY-A slave → iTerm2 读 master 显示。即回显往返了一次网络,这也是高延迟场景下”打字感觉卡顿一下才出字”的原因。
- 信号路径(Ctrl-C):本地按下 Ctrl-C 产生的
0x03字节,本地 raw 模式不拦截,原样传到远程;远程 PTY-B 的 line discipline 识别出 INTR 字符后,直接给该终端前台进程组发SIGINT。信号是在远程产生的,不是本地。 - 窗口 resize 路径:本地窗口变化 → 本地内核对 PTY-A 发
SIGWINCH→ ssh 客户端(其控制终端正是 PTY-A)捕获后,通过 SSH 协议的 window-change channel request(应用层消息,不混在字节流里)通知 sshd → sshd 对 PTY-B master 做ioctl(TIOCSWINSZ)→ 远程内核更新 winsize 并给远程前台进程组发SIGWINCH。
3.5 交互式 shell 的行编辑与 TTY 模式的动态切换
- TTY 模式挂在设备本身,不是进程私有状态:
termios设置是内核里 tty 结构体的一部分,任何有权限、持有该 fd 的进程都能随时tcsetattr()修改,立即对该 tty 上所有读写生效。 - 规范的终端程序遵循 save/restore 约定:进入时
tcgetattr()保存现场,退出前用保存的值恢复。ssh、vim、less、top都这样做,这是编程约定,内核不强制。 - zsh/bash 自身也会切 TTY 模式:这类自带命令行编辑器(zsh 的 ZLE、bash 的 readline)的 shell,在等待输入的 prompt 状态下,会把 tty 切到接近 raw(non-canonical/cbreak)的模式,因为要自己逐字符接管按键实现历史翻页、Tab 补全、语法高亮,这些内核 cooked 模式不支持。更完整的时间线是:
zsh(ZLE raw-ish) → fork 外部命令前恢复默认 cooked → 前台命令(如 ssh)自己再存现场并切 raw → 命令退出恢复到那份 cooked → zsh 拿回前台后再次切回 ZLE 模式。 - 异常退出会导致状态残留:若前台程序被
kill -9(SIGKILL)杀死,没有机会执行恢复步骤,tty 会卡在它设置的那个模式(通常是 raw),表现为 shell 不回显、方向键乱码。手动stty sane或reset可以把 termios 刷回合理默认值。
3.6 真实案例:kill -9 vim 后终端花屏
用 kill -9 杀死远程一个 vim 进程后,观察到本地 shell 大量刷出 zsh: command not found: 0、zsh: command not found: 173 等乱码提示,且后续鼠标操作还会持续追加类似乱码行。iTerm2 顶部同时弹出提示:”Looks like focus reporting was left on when an ssh session ended unexpectedly or an app misbehaved.”
根因是两套完全独立的机制被搞脏,且是不同层面的问题:
- 前几节讨论的 raw/cooked、回显、信号翻译,全部属于内核 tty 驱动 + line discipline 这一层,靠
ioctl/tcsetattr配置。 - vim 进入编辑模式时,还会做另一件事:向 stdout 发送若干 DECSET 转义序列,命令终端模拟器本身切换行为模式,这是终端模拟器协议层的状态,和内核 termios 是两条平行轨道:
\e[?1000h(外加常见的\e[?1002h/\e[?1003h/\e[?1006h):开启鼠标事件报告,此后鼠标移动/点击会被终端模拟器编码成转义序列,当作”键盘输入”注入这个 pane 的 stdin;\e[?1004h:开启焦点报告(focus in/out 事件同样编码后注入 stdin);\e[?1049h(或1047):切换到 alternate screen(vim 全屏编辑用的”备用屏幕”)。 (以上 DECSET 参数编号已用 xterm.js VT Features 文档和 XFree86ctlseqs.html交叉核对,属于 xterm 系终端模拟器的通用约定,iTerm2 兼容实现。)
- 正常退出时,vim 会发对应的”关闭”转义序列(
\e[?1000l、\e[?1004l、\e[?1049l)把 iTerm2 的模式改回去。kill -9是SIGKILL,进程没有机会执行任何清理代码,这些”关闭”指令永远没发出去,iTerm2 因此一直卡在”把鼠标/焦点事件转成输入注入 stdin”的模式。 - 花屏的具体成因:鼠标事件被 iTerm2 编码成类似
\e[<0;173;8M的转义序列,走的是和前文回显/信号完全一样的转发链路——写入本地 PTY-A slave(当作你敲的字符)→ ssh 客户端原样加密转发 → sshd 写入远程 PTY-B master → 远程 zsh 读到。zsh 完全不知道这是鼠标事件,只当成命令行文本,转义序列头部的控制字符被吃掉/异常显示,剩下的数字尾巴被当成一条条命令提交执行,于是刷出zsh: command not found: <数字片段>。
处理方式:
- 直接响应 iTerm2 的提示条(点 Yes)关闭它本地识别到的 focus reporting 状态;
- 或在远程 shell 手动发一次”全部关闭”转义序列:
printf '\e[?1000l\e[?1002l\e[?1003l\e[?1006l\e[?1004l\e[?1049l'; - 更省心地直接
reset,它会同时把 termios 也拉回 sane 状态,一次解决两层问题; - 治本:避免直接
kill -9一个 TUI 程序(vim/less/top/tmux 等),优先kill -TERM或在程序内正常退出,给它机会跑清理逻辑。
3.7 PTY pair 的典型样板代码
POSIX 规定了一套标准的 PTY 创建流程(posix_openpt/grantpt/unlockpt/ptsname),几乎所有终端模拟器、ssh 服务端、docker daemon、tmux 内部都是这套流程的变体。下面是简化后的样板(C,来自 POSIX 规范 posix_openpt 手册页的示例并补充了子进程接管终端的常见做法,属于教学性简化代码,实际生产实现会有更多错误处理和平台差异):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <termios.h>
int main(void) {
int masterfd;
char *slave_name;
/* 1. 打开 master 端,得到一个 fd,同时不让它成为当前进程的控制终端 */
masterfd = posix_openpt(O_RDWR | O_NOCTTY);
if (masterfd == -1) exit(1);
/* 2. 授权 + 解锁 slave 设备(否则 slave 打不开),并拿到 slave 路径 */
if (grantpt(masterfd) == -1 || unlockpt(masterfd) == -1) exit(1);
slave_name = ptsname(masterfd); /* 例如 /dev/pts/7 */
pid_t pid = fork();
if (pid == 0) {
/* ---- 子进程:即将成为这个 PTY 的"从端使用者"(如 shell) ---- */
close(masterfd); /* 子进程不需要 master 端 */
setsid(); /* 开一个新会话,脱离原控制终端 */
int slavefd = open(slave_name, O_RDWR);
ioctl(slavefd, TIOCSCTTY, 0); /* 把这个 slave 设为新会话的控制终端 */
dup2(slavefd, STDIN_FILENO); /* 子进程的 stdin/stdout/stderr */
dup2(slavefd, STDOUT_FILENO); /* 全部指向 PTY slave */
dup2(slavefd, STDERR_FILENO);
close(slavefd);
execlp("/bin/bash", "bash", NULL); /* 子进程从此就是 PTY slave 的"住户" */
_exit(1);
}
/* ---- 父进程:持有 master 端,扮演"终端模拟器"角色 ---- */
/* 典型职责:
- 从 masterfd 读字节,画到屏幕上(终端模拟器的渲染循环)
- 把用户按键写进 masterfd(转发给子进程的 stdin)
- 需要时用 tcsetattr()/ioctl(TIOCSWINSZ) 控制这个 PTY 的模式和窗口尺寸 */
char buf[256];
ssize_t n;
while ((n = read(masterfd, buf, sizeof(buf))) > 0) {
write(STDOUT_FILENO, buf, n);
}
return 0;
}
对照理解:
- 父进程 = 终端模拟器(iTerm2/docker 客户端/sshd 主进程扮演的角色),只持有 master fd,负责渲染和转发。
- 子进程 = 被接管的程序(shell/容器进程/远程 bash),通过
dup2把 stdin/stdout/stderr 全部指向 slave fd,从此这个进程认为自己”连在一个真实终端上”。 setsid()+ioctl(TIOCSCTTY)这两步是让 slave 成为子进程新会话的控制终端(controlling terminal)——只有成为控制终端后,Ctrl-C等信号才能被 line discipline 正确路由到这个会话的前台进程组,这也解释了 3.2 节里”信号翻译”依赖的前提条件。- glibc 也提供了封装好的便捷函数
forkpty()(<pty.h>),一次调用把上面的posix_openpt+grantpt+unlockpt+fork+setsid+dup2全部做掉,很多实际项目(包括不少终端模拟器和tmux)会直接用它而不是手写完整流程。 - 这段代码没有处理窗口尺寸同步、错误处理、SIGCHLD 回收等,只用来建立”master/slave 各自角色”的直观认识;对照回看 3.4 节的 SSH 链路图,PTY-A、PTY-B 都是各自宿主机上跑的这样一段逻辑(sshd 侧还要多一层网络转发)。
3.8 程序会不会根据 stdio 类型改变行为:会,而且很常见
这不是理论假设,而是大量真实程序的既有设计——程序可以在运行时用 isatty(fd)(ttyname/fstat 判断字符设备类型也可以达到类似效果)检测自己的 stdin/stdout/stderr 是否连接着真实终端,并据此切换输出格式乃至整体行为:
- stdio 缓冲策略:C 标准库(glibc)对 stdout 的默认缓冲模式取决于
isatty(fd)——连着终端时是行缓冲(遇到\n就刷新,交互体验流畅);重定向到文件或管道时自动切成全缓冲(攒够一块内存才刷新,吞吐更高但看不到实时输出)。这也是为什么some_program | tee log.txt里输出经常”卡住半天才刷一大段”的常见原因,需要程序自己setvbuf(stdout, NULL, _IONBF, 0)强制关闭缓冲,或者外部借助 PTY 伪装成终端来骗过这个判断(script/unbuffer/stdbuf等工具的原理正是”分配一个假 PTY 让程序以为自己连着终端”)。 - 颜色/高亮输出:
git、ls --color=auto、grep --color=auto、大多数现代 CLI 默认只在isatty(stdout)为真时输出 ANSI 颜色转义序列,管道/重定向场景自动降级为纯文本,避免颜色控制字符污染文件或下游程序的解析。 - 进度条/交互式 UI:
pip、docker build、curl(默认进度条)、npm install等在检测到 stdout/stderr 不是 tty 时,会切换成纯日志式的逐行输出,而不是绘制会动态刷新、覆盖同一行的进度条——因为进度条依赖”能随意移动光标重绘”这个终端能力,管道场景下没有意义甚至会产生大量控制字符垃圾。 - 交互式 prompt / 密码输入:很多程序要求 stdin 必须是 tty 才会弹出交互确认或密码输入(例如
sudo、ssh的密码认证、git某些场景),检测到非 tty 时会直接报错或转而读环境变量/参数,避免在脚本化场景卡死等待永远不会来的输入。 - 一屏输出 vs 一行一条:
ls连终端时按列对齐展示;重定向或管道时自动切换成每行一个文件名,方便xargs/awk处理。git log/man/less类命令连终端时会自动起分页器(pager),非交互场景则直接把全部内容吐给 stdout。
这几节讨论的 -t/PTY 分配之所以重要,本质就是为了让远端/容器内的程序在 isatty() 检测时得到”是”的答案,从而触发它为交互场景设计的那套行为(颜色、进度条、行缓冲、prompt);如果不分配 PTY(比如 docker exec 不加 -t、或者脚本化跑 ssh host cmd),这些程序会规规矩矩地表现成”批处理模式”,这也是本文 3.1 节提到”脚本化调用故意不加 -t“背后真正的动机——不是单纯图省事,而是主动选择让程序进入更适合脚本消费的输出模式。
3.9 补充:docker exec -t 到底影响本地还是远程
延续 3.1 的讨论,-t 常被简化理解为”只是让 daemon 侧给容器分配一个 PTY 中继”,但结合 docker/cli 源码(cli/command/container/run.go / exec.go)看,这个理解只对了一半:
-t(config.Tty)本身确实是发给 daemon 的请求参数,语义是”请在容器那一侧分配一个 PTY”,这部分是远程动作,没有错。但 docker 客户端在本地是否要做配合动作,取决于源码里这样一个联合判断(
run.go可见):1 2 3
if config.Tty && dockerCli.Out().IsTerminal() { // 触发 MonitorTtySize + SetRawTerminal }
即:本地是否切 raw、是否监听 resize,由
-t参数 和 本地 stdout 是否为 tty(IsTerminal(),对应isatty())共同决定,二者缺一都不会触发。这与之前讨论的”本地那个 tty 是 zsh fork 子进程时天然继承来的,默认是 cooked 模式”并不矛盾——docker 客户端进程继承的 tty 本身默认是 cooked,raw 化是 docker 客户端主动调用SetRawTerminal做的,不是”自带”的状态,也不是-t之外的独立开关。- 验证前提:如果本地 stdout 不是 tty(比如管道、CI),传
-t会直接报错the input device is not a TTY——这说明-t的可用性依赖本地终端状态,并不是”只管远程、不碰本地”。 - 本地配合的两件事:
SetRawTerminal把继承来的本地 tty 从 cooked 切成 raw,让本地不再自己做行编辑/回显,字节原样透传给 docker 客户端进程,再转发给容器里新分配的 PTY;- 起一个 goroutine 做
MonitorTtySize,监听本地SIGWINCH,通过 API 把新尺寸同步给容器的 PTY(container_resize)。
- 和 SSH 链路完全同构:
docker exec -t里的”docker 客户端”对应 SSH 里的”ssh 客户端”,”容器里新分配的 PTY”对应”远程 sshd 分配的 PTY-B”,”本地 SetRawTerminal”对应”ssh 客户端 tcsetattr 切 raw”,”container_resize API 调用”对应”SSH window-change channel request”。两套机制的分工模式一致:真正的行编辑/回显/信号翻译发生在远端(容器 PTY 或远程 PTY-B),但本地这一端并非被动旁观,而是主动切 raw 配合透传。
更准确的表述:-t 决定”要不要在远端分配 PTY”,这是它的直接语义;但该参数是否能生效、是否触发本地切 raw + 监听 resize,取决于本地终端状态这个独立前提,本地并非”不受影响”。
4. 关键概念
| 概念 | 一句话解释 |
|---|---|
| PTY(pseudo-terminal) | 内核提供的一对虚拟字符设备(master/slave),让”没有真实硬件终端”的场景(远程登录、容器、终端模拟器)也能获得终端语义 |
| line discipline | 挂在 PTY/TTY 驱动和用户态读写之间的可插拔内核模块(默认 N_TTY),负责行编辑、回显、控制字符→信号翻译 |
| termios | 描述一个 tty 当前行为参数的内核结构体,stty/tcsetattr 操作的就是它;raw/cooked 只是它众多参数组合出的常见状态,不是独立开关 |
| cooked(canonical)模式 | line discipline 缓冲整行、支持行内编辑、自动回显,按 \n 提交给上层 |
| raw 模式 | 不缓冲、不编辑、不自动回显,字节直接可读,应用程序自己接管所有按键语义 |
| DECSET/DECRST | 终端模拟器协议层的模式开关指令(CSI ? Pm h/l),管的是鼠标报告、焦点报告、alternate screen 等,和内核 termios 是完全独立的两条轨道 |
| SSH window-change request | SSH 协议里专门的 channel request 类型,用于把本地终端 resize 事件同步给远程 PTY,不是靠字节流里混控制字符实现 |
IsTerminal() / isatty() | 判断一个文件描述符是否连接着真实终端设备的调用;docker exec -t、ssh 分配 PTY 前都会先检查本地这一端满足此条件,否则报错或退化行为 |
| controlling terminal | 一个会话(session)关联的”主控终端”,只有成为控制终端的 PTY slave 才能让 line discipline 把 Ctrl-C 等特殊字符翻译成信号发给该会话的前台进程组;通过 setsid() + ioctl(TIOCSCTTY) 建立 |
forkpty() | glibc 提供的便捷封装,一次调用完成”开 PTY master + fork + 子进程接管 slave 为控制终端”,等价于手写 posix_openpt/grantpt/unlockpt/fork/setsid/dup2 那一整套流程 |
5. 依据、验证与修正
docker exec -i/-t语义:依据 Context7 拉取的/docker/docs官方文档(docker run/docker compose exec参考页)核实,-i保持 stdin 打开、-t分配 PTY 的说法与官方描述一致;-t下 stdout/stderr 共享同一 PTY、不再多路复用这一点,依据 moby API 文档(v1.14/v1.21attach 接口说明)关于”TTY enabled 时是 raw PTY 数据流,disabled 时才按 stream type 头部多路复用”的描述,属于已核实的事实。- line discipline 的分层模型、raw/cooked 行为、信号与窗口尺寸处理:属于 Unix/Linux TTY 子系统的经典设计,讨论中未逐条查阅内核源码或 POSIX termios 规范原文,是基于已确立的通用知识复述,可视为高置信度但未在本次逐条核对一手规范文档的内容。
- SSH 链路的 PTY 分布、window-change 走独立 channel request:符合 SSH 协议(RFC 4254 第 6.7 节定义
window-change是独立的 channel request 类型)的常见描述,本次讨论未直接翻阅 RFC 原文核对,视为个人理解,依据是对 SSH 协议设计的既有认知,未做本轮一手核验。 - DECSET 参数编号(1000/1002/1003/1004/1006/1049):已用 Tavily 搜索交叉核对 xterm.js 官方 VT Features 文档与 XFree86
ctlseqs.html(xterm 控制序列权威参考),两份来源对参数编号和语义一致,视为已验证。 - zsh ZLE / bash readline 在 prompt 状态下切换 tty 到 raw-ish 模式:这是对 shell 行编辑器实现机制的个人理解性推断,未查阅 zsh/readline 源码或官方文档逐条验证,标注为待验证。
- iTerm2 实际弹出的提示文案:来自用户提供的真实截图,是一手材料,可直接采信。
docker exec -t本地/远程分工:已用 GitHub 源码搜索定位到docker/cli仓库cli/command/container/run.go中if config.Tty && dockerCli.Out().IsTerminal()这一判断逻辑,确认”本地是否切 raw + 监听 resize”由-t参数与本地IsTerminal()联合决定,视为已用一手源码核验;但未逐行读完整个SetRawTerminal/MonitorTtySize实现细节,具体调用时序仍是基于搜索结果片段的合理推断。- PTY pair 样板代码:
posix_openpt/grantpt/unlockpt/ptsname这套核心 API 及其示例代码依据 POSIX.1-2017 官方规范(man7.org收录的posix_openpt(3p)手册页,引用了 IEEE/The Open Group 版权文本)核实,视为已验证;setsid+ioctl(TIOCSCTTY)+dup2接管控制终端的写法是根据 Unix 编程通用实践补充的教学性简化代码,未逐条对照 Linuxtty_ioctl(4)手册页核验每个ioctl参数的确切行为,标注为待验证;forkpty()的存在和作用为既有认知复述,未查阅 glibc 手册原文核对签名细节。 - 程序依据 stdio 类型改变行为:stdio 缓冲策略(终端下行缓冲、非终端下全缓冲)依据 glibc
stdio手册页描述及多篇技术博客交叉印证(”Buffering in pipes”、setvbuf手册页摘录),视为已验证;git/ls --color=auto/进度条工具等依据isatty()切换颜色/进度条/分页器行为的具体列举,属于对常见 CLI 工具行为的个人观察总结,未逐一查阅每个工具的源码或官方文档确认判断逻辑的实现细节,标注为待验证(但这是广泛可复现的现象,可自行用| cat重定向验证)。
6. 不确定点
- SSH
window-changerequest 的协议细节(字段格式、是否所有 SSH 客户端/服务端实现都遵循同一行为)未查阅 RFC 4254 原文核对。 - zsh ZLE 具体在什么时机、以什么 termios 参数组合切换模式(是完全 raw 还是 cbreak 变体),未看 zsh 源码,只是合理推断。
- 未验证 iTerm2 对 DECSET 1000/1002/1003 系列鼠标协议的具体兼容范围(是否所有子模式都支持),只确认了 1004(focus)和 1049(alt screen)在本次案例里确实被触发。
- 本文只覆盖了 xterm 系终端模拟器(iTerm2 兼容)的行为,未涉及其他终端模拟器(如 Windows Terminal、GNOME Terminal)在协议层实现上是否存在差异。
docker/cli的SetRawTerminal/MonitorTtySize具体实现(如是否有额外的错误处理、exec场景和run场景的代码路径是否完全一致)未逐行通读源码,只核对了触发条件的那一行判断逻辑。- 3.7 节样板代码里
setsid()/ioctl(TIOCSCTTY)的具体行为未逐条对照 Linuxtty_ioctl(4)手册页核验,只是通用编程实践的复述;未在真实机器上编译运行验证这段代码能否直接工作。 - 3.8 节列举的具体工具(
git/ls/pip/docker build等)判断isatty()后切换行为的说法,未逐一查阅各工具源码确认,是基于可复现观察的归纳,不排除个别版本/配置下行为有差异。
7. 复习索引
- 一句话记忆:
-i管 stdin 有没有数据,-t管这条 stdin/stdout/stderr 是不是终端;两者独立,交互式操作一般要一起开。 - 易混点:
-t开启后 stdout/stderr 会合并成一路 PTY 数据流,不是”只影响输出好不好看”。 - 易混点:line discipline 不是被动转发,是内核里主动拦截/解释字节流的一层(回显、行编辑、信号翻译、窗口尺寸都由它触发,不是终端模拟器自己做的)。
- 易混点:SSH 链路里本地和远程各有一个独立 PTY,本地那个在 ssh 客户端运行期间被切成 raw、退化成纯管道,真正的终端语义发生在远程那一层。
- 易混点:回显、Ctrl-C 信号在 SSH 场景下都是远程产生的效果,往返了一次网络,不是本地终端自己处理的。
- 关键区分:TTY/termios(内核层,管字节语义)vs DECSET(终端模拟器协议层,管鼠标/焦点/alt screen),两者都可能因进程被
kill -9而残留脏状态,但清理手段不同——stty sane/reset治 termios,发对应 DECRST 序列或reset治终端模拟器状态。 - 急救命令:终端花屏时先试
reset,一次性把 termios 和常见终端模式都拉回默认。 - 易混点:
docker exec -t不是”只影响远程”。-t的直接语义是请求容器侧分配 PTY(远程),但本地是否切 raw、监听 resize,由-t参数 + 本地IsTerminal()联合触发,本地 tty 默认是 shell 继承来的 cooked 状态,raw 化是 docker 客户端主动做的,和ssh客户端的行为完全同构。 - 易混点:程序检测 stdio 类型不是”理论上可以”,而是标准库和大量常用 CLI 的默认行为——
isatty()一次判断,决定了缓冲策略(行缓冲 vs 全缓冲)、要不要输出颜色/进度条、要不要起分页器、要不要弹交互 prompt。分配 PTY(-t)的本质作用之一就是让远端程序在这个判断里得到”是”。 - 样板代码骨架:
posix_openpt开 master →grantpt+unlockpt解锁 →ptsname拿 slave 路径 →fork→ 子进程setsid+open(slave)+ioctl(TIOCSCTTY)+dup2接管为控制终端 →exec目标程序;父进程持有 master,负责读写转发与tcsetattr/ioctl(TIOCSWINSZ)控制。glibc 的forkpty()是这一整套流程的封装。
8. 下一步
- 若需要更硬的依据,可查 POSIX
termios(3)规范原文和 Linuxtty_ldisc内核文档,替换掉当前”高置信度复述”的部分。 - 可以翻一遍 RFC 4254 第 6.7 节,确认
window-changerequest 的具体字段和各实现的一致性。 - 有兴趣可以用
strace/ltrace实际跟踪一次ssh建连和 vim 启动过程,抓到真实的tcsetattr/ioctl(TIOCSWINSZ)/写转义序列调用,把本文里的”理解”替换成实测证据。