5.5 终端的输出

通过使用termios结构,你可以控制键盘的输入。但我们希望对程序输出到屏幕上的内容也能具有同样的控制能力。在本章的一开始,你用printf函数将字符输出到屏幕上,但还没有办法将输出的内容放置到屏幕上的特定位置。

5.5.1 终端的类型

许多UNIX系统都是通过终端来使用的,虽然如今在很多情况下,“终端”可能实际上只是在PC上运行的一个终端仿真程序或者是窗口环境中的一个终端应用程序,比如X11中的xterm。

历史上,不同的制造厂商生产了大量的各种类型的硬件终端。虽然它们几乎都用escape转义序列(以escape字符开头的字符串)来控制光标的位置和终端的其他属性——比如黑体和闪烁等,但在具体实现手段上并没有统一的标准。某些陈旧的终端还使用不同的卷屏方式,这将导致使用退格键时可能会删除字符,也可能不会删除字符,凡此种种,不一而足。

escape转义序列有一个ANSI标准,它以数字设备公司(DEC公司)的VT系列终端所使用的转义序列为基础,但并不完全一致。许多软件终端程序提供了对标准硬件终端如VT100、VT220、ANSI等的仿真功能。

对程序员来说,如果他希望编写一个可以控制屏幕输出的软件,并且能够运行在各种类型的终端之上,则硬件终端的多样性是程序员要面对的一个主要问题。例如,ANSI终端使用转义序列Escape,[,A将光标移动到上一行,而ADM-3a终端(多年前它是一种很常见的终端)只需使用一个控制字符Ctrl+K就可以完成这一任务。

编写能够应付连接到UNIX系统上的各种不同类型终端的程序,看上去是一项非常让人畏惧的任务。这样的程序必须针对每种类型的终端编写相应的代码。

让人欣慰的是,terminfo软件包的出现解决了这一问题。程序不再需要去迎合每种类型的终端,取而代之的是,程序通过查询终端类型数据库来找到正确的终端信息。在大多数现代UNIX系统(包括Linux)中,这个软件包和另一个软件包curses集成在一起。你将在下一章学习后者。

为了使用terminfo函数,你通常需要在程序中包括curses头文件curses.h和terminfo自己的头文件term.h。在一些Linux系统上,你可能不得不使用被称为ncurses的curses实现,并在程序中包括ncurses.h头文件以提供对terminfo函数的原型定义。

5.5.2 识别终端类型

Linux环境包含一个变量TERM,它的值被设置为当前正在使用的终端类型。这通常是由系统在用户登录时自动设置的。系统管理员可以针对每个直接连接的终端设置默认的终端类型,对于通过网络远程连接的用户,可以提示用户选择自己的终端类型。TERM环境变量的值可以通过telnet进行协商,并由rlogin程序进行传递。

用户可以通过查询shell来了解,从系统的角度看自己正在使用的终端是何种类型的:

5.5 终端的输出 - 图1

在这个例子中,shell是通过xterm程序(一个X视窗系统中的终端仿真程序)或是提供类似功能的程序(如KDE的Konsole或GNOME的gnome-terminal)运行的。

terminfo软件包包含一个由大量不同类型终端的功能标志和escape转义序列等信息构成的数据库,并且为使用它们提供了一个统一的编程接口。一个使用这个软件包的程序能够随着数据库的扩展来适应未来的终端类型,对不同类型终端的支持不再需要由应用程序自身来提供。

terminfo的功能标志由属性描述,它们被保存在一组编译好的terminfo文件中,这些文件通常可以在/usr/lib/terminfo或/usr/share/terminfo目录中找到。每个终端(包括许多不同类型的打印机,它们也可以通过terminfo来定义)都有一个定义其功能标志和如何访问其特征的文件。为避免创建一个很大的目录,真正的文件都保存在下一级的子目录中,子目录名就是终端类型名的第一个字母。例如,VT100终端的定义就放在文件…terminfo/v/vt100中。

每个终端类型对应一个terminfo文件,文件格式是可读的源代码,然后通过tic命令将源文件编译为更加紧凑、有效的格式,以方便应用程序的使用。奇怪的是,X/Open规范提到了源文件和编译格式的定义,但却未提到把源文件转换为编译格式的tic命令。你可以用infocmp程序输出已编译terminfo数据项的可读版本。

下面是VT100终端对应的terminfo文件的样本:

5.5 终端的输出 - 图2

每个terminfo定义由3种类型的数据项组成。每个数据项被称为capname,它们分别用于定义终端的一种功能标志。

布尔功能标志指出终端是否支持某个特定的功能。例如,如果终端支持XON/XOFF流控,则在该终端对应的terminfo文件中定义布尔功能标志xon。

数值功能标志定义长度,例如:lines定义的是屏幕上可以显示的行数,cols定义的是屏幕上可以显示的列数。具体数字和功能标志名之间用字符#隔开。如果要定义一个有80列24行显示范围的终端,可以写为cols#80,lines#24。

字符串功能标志稍微复杂一些。它用来定义两种截然不同的终端功能:用于访问终端功能的输出字符串和当用户按下特定按键(通常是功能键或在数字小键盘上的特殊键)时终端接收到的输入字符串。有些字符串功能标志非常简单,例如el表示“删除到行尾”。在VT100终端上,用于完成这一功能的escape转义序列是Esc,[,K,在terminfo源文件中写为el=\E[K。

特殊键的定义也采用类似的方法。例如,VT100终端上的F1功能键发送的escape转义序列是Esc,O,P,它被定义为kfl=\EOP。

当escape转义序列本身还需要带有参数时,情况会变得更加复杂。大多数终端都能将光标移动到一个特定的行列位置。很明显,为光标可能移动到的每个位置定义一个功能标志是不现实的,解决方法是使用一个通用的字符串功能标志,在使用这个字符串时,通过插入参数来确定光标要移动到的确定位置。例如,VT100终端通过转义序列Esc,[,<row>,;,<col>,H将光标移动到一个特定位置。在terminfo源文件中,它被写为相当复杂的字符串cup=\E[%i%p1%d;%p2%dH$<5>。

下面给出了它的含义。

❑ \E:发送Escape字符。

❑ [:发送[字符。

❑ %i:增加参数值。

❑ %p1:将第一个参数放入栈。

❑ %d:将栈上的数字输出为一个十进制数。

❑ ;:发送;字符。

❑ %p2:将第二个参数放入栈。

❑ %d:将栈上的数字输出为一个十进制数。

❑ H:发送H字符。

这种写法看起来非常复杂,但它允许参数以固定的顺序排列,与终端期望它们出现在最终escape转义序列中的顺序无关。%i的作用是增加参数的值,它是必不可少的,因为标准的光标寻址方法是将屏幕的左上角看做是(0,0),而VT100终端把这个位置定义为(1,1)。最后的$<5>表示需要延迟一段时间,该时间的长度为输出五个字符所花费的时间,终端将利用这段时间来处理光标的移动。

我们可以自己定义许多功能标志,但幸运的是,大多数UNIX和Linux系统已经预定义好了大部分终端的功能标志。如果需要增加一个新终端,你可以在terminfo的手册页中找到完整的功能标志列表。一种比较好的方法是首先找到与新终端类似的一个终端,以它为出发点,将新终端定义为这个已有终端的变体,或者逐个对新终端的功能标志进行定义,按需要修改它们。

除了terminfo的手册页以外,你还可以参考由O'Reilly出版的Termcap and Terminfo(作者是John Strand、Linda Mui和Tim)。

5.5.3 使用terminfo功能标志

现在,你已知道如何定义终端的功能标志,你还需知道如何访问它们。当使用terminfo时,你要做的第一件事情就是调用函数setupterm来设置终端类型,这将为当前的终端类型初始化一个TERMINAL结构。然后,你就可以查看当前终端的功能标志并使用它们的功能了。setupterm函数的调用方法如下所示:

5.5 终端的输出 - 图3

setupterm库函数将当前终端类型设置为参数term指向的值,如果term是空指针,就使用环境变量TERM的值。参数fd为一个打开的文件描述符,它用于向终端写数据。如果参数errret不是一个空指针,则函数的返回值保存在该参数指向的整型变量中,下面给出了可能写入的值。

❑ -1:terminfo数据库不存在。

❑ 0:terminfo数据库中没有匹配的数据项。

❑ 1:成功。

setupterm函数在成功时返回常量OK,失败时返回ERR。如果errret被设置为空指针,setupterm函数会在失败时输出一条诊断信息并导致程序直接退出,就像下面这个例子:

5.5 终端的输出 - 图4

在你的系统中运行这个程序的结果可能和这里给出的不完全一样,但含义是很清楚的。字符串Done不会输出,因为setupterm函数会在执行失败时导致程序直接退出。

5.5 终端的输出 - 图5

请注意这个例子中的编译命令行:在这个Linux系统上,我们使用的是curses函数库的ncurses实现,并使用位于标准位置的标准头文件。在这类系统上,你可以直接在程序中包含curses.h头文件,并在编译时为库文件指定-lncurses选项。

对于菜单选择函数来说,你希望它能够首先清屏,然后在屏幕上移动光标并将数据写到屏幕的不同位置。在成功调用setupterm函数后,你即可通过如下3个函数调用来访问terminfo的功能标志,每个函数对应一个功能标志类型:

5.5 终端的输出 - 图6

函数tigetflag、tigetnum和tigetstr分别返回terminfo中的布尔功能标志、数值功能标志和字符串功能标志的值。失败时(例如,某个功能标志不存在),tigetflag函数返回-1,tigetnum函数返回-2,tigetstr函数返回(char *)-1。

你可以用terminfo数据库来查找当前终端的显示区大小,下面的程序sizeterm.c通过获取cols和lines功能标志来实现这一功能:

5.5 终端的输出 - 图7

5.5 终端的输出 - 图8

如果在一台工作站的一个窗口中运行这个程序,输出结果将反映当前窗口的大小,如下所示:

5.5 终端的输出 - 图9

如果用tigetstr函数来获取xterm终端类型的光标移动功能标志cup的值,你将会得到一个参数化的结果\E[%p1%d;%p2%dH。

这个功能标志需要两个参数:光标要移动到的行号和列号。这两个坐标都是从0开始计算的,(0,0)表示屏幕的左上角。

你可以使用tparm函数用实际的数值替换功能标志中的参数,一次最多可以替换9个参数,并返回一个可用的escape转义序列。该函数的定义如下:

5.5 终端的输出 - 图10

当用tparm函数构造好终端的escape转义序列后,你必须将其发送到终端。要想正确地完成这一操作,你不能通过printf函数将字符串发送到终端,而必须使用系统提供的如下几个特殊函数,这些函数可以正确地处理终端完成一个操作所需要的延时:

5.5 终端的输出 - 图11

putp函数在成功时返回OK,失败时返回ERR。它以一个终端控制字符串为参数,并将其发送到标准输出stdout。

所以,如果要将光标移动到屏幕上的第5行第30列,你可以使用如下代码段:

5.5 终端的输出 - 图12

tputs函数是为不能通过标准输出stdout访问终端的情况准备的,它可以指定一个用于输出字符的函数。tputs函数的返回值是用户指定的函数putfunc的返回结果。参数affcnt的作用是表明受这一变化影响的行数,它一般被设置为1。真正用于输出控制字符串的函数的参数和返回值类型必须与putchar函数相同。事实上,函数调用putp(string)就等同于函数调用tputs(string,1,putchar)。在下一个例子中,你将看到tputs函数使用用户指定的输出函数的情况。

注意,一些老版本的Linux将tputs函数的最后一个参数定义为int(*putfunc)(char),如果是这样,你就必须修改下面实验中的char_to_terminal函数的定义。

如果通过手册页查找与tparm函数以及终端功能标志相关的信息,你可能会看到函数tgoto。用该函数来移动光标会更加简单,但我们并未使用它,原因是在1997年版的X/Open规范(单一UNIX规范版本2)中并未包含该函数的定义。因此,我们建议读者在新编写的程序中也不要使用这类函数。

向菜单选择函数里添加屏幕处理功能的准备工作已基本就绪,现在唯一未提到的就是清屏操作。这一操作可以通过使用clear功能标志来完成,它首先清屏,然后将光标放到屏幕的左上角。但有些终端并不支持clear功能标志,此时,你需要首先将光标移动到屏幕的左上角,然后使用命令ed(delete to end of display,删除到显示区域结尾)。

将上面这些内容结合在一起,你将编写样本菜单程序的最终版本screenmenu.c,它将把菜单选项“画”在屏幕上供用户选择。

实 验 完整的终端控制

你可以重新编写menu4.c中的getchoice函数以提供完整的终端控制功能。在下面的程序清单中,我们省略了main函数,因为无需对其进行修改。其他与menu4.c不一致的地方都以灰色背景显示。

5.5 终端的输出 - 图13

5.5 终端的输出 - 图14

将这个程序保存为menu5.c。

实验解析

重新编写的getchoice函数实现的菜单内容与前面的例子完全一样,但其屏幕输出部分进行了修改以充分利用terminfo的功能标志。在用户进行下一次选择前,程序会有清屏操作,如果想在清屏之前让信息You have chosen:在屏幕上多停留一会儿,你可以在main函数中增加一条调用sleep函数的语句,如下所示:

5.5 终端的输出 - 图15

这个程序里的最后一个函数char_to_terminal包含了对putc函数的调用,我们将在第3章介绍putc函数。为使本章内容更加完整,我们再看一个如何检测用户击键动作的程序示例。