Linux 里有个特殊目录 /dev/,里面住着各种设备文件:

/dev/null     ← 写什么扔什么,读永远 EOF
/dev/zero     ← 读出来全是 \0
/dev/random   ← 读出来是随机字节
/dev/tty      ← 当前终端

对用户程序来说,它们和普通文件没区别:openreadwriteclose,完全一样的接口。

这一章实现字符设备(char device)框架,让 VFS 能路由 /dev/ 路径,并内置 nullzero 两个最基础的设备。


什么是字符设备

字符设备(character device)的特点:

  • 按字节读写,没有块/扇区的概念
  • 没有随机寻址(不像磁盘文件可以 seek)
  • 读写是即时的:写进去就消失(null)或立刻可读(zero/tty)

与之对应的是块设备(block device),如硬盘,以固定大小的块为单位操作。


设备注册表

核心数据结构 cdev_t

typedef struct {
    char    name[16];              // 设备名,如 "null"、"zero"
    int     (*open) (void);
    int     (*read) (void *buf, uint32_t len);
    int     (*write)(const void *buf, uint32_t len);
    void    (*close)(int fd);
    int     used;
} cdev_t;

static cdev_t devs[CDEV_MAX];     // 全局设备表

通过函数指针实现多态——不同设备注册不同的 read/write 实现。

chardev_registerdevs[] 添加一个设备,chardev_open 按名字查找并调用 open()


/dev/null

static int null_read(void *buf, uint32_t len) {
    (void)buf; (void)len;
    return 0;    // EOF
}
static int null_write(const void *buf, uint32_t len) {
    (void)buf;
    return (int)len;   // 假装全写进去了,其实丢掉
}

Unix 里用 >/dev/null 重定向就是这个效果——写操作不报错,数据直接消失。


/dev/zero

static int zero_read(void *buf, uint32_t len) {
    uint8_t *p = (uint8_t *)buf;
    for (uint32_t i = 0; i < len; i++) p[i] = 0;
    return (int)len;
}

常见用途:dd if=/dev/zero of=file bs=1024 count=1024 快速创建一个 1MB 的全零文件。


VFS 集成

在 VFS 层加入第四种文件类型 VFILE_CDEV = 4,并给 VFile 结构加上 cdev_fd 字段:

typedef struct {
    ...
    int  type;
    int  pipe_idx;
    int  proc_fd;
    int  cdev_fd;    // 新增
} VFile;

vfs_open 检测路径前缀:

int vfs_open(const char *path) {
    if (str_startswith(path, "/proc/"))
        return vfs_open_proc(path + 5);
    if (str_startswith(path, "/dev/"))
        return vfs_open_cdev(path + 5);  // 跳过 "/dev/"
    ...
}

vfs_read/write/close 按 type 分发:

if (f->type == VFILE_CDEV)
    return chardev_read(f->cdev_fd, buf, len);

修复 fd=0 的二义性

之前 SYS_READ(fd=0) 总是走 TTY,但现在进程可能会 open("/dev/null") 并拿到 fd=0(进程 fd_table 里第一个空位就是 0)。

修复:只有当 fd_table[0] < 0(进程没有显式打开 fd 0)时才走 TTY:

if (rfd == 0 && current->fd_table[0] < 0) {
    rn = tty_read(kbuf, len);
} else {
    rn = fd_read(current->fd_table, rfd, kbuf, len);
}

踩坑:boot loader 扇区数不够

加了 chardev 之后,kernel.bin 从 24352 字节增长到 25728 字节(多了 1376 字节 ≈ 2.7 扇区)。

boot.asm 原来只读 50 个扇区(25600 字节),现在少读了最后 128 字节。偏偏这 128 字节里包含了 GDT 表!

表现:内核每次启动打印 “Kernel loaded!” 后立刻 triple fault,无限重启。

根因gdt_init() 执行 lgdt 指令,加载的 GDT 基地址指向一片未被加载的内存(恰好是零),CPU 读到无效段描述符,触发保护异常,继而 double fault,最终 triple fault,机器重启。

修复:把 boot.asm 里的扇区数从 50 改为 60,给内核留足空间。

mov al, 60    ; 读 60 个扇区(30KB),足够未来一段时间

这类问题很隐蔽——代码逻辑完全正确,二进制文件本身也对,只是没被完整加载进内存。调试时要从"数据是否被正确加载"这个角度排查。


测试

; open /dev/null,写入数据
mov rax, SYS_OPEN
lea rdi, [rel dev_null]   ; "/dev/null"
syscall
; 返回 fd=0(进程第一个空位)

; SYS_FWRITE(fd=0, "discard me", 10)
; → vfs_write → chardev_write → null_write → 静默丢弃

; open /dev/zero,读 8 字节
mov rax, SYS_OPEN
lea rdi, [rel dev_zero]   ; "/dev/zero"
syscall

; SYS_READ(fd, buf, 8)
; → vfs_read → chardev_read → zero_read → 全零

运行结果:

chardev test start
write /dev/null: ok
read /dev/zero: ok (got zeros)

总结

新增内容 作用
chardev.h/c 设备注册表,函数指针多态
/dev/null 写丢弃,读 EOF
/dev/zero 读全零
VFILE_CDEV VFS 新文件类型
vfs_open_cdev /dev/ 前缀路由
cdev_fd in VFile VFS 层到 chardev 层的索引

字符设备框架建立后,添加新设备只需要实现 4 个函数(open/read/write/close)并调用 chardev_register,完全不需要修改 VFS 或系统调用层。