本章要解决什么
操作系统应该如何管理持久存储设备?都有哪些 API?实现上有哪些要点?
How should the OS manage a persistent device? What are the APIs? What are the important aspects of the implementation?
进程虚拟化了 CPU,地址空间虚拟化了内存;这一章给虚拟化拼图加上最后一块:持久存储。硬盘、SSD 上的数据断电也不会丢,用户最在乎的东西都放在这里,所以操作系统必须格外小心。
本章是一篇"插叙":先不谈实现,只站在程序员的角度,把 UNIX 文件系统的接口过一遍——open、read、write、lseek、fsync、rename、stat、link、unlink、mkdir、mount……读完你会明白为什么删除文件的系统调用叫 unlink。下一章(第 40 章)再打开盖子,看这些接口在磁盘上是怎样实现的。
核心概念速览
文件(file)
一个线性的字节数组,可读可写。它的低级名字是 inode 号(i-number)。
目录(directory)
也有 inode 号,内容是一串 (用户可读的名字, inode 号) 对;目录套目录形成目录树。
文件描述符(fd)
open() 返回的小整数,每个进程私有,是访问文件的"能力"(capability)。
打开文件表(open file table)
系统级的表,每项记录:指向哪个 inode、当前偏移、读写权限、引用计数。
fsync()
write() 只是把数据交给文件系统"以后再写";fsync() 强制把脏数据写到磁盘后才返回。
rename() 的原子性
崩溃时文件要么是旧名、要么是新名,没有中间态——原子更新文件的关键工具。
硬链接 vs 符号链接
硬链接是同一 inode 的另一个名字(link count +1);符号链接是一个存着路径名的独立小文件。
mkfs 与 mount
mkfs 在分区上建一个空文件系统;mount 把它"粘"到目录树的某个挂载点上。
文件和目录
存储虚拟化里发展出了两个关键抽象。
文件(file)就是一个线性的字节数组,每个字节都能读写。每个文件有一个低级名字(low-level name),通常是个数字,用户往往意识不到它;由于历史原因,它叫做 inode 号(inode number / i-number)。操作系统一般不关心文件的内部结构(图片?文本?C 代码?)——文件系统只负责把数据持久地存好,你下次要的时候原样还给你。这件事并没有听起来那么简单!
目录(directory)也有 inode 号,但内容很特定:一串 (用户可读的名字, 低级名字) 对。比如某文件的低级名字是 10,用户叫它 foo,那么它所在的目录里就有一项 ("foo", "10")。目录项可以指向文件,也可以指向别的目录,于是就能搭出任意的目录树(directory tree / directory hierarchy)。
目录树从根目录(root directory) / 开始,用分隔符逐级命名子目录,直到指定目标,这就是绝对路径名(absolute pathname),比如 /foo/bar.txt。同名文件只要在树的不同位置就不冲突(图里有两个 bar.txt)。文件名里的 .txt、.c 通常只是约定,没人强制 main.c 里一定是 C 代码。
命名是计算机系统的重要一环。在 UNIX 里,几乎一切都通过文件系统命名:文件、设备、管道,甚至进程(/proc)。这种统一让概念模型更简单、系统更模块化。访问任何资源的第一步,就是能叫出它的名字。
创建文件:open() 与文件描述符
用 open() 并传入 O_CREAT 标志即可创建文件:
int fd = open("foo", O_CREAT|O_WRONLY|O_TRUNC, S_IRUSR|S_IWUSR);
O_CREAT:文件不存在就创建;O_WRONLY:只能写;O_TRUNC:如果已存在,把它截断为 0 字节(清空旧内容)。- 第三个参数是权限:
S_IRUSR|S_IWUSR让属主可读可写。
老办法是 int fd = creat("foo");,相当于 open() 加上 O_CREAT|O_WRONLY|O_TRUNC。既然 open 能建文件,creat 就不太用了(完全可以实现成调用 open 的库函数)。它在 UNIX 传说里却有特殊地位:有人问 Ken Thompson 重新设计 UNIX 会改什么,他说:"我会把 creat 拼成带 e 的。"
open() 的返回值是文件描述符(file descriptor):一个整数,每个进程私有,之后用它来读写文件(前提是你有权限)。可以把它看成一种能力(capability)——一个不透明的句柄,持有它就有权做某些操作;也可以把它看成指向"文件对象"的指针,有了对象就能调用 read()、write() 这些"方法"。
文件描述符由操作系统按进程管理。xv6 内核里,struct proc 有一个数组:
struct proc {
...
struct file *ofile[NOFILE]; // Open files:下标就是 fd
...
};
数组下标就是文件描述符,每个元素是指向 struct file 的指针,后者记录被读写文件的信息。三层关系如下图——这是整章最重要的一张图,后面的 fork、dup、lseek 全靠它解释:
读写文件:用 strace 偷看 cat
先建一个文件,再用 cat 显示它:
prompt> echo hello > foo
prompt> cat foo
hello
cat 是怎样访问 foo 的?Linux 上的 strace 能跟踪一个程序执行的每一个系统调用、参数和返回值(Mac 上是 dtruss,老 UNIX 上是 truss)。原书的输出(删掉了无关调用):
prompt> strace cat foo
...
open("foo", O_RDONLY|O_LARGEFILE) = 3
read(3, "hello\n", 4096) = 6
write(1, "hello\n", 6) = 6
hello
read(3, "", 4096) = 0
close(3) = 0
...
- open:只读打开(
O_RDONLY),使用 64 位偏移(O_LARGEFILE),成功返回 fd 3。为什么是 3 而不是 0 或 1?因为每个进程启动时已经打开了三个文件:标准输入(0)、标准输出(1)、标准错误(2)。新打开的文件拿到最小的空闲编号,几乎总是 3。 - read(3, …, 4096):第一个参数是 fd,告诉文件系统读哪个文件(进程可能同时开着很多文件);第二个是放结果的缓冲区(strace 把读到的内容 "hello" 显示在这里);第三个是缓冲区大小 4KB。返回 6:5 个字母加一个换行符。
- write(1, …, 6):把内容写到 fd 1,也就是标准输出,于是屏幕上出现 hello。cat 可能直接调用 write,也可能调用
printf(),由库函数整理格式后最终调用 write。 - 再 read:返回 0,表示已经读到文件末尾,程序据此知道文件读完了。
- close(3):告诉系统不再使用这个 fd。
写文件也是类似的步骤:open 打开写 → 可能多次 write → close。可以用 strace 跟踪你自己的程序,或 dd if=foo of=bar。
使用 strace(以及类似工具)。常用参数:-f 连 fork 出的子进程一起跟踪;-t 显示每次调用的时间;-e trace=open,close,read,write 只跟踪这几种调用。在 WSL / Ubuntu 上安装:sudo apt install strace,然后 strace -e trace=openat,read,write,close cat foo(新版 glibc 用 openat 代替 open)。本章实验里的 mycat 在没装 strace 时也能打印出同样的调用序列。
每个进程维护一个文件描述符数组,每个描述符指向系统级打开文件表(open file table)中的一项。这一项记录:描述符对应哪个底层文件、当前偏移,以及文件是否可读、可写等细节。
不按顺序读写:lseek() 与当前偏移
到目前为止的访问都是顺序的:从头读到尾。但有时需要从文件的某个偏移处读写,比如在文本文档上建了索引,查一个词时就要跳到随机位置去读。这就用 lseek():
off_t lseek(int fildes, off_t offset, int whence);
SEEK_SET:偏移设为offset;SEEK_CUR:偏移设为当前位置 +offset;SEEK_END:偏移设为文件大小 +offset。
对进程打开的每个文件,操作系统都跟踪一个当前偏移(current offset),决定下一次读写从哪里开始。它有两种更新方式:① 读或写 N 字节,偏移自动 +N;② 用 lseek 显式修改。偏移存在 struct file 里(xv6 的简化版):
struct file {
int ref; // 引用计数:有几个 fd(可能跨进程)指向这里
char readable;
char writable;
struct inode *ip; // 指向底层文件的 inode
uint off; // 当前偏移
};
所有这些 struct file 合起来就是打开文件表。xv6 把它们放在一个数组里,整张表用一把锁保护:
struct {
struct spinlock lock;
struct file file[NFILE];
} ftable;
名字起得不好的 lseek() 让很多学生把它和磁盘寻道混为一谈。千万别!lseek() 只是修改操作系统内存里的一个变量,记录这个进程下一次读写从哪开始。磁盘寻道是指发往磁盘的读写请求和上一次不在同一磁道,磁头必须移动。更容易混淆的是:用 lseek 跳到文件各处再读写,确实会导致更多磁盘寻道——但 lseek 本身绝不会产生任何磁盘 I/O。
原书用三张表追踪偏移的变化。下面的模拟器可以逐行重放它们(预设 ①②③),这里先把结论列出来:
| ① 顺序读 300 字节的文件 | 返回值 | 当前偏移 |
|---|---|---|
| fd = open("file", O_RDONLY); | 3 | 0 |
| read(fd, buffer, 100); | 100 | 100 |
| read(fd, buffer, 100); | 100 | 200 |
| read(fd, buffer, 100); | 100 | 300 |
| read(fd, buffer, 100); | 0 | 300 |
| close(fd); | 0 | – |
偏移在 open 时初始化为 0,每次 read 增加;读到文件末尾之后再 read 返回 0,表示读完了。
| ② 同一文件打开两次 | 返回值 | OFT[10] 偏移 | OFT[11] 偏移 |
|---|---|---|---|
| fd1 = open("file", O_RDONLY); | 3 | 0 | – |
| fd2 = open("file", O_RDONLY); | 4 | 0 | 0 |
| read(fd1, buffer1, 100); | 100 | 100 | 0 |
| read(fd2, buffer2, 100); | 100 | 100 | 100 |
| close(fd1); | 0 | – | 100 |
| close(fd2); | 0 | – | – |
两个 fd(3 和 4)各指向打开文件表里不同的表项(OFT = Open File Table,这里是第 10、11 项),偏移互不影响。
| ③ 先 lseek 再读 | 返回值 | 当前偏移 |
|---|---|---|
| fd = open("file", O_RDONLY); | 3 | 0 |
| lseek(fd, 200, SEEK_SET); | 200 | 200 |
| read(fd, buffer, 50); | 50 | 250 |
| close(fd); | 0 | – |
共享打开文件表项:fork() 与 dup()
多数情况下 fd 与打开文件表项是一对一的:即使另一个进程同时读同一个文件,它也有自己的表项和偏移。但有两种重要的例外会共享表项。
fork()
父进程打开文件后 fork,子进程用 lseek 改偏移然后退出,父进程等子进程结束后查看偏移(原书 Figure 39.2,fork-seek.c):
int main(int argc, char *argv[]) {
int fd = open("file.txt", O_RDONLY);
assert(fd >= 0);
int rc = fork();
if (rc == 0) { // 子进程
rc = lseek(fd, 10, SEEK_SET);
printf("child: offset %d\n", rc);
} else if (rc > 0) { // 父进程
(void) wait(NULL);
printf("parent: offset %d\n", (int) lseek(fd, 0, SEEK_CUR));
}
return 0;
}
prompt> ./fork-seek
child: offset 10
parent: offset 10
父进程看到的偏移也是 10!因为 fork 复制的是 fd 数组,数组里的指针仍指向同一个打开文件表项,于是该表项的引用计数 ref 变成 2。只有两个进程都关闭了文件(或退出),表项才被删除:
共享偏移有时很有用:多个协作进程往同一个输出文件写,不需要额外协调就不会互相覆盖。
dup()、dup2()
dup() 让进程新建一个 fd,指向与已有 fd 相同的打开文件(原书 Figure 39.4):
int fd = open("README", O_RDONLY);
assert(fd >= 0);
int fd2 = dup(fd);
// 现在 fd 和 fd2 可以互换使用
dup2(oldfd, newfd) 则让指定的 newfd 指向 oldfd 的表项(必要时先关闭 newfd)。这正是 shell 实现输出重定向的方法:ls > out.txt 时,shell 在 fork 出的子进程里先 open("out.txt") 得到 fd 3,再 dup2(3, 1) 让标准输出指向文件,然后 exec 程序——程序照常往 fd 1 写,却写进了文件。
模拟器:fd 表 → 打开文件表 → inode
在下面的模拟器里,每一行是一次系统调用,可以用 fd = open("file", O_RDONLY) 这样的写法把返回值存进变量;行首写 child: 表示由子进程执行(fork 之后),不写默认是父进程。支持 open / read / write / lseek / dup / fork / close / exit / wait / link / unlink / fsync。
文件描述符表可视化
立即写入:fsync()
大多数时候,程序调用 write() 只是告诉文件系统:"请在将来某个时候把这些数据写到持久存储上。"出于性能考虑,文件系统会把写操作在内存里缓冲一段时间(比如 5 秒或 30 秒),之后才真正发给存储设备。从应用看,写完成得很快;只有在极少数情况下(write 之后、落盘之前机器崩溃)数据才会丢失。
但有些应用需要更强的保证。比如数据库管理系统(DBMS),正确的恢复协议要求能在某些时刻强制把数据写到磁盘。UNIX 提供 fsync(int fd):文件系统把这个 fd 对应文件的所有脏数据(dirty data,尚未写入的数据)强制写到磁盘,全部完成后 fsync 才返回。
int fd = open("foo", O_CREAT|O_WRONLY|O_TRUNC, S_IRUSR|S_IWUSR);
assert(fd > -1);
int rc = write(fd, buffer, size);
assert(rc == size);
rc = fsync(fd); // 返回时,数据已经持久化(如果 fsync 实现正确的话)
assert(rc == 0);
有意思的是,这还不够!如果 foo 是新建的,你还需要对它所在的目录也调用 fsync,才能保证"目录里有 foo 这个名字"也持久化了。这种细节经常被忽略,导致大量应用层 bug。
重命名:rename() 的原子性
命令行上 mv foo bar 用的是 rename(char *old, char *new) 系统调用。它提供了一个有趣的保证:对系统崩溃而言通常是原子的(atomic)——如果重命名过程中系统崩溃,文件要么还叫旧名、要么已叫新名,不会出现奇怪的中间状态。这使 rename 成为原子地更新文件的关键。
设想编辑器要在 foo.txt 中间插入一行,并保证文件要么是完整的旧版、要么是完整的新版:
int fd = open("foo.txt.tmp", O_WRONLY|O_CREAT|O_TRUNC, S_IRUSR|S_IWUSR);
write(fd, buffer, size); // ① 把新版本完整地写到临时文件
fsync(fd); // ② 强制落盘
close(fd);
rename("foo.txt.tmp", "foo.txt"); // ③ 原子地替换,旧版本同时被删除
mmap() 是访问文件中持久数据的另一种方式:它在文件的字节偏移与进程的虚拟地址之间建立对应——前者叫后备文件(backing file),后者叫它的内存映像(in-memory image)。之后进程直接用 load/store 指令访问内存映像就能读写文件。
把文件的持久性和内存的访问方式结合起来,就得到持久内存(persistent memory)这种编程风格,省去了内存格式与存储格式之间的来回转换。原书示例 pstack.c 把一个栈存在文件 ps.img 里:p = mmap(NULL, file_size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0) 之后,用 p->n、p->stack[] 就能直接操作,这次 push 的数据下次运行还能 pop 出来。不过若在 push 的"递增 n"和"赋值"之间崩溃,栈会不一致——应用需要相对于故障原子地更新持久内存的机制。
获取文件信息:stat()
除了访问文件内容,文件系统还保存了关于每个文件的大量信息,统称元数据(metadata)。用 stat() 或 fstat()(传路径名或 fd)可以取出来,填进一个 struct stat:
struct stat {
dev_t st_dev; // 文件所在设备的 ID
ino_t st_ino; // inode 号
mode_t st_mode; // 类型与权限
nlink_t st_nlink; // 硬链接个数
uid_t st_uid; // 属主的用户 ID
gid_t st_gid; // 属组 ID
dev_t st_rdev; // 设备 ID(如果是特殊文件)
off_t st_size; // 总大小(字节)
blksize_t st_blksize; // 文件系统 I/O 的块大小
blkcnt_t st_blocks; // 分配的块数(以 512 字节为单位)
time_t st_atime; // 最后访问时间
time_t st_mtime; // 最后修改时间
time_t st_ctime; // 最后状态改变时间
};
prompt> echo hello > file
prompt> stat file
File: 'file'
Size: 6 Blocks: 8 IO Block: 4096 regular file
Device: 811h/2065d Inode: 67158084 Links: 1
Access: (0640/-rw-r-----) Uid: (30686/ remzi) Gid: (30686/ remzi)
Access: 2011-05-03 15:50:20.157594748 -0500
Modify: 2011-05-03 15:50:20.157594748 -0500
Change: 2011-05-03 15:50:20.157594748 -0500
注意 Size 是 6 字节,Blocks 却是 8(8 × 512B = 4KB):文件系统按块分配空间。文件系统通常把这些信息存在一个叫 inode 的结构里。所有 inode 都在磁盘上,活跃的 inode 通常在内存中有缓存副本。下一章会细讲 inode。
删除文件:神秘的 unlink()
在 UNIX 上删除文件用 rm。但 rm 调用的是哪个系统调用?
prompt> strace rm foo
...
unlink("foo") = 0
...
只有一个名字古怪的 unlink():接受要删除的文件名,成功返回 0。为什么叫"取消链接"而不是 remove 或 delete?要回答这个问题,得先弄懂目录和链接。
目录:创建、读取、删除
你永远不能直接写目录。目录的格式属于文件系统元数据,文件系统要对它的完整性负责,所以只能通过创建文件、目录等操作间接地更新目录。
创建目录用 mkdir():
prompt> strace mkdir foo
...
mkdir("foo", 0777) = 0
...
新目录被认为是"空"的,但其实有两项:.(dot)指向自己,..(dot-dot)指向父目录。ls -a 能看到它们。
读目录不能像读文件一样 open/read,而是用 opendir()、readdir()、closedir()。下面是一个迷你 ls,打印每一项的 inode 号和名字:
int main(int argc, char *argv[]) {
DIR *dp = opendir(".");
assert(dp != NULL);
struct dirent *d;
while ((d = readdir(dp)) != NULL) { // 一次读一个目录项
printf("%lu %s\n", (unsigned long) d->d_ino, d->d_name);
}
closedir(dp);
return 0;
}
struct dirent {
char d_name[256]; // 文件名
ino_t d_ino; // inode 号
off_t d_off; // 到下一个 dirent 的偏移
unsigned short d_reclen; // 本记录长度
unsigned char d_type; // 文件类型
};
目录里的信息很少(基本就是名字 → inode 号),所以 ls -l 要对每个文件再调用一次 stat() 取得长度等详细信息——用 strace 对比 ls 和 ls -l 就能看到。
删除目录用 rmdir()。因为一条命令可能删掉大量数据,rmdir 要求目录必须为空(只有 . 和 ..),否则直接失败。
小心强大的命令。rm * 删除当前目录所有文件,rm -rf * 还会递归删除子目录及其内容。要是不小心在文件系统的根目录下执行……用很少的按键就能完成大量工作,也能迅速造成巨大破坏。
硬链接:link() 与引用计数
现在回到 unlink 之谜。link(old, new) 系统调用(命令行 ln)为已有文件创建另一个名字:
prompt> echo hello > file
prompt> ln file file2
prompt> cat file2
hello
prompt> ls -i file file2
67158084 file
67158084 file2
link 只是在目录里再建一个名字,指向同一个 inode 号,文件本身完全没有被复制。所以创建文件其实做了两件事:① 建立一个 inode 结构,记录文件的几乎一切(大小、数据块位置……);② 把一个人类可读的名字链接到这个 inode,放进某个目录。建好硬链接后,文件系统看 file 和 file2 毫无区别,它们都只是指向 inode 67158084 的链接。
所以删除文件就是 unlink():删掉名字与 inode 之间的这条"链接",并把 inode 里的引用计数(reference count,也叫 link count)减 1。只有计数降到 0,文件系统才释放 inode 和相关数据块,真正"删除"文件。原书的实验:
补充一个细节:计数为 0 的文件如果还被某个进程打开着,文件系统会等它关闭后才真正回收——这就是为什么删掉一个正在写的日志文件,磁盘空间不会立刻释放。模拟器的预设 ⑥ 演示了这一点。
符号链接
硬链接有局限:不能给目录建硬链接(怕在目录树里造成环);不能链接到其他分区的文件(inode 号只在一个文件系统内唯一)。于是有了符号链接(symbolic link),也叫软链接(soft link),用 ln -s 创建:
prompt> echo hello > file
prompt> ln -s file file2
prompt> cat file2
hello
表面上差不多,本质完全不同:符号链接本身是一个文件,是除普通文件、目录之外文件系统认识的第三种类型。stat 会显示 "symbolic link",ls -l 首字符是 l。它的数据就是目标的路径名,所以 file2 的大小是 4 字节("file"),链接到 alongerfilename 的 file3 大小是 15 字节。
正因为只存路径名,符号链接可能出现悬空引用(dangling reference):
prompt> echo hello > file
prompt> ln -s file file2
prompt> rm file
prompt> cat file2
cat: file2: No such file or directory
权限位与访问控制列表
文件系统也提供了磁盘的虚拟视图,但和 CPU、内存的虚拟化不同,文件通常在不同用户和进程之间共享,所以需要一套控制共享程度的机制。最经典的是 UNIX 的权限位(permission bits):
prompt> ls -l foo.txt
-rw-r--r-- 1 remzi wheel 0 Aug 24 16:29 foo.txt
第一个字符是类型(- 普通文件、d 目录、l 符号链接),与权限基本无关。后面 9 个字符分三组:属主(owner)、组(group)、其他人(other),每组是读 r、写 w、执行 x。上例表示属主可读写,wheel 组成员和其他人只读。属主可以用 chmod(change mode)修改,比如 chmod 600 foo.txt:每一组用一个八进制数字表示,r=4、w=2、x=1,属主 4|2=6,组和其他人都是 0,结果是 rw-------。
chmod 计算器:八进制 ⇄ rwx
执行位对普通文件决定能否运行它:chmod 600 hello.csh 之后 ./hello.csh 会报 Permission denied。对目录含义不同:x 允许用户(或组、所有人)进入该目录(cd)、访问其中的名字;与 w 结合才能在里面创建文件。
比权限位更强大的是访问控制列表(access control list,ACL)。分布式文件系统 AFS 给每个目录一个 ACL,可以精确列出谁能、谁不能访问:
prompt> fs listacl private
Access list for private is
Normal rights:
system:administrators rlidwka
remzi rlidwka
rlidwka 分别是 read、lookup、insert、delete、write、lock、administer。要让另一位作者读这个目录:fs setacl private/ andrea rl。
谁有权执行特权操作,比如删除一个不活跃用户的文件来腾空间?本地文件系统通常有一个超级用户(superuser,即 root),无视权限访问一切文件;AFS 里是 system:administrators 组。这些受信任的用户本身就是安全隐患:攻击者一旦冒充成功,就能访问系统中的全部信息。
警惕 TOCTTOU。检查时间到使用时间(Time Of Check To Time Of Use)问题:在"有效性检查"与"相关操作"之间存在时间窗口,这期间被检查的对象可能被恶意改变。例:以 root 运行的邮件服务先用 lstat() 确认收件箱是属于目标用户的普通文件,再往里追加邮件;攻击者恰好在检查之后、写入之前用 rename() 把收件箱换成指向 /etc/passwd 的东西,服务就把邮件内容写进了密码文件。没有简单完美的解法:减少需要 root 的服务;O_NOFOLLOW 让 open 在目标是符号链接时失败;事务性文件系统能根治但很少部署。所以老生常谈:写高权限代码要小心!
创建和挂载文件系统:mkfs 与 mount
最后一个问题:怎样用许多底层文件系统拼出一棵完整的目录树?先创建文件系统,再挂载它。
mkfs("make fs"):给它一个设备(比如磁盘分区 /dev/sda1)和文件系统类型(比如 ext3),它就在分区上写入一个空文件系统,从一个根目录开始。
mount(底层是 mount() 系统调用):取一个已有目录作为挂载点(mount point),把新文件系统"粘贴"到目录树的这个位置。
prompt> mount -t ext3 /dev/sda1 /home/users
prompt> ls /home/users/
a b
这就是 mount 的美妙之处:不再是一堆零散的文件系统,而是统一成一棵树,命名一致又方便。运行不带参数的 mount 能看到系统上挂了什么:
/dev/sda1 on / type ext3 (rw)
proc on /proc type proc (rw)
sysfs on /sys type sysfs (rw)
/dev/sda5 on /tmp type ext3 (rw)
/dev/sda7 on /var/vice/cache type ext3 (rw)
tmpfs on /dev/shm type tmpfs (rw)
AFS on /afs type afs (rw)
ext3(标准磁盘文件系统)、proc(访问进程信息的伪文件系统)、tmpfs(只放临时文件的内存文件系统)、AFS(分布式文件系统)……五花八门,全被粘在同一棵树上。也正因为不同分区是不同的文件系统,inode 号只在各自内部唯一,硬链接才不能跨越挂载点。
先预测,再验证
fd = open("file", O_RDONLY)、read(fd, b, 50)、rc = fork()、child: read(fd, b, 100)、child: exit()、read(fd, b, 100)(file 大小 300)。父进程最后这次 read 从哪个偏移开始?返回多少?结束时偏移是多少?看答案
父子共享同一个表项。父读 50 → 偏移 50;子读 100 → 偏移 150;子退出,refcnt 从 2 降回 1;父再读从 150 开始,返回 100,偏移变为 250。如果子进程是自己重新 open 的文件,父进程就会从 50 开始读。
fd2 = dup(fd) 之后 close(fd),fd2 还能用吗?打开文件表项的 refcnt 依次是多少?用预设 ⑤ 验证。看答案
能用。dup 后 refcnt = 2,close(fd) 后 refcnt = 1,表项仍在,偏移也保留(预设里第三次读从 200 开始)。直到 close(fd2) 让 refcnt 变 0,表项才被释放。
echo hello > file; ln file file2; ln file file3; rm file2 后,stat file3 显示 Links 是几?如果此时有进程打开着 file3,再执行 rm file file3,这个进程还能读到 hello 吗?磁盘空间什么时候释放?看答案
Links = 2(file 和 file3)。全部 rm 之后 link count 为 0,但 inode 仍被打开文件表引用,进程照样能读到 hello;等最后一个 fd 关闭,inode 和数据块才被释放。用预设 ⑥ 单步看"links=0 但仍被打开"那一步。
./mystat 观察。看答案
目录的 link count = 2 + 子目录数:父目录里指向它的名字 1 个、自己的 "." 1 个、每个子目录的 ".." 各 1 个,所以是 5。普通文件、符号链接、硬链接都只影响文件自己的 link count,不影响目录的,仍是 5。
711(drwx--x--x)。组里的用户能 ls 它吗?能 cat 里面一个已知名字、权限为 644 的文件吗?看答案
不能 ls(没有 r,读不了目录项列表);但能 cat 已知名字的文件(目录有 x,可以按名字查找;文件本身对组可读)。很多系统上的用户主目录就用这种"能穿过、不能列出"的设置。
动手实验
代码在 labs/39-files-dirs/,五个小程序对应本章的五个主题:
cd labs/39-files-dirs && make && ./mycat && ./links && ./mystat && ./fork-seek && ./atomic-write
关于 strace:本机没有安装 strace。WSL / Ubuntu / Debian 上安装:sudo apt install strace;然后试 strace cat foo、strace -e trace=openat,read,write,close cat foo、strace rm foo、strace mv foo bar、strace -f ./fork-seek。没有 strace 时,mycat 自己执行 cat 的系统调用序列,并按 strace 的格式把每一步打印到标准错误。
以下都是示例输出(WSL2 · ext4 · gcc 14),你的机器上 inode 号、时间等数字会不同。
1. mycat——和原书的 strace 输出对照(中间那行 hello 是真正写到标准输出的内容):
openat(AT_FDCWD, "foo", O_RDONLY) = 3
read(3, "hello\n", 4096) = 6
hello
write(1, "hello\n", 6) = 6
read(3, "", 4096) = 0
close(3) = 0
2. links——复刻原书的硬链接 / 符号链接实验:
== 硬链接:link() 只是在目录里再加一个名字,指向同一个 inode ==
echo hello > file
file inode=1034694 links=1 size=6 regular file
ln file file2
file inode=1034694 links=2 size=6 regular file
file2 inode=1034694 links=2 size=6 regular file
ln file2 file3
file inode=1034694 links=3 size=6 regular file
rm file
file2 inode=1034694 links=2 size=6 regular file
rm file2
file3 inode=1034694 links=1 size=6 regular file
cat file3: hello
rm file3 (link count 降到 0,inode 和数据块才真正被释放)
== 符号链接:一个独立的小文件,内容是目标的路径名 ==
ln -s file file2
file inode=1034694 links=1 size=6 regular file
file2 inode=1034695 links=1 size=4 symbolic link -> file
cat file2: hello
ln -s alongerfilename file3
file3 inode=1034697 links=1 size=15 symbolic link -> alongerfilename
rm file (符号链接不增加 link count,也拦不住删除)
file2 inode=1034695 links=1 size=4 symbolic link -> file
cat file2: 打开失败(No such file or directory)
→ file2 成了悬空引用(dangling reference)
3. mystat——自己实现的 stat,外加目录 link count 的观察:
$ echo hello > file; ./mystat file
File: file
Size: 6 Blocks: 8 IO Block: 4096 regular file
Device: 8,80 Inode: 1034694 Links: 1
Access: (0640/-rw-r-----) Uid: 1000 Gid: 1000
Access: 2026-09-24 23:03:14.634124807
Modify: 2026-09-24 23:03:14.634124807
Change: 2026-09-24 23:03:14.634124807
== 目录的 link count = 2 + 子目录个数 ==
mkdir d -> d 的 links = 2 (父目录里的 "d" + 自己的 ".")
touch d/f -> d 的 links = 2 (普通文件不影响)
mkdir d/sub1 -> d 的 links = 3 (sub1/.. 指向 d)
mkdir d/sub2 -> d 的 links = 4 (sub2/.. 指向 d)
mkdir d/sub3 -> d 的 links = 5 (sub3/.. 指向 d)
rmdir d -> 成功(此时只剩 . 和 ..)
4. fork-seek——Figure 39.2 原样复现,再对比 dup、两次 open 与 dup2 重定向:
== 1. fork():父子进程共享同一个打开文件表项 ==
child: offset 10
parent: offset 10
== 2. dup():两个 fd,同一个表项,偏移一起动 ==
fd=3 fd2=4
read(fd, 100) = 100 -> fd 偏移 100, fd2 偏移 100
read(fd2, 100) = 100 -> fd 偏移 200, fd2 偏移 200
== 3. 两次 open():两个独立的表项,偏移各管各的 ==
fd1=3 fd2=4
read(fd1, 100) = 100 -> fd1 偏移 100, fd2 偏移 0
read(fd2, 100) = 100 -> fd1 偏移 100, fd2 偏移 100
== 4. dup2() 做输出重定向(shell 的 > 就是这么实现的)==
out.txt 的内容:这行字写进了 out.txt,而不是屏幕
5. atomic-write——write 临时文件 + fsync + rename + fsync 目录:
初始状态:
foo.txt: "line 1\nline 3\n"
1. open("foo.txt.tmp", O_WRONLY|O_CREAT|O_TRUNC)
= 3
2. write(fd, 新内容, 32) = 32
此刻崩溃:foo.txt 仍是完整的旧版本,tmp 文件可能残缺——但没人会读它
foo.txt: "line 1\nline 3\n"
3. fsync(fd) = 0 (新内容真正落盘后才继续)
4. close(fd) = 0
5. rename("foo.txt.tmp", "foo.txt") = 0 (原子地把新文件换上,旧文件同时消失)
6. fsync(目录 ".") = 0 (让"改名"这一目录修改本身也持久化)
最终状态(任何时刻崩溃,foo.txt 要么是旧版、要么是新版):
foo.txt: "line 1\nline 2 (inserted)\nline 3\n"
foo.txt.tmp: (不存在)
怎么解读:mycat 的 fd 是 3(0/1/2 已被占);links 里硬链接让 inode 号相同、Links 递增,符号链接则有自己的 inode、大小等于路径长度(4 和 15)、删掉目标就悬空;mystat 显示 6 字节文件占 8 个 512B 块,目录每多一个子目录 Links 就加 1;fork-seek 说明 fork 和 dup 共享偏移而两次 open 不共享;atomic-write 在每一步都保证 foo.txt 是完整的一个版本。注意:fsync 的效果无法在不断电的情况下直接"看到",程序只能展示调用顺序,真正的意义要到崩溃时才体现(第 42 章会细讲)。
自测题
lseek(fd, 1000000, SEEK_SET) 会发生什么?write() 成功返回之后,数据的状态是:rm file 后:常见误区与小结
- lseek = 磁盘寻道?不是。lseek 只改内存里的偏移变量,不产生 I/O。
- rm 就是删除数据?rm 调用 unlink,只删一个名字;还有别的硬链接或还有进程开着它时,数据都还在。
- write 返回就安全了?数据可能还在内存缓冲里;需要持久性要调用 fsync(新建文件还要 fsync 目录)。
- fd 就是 inode 号?fd 是每进程私有的小整数,经过打开文件表才指向 inode。
- fork 后父子各有一份偏移?不,共享同一个打开文件表项;只有各自重新 open 才独立。
- 符号链接和硬链接差不多?硬链接是同一 inode 的另一个名字,不能指向目录、不能跨文件系统;符号链接是存路径名的独立文件,可以跨文件系统、指向目录,但可能悬空。
- 可以直接写目录?不行,目录只能通过创建 / 删除 / 改名等操作间接修改。
本章要点(原书"关键文件系统术语")
- 文件是可创建、读、写、删除的字节数组,有唯一的低级名字(i-number)。
- 目录是 (人类可读名字, 低级名字) 元组的集合,本身也有 i-number,总有
.和..两项;目录树从根开始组织所有文件和目录。 - 访问文件先用
open()向操作系统申请,得到文件描述符;它是每进程私有的,指向打开文件表中记录文件、当前偏移等信息的表项。 read()、write()自动更新偏移,lseek()显式修改,从而支持随机访问。- 强制持久化要用
fsync()等调用;既正确又高效地使用它并不容易。 - 让多个名字指向同一个文件,用硬链接或符号链接;删除文件就是对它做最后一次
unlink()。 - 权限位提供基本的共享控制,ACL 更精细;mkfs + mount 把多个文件系统拼成一棵树。
和后续章节的联系:本章只看了接口。下一章(第 40 章 文件系统实现)会用极简的 vsfs 打开盖子:inode 在磁盘上长什么样、目录项怎样存储、open("/foo/bar") 要逐级读哪些块、创建文件为什么要写位图——你会看到 link count、unlink、fsync 在磁盘结构上对应的是什么。之后的崩溃一致性(第 42 章)会解释为什么 rename 能做到原子、为什么 fsync 如此重要。