用 C 与 POSIX 系统调用写一个零依赖的终端编辑器

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

中文
复制
编辑器自身运行时的截图:终端窗口里显示一段 C 源码,底部状态栏写着文件名与行号 src/main.c - 1075 of 1412 lines,最下方是搜索提示

我为什么做这件事?#

作为一个对操作系统非常着迷的人,我自然而然地拿起了那本被誉为圣杯的操作系统教材——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 上查看

来源: 111Nation DevBlog← 返回首页