Linux 块层编程指南
1 存储栈IO路径简介
当使用 read() 和 write() 系统调用向内核提交I/O请求后,要历经多个层次才能完整执行。简单来说,一般包括了以下流程:
- 首先,经过虚拟文件系统。虚拟文件系统提供了统一的文件和文件系统的相关接口,屏蔽了不同文件系统的差异和操作细节;
- 其次,经过适配当前磁盘分区的文件系统。常见文件系统包括了EXT2/3/4、FATFS等;
- 然后,经过通用块层。块层会将I/O请求根据需求进行切分和合并等处理,并交给调度层进行请求调度。处理完成后会将I/O请求下发给驱动程序继续进行操作;
- 最后,经过驱动程序。驱动根据总线协议将请求转换为对应格式的命令。
通常,应用程序通过系统调用的方式发送I/O相关命令到虚拟文件系统。而具体的文件系统在实现上有很大区别,难以用一篇文章简单的概括。本文重点关注I/O请求在通用块层及其之下的层次中是怎样被处理的。
本文以Linux kernel 5.10版本为例,从关键字段出发,通过介绍几个关键函数,着重分析I/O处理过程中的数据传递流程。此外,介绍相关字段的定义方式,为块层和驱动开发提供一份编程指南。
2 I/O请求的创建和下发
开始前,先给出一个IO请求创建和下发过程的调用栈样例:
1 |
|
2.1 bio
bio结构体是块设备I/O的基础单元,它描述了一次I/O操作。一个bio结构体包含了需要被读写的数据块、数据缓冲区的指针等信息。
2.1.1 bio的创建
通常,bio来自上层文件系统,由文件系统负责创建和初始化等工作。然而,在块层开发工作中,特别是基于Device Mapper机制进行开发时,会遇到创建新的bio结构体来承载命令的需求。本小节介绍两种常用的bio创建方式。
bio clone
bio_clone_fast
函数用于克隆一个现有的bio结构体。常用于对bio进行重用。
1 |
|
bio alloc
bio_alloc
函数用于创建一个新的bio结构体,并为其分配所需的资源。
1 |
|
创建完成后,还要根据需求,对bio的各个字段进行相应设置。以下是一个简单示例:
1 |
|
2.1.2 bio的下发
下发请求的同时更新统计信息:
1 |
|
下发请求的同时更新不统计信息:
1 |
|
2.2 request
request 结构体是I/O请求的更高级表示,它通常包含一个或多个bio 结构体。同一request中的bio一般具有相似的特征,如具有相同的写入提示(write hint)等。从bio到request的过程中,需要根据bio中的相应字段来设置request的字段。
2.2.1 request的创建
blk_mq_alloc_request
通过此函数可申请一个新的request。一般情况下,bio提交后的处理函数会自行创建和分配request。
1 |
|
blk_mq_submit_bio
此函数会调用__blk_mq_alloc_request
函数创建request
1 |
|
通常,在对bio进行定制化修改(如添加新字段后),如果需要更快地在底层使用这些字段,可以在request中添加相应的字段,并进行相应赋值。
1 |
|
2.2.2 request的下发
如不涉及I/O调度相关的工作,此部分仅作了解即可。
1 |
|
2.3 NVMe command
NVMe(Non-Volatile Memory Express)是一种用于访问非易失性存储介质(如SSD)的传输协议。
2.3.1 NVMe command的创建
NVMe command是一种固定长度的命令格式。命令的解析方式根据需求不同可以自行定义,但单个命令的大小必须为64B。这是它与request和bio的一个重要的区别。
nvme_queue_rq
1 |
|
创建完成后,同样的需要进行相应字段的赋值。注意:如果在上层添加了新字段,需要认真分析每一个命令的保留字段,从中选择合适的接收新字段的值。
1 |
|
2.3.2 NVMe command的下发
NVMe command提交到固态盘的过程与固态盘所采用的协议有关。具体细节一般无需关注。
1 |
|
3 SSD内的IO处理
SSD(固态硬盘)作为一种主动设备,其性能提升的一大关键在于使用了提交队列(SQ)和完成队列(CQ)机制。这些机制在NVMe(Non-Volatile Memory Express)协议中得到了广泛应用,是现代高性能存储设备的核心技术之一。
3.1 提交队列(SQ)和完成队列(CQ)的工作机制
3.1.1 提交队列(Submission Queue,SQ)
提交队列是主机向SSD提交I/O请求的地方。每个提交队列都是一个环形缓冲区,存储着待执行的命令。以下是SQ的工作机制:
命令提交:
- 主机将I/O命令(如读或写命令)写入提交队列。
- 每个命令包含了操作的类型、目标地址、数据长度等信息。
- 主机更新提交队列的尾指针(Tail Pointer),指向新的命令位置。
命令传递:
- 主机通过DMA(Direct Memory Access,直接内存访问)将提交队列中的命令传递给SSD。
- NVMe控制器轮询或中断方式检测到提交队列的变化,读取新命令进行处理。
3.1.2 完成队列(Completion Queue,CQ)
完成队列是SSD向主机报告已完成的I/O请求的地方。每个完成队列也是一个环形缓冲区,存储着命令完成的信息。以下是CQ的工作机制:
命令完成:
- SSD处理完一个命令后,将命令的完成信息写入完成队列。
- 每条完成信息包含了命令的标识符、执行状态等信息。
- SSD更新完成队列的尾指针,指向新的完成信息位置。
命令返回:
- 主机通过DMA读取完成队列中的完成信息。
- 主机更新完成队列的头指针(Head Pointer),标识已处理的完成信息。
- 主机根据完成信息,执行相应的回调函数或处理后续操作。
4 I/O请求的返回和结束
开始前,先给出一个IO请求返回和结束过程的调用栈样例:
1 |
|
4.1 NVMe command
4.1.1 NVMe command的返回
1 |
|
4.1.2 NVMe command的结束
1 |
|
4.2 request
4.2.1 request的返回
request需要接收cqe中附加的请求执行结果相关信息。如果需要在返回结果中传递特定信息(如ZNS append命令需要返回的实际LBA信息),需要在nvme_end_req
函数中进行相应的赋值。
1 |
|
4.2.2 Request的结束
1 |
|
4.3 bio
4.3.1 bio的返回
同样的,bio需要接收来自request的命令执行相关信息。相关信息在req_bio_endio
函数中进行相应的赋值。
1 |
|
4.3.2 bio的结束
1 |
|
5 如何添加一个新的I/O路径
添加一个新的I/O路径主要包含两个任务:一是新命令和新字段的定义,二是上面提到的新字段值的传递。以下是一个需要修改的结构体的示例:
1 |
|