• 【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设备把包直接扔到的用户态,这部分将会放在本系列的第三章中讲解。

     

  • 相关阅读:
    springboot成神之——websocket发送和请求消息
    springboot成神之——发送邮件
    springboot成神之——spring文件下载功能
    springboot成神之——spring的文件上传
    springboot成神之——basic auth和JWT验证结合
    springboot成神之——Basic Auth应用
    leetcode-easy-array-122 best time to buy and sell stocks II
    leetcode-easy-array-31 three sum
    leetcode-mid-others-621. Task Scheduler
    leetcode-mid-math-371. Sum of Two Integers-NO-???
  • 原文地址:https://www.cnblogs.com/jungle1996/p/12452636.html
Copyright © 2020-2023  润新知