Linux 块层编程指南

1 存储栈IO路径简介

当使用 read() 和 write() 系统调用向内核提交I/O请求后,要历经多个层次才能完整执行。简单来说,一般包括了以下流程:

  1. 首先,经过虚拟文件系统。虚拟文件系统提供了统一的文件和文件系统的相关接口,屏蔽了不同文件系统的差异和操作细节;
  2. 其次,经过适配当前磁盘分区的文件系统。常见文件系统包括了EXT2/3/4、FATFS等;
  3. 然后,经过通用块层。块层会将I/O请求根据需求进行切分和合并等处理,并交给调度层进行请求调度。处理完成后会将I/O请求下发给驱动程序继续进行操作;
  4. 最后,经过驱动程序。驱动根据总线协议将请求转换为对应格式的命令。

通常,应用程序通过系统调用的方式发送I/O相关命令到虚拟文件系统。而具体的文件系统在实现上有很大区别,难以用一篇文章简单的概括。本文重点关注I/O请求在通用块层及其之下的层次中是怎样被处理的。

本文以Linux kernel 5.10版本为例,从关键字段出发,通过介绍几个关键函数,着重分析I/O处理过程中的数据传递流程。此外,介绍相关字段的定义方式,为块层和驱动开发提供一份编程指南。

2 I/O请求的创建和下发

开始前,先给出一个IO请求创建和下发过程的调用栈样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
sumbit_bio_noacct(struct bio * bio)

__submit_bio_nocacct_mq(struct bio * bio)

blk_mq_submit_bio(struct bio * bio)

blk_mq_try_issue_directly(struct blk_mq_hw_ctx *hctx, struct request *rq, blk_qc_t *cookie)

__blk_mq_try_issue_directly(struct blk_mq_hw_ctx *hctx, struct request *rq, blk_qc_t *cookie, bool bypass_insert, bool last)

__blk_mq_issue_directly(struct blk_mq_hw_ctx *hctx, struct request *rq, blk_qc_t *cookie)

nvme_queue_rq(struct blk_mq_hw_ctx *hctx, const struct blk_mq_queue_data *bd)

nvme_submit_cmd(struct nvme_queue *nvmeq, struct nvme_command *cmd, bool write_sq)

nvme_write_sq_db(nvmeq, write_sq)

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
2
3
// struct bio_set bs;
// bioset_init(&bs, NUMBER, 0, BIOSET_NEED_BVECS)
struct bio *clone = bio_clone_fast(bio, GFP_NOIO, &bs);

bio alloc

bio_alloc函数用于创建一个新的bio结构体,并为其分配所需的资源。

1
2
3
4
struct bio *b  = bio_alloc(GFP_KERNEL, 1);

struct page *page = alloc_page(GFP_KERNEL);
bio_add_page(read_bio, page, PAGE_SIZE, 0)

创建完成后,还要根据需求,对bio的各个字段进行相应设置。以下是一个简单示例:

1
2
3
4
5
6
7
8
struct *bio;
bio->bi_opf = REQ_OP_KV_RETRIEVE | REQ_NOMERGE;
// void my_end_io(struct bio *bio);
bio->bi_end_io = my_end_io;
// sturct xxx_config *private_data;
bio->bi_private = private_data;
// struct block_device *bdev
bio_set_dev(bio, bdev);

2.1.2 bio的下发

下发请求的同时更新统计信息:

1
sumbit_bio(bio);

下发请求的同时更新不统计信息:

1
sumbit_bio_noacct(bio);

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
2
3
4
// struct request_queue *q;
// unsigned int op;
// blk_mq_req_flags_t flags;
struct request *req = blk_mq_alloc_request(q, op, flags);

blk_mq_submit_bio

此函数会调用__blk_mq_alloc_request函数创建request

1
2
3
// struct request_queue *q = bio->bi_disk->queue;
// struct blk_mq_alloc_data data = {.q = q,};
struct request *rq = __blk_mq_alloc_request(&data);

通常,在对bio进行定制化修改(如添加新字段后),如果需要更快地在底层使用这些字段,可以在request中添加相应的字段,并进行相应赋值。

1
2
// static void blk_mq_bio_to_request(struct request *rq, struct bio *bio, unsigned int nr_segs)
rq->new_value = bio->newvalue;

2.2.2 request的下发

如不涉及I/O调度相关的工作,此部分仅作了解即可。

1
blk_mq_try_issue_directly(data.hctx, rq, &cookie);

2.3 NVMe command

NVMe(Non-Volatile Memory Express)是一种用于访问非易失性存储介质(如SSD)的传输协议。

2.3.1 NVMe command的创建

NVMe command是一种固定长度的命令格式。命令的解析方式根据需求不同可以自行定义,但单个命令的大小必须为64B。这是它与request和bio的一个重要的区别。

nvme_queue_rq

1
struct nvme_command cmnd;

创建完成后,同样的需要进行相应字段的赋值。注意:如果在上层添加了新字段,需要认真分析每一个命令的保留字段,从中选择合适的接收新字段的值。

1
2
3
// blk_status_t nvme_setup_cmd(struct nvme_ns *ns, struct request *req, struct nvme_command *cmd)
// enum nvme_opcode op;
cmd->common.opcode = op;

2.3.2 NVMe command的下发

NVMe command提交到固态盘的过程与固态盘所采用的协议有关。具体细节一般无需关注。

1
nvme_submit_cmd(nvmeq, &cmnd, bd->last);

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的工作机制:

  1. 命令提交:

    • 主机将I/O命令(如读或写命令)写入提交队列。
    • 每个命令包含了操作的类型、目标地址、数据长度等信息。
    • 主机更新提交队列的尾指针(Tail Pointer),指向新的命令位置。
  2. 命令传递:

    • 主机通过DMA(Direct Memory Access,直接内存访问)将提交队列中的命令传递给SSD。
    • NVMe控制器轮询或中断方式检测到提交队列的变化,读取新命令进行处理。

3.1.2 完成队列(Completion Queue,CQ)

完成队列是SSD向主机报告已完成的I/O请求的地方。每个完成队列也是一个环形缓冲区,存储着命令完成的信息。以下是CQ的工作机制:

  1. 命令完成:

    • SSD处理完一个命令后,将命令的完成信息写入完成队列。
    • 每条完成信息包含了命令的标识符、执行状态等信息。
    • SSD更新完成队列的尾指针,指向新的完成信息位置。
  2. 命令返回:

    • 主机通过DMA读取完成队列中的完成信息。
    • 主机更新完成队列的头指针(Head Pointer),标识已处理的完成信息。
    • 主机根据完成信息,执行相应的回调函数或处理后续操作。

4 I/O请求的返回和结束

开始前,先给出一个IO请求返回和结束过程的调用栈样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
nvme_process_cq(struct nvme_queue *nvmeq)

nvme_handle_cqe(struct nvme_queue *nvmeq, u16 idx)

nvme_pci_complete_rq(struct request *req)

nvme_complete_rq(struct request *req)

nvme_end_req(struct request *req)

blk_mq_end_request(struct request *rq, blk_status_t error)

__blk_update_request(struct request *req, blk_status_t error,unsigned int nr_bytes)

req_bio_endio(struct request *rq, struct bio *bio, unsigned int nbytes, blk_status_t error)

bio_endio(struct bio *bio);

4.1 NVMe command

4.1.1 NVMe command的返回

1
struct nvme_completion *cqe = &nvmeq->cqes[idx];

4.1.2 NVMe command的结束

1
nvme_complete_rq(struct request *req)

4.2 request

4.2.1 request的返回

request需要接收cqe中附加的请求执行结果相关信息。如果需要在返回结果中传递特定信息(如ZNS append命令需要返回的实际LBA信息),需要在nvme_end_req函数中进行相应的赋值。

1
req->result = le32_to_cpu(nvme_req(req)->result.u32)

4.2.2 Request的结束

1
req_bio_endio(req, bio, bio_bytes, error);

4.3 bio

4.3.1 bio的返回

同样的,bio需要接收来自request的命令执行相关信息。相关信息在req_bio_endio函数中进行相应的赋值。

1
bio->bi_result = rq->result;

4.3.2 bio的结束

1
bio_endio(bio);

5 如何添加一个新的I/O路径

添加一个新的I/O路径主要包含两个任务:一是新命令和新字段的定义,二是上面提到的新字段值的传递。以下是一个需要修改的结构体的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// include/linux/blk_types.h
struct bio {
···
int new_value;
···
}

enum req_opf {
···
REQ_NEW_OP = 100,
···
}

// include/linux/blkdev.h
struct request {
···
int new_value;
···
}

// inlude/linux/nvme.h
enum nvme_opcode {
···
nvme_new_op = 100,
···
}

struct nvme_command {
···
struct nvme_new_command new;
···
}

struct nvme_new_command {
__u8 opcde;
__u8 flags;
__u16 command_id;
__le32 nsid;
__u64 rsvd;
__le32 offset;
__u32 rsvd2;
union nvme_data_ptr dptr; /* value dptr prp1,2 */
__le32 value_len; /* size in word */
__u8 key_len; /* 0 ~ 255 (keylen - 1) */
__u8 option;
__u8 invalid_byte:2;
__u8 rsvd3:6;
__u8 rsvd4;
union {
struct {
char key[16];
};
struct {
__le64 key_prp;
__le64 key_prp2;
};
};
};

Linux 块层编程指南
https://zdawng.github.io/posts/43eef8dd/
作者
ZDawnG
发布于
2024年7月2日
许可协议