mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
7067 字
19 分钟
块设备与 I/O 栈
2025-03-12

在上一章中,我们深入剖析了 VFS 与文件系统的设计——从 super_blockinode,从 dentry 缓存到 ext4 的磁盘布局。但一个关键问题始终悬而未决:当 VFS 层决定要读写一个文件时,这个请求如何穿越内核的 I/O 栈,最终到达物理磁盘?

答案藏在 Linux 的块设备层与 I/O 栈中。这是一条从 VFS 到驱动的完整数据通路,涉及 Page Cache 的命中与穿透、bio 请求的构造与分裂、通用块层的合并与排序、I/O 调度器的策略抉择、blk-mq 多队列的硬件映射,以及 NVMe 等高速设备的驱动模型。理解这条通路,是理解 Linux 存储 I/O 性能调优的根基。

本章将从块设备的基本概念出发,逐层拆解 I/O 栈的每一个关键环节。

一、块设备与字符设备:两种截然不同的 I/O 模型#

Linux 将设备分为两大类:块设备(Block Device)字符设备(Character Device)。它们的根本区别不在于物理形态,而在于内核对 I/O 请求的管理方式。

1.1 块设备的特征#

块设备以固定大小的块(block) 为最小寻址单位,支持随机访问——你可以直接跳到第 1024 个块读取数据,而不需要先读完前 1023 个块。块设备的核心特征:

  • 块大小:通常为 512 字节(传统硬盘)或 4096 字节(现代 SSD),但内核统一使用 512 字节的逻辑扇区作为内部计量单位
  • 寻址方式:通过块号(LBA,Logical Block Address)直接定位,无需顺序遍历
  • 缓冲机制:块设备的 I/O 必须经过 Page Cache(除非使用 Direct I/O),内核可以在内存中缓存热点数据
  • 可挂载文件系统:块设备上可以创建文件系统并挂载,这是它最核心的用途

典型的块设备包括:硬盘(HDD/SSD)、U 盘、NVMe 设备、虚拟块设备(loop 设备、LVM 逻辑卷)。

1.2 字符设备的特征#

字符设备以字节流为 I/O 模型,按顺序读写数据,不支持随机寻址:

  • 无固定块大小:读写单位是任意长度的字节
  • 顺序访问:数据像流水一样进出,不能”跳转”
  • 无缓冲:字符设备的 I/O 通常不经过 Page Cache,直接在用户空间和驱动之间传递
  • 不可挂载文件系统:字符设备上不能创建文件系统

典型的字符设备包括:键盘(/dev/input/event0)、鼠标、串口(/dev/ttyS0)、音频设备、/dev/null/dev/random

1.3 设备文件与主次设备号#

在 Linux 中,设备通过 /dev 下的设备文件(也称设备节点)暴露给用户空间。每个设备文件有两个关键属性:

  • 主设备号(Major Number):标识驱动程序。同一类设备共享同一个主设备号
  • 次设备号(Minor Number):标识同一驱动下的具体设备实例
# 查看块设备的主次设备号
ls -l /dev/sda
# brw-rw---- 1 root disk 8, 0 Apr 19 10:00 /dev/sda
# 类型(b=块设备) 主设备号=8 次设备号=0
# 查看字符设备的主次设备号
ls -l /dev/ttyS0
# crw-rw---- 1 root dialout 4, 64 Apr 19 10:00 /dev/ttyS0
# 类型(c=字符设备) 主设备号=4 次设备号=64
Note

设备文件的 b 前缀表示块设备,c 前缀表示字符设备。内核通过 include/linux/kdev_t.h 中的宏 MKDEV(major, minor) 将主次设备号编码为 dev_t 类型(32 位,高 12 位为主设备号,低 20 位为次设备号)。

二、I/O 栈全景:从系统调用到磁盘#

理解块设备与 I/O 栈的关键,是建立一条完整的数据通路视图。一个写请求从用户空间的 write() 系统调用出发,到数据真正落盘,需要穿越以下层次:

graph TB subgraph 用户空间 APP["用户进程<br/>write()/read()"] end subgraph 内核空间 SYSCALL["系统调用层<br/>vfs_write()/vfs_read()"] VFS["VFS 层<br/>super_block/inode/file"] PC["Page Cache<br/>address_space/radix_tree"] BL["通用块层<br/>bio 构造与提交"] BLKMQ["blk-mq 层<br/>软件队列 → 硬件队列"] SCHED["I/O 调度器<br/>合并/排序/调度"] DRV["块设备驱动<br/>request_fn / queue_rq"] end HW["存储硬件<br/>HDD/SSD/NVMe"] APP -->|"系统调用"| SYSCALL SYSCALL --> VFS VFS --> PC PC -->|"Cache Miss<br/>构造 bio"| BL BL --> BLKMQ BLKMQ --> SCHED SCHED --> DRV DRV --> HW PC -.->|"Cache Hit<br/>直接返回"| APP style PC fill:#4CAF50,color:#fff style BLKMQ fill:#2196F3,color:#fff style SCHED fill:#FF9800,color:#fff style HW fill:#607D8B,color:#fff

这条通路的每一层都有明确的职责:

  1. VFS 层:将文件偏移量转换为文件内的逻辑块号,通过 address_space 查找 Page Cache
  2. Page Cache:如果数据已在缓存中,直接返回(读)或标记为脏页(写);否则进入块层
  3. 通用块层:将页面的 I/O 请求封装为 bio 结构,提交给块设备
  4. blk-mq 层:将 bio 转换为 request,映射到对应的硬件队列
  5. I/O 调度器:对请求进行合并、排序和调度,优化磁盘访问模式
  6. 块设备驱动:将请求转化为硬件可执行的命令(如 ATA 命令、NVMe 命令),通过 DMA 传输数据
Tip

在上一章中,讨论了 VFS 层如何将文件操作映射到具体文件系统的实现。本章聚焦于 VFS 之下的部分——从 Page Cache 未命中开始,到数据到达物理设备为止的完整路径。

三、bio:块 I/O 的基本载体#

3.1 bio 结构体#

bio(Block I/O)是通用块层中 I/O 请求的基本载体。它描述了一次块设备 I/O 操作的所有信息:目标设备、起始扇区、数据方向(读/写)、以及数据在内存中的位置。

include/linux/blk_types.h
struct bio {
struct bio *bi_next; // 链接到下一个 bio(请求队列中)
struct block_device *bi_bdev; // 目标块设备
unsigned int bi_opf; // 操作标志(READ/WRITE/SYNC 等)
sector_t bi_iter.bi_sector; // 起始扇区号(512 字节单位)
unsigned int bi_vcnt; // bio_vec 数组中的段数
unsigned int bi_idx; // 当前正在处理的 bio_vec 索引
struct bio_vec *bi_io_vec; // 指向 bio_vec 数组
bio_end_io_t *bi_end_io; // I/O 完成回调
void *bi_private; // 私有数据,传给完成回调
unsigned short bi_flags; // 状态标志
// ...
};

bio 的核心设计思想是分散-聚集(Scatter-Gather)I/O:一次 I/O 操作的数据在物理内存中不必连续,而是由一组 bio_vec 描述,每个 bio_vec 指向一个内存页面中的连续区域。

3.2 bio_vec:内存段的描述#

include/linux/bvec.h
struct bio_vec {
struct page *bv_page; // 指向物理页面
unsigned int bv_len; // 该段数据的长度(字节)
unsigned int bv_offset; // 数据在页面内的偏移
};

一个 bio 可以包含多个 bio_vec,形成一条段链表。这种设计有两个重要优势:

  1. 零拷贝:I/O 数据可以直接从用户空间的页面(通过 get_user_pages() 固定)构造 bio_vec,无需将数据拷贝到连续的内核缓冲区
  2. 大 I/O 支持:一个 bio 最多可以包含 BIO_MAX_VECS(通常为 256)个段,每段最大 4 KB(一个页面),因此单个 bio 最大可描述 1 MB 的 I/O

3.3 bio 的构造过程#

当 Page Cache 未命中时,VFS 层调用 mpage_readpages()mpage_writepages() 将页面的 I/O 请求构造为 bio

// fs/mpage.c(简化)
static int mpage_readpages(struct address_space *mapping,
struct list_head *pages)
{
struct bio *bio = NULL;
// 遍历需要读取的页面列表
for_each_page(pages) {
// 如果当前 bio 可以容纳此页面(扇区连续),则追加
// 否则提交当前 bio,创建新的 bio
if (bio && bio_can_merge(bio, page))
bio_add_page(bio, page, PAGE_SIZE, 0);
else {
if (bio)
submit_bio(bio); // 提交旧 bio
bio = bio_alloc(GFP_KERNEL, 1);
bio->bi_bdev = bdev;
bio->bi_iter.bi_sector = page_sector(page);
bio_add_page(bio, page, PAGE_SIZE, 0);
}
}
if (bio)
submit_bio(bio); // 提交最后一个 bio
}
Note

submit_bio() 是通用块层的入口函数。它将 bio 提交给目标块设备的请求队列,标志着 I/O 请求从文件系统层正式进入块设备层。

四、通用块层与 request_queue#

4.1 request_queue:块设备的 I/O 队列#

在传统(非 blk-mq)的块设备架构中,每个块设备关联一个 request_queue,它是 I/O 请求的调度和管理中心:

// include/linux/blkdev.h(简化)
struct request_queue {
struct request *last_merge; // 上次合并的请求(加速合并查找)
struct elevator_queue *elevator; // I/O 调度器(电梯算法)
struct request_list rq; // 请求分配池
request_fn_proc *request_fn; // 驱动的请求处理函数
make_request_fn *make_request_fn; // bio 到 request 的转换函数
// ...
};

request_queue 的核心职责:

  1. 接收 bio:通过 make_request_fnbio 转换为 request
  2. I/O 调度:通过 elevator 对请求进行合并、排序和调度
  3. 派发请求:通过 request_fn 将调度后的请求交给驱动处理

4.2 request:调度后的 I/O 请求#

request 是经过 I/O 调度器处理后的请求结构,它由一个或多个 bio 合并而成:

// include/linux/blk-mq.h(简化)
struct request {
struct list_head queuelist; // 在调度队列中的链表节点
struct bio *bio; // 挂载的第一个 bio
struct bio *biotail; // 挂载的最后一个 bio
sector_t __sector; // 起始扇区
unsigned int __data_len; // 数据总长度
struct request_queue *q; // 所属的请求队列
blk_mq_ctx_t *mq_ctx; // blk-mq 软件队列上下文
blk_mq_hw_ctx_t *mq_hctx; // blk-mq 硬件队列上下文
// ...
};

一个 request 可以包含多个 bio——当多个 bio 的扇区地址相邻时,I/O 调度器会将它们合并为一个 request,减少驱动需要处理的请求数量。

4.3 传统架构的瓶颈#

传统的单队列 request_queue 架构在高速存储设备面前暴露了严重的瓶颈:

  • 全局锁竞争:所有 CPU 共享一个请求队列,对队列的任何操作都需要获取自旋锁,在多核系统上成为严重的争用点
  • 单点调度:只有一个 I/O 调度器实例处理所有请求,无法并行
  • 中断处理瓶颈:I/O 完成中断只在一个 CPU 上处理,无法利用多核的并行处理能力

这些问题在 HDD 时代并不突出——磁盘本身的机械延迟远大于锁竞争的开销。但当 NVMe SSD 的延迟降到微秒级时,单队列的锁竞争开销就成了不可接受的瓶颈。这正是 blk-mq 诞生的根本原因。

五、blk-mq:多队列块层架构#

5.1 设计动机#

blk-mq(Block Multi-Queue)由 Jens Axboe 在 3.13 内核中引入,彻底重构了块设备的 I/O 路径。其核心思想是将单一的全局队列拆分为多组软件队列和硬件队列,消除全局锁竞争,充分发挥多核系统和高速存储设备的性能潜力。

graph TB subgraph "CPU 核心" C0["CPU 0"] C1["CPU 1"] C2["CPU 2"] C3["CPU 3"] end subgraph "软件队列(Software Queues)" SQ0["ctx 0<br/>无锁 per-CPU"] SQ1["ctx 1<br/>无锁 per-CPU"] SQ2["ctx 2<br/>无锁 per-CPU"] SQ3["ctx 3<br/>无锁 per-CPU"] end subgraph "硬件队列(Hardware Queues)" HQ0["hctx 0<br/>硬件队列 0"] HQ1["hctx 1<br/>硬件队列 1"] end subgraph "I/O 调度器" SCHED0["调度器 0<br/>mq-deadline/BFQ"] SCHED1["调度器 1<br/>mq-deadline/BFQ"] end subgraph "块设备驱动" DRV["NVMe 驱动<br/>queue_rq()"] end HW["NVMe 设备<br/>Submission Queue 0<br/>Submission Queue 1"] C0 --> SQ0 C1 --> SQ1 C2 --> SQ2 C3 --> SQ3 SQ0 -->|"flush/batch"| HQ0 SQ1 -->|"flush/batch"| HQ0 SQ2 -->|"flush/batch"| HQ1 SQ3 -->|"flush/batch"| HQ1 HQ0 --> SCHED0 HQ1 --> SCHED1 SCHED0 --> DRV SCHED1 --> DRV DRV --> HW style SQ0 fill:#4CAF50,color:#fff style SQ1 fill:#4CAF50,color:#fff style SQ2 fill:#4CAF50,color:#fff style SQ3 fill:#4CAF50,color:#fff style HQ0 fill:#2196F3,color:#fff style HQ1 fill:#2196F3,color:#fff style DRV fill:#FF9800,color:#fff style HW fill:#607D8B,color:#fff

5.2 软件队列:blk_mq_ctx#

每个 CPU 核心对应一个软件队列上下文 blk_mq_ctx,它是 per-CPU 的无锁队列:

// block/blk-mq.h(简化)
struct blk_mq_ctx {
struct spin_lock lock; // 保护本上下文的锁
struct list_head rq_list; // 待处理的 request 链表
unsigned int cpu; // 所属 CPU 编号
unsigned int index_hw; // 映射到的硬件队列索引
// ...
};

当 CPU 提交一个 bio 时,blk_mq_submit_bio() 将其转换为 request 并放入当前 CPU 的软件队列中。由于每个 CPU 有独立的软件队列,提交路径完全无锁——这是 blk-mq 消除锁竞争的关键。

5.3 硬件队列:blk_mq_hw_ctx#

每个硬件队列对应一个 blk_mq_hw_ctx(Hardware Queue Context),它代表设备端的一个实际提交队列:

// block/blk-mq.h(简化)
struct blk_mq_hw_ctx {
struct blk_mq_ctx **ctxs; // 映射到此硬件队列的 CPU 上下文数组
unsigned int nr_ctx; // 映射的 CPU 上下文数量
struct request_queue *queue; // 所属的请求队列
struct blk_mq_tags *tags; // 请求标签(用于跟踪已派发的请求)
struct hlist_node cpuhp_dead; // CPU hotplug 支持
// ...
};

硬件队列的数量取决于设备的硬件队列深度。传统 SATA SSD 通常只有 1 个硬件队列,而 NVMe 设备可以支持多达 64 个甚至更多。

5.4 软件队列到硬件队列的映射#

blk-mq 通过 blk_mq_map_queues() 建立 CPU 到硬件队列的映射关系。映射策略遵循以下原则:

  • 分组映射:同一 NUMA 节点的 CPU 尽量映射到同一组硬件队列,减少跨节点访问
  • 均衡分布:每个硬件队列映射的 CPU 数量尽量均匀
  • CPU hotplug 感知:当 CPU 上线/下线时,映射关系会动态调整
// block/blk-mq.c(简化)
void blk_mq_map_queues(struct blk_mq_queue_map *map)
{
// 将 CPU 按组均匀映射到硬件队列
for_each_possible_cpu(cpu) {
int hw_queue_id = cpu % map->nr_queues;
map->mq_map[cpu] = hw_queue_id;
}
}

5.5 请求的提交与派发流程#

blk-mq 的 I/O 路径分为提交路径派发路径两个阶段:

提交路径(Submit Path)

  1. submit_bio()blk_mq_submit_bio()
  2. 从当前 CPU 的软件队列上下文分配 request
  3. bio 合并到 request(如果可以合并),或创建新的 request
  4. request 加入软件队列(per-CPU,无锁)

派发路径(Dispatch Path)

  1. 软件队列中的 request 被批量”刷入”硬件队列(通过 blk_mq_flush_busy_ctxs()
  2. I/O 调度器对硬件队列中的请求进行排序和调度
  3. 调度器将请求派发给驱动的 queue_rq() 回调
  4. 驱动将请求转化为硬件命令(如 NVMe Command),写入设备的提交队列
Important

blk-mq 的提交路径是同步的、per-CPU 无锁的,而派发路径是异步的、可并行的。这种分离设计确保了提交路径的极低延迟,同时允许派发路径充分利用多核并行性。

六、I/O 调度器:合并、排序与策略#

I/O 调度器(也称 I/O Elevator,电梯算法)是块设备层中最具策略性的组件。它的核心目标是在吞吐量延迟之间找到最佳平衡点。

6.1 I/O 调度器的核心操作#

I/O 调度器执行三个关键操作:

合并(Merge):当新请求与队列中已有请求的扇区地址相邻时,将它们合并为一个更大的请求。合并分为两种:

  • 前向合并(Front Merge):新请求的扇区紧接在已有请求之后
  • 后向合并(Back Merge):新请求的扇区紧邻已有请求之前

后向合并在实际工作负载中更常见(因为写入通常是顺序追加),因此 request_queue 专门缓存了 last_merge 指针来加速后向合并查找。

排序(Sort):将请求按起始扇区号排序,使磁盘磁头的移动路径类似于电梯的运行轨迹——这就是”电梯算法”名称的由来。排序对 HDD 至关重要(减少寻道时间),对 SSD 意义较小(随机访问延迟均匀)。

调度(Dispatch):决定何时将请求从调度队列派发给驱动。不同的调度器在此步骤上策略迥异。

6.2 CFQ(Completely Fair Queuing)#

CFQ 是 Linux 2.6 至 3.x 时代的默认 I/O 调度器,其设计灵感来自 CPU 调度的 CFS:

  • 为每个进程维护独立的 I/O 请求队列
  • 按时间片轮转方式在进程队列之间切换,保证 I/O 带宽的公平分配
  • 通过进程的 I/O 优先级(ionice)调整时间片长度和调度频率
  • 对顺序 I/O 进行预读(anticipatory),减少寻道开销

CFQ 的致命缺陷:复杂度极高,且在 SSD 上表现不佳——SSD 不需要磁头寻道,CFQ 的排序和预读反而增加了不必要的延迟。CFQ 在 5.x 内核中被彻底移除。

6.3 Deadline 调度器#

Deadline 调度器的设计哲学是防止请求饥饿

  • 维护三个队列:读 FIFO写 FIFO排序队列(红黑树)
  • 每个请求在进入 FIFO 时被赋予一个截止时间(读请求 500ms,写请求 5s)
  • 正常情况下从排序队列按扇区顺序派发请求(优化寻道)
  • 当某个请求即将超时,优先派发该请求(防止饥饿)

Deadline 调度器简洁高效,特别适合数据库等随机 I/O 密集型负载。其 blk-mq 版本为 mq-deadline

6.4 BFQ(Budget Fair Queuing)#

BFQ 是 CFQ 的现代替代者,专为桌面和交互式场景设计:

  • 为每个进程/组维护独立的 I/O 预算(budget),预算用完后切换到下一个进程
  • 通过低延迟启发式(low-latency heuristic)检测交互式应用,给予更小的预算和更频繁的调度机会
  • 精确的带宽分配:BFQ 可以保证特定进程获得精确的 I/O 带宽比例
  • 对 SSD 有专门的优化路径,减少不必要的排序开销

BFQ 的缺点是吞吐量较低——在顺序 I/O 密集型场景下,BFQ 的预算切换机制会导致吞吐量比 mq-deadline 低 10%-30%。

6.5 none(Noop)#

none 调度器(旧称 Noop)是最简单的调度器——不做任何排序,仅执行合并。请求按 FIFO 顺序直接派发给驱动。它适用于:

  • NVMe SSD:设备内部已有自己的调度逻辑,内核再排序反而添乱
  • 虚拟机中的虚拟块设备:宿主机已经做了 I/O 调度,Guest 内核无需重复
  • 精密的 I/O 路径:当上层(如数据库)已经精心安排了 I/O 顺序时

6.6 调度器选择指南#

场景推荐调度器原因
桌面/交互式BFQ低延迟保障,交互响应好
服务器/数据库mq-deadline防饥饿,延迟可预测
NVMe SSDnone设备自带调度,内核无需干预
虚拟机虚拟磁盘none宿主机已调度
通用 SSDmq-deadline 或 BFQ视延迟/吞吐量偏好而定
传统 HDDmq-deadline排序减少寻道,防饥饿
# 查看当前调度器
cat /sys/block/sda/queue/scheduler
# 输出:[mq-deadline] none bfq
# 切换调度器
echo bfq | sudo tee /sys/block/sda/queue/scheduler
# 输出:mq-deadline [bfq] none
Tip

方括号 [...] 标记的是当前正在使用的调度器。切换调度器是动态的,无需重启,但已有的请求会继续使用旧调度器处理。

七、I/O 合并与排序的深层机制#

7.1 合并的类型与条件#

I/O 合并是提升吞吐量最有效的手段之一。两个请求可以合并的前提条件:

  1. 扇区相邻:一个请求的结束扇区恰好是另一个请求的起始扇区
  2. 方向相同:都是读或都是写
  3. 同一设备:目标块设备相同
  4. 未超过最大尺寸:合并后的请求不超过 max_sectors_kb 限制

合并带来的收益是巨大的:假设两个 4 KB 的读请求合并为一个 8 KB 的请求,驱动只需向设备发送一条命令,而不是两条。对于 HDD,这意味着减少一次寻道;对于 SSD,这意味着减少一次命令处理开销。

7.2 排序的红黑树实现#

I/O 调度器使用红黑树按扇区号对请求进行排序,保证插入和查找的时间复杂度为 O(log n):

// block/blk-mq-sched.c(简化)
void blk_mq_sched_insert_request(struct request *rq, bool at_head)
{
struct request_queue *q = rq->q;
// 如果可以合并,尝试合并
if (blk_mq_sched_try_merge(q, rq, &free))
return;
// 否则插入红黑树(按扇区排序)
elv_rb_add(q->elevator, rq);
}

红黑树的优势在于:当新请求到达时,可以快速判断它是否可以与已有请求合并(查找前驱和后继节点),同时保持整体的有序性。

7.3 插入与合并的快速路径#

blk-mq 为合并操作设计了快速路径

  1. 首先检查 request_queue->last_merge——缓存的最近一个请求。如果新请求可以与之合并,直接合并,无需搜索红黑树
  2. 如果快速路径失败,才在红黑树中查找前驱/后继节点进行合并判断
  3. 如果无法合并,将请求插入红黑树

这种”缓存最近合并点”的优化在实际工作负载中非常有效——顺序 I/O 模式下,新请求几乎总是能与 last_merge 合并。

八、I/O 优先级:ionice 与 cgroup I/O 控制#

8.1 ionice:进程级 I/O 优先级#

Linux 允许通过 ionice 为进程设置 I/O 优先级,影响 I/O 调度器的调度决策。I/O 优先级分为三类:

  • IOPRIO_CLASS_RT(实时):最高优先级,调度器总是优先处理。谨慎使用,可能导致其他进程饥饿
  • IOPRIO_CLASS_BE(Best Effort,默认):普通优先级,分为 0-7 共 8 个级别,0 最高
  • IOPRIO_CLASS_IDLE(空闲):最低优先级,只有当没有其他类别的请求时才会被调度
# 查看进程的 I/O 优先级
ionice -p $$
# 输出:best-effort: prio 4
# 将 tar 备份设置为空闲优先级,不影响前台交互
ionice -c 3 tar czf backup.tar.gz /data
# 将数据库设置为实时优先级(谨慎!)
ionice -c 1 -n 0 mysqld
# 设置 Best Effort 优先级为最高(0)
ionice -c 2 -n 0 dd if=/dev/sda of=/dev/null
Warning

I/O 优先级的效果取决于调度器。BFQ 完整支持 ionice 的语义;mq-deadline 仅部分支持(主要区分 RT 和 IDLE);none 调度器完全忽略 I/O 优先级。

8.2 cgroup v2 I/O 控制器#

cgroup v2 提供了更精细的 I/O 带宽控制,可以限制 cgroup 的 IOPS 和吞吐量:

# 创建测试 cgroup
sudo mkdir /sys/fs/cgroup/test-io
echo $$ | sudo tee /sys/fs/cgroup/test-io/cgroup.procs
# 限制读 IOPS 为 1000
echo "8:0 rbps=10485760 wiops=1000" | sudo tee /sys/fs/cgroup/test-io/io.max
# 8:0 = 主设备号 8, 次设备号 0(即 /dev/sda)
# rbps = 读带宽限制(字节/秒),wiops = 写 IOPS 限制
# 查看当前 I/O 统计
cat /sys/fs/cgroup/test-io/io.stat
# 8:0 rbytes=12345678 wbytes=87654321 rios=1234 wios=5678
# 清理
sudo rmdir /sys/fs/cgroup/test-io

cgroup I/O 控制器与 I/O 调度器协同工作:BFQ 原生支持 cgroup 的带宽比例分配(io.weight),而其他调度器通过内核的节流(throttle)机制实现硬限制。

九、Device Mapper:块设备的软件定义层#

Device Mapper(DM)是 Linux 内核中一个强大的块设备映射框架,它允许在物理块设备之上构建虚拟的块设备,实现存储的”软件定义”。

9.1 DM 的架构#

DM 的核心概念是映射表(Mapping Table):它定义了虚拟设备的每个逻辑扇区范围映射到哪个物理设备的哪些扇区。映射表由多行组成,每行描述一个连续的映射范围:

0 2097152 linear /dev/sda1 0
2097152 1048576 linear /dev/sdb1 0

这表示:虚拟设备的 0-2097151 扇区映射到 /dev/sda1 的 0 扇区开始,2097152-3145727 扇区映射到 /dev/sdb1 的 0 扇区开始。

9.2 LVM(Logical Volume Manager)#

LVM 是 Device Mapper 最常见的应用,它提供了灵活的卷管理能力:

  • PV(Physical Volume):物理块设备(如 /dev/sda
  • VG(Volume Group):将多个 PV 组合成一个存储池
  • LV(Logical Volume):从 VG 中分配的逻辑卷,可以动态扩展/缩小

LVM 在底层使用 DM 的 linear 目标(线性映射)和 striped 目标(条带化映射)实现逻辑卷到物理卷的映射。

9.3 dm-crypt:磁盘加密#

dm-crypt 提供透明的块设备加密

  • 写入时:数据经过加密引擎加密后写入底层设备
  • 读取时:从底层设备读取密文,解密后返回给上层
  • 支持 AES-XTS、LUKS 等加密方案
  • 加密密钥由内核密钥环管理,不落盘

dm-crypt 的映射表格式:

0 2097152 crypt aes-xts-plain64 <key> 0 /dev/sda1 0

9.4 dm-verity:完整性校验#

dm-verity 用于只读分区的完整性验证,常见于 Android 系统分区:

  • 将分区划分为 4 KB 的块,为每个块计算哈希值
  • 所有块的哈希值组成一棵 Merkle 树,根哈希存储在可信位置
  • 读取时,dm-verity 实时校验块的哈希值,检测任何篡改
  • 一旦检测到不匹配,返回 EIO 错误,阻止读取被篡改的数据

9.5 其他 DM 目标#

DM 目标功能典型用途
linear线性映射LVM 基本映射
striped条带化RAID 0 性能加速
mirror镜像RAID 1 数据冗余
snapshot快照LVM 快照备份
thin精简配置存储超配
crypt加密磁盘加密
verity完整性校验Android 系统分区
multipath多路径SAN 存储高可用

十、NVMe 驱动模型:高速存储的范式革命#

NVMe(Non-Volatile Memory Express)不仅是一种新的存储接口协议,更是一种全新的驱动模型。它彻底抛弃了传统 SCSI/ATA 的命令队列模型,采用多对提交/完成队列对的设计,与 blk-mq 完美契合。

10.1 NVMe 的队列模型#

NVMe 设备维护多组提交队列(Submission Queue, SQ)完成队列(Completion Queue, CQ)

  • SQ:主机向设备提交命令的环形缓冲区。主机将 NVMe 命令写入 SQ,然后”敲响门铃”(写 Doorbell 寄存器)通知设备
  • CQ:设备向主机报告命令完成状态的环形缓冲区。设备将完成条目写入 CQ,然后触发中断(或由主机轮询)通知主机
  • 每个 SQ 关联一个 CQ,但一个 CQ 可以被多个 SQ 共享
// drivers/nvme/host/nvme.h(简化)
struct nvme_queue {
struct nvme_dev *dev;
u32 sq_tail; // SQ 尾指针(主机写)
u32 cq_head; // CQ 头指针(主机读)
u32 q_depth; // 队列深度
u16 qid; // 队列 ID(0=Admin, 1+=I/O)
struct nvme_command *sq_cmds; // SQ 命令缓冲区
struct nvme_completion *cqes; // CQ 完成条目缓冲区
// ...
};

NVMe 的 Admin Queue(队列 ID 0)用于管理命令(如创建/删除 I/O 队列、识别设备),I/O Queue(队列 ID 1+)用于数据传输。

10.2 PRP 与 SGL:数据传输的两种寻址方式#

NVMe 支持两种数据传输的内存寻址方式:

PRP(Physical Region Page)

  • NVMe 1.0 引入,简单但有限制
  • 由 PRP1 和 PRP2 两个字段描述数据在物理内存中的位置
  • PRP1 指向第一个物理页面(可以有偏移),PRP2 指向一个 PRP 列表(当数据跨越多个不连续页面时)
  • PRP 列表是一个物理页地址数组,每个条目必须页对齐
  • 限制:每个 PRP 列表最多 512 个条目(一个 4 KB 页面),因此单个命令最多描述约 2 MB 数据

SGL(Scatter-Gather List)

  • NVMe 1.1 引入,更灵活
  • 由 SGL 段(Segment)组成,每个段描述一段连续的物理内存区域
  • 支持任意对齐和长度,不受页对齐限制
  • SGL 段可以链接(类似链表),支持任意大的数据传输
  • 更好地与 bio_vec 的分散-聚集模型对应
// NVMe PRP 与 SGL 的对比
// PRP:简单但要求页对齐
struct nvme_command {
__le64 prp1; // 第一个物理页地址
__le64 prp2; // PRP 列表地址或第二个物理页地址
};
// SGL:灵活但结构更复杂
struct nvme_sgl_desc {
__le64 addr; // 数据缓冲区物理地址
__le32 length; // 数据长度
__u8 type; // SGL 描述符类型
__u8 flags;
};
Note

Linux NVMe 驱动优先使用 SGL(如果设备支持),因为 SGL 可以直接映射 bio_vec 的分散-聚集结构,避免额外的内存拷贝。对于不支持 SGL 的设备,驱动会回退到 PRP 模式,此时可能需要将 bio_vec 转换为 PRP 列表。

10.3 NVMe 驱动与 blk-mq 的对接#

NVMe 驱动是 blk-mq 的”最佳实践”用户。每个 NVMe I/O 队列对应一个 blk-mq 硬件队列:

// drivers/nvme/host/pci.c(简化)
static const struct blk_mq_ops nvme_mq_ops = {
.queue_rq = nvme_queue_rq, // 处理请求:构造 NVMe 命令并写入 SQ
.complete = nvme_complete_rq, // 请求完成回调
.init_hctx = nvme_init_hctx, // 初始化硬件队列上下文
.init_request = nvme_init_request, // 初始化请求结构
.map_queues = nvme_pci_map_queues, // 建立 CPU → 硬件队列映射
};

当 blk-mq 将请求派发给 NVMe 驱动时:

  1. nvme_queue_rq() 被调用,request 作为参数
  2. 驱动从 request 中提取 bio 信息,构造 NVMe Read/Write 命令
  3. 将命令写入 SQ 的当前尾位置,更新 sq_tail
  4. 写 SQ 的 Doorbell 寄存器,通知设备有新命令
  5. 设备处理完命令后,将完成条目写入 CQ,触发 MSI-X 中断
  6. 中断处理程序读取 CQ 条目,调用 nvme_complete_rq() 完成 request

10.4 NVMe 的中断与轮询#

NVMe 驱动支持三种 I/O 完成通知方式:

  1. 中断模式(默认):设备完成命令后触发 MSI-X 中断。每个 I/O 队列可以绑定独立的 MSI-X 向量,实现中断的负载均衡
  2. 轮询模式(blk-mq POLL):对于极低延迟场景,驱动可以轮询 CQ 而不是等待中断。通过 io_uringIOSQE_IO_HARDLINKO_DIRECT + RWF_NOWAIT 触发
  3. 混合模式:中断和轮询结合,空闲时用中断,繁忙时切换到轮询

十一、从 bio 到磁盘:完整 I/O 路径追踪#

用一个具体的例子,追踪一个 4 KB 的同步写请求从 write() 系统调用到数据落盘的完整路径:

  1. 用户空间write(fd, buf, 4096) → 系统调用 sys_write()
  2. VFS 层vfs_write()ext4_file_write_iter()generic_perform_write()
  3. Page Cache:将用户数据拷贝到 Page Cache 中的页面,标记为脏页
  4. 脏页写回pdflush/kworker 线程被唤醒,调用 ext4_writepages()
  5. bio 构造mpage_writepages() 将脏页构造为 bio,调用 submit_bio()
  6. 通用块层blk_mq_submit_bio()bio 转换为 request
  7. I/O 合并:检查是否可以与软件队列中的已有请求合并
  8. 软件队列request 加入当前 CPU 的 blk_mq_ctx
  9. 刷入硬件队列blk_mq_flush_busy_ctxs() 将请求批量移到硬件队列
  10. I/O 调度:调度器对请求进行排序/调度,派发给驱动
  11. NVMe 驱动nvme_queue_rq() 构造 NVMe Write 命令,写入 SQ,敲 Doorbell
  12. 设备处理:NVMe 控制器从 SQ 取出命令,执行写入,将完成条目写入 CQ
  13. 中断通知:MSI-X 中断触发,nvme_irq() 读取 CQ,调用 nvme_complete_rq()
  14. 完成回调bio->bi_end_io() 被调用,唤醒等待 I/O 完成的进程
  15. 返回用户空间write() 系统调用返回
Important

对于同步写(非 O_SYNC),步骤 3 之后 write() 就可以返回了——数据已经写入 Page Cache,但尚未落盘。真正的落盘发生在步骤 4-14,由内核的脏页写回机制异步执行。只有 fsync()O_SYNC 才会等待步骤 14 完成后才返回。

十二、动手实践#

实践一:查看块设备信息与调度器#

# 列出所有块设备
lsblk
# NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
# sda 8:0 0 500G 0 disk
# ├─sda1 8:1 0 512M 0 part /boot
# └─sda2 8:2 0 499.5G 0 part /
# 查看块设备的详细属性
cat /sys/block/sda/queue/scheduler
# [mq-deadline] none bfq
# 切换 I/O 调度器
echo bfq | sudo tee /sys/block/sda/queue/scheduler
# mq-deadline none [bfq]
# 查看队列参数
cat /sys/block/sda/queue/max_sectors_kb
# 1280
cat /sys/block/sda/queue/nr_requests
# 64(默认请求队列深度)

实践二:使用 iostat 监控 I/O 性能#

# 安装 sysstat 包
sudo apt install sysstat
# 每秒输出一次扩展 I/O 统计
iostat -x 1
# Device rrqm/s wrqm/s r/s w/s rMB/s wMB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
# sda 0.00 5.00 10.0 20.0 0.04 0.10 4.80 0.02 0.67 0.50 0.75 0.33 1.00
# 关键指标解读:
# rrqm/s, wrqm/s — 每秒合并的读/写请求数
# r/s, w/s — 每秒完成的读/写 IOPS
# avgrq-sz — 平均请求大小(扇区数)
# avgqu-sz — 平均队列深度
# await — 平均 I/O 延迟(毫秒),包括排队时间和服务时间
# svctm — 平均服务时间(毫秒),仅设备处理时间
# %util — 设备利用率(100% 表示设备饱和)

实践三:使用 blktrace 追踪 I/O 路径#

# 安装 blktrace
sudo apt install blktrace
# 在一个终端开始追踪 /dev/sda 的 I/O 事件
sudo blktrace -d /dev/sda -o - | blkparse -i -
# 在另一个终端产生 I/O
dd if=/dev/zero of=/tmp/testfile bs=4K count=1000 oflag=direct
# blkparse 输出解读:
# 8,0 0 1234 1.234567 C W 1024 + 8 [dd]
# ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
# 主次号 CPU 序号 时间戳 操作 起始扇区 扇区数 进程名
#
# 操作类型:
# C = 完成(Complete) Q = 入队(Queue) G = 获取请求(Get)
# I = 插入调度器(Insert) D = 派发给驱动(Dispatch) M = 合并(Merge)
# R = 读 W = 写 F = 刷回(Flush) S = 同步(Sync)

实践四:使用 ionice 控制 I/O 优先级#

# 查看当前进程的 I/O 优先级
ionice -p $$
# best-effort: prio 4
# 启动一个低优先级的后台备份
ionice -c 3 tar czf /tmp/backup.tar.gz /usr &
BACKUP_PID=$!
# 同时启动一个正常优先级的读取
dd if=/dev/sda of=/dev/null bs=1M count=100
# 观察:备份任务几乎不影响正常读取的 I/O 延迟
iostat -x 1
# 清理
kill $BACKUP_PID

实践五:观察 Device Mapper 映射#

# 查看系统中的 DM 设备
dmsetup ls
# vg-root (253:0)
# vg-swap (253:1)
# 查看 DM 设备的映射表
dmsetup table /dev/mapper/vg--root
# 0 1048576000 linear 8:2 2048
# 查看 DM 设备的状态
dmsetup status /dev/mapper/vg--root
# 0 1048576000 linear
# 如果使用 LVM,也可以通过 LVM 工具查看
lvs
# LV VG Attr LSize Pool Origin Data% Meta%
# root vg -wi-ao-- 500.00g
# swap vg -wi-ao-- 4.00g

实践六:NVMe 设备信息查看#

# 安装 nvme-cli
sudo apt install nvme-cli
# 列出 NVMe 设备
sudo nvme list
# Node SN Model Namespace Usage Format FW Rev
# /dev/nvme0n1 S4G2NF0M123456 Samsung SSD 980 PRO 1 500.11 GB 512 5B2QGXA7
# 查看 NVMe 设备的详细标识信息
sudo nvme id-ctrl /dev/nvme0
# 输出包括:型号、固件版本、队列数、队列深度等
# 查看 NVMe 命名空间信息
sudo nvme id-ns /dev/nvme0n1
# 输出包括:命名空间大小、扇区大小、LBA 格式等
# 查看 NVMe 设备的智能信息
sudo nvme smart-log /dev/nvme0
# 输出包括:温度、使用寿命、写入量、错误计数等
# 查看当前 NVMe 队列映射
cat /sys/block/nvme0n1/queue/scheduler
# [none] mq-deadline bfq
# NVMe 默认使用 none 调度器

十三、核心源码导航#

文件路径核心内容
block/blk-mq.cblk-mq 核心:blk_mq_submit_bio()blk_mq_flush_busy_ctxs()、请求分配与派发
block/blk-core.c块层核心:submit_bio()blk_cleanup_queue()、通用块层基础设施
block/blk-merge.cI/O 合并:blk_attempt_req_merge()bio_merge()、前向/后向合并逻辑
block/elevator.cI/O 调度器框架:elv_merge()elv_dispatch_sort()、调度器注册与管理
block/mq-deadline.cmq-deadline 调度器实现:FIFO + 排序队列
block/bfq-iosched.cBFQ 调度器实现:预算公平队列、低延迟启发式
drivers/nvme/host/core.cNVMe 核心驱动:命令构造、队列管理、命名空间管理
drivers/nvme/host/pci.cNVMe PCI 驱动:SQ/CQ 管理、中断处理、PRP/SGL 映射
drivers/md/dm.cDevice Mapper 核心:映射表管理、bio 重映射、目标驱动接口
include/linux/blk-mq.hblk-mq 数据结构定义:blk_mq_opsblk_mq_hw_ctxblk_mq_ctx
include/linux/blk_types.hbiobio_vec 等块 I/O 类型定义
include/linux/blkdev.hrequest_queuerequest 等块设备核心结构

参考资料#

书籍#

  • 《Linux 内核设计与实现》 第 13 章 — Robert Love 对块设备层与 I/O 调度的经典讲解
  • 《深入理解 Linux 内核》 第 14 章 — Daniel P. Bovet 等对块设备驱动与 I/O 栈的详细剖析
  • 《Linux 设备驱动程序》 第 16 章 — Jonathan Corbet 等对块设备驱动开发的权威指南
  • 《Systems Performance》 第 9 章 — Brendan Gregg 对存储 I/O 性能分析与调优的实践指南

内核文档#

规范与标准#

手册页#

  • man 1 iostat — I/O 统计监控工具
  • man 8 blktrace — 块设备 I/O 路径追踪
  • man 1 ionice — I/O 优先级设置
  • man 8 dmsetup — Device Mapper 管理工具
  • man 8 nvme — NVMe 命令行工具
  • man 8 lsblk — 块设备信息查看

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

块设备与 I/O 栈
https://blog.souloss.com/posts/linux-internals/block-devices-and-io-stack/
作者
Souloss
发布于
2025-03-12
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时