4.2 Linux终端
本节将讨论如何更好地控制用户终端,也就是键盘输入与屏幕输出。除了这些,还将了解到即使是在输入重定向的情况下,编写的程序如何由用户处读取输入,以及确保输出到屏幕的正确位置。
4.2.1 终端读取与写入
当一个程序由命令提示行启动时,shell会将标准的输入与输出流连接到程序。用户可以通过使用getchar与printf例程来读取与写入这些默认流从而实现与用户进行简单地交互。例如用C语言只使用这两个例程,来写一个菜单范例,将写好的程序命名为menu1.c。
(1)由下面的代码行开始,其中定义了要作为菜单使用的数组,以及getchoice函数原型。
#include <stdio.h> char *menu[] = { "a - add new record", "d - delete record", "q - quit", NULL, }; int getchoice(char *greet, char *choices[]);
(2)main函数调用getchoice。
int main() { int choice = 0; do { choice = getchoice("Please select an action", menu); printf("You have chosen: %c/n", choice); } while(choice != 'q'); exit(0); }
(3)下面是重要的代码:打印菜单与读取用户输入的函数。
int getchoice(char *greet, char *choices[]) { int chosen = 0; int selected; char **option; do { printf("Choice: %s/n",greet); option = choices; while(*option) { printf("%s/n",*option); option++; } selected = getchar(); option = choices; while(*option) { if(selected == *option[0]) { chosen = 1; break; } option++; } if(!chosen) { printf("Incorrect choice, select again/n"); } } while(!chosen); return selected; }
getchoice函数打印程序简介greet与choices,然后要求用户选择一个初始字符。程序会循环直到getchar函数返回一个与option数组实体的第一个字符相匹配的字符。当编译运行这个程序时,发现它并不是如我们期望的那样运行。下面演示运行这个程序所进行的对话:
$ ./menu1 Choice: Please select an action a - add new record d - delete record q - quit a You have chosen: a Choice: Please select an action a - add new record d - delete record q - quit Incorrect choice, select again Choice: Please select an action a - add new record d - delete record q - quit q You have chosen: q $
在这里用户必须输入A/Enter/Q/Enter来做出选择。这里至少有两个问题:一个是在每次正确地选择之后都会得到Incorrect choice输出;另外一个是,必须在程序读取输入之前按Enter键。这两个问题是紧密相关的。在默认情况下,从终端输入直到用户按Enter键后才会为程序所用。在大多数情况下,这是一个优点,因为这样可以允许用户使用Backspace键或是Delete键修正输入错误。只有当用户对在屏幕上输入的内容确认以后才会按Enter键使得输入为程序所用。这种行为称之为典型模式,或者是标准模式。所有的输入都是以行的方式进行处理的,在一行输入完整(通常是当用户按Enter键时)后,终端界面管理所有的按键输入,而且程序不会读取任何字符。与这种模式相对的模式称为非典型模式,此时程序在输入字符的处理上有更多的控制权。
除此之外,Linux终端处理器喜欢将字符转换为信号,并且可以自动执行Backspace与Delete操作。那么我们编写的程序中发生了什么呢?Linux系统在用户按Enter键之前会保存输入,然后将选择的字符与后面的Enter信号发送给程序。所以每次输入一个菜单选项时,程序调用getchar处理字符,然后再次调用getchar,此时会立即返回Enter字符。
程序实际看到的字符并不是一个ASCII码的回车符,CR(十进制13,十六进制0D),而是换行(十进制10,十六进制0A)。这是因为Linux内部总是使用换行来结束文本行,也就是说,Linux只使用换行来表示新行,而其他的系统,例如MS DOS系统,使用回车和换行来表示新行。如果输入或是输出设备也发送或是请求一个回车符,Linux终端会小心地进行处理。这对于习惯使用MS DOS或是其他系统环境的用户,会觉得有一些奇怪,但这样的一个好处是Linux中文本与二进制之间并没有真正的区别。只有当用户向一个终端、打印机或是绘图仪输入或输出时才会处理回车信息。
可以使用一些代码来忽略额外的换行符,来简单地修改上述菜单程序的主要缺陷,如下所示:
do { selected = getchar(); } while(selected == '/n');
这解决了第一个问题,再回到需要按Enter键使程序读取输入的第二个问题(我们会在后面讨论一个更好的处理换行的方法)。
对于Linux程序,可以很容易地将它们地输入或输出重定向到一个文件或是其他的程序,来看一下将输出重定到一个文件时程序是如何处理的:
$ menu1 > file a q $
以上程序可以认为是成功的,因为回车重定向到一个文件而不是终端。然而,这里却有希望阻止的情况,或者说希望分离提示,从而可以安全地重定向。
可以通过检测一个底层文件描述符是否与一个终端相关联来区分标准输出是否已被重定向。使用isatty系统调用可以完成这项工作,只需要简单地传递给它们一个可用的文件描述符,就可以检测出这个文件描述符是否连接到一个终端。
#include <unistd.h> int isatty(int fd);
如果打开的文件描述符fd连接到一个终端,那么isatty系统调用就会返回1,否则返回0。
如果stdout已经被重定向应该怎么办呢?这时仅是退出是不够的,因为用户并不知道程序为什么会运行失败。在stdout上打印一条信息也没有用,因为它已经被重定向离开终端了。一个解决办法就是写入stderr,此时它并没有被shell命令>file进行重定向。
使用前面编写的程序menu1.c,包含一个新的include,将main改为下面的代码,并将其命名为menu2.c。
#include <unistd.h> ... int main() { int choice = 0; if(!isatty(fileno(stdout))) { fprintf(stderr,"You are not a terminal!/n"); exit(1); } do { choice = getchoice("Please select an action", menu); printf("You have chosen: %c/n", choice); } while(choice != 'q'); exit(0); }
新版本的代码使用isatty函数来测试标准是否连接到一个终端,如果不是则会结束执行。同样也可以使用shell测试来决定是否提供一个提示符。比较常见的是同时重定向stdout与stderr,从而使其离开终端,可以按如下示例将错误信息重定向到一个不同的文件。
$ menu2 >file 2>file.error $
或者是将两个输出流组合到一个文件中,如下所示。
$ menu2 >file 2amp;>&1 $
在这个例子中,需要向控制台发送一条消息。
如果需要阻止程序中与用户交互的部分被重定向,但是对于其他的输入或是输出是允许的,此时就需要分离与stdout和stderr的交互,这可以通过直接读写终端来做到。Linux是一个多用户系统,通常有许多终端直接相连或是通过网络相连,如何来确定要使用的正确终端呢?幸运地,Linux和Unix系统通过提供一个特殊的设备/dev/tty使事情变得简单,这个设备通常是当前的终端或是登录对话。因为Linux将所有的内容都视为文件,因此可以使用通常的文件操作来读写/dev/tty设备。
接下来修改选择程序,从而可以向getchoice例程传递参数,以更好地控制输出。这里将其命名为menu3.c。
打开menu2.c,将其内容改为下列代码,这样输入和输出就可以重定向到/dev/tty。
#include <stdio.h> #include <unistd.h> char *menu[] = { "a - add new record", "d - delete record", "q - quit", NULL, }; int getchoice(char *greet, char *choices[], FILE *in, FILE *out); int main() { int choice = 0; FILE *input; FILE *output; if(!isatty(fileno(stdout))) { fprintf(stderr,"You are not a terminal, OK./n"); } input = fopen("/dev/tty", "r"); output = fopen("/dev/tty", "w"); if(!input || !output) { fprintf(stderr,"Unable to open /dev/tty/n"); exit(1); } do { choice = getchoice("Please select an action", menu, input, output); printf("You have chosen: %c/n", choice); } while(choice != 'q'); exit(0); } int getchoice(char *greet, char *choices[], FILE *in, FILE *out) { int chosen = 0; int selected; char **option; do { fprintf(out,"Choice: %s/n",greet); option = choices; while(*option) { fprintf(out,"%s/n",*option); option++; } do { selected = fgetc(in); } while(selected == '/n'); option = choices; while(*option) { if(selected == *option[0]) { chosen = 1; break; } option++; } if(!chosen) { fprintf(out,"Incorrect choice, select again/n"); } } while(!chosen); return selected; }
这样,当使用输出重定向来运行这个程序时,可以看到提示符与通常的程序输出是分离的。
$ menu3 > file You are not a terminal, OK. Choice: Please select an action a - add new record d - delete record q - quit d Choice: Please select an action a - add new record d - delete record q - quit q $ cat file You have chosen: d You have chosen: q
4.2.2 终端驱动器与通用终端接口
有时程序需要更好地控制终端而不是使用简单的文件操作来控制。Linux提供了一个可以用来控制终端驱动器的接口集合,从而更好地控制终端的输入与输出处理过程。
可以通过一个用来进行分离读写操作的函数调用集合来控制终端,使得数据接口更为清晰,同时又能更好地控制终端的行为。这并不说是说终端I/O接口是清晰的,而是可以处理各种不同的硬件。
在Linux术语中,用于“行规程”(line discipline)控制接口,从而使程序在指定终端驱动器的行为方面更为灵活。可以控制的主要特征包括以下几个。
行编辑 决定是否允许编辑使用Backspace。
缓冲 决定是立即读取字符,或是在一个延时后读取。
回显 允许控制回显,例如当正在读取密码时回显。
CR/LF 决定输入与输出映射,也就是当输入一个/n时会发生什么。
行速度 很少用在PC控制台上,这些速度对于调制解调器和串口线上的终端非常重要。
termios是POSIX所指定的标准接口,与System V接口的termio类似。终端接口是通过在一个termios类型的结构中设置值以及使用一组函数调用来进行控制的,所有这些都定义在头文件termios.h中。
使用定义在termios.h中的函数的程序需要使用一个合适的函数库进行链接。这通常是curses库,所以编译程序时,需要在编译器命令行的最后加上-lcurses。在一些老的Linux系统中,curses库是由一个新curses或是ncurses来提供的,在这种情况下,库名字与链接参数就分别变为-lncurses。
可以通过操作来影响终端的值,分为几种模式:输入(input)、输出(output)、控制(control)、本地(local)及特殊控制字符(Special control characters)。
一个最小的termios结构通常声明如下(X/Open规范允许添加一些其他的域)。
#include <termios.h> struct termios { tcflag_t c_iflag; tcflag_t c_oflag; tcflag_t c_cflag; tcflag_t c_lflag; cc_t c_cc[NCCS]; };
成员的名字对应上面列表中的五个参数。
可以通过调用tcgetattr函数来为终端初始化termios结构,其函数原型如下。
#include <termios.h> int tcgetattr(int fd, struct termios *termios_p);
这个函数调用将终端接口变量的当前值写入由termios_p所指向的结构中。如果这些值被修改了,可以使用tcsetattr函数来重新配置终端接口。
#include <termios.h> int tcsetattr(int fd, int actions, const struct termios *termios_p);
tcsetattr函数中的actions域控制如何应用这些修改。三个可能的值分别如下。
(1)TCSANOW:立即更改。
(2)TCSADRAIN:当前输出完成时更改。
(3)TCSAFLUSH:当前输出完成时更改,但是忽略当前可用的输入与read调用中未返回的输入。
在程序启动前保存终端设置是非常重要的,通常程序负责初始保存并在程序完成时恢复设置。
下面详细讲述这些模式以及相关的函数调用。
1.输入模式
输入模式控制输入(终端驱动器在串口或是键盘上接收的字符)在传递给程序之前是如何处理的,通过在termios结构的c_iflag成员中设置相应的标记来进行设置。所有这些标记都定义为宏,而且可以使用位或进行组合。对于所有的终端模式都是如此。
可以用于c_iflag的宏如下。
BRKINT:在一行中检测到中断(break)条件时产生一个中断。
IGNBRK:在一行中忽略中断条件。
INCRNL:将接收到的回车转换为换行。
IGNCR:忽略接收到的回车。
INLCR:将接收到的新行转换为回车。
IGNPAR:忽略带有奇偶检验误差的字符。
INPCK:在接收到的字符上执行奇偶校验。
PARMRK:标记奇偶校验误差。
ISTRIP:去除所有的输入字符。
IXOFF:在输入上允许软件流控制。
IXON:在输出上允许软件流控制。
如果没有设置BRKINT与IGNBRK,一行中的break条件将会被读取为NULL(Ox00)字符。我们并不需要经常改变输入模式,因为默认值通常是最合适的,所以在这里不会进行更深入地讨论。
2.输出模式
输出模式控制输出字符是如何进行处理的,也就是说,程序所发送的字符在传递到串口或是屏幕之前是如何被处理的。许多输出模式都有相对应的输入模式。此外还存在一些其他的标记,这些标记主要关注于需要时间处理字符的慢速终端。几乎所有这些模式都可以使用终端功能的terminfo数据进行处理,这会在后面介绍到。
通过设置termios结构的c_flag成员标记可以控制输出模式,在c_oflag允许使用的标记如下。
OPOST:打开输出处理。
ONLCR:将输出的新行转换为回车-换行。
OCRNL:将输出的回车转换为新行。
ONOCR:在第0列不输出回车。
ONLRET:新行也需要一个回车。
OFILL:发送填充字符来提供延时。
OFDEL:使用DEL作为填充字符,而不是NULL。
NLDLY:新行延时选择。
CRDLY:回车延时选择。
TABDLY:Tab延时选择。
BSDLY:Backspace延时选择。
VTDLY:垂直Tab延时选择。
FFDLY:换页延时选择。
如果OPOST没有设置,所有其他的标记都会被忽略。输出模式也不经常使用,所以这里也不进行深入讨论。
3.控制模式
控制模式控制终端的硬件特点。可以通过设置termios结构中的c_cflag成员的值来指定控制模式,其可用的值如下。
CLOCAL:忽略调制解调器状态行。
CREAD:允许字符接收。
CS5:在发送或是接收的字符中使用5位(5 bits)。
CS6:在发送或是接收的字符中使用6位。
CS7:在发送或是接收的字符中使用7位。
CS8:在发送或是接收的字符中使用8位。
CSTOPB:每个字符使用两个结束位,而不是一个。
HUPCL:关闭时挂起调制解调器。
PARENB:允许奇偶校验生成与检测。
PARODD:设置为奇校验。
如果设置了HUPCL,当终端驱动器检测到指向终端的最后一个文件描述符已经关闭时,它就会将调制解调器控制行设置为挂起。控制模式主要用于串口线连接到一个调制解调器上的情况,尽管他们也可以用于与终端交互。通常,使用termios的控制模式改变终端的配置要比改变默认行行为简单得多。
4.本地模式
本地模式控制终端的各种特性,可以通过设置termios结构中的c_lflag成员的值来指定本地模式,其可用的宏如下。
ECHO:允许输入字符的本地回显。
ECHOE:在接收EPASE时执行Backspace、Space和Backspace组合。
ECHOK:在KILL字符上执行清除行。
ECHONL:回显新行字符。
ICANON:允许正规输入处理。
IEXTEN:允许实现特定函数。
ISIG:允许信号。
NOFLSH:禁止队列flush。
TOSTOP:在写尝试上发送后台处理信号。
最重要的两个标记为ECHO(允许系统管理者抑制输入字符的回显)和ICANON(用于在两个不同的处理接收字符模式中切换终端)。如果设置了ICANON标记,这一行就处理正规模式;如果没有,这一行就处理非正规模式。
5.特殊控制字符
另外还有一些字符集合,例如Ctrl-C,当用户输入时会以特殊的方式运行。termios结构的c_cc数组成员包含映射到每一个支持函数的字符。每一个字符的位置(在数组中的索引)是由一个宏定义的,但是它们必须控制的字符并没有限制。
依据终端是否设置为正规模式(例如,在termios的c_lfalg成员设置ICANON标记),c_cc数组以两种不同的方式来使用。这里要注意的是,两种不同模式的数组索引值所使用的方式有某些重叠。正因如此,绝不能混用这两种模式的值。
对于正规模式,数组索引如下。
VEOF:EOF字符。
VEOL:EOL字符。
VERASE:ERASE字符。
VINTR:INTR字符。
VKILL:KILL字符。
VQUIT:QUIT字符。
VSUSP:SUSP字符。
VSTART:START字符。
VSTOP:STOP字符。
对于非正规模式,数组索引如下。
VINTR:INTR字符。
VMIN:MIN值。
VQUIT:QUIT字符。
VSUSP:SUSP字符。
VTIME:TIME值。
VSTART:START字符。
VSTOP:STOP字符。
因为特殊字符以及非正规字符MIN与TIME值对于高级输入字符的处理非常重要,所以这里进行详细地解释,如表4-10所示。
表4-10 特殊字符与描述
TIME与MIN的值只用于非正规模式,而且共同作用来控制输入的读取。同时,它们也控制当程序试图读取与一个终端相关联的文件描述符时会发生什么,有四种情况。
MIN=0同时TIME=0 在这种情况下,一个read调用会立即返回。如果某些字符可用,它们就会立即返回;如果没有可用字符,read会返回零并且不会读取任何字符。
MIN=0同时TIME>0 在这种情况下,当有任何可以读取的字符或是TIME的1/10秒逝去时,read会返回。如果因为时间过期没有读取任何字符,read就会返回零。否则,会返回读取的字符数。
MIN>0同时TIME=0 在这种情况下,read会等待直到有MIN个字符可以读取,然后返回读取的字符数。在文件结尾时会返回零。
MIN>0同时TIME>0 这是最复杂的情况。当调用read时,它等待接收一个字符。当接收到第一个字符,以及在接下来的时间序列内接收到一个字符时,就会启动一个中间字符(inter-character)计时器(如果已经在运行就重新启动)。当有MIN个字符可以读取或是中间字符计时器的TIME时间值过去1/10秒时,read会返回。这可以用于区分Escape按键的一次按下操作与一个函数键值转义序列的启动之间的区别。但要小心,网络通信或是高级处理器会擦除时间信息。
通过设置非正规模式及使用MIN与TIME值,程序可以处理逐个字符的输入。如果要查看正在使用的shell所使用的termios设置,可以使用下面的命令来得到一个列表。
$ styy -a
在Linux系统上,对标准的termios结构进行一些扩展,其输出如下。
speed 38400 baud; rows 44; columns 109; line = 0; intr = ^C; quit = ^/; erase = ^?; kill = ^U; eof = ^D; eol = <undef>; eol2 = <undef>; start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W; lnext = ^V; flush = ^O; min = 1; time = 0; -parenb -parodd cs8 -hupcl -cstopb cread -clocal -crtscts -ignbrk -brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff - iuclc -ixany -imaxbel opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0 isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt echoctl echoke
在这些输出结果之间,可以看到EOF字符为Ctrl+D并允许回显。如果试验终端控制,可以很容易使这个终端处理非标准状态,从而会其使用非常困难。有几种方法可以做到这一点。
第一种方法是,如果stty版本支持,可以使用下面的命令。
$ stty sane
如果失去了Enter键到新行字符的映射,需要输入“stty sane”,但不是按Enter键,而是按Ctrl+J(新行字符)。
第二种方法是使用stty -g命令将当前的stty设置保存为可以重新读取的格式。在命令行中,可以使用下面的命令。
$ stty -g > save_stty .. <experiment with settings> .. $ stty $(cat save_stty)
对于最后一个stty命令仍然需要使用Ctrl+J而不是回车。在shell脚本中也使用同样的技术。
save_stty="$(stty -g)" <alter stty settings> stty $save_stty
第三种方法是使用另一个不同的终端,通过ps命令来查看要使其成为不可用的shell,然后使用kill HUP<process id>来强制结束这个shell。因为stty参数总是在一个登录提示出现之前进行设置的,所以可以正常登录。
也可以使用stty命令直接由命令行来设置终端模式。要设置一个shell脚本可以执行单个字符读取的模式,需要关闭正规模式,同时将min设置为1,而time设置为0。命令如下。
$ stty -icanon min 1 time 0
现在终端被设置为可以立即读取字符。可以试着运行第一个程序,会发现其运行情况正如我们所希望的那样。也可以在提示输入密码之前关闭回显来输入密码,其命令如下。
$ stty -echo
一定要记住,在试验之后一定要用命令stty echo来打开回显。
termios结构所提供的最后一个函数可以操作线速率。是由函数调用来设置。四个调用原型如下。
#include <termios.h> speed_t cfgetispeed(const struct termios *); speed_t cfgetospeed(const struct termios *); int cfsetispeed(struct termios *, speed_t speed); int cfsetospeed(struct termios *, speed_t speed);
这些函数是作用在termios结构上的,而不是直接作用在端口上。这就意味着要设置一个新的速率,必须使用tcgetattr读取当前的设置,使用上述函数中的一个来设置速率,然后使用tcsetattr将termios结构写回。只有tcsetattr调用之后,线速率才会改变。
在上面的函数调用中允许各种速率值,其中最重要的为:B0,挂起终端;B1200,1200波特;B2400,2400波特;B9600,9600波特;B19200,19200波特;B38400:38400波特。标准并没有定义大于38400的速率,对于大于这个速率的串口也没有相应的支持函数。一些系统,包括Linux,为选择更快的速率定义了B57600、B115200和B230400这几个速率。如果使用的是Linux的某个早期版本,不可以使用这些常量,则可以使用setserial命令来获得57600和115200等非标准速率。在这种情况下,当选择B38400时会使用这些速率。这两种方法都是不可以移植的,所以使用时要注意。
对于终端控制还有一些其他的函数,这些函数直接作用在文件描述符上,而不需要读取与设置termios结构。它们的定义如下。
#include <termios.h> int tcdrain(int fd); int tcflow(int fd, int flowtype); int tcflush(int fd, int in_out_selector);
这些函数的作用如下。
tcdrain:会使调用函数在所有的输出队列发送之前等待。
tcflow:用于中止或是重启输出。
tcflush:可用于冲刷输入、输出或是二者。
前面已经讨论了关于termios结构相当多的主题内容,下面来看一些实际的例子。也许最简单的例子就是在读取密码时禁止回显了,这可以通过关闭ECHO标记来做到。
【例4-1】使用termios读取密码。
(1)密码程序password.c,以下面的定义开始。
#include <termios.h> #include <stdio.h> #define PASSWORD_LEN 8 int main() { struct termios initialrsettings, newrsettings; char password[PASSWORD_LEN + 1];
(2)接下来使用下面的语句由当前的标准输入读取当前的设置,并且将其复制到前面创建的termios结构中。
tcgetattr(fileno(stdin), &initialrsettings)
(3)制作一份原始设置的拷贝来替换它们。在newrsettings中关闭ECHO标记,并且询问用户密码。
newrsettings = initialrsettings; newrsettings.c_lflag &= ~ECHO; printf("Enter password: ");
(4)将终端属性设置为newrsettings并读取密码。最后,将终端属性设置为其原始属性,并且打印密码来验证前面的效果。
if(tcsetattr(fileno(stdin), TCSAFLUSH, &newrsettings) != 0) { fprintf(stderr,"Could not set attributes/n"); } else { fgets(password, PASSWORD_LEN, stdin); tcsetattr(fileno(stdin), TCSANOW, &initialrsettings); fprintf(stdout, "/nYou entered %s/n", password); } exit(0); }
执行过程如下。
$ ./password Enter password: You entered hello $
在这个例子中,在“Enter password:”提示之后输入“hello”,但是输入的字符并没有回显,直到用户按Enter键时才产生输出。我们很小心地使用语句X &= ~FLAG(清除相应的FLAG位)来改变需要改变的标记位。如果需要,可以使用X |= FLAG来设置由FLAG定义的位,虽然在上面的这个例子中并不需要这样做。
当设置属性时,使用TCSAFLUSH来忽略程序准备读取之前用户所输入的字符,这是使用户在回显关闭之前不需要输入密码的一个好办法。同时,也在程序结束之前恢复了先前的设置。
termios结构的另一个通常用法是可以设置终端为一种可以立即读取用户输入字符的状态,这可通过关闭正规模式并且设置MIN与TIME的值来做到。
【例4-2】读取每个字符。
(1)下面的代码与pasword.c相类似,但是需要插入到menu3.c中来生成新程序menu4.c。在开始之前,程序顶部包含一个新的头文件,内容如下。
#include <stdio.h> #include <unistd.h> #include <termios.h>
(2)然后在main函数中定义一些新的变量。
int choice = 0; FILE *input; FILE *output; struct termios initial_settings, new_settings;
(3)在调用getchoice函数之前修改终端特点,这里就是需要插入代码的地方。
fprintf(stderr, "Unable to open /dev/tty/n"); exit(1); } tcgetattr(fileno(input),&initial_settings); new_settings = initial_settings; new_settings.c_lflag &= ~ICANON; new_settings.c_lflag &= ~ECHO; new_settings.c_cc[VMIN] = 1; new_settings.c_cc[VTIME] = 0; if(tcsetattr(fileno(input), TCSANOW, &new_settings) != 0) { fprintf(stderr,"could not set attributes/n"); } fprintf(stderr, "Unable to open /dev/tty/n"); exit(1); } tcgetattr(fileno(input),&initial_settings); new_settings = initial_settings; new_settings.c_lflag &= ~ICANON; new_settings.c_lflag &= ~ECHO; new_settings.c_cc[VMIN] = 1; new_settings.c_cc[VTIME] = 0; if(tcsetattr(fileno(input), TCSANOW, &new_settings) != 0) { fprintf(stderr,"could not set attributes/n"); }
(4)在程序结束之前恢复原始设置。
do { choice = getchoice("Please select an action", menu, input, output); printf("You have chosen: %c/n", choice); } while (choice != 'q'); tcsetattr(fileno(input),TCSANOW,&initial_settings); exit(0); }
(5)检测回车换行符以确保处于非正规模式,因为不会再执行默认的CR到LF的映射。
do { selected = fgetc(in); } while (selected == '/n' || selected == '/r');
(6)如果此时用户在我们程序运行时按Ctrl+C键,程序就会终止。可以通过在本地模式中清除ISIG标记来禁止特殊字符的处理,在主函数中添加下面的代码行即可。
new_settings.c_lflag &= ~ISIG;
如果将这些修改加入到上面的程序中,就会得到一个立即响应并且输入不会回显的主程序。
$ ./menu4 Choice: Please select an action a - add new record d - delete record q - quit You have chosen: a Choice: Please select an action a - add new record d - delete record q - quit You have chosen: q $
4.2.3 终端输出
使用termios结构可以控制键盘输入,但是如果能对显示在屏幕上的输出进行同样级别的控制也许会更好。本节将介绍使用printf向屏幕输出字符的方法,但是这种输出无法将输出定位在屏幕上的某个特定位置。
1.终端类型
许多Linux系统使用终端,尽管在现在的多数情况下,终端也许就是一个运行终端程序的PC。从历史上来说,不同的生产厂商提供了大量的硬件终端。尽管它们都使用转义序列(以转义字符开始的字符串)来提供对光标与属性的控制,例如粗体与闪烁等,但是并没有以标准的方式来提供这些特性。某些老的终端还具有不同的滚动功能,当发送backspace滚动条时也许会消失。
硬件终端的多样性对于那些希望编写控制屏幕以及运行在多个终端类型上的软件的程序员来说,是一个极大的问题。例如,ANSI标准使用转义序列Escape+[+A来将光标上移一行,然而ADM-3a终端却使用控制字符Ctrl+K。要编写处理各种不同的连接到Linux系统上的终端类型的程序是一件极其困难的任务,程序也许要为每一个终端类型提供不同的源代码。
这样,在一个名为terminfo的包中提供了一个解决方案就显得并不为奇。程序不会迎合各种终端类型,相反,程序会查找一个终端类型数据库来得到正确的信息。在大多数的现代Unix系统中,包括Linux,这些信息已经被集成到一个名为curses的软件包中。在Linux中要使用ncurses来实现,并且要包含ncurses.h文件来提供terminfo函数的原型。terminfo函数本身的声明在其头文件term.h中。而在新版本的Linux系统中,在terminfo与ncurses之间有一个模糊的界线,许多需要terminfo函数的程序必须同时包含ncurses头文件。为避免混乱,现在的Linux发行版本同时提供一个与Unix系统更兼容的curses头文件与库。在这些系统上,推荐使用curses.h与-lcurses。
2.标识终端类型
Linux环境包含一个变量TERM,它被设置为当前正在使用的终端类型,通常是在系统登录时由系统自动设置。系统管理员也许会为每一个直接连接到终端的用户设置一个默认的终端类型,这些用户也许是要提供终端类型的远程或是网络用户。TERM的值可以通过telnet协商,并通过rlogin传递。
用户可以查询shell来确定正使用的终端类型。
$ echo $TERM xterm $
在这个例子中,shell是由一个名为xterm的程序来运行的,它是一个X Window系统的终端模拟器,或是提供类似功能的程序,例如KDE的konsole或是gnome的gnome-terminal。
3.定义terminfo功能
terminfo软件包包含了一个由大量不同类型终端的功能标志和转义序列等信息组成的数据库,并且为程序员提供了统一的接口。这样编写的程序就可以在数据库扩展时利用未来终端的优点,而不是每一个程序都必须为不同的终端提供支持。terminfo的功能是通过属性来描述的,这些属性存储在已编译的terminfo文件集合中,并且通常可以在/usr/lib/terminfo或是/usr/share/terminfo中找到。对于每一个终端(也包括一些可以在terminfo中指定的打印机),有一个文件来定义其功能以及如何访问这些特性。为了避免创建一个非常大的目录,实际应用中文件都存储在子目录中,而子目录的名字只是终端类型的第一个字符。所以,VT100的定义可以在...terminfo/v/vt100中找到。
对于每一个终端类型都会以可读的源码格式来编写一个terminfo文件,然后使用tic命令将其编译为应用程序可用的更为紧凑和高效的格式。奇怪的是,X/Open规范谈到源码以及编译的格式定义,但是却没有提到实际编译源码的tic命令。使用infocmp程序可以输出一个已编译的terminfo实体的可读版本信息。
下面是一个VT100终端的terminfo文件的例子。
$ infocmp vt100 vt100|vt100-am|dec vt100 (w/advanced video), am, mir, msgr, xenl, xon, cols#80, it#8, lines#24, vt#3, acsc=``aaffggjjkkllmmnnooppqqrrssttuuvvwwxxyyzz{{||}}~~, bel=^G, blink=/E[5m$<2>, bold=/E[1m$<2>, clear=/E[H/E[J$<50>, cr=/r, csr=/E[%i%p1%d;%p2%dr, cub=/E[%p1%dD, cub1=/b, cud=/E[%p1%dB, cud1=/n, cuf=/E[%p1%dC, cuf1=/E[C$<2>, cup=/E[%i%p1%d;%p2%dH$<5>, cuu=/E[%p1%dA, cuu1=/E[A$<2>, ed=/E[J$<50>, el=/E[K$<3>, el1=/E[1K$<3>, enacs=/E(B/E)0, home=/E[H, ht=/t, hts=/EH, ind=/n, ka1=/EOq, ka3=/EOs, kb2=/EOr, kbs=/b, kc1=/EOp, kc3=/EOn, kcub1=/EOD, kcud1=/EOB, kcuf1=/EOC, kcuu1=/EOA, kent=/EOM, kf0=/EOy, kf1=/EOP, kf10=/EOx, kf2=/EOQ, kf3=/EOR, kf4=/EOS, kf5=/EOt, kf6=/EOu, kf7=/EOv, kf8=/EOl, kf9=/EOw, rc=/E8, rev=/E[7m$<2>, ri=/EM$<5>, rmacs=^O, rmkx=/E[?1l/E>, rmso=/E[m$<2>, rmul=/E[m$<2>, rs2=/E>/E[?3l/E[?4l/E[?5l/E[?7h/E[?8h, sc=/E7, sgr=/E[0%?%p1%p6%|%t;1%;%?%p2%t;4%;%?%p1%p3%|%t;7%;%?%p4%t;5%;m%?%p9%t^ N%e^O%;, sgr0=/E[m^O$<2>, smacs=^N, smkx=/E[?1h/E=, smso=/E[1;7m$<2>, smul=/E[4m$<2>, tbc=/E[3g,
每一个terminfo定义由三种类型的实体构成,每一个实体被称之为capname并且定义了一个终端功能。布尔功能只是简单地指示一个终端是否支持一个特定的功能。例如,如果终端支持XON/XOFF流控制就会显示xon布尔功能。数值功能定义了与长度有关的参数,例如lines定义了屏幕上的行数,而cols定义了屏幕上的列数。指定的数字是通过#字符与功能相区分的。例如要定义一个具有80列与24行的终端,可以写成cols#80,lines#24。字符串功能显得有些复杂,它们用于两种不同的功能:定义访问终端所需要的输出字符串以及定义当用户按下特定的按键时会接收的输入字符串,通常为功能键或是数字键盘上的特殊键。某些字符功能相当简单,例如el,表示“清除直到一行结束”。在一个VT100的终端上,要完成这个任务的转义序列为Esc+[+K,在terminfo的源码格式中则为el=/E[K。特殊键的定义与此相类似,例如,VT100上的功能键F1发送的转义序列为Esc+O+P,其定义为kf1=/EOP。
当转义序列需要一些参数时,其定义会显得有些复杂。大多数的终端可以将光标移动到一个特定的行与列位置。例如,VT100终端使用转义序列Esc+[+<row>+;+<col>+H来将光标移到一个指定位置,在terminfo源码格式中,其定义为cup=/E[%i%p1%d;%p2%dH来将光标移动到指定位置。
其含义如下。
/E:发送Escape。
[:发送“[”字符。
%i:增加参数。
%p1:将第一个参数放入堆栈。
%d:将堆栈上的数字作为十进制数字输出。
;:发送“;”字符。
%p2:将第二个参数放入堆栈。
%d:将堆栈上的数字作为十进制数字输出。
H:发送H字符。
这看起来似乎有些复杂,但是却允参数以固定的顺序出现,独立于终端希望它们出现在最终的转义序列中的顺序。增加参数%i是必须的,因为标准的光标位置位于屏幕的左上角(0,0),但是VT100的光标位置为(1,1)。最后的$<5>表明需要等同于输出5个字符的时间来让终端处理光标移动。
我们将会定义许多终端,幸运的是,大多数的Unix和Linux系统已经预定义了大多数的终端。如果需要添加一个新的终端,可以在terminfo手册页中查找到完整的功能列表。一个好的起点是定位那些与新终端相似的终端,将新终端定义为已存在终端的一个变体。
4.使用terminfo功能
知道了如何定义终端这项功能,接下来需要了解如何访问终端。当使用terminfo时,需要做的第一件事就是通过调用setupterm来设置终端类型,这会为当前的终端类型初始化一个TERMINAL结构,然后才可以访问并使用终端功能。setupterm函数原型如下。
#include <term.h> int setupterm(char *term, int fd, int *errret);
setupterm库函数将当前的终端类型设置为参数term所指定的终端类型。如果term为一个空指针,那么就会使用TERM环境变量。写入终端所用的打开的文件描述符必须由参数fd传递。函数执行结果存储在由errret所指向的整型变量中(如果该变量不为空)。写入的值可能如下。
-1:没有terminfo数据库。
0:在terminfo数据库中没有匹配的实体。
1:成功。
如果成功,setupterm函数会返回常量OK,如果失败则会返回ERR。如果errret设置为一个空指针,函数执行失败时就会输出一个诊断信息并且退出程序,如下面的例子所示:
#include <stdio.h> #include <term.h> #include <ncurses.h> int main() { setupterm("unlisted",fileno(stdout),(int *)0); printf("Done./n"); exit(0); }
在这里并没有打印出Done,因为setupterm函数执行失败从而导致程序退出。
$ cc -o badterm badterm.c -I/usr/include/ncurses -lncurses $ badterm 'unlisted’: unknown terminal type. $
注意上面例子中的编译命令。在这个Linux系统上,ncurses头文件位于/usr/include/ ncurses目录,所以必须使用-I选项来指示编译器在这里进行查找。而某些Linux系统也许会由标准位置访问ncurses库,在这些系统上,只需要简单地包含curses.h头文件,并且为库指定-lcurses选项即可。
对于菜单选择函数,我们希望可以有清屏、在屏幕上移动光标,以及可以在屏幕上的任意位置写入。一旦调用了setupterm函数,就可以使用不同的函数来访问terminfo功能,其功能类型如下。
#include <term.h> int tigetflag(char *capname); int tigetnum(char *capname); char *tigetstr(char *capname);
函数tigetflag、tigetnum、tigetstr分别返回布尔数字值以及字符串terminfo功能。如果失败,tigetflag会返回-1,tigetnum会返回-2,而tigetstr会返回(char *)-1。
下面使用程序sizeterm.c取得cols与lines的设置值以确定终端尺寸。
#include <stdio.h> #include <term.h> #include <ncurses.h> int main() { int nrows, ncolumns; setupterm(NULL, fileno(stdout), (int *)0); nrows = tigetnum("lines"); ncolumns = tigetnum("cols"); printf("This terminal has %d columns and %d rows/n", ncolumns, nrows); exit(0); } $ echo $TERM vt100 $ sizeterm This terminal has 80 columns and 24 rows $
如果在工作站的一个窗口内运行这个程序,会得到反映当前窗口尺寸的答案。
$ echo $TERM xterm $ sizeterm This terminal has 88 columns and 40 rows $
如果使用tigetstr来取得xterm终端类型的光标移动功能(cup),会得到一个参数化的答案/E[%p1%d;%p2%dH。这个功能需要两个参数:光标要移动到的行与列,这两个坐标都是由屏幕左上角的零点处开始计量的。
可以使用tparm函数用实际的值来代替功能中的参数,最多可以替换9个参数,并且会返回一个可用的转义序列。
#include <term.h> char *tparm(char *cap, long p1, long p2, ..., long p9);
一旦使用tparm来组织终端转义序列,必须将其发送到终端。要正确地处理,不应使用printf来向终端发送字符串,而要使用特殊的函数,这些函数为终端完成一个操作的正确处理提供了必要的延时。这些函数包括:
#include <term.h> int putp(char *const str); int tputs(char *const str, int affcnt, int (*putfunc)(int));
如果成功,putp返回OK,如果失败,则会返回ERR。putp函数将终端控制字符串作为参数并且将其发送到标准输出设备。
如果要移动到屏幕的第5行,第30列,可以使用下面的代码。
char *cursor; char *esc_sequence; cursor = tigetstr("cup"); esc_sequence = tparm(cursor,5,30); putp(esc_sequence);
tputs函数是为那些不能通过stdout访问终端但允许指定输出字符所使用的函数而提供的。它会返回用户指定的函数putfunc的结果。affcnt参数用来指明更改会影响到的行数,通常将其设置为1。用于输出字符串的函数必须与putchar函数具有相同的参数与返回结果。事实上,putp(string)等同于调用tputs(string,1,putchar),读者将会在下面的例子中以用户指定的输出函数来使用tputs函数。一些老的Linux版本将tputs函数的最后一个参数定义为int (*putfunc)(char),这会强制要求修改char_to_terminal函数。
现在已经准备好为菜单选择功能添加屏幕处理功能了,还有一件需要做的事就是使用clear来清除屏幕。某些终端不支持clear功能,使得光标停留在屏幕的左上角。在这种情况下,可以将光标放置在左上角,并且使用“删除直到显示结尾”命令ed。
将所有这些信息结合在一起,就可以编写菜单程序的最终版本screen-menu.c,到这一步将会在屏幕上“画”出选项,从而供用户选择。
【例4-3】完全终端控制。
重新编写menu4.c的getchoice函数可为用户提供完全的终端控制。在下面的程序代码中,省略了main函数,因为它没有变化。
#include <stdio.h> #include <unistd.h> #include <termios.h> #include <term.h> #include <curses.h> static FILE *output_stream = (FILE *)0; char *menu[] = { "a - add new record", "d - delete record", "q - quit", NULL, }; int getchoice(char *greet, char *choices[], FILE *in, FILE *out); int char_to_terminal(int char_to_write); int main() { ... } int getchoice(char *greet, char *choices[], FILE *in, FILE *out) { int chosen = 0; int selected; int screenrow, screencol = 10; char **option; char *cursor, *clear; output_stream = out; setupterm(NULL,fileno(out), (int *)0); cursor = tigetstr("cup"); clear = tigetstr("clear"); screenrow = 4; tputs(clear, 1, (int *) char_to_terminal); tputs(tparm(cursor, screenrow, screencol), 1, char_to_terminal); fprintf(out, "Choice: %s, greet); screenrow += 2; option = choices; while(*option) { tputs(tparm(cursor, screenrow, screencol), 1, char_to_terminal); fprintf(out,"%s", *option); screenrow++; option++; } fprintf(out, "/n"); do { fflush(out); selected = fgetc(in); option = choices; while(*option) { if(selected == *option[0]) { chosen = 1; break; } option++; } if(!chosen) { tputs(tparm(cursor, screenrow, screencol), 1, char_to_terminal); fprintf(out,"Incorrect choice, select again/n"); } } while(!chosen); tputs(clear, 1, char_to_terminal); return selected; } int char_to_terminal(int char_to_write) { if (output_stream) putc(char_to_write, output_stream); return 0; }
重写的getchoice函数实现了与前面的例子中相同的菜单,但是对输出函数进行了修改,从而可以使用terminfo功能。如果希望在屏幕被清除之前使“You have chosen:”的信息停留一会,可以使用下面的选择,在main函数中添加一个sleep调用。
do { choice = getchoice("Please select an action", menu, input, output); printf("/nYou have chosen: %c/n", choice); sleep(1); } while (choice != 'q');