summaryrefslogtreecommitdiffstats
path: root/content/posts/学习文本编辑器的随想.md
diff options
context:
space:
mode:
authoryingyu5658 <i@yingyu5658.me>2025-12-13 08:33:08 +0800
committeryingyu5658 <i@yingyu5658.me>2025-12-13 08:33:08 +0800
commit1e5f8eb33bc41cb59faf059e83701152785cabea (patch)
tree45867273ac2178285be840764f7962d2b55556c6 /content/posts/学习文本编辑器的随想.md
downloadblog-1e5f8eb33bc41cb59faf059e83701152785cabea.tar.gz
blog-1e5f8eb33bc41cb59faf059e83701152785cabea.zip
Initial commit
Diffstat (limited to 'content/posts/学习文本编辑器的随想.md')
-rw-r--r--content/posts/学习文本编辑器的随想.md214
1 files changed, 214 insertions, 0 deletions
diff --git a/content/posts/学习文本编辑器的随想.md b/content/posts/学习文本编辑器的随想.md
new file mode 100644
index 0000000..11d2f9a
--- /dev/null
+++ b/content/posts/学习文本编辑器的随想.md
@@ -0,0 +1,214 @@
+---
+date: '2025-12-06T13:26:47+08:00'
+draft: false
+title: '剖析千行C语言文本编辑器Kilo的技术细节'
+slug: 'kilo-analysis'
+categories:
+ - 技术
+tags:
+ - C语言
+ - 文本编辑器
+ - Kilo
+description: "关于Kilo的学习笔记。"
+---
+今天上午学习了一下[Kilo](https://github.com/antirez/kilo)的源代码。我很早以前就对文本编辑器的实现方法感兴趣了。
+
+Kilo是一个很简易却不简陋的项目,清晰地展示了如何构建一个终端下的文本编辑器,它的目的不是真正让你学会去开发一个高标准高质量,能投入使用的文本编辑器,而是理解文本编辑器的核心骨架、理解一个看似庞大一团糟的问题的拆解思路。这是一个很好的起点。也过了一把爽玩C语言的瘾(虽然我并没有写几行代码)。
+
+## 程序分析
+
+整个项目只有一个文件,一千三百行代码。我用了大概一个半小时梳理了程序的执行流程,手画了一个流程图。为了美观,我又用[Graphviz](https://www.glowisle.me/posts/%E7%AE%80%E5%8D%95%E4%BD%BF%E7%94%A8graphviz%E7%BB%98%E5%88%B6%E7%A8%8B%E5%BA%8F%E6%B5%81%E7%A8%8B%E5%9B%BE/)绘制了一个电子版:
+
+![程序流程图(大意)](https://images.glowisle.me/kilo-graph.png)
+
+这张图我省略了一些深的函数调用,但也能帮助我大体上掌握这个程序的执行流程。结合这张图与源码,我发现文本编辑器的核心功能——打开、编辑、保存,实现难度并不大,在C语言中容易踩坑的是缓冲区处理、文件读写这种老生常谈的内容。在这个程序中,调用最多、最重磅的部分是`initEditor`这个函数,以及后续的高亮处理,尤其是前者在窗口尺寸计算、修改后的做法上花费了大功夫。其实和终端环境的交互才是最麻烦的点,它提供的封装和抽象并不多,有很多需要自己手动调试的地方,繁琐是显著特征。
+
+### 终端信号处理
+
+我发现在终端程序里,需要快捷键的部分都是使用Raw mode和signal相关的函数组合实现的,在理解`signal`这个函数和它的有关宏的概念时,耗费了比较长的时间。
+
+简单来说,signal用于处理用户在终端发出的信号,比如`SIGINT`代表由`C-c`产生的中断信号,`SIGIGN`代表忽略信号,即接受到这个信号以后什么都不做,关于如何接受信号,就要说起`signal()`这个函数。定义如下:
+
+```c
+void (*signal(int sig, void (*func)(int)))(int);
+```
+
+看起来非常复杂,说人话就是接受两个参数,第一个参数是int类型的`sig`,是信号编号,比如`SIGINT`,这是要接收的信号。第二个参数是一个函数指针,接受一个返回void,参数是int类型的信号处理函数,使用第二个函数中的函数对接受到的信号做处理。函数返回原来的信号处理函数(函数指针)。
+
+可以用typedef简化理解:
+
+```c
+// 定义信号处理函数的类型
+typedef void (*sighandler_t)(int);
+
+// 用 typedef 重写 signal 声明
+sighandler_t signal(int sig, sighandler_t func);
+```
+
+在Kilo中,`C-c`是被忽略的,因为它非常容易导致丢失修改,可以这样实现:
+
+```c
+signal(SIGINT, SIGIGN);
+```
+
+不过,在Kilo的实现,是通过调用`editorReadKey()`,从Raw Mode 中读取一个按键存入数组,用switch匹配按键对应的值再返回给调用方,调用方也通过switch,匹配对应的操作函数。而在`C-c`的部分,则是直接break掉了。
+
+```c
+...
+
+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`为例:
+
+```c
+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`类型的变量,里面的东西也可以展开说说,定义如下:
+
+```c
+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()`中,有这样的代码:
+
+```c
+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的大小`:
+
+```c
+(unsigned long long)row->size + tabs * 8 + nonprint * 9 + 1;
+```
+
+那个恒为0的变量`nonprint`可能是为将来打印不可见字符设计的。结尾的`+1`为`'\0'`预留。
+
+按照这个公式,给`render`分配内存:
+
+```c
+row->render = malloc(row->size + tabs * 8 + nonprint * 9 + 1);
+```
+
+随后,这些代码在非制表位填充空格:
+
+```c
+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'; //在字符末尾添加结束符
+```
+
+虽然有点绕,但设计还是非常巧妙的!
+
+---
+
+### 代码高亮
+
+源码中使用大量篇幅实现了代码高亮,定义了一些关键字:
+
+```c
+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()`中,简单粗暴地遍历字符匹配这些关键字。在一般的教学例子中这样实现是可以的,我认为在具体的工程中应当用词法分析、语法分析和字典树去匹配。更易于维护和拓展,也能适配复杂的嵌套。
+
+---
+
+上述分析提到的缺点都可以作为优化方向,比如提供更简单操作接口,用词法分析技术或接入LSP服务器,为程序提供Lua接口来扩展插件……不过我相信在古老的纯C应用中,添加这些功能的繁琐程度和开发周期简直是灾难级别的。但是在处理快捷键上,使用`termcap`库的难度应该小于修改代码高亮部分的难度。
+
+这个项目最值得学习的点是如何将抽象的功能和终端联系起来、如何设计合理的数据结构以及标准库的使用。是阐释「程序 = 数据结构 + 算法」的很好例子。不过我自己是想不到那些函数该什么时候用,没准还会手动实现标准库造好的轮子呢。
+
+学习的过程很好玩,从主函数开始探索整个程序,一段一段地跳转调用,A调用B,B调用C,C调用D,理解了逻辑后再把它们画成图,对感兴趣的部分深入研究,有一种前人用他的智慧抚平我大脑褶皱的感觉……读懂它,几乎就等于一只脚趾踩上了理解Vim / Nano等项目的大门吧。
+
+想自己重新实现一次,然后加入自己的优化,比如联动Lua / Zig甚至是Go来实现上层的功能,好玩好玩真好玩。
+
+头皮好痒,要长脑子了!
+
+---
+
+- 在查资料的过程中又发现了 [vis](https://github.com/martanne/vis) 和 [micro](https://github.com/zyedidia/micro)(它甚至是用Go写的),又有新玩具了!