1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
|
---
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/)绘制了一个电子版:

这张图我省略了一些深的函数调用,但也能帮助我大体上掌握这个程序的执行流程。结合这张图与源码,我发现文本编辑器的核心功能——打开、编辑、保存,实现难度并不大,在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写的),又有新玩具了!
|