乞丐版Vi编辑器的实现1-Raw mode
Contents
这一节的内容可能比较枯燥,是一些底层的 Terminal 的属性:canonical mode,回显(echoing),键盘按键对应的 ASCII 码(可以发现一些按键不止对应一个编码,还有些按键对应的是同一个编码)和一些杂项设置…通过改变这些设置,让 Terminal 进入所谓的 “raw mode”。
Makefile
我使用 make 来构建 VIP,因为只有两个源文件,所以 Makefile 很简单:
|
|
CFLAGS 里使用 c99 标准;使用 -O3
进行编译优化,主要是对内联函数进行优化;-Wall -Wextra -pedantic
会打印一些额外的警告,对 debug 有帮助。
错误处理
首先介绍一下通用的错误处理函数,当遇到错误是,我们只是打印错误消息,然后直接退出程序。虽然程序没有那么健壮,但是足够简单(
|
|
Canonical mode
我们使用read()
system call 来读取一个字节的输入:
|
|
上面的代码,至少可以说明两点:
- 当我们按下按键时,输入的字符马上显示在屏幕上了,即echoing(回显)。
- 只有按下
Enter
键后,程序才接收到输入的字符,即 canonical mode,是按行进行输入的。
接下来,我们关闭 echoing 和 canonical mode,因为我不需要 Terminal 的回显,我们要自己来实现显示字符,也不要按行读取字符,每当按下一个按键时,程序就会接收到它,编辑器应该是按字节读取的。
关闭回显和canonical mode
使用 tcgetattr
当前 terminal 的属性,使用 tcsetattr
设置 terminal 的属性,这些方法都在termios.h
头文件中。
|
|
定义 Editor 结构体来保存全局变量,之后会添加更多变量到其中的。目前就只有一个 termios
,用来保存之前的 terminal属性。在enable_raw_mode()
函数里,对终端属性一通乱改之后,那么在程序退出的时候需要恢复原理的属性吧。
这里利用atexi()
函数,会在程序退出之前调用传给他的函数。
这时候,如果你运行程序,似乎没有发现什么区别。我们按下按键之后,字符还是马上显示在屏幕上了。其实不然,终端已经关闭了 echoing 和 canonical mode。那么,程序就是按字节读取的,不需要按下ENTER
,而字符可以显示在屏幕上是因为这句代码:write(STDOUT_FILENO, &c, 1);
,这可以看做我们自己又实现了回显,注释掉这句代码,就没有任何输出了。
按键
在屏幕打印出我们按下按键的编码,这样有利于之后的编码。比如,必须知道INS
键的编码,我们才能在读取的时候捕获它。运行下面的代码可以发现INS
键其实对应的不是一个ASCII码,而是四个:27 [ 2 ~
。
|
|
iscntrl()
来自ctype.h
头文件,顾名思义,它是is control的缩写,判断一个字符是否是 control character。
Control characters are nonprintable characters that we don’t want to print to the screen. ASCII codes 0–31 are all control characters, and 127 is also a control character. . ASCII codes 32–126 are all printable.
可以测试一下键盘上所有按键,会发现一些有趣的事:
- 方向键,
Page Up
,Paage down
,Home
,End
这些按键,都是3到4 个字节的,以27
,[
开头。这称为转义序列。Esc
键是27。 Backspace
是127,Enter
为10。CTRL-A
是1,CTRL-B
是2,CTRL-C
是3,似乎CTRL-WORD
对应的就是26个字母的顺序,这在我的另一篇博客里也有提及。CTRL-S
可能会让程序不能输出,可以按下CTRL-Q
恢复,这是一种 stop sending you output的进制。CTRL-Z
也可能是CTRL-Y
发送SIGTSTP
信号,会使得你的程序挂起。在终端输入fg
,可以唤醒程序。CTRL-C
发送SIGINT
信号结束进程。
关闭 CTRL-C
CTRL-Z
CTRL-V
|
|
关闭 CTRL-S
CTRL-Q
|
|
修复 CTRL-M
你会发现CTRL-WORD
都是对应字母表的顺序的,除了这个CTRL-M
是10,CTRL-J
已经是10了,还有什么也是10吗,就是Enter
键。这是终端的一个功能,会把 13('\r'
) 变成 10(\n
)。即, translating any carriage returns (13
, '\r'
) inputted by the user into newlines (10
, '\n'
)。
|
|
之后,CTRL-M
和Enter
都会变成13。
关闭 output processing
其实我也一直不明白为啥要用\r\n
来表示换行,直到看了这篇教程。先来解释一下\r
和\n
的作用:
-
\r
,carriage。移动光标到这一行的行首。 -
\n
,newline。移动光标到下一行(如果需要的话会滚动屏幕),但是不改变横坐标。
相信这样的解释以后,你已经明白为什么需要两个字符,才能达到所谓的“换行”效果了。
终端会自动把\n
转化成\r\n
,这带来很多方便,我们使用printf
函数时,只需要加上\n
就可以达到换行效果。而现在是时候需要关闭这个功能了。
|
|
没有 \r
的\n
,就是下图这个样子的
这之后,我们需要修改printf
语句为:printf("\r\n")
。这显然不方便,所以我编写了一个println
内联函数来代替。
|
|
Timeout for read()
现在,read()
函数是阻塞的,程序阻塞在read()
,等待键盘的输入。 如果 在等待的过程中我们想要做些事情该咋办呢?我们可以设置 timeout,这样如果一定时间内read()
没有读到输入,就会直接返回,这样依赖read()
函数,就不是阻塞的了。
|
|
VMIN
表示最小可读的字节数。我们设置为0,读取任何可读的字节。VTIME
表示read()
的最长等待时间,设置为2,表示200毫秒。如果read()
超时了,它会返回 0 ,进而进入下一个循环。运行程序可以看到程序一直打印0。而当按下按键时,会显示按键的编码。
小结
这一步中,我们让终端进入 raw mode,这使得编辑器可以自己渲染和打印字符在屏幕上。下一步,我们会处理低级的终端输入输出,并利用这些来渲染和移动光标。
第一步的代码可以在GitHub上查看,我用step1, step2 … 这样分支来分离每一步的代码。更加详细的步骤在steps/
文件夹下给出。