第 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) ptyFork 模式——父取 master,子集 sid 后开 slave 变 ctty;(3) master/slave 双向字节 relay(如 script.c);(4) Packet mode 用 exceptional 通知 flow-control 事件;(5) pty 是 ssh/telnet/screen/script 共同的内核基石。

      一、核心概念

      本章围绕 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 三件套

      posix_openpt(flags)= open /dev/ptmx 取 master;grantpt(mfd) 改 slave 属主(Linux 上是 no-op,标准化调用仍要做);unlockpt(mfd) 解锁才能 open slave;ptsname(mfd) 得 slave 名字 /dev/pts/N;TLPI ptyMasterOpen 封装。

      §64.2;flags 一般 O_RDWR | O_NOCTTY;动态 slave 名 /dev/pts/0..N;System V-style,Linux 2.6.4 起 /proc/sys/kernel/pty/max 控制上限(默认 4096)。

      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 也提供 openpty()forkpty()

      packet mode (TIOCPKT)

      ioctl(mfd, TIOCPKT, 1) 启用;后续 read master 收到两类数据:(1) 0xx 控制字节为位掩码(flush、flow-control 状态变化);(2) 0 + 数据表示 slave 写入;select 报 exceptional(poll POLLPRI)。

      §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 风格:成对预创 /dev/ptyXY master + /dev/ttyXY slave(X ∈ p-za-e, Y ∈ 0-9a-f);约 256 对;Linux 2.6.4 起 CONFIG_LEGACY_PTYS 才启用;新代码优先 UNIX 98。

      §64.8;BSD pty 没有 grantpt 等价物;非 ptmx clone open;TLPI Listing 64-4 给出对等 ptyMasterOpen BSD 实现。

      二、详细笔记

      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

      Exampleecho 65536 > /proc/sys/kernel/pty/max(运行时)。

      64.4 ptyFork:连接两个进程

      WhatptyFork(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)

      Whatioctl(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 共享同一份 termioswinsize;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 实现。

      三、关键图表

      关键函数
      函数 头文件 用途

      posix_openpt

      <stdlib.h>

      开 unused master (实为 open /dev/ptmx)

      grantpt

      <stdlib.h>

      改 slave 属主 / 权限

      unlockpt

      <stdlib.h>

      解 slave 锁

      ptsname

      <stdlib.h>

      取 slave 名 (/dev/pts/N)

      ptyMasterOpen

      "pty_master_open.h"

      TLPI 封装 UNIX 98 + BSD

      ptyFork

      "pty_fork.h"

      TLPI 封装 fork+setsid+slave+ctty+dup2

      openpty / forkpty

      <pty.h> (glibc)

      BSD 风格简化版

      ioctl TIOCSCTTY

      <sys/ioctl.h>

      显式设 ctty(BSD 必要)

      ioctl TIOCPKT

      <sys/ioctl.h>

      启 / 关 packet mode

      ioctl TIOCSWINSZ / TIOCGWINSZ

      <sys/ioctl.h>

      设 / 读 winsize

      master-slave 关系
      方向 行为

      master 写入

      出现在 slave 输入

      slave 写入

      出现在 master 输出(driver 端可读)

      master 关闭全部 fd

      slave 读 EOF;写失败 EIO;controlling process 收 SIGHUP

      slave 关闭全部 fd

      master 读失败 EIO;master 写仍可成功暂存

      canonical mode 下

      input 在 slave 输出端 line-buffered

      容量

      单方向 ~ 4096 字节 (Linux)

      四、思维导图

      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

      五、重点与易错点

      1. master 关闭会 SIGHUP 给 slave ctty 进程组——写 sshd 时必须显式让对端 shell 退出(推荐 master 关闭而非简单 kill),客户端断网时 shell 自然收 SIGHUP 死亡。

      2. slave 必须先 open 才能 ioctl(TIOCSCTTY);BSD 下没 TIOCSCTTY 就开不出 controlling tty。

      3. ptyFork() setsid 必须在 fork 后立即调,否则子进程没失去 ctty 时 open slave 不会自动变 ctty。

      4. 不要忘记 grantpt / unlockpt——SUSv3 要求;虽然 Linux 自动 grant + unlock,可移植程序必须调,glibc 不会悄悄替你做。

      5. posix_openpt 是 POSIX 标准 API,实际 Linux 就是 open("/dev/ptmx", flags)——直接 open 也行,但不是可移植方式。

      6. slave 默认 canonical mode——driver 写 ^C 会被解释为 SIGINT;要正确转发 SIGINT 用 raw / packet mode。

      7. pty 容量限为 ~4 KB——driver 在 master 端 write 大数据要分片,否则阻塞;ssh 一般按 4K 内 split。

      8. packet mode 非 SUSv3——telnet 服务器跨 UNIX 有差异;优先 ETM (Escape Telnet Mode) RFC 854 协议而非 pty packet mode。

      9. TIOCSWINSZ 会向 slave 端 ctty 进程组发 SIGWINCH——子 shell / 程序通常装 handler 重新 query TIOCGWINSZ 重画。

      10. script 程序推荐 atexit 恢复 termios——raw mode 退出,否则 shell 退出后 user 的父终端卡 raw。

      11. script 父必须大写 winsize 同步给 master——否则子 vim 显示错。

      12. pty 在 SSH / firewall 上要慎用 packet mode——packet mode 的 0xx 控制字节会被混淆;现代 SSH 直接在用户协议层嵌入 IAC 而非依赖 TIOCPKT。

      13. ptyFork 后子进程 close(mfd)——否则 master 引用计数 > 1,master 关闭永不收 SIGHUP,子端孤儿。

      14. BSD pty 数量上限 256(CONFIG_LEGACY_PTYS);UNIX 98 默认 4096,最大 1048576——大量 ssh 终端部署务必调大 pty/max

      15. pty 应对 TIOCPKT 时 select 通知 exceptional(POLLPRI);不是 readable——poll 代码 EPOLLPRI/epoll EPOLLPRI 同样适用。

      16. 跨章衔接:第 56-61 章 socket 是 driver 端的协议;第 62 章 termios 全部可作用在 slave 上;第 63 章 select/epoll/poll 用于 driver 双向 relay;第 4 章 SO_PEERCRED 用于 ptyFork 后验证对端身份。