Hang Zhengyang

文件描述符与文件

实践:util lab 的 sixfive、memdump(fd 0/管道)、find(读目录)、sh 重定向。

前置:进程。

文件描述符(fd)

一切皆 fd

  • 文件、终端、管道对用户来说都是一个小整数,都用 read/write 操作。0/1/2 只是约定(stdin/stdout/stderr),内核并不区分。
  • 在 util lab 里的体现:echo abc | memdump S 里,memdump 只是读 fd 0,不知道也不关心数据来自管道。sixfive 没参数时读 fd 0,有参数时读 open 返回的 fd,处理代码是同一个函数。
  • 好处:程序只依赖"字节流"这个接口,所以小工具能用管道任意组合。
  • 后面在哪实现:fs lab(文件、inode),kernel/file.c、kernel/pipe.c。

fd 到底是什么

一句话:fd 是进程手里的一个"号码牌",是内核里一张表的下标;真正的"打开的文件"对象在内核里,程序永远拿不到它本身。

三层结构:

 进程 A                            内核全局                         实际对象
 struct proc                       ftable.file[NFILE=100]
 ofile[NOFILE=16]                  (kernel/file.c:19)
 (kernel/proc.h:101)
 ┌───┐                            ┌─────────────────────────┐
 │ 0 │──────────────────────────▶│ type=FD_DEVICE (console)│──▶ 控制台驱动
 │ 1 │──────────────────────────▶│ ref=3 (0/1/2 共享一个)   │
 │ 2 │──────────────────────────▶└─────────────────────────┘
 │ 3 │──────────────────────────▶┌─────────────────────────┐
 │...│                            │ type=FD_INODE, off=1024 │──▶ inode(磁盘上的 README)
 └───┘                            │ readable=1, ref=1       │
                                  └─────────────────────────┘
  • fd(用户态):就是一个 int,ofile[] 数组的下标。open 返回的 3 只是说"表里第 3 格",它本身不含任何信息。拿着 3 去问另一个进程,在那边指的是完全不同的东西,或者什么都不是。
  • struct file(内核):真正的"打开的文件"(kernel/file.h),记录类型(管道 / 普通文件 / 设备)、读写位置 off、能读还是能写、引用计数 ref。
  • 底层对象:inode(磁盘文件)、pipe(内核里的环形缓冲区)、设备(devsw[] 里的驱动函数)。

一次 read(3, buf, 512) 发生了什么: sys_read → argfd 检查 0 <= 3 < NOFILE、ofile[3] != 0,取出 struct file *(sysfile.c:22-35)→ fileread 按 type 分派:管道走 piperead,设备走 devsw[major].read(控制台),普通文件走 readi 并把 off 往后推(file.c:114-122)。

所以"fd 是被程序使用的东西吗?":是程序在用,但它只是个句柄。程序能做的只有"拿着号码请内核办事"。这样设计的好处:

  1. 安全:程序没法伪造或篡改"打开的文件",只能传一个整数。内核会检查下标合法、格子非空,越界或空格子直接返回 -1。权限(readable/writable)也存在内核那一侧。
  2. 统一:管道、文件、控制台在用户看来一模一样,差异全藏在 fileread 的分派里。这就是 memdump 不知道数据来自管道的原因。
  3. 可以继承、可以共享:
    • fork 时子进程复制父进程的 ofile[] 表,但指向同一批 struct file(filedup 只把 ref 加一,proc.c:286-287)。所以父子进程共享读写位置:父进程写了 10 字节,子进程接着从第 10 字节往后写。
    • exec 不动 ofile[],所以 fork 之后改好 fd 0/1 再 exec,新程序就自动用上了重定向。这就是 shell 实现 > 和 | 的全部秘密。
    • close(fd) 只是清空自己表里那一格并把 ref 减一,减到 0 才真正释放 struct file。管道的读端要等所有写端 fd 都关掉才能读到 EOF,所以 sh.c 里那些 close(p[0]); close(p[1]) 一个都不能少。
  4. "最小可用编号"规则:fdalloc 从 0 开始找第一个空格子(sysfile.c:40-52)。所以 close(1); open("out", ...) 一定拿到 1,这就是重定向的实现方法。
  5. 0/1/2 只是约定:内核并不知道 0 是"标准输入"。是 init 在启动时 open("console") 得到 0,再 dup 两次得到 1、2(user/init.c:19-24),sh 和它 fork 出来的所有进程都继承了这三个。

上限:每个进程最多 NOFILE = 16 个 fd,整个系统最多 NFILE = 100 个 struct file。find 递归太深会打不开目录,就是撞上了前一个上限。

完整例子:一条命令的一生。

目录与文件名

  • 目录的内容就是一串定长的 struct dirent { inum, name[14] },所以 find 用普通的 read 就能遍历它。
  • 名字只存在于目录项里;文件本体是 inode(stat 返回的 ino、type、size 都是 inode 的属性)。所以同一个 inode 可以有多个名字(ln),rm 删掉的只是一个目录项。inum == 0 的空槽就是被删掉的目录项。
  • . 和 .. 也是普通的目录项,分别指向自己和父目录,所以目录树里其实有环。这就是 find 必须跳过它们的根本原因。
  • 后面在哪实现:fs lab,kernel/fs.c 的 dirlookup / dirlink。

一条命令的一生:sh 里跑 sixfive sixfive.txt > out

用一条命令把 fork 与 exec、程序怎么跑起来、fd 串起来。选带重定向的写法,是因为它能同时展示"内存被替换"和"fd 被保留"。实测输出:cat out 得到 5 100 18 6。

每一步都跟踪两样东西:这个进程的内存里是什么程序、它的 fd 表长什么样。

第 0 步:sh 在等你输入

进程 sh (pid 2)    内存:sh 的代码
fd: 0→console  1→console  2→console      (三个都指向同一个 struct file)

这三个 fd 是 init 开的(user/init.c:19-24),sh 由 init fork 出来,继承了它们。init 自己也一直开着这三个,所以这个 struct file 的 ref 此时是 6(init 3 个 + sh 3 个)。sh 阻塞在 read(0, ...) 里(getcmd,sh.c:314)。

第 1 步:解析(sh 进程自己)

你按回车,控制台驱动把整行交给 sh 的 read。sh 把它解析成一棵树(sh.c:525):

REDIR(fd=1, file="out", mode=O_WRONLY|O_CREATE|O_TRUNC)
  └─ EXEC(argv = {"sixfive", "sixfive.txt", 0})

不是 cd、wait 这类内建命令,于是走到 runtop 末尾的 fork1()(sh.c:484)。

第 2 步:fork —— 出现一个"另一个 sh"

sh (pid 2)                         子进程 (pid 5,数字仅为示例)
内存:sh 的代码                     内存:sh 代码的一份拷贝(uvmcopy,proc.c:271)
fd: 0,1,2 → console               fd: 0,1,2 → 同一个 console struct file(ref +3)
  • 此时子进程跑的还是 sh 的代码,它根本还不是 sixfive。
  • fd 表被复制了,但指向同一个 struct file(filedup 只把 ref 加一)。
  • 父进程进入 waitfor(5),睡在 wait 里。

第 3 步:重定向(子进程里,仍然是 sh 的代码)

子进程执行 runcmd 的 REDIR 分支(sh.c:111):

close(1);                     // 子进程的 1 号格空出来,console 的 ref -1
open("out", O_WRONLY|O_CREATE|O_TRUNC);
                              // fdalloc 从 0 开始找空格:0 被占、1 空 → 一定拿到 1
子进程 fd: 0→console  1→out(新的 struct file,FD_INODE,off=0)  2→console

这件事只发生在子进程里,父进程 sh 的 1 号还是控制台。这就是 fork 和 exec 必须分开的原因:中间这个窗口用来调整新程序的环境,又不影响 shell 自己。

第 4 步:exec —— 内存换掉,fd 表不动

子进程走到 EXEC 分支,exec("sixfive", argv)(sh.c:107)。内核:

  1. 在当前目录找到文件 sixfive(mkfs 打包时由 _sixfive 改名而来),读 ELF 头。
  2. 建新页表,载入 sixfive 的代码;在新栈上放好 "sixfive"、"sixfive.txt" 和 argv 指针数组。
  3. epc = start,a0 = 2(argc),a1 = argv。
  4. 释放旧页表:子进程内存里的 sh 代码消失了。
子进程 (pid 5)    内存:sixfive 的代码    ← 换了
fd: 0→console  1→out  2→console          ← 原样保留

pid 没变,fd 没变,只有内存里的程序变了。 sixfive 对重定向毫不知情。

第 5 步:start → main

返回用户态,CPU 从 start 开始执行,start 调用 main(2, argv)。sixfive 开始干活:

fd = open("sixfive.txt", O_RDONLY);   // 0、1、2 都被占 → 拿到 3
fd: 0→console  1→out  2→console  3→sixfive.txt(FD_INODE,off=0,只读)
  • read(3, buf, 512):argfd 查表拿到 ofile[3] → fileread 发现是 FD_INODE → readi 从磁盘读,off 往后推。读到文件末尾时返回 0。
  • printf("%lu\n", 5) 最终是 write(1, "5\n", 2):ofile[1] 是 out → 写进文件,而不是屏幕。
  • close(3):3 号格清空,sixfive.txt 的 struct file ref 1→0,真正释放。
  • 如果打不开,fprintf(2, ...):2 号还是控制台,所以错误信息仍然显示在屏幕上,不会混进 out。这就是区分 stdout 和 stderr 的意义。

第 6 步:exit

exit(0) → 内核 kexit(proc.c:326)把所有还开着的 fd 逐个关掉(proc.c:337-338),所以就算程序忘了 close 也不会泄漏:

  • out 的 struct file ref 1→0 → 释放,inode 的引用也放掉(fileclose → iput)
  • console 的 ref 减 2(子进程最后还持有 0 和 2)

然后进程变成 ZOMBIE(proc.c:358):内存已经释放,但进程表项还留着,等父进程来收尸,取退出状态。

第 7 步:sh 被唤醒

父进程 sh 的 wait 返回 5,waitfor 看到正是自己在等的 pid,就返回了。子进程的进程表项被回收,sh 打印 $ ,回到第 0 步。

换个写法对比:echo 12 30 7 | sixfive

这次 sixfive 没有文件参数,就去 read(0)。sh 先建了一个管道,在 sixfive 的子进程里 close(0); dup(p[0]),于是 fd 0 指向管道的读端(FD_PIPE)。sixfive 的代码一个字没改,fileread 按类型分派到了 piperead。echo 退出后,管道写端的最后一个引用被关掉,piperead 返回 0,sixfive 就看到了 EOF。

一张图总结:

            内存里的程序            fd 1 指向
fork 前     sh                     console
fork 后     sh(拷贝)              console(共享)
重定向后    sh(拷贝)              out           ← fd 变,程序不变
exec 后     sixfive                out           ← 程序变,fd 不变
exit 后     (释放)                (全部关闭)

两个概念在这里交汇:fork 复制两者;重定向只改 fd;exec 只换程序;exit 清掉两者。 shell 的全部能力,都来自这四个操作可以分开做。