文件描述符与文件
实践: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。权限(
readable/writable)也存在内核那一侧。 - 统一:管道、文件、控制台在用户看来一模一样,差异全藏在
fileread的分派里。这就是 memdump 不知道数据来自管道的原因。 - 可以继承、可以共享:
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])一个都不能少。
- "最小可用编号"规则:
fdalloc从 0 开始找第一个空格子(sysfile.c:40-52)。所以close(1); open("out", ...)一定拿到 1,这就是重定向的实现方法。 - 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)。内核:
- 在当前目录找到文件
sixfive(mkfs 打包时由_sixfive改名而来),读 ELF 头。 - 建新页表,载入 sixfive 的代码;在新栈上放好
"sixfive"、"sixfive.txt"和 argv 指针数组。 epc = start,a0 = 2(argc),a1 = argv。- 释放旧页表:子进程内存里的 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 fileref 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 的全部能力,都来自这四个操作可以分开做。