上一节,我们的Ext2文件系统之旅告一段落。干了许久的基础设施建设的事情,今天我们来做一件更有意思的事情——给我们的操作系统移植一个文本编辑器,并借此机会把tty能力狠狠地加强一波。

kilo 是 antirez 写的一个极简的文本编辑器,代码只有一千多行。虽然短小,但它用到了不少 POSIX 终端接口——termios、ioctl、VT100 转义序列等等。恰好这些是我们的操作系统还缺少的东西,正好拿它来驱动我们的开发。

补全缺少的头文件

我们直接从kilo的编译开始,kilo编译,我们缺少的东西便一览无遗。kilo 编译时发现缺少一堆头文件:

image-20260618191829981

我们来一个一个补全吧。

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_iflaginput,数据从键盘进来时怎么预处理(比如要不要把回车转成换行)
  • c_oflagoutput,数据往屏幕输出时怎么后处理(比如要不要在换行前自动加回车)
  • c_cflagcontrol,硬件控制参数(波特率、数据位数,串口时代的遗产)
  • c_lflaglocal,本地终端行为(要不要回显、要不要行缓冲、要不要响应 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;
}

这里有个细节——TCSETSWTCSETSF 的区别在于前者等到当前输出完成后再应用设置,后者还会丢弃未读取的输入。我偷了个懒,用 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,但是不妨碍整体使用!

image-20260618192543306

总结

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

下一节,我们来实现procfs和ps、netstat命令!