【前言】
关于DPDK如果实现bypass内核的原理,在上一篇《【DPDK】谈谈DPDK如何实现bypass内核的原理 其一 PCI设备与UIO驱动》中已经描述了在DPDK启动前做的准备工作,那么本篇文章将着重分析DPDK部分的职责,也就是从软件的的角度来分析在第一篇文章的基础上,如何做到真正的操作设备。
注意:
- 本篇文章将会更着重分析软件部分的实现,也就是分析代码实现;
- 同样,本篇会跨过中断部分与vfio部分,中断部分与vfio会在以后另开文章继续分析;
- 人能力以及水平有限,没办法保证没有疏漏,如有疏漏还请各路神仙进行指正,本篇内容都是本人个人理解,也就是原创内容。
- 另外在分析代码的过程中,为了防止一些无挂紧要的逻辑显得代码又臭又长,会对其中不重要或者与主要逻辑不相关的代码进行省略,包括且不限于,变量声明、部分不重要数据的初始化、异常处理、无关主要逻辑的模块函数调用等。
【1.DPDK的初始化】
再次回顾第一篇文章中的三个Questions:
Q:igb_uio/vfio-pci的作用是什么?为什么要用这两个驱动?这里的“驱动”和dpdk内部对网卡的“驱动”(dpdk/driver/)有什么区别呢?
Q:dpdk-devbinds是如何做到的将内核驱动解绑后绑定新的驱动呢?
Q:dpdk应用内部是如何操作pci设备的呢?是怎么让pci设备可以将数据包直接扔到用户态的呢?
其中第一个和第二个Questions便是DPDK应用启动前的前奏,其原理在第一篇文章已经阐述完毕,现在回到第三个Questions,DPDK应用内部是如何操作pci设备的。
回想DPDK应用的启动过程,以最标准的l3fwd应用启动为例,其启动的参数格式如下:
l3fwd [eal params] -- [config params]
参数分为两部分,第一部分为所有DPDK应用基本都要输入的参数,也就是eal参数,关于eal参数的解释可以看DPDK官方的doc:
https://doc.dpdk.org/guides/linux_gsg/linux_eal_parameters.html
其中,eal参数的作用主要是DPDK初始化时使用,阅读过DPDK example的源代码或在DPDK的基础上开发的应用,对一个函数应该颇为熟悉:
int rte_eal_init(int argc, char **argv)
其中eal参数便是给rte_eal_init进行初始化,指示DPDK应用“该怎么初始化”。
【2.准备工作】
在进行PCI的资源扫描之前有一些准备工作,这部分的工作不是在main函数中完成的,也更不是在rte_eal_init这个DPDK初始化函数中完成的,来到DPDK源代码中的drivers/bus/pci/pci_common.c文件中,在这个.c文件中的最后部分我们可以看到如下的代码:
struct rte_pci_bus rte_pci_bus = { .bus = { .scan = rte_pci_scan, .probe = rte_pci_probe, .find_device = pci_find_device, .plug = pci_plug, .unplug = pci_unplug, .parse = pci_parse, .dma_map = pci_dma_map, .dma_unmap = pci_dma_unmap, .get_iommu_class = rte_pci_get_iommu_class, .dev_iterate = rte_pci_dev_iterate, .hot_unplug_handler = pci_hot_unplug_handler, .sigbus_handler = pci_sigbus_handler, }, .device_list = TAILQ_HEAD_INITIALIZER(rte_pci_bus.device_list), .driver_list = TAILQ_HEAD_INITIALIZER(rte_pci_bus.driver_list), };
RTE_REGISTER_BUS(pci, rte_pci_bus.bus);
代码1.
如果看过内核代码,那么对这种“操作”应该会比较亲切,代码1中的操作是一种利用C语言实现类似于面向对象语言泛型的一种常见方式,例如C++。其中数据结构struct rte_pci_bus 可以看作一类总线的抽象,那么这个代码1中描述的便是PCI这种总线的实例。但是同样要注意一点,代码1中的struct rte_pci_bus rte_pci_bus这个变量的类型和变量名字长得他娘的一模一样....接下来可以看一下RTE_REGISTER_BUS这个奇怪的宏:
#define RTE_REGISTER_BUS(nm, bus) RTE_INIT_PRIO(businitfn_ ##nm, BUS) { (bus).name = RTE_STR(nm); rte_bus_register(&bus); } void rte_bus_register(struct rte_bus *bus) { RTE_VERIFY(bus); RTE_VERIFY(bus->name && strlen(bus->name)); /* A bus should mandatorily have the scan implemented */ RTE_VERIFY(bus->scan); RTE_VERIFY(bus->probe); RTE_VERIFY(bus->find_device); /* Buses supporting driver plug also require unplug. */ RTE_VERIFY(!bus->plug || bus->unplug); //将rte_bus结构插入至rte_bus_list链表中 TAILQ_INSERT_TAIL(&rte_bus_list, bus, next); RTE_LOG(DEBUG, EAL, "Registered [%s] bus. ", bus->name); }
代码2.
可以看到RTE_REGISTER_BUS其实是一个宏函数,内部实现是rte_bus_register,而rte_bus_register内部做了两件事:
- 校验rte_bus结构中的方法以及属性,也就是参数的前置检查;
- 将rte_bus结构,也就是入参插入到rte_bus_list这个链表中;
那么这里我们可以初步得出一个结论:
- 调用RTE_REGISTER_BUS这个宏进行注册的总线(rte_bus)会被一个链表串起来做集中管理,以后想对某个bus调用对应的方法,只需要遍历这个链表然后找到想要操作的bus,再调用方法即可。那它的伪代码我们至少可以脑补出如代码3中描述的一样:
foreach list_node in list: if list_node is we want: list_node->method()
代码3.
但是RTE_REGISTER_BUS这个宏的出现至少带给我们如下几个问题:
- 这个宏里面实际上是一个函数,那这个函数是在哪调用的?
- 啥时候遍历这个链表然后执行rte_bus的方法(method)呢?
接下来便重点看这两个问题,先看第一个问题,这个函数是在哪调用的,通常我们看一个函数在哪调用的最常见的方法便是搜索整个项目,或用一些IDE自带的分析关联功能去找在哪个位置调用的这个宏,或这个函数,但是在RTE_REGISTER_BUS这个宏面前,没有任何一个地方调用这个宏。
还记得一个经典的问题么?
一个程序的启动过程中,main函数是最先执行的么?
在这里便可以顺便解答这个问题,再重新看代码2中的RTE_REGISTER_BUS这个宏,里面还夹杂着一个令人注意的宏,RTE_INIT_PRO,接下来为了便于分析,我们将宏里面的内容全部展开,见代码4.
/******展开前******/ /* 位于lib/librte_eal/common/include/rte_common.h */ #define RTE_PRIO(prio) RTE_PRIORITY_ ## prio #ifndef RTE_INIT_PRIO #define RTE_INIT_PRIO(func, prio) static void __attribute__((constructor(RTE_PRIO(prio)), used)) func(void) #endif #define _RTE_STR(x) #x #define RTE_STR(x) _RTE_STR(x) /* 位于lib/librte_eal/common/include/rte_bus.h */ #define RTE_REGISTER_BUS(nm, bus) RTE_INIT_PRIO(businitfn_ ##nm, BUS) { (bus).name = RTE_STR(nm); rte_bus_register(&bus); } /******展开后******/ /* 这里以RTE_REGISTER_BUS(pci, rte_pci_bus.bus)为例 */ #define RTE_REGISTER_BUS(nm, bus) static void __attribute__((constructor(RTE_PRIORITY_BUS), used)) businitfn_pci(void) { rte_pci_bus.bus.name = "pci" rte_bus_register(&rte_pci_bus.bus); }
代码4.
另外注意的一点是,这里如果想顺利展开,必须得知道在C语言中的宏中,出现“#”意味着什么:
- #:一个井号,代表着后续连着的字符转换成字符串,例如#BUS,那么在预编译完成后就会变成“BUS”
- ##:两个井号,代表着连接,这个地方通常可以用来实现C++中的模板功能,例如MY_##NAME,那么在预编译完成后就会变成MY_NAME
再次回到代码4中的代码,其中最令人值得注意的细节便是“__attribute__((constructor(RTE_PRIORITY_BUS), used))”,这个地方实际上使用GCC的属性将这个函数进行声明,我们可以查阅GCC的doc来看一下constructor这个属性是什么作用,以gcc 4.85为例,见图1:
图1.GCC文档中关于constructor属性的描述
其实GCC文档中已经说的很明白了,constructor会在main函数调用前而被调用,并且如果程序中如果出现了多个用GCC的constructor属性声明的函数,可以利用优先级对其进行排序,当然在这里,优先级数值越大的constructor优先级越小,运行的顺序越靠后。
- P.S. RTE_REGISTER_BUS展开时,另一个”used“的函数声明比较常见,就是告诉编译器,这个函数有用,别给老子报警(通常我们编译时在gcc的CFLAGS中加上-Wall -Werror的参数时,一个你没有使用的函数,gcc在编译的时候会直接爆出一个error,”xxx define but not used“,这个used就是用来对付这种警告/错误的,一般在内联汇编函数上用的比较多)
那到了这里,第一个问题的答案已经逐渐明了
- 这个宏里面实际上是一个函数,那这个函数是在哪调用的?
- 答:RTE_REGISTER_BUS内部的函数被gcc用constructor属性进行了声明,因此会在main函数被调用之前而运行,也就是在main函数被调用之前,rte_bus就已经加入到全局的”bus“链表中了。
接下来再看第二个问题,”啥时候遍历这个链表然后执行rte_bus的方法(method)呢?“,答案在dpdk的初始化函数rte_eal_init中。
【2.资源的扫描】
在准备工作完成后,我们现在有了一个全局链表,这个链表中存储着一个个总线的实例,也就是”struct rte_bus“结果,那么此时这个全局链表可以看作一个管理结构,想要完成对应的任务,只需要遍历这个链表就可以了。
来到DPDK的初始化函数rte_eal_init函数,这个函数调用非常复杂, 而且涉及的模块众多,根本没有办法进行一次性全面的分析,但是好处是我们只需要找到我们关注的地方即可,见代码5:
int rte_eal_init(int argc, char **argv) { ......;//初始化的模块过多,并且无关,直接忽略 if (rte_bus_scan()) { rte_eal_init_alert("Cannot scan the buses for devices"); rte_errno = ENODEV; rte_atomic32_clear(&run_once); return -1; } ......;//初始化的模块过多,并且无关,直接忽略 } /* 扫描事先注册好的全局总线链表,调用scan方法进行扫描 */ int rte_bus_scan(void) { int ret; struct rte_bus *bus = NULL; //遍历总线链表 TAILQ_FOREACH(bus, &rte_bus_list, next) { //调用某一总线的scan函数钩子 ret = bus->scan(); if (ret) RTE_LOG(ERR, EAL, "Scan for (%s) bus failed. ", bus->name); } return 0; }
代码5.
在代码5中的代码中,rte_eal_init函数调用了rte_bus_scan函数,而rte_bus_scan函数是一段非常简单的代码,功能就是是对总线进行扫描,然后调用事先注册好的某一总线实例的scan函数钩子,那么回到代码1.中,我们来看一下PCI总线的scan函数是什么,答案便是rte_pci_scan函数,那么接下来的任务便是进入rte_pci_scan函数,看了下PCI这种总线的扫描函数做了哪些事情。
/* * PCI总线的扫描函数 */ int rte_pci_scan(void) { ......//变量声明,省略 //1.打开/sys/bus/pci/devices/目录 dir = opendir(rte_pci_get_sysfs_path()); ......//异常处理,省略 //2.接下来的内容便是扫描devices目录下所有的PCI地址目录 while ((e = readdir(dir)) != NULL) { if (e->d_name[0] == '.') continue; ......//格式化字符串,省略 //3.扫描某个PCI地址目录 if (pci_scan_one(dirname, &addr) < 0) goto error; } ......//异常处理,资源释放,省略 }
代码6.
其中代码6的逻辑非常简单,就是进入/sys/bus/pci/devices目录扫描目录下所有的PCI设备,然后再进入PCI设备的目录下扫描PCI设备的资源,如图2所示。
图2.rte_pci_scan的原理
进入pci_scan_one函数后,便开始对这个PCI设备目录中的每一个文件进行读取,拿到对应的信息,在第一篇文章中也提到过,内核会将PCI设备的信息通过文件系统这种特殊的接口暴露给用户态,供用户态程序读取,那么pci_scan_one的逻辑便如图3所示。
图3.pci_scan_one的函数执行逻辑
可以看到图3中pci_scan_one函数的执行逻辑,其实同样非常简单,就是将PCI设备目录下的sysfs进行读取、解析。这11步中值得注意的有3步,分别是第9、第10以及第11步,接下来将重点观察这3步的内容,先从第9步说起。
其实第9步调用pci_parse_sysfs_resource函数执行的内容就是去解析/sys/bus/pci/devices/0000:81:00.0/resource这个文件,之前在第一篇文章中也提到过,这个resource文件中包含着PCI BAR的信息,其中有分为三列,第一列为PCI BAR的起始地址,第二列为PCI BAR的终止地址,第三列为PCI BAR的标识,那么这个函数便是用于解析resource文件,拿到对应的PCI BAR信息,见代码7.
/* * 解析[pci_addr]/resource文件 * @param filename resource文件所在的目录,例如/sys/bus/pci/devices/0000:81:00.0/resource * @param dev PCI设备的实例 */ static int pci_parse_sysfs_resource(const char *filename, struct rte_pci_device *dev) { ......//变量声明,省略 //1.open resource文件 f = fopen(filename, "r"); ......//异常处理,省略 //2.遍历6个PCI BAR,关于PCI BAR的数量与作用在上一篇文章中已经阐述 for (i = 0; i<PCI_MAX_RESOURCE; i++) { ......//异常处理,省略 //3.解析某一行PCI BAR的字符串,拿到PCI BAR的起始地址、结束地址以及标识 if (pci_parse_one_sysfs_resource(buf, sizeof(buf), &phys_addr, &end_addr, &flags) < 0) goto error; //4.只要Memory BAR,把信息拿到,存至数据结构中,至于为什么只需要Memory BAR,在上一篇文章中已经阐述完毕 if (flags & IORESOURCE_MEM) { dev->mem_resource[i].phys_addr = phys_addr; dev->mem_resource[i].len = end_addr - phys_addr + 1; dev->mem_resource[i].addr = NULL; } } ......//异常处理,资源释放,省略 }
代码7.
可以看到,pci_parse_sysfs_resource函数内部的执行逻辑同样非常简单,就是解析resource文件,把Memory类型的PCI BAR信息提去并拿出来(这里注意,关于为什么只拿Memory类型的PCI BAR在上一篇文章中已经阐述),那么图3中的步骤9的作用便分析完毕,接下来看图3中的步骤10.
图3中的步骤10主要是拿到当前PCI设备用的驱动类型,但是是怎么拿到的呢?答案也很简单,看软连接的链接信息就可以得知,见图4.所以说这个pci_get_kernel_driver_by_path的函数名命名可以说是非常到位了。
图4.pci_get_kernel_driver_by_path的实现原理
那么至此,图3中的步骤10的原理也阐述完毕,接着看步骤11,步骤11主要涉及数据结构的关系,其中rte_pci_bus这个结构体象征着PCI总线,而同样已知的是,一条总线上会挂在一些数量的总线设备,举个例子,PCI总线上会有一些PCI设备,那么这些PCI设备的抽象类型便是rte_pci_devices类型,那么这也同样是一个包含的关系,即rte_pci_bus这个结构从概念上是包含rte_pci_devices这个类型的,所以在rte_pci_bus这个结构上有一个devices_list链表用来集中管理总线上的设备,数据结构关系可以如图5所示。
图5.rte_pci_bus与rte_pci_device数据结构关系图
可以看到在图5中,rte_pci_device这个结构被串在了rte_pci_bus结构中的devices_list这个链表中,同样需要值得注意的是rte_pci_device这个结构体对象,其中很多的成员属性在这里先说一下
- TAILQ_ENTRY(rte_pci_device) next:这个对象就是一个链表结构,用来将前后的rte_pci_device串起来,方便管理,没什么实际意义;
- struct rte_device device:struct rte_device这个结构体象征着设备的一些通用信息,举个例子,不管是什么类型设备,PCI设备还是啥SDIO设备,他们都具有“名字”、“驱动”这些共性的特征,那么关于这些共性特征描述便抽象成struct rte_device这个结构体类型;
- struct rte_pci_addr addr:struct rte_pci_addr这个结构体象征着一个PCI地址,举个例子,0000:81:00.1,这便是一个PCI设备的地址,并且实际上这个地址是由四部分组成的,第一个部分叫做"domain",也就是第一个冒号之前的4个数字0000,第二个部分叫做“bus”,也就是第一个冒号和第二个冒号之间的2个数字81,第三个部分叫做“device_id”,在第二个冒号和最后一个句号之间的2个数字00,最后一个部分也就是第四个部分叫做"function",也就是最后一个句号之后的1个数字1。但是关于PCI地址为啥这么分,本人也不知道...;
- struct rte_pci_id id:struct rte_pci_id这个结构体象征着PCI驱动的一些ID号,包括之前反复提过的class id、vendor id、device id、subsystem vendor id以及subsystem device id;
- struct rte_mem_resource mem_resource[6]:这个是重点,mem_resource,类似于内核中的resource结构体,里面存着解析完resource文件后的PCI BAR信息,也就是图3中的步骤9将resource文件中的信息提去后存志这个mem_resource对象中;
- struct rte_intr_handle intr_handle:中断句柄,本篇文章不包含中断相关内容,关于中断的原理解析会放到以后的文章中介绍;
- struct rte_pci_driver driver:这个也是重点,描述这个PCI设备用的是何种驱动,但是这里需要注意的是,这里的驱动可不是指的内核中的那些驱动,也不是指的igb_uio/vfio-pci,而是指的DPDK的用户态PMD驱动;
- max_vfs:这个主要是与sriov相关,指的是这个PCI设备最大能虚拟出几个VF,sriov是网络虚拟化领域中常用的一种技术;
- kdrv:内核驱动,但是也要注意,只要不是igb_uio/vfio-pci驱动,其他的驱动一律变成UNKNOWN,比如现在的网卡是一个内核的ixgbe驱动,DPDK应用不关心,它只关心是不是igb_uio/vfio-pci驱动,所以一律赋值为UNKNOWN;
- vfio_req_intr_handle:这个同样是重点,vfio驱动的中断句柄,但是本篇文章不涉及中断,也不涉及vfio,关于这两个地方以后会专门开文章来介绍。
至此,DPDK启动过程中,PCI资源的扫描任务就此完成,在这一阶段完成后,可以得到一个非常重要的结论:
- 扫描的PCI设备资源、属性信息全部被存到了图5中的rte_pci_bus.device_list这个链表中
那么根据这个结论,也可以推导出接下来要做什么事情,那便是去遍历这个device_list,对每一个PCI设备做接管、初始化工作。
【3.PCI设备加载PMD驱动】
接下来便是核心的地方,根据第二章的描述,现在已经将每一个PCI设备扫描完成,拿到了关键的信息,接下来便是怎么根据这些信息来完成PMD驱动的加载。再次回到rte_eal_init这个DPDK初始化的关键函数。
1 int rte_eal_init(int argc, char **argv) 2 { 3 ......//其他模块初始化,省略 4 //1.扫描总线,第二章已经分析完毕 5 if (rte_bus_scan()) { 6 ......//异常处理,省略 7 } 8 ......//其他模块初始化,省略 9 //2.总线探测 10 if (rte_bus_probe()) { 11 ......//异常处理省略 12 } 13 .......//其他处理,省略 14 }
代码8.
第二个关键函数便是rte_bus_probe函数,这个函数就是负责将总线数据结构上的设备进行驱动的加载,进入rte_bus_scan的函数逻辑。
//总线扫描函数 int rte_bus_probe(void) { int ret; struct rte_bus *bus, *vbus = NULL; //1.遍历rte_bus_list链表,拿到事先注册的所有rte_pci_bus数据结构 TAILQ_FOREACH(bus, &rte_bus_list, next) { if (!strcmp(bus->name, "vdev")) { vbus = bus; continue; } //2.调用总线数据结构的probe钩子函数,对于pci设备来说,那么就是rte_pci_probe函数 ret = bus->probe(); if (ret) RTE_LOG(ERR, EAL, "Bus (%s) probe failed. ", bus->name); } ......//省略 return 0; }
代码9.
可以看到rte_bus_probe函数的实现逻辑同样非常简单,见代码1中的rte_pci_bus对象的注册,可以看到probe这个函数钩子就是rte_pci_bus这个结构中的rte_pci_probe函数,那么接下来便可以着重分析PCI总线的probe函数,也就是rte_pci_probe函数。
//PCI总线的探测函数 int rte_pci_probe(void) { ......//初始化,变量声明,省略 //1.遍历rte_pci_bus的device_list链表,拿到每一个PCI设备对象 FOREACH_DEVICE_ON_PCIBUS(dev) { probed++; devargs = dev->device.devargs; //对PCI设备对象调用pci_probe_all_drivers函数,这里的决策是要么探测所有,要么根据白名单进行选择性探测,在DPDK初始化时可以指定白名单参数,对指定的PCI设备进行探测 if (probe_all) ret = pci_probe_all_drivers(dev); else if (devargs != NULL && devargs->policy == RTE_DEV_WHITELISTED) ret = pci_probe_all_drivers(dev); ......//异常处理,省略 } return (probed && probed == failed) ? -1 : 0; } //用PMD驱动对pci设备进行挂载 static int pci_probe_all_drivers(struct rte_pci_device *dev) { ......//异常处理、变量声明,省略 //1.遍历事先注册好的驱动链表,注意这里的PMD驱动的注册原理与总线的注册逻辑类似,可以自行分析 FOREACH_DRIVER_ON_PCIBUS(dr) { //2.拿驱动去探测设备,这里的逻辑是事先注册的驱动挨个探测一遍,匹配和过滤的规则在函数内部里实现 rc = rte_pci_probe_one_driver(dr, dev); ......//异常处理,省略 return 0; } return 1; }
代码10.
接着再进入rte_pci_probe_one_driver,看PCI设备如何关联上对应的PMD驱动,再如何加载驱动的,代码分析见代码11.
static int rte_pci_probe_one_driver(struct rte_pci_driver *dr, struct rte_pci_device *dev) { ......//参数检查,变量初始化,省略 //1.对PCI设备和驱动进行匹配,道理也很简单,一个I350的卡不可能给他上i40e的驱动 if (!rte_pci_match(dr, dev)) /* Match of device and driver failed */ return 1; //2.看这个设备是否是在黑名单参数里,如果在,那就跳过,类似于白名单,在DPDK初始化时可以指定黑名单 if (dev->device.devargs != NULL && dev->device.devargs->policy == RTE_DEV_BLACKLISTED) { return 1; } //3.检查numa节点的有效性 if (dev->device.numa_node < 0) { ......//异常处理,省略 } //4.检查设备是否已经加载过驱动,都加载了那还加载个屁,接着跳过 already_probed = rte_dev_is_probed(&dev->device); if (already_probed && !(dr->drv_flags & RTE_PCI_DRV_PROBE_AGAIN)) { return -EEXIST; } //5.逻辑到了这里,那么设备是已经确认了没有加载驱动,并且已经和驱动配对成功,那么进行指针赋值 if (!already_probed) dev->driver = dr; //6.驱动是否需要PCI BAR资源映射,对于大多数驱动,ixgbe、igb、i40e等驱动,都是需要进行重新映射的,不映射拿不到PCI BAR if (!already_probed && (dr->drv_flags & RTE_PCI_DRV_NEED_MAPPING)) { //7.调用rte_pci_map_device对设备进行PCI BAR资源映射 ret = rte_pci_map_device(dev); ......//异常处理,省略 } //8.调用驱动的probe函数进行驱动的加载 ret = dr->probe(dr, dev); ......//异常处理,省略 return ret; }
代码11.
代码11分析了rte_pci_probe_one_driver函数的执行逻辑,到这里,我们重新梳理一下从rte_eal_init函数到rte_pci_probe_one_driver的函数调用流程以及逻辑流程,见图6与图7.
图6.rte_eal_init中PCI设备的扫描到加载函数调用过程
在进入PMD驱动具体的加载函数前,先说一下图6中的粉色框标识的函数rte_pci_device_map,这个函数执行了重要的PCI BAR映射逻辑,因此这个函数属于一个重要的关键函数,所以先分析一下rte_pci_device_map这个函数的实现,见代码12.
//对PCI设备进行映射,这里实际说的比较笼统,起始是对PCI设备的PCI BAR资源进行映射到用户空间,让应用程序可以访问、操作以及配置PCI BAR int rte_pci_map_device(struct rte_pci_device *dev) { switch (dev->kdrv) { case RTE_KDRV_VFIO: #ifdef VFIO_PRESENT //如果是VFIO驱动接管,则进入pci_vfio_map_resource,也就是进入vfio的逻辑来映射资源 if (pci_vfio_is_enabled()) ret = pci_vfio_map_resource(dev); #endif break; case RTE_KDRV_IGB_UIO: case RTE_KDRV_UIO_GENERIC: //如果是uio驱动,那么就进入pci_uio_map_resource,也就是进入uio的逻辑来映射资源 if (rte_eal_using_phys_addrs()) { ret = pci_uio_map_resource(dev); } break; } ......//异常处理,省略 } //uio驱动框架下的映射PCI设备资源 int pci_uio_map_resource(struct rte_pci_device *dev) { ......//参数检查、变量初始化,省略 //1.申请uio资源 ret = pci_uio_alloc_resource(dev, &uio_res); //2.对6个PCI BAR进行映射 for (i = 0; i != PCI_MAX_RESOURCE; i++) { //跳过无效BAR phaddr = dev->mem_resource[i].phys_addr; if (phaddr == 0) continue; //其实对于intel的网卡,只有BAR0 & BAR1能进行映射,其中在64bit的工作模式下,BAR 0和BAR 1被归为同一个PCI BAR,这里的原理可以看上一篇文章 //3.调用pci_uio_map_resource_by_index函数对具体的一块PCI BAR进行映射 ret = pci_uio_map_resource_by_index(dev, i, uio_res, map_idx); map_idx++; } uio_res->nb_maps = map_idx; TAILQ_INSERT_TAIL(uio_res_list, uio_res, next); ......//异常处理,省略 } //PCI设备对某个BAR进行映射 int pci_uio_map_resource_by_index(struct rte_pci_device *dev, int res_idx, struct mapped_pci_resource *uio_res, int map_idx) { ......//变量初始化、异常处理,省略 if (!wc_activate || fd < 0) { ......//字符串处理,拿到resource0..N的文件路径,举个例子/sys/bus/pci/devices/0000:81:00.0/resource0 //1.对resource0..N开始open fd = open(devname, O_RDWR); ......//异常处理,省略 } //2.对这个resource0..N进行映射 mapaddr = pci_map_resource(pci_map_addr, fd, 0, (size_t)dev->mem_resource[res_idx].len, 0); ......//异常处理,省略 //3.对映射完成的空间进行长度累加,从这里可以看出,如果要映射多个PCI BAR,dpdk会让这些映射后的虚拟空间是连续的 pci_map_addr = RTE_PTR_ADD(mapaddr, (size_t)dev->mem_resource[res_idx].len); //4.赋值,其中最重要的就是这个mapaddr,这个指针内部的地址,就是PCI BAR映射到用户空间的虚拟地址,最终这个地址会被保存在mem_resource结构中的addr至真中 maps[map_idx].phaddr = dev->mem_resource[res_idx].phys_addr; maps[map_idx].size = dev->mem_resource[res_idx].len; maps[map_idx].addr = mapaddr; maps[map_idx].offset = 0; strcpy(maps[map_idx].path, devname); dev->mem_resource[res_idx].addr = mapaddr; ......//异常处理,省略 } /* * 对resource0..N资源进行映射 * @param requested_addr 请求的地址,告诉从哪个虚拟地址开始映射,主要是为了让多个PCI BAR的情况下,映射后的虚拟地址是连续的,这样方便管理 * @param fd resource0..N文件的文件描述符 * @param offset 偏移,注意,映射PCI设备的资源文件resource0..N,这里的偏移必须是0,关于为什么是0,Linux Kernel Doc有规定,可以见上一篇文章 * @param size 映射的空间大小,这个可以通过PCI BAR的结束地址 - PCI BAR的起始地址 + 1计算出来 * @param additional_flags 控制标识,为0 * @return 成功返回映射后的虚拟地址,失败返回NULL */ void * pci_map_resource(void *requested_addr, int fd, off_t offset, size_t size, int additional_flags) { void *mapaddr; //1.映射PCI resource0..N文件 mapaddr = mmap(requested_addr, size, PROT_READ | PROT_WRITE, MAP_SHARED | additional_flags, fd, offset); ......//异常处理,省略 return mapaddr; }
代码12.
代码12由于涉及到4个函数,并且关系是强相关的,拆解后不利于分析,因此插入到一块代码区域中,显得有些长,但是非常重要。其中代码12的函数调用流程如图7所示。
图7.PCI BAR资源映射的函数调用关系
其中PCI BAR的资源映射函数rte_pci_map_device至少告诉我们这么几个信息:
- DPDK拿到PCI BAR不是通过UIO驱动拿到的,而是直接通过Kernel对用户空间的接口,也就是通过sysfs拿到的,具体就是映射resource0..N文件。这里在上一篇文章中已经介绍过;
- 映射后的虚拟地址已经存至了rte_pci_device->mem_resouce[PCI_BAR_INDEX].add指针中。
那么至此,rte_bus_probe这个总线挂载函数的内部执行流程已经分析完毕,同样也拿到了关键的资源,也就是PCI BAR映射到用户空间后的地址,通过这个地址,便拿到了寄存器的基地址,接下来对PCI设备的配置以及操作只需要将这个基地址 + 寄存器地址偏移,即可拿到寄存器地址,便可以对其进行读写。在进一步分析之前,我会先给出rte_bus_scan函数执行的逻辑图,请注意的是,函数执行的逻辑图会从宏观上阐述执行的逻辑,所以会忽略函数调用的维度,关于函数调用关系的维度,见图6以及图7即可。接下来rte_bus_probe函数的执行逻辑图请见图8.
图8.rte_bus_probe函数的执行逻辑
说完了rte_bus_probe的函数执行逻辑,再来完善一下图5的数据结构关系,完善后见图8。
图8.图5数据结构的完善
但是到了这里还没有结束,接下来便进入PMD驱动的加载函数。
【4.PMD驱动的加载】
第四章将着重以ixgbe驱动为例,讲解PMD驱动是如何加载的,先进入ixgbe的probe函数,也就是图8中的eth_ixgb_pci_probe函数。
tatic int eth_ixgbe_pci_probe(struct rte_pci_driver *pci_drv __rte_unused, struct rte_pci_device *pci_dev) { ......//初始化以及其他处理,省略 retval = rte_eth_dev_create(&pci_dev->device, pci_dev->device.name, sizeof(struct ixgbe_adapter), eth_dev_pci_specific_init, pci_dev, eth_ixgbe_dev_init, NULL); ......//其他处理,省略 return 0; }
代码13.
可以看到eth_ixgbe_pci_probe的主要处理还是非常简单的,就是调用rte_eth_dev_create去创建PMD驱动,那么接着进入rte_eth_dev_create函数进行分析,见代码14.这个函数较为重要,会重点分析
/* * 创建PMD驱动 * @param device[in] rte_pci_device->rte_device,在图8中已经说明为设备的通用信息结构
* @param name[in] 设备名
* @param priv_data_size 私有数据的大小,这个私有数据很重要,可以理解指的就是PMD驱动,因为每个网卡的信息都可能不一样,所以将这些私有数据打成一个void *来实现泛型
* @param ethdev_bus_specific_init 一个函数指针,为eth_dev_bus_specific_init函数,这个函数有BUG,在multiprocess模型下,此BUG已被本人解决并提交了patch,目前已经被intel社区采纳,在20.02版本以后修复,BUG可以看这篇文章https://www.cnblogs.com/jungle1996/p/12191070.html * @param bus_init_params 就是rte_pci_device结构,这个结构在图8中已经说明为PCI设备的描述结构
* @param ethdev_init 函数指针,为PMD驱动初始化函数,在ixgbe这个驱动下为eth_ixgbe_dev_init
* @param init_param PMD驱动初始化的参数,一般为NULL
*/ int __rte_experimental rte_eth_dev_create(struct rte_device *device, const char *name, size_t priv_data_size, ethdev_bus_specific_init ethdev_bus_specific_init, void *bus_init_params, ethdev_init_t ethdev_init, void *init_params) { ......//变量声明,参数检查,省略 //1.拿到ete_eth_dev结构,对于不同的类型的进程拿到方法不一样,至于为啥这样,是因为这个结构中的一些属性来自于共享内存,
//因此对于secondary进程需要attach到primary进程中的共享内存中,拿到这些共享内存数据。 if (rte_eal_process_type() == RTE_PROC_PRIMARY) {
//2.申请内存,得到rte_eth_dev结构,注意这个结构并不是来自于共享内存,而是这个结构中的一些属性来自于共享内存,这个结构只是一个local变量
//但是请注意,在这个函数的内部实现中,已经拿到了共享内存地址,并赋值至rte_eth_dev->data这个指针 ethdev = rte_eth_dev_allocate(name);
//3.如果指定了有私有数据,那就申请这个私有数据 if (priv_data_size) { ethdev->data->dev_private = rte_zmalloc_socket( name, priv_data_size, RTE_CACHE_LINE_SIZE, device->numa_node); ......//异常处理,省略 } } else {
//4.由于secondary进程的权限比较低,没有掌控内存的权限,因此关键数据只能通过attach到primary暴露的共享内存中,拿到关键数据
//其实这个地方主要是要拿到rte_eth_dev->data这个指针指向的共享内存(因为这里面有PCI BAR映射后的地址) ethdev = rte_eth_dev_attach_secondary(name); ......//异常处理,省略 } //5.指针赋值,没啥说的,就是让PMD驱动也可以通过device来拿到PCI设备的信息 ethdev->device = device; //6.调用eth_dev_bus_specific_init函数,这个函数内部有BUG,请注意 if (ethdev_bus_specific_init) { retval = ethdev_bus_specific_init(ethdev, bus_init_params); ......//异常处理,省略 } //7.调用PMD驱动的初始化,对PMD驱动进行初始化,在xigbe驱动下为eth_ixgbe_dev_init retval = ethdev_init(ethdev, init_params); ......//异常处理,省略 rte_eth_dev_probing_finish(ethdev); }
代码14.
可以看到,代码14中的rte_eth_dev_create函数还是比较重要的,可以说是衔接PCI设备与PMD驱动的接口层函数。所以懒得看代码中的注释的可以直接看图9给出的rte_eth_dev_create的函数内部流程图。见图9.
图9.rte_eth_dev_create函数的执行流程
分析完rte_eth_dev_create函数后,便自然的进入了PMD驱动的初始化函数,接下来会以ixgbe这种驱动进行分析,那么在ixgbe驱动下,初始化函数为eth_ixgbe_dev_init。
接下来不会全面分析,因为对于驱动而言,他的初始化逻辑是巨他妈的长的...分析这部分代码,我们只需要记住我们的初衷即可,我们的初衷即为:
dpdk应用内部是如何操作pci设备的呢?是怎么让pci设备可以将数据包直接扔到用户态的呢?
我们之前也阐述了,为了实现这个初衷,我们一定要不惜一切代价让PMD驱动拿到PCI BAR,然后通过PCI BAR去操作寄存器,并且同过第2章和第3章的分析,我们其实已经拿到了PCI BAR,通过mmap映射resource0..N这个内核通过sysfs开放的接口,现在这个PCI BAR经过映射后的虚拟机地址正在rte_pci_device->mem_resource[idx].addr中沉睡,我们的任务只不过是让PMD驱动结构拿到这个地址而已,换而言之,其实就是等号左右赋值一下就可以完成,那么我们来看eth_ixgbe_dev_init函数,见图10.
图10.PCI BAR虚拟地址的赋值
#define IXGBE_DEV_PRIVATE_TO_HW(adapter) (&((struct ixgbe_adapter *)adapter)->hw) /* * ixgbe驱动的初始化函数 * @param eth_dev[in] PMD驱动描述结构 */ static int eth_ixgbe_dev_init(struct rte_eth_dev *eth_dev, void *init_params __rte_unused) { ......//无关逻辑,省略 //1.将PMD驱动中的私有空间进行转换成ixgbe_adapter结构,再拿到ixgbe_adatper其中的hw属性,注意这个变量的内存位于共享内存中,因此secondary也是拿得到的,这就是secondary为啥可以读网卡寄存器状态,因此secondary其实是可以通过共享内存拿到PCI BAR的 struct ixgbe_hw *hw = IXGBE_DEV_PRIVATE_TO_HW(eth_dev->data->dev_private); ......//无关逻辑,省略 //2.挂钩子函数,给ixgbe这个PMD驱动指定收发包函数 eth_dev->dev_ops = &ixgbe_eth_dev_ops; eth_dev->rx_pkt_burst = &ixgbe_recv_pkts; eth_dev->tx_pkt_burst = &ixgbe_xmit_pkts; eth_dev->tx_pkt_prepare = &ixgbe_prep_pkts; if (rte_eal_process_type() != RTE_PROC_PRIMARY) { ......//secondary进程的相关逻辑,省略 } ......//拷贝PCI设备信息 rte_eth_copy_pci_info(eth_dev, pci_dev); //3.最重要的一步,拿到PCI BAR以及设备号还有厂商号 //至此,PMD驱动成功拿到经过映射到进程虚拟空间的PCI BAR hw->device_id = pci_dev->id.device_id; hw->vendor_id = pci_dev->id.vendor_id; hw->hw_addr = (void *)pci_dev->mem_resource[0].addr; hw->allow_unsupported_sfp = 1; //4.其他部分的初始化工作,先暂时省略 return 0; }
代码15.
经过代码15所示,我们可以看到在eth_ixgbe_dev_init这个函数中,PMD驱动已经拿到了经过mmap映射后的在进程用户空间的PCI BAR地址,接下来对PCI设备的配置,通过这个PCI BAR + 寄存器地址偏移拿到寄存器地址,便可以对寄存器进行读写、配置。到这里我们先暂停一下脚步,过一下数据结构之间的关系。见图11.
从图11中可以看出,一番操作后,PCI BAR已经被ixgbe_adapter-hw指针指向,接下来想拿到PCI BAR只需要对rte_dev->data->dev_private调用IXGBE_DEV_PRIVATE_TO_HW即可拿到PCI BAR。而且还要注意的是由于rte_dev->data指针指向的空间为共享内存,因此PCI BAR实际上也在共享内存中,这也就是Secondary进程可以读取网卡的寄存器配置以及状态,就是因为Seconday进程实际上可以通过共享内存拿到PCI BAR,然后想读寄存器信息以及状态,和primary进程相同,只需要PCI BAR + 寄存器地址偏移拿到寄存器地址,便可以实现对寄存器状态信息的读取。
但是这还没有结束,我们还差最后一个问题没有解决,那便是,DPDK怎么让PCI设备把包直接扔到的用户态,这部分将会放在本系列的第三章中讲解。