从零编写操作系统 22 - 文件描述符:把名字、游标与资源寿命分开
第 21 篇已经能在 FAT16 根目录中查找文件,并按指定 offset 读取字节。这个接口仍然不适合直接交给用户程序:调用者每次都要提供文件元数据和位置,内核也无法回答某个进程打开了哪些资源、两个调用是否应当共享读取位置,以及进程异常退出后由谁释放资源。
本篇增加两级句柄结构。每个进程拥有 8 个文件描述符槽,槽中保存对全局 open-file 对象的引用;open-file 对象保存类型、读写能力、引用计数、文件 offset 和 FAT 元数据。FAT 文件与物理终端由同一组 open/read/fd_write/close 系统调用访问,但各自拒绝不支持的操作。
实验不只验证 README.TXT 能读出来。两次独立打开必须拥有不同 offset,父子进程显式映射同一个对象时必须共享 offset;正常退出和页故障退出都要在成为 ZOMBIE 前关闭 fd。另一个镜像连续制造六次 ELF 分配失败和一次内核栈失败,检查暂存引用是否全部回滚。终端实验则通过 QEMU HMP 注入真实 PS/2 按键,确认用户进程在 int 0x80 内阻塞,PIT 和 idle 仍能继续运行。
一个整数不能保存全部文件状态
文件描述符只是进程内的小整数。POSIX 对 open file description 的定义把另一组状态放在整数背后的对象里:当前 offset、访问模式和文件状态属于该对象;一个对象可以被多个 fd 引用。每次 open() 创建新对象,dup() 或进程继承则让多个 fd 指向同一对象。
本项目采用同样的分层,但不宣称实现 POSIX:
1 | |
两张表承担不同责任。fd 表决定“当前进程用哪个整数访问对象”,所以关闭 fd 只清当前进程的槽;open-file 表决定“这次打开的状态是什么”,所以共享它的父子进程看到同一个 offset。FAT 目录项只描述磁盘上的文件,不保存任何进程的读取位置。
全局表只有 7 个对象不是硬件限制。这个值刻意小于单进程的 8 个 fd 槽,专项实验可以分别触发两种耗尽:fd 表满返回 EMFILE;清出一个 fd 槽但仍让 7 个对象存活,再次打开返回 ENFILE。把两类错误分开后,诊断才能指出耗尽的是进程命名空间还是系统对象池。
可迁移的设计规则是把“名字”和“被命名对象”分开。名字属于使用者,对象保存共享状态,引用计数只管理对象寿命。页表映射、socket 句柄和窗口句柄都会遇到相同的问题。
open 建立一条所有权链
SYS_OPEN=6 接收用户路径和 flags。当前 FAT 只支持 ASCII 8.3 文件名及只读访问,因此非零 flags 返回 EINVAL,路径超过 15 字节返回 ENAMETOOLONG。路径逐字节通过 copy_from_user 复制,内核不会直接解引用 CPL3 地址。
1 | |
顺序不能颠倒。先确认本进程还有 fd 槽,可避免明知无法安装时仍访问磁盘;FAT 查找成功后才分配 open-file;若安装 fd 失败,调用者持有的初始引用必须立即归还。task_fd_install_current() 还断言对象非空,避免把写入空槽误报成一次成功安装。
成功时使用最低空闲 fd。根演示进程启动后,0 是终端输入,1 和 2 指向同一个终端输出对象;第一次打开文件因此返回 3。关闭中间槽后再次打开会复用该整数,但原对象只在最后一个引用消失时释放。
当前 SYS_WRITE=1(buffer,length) 暂时保留,用于复跑第 17、19 篇的旧用户程序;新的描述符接口使用 SYS_FD_WRITE=8(fd,buffer,length)。这两个调用号并存是迁移期设计,不是 Linux i386 ABI。
用户地址在后端产生副作用前完成验证
read(fd, buffer, count) 要写用户内存,因此逐页检查目标页存在且带用户可写权限;fd_write 只读取用户内存,代码页虽然只读,仍可作为合法源缓冲。两条路径都先检查 address + count 是否溢出、每页是否属于用户空间,以及 count 是否超过单次 256 字节上限。
1 | |
读取先进入有界内核缓冲,再一次性 copyout。坏目标地址不会提前消费终端字符,也不会推进文件 offset。写终端时同样先把完整源范围复制进内核;若第二页缺失,屏幕和串口不会留下第一页面的输出前缀。
零长度调用沿用第 17 篇的契约:有效 fd 返回 0,不检查未使用的地址;坏 fd 仍先返回 EBADF。专项镜像覆盖了低地址、只读目标页、跨入未映射页、32 位长度溢出和超过 256 字节上限。五个用户地址错误都返回到调用者,没有把可恢复的参数错误升级为页故障。
这类接口应把验证放在第一个不可逆副作用之前。对文件是 offset 提交,对终端是消费输入或产生输出;后续的 pipe 则还包括改变缓冲区读写位置和唤醒对端。
短读、EOF 和 offset 提交
FAT 后端调用第 21 篇留下的 fat_read_at(file, offset, buffer, length)。只有返回正数后,open-file 的 offset 才增加实际字节数;返回 0 表示已经到达 EOF,负数表示错误。一次调用少于请求长度是合法短读,不能被用户程序直接当成失败。
测试文件是 1479 字节。用户程序每次请求 128 字节,前 11 次得到完整块,最后一次得到 71 字节,再下一次得到 0。所有字节的 FNV-1a 为 f34dc752。这个读取仍然沿着第 21 篇构造的 2 -> 9 -> 4 非连续簇链完成。
独立与共享 offset 用两组实验区分。两次 open("README.TXT") 后,A 先读 64 字节,B 再读 64 字节,两段内容相同;A 再读 256 字节,只推进 A 自己的对象。共享实验中,父进程先读 37 字节,子进程从映射到 fd 0 的同一对象读 19 字节,父进程下一字节来自 offset 56。
FAT 模块有全局卷对象和工作缓冲,不可重入。当前系统为单 CPU,文件 syscall 处于 trap_depth==1,时钟中断只记录延期调度;file_read() 还在 FAT 调用期间禁止抢占,因此共享 offset 的“读取并提交”不会被另一任务插入。这个条件如果改变,例如磁盘等待开始主动睡眠,open-file 和 FAT 工作区就需要可睡眠的串行化协议,不能继续依赖当前执行模型。
引用从 spawn 转移到 exit
本项目没有 fork 和 dup。spawn(name, io_map) 用一个项目专用的 32 位值显式映射子进程的 0、1、2:高位 magic 为 0xd2200000,三个低半字节分别保存来源 fd 加一,0 表示目标槽为空。
映射不是把 offset 复制一份。内核先为三个来源对象逐项 file_get(),把引用暂存在局部数组,再装载 ELF、建立用户页和分配内核栈。任务槽成功发布后,数组中的引用转移给子进程;任一步失败,mapped_put() 释放所有已经取得的引用。
1 | |
失败镜像把分配失败点从第一个 ELF 页推进到内核栈,连续七次 spawn 都返回 ENOMEM。随后关闭失败注入,正常 spawn、read 和 wait 成功。GDB 记录的 fd_spawn_rollbacks=7,文件对象分配/释放数和空闲页均回到初值。
进程退出时,资源释放顺序是另一处关键边界:
1 | |
故意越界的子进程在页故障后也进入同一 process_publish_exit()。断点命中时,它的 8 个 fd 槽已经全空,用户地址空间和内核栈仍然存在,任务尚未发布为 ZOMBIE。wait 路径只断言 fd 已清空,不做第二次 file_put()。这个顺序将在管道篇决定 EOF 能否到达:退出进程若等到父进程 wait 才关闭写端,读者可能永久等待。
终端读会在系统调用中阻塞
终端输出对象同步调用 console_putc(),终端输入对象则复用第 09 篇的 IRQ1 原始扫描码队列和 set-1 解析器。IRQ handler 只读取端口、入队并唤醒等待者;keyboard_read_char() 是唯一消费者,旧 keyboard_loop() 不再与用户进程竞争同一队列。
队列为空时,reader 已经从用户态通过 int 0x80 进入内核,trap_depth 为 1。旧的普通任务阻塞路径只接受深度 0,本篇让调度器显式保存当前允许深度:任务以 BLOCKED 状态切走,输入到达后再回到原内核栈,最终从同一次 syscall 返回用户态。
测试通过 GDB 观察到:
1 | |
暂停 0.2 秒后 PIT tick 增长,reader 没有重复轮询,CPU 落在 IF=1 的 idle hlt。随后 QEMU HMP 依次执行 sendkey a、sendkey b 和 sendkey ret,用户程序三次短读得到 ab\n。
当前终端是逐字符输入,不是 canonical 终端。没有完整行缓冲、Ctrl-D、termios、作业控制或多个 reader 的公平性;“暂时没有按键”只会阻塞,不会被误报成 EOF。第 23 篇的 Shell 会在用户态组合这些字符。
三个镜像留下的证据
正常镜像的串口末尾是:
1 | |
正常串口记录与正常 GDB 记录还检查了 file_peak_live=7、file_allocations=file_releases=16、14 次 exit 关闭和空闲页恢复。失败镜像的串口与GDB 记录保存了七个失败点及恢复结果。终端实验的阻塞现场、idle 现场、完成记录和串口输出记录了 HMP 输入链路。
专项检查后又在主工作区复跑 FAT、块设备、进程、系统调用、ELF、内存、容量和键盘检查,八项目标均退出 0,完整输出见累计回归记录。它们分别守住非连续/损坏簇链、ATA 错误、进程回收、用户复制、恶意 ELF、页分配、loader 上限和 PS/2 旧行为。
当前边界
本篇只支持固定根目录中的 FAT16 8.3 文件;没有目录 fd、目录枚举、seek、写文件和当前工作目录。第 30 篇实现 ls 前仍需定义定宽目录项 ABI,不能把内核结构体直接复制给用户。
损坏 FAT 和非连续簇链由第 21 篇的 check-fat 在同一累计代码上动态回归。本篇静态确认 offset 只在 fat_read_at() 成功后提交,但没有把损坏卷再经过 SYS_READ 跑一遍,因此不把这条边界记成 Day 22 专项动态覆盖。
实验运行在 QEMU 8.2.2、SeaBIOS 1.16.3、pc-i440fx-7.2、TCG、qemu32、单 CPU和 64 MiB 内存。HMP sendkey 验证了模拟 i8042/PS/2 路径,不代表真实键盘或其他虚拟机实现。
第 23 篇会让 Shell 和 cat/echo 只依赖 fd 接口,并把 ELF 从内核内嵌数组迁到 FAT16 文件。那时必须重新核验磁盘 ELF 的大小、恶意头、装载失败回滚和 Shell 的错误恢复。
下载与复现
下载第 22 篇完整源码。附件 SHA-256:
1 | |
解压后执行:
1 | |
附件包含第 01–22 篇累计工程和打包前已经形成的 docs/day22-* 证据,不含任何 build/ 产物。它在新的空目录中从零重建,随后运行本篇专项和八项累计回归,全部退出 0;重建的 kernel.bin、mbr.img 与 day22-user.elf 也和主工作区逐字节相同。完整输出见独立附件复验记录,这份记录验证的是已经冻结的 ZIP,因此不反向放进 ZIP 自身。
练习
- 给同一进程增加
dup(oldfd),验证两个 fd 共享 offset,并证明关闭其中一个不会释放对象。 - 把
file_open()调整为先分配 open-file 再查 FAT,故意让 fd 表满,观察错误优先级和无意义磁盘访问如何变化。 - 设计 canonical 终端输入:明确退格、行缓冲上限、Ctrl-D、多个 reader 和窗口关闭时的唤醒规则,再决定这些状态属于终端对象还是 Shell。
上一篇:21 - 只读 FAT16。
下一篇:23 - 用户态 Shell。
参考资料
- POSIX.1-2024 Definitions:fd、file offset 和 open file description 的定义。
- POSIX.1-2024
open()、dup()、read()、write()、close()与_Exit():用于核对对象、偏移、短读、最后关闭和退出关闭语义。 - POSIX.1-2024 §2.9.7 Thread Interactions with File Operations:用于检查共享 open-file 操作的原子效果。
- Linux man-pages 6.19:
open(2)、dup(2)、read(2)与close(2),用于独立交叉核对对象和 offset 语义。 - xv6-riscv
file.c、sysfile.c与proc.c:固定提交9e3161a9abf5f51ea402562d1874caf6c4926597,仅作教学实现对照,不用于证明 x86 trap 或本项目 ABI。

