SPDK NVMe之hello world

可以先看NVMe 将 I/O 提交到本地 PCIe 设备

什么是NVMe控制器

NVMe(Non-Volatile Memory Express)控制器是一种硬件设备,通常是一个芯片或一个PCIe卡,它用于管理和控制连接到计算机系统的NVMe存储设备,如固态硬盘(SSD)。NVMe控制器是NVMe SSD的关键组件,它负责与存储设备之间的通信,包括读取和写入数据,以及执行各种管理操作。

NVMe控制器的主要功能包括:

命令队列管理:NVMe控制器能够管理多个命令队列,这允许并行处理多个命令,从而提高了性能。

内存管理:NVMe控制器负责管理与存储设备通信所需的内存区域,包括用于存储数据和命令的缓冲区。

错误处理:它能够检测和处理与存储设备通信时可能发生的错误,以确保数据的完整性和可靠性。

固件升级:NVMe控制器通常支持固件升级,以便更新控制器的功能和性能。

NVMe协议支持:NVMe控制器遵循NVMe协议,该协议定义了与存储设备通信的规则和命令。

一个nvme控制器 控制一个ssd设备还是多个

一个NVMe控制器通常可以控制多个NVMe SSD设备,这取决于NVMe控制器的设计和规格。NVMe(Non-Volatile Memory Express)是一种高性能、低延迟的存储协议,旨在充分利用固态硬盘(SSD)的性能潜力。

每个NVMe SSD设备都有一个唯一的命名空间(Namespace),而NVMe协议允许每个NVMe控制器管理多个命名空间。每个命名空间可以看作是一个独立的逻辑存储单元,类似于传统硬盘上的分区。这些命名空间可以属于同一个NVMe SSD设备或多个设备,具体取决于硬件和系统的配置。

在具体的应用中,NVMe控制器可以通过其管理命名空间来控制多个NVMe SSD设备。这种多设备管理的方式可以提供更高的存储容量、吞吐量和可用性,特别适用于数据中心和高性能计算环境。

需要注意的是,NVMe控制器和NVMe SSD设备之间的连接通常是通过PCIe总线完成的,因此系统的PCIe插槽数量和带宽限制了可以连接到同一控制器的设备数量。在现代服务器和存储系统中,通常支持多个NVMe SSD设备连接到同一个NVMe控制器,以实现高性能和高容量的存储解决方案。

spdk_nvme_probe :用于启动NVMe控制器的枚举和连接过程。它允许应用程序发现系统中的NVMe控制器,并为每个控制器调用回调函数来处理连接和初始化。如果没有指定传输标识符,则默认使用PCIe传输标识符。函数返回0表示成功,否则返回负数表示错误

/**
 * 枚举和连接NVMe控制器。
 *
 * @param trid NVMe控制器的传输标识符(Transport Identifier)。
 *             如果为NULL,将使用默认的PCIe传输标识符。
 * @param cb_ctx 回调上下文,用于传递给 probe_cb 和 attach_cb 回调函数。
 * @param probe_cb 用于发现每个NVMe控制器的回调函数。
 * @param attach_cb 用于连接每个NVMe控制器的回调函数。
 * @param remove_cb 当控制器被删除时调用的回调函数。
 *
 * @return 成功时返回0,否则返回负数。
 */
int
spdk_nvme_probe(const struct spdk_nvme_transport_id *trid, void *cb_ctx,
		spdk_nvme_probe_cb probe_cb, spdk_nvme_attach_cb attach_cb,
		spdk_nvme_remove_cb remove_cb)
{
	struct spdk_nvme_transport_id trid_pcie;
	struct spdk_nvme_probe_ctx *probe_ctx;

	// 如果传输标识符为空,则使用默认的PCIe传输标识符
	if (trid == NULL) {
		memset(&trid_pcie, 0, sizeof(trid_pcie));
		spdk_nvme_trid_populate_transport(&trid_pcie, SPDK_NVME_TRANSPORT_PCIE);
		trid = &trid_pcie;
	}

	// 创建异步的NVMe控制器枚举上下文
	probe_ctx = spdk_nvme_probe_async(trid, cb_ctx, probe_cb,
					  attach_cb, remove_cb);
	if (!probe_ctx) {
		SPDK_ERRLOG("创建枚举上下文失败\n");
		return -1;
	}

	/*
	 * 即使一个或多个 nvme_attach() 调用失败,也要继续执行,
	 * 但要保持 rc 的值以在返回时指示错误。
	 */
	return nvme_init_controllers(probe_ctx);
}

attach_cb :这个回调函数在成功连接到NVMe控制器后会执行,并可以用于处理连接后的初始化和配置操作。在函数中,它首先分配一个数据结构来存储有关控制器的信息,然后打印连接的控制器的地址,获取控制器的详细信息,并将连接的控制器添加到全局链表中,最后注册控制器上的命名空间。

/**
 * NVMe控制器连接成功后的回调函数。
 *
 * @param cb_ctx 回调上下文,用于传递给回调函数。
 * @param trid 连接的NVMe控制器的传输标识符(Transport Identifier)。
 * @param ctrlr 已连接的NVMe控制器。
 * @param opts NVMe控制器的选项。
 */
static void
attach_cb(void *cb_ctx, const struct spdk_nvme_transport_id *trid,
	  struct spdk_nvme_ctrlr *ctrlr, const struct spdk_nvme_ctrlr_opts *opts)
{
	int nsid;
	struct ctrlr_entry *entry;
	struct spdk_nvme_ns *ns;
	const struct spdk_nvme_ctrlr_data *cdata;

	// 分配一个用于存储NVMe控制器信息的数据结构
	entry = malloc(sizeof(struct ctrlr_entry));
	if (entry == NULL) {
		perror("ctrlr_entry malloc");
		exit(1);
	}

	// 连接成功的NVMe控制器的传输地址
	printf("已连接到 %s\n", trid->traddr);

	/*
	 * spdk_nvme_ctrlr 是SPDK中对NVMe控制器的逻辑抽象。
	 * 在初始化过程中,通过NVMe管理命令读取控制器的IDENTIFY数据,
	 * 并且可以使用spdk_nvme_ctrlr_get_data()来获取控制器的详细信息。
	 * 有关NVMe控制器IDENTIFY的详细信息,请参考NVMe规范。
	 */
	cdata = spdk_nvme_ctrlr_get_data(ctrlr);

	// 格式化NVMe控制器的名称和序列号,并将其存储在entry->name中
	snprintf(entry->name, sizeof(entry->name), "%-20.20s (%-20.20s)", cdata->mn, cdata->sn);

	// 将连接的NVMe控制器添加到全局控制器链表中
	entry->ctrlr = ctrlr;
	TAILQ_INSERT_TAIL(&g_controllers, entry, link);

	/*
	 * 每个控制器都有一个或多个命名空间(namespace)。
	 * 一个NVMe命名空间基本上等同于SCSI LUN(逻辑单元号)。
	 * 控制器的IDENTIFY数据告诉我们控制器上有多少个命名空间。
	 * 对于Intel(R) P3X00控制器,通常只有一个命名空间。
	 *
	 * 请注意,在NVMe中,命名空间ID从1开始,而不是从0开始。
	 */
	for (nsid = spdk_nvme_ctrlr_get_first_active_ns(ctrlr); nsid != 0;
	     nsid = spdk_nvme_ctrlr_get_next_active_ns(ctrlr, nsid)) {
		ns = spdk_nvme_ctrlr_get_ns(ctrlr, nsid);
		if (ns == NULL) {
			continue;
		}
		// 注册连接的NVMe命名空间
		register_ns(ctrlr, ns);
	}
}

读写过程

3.1 HOST和Controller数据传输

(1) HOST、Controller和Qpair

HOST 就是NVMe卡所插入的系统,如图3所示,HOST和Controller之间的交互通过Qpair进行。Qpair分为IO Qpair和Admin Qpair,顾名思义,Admin Qpair用于控制命令的传输,而IO Qpair用于IO命令的传输[1]。

Qpair对由提交队列(Submission Queue, SQ)和完成队列(Completion Queue, CQ)组成的固定元素数量的环形队列[2]。提交队列是由固定元素数量的64字节的命令组成的数组,加上2个整数(头和尾索引)。完成队列由固定元素数量的16字节命令加上2个整数(头和尾索引)所组成的环形队列。另外还有两个32位寄存器(Doorbell),Head Doorbell和Tail Doorbell。

HOST和Controller通信

IO过程

下面详细说明如何通过SPDK将I/O提交到本地PCIe设备。

通过构造一个64字节的命令,将I/O提交到NVMe设备,将其放入提交队列尾部索引当前位置的提交队列中,然后将提交队列尾部的新索引写入提交队列Tail Doorbell。也可以写多条命令到SQ,然后只写一次Doorbell就可以提交所有命令。

命令本身描述了操作,还描述了主机内存中包含与命令关联的主机内存数据的位置,也就是我们要写入数据的位置,或将读取的数据放置到内存中的位置。通过DMA的方式将数据传输到该地址或从该地址传输数据。

完成队列的工作方式类似,设备将命令的响应消息写入到CQ中。CQ中的每个元素包含一个相位Phase Tag,在整个环的每个循环上在0和1之间切换。设备通过中断通知HOST CQ的更新,但是SPDK不启用中断,而是轮询相位位以检测CQ的更新。中断是非常繁重的操作,因此轮询这个相位通常效率要高得多。图5详细展示了Host和Controller交互的过程。

在这里插入图片描述

HOST和Controller交互过程[3]

HOST和Controller交互的过程如下:

  1. HOST将一个或多个命令放入下一个可用的SQ slot(s)中。

  2. HOST更新SQ Tail Doorbell 寄存器,向Controller表明有新的命令进行了提交,需要进行处理。

  3. 控制器将提交队列插槽中的命令传输到控制器以执行。

  4. 控制器继续执行下一个命令。

  5. 命令完成执行后,控制器将命令完成状态写入到相对应的CQ中。

  6. 控制器通过中断通知HOST有新的CQ entry生成,需要进行处理。

  7. 主机处理CQ中的新的元素。

  8. HOST写入CQ Head Doorbell寄存器,以指示完成队列元素已被处理。

SPDK IO Process

接下来我们进行设备的读写过程的研究。在hello_world.c函数中,设备的读写主要是hello_world函数中完成的。

整体结构也比较清晰,在函数中,遍历namespace,首先进行IO Qpair的创建,然后分配一段buffer用来传输数据以及后续数据接收的验证。接着就可以调用spdk_nvme_ns_cmd_write将要写入的内容写入到namespace的LBA 0,轮询CQ,对CQ中的命令进行处理,最后将IO Qpair释放。接下来进行具体分析。hello_world的整体代码结构如下所示:

/**
 * 执行NVMe "Hello world!" 示例操作。
 */
static void
hello_world(void)
{
	struct ns_entry			*ns_entry;
	struct hello_world_sequence	sequence;
	int				rc;
	size_t				sz;

	TAILQ_FOREACH(ns_entry, &g_namespaces, link) {
		/*
		 * 分配一个I/O qpair,用于提交读/写请求到控制器的命名空间。
		 * NVMe控制器通常支持多个qpairs。任何为控制器分配的I/O qpair
		 * 都可以提交I/O到该控制器上的任何命名空间。
		 *
		 * SPDK NVMe驱动程序不提供qpairs访问的同步,应用程序必须确保
		 * 只有一个线程提交I/O到一个qpairs,并且同一个线程必须
		 * 在该qpairs上检查完成。这样可以通过使所有I/O操作完全无锁来实现
		 * 极高的I/O处理效率。
		 */
		ns_entry->qpair = spdk_nvme_ctrlr_alloc_io_qpair(ns_entry->ctrlr, NULL, 0);
		if (ns_entry->qpair == NULL) {
			printf("错误: spdk_nvme_ctrlr_alloc_io_qpair() 失败\n");
			return;
		}

		/*
		 * 使用spdk_dma_zmalloc分配一个4KB的零填充缓冲区。
		 * 此内存将被固定,这是SPDK NVMe I/O操作所需的数据缓冲区。
		 */
		sequence.using_cmb_io = 1;
		sequence.buf = spdk_nvme_ctrlr_map_cmb(ns_entry->ctrlr, &sz);
		if (sequence.buf == NULL || sz < 0x1000) {
			sequence.using_cmb_io = 0;
			sequence.buf = spdk_zmalloc(0x1000, 0x1000, NULL, SPDK_ENV_SOCKET_ID_ANY, SPDK_MALLOC_DMA);
		}
		if (sequence.buf == NULL) {
			printf("错误: 写入缓冲区分配失败\n");
			return;
		}
		if (sequence.using_cmb_io) {
			printf("信息: 使用控制器内存缓冲区进行I/O\n");
		} else {
			printf("信息: 使用主机内存缓冲区进行I/O\n");
		}
		sequence.is_completed = 0;
		sequence.ns_entry = ns_entry;

		/*
		 * 如果命名空间是Zoned命名空间(Zoned Namespace),而不是常规NVM命名空间,
		 * 我们需要在写入之前重置第一个区域(zone)。对于常规NVM命名空间,这是不需要的。
		 */
		if (spdk_nvme_ns_get_csi(ns_entry->ns) == SPDK_NVME_CSI_ZNS) {
			reset_zone_and_wait_for_completion(&sequence);
		}

		/*
		 * 将 "Hello world!" 打印到 sequence.buf 缓冲区。
		 * 我们将此数据写入命名空间的LBA 0,然后稍后将其读入到一个单独的缓冲区,
		 * 以演示完整的I/O路径。
		 */
		snprintf(sequence.buf, 0x1000, "%s", "Hello world!\n");

		/*
		 * 将数据缓冲区写入此命名空间的LBA 0。
		 * "write_complete" 和 "&sequence" 被指定为完成回调函数和参数。
		 * 当写入I/O完成时,将使用参数 &sequence 调用 write_complete()。
		 * 这允许用户为每个I/O指定不同的完成回调例程,以及传递唯一的句柄
		 * 作为参数,以便应用程序知道哪个I/O已完成。
		 *
		 * 请注意,SPDK NVMe驱动程序只会在应用程序调用spdk_nvme_qpair_process_completions()
		 * 时检查完成。触发轮询过程是应用程序的责任。
		 */
		rc = spdk_nvme_ns_cmd_write(ns_entry->ns, ns_entry->qpair, sequence.buf,
					    0, /* LBA start */
					    1, /* number of LBAs */
					    write_complete, &sequence, 0);
		if (rc != 0) {
			fprintf(stderr, "启动写入I/O失败\n");
			exit(1);
		}

		/*
		 * 轮询完成。这里的0表示处理所有可用的完成。
		 * 在某些使用模型中,调用者可以指定正整数,而不是0,以表示应该处理的最大完成数。
		 * 该函数永远不会阻塞 - 如果指定的qpair上没有待处理的完成,它将立即返回。
		 *
		 * 当写入I/O完成时,write_complete()将提交一个新的I/O,以读取LBA 0到一个单独的缓冲区,
		 * 并指定read_complete()作为其完成例程。当读取I/O完成时,read_complete()将打印缓冲区内容,
		 * 并将 sequence.is_completed = 1。这将终止此循环,然后退出程序。
		 */
		while (!sequence.is_completed) {
			spdk_nvme_qpair_process_completions(ns_entry->qpair, 0);
		}

		/*
		 * 释放I/O qpair。通常在应用程序退出时执行此操作。
		 * 但是SPDK支持在运行时释放并重新分配qpairs。在尝试释放qpairs之前,调用方必须确保所有待处理的I/O已完成。
		 */
		spdk_nvme_ctrlr_free_io_qpair(ns_entry->qpair);
	}
}

接下来对helloworld进一步解析

创建IO Qpair

首先需要进行IO Qpqir的创建,IO Qpair初始化过程中预先分配了一组Request对象来跟踪IO过程。操作是异步的,因此它不能简单地跟踪调用堆栈上请求的状态。在堆上分配新的请求对象会太慢,因此SPDK会在NVMe Qpair对象struct SPDK_NVMe_qpair中预先分配一组Request对象。分配Qpair的请求数大于NVMe提交队列的实际队列深度,SPDK支持对提交的请求进行排队,这将允许用户能够提交多于硬件队列实际容纳的请求。

提交SQ

在这里插入图片描述
Host向Controller提交命令的整体结构如图6所示。首先构建64字节NVMe命令,该命令内置于到Request对象中,而不是直接嵌入到NVMe提交队列中。命令创建好之后,SPDK会查找队列中第一个可用的队列项,SPDK会为提交队列中的每个元素分配一个Tracker的对象,并且Trackers是以数组的形式组织的,这意味这我们可以进行快速的索引。Tracker包含指向当前占用该队列元素的Request的指针。我们使用Tracker数组的index更新命令的CID值,这意味我们可以借助CID值对Tracker进行索引,这样可以找到对应的Request,当然也就可以找到对应的命令,这在恢复Request时很有用。

创建好Tracker之后,建立PRP list用于描述数据缓冲区,SPDK将64字节的命令复制到实际的NVMe提交队列中,然后更新提交队列Tail Doorbell,通知设备去处理。然后SPDK直接返回给用户,而不需要等待命令的完成。

轮询CQ

用户轮询spdk_nvme_qpair_process_completions来告诉SPDK检查完成队列。具体来说,它读取CQ的元素的Phase Tag,当它翻转时,查看命令的CID值并以CID值作为索引找到指向Request对象的Tacker。Request对象包含用户最初提供的函数指针,然后调用该指针来完成命令。若用户未指定最多处理多少个完成IO (max_completions),spdk_nvme_qpair_process_completions函数将执行完当前CQ中所有已经完成的IO,然后统一更新CQ Head Doorbell,告诉设备有可用的CQ元素 。

在spdk_nvme_ns_cmd_write函数的回调函数write_complete中,当写入的IO完成后,将会释放写IO相关联的缓冲区,然后重新分配一个缓冲区用于将写入NVMe的数据读取回来,spdk_nvme_ns_cmd_read函数完成数据读取的功能,以此进行验证,完成完整的一次写入和读取的过程。读取过程和写入过程类似,同样地,读取完成后调用read_complete回调函数,在read_complete回调函数中会打印出读取过来的内容,并且释放缓冲区。read_complete还会将sequence.is_completed 置1作为结束轮询的标志。循环的函数如下:
在这里插入图片描述
CQ的函数执行流程如图所示
在这里插入图片描述
释放资源

释放资源的过程都在cleanup函数中,首先遍历Namespace和Controller,清理全局链表,在对每个Controller所拥有的资源进行清理时,分配了一个detach_ctx来确保资源的完全释放。资源的释放过程主要分为两个函数来进行,spdk_nvme_detach_async和spdk_nvme_detach_poll_async。

在释放资源过程中会执行未完成的IO,删除SQ和CQ,并且修改相关寄存器值。当detach_ctx不为空并且spdk_nvme_detach_poll_async返回值为-EAGIN时,会一直调用spdk_nvme_detach_poll_async直到整个detach过程结束。

运行结果

在这里插入图片描述

参考文章链接:https://blog.csdn.net/weixin_37097605/article/details/128125311

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

写一封情书

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值