本文主要参考:viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html
标准IO流介绍
通常情况下每个程序加载后都会有3个流被fopen——stdin
、stdout
、stderr
,它们是标准C中的FILE*
指针。在unix环境对应的文件描述符为0、1、2(宏定义为STDIN_FILENO
、STDOUT_FILENO
、STDERR_FILENO
)。当然,程序退出的时候它们也会被fclose。
不要简单地把stdin视作键盘缓冲区,stdout视作屏幕缓冲区,尽管多数时候确实像这么回事。关键在于unix中的IO重定向,这三个流都可以被重定向到任意的文件或设备中,一切皆文件嘛。
1 | int main(int argc, char* argv[]) |
上述代码中的执行过程看似是先在命令行下输入两个数字,然后打印出两数之和,但只要动点手脚…
1 | $ echo "1 2" > input.txt |
可以看到,程序瞬间执行完且并无任何打印,相应的输入输出内容已存放在input.txt和output.txt中。所以:
- stdin可以是数据、键盘、鼠标、触摸板、终端等任何一种“文件”
- stdout可以是数据、键盘、鼠标、触摸板、终端等任何一种“文件”
- stderr可以是数据、键盘、鼠标、触摸板、终端等任何一种“文件”
理解标准IO流的好处能够提升某些应用场景下软件的开发效率,例如我们需要从某个终端输入一些内容,然后将处理结果显示到另一个终端,再把错误日志存储到某个文件,如果能用好标准IO流,不仅可以省去日志管理的开发,还能提高程序的灵活性。
言归正传,本文计划写一个“友好的命令行认证交互”程序来实践和理解标准IO流的各种模式,说白了就是如何在命令行下更好地让用户输入密码。先来看个最原始的程序:
1 | // auth.c |
代码大意就是让用户输入用户名密码,然后比对这两个字符串,如果相等输出通过,否则输出失败。
不用换行符输出字符串
上边的代码编译执行后的效果大概是这样的:
1 | $ cc auth.c && ./a.out |
显然,我们希望用户直接在提示内容后紧接着输入内容(而非新行),但如果我们在printf
字符串末尾不添加'\n'
符终端是不会有输出的。
PS:
printf
函数名意为print formatted
,即格式化打印,它等价于fprintf(stdout)
,而后者的函数名意为file print formatted
,C语言中有很多f
作为前缀或后缀的函数,通常前缀才是指文件操作。
由于printf仅仅是把字符串写入stdout这个“文件”缓存,除非缓存遇到\n
符、缓存满了、缓存被刷新了、stdout被关闭了等情况才会触发真正的打印输出。所以,在没有换行符的情况下,只需强制刷新输出缓存即可,这里需要调用fflush
。
把先前的代码稍微改造下:
1 | int main(int argc, char* argv[]) |
1 | $ cc auth.c && ./a.out |
可以看到,强刷输出缓冲后,即可让用户仅接着提示后输入内容。但新问题又来了,密码也赤裸裸地显示了怎么办?
ECHO回显模式
当我们打开一个命令行终端并在里边输入指令时,我们能够看到输入了哪些字符,这就是回显模式。不过要切记一点,尽管回显内容会打印到屏幕上,但和stdout没半毛钱关系,归根结底是stdin自己的事。
某些场景下回显其实是多余甚至有害的,正如上述输入密码的例子,因此这里要做的就是在用户输入密码前把回显关闭。怎么关——termios
。
termios是一个结构体定义,配合相关函数提供了一套通用终端通信模式的控制接口,本文就不展开说明了,详情查阅官方文档man7.org/linux/man-pages/man3/termios.3.html。这里只拣重点的说,termios定义如下:
1 | struct termios { |
从定义中可以看到结构体分别定义了输入、输出、控制、本地等4种标识,每种标识都是unsigned long
类型,即每种标识的不同位代表不同的功能开关,而回显模式主要由c_lflag
字段控制。unix为不同的回显模式提供了几个宏定义:
- ECHO 回显键盘中的各种输入字符,如数字、字母、特殊字符键
- ECHOE 当ICANON也被设置时,回显键盘
ERASE
符(Del键)并删除前一个字符,回显WERASE
符(Alt+Del)并删除前一个词,默认关闭 - ECHOK 当ICANON也被设置时,回显
KILL
符并删除当前行(不是很懂,我试过Ctrl+U和Ctrl+K没效果) - ECHONL 当ICANON也被设置时,回显
NL
符(Enter键) - ECHOCTL 回显控制字符,如^C、^D、^H、^K、^X、箭头键等
上述ICANON标志位后续会说明,这里只需要知道它默认是被打开的,
回到程序本身,密码的构成多为数字、字母、特殊字符,因此只需要在输入密码是关闭ECHO
标志位即可,在程序退出时又重新打开。继续改造代码
1 |
|
这次运行程序后就会发现,当输入密码时就不会再有字符显示了,是不是有点像在sudo的时候输入密码一样。
1 | $ cc auth.c && ./a.out |
但我还想再进一步…在输入密码时将输入内容显示为******
。
Canonical模式
在禁用回显的同时还要实现将每个输入字符回显为*
样式,我们就必须在每次keypress的时候打印一个星号,但如果直接调用getchar
读输入时就会发现必须在用户敲下回车键才能读到,如何免回车读取一个字符呢——答,禁用ICANON。
Canonical(常规模式)即前文提到的ICANON
标志位,它同样受控于c_lflag
字段。此模式下的输入处理都是line-by-line在缓存中的,这也就是为什么我们在调用fgets
时总需要回车才能读到,只要你没回车就可以在行内进行简单编辑,如删除字符、清行、复制粘贴等操作。一旦关闭后,输入的处理就会变成byte-by-byte,行内编辑也失效了,什么删除符、箭头、TAB、^Y、^U、^X、F1-F12等特殊键统统被当作输入字符处理,且立即生效。
继续改造main函数,在输入密码前关闭回显,同时使用非常规模式(Noncanonical),实时读取每个字符并打印为星号:
1 | int main(int argc, char* argv[]) |
好了,此时输入密码是就能自动替换为*符号了。
1 | $ cc auth.c&& ./a.out |
但我还想再进一步…超过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 |
|
如此一来当用户超过20秒没有输入时将直接判定错误!
但我还想再进一步…目前密码输入是不支持删除、左右移动光标、清空输入、取消等操作。这个…其实和stdin的模式关系不大,就不去实现了😄。
按键不完全捕获指南
ASCII码类按键
键盘上大多数键或组合件背后都有对应的ASCII码,这些按键输入大体可分为控制字符(0~31及127)和可显字符(32~126),其中:
- 0是NULL,对应快捷键
Ctrl+@
- 1~26是各种控制字符,对应快捷键
Ctrl+A
~Ctrl+Z
- 48~57对应数字键0~9
- 65~90对应大写字母A~Z
- 97~122对应小写字母a~z
- 127是删除键
其它的字符码就只能网上搜了,我觉得整理得较好的ASCII码表应该是IBM这篇文档:ASCII and EBCDIC character sets。
信号类按键
Linux下这其中的很多快捷键会触发信号,如Ctrl+C
、Ctrl+Z
等,尽管它们也有对应ASCII码,但程序往往会优先去响应信号处理,如果要捕获此类按键的话需先使用signal()
注册新号处理函数,然后再函数中根据信号ID去进一步处理。
如果觉得这样麻烦,倒是还有一种手段,还记得前面介绍的termios结构体吗?
1 | struct termios attr; |
好了,这样一来所有的Ctrl+A~Z都可以捕获了。
转义序列按键
键盘左上角的Esc键为转义符\033
——这是一个8进制,对应ASCII码27。例如方向键、F1~F12键等它们发送的都是以\033开头的转义序列(有多个字符)。
此类按键的捕获没什么技巧,读出来,判断!
1 |
补充:关于conio.h中的getch
某些文章中读取字符喜欢用getch
函数,可直接实现无回显和Noncanonical的效果。尽管很方便,但需要注意一点conio.h
并非C的标准库,UNIX、POSIX标准中均没有相关实现。