【DPDK】谈谈DPDK如何实现bypass内核的原理 其二 DPDK部分的实现

【前言】

  关于DPDK如果实现bypass内核的原理,在上一篇《【DPDK】谈谈DPDK如何实现bypass内核的原理 其一 PCI设备与UIO驱动》中已经描述了在DPDK启动前做的准备工作,那么本篇文章将着重分析DPDK部分的职责,也就是从软件的的角度来分析在第一篇文章的基础上,如何做到真正的操作设备。

注意:

  1. 本篇文章将会更着重分析软件部分的实现,也就是分析代码实现;
  2. 同样,本篇会跨过中断部分与vfio部分,中断部分与vfio会在以后另开文章继续分析;
  3. 人能力以及水平有限,没办法保证没有疏漏,如有疏漏还请各路神仙进行指正,本篇内容都是本人个人理解,也就是原创内容。
  4. 另外在分析代码的过程中,为了防止一些无挂紧要的逻辑显得代码又臭又长,会对其中不重要或者与主要逻辑不相关的代码进行省略,包括且不限于,变量声明、部分不重要数据的初始化、异常处理、无关主要逻辑的模块函数调用等。

【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内部做了两件事:

  1. 校验rte_bus结构中的方法以及属性,也就是参数的前置检查;
  2. 将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这个宏的出现至少带给我们如下几个问题:

  1. 这个宏里面实际上是一个函数,那这个函数是在哪调用的?
  2. 啥时候遍历这个链表然后执行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就是用来对付这种警告/错误的,一般在内联汇编函数上用的比较多)

  那到了这里,第一个问题的答案已经逐渐明了

  1. 这个宏里面实际上是一个函数,那这个函数是在哪调用的?
    • 答: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至少告诉我们这么几个信息:

  1. DPDK拿到PCI BAR不是通过UIO驱动拿到的,而是直接通过Kernel对用户空间的接口,也就是通过sysfs拿到的,具体就是映射resource0..N文件。这里在上一篇文章中已经介绍过;
  2. 映射后的虚拟地址已经存至了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设备把包直接扔到的用户态,这部分将会放在本系列的第三章中讲解。

 

原文地址:https://www.cnblogs.com/jungle1996/p/12452636.html