4. 网络驱动接口(NDIS):devn-*驱动框架、驱动注册与初始化、数据包发送与接收(Tx/Rx Ring)、中断处理与NAPI

好,咱们进入NDIS这一章。说实话,QNX的网络驱动框架跟Linux的NAPI有些神似,但骨子里又完全是另一套东西。我最早接触NDIS时,被那个devn-*命名规则搞得有点懵,后来才发现,这其实是QNX设计哲学的一个缩影——一切皆资源,驱动就是资源管理器。

4.1 devn-*驱动框架:QNX的网络驱动长什么样?

在QNX里,网络驱动都是以devn-开头的共享库。比如你常见的devn-e1000.sodevn-rtl8169.so。它们不是独立的进程,而是被io-pktio-sock动态加载的模块。

我个人习惯把NDIS驱动理解成三层:

  • 底层:硬件寄存器操作、DMA描述符管理
  • 中间层:NDIS接口回调函数,比如ndis_attach()ndis_detach()
  • 上层:与协议栈的绑定,通过io-pkt的API注册网络接口

驱动加载时,io-pkt会调用驱动的ndis_attach()函数。这个函数要完成什么?说白了就是三件事:

  1. 检测硬件是否存在(PCI配置空间读取)
  2. 分配资源(中断、内存、I/O端口)
  3. 初始化Tx/Rx环形缓冲区

核心要点:NDIS驱动本质上是一个遵循特定接口约定的共享对象。你只要实现了ndis_entry这个导出函数,剩下的就是填充回调函数表。

4.2 驱动注册与初始化:从ndis_entry开始

每个NDIS驱动都必须导出一个ndis_entry函数。这是驱动的入口点,类似于Linux的module_init。我见过不少新手在这里栽跟头——函数签名写错一个参数,驱动加载直接失败。

// 典型的ndis_entry实现
int ndis_entry(ndis_entry_type_t type, void *arg) {
    switch (type) {
        case NDIS_ATTACH:
            return my_ndis_attach((ndis_adapter_t *)arg);
        case NDIS_DETACH:
            return my_ndis_detach((ndis_adapter_t *)arg);
        case NDIS_IOCTL:
            return my_ndis_ioctl((ndis_adapter_t *)arg, ...);
        default:
            return NDIS_FAIL;
    }
}

ndis_attach()里,你需要做几件关键的事:

  • PCI设备探测:读取Vendor ID、Device ID,匹配你的驱动表
  • BAR空间映射:把PCI基地址寄存器映射到虚拟地址空间
  • 中断注册:调用InterruptAttach()InterruptAttachEvent()
  • 环形缓冲区分配:为Tx和Rx各分配一组描述符

我的经验:初始化顺序很重要。我曾经在一个项目里先注册中断再初始化DMA,结果中断一上来就访问了未初始化的描述符,系统直接panic。正确的顺序应该是:硬件复位 → 分配内存 → 初始化描述符 → 最后使能中断。

4.3 数据包发送与接收:Tx/Rx Ring的运作机制

Tx/Rx Ring,说白了就是驱动和硬件之间的一圈共享内存。硬件通过DMA直接读写这些描述符,驱动则负责填充和回收。你想想看,如果没有这个环形缓冲区,每个包都要CPU亲自搬运,那性能得多差?

接收路径(Rx)

  1. 驱动预先分配好一组缓冲区,把物理地址填入Rx描述符
  2. 硬件收到数据包,通过DMA写入缓冲区,更新描述符状态
  3. 驱动在中断处理中扫描Rx Ring,发现有新包就取走
  4. 把数据包上交给协议栈,然后重新分配缓冲区还给硬件

发送路径(Tx)

  1. 协议栈把待发送的数据包交给驱动
  2. 驱动把数据包的物理地址填入Tx描述符
  3. 通知硬件开始发送(写发送控制寄存器)
  4. 硬件发送完成后,通过中断或轮询通知驱动回收描述符
// Rx描述符结构示例(简化版)
typedef struct {
    uint32_t addr_low;   // 缓冲区物理地址低32位
    uint32_t addr_high;  // 缓冲区物理地址高32位
    uint16_t length;     // 数据长度
    uint16_t status;     // 状态标志
} rx_desc_t;

注意:描述符的status字段必须使用内存屏障(mem_barrier())来保证CPU和硬件看到的顺序一致。我曾经调试过一个丢包问题,折腾了两天才发现是描述符状态更新后忘了加屏障,硬件读到了过期的数据。

4.4 中断处理与NAPI:别再让中断风暴搞垮你的系统

传统的中断处理方式很简单:每来一个包,CPU就跳进中断服务程序(ISR),把包从硬件搬到内存。但问题来了——如果网络流量很大,比如万兆网卡每秒几百万个包,CPU会被中断淹没,连干正事的时间都没有。这就是所谓的「中断风暴」。

QNX的NAPI(New API)借鉴了Linux的思路:

  • 中断模式:流量低时,每个包都触发中断,延迟低
  • 轮询模式:流量高时,关闭中断,驱动主动轮询硬件,减少中断开销

切换的阈值由驱动自己控制。我一般这样实现:

// NAPI轮询函数示例
int my_poll(ndis_adapter_t *adapter, int budget) {
    int work_done = 0;
    
    // 从Rx Ring中取包,最多取budget个
    while (work_done < budget) {
        if (rx_ring_empty(adapter)) break;
        
        // 处理一个接收包
        process_rx_packet(adapter);
        work_done++;
    }
    
    // 如果没处理完,继续轮询;否则切回中断模式
    if (work_done < budget) {
        // 重新使能中断
        enable_interrupts(adapter);
        return 0;  // 表示处理完毕
    }
    
    return work_done;  // 表示还有包没处理完
}

关键点budget参数决定了每次轮询最多处理多少个包。设得太小,轮询效率低;设得太大,其他任务可能被饿死。我一般设为64或128,具体要看你的CPU性能和网络带宽。

中断处理函数里,我习惯只做最轻量的事:

  • 读取中断状态寄存器,确认中断来源
  • 清除中断标志
  • 关闭中断(防止嵌套)
  • 触发NAPI轮询(通过ndis_schedule_poll()

剩下的数据搬运工作,全部交给轮询上下文去完成。这样ISR的执行时间可以控制在几微秒以内,不会影响系统的实时性。

避坑指南:我曾经在一个项目中,中断处理函数里调用了malloc()来分配缓冲区。结果在高负载下,malloc()触发了内存回收,导致ISR阻塞了几百微秒。嗯,从此以后我所有的ISR里只用预分配的内存池,绝不动态分配。

小结

NDIS驱动框架其实不复杂,核心就是理解devn-*的加载机制、Tx/Rx Ring的数据流动、以及中断与NAPI的配合。你只要把这三块吃透了,写一个千兆网卡的驱动,大概也就一两周的事。

下一章我们会深入调试工具,讲讲怎么用pci-toolio-pkt -d这些命令来排查驱动问题。到时候我会分享一个我亲手踩过的坑——因为一个描述符对齐问题,导致网卡在特定流量下疯狂丢包,查了整整三天才找到原因。