用 C 与 POSIX 系统调用写一个零依赖的终端编辑器
作者第一次写 C,照着 Kilo 指南做了个终端编辑器:raw mode 用 tcgetattr/tcsetattr、把字节流解析成按键(含 ANSI 转义序列)、用 ioctl(TIOCGWINSZ) 查窗口尺寸、用 write() 逐行绘制。全程不用任何第三方库。
中文
复制

我为什么做这件事?#
作为一个对操作系统非常着迷的人,我自然而然地拿起了那本被誉为圣杯的操作系统教材——Albert Woodhull 和 Andrew Tanenbaum 写的 《Operating Systems: Design and Implementation》。这本书会带你了解 MINIX 3 或任何符合 UNIX/POSIX 的操作系统(比如 Linux、macOS、FreeBSD),以及曾经的 Windows NT,在底层是如何运作的。我想把书里这些宝贵的知识用起来,于是就有了做一个终端工具的想法。「可是做文本编辑器怎么能让你学到操作系统的东西?」出乎意料的是,能学到的太多了!我做 Editor 的目标是不使用任何第三方库,只允许自己用核心系统库和系统调用。这逼着我去看清用户态工具在最底层是如何表现的,也就不可避免地揭开了操作系统运作方式中那些「黑箱」。
开始之前
警告
代码示例为了便于说明做了刻意简化,完整源码见这里。我不可能在博客里把所有东西都讲一遍,所以下面会大幅删减,只保留与系统编程和核心功能相关的部分。
设置终端
在能够修改文件、写入文件之前,我们得先搞清楚一件事:怎么把文本和状态显示到终端上。根据那本 Operating Systems 教材和 antirez 的指南,终端默认处于 cooked 模式,也就是 canonical 模式。这意味着像 /bin/bash 这样的 shell 允许你输入一整条命令,比如
~ $ ls /home/chloe/ | grep 'my file'
只有当你按下 Enter 之后,/bin/bash 才会收到那些代表一条命令的按键输入。
这就成问题了,因为我们希望 Editor 能看到每一次按键,而不是让你每敲一个键都得按一次 Enter。我们需要的是 raw 模式,也就是 non-canonical 模式。这个模式让 Editor 能在你按下按键的那一刻就看到输入。
为此,我们需要用 C 实现几个系统调用,让操作系统切换终端模式。在下面的代码片段中,我们用 tcgetattr() 获取终端设置,也就是属性。把指针传给函数是极其常见的做法,这样函数(如 tcgetattr())就能写入外部的数据结构。
我们把这些设置保存在 esettings.orig_termios 结构体中,因为之后要能恢复它们。接着我们复制这份设置,用位运算应用自己的设置。为了省时间,这里只简单说明:termios 数据结构保存的二进制整数分别表示:
raw.c_iflag- 终端输入设置raw.c_oflag- 终端输出标志raw.c_cflag- 终端控制模式raw.c_lflag- 终端本地模式
关闭某些设置的办法,是拿这些标志的非(~)做按位 AND(&=),比如 ~(BRKINT)。多个标志可以用管道符(’|’)组合起来,一次性全部清除。
这里的例外是 raw.c_cflag,我们要打开一个设置,指定字符长度为 8 位。为此我们用按位 OR(|=)把 CS8 位强制设为 1。
Tip
如果还没看明白,antirez 解释得很清楚,去看看
用 raw.c_cc 指定超时设置。这个 API 干净明了,用常量来指示位置,我们可以在这些位置为 VTIME 和 VMIN 指定具体的时间设置。
我们用 tcsetattr() 把改动提交到终端。
void enableRawMode() {
// Get current terminal settings
tcgetattr(STDIN_FILENO, &esettings.orig_termios);
// Duplicate terminal settings
struct termios raw = esettings.orig_termios;
// Modify settings on the duplicate
raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
raw.c_oflag &= ~(OPOST);
raw.c_cflag |= (CS8);
raw.c_lflag &= ~(ECHO | ICANON | ISIG | IEXTEN);
raw.c_cc[VMIN] = 0; // read() can terminate with a min of 0 bytes recieved
raw.c_cc[VTIME] = 1; // read() maximum wait time is 100 milliseconds
// Commit to raw mode by passing the duplicate
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
假设用户编辑完文本,退出了编辑器。问题来了!操作系统仍然把终端留在 raw 模式,这对其他进程(程序)非常糟糕。我们在 disableRawMode() 中恢复旧设置,把旧的终端设置 esettings.orig_termios 交给 tcsetattr()。
就这样,我们成功为 Editor 准备好了终端 :)
正确的按键输入方式
“我还以为按键输入已经搞定了!”:(
结果发现,虽然 Editor 能即时收到按键输入,但收到的是字节流。把这些字节格式化并解释出来,是我们的活儿。
实现方式是这样:定义一个 enum,接收编辑器所有合法的按键输入。由于这个 enum 在表示非 ASCII 的特殊字符时会超出 128,我们用 int 数据类型而不是 char,以防整数溢出。
enum editorKey {
BACKSPACE = 127,
ARROW_LEFT = 1000,
ARROW_UP,
ARROW_DOWN,
ARROW_RIGHT,
DEL_KEY,
PAGE_UP,
PAGE_DOWN,
HOME_KEY,
END_KEY,
NO_KEY_PRESS,
接着把所有按键输入逻辑——将 ASCII 和特殊字符流映射为单个整数以便处理——封装成 get_key()。在 raw 模式下,按键输入会立即送达:如果用户输入的是普通字母(大写或小写),我们收到的就是该字母,并按其常规 ASCII 码返回。而方向键、Home、Delete 这类特殊键通常不以标准 ASCII 发送,而是以 ANSI 转义码发送。
read
系统调用用于从文件读取。由于 UNIX 操作系统把一切皆视为文件,因此我们可以读取 STDIN_FILENO 的内容——这是一个标准化文件描述符,指向与当前终端会话相连的输入缓冲区。
VT100
转义码以转义序列开头,即 \x1b(十六进制)或 \033(八进制),后面跟一串 ASCII 字符,用来表示特殊字符或特殊终端操作。例如右方向键表示为 \x1b[C,Delete 键表示为 \x1b3~。
Enter 和 Backspace 这类按键不需要用 enum 处理。它们本身就已经是纯 ASCII 的 \r 和 \b。
Note
get_key()的目标是把一次按键表示为一个整数。这样处理按键就轻松多了!
int get_key() {
char c = '\0';
ssize_t res = read(STDIN_FILENO, &c, 1);
// If not special character, return ascii value as is
if (c != '\x1b') return c;
// Capture special keys, i.e Arrow keys
char special[3];
// Special characters have trailing characters left to read
read(STDIN_FILENO, &special[0], 1);
read(STDIN_FILENO, &special[1], 1);
if (special[0] == '[') {
switch (special[1]) {
case 'A': return ARROW_UP;
case 'B': return ARROW_DOWN;
case 'C': return ARROW_RIGHT;
case 'D': return ARROW_LEFT;
case 'H': return HOME_KEY;
case 'F': return END_KEY;
}
// Numbers as the 2nd char indicates more special keys
if (special[1] >= '0' && special[1] <= '9'){
read(STDIN_FILENO, &special[2], 1);
if (special[2] != '~') return '\x1b';
switch (special[1]) {
case '1': case '7': return HOME_KEY;
case '4': case '8': return END_KEY;
case '3': return DEL_KEY;
case '5': return PAGE_UP;
case '6': return PAGE_DOWN;
}
}
}
return '\x1b'; // We got an escape code but it was incomplete
把复杂按键表示为单个整数之后,我们就可以把它传给 process_keypress(),让编辑器执行相应操作。把程序拆分成职责明确的模块,永远是好习惯!
显示逻辑
组织文本
要能方便地操作和显示文本,就得把文本组织成易于遍历的结构。为此,Editor 在全局编辑器设置下使用一个主 struct,即 esettings。
如下所示,文本经过组织,可以轻松遍历。erow 是一个数据类型 struct,存储与单行相关的数据。可以看到,每一行存储实际的 ASCII 字符,即 char *chars。char *render 类似,区别在于把制表符显示为四个空格(由 TAB_STOP 预处理宏定义),而不是普通的 ASCII \t。这样做是因为,一个 \t 在我们的光标定位系统 cx 和 cy 中只算一个字符,但制表符在屏幕上实际占据的宽度不止一个字符!当某一行的 indent_level 大于 0 时,这能避免光标移动出现 bug。
我们把所有行存储为一个 erow 结构体数组。
Note
这样一来,光标就能轻松地在行与行之间跳转,也能方便地查询某一行的信息,而不必对整个文件内容做完整的 O(1) 操作。
#define TAB_STOP 4
typedef struct erow { // Single row data
int index;
int size; // char size
int indent_level;
int rsize; // render size
char *chars;
char *render;
...
} erow;
struct editorConfig {
...
unsigned int cx, cy; // Cursor position in file
unsigned int rx; // Rendered cursor position in rendered file
unsigned int ws_col, ws_row; // Screen dimensions
int numrows; // Number of rows in file
int rowoff; // Line number displayed as first line on screen
int coloff; // Character of current line displayed as first character on screen
erow* row; // File row data
...
获取窗口尺寸
在向终端显示文本之前,我们需要向操作系统查询终端窗口的尺寸。一个自说明的函数 get_window_size() 为我们封装了这一功能。
由于终端是基于文本的,窗口尺寸就是字符行数乘以字符列数。我们使用一个极其重要的 POSIX 系统调用 ioctl(),向终端驱动查询终端尺寸。
ioctl() 用于与 read 或 write() 无法交互的特殊文件打交道,这类文件通常是驱动程序。我们告诉 ioctl(),我们要查询的是终端的输出流(STDOUT_FILENO),并且想获取窗口尺寸(TIOCGWINSZ)。我们向 ioctl() 传入一个 winsize 结构体,终端窗口尺寸会被写入其中。
如果这一步失败,我们就改用 write() 系统调用,打印特殊的 VT100 转义码,把光标移到窗口的最边缘,再取回光标的相对位置。
int get_window_size(unsigned int *rows, unsigned int *cols) {
struct winsize ws;
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1 || ws.ws_col == 0) {
// Driver Query Unsuccessful
if (write(STDOUT_FILENO, "\x1b[999C\x1b[999B", 12) != 12) return -1;
return get_cursor_position(rows, cols);
}
*cols = ws.ws_col;
*rows = ws.ws_row;
return 0;
状态栏与提示符
打好基础之后,显示文本就相对简单了,为了节省篇幅,这里不再详细展开。只需要在遍历每一行时,用 write() 系统调用写入标准输出流(STDOUT_FILENO),再用 *esettings.row 把每个 渲染后的字符 *esettings.row->render 打印到终端即可。
Extra
编辑器通过
esettings.rowoff和esettings.coloff记录窗口中首先出现的行与字符。借助这些信息,我们可以改变首先出现的行与字符,从而实现滚动。escroll()做的就是这件事
函数 edraw_status() 用于绘制白底状态栏。我们只需用 write() 写入 STDOUT_FILENO,通过转义码把光标移到目标位置,再用 \x1b[7m 改变背景颜色。
prompt() 负责显示和管理文本编辑器的提示符。提示符用于搜索功能,或提示你想另存为的文件名。绘制提示符的方式与之前类似:打印状态消息后,将光标移到下一行。随后进入一个无限循环,轮询用户输入,直到用户输入文本或取消提示。
Extra
prompt()接受一个回调,编辑器的其他模块可以通过它收到每次按键输入的通知。Editor 的增量搜索就是这么实现的!
实现文本编辑
文件操作与 POSIX I/O
打开文件本身很简单,即使用 POSIX 系统调用也一样,但字符串的所有权需要谨慎的内存管理!
在给 esettings.filename 赋值之前,必须先释放已有的缓冲区指针,并用 strdup() 深拷贝传入的 filename。从栈帧或函数参数传入的文件路径,其生命周期可能不会延续到主程序循环之后,因此在堆上创建一份深拷贝可以避免日后出现悬空指针和段错误!
fopen()
用于打开一个已存在的文件。我们以只读方式打开("r")。
eopen() 用 getline() 逐行读取文件内容。每一行都会去掉末尾的换行符(\r、\n),然后把该行的字符串表示传给 insert_row(),构建出我们的 erow 数据结构。最后,把 esettings.dirty = 0 重置,表示文件尚未被修改,也就是说文件还是干净的 :)
void eopen(char* filename) {
free(esettings.filename); // Clear previous file pointer
esettings.filename = strdup(filename);
eselect_syntax_highlight();
FILE *fp = fopen(filename, "r");
char *line = NULL;
size_t linecap = 0;
ssize_t len;
while((len = getline(&line, &linecap, fp)) != -1) {
// Remove Trailing '\n' and '\r'
while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r')) len--;
insert_row(esettings.numrows, line, len);
}
free(line);
fclose(fp);
esettings.dirty = 0;
保存文件时,如果用户启动编辑器时没有用参数指定要编辑的文件,我们就用 prompt() 提示用户输入文件名。我们用 open()
获取该文件的文件描述符。通过指定位标志 O_RDWR | O_CREAT,文件会以读写模式打开;如果文件尚不存在,则会被创建。0644 以八进制表示文件的权限。文件的所有者,也就是创建文件的用户,拥有读写权限,而系统上的其他所有人只有读权限。
我们用 goto 语句让错误处理保持简洁,避免重复的清理代码。这样就不会出现层层嵌套的 if 语句,主流程也能保持在较低的缩进层级。写代码时,永远要追求易读性!
我们用 ftruncate()
来调整文件大小,使其恰好容纳用户的文本内容,不浪费额外存储空间。
void esave() {
esettings.filename = prompt("Save file as:\t %s", NULL);
int len;
char *buf = erows_to_string(&len);
int fd = open(esettings.filename, O_RDWR | O_CREAT, 0644);
if (fd == -1) {
goto cleanup_buf;
}
if (ftruncate(fd, len) == -1) {
goto cleanup_fd;
}
if (write(fd, buf, len) != len) {
goto cleanup_fd;
}
eset_message("%d bytes written to disk", len);
close(fd);
free(buf);
esettings.dirty = 0;
return;
cleanup_fd:
close(fd);
cleanup_buf:
free(buf);
eset_message("Error Saving! I/O error: %s", strerror(errno));
结语
以上就是 Editor 的全部核心内容。可惜一篇博客讲不完一千多行的代码库。语法高亮和搜索的细节我特意略过了,如果你对这个项目感兴趣,可以在我的 GitHub 上查看