一、为什么要用kni
通常情况下dpdk用于二三层报文转发,接收到来自网卡的报文后,如果是二层报文则查找fdb表; 如果是三层报文,则进行dnat, snat处理后,查找路由表, 将报文转发给下一跳路由。这些二三层转发操作都是直接转发到另一台设备上,不需要经过内核,无需内核协议栈的参与。dpdk将报文发往内核协议栈的过程,也叫作exception path
然而有些场景下报文是直接发给运行dpdk程序的这台设备本身的。例如ping运行dpdk程序这台设备;或者访问dpdk程序这台设备上运行的nginx服务器, ftp服务器,smtp邮件服务器等等。 这些操作都是发给运行dpdk程序本机这台设备, 因此报文是一定需要经过内核,由tcp协议栈进行处理。也就是说dpdk收到这些报文后,需要将报文转发给内核。例如ping操作,dpdk收到ping请求后,将报文发给内核协议栈,由内核协议栈处理完ping请求后,发送ping响应。dpdk收到ping响应后再转发给源主机。在如访问本机的nginx服务器, dpdk收到http请求后,转发给内核协议栈,内核协议栈收到http请求后,在将这个请求转发到应用层监听80端口的服务器进程。
那dpdk通过什么方式将报文转发给内核呢? 可以通过kni设备,也可以通过tun/tap虚拟网卡来实现。 相对于tun/tap实现, kni减少了内核态与应用层之间内存拷贝的操作,具有更高的转发性能。
二、kni的使用
kni分为应用层实现与驱动层实现。
1、驱动层使用
当编译好dpdk后,在dpdk安装目录下有一个kmod目录,里面会生成rte_kni.ko驱动。在kmod目录下执行insmod ./rte_kni.ko加载kni驱动。加载kni驱动时可以指定参数,例如指定多线程参数等,如果不指定参数,则默认是单线程模型。需要注意的是,如果dpdk编译完成后,在kmod目录下找不到rte_kni.ko驱动,那换更高的dpdk版本吧。我使用的dpdk1.8版本就没有生成这个驱动,换到最新版本就有了。
内核加载完这个kni驱动后,此时会在/dev目录下生产一个kni设备文件。
2、应用层使用
应用层examples目录下提供了一个kni的例子,编译好这个kni例子就可以执行了。如果编译kni报错,则有可能是kni相关的开关没有打开,也就不会参与编译。此时需要在dpdk安装目录下搜索所有的kni关键词, 将搜索到所有关于kni的开关打开就好了,重新编译整个dpdk。
编译好kni例子后,就可以执行./kni -c 0xf -- -p 0x1 -P --config="(0,0,1,2)"运行kni程序。
执行ifconfig -a就可以看到这个kni设备名,例如:vEth0_0。 后续dpdk与内核的交互,都是通过这个kni设备来进行。
当生成了kni设备后,后续就可以使用应用层工具ifconfig, ethtool, tcpdump对这个kni设备进行操作。例如
ifcofnig vEth0_0 up 开启kni设备
ifcofnig vEth0_0 down 关闭kni设备
ifcofnig vEth0_0 192.168.0.1 netmast 255.255.255.0 promisc 设置ip
ethtool -i vEth0_0 查看kni设备信息
tcpcudp -i vEth0_0 -nne -s0 -v 抓到这个kni设备的报文
三、kni实现原理
要使得dpdk能够利用kni设备将报文发给内核协议栈, kni需要实现应用层功能与驱动层功能。 驱动层需要创建一个/dev/kni混合设备,这个在应用层加载kni驱动的时候自动完成创建。 通过这个/dev/kni混合设备,可以接收应用层的ioctl消息,按需来创建各种kni设备、删除kni设备、打开kni设备、关闭kni设备、设置mtu、接收ethtool工具的命令操作消息等等。需要注意的是,驱动层创建的两种设备,一个是/dev/kni混合设备, 另一个是kni设备,这两个是不同的设备类型。
应用层则提供给调用者操作kni设备的接口。例如kni的初始化、按照需要为每个网卡分配一个或者多个kni设备、将来自网卡的报文通过kni设备发给内核、接收来自内核的报文后将报文通过网卡发送出去。另外也可以使用linux工具ifconfig、ethtool、tcpdump来操作kni设备。例如给kni设备配置ip地址,抓包等。
下面分别从应用层与驱动层,来看下kni设备的具体实现。
四、应用层kni的实现
对于每一个网卡,都可以创建一个或者多个kni设备。具体每一张网卡可以创建多少个kni设备,由应用层自行指定。创建完kni设备后,ifconfig -a命令执行后就可以看到这些虚拟网卡名,例如veth1_0, veth1_1。如果加载驱动的时候,指定了单线程模型,则kni驱动将只会创建一个线程,用于所有的kni设备接收来自应用层的报文。 如果加载kni驱动的时候,指定了多线程模型,则对于每个kni设备,kni驱动都会创建一个线程去接收来自应用层发来的报文。 kni设备与线程是一一对应的关系。
先以一张图来整体说明下kni设备应用层整体的结构。
应用层使用一个struct rte_kni_memzone_slot数组来存放所有的kni设备, 每个数组元素对应一个kni设备。 每个kni设备本身,都有一个独占的发送队列、接收队列、分配队列、释放队列、请求队列、响应队列。需要注意的是m_ctx成员指向的struct rte_kni结构本身,内部也有各种队列,但这些都是一个指针,指向刚才提到的些队列,是一种引用关系,而不会为它重复开辟这些队列空间。
当应用层从网卡收到报文后,将报文放到kni设备的rx接收队队列。kni驱动就会从这个rx接收队列中取出mbuf报文,将mbuf报文转为内核协议栈支持的sk_buff,调用netif_rx内核接口发给内核。将报文发给内核后,会将这些mbuf报文放到free释放队列,由应用层读取释放队列中待释放的mbuf进行释放操作。为什么要由应用层释放呢? 秉承谁开辟空间,那就谁释放的原则。另外也是为了使得驱动层代码最简洁化,驱动只实现最少的功能,将mbuf内存申请与释放的操作交给应用层来操作。驱动与应用层通过malloc, free队列来传递已经分配好或者待释放的报文。
当kni驱动收到来自内核的报文后,会调用kni_net_tx从malloc分配队列中获取一个应用层已经分配好的mbuf结构。同时将sk_buff报文转为mbuf报文,存放到mbuf中。之后将mbuf报文发到tx发送队列中。应用层从tx发送队列中获取报文后,将报文通过网卡发送出去。应用层也会重新开辟mbuf空间,放到malloc队列中,供后续kni驱动发包给应用层使用。这也可以体现刚才说的内容,驱动只实现最少的功能,将mbuf内存申请与释放的操作交给应用层来操作。驱动与应用层通过malloc, free队列来传递已经分配好或者待释放的报文。
那请求与发送队列是做什么的?当应用层调用ifconfig, ethtool等工具,设置kni设备的mtu, 使得kni设备up/down的时候。kni驱动收到这些设置操作,会构造一个请求报文,将这个请求报文放到req请求队列。应用层就会从这个req请求队列中获取一个请求,执行这个请求操作。例如设置mtu, 使得网卡up等。应用层会调用pmd用户态驱动实现的接口,来真正的对网卡设置mtu, 使得网卡up等操作。之后应用层会构造一个响应消息,将消息放到resp队列中,驱动从这个resp队列中获取响应消息就知道请求的执行结果了。
接下里进入代码分析环节,看下应用层代码的实现。
1、kni初始化
应用层调用rte_kni_init接口执行kni初始化操作。所谓的kni初始化,其实就是为所有的kni设备分配好空间,构成上图中提到的struct rte_kni_memzone_slot数组。 每个数组元素对应一个kni设备。并为每一个kni设备,分配好发送队列、接收队列、分配队列、释放队列、请求队列、响应队列。另外会将每个kni设备构成一个数组链表, 既能有数组快速遍历功能,也有链表快速插入删除操作的高效。
另外也会执行打开/dev/kni混合设备的操作,之所以要打开/dev/kni混合设备,是为了后续能通过ioctl操作这个混合设备,进而能创建kni设备。
void rte_kni_init(unsigned int max_kni_ifaces)
{
//打开/dev/kni设备
kni_fd = open("/dev/" KNI_DEVICE, O_RDWR);
//为每个kni设备开辟队列空间
for (i = 0; i < max_kni_ifaces; i++)
{
it = &kni_memzone_pool.slots[i];
//开辟kni结构
snprintf(mz_name, RTE_MEMZONE_NAMESIZE, "KNI_INFO_%d", i);
mz = kni_memzone_reserve(mz_name, sizeof(struct rte_kni), SOCKET_ID_ANY, 0);
it->m_ctx = mz;
//开辟发送队列空间
snprintf(obj_name, OBJNAMSIZ, "kni_tx_%d", i);
mz = kni_memzone_reserve(obj_name, KNI_FIFO_SIZE, SOCKET_ID_ANY, 0);
it->m_tx_q = mz;
//开辟接收队列空间
snprintf(obj_name, OBJNAMSIZ, "kni_rx_%d", i);
mz = kni_memzone_reserve(obj_name, KNI_FIFO_SIZE, SOCKET_ID_ANY, 0);
it->m_rx_q = mz;
/*开辟分配队列空间 ALLOC RING */
snprintf(obj_name, OBJNAMSIZ, "kni_alloc_%d", i);
mz = kni_memzone_reserve(obj_name, KNI_FIFO_SIZE, SOCKET_ID_ANY, 0);
it->m_alloc_q = mz;
/* 开辟释放队列空间FREE RING */
snprintf(obj_name, OBJNAMSIZ, "kni_free_%d", i);
mz = kni_memzone_reserve(obj_name, KNI_FIFO_SIZE, SOCKET_ID_ANY, 0);LL);
it->m_free_q = mz;
/* 开辟请求队列空间 */
snprintf(obj_name, OBJNAMSIZ, "kni_req_%d", i);
mz = kni_memzone_reserve(obj_name, KNI_FIFO_SIZE, SOCKET_ID_ANY, 0);
it->m_req_q = mz;
/* 开辟响应队列空间 */
snprintf(obj_name, OBJNAMSIZ, "kni_resp_%d", i);
mz = kni_memzone_reserve(obj_name, KNI_FIFO_SIZE, SOCKET_ID_ANY, 0);
it->m_resp_q = mz;
//构成一个数组链表
it->next = &kni_memzone_pool.slots[i+1];
}
}
2、应用层kni设备的创建
调用rte_kni_alloc接口将会创建一个kni设备。具体实现方式就是向/dev/kni混合设备发送ioctl消息,kni驱动收到ioctl消息后后,kni驱动负责创建kni设备。另外也会对发送队列,接收队列、分配队列、释放队列、请求队列、响应队列进行初始化操作。同时将将rte_kni结构中的各种队列与struct rte_kni_memzone_slot中的相应队列关联起来,也就是一种引用关系。
struct rte_kni * rte_kni_alloc(struct rte_mempool *pktmbuf_pool, const struct rte_kni_conf *conf, struct rte_kni_ops *ops)
{
//从所有空闲的kni设备曹中获取一个空闲曹
slot = kni_memzone_pool_alloc();
//得到struct rte_kni结构
ctx = slot->m_ctx->addr;
//将rte_kni结构中的发送、接收、分片、释放、请求、响应队列与struct rte_kni_memzone_slot中的
//发送、接收、分片、释放、请求、响应队列关联起来。是一种引用关系
mz = slot->m_tx_q;
ctx->tx_q = mz->addr;
//发送队列初始化
kni_fifo_init(ctx->tx_q, KNI_FIFO_COUNT_MAX);
//通过/dev/kni设备发送ioctl,用来创建kni设备
ret = ioctl(kni_fd, RTE_KNI_IOCTL_CREATE, &dev_info);
}
3、应用层发包到内核
应用层在收到来自网卡的报文后,通过调用rte_kni_tx_burst接口将报文发给内核。具体实现方式就是将报文放到kni设备所在的发送队列,kni驱动就会从这个队列中取出mbuf报文。kni驱动将这个mbuf报文转为内核支持的sk_buff,通过调用netif_rx内核函数发给内核。
将报文发给内核后,kni驱动会将这些已经发给内核的mbuf报文放到free释放队列,由应用层读取释放队列中待释放的mbuf进行释放操作
//发包给内核
unsigned rte_kni_tx_burst(struct rte_kni *kni, struct rte_mbuf **mbufs, unsigned num)
{
//将报文写入kni设备所在发送队列,内核从这个队列读数据
unsigned ret = kni_fifo_put(kni->rx_q, (void **)mbufs, num);
//释放内核已经接收的报文
kni_free_mbufs(kni);
return ret;
}
4、应用层接收内核的报文
当kni驱动收到来自内核的报文后,会调用kni_net_tx从malloc分配队列中获取一个应用层已经分配好的mbuf结构。同时将sk_buff报文转为mbuf报文,存放到mbuf中。之后kni驱动将mbuf报文放到tx发送队列中。
应用层从tx发送队列中获取报文后,将报文通过网卡发送出去。应用层也会重新开辟mbuf空间,放到malloc队列中,供后续kni驱动发包给应用层使用
//从内核收包
unsigned rte_kni_rx_burst(struct rte_kni *kni, struct rte_mbuf **mbufs, unsigned num)
{
//从队列获取来自内核的报文
unsigned ret = kni_fifo_get(kni->tx_q, (void **)mbufs, num);
//分配新的空间给驱动使用
kni_allocate_mbufs(kni);
return ret;
}
5、应用层对kni设备的配置操作
当应用层调用ifconfig, ethtool等工具,设置kni设备的mtu, 使得kni设备up/down的时候。kni驱动收到这些设置操作,会构造一个请求报文,将这个请求报文放到req请求队列。应用层就会从这个req请求队列中获取一个请求,执行这个请求操作。例如设置mtu, 使得网卡up等。应用层会调用pmd用户态驱动实现的接口,来真正的对网卡设置mtu, 使得网卡up等操作。之后应用层会构造一个响应消息,将消息放到resp队列中,驱动从这个resp队列中获取响应消息就知道请求的执行结果了。
驱动层操作的接口:
//打开kni设备
static int kni_net_open(struct net_device *dev)
{
struct rte_kni_request req;
//构造设置网卡up的请求内容
req.req_id = RTE_KNI_REQ_CFG_NETWORK_IF;
req.if_up = 1;
ret = kni_net_process_request(kni, &req);
}
static int kni_net_process_request(struct kni_dev *kni, struct rte_kni_request *req)
{
//请求消息放到队列
num = kni_fifo_put(kni->req_q, &kni->sync_va, 1);
//等待响应
ret_val = wait_event_interruptible_timeout(kni->wq, kni_fifo_count(kni->resp_q), 3 * HZ);
//获取响应
num = kni_fifo_get(kni->resp_q, (void **)&resp_va, 1);
}
应用层主动发起操作,kni驱动接收到消息后会构造请求放到请求队列,之后应用层读取队列的请求后调用pmd用户态驱动提供的接口。处理完后应用层构造响应消息,放入到响应队列。kni驱动读取响应队列中的响应消息就知道结果了。是不是感觉兜了一大圈的节奏。
应用层操作的接口:rte_kni_handle_request; 该函数用于处理请求队列中的请求,因此这个函数需要周期性的执行,以免请求得不到处理,超时导致配置失败。为了避免请求队列中的请求被延迟,也为了避免阻塞dpdk快路径处理,可以使用一个单独的线程来处理rte_kni_handle_request函数。
int rte_kni_handle_request(struct rte_kni *kni)
{
//应用层从队列中获取一个请求
ret = kni_fifo_get(kni->req_q, (void **)&req, 1);
//根据消息id, 开始处理请求
switch (req->req_id)
{
case RTE_KNI_REQ_CHANGE_MTU:
//设置网卡的mtu
req->result = kni->ops.change_mtu(kni->ops.port_id, req->new_mtu);
break;
case RTE_KNI_REQ_CFG_NETWORK_IF:
//设置网卡up/down
req->result = kni->ops.config_network_if(kni->ops.port_id, req->if_up);
break;
}
//处理完请求后,将请求的结果写入队列。kni驱动从这个队列获取结果
ret = kni_fifo_put(kni->resp_q, (void **)&req, 1);
}
应用层有两种方式为kni设备设置操作接口。一个是调用rte_kni_alloc创建kni设备时,会要求传递一个kni设备的操作对象ops, 这个对象中实现了操作kni设备的接口,例如改变mtu, 设置kni设备up/down等;另一种方式是调用rte_kni_register_handlers为kni设置注册一个操作接口。
struct rte_kni_ops ops;
ops.change_mtu = kni_application_change_mtu;
ops.config_network_if = kni_application_config_network_if;
ops.config_mac_address = kni_application_config_mac_address;
rte_kni_alloc(mbuf_pool, &conf, &ops);
6、应用层内存队列的分配与释放
驱动只实现最少的功能,将mbuf内存申请与释放的操作交给应用层来操作。驱动与应用层通过malloc, free队列来传递已经分配好或者待释放的报文。具体来说就是应用层开辟好报文空间后,将空间放到分配队列中给kni驱动使用,kni驱动从这个分配队列中获取报文空间。 kni驱动使用完这个报文空间后,会放到释放队列,应用层读取这个释放队列中待释放的报文进行释放操作。
static void kni_allocate_mbufs(struct rte_kni *kni)
{
//从内存池中mbuf空间
for (i = 0; i < MAX_MBUF_BURST_NUM; i++)
{
pkts[i] = rte_pktmbuf_alloc(kni->pktmbuf_pool);
}
//将mbuf放入队列,给kni驱动使用
ret = kni_fifo_put(kni->alloc_q, (void **)pkts, i);
}
static void kni_free_mbufs(struct rte_kni *kni)
{
int i, ret;
struct rte_mbuf *pkts[MAX_MBUF_BURST_NUM];
//从队列中获取报文进行释放操作
ret = kni_fifo_get(kni->free_q, (void **)pkts, MAX_MBUF_BURST_NUM);
for (i = 0; i < ret; i++)
{
rte_pktmbuf_free(pkts[i]);
}
}
到此为止,kni设备应用层部分已经分析完了,接下里分析驱动层kni的实现。
五、驱动层kni的实现
驱动层会创建一个/dev/kni混合设备,这个在应用层加载kni驱动的时候自动完成创建。 通过这个/dev/kni混合设备,可以接收应用层的ioctl消息,按需来创建各种kni设备、删除kni设备、打开kni设备、关闭kni设备、设置mtu、接收ethtool工具的命令操作消息等等。需要注意的是,驱动层创建的两种设备,一个是/dev/kni混合设备, 另一个是kni设备,这两个是不同的设备类型。
1、kni驱动初始化
kni驱动初始化的时候,会在/dev目录下创建一个/dev/kni混合设备, 后续由这个混合设备创建kni设备。另外kni驱动初始化的时候,还会初始化线程模型,根据加载驱动的参数来决定是启用单线程还是多线程。以此同时注册接收应用层报文的回调,正常情况下都使用kni_net_rx_normal这个接口来接收来自应用层的报文
static int __init kni_init(void)
{
//线程模型初始化
kni_parse_kthread_mode();
//注册一个/dev/kni设备
misc_register(&kni_misc);
//根据loopback模式,注册接收来自应用层报文的接收回调
kni_net_config_lo_mode(lo_mode);
return 0;
}
来看下可以对/dev/kni混合设备执行哪些操作。从中可以看出应用层可以对/dev/kni混合设备执行打开混合设备、关闭混合设备、对混合设备执行ioctl操作。
//dev/kni设备操作接口
static struct file_operations kni_fops =
{
.owner = THIS_MODULE,
.open = kni_open, //打开/dev/kni混合设备
.release = kni_release, //关闭/dev/kni混合设备
.unlocked_ioctl = (void *)kni_ioctl,
.compat_ioctl = (void *)kni_compat_ioctl,
};
//dev/kni混合设备
static struct miscdevice kni_misc =
{
.minor = MISC_DYNAMIC_MINOR,
.name = KNI_DEVICE,
.fops = &kni_fops, //混合设备操作接口
};
2、驱动中kni设备的创建
应用层对/dev/kni执行ioctl系统调用时,可以创建或者删除一个kni设备。创建完kni设备后,执行ifconfig -a就可以看到对应的kni设备,例如veth0_0, veth0_1等。
static int kni_ioctl(struct inode *inode, unsigned int ioctl_num, unsigned long ioctl_param)
{
switch (_IOC_NR(ioctl_num))
{
case _IOC_NR(RTE_KNI_IOCTL_CREATE):
//创建kni设备
ret = kni_ioctl_create(ioctl_num, ioctl_param);
break;
case _IOC_NR(RTE_KNI_IOCTL_RELEASE):
//销毁kni设备
ret = kni_ioctl_release(ioctl_num, ioctl_param);
break;
}
}
创建kni设备过程比较多。首先将应用层的ioctl设置信息拷贝到内核空间来,根据应用层提供的参数来进行设置。创建好kni设备后,根据应用层传进来的参数,例如将各种队列从应用层空间转换到内核空间来(指向的内存位置是同一个)。另外也会设置ethtool的操作接口,驱动层实现这个接口,使得应用层能够使用ethtool工具对kni设备进行操作。最后,如果驱动被加载时指定了多线程模型,则会为这个kni设备创建一个线程,用于驱动与应用层之间的交互。
static int kni_ioctl_create(unsigned int ioctl_num, unsigned long ioctl_param)
{
//从应用层拷贝数据到内核
ret = copy_from_user(&dev_info, (void *)ioctl_param, sizeof(dev_info));
//创建一个kni设备,内部会调用kni_net_init对net_dev初始化。kni_dev作为net_dev的私有结构
net_dev = alloc_netdev(sizeof(struct kni_dev), dev_info.name, kni_net_init);
kni = netdev_priv(net_dev);
//转换用户空间的队列,到内核空间
kni->tx_q = phys_to_virt(dev_info.tx_phys);
kni->rx_q = phys_to_virt(dev_info.rx_phys);
//对kni设备,设置针对ethtool工具的操作接口
kni_set_ethtool_ops(kni->net_dev);
//将创建的kni设备注册到内核。注册完成后执行ifconfig -a就可以看到kni设备
ret = register_netdev(net_dev);
//如果是多线程模型,则创建kni线程,用于处理与应用层的交互
if (multiple_kthread_on)
{
kni->pthread = kthread_create(kni_thread_multiple, (void *)kni, "kni_%s", kni->name);
}
//将kni设备插入到链表
list_add(&kni->list, &kni_list_head);
}
来看下应用层可以对kni设备执行什么设置操作。应用层可以调用ifconfig工具,例如ifconfig veth0_0 up打开kni设备,调用
ifconfig veth0_0 down关闭kni设备。
//kni设备的操作接口
static const struct net_device_ops kni_net_netdev_ops =
{
.ndo_open = kni_net_open, //打开kni设备
.ndo_stop = kni_net_release, //关闭kni设备
.ndo_set_config = kni_net_config,
.ndo_start_xmit = kni_net_tx, //内核发包给应用层
.ndo_change_mtu = kni_net_change_mtu,
.ndo_do_ioctl = kni_net_ioctl,
.ndo_get_stats = kni_net_stats,
.ndo_tx_timeout = kni_net_tx_timeout,
.ndo_set_mac_address = kni_net_set_mac,
};
//初始化kni设备
void kni_net_init(struct net_device *dev)
{
//注册kni设备的操作接口
ether_setup(dev); /* assign some of the fields */
dev->netdev_ops = &kni_net_netdev_ops;
}
除此之外还kni设备还提供了对ethtool工具的操作接口。需要注意的是,为了使得kni设备能够支持ethtool工具,需要使用linux内核提供的标准ixgbe/igb驱动。
//ethtool工具操作接口
struct ethtool_ops kni_ethtool_ops =
{
.begin = kni_check_if_running,
.get_drvinfo = kni_get_drvinfo,
.get_settings = kni_get_settings,
.set_settings = kni_set_settings,
.get_regs_len = kni_get_regs_len,
.get_regs = kni_get_regs,
.....................................
};
//ethtool工具操作接口
void kni_set_ethtool_ops(struct net_device *netdev)
{
netdev->ethtool_ops = &kni_ethtool_ops;
}
3、多线程模式下接收应用层报文
多线程模式下,每一个kni设备都有一个与之一一对于的线程,用于从接收队列中接收来自应用层的报文。同时也会从响应队列接收来自应用层处理完成后的命令响应。多线程入口为:kni_thread_multiple
//多线程模式下,线程入口
static int kni_thread_multiple(void *param)
{
while (!kthread_should_stop())
{
//接收来自应用层的报文
kni_net_rx(dev);
//接收来自应用层的响应消息
kni_net_poll_resp(dev);
}
}
正常情况下接收应用层报文的接口为kni_net_rx_normal。首先会从rx接收队列中获取应用层传进来的报文,然后将报文转为内核协议栈支持的sk_buff节后,最后调用netif_rx内核接口将sk_buff发往内核。 对于已经发往内核的报文,将mbuf放回到释放队列,由应用层统一进行释放,保证驱动代码的简洁,使得驱动只做最小的事情。
应用层dpdk使用的是物理地址,kni驱动使用的是虚拟地址,因此kni驱动中需要将物理地址转为虚拟地址。需要注意的是,当fifo满的时候,后面收到的报文会被丢弃。
//接收应用层报文处理, 将mbuf转为sk_buff后,将报文发往内核
static void kni_net_rx_normal(struct kni_dev *kni)
{
//从应用层传进来的队列中获取报文
ret = kni_fifo_get(kni->rx_q, (void **)va, num);
//将来自应用层的报文转成sk_buff结构,然后发给内核协议栈
for (i = 0; i < num; i++)
{
kva = (void *)va[i] - kni->mbuf_va + kni->mbuf_kva;
len = kva->data_len;
data_kva = kva->buf_addr + kva->data_off - kni->mbuf_va + kni->mbuf_kva;
//开辟sk_bff空间
skb = dev_alloc_skb(len + 2);
//从mbuf拷贝报文到sk_buff
memcpy(skb_put(skb, len), data_kva, len);
skb->dev = dev;
skb->protocol = eth_type_trans(skb, dev);
skb->ip_summed = CHECKSUM_UNNECESSARY;
//交给内核,发送到协议栈
netif_rx(skb);
}
//已经处理完成的报文,放到释放队列,由应用层进行释放
ret = kni_fifo_put(kni->free_q, (void **)va, num);
}
4、单线程模式下接收应用层报文
单线程模式下,整个kni驱动将只会有一个线程,用于接收所有kni设备来自应用层的报文。在应用层执行open操作,打开/dev/kni混合设备的时候将会创建单线程。单线程入口为kni_thread_single
//打开/dev/kni混合设备
static int kni_open(struct inode *inode, struct file *file)
{
//创建单线程
kni_kthread = kthread_run(kni_thread_single, NULL, "kni_single");
}
单线程模式下只有一个线程,轮询所有的kni设备,接收这个kni设备来自应用层的报文。
//单线程模式下,线程入口
static int kni_thread_single(void *unused)
{
int j;
struct kni_dev *dev, *n;
while (!kthread_should_stop())
{
//遍历所有的kni设备
list_for_each_entry_safe(dev, n, &kni_list_head, list)
{
//接收kni设备来自应用层的报文
kni_net_rx(dev);
//接收kni设备来自应用层的响应消息
kni_net_poll_resp(dev);
}
}
}
kni内核线程不是死循环,而是一次性处理完一定量的的报文后,会主动睡眠让出cpu,默认睡眠5微妙。这个睡眠是可以中断的,如果收到中断,还是会立即返回,返回值就是还剩余睡眠的时间,此时可以继续进行接收报文。为了避免死循环占用过多的cpu,使用了睡眠方式,因此延时吞吐都不会太理想。
当有多个kni设备时,建议使用多线程模式,每个kni设备对应一个线程,提升单kni设备的性能,也可以为kni线程指定cpu亲和性。好的优化方式是使用eventfd机制,用户态发包后唤醒kni内核线程处理,性能才会更高,这和epoll是一样的道理,有数据就立即返回,没有数据就睡眠等待超时返回。另一种优化方式是,dpdk与kni设备使用多个fifo, 每个fifo启用一个内核线程,利用cpu亲和性,将这些kni内核线程和cpu绑定,充分利用多核的特性来提升性能。
5、kni驱动发包给应用层
当kni驱动收到来自内核的报文后,会调用kni_net_tx从malloc分配队列中获取一个应用层已经分配好的mbuf结构。同时将sk_buff报文转为mbuf报文,存放到mbuf中。之后将mbuf报文放到tx发送队列中。应用层从tx发送队列中获取报文后,将报文通过网卡发送出去。
static int kni_net_tx(struct sk_buff *skb, struct net_device *dev)
{
//从应用层获取一个mbuf空间,将sk_buff的内容填充到这个mbuf中。然后发给应用层
ret = kni_fifo_get(kni->alloc_q, (void **)&pkt_va, 1);
pkt_kva = (void *)pkt_va - kni->mbuf_va + kni->mbuf_kva;
data_kva = pkt_kva->buf_addr + pkt_kva->data_off - kni->mbuf_va + kni->mbuf_kva;
//将sk_buff填充到mbuf中
len = skb->len;
memcpy(data_kva, skb->data, len);
pkt_kva->pkt_len = len;
pkt_kva->data_len = len;
//将报文放到发送队列,由应用层读取
ret = kni_fifo_put(kni->tx_q, (void **)&pkt_va, 1);
}
static void queue_process(struct work_struct *work)
{
while ((skb = skb_dequeue(&npinfo->txq)))
{
const struct net_device_ops *ops = dev->netdev_ops;
//kni_net_tx
ops->ndo_start_xmit(skb, dev);
}
}
六、综合案例
以一个邮件服务器案例来说明使用dpdk以及kni之间需要注意的地方。邮件服务器分为3个部分。
1、模块组成
首先是nignx服务器,这是一个http服务器,是邮件服务器的控制页面,用于对邮件服务器进行设置操作。例如设置邮件服务器监听的端口,设置邮件服务器支持的协议类型等。
接着是邮件服务器本身,用于邮件协议的处理,处理邮件的收发。
最后是dpdk程序,负责将邮件消息,以及邮件服务器的控制消息转发给邮件服务器。
2、实现过程
dpdk与邮件服务处于两个不同的进程,双方之间使用ring无锁队列进行通信,也就是通过共享内存的方式通信。当网卡收到报文后,被dpdk托管,dpdk发现是邮件协议的报文,进而将报文写入到队列中,发给邮件服务器。邮件服务器从队列中接收报文后,对报文进行处理。
同样,dpdk收到网卡的报文,发现报文不是邮件协议,而是一些控制报文,发现目的ip是本机自己。则将报文通过kni设备转发给内核。内核协议栈收包后,内核将报文发给应用层的nginx服务器。nginx服务器接收消息后,通过ipc进程通信的方式,发给邮件服务器,对邮件服务器进行配置操作。例如禁用smtp功能。
3、dpdk注意项
(1)、当报文是到达本机的,例如ping等。则dpdk将报文发给kni设备,进入内核协议栈处理
(2)、如果报文不是发给本机的, 接收到来自网卡的报文后,dpdk判断如果是二层报文则查找fdb表; 如果是三层报文,则进行dnat, snat处理后,查找路由表, 将报文转发给下一跳路由。 当然,二三层转发,路由,snat, dnat都需要应用层自己实现。
(3)、通常网卡被dpdk拖管后,ifconfig是看不到网卡信息了的,也就无法通过tcpdump进行抓包。怎么做呢?使用kni设备,或者tun设备,就可以给虚拟网卡设置一个ip地址, 自然也就可以通过tcpdcump抓包。但此时仅能够抓经过内核协议栈的报文,也就是经过本机的报文,无法抓转发给下一跳的报文。怎么做呢? 这就需要代码来实现了,其实也不会复杂。在抓转发报文的时候,将转发到下一跳的报文顺便发一份给内核就好了。在使用tcpdump抓包的时候,tcpdump内部使用libcap库就能从内核抓到所有的报文。
(4)、需要设置kni设备的carrier为true,内核收到dpdk的报文后才会回包。有三种方式可以实现这个目的。手动将/sys/devices/virtual/net/vEth0/carrier文件的内容设置为1;或者插入kni驱动时设置好carrier,例如insmod rte_kni.ko carrier=on; 最后一种方式是调用rte_kni_update_link这个函数。
七、tun虚拟网卡拓展
除了kni外,还有一种方式,也可以将报文发给内核协议栈,那就是tun虚拟网卡方式。这其实和kni操作是差不多的。dpdk提供了exception_path例子来介绍tun的使用。首先应用层打开/dev/net/tun设备,然后通过往这个/dev/net/tun设备发送ioctl消息, 内核接收到ioctl消息后创建虚拟网卡。这和kni设备的创建是不是很相似,kni设备是通过往/dev/kni混合设备发ioctl消息来创建kni设备的。在创建完虚拟网卡后,也可以和kni设备执行类似的操作。例如ifconfig配置tun虚拟网卡ip, ethtool设置虚拟网卡信息,tcpdump抓包等。
kni作为用户态和内核的接口,没有系统调用和内存拷贝,dpdk通过大页内存实现的fifo,从而实现零拷贝,因此比传统的tun/tap设备性能更好,而tun需要通过write系统调用,同时从应用层拷贝要发送的数据到内核。
kni混合设备只实现了open和ioctl接口,没有实现read/write接口,因此不能像tun/tap设备一样使用读写文件的方式进行数据收发。
要使用tun虚拟网卡功能,大体上就下面三个调用操作就行了。
//读写方式打开tun设备
int tap_fd = open("/dev/net/tun", O_RDWR);
ifr.ifr_flags = IFF_TAP | IFF_NO_PI;
snprintf(ifr.ifr_name, IFNAMSIZ, "%s", name);
ret = ioctl(tap_fd, TUNSETIFF, (void *) &ifr);
//通过tun,将报文发往内核
ret = write(tap_fd, rte_pktmbuf_mtod(m, void*), rte_pktmbuf_data_len(m));
//通过tun接口,从内核接收报文
ret = read(tap_fd, rte_pktmbuf_mtod(m, void *), MAX_PACKET_SZ);
到此kni设备的实现已经分析完成了。