vi-1.18源码阅读笔记
附上源码链接:https://files-cdn.cnblogs.com/files/shiweifu/vi.zip
vi_main
首先进入主函数
主函数一开始给这些变量赋值:
CMrc= "\033[%d;%dH"; // Terminal Crusor motion ESC sequence
CMup= "\033[A"; // move cursor up one line, same col
CMdown="\n"; // move cursor down one line, same col
Ceol= "\033[0K"; // Clear from cursor to end of line
Ceos= "\033[0J"; // Clear from cursor to end of screen
SOs = "\033[7m"; // Terminal standout mode on
SOn = "\033[0m"; // Terminal standout mode off
bell= "\007"; // Terminal bell sequence
这些值是静态全局变量,具体的用法是用其改变编辑器的输出或者光标的位置,例如fprintf(stdout, CMrc, 10, 10)
就是把光标移动到10、10
的位置
更多可以参见:man console_codes
然后,主函数设置了一个status_buffer
,大小为200,它的主要用途是hold messages
status_buffer = (Byte *) malloc(200); // hold messages to user
即状态信息输出缓存
接下来,使用getopt
解包输入参数并进行操作,但是因为版本比较低,因此这些操作统统没有实现,只是写了注释而已
// 1- process $HOME/.exrc file
// 2- process EXINIT variable from environment
// 3- process command line args
while ((c = getopt(argc, argv, "hCR")) != -1) {
switch (c) {
//case 'r': // recover flag- ignore- we don't use tmp file
//case 'x': // encryption flag- ignore
//case 'c': // execute command first
//case 'h': // help -- just use default
default:
show_help();
return 1;
}
}
getopt
函数一般用来解包简单的参数输入,具体可以使用man "getopt(3)"
进行查看
然后,分辨要打开的文件,并逐个进入真正控制整个程序的函数:edit_file
fn_start
即filename_start
,它等于optind
(在getopt
中有介绍),具体的意思是等于要打开的文件的文件名开始于输入字符串的第几个参数。
总结,主函数的工作是初始化一些全局变量、解包参数、并调用真正工作的函数edit_file
edit_file
这个函数是真正控制程序具体操作的函数。它传入一个文件名,打开这个文件进行各种操作。
首先它调用了一个函数rawmode
rawmode
函数初始化了控制台的一些设置信息,它所使用的函数依赖于termios.h
其次,它设置了初始的页面尺寸:24行、80列,然后使用new_screen
函数对页面进行初始化:其实就是分配存放显示内容的缓存
接下来,调用stat
系统函数查看所要打开的文件的大小,并将两倍的文件大小设置为缓存text
的大小——其中text的大小至少为10240。这个缓存主要用来存储文件信息。
另外,还有另外两个指针:screenbegein
是text的一个索引,dot
是where all the action takes place
接下来,进行一个稍微复杂一点的操作:打开文件,并将文件写入缓存
file_insert
这个函数传入三个参数:fn、p、size
,即文件名、缓存指针、文件大小
首先,进行一系列严谨的判断,判断文件是否能打开,接下来,使用了这两个函数进行文件缓存管理:text_hole_make
、text_hole_delete
,顾名思义,是在缓存上面“开一个大洞”
程序维护这几个指针进行文件缓存的管理:
text
,文件缓存end
,文件缓存的边界
在text_hold_make
函数中,原来的内存布局:
|p|...|end|
现在要在p
处分配一个大小为size的空间
|p|(size)|...|end|
就是将p~end
之间的数据后移size
text_hold_delete
就是上面的逆过程
在这个函数中,调用了一个psbs
函数
psb/psbs
它的用途是:format the status buffer, the bottom line of screen
这个函数使用了C语言的可变长参数写法
具体可以参考如下博客:
https://blog.csdn.net/happylzs2008/article/details/91353951
它将提示信息输出到提示信息缓存中
回到edit_file函数
接下来,它做了一连串的初始化设置
设置完毕后,使用edit_status
函数展示提示信息,即将提示信息缓存输出到提示信息输出位置
以下有几个参数:
cmd_mod
, 0为命令模式,1为输入模式,2为Replace模式editing
,表示正在编辑文件cmdcnt
,cmd countcrow
,cursor on crow and ccoltabstop
,tab空格数量
然后调用了一个redraw
函数,重新绘制
这个函数的意思是Force refresh of all Lines,主要步骤有:
- 使用
place_cursor
函数把光标放在开头的位置 - 将光标到屏幕结束的位置清空
- 同时,将输出缓存清零
- 调用
refresh
函数重新绘制
refresh
refresh
函数的功能是,把文件缓存text
复制到sreen
缓存中,并且对比当前行的屏幕缓存与text
是否不一致,若不一致,则更新该行
它定义了这些变量:
old_offset
// 暂时不清楚其作用li
,changed
buf
tp
(text ptr)、sp
(screen ptr)
首先调用sync_cursor
,同步光标位置,具体实现方式看代码
其次,对比text
和screen
缓存,并且标记哪些行是需要被更新的
首先,将当前的text
的某一行输进buffer
然后,对比buf
与当前输出有是否不同
如果full_screen
为真,则不比较是否异同,强制刷新每一行
回到edit_file
函数
接下来,程序进入主循环
首先,使用get_one_char
函数读取一个命令,然后执行该命令,接着调用refresh
函数进行刷新
这就是整个程序的循环过程
接下来对该循环使用的每一个函数进行讲解
get_one_char
get_one_char
调用了readit
函数,从任意一个地方获取用户输入的一个字符并返回
readit
函数是一个几乎比较底层的操作
它定义了一个结构体esc_cmds
,记录的是各种特殊指令的键值
该结构体有两个成员:seq
字符串与val
字符
其中seq
记录的是从控制台接受的特殊按键的字符,val
记录的是转换为正常操作的虚拟键码
注意<0x1b>
,该字符是ascii中的分割符,输入效果和空格一样,但是无法由键盘直接输入,用来处理输入数据的分割
接下来的一个宏ESCCMDS_COUNT
的意思是特殊按键的数量
在该函数之前定义了一个readbuffer
缓冲,用于存储读取的字符
首先判断readbuf
是否已经空了,如果是,则读取一定量的输入到缓存中
若读取失败,则判断errno
的值,并对不同的情况作出不同的处理,最差的情况是直接退出编辑。
errno
是记录系统的最后一次错误代码,定义在头文件errorno.h
中,详见百度百科
若输入的不是潜在的命令字符串,则直接返回输入的数据
接下来使用了两个宏:FD_ZERO
、FD_SET
其中FD
是file descriptor的缩写,即文件描述符
它们操作对象是一个很重要的结构体fd_set
,这个集合中存放的是文件描述符,即文件句柄
FD_ZERO
即清空fd_set
集合,让其不再包含任何文件句柄
FD_SET
即将一个指定的文件描述符加入集合之中
其他更多的有关操作详见
https://blog.csdn.net/u012252959/article/details/48574711
为什么要在这里使用这两个宏?主要是因为下面会使用select
函数
select函数用于在非阻塞中实现多路复用输入/输出模型
详见
https://blog.csdn.net/u012252959/article/details/48574711
https://blog.csdn.net/piaojun_pj/article/details/5991968
在这里,首先将文件描述符集合清零,其次,将文件描述符0,即标准输入,加入文件描述符集合
然后,对tv
结构体进行了设置
tv
即timeval
,是一个结构体,定义于time.h
详见
https://www.cnblogs.com/biggerjun2015/p/5062234.html
主要目的是设定一个等待时间
接下来使用select
函数,若在限定的时间内,它所监听的文件描述符,即标准输出,出现了状态变化,即在这个时间内有输出,且输入缓冲区有空间,则将其填入输入缓冲区
select
函数的作用是监视我们需要监视的文件描述符的状态变化情况,并且通过返回的值告诉我们
接下来,在此前定义的结构体esccmds
中寻找输入对应的命令,并且返回对应的虚拟键值
总而言之,get_one_char
实现了以下的功能:
从标准输入中读取一定大小的内容到输入缓存中,并将对应的命令字符转换为虚拟键值后返回
do_cmd
该函数的作用是对输入的每一个字符执行对应的操作
首先检查输入的字符是否是命令字符,若是,则进入对应的操作
若不是,则对其进行检查
如果处于Replace
模式,则将会把旧的数据用新的输入覆盖
replace模式的逻辑是,如果要覆盖的数据是回车,则不予覆盖,转成插入模式
否则,如果要输入的字符不是分隔符<0x1b>
,则替换当前的字符
替换操作使用了以下两个函数:yank_delete
与char_insert
yank_delete
的作用是,在源文件的文件缓冲区上进行删除操作
char_insert
的作用是,插入一个字符,对其进行格式化处理,并返回插入的字符的指针
若不是命令字符,在做完这些处理后跳转到dc1
处
以前的代码太nb了,到处都是goto,没有一个强大的大脑根本写不下去
dc1处的代码内容是,保证源码缓存至少有一个回车,并且判读光标的位置是否合法并对其进行微调
接下来应该查看key_cmd_mode
处的代码
但是这一段代码实在是太nb了
首先是默认情况,default,对未录入的命令进行处理
接下来是对各种情况的处理,都很常规,没有再介绍的必要
需要注意的是,edit_file
函数主循环的最后是一个休眠函数
此处休眠函数的作用并非休眠,而是获取是否有追加的输入信息,若无连续的输入,则刷新页面
总结
首先是vi程序的执行流程
使用主函数对程序输入参数进行解包,进行一连串的预处理后,挨个对要操作的文件使用edit_file
函数进行真正的处理
同样,edit_file
函数也是先进行一连串的预处理,然后进入主循环进行控制。
其次是vi程序使用的数据结构
该程序并没有使用很复杂的数据结构,使用几个普通的数组充当缓存。
这种朴素的数据结构的插入和查找操作的复杂度都是O(n)
,仅仅适用于小量的数据操作,在碰到巨大的数据结构的时候极易崩溃。
其次是vi程序的结构
虽然上古时代的程序goto满天飞且几乎全部都是全局变量,但是我们仍然能从里面一窥程序的结构
vi逻辑由如下部分构成:
- 输入部分
- 控制部分
- 渲染部分
此外,vi内还有很多缓存与全局变量
输入部分
输入部分有:
-
宏定义
大部分为命令模式输入的按键的虚拟键码的宏定义 -
数据结构
在函数readit
中定义了一个数据结构esc_cmds
-
缓存
主要的缓存为readbuf -
函数
主要有关的函数有:
get_one_char
readit
控制部分与渲染部分
有些元素是控制部分与渲染部分共有的,例如光标指针dot
与输出缓存screen
,首先是控制部分接受输入部分解析完成的信号后对渲染数据进行操作,例如光标下移等等,然后渲染部分使用refresh函数进行渲染,而类似于插入字符等操作,则是控制部分与渲染部分共同参与的。
另一些元素则是控制部分独有的。例如全局变量editing即是edit_file函数主循环的退出判断条件
其他元素是渲染部分独有的。例如状态输出缓存status_line
等。
但是渲染部分无法脱离控制部分独立存在。此外,输入部分也是由控制部分调用的。
心得
在阅读源码的时候,切忌逐字逐行地浏览,因为源码的体量往往是巨大的,从几千行到几百万行不等,且源码往往也不是由一个人完成的,在代码风格或者其他方面必定会有些许的差异。
在阅读源码的时候,应该充分利用注释,对于一些复杂的内容,优秀的源码必定会留有注释,而对于一些无关紧要的函数,仅仅只需要知道它的用途即可。优秀的函数命名风格往往能顾名思义。
在阅读源码的时候,一定要紧抓重点,想清楚自己需要的是什么东西。另外,额外的笔记是必须的,否则很可能睡一觉起来就忘光了。
最后,由于一些源码的撰写时间久远,例如我看的这一份,是2002年的,年龄和我一样大,必定存在很多在当时是通用而现在被淘汰的写法,例如大量的goto语句与大量全局变量等等,也有很多语法等方面与现代用法不同。在面对这些差异的时候,要取长补短,而非钻牛角尖。