64 伪终端

基本认识

伪终端解决这样的问题:远程登录等场合,用户并不能和真正的终端进行交互,而且信息也不能简单通过 socket 转发,因为很多(面向终端的)应用程序是假设了有控制终端的。应用了伪终端的程序包括:script(1)(能录制本次交互程序的全部用户输入和用户能看到的输出)、screen(1)、expect(1)(在 一个可以用来测试某交互程序的是否如期运行的工具,在 Debian 上需要额外安装)、xterm 等终端模拟器

伪终端很像一个双向管道。主设备写入、从设备可以读;从设备写入、主设备可以读。在从设备侧,伪终端表现得就和真实终端一样:所有在终端上能使用的系统调用在伪终端上都不会报错,对伪终端无意义的设置也会被忽略。因此,驱动程序(接收用户输入)面向主设备,应用程序面向从设备(它们对自己使用的是真实终端还是伪终端可能并不知情)。

System V (UNIX 98) 伪终端

  • posix_openpt() 函数可以打开一个未使用的伪终端主设备,返回其 fd。
  • grantpt() 可用于修改对应的从设备的属组和权限。
  • unlockpt() 用于解锁从设备,这样从设备就能被打开了(这是为了应对竟态条件)。
  • ptsname() 函数返回主设备对应的从设备名称。

posix_openpt()O_NOCTTY 标志可以使得伪终端主设备不成为当前进程的控制终端,在 Linux 上是默认启用的;O_RDWR 标志以读写方式打开,一般开发者都会指定此标志。最初 System V 终端实现中,获取主设备是通过打开伪终端主克隆设备 /dev/ptmx 实现的。也就是说,posix_openpt() 完全可以被实现为:

int
posix_openpt(int flags)
{
    return open("/dev/ptmx", flags);
}

对伪终端数量在 /proc/sys/kernel/pty/max 中可查(我系统里面是 4096),还有一个文件 /proc/sys/kernel/pty/nr 用来记录当前有多少伪终端设备被打开。每次打开一个新的伪终端,在 /dev/pts/ 文件夹下就会多一个以数字命名的伪终端从设备,而 /proc/sys/kernel/pty/nr 的计数会 +1。

grantpt() 一般被用来修改从设备属主为当前进程有效用户 ID,修改从设备组为 tty(原因举例:wall(1) 和 write(1) 是 set-group-ID 程序,组为 tty,因此如果我们不修改从设备的属组,这两个程序就无法访问伪终端),修改其权限为拥有者可读写、组可写。这些操作在 Linux 上默认执行,但是为了可移植性最好显式执行。

ptsname() 是不可重入的,GLIBC 中提供了可重入版本 ptsname_r(),必须定义 _GNU_SOURCE 来访问。在 Linux 上,伪终端从设备名称是 /dev/pts/xx,其中 /dev/pts 是叫做 devpts 的特殊的文件系统。

给人的感觉是:主设备都是 /dev/ptmx,从设备在 /dev/pts/ 文件夹下各有一个名字。

/dev/tty* 和 /dev/console

/dev/tty* 是什么?都已经有 /dev/ptmx 了,为啥 /dev 下面还有很多 tty 文件?这些虚拟终端应该是系统在字符模式下使用的(不是图形模式),参考 https://www.tecmint.com/linux-tty-tty0-and-console/ 。其中 /dev/tty0 是默认终端。

/dev/console 是系统控制台(system console),它将内核的控制台暴露给了用户空间。

伪终端 I/O

如果伪终端主设备关闭

伪终端主设备上的读写具有和管道类似的特性,然后还有和终端类似的特性,理解了这两点,下面的行为就好理解了。

如果主设备关闭:

  1. 从设备上的 read() 会返回文件结尾 EOF。(比较:和管道上是一样的。)
  2. 从设备上的 write() 会失败,错误码为 EIO(有些 UNIX 实现上错误码是 ENXIO)。(比较:管道上面是收到 SIGPIPE 并有 EPIPE 错误码。)
  3. 如果从设备有控制进程,那么控制进程会收到 SIGHUP。(比较:这个是终端的特性。)

如果伪终端从设备关闭

和管道有差异。

  1. 主设备上的 read() 会失败,错误码为 EIO(有些 UNIX 实现上是返回 EOF)。
  2. 主设备上的 write() 可以正常进行,除非缓冲区已经写满,这时调用者会被阻塞。这是因为从设备可以被其他进程继续打开,只要主设备还在,从设备重新打开就能再次连上。但是在有的 UNIX 实现中,write() 会失败并设置 EIO 错误码,有的 write() 会成功但是不会实际写入任何字节。

由于 UNIX 实现差异很大,一般建议用 read() 判断从设备是否还在,如果不在就不要继续写了。

信包(Packet)模式

信包模式允许将从设备上软流控事件以数据读取的方式传输给主设备控制进程。在这个模式下,从主设备上面的读取要么返回一个单字节非零控制符(表示额外的流控事件),要么返回一个零字节,然后接着就是普通数据。也就是把控制信息以这种编码方式嵌入在了数据读取的流程中。

当处于信包模式的伪终端状态发生改变,select() 会提示主设备端发生异常(exceptfds),而 poll() 会在 revents 中返回 POLLPRI(有高优先级数据可以读取)。

实现 script(1) 程序

要点:

  1. script(1) 这个时候像是驱动程序,接收用户输入,在用户和子进程(shell)之间传递信息。
  2. script(1) 应该将主设备使用 tcsetattr() 设置为 raw 模式,防止主设备解释特殊字符。这样从设备才能正确解释特殊字符——而不是让特殊字符经过两轮解释。

终端属性和窗口大小

伪终端主从设备共享终端属性(termios)和窗口大小(winsize)结构。

伪终端的从设备一侧是不能收到窗口大小变化通知的。为了正确处理窗口大小变化,控制伪终端主设备的进程应该注册 SIGWINCH 信号处理,在收到信号时使用 ioctl()TIOCGWINSZ 操作获取当前的窗口大小,然后将伪终端主设备的窗口大小用 ioctl()TIOCSWINSZ 操作(一个字母之差,G 变 S)设置为当前获取的值(从设备的大小也会同步被设置)。

ioctl() 的参数解释可见 ioctl_tty(2)。

BSD 风格伪终端

BSD 风格伪终端是提前分配好的,大多数实现中会至少提供 32 对名称为 /dev/pty[pq][0-9a-f] 的伪终端主设备,对应从设备是将 pty 字符串替换成 tty

搜索可用终端时不是用 posix_openpt() 获取或者打开 /dev/ptmx,而是手动遍历所有伪终端主设备名,然后尝试打开,失败则继续查找下一个伪终端主设备。