2. I/O 栈全景:从系统调用到块设备的完整 I/O 路径
理解 Linux 内核 I/O 栈是进行文件系统调试的基石。一次简单的 read() 或 write() 系统调用,在抵达物理磁盘之前,会穿越多个内核子系统。本章将逐层剖析这条完整路径,涵盖 VFS、页缓存、块层 以及 SCSI/ATA 驱动,并建立各层之间的调试关联。
read() 返回成功但数据错误,问题可能出在页缓存或块层;而设备超时则通常指向驱动层或硬件。
2.1 整体架构:I/O 栈的七层模型
Linux I/O 栈从用户态到硬件,可划分为七个逻辑层。每一层都封装了特定的职责,并通过标准接口与上下层交互。
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() 系统调用为例,其内核路径如下:
- 系统调用入口:
SYSCALL_DEFINE3(read, ...)在fs/read_write.c中。 - 获取文件对象: 通过
fdget(fd)从当前进程的文件描述符表获取struct file。 - 调用文件操作: 执行
file->f_op->read(file, buf, count, &pos)。 - VFS 通用层: 对于常规文件,通常调用
vfs_read(),它进行权限检查后,调用具体文件系统的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() 到达具体文件系统后,首先查询页缓存:
- 查找: 调用
find_get_page(mapping, index)在 XArray 中查找。 - 命中: 直接复制数据到用户缓冲区,返回。
- 未命中: 分配新页框,调用
mapping->a_ops->readpage()发起磁盘 I/O。 - 预读 (Read-Ahead): 内核会预测后续访问模式,提前加载更多页到缓存中。
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) 等。
2.5.2 ATA 与 libata
对于 SATA 设备,内核使用 libata 库。它模拟了 SCSI 接口,使得 SATA 设备在驱动层看起来像 SCSI 设备。这意味着 sd 驱动可以同时管理 SCSI 和 SATA 磁盘。
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 传输 | 设备超时/链路错误 |
blktrace 追踪 I/O 请求在块层的延迟分布;使用 ftrace 的 filemap 和 ext4 事件追踪页缓存和文件系统行为;使用 crash 的 struct 命令检查各层数据结构的状态。