DPDK主要功能:利用IA(intel architecture)多核处理器进行高性能数据包处理
Linux下传统的网络设备驱动包处理的动作可以概括如下:
频繁的中断会降低系统处理数据包的速度。
DPDK可以很好的在英特尔架构下执行高性能网络数据包处理,主要使用了以下技术:
UIO提供了用户态驱动开发的框架,主要是由于驱动依赖的内核函数和宏因内核版本变化,导致驱动可能也需要改。所以改为用户态驱动来完成任务。
dpdk中的驱动需要定期检查设备是否有中断产生,并不是用来收发数据。另外通过mmap来操作设备的设备内存。UIO框架本身要处理设备的中断,中断只能在内核态处理,uio的中断处理函数也只是增加中断的计数而已。
mmap可以处理物理内存映射,逻辑内存,内核虚拟内存映射。 UIO也是通过mmap将设备的内存映射到用户空间,当然用户态也可以通过/sys/class/uio/uioX/maps/mapX来实现对设备内存的访问,所以发送和接受数据是通过操作设备的内存来完成的。
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
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_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的所有程序都是这样的运行思路:
这是一个简单的单核收发包示例程序,对收入报文不做处理,直接进行转发,简单介绍一下代码:
主函数代码如下,可以看到主线程做的前期工作
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收发包逻辑,不涉及任何硬件部分。
这个样例是用来进行三层转发(即网络层转发,类似于路由器功能),数据包收入到系统中会查询IP报文头部,依据目标地址进行路由查找,发现目的网口,就修改IP头部,将报文从目的端口送出。路由查找有两种方式:1、基于目标IP地址的完全匹配;2、基于路由表的最长掩码匹配。