第 64 章 伪终端 (Pseudoterminals)
核心结论
-
伪终端 (pty) = 双向虚拟字符设备对:master 与 slave 是两个 fd;slave 对应用程序表现为「真终端」——支持 canonical mode、tcsetattr、TIOCCONS、SIGINT/C 等。master 由驱动/服务端读写,模拟用户键盘输入。
-
pty 的最经典用途是「套个真程序跨网络」:ssh、telnet、script、screen、xterm 等都是「driver 写 master → 内核 pty slave → 程序读 stdin,反向同理」结构;驱动用 select/poll/epoll 来回 relay。
-
UNIX 98 pty 三件套:
posix_openpt(O_RDWR | O_NOCTTY)开 master →grantpt改 slave 属主 / 权限 →unlockpt解除内核锁 →ptsname取/dev/pts/N名(Linux 上 posix_openpt 实质就是open("/dev/ptmx", flags))。 -
ptyFork = fork + setsid + open slave + dup2 to 0/1/2:父进程拿 master fd、子 open slave(成为 controlling tty)后 dup2 成 stdin/stdout/stderr;BSD 下还需 ioctl(TIOCSCTTY) 显式设控制终端。
-
Packet mode (TIOCPKT) 用于 telnet/rlogin:在 master 上启 TIOCPKT,内核把 flow control / flush 状态变化合并为 0xx 控制字节通知 driver,select 把 ready 报为 exceptional(POLLPRI)。
-
pty 与 pipe / pipe buffer 限制:Linux pty 单方向容量约 4 KB;master 全关 → slave 读 EOF、写 EIO;slave 全关 → master 读 EIO、写可能成功(Linux 暂存,待 slave 重开)。
|
本章主旨
本章是 socket / 终端 两章之后的「桥接」——如何让任何终端化程序(vi、bash、ssh 内 shell)通过「伪终端对」间接与外部通信。读者需掌握:(1) UNIX 98 pty 三件套与 BSD pty 的差异;(2) |
一、核心概念
本章围绕 6 个核心概念:master/slave 对、pty 三件套 (posix_openpt/grantpt/unlockpt)、ptyFork、slave 变 ctty、packet mode、relay 模型 (script.c)。
| 概念 | 定义 + 重要性 | 实现提示 |
|---|---|---|
Master 与 Slave 对 |
pty master(被 driver 端读写)+ pty slave(被终端化程序当 stdin/stdout/stderr)的虚拟字符设备对;任何对 master 写都出现在 slave 输入(反之亦然);slave 行为完全等同真终端。 |
§64.1;slave 可 tcsetattr、tty 相关 ioctl;master 不能成为 controlling tty;Linux pty 容量约 4 KB。 |
UNIX 98 pty 三件套 |
|
§64.2;flags 一般 |
ptyFork() 模板 |
父 ptyMasterOpen 取 mfd → fork → 父返 mfd + pid;子 setsid()(变新 session leader + 丢旧 ctty)→ close mfd → open slave(自动成 ctty;BSD 上 ioctl(TIOCSCTTY))→ dup2 slave 到 0/1/2 → exec;可设 termios / winsize。 |
§64.4;TLPI Listing 64-2 给完整实现;GLIBC 也提供 |
packet mode (TIOCPKT) |
|
§64.5;非 SUSv3;用于 telnet / rlogin 透明处理 S/Q;BSD pty 不支持 packet mode。 |
script(1) 模型 |
driver 进程 = 父 = relay server;终端化程序 = 子 = shell;script 在 stdin + master 间 select,stdin 输入 → 写 master;master 输出 → 写 stdout + typescript 文件;atexit 恢复 termios。 |
§64.6;TLPI Listing 64-3 完整实现;支持 raw mode + SIGWINCH 自动 propagete。 |
BSD pty 与内核限制 |
旧 BSD 风格:成对预创 |
§64.8;BSD pty 没有 grantpt 等价物;非 ptmx clone open;TLPI Listing 64-4 给出对等 |
二、详细笔记
64.1 pty 概览与典型应用
What:pty 是内核提供的虚拟字符设备对(master / slave),模拟「真实终端」给任何要求 controlling-tty 的程序。
Why:(1) 让 socket 不能直接接入终端化程序——vi、bash 期望 tty 行为;(2) 跨主机网络登录(ssh / telnet);(3) script / screen / expect 等"中间人"程序;(4) 把 stdin/stdout 不接 tty 的进程变 tty(stdio 全缓冲 → 行缓冲)。
How:
| 应用 | driver 端 | terminal 端 | relay 模型 |
|---|---|---|---|
ssh |
sshd 子进程 |
login shell |
master ↔ ssh socket |
telnet |
telnetd |
login shell |
master ↔ telnet socket |
script(1) |
script 父进程 |
子 shell |
master ↔ tty + typescript |
screen(1) |
screen |
每个 shell window |
多 master ↔ 1 tty |
expect(1) |
expect driver |
任何交互程序 |
master ↔ 脚本 |
xterm |
xterm |
子 shell |
master ↔ X server |
When:(1) 写网络登录服务 → pty + 加密 (ssh);(2) 自动化驱动交互程序 → pty + expect;(3) 录制 shell session → script。
Example:经典 sshd fork 出 sshd child → ptyFork → bash via slave → bash output 经 master 到 ssh 加密 → ssh client → xterm pty。
64.2.1 posix_openpt / grantpt / unlockpt / ptsname
What:UNIX 98 pty 标准四件套。
Why:BSD 风格依赖静态 /dev/ptyXY 设备,已过时;UNIX 98 通过 /dev/ptmx 复用 + 动态创建 slave,可与文件系统解耦。
How:
#define _XOPEN_SOURCE 600
#include <stdlib.h>
#include <fcntl.h>
int posix_openpt(int flags); /* O_RDWR | O_NOCTTY */
int grantpt(int mfd);
int unlockpt(int mfd);
char *ptsname(int mfd); /* 静态缓存名 /dev/pts/N */
-
posix_openpt实际是open("/dev/ptmx", flags)——Linux 上 ptmx 是 clone device,每次 open 取下一个空闲 slave。 -
grantpt通常 fork 跑 setuid pt_chown 改属主与权限(Linux 上是 no-op,但调用是标准做法)。 -
unlockpt解锁后才能成功 open slave。 -
ptsname返回/dev/pts/N;*_r是 GNU 扩展。
When:(1) 写 portable pty 程序 → 必调这 4 个函数;(2) Linux-only 现代程序也可直接 open("/dev/ptmx", O_RDWR)。
Example:TLPI Listing 64-1 ptyMasterOpen 完整封装。
64.2.4 pty 数量限制
What:Linux 2.6.4 前 CONFIG_UNIX98_PTYS 编译期;之后 /proc/sys/kernel/pty/max 动态(默认 4096,最大 1048576),/proc/sys/kernel/pty/nr 显示当前使用。
Why:限制内核资源 — 每个 pty 占少量非交换内存。
When:(1) SSH / xterm 集中部署上千用户 → 调大 max;(2) 监控写入失败 ENOSPC。
Example:echo 65536 > /proc/sys/kernel/pty/max(运行时)。
64.4 ptyFork:连接两个进程
What:ptyFork(int *masterFd, char *slaveName, size_t snLen, const termios *slaveTermios, const winsize *slaveWS) 一调用完成「父拿 master fd、子 open slave + 变 ctty + dup2 到 0/1/2」。
Why:每个 pty driver 都要写这一样板;用封装函数让 ssh、telnet、expect、script 等复用。
How(TLPI Listing 64-2 核心流程):
// 摘自《The Linux Programming Interface》 第 64 章 Listing 64-2
pid_t ptyFork(int *masterFd, char *slaveName, size_t snLen,
const struct termios *slaveTermios, const struct winsize *slaveWS)
{
int mfd, slaveFd, savedErrno;
pid_t childPid;
char slname[MAX_SNAME];
mfd = ptyMasterOpen(slname, MAX_SNAME);
if (mfd == -1) return -1;
if (slaveName != NULL) {
if (strlen(slname) < snLen) strncpy(slaveName, slname, snLen);
else { close(mfd); errno = EOVERFLOW; return -1; }
}
childPid = fork();
if (childPid == -1) { close(mfd); return -1; }
if (childPid != 0) { /* Parent */
*masterFd = mfd;
return childPid;
}
/* Child: become session leader, acquire controlling tty on pty slave */
if (setsid() == -1) errExit("setsid");
close(mfd);
slaveFd = open(slname, O_RDWR);
if (slaveFd == -1) errExit("open-slave");
#ifdef TIOCSCTTY
if (ioctl(slaveFd, TIOCSCTTY, 0) == -1) errExit("ioctl-TIOCSCTTY");
#endif
if (slaveTermios != NULL)
if (tcsetattr(slaveFd, TCSANOW, slaveTermios) == -1) errExit("tcsetattr");
if (slaveWS != NULL)
if (ioctl(slaveFd, TIOCSWINSZ, slaveWS) == -1) errExit("ioctl-TIOCSWINSZ");
if (dup2(slaveFd, STDIN_FILENO) != STDIN_FILENO) errExit("dup2-STDIN");
if (dup2(slaveFd, STDOUT_FILENO) != STDOUT_FILENO) errExit("dup2-STDOUT");
if (dup2(slaveFd, STDERR_FILENO) != STDERR_FILENO) errExit("dup2-STDERR");
if (slaveFd > STDERR_FILENO) close(slaveFd);
return 0;
}
When:(1) sshd、telnetd、rlogind、script、screen、xterm 用 ptyFork 模板;(2) GLIBC 还提供 openpty() + forkpty(),BSD 历史遗留。
Example:glibc forkpty() 与 ptyFork 类似,但 openpty 不返 slave name。
64.5 pty I/O 语义
What:pty 像双向 pipe,但 slave 等同终端——会解释 LF/CR、触发 SIGINT、line-buffered(default canonical mode)。
Why:(1) 知道何时写控制字符能产生信号;(2) 知道 master 关闭会触发 slave SIGHUP(登录服务遗弃时给对端 shell 发 SIGHUP 让其清理)。
How:
| 事件 | master 关闭(fd 全部释放) | slave 关闭(fd 全部释放) |
|---|---|---|
读 slave |
read() 返 EOF |
(n/a) |
写 slave |
write() 失败 EIO(某些 UNIX: ENXIO) |
(n/a) |
读 master |
(n/a) |
read() 失败 EIO(某些 UNIX: 返 EOF) |
写 master |
(n/a) |
write() 可能成功;slave 重开可读回(Linux 行) |
信号 |
若 slave 有 controlling process → 发 SIGHUP |
— |
容量约 4KB(Linux);写满阻塞直到对端 consume。
When:(1) 写 ssh server 时关闭 master 前要确认让对端 shell 收到 SIGHUP(推荐走 kill(0));(2) 监测 master read EOF → 客户端断线;(3) 期望缓冲总字节 ≤ 4 KB,否则阻塞。
Example:TLPI demo:master 关闭 → kill -0 验证 shell 死;同时阻塞 sshd 端 write 直到用户断。
64.5.1 Packet mode (TIOCPKT)
What:ioctl(mfd, TIOCPKT, 1) 启用 packet mode。后续 read(mfd, buf, n) 收两类:
. 控制字节:非零位掩码(TIOCPKT_FLUSHREAD、TIOCPKT_FLUSHWRITE、TIOCPKT_STOP、TIOCPKT_START、TIOCPKT_IOCTL 等)通知 flow-control / ioctl 状态。
. 数据:0 + 实际数据(slave write 触发)。
select 把 packet mode 通知报为 exceptional(poll 报 POLLPRI)。
Why:让 driver 知道 slave 上发生了 IXON 字符等非「纯数据」事件——telnet、rlogin 通常把这些 S/Q 透明转义。
How:
int arg = 1;
ioctl(mfd, TIOCPKT, &arg); /* 启;0 关 */
/* 主循环:read → first byte 0 → 后面是 slave 写入数据 */
/* first byte != 0 → 控制信息,丢弃 */
fd_set rfds, xfds;
FD_SET(mfd, &rfds);
FD_SET(mfd, &xfds); /* 监听 packet 通知 */
select(mfd+1, &rfds, NULL, &xfds, NULL);
if (FD_ISSET(mfd, &xfds)) { /* poll()'s POLLPRI */
/* 重新读 control byte */
}
When:(1) telnet 服务器(实现 IAC + DO/DONT negotiation);(2) ssh 老式客户端。
Example:telnetd、rlogind 内部实现都用 packet mode 防 ^S 字符被误当作 payload。
64.6 script(1) 实现:relay 模式的样板
What:script 程序是 pty relay 的最小范本——父 = script、子 = shell;script 转发 stdin ↔ master,并把 master 输出写到 stdout + typescript。
Why:(1) 演示 pty 与普通 terminal ioctl 的差异;(2) 演示 SIGWINCH 如何 transparently propagation 给子 shell。
How(TLPI Listing 64-3 核心):
// 摘自《The Linux Programming Interface》 第 64 章 Listing 64-3 (核心循环)
if (tcgetattr(STDIN_FILENO, &ttyOrig) == -1) errExit("tcgetattr");
if (ioctl(STDIN_FILENO, TIOCGWINSZ, &ws) < 0) errExit("ioctl-TIOCGWINSZ");
childPid = ptyFork(&masterFd, slaveName, MAX_SNAME, &ttyOrig, &ws);
/* child */
/* execlp(shell, shell, (char*)NULL); */
/* parent */
scriptFd = open(argv[1] ? argv[1] : "typescript", O_WRONLY|O_CREAT|O_TRUNC, 0666);
ttySetRaw(STDIN_FILENO, &ttyOrig); /* 关 term 处理 */
atexit(ttyReset); /* 退出恢复 */
for (;;) {
FD_ZERO(&inFds);
FD_SET(STDIN_FILENO, &inFds);
FD_SET(masterFd, &inFds);
select(masterFd + 1, &inFds, NULL, NULL, NULL);
if (FD_ISSET(STDIN_FILENO, &inFds)) {
numRead = read(STDIN_FILENO, buf, BUF_SIZE);
if (numRead <= 0) exit(EXIT_SUCCESS);
write(masterFd, buf, numRead);
}
if (FD_ISSET(masterFd, &inFds)) {
numRead = read(masterFd, buf, BUF_SIZE);
if (numRead <= 0) exit(EXIT_SUCCESS);
write(STDOUT_FILENO, buf, numRead);
write(scriptFd, buf, numRead);
}
}
要点: . 父进 raw mode:关 echo / canonical,input 直接 relay 给 master;shell 也在 slave 上 tcsetattr 时保留 user 当前 termios(raw or cooked)。 . atexit 恢复 termios:避免 shell 退后 window 自己 terminal 卡死。 . SIGWINCH propagation:script 监听 SIGWINCH → ioctl(TIOCGWINSZ) 拿新 winsize → ioctl on master → 触发 slave 的 SIGWINCH。
When:(1) 写终端调试器(strace 能跟 shell);(2) 写 pexpect 自动化框架;(3) 写 telnet/ssh 调试 client。
Example:实用 /tmp:script 用 script typescript.txt 后 vim 可在 typescript 里回放。
64.7 Termios 与 Winsize 共享
What:master 与 slave 共享同一份 termios 与 winsize;tcsetattr/tcgetattr 任何一个 fd 都影响内核同一个记录;master ioctl(TIOCSWINSZ) 自动 propagate SIGWINCH 给 slave 端前台进程组。
Why:(1) driver 可改 slave 端 term 设置(如 script 复制原 term 给 slave);(2) resize 一致——「子 shell 看到的窗大小 = xterm resize 后的真实大小」。
How:
/* 父侦 SIGWINCH */
sa.sa_handler = sigwinch_handler;
sigaction(SIGWINCH, &sa, NULL);
/* handler 内:
ioctl(STDIN_FILENO, TIOCGWINSZ, &ws);
ioctl(masterFd, TIOCSWINSZ, &ws); // 自动 propagate
*/
When:(1) 写 screen multiplexer → 必须 propagete SIGWINCH;(2) tmux、byobu 都这么做;(3) remote desktop 类似机制。
Example:vim 用 TIOCGWINSZ 拿 window size;xterm resize 时 SIGWINCH → script → 设置 master winsize → slave 进程组 SIGWINCH → vim 重新读 → 重新布局。
64.8 BSD pty 简介
What:BSD 风格 pty 是成对静态设备 /dev/ptyXY + /dev/ttyXY(X ∈ [p-za-e], Y ∈ [0-9a-f],256 对上限);不再推荐新代码使用。
Why:历史程序 / 跨 port BSD 时可能碰到;了解一下避免混淆。
How:
/* BSD 试探 */
for (x = "pqrstuvwxyzabcde"; *x; x++) {
for (y = "0123456789abcdef"; *y; y++) {
snprintf(name, sizeof(name), "/dev/pty%c%c", *x, *y);
masterFd = open(name, O_RDWR);
if (masterFd >= 0) goto found;
if (errno == ENOENT) goto done; /* 跑完 */
/* EIO 等忽略继续 */
}
}
found:
snprintf(slaveName, ..., "/dev/tty%c%c", *x, *y);
When:(1) 维护老 SSH / telnet 实现可能碰到 /dev/ttyp0 等;(2) 现代程序优先 UNIX 98。
Example:TLPI Listing 64-4 BSD ptyMasterOpen 实现。
三、关键图表
|
关键函数
|
|
master-slave 关系
|
四、思维导图
mindmap
root((第 64 章 伪终端))
pty 概览
master slave 对
虚拟字符设备
等同真终端
应用
ssh telnet
script screen
xterm expect
UNIX 98 pty
posix_openpt
grantpt unlockpt
ptsname
dev pts N
ptyFork
parent 拿 master
child setsid open slave
dup2 stdin out err
exec
pty I/O 语义
双 pipe
4KB 容量
closed signal
packet mode
TIOCPKT
控制字节
select 报 except
winsize 同步
SIGWINCH 父 监听
TIOCSWINSZ master
propagate slave
BSD pty
dev ptyXY
静态设备
legacy
五、重点与易错点
-
master 关闭会 SIGHUP 给 slave ctty 进程组——写 sshd 时必须显式让对端 shell 退出(推荐 master 关闭而非简单 kill),客户端断网时 shell 自然收 SIGHUP 死亡。
-
slave 必须先 open 才能 ioctl(TIOCSCTTY);BSD 下没 TIOCSCTTY 就开不出 controlling tty。
-
ptyFork() setsid 必须在 fork 后立即调,否则子进程没失去 ctty 时 open slave 不会自动变 ctty。
-
不要忘记 grantpt / unlockpt——SUSv3 要求;虽然 Linux 自动 grant + unlock,可移植程序必须调,glibc 不会悄悄替你做。
-
posix_openpt 是 POSIX 标准 API,实际 Linux 就是
open("/dev/ptmx", flags)——直接 open 也行,但不是可移植方式。 -
slave 默认 canonical mode——driver 写 ^C 会被解释为 SIGINT;要正确转发 SIGINT 用 raw / packet mode。
-
pty 容量限为 ~4 KB——driver 在 master 端 write 大数据要分片,否则阻塞;ssh 一般按 4K 内 split。
-
packet mode 非 SUSv3——telnet 服务器跨 UNIX 有差异;优先 ETM (Escape Telnet Mode) RFC 854 协议而非 pty packet mode。
-
TIOCSWINSZ 会向 slave 端 ctty 进程组发 SIGWINCH——子 shell / 程序通常装 handler 重新 query TIOCGWINSZ 重画。
-
script 程序推荐 atexit 恢复 termios——raw mode 退出,否则 shell 退出后 user 的父终端卡 raw。
-
script 父必须大写 winsize 同步给 master——否则子 vim 显示错。
-
pty 在 SSH / firewall 上要慎用 packet mode——packet mode 的 0xx 控制字节会被混淆;现代 SSH 直接在用户协议层嵌入 IAC 而非依赖 TIOCPKT。
-
ptyFork 后子进程 close(mfd)——否则 master 引用计数 > 1,master 关闭永不收 SIGHUP,子端孤儿。
-
BSD pty 数量上限 256(CONFIG_LEGACY_PTYS);UNIX 98 默认 4096,最大 1048576——大量 ssh 终端部署务必调大
pty/max。 -
pty 应对 TIOCPKT 时 select 通知 exceptional(POLLPRI);不是 readable——poll 代码 EPOLLPRI/epoll EPOLLPRI 同样适用。
-
跨章衔接:第 56-61 章 socket 是 driver 端的协议;第 62 章 termios 全部可作用在 slave 上;第 63 章 select/epoll/poll 用于 driver 双向 relay;第 4 章 SO_PEERCRED 用于 ptyFork 后验证对端身份。