前几章的用户程序如果想读文件,得直接传 inode 号给内核——read(ino, offset, buf, len)。这意味着用户程序要自己记当前读到哪了,而且根本不知道文件名,只有一个数字。

这一章引入 文件描述符(fd),让用户程序用熟悉的方式操作文件:

int fd = open("hello.txt");
int n  = read(fd, buf, 64);
close(fd);

fd 是什么

fd 是一个进程内的整数,通常从 0 开始分配(0=stdin,1=stdout,2=stderr,之后是普通文件)。

它背后有三层:

进程 fd_table[]          内核 open_file             磁盘
  fd=3  ──────────→   { ino=42, offset=0 }  ──→  hello.txt
  fd=4  ──────────→   { ino=43, offset=0 }  ──→  readme.txt

fd 本身只是下标,真正存 offset 的是内核里的"打开文件"结构。这样的好处:

  • offset 由内核自动推进,用户不用管
  • fork 后父子可以共享同一个打开文件(共享 offset)
  • 文件、管道、设备用同一套接口,背后实现不同

实现:每个进程一张 fd 表

process_t 里加一个数组:

typedef struct {
    // ...
    int32_t fd_table[PROC_MAX_FD];  // fd → vfs_fd,-1 表示空槽
} process_t;

fd_table[fd] 存的是 VFS 层的内部 fd(VFile 数组下标)。

fd_open 的流程:

int32_t fd_open(int32_t fd_table[], const char *path) {
    int vfs_fd = vfs_open(path);       // VFS 打开文件,拿到 vfs_fd
    for (int i = 0; i < PROC_MAX_FD; i++) {
        if (fd_table[i] == -1) {
            fd_table[i] = vfs_fd;      // 找第一个空槽
            return i;                  // 返回进程 fd
        }
    }
    return -1;
}

fd_readfd_close 类似——通过 fd_table[fd] 找到 vfs_fd,委托给 VFS 层处理,offset 推进也在 VFS 层完成。

fork 时 fd_copy_table 把父进程的 fd_table 复制给子进程,两者指向同一批 VFile,天然共享 offset。


新增三个 syscall

参数
5 SYS_OPEN a1=路径字符串
6 SYS_READ a1=fd, a2=buf地址, a3=len
7 SYS_CLOSE a1=fd

用户程序:

mov rax, 5              ; SYS_OPEN
lea rdi, [rel filename] ; a1 = "hello.txt"
syscall
mov [rel fd_val], rax   ; 保存 fd

mov rax, 6              ; SYS_READ
mov rdi, [rel fd_val]   ; a1 = fd
lea rsi, [rel buf]      ; a2 = 读到哪
mov rdx, 64             ; a3 = 读多少
syscall

两个隐蔽的 bug

bug1:syscall 用的是用户栈

syscall 指令进内核时不切换栈,rsp 还是用户栈。之前的 syscall(write、exit)只做简单操作,用户栈够用。

fd_openvfs_openext2_lookupata_read_sector,这条调用链很深,用户栈只有 4KB,直接溢出——没有任何报错,CPU triple fault 重启。

修法:在 syscall_entry.asm 里,进来就切换到内核栈:

mov r10, rsp              ; 暂存用户 rsp
lea rsp, [rel tss]
mov rsp, [rsp + 4]        ; 切到 tss.rsp0(内核栈顶)

; 在内核栈上保存寄存器,call syscall_handler
...
pop rsp                   ; 恢复用户 rsp
o64 sysret

tss.rsp0 之前只在硬件中断从 ring3 进 ring0 时被 CPU 自动用,这里 syscall 需要手动读它

bug2:读磁盘期间可能被调度器切走

SYS_READ 把用户传来的 buf 地址直接传给 ext2_read,后者用 ATA PIO 轮询等磁盘,轮询期间 PIT timer 中断触发,调度器切换到另一个进程,CR3 换掉——磁盘数据写到了错误的物理页,Page Fault。

修法:先读到内核 kbuf,再 memcpy 到用户地址;读磁盘期间加 cli/sti 防止被切走:

static uint8_t kbuf[512];
__asm__ volatile("cli");
int32_t n = fd_read(current->fd_table, fd, kbuf, len);
__asm__ volatile("sti");
if (n > 0) copy_to_user(buf, kbuf, n);

验证

[sys] open path=hello.txt
Hello from ext2!
[exit] pid=0x1 code=0x0

用户程序 open 拿到 fd,read 读出文件内容,write 打印,closeexit——整套流程跑通。


小结

fd 的价值不在于它本身有多复杂,而在于它建立了一个统一的抽象:文件、管道、设备,对用户程序来说都是一个整数。下一章在这个基础上实现 duppipe——管道的本质就是一对 fd,一端写、一端读。

概念 实现位置 要点
fd process_t.fd_table[] 进程内整数,指向 vfs_fd
offset VFS VFile.offset 内核自动推进
syscall 栈 syscall_entry.asm 必须切到 tss.rsp0
磁盘读保护 SYS_READ cli/sti + kbuf 中转