2. I/O 栈全景:从系统调用到块设备的完整 I/O 路径

理解 Linux 内核 I/O 栈是进行文件系统调试的基石。一次简单的 read() 或 write() 系统调用,在抵达物理磁盘之前,会穿越多个内核子系统。本章将逐层剖析这条完整路径,涵盖 VFS、页缓存、块层 以及 SCSI/ATA 驱动,并建立各层之间的调试关联。

调试视角: 当 I/O 异常发生时,定位问题所在层是第一步。例如,read() 返回成功但数据错误,问题可能出在页缓存或块层;而设备超时则通常指向驱动层或硬件。

2.1 整体架构:I/O 栈的七层模型

Linux I/O 栈从用户态到硬件,可划分为七个逻辑层。每一层都封装了特定的职责,并通过标准接口与上下层交互。

Linux I/O 栈七层架构 Linux I/O 栈七层架构 用户态应用 (read/write) 系统调用层 (sys_read/sys_write) 虚拟文件系统 (VFS) 具体文件系统 (ext4/xfs/btrfs) 页缓存 (Page Cache) 块层 (Block Layer / I/O 调度) SCSI/ATA 驱动层 (硬件) 调试关注点 • strace 追踪 • 文件操作钩子 • 挂载/超级块 • inode/dentry • 日志/事务 • 脏页回写 • 缓存命中率 • bio 结构 • 调度器队列 • 合并/排序 • 命令超时 • 中断处理 • 错误恢复

2.2 虚拟文件系统 (VFS):统一接口层

VFS 是 Linux 文件系统的核心抽象。它定义了一组通用的对象和操作接口,使得 cp、ls 等用户态程序无需关心底层文件系统是 ext4、XFS 还是 NFS。

2.2.1 VFS 四大核心对象

对象 数据结构 作用 调试关键字段
超级块 (Super Block) struct super_block 存储已挂载文件系统的全局元数据 s_dev, s_type, s_op
索引节点 (Inode) struct inode 代表一个文件或目录的元数据(权限、大小、位置) i_ino, i_mode, i_size, i_blocks
目录项 (Dentry) struct dentry 维护路径名与 inode 的映射关系,用于路径查找加速 d_name, d_parent, d_inode
文件对象 (File) struct file 代表一个打开的文件实例,持有当前读写位置 f_pos, f_flags, f_op
调试技巧: 使用 crash> files 或 gdb 查看进程的 struct files_struct,可以列出所有打开的文件描述符及其对应的 struct file 和 struct inode。当怀疑文件描述符泄漏时,这是首选方法。

2.2.2 系统调用到 VFS 的路径

以 read() 系统调用为例,其内核路径如下:

  1. 系统调用入口: SYSCALL_DEFINE3(read, ...) 在 fs/read_write.c 中。
  2. 获取文件对象: 通过 fdget(fd) 从当前进程的文件描述符表获取 struct file。
  3. 调用文件操作: 执行 file->f_op->read(file, buf, count, &pos)。
  4. VFS 通用层: 对于常规文件,通常调用 vfs_read(),它进行权限检查后,调用具体文件系统的 read_iter 方法。
read 系统调用 VFS 路径 read() 系统调用 VFS 路径 用户态 read() sys_read() vfs_read() file->f_op->read_iter 具体文件系统实现 调试断点位置 • do_sys_open • vfs_read/vfs_write • ext4_file_read_iter

2.3 页缓存 (Page Cache):性能加速器

页缓存是内核为磁盘数据提供的高速缓存,位于内存与文件系统之间。它极大地提升了文件读写性能,但也引入了数据一致性和回写延迟等调试难点。

2.3.1 缓存结构与回写机制

  • 基数树 (Radix Tree) / XArray: 每个 inode 关联一个 struct address_space,其核心是一个 XArray,用于快速索引文件偏移量对应的缓存页。
  • 脏页 (Dirty Page): 被写入但尚未同步到磁盘的页。内核通过 pdflush (或 bdi 线程) 在后台定期回写。
  • 回写阈值: 由 /proc/sys/vm/dirty_ratio 和 dirty_background_ratio 控制。当脏页比例超过阈值时,进程的写操作会变为同步等待。
常见异常: 系统突然掉电后,文件内容损坏或大小不对。这通常是因为页缓存中的脏页尚未回写。调试时,检查 /proc/meminfo 中的 Dirty 字段,以及 dirty_expire_centisecs 参数。

2.3.2 读路径:缓存命中与未命中

当 read() 到达具体文件系统后,首先查询页缓存:

  1. 查找: 调用 find_get_page(mapping, index) 在 XArray 中查找。
  2. 命中: 直接复制数据到用户缓冲区,返回。
  3. 未命中: 分配新页框,调用 mapping->a_ops->readpage() 发起磁盘 I/O。
  4. 预读 (Read-Ahead): 内核会预测后续访问模式,提前加载更多页到缓存中。
页缓存读路径 页缓存读路径:命中 vs 未命中 文件系统 readpage 缓存命中? 直接复制到用户态 分配页框,发起 I/O submit_bio() 调试点 • 检查 PageUptodate 标志

2.4 块层 (Block Layer):I/O 调度与合并

当页缓存未命中或需要回写脏页时,I/O 请求会下发给块层。块层负责将文件系统的逻辑请求转换为块设备的物理 I/O 请求,并进行调度优化。

2.4.1 核心数据结构:bio

struct bio 是块层 I/O 请求的基本单元。它描述了一次 I/O 操作的目标设备、起始扇区、数据方向和内存页列表。

// include/linux/blk_types.h
struct bio {
    struct bio      *bi_next;       // 链表指针
    struct block_device *bi_bdev;   // 目标块设备
    unsigned int    bi_opf;         // 操作标志 (读/写/丢弃等)
    unsigned short  bi_flags;       // 状态标志
    unsigned short  bi_ioprio;      // I/O 优先级
    struct bvec_iter    bi_iter;    // 当前处理位置 (扇区偏移)
    unsigned int    bi_private;     // 驱动私有数据
    struct bio_vec  bi_inline_vecs[]; // 内联向量
};
调试技巧: 在 crash 中,使用 bio <address> 可以查看 bio 的详细信息,包括 bi_sector、bi_size 和 bi_bdev。这对于追踪 I/O 请求是否发往了错误的设备或扇区非常有用。

2.4.2 I/O 调度器

I/O 调度器负责将多个 bio 请求合并、排序,以提高磁盘吞吐量。常见的调度器包括:

调度器 算法 适用场景 调试关注点
mq-deadline 按扇区排序 + 截止时间 通用场景,兼顾延迟与吞吐 请求是否被饿死
BFQ 按进程分配时间片 桌面/交互式系统 进程 I/O 带宽是否公平
Kyber 基于延迟的令牌桶 快速 NVMe 设备 令牌桶是否耗尽
none 无调度,直接下发 高性能 NVMe / 硬件调度 应用层是否过度排队

2.5 SCSI/ATA 驱动层:与硬件对话

块层将调度后的请求通过 blk_mq_submit_bio() 提交给设备驱动。对于传统 SCSI 和 SATA 设备,驱动层负责将 bio 转换为硬件命令。

2.5.1 SCSI 子系统架构

SCSI 子系统分为三层:

  • 上层 (Upper Layer): 包括 sd (磁盘)、sr (光驱) 等驱动,负责将块层请求转换为 SCSI 命令。
  • 中间层 (Mid Layer): 实现 SCSI 命令的通用处理、错误恢复和超时管理。
  • 下层 (Lower Layer / LLDD): 与具体硬件适配器交互,如 ahci (SATA)、megaraid_sas (RAID) 等。
SCSI 子系统三层架构 SCSI 子系统三层架构 上层驱动 (sd / sr / st) SCSI 中间层 (scsi_mid) 底层驱动 (ahci / mpt3sas) 硬件设备 调试命令 • cat /proc/scsi/scsi • lsscsi • sg_inq /dev/sda • echo scsi remove-single-device • dmesg | grep scsi • crash> scsi • crash> dev -d

2.5.2 ATA 与 libata

对于 SATA 设备,内核使用 libata 库。它模拟了 SCSI 接口,使得 SATA 设备在驱动层看起来像 SCSI 设备。这意味着 sd 驱动可以同时管理 SCSI 和 SATA 磁盘。

异常案例: 当 SATA 链路发生错误时,libata 会触发 EH (Error Handler)。调试时,dmesg 中会出现类似 ata1: exception Emask 0x10 SAct 0x0 SErr 0x4050000 action 0x6 frozen 的信息。此时需要检查 smartctl 输出和硬件连接。

2.6 完整 I/O 路径总结

将以上所有层串联起来,一次 write() 系统调用的完整路径如下:

步骤 层 关键操作 可能异常
1 用户态 调用 write(fd, buf, count) 无效 fd
2 系统调用 sys_write() → vfs_write() 权限错误
3 VFS 获取 struct file,调用 f_op->write_iter 文件系统只读
4 具体文件系统 ext4 分配块,更新元数据,写入日志 (JBD2) 磁盘空间不足
5 页缓存 写入缓存页,标记为脏 内存不足 (ENOMEM)
6 块层 创建 bio,经过 I/O 调度器合并排序 请求队列满
7 驱动层 SCSI 命令发送,硬件 DMA 传输 设备超时/链路错误
调试路径选择: 当遇到 I/O 性能问题时,可以使用 blktrace 追踪 I/O 请求在块层的延迟分布;使用 ftrace 的 filemap 和 ext4 事件追踪页缓存和文件系统行为;使用 crash 的 struct 命令检查各层数据结构的状态。