4. 网络驱动接口(NDIS):devn-*驱动框架、驱动注册与初始化、数据包发送与接收(Tx/Rx Ring)、中断处理与NAPI
好,咱们进入NDIS这一章。说实话,QNX的网络驱动框架跟Linux的NAPI有些神似,但骨子里又完全是另一套东西。我最早接触NDIS时,被那个devn-*命名规则搞得有点懵,后来才发现,这其实是QNX设计哲学的一个缩影——一切皆资源,驱动就是资源管理器。
4.1 devn-*驱动框架:QNX的网络驱动长什么样?
在QNX里,网络驱动都是以devn-开头的共享库。比如你常见的devn-e1000.so、devn-rtl8169.so。它们不是独立的进程,而是被io-pkt或io-sock动态加载的模块。
我个人习惯把NDIS驱动理解成三层:
- 底层:硬件寄存器操作、DMA描述符管理
- 中间层:NDIS接口回调函数,比如
ndis_attach()、ndis_detach() - 上层:与协议栈的绑定,通过
io-pkt的API注册网络接口
驱动加载时,io-pkt会调用驱动的ndis_attach()函数。这个函数要完成什么?说白了就是三件事:
- 检测硬件是否存在(PCI配置空间读取)
- 分配资源(中断、内存、I/O端口)
- 初始化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):
- 驱动预先分配好一组缓冲区,把物理地址填入Rx描述符
- 硬件收到数据包,通过DMA写入缓冲区,更新描述符状态
- 驱动在中断处理中扫描Rx Ring,发现有新包就取走
- 把数据包上交给协议栈,然后重新分配缓冲区还给硬件
发送路径(Tx):
- 协议栈把待发送的数据包交给驱动
- 驱动把数据包的物理地址填入Tx描述符
- 通知硬件开始发送(写发送控制寄存器)
- 硬件发送完成后,通过中断或轮询通知驱动回收描述符
// 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-tool、io-pkt -d这些命令来排查驱动问题。到时候我会分享一个我亲手踩过的坑——因为一个描述符对齐问题,导致网卡在特定流量下疯狂丢包,查了整整三天才找到原因。