大学生活 / 计算机相关 · 2025年10月28日 0

如何用C语言复现经典文本编辑器vi——搭建框架

是不是学完了C语言很长时间都在对付一个黑框框,输出一些很简单的东西?本系列将会教你写一个简单的文本编辑器,这个编辑器基于控制台,但实现了Unix下著名编辑器vi的基本功能。

vi是上世纪80-90年代BSD操作系统下的一个文本编辑器,取代了ed编辑器的位置。早期的ed编辑器一次只能显示一行,非常难用,vi实现了同时显示多行,也就是可视化编辑。

vi的基本功能包括:

  • 普通模式
  • 插入模式
  • 命令模式
  • 模式切换
  • hjkl移动光标
  • dd命令删除行
  • 检索
  • 撤销、重做

本文是系列的第一篇文章,将带你完成一个基本框架。

本系列并不是C语言入门教程,本系列假设你已经学完了:

  • C语言变量
  • 基本输入输出
  • 顺序、选择、循环结构
  • 数组
  • 函数
  • 指针
  • 结构体
  • 枚举
  • 预处理命令
  • 文件操作

本文的代码可以从GitHub下载:https://github.com/Mike-Solar/modern-vi

对于这个系列的任何问题可以电子邮件联系我:mike@mikesolar.cn

多文件编程和#include

在C语言入门阶段,我们编写的代码只有一个源文件。但是,如果代码有几千行,还能写到一个文件吗?当然不能。这时候,多文件编程就派上用场了。

之前我们肯定用过#include指令,它的作用是引入一个头文件,相当于把这个头文件的内容拷贝到这条指令所在的位置。实际上,任何纯文本文件都可以#include,但请不要这样做。你可以#include一个.c文件,或者一个txt,当然这种做法并不推荐。#include的标准用法是用于引入头文件。

那我们就得到了多文件编程的一种思路:把一部分代码写入头文件里,然后#include进来。这种做法是可行的,但有一个问题:现代多核处理器可以并发地执行多个编译任务,可是一个.c文件只能在一个核心上编译。一个源文件多个头文件的架构会导致“一核有难八核围观”。

怎么办呢?只要把源文件也拆成多个就行了。

在C语言中,一个源文件也叫一个编译单元。还记得C语言编译的两个阶段吗?是编译和链接。其中:

  • 每个编译单元独立编译,形成目标文件
  • 如果目标文件中包含了对其他目标文件中的函数或全局变量的引用,则会生成编译符号
  • 链接器把这些目标文件“拼接”成一个可执行文件,并为每个编译符号找到它们的实现。

这样,不同编译单元可以放到不同的核心上编译,可以充分发挥现代多核编译器的优势。

那么,当一个编译单元要引用另一个编译单元中的函数和全局变量(统称符号)时,编译器如何知道它是存在的,而不是程序员写错了呢?这就是函数声明全局变量声明

在入门阶段我们也许学习过函数声明,它的格式和函数定义一样,只不过没有函数体,末尾加分号。全局变量声明的方式是extern+变量定义。注意变量不能在声明处赋初值。

每个引用的不在当前编译单元定义的符号都必须在当前编译单元有声明。那么能不能把这些声明提取出来,放在一个文件里,告诉编译器去这里找声明?恭喜你发明了头文件。

因此,多文件编程需要:

  • 在头文件里编写声明
  • 在源文件里编写定义
  • typedef、结构体、共用体、枚举、宏的定义直接写在头文件里,因为它们并不会作为符号出现在目标文件中。

防止头文件被重复包含

假设这样一种情况,a.c引入了b.h和c.h,b.h引入了c.h,这时a.h里会有两份c.h,会出现“xx不明确”的错误,因为存在结构体/枚举/typedef/……重定义的问题。这时要防止头文件被重复包含。

最常见的做法是:

#ifndef XXX_H
#define XXX_H
// 头文件代码
#endif

这里用到了条件编译:

  • 如果没有定义宏XXX_H(通常是头文件名大写),则定义它作为空宏,并保留下面的代码
  • 如果定义了,删除后面的代码
  • 由于不能用大括号,用#endif表示结束。

API和系统调用

在入门阶段,我们都是和C语言标准库打交道,但是现在我们要介绍一些其他的库和函数。这其中最底层的就是Windows API和Linux系统调用。

你也许会好奇,C语言标准库并没有提供创建图形界面的函数,那么电脑上那么多图形界面的程序是如何实现的?这就要介绍Windows API了。

Windows API(应用程序接口)是Windows提供给应用程序的一组函数,这些函数提供了对操作系统功能的访问和对硬件的调用。例如你想创建一个窗口,你需要CreateWindow函数;向控制台打印文本需要WriteConsole函数;打开文件需要CreateFile函数……

你也许会好奇,哎,这和我学的不一样啊,不是printf和fopen吗?答案是printf底层调用了WriteConsole,fopen底层调用了CreateFile。像这样,调用一组函数来实现一个功能,然后向外部暴露一个接口,开发者只需要在乎这个接口如何使用而不需要了解底层函数的行为,叫封装。因此我们说,printf封装了WriteConsole,fopen封装了CreateFile。

Windows默认不提供C标准库,只提供API。这就是为什么你写的程序在没有安装Visual C++ Redistributable for Visual Studio 2015-2022就无法运行。

对于GNU/Linux的情况,由于Linux内核并不知道你用的是什么C库,因此它只提供系统调用。把系统调用封装成库是C标准库(例如glibc或musl libc)的责任。

第三方库

直接使用C语言标准库或操作系统API会非常麻烦,好在很多热心人把这些函数封装成了第三方库。很多第三方库是开源的,但这并不意味着可以随意使用。使用第三方库请务必注意许可协议!!!

在本程序中,我们要使用一个库:ncurses。我们将会使用CMake集成它。

VS Code,MSYS 2和CMake

你也许用过Dev-C++之类的IDE(集成开发环境),但它们并不是通常意义上成熟的、可用于生产环境的IDE。在本项目中,我们使用Visual Studio Code(VS Code)作为IDE,当然你也可以选择别的,甚至是记事本之类的,只要它能支持CMake就可以。

MSYS 2是Windows上模拟Unix环境的一个工具,它最大的优势就是提供了一个叫pacman的包管理器,可以自动下载第三方库。

这个组合的教程在网上有很多,这里推荐一篇:给萌新的C/C++环境搭建攻略(VSCode和MSYS2)。如果清华镜像站返回错误,可以切换至阿里巴巴镜像站或者中科大镜像站。方法

我们也需要CMake,也请一并装上。

如果你使用Mac,你只需要装好VS Code和CMake,并按照前述文章装好插件,第三方库已经预装。如果你使用Linux,从你的包管理器安装前述库。

介绍ncurses

ncurses是一个终端支持库。

你也许会好奇:如何实现在控制台里移动光标位置、替换字符、在指定位置输出字符?之前我们都是在Windows里用<conio.h>头文件里的代码实现的,或者干脆实现不了;在Linux/macOS上则需要打印控制字符实现。这非常麻烦,实现更复杂的终端效果就更麻烦了。所以我们有了终端支持库。ncurses就是其中一个。

Windows上安装ncurses的方式很简单,在MSYS 2 UCRT终端输入:

pacman -S mingw-w64-ucrt-x86_64-ncurses ncurses-devel

然后输入Y按回车确认。

如果你使用Mac,第三方库已经预装。如果你使用Linux,从你的包管理器安装前述库。

创建main函数,解析参数

通常我们会通过在终端里输入vi filename.txt命令来创建或打开文件,因此我们需要解析命令行参数。

在C语言中,有两种常见的main函数的声明,一种是我们常用的int main(void),有时写作int main(),另一种是

int main(int argc, char *argv[])

其中,argc是参数个数,argv是参数内容,argv[0]是程序的名字。因此,我们只要检测argv[1]就能拿到文件路径:

if (argc > 1) {
    if ((current_file = fopen(argv[1], "r+")) == NULL) {
        current_file = fopen(argv[1], "w+");
    }
}

如果argc[1]不存在,那么我们显示“No file opened”。这里我们要在屏幕中央显示这一行,因此我们需要借助ncurses来完成。

让我们声明几个全局变量。创建头文件global.h,添加:

#ifndef GLOBAL_H
#define GLOBAL_H
#include <stdio.h>
// 当前打开的文件
extern FILE* current_file;
// 当前缓冲区
struct buffer {
    size_t size;
    char *data;
};
extern struct buffer buffer;

// 当前模式
enum mode {
    NORMAL,
    INSERT,
    COMMAND
};

extern enum mode current_mode;

// 错误码通常用0-255,因此256表示继续
#define CONTINUE_CODE 256

#endif //GLOBAL_H

其中,变量加extern是为了声明这个全局变量。和函数一样,全局变量也可以声明而不定义,就是这样实现的。

为什么不在头文件里定义全局变量?那是因为头文件会被包含到每个include了它的源文件。如果有两个同名全局变量,会在链接阶段出现符号冲突;而如果不声明变量,编译器不知道它的存在。因此只能在源文件里定义变量,在头文件里声明但不定义。函数也是一个道理。

在main.c开头导入必要的头文件:

#include <ncurses.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "global.h"

注意 如果你使用Windows,请用ncurses/ncurses.h替换ncurses.h,要不然找不到头文件

定义这些变量:

FILE *current_file = NULL;
char *no_file_opened = "No file opened.";
struct buffer buffer = {0,NULL};
enum mode current_mode = NORMAL;

在main函数开头添加:

initscr();

初始化控制台。在这行后面,直到endwin(),控制台被ncurses接管,我们无法使用标准的printf/scanf,只能借助ncurses自带的函数。

在main函数结尾添加:

endwin();

之后在中间添加:(在上一个if后面)

if (current_file == NULL) {
    int scrLine = 0, scrCol = 0;
    getmaxyx(stdscr, scrLine, scrCol); // 获取标准屏幕的行/列数
    move(scrLine / 2 - 1, scrCol / 2 - strlen(no_file_opened)); // 将光标移至屏幕中央

    printw(no_file_opened);

    move(0, 0);
}

请注意,这里的getmaxyx是一个宏,因此scrCol和srcLine不需要取地址符,宏展开后会为它们加上的。如果不知道这是什么东西,请搜索一下含参数宏。

move宏用于将光标移动到特定的位置,注意列在前行在后,行列从0开始计算,因此移动到最后一行的话scrLine要-1。

printw是ncurses提供的和printf功能一样的安全的替代函数。

如果成功打开了文件,我们将读取一屏数据并显示出来:

else {
// 读取一屏数据
int scrLine = 0, scrCol = 0;
getmaxyx(stdscr, scrLine, scrCol); // 获取标准屏幕的行/列数
scrLine -= 2; // 留出最后一行用于状态信息

// 申请一片内存作为缓冲区,用于存放读取到的字符串
buffer.data=(char*)malloc(scrLine * scrCol * sizeof(char)+sizeof(char));
buffer.size=scrLine*scrCol+1;
memset(buffer.data, 0, scrLine * scrCol * sizeof(char));

for (int i = 0,line_index=0,cur=0; i < scrLine * scrCol; i++,cur++) {
int c=fgetc(current_file);
if (c==EOF) {
break;
}
buffer.data[cur]=c;
if (c=='\n') {
i+=scrLine-line_index;
line_index=0;
}
else if (i%scrLine==0) {
line_index=0;
}
else {
line_index++;
}
}
printw(buffer.data);
}

for循环的逻辑是:

  1. 如果遇到EOF,则停止读取;
  2. 如果遇到换行符,则重置当前行字符数(line_index),并把增加i的值。i用于标记当前在屏幕上占据了多少字符的空间,这里类似于把剩余空间用空字符填满;
  3. 如果该换行了,重置当前行字符数(line_index);
  4. 其他情况,当前行的字符数自增。

无论如何,i和游标cur(用于标记当前缓冲区内占用了几个字符的位置)都会自增。

接下来我们创建CMakeLists.txt。

创建CMakeLists.txt

CMake是一个构建系统,与具体的IDE无关。试想:

  • 你修改了一个大项目的一个文件,难道要全部重新编译?
  • 一个有上千个源文件的大项目,编译命令得多复杂?
  • IDE更换后如何无缝迁移?

CMake可以:

  • 生成Makefile文件或其他构建系统需要的文件,Makefile之类的会检测时间戳,只编译改动过的部分
  • 自动生成构建命令
  • 与IDE无关,可以在VS/VS Code/CLion之间无缝切换

为什么不直接用Makefile?Makefile的语法很复杂,而且平台强相关,也就是A编译器的Makefile在B上不能用。CMake不存在这种问题。

CMakeLists.txt描述了项目的结构和依赖情况。我们的CMakeLists.txt长这样:

cmake_minimum_required(VERSION 3.31)
project(modernvi C)

set(CMAKE_C_STANDARD 11)

include_directories(${CMAKE_SOURCE_DIR})

add_executable(modernvi main.c
global.h)

target_link_libraries(modernvi PRIVATE ncurses ncursesw)

让我们解释一下:

cmake_minimum_required(VERSION 3.31)

这行代码规定了要构建这个项目需要的最低CMake版本是3.31。

project(modernvi C)

这行代码声明了一个叫“modernvi“的项目。请注意这个名字和可执行文件的名字没关系。“C”表示C语言。

set(CMAKE_C_STANDARD 11)

标记C语言版本为C11。

include_directories(${CMAKE_SOURCE_DIR})

将当前项目根目录加入头文件查找路径。头文件查找路径,顾名思义,你#include <stdio.h>时为何能找到stdio.h?因为它在查找路径里。

add_executable(modernvi main.c
global.h)

这里声明了一个可执行文件叫modernvi,在Windows上.exe会自动加上。它需要main.c和global.h。

一些地方会教你用aux_source_directory,但推荐的做法是手工列出所有头文件。

target_link_libraries(modernvi PRIVATE ncurses ncursesw)

链接ncurses库。这里由于ncurses已经在默认的库文件查找路径里,不需要指明路径;否则需要target_link_directories。

创建普通和命令模式的代码

接下来我们来编写普通模式的代码。

创建mode目录,在下面创建normal、insert、command三个子目录,分别代表普通、插入、命令模式。

在mode/normal下创建normal.c和normal.h,在normal.h里声明两个函数:

#ifndef NORMAL_H
#define NORMAL_H

#include "global.h"

void enter_normal_mode();

int handle_normal_input(char ch);

#endif //NORMAL_H

enter_normal_mode用来进入普通模式:

void enter_normal_mode() {
noecho(); // 关闭屏幕回显
current_mode = NORMAL;
int scrLine = 0, scrCol = 0;
getmaxyx(stdscr, scrLine, scrCol); // 获取标准屏幕的行/列数
// 移动光标到最后一行开头
move(scrLine - 1, 0);
// 清空本行
clrtoeol();
printw("NORMAL");
move(0,0);
}

注释很清楚了。

handle_normal_input处理普通模式的输入,我们暂时只处理进入命令模式的冒号键:

int handle_normal_input(char ch) {
switch (ch) {
case ':':
enter_command_mode();
break;
}
}

创建mode/command/command.c和mode/command/command.h,在command.h里声明两个函数:

#ifndef COMMAND_H
#define COMMAND_H

void enter_command_mode();
int handle_command_input(char ch);

#endif //COMMAND_H

enter_command_mode用于进入命令模式,handle_command_input处理命令输入:


#include "command.h"
#include <ncurses.h>

#include "global.h"
#include "mode/normal/normal.h"

static int handle_command();

struct command {
char data[1024];
int cur;
} command={{0}, 0};

void enter_command_mode() {
// 开启回显
echo();

current_mode=COMMAND;

// 移动到行尾
int scrLine = 0, scrCol = 0;
getmaxyx(stdscr, scrLine, scrCol);
move(scrLine - 1, 0);

// 清空最后一行
clrtoeol();

printw(":");
}

int handle_command_input(char ch) {
//如果是回车,开始解析命令
if (ch=='\n') {
return handle_command();
}
//如果是ESC,返回普通模式
if (ch==27){
enter_normal_mode();
}
//其他情况,将字符加到字符串末尾
else {
command.data[command.cur] = ch;
command.cur++;
}
return CONTINUE_CODE;

}
static int handle_command() {

// 我们暂时只处理q命令

for (int i=0; i<command.cur; i++) {
switch (command.data[i]) {
case 'q':
return 0;
}
}
}

注意 如果你使用Windows,请用ncurses/ncurses.h替换ncurses.h,要不然找不到头文件

将上述四个文件加到CMakeLists.txt:

cmake_minimum_required(VERSION 3.31)
project(modernvi C)

set(CMAKE_C_STANDARD 11)

include_directories(${CMAKE_SOURCE_DIR})

add_executable(modernvi main.c
global.h
mode/normal/normal.c
mode/normal/normal.h
mode/command/command.c
mode/command/command.h)

target_link_libraries(modernvi PRIVATE ncurses ncursesw)

回到main.c,追加处理输入的代码:

enter_normal_mode();

// 处理输入
int code;
while (true) {
switch (current_mode) {
case NORMAL:
handle_normal_input(getch());
break;
case COMMAND:
int code=handle_command_input(getch());
if (code!=CONTINUE_CODE) {
goto exit;
}
}
}

关闭文件退出:


exit:
if (current_file != NULL) {
fclose(current_file);
}

goto是无条件跳转指令。很多地方不讲goto,因为用它是不好的习惯。本文展示的是唯一正确用途。

完整的main.c:

#include <ncurses.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "global.h"
#include "event/event.h"
#include "mode/command/command.h"
#include "mode/normal/normal.h"

FILE *current_file = NULL;
char *no_file_opened = "No file opened.";
struct buffer buffer = {0,NULL};
enum mode current_mode = NORMAL;

int main(int argc, char *argv[]) {
initscr();

if (argc > 1) {
if ((current_file = fopen(argv[1], "r+")) == NULL) {
current_file = fopen(argv[1], "w+");
}
}
if (current_file == NULL) {
int scrLine = 0, scrCol = 0;

getmaxyx(stdscr, scrLine, scrCol); // 获取标准屏幕的行/列数
move(scrLine / 2 - 1, scrCol / 2 - strlen(no_file_opened)); // 将光标移至屏幕中央

printw(no_file_opened);

move(0,0);
} else {
// 读取一屏数据
int scrLine = 0, scrCol = 0;
getmaxyx(stdscr, scrLine, scrCol); // 获取标准屏幕的行/列数
scrLine -= 2; // 留出最后一行用于状态信息

// 申请一片内存作为缓冲区,用于存放读取到的字符串
buffer.data=(char*)malloc(scrLine * scrCol * sizeof(char)+sizeof(char));
buffer.size=scrLine*scrCol+1;
memset(buffer.data, 0, scrLine * scrCol * sizeof(char));

for (int i = 0,line_index=0,cur=0; i < scrLine * scrCol; i++,cur++) {
int c=fgetc(current_file);
if (c==EOF) {
break;
}
buffer.data[cur]=c;
if (c=='\n') {
i+=scrLine-line_index;
line_index=0;
}
else if (i%scrLine==0) {
line_index=0;
}
else {
line_index++;
}
}
printw(buffer.data);
}
enter_normal_mode();

// 处理输入
int code;
while (true) {
switch (current_mode) {
case NORMAL:
handle_normal_input(getch());
break;
case COMMAND:
int code=handle_command_input(getch());
if (code!=CONTINUE_CODE) {
goto exit;
}
}
}

exit:
if (current_file != NULL) {
fclose(current_file);
}

endwin();

return code;
}

注意 如果你使用Windows,请用ncurses/ncurses.h替换ncurses.h,要不然找不到头文件

现在你可以运行一下。

mkdir build
cd build
cmake .. -G "MinGW Makefiles"
make
./modernvi ../main.c

如果你用了不同的文件名,替换modernvi。macOS下替换MinGW Makefiles为Unix Makefiles。

应该显示main.c的前几行,按下:q可以退出。

思考题

最后留一个思考题:如何实现按hjkl键来上下左右移动光标?

下一篇文章我们讲解这个问题,同时讲解如何实现文件的编辑和保存。