Linux终端简介
- 导语
- 基本终端交互
- 终端读写
- 标准/非标准模式
- 重定向处理
- 终端对话
- termios结构
- 模式相关
- 输入模式
- 输出模式
- 控制模式
- 本地模式
- 特殊控制字符
- 终端速度
- 其他函数
- 终端输出
- 终端类型
- terminfo
- 击键动作检测
- 虚拟控制台&伪终端
- 总结
- 参考文献
导语
本章基本是以一个简单的用户交互程序作为基准,随后逐步拓宽,最后完成一个功能较为齐全的C语言交互程序,涉及到的知识点有输入输出模式、termios结构等
基本终端交互
终端读写
书上给出了一个简单的菜单例子,具体代码和运行结果如下
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/utsname.h>
#include <unistd.h>
#include <pwd.h>
#include <syslog.h>
#include <sys/resource.h>
#include <sys/time.h>
#include <math.h>
#include <time.h>
char *menu[]=
{
"a - add new record",
"d - delete record",
"q - quit",
NULL,
};
int getchoice(char *greet, char *choices)//实现选择
{
int choose=0,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])
{
choose=1;
break;
}
option++;
}
if(!choose)
printf("Incorrect choice, select again\n");
}while(!choose);
return selected;
}
int main()
{
int choice=0;
do
{
choice=getchoice("Please select an action",menu);//获取选项
printf("You have chosen: %c\n",choice);
}while(choice!='q');
return 0;
}
结果如下,可以发现,每次似乎多运行了一次getchoice函数,导致有一次无效的输出,这其实是因为输入实际上处理的是两个字符,一个是输入的字母,另一个是回车
标准/非标准模式
像上面所述的,只有在用户按下回车时,程序才能读到用户的输入,这种模式叫标准模式,它允许用户在真正要输入给程序时对输入进行修改,与标准模式相对的事非标准模式,它有更大的控制权
并且,在上述程序中,用户输入的是回车,但是程序看到的字符并不是回车符CR,而是换行符LF,因为Linux以换行符作为文本行的结束,一些操作系统用回车符和换行符两个字符的结合来表示一行的结束
可以将上述程序的字符获取部分改成
while((selected=getchar())=='\n');
这样就能把换行符给去掉了
重定向处理
Linux中可以将程序的I/O重定向到其他文件或程序,例如将刚才的程序进行重定向,得到的结果如下
可以看到程序的输出被重定向到文件,而不是显示终端,有时甚至可以让用户看到的提示信息与其他输出进行区分对待,比如一个被输出到终端,另一个被重定向,例如,当stdout已经被重定向的时候,可以使用stderr输出信息
可以通过检查底层文件描述符与终端的关联关系来进行判断,系统调用isatty就是用来完成这一任务的,函数原型如下
int isatty(int fd);
//fd存在与终端的链接,返1,否则0
在主函数添加了对文件描述符的判断之后,运行程序的结果如下,可以看到,当进行重定向的时候,信息通过stderr输出到了终端
终端对话
上述程序有一个问题,程序的所有输出全部都重定向到文件当中了,但有时我们希望程序的交互部分不被重定向,其他的IO可以被重定向,这个时候就需要将交互部分与标准流分开,Linux提供了解决方案——对终端进行读写,Linux本身是多用户系统,通常有多个终端
Linux提供了一个特殊目录/dev/tty实现对终端的读写,该目录始终指向当前终端或登录对话
书上给出了使用/dev/tty的实现
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/utsname.h>
#include <unistd.h>
#include <pwd.h>
#include <syslog.h>
#include <sys/resource.h>
#include <sys/time.h>
#include <math.h>
#include <time.h>
char *menu[]=
{
"a - add new record",
"d - delete record",
"q - quit",
NULL,
};
int getchoice(char *greet, char *choices[],FILE*in,FILE*out)//实现选择
{
int choose=0,selected;
char **option;
do
{
fprintf(out,"Choice: %s\n",greet);
option=choices;
while(*option)
{
fprintf(out,"%s\n",*option);//这里被修改,输出的对象为终端
option++;
}
while((selected=fgetc(in))=='\n');//输入的对象也是终端
option=choices;
while(*option)
{
if(selected==*option[0])
{
choose=1;
break;
}
option++;
}
if(!choose)
fprintf(out,"Incorrect choice, select again\n");
}while(!choose);
return selected;
}
int main()
{
int choice=0;
FILE* input,*output;
input=fopen("/dev/tty","r");//打开终端
output=fopen("/dev/tty","w");//写入终端
if(!input||!output)
{
fprintf(stderr,"无法打开制定目录\n");
exit(1);
}
if(!isatty(fileno(stdout)))
{
fprintf(stderr,"非终端输出\n");
//exit(1);
}
do
{
choice=getchoice("Please select an action",menu,input,output);//获取选项
printf("You have chosen: %c\n",choice);
}while(choice!='q');
return 0;
}
运行的结果如下,可以看到交互部分和程序的输出部分分别输出到不同的地方
termios结构
termios是POSIX定义的标准接口,可以通过设置它的成员变量以及使用函数调用来完成对终端接口的控制
termios可以调整的值有I/O模式,本地模式和特殊控制字符,最小的termios结构定义如下
#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]; // 控制字符数组
// 可能还有其他成员,如c_ispeed和c_ospeed(在某些实现中)
};
可以使用tcgetattr初始化一个terimos结构,函数原型如下
int tcsetattr(int fd,int actions,const struct termios *termios_p);
actions有三种取值,TCSANOW(立刻对值修改),TCSADRAIN(当前输出完再对值进行修改),TCSAFLUSH(当前的输出完成后再对值进行修改,丢弃还未从read调用返回的当前可用的任何输入)
模式相关
通过设置termios对应的成员变量的值,可以实现对终端各种模式的控制
输入模式
输入模式控制输入数据(终端驱动程序从串行口或键盘接受到的字符)在被传递给程序前的处理方式,通过设置c_iflag的宏可以设置输入模式,通常用户无需修改,默认值就是最合适的,设置的时候可以用OR来一次性设置多个约束,c_iflag用到的宏具体可以见手册
输出模式
输出模式控制输出字符的处理方式,即程序发送出去的字符在传递到串行口或屏幕前是如何处理的,很多处理方式正好与输入模式是对应的,可以通过设置c_oflag成员对输出模式进行控制,一用到的宏具体可以见手册
控制模式
控制模式控制终端的硬件特性,可以通过设置c_cflag进行配置,用到的宏具体可以见手册
本地模式
本地模式控制终端的各种特性,可以设置c_lflag来实现对本地模式的配置,用到的宏具体可以见手册
特殊控制字符
特殊控制字符是字符组合,当用户输入对应的组合键,终端会有一些特殊的处理方式,c_cc数组成员将各种特殊控制字符映射到对应的支持函数(可以联想计组的终端处理),每个字符的位置由一个宏定义,并且在不同模式下(标准和非标准)数组下标有重叠部分,标准模式和非标准模式下使用的数组下标具体可以见手册
除了这些支持函数外,还有一对重要的非标准模式下的参数TIME和MIN,通过设置它们,可以使得程序逐个字符地处理,他们结合的情况一般是四种情况,(0,0)、(>0,0)、(>0,>0)、(0,>0),具体含义见书或手册
还可以通过stty命令查看termios的取值
终端速度
termios还可以控制I/O速度,只需要调用对应的函数即可,需要注意的是输入和输出速度并不是同时设置的,函数原型如下
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);
显而易见,这四个函数都是存取器,speed的值有很多,如B0,B1200,B9600等,此处不赘述
其他函数
还有一些其他的函数,直接对文件描述符进行操作,不需要经过termios,函数原型如下
int tcdrain(int fd);
//让调用程序移植等待,直到所有排队输出完
int tcflow(int fd,int flowtype);
//暂停或重新开始输出
int tcflush(int fd,int in_out_selector);
//清空输入或输出
书上给出了一个简单取消密码回显的程序,代码如下
#include <termios.h>
#include <stdio.h>
#include <stdlib.h>
int main()
{
struct termios initset,newset;
char password[8];
tcgetattr(fileno(stdin),&initset);
newset=initset;
newset.c_lflag&=~ECHO;//这里取消了密码回显
printf("输入密码:");
if(tcsetattr(fileno(stdin),TCSAFLUSH,&newset)!=0)//使用TCSAFLUSH丢弃用户在
//程序准备好前的输入
fprintf(stderr,"设置错误");
else
{
fgets(password,8,stdin);
tcsetattr(fileno(stdin),TCSANOW,&initset);//初始化设置
fprintf(stdout,"\n输入的密码为%s\n",password);
}
return 0;
}
运行的结果如下,可以看到密码在输入的时候成功没有回显
在上述基础上,可以对menu进行进一步的修改,使得用户一输入程序就执行,并且不产生用户的回显,书上给出的修改后的程序和结果如下
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/utsname.h>
#include <unistd.h>
#include <pwd.h>
#include <syslog.h>
#include <termios.h>
#include <sys/resource.h>
#include <sys/time.h>
#include <math.h>
#include <time.h>
char *menu[]=
{
"a - add new record",
"d - delete record",
"q - quit",
NULL,
};
int getchoice(char *greet, char *choices[],FILE*in,FILE*out)//实现选择
{
int choose=0,selected;
char **option;
do
{
fprintf(out,"Choice: %s\n",greet);
option=choices;
while(*option)
{
fprintf(out,"%s\n",*option);
option++;
}
while((selected=fgetc(in))=='\n'||selected=='\r');
//非标准模式,回车和换行之间的映射不存在,需要对回车单独判断
option=choices;
while(*option)
{
if(selected==*option[0])
{
choose=1;
break;
}
option++;
}
if(!choose)
fprintf(out,"Incorrect choice, select again\n");
}while(!choose);
return selected;
}
int main()
{
int choice=0;
FILE* input,*output;
input=fopen("/dev/tty","r");//打开终端
output=fopen("/dev/tty","w");//写入终端
struct termios initset,newset;//termios变量,用来实现设置
if(!input||!output)
{
fprintf(stderr,"无法打开制定目录\n");
exit(1);
}
if(!isatty(fileno(stdout)))
{
fprintf(stderr,"非终端输出\n");
//exit(1);
}
tcgetattr(fileno(input),&initset);
newset=initset;
newset.c_lflag&=~ICANON;
newset.c_lflag&=~ECHO;
newset.c_cc[VMIN]=1;
newset.c_cc[VTIME]=0;
//设置MIN和TIME,有min个字符读取就返回
newset.c_lflag&=~ISIG;
if(tcsetattr(fileno(input),TCSANOW,&newset)!=0)
fprintf(stderr,"设置错误\n");
do
{
choice=getchoice("Please select an action",menu,input,output);//获取选项
printf("You have chosen: %c\n",choice);
}while(choice!='q');
tcsetattr(fileno(input),TCSANOW,&initset);
return 0;
}
终端输出
通过termios结构可以控制键盘的输入,有时用户会要求对程序输出到屏幕上的内容也有同样的控制能力(例如输出到屏幕的特定位置)
终端类型
终端是系统和用户交互的界面,在Linux中,通常使用terminfo软件包来和终端交互,有时候还需要用到curses
可以通过查询TERM变量来查看系统用的是什么样的终端
每个终端都有一个定义其功能标志和如何访问其特征的文件的文件,真正的文件都保存在下一级的子目录中,子目录名就是终端类型名的第一个字母
terminfo
每个终端类型都对应一个terminfo文件,terminfo定义由三种类型的数据项组成,分别对应终端的一种功能标志,分别是布尔功能标志、数值功能标志、字符串功能标志
大多数系统已经预定好了大部分终端的功能标志,还有修改escape转移序列,这里不赘述
使用terminfo时要调用setupterm设置终端类型,这将为当前终端类型初始化为一个TERMINAL结构,函数原型如下
int setupterm(char *term, int fd,int*errret);
//当前终端类型设置为term指向的值(空默认是TERM),fd是打开的文件描述符
//errret保存返回的函数值,-1数据库不存在,0无匹配,1,成功
在成功设置term之后,可以通过功能标志访问器来访问terminfo的功能标志,函数原型如下
int tigetflag(char *capname);
int tigetnum(char *capname);
char *tigetstr(char *capname);
下面是书上一个显示终端显示区大小的程序代码和运行结果
#include <stdio.h>
#include <term.h>
#include <curses.h>
#include <stdlib.h>
int main()
{
int row,col;
setupterm(NULL,fileno(stdout),(int*)0);
row=tigetnum("lines");
col=tigetnum("cols");
printf("大小为%d * %d\n",row,col);
exit(0);
}
需要注意的是执行的时候可能会遇到term.h无法找到的问题,这里可以参考另一篇(Ubuntu23.10下处理libncurses5-dev包的安装问题),以及编译时要加上-lcurses与对应的库进行链接,可以看到输出了终端的界面大小
除此之外,还可以用tparm函数用实际数值替换功能标志中的参数,用tparm构造好终端的escape转移序列后,必须将序列发送到终端,并且必须用putp或者tputs来处理终端完成一个操作所需要的延时,相关函数原型如下
char *tparm(char *cap,long p1,long p2,...,long p9);
int putp(char *const str);//以一个终端字符串为函数,将其发送到stdout
int tputs(char *const str, int affcnt, int (*putfunc)(int));
//为不能通过stdout访问终端的情况准备的,可以指定一个用于输出字符的函数
//返回值是用户指定函数的返回结果,affcnt是受这一变化影响的函数
//putp(str)等价于tputs(str,1,putchar)
使用上述函数完成最终菜单程序和运行结果如下,需要注意的是,下面这个程序继承的是先前的termios,也就是说不会对用户的输入进行回显
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/utsname.h>
#include <unistd.h>
#include <pwd.h>
#include <syslog.h>
#include <termios.h>
#include <sys/resource.h>
#include <sys/time.h>
#include <math.h>
#include <time.h>
#include <term.h>
#include <curses.h>
static FILE *output_stream;
char *menu[]=
{
"a - add new record",
"d - delete record",
"q - quit",
NULL,
};
int char_to_terminal(int flag)
{
if(output_stream)putc(flag,output_stream);//把flag的值送给输出流
return 0;
}
int getchoice(char *greet, char *choices[],FILE*in,FILE*out)//实现选择
{
int choose=0,selected;
int row=4,col=10;
char *cursor,*clear;
char **option;
option=choices;
output_stream=out;
setupterm(0,fileno(out),0);//设置term类型
cursor=tigetstr("cup");//拿终端的值
clear=tigetstr("clear");//同上
tputs(clear,1,char_to_terminal);//清屏
tputs(tparm(cursor,row,col),1,char_to_terminal);//设置光标到指定位置
fprintf(out,"Choice: %s\n",greet);
row+=2;
while(*option)
{
tputs(tparm(cursor,row,col),1,char_to_terminal);
fprintf(out,"%s\n",*option);
row++;
option++;
}
fprintf(out,"\n");
do
{
fflush(out);
while((selected=fgetc(in))=='\n'||selected=='\r');
//非标准模式,回车和换行之间的映射不存在,需要对回车单独判断
option=choices;
while(*option)
{
if(selected==*option[0])
{
choose=1;
break;
}
option++;
}
if(!choose)
{
tputs(tparm(cursor,row,col),1,char_to_terminal);
fprintf(out,"Incorrect choice, select again\n");
}
}
while(!choose);
tputs(clear,1,char_to_terminal);
return selected;
}
int main()
{
int choice=0;
FILE* input,*output;
input=fopen("/dev/tty","r");//打开终端
output=fopen("/dev/tty","w");//写入终端
struct termios initset,newset;//termios变量,用来实现设置
if(!input||!output)
{
fprintf(stderr,"无法打开制定目录\n");
exit(1);
}
if(!isatty(fileno(stdout)))
{
fprintf(stderr,"非终端输出\n");
//exit(1);
}
tcgetattr(fileno(input),&initset);
newset=initset;
newset.c_lflag&=~ICANON;
newset.c_lflag&=~ECHO;
newset.c_cc[VMIN]=1;
newset.c_cc[VTIME]=0;
//设置MIN和TIME,有min个字符读取就返回
newset.c_lflag&=~ISIG;
if(tcsetattr(fileno(input),TCSANOW,&newset)!=0)
fprintf(stderr,"设置错误\n");
do
{
choice=getchoice("Please select an action",menu,input,output);//获取选项
printf("You have chosen: %c\n",choice);
sleep(10);//睡10s后刷屏
}
while(choice!='q');
tcsetattr(fileno(input),TCSANOW,&initset);
return 0;
}
击键动作检测
Linux中没有MS-DOS对应的kbhit函数,但是可以通过termios等实现,下面给出书上的代码和运行结果
程序将终端设置为有字符读取时read才返回,kbnit将终端改成检查输入并立刻返回,在程序退出前回复终端的初始设置
#include <stdio.h>
#include <stdlib.h>
#include <term.h>
#include <curses.h>
#include <unistd.h>
struct termios initset,newset;
int peek=-1;
void init_keyboard()
{
tcgetattr(0,&initset);//拿初始设置用来还原
newset=initset;
newset.c_lflag&=~ICANON;
newset.c_lflag&=~ECHO;//取消回显
newset.c_lflag&=~ISIG;
newset.c_cc[VMIN]=1;
newset.c_cc[VTIME]=0;
//read一直等待,直到有1个字符才返回
tcsetattr(0,TCSANOW,&newset);
//设置termios
}
void close_keyboard()
{
tcsetattr(0,TCSANOW,&initset);//还原设置
}
int kbhit()
{
char ch;
int nread;
if(peek!=-1)return 1;//如果没输入
newset.c_cc[VMIN]=0;
tcsetattr(0,TCSANOW,&newset);
nread=read(0,&ch,1);
newset.c_cc[VMIN]=1;
tcsetattr(0,TCSANOW,&newset);
if(nread==1)
{
peek=ch;
return 1;
}
return 0;
}
int readch()
{
char ch;
if(peek!=-1)
{
ch=peek;
peek=-1;
return ch;
}
read(0,&ch,1);
}
int main()
{
int ch=0;
init_keyboard();
while(ch!='q')
{
printf("循环\n");
sleep(1);
if(kbhit())
{
ch=readch();
printf("你按下了%c\n",ch);
}
}
close_keyboard();
return 0;
}
虚拟控制台&伪终端
linux提供了虚拟控制台,多个虚拟控制台可以提供给用户使用并切换,虚拟控制台通过/dev/ttyN使用(N为数字),使用who和ps可以看到进入系统的用户以及在当前虚拟控制台上运行的shell和程序
伪终端和一般的终端相似,区别是伪终端没有对应的硬件设备,类似于给其他程序提供终端形式的接口,过去伪终端以特定方式实现,但现在已经被合并到UNIX规范里,一般叫UNIX98/PTY(2010年)
总结
本章的内容很多,Linux对终端的操作繁多,并且有专门的结构体供操作,除此之外,各种其他的流也可以操作,比如stdout和dev等
参考文献
- 《Linux程序设计(第四版)》
- Ubuntu23.10下处理libncurses5-dev包的安装问题