07 | linux终端的重新认识(1)
目标:学习如何更好的控制用户终端,包括控制键盘输入及屏幕输入。学习如何保证编写的程序能够从用户哪里获取输入(即使用户对程序使用了输入重定向),以及确保程序的输入显示在屏幕的正确位置上(第2篇)。
对终端进行读写
已知:当一个程序在命令提示符中被调用时,shell 负责将标准输入和标准输出流连接到你的程序。通过在程序中使用 getchar 和 printf 函数你可以很容易的对这些默认流进行读写,实现程序和用户的交互。
#include <stdio.h>
#include <stdlib.h>
char *menu[]={
"a - add new record",
"d - delete record",
"q - quit",
NULL, //遍历的哨兵元素
};
int getchoice(char *greet,char *choices[]);
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)
}
下面是这个程序的核心代码:负责显示菜单及读取用户输入的函数getchoice:
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;
}
用户必须要输入“A/回车/Q/回车"等才能做出选择。从上面的例子中可以看出,这个程序至少有两个问题。最严重的问题是,每当你做出正确的选择后,屏幕上都会出现错误提Incorrectchoice,(因为 回车键 )select again (错误的选择,请重新选择)。另一个问题是,只有在按下回车键后程序才会读取输入。
标准模式和非标准模式
这两个问题是紧密相关的。默认情况下,只有在用户按下回车键后,程序才能读到终端的输入。在大多数情况下,这样做是有益的,因为它允许用户使用退格键( Backspace)或删除键(Delete) 来纠正输入中的错误,用户只在对自己在屏幕上看到的内容满意时,才会按下回车键把键入的数据传递给程序。
这种处理方式被称为规范模式( canonical mode)或标准模式( standard mode)。
所有的输入都基于行进行处理,在一个输入行完成前(通常是用户按下回车键之前),终端接口负责管理所有的键盘输入,包括退格键,应用程序读不到用户输入的任何字符。
与标准模式相对的是非标准模式(non-canonical mode),在这种模式中,应用程序对用户输入字符的处理拥有更大的控制权。我们稍后会再回到这两种模式上来。
那么,这个程序的问题究竟在哪里呢?是这样的,Linux会 暂存用户输入的内容,直到用户按下
回车键,然后将用户选择的字符及紧随其后的回车符一-起传递给程序。所以,每当你输入一个菜单选择时,程序就调用getchar函数处理该字符,而当程序在下- - 次循环中再次调用getchar函数时,它会立刻返回-一个回车符😭。
程序真正看到的字符并不是ASCII码的回车符CR (十进制表示为13,十六进制表示为oD),而是换行符LF (十进制表示为10,十六进制表示为0A)。
这是因为,Linux同UNIX系统一样,在其内部都是以换行符作为文本行的结束。也就是说,UNIX用-一个单独的换行符来表示一行的结束,而其他的操作系统(如MS-DOS)用回车符和换行符两个字符的结合来表示一行的结束。
如果输入或输出设备本身需要发送或接收一一个回车符, 则由Linux终端处理程序负责完成它。如果你已经习惯MS_DOS或其他操作系统的环境,你可能会对Linux的这种做法感到有一些奇怪。 但这样做的最大好处是,它使得在Linux系统中,文本文件和二进制文件无任何实际的区别。只有在对终端、某些打印机或绘图仪进行输入输出时,你才需要对回车符进行处理。
在下面的代码中,通过忽略额外的换行符来纠正菜单例程中的主要错误:
do{
selected=getchar();
}while(selected=='\n');
处理重定向输出
./menu1 > file
程序的输出确实被重定向到文件,而不是显示在终端上。
❓但是,如果你希望对准备让用户看到的提示信息与其他输出区别对待呢?也就是并不是所有的都重定向了呢?
😮如果想知道标准输出是否被重定向了,只需检查底层文件描述符是否关联到一个终端即可。系统
调用isatty就是用来完成这一 任务的。你只需将有效的文件描述符传递给它,它就可判断出该描述符是否连接到一个终端。
include <unistd.h>
int isatty(int fd);
如果打开的文件描述符 fa 连接到一个终端,则系统调用isatty返回1,否则返回0。
在这个程序中,你使用的是文件流,但isatty只能对文件描述符进行操作。为了提供必要的转换,你需要把 isatty调用 与 fileno函数 (👉在python 中也 有同名的函数)结合使用。
文件流是 对 文件描述符 更高层的抽象
//检查是否存在输出重定向
#include <unistd.h>
if(!isatty(fileno(stdout))){
fprintf(stderr, "you are not a terminal\n" ); //说明被重定向了!
exit(1);
}
与终端进行对话
如果不希望程序中与用户交互的部分被重定向,但允许其他的输入和输出被重定向,你就需要将与用户交互的部分与stdout、stderr分 离开。为此,你可直接对终端进行读写。
由于Linux本身是多用户系统,它通常拥有多个终端,这些终端或者是直接连接的,或者是通过网络进行连接的,那么,你怎样才能找到要使用的正确终端呢?
😮幸运的是,Linux和UNIX提供了一个特殊设备/dev/tty来解决这一问题, 该设备始终是指向当前终端或当前的登录会话。由于Linux把一切事物都看作为文件,所以你可以用一般文件的操作方式来对/dev/tty进行读写。
在下面的实验中,你通过向getchoice函数传递参数的方法来加强对输出的控制,修改后的程序
为menu3.c.
对其做如下修改,使得输入和输出直接指向/dev/tty(任凭你如何重定向,这个设备就是终端不会变!):👇
#include <stdio.h>
#include <stdlib.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 可能会被重定向
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
cat file
终端驱动程序和通用终端接口
Linux提供了一组编程接口用来控制终端驱动程序的行为,从而使得更好地控制终端的输入和输出。
可以说这组接口决定了 终端 具体读写的行为😮(相当于给程序员开辟了一条绿色通道)。
概述
用UNIX的术语来说,控制接口定义了一个“线路规程”,它使程序在指定终端驱动程序的行为时拥有极大的灵活性。
下面是你能够控制的主要功能。
-
行编辑:是否允许用退格键进行编辑。
-
缓存:是立即读取字符,还是等待一段可配置的延迟之后再读取它们。
-
回显:允许控制字符的回显,例如读取密码时。
-
回车/换行(CR/LF): 定义如何在输入/输出时映射回车/换行符,比如打印\n字符时应该如何
处理。 -
线速:这一功能很少用于PC控制台,但对调制解调器或通过串行线连接的终端就很重要。
硬件模型
下面的概念布局图(某些早期UNIX站点的实际情况就是这样)是让一台UNIX机器通过串行口连接一台调制解调器,再通过电话线连接到用户端的调制解调器,该调制解调器最终连接到用户的
终端。事实上,这正是某些小型ISP(因特网服务提供商)在因特网早期使用的一种配置情况。这种
连接方式可以看作是客户/服务器模型的一个“远亲”,它用于程序运行在大型主机上,而用户工作在哑终端的情况。
使用这样一个硬件模型的好处是,绝大多数现实世界中的情况都只是这一最复杂情况的子集。如果这个模型忽略了一些功能,那么它就不能很好的支持各种现实情况。
termios 结构
termios是在POSIX规范中定义的标准接口,它类似于系统V中的termio接口。通过设置termios 类型的数据结构中的值和使用一小组函数调用,你就可以对终端接口进行控制。termios 数据结构和相关函数调用都定义在头文件termios .h中。
如果程序需要调用定义在termios.h头文件中的函数,它就需要与一个正确的函数库进
行链接,这个函数库可能是标准的C函数库或者curses函数库(取决于你的安装情况)。如果需要,在编译程序时,在编译命令的末尾加上-lcurses
在一些老版本的Linux系统中,curses库被命名为new curses.在这种情况下,库名和链接参数就需要相应地改为 ncurses 和- lncurses.
可以被调整来影响终端的值按照不同的模式被分成如下几组:
-
输入模式
-
输出模式
-
控制模式
-
本地模式🚩
-
特殊控制字符
最小的termios结构的典型定义如下(X/Open规范 允许包含附加字段):
#include <termios .h>
struct termios {
tcflag_t c_iflag;
tcflag_t c_oflag;
tcflag_t c_cflag;
tcflag_t c_1flag;
cc_t c_cc[NCCS] ;
};
tcgetattr 函数
你可以调用函数tcgetattr来初始化与一个终端对应的termios结构,该函数的原型如下:
#include <termios.h>
int tcgetattr(int fd, struct termios *termios_ p) ;
tcsetattr 函数
这个函数调用把当前终端接口变量的值写入termios_p参数指向的结构。如果这些值其后被修改
了,你可通过调用函数tcsetattr来重新配置终端接口,该函数的原型如下:
#include <termios.h>
int tcsetattr (int fd,int actions, const struct termios *termios_ D) ;
参数actions控制修改方式,共有3种修改方式,如下所示。
- TCSANOW:立刻对值进行修改。
- TCSADRAIN:等当前的输出完成后再对值进行修改。.
- TCSAFLUSH:等当前的输出完成后再对值进行修改,但丢弃还未从read调用返回的当前可用的任何输入。
注意,程序有责任将终端设置恢复到程序开始运行之前的状态,这一点是非常重要的。
首先保存这些值,然后在程序结束时恢复它们,这永远是程序的职责。
你首先应该了解的是本地模式,它也是最重要的一种模式。
👉 我们在本章中编写的第-一个应用程序出现了两个问题,其中第二个问题(用户必须按下回车键才能让程序读取输入)的解决方法是使用标准模式或非标准模式,即你可以让程序等待一行输 入完毕后再进行处理,或让它一有字符键入就立刻处理。
输入模式
输入模式控制输入数据(终端驱动程序从串行口或键盘接收到的字符)在被传递给程序之前的处理方式。你通过设置termios结构中c_ iflag成 员的标志对它们进行控制。所有的标志都被定义为宏,并可通过按位或的方式结合起来。这也是所有终端模式都采用的方法。
可用于c_ iflag成员的宏如下所示。
BRKINT:当在输入行中检测到一一个终止状态(连接丢失)时,产生一个中断。
IGNBRK:忽略输入行中的终止状态。
ICRNL:将接收到的回车符转换为新行符。
IGNCR:忽略接收到的回车符。
INLCR:将接收到的新行符转换为回车符。
IGNPAR:忽略奇偶校验错误的字符。
INPCK:对接收到的字符执行奇偶校验。
PARMRK:对奇偶校验错误做出标记。
ISTRIP:将所有接收到的字符裁减为7比特。
IXOFF:对输入启用软件流控。
IXON:对输出启用软件流控。如果BRKINT和IGNBRK标志都未被设置,则输入行中的终止状态就被读取为NULL (0x00)字符。
用户一般无需频繁修改输入模式,因为它的默认值通常就是最合适的,所以我们在这里就不过多讨论了。
输出模式
输出模式控制输出字符的处理方式,即由程序发送出去的字符在传递到串行口或屏幕之前是如何处理的。正如你预料的那样,许多处理方式正好与输入模式对应。
它还有几个其他标志,主要用于慢速终端,因为这些终端在处理回车符等字符时需要花费一定的时间。
你通过设置termios结构中c_oflag成员的标志对输出模式进行控制。可用于c_oflag成员的宏如下所示。
OPOST:打开输出处理功能。
ONLCR:将输出中的换行符转换为回车/换行符。
OCRNL:将输出中的回车符转换为新行符。
ONOCR:在第0列不输出回车符。
ONLRET:不输出回车符。
OFILL:发送填充字符以提供延时。
OFDEL:用DEL而不是NULL字符作为填充字符。
NLDLY:新行符延时选择。
CRDLY:回车符延时选择。
TABDLY:制表符延时选择。
BSDLY:退格符延时选择。
VTDLY:垂直制表符延时选择。
FFDLY:换页符延时选择。如果没有设置OPOST,则所有其他标志都被忽略。
控制模式
控制模式控制终端的硬件特性。你通过设置termios结构中c_cflag成 员的标志对控制模式进行配置。可用于c_cflag成员的宏如下所示。
CLOCAL:忽略所有调制解调器的状态行。
CREAD:启用字符接收器。 .
CS5:发送或接收字符时使用5比特。
CS6:发送或接收字符时使用6比特。
CS7:发送或接收字符时使用7比特。
CS8:发送或接收字符时使用8比特。
CSTOPB:每个字符使用两个停止位而不是-一个。
HUPCL:关闭时挂断调制解调器。
PARENB:启用奇偶校验码的生成和检测功能。
PARODD:使用奇校验而不是偶校验。如果设置了HUPCL标志,当终端驱动程序检测到与终端对应的最后一个文件描述符被关闭时, 它将通过设置调制解调器的控制线来挂断电话线路。
控制模式主要用于串行线连接调制解调器的情况,虽然它也可用来和终端进行“对话”。但与通过使用termios的控制模式来修改默认的线路行为相比,直接修改终端配置文件通常更加容易一些。
本地模式
本地模式控制终端的各种特性。你通过设置termios结构中c_1flag成员的标志对本地模式进行配置。可用于c_ _1flag成员的宏如下所示。
-
ECHO:启用输入字符的本地回显功能。🚩
-
ECHOE:接收到ERASE时执行退格、空格、退格的动作组合。.
-
ECHOK:接收到KILL字符时执行行删除操作。
-
ECHONL:回显新行符。
-
ICANON:启用标准输入处理(参见下面的说明)。🚩
-
IEXTEN:启用基于特定实现的函数。
-
ISIG:启用信号。🚩
-
NOFLSH: 禁止清空队列。
-
TOSTOP:在试图进行写操作之前给后台进程发送一一个信号。
👉这里最重要的两个标志是ECHO和ICANON。前者的作用是抑制键入字符的回显,而后者是将终端
在两个截然不同的接收字符处理模式间进行切换。如果设置了ICANON标志,就启用标准输入行处理模
式,否则,就启用非标准模式。
特殊控制字符
特殊控制字符是一些字符组合,如Ctrl+C, 当用户键入这样的组合键时,终端会采取一些特殊的处理方式。❓(有没有中断向量的感觉)termios结构中的c_ cc数组成员将各种特殊控制字符映射到对应的支持函数。每个字符的位置(它在数组中的下标)是由-一个宏定义的,但并不限制这些字符必须是控制字符。
根据终端是否被设置为标准模式(即termios结构中c_ 1flag成员是否设置了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字符。
😮貌似对我的键盘并不了解
字符
由于这些特殊字符和非标准值对于输入字符的高级处理非常重要,所以我们在这里对它们进行详
细的解释。
TIME和MIN值
TIME和MIN的值只能用于非标准模式,两者结合起来共同控制对输入的读取。此外,两者的结合使用还能控制在一个程序试图读取与一个终端关联的文件描述符时将发生的情况。
两者的结合分为如下4种情况。
-
MIN = 0和TIME = 0:在这种情况下,read调用总是立刻返回。
如果有等待处理的字符,它们就会被返回;如果没有字符等待处理,read调用返回0,并且不读取任何字符。
-
MIN = 0和TIME > 0:在这种情况下,只要有字符可以处理或者是经过TIME个十分之一秒的时间间隔,read调用 就返回。如果因为超时而未读到任何字符,read返回0,否则read返回读取的字符数目。
-
MIN > 0 和TIME = 0:在这种情况下,read 调用将一直等待, 直到有MIN个字符可以读取时才返回,返回值是读取的字符数量。到达文件尾时返回0。
-
MIN > 0和TIME > 0: 这是最复杂的一一种情况。当read被调用时,它会等待接收一个字符。在接收到第一个字符及后续的每个字符后,一个字符间隔定时器被启动(如果定时器已在运行,则重启它)。当有MIN个字符可读或两个字符之间的时间间隔超过TIME个十分之一秒时, read调用返回。
这个功能可用于区分是单独按下了Escape键还是按下一个以Escape键开始的功能组合键。
但要注意的是,网络通信或处理器的高负载将使得类似这样的定时器失去作用。
通过设置非标准模式与使用MIN和TIME值,程序可以逐个字符地处理输入。
通过shell访问终端模式
如果在使用shell时想查看当前的termios设置情况,可以使用下面的命令:
stty -a
从上面的命令输出中,你可以看到,EOF字符是CtrI+D并且启用了本地回显。当在做终端控制的练习时,一不小心就会将终端设置为非标准状态,这将使得终端的使用非常困难。下面几种方法可以帮你摆脱这种困境。
如果回车键和新行符的映射关系丢失。使用命令 stty sane ,然后按下 Ctrl+J(他对应新行符),而不是按下回车键Enter
第二种方法。用命令 stty -g 将当前stty 设置保存到某种可以重新读取的形式中,使用的命令如下:stty -g > save_stty。之后我们打开这个文件在这里面修改。最后执行stty $(cat save_stty) 。注意最后这个stty 命令可能也需要上面的第一种方法,因为“回车失效了”
第三种方法。如果上面两种方法都不能解决问题,还有第三种方法,就是从另一个终端登录,用ps命令查找不能使用的那个shell的进程号,然后用命令kill HUP <进程号>强制中止该shell。因为系统总是在给出登录提示符之前重置stty参数,所以你就可以正常地登录系统了。
在命令提示符下设置终端模式
😮你还可以在命令提示符下用stty命令直接设置终端模式。
比如说,如果想让shell脚本可以读取单字符,你就需要关闭终端的标准模式,同时将MIN设为1, .
TIME设为0。使用的命令如下:
$ stty -icanon min 1 time 0
😮在程序提示输入密码前将回显功能关闭。使用的命令如下:
$ stty -echo
注意,在使用上面命令之后要记住用命令stty echo将回显功能再次恢复启用。
其他函数
🚩在控制终端方面还有一些其他的函数。它们直接对文件描述符进行操作,不需要读写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标志即可做到这一点。
#include <termios.h>
#include <stdio.h>
#include <stdlib.h>
#define PASSWORD_LEN 8
int main()
{
struct termios initialresettings,newsettings;
char password[PASSWORD_LEN+1];
// 获取标准输入的当前设置,并保存
tcgetattr(fileno(stdin),&initialresettings);
// 留一个副本
newsettings=initialresettings;
newsettings.c_lflag &= ~ECHO;
printf("Enter password: ");
// 接下来,用newsettings 变量设置终端属性并读取用户输入的密码。
if(tcsetattr(fileno(stdin),TCSAFLUSH,&newsettings)!=0)
{
fprintf(stderr, "could not set attributes\n");
}
else
{
fgets(password,PASSWORD_LEN,stdin);
// 设为原来的值 立即生效
tcsetattr(fileno(stdin),TCSANOW,&initialresettings);
fprintf(stdout,"\n you entered %s\n",password);
}
exit(0);
}
实验效果 👇
🔧实验二
读取每个字符
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <termios.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;
struct termios initial_settings,new_settings; // 设置新变量
//判断是否重定向
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);
}
//-----------------------------------
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; //无阻塞
new_settings.c_lflag &= ~ISIG; //禁止对特殊字符处理,ctrl+c 没有办法退出
if(tcsetattr(fileno(input),TCSANOW,&new_settings)!=0)
{
fprintf(stderr,"could not set attributes\n");
}
//-----------------------------------
do
{
choice = getchoice("please select an action",menu,input,output);
//这一行 printf 可能会被重定向
printf("you have chosen: %c\n",choice);
}while(choice!='q');
//-------------------------------------
tcsetattr(fileno(input),TCSANOW,&initial_settings);
//-------------------------------------
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' || selected == '\r');
//---由于在非标准模式下,也就是本地模式,默认的回车和换行符之间的映射已经不存在了,
//所以需要对回车符 \r 进行检查 (其实就是过滤)
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;
}
如果将这些修改放入菜单程序,则只要用户一键入 字符就会立刻得到程序的响应,而且用户键入的字符不会回显。