0%

stdin输入模式详解

本文主要参考:viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html

标准IO流介绍

通常情况下每个程序加载后都会有3个流被fopen——stdinstdoutstderr,它们是标准C中的FILE*指针。在unix环境对应的文件描述符为0、1、2(宏定义为STDIN_FILENOSTDOUT_FILENOSTDERR_FILENO)。当然,程序退出的时候它们也会被fclose。

不要简单地把stdin视作键盘缓冲区,stdout视作屏幕缓冲区,尽管多数时候确实像这么回事。关键在于unix中的IO重定向,这三个流都可以被重定向到任意的文件或设备中,一切皆文件嘛。

1
2
3
4
5
6
7
int main(int argc, char* argv[])
{
int a, b;
scanf("%d %d", &a, &b);
printf("%d + %d = %d\n", a, b, a+b);
return 0;
}

上述代码中的执行过程看似是先在命令行下输入两个数字,然后打印出两数之和,但只要动点手脚…

1
2
3
4
5
6
$ echo "1 2" > input.txt
$ cc main.c
# 将stdin重定向到input.txt,将stdout重定向到output.txt
$ ./a.out 0<input.txt 1>output.txt
$ cat output.txt
1 + 2 = 3

可以看到,程序瞬间执行完且并无任何打印,相应的输入输出内容已存放在input.txt和output.txt中。所以:

  • stdin可以是数据、键盘、鼠标、触摸板、终端等任何一种“文件”
  • stdout可以是数据、键盘、鼠标、触摸板、终端等任何一种“文件”
  • stderr可以是数据、键盘、鼠标、触摸板、终端等任何一种“文件”

理解标准IO流的好处能够提升某些应用场景下软件的开发效率,例如我们需要从某个终端输入一些内容,然后将处理结果显示到另一个终端,再把错误日志存储到某个文件,如果能用好标准IO流,不仅可以省去日志管理的开发,还能提高程序的灵活性。

言归正传,本文计划写一个“友好的命令行认证交互”程序来实践和理解标准IO流的各种模式,说白了就是如何在命令行下更好地让用户输入密码。先来看个最原始的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// auth.c
#include <stdio.h>
#include <string.h>

int main(int argc, char* argv[])
{
char username[32] = {0};
char password[32] = {0};

printf("enter username: \n");
fgets(username, sizeof(username), stdin);
printf("enter password: \n");
fgets(password, sizeof(password), stdin);

printf("%s\n", strcmp(username, password) ? "✖️" : "✔️");
return 0;
}

代码大意就是让用户输入用户名密码,然后比对这两个字符串,如果相等输出通过,否则输出失败。

不用换行符输出字符串

上边的代码编译执行后的效果大概是这样的:

1
2
3
4
5
6
$ cc auth.c && ./a.out
enter username:
philon
enter password:
philon
✔️

显然,我们希望用户直接在提示内容后紧接着输入内容(而非新行),但如果我们在printf字符串末尾不添加'\n'符终端是不会有输出的。

PS:printf函数名意为print formatted,即格式化打印,它等价于fprintf(stdout),而后者的函数名意为file print formatted,C语言中有很多f作为前缀或后缀的函数,通常前缀才是指文件操作。

由于printf仅仅是把字符串写入stdout这个“文件”缓存,除非缓存遇到\n符、缓存满了、缓存被刷新了、stdout被关闭了等情况才会触发真正的打印输出。所以,在没有换行符的情况下,只需强制刷新输出缓存即可,这里需要调用fflush

把先前的代码稍微改造下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int main(int argc, char* argv[])
{
char username[32] = {0};
char password[32] = {0};

printf("enter username: ");
fflush(stdout);
fgets(username, sizeof(username), stdin);

printf("enter password: ");
fflush(stdout);
fgets(password, sizeof(password), stdin);

printf("%s\n", strcmp(username, password) ? "✖️" : "✔️");
return 0;
}
1
2
3
4
$ cc auth.c && ./a.out
enter username: philon
enter password: philon
✔️

可以看到,强刷输出缓冲后,即可让用户仅接着提示后输入内容。但新问题又来了,密码也赤裸裸地显示了怎么办?

ECHO回显模式

当我们打开一个命令行终端并在里边输入指令时,我们能够看到输入了哪些字符,这就是回显模式。不过要切记一点,尽管回显内容会打印到屏幕上,但和stdout没半毛钱关系,归根结底是stdin自己的事。

某些场景下回显其实是多余甚至有害的,正如上述输入密码的例子,因此这里要做的就是在用户输入密码前把回显关闭。怎么关——termios

termios是一个结构体定义,配合相关函数提供了一套通用终端通信模式的控制接口,本文就不展开说明了,详情查阅官方文档man7.org/linux/man-pages/man3/termios.3.html。这里只拣重点的说,termios定义如下:

1
2
3
4
5
6
7
8
9
struct termios {
tcflag_t c_iflag; /* input flags */
tcflag_t c_oflag; /* output flags */
tcflag_t c_cflag; /* control flags */
tcflag_t c_lflag; /* local flags */
cc_t c_cc[NCCS]; /* control chars */
speed_t c_ispeed; /* input speed */
speed_t c_ospeed; /* output speed */
};

从定义中可以看到结构体分别定义了输入、输出、控制、本地等4种标识,每种标识都是unsigned long类型,即每种标识的不同位代表不同的功能开关,而回显模式主要由c_lflag字段控制。unix为不同的回显模式提供了几个宏定义:

  • ECHO 回显键盘中的各种输入字符,如数字、字母、特殊字符键
  • ECHOEICANON也被设置时,回显键盘ERASE符(Del键)并删除前一个字符,回显WERASE符(Alt+Del)并删除前一个词,默认关闭
  • ECHOKICANON也被设置时,回显KILL符并删除当前行(不是很懂,我试过Ctrl+U和Ctrl+K没效果)
  • ECHONLICANON也被设置时,回显NL符(Enter键)
  • ECHOCTL 回显控制字符,如^C、^D、^H、^K、^X、箭头键等

上述ICANON标志位后续会说明,这里只需要知道它默认是被打开的,

回到程序本身,密码的构成多为数字、字母、特殊字符,因此只需要在输入密码是关闭ECHO标志位即可,在程序退出时又重新打开。继续改造代码

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
#include <unistd.h>
#include <termios.h>

int setlflag(int flags, int enable)
{
struct termios attr;
tcgetattr(STDIN_FILENO, &attr);
if (enable) {
attr.c_lflag |= flags; // 使能标志位
} else {
attr.c_lflag &= ~flags; // 清除标志位
}
return tcsetattr(STDIN_FILENO, TCIFLUSH, &attr);
}

int main(int argc, char* argv[])
{
char username[32] = {0};
char password[32] = {0};

printf("enter username: ");
fflush(stdout);
fgets(username, sizeof(username), stdin);

printf("enter password: ");
fflush(stdout);
setecho(ECHO, 0); // 关闭回显
fgets(password, sizeof(password), stdin);
setecho(ECHO, 1); // 打开回显

printf("%s\n", strcmp(username, password) ? "✖️" : "✔️");
return 0;
}

这次运行程序后就会发现,当输入密码时就不会再有字符显示了,是不是有点像在sudo的时候输入密码一样。

1
2
3
$ cc auth.c && ./a.out
enter username: philon
enter password: ✔️

但我还想再进一步…在输入密码时将输入内容显示为******

Canonical模式

在禁用回显的同时还要实现将每个输入字符回显为*样式,我们就必须在每次keypress的时候打印一个星号,但如果直接调用getchar读输入时就会发现必须在用户敲下回车键才能读到,如何免回车读取一个字符呢——答,禁用ICANON

Canonical(常规模式)即前文提到的ICANON标志位,它同样受控于c_lflag字段。此模式下的输入处理都是line-by-line在缓存中的,这也就是为什么我们在调用fgets时总需要回车才能读到,只要你没回车就可以在行内进行简单编辑,如删除字符、清行、复制粘贴等操作。一旦关闭后,输入的处理就会变成byte-by-byte,行内编辑也失效了,什么删除符、箭头、TAB、^Y、^U、^X、F1-F12等特殊键统统被当作输入字符处理,且立即生效。

继续改造main函数,在输入密码前关闭回显,同时使用非常规模式(Noncanonical),实时读取每个字符并打印为星号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int main(int argc, char* argv[])
{
...

printf("enter password: ");
fflush(stdout);

setlflag(ECHO | ICANON, 0); // 关闭回显及常规模式
for (int i = 0; i < sizeof(password) && password[i-1] != '\n'; i++) {
password[i] = getchar();
printf("*");
fflush(stdout);
}
printf("\n");
setlflag(ECHO | ICANON, 1); // 恢复回显及常规模式

printf("%s\n", strcmp(username, password) ? "✖️" : "✔️");
return 0;
}

好了,此时输入密码是就能自动替换为*符号了。

1
2
3
4
$ cc auth.c&& ./a.out
enter username: philon
enter password: *******
✔️

但我还想再进一步…超过20秒没有输入则判定失败。

stdin的最小字节数与超时

一旦stdin的Canonical模式被关闭后,可以使用c_cc[VMIN]c_cc[VTIME]来配置stdin的最小输入字节数和输入超时,注意VTIME的单位是分秒,即十分之一秒。此外c_cc是一个unsigned char数组,也就是说VMIN和VTIME的值最多为255。

这两个参数的设置可分为4种情况:

  • VMIN == 0 && VTIME == 0: 每次读取时立即返回,不论是否有输入内容
  • VMIN > 0 && VTIME == 0: 每次读取时都会阻塞,直到有输入内容长度为VMIN个字节才返回
  • VMIN == 0 && VTIME > 0: 每次读取时立刻开始计时,一旦有输入就立刻返回,否则等到VTIME*10(分秒)后就超时返回0
  • VMIN > 0 && VTIME > 0: 每次读取时都会阻塞,直到第一个字节输入才开始计时(每次输入都会重新计时),输入超时或者输入长度达到VMIN时都会返回

PS:要小心fread函数!!我在实际开发中发现当VMIN大于0时,函数是否返回只取决于输入长度达到fread调用时传入的长度参数,而UNIX中的read函数不存在此问题。另外根据官方手册的说法,当文件属性被设置为O_NONBLOCK后,VMIN和VTIME就会被忽略。

那好,如果要实现检测用户20秒未输入的应该选用第三种情况:VMIN == 0 && VTIME > 0,继续改造代码。

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

...

int setcc(int min, int timeout)
{
struct termios attr;
tcgetattr(STDIN_FILENO, &attr);
attr.c_cc[VMIN] = min;
attr.c_cc[VTIME] = timeout;
return tcsetattr(STDIN_FILENO, TCIFLUSH, &attr);
}

int main(int argc, char* argv[])
{
...

setlflag(ECHO | ICANON, 0);
setcc(0, 200); // 设置stdin为30秒未输入超时
for (int i = 0; i < sizeof(password) && password[i-1] != '\n'; i++) {
password[i] = getchar();
printf("*");
fflush(stdout);
}

...
}

如此一来当用户超过20秒没有输入时将直接判定错误!

但我还想再进一步…目前密码输入是不支持删除、左右移动光标、清空输入、取消等操作。这个…其实和stdin的模式关系不大,就不去实现了😄。

按键不完全捕获指南

ASCII码类按键

键盘上大多数键或组合件背后都有对应的ASCII码,这些按键输入大体可分为控制字符(0~31及127)和可显字符(32~126),其中:

  • 0是NULL,对应快捷键Ctrl+@
  • 1~26是各种控制字符,对应快捷键Ctrl+ACtrl+Z
  • 48~57对应数字键0~9
  • 65~90对应大写字母A~Z
  • 97~122对应小写字母a~z
  • 127是删除键

其它的字符码就只能网上搜了,我觉得整理得较好的ASCII码表应该是IBM这篇文档:ASCII and EBCDIC character sets

信号类按键

Linux下这其中的很多快捷键会触发信号,如Ctrl+CCtrl+Z等,尽管它们也有对应ASCII码,但程序往往会优先去响应信号处理,如果要捕获此类按键的话需先使用signal()注册新号处理函数,然后再函数中根据信号ID去进一步处理。

如果觉得这样麻烦,倒是还有一种手段,还记得前面介绍的termios结构体吗?

1
2
3
4
5
6
struct termios attr;
attr.c_lflag &= ~(ISIG); // 禁用中断信号SIGINT(Ctrl+C) 和 SIGTSTP(Ctrl+Z)
attr.c_lflag &= ~(IEXTEN); // 禁用逐字发送,Ctrl+V和Ctrl+O

attr.c_iflag &= ~(IXON); // 禁用输入流控,Ctrl+S和Ctrl+Q
attr.c_iflag &= ~(ICRNL); // 修复回车换行,Ctrl+M和Ctrl+J

好了,这样一来所有的Ctrl+A~Z都可以捕获了。

转义序列按键

键盘左上角的Esc键为转义符\033——这是一个8进制,对应ASCII码27。例如方向键、F1~F12键等它们发送的都是以\033开头的转义序列(有多个字符)。

此类按键的捕获没什么技巧,读出来,判断!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define ARROW_UP    "\033[A"
#define ARROW_DOWN "\033[B"
#define ARROW_RIGHT "\033[C"
#define ARROW_LEFT "\033[D"

#define F1 "\033OP"
#define F2 "\033OQ"
#define F3 "\033OR"
#define F4 "\033OS"

#define F4 "\033[15~"
#define F6 "\033[17~"
#define F7 "\033[18~"
#define F8 "\033[19~"

#define F9 "\033[20~"
#define F10 "\033[21~"
#define F11 "\033[22~"
#define F12 "\033[23~"

补充:关于conio.h中的getch

某些文章中读取字符喜欢用getch函数,可直接实现无回显和Noncanonical的效果。尽管很方便,但需要注意一点conio.h并非C的标准库,UNIX、POSIX标准中均没有相关实现。

小小鼓励,大大心意!