DPDK探究1-理解DPDK的运行逻辑

DPDK介绍

DPDK主要功能:利用IA(intel architecture)多核处理器进行高性能数据包处理

Linux下传统的网络设备驱动包处理的动作可以概括如下:

  • 数据包到达网卡设备
  • 网卡设备依据配置进行DMA操作
  • 网卡发送中断,唤醒处理器
  • 驱动软件填充读写缓冲区数据结构
  • 数据报文到达内核协议栈,进行高层处理
  • 如果最终应用在用户态,数据从内核搬移到用户态
  • 如果最终应用在内核态,在内核继续进行

频繁的中断会降低系统处理数据包的速度。

DPDK可以很好的在英特尔架构下执行高性能网络数据包处理,主要使用了以下技术:

  • 轮询
    • 避免中断上下文切换的开销
  • 用户态驱动
    • 规避了不必要的内存拷贝,避免了系统调用
  • 亲和性与独占
    • DPDK工作在用户态,线程的调度依然依赖内核,利用线程的CPU亲和性绑定的方式,特定任务可以被指定只在某个核上工作
  • 降低访存开销
    • 如内存大页,内存多通道的交错访问,NUMA系统的使用
  • 软件调优
    • 内存对齐,数据预取,避免跨cache行共享
  • 利用IA新硬件技术
  • 充分挖掘网卡的潜能

DPDK框架简介

  1. 核心库Core Libs:提供系统抽象,打野内存,缓存池,定时器和无锁环等基础组件
  2. PMD库:提供全用户态的驱动,以便通过轮询和线程板顶得到极高的网络吞吐
  3. Classify库:支持精确匹配,最长匹配和通配符匹配,提供常用包处理的查表操作
  4. QoS库:提供网络服务质量相关组件,如限速和调度

解读简单的示例程序,初步了解DPDK

解读helloworld程序,探究DPDK运行思路

#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <errno.h>
#include <sys/queue.h>
#include <rte_memory.h>
#include <rte_launch.h>
#include <rte_eal.h>
#include <rte_per_lcore.h>
#include <rte_lcore.h>
#include <rte_debug.h>
static int
lcore_hello(__attribute__((unused)) void *arg)
{
        unsigned lcore_id;
        lcore_id = rte_lcore_id();  //获得当前核号
        printf("hello from core %u\n", lcore_id);
        return 0;
}
int
main(int argc, char **argv)
{
        int ret;
        unsigned lcore_id;
        ret = rte_eal_init(argc, argv); //EAL层的初始化
        if (ret < 0)
                rte_panic("Cannot init EAL\n");
        /* call lcore_hello() on every slave lcore */
        RTE_LCORE_FOREACH_SLAVE(lcore_id) {
                rte_eal_remote_launch(lcore_hello, NULL, lcore_id);
        }
        /* call it on master lcore too */
        lcore_hello(NULL);
        rte_eal_mp_wait_lcore();
        return 0;
}

首先看lcore_hello(),这个函数是运行在每个核上的回调函数,在主线程中进行调用,函数的参数中存在一个__attribute__((unused)),作用是让编译器取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐。

主线程就是主函数,首先是EAL层的初始化rte_eal_init(argc, argv),这个初始化可以读取可执行程序运行时写入的系统参数(包括用什么核,用什么网卡,内存通道数量),具体参数可以参照DPDK官方网站

int rte_eal_init(int argc, char ** argv);

这个函数最需要的参数是核心掩码,例如-c ffff代表十六个核,当想选择一部分核可以用-l,因为线程的分配需要核的信息。

函数通过读取入口参数,解析并保存为DPDK运行的系统信息,依赖这些信息,构建一个针对包处理设计的运行环境。

接下来是一个宏RTE_LCORE_FOREACH_SLAVE(int id),这个宏的作用是for循环遍历除主核(master core)之外的所有核:

for (i = rte_get_next_lcore(-1, 1, 0);                          \
         i<RTE_MAX_LCORE;                                           \
         i = rte_get_next_lcore(i, 1, 0))

rte_eal_remote_launch声明如下:

int rte_eal_remote_launch(lcore_function_t * f,
                            void * arg,
                            unsigned slave_id 
                         );

类似于多线程编程中的pthread_creat(),是在对应的逻辑核上运行相应的线程,线程的回调函数是f,参数是arg,运行在核号是slave_id的核上。

扫描二维码关注公众号,回复: 1494142 查看本文章

rte_eal_mp_wait_lcore()函数是等待所有的逻辑核(从核slave lcore)完成任务,类似于多线程编程的pthread_join(),这里所有的核心都完成工作后(从RUNNING切换到FINISH状态),状态变为WAIT状态。

If the slave lcore identified by the slave_id is in a FINISHED state, switch to the WAIT state. If the lcore is in RUNNING state, wait until the lcore finishes its job and moves to the FINISHED state.

这便是DPDK的基本运行思路,事实上,DPDK的所有程序都是这样的运行思路:

  1. 主核进行EAL层次的初始化,读取系统参数
  2. 主核读取其他必要的参数
  3. 主核依据读入的参数确定核数,网卡数,进行线程的启动,将对应的函数和参数传入其中
  4. 主核进行自己的工作(一般是定时打印程序状态)
  5. 所有程序结束(这里程序的结束一般是通过linux下的信号来进行的,一般而言,从核运行都是死循环,而信号的到来,如Ctrl+C,会按顺序结束相应的线程,再由主核接收到所有线程结束的消息,结束整个程序)

解读skeleton程序,探究DPDK最基本收发包逻辑

这是一个简单的单核收发包示例程序,对收入报文不做处理,直接进行转发,简单介绍一下代码:

主函数代码如下,可以看到主线程做的前期工作

int main(int argc, char *argv[])
{
    struct rte_mempool *mbuf_pool;
    unsigned nb_ports;
    uint8_t portid;

    int ret = rte_eal_init(argc, argv); //初始化EAL层
    if (ret < 0)
        rte_exit(EXIT_FAILURE, "Error with EAL initialization\n");

    argc -= ret;
    argv += ret;

    nb_ports = rte_eth_dev_count(); //读取网口数量,转发包要求网口数为偶数
    if (nb_ports < 2 || (nb_ports & 1))
        rte_exit(EXIT_FAILURE, "Error: number of ports must be even\n");

    //创建mbuf池,有了mbuf池就可以创建mbuf了
    mbuf_pool = rte_pktmbuf_pool_create("MBUF_POOL", NUM_MBUFS * nb_ports,
        MBUF_CACHE_SIZE, 0, RTE_MBUF_DEFAULT_BUF_SIZE, rte_socket_id());

    if (mbuf_pool == NULL)
        rte_exit(EXIT_FAILURE, "Cannot create mbuf pool\n");

    //初始化所有网口
    for (portid = 0; portid < nb_ports; portid++)
        if (port_init(portid, mbuf_pool) != 0)
            rte_exit(EXIT_FAILURE, "Cannot init port %"PRIu8 "\n",
                    portid);

    if (rte_lcore_count() > 1)
        printf("\nWARNING: Too many lcores enabled. Only 1 used.\n");

    lcore_main();   //主核进行的工作

    return 0;
}

网卡初始化函数为port_init(),对指定的端口设置队列数目,在收发两个方向上,基于端口和队列进行配置设置,缓冲区进行关联设置。

这里的初始化代码也是自行调用API编写:

static inline int port_init(uint8_t port, struct rte_mempool *mbuf_pool)
{
    struct rte_eth_conf port_conf = port_conf_default;
    //这里使用一个默认的单队列结构体进行队列的初始化
    const uint16_t rx_rings = 1, tx_rings = 1;  //收发队列数量(各为1)
    uint16_t nb_rxd = RX_RING_SIZE;// 1<<16,大小为64k
    uint16_t nb_txd = TX_RING_SIZE;//同上
    int retval;
    uint16_t q;

    if (port >= rte_eth_dev_count())
        return -1;

    //网口设置:配置网卡设备,参数包括网口,收发队列数目,配置结构体
    retval = rte_eth_dev_configure(port, rx_rings, tx_rings, &port_conf);
    if (retval != 0)
        return retval;
    //检查Rx和Tx描述符(mbuf)的数量是否满足网卡的描述符限制,不满足将其调整为边界(改变其值)
    retval = rte_eth_dev_adjust_nb_rx_tx_desc(port, &nb_rxd, &nb_txd);
    if (retval != 0)
        return retval;

    //队列初始化:对指定端口的某个队列,指定内存描述符数量,报文缓冲区,并配置队列
    for (q = 0; q < rx_rings; q++) {
        retval = rte_eth_rx_queue_setup(port, q, nb_rxd,
                rte_eth_dev_socket_id(port), NULL, mbuf_pool);
        if (retval < 0)
            return retval;
    }

    for (q = 0; q < tx_rings; q++) {
        retval = rte_eth_tx_queue_setup(port, q, nb_txd,
                rte_eth_dev_socket_id(port), NULL);
        if (retval < 0)
            return retval;
    }

    //初始化完成后启动网口 
    retval = rte_eth_dev_start(port);
    if (retval < 0)
        return retval;

    //检索网卡设备的MAC地址并存入addr中
    struct ether_addr addr;
    rte_eth_macaddr_get(port, &addr);
    printf("Port %u MAC: %02" PRIx8 " %02" PRIx8 " %02" PRIx8
               " %02" PRIx8 " %02" PRIx8 " %02" PRIx8 "\n",
            (unsigned)port,
            addr.addr_bytes[0], addr.addr_bytes[1],
            addr.addr_bytes[2], addr.addr_bytes[3],
            addr.addr_bytes[4], addr.addr_bytes[5]);

    //将网卡设置为混杂模式
    rte_eth_promiscuous_enable(port);

    return 0;
}

默认的队列初始化结构体如下,仅仅指定了最大包长度为以太网最大长度1518。

static const struct rte_eth_conf port_conf_default = {
    .rxmode = { .max_rx_pkt_len = ETHER_MAX_LEN }   //前面加.代表指定成员进行初始化
};

初始化网卡之后,主核直接运行业务逻辑lcore_main()

static __attribute__((noreturn)) void lcore_main(void)
{
    const uint8_t nb_ports = rte_eth_dev_count();
    uint8_t port;

    //检测网口和运行线程是不是属于同一NUMA节点,加快运行速度
    for (port = 0; port < nb_ports; port++)
        if (rte_eth_dev_socket_id(port) > 0 && //网卡所在的NUMA套接字
                rte_eth_dev_socket_id(port) !=
                        (int)rte_socket_id())   //逻辑线程所在CPU的id(CPU和NUMA是对应的)
            printf("WARNING, port %u is on remote NUMA node to "
                    "polling thread.\n\tPerformance will "
                    "not be optimal.\n", port);

    printf("\nCore %u forwarding packets. [Ctrl+C to quit]\n",
            rte_lcore_id());

    for (;;) {  //死循环
        //遍历网口
        for (port = 0; port < nb_ports; port++) {
            struct rte_mbuf *bufs[BURST_SIZE];  //一组mbuf集合,按照cache行,一次性最多收8个数据包(的mbuf地址)
            //收一组包,返回收到的包的个数,从网卡队列取包放到bufs数组中(传地址,零拷贝)
            const uint16_t nb_rx = rte_eth_rx_burst(port, 0,
                    bufs, BURST_SIZE);

            if (unlikely(nb_rx == 0))
                continue;

            //转发到相邻(port->port^1,即0->1,1->0,2->3,3->2)网口
            const uint16_t nb_tx = rte_eth_tx_burst(port ^ 1, 0,
                    bufs, nb_rx);

            //如果出现没有转发的数据包(我猜测是性能不够的原因),就要把没有转发的mbuf手动释放
            if (unlikely(nb_tx < nb_rx)) {
                uint16_t buf;
                for (buf = nb_tx; buf < nb_rx; buf++)
                    rte_pktmbuf_free(bufs[buf]);    //释放mbuf
            }
        }
    }
}

对于DPDK的收包和转发来说,都是一次处理多个数据包,原因是cache行的内存对齐可以一次处理多个地址,并且可以充分利用处理器内部的乱序执行和并行处理能力。

这就构成了最基本的DPDK收发包逻辑,不涉及任何硬件部分。

简要介绍L3fwd

这个样例是用来进行三层转发(即网络层转发,类似于路由器功能),数据包收入到系统中会查询IP报文头部,依据目标地址进行路由查找,发现目的网口,就修改IP头部,将报文从目的端口送出。路由查找有两种方式:1、基于目标IP地址的完全匹配;2、基于路由表的最长掩码匹配。

猜你喜欢

转载自blog.csdn.net/u012630961/article/details/80569317
今日推荐