第19章 伪终端

19.1 引言

在第9章中,我们了解到,终端登录是经由自动提供终端语义的终端设备进行的。在终端和运行程序之间有一个终端行规程(见图18-2),通过该规程我们能够设置终端的特殊字符(如退格、行删除、中断等)。但是,当一个登录请求到达网络连接时,终端行规程并不是自动被加载到网络连接和登录shell之间的。图9-5显示了一个伪终端(pseudo terminal)设备驱动程序,用于提供终端语义。

伪终端除了用于网络登录,还有其他用途,本章将对此进行介绍。首先概要叙述如何使用伪终端,接着讨论某些特殊使用情况。然后,提供在多种平台下用于创建伪终端的函数,并使用这些函数编写一个程序,我们将该程序称为pty。将看到pty程序的各种用途:抄录在终端上输入和输出的所有字符(script(1)程序);运行协同进程来避免图15-19中的程序遇到的缓冲区问题。

19.2 概述

伪终端这个术语是指,对于一个应用程序而言,它看上去像一个终端,但事实上它并不是一个真正的终端。图 19-1 显示了使用伪终端时,相关进程的典型安排。图中的关键点如下。

图19-1 使用伪终端的相关进程的典型结构

•通常,一个进程打开伪终端主设备,然后调用 fork。子进程建立一个新的会话,打开一个相应的伪终端从设备,将其文件描述符复制到标准输入、标准输出和标准错误,然后调用exec。伪终端从设备成为子进程的控制终端。

•对于伪终端从设备上的用户进程来说,其标准输入、标准输出和标准错误都是终端设备。通过这些描述符,用户进程能够处理第 18 章中的所有终端 I/O 函数。但是因为伪终端从设备不是真正的终端设备,所以无意义的函数调用(例如,改变波特率、发送中断符、设置奇偶校验)将被忽略。

•任何写到伪终端主设备的都会作为从设备的输入,反之亦然。事实上,所有从设备端的

输入都来自于伪终端主设备上的用户进程。这看起来就像一个双向管道,但从设备上的终端行规程使我们拥有普通管道没有的其他处理能力。

图19-1显示了FreeBSD、Mac OS X或Linux系统中的伪终端结构。19.3 节将介绍如何打开这些设备。

在Solaris中,伪终端是使用STREAMS子系统构建的(见14.4节)。图19-2详细描述了Solaris中各个伪终端STREAMS模块的安排。虚线框中的两个 STREAMS 模块是可选的。pckt 和 ptem 模块帮助提供伪终端特有的语义。另外两个模块(ldterm 和 ttcompat)提供行规程处理。19.3 节将展示如何建立这些STREAMS模块的安排。

现在简化以上图示,不再画出图 19-1 中的“读函数和写函数”或图19-2中的“流首”。同时使用缩写“PTY”表示伪终端,并将图 19-2中所有伪终端从设备之上的 STREAMS 模块合并在一起表示为“终端行规程”模块,像图19-1中的那样。

图19-2 Solaris中的伪终端安排

现在,我们来考察伪终端的某些典型用途。

1.网络登录服务器

伪终端可用于构造提供网络登录的服务器。典型的例子是 telnetd 和 rlogind 服务器。Stevens[1990]中的第15章详细讨论了提供rlogin服务的步骤。一旦登录shell运行在远端主机上,即可得到图19-3中所示的安排。telnetd服务器使用类似的安排。

在rlogind服务器和登录shell之间有两个exec调用,这是因为login程序通常是在两个exec之间检验用户是否合法。

图19-3的一个关键点是,驱动PTY主设备的进程通常同时在读写另一个I/O流。本例中另一个I/O流是TCP/IP框。这表示该进程必然使用了某种形式的诸如select或poll这样的I/O多路转接(见14.4节),或者被分成两个进程或线程。

2.窗口系统终端模拟

窗口系统通常提供一个终端模拟器,这样我们就能在熟悉的命令行环境中通过 shell 来运行程序。终端模拟器作为shell和窗口管理器之间的媒介。每个shell在自己的窗口中执行。这个安排(两个shell运行在不同窗口)如图19-4所示。

shell将自己的标准输入、标准输出、标准错误连接到PTY的从设备端。终端模拟器程序打开PTY的主设备。终端模拟器除了作为窗口子系统的接口,还要负责模拟一种特殊的终端,这意味着它需要根据它所模拟的设备类型来响应返回码。这些码列在termcap和terminfo数据库中。

图19-3 rlogind服务器的进程安排

图19-4 窗口系统的进程安排

当用户改变终端模拟器窗口的大小时,窗口管理器会通知终端模拟器。终端模拟器在PTY的主设备端发出TIOCSWINSZ ioctl命令来设置从设备的窗口大小。如果新的窗口大小和当前的不同,内核会发送一个SIGWINCH信号给前台PTY从设备的进程组。如果应用程序在窗口大小改变时需要重绘屏幕,它就会捕捉这个SIGWINCH信号,然后发出TIOCSWINSZ ioctl命令获得新的屏幕尺寸并重绘屏幕。

3.script程序

script(1)程序是随大多数 UNIX 系统提供的,它将终端会话期间的所有输入和输出信息复制到一个文件中。为完成此工作,该程序将自己置于终端和一个新调用的登录shell之间。图19-5详细描述了script程序有关的交互。这里要特别指出,script程序通常是从登录shell启动的,该shell还要等待script程序的终止。

图19-5 script程序

script程序运行时,位于PTY从设备上的终端行规程的所有输出都将复制到脚本文件中(通常称为typescript)。因为击键通常由该行规程模块回显,所以该脚本文件也包括了输入的内容。但是,因为键入的口令不会回显,所以该脚本文件不会包含口令。

在编写本书第1版时,Rich Stevens用script程序获取实例程序的输出。这样避免了手工复制程序输出可能带来的错误。但是,使用script的不足之处是必须处理脚本文件中的控制字符。

在19.5节开发了通用的pty程序后,我们将看到使用pty程序和一个简单的shell脚本就能够实现一个新版本的script程序。

4.expect程序

伪终端可以用来在非交互模式中驱动交互式程序的运行。许多硬连线程序需要一个终端才能运行,passwd(1)命令就是一个例子,它要求用户在系统提示后输入口令。

为了支持批处理操作模式而修改所有交互式程序是非常麻烦的,与这种处理相比,一个更好的解决方法是通过一个脚本来驱动交互式程序。expect程序[Libes 1990, 1991, 1994]提供了这样的方法。类似于19.5节的pty程序,它使用伪终端来运行其他程序。并且,expect还提供了一种编程语言用于检查运行程序的输出,以确定用什么作为输入发送给该程序。当一个源自脚本的交互式的程序正在运行时,不能仅仅是将脚本中的所有内容复制到程序中去,或者将程序的输出送至脚本,而是必须要向程序发送某个输入,检查它的输出,并决定下一步发送给程序的内容。

5.运行协同进程

在图15-19所示的协同进程的例子中,我们不能调用使用标准I/O库进行输入、输出的协同进程,这是因为当通过管道与协同进程进行通信时,标准I/O库会完全缓冲标准输入和标准输出,从而引起死锁。如果协同进程是一个已经编译的程序而我们又没有源程序,则无法在源程序中加入fflush语句来解决这个问题。图15-16显示了一个进程驱动协同进程的情况。我们需要做的是将一个伪终端放到两个进程之间(如图19-6所示),诱使协同进程认为它是由终端驱动的,而非另一个进程。

图19-6 用伪终端驱动一个协同进程

现在协同进程的标准输入和标准输出就像终端设备一样,所以标准I/O库会将这两个流设置成行缓冲。

父进程有两种方法在自身和协同进程之间获得伪终端。(这种情况下的父进程可以类似图15-18中的程序,使用两个管道和协同进程进行通信。)一个方法是,父进程直接调用pty_fork函数(见19.4节)而不是调用fork。另一种方法是,exec该pty程序(见19.5节),将协同进程作为参数。我们将在给出pty程序后介绍这两种方法。

6.观看长时间运行程序的输出

使用任何一个标准shell,可以将一个需要长时间运行的程序放到后台运行。但是,如果将该程序的标准输出重定向到一个文件,并且它产生的输出又不多,那么我们就不能方便地监控程序的进展,因为标准I/O库将完全缓冲它的标准输出。我们看到的将只是标准I/O库函数写到输出文件中的成块输出,有时甚至可能是长度为8 192字节的数据块。

如果有源程序,则可以加入fflush调用强制标准I/O缓冲区在某些节点冲洗或者把缓冲模式改成使用setvbuf的行缓冲。然而,如果没有源程序,可以在pty程序下运行该程序,让标准I/O库认为标准输出是终端。图19-7显示了这个安排,我们将这个缓慢输出的程序称为slowout。从登录shell到pty进程的fort/exec箭头是用虚线表示的,为的是强调pty进程是作为后台任务运行的。

图19-7 使用伪终端运行一个缓慢输出的程序

19.3 打开伪终端设备

PTY表现得就像物理终端设备一样,因此应用程序就无须在意它们在使用的是何种设备。然而,在打开PTY设备文件时,应用程序并不需要设置O_TTY_INIT标识。Single UNIX Specification已经要求 PTY 从设备端第一次被打开的时候要初始化,这样该设备正常工作所需要的所有非标准termios标识就都被设置了。这个要求旨在允许PTY设备和遵循POSIX的调用tcgetattr和tcsetattr的应用程序正确地运行。

各种平台打开伪终端设备的方法有所不同。在Single UNIX Specification的XSI扩展中包含了很多函数,试图统一这些方法。这些函数的基础是SVR4用于管理基于STREAMS的伪终端的一组函数。posix_openpt函数提供了一种可移植的方法来打开下一个可用伪终端主设备。

#include <stdlib.h>

#include <fcntl.h>

int posix_openpt(int oflag);

返回值:若成功,返回下一个可用的PTY主设备文件描述符;若出错,返回-1

参数oflag是一个位屏蔽字,指定如何打开主设备,它类似于open(2)的oflag参数,但是并不支持所有打开标志。对于posix_openpt,可以指定O_RDWR来打开主设备进行读、写,指定O_NOCTTY来防止主设备成为调用者的控制终端。其他打开标志都会导致未定义的行为。

在伪终端从设备可用之前,它的权限必须设置,以便应用程序可以访问它。grantpt 函数提供这样的功能:它把从设备节点的用户ID设置为调用者的实际用户ID,设置其组ID为一非指定值,通常是可以访问该终端设备的组。权限被设置为:对个体所有者是读/写,对组所有者是写(0620)。

实现通常将PTY从设备的组所有者设置为tty组。把那些要对系统中所有活动终端具有写权限的程序(如wall(1)和write(1))的设置组ID设置为tty组。因为在PTY从设备上tty组的写权限是被允许的,所以这些程序就可以向活动终端写入。

#include <stdlib.h>

int grantpt(int fd);

int unlockpt(int fd);

两个函数的返回值:若成功,返回0;若出错,返回-1

为了更改从设备节点的权限,grantpt可能需要fork并exec一个设置用户ID程序(如在Solaris中是/usr/lib/pt_chmod)。于是,如果调用者捕捉到 SIGCHLD 信号,那么其行为是未说明的。

unlockpt 函数用于准予对伪终端从设备的访问,从而允许应用程序打开该设备。阻止其他进程打开从设备后,建立该设备的应用程序有机会在使用主、从设备之前正确地初始化这些设备。

注意,在grantpt和unlockpt这两个函数中,文件描述符参数是与伪终端主设备关联的文件描述符。

如果给定了伪终端主设备的文件描述符,那么可以用 ptsname 函数找到伪终端从设备的路径名。这使应用程序可以独立于给定平台的某种特定约定而标识从设备。注意,该函数返回的名字可能存储在静态存储中,因此后续的调用可能会覆盖它。

#include <stdlib.h>

char *ptsname(int fd);

返回值:若成功,返回指向PTY从设备名的指针;若出错,返回NULL

图19-8总结了Single UNIX Specification中的伪终端函数,指出了本书讨论的4种平台分别支持哪些函数。

图19-8 XSI伪终端函数

在FreeBSD中,grantpt和unlockpt除了参数验证外不执行任何操作,PTY是通过正确的权限动态地创建出来的。注意,FreeBSD定义O_NOCTTY标志只是为了兼容调用posix_openpt的应用程序。在FreeBSD中打开终端设备并不会引起分配控制终端的副作用,所以O_NOCTTY标志并无作用。

Single UNIX Specification已经改善了此方面的可移植性,但是差距仍然存在。我们提供了两个处理所有这些细节的函数:ptym_open和ptys_open。ptym_open打开下一个可用的PTY主设备,ptys_open打开相应的从设备。

#include "apue.h"

int ptym_open(char *pts_name, int pts_namesz);

返回值:若成功,返回PTY主设备文件描述符;若出错,返回-1

int ptys_open(char *pts_name);

返回值:若成功,返回PTY从设备文件描述符;若出错,返回-1

通常,不直接调用这两个函数,而是由函数 pty_fork(见 19.4 节)调用它们,并且还会fork出一个子进程。

ptym_open函数打开下一个可用的PTY主设备。调用者必须分配一个数组来存放主设备或从设备的名字,并且如果调用成功,相应的从设备名会通过pts_name返回。然后,这个名字传给用来打开该从设备的ptys_open函数。缓冲区的字节长度由pts_namesz传送,使得ptym_open函数不会复制比该缓冲区长的字符串。

在说明pty_fork函数之后,提供两个函数来打开这两个设备的原因将会很明显。通常,一个进程调用ptym_open来打开一个主设备并且得到从设备名。该进程然后fork子进程,子进程在调用setsid建立新的会话后调用ptys_open打开从设备。这就是从设备如何成为子进程控制终端的过程(见图19-9)。

图19-9 伪终端打开函数

ptym_open函数用XSI PTY函数找到并打开一个未被使用的PTY主设备,并初始化对应的PTY从设备。ptys_open函数打开的是PTY从设备。然而在Solaris系统中,在PTY从设备表现得像个终端前,我们可能需要多做几步工作。

在Solaris中,打开从设备后,我们可能需要将3个STREAMS模块压入从设备的流中。伪终端仿真模块(ptem)和终端行规程模块(ldterm)合在一起像一个真正的终端一样工作。ttcompat提供了对早期系统(如V7、4BSD和Xenix)的ioctl调用的兼容性。这是一个可选的模块,但是因为对于网络登录,它是自动压入的,所以我们将它压入到从设备的流中。

也可能并不需要压入这3个模块,其原因是,它们可能已经位于流中。STREAMS系统支持一种称为autopush(自动压入)的工具,它允许系统管理员配置一张模块列表,只要打开一个特定设备,就将这些模块压入流中(详见Rago[1993])。使用I_FIND ioctl命令观察ldterm是否已在流中。如果是,则认为该流已用autopush机制配置,这样就无需再压入相应模块。

Linux、Mac OS X和Solaris都遵循历史上System V的行为:如果调用者是一个还没有控制终端的会话首进程,这个打开(open)的调用会分配一个PTY从设备作为控制终端。如果不想让这种情况发生,可以在打开(open)时设置O_NOCTTY标志。然而,在FreeBSD中,打开PTY从设备不会产生分配其作为控制终端的副作用,下一节将探讨如何在FreeBSD中分配控制终端。

19.4 函数pty_fork

现在使用上一节介绍的两个函数ptym_open 和ptys_open来编写一个新函数,我们称之为pty_fork。这个新函数具有如下功能:用fork调用打开主设备和从设备,创建作为会话首进程的子进程并使其具有控制终端。

#include "apue.h"

#include <termios.h>

pid_t pty_fork(int *ptrfdm, char *slave_name, int slave_namesz,

const struct termios *slave_termios,

const struct winsize *slave_winsize);

返回值:子进程中返回0;父进程中返回子进程的进程ID;若出错,返回−1

PTY主设备的文件描述符通过ptrfdm指针返回。

如果slave_name不为空,从设备名被存储在该指针指向的存储区中。调用者必须为该存储区分配空间。

如果指针slave_termios不为空,则系统使用该指针所引用的结构初始化从设备的终端行规程。如果该指针为空,那么系统将会把从设备的termios结构设置成实现定义的初始状态。类似地,如果slave_winsize指针不为空,那么按该指针所引用的结构初始化从设备的窗口大小。如果该指针为空,winsize结构通常被初始化为0。

图19-10显示了该函数的代码。它调用相应的ptym_open和ptys_open函数,在本书讨论的4种平台上,pty_fork函数都能工作。

图19-10 pty_fork函数

在打开PTY主设备后,调用fork。正如前面提到的,子进程先调用setsid建立新的会话,然后才调用ptys_open。当调用setsid时,子进程还不是一个进程组的首进程,因此执行9.5节中列出的3个操作步骤:(a)子进程创建一个新的会话,它是该会话的首进程;(b)子进程创建一个新的进程组;(c)子进程断开与以前可能有的控制终端的关联,于是不再有控制终端。在Linux、Mac OS X和Solaris系统中,当调用ptys_open时,从设备成为新会话的控制终端。在FreeBSD系统中,必须调用TIOCSCTTY ioctl来分配一个控制终端。(回想图9-8,其他3个平台也支持TIOCSCTTY ioctl命令,但是只有在FreeBSD中需要我们去调用它。)

termios和winsize这两个结构在子进程中初始化。最后从设备的文件描述符被复制到子进程的标准输入、标准输出和标准错误中。这意味着不管子进程以后调用exec执行何种程序,它都具有同PTY从设备(其控制终端)联系起来的上述3个描述符。

在调用fork后,父进程返回PTY主设备的描述符以及子进程的进程ID。下一节将在pty程序中使用pty_fork函数。

19.5 pty程序

编写pty程序的目的是用

pty prog arg1 arg2

来代替

prog arg1 arg2

当用pty来执行另一个程序时,那个程序在一个它自己的会话中执行,并和一个伪终端连接。

让我们查看pty程序的源代码。第一个文件(见图19-11)包含main函数。它调用上一节的pty_fork函数。

图19-11 pty程序的main函数

下一节介绍pty程序的不同用途时,将看到多种命令行选项。getopt函数帮助我们以协调一致的模式分析命令行参数。为了在Linux系统中强制POSIX行为,我们将选项字符串的第一个字符设置为加号。

在调用pty_fork前,我们获取termios和winsize结构的当前值,将其作为参数传递给pty_fork。通过这种方法,PTY从设备具有和当前终端相同的初始状态。

子进程从pty_fork返回后,可选地关闭了PTY从设备的回显,然后调用execvp来执行命令行指定的程序。所有余下的命令行参数将成为该程序的参数。

父进程可选地将用户终端设置为原始模式。在这种情况下,父进程还要设置退出处理程序,使得在调用exit时复原终端状态。下一节将描述do_driver函数。

接下来,父进程调用函数 loop(见图 19-12),该函数仅仅是将从标准输入接收到的所有内容复制到PTY主设备,并将PTY主设备接收到的所有内容复制到标准输出。尽管使用select或poll的单进程或多线程是可行的,但是为了有所变化,这里使用了两个进程。

图19-12 loop函数

注意,因为使用了两个进程,所以一个终止时,必须通知另一个。我们用 SIGTERM 信号进行这种通知。

19.6 使用pty程序

接下来看几个pty程序的应用实例,并了解使用不同命令行选项的必要性。

如果使用Korn shell,那么我们执行命令:

pty ksh

会得到一个运行在伪终端下的全新shell。

如果文件ttyname包含了图18-16中所示的程序,那么可按如下模式执行pty程序:

$ who

sar console May 19 16:47

sar ttys000 May 19 16:47

sar ttys001 May 19 16:48

sar ttys002 May 19 16:48

sar ttys003 May 19 16:49

sar ttys004 May 19 16:49    ttys004是当前使用的最高PTY设备

$ pty ttyname         在PTY上运行图18-16中的程序

fd 0: /dev/ttys005       ttys005是下一个可用的PTY

fd 1: /dev/ttys005

fd 2: /dev/ttys005

1.utmp文件

6.8节讨论过记录当前登录到UNIX系统的用户的utmp文件。那么在伪终端上运行程序的用户是否被认为是登录了呢?如果是用telnetd和rlogind远程登录,显然在伪终端上登录的用户应该在utmp文件中有相应记录项。但是,通过窗口系统或script类程序在伪终端上运行shell的用户是否应该在utmp文件中有相应记录项呢?有的系统有记录,有的没有。如果在utmp文件中没有记录的话,who(1)程序一般不会显示相应伪终端正在被使用。

除非utmp文件允许其他用户的写权限(这被认为是一个安全漏洞),否则一般使用伪终端的程序将不能对utmp文件进行写操作。

2.作业控制交互

当在pty下运行作业控制shell时,它能够正常地运行。例如,

pty ksh

将在pty下运行Korn shell。我们能够在这个新shell下运行程序并使用作业控制,这如同在登录shell中一样。但如果在pty下运行一个交互式程序而不是作业控制shell,例如,

pty cat

那么在键入作业控制挂起字符之前该程序的运行一切正常。而在键入作业控制挂起字符时,作业控制挂起字符将会被显示为^Z,并且被忽略。在早期基于 BSD 的系统中,cat 进程终止,pty进程终止,回到初始登录shell。为了明白其中的原因,我们需要检查所有相关的进程以及这些进程所属的进程组和会话。图19-13显示了pty cat运行时的安排。

键入挂起字符(Ctrl+Z)时,它被cat进程下的行规程模块所识别,这是因为pty将终端(在pty父进程之下)设置为原始模式。但内核不会停止cat进程,这是因为它属于一个孤儿进程组(见9.10节)。cat的父进程是pty父进程,它属于另一个会话。

图19-13 pty cat的进程组和会话

历史上,不同的系统处理这种情况的方法也不同。POSIX.1 只是说明 SIGTSTP 信号不能被发送给进程。4.3BSD 的派生系统向进程递送一个它从不捕获的SIGKILL 信号。4.4BSD没有采用发送SIGKILL信号的方法,转而采用符合于POSIX.1的处理方法。如果SIGTSTP信号具有默认配置,并且传递给孤儿进程组中的一个进程,那么4.4BSD的内核会无声息地丢弃SIGTSTP信号。大多数当前的实现都采用这种处理模式。

当我们使用pty来运行作业控制shell时,被这个新shell调用的作业决不会是任何孤儿进程组的成员,这是因为作业控制shell总是属于同一个会话。在这种情况下,键入的Ctrl+Z被发送到由shell调用的进程,而不是shell本身。

让pty调用的进程能够处理作业控制信号的唯一的方法是:另外增加一个pty命令行标志,使pty子进程自己能够识别作业挂起字符(在pty子进程中),而不是让该字符穿越所有路程而到达另一个行规程模块。

3.检查长时间运行程序的输出

另一个使用pty进行作业控制交互的实例见图19-7。如果运行一个缓慢产生输出的程序:

pty slowout > file.out &

当子进程试图从标准输入(终端)读入数据时,pty进程立刻停止运行。这是因为该作业是一个后台作业,并且当它试图访问终端时会使作业控制停止。如果将标准输入重定向使得pty不从终端读取数据,如:

pty slowout < /dev/null > file.out &

那么pty程序也立即停止,因为它从标准输入和终端读取到一个文件结束符。解决这个问题的方法是使用-i选项,这个选项的含义是忽略来自标准输入的文件结束符:

pty -i slowout < /dev/null > file.out &

这个标志导致在遇到文件结束符时,图19-13的pty子进程退出,但子进程不会告诉父进程终止。相反,父进程一直将PTY从设备的输出复制到标准输出(本例中是文件file.out)。

4.script程序

使用pty程序可以把script(1)程序实现成下面shell脚本:

#!/bin/sh

pty "${SHELL:-/bin/sh}" | tee typescript

一旦执行这个shell脚本,即可执行ps命令来观察进程之间的关系。图19-14详细地显示了这些关系。

图19-14 script shell脚本的进程安排

管道

在这个例子中,假设SHELL变量是Korn shell(可能是/bin/ksh)。如前面所述,script仅仅是将新的 shell(和它调用的所有的子进程)的输出复制出来,但是因为 PTY 从设备上的行规程模块通常允许回显,所以绝大多数键入也都被写到typescript文件中。

5.运行协同进程

在图15-8所示的程序中,协同进程不能使用标准I/O函数,其原因是标准输入和标准输出不是终端,所以标准I/O函数会将它们放到缓冲区中。如果把

if (execl("./add2", "add2", (char *)0) < 0)

替换成

if (execl("./pty", "pty", "-e", "add2", (char *)0) < 0)

在pty下运行协同进程,该程序即使使用了标准I/O仍然可以正确运行。

图19-15显示了在使用伪终端作为协同进程的输入和输出时,进程的安排。这是图19-6的扩充,它显示了所有的进程连接和数据流。框中的“驱动程序”是按前面的说明更改了 execl 的图15-8的程序。

这一实例显示了-e(不回显)选项对于pty程序的重要性。因为pty程序的标准输入没有连接到终端,所以它不以交互方式运行。在图 19-11 程序中,interactive 标志默认为假,这是因为对isatty调用的返回是假。这意味着真正终端上的行规程保持在规范模式下,并允许回显。指定-e选项后,关掉了PTY从设备上的行规程模块的回显。如果不这样做,则键入的每一个字符都将被两个行规程模块各回显一次。

图19-15 运行一个协同进程,以伪终端作为其输入和输出

还能用-e选项关闭termios结构的ONLCR标志,以防止所有协同进程的输出被回车和换行符终止。

在不同的系统上测试这个例子,会遇到14.7节中描述readn和writen函数时顺便提到的同样问题。当描述符引用的不是普通磁盘文件时,从read返回的数据量可能会因两个实现之间的不同而有所区别。使用pty的协同进程实例产生了非预期的结果,其原因可追溯至图15-18的程序中读管道的read函数,它返回的结果不足一行。解决方法是不使用图15-18中的程序,而是要使用来自于习题15.5针对这个程序的另外一个版本,这个版本改用标准I/O库,将两个管道的标准I/O流都设置为行缓冲。这样,fgets函数将会读完一个整行。图15-18的程序中的while循环假设发送到协同进程的每一行都会带来一行的返回结果。

6.非交互地驱动交互式程序

虽然让pty运行任意协同进程,甚至交互式的协同进程的想法很诱人,但这是行不通的。问题在于pty只是将其标准输入复制到PTY,并将来自PTY的数据复制到其标准输出,而并不关心具体发送的或得到的是什么数据。

举个例子,我们可以在pty下运行telnet命令,直接与远程主机对话:

pty telnet 192.168.1.3

这样做与直接键入 telnet 192.168.1.3 相比,并没有带来更多的好处,但我们可能希望在一个脚本中运行telnet程序,其目的很可能是要检验远程主机的某个条件。如果telnet.cmd文件包括下面4行:

sar

passwd

uptime

exit

第1行是登录到远程主机时使用的用户名,第2行是口令,第3行是希望运行的命令,第4行终止此会话。如果按下列方式运行此脚本:

pty -i < telnet.cmd telnet 192.168.1.3

那么,它不会像我们所想的那样操作。而是,telnet.cmd文件的内容在还没有得到机会提示我们输入账户名和口令之前,就被发送到了远程主机。当它关闭回显而读口令时,login 使用 tcsetattr 选项,于是丢弃了已在队列中的所有数据。这样一来,我们发送的数据就被丢掉了。

当以交互方式运行telnet程序时,我们等待远程主机发出输入口令的提示,然后再键入口令,但是pty程序不知道这样做。这就是需要一个比pty更巧妙的程序,如expect,从脚本文件驱动交互式程序的原因。

即使如前所示那样从图15-18程序运行pty,这也没有任何帮助。因为图15-18中的程序认为它在一个管道写入的每一行都会在另一个管道产生一行。对于一个交互式程序,输入一行可能产生多行输出。更进一步,图15-18中的程序在从协同进程读之前,它总是先发送一行给该进程。如果想在发送给协同进程一些数据之前从协同进程处读,这种策略就行不通了。

有一些从shell脚本驱动交互式程序的方法。可以在pty上增加一种命令语言和一个解释器。但是一个适当的命令语言可能十倍于pty程序的大小。另一种选择是使用命令语言并用pty_fork函数来调用交互式程序,这正是expect程序所做的。

我们将采用一种不同的途径,使用选项-d使pty程序的输入和输出与驱动进程连接起来。该驱动进程的标准输出是pty的标准输入,反之亦然。这有点像协同进程,只是在pty的“另一边”。此种进程结构与图19-15中所示的几乎相同,只是在这种场景中,由pty来完成驱动进程的fork和exec。而且我们在pty和驱动进程二者之间使用的是一个双向的流管道,而不是两个半双工管道。

图19-16展示的是do_driver函数的源代码,在使用-d选项时,该函数由pty(见图19-11)的main函数调用。

图19-16 pty程序的do_driver函数

通过我们自己编写由pty调用的驱动程序,可以按我们所希望的方式驱动交互式程序。即使驱动程序有和pty连接在一起的标准输入和标准输出,驱动进程仍然可以通过读、写/dev/tty同用户交互。这个解决方法仍不如expect程序通用,但是它用不到50行的代码提供了pty的一种实用的选项。

19.7 高级特性

伪终端还有其他特性,我们在这里简略提一下。Sun Microsystems[2002]和BSD pts(4)的手册页对此有更详细的说明。

1.打包模式

打包模式(packet mode)能够使PTY主设备了解到PTY从设备的状态变化。在Solaris系统中,可以通过将STREAMS模块pckt压入PTY主设备端来设置这种模式。图19-2显示了这种可选模块。在FreeBSD、Linux和Mac OS X中,可以用TIOCPKT ioctl命令来设置这种模式。

Solaris和其他平台相比较,具体的打包模式有所不同。在Solaris中,读取PTY主设备的进程必须调用 getmsg 从流首取得消息,这是因为 pckt 模块将一些事件转化成了无数据的STREAMS消息。在其他平台中,每一次对PTY主设备的读操作都会返回带有可选数据的状态字节。

无论实现细节如何,打包模式的目的是,当PTY从设备上的行规程模块出现以下事件时,通知进程从PTY主设备读取数据:读队列被冲洗;写队列被冲洗,输出被停止(如Ctrl+S),输出重新开始,XON/XOFF 流控制被禁用后重新启用,XON/XOFF 流控制被启用后重新禁用。这些事件由rlogin客户进程和rlogind服务器进程使用。

2.远程模式

PTY主设备可以用TIOCREMOTE ioctl命令将PTY从设备设置成远程模式。虽然FreeBSD、Mac OS X 10.6.8和Solaris 10使用同样的命令来启用或禁用这个特性,但是在Solaris中,ioctl的第三个参数是一个整型数,而在Mac OS X中则是一个指向整型数的指针。(FreeBSD 8.0和Linux 3.2.0不支持这一命令。)

当PTY主设备将PTY从设备设置成这种模式时,它通知PTY从设备上的行规程模块对从主设备接收到的任何数据都不进行任何处理,不管从设备 termios 结构中的规范或非规范标志是否设置,都是这样。远程模式适用于窗口管理器这种进行自己的行编辑的应用程序。

3.窗口大小变化

PTY主设备上的进程可以用TIOCSWINSZ ioctl命令来设置从设备的窗口大小。如果新的大小和当前的大小不同,SIGWINCH信号将被发送到PTY从设备的前台进程组。

4.信号发生

读、写PTY主设备的进程可以向PTY从设备的进程组发送信号。在Solaris 10中,可以用TIOCSIGNAL ioctl命令做到这一点。在FreeBSD 8.0、Linux 3.2.0和 Mac OS X 10.6.8中,用TIOCSIG ioctl来做到这一点。在这两种情况下,第三个参数都是信号编号值。

19.8 小结

本章开始部分简要叙述了如何使用伪终端,并观察了某些应用实例。接着,分析说明了在本书讨论的4种平台上打开伪终端所需的代码。然后用此代码提供了通用pty_fork函数,它可用于多种不同的应用。该函数是小程序(pty)的基础,我们使用这一程序揭示了伪终端的许多属性。

伪终端在大多数UNIX系统中每天都被用来进行网络登录。我们还检查了伪终端的许多其他用途,从script程序到使用批处理脚本来驱动交互式程序等。

习题

19.1 当用telnet或rlogin远程登录到一个BSD系统上时,像我们在19.3节讨论过的那样, PTY从设备的所有权和权限被设置。该过程是如何发生的?

19.2 使用pty程序来确定你的系统用于初始化PTY从设备的termios结构和winsize结构的值。

19.3 重写loop函数(见图19-12),使之成为使用select或poll的单个进程。

19.4 在子进程中,pty_fork返回后,标准输入、标准输出和标准错误都以读写模式打开。能够将标准输入变成只读,另两个变成只写吗?

19.5 在图19-13中,指出哪些进程组是前台的,哪些进程组是后台的,并指出会话首进程。

19.6 在图19-13中,当键入文件终止符时,进程终止的顺序是什么?如果可能的话,用进程会计信息验证之。

19.7 script(1)程序通常在输出文件头增加一行说明它的开始时间,在输出文件末尾增加一行说明它的结束时间。将这些特性添加到本章展示的简单的shell脚本中。

19.8 解释为什么在下面的例子中,即使程序 ttyname(见图 18-16)只产生输出而不读入的情况下,文件data的内容还被输出到终端上。

$ cat data             一个两行的文件

hello,

world

$ pty -i < data ttyname -i     -i表示忽略stdin的文件结束标志

hello,               这两行来自何处?

world

fd 0:/dev/ttys005         我们期望ttyname输出这3行

fd 1:/dev/ttys005

fd 2:/dev/ttys005

19.9 编写一个调用pty_fork 的程序,该程序有一个子进程,该子进程exec另一个你写的程序。子进程exec的新程序能够捕获SIGTERM和SIGWINCH。当捕获到信号时,要打印出有关消息,并且对于后一种信号,还要打印终端窗口大小。然后让父进程用 19.7 节描述过的ioctl命令向PTY从设备的进程组发送SIGTERM信号。从PTY从设备读回消息并验证捕获到了该信号。接下来由父进程设置PTY从设备窗口的大小,并再读回PTY从设备的输出。让父进程退出(exit)并确定PTY从设备进程是否也要终止;如果要终止,应如何终止?