NdisFilter驱动数据全部转发到应用层的性能之优化(使用共享环形队列方式)

by fanxiushu  2019-01-22 转载或引用请注明原始作者。
在上一篇文章中,
https://blog.csdn.net/fanxiushu/article/details/86516610 (windows7以上平台 NDISFilter 网卡过滤驱动开发)

阐述了NdisFilter驱动开发过程,并且结合我的实际应用,
把数据包全部转发到应用层来处理,从而实现应用层级别的 NAT程序和拦截过滤,统计各种数据包等功能。

在上篇文章中阐述了开发NdisFilter驱动的目的,是因为我自己使用的各类电脑手机等上网,
都是基于我自己开发的运行于windows平台的NAT程序,当然,使用的是电脑通过PPoE拨号,
至于为何不使用现成的硬件路由器设备拨号,算是比较另类吧(哈哈)。

简述一下我的ADSL拨号速度的发展历史,
先是512K, 这种蜗牛速度,在那个时期也是忍过来了。接着是4M速度,这个速度经历了几年,速度勉强还算凑合。
然后是50M的速度,也是维持了好几年,然后直到最近几个月,速度一下提升到了500M,终于感觉到现代化科技了。

也就是说,之前的ADSL拨号速度全是在百兆环境下,在100M环境下,我原先的NAT程序效率能跟得上。
正如上篇文章所说,采用IRP通讯方式,定义 四个IOCTL来传输数据,
IOCTL_READ_FROM_UP,   IOCTL_READ_FROM_DOWN,
IOCTL_WRITE_TO_UP,       IOCTL_WRITE_TO_DOWN,
并且在应用层使用完成端口来传输,再做些其他优化,基本上能达到原始网卡速度的90%以上甚至接近原始网卡传输速度。
但是在千兆网环境下,还是使用这种传输方式,速度没能达到很理想的效果。
500M的带宽,使用电信测速,当运行我的NAT程序之后,测试下来,只能达到300-400M,最高一次410M。
因此在开发完成NdisFilter驱动之后,最近这些天,就想着做些优化。
如果还利用IRP做IOCTL传输,我能想到的优化方法,基本都想到了,比如单线程使用完成端口投递多个读写请求,
线程提升到最高优先级等等。这次得换个 IO 方式,不使用 IRP 通讯。

其实早在2017年写的一篇关于虚拟网卡驱动开发的文章:
https://blog.csdn.net/fanxiushu/article/details/69415282 (windows虚拟网卡驱动开发)

在文中的末尾,提到优化的时候,就提到了使用共享内存,形成一个环形队列(Ring Queue),
驱动不停朝环形队列读写数据,应用层也不停的操作环形队列的数据。

以前开发的关于网络通讯的驱动,都没做这个事情,没有做这个改变的动力和必要。因为大部分都在百兆环境情况下,
或者程序本身通讯的数据量都不大, 使用IOCTL方式传输的数据包,损失的效率都不高,也就是基本都接近原始速度。
这次算是破斧成舟,把通讯方式全改了,虽然驱动和应用层通讯接口全都改变,但是应用层提供给原先的NAT程序的接口不变。
这样就跟上篇文章所说的,放弃WFP框架,采用NdisFilter是一样的,都是为了尽量减少程序的大修改。

在NdisFilter驱动里面,可以拦截上下两个方向的数据包的传输,如果再把数据包转发到应用层,就会分成四个方向:
1,从上层获取数据,转发到应用层。
2,从应用层获取数据包,转发到下层。
3,从下次获取数据包,转发到应用层。
4,从应用层获取数据包,转发到上层。
这也是上面提到的四个IOCTL的由来。当应用层准备绑定到某个网卡的时候,传输某个IOCTL到驱动,并包含相关参数。
定义如下数据结构:


#define MAX_FRAME_DATA_SIZE      (512*1024)   //每个数据块的最大大小,大多情况是 1514大小,考虑到巨型帧的情况

///环形队列
struct ring_queue_t
{
    LONG          front;  //头部,取数据的时候会修改
    LONG          rear;   //尾部,添加数据时候会修改
    LONG          reserved;
    LONG          buffer_size;
    char          buffer[sizeof(LONG)];
};

///
#define DIR_UP_INDEX       0
#define DIR_DOWN_INDEX     1
#define DIR_COUNT          2

struct attach_param_t
{
    LONG            if_index; // 需要绑定的网卡Index
   
    LONG            mem_size; // 需要创建的共享内存大小

    LONGLONG        detach_event;  // 驱动解除到某个网卡的绑定之后会设置此事件
   
    //tx 表示从驱动传输数据到应用层,包括从上层和下层传输
    struct {
        ULONGLONG        tx_handle;   // 作为传入参数是 EVENT,用来指示有数据传输
        ULONGLONG        tx_useraddr; // 作为传出参数是 user_addr,映射的应用层的内存地址
    }tx[DIR_COUNT];

    rx 表示从应用层传输数据到驱动,包括传输到上层或下层
    struct {
        ULONGLONG        rx_handle;   // 同上
        ULONGLONG        rx_useraddr; //  同上
    }rx[DIR_COUNT];
   
   
};

如上的结构,绑定到某块网卡的时候,向驱动传输 attach_param_t结构,
于是在我们的驱动里边,调用 ExAllocatePoolWithTag 分配 mem_size大小的 分页内存,
然后调用 IoAllocateMdl 创建MDL, 调用 MmProbeAndLockPages 锁定内存。
再调用 MmMapLockedPagesSpecifyCache 把内存映射到用户空间。
再调用 MmGetSystemAddressForMdlSafe 把这块内存映射到内核空间,这个就是驱动接下来需要操作的环形队列内存块。
并且调用我们自己实现的函数 rq_init 把这块映射到内核空间的内存初始化 为 ring_queue_t 数据结构。

类似如下伪代码:
      mem = ExAllocatePoolWithTag(PagedPool, mem_size, 'FXSD');
      mdl = IoAllocateMdl(mem, mem_size, FALSE, FALSE, NULL);
      MmProbeAndLockPages(mdl, KernelMode, IoModifyAccess);
      user_addr = MmMapLockedPagesSpecifyCache(mdl, UserMode, MmCached, NULL, FALSE, NormalPagePriority); //用户空间内存地址
     ring_queue_t* rq = (ring_queue_t*)MmGetSystemAddressForMdlSafe(mdl, NormalPagePriority);
     rq_init(rq); // 初始化ring queue,这个rq指针保存到驱动里,用于传输数据的时候,读写这个循环队列。

在这里,因为是 四个方向,所以得做四个这样的操作,创建四块这样的环形队列内存。
同时在驱动里,调用 ObReferenceObjectByHandle 把从应用层传递各种事件转换成 KEVENT对象,用于各种情况和有数据的通知事件。

绑定成功之后,就可以开始数据通信了。
这里以从上层传递数据包到底层网卡为例,至于另外一个方向,也是类似的处理。
当从NDIS协议驱动传输数据到我们的NDISFilter驱动时候, FilterSendNetBufferLists 就会被调用。
我们在这个回调函数里边,遍历 NET_BUFFER_LSIT,对每个NET_BUFFER_LIST遍历里边的每个 NET_BUFFER,
然后利用NdisGetDataBuffer函数获取NET_BUFFER里边的以太网数据包,
然后使用我们自己实现的环形队列的入队函数 rq_enqueue,把这个数据包复制到 tx_up_rq 环形队列里。
(tx表示从驱动传输到应用层,up表示从上层传输, rq是 ring queue缩写,event是事件,这么定义名称为了方便区分)
完成操作之后, 调用KeSetEvent函数通知 tx_up_event 有数据包写到环形队列里。

这里列出 rq_enqueue函数的实现,
并且这个队列不像其他环形队列那样,每个元素是固定长度。这里采用 4字节数据长度 + 数据包内容  的方式组织每个元素。
int rq_enqueue(ring_queue_t* rq, PCHAR* p_data, LONG data_len)
{
    if (data_len > MAX_FRAME_DATA_SIZE ) {
        DPT("*** max paket length > 512K.\n");
        return RQ_ERR_NORMAL;
    }

    填充规则 4字节长度+数据内容,最后4字节填充0,下一个接着从这个0的4字节开始 .
    LONG valid_len = sizeof(LONG) + data_len + sizeof(LONG);

    if (rq->rear + valid_len >= rq->buffer_size) { //到达尾部,重新开始
        ///
       
        if (rq->front == 0) { //先判断出队情况,这种情况说明队列满了
            DPT("*** rq full when reset rear=0.\n");
            return RQ_ERR_FULL;
        }

        rq->rear = 0; // 重新开始
       
        if ( valid_len >= rq->buffer_size) {
            return RQ_ERR_NORMAL;
        }
    }
   
    if (rq->rear < rq->front) { //rear 追赶front,追上的话说明队列满了
       
        if (rq->rear + valid_len > rq->front) {
            DPT("*** rq full rear=%d, front=%d, datalen=%d\n", rq->rear, rq->front, data_len );
            return RQ_ERR_FULL;
        }
       
    }
    else { // rq->front <= rq->rear, front追赶rear, 说明可以容纳

    }

    /
    *(PLONG)&rq->buffer[rq->rear] = data_len;
    *(PLONG)&rq->buffer[rq->rear + sizeof(LONG) + data_len] = 0; //尾部设置为0, 为了在取数据时候,判断是否到达队列尾部
    *p_data = &rq->buffer[rq->rear + sizeof(LONG)];

   
    return RQ_ERR_SUCCESS;
}
以上就是rq_enqueu的实现 ,至于出队函数 rq_dequeue可以自己去实现。

 驱动把数据包写到环形队列之后,我们再来看看应用层程序如何取这个数据包,
上面说了,利用MmMapLockedPagesSpecifyCache 把共享内存块(也就是环形队列)共享到应用层空间,
因此应用层程序同样获取到了 tx_up_rq 队列,
我们CreateThread 创建一个 线程,这个线程要求更高的优先级,然后调用WaitForSingleObject 在 tx_up_event上等待。
当有数据包的时候,Wait函数返回,然后调用 rq_dequeue 获取每个数据包,
然后就是程序数据包逻辑分析处理模块的分析过程,当把数据包分析处理好之后,就需要把数据包再次发给驱动,这次应该是发给下层。

同样的,应用层获取到对应的 rx_down_rq 环形队列, rx_down_event 事件 。
(rx是表示从应用层传递数据到驱动,down表示是驱动朝下层传递,rq 是环形队列,event是事件。)
这个时候调用 rq_enqueue 把数据包写到 rx_down_rq环形队列,然后SetEvent设置 rx_down_event事件。
这里还需要注意一个问题,就是写的时候,可能是不同线程写,因此我们必须加锁。
本来可以现成利用 CRITIACL_SECTION临界段来同步,但是为了考虑效率,还是需要在应用层实现自旋锁来同步。
其实这个自旋锁的实现也是挺简单的,如下就可实现一个基本功能的自旋锁。
struct spinlock_t
{
    volatile LONG locked;
};
void spin_lock_init(spinlock_t* lck) { lck->locked = 0; }
FORCEINLINE void spin_lock(spinlock_t* lck)
{
    while (InterlockedCompareExchange(&lck->locked, 1, 0) != 0 ) {
       
        YieldProcessor();
    }
}
FORCEINLINE void spin_unlock(spinlock_t* lck)
{
    InterlockedExchange(&lck->locked, 0);
}
这样我们在应用层实现write数据包的功能的如下伪代码:
    spin_lock(&spinlock);
    int r = rq_enqueue(rx_down_rq, &buf, packet_len);
    memcpy(buf, packet, packet_len); ///把数据包复制到环形队列
    rq_enqueue_complete(rx_down_rq, packet_len); //完成复制
    spin_unlock(&spinlock);
    SetEvent(&rx_down_event);

再次回到NdisFilter驱动。
同样的,在驱动调用 PsCreateSystemThread 创建监控现成,然后在此线程中 调用 KeWait。。。函数等待 rx_down_event事件,
当有数据包,Wait函数返回,然后调用rq_dequeue获取到数据包,
然后创建 NET_BUFFER_LIST,把从环形队列获取的数据包复制到对应的 NET_BUFFER中,
然后调用 NdisFSendBufferLists 继续朝下层传递这个数据包。
这样数据包从Ndis协议驱动到底层网卡传输,途经我们的Ndisfilter驱动的流程就完成,
同样朝上传输也是差不多的过程,这里也就不再赘述。

我们再来看看整个流程中,数据包被复制的次数:
1,第一次,是在 FilterSendNetBufferLists  回调函数中,把数据包从NET_BUFFER复制到tx_up_rq环形队列中。
2,第二次,是在应用层进行分析处理的时候,需要把数据包复制到别的地方,
                    有时可能还得修改甚至把数据包长度加长,比如转换成 PPoE协议,需要添加额外的PPoE头。
3,第三次,应用层分析完成,然后需要把数据包复制到 rx_down_rq 环形队列中。
4,第四次,在驱动内部,需要把 rx_down_rq环形队列的数据包再次复制到NET_BUFFER中去然后调用 NdisFSendBufferLists投递。

这么算下来,为了完成一次数据包的交换,至少需要复制 四次。这个复制次数是比较多的。

但是不管怎么说,这个方式都比使用IRP通讯高,因为对于 NdisFilter驱动来说,每个数据包的交换,
仅仅只是在驱动内部的内存中复制来复制去,而不再牵涉到 IRP通讯,
IRP通讯每次都要做应用层和内核层的切换操作,如果量少倒无所谓,但是量大了,就会消耗大量CPU,同时传输效率也上不去。

把整个驱动修改优化完成,采用全新的这种环形队列方式通讯,
然后再使用 电信测速来测试效果,结果效果比较理想。
在我的电脑不做其他处理,专门用来测速的时候, 500M的带宽,基本测试下来,
如果不运行我的驱动,测试在500-550M之间,运行我的驱动之后,测试下来,基本也是 500-530M,也就是损失非常小。
当然如果电脑还在做其他消耗CPU的处理,比如同时在看电影,或者玩游戏,这个测试结果就不大理想了。
大致原因应该在应用层优化上,因为电脑在做其他耗时处理的时候,
应用层的接收线程不管设置多高的优先级,都会被切换出去,
造成这个接收线程暂时中断,而网络通讯必须实时保持接收处理状态。

不管怎么说,这都是一个大进步,在千兆网环境下,利用环形队列的算法,
把网络数据包全部转发到应用层来处理,提供了一个更好的优化途径。

虽然在性能要求很高的网络环境中,把网络数据包转发到应用层来处理的做法,不管怎么说,都不是一个明智的做法。
但偏要在这个不明智的做法中寻求更高效的优化手段。

 

已标记关键词 清除标记
相关推荐
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页
实付 99.00元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值