0%

Chapter01 --- 编写自己的`more`程序(2)

简介

在上一篇博文中,介绍了 Unix/Linux编程实践教程 第一章中的两个标程,这两个标程实现了很简陋(根本没办法用)的more命令,这一篇博文则是Rivers在自己实现一个勉强可以使用的more命令的途中的一些感悟。具体的源码在Github上都有了。可以通过查看不同的commit来看各种功能具体是怎么实现的。也欢迎来找bug。

输入字符,立刻交互

在之前实现的的more命令中,如果想要输入命令,必须要回车,比如说想要退出,就要先键入q,再键入回车,真实的more命令却不是这样的,键入了q之后会立刻退出,而且可以发现,当键入了q或空格之后,终端的屏幕上也出现了q或空格字符,真实的more命令则不会回显我们的字符。

事实上,这是因为终端默认是处在canonicalechoing的模式,只能一行行地读入,并且会自动回显输入,那么,怎么修改这一属性呢?这需要两个函数tcgetattrtcsetattr,从函数名上就可以看出,这两个函数一个用来获取终端属性,另一个用来设置终端属性,接下来要做的就是查看手册,找到合适的设置项了。

经过一番挣扎,有了如下的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#define CMD_FILE "/dev/tty"
struct termios old;

void
exit_more() {
tcsetattr(CMD_FILENO, TCSANOW, &old);
......
}

void
init_more() {
// call exit_more at exit
atexit(exit_more);

......

// open /dev/tty
if ((cmd = fopen(CMD_FILE, "r")) == NULL) {
exit(1);
}
CMD_FILENO = fileno(cmd);

// set the terminal mode
struct termios tm;
tcgetattr(CMD_FILENO, &old);
tm = old;
tm.c_lflag &= ~(ICANON | ECHO);
tm.c_cc[VMIN] = 1;
tm.c_cc[VTIME] = 0;
if (tcsetattr(CMD_FILENO, TCSADRAIN, &tm) == -1) {
perror("tcsetattr");
}

......
}

其中old用于保存原始的终端状态,以便在程序结束时恢复,&= ~(ICANON | ECHO)是同时关掉 canonical模式和回显模式,下面设置的c_cc[VMIN|VTIME]分别是读入字符的个数和时延,时延当然是设置为0,因为需要及时反馈,字符个数设置为1或0都可以,如果大于1,就会导致要输入固定数量个字符才会读入,不是Rivers想要的。

可能还有人会注意到Rivers第一次设置属性的时候使用的是TCSADRAIN行为模式,恢复的时候却是TCSANOW模式,其实这两个在这里并没有区别,只不过测试的时候,最终没有统一,虽然Rivers不敢说搞懂了这两个之间的区别,但是Rivers觉得,TCSANOW就是立刻设置,TCSADRAIN就是等待之前所有被缓冲的输出成功后再生效,如果有人确切知道,欢迎与Rivers交流。

之前错误的实现

在上面的正确实现之前,Rivers一直被一个bug困扰着,甚至于前往了stackoverflow上提问。

这个bug的表现是,当我使用命令行参数传入文件名的时候(如more test_file),一切都没有问题,但是当我使用管道时(如ls /bin | more),就再也做不到键盘输入,立刻交互和没有回显。

啊呀,这个bug怎么有点熟悉呢?似乎在上一篇博文中,第一个参考实现就是参数文件名可以键盘读入,而管道连接不可以键盘读入,解决的方法是不从stdin读入,而从/dev/tty读入,事实上也确实如此,傻傻的Rivers在这里犯了同样的错误,导致bug的代码如下:

1
2
3
4
5
6
tcgetattr(STDIN_FILENO, &old);
tm = old;
tm.c_lflag &= ~(ICANON | ECHO);
tm.c_cc[VMIN] = 1;
tm.c_cc[VTIME] = 0;
tcsetattr(STDIN_FILENO, TCSADRAIN, &tm);

只设置stdin的属性,当参数传递文件名时,/dev/tty就是stdin,所以没有问题,但是使用管道之后,/dev/ttystdin不同,就出现了设置失效的问题。(果然Rivers就是不能举一反三,太气了呜呜呜)

获得终端的高度

由于more命令需要一次显示一页内容,所以这一页内容有多少,就是一个必要的信息,也就是终端的高度,终端的高度有多种方式获得

首先是可以使用环境变量LINES获得,在终端中直接使用echo $LINES就可以查看终端高度,在C中,结合库函数getenvatoi就可以获得。

但是当Rivers使用这一方法时,却失败了,查阅了网上资料之后,发现LINES环境变量是默认不传入程序的(当然有时也会传入程序,终端命令env会显示所有传入程序的环境变量),终端中使用export LINES就可以传入了,但是这显然不行,因为平时使用more的时候根本不需要传入LINES

于是,又可以使用笨拙一点的办法

1
2
3
4
5
6
7
8
9
10
// get number of lines
char * lines_str = getenv("LINES");
struct winsize ws;
if (lines_str != NULL) {
num_of_lines = atoi(lines_str) - 1;
} else if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0){
num_of_lines = ws.ws_row - 1;
} else {
num_of_lines = 24;
}

使用ioctl来获取终端的大小信息。其中-1是因为最后一行输出反白的More而不是文件内容。

藏起来的光标和不在上滚的反白More?

在真实使用的more命令中,终端上是没有光标的,按回车,More?提示也不会上滚,Rivers实现这两个功能时,使用了Escape Code。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void
init_more() {
......

// hide the cursor
fputs("\033[?25l", stdout);
}

void
exit_more() {
......
// show the cursor
fputs("\033[?25h", stdout);
}

int
see_more(long cur_sz, long file_sz, const char *const file_nm) {
......
// put cursor back to the beginning of the line
printf("\033[%dD", num_of_lines);

while ((c = fgetc(cmd)) != EOF) {
switch (c) {
case ' ':
// erase the line
fputs("\033[0K", stdout);
return num_of_lines;
case '\n':
// erase the line
fputs("\033[0K", stdout);
return 1;
case 'q':
// erase the line
fputs("\033[0K", stdout);
return 0;
}
}
......
}

其中实现掩藏光标是直接通过\033[?25l\033[?25h两个Escape Code 实现的,而More?不在上滚则是通过在see_more()返回前将More?一行清空实现的,这并不会有问题,因为只有在等待读入的时候才有必要显示More?提示符。

文件百分比与多文件支持

文件百分比和多文件支持都要在More?提示符后加入一条额外的消息,或是百分比,或是Next File FILE_NAME,那么只需要给see_more多设置几个参数,来指示下一次输出哪一种消息以及消息的具体内容。

多文件还要在每个文件之前输出其文件名,比如

1
2
3
4
5
::::::::::::
./src/more.c
::::::::::::
#include <stdio.h>
......

这也可以通过给do_more加参数实现,具体可参考Rivers的Github源码

后记

在实现more的时候,Rivers也去翻了翻more的官方源码,有2000+行,其中考虑了各种各样的情况,包括如果/dev/tty打不开应该如何等等,有兴趣的朋友可以下载来看,moreutil-linux包里。