剖析千行C语言文本编辑器Kilo的技术细节
今天上午学习了一下Kilo的源代码。我很早以前就对文本编辑器的实现方法感兴趣了。 Kilo是一个很简易却不简陋的项目,清晰地展示了如何构建一个终端下的文本编辑器,它的目的不是真正让你学会去开发一个高标准高质量,能投入使用的文本编辑器,而是理解文本编辑器的核心骨架、理解一个看似庞大一团糟的问题的拆解思路。这是一个很好的起点。也过了一把爽玩C语言的瘾(虽然我并没有写几行代码)。 程序分析 整个项目只有一个文件,一千三百行代码。我用了大概一个半小时梳理了程序的执行流程,手画了一个流程图。为了美观,我又用Graphviz绘制了一个电子版: 这张图我省略了一些深的函数调用,但也能帮助我大体上掌握这个程序的执行流程。结合这张图与源码,我发现文本编辑器的核心功能——打开、编辑、保存,实现难度并不大,在C语言中容易踩坑的是缓冲区处理、文件读写这种老生常谈的内容。在这个程序中,调用最多、最重磅的部分是initEditor这个函数,以及后续的高亮处理,尤其是前者在窗口尺寸计算、修改后的做法上花费了大功夫。其实和终端环境的交互才是最麻烦的点,它提供的封装和抽象并不多,有很多需要自己手动调试的地方,繁琐是显著特征。 终端信号处理 我发现在终端程序里,需要快捷键的部分都是使用Raw mode和signal相关的函数组合实现的,在理解signal这个函数和它的有关宏的概念时,耗费了比较长的时间。 简单来说,signal用于处理用户在终端发出的信号,比如SIGINT代表由C-c产生的中断信号,SIGIGN代表忽略信号,即接受到这个信号以后什么都不做,关于如何接受信号,就要说起signal()这个函数。定义如下: void (*signal(int sig, void (*func)(int)))(int); 看起来非常复杂,说人话就是接受两个参数,第一个参数是int类型的sig,是信号编号,比如SIGINT,这是要接收的信号。第二个参数是一个函数指针,接受一个返回void,参数是int类型的信号处理函数,使用第二个函数中的函数对接受到的信号做处理。函数返回原来的信号处理函数(函数指针)。 可以用typedef简化理解: // 定义信号处理函数的类型 typedef void (*sighandler_t)(int); // 用 typedef 重写 signal 声明 sighandler_t signal(int sig, sighandler_t func); 在Kilo中,C-c是被忽略的,因为它非常容易导致丢失修改,可以这样实现: signal(SIGINT, SIGIGN); 不过,在Kilo的实现,是通过调用editorReadKey(),从Raw Mode 中读取一个按键存入数组,用switch匹配按键对应的值再返回给调用方,调用方也通过switch,匹配对应的操作函数。而在C-c的部分,则是直接break掉了。 ... case CTRL_C: /* Ctrl-c */ /* We ignore ctrl-c, it can't be so simple to lose the changes * to the edited file. */ break; ... 这种实现方法也有一定局限性,不同的终端模拟器可能发送不同的转义字符,硬编码转义字符会出现不适配的情况。并且使用read()阻塞读取输入有性能瓶颈。 精妙的数据结构与算法 这个程序最有趣的地方在于清晰、通用的数据结构的设计,以editorConfig为例: struct editorConfig { int cx, cy; /* Cursor x and y position in characters */ int rowoff; /* Offset of row displayed. */ int coloff; /* Offset of column displayed. */ int screenrows; /* Number of rows that we can show */ int screencols; /* Number of cols that we can show */ int numrows; /* Number of rows */ int rawmode; /* Is terminal raw mode enabled? */ erow *row; /* Rows */ int dirty; /* File modified but not saved. */ char *filename; /* Currently open filename */ char statusmsg[80]; time_t statusmsg_time; struct editorSyntax *syntax; /* Current syntax highlight, or NULL. */ }; 我们定义了一个editorConfig类型的变量,它全局唯一,维护了程序的基本状态,包括行、列、滚动偏移、终端尺寸。让程序状态的流转非常清楚。这些内容都是一个文本编辑器需要关心的最核心内容:光标位置、视图偏移、数据和文件的状态等信息。 通过这个结构体,能简单地获取程序当前的状态,或者为某项功能对状态作出修改,对一个新手来说还是挺拓宽思路的,至少我想不到怎么设计这些数据结构。 数据与显示的分离 在editorConfig中嵌套了一个erow类型的变量,里面的东西也可以展开说说,定义如下: typedef struct erow { int idx; /* Row index in the file, zero-based. */ int size; /* Size of the row, excluding the null term. */ int rsize; /* Size of the rendered row. */ char *chars; /* Row content. */ char *render; /* Row content "rendered" for screen (for TABs). */ unsigned char *hl; /* Syntax highlight type for each character in render.*/ int hl_oc; /* Row had open comment at end in last syntax highlight check. */ } erow; 这里面有一个render字段,在editorUpdateRow()中,有这样的代码: unsigned int tabs = 0, nonprint = 0; int j, idx; /* Create a version of the row we can directly print on the screen, * respecting tabs, substituting non printable characters with '?'. */ free(row->render); for (j = 0; j < row->size; j++) if (row->chars[j] == TAB) tabs++; unsigned long long allocsize = (unsigned long long)row->size + tabs * 8 + nonprint * 9 + 1; if (allocsize > UINT32_MAX) { printf("Some line of the edited file is too long for kilo\n"); exit(1); } 循环的if中使用的 TAB 定义在KEY_ACTION枚举,值为9,在ASCII码中是\t也就是水平制表符。代码在统计tab的数量。 问题在于,一个\t在内存中占1字节,但在屏幕显示的时候会占据八个字符的宽度,这里就体现出render的作用了,如果一行有两个\t,每个最多展开为八个空格,那么所需要计算的大小就是2 * 8 + chars的大小: (unsigned long long)row->size + tabs * 8 + nonprint * 9 + 1; 那个恒为0的变量nonprint可能是为将来打印不可见字符设计的。结尾的+1为'\0'预留。 按照这个公式,给render分配内存: row->render = malloc(row->size + tabs * 8 + nonprint * 9 + 1); 随后,这些代码在非制表位填充空格: idx = 0; for (j = 0; j < row->size; j++) { if (row->chars[j] == TAB) { row->render[idx++] = ' '; while ((idx + 1) % 8 != 0) // 在非制表位填充空格 row->render[idx++] = ' '; } else { // 正常字符直接赋值 row->render[idx++] = row->chars[j]; } } row->rsize = idx; // 在循环结束的时候,idx等于写入字符总数 row-render[idx] = '\0'; //在字符末尾添加结束符 虽然有点绕,但设计还是非常巧妙的! 代码高亮 源码中使用大量篇幅实现了代码高亮,定义了一些关键字: char *C_HL_extensions[] = {".c", ".h", ".cpp", ".hpp", ".cc", NULL}; char *C_HL_keywords[] = { /* C Keywords */ "auto", "break", "case", "continue", "default", "do", "else", "enum", "extern", "for", "goto", "if", "register", "return", "sizeof", "static", "struct", "switch", "typedef", "union", "volatile", "while", "NULL", /* C++ Keywords */ "alignas", "alignof", "and", "and_eq", "asm", "bitand", "bitor", "class", "compl", "constexpr", "const_cast", "deltype", "delete", "dynamic_cast", "explicit", "export", "false", "friend", "inline", "mutable", "namespace", "new", "noexcept", "not", "not_eq", "nullptr", "operator", "or", "or_eq", "private", "protected", "public", "reinterpret_cast", "static_assert", "static_cast", "template", "this", "thread_local", "throw", "true", "try", "typeid", "typename", "virtual", "xor", "xor_eq", /* C types */ "int|", "long|", "double|", "float|", "char|", "unsigned|", "signed|", "void|", "short|", "auto|", "const|", "bool|", NULL}; 然后在具体实现editorUpdateSyntax()中,简单粗暴地遍
以上内容来自博客订阅源,版权归原作者。关注作者后可以在 关注列表 第一时间查看作者文章。
登录后,加入这场讨论。
登录参与