上一节,我们的Ext2文件系统之旅告一段落。干了许久的基础设施建设的事情,今天我们来做一件更有意思的事情——给我们的操作系统移植一个文本编辑器,并借此机会把tty能力狠狠地加强一波。
kilo 是 antirez 写的一个极简的文本编辑器,代码只有一千多行。虽然短小,但它用到了不少 POSIX 终端接口——termios、ioctl、VT100 转义序列等等。恰好这些是我们的操作系统还缺少的东西,正好拿它来驱动我们的开发。
补全缺少的头文件
我们直接从kilo的编译开始,kilo编译,我们缺少的东西便一览无遗。kilo 编译时发现缺少一堆头文件:

我们来一个一个补全吧。
libc/include/sys/ioctl.h
标准的 ioctl 接口不是传什么 cmd 字符串,而是传数字表示的 request 码。我们定义了最基本的几个:
/* ioctl 请求码 */
#define TIOCGWINSZ 0x5413 /* 获取终端窗口大小 */
#define TIOCSWINSZ 0x5414 /* 设置终端窗口大小 */
struct winsize {
uint16_t ws_row; /* 行数 */
uint16_t ws_col; /* 列数 */
uint16_t ws_xpixel; /* 水平像素数(通常不用) */
uint16_t ws_ypixel; /* 垂直像素数(通常不用) */
};
然后 ioctl 的系统调用封装用可变参数实现:
extern "C" int ioctl(int fd, unsigned long request, ...) {
va_list ap;
va_start(ap, request);
void* arg = va_arg(ap, void*);
va_end(ap);
return syscall3((uint32_t)SYSCALL::IOCTL, (uint32_t)fd, (uint32_t)request, (uint32_t)arg);
}
libc/include/termios.h
termios = terminal input/output settings,是 POSIX 标准的终端输入输出配置规范。
typedef uint32_t tcflag_t;
typedef uint8_t cc_t;
#define NCCS 16
/* c_lflag 位 */
#define ECHO 0x0001 /* 回显 */
#define ICANON 0x0002 /* 规范模式(行缓冲) */
#define ISIG 0x0004 /* 启用信号(Ctrl+C 等) */
struct termios {
tcflag_t c_iflag; /* 输入模式 */
tcflag_t c_oflag; /* 输出模式 */
tcflag_t c_cflag; /* 控制模式 */
tcflag_t c_lflag; /* 本地模式 */
cc_t c_cc[NCCS]; /* 控制字符 */
};
struct termios 里的四个 flag 字段对应终端数据流的四个阶段:
c_iflag— input,数据从键盘进来时怎么预处理(比如要不要把回车转成换行)c_oflag— output,数据往屏幕输出时怎么后处理(比如要不要在换行前自动加回车)c_cflag— control,硬件控制参数(波特率、数据位数,串口时代的遗产)c_lflag— local,本地终端行为(要不要回显、要不要行缓冲、要不要响应 Ctrl+C)
libc/include/fnctl.h
顺便把 fcntl.h 也补上了,定义了文件控制的基础常量:
#define O_RDONLY 0x0000
#define O_WRONLY 0x0001
#define O_RDWR 0x0002
#define O_CREAT 0x0040
#define O_TRUNC 0x0200
libc/include/errno.h 修复
这还有个插曲:之前的 errno.h 文件内容实际上是 termios 的定义,guard macro 写的是 _TERMIOS_H。这显然是从某个草稿直接 rename 过来忘了改内容了。这里把内容修正为真正的 errno 定义:
extern int errno;
#define EPERM 1 /* 操作不允许 */
#define ENOENT 2 /* 文件或目录不存在 */
#define EINTR 4 /* 系统调用被信号中断 */
#define EBADF 9 /* 无效文件描述符 */
#define ENOMEM 12 /* 内存不足 */
#define EACCES 13 /* 权限不足 */
#define EINVAL 22 /* 无效参数 */
#define ENOTTY 25 /* 不是终端设备 */
#define ENOSYS 38 /* 系统调用未实现 */
// ...
补齐 libc 能力
补充几个kilo要用到的标准库函数:
exit() 与 atexit()
int _exit(int status) {
return syscall1((uint32_t)SYSCALL::EXIT, (uint32_t)status);
}
int atexit(atexit_func func) {
if (atexit_count >= 32) return -1;
atexit_handlers[atexit_count++] = func;
return 0;
}
void exit(int status) {
for (int i = atexit_count - 1; i >= 0; i--) {
atexit_handlers[i]();
}
_exit(status);
}
realloc()
之前只实现了 malloc/free,kilo 用到了 realloc。实现很简单——先分配新块,memcpy 旧数据过去,再释放旧块:
void* realloc(void* ptr, size_t new_size) {
if (!ptr) return malloc(new_size);
if (new_size == 0) { free(ptr); return nullptr; }
block_t block = (block_t)ptr - 1;
size_t old_size = block_size(block);
void* new_ptr = malloc(new_size);
if (!new_ptr) return nullptr;
memcpy(new_ptr, ptr, old_size < new_size ? old_size : new_size);
free(ptr);
return new_ptr;
}
这个实现在后续的commit中被修正——之前是直接用 new_size 去 memcpy,但旧块可能更小,会导致越界读。改成取 min(old_size, new_size) 就对了。
strerror() 与 strstr()
extern "C" const char* strerror(int errnum) {
switch (errnum) {
case 0: return "Success";
case EINTR: return "Interrupted system call";
case EAGAIN: return "Resource temporarily unavailable";
case EBADF: return "Bad file descriptor";
case ENOMEM: return "Out of memory";
case EACCES: return "Permission denied";
case ENOENT: return "No such file or directory";
case EEXIST: return "File exists";
case EINVAL: return "Invalid argument";
case ENOSPC: return "No space left on device";
case EROFS: return "Read-only file system";
case ENOSYS: return "Function not implemented";
default: return "Unknown error";
}
}
extern "C" char* strstr(const char* haystack, const char* needle) {
if (!*needle) return (char*)haystack;
for (; *haystack; haystack++) {
const char* h = haystack;
const char* n = needle;
while (*h && *n && *h == *n) { h++; n++; }
if (!*n) return (char*)haystack;
}
return nullptr;
}
getline()
需要补充 getline,kilo 用它读取文件内容:
// getline — 从文件流中读取一行
ssize_t getline(char **lineptr, size_t *n, FILE *stream) {
// ...
}
以及 time/time.cpp 提供时间相关的函数。
ioctl 处理请求
有了用户态的头文件和 ioctl 系统调用封装,接下来就要在内核里实际响应这些请求了。下面实现了 console 设备的 ioctl 接口。
首先在dev_operation里加上 ioctl 函数指针:
struct dev_operation {
int (*read)(char* buffer, uint32_t offset, uint32_t size);
int (*write)(const char* buffer, uint32_t size);
int (*set_poll)(process_queue* poll_queue);
int (*peek)();
int (*ioctl)(uint32_t request, void* arg); // 新增
};
然后在devfs.cpp里实现 vfs 层的 ioctl 分发:
static int ioctl(mounting_point* mp, uint32_t inode_id, uint32_t request, void* arg) {
if (!mp->data || (reinterpret_cast<dev_item*>(mp->data)->devcnt <= inode_id)) return -1;
dev_item* item = reinterpret_cast<dev_item*>(mp->data);
if (item->entry[inode_id].opr->ioctl == nullptr) return -1;
return item->entry[inode_id].opr->ioctl(request, arg);
}
接着在 console 驱动里实现console_ioctl:
static int console_ioctl(uint32_t request, void* arg) {
if (!arg) return -EFAULT;
switch (request) {
case TCGETS:
*(termios*)arg = terminal_get_setting();
break;
case TCSETS:
terminal_apply_setting(*(termios*)arg);
break;
case TCSETSW:
next_setting = *(termios*)arg;
has_next_setting_flag = 2;
break;
case TCSETSF:
terminal_apply_setting(*(termios*)arg);
terminal_flush();
break;
case TIOCGWINSZ:
terminal_getwinsize(*(winsize*)arg);
break;
}
return 0;
}
这里有个细节——TCSETSW 和 TCSETSF 的区别在于前者等到当前输出完成后再应用设置,后者还会丢弃未读取的输入。我偷了个懒,用 has_next_setting_flag 分了三种情况:1 表示下次 write 前应用,2 表示同时 flush。
termios 与终端行为管理
我们还需要做核心改造——让终端真正响应 termios 配置。这是一个非常有趣的部分,能让你知道现在的tty是怎么设置各种各样的格式、其内部拥有什么样的状态!
配置集中管理
之前 termios setting 放在console.cpp 里,但是终端行为应该由 tty 层统一管理,我们需要把它移到tty.cpp,并暴露接口:
const termios& terminal_get_setting() {
return setting;
}
void terminal_apply_setting(const termios& s) {
setting = s;
}
初始化时默认开启 ECHO、ICANON 和 ISIG:
setting.c_lflag |= (ECHO | ICANON | ISIG);
Ctrl+C 响应 ISIG
之前不管三七二十一,碰到 0x03 就发 SIGINT。现在只有在 ISIG 标志位被设置时才发:
void terminal_input(char c) {
if (c == 0x03 && (setting.c_lflag & ISIG)) {
terminal_write("^C\n", 3);
if (foreground_pid == 0) return;
send_signal(foreground_pid, SIGINT);
// ...
}
}
ECHO 控制回显
kilo 进入 raw mode 时会关掉 ECHO,这样你打字的时候字符不会回显到屏幕上——编辑器自己会管理屏幕内容。我们在terminal_write里判断:
if (terminal_get_setting().c_lflag & ECHO)
terminal_draw_char(terminal_col++ * FONT_WIDTH, terminal_row * FONT_HEIGHT, glyph, terminal_color);
关掉 ECHO 后,控制台只把字符写入缓冲区,不绘制到屏幕。
规范模式 vs 原始模式
默认是规范模式(ICANON),也就是行缓冲——用户按回车之前,控制台不会把数据交给读取的进程。kilo 需要的是原始模式,每次按键都要立即响应。
static int console_read(char* buffer, uint32_t offset, uint32_t size) {
// 非规范模式:逐字符读取
if ((terminal_get_setting().c_lflag & ICANON) == 0) {
int c = terminal_read_char();
if (c < 0) return 0;
buffer[0] = (char)c;
return 1;
}
// 规范模式:行缓冲
while (!line_ready) { /* ... */ }
}
在 terminal_read_char 里,原始模式的支持更精细——如果 VMIN == 0,用超时等待而不是无限 yield:
int terminal_read_char() {
bool raw = (setting.c_lflag & ICANON) == 0;
while (1) {
if (process_list[cur_process_id]->signal) return -1;
// ...
if (raw && setting.c_cc[VMIN] == 0) {
// VMIN=0: 超时等待,没数据就返回 -1
timeout(&tty_wait_queue, read_wait_time);
SpinlockGuard ttyguard(tty_lock);
if (kbd_buffer.head == kbd_buffer.tail) return -1;
break;
} else {
yield();
}
}
// ...
}
特殊键识别
我们还需要让键盘驱动支持方向键等特殊键。PS/2 键盘的扩展扫描码以 0xE0 前缀开头,我们需要识别它并转换成 VT100 转义序列。
static bool e0_prefix = false;
void keyboard_interrupt_handler(registers* /* regs */) {
uint8_t scancode = hal_inb(0x60);
if (scancode == 0xE0) {
e0_prefix = true;
return;
}
if (e0_prefix) {
e0_prefix = false;
handle_e0_scancode(scancode);
return;
}
// ...普通按键处理
}
转换逻辑:
static void handle_e0_scancode(uint8_t scancode) {
if (scancode & KEY_RELEASED_MASK) return;
switch (scancode) {
case 0x48: send_escape_seq("\x1b[A"); break; /* 上 */
case 0x50: send_escape_seq("\x1b[B"); break; /* 下 */
case 0x4D: send_escape_seq("\x1b[C"); break; /* 右 */
case 0x4B: send_escape_seq("\x1b[D"); break; /* 左 */
case 0x47: send_escape_seq("\x1b[H"); break; /* Home */
case 0x4F: send_escape_seq("\x1b[F"); break; /* End */
case 0x53: send_escape_seq("\x1b[3~"); break; /* Delete */
case 0x52: send_escape_seq("\x1b[2~"); break; /* Insert */
case 0x49: send_escape_seq("\x1b[5~"); break; /* Page Up */
case 0x51: send_escape_seq("\x1b[6~"); break; /* Page Down */
}
}
这样 kilo 就能收到方向键、Home、End 这些按键了。
光标
我们的tty一直没有光标显示,在文本编辑上光标显示就显得尤为重要了,我们需要给 tty 加上光标显示。
实现思路很简单:在 terminal_write 输出字符前,先把光标位置的像素块擦除(画成黑色),输出字符后,再在下一个位置画一个白色方块作为光标。
// 写字符前擦除光标
if (terminal_col < terminal_cols && terminal_row < terminal_rows)
terminal_fill_rect(terminal_col * FONT_WIDTH, terminal_row * FONT_HEIGHT,
FONT_WIDTH, FONT_HEIGHT, 0x00000000);
// ...输出字符逻辑...
// 写字符后在新位置画光标
if (terminal_col < terminal_cols && terminal_row < terminal_rows)
terminal_fill_rect(terminal_col * FONT_WIDTH, terminal_row * FONT_HEIGHT,
FONT_WIDTH, FONT_HEIGHT, 0x00FFFFFF);
backspace 时也有对应的光标移动处理。
VT100 状态机
这是最核心的部分。在 terminal_write 里嵌入了一个 VT100/ANSI 转义序列解析器:
状态机设计
NORMAL → ESC (\x1b) → CSI ([) → 解析参数 → 执行命令
CSI_PRIV (?) → 解析参数 → 执行命令
const uint8_t NORMAL = 0;
const uint8_t ESC = 1;
const uint8_t CSI = 2;
const uint8_t CSI_PRIV = 3;
static uint8_t state = 0;
static char param[16];
static uint32_t params[4];
CSI 命令处理
光标定位 CSI H:kilo 用这个在屏幕任意位置绘制字符。
} else if (data[i] == 'H') {
param[param_len] = '\0';
if (param_len > 0) params[params_idx++] = atoi(param);
// one-based → zero-based
int row = (params_idx > 0 && params[0] > 0) ? params[0] - 1 : 0;
int col = (params_idx > 1 && params[1] > 0) ? params[1] - 1 : 0;
if (row >= (int)terminal_rows) row = terminal_rows - 1;
if (col >= (int)terminal_cols) col = terminal_cols - 1;
terminal_row = row;
terminal_col = col;
// 重置状态
}
清屏 CSI 2 J:kilo 退出时清屏用。
} else if (data[i] == 'J') {
int mode = param_len > 0 ? atoi(param) : 0;
if (mode == 2) {
terminal_fill_rect(0, 0, fb_width, fb_height, 0x00000000);
terminal_row = 0;
terminal_col = 0;
}
}
清除行尾 CSI K:kilo 刷新行时先清除当前行光标后的内容再重绘。
} else if (data[i] == 'K') {
terminal_fill_rect(terminal_col * FONT_WIDTH, terminal_row * FONT_HEIGHT,
fb_width - terminal_col * FONT_WIDTH, FONT_HEIGHT, 0x00000000);
}
SGR 参数 CSI m:设置字符属性(颜色、反显等)。第一个版本只实现了 reset 和反显的占位:
} else if (data[i] == 'm') {
int code = (params_idx > 0) ? params[0] : 0;
if (code == 0 || code == 39) terminal_color = 0x00FFFFFF;
else if (code == 7) { /* TODO: 反显 */ }
}
CSI_PRIV 模式
用于隐藏/显示光标,kilo 在编辑时会隐藏光标,退出时恢复。
if (state == CSI_PRIV) {
if (data[i] == 'l') { // 重置模式
if (params[params_idx % PARAMS_LENGTH] == 25) {
show_cursor = false;
// 擦除光标
terminal_fill_rect(terminal_col * FONT_WIDTH, terminal_row * FONT_HEIGHT,
FONT_WIDTH, FONT_HEIGHT, 0x00000000);
}
} else if (data[i] == 'h') { // 设置模式
if (params[params_idx % PARAMS_LENGTH] == 25) {
show_cursor = true;
// 绘制光标
terminal_fill_rect(terminal_col * FONT_WIDTH, terminal_row * FONT_HEIGHT,
FONT_WIDTH, FONT_HEIGHT, 0x00FFFFFF);
}
}
}
其他增强
额外修复了字符绘制——之前只画前景像素,不清背景,导致残影。现在每次都把背景像素清除:
void terminal_draw_char(int x, int y, const uint8_t* font_char, uint32_t color) {
for (int row = 0; row < FONT_HEIGHT; row++) {
for (int col = 0; col < FONT_WIDTH; col++) {
if (font_char[row] & (0x80 >> col)) {
terminal_putpixel(x + col, y + row, color);
} else {
terminal_putpixel(x + col, y + row, 0x00000000); // 清背景
}
}
}
}
另外 sys_open 修复了路径解析——现在会先调用 resolve_path 把相对路径转成绝对路径,之前是直接把未解析的路径传给 vfs。
同时 format.h 也做了增强,支持了 * 格式的宽度和精度,以及精度对整数和字符串的影响。
SGR 颜色高亮
实现了完整的 SGR 颜色支持,还给 tty 加上了 Tokyo Night 主题色。
前景色与背景色
之前绘制字符只传一个 color,现在改成了传 fg 和 bg:
void terminal_draw_char(int x, int y, const uint8_t* font_char, uint32_t fg, uint32_t bg) {
for (int row = 0; row < FONT_HEIGHT; row++) {
for (int col = 0; col < FONT_WIDTH; col++) {
if (font_char[row] & (0x80 >> col))
terminal_putpixel(x + col, y + row, fg);
else
terminal_putpixel(x + col, y + row, bg);
}
}
}
SGR 完整实现
for (int p = 0; p < params_idx; p++) {
switch (params[p]) {
case 0: // 重置
terminal_color = 0x00FFFFFF;
terminal_bg_color = 0x00000000;
terminal_reverse = false;
break;
case 7: // 反显
terminal_reverse = true;
break;
case 27: // 取消反显
terminal_reverse = false;
break;
// 标准前景色 (30-37)
case 30: terminal_color = 0x00414868; break; // 黑(深蓝灰)
case 31: terminal_color = 0x00F7768E; break; // 红
case 32: terminal_color = 0x009ECE6A; break; // 绿
case 33: terminal_color = 0x00E0AF68; break; // 黄
case 34: terminal_color = 0x007AA2F7; break; // 蓝
case 35: terminal_color = 0x00BB9AF7; break; // 洋红
case 36: terminal_color = 0x007DCFFF; break; // 青
case 37: terminal_color = 0x00A9B1D6; break; // 白
case 39: terminal_color = 0x00C0CAF5; break; // 默认前景
// 标准背景色 (40-49) ...
// 亮色前景 (90-97) ...
}
}
颜色方案采用了 Tokyo Night 风格的配色,效果还不错。
反显支持
反显模式下,fg 和 bg 互换:
uint32_t fg = terminal_reverse ? terminal_bg_color : terminal_color;
uint32_t bg = terminal_reverse ? terminal_color : terminal_bg_color;
所有的 fill_rect 操作也从硬编码的 0x00000000 改成了使用 bg 色,这样在有色背景的场景下不会出现黑色方块。
效果
经过这一系列改造,我们的操作系统终于可以运行 kilo 了!文本编辑、光标移动、屏幕刷新、颜色高亮——这些现代终端的基本能力都具备了。这些功能的补全,完全是由”我要让 kilo 跑起来”这个需求驱动的。虽然在光标移动之后编辑会有一些bug,但是不妨碍整体使用!

总结
这次移植“迫使”我们为这个操作系统补全了各色的功能——从头文件、libc 函数,再到进阶的tty能力:ioctl 系统调用框架、termios 配置管理、键盘驱动增强、光标渲染、VT100 转义序列解析,到 SGR 颜色支持。最终,一个实用的软件——kilo被成功移植到了我们的操作系统!我们可以自豪地宣布——kilo被我们的操作系统基本支持了!
下一节,我们来实现procfs和ps、netstat命令!

参与讨论
(Participate in the discussion)
参与讨论
没有发现评论
暂无评论