xv6 的日志系统(Logging System)的设计
xv6 的日志系统是一个简洁但功能完备的预写式日志(Write-Ahead Logging, WAL) 实现。
它的核心目标是保证文件系统操作的原子性(Atomicity)从而在系统突然崩溃(如断电)时,保护文件系统不被损坏,维持其一致性(Consistency)。
1. 设计原理:问题与目标
问题所在
文件系统的操作天生就不是原子的。例如,创建一个文件这个看似单一的操作,在磁盘层面至少需要 4 步:
- 在磁盘位图(bitmap)中找到一个空闲的 inode,并将其标记为已使用。
- 初始化这个 inode(设置类型为文件,链接数为 1 等),并将其写回磁盘。
- 在父目录的数据块中,添加一个目录项,包含新文件名和 inode 号。
- 更新父目录 inode 的大小和修改时间。
如果在执行这 4 步的任何中间环节发生崩溃,文件系统就会处于一个不一致的、损坏的状态。例如,inode 分配了但目录项没写入,就会产生一个无法访问的“幽灵文件”,并造成空间泄漏。
设计目标
日志系统的目标就是将这一系列非原子的磁盘写操作,捆绑成一个逻辑上的原子事务(Atomic Transaction)。这个事务要么完全成功,要么完全失败(即恢复到操作开始前的状态),绝不允许停留在中间状态。
核心原理:预写式日志 (WAL)
基本思想非常简单:“先写日志,再写数据”。
在真正去修改文件系统(如 inode 区、位图区)之前,先把所有要做的修改内容,集中地、顺序地写入磁盘上的一个特殊区域——日志区(log block)。然后,在日志区标记一个“提交点”,表示“这次事务的所有修改内容都已安全记录”。之后,才把日志区的内容真正搬运到它们最终的位置。
这样,在崩溃恢复时,只需检查日志区:
- 如果日志显示事务已经“提交”,但可能没搬运完,那就重新搬运一次,完成事务。
- 如果日志显示事务没有“提交”,那就直接忽略日志区的内容,因为真正的文件系统区域还没被动过。
2. 磁盘布局:日志区的结构
在 xv6 的磁盘上,紧跟在超级块(Superblock)和位图(Bitmap)之后,就是日志区。它由两部分组成:
- 日志头块 (Log Header Block):
- 这是日志区的第一个块。
- 它是一个
struct logheader结构,包含了 n(本次事务包含的块数量)和一个 block[] 数组(记录了每个块的最终磁盘地址)。 - 它就是事务的“清单”或“目录”。当 n > 0 时,它也充当了“提交记录”。
- 日志数据块 (Log Data Blocks):
- 紧跟在日志头后面的 LOGSIZE - 1 个块。
- 它们是“仓库”,用来暂存那些被修改了的块的新内容。
3. 核心流程:一次完整事务的生命周期
让我们以一个完整的事务为例,看看代码是如何驱动这个流程的。
第 1 步: begin_op() - 事务开始
当一个高层文件系统函数(如 create, write)被调用时,它首先会调用 begin_op()。
- 作用:通知日志系统,一个原子操作序列即将开始。
- 实现:它内部有一个计数器。因为事务可以嵌套(例如 create 调用了 ialloc,它们都可能调用 begin_op),这个函数只是简单地增加一个计数,只有当最外层的操作开始时,才会真正“启动”日志系统。
第 2 步: log_write() - 记录修改
在事务过程中,每当需要修改一个磁盘块(如 inode 块、bitmap 块、数据块)时,内核不会直接修改它,而是调用 log_write()。
- 作用:将这次修改“暂存”到日志中。
- 实现:
- 它首先检查这个块是否已经在当前事务的日志中了。如果是,直接复用之前分配的日志块。
- 如果不是,它会:
- 在内存的 log.header 中,记录下这个块的最终地址,并把 log.header.n 加一。
- 从磁盘的日志数据区分配一个空闲的槽位。
- 将这个块的新内容写入到对应的日志数据块中(实际上是写入了内存中的 buffer cache,并标记 buffer 属于日志区)。
- 为了防止这个块在事务提交前被从 buffer cache 中驱逐或被重用,它会调用 bpin() 来“钉住”这个 buffer,增加它的引用计数。
第 3 步: end_op() - 事务结束
当高层文件系统函数完成所有逻辑后,它会调用 end_op()。
- 作用:通知日志系统,一个原子操作序列结束了。
- 实现:
- 它会减少 begin_op 增加的那个计数器。
- 当计数器减到 0 时,意味着最外层的事务已经完成,是时候真正提交(commit)整个事务了。这时,它会调用
commit()。
第 4 步: commit() - 提交事务(核心!)
这是保证原子性的魔法发生的地方。
write_log():将所有在本次事务中被修改过的、暂存在内存 Buffer Cache 中的日志数据块,全部写回到磁盘上的日志数据区。确保所有“材料”都已入库。write_head():这是“提交点”(Commit Point)。将内存中已经构建好的 log.header(现在包含了完整的 n 和 block[] 列表),一次性地写入到磁盘上的日志头块。- 一旦这个操作完成,事务就被视为“已提交”。从这一刻起,系统就承诺必须完成这个事务。
install_trans():安装事务。现在,日志已经安全地记录在磁盘上,可以开始修改真正的文件系统了。该函数会:- 读取磁盘上的日志头,得到要修改的块列表。
- 将磁盘上日志数据区的每个块,逐一复制到其在文件系统中的最终位置。
- 这个操作是幂等(Idempotent)的,即使在复制过程中崩溃,重启后重做一次 install_trans 也不会有任何副作用。
write_head()(再次调用):当所有块都成功“安装”后,内核会将内存中的 log.header.n 置为 0,然后再次调用 write_head() 将这个空的头部写回磁盘上的日志头块。这会清除“提交记录”,表示事务已圆满完成,日志区可以被下一次事务使用了。
4. 崩溃恢复流程
当 xv6 启动时,会调用 initlog(),它内部会调用 recover_from_log()。
read_head():读取磁盘上的日志头块。- 检查提交记录:
- 如果 header.n == 0,说明上次关机前所有事务都已正常完成。一切安好,什么都不用做。
- 如果 header.n > 0,说明系统在上次的 commit() 过程中发生了崩溃。具体来说,是在 write_head() 写入提交记录之后,但在最后清空日志头之前崩溃的。
- 重放日志(Replay):
- 既然日志是有效的,恢复程序会调用 install_trans(),像正常提交流程一样,把日志数据区的块再次复制到它们的最终位置。
- 完成复制后,再调用 write_head() 清空日志头。
通过这个恢复流程,文件系统被恢复到了一个一致的状态,一个逻辑事务被完整地完成了。
总结
xv6 的日志设计是一个经典的、极简的 WAL 实现。它通过“内存中准备 → 磁盘上承诺 → 磁盘上执行 → 磁盘上清理”这一系列流程,巧妙地将复杂的、非原子的文件系统操作转换为了逻辑上原子的事务,极大地增强了文件系统的可靠性。