xv6 的日志系统(Logging System)的设计

xv6 的日志系统是一个简洁但功能完备的预写式日志(Write-Ahead Logging, WAL) 实现。

它的核心目标是保证文件系统操作的原子性(Atomicity)从而在系统突然崩溃(如断电)时,保护文件系统不被损坏,维持其一致性(Consistency)。


1. 设计原理:问题与目标

问题所在

文件系统的操作天生就不是原子的。例如,创建一个文件这个看似单一的操作,在磁盘层面至少需要 4 步:

  1. 在磁盘位图(bitmap)中找到一个空闲的 inode,并将其标记为已使用。
  2. 初始化这个 inode(设置类型为文件,链接数为 1 等),并将其写回磁盘。
  3. 在父目录的数据块中,添加一个目录项,包含新文件名和 inode 号。
  4. 更新父目录 inode 的大小和修改时间。

如果在执行这 4 步的任何中间环节发生崩溃,文件系统就会处于一个不一致的、损坏的状态。例如,inode 分配了但目录项没写入,就会产生一个无法访问的“幽灵文件”,并造成空间泄漏。

设计目标

日志系统的目标就是将这一系列非原子的磁盘写操作,捆绑成一个逻辑上的原子事务(Atomic Transaction)。这个事务要么完全成功,要么完全失败(即恢复到操作开始前的状态),绝不允许停留在中间状态。

核心原理:预写式日志 (WAL)

基本思想非常简单:“先写日志,再写数据”。

在真正去修改文件系统(如 inode 区、位图区)之前,先把所有要做的修改内容,集中地、顺序地写入磁盘上的一个特殊区域——日志区(log block)。然后,在日志区标记一个“提交点”,表示“这次事务的所有修改内容都已安全记录”。之后,才把日志区的内容真正搬运到它们最终的位置。

这样,在崩溃恢复时,只需检查日志区:

  • 如果日志显示事务已经“提交”,但可能没搬运完,那就重新搬运一次,完成事务。
  • 如果日志显示事务没有“提交”,那就直接忽略日志区的内容,因为真正的文件系统区域还没被动过。

2. 磁盘布局:日志区的结构

在 xv6 的磁盘上,紧跟在超级块(Superblock)和位图(Bitmap)之后,就是日志区。它由两部分组成:

  1. 日志头块 (Log Header Block):
    • 这是日志区的第一个块。
    • 它是一个 struct logheader 结构,包含了 n(本次事务包含的块数量)和一个 block[] 数组(记录了每个块的最终磁盘地址)。
    • 它就是事务的“清单”或“目录”。当 n > 0 时,它也充当了“提交记录”。
  2. 日志数据块 (Log Data Blocks):
    • 紧跟在日志头后面的 LOGSIZE - 1 个块。
    • 它们是“仓库”,用来暂存那些被修改了的块的新内容。

3. 核心流程:一次完整事务的生命周期

让我们以一个完整的事务为例,看看代码是如何驱动这个流程的。

第 1 步: begin_op() - 事务开始

当一个高层文件系统函数(如 create, write)被调用时,它首先会调用 begin_op()

  • 作用:通知日志系统,一个原子操作序列即将开始。
  • 实现:它内部有一个计数器。因为事务可以嵌套(例如 create 调用了 ialloc,它们都可能调用 begin_op),这个函数只是简单地增加一个计数,只有当最外层的操作开始时,才会真正“启动”日志系统。

第 2 步: log_write() - 记录修改

在事务过程中,每当需要修改一个磁盘块(如 inode 块、bitmap 块、数据块)时,内核不会直接修改它,而是调用 log_write()

  • 作用:将这次修改“暂存”到日志中。
  • 实现
    1. 它首先检查这个块是否已经在当前事务的日志中了。如果是,直接复用之前分配的日志块。
    2. 如果不是,它会:
      • 在内存的 log.header 中,记录下这个块的最终地址,并把 log.header.n 加一。
      • 从磁盘的日志数据区分配一个空闲的槽位。
      • 将这个块的新内容写入到对应的日志数据块中(实际上是写入了内存中的 buffer cache,并标记 buffer 属于日志区)。
    3. 为了防止这个块在事务提交前被从 buffer cache 中驱逐或被重用,它会调用 bpin() 来“钉住”这个 buffer,增加它的引用计数。

第 3 步: end_op() - 事务结束

当高层文件系统函数完成所有逻辑后,它会调用 end_op()

  • 作用:通知日志系统,一个原子操作序列结束了。
  • 实现
    1. 它会减少 begin_op 增加的那个计数器。
    2. 当计数器减到 0 时,意味着最外层的事务已经完成,是时候真正提交(commit)整个事务了。这时,它会调用 commit()

第 4 步: commit() - 提交事务(核心!)

这是保证原子性的魔法发生的地方。

  1. write_log():将所有在本次事务中被修改过的、暂存在内存 Buffer Cache 中的日志数据块,全部写回到磁盘上的日志数据区。确保所有“材料”都已入库。
  2. write_head():这是“提交点”(Commit Point)。将内存中已经构建好的 log.header(现在包含了完整的 n 和 block[] 列表),一次性地写入到磁盘上的日志头块。
    • 一旦这个操作完成,事务就被视为“已提交”。从这一刻起,系统就承诺必须完成这个事务。
  3. install_trans():安装事务。现在,日志已经安全地记录在磁盘上,可以开始修改真正的文件系统了。该函数会:
    • 读取磁盘上的日志头,得到要修改的块列表。
    • 将磁盘上日志数据区的每个块,逐一复制到其在文件系统中的最终位置。
    • 这个操作是幂等(Idempotent)的,即使在复制过程中崩溃,重启后重做一次 install_trans 也不会有任何副作用。
  4. write_head()(再次调用):当所有块都成功“安装”后,内核会将内存中的 log.header.n 置为 0,然后再次调用 write_head() 将这个空的头部写回磁盘上的日志头块。这会清除“提交记录”,表示事务已圆满完成,日志区可以被下一次事务使用了。

4. 崩溃恢复流程

当 xv6 启动时,会调用 initlog(),它内部会调用 recover_from_log()

  1. read_head():读取磁盘上的日志头块。
  2. 检查提交记录
    • 如果 header.n == 0,说明上次关机前所有事务都已正常完成。一切安好,什么都不用做。
    • 如果 header.n > 0,说明系统在上次的 commit() 过程中发生了崩溃。具体来说,是在 write_head() 写入提交记录之后,但在最后清空日志头之前崩溃的。
  3. 重放日志(Replay)
    • 既然日志是有效的,恢复程序会调用 install_trans(),像正常提交流程一样,把日志数据区的块再次复制到它们的最终位置。
    • 完成复制后,再调用 write_head() 清空日志头。

通过这个恢复流程,文件系统被恢复到了一个一致的状态,一个逻辑事务被完整地完成了。


总结

xv6 的日志设计是一个经典的、极简的 WAL 实现。它通过“内存中准备 → 磁盘上承诺 → 磁盘上执行 → 磁盘上清理”这一系列流程,巧妙地将复杂的、非原子的文件系统操作转换为了逻辑上原子的事务,极大地增强了文件系统的可靠性。