xv6 文件系统与日志 / xv6 File System And Log
1. 这篇文章解决什么问题
文件系统的目标很朴素。
它要把“文件名”稳定地映射到“文件内容”。
它还要在断电或崩溃后尽量不坏。
它还要让多个进程并发访问时不互相踩坏数据。
这三个目标放在一起,就不朴素了。
xv6 的做法是把文件系统拆成几层:
names
|
v
directories
|
v
inodes
|
v
blocks
|
v
log这篇文章聚焦:
- 磁盘布局是什么。
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 表示块大小。
------- disk image -------+
| block 0 | block 1 | ... |
+---------+---------+-----+superblock:超级块。它保存整个文件系统的总体信息,比如一共有多少块、inode 从哪里开始、日志区在哪里。读文件系统之前,内核要先知道这张“地图”。
inode:文件的身份和元数据。它保存类型、大小、数据块地址等。目录通过名字找到 inode 编号,inode 再找到数据块。
directory:目录本质上也是一种文件。它的内容不是普通文本,而是一条条“名字 -> inode 编号”的目录项。
log:日志用于崩溃恢复。文件系统修改通常要改多个磁盘块,如果只改了一半就崩溃,磁盘会处于不一致状态。日志让一组修改要么整体生效,要么整体不生效。
buffer cache:磁盘块缓存。内核不会每次都直接操作磁盘,而是先把块读到内存中的 struct buf。buffer cache 同时承担缓存、同步和块级访问入口的角色。
2. 最简单心智模型
先别把文件系统想成一团细节。
把它想成一条从名字到块的链。
图:
path name
|
v
directory entries
|
v
inode number
|
v
inode metadata
|
v
data block numbers
|
v
disk blocks文件系统的核心不是“存文件内容”。
而是“管理关系”。
关系包括:
- 名字和 inode 的关系。
- inode 和数据块的关系。
- 目录和子目录的关系。
- 修改顺序和崩溃恢复的关系。
- 内存缓存和磁盘一致性的关系。
如果只记一个句子,可以记这个:
xv6 文件系统 = 目录查名字 + inode 管元数据 + 日志保完整性 + buffer cache 提性能3. 涉及的 xv6 源码文件
本篇主要涉及:
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.hkernel/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 文件开头就写了五层:
Blocks
Log
Files
Directories
Names可以把它翻译成中文:
Blocks:最底层,原始磁盘块分配。Log:崩溃恢复。Files:inode 和文件内容。Directories:名字到 inode 的映射。Names:路径字符串解析。
图:
+--------------------------------------------------+
| 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 |
+--------------------------------------------------+这五层不是纯理论分类。
它们对应源码里的函数边界。
越往下越接近磁盘。
越往上越接近用户视角。
5. 磁盘布局
xv6 的磁盘布局在 kernel/fs.h 中定义。
典型顺序是:
[ boot block | super block | log | inode blocks | free bit map | data blocks ]图:
disk
|
+-- block 0: boot block
+-- block 1: super block
+-- block 2..logstart+nlog-1: log
+-- inode blocks
+-- bitmap blocks
+-- data blocks5.1 术语解释
block 是块。
在 xv6 里,一个磁盘块大小是 BSIZE = 1024 字节。
块是文件系统分配和传输的基本单位。
superblock 是超级块。
它保存整个文件系统布局信息。
inode block 是保存一批磁盘 inode 的块。
bitmap block 是位图块。
它记录哪些数据块已分配。
data block 是真正存放文件内容的块。
5.2 superblock
struct superblock 保存:
magicsizenblocksninodesnloglogstartinodestartbmapstart
它的职责是告诉内核:
where is log?
where are inode blocks?
where is bitmap?
where do data blocks begin?fsinit() 会读超级块并验证 FSMAGIC。
如果 magic 不对,文件系统可能根本不是 xv6 格式。
6. 磁盘 inode 和内存 inode
fs.h 里定义了 struct dinode。
这是磁盘上的 inode 格式。
字段:
type
major
minor
nlink
size
addrs[]fs.c 里定义了 struct inode。
这是内存中的 inode 缓存对象。
它比磁盘版多了:
reflockvalid
图:
disk:
struct dinode
memory:
struct inode
+-- ref
+-- lock
+-- valid
+-- cached metadata为什么要分两份?
因为磁盘结构要稳定。
内存结构要服务并发和缓存。
struct inode 的 valid 表示磁盘信息是否已经读入内存。
ilock() 时如果 valid == 0,就会从磁盘读取。
这叫懒加载。
7. inode 的职责
inode 负责的不是名字。
它负责的是文件的元数据和内容块地址。
常见职责:
inode
|
+-- file type
+-- size
+-- link count
+-- major/minor for device nodes
+-- data block pointers在 xv6 里,一个 inode 可以表示:
- 普通文件。
- 目录。
- 设备节点。
inode 不表示管道。
管道是另一个后端。
inode 也不直接表示路径。
路径由目录项组成。
8. inode 编号和目录项
inode 通过 inode number,也就是 inum 来标识。
磁盘上的 inode 是按序排在 inode blocks 里的。
块号计算由宏处理:
IBLOCK(i, sb)这表示 inode i 位于哪个磁盘块。
目录项 struct dirent 保存:
inum
name[DIRSIZ]图:
directory block
|
+-- [inum=2, name="README"]
+-- [inum=8, name="console"]
+-- [inum=15, name="x"]目录就是“名字到 inode 编号”的表。
文件系统路径解析就是连续查目录表。
9. 目录为什么是文件
目录本身也是一种 inode。
它的 type 是 T_DIR。
目录内容是若干 dirent。
这有个好处。
文件系统只要会读写文件内容,就能读写目录。
但是目录需要额外的语义规则:
- 不能随便丢弃
.和..。 - 同一目录中名字不能重复。
- 删除目录前必须为空。
所以目录是“文件的一种”,但不是普通字节文件。
图:
directory inode
|
+-- data block 0 -> dirent list
+-- data block 1 -> dirent list10. 目录项里的 "." 和 ".."
"." 表示当前目录。
".." 表示父目录。
它们让路径遍历和回退变得简单。
例如:
/a/b/c在进入 c 之前,内核可以通过 .. 回到 b。
xv6 在创建目录时会自动写入这两个条目。
mkdir 的实现里会注意:
- 给新目录写
"."。 - 给新目录写
".."。 - 给父目录增加一次链接计数,因为子目录的
".."指回它。
这就是为什么目录的 nlink 语义比普通文件更绕。
11. 块分配器:balloc 和 bfree
文件系统要能分配数据块。
也要能释放数据块。
这在 fs.c 中由 balloc() 和 bfree() 完成。
它们操作的是位图。
位图的一位对应一个块。
图:
bitmap byte
|
+-- bit 0 -> block 0
+-- bit 1 -> block 1
+-- bit 2 -> block 2balloc() 会从位图里找一个 0 位。
找到后把它置 1。
然后把对应数据块清零。
bfree() 会把位图里的那一位重新清 0。
为什么要清零?
避免新文件看到旧文件残留数据。
这是简单但很重要的安全习惯。
12. bmap:逻辑块号到物理块号
文件内容不是连续放在磁盘上的。
inode 里只保存块地址。
bmap(ip, bn) 负责把文件中的第 bn 个逻辑块映射到磁盘块号。
这里有两种寻址:
direct blocks
|
+-- ip->addrs[0..NDIRECT-1]
indirect block
|
+-- ip->addrs[NDIRECT] -> a block full of more block numbers图:
inode
|
+-- addrs[0] -> data block
+-- addrs[1] -> data block
...
+-- addrs[11] -> data block
+-- addrs[12] -> indirect block -> many more data blocksbmap() 的职责:
logical block number -> disk block number如果对应块不存在,它会分配。
所以 bmap() 不是只读查询。
它可能修改 inode 的地址数组。
这也是它必须在 inode 锁保护下工作的原因。
13. readi 和 writei
readi() 从 inode 读数据。
writei() 往 inode 写数据。
它们的共同前提是:
caller must hold ip->lock这是为了保证 inode 的 size、addrs[]、type 等字段一致。
13.1 readi
readi() 会先检查偏移和长度是否越界。
然后按块循环:
- 找到对应数据块。
bread()取缓存块。- 复制到目标地址。
brelse()释放 buffer。
13.2 writei
writei() 先检查最大文件大小。
然后按块循环:
- 通过
bmap()确保块存在。 bread()取得块。- 把用户数据拷进缓存块。
log_write()标记这个块会被提交。brelse()。
最后如果写大了,更新 ip->size。
再调用 iupdate() 把 inode 元数据写回磁盘。
图:
readi/writei
|
+-- ip->lock
+-- bmap
+-- bread
+-- copy data
+-- log_write on dirty blocks
+-- brelse
+-- iupdate when needed14. buffer cache 是什么
buffer cache 是磁盘块缓存。
它缓存最近读写过的块。
这样可以减少磁盘访问。
更重要的是,它提供块级同步点。
struct buf 定义在 buf.h。
字段:
valid
disk
dev
blockno
lock
refcnt
prev
next
data[BSIZE]它表示一个具体磁盘块的内存副本。
图:
disk block 123
|
v
struct buf
|
+-- cached data[1024]
+-- sleep lock for exclusive use
+-- refcnt for cache pinningbuffer 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 链表头部。
图:
bread
|
+-- search cache
+-- maybe read from disk
+-- return locked buf
brelse
|
+-- unlock buf
+-- dec refcnt
+-- maybe move to MRU head16. 为什么 buffer cache 需要 LRU
磁盘很慢。
内存很快。
所以最近用过的块最好留在内存里。
LRU 表示 least recently used,最近最少使用。
xv6 用链表维护缓存块的热度顺序。
链表头表示最近用过。
链表尾表示最久没用。
当需要回收空 buffer 时,优先从尾部找。
这是一个很常见的缓存策略。
它不完美,但简单且有效。
17. log 的职责
文件系统不是只要“最后结果对”。
它还要避免中途崩溃后文件系统处于半更新状态。
例如:
allocate inode
write data block
update directory entry
update inode size如果在中间断电,可能出现:
- 目录项已经指向新 inode,但 inode 还没写好。
- 数据块已经分配,但 bitmap 还没更新。
- inode size 更新了,但数据块还没写进。
这类问题叫撕裂更新。
日志的职责就是把一组相关更新当成事务。
要么全部生效。
要么重启后全部回放到完整状态。
18. 什么是事务日志
事务日志是一种崩溃恢复技术。
英文是 transaction log 或 journaling。
xv6 用的是物理 redo log。
意思是:
- 先把要改的磁盘块内容写到日志区。
- 再把日志头写到磁盘。
- 头写成功后,这个事务就算提交。
- 然后把日志里的块复制到它们的最终位置。
- 最后清空日志头。
图:
modify cached blocks
|
v
write log blocks
|
v
write log header <-- commit point
|
v
install to home locations
|
v
clear log header“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()。
图:
FS syscall
|
+-- begin_op
|
+-- modify in-memory blocks
|
+-- log_write dirty buffers
|
+-- end_op
|
+-- maybe commit这说明日志不是每次写一个块就立即同步。
它是按事务批量提交。
20. commit 的四步
commit() 在 log.c 里。
它通常包含四步:
write_log()把缓存块写入日志区。write_head()把事务头写到日志头块。install_trans()把日志内容复制到最终位置。- 再次
write_head()清空日志。
图:
cache -> log blocks -> header -> home blocks -> clear header为什么把头写入看作真正提交点?
因为如果在写完日志块后崩溃,但头还没写,恢复时会把它当作未提交事务忽略。
如果头写了,就说明这批更新在逻辑上已经完成。
即使之后在安装到 home blocks 的路上崩溃,也可以靠日志重放恢复。
21. recover_from_log
系统启动时,initlog() 会调用恢复逻辑。
recover_from_log() 做三件事:
- 读日志头。
- 把日志里的事务安装到 home 位置。
- 清空日志头。
图:
boot
|
v
read header
|
+-- no committed txn -> nothing to do
|
+-- committed txn exists -> replay
|
v
clear header这是 redo log 的核心。
恢复不需要回滚。
只需要重做已经提交但还没完全安装的内容。
22. log_write 的职责
log_write(bp) 不是简单的“写盘”。
它做的是“记录这个 buffer 在本次事务中被修改过”。
然后把它 pin 住。
pin 住的意思是:
这个块在事务结束前不能从缓存里被回收。
为什么?
因为后面的 write_log() 还要读它。
如果它提前被挤掉,就会出错。
log_write() 还有一个优化叫 log absorption。
如果同一个块在同一个事务里被多次修改,只记录一次即可。
这样日志里不会重复存多个版本。
23. 日志和 buffer cache 的关系
日志并不是直接操作磁盘。
它先操作缓存中的 buffer。
实际过程是:
read block into buf
modify buf->data
log_write(buf)
later commit copies buf to log
later install_trans copies log to home也就是说:
- 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() 会创建 "." 和 ".."。
这些都不是单独的元数据字段。
它们是目录文件的数据内容。
所以目录操作必须走日志。
图:
mkdir
|
+-- allocate inode
+-- write "." entry
+-- write ".." entry
+-- link name in parent
+-- update parent nlink任何一步崩溃,都可能造成树结构不一致。
日志把这几步捆在一起。
26. 目录查找:namei 和 dirlookup
路径解析是从名字走到 inode 的过程。
核心函数是 namei() 和 nameiparent()。
它们最终都走到 namex()。
namex() 会:
- 从根目录或当前工作目录开始。
- 用
skipelem()切出路径片段。 - 对每一级目录调用
dirlookup()。 - 找到最终 inode 或父目录 inode。
dirlookup(dp, name, poff) 在目录 dp 中查找名字 name。
如果找到,返回对应 inode。
如果没找到,返回 0。
图:
path /a/b/c
|
v
namex
|
+-- dirlookup(root, "a")
+-- dirlookup(inode(a), "b")
+-- dirlookup(inode(b), "c")目录查找的本质就是线性扫描 dirent。
这很朴素。
也很慢。
但足够教学。
27. 目录插入:dirlink
dirlink(dp, name, inum) 把一个新目录项插入目录。
它先检查名字是否已经存在。
然后找一个空槽位。
最后写入新的 dirent。
这也必须在事务里完成。
因为:
- 目录项写进去前,inode 可能已经分配。
- inode 分配后,目录项没写进去,文件会变成孤儿。
日志的作用就是让这些步骤要么一起出现,要么一起不出现。
28. unlink 的完整含义
unlink(path) 删除名字。
它不会立刻删除内容。
它会:
- 找到父目录。
- 找到目录项对应 inode。
- 检查不能删
.和..。 - 如果是目录,还要检查目录是否为空。
- 把目录项清零。
- 减少目标 inode 的
nlink。 - 如果删的是目录,还要减少父目录的
nlink。
图:
unlink
|
+-- remove name from parent dir
+-- decrement target nlink
+-- maybe decrement parent nlink
+-- if nlink == 0 and no refs left -> free inode later删除名字和回收存储是两个阶段。
这就是 Unix 文件删除语义的精髓。
29. create 的角色
create(path, type, major, minor) 用来创建新 inode。
它可能被 open(O_CREATE)、mkdir、mknod 复用。
流程:
- 找到父目录。
- 看名字是否已存在。
- 如果存在,按规则返回已有 inode 或失败。
- 如果不存在,调用
ialloc()分配新 inode。 - 填 type、major、minor、nlink。
- 如果是目录,写入
"."和".."。 - 把新 inode 链接到父目录。
这里的“创建”包含多个对象:
new inode
parent directory entry
possibly dot entries
possibly link count changes所以 create() 本质上是一个小事务编排器。
30. ialloc 和 iput
ialloc(dev, type) 在 inode 表中找空 inode。
它把磁盘上的 dinode->type 置为非零。
这表示 inode 被占用了。
然后返回对应的内存 inode。
iput(ip) 释放 inode 引用。
如果这是最后一个引用,并且 nlink == 0,它还会真正回收 inode 内容和磁盘条目。
图:
ialloc
|
+-- allocate on disk
+-- return referenced inode
iput
|
+-- drop ref
+-- maybe truncate blocks
+-- maybe free inode on diskiput() 必须在事务内调用。
因为它可能触发块释放、inode 清空等一系列磁盘修改。
31. itrunc:截断文件
itrunc(ip) 释放 inode 持有的所有数据块。
它会:
- 释放直接块。
- 读取间接块。
- 释放间接块指向的数据块。
- 释放间接块本身。
- 把
size设为 0。 - 更新 inode。
图:
inode
|
+-- direct blocks -> free
+-- indirect block -> free its children -> free itself截断不是简单把 size 设 0。
必须把块归还给位图。
否则文件系统会漏块。
32. buffer cache 的同步点意义
buffer cache 不只是缓存。
它还是一个同步点。
例如两个进程同时修改同一个块。
因为 buffer 具有独占 sleeplock,所以它们不会直接并发写同一块内容。
如果一个进程 bread() 了某块,它会拿到上锁 buffer。
另一个进程想要同一块,就必须等待。
这让块级修改变成串行化。
图:
process A -> bread(block 5) -> holds buf lock
process B -> bread(block 5) -> sleeps
process A -> brelse
process B -> wakes and continues这也是为什么文件系统代码经常围绕 bread/brelse 展开。
33. 日志事务的并发控制
begin_op/end_op/log_write 不是各干各的。
它们共同维持日志空间和事务边界。
begin_op() 会检查:
- 当前是否正在 commit。
- 预留日志空间是否足够。
如果不够,就睡眠。
end_op() 如果发现自己是最后一个活动系统调用,就触发 commit。
log_write() 只能在事务内调用。
这是为了保证所有被记录的块都属于某个活跃事务。
图:
begin_op
|
+-- reserve space
+-- maybe sleep
log_write
|
+-- record dirty block
+-- pin buffer
end_op
|
+-- maybe commit34. 为什么 log 有容量限制
日志区大小有限。
如果一个事务太大,可能放不下。
所以 xv6 使用 MAXOPBLOCKS 和 LOGBLOCKS 做空间预估。
MAXOPBLOCKS 不是说每个系统调用必定修改这么多块。
它是一个保守上界。
这样 begin_op() 可以提前判断:
if all currently outstanding ops together could still fit, proceed
else sleep until commit frees space这是典型的保守资源预留策略。
宁可少并发一点,也不要把日志撑爆。
35. 为什么 commit 只在 end_op 后发生
如果还有文件系统系统调用在进行,就不能随便提交日志。
否则一个事务可能把另一个事务的未完成修改写出去。
那就破坏了事务边界。
所以 xv6 选择:
commit only when outstanding == 0这会让多个系统调用共享一次提交。
好处是简单。
坏处是吞吐量不是最高。
但对于教学内核,这个取舍很合理。
36. 常见误解
误解一:日志就是把所有写请求直接写两遍。
不是。
它是先写日志区,再安装到 home blocks。
误解二:日志保证所有代码都线程安全。
不是。
它只保证文件系统更新的崩溃一致性。
误解三:buffer cache 和日志是同一个东西。
不是。
buffer cache 管块缓存。
日志管事务和恢复。
误解四:目录就是一个字符串表。
不完全对。
目录是 inode 里的数据文件,内容是 dirent。
误解五:inode 就是文件名。
不是。
inode 是文件本体的元数据。
名字在目录里。
误解六:unlink 会立即物理删除文件内容。
不是。
它只是减少链接计数,真正释放要等引用归零。
37. 读源码的顺序建议
如果想顺着理解文件系统,推荐这样读:
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这个顺序比直接从 sys_open() 乱跳更稳。
因为你会先知道每层在干什么。
38. 小实验一:跟踪一次 open(O_CREATE)
写一个程序创建文件。
观察调用链:
sys_open
|
+-- begin_op
+-- create
+-- nameiparent
+-- ialloc
+-- dirlink
+-- filealloc
+-- fdalloc
+-- end_op你会看到:
- 父目录被修改。
- 新 inode 被分配。
- 日志记录了这些块。
如果在中间断电,恢复后文件系统仍然应该一致。
39. 小实验二:观察 unlink 后的 nlink
写一个程序:
open file
write data
unlink file
keep fd open
fstat fd
close fd观察 nlink。
它会在 unlink() 后下降。
但文件仍可访问。
这说明链接计数和打开引用是不同的。
40. 小实验三:目录空不空
创建一个目录,再在里面放一个文件。
然后尝试删除这个目录。
应该失败。
因为 unlink() 会检查目录是否空。
你可以在 isdirempty() 周围加少量打印。
观察它是如何跳过 "." 和 ".." 的。
41. 小实验四:只读观察 bmap
在 bmap() 附近打印逻辑块号和返回块号。
执行一个连续写入的大文件。
观察:
logical block 0 -> direct addr
logical block 1 -> direct addr
...
logical block 12 -> indirect block entry这能帮助你理解 direct 和 indirect 的分界。
42. 工程取舍
xv6 文件系统故意保持简单。
它没有:
- 多级日志。
- 延迟分配的复杂策略。
- B+ 树元数据结构。
- 写时复制。
- 快照。
- 文件系统缓存回写后台线程。
- 复杂的目录索引。
这些都让实现更接近教科书。
但它仍然展示了三个核心工程问题:
- 命名和元数据如何分离。
- 缓存和同步如何共存。
- 崩溃一致性如何通过日志维持。
这三个问题在更大的系统里仍然存在。
只是形式更复杂。
43. 本篇总结
文件系统最底层是块。
块之上是 buffer cache。
块之上的持久化更新由日志保护。
inode 把块组织成文件。
目录把名字映射到 inode。
路径把多级目录串起来。
最终主线是:
name -> directory -> inode -> blocks再加上一层:
modify blocks -> log transaction -> crash recovery这两条线合起来,就是 xv6 文件系统的骨架。
下一篇进入:
14-device-driver-console-uart-disk.md那里会看 console、uart、plic 和 virtio_disk 如何把中断、输入输出和磁盘请求接起来。