本章要解决什么

操作系统应该如何管理持久存储设备?都有哪些 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)。

/ foo bar bar.txt bar foo bar.txt 圆角 = 目录,方角 = 文件
原书 Figure 39.1:合法目录有 /、/foo、/bar、/bar/bar、/bar/foo;合法文件有 /foo/bar.txt 和 /bar/foo/bar.txt

目录树从根目录(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);

老办法是 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 全靠它解释:

进程私有:fd 表 系统共享:打开文件表 文件系统:inode struct proc (pid 100) 0: 标准输入 → tty 1: 标准输出 → tty 2: 标准错误 → tty 3: ofile[3] ● 4: ofile[4] ● struct file (OFT[10])ref: 1 readable: 1off: 100 ip: ● struct file (OFT[11])ref: 1 writable: 1off: 0 ip: ● inode 1000size: 300nlink: 1数据块指针…
同一进程把同一个文件打开两次:两个 fd → 两个独立的打开文件表项(各自的偏移)→ 同一个 inode

读写文件:用 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
...
  1. open:只读打开(O_RDONLY),使用 64 位偏移(O_LARGEFILE),成功返回 fd 3。为什么是 3 而不是 0 或 1?因为每个进程启动时已经打开了三个文件:标准输入(0)、标准输出(1)、标准错误(2)。新打开的文件拿到最小的空闲编号,几乎总是 3。
  2. read(3, …, 4096):第一个参数是 fd,告诉文件系统读哪个文件(进程可能同时开着很多文件);第二个是放结果的缓冲区(strace 把读到的内容 "hello" 显示在这里);第三个是缓冲区大小 4KB。返回 6:5 个字母加一个换行符。
  3. write(1, …, 6):把内容写到 fd 1,也就是标准输出,于是屏幕上出现 hello。cat 可能直接调用 write,也可能调用 printf(),由库函数整理格式后最终调用 write。
  4. 再 read:返回 0,表示已经读到文件末尾,程序据此知道文件读完了。
  5. 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);

对进程打开的每个文件,操作系统都跟踪一个当前偏移(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);30
read(fd, buffer, 100);100100
read(fd, buffer, 100);100200
read(fd, buffer, 100);100300
read(fd, buffer, 100);0300
close(fd);0–

偏移在 open 时初始化为 0,每次 read 增加;读到文件末尾之后再 read 返回 0,表示读完了。

② 同一文件打开两次返回值OFT[10] 偏移OFT[11] 偏移
fd1 = open("file", O_RDONLY);30–
fd2 = open("file", O_RDONLY);400
read(fd1, buffer1, 100);1001000
read(fd2, buffer2, 100);100100100
close(fd1);0–100
close(fd2);0––

两个 fd(3 和 4)各指向打开文件表里不同的表项(OFT = Open File Table,这里是第 10、11 项),偏移互不影响。

③ 先 lseek 再读返回值当前偏移
fd = open("file", O_RDONLY);30
lseek(fd, 200, SEEK_SET);200200
read(fd, buffer, 50);50250
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。只有两个进程都关闭了文件(或退出),表项才被删除:

ParentFile Descriptors 3: ● ChildFile Descriptors 3: ● Open File Table refcnt: 2off: 10inode: ● Inode 1000(file.txt)
原书 Figure 39.3:父子进程共享同一个打开文件表项(refcnt 2,off 10)

共享偏移有时很有用:多个协作进程往同一个输出文件写,不需要额外协调就不会互相覆盖。

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。

模拟器

文件描述符表可视化

单步执行系统调用,观察每个进程的 fd 表、系统打开文件表(编号从 10 开始,与原书一致;含偏移、refcnt、读写模式)和 inode(link count、被打开次数)如何变化。底部自动生成原书格式的"系统调用 | 返回值 | 当前偏移"表。
改完脚本后点这里重新运行

立即写入: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");   // ③ 原子地替换,旧版本同时被删除
write(tmp) fsync(tmp) close rename(tmp, foo) fsync(目录) 若在此区间崩溃,重启后看到: foo.txt = 完整的旧版本(tmp 可能残缺,没关系) foo.txt = 完整的新版本 任何时刻都不会看到"半新半旧"的 foo.txt
write 临时文件 + fsync + rename:原子文件更新的标准套路

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。

在 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 * 还会递归删除子目录及其内容。要是不小心在文件系统的根目录下执行……用很少的按键就能完成大量工作,也能迅速造成巨大破坏。

现在回到 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 和相关数据块,真正"删除"文件。原书的实验:

三个硬链接的建立与删除:Links 1 → 2 → 3 → 2 → 1 → 0(为 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 字节。

硬链接:ln file file2 目录 file → 67158084file2 → 67158084 inode 67158084Links: 2 两个名字地位完全平等,删掉任何一个文件都还在 符号链接:ln -s file file2 目录 file → 67158084file2 → 67158090 inode 67158090类型 l,size 4数据: "file" inode 67158084Links: 1 打开 file2 时,文件系统读出路径 "file", 再按名字重新查找——file 被删了就成了悬空引用
硬链接共享同一个 inode(link count 计数);符号链接是一个独立的 inode,数据里存的是路径名

正因为只存路径名,符号链接可能出现悬空引用(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

输入八进制模式或勾选各个位,看 ls -l 显示什么、每类用户能做什么。切换"目录"看看 x 位的含义如何变化。

执行位对普通文件决定能否运行它: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),把新文件系统"粘贴"到目录树的这个位置。

现有目录树(根文件系统) / etc home users 挂载点(空目录) /dev/sda1 上的 ext3(未挂载) / a b foo foo mount 之后 / home users a b foo foo mount -t ext3 /dev/sda1 /home/users
挂载后,/home/users 就是新文件系统的根:/home/users/a/foo、/home/users/b/foo
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 但仍被打开"那一步。

(原书作业 1 的延伸)一个目录里有 5 个普通文件和 3 个子目录,它的 link count 是多少?再往里建一个符号链接和一个硬链接呢?先预测,再运行实验里的 ./mystat 观察。
看答案

目录的 link count = 2 + 子目录数:父目录里指向它的名字 1 个、自己的 "." 1 个、每个子目录的 ".." 各 1 个,所以是 5。普通文件、符号链接、硬链接都只影响文件自己的 link count,不影响目录的,仍是 5。

用 chmod 计算器:一个目录的模式是 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 章会细讲)。

自测题

1. 一个新启动的进程第一次调用 open() 成功,返回值通常是多少?
0、1、2 分别是标准输入、标准输出、标准错误,已经打开。open 返回最小的空闲描述符,所以通常是 3。fd 不是 inode 号,它只是进程私有数组的下标。
2. 调用 lseek(fd, 1000000, SEEK_SET) 会发生什么?
lseek 只是改一个变量,和磁盘寻道无关(之后的读写可能导致寻道)。偏移可以超过文件末尾,之后再写会形成"洞"。
3. 进程打开文件得到 fd 3 后 fork。子进程 read(3, buf, 100)。之后父进程 read(3, buf, 100) 从哪里读起?
fork 复制的是 fd 数组(指针),指向同一个打开文件表项,refcnt 变为 2,偏移共享。
4. 下面哪种情况下,两个 fd 各自拥有独立的当前偏移?
每次 open 都会在打开文件表里新建一项,偏移独立(原书的 OFT[10]/OFT[11] 表)。dup、dup2、fork 都是共享表项。
5. write() 成功返回之后,数据的状态是:
文件系统出于性能会缓冲写 5~30 秒;fsync 强制刷盘。close 并不保证落盘。
6. 文件 file 有 3 个硬链接(file、file2、file3)。执行 rm file 后:
rm 调用 unlink,只删一个名字并把引用计数减 1;只有计数为 0(且没有进程打开)才真正释放 inode 和数据块。悬空引用是符号链接才有的问题。
7. 关于符号链接,下列说法错误的是:
符号链接只存路径名,不影响目标 inode 的引用计数——所以目标被删后它会悬空。硬链接才会让 link count 加 1。
8. 编辑器想原子地更新 foo.txt,下列哪个顺序是正确的?
D 在截断后、写完前崩溃会得到残缺文件;B 中间会有 foo.txt 不存在的时刻;C 在 fsync 之前 rename,崩溃后 foo.txt 可能指向尚未落盘(空或残缺)的数据。A 是原书的做法(再 fsync 一下目录更稳妥)。
9. 为什么不能在 /home(ext4 分区)上为 /mnt/usb(另一个文件系统)里的文件创建硬链接?
目录项只存 (名字, inode 号),而 inode 号离开所属文件系统就没有意义。跨文件系统要用符号链接(存路径名)。

常见误区与小结

本章要点(原书"关键文件系统术语")

和后续章节的联系:本章只看了接口。下一章(第 40 章 文件系统实现)会用极简的 vsfs 打开盖子:inode 在磁盘上长什么样、目录项怎样存储、open("/foo/bar") 要逐级读哪些块、创建文件为什么要写位图——你会看到 link count、unlink、fsync 在磁盘结构上对应的是什么。之后的崩溃一致性(第 42 章)会解释为什么 rename 能做到原子、为什么 fsync 如此重要。