DPDK介绍
DPDK主要功能:利用IA(intel architecture)多核处理器进行高性能数据包处理
Linux下传统的网络设备驱动包处理的动作可以概括如下:
- 数据包到达网卡设备
- 网卡设备依据配置进行DMA操作
- 网卡发送中断,唤醒处理器
- 驱动软件填充读写缓冲区数据结构
- 数据报文到达内核协议栈,进行高层处理
- 如果最终应用在用户态,数据从内核搬移到用户态
- 如果最终应用在内核态,在内核继续进行
频繁的中断会降低系统处理数据包的速度。
DPDK可以很好的在英特尔架构下执行高性能网络数据包处理,主要使用了以下技术:
- 轮询
- 避免中断上下文切换的开销
- 用户态驱动
- 规避了不必要的内存拷贝,避免了系统调用
- 亲和性与独占
- DPDK工作在用户态,线程的调度依然依赖内核,利用线程的CPU亲和性绑定的方式,特定任务可以被指定只在某个核上工作
- 降低访存开销
- 如内存大页,内存多通道的交错访问,NUMA系统的使用
- 软件调优
- 内存对齐,数据预取,避免跨cache行共享
- 利用IA新硬件技术
- 充分挖掘网卡的潜能
DPDK框架简介
- 核心库Core Libs:提供系统抽象,打野内存,缓存池,定时器和无锁环等基础组件
- PMD库:提供全用户态的驱动,以便通过轮询和线程板顶得到极高的网络吞吐
- Classify库:支持精确匹配,最长匹配和通配符匹配,提供常用包处理的查表操作
- 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的核上。
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的所有程序都是这样的运行思路:
- 主核进行EAL层次的初始化,读取系统参数
- 主核读取其他必要的参数
- 主核依据读入的参数确定核数,网卡数,进行线程的启动,将对应的函数和参数传入其中
- 主核进行自己的工作(一般是定时打印程序状态)
- 所有程序结束(这里程序的结束一般是通过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、基于路由表的最长掩码匹配。