4. 字符设备与网络设备驱动框架
在 Linux 内核中,字符设备与网络设备虽然都属于设备驱动,但它们的抽象模型、注册方式和数据通路差异很大。字符设备以“文件”为中心,用户通过 /dev 下的设备节点调用 open/read/write/ioctl,驱动实现 file_operations 即可;网络设备则以“接口”为中心,用户看到的是 eth0、can0 这类网络接口,数据不是通过文件读写,而是通过协议栈的收发路径进出。理解这一区别,是掌握网络驱动框架的起点。
一、字符设备与网络设备的结构差异
字符设备驱动通常围绕 struct cdev 和 struct file_operations 展开。注册时向内核提供主次设备号,用户空间通过 mknod 或 udev 生成设备节点,随后 open 会绑定到驱动的 open 回调,read/write 直接进入驱动缓冲区。它的核心是“文件接口”。
网络设备驱动则围绕 struct net_device 展开。它不创建设备节点,而是向内核注册一个网络接口。用户通过 socket 编程使用协议栈,协议栈再调用驱动提供的发送函数;接收方向则由驱动在中断或轮询中把数据包交给协议栈。它的核心是“数据包接口”。
因此,网络驱动没有 read/write 文件操作,取而代之的是 ndo_start_xmit、ndo_open、ndo_stop、ndo_set_rx_mode 等 net_device_ops 回调。字符设备驱动关注“字节流”,网络设备驱动关注“帧/包”。
二、net_device 的注册与生命周期
网络设备驱动的第一个关键动作是分配并注册 net_device。现代内核推荐使用 alloc_etherdev 或 alloc_netdev 分配,前者是后者的以太网特化版本。分配后需要填充关键字段:netdev_ops、ethtool_ops、if_port、dev_addr、watchdog_timeo 等。随后调用 register_netdev 把接口挂入内核网络设备链表,成功后用户空间即可看到接口名。
注销时调用 unregister_netdev,它会等待引用计数归零并释放资源。注意:register_netdev 成功并不代表接口已经“打开”,接口的打开与关闭由 ndo_open 和 ndo_stop 控制。典型流程是:模块加载时分配并注册 net_device;用户执行 ip link set eth0 up 时触发 ndo_open;执行 ip link set eth0 down 时触发 ndo_stop;模块卸载时注销并释放。
三、open/close 与 ndo_start_xmit
ndo_open 是接口真正开始工作的入口。它通常完成:申请中断号、初始化收发描述符环、启动 DMA、使能 MAC 和 PHY、开启发送队列、启动 NAPI 或中断。若这些步骤失败,必须回滚已申请资源,否则会造成泄漏。ndo_stop 则相反:停止发送队列、关闭 NAPI、释放中断、复位 MAC、释放 DMA 缓冲区。
发送路径的核心是 ndo_start_xmit。协议栈把 sk_buff 交给驱动,驱动需要把数据映射为 DMA 可访问的地址,写入发送描述符,通知硬件发送。返回值必须是 NETDEV_TX_OK 或 NETDEV_TX_BUSY。若返回 NETDEV_TX_BUSY,协议栈会稍后重试;但现代驱动通常通过停止发送队列 netif_stop_queue 来避免忙等,待发送完成中断中再 netif_wake_queue 唤醒队列。
发送完成通常由中断通知。驱动在中断处理中回收已发送的 sk_buff,解除 DMA 映射,更新队列状态。若发送超时,看门狗 ndo_tx_timeout 会被调用,驱动应复位硬件并恢复队列。
四、中断处理与 NAPI 轮询机制
接收方向有两种典型模式:纯中断和 NAPI。纯中断模式下,每来一个包就触发一次中断,驱动在中断上下文中分配 sk_buff、拷贝数据、调用 netif_rx 或 napi_gro_receive 上交协议栈。高流量时中断会非常频繁,导致 CPU 被中断占满。
NAPI 的核心思想是“中断触发、轮询收包”。第一次中断到来时,驱动关闭该设备的中断源,调用 napi_schedule 把 NAPI 实例挂入软中断轮询队列;随后在软中断上下文中调用驱动的 poll 函数,一次轮询批量收取多个包。当轮询预算耗尽或没有更多包时,重新开启中断。这样在高负载下显著降低中断次数。
实现 NAPI 需要:在 net_device 中嵌入 struct napi_struct;在 ndo_open 中调用 netif_napi_add 注册 poll 函数和权重;在中断处理中调用 napi_schedule;在 poll 函数中使用 napi_gro_receive 上交数据包,并返回本次处理的包数;当返回小于预算时调用 napi_complete_done 并重新使能中断。
下面这张图把网络设备驱动的注册、打开、发送、接收与 NAPI 轮询关系放在同一张结构图中,便于对照理解各回调与数据流的位置。
五、一个可参考的 NAPI 实现骨架
下面给出一个简化的教学示例,展示 ndo_open、中断处理与 poll 函数之间的配合关系。示例中的寄存器地址、中断号和预算值均为假设值,仅用于说明结构。
#define RX_BUDGET 64
static int my_open(struct net_device *ndev)
{
struct my_priv *priv = netdev_priv(ndev);
int ret;
ret = request_irq(ndev->irq, my_interrupt, IRQF_SHARED,
ndev->name, ndev);
if (ret)
return ret;
napi_enable(&priv->napi);
my_hw_start(priv);
netif_start_queue(ndev);
return 0;
}
static irqreturn_t my_interrupt(int irq, void *dev_id)
{
struct net_device *ndev = dev_id;
struct my_priv *priv = netdev_priv(ndev);
/* 关闭本设备中断,避免重复触发 */
my_disable_irq(priv);
if (napi_schedule_prep(&priv->napi)) {
my_ack_irq(priv);
__napi_schedule(&priv->napi);
}
return IRQ_HANDLED;
}
static int my_poll(struct napi_struct *napi, int budget)
{
struct my_priv *priv = container_of(napi, struct my_priv, napi);
int work_done = 0;
while (work_done < budget) {
struct sk_buff *skb = my_rx_one(priv);
if (!skb)
break;
napi_gro_receive(napi, skb);
work_done++;
}
if (work_done < budget) {
napi_complete_done(napi, work_done);
my_enable_irq(priv);
}
return work_done;
}
这段代码的关键判断是:work_done < budget 表示本轮已经把当前可收的包收完,可以结束轮询并重新打开中断;若等于预算,说明还有包待收,应继续留在轮询状态,避免中断风暴。实际驱动中还要处理 DMA 一致性、缓存刷新、错误计数和 skb 分配失败等情况。
ethtool -S eth0 观察收发计数与错误计数;使用 cat /proc/interrupts 查看中断分布;使用 ip -s link show eth0 查看队列丢包。若中断数远高于收包数,说明 NAPI 未生效或预算设置过小;若发送队列长期停止,检查 ndo_start_xmit 是否返回 NETDEV_TX_BUSY 后未唤醒队列。
总结来看,网络设备驱动的骨架可以概括为:分配并注册 net_device,实现 ndo_open/ndo_stop 管理硬件生命周期,实现 ndo_start_xmit 完成发送,接收侧用中断触发 NAPI 轮询批量收包。字符设备以文件为入口,网络设备以接口和数据包为入口,两者在注册模型和数据通路上有本质区别。掌握这些回调的调用时机与数据流向,才能在航电计算平台的网络驱动开发中准确定位问题。