前几章的用户程序如果想读文件,得直接传 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_read、fd_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_open → vfs_open → ext2_lookup → ata_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 打印,close,exit——整套流程跑通。
小结
fd 的价值不在于它本身有多复杂,而在于它建立了一个统一的抽象:文件、管道、设备,对用户程序来说都是一个整数。下一章在这个基础上实现 dup 和 pipe——管道的本质就是一对 fd,一端写、一端读。
| 概念 | 实现位置 | 要点 |
|---|---|---|
| fd | process_t.fd_table[] | 进程内整数,指向 vfs_fd |
| offset | VFS VFile.offset | 内核自动推进 |
| syscall 栈 | syscall_entry.asm | 必须切到 tss.rsp0 |
| 磁盘读保护 | SYS_READ | cli/sti + kbuf 中转 |