从零写 OS 内核-第十二篇:VFS 中的 stdin/stdout/stderr —— 让用户程序"会说话"!

"你的 OS 能运行用户程序,但它们如何与外界交互?
今天,我们把串口、VGA 显卡包装成标准文件,让 printf 真正工作!"

在前几篇中,我们实现了 ext2 文件系统、IDE 驱动,用户程序能从磁盘加载并运行。
但你是否注意到:用户程序仍无法输出信息
它们调用 write(1, "Hello", 5),但内核不知道"1"代表什么。

真正的操作系统,必须为每个进程提供标准输入(stdin)、标准输出(stdout)、标准错误(stderr)
并让它们像普通文件一样工作——这正是 Unix "一切皆文件"哲学的精髓。

今天,我们就来在 VFS 中实现这三个特殊文件,
让你的用户程序能通过 printfputs 等标准库函数(或直接系统调用)与世界对话!


🌐 一、标准流的本质:特殊的"文件"

在 Unix 中:

  • stdin = 文件描述符 0
  • stdout = 文件描述符 1
  • stderr = 文件描述符 2

它们不是磁盘文件,而是内核提供的抽象接口,背后可以是:

  • 串口(Serial Port)
  • VGA 文本缓冲区
  • 网络 socket
  • 管道(Pipe)
  • 伪终端(PTY)

💡 关键思想:通过 VFS 的 file_operations 抽象,将硬件输出"伪装"成文件!


🧱 二、VFS 中的设备文件设计

我们需要一种机制,将 文件路径(如 /dev/tty)映射到设备驱动

方案:设备 inode + 设备号

// 设备类型
#define S_IFCHR 0x2000  // 字符设备
#define S_IFBLK 0x6000  // 块设备

// 设备号(主设备号 << 8 | 次设备号)
#define MKDEV(major, minor) (((major) << 8) | (minor))
#define MAJOR(dev) ((dev) >> 8)
#define MINOR(dev) ((dev) & 0xFF)

struct inode {
    uint16_t i_mode;
    uint32_t i_rdev;    // 设备号(仅设备文件有效)
    // ...
};

预定义设备:

| 路径 | 主设备 | 次设备 | 说明 | |——|——–|——–|——| | /dev/tty | 1 | 0 | 控制台(stdout/stderr)| | /dev/console | 1 | 1 | 系统控制台 | | /dev/null | 1 | 3 | 黑洞设备 |

标准流不对应具体路径,而是进程创建时预打开的文件描述符


⚙️ 三、实现控制台设备(Console)

控制台设备将输出重定向到串口 + VGA

1. 控制台文件操作

static ssize_t console_write(struct file *file, const char *buf, size_t count) {
    // 同时输出到串口和 VGA
    for (size_t i = 0; i < count; i++) {
        uart_putc(buf[i]);      // 串口
        vga_putc(buf[i]);       // VGA 显存
    }
    return count;
}

static const struct file_operations console_fops = {
    .write = console_write,
    .read = NULL, // 控制台不可读
};

2. 创建控制台 inode

struct inode *create_console_inode() {
    struct inode *inode = alloc_inode();
    inode->i_mode = S_IFCHR | 0666; // 字符设备,可读可写
    inode->i_rdev = MKDEV(1, 0);    // 主设备 1,次设备 0
    return inode;
}

🖥️ 四、进程初始化:预打开标准流

每个新进程创建时(fork/exec),内核自动打开三个文件描述符:

void setup_std_fds(task_t *task) {
    // 0: stdin → 通常指向控制台(可读)
    task->fd_table[0] = alloc_file();
    task->fd_table[0]->f_inode = create_console_inode();
    task->fd_table[0]->f_op = &console_fops;

    // 1: stdout → 控制台
    task->fd_table[1] = alloc_file();
    task->fd_table[1]->f_inode = create_console_inode();
    task->fd_table[1]->f_op = &console_fops;

    // 2: stderr → 控制台
    task->fd_table[2] = alloc_file();
    task->fd_table[2]->f_inode = create_console_inode();
    task->fd_table[2]->f_op = &console_fops;
}

🔑 关键fork 时子进程复制父进程的 fd_table,所以子进程继承标准流!


📞 五、系统调用对接:write(fd, buf, count)

现在,sys_write 可以处理任意文件描述符:

int sys_write(int fd, const void *buf, size_t count) {
    task_t *task = current_task;
    if (fd < 0 || fd >= MAX_FDS || !task->fd_table[fd]) {
        return -1;
    }

    struct file *file = task->fd_table[fd];
    
    // 安全验证:用户指针
    if (!validate_user_ptr(buf, count)) {
        return -1;
    }

    // 调用具体文件操作
    if (file->f_op && file->f_op->write) {
        return file->f_op->write(file, buf, count);
    }
    return -1;
}

当用户调用 write(1, "Hello", 5),内核会调用 console_write


🧪 六、测试:用户程序中的 printf

user.c

void _start() {
    // 直接系统调用
    char msg1[] = "Hello from stdout!\n";
    asm volatile ("int $0x80" : : "a"(4), "b"(1), "c"(msg1), "d"(18));

    // 通过 C 库封装(如果你实现了 simple libc)
    // printf("Hello from printf!\n");

    // stderr
    char msg2[] = "This is stderr!\n";
    asm volatile ("int $0x80" : : "a"(4), "b"(2), "c"(msg2), "d"(16));

    // stdin(简单回显)
    char input[32];
    asm volatile ("int $0x80" : : "a"(3), "b"(0), "c"(input), "d"(32));
    // 再输出
    asm volatile ("int $0x80" : : "a"(4), "b"(1), "c"(input), "d"(32));
}

运行效果(QEMU 串口输出):

Hello from stdout!
This is stderr!
<你输入的字符>

标准流工作正常!


🛠️ 七、扩展:实现 /dev 目录与设备文件

虽然标准流是预打开的,但我们也希望用户能通过路径访问设备:

1. 在 ext2 根目录创建设备节点

# 在制作磁盘镜像时
mknod /dev/tty c 1 0
mknod /dev/console c 1 1
mknod /dev/null c 1 3

2. VFS 路径解析支持设备 inode

open("/dev/tty", O_RDWR) 时:

  • ext2 返回一个 i_mode 包含 S_IFCHR 的 inode
  • sys_open 创建 file 时,根据 i_rdev 选择设备驱动

💡 这样,cat /dev/tty 也能工作!


🚫 八、特殊设备:/dev/null

实现一个"黑洞"设备,写入丢弃,读取返回 EOF:

static ssize_t null_write(struct file *file, const char *buf, size_t count) {
    return count; // 丢弃所有数据
}

static ssize_t null_read(struct file *file, char *buf, size_t count) {
    return 0; // EOF
}

static const struct file_operations null_fops = {
    .read = null_read,
    .write = null_write,
};

/dev/null 是 Unix 中重定向错误输出的标准方式


⚠️ 九、安全与边界

  1. stdin 读取
    • 当前简单实现为阻塞读取串口
    • 后续需支持缓冲、行编辑、Ctrl+C 信号
  2. 多进程输出交错
    • 多个进程同时写 stdout 会导致混杂
    • 解决方案:在 console_write 中加自旋锁
  3. 权限控制
    • 标准流应只对当前会话进程可访问
    • 后续通过进程组/会话管理实现

💬 写在最后

stdin/stdout/stderr 看似简单,
却是 Unix I/O 模型的灵魂所在
它们让程序无需关心输出目的地,
只需专注于数据处理。

今天你实现的这三个文件描述符,
正是 Linux 中 /proc/self/fd/{0,1,2} 的源头。

🌟 一切皆文件,连你的键盘和屏幕也不例外。


📬 动手挑战
编写一个用户程序,将 stderr 重定向到 /dev/null(通过关闭 fd 2 并重新打开 /dev/null)。
欢迎在评论区分享你的重定向技巧!

👇 下一篇你想看:管道(Pipe)实现,还是 伪终端(PTY)与 shell 交互


#操作系统 #内核开发 #标准流 #VFS #stdin #stdout #stderr #设备文件 #从零开始


📢 彩蛋:关注后回复关键词 "console",获取:

  • 完整控制台设备驱动代码
  • 标准流初始化与 fork 复制逻辑
  • 用户态 printf 封装示例(simple libc)