Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← 系统与高性能 / Systems & Performance

xv6 源码学习 / xv6 Source Learning

1. xv6 源码学习专题 / xv6 Source Learning

2. xv6 学习路线:从 C 指针到操作系统源码 / xv6 Learning Roadmap

3. 读 xv6 必备的 C 语言和指针基础 / C And Pointers For xv6

4. 读 xv6 必备的 RISC-V 基础 / RISC-V Basics For xv6

5. 如何阅读 xv6 源码 / How To Read xv6 Source

6. xv6 启动与内核入口 / xv6 Boot And Kernel Entry

7. xv6 内存布局与页表 / xv6 Memory Layout And Page Tables

8. xv6 trap、中断与系统调用 / xv6 Traps Interrupts And System Calls

9. xv6 进程与 struct proc / xv6 Process And struct proc

10. xv6 上下文切换与调度器 / xv6 Context Switch And Scheduler

11. xv6 sleep/wakeup、wait 与 exit / xv6 Sleep Wakeup Wait And Exit

12. xv6 锁与并发 / xv6 Locks And Concurrency

13. xv6 文件描述符与 inode / xv6 File Descriptor And Inode

14. xv6 文件系统与日志 / xv6 File System And Log

15. xv6 设备驱动、console、UART 与磁盘 / xv6 Device Driver Console UART And Disk

16. xv6 用户程序与 shell / xv6 User Programs And Shell

17. 用 QEMU/GDB 调试 xv6 / Debugging xv6 With QEMU And GDB

18. 从 xv6 过渡到 Linux 0.11 / Bridge From xv6 To Linux 0.11

19. xv6 专题总结与实践项目 / xv6 Summary And Practice Projects

本页目录

xv6 文件系统与日志 / xv6 File System And Log ​

1. 这篇文章解决什么问题 ​

文件系统的目标很朴素。

它要把“文件名”稳定地映射到“文件内容”。

它还要在断电或崩溃后尽量不坏。

它还要让多个进程并发访问时不互相踩坏数据。

这三个目标放在一起,就不朴素了。

xv6 的做法是把文件系统拆成几层:

text
names
  |
  v
directories
  |
  v
inodes
  |
  v
blocks
  |
  v
log
1
2
3
4
5
6
7
8
9
10
11
12
13

这篇文章聚焦:

  • 磁盘布局是什么。
  • superblock 是什么。
  • dinode 和 inode 有什么关系。
  • 目录为什么只是文件的一种。
  • readi/writei/bmap/dirlookup/dirlink 在做什么。
  • 日志事务为什么能防止崩溃撕裂。
  • buffer cache 为什么是文件系统的中枢。

本文基于标准 MIT xv6-riscv 文件系统结构。

本地 E:\Github\xv6-riscv 可读,所以源码文件名按本地标准仓库说明。

1.1 本文基础词小注释 ​

文件系统 filesystem:文件系统不是某一个文件,而是一套规则和代码。它规定文件名如何变成 inode,inode 如何找到数据块,磁盘上哪些块空闲,崩溃后如何恢复。

磁盘块 block:磁盘读写的基本单位。你可以把磁盘想成很多固定大小的小格子,文件内容最终会落在这些格子里。xv6 常用 BSIZE 表示块大小。

text
------- disk image -------+
| block 0 | block 1 | ... |
+---------+---------+-----+
1
2
3

superblock:超级块。它保存整个文件系统的总体信息,比如一共有多少块、inode 从哪里开始、日志区在哪里。读文件系统之前,内核要先知道这张“地图”。

inode:文件的身份和元数据。它保存类型、大小、数据块地址等。目录通过名字找到 inode 编号,inode 再找到数据块。

directory:目录本质上也是一种文件。它的内容不是普通文本,而是一条条“名字 -> inode 编号”的目录项。

log:日志用于崩溃恢复。文件系统修改通常要改多个磁盘块,如果只改了一半就崩溃,磁盘会处于不一致状态。日志让一组修改要么整体生效,要么整体不生效。

buffer cache:磁盘块缓存。内核不会每次都直接操作磁盘,而是先把块读到内存中的 struct buf。buffer cache 同时承担缓存、同步和块级访问入口的角色。

2. 最简单心智模型 ​

先别把文件系统想成一团细节。

把它想成一条从名字到块的链。

图:

text
path name
  |
  v
directory entries
  |
  v
inode number
  |
  v
inode metadata
  |
  v
data block numbers
  |
  v
disk blocks
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

文件系统的核心不是“存文件内容”。

而是“管理关系”。

关系包括:

  • 名字和 inode 的关系。
  • inode 和数据块的关系。
  • 目录和子目录的关系。
  • 修改顺序和崩溃恢复的关系。
  • 内存缓存和磁盘一致性的关系。

如果只记一个句子,可以记这个:

text
xv6 文件系统 = 目录查名字 + inode 管元数据 + 日志保完整性 + buffer cache 提性能
1

3. 涉及的 xv6 源码文件 ​

本篇主要涉及:

text
kernel/fs.h
kernel/fs.c
kernel/log.c
kernel/bio.c
kernel/buf.h
kernel/file.c
kernel/sysfile.c
kernel/param.h
kernel/defs.h
kernel/stat.h
1
2
3
4
5
6
7
8
9
10

kernel/fs.h

定义磁盘格式。

包括 superblock、dinode、dirent、块号计算宏。

kernel/fs.c

实现 inode 分配、读写、目录查找、路径查找、块映射、截断。

kernel/log.c

实现事务日志。

保证多步文件系统更新在崩溃后仍可恢复。

kernel/bio.c

实现 buffer cache。

负责缓存磁盘块,给上层提供块级同步点。

kernel/buf.h

定义 struct buf。

kernel/file.c

在文件层调用 begin_op/end_op,把日志事务和文件写入接起来。

kernel/sysfile.c

系统调用入口。

例如 open、link、unlink、mkdir、mknod。

kernel/param.h

定义容量限制,例如 NBUF、NINODE、MAXOPBLOCKS、LOGBLOCKS。

kernel/stat.h

定义文件类型与 struct stat。

4. 文件系统的五层结构 ​

fs.c 文件开头就写了五层:

text
Blocks
Log
Files
Directories
Names
1
2
3
4
5

可以把它翻译成中文:

  • Blocks:最底层,原始磁盘块分配。
  • Log:崩溃恢复。
  • Files:inode 和文件内容。
  • Directories:名字到 inode 的映射。
  • Names:路径字符串解析。

图:

text
+--------------------------------------------------+
| Names:  /usr/rtm/xv6/fs.c                        |
+--------------------------------------------------+
| Directories: dirent[name -> inum]                |
+--------------------------------------------------+
| Files: inode metadata + data block mapping       |
+--------------------------------------------------+
| Log: transactional redo log                      |
+--------------------------------------------------+
| Blocks: bitmap allocator for raw disk blocks     |
+--------------------------------------------------+
1
2
3
4
5
6
7
8
9
10
11

这五层不是纯理论分类。

它们对应源码里的函数边界。

越往下越接近磁盘。

越往上越接近用户视角。

5. 磁盘布局 ​

xv6 的磁盘布局在 kernel/fs.h 中定义。

典型顺序是:

text
[ boot block | super block | log | inode blocks | free bit map | data blocks ]
1

图:

text
disk
  |
  +-- block 0: boot block
  +-- block 1: super block
  +-- block 2..logstart+nlog-1: log
  +-- inode blocks
  +-- bitmap blocks
  +-- data blocks
1
2
3
4
5
6
7
8

5.1 术语解释 ​

block 是块。

在 xv6 里,一个磁盘块大小是 BSIZE = 1024 字节。

块是文件系统分配和传输的基本单位。

superblock 是超级块。

它保存整个文件系统布局信息。

inode block 是保存一批磁盘 inode 的块。

bitmap block 是位图块。

它记录哪些数据块已分配。

data block 是真正存放文件内容的块。

5.2 superblock ​

struct superblock 保存:

  • magic
  • size
  • nblocks
  • ninodes
  • nlog
  • logstart
  • inodestart
  • bmapstart

它的职责是告诉内核:

text
where is log?
where are inode blocks?
where is bitmap?
where do data blocks begin?
1
2
3
4

fsinit() 会读超级块并验证 FSMAGIC。

如果 magic 不对,文件系统可能根本不是 xv6 格式。

6. 磁盘 inode 和内存 inode ​

fs.h 里定义了 struct dinode。

这是磁盘上的 inode 格式。

字段:

text
type
major
minor
nlink
size
addrs[]
1
2
3
4
5
6

fs.c 里定义了 struct inode。

这是内存中的 inode 缓存对象。

它比磁盘版多了:

  • ref
  • lock
  • valid

图:

text
disk:
  struct dinode

memory:
  struct inode
    +-- ref
    +-- lock
    +-- valid
    +-- cached metadata
1
2
3
4
5
6
7
8
9

为什么要分两份?

因为磁盘结构要稳定。

内存结构要服务并发和缓存。

struct inode 的 valid 表示磁盘信息是否已经读入内存。

ilock() 时如果 valid == 0,就会从磁盘读取。

这叫懒加载。

7. inode 的职责 ​

inode 负责的不是名字。

它负责的是文件的元数据和内容块地址。

常见职责:

text
inode
  |
  +-- file type
  +-- size
  +-- link count
  +-- major/minor for device nodes
  +-- data block pointers
1
2
3
4
5
6
7

在 xv6 里,一个 inode 可以表示:

  • 普通文件。
  • 目录。
  • 设备节点。

inode 不表示管道。

管道是另一个后端。

inode 也不直接表示路径。

路径由目录项组成。

8. inode 编号和目录项 ​

inode 通过 inode number,也就是 inum 来标识。

磁盘上的 inode 是按序排在 inode blocks 里的。

块号计算由宏处理:

c
IBLOCK(i, sb)
1

这表示 inode i 位于哪个磁盘块。

目录项 struct dirent 保存:

text
inum
name[DIRSIZ]
1
2

图:

text
directory block
  |
  +-- [inum=2, name="README"]
  +-- [inum=8, name="console"]
  +-- [inum=15, name="x"]
1
2
3
4
5

目录就是“名字到 inode 编号”的表。

文件系统路径解析就是连续查目录表。

9. 目录为什么是文件 ​

目录本身也是一种 inode。

它的 type 是 T_DIR。

目录内容是若干 dirent。

这有个好处。

文件系统只要会读写文件内容,就能读写目录。

但是目录需要额外的语义规则:

  • 不能随便丢弃 . 和 ..。
  • 同一目录中名字不能重复。
  • 删除目录前必须为空。

所以目录是“文件的一种”,但不是普通字节文件。

图:

text
directory inode
  |
  +-- data block 0 -> dirent list
  +-- data block 1 -> dirent list
1
2
3
4

10. 目录项里的 "." 和 ".." ​

"." 表示当前目录。

".." 表示父目录。

它们让路径遍历和回退变得简单。

例如:

text
/a/b/c
1

在进入 c 之前,内核可以通过 .. 回到 b。

xv6 在创建目录时会自动写入这两个条目。

mkdir 的实现里会注意:

  • 给新目录写 "."。
  • 给新目录写 ".."。
  • 给父目录增加一次链接计数,因为子目录的 ".." 指回它。

这就是为什么目录的 nlink 语义比普通文件更绕。

11. 块分配器:balloc 和 bfree ​

文件系统要能分配数据块。

也要能释放数据块。

这在 fs.c 中由 balloc() 和 bfree() 完成。

它们操作的是位图。

位图的一位对应一个块。

图:

text
bitmap byte
  |
  +-- bit 0 -> block 0
  +-- bit 1 -> block 1
  +-- bit 2 -> block 2
1
2
3
4
5

balloc() 会从位图里找一个 0 位。

找到后把它置 1。

然后把对应数据块清零。

bfree() 会把位图里的那一位重新清 0。

为什么要清零?

避免新文件看到旧文件残留数据。

这是简单但很重要的安全习惯。

12. bmap:逻辑块号到物理块号 ​

文件内容不是连续放在磁盘上的。

inode 里只保存块地址。

bmap(ip, bn) 负责把文件中的第 bn 个逻辑块映射到磁盘块号。

这里有两种寻址:

text
direct blocks
  |
  +-- ip->addrs[0..NDIRECT-1]

indirect block
  |
  +-- ip->addrs[NDIRECT] -> a block full of more block numbers
1
2
3
4
5
6
7

图:

text
inode
  |
  +-- addrs[0]  -> data block
  +-- addrs[1]  -> data block
  ...
  +-- addrs[11] -> data block
  +-- addrs[12] -> indirect block -> many more data blocks
1
2
3
4
5
6
7

bmap() 的职责:

text
logical block number -> disk block number
1

如果对应块不存在,它会分配。

所以 bmap() 不是只读查询。

它可能修改 inode 的地址数组。

这也是它必须在 inode 锁保护下工作的原因。

13. readi 和 writei ​

readi() 从 inode 读数据。

writei() 往 inode 写数据。

它们的共同前提是:

text
caller must hold ip->lock
1

这是为了保证 inode 的 size、addrs[]、type 等字段一致。

13.1 readi ​

readi() 会先检查偏移和长度是否越界。

然后按块循环:

  • 找到对应数据块。
  • bread() 取缓存块。
  • 复制到目标地址。
  • brelse() 释放 buffer。

13.2 writei ​

writei() 先检查最大文件大小。

然后按块循环:

  • 通过 bmap() 确保块存在。
  • bread() 取得块。
  • 把用户数据拷进缓存块。
  • log_write() 标记这个块会被提交。
  • brelse()。

最后如果写大了,更新 ip->size。

再调用 iupdate() 把 inode 元数据写回磁盘。

图:

text
readi/writei
  |
  +-- ip->lock
  +-- bmap
  +-- bread
  +-- copy data
  +-- log_write on dirty blocks
  +-- brelse
  +-- iupdate when needed
1
2
3
4
5
6
7
8
9

14. buffer cache 是什么 ​

buffer cache 是磁盘块缓存。

它缓存最近读写过的块。

这样可以减少磁盘访问。

更重要的是,它提供块级同步点。

struct buf 定义在 buf.h。

字段:

text
valid
disk
dev
blockno
lock
refcnt
prev
next
data[BSIZE]
1
2
3
4
5
6
7
8
9

它表示一个具体磁盘块的内存副本。

图:

text
disk block 123
  |
  v
struct buf
  |
  +-- cached data[1024]
  +-- sleep lock for exclusive use
  +-- refcnt for cache pinning
1
2
3
4
5
6
7
8

buffer cache 的两把锁常常被一起提到。

全局 bcache.lock 是 spinlock。

每个 buf 自己有 sleeplock。

前者保护缓存链表和引用计数。

后者保护某一个块的内容。

15. bread、bwrite、brelse ​

bread(dev, blockno) 返回一个上锁的 buffer。

如果缓存里已经有这个块,就直接返回。

否则从最少最近使用的空 buffer 里挑一个。

如果 buffer 内容还没读入,就调用设备驱动把块读进来。

bwrite(bp) 把 buffer 内容写回磁盘。

注:在 xv6 里,通常只有日志层调用 bwrite()。

brelse(bp) 释放 buffer。

它会:

  • 解锁该 buffer。
  • 减少引用计数。
  • 如果引用降到 0,就把它放回 LRU 链表头部。

图:

text
bread
  |
  +-- search cache
  +-- maybe read from disk
  +-- return locked buf

brelse
  |
  +-- unlock buf
  +-- dec refcnt
  +-- maybe move to MRU head
1
2
3
4
5
6
7
8
9
10
11

16. 为什么 buffer cache 需要 LRU ​

磁盘很慢。

内存很快。

所以最近用过的块最好留在内存里。

LRU 表示 least recently used,最近最少使用。

xv6 用链表维护缓存块的热度顺序。

链表头表示最近用过。

链表尾表示最久没用。

当需要回收空 buffer 时,优先从尾部找。

这是一个很常见的缓存策略。

它不完美,但简单且有效。

17. log 的职责 ​

文件系统不是只要“最后结果对”。

它还要避免中途崩溃后文件系统处于半更新状态。

例如:

text
allocate inode
write data block
update directory entry
update inode size
1
2
3
4

如果在中间断电,可能出现:

  • 目录项已经指向新 inode,但 inode 还没写好。
  • 数据块已经分配,但 bitmap 还没更新。
  • inode size 更新了,但数据块还没写进。

这类问题叫撕裂更新。

日志的职责就是把一组相关更新当成事务。

要么全部生效。

要么重启后全部回放到完整状态。

18. 什么是事务日志 ​

事务日志是一种崩溃恢复技术。

英文是 transaction log 或 journaling。

xv6 用的是物理 redo log。

意思是:

  • 先把要改的磁盘块内容写到日志区。
  • 再把日志头写到磁盘。
  • 头写成功后,这个事务就算提交。
  • 然后把日志里的块复制到它们的最终位置。
  • 最后清空日志头。

图:

text
modify cached blocks
  |
  v
write log blocks
  |
  v
write log header   <-- commit point
  |
  v
install to home locations
  |
  v
clear log header
1
2
3
4
5
6
7
8
9
10
11
12
13

“redo” 的意思是重做。

崩溃恢复时,如果日志头显示有未完成事务,就把日志里的内容再写回家。

19. begin_op 和 end_op ​

begin_op() 和 end_op() 是文件系统系统调用的边界标记。

用户态一次文件系统操作通常会包进这对调用。

例如 sys_open、sys_link、sys_unlink、sys_write 都会在合适位置开始和结束事务。

begin_op() 做的事:

  • 等待当前日志空间够用。
  • 增加 outstanding。

end_op() 做的事:

  • 减少 outstanding。
  • 如果这是最后一个活动操作,就触发 commit()。

图:

text
FS syscall
  |
  +-- begin_op
  |
  +-- modify in-memory blocks
  |
  +-- log_write dirty buffers
  |
  +-- end_op
         |
         +-- maybe commit
1
2
3
4
5
6
7
8
9
10
11

这说明日志不是每次写一个块就立即同步。

它是按事务批量提交。

20. commit 的四步 ​

commit() 在 log.c 里。

它通常包含四步:

  1. write_log() 把缓存块写入日志区。
  2. write_head() 把事务头写到日志头块。
  3. install_trans() 把日志内容复制到最终位置。
  4. 再次 write_head() 清空日志。

图:

text
cache -> log blocks -> header -> home blocks -> clear header
1

为什么把头写入看作真正提交点?

因为如果在写完日志块后崩溃,但头还没写,恢复时会把它当作未提交事务忽略。

如果头写了,就说明这批更新在逻辑上已经完成。

即使之后在安装到 home blocks 的路上崩溃,也可以靠日志重放恢复。

21. recover_from_log ​

系统启动时,initlog() 会调用恢复逻辑。

recover_from_log() 做三件事:

  • 读日志头。
  • 把日志里的事务安装到 home 位置。
  • 清空日志头。

图:

text
boot
  |
  v
read header
  |
  +-- no committed txn -> nothing to do
  |
  +-- committed txn exists -> replay
  |
  v
clear header
1
2
3
4
5
6
7
8
9
10
11

这是 redo log 的核心。

恢复不需要回滚。

只需要重做已经提交但还没完全安装的内容。

22. log_write 的职责 ​

log_write(bp) 不是简单的“写盘”。

它做的是“记录这个 buffer 在本次事务中被修改过”。

然后把它 pin 住。

pin 住的意思是:

这个块在事务结束前不能从缓存里被回收。

为什么?

因为后面的 write_log() 还要读它。

如果它提前被挤掉,就会出错。

log_write() 还有一个优化叫 log absorption。

如果同一个块在同一个事务里被多次修改,只记录一次即可。

这样日志里不会重复存多个版本。

23. 日志和 buffer cache 的关系 ​

日志并不是直接操作磁盘。

它先操作缓存中的 buffer。

实际过程是:

text
read block into buf
modify buf->data
log_write(buf)
later commit copies buf to log
later install_trans copies log to home
1
2
3
4
5

也就是说:

  • buffer cache 提供“块的内存副本”。
  • log 提供“修改的持久化顺序”。

这两层分工很重要。

如果没有 buffer cache,日志每次都要自己处理块缓存。

如果没有日志,buffer cache 只能加速,不能保证崩溃一致性。

24. 为什么 inode 更新也要进日志 ​

inode 也是磁盘块的一部分。

修改 inode 元数据时,实际是修改包含该 inode 的磁盘块。

例如:

  • 新增 block address。
  • 更新 size。
  • 更新 nlink。

这些变化也必须事务化。

因为如果只写数据块,不写 inode,文件内容就找不到了。

如果只写 inode,不写数据块,文件内容就可能指向垃圾。

所以 inode 更新也是 log 的对象。

iupdate() 会把内存 inode 的字段写回磁盘 inode 块,并通过 log_write() 记录。

25. 为什么目录更新也要进日志 ​

目录项也是磁盘块内容。

例如 dirlink() 会把一个新的 (name, inum) 写进目录所在的数据块。

unlink() 会把目录项清空。

mkdir() 会创建 "." 和 ".."。

这些都不是单独的元数据字段。

它们是目录文件的数据内容。

所以目录操作必须走日志。

图:

text
mkdir
  |
  +-- allocate inode
  +-- write "." entry
  +-- write ".." entry
  +-- link name in parent
  +-- update parent nlink
1
2
3
4
5
6
7

任何一步崩溃,都可能造成树结构不一致。

日志把这几步捆在一起。

26. 目录查找:namei 和 dirlookup ​

路径解析是从名字走到 inode 的过程。

核心函数是 namei() 和 nameiparent()。

它们最终都走到 namex()。

namex() 会:

  • 从根目录或当前工作目录开始。
  • 用 skipelem() 切出路径片段。
  • 对每一级目录调用 dirlookup()。
  • 找到最终 inode 或父目录 inode。

dirlookup(dp, name, poff) 在目录 dp 中查找名字 name。

如果找到,返回对应 inode。

如果没找到,返回 0。

图:

text
path /a/b/c
  |
  v
namex
  |
  +-- dirlookup(root, "a")
  +-- dirlookup(inode(a), "b")
  +-- dirlookup(inode(b), "c")
1
2
3
4
5
6
7
8

目录查找的本质就是线性扫描 dirent。

这很朴素。

也很慢。

但足够教学。

27. 目录插入:dirlink ​

dirlink(dp, name, inum) 把一个新目录项插入目录。

它先检查名字是否已经存在。

然后找一个空槽位。

最后写入新的 dirent。

这也必须在事务里完成。

因为:

  • 目录项写进去前,inode 可能已经分配。
  • inode 分配后,目录项没写进去,文件会变成孤儿。

日志的作用就是让这些步骤要么一起出现,要么一起不出现。

28. unlink 的完整含义 ​

unlink(path) 删除名字。

它不会立刻删除内容。

它会:

  • 找到父目录。
  • 找到目录项对应 inode。
  • 检查不能删 . 和 ..。
  • 如果是目录,还要检查目录是否为空。
  • 把目录项清零。
  • 减少目标 inode 的 nlink。
  • 如果删的是目录,还要减少父目录的 nlink。

图:

text
unlink
  |
  +-- remove name from parent dir
  +-- decrement target nlink
  +-- maybe decrement parent nlink
  +-- if nlink == 0 and no refs left -> free inode later
1
2
3
4
5
6

删除名字和回收存储是两个阶段。

这就是 Unix 文件删除语义的精髓。

29. create 的角色 ​

create(path, type, major, minor) 用来创建新 inode。

它可能被 open(O_CREATE)、mkdir、mknod 复用。

流程:

  • 找到父目录。
  • 看名字是否已存在。
  • 如果存在,按规则返回已有 inode 或失败。
  • 如果不存在,调用 ialloc() 分配新 inode。
  • 填 type、major、minor、nlink。
  • 如果是目录,写入 "." 和 ".."。
  • 把新 inode 链接到父目录。

这里的“创建”包含多个对象:

text
new inode
parent directory entry
possibly dot entries
possibly link count changes
1
2
3
4

所以 create() 本质上是一个小事务编排器。

30. ialloc 和 iput ​

ialloc(dev, type) 在 inode 表中找空 inode。

它把磁盘上的 dinode->type 置为非零。

这表示 inode 被占用了。

然后返回对应的内存 inode。

iput(ip) 释放 inode 引用。

如果这是最后一个引用,并且 nlink == 0,它还会真正回收 inode 内容和磁盘条目。

图:

text
ialloc
  |
  +-- allocate on disk
  +-- return referenced inode

iput
  |
  +-- drop ref
  +-- maybe truncate blocks
  +-- maybe free inode on disk
1
2
3
4
5
6
7
8
9
10

iput() 必须在事务内调用。

因为它可能触发块释放、inode 清空等一系列磁盘修改。

31. itrunc:截断文件 ​

itrunc(ip) 释放 inode 持有的所有数据块。

它会:

  • 释放直接块。
  • 读取间接块。
  • 释放间接块指向的数据块。
  • 释放间接块本身。
  • 把 size 设为 0。
  • 更新 inode。

图:

text
inode
  |
  +-- direct blocks -> free
  +-- indirect block -> free its children -> free itself
1
2
3
4

截断不是简单把 size 设 0。

必须把块归还给位图。

否则文件系统会漏块。

32. buffer cache 的同步点意义 ​

buffer cache 不只是缓存。

它还是一个同步点。

例如两个进程同时修改同一个块。

因为 buffer 具有独占 sleeplock,所以它们不会直接并发写同一块内容。

如果一个进程 bread() 了某块,它会拿到上锁 buffer。

另一个进程想要同一块,就必须等待。

这让块级修改变成串行化。

图:

text
process A -> bread(block 5) -> holds buf lock
process B -> bread(block 5) -> sleeps
process A -> brelse
process B -> wakes and continues
1
2
3
4

这也是为什么文件系统代码经常围绕 bread/brelse 展开。

33. 日志事务的并发控制 ​

begin_op/end_op/log_write 不是各干各的。

它们共同维持日志空间和事务边界。

begin_op() 会检查:

  • 当前是否正在 commit。
  • 预留日志空间是否足够。

如果不够,就睡眠。

end_op() 如果发现自己是最后一个活动系统调用,就触发 commit。

log_write() 只能在事务内调用。

这是为了保证所有被记录的块都属于某个活跃事务。

图:

text
begin_op
  |
  +-- reserve space
  +-- maybe sleep

log_write
  |
  +-- record dirty block
  +-- pin buffer

end_op
  |
  +-- maybe commit
1
2
3
4
5
6
7
8
9
10
11
12
13

34. 为什么 log 有容量限制 ​

日志区大小有限。

如果一个事务太大,可能放不下。

所以 xv6 使用 MAXOPBLOCKS 和 LOGBLOCKS 做空间预估。

MAXOPBLOCKS 不是说每个系统调用必定修改这么多块。

它是一个保守上界。

这样 begin_op() 可以提前判断:

text
if all currently outstanding ops together could still fit, proceed
else sleep until commit frees space
1
2

这是典型的保守资源预留策略。

宁可少并发一点,也不要把日志撑爆。

35. 为什么 commit 只在 end_op 后发生 ​

如果还有文件系统系统调用在进行,就不能随便提交日志。

否则一个事务可能把另一个事务的未完成修改写出去。

那就破坏了事务边界。

所以 xv6 选择:

text
commit only when outstanding == 0
1

这会让多个系统调用共享一次提交。

好处是简单。

坏处是吞吐量不是最高。

但对于教学内核,这个取舍很合理。

36. 常见误解 ​

误解一:日志就是把所有写请求直接写两遍。

不是。

它是先写日志区,再安装到 home blocks。

误解二:日志保证所有代码都线程安全。

不是。

它只保证文件系统更新的崩溃一致性。

误解三:buffer cache 和日志是同一个东西。

不是。

buffer cache 管块缓存。

日志管事务和恢复。

误解四:目录就是一个字符串表。

不完全对。

目录是 inode 里的数据文件,内容是 dirent。

误解五:inode 就是文件名。

不是。

inode 是文件本体的元数据。

名字在目录里。

误解六:unlink 会立即物理删除文件内容。

不是。

它只是减少链接计数,真正释放要等引用归零。

37. 读源码的顺序建议 ​

如果想顺着理解文件系统,推荐这样读:

text
fs.h
  -> 磁盘格式
fs.c
  -> inode / directory / path / blocks
bio.c + buf.h
  -> block cache
log.c
  -> transaction and recovery
sysfile.c
  -> system call entry points
file.c
  -> how file layer uses fs + log
1
2
3
4
5
6
7
8
9
10
11
12

这个顺序比直接从 sys_open() 乱跳更稳。

因为你会先知道每层在干什么。

38. 小实验一:跟踪一次 open(O_CREATE) ​

写一个程序创建文件。

观察调用链:

text
sys_open
  |
  +-- begin_op
  +-- create
       +-- nameiparent
       +-- ialloc
       +-- dirlink
  +-- filealloc
  +-- fdalloc
  +-- end_op
1
2
3
4
5
6
7
8
9
10

你会看到:

  • 父目录被修改。
  • 新 inode 被分配。
  • 日志记录了这些块。

如果在中间断电,恢复后文件系统仍然应该一致。

39. 小实验二:观察 unlink 后的 nlink ​

写一个程序:

text
open file
write data
unlink file
keep fd open
fstat fd
close fd
1
2
3
4
5
6

观察 nlink。

它会在 unlink() 后下降。

但文件仍可访问。

这说明链接计数和打开引用是不同的。

40. 小实验三:目录空不空 ​

创建一个目录,再在里面放一个文件。

然后尝试删除这个目录。

应该失败。

因为 unlink() 会检查目录是否空。

你可以在 isdirempty() 周围加少量打印。

观察它是如何跳过 "." 和 ".." 的。

41. 小实验四:只读观察 bmap ​

在 bmap() 附近打印逻辑块号和返回块号。

执行一个连续写入的大文件。

观察:

text
logical block 0 -> direct addr
logical block 1 -> direct addr
...
logical block 12 -> indirect block entry
1
2
3
4

这能帮助你理解 direct 和 indirect 的分界。

42. 工程取舍 ​

xv6 文件系统故意保持简单。

它没有:

  • 多级日志。
  • 延迟分配的复杂策略。
  • B+ 树元数据结构。
  • 写时复制。
  • 快照。
  • 文件系统缓存回写后台线程。
  • 复杂的目录索引。

这些都让实现更接近教科书。

但它仍然展示了三个核心工程问题:

  • 命名和元数据如何分离。
  • 缓存和同步如何共存。
  • 崩溃一致性如何通过日志维持。

这三个问题在更大的系统里仍然存在。

只是形式更复杂。

43. 本篇总结 ​

文件系统最底层是块。

块之上是 buffer cache。

块之上的持久化更新由日志保护。

inode 把块组织成文件。

目录把名字映射到 inode。

路径把多级目录串起来。

最终主线是:

text
name -> directory -> inode -> blocks
1

再加上一层:

text
modify blocks -> log transaction -> crash recovery
1

这两条线合起来,就是 xv6 文件系统的骨架。

下一篇进入:

text
14-device-driver-console-uart-disk.md
1

那里会看 console、uart、plic 和 virtio_disk 如何把中断、输入输出和磁盘请求接起来。

最后更新于:

Pager
上一篇13. xv6 文件描述符与 inode / xv6 File Descriptor And Inode
下一篇15. xv6 设备驱动、console、UART 与磁盘 / xv6 Device Driver Console UART And Disk

持续记录,持续成长

Copyright © Tidenflow