NVMe驱动要点(古猫先生)

来源:NVME驱动_古猫先生的博客-CSDN博客

主要是留存资料方便查看。其实做ZNSSSD研究读驱动没有太大必要。

古猫先生所阅读的NVMe从Kconfig和Makefile来看,了解NVMe over PCIe相关的知识点,主要关注三个文件:core.c, pci.c, scsi.c.

1.概述与nvme_core_init函数解析

打开core.c文件,找到程序入口module_init(nvme_core_init),

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
int __init nvme_core_init(void)
{

int result;

//1. 注册字符设备"nvme"
result = __register_chrdev(nvme_char_major, 0, NVME_MINORS, "nvme", &nvme_dev_fops);

if (result < 0)
return result;
else if (result > 0)
nvme_char_major = result;

//2. 新建一个nvme class,拥有者(Owner)是为THIS_MODULE
nvme_class = class_create(THIS_MODULE, "nvme");

//如果有Error发生,删除字符设备nvme
if (IS_ERR(nvme_class))
{
result = PTR_ERR(nvme_class);
goto unregister_chrdev;
}

return 0;

unregister_chrdev:
__unregister_chrdev(nvme_char_major, 0, NVME_MINORS, "nvme");
return result;
}

从上面的code来看,nvme_core_init主要做了两件事:

  1. 调用__register_chrdev函数,注册一个名为”nvme”的字符设备.
  2. 调用class_create函数,动态创建设备的逻辑类,并完成部分字段的初始化,然后将其添加到内核中。创建的逻辑类位于/sys/class/.

在注册字符设备时,涉及到了设备号的知识点:

一个字符设备或者块设备都有一个主设备号(Major)和次设备号(Minor)。主设备号用来表示一个特定的驱动程序,次设备号用来表示使用该驱动程序的各个设备。比如,我们在Linux系统上挂了两块NVMe SSD. 那么主设备号就可以自动分配一个数字(比如8),次设备号分别为1和2.

例如,在32位机子中,设备号共32位,高12位表示主设备号,低20位表示次设备号。
注册字符设备之后,可以通过open,ioctrl,release接口对其进行操作。nvme字符设备的文件操作结构体nvme_dev_fops定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static const struct file_operations nvme_dev_fops = {

.owner = THIS_MODULE,

// open()用来打开一个设备,在该函数中可以对设备进行初始化。
.open = nvme_dev_open,

// release()用来释放open()函数中申请的资源。
.release = nvme_dev_release,

// nvme_dev_ioctl()提供一种执行设备特定命令的方法。
.unlocked_ioctl = nvme_dev_ioctl,

.compat_ioctl = nvme_dev_ioctl,

/* 这里的ioctl有两个unlocked_ioctl和compat_ioctl,如果这两个同时存在的话,优先调用unlocked_ioctl,对于compat_ioctl只有打来了CONFIG_COMPAT 才会调用compat_ioctl。obj-$(CONFIG_COMPAT) += compat.o compat_ioctl.o */

};

其中:

  • nvme_dev_open通过代表设备的inode来获取设备号,然后遍历nvme_ctrl_list(所有的nvme设备都会加入这个list)找到对应的nvme设备,然后做一系列检查,最后找到的nvme设备放到file->private_data区域。file指针也是参数传进来的。

  • nvme_dev_release比较简单,就是用来释放open()函数中申请的资源。

  • nvme_dev_ioctl 中实现了5种命令。其中就包括Admin、IO命令(有一点需要注意的是,NVME_IOCTL_IO_CMD不支持NVMe设备有多个namespace的情况)。除了这两种,还有NVME_IOCTL_RESETNVME_IOCTL_SUBSYS_RESET 通过nvme的ops直接写register来Reset Ioctrl和Ioctrl_subsys。这个nvme的ops名字叫做”nvme_ctrl_ops“, 这个ops在nvme初始化中调用nvme_probe()时赋值为”nvme_pci_ctrl_ops“。。。。这里就没继续看了,毕竟只是做大概了解。

2.NVMe初始化

这次关注的是Pci.c,找到入口 module_init(nvme_init)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static int __init nvme_init(void)
{
int result;
//1, 创建全局工作队列
nvme_workq = alloc_workqueue("nvme", WQ_UNBOUND | WQ_MEM_RECLAIM, 0);
if (!nvme_workq)
return -ENOMEM;

//2, 注册NVMe驱动
result = pci_register_driver(&nvme_driver);
if (result)
destroy_workqueue(nvme_workq);

return result;
}

主要分为两部分工作:创建全局工作队列和注册NVMe驱动。

创建全局队列应该不用看。。。。

注册过程是,调用kernel提供的pci_register_driver()函数将nvme_driver注册到PCI Bus

系统启动时,BIOS会枚举整个PCI Bus, 之后将扫描到的设备通过ACPI tables传给操作系统。当操作系统加载时,PCI Bus驱动则会根据此信息读取各个PCI设备的Header Config空间,从class code寄存器获得一个特征值。class code就是PCI bus用来选择哪个驱动加载设备的唯一根据。NVMe Spec定义NVMe设备的Class code=0x010802h, 如下图。
1680767899551

根据code来看,nvme driver会将class code写入nvme_id_table,nvme_id_table中会有相应的属性。这块儿应该不用懂。 反正会有定义0x010802h 代表的NVMe设备逻辑类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static struct pci_driver nvme_driver = {

.name = "nvme",

.id_table = nvme_id_table,

.probe = nvme_probe,

.remove = nvme_remove,

.shutdown = nvme_shutdown,

.driver = {
.pm = &nvme_dev_pm_ops,
},

.sriov_configure = nvme_pci_sriov_configure,

.err_handler = &nvme_err_handler,

};

pci_register_driver()函数将nvme_driver注册到PCI Bus之后,PCI Bus就明白了这个驱动是给NVMe设备(Class code=0x010802h)用的。

到这里,只是找到PCI Bus上面驱动与NVMe设备的对应关系。nvme_init执行完毕,返回后,nvme驱动就啥事不做了,直到pci总线枚举出了这个nvme设备,就开始调用nvme_probe()函数开始干活咯。

3.nvme_probe()

同样定义在pci.c中:

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
static int nvme_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{

int node, result = -ENOMEM;
struct nvme_dev *dev;

// 通过调用dev_to_node 得到这个pci_dev的numa节点,如果没有指定的话,默认用first_memory_node,也就是第一个numa节点.
node = dev_to_node(&pdev->dev);
if (node == NUMA_NO_NODE)
set_dev_node(&pdev->dev, first_memory_node);
// 为nvme dev节点分配空间
dev = kzalloc_node(sizeof(*dev), GFP_KERNEL, node);
if (!dev)
return -ENOMEM;
// 为每个cpu core分配queue。queues为每个core分配一个io queue,所有的core共享一个admin queue。这里的queue的概念,更严格的说,是一组submission queue和completion quque。
dev->queues = kzalloc_node((num_possible_cpus() + 1) * sizeof(void *),GFP_KERNEL, node); //这里之所以多1,是因为admin-queue

if (!dev->queues)
goto free;
//增加设备对象的引用计数
dev->dev = get_device(&pdev->dev);
//为设备设置私有数据指针
pci_set_drvdata(pdev, dev);

// 获得PCI Bar的虚拟地址
result = nvme_dev_map(dev);

if (result)
goto free;

// 初始化两个work变量, 放在nvme_workq中执行
INIT_WORK(&dev->reset_work, nvme_reset_work);
INIT_WORK(&dev->remove_work, nvme_remove_dead_ctrl_work);

// 初始化定时器watchdog
setup_timer(&dev->watchdog_timer, nvme_watchdog_timer, (unsigned long)dev);
// 初始化互斥锁
mutex_init(&dev->shutdown_lock);
// 初始化完成量
init_completion(&dev->ioq_wait);

// 设置DMA需要的PRP内存池
result = nvme_setup_prp_pools(dev);

if (result)
goto put_pci;

// 初始化NVMe Controller结构
result = nvme_init_ctrl(&dev->ctrl, &pdev->dev, &nvme_pci_ctrl_ops,id->driver_data);

if (result)
goto release_pools;

// 打印日志
dev_info(dev->ctrl.device, "pci function %s\n", dev_name(&pdev->dev));
// 将reset_work放入nvme_workq工作队列
queue_work(nvme_workq, &dev->reset_work);
return 0;

release_pools:
nvme_release_prp_pools(dev);

put_pci:
put_device(dev->dev);
nvme_dev_unmap(dev);

free:
kfree(dev->queues);
kfree(dev);

return result;

}

总结:

  • 为dev,dev->queues分配空间。为每个cpu core分配一个。queues为每个core分配一个io queue,所有的core共享一个admin queue。这里的queue的概念,更严格的说,是一组submission queue和completion queue的集合。
  • 调用nvme_dev_map获得PCI Bar的虚拟地址。

  • 初始化两个work变量,定时器,互斥锁,完成量。

  • 调用nvme_setup_prp_pools设置DMA需要的PRP内存池。
  • 调用nvme_init_ctrl初始化NVMe Controller结构。
  • 通过workqueue调度dev->reset_work,也就是调度nvme_reset_work函数,来reset nvme controller。

3.1 nvme_dev_map

在 PCI 设备中,BAR(Base Address Register,基地址寄存器)用于指定设备希望映射到主内存中的内存量,并在设备枚举后保存映射内存块开始的(基)地址。一个设备最多可以拥有六个 32 位的 BAR 或将两个 BAR 组合成一个 64 位的 BAR。BAR 的值由系统软件(例如 BIOS 和操作系统内核)设置。当系统软件知道设备在地址空间方面的需求时,它将为该设备分配一个适当类型(IO、非预取式 MMIO 或预取式 MMIO)的可用地址范围。然后,系统软件将分配给设备的起始地址写入 BAR 寄存器。

一旦 BAR 的值确定了,其指定范围内的当前设备中的内部寄存器(或内部存储空间)就可以被访问了。当该设备确认某一个请求(Request)中的地址在自己的 BAR 的范围内,便会接受这请求。

假设有一个 PCI 网络适配器,它需要 256 字节的内存来存储其寄存器。在设备枚举期间,系统软件(例如 BIOS)将查询该设备的 BAR 以确定其内存需求。然后,系统软件将为该设备分配一个 256 字节的内存块,并将该内存块的起始地址写入设备的 BAR。

一旦 BAR 的值确定了,操作系统就可以通过读写该内存块中的地址来访问网络适配器的寄存器。例如,如果操作系统想要读取网络适配器的状态寄存器,它可以读取内存块中与状态寄存器对应的地址来获取该信息。

而对于容量大的块设备,例如硬盘驱动器,通常不会将其所有空间都映射到内存中。相反,操作系统会使用其他技术来访问设备的数据。

例如,在访问硬盘驱动器时,操作系统会使用 I/O 操作来读写硬盘驱动器的寄存器。这些寄存器用于指定要访问的扇区号、传输的数据量以及要执行的命令等信息。然后,硬盘驱动器会根据这些信息执行相应的操作,并通过 DMA(Direct Memory Access,直接内存访问)将数据传输到主内存中。

因此,对于容量大的块设备,操作系统并不需要将其所有空间都映射到内存中,而是使用 I/O 操作和 DMA 来访问设备的数据。

设备枚举是指计算机启动时,系统软件(例如 BIOS)扫描系统总线以识别连接到计算机上的所有设备的过程。在设备枚举期间,系统软件将读取每个设备的配置信息,包括设备 ID、厂商 ID 和 BAR 等信息。然后,系统软件将为每个设备分配资源,例如中断号、DMA 通道和内存地址空间。最后,系统软件将这些信息保存在设备的配置空间中,以便操作系统在启动时可以访问它们。枚举设备就是调用 probe。

BAR(Base Address Register,基地址寄存器)位于 PCI 设备的配置空间中。配置空间是一个设备内部的特殊存储区域,用于存储设备的配置信息,包括设备 ID、厂商 ID 和 BAR 等信息。系统软件(例如 BIOS 和操作系统内核)可以通过读写配置空间来获取和设置设备的配置信息。

当系统软件为 PCI 设备分配内存地址空间时,它会将分配给设备的内存块的起始地址写入设备的 BAR。这样,操作系统就可以通过读写该内存块中的地址来访问设备的寄存器或内部存储空间。

下图为pci设备配置空间示意图:

1680780632361

nvme_dev_map的执行过程可以主要分为三步:

第一步:调用pci_select_bars,其返回值为mask。因为pci设备的header配置空间有6个32位的Bar寄存器(如下图),所以mark中的每一位的值就代表其中一个Bar是否被置起。

第二步:调用pci_request_selected_regions,这个函数的一个参数就是之前调用pci_select_bars返回的mask值,作用就是把对应的这个几个bar保留起来,不让别人使用。

第三步:调用ioremap。在linux中我们无法直接访问物理地址,需要映射到虚拟地址,ioremap就是这个作用。映射完后,我们访问dev->bar就可以直接操作nvme设备上的寄存器了。但是代码中,并没有根据pci_select_bars的返回值来决定映射哪个bar,而是直接映射bar0,原因是nvme协议中强制规定了bar0就是内存映射的基址。
1680830380877

3.2 nvme_setup_prp_pools

主要是创建了两个dma pool,后面就可以通过其他dma函数从dma pool中获得memory了。prp_small_pool里提供的是块大小为256字节的内存,prp_page_pool提供的是块大小为Page_Size(格式化时确定,例如4KB)的内存,主要是为了对于不一样长度的prp list来做优化。

3.3 nvme_init_ctrl

nvme_init_ctrl的作用主要是调用device_create_with_groups函数创建一个名字叫nvme0的字符设备。这个nvme0中的0是通过nvme_set_instance获得的。这个过程中,通过ida_get_new获得唯一的索引值。

3.4 nvme_reset_work

4.nvme_reset_work

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
static void nvme_reset_work(struct work_struct *work)
{
struct nvme_dev *dev = container_of(work, struct nvme_dev, reset_work);

int result = -ENODEV;

// 检查NVME_CTRL_RESETTING标志,来确保nvme_reset_work不会被重复进入.
if (WARN_ON(dev->ctrl.state == NVME_CTRL_RESETTING))
goto out;

// If we're called to reset a live controller first shut it down before moving on.
if (dev->ctrl.ctrl_config & NVME_CC_ENABLE)
nvme_dev_disable(dev, false);

if (!nvme_change_ctrl_state(&dev->ctrl, NVME_CTRL_RESETTING))
goto out;

/*
nvme_pci_enable(dev)
使能nvme设备的内存空间iomem,也就是之前映射的bar空间。
设置设备具有获得总线的能力,即调用这个函数,使设备具备申请使用PCI总线的能力。
设定这个nvme设备的DMA区域大小,64 bits或者32 bits
为设备分配中断请求,INITx/MSI/MSI-X
设置Doorbell地址
。。。。应该不用细看了吧
*/
result = nvme_pci_enable(dev);
if (result)
goto out;

/*
创建Admin SQ/CQ的创建
调用nvme_alloc_queue分配NVMe queue
nvme_alloc_queue分配NVMe queue后,就要将nvme admin queue的属性以及已经分配的admin SQ/CQ内存地址写入寄存器。
writel(aqa, dev->bar + NVME_REG_AQA);
lo_hi_writeq(nvmeq->sq_dma_addr, dev->bar + NVME_REG_ASQ);
lo_hi_writeq(nvmeq->cq_dma_addr, dev->bar + NVME_REG_ACQ);
最后一步就是调用queue_request_irq申请中断。这个函数主要的工作是设置中断处理函数,默认情况下不使用线程化的中断处理,而是使用中断上下文的中断处理。
*/
result = nvme_configure_admin_queue(dev);
if (result)
goto out;

nvme_init_queue(dev->queues[0], 0);
result = nvme_alloc_admin_tags(dev);
if (result)
goto out;

/*
调用nvme_identify_ctrl读取identify data.
调用nvme_set_queue_limits设置queue write cache的大小.
*/
result = nvme_init_identify(&dev->ctrl);
if (result)
goto out;

/*
调用nvme_set_queue_count发送set feature cmd设置IO queues的数目;
确定了IO queues的数目之后,调用nvme_creat_io_queues函数完成IO queues的创建。
先分配内存 在调用nvme_create_queue真正实现SQ/CQ的创建
nvme_create_queue函数先通过调用adapter_alloc_cq和adapter_alloc_sq创建CQ/SQ, 然后在调用queue_request_irq申请中断,最后调用nvme_init_queue初始化前面创建的CQ/SQ.
*/
result = nvme_setup_io_queues(dev);
if (result)
goto out;

if (dev->online_queues > 1)
nvme_queue_async_events(&dev->ctrl);

mod_timer(&dev->watchdog_timer, round_jiffies(jiffies + HZ));

// Keep the controller around but remove all namespaces if we don't have any working I/O queue.
if (dev->online_queues < 2)
{
dev_warn(dev->ctrl.device, "IO queues not created\n");
nvme_kill_queues(&dev->ctrl);
nvme_remove_namespaces(&dev->ctrl);
}
else
{
/*
主要是调用了blk_mq_start_stopped_hw_queues和blk_mq_kick_requeue_list去启动mq-block层的hardware queues和request queues.
*/
nvme_start_queues(&dev->ctrl);
/*
对tagset结构体初始化,接着调用blk_mq_alloc_tag_set分配tag set并与request queue关联
*/
nvme_dev_add(dev);
}

if (!nvme_change_ctrl_state(&dev->ctrl, NVME_CTRL_LIVE))
{
dev_warn(dev->ctrl.device, "failed to mark controller live\n");
goto out;
}

/*
主要是调用了两个scan namespace的函数:nvme_scan_ns_list和nvme_scan_ns_sequential。
nvme_scan_ns_list,如果执行成功的话,返回0,因此不再执行nvme_scan_ns_sequential。
nvme_scan_ns_sequential的作用就是检查namespace是否合法,去掉不合法的namespace。
在nvme_scan_work的最后,将所有找到的namespace通过list_sort(NULL, &ctrl->namespaces, ns_cmp)来排序.
nvme_scan_work 就是在nvme_reset_work已经发现nvme controller的情况下,再次对这个nvme controller下面的进行扫描,因为namespace最多可以两级级联,每个nvme controller下的name space都是放在ctrl->namespaces 这个链表中,且是按照name space id的大小排序。
*/
if (dev->online_queues > 1)
nvme_queue_scan(&dev->ctrl);

return;

out:
nvme_remove_dead_ctrl(dev, result);

}

扩展: 线程化与中断上下文的概念(注: 此部分来自维基百科)

中断线程化是实现Linux实时性的一个重要步骤,在linux标准内核中,中断是最高优先级的执行单元,不管内核当时处理什么,只要有中断事件,系统将立即响应该事件并执行相应的中断处理代码,除非当时中断关闭。因此,如果系统有严重的网络或I/O负载,中断将非常频繁,后发生的实时任务将很难有机会运行,也就是说,毫无实时性可言。中断线程化之后,中断将作为内核线程运行而且赋予不同的实时优先级,实时任务可以有比中断线程更高的优先级,这样,实时任务就可以作为最高优先级的执行单元来运行,即使在严重负载下仍有实时性保证。

内核空间和用户空间是操作系统理论的基础之一,即内核功能模块运行在内核空间,而应用程序运行在用户空间。现代的CPU都具有不同的操作模式,代表不同的级别,不同的级别具有不同的功能,在较低的级别中将禁止某些操作。Linux系统设计时利用了这种硬件特性,使用了两个级别,最高级别和最低级别,内核运行在最高级别(内核态),这个级别可以进行所有操作,而应用程序运行在较低级别(用户态),在这个级别,处理器控制着对硬件的直接访问以及对内存的非授权访问。内核态和用户态有自己的内存映射,即自己的地址空间。

正是有了不同运行状态的划分,才有了上下文的概念。用户空间的应用程序,如果想要请求系统服务,比如操作一个物理设备,或者映射一段设备空间的地址到用户空间,就必须通过系统调用来(操作系统提供给用户空间的接口函数)实现。通过系统调用,用户空间的应用程序就会进入内核空间,由内核代表该进程运行于内核空间,这就涉及到上下文的切换,用户空间和内核空间具有不同的地址映射,通用或专用的寄存器组。而用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行。所谓的进程上下文,就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。

同理,硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理,中断上下文就可以理解为硬件传递过来的这些参数和内核需要保存的一些环境,主要是被中断的进程的环境。