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

54 篇文章 16 订阅
49 篇文章 11 订阅

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的处理,比如同时在看电影,或者玩游戏,这个测试结果就不大理想了。
大致原因应该在应用层优化上,因为电脑在做其他耗时处理的时候,
应用层的接收线程不管设置多高的优先级,都会被切换出去,
造成这个接收线程暂时中断,而网络通讯必须实时保持接收处理状态。

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

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

 

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
Filter Drivers主要包括以下特性:   1) 一个Filter Drivers实例叫Filter Module。Filter Module附加在一个适配器的微端口驱动上, 来自相同或不同Filter Drivers的多个Filter Module都可以被堆叠在一个网络适配器上   2) 在Filter Module被安装到驱动栈时,之上的协议驱动和之下的微端口驱动都不需要提供额外的支持功能   3) 因为Filter Module不像中间层驱动(intermediate driver)那样提供一个虚拟的微口,也不与某个设备对象联结,所以在微端口适配器(miniport adapter)之上的Filter Module 功能相当于一个修过过版本的微端口适配器(miniport adapter)。(原文:Because filter drivers do not implement virtual miniports like an intermediate driver, filter drivers are not associated with a device object. A miniport adapter with overlying filter modules functions as a modified version of the miniport adapter.)   4) NDIS使用配置信息来到决定一个Filter Module附加到一个网络适配器栈上的顺序   5) 在不用拆除整驱动栈的情况下,NDIS可以动态的插入、删除Filter Module或进行重新配置   6) 当NDIS重起驱动栈的时候协议驱动可以获得在栈上的Filter Module列表   7) Filter Drivers可以过滤下层网络适配器上绝大部分的通信。Filter Module不联结某特定的绑定(Filter modules are not associated with any particular binding between overlying protocol drivers and the miniport adapter.)   8) Filter Drivers 可以选择为过滤服务也可以选择为分流的不过滤服务,选择为哪一种是可以动态配置的(Filter drivers can select the services that are filtered and can be bypassed for the services that are not filtered. The selection of the services that are bypassed and the services that are filtered can be reconfigured dynamically.)   9) NDIS 保证有效的上下文空间,也不就是说Filter Drivers不要需要通代码COPY缓冲区来获得上下文空间  
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值